@mevdragon/vidfarm-devcli 0.19.0 → 0.20.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.
Files changed (44) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +3 -3
  2. package/.agents/skills/music/SKILL.md +416 -0
  3. package/.agents/skills/music/references/api_reference.md +519 -0
  4. package/.agents/skills/music/references/installation.md +65 -0
  5. package/.agents/skills/text-to-speech/SKILL.md +226 -0
  6. package/.agents/skills/text-to-speech/references/installation.md +90 -0
  7. package/.agents/skills/text-to-speech/references/streaming.md +307 -0
  8. package/.agents/skills/text-to-speech/references/voice-settings.md +115 -0
  9. package/.agents/skills/vidfarm-media/SKILL.md +25 -11
  10. package/.agents/skills/vidfarm-media/references/tts.md +41 -3
  11. package/SKILL.director.md +31 -13
  12. package/SKILL.platform.md +2 -2
  13. package/demo/dist/app.css +1 -1
  14. package/demo/dist/app.js +76 -74
  15. package/dist/src/account-pages-legacy.js +1 -1
  16. package/dist/src/app.js +308 -111
  17. package/dist/src/cli.js +162 -5
  18. package/dist/src/config.js +13 -0
  19. package/dist/src/devcli/clips.js +7 -2
  20. package/dist/src/devcli/composition-edit.js +5 -2
  21. package/dist/src/domain.js +3 -0
  22. package/dist/src/editor-chat.js +8 -1
  23. package/dist/src/primitive-context.js +45 -2
  24. package/dist/src/primitive-registry.js +140 -18
  25. package/dist/src/reskin/chat-page.js +1 -1
  26. package/dist/src/reskin/inpaint-clipper-page.js +446 -205
  27. package/dist/src/reskin/inpaint-page.js +1 -1
  28. package/dist/src/reskin/inpaint-video-page.js +1 -1
  29. package/dist/src/reskin/library-page.js +7 -1
  30. package/dist/src/reskin/portfolio-page.js +687 -0
  31. package/dist/src/reskin/settings-page.js +4 -4
  32. package/dist/src/reskin/theme.js +1 -0
  33. package/dist/src/services/billing.js +5 -0
  34. package/dist/src/services/clip-curation/ffmpeg.js +48 -0
  35. package/dist/src/services/clip-curation/hunt.js +2 -0
  36. package/dist/src/services/clip-curation/index.js +1 -1
  37. package/dist/src/services/clip-curation/scan.js +29 -16
  38. package/dist/src/services/elevenlabs.js +222 -0
  39. package/dist/src/services/hyperframes.js +252 -83
  40. package/dist/src/services/providers.js +216 -2
  41. package/dist/src/services/serverless-jobs.js +3 -1
  42. package/dist/src/services/swipe-customize.js +5 -2
  43. package/dist/src/template-editor-pages.js +1 -1
  44. package/package.json +1 -1
package/dist/src/cli.js CHANGED
@@ -285,6 +285,8 @@ Raws (the third library — mine long-form video into a reusable raws store):
285
285
  --tracer <name> Group this session's clips (also the default folder)
286
286
  --folder <name> Save the clip into /raws/<name>
287
287
  --name <text> Human name for the clip
288
+ --audio-only Save just the audio of the subrange as an .m4a raw
289
+ (needs --start/--end; e.g. lift a copyright-free track)
288
290
  (multi-clip: re-run with a new range + same --tracer)
289
291
 
290
292
  Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit backup:
@@ -300,7 +302,19 @@ Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit bac
300
302
  --engine local KEYLESS local Kokoro-82M engine (free, no account) — fixed voice
301
303
  presets (af_heart, am_adam, bf_emma, …), no --style. When NO
302
304
  provider key is found the default byok engine auto-falls back here.
303
- --cloud BACKUP: saved-key platform job → POST /api/v1/primitives/audio/speech (+ poll)
305
+ --cloud BACKUP: platform job → POST /api/v1/primitives/audio/speech (+ poll).
306
+ Defaults to ElevenLabs on the platform key (wallet-billed); --voice
307
+ takes an ElevenLabs voice_id (see: vidfarm voices).
308
+ --own-key (--cloud) use your own ElevenLabs / BYOK key instead of the wallet
309
+ --list-voices List ElevenLabs voices and exit (same as: vidfarm voices)
310
+ music "<prompt>" Prompt → music track (ElevenLabs). CLOUD-ONLY (no local engine)
311
+ --length <sec> | --length-ms <ms> Duration (default 30s, max 5 min)
312
+ --out <file> Output mp3 path (default music-<id>.mp3)
313
+ --own-key Use your own ElevenLabs key instead of the platform key + wallet
314
+ (default: platform ElevenLabs, wallet-billed) → POST /api/v1/primitives/music/generate
315
+ voices List ElevenLabs voices for TTS (voice_id + name + labels).
316
+ --own-key List voices on your own saved ElevenLabs key (default: platform account)
317
+ --limit <n> | --json Show more / raw JSON
304
318
  stt <file|url> Video or audio → transcript (alias: transcribe). LOCAL: local ffmpeg demux +
305
319
  Returns BOTH formats: the simple subtitle your provider key (gemini labels
306
320
  version (plain text + timed SRT cues) and speakers; openai/openrouter give
@@ -315,8 +329,11 @@ Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit bac
315
329
  timestamps (words[] in the .json/--json output), no speaker labels.
316
330
  When NO provider key is found the default byok engine auto-falls
317
331
  back here. --model then picks the whisper model (tiny.en…large-v3).
318
- --cloud BACKUP: saved-key platform job → POST /api/v1/primitives/audio/transcribe (+ poll)
332
+ --cloud BACKUP: platform job → POST /api/v1/primitives/audio/transcribe (+ poll).
333
+ Defaults to ElevenLabs Scribe on the platform key (wallet-billed;
334
+ native diarization + real word timings).
319
335
  (a local file uploads to your temp folder first — 30-day TTL)
336
+ --own-key (--cloud) use your own ElevenLabs / BYOK key instead of the wallet
320
337
 
321
338
  Animated captions (word-by-word TikTok/CapCut styles on a pulled/served composition):
322
339
  captions generate <dir> Transcribe the narration & lay down animated LOCAL: local ffmpeg demux +
@@ -586,6 +603,12 @@ async function main() {
586
603
  case "tts":
587
604
  await runTtsCommand(rest);
588
605
  return;
606
+ case "music":
607
+ await runMusicCommand(rest);
608
+ return;
609
+ case "voices":
610
+ await runVoicesCommand(rest);
611
+ return;
589
612
  case "stt":
590
613
  case "transcribe":
591
614
  await runSttCommand(rest);
@@ -3045,10 +3068,17 @@ async function runTtsCommand(argv) {
3045
3068
  "gemini-key": { type: "string" },
3046
3069
  "openai-key": { type: "string" },
3047
3070
  "openrouter-key": { type: "string" },
3071
+ "own-key": { type: "boolean", default: false },
3072
+ "list-voices": { type: "boolean", default: false },
3048
3073
  "no-wait": { type: "boolean", default: false },
3049
3074
  tracer: { type: "string" }
3050
3075
  }
3051
3076
  });
