@nodetool-ai/video-nodes 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +58 -0
  2. package/dist/.nodetool-build-stamp +1 -0
  3. package/dist/index.d.ts +9 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +11 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/nodes/ffmpeg-helpers.d.ts +77 -0
  8. package/dist/nodes/ffmpeg-helpers.d.ts.map +1 -0
  9. package/dist/nodes/ffmpeg-helpers.js +220 -0
  10. package/dist/nodes/ffmpeg-helpers.js.map +1 -0
  11. package/dist/nodes/lib-video-download.d.ts +29 -0
  12. package/dist/nodes/lib-video-download.d.ts.map +1 -0
  13. package/dist/nodes/lib-video-download.js +353 -0
  14. package/dist/nodes/lib-video-download.js.map +1 -0
  15. package/dist/nodes/model3d/analysis.d.ts +16 -0
  16. package/dist/nodes/model3d/analysis.d.ts.map +1 -0
  17. package/dist/nodes/model3d/analysis.js +38 -0
  18. package/dist/nodes/model3d/analysis.js.map +1 -0
  19. package/dist/nodes/model3d/base.d.ts +28 -0
  20. package/dist/nodes/model3d/base.d.ts.map +1 -0
  21. package/dist/nodes/model3d/base.js +20 -0
  22. package/dist/nodes/model3d/base.js.map +1 -0
  23. package/dist/nodes/model3d/boolean-ops.d.ts +2 -0
  24. package/dist/nodes/model3d/boolean-ops.d.ts.map +1 -0
  25. package/dist/nodes/model3d/boolean-ops.js +172 -0
  26. package/dist/nodes/model3d/boolean-ops.js.map +1 -0
  27. package/dist/nodes/model3d/cleanup.d.ts +56 -0
  28. package/dist/nodes/model3d/cleanup.d.ts.map +1 -0
  29. package/dist/nodes/model3d/cleanup.js +144 -0
  30. package/dist/nodes/model3d/cleanup.js.map +1 -0
  31. package/dist/nodes/model3d/convert.d.ts +27 -0
  32. package/dist/nodes/model3d/convert.d.ts.map +1 -0
  33. package/dist/nodes/model3d/convert.js +66 -0
  34. package/dist/nodes/model3d/convert.js.map +1 -0
  35. package/dist/nodes/model3d/defaults.d.ts +34 -0
  36. package/dist/nodes/model3d/defaults.d.ts.map +1 -0
  37. package/dist/nodes/model3d/defaults.js +28 -0
  38. package/dist/nodes/model3d/defaults.js.map +1 -0
  39. package/dist/nodes/model3d/document-ops.d.ts +4 -0
  40. package/dist/nodes/model3d/document-ops.d.ts.map +1 -0
  41. package/dist/nodes/model3d/document-ops.js +40 -0
  42. package/dist/nodes/model3d/document-ops.js.map +1 -0
  43. package/dist/nodes/model3d/generation.d.ts +56 -0
  44. package/dist/nodes/model3d/generation.d.ts.map +1 -0
  45. package/dist/nodes/model3d/generation.js +203 -0
  46. package/dist/nodes/model3d/generation.js.map +1 -0
  47. package/dist/nodes/model3d/glb.d.ts +29 -0
  48. package/dist/nodes/model3d/glb.d.ts.map +1 -0
  49. package/dist/nodes/model3d/glb.js +255 -0
  50. package/dist/nodes/model3d/glb.js.map +1 -0
  51. package/dist/nodes/model3d/index.d.ts +22 -0
  52. package/dist/nodes/model3d/index.d.ts.map +1 -0
  53. package/dist/nodes/model3d/index.js +41 -0
  54. package/dist/nodes/model3d/index.js.map +1 -0
  55. package/dist/nodes/model3d/io.d.ts +81 -0
  56. package/dist/nodes/model3d/io.d.ts.map +1 -0
  57. package/dist/nodes/model3d/io.js +162 -0
  58. package/dist/nodes/model3d/io.js.map +1 -0
  59. package/dist/nodes/model3d/mesh-ops.d.ts +40 -0
  60. package/dist/nodes/model3d/mesh-ops.d.ts.map +1 -0
  61. package/dist/nodes/model3d/mesh-ops.js +775 -0
  62. package/dist/nodes/model3d/mesh-ops.js.map +1 -0
  63. package/dist/nodes/model3d/nodes.d.ts +2 -0
  64. package/dist/nodes/model3d/nodes.d.ts.map +1 -0
  65. package/dist/nodes/model3d/nodes.js +2 -0
  66. package/dist/nodes/model3d/nodes.js.map +1 -0
  67. package/dist/nodes/model3d/ops.d.ts +44 -0
  68. package/dist/nodes/model3d/ops.d.ts.map +1 -0
  69. package/dist/nodes/model3d/ops.js +142 -0
  70. package/dist/nodes/model3d/ops.js.map +1 -0
  71. package/dist/nodes/model3d/render.d.ts +51 -0
  72. package/dist/nodes/model3d/render.d.ts.map +1 -0
  73. package/dist/nodes/model3d/render.js +124 -0
  74. package/dist/nodes/model3d/render.js.map +1 -0
  75. package/dist/nodes/model3d/render3d-core.d.ts +177 -0
  76. package/dist/nodes/model3d/render3d-core.d.ts.map +1 -0
  77. package/dist/nodes/model3d/render3d-core.js +366 -0
  78. package/dist/nodes/model3d/render3d-core.js.map +1 -0
  79. package/dist/nodes/model3d/render3d-headless.d.ts +75 -0
  80. package/dist/nodes/model3d/render3d-headless.d.ts.map +1 -0
  81. package/dist/nodes/model3d/render3d-headless.js +190 -0
  82. package/dist/nodes/model3d/render3d-headless.js.map +1 -0
  83. package/dist/nodes/model3d/render3d-page.d.ts +16 -0
  84. package/dist/nodes/model3d/render3d-page.d.ts.map +1 -0
  85. package/dist/nodes/model3d/render3d-page.js +51 -0
  86. package/dist/nodes/model3d/render3d-page.js.map +1 -0
  87. package/dist/nodes/model3d/transforms.d.ts +56 -0
  88. package/dist/nodes/model3d/transforms.d.ts.map +1 -0
  89. package/dist/nodes/model3d/transforms.js +148 -0
  90. package/dist/nodes/model3d/transforms.js.map +1 -0
  91. package/dist/nodes/model3d/types.d.ts +82 -0
  92. package/dist/nodes/model3d/types.d.ts.map +1 -0
  93. package/dist/nodes/model3d/types.js +2 -0
  94. package/dist/nodes/model3d/types.js.map +1 -0
  95. package/dist/nodes/model3d/utils.d.ts +46 -0
  96. package/dist/nodes/model3d/utils.d.ts.map +1 -0
  97. package/dist/nodes/model3d/utils.js +190 -0
  98. package/dist/nodes/model3d/utils.js.map +1 -0
  99. package/dist/nodes/model3d.d.ts +2 -0
  100. package/dist/nodes/model3d.d.ts.map +1 -0
  101. package/dist/nodes/model3d.js +2 -0
  102. package/dist/nodes/model3d.js.map +1 -0
  103. package/dist/nodes/placeholder-values.d.ts +10 -0
  104. package/dist/nodes/placeholder-values.d.ts.map +1 -0
  105. package/dist/nodes/placeholder-values.js +19 -0
  106. package/dist/nodes/placeholder-values.js.map +1 -0
  107. package/dist/nodes/script.d.ts +161 -0
  108. package/dist/nodes/script.d.ts.map +1 -0
  109. package/dist/nodes/script.js +673 -0
  110. package/dist/nodes/script.js.map +1 -0
  111. package/dist/nodes/storyboard.d.ts +269 -0
  112. package/dist/nodes/storyboard.d.ts.map +1 -0
  113. package/dist/nodes/storyboard.js +1017 -0
  114. package/dist/nodes/storyboard.js.map +1 -0
  115. package/dist/nodes/timeline/assetFiles.d.ts +19 -0
  116. package/dist/nodes/timeline/assetFiles.d.ts.map +1 -0
  117. package/dist/nodes/timeline/assetFiles.js +52 -0
  118. package/dist/nodes/timeline/assetFiles.js.map +1 -0
  119. package/dist/nodes/timeline/compositeRender.d.ts +60 -0
  120. package/dist/nodes/timeline/compositeRender.d.ts.map +1 -0
  121. package/dist/nodes/timeline/compositeRender.js +508 -0
  122. package/dist/nodes/timeline/compositeRender.js.map +1 -0
  123. package/dist/nodes/timeline/contactSheet.d.ts +43 -0
  124. package/dist/nodes/timeline/contactSheet.d.ts.map +1 -0
  125. package/dist/nodes/timeline/contactSheet.js +78 -0
  126. package/dist/nodes/timeline/contactSheet.js.map +1 -0
  127. package/dist/nodes/timeline/outputFormats.d.ts +69 -0
  128. package/dist/nodes/timeline/outputFormats.d.ts.map +1 -0
  129. package/dist/nodes/timeline/outputFormats.js +173 -0
  130. package/dist/nodes/timeline/outputFormats.js.map +1 -0
  131. package/dist/nodes/timeline/pngSequence.d.ts +44 -0
  132. package/dist/nodes/timeline/pngSequence.d.ts.map +1 -0
  133. package/dist/nodes/timeline/pngSequence.js +172 -0
  134. package/dist/nodes/timeline/pngSequence.js.map +1 -0
  135. package/dist/nodes/timeline/rasterizers.d.ts +47 -0
  136. package/dist/nodes/timeline/rasterizers.d.ts.map +1 -0
  137. package/dist/nodes/timeline/rasterizers.js +103 -0
  138. package/dist/nodes/timeline/rasterizers.js.map +1 -0
  139. package/dist/nodes/timeline/rawFrames.d.ts +149 -0
  140. package/dist/nodes/timeline/rawFrames.d.ts.map +1 -0
  141. package/dist/nodes/timeline/rawFrames.js +598 -0
  142. package/dist/nodes/timeline/rawFrames.js.map +1 -0
  143. package/dist/nodes/timeline.d.ts +206 -0
  144. package/dist/nodes/timeline.d.ts.map +1 -0
  145. package/dist/nodes/timeline.js +1395 -0
  146. package/dist/nodes/timeline.js.map +1 -0
  147. package/dist/nodes/video.d.ts +705 -0
  148. package/dist/nodes/video.d.ts.map +1 -0
  149. package/dist/nodes/video.js +2672 -0
  150. package/dist/nodes/video.js.map +1 -0
  151. package/dist/render3d-page.js +4118 -0
  152. package/package.json +95 -0
