@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.
Files changed (267) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +87 -0
  3. package/dist/audio-routing.d.ts +2 -0
  4. package/dist/audio-routing.d.ts.map +1 -0
  5. package/dist/audio-routing.js +4 -0
  6. package/dist/audio-routing.js.map +1 -0
  7. package/dist/audio-stage.d.ts +4 -0
  8. package/dist/audio-stage.d.ts.map +1 -0
  9. package/dist/audio-stage.js +25 -0
  10. package/dist/audio-stage.js.map +1 -0
  11. package/dist/beat-analyze.d.ts +22 -0
  12. package/dist/beat-analyze.d.ts.map +1 -0
  13. package/dist/beat-analyze.js +111 -0
  14. package/dist/beat-analyze.js.map +1 -0
  15. package/dist/beat-sync-types.d.ts +33 -0
  16. package/dist/beat-sync-types.d.ts.map +1 -0
  17. package/dist/beat-sync-types.js +4 -0
  18. package/dist/beat-sync-types.js.map +1 -0
  19. package/dist/bundle-assembler.d.ts +54 -0
  20. package/dist/bundle-assembler.d.ts.map +1 -0
  21. package/dist/bundle-assembler.js +376 -0
  22. package/dist/bundle-assembler.js.map +1 -0
  23. package/dist/bundle-durations.d.ts +5 -0
  24. package/dist/bundle-durations.d.ts.map +1 -0
  25. package/dist/bundle-durations.js +68 -0
  26. package/dist/bundle-durations.js.map +1 -0
  27. package/dist/bundle-storyboard.d.ts +11 -0
  28. package/dist/bundle-storyboard.d.ts.map +1 -0
  29. package/dist/bundle-storyboard.js +82 -0
  30. package/dist/bundle-storyboard.js.map +1 -0
  31. package/dist/captions.d.ts +25 -0
  32. package/dist/captions.d.ts.map +1 -0
  33. package/dist/captions.js +56 -0
  34. package/dist/captions.js.map +1 -0
  35. package/dist/cast-db.d.ts +61 -0
  36. package/dist/cast-db.d.ts.map +1 -0
  37. package/dist/cast-db.js +324 -0
  38. package/dist/cast-db.js.map +1 -0
  39. package/dist/cast-lora-train.d.ts +18 -0
  40. package/dist/cast-lora-train.d.ts.map +1 -0
  41. package/dist/cast-lora-train.js +215 -0
  42. package/dist/cast-lora-train.js.map +1 -0
  43. package/dist/cast-loras.d.ts +21 -0
  44. package/dist/cast-loras.d.ts.map +1 -0
  45. package/dist/cast-loras.js +69 -0
  46. package/dist/cast-loras.js.map +1 -0
  47. package/dist/clip-content-validate.d.ts +43 -0
  48. package/dist/clip-content-validate.d.ts.map +1 -0
  49. package/dist/clip-content-validate.js +115 -0
  50. package/dist/clip-content-validate.js.map +1 -0
  51. package/dist/clip-job-model.d.ts +42 -0
  52. package/dist/clip-job-model.d.ts.map +1 -0
  53. package/dist/clip-job-model.js +8 -0
  54. package/dist/clip-job-model.js.map +1 -0
  55. package/dist/clip-validate.d.ts +33 -0
  56. package/dist/clip-validate.d.ts.map +1 -0
  57. package/dist/clip-validate.js +247 -0
  58. package/dist/clip-validate.js.map +1 -0
  59. package/dist/d1-retry.d.ts +20 -0
  60. package/dist/d1-retry.d.ts.map +1 -0
  61. package/dist/d1-retry.js +67 -0
  62. package/dist/d1-retry.js.map +1 -0
  63. package/dist/db-env.d.ts +6 -0
  64. package/dist/db-env.d.ts.map +1 -0
  65. package/dist/db-env.js +2 -0
  66. package/dist/db-env.js.map +1 -0
  67. package/dist/dialogue-lines.d.ts +23 -0
  68. package/dist/dialogue-lines.d.ts.map +1 -0
  69. package/dist/dialogue-lines.js +84 -0
  70. package/dist/dialogue-lines.js.map +1 -0
  71. package/dist/film-advance-lease.d.ts +9 -0
  72. package/dist/film-advance-lease.d.ts.map +1 -0
  73. package/dist/film-advance-lease.js +19 -0
  74. package/dist/film-advance-lease.js.map +1 -0
  75. package/dist/film-model.d.ts +415 -0
  76. package/dist/film-model.d.ts.map +1 -0
  77. package/dist/film-model.js +541 -0
  78. package/dist/film-model.js.map +1 -0
  79. package/dist/film-orchestrator.d.ts +216 -0
  80. package/dist/film-orchestrator.d.ts.map +1 -0
  81. package/dist/film-orchestrator.js +2204 -0
  82. package/dist/film-orchestrator.js.map +1 -0
  83. package/dist/film-render-bridge.d.ts +26 -0
  84. package/dist/film-render-bridge.d.ts.map +1 -0
  85. package/dist/film-render-bridge.js +176 -0
  86. package/dist/film-render-bridge.js.map +1 -0
  87. package/dist/finish-hash.d.ts +10 -0
  88. package/dist/finish-hash.d.ts.map +1 -0
  89. package/dist/finish-hash.js +60 -0
  90. package/dist/finish-hash.js.map +1 -0
  91. package/dist/index.d.ts +48 -0
  92. package/dist/index.d.ts.map +1 -0
  93. package/dist/index.js +45 -0
  94. package/dist/index.js.map +1 -0
  95. package/dist/key-safety.d.ts +5 -0
  96. package/dist/key-safety.d.ts.map +1 -0
  97. package/dist/key-safety.js +23 -0
  98. package/dist/key-safety.js.map +1 -0
  99. package/dist/lora-bundle.d.ts +19 -0
  100. package/dist/lora-bundle.d.ts.map +1 -0
  101. package/dist/lora-bundle.js +75 -0
  102. package/dist/lora-bundle.js.map +1 -0
  103. package/dist/lora-keys.d.ts +2 -0
  104. package/dist/lora-keys.d.ts.map +1 -0
  105. package/dist/lora-keys.js +4 -0
  106. package/dist/lora-keys.js.map +1 -0
  107. package/dist/modules/conformance.d.ts +58 -0
  108. package/dist/modules/conformance.d.ts.map +1 -0
  109. package/dist/modules/conformance.js +369 -0
  110. package/dist/modules/conformance.js.map +1 -0
  111. package/dist/modules/manifest-validate.d.ts +5 -0
  112. package/dist/modules/manifest-validate.d.ts.map +1 -0
  113. package/dist/modules/manifest-validate.js +60 -0
  114. package/dist/modules/manifest-validate.js.map +1 -0
  115. package/dist/modules/registry.d.ts +184 -0
  116. package/dist/modules/registry.d.ts.map +1 -0
  117. package/dist/modules/registry.js +653 -0
  118. package/dist/modules/registry.js.map +1 -0
  119. package/dist/modules/render-pipeline.d.ts +38 -0
  120. package/dist/modules/render-pipeline.d.ts.map +1 -0
  121. package/dist/modules/render-pipeline.js +40 -0
  122. package/dist/modules/render-pipeline.js.map +1 -0
  123. package/dist/modules/types.d.ts +544 -0
  124. package/dist/modules/types.d.ts.map +1 -0
  125. package/dist/modules/types.js +57 -0
  126. package/dist/modules/types.js.map +1 -0
  127. package/dist/operator-config.d.ts +20 -0
  128. package/dist/operator-config.d.ts.map +1 -0
  129. package/dist/operator-config.js +85 -0
  130. package/dist/operator-config.js.map +1 -0
  131. package/dist/output-extract.d.ts +8 -0
  132. package/dist/output-extract.d.ts.map +1 -0
  133. package/dist/output-extract.js +129 -0
  134. package/dist/output-extract.js.map +1 -0
  135. package/dist/planner-prompt.d.ts +12 -0
  136. package/dist/planner-prompt.d.ts.map +1 -0
  137. package/dist/planner-prompt.js +337 -0
  138. package/dist/planner-prompt.js.map +1 -0
  139. package/dist/planner-yaml.d.ts +15 -0
  140. package/dist/planner-yaml.d.ts.map +1 -0
  141. package/dist/planner-yaml.js +157 -0
  142. package/dist/planner-yaml.js.map +1 -0
  143. package/dist/platform/fetcher.d.ts +3 -0
  144. package/dist/platform/fetcher.d.ts.map +1 -0
  145. package/dist/platform/fetcher.js +7 -0
  146. package/dist/platform/fetcher.js.map +1 -0
  147. package/dist/platform/index.d.ts +6 -0
  148. package/dist/platform/index.d.ts.map +1 -0
  149. package/dist/platform/index.js +5 -0
  150. package/dist/platform/index.js.map +1 -0
  151. package/dist/platform/object-store-r2.d.ts +26 -0
  152. package/dist/platform/object-store-r2.d.ts.map +1 -0
  153. package/dist/platform/object-store-r2.js +74 -0
  154. package/dist/platform/object-store-r2.js.map +1 -0
  155. package/dist/platform/orchestrator-context.d.ts +20 -0
  156. package/dist/platform/orchestrator-context.d.ts.map +1 -0
  157. package/dist/platform/orchestrator-context.js +28 -0
  158. package/dist/platform/orchestrator-context.js.map +1 -0
  159. package/dist/platform/r2-types.d.ts +41 -0
  160. package/dist/platform/r2-types.d.ts.map +1 -0
  161. package/dist/platform/r2-types.js +3 -0
  162. package/dist/platform/r2-types.js.map +1 -0
  163. package/dist/platform/types.d.ts +90 -0
  164. package/dist/platform/types.d.ts.map +1 -0
  165. package/dist/platform/types.js +23 -0
  166. package/dist/platform/types.js.map +1 -0
  167. package/dist/preflight.d.ts +57 -0
  168. package/dist/preflight.d.ts.map +1 -0
  169. package/dist/preflight.js +243 -0
  170. package/dist/preflight.js.map +1 -0
  171. package/dist/presign.d.ts +5 -0
  172. package/dist/presign.d.ts.map +1 -0
  173. package/dist/presign.js +24 -0
  174. package/dist/presign.js.map +1 -0
  175. package/dist/public-id.d.ts +8 -0
  176. package/dist/public-id.d.ts.map +1 -0
  177. package/dist/public-id.js +25 -0
  178. package/dist/public-id.js.map +1 -0
  179. package/dist/render-adopt.d.ts +3 -0
  180. package/dist/render-adopt.d.ts.map +1 -0
  181. package/dist/render-adopt.js +92 -0
  182. package/dist/render-adopt.js.map +1 -0
  183. package/dist/render-log.d.ts +25 -0
  184. package/dist/render-log.d.ts.map +1 -0
  185. package/dist/render-log.js +139 -0
  186. package/dist/render-log.js.map +1 -0
  187. package/dist/render-module-config.d.ts +28 -0
  188. package/dist/render-module-config.d.ts.map +1 -0
  189. package/dist/render-module-config.js +124 -0
  190. package/dist/render-module-config.js.map +1 -0
  191. package/dist/render-mux.d.ts +18 -0
  192. package/dist/render-mux.d.ts.map +1 -0
  193. package/dist/render-mux.js +69 -0
  194. package/dist/render-mux.js.map +1 -0
  195. package/dist/render-orchestrator.d.ts +71 -0
  196. package/dist/render-orchestrator.d.ts.map +1 -0
  197. package/dist/render-orchestrator.js +376 -0
  198. package/dist/render-orchestrator.js.map +1 -0
  199. package/dist/render-sweep.d.ts +5 -0
  200. package/dist/render-sweep.d.ts.map +1 -0
  201. package/dist/render-sweep.js +62 -0
  202. package/dist/render-sweep.js.map +1 -0
  203. package/dist/renders-db.d.ts +129 -0
  204. package/dist/renders-db.d.ts.map +1 -0
  205. package/dist/renders-db.js +752 -0
  206. package/dist/renders-db.js.map +1 -0
  207. package/dist/runpod-submit.d.ts +151 -0
  208. package/dist/runpod-submit.d.ts.map +1 -0
  209. package/dist/runpod-submit.js +469 -0
  210. package/dist/runpod-submit.js.map +1 -0
  211. package/dist/runpod-types.d.ts +2 -0
  212. package/dist/runpod-types.d.ts.map +1 -0
  213. package/dist/runpod-types.js +4 -0
  214. package/dist/runpod-types.js.map +1 -0
  215. package/dist/scatter-notify.d.ts +4 -0
  216. package/dist/scatter-notify.d.ts.map +1 -0
  217. package/dist/scatter-notify.js +43 -0
  218. package/dist/scatter-notify.js.map +1 -0
  219. package/dist/scatter-orchestrator-types.d.ts +56 -0
  220. package/dist/scatter-orchestrator-types.d.ts.map +1 -0
  221. package/dist/scatter-orchestrator-types.js +3 -0
  222. package/dist/scatter-orchestrator-types.js.map +1 -0
  223. package/dist/scatter-orchestrator.d.ts +70 -0
  224. package/dist/scatter-orchestrator.d.ts.map +1 -0
  225. package/dist/scatter-orchestrator.js +614 -0
  226. package/dist/scatter-orchestrator.js.map +1 -0
  227. package/dist/scatter.d.ts +35 -0
  228. package/dist/scatter.d.ts.map +1 -0
  229. package/dist/scatter.js +171 -0
  230. package/dist/scatter.js.map +1 -0
  231. package/dist/secret-store.d.ts +5 -0
  232. package/dist/secret-store.d.ts.map +1 -0
  233. package/dist/secret-store.js +19 -0
  234. package/dist/secret-store.js.map +1 -0
  235. package/dist/shot-durations-parse.d.ts +3 -0
  236. package/dist/shot-durations-parse.d.ts.map +1 -0
  237. package/dist/shot-durations-parse.js +43 -0
  238. package/dist/shot-durations-parse.js.map +1 -0
  239. package/dist/srt.d.ts +11 -0
  240. package/dist/srt.d.ts.map +1 -0
  241. package/dist/srt.js +54 -0
  242. package/dist/srt.js.map +1 -0
  243. package/dist/storyboard-ids.d.ts +2 -0
  244. package/dist/storyboard-ids.d.ts.map +1 -0
  245. package/dist/storyboard-ids.js +12 -0
  246. package/dist/storyboard-ids.js.map +1 -0
  247. package/dist/storyboard-projects-db.d.ts +31 -0
  248. package/dist/storyboard-projects-db.d.ts.map +1 -0
  249. package/dist/storyboard-projects-db.js +153 -0
  250. package/dist/storyboard-projects-db.js.map +1 -0
  251. package/dist/storyboard-validate.d.ts +72 -0
  252. package/dist/storyboard-validate.d.ts.map +1 -0
  253. package/dist/storyboard-validate.js +595 -0
  254. package/dist/storyboard-validate.js.map +1 -0
  255. package/dist/structured-events.d.ts +6 -0
  256. package/dist/structured-events.d.ts.map +1 -0
  257. package/dist/structured-events.js +15 -0
  258. package/dist/structured-events.js.map +1 -0
  259. package/dist/tar.d.ts +9 -0
  260. package/dist/tar.d.ts.map +1 -0
  261. package/dist/tar.js +151 -0
  262. package/dist/tar.js.map +1 -0
  263. package/dist/voices.d.ts +12 -0
  264. package/dist/voices.d.ts.map +1 -0
  265. package/dist/voices.js +37 -0
  266. package/dist/voices.js.map +1 -0
  267. 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