3077
+ // --list-voices: browse the ElevenLabs voice catalog and exit (cloud call).
3078
+ if (parsed.values["list-voices"]) {
3079
+ await runVoicesCommand(parsed.values["own-key"] ? ["--own-key"] : []);
3080
+ return;
3081
+ }
3052
3082
  const text = (parsed.values.text ?? parsed.positionals.join(" ")).trim();
3053
3083
  if (!text)
3054
3084
  throw new Error('tts requires text: vidfarm tts "Welcome back to the channel" --style "calm, warm narrator".');
@@ -3118,7 +3148,9 @@ async function runTtsCommand(argv) {
3118
3148
  }
3119
3149
  return;
3120
3150
  }
3121
- // BACKUP: the platform primitive route (saved provider keys, async job).
3151
+ // BACKUP: the platform primitive route (async job). Defaults to high-quality
3152
+ // ElevenLabs on the platform key, billed to the wallet (use_wallet_credits).
3153
+ // --own-key flips to your own saved ElevenLabs / BYOK provider key instead.
3122
3154
  const ctx = commonContext(parsed.values);
3123
3155
  const payload = { text, output_format: format };
3124
3156
  if (voice)
@@ -3129,6 +3161,11 @@ async function runTtsCommand(argv) {
3129
3161
  payload.provider = parsed.values.provider;
3130
3162
  if (parsed.values.model)
3131
3163
  payload.model = parsed.values.model;
3164
+ if (parsed.values["own-key"])
3165
+ payload.use_wallet_credits = false;
3166
+ if (!ctx.json && !parsed.values["own-key"]) {
3167
+ console.log(`${DIM}Using ElevenLabs on the platform key (wallet-billed). Browse voices: vidfarm voices — then pass --voice <voice_id>. Add --own-key to use your own key.${RESET}`);
3168
+ }
3132
3169
  const tracer = parsed.values.tracer ?? `devcli-tts-${Date.now().toString(36)}`;
3133
3170
  const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/audio/speech", auth: ctx.auth, body: { tracer, payload } });
3134
3171
  assertApiOk(submit, "tts");
@@ -3290,6 +3327,112 @@ function emitSttResult(input) {
3290
3327
  console.log(`${GREEN}Wrote ${dest}${RESET}`);
3291
3328
  }
3292
3329
  }
