@mevdragon/vidfarm-devcli 0.12.0 → 0.13.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/SKILL.director.md +6 -3
- package/SKILL.platform.md +2 -2
- package/demo/dist/app.css +1 -1
- package/demo/dist/app.js +121 -62
- package/dist/src/app.js +738 -106
- package/dist/src/cli.js +18 -4
- package/dist/src/composition-runtime.js +64 -6
- package/dist/src/devcli/clips.js +5 -0
- package/dist/src/editor-chat.js +1 -1
- package/dist/src/frontend/homepage-view.js +1 -1
- package/dist/src/frontend/template-editor-chat.js +3 -1
- package/dist/src/homepage.js +7 -0
- package/dist/src/primitive-registry.js +237 -47
- package/dist/src/services/providers.js +1 -0
- package/dist/src/services/serverless-records.js +12 -2
- package/dist/src/services/storage.js +12 -0
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +23 -23
- package/public/assets/page-runtime-client-app.js +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -81,6 +81,9 @@ Discover & inspiration (browse the viral-video catalog, add your own source):
|
|
|
81
81
|
local MP4/MOV/WebM file — as a private (files: presign + PUT via
|
|
82
82
|
template. --title (uploads; defaults to the /discover/templates/upload)
|
|
83
83
|
template id), --tagline, --notes optional.
|
|
84
|
+
--raw (uploads) marks it as your own raw
|
|
85
|
+
project footage — the editor won't auto-
|
|
86
|
+
prompt Decompose on it.
|
|
84
87
|
inspiration-rm <id> Delete a private inspiration/template you own → DELETE /discover/templates/:id
|
|
85
88
|
inspiration-decompose <id> AI-decompose an inspiration into scenes → POST /api/v1/inspirations/:id/decompose
|
|
86
89
|
|
|
@@ -621,6 +624,11 @@ async function runServeCommand(argv) {
|
|
|
621
624
|
// local-render by definition — cloud rendering is the EXPLICIT
|
|
622
625
|
// render_target="cloud" upstream handoff (the editor's "Render in Cloud").
|
|
623
626
|
process.env.VIDFARM_JOB_STATE_MACHINE_ARN = "";
|
|
627
|
+
// Same trap for clip hunts: a dev-shell .env carrying the staging clip-scan
|
|
628
|
+
// ARN would route this box's /clips/scan to the cloud Step Functions. serve
|
|
629
|
+
// hunts are LOCAL-FIRST (local agent CLI / BYOK keys); the upstream cloud is
|
|
630
|
+
// the explicit backup inside the scan route itself.
|
|
631
|
+
process.env.VIDFARM_CLIP_SCAN_STATE_MACHINE_ARN = "";
|
|
624
632
|
// Same trap as the state machine ARN: a dev-shell .env can carry the staging
|
|
625
633
|
// media-lambda URL, which would route probe/thumbnail/trim/etc. to a cloud
|
|
626
634
|
// Lambda that cannot fetch this box's localhost storage URLs ("fetch
|
|
@@ -1265,7 +1273,7 @@ async function runVideosCommand(argv) {
|
|
|
1265
1273
|
emitResult(result, ctx.json, [["Browse ", discoverFrontendUrl(ctx.host)]]);
|
|
1266
1274
|
}
|
|
1267
1275
|
async function runInspirationAddCommand(argv) {
|
|
1268
|
-
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), title: { type: "string" }, tagline: { type: "string" }, notes: { type: "string" } } });
|
|
1276
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), title: { type: "string" }, tagline: { type: "string" }, notes: { type: "string" }, raw: { type: "boolean", default: false } } });
|
|
1269
1277
|
const source = parsed.positionals[0];
|
|
1270
1278
|
if (!source)
|
|
1271
1279
|
throw new Error("inspiration-add requires a video URL (TikTok/YouTube/Instagram/X) or a local video file path.");
|
|
@@ -1285,7 +1293,10 @@ async function runInspirationAddCommand(argv) {
|
|
|
1285
1293
|
fileName: path.basename(localPath),
|
|
1286
1294
|
title: parsed.values.title,
|
|
1287
1295
|
tagline: parsed.values.tagline,
|
|
1288
|
-
notes: parsed.values.notes
|
|
1296
|
+
notes: parsed.values.notes,
|
|
1297
|
+
// --raw: this is the user's own footage starting a new project, not an
|
|
1298
|
+
// inspiration to study — the editor skips the auto Decompose prompt.
|
|
1299
|
+
origin: parsed.values.raw ? "raw" : undefined
|
|
1289
1300
|
});
|
|
1290
1301
|
emitResult(result, ctx.json, [["Discover ", discoverFrontendUrl(ctx.host)]]);
|
|
1291
1302
|
return;
|
|
@@ -1380,7 +1391,7 @@ async function ingestUploadedVideo(ctx, input) {
|
|
|
1380
1391
|
host: ctx.host,
|
|
1381
1392
|
path: "/discover/templates",
|
|
1382
1393
|
auth: ctx.auth,
|
|
1383
|
-
body: { upload: { storage_key: storageKey, file_name: uploadedName }, title: input.title, tagline: input.tagline, notes: input.notes }
|
|
1394
|
+
body: { upload: { storage_key: storageKey, file_name: uploadedName }, title: input.title, tagline: input.tagline, notes: input.notes, origin: input.origin }
|
|
1384
1395
|
});
|
|
1385
1396
|
assertApiOk(result, "ingest");
|
|
1386
1397
|
return result;
|
|
@@ -1555,7 +1566,10 @@ async function runCreateCommand(argv) {
|
|
|
1555
1566
|
throw new Error(`create: could not fetch the generated video (${dl.status}).`);
|
|
1556
1567
|
const buffer = Buffer.from(await dl.arrayBuffer());
|
|
1557
1568
|
const title = parsed.values.title ?? prompt.slice(0, 80);
|
|
1558
|
-
|
|
1569
|
+
// origin:"raw" — a prompt-generated video is the user's own new project, not
|
|
1570
|
+
// an imported inspiration; the editor must not auto-prompt Decompose (matters
|
|
1571
|
+
// especially with --no-decompose).
|
|
1572
|
+
const add = await ingestUploadedVideo(ctx, { buffer, fileName: "generated.mp4", contentType: "video/mp4", title, origin: "raw" });
|
|
1559
1573
|
const inspirationId = add.json?.inspiration_id;
|
|
1560
1574
|
if (!inspirationId)
|
|
1561
1575
|
throw new Error("create: ingest did not return an inspiration id.");
|
|
@@ -229,16 +229,65 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
|
|
|
229
229
|
// Burns there is nothing to normalize — the scrub is simply the absolute
|
|
230
230
|
// offset into the layer. Paused-by-stylesheet + scrubbed-here matches the
|
|
231
231
|
// render producer's CSS adapter exactly.
|
|
232
|
+
//
|
|
233
|
+
// Stability: Chrome PERMANENTLY drops a paused fill:none CSS animation the
|
|
234
|
+
// moment its currentTime is pushed past the effect end, and never re-creates
|
|
235
|
+
// it while the computed animation-name is unchanged — so one forward scrub
|
|
236
|
+
// across a word's window would leave that word stuck in its resting state
|
|
237
|
+
// (invisible for word-pop/stack-up) on every later frame. Past the end, a
|
|
238
|
+
// parked fill:none animation looks identical to the resting styles, so park
|
|
239
|
+
// at 0 instead of overshooting. fill:forwards presets (karaoke-fill,
|
|
240
|
+
// cumulative-reveal) must overshoot to hold their final keyframe — finished
|
|
241
|
+
// + forwards animations keep applying their effect and survive. If a word's
|
|
242
|
+
// animation is missing anyway (dropped before this guard, or an innerHTML
|
|
243
|
+
// morph replaced the spans mid-scrub), restart it declaratively by cycling
|
|
244
|
+
// animation-name through none.
|
|
245
|
+
function captionWordAnimationFor(word) {
|
|
246
|
+
let anims = [];
|
|
247
|
+
try { anims = word.getAnimations(); } catch { return null; }
|
|
248
|
+
for (const anim of anims) {
|
|
249
|
+
if (typeof anim.animationName === "string" && anim.animationName.indexOf("vf-cap-") === 0) return anim;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
232
254
|
function syncCaptionClip(clip, timing) {
|
|
233
255
|
if (typeof clip.getAnimations !== "function") return;
|
|
234
256
|
const offsetMs = Math.max(0, (time - timing.start)) * 1000;
|
|
235
|
-
let
|
|
236
|
-
try {
|
|
237
|
-
for (const
|
|
238
|
-
if (typeof
|
|
257
|
+
let words = [];
|
|
258
|
+
try { words = Array.from(clip.querySelectorAll('[data-cap-word="true"]')); } catch { return; }
|
|
259
|
+
for (const word of words) {
|
|
260
|
+
if (typeof word.getAnimations !== "function") continue;
|
|
261
|
+
let anim = captionWordAnimationFor(word);
|
|
262
|
+
if (!anim) {
|
|
263
|
+
const inlineEndMs =
|
|
264
|
+
((parseFloat(word.style.animationDelay) || 0) + (parseFloat(word.style.animationDuration) || 0)) * 1000;
|
|
265
|
+
let needed = offsetMs <= inlineEndMs;
|
|
266
|
+
if (!needed) {
|
|
267
|
+
try { needed = getComputedStyle(word).animationFillMode.indexOf("forward") !== -1 || getComputedStyle(word).animationFillMode.indexOf("both") !== -1; } catch {}
|
|
268
|
+
}
|
|
269
|
+
if (!needed) continue;
|
|
270
|
+
try {
|
|
271
|
+
word.style.animationName = "none";
|
|
272
|
+
void word.offsetWidth;
|
|
273
|
+
word.style.removeProperty("animation-name");
|
|
274
|
+
} catch { continue; }
|
|
275
|
+
anim = captionWordAnimationFor(word);
|
|
276
|
+
if (!anim) continue;
|
|
277
|
+
}
|
|
278
|
+
let target = offsetMs;
|
|
279
|
+
try {
|
|
280
|
+
const computed = anim.effect && typeof anim.effect.getComputedTiming === "function"
|
|
281
|
+
? anim.effect.getComputedTiming()
|
|
282
|
+
: null;
|
|
283
|
+
if (computed && typeof computed.endTime === "number" && offsetMs >= computed.endTime
|
|
284
|
+
&& computed.fill !== "forwards" && computed.fill !== "both") {
|
|
285
|
+
target = 0;
|
|
286
|
+
}
|
|
287
|
+
} catch {}
|
|
239
288
|
try {
|
|
240
289
|
anim.pause();
|
|
241
|
-
anim.currentTime =
|
|
290
|
+
anim.currentTime = target;
|
|
242
291
|
} catch {}
|
|
243
292
|
}
|
|
244
293
|
}
|
|
@@ -369,7 +418,16 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
|
|
|
369
418
|
return playbackRate;
|
|
370
419
|
},
|
|
371
420
|
setMuted,
|
|
372
|
-
refreshClips
|
|
421
|
+
refreshClips,
|
|
422
|
+
// Re-scrub caption word animations at the current playhead without a full
|
|
423
|
+
// apply(). The editor calls this after live-patching a caption layer's
|
|
424
|
+
// DOM (style/text edits rebuild the word spans, whose fresh CSS-paused
|
|
425
|
+
// animations sit at currentTime 0 — resting/invisible — until scrubbed).
|
|
426
|
+
syncCaptions() {
|
|
427
|
+
for (const clip of Array.from(document.querySelectorAll("[data-caption-animation]"))) {
|
|
428
|
+
if (clip instanceof HTMLElement) syncCaptionClip(clip, readTiming(clip));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
373
431
|
};
|
|
374
432
|
|
|
375
433
|
refreshClips();
|
package/dist/src/devcli/clips.js
CHANGED
|
@@ -166,6 +166,9 @@ async function runScan(argv) {
|
|
|
166
166
|
const guidance = buildEffectiveGuidance({ contentPrompt: guidancePromptRaw, avoidText }) || undefined;
|
|
167
167
|
// ── BACKUP path: run the hunt on the deployed pipeline (--cloud) ──────────
|
|
168
168
|
if (values.cloud) {
|
|
169
|
+
const apiProvider = values.provider && !["agent", "local", "local-agent"].includes(values.provider)
|
|
170
|
+
? normalizeProvider(values.provider)
|
|
171
|
+
: undefined; // unset → the cloud auto-picks from the account's saved keys
|
|
169
172
|
return runScanCloud({
|
|
170
173
|
videoArg: positionals[0],
|
|
171
174
|
sourceUrl: values.url?.trim() || undefined,
|
|
@@ -173,6 +176,7 @@ async function runScan(argv) {
|
|
|
173
176
|
apiKey: values["api-key"],
|
|
174
177
|
tracer: values.tracer,
|
|
175
178
|
prompt: guidancePromptRaw,
|
|
179
|
+
provider: apiProvider,
|
|
176
180
|
windows: rawWindows,
|
|
177
181
|
durationBand,
|
|
178
182
|
aspect,
|
|
@@ -431,6 +435,7 @@ async function runScanCloud(input) {
|
|
|
431
435
|
body: JSON.stringify({
|
|
432
436
|
...(input.sourceUrl ? { source_url: input.sourceUrl } : { temp_file_id: tempFileId, filename: fileName }),
|
|
433
437
|
prompt: input.prompt ?? "",
|
|
438
|
+
...(input.provider ? { provider: input.provider } : {}),
|
|
434
439
|
...(input.tracer ? { tracer: input.tracer } : {}),
|
|
435
440
|
hunt_spec: {
|
|
436
441
|
...(input.windows.length ? { windows: input.windows } : {}),
|
package/dist/src/editor-chat.js
CHANGED
|
@@ -80,7 +80,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
|
|
|
80
80
|
"Use set_layer_visual for geometry (x, y, width, height in %, plus font_size and border_radius in px). Use set_layer_style for text/color styling (color, background, background_style plain|outline|highlight-solid|highlight-translucent, font_family, font_weight, italic, underline, text_align, border_radius, font_size). Use set_layer_media to swap src, volume, muted, playback_start, or object_fit on media layers. Use set_layer_timing for start, duration, track index, playback_start.",
|
|
81
81
|
"KEN BURNS / ANIMATE STILL IMAGES: every still-image layer supports a built-in Ken Burns effect — a slow pan or zoom that spans the clip's full duration in both the preview and the final render. Apply it with editor_action set_layer_media and the ken_burns field (presets: zoom-in, zoom-out, pan-left, pan-right, pan-up, pan-down, zoom-in-left, zoom-in-right; 'none' removes it), optionally ken_burns_intensity (0.04-0.5, default 0.18; ~0.1 subtle, ~0.3 dramatic). When the user says 'animate the images', 'make the photos move', 'ken burns', or similar, apply a preset to EVERY still-image layer in one pass (one set_layer_media call per layer), varying presets across adjacent clips — e.g. zoom-in, then pan-left, then zoom-out — so consecutive stills don't repeat the same motion. Zoom presets suit subjects centered in frame (products, faces); pan presets suit wide scenery or tall screenshots. You can also seed ken_burns directly on add_layer (kind=image) or generate_layer (media_type=image) so freshly placed stills arrive already animated. It only applies to image layers — never set it on video, text, or audio.",
|
|
82
82
|
"SCENE TRANSITIONS: every visual clip supports a first-class transition at its start — the clip animates IN over the previous clip on its track, which is automatically held on screen beneath it for the transition window, in both the preview and the final render. Apply it with editor_action set_layer_media and the transition field ON THE INCOMING CLIP — the clip AFTER the cut, never the one before it (presets: crossfade, fade-black, slide-left/right/up/down, wipe-left/right/up/down, zoom-in, zoom-out, circle-open; 'none' removes it), optionally transition_duration (0.1-2.5s, default 0.5; ~0.3 snappy, ~1.0 slow cinematic). When the user says 'add transitions', 'smooth out the cuts', 'crossfade between scenes', or similar, apply a transition to EVERY scene clip except the first in one pass (one set_layer_media call per clip). Match the preset to the template's energy: crossfade/fade-black read calm and cinematic, slide/wipe read energetic (vary directions across cuts), zoom-in/zoom-out/circle-open read punchy and meme-y; keep ONE family per video unless asked otherwise. You can also seed transition directly on add_layer or generate_layer so a newly placed scene arrives with its entrance. Transitions belong on video/image scene clips — never on audio; on text overlays prefer them only when the user asks. Timing is handled for you — never move clip starts or durations to 'make room' for a transition.",
|
|
83
|
-
"ANIMATED CAPTIONS (word-by-word, TikTok/CapCut style): the composition supports first-class animated captions — caption layers whose words animate one at a time in both the preview and the final render. Available preset looks (caption_style): spotlight (active word gets a rounded highlight pill on bold outlined text — the classic Hormozi/CapCut look), karaoke (words fill with a color as they are spoken and stay lit), word-pop (one word at a time, punchy scale-in), stack-up (words fade/rise in and accumulate), neon (active word pulses with a glow), bounce (active word bounces). When the user asks to 'add captions', 'add animated captions', 'add subtitles', or similar:
|
|
83
|
+
"ANIMATED CAPTIONS (word-by-word, TikTok/CapCut style): the composition supports first-class animated captions — caption layers whose words animate one at a time in both the preview and the final render. Available preset looks (caption_style): spotlight (active word gets a rounded highlight pill on bold outlined text — the classic Hormozi/CapCut look), karaoke (words fill with a color as they are spoken and stay lit), word-pop (one word at a time, punchy scale-in), stack-up (words fade/rise in and accumulate), neon (active word pulses with a glow), bounce (active word bounces). When the user asks to 'add captions', 'add animated captions', 'add subtitles', or similar, pick the cue source by where the speech lives: (a) narration that needs TRANSCRIBING — a TTS voiceover layer, replaced audio, or when word-accurate karaoke timing matters — call the captions primitive: POST /api/v1/primitives/audio/captions with body { tracer, payload: { source_url: <the narration audio/video URL from editor_context>, style } }; the finished job's result carries captions (the cue array) and set_captions_action (fully-formed editor_action arguments) — apply them verbatim with editor_action action_type=set_captions. Real word timestamps need an OpenAI key (tried first; gemini/openrouter degrade to estimated word windows). (b) speech that is the SOURCE VIDEO's own audio on an already-decomposed fork — video_context already has the transcript's timestamped segments, so skip the job and call editor_action action_type=set_captions directly with captions=[{text, start, duration, words?}] cues derived from those segments — split segment text into pages of 3-5 words, keep each cue inside its segment's time window, and pass word timings when the transcript provides them (otherwise omit words and the editor estimates). Pick caption_style to match the template's voice (composition_context), or spotlight by default; override colors with caption_active_color / caption_highlight_color / color / background / background_style, and caption_uppercase for shouty formats. set_captions REPLACES all existing animated caption layers in one call — use it for 'restyle all the captions' too (same cues, new style) when cue text/timing is already in editor_context (each caption layer's text and caption_animation are listed there). For a single cue, set_layer_style with caption_* fields restyles just that layer, set_layer_text rewrites its words (timings re-estimate), and caption_animation='none' flattens it back to static text. Never hand-build word-by-word captions out of many add_layer text layers — always use set_captions. There is also a ONE-STEP server route, POST /api/v1/compositions/:forkId/captions { audio_url?|text?, style?, ... }, which transcribes the fork's own narration and writes the caption layers into the working composition server-side — use it ONLY for headless/automation flows, never while this editor session is open (the editor's next auto-save would overwrite the server-side change; in the editor always mutate through editor_action set_captions).",
|
|
84
84
|
"Use duplicate_layer to clone an existing layer; supply layer_key for the source, and optionally start/duration/track/label on the duplicate. Use split_layer with layer_key and split_time to cut at a moment in the timeline. Use group_layers with layer_keys (>=2) and optional group_label; ungroup_layers with layer_keys of any grouped members.",
|
|
85
85
|
"Prefer one editor_action call per mutation. For a multi-layer composition, sequence calls: generate or fetch each asset, then add or edit each layer with its own editor_action call. Chain edits by reusing the layer_key you assigned during add_layer.",
|
|
86
86
|
"Layer/track ordering is the ONLY thing that controls visual stacking. `track` is both the timeline layer index AND the CSS z-index — the editor forces `z-index = track` on every layer at publish time. Higher track draws visually on top; lower track draws underneath. There is no separate z-index override — if the AI wants a layer above another, give it a higher track. If html/text overlays should sit ABOVE a video/image, their track must be a larger integer than the visual media below them.",
|
|
@@ -378,7 +378,7 @@ function TemplateCard(input) {
|
|
|
378
378
|
? _jsx(LazyVideo, { src: template.previewUrl, controls: false })
|
|
379
379
|
: _jsx("img", { className: "preview", src: template.previewUrl, alt: `${template.title} preview`, loading: "lazy", decoding: "async" })
|
|
380
380
|
: _jsx("div", { className: "preview preview-empty", children: template.status === "processing" ? "Downloading…" : "No preview" });
|
|
381
|
-
return (_jsxs("li", { className: "discover-card row", "data-template-id": template.templateId, children: [template.previewUrl ? (_jsxs("button", { className: "discover-card-media discover-card-media-button", type: "button", "aria-label": `Open ${template.title} preview`, onClick: () => input.onOpenPreview?.(template), children: [media, _jsx("span", { className: "discover-card-media-affordance", "aria-hidden": "true", children: "View" })] })) : (_jsx("div", { className: "discover-card-media", children: media })), _jsxs("div", { className: "discover-card-copy", children: [_jsxs("div", { className: "discover-card-head", children: [_jsxs("div", { className: "discover-card-title-wrap", children: [_jsx("h2", { children: template.title }), _jsx("p", { className: "discover-card-meta", children: formatTemplateMeta(template) })] }), _jsxs("div", { className: "discover-card-actions", children: [_jsx(BookmarkButton, { templateId: template.templateId, title: template.title, isBookmarked: input.isBookmarked, onToggleBookmark: input.onToggleBookmark }), isPending ? (_jsx("span", { className: "cta-button discover-card-cta-disabled", "aria-disabled": "true", children: template.status === "failed" ? "Failed" : "Preparing…" })) : (_jsx(CreateButton, { templateId: template.templateId, accountUserId: input.accountUserId })), _jsx(CardMenu, { template: template, isBookmarked: input.isBookmarked, onToggleBookmark: input.onToggleBookmark, onDeleteTemplate: input.onDeleteTemplate, accountUserId: input.accountUserId })] })] }), _jsx("div", { className: "discover-card-body", children: _jsx("p", { children: String(template.viralDna || "").trim() || "No layer strategy provided for this composition yet." }) }), _jsxs("div", { className: "discover-card-tags", children: [template.visibility === "private" ? (_jsx("div", { className: "pill-tag private-pill", title: "Only visible to your account", children: "Only you" })) : null, template.status === "processing" ? (_jsx("div", { className: "pill-tag processing-pill", children: "Processing" })) : template.status === "failed" ? (_jsx("div", { className: "pill-tag failed-pill", children: "Download failed" })) : null, _jsx("div", { className: "pill-tag difficulty-pill", "data-difficulty": template.difficulty, children: formatDifficulty(template.difficulty) }), _jsx("div", { className: "pill-tag", children: template.sourceType || (isVideoPreview(template.previewUrl) ? "Video source" : "Image sequence") }), _jsx(CopyInlineButton, { text: template.templateId, label: template.templateId })] })] })] }));
|
|
381
|
+
return (_jsxs("li", { className: "discover-card row", "data-template-id": template.templateId, children: [template.previewUrl ? (_jsxs("button", { className: "discover-card-media discover-card-media-button", type: "button", "aria-label": `Open ${template.title} preview`, onClick: () => input.onOpenPreview?.(template), children: [media, _jsx("span", { className: "discover-card-media-affordance", "aria-hidden": "true", children: "View" })] })) : (_jsx("div", { className: "discover-card-media", children: media })), _jsxs("div", { className: "discover-card-copy", children: [_jsxs("div", { className: "discover-card-head", children: [_jsxs("div", { className: "discover-card-title-wrap", children: [_jsx("h2", { children: template.title }), _jsx("p", { className: "discover-card-meta", children: formatTemplateMeta(template) })] }), _jsxs("div", { className: "discover-card-actions", children: [_jsx(BookmarkButton, { templateId: template.templateId, title: template.title, isBookmarked: input.isBookmarked, onToggleBookmark: input.onToggleBookmark }), isPending ? (_jsx("span", { className: "cta-button discover-card-cta-disabled", "aria-disabled": "true", children: template.status === "failed" ? "Failed" : "Preparing…" })) : (_jsx(CreateButton, { templateId: template.templateId, accountUserId: input.accountUserId })), _jsx(CardMenu, { template: template, isBookmarked: input.isBookmarked, onToggleBookmark: input.onToggleBookmark, onDeleteTemplate: input.onDeleteTemplate, accountUserId: input.accountUserId })] })] }), _jsx("div", { className: "discover-card-body", children: _jsx("p", { children: String(template.viralDna || "").trim() || "No layer strategy provided for this composition yet." }) }), _jsxs("div", { className: "discover-card-tags", children: [template.origin === "raw" ? (_jsx("div", { className: "pill-tag origin-pill", title: "Your own project \u2014 created from a prompt or your raw clips, not an imported inspiration", children: "Your project" })) : null, template.visibility === "private" ? (_jsx("div", { className: "pill-tag private-pill", title: "Only visible to your account", children: "Only you" })) : null, template.status === "processing" ? (_jsx("div", { className: "pill-tag processing-pill", children: "Processing" })) : template.status === "failed" ? (_jsx("div", { className: "pill-tag failed-pill", children: "Download failed" })) : null, _jsx("div", { className: "pill-tag difficulty-pill", "data-difficulty": template.difficulty, children: formatDifficulty(template.difficulty) }), _jsx("div", { className: "pill-tag", children: template.sourceType || (isVideoPreview(template.previewUrl) ? "Video source" : "Image sequence") }), _jsx(CopyInlineButton, { text: template.templateId, label: template.templateId })] })] })] }));
|
|
382
382
|
}
|
|
383
383
|
function PreviewModal(input) {
|
|
384
384
|
const { onClose, template } = input;
|
|
@@ -3624,10 +3624,12 @@ export function TemplateEditorChat({ boot }) {
|
|
|
3624
3624
|
}
|
|
3625
3625
|
if (!storageKey)
|
|
3626
3626
|
throw new Error("upload did not return a storage key.");
|
|
3627
|
+
// origin:"raw" — a prompt-generated video is the user's own new project,
|
|
3628
|
+
// not an imported inspiration; the editor must not auto-prompt Decompose.
|
|
3627
3629
|
const addRes = await fetch(new URL("/discover/templates", origin), {
|
|
3628
3630
|
method: "POST",
|
|
3629
3631
|
headers: jsonHeaders,
|
|
3630
|
-
body: JSON.stringify({ upload: { storage_key: storageKey, file_name: uploadedName }, title })
|
|
3632
|
+
body: JSON.stringify({ upload: { storage_key: storageKey, file_name: uploadedName }, title, origin: "raw" })
|
|
3631
3633
|
});
|
|
3632
3634
|
const add = await readJson(addRes);
|
|
3633
3635
|
if (!addRes.ok)
|
package/dist/src/homepage.js
CHANGED
|
@@ -366,6 +366,13 @@ export function renderHomepage(input) {
|
|
|
366
366
|
font-weight: 800;
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
+
.origin-pill {
|
|
370
|
+
border-color: rgba(46, 160, 100, 0.35);
|
|
371
|
+
background: rgba(46, 160, 100, 0.1);
|
|
372
|
+
color: #1f7a4d;
|
|
373
|
+
font-weight: 800;
|
|
374
|
+
}
|
|
375
|
+
|
|
369
376
|
.processing-pill {
|
|
370
377
|
border-color: rgba(88, 132, 196, 0.4);
|
|
371
378
|
background: rgba(88, 132, 196, 0.12);
|
|
@@ -8,6 +8,8 @@ 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
10
|
import { buildSrtFromSegments, defaultSpeechModelFor, inferSpeechProviderForVoice, speechVoiceMismatch } from "./services/speech.js";
|
|
11
|
+
import { buildCaptionCues, cuesFromText } from "./services/captions.js";
|
|
12
|
+
import { CAPTION_ANIMATIONS, CAPTION_STYLE_PRESETS, captionStylePresetById } from "./hyperframes/composition.js";
|
|
11
13
|
import { buildHtmlImageCompositionHtml, buildMediaDedupeCompositionHtml, DEDUPE_DEFAULT_EFFECTS } from "./primitives/hyperframes-media.js";
|
|
12
14
|
const imageProviderSchema = z.enum(["openai", "gemini", "openrouter"]);
|
|
13
15
|
const videoProviderSchema = z.enum(["openai", "gemini", "openrouter"]);
|
|
@@ -406,9 +408,58 @@ const sttPayloadSchema = z.preprocess((raw) => {
|
|
|
406
408
|
source_url: mediaSourceUrlSchema,
|
|
407
409
|
media_type: z.enum(["auto", "video", "audio"]).default("auto"),
|
|
408
410
|
diarize: z.boolean().default(true),
|
|
411
|
+
// Word-level timings on each segment (animated captions). Real timestamps
|
|
412
|
+
// come from the openai path (whisper-1); other providers keep segment-level
|
|
413
|
+
// output and downstream callers estimate word windows.
|
|
414
|
+
word_timestamps: z.boolean().default(false),
|
|
409
415
|
language: z.string().min(2).max(16).optional(),
|
|
410
416
|
prompt: z.string().min(1).max(2000).optional()
|
|
411
417
|
}));
|
|
418
|
+
// Animated captions from speech: STT with word-level timings, paged into
|
|
419
|
+
// ready-to-apply cue layers. Either transcribe a source_url or page plain text.
|
|
420
|
+
const captionsPayloadSchema = z.preprocess((raw) => {
|
|
421
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
422
|
+
return raw;
|
|
423
|
+
const payload = raw;
|
|
424
|
+
return {
|
|
425
|
+
...payload,
|
|
426
|
+
source_url: payload.source_url ??
|
|
427
|
+
payload.source_video_url ??
|
|
428
|
+
payload.source_audio_url ??
|
|
429
|
+
payload.source_media_url ??
|
|
430
|
+
payload.video_url ??
|
|
431
|
+
payload.audio_url ??
|
|
432
|
+
payload.url
|
|
433
|
+
};
|
|
434
|
+
}, z.object({
|
|
435
|
+
provider: speechProviderSchema.optional(),
|
|
436
|
+
model: z.string().min(1).optional(),
|
|
437
|
+
source_url: mediaSourceUrlSchema.optional(),
|
|
438
|
+
media_type: z.enum(["auto", "video", "audio"]).default("auto"),
|
|
439
|
+
/** Skip STT entirely: page this script across the window instead. */
|
|
440
|
+
text: z.string().min(1).max(20_000).optional(),
|
|
441
|
+
language: z.string().min(2).max(16).optional(),
|
|
442
|
+
prompt: z.string().min(1).max(2000).optional(),
|
|
443
|
+
/** Named look: spotlight | karaoke | word-pop | stack-up | neon | bounce. */
|
|
444
|
+
style: z.string().min(1).max(32).optional(),
|
|
445
|
+
animation: z.enum(CAPTION_ANIMATIONS).optional(),
|
|
446
|
+
active_color: z.string().min(1).max(64).optional(),
|
|
447
|
+
highlight_color: z.string().min(1).max(64).optional(),
|
|
448
|
+
uppercase: z.boolean().optional(),
|
|
449
|
+
color: z.string().min(1).max(64).optional(),
|
|
450
|
+
background: z.string().min(1).max(64).optional(),
|
|
451
|
+
background_style: z.enum(["plain", "outline", "highlight-solid", "highlight-translucent"]).optional(),
|
|
452
|
+
font_family: z.string().min(1).max(64).optional(),
|
|
453
|
+
font_size: z.number().int().min(8).max(200).optional(),
|
|
454
|
+
max_words_per_cue: z.number().int().min(1).max(8).default(4),
|
|
455
|
+
/** Shift every cue by this many seconds (where the narration layer starts). */
|
|
456
|
+
offset_sec: z.number().min(0).max(3600).default(0),
|
|
457
|
+
/** Text mode window: where the paged script starts and how long it spans. */
|
|
458
|
+
window_start: z.number().min(0).max(3600).default(0),
|
|
459
|
+
window_duration: z.number().positive().max(3600).optional()
|
|
460
|
+
}).refine((payload) => Boolean(payload.source_url || payload.text), {
|
|
461
|
+
message: "Provide source_url (audio/video to transcribe) or text (script to page)."
|
|
462
|
+
}));
|
|
412
463
|
const probeVideoPayloadSchema = z.object({
|
|
413
464
|
source_video_url: mediaSourceUrlSchema
|
|
414
465
|
});
|
|
@@ -561,6 +612,7 @@ const PRIMITIVE_BRAINSTORM_PRODUCT_PLACEMENT_ID = "primitive:brainstorm_product_
|
|
|
561
612
|
const PRIMITIVE_MEDIA_DEDUPE_ID = "primitive:media_dedupe";
|
|
562
613
|
const PRIMITIVE_TTS_ID = "primitive:tts";
|
|
563
614
|
const PRIMITIVE_STT_ID = "primitive:stt";
|
|
615
|
+
const PRIMITIVE_CAPTIONS_ID = "primitive:captions";
|
|
564
616
|
// Gemini inline audio caps out around 20MB; at the 64kbps mono mp3 we extract,
|
|
565
617
|
// that is roughly 40 minutes of speech.
|
|
566
618
|
const MAX_TRANSCRIBE_AUDIO_BYTES = 20 * 1024 * 1024;
|
|
@@ -1686,6 +1738,55 @@ const ttsPrimitive = definePrimitive({
|
|
|
1686
1738
|
}
|
|
1687
1739
|
}
|
|
1688
1740
|
});
|
|
1741
|
+
// Shared source→speech-ready-audio loader for the STT-family primitives.
|
|
1742
|
+
// Plain audio URLs pass through; videos (and unknown containers) go through
|
|
1743
|
+
// the ffmpeg seam, which demuxes and normalizes into a small mono mp3 the
|
|
1744
|
+
// speech providers accept.
|
|
1745
|
+
async function loadSpeechAudioForPrimitive(ctx, payload) {
|
|
1746
|
+
const sourceKind = payload.media_type === "auto" ? inferSpeechSourceKind(payload.source_url) : payload.media_type;
|
|
1747
|
+
let audioBytes;
|
|
1748
|
+
let audioContentType;
|
|
1749
|
+
let extractionMetadata = null;
|
|
1750
|
+
if (sourceKind === "audio") {
|
|
1751
|
+
ctx.logger.progress(0.1, "Fetching source audio");
|
|
1752
|
+
const response = await fetch(payload.source_url);
|
|
1753
|
+
if (!response.ok) {
|
|
1754
|
+
throw new Error(`Could not fetch source audio (${response.status}).`);
|
|
1755
|
+
}
|
|
1756
|
+
audioBytes = new Uint8Array(await response.arrayBuffer());
|
|
1757
|
+
audioContentType = response.headers.get("content-type")?.split(";")[0]?.trim() || "audio/mpeg";
|
|
1758
|
+
}
|
|
1759
|
+
else {
|
|
1760
|
+
ctx.logger.progress(0.1, "Extracting audio track from source", {
|
|
1761
|
+
executionMode: config.VIDFARM_PRIMITIVE_MEDIA_LAMBDA_URL ? "lambda" : "local_fallback"
|
|
1762
|
+
});
|
|
1763
|
+
const extracted = await executePrimitiveMediaOperation(ctx, {
|
|
1764
|
+
operation: "video_extract_audio",
|
|
1765
|
+
payload: {
|
|
1766
|
+
source_video_url: payload.source_url,
|
|
1767
|
+
output_format: "mp3",
|
|
1768
|
+
audio_bitrate_kbps: 64
|
|
1769
|
+
},
|
|
1770
|
+
outputKey: "source-audio.mp3"
|
|
1771
|
+
});
|
|
1772
|
+
if (!extracted.publicUrl) {
|
|
1773
|
+
throw new Error("Audio extraction produced no readable audio URL.");
|
|
1774
|
+
}
|
|
1775
|
+
const response = await fetch(extracted.publicUrl);
|
|
1776
|
+
if (!response.ok) {
|
|
1777
|
+
throw new Error(`Could not read extracted audio (${response.status}).`);
|
|
1778
|
+
}
|
|
1779
|
+
audioBytes = new Uint8Array(await response.arrayBuffer());
|
|
1780
|
+
audioContentType = extracted.contentType || "audio/mpeg";
|
|
1781
|
+
extractionMetadata = extracted.metadata ?? null;
|
|
1782
|
+
}
|
|
1783
|
+
if (audioBytes.byteLength > MAX_TRANSCRIBE_AUDIO_BYTES) {
|
|
1784
|
+
const sizeMb = Math.round(audioBytes.byteLength / (1024 * 1024));
|
|
1785
|
+
const maxMb = Math.round(MAX_TRANSCRIBE_AUDIO_BYTES / (1024 * 1024));
|
|
1786
|
+
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.`);
|
|
1787
|
+
}
|
|
1788
|
+
return { audioBytes, audioContentType, extractionMetadata };
|
|
1789
|
+
}
|
|
1689
1790
|
const sttPrimitive = definePrimitive({
|
|
1690
1791
|
id: PRIMITIVE_STT_ID,
|
|
1691
1792
|
kind: "stt",
|
|
@@ -1699,53 +1800,9 @@ const sttPrimitive = definePrimitive({
|
|
|
1699
1800
|
jobs: {
|
|
1700
1801
|
async run(ctx, input) {
|
|
1701
1802
|
const payload = sttPayloadSchema.parse(input);
|
|
1702
|
-
const
|
|
1703
|
-
let audioBytes;
|
|
1704
|
-
let audioContentType;
|
|
1705
|
-
let extractionMetadata = null;
|
|
1706
|
-
if (sourceKind === "audio") {
|
|
1707
|
-
ctx.logger.progress(0.1, "Fetching source audio");
|
|
1708
|
-
const response = await fetch(payload.source_url);
|
|
1709
|
-
if (!response.ok) {
|
|
1710
|
-
throw new Error(`Could not fetch source audio (${response.status}).`);
|
|
1711
|
-
}
|
|
1712
|
-
audioBytes = new Uint8Array(await response.arrayBuffer());
|
|
1713
|
-
audioContentType = response.headers.get("content-type")?.split(";")[0]?.trim() || "audio/mpeg";
|
|
1714
|
-
}
|
|
1715
|
-
else {
|
|
1716
|
-
// Videos (and unknown extensions) go through the ffmpeg seam: it
|
|
1717
|
-
// demuxes video AND normalizes odd audio containers into a small
|
|
1718
|
-
// mono mp3 the speech providers accept.
|
|
1719
|
-
ctx.logger.progress(0.1, "Extracting audio track from source", {
|
|
1720
|
-
executionMode: config.VIDFARM_PRIMITIVE_MEDIA_LAMBDA_URL ? "lambda" : "local_fallback"
|
|
1721
|
-
});
|
|
1722
|
-
const extracted = await executePrimitiveMediaOperation(ctx, {
|
|
1723
|
-
operation: "video_extract_audio",
|
|
1724
|
-
payload: {
|
|
1725
|
-
source_video_url: payload.source_url,
|
|
1726
|
-
output_format: "mp3",
|
|
1727
|
-
audio_bitrate_kbps: 64
|
|
1728
|
-
},
|
|
1729
|
-
outputKey: "source-audio.mp3"
|
|
1730
|
-
});
|
|
1731
|
-
if (!extracted.publicUrl) {
|
|
1732
|
-
throw new Error("Audio extraction produced no readable audio URL.");
|
|
1733
|
-
}
|
|
1734
|
-
const response = await fetch(extracted.publicUrl);
|
|
1735
|
-
if (!response.ok) {
|
|
1736
|
-
throw new Error(`Could not read extracted audio (${response.status}).`);
|
|
1737
|
-
}
|
|
1738
|
-
audioBytes = new Uint8Array(await response.arrayBuffer());
|
|
1739
|
-
audioContentType = extracted.contentType || "audio/mpeg";
|
|
1740
|
-
extractionMetadata = extracted.metadata ?? null;
|
|
1741
|
-
}
|
|
1742
|
-
if (audioBytes.byteLength > MAX_TRANSCRIBE_AUDIO_BYTES) {
|
|
1743
|
-
const sizeMb = Math.round(audioBytes.byteLength / (1024 * 1024));
|
|
1744
|
-
const maxMb = Math.round(MAX_TRANSCRIBE_AUDIO_BYTES / (1024 * 1024));
|
|
1745
|
-
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.`);
|
|
1746
|
-
}
|
|
1803
|
+
const { audioBytes, audioContentType, extractionMetadata } = await loadSpeechAudioForPrimitive(ctx, payload);
|
|
1747
1804
|
ctx.logger.progress(0.45, "Transcribing speech");
|
|
1748
|
-
const provider = payload.provider ?? "gemini";
|
|
1805
|
+
const provider = payload.provider ?? (payload.word_timestamps ? "openai" : "gemini");
|
|
1749
1806
|
const model = payload.model ?? defaultSpeechModelFor("stt", provider) ?? "";
|
|
1750
1807
|
const transcription = await ctx.providers.transcribeSpeech({
|
|
1751
1808
|
provider,
|
|
@@ -1754,7 +1811,8 @@ const sttPrimitive = definePrimitive({
|
|
|
1754
1811
|
contentType: audioContentType,
|
|
1755
1812
|
prompt: payload.prompt,
|
|
1756
1813
|
language: payload.language,
|
|
1757
|
-
diarize: payload.diarize
|
|
1814
|
+
diarize: payload.diarize,
|
|
1815
|
+
wordTimestamps: payload.word_timestamps
|
|
1758
1816
|
});
|
|
1759
1817
|
ctx.logger.progress(0.85, "Storing transcript artifacts");
|
|
1760
1818
|
const speakers = [...new Set(transcription.segments.map((segment) => segment.speaker))];
|
|
@@ -1802,6 +1860,137 @@ const sttPrimitive = definePrimitive({
|
|
|
1802
1860
|
}
|
|
1803
1861
|
}
|
|
1804
1862
|
});
|
|
1863
|
+
// Speech → animated captions convenience primitive. One job: transcribe the
|
|
1864
|
+
// narration WITH word-level timings (openai/whisper-1 real timestamps, other
|
|
1865
|
+
// providers estimated), page it into CapCut-style cues, and return the cue
|
|
1866
|
+
// list shaped EXACTLY for the editor's `editor_action set_captions` — the
|
|
1867
|
+
// output even carries a ready `set_captions_action` object so an agent can
|
|
1868
|
+
// pipe it through without any cue math. Composition-agnostic: it never
|
|
1869
|
+
// mutates a fork; POST /api/v1/compositions/:forkId/captions is the one-step
|
|
1870
|
+
// variant that applies server-side.
|
|
1871
|
+
const captionsPrimitive = definePrimitive({
|
|
1872
|
+
id: PRIMITIVE_CAPTIONS_ID,
|
|
1873
|
+
kind: "captions",
|
|
1874
|
+
operations: {
|
|
1875
|
+
run: {
|
|
1876
|
+
description: "Transcribe narration (or page a script) into animated word-by-word caption cues ready for the editor's set_captions action.",
|
|
1877
|
+
inputSchema: captionsPayloadSchema,
|
|
1878
|
+
workflow: "run"
|
|
1879
|
+
}
|
|
1880
|
+
},
|
|
1881
|
+
jobs: {
|
|
1882
|
+
async run(ctx, input) {
|
|
1883
|
+
const payload = captionsPayloadSchema.parse(input);
|
|
1884
|
+
const preset = payload.style
|
|
1885
|
+
? captionStylePresetById(payload.style)
|
|
1886
|
+
: payload.animation
|
|
1887
|
+
? null
|
|
1888
|
+
: CAPTION_STYLE_PRESETS[0];
|
|
1889
|
+
if (payload.style && !preset) {
|
|
1890
|
+
throw new Error(`Unknown caption style "${payload.style}". Use one of: ${CAPTION_STYLE_PRESETS.map((entry) => entry.id).join(", ")} — or pass animation directly.`);
|
|
1891
|
+
}
|
|
1892
|
+
const animation = payload.animation ?? preset?.animation ?? "highlight-pop";
|
|
1893
|
+
let cues;
|
|
1894
|
+
let wordTimestampSource = "estimated";
|
|
1895
|
+
let transcriptText = null;
|
|
1896
|
+
let transcriptArtifacts = {};
|
|
1897
|
+
if (payload.text?.trim()) {
|
|
1898
|
+
const tokens = payload.text.trim().split(/\s+/).filter(Boolean);
|
|
1899
|
+
const windowDuration = payload.window_duration ?? Math.max(0.4, tokens.length * 0.32);
|
|
1900
|
+
cues = cuesFromText(payload.text, payload.window_start + payload.offset_sec, windowDuration, {
|
|
1901
|
+
maxWordsPerCue: payload.max_words_per_cue
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
else {
|
|
1905
|
+
const { audioBytes, audioContentType } = await loadSpeechAudioForPrimitive(ctx, {
|
|
1906
|
+
source_url: payload.source_url,
|
|
1907
|
+
media_type: payload.media_type
|
|
1908
|
+
});
|
|
1909
|
+
ctx.logger.progress(0.45, "Transcribing speech with word timings");
|
|
1910
|
+
// openai first: whisper-1 is the only provider with REAL word-level
|
|
1911
|
+
// timestamps; providerAttempts falls back to the caller's other keys.
|
|
1912
|
+
const provider = payload.provider ?? "openai";
|
|
1913
|
+
const model = payload.model ?? defaultSpeechModelFor("stt", provider) ?? "";
|
|
1914
|
+
const transcription = await ctx.providers.transcribeSpeech({
|
|
1915
|
+
provider,
|
|
1916
|
+
model,
|
|
1917
|
+
audio: audioBytes,
|
|
1918
|
+
contentType: audioContentType,
|
|
1919
|
+
prompt: payload.prompt,
|
|
1920
|
+
language: payload.language,
|
|
1921
|
+
diarize: false,
|
|
1922
|
+
wordTimestamps: true
|
|
1923
|
+
});
|
|
1924
|
+
if (!transcription.segments.length) {
|
|
1925
|
+
throw new Error("Transcription returned no speech segments — the source may be silent.");
|
|
1926
|
+
}
|
|
1927
|
+
transcriptText = transcription.text || null;
|
|
1928
|
+
wordTimestampSource = transcription.segments.some((segment) => segment.words?.length) ? "provider" : "estimated";
|
|
1929
|
+
ctx.logger.progress(0.7, "Paging transcript into caption cues");
|
|
1930
|
+
cues = buildCaptionCues(transcription.segments, {
|
|
1931
|
+
maxWordsPerCue: payload.max_words_per_cue,
|
|
1932
|
+
offsetSec: payload.offset_sec
|
|
1933
|
+
});
|
|
1934
|
+
const srt = buildSrtFromSegments(transcription.segments);
|
|
1935
|
+
const storedSrt = srt ? await ctx.storage.putText("transcript.srt", srt, "application/x-subrip; charset=utf-8") : null;
|
|
1936
|
+
if (storedSrt?.url)
|
|
1937
|
+
transcriptArtifacts.srt_url = storedSrt.url;
|
|
1938
|
+
}
|
|
1939
|
+
if (!cues.length) {
|
|
1940
|
+
throw new Error("No caption cues produced from the source.");
|
|
1941
|
+
}
|
|
1942
|
+
// Style echo in the exact field names editor_action set_captions takes,
|
|
1943
|
+
// so the agent can spread this straight into the action.
|
|
1944
|
+
const styleFields = {
|
|
1945
|
+
...(preset ? { caption_style: preset.id } : {}),
|
|
1946
|
+
caption_animation: animation,
|
|
1947
|
+
...(payload.active_color ?? preset?.activeColor ? { caption_active_color: payload.active_color ?? preset?.activeColor } : {}),
|
|
1948
|
+
...(payload.highlight_color ?? preset?.highlightColor ? { caption_highlight_color: payload.highlight_color ?? preset?.highlightColor } : {}),
|
|
1949
|
+
...((payload.uppercase ?? preset?.uppercase) !== undefined ? { caption_uppercase: payload.uppercase ?? preset?.uppercase } : {}),
|
|
1950
|
+
...(payload.color ?? preset?.color ? { color: payload.color ?? preset?.color } : {}),
|
|
1951
|
+
...(payload.background ?? preset?.background ? { background: payload.background ?? preset?.background } : {}),
|
|
1952
|
+
...(payload.background_style ?? preset?.textBackgroundStyle ? { background_style: payload.background_style ?? preset?.textBackgroundStyle } : {}),
|
|
1953
|
+
...(payload.font_family ? { font_family: payload.font_family } : {}),
|
|
1954
|
+
...(payload.font_size ? { font_size: payload.font_size } : {})
|
|
1955
|
+
};
|
|
1956
|
+
const setCaptionsAction = {
|
|
1957
|
+
action_type: "set_captions",
|
|
1958
|
+
captions: cues,
|
|
1959
|
+
...styleFields,
|
|
1960
|
+
explanation: "Apply transcribed animated captions."
|
|
1961
|
+
};
|
|
1962
|
+
ctx.logger.progress(0.85, "Storing caption artifacts");
|
|
1963
|
+
const storedCaptions = await ctx.storage.putJson("captions.json", {
|
|
1964
|
+
source_url: payload.source_url ?? null,
|
|
1965
|
+
word_timestamps: wordTimestampSource,
|
|
1966
|
+
style: styleFields,
|
|
1967
|
+
cue_count: cues.length,
|
|
1968
|
+
cues,
|
|
1969
|
+
set_captions_action: setCaptionsAction,
|
|
1970
|
+
...(transcriptText ? { transcript_text: transcriptText } : {})
|
|
1971
|
+
});
|
|
1972
|
+
const cuesInline = JSON.stringify(cues).length <= 60_000;
|
|
1973
|
+
ctx.logger.progress(1, "Caption cues ready");
|
|
1974
|
+
return {
|
|
1975
|
+
progress: 1,
|
|
1976
|
+
output: {
|
|
1977
|
+
files: compactUrls([storedCaptions.url, transcriptArtifacts.srt_url]),
|
|
1978
|
+
primary_file_url: storedCaptions.url,
|
|
1979
|
+
cue_count: cues.length,
|
|
1980
|
+
word_timestamps: wordTimestampSource,
|
|
1981
|
+
...styleFields,
|
|
1982
|
+
// The cue list, ready to pass as editor_action set_captions `captions`.
|
|
1983
|
+
captions: cuesInline ? cues : undefined,
|
|
1984
|
+
captions_truncated: !cuesInline,
|
|
1985
|
+
// Fully-formed editor_action arguments — apply verbatim.
|
|
1986
|
+
set_captions_action: cuesInline ? setCaptionsAction : undefined,
|
|
1987
|
+
captions_json_url: storedCaptions.url,
|
|
1988
|
+
transcript: transcriptArtifacts.srt_url ? { srt_url: transcriptArtifacts.srt_url } : undefined
|
|
1989
|
+
}
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
});
|
|
1805
1994
|
const videoProbePrimitive = definePrimitive({
|
|
1806
1995
|
id: PRIMITIVE_VIDEO_PROBE_ID,
|
|
1807
1996
|
kind: "video_probe",
|
|
@@ -2380,6 +2569,7 @@ class PrimitiveRegistry {
|
|
|
2380
2569
|
[audioNormalizePrimitive.id, audioNormalizePrimitive],
|
|
2381
2570
|
[ttsPrimitive.id, ttsPrimitive],
|
|
2382
2571
|
[sttPrimitive.id, sttPrimitive],
|
|
2572
|
+
[captionsPrimitive.id, captionsPrimitive],
|
|
2383
2573
|
[hyperframesRenderPrimitive.id, hyperframesRenderPrimitive],
|
|
2384
2574
|
[brainstormHooksPrimitive.id, brainstormHooksPrimitive],
|
|
2385
2575
|
[brainstormAwarenessStagesPrimitive.id, brainstormAwarenessStagesPrimitive],
|
|
@@ -1555,6 +1555,7 @@ export class ProviderService {
|
|
|
1555
1555
|
prompt: input.prompt,
|
|
1556
1556
|
language: input.language,
|
|
1557
1557
|
diarize: input.diarize,
|
|
1558
|
+
wordTimestamps: input.wordTimestamps,
|
|
1558
1559
|
apiKey: lease.secret
|
|
1559
1560
|
})));
|
|
1560
1561
|
await this.serverlessProviderKeys.recordProviderKeyUsage({
|