Skip to content

Commit 5238e89

Browse files
ralyodioclaude
andcommitted
feat(genmedia): add provider-agnostic generative media layer
Adds image, video, speech and music generation alongside the existing ffmpeg pipeline, behind one interface with swappable providers. Seven repos across the portfolio (viral-video, makedemo, amazon-affiliate, reeleel, brisk.news, bl0ggers, intr0s) have each independently rebuilt the same script -> image -> TTS -> mux pipeline. This is the shared layer they can all sit on. - Providers: google (Gemini Image, Veo, Gemini TTS, Lyria), openai (gpt-image-1, gpt-4o-mini-tts), elevenlabs (TTS) - No new runtime dependencies: every call is plain fetch against a documented REST endpoint, using the global fetch in Node 20+ - Provider resolution picks the first preferred provider for a capability that actually has credentials; overridable per call or via GENMEDIA_PROVIDER. Speech prefers ElevenLabs so shipped products do not silently change voice - Veo long-running operations are polled to completion; results carry native synchronized audio, removing the separate voiceover and mux stages - Google PCM speech output is wrapped in a WAV container so ffmpeg needs no out-of-band format hints - generateBatch bounds concurrency, preserves order and captures per-item errors instead of failing the run - Retries with exponential backoff and Retry-After on 429/5xx; client errors such as a rejected prompt fail immediately 53 new tests, all provider calls exercised through an injected fetch so the suite runs offline and without credentials. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9d89323 commit 5238e89

13 files changed

Lines changed: 2528 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ A lightweight Node.js module for transcoding videos to web-friendly MP4 format u
2727
- Audio enhancement features (normalization, noise reduction, fades)
2828
- Thumbnail Generation at specified intervals or timestamps
2929
- Batch processing of multiple files with a fancy terminal UI
30+
- Generative media (image, video, speech, music) behind one provider-agnostic interface
3031
- No file storage - just passes through to FFmpeg
3132
- Lightweight with minimal dependencies
3233

