@nodetool-ai/execution 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 (231) hide show
  1. package/README.md +129 -0
  2. package/dist/.nodetool-build-stamp +1 -0
  3. package/dist/app-debug/app-spec.d.ts +94 -0
  4. package/dist/app-debug/app-spec.d.ts.map +1 -0
  5. package/dist/app-debug/app-spec.js +710 -0
  6. package/dist/app-debug/app-spec.js.map +1 -0
  7. package/dist/app-debug/application-target.d.ts +53 -0
  8. package/dist/app-debug/application-target.d.ts.map +1 -0
  9. package/dist/app-debug/application-target.js +126 -0
  10. package/dist/app-debug/application-target.js.map +1 -0
  11. package/dist/app-debug/bundle-target.d.ts +21 -0
  12. package/dist/app-debug/bundle-target.d.ts.map +1 -0
  13. package/dist/app-debug/bundle-target.js +51 -0
  14. package/dist/app-debug/bundle-target.js.map +1 -0
  15. package/dist/app-debug/graph-shape.d.ts +14 -0
  16. package/dist/app-debug/graph-shape.d.ts.map +1 -0
  17. package/dist/app-debug/graph-shape.js +23 -0
  18. package/dist/app-debug/graph-shape.js.map +1 -0
  19. package/dist/app-debug/index.d.ts +26 -0
  20. package/dist/app-debug/index.d.ts.map +1 -0
  21. package/dist/app-debug/index.js +19 -0
  22. package/dist/app-debug/index.js.map +1 -0
  23. package/dist/app-debug/markdown.d.ts +6 -0
  24. package/dist/app-debug/markdown.d.ts.map +1 -0
  25. package/dist/app-debug/markdown.js +133 -0
  26. package/dist/app-debug/markdown.js.map +1 -0
  27. package/dist/app-debug/runtime.d.ts +267 -0
  28. package/dist/app-debug/runtime.d.ts.map +1 -0
  29. package/dist/app-debug/runtime.js +633 -0
  30. package/dist/app-debug/runtime.js.map +1 -0
  31. package/dist/app-debug/script-operation.d.ts +58 -0
  32. package/dist/app-debug/script-operation.d.ts.map +1 -0
  33. package/dist/app-debug/script-operation.js +44 -0
  34. package/dist/app-debug/script-operation.js.map +1 -0
  35. package/dist/app-debug/simulate.d.ts +85 -0
  36. package/dist/app-debug/simulate.d.ts.map +1 -0
  37. package/dist/app-debug/simulate.js +991 -0
  38. package/dist/app-debug/simulate.js.map +1 -0
  39. package/dist/app-debug/summarize.d.ts +64 -0
  40. package/dist/app-debug/summarize.d.ts.map +1 -0
  41. package/dist/app-debug/summarize.js +65 -0
  42. package/dist/app-debug/summarize.js.map +1 -0
  43. package/dist/app-debug/types.d.ts +343 -0
  44. package/dist/app-debug/types.d.ts.map +1 -0
  45. package/dist/app-debug/types.js +2 -0
  46. package/dist/app-debug/types.js.map +1 -0
  47. package/dist/cost-ledger.d.ts +147 -0
  48. package/dist/cost-ledger.d.ts.map +1 -0
  49. package/dist/cost-ledger.js +288 -0
  50. package/dist/cost-ledger.js.map +1 -0
  51. package/dist/debug/collector.d.ts +28 -0
  52. package/dist/debug/collector.d.ts.map +1 -0
  53. package/dist/debug/collector.js +284 -0
  54. package/dist/debug/collector.js.map +1 -0
  55. package/dist/debug/index.d.ts +15 -0
  56. package/dist/debug/index.d.ts.map +1 -0
  57. package/dist/debug/index.js +16 -0
  58. package/dist/debug/index.js.map +1 -0
  59. package/dist/debug/types.d.ts +179 -0
  60. package/dist/debug/types.d.ts.map +1 -0
  61. package/dist/debug/types.js +11 -0
  62. package/dist/debug/types.js.map +1 -0
  63. package/dist/debug/verdict.d.ts +27 -0
  64. package/dist/debug/verdict.d.ts.map +1 -0
  65. package/dist/debug/verdict.js +51 -0
  66. package/dist/debug/verdict.js.map +1 -0
  67. package/dist/executor-resolver.d.ts +13 -0
  68. package/dist/executor-resolver.d.ts.map +1 -0
  69. package/dist/executor-resolver.js +13 -0
  70. package/dist/executor-resolver.js.map +1 -0
  71. package/dist/fal-output-decoder.d.ts +36 -0
  72. package/dist/fal-output-decoder.d.ts.map +1 -0
  73. package/dist/fal-output-decoder.js +129 -0
  74. package/dist/fal-output-decoder.js.map +1 -0
  75. package/dist/generation-lifecycle.d.ts +54 -0
  76. package/dist/generation-lifecycle.d.ts.map +1 -0
  77. package/dist/generation-lifecycle.js +431 -0
  78. package/dist/generation-lifecycle.js.map +1 -0
  79. package/dist/generation-recovery-worker.d.ts +100 -0
  80. package/dist/generation-recovery-worker.d.ts.map +1 -0
  81. package/dist/generation-recovery-worker.js +995 -0
  82. package/dist/generation-recovery-worker.js.map +1 -0
  83. package/dist/generation-tracker.d.ts +83 -0
  84. package/dist/generation-tracker.d.ts.map +1 -0
  85. package/dist/generation-tracker.js +436 -0
  86. package/dist/generation-tracker.js.map +1 -0
  87. package/dist/index.d.ts +28 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/index.js +21 -0
  90. package/dist/index.js.map +1 -0
  91. package/dist/js-script-debug/index.d.ts +13 -0
  92. package/dist/js-script-debug/index.d.ts.map +1 -0
  93. package/dist/js-script-debug/index.js +12 -0
  94. package/dist/js-script-debug/index.js.map +1 -0
  95. package/dist/js-script-debug/markdown.d.ts +6 -0
  96. package/dist/js-script-debug/markdown.d.ts.map +1 -0
  97. package/dist/js-script-debug/markdown.js +62 -0
  98. package/dist/js-script-debug/markdown.js.map +1 -0
  99. package/dist/js-script-debug/outputs.d.ts +20 -0
  100. package/dist/js-script-debug/outputs.d.ts.map +1 -0
  101. package/dist/js-script-debug/outputs.js +30 -0
  102. package/dist/js-script-debug/outputs.js.map +1 -0
  103. package/dist/js-script-debug/report.d.ts +15 -0
  104. package/dist/js-script-debug/report.d.ts.map +1 -0
  105. package/dist/js-script-debug/report.js +90 -0
  106. package/dist/js-script-debug/report.js.map +1 -0
  107. package/dist/js-script-debug/types.d.ts +63 -0
  108. package/dist/js-script-debug/types.d.ts.map +1 -0
  109. package/dist/js-script-debug/types.js +10 -0
  110. package/dist/js-script-debug/types.js.map +1 -0
  111. package/dist/js-script-debug/validate.d.ts +38 -0
  112. package/dist/js-script-debug/validate.d.ts.map +1 -0
  113. package/dist/js-script-debug/validate.js +137 -0
  114. package/dist/js-script-debug/validate.js.map +1 -0
  115. package/dist/message-stream.d.ts +33 -0
  116. package/dist/message-stream.d.ts.map +1 -0
  117. package/dist/message-stream.js +58 -0
  118. package/dist/message-stream.js.map +1 -0
  119. package/dist/normalize-graph.d.ts +13 -0
  120. package/dist/normalize-graph.d.ts.map +1 -0
  121. package/dist/normalize-graph.js +63 -0
  122. package/dist/normalize-graph.js.map +1 -0
  123. package/dist/output-names.d.ts +13 -0
  124. package/dist/output-names.d.ts.map +1 -0
  125. package/dist/output-names.js +13 -0
  126. package/dist/output-names.js.map +1 -0
  127. package/dist/predicates.d.ts +6 -0
  128. package/dist/predicates.d.ts.map +1 -0
  129. package/dist/predicates.js +6 -0
  130. package/dist/predicates.js.map +1 -0
  131. package/dist/preflight.d.ts +87 -0
  132. package/dist/preflight.d.ts.map +1 -0
  133. package/dist/preflight.js +139 -0
  134. package/dist/preflight.js.map +1 -0
  135. package/dist/provider-secret-key.d.ts +3 -0
  136. package/dist/provider-secret-key.d.ts.map +1 -0
  137. package/dist/provider-secret-key.js +7 -0
  138. package/dist/provider-secret-key.js.map +1 -0
  139. package/dist/service/app-debug-service.d.ts +78 -0
  140. package/dist/service/app-debug-service.d.ts.map +1 -0
  141. package/dist/service/app-debug-service.js +201 -0
  142. package/dist/service/app-debug-service.js.map +1 -0
  143. package/dist/service/app-run-server.d.ts +28 -0
  144. package/dist/service/app-run-server.d.ts.map +1 -0
  145. package/dist/service/app-run-server.js +104 -0
  146. package/dist/service/app-run-server.js.map +1 -0
  147. package/dist/service/debug-sessions.d.ts +158 -0
  148. package/dist/service/debug-sessions.d.ts.map +1 -0
  149. package/dist/service/debug-sessions.js +358 -0
  150. package/dist/service/debug-sessions.js.map +1 -0
  151. package/dist/service/index.d.ts +22 -0
  152. package/dist/service/index.d.ts.map +1 -0
  153. package/dist/service/index.js +17 -0
  154. package/dist/service/index.js.map +1 -0
  155. package/dist/service/job-progress.d.ts +40 -0
  156. package/dist/service/job-progress.d.ts.map +1 -0
  157. package/dist/service/job-progress.js +53 -0
  158. package/dist/service/job-progress.js.map +1 -0
  159. package/dist/service/workflow-run.d.ts +279 -0
  160. package/dist/service/workflow-run.d.ts.map +1 -0
  161. package/dist/service/workflow-run.js +598 -0
  162. package/dist/service/workflow-run.js.map +1 -0
  163. package/dist/service/workflow-workspace.d.ts +89 -0
  164. package/dist/service/workflow-workspace.d.ts.map +1 -0
  165. package/dist/service/workflow-workspace.js +160 -0
  166. package/dist/service/workflow-workspace.js.map +1 -0
  167. package/dist/session.d.ts +62 -0
  168. package/dist/session.d.ts.map +1 -0
  169. package/dist/session.js +353 -0
  170. package/dist/session.js.map +1 -0
  171. package/dist/sketch-debug/index.d.ts +11 -0
  172. package/dist/sketch-debug/index.d.ts.map +1 -0
  173. package/dist/sketch-debug/index.js +10 -0
  174. package/dist/sketch-debug/index.js.map +1 -0
  175. package/dist/sketch-debug/markdown.d.ts +6 -0
  176. package/dist/sketch-debug/markdown.d.ts.map +1 -0
  177. package/dist/sketch-debug/markdown.js +93 -0
  178. package/dist/sketch-debug/markdown.js.map +1 -0
  179. package/dist/sketch-debug/report.d.ts +15 -0
  180. package/dist/sketch-debug/report.d.ts.map +1 -0
  181. package/dist/sketch-debug/report.js +98 -0
  182. package/dist/sketch-debug/report.js.map +1 -0
  183. package/dist/sketch-debug/types.d.ts +62 -0
  184. package/dist/sketch-debug/types.d.ts.map +1 -0
  185. package/dist/sketch-debug/types.js +10 -0
  186. package/dist/sketch-debug/types.js.map +1 -0
  187. package/dist/sketch-debug/validate.d.ts +14 -0
  188. package/dist/sketch-debug/validate.d.ts.map +1 -0
  189. package/dist/sketch-debug/validate.js +478 -0
  190. package/dist/sketch-debug/validate.js.map +1 -0
  191. package/dist/supervisor.d.ts +45 -0
  192. package/dist/supervisor.d.ts.map +1 -0
  193. package/dist/supervisor.js +97 -0
  194. package/dist/supervisor.js.map +1 -0
  195. package/dist/timeline-debug/index.d.ts +11 -0
  196. package/dist/timeline-debug/index.d.ts.map +1 -0
  197. package/dist/timeline-debug/index.js +10 -0
  198. package/dist/timeline-debug/index.js.map +1 -0
  199. package/dist/timeline-debug/legibility.d.ts +23 -0
  200. package/dist/timeline-debug/legibility.d.ts.map +1 -0
  201. package/dist/timeline-debug/legibility.js +240 -0
  202. package/dist/timeline-debug/legibility.js.map +1 -0
  203. package/dist/timeline-debug/markdown.d.ts +6 -0
  204. package/dist/timeline-debug/markdown.d.ts.map +1 -0
  205. package/dist/timeline-debug/markdown.js +103 -0
  206. package/dist/timeline-debug/markdown.js.map +1 -0
  207. package/dist/timeline-debug/motion.d.ts +20 -0
  208. package/dist/timeline-debug/motion.d.ts.map +1 -0
  209. package/dist/timeline-debug/motion.js +362 -0
  210. package/dist/timeline-debug/motion.js.map +1 -0
  211. package/dist/timeline-debug/report.d.ts +16 -0
  212. package/dist/timeline-debug/report.d.ts.map +1 -0
  213. package/dist/timeline-debug/report.js +100 -0
  214. package/dist/timeline-debug/report.js.map +1 -0
  215. package/dist/timeline-debug/showcase.d.ts +7 -0
  216. package/dist/timeline-debug/showcase.d.ts.map +1 -0
  217. package/dist/timeline-debug/showcase.js +347 -0
  218. package/dist/timeline-debug/showcase.js.map +1 -0
  219. package/dist/timeline-debug/types.d.ts +64 -0
  220. package/dist/timeline-debug/types.d.ts.map +1 -0
  221. package/dist/timeline-debug/types.js +10 -0
  222. package/dist/timeline-debug/types.js.map +1 -0
  223. package/dist/timeline-debug/validate.d.ts +24 -0
  224. package/dist/timeline-debug/validate.d.ts.map +1 -0
  225. package/dist/timeline-debug/validate.js +1432 -0
  226. package/dist/timeline-debug/validate.js.map +1 -0
  227. package/dist/types.d.ts +211 -0
  228. package/dist/types.d.ts.map +1 -0
  229. package/dist/types.js +2 -0
  230. package/dist/types.js.map +1 -0
  231. package/package.json +103 -0
