@mevdragon/vidfarm-devcli 0.11.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 +21 -3
- package/SKILL.platform.md +17 -3
- package/demo/dist/app.css +1 -1
- package/demo/dist/app.js +251 -66
- package/dist/src/app.js +788 -110
- package/dist/src/cli.js +57 -6
- package/dist/src/composition-runtime.js +136 -2
- package/dist/src/devcli/captions.js +310 -0
- package/dist/src/devcli/clips.js +5 -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/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/hyperframes/composition.js +315 -0
- package/dist/src/primitive-registry.js +250 -49
- 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 +11 -4
- package/dist/src/services/serverless-records.js +12 -2
- package/dist/src/services/speech.js +166 -4
- 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
|
@@ -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
|
|
@@ -79,6 +81,9 @@ Discover & inspiration (browse the viral-video catalog, add your own source):
|
|
|
79
81
|
local MP4/MOV/WebM file — as a private (files: presign + PUT via
|
|
80
82
|
template. --title (uploads; defaults to the /discover/templates/upload)
|
|
81
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.
|
|
82
87
|
inspiration-rm <id> Delete a private inspiration/template you own → DELETE /discover/templates/:id
|
|
83
88
|
inspiration-decompose <id> AI-decompose an inspiration into scenes → POST /api/v1/inspirations/:id/decompose
|
|
84
89
|
|
|
@@ -141,6 +146,12 @@ Generate AI media and drop it on the timeline (for local coding agents):
|
|
|
141
146
|
zoom-out|pan-left|pan-right|pan-up|pan-down|
|
|
142
147
|
zoom-in-left|zoom-in-right
|
|
143
148
|
--ken-burns-intensity <n> Travel/zoom strength 0.04-0.5 (default 0.18)
|
|
149
|
+
--transition <preset> Scene transition INTO this clip (the previous
|
|
150
|
+
butt-cut clip is held beneath it automatically):
|
|
151
|
+
crossfade|fade-black|slide-left|slide-right|
|
|
152
|
+
slide-up|slide-down|wipe-left|wipe-right|wipe-up|
|
|
153
|
+
wipe-down|zoom-in|zoom-out|circle-open
|
|
154
|
+
--transition-duration <s> Transition length in seconds 0.1-2.5 (default 0.5)
|
|
144
155
|
remove-video-captions <forkId> Read the two video sources: → GET .../compositions/:forkId/remove-video-captions
|
|
145
156
|
original vs decomposed (caption-free),
|
|
146
157
|
non-billing (alias: ghostcut)
|
|
@@ -167,7 +178,9 @@ Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit bac
|
|
|
167
178
|
The VOICE STYLE is promptable via --style. OPENROUTER_API_KEY, no cloud job
|
|
168
179
|
--style "<direction>" Voice-style prompt: tone/pacing/accent/emotion/persona
|
|
169
180
|
(e.g. "calm, warm bedtime narrator", "excited sports announcer")
|
|
170
|
-
--voice <name> Provider voice preset (openai: alloy/ash/coral…; gemini: Kore/Puck…)
|
|
181
|
+
--voice <name> Provider voice preset (openai: alloy/ash/coral…; gemini: Kore/Puck…).
|
|
182
|
+
The provider is inferred from the voice when --provider is omitted;
|
|
183
|
+
a voice from the wrong provider family is rejected with a clear error.
|
|
171
184
|
--out <file> Output audio path (default tts-<id>.mp3|wav)
|
|
172
185
|
--provider <p> --model <m> --format mp3|wav
|
|
173
186
|
--cloud BACKUP: saved-key platform job → POST /api/v1/primitives/audio/speech (+ poll)
|
|
@@ -183,6 +196,17 @@ Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit bac
|
|
|
183
196
|
--provider <p> --model <m>
|
|
184
197
|
--cloud BACKUP: saved-key platform job → POST /api/v1/primitives/audio/transcribe (+ poll)
|
|
185
198
|
(a local file uploads to your temp folder first — 30-day TTL)
|
|
199
|
+
|
|
200
|
+
Animated captions (word-by-word TikTok/CapCut styles on a pulled/served composition):
|
|
201
|
+
captions generate <dir> Transcribe the narration & lay down animated LOCAL: local ffmpeg demux +
|
|
202
|
+
caption cue layers (openai = real word your provider key; writes
|
|
203
|
+
timestamps; gemini/openrouter estimated). composition.html on disk
|
|
204
|
+
--style <preset> spotlight|karaoke|word-pop|stack-up|neon|bounce (default spotlight)
|
|
205
|
+
--audio <file|url> | --srt <file> | --text "<script>" Pick the transcript source
|
|
206
|
+
--max-words <n> --active-color <css> --uppercase … (run 'vidfarm captions' for all flags)
|
|
207
|
+
captions style <dir> Restyle existing cues (same text/timings)
|
|
208
|
+
captions list <dir> Show the current animated caption cues [--json]
|
|
209
|
+
captions clear <dir> Remove all animated caption layers
|
|
186
210
|
versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
|
|
187
211
|
snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
|
|
188
212
|
render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
|
|
@@ -437,6 +461,9 @@ async function main() {
|
|
|
437
461
|
case "clips":
|
|
438
462
|
await runClipsCommand(rest);
|
|
439
463
|
return;
|
|
464
|
+
case "captions":
|
|
465
|
+
await runCaptionsCommand(rest);
|
|
466
|
+
return;
|
|
440
467
|
default:
|
|
441
468
|
if (!command.startsWith("-")) {
|
|
442
469
|
// Positional template id → default to booting the local editor.
|
|
@@ -597,6 +624,11 @@ async function runServeCommand(argv) {
|
|
|
597
624
|
// local-render by definition — cloud rendering is the EXPLICIT
|
|
598
625
|
// render_target="cloud" upstream handoff (the editor's "Render in Cloud").
|
|
599
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 = "";
|
|
600
632
|
// Same trap as the state machine ARN: a dev-shell .env can carry the staging
|
|
601
633
|
// media-lambda URL, which would route probe/thumbnail/trim/etc. to a cloud
|
|
602
634
|
// Lambda that cannot fetch this box's localhost storage URLs ("fetch
|
|
@@ -1241,7 +1273,7 @@ async function runVideosCommand(argv) {
|
|
|
1241
1273
|
emitResult(result, ctx.json, [["Browse ", discoverFrontendUrl(ctx.host)]]);
|
|
1242
1274
|
}
|
|
1243
1275
|
async function runInspirationAddCommand(argv) {
|
|
1244
|
-
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 } } });
|
|
1245
1277
|
const source = parsed.positionals[0];
|
|
1246
1278
|
if (!source)
|
|
1247
1279
|
throw new Error("inspiration-add requires a video URL (TikTok/YouTube/Instagram/X) or a local video file path.");
|
|
@@ -1261,7 +1293,10 @@ async function runInspirationAddCommand(argv) {
|
|
|
1261
1293
|
fileName: path.basename(localPath),
|
|
1262
1294
|
title: parsed.values.title,
|
|
1263
1295
|
tagline: parsed.values.tagline,
|
|
1264
|
-
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
|
|
1265
1300
|
});
|
|
1266
1301
|
emitResult(result, ctx.json, [["Discover ", discoverFrontendUrl(ctx.host)]]);
|
|
1267
1302
|
return;
|
|
@@ -1356,7 +1391,7 @@ async function ingestUploadedVideo(ctx, input) {
|
|
|
1356
1391
|
host: ctx.host,
|
|
1357
1392
|
path: "/discover/templates",
|
|
1358
1393
|
auth: ctx.auth,
|
|
1359
|
-
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 }
|
|
1360
1395
|
});
|
|
1361
1396
|
assertApiOk(result, "ingest");
|
|
1362
1397
|
return result;
|
|
@@ -1531,7 +1566,10 @@ async function runCreateCommand(argv) {
|
|
|
1531
1566
|
throw new Error(`create: could not fetch the generated video (${dl.status}).`);
|
|
1532
1567
|
const buffer = Buffer.from(await dl.arrayBuffer());
|
|
1533
1568
|
const title = parsed.values.title ?? prompt.slice(0, 80);
|
|
1534
|
-
|
|
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" });
|
|
1535
1573
|
const inspirationId = add.json?.inspiration_id;
|
|
1536
1574
|
if (!inspirationId)
|
|
1537
1575
|
throw new Error("create: ingest did not return an inspiration id.");
|
|
@@ -1986,6 +2024,8 @@ function placeMediaOnDisk(input) {
|
|
|
1986
2024
|
objectFit: input.objectFit,
|
|
1987
2025
|
kenBurns: input.kenBurns,
|
|
1988
2026
|
kenBurnsIntensity: input.kenBurnsIntensity,
|
|
2027
|
+
transition: input.transition,
|
|
2028
|
+
transitionDuration: input.transitionDuration,
|
|
1989
2029
|
slug: input.slug
|
|
1990
2030
|
};
|
|
1991
2031
|
const result = input.replace
|
|
@@ -2017,6 +2057,8 @@ async function runGenerateCommand(argv) {
|
|
|
2017
2057
|
track: { type: "string" },
|
|
2018
2058
|
"ken-burns": { type: "string" },
|
|
2019
2059
|
"ken-burns-intensity": { type: "string" },
|
|
2060
|
+
transition: { type: "string" },
|
|
2061
|
+
"transition-duration": { type: "string" },
|
|
2020
2062
|
"layer-key": { type: "string" }
|
|
2021
2063
|
}
|
|
2022
2064
|
});
|
|
@@ -2090,6 +2132,8 @@ async function runGenerateCommand(argv) {
|
|
|
2090
2132
|
track: parsed.values.track ? Number(parsed.values.track) : undefined,
|
|
2091
2133
|
kenBurns: parsed.values["ken-burns"],
|
|
2092
2134
|
kenBurnsIntensity: parsed.values["ken-burns-intensity"] ? Number(parsed.values["ken-burns-intensity"]) : undefined,
|
|
2135
|
+
transition: parsed.values.transition,
|
|
2136
|
+
transitionDuration: parsed.values["transition-duration"] ? Number(parsed.values["transition-duration"]) : undefined,
|
|
2093
2137
|
layerKey: parsed.values["layer-key"]
|
|
2094
2138
|
});
|
|
2095
2139
|
if (!ctx.json)
|
|
@@ -2158,7 +2202,10 @@ async function runTtsCommand(argv) {
|
|
|
2158
2202
|
const voice = parsed.values.voice;
|
|
2159
2203
|
const json = Boolean(parsed.values.json);
|
|
2160
2204
|
if (!parsed.values.cloud) {
|
|
2161
|
-
|
|
2205
|
+
// A Gemini voice preset ("Kore", "Puck", …) should find a Gemini-capable
|
|
2206
|
+
// key first — openrouter's default TTS model is Gemini-family too.
|
|
2207
|
+
const keyPreference = inferSpeechProviderForVoice(voice) === "gemini" ? ["gemini", "openrouter", "openai"] : ["openai", "gemini", "openrouter"];
|
|
2208
|
+
const auth = resolveLocalSpeechAuth(parsed.values, keyPreference, parsed.values.provider);
|
|
2162
2209
|
if (!auth)
|
|
2163
2210
|
throw new Error(speechKeyHint("tts"));
|
|
2164
2211
|
if (!json)
|
|
@@ -2471,6 +2518,8 @@ async function runPlaceCommand(argv) {
|
|
|
2471
2518
|
"object-fit": { type: "string" },
|
|
2472
2519
|
"ken-burns": { type: "string" },
|
|
2473
2520
|
"ken-burns-intensity": { type: "string" },
|
|
2521
|
+
transition: { type: "string" },
|
|
2522
|
+
"transition-duration": { type: "string" },
|
|
2474
2523
|
"layer-key": { type: "string" },
|
|
2475
2524
|
slug: { type: "string" },
|
|
2476
2525
|
// Local file support: where a serve box serves /storage from, and which
|
|
@@ -2513,6 +2562,8 @@ async function runPlaceCommand(argv) {
|
|
|
2513
2562
|
objectFit: parsed.values["object-fit"],
|
|
2514
2563
|
kenBurns: parsed.values["ken-burns"],
|
|
2515
2564
|
kenBurnsIntensity: num(parsed.values["ken-burns-intensity"]),
|
|
2565
|
+
transition: parsed.values.transition,
|
|
2566
|
+
transitionDuration: num(parsed.values["transition-duration"]),
|
|
2516
2567
|
layerKey: parsed.values["layer-key"],
|
|
2517
2568
|
slug: parsed.values.slug
|
|
2518
2569
|
});
|
|
@@ -171,14 +171,137 @@ 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
|
+
//
|
|
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
|
+
|
|
254
|
+
function syncCaptionClip(clip, timing) {
|
|
255
|
+
if (typeof clip.getAnimations !== "function") return;
|
|
256
|
+
const offsetMs = Math.max(0, (time - timing.start)) * 1000;
|
|
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 {}
|
|
288
|
+
try {
|
|
289
|
+
anim.pause();
|
|
290
|
+
anim.currentTime = target;
|
|
291
|
+
} catch {}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
174
295
|
function apply(nextTime, options = {}) {
|
|
175
296
|
const forceSeek = Boolean(options.forceSeek);
|
|
176
297
|
const fromClock = Boolean(options.fromClock);
|
|
177
298
|
time = clampTime(nextTime);
|
|
178
299
|
let activeVideoProxy = null;
|
|
300
|
+
const transitionWindows = collectTransitionWindows();
|
|
179
301
|
for (const clip of clips) {
|
|
180
302
|
const timing = readTiming(clip);
|
|
181
|
-
const
|
|
303
|
+
const hold = transitionHoldFor(clip, timing, transitionWindows);
|
|
304
|
+
const active = time >= timing.start && time < timing.end + hold;
|
|
182
305
|
if (clip instanceof HTMLElement && !(clip instanceof HTMLAudioElement)) {
|
|
183
306
|
if (clip.getAttribute("data-vf-timeline-proxy") === "video") {
|
|
184
307
|
if (active) activeVideoProxy = clip;
|
|
@@ -186,6 +309,8 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
|
|
|
186
309
|
setVisibility(clip, active);
|
|
187
310
|
}
|
|
188
311
|
if (clip.hasAttribute("data-kenburns")) syncKenBurnsClip(clip, timing);
|
|
312
|
+
if (clip.hasAttribute("data-caption-animation")) syncCaptionClip(clip, timing);
|
|
313
|
+
if (clip.hasAttribute("data-transition")) syncTransitionClip(clip, timing);
|
|
189
314
|
}
|
|
190
315
|
if (clip instanceof HTMLMediaElement) {
|
|
191
316
|
applyMediaState(clip);
|
|
@@ -293,7 +418,16 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
|
|
|
293
418
|
return playbackRate;
|
|
294
419
|
},
|
|
295
420
|
setMuted,
|
|
296
|
-
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
|
+
}
|
|
297
431
|
};
|
|
298
432
|
|
|
299
433
|
refreshClips();
|
|
@@ -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
|