3330
+ // Music generation (ElevenLabs) — cloud-only (no local engine). Defaults to the
3331
+ // platform key billed to the wallet; --own-key uses your saved ElevenLabs key.
3332
+ async function runMusicCommand(argv) {
3333
+ const parsed = parseArgs({
3334
+ args: argv,
3335
+ allowPositionals: true,
3336
+ options: {
3337
+ ...commonOptions(),
3338
+ prompt: { type: "string" },
3339
+ "length-ms": { type: "string" },
3340
+ length: { type: "string" },
3341
+ model: { type: "string" },
3342
+ out: { type: "string" },
3343
+ "own-key": { type: "boolean", default: false },
3344
+ "no-wait": { type: "boolean", default: false },
3345
+ tracer: { type: "string" }
3346
+ }
3347
+ });
3348
+ const prompt = (parsed.values.prompt ?? parsed.positionals.join(" ")).trim();
3349
+ if (!prompt)
3350
+ throw new Error('music requires a prompt: vidfarm music "upbeat lo-fi hip hop beat with jazzy piano" --length 30');
3351
+ const ctx = commonContext(parsed.values);
3352
+ let musicLengthMs;
3353
+ const lengthMs = parsed.values["length-ms"]?.trim();
3354
+ const lengthSec = parsed.values.length?.trim();
3355
+ if (lengthMs)
3356
+ musicLengthMs = Math.round(Number(lengthMs));
3357
+ else if (lengthSec)
3358
+ musicLengthMs = Math.round(Number(lengthSec) * 1000);
3359
+ const payload = { prompt };
3360
+ if (musicLengthMs && Number.isFinite(musicLengthMs))
3361
+ payload.music_length_ms = musicLengthMs;
3362
+ if (parsed.values.model)
3363
+ payload.model = parsed.values.model;
3364
+ if (parsed.values["own-key"])
3365
+ payload.use_wallet_credits = false;
3366
+ if (!ctx.json && !parsed.values["own-key"]) {
3367
+ console.log(`${DIM}Composing with ElevenLabs on the platform key (wallet-billed). Add --own-key to use your own ElevenLabs key.${RESET}`);
3368
+ }
3369
+ const tracer = parsed.values.tracer ?? `devcli-music-${Date.now().toString(36)}`;
3370
+ const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/music/generate", auth: ctx.auth, body: { tracer, payload } });
3371
+ assertApiOk(submit, "music");
3372
+ const jobId = submit.json?.job_id;
3373
+ if (parsed.values["no-wait"] || !jobId) {
3374
+ if (!ctx.json && jobId)
3375
+ console.log(`${DIM}Queued ${jobId} (tracer ${tracer}). Poll: vidfarm api GET /api/v1/user/me/jobs/${jobId} — or drop --no-wait to auto-poll.${RESET}`);
3376
+ emitResult(submit, ctx.json);
3377
+ return;
3378
+ }
3379
+ if (!ctx.json)
3380
+ console.log(`${DIM}Generating music (${jobId})… polling every 5s.${RESET}`);
3381
+ const job = await pollPrimitiveJob(ctx, jobId);
3382
+ const mediaUrl = resolveJobMediaUrl(job);
3383
+ if (!mediaUrl) {
3384
+ if (ctx.json) {
3385
+ printJson({ ok: false, job_id: jobId, status: String(job?.status ?? ""), job });
3386
+ }
3387
+ else {
3388
+ console.log(`${RED}Music ${String(job?.status ?? "did not finish")} — no audio URL.${RESET}`);
3389
+ printJson(job);
3390
+ }
3391
+ process.exitCode = 1;
3392
+ return;
3393
+ }
3394
+ const outPath = path.resolve(process.cwd(), parsed.values.out ?? `music-${jobId}.mp3`);
3395
+ await downloadUrlToFile(mediaUrl, outPath);
3396
+ if (ctx.json) {
3397
+ printJson({ ok: true, job_id: jobId, tracer, audio_url: mediaUrl, out: outPath });
3398
+ }
3399
+ else {
3400
+ console.log(`${GREEN}Wrote ${outPath}${RESET} ${DIM}(${mediaUrl})${RESET}`);
3401
+ console.log(`${DIM}Place it as its own <audio> layer under narration (~0.1–0.2 volume).${RESET}`);
3402
+ }
3403
+ }
3404
+ // List ElevenLabs voices for TTS. --own-key lists the voices on your own saved
3405
+ // ElevenLabs key; default lists the platform account's voices.
3406
+ async function runVoicesCommand(argv) {
3407
+ const parsed = parseArgs({
3408
+ args: argv,
3409
+ allowPositionals: true,
3410
+ options: {
3411
+ ...commonOptions(),
3412
+ "own-key": { type: "boolean", default: false },
3413
+ limit: { type: "string" }
3414
+ }
3415
+ });
3416
+ const ctx = commonContext(parsed.values);
3417
+ const query = parsed.values["own-key"] ? "?use_wallet_credits=false" : "";
3418
+ const res = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/primitives/audio/voices${query}`, auth: ctx.auth });
3419
+ assertApiOk(res, "voices");
3420
+ const body = (res.json ?? {});
3421
+ const voices = Array.isArray(body.voices) ? body.voices : [];
3422
+ if (ctx.json) {
3423
+ printJson(body);
3424
+ return;
3425
+ }
3426
+ console.log(`${DIM}${voices.length} ElevenLabs voices (${body.scope ?? "platform"} account). Browse more: ${body.voice_library_url ?? "https://elevenlabs.io/app/voice-library"}${RESET}`);
3427
+ const limit = Number(parsed.values.limit ?? "40") || 40;
3428
+ for (const voice of voices.slice(0, limit)) {
3429
+ const labels = voice.labels && typeof voice.labels === "object" ? Object.values(voice.labels).join(", ") : "";
3430
+ console.log(`${GREEN}${voice.voice_id}${RESET} ${voice.name}${labels ? ` ${DIM}(${labels})${RESET}` : ""}`);
3431
+ }
3432
+ if (voices.length > limit)
3433
+ console.log(`${DIM}… ${voices.length - limit} more (raise with --limit or use --json).${RESET}`);
3434
+ console.log(`${DIM}Use one: vidfarm tts "Hello" --cloud --voice <voice_id>${RESET}`);
3435
+ }
3293
3436
  async function runSttCommand(argv) {
3294
3437
  const parsed = parseArgs({
3295
3438
  args: argv,
@@ -3307,6 +3450,7 @@ async function runSttCommand(argv) {
3307
3450
  "gemini-key": { type: "string" },
3308
3451
  "openai-key": { type: "string" },
3309
3452
  "openrouter-key": { type: "string" },
3453
+ "own-key": { type: "boolean", default: false },
3310
3454
  "no-wait": { type: "boolean", default: false },
3311
3455
  tracer: { type: "string" },
3312
3456
  folder: { type: "string" }
@@ -3405,6 +3549,11 @@ async function runSttCommand(argv) {
3405
3549
  payload.provider = parsed.values.provider;
3406
3550
  if (parsed.values.model)
3407
3551
  payload.model = parsed.values.model;
3552
+ // Cloud STT defaults to ElevenLabs Scribe on the platform key (native
3553
+ // diarization + real word timings), wallet-billed. --own-key uses your own
3554
+ // ElevenLabs / BYOK key instead.
3555
+ if (parsed.values["own-key"])
3556
+ payload.use_wallet_credits = false;
3408
3557
  const tracer = parsed.values.tracer ?? `devcli-stt-${Date.now().toString(36)}`;
3409
3558
  const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/audio/transcribe", auth: ctx.auth, body: { tracer, payload } });
3410
3559
  assertApiOk(submit, "stt");
@@ -3953,7 +4102,8 @@ async function runClipperCommand(argv) {
3953
4102
  out: { type: "string" },
3954
4103
  tracer: { type: "string" },
3955
4104
  folder: { type: "string" },
3956
- name: { type: "string" }
4105
+ name: { type: "string" },
4106
+ "audio-only": { type: "boolean", default: false }
3957
4107
  }
3958
4108
  });
3959
4109
  const source = parsed.positionals[0];
@@ -3970,6 +4120,10 @@ async function runClipperCommand(argv) {
3970
4120
  if (!wholeVideo && (!hasStart || !hasEnd)) {
3971
4121
  throw new Error("clipper needs BOTH --start and --end for an exact subrange, or NEITHER to import the whole video.");
3972
4122
  }
4123
+ const audioOnly = Boolean(parsed.values["audio-only"]);
4124
+ if (audioOnly && wholeVideo) {
4125
+ throw new Error("--audio-only needs an exact subrange (--start <t> --end <t>). Whole-video import keeps the video track.");
4126
+ }
3973
4127
  const body = {};
3974
4128
  if (parsed.values.tracer)
3975
4129
  body.tracer = String(parsed.values.tracer);
@@ -3977,6 +4131,8 @@ async function runClipperCommand(argv) {
3977
4131
  body.folder_path = String(parsed.values.folder);
3978
4132
  if (parsed.values.name)
3979
4133
  body.name = String(parsed.values.name);
4134
+ if (audioOnly)
4135
+ body.audio_only = true;
3980
4136
  // Resolve the source into the body once: a URL rides as source_url; a local
3981
4137
  // file is staged into the temp store and referenced by temp_file_id.
3982
4138
  if (/^https?:\/\//i.test(source)) {
@@ -4037,7 +4193,8 @@ async function runClipperCommand(argv) {
4037
4193
  }
4038
4194
  const j = res.json;
4039
4195
  const dest = j?.folder_path ? `/raws/${j.folder_path}` : "your raws library";
4040
- console.log(`${GREEN}Clipped ${(j?.duration_sec ?? (endSec - startSec)).toFixed?.(2) ?? j?.duration_sec}s ${dest} (clip ${j?.clip_id ?? "?"}).${RESET}`);
4196
+ const kindWord = (j?.media_kind === "audio" || audioOnly) ? "audio" : "clip";
4197
+ console.log(`${GREEN}Clipped ${kindWord} ${(j?.duration_sec ?? (endSec - startSec)).toFixed?.(2) ?? j?.duration_sec}s → ${dest} (clip ${j?.clip_id ?? "?"}).${RESET}`);
4041
4198
  if (j?.view_url)
4042
4199
  console.log(`${DIM}Preview: ${new URL(j.view_url, ctx.host).toString()}${RESET}`);
4043
4200
  console.log(`${DIM}Grab another range from the same source with the same --tracer/--folder.${RESET}`);
@@ -32,6 +32,7 @@ const schema = z.object({
32
32
  PROVIDER_LEASE_WAIT_TIMEOUT_SECONDS: z.coerce.number().default(5 * 60),
33
33
  PROVIDER_LEASE_INLINE_WAIT_SECONDS: z.coerce.number().default(5),
34
34
  PROVIDER_LEASE_STEP_WAIT_SECONDS: z.coerce.number().int().min(1).default(60),
35
+ RENDER_POLL_WAIT_SECONDS: z.coerce.number().int().min(1).default(20),
35
36
  PROVIDER_LEASE_MAX_STEP_WAIT_SECONDS: z.coerce.number().int().min(1).default(12 * 60 * 60),
36
37
  MAX_ACTIVE_JOBS_PER_CUSTOMER: z.coerce.number().int().min(1).default(30),
37
38
  STORAGE_DRIVER: z.enum(["local", "s3"]).default("local"),
@@ -65,6 +66,18 @@ const schema = z.object({
65
66
  OPENROUTER_API_KEY: z.string().optional(),
66
67
  GEMINI_API_KEY: z.string().optional(),
67
68
  PERPLEXITY_API_KEY: z.string().optional(),
69
+ // Platform ElevenLabs key — the fallback used for music / TTS / STT primitives
70
+ // when the caller opts into `use_wallet_credits` (the default) or has no usable
71
+ // BYOK audio key. Usage on this key is billed to the customer's vidfarm wallet.
72
+ ELEVENLABS_API_KEY: z.string().optional(),
73
+ // Base provider costs for ElevenLabs primitives billed to the wallet (the
74
+ // billing lambda applies the standard markup on top). Defaults track published
75
+ // ElevenLabs list pricing: TTS multilingual v2/v3 $0.10 / 1K chars (flash/turbo
76
+ // is $0.05), Music $0.15 / minute, Scribe STT $0.22 / hour ≈ $0.003667 / min.
77
+ // Override per-environment as pricing shifts.
78
+ ELEVENLABS_MUSIC_USD_PER_MIN: z.coerce.number().min(0).default(0.15),
79
+ ELEVENLABS_TTS_USD_PER_1K_CHARS: z.coerce.number().min(0).default(0.10),
80
+ ELEVENLABS_STT_USD_PER_MIN: z.coerce.number().min(0).default(0.003667),
68
81
  GHOSTCUT_KEY: z.string().optional(),
69
82
  GHOSTCUT_SECRET: z.string().optional(),
70
83
  GHOSTCUT_USD_PER_30S: z.coerce.number().min(0).default(0.10),
@@ -44,6 +44,7 @@ export const CLIPS_HELP = `vidfarm raws — build & search a local raws library
44
44
  --yes Skip the >$1 confirmation prompt
45
45
  --frames <n> Keyframes per scene for tagging (1-3, default 2)
46
46
  --no-audio Skip per-scene audio (Gemini only; no transcripts; cheaper)
47
+ --audio-only Save each mined clip as an audio-only .m4a (no video); waveform thumb
47
48
  --concurrency <n> Parallel scenes (default 3; agent default 2)
48
49
  --threshold <0..1> Scene-cut sensitivity (default 0.3; lower = more cuts)
49
50
  --min-scene <sec> Merge scenes shorter than this (default 0.6)
@@ -133,6 +134,7 @@ async function runScan(argv) {
133
134
  "allow-text": { type: "boolean" },
134
135
  "max-clips": { type: "string" },
135
136
  "no-refine": { type: "boolean" },
137
+ "audio-only": { type: "boolean" },
136
138
  cloud: { type: "boolean" },
137
139
  url: { type: "string" },
138
140
  tracer: { type: "string" },
@@ -197,7 +199,8 @@ async function runScan(argv) {
197
199
  aspect,
198
200
  cropFocus,
199
201
  avoidText,
200
- maxClips
202
+ maxClips,
203
+ audioOnly: Boolean(values["audio-only"])
201
204
  });
202
205
  }