@@ -0,0 +1,1432 @@
1
+ /**
2
+ * Static validation of a timeline document.
3
+ *
4
+ * Pure and structural: everything here is decidable from the document alone —
5
+ * no database, no assets, no decode, no rendering. The check catalog follows
6
+ * the landmines in `packages/timeline/AGENTS.md` (timeline-space vs.
7
+ * source-space, boundary properties on split, overlap) plus the schema's own
8
+ * history: every `Without this field Zod strips it on every PATCH` comment in
9
+ * `@nodetool-ai/protocol/api-schemas/timeline.ts` is a data-loss bug that
10
+ * shipped, which is what `field_stripped` exists to catch mechanically.
11
+ */
12
+ import { timelineDocument } from "@nodetool-ai/protocol/api-schemas/timeline.js";
13
+ import { ANIMATION_PRESETS, CLIP_EFFECT_TYPES, CLIP_SHAPE_KINDS, CUSTOM_ANIMATION_PRESET_ID, EASING_IDS, normalizeCustomCurves, validateAnimationStyleTracks, glyphTracksNeedStagger, BUNDLED_FONT_FAMILIES, isKnownShapeKind, parseClipEffectType, parseEasing, resolveFontFamily, resolveCustomMask, computeModel3DBakeHash, isGeneratedMatteStale, isReframeStale, isCropUsable, sourceRate, DEFAULT_TEMPO, resolveTempo, validateNotes, visibleNotes } from "@nodetool-ai/timeline";
14
+ import { MASK_KINDS, MAX_VIDEO_LAYERS, TRANSITION_DIRECTIONS, TRANSITION_TYPES, parseSvgPath, parseTransitionDirection, parseTransitionType } from "@nodetool-ai/timeline/scene";
15
+ import { checkShowcase } from "./showcase.js";
16
+ import { checkLegibility } from "./legibility.js";
17
+ import { checkClipMotion } from "./motion.js";
18
+ const DEFAULT_FPS = 30;
19
+ /** The sequence size a document carries no meta for — 1080p, the editor's own. */
20
+ const DEFAULT_WIDTH = 1920;
21
+ const DEFAULT_HEIGHT = 1080;
22
+ const PRESET_IDS = new Set([
23
+ ...ANIMATION_PRESETS.map((preset) => preset.id),
24
+ CUSTOM_ANIMATION_PRESET_ID
25
+ ]);
26
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
27
+ const clipLabel = (clip) => `${clip.name || clip.id}`;
28
+ /** What an `easing` field accepts, for the `unknown_easing` message. */
29
+ const EASING_GRAMMAR = `${EASING_IDS.join(", ")}, cubic-bezier(x1,y1,x2,y2), spring(stiffness,damping,mass)`;
30
+ /**
31
+ * An easing string nothing in the grammar parses. A warning, not an error: the
32
+ * sampler eases linearly rather than dropping the motion, so a document from a
33
+ * newer build still plays (I2). The offending string is named because it is
34
+ * almost always a typo — `ease-out` for `easeOut`, a spring with two arguments.
35
+ */
36
+ function unknownEasingIssue(clip, easing, path) {
37
+ if (easing === undefined || parseEasing(easing) !== null)
38
+ return null;
39
+ return {
40
+ severity: "warning",
41
+ code: "unknown_easing",
42
+ message: `Clip "${clipLabel(clip)}" eases with "${easing}", which this build cannot parse — it will ease linearly. Expected one of ${EASING_GRAMMAR}.`,
43
+ path,
44
+ clipId: clip.id,
45
+ trackId: clip.trackId
46
+ };
47
+ }
48
+ /** Source milliseconds a curve may overhang before it counts as outside. */
49
+ const SOURCE_WINDOW_SLACK_MS = 1;
50
+ /**
51
+ * The stretch of source a clip shows: the remap curve's own range when it
52
+ * carries one, otherwise the in-point plus what the clip consumes at its rate.
53
+ * `outPointMs` is deliberately not consulted — a document where it disagrees
54
+ * with `durationMs * rate` is what `in_out_duration_mismatch` reports.
55
+ */
56
+ function clipSourceWindow(clip) {
57
+ const keyframes = clip.timeRemap?.keyframes ?? [];
58
+ if (keyframes.length > 0) {
59
+ const times = keyframes.map((keyframe) => keyframe.sourceMs);
60
+ return { fromMs: Math.min(...times), toMs: Math.max(...times) };
61
+ }
62
+ const fromMs = clip.inPointMs ?? 0;
63
+ return { fromMs, toMs: fromMs + clip.durationMs * sourceRate(clip) };
64
+ }
65
+ /**
66
+ * A source-anchored curve reaching past the source the clip plays.
67
+ *
68
+ * The keyframes name absolute times in the media, so the part outside the
69
+ * clip's window is never sampled: the motion the author drew there does not
70
+ * happen, and the sampler holds the edge value instead. A warning rather than
71
+ * an error — the clip still renders, and a trim that shortened the clip
72
+ * without re-slicing (an older client's edit) lands here.
73
+ */
74
+ function sourceCurveOutsideWindowIssue(clip, animationId, curves) {
75
+ const times = [];
76
+ for (const curve of curves) {
77
+ for (const keyframe of curve.keyframes) {
78
+ if (keyframe.sourceMs !== undefined)
79
+ times.push(keyframe.sourceMs);
80
+ }
81
+ }
82
+ if (times.length === 0)
83
+ return null;
84
+ const curveFromMs = Math.min(...times);
85
+ const curveToMs = Math.max(...times);
86
+ const window = clipSourceWindow(clip);
87
+ if (curveFromMs >= window.fromMs - SOURCE_WINDOW_SLACK_MS &&
88
+ curveToMs <= window.toMs + SOURCE_WINDOW_SLACK_MS) {
89
+ return null;
90
+ }
91
+ return {
92
+ severity: "warning",
93
+ code: "source_curve_outside_window",
94
+ message: `Clip "${clipLabel(clip)}" animation "${animationId}" keyframes ${Math.round(curveFromMs)}–${Math.round(curveToMs)}ms of source, outside the ${Math.round(window.fromMs)}–${Math.round(window.toMs)}ms the clip plays — the motion outside never runs. Re-slice the curve to the clip's source window.`,
95
+ path: "animations[*].custom.curves[*].keyframes[*].sourceMs",
96
+ clipId: clip.id,
97
+ trackId: clip.trackId
98
+ };
99
+ }
100
+ /** What a transition's `type` accepts, for the `unknown_transition` message. */
101
+ const TRANSITION_GRAMMAR = TRANSITION_TYPES.join(", ");
102
+ /** What a `wipe`/`push`/`slide` `direction` accepts. */
103
+ const DIRECTION_GRAMMAR = TRANSITION_DIRECTIONS.join(", ");
104
+ /**
105
+ * A transition type this build cannot draw.
106
+ *
107
+ * `type` is a plain string on the wire (I2), so a cut a newer build authored
108
+ * parses, reaches the renderer, and cross-fades. This names the type the
109
+ * document asked for and says what happens instead. It reads the parsed clip:
110
+ * the schema no longer refuses an unknown type, so there is nothing left for a
111
+ * pre-parse scan of the raw document to see.
112
+ */
113
+ function unknownTransitionTypeIssue(clip) {
114
+ const transition = clip.transitionIn;
115
+ if (!transition || parseTransitionType(transition.type) !== null)
116
+ return null;
117
+ return {
118
+ severity: "warning",
119
+ code: "unknown_transition",
120
+ message: `Clip "${clipLabel(clip)}" opens with a "${transition.type}" transition, which this build cannot draw — it cross-fades instead. Expected one of ${TRANSITION_GRAMMAR}.`,
121
+ path: "transitionIn.type",
122
+ clipId: clip.id,
123
+ trackId: clip.trackId
124
+ };
125
+ }
126
+ /**
127
+ * A `direction` the renderer cannot read on a transition whose type it can.
128
+ * Falls back to `left` at render time, the way an unknown type falls back to a
129
+ * cross-fade.
130
+ */
131
+ function unknownDirectionIssue(clip) {
132
+ const transition = clip.transitionIn;
133
+ if (!transition || !("direction" in transition))
134
+ return null;
135
+ const { direction } = transition;
136
+ // An unknown type can carry any value under a key our own types use as a
137
+ // string, so the read is guarded rather than trusted.
138
+ if (typeof direction !== "string")
139
+ return null;
140
+ if (parseTransitionDirection(direction) !== null)
141
+ return null;
142
+ return {
143
+ severity: "warning",
144
+ code: "unknown_transition",
145
+ message: `Clip "${clipLabel(clip)}" runs its ${transition.type} toward "${direction}", which this build cannot read — it runs left. Expected one of ${DIRECTION_GRAMMAR}.`,
146
+ path: "transitionIn.direction",
147
+ clipId: clip.id,
148
+ trackId: clip.trackId
149
+ };
150
+ }
151
+ /**
152
+ * Collect every leaf/branch path the parse output dropped from the input.
153
+ *
154
+ * Exported because the save path needs the same answer the debug report gives:
155
+ * `timeline.update` in `packages/websocket` parses the incoming document with
156
+ * the same stripping schema and reports what it dropped, so a field lost on
157
+ * autosave is named at the moment it is lost rather than at the next debug run.
158
+ */
159
+ export function collectStrippedPaths(input, output, path, found) {
160
+ if (Array.isArray(input)) {
161
+ if (!Array.isArray(output))
162
+ return;
163
+ input.forEach((item, index) => {
164
+ collectStrippedPaths(item, output[index], `${path}[*]`, found);
165
+ });
166
+ return;
167
+ }
168
+ if (!isRecord(input))
169
+ return;
170
+ if (!isRecord(output))
171
+ return;
172
+ for (const [key, value] of Object.entries(input)) {
173
+ // An explicit `undefined` carries nothing, so its absence loses nothing.
174
+ if (value === undefined)
175
+ continue;
176
+ const childPath = path ? `${path}.${key}` : key;
177
+ if (!(key in output) || output[key] === undefined) {
178
+ found.add(childPath);
179
+ continue;
180
+ }
181
+ collectStrippedPaths(value, output[key], childPath, found);
182
+ }
183
+ }
184
+ /**
185
+ * Fields the schema silently drops. A stripped field survives in memory and
186
+ * disappears on the next autosave round-trip, so it never fails loudly — the
187
+ * only way to see it is to diff the input against what Zod handed back.
188
+ */
189
+ function checkFieldStripping(raw, parsed) {
190
+ const paths = new Set();
191
+ collectStrippedPaths(raw, parsed, "", paths);
192
+ return [...paths].sort().map((path) => ({
193
+ severity: "warning",
194
+ code: "field_stripped",
195
+ path,
196
+ message: `\`${path}\` is present in the document but absent after schema parse — the schema strips it, so it is lost on the next save.`
197
+ }));
198
+ }
199
+ function checkDuplicateIds(doc) {
200
+ const issues = [];
201
+ const check = (kind, ids) => {
202
+ const seen = new Set();
203
+ const reported = new Set();
204
+ for (const id of ids) {
205
+ if (seen.has(id) && !reported.has(id)) {
206
+ reported.add(id);
207
+ const issue = {
208
+ severity: "error",
209
+ code: "duplicate_id",
210
+ message: `Duplicate ${kind} id "${id}".`
211
+ };
212
+ if (kind === "track") {
213
+ issue.trackId = id;
214
+ }
215
+ if (kind === "clip") {
216
+ issue.clipId = id;
217
+ }
218
+ issues.push(issue);
219
+ }
220
+ seen.add(id);
221
+ }
222
+ };
223
+ check("track", doc.tracks.map((track) => track.id));
224
+ check("clip", doc.clips.map((clip) => clip.id));
225
+ check("marker", doc.markers.map((marker) => marker.id));
226
+ return issues;
227
+ }
228
+ function checkClip(clip, trackIds, fps, canvas) {
229
+ const issues = [];
230
+ const at = { clipId: clip.id, trackId: clip.trackId };
231
+ const label = clipLabel(clip);
232
+ if (clip.textStyle?.path && !parseSvgPath(clip.textStyle.path).ok) {
233
+ issues.push({
234
+ severity: "error",
235
+ code: "text_path_invalid",
236
+ message: `Clip "${label}" has an invalid text path.`,
237
+ path: "textStyle.path",
238
+ ...at
239
+ });
240
+ }
241
+ if (!trackIds.has(clip.trackId)) {
242
+ issues.push({
243
+ severity: "error",
244
+ code: "clip_track_missing",
245
+ message: `Clip "${label}" sits on track "${clip.trackId}", which the document does not declare.`,
246
+ ...at
247
+ });
248
+ }
249
+ if (clip.startMs < 0) {
250
+ issues.push({
251
+ severity: "error",
252
+ code: "negative_timing",
253
+ message: `Clip "${label}" starts at ${clip.startMs}ms — before the timeline origin.`,
254
+ path: "startMs",
255
+ ...at
256
+ });
257
+ }
258
+ if (clip.durationMs <= 0) {
259
+ issues.push({
260
+ severity: "error",
261
+ code: "negative_timing",
262
+ message: `Clip "${label}" has durationMs ${clip.durationMs} — a clip must last longer than zero.`,
263
+ path: "durationMs",
264
+ ...at
265
+ });
266
+ }
267
+ const fadeIn = clip.fadeInMs ?? 0;
268
+ const fadeOut = clip.fadeOutMs ?? 0;
269
+ if (fadeIn + fadeOut > clip.durationMs) {
270
+ issues.push({
271
+ severity: "error",
272
+ code: "fade_exceeds_duration",
273
+ message: `Clip "${label}" fades ${fadeIn}ms in + ${fadeOut}ms out over a ${clip.durationMs}ms clip — the fades overlap.`,
274
+ ...at
275
+ });
276
+ }
277
+ const { inPointMs, outPointMs } = clip;
278
+ if (inPointMs !== undefined && inPointMs < 0) {
279
+ issues.push({
280
+ severity: "error",
281
+ code: "in_out_points_invalid",
282
+ message: `Clip "${label}" has inPointMs ${inPointMs} — a source point cannot be negative.`,
283
+ path: "inPointMs",
284
+ ...at
285
+ });
286
+ }
287
+ if (outPointMs !== undefined && outPointMs < 0) {
288
+ issues.push({
289
+ severity: "error",
290
+ code: "in_out_points_invalid",
291
+ message: `Clip "${label}" has outPointMs ${outPointMs} — a source point cannot be negative.`,
292
+ path: "outPointMs",
293
+ ...at
294
+ });
295
+ }
296
+ if (inPointMs !== undefined &&
297
+ outPointMs !== undefined &&
298
+ outPointMs <= inPointMs) {
299
+ issues.push({
300
+ severity: "error",
301
+ code: "in_out_points_invalid",
302
+ message: `Clip "${label}" has outPointMs ${outPointMs} at or before inPointMs ${inPointMs} — the source span is empty.`,
303
+ ...at
304
+ });
305
+ }
306
+ else if (inPointMs !== undefined &&
307
+ outPointMs !== undefined &&
308
+ clip.durationMs > 0) {
309
+ // Timeline duration and source span are different quantities: the source
310
+ // consumes `rate` ms per timeline ms. Comparing them without the rate is
311
+ // the bug `sourceRate` exists to prevent.
312
+ const rate = sourceRate(clip);
313
+ const expected = clip.durationMs * rate;
314
+ const actual = outPointMs - inPointMs;
315
+ if (Math.abs(actual - expected) > 1) {
316
+ issues.push({
317
+ severity: "warning",
318
+ code: "in_out_duration_mismatch",
319
+ message: `Clip "${label}" spans ${actual}ms of source but ${clip.durationMs}ms of timeline at rate ${rate} (expected ${Math.round(expected)}ms of source).`,
320
+ ...at
321
+ });
322
+ }
323
+ }
324
+ if (clip.speedMultiplier !== undefined && clip.speedMultiplier <= 0) {
325
+ issues.push({
326
+ severity: "error",
327
+ code: "speed_multiplier_invalid",
328
+ message: `Clip "${label}" has speedMultiplier ${clip.speedMultiplier} — playback rate must be positive.`,
329
+ path: "speedMultiplier",
330
+ ...at
331
+ });
332
+ }
333
+ if (clip.transitionIn && clip.transitionIn.durationMs > clip.durationMs) {
334
+ issues.push({
335
+ severity: "warning",
336
+ code: "transition_exceeds_duration",
337
+ message: `Clip "${label}" opens with a ${clip.transitionIn.durationMs}ms ${clip.transitionIn.type} over a ${clip.durationMs}ms clip.`,
338
+ ...at
339
+ });
340
+ }
341
+ const transitionEasing = unknownEasingIssue(clip, clip.transitionIn?.easing, "transitionIn.easing");
342
+ if (transitionEasing)
343
+ issues.push(transitionEasing);
344
+ const transitionType = unknownTransitionTypeIssue(clip);
345
+ if (transitionType)
346
+ issues.push(transitionType);
347
+ const transitionDirection = unknownDirectionIssue(clip);
348
+ if (transitionDirection)
349
+ issues.push(transitionDirection);
350
+ for (const [index, keyframe] of (clip.timeRemap?.keyframes ?? []).entries()) {
351
+ const issue = unknownEasingIssue(clip, keyframe.easing, `timeRemap.keyframes[${index}].easing`);
352
+ if (issue)
353
+ issues.push(issue);
354
+ }
355
+ for (const animation of clip.animations ?? []) {
356
+ const animationEasing = unknownEasingIssue(clip, animation.easing, "animations[*].easing");
357
+ if (animationEasing)
358
+ issues.push(animationEasing);
359
+ if (!PRESET_IDS.has(animation.preset)) {
360
+ issues.push({
361
+ severity: "error",
362
+ code: "unknown_animation_preset",
363
+ message: `Clip "${label}" animation "${animation.id}" uses preset "${animation.preset}", which this build does not ship.`,
364
+ path: "animations[*].preset",
365
+ ...at
366
+ });
367
+ continue;
368
+ }
369
+ if (animation.styleTracks) {
370
+ if (glyphTracksNeedStagger(animation.styleTracks, animation.stagger)) {
371
+ issues.push({
372
+ severity: "error",
373
+ code: "animation_style_invalid",
374
+ message: `Clip "${label}" animation "${animation.id}" uses glyph tracks without a stagger unit.`,
375
+ path: "animations[*].styleTracks",
376
+ ...at
377
+ });
378
+ }
379
+ for (const error of validateAnimationStyleTracks(clip, animation.styleTracks)) {
380
+ issues.push({
381
+ severity: "error",
382
+ code: "animation_style_invalid",
383
+ message: `Clip "${label}" animation "${animation.id}": ${error}.`,
384
+ path: "animations[*].styleTracks",
385
+ ...at
386
+ });
387
+ }
388
+ }
389
+ if (animation.preset !== CUSTOM_ANIMATION_PRESET_ID)
390
+ continue;
391
+ // Style and text animators use the same compiled window without numeric
392
+ // curves. They render through their own typed tracks.
393
+ if (!animation.custom?.curves?.length &&
394
+ (animation.styleTracks?.length || animation.textAnimator))
395
+ continue;
396
+ // A custom animation renders nothing unless its baked curves survive the
397
+ // one gate every render site applies, so what the compiler would skip with
398
+ // a console warning is reported here instead.
399
+ const baked = normalizeCustomCurves(animation.custom?.curves, animation.custom?.timeBase);
400
+ if (!baked.ok) {
401
+ issues.push({
402
+ severity: "error",
403
+ code: "custom_animation_invalid",
404
+ message: `Clip "${label}" animation "${animation.id}" is a custom animation whose baked curves are unusable: ${baked.error}. Re-bake it from its script.`,
405
+ path: "animations[*].custom.curves",
406
+ ...at
407
+ });
408
+ continue;
409
+ }
410
+ for (const easing of baked.unknownEasings ?? []) {
411
+ const issue = unknownEasingIssue(clip, easing, "animations[*].custom.curves[*].keyframes[*].easing");
412
+ if (issue)
413
+ issues.push(issue);
414
+ }
415
+ if (baked.timeBase === "source") {
416
+ const outside = sourceCurveOutsideWindowIssue(clip, animation.id, baked.curves);
417
+ if (outside)
418
+ issues.push(outside);
419
+ }
420
+ const mask = resolveCustomMask(baked.curves, animation.custom?.mask);
421
+ if (!mask.ok) {
422
+ issues.push({
423
+ severity: "error",
424
+ code: "custom_animation_invalid",
425
+ message: `Clip "${label}" animation "${animation.id}": ${mask.error}.`,
426
+ path: "animations[*].custom.mask",
427
+ ...at
428
+ });
429
+ continue;
430
+ }
431
+ }
432
+ if (clip.sourceType === "generated" && !clip.workflowId && !clip.prompt) {
433
+ issues.push({
434
+ severity: "warning",
435
+ code: "binding_incomplete",
436
+ message: `Clip "${label}" is generated but names neither a workflowId nor a prompt — nothing can produce its media.`,
437
+ ...at
438
+ });
439
+ }
440
+ for (const word of clip.caption?.words ?? []) {
441
+ if (word.endMs <= word.startMs ||
442
+ word.startMs < 0 ||
443
+ word.endMs > clip.durationMs) {
444
+ issues.push({
445
+ severity: "warning",
446
+ code: "caption_out_of_range",
447
+ message: `Clip "${label}" caption word "${word.word}" spans ${word.startMs}–${word.endMs}ms, outside the clip's 0–${clip.durationMs}ms window.`,
448
+ ...at
449
+ });
450
+ break;
451
+ }
452
+ }
453
+ issues.push(...maskIssues(clip));
454
+ issues.push(...unknownEffectIssues(clip), ...animateIgnoredIssues(clip));
455
+ issues.push(...cropIssues(clip));
456
+ const shapeKind = unknownShapeKindIssue(clip);
457
+ if (shapeKind)
458
+ issues.push(shapeKind);
459
+ issues.push(...model3dIssues(clip, { fps, ...canvas }));
460
+ issues.push(...fontPortabilityIssues(clip));
461
+ const frameMs = 1000 / fps;
462
+ if (clip.durationMs > 0 && clip.durationMs < frameMs) {
463
+ issues.push({
464
+ severity: "warning",
465
+ code: "clip_shorter_than_frame",
466
+ message: `Clip "${label}" lasts ${clip.durationMs}ms, less than one frame at ${fps}fps (${frameMs.toFixed(2)}ms) — it may never be sampled.`,
467
+ ...at
468
+ });
469
+ }
470
+ return issues;
471
+ }
472
+ /** The families NodeTool ships, for the `font_not_portable` message. */
473
+ const BUNDLED_FONT_GRAMMAR = BUNDLED_FONT_FAMILIES.join(", ");
474
+ /**
475
+ * A text or caption style naming a family this build does not ship (D8).
476
+ *
477
+ * A warning, because the picture is drawn either way — the family list ends in
478
+ * a generic, so text appears. What is lost is that it is the *same* picture:
479
+ * a system font resolves against whatever the machine drawing the frame has,
480
+ * so the editor preview, a server render and the agent's frame preview each
481
+ * pick their own face, and the difference only shows when two of them are
482
+ * compared. That is F15, and naming the family here is the cheap half of the
483
+ * fix.
484
+ */
485
+ function fontPortabilityIssues(clip) {
486
+ const issues = [];
487
+ const at = { clipId: clip.id, trackId: clip.trackId };
488
+ const declared = [
489
+ ["textStyle.fontFamily", clip.textStyle?.fontFamily],
490
+ ["caption.style.fontFamily", clip.caption?.style?.fontFamily]
491
+ ];
492
+ for (const [path, family] of declared) {
493
+ if (family === undefined || family.trim() === "")
494
+ continue;
495
+ if (resolveFontFamily(family).portable)
496
+ continue;
497
+ issues.push({
498
+ severity: "warning",
499
+ code: "font_not_portable",
500
+ message: `Clip "${clipLabel(clip)}" is set in "${family}", which NodeTool does not ship — every host resolves it against its own installed fonts, so the editor preview and the render can differ. Bundled families: ${BUNDLED_FONT_GRAMMAR}.`,
501
+ path,
502
+ ...at
503
+ });
504
+ }
505
+ return issues;
506
+ }
507
+ /**
508
+ * A crop whose insets keep no picture.
509
+ *
510
+ * `left + right >= 1` on either axis leaves nothing to draw. Both compositors
511
+ * fall back to the whole source rather than blanking the shot — a slider
512
+ * dragged to the end should show the uncropped picture, not a hole — so this is
513
+ * a warning about a framing that is being ignored, not an error.
514
+ */
515
+ function cropIssues(clip) {
516
+ const crop = clip.crop;
517
+ if (!crop || isCropUsable(crop))
518
+ return [];
519
+ return [
520
+ {
521
+ severity: "warning",
522
+ code: "crop_degenerate",
523
+ message: `Clip "${clipLabel(clip)}" is cropped to nothing (left ${crop.left} + ` +
524
+ `right ${crop.right}, top ${crop.top} + bottom ${crop.bottom}); each ` +
525
+ "pair must stay below 1. The clip draws its whole source instead.",
526
+ path: "crop",
527
+ clipId: clip.id,
528
+ trackId: clip.trackId
529
+ }
530
+ ];
531
+ }
532
+ /** What an effect's `type` accepts, for the `unknown_effect` message. */
533
+ const EFFECT_GRAMMAR = CLIP_EFFECT_TYPES.join(", ");
534
+ /**
535
+ * An effect type this build does not apply (D7).
536
+ *
537
+ * `type` is a plain string on the wire (I2), so an effect a newer build
538
+ * authored parses and reaches the renderer, which steps over it. A warning
539
+ * rather than an error: the layer draws ungraded, which is a different picture
540
+ * and not a broken one. Canvas 2D reports the same set at render time through
541
+ * `unsupportedEffectTypes`; this is the half that answers before anything runs.
542
+ */
543
+ function unknownEffectIssues(clip) {
544
+ const issues = [];
545
+ for (const [index, effect] of (clip.effects ?? []).entries()) {
546
+ if (parseClipEffectType(effect.type) !== null)
547
+ continue;
548
+ issues.push({
549
+ severity: "warning",
550
+ code: "unknown_effect",
551
+ message: `Clip "${clipLabel(clip)}" carries a "${effect.type}" effect, which this build cannot apply — the layer draws without it. Expected one of ${EFFECT_GRAMMAR}.`,
552
+ path: `effects[${index}].type`,
553
+ clipId: clip.id,
554
+ trackId: clip.trackId
555
+ });
556
+ }
557
+ return issues;
558
+ }
559
+ /**
560
+ * Generator and stylize modes whose shader never reads `time`, the value
561
+ * `animate` stamps each frame (`filters.visualFx@1` in `@nodetool-ai/gpu`).
562
+ * `gradientWipe` is listed apart: it reads `time` as its map selector, so the
563
+ * stamped seconds switch it from linear to radial to noise.
564
+ */
565
+ const CLOCKLESS_MODES = new Set([
566
+ "generator:conicGradient", "generator:gridPattern",
567
+ "stylize:rgbSplit", "stylize:radialBlur", "stylize:zoomBlur", "stylize:halftone",
568
+ "stylize:dither", "stylize:lightRays", "stylize:lensFlare", "stylize:innerShadow",
569
+ "stylize:innerGlow", "stylize:edgeHighlight"
570
+ ]);
571
+ /** An `animate` flag that moves nothing, or that breaks a gradient wipe. */
572
+ function animateIgnoredIssues(clip) {
573
+ const issues = [];
574
+ for (const [index, effect] of (clip.effects ?? []).entries()) {
575
+ if (effect.type !== "generator" && effect.type !== "stylize")
576
+ continue;
577
+ if (effect.enabled === false || effect.animate !== true)
578
+ continue;
579
+ const key = `${effect.type}:${effect.mode}`;
580
+ let consequence = null;
581
+ if (key === "stylize:gradientWipe") {
582
+ consequence = "reads `time` as the value that selects the map, so the stamped clock switches it from linear to radial to noise. Set `time` instead and turn `animate` off";
583
+ }
584
+ else if (CLOCKLESS_MODES.has(key)) {
585
+ consequence = `never reads the clock \`animate\` drives, so it stays still. Animate \`effect.${effect.id}.${effect.mode === "conicGradient" ? "angle" : "amount"}\` with a style track instead`;
586
+ }
587
+ if (!consequence)
588
+ continue;
589
+ issues.push({
590
+ severity: "warning",
591
+ code: "effect_animate_ignored",
592
+ message: `Clip "${clipLabel(clip)}" sets animate on its ${effect.mode} ${effect.type}, which ${consequence}.`,
593
+ path: `effects[${index}].animate`,
594
+ clipId: clip.id,
595
+ trackId: clip.trackId
596
+ });
597
+ }
598
+ return issues;
599
+ }
600
+ /** What a shape's `kind` accepts, for the `unknown_shape_kind` message. */
601
+ const SHAPE_KIND_GRAMMAR = CLIP_SHAPE_KINDS.join(", ");
602
+ /**
603
+ * A shape geometry this build cannot draw.
604
+ *
605
+ * `kind` is a plain string on the wire (I2), so a shape a newer build authored
606
+ * parses and reaches the renderer, which builds no outline for it — the clip
607
+ * draws nothing. A warning rather than an error: the rest of the document
608
+ * plays, and the alternative is refusing the whole timeline over one shape.
609
+ */
610
+ function unknownShapeKindIssue(clip) {
611
+ const kind = clip.shapeStyle?.kind;
612
+ if (kind === undefined || isKnownShapeKind(kind))
613
+ return null;
614
+ return {
615
+ severity: "warning",
616
+ code: "unknown_shape_kind",
617
+ message: `Clip "${clipLabel(clip)}" draws a "${kind}" shape, which this build has no geometry for — it draws nothing. Expected one of ${SHAPE_KIND_GRAMMAR}.`,
618
+ path: "shapeStyle.kind",
619
+ clipId: clip.id,
620
+ trackId: clip.trackId
621
+ };
622
+ }
623
+ /**
624
+ * A 3D clip that cannot draw, and a bake that no longer matches what it would.
625
+ *
626
+ * The style and the glTF are both errors: the style names the camera, the
627
+ * lighting and the animation a 3D layer renders from, and the glTF is the
628
+ * clip's asset the way an image is an image clip's — without either the clip
629
+ * draws nothing at all, and nothing else in the document compensates.
630
+ *
631
+ * A stale bake is a warning instead: the scene model falls back to the live 3D
632
+ * layer, so what plays is the intended picture at proxy quality rather than a
633
+ * render of a style the clip no longer has.
634
+ */
635
+ function model3dIssues(clip, sequence) {
636
+ if (clip.mediaType !== "model3d")
637
+ return [];
638
+ const issues = [];
639
+ const at = { clipId: clip.id, trackId: clip.trackId };
640
+ const label = clipLabel(clip);
641
+ if (!clip.model3dStyle) {
642
+ issues.push({
643
+ severity: "error",
644
+ code: "model3d_style_missing",
645
+ message: `Clip "${label}" is a 3D clip with no model3dStyle — nothing names its camera, lighting or animation, so it draws nothing.`,
646
+ path: "model3dStyle",
647
+ ...at
648
+ });
649
+ }
650
+ if (!clip.currentAssetId) {
651
+ issues.push({
652
+ severity: "error",
653
+ code: "model3d_style_missing",
654
+ message: `Clip "${label}" is a 3D clip with no currentAssetId — the glTF a 3D clip draws is its asset, the way an image is an image clip's.`,
655
+ path: "currentAssetId",
656
+ ...at
657
+ });
658
+ }
659
+ const bake = clip.model3dStyle?.bake;
660
+ if (bake && bake.dependencyHash !== computeModel3DBakeHash(clip, sequence)) {
661
+ issues.push({
662
+ severity: "warning",
663
+ code: "bake_stale",
664
+ message: `Clip "${label}" carries a bake rendered from a style it no longer has — the live 3D layer draws instead. Re-bake it.`,
665
+ path: "model3dStyle.bake.dependencyHash",
666
+ ...at
667
+ });
668
+ }
669
+ return issues;
670
+ }
671
+ /** What a mask's `kind` accepts, for the `mask_path_invalid` message. */
672
+ const MASK_KIND_GRAMMAR = MASK_KINDS.join(", ");
673
+ /**
674
+ * A mask this build cannot rasterize (D6) — a warning, because the layer draws
675
+ * unmasked rather than not at all.
676
+ *
677
+ * Two ways to get there, and the message names which. `kind` is a plain string
678
+ * on the wire (I2), so a mask shape a newer build authored parses and reaches
679
+ * the renderer, which skips it. And a `path` mask's `d` is only ever read at
680
+ * render time, where a typo in the path data is a mask that quietly does
681
+ * nothing.
682
+ */
683
+ function maskIssues(clip) {
684
+ const mask = clip.mask;
685
+ if (!mask)
686
+ return [];
687
+ const at = { clipId: clip.id, trackId: clip.trackId };
688
+ if (!MASK_KINDS.includes(mask.kind)) {
689
+ return [
690
+ {
691
+ severity: "warning",
692
+ code: "mask_path_invalid",
693
+ message: `Clip "${clipLabel(clip)}" is masked with kind "${mask.kind}", which this build cannot rasterize — it draws unmasked. Expected one of ${MASK_KIND_GRAMMAR}.`,
694
+ path: "mask.kind",
695
+ ...at
696
+ }
697
+ ];
698
+ }
699
+ if (mask.kind !== "path")
700
+ return [];
701
+ const parsed = parseSvgPath(mask.d ?? "");
702
+ if (parsed.ok)
703
+ return [];
704
+ return [
705
+ {
706
+ severity: "warning",
707
+ code: "mask_path_invalid",
708
+ message: `Clip "${clipLabel(clip)}" has a path mask this build cannot draw — it draws unmasked. ${parsed.error}.`,
709
+ path: "mask.d",
710
+ ...at
711
+ }
712
+ ];
713
+ }
714
+ /**
715
+ * Track mattes (D6). A `matte` must name a clip the document contains, and not
716
+ * itself — a matte source never draws, so a clip matted by itself resolves to
717
+ * nothing at all.
718
+ *
719
+ * Both are errors rather than warnings because they cost the whole point of
720
+ * the field without failing anything: a missing source draws the layer
721
+ * unmatted, so a keyhole meant to reveal one shape shows the entire picture.
722
+ */
723
+ function checkMattes(doc) {
724
+ const issues = [];
725
+ const ids = new Set(doc.clips.map((clip) => clip.id));
726
+ for (const clip of doc.clips) {
727
+ const matte = clip.matte;
728
+ if (!matte)
729
+ continue;
730
+ const at = { clipId: clip.id, trackId: clip.trackId };
731
+ if (!ids.has(matte.sourceClipId)) {
732
+ issues.push({
733
+ severity: "error",
734
+ code: "matte_source_missing",
735
+ message: `Clip "${clipLabel(clip)}" is matted by "${matte.sourceClipId}", which the document does not contain — it draws unmatted.`,
736
+ path: "matte.sourceClipId",
737
+ ...at
738
+ });
739
+ continue;
740
+ }
741
+ if (matte.sourceClipId === clip.id) {
742
+ issues.push({
743
+ severity: "error",
744
+ code: "matte_source_missing",
745
+ message: `Clip "${clipLabel(clip)}" names itself as its matte source — a matte source never draws itself, so the clip resolves to nothing and disappears.`,
746
+ path: "matte.sourceClipId",
747
+ ...at
748
+ });
749
+ }
750
+ }
751
+ return issues;
752
+ }
753
+ /**
754
+ * A matte whose source names an adjustment clip.
755
+ *
756
+ * An adjustment clip draws no picture of its own — `attachMattes`
757
+ * (`render/sceneModel.ts`) treats that the same as a source clip the document
758
+ * does not contain: the layer draws unmatted and, unlike a genuinely missing
759
+ * or inactive source, nothing is logged at render time. A warning, because the
760
+ * document still renders — just not the cutout the author asked for.
761
+ */
762
+ function checkMatteSourceAdjustment(doc) {
763
+ const issues = [];
764
+ const byId = new Map(doc.clips.map((clip) => [clip.id, clip]));
765
+ for (const clip of doc.clips) {
766
+ const matte = clip.matte;
767
+ if (!matte)
768
+ continue;
769
+ const source = byId.get(matte.sourceClipId);
770
+ if (!source || source.mediaType !== "adjustment")
771
+ continue;
772
+ issues.push({
773
+ severity: "warning",
774
+ code: "matte_source_invalid",
775
+ message: `Clip "${clipLabel(clip)}" is matted by "${clipLabel(source)}", which is an adjustment clip — an adjustment has no pixels of its own, so the layer draws unmatted.`,
776
+ path: "matte.sourceClipId",
777
+ clipId: clip.id,
778
+ trackId: clip.trackId
779
+ });
780
+ }
781
+ return issues;
782
+ }
783
+ /**
784
+ * Fields an adjustment clip carries that the scene model ignores on one (D-adj):
785
+ * `transform`, `borderRadius`, `blendMode` and `matte` all place or shape a
786
+ * picture the clip does not draw, and `effects` with nothing enabled leaves
787
+ * the clip a no-op even though it still costs a resolve. Both are warnings —
788
+ * the document still renders, just not differently for the field carried.
789
+ */
790
+ function checkAdjustmentEffects(doc) {
791
+ const issues = [];
792
+ for (const clip of doc.clips) {
793
+ if (clip.mediaType !== "adjustment")
794
+ continue;
795
+ const at = { clipId: clip.id, trackId: clip.trackId };
796
+ const label = clipLabel(clip);
797
+ if (!(clip.effects ?? []).some((effect) => effect.enabled)) {
798
+ issues.push({
799
+ severity: "warning",
800
+ code: "adjustment_no_effects",
801
+ message: `Adjustment clip "${label}" carries no enabled effect — it treats nothing beneath it and is a no-op.`,
802
+ ...at
803
+ });
804
+ }
805
+ const ignoredFields = [];
806
+ if (clip.transform !== undefined)
807
+ ignoredFields.push("transform");
808
+ if (clip.borderRadius !== undefined)
809
+ ignoredFields.push("borderRadius");
810
+ if (clip.blendMode !== undefined)
811
+ ignoredFields.push("blendMode");
812
+ if (clip.matte !== undefined)
813
+ ignoredFields.push("matte");
814
+ if (ignoredFields.length > 0) {
815
+ issues.push({
816
+ severity: "warning",
817
+ code: "adjustment_field_ignored",
818
+ message: `Adjustment clip "${label}" carries ${ignoredFields.join(", ")} — an adjustment draws no picture of its own, so ${ignoredFields.length > 1 ? "those fields go" : "that field goes"} unused.`,
819
+ path: ignoredFields[0],
820
+ ...at
821
+ });
822
+ }
823
+ }
824
+ return issues;
825
+ }
826
+ /**
827
+ * An adjustment clip whose window has nothing to treat (D-adj).
828
+ *
829
+ * Unparented, it treats every track with a *higher* index than its own —
830
+ * index 0 draws on top, so a higher index is further down the stack
831
+ * (`render/sceneModel.ts` § z-order) — so the check looks for any clip on
832
+ * such a track overlapping its window; audio and midi tracks never composite
833
+ * and are excluded the same way `checkDocumentLevel`'s z-order check excludes
834
+ * them. Parented to a group, it treats that group's own composited surface
835
+ * instead (`groupNeedsPrecomposite`), so the check looks at siblings sharing
836
+ * the same `parentId` rather than at tracks. Both are warnings: the clip still
837
+ * resolves, it simply never has anything under it to change.
838
+ */
839
+ function checkAdjustmentTargets(doc) {
840
+ const issues = [];
841
+ const trackById = new Map(doc.tracks.map((track) => [track.id, track]));
842
+ const clipById = new Map(doc.clips.map((clip) => [clip.id, clip]));
843
+ const siblingsByParent = new Map();
844
+ for (const clip of doc.clips) {
845
+ if (clip.parentId === undefined)
846
+ continue;
847
+ const list = siblingsByParent.get(clip.parentId);
848
+ if (list)
849
+ list.push(clip);
850
+ else
851
+ siblingsByParent.set(clip.parentId, [clip]);
852
+ }
853
+ const overlaps = (a, b) => b.startMs < a.startMs + a.durationMs && b.startMs + b.durationMs > a.startMs;
854
+ for (const clip of doc.clips) {
855
+ if (clip.mediaType !== "adjustment")
856
+ continue;
857
+ const at = { clipId: clip.id, trackId: clip.trackId };
858
+ const label = clipLabel(clip);
859
+ if (clip.parentId !== undefined) {
860
+ const parent = clipById.get(clip.parentId);
861
+ if (!parent || parent.mediaType !== "group")
862
+ continue; // reported by checkParents
863
+ const siblings = siblingsByParent.get(clip.parentId) ?? [];
864
+ const treatsSomething = siblings.some((sibling) => sibling.id !== clip.id && overlaps(clip, sibling));
865
+ if (!treatsSomething) {
866
+ issues.push({
867
+ severity: "warning",
868
+ code: "adjustment_group_empty",
869
+ message: `Adjustment clip "${label}" is parented to group "${clipLabel(parent)}", but no other clip in that group overlaps its window — it treats nothing.`,
870
+ ...at
871
+ });
872
+ }
873
+ continue;
874
+ }
875
+ const ownTrack = trackById.get(clip.trackId);
876
+ if (!ownTrack)
877
+ continue; // reported by checkClip as clip_track_missing
878
+ const treatsSomething = doc.clips.some((other) => {
879
+ if (other.id === clip.id)
880
+ return false;
881
+ const otherTrack = trackById.get(other.trackId);
882
+ if (!otherTrack)
883
+ return false;
884
+ if (otherTrack.type === "audio" || otherTrack.type === "midi")
885
+ return false;
886
+ return otherTrack.index > ownTrack.index && overlaps(clip, other);
887
+ });
888
+ if (!treatsSomething) {
889
+ issues.push({
890
+ severity: "warning",
891
+ code: "adjustment_treats_nothing",
892
+ message: `Adjustment clip "${label}" has no clip on a lower track (a higher track index) overlapping its window — it treats nothing.`,
893
+ ...at
894
+ });
895
+ }
896
+ }
897
+ return issues;
898
+ }
899
+ /**
900
+ * Generated mattes (D2). A matte cut from the clip's own source stays aligned
901
+ * through any edit, so the only thing that can go wrong is that it no longer
902
+ * describes what the clip shows: the asset was regenerated under it, or a trim
903
+ * or a speed change now reaches source the generation never covered.
904
+ *
905
+ * A warning rather than an error: the clip still renders, keyed by the matte it
906
+ * has — over the wrong picture, or with the uncovered stretch keyed by whatever
907
+ * the mask video's last frame holds. The fix is a regenerate, which costs a
908
+ * provider call, so this reports rather than blocks.
909
+ *
910
+ * `isGeneratedMatteStale` is the same predicate the editor and the compositor
911
+ * ask, so a document that validates clean cannot be shown as stale in the UI.
912
+ */
913
+ function checkGeneratedMattes(doc) {
914
+ const issues = [];
915
+ for (const clip of doc.clips) {
916
+ const matte = clip.generatedMatte;
917
+ if (!matte || !isGeneratedMatteStale(clip))
918
+ continue;
919
+ const reason = matte.sourceAssetId === clip.currentAssetId
920
+ ? `its window now covers ${Math.round(clipSourceWindow(clip).fromMs)}–${Math.round(clipSourceWindow(clip).toMs)}ms of the source and the matte was cut from ${Math.round(matte.sourceRange.fromMs)}–${Math.round(matte.sourceRange.toMs)}ms`
921
+ : `it was cut from asset "${matte.sourceAssetId}" and the clip now plays "${clip.currentAssetId ?? "none"}"`;
922
+ issues.push({
923
+ severity: "warning",
924
+ code: "generated_matte_stale",
925
+ message: `Clip "${clipLabel(clip)}" has a generated matte that no longer matches what it plays — ${reason}. Regenerate it.`,
926
+ path: "generatedMatte",
927
+ clipId: clip.id,
928
+ trackId: clip.trackId
929
+ });
930
+ }
931
+ return issues;
932
+ }
933
+ /** Report automatic framing whose analysis no longer matches the active source. */
934
+ function checkReframes(doc) {
935
+ const issues = [];
936
+ const mediaTracks = doc.mediaTracks ?? [];
937
+ for (const clip of doc.clips) {
938
+ if (!clip.reframe)
939
+ continue;
940
+ const track = clip.reframe.trackId
941
+ ? mediaTracks.find((candidate) => candidate.id === clip.reframe?.trackId)
942
+ : undefined;
943
+ if (!isReframeStale(clip, track))
944
+ continue;
945
+ issues.push({
946
+ severity: "warning",
947
+ code: "reframe_stale",
948
+ message: `Clip "${clipLabel(clip)}" has automatic framing that no longer matches its active source. Rendering falls back to centred framing until it is regenerated.`,
949
+ path: "reframe",
950
+ clipId: clip.id,
951
+ trackId: clip.trackId
952
+ });
953
+ }
954
+ return issues;
955
+ }
956
+ /**
957
+ * Parent links (D4). A `parentId` must name a clip the document contains, that
958
+ * clip must be a group, and the chain must reach a root.
959
+ *
960
+ * The first two are warnings and the cycle is an error, which is the split the
961
+ * rest of this catalog uses: a child whose parent is missing or is not a group
962
+ * renders unparented — the wrong picture, from a document a newer build could
963
+ * have meant (I2) — while a cycle is a document that cannot be resolved at all,
964
+ * and no read of it produces the scene its author described.
965
+ */
966
+ function checkParents(doc) {
967
+ const issues = [];
968
+ const byId = new Map(doc.clips.map((clip) => [clip.id, clip]));
969
+ /** Chains already walked, so a shared ancestor is not re-walked per child. */
970
+ const settled = new Map();
971
+ for (const clip of doc.clips) {
972
+ const parentId = clip.parentId;
973
+ if (parentId === undefined)
974
+ continue;
975
+ const at = { clipId: clip.id, trackId: clip.trackId, path: "parentId" };
976
+ const parent = byId.get(parentId);
977
+ if (!parent) {
978
+ issues.push({
979
+ severity: "warning",
980
+ code: "parent_missing",
981
+ message: `Clip "${clipLabel(clip)}" names parent "${parentId}", which the document does not contain — it renders unparented.`,
982
+ ...at
983
+ });
984
+ continue;
985
+ }
986
+ if (parent.mediaType !== "group") {
987
+ issues.push({
988
+ severity: "warning",
989
+ code: "parent_not_group",
990
+ message: `Clip "${clipLabel(clip)}" names parent "${clipLabel(parent)}", whose mediaType is "${parent.mediaType}" — only a clip with mediaType "group" can be a transform parent.`,
991
+ ...at
992
+ });
993
+ continue;
994
+ }
995
+ const walked = [];
996
+ const seen = new Set();
997
+ let cursor = clip;
998
+ let loopedAt;
999
+ while (cursor) {
1000
+ if (seen.has(cursor.id)) {
1001
+ loopedAt = cursor.id;
1002
+ break;
1003
+ }
1004
+ const known = settled.get(cursor.id);
1005
+ if (known === "rooted")
1006
+ break;
1007
+ if (known === "cyclic") {
1008
+ loopedAt = cursor.id;
1009
+ break;
1010
+ }
1011
+ seen.add(cursor.id);
1012
+ walked.push(cursor.id);
1013
+ cursor = cursor.parentId ? byId.get(cursor.parentId) : undefined;
1014
+ }
1015
+ for (const id of walked) {
1016
+ settled.set(id, loopedAt === undefined ? "rooted" : "cyclic");
1017
+ }
1018
+ if (loopedAt !== undefined) {
1019
+ issues.push({
1020
+ severity: "error",
1021
+ code: "parent_cycle",
1022
+ message: `Clip "${clipLabel(clip)}" has a parent chain that loops back to "${loopedAt}" — the chain is refused, so it renders unparented.`,
1023
+ ...at
1024
+ });
1025
+ }
1026
+ }
1027
+ return issues;
1028
+ }
1029
+ /**
1030
+ * What an overlap does at render, which is the half the warning used to leave
1031
+ * out.
1032
+ *
1033
+ * Two clips overlapping on one track cross-fade: with no `transitionIn` on the
1034
+ * later clip the renderer auto-cross-fades over exactly the overlap
1035
+ * (`resolveTransition`). That is what a picture track wants and what an overlay
1036
+ * track almost never does — two titles meant to be on screen together dissolve
1037
+ * into each other instead, and nobody asked for that transition. Concurrently
1038
+ * visible overlays belong on separate tracks.
1039
+ */
1040
+ function overlapConsequence(later, trackType) {
1041
+ if (later.transitionIn) {
1042
+ return `That window is the ${later.transitionIn.type} transition authored on "${clipLabel(later)}".`;
1043
+ }
1044
+ if (trackType === "midi") {
1045
+ // Nothing cross-fades notes: both phrases sound, and their notes sum.
1046
+ return "Both phrases play over the overlap and their notes sum — put one on its own midi track if that is not the chord you meant.";
1047
+ }
1048
+ const auto = "With no transition authored on the later clip the renderer auto-cross-fades over the overlap.";
1049
+ return trackType === "overlay" || trackType === "subtitle"
1050
+ ? `${auto} Two overlays meant to be visible at once belong on separate tracks — put one on its own track (add_track) instead of overlapping them here.`
1051
+ : auto;
1052
+ }
1053
+ /**
1054
+ * Where a clip's last repeater copy ends. `expandTemporalClips` starts each
1055
+ * copy `timeStepMs` after the one before and keeps the original's duration,
1056
+ * so the copies reach past the clip. A parent group's window still clips them.
1057
+ */
1058
+ function repeaterReachMs(clip, byId) {
1059
+ const end = clip.startMs + Math.max(0, clip.durationMs);
1060
+ const repeater = clip.repeater;
1061
+ if (!repeater || !(repeater.timeStepMs > 0))
1062
+ return end;
1063
+ const count = Math.max(1, Math.min(128, Math.floor(repeater.count)));
1064
+ let reach = end + (count - 1) * repeater.timeStepMs;
1065
+ const parent = clip.parentId ? byId.get(clip.parentId) : undefined;
1066
+ if (parent)
1067
+ reach = Math.min(reach, parent.startMs + Math.max(0, parent.durationMs));
1068
+ return Math.max(end, reach);
1069
+ }
1070
+ function checkOverlaps(doc) {
1071
+ const issues = [];
1072
+ const trackTypes = new Map(doc.tracks.map((t) => [t.id, t.type]));
1073
+ const byTrack = new Map();
1074
+ for (const clip of doc.clips) {
1075
+ const list = byTrack.get(clip.trackId);
1076
+ if (list)
1077
+ list.push(clip);
1078
+ else
1079
+ byTrack.set(clip.trackId, [clip]);
1080
+ }
1081
+ const byId = new Map(doc.clips.map((clip) => [clip.id, clip]));
1082
+ for (const [trackId, clips] of byTrack) {
1083
+ const ordered = [...clips].sort((a, b) => a.startMs - b.startMs);
1084
+ for (let i = 0; i < ordered.length - 1; i += 1) {
1085
+ const current = ordered[i];
1086
+ const end = current.startMs + Math.max(0, current.durationMs);
1087
+ const reach = repeaterReachMs(current, byId);
1088
+ for (let j = i + 1; j < ordered.length; j += 1) {
1089
+ const next = ordered[j];
1090
+ if (next.startMs >= reach)
1091
+ break;
1092
+ const copies = next.startMs >= end
1093
+ ? ` Its repeater copies run to ${reach}ms, each starting a step later with the full duration.`
1094
+ : "";
1095
+ issues.push({
1096
+ severity: "warning",
1097
+ code: "clips_overlap",
1098
+ message: `Clips "${clipLabel(current)}" (${current.startMs}–${end}ms) and "${clipLabel(next)}" (${next.startMs}–${next.startMs + next.durationMs}ms) overlap on track "${trackId}".${copies} ${overlapConsequence(next, trackTypes.get(trackId))}`,
1099
+ trackId,
1100
+ clipId: current.id
1101
+ });
1102
+ }
1103
+ }
1104
+ }
1105
+ return issues;
1106
+ }
1107
+ /**
1108
+ * Instants where more video clips overlap than the compositor will draw.
1109
+ *
1110
+ * The cap is applied while resolving a frame, so the clips past it never reach
1111
+ * the picture. Sampling frames to find that would miss an overlap shorter than
1112
+ * the sample interval, so this is a sweep over the clip windows themselves:
1113
+ * every start raises the count and every end lowers it, ends first at a shared
1114
+ * instant because a clip is active over `[startMs, startMs + durationMs)`. The
1115
+ * count is therefore exact at every boundary, and the peak between boundaries
1116
+ * is one of them.
1117
+ *
1118
+ * What counts is what the scene model would count: a clip on a visible video or
1119
+ * overlay track whose media is picture the compositor holds in its video pool.
1120
+ * Images are not capped, and audio, text, shape and group clips draw through
1121
+ * other paths.
1122
+ */
1123
+ function checkVideoLayerCap(doc) {
1124
+ const cappedTrackIds = new Set(doc.tracks
1125
+ .filter((track) => track.visible && (track.type === "video" || track.type === "overlay"))
1126
+ .map((track) => track.id));
1127
+ const capped = doc.clips.filter((clip) => cappedTrackIds.has(clip.trackId) &&
1128
+ (clip.mediaType === "video" || clip.mediaType === "overlay") &&
1129
+ clip.durationMs > 0);
1130
+ // -1 before +1 at one instant, so a clip ending where another starts does
1131
+ // not read as two simultaneous layers.
1132
+ const events = capped.flatMap((clip) => [
1133
+ { timeMs: clip.startMs, delta: 1 },
1134
+ { timeMs: clip.startMs + clip.durationMs, delta: -1 }
1135
+ ]);
1136
+ events.sort((a, b) => a.timeMs - b.timeMs || a.delta - b.delta);
1137
+ let open = 0;
1138
+ let peak = 0;
1139
+ let peakAtMs = 0;
1140
+ for (const event of events) {
1141
+ open += event.delta;
1142
+ if (open > peak) {
1143
+ peak = open;
1144
+ peakAtMs = event.timeMs;
1145
+ }
1146
+ }
1147
+ if (peak <= MAX_VIDEO_LAYERS)
1148
+ return [];
1149
+ return [
1150
+ {
1151
+ severity: "warning",
1152
+ code: "layer_cap_exceeded",
1153
+ message: `${peak} video clips overlap at ${peakAtMs}ms — the compositor draws ${MAX_VIDEO_LAYERS} and silently discards the rest, keeping the ones on the topmost tracks.`
1154
+ }
1155
+ ];
1156
+ }
1157
+ /**
1158
+ * MIDI: what a played part cannot survive.
1159
+ *
1160
+ * A midi clip is notes plus a window over them, and the track owns the synth —
1161
+ * so the two placement rules are errors: a midi clip anywhere but a midi track
1162
+ * has no instrument to sound it, and a non-midi clip on a midi track has no
1163
+ * picture path and no audio path either. Both are silence, not a different
1164
+ * picture, which is what separates them from the warnings below.
1165
+ *
1166
+ * `midi_clip_retimed` is the same class: `speedMultiplier`, `speedBaked` and
1167
+ * `timeRemap` all retime *source samples*, and a midi clip has none — its
1168
+ * timing comes from ticks read against the tempo. A retimed midi clip is a
1169
+ * document no reader can honour, so it is reported rather than silently
1170
+ * ignored at render.
1171
+ *
1172
+ * The two warnings are recoverable: a clip whose window plays none of its
1173
+ * notes still loads (it just makes no sound), and a document with midi and no
1174
+ * `tempo` is read at {@link DEFAULT_TEMPO} — right until someone stores a
1175
+ * different one and every phrase moves.
1176
+ */
1177
+ function checkMidi(doc) {
1178
+ const issues = [];
1179
+ const trackTypes = new Map(doc.tracks.map((track) => [track.id, track.type]));
1180
+ const bpm = resolveTempo(doc).bpm;
1181
+ let sawMidi = doc.tracks.some((track) => track.type === "midi");
1182
+ for (const clip of doc.clips) {
1183
+ const trackType = trackTypes.get(clip.trackId);
1184
+ const at = { clipId: clip.id, trackId: clip.trackId };
1185
+ const label = clipLabel(clip);
1186
+ if (clip.mediaType === "midi") {
1187
+ sawMidi = true;
1188
+ // An unknown track is already `clip_track_missing`; saying it twice
1189
+ // helps nobody.
1190
+ if (trackType !== undefined && trackType !== "midi") {
1191
+ issues.push({
1192
+ severity: "error",
1193
+ code: "midi_clip_off_midi_track",
1194
+ message: `Clip "${label}" carries notes but sits on a ${trackType} track — only a midi track has an instrument to play them, so it makes no sound. Move it to a midi track (add_track type "midi").`,
1195
+ path: "trackId",
1196
+ ...at
1197
+ });
1198
+ }
1199
+ }
1200
+ else if (trackType === "midi") {
1201
+ issues.push({
1202
+ severity: "error",
1203
+ code: "non_midi_clip_on_midi_track",
1204
+ message: `Clip "${label}" is a ${clip.mediaType} clip on a midi track — a midi track plays notes through its instrument and draws no picture, so this clip is neither heard nor seen.`,
1205
+ path: "trackId",
1206
+ ...at
1207
+ });
1208
+ }
1209
+ // Checked wherever notes are stored: a list is unplayable for the same
1210
+ // reasons whatever kind of clip is carrying it.
1211
+ for (const problem of validateNotes(clip.notes ?? [])) {
1212
+ issues.push({
1213
+ severity: "error",
1214
+ code: "midi_notes_invalid",
1215
+ message: `Clip "${label}"${problem.noteId ? ` note "${problem.noteId}"` : ""}: ${problem.message}`,
1216
+ path: problem.index === undefined ? "notes" : `notes[${problem.index}]`,
1217
+ ...at
1218
+ });
1219
+ }
1220
+ if (clip.mediaType !== "midi")
1221
+ continue;
1222
+ const retimed = [];
1223
+ if (clip.speedMultiplier !== undefined && clip.speedMultiplier !== 1) {
1224
+ retimed.push(`speedMultiplier ${clip.speedMultiplier}`);
1225
+ }
1226
+ if (clip.speedBaked)
1227
+ retimed.push("speedBaked");
1228
+ if (clip.timeRemap)
1229
+ retimed.push("timeRemap");
1230
+ if (retimed.length > 0) {
1231
+ issues.push({
1232
+ severity: "error",
1233
+ code: "midi_clip_retimed",
1234
+ message: `Clip "${label}" carries ${retimed.join(" and ")} — those retime source samples, and a midi clip has none: its timing is ticks read against the document tempo. Change the tempo with set_tempo instead.`,
1235
+ path: "speedMultiplier",
1236
+ ...at
1237
+ });
1238
+ }
1239
+ const carried = clip.notes?.length ?? 0;
1240
+ if (visibleNotes(clip, bpm).length === 0) {
1241
+ const windowStart = clip.inPointMs ?? 0;
1242
+ issues.push({
1243
+ severity: "warning",
1244
+ code: "midi_clip_silent",
1245
+ message: carried === 0
1246
+ ? `Clip "${label}" carries no notes, so it makes no sound. Give it some with set_notes, or delete it.`
1247
+ : `Clip "${label}" plays none of its ${carried} notes: not one of them starts inside the clip's window (${windowStart}–${windowStart + clip.durationMs}ms of content at ${bpm} BPM). Widen the window or move the notes.`,
1248
+ ...at
1249
+ });
1250
+ }
1251
+ }
1252
+ if (sawMidi && doc.tempo === undefined) {
1253
+ issues.push({
1254
+ severity: "warning",
1255
+ code: "midi_tempo_missing",
1256
+ message: `This document has midi in it but no \`tempo\`, so every note is read at the ${DEFAULT_TEMPO.bpm} BPM default. Store the tempo with set_tempo — the moment one is written the midi clips are rescaled to it.`,
1257
+ path: "tempo"
1258
+ });
1259
+ }
1260
+ return issues;
1261
+ }
1262
+ function checkDocumentLevel(doc) {
1263
+ const issues = [];
1264
+ for (const marker of doc.markers) {
1265
+ if (marker.timeMs < 0) {
1266
+ issues.push({
1267
+ severity: "error",
1268
+ code: "negative_timing",
1269
+ message: `Marker "${marker.label || marker.id}" sits at ${marker.timeMs}ms — before the timeline origin.`,
1270
+ path: "markers[*].timeMs"
1271
+ });
1272
+ }
1273
+ }
1274
+ // Only visual tracks composite, so only they compete for z-order. Audio
1275
+ // tracks sharing an index with anything is normal and harmless.
1276
+ const indexes = new Map();
1277
+ for (const track of doc.tracks) {
1278
+ // Neither audio nor midi composites, so neither competes for z-order.
1279
+ if (track.type === "audio" || track.type === "midi")
1280
+ continue;
1281
+ const previous = indexes.get(track.index);
1282
+ if (previous !== undefined) {
1283
+ issues.push({
1284
+ severity: "warning",
1285
+ code: "duplicate_track_index",
1286
+ message: `Visual tracks "${previous}" and "${track.id}" both claim index ${track.index} — their stacking order is undefined.`,
1287
+ trackId: track.id
1288
+ });
1289
+ }
1290
+ else {
1291
+ indexes.set(track.index, track.id);
1292
+ }
1293
+ }
1294
+ const clipIds = new Set(doc.clips.map((clip) => clip.id));
1295
+ for (const clip of doc.clips) {
1296
+ for (const link of clip.animationLinks ?? []) {
1297
+ if ("sourceClipId" in link && !clipIds.has(link.sourceClipId)) {
1298
+ issues.push({
1299
+ severity: "error",
1300
+ code: "animation_link_source_missing",
1301
+ message: `Clip "${clipLabel(clip)}" links animation to missing clip "${link.sourceClipId}".`,
1302
+ path: "animationLinks[*].sourceClipId",
1303
+ clipId: clip.id,
1304
+ trackId: clip.trackId
1305
+ });
1306
+ }
1307
+ }
1308
+ }
1309
+ for (const line of doc.transcript ?? []) {
1310
+ for (const clipId of line.clipIds) {
1311
+ if (!clipIds.has(clipId)) {
1312
+ issues.push({
1313
+ severity: "warning",
1314
+ code: "transcript_clip_missing",
1315
+ message: `Transcript line "${line.id}" owns clip "${clipId}", which the document does not contain.`,
1316
+ clipId
1317
+ });
1318
+ }
1319
+ }
1320
+ }
1321
+ // A `linkId` binds a video clip to its extracted audio; a lone one means the
1322
+ // partner was deleted and moves/trims no longer travel together.
1323
+ const linked = new Map();
1324
+ for (const clip of doc.clips) {
1325
+ if (!clip.linkId)
1326
+ continue;
1327
+ const list = linked.get(clip.linkId);
1328
+ if (list)
1329
+ list.push(clip.id);
1330
+ else
1331
+ linked.set(clip.linkId, [clip.id]);
1332
+ }
1333
+ for (const [linkId, ids] of linked) {
1334
+ if (ids.length === 1) {
1335
+ issues.push({
1336
+ severity: "warning",
1337
+ code: "link_partner_missing",
1338
+ message: `Clip "${ids[0]}" carries linkId "${linkId}" but no other clip shares it — its linked partner is gone.`,
1339
+ clipId: ids[0]
1340
+ });
1341
+ }
1342
+ }
1343
+ return issues;
1344
+ }
1345
+ /**
1346
+ * Zod issues as the paths and messages a reader can act on.
1347
+ *
1348
+ * `transitionIn` and every `effects[]` entry are unions (I2: a type from a
1349
+ * newer build parses through a permissive branch). A union reports one
1350
+ * `invalid_union` against the object with the reason each branch failed buried
1351
+ * in `errors`, so reporting it verbatim would say "Invalid input" about the
1352
+ * clip rather than naming the field that is wrong. Flattening puts each
1353
+ * branch's issues back on the union's own path.
1354
+ */
1355
+ function flattenSchemaIssues(issues, prefix = []) {
1356
+ const out = [];
1357
+ for (const issue of issues) {
1358
+ const path = [...prefix, ...issue.path];
1359
+ if (issue.errors === undefined) {
1360
+ out.push({
1361
+ path: path.map((p) => String(p)).join("."),
1362
+ message: issue.message
1363
+ });
1364
+ continue;
1365
+ }
1366
+ for (const branch of issue.errors) {
1367
+ out.push(...flattenSchemaIssues(branch, path));
1368
+ }
1369
+ }
1370
+ return out;
1371
+ }
1372
+ /**
1373
+ * Validate a parsed-JSON timeline document. `raw` is untrusted: anything that
1374
+ * fails the schema is reported as `schema_invalid` and the structural checks
1375
+ * are skipped, since they read fields the parse could not establish.
1376
+ */
1377
+ export function validateTimelineSequence(raw, meta) {
1378
+ const parsed = timelineDocument.safeParse(raw);
1379
+ if (!parsed.success) {
1380
+ const errors = flattenSchemaIssues(parsed.error.issues)
1381
+ .slice(0, 25)
1382
+ .map(({ path, message }) => {
1383
+ const schemaIssue = {
1384
+ severity: "error",
1385
+ code: "schema_invalid",
1386
+ message: `${path || "(root)"}: ${message}`
1387
+ };
1388
+ if (path) {
1389
+ schemaIssue.path = path;
1390
+ }
1391
+ return schemaIssue;
1392
+ });
1393
+ if (errors.length === 0) {
1394
+ errors.push({
1395
+ severity: "error",
1396
+ code: "schema_invalid",
1397
+ message: "Document does not match the timeline schema."
1398
+ });
1399
+ }
1400
+ return { ok: false, errors, warnings: [] };
1401
+ }
1402
+ const doc = parsed.data;
1403
+ const fps = meta?.fps && meta.fps > 0 ? meta.fps : DEFAULT_FPS;
1404
+ const canvas = {
1405
+ width: meta?.width && meta.width > 0 ? meta.width : DEFAULT_WIDTH,
1406
+ height: meta?.height && meta.height > 0 ? meta.height : DEFAULT_HEIGHT
1407
+ };
1408
+ const trackIds = new Set(doc.tracks.map((track) => track.id));
1409
+ const issues = [
1410
+ ...checkFieldStripping(raw, doc),
1411
+ ...checkDuplicateIds(doc),
1412
+ ...doc.clips.flatMap((clip) => checkClip(clip, trackIds, fps, canvas)),
1413
+ ...doc.clips.flatMap((clip) => checkClipMotion(clip, canvas)),
1414
+ ...checkLegibility(doc, canvas.height),
1415
+ ...checkParents(doc),
1416
+ ...checkMattes(doc),
1417
+ ...checkGeneratedMattes(doc),
1418
+ ...checkReframes(doc),
1419
+ ...checkMatteSourceAdjustment(doc),
1420
+ ...checkAdjustmentEffects(doc),
1421
+ ...checkAdjustmentTargets(doc),
1422
+ ...checkOverlaps(doc),
1423
+ ...checkVideoLayerCap(doc),
1424
+ ...checkMidi(doc),
1425
+ ...checkDocumentLevel(doc),
1426
+ ...(meta?.tier === "showcase" ? checkShowcase(doc, canvas) : [])
1427
+ ];
1428
+ const errors = issues.filter((issue) => issue.severity === "error");
1429
+ const warnings = issues.filter((issue) => issue.severity === "warning");
1430
+ return { ok: errors.length === 0, errors, warnings };
1431
+ }
1432
+ //# sourceMappingURL=validate.js.map