@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.
- package/README.md +129 -0
- package/dist/.nodetool-build-stamp +1 -0
- package/dist/app-debug/app-spec.d.ts +94 -0
- package/dist/app-debug/app-spec.d.ts.map +1 -0
- package/dist/app-debug/app-spec.js +710 -0
- package/dist/app-debug/app-spec.js.map +1 -0
- package/dist/app-debug/application-target.d.ts +53 -0
- package/dist/app-debug/application-target.d.ts.map +1 -0
- package/dist/app-debug/application-target.js +126 -0
- package/dist/app-debug/application-target.js.map +1 -0
- package/dist/app-debug/bundle-target.d.ts +21 -0
- package/dist/app-debug/bundle-target.d.ts.map +1 -0
- package/dist/app-debug/bundle-target.js +51 -0
- package/dist/app-debug/bundle-target.js.map +1 -0
- package/dist/app-debug/graph-shape.d.ts +14 -0
- package/dist/app-debug/graph-shape.d.ts.map +1 -0
- package/dist/app-debug/graph-shape.js +23 -0
- package/dist/app-debug/graph-shape.js.map +1 -0
- package/dist/app-debug/index.d.ts +26 -0
- package/dist/app-debug/index.d.ts.map +1 -0
- package/dist/app-debug/index.js +19 -0
- package/dist/app-debug/index.js.map +1 -0
- package/dist/app-debug/markdown.d.ts +6 -0
- package/dist/app-debug/markdown.d.ts.map +1 -0
- package/dist/app-debug/markdown.js +133 -0
- package/dist/app-debug/markdown.js.map +1 -0
- package/dist/app-debug/runtime.d.ts +267 -0
- package/dist/app-debug/runtime.d.ts.map +1 -0
- package/dist/app-debug/runtime.js +633 -0
- package/dist/app-debug/runtime.js.map +1 -0
- package/dist/app-debug/script-operation.d.ts +58 -0
- package/dist/app-debug/script-operation.d.ts.map +1 -0
- package/dist/app-debug/script-operation.js +44 -0
- package/dist/app-debug/script-operation.js.map +1 -0
- package/dist/app-debug/simulate.d.ts +85 -0
- package/dist/app-debug/simulate.d.ts.map +1 -0
- package/dist/app-debug/simulate.js +991 -0
- package/dist/app-debug/simulate.js.map +1 -0
- package/dist/app-debug/summarize.d.ts +64 -0
- package/dist/app-debug/summarize.d.ts.map +1 -0
- package/dist/app-debug/summarize.js +65 -0
- package/dist/app-debug/summarize.js.map +1 -0
- package/dist/app-debug/types.d.ts +343 -0
- package/dist/app-debug/types.d.ts.map +1 -0
- package/dist/app-debug/types.js +2 -0
- package/dist/app-debug/types.js.map +1 -0
- package/dist/cost-ledger.d.ts +147 -0
- package/dist/cost-ledger.d.ts.map +1 -0
- package/dist/cost-ledger.js +288 -0
- package/dist/cost-ledger.js.map +1 -0
- package/dist/debug/collector.d.ts +28 -0
- package/dist/debug/collector.d.ts.map +1 -0
- package/dist/debug/collector.js +284 -0
- package/dist/debug/collector.js.map +1 -0
- package/dist/debug/index.d.ts +15 -0
- package/dist/debug/index.d.ts.map +1 -0
- package/dist/debug/index.js +16 -0
- package/dist/debug/index.js.map +1 -0
- package/dist/debug/types.d.ts +179 -0
- package/dist/debug/types.d.ts.map +1 -0
- package/dist/debug/types.js +11 -0
- package/dist/debug/types.js.map +1 -0
- package/dist/debug/verdict.d.ts +27 -0
- package/dist/debug/verdict.d.ts.map +1 -0
- package/dist/debug/verdict.js +51 -0
- package/dist/debug/verdict.js.map +1 -0
- package/dist/executor-resolver.d.ts +13 -0
- package/dist/executor-resolver.d.ts.map +1 -0
- package/dist/executor-resolver.js +13 -0
- package/dist/executor-resolver.js.map +1 -0
- package/dist/fal-output-decoder.d.ts +36 -0
- package/dist/fal-output-decoder.d.ts.map +1 -0
- package/dist/fal-output-decoder.js +129 -0
- package/dist/fal-output-decoder.js.map +1 -0
- package/dist/generation-lifecycle.d.ts +54 -0
- package/dist/generation-lifecycle.d.ts.map +1 -0
- package/dist/generation-lifecycle.js +431 -0
- package/dist/generation-lifecycle.js.map +1 -0
- package/dist/generation-recovery-worker.d.ts +100 -0
- package/dist/generation-recovery-worker.d.ts.map +1 -0
- package/dist/generation-recovery-worker.js +995 -0
- package/dist/generation-recovery-worker.js.map +1 -0
- package/dist/generation-tracker.d.ts +83 -0
- package/dist/generation-tracker.d.ts.map +1 -0
- package/dist/generation-tracker.js +436 -0
- package/dist/generation-tracker.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/js-script-debug/index.d.ts +13 -0
- package/dist/js-script-debug/index.d.ts.map +1 -0
- package/dist/js-script-debug/index.js +12 -0
- package/dist/js-script-debug/index.js.map +1 -0
- package/dist/js-script-debug/markdown.d.ts +6 -0
- package/dist/js-script-debug/markdown.d.ts.map +1 -0
- package/dist/js-script-debug/markdown.js +62 -0
- package/dist/js-script-debug/markdown.js.map +1 -0
- package/dist/js-script-debug/outputs.d.ts +20 -0
- package/dist/js-script-debug/outputs.d.ts.map +1 -0
- package/dist/js-script-debug/outputs.js +30 -0
- package/dist/js-script-debug/outputs.js.map +1 -0
- package/dist/js-script-debug/report.d.ts +15 -0
- package/dist/js-script-debug/report.d.ts.map +1 -0
- package/dist/js-script-debug/report.js +90 -0
- package/dist/js-script-debug/report.js.map +1 -0
- package/dist/js-script-debug/types.d.ts +63 -0
- package/dist/js-script-debug/types.d.ts.map +1 -0
- package/dist/js-script-debug/types.js +10 -0
- package/dist/js-script-debug/types.js.map +1 -0
- package/dist/js-script-debug/validate.d.ts +38 -0
- package/dist/js-script-debug/validate.d.ts.map +1 -0
- package/dist/js-script-debug/validate.js +137 -0
- package/dist/js-script-debug/validate.js.map +1 -0
- package/dist/message-stream.d.ts +33 -0
- package/dist/message-stream.d.ts.map +1 -0
- package/dist/message-stream.js +58 -0
- package/dist/message-stream.js.map +1 -0
- package/dist/normalize-graph.d.ts +13 -0
- package/dist/normalize-graph.d.ts.map +1 -0
- package/dist/normalize-graph.js +63 -0
- package/dist/normalize-graph.js.map +1 -0
- package/dist/output-names.d.ts +13 -0
- package/dist/output-names.d.ts.map +1 -0
- package/dist/output-names.js +13 -0
- package/dist/output-names.js.map +1 -0
- package/dist/predicates.d.ts +6 -0
- package/dist/predicates.d.ts.map +1 -0
- package/dist/predicates.js +6 -0
- package/dist/predicates.js.map +1 -0
- package/dist/preflight.d.ts +87 -0
- package/dist/preflight.d.ts.map +1 -0
- package/dist/preflight.js +139 -0
- package/dist/preflight.js.map +1 -0
- package/dist/provider-secret-key.d.ts +3 -0
- package/dist/provider-secret-key.d.ts.map +1 -0
- package/dist/provider-secret-key.js +7 -0
- package/dist/provider-secret-key.js.map +1 -0
- package/dist/service/app-debug-service.d.ts +78 -0
- package/dist/service/app-debug-service.d.ts.map +1 -0
- package/dist/service/app-debug-service.js +201 -0
- package/dist/service/app-debug-service.js.map +1 -0
- package/dist/service/app-run-server.d.ts +28 -0
- package/dist/service/app-run-server.d.ts.map +1 -0
- package/dist/service/app-run-server.js +104 -0
- package/dist/service/app-run-server.js.map +1 -0
- package/dist/service/debug-sessions.d.ts +158 -0
- package/dist/service/debug-sessions.d.ts.map +1 -0
- package/dist/service/debug-sessions.js +358 -0
- package/dist/service/debug-sessions.js.map +1 -0
- package/dist/service/index.d.ts +22 -0
- package/dist/service/index.d.ts.map +1 -0
- package/dist/service/index.js +17 -0
- package/dist/service/index.js.map +1 -0
- package/dist/service/job-progress.d.ts +40 -0
- package/dist/service/job-progress.d.ts.map +1 -0
- package/dist/service/job-progress.js +53 -0
- package/dist/service/job-progress.js.map +1 -0
- package/dist/service/workflow-run.d.ts +279 -0
- package/dist/service/workflow-run.d.ts.map +1 -0
- package/dist/service/workflow-run.js +598 -0
- package/dist/service/workflow-run.js.map +1 -0
- package/dist/service/workflow-workspace.d.ts +89 -0
- package/dist/service/workflow-workspace.d.ts.map +1 -0
- package/dist/service/workflow-workspace.js +160 -0
- package/dist/service/workflow-workspace.js.map +1 -0
- package/dist/session.d.ts +62 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +353 -0
- package/dist/session.js.map +1 -0
- package/dist/sketch-debug/index.d.ts +11 -0
- package/dist/sketch-debug/index.d.ts.map +1 -0
- package/dist/sketch-debug/index.js +10 -0
- package/dist/sketch-debug/index.js.map +1 -0
- package/dist/sketch-debug/markdown.d.ts +6 -0
- package/dist/sketch-debug/markdown.d.ts.map +1 -0
- package/dist/sketch-debug/markdown.js +93 -0
- package/dist/sketch-debug/markdown.js.map +1 -0
- package/dist/sketch-debug/report.d.ts +15 -0
- package/dist/sketch-debug/report.d.ts.map +1 -0
- package/dist/sketch-debug/report.js +98 -0
- package/dist/sketch-debug/report.js.map +1 -0
- package/dist/sketch-debug/types.d.ts +62 -0
- package/dist/sketch-debug/types.d.ts.map +1 -0
- package/dist/sketch-debug/types.js +10 -0
- package/dist/sketch-debug/types.js.map +1 -0
- package/dist/sketch-debug/validate.d.ts +14 -0
- package/dist/sketch-debug/validate.d.ts.map +1 -0
- package/dist/sketch-debug/validate.js +478 -0
- package/dist/sketch-debug/validate.js.map +1 -0
- package/dist/supervisor.d.ts +45 -0
- package/dist/supervisor.d.ts.map +1 -0
- package/dist/supervisor.js +97 -0
- package/dist/supervisor.js.map +1 -0
- package/dist/timeline-debug/index.d.ts +11 -0
- package/dist/timeline-debug/index.d.ts.map +1 -0
- package/dist/timeline-debug/index.js +10 -0
- package/dist/timeline-debug/index.js.map +1 -0
- package/dist/timeline-debug/legibility.d.ts +23 -0
- package/dist/timeline-debug/legibility.d.ts.map +1 -0
- package/dist/timeline-debug/legibility.js +240 -0
- package/dist/timeline-debug/legibility.js.map +1 -0
- package/dist/timeline-debug/markdown.d.ts +6 -0
- package/dist/timeline-debug/markdown.d.ts.map +1 -0
- package/dist/timeline-debug/markdown.js +103 -0
- package/dist/timeline-debug/markdown.js.map +1 -0
- package/dist/timeline-debug/motion.d.ts +20 -0
- package/dist/timeline-debug/motion.d.ts.map +1 -0
- package/dist/timeline-debug/motion.js +362 -0
- package/dist/timeline-debug/motion.js.map +1 -0
- package/dist/timeline-debug/report.d.ts +16 -0
- package/dist/timeline-debug/report.d.ts.map +1 -0
- package/dist/timeline-debug/report.js +100 -0
- package/dist/timeline-debug/report.js.map +1 -0
- package/dist/timeline-debug/showcase.d.ts +7 -0
- package/dist/timeline-debug/showcase.d.ts.map +1 -0
- package/dist/timeline-debug/showcase.js +347 -0
- package/dist/timeline-debug/showcase.js.map +1 -0
- package/dist/timeline-debug/types.d.ts +64 -0
- package/dist/timeline-debug/types.d.ts.map +1 -0
- package/dist/timeline-debug/types.js +10 -0
- package/dist/timeline-debug/types.js.map +1 -0
- package/dist/timeline-debug/validate.d.ts +24 -0
- package/dist/timeline-debug/validate.d.ts.map +1 -0
- package/dist/timeline-debug/validate.js +1432 -0
- package/dist/timeline-debug/validate.js.map +1 -0
- package/dist/types.d.ts +211 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- 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
|