@@ -566,6 +567,111 @@ if (skippedFiles.length > 0) {
566567
}
567568
```
568569

570+
### Generating Media
571+
572+
Alongside transcoding, the module can generate images, video, speech and music through one provider-agnostic interface. Generated assets come back as ordinary files, so the existing ffmpeg pipeline picks them up without any glue code.
573+
574+
This adds **no runtime dependencies** — every provider is a plain `fetch` call against a documented REST endpoint.
575+
576+
```javascript
577+
import { generateImage, generateSpeech, transcodeAudio } from '@profullstack/transcoder';
578+
579+
// Generate an image
580+
const image = await generateImage({
581+
prompt: 'A wide editorial photo of an empty recording studio at golden hour',
582+
aspectRatio: '16:9'
583+
});
584+
await image.toFile('./output/studio.png');
585+
586+
// Generate a voiceover, then transcode it with the existing pipeline
587+
const speech = await generateSpeech({ text: 'Here is what changed in this release.' });
588+
const raw = await speech.toFile('./output/voiceover');
589+
await transcodeAudio(raw, './output/voiceover.mp3', { preset: 'audio-high' });
590+
```
591+
592+
#### Providers
593+
594+
| Provider | Capabilities | Credentials |
595+
|----------|--------------|-------------|
596+
| `google` | image, video, speech, music | `GOOGLE_API_KEY` (Lyria also needs `GOOGLE_CLOUD_PROJECT` and `GOOGLE_ACCESS_TOKEN`) |
597+
| `openai` | image, speech | `OPENAI_API_KEY` |
598+
| `elevenlabs` | speech | `ELEVENLABS_API_KEY` |
599+
600+
A provider is chosen automatically: the first preferred provider for that capability that actually has credentials configured. Speech prefers ElevenLabs, images prefer Google, and both can be overridden per call with `provider`, or globally with the `GENMEDIA_PROVIDER` environment variable.
601+
602+
```javascript
603+
import { describeProviders } from '@profullstack/transcoder';
604+
605+
// Which providers are usable right now?
606+
console.log(describeProviders());
607+
// [{ name: 'google', capabilities: [...], envVars: [...], configured: false }, ...]
608+
```
609+
610+
#### Video with synchronized audio
611+
612+
Veo generates its own dialogue, effects and ambient audio, which removes the separate voiceover and mux stages from a typical short-video pipeline:
613+
614+
```javascript
615+
const clip = await generateVideo({
616+
prompt: 'Slow dolly across a quiet workshop, dust in the light, ambient room tone',
617+
aspectRatio: '16:9',
618+
onProgress: ({ elapsed }) => console.log(`rendering (${Math.round(elapsed / 1000)}s)`)
619+
});
620+
621+
console.log(clip.meta.hasNativeAudio); // true
622+
await clip.toFile('./output/clip.mp4');
623+
```
624+
625+
Video generation is a long-running operation and is polled internally until it completes, up to `maxWait` (default 10 minutes).
626+
627+
#### Multi-speaker speech
628+
629+
A single call produces a two-host conversation, with no editing step between the parts:
630+
631+
```javascript
632+
const dialogue = await generateSpeech({
633+
provider: 'google',
634+
text: 'Host: So what shipped this week?\nGuest: The shared media layer.',
635+
speakers: [
636+
{ speaker: 'Host', voice: 'Kore' },
637+
{ speaker: 'Guest', voice: 'Puck' }
638+
]
639+
});
640+
```
641+
642+
Google's speech models return headerless PCM; it is wrapped in a WAV container automatically so ffmpeg does not need out-of-band format hints.
643+
644+
#### Music beds
645+
646+
```javascript
647+
const bed = await generateMusic({
648+
prompt: 'An understated, optimistic instrumental bed with light percussion',
649+
seed: 42
650+
});
651+
```
652+
653+
Generated music sidesteps the licensing problem that otherwise prevents user-facing video products from shipping with a soundtrack at all.
654+
655+
#### Batch generation
656+
657+
Providers rate limit, so an unbounded `Promise.all` over a storyboard is the quickest route to `429`s. `generateBatch` bounds concurrency, preserves input order, and captures per-item errors rather than failing the whole run:
658+
659+
```javascript
660+
const results = await generateBatch(
661+
scenes.map(prompt => ({ kind: 'image', prompt })),
662+
{ concurrency: 2, onProgress: ({ completed, total }) => console.log(`${completed}/${total}`) }
663+
);
664+
665+
for (const { media, error, index } of results) {
666+
if (error) continue;
667+
await media.toFile(`./output/scene-${index + 1}`);
668+
}
669+
```
670+
671+
Rate limits and transient upstream faults are retried with exponential backoff and `Retry-After` support; client errors such as a rejected prompt fail immediately.
672+
673+
See [examples/genmedia.js](examples/genmedia.js) for a runnable walkthrough.
674+
569675
### Using the CLI Tool
570676

571677
The module includes a command-line interface (CLI) for easy video transcoding, thumbnail generation, and watermarking directly from your terminal:
@@ -838,6 +944,54 @@ Generates thumbnails from a video file without transcoding.
838944

839945
- Promise that resolves with an array of thumbnail paths
840946

947+
### generateImage(options) / generateVideo(options) / generateSpeech(options) / generateMusic(options)
948+
949+
Generates a media asset through the configured provider.
950+
951+
**Common options:**
952+
953+
- `provider` (string): Force a specific provider instead of resolving one
954+
- `model` (string): Override the provider's default model
955+
- `apiKey` (string): Credentials, otherwise read from the environment
956+
- `timeout` (number): Per-request timeout in ms (default: `120000`)
957+
- `retries` (number): Retries after the first attempt (default: `3`)
958+
- `fetchImpl` (Function): Fetch implementation, useful in tests
959+
960+
**Capability-specific options:**
961+
962+
- `generateImage`: `prompt`, `aspectRatio`, `size`, `referenceImages`
963+
- `generateVideo`: `prompt`, `aspectRatio`, `resolution`, `negativePrompt`, `image`, `pollInterval`, `maxWait`, `onProgress`
964+
- `generateSpeech`: `text`, `voice`, `speakers`, `instructions`, `format`
965+
- `generateMusic`: `prompt`, `negativePrompt`, `seed`, `projectId`, `location`, `accessToken`
966+
967+
**Returns:**
968+
969+
- Promise that resolves with a `GeneratedMedia` instance: `{ data, mimeType, provider, model, kind, meta }`, plus `size`, `extension`, `toFile(path)` and `toDataUri()`
970+
971+
### generateBatch(requests, [options])
972+
973+
Generates several assets concurrently with a bounded number of requests in flight.
974+
975+
**Parameters:**
976+
977+
- `requests` (Array): Requests as `{ kind, ...options }` where `kind` is `image`, `video`, `speech` or `music`
978+
- `options.concurrency` (number): Maximum requests in flight (default: `3`)
979+
- `options.onProgress` (Function): Called with `{ index, total, completed, error }`
980+
981+
**Returns:**
982+
983+
- Promise that resolves with an array of `{ index, media, error }`, in the order the requests were given
984+
985+
### describeProviders()
986+
987+
**Returns:**
988+
989+
- Array of `{ name, capabilities, defaultModels, envVars, configured }`, one entry per registered provider
990+
991+
### registerProvider(provider)
992+
993+
Registers a custom provider. A provider is an object with `name`, `capabilities`, `envVars`, and any of `generateImage`, `generateVideo`, `generateSpeech`, `generateMusic`.
994+
841995
### BatchProcessEmitter Events
842996

843997
The emitter returned by the batchProcessDirectory function emits the following events:

‎examples/genmedia.js‎

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/**
2+
* Generative media example for the transcode module
3+
*
4+
* This example demonstrates generating an image, a voiceover, a music bed and a
5+
* video clip through one interface, then handing the results to the existing
6+
* ffmpeg pipeline. Nothing runs without credentials, so the script reports which
7+
* providers are configured first and skips whatever it cannot reach.
8+
*/
9+
10+
// In a real project, you would import from the package:
11+
// import { generateImage, generateSpeech, transcode } from '@profullstack/transcoder';
12+
// For this example, we're importing directly from the local file:
13+
import {
14+
generateImage,
15+
generateVideo,
16+
generateSpeech,
17+
generateMusic,
18+
generateBatch,
19+
describeProviders,
20+
hasCredentials
21+
} from '../index.js';
22+
import fs from 'fs';
23+
24+
const outputDir = './test-videos/output/genmedia';
25+
if (!fs.existsSync(outputDir)) {
26+
fs.mkdirSync(outputDir, { recursive: true });
27+
}
28+
29+
// Example 1: See what is actually usable in this environment
30+
console.log('Example 1: Provider capabilities');
31+
for (const provider of describeProviders()) {
32+
const state = provider.configured ? 'configured' : `needs ${provider.envVars[0]}`;
33+
console.log(` ${provider.name.padEnd(12)} ${provider.capabilities.join(', ').padEnd(28)} ${state}`);
34+
}
35+
36+
// Example 2: An image, written straight to disk
37+
async function imageExample() {
38+
console.log('\nExample 2: Image generation');
39+
if (!hasCredentials('google') && !hasCredentials('openai')) {
40+
console.log(' Skipped: no image provider configured');
41+
return;
42+
}
43+
44+
const image = await generateImage({
45+
prompt: 'A wide editorial photo of an empty recording studio at golden hour',
46+
aspectRatio: '16:9'
47+
});
48+
49+
const written = await image.toFile(`${outputDir}/studio`);
50+
console.log(` ${image.provider}/${image.model} -> ${written} (${image.size} bytes)`);
51+
}
52+
53+
// Example 3: A voiceover, then transcode it to a web-friendly format
54+
async function speechExample() {
55+
console.log('\nExample 3: Voiceover');
56+
if (!hasCredentials('elevenlabs') && !hasCredentials('openai') && !hasCredentials('google')) {
57+
console.log(' Skipped: no speech provider configured');
58+
return;
59+
}
60+
61+
const speech = await generateSpeech({
62+
text: 'Here is what changed in this release, in about forty five seconds.'
63+
});
64+
65+
const written = await speech.toFile(`${outputDir}/voiceover`);
66+
console.log(` ${speech.provider}/${speech.model} -> ${written} (${speech.size} bytes)`);
67+
68+
// The result is a normal audio file, so the existing pipeline takes it from here:
69+
// await transcodeAudio(written, `${outputDir}/voiceover.mp3`, { preset: 'audio-high' });
70+
}
71+
72+
// Example 4: Two hosts in a single call, no editing between them
73+
async function podcastExample() {
74+
console.log('\nExample 4: Multi-speaker dialogue');
75+
if (!hasCredentials('google')) {
76+
console.log(' Skipped: GOOGLE_API_KEY is not set');
77+
return;
78+
}
79+
80+
const dialogue = await generateSpeech({
81+
provider: 'google',
82+
text: 'Host: So what actually shipped this week?\nGuest: The shared media layer, finally.',
83+
speakers: [
84+
{ speaker: 'Host', voice: 'Kore' },
85+
{ speaker: 'Guest', voice: 'Puck' }
86+
]
87+
});
88+
89+
console.log(` -> ${await dialogue.toFile(`${outputDir}/dialogue`)}`);
90+
}
91+
92+
// Example 5: A music bed. Generated audio sidesteps the licensing problem that
93+
// otherwise stops user-facing video products from shipping with any soundtrack.
94+
async function musicExample() {
95+
console.log('\nExample 5: Music bed');
96+
if (!process.env.GOOGLE_CLOUD_PROJECT || !process.env.GOOGLE_ACCESS_TOKEN) {
97+
console.log(' Skipped: Lyria needs GOOGLE_CLOUD_PROJECT and GOOGLE_ACCESS_TOKEN');
98+
return;
99+
}
100+
101+
const music = await generateMusic({
102+
prompt: 'An understated, optimistic instrumental bed with light percussion',
103+
seed: 42
104+
});
105+
106+
console.log(` -> ${await music.toFile(`${outputDir}/bed`)}`);
107+
}
108+
109+
// Example 6: A video clip. Veo returns synchronized audio, so the usual
110+
// generate-voiceover-then-mux stage is unnecessary here.
111+
async function videoExample() {
112+
console.log('\nExample 6: Video generation');
113+
if (!hasCredentials('google')) {
114+
console.log(' Skipped: GOOGLE_API_KEY is not set');
115+
return;
116+
}
117+
118+
const clip = await generateVideo({
119+
prompt: 'Slow dolly across a quiet workshop, dust in the light, ambient room tone',
120+
aspectRatio: '16:9',
121+
onProgress: ({ elapsed }) => console.log(` still rendering (${Math.round(elapsed / 1000)}s)`)
122+
});
123+
124+
const written = await clip.toFile(`${outputDir}/clip.mp4`);
125+
console.log(` ${clip.model} -> ${written} (${clip.size} bytes, native audio: ${clip.meta.hasNativeAudio})`);
126+
}
127+
128+
// Example 7: A storyboard, generated concurrently but politely
129+
async function batchExample() {
130+
console.log('\nExample 7: Batch storyboard');
131+
if (!hasCredentials('google') && !hasCredentials('openai')) {
132+
console.log(' Skipped: no image provider configured');
133+
return;
134+
}
135+
136+
const scenes = [
137+
'Scene 1: a closed laptop on a workbench, morning light',
138+
'Scene 2: the same workbench, tools laid out in a row',
139+
'Scene 3: a wide shot of the finished piece'
140+
];
141+
142+
const results = await generateBatch(
143+
scenes.map(prompt => ({ kind: 'image', prompt })),
144+
{
145+
concurrency: 2,
146+
onProgress: ({ completed, total }) => console.log(` ${completed}/${total}`)
147+
}
148+
);
149+
150+
for (const result of results) {
151+
if (result.error) {
152+
console.log(` scene ${result.index + 1} failed: ${result.error.message}`);
153+
continue;
154+
}
155+
console.log(` scene ${result.index + 1} -> ${await result.media.toFile(`${outputDir}/scene-${result.index + 1}`)}`);
156+
}
157+
}
158+
159+
async function main() {
160+
const examples = [imageExample, speechExample, podcastExample, musicExample, videoExample, batchExample];
161+
162+
for (const example of examples) {
163+
try {
164+
await example();
165+
} catch (error) {
166+
console.error(` Error: ${error.message}`);
167+
}
168+
}
169+
170+
console.log('\nDone.');
171+
}
172+
173+
main();

‎package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"test:image": "mocha test/image.test.js",
2828
"test:batch": "mocha test/batch.test.js",
2929
"test:terminal-ui": "mocha test/terminal-ui.test.js",
30+
"test:genmedia": "mocha test/genmedia.test.js",
3031
"generate-test-video": "node scripts/generate-test-video.js",
3132
"generate-test-audio": "node scripts/generate-test-audio.js",
3233
"example": "node examples/basic-usage.js",
@@ -41,6 +42,7 @@
4142
"example:square": "node examples/square-padding.js",
4243
"example:batch": "node examples/batch-processing.js",
4344
"example:audio-enhancement": "node examples/audio-enhancement.js",
45+
"example:genmedia": "node examples/genmedia.js",
4446
"example:cli": "./examples/example.sh",
4547
"install-ffmpeg": "./bin/build-ffmpeg.sh",
4648
"install-imagemagick": "./bin/install-imagemagick.sh",

0 commit comments

Comments
 (0)