203
206
  const videoArg = positionals[0];
@@ -336,6 +339,7 @@ async function runScan(argv) {
336
339
  guidancePrompt: guidance,
337
340
  refineSegmentation: !values["no-refine"],
338
341
  reencodeClips: true,
342
+ audioOnly: Boolean(values["audio-only"]),
339
343
  crop: aspect && aspect !== "original" ? { aspect, focus: cropFocus } : undefined
340
344
  };
341
345
  console.log(`[raws] scanning ${scenes.length} scenes (${provider}/${activeClient.tagModelId}, ${concurrency}x) …`);
@@ -466,7 +470,8 @@ async function runScanCloud(input) {
466
470
  // Send explicitly (incl. false) so --allow-text can override the
467
471
  // server-side avoid-text default.
468
472
  avoid_text: input.avoidText,
469
- ...(input.maxClips ? { max_clips: input.maxClips } : {})
473
+ ...(input.maxClips ? { max_clips: input.maxClips } : {}),
474
+ ...(input.audioOnly ? { audio_only: true } : {})
470
475
  }
471
476
  })
472
477
  });
@@ -316,9 +316,12 @@ function resolveCaptionLook(opts) {
316
316
  color: opts.color ?? preset?.color ?? "#ffffff",
317
317
  background: opts.background ?? preset?.background ?? "#000000",
318
318
  backgroundStyle: (opts.backgroundStyle ?? preset?.textBackgroundStyle ?? "plain"),
319
- fontFamily: opts.fontFamily ?? "Montserrat",
319
+ // A 0 (or empty) font size/family renders the caption invisibly — the shared
320
+ // action/flag schema lets callers pass font_size:0 or font_family:"", so treat
321
+ // only a positive size / non-empty family as an intentional override.
322
+ fontFamily: opts.fontFamily?.trim() ? opts.fontFamily.trim() : "Montserrat",
320
323
  fontWeight: opts.fontWeight ?? preset?.fontWeight ?? 800,
321
- fontSize: opts.fontSize ?? 38,
324
+ fontSize: typeof opts.fontSize === "number" && opts.fontSize > 0 ? opts.fontSize : 38,
322
325
  presetId: preset?.id ?? animation
323
326
  };
