onda-engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +106 -0
- package/LICENSE-APACHE +202 -0
- package/README.md +84 -0
- package/dist/chunk-NCNYMPIQ.js +12763 -0
- package/dist/chunk-NCNYMPIQ.js.map +1 -0
- package/dist/cinema.d.ts +580 -0
- package/dist/cinema.js +1687 -0
- package/dist/cinema.js.map +1 -0
- package/dist/components-manifest.d.ts +2 -0
- package/dist/components-manifest.js +3 -0
- package/dist/components-manifest.js.map +1 -0
- package/dist/components.d.ts +3480 -0
- package/dist/components.js +11486 -0
- package/dist/components.js.map +1 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest-7N3yu9tB.d.ts +131 -0
- package/dist/player.d.ts +177 -0
- package/dist/player.js +1749 -0
- package/dist/player.js.map +1 -0
- package/dist/react.d.ts +2141 -0
- package/dist/react.js +2052 -0
- package/dist/react.js.map +1 -0
- package/dist/render.d.ts +42 -0
- package/dist/render.js +113 -0
- package/dist/render.js.map +1 -0
- package/dist/wasm/pkg/onda_wasm.js +598 -0
- package/dist/wasm/pkg/onda_wasm_bg.wasm +0 -0
- package/dist/wasm-audio/pkg/onda_wasm_audio.js +417 -0
- package/dist/wasm-audio/pkg/onda_wasm_audio_bg.wasm +0 -0
- package/dist/wasm-vello/index.js +32 -0
- package/dist/wasm-vello/pkg/onda_wasm_vello.js +1325 -0
- package/dist/wasm-vello/pkg/onda_wasm_vello_bg.wasm +0 -0
- package/package.json +112 -0
package/dist/cinema.js
ADDED
|
@@ -0,0 +1,1687 @@
|
|
|
1
|
+
import * as Components from 'onda-engine/components';
|
|
2
|
+
import { settleTime, manifestEntry, fitMaxWidth, fitFontSize, measureText, resolvePlacement, isPlacement, defaultTheme } from 'onda-engine/components';
|
|
3
|
+
import { TransitionSeries, linearTiming, Sequence, Group, Rect, Composition, crossFade, useCurrentFrame, useVideoConfig, Camera, lumaWipe, filmBurn, whipPan, zoomBlur, typeMask, morph, gridPixelate, glassWipe, expandMorph, devicePullback, chromaticAberration, blur, none, dipToColor, depthPush, zoom, push, clockWipe, flip, iris, wipe, slide, fade, Scene3D, AbsoluteFill, Text, clipEllipse, clipPath, clipRect } from 'onda-engine/react';
|
|
4
|
+
import { createElement, cloneElement } from 'react';
|
|
5
|
+
|
|
6
|
+
// ../cinema/src/index.tsx
|
|
7
|
+
|
|
8
|
+
// ../cinema/src/props.ts
|
|
9
|
+
var PLACEMENT_COORDS = {
|
|
10
|
+
center: [0.5, 0.5],
|
|
11
|
+
top: [0.5, 0.1],
|
|
12
|
+
bottom: [0.5, 0.9],
|
|
13
|
+
left: [0.1, 0.5],
|
|
14
|
+
right: [0.9, 0.5],
|
|
15
|
+
"top-left": [0.1, 0.1],
|
|
16
|
+
"top-right": [0.9, 0.1],
|
|
17
|
+
"bottom-left": [0.1, 0.9],
|
|
18
|
+
"bottom-right": [0.9, 0.9],
|
|
19
|
+
"upper-third": [0.5, 0.28],
|
|
20
|
+
"lower-third": [0.5, 0.72]
|
|
21
|
+
};
|
|
22
|
+
var SELF_ANCHORING = /* @__PURE__ */ new Set([
|
|
23
|
+
"LowerThird",
|
|
24
|
+
"Callout",
|
|
25
|
+
"BlurReveal",
|
|
26
|
+
"Button",
|
|
27
|
+
"Captions",
|
|
28
|
+
"ChapterCard",
|
|
29
|
+
"CountUp",
|
|
30
|
+
"EndCard",
|
|
31
|
+
"InputField",
|
|
32
|
+
"KineticText",
|
|
33
|
+
"MaskReveal",
|
|
34
|
+
"MatrixDecode",
|
|
35
|
+
"PricingCard",
|
|
36
|
+
"QuoteCard",
|
|
37
|
+
"SlotMachineRoll",
|
|
38
|
+
"StatCard",
|
|
39
|
+
"Terminal",
|
|
40
|
+
"TextAnimator",
|
|
41
|
+
"TitleCard",
|
|
42
|
+
"Typewriter"
|
|
43
|
+
]);
|
|
44
|
+
function placementOffset(props, w, h) {
|
|
45
|
+
const p = props?.placement;
|
|
46
|
+
let fx = 0.5;
|
|
47
|
+
let fy = 0.5;
|
|
48
|
+
const coords = typeof p === "string" ? PLACEMENT_COORDS[p] : void 0;
|
|
49
|
+
if (coords) [fx, fy] = coords;
|
|
50
|
+
else if (p && typeof p === "object") {
|
|
51
|
+
const o = p;
|
|
52
|
+
if (typeof o.x === "number") fx = o.x;
|
|
53
|
+
if (typeof o.y === "number") fy = o.y;
|
|
54
|
+
}
|
|
55
|
+
return [(fx - 0.5) * w, (fy - 0.5) * h];
|
|
56
|
+
}
|
|
57
|
+
var SIZE_ROLES = {
|
|
58
|
+
hero: 0.15,
|
|
59
|
+
heading: 0.09,
|
|
60
|
+
subheading: 0.052,
|
|
61
|
+
body: 0.03,
|
|
62
|
+
caption: 0.02
|
|
63
|
+
};
|
|
64
|
+
var roleToPx = (role, w, h) => Math.round((SIZE_ROLES[role] ?? 0) * Math.min(w, h));
|
|
65
|
+
var isPxSource = (name) => name === "fontSize" || name.endsWith("FontSize");
|
|
66
|
+
var PROP_ALIASES = {
|
|
67
|
+
TitleCard: {
|
|
68
|
+
titleSize: "titleSize",
|
|
69
|
+
titleFontSize: "titleSize",
|
|
70
|
+
subtitleSize: "subtitleSize",
|
|
71
|
+
subtitleFontSize: "subtitleSize"
|
|
72
|
+
},
|
|
73
|
+
Highlight: { size: "fontSize", fontSize: "fontSize" },
|
|
74
|
+
WordStagger: { size: "fontSize", fontSize: "fontSize" },
|
|
75
|
+
Captions: { size: "fontSize", fontSize: "fontSize" },
|
|
76
|
+
StatCard: {
|
|
77
|
+
numberSize: "valueSize",
|
|
78
|
+
numberFontSize: "valueSize",
|
|
79
|
+
labelSize: "labelSize",
|
|
80
|
+
labelFontSize: "labelSize"
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
function adaptProps(component, props, w, h) {
|
|
84
|
+
const out = { ...props ?? {} };
|
|
85
|
+
const aliases = PROP_ALIASES[component];
|
|
86
|
+
if (aliases) {
|
|
87
|
+
const pxValue = {};
|
|
88
|
+
const roleValue = {};
|
|
89
|
+
for (const [src, target] of Object.entries(aliases)) {
|
|
90
|
+
if (!(src in out)) continue;
|
|
91
|
+
const v = out[src];
|
|
92
|
+
if (isPxSource(src)) {
|
|
93
|
+
if (typeof v === "number") pxValue[target] = v;
|
|
94
|
+
} else if (typeof v === "string" && v in SIZE_ROLES) {
|
|
95
|
+
roleValue[target] = roleToPx(v, w, h);
|
|
96
|
+
} else if (typeof v === "number") {
|
|
97
|
+
roleValue[target] = v;
|
|
98
|
+
}
|
|
99
|
+
if (src !== target) delete out[src];
|
|
100
|
+
}
|
|
101
|
+
for (const target of /* @__PURE__ */ new Set([...Object.keys(roleValue), ...Object.keys(pxValue)])) {
|
|
102
|
+
out[target] = pxValue[target] ?? roleValue[target];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const k of Object.keys(out)) {
|
|
106
|
+
const v = out[k];
|
|
107
|
+
if (typeof v === "string" && v in SIZE_ROLES) out[k] = roleToPx(v, w, h);
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ../cinema/src/timing.ts
|
|
113
|
+
function timeSpecToSeconds(spec, fps) {
|
|
114
|
+
if (spec == null) return 0;
|
|
115
|
+
if (typeof spec === "number") return spec >= 0 ? spec : 0;
|
|
116
|
+
const s = spec.trim();
|
|
117
|
+
if (s === "") return 0;
|
|
118
|
+
if (s.includes(":")) {
|
|
119
|
+
const [m, sec] = s.split(":");
|
|
120
|
+
return (Number(m) || 0) * 60 + (Number(sec) || 0);
|
|
121
|
+
}
|
|
122
|
+
if (s.endsWith("ms")) return (Number(s.slice(0, -2)) || 0) / 1e3;
|
|
123
|
+
if (s.endsWith("f")) return (Number(s.slice(0, -1)) || 0) / fps;
|
|
124
|
+
if (s.endsWith("s")) return Number(s.slice(0, -1)) || 0;
|
|
125
|
+
return Number(s) || 0;
|
|
126
|
+
}
|
|
127
|
+
function toFrames(spec, fps) {
|
|
128
|
+
return Math.round(timeSpecToSeconds(spec, fps) * fps);
|
|
129
|
+
}
|
|
130
|
+
function sceneDurationSeconds(scene, fps) {
|
|
131
|
+
if (scene.for != null) return timeSpecToSeconds(scene.for, fps);
|
|
132
|
+
let max = 0;
|
|
133
|
+
for (const track of scene.tracks) {
|
|
134
|
+
for (const e of track.entries) {
|
|
135
|
+
const end = timeSpecToSeconds(e.at, fps) + timeSpecToSeconds(e.for, fps);
|
|
136
|
+
if (end > max) max = end;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return max > 0 ? max : 3;
|
|
140
|
+
}
|
|
141
|
+
function sceneDurationFrames(scene, fps) {
|
|
142
|
+
return Math.max(1, Math.round(sceneDurationSeconds(scene, fps) * fps));
|
|
143
|
+
}
|
|
144
|
+
var DEFAULT_TRANSITION_FRAMES = 15;
|
|
145
|
+
function transitionOverlapFrames(prev, scene, fps) {
|
|
146
|
+
if (!prev || !scene.transition) return 0;
|
|
147
|
+
const requested = scene.transition.durationInFrames ?? DEFAULT_TRANSITION_FRAMES;
|
|
148
|
+
const shorter = Math.min(sceneDurationFrames(prev, fps), sceneDurationFrames(scene, fps));
|
|
149
|
+
return Math.max(1, Math.min(requested, Math.floor(shorter / 3)));
|
|
150
|
+
}
|
|
151
|
+
function scenePlacements(scenes, fps) {
|
|
152
|
+
const out = [];
|
|
153
|
+
let offset = 0;
|
|
154
|
+
scenes.forEach((scene, i) => {
|
|
155
|
+
const overlapIn = i > 0 ? transitionOverlapFrames(scenes[i - 1], scene, fps) : 0;
|
|
156
|
+
offset -= overlapIn;
|
|
157
|
+
const durationInFrames = sceneDurationFrames(scene, fps);
|
|
158
|
+
out.push({ start: offset, durationInFrames, overlapIn });
|
|
159
|
+
offset += durationInFrames;
|
|
160
|
+
});
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
function totalFrames(payload, fps) {
|
|
164
|
+
let total = 0;
|
|
165
|
+
let prev;
|
|
166
|
+
for (const scene of payload.scenes) {
|
|
167
|
+
total -= transitionOverlapFrames(prev, scene, fps);
|
|
168
|
+
total += sceneDurationFrames(scene, fps);
|
|
169
|
+
prev = scene;
|
|
170
|
+
}
|
|
171
|
+
return Math.max(1, total);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ../cinema/src/inspect/constants.ts
|
|
175
|
+
var CONTRAST_MIN_BODY = 4.5;
|
|
176
|
+
var CONTRAST_MIN_LARGE = 3;
|
|
177
|
+
var LARGE_TEXT_PX = 24;
|
|
178
|
+
var LARGE_TEXT_BOLD_PX = 14 * 4 / 3;
|
|
179
|
+
var BOLD_WEIGHT = 700;
|
|
180
|
+
var READ_SECONDS_PER_WORD = 0.25;
|
|
181
|
+
var READ_ORIENTATION_SECONDS = 0.6;
|
|
182
|
+
var READ_MIN_SECONDS = 1.2;
|
|
183
|
+
function readingTimeSeconds(wordCount) {
|
|
184
|
+
return Math.max(READ_MIN_SECONDS, READ_SECONDS_PER_WORD * wordCount + READ_ORIENTATION_SECONDS);
|
|
185
|
+
}
|
|
186
|
+
var v1920 = (px) => px / 1920;
|
|
187
|
+
var h1080 = (px) => px / 1080;
|
|
188
|
+
var SAFE_AREAS = {
|
|
189
|
+
"16:9": { top: 0.05, bottom: 0.05, left: 0.1, right: 0.1 },
|
|
190
|
+
"9:16": { top: v1920(220), bottom: v1920(420), left: h1080(60), right: h1080(164) },
|
|
191
|
+
"1:1": { top: 0.05, bottom: 0.1, left: 0.06, right: 0.06 },
|
|
192
|
+
"4:5": { top: 0.05, bottom: 0.12, left: 0.06, right: 0.06 }
|
|
193
|
+
};
|
|
194
|
+
function inferFormat(width, height) {
|
|
195
|
+
const ratio = width / height;
|
|
196
|
+
const known = [
|
|
197
|
+
["16:9", 16 / 9],
|
|
198
|
+
["9:16", 9 / 16],
|
|
199
|
+
["1:1", 1],
|
|
200
|
+
["4:5", 4 / 5]
|
|
201
|
+
];
|
|
202
|
+
let best = "16:9";
|
|
203
|
+
let bestD = Number.POSITIVE_INFINITY;
|
|
204
|
+
for (const [id, r] of known) {
|
|
205
|
+
const d = Math.abs(Math.log(ratio / r));
|
|
206
|
+
if (d < bestD) {
|
|
207
|
+
bestD = d;
|
|
208
|
+
best = id;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return best;
|
|
212
|
+
}
|
|
213
|
+
var FONT_FLOOR_PX = {
|
|
214
|
+
"16:9": 28,
|
|
215
|
+
"9:16": 40,
|
|
216
|
+
"1:1": 40,
|
|
217
|
+
"4:5": 40
|
|
218
|
+
};
|
|
219
|
+
var FONT_FLOOR_REFERENCE_DIM = 1080;
|
|
220
|
+
function fontFloorPx(format, width, height) {
|
|
221
|
+
return FONT_FLOOR_PX[format] * Math.min(width, height) / FONT_FLOOR_REFERENCE_DIM;
|
|
222
|
+
}
|
|
223
|
+
var FOCAL_COLLISION_WINDOW_SECONDS = 0.25;
|
|
224
|
+
var TRANSITION_BUDGET_SECONDS = 0.6;
|
|
225
|
+
var DENSITY_MAX_NON_AMBIENT = 5;
|
|
226
|
+
var DENSITY_MAX_FOCAL = 1;
|
|
227
|
+
|
|
228
|
+
// ../cinema/src/inspect/collisions.ts
|
|
229
|
+
var secs = (frames, fps) => `${(Math.round(frames / fps * 100) / 100).toString()}s`;
|
|
230
|
+
var checkCollisions = (ctx) => {
|
|
231
|
+
const { resolved } = ctx;
|
|
232
|
+
const { fps } = resolved;
|
|
233
|
+
const violations = [];
|
|
234
|
+
const windowFrames = FOCAL_COLLISION_WINDOW_SECONDS * fps;
|
|
235
|
+
const focal = resolved.entries.filter((e) => e.role === "focal" && e.visibleFrames > 0).sort((a, b) => a.absStart - b.absStart);
|
|
236
|
+
for (let i = 1; i < focal.length; i++) {
|
|
237
|
+
const a = focal[i - 1];
|
|
238
|
+
const b = focal[i];
|
|
239
|
+
if (!a || !b) continue;
|
|
240
|
+
const gap = b.absStart - a.absStart;
|
|
241
|
+
if (gap <= windowFrames) {
|
|
242
|
+
violations.push({
|
|
243
|
+
check: "timing.collisions",
|
|
244
|
+
severity: "warn",
|
|
245
|
+
targetId: b.targetId,
|
|
246
|
+
sceneId: b.sceneId,
|
|
247
|
+
message: `focal entrances collide: "${a.targetId}" and "${b.targetId}" begin ${secs(gap, fps)} apart (\u2264${FOCAL_COLLISION_WINDOW_SECONDS}s \u2014 inside the attention window; stagger them or demote one to 'support')`
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
for (const entry of resolved.entries) {
|
|
252
|
+
const settle = settleTime(entry.component, entry.adapted, fps);
|
|
253
|
+
if (settle === null || entry.visibleFrames <= 0) continue;
|
|
254
|
+
if (settle > entry.visibleFrames) {
|
|
255
|
+
const fitsViaClamp = manifestEntry(entry.component)?.props.some((p) => p.name === "fitToClip");
|
|
256
|
+
violations.push({
|
|
257
|
+
check: "timing.collisions",
|
|
258
|
+
severity: "warn",
|
|
259
|
+
targetId: entry.targetId,
|
|
260
|
+
sceneId: entry.sceneId,
|
|
261
|
+
message: `${entry.component}'s entrance settles at ${secs(settle, fps)} but it is only on screen for ${secs(entry.visibleFrames, fps)} \u2014 the move is cut off mid-flight`,
|
|
262
|
+
// Mechanical: the component's own envelope clamp, when it has one.
|
|
263
|
+
fix: fitsViaClamp ? { prop: "fitToClip", suggested: true } : void 0
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const budgetFrames = Math.round(TRANSITION_BUDGET_SECONDS * fps);
|
|
268
|
+
for (const t of resolved.transitions) {
|
|
269
|
+
if (t.durationInFrames > budgetFrames) {
|
|
270
|
+
violations.push({
|
|
271
|
+
check: "timing.collisions",
|
|
272
|
+
severity: "warn",
|
|
273
|
+
targetId: t.sceneId,
|
|
274
|
+
sceneId: t.sceneId,
|
|
275
|
+
message: `"${t.type}" transition into "${t.sceneId}" runs ${secs(t.durationInFrames, fps)} \u2014 over the ${TRANSITION_BUDGET_SECONDS}s budget (both scenes read as mush for the whole overlap)`,
|
|
276
|
+
fix: { prop: "transition.durationInFrames", suggested: budgetFrames }
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return violations;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// ../cinema/src/inspect/density.ts
|
|
284
|
+
function sceneDensity(resolved, sceneIndex) {
|
|
285
|
+
const sceneId = resolved.scenes[sceneIndex]?.scene.id ?? `scene-${sceneIndex}`;
|
|
286
|
+
const events = [];
|
|
287
|
+
for (const e of resolved.entries) {
|
|
288
|
+
if (e.sceneIndex !== sceneIndex || e.visibleFrames <= 0) continue;
|
|
289
|
+
const nonAmbient2 = e.role === "ambient" ? 0 : 1;
|
|
290
|
+
const focalDelta = e.role === "focal" ? 1 : 0;
|
|
291
|
+
events.push({ frame: e.localStart, nonAmbient: nonAmbient2, focal: focalDelta });
|
|
292
|
+
events.push({
|
|
293
|
+
frame: e.localStart + e.visibleFrames,
|
|
294
|
+
nonAmbient: -nonAmbient2,
|
|
295
|
+
focal: -focalDelta
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
events.sort((a, b) => a.frame - b.frame || a.nonAmbient - b.nonAmbient);
|
|
299
|
+
let nonAmbient = 0;
|
|
300
|
+
let focal = 0;
|
|
301
|
+
let peakNonAmbient = 0;
|
|
302
|
+
let peakFocal = 0;
|
|
303
|
+
let peakFrame = 0;
|
|
304
|
+
for (const ev of events) {
|
|
305
|
+
nonAmbient += ev.nonAmbient;
|
|
306
|
+
focal += ev.focal;
|
|
307
|
+
if (nonAmbient > peakNonAmbient) {
|
|
308
|
+
peakNonAmbient = nonAmbient;
|
|
309
|
+
peakFrame = ev.frame;
|
|
310
|
+
}
|
|
311
|
+
if (focal > peakFocal) peakFocal = focal;
|
|
312
|
+
}
|
|
313
|
+
return { sceneId, peakNonAmbient, peakFocal, peakFrame };
|
|
314
|
+
}
|
|
315
|
+
function densityMetrics(resolved) {
|
|
316
|
+
return resolved.scenes.map((_, i) => sceneDensity(resolved, i));
|
|
317
|
+
}
|
|
318
|
+
var checkDensity = (ctx) => {
|
|
319
|
+
const violations = [];
|
|
320
|
+
for (const d of densityMetrics(ctx.resolved)) {
|
|
321
|
+
if (d.peakNonAmbient > DENSITY_MAX_NON_AMBIENT) {
|
|
322
|
+
violations.push({
|
|
323
|
+
check: "density.score",
|
|
324
|
+
severity: "warn",
|
|
325
|
+
targetId: d.sceneId,
|
|
326
|
+
sceneId: d.sceneId,
|
|
327
|
+
message: `scene "${d.sceneId}" peaks at ${d.peakNonAmbient} concurrently visible non-ambient entries (frame ${d.peakFrame}, scene-local) \u2014 budget is ${DENSITY_MAX_NON_AMBIENT}; cut, stagger, or mark atmosphere 'ambient'`
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
if (d.peakFocal > DENSITY_MAX_FOCAL) {
|
|
331
|
+
violations.push({
|
|
332
|
+
check: "density.score",
|
|
333
|
+
severity: "warn",
|
|
334
|
+
targetId: d.sceneId,
|
|
335
|
+
sceneId: d.sceneId,
|
|
336
|
+
message: `scene "${d.sceneId}" shows ${d.peakFocal} focal entries at once \u2014 only ${DENSITY_MAX_FOCAL} thing can be THE thing; demote the rest to 'support'`
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return violations;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// ../cinema/src/inspect/frames.ts
|
|
344
|
+
var checkTransitionCapture = (ctx) => {
|
|
345
|
+
const frames = ctx.opts.frames;
|
|
346
|
+
if (!frames || frames.length === 0) return [];
|
|
347
|
+
const { resolved } = ctx;
|
|
348
|
+
const violations = [];
|
|
349
|
+
for (const f of frames) {
|
|
350
|
+
const hit = resolved.transitions.find((t) => f >= t.start && f < t.start + t.durationInFrames);
|
|
351
|
+
if (!hit) continue;
|
|
352
|
+
const before = hit.start - 1;
|
|
353
|
+
const after = hit.start + hit.durationInFrames;
|
|
354
|
+
const candidates = [before, after].filter((c) => c >= 0 && c < resolved.totalFrames);
|
|
355
|
+
const suggested = candidates.length > 0 ? candidates.reduce((best, c) => Math.abs(c - f) < Math.abs(best - f) ? c : best) : f;
|
|
356
|
+
violations.push({
|
|
357
|
+
check: "frames.transitionCapture",
|
|
358
|
+
severity: "warn",
|
|
359
|
+
targetId: hit.sceneId,
|
|
360
|
+
sceneId: hit.sceneId,
|
|
361
|
+
message: `frame ${f} lands inside the "${hit.type}" transition into "${hit.sceneId}" (frames ${hit.start}\u2013${hit.start + hit.durationInFrames - 1}) \u2014 it captures two scenes blended`,
|
|
362
|
+
fix: { prop: "frames", suggested }
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
return violations;
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
// ../cinema/src/inspect/color.ts
|
|
369
|
+
function parseColor(value) {
|
|
370
|
+
if (typeof value !== "string") return null;
|
|
371
|
+
const s = value.trim();
|
|
372
|
+
if (!s.startsWith("#")) return null;
|
|
373
|
+
const hex = s.slice(1);
|
|
374
|
+
if (!/^[0-9a-fA-F]+$/.test(hex)) return null;
|
|
375
|
+
if (hex.length === 3) {
|
|
376
|
+
const [r, g, b] = hex;
|
|
377
|
+
return {
|
|
378
|
+
r: Number.parseInt(`${r}${r}`, 16) / 255,
|
|
379
|
+
g: Number.parseInt(`${g}${g}`, 16) / 255,
|
|
380
|
+
b: Number.parseInt(`${b}${b}`, 16) / 255,
|
|
381
|
+
a: 1
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
if (hex.length === 6 || hex.length === 8) {
|
|
385
|
+
return {
|
|
386
|
+
r: Number.parseInt(hex.slice(0, 2), 16) / 255,
|
|
387
|
+
g: Number.parseInt(hex.slice(2, 4), 16) / 255,
|
|
388
|
+
b: Number.parseInt(hex.slice(4, 6), 16) / 255,
|
|
389
|
+
a: hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
var linearize = (c) => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
395
|
+
function relativeLuminance(c) {
|
|
396
|
+
return 0.2126 * linearize(c.r) + 0.7152 * linearize(c.g) + 0.0722 * linearize(c.b);
|
|
397
|
+
}
|
|
398
|
+
function contrastRatio(a, b) {
|
|
399
|
+
const la = relativeLuminance(a);
|
|
400
|
+
const lb = relativeLuminance(b);
|
|
401
|
+
const [hi, lo] = la >= lb ? [la, lb] : [lb, la];
|
|
402
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ../cinema/src/inspect/resolve.ts
|
|
406
|
+
function resolveComposition(payload) {
|
|
407
|
+
const fps = payload.fps > 0 ? payload.fps : 30;
|
|
408
|
+
const { width, height } = payload;
|
|
409
|
+
const scenesIn = payload.scenes ?? [];
|
|
410
|
+
const placements = scenePlacements(scenesIn, fps);
|
|
411
|
+
const total = totalFrames(payload, fps);
|
|
412
|
+
const scenes = scenesIn.map((scene, index) => ({
|
|
413
|
+
scene,
|
|
414
|
+
index,
|
|
415
|
+
start: placements[index]?.start ?? 0,
|
|
416
|
+
durationInFrames: placements[index]?.durationInFrames ?? 1
|
|
417
|
+
}));
|
|
418
|
+
const transitions = [];
|
|
419
|
+
scenesIn.forEach((scene, i) => {
|
|
420
|
+
const overlap = placements[i]?.overlapIn ?? 0;
|
|
421
|
+
if (i > 0 && scene.transition && overlap > 0) {
|
|
422
|
+
transitions.push({
|
|
423
|
+
sceneIndex: i,
|
|
424
|
+
sceneId: scene.id,
|
|
425
|
+
type: scene.transition.type,
|
|
426
|
+
start: placements[i]?.start ?? 0,
|
|
427
|
+
durationInFrames: overlap
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
const entries = [];
|
|
432
|
+
for (const { scene, index: si, start: sceneStart, durationInFrames: sceneDur } of scenes) {
|
|
433
|
+
scene.tracks?.forEach((track, ti) => {
|
|
434
|
+
track.entries.forEach((entry, ei) => {
|
|
435
|
+
const path = `scenes[${si}].tracks[${ti}].entries[${ei}]`;
|
|
436
|
+
const localStart = toFrames(entry.at, fps);
|
|
437
|
+
const dur = toFrames(entry.for, fps);
|
|
438
|
+
const visibleEnd = Math.min(localStart + dur, sceneDur);
|
|
439
|
+
entries.push({
|
|
440
|
+
kind: "scene",
|
|
441
|
+
component: entry.component,
|
|
442
|
+
props: entry.props ?? {},
|
|
443
|
+
adapted: adaptProps(entry.component, entry.props, width, height),
|
|
444
|
+
role: entry.role ?? "support",
|
|
445
|
+
targetId: entry.id ?? path,
|
|
446
|
+
path,
|
|
447
|
+
sceneId: scene.id,
|
|
448
|
+
sceneIndex: si,
|
|
449
|
+
trackIndex: ti,
|
|
450
|
+
entryIndex: ei,
|
|
451
|
+
localStart,
|
|
452
|
+
absStart: sceneStart + localStart,
|
|
453
|
+
durationInFrames: dur,
|
|
454
|
+
visibleFrames: Math.max(0, visibleEnd - localStart),
|
|
455
|
+
raw: entry
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
const layerEntries = [];
|
|
461
|
+
payload.layers?.forEach((layer, li) => {
|
|
462
|
+
layer.entries.forEach((entry, ei) => {
|
|
463
|
+
const path = `layers[${li}].entries[${ei}]`;
|
|
464
|
+
const from = toFrames(entry.at ?? 0, fps);
|
|
465
|
+
const dur = entry.for != null ? toFrames(entry.for, fps) : Math.max(1, total - from);
|
|
466
|
+
layerEntries.push({
|
|
467
|
+
kind: "layer",
|
|
468
|
+
component: entry.component,
|
|
469
|
+
props: entry.props ?? {},
|
|
470
|
+
adapted: adaptProps(entry.component, entry.props, width, height),
|
|
471
|
+
role: "support",
|
|
472
|
+
targetId: entry.id ?? path,
|
|
473
|
+
path,
|
|
474
|
+
localStart: from,
|
|
475
|
+
absStart: from,
|
|
476
|
+
durationInFrames: dur,
|
|
477
|
+
visibleFrames: Math.max(0, Math.min(from + dur, total) - from),
|
|
478
|
+
under: Boolean(layer.under),
|
|
479
|
+
raw: entry
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
return {
|
|
484
|
+
payload,
|
|
485
|
+
fps,
|
|
486
|
+
width,
|
|
487
|
+
height,
|
|
488
|
+
totalFrames: total,
|
|
489
|
+
scenes,
|
|
490
|
+
entries,
|
|
491
|
+
layerEntries,
|
|
492
|
+
transitions
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
function windowsOverlap(aStart, aLen, bStart, bLen) {
|
|
496
|
+
return aStart < bStart + bLen && bStart < aStart + aLen;
|
|
497
|
+
}
|
|
498
|
+
function parseDefault(literal) {
|
|
499
|
+
if (literal === void 0) return void 0;
|
|
500
|
+
const s = literal.trim();
|
|
501
|
+
if (s === "true") return true;
|
|
502
|
+
if (s === "false") return false;
|
|
503
|
+
const n = Number(s);
|
|
504
|
+
if (s !== "" && Number.isFinite(n)) return n;
|
|
505
|
+
if (s.startsWith("'") && s.endsWith("'") || s.startsWith('"') && s.endsWith('"') || s.startsWith("`") && s.endsWith("`"))
|
|
506
|
+
return s.slice(1, -1);
|
|
507
|
+
return void 0;
|
|
508
|
+
}
|
|
509
|
+
var wordsOf = (s) => s.split(/\s+/).filter(Boolean).length;
|
|
510
|
+
function totalWords(blocks) {
|
|
511
|
+
return blocks.reduce((sum, b) => sum + wordsOf(b.content), 0);
|
|
512
|
+
}
|
|
513
|
+
function letterSpacingPx(value, fontSize) {
|
|
514
|
+
if (typeof value === "number") return value;
|
|
515
|
+
if (typeof value !== "string") return void 0;
|
|
516
|
+
const n = Number.parseFloat(value.trim());
|
|
517
|
+
if (!Number.isFinite(n)) return void 0;
|
|
518
|
+
return value.trim().endsWith("em") ? n * fontSize : n;
|
|
519
|
+
}
|
|
520
|
+
function propValue(entry, meta, name) {
|
|
521
|
+
const explicit = entry.adapted[name];
|
|
522
|
+
if (explicit !== void 0) return explicit;
|
|
523
|
+
return parseDefault(meta?.default);
|
|
524
|
+
}
|
|
525
|
+
var WRAPPING_COMPONENTS = /* @__PURE__ */ new Set(["Captions"]);
|
|
526
|
+
var NON_READABLE_TEXT_PROPS = /* @__PURE__ */ new Set(["charset"]);
|
|
527
|
+
function sizePropFor(m, textProp) {
|
|
528
|
+
const sizeProps = m.props.filter((p) => p.role === "fontSize");
|
|
529
|
+
const prefixed = sizeProps.find(
|
|
530
|
+
(p) => p.name === `${textProp}Size` || p.name === `${textProp}FontSize`
|
|
531
|
+
);
|
|
532
|
+
if (prefixed) return prefixed;
|
|
533
|
+
if (textProp === "text" || sizeProps.length === 1)
|
|
534
|
+
return sizeProps.find((p) => p.name === "fontSize") ?? sizeProps[0];
|
|
535
|
+
return void 0;
|
|
536
|
+
}
|
|
537
|
+
function colorPropFor(m, textProp) {
|
|
538
|
+
const colorProps = m.props.filter((p) => p.role === "color");
|
|
539
|
+
return colorProps.find((p) => p.name === `${textProp}Color`) ?? (textProp === "text" || colorProps.length === 1 ? colorProps.find((p) => p.name === "color") : void 0);
|
|
540
|
+
}
|
|
541
|
+
var MUTED_HINT = /textmuted|muted|dim\b/i;
|
|
542
|
+
function textBlocks(entry, theme) {
|
|
543
|
+
const m = manifestEntry(entry.component);
|
|
544
|
+
if (!m) return [];
|
|
545
|
+
const blocks = [];
|
|
546
|
+
for (const p of m.props) {
|
|
547
|
+
if (p.role !== "text" || NON_READABLE_TEXT_PROPS.has(p.name)) continue;
|
|
548
|
+
const value = propValue(entry, p, p.name);
|
|
549
|
+
if (typeof value !== "string" || value.trim() === "") continue;
|
|
550
|
+
const sizeMeta = sizePropFor(m, p.name);
|
|
551
|
+
const sizeValue = sizeMeta ? propValue(entry, sizeMeta, sizeMeta.name) : void 0;
|
|
552
|
+
const fontSize = typeof sizeValue === "number" && sizeValue > 0 ? sizeValue : 48;
|
|
553
|
+
const colorMeta = colorPropFor(m, p.name);
|
|
554
|
+
const explicitColor = colorMeta ? entry.adapted[colorMeta.name] : void 0;
|
|
555
|
+
let color;
|
|
556
|
+
let colorExplicit = false;
|
|
557
|
+
if (typeof explicitColor === "string") {
|
|
558
|
+
color = explicitColor;
|
|
559
|
+
colorExplicit = true;
|
|
560
|
+
} else if (colorMeta?.themeable) {
|
|
561
|
+
color = MUTED_HINT.test(colorMeta.description) ? theme.textMuted : theme.text;
|
|
562
|
+
} else {
|
|
563
|
+
color = theme.text;
|
|
564
|
+
}
|
|
565
|
+
const weightMeta = m.props.find((q) => q.name === "fontWeight");
|
|
566
|
+
const weightValue = weightMeta ? propValue(entry, weightMeta, "fontWeight") : void 0;
|
|
567
|
+
const familyValue = entry.adapted.fontFamily;
|
|
568
|
+
const fitValue = entry.adapted.fit;
|
|
569
|
+
const maxWidthValue = entry.adapted.maxWidth;
|
|
570
|
+
blocks.push({
|
|
571
|
+
textProp: p.name,
|
|
572
|
+
content: value,
|
|
573
|
+
fontSize,
|
|
574
|
+
sizeProp: sizeMeta?.name,
|
|
575
|
+
color,
|
|
576
|
+
colorExplicit,
|
|
577
|
+
fontWeight: typeof weightValue === "number" ? weightValue : 400,
|
|
578
|
+
fontFamily: typeof familyValue === "string" ? familyValue : void 0,
|
|
579
|
+
letterSpacing: letterSpacingPx(entry.adapted.letterSpacing, fontSize),
|
|
580
|
+
fit: fitValue === "frame" || fitValue === "none" ? fitValue : void 0,
|
|
581
|
+
maxWidth: typeof maxWidthValue === "number" ? maxWidthValue : void 0
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
return blocks;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ../cinema/src/inspect/legibility.ts
|
|
588
|
+
function isCovering(component) {
|
|
589
|
+
if (component === "Scrim") return true;
|
|
590
|
+
const m = manifestEntry(component);
|
|
591
|
+
return m?.occlusion === "full_frame" || m?.sceneRole === "background";
|
|
592
|
+
}
|
|
593
|
+
function isMediaBearing(entry) {
|
|
594
|
+
const m = manifestEntry(entry.component);
|
|
595
|
+
if (!m) return false;
|
|
596
|
+
if (m.category === "Media") return true;
|
|
597
|
+
return m.props.some(
|
|
598
|
+
(p) => p.role === "url" && (p.required || entry.adapted[p.name] !== void 0)
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
function fillColors(entry) {
|
|
602
|
+
const m = manifestEntry(entry.component);
|
|
603
|
+
if (!m) return [];
|
|
604
|
+
const out = [];
|
|
605
|
+
for (const p of m.props) {
|
|
606
|
+
if (p.role !== "color") continue;
|
|
607
|
+
const value = entry.adapted[p.name] ?? parseDefault(p.default);
|
|
608
|
+
const candidates = Array.isArray(value) ? value : [value];
|
|
609
|
+
for (const c of candidates) {
|
|
610
|
+
const parsed = parseColor(c);
|
|
611
|
+
if (parsed) out.push(parsed);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (out.length === 0 && entry.component === "Scrim") {
|
|
615
|
+
const white = parseColor("#ffffff");
|
|
616
|
+
if (white) out.push(white);
|
|
617
|
+
}
|
|
618
|
+
return out;
|
|
619
|
+
}
|
|
620
|
+
function coverOpacity(entry) {
|
|
621
|
+
const m = manifestEntry(entry.component);
|
|
622
|
+
const meta = m?.props.find((p) => p.name === "opacity");
|
|
623
|
+
const v = entry.adapted.opacity ?? parseDefault(meta?.default);
|
|
624
|
+
return typeof v === "number" && v >= 0 && v <= 1 ? v : 1;
|
|
625
|
+
}
|
|
626
|
+
function over(top, alpha, under) {
|
|
627
|
+
const a = Math.max(0, Math.min(1, alpha * top.a));
|
|
628
|
+
return {
|
|
629
|
+
r: top.r * a + under.r * (1 - a),
|
|
630
|
+
g: top.g * a + under.g * (1 - a),
|
|
631
|
+
b: top.b * a + under.b * (1 - a),
|
|
632
|
+
a: 1
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
function backdropFor(target, resolved) {
|
|
636
|
+
const beneath = [];
|
|
637
|
+
const overlapsTarget = (e) => windowsOverlap(e.absStart, e.visibleFrames, target.absStart, target.visibleFrames);
|
|
638
|
+
for (const e of resolved.layerEntries) {
|
|
639
|
+
if (e.under && overlapsTarget(e)) beneath.push(e);
|
|
640
|
+
}
|
|
641
|
+
if (target.kind === "scene") {
|
|
642
|
+
for (const e of resolved.entries) {
|
|
643
|
+
if (e.sceneIndex !== target.sceneIndex || !overlapsTarget(e)) continue;
|
|
644
|
+
const ti = e.trackIndex ?? 0;
|
|
645
|
+
const ei = e.entryIndex ?? 0;
|
|
646
|
+
const tTi = target.trackIndex ?? 0;
|
|
647
|
+
const tEi = target.entryIndex ?? 0;
|
|
648
|
+
if (ti < tTi || ti === tTi && ei < tEi) beneath.push(e);
|
|
649
|
+
}
|
|
650
|
+
} else {
|
|
651
|
+
for (const e of resolved.entries) if (overlapsTarget(e)) beneath.push(e);
|
|
652
|
+
}
|
|
653
|
+
const veils = [];
|
|
654
|
+
for (let i = beneath.length - 1; i >= 0; i--) {
|
|
655
|
+
const e = beneath[i];
|
|
656
|
+
if (!e || !isCovering(e.component)) continue;
|
|
657
|
+
if (isMediaBearing(e)) return { kind: "media" };
|
|
658
|
+
const colors = fillColors(e);
|
|
659
|
+
if (colors.length === 0) return { kind: "unknown" };
|
|
660
|
+
const alpha = coverOpacity(e);
|
|
661
|
+
const opaque = alpha >= 0.95 && colors.every((c) => c.a >= 0.95);
|
|
662
|
+
if (opaque) return { kind: "colors", colors: composite(colors, veils) };
|
|
663
|
+
veils.push({ colors, alpha });
|
|
664
|
+
}
|
|
665
|
+
const bg = parseColor(resolveBackground(resolved.payload.brand));
|
|
666
|
+
if (!bg) return { kind: "unknown" };
|
|
667
|
+
return { kind: "colors", colors: composite([bg], veils) };
|
|
668
|
+
}
|
|
669
|
+
function resolveBackground(brand) {
|
|
670
|
+
return brand?.bg ?? "#08080a";
|
|
671
|
+
}
|
|
672
|
+
function composite(base, veils) {
|
|
673
|
+
let result = base;
|
|
674
|
+
for (let i = veils.length - 1; i >= 0; i--) {
|
|
675
|
+
const veil = veils[i];
|
|
676
|
+
if (!veil) continue;
|
|
677
|
+
const next = [];
|
|
678
|
+
for (const under of result)
|
|
679
|
+
for (const top of veil.colors) next.push(over(top, veil.alpha, under));
|
|
680
|
+
result = next;
|
|
681
|
+
}
|
|
682
|
+
return result;
|
|
683
|
+
}
|
|
684
|
+
function requiredContrast(fontSize, fontWeight) {
|
|
685
|
+
const large = fontSize >= LARGE_TEXT_PX || fontWeight >= BOLD_WEIGHT && fontSize >= LARGE_TEXT_BOLD_PX;
|
|
686
|
+
return large ? CONTRAST_MIN_LARGE : CONTRAST_MIN_BODY;
|
|
687
|
+
}
|
|
688
|
+
var fmt = (n) => (Math.round(n * 100) / 100).toString();
|
|
689
|
+
var checkLegibility = (ctx) => {
|
|
690
|
+
const { resolved, theme, format } = ctx;
|
|
691
|
+
const floor = fontFloorPx(format, resolved.width, resolved.height);
|
|
692
|
+
const violations = [];
|
|
693
|
+
for (const entry of [...resolved.entries, ...resolved.layerEntries]) {
|
|
694
|
+
const blocks = textBlocks(entry, theme);
|
|
695
|
+
if (blocks.length === 0) continue;
|
|
696
|
+
for (const b of blocks) {
|
|
697
|
+
if (b.fontSize < floor) {
|
|
698
|
+
violations.push({
|
|
699
|
+
check: "text.legibility",
|
|
700
|
+
severity: "warn",
|
|
701
|
+
targetId: entry.targetId,
|
|
702
|
+
sceneId: entry.sceneId,
|
|
703
|
+
message: `${entry.component} "${b.textProp}" is ${fmt(b.fontSize)}px \u2014 below the ${fmt(floor)}px ${format} floor (body text on a phone-scale canvas needs \u2265${fmt(floor)}px; legibility.info video-text rules)`,
|
|
704
|
+
fix: { prop: b.sizeProp ?? "fontSize", suggested: Math.ceil(floor) }
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
const backdrop = backdropFor(entry, resolved);
|
|
709
|
+
if (backdrop.kind === "media") {
|
|
710
|
+
violations.push({
|
|
711
|
+
check: "text.legibility",
|
|
712
|
+
severity: "info",
|
|
713
|
+
targetId: entry.targetId,
|
|
714
|
+
sceneId: entry.sceneId,
|
|
715
|
+
message: `${entry.component} sits over image/video \u2014 contrast unverifiable analytically (verify on a rendered frame, or put a Scrim behind it)`
|
|
716
|
+
});
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
if (backdrop.kind === "unknown") {
|
|
720
|
+
violations.push({
|
|
721
|
+
check: "text.legibility",
|
|
722
|
+
severity: "info",
|
|
723
|
+
targetId: entry.targetId,
|
|
724
|
+
sceneId: entry.sceneId,
|
|
725
|
+
message: `${entry.component}'s backdrop color can't be resolved analytically \u2014 contrast unverified`
|
|
726
|
+
});
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
for (const b of blocks) {
|
|
730
|
+
const textColor = parseColor(b.color);
|
|
731
|
+
if (!textColor) continue;
|
|
732
|
+
const required = requiredContrast(b.fontSize, b.fontWeight);
|
|
733
|
+
let worst = Number.POSITIVE_INFINITY;
|
|
734
|
+
for (const c of backdrop.colors) worst = Math.min(worst, contrastRatio(textColor, c));
|
|
735
|
+
if (worst < required) {
|
|
736
|
+
violations.push({
|
|
737
|
+
check: "text.legibility",
|
|
738
|
+
severity: "error",
|
|
739
|
+
targetId: entry.targetId,
|
|
740
|
+
sceneId: entry.sceneId,
|
|
741
|
+
message: `${entry.component} "${b.textProp}" contrast is ${fmt(worst)}:1 against its backdrop \u2014 below the WCAG ${fmt(required)}:1 minimum for ${fmt(b.fontSize)}px text (SC 1.4.3)`
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return violations;
|
|
747
|
+
};
|
|
748
|
+
var checkOverflow = (ctx) => {
|
|
749
|
+
const { resolved, safe, theme, format } = ctx;
|
|
750
|
+
const { width, height } = resolved;
|
|
751
|
+
const safeRect = {
|
|
752
|
+
left: safe.left * width,
|
|
753
|
+
top: safe.top * height,
|
|
754
|
+
right: width - safe.right * width,
|
|
755
|
+
bottom: height - safe.bottom * height
|
|
756
|
+
};
|
|
757
|
+
const violations = [];
|
|
758
|
+
for (const entry of [...resolved.entries, ...resolved.layerEntries]) {
|
|
759
|
+
if (WRAPPING_COMPONENTS.has(entry.component)) continue;
|
|
760
|
+
const blocks = textBlocks(entry, theme);
|
|
761
|
+
for (const b of blocks) {
|
|
762
|
+
if (b.content.includes("\n")) continue;
|
|
763
|
+
const measureOpts = {
|
|
764
|
+
fontFamily: b.fontFamily,
|
|
765
|
+
fontWeight: b.fontWeight,
|
|
766
|
+
letterSpacing: b.letterSpacing
|
|
767
|
+
};
|
|
768
|
+
const cap = fitMaxWidth({ fit: b.fit, maxWidth: b.maxWidth }, width);
|
|
769
|
+
const size = cap !== void 0 ? fitFontSize(b.content, b.fontSize, cap, measureOpts) : b.fontSize;
|
|
770
|
+
const m = measureText(b.content, size, measureOpts);
|
|
771
|
+
if (m.width <= 0) continue;
|
|
772
|
+
const placement = entry.adapted.placement;
|
|
773
|
+
const box = resolvePlacement(
|
|
774
|
+
isPlacement(placement) ? placement : void 0,
|
|
775
|
+
{ width, height },
|
|
776
|
+
{ width: m.width, height: m.height }
|
|
777
|
+
);
|
|
778
|
+
const x0 = box.originX;
|
|
779
|
+
const y0 = box.originY;
|
|
780
|
+
const x1 = x0 + m.width;
|
|
781
|
+
const y1 = y0 + m.height;
|
|
782
|
+
const escapesFrame = x0 < 0 || y0 < 0 || x1 > width || y1 > height;
|
|
783
|
+
const escapesSafe = x0 < safeRect.left || y0 < safeRect.top || x1 > safeRect.right || y1 > safeRect.bottom;
|
|
784
|
+
if (!escapesFrame && !escapesSafe) continue;
|
|
785
|
+
const boxFitsSafe = (fs) => {
|
|
786
|
+
const fm = measureText(b.content, fs, measureOpts);
|
|
787
|
+
const fb = resolvePlacement(
|
|
788
|
+
isPlacement(placement) ? placement : void 0,
|
|
789
|
+
{ width, height },
|
|
790
|
+
{ width: fm.width, height: fm.height }
|
|
791
|
+
);
|
|
792
|
+
return fb.originX >= safeRect.left && fb.originY >= safeRect.top && fb.originX + fm.width <= safeRect.right && fb.originY + fm.height <= safeRect.bottom;
|
|
793
|
+
};
|
|
794
|
+
let lo = 1;
|
|
795
|
+
let hi = Math.floor(size);
|
|
796
|
+
let fitted = 0;
|
|
797
|
+
while (lo <= hi) {
|
|
798
|
+
const mid = lo + hi >> 1;
|
|
799
|
+
if (boxFitsSafe(mid)) {
|
|
800
|
+
fitted = mid;
|
|
801
|
+
lo = mid + 1;
|
|
802
|
+
} else {
|
|
803
|
+
hi = mid - 1;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
const fix = b.sizeProp && fitted > 0 && fitted < size ? { prop: b.sizeProp, suggested: fitted } : void 0;
|
|
807
|
+
violations.push({
|
|
808
|
+
check: "layout.overflow",
|
|
809
|
+
severity: escapesFrame ? "error" : "warn",
|
|
810
|
+
targetId: entry.targetId,
|
|
811
|
+
sceneId: entry.sceneId,
|
|
812
|
+
message: escapesFrame ? `${entry.component} "${b.textProp}" measures ${Math.round(m.width)}px wide at ${Math.round(size)}px \u2014 it escapes the ${width}\xD7${height} frame` : `${entry.component} "${b.textProp}" measures ${Math.round(m.width)}px wide at ${Math.round(size)}px \u2014 outside the ${format} safe area (platform UI / title-safe band)`,
|
|
813
|
+
fix
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return violations;
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
// ../cinema/src/inspect/reading.ts
|
|
821
|
+
var checkReadingTime = (ctx) => {
|
|
822
|
+
const { resolved, theme } = ctx;
|
|
823
|
+
const violations = [];
|
|
824
|
+
for (const entry of [...resolved.entries, ...resolved.layerEntries]) {
|
|
825
|
+
if (entry.role === "ambient") continue;
|
|
826
|
+
const words = totalWords(textBlocks(entry, theme));
|
|
827
|
+
if (words === 0) continue;
|
|
828
|
+
const needed = readingTimeSeconds(words);
|
|
829
|
+
const visible = entry.visibleFrames / resolved.fps;
|
|
830
|
+
if (visible >= needed) continue;
|
|
831
|
+
const suggested = Math.ceil(needed * 10) / 10;
|
|
832
|
+
violations.push({
|
|
833
|
+
check: "timing.readingTime",
|
|
834
|
+
severity: "warn",
|
|
835
|
+
targetId: entry.targetId,
|
|
836
|
+
sceneId: entry.sceneId,
|
|
837
|
+
message: `${entry.component} shows ${words} word${words === 1 ? "" : "s"} for ${(Math.round(visible * 100) / 100).toString()}s \u2014 ${suggested}s needed to read it (0.25s/word + 0.6s orientation, \u22651.2s)`,
|
|
838
|
+
fix: { prop: "for", suggested: `${suggested}s` }
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
return violations;
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
// ../cinema/src/inspect/index.ts
|
|
845
|
+
var CHECKS = {
|
|
846
|
+
"text.legibility": checkLegibility,
|
|
847
|
+
"layout.overflow": checkOverflow,
|
|
848
|
+
"timing.readingTime": checkReadingTime,
|
|
849
|
+
"timing.collisions": checkCollisions,
|
|
850
|
+
"density.score": checkDensity,
|
|
851
|
+
"frames.transitionCapture": checkTransitionCapture
|
|
852
|
+
};
|
|
853
|
+
function inspect(payload, opts = {}) {
|
|
854
|
+
const resolved = resolveComposition(payload);
|
|
855
|
+
const format = opts.format ?? inferFormat(resolved.width, resolved.height);
|
|
856
|
+
const ctx = {
|
|
857
|
+
payload,
|
|
858
|
+
resolved,
|
|
859
|
+
format,
|
|
860
|
+
safe: SAFE_AREAS[format],
|
|
861
|
+
theme: {
|
|
862
|
+
text: payload.brand?.text ?? defaultTheme.text,
|
|
863
|
+
textMuted: payload.brand?.dim ?? defaultTheme.textMuted,
|
|
864
|
+
background: payload.brand?.bg ?? "#08080a"
|
|
865
|
+
},
|
|
866
|
+
opts
|
|
867
|
+
};
|
|
868
|
+
const violations = [];
|
|
869
|
+
for (const check of Object.values(CHECKS)) violations.push(...check(ctx));
|
|
870
|
+
const summary = { error: 0, warn: 0, info: 0 };
|
|
871
|
+
for (const v of violations) summary[v.severity]++;
|
|
872
|
+
const density = densityMetrics(resolved);
|
|
873
|
+
return {
|
|
874
|
+
violations,
|
|
875
|
+
summary,
|
|
876
|
+
format,
|
|
877
|
+
fps: resolved.fps,
|
|
878
|
+
totalFrames: resolved.totalFrames,
|
|
879
|
+
density
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// ../cinema/src/index.tsx
|
|
884
|
+
var DUR = Components.DURATION;
|
|
885
|
+
var num = (v, d) => typeof v === "number" && Number.isFinite(v) ? v : d;
|
|
886
|
+
var asDir = (v) => v === "down" || v === "left" || v === "right" || v === "up" ? v : "up";
|
|
887
|
+
var exitStart = (p, dur, exitDur) => Math.max(0, dur - exitDur - num(p.delay, 0));
|
|
888
|
+
var CHOREOGRAPHY = {
|
|
889
|
+
// Direct-manipulation keyframe animation (the Studio editor). Shares the sampler
|
|
890
|
+
// with the Keyframes component + Studio preview, so interpolation is identical.
|
|
891
|
+
// Position is an OFFSET from placement (additive); opacity/scale absolute;
|
|
892
|
+
// rotation in degrees.
|
|
893
|
+
keyframes: (frame, _fps, p) => {
|
|
894
|
+
const k = Components.sampleKeyframes(p, frame);
|
|
895
|
+
return {
|
|
896
|
+
opacity: k.opacity,
|
|
897
|
+
x: k.x,
|
|
898
|
+
y: k.y,
|
|
899
|
+
scaleX: k.scale,
|
|
900
|
+
scaleY: k.scale,
|
|
901
|
+
rotation: k.rotation
|
|
902
|
+
};
|
|
903
|
+
},
|
|
904
|
+
entryFade: (frame, fps, p) => Components.entryFade({
|
|
905
|
+
frame,
|
|
906
|
+
fps,
|
|
907
|
+
delay: num(p.delay, 0),
|
|
908
|
+
durationInFrames: num(p.durationInFrames, DUR.base)
|
|
909
|
+
}),
|
|
910
|
+
entryFadeRise: (frame, fps, p) => Components.entryFadeRise({
|
|
911
|
+
frame,
|
|
912
|
+
fps,
|
|
913
|
+
delay: num(p.delay, 0),
|
|
914
|
+
durationInFrames: num(p.durationInFrames, DUR.base),
|
|
915
|
+
travelPx: num(p.travelPx, 12)
|
|
916
|
+
}),
|
|
917
|
+
entrySlide: (frame, fps, p) => Components.entrySlide({
|
|
918
|
+
frame,
|
|
919
|
+
fps,
|
|
920
|
+
delay: num(p.delay, 0),
|
|
921
|
+
durationInFrames: num(p.durationInFrames, DUR.base),
|
|
922
|
+
direction: asDir(p.direction),
|
|
923
|
+
distance: num(p.distance, 12)
|
|
924
|
+
}),
|
|
925
|
+
entryScale: (frame, fps, p) => Components.entryScale({
|
|
926
|
+
frame,
|
|
927
|
+
fps,
|
|
928
|
+
delay: num(p.delay, 0),
|
|
929
|
+
durationInFrames: num(p.durationInFrames, DUR.base),
|
|
930
|
+
from: num(p.from, 0.9)
|
|
931
|
+
}),
|
|
932
|
+
heroReveal: (frame, fps, p) => Components.heroReveal({
|
|
933
|
+
frame,
|
|
934
|
+
fps,
|
|
935
|
+
delay: num(p.delay, 0),
|
|
936
|
+
durationInFrames: num(p.durationInFrames, DUR.slow),
|
|
937
|
+
travelPx: num(p.travelPx, 16)
|
|
938
|
+
}),
|
|
939
|
+
exitFade: (frame, fps, p, dur) => {
|
|
940
|
+
const ed = num(p.durationInFrames, DUR.fast);
|
|
941
|
+
return Components.exitFade({ frame, fps, delay: exitStart(p, dur, ed), durationInFrames: ed });
|
|
942
|
+
},
|
|
943
|
+
exitFadeFall: (frame, fps, p, dur) => {
|
|
944
|
+
const ed = num(p.durationInFrames, DUR.fast);
|
|
945
|
+
return Components.exitFadeFall({
|
|
946
|
+
frame,
|
|
947
|
+
fps,
|
|
948
|
+
delay: exitStart(p, dur, ed),
|
|
949
|
+
durationInFrames: ed,
|
|
950
|
+
travelPx: num(p.travelPx, 8)
|
|
951
|
+
});
|
|
952
|
+
},
|
|
953
|
+
exitSlide: (frame, fps, p, dur) => {
|
|
954
|
+
const ed = num(p.durationInFrames, DUR.fast);
|
|
955
|
+
return Components.exitSlide({
|
|
956
|
+
frame,
|
|
957
|
+
fps,
|
|
958
|
+
delay: exitStart(p, dur, ed),
|
|
959
|
+
durationInFrames: ed,
|
|
960
|
+
direction: asDir(p.direction),
|
|
961
|
+
distance: num(p.distance, 12)
|
|
962
|
+
});
|
|
963
|
+
},
|
|
964
|
+
exitScale: (frame, fps, p, dur) => {
|
|
965
|
+
const ed = num(p.durationInFrames, DUR.fast);
|
|
966
|
+
return Components.exitScale({ frame, fps, delay: exitStart(p, dur, ed), durationInFrames: ed });
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
var REST = { opacity: 1, x: 0, y: 0, scaleX: 1, scaleY: 1 };
|
|
970
|
+
function composeMotion(animate, frame, fps, dur) {
|
|
971
|
+
let m = REST;
|
|
972
|
+
for (const a of animate ?? []) {
|
|
973
|
+
const fn = CHOREOGRAPHY[a.pattern];
|
|
974
|
+
if (!fn) continue;
|
|
975
|
+
const r = fn(frame, fps, a.params ?? {}, dur);
|
|
976
|
+
m = {
|
|
977
|
+
opacity: m.opacity * r.opacity,
|
|
978
|
+
x: m.x + r.x,
|
|
979
|
+
y: m.y + r.y,
|
|
980
|
+
scaleX: m.scaleX * r.scaleX,
|
|
981
|
+
scaleY: m.scaleY * r.scaleY,
|
|
982
|
+
rotation: (m.rotation ?? 0) + (r.rotation ?? 0)
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
return m;
|
|
986
|
+
}
|
|
987
|
+
var TRANSITIONS = {
|
|
988
|
+
"cross-fade": () => crossFade(),
|
|
989
|
+
fade: () => fade(),
|
|
990
|
+
slide: (o) => slide(o),
|
|
991
|
+
wipe: (o) => wipe(o),
|
|
992
|
+
iris: () => iris(),
|
|
993
|
+
flip: () => flip(),
|
|
994
|
+
"clock-wipe": () => clockWipe(),
|
|
995
|
+
push: (o) => push(o),
|
|
996
|
+
zoom: (o) => zoom(o),
|
|
997
|
+
"depth-push": () => depthPush(),
|
|
998
|
+
"dip-to-color": (o) => dipToColor(o),
|
|
999
|
+
none: () => none(),
|
|
1000
|
+
// Effect transitions — approximated in the presentation layer (no engine blur).
|
|
1001
|
+
blur: () => blur(),
|
|
1002
|
+
"chromatic-aberration": () => chromaticAberration(),
|
|
1003
|
+
"device-pullback": () => devicePullback(),
|
|
1004
|
+
"expand-morph": () => expandMorph(),
|
|
1005
|
+
"glass-wipe": (o) => glassWipe(o),
|
|
1006
|
+
"grid-pixelate": (o) => gridPixelate(o),
|
|
1007
|
+
morph: () => morph(),
|
|
1008
|
+
"type-mask": (o) => typeMask(o),
|
|
1009
|
+
"zoom-blur": (o) => zoomBlur(o),
|
|
1010
|
+
"whip-pan": (o) => whipPan(o),
|
|
1011
|
+
"film-burn": (o) => filmBurn(o),
|
|
1012
|
+
"luma-wipe": () => lumaWipe()
|
|
1013
|
+
};
|
|
1014
|
+
var presentationFor = (type, options) => (TRANSITIONS[type] ?? crossFade)(options);
|
|
1015
|
+
var isComponent = (v) => typeof v === "function";
|
|
1016
|
+
var NAME_ALIASES = {
|
|
1017
|
+
RgbGlitchText: "RgbGlitch"
|
|
1018
|
+
// ondajs `rgb-glitch-text` → @onda `RgbGlitch`
|
|
1019
|
+
};
|
|
1020
|
+
function defaultRegistry() {
|
|
1021
|
+
const reg = {};
|
|
1022
|
+
for (const [name, value] of Object.entries(Components)) {
|
|
1023
|
+
if (/^[A-Z]/.test(name) && isComponent(value)) {
|
|
1024
|
+
reg[name] = value;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
for (const [alias, target] of Object.entries(NAME_ALIASES)) {
|
|
1028
|
+
if (reg[target] && !reg[alias]) reg[alias] = reg[target];
|
|
1029
|
+
}
|
|
1030
|
+
return reg;
|
|
1031
|
+
}
|
|
1032
|
+
function errorPlaceholder(name) {
|
|
1033
|
+
return createElement(
|
|
1034
|
+
AbsoluteFill,
|
|
1035
|
+
{ justify: "center", align: "center" },
|
|
1036
|
+
createElement(
|
|
1037
|
+
Text,
|
|
1038
|
+
{ fontSize: 32, color: "#ff6b6b", fontWeight: 600 },
|
|
1039
|
+
`\u26A0 unknown component: ${name}`
|
|
1040
|
+
)
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
function matteClipProps(matte, clip, registry, width, height) {
|
|
1044
|
+
const out = {};
|
|
1045
|
+
if (matte) {
|
|
1046
|
+
const Comp = registry[matte.component];
|
|
1047
|
+
if (Comp) {
|
|
1048
|
+
out.matte = createElement(Comp, adaptProps(matte.component, matte.props, width, height));
|
|
1049
|
+
out.matteMode = matte.mode ?? "alpha";
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
if (clip) {
|
|
1053
|
+
out.clip = clip.shape === "ellipse" ? clipEllipse(clip.width ?? width, clip.height ?? height) : clip.shape === "path" && clip.data ? clipPath(clip.data) : clipRect(clip.width ?? width, clip.height ?? height, clip.cornerRadius);
|
|
1054
|
+
}
|
|
1055
|
+
return out;
|
|
1056
|
+
}
|
|
1057
|
+
function AnimatedEntry({
|
|
1058
|
+
component,
|
|
1059
|
+
props,
|
|
1060
|
+
animate,
|
|
1061
|
+
effects,
|
|
1062
|
+
depth,
|
|
1063
|
+
transform3d,
|
|
1064
|
+
matte,
|
|
1065
|
+
clip,
|
|
1066
|
+
durationInFrames,
|
|
1067
|
+
registry
|
|
1068
|
+
}) {
|
|
1069
|
+
const frame = useCurrentFrame();
|
|
1070
|
+
const { fps, width, height } = useVideoConfig();
|
|
1071
|
+
const m = composeMotion(animate, frame, fps, durationInFrames);
|
|
1072
|
+
const Comp = registry[component];
|
|
1073
|
+
const base = Comp ? createElement(Comp, adaptProps(component, props, width, height)) : errorPlaceholder(component);
|
|
1074
|
+
const fxChild = effects || typeof depth === "number" ? createElement(
|
|
1075
|
+
Group,
|
|
1076
|
+
{ ...effects ?? {}, ...typeof depth === "number" ? { depth } : {} },
|
|
1077
|
+
base
|
|
1078
|
+
) : base;
|
|
1079
|
+
const matteChild = matte || clip ? createElement(
|
|
1080
|
+
Group,
|
|
1081
|
+
matteClipProps(matte, clip, registry, width, height),
|
|
1082
|
+
fxChild
|
|
1083
|
+
) : fxChild;
|
|
1084
|
+
const child = transform3d && Object.keys(transform3d).length > 0 ? createElement(
|
|
1085
|
+
Scene3D,
|
|
1086
|
+
{},
|
|
1087
|
+
createElement(
|
|
1088
|
+
Group,
|
|
1089
|
+
{ ...transform3d },
|
|
1090
|
+
matteChild
|
|
1091
|
+
)
|
|
1092
|
+
) : matteChild;
|
|
1093
|
+
const cx = width / 2;
|
|
1094
|
+
const cy = height / 2;
|
|
1095
|
+
const [px, py] = SELF_ANCHORING.has(component) ? [0, 0] : placementOffset(props, width, height);
|
|
1096
|
+
return createElement(
|
|
1097
|
+
Group,
|
|
1098
|
+
{ x: m.x + px, y: m.y + py, opacity: m.opacity },
|
|
1099
|
+
createElement(
|
|
1100
|
+
Group,
|
|
1101
|
+
{ x: cx, y: cy },
|
|
1102
|
+
createElement(
|
|
1103
|
+
Group,
|
|
1104
|
+
{ scaleX: m.scaleX, scaleY: m.scaleY, rotation: m.rotation },
|
|
1105
|
+
createElement(Group, { x: -cx, y: -cy }, child)
|
|
1106
|
+
)
|
|
1107
|
+
)
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
function MorphSuppressGate({
|
|
1111
|
+
suppress,
|
|
1112
|
+
children
|
|
1113
|
+
}) {
|
|
1114
|
+
const frame = useCurrentFrame();
|
|
1115
|
+
if (frame >= suppress.from && frame < suppress.to) return null;
|
|
1116
|
+
return children;
|
|
1117
|
+
}
|
|
1118
|
+
function EntrySlot({
|
|
1119
|
+
entry,
|
|
1120
|
+
registry,
|
|
1121
|
+
suppress
|
|
1122
|
+
}) {
|
|
1123
|
+
const { fps } = useVideoConfig();
|
|
1124
|
+
const animated = createElement(AnimatedEntry, {
|
|
1125
|
+
component: entry.component,
|
|
1126
|
+
props: entry.props,
|
|
1127
|
+
animate: entry.animate,
|
|
1128
|
+
effects: entry.effects,
|
|
1129
|
+
depth: entry.depth,
|
|
1130
|
+
transform3d: entry.transform3d,
|
|
1131
|
+
matte: entry.matte,
|
|
1132
|
+
clip: entry.clip,
|
|
1133
|
+
durationInFrames: toFrames(entry.for, fps),
|
|
1134
|
+
registry
|
|
1135
|
+
});
|
|
1136
|
+
return createElement(
|
|
1137
|
+
Sequence,
|
|
1138
|
+
{ from: toFrames(entry.at, fps), durationInFrames: toFrames(entry.for, fps) },
|
|
1139
|
+
// biome-ignore lint/correctness/noChildrenProp: raw createElement props object, not JSX
|
|
1140
|
+
suppress ? createElement(MorphSuppressGate, { suppress, children: animated }) : animated
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
function AnimatedCamera({
|
|
1144
|
+
move,
|
|
1145
|
+
durationInFrames,
|
|
1146
|
+
children
|
|
1147
|
+
}) {
|
|
1148
|
+
const frame = useCurrentFrame();
|
|
1149
|
+
const { width, height } = useVideoConfig();
|
|
1150
|
+
const p = durationInFrames > 1 ? Math.min(1, Math.max(0, frame / (durationInFrames - 1))) : 1;
|
|
1151
|
+
const e = p * p * (3 - 2 * p);
|
|
1152
|
+
const from = move.from ?? {};
|
|
1153
|
+
const to = move.to ?? {};
|
|
1154
|
+
const lerp = (a, b, d) => {
|
|
1155
|
+
const av = a ?? d;
|
|
1156
|
+
const bv = b ?? d;
|
|
1157
|
+
return av + (bv - av) * e;
|
|
1158
|
+
};
|
|
1159
|
+
return createElement(
|
|
1160
|
+
Camera,
|
|
1161
|
+
{
|
|
1162
|
+
focusX: lerp(from.x, to.x, 0.5) * width,
|
|
1163
|
+
focusY: lerp(from.y, to.y, 0.5) * height,
|
|
1164
|
+
zoom: lerp(from.zoom, to.zoom, 1),
|
|
1165
|
+
rotate: lerp(from.rotate, to.rotate, 0)
|
|
1166
|
+
},
|
|
1167
|
+
children
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
function SceneTracks({
|
|
1171
|
+
scene,
|
|
1172
|
+
registry,
|
|
1173
|
+
suppress,
|
|
1174
|
+
responsive
|
|
1175
|
+
}) {
|
|
1176
|
+
return createElement(
|
|
1177
|
+
Group,
|
|
1178
|
+
null,
|
|
1179
|
+
...scene.tracks.map(
|
|
1180
|
+
(track, ti) => createElement(
|
|
1181
|
+
Group,
|
|
1182
|
+
{ key: track.id ?? `track-${ti}` },
|
|
1183
|
+
...track.entries.map((entry, ei) => {
|
|
1184
|
+
const key = entry.id ?? `entry-${ei}`;
|
|
1185
|
+
const slot = createElement(EntrySlot, { entry, registry, suppress: suppress?.get(entry) });
|
|
1186
|
+
if (!responsive) return cloneElement(slot, { key });
|
|
1187
|
+
const t = Components.responsiveEntryTransform(
|
|
1188
|
+
Components.entryDesignAnchor(entry.props),
|
|
1189
|
+
responsive.design,
|
|
1190
|
+
responsive.out
|
|
1191
|
+
);
|
|
1192
|
+
if (t.x === 0 && t.y === 0 && t.scale === 1) return cloneElement(slot, { key });
|
|
1193
|
+
return createElement(
|
|
1194
|
+
Group,
|
|
1195
|
+
{ key, x: t.x, y: t.y, scaleX: t.scale, scaleY: t.scale },
|
|
1196
|
+
slot
|
|
1197
|
+
);
|
|
1198
|
+
})
|
|
1199
|
+
)
|
|
1200
|
+
)
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
function entryTransform(entry, w, h) {
|
|
1204
|
+
const [px, py] = SELF_ANCHORING.has(entry.component) ? [0, 0] : placementOffset(entry.props, w, h);
|
|
1205
|
+
const s = entry.props?.scale;
|
|
1206
|
+
return { x: px, y: py, scale: typeof s === "number" && Number.isFinite(s) ? s : 1 };
|
|
1207
|
+
}
|
|
1208
|
+
function morphEntriesAtTail(scene, fps, sceneDur, overlap) {
|
|
1209
|
+
const m = /* @__PURE__ */ new Map();
|
|
1210
|
+
const cutStart = sceneDur - overlap;
|
|
1211
|
+
for (const track of scene.tracks) {
|
|
1212
|
+
for (const e of track.entries) {
|
|
1213
|
+
if (!e.morphKey) continue;
|
|
1214
|
+
const start = toFrames(e.at, fps);
|
|
1215
|
+
const end = start + toFrames(e.for, fps);
|
|
1216
|
+
if (start < sceneDur && end > cutStart) m.set(e.morphKey, e);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
return m;
|
|
1220
|
+
}
|
|
1221
|
+
function morphEntriesAtHead(scene, fps, overlap) {
|
|
1222
|
+
const m = /* @__PURE__ */ new Map();
|
|
1223
|
+
for (const track of scene.tracks) {
|
|
1224
|
+
for (const e of track.entries) {
|
|
1225
|
+
if (!e.morphKey) continue;
|
|
1226
|
+
const start = toFrames(e.at, fps);
|
|
1227
|
+
const end = start + toFrames(e.for, fps);
|
|
1228
|
+
if (start < overlap && end > 0) m.set(e.morphKey, e);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
return m;
|
|
1232
|
+
}
|
|
1233
|
+
function MorphLayer({ pair, registry }) {
|
|
1234
|
+
const frame = useCurrentFrame();
|
|
1235
|
+
const { width, height } = useVideoConfig();
|
|
1236
|
+
const n = pair.overlapFrames;
|
|
1237
|
+
const p = n > 1 ? Math.min(1, Math.max(0, frame / (n - 1))) : 1;
|
|
1238
|
+
const e = p * p * (3 - 2 * p);
|
|
1239
|
+
const lerp = (a, b) => a + (b - a) * e;
|
|
1240
|
+
const x = lerp(pair.from.x, pair.toT.x);
|
|
1241
|
+
const y = lerp(pair.from.y, pair.toT.y);
|
|
1242
|
+
const scale = lerp(pair.from.scale, pair.toT.scale);
|
|
1243
|
+
const Comp = registry[pair.to.component];
|
|
1244
|
+
const base = Comp ? createElement(Comp, adaptProps(pair.to.component, pair.to.props, width, height)) : errorPlaceholder(pair.to.component);
|
|
1245
|
+
const cx = width / 2;
|
|
1246
|
+
const cy = height / 2;
|
|
1247
|
+
return createElement(
|
|
1248
|
+
Group,
|
|
1249
|
+
{ x, y },
|
|
1250
|
+
createElement(
|
|
1251
|
+
Group,
|
|
1252
|
+
{ x: cx, y: cy },
|
|
1253
|
+
createElement(
|
|
1254
|
+
Group,
|
|
1255
|
+
{ scaleX: scale, scaleY: scale },
|
|
1256
|
+
createElement(Group, { x: -cx, y: -cy }, base)
|
|
1257
|
+
)
|
|
1258
|
+
)
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
function planMorphs(scenes, fps, w, h, sceneStart, sceneDur) {
|
|
1262
|
+
const pairs = [];
|
|
1263
|
+
const suppress = /* @__PURE__ */ new Map();
|
|
1264
|
+
const addSuppress = (sceneIdx, entry, win) => {
|
|
1265
|
+
let m = suppress.get(sceneIdx);
|
|
1266
|
+
if (!m) {
|
|
1267
|
+
m = /* @__PURE__ */ new Map();
|
|
1268
|
+
suppress.set(sceneIdx, m);
|
|
1269
|
+
}
|
|
1270
|
+
m.set(entry, win);
|
|
1271
|
+
};
|
|
1272
|
+
for (let i = 1; i < scenes.length; i++) {
|
|
1273
|
+
const prev = scenes[i - 1];
|
|
1274
|
+
const cur = scenes[i];
|
|
1275
|
+
if (!prev || !cur || !cur.transition) continue;
|
|
1276
|
+
const overlap = transitionOverlapFrames(prev, cur, fps);
|
|
1277
|
+
if (overlap <= 0) continue;
|
|
1278
|
+
const prevDur = sceneDur[i - 1] ?? 0;
|
|
1279
|
+
const aMap = morphEntriesAtTail(prev, fps, prevDur, overlap);
|
|
1280
|
+
if (aMap.size === 0) continue;
|
|
1281
|
+
const bMap = morphEntriesAtHead(cur, fps, overlap);
|
|
1282
|
+
if (bMap.size === 0) continue;
|
|
1283
|
+
const overlapStart = sceneStart[i] ?? 0;
|
|
1284
|
+
for (const [key, aEntry] of aMap) {
|
|
1285
|
+
const bEntry = bMap.get(key);
|
|
1286
|
+
if (!bEntry) continue;
|
|
1287
|
+
pairs.push({
|
|
1288
|
+
to: bEntry,
|
|
1289
|
+
from: entryTransform(aEntry, w, h),
|
|
1290
|
+
toT: entryTransform(bEntry, w, h),
|
|
1291
|
+
overlapStart,
|
|
1292
|
+
overlapFrames: overlap
|
|
1293
|
+
});
|
|
1294
|
+
addSuppress(i - 1, aEntry, { from: prevDur - overlap, to: prevDur });
|
|
1295
|
+
addSuppress(i, bEntry, { from: 0, to: overlap });
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
return { pairs, suppress };
|
|
1299
|
+
}
|
|
1300
|
+
function brandToTheme(brand) {
|
|
1301
|
+
const t = {};
|
|
1302
|
+
if (brand.accent) t.accent = brand.accent;
|
|
1303
|
+
if (brand.accentSoft) t.accentSoft = brand.accentSoft;
|
|
1304
|
+
if (brand.text) t.text = brand.text;
|
|
1305
|
+
if (brand.dim) t.textMuted = brand.dim;
|
|
1306
|
+
if (brand.bg) t.background = brand.bg;
|
|
1307
|
+
if (brand.surface) t.surface = brand.surface;
|
|
1308
|
+
if (brand.border) t.border = brand.border;
|
|
1309
|
+
if (brand.fontBody) t.fontFamily = brand.fontBody;
|
|
1310
|
+
if (brand.fontDisplay) t.headingFamily = brand.fontDisplay;
|
|
1311
|
+
return t;
|
|
1312
|
+
}
|
|
1313
|
+
function fitGroup(scene, width, height, child) {
|
|
1314
|
+
const dw = scene.designWidth;
|
|
1315
|
+
const dh = scene.designHeight;
|
|
1316
|
+
const fit = scene.fit;
|
|
1317
|
+
if (!dw || !dh || !fit || fit === "responsive" || dw === width && dh === height) return child;
|
|
1318
|
+
const scale = fit === "contain" ? Math.min(width / dw, height / dh) : Math.max(width / dw, height / dh);
|
|
1319
|
+
const x = (width - dw * scale) / 2;
|
|
1320
|
+
const y = (height - dh * scale) / 2;
|
|
1321
|
+
return createElement(Group, { x, y, scaleX: scale, scaleY: scale }, child);
|
|
1322
|
+
}
|
|
1323
|
+
function sceneResponsive(scene, width, height) {
|
|
1324
|
+
const dw = scene.designWidth;
|
|
1325
|
+
const dh = scene.designHeight;
|
|
1326
|
+
if (scene.fit !== "responsive" || !dw || !dh || dw === width && dh === height) return void 0;
|
|
1327
|
+
return { design: { width: dw, height: dh }, out: { width, height } };
|
|
1328
|
+
}
|
|
1329
|
+
function buildComposition(payload, opts = {}) {
|
|
1330
|
+
const {
|
|
1331
|
+
width,
|
|
1332
|
+
height,
|
|
1333
|
+
fps,
|
|
1334
|
+
scenes,
|
|
1335
|
+
layers = [],
|
|
1336
|
+
brand,
|
|
1337
|
+
linear,
|
|
1338
|
+
finish,
|
|
1339
|
+
motionBlur,
|
|
1340
|
+
dof
|
|
1341
|
+
} = payload;
|
|
1342
|
+
const registry = opts.registry ?? defaultRegistry();
|
|
1343
|
+
const total = totalFrames(payload, fps);
|
|
1344
|
+
const placements = scenePlacements(scenes, fps);
|
|
1345
|
+
const sceneDur = placements.map((p) => p.durationInFrames);
|
|
1346
|
+
const sceneStart = placements.map((p) => p.start);
|
|
1347
|
+
const morphPlan = planMorphs(scenes, fps, width, height, sceneStart, sceneDur);
|
|
1348
|
+
const seriesChildren = [];
|
|
1349
|
+
scenes.forEach((scene, i) => {
|
|
1350
|
+
if (i > 0 && scene.transition) {
|
|
1351
|
+
seriesChildren.push(
|
|
1352
|
+
createElement(TransitionSeries.Transition, {
|
|
1353
|
+
key: `transition-${i}`,
|
|
1354
|
+
presentation: presentationFor(scene.transition.type, scene.transition.options),
|
|
1355
|
+
timing: linearTiming({
|
|
1356
|
+
durationInFrames: transitionOverlapFrames(scenes[i - 1], scene, fps)
|
|
1357
|
+
})
|
|
1358
|
+
})
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
const sceneSuppress = morphPlan.suppress.get(i);
|
|
1362
|
+
const responsive = sceneResponsive(scene, width, height);
|
|
1363
|
+
seriesChildren.push(
|
|
1364
|
+
createElement(
|
|
1365
|
+
TransitionSeries.Sequence,
|
|
1366
|
+
{ key: scene.id, durationInFrames: sceneDur[i] ?? sceneDurationFrames(scene, fps) },
|
|
1367
|
+
scene.camera ? createElement(AnimatedCamera, {
|
|
1368
|
+
move: scene.camera,
|
|
1369
|
+
durationInFrames: sceneDur[i] ?? sceneDurationFrames(scene, fps),
|
|
1370
|
+
// biome-ignore lint/correctness/noChildrenProp: raw createElement props object, not JSX
|
|
1371
|
+
children: fitGroup(
|
|
1372
|
+
scene,
|
|
1373
|
+
width,
|
|
1374
|
+
height,
|
|
1375
|
+
createElement(SceneTracks, {
|
|
1376
|
+
scene,
|
|
1377
|
+
registry,
|
|
1378
|
+
suppress: sceneSuppress,
|
|
1379
|
+
responsive
|
|
1380
|
+
})
|
|
1381
|
+
)
|
|
1382
|
+
}) : fitGroup(
|
|
1383
|
+
scene,
|
|
1384
|
+
width,
|
|
1385
|
+
height,
|
|
1386
|
+
createElement(SceneTracks, { scene, registry, suppress: sceneSuppress, responsive })
|
|
1387
|
+
)
|
|
1388
|
+
)
|
|
1389
|
+
);
|
|
1390
|
+
});
|
|
1391
|
+
const morphLayers = morphPlan.pairs.map(
|
|
1392
|
+
(pair, i) => createElement(
|
|
1393
|
+
Sequence,
|
|
1394
|
+
{
|
|
1395
|
+
key: `morph-${i}`,
|
|
1396
|
+
from: pair.overlapStart,
|
|
1397
|
+
durationInFrames: pair.overlapFrames
|
|
1398
|
+
},
|
|
1399
|
+
createElement(MorphLayer, { pair, registry })
|
|
1400
|
+
)
|
|
1401
|
+
);
|
|
1402
|
+
const layerEls = (under) => layers.filter((l) => Boolean(l.under) === under).flatMap(
|
|
1403
|
+
(layer, li) => layer.entries.map((entry, ei) => {
|
|
1404
|
+
const from = toFrames(entry.at ?? 0, fps);
|
|
1405
|
+
const dur = entry.for != null ? toFrames(entry.for, fps) : Math.max(1, total - from);
|
|
1406
|
+
return createElement(
|
|
1407
|
+
Sequence,
|
|
1408
|
+
{ key: `layer-${under ? "u" : "o"}-${li}-${ei}`, from, durationInFrames: dur },
|
|
1409
|
+
createElement(AnimatedEntry, {
|
|
1410
|
+
component: entry.component,
|
|
1411
|
+
props: entry.props,
|
|
1412
|
+
animate: entry.animate,
|
|
1413
|
+
effects: entry.effects,
|
|
1414
|
+
depth: entry.depth,
|
|
1415
|
+
transform3d: entry.transform3d,
|
|
1416
|
+
matte: entry.matte,
|
|
1417
|
+
clip: entry.clip,
|
|
1418
|
+
durationInFrames: dur,
|
|
1419
|
+
registry
|
|
1420
|
+
})
|
|
1421
|
+
);
|
|
1422
|
+
})
|
|
1423
|
+
);
|
|
1424
|
+
const root = createElement(
|
|
1425
|
+
Group,
|
|
1426
|
+
null,
|
|
1427
|
+
createElement(Rect, { width, height, fill: brand?.bg ?? "#08080a" }),
|
|
1428
|
+
...layerEls(true),
|
|
1429
|
+
createElement(TransitionSeries, null, ...seriesChildren),
|
|
1430
|
+
...morphLayers,
|
|
1431
|
+
...layerEls(false)
|
|
1432
|
+
);
|
|
1433
|
+
const content = brand ? createElement(Components.ThemeProvider, { theme: brandToTheme(brand) }, root) : root;
|
|
1434
|
+
return createElement(
|
|
1435
|
+
Composition,
|
|
1436
|
+
{ width, height, fps, durationInFrames: total, linear, finish, motionBlur, dof },
|
|
1437
|
+
content
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
function editDistance(a, b) {
|
|
1441
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
1442
|
+
for (let i = 1; i <= a.length; i++) {
|
|
1443
|
+
const cur = [i];
|
|
1444
|
+
for (let j = 1; j <= b.length; j++) {
|
|
1445
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
1446
|
+
cur[j] = Math.min((prev[j] ?? 0) + 1, (cur[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
|
|
1447
|
+
}
|
|
1448
|
+
prev = cur;
|
|
1449
|
+
}
|
|
1450
|
+
return prev[b.length] ?? 0;
|
|
1451
|
+
}
|
|
1452
|
+
function closestComponent(name, registry) {
|
|
1453
|
+
let best = null;
|
|
1454
|
+
let bestD = Number.POSITIVE_INFINITY;
|
|
1455
|
+
for (const k of Object.keys(registry)) {
|
|
1456
|
+
const d = editDistance(name.toLowerCase(), k.toLowerCase());
|
|
1457
|
+
if (d < bestD) {
|
|
1458
|
+
bestD = d;
|
|
1459
|
+
best = k;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
return best && bestD <= Math.max(2, Math.floor(name.length / 3)) ? best : null;
|
|
1463
|
+
}
|
|
1464
|
+
function isValidTimeSpec(v) {
|
|
1465
|
+
if (typeof v === "number") return Number.isFinite(v);
|
|
1466
|
+
const s = v.trim();
|
|
1467
|
+
if (s === "") return false;
|
|
1468
|
+
const numOk = (x) => x.trim() !== "" && Number.isFinite(Number(x));
|
|
1469
|
+
if (s.includes(":")) {
|
|
1470
|
+
const [m, sec] = s.split(":");
|
|
1471
|
+
return numOk(m ?? "") && numOk(sec ?? "");
|
|
1472
|
+
}
|
|
1473
|
+
if (s.endsWith("ms")) return numOk(s.slice(0, -2));
|
|
1474
|
+
if (s.endsWith("f")) return numOk(s.slice(0, -1));
|
|
1475
|
+
if (s.endsWith("s")) return numOk(s.slice(0, -1));
|
|
1476
|
+
return numOk(s);
|
|
1477
|
+
}
|
|
1478
|
+
var BRIDGE_PROPS = ["placement", "scale"];
|
|
1479
|
+
function knownPropsFor(component) {
|
|
1480
|
+
const m = Components.manifestEntry(component);
|
|
1481
|
+
if (!m) return null;
|
|
1482
|
+
const known = new Set(m.props.map((p) => p.name));
|
|
1483
|
+
const shape = m.schema.shape;
|
|
1484
|
+
if (shape && typeof shape === "object") for (const k of Object.keys(shape)) known.add(k);
|
|
1485
|
+
for (const src of Object.keys(PROP_ALIASES[component] ?? {})) known.add(src);
|
|
1486
|
+
for (const k of BRIDGE_PROPS) known.add(k);
|
|
1487
|
+
return known;
|
|
1488
|
+
}
|
|
1489
|
+
function validateComposition(payload, opts = {}) {
|
|
1490
|
+
const registry = opts.registry ?? defaultRegistry();
|
|
1491
|
+
const fidelity = Components.COMPONENT_FIDELITY;
|
|
1492
|
+
const diags = [];
|
|
1493
|
+
if (!(payload.fps > 0)) diags.push({ level: "error", path: "fps", message: "fps must be > 0" });
|
|
1494
|
+
if (!(payload.width > 0 && payload.height > 0))
|
|
1495
|
+
diags.push({ level: "error", path: "size", message: "width/height must be > 0" });
|
|
1496
|
+
if (!payload.scenes?.length)
|
|
1497
|
+
diags.push({ level: "error", path: "scenes", message: "composition has no scenes" });
|
|
1498
|
+
const fps = payload.fps > 0 ? payload.fps : 30;
|
|
1499
|
+
const checkTime = (v, path, required) => {
|
|
1500
|
+
if (v === void 0) {
|
|
1501
|
+
if (required) diags.push({ level: "error", path, message: "missing required time" });
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
if (!isValidTimeSpec(v)) {
|
|
1505
|
+
diags.push({
|
|
1506
|
+
level: "error",
|
|
1507
|
+
path,
|
|
1508
|
+
message: `invalid time ${JSON.stringify(v)} \u2014 use seconds (e.g. 2) or a spec string ("2s", "500ms", "0:02", "90f")`
|
|
1509
|
+
});
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
const secs2 = typeof v === "number" ? v : timeSpecToSeconds(v, fps);
|
|
1513
|
+
if (secs2 < 0)
|
|
1514
|
+
diags.push({ level: "error", path, message: `time ${JSON.stringify(v)} is negative` });
|
|
1515
|
+
};
|
|
1516
|
+
const checkEntry = (e, path, timed) => {
|
|
1517
|
+
if (e.role !== void 0 && e.role !== "focal" && e.role !== "support" && e.role !== "ambient")
|
|
1518
|
+
diags.push({
|
|
1519
|
+
level: "error",
|
|
1520
|
+
path: `${path}.role`,
|
|
1521
|
+
message: `invalid role ${JSON.stringify(e.role)} \u2014 use 'focal' | 'support' | 'ambient' (absent = 'support')`
|
|
1522
|
+
});
|
|
1523
|
+
if (!registry[e.component]) {
|
|
1524
|
+
const guess = closestComponent(e.component, registry);
|
|
1525
|
+
diags.push({
|
|
1526
|
+
level: "error",
|
|
1527
|
+
path: `${path}.component`,
|
|
1528
|
+
message: `unknown component "${e.component}"${guess ? ` \u2014 did you mean "${guess}"?` : ""}`
|
|
1529
|
+
});
|
|
1530
|
+
} else {
|
|
1531
|
+
const f = fidelity?.[e.component];
|
|
1532
|
+
if (f?.fidelity === "apes_remotion")
|
|
1533
|
+
diags.push({
|
|
1534
|
+
level: "warning",
|
|
1535
|
+
path: `${path}.component`,
|
|
1536
|
+
message: `"${e.component}" imitates a browser-only effect the engine doesn't do natively \u2014 it renders a stylized approximation; avoid for hero moments.`
|
|
1537
|
+
});
|
|
1538
|
+
else if (f?.fidelity === "degraded")
|
|
1539
|
+
diags.push({
|
|
1540
|
+
level: "info",
|
|
1541
|
+
path: `${path}.component`,
|
|
1542
|
+
message: `"${e.component}" renders an approximation until the engine gains "${f.needsFeature}".`
|
|
1543
|
+
});
|
|
1544
|
+
if (f?.backend === "gpu_only")
|
|
1545
|
+
diags.push({
|
|
1546
|
+
level: "warning",
|
|
1547
|
+
path: `${path}.component`,
|
|
1548
|
+
message: `"${e.component}" needs the GPU (Vello) backend \u2014 it won't render correctly on the CPU reference (e.g. a CPU-verified or no-GPU export).`
|
|
1549
|
+
});
|
|
1550
|
+
const known = knownPropsFor(e.component);
|
|
1551
|
+
if (known && e.props) {
|
|
1552
|
+
for (const k of Object.keys(e.props)) {
|
|
1553
|
+
if (!known.has(k))
|
|
1554
|
+
diags.push({
|
|
1555
|
+
level: "warning",
|
|
1556
|
+
path: `${path}.props.${k}`,
|
|
1557
|
+
message: `unknown prop "${k}" on ${e.component} \u2014 passed through (the component ignores props it doesn't declare)`
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
checkTime(e.at, `${path}.at`, timed);
|
|
1563
|
+
checkTime(e.for, `${path}.for`, timed);
|
|
1564
|
+
(e.animate ?? []).forEach((a, i) => {
|
|
1565
|
+
if (!CHOREOGRAPHY[a.pattern])
|
|
1566
|
+
diags.push({
|
|
1567
|
+
level: "warning",
|
|
1568
|
+
path: `${path}.animate[${i}]`,
|
|
1569
|
+
message: `unknown choreography pattern "${a.pattern}" (ignored)`
|
|
1570
|
+
});
|
|
1571
|
+
});
|
|
1572
|
+
};
|
|
1573
|
+
payload.scenes?.forEach((scene, si) => {
|
|
1574
|
+
if (scene.transition && !TRANSITIONS[scene.transition.type])
|
|
1575
|
+
diags.push({
|
|
1576
|
+
level: "warning",
|
|
1577
|
+
path: `scenes[${si}].transition`,
|
|
1578
|
+
message: `transition "${scene.transition.type}" not in the engine yet \u2014 falls back to cross-fade`
|
|
1579
|
+
});
|
|
1580
|
+
if (!scene.tracks?.length)
|
|
1581
|
+
diags.push({ level: "warning", path: `scenes[${si}].tracks`, message: "scene has no tracks" });
|
|
1582
|
+
scene.tracks?.forEach(
|
|
1583
|
+
(track, ti) => track.entries.forEach(
|
|
1584
|
+
(e, ei) => checkEntry(e, `scenes[${si}].tracks[${ti}].entries[${ei}]`, true)
|
|
1585
|
+
)
|
|
1586
|
+
);
|
|
1587
|
+
});
|
|
1588
|
+
payload.layers?.forEach(
|
|
1589
|
+
(layer, li) => layer.entries.forEach((e, ei) => checkEntry(e, `layers[${li}].entries[${ei}]`, false))
|
|
1590
|
+
);
|
|
1591
|
+
return diags;
|
|
1592
|
+
}
|
|
1593
|
+
//! The Studio→engine PROP vocabulary — size-role tokens, prop-name aliases,
|
|
1594
|
+
//! placement coords, and the self-anchoring list. Extracted (verbatim) from the
|
|
1595
|
+
//! renderer in `index.tsx` so the inspector can resolve an entry's effective
|
|
1596
|
+
//! props/placement the SAME way `buildComposition` does, without importing the
|
|
1597
|
+
//! React renderer. Behavior is identical to the pre-extraction code.
|
|
1598
|
+
//! Time/frame math — mirrors Studio's `composition.ts` helpers so scene windows
|
|
1599
|
+
//! and audio offsets line up with what actually plays.
|
|
1600
|
+
//! The timeline composition payload — the document an agent (ONDA Studio) emits
|
|
1601
|
+
//! and `buildComposition` turns into an @onda-engine/react scene. Structural mirror of
|
|
1602
|
+
//! Studio's `composition.ts` schema (kept as plain types; validation is
|
|
1603
|
+
//! `validateComposition`).
|
|
1604
|
+
//! Inspector constants — every threshold the checks measure against, with its
|
|
1605
|
+
//! source. Research-backed values cite the standard/paper; the rest are marked
|
|
1606
|
+
//! PRODUCT DECISION (ours to tune, no external authority).
|
|
1607
|
+
//! `timing.collisions` — attention can't be in two places at once.
|
|
1608
|
+
//!
|
|
1609
|
+
//! Three measurements:
|
|
1610
|
+
//! 1. Two FOCAL entrances beginning within the 250ms attention window
|
|
1611
|
+
//! (attentional blink: a second target 200–500ms after a first is routinely
|
|
1612
|
+
//! missed — Raymond, Shapiro & Arnell 1992; see constants.ts).
|
|
1613
|
+
//! 2. An entrance whose settle (the `@onda-engine/components` settleTime registry —
|
|
1614
|
+
//! the same formulas the components run) outlives the entry's visible
|
|
1615
|
+
//! window: the move is cut off mid-flight.
|
|
1616
|
+
//! 3. A scene transition longer than the 0.6s budget.
|
|
1617
|
+
//! `density.score` — how much is on screen at once, per scene?
|
|
1618
|
+
//! An event sweep over entry visible windows finds each scene's PEAK count of
|
|
1619
|
+
//! concurrently visible non-ambient entries (and focal entries). Budgets:
|
|
1620
|
+
//! ≤5 non-ambient, ≤1 focal (see constants.ts — product decisions). The peaks
|
|
1621
|
+
//! are always reported on the InspectReport; violations fire over budget.
|
|
1622
|
+
//! `frames.transitionCapture` — don't thumbnail a frame that's mid-transition.
|
|
1623
|
+
//! Given `opts.frames` (the indices a consumer intends to capture), flag any
|
|
1624
|
+
//! that land inside a transition's overlap window — those frames show two
|
|
1625
|
+
//! scenes blended. The fix is mechanical: the nearest frame outside the window.
|
|
1626
|
+
//! WCAG color math — hex parsing, relative luminance, contrast ratio.
|
|
1627
|
+
//! Formulas from WCAG 2.x: relative luminance per
|
|
1628
|
+
//! https://www.w3.org/WAI/GL/wiki/Relative_luminance (sRGB linearization), and
|
|
1629
|
+
//! contrast ratio `(L1 + 0.05) / (L2 + 0.05)` per
|
|
1630
|
+
//! https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum (SC 1.4.3).
|
|
1631
|
+
//! Engine colors are hex (`#rgb`, `#rrggbb`, `#rrggbbaa`) — anything else is
|
|
1632
|
+
//! "unparseable" and the caller treats the contrast as unverifiable.
|
|
1633
|
+
//! Resolve a composition payload to the FRAME timeline the checks measure on —
|
|
1634
|
+
//! the same resolution `buildComposition` performs (shared `scenePlacements` +
|
|
1635
|
+
//! `toFrames` + `adaptProps`), minus the React tree. Every entry gets absolute
|
|
1636
|
+
//! start/visible frames, its effective (size-role-resolved) props, and its
|
|
1637
|
+
//! attention role.
|
|
1638
|
+
//! Text extraction — what does this entry SAY, at what size, in what color?
|
|
1639
|
+
//! Driven by the `@onda-engine/components` manifest (per-prop semantic roles), so the
|
|
1640
|
+
//! inspector knows each component's text props + defaults without hardcoding
|
|
1641
|
+
//! eighty dialects. A `TextBlock` is one string a viewer reads: content +
|
|
1642
|
+
//! resolved px font size + resolved color + the measurement options needed to
|
|
1643
|
+
//! reproduce the component's own metrics.
|
|
1644
|
+
//! `text.legibility` — is every readable string big enough and contrasty enough?
|
|
1645
|
+
//! Two measurements per text block:
|
|
1646
|
+
//! 1. Font size vs the per-format research floor (legibility.info: 40px body
|
|
1647
|
+
//! minimum for full-HD phone-first formats — see constants.ts).
|
|
1648
|
+
//! 2. WCAG 2.x contrast of the text color vs what's BEHIND its placement box,
|
|
1649
|
+
//! found by walking the z-order beneath the entry: solid fills and gradient
|
|
1650
|
+
//! stops are checked analytically (worst stop wins, translucent veils are
|
|
1651
|
+
//! alpha-composited); an image/video behind yields an `info` — contrast is
|
|
1652
|
+
//! unverifiable analytically.
|
|
1653
|
+
//! `layout.overflow` — does measured text stay inside the frame and the
|
|
1654
|
+
//! format's safe area?
|
|
1655
|
+
//! Width comes from the engine's own text metrics (`measureText`, cosmic-text
|
|
1656
|
+
//! via wasm — warm it with `preloadTextMetrics()` in Node for real numbers; the
|
|
1657
|
+
//! glyph-count estimate is the documented fallback). Placement resolves through
|
|
1658
|
+
//! the shared `resolvePlacement` contract with the measured element size, so
|
|
1659
|
+
//! the checked box is where the component actually sits. Entries whose own
|
|
1660
|
+
//! `fit`/`maxWidth` auto-fit caps the line are measured at the FITTED size.
|
|
1661
|
+
//! `timing.readingTime` — does every text stay up long enough to be read?
|
|
1662
|
+
//! Needed time = `max(1.2s, 0.25s × words + 0.6s)` — see constants.ts for the
|
|
1663
|
+
//! research trail (BBC subtitle guidelines, Brysbaert 2019). Measured against
|
|
1664
|
+
//! the entry's VISIBLE window (its duration clamped to the scene cut).
|
|
1665
|
+
//! The INSPECTOR — deterministic quality metrics over a composition payload.
|
|
1666
|
+
//! `inspect(payload, opts?)` measures the SAME document `validateComposition`
|
|
1667
|
+
//! checks and `buildComposition` renders, resolved to frames with the same
|
|
1668
|
+
//! helpers, and returns structured violations: legibility (font floors + WCAG
|
|
1669
|
+
//! contrast), layout overflow vs per-platform safe areas, reading time, focal
|
|
1670
|
+
//! entrance/settle/transition collisions, per-scene density, and
|
|
1671
|
+
//! transition-window thumbnail capture. Every threshold lives in
|
|
1672
|
+
//! `constants.ts` with its source.
|
|
1673
|
+
//! Where `validateComposition` answers "will this render?", `inspect` answers
|
|
1674
|
+
//! "will this read?" — both deterministic, both agent-correctable.
|
|
1675
|
+
//! Text widths use the engine's cosmic-text metrics; call
|
|
1676
|
+
//! `preloadTextMetrics()` (from `@onda-engine/components`) before inspecting in Node
|
|
1677
|
+
//! for shaped widths instead of the glyph-count estimate.
|
|
1678
|
+
//! `@onda-engine/cinema` — turn a timeline composition payload into an `@onda-engine/react`
|
|
1679
|
+
//! scene. This is the spec→engine renderer ONDA Studio uses in place of its
|
|
1680
|
+
//! Remotion `composition-renderer`: scenes play through a `<TransitionSeries>`,
|
|
1681
|
+
//! tracks layer as `<AbsoluteFill>`s, and each entry is a registry component
|
|
1682
|
+
//! wrapped in its choreography — applied as numeric `Motion` on a `<Group>`
|
|
1683
|
+
//! (the engine transform), not CSS.
|
|
1684
|
+
|
|
1685
|
+
export { CHECKS, CONTRAST_MIN_BODY, CONTRAST_MIN_LARGE, DENSITY_MAX_FOCAL, DENSITY_MAX_NON_AMBIENT, FOCAL_COLLISION_WINDOW_SECONDS, FONT_FLOOR_PX, READ_MIN_SECONDS, READ_ORIENTATION_SECONDS, READ_SECONDS_PER_WORD, SAFE_AREAS, TRANSITION_BUDGET_SECONDS, buildComposition, contrastRatio, fontFloorPx, inferFormat, inspect, parseColor, readingTimeSeconds, relativeLuminance, resolveComposition, scenePlacements, textBlocks, timeSpecToSeconds, toFrames, totalFrames, totalWords, validateComposition };
|
|
1686
|
+
//# sourceMappingURL=cinema.js.map
|
|
1687
|
+
//# sourceMappingURL=cinema.js.map
|