@skyphusion-labs/vivijure-core 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +87 -0
- package/dist/audio-routing.d.ts +2 -0
- package/dist/audio-routing.d.ts.map +1 -0
- package/dist/audio-routing.js +4 -0
- package/dist/audio-routing.js.map +1 -0
- package/dist/audio-stage.d.ts +4 -0
- package/dist/audio-stage.d.ts.map +1 -0
- package/dist/audio-stage.js +25 -0
- package/dist/audio-stage.js.map +1 -0
- package/dist/beat-analyze.d.ts +22 -0
- package/dist/beat-analyze.d.ts.map +1 -0
- package/dist/beat-analyze.js +111 -0
- package/dist/beat-analyze.js.map +1 -0
- package/dist/beat-sync-types.d.ts +33 -0
- package/dist/beat-sync-types.d.ts.map +1 -0
- package/dist/beat-sync-types.js +4 -0
- package/dist/beat-sync-types.js.map +1 -0
- package/dist/bundle-assembler.d.ts +54 -0
- package/dist/bundle-assembler.d.ts.map +1 -0
- package/dist/bundle-assembler.js +376 -0
- package/dist/bundle-assembler.js.map +1 -0
- package/dist/bundle-durations.d.ts +5 -0
- package/dist/bundle-durations.d.ts.map +1 -0
- package/dist/bundle-durations.js +68 -0
- package/dist/bundle-durations.js.map +1 -0
- package/dist/bundle-storyboard.d.ts +11 -0
- package/dist/bundle-storyboard.d.ts.map +1 -0
- package/dist/bundle-storyboard.js +82 -0
- package/dist/bundle-storyboard.js.map +1 -0
- package/dist/captions.d.ts +25 -0
- package/dist/captions.d.ts.map +1 -0
- package/dist/captions.js +56 -0
- package/dist/captions.js.map +1 -0
- package/dist/cast-db.d.ts +61 -0
- package/dist/cast-db.d.ts.map +1 -0
- package/dist/cast-db.js +324 -0
- package/dist/cast-db.js.map +1 -0
- package/dist/cast-lora-train.d.ts +18 -0
- package/dist/cast-lora-train.d.ts.map +1 -0
- package/dist/cast-lora-train.js +215 -0
- package/dist/cast-lora-train.js.map +1 -0
- package/dist/cast-loras.d.ts +21 -0
- package/dist/cast-loras.d.ts.map +1 -0
- package/dist/cast-loras.js +69 -0
- package/dist/cast-loras.js.map +1 -0
- package/dist/clip-content-validate.d.ts +43 -0
- package/dist/clip-content-validate.d.ts.map +1 -0
- package/dist/clip-content-validate.js +115 -0
- package/dist/clip-content-validate.js.map +1 -0
- package/dist/clip-job-model.d.ts +42 -0
- package/dist/clip-job-model.d.ts.map +1 -0
- package/dist/clip-job-model.js +8 -0
- package/dist/clip-job-model.js.map +1 -0
- package/dist/clip-validate.d.ts +33 -0
- package/dist/clip-validate.d.ts.map +1 -0
- package/dist/clip-validate.js +247 -0
- package/dist/clip-validate.js.map +1 -0
- package/dist/d1-retry.d.ts +20 -0
- package/dist/d1-retry.d.ts.map +1 -0
- package/dist/d1-retry.js +67 -0
- package/dist/d1-retry.js.map +1 -0
- package/dist/db-env.d.ts +6 -0
- package/dist/db-env.d.ts.map +1 -0
- package/dist/db-env.js +2 -0
- package/dist/db-env.js.map +1 -0
- package/dist/dialogue-lines.d.ts +23 -0
- package/dist/dialogue-lines.d.ts.map +1 -0
- package/dist/dialogue-lines.js +84 -0
- package/dist/dialogue-lines.js.map +1 -0
- package/dist/film-advance-lease.d.ts +9 -0
- package/dist/film-advance-lease.d.ts.map +1 -0
- package/dist/film-advance-lease.js +19 -0
- package/dist/film-advance-lease.js.map +1 -0
- package/dist/film-model.d.ts +415 -0
- package/dist/film-model.d.ts.map +1 -0
- package/dist/film-model.js +541 -0
- package/dist/film-model.js.map +1 -0
- package/dist/film-orchestrator.d.ts +216 -0
- package/dist/film-orchestrator.d.ts.map +1 -0
- package/dist/film-orchestrator.js +2204 -0
- package/dist/film-orchestrator.js.map +1 -0
- package/dist/film-render-bridge.d.ts +26 -0
- package/dist/film-render-bridge.d.ts.map +1 -0
- package/dist/film-render-bridge.js +176 -0
- package/dist/film-render-bridge.js.map +1 -0
- package/dist/finish-hash.d.ts +10 -0
- package/dist/finish-hash.d.ts.map +1 -0
- package/dist/finish-hash.js +60 -0
- package/dist/finish-hash.js.map +1 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +45 -0
- package/dist/index.js.map +1 -0
- package/dist/key-safety.d.ts +5 -0
- package/dist/key-safety.d.ts.map +1 -0
- package/dist/key-safety.js +23 -0
- package/dist/key-safety.js.map +1 -0
- package/dist/lora-bundle.d.ts +19 -0
- package/dist/lora-bundle.d.ts.map +1 -0
- package/dist/lora-bundle.js +75 -0
- package/dist/lora-bundle.js.map +1 -0
- package/dist/lora-keys.d.ts +2 -0
- package/dist/lora-keys.d.ts.map +1 -0
- package/dist/lora-keys.js +4 -0
- package/dist/lora-keys.js.map +1 -0
- package/dist/modules/conformance.d.ts +58 -0
- package/dist/modules/conformance.d.ts.map +1 -0
- package/dist/modules/conformance.js +369 -0
- package/dist/modules/conformance.js.map +1 -0
- package/dist/modules/manifest-validate.d.ts +5 -0
- package/dist/modules/manifest-validate.d.ts.map +1 -0
- package/dist/modules/manifest-validate.js +60 -0
- package/dist/modules/manifest-validate.js.map +1 -0
- package/dist/modules/registry.d.ts +184 -0
- package/dist/modules/registry.d.ts.map +1 -0
- package/dist/modules/registry.js +653 -0
- package/dist/modules/registry.js.map +1 -0
- package/dist/modules/render-pipeline.d.ts +38 -0
- package/dist/modules/render-pipeline.d.ts.map +1 -0
- package/dist/modules/render-pipeline.js +40 -0
- package/dist/modules/render-pipeline.js.map +1 -0
- package/dist/modules/types.d.ts +544 -0
- package/dist/modules/types.d.ts.map +1 -0
- package/dist/modules/types.js +57 -0
- package/dist/modules/types.js.map +1 -0
- package/dist/operator-config.d.ts +20 -0
- package/dist/operator-config.d.ts.map +1 -0
- package/dist/operator-config.js +85 -0
- package/dist/operator-config.js.map +1 -0
- package/dist/output-extract.d.ts +8 -0
- package/dist/output-extract.d.ts.map +1 -0
- package/dist/output-extract.js +129 -0
- package/dist/output-extract.js.map +1 -0
- package/dist/planner-prompt.d.ts +12 -0
- package/dist/planner-prompt.d.ts.map +1 -0
- package/dist/planner-prompt.js +337 -0
- package/dist/planner-prompt.js.map +1 -0
- package/dist/planner-yaml.d.ts +15 -0
- package/dist/planner-yaml.d.ts.map +1 -0
- package/dist/planner-yaml.js +157 -0
- package/dist/planner-yaml.js.map +1 -0
- package/dist/platform/fetcher.d.ts +3 -0
- package/dist/platform/fetcher.d.ts.map +1 -0
- package/dist/platform/fetcher.js +7 -0
- package/dist/platform/fetcher.js.map +1 -0
- package/dist/platform/index.d.ts +6 -0
- package/dist/platform/index.d.ts.map +1 -0
- package/dist/platform/index.js +5 -0
- package/dist/platform/index.js.map +1 -0
- package/dist/platform/object-store-r2.d.ts +26 -0
- package/dist/platform/object-store-r2.d.ts.map +1 -0
- package/dist/platform/object-store-r2.js +74 -0
- package/dist/platform/object-store-r2.js.map +1 -0
- package/dist/platform/orchestrator-context.d.ts +20 -0
- package/dist/platform/orchestrator-context.d.ts.map +1 -0
- package/dist/platform/orchestrator-context.js +28 -0
- package/dist/platform/orchestrator-context.js.map +1 -0
- package/dist/platform/r2-types.d.ts +41 -0
- package/dist/platform/r2-types.d.ts.map +1 -0
- package/dist/platform/r2-types.js +3 -0
- package/dist/platform/r2-types.js.map +1 -0
- package/dist/platform/types.d.ts +90 -0
- package/dist/platform/types.d.ts.map +1 -0
- package/dist/platform/types.js +23 -0
- package/dist/platform/types.js.map +1 -0
- package/dist/preflight.d.ts +57 -0
- package/dist/preflight.d.ts.map +1 -0
- package/dist/preflight.js +243 -0
- package/dist/preflight.js.map +1 -0
- package/dist/presign.d.ts +5 -0
- package/dist/presign.d.ts.map +1 -0
- package/dist/presign.js +24 -0
- package/dist/presign.js.map +1 -0
- package/dist/public-id.d.ts +8 -0
- package/dist/public-id.d.ts.map +1 -0
- package/dist/public-id.js +25 -0
- package/dist/public-id.js.map +1 -0
- package/dist/render-adopt.d.ts +3 -0
- package/dist/render-adopt.d.ts.map +1 -0
- package/dist/render-adopt.js +92 -0
- package/dist/render-adopt.js.map +1 -0
- package/dist/render-log.d.ts +25 -0
- package/dist/render-log.d.ts.map +1 -0
- package/dist/render-log.js +139 -0
- package/dist/render-log.js.map +1 -0
- package/dist/render-module-config.d.ts +28 -0
- package/dist/render-module-config.d.ts.map +1 -0
- package/dist/render-module-config.js +124 -0
- package/dist/render-module-config.js.map +1 -0
- package/dist/render-mux.d.ts +18 -0
- package/dist/render-mux.d.ts.map +1 -0
- package/dist/render-mux.js +69 -0
- package/dist/render-mux.js.map +1 -0
- package/dist/render-orchestrator.d.ts +71 -0
- package/dist/render-orchestrator.d.ts.map +1 -0
- package/dist/render-orchestrator.js +376 -0
- package/dist/render-orchestrator.js.map +1 -0
- package/dist/render-sweep.d.ts +5 -0
- package/dist/render-sweep.d.ts.map +1 -0
- package/dist/render-sweep.js +62 -0
- package/dist/render-sweep.js.map +1 -0
- package/dist/renders-db.d.ts +129 -0
- package/dist/renders-db.d.ts.map +1 -0
- package/dist/renders-db.js +752 -0
- package/dist/renders-db.js.map +1 -0
- package/dist/runpod-submit.d.ts +151 -0
- package/dist/runpod-submit.d.ts.map +1 -0
- package/dist/runpod-submit.js +469 -0
- package/dist/runpod-submit.js.map +1 -0
- package/dist/runpod-types.d.ts +2 -0
- package/dist/runpod-types.d.ts.map +1 -0
- package/dist/runpod-types.js +4 -0
- package/dist/runpod-types.js.map +1 -0
- package/dist/scatter-notify.d.ts +4 -0
- package/dist/scatter-notify.d.ts.map +1 -0
- package/dist/scatter-notify.js +43 -0
- package/dist/scatter-notify.js.map +1 -0
- package/dist/scatter-orchestrator-types.d.ts +56 -0
- package/dist/scatter-orchestrator-types.d.ts.map +1 -0
- package/dist/scatter-orchestrator-types.js +3 -0
- package/dist/scatter-orchestrator-types.js.map +1 -0
- package/dist/scatter-orchestrator.d.ts +70 -0
- package/dist/scatter-orchestrator.d.ts.map +1 -0
- package/dist/scatter-orchestrator.js +614 -0
- package/dist/scatter-orchestrator.js.map +1 -0
- package/dist/scatter.d.ts +35 -0
- package/dist/scatter.d.ts.map +1 -0
- package/dist/scatter.js +171 -0
- package/dist/scatter.js.map +1 -0
- package/dist/secret-store.d.ts +5 -0
- package/dist/secret-store.d.ts.map +1 -0
- package/dist/secret-store.js +19 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/shot-durations-parse.d.ts +3 -0
- package/dist/shot-durations-parse.d.ts.map +1 -0
- package/dist/shot-durations-parse.js +43 -0
- package/dist/shot-durations-parse.js.map +1 -0
- package/dist/srt.d.ts +11 -0
- package/dist/srt.d.ts.map +1 -0
- package/dist/srt.js +54 -0
- package/dist/srt.js.map +1 -0
- package/dist/storyboard-ids.d.ts +2 -0
- package/dist/storyboard-ids.d.ts.map +1 -0
- package/dist/storyboard-ids.js +12 -0
- package/dist/storyboard-ids.js.map +1 -0
- package/dist/storyboard-projects-db.d.ts +31 -0
- package/dist/storyboard-projects-db.d.ts.map +1 -0
- package/dist/storyboard-projects-db.js +153 -0
- package/dist/storyboard-projects-db.js.map +1 -0
- package/dist/storyboard-validate.d.ts +72 -0
- package/dist/storyboard-validate.d.ts.map +1 -0
- package/dist/storyboard-validate.js +595 -0
- package/dist/storyboard-validate.js.map +1 -0
- package/dist/structured-events.d.ts +6 -0
- package/dist/structured-events.d.ts.map +1 -0
- package/dist/structured-events.js +15 -0
- package/dist/structured-events.js.map +1 -0
- package/dist/tar.d.ts +9 -0
- package/dist/tar.d.ts.map +1 -0
- package/dist/tar.js +151 -0
- package/dist/tar.js.map +1 -0
- package/dist/voices.d.ts +12 -0
- package/dist/voices.d.ts.map +1 -0
- package/dist/voices.js +37 -0
- package/dist/voices.js.map +1 -0
- package/package.json +71 -0
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
// Storyboard render history persistence (v0.34.0).
|
|
2
|
+
//
|
|
3
|
+
// One row per RunPod job submitted via POST /api/storyboard/render. The row is
|
|
4
|
+
// inserted at submit time and updated by the poll + cancel handlers with the
|
|
5
|
+
// latest status, output, error, and timing fields. GET /api/storyboard/renders
|
|
6
|
+
// lists rows newest first.
|
|
7
|
+
//
|
|
8
|
+
// Ownership: the studio is single-operator; list/get/patch/delete are scoped by
|
|
9
|
+
// row id (and optional project_id on list) only. Edge auth is Cloudflare Access.
|
|
10
|
+
// (The legacy per-operator identity column was removed in the identity strip; memory:
|
|
11
|
+
// vivijure-user-email-strip -- the studio is single-operator, nothing scopes by identity.)
|
|
12
|
+
// Poll / cancel proxy to RunPod regardless of DB state (so jobs submitted before
|
|
13
|
+
// v0.34.0 are still pollable directly via their jobId); the row UPDATE is a no-op
|
|
14
|
+
// when no row exists for that jobId.
|
|
15
|
+
import { writeRenderLog } from "./render-log.js";
|
|
16
|
+
import { withD1Retry } from "./d1-retry.js";
|
|
17
|
+
import { newPublicId } from "./public-id.js";
|
|
18
|
+
// Surface a corrupted *_json column instead of swallowing it silently (issue #15).
|
|
19
|
+
// The empty / NULL case is handled by a length guard BEFORE the parse, so this only
|
|
20
|
+
// fires on a row whose column HAS content that will not parse -- a genuine anomaly
|
|
21
|
+
// worth a log line. The caller keeps its own safe fallback; this just makes the
|
|
22
|
+
// corruption diagnosable rather than invisible.
|
|
23
|
+
function warnCorruptColumn(column, e) {
|
|
24
|
+
console.warn(`renders: corrupt ${column} JSON in a row, using fallback: ${e instanceof Error ? e.message : String(e)}`);
|
|
25
|
+
}
|
|
26
|
+
export function toPublicRenderRow(row) {
|
|
27
|
+
const { id: _internalId, project_id: _internalProjectId, parent_id: _internalParentId, public_id, project_public_id, parent_public_id, ...rest } = row;
|
|
28
|
+
return { ...rest, id: public_id, project_id: project_public_id, parent_id: parent_public_id };
|
|
29
|
+
}
|
|
30
|
+
// The one column list every full-row SELECT uses, so the SQL and RawRenderRow
|
|
31
|
+
// cannot drift apart independently in two call sites.
|
|
32
|
+
const RENDER_ROW_COLUMNS = `
|
|
33
|
+
r.id, r.public_id, r.job_id, r.project, r.bundle_key, r.quality_tier,
|
|
34
|
+
r.render_overrides, r.status, r.output_key, r.output_json AS output,
|
|
35
|
+
r.error, r.execution_time_ms, r.delay_time_ms,
|
|
36
|
+
r.submitted_at, r.updated_at, r.completed_at, r.label, r.keyframes_json, r.mode,
|
|
37
|
+
r.locked_shots_json, r.project_id, r.folder_path, r.tags_json, r.parent_id,
|
|
38
|
+
p.public_id AS project_public_id, pr.public_id AS parent_public_id`;
|
|
39
|
+
// The FROM + LEFT JOINs that resolve the FK columns to their referents' public ids. Kept as one
|
|
40
|
+
// constant so the two full-row read sites (getRenderByIdForUser / listRendersForUser) cannot drift.
|
|
41
|
+
// LEFT JOIN so a NULL FK (or a since-deleted referent) yields a NULL public id, never a dropped row.
|
|
42
|
+
const RENDER_ROW_FROM = `
|
|
43
|
+
FROM renders r
|
|
44
|
+
LEFT JOIN storyboard_projects p ON r.project_id = p.id
|
|
45
|
+
LEFT JOIN renders pr ON r.parent_id = pr.id`;
|
|
46
|
+
function nowSeconds() {
|
|
47
|
+
return Math.floor(Date.now() / 1000);
|
|
48
|
+
}
|
|
49
|
+
// v0.55.0: parse + validate a project_id intake (from the request body
|
|
50
|
+
// or query string). Pure so vitest can assert the contract without env.
|
|
51
|
+
// Returns null for any non-positive-integer input, which the caller
|
|
52
|
+
// then treats as "no project filter" / "transient submit".
|
|
53
|
+
export function normalizeProjectIdInput(raw) {
|
|
54
|
+
if (raw === undefined || raw === null || raw === "")
|
|
55
|
+
return null;
|
|
56
|
+
if (typeof raw === "number") {
|
|
57
|
+
return Number.isInteger(raw) && raw > 0 ? raw : null;
|
|
58
|
+
}
|
|
59
|
+
if (typeof raw === "string") {
|
|
60
|
+
const n = Number(raw);
|
|
61
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const TERMINAL_STATUSES = new Set([
|
|
66
|
+
"COMPLETED",
|
|
67
|
+
"FAILED",
|
|
68
|
+
"CANCELLED",
|
|
69
|
+
"TIMED_OUT",
|
|
70
|
+
]);
|
|
71
|
+
export function isTerminalStatus(status) {
|
|
72
|
+
return TERMINAL_STATUSES.has(status);
|
|
73
|
+
}
|
|
74
|
+
// v0.136.0: how long after submit we keep treating a RunPod "job not found"
|
|
75
|
+
// (404 on /status) as a momentary post-submit propagation race rather than a
|
|
76
|
+
// dropped job. RunPod's /run can return IN_QUEUE before /status can see the
|
|
77
|
+
// job; we show "SUBMITTED" during this window and only fail the row once the
|
|
78
|
+
// 404 persists past it. Generous on purpose: false-failing a real job is worse
|
|
79
|
+
// than a slightly delayed phantom verdict.
|
|
80
|
+
export const PHANTOM_GRACE_SECONDS = 150;
|
|
81
|
+
export function classifyMissingJob(rowStatus, submittedAtSec, nowSec, graceSec = PHANTOM_GRACE_SECONDS) {
|
|
82
|
+
if (isTerminalStatus(rowStatus))
|
|
83
|
+
return "terminal";
|
|
84
|
+
return nowSec - submittedAtSec < graceSec ? "confirming" : "phantom";
|
|
85
|
+
}
|
|
86
|
+
/** Build the bound INSERT for a render row, idempotent on job_id (ON CONFLICT DO NOTHING).
|
|
87
|
+
* Returned UNEXECUTED so a caller can `.run()` it directly (insertRender) or compose several
|
|
88
|
+
* into one all-or-nothing `env.DB.batch([...])` -- the atomic scatter submit (#289). */
|
|
89
|
+
export function buildInsertRenderStmt(env, row) {
|
|
90
|
+
const now = nowSeconds();
|
|
91
|
+
const overrides = row.renderOverrides ? JSON.stringify(row.renderOverrides) : null;
|
|
92
|
+
const mode = row.mode ?? "full";
|
|
93
|
+
const projectId = typeof row.projectId === "number" && row.projectId > 0
|
|
94
|
+
? row.projectId
|
|
95
|
+
: null;
|
|
96
|
+
const parentId = typeof row.parentId === "number" && row.parentId > 0
|
|
97
|
+
? row.parentId
|
|
98
|
+
: null;
|
|
99
|
+
return env.DB.prepare(`INSERT INTO renders (
|
|
100
|
+
public_id, job_id, project, bundle_key, quality_tier,
|
|
101
|
+
render_overrides, status, submitted_at, updated_at, mode,
|
|
102
|
+
project_id, parent_id
|
|
103
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
104
|
+
ON CONFLICT(job_id) DO NOTHING`).bind(newPublicId(), row.jobId, row.project, row.bundleKey, row.qualityTier, overrides, row.status, now, now, mode, projectId, parentId);
|
|
105
|
+
}
|
|
106
|
+
export async function insertRender(env, row) {
|
|
107
|
+
await buildInsertRenderStmt(env, row).run();
|
|
108
|
+
}
|
|
109
|
+
// Best-effort UPDATE from a poll / cancel response. No-op when no row
|
|
110
|
+
// exists for the jobId (matches the "back-compat for pre-v0.34.0 jobs"
|
|
111
|
+
// policy). Ownership is NOT checked here; the route handler enforces
|
|
112
|
+
// authn via Cloudflare Access at the edge; the single-operator studio does no per-identity
|
|
113
|
+
// authz (the list endpoint is unscoped).
|
|
114
|
+
export async function updateRenderFromView(env, view, ctx) {
|
|
115
|
+
const now = nowSeconds();
|
|
116
|
+
const completed = TERMINAL_STATUSES.has(view.status) ? now : null;
|
|
117
|
+
// Pull output_key out of the GPU side's COMPLETED envelope when present.
|
|
118
|
+
let outputKey = null;
|
|
119
|
+
let keyframesJson = null;
|
|
120
|
+
let modeFromOutput = null;
|
|
121
|
+
if (view.output &&
|
|
122
|
+
typeof view.output === "object" &&
|
|
123
|
+
!Array.isArray(view.output)) {
|
|
124
|
+
const o = view.output;
|
|
125
|
+
if (typeof o.output_key === "string" && o.output_key.length > 0) {
|
|
126
|
+
outputKey = o.output_key;
|
|
127
|
+
}
|
|
128
|
+
// v0.39.0: extract the keyframes list (GPU 0.4.0+) so we can render
|
|
129
|
+
// thumbnails in the history row without re-parsing output_json.
|
|
130
|
+
const refs = normalizeKeyframes(o.keyframes);
|
|
131
|
+
if (refs.length > 0)
|
|
132
|
+
keyframesJson = JSON.stringify(refs);
|
|
133
|
+
// v0.40.0: GPU 0.4.2+ surfaces the run mode in the envelope. We mirror
|
|
134
|
+
// it into the row so the UI can render the keyframes-only flow even
|
|
135
|
+
// if the row was inserted before the mode column had a value.
|
|
136
|
+
// v0.42.0: also recognize "finalized" mode from the GPU's finalize
|
|
137
|
+
// action; same COALESCE-write pattern.
|
|
138
|
+
if (typeof o.mode === "string" && o.mode.length > 0) {
|
|
139
|
+
modeFromOutput = o.mode;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const outputJson = view.output !== undefined ? JSON.stringify(view.output) : null;
|
|
143
|
+
// Advance hot path: retry a transient D1 blip so a sweep tick self-heals instead of aborting.
|
|
144
|
+
await withD1Retry(() => env.DB.prepare(`UPDATE renders SET
|
|
145
|
+
status = ?,
|
|
146
|
+
output_key = COALESCE(?, output_key),
|
|
147
|
+
output_json = ?,
|
|
148
|
+
error = ?,
|
|
149
|
+
execution_time_ms = ?,
|
|
150
|
+
delay_time_ms = ?,
|
|
151
|
+
updated_at = ?,
|
|
152
|
+
completed_at = COALESCE(?, completed_at),
|
|
153
|
+
keyframes_json = COALESCE(?, keyframes_json),
|
|
154
|
+
mode = COALESCE(?, mode)
|
|
155
|
+
WHERE job_id = ?`)
|
|
156
|
+
.bind(view.status, outputKey, outputJson, view.error ?? null, view.executionTimeMs ?? null, view.delayTimeMs ?? null, now, completed, keyframesJson, modeFromOutput, view.jobId)
|
|
157
|
+
.run());
|
|
158
|
+
// v0.141.0: on terminal status, persist a per-render log to R2 (conventional
|
|
159
|
+
// key renders/logs/<jobId>.txt) so History can offer a "view logs" link.
|
|
160
|
+
// Best-effort: this never blocks or breaks the render-resolve path. When an
|
|
161
|
+
// ExecutionContext is supplied (the poll route) the R2 write runs via
|
|
162
|
+
// ctx.waitUntil, OFF the poll hot path -- the caller's response no longer waits
|
|
163
|
+
// on an R2 PUT (issue #15). Without ctx (tests / other callers) it falls back to
|
|
164
|
+
// awaiting so behavior is unchanged. A failure is logged rather than swallowed
|
|
165
|
+
// silently, so a persistently failing log write is diagnosable instead of invisible.
|
|
166
|
+
if (completed !== null) {
|
|
167
|
+
const logTask = (async () => {
|
|
168
|
+
try {
|
|
169
|
+
await writeRenderLog(env, view);
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
console.warn(`render log write failed for job ${view.jobId}: ${e instanceof Error ? e.message : String(e)}`);
|
|
173
|
+
}
|
|
174
|
+
})();
|
|
175
|
+
if (ctx)
|
|
176
|
+
ctx.waitUntil(logTask);
|
|
177
|
+
else
|
|
178
|
+
await logTask;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// v0.146.0: cloud-animate progress feedback. A cloud animation runs one
|
|
182
|
+
// provider call per shot for several minutes, so write a lightweight
|
|
183
|
+
// "done of total" marker into output_json as each shot lands; the History row
|
|
184
|
+
// surfaces "animating shot k/N" while the job is in flight. Guarded to
|
|
185
|
+
// non-terminal rows so it can never clobber a row that already finished (the
|
|
186
|
+
// finalize step overwrites output_json with the real output at COMPLETED).
|
|
187
|
+
export async function setCloudAnimateProgress(env, jobId, done, total) {
|
|
188
|
+
const now = nowSeconds();
|
|
189
|
+
const json = JSON.stringify({ mode: "cloud-finalized", progress: { done, total } });
|
|
190
|
+
await env.DB.prepare(`UPDATE renders SET output_json = ?, updated_at = ?
|
|
191
|
+
WHERE job_id = ?
|
|
192
|
+
AND status NOT IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')`)
|
|
193
|
+
.bind(json, now, jobId)
|
|
194
|
+
.run();
|
|
195
|
+
}
|
|
196
|
+
export async function setHybridProgress(env, jobId, lanes) {
|
|
197
|
+
const now = nowSeconds();
|
|
198
|
+
const done = lanes.gpu.done + lanes.cloud.done;
|
|
199
|
+
const total = lanes.gpu.total + lanes.cloud.total;
|
|
200
|
+
const json = JSON.stringify({
|
|
201
|
+
mode: "cloud-finalized",
|
|
202
|
+
progress: { done, total, gpu: lanes.gpu, cloud: lanes.cloud },
|
|
203
|
+
});
|
|
204
|
+
await env.DB.prepare(`UPDATE renders SET output_json = ?, updated_at = ?
|
|
205
|
+
WHERE job_id = ?
|
|
206
|
+
AND status NOT IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')`)
|
|
207
|
+
.bind(json, now, jobId)
|
|
208
|
+
.run();
|
|
209
|
+
}
|
|
210
|
+
export async function getRenderForPoll(env, jobId) {
|
|
211
|
+
const r = await env.DB.prepare(`SELECT status, submitted_at, output_json AS output, output_key, error
|
|
212
|
+
FROM renders WHERE job_id = ?`)
|
|
213
|
+
.bind(jobId)
|
|
214
|
+
.first();
|
|
215
|
+
if (!r)
|
|
216
|
+
return null;
|
|
217
|
+
let output = null;
|
|
218
|
+
const opRaw = r.output;
|
|
219
|
+
if (typeof opRaw === "string" && opRaw.length > 0) {
|
|
220
|
+
try {
|
|
221
|
+
output = JSON.parse(opRaw);
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
warnCorruptColumn("output_json", e);
|
|
225
|
+
output = opRaw;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
status: String(r.status),
|
|
230
|
+
submitted_at: Number(r.submitted_at),
|
|
231
|
+
output,
|
|
232
|
+
output_key: r.output_key ? String(r.output_key) : null,
|
|
233
|
+
error: r.error ? String(r.error) : null,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
// v0.136.0: fail a render row by jobId (used when RunPod has no record of the
|
|
237
|
+
// job past the grace window). Guarded so it never clobbers a row that already
|
|
238
|
+
// reached a terminal state. Returns true iff a non-terminal row was flipped.
|
|
239
|
+
export async function markRenderFailedByJobId(env, jobId, error) {
|
|
240
|
+
const now = nowSeconds();
|
|
241
|
+
const res = await withD1Retry(() => env.DB.prepare(`UPDATE renders SET
|
|
242
|
+
status = 'FAILED',
|
|
243
|
+
error = ?,
|
|
244
|
+
completed_at = COALESCE(completed_at, ?),
|
|
245
|
+
updated_at = ?
|
|
246
|
+
WHERE job_id = ?
|
|
247
|
+
AND status NOT IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')`)
|
|
248
|
+
.bind(error.slice(0, 2000), now, now, jobId)
|
|
249
|
+
.run());
|
|
250
|
+
return (res.meta?.changes ?? 0) > 0;
|
|
251
|
+
}
|
|
252
|
+
// v0.122.0: off-GPU finish bookkeeping. When a render used finish_offloaded, the
|
|
253
|
+
// pod returns clips (no assembled MP4); the Worker assembles via the video-finish
|
|
254
|
+
// container on poll-completion. finish_state (NULL -> 'finishing' -> 'done' |
|
|
255
|
+
// 'failed') is the idempotency lock so concurrent polls don't double-run the
|
|
256
|
+
// container.
|
|
257
|
+
// Atomically claim the finish for this job. Returns true iff THIS caller won the
|
|
258
|
+
// claim (flipped finish_state to 'finishing'); a concurrent poll that lost gets
|
|
259
|
+
// false and should report "still finishing". 'failed' is re-claimable (retry).
|
|
260
|
+
export async function claimFinish(env, jobId) {
|
|
261
|
+
const now = nowSeconds();
|
|
262
|
+
const res = await withD1Retry(() => env.DB.prepare(`UPDATE renders SET finish_state = 'finishing', updated_at = ?
|
|
263
|
+
WHERE job_id = ? AND COALESCE(finish_state, '') NOT IN ('finishing', 'done')`)
|
|
264
|
+
.bind(now, jobId)
|
|
265
|
+
.run());
|
|
266
|
+
return (res.meta?.changes ?? 0) === 1;
|
|
267
|
+
}
|
|
268
|
+
export async function markFinishDone(env, jobId, outputKey, outputJson) {
|
|
269
|
+
const now = nowSeconds();
|
|
270
|
+
await withD1Retry(() => env.DB.prepare(`UPDATE renders SET output_key = ?, output_json = ?, status = 'COMPLETED',
|
|
271
|
+
finish_state = 'done', completed_at = COALESCE(completed_at, ?), updated_at = ?
|
|
272
|
+
WHERE job_id = ?`)
|
|
273
|
+
.bind(outputKey, outputJson, now, now, jobId)
|
|
274
|
+
.run());
|
|
275
|
+
}
|
|
276
|
+
export async function claimRenderNotify(env, jobId) {
|
|
277
|
+
const now = nowSeconds();
|
|
278
|
+
const res = await env.DB.prepare(`UPDATE renders SET notified_at = ?
|
|
279
|
+
WHERE job_id = ? AND notified_at IS NULL
|
|
280
|
+
AND status IN ('COMPLETED', 'FAILED')
|
|
281
|
+
AND COALESCE(mode, 'full') != 'keyframes-only'`)
|
|
282
|
+
.bind(now, jobId)
|
|
283
|
+
.run();
|
|
284
|
+
if ((res.meta?.changes ?? 0) !== 1)
|
|
285
|
+
return null;
|
|
286
|
+
const row = await env.DB.prepare(`SELECT project, status, output_key, error, execution_time_ms, mode
|
|
287
|
+
FROM renders WHERE job_id = ?`)
|
|
288
|
+
.bind(jobId)
|
|
289
|
+
.first();
|
|
290
|
+
return row ?? null;
|
|
291
|
+
}
|
|
292
|
+
// S4 (migration 0007): film-advance lease. advanceFilmJob does an unlocked read-modify-write on
|
|
293
|
+
// the R2 film-job doc and is driven concurrently by the 1-minute cron sweep AND every client
|
|
294
|
+
// status poll; two drivers in the same tick can each submit the next phase's external work (clip
|
|
295
|
+
// start, dialogue batch, per-shot finish/speech/master steps, mux, notify) -- duplicated GPU
|
|
296
|
+
// spend -- and clobber each other's doc writes (a lost poll token orphans a RunPod job). Same
|
|
297
|
+
// conditional-UPDATE discipline as claimFinish: one winner per tick, checked via meta.changes.
|
|
298
|
+
// The lease value (its expiry, unix ms) doubles as the holder's token, so a release can never
|
|
299
|
+
// clear a successor's lease. Expiry makes a crashed winner's claim re-grantable instead of
|
|
300
|
+
// wedging the job: the first driver past the expiry wins it fresh (the sweep re-drives every
|
|
301
|
+
// minute, so the worst-case stall after a crash is the TTL).
|
|
302
|
+
export { FILM_ADVANCE_LEASE_TTL_SECONDS, claimFilmAdvance, releaseFilmAdvance, } from "./film-advance-lease.js";
|
|
303
|
+
// v0.139.0: jobs to resolve in the background (the cron sweep) so a fire-and-
|
|
304
|
+
// forget API render still reaches terminal + emails its owner without a client
|
|
305
|
+
// polling. Only non-terminal rows recent enough to still be live on RunPod;
|
|
306
|
+
// keyframe previews excluded (never emailed). Bounded so one tick is cheap.
|
|
307
|
+
// v0.161.1: scatter shard children (parent_id IS NOT NULL) are excluded -- the
|
|
308
|
+
// parent's gather owns their lifecycle + the single notify, so a shard must not
|
|
309
|
+
// be swept (RunPod-polled + emailed) on its own. Scatter PARENTS (parent_id NULL)
|
|
310
|
+
// stay in the sweep; the scheduled handler drives their gather, never RunPod-polls.
|
|
311
|
+
export async function listUnresolvedNotifiableJobs(env, maxAgeSeconds, limit = 25) {
|
|
312
|
+
const cutoff = nowSeconds() - Math.max(0, maxAgeSeconds);
|
|
313
|
+
const res = await env.DB.prepare(`SELECT job_id FROM renders
|
|
314
|
+
WHERE status NOT IN ('COMPLETED', 'FAILED', 'CANCELLED')
|
|
315
|
+
AND notified_at IS NULL
|
|
316
|
+
AND COALESCE(mode, 'full') != 'keyframes-only'
|
|
317
|
+
AND parent_id IS NULL
|
|
318
|
+
AND submitted_at >= ?
|
|
319
|
+
ORDER BY submitted_at ASC
|
|
320
|
+
LIMIT ?`)
|
|
321
|
+
.bind(cutoff, Math.min(Math.max(1, limit), 100))
|
|
322
|
+
.all();
|
|
323
|
+
return (res.results ?? []).map((r) => String(r.job_id)).filter((s) => s.length > 0);
|
|
324
|
+
}
|
|
325
|
+
// Stranded post-clips film jobs the normal age-windowed sweep no longer picks up.
|
|
326
|
+
// Once a film's clips are rendered (phase is finish/assemble/mux), the only work
|
|
327
|
+
// left is the CPU-only ffmpeg concat in video-finish, which never expires -- so a
|
|
328
|
+
// job that stalled at assemble (e.g. the client stopped polling, or a transient
|
|
329
|
+
// container outage outlasted a poll) should self-heal even past SWEEP_MAX_AGE_SECONDS,
|
|
330
|
+
// rather than abandoning a fully-rendered film over its final CPU step. The age cutoff
|
|
331
|
+
// exists to stop chasing RunPod jobs the platform has GC'd; that does not apply here,
|
|
332
|
+
// because the finished clips are already in R2 and addressable from the film-job doc.
|
|
333
|
+
// We gate on the persisted poll phase (output_json) so we never re-drive a job that
|
|
334
|
+
// never got past the GPU stage; the caller additionally confirms the film-job doc still
|
|
335
|
+
// exists in R2 before advancing. Bounded + ordered oldest-first so one tick stays cheap.
|
|
336
|
+
export async function listStrandedPostClipsFilmJobs(env, maxAgeSeconds, limit = 25) {
|
|
337
|
+
const cutoff = nowSeconds() - Math.max(0, maxAgeSeconds);
|
|
338
|
+
const res = await env.DB.prepare(`SELECT job_id FROM renders
|
|
339
|
+
WHERE status NOT IN ('COMPLETED', 'FAILED', 'CANCELLED')
|
|
340
|
+
AND notified_at IS NULL
|
|
341
|
+
AND COALESCE(mode, 'full') != 'keyframes-only'
|
|
342
|
+
AND parent_id IS NULL
|
|
343
|
+
AND submitted_at < ?
|
|
344
|
+
AND (
|
|
345
|
+
output_json LIKE '%"phase":"assemble"%'
|
|
346
|
+
OR output_json LIKE '%"phase":"finish"%'
|
|
347
|
+
OR output_json LIKE '%"phase":"mux"%'
|
|
348
|
+
)
|
|
349
|
+
ORDER BY submitted_at ASC
|
|
350
|
+
LIMIT ?`)
|
|
351
|
+
.bind(cutoff, Math.min(Math.max(1, limit), 100))
|
|
352
|
+
.all();
|
|
353
|
+
return (res.results ?? []).map((r) => String(r.job_id)).filter((s) => s.length > 0);
|
|
354
|
+
}
|
|
355
|
+
export async function markFinishFailed(env, jobId, error) {
|
|
356
|
+
const now = nowSeconds();
|
|
357
|
+
await withD1Retry(() => env.DB.prepare(`UPDATE renders SET finish_state = 'failed', error = ?, updated_at = ? WHERE job_id = ?`)
|
|
358
|
+
.bind(error.slice(0, 2000), now, jobId)
|
|
359
|
+
.run());
|
|
360
|
+
}
|
|
361
|
+
export async function getFinishState(env, jobId) {
|
|
362
|
+
const row = await withD1Retry(() => env.DB.prepare(`SELECT finish_state, output_key FROM renders WHERE job_id = ?`)
|
|
363
|
+
.bind(jobId)
|
|
364
|
+
.first());
|
|
365
|
+
return row ?? null;
|
|
366
|
+
}
|
|
367
|
+
// v0.42.0: defensive parse of a locked-shots array stored as JSON in
|
|
368
|
+
// the renders.locked_shots_json column OR coming in over the wire on
|
|
369
|
+
// a PATCH. Drops non-string + empty + duplicate entries; clamps the
|
|
370
|
+
// list length to a sane upper bound so a malformed client cannot
|
|
371
|
+
// stuff arbitrary blobs into the row.
|
|
372
|
+
const MAX_LOCKED_SHOTS = 200;
|
|
373
|
+
export function normalizeLockedShots(raw) {
|
|
374
|
+
if (!Array.isArray(raw))
|
|
375
|
+
return [];
|
|
376
|
+
const seen = new Set();
|
|
377
|
+
const out = [];
|
|
378
|
+
for (const entry of raw) {
|
|
379
|
+
if (typeof entry !== "string")
|
|
380
|
+
continue;
|
|
381
|
+
const trimmed = entry.trim();
|
|
382
|
+
if (!trimmed || trimmed.length > 80)
|
|
383
|
+
continue;
|
|
384
|
+
if (seen.has(trimmed))
|
|
385
|
+
continue;
|
|
386
|
+
seen.add(trimmed);
|
|
387
|
+
out.push(trimmed);
|
|
388
|
+
if (out.length >= MAX_LOCKED_SHOTS)
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
// v0.126.0: normalize a free-form folder path. Splits on "/", trims each
|
|
394
|
+
// segment, drops empties (so leading / trailing / doubled slashes collapse),
|
|
395
|
+
// rejoins, and caps length. Returns null for "unfiled" (empty / non-string).
|
|
396
|
+
export function normalizeFolderPath(raw) {
|
|
397
|
+
if (typeof raw !== "string")
|
|
398
|
+
return null;
|
|
399
|
+
const parts = raw
|
|
400
|
+
.split("/")
|
|
401
|
+
.map((s) => s.trim())
|
|
402
|
+
.filter((s) => s.length > 0);
|
|
403
|
+
if (parts.length === 0)
|
|
404
|
+
return null;
|
|
405
|
+
const joined = parts.join("/");
|
|
406
|
+
return joined.length > 200 ? joined.slice(0, 200) : joined;
|
|
407
|
+
}
|
|
408
|
+
const MAX_TAGS = 24;
|
|
409
|
+
const MAX_TAG_LEN = 40;
|
|
410
|
+
// v0.126.0: normalize a tag list. Lowercase + trim each, drop empties, cap
|
|
411
|
+
// each tag's length and the total count, dedupe (order-preserving). Mirrors
|
|
412
|
+
// normalizeLockedShots; used on both the PATCH write path and the read path.
|
|
413
|
+
export function normalizeTags(raw) {
|
|
414
|
+
if (!Array.isArray(raw))
|
|
415
|
+
return [];
|
|
416
|
+
const seen = new Set();
|
|
417
|
+
const out = [];
|
|
418
|
+
for (const entry of raw) {
|
|
419
|
+
if (typeof entry !== "string")
|
|
420
|
+
continue;
|
|
421
|
+
const tag = entry.trim().toLowerCase().slice(0, MAX_TAG_LEN);
|
|
422
|
+
if (!tag)
|
|
423
|
+
continue;
|
|
424
|
+
if (seen.has(tag))
|
|
425
|
+
continue;
|
|
426
|
+
seen.add(tag);
|
|
427
|
+
out.push(tag);
|
|
428
|
+
if (out.length >= MAX_TAGS)
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
return out;
|
|
432
|
+
}
|
|
433
|
+
// Best-effort coerce `output.keyframes` from a job envelope into a
|
|
434
|
+
// well-formed KeyframeRef[]. Anything that does not look like an
|
|
435
|
+
// object with string `shot_id` + `key` is dropped silently; that
|
|
436
|
+
// way a GPU side that adds future fields to each entry does not
|
|
437
|
+
// crash the UPDATE.
|
|
438
|
+
export function normalizeKeyframes(raw) {
|
|
439
|
+
if (!Array.isArray(raw))
|
|
440
|
+
return [];
|
|
441
|
+
const out = [];
|
|
442
|
+
for (const entry of raw) {
|
|
443
|
+
if (!entry || typeof entry !== "object")
|
|
444
|
+
continue;
|
|
445
|
+
const e = entry;
|
|
446
|
+
if (typeof e.shot_id !== "string" || e.shot_id.length === 0)
|
|
447
|
+
continue;
|
|
448
|
+
if (typeof e.key !== "string" || e.key.length === 0)
|
|
449
|
+
continue;
|
|
450
|
+
out.push({ shot_id: e.shot_id, key: e.key });
|
|
451
|
+
}
|
|
452
|
+
return out;
|
|
453
|
+
}
|
|
454
|
+
// Fetch one row by D1 PK. Returns null when the row does not exist.
|
|
455
|
+
// v0.136.4: point a finished render at a new MP4 that has audio muxed in
|
|
456
|
+
// (produced off-GPU by the video-finish container). Updates output_key plus the
|
|
457
|
+
// output_json's output_key / has_audio / seconds so the History download link
|
|
458
|
+
// and the audio badge reflect the muxed version.
|
|
459
|
+
export async function setRenderAudioOutput(env, id, outputKey, seconds) {
|
|
460
|
+
const now = nowSeconds();
|
|
461
|
+
const res = await env.DB.prepare(`UPDATE renders SET
|
|
462
|
+
output_key = ?,
|
|
463
|
+
output_json = json_set(
|
|
464
|
+
COALESCE(output_json, '{}'),
|
|
465
|
+
'$.output_key', ?,
|
|
466
|
+
'$.has_audio', json('true'),
|
|
467
|
+
'$.seconds', ?
|
|
468
|
+
),
|
|
469
|
+
updated_at = ?
|
|
470
|
+
WHERE id = ?`)
|
|
471
|
+
.bind(outputKey, outputKey, seconds, now, id)
|
|
472
|
+
.run();
|
|
473
|
+
return (res.meta?.changes ?? 0) > 0;
|
|
474
|
+
}
|
|
475
|
+
// Resolve an opaque public id to the internal integer PK (the :id route boundary for renders).
|
|
476
|
+
// Null when no render carries that public_id -- a bare sequential integer matches nothing, so the
|
|
477
|
+
// route 404s. INDEX-backed (idx_renders_public_id) unique lookup, mirroring getRenderIdByJobId.
|
|
478
|
+
export async function getRenderIdByPublicId(env, publicId) {
|
|
479
|
+
const r = await env.DB.prepare(`SELECT id FROM renders WHERE public_id = ? LIMIT 1`)
|
|
480
|
+
.bind(publicId)
|
|
481
|
+
.first();
|
|
482
|
+
return r ? Number(r.id) : null;
|
|
483
|
+
}
|
|
484
|
+
export async function getRenderByIdForUser(env, id) {
|
|
485
|
+
const r = await env.DB.prepare(`SELECT${RENDER_ROW_COLUMNS}${RENDER_ROW_FROM}
|
|
486
|
+
WHERE r.id = ?`)
|
|
487
|
+
.bind(id)
|
|
488
|
+
.first();
|
|
489
|
+
if (!r)
|
|
490
|
+
return null;
|
|
491
|
+
return normalizeRow(r);
|
|
492
|
+
}
|
|
493
|
+
// Update one row's label. Empty / null clears it.
|
|
494
|
+
export async function setRenderLabel(env, id, label) {
|
|
495
|
+
const now = Math.floor(Date.now() / 1000);
|
|
496
|
+
const result = await env.DB.prepare(`UPDATE renders SET label = ?, updated_at = ? WHERE id = ?`)
|
|
497
|
+
.bind(label, now, id)
|
|
498
|
+
.run();
|
|
499
|
+
const changes = result.meta?.changes ?? 0;
|
|
500
|
+
return changes > 0;
|
|
501
|
+
}
|
|
502
|
+
// True when at least one OTHER row references the same output_key. Used
|
|
503
|
+
// to gate R2 artifact deletion: re-renders of the same project can share
|
|
504
|
+
// an output filename (rp_handler.py writes `renders/<project>/<name>.mp4`,
|
|
505
|
+
// so a re-render at the same name would overwrite), and we never want to
|
|
506
|
+
// strand a still-referenced artifact.
|
|
507
|
+
export async function countOtherRowsWithOutputKey(env, id, outputKey) {
|
|
508
|
+
const r = await env.DB.prepare(`SELECT COUNT(*) AS n FROM renders WHERE output_key = ? AND id != ?`)
|
|
509
|
+
.bind(outputKey, id)
|
|
510
|
+
.first();
|
|
511
|
+
return Number(r?.n ?? 0);
|
|
512
|
+
}
|
|
513
|
+
// Delete one row by D1 PK. Returns true when a row was removed.
|
|
514
|
+
export async function deleteRenderRow(env, id) {
|
|
515
|
+
const result = await env.DB.prepare(`DELETE FROM renders WHERE id = ?`)
|
|
516
|
+
.bind(id)
|
|
517
|
+
.run();
|
|
518
|
+
const changes = result.meta?.changes ?? 0;
|
|
519
|
+
return changes > 0;
|
|
520
|
+
}
|
|
521
|
+
/** The renders-list page size when a caller passes no `limit`. ONE source of truth for both the
|
|
522
|
+
* GET /api/storyboard/renders route and this function's default, so they cannot drift (#670: the route
|
|
523
|
+
* said 100, this signature said 50). 50 is a sensible API/MCP page (well under the 200 clamp below);
|
|
524
|
+
* the frontend always sends its own explicit limit, so this governs headless/API/MCP consumers. */
|
|
525
|
+
export const DEFAULT_RENDERS_LIMIT = 50;
|
|
526
|
+
export async function listRendersForUser(env, limit = DEFAULT_RENDERS_LIMIT, projectId = null) {
|
|
527
|
+
// Clamp limit so a runaway client cannot drain the DB binding.
|
|
528
|
+
const cap = Math.min(Math.max(1, Math.floor(limit)), 200);
|
|
529
|
+
const baseSelect = `SELECT${RENDER_ROW_COLUMNS}${RENDER_ROW_FROM}`;
|
|
530
|
+
const stmt = projectId !== null && projectId > 0
|
|
531
|
+
? env.DB.prepare(`${baseSelect}
|
|
532
|
+
WHERE r.project_id = ? OR r.project_id IS NULL
|
|
533
|
+
ORDER BY r.submitted_at DESC
|
|
534
|
+
LIMIT ?`).bind(projectId, cap)
|
|
535
|
+
: env.DB.prepare(`${baseSelect}
|
|
536
|
+
ORDER BY r.submitted_at DESC
|
|
537
|
+
LIMIT ?`).bind(cap);
|
|
538
|
+
const result = await stmt.all();
|
|
539
|
+
return (result.results ?? []).map(normalizeRow);
|
|
540
|
+
}
|
|
541
|
+
// D1 returns JSON columns as opaque strings; parse them back. A malformed
|
|
542
|
+
// stored JSON falls back to null (overrides) or the raw string (output) so
|
|
543
|
+
// a corrupted row never crashes a list response. The RawRenderRow type is a
|
|
544
|
+
// compile-time claim only, so every guard below still handles a missing
|
|
545
|
+
// (undefined) or wrongly-typed field at runtime (`== null` covers both
|
|
546
|
+
// SQL NULL and an absent column).
|
|
547
|
+
function normalizeRow(r) {
|
|
548
|
+
let overrides = null;
|
|
549
|
+
const oRaw = r.render_overrides;
|
|
550
|
+
if (typeof oRaw === "string" && oRaw.length > 0) {
|
|
551
|
+
try {
|
|
552
|
+
const parsed = JSON.parse(oRaw);
|
|
553
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
554
|
+
overrides = parsed;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
catch (e) {
|
|
558
|
+
warnCorruptColumn("render_overrides", e);
|
|
559
|
+
overrides = null;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
let output = null;
|
|
563
|
+
const opRaw = r.output;
|
|
564
|
+
if (typeof opRaw === "string" && opRaw.length > 0) {
|
|
565
|
+
try {
|
|
566
|
+
output = JSON.parse(opRaw);
|
|
567
|
+
}
|
|
568
|
+
catch (e) {
|
|
569
|
+
warnCorruptColumn("output_json", e);
|
|
570
|
+
output = opRaw;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
let keyframes = null;
|
|
574
|
+
const kfRaw = r.keyframes_json;
|
|
575
|
+
if (typeof kfRaw === "string" && kfRaw.length > 0) {
|
|
576
|
+
try {
|
|
577
|
+
const parsed = JSON.parse(kfRaw);
|
|
578
|
+
const refs = normalizeKeyframes(parsed);
|
|
579
|
+
if (refs.length > 0)
|
|
580
|
+
keyframes = refs;
|
|
581
|
+
}
|
|
582
|
+
catch (e) {
|
|
583
|
+
warnCorruptColumn("keyframes_json", e);
|
|
584
|
+
keyframes = null;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
id: Number(r.id),
|
|
589
|
+
public_id: String(r.public_id),
|
|
590
|
+
job_id: String(r.job_id),
|
|
591
|
+
// D1 returns a SQL-NULL column as JS null, and String(null) === "null":
|
|
592
|
+
// the literal "null" string is truthy and defeats every downstream falsy
|
|
593
|
+
// guard (planner labels, download names, and re-render eligibility gating
|
|
594
|
+
// that keys off a truthy bundle_key). The schema permits these three to be
|
|
595
|
+
// NULL (migrations/0001_init.sql), so coerce SQL NULL to "" here to keep
|
|
596
|
+
// the RenderRow non-null string contract with a falsy empty value.
|
|
597
|
+
project: r.project == null ? "" : String(r.project),
|
|
598
|
+
bundle_key: r.bundle_key == null ? "" : String(r.bundle_key),
|
|
599
|
+
quality_tier: r.quality_tier == null ? "" : String(r.quality_tier),
|
|
600
|
+
render_overrides: overrides,
|
|
601
|
+
status: String(r.status),
|
|
602
|
+
output_key: r.output_key ? String(r.output_key) : null,
|
|
603
|
+
output,
|
|
604
|
+
error: r.error ? String(r.error) : null,
|
|
605
|
+
execution_time_ms: r.execution_time_ms == null ? null : Number(r.execution_time_ms),
|
|
606
|
+
delay_time_ms: r.delay_time_ms == null ? null : Number(r.delay_time_ms),
|
|
607
|
+
submitted_at: Number(r.submitted_at),
|
|
608
|
+
updated_at: Number(r.updated_at),
|
|
609
|
+
completed_at: r.completed_at == null ? null : Number(r.completed_at),
|
|
610
|
+
label: typeof r.label === "string" && r.label.length > 0 ? r.label : null,
|
|
611
|
+
keyframes,
|
|
612
|
+
// v0.40.0: collapse NULL / unknown values to 'full' so callers do
|
|
613
|
+
// not need to do this themselves. Legacy rows pre-dating the mode
|
|
614
|
+
// column read as NULL and are therefore 'full'.
|
|
615
|
+
// v0.42.0 adds 'finalized' as a third recognized value.
|
|
616
|
+
mode: r.mode === "keyframes-only"
|
|
617
|
+
? "keyframes-only"
|
|
618
|
+
: r.mode === "finalized"
|
|
619
|
+
? "finalized"
|
|
620
|
+
: r.mode === "cloud-finalized"
|
|
621
|
+
? "cloud-finalized"
|
|
622
|
+
: "full",
|
|
623
|
+
// v0.42.0: parse the locked_shots_json column back into a string
|
|
624
|
+
// array; NULL / empty / malformed -> null (read as "nothing
|
|
625
|
+
// locked"). The normalizer keeps the same MAX_LOCKED_SHOTS cap as
|
|
626
|
+
// the write path so a corrupted row cannot bloat a list response.
|
|
627
|
+
locked_shots: (() => {
|
|
628
|
+
const lsRaw = r.locked_shots_json;
|
|
629
|
+
if (typeof lsRaw !== "string" || lsRaw.length === 0)
|
|
630
|
+
return null;
|
|
631
|
+
try {
|
|
632
|
+
const parsed = JSON.parse(lsRaw);
|
|
633
|
+
const arr = normalizeLockedShots(parsed);
|
|
634
|
+
return arr.length > 0 ? arr : null;
|
|
635
|
+
}
|
|
636
|
+
catch (e) {
|
|
637
|
+
warnCorruptColumn("locked_shots_json", e);
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
})(),
|
|
641
|
+
// v0.55.0: NULL for legacy rows or transient (no-project) submits.
|
|
642
|
+
project_id: r.project_id == null ? null : Number(r.project_id),
|
|
643
|
+
// v0.126.0: organization fields. folder_path is stored verbatim (already
|
|
644
|
+
// normalized on the write path); tags_json is a JSON array re-normalized
|
|
645
|
+
// on read so a hand-edited / corrupted row can never bloat a list.
|
|
646
|
+
folder_path: typeof r.folder_path === "string" && r.folder_path.length > 0
|
|
647
|
+
? r.folder_path
|
|
648
|
+
: null,
|
|
649
|
+
tags: (() => {
|
|
650
|
+
const tRaw = r.tags_json;
|
|
651
|
+
if (typeof tRaw !== "string" || tRaw.length === 0)
|
|
652
|
+
return [];
|
|
653
|
+
try {
|
|
654
|
+
return normalizeTags(JSON.parse(tRaw));
|
|
655
|
+
}
|
|
656
|
+
catch (e) {
|
|
657
|
+
warnCorruptColumn("tags_json", e);
|
|
658
|
+
return [];
|
|
659
|
+
}
|
|
660
|
+
})(),
|
|
661
|
+
// v0.145.2: NULL on top-level renders; set on finalize / animate-cloud
|
|
662
|
+
// children to the keyframes-only preview render they derive from.
|
|
663
|
+
parent_id: r.parent_id == null ? null : Number(r.parent_id),
|
|
664
|
+
// S9 (F13): FK public ids from the LEFT JOIN; NULL when the FK is NULL or the referent is gone.
|
|
665
|
+
project_public_id: r.project_public_id == null ? null : String(r.project_public_id),
|
|
666
|
+
parent_public_id: r.parent_public_id == null ? null : String(r.parent_public_id),
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
// v0.42.0: PATCH locked_shots on a row.
|
|
670
|
+
export async function setRenderLockedShots(env, id, lockedShots) {
|
|
671
|
+
const now = Math.floor(Date.now() / 1000);
|
|
672
|
+
const json = lockedShots.length > 0 ? JSON.stringify(lockedShots) : null;
|
|
673
|
+
const result = await env.DB.prepare(`UPDATE renders SET locked_shots_json = ?, updated_at = ? WHERE id = ?`)
|
|
674
|
+
.bind(json, now, id)
|
|
675
|
+
.run();
|
|
676
|
+
const changes = result.meta?.changes ?? 0;
|
|
677
|
+
return changes > 0;
|
|
678
|
+
}
|
|
679
|
+
// v0.161.0: the integer id for a job_id. The scatter submit inserts a parent
|
|
680
|
+
// row keyed by a synthetic scatter-<uuid> job_id, then needs its autoincrement
|
|
681
|
+
// id to link the child shard rows via parent_id. job_id is UNIQUE + unguessable,
|
|
682
|
+
// so this is not user-scoped (the same capability model as getRenderForPoll).
|
|
683
|
+
export async function getRenderIdByJobId(env, jobId) {
|
|
684
|
+
const r = await env.DB.prepare(`SELECT id FROM renders WHERE job_id = ?`)
|
|
685
|
+
.bind(jobId)
|
|
686
|
+
.first();
|
|
687
|
+
return r ? Number(r.id) : null;
|
|
688
|
+
}
|
|
689
|
+
// v0.161.0: the child shard rows of a scatter parent (job_id + last status),
|
|
690
|
+
// for the gather watcher to poll each shard and decide finish/wait/fail. A
|
|
691
|
+
// scatter parent's children are exactly its shards (no finalize/animate child
|
|
692
|
+
// ever points at a scatter parent), so parent_id alone is the right filter.
|
|
693
|
+
export async function getScatterChildren(env, parentId) {
|
|
694
|
+
const rs = await env.DB.prepare(`SELECT job_id, status FROM renders WHERE parent_id = ? ORDER BY id ASC`)
|
|
695
|
+
.bind(parentId)
|
|
696
|
+
.all();
|
|
697
|
+
return (rs.results ?? []).map((r) => ({ job_id: String(r.job_id), status: String(r.status) }));
|
|
698
|
+
}
|
|
699
|
+
// v0.126.0: PATCH the folder_path on a row. null / '' clears it (unfiled).
|
|
700
|
+
export async function setRenderFolder(env, id, folderPath) {
|
|
701
|
+
const now = Math.floor(Date.now() / 1000);
|
|
702
|
+
const result = await env.DB.prepare(`UPDATE renders SET folder_path = ?, updated_at = ? WHERE id = ?`)
|
|
703
|
+
.bind(folderPath, now, id)
|
|
704
|
+
.run();
|
|
705
|
+
const changes = result.meta?.changes ?? 0;
|
|
706
|
+
return changes > 0;
|
|
707
|
+
}
|
|
708
|
+
// v0.126.0: PATCH the tags on a row. An empty list stores NULL (untagged).
|
|
709
|
+
export async function setRenderTags(env, id, tags) {
|
|
710
|
+
const now = Math.floor(Date.now() / 1000);
|
|
711
|
+
const json = tags.length > 0 ? JSON.stringify(tags) : null;
|
|
712
|
+
const result = await env.DB.prepare(`UPDATE renders SET tags_json = ?, updated_at = ? WHERE id = ?`)
|
|
713
|
+
.bind(json, now, id)
|
|
714
|
+
.run();
|
|
715
|
+
const changes = result.meta?.changes ?? 0;
|
|
716
|
+
return changes > 0;
|
|
717
|
+
}
|
|
718
|
+
// v0.126.0: the distinct tags this user has applied across all their renders,
|
|
719
|
+
// most-used first (then alphabetical), for the history tag-filter autocomplete.
|
|
720
|
+
// Cap the tag-autocomplete scan to the most recent tagged renders instead of loading EVERY render's
|
|
721
|
+
// tags_json (issue #12). Autocomplete only needs a representative set; the newest tagged renders are
|
|
722
|
+
// the relevant ones, and the count-sort below still ranks within that window.
|
|
723
|
+
const TAG_SCAN_LIMIT = 500;
|
|
724
|
+
export async function listUserTags(env) {
|
|
725
|
+
const result = await env.DB.prepare(`SELECT tags_json FROM renders
|
|
726
|
+
WHERE tags_json IS NOT NULL
|
|
727
|
+
ORDER BY submitted_at DESC
|
|
728
|
+
LIMIT ?`)
|
|
729
|
+
.bind(TAG_SCAN_LIMIT)
|
|
730
|
+
.all();
|
|
731
|
+
const rows = result.results ?? [];
|
|
732
|
+
const counts = new Map();
|
|
733
|
+
for (const row of rows) {
|
|
734
|
+
if (typeof row.tags_json !== "string")
|
|
735
|
+
continue;
|
|
736
|
+
let parsed;
|
|
737
|
+
try {
|
|
738
|
+
parsed = JSON.parse(row.tags_json);
|
|
739
|
+
}
|
|
740
|
+
catch (e) {
|
|
741
|
+
warnCorruptColumn("tags_json", e);
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
for (const tag of normalizeTags(parsed)) {
|
|
745
|
+
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return [...counts.entries()]
|
|
749
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
750
|
+
.map(([tag]) => tag);
|
|
751
|
+
}
|
|
752
|
+
//# sourceMappingURL=renders-db.js.map
|