@@ -0,0 +1,1017 @@
1
+ /**
2
+ * Storyboard nodes — a persisted board as a graph value.
3
+ *
4
+ * A director approves one board in the editor; a graph then re-runs it for a
5
+ * new product, a new actor, a new SKU. These nodes are that loop: read a board
6
+ * (`LoadStoryboard`, `StoryboardShots`), derive a copy for a new cast
7
+ * (`RecastStoryboard`), spend on its frames (`RenderStills`, `RenderClips`),
8
+ * and cut it (`AssembleTimeline`). Design:
9
+ * [docs/graph-resources/design.md](../../../../docs/graph-resources/design.md) §4.2.
10
+ *
11
+ * The derivation, the render plan and the render path itself come from
12
+ * `@nodetool-ai/storyboard`, the same functions the agent capabilities and the
13
+ * board editor call, so a board rendered from a graph matches one rendered from
14
+ * chat.
15
+ *
16
+ * **Write contract.** A `StoryboardRef` is read-only unless it carries
17
+ * `writable: true`, and only a node that created or derived the row in this run
18
+ * sets it. `RenderStills`, `RenderClips` and `AssembleTimeline` refuse a ref
19
+ * without the flag before they read anything, so a picker wired straight into a
20
+ * render node fails before any spend rather than after it.
21
+ */
22
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
23
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
24
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
25
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
26
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
27
+ };
28
+ import { BaseNode, prop } from "@nodetool-ai/node-sdk";
29
+ import { isObjectLike, isString } from "@nodetool-ai/node-sdk";
30
+ import { loadMediaRefBytes, probeVideoDurationSeconds, resolveEntities } from "@nodetool-ai/runtime";
31
+ import { shotRenderMode, requiredVideoTasksForShots } from "@nodetool-ai/protocol";
32
+ import { planShotRenders, recastStoryboard, renderShots } from "@nodetool-ai/storyboard";
33
+ import { applyMeasuredShotClipDurations, buildLinkedTimeline, buildStoryboardTimeline, cloneTimelineForBoard, foreignTimelineParts, frameSizeForAspect, makeSequence, refillShotClips, scriptLinesById } from "@nodetool-ai/timeline";
34
+ import { tagAsServer } from "@nodetool-ai/nodes-utils";
35
+ const storyboardRefDefault = {
36
+ type: "storyboard",
37
+ id: null,
38
+ data: null
39
+ };
40
+ /** An unset model selection: the picker's "nothing chosen" value. */
41
+ const emptyModel = (type) => ({
42
+ type,
43
+ provider: "empty",
44
+ id: "",
45
+ name: "",
46
+ path: null,
47
+ supported_tasks: []
48
+ });
49
+ /**
50
+ * `RecastStoryboard` finds the copy a previous run made by (project,
51
+ * `templateId`, `recastKey`). A host that wires no `listStoryboards` cannot
52
+ * answer that, and `reuse_existing` says so rather than quietly making a
53
+ * second row — and a second bill — on every run.
54
+ */
55
+ const isRecord = (value) => isObjectLike(value);
56
+ /** A board row, when a host's answer carries what the node reads off one. */
57
+ const asBoardRow = (value) => isRecord(value) && isString(value["id"]) && isRecord(value["document"])
58
+ ? value
59
+ : null;
60
+ /** The board rows a host can list, or null when it cannot list any. */
61
+ async function listBoards(context, projectId) {
62
+ if (!context.hasModelInterface("listStoryboards"))
63
+ return null;
64
+ const rows = await context.listStoryboards({ projectId });
65
+ return rows
66
+ .map(asBoardRow)
67
+ .filter((row) => row !== null);
68
+ }
69
+ // ---------------------------------------------------------------------------
70
+ // Board access
71
+ // ---------------------------------------------------------------------------
72
+ const requireContext = (context, node) => {
73
+ if (!context) {
74
+ throw new Error(`${node} requires a processing context`);
75
+ }
76
+ return context;
77
+ };
78
+ /**
79
+ * The board a ref points at.
80
+ *
81
+ * An inline `data` document is read without touching the database — the shape
82
+ * the debug harness and the node tests use. Anything that writes needs the row,
83
+ * so it calls {@link loadBoardRow} instead.
84
+ */
85
+ async function readBoard(ref, context, node) {
86
+ const boardRef = (ref ?? {});
87
+ if (!boardRef.id) {
88
+ if (isRecord(boardRef.data)) {
89
+ return {
90
+ id: "",
91
+ projectId: "default",
92
+ name: "",
93
+ document: boardRef.data,
94
+ updatedAt: ""
95
+ };
96
+ }
97
+ throw new Error(`${node}: storyboard input is empty — connect a Constant Storyboard node and pick a board`);
98
+ }
99
+ const ctx = requireContext(context, node);
100
+ const row = (await ctx.getStoryboard(boardRef.id));
101
+ if (!row) {
102
+ throw new Error(`${node}: storyboard not found: ${boardRef.id}`);
103
+ }
104
+ return row;
105
+ }
106
+ /** The persisted row a write needs; an inline document is not one. */
107
+ async function loadBoardRow(ref, context, node) {
108
+ const ctx = requireContext(context, node);
109
+ const row = await readBoard(ref, ctx, node);
110
+ if (!row.id) {
111
+ throw new Error(`${node}: an inline storyboard document has no row to write to. Pick a persisted board.`);
112
+ }
113
+ return { row, context: ctx };
114
+ }
115
+ /**
116
+ * Refuse a board this run did not derive.
117
+ *
118
+ * The question is the flag on the ref, never whether a derived row happens to
119
+ * exist: a first run has no copies yet, and a copy picked from the library is
120
+ * as approved as the template it came from. Checked before the board is read,
121
+ * so a miswire costs nothing.
122
+ */
123
+ function requireWritable(ref, allowWrites, node) {
124
+ if (allowWrites)
125
+ return;
126
+ const boardRef = (ref ?? {});
127
+ if (boardRef.writable === true)
128
+ return;
129
+ throw new Error(`${node} will not write a storyboard this run did not derive. Feed it the board from RecastStoryboard, or turn on allow_writes to render the board you picked.`);
130
+ }
131
+ /** The ref this run may keep writing to. */
132
+ const writableRef = (id) => ({
133
+ type: "storyboard",
134
+ id,
135
+ writable: true
136
+ });
137
+ /** The board's cast, read from the library. */
138
+ async function loadBoardEntities(context, doc) {
139
+ const ids = doc.entityIds ?? [];
140
+ if (ids.length === 0 || !context.hasModelInterface("getEntity"))
141
+ return [];
142
+ const loaded = await Promise.all(ids.map((id) => context.getEntity(id)));
143
+ return loaded.filter((entity) => !!entity);
144
+ }
145
+ /**
146
+ * The model a render uses: the node's override when set, else the board's.
147
+ *
148
+ * Unset is an error, never a default — a board rendered on a model nobody chose
149
+ * is a bill nobody agreed to.
150
+ */
151
+ function resolveModel(boardModel, override, kind, capability) {
152
+ const provider = override?.provider && override.provider !== "empty"
153
+ ? override.provider
154
+ : isString(boardModel?.["provider"])
155
+ ? boardModel["provider"]
156
+ : "";
157
+ const model = override?.id && override.id !== ""
158
+ ? override.id
159
+ : isString(boardModel?.["id"])
160
+ ? boardModel["id"]
161
+ : "";
162
+ if (!provider || !model) {
163
+ throw new Error(`No ${kind} model is set on this storyboard. Set one on the board, or wire this node's ${kind === "still" ? "image_model" : "video_model"} input (find_model with capability=${capability} lists the choices).`);
164
+ }
165
+ return {
166
+ provider,
167
+ model,
168
+ supportedTasks: override?.id && override.id !== ""
169
+ ? override.supported_tasks
170
+ : Array.isArray(boardModel?.["supported_tasks"])
171
+ ? boardModel["supported_tasks"].filter(isString)
172
+ : undefined
173
+ };
174
+ }
175
+ /**
176
+ * The render path's host seam (`renderShots` in `@nodetool-ai/storyboard`).
177
+ *
178
+ * The same four operations the agent capability implements, over the context's
179
+ * model interfaces instead of the model classes — one render path, whether the
180
+ * board is rendered from a graph or from chat.
181
+ *
182
+ * `videoDurationSeconds` is absent here because render-time MP4 inspection is
183
+ * owned by the agent package. AssembleTimeline probes the persisted media
184
+ * independently, so graph-rendered clips still use their delivered length.
185
+ */
186
+ function renderHost(context) {
187
+ return {
188
+ runGeneration: async (request) => {
189
+ const result = await context.runGeneration({
190
+ id: request.id,
191
+ provider: request.provider,
192
+ capability: request.capability,
193
+ model: request.model,
194
+ params: request.params,
195
+ origin: { surface: "workflow" },
196
+ persist: request.persist
197
+ });
198
+ return { output: result.output, assets: result.assets };
199
+ },
200
+ getStoryboard: async (id) => {
201
+ const row = (await context.getStoryboard(id));
202
+ return row ? { document: row.document, updatedAt: row.updatedAt } : null;
203
+ },
204
+ updateStoryboard: async ({ id, document, baseUpdatedAt }) => {
205
+ const saved = (await context.updateStoryboard(id, {
206
+ document,
207
+ baseUpdatedAt
208
+ }));
209
+ return saved ? { document, updatedAt: saved.updatedAt } : null;
210
+ },
211
+ loadMedia: async (ref) => loadMediaRefBytes(ref, context)
212
+ };
213
+ }
214
+ // ---------------------------------------------------------------------------
215
+ // Shot selection
216
+ // ---------------------------------------------------------------------------
217
+ /** `targets` as the plan reads it: undefined means every shot. */
218
+ const asTargets = (value) => {
219
+ if (!Array.isArray(value))
220
+ return undefined;
221
+ const targets = value.map((entry) => String(entry).trim()).filter(Boolean);
222
+ return targets.length > 0 ? targets : undefined;
223
+ };
224
+ const orderedShots = (doc) => [...doc.shots].sort((a, b) => a.index - b.index);
225
+ const imageRefOf = (outcome) => ({
226
+ type: "image",
227
+ uri: outcome.assetUri ?? "",
228
+ asset_id: outcome.assetId ?? null
229
+ });
230
+ const videoRefOf = (outcome) => ({
231
+ type: "video",
232
+ uri: outcome.assetUri ?? "",
233
+ asset_id: outcome.assetId ?? null
234
+ });
235
+ /** The shots that already carry a take of this kind. */
236
+ const shotsWithVersion = (doc, kind) => new Set(doc.shots
237
+ .filter((shot) => (kind === "keyframe" ? !!shot.keyframe : !!shot.clip))
238
+ .map((shot) => shot.id));
239
+ /**
240
+ * Drop the plans a gate refuses, keeping the shot ids each gate skipped.
241
+ *
242
+ * `onlyStale` skips a shot the board would render exactly as it already did —
243
+ * which is a question only a shot that *has* a take can answer, so `rendered`
244
+ * bounds it. A never-rendered shot reads `fresh` on the plan (nothing recorded
245
+ * is nothing to be out of date with) and must still be rendered.
246
+ *
247
+ * `max_shots` truncates rather than failing: the cap is a spend gate, and a
248
+ * board bigger than the cap renders its first N and says which it left, rather
249
+ * than refusing the run (design §1, P4).
250
+ */
251
+ function applyGates(plans, gates) {
252
+ const skipped = [];
253
+ let kept = plans;
254
+ const refused = gates.skipShotIds;
255
+ if (refused && refused.size > 0) {
256
+ skipped.push(...kept.filter((plan) => refused.has(plan.shotId)).map((plan) => plan.shotId));
257
+ kept = kept.filter((plan) => !refused.has(plan.shotId));
258
+ }
259
+ if (gates.onlyStale) {
260
+ const current = (plan) => plan.fresh && gates.rendered.has(plan.shotId);
261
+ skipped.push(...kept.filter(current).map((plan) => plan.shotId));
262
+ kept = kept.filter((plan) => !current(plan));
263
+ }
264
+ const limit = Math.max(0, Math.floor(gates.maxShots));
265
+ if (kept.length > limit) {
266
+ skipped.push(...kept.slice(limit).map((plan) => plan.shotId));
267
+ kept = kept.slice(0, limit);
268
+ }
269
+ return { plans: kept, skipped };
270
+ }
271
+ const clampConcurrency = (value) => {
272
+ const parsed = Math.floor(Number(value));
273
+ if (!Number.isFinite(parsed) || parsed < 1)
274
+ return 1;
275
+ return Math.min(parsed, 8);
276
+ };
277
+ async function mapWithConcurrency(items, limit, task) {
278
+ const results = new Array(items.length);
279
+ let next = 0;
280
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
281
+ for (;;) {
282
+ const index = next++;
283
+ if (index >= items.length)
284
+ return;
285
+ results[index] = await task(items[index]);
286
+ }
287
+ });
288
+ await Promise.all(workers);
289
+ return results;
290
+ }
291
+ export class LoadStoryboardNode extends BaseNode {
292
+ static nodeType = "nodetool.storyboard.LoadStoryboard";
293
+ static title = "Load Storyboard";
294
+ static description = "Read a persisted storyboard's shots, cast and settings.\n storyboard, board, shots, entities, load\n\n Use cases:\n - Feed a board's shots into a batch\n - Read the cast a board was directed with\n - Branch a workflow on a board's shot count";
295
+ static metadataOutputTypes = {
296
+ storyboard: "storyboard",
297
+ shots: "list[dict]",
298
+ entities: "list[entity]",
299
+ style: "str",
300
+ aspect_ratio: "str",
301
+ name: "str",
302
+ image_model: "image_model",
303
+ video_model: "video_model",
304
+ shot_count: "int"
305
+ };
306
+ static inlineFields = ["storyboard"];
307
+ static inputFields = ["storyboard"];
308
+ async process(context) {
309
+ const row = await readBoard(this.storyboard, context, "LoadStoryboard");
310
+ const doc = row.document;
311
+ const shots = orderedShots(doc);
312
+ const entities = context ? await loadBoardEntities(context, doc) : [];
313
+ // The ref passes through as it arrived: reading a board never earns the
314
+ // right to write it (write contract, design §4.2).
315
+ return {
316
+ storyboard: { type: "storyboard", id: row.id || null },
317
+ shots,
318
+ entities,
319
+ style: doc.style ?? "",
320
+ aspect_ratio: doc.aspectRatio || "16:9",
321
+ name: row.name,
322
+ image_model: doc.imageModel ?? null,
323
+ video_model: doc.videoModel ?? null,
324
+ shot_count: shots.length
325
+ };
326
+ }
327
+ }
328
+ __decorate([
329
+ prop({
330
+ type: "storyboard",
331
+ default: storyboardRefDefault,
332
+ title: "Storyboard",
333
+ description: "The board to read."
334
+ })
335
+ ], LoadStoryboardNode.prototype, "storyboard", void 0);
336
+ export class StoryboardShotsNode extends BaseNode {
337
+ static nodeType = "nodetool.storyboard.StoryboardShots";
338
+ static title = "Storyboard Shots";
339
+ static description = "Fan a persisted storyboard out into one message per shot, with its rendered still and clip.\n storyboard, shots, iterate, keyframe, clip\n\n Use cases:\n - Driving a per-shot pipeline from an approved board\n - Collecting a board's stills or clips downstream\n - Iterating over shots without re-directing them";
340
+ static metadataOutputTypes = {
341
+ shot: "dict",
342
+ index: "int",
343
+ slug: "str",
344
+ keyframe: "image",
345
+ clip: "video",
346
+ output: "list[dict]"
347
+ };
348
+ static inlineFields = ["storyboard"];
349
+ static inputFields = ["storyboard"];
350
+ // Emit per-shot output_updates even when the handles are wired onward, so
351
+ // the node shows shots as they stream regardless of downstream connections.
352
+ static alwaysEmitOutputUpdates = true;
353
+ static inputMode = "buffered";
354
+ static outputCorrelation = {
355
+ shot: { kind: "iteration", source: "__execution__", group: "items" },
356
+ index: { kind: "iteration", source: "__execution__", group: "items" },
357
+ slug: { kind: "iteration", source: "__execution__", group: "items" },
358
+ keyframe: { kind: "iteration", source: "__execution__", group: "items" },
359
+ clip: { kind: "iteration", source: "__execution__", group: "items" },
360
+ output: { kind: "single", source: "__execution__" }
361
+ };
362
+ async process(context) {
363
+ const shots = [];
364
+ for await (const chunk of this.genProcess(context)) {
365
+ if (chunk.shot)
366
+ shots.push(chunk.shot);
367
+ }
368
+ return { output: shots };
369
+ }
370
+ async *genProcess(context) {
371
+ const row = await readBoard(this.storyboard, context, "StoryboardShots");
372
+ const shots = orderedShots(row.document);
373
+ for (const shot of shots) {
374
+ yield {
375
+ shot,
376
+ index: shot.index,
377
+ slug: shot.slug ?? "",
378
+ keyframe: shot.keyframe
379
+ ? {
380
+ type: "image",
381
+ uri: shot.keyframe.uri ?? "",
382
+ asset_id: shot.keyframe.asset_id ?? null
383
+ }
384
+ : null,
385
+ clip: shot.clip
386
+ ? {
387
+ type: "video",
388
+ uri: shot.clip.uri ?? "",
389
+ asset_id: shot.clip.asset_id ?? null
390
+ }
391
+ : null
392
+ };
393
+ }
394
+ yield {
395
+ shot: null,
396
+ index: null,
397
+ slug: null,
398
+ keyframe: null,
399
+ clip: null,
400
+ output: shots
401
+ };
402
+ }
403
+ }
404
+ __decorate([
405
+ prop({
406
+ type: "storyboard",
407
+ default: storyboardRefDefault,
408
+ title: "Storyboard",
409
+ description: "The board whose shots to stream."
410
+ })
411
+ ], StoryboardShotsNode.prototype, "storyboard", void 0);
412
+ export class RecastStoryboardNode extends BaseNode {
413
+ static nodeType = "nodetool.storyboard.RecastStoryboard";
414
+ static title = "Recast Storyboard";
415
+ static description = "Copy an approved storyboard onto a new cast, keeping every frame whose prompt did not change.\n storyboard, recast, variant, batch, entities\n\n Use cases:\n - Re-running an approved board for another product\n - Swapping the actor or location in a template\n - Producing one variant per SKU without re-directing";
416
+ static metadataOutputTypes = {
417
+ storyboard: "storyboard",
418
+ invalidated: "list[str]",
419
+ kept: "list[str]"
420
+ };
421
+ static inlineFields = ["storyboard"];
422
+ static inputFields = ["storyboard", "cast"];
423
+ async process(context) {
424
+ const { row, context: ctx } = await loadBoardRow(this.storyboard, context, "RecastStoryboard");
425
+ const doc = row.document;
426
+ const entities = await resolveEntities(this.cast, ctx);
427
+ if (entities.length === 0) {
428
+ throw new Error("RecastStoryboard: cast is empty — connect the entities that replace the template's.");
429
+ }
430
+ const replaces = Array.isArray(this.replaces) ? this.replaces : [];
431
+ const cast = entities.map((entity, index) => {
432
+ const target = String(replaces[index] ?? "").trim();
433
+ return target ? { entity, replaces: target } : { entity };
434
+ });
435
+ const boardEntities = await loadBoardEntities(ctx, doc);
436
+ // The mapping this cast resolves to. `recastKey` depends on which board
437
+ // entity each cast member ended up replacing, which only the derivation
438
+ // works out — so the copy is derived once to learn the key, and again
439
+ // against the copy that key finds. Both passes are pure.
440
+ const probe = recastStoryboard({
441
+ document: doc,
442
+ sourceId: row.id,
443
+ boardEntities,
444
+ cast
445
+ });
446
+ const existing = this.reuse_existing
447
+ ? await this.findExisting(ctx, row, probe.recastKey)
448
+ : null;
449
+ const result = existing
450
+ ? recastStoryboard({
451
+ document: doc,
452
+ sourceId: row.id,
453
+ boardEntities,
454
+ cast,
455
+ existing: existing.document
456
+ })
457
+ : probe;
458
+ const name = this.copyName(row, result.substitutions.map((s) => s.to));
459
+ const saved = existing
460
+ ? (await ctx.updateStoryboard(existing.id, {
461
+ document: result.document
462
+ }))
463
+ : (await ctx.createStoryboard({
464
+ name,
465
+ projectId: row.projectId,
466
+ document: result.document
467
+ }));
468
+ if (!saved) {
469
+ throw new Error(`RecastStoryboard: could not save the copy of ${row.name || row.id}.`);
470
+ }
471
+ return {
472
+ storyboard: writableRef(saved.id),
473
+ invalidated: result.invalidatedShotIds,
474
+ kept: result.keptShotIds
475
+ };
476
+ }
477
+ /**
478
+ * The copy this mapping made last time, when the host can look one up.
479
+ *
480
+ * The scoped lookup asks for that one row; the listing fallback scans what a
481
+ * host that wires no scoped lookup can answer with, which is a window of the
482
+ * most recently updated boards — enough for a handful of variants, not for a
483
+ * catalog whose copies fall out of it.
484
+ */
485
+ async findExisting(context, row, recastKey) {
486
+ if (context.hasModelInterface("findRecastStoryboard")) {
487
+ return asBoardRow(await context.findRecastStoryboard({
488
+ projectId: row.projectId,
489
+ templateId: row.id,
490
+ recastKey
491
+ }));
492
+ }
493
+ const boards = await listBoards(context, row.projectId);
494
+ if (boards === null) {
495
+ throw new Error("RecastStoryboard: this host cannot look up the copy a previous run made, so reuse_existing would create a second board (and a second bill) every run. Turn reuse_existing off to make a fresh copy each time.");
496
+ }
497
+ return (boards.find((board) => board.id !== row.id &&
498
+ board.document.templateId === row.id &&
499
+ board.document.recastKey === recastKey) ?? null);
500
+ }
501
+ copyName(row, cast) {
502
+ const given = String(this.name ?? "").trim();
503
+ if (given)
504
+ return given;
505
+ const names = cast.map((entity) => entity.name).filter(Boolean);
506
+ const base = row.name || "Storyboard";
507
+ return names.length > 0 ? `${base} — ${names.join(", ")}` : `${base} (recast)`;
508
+ }
509
+ }
510
+ __decorate([
511
+ prop({
512
+ type: "storyboard",
513
+ default: storyboardRefDefault,
514
+ title: "Storyboard",
515
+ description: "The template board to copy. It is never written."
516
+ })
517
+ ], RecastStoryboardNode.prototype, "storyboard", void 0);
518
+ __decorate([
519
+ prop({
520
+ type: "list[entity]",
521
+ default: [],
522
+ title: "Cast",
523
+ description: "Entities to put in the copy. Each replaces the board entity named in `replaces`, or the board's single entity of the same kind."
524
+ })
525
+ ], RecastStoryboardNode.prototype, "cast", void 0);
526
+ __decorate([
527
+ prop({
528
+ type: "list[str]",
529
+ default: [],
530
+ title: "Replaces",
531
+ description: "Board entity id or name each cast member stands in for, in cast order. Leave an entry empty to infer it from the entity's kind."
532
+ })
533
+ ], RecastStoryboardNode.prototype, "replaces", void 0);
534
+ __decorate([
535
+ prop({
536
+ type: "str",
537
+ default: "",
538
+ title: "Name",
539
+ description: "Name for the copy. Defaults to the template's name plus the new cast."
540
+ })
541
+ ], RecastStoryboardNode.prototype, "name", void 0);
542
+ __decorate([
543
+ prop({
544
+ type: "bool",
545
+ default: true,
546
+ title: "Reuse Existing",
547
+ description: "Re-derive the copy this mapping made last time instead of creating a second one, so a re-run pays only for what changed."
548
+ })
549
+ ], RecastStoryboardNode.prototype, "reuse_existing", void 0);
550
+ export class RenderStillsNode extends BaseNode {
551
+ static nodeType = "nodetool.storyboard.RenderStills";
552
+ static title = "Render Stills";
553
+ static description = "Render the keyframe of every stale shot on a storyboard and save it onto the board.\n storyboard, keyframe, still, render, batch\n\n Use cases:\n - Filling a recast board's frames before the clips\n - Re-rendering only the shots a template edit changed\n - Stopping a batch at the stills to review the spend";
554
+ static metadataOutputTypes = {
555
+ storyboard: "storyboard",
556
+ keyframes: "list[image]",
557
+ rendered: "list[str]",
558
+ skipped: "list[str]",
559
+ failed: "list[str]"
560
+ };
561
+ static inlineFields = ["storyboard"];
562
+ static inputFields = ["storyboard", "targets"];
563
+ async process(context) {
564
+ requireWritable(this.storyboard, this.allow_writes, "RenderStills");
565
+ const { row, context: ctx } = await loadBoardRow(this.storyboard, context, "RenderStills");
566
+ const doc = row.document;
567
+ const model = resolveModel(doc.imageModel, this.image_model, "still", "text_to_image");
568
+ const entities = await loadBoardEntities(ctx, doc);
569
+ const targets = asTargets(this.targets);
570
+ const options = {
571
+ provider: model.provider,
572
+ model: model.model
573
+ };
574
+ const plans = planShotRenders(doc, entities, "keyframe", targets, options);
575
+ // A direct-mode shot renders its clip from the prompt and needs no still —
576
+ // unless it was named, where a board frame to look at is worth having.
577
+ const direct = new Set(targets
578
+ ? []
579
+ : doc.shots
580
+ .filter((shot) => shotRenderMode(shot) === "direct")
581
+ .map((shot) => shot.id));
582
+ const selection = applyGates(plans, {
583
+ onlyStale: this.only_stale,
584
+ rendered: shotsWithVersion(doc, "keyframe"),
585
+ skipShotIds: direct,
586
+ maxShots: this.max_shots
587
+ });
588
+ const outcomes = await renderShots(renderHost(ctx), { id: row.id }, selection.plans, { concurrency: clampConcurrency(this.concurrency) });
589
+ return {
590
+ storyboard: writableRef(row.id),
591
+ keyframes: outcomes.filter((o) => o.ok).map(imageRefOf),
592
+ rendered: outcomes.filter((o) => o.ok).map((o) => o.shotId),
593
+ skipped: selection.skipped,
594
+ failed: outcomes.filter((o) => !o.ok).map((o) => o.shotId)
595
+ };
596
+ }
597
+ }
598
+ __decorate([
599
+ prop({
600
+ type: "storyboard",
601
+ default: storyboardRefDefault,
602
+ title: "Storyboard",
603
+ description: "The board whose stills to render."
604
+ })
605
+ ], RenderStillsNode.prototype, "storyboard", void 0);
606
+ __decorate([
607
+ prop({
608
+ type: "list[str]",
609
+ default: [],
610
+ title: "Targets",
611
+ description: "Shot ids, indices or slugs to render. Empty renders every shot the gates allow."
612
+ })
613
+ ], RenderStillsNode.prototype, "targets", void 0);
614
+ __decorate([
615
+ prop({
616
+ type: "int",
617
+ default: 24,
618
+ title: "Max Shots",
619
+ description: "Most shots this call renders. The rest are reported as skipped, not rendered.",
620
+ min: 1,
621
+ max: 200
622
+ })
623
+ ], RenderStillsNode.prototype, "max_shots", void 0);
624
+ __decorate([
625
+ prop({
626
+ type: "int",
627
+ default: 2,
628
+ title: "Concurrency",
629
+ description: "Renders in flight at once.",
630
+ min: 1,
631
+ max: 8
632
+ })
633
+ ], RenderStillsNode.prototype, "concurrency", void 0);
634
+ __decorate([
635
+ prop({
636
+ type: "bool",
637
+ default: true,
638
+ title: "Only Stale",
639
+ description: "Skip a shot whose still already matches what the board would render."
640
+ })
641
+ ], RenderStillsNode.prototype, "only_stale", void 0);
642
+ __decorate([
643
+ prop({
644
+ type: "image_model",
645
+ default: emptyModel("image_model"),
646
+ title: "Image Model",
647
+ description: "Overrides the board's still model for this call."
648
+ })
649
+ ], RenderStillsNode.prototype, "image_model", void 0);
650
+ __decorate([
651
+ prop({
652
+ type: "bool",
653
+ default: false,
654
+ title: "Allow Writes",
655
+ description: "Render the board as it was picked, rather than only one this run derived."
656
+ })
657
+ ], RenderStillsNode.prototype, "allow_writes", void 0);
658
+ export class RenderClipsNode extends BaseNode {
659
+ static nodeType = "nodetool.storyboard.RenderClips";
660
+ static title = "Render Clips";
661
+ static description = "Animate every stale shot on a storyboard and save the clip onto the board.\n storyboard, clip, video, render, batch\n\n Use cases:\n - Turning approved stills into shots\n - Re-rendering only the clips a recast invalidated\n - Bounding what one run spends on video";
662
+ static metadataOutputTypes = {
663
+ storyboard: "storyboard",
664
+ clips: "list[video]",
665
+ rendered: "list[str]",
666
+ skipped: "list[str]",
667
+ failed: "list[str]"
668
+ };
669
+ static inlineFields = ["storyboard"];
670
+ static inputFields = ["storyboard", "targets"];
671
+ async process(context) {
672
+ requireWritable(this.storyboard, this.allow_writes, "RenderClips");
673
+ const { row, context: ctx } = await loadBoardRow(this.storyboard, context, "RenderClips");
674
+ const doc = row.document;
675
+ const model = resolveModel(doc.videoModel, this.video_model, "clip", doc.shots.some((shot) => shotRenderMode(shot) === "reference")
676
+ ? "reference_to_video"
677
+ : doc.shots.every((shot) => shotRenderMode(shot) === "direct")
678
+ ? "text_to_video"
679
+ : "image_to_video");
680
+ const requiredTasks = requiredVideoTasksForShots(doc.shots);
681
+ let supportedTasks = model.supportedTasks;
682
+ try {
683
+ const provider = await ctx.getProvider(model.provider);
684
+ const discovered = await provider.getAvailableVideoModels();
685
+ supportedTasks = discovered.find((entry) => entry.id === model.model)?.supportedTasks;
686
+ }
687
+ catch (error) {
688
+ if (requiredTasks.length > 1) {
689
+ throw new Error(`Could not verify video model ${model.provider}/${model.model} against the storyboard's mixed shot modes before rendering.`, { cause: error });
690
+ }
691
+ // Single-mode legacy providers may not implement model discovery.
692
+ }
693
+ if (requiredTasks.length > 1 && !supportedTasks?.length) {
694
+ throw new Error(`Could not verify video model ${model.provider}/${model.model} against the storyboard's mixed shot modes before rendering.`);
695
+ }
696
+ if (supportedTasks &&
697
+ requiredTasks.some((task) => !supportedTasks.includes(task))) {
698
+ throw new Error(`Video model ${model.provider}/${model.model} does not support every shot mode on this storyboard. Required capabilities: ${requiredTasks.join(", ")}.`);
699
+ }
700
+ const entities = await loadBoardEntities(ctx, doc);
701
+ const targets = asTargets(this.targets);
702
+ // A linked board times its shots from the words they cover, so a clip is
703
+ // rendered long enough to hold its voiceover.
704
+ const script = await loadLinkedScript(ctx, doc);
705
+ const options = {
706
+ provider: model.provider,
707
+ model: model.model,
708
+ scriptLines: scriptLinesById(script?.document.sections ?? [])
709
+ };
710
+ const plans = planShotRenders(doc, entities, "clip", targets, options);
711
+ // A shot whose picture is a window into another's clip has nothing of its
712
+ // own to render, and a keyframe-mode shot with no still has nothing to
713
+ // animate — refusing that one is what `require_keyframe` buys.
714
+ const covered = new Set(doc.shots.filter((shot) => shot.covered_by?.shot_id).map((shot) => shot.id));
715
+ const unrenderable = new Set([
716
+ ...covered,
717
+ ...(this.require_keyframe
718
+ ? plans
719
+ .filter((plan) => plan.mode === "keyframe" && !plan.sourceKeyframe)
720
+ .map((plan) => plan.shotId)
721
+ : [])
722
+ ]);
723
+ const selection = applyGates(plans, {
724
+ onlyStale: this.only_stale,
725
+ rendered: shotsWithVersion(doc, "clip"),
726
+ skipShotIds: unrenderable,
727
+ maxShots: this.max_shots
728
+ });
729
+ const outcomes = await renderShots(renderHost(ctx), { id: row.id }, selection.plans, { concurrency: clampConcurrency(this.concurrency) });
730
+ return {
731
+ storyboard: writableRef(row.id),
732
+ clips: outcomes.filter((o) => o.ok).map(videoRefOf),
733
+ rendered: outcomes.filter((o) => o.ok).map((o) => o.shotId),
734
+ skipped: selection.skipped,
735
+ failed: outcomes.filter((o) => !o.ok).map((o) => o.shotId)
736
+ };
737
+ }
738
+ }
739
+ __decorate([
740
+ prop({
741
+ type: "storyboard",
742
+ default: storyboardRefDefault,
743
+ title: "Storyboard",
744
+ description: "The board whose clips to render."
745
+ })
746
+ ], RenderClipsNode.prototype, "storyboard", void 0);
747
+ __decorate([
748
+ prop({
749
+ type: "list[str]",
750
+ default: [],
751
+ title: "Targets",
752
+ description: "Shot ids, indices or slugs to render. Empty renders every shot the gates allow."
753
+ })
754
+ ], RenderClipsNode.prototype, "targets", void 0);
755
+ __decorate([
756
+ prop({
757
+ type: "int",
758
+ default: 8,
759
+ title: "Max Shots",
760
+ description: "Most shots this call renders. The rest are reported as skipped, not rendered.",
761
+ min: 1,
762
+ max: 100
763
+ })
764
+ ], RenderClipsNode.prototype, "max_shots", void 0);
765
+ __decorate([
766
+ prop({
767
+ type: "bool",
768
+ default: true,
769
+ title: "Require Keyframe",
770
+ description: "Skip a keyframe-mode shot that has no selected still, instead of spending on a clip with nothing to animate."
771
+ })
772
+ ], RenderClipsNode.prototype, "require_keyframe", void 0);
773
+ __decorate([
774
+ prop({
775
+ type: "int",
776
+ default: 1,
777
+ title: "Concurrency",
778
+ description: "Renders in flight at once.",
779
+ min: 1,
780
+ max: 8
781
+ })
782
+ ], RenderClipsNode.prototype, "concurrency", void 0);
783
+ __decorate([
784
+ prop({
785
+ type: "bool",
786
+ default: true,
787
+ title: "Only Stale",
788
+ description: "Skip a shot whose clip already matches what the board would render."
789
+ })
790
+ ], RenderClipsNode.prototype, "only_stale", void 0);
791
+ __decorate([
792
+ prop({
793
+ type: "video_model",
794
+ default: emptyModel("video_model"),
795
+ title: "Video Model",
796
+ description: "Overrides the board's clip model for this call."
797
+ })
798
+ ], RenderClipsNode.prototype, "video_model", void 0);
799
+ __decorate([
800
+ prop({
801
+ type: "bool",
802
+ default: false,
803
+ title: "Allow Writes",
804
+ description: "Render the board as it was picked, rather than only one this run derived."
805
+ })
806
+ ], RenderClipsNode.prototype, "allow_writes", void 0);
807
+ // ---------------------------------------------------------------------------
808
+ // AssembleTimeline
809
+ // ---------------------------------------------------------------------------
810
+ /** The script a board links, when it links one that can still be read. */
811
+ async function loadLinkedScript(context, doc) {
812
+ const scriptId = doc.screenplay?.script_id;
813
+ if (!scriptId || !context.hasModelInterface("getScript"))
814
+ return null;
815
+ const row = (await context.getScript(scriptId));
816
+ return row ?? null;
817
+ }
818
+ export class AssembleTimelineNode extends BaseNode {
819
+ static nodeType = "nodetool.storyboard.AssembleTimeline";
820
+ static title = "Assemble Timeline";
821
+ static description = "Cut a storyboard's rendered shots into a timeline, inheriting the template's cut when the board is a copy.\n storyboard, timeline, assemble, cut, edit\n\n Use cases:\n - Turning a finished board into an editable sequence\n - Giving every recast copy the approved cut, titles and music\n - Re-cutting a board after re-rendering some shots";
822
+ static metadataOutputTypes = {
823
+ timeline: "timeline",
824
+ skipped_shots: "list[str]",
825
+ retimed: "list[dict]"
826
+ };
827
+ static inlineFields = ["storyboard"];
828
+ static inputFields = ["storyboard"];
829
+ async process(context) {
830
+ requireWritable(this.storyboard, this.allow_writes, "AssembleTimeline");
831
+ const { row, context: ctx } = await loadBoardRow(this.storyboard, context, "AssembleTimeline");
832
+ const doc = row.document;
833
+ const script = await loadLinkedScript(ctx, doc);
834
+ const scriptId = script ? doc.screenplay?.script_id ?? null : null;
835
+ const signal = ctx.signal ?? new AbortController().signal;
836
+ const measurements = await mapWithConcurrency(doc.shots, clampConcurrency(undefined), async (shot) => {
837
+ const clip = shot.clip;
838
+ const assetId = clip?.asset_id;
839
+ if (shot.status !== "rendered" || !assetId)
840
+ return null;
841
+ try {
842
+ const bytes = await loadMediaRefBytes(clip, ctx);
843
+ const seconds = bytes?.length
844
+ ? await probeVideoDurationSeconds(bytes, signal)
845
+ : null;
846
+ return seconds === null ? null : [assetId, seconds];
847
+ }
848
+ catch (error) {
849
+ if (signal.aborted)
850
+ throw error;
851
+ return null;
852
+ }
853
+ });
854
+ const durations = new Map();
855
+ for (const measurement of measurements) {
856
+ if (measurement)
857
+ durations.set(...measurement);
858
+ }
859
+ const measuredShots = applyMeasuredShotClipDurations(doc.shots, durations);
860
+ const assembled = script
861
+ ? buildLinkedTimeline({
862
+ boardId: row.id,
863
+ shots: measuredShots,
864
+ musicPrompt: doc.screenplay?.music_prompt,
865
+ script: {
866
+ scriptId: script.id,
867
+ cast: script.document.cast,
868
+ sections: script.document.sections
869
+ }
870
+ })
871
+ : buildStoryboardTimeline({
872
+ boardId: row.id,
873
+ shots: measuredShots,
874
+ narration: doc.screenplay?.narration,
875
+ musicPrompt: doc.screenplay?.music_prompt
876
+ });
877
+ if (assembled.clips.length === 0) {
878
+ throw new Error("AssembleTimeline: no shot has a rendered clip, so there is nothing to assemble. Run RenderStills, then RenderClips.");
879
+ }
880
+ const { width, height } = frameSizeForAspect(doc.aspectRatio);
881
+ const fps = Math.max(1, Math.min(Math.floor(Number(this.fps) || 30), 120));
882
+ const name = String(this.name ?? "").trim() || row.name;
883
+ // The cut this board writes into: its own, else the one it inherits from
884
+ // the template it was recast from, else a fresh sequence. Only the board's
885
+ // own sequence already exists as a row; the other two are created below.
886
+ const owned = await this.loadOwnSequence(ctx, row);
887
+ const base = owned ?? (await this.templateCut(ctx, row));
888
+ const { tracks, clips } = this.mergeCut(base, assembled, {
889
+ owns: (clip) => clip.storyboardBoardId === row.id ||
890
+ (!!scriptId && clip.scriptId === scriptId),
891
+ liveShotIds: new Set(doc.shots.map((shot) => shot.id)),
892
+ scriptLinked: scriptId !== null
893
+ });
894
+ const durationMs = clips.reduce((end, clip) => Math.max(end, clip.startMs + clip.durationMs), 0);
895
+ const sequence = {
896
+ ...(base ??
897
+ makeSequence({ projectId: row.projectId, name })),
898
+ name,
899
+ fps,
900
+ width,
901
+ height,
902
+ durationMs,
903
+ tracks,
904
+ clips,
905
+ markers: base?.markers ?? []
906
+ };
907
+ const saved = (owned
908
+ ? await ctx.updateTimelineSequence(sequence.id, sequence)
909
+ : await ctx.createTimelineSequence(sequence));
910
+ if (!saved) {
911
+ throw new Error(`AssembleTimeline: could not save the sequence for ${row.name || row.id}.`);
912
+ }
913
+ if (row.timelineId !== saved.id) {
914
+ await ctx.updateStoryboard(row.id, { timelineId: saved.id });
915
+ }
916
+ return {
917
+ timeline: { type: "timeline", id: saved.id },
918
+ skipped_shots: assembled.skippedShotIds,
919
+ retimed: assembled.retimedShots
920
+ };
921
+ }
922
+ /**
923
+ * The cut this assembly writes, over the one that was there.
924
+ *
925
+ * An unlinked board's shot clips are refilled in place
926
+ * ({@link refillShotClips}): a copy inherits the approved cut, and a
927
+ * re-render lands in the edit rather than rebuilding it in board order. A
928
+ * script-linked board is rebuilt, because there the words decide where each
929
+ * shot sits and how long it runs, and a re-assemble has to be free to re-time
930
+ * the cut when a line is re-voiced. Both keep every clip the board does not
931
+ * own (design §3.2, §4.2).
932
+ */
933
+ mergeCut(base, assembled, how) {
934
+ if (!base) {
935
+ return { tracks: [...assembled.tracks], clips: [...assembled.clips] };
936
+ }
937
+ if (how.scriptLinked) {
938
+ const previous = foreignTimelineParts(base, how.owns);
939
+ return {
940
+ tracks: [...assembled.tracks, ...previous.tracks],
941
+ clips: [...assembled.clips, ...previous.clips]
942
+ };
943
+ }
944
+ return refillShotClips(base, assembled, how);
945
+ }
946
+ /** The sequence this board already owns, when it still exists. */
947
+ async loadOwnSequence(context, row) {
948
+ if (!row.timelineId)
949
+ return null;
950
+ return (await context.getTimelineSequence(row.timelineId));
951
+ }
952
+ /**
953
+ * The template's cut, cloned for this copy.
954
+ *
955
+ * A copy inherits the approved edit rather than a bare assembly: the
956
+ * template's shot clips are re-stamped to this board (and refilled from its
957
+ * own renders by the assemble above), while the text overlay, the music bed
958
+ * and every other track come across untouched. The clone is returned
959
+ * unsaved — the assemble writes it as one row, so a failure part way leaves
960
+ * no half-built sequence behind.
961
+ */
962
+ async templateCut(context, row) {
963
+ const templateId = row.document.templateId;
964
+ if (!templateId)
965
+ return null;
966
+ const template = (await context.getStoryboard(templateId));
967
+ if (!template?.timelineId)
968
+ return null;
969
+ const source = (await context.getTimelineSequence(template.timelineId));
970
+ if (!source)
971
+ return null;
972
+ return cloneTimelineForBoard(source, { boardId: templateId }, { boardId: row.id });
973
+ }
974
+ }
975
+ __decorate([
976
+ prop({
977
+ type: "storyboard",
978
+ default: storyboardRefDefault,
979
+ title: "Storyboard",
980
+ description: "The board to cut."
981
+ })
982
+ ], AssembleTimelineNode.prototype, "storyboard", void 0);
983
+ __decorate([
984
+ prop({
985
+ type: "str",
986
+ default: "",
987
+ title: "Name",
988
+ description: "Name for the sequence. Defaults to the board's name."
989
+ })
990
+ ], AssembleTimelineNode.prototype, "name", void 0);
991
+ __decorate([
992
+ prop({
993
+ type: "int",
994
+ default: 30,
995
+ title: "FPS",
996
+ description: "Frame rate of the assembled sequence.",
997
+ min: 1,
998
+ max: 120
999
+ })
1000
+ ], AssembleTimelineNode.prototype, "fps", void 0);
1001
+ __decorate([
1002
+ prop({
1003
+ type: "bool",
1004
+ default: false,
1005
+ title: "Allow Writes",
1006
+ description: "Cut the board as it was picked, rather than only one this run derived."
1007
+ })
1008
+ ], AssembleTimelineNode.prototype, "allow_writes", void 0);
1009
+ export const STORYBOARD_NODES = tagAsServer([
1010
+ LoadStoryboardNode,
1011
+ StoryboardShotsNode,
1012
+ RecastStoryboardNode,
1013
+ RenderStillsNode,
1014
+ RenderClipsNode,
1015
+ AssembleTimelineNode
1016
+ ]);
1017
+ //# sourceMappingURL=storyboard.js.map