@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.
- package/.agents/skills/editor-capabilities/SKILL.md +3 -3
- package/demo/dist/app.css +1 -1
- package/demo/dist/app.js +76 -74
- package/dist/src/account-pages-legacy.js +1 -1
- package/dist/src/app.js +179 -70
- package/dist/src/config.js +1 -0
- package/dist/src/devcli/composition-edit.js +5 -2
- package/dist/src/editor-chat.js +4 -1
- package/dist/src/primitive-context.js +31 -2
- package/dist/src/reskin/inpaint-page.js +1 -1
- package/dist/src/reskin/inpaint-video-page.js +1 -1
- package/dist/src/reskin/portfolio-page.js +687 -0
- package/dist/src/services/hyperframes.js +252 -83
- package/dist/src/services/serverless-jobs.js +3 -1
- package/dist/src/template-editor-pages.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
9703
|
-
//
|
|
9704
|
-
//
|
|
9705
|
-
//
|
|
9706
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/dist/src/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
324
|
+
fontSize: typeof opts.fontSize === "number" && opts.fontSize > 0 ? opts.fontSize : 38,
|
|
322
325
|
presetId: preset?.id ?? animation
|
|
323
326
|
};
|
|
324
327
|
}
|
package/dist/src/editor-chat.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|