@mevdragon/vidfarm-devcli 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/demo/dist/app.js +56 -28
- package/dist/src/app.js +147 -6
- package/dist/src/cli.js +331 -50
- package/dist/src/devcli/telemetry.js +236 -0
- package/dist/src/frontend/template-editor-chat.js +212 -0
- package/dist/src/services/hyperframes.js +22 -9
- package/package.json +1 -1
- package/public/assets/page-runtime-client-app.js +31 -31
package/dist/src/app.js
CHANGED
|
@@ -4096,6 +4096,28 @@ function createEditorFrontendActionTool(input) {
|
|
|
4096
4096
|
}
|
|
4097
4097
|
});
|
|
4098
4098
|
}
|
|
4099
|
+
// Discover-chat only: lets the AI create a BRAND-NEW video for the user, either
|
|
4100
|
+
// from a text prompt (mode=generate) or by replicating a pasted video URL
|
|
4101
|
+
// (mode=replicate). The tool itself is a passthrough — it just structures the
|
|
4102
|
+
// user's intent; the chat frontend runs the ingest→decompose→fork pipeline and
|
|
4103
|
+
// drops an "Open in editor" link into the thread when it's ready.
|
|
4104
|
+
function createVideoCreationTool() {
|
|
4105
|
+
return tool({
|
|
4106
|
+
description: "Create a NEW editable video for the user from scratch. Call this the MOMENT the user asks to \"create/make a video of ...\" (set mode=generate and put the idea in `prompt`) OR to \"replicate/recreate/remix this video\" from a pasted TikTok/YouTube/Instagram/X URL (set mode=replicate, put the URL in `source_url`, and put any requested changes in `instructions`). This kicks off video creation in the browser and posts an 'Open in editor' link to the chat when the new video is ready — you do not need to poll or call any other tool. This is the ONLY way to make a brand-new video; the editor tools only edit an already-open composition.",
|
|
4107
|
+
inputSchema: z.object({
|
|
4108
|
+
mode: z.enum(["generate", "replicate"]).describe("generate = brand-new from a text prompt; replicate = recreate a pasted video URL."),
|
|
4109
|
+
prompt: z.string().optional().describe("For mode=generate: what the video should be about."),
|
|
4110
|
+
source_url: z.string().optional().describe("For mode=replicate: the TikTok/YouTube/Instagram/X video URL to recreate."),
|
|
4111
|
+
instructions: z.string().optional().describe("Optional changes to apply while creating/recreating (e.g. 'make it about my coffee brand', 'change the hook to ...').")
|
|
4112
|
+
}),
|
|
4113
|
+
execute: async ({ mode, prompt, source_url, instructions }) => ({
|
|
4114
|
+
mode,
|
|
4115
|
+
prompt: prompt ?? null,
|
|
4116
|
+
source_url: source_url ?? null,
|
|
4117
|
+
instructions: instructions ?? null
|
|
4118
|
+
})
|
|
4119
|
+
});
|
|
4120
|
+
}
|
|
4099
4121
|
function createEditorActionTool() {
|
|
4100
4122
|
return tool({
|
|
4101
4123
|
description: "Directly mutate the composition timeline in the editor OR start an export render OR generate-and-place AI media. Use this whenever the user asks for a timeline change (add/remove/duplicate/split layers, retime, reposition, resize, restyle text, swap media, group/ungroup), asks to generate an AI video/image and drop it on the timeline (action_type=generate_layer), or asks to export/render/publish/download the final MP4 (action_type=export_composition). The most recent <editor_context> in the user message is the source of truth for current layer_keys, layer indices (called 'track'), durations, timeline_gaps (blank spans), pending_generations (AI gens still rendering), and the most recent exported MP4 (last_export_url). Always provide explanation. For add_layer, also provide layer_key with a stable descriptive id (e.g., 'vf-caption-hook', 'vf-hero-image') so subsequent tool calls in the same turn can reference it — the editor will use that id as the new layer's data-hf-id. PREFER action_type=generate_layer to create NEW AI footage/images on the timeline: it submits the primitive generate job, drops a placeholder clip into the target slot immediately, and auto-swaps in the finished media when the job settles (no manual poll, no separate add_layer). Only use the http_request→add_layer flow for media that already has a URL. Avoid layer/track collisions: pick a track index higher than any existing layer on the requested time range, or set start/duration so the range is free. Never invent layer_keys for remove/set/duplicate/split actions; only use keys present in the most recent editor_context. For action_type=export_composition, no layer_key/geometry fields are needed; the editor kicks off the render and streams status in the UI, and the resulting MP4 URL will appear in the next <editor_context> as last_export_url.",
|
|
@@ -4303,7 +4325,12 @@ async function streamEditorChatAgent(input, send) {
|
|
|
4303
4325
|
frontend_action: createEditorFrontendActionTool({
|
|
4304
4326
|
tracerRegistry
|
|
4305
4327
|
}),
|
|
4306
|
-
editor_action: createEditorActionTool()
|
|
4328
|
+
editor_action: createEditorActionTool(),
|
|
4329
|
+
// Discover/brainstorm surface only: "create me a video of…" / "replicate
|
|
4330
|
+
// this URL…". The editor surfaces edit an already-open composition.
|
|
4331
|
+
...(input.template.templateId === "chat-brainstorm"
|
|
4332
|
+
? { create_video: createVideoCreationTool() }
|
|
4333
|
+
: {})
|
|
4307
4334
|
};
|
|
4308
4335
|
})()
|
|
4309
4336
|
: undefined;
|
|
@@ -6536,6 +6563,66 @@ app.post(`${INSPIRATIONS_PREFIX}/:inspirationId/decompose`, async (c) => {
|
|
|
6536
6563
|
}
|
|
6537
6564
|
});
|
|
6538
6565
|
});
|
|
6566
|
+
// A "processing" state older than this is treated as abandoned (crashed lambda)
|
|
6567
|
+
// so a forced re-run may start. Must exceed the longest real decompose but stay
|
|
6568
|
+
// under the API lambda's 15-min timeout.
|
|
6569
|
+
const DECOMPOSE_PROCESSING_STALE_MS = 10 * 60 * 1000;
|
|
6570
|
+
async function readDecomposeJobState(forkId) {
|
|
6571
|
+
const text = await storage.readText(storage.compositionForkWorkingKey(forkId, "decompose.json"));
|
|
6572
|
+
if (!text)
|
|
6573
|
+
return null;
|
|
6574
|
+
try {
|
|
6575
|
+
return JSON.parse(text);
|
|
6576
|
+
}
|
|
6577
|
+
catch {
|
|
6578
|
+
return null;
|
|
6579
|
+
}
|
|
6580
|
+
}
|
|
6581
|
+
async function writeDecomposeJobState(forkId, state) {
|
|
6582
|
+
try {
|
|
6583
|
+
await storage.putJson(storage.compositionForkWorkingKey(forkId, "decompose.json"), state);
|
|
6584
|
+
}
|
|
6585
|
+
catch (error) {
|
|
6586
|
+
console.error("decompose.json write failed", error instanceof Error ? error.message : error);
|
|
6587
|
+
}
|
|
6588
|
+
}
|
|
6589
|
+
function decomposeDoneResponse(job, extra = {}) {
|
|
6590
|
+
return {
|
|
6591
|
+
ok: true,
|
|
6592
|
+
status: "done",
|
|
6593
|
+
mode: "smart",
|
|
6594
|
+
provider: job.provider ?? null,
|
|
6595
|
+
model: job.model ?? null,
|
|
6596
|
+
scene_count: job.sceneCount ?? 0,
|
|
6597
|
+
caption_count: job.captionCount ?? 0,
|
|
6598
|
+
duration_seconds: job.durationSeconds ?? null,
|
|
6599
|
+
became_default_fork: job.becameDefaultFork ?? false,
|
|
6600
|
+
ghostcut_pending: job.ghostcutPending ?? false,
|
|
6601
|
+
...extra
|
|
6602
|
+
};
|
|
6603
|
+
}
|
|
6604
|
+
// Read-only decompose job status — the client's poll target while a decompose
|
|
6605
|
+
// runs (or after a 504). Fast: just reads decompose.json.
|
|
6606
|
+
app.get(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
6607
|
+
const caller = await getBrowserCustomer(c);
|
|
6608
|
+
try {
|
|
6609
|
+
const access = await assertForkAccess({ forkId: c.req.param("forkId"), customerId: caller?.id ?? null, shareToken: readShareToken(c) }, "view");
|
|
6610
|
+
const job = await readDecomposeJobState(access.fork.id);
|
|
6611
|
+
if (!job)
|
|
6612
|
+
return c.json({ ok: true, status: "none" });
|
|
6613
|
+
if (job.status === "done")
|
|
6614
|
+
return c.json(decomposeDoneResponse(job, { resumed: true }));
|
|
6615
|
+
if (job.status === "failed")
|
|
6616
|
+
return c.json({ ok: true, status: "failed", error: job.failureReason ?? "Decompose failed." });
|
|
6617
|
+
const stale = Date.now() - (job.updatedAtMs ?? job.startedAtMs ?? 0) >= DECOMPOSE_PROCESSING_STALE_MS;
|
|
6618
|
+
return c.json({ ok: true, status: "processing", stale, started_at_ms: job.startedAtMs });
|
|
6619
|
+
}
|
|
6620
|
+
catch (error) {
|
|
6621
|
+
if (error instanceof ForkNotFoundError || error instanceof ForkAccessDeniedError)
|
|
6622
|
+
return forkAccessErrorResponse(c, error);
|
|
6623
|
+
return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
|
|
6624
|
+
}
|
|
6625
|
+
});
|
|
6539
6626
|
app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
6540
6627
|
const caller = await getBrowserCustomer(c);
|
|
6541
6628
|
if (!caller)
|
|
@@ -6552,6 +6639,36 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6552
6639
|
const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
|
|
6553
6640
|
const userPromptRaw = typeof body.user_prompt === "string" ? body.user_prompt.trim() : "";
|
|
6554
6641
|
const userPrompt = userPromptRaw.length > 0 ? userPromptRaw.slice(0, 4000) : null;
|
|
6642
|
+
// Idempotency / resumability guard. POST STARTS a decompose (idempotently);
|
|
6643
|
+
// GET /auto-decompose is the poll. An in-flight run always wins so a client's
|
|
6644
|
+
// post-504 retry (or a second caller) never starts a duplicate. A completed
|
|
6645
|
+
// job is returned from cache unless `force` (the editor's decompose click)
|
|
6646
|
+
// asks for a fresh re-run. Absent / failed / abandoned jobs (re)start — so a
|
|
6647
|
+
// plain POST with no `force` still kicks the first decompose (back-compat for
|
|
6648
|
+
// the editor-chat copilot's http_request call).
|
|
6649
|
+
const forceRun = body.force === true;
|
|
6650
|
+
const existingJob = await readDecomposeJobState(fork.id);
|
|
6651
|
+
const jobFresh = existingJob
|
|
6652
|
+
? Date.now() - (existingJob.updatedAtMs ?? existingJob.startedAtMs ?? 0) < DECOMPOSE_PROCESSING_STALE_MS
|
|
6653
|
+
: false;
|
|
6654
|
+
if (existingJob?.status === "processing" && jobFresh) {
|
|
6655
|
+
return c.json({ ok: true, status: "processing", started_at_ms: existingJob.startedAtMs });
|
|
6656
|
+
}
|
|
6657
|
+
if (existingJob?.status === "done" && !forceRun) {
|
|
6658
|
+
return c.json(decomposeDoneResponse(existingJob, { resumed: true }));
|
|
6659
|
+
}
|
|
6660
|
+
// Claim the job so concurrent polls/kicks back off. A local helper stamps
|
|
6661
|
+
// "failed" on every early-error return so a poller never hangs on processing.
|
|
6662
|
+
await writeDecomposeJobState(fork.id, { status: "processing", startedAtMs, updatedAtMs: Date.now() });
|
|
6663
|
+
const failDecompose = async (payload, statusCode) => {
|
|
6664
|
+
await writeDecomposeJobState(fork.id, {
|
|
6665
|
+
status: "failed",
|
|
6666
|
+
startedAtMs,
|
|
6667
|
+
updatedAtMs: Date.now(),
|
|
6668
|
+
failureReason: typeof payload.error === "string" ? payload.error : "Auto-decompose failed."
|
|
6669
|
+
});
|
|
6670
|
+
return c.json(payload, statusCode);
|
|
6671
|
+
};
|
|
6555
6672
|
// Parse the current composition HTML with a real DOM (linkedom) so
|
|
6556
6673
|
// attribute values come back UNESCAPED — `data-source-video="foo?a=1&b=2"`
|
|
6557
6674
|
// reads as `foo?a=1&b=2`, which is what ghostcut and the vision extractor
|
|
@@ -6588,7 +6705,7 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6588
6705
|
}
|
|
6589
6706
|
catch (error) {
|
|
6590
6707
|
if (error instanceof MediaDurationExceededError) {
|
|
6591
|
-
return
|
|
6708
|
+
return failDecompose({
|
|
6592
6709
|
ok: false,
|
|
6593
6710
|
error: `Source video is ${error.durationSeconds.toFixed(1)}s — Vidfarm caps auto-decompose at ${error.maxDurationSeconds}s. Trim the source before forking.`,
|
|
6594
6711
|
code: "source_too_long",
|
|
@@ -6706,13 +6823,13 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6706
6823
|
const customerKeys = await loadCustomerVisionKeys(caller.id);
|
|
6707
6824
|
const hasAnyKey = Boolean(customerKeys.gemini || customerKeys.openai || customerKeys.openrouter);
|
|
6708
6825
|
if (!hasAnyKey) {
|
|
6709
|
-
return
|
|
6826
|
+
return failDecompose({
|
|
6710
6827
|
ok: false,
|
|
6711
6828
|
error: "Decompose requires an AI provider key. Add a Gemini, OpenAI, or OpenRouter key in Settings and try again."
|
|
6712
6829
|
}, 400);
|
|
6713
6830
|
}
|
|
6714
6831
|
if (!sourceUrl) {
|
|
6715
|
-
return
|
|
6832
|
+
return failDecompose({ ok: false, error: "Composition has no source video URL." }, 400);
|
|
6716
6833
|
}
|
|
6717
6834
|
const inspirationForTagline = forkInspirationId
|
|
6718
6835
|
? await serverlessRecords.getInspiration(forkInspirationId)
|
|
@@ -6742,13 +6859,13 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6742
6859
|
}
|
|
6743
6860
|
catch (error) {
|
|
6744
6861
|
console.error("smart decompose failed", error instanceof Error ? error.message : error);
|
|
6745
|
-
return
|
|
6862
|
+
return failDecompose({
|
|
6746
6863
|
ok: false,
|
|
6747
6864
|
error: error instanceof Error ? error.message : "Smart decompose failed."
|
|
6748
6865
|
}, 502);
|
|
6749
6866
|
}
|
|
6750
6867
|
if (smart.scenes.length === 0 && smart.captions.length === 0) {
|
|
6751
|
-
return
|
|
6868
|
+
return failDecompose({
|
|
6752
6869
|
ok: false,
|
|
6753
6870
|
error: "Smart decompose returned no scenes. Try again with different guidance."
|
|
6754
6871
|
}, 502);
|
|
@@ -6894,8 +7011,23 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6894
7011
|
}
|
|
6895
7012
|
}
|
|
6896
7013
|
await chargeDecomposeUsage({ mode: "smart" });
|
|
7014
|
+
// Stamp the job "done" so a client whose request 504'd mid-run (or any later
|
|
7015
|
+
// poll) resolves to completion and reloads the composition.
|
|
7016
|
+
await writeDecomposeJobState(fork.id, {
|
|
7017
|
+
status: "done",
|
|
7018
|
+
startedAtMs,
|
|
7019
|
+
updatedAtMs: Date.now(),
|
|
7020
|
+
provider: smart.provider,
|
|
7021
|
+
model: smart.model,
|
|
7022
|
+
sceneCount: smart.scenes.length,
|
|
7023
|
+
captionCount: smart.captions.length,
|
|
7024
|
+
durationSeconds: smart.durationSeconds,
|
|
7025
|
+
becameDefaultFork: becameDefault,
|
|
7026
|
+
ghostcutPending: ghostcutTaskId !== null
|
|
7027
|
+
});
|
|
6897
7028
|
return c.json({
|
|
6898
7029
|
ok: true,
|
|
7030
|
+
status: "done",
|
|
6899
7031
|
mode: "smart",
|
|
6900
7032
|
provider: smart.provider,
|
|
6901
7033
|
model: smart.model,
|
|
@@ -6911,6 +7043,15 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
6911
7043
|
catch (error) {
|
|
6912
7044
|
if (error instanceof ForkNotFoundError || error instanceof ForkAccessDeniedError)
|
|
6913
7045
|
return forkAccessErrorResponse(c, error);
|
|
7046
|
+
// Persist a failed job (best-effort) so the client's poll reads a terminal
|
|
7047
|
+
// failure instead of hanging on "processing". Safe: only reached after the
|
|
7048
|
+
// caller passed access control.
|
|
7049
|
+
await writeDecomposeJobState(c.req.param("forkId"), {
|
|
7050
|
+
status: "failed",
|
|
7051
|
+
startedAtMs,
|
|
7052
|
+
updatedAtMs: Date.now(),
|
|
7053
|
+
failureReason: error instanceof Error ? error.message : "Auto-decompose failed."
|
|
7054
|
+
});
|
|
6914
7055
|
return c.json({ ok: false, error: error instanceof Error ? error.message : "Auto-decompose failed." }, 500);
|
|
6915
7056
|
}
|
|
6916
7057
|
});
|
package/dist/src/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ 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
12
|
import { runClipsCommand } from "./devcli/clips.js";
|
|
13
|
+
import { initTelemetry, reportCliCrash } from "./devcli/telemetry.js";
|
|
13
14
|
// vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
|
|
14
15
|
// `serve` command boots the FULL editor locally (single origin, disk-backed
|
|
15
16
|
// records + storage) so power users edit compositions on disk while a browser
|
|
@@ -80,6 +81,19 @@ Discover & inspiration (browse the viral-video catalog, add your own source):
|
|
|
80
81
|
inspiration-rm <id> Delete a private inspiration/template you own → DELETE /discover/templates/:id
|
|
81
82
|
inspiration-decompose <id> AI-decompose an inspiration into scenes → POST /api/v1/inspirations/:id/decompose
|
|
82
83
|
|
|
84
|
+
Make a video in one command (orchestrates ingest → decompose → fork):
|
|
85
|
+
replicate <url> Recreate an existing video end-to-end: fetch → ingest + decompose + fork
|
|
86
|
+
the URL, AI-decompose/recreate its scenes,
|
|
87
|
+
and open an editable fork. --prompt "...and
|
|
88
|
+
make it about X" steers the recreation.
|
|
89
|
+
--title, --no-fork optional.
|
|
90
|
+
create "<prompt>" Spin up a NEW video from a text prompt: AI- → generate + ingest + decompose + fork
|
|
91
|
+
generate a base video, wrap it into an
|
|
92
|
+
editable template, decompose + fork. --duration
|
|
93
|
+
<s>, --aspect-ratio, --resolution, --provider,
|
|
94
|
+
--model, --audio, --ref <url|@file>, --title,
|
|
95
|
+
--no-decompose, --no-fork. Needs a provider key.
|
|
96
|
+
|
|
83
97
|
Composition lifecycle (fork → decompose → snapshot → render):
|
|
84
98
|
fork <template_id> Fork a template into an editable composition → POST /api/v1/compositions
|
|
85
99
|
pull <forkId> Sync composition.html/json + video-context → GET .../compositions/:forkId/{composition.html,...}
|
|
@@ -232,8 +246,11 @@ Publish-mode options:
|
|
|
232
246
|
--message <text> Message attached to the published version snapshot
|
|
233
247
|
--no-snapshot Only update the working copy; skip the version snapshot
|
|
234
248
|
`;
|
|
235
|
-
void main().catch((error) => {
|
|
249
|
+
void main().catch(async (error) => {
|
|
236
250
|
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
|
|
251
|
+
// Report only unexpected crashes (bugs), never the deliberate user-facing
|
|
252
|
+
// `throw new Error(...)` above; no-op unless a devcli DSN is configured.
|
|
253
|
+
await reportCliCrash(error);
|
|
237
254
|
process.exit(1);
|
|
238
255
|
});
|
|
239
256
|
async function main() {
|
|
@@ -243,6 +260,9 @@ async function main() {
|
|
|
243
260
|
return;
|
|
244
261
|
}
|
|
245
262
|
const command = argv[0];
|
|
263
|
+
// Opt-out crash telemetry (also covers the long-lived `serve` runtime, whose
|
|
264
|
+
// in-process job/render captures share this client). Guarded + no-op by default.
|
|
265
|
+
initTelemetry(command);
|
|
246
266
|
const rest = argv.slice(1);
|
|
247
267
|
switch (command) {
|
|
248
268
|
// `serve` (and the bare `<template_id>` / legacy `edit` alias) boot the full
|
|
@@ -273,6 +293,12 @@ async function main() {
|
|
|
273
293
|
case "inspiration-decompose":
|
|
274
294
|
await runInspirationDecomposeCommand(rest);
|
|
275
295
|
return;
|
|
296
|
+
case "replicate":
|
|
297
|
+
await runReplicateCommand(rest);
|
|
298
|
+
return;
|
|
299
|
+
case "create":
|
|
300
|
+
await runCreateCommand(rest);
|
|
301
|
+
return;
|
|
276
302
|
case "fork":
|
|
277
303
|
await runForkCommand(rest);
|
|
278
304
|
return;
|
|
@@ -1194,54 +1220,14 @@ async function runInspirationAddCommand(argv) {
|
|
|
1194
1220
|
if (!existsSync(localPath)) {
|
|
1195
1221
|
throw new Error(`inspiration-add: "${source}" is not an http(s) URL and no such local file exists.`);
|
|
1196
1222
|
}
|
|
1197
|
-
const fileName = path.basename(localPath);
|
|
1198
1223
|
const buffer = readFileSync(localPath);
|
|
1199
|
-
const
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
body: { file_name: fileName, content_type: contentType, size_bytes: buffer.byteLength }
|
|
1206
|
-
});
|
|
1207
|
-
assertApiOk(presign, "inspiration-add (presign)");
|
|
1208
|
-
let storageKey = presign.json?.storage_key;
|
|
1209
|
-
let uploadedName = presign.json?.file_name || fileName;
|
|
1210
|
-
if (presign.json?.transport === "presigned" && presign.json?.upload?.url) {
|
|
1211
|
-
const upload = presign.json.upload;
|
|
1212
|
-
const put = await fetch(upload.url, {
|
|
1213
|
-
method: upload.method || "PUT",
|
|
1214
|
-
headers: upload.headers || {},
|
|
1215
|
-
body: new Uint8Array(buffer)
|
|
1216
|
-
});
|
|
1217
|
-
if (!put.ok)
|
|
1218
|
-
throw new Error(`inspiration-add upload failed with HTTP ${put.status}.`);
|
|
1219
|
-
}
|
|
1220
|
-
else {
|
|
1221
|
-
const form = new FormData();
|
|
1222
|
-
form.append("file", new Blob([new Uint8Array(buffer)], { type: contentType }), fileName);
|
|
1223
|
-
const res = await fetch(new URL(presign.json?.upload?.url || "/discover/templates/upload", ctx.host), {
|
|
1224
|
-
method: "POST",
|
|
1225
|
-
headers: buildAuthHeaders(ctx.auth),
|
|
1226
|
-
body: form
|
|
1227
|
-
});
|
|
1228
|
-
const json = await res.json().catch(() => null);
|
|
1229
|
-
if (!res.ok || !json?.storage_key) {
|
|
1230
|
-
throw new Error(`inspiration-add upload failed with HTTP ${res.status}${json?.error ? `: ${json.error}` : ""}.`);
|
|
1231
|
-
}
|
|
1232
|
-
storageKey = json.storage_key;
|
|
1233
|
-
uploadedName = json.file_name || fileName;
|
|
1234
|
-
}
|
|
1235
|
-
if (!storageKey)
|
|
1236
|
-
throw new Error("inspiration-add upload did not return a storage key.");
|
|
1237
|
-
const result = await apiRequest({
|
|
1238
|
-
method: "POST",
|
|
1239
|
-
host: ctx.host,
|
|
1240
|
-
path: "/discover/templates",
|
|
1241
|
-
auth: ctx.auth,
|
|
1242
|
-
body: { upload: { storage_key: storageKey, file_name: uploadedName }, title: parsed.values.title, tagline: parsed.values.tagline, notes: parsed.values.notes }
|
|
1224
|
+
const result = await ingestUploadedVideo(ctx, {
|
|
1225
|
+
buffer,
|
|
1226
|
+
fileName: path.basename(localPath),
|
|
1227
|
+
title: parsed.values.title,
|
|
1228
|
+
tagline: parsed.values.tagline,
|
|
1229
|
+
notes: parsed.values.notes
|
|
1243
1230
|
});
|
|
1244
|
-
assertApiOk(result, "inspiration-add");
|
|
1245
1231
|
emitResult(result, ctx.json, [["Discover ", discoverFrontendUrl(ctx.host)]]);
|
|
1246
1232
|
return;
|
|
1247
1233
|
}
|
|
@@ -1287,6 +1273,259 @@ async function runInspirationDecomposeCommand(argv) {
|
|
|
1287
1273
|
}
|
|
1288
1274
|
emitResult(result, ctx.json);
|
|
1289
1275
|
}
|
|
1276
|
+
// Presign → PUT the bytes (or server multipart fallback) → POST /discover/templates
|
|
1277
|
+
// with the storage key. Shared by `inspiration-add` (local file) and `create`
|
|
1278
|
+
// (a generated MP4 wrapped into a new template). Returns the ingest ApiResult.
|
|
1279
|
+
async function ingestUploadedVideo(ctx, input) {
|
|
1280
|
+
const { buffer, fileName } = input;
|
|
1281
|
+
const contentType = input.contentType || guessContentType(fileName);
|
|
1282
|
+
const presign = await apiRequest({
|
|
1283
|
+
method: "POST",
|
|
1284
|
+
host: ctx.host,
|
|
1285
|
+
path: "/discover/templates/upload/presign",
|
|
1286
|
+
auth: ctx.auth,
|
|
1287
|
+
body: { file_name: fileName, content_type: contentType, size_bytes: buffer.byteLength }
|
|
1288
|
+
});
|
|
1289
|
+
assertApiOk(presign, "upload (presign)");
|
|
1290
|
+
let storageKey = presign.json?.storage_key;
|
|
1291
|
+
let uploadedName = presign.json?.file_name || fileName;
|
|
1292
|
+
if (presign.json?.transport === "presigned" && presign.json?.upload?.url) {
|
|
1293
|
+
const upload = presign.json.upload;
|
|
1294
|
+
const put = await fetch(upload.url, {
|
|
1295
|
+
method: upload.method || "PUT",
|
|
1296
|
+
headers: upload.headers || {},
|
|
1297
|
+
body: new Uint8Array(buffer)
|
|
1298
|
+
});
|
|
1299
|
+
if (!put.ok)
|
|
1300
|
+
throw new Error(`upload failed with HTTP ${put.status}.`);
|
|
1301
|
+
}
|
|
1302
|
+
else {
|
|
1303
|
+
const form = new FormData();
|
|
1304
|
+
form.append("file", new Blob([new Uint8Array(buffer)], { type: contentType }), fileName);
|
|
1305
|
+
const res = await fetch(new URL(presign.json?.upload?.url || "/discover/templates/upload", ctx.host), {
|
|
1306
|
+
method: "POST",
|
|
1307
|
+
headers: buildAuthHeaders(ctx.auth),
|
|
1308
|
+
body: form
|
|
1309
|
+
});
|
|
1310
|
+
const json = await res.json().catch(() => null);
|
|
1311
|
+
if (!res.ok || !json?.storage_key) {
|
|
1312
|
+
throw new Error(`upload failed with HTTP ${res.status}${json?.error ? `: ${json.error}` : ""}.`);
|
|
1313
|
+
}
|
|
1314
|
+
storageKey = json.storage_key;
|
|
1315
|
+
uploadedName = json.file_name || fileName;
|
|
1316
|
+
}
|
|
1317
|
+
if (!storageKey)
|
|
1318
|
+
throw new Error("upload did not return a storage key.");
|
|
1319
|
+
const result = await apiRequest({
|
|
1320
|
+
method: "POST",
|
|
1321
|
+
host: ctx.host,
|
|
1322
|
+
path: "/discover/templates",
|
|
1323
|
+
auth: ctx.auth,
|
|
1324
|
+
body: { upload: { storage_key: storageKey, file_name: uploadedName }, title: input.title, tagline: input.tagline, notes: input.notes }
|
|
1325
|
+
});
|
|
1326
|
+
assertApiOk(result, "ingest");
|
|
1327
|
+
return result;
|
|
1328
|
+
}
|
|
1329
|
+
// Poll GET /api/v1/videos/:id (which finalizes the download job and mints the
|
|
1330
|
+
// template once the source video lands) until the inspiration is ready/failed.
|
|
1331
|
+
async function pollInspirationReady(ctx, inspirationId, timeoutMs = 5 * 60 * 1000) {
|
|
1332
|
+
const deadline = Date.now() + timeoutMs;
|
|
1333
|
+
let last = null;
|
|
1334
|
+
for (;;) {
|
|
1335
|
+
const res = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/videos/${encodeURIComponent(inspirationId)}`, auth: ctx.auth });
|
|
1336
|
+
if (res.ok && res.json) {
|
|
1337
|
+
last = res.json;
|
|
1338
|
+
const status = String(last.status ?? "");
|
|
1339
|
+
if (status === "ready")
|
|
1340
|
+
return last;
|
|
1341
|
+
if (status === "failed")
|
|
1342
|
+
throw new Error(`source video download failed${last.error ? `: ${last.error}` : ""}.`);
|
|
1343
|
+
}
|
|
1344
|
+
if (Date.now() > deadline) {
|
|
1345
|
+
throw new Error("source video download did not finish in time — re-run, or check with `vidfarm videos --mine`.");
|
|
1346
|
+
}
|
|
1347
|
+
await sleep(5000);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
async function decomposeInspiration(ctx, inspirationId, userPrompt) {
|
|
1351
|
+
const result = await apiRequest({
|
|
1352
|
+
method: "POST",
|
|
1353
|
+
host: ctx.host,
|
|
1354
|
+
path: `/api/v1/inspirations/${encodeURIComponent(inspirationId)}/decompose`,
|
|
1355
|
+
auth: ctx.auth,
|
|
1356
|
+
body: { user_prompt: userPrompt }
|
|
1357
|
+
});
|
|
1358
|
+
assertApiOk(result, "decompose");
|
|
1359
|
+
return result;
|
|
1360
|
+
}
|
|
1361
|
+
async function forkTemplate(ctx, templateId, title) {
|
|
1362
|
+
const result = await apiRequest({
|
|
1363
|
+
method: "POST",
|
|
1364
|
+
host: ctx.host,
|
|
1365
|
+
path: "/api/v1/compositions",
|
|
1366
|
+
auth: ctx.auth,
|
|
1367
|
+
body: { template_id: templateId, title }
|
|
1368
|
+
});
|
|
1369
|
+
assertApiOk(result, "fork");
|
|
1370
|
+
return result;
|
|
1371
|
+
}
|
|
1372
|
+
// Print the final "your video is ready" block with the openable editor URL.
|
|
1373
|
+
function emitVideoResult(ctx, payload, openTemplate, forkId) {
|
|
1374
|
+
if (ctx.json) {
|
|
1375
|
+
printJson({ ok: true, ...payload });
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
printJson({ ok: true, ...payload });
|
|
1379
|
+
console.log("");
|
|
1380
|
+
console.log(` ${BOLD}Open editor ${RESET} ${FRONTEND}${editorFrontendUrl(ctx.host, openTemplate, forkId)}${RESET}`);
|
|
1381
|
+
if (forkId) {
|
|
1382
|
+
console.log(`${DIM}Edit with an agent on disk: vidfarm pull ${forkId} --dir .vidfarm/${forkId}${RESET}`);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
// `replicate <url> [--prompt "...and make it about X"]` — one command from a
|
|
1386
|
+
// TikTok/YT/IG/X URL to an editable recreation. Chains: ingest → wait for the
|
|
1387
|
+
// download + template mint → AI decompose/recreate (steered by --prompt) →
|
|
1388
|
+
// fork a personal editable copy → print the editor URL.
|
|
1389
|
+
async function runReplicateCommand(argv) {
|
|
1390
|
+
const parsed = parseArgs({
|
|
1391
|
+
args: argv,
|
|
1392
|
+
allowPositionals: true,
|
|
1393
|
+
options: { ...commonOptions(), prompt: { type: "string" }, title: { type: "string" }, "no-fork": { type: "boolean", default: false } }
|
|
1394
|
+
});
|
|
1395
|
+
const url = parsed.positionals[0];
|
|
1396
|
+
if (!url || !/^https?:\/\//i.test(url)) {
|
|
1397
|
+
throw new Error('replicate requires a video URL (TikTok/YouTube/Instagram/X): `vidfarm replicate <url> --prompt "..."`.');
|
|
1398
|
+
}
|
|
1399
|
+
const ctx = commonContext(parsed.values);
|
|
1400
|
+
const userPrompt = parsed.values.prompt?.trim() || null;
|
|
1401
|
+
// 1. Ingest the URL → private inspiration (downloads the source video).
|
|
1402
|
+
if (!ctx.json)
|
|
1403
|
+
process.stdout.write(`${DIM}Fetching source video… ${RESET}`);
|
|
1404
|
+
const add = await apiRequest({ method: "POST", host: ctx.host, path: "/discover/templates", auth: ctx.auth, body: { source_url: url } });
|
|
1405
|
+
assertApiOk(add, "replicate (ingest)");
|
|
1406
|
+
const inspirationId = add.json?.inspiration_id;
|
|
1407
|
+
if (!inspirationId)
|
|
1408
|
+
throw new Error("replicate: ingest did not return an inspiration id.");
|
|
1409
|
+
// 2. Wait for the download + template mint.
|
|
1410
|
+
const ready = add.json?.status === "ready" ? add.json : await pollInspirationReady(ctx, inspirationId);
|
|
1411
|
+
if (!ctx.json)
|
|
1412
|
+
console.log(`${GREEN}done${RESET}`);
|
|
1413
|
+
const templateId = ready.template_id;
|
|
1414
|
+
if (!templateId)
|
|
1415
|
+
throw new Error("replicate: no template was minted from the source video.");
|
|
1416
|
+
// 3. Decompose (AI scene analysis + recreation), steered by --prompt.
|
|
1417
|
+
if (!ctx.json)
|
|
1418
|
+
process.stdout.write(`${DIM}Analyzing + recreating scenes (can take a minute)… ${RESET}`);
|
|
1419
|
+
const decomposed = await decomposeInspiration(ctx, inspirationId, userPrompt);
|
|
1420
|
+
if (!ctx.json)
|
|
1421
|
+
console.log(`${GREEN}done${RESET}`);
|
|
1422
|
+
const defaultForkId = decomposed.json?.default_fork_id;
|
|
1423
|
+
// 4. Fork a personal editable copy (unless --no-fork opens the shared base).
|
|
1424
|
+
let forkId = defaultForkId;
|
|
1425
|
+
let openTemplate = templateId;
|
|
1426
|
+
if (!parsed.values["no-fork"]) {
|
|
1427
|
+
const fork = await forkTemplate(ctx, templateId, parsed.values.title);
|
|
1428
|
+
forkId = fork.json?.fork_id ?? defaultForkId;
|
|
1429
|
+
openTemplate = fork.json?.template_id ?? templateId;
|
|
1430
|
+
}
|
|
1431
|
+
emitVideoResult(ctx, { inspiration_id: inspirationId, template_id: openTemplate, fork_id: forkId, default_fork_id: defaultForkId }, openTemplate, forkId);
|
|
1432
|
+
}
|
|
1433
|
+
// `create "<prompt>"` — spin up a brand-new editable video from a text prompt
|
|
1434
|
+
// (no source URL). Chains: generate a base video via the video primitive → wrap
|
|
1435
|
+
// the MP4 into a new template (upload-ingest) → AI decompose into an editable
|
|
1436
|
+
// timeline → fork → print the editor URL. Requires an AI provider key (the
|
|
1437
|
+
// generate + decompose steps use it), same as the web editor.
|
|
1438
|
+
async function runCreateCommand(argv) {
|
|
1439
|
+
const parsed = parseArgs({
|
|
1440
|
+
args: argv,
|
|
1441
|
+
allowPositionals: true,
|
|
1442
|
+
options: {
|
|
1443
|
+
...commonOptions(),
|
|
1444
|
+
prompt: { type: "string" },
|
|
1445
|
+
"aspect-ratio": { type: "string" },
|
|
1446
|
+
duration: { type: "string" },
|
|
1447
|
+
resolution: { type: "string" },
|
|
1448
|
+
provider: { type: "string" },
|
|
1449
|
+
model: { type: "string" },
|
|
1450
|
+
audio: { type: "boolean", default: false },
|
|
1451
|
+
ref: { type: "string", multiple: true },
|
|
1452
|
+
title: { type: "string" },
|
|
1453
|
+
"no-decompose": { type: "boolean", default: false },
|
|
1454
|
+
"no-fork": { type: "boolean", default: false }
|
|
1455
|
+
}
|
|
1456
|
+
});
|
|
1457
|
+
const prompt = (parsed.values.prompt ?? parsed.positionals.join(" ")).trim();
|
|
1458
|
+
if (!prompt)
|
|
1459
|
+
throw new Error('create requires a prompt: `vidfarm create "a 15s ad for my coffee brand"` (or --prompt "...").');
|
|
1460
|
+
const ctx = commonContext(parsed.values);
|
|
1461
|
+
const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
|
|
1462
|
+
// 1. Generate the base video from the prompt.
|
|
1463
|
+
const payload = { prompt };
|
|
1464
|
+
if (parsed.values["aspect-ratio"])
|
|
1465
|
+
payload.aspect_ratio = parsed.values["aspect-ratio"];
|
|
1466
|
+
if (parsed.values.provider)
|
|
1467
|
+
payload.provider = parsed.values.provider;
|
|
1468
|
+
if (parsed.values.model)
|
|
1469
|
+
payload.model = parsed.values.model;
|
|
1470
|
+
if (parsed.values.duration)
|
|
1471
|
+
payload.duration = Math.round(Number(parsed.values.duration));
|
|
1472
|
+
if (parsed.values.resolution)
|
|
1473
|
+
payload.resolution = parsed.values.resolution;
|
|
1474
|
+
if (parsed.values.audio)
|
|
1475
|
+
payload.generate_audio = true;
|
|
1476
|
+
if (refs.length)
|
|
1477
|
+
payload.input_references = refs.slice(0, 8);
|
|
1478
|
+
const tracer = `devcli-create-${Date.now().toString(36)}`;
|
|
1479
|
+
const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/videos/generate", auth: ctx.auth, body: { tracer, payload } });
|
|
1480
|
+
assertApiOk(submit, "create (generate)");
|
|
1481
|
+
const jobId = submit.json?.job_id;
|
|
1482
|
+
if (!jobId)
|
|
1483
|
+
throw new Error("create: generate did not return a job id.");
|
|
1484
|
+
if (!ctx.json)
|
|
1485
|
+
console.log(`${DIM}Generating base video (${jobId})… polling every 5s.${RESET}`);
|
|
1486
|
+
const job = await pollPrimitiveJob(ctx, jobId);
|
|
1487
|
+
const mediaUrl = resolveJobMediaUrl(job);
|
|
1488
|
+
if (!mediaUrl)
|
|
1489
|
+
throw new Error(`create: generation ${String(job?.status ?? "did not finish")} — no media URL. Check your provider keys and wallet.`);
|
|
1490
|
+
// 2. Download the generated MP4 and wrap it into a new template (upload-ingest
|
|
1491
|
+
// path — the same one `inspiration-add <file>` uses).
|
|
1492
|
+
if (!ctx.json)
|
|
1493
|
+
process.stdout.write(`${DIM}Building an editable template from the generated video… ${RESET}`);
|
|
1494
|
+
const dl = await fetch(mediaUrl, { headers: buildAuthHeaders(ctx.auth) });
|
|
1495
|
+
if (!dl.ok)
|
|
1496
|
+
throw new Error(`create: could not fetch the generated video (${dl.status}).`);
|
|
1497
|
+
const buffer = Buffer.from(await dl.arrayBuffer());
|
|
1498
|
+
const title = parsed.values.title ?? prompt.slice(0, 80);
|
|
1499
|
+
const add = await ingestUploadedVideo(ctx, { buffer, fileName: "generated.mp4", contentType: "video/mp4", title });
|
|
1500
|
+
const inspirationId = add.json?.inspiration_id;
|
|
1501
|
+
if (!inspirationId)
|
|
1502
|
+
throw new Error("create: ingest did not return an inspiration id.");
|
|
1503
|
+
const ready = add.json?.status === "ready" ? add.json : await pollInspirationReady(ctx, inspirationId);
|
|
1504
|
+
if (!ctx.json)
|
|
1505
|
+
console.log(`${GREEN}done${RESET}`);
|
|
1506
|
+
const templateId = ready.template_id;
|
|
1507
|
+
if (!templateId)
|
|
1508
|
+
throw new Error("create: no template was minted from the generated video.");
|
|
1509
|
+
// 3. Decompose into an editable per-scene timeline (unless --no-decompose).
|
|
1510
|
+
let defaultForkId;
|
|
1511
|
+
if (!parsed.values["no-decompose"]) {
|
|
1512
|
+
if (!ctx.json)
|
|
1513
|
+
process.stdout.write(`${DIM}Analyzing into an editable timeline… ${RESET}`);
|
|
1514
|
+
const decomposed = await decomposeInspiration(ctx, inspirationId, prompt);
|
|
1515
|
+
defaultForkId = decomposed.json?.default_fork_id;
|
|
1516
|
+
if (!ctx.json)
|
|
1517
|
+
console.log(`${GREEN}done${RESET}`);
|
|
1518
|
+
}
|
|
1519
|
+
// 4. Fork a personal editable copy (unless --no-fork).
|
|
1520
|
+
let forkId = defaultForkId;
|
|
1521
|
+
let openTemplate = templateId;
|
|
1522
|
+
if (!parsed.values["no-fork"]) {
|
|
1523
|
+
const fork = await forkTemplate(ctx, templateId, parsed.values.title);
|
|
1524
|
+
forkId = fork.json?.fork_id ?? defaultForkId;
|
|
1525
|
+
openTemplate = fork.json?.template_id ?? templateId;
|
|
1526
|
+
}
|
|
1527
|
+
emitVideoResult(ctx, { inspiration_id: inspirationId, template_id: openTemplate, fork_id: forkId, default_fork_id: defaultForkId, generated_video_url: mediaUrl }, openTemplate, forkId);
|
|
1528
|
+
}
|
|
1290
1529
|
// ── Composition lifecycle ───────────────────────────────────────────────────
|
|
1291
1530
|
async function runForkCommand(argv) {
|
|
1292
1531
|
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), title: { type: "string" } } });
|
|
@@ -1312,13 +1551,55 @@ async function runDecomposeCommand(argv) {
|
|
|
1312
1551
|
if (!forkId)
|
|
1313
1552
|
throw new Error("decompose requires a fork id.");
|
|
1314
1553
|
const ctx = commonContext(parsed.values);
|
|
1315
|
-
const
|
|
1554
|
+
const decomposePath = `/api/v1/compositions/${encodeURIComponent(forkId)}/auto-decompose`;
|
|
1555
|
+
// Decompose can run longer than the 60s CloudFront origin timeout on long
|
|
1556
|
+
// videos. `force: true` KICKS a resumable run; the server keeps working even
|
|
1557
|
+
// if this request 504s. So we tolerate a non-`done` kick and poll GET
|
|
1558
|
+
// .../auto-decompose until the job is terminal.
|
|
1559
|
+
let result = await apiRequest({
|
|
1316
1560
|
method: "POST",
|
|
1317
1561
|
host: ctx.host,
|
|
1318
|
-
path:
|
|
1562
|
+
path: decomposePath,
|
|
1319
1563
|
auth: ctx.auth,
|
|
1320
|
-
body: { mode: parsed.values.mode, user_prompt: parsed.values.prompt ?? null }
|
|
1564
|
+
body: { force: true, mode: parsed.values.mode, user_prompt: parsed.values.prompt ?? null }
|
|
1321
1565
|
});
|
|
1566
|
+
const kickStatus = typeof result.json?.status === "string" ? result.json.status : null;
|
|
1567
|
+
if (!(result.ok && kickStatus === "done")) {
|
|
1568
|
+
if (!ctx.json)
|
|
1569
|
+
process.stdout.write(`${DIM}Decomposing (may take a minute on long videos)… ${RESET}`);
|
|
1570
|
+
const deadline = Date.now() + 12 * 60 * 1000;
|
|
1571
|
+
let settled = null;
|
|
1572
|
+
while (Date.now() < deadline) {
|
|
1573
|
+
await sleep(5000);
|
|
1574
|
+
let statusResult;
|
|
1575
|
+
try {
|
|
1576
|
+
statusResult = await apiRequest({ method: "GET", host: ctx.host, path: decomposePath, auth: ctx.auth });
|
|
1577
|
+
}
|
|
1578
|
+
catch {
|
|
1579
|
+
continue;
|
|
1580
|
+
}
|
|
1581
|
+
const s = typeof statusResult.json?.status === "string" ? statusResult.json.status : "none";
|
|
1582
|
+
if (s === "done" || s === "failed") {
|
|
1583
|
+
settled = statusResult;
|
|
1584
|
+
break;
|
|
1585
|
+
}
|
|
1586
|
+
if (s === "none")
|
|
1587
|
+
break; // the run never claimed — surface the kick error below
|
|
1588
|
+
}
|
|
1589
|
+
if (settled) {
|
|
1590
|
+
result = settled;
|
|
1591
|
+
if (!ctx.json)
|
|
1592
|
+
console.log(settled.json?.status === "done" ? `${GREEN}done${RESET}` : `${RED}failed${RESET}`);
|
|
1593
|
+
}
|
|
1594
|
+
else {
|
|
1595
|
+
// Timed out or never started — surface the kick's own error if it was one.
|
|
1596
|
+
assertApiOk(result, "decompose");
|
|
1597
|
+
throw new Error("decompose did not complete in time (it may still finish server-side; re-run `vidfarm decompose` to check).");
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
if (result.json?.status === "failed") {
|
|
1601
|
+
throw new Error(`decompose failed: ${result.json?.error ?? "unknown error"}`);
|
|
1602
|
+
}
|
|
1322
1603
|
assertApiOk(result, "decompose");
|
|
1323
1604
|
if (!ctx.json && result.json?.ghostcut_pending) {
|
|
1324
1605
|
console.log(`${DIM}Subtitle removal (GhostCut) is running in the background — poll \`vidfarm api POST /api/v1/compositions/${forkId}/remove-video-captions-poll\` or GET .../remove-video-captions until done.${RESET}`);
|