324
327
  }
@@ -1,2 +1,5 @@
1
+ // Providers that count as a qualified AI key for text/image/video generation
2
+ // (everything except the audio-only elevenlabs key).
3
+ export const QUALIFIED_AI_PROVIDER_TYPES = ["openai", "gemini", "openrouter", "perplexity"];
1
4
  export const CUSTOMER_PLAN_TIERS = ["client", "standalone", "agency"];
2
5
  //# sourceMappingURL=domain.js.map
@@ -21,6 +21,10 @@ export function defaultEditorChatModel(provider) {
21
21
  return "qwen/qwen3.6-plus";
22
22
  case "perplexity":
23
23
  return "sonar";
24
+ default:
25
+ // Non-text providers (e.g. audio-only elevenlabs) never reach editor chat;
26
+ // return a safe text default rather than undefined.
27
+ return "gpt-5.4-mini";
24
28
  }
25
29
  }
26
30
  export function buildTemplateEditorChatSystemPrompt(input) {
@@ -74,6 +78,9 @@ export function buildTemplateEditorChatSystemPrompt(input) {
74
78
  "If the user asks to add, remove, retime, reposition, resize, restyle, swap media, tag, note, group, ungroup, duplicate, split, animate, keyframe, nudge, ripple, trim, or restack layers on the composition timeline, call the editor_action tool. Supported action_type values are add_layer, remove_layer, set_layer_timing, set_layer_visual, set_layer_style, set_layer_media, set_layer_text, set_layer_identity, duplicate_layer, split_layer, group_layers, ungroup_layers, generate_layer, replace_composition_html, export_composition, set_captions, set_transitions, set_layer_keyframes, nudge_layers, ripple_edit, trim_layer, set_layer_zindex, set_composition, and fork_composition.",
75
79
  "FINE TIMELINE CONTROL — you have trackpad-level verbs; use them instead of rewriting the whole document for small changes: (1) set_layer_keyframes authors a custom, script-free CSS @keyframes animation on ONE layer that previews AND renders — pass layer_key + keyframes:[{offset(0..1), opacity?, translate_x?, translate_y?, scale?, rotate?}, …] (>=2 stops), optional keyframe_easing (linear/ease/ease-in/ease-out/ease-in-out/cubic-bezier(...)) and keyframe_duration (seconds, default = clip duration). translate_x/translate_y are percentages of the layer's OWN box. Example fly-up-and-fade-in: keyframes=[{offset:0,opacity:0,translate_y:40},{offset:1,opacity:1,translate_y:0}]. This is the durable way to hand-author motion beyond the Ken Burns / transition / caption presets — the JS runtime adapters (anime.js/GSAP/Lottie) are devcli-only. (2) nudge_layers shifts one or more layers by a RELATIVE delta_start (seconds) and/or delta_track (lanes) — pass layer_key or layer_keys; grouped clips move together. (3) ripple_edit inserts (delta_start>0) or closes (delta_start<0) time at at_time and shifts every downstream clip so the rest of the timeline stays in sync — use it to make room before an insert or to close a gap you can see in timeline_gaps. (4) trim_layer moves ONE edge to a time: edge='start' (left; advances the in-point, and for video/audio pushes the media start so the visible frame is unchanged) or edge='end' (right; changes duration), with to_time in composition seconds. (5) set_layer_zindex restacks: z_order='front'|'back'|'forward'|'backward' (stacking == track index; higher draws on top) or an explicit track. All of these auto-resolve track collisions and note it in the summary; read the NEXT editor_context to confirm the new start/duration/track/animation landed.",
76
80
  "If the user asks to export, render, publish, or produce the final MP4 for this composition (phrases like 'export it', 'render this', 'kick off the export', 'make the mp4'), call editor_action with action_type=export_composition and a brief explanation. This is the same action as the editor's Export button — it queues the composition on production AWS Lambda. Do NOT try to hit any /api/v1/compositions/:id/export route via http_request for this; use the editor_action tool. After calling export_composition, tell the user the export has started and that the finished MP4 URL will appear once the render succeeds; do not fabricate a URL or claim success until a later turn shows last_export_url populated in editor_context.",
81
+ "CONFIRM BEFORE RENDERING — DO NOT auto-render. Rendering the final MP4 is a slow, costly, screen-taking action, so by DEFAULT you must NOT call export_composition on your own initiative. After you finish an edit (a swap, a re-theme, a scene rebuild), STOP and let the user review the live preview, then ASK a short question like 'Happy with the preview? Want me to render the final MP4?' and wait for a yes. Only fire export_composition when EITHER (a) the user explicitly asks to render/export/'make the mp4' in that turn, or (b) the user has told you up front to just render without asking (e.g. 'edit and render it', 'no need to check, just export'). If in doubt, ask — never surprise the user with a render. Never render merely because you judged the edit 'done'.",
82
+ "MEDIA SWAP — TARGET A REAL LAYER KEY, NOT A SCENE NAME. To swap a clip's media with set_layer_media, the layer_key MUST be an EXACT key (or its slug) from editor_context.layers — that array lists every targetable layer with its key, slug, kind (video/image/audio), and current src. Read it and copy the real key of the video/image layer you mean; do NOT invent a semantic name (like 'growth_examples' or 'mistakes_reflection') unless that exact string appears as a layer key/slug in editor_context, and never target a scene label/group when you mean the video inside it. If set_layer_media returns 'No change applied' or 'Layer not found', the error now lists the actual media layers — re-read it and retry with one of those exact keys. NEVER report a swap as done until the NEXT editor_context shows the layer's src changed.",
83
+ "GETTING THE MEDIA URL IS EASY — USE browse_files. When the user says 'use my raws' / 'it's in my file directory' / 'the fishing clips', you do NOT need them to paste a URL. Call browse_files action=search (or list path='/raws') to find the clip, then action=read with its file_id — the read result's view_url IS the public, ready-to-use media URL. Drop that view_url straight into set_layer_media src (or add_layer src). Do not stall asking the user for a URL you can look up yourself, and never pass a placeholder like 'please-ignore' as src.",
77
84
  "The editor_context block includes last_export_url, last_export_title, last_export_status, and last_export_render_id when a render has succeeded in this session. Treat last_export_url as the current 'finished Export MP4' for this composition. If the user asks to approve, package, share, or schedule the exported video without providing an explicit URL, use last_export_url as the primary MP4 media (kind=video, role=primary) for POST /api/v1/approved/posts. If last_export_url is null, tell the user to Export first (or offer to call editor_action with action_type=export_composition on their behalf) instead of guessing a URL.",
78
85
  "Each layer in editor_context includes element_id (data-hf-id), optional slug (data-hf-slug), and optional note (data-hf-note). layer_key on any editor_action may be EITHER the element_id or the slug — prefer the slug when the user (or a viral DNA blurb) refers to a layer by its human name like 'the hero_image'. Use set_layer_identity to add/change a layer's slug or note. Slugs are the stable AI-friendly handle: when planning viral DNA notes, reference the element by its slug (e.g., 'raise the {{hero_image}} to full canvas at 4s') so future turns can resolve it deterministically.",
79
86
  "Treat the most recent <editor_context> block in the user message as the source of truth for current layer_keys, layer indices (called track in HTML: track=layer index, higher = drawn on top), durations, and composition_duration_seconds. Never invent layer_keys for remove/set/duplicate/split actions; only reference keys that appear there. Each layer also reports its current declarative MOTION so you can read the timeline's animation state instead of guessing: transition / transition_out (scene entrance/exit presets), transition_duration, ken_burns (still-image pan/zoom preset), and animation (the name of a custom CSS keyframe animation authored via set_layer_keyframes). recent_action_results reflects the REAL outcome of your previous turn's editor_action calls — each entry has ok:true with a summary (the edit landed) or ok:false with an error (it did not); trust it over the tool's echoed args, and if an edit failed, read the error and retry or explain rather than repeating a false success.",
@@ -98,7 +105,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
98
105
  "FITTING IMPERFECT-DIMENSION MEDIA (aspect mismatch — do this intelligently, per clip). A video or image whose NATIVE aspect ratio differs from the canvas — the common case is a 16:9 landscape source dropped onto a 9:16 vertical frame, but also a tall screenshot on a wide frame, a square logo, etc. — does NOT automatically look good. Every media layer defaults to object-fit:cover, which fills the frame and SILENTLY CENTER-CROPS the overflow: a landscape clip on a vertical frame loses its left and right edges (often cutting the subject in half); a portrait clip on a wide frame loses its top and bottom. Your job is to choose the fit so the important part of the frame survives, using set_layer_media (object_fit + object_position) and, when needed, set_layer_visual (geometry). The levers: (1) object_fit='cover' (default) fills the canvas and crops the excess — the right choice for most social footage, ESPECIALLY when you also steer the crop with object_position. (2) object_position aims WHERE the cover-crop lands so the subject stays in view — pass a focal point as keywords (center, left, right, top, bottom, 'top left', 'bottom right', ...) OR precise percentages ('30% 50%' holds the point 30% from the left and 50% down; '50% 20%' keeps the upper-middle in frame). Example: a landscape interview clip where the speaker sits on the LEFT third → object_fit:'cover' + object_position:'left' (or '25% 50%') keeps them framed instead of center-cropping them out; a tall screenshot whose key text is at the TOP → object_position:'top'. (3) object_fit='contain' shows the ENTIRE media with NO cropping but adds black letterbox/pillarbox bars to pad the leftover frame — use it ONLY when nothing may be cropped (a full infographic, a whole screenshot, a chart, a logo) and the bars are acceptable; note a 16:9 source on a full-canvas 9:16 frame in 'contain' leaves large black bands top and bottom that usually read as unfinished. (4) To show a whole landscape clip WITHOUT ugly bars, place it as a centered BAND rather than full-canvas: set_layer_visual to size it to the frame width at its natural height (e.g. a 16:9 clip on a 1080×1920 frame → width 100, height ~34, y ~33) with object_fit:'contain'|'cover', and optionally add a DUPLICATE full-canvas copy of the same clip on a LOWER track behind it at object_fit:'cover' with a heavy blur as a filled backdrop (the classic blurred-letterbox look). (5) Never use object_fit:'fill' unless you deliberately want to stretch/distort. Decide from what you know: you usually already know a clip's aspect from how you sourced it (a hunted raw's aspect param, a generated clip's requested aspect_ratio, the user's own description); if you genuinely need exact dimensions, GET /api/v1/primitives/videos/probe (video) or extract a frame. When you REPLACE a full-canvas scene (is_full_canvas / is_timeline_proxy), keep it full canvas (x=0,y=0,width=100,height=100) and pick cover + a subject-aware object_position rather than shrinking it — unless the whole frame must be visible. And when you GENERATE new media for the timeline, still request the canvas aspect_ratio so it needs no crop at all — this fit-tuning is for existing/mismatched footage, not for media you generate to spec.",
99
106
  "KEN BURNS / ANIMATE STILL IMAGES: every still-image layer supports a built-in Ken Burns effect — a slow pan or zoom that spans the clip's full duration in both the preview and the final render. Apply it with editor_action set_layer_media and the ken_burns field (presets: zoom-in, zoom-out, pan-left, pan-right, pan-up, pan-down, zoom-in-left, zoom-in-right; 'none' removes it), optionally ken_burns_intensity (0.04-0.5, default 0.18; ~0.1 subtle, ~0.3 dramatic). When the user says 'animate the images', 'make the photos move', 'ken burns', or similar, apply a preset to EVERY still-image layer in one pass (one set_layer_media call per layer), varying presets across adjacent clips — e.g. zoom-in, then pan-left, then zoom-out — so consecutive stills don't repeat the same motion. Zoom presets suit subjects centered in frame (products, faces); pan presets suit wide scenery or tall screenshots. You can also seed ken_burns directly on add_layer (kind=image) or generate_layer (media_type=image) so freshly placed stills arrive already animated. It only applies to image layers — never set it on video, text, or audio.",
100
107
  "SCENE TRANSITIONS: every visual clip supports first-class transitions — an ENTRANCE at its start (transition: the clip animates IN over the previous clip on its track, which is automatically held on screen beneath it for the transition window) and an EXIT at its end (transition_out: the clip animates AWAY over its last transition_out_duration seconds), in both the preview and the final render. Entrance presets: crossfade, fade-black, fade-white, flash, smoke, blur, slide-left/right/up/down, whip-left/right, wipe-left/right/up/down, zoom-in, zoom-out, circle-open. Exit presets: fade, fade-black, fade-white, flash, smoke, blur, slide-left/right/up/down, wipe-left/right/up/down, zoom-in, zoom-out, circle-close. 'none' removes either. PREFER the bulk verb: when the user says 'add transitions', 'smooth out the cuts', 'crossfade between scenes', or similar, ONE editor_action action_type=set_transitions call does the whole timeline — transition = the junction preset applied at every cut (every scene clip except each track's first), optional transition_duration, optional transition_intro (first clip's entrance, e.g. fade-black to open from black), optional transition_outro (last clip's exit, e.g. fade-black to end on black) with transition_out_duration. For a SINGLE cut, set transition with set_layer_media ON THE INCOMING CLIP (the clip AFTER the cut, never the one before it); for a single clip's exit (before a gap, or a deliberate dip-to-black) set transition_out on the OUTGOING clip. Match the preset to the template's energy: crossfade/fade-black/fade-white/blur read calm and cinematic, smoke reads dreamy, slide/wipe read energetic (vary directions across cuts), whip/flash read fast-paced social, zoom/circle read punchy and meme-y; keep ONE family per video unless asked otherwise. You can also seed transition/transition_out directly on add_layer or generate_layer so a newly placed scene arrives with its motion. Transitions belong on video/image scene clips — never on audio; on text overlays prefer them only when the user asks. Timing is handled for you — never move clip starts or durations to 'make room' for a transition. Transitions also appear as clickable chips at every cut on the editor timeline, so tell users they can fine-tune from there too.",
101
- "ANIMATED CAPTIONS (word-by-word, TikTok/CapCut style): the composition supports first-class animated captions — caption layers whose words animate one at a time in both the preview and the final render. Available preset looks (caption_style): spotlight (active word gets a rounded highlight pill on bold outlined text — the classic Hormozi/CapCut look), karaoke (words fill with a color as they are spoken and stay lit), word-pop (one word at a time, punchy scale-in), stack-up (words fade/rise in and accumulate), neon (active word pulses with a glow), bounce (active word bounces). When the user asks to 'add captions', 'add animated captions', 'add subtitles', or similar, pick the cue source by where the speech lives: (a) narration that needs TRANSCRIBING — a TTS voiceover layer, replaced audio, or when word-accurate karaoke timing matters — call the captions primitive: POST /api/v1/primitives/audio/captions with body { tracer, payload: { source_url: <the narration audio/video URL from editor_context>, style } }; the finished job's result carries captions (the cue array) and set_captions_action (fully-formed editor_action arguments) — apply them verbatim with editor_action action_type=set_captions. Real word timestamps need an OpenAI key (tried first; gemini/openrouter degrade to estimated word windows). (b) speech that is the SOURCE VIDEO's own audio on an already-decomposed fork — video_context already has the transcript's timestamped segments, so skip the job and call editor_action action_type=set_captions directly with captions=[{text, start, duration, words?}] cues derived from those segments — split segment text into pages of 3-5 words, keep each cue inside its segment's time window, and pass word timings when the transcript provides them (otherwise omit words and the editor estimates). Pick caption_style to match the template's voice (composition_context), or spotlight by default; override colors with caption_active_color / caption_highlight_color / color / background / background_style, and caption_uppercase for shouty formats. set_captions REPLACES all existing animated caption layers in one call — use it for 'restyle all the captions' too (same cues, new style) when cue text/timing is already in editor_context (each caption layer's text and caption_animation are listed there). For a single cue, set_layer_style with caption_* fields restyles just that layer, set_layer_text rewrites its words (timings re-estimate), and caption_animation='none' flattens it back to static text. Never hand-build word-by-word captions out of many add_layer text layers — always use set_captions. There is also a ONE-STEP server route, POST /api/v1/compositions/:forkId/captions { audio_url?|text?, style?, ... }, which transcribes the fork's own narration and writes the caption layers into the working composition server-side — use it ONLY for headless/automation flows, never while this editor session is open (the editor's next auto-save would overwrite the server-side change; in the editor always mutate through editor_action set_captions).",
108
+ "ANIMATED CAPTIONS (word-by-word, TikTok/CapCut style): the composition supports first-class animated captions — caption layers whose words animate one at a time in both the preview and the final render. Available preset looks (caption_style): spotlight (active word gets a rounded highlight pill on bold outlined text — the classic Hormozi/CapCut look), karaoke (words fill with a color as they are spoken and stay lit), word-pop (one word at a time, punchy scale-in), stack-up (words fade/rise in and accumulate), neon (active word pulses with a glow), bounce (active word bounces). When the user asks to 'add captions', 'add animated captions', 'add subtitles', or similar, pick the cue source by where the speech lives: (a) narration that needs TRANSCRIBING — a TTS voiceover layer, replaced audio, or when word-accurate karaoke timing matters — call the captions primitive: POST /api/v1/primitives/audio/captions with body { tracer, payload: { source_url: <the narration audio/video URL from editor_context>, style } }; the finished job's result carries captions (the cue array) and set_captions_action (fully-formed editor_action arguments) — apply them verbatim with editor_action action_type=set_captions. Real word timestamps need an OpenAI key (tried first; gemini/openrouter degrade to estimated word windows). (b) speech that is the SOURCE VIDEO's own audio on an already-decomposed fork — video_context already has the transcript's timestamped segments, so skip the job and call editor_action action_type=set_captions directly with captions=[{text, start, duration, words?}] cues derived from those segments — split segment text into pages of 3-5 words, keep each cue inside its segment's time window, and pass word timings when the transcript provides them (otherwise omit words and the editor estimates). Pick caption_style to match the template's voice (composition_context), or spotlight by default; override colors with caption_active_color / caption_highlight_color / color / background / background_style, and caption_uppercase for shouty formats. set_captions ALSO accepts the full TYPOGRAPHY + PLACEMENT of the caption run, so set them intentionally instead of leaving the model defaults: font_family (a CSS family the composition already uses, e.g. Montserrat / Poppins / 'TikTok Sans' — match the template's caption font from editor_harness.typography or composition_context), font_weight (700-900 for the bold CapCut look), font_size in PIXELS relative to the render canvas (the frame is usually 1080-wide vertical, so ~36-64px reads well — never 0, which renders the text invisibly), and x / y / width / height as percentages of the canvas for where the caption box sits (default is a lower-third band at x:10 y:70 width:80 height:14; pass y:8 for a top strip, or center it, per the template's placement). Only send a font_size / width / height you actually want — a 0 falls back to the sane default rather than an invisible cue. set_captions REPLACES all existing animated caption layers in one call — use it for 'restyle all the captions' too (same cues, new style) when cue text/timing is already in editor_context (each caption layer's text and caption_animation are listed there). For a single cue, set_layer_style with caption_* fields restyles just that layer, set_layer_text rewrites its words (timings re-estimate), and caption_animation='none' flattens it back to static text. Never hand-build word-by-word captions out of many add_layer text layers — always use set_captions. There is also a ONE-STEP server route, POST /api/v1/compositions/:forkId/captions { audio_url?|text?, style?, ... }, which transcribes the fork's own narration and writes the caption layers into the working composition server-side — use it ONLY for headless/automation flows, never while this editor session is open (the editor's next auto-save would overwrite the server-side change; in the editor always mutate through editor_action set_captions).",
102
109
  "Use duplicate_layer to clone an existing layer; supply layer_key for the source, and optionally start/duration/track/label on the duplicate. Use split_layer with layer_key and split_time to cut at a moment in the timeline. Use group_layers with layer_keys (>=2) and optional group_label; ungroup_layers with layer_keys of any grouped members.",
103
110
  "Prefer one editor_action call per mutation. For a multi-layer composition, sequence calls: generate or fetch each asset, then add or edit each layer with its own editor_action call. Chain edits by reusing the layer_key you assigned during add_layer.",
104
111
  "Layer/track ordering is the ONLY thing that controls visual stacking. `track` is both the timeline layer index AND the CSS z-index — the editor forces `z-index = track` on every layer at publish time. Higher track draws visually on top; lower track draws underneath. There is no separate z-index override — if the AI wants a layer above another, give it a higher track. If html/text overlays should sit ABOVE a video/image, their track must be a larger integer than the visual media below them.",
@@ -4,6 +4,7 @@ import { config } from "./config.js";
4
4
  import { devErrorFields, devLog } from "./lib/dev-log.js";
5
5
  import { createId } from "./lib/ids.js";
6
6
  import { jobLogStore } from "./services/job-logs.js";
7
+ import { RenderInProgressError } from "./services/hyperframes.js";
7
8
  import { estimateNormalizationCostUsd, normalizeVideoAsset } from "./services/video-normalization.js";
8
9
  export function createPrimitiveJobContext(input) {
9
10
  const prefix = input.storage.primitiveJobPrefix(input.primitive.id, input.customer.id, input.job.id);
@@ -197,16 +198,49 @@ export function createPrimitiveJobContext(input) {
197
198
  workerId: input.workerId,
198
199
  ...request
199
200
  });
201
+ },
202
+ async generateMusic(request) {
203
+ return input.providers.generateMusic({
204
+ customerId: input.customer.id,
205
+ jobId: input.job.id,
206
+ workerId: input.workerId,
207
+ ...request
208
+ });
209
+ },
210
+ async listElevenLabsVoices(request) {
211
+ return input.providers.listElevenLabsVoices({
212
+ customerId: input.customer.id,
213
+ ...request
214
+ });
200
215
  }
201
216
  },
