@ossy/media-tasks 3.18.0 → 3.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -3
- package/package.json +16 -6
- package/src/Definition.js +7 -0
- package/src/convert-common-audio.action.js +5 -0
- package/src/convert-common-audio.js +243 -0
- package/src/convert-common-audio.schema.js +41 -0
- package/src/convert-common-audio.spec.js +188 -0
- package/src/convert-common-audio.task.js +142 -0
- package/src/convert-common-image.action.js +5 -0
- package/src/convert-common-image.js +143 -0
- package/src/convert-common-image.schema.js +46 -0
- package/src/convert-common-image.spec.js +101 -0
- package/src/convert-common-image.task.js +155 -0
- package/src/embed-resource.action.js +5 -0
- package/src/embed-resource.js +111 -0
- package/src/embed-resource.schema.js +46 -0
- package/src/embed-resource.spec.js +179 -0
- package/src/embed-resource.task.js +199 -0
- package/src/en.translations.json +4 -0
- package/src/extract-colors.action.js +5 -0
- package/src/extract-colors.task.js +13 -7
- package/src/openrouter.integration.js +2 -1
- package/src/resize-common-web.action.js +5 -0
- package/src/resize-common-web.task.js +13 -7
- package/src/run-prompt.task.js +6 -0
- package/src/sv.translations.json +4 -0
- package/src/text-to-voice.action.js +5 -0
- package/src/text-to-voice.js +160 -0
- package/src/text-to-voice.schema.js +51 -0
- package/src/text-to-voice.spec.js +102 -0
- package/src/text-to-voice.task.js +191 -0
- package/src/visual-content-descriptors.action.js +5 -0
- package/src/visual-content-descriptors.task.js +8 -1
package/README.md
CHANGED
|
@@ -10,10 +10,13 @@ Tasks in this package are registered with `TaskService.RegisterTask()` at API st
|
|
|
10
10
|
|---------|------------------------------|-------------|
|
|
11
11
|
| `@ossy/media-tasks/tasks/resize-common-web` | file `Created` + `image/*` | Common web sizes + BlurHash → storage keys under the source id |
|
|
12
12
|
| `@ossy/media-tasks/tasks/extract-colors` | file `Created` + `image/*` | Dominant color + palette JSON → storage |
|
|
13
|
+
| `@ossy/media-tasks/tasks/convert-common-image` | file `Created` + `image/*` (+ primary action) | First-slice format convert via **sharp**: auto-run for HEIC/HEIF/TIFF/BMP/AVIF **and** jpeg/png/webp → configurable `jpeg`/`png`/`webp` (default jpeg; skips when already the target). Manual Storage → Tasks invoke accepts any image. Inputs `format`, `quality`. |
|
|
13
14
|
| `@ossy/media-tasks/tasks/visual-content-descriptors` | file `Created` + `image/*` / `video/*` | OpenRouter vision → title/description/tags/alt JSON → storage. Input `model` (config or payload); default `openai/gpt-4o-mini`, overridable per trigger. |
|
|
14
15
|
| `@ossy/media-tasks/tasks/run-prompt` | file `Created` + `text/*` / `image/*` / `application/json`, or `@ossy/resources/schema/markdown` `Created` | OpenRouter chat completion. Inputs `prompt` (required), `model` (default `openai/gpt-4o-mini`), optional `temperature`. File bytes or the Markdown document `body` are the context. |
|
|
16
|
+
| `@ossy/media-tasks/tasks/convert-common-audio` | file `Created` + `audio/*`, or `on_action` | General audio format converter via ffmpeg: MP3 (default), AAC, OGG, WAV, AIFF, FLAC. Inputs `format`, `quality`. Skips uploads already in the target container MIME. |
|
|
17
|
+
| `@ossy/media-tasks/tasks/text-to-voice` | `on_action` + Markdown `Created` + file `Created` + `text/*` | OpenRouter TTS → MP3. Inputs `text` (optional when a resource supplies body/bytes), `voice` (default `en_paul_neutral`), `model` (OpenRouter slug; default `mistralai/voxtral-mini-tts-2603`). |
|
|
15
18
|
|
|
16
|
-
All
|
|
19
|
+
All seven set **`configurable: true`**. Workspaces arm them on `/automation` with their own `triggers[]` (within the ceiling above) and optional placement folders. Until armed, they do not run.
|
|
17
20
|
|
|
18
21
|
## Task outputs
|
|
19
22
|
|
|
@@ -24,8 +27,11 @@ All four set **`configurable: true`**. Workspaces arm them on `/automation` with
|
|
|
24
27
|
|------|--------|---------------|
|
|
25
28
|
| extract-colors | `palette` | `@ossy/media-tasks/schema/extract-colors` |
|
|
26
29
|
| resize-common-web | `sizes` (+ binary keys) | `@ossy/media-tasks/schema/resize-common-web` (JSON meta placed; binaries via URL) |
|
|
30
|
+
| convert-common-image | `converted` (+ `image` binary) | `@ossy/media-tasks/schema/convert-common-image` (JSON meta placed; bytes at `…-image`) |
|
|
27
31
|
| visual-content-descriptors | `descriptors` | `@ossy/media-tasks/schema/visual-content-descriptors` |
|
|
28
32
|
| run-prompt | `completion` | `@ossy/platform/schema/text-completion` (freeform text/md — a platform schema, not feature-owned) |
|
|
33
|
+
| convert-common-audio | `converted` (+ `audio` binary) | `@ossy/media-tasks/schema/convert-common-audio` (JSON meta placed; bytes at `…-audio`) |
|
|
34
|
+
| text-to-voice | `speech` (+ `audio` MP3) | `@ossy/media-tasks/schema/text-to-voice` (JSON meta placed; bytes at `…-audio`) |
|
|
29
35
|
|
|
30
36
|
See [TaskOutput.md](../../docs/concepts/TaskOutput.md).
|
|
31
37
|
|
|
@@ -55,12 +61,16 @@ export async function run ({ event, sdk, inputs }) { ... }
|
|
|
55
61
|
|
|
56
62
|
| Id | Credentials | Used by |
|
|
57
63
|
|----|-------------|---------|
|
|
58
|
-
| `openrouter` | `OPENROUTER_API_KEY` (required); optional `OPENROUTER_SITE_URL`, `OPENROUTER_APP_TITLE`, `OPENROUTER_BASE_URL` | `visual-content-descriptors` and `
|
|
64
|
+
| `openrouter` | `OPENROUTER_API_KEY` (required); optional `OPENROUTER_SITE_URL`, `OPENROUTER_APP_TITLE`, `OPENROUTER_BASE_URL` | `visual-content-descriptors`, `run-prompt`, and `text-to-voice` via `integrations.get('openrouter')` |
|
|
59
65
|
|
|
60
66
|
OpenRouter is OpenAI-compatible (`baseURL` `https://openrouter.ai/api/v1`). The VCD `model` input defaults to `openai/gpt-4o-mini`. A workspace can set another slug as the instance default, and a different slug on a single trigger (folder or MIME). An invoke payload still wins.
|
|
61
67
|
|
|
62
68
|
`run-prompt` sends the resolved prompt as the user message. A matching file is appended as context (text/JSON excerpt, or an image data URL). A Markdown document (`@ossy/resources/schema/markdown`) sends `content.body` instead. The completion is stored at `{resourceId}:task-run-prompt-completion` and placed only when the workspace set a folder. **Run now** invokes `@ossy/media-tasks/actions/run-prompt` with the editor values; without a resource id the completion is returned on the task run and not stored.
|
|
63
69
|
|
|
70
|
+
**Provider decision (#898):** TTS uses the shared `openrouter` integration (OpenRouter `/audio/speech`, OpenAI-compatible). Default model `mistralai/voxtral-mini-tts-2603` (in OpenRouter's speech catalog); legacy `tts-1` / `tts-1-hd` aliases map onto that slug. Writes an MP3 artifact. Character usage is **not** counted against the ADR 0017 LLM token cap.
|
|
71
|
+
|
|
72
|
+
`text-to-voice` resolves spoken text from (1) the `text` input, else (2) Markdown `content.body`, else (3) text-file bytes. Primary action `@ossy/media-tasks/actions/text-to-voice` supports manual Storage → Tasks invokes (epic #889 / #934).
|
|
73
|
+
|
|
64
74
|
## Usage metering and a monthly token cap (ADR 0017)
|
|
65
75
|
|
|
66
76
|
Both OpenRouter tasks read `response.usage` and return it as `result.usage = { model, promptTokens, completionTokens, totalTokens }`. `TaskRunService` forwards this onto `MeteringService.record()` automatically (any task returning `result.usage` in this shape gets metered — no per-task wiring needed).
|
|
@@ -71,5 +81,21 @@ Before calling OpenRouter, each task calls `assertLlmUsageBudget()` (`@ossy/plat
|
|
|
71
81
|
|
|
72
82
|
- **`sharp`** (peer) — image resizing + metadata
|
|
73
83
|
- **`blurhash`** — encode compact placeholders after resize
|
|
74
|
-
- **`openai`** (peer) — OpenAI SDK client pointed at OpenRouter
|
|
84
|
+
- **`openai`** (peer) — OpenAI SDK client pointed at OpenRouter (chat + TTS)
|
|
85
|
+
- **`ffmpeg-static`** (optional peer; also a media-tasks **devDependency** so CI/unit tests resolve a binary) — portable ffmpeg for `convert-common-audio`; otherwise `FFMPEG_PATH` or system `ffmpeg` on PATH
|
|
75
86
|
- **`@ossy/platform`** — `StorageClient` for reading/writing file bytes
|
|
87
|
+
|
|
88
|
+
## Audio output formats (`convert-common-audio`)
|
|
89
|
+
|
|
90
|
+
`convert-common-audio` converts between these container formats via ffmpeg. Inputs: `format` (default `mp3`) and `quality` 1–100 for lossy codecs (ignored for wav/aiff/flac). Auto-run on `audio/*` Created skips uploads already in the target container MIME; Storage → Tasks (`on_action`) always offers the format/quality dialog.
|
|
91
|
+
|
|
92
|
+
| Format | Codec | Content-Type | Quality mapping (input 1–100) |
|
|
93
|
+
|--------|-------|--------------|-------------------------------|
|
|
94
|
+
| `mp3` (default) | `libmp3lame` | `audio/mpeg` | VBR `q:a` 0–9 — `Math.round((1 - quality/100) * 9)` (100 → 0 best) |
|
|
95
|
+
| `aac` | `aac` | `audio/aac` | Bitrate 64k–320k — `64 + (quality/100) * 256` |
|
|
96
|
+
| `ogg` | `libvorbis` | `audio/ogg` | VBR `q:a` 1–10 — `max(1, round(quality/10))` |
|
|
97
|
+
| `wav` | `pcm_s16le` | `audio/wav` | Lossless PCM — quality ignored |
|
|
98
|
+
| `aiff` | `pcm_s16be` | `audio/aiff` | Lossless PCM — quality ignored |
|
|
99
|
+
| `flac` | `flac` | `audio/flac` | Lossless — fixed `compression_level` 5 |
|
|
100
|
+
|
|
101
|
+
JSON meta artifact `converted` stores `format`, `contentType`, `sourceContentType`, `quality`, `size`, `audioKey`; binary bytes live at `…-audio`. Storage → Tasks shows an inline player plus `FORMAT · quality N`. Binary Content-Type uses magic-byte sniffing (WAV/AIFF/OGG/FLAC/MP3/AAC). DJ-specific preprocessing (BPM/key, Camelot tags, EBU R128 for rekordbox/CDJ USB) is tracked separately in [#951](https://github.com/ossy-se/ossy/issues/951) (`analyze-dj-audio`, not shipped yet).
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/media-tasks",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "Changestream-triggered media processing tasks (image resize, AI descriptors, prompts)",
|
|
3
|
+
"version": "3.19.0",
|
|
4
|
+
"description": "Changestream-triggered media processing tasks (image resize, audio convert, AI descriptors, prompts)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"module": "./src/index.js",
|
|
8
8
|
"exports": {
|
|
9
|
-
".": "./src/index.js"
|
|
9
|
+
".": "./src/index.js",
|
|
10
|
+
"./embed-resource": "./src/embed-resource.js"
|
|
10
11
|
},
|
|
11
12
|
"ossy": {
|
|
12
13
|
"src": "./src"
|
|
@@ -17,15 +18,24 @@
|
|
|
17
18
|
},
|
|
18
19
|
"dependencies": {
|
|
19
20
|
"@ossy/observability": "^3.0.9",
|
|
20
|
-
"@ossy/platform": "^3.
|
|
21
|
-
"@ossy/resources": "^3.
|
|
21
|
+
"@ossy/platform": "^3.19.0",
|
|
22
|
+
"@ossy/resources": "^3.19.0",
|
|
22
23
|
"@ossy/schema": "^3.18.0",
|
|
23
24
|
"blurhash": "^2.0.5"
|
|
24
25
|
},
|
|
25
26
|
"peerDependencies": {
|
|
27
|
+
"ffmpeg-static": ">=5",
|
|
26
28
|
"openai": ">=4",
|
|
27
29
|
"sharp": ">=0.34"
|
|
28
30
|
},
|
|
31
|
+
"peerDependenciesMeta": {
|
|
32
|
+
"ffmpeg-static": {
|
|
33
|
+
"optional": true
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"ffmpeg-static": "^5.3.0"
|
|
38
|
+
},
|
|
29
39
|
"publishConfig": {
|
|
30
40
|
"access": "public",
|
|
31
41
|
"registry": "https://registry.npmjs.org"
|
|
@@ -36,5 +46,5 @@
|
|
|
36
46
|
],
|
|
37
47
|
"author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
|
|
38
48
|
"license": "MIT",
|
|
39
|
-
"gitHead": "
|
|
49
|
+
"gitHead": "0ac103001a468f56597c02cf1c6802bf7bd89b33"
|
|
40
50
|
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* General audio format converter (#897 — generalised from DJ-specific).
|
|
3
|
+
*
|
|
4
|
+
* Converts between common audio formats via ffmpeg. DJ/PCM preprocessing
|
|
5
|
+
* (BPM, Camelot key, rekordbox/CDJ USB prep) is tracked separately in #951
|
|
6
|
+
* (`analyze-dj-audio`) — that task is not implemented in this package yet.
|
|
7
|
+
*
|
|
8
|
+
* Supported output formats: mp3, aac, ogg, wav, aiff, flac
|
|
9
|
+
* Default: mp3
|
|
10
|
+
*
|
|
11
|
+
* Dependency: system `ffmpeg` on PATH, or optional peer `ffmpeg-static`
|
|
12
|
+
* (or `FFMPEG_PATH`). No fluent-ffmpeg wrapper — spawn is enough.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn } from 'node:child_process'
|
|
16
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
17
|
+
import { tmpdir } from 'node:os'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
|
|
20
|
+
/** @type {ReadonlyArray<string>} */
|
|
21
|
+
export const COMMON_AUDIO_TARGET_FORMATS = Object.freeze([
|
|
22
|
+
'mp3', 'aac', 'ogg', 'wav', 'aiff', 'flac',
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
/** @type {Readonly<Record<string, string>>} */
|
|
26
|
+
export const COMMON_AUDIO_FORMAT_CONTENT_TYPES = Object.freeze({
|
|
27
|
+
mp3: 'audio/mpeg',
|
|
28
|
+
aac: 'audio/aac',
|
|
29
|
+
ogg: 'audio/ogg',
|
|
30
|
+
wav: 'audio/wav',
|
|
31
|
+
aiff: 'audio/aiff',
|
|
32
|
+
flac: 'audio/flac',
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
/** MIME types already matching a target container (upload auto-run skip). */
|
|
36
|
+
export const COMMON_AUDIO_TARGET_MIME_TYPES = Object.freeze({
|
|
37
|
+
mp3: Object.freeze(['audio/mpeg', 'audio/mp3']),
|
|
38
|
+
aac: Object.freeze(['audio/aac', 'audio/x-aac']),
|
|
39
|
+
ogg: Object.freeze(['audio/ogg']),
|
|
40
|
+
wav: Object.freeze(['audio/wav', 'audio/x-wav', 'audio/wave']),
|
|
41
|
+
aiff: Object.freeze(['audio/aiff', 'audio/x-aiff']),
|
|
42
|
+
flac: Object.freeze(['audio/flac', 'audio/x-flac']),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
/** ffmpeg codec per output format. */
|
|
46
|
+
const FORMAT_CODEC = Object.freeze({
|
|
47
|
+
mp3: 'libmp3lame',
|
|
48
|
+
aac: 'aac',
|
|
49
|
+
ogg: 'libvorbis',
|
|
50
|
+
wav: 'pcm_s16le',
|
|
51
|
+
aiff: 'pcm_s16be',
|
|
52
|
+
flac: 'flac',
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
export const DEFAULT_COMMON_AUDIO_FORMAT = 'mp3'
|
|
56
|
+
export const DEFAULT_COMMON_AUDIO_QUALITY = 85
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {unknown} value
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeCommonAudioFormat (value) {
|
|
63
|
+
const raw = typeof value === 'string' ? value.trim().toLowerCase() : ''
|
|
64
|
+
if (raw === 'aif') return 'aiff'
|
|
65
|
+
if (raw === 'm4a' || raw === 'mp4') return 'aac'
|
|
66
|
+
if (COMMON_AUDIO_TARGET_FORMATS.includes(raw)) return raw
|
|
67
|
+
return DEFAULT_COMMON_AUDIO_FORMAT
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {unknown} value
|
|
72
|
+
* @returns {number}
|
|
73
|
+
*/
|
|
74
|
+
export function normalizeCommonAudioQuality (value) {
|
|
75
|
+
const n = typeof value === 'number' ? value : Number(String(value ?? '').trim())
|
|
76
|
+
if (!Number.isFinite(n) || n <= 0) return DEFAULT_COMMON_AUDIO_QUALITY
|
|
77
|
+
return Math.max(1, Math.min(100, Math.round(n)))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {unknown} contentType
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
export function normalizeContentType (contentType) {
|
|
85
|
+
return String(contentType || '').split(';')[0].trim().toLowerCase()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {unknown} contentType
|
|
90
|
+
* @returns {boolean}
|
|
91
|
+
*/
|
|
92
|
+
export function isAudioContentType (contentType) {
|
|
93
|
+
return normalizeContentType(contentType).startsWith('audio/')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {string} format
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
export function contentTypeForCommonAudioFormat (format) {
|
|
101
|
+
const normalized = normalizeCommonAudioFormat(format)
|
|
102
|
+
return COMMON_AUDIO_FORMAT_CONTENT_TYPES[normalized] || COMMON_AUDIO_FORMAT_CONTENT_TYPES.mp3
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Skip when the source MIME already matches the requested target container.
|
|
107
|
+
*
|
|
108
|
+
* @param {unknown} sourceContentType
|
|
109
|
+
* @param {string} targetFormat
|
|
110
|
+
* @returns {boolean}
|
|
111
|
+
*/
|
|
112
|
+
export function isAlreadyCommonAudioTargetFormat (sourceContentType, targetFormat) {
|
|
113
|
+
const format = normalizeCommonAudioFormat(targetFormat)
|
|
114
|
+
const mime = normalizeContentType(sourceContentType)
|
|
115
|
+
const accepted = COMMON_AUDIO_TARGET_MIME_TYPES[format] || []
|
|
116
|
+
return accepted.includes(mime)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build ffmpeg quality flags for a given format and quality (1–100).
|
|
121
|
+
*
|
|
122
|
+
* @param {string} format
|
|
123
|
+
* @param {number} quality 1–100
|
|
124
|
+
* @returns {string[]}
|
|
125
|
+
*/
|
|
126
|
+
export function qualityFlagsForFormat (format, quality) {
|
|
127
|
+
switch (format) {
|
|
128
|
+
case 'mp3': {
|
|
129
|
+
// VBR: q:a 0 (best) – 9 (worst). quality 100→0, quality 1→9.
|
|
130
|
+
const q = Math.round((1 - quality / 100) * 9)
|
|
131
|
+
return ['-q:a', String(q)]
|
|
132
|
+
}
|
|
133
|
+
case 'ogg': {
|
|
134
|
+
// q:a 0 (worst) – 10 (best). quality 100→10, quality 1→1.
|
|
135
|
+
const q = Math.max(1, Math.round(quality / 10))
|
|
136
|
+
return ['-q:a', String(q)]
|
|
137
|
+
}
|
|
138
|
+
case 'aac': {
|
|
139
|
+
// Map quality 1–100 → 64k–320k.
|
|
140
|
+
const kbps = Math.round(64 + (quality / 100) * 256)
|
|
141
|
+
return ['-b:a', `${kbps}k`]
|
|
142
|
+
}
|
|
143
|
+
case 'flac':
|
|
144
|
+
// compression_level 0 (fastest) – 12 (smallest). Default 5 is fine.
|
|
145
|
+
return ['-compression_level', '5']
|
|
146
|
+
default:
|
|
147
|
+
// wav / aiff — PCM, lossless, no quality flags needed.
|
|
148
|
+
return []
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Resolve the ffmpeg binary: `FFMPEG_PATH`, optional `ffmpeg-static`, then PATH.
|
|
154
|
+
*
|
|
155
|
+
* @returns {Promise<string>}
|
|
156
|
+
*/
|
|
157
|
+
export async function resolveFfmpegPath () {
|
|
158
|
+
if (typeof process.env.FFMPEG_PATH === 'string' && process.env.FFMPEG_PATH.trim()) {
|
|
159
|
+
return process.env.FFMPEG_PATH.trim()
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const mod = await import('ffmpeg-static')
|
|
163
|
+
const path = mod?.default || mod
|
|
164
|
+
if (typeof path === 'string' && path) return path
|
|
165
|
+
} catch {
|
|
166
|
+
// peer optional — fall through to system binary
|
|
167
|
+
}
|
|
168
|
+
return 'ffmpeg'
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {string} ffmpegPath
|
|
173
|
+
* @param {string[]} args
|
|
174
|
+
* @returns {Promise<void>}
|
|
175
|
+
*/
|
|
176
|
+
export function runFfmpeg (ffmpegPath, args) {
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
const child = spawn(ffmpegPath, args, { stdio: ['ignore', 'ignore', 'pipe'] })
|
|
179
|
+
let stderr = ''
|
|
180
|
+
child.stderr?.on('data', (chunk) => { stderr += String(chunk) })
|
|
181
|
+
child.on('error', (err) => {
|
|
182
|
+
reject(new Error(`[convert-common-audio] failed to spawn ffmpeg: ${err.message}`))
|
|
183
|
+
})
|
|
184
|
+
child.on('close', (code) => {
|
|
185
|
+
if (code === 0) { resolve(); return }
|
|
186
|
+
const detail = stderr.trim() || `exit ${code}`
|
|
187
|
+
reject(new Error(`[convert-common-audio] ffmpeg failed: ${detail}`))
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Convert an audio buffer to the target format via ffmpeg.
|
|
194
|
+
*
|
|
195
|
+
* @param {Buffer} sourceBuffer
|
|
196
|
+
* @param {{ format?: string, quality?: number, ffmpegPath?: string }} [options]
|
|
197
|
+
* @returns {Promise<{
|
|
198
|
+
* buffer: Buffer,
|
|
199
|
+
* format: string,
|
|
200
|
+
* contentType: string,
|
|
201
|
+
* size: number,
|
|
202
|
+
* }>}
|
|
203
|
+
*/
|
|
204
|
+
export async function convertCommonAudioBuffer (sourceBuffer, options = {}) {
|
|
205
|
+
if (!Buffer.isBuffer(sourceBuffer) || sourceBuffer.length === 0) {
|
|
206
|
+
throw new Error('[convert-common-audio] source buffer is required')
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const format = normalizeCommonAudioFormat(options.format)
|
|
210
|
+
const quality = normalizeCommonAudioQuality(options.quality ?? DEFAULT_COMMON_AUDIO_QUALITY)
|
|
211
|
+
const ffmpegPath = options.ffmpegPath || await resolveFfmpegPath()
|
|
212
|
+
const codec = FORMAT_CODEC[format]
|
|
213
|
+
|
|
214
|
+
const dir = await mkdtemp(join(tmpdir(), 'ossy-common-audio-'))
|
|
215
|
+
const inputPath = join(dir, 'input.bin')
|
|
216
|
+
const outputPath = join(dir, `output.${format}`)
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
await writeFile(inputPath, sourceBuffer)
|
|
220
|
+
await runFfmpeg(ffmpegPath, [
|
|
221
|
+
'-y',
|
|
222
|
+
'-hide_banner',
|
|
223
|
+
'-loglevel', 'error',
|
|
224
|
+
'-i', inputPath,
|
|
225
|
+
'-vn',
|
|
226
|
+
'-acodec', codec,
|
|
227
|
+
...qualityFlagsForFormat(format, quality),
|
|
228
|
+
outputPath,
|
|
229
|
+
])
|
|
230
|
+
const buffer = await readFile(outputPath)
|
|
231
|
+
if (!buffer.length) {
|
|
232
|
+
throw new Error('[convert-common-audio] ffmpeg produced an empty output')
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
buffer,
|
|
236
|
+
format,
|
|
237
|
+
contentType: contentTypeForCommonAudioFormat(format),
|
|
238
|
+
size: buffer.length,
|
|
239
|
+
}
|
|
240
|
+
} finally {
|
|
241
|
+
await rm(dir, { recursive: true, force: true })
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { TASK_OUTPUT_PROVENANCE_FIELDS } from '@ossy/schema'
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'Convert audio format',
|
|
5
|
+
id: '@ossy/media-tasks/schema/convert-common-audio',
|
|
6
|
+
categoryName: 'Media processing',
|
|
7
|
+
icon: 'music',
|
|
8
|
+
fields: [
|
|
9
|
+
{
|
|
10
|
+
name: 'format',
|
|
11
|
+
type: 'text',
|
|
12
|
+
description: 'Output container (mp3, aac, ogg, wav, aiff, or flac)',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: 'contentType',
|
|
16
|
+
type: 'text',
|
|
17
|
+
description: 'MIME type of the converted audio bytes',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'sourceContentType',
|
|
21
|
+
type: 'text',
|
|
22
|
+
description: 'MIME type of the source upload',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'quality',
|
|
26
|
+
type: 'number',
|
|
27
|
+
description: 'Encode quality 1–100 used for lossy formats',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'size',
|
|
31
|
+
type: 'number',
|
|
32
|
+
description: 'Converted audio byte length',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'audioKey',
|
|
36
|
+
type: 'text',
|
|
37
|
+
description: 'Storage key for the converted audio binary artifact',
|
|
38
|
+
},
|
|
39
|
+
...TASK_OUTPUT_PROVENANCE_FIELDS,
|
|
40
|
+
],
|
|
41
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_COMMON_AUDIO_FORMAT,
|
|
7
|
+
DEFAULT_COMMON_AUDIO_QUALITY,
|
|
8
|
+
contentTypeForCommonAudioFormat,
|
|
9
|
+
convertCommonAudioBuffer,
|
|
10
|
+
isAlreadyCommonAudioTargetFormat,
|
|
11
|
+
isAudioContentType,
|
|
12
|
+
normalizeCommonAudioFormat,
|
|
13
|
+
normalizeCommonAudioQuality,
|
|
14
|
+
qualityFlagsForFormat,
|
|
15
|
+
resolveFfmpegPath,
|
|
16
|
+
} from './convert-common-audio.js'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} ffmpegPath
|
|
20
|
+
* @param {string[]} args
|
|
21
|
+
* @param {string} outputPath
|
|
22
|
+
* @returns {Promise<Buffer>}
|
|
23
|
+
*/
|
|
24
|
+
async function encodeWithFfmpeg (ffmpegPath, args, outputPath) {
|
|
25
|
+
await new Promise((resolve, reject) => {
|
|
26
|
+
const child = spawn(ffmpegPath, args, { stdio: ['ignore', 'ignore', 'pipe'] })
|
|
27
|
+
let stderr = ''
|
|
28
|
+
child.stderr?.on('data', (chunk) => { stderr += String(chunk) })
|
|
29
|
+
child.on('error', reject)
|
|
30
|
+
child.on('close', (code) => {
|
|
31
|
+
if (code === 0) resolve()
|
|
32
|
+
else reject(new Error(stderr || `exit ${code}`))
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
return readFile(outputPath)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @returns {Promise<string|null>}
|
|
40
|
+
*/
|
|
41
|
+
async function resolveUsableFfmpegPath () {
|
|
42
|
+
const ffmpegPath = await resolveFfmpegPath()
|
|
43
|
+
try {
|
|
44
|
+
await new Promise((resolve, reject) => {
|
|
45
|
+
const child = spawn(ffmpegPath, ['-version'], { stdio: ['ignore', 'ignore', 'ignore'] })
|
|
46
|
+
child.on('error', reject)
|
|
47
|
+
child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`exit ${code}`))))
|
|
48
|
+
})
|
|
49
|
+
return ffmpegPath
|
|
50
|
+
} catch {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} ffmpegPath
|
|
57
|
+
* @returns {Promise<Buffer>}
|
|
58
|
+
*/
|
|
59
|
+
async function makeTestMp3 (ffmpegPath) {
|
|
60
|
+
const dir = await mkdtemp(join(tmpdir(), 'ossy-common-audio-fixture-'))
|
|
61
|
+
const outputPath = join(dir, 'tone.mp3')
|
|
62
|
+
try {
|
|
63
|
+
return await encodeWithFfmpeg(ffmpegPath, [
|
|
64
|
+
'-y', '-hide_banner', '-loglevel', 'error',
|
|
65
|
+
'-f', 'lavfi',
|
|
66
|
+
'-i', 'sine=frequency=440:duration=0.25',
|
|
67
|
+
'-ac', '1',
|
|
68
|
+
'-ar', '22050',
|
|
69
|
+
'-c:a', 'libmp3lame',
|
|
70
|
+
'-b:a', '128k',
|
|
71
|
+
outputPath,
|
|
72
|
+
], outputPath)
|
|
73
|
+
} finally {
|
|
74
|
+
await rm(dir, { recursive: true, force: true })
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('convert-common-audio helpers', () => {
|
|
79
|
+
it('normalizes format (aliases + defaults)', () => {
|
|
80
|
+
expect(normalizeCommonAudioFormat('MP3')).toBe('mp3')
|
|
81
|
+
expect(normalizeCommonAudioFormat('aif')).toBe('aiff')
|
|
82
|
+
expect(normalizeCommonAudioFormat('m4a')).toBe('aac')
|
|
83
|
+
expect(normalizeCommonAudioFormat('mp4')).toBe('aac')
|
|
84
|
+
expect(normalizeCommonAudioFormat('nope')).toBe(DEFAULT_COMMON_AUDIO_FORMAT)
|
|
85
|
+
expect(normalizeCommonAudioFormat('')).toBe(DEFAULT_COMMON_AUDIO_FORMAT)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('normalizes quality', () => {
|
|
89
|
+
expect(normalizeCommonAudioQuality(85)).toBe(85)
|
|
90
|
+
expect(normalizeCommonAudioQuality(0)).toBe(DEFAULT_COMMON_AUDIO_QUALITY)
|
|
91
|
+
expect(normalizeCommonAudioQuality(150)).toBe(100)
|
|
92
|
+
expect(normalizeCommonAudioQuality('bad')).toBe(DEFAULT_COMMON_AUDIO_QUALITY)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('recognizes audio MIME and already-converted sources', () => {
|
|
96
|
+
expect(isAudioContentType('audio/mpeg')).toBe(true)
|
|
97
|
+
expect(isAudioContentType('image/png')).toBe(false)
|
|
98
|
+
expect(isAlreadyCommonAudioTargetFormat('audio/mpeg', 'mp3')).toBe(true)
|
|
99
|
+
expect(isAlreadyCommonAudioTargetFormat('audio/mp3', 'mp3')).toBe(true)
|
|
100
|
+
expect(isAlreadyCommonAudioTargetFormat('audio/wav', 'wav')).toBe(true)
|
|
101
|
+
expect(isAlreadyCommonAudioTargetFormat('audio/x-wav', 'wav')).toBe(true)
|
|
102
|
+
expect(isAlreadyCommonAudioTargetFormat('audio/aiff', 'aiff')).toBe(true)
|
|
103
|
+
expect(isAlreadyCommonAudioTargetFormat('audio/flac', 'flac')).toBe(true)
|
|
104
|
+
expect(isAlreadyCommonAudioTargetFormat('audio/mpeg', 'wav')).toBe(false)
|
|
105
|
+
expect(contentTypeForCommonAudioFormat('mp3')).toBe('audio/mpeg')
|
|
106
|
+
expect(contentTypeForCommonAudioFormat('aiff')).toBe('audio/aiff')
|
|
107
|
+
expect(contentTypeForCommonAudioFormat('flac')).toBe('audio/flac')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('builds correct ffmpeg quality flags', () => {
|
|
111
|
+
// MP3 VBR: quality 100 → q:a 0, quality 1 → q:a 9
|
|
112
|
+
expect(qualityFlagsForFormat('mp3', 100)).toEqual(['-q:a', '0'])
|
|
113
|
+
expect(qualityFlagsForFormat('mp3', 1)).toEqual(['-q:a', '9'])
|
|
114
|
+
// (1 - 85/100) * 9 = 1.35 → rounds to 1
|
|
115
|
+
expect(qualityFlagsForFormat('mp3', 85)).toEqual(['-q:a', '1'])
|
|
116
|
+
// OGG
|
|
117
|
+
expect(qualityFlagsForFormat('ogg', 100)).toEqual(['-q:a', '10'])
|
|
118
|
+
expect(qualityFlagsForFormat('ogg', 50)).toEqual(['-q:a', '5'])
|
|
119
|
+
// AAC bitrate range
|
|
120
|
+
const aacFlags = qualityFlagsForFormat('aac', 85)
|
|
121
|
+
expect(aacFlags[0]).toBe('-b:a')
|
|
122
|
+
// lossless: no quality flags
|
|
123
|
+
expect(qualityFlagsForFormat('wav', 85)).toEqual([])
|
|
124
|
+
expect(qualityFlagsForFormat('aiff', 85)).toEqual([])
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('resolves an ffmpeg binary', async () => {
|
|
128
|
+
const path = await resolveFfmpegPath()
|
|
129
|
+
expect(typeof path).toBe('string')
|
|
130
|
+
expect(path.length).toBeGreaterThan(0)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('convertCommonAudioBuffer', () => {
|
|
135
|
+
/** @type {string|null} */
|
|
136
|
+
let ffmpegPath = null
|
|
137
|
+
|
|
138
|
+
beforeAll(async () => {
|
|
139
|
+
ffmpegPath = await resolveUsableFfmpegPath()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('converts MP3 bytes to WAV', async () => {
|
|
143
|
+
if (!ffmpegPath) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
'ffmpeg binary not found — install media-tasks devDependency ffmpeg-static, or set FFMPEG_PATH / system ffmpeg',
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
const sourceBuffer = await makeTestMp3(ffmpegPath)
|
|
149
|
+
expect(sourceBuffer.length).toBeGreaterThan(0)
|
|
150
|
+
|
|
151
|
+
const result = await convertCommonAudioBuffer(sourceBuffer, { format: 'wav', ffmpegPath })
|
|
152
|
+
expect(result.format).toBe('wav')
|
|
153
|
+
expect(result.contentType).toBe('audio/wav')
|
|
154
|
+
expect(result.size).toBeGreaterThan(0)
|
|
155
|
+
expect(result.buffer.subarray(0, 4).toString('ascii')).toBe('RIFF')
|
|
156
|
+
expect(result.buffer.subarray(8, 12).toString('ascii')).toBe('WAVE')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('converts MP3 bytes to AIFF', async () => {
|
|
160
|
+
if (!ffmpegPath) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
'ffmpeg binary not found — install media-tasks devDependency ffmpeg-static, or set FFMPEG_PATH / system ffmpeg',
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
const sourceBuffer = await makeTestMp3(ffmpegPath)
|
|
166
|
+
const result = await convertCommonAudioBuffer(sourceBuffer, { format: 'aiff', ffmpegPath })
|
|
167
|
+
expect(result.format).toBe('aiff')
|
|
168
|
+
expect(result.contentType).toBe('audio/aiff')
|
|
169
|
+
expect(result.buffer.subarray(0, 4).toString('ascii')).toBe('FORM')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('converts MP3 bytes to MP3 (re-encode)', async () => {
|
|
173
|
+
if (!ffmpegPath) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
'ffmpeg binary not found — install media-tasks devDependency ffmpeg-static, or set FFMPEG_PATH / system ffmpeg',
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
const sourceBuffer = await makeTestMp3(ffmpegPath)
|
|
179
|
+
const result = await convertCommonAudioBuffer(sourceBuffer, { format: 'mp3', ffmpegPath })
|
|
180
|
+
expect(result.format).toBe('mp3')
|
|
181
|
+
expect(result.contentType).toBe('audio/mpeg')
|
|
182
|
+
expect(result.size).toBeGreaterThan(0)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('rejects an empty source buffer', async () => {
|
|
186
|
+
await expect(convertCommonAudioBuffer(Buffer.alloc(0))).rejects.toThrow(/source buffer/)
|
|
187
|
+
})
|
|
188
|
+
})
|