@mevdragon/vidfarm-devcli 0.11.0 → 0.12.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 +17 -2
- package/SKILL.platform.md +16 -2
- package/demo/dist/app.js +191 -65
- package/dist/src/app.js +50 -4
- package/dist/src/cli.js +39 -2
- package/dist/src/composition-runtime.js +77 -1
- package/dist/src/devcli/captions.js +310 -0
- package/dist/src/devcli/composition-edit.js +142 -0
- package/dist/src/devcli/speech.js +4 -1
- package/dist/src/editor-chat.js +4 -2
- package/dist/src/hyperframes/composition.js +315 -0
- package/dist/src/primitive-registry.js +13 -2
- package/dist/src/services/captions.js +123 -0
- package/dist/src/services/hyperframes.js +113 -1
- package/dist/src/services/provider-errors.js +3 -0
- package/dist/src/services/providers.js +10 -4
- package/dist/src/services/speech.js +166 -4
- package/package.json +1 -1
package/dist/src/app.js
CHANGED
|
@@ -21,7 +21,7 @@ import { writeForkManifest } from "./services/fork-manifest.js";
|
|
|
21
21
|
import { renderHelpPage } from "./help-page.js";
|
|
22
22
|
import { renderHomepage } from "./homepage.js";
|
|
23
23
|
import { COMPOSITION_RUNTIME_SCRIPT_BODY } from "./composition-runtime.js";
|
|
24
|
-
import { KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER } from "./hyperframes/composition.js";
|
|
24
|
+
import { CAPTION_ANIM_CSS, CAPTION_STYLE_MARKER, KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER, TRANSITION_CSS, TRANSITION_STYLE_MARKER } from "./hyperframes/composition.js";
|
|
25
25
|
import { normalizeCompositionZIndexHtml, sanitizeCompositionHtml } from "./services/composition-sanitize.js";
|
|
26
26
|
import { applyMarkupUsd } from "./services/billing-pricing.js";
|
|
27
27
|
import { primitiveRegistry } from "./primitive-registry.js";
|
|
@@ -3089,7 +3089,7 @@ function buildPrimitiveEditorDocsRoutes() {
|
|
|
3089
3089
|
{ method: "POST", path: `${PRIMITIVES_PREFIX}/audio/probe`, summary: "Primitive audio probe job. Body must be { tracer, payload: { source_audio_url }, webhook_url? }. Returns durable JSON metadata." },
|
|
3090
3090
|
{ method: "POST", path: `${PRIMITIVES_PREFIX}/audio/concat`, summary: "Primitive audio concat job. Body must be { tracer, payload: { source_audio_urls, output_format?, audio_bitrate_kbps? }, webhook_url? }." },
|
|
3091
3091
|
{ method: "POST", path: `${PRIMITIVES_PREFIX}/audio/normalize`, summary: "Primitive audio normalization job. Body must be { tracer, payload: { source_audio_url, output_format?, audio_bitrate_kbps?, sample_rate_hz? }, webhook_url? }." },
|
|
3092
|
-
{ method: "POST", path: `${PRIMITIVES_PREFIX}/audio/speech`, summary: "Primitive text-to-speech (TTS) job using saved OpenAI, Gemini, or OpenRouter provider keys. Body must be { tracer, payload: { text, voice?, instructions?, provider?, model?, output_format? }, webhook_url? }. instructions is a free-form voice-style prompt (tone, pacing, accent, emotion, persona — e.g. \"calm, warm bedtime narrator with a slight British accent\"). Returns a durable platform audio URL (mp3 or wav). Alias: POST /api/v1/primitives/tts." },
|
|
3092
|
+
{ method: "POST", path: `${PRIMITIVES_PREFIX}/audio/speech`, summary: "Primitive text-to-speech (TTS) job using saved OpenAI, Gemini, or OpenRouter provider keys. Body must be { tracer, payload: { text, voice?, instructions?, provider?, model?, output_format? }, webhook_url? }. instructions is a free-form voice-style prompt (tone, pacing, accent, emotion, persona — e.g. \"calm, warm bedtime narrator with a slight British accent\"). voice is a provider preset (OpenAI: alloy/ash/coral/…; Gemini: Kore/Puck/Charon/…); provider is inferred from the voice when omitted, and a voice paired with the wrong provider is rejected with a 400. Returns a durable platform audio URL (mp3 or wav). Alias: POST /api/v1/primitives/tts." },
|
|
3093
3093
|
{ method: "POST", path: `${PRIMITIVES_PREFIX}/audio/transcribe`, summary: "Primitive speech-to-text (STT) job using saved provider keys. Accepts VIDEO or AUDIO sources; video audio is demuxed automatically. Body must be { tracer, payload: { source_url (aliases: source_video_url, source_audio_url, url), diarize?, language?, prompt?, provider?, model? }, webhook_url? }. Returns TWO formats in one job: a simple subtitle transcript (plain text + timed SRT cues, no speakers) and an advanced multi-speaker version (speaker-attributed timed segments for multi-narration), plus transcript.json/.srt/.txt artifacts. diarize defaults to true; speaker attribution needs a Gemini key (openai/openrouter degrade to a plain single-speaker transcript). Sources over ~40 minutes of speech must be trimmed first. Alias: POST /api/v1/primitives/stt." },
|
|
3094
3094
|
{ method: "POST", path: `${PRIMITIVES_PREFIX}/brainstorm/coldstart`, summary: "Primitive cold-start strategy questionnaire. Body should usually be { tracer, payload: { user_message }, webhook_url? }. user_message can be rough, like \"I don't know where to start\" or a messy description. count is optional only when the user explicitly wants more or fewer questions. offer_description remains accepted as a backward-compatible alias." },
|
|
3095
3095
|
{ method: "POST", path: `${PRIMITIVES_PREFIX}/brainstorm/awareness_stages`, summary: "Primitive awareness-stage recommendation. Body must be { tracer, payload: { offer_description }, webhook_url? }. Use this when the customer is unsure what type of ads to produce." },
|
|
@@ -4187,8 +4187,9 @@ function createEditorActionTool() {
|
|
|
4187
4187
|
"ungroup_layers",
|
|
4188
4188
|
"replace_composition_html",
|
|
4189
4189
|
"export_composition",
|
|
4190
|
-
"generate_layer"
|
|
4191
|
-
|
|
4190
|
+
"generate_layer",
|
|
4191
|
+
"set_captions"
|
|
4192
|
+
]).describe("Which mutation to perform. Use generate_layer to AI-generate a video/image and auto-place it on the timeline (fill a gap or replace a scene). Use export_composition to trigger the same Export button available in the editor UI — this queues an AWS Lambda render and the finished MP4 URL will surface in the next editor_context as last_export_url. Use set_captions to lay down a full run of ANIMATED word-by-word captions (TikTok/CapCut style): pass captions[] cues (or text + start/duration to auto-page), plus caption_style; it replaces any existing animated caption layers. To restyle existing captions without changing text/timing, use set_layer_style with caption_* fields on each caption layer."),
|
|
4192
4193
|
layer_key: z.string().optional().describe("Existing layer key from editor_context (required for remove/set/duplicate/split). MAY be either the element_id (data-hf-id) or the slug (data-hf-slug). For add_layer, optionally provide a stable id (starts with a letter, [A-Za-z0-9_-], <=64 chars) to reuse in later tool calls this turn."),
|
|
4193
4194
|
slug: z.string().optional().describe("snake_case slug for the layer. On add_layer, seeds data-hf-slug. On set_layer_identity, sets it. Slugs give viral DNA a stable handle for the element."),
|
|
4194
4195
|
note: z.string().optional().describe("Human-facing note explaining the layer's role, referenced by viral DNA. On add_layer seeds data-hf-note. On set_layer_identity, sets it. Empty string removes."),
|
|
@@ -4221,6 +4222,23 @@ function createEditorActionTool() {
|
|
|
4221
4222
|
object_position: z.string().optional().describe("CSS object-position for image/video (e.g., center, top, bottom left)."),
|
|
4222
4223
|
ken_burns: z.enum(["zoom-in", "zoom-out", "pan-left", "pan-right", "pan-up", "pan-down", "zoom-in-left", "zoom-in-right", "none"]).optional().describe("Ken Burns motion for still-image layers: a slow pan/zoom spanning the clip's full duration. Apply with set_layer_media on an existing image, or seed it on add_layer/generate_layer when kind/media_type is image. 'none' removes the effect. When the user says 'animate the images' / 'ken burns', apply it to every still image, varying presets across adjacent clips (zoom-in, pan-left, zoom-out, ...) for a documentary feel."),
|
|
4223
4224
|
ken_burns_intensity: z.number().optional().describe("Ken Burns strength: extra zoom / pan travel as a fraction of the frame, 0.04-0.5 (default 0.18; ~0.1 subtle, ~0.3 dramatic). Only meaningful alongside ken_burns."),
|
|
4225
|
+
transition: z.enum(["crossfade", "fade-black", "slide-left", "slide-right", "slide-up", "slide-down", "wipe-left", "wipe-right", "wipe-up", "wipe-down", "zoom-in", "zoom-out", "circle-open", "none"]).optional().describe("Scene transition INTO this clip at its start, from the previous clip on the same track. Set it on the INCOMING clip (the one after the cut) with set_layer_media, or seed it on add_layer/generate_layer. Works on video and image scene clips; 'none' removes it. The previous butt-cut clip is automatically held on screen beneath the incoming one for the transition window — do not retime anything yourself. When the user asks for 'transitions between scenes' / 'smooth cuts', apply one to EVERY scene clip except the first, varying or matching presets to the template's energy (crossfade/fade-black = calm, slide/wipe = energetic, zoom/circle-open = punchy)."),
|
|
4226
|
+
transition_duration: z.number().optional().describe("Transition length in seconds, 0.1-2.5 (default 0.5; ~0.3 snappy, ~1.0 slow cinematic). Only meaningful alongside transition."),
|
|
4227
|
+
caption_style: z.enum(["spotlight", "karaoke", "word-pop", "stack-up", "neon", "bounce"]).optional().describe("Animated caption preset look: spotlight (active word gets a highlight pill, bold outline text), karaoke (words fill with color as spoken), word-pop (one word at a time, punchy scale-in), stack-up (words fade/rise in and stay), neon (active word glow pulse), bounce (active word bounces). Applies animation + colors + background treatment in one go; individual caption_*/color/background/background_style fields override specifics."),
|
|
4228
|
+
caption_animation: z.enum(["highlight-pop", "karaoke-fill", "word-pop", "cumulative-reveal", "glow-pulse", "bounce-wave", "none"]).optional().describe("Word-by-word caption animation, if you want to set it directly instead of via caption_style. Works on set_captions, add_layer (kind=text), and set_layer_style. 'none' removes the animation and flattens the layer back to static text."),
|
|
4229
|
+
caption_active_color: z.string().optional().describe("CSS color the active word takes while it is spoken (karaoke fill color, highlight text color, glow color)."),
|
|
4230
|
+
caption_highlight_color: z.string().optional().describe("CSS color of the rounded pill behind the active word (highlight-pop animation only)."),
|
|
4231
|
+
caption_uppercase: z.boolean().optional().describe("Render caption words in uppercase."),
|
|
4232
|
+
captions: z.array(z.object({
|
|
4233
|
+
text: z.string().describe("Cue text — one page of ~3-5 words shown together."),
|
|
4234
|
+
start: z.number().describe("Cue start in seconds on the composition timeline."),
|
|
4235
|
+
duration: z.number().describe("Cue length in seconds (cues must not overlap)."),
|
|
4236
|
+
words: z.array(z.object({
|
|
4237
|
+
text: z.string(),
|
|
4238
|
+
start: z.number().describe("Word start in seconds RELATIVE to the cue start."),
|
|
4239
|
+
end: z.number().describe("Word end in seconds RELATIVE to the cue start.")
|
|
4240
|
+
})).optional().describe("Word-level timings relative to the cue start (e.g. from a transcript). Omit to auto-estimate across the cue.")
|
|
4241
|
+
})).optional().describe("set_captions: the full animated-caption cue list, one caption layer per cue on a shared free track. Replaces ALL existing animated caption layers. Derive cue timing from the video's transcript/segments when available. Alternatively omit this and pass text (+ start/duration window) to auto-page plain text."),
|
|
4224
4242
|
volume: z.number().optional().describe("Volume 0..1 for audio/video."),
|
|
4225
4243
|
muted: z.boolean().optional().describe("Toggle mute on audio/video."),
|
|
4226
4244
|
group_label: z.string().optional().describe("Optional label for group_layers."),
|
|
@@ -4615,6 +4633,34 @@ function rewriteHyperframesDraftCompositionHtml(html, draftPath) {
|
|
|
4615
4633
|
rewritten = `${rewritten}\n${kenBurnsTag}`;
|
|
4616
4634
|
}
|
|
4617
4635
|
}
|
|
4636
|
+
// Same deal for the animated-caption keyframes: applying a caption style in
|
|
4637
|
+
// the editor must animate immediately on older stored compositions.
|
|
4638
|
+
if (!rewritten.includes(CAPTION_STYLE_MARKER)) {
|
|
4639
|
+
const captionTag = `<style data-vf-captions="true">${CAPTION_ANIM_CSS}</style>`;
|
|
4640
|
+
if (/<\/head>/i.test(rewritten)) {
|
|
4641
|
+
rewritten = rewritten.replace(/<\/head>/i, `${captionTag}\n</head>`);
|
|
4642
|
+
}
|
|
4643
|
+
else if (/<\/body>/i.test(rewritten)) {
|
|
4644
|
+
rewritten = rewritten.replace(/<\/body>/i, `${captionTag}\n</body>`);
|
|
4645
|
+
}
|
|
4646
|
+
else {
|
|
4647
|
+
rewritten = `${rewritten}\n${captionTag}`;
|
|
4648
|
+
}
|
|
4649
|
+
}
|
|
4650
|
+
// And the scene-transition keyframes: toggling data-transition on a clip
|
|
4651
|
+
// must animate immediately on older stored compositions.
|
|
4652
|
+
if (!rewritten.includes(TRANSITION_STYLE_MARKER)) {
|
|
4653
|
+
const transitionTag = `<style data-vf-transitions="true">${TRANSITION_CSS}</style>`;
|
|
4654
|
+
if (/<\/head>/i.test(rewritten)) {
|
|
4655
|
+
rewritten = rewritten.replace(/<\/head>/i, `${transitionTag}\n</head>`);
|
|
4656
|
+
}
|
|
4657
|
+
else if (/<\/body>/i.test(rewritten)) {
|
|
4658
|
+
rewritten = rewritten.replace(/<\/body>/i, `${transitionTag}\n</body>`);
|
|
4659
|
+
}
|
|
4660
|
+
else {
|
|
4661
|
+
rewritten = `${rewritten}\n${transitionTag}`;
|
|
4662
|
+
}
|
|
4663
|
+
}
|
|
4618
4664
|
return rewritten;
|
|
4619
4665
|
}
|
|
4620
4666
|
async function renderHyperframesStudioEditorPage(input) {
|
package/dist/src/cli.js
CHANGED
|
@@ -9,8 +9,10 @@ import { spawnSync } from "node:child_process";
|
|
|
9
9
|
import { Readable } from "node:stream";
|
|
10
10
|
import { pipeline } from "node:stream/promises";
|
|
11
11
|
import { computeCompositionGaps, insertMediaLayer, inspectComposition, replaceLayerWithMedia } from "./devcli/composition-edit.js";
|
|
12
|
+
import { runCaptionsCommand } from "./devcli/captions.js";
|
|
12
13
|
import { runClipsCommand } from "./devcli/clips.js";
|
|
13
14
|
import { MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES, loadSpeechAudioLocally, localGenerateSpeech, localTranscribeSpeech, resolveLocalSpeechAuth } from "./devcli/speech.js";
|
|
15
|
+
import { inferSpeechProviderForVoice } from "./services/speech.js";
|
|
14
16
|
import { initTelemetry, reportCliCrash } from "./devcli/telemetry.js";
|
|
15
17
|
// vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
|
|
16
18
|
// `serve` command boots the FULL editor locally (single origin, disk-backed
|
|
@@ -141,6 +143,12 @@ Generate AI media and drop it on the timeline (for local coding agents):
|
|
|
141
143
|
zoom-out|pan-left|pan-right|pan-up|pan-down|
|
|
142
144
|
zoom-in-left|zoom-in-right
|
|
143
145
|
--ken-burns-intensity <n> Travel/zoom strength 0.04-0.5 (default 0.18)
|
|
146
|
+
--transition <preset> Scene transition INTO this clip (the previous
|
|
147
|
+
butt-cut clip is held beneath it automatically):
|
|
148
|
+
crossfade|fade-black|slide-left|slide-right|
|
|
149
|
+
slide-up|slide-down|wipe-left|wipe-right|wipe-up|
|
|
150
|
+
wipe-down|zoom-in|zoom-out|circle-open
|
|
151
|
+
--transition-duration <s> Transition length in seconds 0.1-2.5 (default 0.5)
|
|
144
152
|
remove-video-captions <forkId> Read the two video sources: → GET .../compositions/:forkId/remove-video-captions
|
|
145
153
|
original vs decomposed (caption-free),
|
|
146
154
|
non-billing (alias: ghostcut)
|
|
@@ -167,7 +175,9 @@ Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit bac
|
|
|
167
175
|
The VOICE STYLE is promptable via --style. OPENROUTER_API_KEY, no cloud job
|
|
168
176
|
--style "<direction>" Voice-style prompt: tone/pacing/accent/emotion/persona
|
|
169
177
|
(e.g. "calm, warm bedtime narrator", "excited sports announcer")
|
|
170
|
-
--voice <name> Provider voice preset (openai: alloy/ash/coral…; gemini: Kore/Puck…)
|
|
178
|
+
--voice <name> Provider voice preset (openai: alloy/ash/coral…; gemini: Kore/Puck…).
|
|
179
|
+
The provider is inferred from the voice when --provider is omitted;
|
|
180
|
+
a voice from the wrong provider family is rejected with a clear error.
|
|
171
181
|
--out <file> Output audio path (default tts-<id>.mp3|wav)
|
|
172
182
|
--provider <p> --model <m> --format mp3|wav
|
|
173
183
|
--cloud BACKUP: saved-key platform job → POST /api/v1/primitives/audio/speech (+ poll)
|
|
@@ -183,6 +193,17 @@ Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit bac
|
|
|
183
193
|
--provider <p> --model <m>
|
|
184
194
|
--cloud BACKUP: saved-key platform job → POST /api/v1/primitives/audio/transcribe (+ poll)
|
|
185
195
|
(a local file uploads to your temp folder first — 30-day TTL)
|
|
196
|
+
|
|
197
|
+
Animated captions (word-by-word TikTok/CapCut styles on a pulled/served composition):
|
|
198
|
+
captions generate <dir> Transcribe the narration & lay down animated LOCAL: local ffmpeg demux +
|
|
199
|
+
caption cue layers (openai = real word your provider key; writes
|
|
200
|
+
timestamps; gemini/openrouter estimated). composition.html on disk
|
|
201
|
+
--style <preset> spotlight|karaoke|word-pop|stack-up|neon|bounce (default spotlight)
|
|
202
|
+
--audio <file|url> | --srt <file> | --text "<script>" Pick the transcript source
|
|
203
|
+
--max-words <n> --active-color <css> --uppercase … (run 'vidfarm captions' for all flags)
|
|
204
|
+
captions style <dir> Restyle existing cues (same text/timings)
|
|
205
|
+
captions list <dir> Show the current animated caption cues [--json]
|
|
206
|
+
captions clear <dir> Remove all animated caption layers
|
|
186
207
|
versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
|
|
187
208
|
snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
|
|
188
209
|
render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
|
|
@@ -437,6 +458,9 @@ async function main() {
|
|
|
437
458
|
case "clips":
|
|
438
459
|
await runClipsCommand(rest);
|
|
439
460
|
return;
|
|
461
|
+
case "captions":
|
|
462
|
+
await runCaptionsCommand(rest);
|
|
463
|
+
return;
|
|
440
464
|
default:
|
|
441
465
|
if (!command.startsWith("-")) {
|
|
442
466
|
// Positional template id → default to booting the local editor.
|
|
@@ -1986,6 +2010,8 @@ function placeMediaOnDisk(input) {
|
|
|
1986
2010
|
objectFit: input.objectFit,
|
|
1987
2011
|
kenBurns: input.kenBurns,
|
|
1988
2012
|
kenBurnsIntensity: input.kenBurnsIntensity,
|
|
2013
|
+
transition: input.transition,
|
|
2014
|
+
transitionDuration: input.transitionDuration,
|
|
1989
2015
|
slug: input.slug
|
|
1990
2016
|
};
|
|
1991
2017
|
const result = input.replace
|
|
@@ -2017,6 +2043,8 @@ async function runGenerateCommand(argv) {
|
|
|
2017
2043
|
track: { type: "string" },
|
|
2018
2044
|
"ken-burns": { type: "string" },
|
|
2019
2045
|
"ken-burns-intensity": { type: "string" },
|
|
2046
|
+
transition: { type: "string" },
|
|
2047
|
+
"transition-duration": { type: "string" },
|
|
2020
2048
|
"layer-key": { type: "string" }
|
|
2021
2049
|
}
|
|
2022
2050
|
});
|
|
@@ -2090,6 +2118,8 @@ async function runGenerateCommand(argv) {
|
|
|
2090
2118
|
track: parsed.values.track ? Number(parsed.values.track) : undefined,
|
|
2091
2119
|
kenBurns: parsed.values["ken-burns"],
|
|
2092
2120
|
kenBurnsIntensity: parsed.values["ken-burns-intensity"] ? Number(parsed.values["ken-burns-intensity"]) : undefined,
|
|
2121
|
+
transition: parsed.values.transition,
|
|
2122
|
+
transitionDuration: parsed.values["transition-duration"] ? Number(parsed.values["transition-duration"]) : undefined,
|
|
2093
2123
|
layerKey: parsed.values["layer-key"]
|
|
2094
2124
|
});
|
|
2095
2125
|
if (!ctx.json)
|
|
@@ -2158,7 +2188,10 @@ async function runTtsCommand(argv) {
|
|
|
2158
2188
|
const voice = parsed.values.voice;
|
|
2159
2189
|
const json = Boolean(parsed.values.json);
|
|
2160
2190
|
if (!parsed.values.cloud) {
|
|
2161
|
-
|
|
2191
|
+
// A Gemini voice preset ("Kore", "Puck", …) should find a Gemini-capable
|
|
2192
|
+
// key first — openrouter's default TTS model is Gemini-family too.
|
|
2193
|
+
const keyPreference = inferSpeechProviderForVoice(voice) === "gemini" ? ["gemini", "openrouter", "openai"] : ["openai", "gemini", "openrouter"];
|
|
2194
|
+
const auth = resolveLocalSpeechAuth(parsed.values, keyPreference, parsed.values.provider);
|
|
2162
2195
|
if (!auth)
|
|
2163
2196
|
throw new Error(speechKeyHint("tts"));
|
|
2164
2197
|
if (!json)
|
|
@@ -2471,6 +2504,8 @@ async function runPlaceCommand(argv) {
|
|
|
2471
2504
|
"object-fit": { type: "string" },
|
|
2472
2505
|
"ken-burns": { type: "string" },
|
|
2473
2506
|
"ken-burns-intensity": { type: "string" },
|
|
2507
|
+
transition: { type: "string" },
|
|
2508
|
+
"transition-duration": { type: "string" },
|
|
2474
2509
|
"layer-key": { type: "string" },
|
|
2475
2510
|
slug: { type: "string" },
|
|
2476
2511
|
// Local file support: where a serve box serves /storage from, and which
|
|
@@ -2513,6 +2548,8 @@ async function runPlaceCommand(argv) {
|
|
|
2513
2548
|
objectFit: parsed.values["object-fit"],
|
|
2514
2549
|
kenBurns: parsed.values["ken-burns"],
|
|
2515
2550
|
kenBurnsIntensity: num(parsed.values["ken-burns-intensity"]),
|
|
2551
|
+
transition: parsed.values.transition,
|
|
2552
|
+
transitionDuration: num(parsed.values["transition-duration"]),
|
|
2516
2553
|
layerKey: parsed.values["layer-key"],
|
|
2517
2554
|
slug: parsed.values.slug
|
|
2518
2555
|
});
|
|
@@ -171,14 +171,88 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
// Scene transitions: a [data-transition] clip animates in (vf-tr-*
|
|
175
|
+
// keyframes) over its first data-transition-duration seconds, ON TOP of the
|
|
176
|
+
// previous clip on its track — so that previous clip must stay active for
|
|
177
|
+
// the transition window. Publish materializes this overlap into
|
|
178
|
+
// data-duration (normalizePublishHtml); the preview grants it live so
|
|
179
|
+
// butt-cut drafts preview exactly like the render.
|
|
180
|
+
function collectTransitionWindows() {
|
|
181
|
+
const windows = [];
|
|
182
|
+
for (const clip of clips) {
|
|
183
|
+
if (!(clip instanceof HTMLElement) || clip instanceof HTMLAudioElement) continue;
|
|
184
|
+
if (!clip.hasAttribute("data-transition")) continue;
|
|
185
|
+
const timing = readTiming(clip);
|
|
186
|
+
let transitionDuration = Number(clip.getAttribute("data-transition-duration"));
|
|
187
|
+
if (!Number.isFinite(transitionDuration) || transitionDuration <= 0) transitionDuration = 0.5;
|
|
188
|
+
windows.push({
|
|
189
|
+
track: Number(clip.getAttribute("data-track-index")) || 0,
|
|
190
|
+
start: timing.start,
|
|
191
|
+
duration: Math.min(transitionDuration, timing.duration)
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return windows;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function transitionHoldFor(clip, timing, windows) {
|
|
198
|
+
if (!windows.length) return 0;
|
|
199
|
+
if (!(clip instanceof HTMLElement) || clip instanceof HTMLAudioElement) return 0;
|
|
200
|
+
const track = Number(clip.getAttribute("data-track-index")) || 0;
|
|
201
|
+
let hold = 0;
|
|
202
|
+
for (const window of windows) {
|
|
203
|
+
if (window.track !== track) continue;
|
|
204
|
+
// Nominal-end adjacency: already-materialized holds (published HTML)
|
|
205
|
+
// shift this clip's end past the next start, fail the check, and are
|
|
206
|
+
// simply not re-granted — no double extension either way.
|
|
207
|
+
if (Math.abs(window.start - timing.end) > 0.05) continue;
|
|
208
|
+
if (window.duration > hold) hold = window.duration;
|
|
209
|
+
}
|
|
210
|
+
return hold;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function syncTransitionClip(clip, timing) {
|
|
214
|
+
if (typeof clip.getAnimations !== "function") return;
|
|
215
|
+
const offsetMs = Math.max(0, (time - timing.start)) * 1000;
|
|
216
|
+
let anims = [];
|
|
217
|
+
try { anims = clip.getAnimations({ subtree: true }); } catch { return; }
|
|
218
|
+
for (const anim of anims) {
|
|
219
|
+
if (typeof anim.animationName !== "string" || anim.animationName.indexOf("vf-tr-") !== 0) continue;
|
|
220
|
+
try {
|
|
221
|
+
anim.pause();
|
|
222
|
+
anim.currentTime = offsetMs;
|
|
223
|
+
} catch {}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Animated caption words carry vf-cap-* keyframes whose inline delay/
|
|
228
|
+
// duration ARE the word windows relative to the layer start, so unlike Ken
|
|
229
|
+
// Burns there is nothing to normalize — the scrub is simply the absolute
|
|
230
|
+
// offset into the layer. Paused-by-stylesheet + scrubbed-here matches the
|
|
231
|
+
// render producer's CSS adapter exactly.
|
|
232
|
+
function syncCaptionClip(clip, timing) {
|
|
233
|
+
if (typeof clip.getAnimations !== "function") return;
|
|
234
|
+
const offsetMs = Math.max(0, (time - timing.start)) * 1000;
|
|
235
|
+
let anims = [];
|
|
236
|
+
try { anims = clip.getAnimations({ subtree: true }); } catch { return; }
|
|
237
|
+
for (const anim of anims) {
|
|
238
|
+
if (typeof anim.animationName !== "string" || anim.animationName.indexOf("vf-cap-") !== 0) continue;
|
|
239
|
+
try {
|
|
240
|
+
anim.pause();
|
|
241
|
+
anim.currentTime = offsetMs;
|
|
242
|
+
} catch {}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
174
246
|
function apply(nextTime, options = {}) {
|
|
175
247
|
const forceSeek = Boolean(options.forceSeek);
|
|
176
248
|
const fromClock = Boolean(options.fromClock);
|
|
177
249
|
time = clampTime(nextTime);
|
|
178
250
|
let activeVideoProxy = null;
|
|
251
|
+
const transitionWindows = collectTransitionWindows();
|
|
179
252
|
for (const clip of clips) {
|
|
180
253
|
const timing = readTiming(clip);
|
|
181
|
-
const
|
|
254
|
+
const hold = transitionHoldFor(clip, timing, transitionWindows);
|
|
255
|
+
const active = time >= timing.start && time < timing.end + hold;
|
|
182
256
|
if (clip instanceof HTMLElement && !(clip instanceof HTMLAudioElement)) {
|
|
183
257
|
if (clip.getAttribute("data-vf-timeline-proxy") === "video") {
|
|
184
258
|
if (active) activeVideoProxy = clip;
|
|
@@ -186,6 +260,8 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
|
|
|
186
260
|
setVisibility(clip, active);
|
|
187
261
|
}
|
|
188
262
|
if (clip.hasAttribute("data-kenburns")) syncKenBurnsClip(clip, timing);
|
|
263
|
+
if (clip.hasAttribute("data-caption-animation")) syncCaptionClip(clip, timing);
|
|
264
|
+
if (clip.hasAttribute("data-transition")) syncTransitionClip(clip, timing);
|
|
189
265
|
}
|
|
190
266
|
if (clip instanceof HTMLMediaElement) {
|
|
191
267
|
applyMediaState(clip);
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// devcli `vidfarm captions …` — animated word-by-word captions (TikTok/CapCut
|
|
2
|
+
// style) for a composition.html on disk. LOCAL-FIRST like `place`/`stt`: the
|
|
3
|
+
// transcript comes from the user's own provider key (openai gives REAL
|
|
4
|
+
// word-level timings via whisper-1; gemini/openrouter fall back to estimated
|
|
5
|
+
// word windows), the DOM edit is a local file write, and a running
|
|
6
|
+
// `vidfarm serve` live-morphs the change into open editor tabs. No cloud job,
|
|
7
|
+
// no wallet. Feed it an --srt file (e.g. from `vidfarm stt --cloud`) to skip
|
|
8
|
+
// STT entirely, or --text to page a script without any transcript.
|
|
9
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { parseArgs } from "node:util";
|
|
12
|
+
import { clearCaptionLayers, computeCompositionGaps, inspectComposition, insertCaptionLayers, readCaptionCues, restyleCaptionLayers } from "./composition-edit.js";
|
|
13
|
+
import { loadSpeechAudioLocally, localTranscribeSpeech, resolveLocalSpeechAuth, MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES } from "./speech.js";
|
|
14
|
+
import { buildCaptionCues, cuesFromText } from "../services/captions.js";
|
|
15
|
+
import { CAPTION_ANIMATIONS, CAPTION_STYLE_PRESETS } from "../hyperframes/composition.js";
|
|
16
|
+
export const CAPTIONS_HELP = `vidfarm captions — animated word-by-word captions (local STT + local file write)
|
|
17
|
+
|
|
18
|
+
captions generate <dir|composition.html> Transcribe narration & lay down animated caption cues
|
|
19
|
+
--audio <file|url> Audio/video to transcribe. Default: the composition's first
|
|
20
|
+
audio layer src, else its first video layer src.
|
|
21
|
+
--srt <file> Skip STT; build cues from this SRT subtitle file.
|
|
22
|
+
--text "<script>" Skip STT; page this text across [--start, --start+--duration].
|
|
23
|
+
--style <preset> ${CAPTION_STYLE_PRESETS.map((p) => p.id).join(" | ")} (default: spotlight)
|
|
24
|
+
--animation <anim> ${CAPTION_ANIMATIONS.join(" | ")}
|
|
25
|
+
--active-color <css> Color the active word takes while spoken
|
|
26
|
+
--highlight-color <css> Pill behind the active word (highlight-pop)
|
|
27
|
+
--color / --background / --background-style plain|outline|highlight-solid|highlight-translucent
|
|
28
|
+
--font <family> / --font-size <px> / --uppercase
|
|
29
|
+
--max-words <n> Words per cue page (default 4)
|
|
30
|
+
--track <n> / --x / --y / --width / --height (%)
|
|
31
|
+
--provider <p> openai | gemini | openrouter (default: openai first — it has
|
|
32
|
+
real word timestamps; gemini/openrouter estimate word windows)
|
|
33
|
+
--language <code> Expected audio language
|
|
34
|
+
--offset <sec> Shift every cue (e.g. narration layer's start)
|
|
35
|
+
--start <sec> --duration <sec> Window for --text paging
|
|
36
|
+
--json
|
|
37
|
+
|
|
38
|
+
captions style <dir|composition.html> --style <preset> Restyle existing cues (same text/timing)
|
|
39
|
+
captions list <dir|composition.html> [--json] Show current animated caption cues
|
|
40
|
+
captions clear <dir|composition.html> Remove all animated caption layers
|
|
41
|
+
|
|
42
|
+
Word timings ride inside each cue layer (per-word animation-delay/duration), so preview,
|
|
43
|
+
serve live-morph, local render, and cloud render all animate identically. \`publish\` pushes
|
|
44
|
+
the edited composition to the cloud as usual.
|
|
45
|
+
`;
|
|
46
|
+
const STYLE_FLAG_OPTIONS = {
|
|
47
|
+
style: { type: "string" },
|
|
48
|
+
animation: { type: "string" },
|
|
49
|
+
"active-color": { type: "string" },
|
|
50
|
+
"highlight-color": { type: "string" },
|
|
51
|
+
color: { type: "string" },
|
|
52
|
+
background: { type: "string" },
|
|
53
|
+
"background-style": { type: "string" },
|
|
54
|
+
font: { type: "string" },
|
|
55
|
+
"font-size": { type: "string" },
|
|
56
|
+
uppercase: { type: "boolean" },
|
|
57
|
+
track: { type: "string" },
|
|
58
|
+
x: { type: "string" },
|
|
59
|
+
y: { type: "string" },
|
|
60
|
+
width: { type: "string" },
|
|
61
|
+
height: { type: "string" }
|
|
62
|
+
};
|
|
63
|
+
export async function runCaptionsCommand(argv) {
|
|
64
|
+
const sub = argv[0];
|
|
65
|
+
const rest = argv.slice(1);
|
|
66
|
+
switch (sub) {
|
|
67
|
+
case "generate":
|
|
68
|
+
return runGenerate(rest);
|
|
69
|
+
case "style":
|
|
70
|
+
return runStyle(rest);
|
|
71
|
+
case "list":
|
|
72
|
+
return runListCues(rest);
|
|
73
|
+
case "clear":
|
|
74
|
+
return runClear(rest);
|
|
75
|
+
case undefined:
|
|
76
|
+
case "help":
|
|
77
|
+
case "--help":
|
|
78
|
+
case "-h":
|
|
79
|
+
console.log(CAPTIONS_HELP);
|
|
80
|
+
return;
|
|
81
|
+
default:
|
|
82
|
+
console.error(`Unknown captions subcommand: ${sub}\n`);
|
|
83
|
+
console.log(CAPTIONS_HELP);
|
|
84
|
+
process.exitCode = 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Same dir-or-file resolution as `vidfarm place` (cli.ts resolveCompositionHtmlPath).
|
|
88
|
+
function resolveCompositionHtml(target) {
|
|
89
|
+
if (!target)
|
|
90
|
+
throw new Error("Provide the composition dir or composition.html path.");
|
|
91
|
+
const abs = path.resolve(process.cwd(), target);
|
|
92
|
+
if (existsSync(abs) && statSync(abs).isDirectory()) {
|
|
93
|
+
const inside = path.join(abs, "composition.html");
|
|
94
|
+
if (!existsSync(inside))
|
|
95
|
+
throw new Error(`No composition.html inside ${abs}. Run \`vidfarm pull <forkId> --dir ${target}\` first.`);
|
|
96
|
+
return inside;
|
|
97
|
+
}
|
|
98
|
+
if (!existsSync(abs))
|
|
99
|
+
throw new Error(`No such composition file or dir: ${abs}.`);
|
|
100
|
+
return abs;
|
|
101
|
+
}
|
|
102
|
+
function styleOptionsFromFlags(values) {
|
|
103
|
+
const num = (key) => {
|
|
104
|
+
const raw = values[key];
|
|
105
|
+
if (typeof raw !== "string" || !raw.trim())
|
|
106
|
+
return undefined;
|
|
107
|
+
const parsed = Number(raw);
|
|
108
|
+
if (!Number.isFinite(parsed))
|
|
109
|
+
throw new Error(`--${key} must be a number.`);
|
|
110
|
+
return parsed;
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
style: typeof values.style === "string" ? values.style : undefined,
|
|
114
|
+
animation: typeof values.animation === "string" ? values.animation : undefined,
|
|
115
|
+
activeColor: typeof values["active-color"] === "string" ? values["active-color"] : undefined,
|
|
116
|
+
highlightColor: typeof values["highlight-color"] === "string" ? values["highlight-color"] : undefined,
|
|
117
|
+
uppercase: values.uppercase === true ? true : undefined,
|
|
118
|
+
color: typeof values.color === "string" ? values.color : undefined,
|
|
119
|
+
background: typeof values.background === "string" ? values.background : undefined,
|
|
120
|
+
backgroundStyle: typeof values["background-style"] === "string" ? values["background-style"] : undefined,
|
|
121
|
+
fontFamily: typeof values.font === "string" ? values.font : undefined,
|
|
122
|
+
fontSize: num("font-size"),
|
|
123
|
+
track: num("track"),
|
|
124
|
+
x: num("x"),
|
|
125
|
+
y: num("y"),
|
|
126
|
+
width: num("width"),
|
|
127
|
+
height: num("height")
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// Minimal SRT reader: index / "start --> end" / text lines. Produces the same
|
|
131
|
+
// segment shape STT returns so cue building is one code path.
|
|
132
|
+
export function parseSrtToSegments(srt) {
|
|
133
|
+
const toSeconds = (stamp) => {
|
|
134
|
+
const match = /(\d+):(\d+):(\d+)[,.](\d+)/.exec(stamp.trim());
|
|
135
|
+
if (!match)
|
|
136
|
+
return null;
|
|
137
|
+
return Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]) + Number(match[4].padEnd(3, "0").slice(0, 3)) / 1000;
|
|
138
|
+
};
|
|
139
|
+
const segments = [];
|
|
140
|
+
for (const block of srt.split(/\r?\n\r?\n+/)) {
|
|
141
|
+
const lines = block.split(/\r?\n/).filter((line) => line.trim());
|
|
142
|
+
const timingIndex = lines.findIndex((line) => line.includes("-->"));
|
|
143
|
+
if (timingIndex === -1)
|
|
144
|
+
continue;
|
|
145
|
+
const [startRaw, endRaw] = lines[timingIndex].split("-->");
|
|
146
|
+
const start = toSeconds(startRaw ?? "");
|
|
147
|
+
const end = toSeconds(endRaw ?? "");
|
|
148
|
+
const text = lines.slice(timingIndex + 1).join(" ").replace(/<[^>]+>/g, "").trim();
|
|
149
|
+
if (start === null || !text)
|
|
150
|
+
continue;
|
|
151
|
+
segments.push({ speaker: "speaker_1", start_sec: start, end_sec: end, text });
|
|
152
|
+
}
|
|
153
|
+
return segments;
|
|
154
|
+
}
|
|
155
|
+
// Default transcription source: the composition's own narration. Prefer a
|
|
156
|
+
// dedicated audio layer; fall back to the first video layer with a src.
|
|
157
|
+
function defaultAudioSource(htmlPath, html) {
|
|
158
|
+
const info = inspectComposition(html);
|
|
159
|
+
const candidate = info.layers.find((layer) => layer.kind === "audio" && layer.src)
|
|
160
|
+
?? info.layers.find((layer) => layer.kind === "video" && layer.src);
|
|
161
|
+
if (!candidate?.src)
|
|
162
|
+
return null;
|
|
163
|
+
const src = candidate.src;
|
|
164
|
+
if (/^https?:\/\//i.test(src))
|
|
165
|
+
return src;
|
|
166
|
+
// Relative srcs resolve against the composition dir (pull/serve layouts).
|
|
167
|
+
return path.resolve(path.dirname(htmlPath), src.replace(/^\.\//, ""));
|
|
168
|
+
}
|
|
169
|
+
async function runGenerate(argv) {
|
|
170
|
+
const { values, positionals } = parseArgs({
|
|
171
|
+
args: argv,
|
|
172
|
+
allowPositionals: true,
|
|
173
|
+
options: {
|
|
174
|
+
...STYLE_FLAG_OPTIONS,
|
|
175
|
+
audio: { type: "string" },
|
|
176
|
+
srt: { type: "string" },
|
|
177
|
+
text: { type: "string" },
|
|
178
|
+
"max-words": { type: "string" },
|
|
179
|
+
provider: { type: "string" },
|
|
180
|
+
language: { type: "string" },
|
|
181
|
+
offset: { type: "string" },
|
|
182
|
+
start: { type: "string" },
|
|
183
|
+
duration: { type: "string" },
|
|
184
|
+
"gemini-key": { type: "string" },
|
|
185
|
+
"openai-key": { type: "string" },
|
|
186
|
+
"openrouter-key": { type: "string" },
|
|
187
|
+
json: { type: "boolean" }
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
const htmlPath = resolveCompositionHtml(positionals[0]);
|
|
191
|
+
const html = readFileSync(htmlPath, "utf8");
|
|
192
|
+
const info = inspectComposition(html);
|
|
193
|
+
const maxWords = values["max-words"] ? Math.max(1, Math.round(Number(values["max-words"]))) : 4;
|
|
194
|
+
const offsetSec = values.offset ? Number(values.offset) : 0;
|
|
195
|
+
if (!Number.isFinite(offsetSec))
|
|
196
|
+
throw new Error("--offset must be a number of seconds.");
|
|
197
|
+
let cues;
|
|
198
|
+
let sourceNote;
|
|
199
|
+
if (typeof values.text === "string" && values.text.trim()) {
|
|
200
|
+
const start = values.start ? Number(values.start) : 0;
|
|
201
|
+
const duration = values.duration ? Number(values.duration) : Math.max(0.4, info.duration_seconds - start);
|
|
202
|
+
cues = cuesFromText(values.text, start + offsetSec, duration, { maxWordsPerCue: maxWords });
|
|
203
|
+
sourceNote = "provided text (estimated word timings)";
|
|
204
|
+
}
|
|
205
|
+
else if (typeof values.srt === "string" && values.srt.trim()) {
|
|
206
|
+
const srtPath = path.resolve(process.cwd(), values.srt);
|
|
207
|
+
if (!existsSync(srtPath))
|
|
208
|
+
throw new Error(`No such SRT file: ${srtPath}`);
|
|
209
|
+
const segments = parseSrtToSegments(readFileSync(srtPath, "utf8"));
|
|
210
|
+
if (!segments.length)
|
|
211
|
+
throw new Error(`No cues found in ${srtPath}.`);
|
|
212
|
+
cues = buildCaptionCues(segments, { maxWordsPerCue: maxWords, offsetSec, fallbackDurationSec: info.duration_seconds });
|
|
213
|
+
sourceNote = `${path.basename(srtPath)} (${segments.length} SRT cues, estimated word timings)`;
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
// openai first: whisper-1 verbose_json is the only path with REAL word
|
|
217
|
+
// timestamps, which is what makes the word-by-word animation land on beat.
|
|
218
|
+
const auth = resolveLocalSpeechAuth(values, ["openai", "gemini", "openrouter"], typeof values.provider === "string" ? values.provider : undefined);
|
|
219
|
+
if (!auth) {
|
|
220
|
+
throw new Error("No local provider key found. Export OPENAI_API_KEY (best: real word timings), GEMINI_API_KEY, or OPENROUTER_API_KEY — or pass --srt/--text instead.");
|
|
221
|
+
}
|
|
222
|
+
const audioTarget = typeof values.audio === "string" && values.audio.trim()
|
|
223
|
+
? values.audio.trim()
|
|
224
|
+
: defaultAudioSource(htmlPath, html);
|
|
225
|
+
if (!audioTarget) {
|
|
226
|
+
throw new Error("Could not find narration to transcribe (no audio/video layer with a src). Pass --audio <file|url>, --srt, or --text.");
|
|
227
|
+
}
|
|
228
|
+
console.log(`Transcribing ${audioTarget} (${auth.provider}, local key)…`);
|
|
229
|
+
const audio = await loadSpeechAudioLocally(audioTarget);
|
|
230
|
+
if (audio.bytes.byteLength > MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES) {
|
|
231
|
+
throw new Error(`Audio is ${(audio.bytes.byteLength / (1024 * 1024)).toFixed(1)}MB (limit ${(MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES / (1024 * 1024)).toFixed(0)}MB). Trim it or transcribe externally and pass --srt.`);
|
|
232
|
+
}
|
|
233
|
+
const transcript = await localTranscribeSpeech({
|
|
234
|
+
auth,
|
|
235
|
+
audio: audio.bytes,
|
|
236
|
+
contentType: audio.contentType,
|
|
237
|
+
language: typeof values.language === "string" ? values.language : undefined,
|
|
238
|
+
diarize: auth.provider === "gemini",
|
|
239
|
+
wordTimestamps: true
|
|
240
|
+
});
|
|
241
|
+
if (!transcript.segments.length)
|
|
242
|
+
throw new Error("Transcription returned no speech segments.");
|
|
243
|
+
cues = buildCaptionCues(transcript.segments, { maxWordsPerCue: maxWords, offsetSec, fallbackDurationSec: info.duration_seconds });
|
|
244
|
+
const hasRealWords = transcript.segments.some((segment) => segment.words?.length);
|
|
245
|
+
sourceNote = `${transcript.provider}/${transcript.model} (${hasRealWords ? "word-level timestamps" : "estimated word timings"})`;
|
|
246
|
+
}
|
|
247
|
+
if (!cues.length)
|
|
248
|
+
throw new Error("No caption cues produced.");
|
|
249
|
+
// Keep cues inside the composition's duration.
|
|
250
|
+
cues = cues.filter((cue) => cue.start < info.duration_seconds)
|
|
251
|
+
.map((cue) => ({ ...cue, duration: Math.min(cue.duration, Math.max(0.1, info.duration_seconds - cue.start)) }));
|
|
252
|
+
const result = insertCaptionLayers(html, cues, styleOptionsFromFlags(values));
|
|
253
|
+
writeFileSync(htmlPath, result.html);
|
|
254
|
+
if (values.json) {
|
|
255
|
+
console.log(JSON.stringify({ ok: true, style: result.styleId, cues: cues.length, replaced: result.replaced, layer_keys: result.layerKeys, source: sourceNote, gaps: computeCompositionGaps(result.html) }, null, 2));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
console.log(`Wrote ${cues.length} animated caption cues (style: ${result.styleId}) from ${sourceNote}.`);
|
|
259
|
+
if (result.replaced > 0)
|
|
260
|
+
console.log(`Replaced ${result.replaced} previous caption cues.`);
|
|
261
|
+
console.log(`Updated ${htmlPath} — a running \`vidfarm serve\` live-morphs it; \`vidfarm publish\` pushes to cloud.`);
|
|
262
|
+
}
|
|
263
|
+
async function runStyle(argv) {
|
|
264
|
+
const { values, positionals } = parseArgs({
|
|
265
|
+
args: argv,
|
|
266
|
+
allowPositionals: true,
|
|
267
|
+
options: { ...STYLE_FLAG_OPTIONS, json: { type: "boolean" } }
|
|
268
|
+
});
|
|
269
|
+
const htmlPath = resolveCompositionHtml(positionals[0]);
|
|
270
|
+
const opts = styleOptionsFromFlags(values);
|
|
271
|
+
if (!opts.style && !opts.animation && !opts.activeColor && !opts.highlightColor && opts.uppercase === undefined && !opts.color && !opts.background && !opts.backgroundStyle && !opts.fontFamily && opts.fontSize === undefined) {
|
|
272
|
+
throw new Error("captions style needs at least one style flag (e.g. --style karaoke).");
|
|
273
|
+
}
|
|
274
|
+
const result = restyleCaptionLayers(readFileSync(htmlPath, "utf8"), opts);
|
|
275
|
+
writeFileSync(htmlPath, result.html);
|
|
276
|
+
if (values.json) {
|
|
277
|
+
console.log(JSON.stringify({ ok: true, style: result.styleId, cues: result.layerKeys.length }, null, 2));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
console.log(`Restyled ${result.layerKeys.length} caption cues to ${result.styleId}.`);
|
|
281
|
+
}
|
|
282
|
+
async function runListCues(argv) {
|
|
283
|
+
const { values, positionals } = parseArgs({
|
|
284
|
+
args: argv,
|
|
285
|
+
allowPositionals: true,
|
|
286
|
+
options: { json: { type: "boolean" } }
|
|
287
|
+
});
|
|
288
|
+
const htmlPath = resolveCompositionHtml(positionals[0]);
|
|
289
|
+
const cues = readCaptionCues(readFileSync(htmlPath, "utf8"));
|
|
290
|
+
if (values.json) {
|
|
291
|
+
console.log(JSON.stringify({ cues }, null, 2));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (!cues.length) {
|
|
295
|
+
console.log("No animated caption layers. Run `vidfarm captions generate <dir>` to add some.");
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
for (const cue of cues) {
|
|
299
|
+
console.log(`${cue.start.toFixed(2).padStart(8)}s +${cue.duration.toFixed(2)}s ${cue.text}`);
|
|
300
|
+
}
|
|
301
|
+
console.log(`${cues.length} cues on track ${cues[0].track}.`);
|
|
302
|
+
}
|
|
303
|
+
async function runClear(argv) {
|
|
304
|
+
const { positionals } = parseArgs({ args: argv, allowPositionals: true, options: {} });
|
|
305
|
+
const htmlPath = resolveCompositionHtml(positionals[0]);
|
|
306
|
+
const result = clearCaptionLayers(readFileSync(htmlPath, "utf8"));
|
|
307
|
+
writeFileSync(htmlPath, result.html);
|
|
308
|
+
console.log(`Removed ${result.removed} animated caption layer${result.removed === 1 ? "" : "s"}.`);
|
|
309
|
+
}
|
|
310
|
+
//# sourceMappingURL=captions.js.map
|