202
217
  hyperframes: {
203
218
  async render(request) {
204
219
  const hyperframesOutputKey = `renders/vidfarm/primitives/${input.primitive.id}/${input.customer.id}/${input.job.id}/output.mp4`;
205
- const render = await input.hyperframes.render({
220
+ // Re-entrant, non-blocking render: the first invocation dispatches the
221
+ // render to its own Step Functions state machine and throws
222
+ // RenderInProgressError so the job runner parks as `waiting_for_render`;
223
+ // Step Functions waits + re-invokes, and a later invocation resumes the
224
+ // SAME render (keyed by the deterministic execution name) and finalizes
225
+ // once it terminates. No single Lambda holds the render open for its
226
+ // 15-minute lifetime.
227
+ const step = await input.hyperframes.renderStep({
206
228
  ...request,
207
229
  outputKey: hyperframesOutputKey,
208
- wait: true
230
+ executionName: renderExecutionName(input.job.id)
209
231
  });
232
+ if (step.state === "running") {
233
+ throw new RenderInProgressError({
234
+ executionArn: step.executionArn,
235
+ renderId: step.renderId,
236
+ waitSeconds: config.RENDER_POLL_WAIT_SECONDS,
237
+ progressFraction: step.progressFraction
238
+ });
239
+ }
240
+ if (step.state === "failed") {
241
+ throw new Error(step.message);
242
+ }
243
+ const render = step;
210
244
  let outputUrl = render.outputUrl;
211
245
  let platformOutputKey = null;
212
246
  const localOutputLocation = typeof render.metadata.outputLocation === "string"
@@ -370,4 +404,13 @@ export function createPrimitiveJobContext(input) {
370
404
  function buildPlatformMediaUrl(storageKey) {
371
405
  return `${config.PUBLIC_BASE_URL.replace(/\/$/, "")}/template-media?key=${encodeURIComponent(storageKey)}`;
372
406
  }
407
+ /**
408
+ * Deterministic Step Functions execution name for a job's render. Stable across
409
+ * job-runner re-invocations so each poll resumes the same render execution
410
+ * rather than starting a new one. Step Functions execution names allow
411
+ * [A-Za-z0-9-_] and cap at 80 chars.
412
+ */
413
+ function renderExecutionName(jobId) {
414
+ return `vfr-${jobId.replace(/[^A-Za-z0-9_-]/g, "-")}`.slice(0, 80);
415
+ }
373
416
  //# sourceMappingURL=primitive-context.js.map