@mevdragon/vidfarm-devcli 0.10.0 → 0.11.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 +19 -1
- package/SKILL.director.md +66 -0
- package/SKILL.platform.md +2 -1
- package/dist/src/app.js +20 -2
- package/dist/src/cli.js +387 -10
- package/dist/src/devcli/speech.js +175 -0
- package/dist/src/editor-chat.js +2 -1
- package/dist/src/primitive-context.js +16 -0
- package/dist/src/primitive-registry.js +232 -0
- package/dist/src/services/local-dynamo.js +0 -0
- package/dist/src/services/provider-errors.js +74 -0
- package/dist/src/services/providers.js +30 -429
- package/dist/src/services/speech.js +467 -0
- package/package.json +1 -1
|
@@ -7,6 +7,7 @@ import { config } from "./config.js";
|
|
|
7
7
|
import { definePrimitive } from "./primitive-sdk.js";
|
|
8
8
|
import { PrimitiveMediaLambdaService } from "./services/primitive-media-lambda.js";
|
|
9
9
|
import { estimateGhostcutCostUsd, isGhostcutConfigured, pollStatus as pollGhostcutStatus, submitSubtitleRemoval } from "./services/ghostcut.js";
|
|
10
|
+
import { buildSrtFromSegments, defaultSpeechModelFor } from "./services/speech.js";
|
|
10
11
|
import { buildHtmlImageCompositionHtml, buildMediaDedupeCompositionHtml, DEDUPE_DEFAULT_EFFECTS } from "./primitives/hyperframes-media.js";
|
|
11
12
|
const imageProviderSchema = z.enum(["openai", "gemini", "openrouter"]);
|
|
12
13
|
const videoProviderSchema = z.enum(["openai", "gemini", "openrouter"]);
|
|
@@ -358,6 +359,47 @@ const trimAudioPayloadSchema = z.object({
|
|
|
358
359
|
message: "Either duration_ms or end_ms is required.",
|
|
359
360
|
path: ["duration_ms"]
|
|
360
361
|
});
|
|
362
|
+
const speechProviderSchema = z.enum(["openai", "gemini", "openrouter"]);
|
|
363
|
+
const ttsPayloadSchema = z.preprocess((raw) => {
|
|
364
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
365
|
+
return raw;
|
|
366
|
+
const payload = raw;
|
|
367
|
+
return {
|
|
368
|
+
...payload,
|
|
369
|
+
text: payload.text ?? payload.input ?? payload.script,
|
|
370
|
+
instructions: payload.instructions ?? payload.style ?? payload.style_instructions ?? payload.voice_style
|
|
371
|
+
};
|
|
372
|
+
}, z.object({
|
|
373
|
+
provider: speechProviderSchema.optional(),
|
|
374
|
+
model: z.string().min(1).optional(),
|
|
375
|
+
text: z.string().min(1).max(8000),
|
|
376
|
+
voice: z.string().min(1).max(120).optional(),
|
|
377
|
+
instructions: z.string().min(1).max(4000).optional(),
|
|
378
|
+
output_format: z.enum(["mp3", "wav"]).default("mp3")
|
|
379
|
+
}));
|
|
380
|
+
const sttPayloadSchema = z.preprocess((raw) => {
|
|
381
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
382
|
+
return raw;
|
|
383
|
+
const payload = raw;
|
|
384
|
+
return {
|
|
385
|
+
...payload,
|
|
386
|
+
source_url: payload.source_url ??
|
|
387
|
+
payload.source_video_url ??
|
|
388
|
+
payload.source_audio_url ??
|
|
389
|
+
payload.source_media_url ??
|
|
390
|
+
payload.video_url ??
|
|
391
|
+
payload.audio_url ??
|
|
392
|
+
payload.url
|
|
393
|
+
};
|
|
394
|
+
}, z.object({
|
|
395
|
+
provider: speechProviderSchema.optional(),
|
|
396
|
+
model: z.string().min(1).optional(),
|
|
397
|
+
source_url: mediaSourceUrlSchema,
|
|
398
|
+
media_type: z.enum(["auto", "video", "audio"]).default("auto"),
|
|
399
|
+
diarize: z.boolean().default(true),
|
|
400
|
+
language: z.string().min(2).max(16).optional(),
|
|
401
|
+
prompt: z.string().min(1).max(2000).optional()
|
|
402
|
+
}));
|
|
361
403
|
const probeVideoPayloadSchema = z.object({
|
|
362
404
|
source_video_url: mediaSourceUrlSchema
|
|
363
405
|
});
|
|
@@ -508,6 +550,14 @@ const PRIMITIVE_BRAINSTORM_ANGLES_ID = "primitive:brainstorm_angles";
|
|
|
508
550
|
const PRIMITIVE_BRAINSTORM_COLDSTART_ID = "primitive:brainstorm_coldstart";
|
|
509
551
|
const PRIMITIVE_BRAINSTORM_PRODUCT_PLACEMENT_ID = "primitive:brainstorm_product_placement";
|
|
510
552
|
const PRIMITIVE_MEDIA_DEDUPE_ID = "primitive:media_dedupe";
|
|
553
|
+
const PRIMITIVE_TTS_ID = "primitive:tts";
|
|
554
|
+
const PRIMITIVE_STT_ID = "primitive:stt";
|
|
555
|
+
// Gemini inline audio caps out around 20MB; at the 64kbps mono mp3 we extract,
|
|
556
|
+
// that is roughly 40 minutes of speech.
|
|
557
|
+
const MAX_TRANSCRIBE_AUDIO_BYTES = 20 * 1024 * 1024;
|
|
558
|
+
// Keep job results comfortably under the DynamoDB item limit: transcripts
|
|
559
|
+
// longer than this inline budget stay artifact-only (transcript.json).
|
|
560
|
+
const MAX_INLINE_TRANSCRIPT_CHARS = 20_000;
|
|
511
561
|
const primitiveMediaService = new PrimitiveMediaLambdaService();
|
|
512
562
|
const hooksReferenceText = readPrimitiveAsset("SELLING_WITH_HOOKS.md");
|
|
513
563
|
const awarenessReferenceText = readPrimitiveAsset("SELLING_AWARENESS_STAGES.md");
|
|
@@ -1566,6 +1616,181 @@ const audioTrimPrimitive = definePrimitive({
|
|
|
1566
1616
|
}
|
|
1567
1617
|
}
|
|
1568
1618
|
});
|
|
1619
|
+
const ttsPrimitive = definePrimitive({
|
|
1620
|
+
id: PRIMITIVE_TTS_ID,
|
|
1621
|
+
kind: "tts",
|
|
1622
|
+
operations: {
|
|
1623
|
+
run: {
|
|
1624
|
+
description: "Generate spoken narration audio from text (TTS) with a promptable voice style, persisted as a reusable platform audio URL.",
|
|
1625
|
+
inputSchema: ttsPayloadSchema,
|
|
1626
|
+
workflow: "run"
|
|
1627
|
+
}
|
|
1628
|
+
},
|
|
1629
|
+
jobs: {
|
|
1630
|
+
async run(ctx, input) {
|
|
1631
|
+
const payload = ttsPayloadSchema.parse(input);
|
|
1632
|
+
ctx.logger.progress(0.12, "Generating speech audio");
|
|
1633
|
+
const provider = payload.provider ?? "openai";
|
|
1634
|
+
const model = payload.model ?? defaultSpeechModelFor("tts", provider) ?? "";
|
|
1635
|
+
const generated = await ctx.providers.generateSpeech({
|
|
1636
|
+
provider,
|
|
1637
|
+
model,
|
|
1638
|
+
text: payload.text,
|
|
1639
|
+
voice: payload.voice,
|
|
1640
|
+
instructions: payload.instructions,
|
|
1641
|
+
responseFormat: payload.output_format
|
|
1642
|
+
});
|
|
1643
|
+
// Gemini TTS answers in wav regardless of the requested format; name the
|
|
1644
|
+
// artifact after what actually came back.
|
|
1645
|
+
const extension = generated.contentType.includes("wav") ? "wav" : "mp3";
|
|
1646
|
+
const stored = await ctx.storage.putBuffer(`speech.${extension}`, generated.bytes, {
|
|
1647
|
+
contentType: generated.contentType,
|
|
1648
|
+
kind: "audio",
|
|
1649
|
+
metadata: {
|
|
1650
|
+
primitive: "tts",
|
|
1651
|
+
provider,
|
|
1652
|
+
model,
|
|
1653
|
+
voice: payload.voice ?? null,
|
|
1654
|
+
styleInstructions: payload.instructions ?? null
|
|
1655
|
+
}
|
|
1656
|
+
});
|
|
1657
|
+
ctx.logger.progress(1, "Speech generation complete");
|
|
1658
|
+
return {
|
|
1659
|
+
progress: 1,
|
|
1660
|
+
output: {
|
|
1661
|
+
files: compactUrls([stored.url]),
|
|
1662
|
+
primary_file_url: stored.url,
|
|
1663
|
+
audioUrl: stored.url,
|
|
1664
|
+
audio: {
|
|
1665
|
+
file_url: stored.url,
|
|
1666
|
+
content_type: generated.contentType,
|
|
1667
|
+
provider,
|
|
1668
|
+
model,
|
|
1669
|
+
voice: payload.voice ?? null,
|
|
1670
|
+
style_instructions: payload.instructions ?? null,
|
|
1671
|
+
text_length: payload.text.length
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
});
|
|
1678
|
+
const sttPrimitive = definePrimitive({
|
|
1679
|
+
id: PRIMITIVE_STT_ID,
|
|
1680
|
+
kind: "stt",
|
|
1681
|
+
operations: {
|
|
1682
|
+
run: {
|
|
1683
|
+
description: "Transcribe speech from a video or audio URL (STT) into a simple subtitle transcript plus speaker-attributed multi-narration segments.",
|
|
1684
|
+
inputSchema: sttPayloadSchema,
|
|
1685
|
+
workflow: "run"
|
|
1686
|
+
}
|
|
1687
|
+
},
|
|
1688
|
+
jobs: {
|
|
1689
|
+
async run(ctx, input) {
|
|
1690
|
+
const payload = sttPayloadSchema.parse(input);
|
|
1691
|
+
const sourceKind = payload.media_type === "auto" ? inferSpeechSourceKind(payload.source_url) : payload.media_type;
|
|
1692
|
+
let audioBytes;
|
|
1693
|
+
let audioContentType;
|
|
1694
|
+
let extractionMetadata = null;
|
|
1695
|
+
if (sourceKind === "audio") {
|
|
1696
|
+
ctx.logger.progress(0.1, "Fetching source audio");
|
|
1697
|
+
const response = await fetch(payload.source_url);
|
|
1698
|
+
if (!response.ok) {
|
|
1699
|
+
throw new Error(`Could not fetch source audio (${response.status}).`);
|
|
1700
|
+
}
|
|
1701
|
+
audioBytes = new Uint8Array(await response.arrayBuffer());
|
|
1702
|
+
audioContentType = response.headers.get("content-type")?.split(";")[0]?.trim() || "audio/mpeg";
|
|
1703
|
+
}
|
|
1704
|
+
else {
|
|
1705
|
+
// Videos (and unknown extensions) go through the ffmpeg seam: it
|
|
1706
|
+
// demuxes video AND normalizes odd audio containers into a small
|
|
1707
|
+
// mono mp3 the speech providers accept.
|
|
1708
|
+
ctx.logger.progress(0.1, "Extracting audio track from source", {
|
|
1709
|
+
executionMode: config.VIDFARM_PRIMITIVE_MEDIA_LAMBDA_URL ? "lambda" : "local_fallback"
|
|
1710
|
+
});
|
|
1711
|
+
const extracted = await executePrimitiveMediaOperation(ctx, {
|
|
1712
|
+
operation: "video_extract_audio",
|
|
1713
|
+
payload: {
|
|
1714
|
+
source_video_url: payload.source_url,
|
|
1715
|
+
output_format: "mp3",
|
|
1716
|
+
audio_bitrate_kbps: 64
|
|
1717
|
+
},
|
|
1718
|
+
outputKey: "source-audio.mp3"
|
|
1719
|
+
});
|
|
1720
|
+
if (!extracted.publicUrl) {
|
|
1721
|
+
throw new Error("Audio extraction produced no readable audio URL.");
|
|
1722
|
+
}
|
|
1723
|
+
const response = await fetch(extracted.publicUrl);
|
|
1724
|
+
if (!response.ok) {
|
|
1725
|
+
throw new Error(`Could not read extracted audio (${response.status}).`);
|
|
1726
|
+
}
|
|
1727
|
+
audioBytes = new Uint8Array(await response.arrayBuffer());
|
|
1728
|
+
audioContentType = extracted.contentType || "audio/mpeg";
|
|
1729
|
+
extractionMetadata = extracted.metadata ?? null;
|
|
1730
|
+
}
|
|
1731
|
+
if (audioBytes.byteLength > MAX_TRANSCRIBE_AUDIO_BYTES) {
|
|
1732
|
+
const sizeMb = Math.round(audioBytes.byteLength / (1024 * 1024));
|
|
1733
|
+
const maxMb = Math.round(MAX_TRANSCRIBE_AUDIO_BYTES / (1024 * 1024));
|
|
1734
|
+
throw new Error(`Audio is too large to transcribe (${sizeMb}MB > ${maxMb}MB, roughly 40 minutes of speech). Trim the source with /videos/trim or /audio/trim and transcribe in parts.`);
|
|
1735
|
+
}
|
|
1736
|
+
ctx.logger.progress(0.45, "Transcribing speech");
|
|
1737
|
+
const provider = payload.provider ?? "gemini";
|
|
1738
|
+
const model = payload.model ?? defaultSpeechModelFor("stt", provider) ?? "";
|
|
1739
|
+
const transcription = await ctx.providers.transcribeSpeech({
|
|
1740
|
+
provider,
|
|
1741
|
+
model,
|
|
1742
|
+
audio: audioBytes,
|
|
1743
|
+
contentType: audioContentType,
|
|
1744
|
+
prompt: payload.prompt,
|
|
1745
|
+
language: payload.language,
|
|
1746
|
+
diarize: payload.diarize
|
|
1747
|
+
});
|
|
1748
|
+
ctx.logger.progress(0.85, "Storing transcript artifacts");
|
|
1749
|
+
const speakers = [...new Set(transcription.segments.map((segment) => segment.speaker))];
|
|
1750
|
+
const srt = buildSrtFromSegments(transcription.segments);
|
|
1751
|
+
const storedText = await ctx.storage.putText("transcript.txt", transcription.text || "");
|
|
1752
|
+
const storedSrt = srt ? await ctx.storage.putText("transcript.srt", srt, "application/x-subrip; charset=utf-8") : null;
|
|
1753
|
+
const storedJson = await ctx.storage.putJson("transcript.json", {
|
|
1754
|
+
source_url: payload.source_url,
|
|
1755
|
+
provider,
|
|
1756
|
+
model,
|
|
1757
|
+
language: transcription.language,
|
|
1758
|
+
diarization: transcription.diarization,
|
|
1759
|
+
text: transcription.text,
|
|
1760
|
+
speakers,
|
|
1761
|
+
segments: transcription.segments,
|
|
1762
|
+
...(extractionMetadata ? { extraction: extractionMetadata } : {})
|
|
1763
|
+
});
|
|
1764
|
+
const inlineOk = (transcription.text?.length ?? 0) <= MAX_INLINE_TRANSCRIPT_CHARS;
|
|
1765
|
+
ctx.logger.progress(1, "Speech transcription complete");
|
|
1766
|
+
return {
|
|
1767
|
+
progress: 1,
|
|
1768
|
+
output: {
|
|
1769
|
+
files: compactUrls([storedJson.url, storedSrt?.url, storedText.url]),
|
|
1770
|
+
primary_file_url: storedJson.url,
|
|
1771
|
+
sourceUrl: payload.source_url,
|
|
1772
|
+
text: inlineOk ? transcription.text : transcription.text.slice(0, MAX_INLINE_TRANSCRIPT_CHARS),
|
|
1773
|
+
text_truncated: !inlineOk,
|
|
1774
|
+
language: transcription.language,
|
|
1775
|
+
diarization: transcription.diarization,
|
|
1776
|
+
// Format 1 — SIMPLE SUBTITLE VERSION: plain transcript + SRT cues (no speakers).
|
|
1777
|
+
subtitles: {
|
|
1778
|
+
srt: inlineOk ? srt : null,
|
|
1779
|
+
srt_url: storedSrt?.url ?? null,
|
|
1780
|
+
text_url: storedText.url
|
|
1781
|
+
},
|
|
1782
|
+
// Format 2 — ADVANCED MULTI-SPEAKER VERSION: speaker-attributed timed segments.
|
|
1783
|
+
speakers,
|
|
1784
|
+
segments: inlineOk ? transcription.segments : undefined,
|
|
1785
|
+
transcript: {
|
|
1786
|
+
json_url: storedJson.url,
|
|
1787
|
+
content_type: "application/json"
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
};
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
});
|
|
1569
1794
|
const videoProbePrimitive = definePrimitive({
|
|
1570
1795
|
id: PRIMITIVE_VIDEO_PROBE_ID,
|
|
1571
1796
|
kind: "video_probe",
|
|
@@ -2142,6 +2367,8 @@ class PrimitiveRegistry {
|
|
|
2142
2367
|
[videoConcatPrimitive.id, videoConcatPrimitive],
|
|
2143
2368
|
[audioConcatPrimitive.id, audioConcatPrimitive],
|
|
2144
2369
|
[audioNormalizePrimitive.id, audioNormalizePrimitive],
|
|
2370
|
+
[ttsPrimitive.id, ttsPrimitive],
|
|
2371
|
+
[sttPrimitive.id, sttPrimitive],
|
|
2145
2372
|
[hyperframesRenderPrimitive.id, hyperframesRenderPrimitive],
|
|
2146
2373
|
[brainstormHooksPrimitive.id, brainstormHooksPrimitive],
|
|
2147
2374
|
[brainstormAwarenessStagesPrimitive.id, brainstormAwarenessStagesPrimitive],
|
|
@@ -2266,6 +2493,11 @@ function defaultTextModelForProvider(provider) {
|
|
|
2266
2493
|
return "gpt-5.4-mini";
|
|
2267
2494
|
}
|
|
2268
2495
|
}
|
|
2496
|
+
// Extensions that are safe to feed a speech provider without the ffmpeg
|
|
2497
|
+
// normalization pass; everything else (video or unknown) gets demuxed first.
|
|
2498
|
+
function inferSpeechSourceKind(sourceUrl) {
|
|
2499
|
+
return /\.(mp3|m4a|aac|ogg|oga|flac|wav)(\?|#|$)/i.test(sourceUrl) ? "audio" : "video";
|
|
2500
|
+
}
|
|
2269
2501
|
function normalizeAiVideoDurationSeconds(explicitSeconds, milliseconds) {
|
|
2270
2502
|
const seconds = Number(explicitSeconds);
|
|
2271
2503
|
if (Number.isFinite(seconds) && seconds > 0) {
|
|
Binary file
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Provider error taxonomy + HTTP-response classification, shared by every
|
|
2
|
+
// surface that talks to an AI provider directly: ProviderService (cloud, leased
|
|
3
|
+
// BYOK keys) and the speech module (also driven key-less from the devcli with a
|
|
4
|
+
// local env key). Lives outside providers.ts so speech.ts can import it without
|
|
5
|
+
// a circular dependency.
|
|
6
|
+
export class ProviderKeyUnavailableError extends Error {
|
|
7
|
+
}
|
|
8
|
+
export class ProviderAuthError extends Error {
|
|
9
|
+
}
|
|
10
|
+
export class ProviderQuotaExceededError extends Error {
|
|
11
|
+
}
|
|
12
|
+
export class ProviderWaitExceededError extends Error {
|
|
13
|
+
}
|
|
14
|
+
export class ProviderLeaseTimeoutError extends Error {
|
|
15
|
+
retryAfterSeconds;
|
|
16
|
+
constructor(message, retryAfterSeconds = 5) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class ProviderRateLimitError extends Error {
|
|
22
|
+
retryAfterSeconds;
|
|
23
|
+
constructor(message, retryAfterSeconds = 60) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export class ProviderTransientError extends Error {
|
|
29
|
+
retryAfterSeconds;
|
|
30
|
+
constructor(message, retryAfterSeconds = 5) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function parseRetryAfterSeconds(value) {
|
|
36
|
+
if (!value) {
|
|
37
|
+
return 60;
|
|
38
|
+
}
|
|
39
|
+
const numeric = Number(value);
|
|
40
|
+
if (Number.isFinite(numeric) && numeric > 0) {
|
|
41
|
+
return Math.max(1, Math.min(Math.ceil(numeric), 60 * 60));
|
|
42
|
+
}
|
|
43
|
+
const parsedDate = Date.parse(value);
|
|
44
|
+
if (Number.isFinite(parsedDate)) {
|
|
45
|
+
return Math.max(1, Math.min(Math.ceil((parsedDate - Date.now()) / 1000), 60 * 60));
|
|
46
|
+
}
|
|
47
|
+
return 60;
|
|
48
|
+
}
|
|
49
|
+
export function isHardQuotaError(details) {
|
|
50
|
+
const normalized = details.toLowerCase();
|
|
51
|
+
return [
|
|
52
|
+
"insufficient_quota",
|
|
53
|
+
"quota_exceeded",
|
|
54
|
+
"exceeded your current quota",
|
|
55
|
+
"billing",
|
|
56
|
+
"insufficient credits",
|
|
57
|
+
"not enough credits",
|
|
58
|
+
"credit balance",
|
|
59
|
+
"prepaid credits",
|
|
60
|
+
"resource_exhausted"
|
|
61
|
+
].some((pattern) => normalized.includes(pattern));
|
|
62
|
+
}
|
|
63
|
+
export function conciseProviderDetails(details) {
|
|
64
|
+
const compact = details.replace(/\s+/g, " ").trim();
|
|
65
|
+
return compact ? `: ${compact.slice(0, 500)}` : "";
|
|
66
|
+
}
|
|
67
|
+
export async function rateLimitOrQuotaError(provider, response) {
|
|
68
|
+
const details = await response.text();
|
|
69
|
+
if (isHardQuotaError(details)) {
|
|
70
|
+
return new ProviderQuotaExceededError(`${provider} quota or billing limit was reached${conciseProviderDetails(details)}`);
|
|
71
|
+
}
|
|
72
|
+
return new ProviderRateLimitError(`${provider} rate limited${conciseProviderDetails(details)}`, parseRetryAfterSeconds(response.headers.get("retry-after")));
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=provider-errors.js.map
|