@mevdragon/vidfarm-devcli 0.19.0 → 0.19.1

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.
@@ -6207,7 +6207,7 @@ export function renderJobRunsPage(input) {
6207
6207
  const timing = finishedAt
6208
6208
  ? 'Finished ' + escapeHtml(finishedAt)
6209
6209
  : 'Progress ' + escapeHtml(Math.round((Number(job.progress) || 0) * 100)) + '%';
6210
- const isPending = ["queued", "running", "waiting_for_provider", "waiting_for_child", "waiting_for_human"].includes(job.status);
6210
+ const isPending = ["queued", "running", "waiting_for_provider", "waiting_for_render", "waiting_for_child", "waiting_for_human"].includes(job.status);
6211
6211
 
6212
6212
  return '<section class="history-linked-job">' +
6213
6213
  '<div class="history-linked-job-head">' +
package/dist/src/app.js CHANGED
@@ -34,6 +34,7 @@ import { renderReskinInpaint } from "./reskin/inpaint-page.js";
34
34
  import { renderReskinInpaintVideo } from "./reskin/inpaint-video-page.js";
35
35
  import { renderReskinInpaintClipper } from "./reskin/inpaint-clipper-page.js";
36
36
  import { renderReskinAgency } from "./reskin/agency-page.js";
37
+ import { renderReskinPortfolio, DEFAULT_PORTFOLIO_SLUG } from "./reskin/portfolio-page.js";
37
38
  import { renderReskinPricing } from "./reskin/pricing-page.js";
38
39
  import { renderReskinLogin } from "./reskin/login-page.js";
39
40
  import { renderReskinHelp } from "./reskin/help-page.js";
@@ -5838,6 +5839,19 @@ app.get("/pricing", async (c) => {
5838
5839
  const customer = await getOptionalPreferredBrowserCustomer(c);
5839
5840
  return c.html(renderReskinPricing({ email: customer?.email ?? "" }));
5840
5841
  });
5842
+ // /portfolio/:slug — public creator/agency showcase (hardcoded data, no auth).
5843
+ app.get("/portfolio", (c) => redirect(c, `/portfolio/${DEFAULT_PORTFOLIO_SLUG}`));
5844
+ app.get("/portfolio/:slug", async (c) => {
5845
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5846
+ const html = renderReskinPortfolio(c.req.param("slug"), {
5847
+ isLoggedIn: Boolean(customer),
5848
+ name: customer?.name ?? null,
5849
+ email: customer?.email ?? null
5850
+ });
5851
+ if (!html)
5852
+ return c.notFound();
5853
+ return c.html(html);
5854
+ });
5841
5855
  app.get("/login", (c) => c.html(renderReskinLogin({ mode: getLoginMode(c.req.query("mode")), routePrefix: "/login" })));
5842
5856
  app.post("/login/password", async (c) => {
5843
5857
  const mode = getLoginMode(c.req.query("mode"));
@@ -9379,6 +9393,40 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
9379
9393
  reason: "manual",
9380
9394
  message: `Smart-decomposed via ${smart.provider}(${smart.model}): ${smart.scenes.length} scene(s), ${smart.captions.length} caption(s)${ghostcutTaskId ? ` + GhostCut task ${ghostcutTaskId} pending` : ""}`
9381
9395
  });
9396
+ // Race-closer: the GhostCut subtitle-removal poll (fast) may have already
9397
+ // finished and mirrored the cleaned video while this (slow) vision analysis
9398
+ // was running — in which case it parked ghostcut.json in
9399
+ // "awaiting_composition" because smart-decompose.json didn't exist yet. Now
9400
+ // that BOTH composition.html and smart-decompose.json are written, apply the
9401
+ // rebuild here so the composition points at the subtitle-removed video even
9402
+ // if the client never polls again. Also self-heals any "done"-without-swap
9403
+ // fork left by the pre-fix code path. Non-fatal.
9404
+ if (ghostcutTaskId !== null) {
9405
+ try {
9406
+ const gcText = await storage.readText(storage.compositionForkWorkingKey(fork.id, "ghostcut.json"));
9407
+ if (gcText) {
9408
+ const gc = JSON.parse(gcText);
9409
+ if (gc.mirroredUrl && (gc.status === "awaiting_composition" || gc.status === "done")) {
9410
+ const currentHtml = await storage.readText(storage.compositionForkWorkingKey(fork.id, "composition.html"));
9411
+ const alreadyClean = currentHtml ? currentHtml.includes(gc.mirroredUrl) : false;
9412
+ if (!alreadyClean) {
9413
+ const swapApplied = await rebuildDecomposedCompositionAgainstCleanVideo({
9414
+ fork,
9415
+ callerCustomerId: caller.id,
9416
+ cleanedSourceUrl: gc.mirroredUrl,
9417
+ snapshotMessage: `GhostCut task ${gc.taskId} completed — subtitles removed, composition rebuilt against cleaned video`
9418
+ });
9419
+ if (swapApplied && gc.status !== "done") {
9420
+ await storage.putJson(storage.compositionForkWorkingKey(fork.id, "ghostcut.json"), { ...gc, status: "done", completedAtMs: gc.completedAtMs ?? Date.now() });
9421
+ }
9422
+ }
9423
+ }
9424
+ }
9425
+ }
9426
+ catch (finalizeError) {
9427
+ console.error("auto-decompose ghostcut finalize failed", finalizeError instanceof Error ? finalizeError.message : finalizeError);
9428
+ }
9429
+ }
9382
9430
  // First successful decompose for this template becomes everyone's default.
9383
9431
  // Later users get seeded from this fork's composition html. Enforced at the
9384
9432
  // storage layer via a conditional write so the first decompose wins even
@@ -9589,6 +9637,68 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/video-context.json`, async (c) => {
9589
9637
  return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
9590
9638
  }
9591
9639
  });
9640
+ // Rebuild a decomposed fork's composition.html DETERMINISTICALLY from the
9641
+ // persisted smart-decompose.json snapshot, substituting `cleanedSourceUrl` (the
9642
+ // GhostCut-mirrored, subtitle-removed video) as the source for every media
9643
+ // layer. Shared by BOTH the async GhostCut poll AND the synchronous tail of
9644
+ // /auto-decompose. Rationale: GhostCut removal (fast, ~30-90s) routinely
9645
+ // finishes BEFORE the vision analysis (slow, minutes) has written
9646
+ // smart-decompose.json, so whichever side "wins" the race must be able to apply
9647
+ // the swap. Returns true iff composition.html was rewritten against the clean
9648
+ // video; false when the snapshot is missing/unparseable/empty (the caller keeps
9649
+ // the original in place and retries later).
9650
+ async function rebuildDecomposedCompositionAgainstCleanVideo(input) {
9651
+ const { fork, callerCustomerId, cleanedSourceUrl, snapshotMessage } = input;
9652
+ const persistedText = await storage.readText(storage.compositionForkWorkingKey(fork.id, "smart-decompose.json"));
9653
+ if (!persistedText) {
9654
+ console.error(`ghostcut-rebuild: smart-decompose.json missing for fork ${fork.id}; composition NOT rebuilt (still references original video)`);
9655
+ return false;
9656
+ }
9657
+ let persisted;
9658
+ try {
9659
+ persisted = JSON.parse(persistedText);
9660
+ }
9661
+ catch (parseError) {
9662
+ console.error("ghostcut-rebuild: smart-decompose.json parse failed", parseError instanceof Error ? parseError.message : parseError);
9663
+ return false;
9664
+ }
9665
+ if (!persisted.result || !Array.isArray(persisted.result.scenes) || persisted.result.scenes.length === 0) {
9666
+ console.error(`ghostcut-rebuild: smart-decompose.json for fork ${fork.id} has no scenes; skipping rebuild`);
9667
+ return false;
9668
+ }
9669
+ const rebuilt = buildSmartDecomposedHtml({
9670
+ compositionId: persisted.compositionId ?? fork.id,
9671
+ sourceUrl: cleanedSourceUrl,
9672
+ title: persisted.title ?? fork.title ?? fork.id,
9673
+ result: {
9674
+ summary: persisted.result.summary ?? "",
9675
+ viralDna: persisted.result.viralDna ?? {
9676
+ trend_tagline: "", hook: "", retention: "", payoff: "",
9677
+ preserve: [], avoid: [], promotions: [], keywords: []
9678
+ },
9679
+ transcript: persisted.result.transcript ?? null,
9680
+ durationSeconds: Number(persisted.result.durationSeconds ?? 0),
9681
+ width: Number(persisted.result.width ?? 720),
9682
+ height: Number(persisted.result.height ?? 1280),
9683
+ scenes: persisted.result.scenes,
9684
+ captions: Array.isArray(persisted.result.captions) ? persisted.result.captions : [],
9685
+ // Placement surfaces live in their own artifact and aren't used by the
9686
+ // HTML rebuild; supply an empty list to satisfy the type.
9687
+ placementSurfaces: [],
9688
+ // Editor harness round-trips from the snapshot so the rebuilt HTML keeps
9689
+ // its data-editor-harness; default if a pre-harness snapshot is rebuilt.
9690
+ editorHarness: persisted.result.editorHarness ?? DEFAULT_EDITOR_HARNESS
9691
+ }
9692
+ });
9693
+ await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), rebuilt, "text/html; charset=utf-8");
9694
+ await snapshotForkVersion({
9695
+ fork,
9696
+ callerCustomerId,
9697
+ reason: "manual",
9698
+ message: snapshotMessage
9699
+ });
9700
+ return true;
9701
+ }
9592
9702
  // Idempotent status probe for the async GhostCut subtitle-removal task kicked
9593
9703
  // off by /auto-decompose. The client polls this every few seconds after a
9594
9704
  // successful decompose. On this endpoint's side:
@@ -9637,6 +9747,41 @@ const pollForkSubtitleRemoval = async (c) => {
9637
9747
  completed_at_ms: state.completedAtMs ?? null
9638
9748
  });
9639
9749
  }
9750
+ // "awaiting_composition": GhostCut already finished and the cleaned video is
9751
+ // mirrored; we're only waiting for smart-decompose.json to exist so we can
9752
+ // rebuild. Do NOT re-poll GhostCut and do NOT re-bill — just retry the
9753
+ // rebuild. Flips to terminal "done" the moment the snapshot is available.
9754
+ if (state.status === "awaiting_composition" && state.mirroredUrl) {
9755
+ const swapApplied = await rebuildDecomposedCompositionAgainstCleanVideo({
9756
+ fork,
9757
+ callerCustomerId: caller.id,
9758
+ cleanedSourceUrl: state.mirroredUrl,
9759
+ snapshotMessage: `GhostCut task ${state.taskId} completed — subtitles removed, composition rebuilt against cleaned video`
9760
+ });
9761
+ if (swapApplied) {
9762
+ const next = {
9763
+ ...state,
9764
+ status: "done",
9765
+ completedAtMs: state.completedAtMs ?? Date.now()
9766
+ };
9767
+ await storage.putJson(stateKey, next);
9768
+ return c.json({
9769
+ ok: true,
9770
+ status: "done",
9771
+ task_id: state.taskId,
9772
+ mirrored_url: state.mirroredUrl,
9773
+ completed_at_ms: next.completedAtMs,
9774
+ swap_applied: true
9775
+ });
9776
+ }
9777
+ // Snapshot still not written — keep the client polling.
9778
+ return c.json({
9779
+ ok: true,
9780
+ status: "pending",
9781
+ task_id: state.taskId,
9782
+ mirrored_url: state.mirroredUrl
9783
+ });
9784
+ }
9640
9785
  // Poll GhostCut once. We deliberately do only ONE HTTP call per invocation
9641
9786
  // so the client controls cadence and lambda time is bounded.
9642
9787
  const status = await pollGhostcutStatus(state.taskId);
@@ -9699,75 +9844,11 @@ const pollForkSubtitleRemoval = async (c) => {
9699
9844
  if (!finalMirroredUrl) {
9700
9845
  throw new Error(`ghostcut mirror key not in a public prefix: ${mirrored.key}`);
9701
9846
  }
9702
- // REBUILD (not swap) the composition from the deterministic smart-decompose
9703
- // JSON we persisted at decompose time, substituting the ghostcut-mirrored
9704
- // URL as the source. No regex, no escape/unescape shuffling, no partial
9705
- // matches on URLs that contain "&". The composition is DERIVED from
9706
- // (scenes + captions + viralDna) × sourceUrl — flip the sourceUrl and
9707
- // reemit the HTML. If the persisted JSON is missing (shouldn't happen
9708
- // outside of a stale/interrupted fork), we log and skip the HTML update
9709
- // — the composition stays pointing at the original video, which the
9710
- // /render guard then blocks anyway.
9711
- let swapApplied = false;
9712
- const persistedText = await storage.readText(storage.compositionForkWorkingKey(fork.id, "smart-decompose.json"));
9713
- if (persistedText) {
9714
- try {
9715
- const persisted = JSON.parse(persistedText);
9716
- if (persisted.result && Array.isArray(persisted.result.scenes)) {
9717
- const rebuilt = buildSmartDecomposedHtml({
9718
- compositionId: persisted.compositionId ?? fork.id,
9719
- sourceUrl: finalMirroredUrl,
9720
- title: persisted.title ?? fork.title ?? fork.id,
9721
- result: {
9722
- summary: persisted.result.summary ?? "",
9723
- viralDna: persisted.result.viralDna ?? {
9724
- trend_tagline: "", hook: "", retention: "", payoff: "",
9725
- preserve: [], avoid: [], promotions: [], keywords: []
9726
- },
9727
- transcript: persisted.result.transcript ?? null,
9728
- durationSeconds: Number(persisted.result.durationSeconds ?? 0),
9729
- width: Number(persisted.result.width ?? 720),
9730
- height: Number(persisted.result.height ?? 1280),
9731
- scenes: persisted.result.scenes,
9732
- captions: Array.isArray(persisted.result.captions) ? persisted.result.captions : [],
9733
- // Placement surfaces live in their own artifact and aren't used
9734
- // by the HTML rebuild; supply an empty list to satisfy the type.
9735
- placementSurfaces: [],
9736
- // Editor harness round-trips from the snapshot so the rebuilt
9737
- // HTML keeps its data-editor-harness; default if a pre-harness
9738
- // snapshot is being rebuilt.
9739
- editorHarness: persisted.result.editorHarness ?? DEFAULT_EDITOR_HARNESS
9740
- }
9741
- });
9742
- await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), rebuilt, "text/html; charset=utf-8");
9743
- await snapshotForkVersion({
9744
- fork,
9745
- callerCustomerId: caller.id,
9746
- reason: "manual",
9747
- message: `GhostCut task ${state.taskId} completed — subtitles removed, composition rebuilt against cleaned video`
9748
- });
9749
- swapApplied = true;
9750
- }
9751
- else {
9752
- console.error(`ghostcut-poll: smart-decompose.json for fork ${fork.id} has no scenes; skipping rebuild`);
9753
- }
9754
- }
9755
- catch (parseError) {
9756
- console.error("ghostcut-poll: smart-decompose.json parse failed", parseError instanceof Error ? parseError.message : parseError);
9757
- }
9758
- }
9759
- else {
9760
- console.error(`ghostcut-poll: smart-decompose.json missing for fork ${fork.id}; composition NOT rebuilt (still references original video)`);
9761
- }
9762
- const next = {
9763
- ...state,
9764
- status: "done",
9765
- mirroredUrl: finalMirroredUrl,
9766
- completedAtMs: Date.now()
9767
- };
9768
- await storage.putJson(stateKey, next);
9769
- // Bill the GhostCut chunk cost. Idempotency keyed on taskId so re-polls
9770
- // (in case of network hiccups) never double-charge.
9847
+ // Bill the GhostCut chunk cost NOW that the mirror succeeded the vendor
9848
+ // work is done and paid for regardless of whether the composition rebuild
9849
+ // lands this tick (it can't until smart-decompose.json exists). Idempotency
9850
+ // keyed on taskId so re-polls / the awaiting_composition retry loop never
9851
+ // double-charge.
9771
9852
  const seconds = state.probedDurationSeconds ?? 30;
9772
9853
  const cost = estimateGhostcutCostUsd(seconds);
9773
9854
  try {
@@ -9795,9 +9876,34 @@ const pollForkSubtitleRemoval = async (c) => {
9795
9876
  catch (billingError) {
9796
9877
  console.error("ghostcut-poll billing failed", billingError instanceof Error ? billingError.message : billingError);
9797
9878
  }
9879
+ // REBUILD (not swap) the composition from the deterministic smart-decompose
9880
+ // snapshot, substituting the ghostcut-mirrored URL as the source. If the
9881
+ // snapshot doesn't exist yet — EXPECTED, because the fast subtitle removal
9882
+ // routinely beats the slow vision analysis that writes it — do NOT go
9883
+ // terminal. Park in "awaiting_composition" (mirror preserved, already
9884
+ // billed) and keep the client polling; the tail of /auto-decompose also
9885
+ // attempts this same rebuild once the snapshot lands, so the swap happens
9886
+ // whichever side wins the race. Going terminal-"done" here without the
9887
+ // rebuild is exactly the bug that stranded compositions on the original
9888
+ // text-baked video.
9889
+ const swapApplied = await rebuildDecomposedCompositionAgainstCleanVideo({
9890
+ fork,
9891
+ callerCustomerId: caller.id,
9892
+ cleanedSourceUrl: finalMirroredUrl,
9893
+ snapshotMessage: `GhostCut task ${state.taskId} completed — subtitles removed, composition rebuilt against cleaned video`
9894
+ });
9895
+ const next = {
9896
+ ...state,
9897
+ status: swapApplied ? "done" : "awaiting_composition",
9898
+ mirroredUrl: finalMirroredUrl,
9899
+ completedAtMs: Date.now()
9900
+ };
9901
+ await storage.putJson(stateKey, next);
9798
9902
  return c.json({
9799
9903
  ok: true,
9800
- status: "done",
9904
+ // "pending" (not "done") until the rebuild actually lands, so the client
9905
+ // keeps polling instead of stopping on a still-original composition.
9906
+ status: swapApplied ? "done" : "pending",
9801
9907
  task_id: state.taskId,
9802
9908
  mirrored_url: finalMirroredUrl,
9803
9909
  completed_at_ms: next.completedAtMs,
@@ -16563,6 +16669,9 @@ function buildJobDebugHints(input) {
16563
16669
  if (input.job.status === "waiting_for_provider" && Date.parse(input.job.runAfter) > Date.now()) {
16564
16670
  hints.push("The job is waiting for an available customer AI provider key. Step Functions will retry it after run_after.");
16565
16671
  }
16672
+ if (input.job.status === "waiting_for_render") {
16673
+ hints.push("The job dispatched a distributed render to its own state machine and is polling it via Step Functions wait+re-invoke (waiting_for_render). This is normal for long renders; check the hyperframes render execution if it never terminates.");
16674
+ }
16566
16675
  if (input.job.status === "running" && input.job.updatedAt) {
16567
16676
  hints.push("For running jobs, compare updated_at with the latest structured log event to check heartbeat/progress freshness.");
16568
16677
  }
@@ -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"),
@@ -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
  }
@@ -74,6 +74,9 @@ export function buildTemplateEditorChatSystemPrompt(input) {
74
74
  "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
75
  "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
76
  "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.",
77
+ "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'.",
78
+ "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.",
79
+ "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
80
  "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
81
  "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
82
  "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 +101,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
98
101
  "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
102
  "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
103
  "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).",
104
+ "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
105
  "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
106
  "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
107
  "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);
@@ -202,11 +203,30 @@ export function createPrimitiveJobContext(input) {
202
203
  hyperframes: {
203
204
  async render(request) {
204
205
  const hyperframesOutputKey = `renders/vidfarm/primitives/${input.primitive.id}/${input.customer.id}/${input.job.id}/output.mp4`;
205
- const render = await input.hyperframes.render({
206
+ // Re-entrant, non-blocking render: the first invocation dispatches the
207
+ // render to its own Step Functions state machine and throws
208
+ // RenderInProgressError so the job runner parks as `waiting_for_render`;
209
+ // Step Functions waits + re-invokes, and a later invocation resumes the
210
+ // SAME render (keyed by the deterministic execution name) and finalizes
211
+ // once it terminates. No single Lambda holds the render open for its
212
+ // 15-minute lifetime.
213
+ const step = await input.hyperframes.renderStep({
206
214
  ...request,
207
215
  outputKey: hyperframesOutputKey,
208
- wait: true
216
+ executionName: renderExecutionName(input.job.id)
209
217
  });
218
+ if (step.state === "running") {
219
+ throw new RenderInProgressError({
220
+ executionArn: step.executionArn,
221
+ renderId: step.renderId,
222
+ waitSeconds: config.RENDER_POLL_WAIT_SECONDS,
223
+ progressFraction: step.progressFraction
224
+ });
225
+ }
226
+ if (step.state === "failed") {
227
+ throw new Error(step.message);
228
+ }
229
+ const render = step;
210
230
  let outputUrl = render.outputUrl;
211
231
  let platformOutputKey = null;
212
232
  const localOutputLocation = typeof render.metadata.outputLocation === "string"
@@ -370,4 +390,13 @@ export function createPrimitiveJobContext(input) {
370
390
  function buildPlatformMediaUrl(storageKey) {
371
391
  return `${config.PUBLIC_BASE_URL.replace(/\/$/, "")}/template-media?key=${encodeURIComponent(storageKey)}`;
372
392
  }
393
+ /**
394
+ * Deterministic Step Functions execution name for a job's render. Stable across
395
+ * job-runner re-invocations so each poll resumes the same render execution
396
+ * rather than starting a new one. Step Functions execution names allow
397
+ * [A-Za-z0-9-_] and cap at 80 chars.
398
+ */
399
+ function renderExecutionName(jobId) {
400
+ return `vfr-${jobId.replace(/[^A-Za-z0-9_-]/g, "-")}`.slice(0, 80);
401
+ }
373
402
  //# sourceMappingURL=primitive-context.js.map
@@ -1437,7 +1437,7 @@ ${INPAINT_TABS_CSS}
1437
1437
  }
1438
1438
 
1439
1439
  function isPendingJobStatus(status) {
1440
- return ["queued", "running", "waiting_for_provider", "waiting_for_child", "waiting_for_human"].includes(String(status || ""));
1440
+ return ["queued", "running", "waiting_for_provider", "waiting_for_render", "waiting_for_child", "waiting_for_human"].includes(String(status || ""));
1441
1441
  }
1442
1442
 
1443
1443
  function pendingHistoryItemKey(jobId) {
@@ -917,7 +917,7 @@ const INPAINT_VIDEO_SCRIPT = `
917
917
  var candidate = (out.video && out.video.file_url) || out.primary_file_url || (result.video && result.video.file_url) || result.primary_file_url || (Array.isArray(out.files) ? out.files[0] : "") || (Array.isArray(result.files) ? result.files[0] : "");
918
918
  return candidate || "";
919
919
  }
920
- function isPendingStatus(status){ return ["queued","running","waiting_for_provider","waiting_for_child","waiting_for_human"].indexOf(String(status || "")) >= 0; }
920
+ function isPendingStatus(status){ return ["queued","running","waiting_for_provider","waiting_for_render","waiting_for_child","waiting_for_human"].indexOf(String(status || "")) >= 0; }
921
921
  function pendingKey(jobId){ return "job:" + String(jobId || ""); }
922
922
  function makePending(input){
923
923
  var jobId = String((input && (input.jobId || input.job_id)) || ""); if(!jobId) return null;