-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpatch-engine.js
More file actions
598 lines (559 loc) · 25.2 KB
/
Copy pathpatch-engine.js
File metadata and controls
598 lines (559 loc) · 25.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
/**
* patch-engine — V4-04C: linkedom-backed HTML patch + timeline restructure
*
* Shared semantics with src/video/v4/patchHtml.ts via the V5-P0B/P1A canonical
* table (`prop-map.js`, mirrored in the app as `src/video/v4/propMap.ts`;
* parity enforced byte-a-byte by tests in BOTH repos):
* - `textContent` / `text` (V5-P1A alias) → textContent
* - `src` → attribute
* - `background-image` → wrap `url(...)`
* - UI-only flags (autoAspect/volume/…) → FILTERED (no invalid CSS, P1 #4)
* - decl props (font/size/color/align/radius/…) → mapped CSS property with
* the unit from the table entry (V5-P1A)
* - attr props (animIn/out/dur/delay) → `data-anim-*` attributes (V5-P1A;
* visual materialization lands in S09)
* - GEOMETRY (x/y/width/height/rotation) → AD-1: `--el-*` custom properties
* consumed by individual transforms / layout vars on the same element.
* x/y values are ABSOLUTE composition coordinates converted to a translate
* DELTA against the authored inline `left/top` baseline — the engine never
* writes `left/top` literals (B1.3 fix).
*
* Patches target `#id` selectors ONLY (400 from the routes otherwise) —
* arbitrary selectors would let an authenticated user rewrite the runtime
* scripts of a composition.
*
* `applyTimelineRestructure` rewrites the composition deterministically from
* an editor timeline: per-clip data-duration = durationFrames/fps, data-start
* recomputed cumulatively, clips whose id vanished are removed, and the root
* data-duration becomes the new total.
*
* V5-P0C (seek determinism): every window written here (and by
* `normalizeClipWindows` at job staging) is quantized to the fps frame grid
* with FLOAT-EXACT contiguous boundaries — for each integer frame tick,
* exactly one clip window contains it (B2 fix). Builder-declared gaps are an
* exception: staging preserves every authored window and emits a high-signal
* warning rather than silently changing timeline intent. `window-lint.js`
* validates the invariant after every write.
*/
import { parseHTML } from "linkedom";
import { PROP_MAP, UI_ONLY_PROPS, GEOM_CONSUMPTION, computeGeomDelta } from "./prop-map.js";
import { collectAnimSpecs, upsertAnimBlock } from "./anim-presets.js";
import { collectKeyframeSpecs, upsertKeyframeBlock, touchesKeyframeProps } from "./keyframes-presets.js";
import { touchesAudioProps, upsertAudioBlock } from "./audio-presets.js";
import {
TRANSITION_TWEENS,
parseTransitionAttr,
collectTransitionSpecs,
upsertTransitionBlock,
} from "./transition-presets.js";
/** Numeric CSS value (px/deg candidates) — integers, decimals and negatives. */
const NUMERIC_VALUE_RE = /^-?\d+(\.\d+)?$/;
/** Props de animação (V5-P1D): o lote que as toca regenera o bloco materializado. */
const ANIM_PROP_RE = /^anim(In|Out|DurMs|DelayMs)$/;
/**
* V5-P1E (§6.5): propriedade RESERVADA para operações de nó no canal de
* patches — valores 'remove' | 'duplicate'. Caso especial ao nível de
* textContent/src (NÃO entra na PROP_MAP); espelhada nos três motores
* (app patchHtml.ts, helper do preview, aqui) com paridade por marcadores.
*/
export const NODE_OP_PROPERTY = "element";
/** Menor sufixo `-copy-N` livre para o id base (determinístico nos 3 motores). */
function nextCopyId(doc, baseId) {
for (let n = 1; ; n += 1) {
const candidate = `${baseId}-copy-${n}`;
if (!doc.getElementById(candidate)) return candidate;
}
}
/**
* Guarda V5-P1E: apagar/duplicar CENA (clip dono-da-raiz — mesma definição
* de ownership do normalizeClipWindows) é operação ESTRUTURAL da timeline
* (/restructure), nunca do inspector; permitir quebraria a contiguidade
* frame-exata das janelas (B2).
*/
function isRootOwnedSceneClip(el, doc) {
let hasClipClass = false;
try {
hasClipClass = Boolean(el.classList && el.classList.contains("clip"));
} catch {
return false;
}
if (!hasClipClass) return false;
const root = doc.querySelector("[data-composition-id]");
if (!root) return false;
const owner = typeof el.closest === "function" ? el.closest("[data-composition-id]") : null;
return owner === null || owner === root;
}
/** Extract one authored declaration value from a style attribute string. */
export function parseInlineDecl(styleAttr, prop) {
if (!styleAttr) return null;
const re = new RegExp(`(^|;)\\s*${prop}\\s*:\\s*([^;]*)`, "i");
const m = styleAttr.match(re);
return m ? m[2].trim() : null;
}
/** Authored px baseline for translate deltas (missing/auto/non-numeric → 0). */
function authoredOffsetPx(styleAttr, prop) {
const raw = parseInlineDecl(styleAttr, prop);
if (!raw) return 0;
const n = parseFloat(raw); // '340px' → 340 · 'auto'/'' → NaN → 0
return Number.isFinite(n) ? n : 0;
}
/**
* Apply one geometry patch (V5-P0B / AD-1): write the `--el-*` var (+ unit
* for numeric values; non-numeric pass verbatim without delta math) plus its
* consumption declaration on the SAME element. x/y deltas are computed
* against the authored inline left/top so the persisted HTML reproduces the
* preview exactly while keeping the authored origin untouched.
*/
function applyGeometryPatch(el, property, value) {
const entry = PROP_MAP[property];
const existing = el.getAttribute("style");
let out = value;
if (NUMERIC_VALUE_RE.test(value)) {
let num = Number(value);
if (property === "x") num = computeGeomDelta(authoredOffsetPx(existing, "left"), num);
if (property === "y") num = computeGeomDelta(authoredOffsetPx(existing, "top"), num);
out = `${num}${entry.unit}`;
}
el.setAttribute("style", mergeStyle(existing, entry.output, out));
const consumption = GEOM_CONSUMPTION[property] || null;
if (!consumption) return;
const sep = consumption.indexOf(":");
el.setAttribute(
"style",
mergeStyle(el.getAttribute("style"), consumption.slice(0, sep).trim(), consumption.slice(sep + 1).trim()),
);
}
/** Only `#id` selectors are accepted for patch application. */
export function assertIdSelector(selector) {
if (typeof selector !== "string" || !/^#[A-Za-z][\w-]*$/.test(selector)) {
throw Object.assign(new Error(`unsupported selector: ${selector} (only #id allowed)`), {
status: 400,
});
}
return selector.slice(1);
}
/**
* Apply `{selector, property, value}` patches to an HTML string via
* linkedom. Returns the patched HTML. Missing elements are skipped.
*/
export function applyPatchesLinkedom(html, patches) {
const { document } = parseHTML(html);
let applied = 0;
let touchedAnim = false;
let touchedNode = false;
/** V5-P1E: ids criados por ops `duplicate`, pela ordem de aplicação. */
const created = [];
for (const patch of patches) {
const id = assertIdSelector(patch.selector);
const el = document.getElementById(id);
if (!el) continue;
const property = String(patch.property);
const value = String(patch.value ?? "");
if (property === "textContent" || property === "text") {
el.textContent = value;
} else if (property === NODE_OP_PROPERTY) {
// V5-P1E §6.5: operações de nó — remove/duplicate. Cenas recusadas e
// valores desconhecidos fazem `continue` (não contam como aplicados).
if (isRootOwnedSceneClip(el, document)) continue;
if (value === "remove") {
if (el.parentNode) el.parentNode.removeChild(el);
touchedNode = true;
} else if (value === "duplicate") {
const newId = nextCopyId(document, id);
const clone = el.cloneNode(true);
clone.setAttribute("id", newId);
try {
clone.removeAttribute("data-hf-autostamped");
} catch {
/* atributo opcional */
}
if (el.parentNode) el.parentNode.insertBefore(clone, el.nextSibling);
created.push(newId);
touchedNode = true;
} else {
continue;
}
} else if (property === "src") {
el.setAttribute("src", value);
} else if (property === "background-image") {
el.setAttribute("style", mergeStyle(el.getAttribute("style"), "background-image", value.startsWith("url(") ? value : `url(${value})`));
} else if (UI_ONLY_PROPS.has(property)) {
// V5-P1A (aceitação P1 #4): flag sem efeito render — nada muda no HTML
// e o patch NÃO conta como aplicado.
continue;
} else if (PROP_MAP[property]?.kind === "geom") {
applyGeometryPatch(el, property, value);
} else if (PROP_MAP[property]?.kind === "attr") {
// V5-P1A §2.2: transporte de animação como atributo `data-anim-*`.
// V5-P8B §2.1: `keyframes` removido ('' = sem keys) apaga o atributo.
const attrOut = PROP_MAP[property].output;
if (value === "" && attrOut === "data-kf") el.removeAttribute(attrOut);
else el.setAttribute(attrOut, value);
// V5-P1D §2.3: o lote que toca animação regenera o bloco materializado.
touchedAnim = true;
} else if (PROP_MAP[property]?.kind === "decl") {
const entry = PROP_MAP[property];
const out = entry.unit && NUMERIC_VALUE_RE.test(value) ? `${value}${entry.unit}` : value;
el.setAttribute("style", mergeStyle(el.getAttribute("style"), entry.output, out));
} else {
// Unknown property: pass through verbatim (legacy behaviour).
el.setAttribute("style", mergeStyle(el.getAttribute("style"), property, value));
}
applied += 1;
}
// V5-P1D §2.3 + V5-P1E: materialização dos presets de animação — UM bloco
// `<script id="__vp-anim-materialized__">` regenerado a partir do DOM
// (determinístico → convergência byte-exata; zero specs remove o bloco).
// Lotes com ops de nó TAMBÉM regeneram: specs derivadas do DOM pós-op
// (clone de elemento animado ganha spec; specs de nós removidos somem).
// V5-P5C §10.3: lotes que tocam props de áudio (volume/muted/fades) também
// regeneram o bloco `__vp-audio-materialized__` (tweens de volume/fades).
let outHtml =
touchedAnim || touchedNode
? upsertAnimBlock(document.toString(), collectAnimSpecs(document))
: document.toString();
let touchedKf = false;
try {
touchedKf = touchesKeyframeProps(patches);
} catch {}
if (touchedKf || touchedNode) outHtml = upsertKeyframeBlock(outHtml, collectKeyframeSpecs(document));
if (touchesAudioProps(patches)) outHtml = upsertAudioBlock(outHtml);
return { html: outHtml, applied, created };
}
/** Merge one `prop: value` declaration into an existing style attribute.
* Canonical form (single spaces, no trailing `;`) guarantees re-applying the
* same geometry patches converges byte-exactly (idempotent persists). */
function mergeStyle(existing, prop, value) {
const decl = `${prop}: ${value}`;
if (!existing) return decl;
const re = new RegExp(`(^|;)\\s*${prop}\\s*:\\s*[^;]*;?`, "i");
const m = existing.match(re);
if (m) {
const sep = m[1];
const atEnd = m.index + m[0].length >= existing.length;
return existing.replace(re, () => `${sep ? `${sep} ` : ""}${decl}${atEnd ? "" : ";"}`);
}
return `${existing.replace(/;\s*$/, "")}; ${decl}`;
}
// ── V5-P0C: frame-exact window serialization (seek determinism) ─────────────
// The runtime evaluates visibility per element as
// t >= parseFloat(data-start) && t < parseFloat(data-start) + parseFloat(data-duration)
// with t on the frame grid (f/fps). Decimal rounding of the attributes makes
// `start + duration` overshoot the next written start by rounding noise and
// TWO scenes render at boundary ticks (B2). The helpers below guarantee the
// float-exact invariant: for every integer frame f,
// exactly one clip window contains fl(f/fps).
/** Bit-decrement for positive finite doubles → previous representable value. */
const f64Scratch = new Float64Array(1);
const u64Scratch = new BigUint64Array(f64Scratch.buffer);
function nextDownDouble(v) {
f64Scratch[0] = v;
u64Scratch[0] -= 1n;
return f64Scratch[0];
}
/**
* Serialize the duration of the window [a, b) such that the runtime-computed
* end `parse(a) + parse(d)` never exceeds the next boundary double `b`.
* Candidate is the exact double difference; if summation still overshoots
* (double-rounding), step down by 1 ULP — leaving at most a femtosecond-scale
* gap that frame-grid seeks can never land in.
*/
export function exactWindowDuration(a, b) {
let d = b - a;
let guard = 0;
while (a + d > b && guard < 8) {
d = nextDownDouble(d);
guard += 1;
}
return d;
}
/**
* V5-P0C §2.1 (V5-P2C §2.4, V5_16.2 Fase B): quantize top-level clip windows
* to the frame grid with contiguous BOUNDARIES. Deliberate transition
* overlaps declared via `data-transition-out` on the OUTGOING clip are
* preserved: the boundary grid k stays cumulative, but the outgoing window
* is re-extended by its declared overlap frames — normalize(restructured) is
* byte-stable. A declared gap greater than one frame is builder intent, not
* rounding drift: no window in that composition is rewritten and the first
* gap is returned as CLIP_WINDOWS_NON_CONTIGUOUS.
*
* Only clips OWNED BY THE COMPOSITION ROOT are rewritten; nested composition
* internals keep their own timeline. Clips without a usable data-duration
* pass through untouched.
*
* @param {string} html Composition HTML.
* @param {number} fps Frame grid (default 30).
* @returns {{ html: string, adjusted: Array<{id: string|null, from: {start: string, duration: string}, to: {start: string, duration: string}}>, warning: null|{code: string, previous_id: string|null, next_id: string|null, declared_start: number, expected_start: number, tolerance_seconds: number} }}
*/
export function normalizeClipWindows(html, fps = 30) {
if (!Number.isFinite(fps) || fps <= 0) {
throw Object.assign(new Error("fps must be a positive number"), { status: 400 });
}
const { document } = parseHTML(html);
const rootEl = document.querySelector("[data-composition-id]");
const clips = Array.from(document.querySelectorAll(".clip")).filter((el) => {
const owner = el.closest("[data-composition-id]");
return owner === null || owner === rootEl;
});
/** @type {Array<{el, core: number, ovFrames: number, declaredStart: number}>} */
const infos = [];
for (const el of clips) {
const rawDuration = parseFloat(el.getAttribute("data-duration"));
if (!Number.isFinite(rawDuration) || rawDuration <= 0) continue;
// sec*fps → round → /fps (master plan §5.2.1): the grid is integer frames.
// V5-P2C: the outgoing clip's raw duration INCLUDES its transition
// overlap — subtract it to recover the core frames on the grid.
const framesRaw = Math.max(1, Math.round(rawDuration * fps));
const tr = parseTransitionAttr(el.getAttribute("data-transition-out"));
const ovFrames = tr ? Math.round((tr.durationMs / 1000) * fps) : 0;
const declaredStart = parseFloat(el.getAttribute("data-start"));
infos.push({ el, core: Math.max(1, framesRaw - ovFrames), ovFrames, declaredStart });
}
const toleranceSeconds = 1 / fps;
let expectedFrames = 0;
for (let i = 0; i < infos.length; i++) {
const info = infos[i];
const expectedStart = expectedFrames / fps;
const declaredStartFrames = info.declaredStart * fps;
const frameComparisonEpsilon = Number.EPSILON * Math.max(
1,
Math.abs(declaredStartFrames),
Math.abs(expectedFrames),
) * 8;
if (
i > 0 &&
Number.isFinite(info.declaredStart) &&
declaredStartFrames - expectedFrames > 1 + frameComparisonEpsilon
) {
const previous = infos[i - 1].el;
return {
html,
adjusted: [],
warning: {
code: "CLIP_WINDOWS_NON_CONTIGUOUS",
previous_id: previous.id || previous.getAttribute("data-hf-id") || null,
next_id: info.el.id || info.el.getAttribute("data-hf-id") || null,
declared_start: info.declaredStart,
expected_start: expectedStart,
tolerance_seconds: toleranceSeconds,
},
};
}
expectedFrames += info.core;
}
const adjusted = [];
let cursorFrames = 0;
for (const { el, core, ovFrames } of infos) {
const startSec = cursorFrames / fps;
cursorFrames += core;
const nextSec = cursorFrames / fps;
// The outgoing window extends back over the overlap it declares; the
// incoming neighbor keeps starting exactly at the boundary.
const endSec = nextSec + ovFrames / fps;
const durSec = exactWindowDuration(startSec, endSec);
const from = {
start: el.getAttribute("data-start") ?? "",
duration: el.getAttribute("data-duration") ?? "",
};
const to = { start: String(startSec), duration: String(durSec) };
if (from.start !== to.start || from.duration !== to.duration) {
el.setAttribute("data-start", to.start);
el.setAttribute("data-duration", to.duration);
adjusted.push({ id: el.id || el.getAttribute("data-hf-id") || null, from, to });
}
}
return { html: document.toString(), adjusted, warning: null };
}
/**
* V5-P2C §2.4: normalize the transitions payload — keyed by the INCOMING
* clip id; unknown ids/kinds and non-finite durations are ignored, durations
* are clamped to the canonical [200, 800] ms range.
*
* @param {unknown} transitions Raw payload array.
* @returns {Map<string, { kind: string, durationMs: number }>}
*/
function normalizeTransitionsPayload(transitions) {
/** @type {Map<string, { kind: string, durationMs: number }>} */
const map = new Map();
if (!Array.isArray(transitions)) return map;
for (const t of transitions) {
if (!t || typeof t.id !== "string" || !t.id) continue;
const kind = typeof t.kind === "string" ? t.kind : "";
const ms = Number(t.durationMs);
if (!TRANSITION_TWEENS[kind] || !Number.isFinite(ms)) continue;
map.set(t.id, {
kind,
durationMs: Math.min(800, Math.max(200, Math.round(ms))),
});
}
return map;
}
/**
* V4-04C §2.3 (V5-P2C §2.4): rewrite the composition timeline from editor
* scenes + boundary transitions.
*
* Transition semantics: the OUTGOING clip's window is EXTENDED by the overlap
* frames past the shared boundary; the INCOMING clip starts intact at k_{i+1}
* and the root data-duration stays the cumulative total — every frame tick is
* covered by exactly one clip, except exactly two inside a declared overlap.
* Both sides get their self-describing attribute (`data-transition-out` on A,
* `data-transition-in` on B) and the materialization block is regenerated.
* Stale transition attributes are stripped first so removal converges
* byte-exactly to the clean apply.
*
* @param {string} html Composition HTML.
* @param {Array<{id: string, durationFrames: number}>} scenes Ordered scenes.
* @param {number} fps Timeline fps (default 30).
* @param {Array<{id: string, kind: string, durationMs: number}>} [transitions]
* Boundary transitions keyed by incoming scene id.
* @returns {{ html: string, applied: number, removed: string[] }}
*/
export function applyTimelineRestructure(html, scenes, fps = 30, transitions = [], replaceScene = null) {
if (!Array.isArray(scenes) || scenes.length === 0) {
throw Object.assign(new Error("scenes must be a non-empty array"), { status: 400 });
}
if (!Number.isFinite(fps) || fps <= 0) {
throw Object.assign(new Error("fps must be a positive number"), { status: 400 });
}
// V5-P4B (S19 §2.1): optional scene content replacement. The fragment is
// probed for structural violations BEFORE any mutation — scenes are owned
// by the timeline (B2 invariant) and scripts never enter through this
// channel (animations ride the data-anim-* attribute channel instead).
let replacedId = null;
if (replaceScene !== null && replaceScene !== undefined) {
if (
!replaceScene ||
typeof replaceScene !== "object" ||
typeof replaceScene.id !== "string" ||
replaceScene.id.length === 0
) {
throw Object.assign(new Error("replace_scene_html must be {id, html}"), { status: 400 });
}
assertReplaceableFragment(replaceScene.html);
replacedId = replaceScene.id;
}
const { document } = parseHTML(html);
const root = document.querySelector('[data-composition-id]');
// V5-P0C: only clips owned by THE composition root are scene clips —
// nested composition internals keep their own timeline.
const allClips = Array.from(document.querySelectorAll(".clip")).filter((el) => {
const owner = el.closest("[data-composition-id]");
return owner === null || owner === root;
});
const keepIds = new Set(scenes.map((s) => String(s.id)));
const removed = [];
for (const clip of allClips) {
const id = clip.id || clip.getAttribute("data-hf-id");
if (id && !keepIds.has(id)) {
removed.push(id);
clip.remove();
}
}
// V5-P0C §2.1: quantize to the frame grid (integer boundaries) and
// serialize with float-exact contiguity — the runtime must never see
// two windows covering the same frame tick outside a declared overlap (B2).
const entries = [];
for (const scene of scenes) {
const el = document.getElementById(String(scene.id));
if (!el) continue; // scene not present in the composition — skip
const frames = Number(scene.durationFrames);
if (!Number.isFinite(frames) || frames <= 0) {
throw Object.assign(new Error(`scene ${scene.id} has invalid durationFrames`), { status: 400 });
}
entries.push({ el, frames: Math.round(frames) });
}
// V5-P4B (S19 §2.1): swap the target scene's CONTENT — the clip element
// itself (id, window attrs, track index, transition attrs) stays untouched,
// so timing survives by construction, not by LLM discipline.
if (replacedId !== null) {
const entry = entries.find((e) => String(e.el.id) === replacedId);
if (!entry) {
throw Object.assign(new Error(`scene ${replacedId} not found`), { status: 404 });
}
entry.el.innerHTML = replaceScene.html;
}
// V5-P2C: stale attributes first — deterministic convergence when a
// transition is later removed (byte-equal to a clean apply).
for (const { el } of entries) {
el.removeAttribute("data-transition-out");
el.removeAttribute("data-transition-in");
}
const transMap = normalizeTransitionsPayload(transitions);
let cursorFrames = 0;
let applied = 0;
for (let i = 0; i < entries.length; i += 1) {
const { el, frames } = entries[i];
const startSec = cursorFrames / fps;
cursorFrames += frames;
const nextSec = cursorFrames / fps;
// Overlap of the boundary AFTER this clip (declared by its incoming
// neighbor), clamped to the frames actually available on both sides.
let extendFrames = 0;
const nextEntry = entries[i + 1];
if (nextEntry) {
const tr = transMap.get(String(nextEntry.el.id));
if (tr) {
extendFrames = Math.min(
Math.round((tr.durationMs / 1000) * fps),
frames,
nextEntry.frames,
);
const effectiveDurationMs = (extendFrames / fps) * 1000;
el.setAttribute("data-transition-out", `${tr.kind}@${effectiveDurationMs}`);
nextEntry.el.setAttribute("data-transition-in", `${tr.kind}@${effectiveDurationMs}`);
}
}
const endSec = (cursorFrames + extendFrames) / fps;
el.setAttribute("data-start", String(startSec));
el.setAttribute("data-duration", String(exactWindowDuration(startSec, endSec)));
applied += 1;
}
// The overlap lives INSIDE the total: root duration unchanged.
const totalSec = cursorFrames / fps;
if (root && entries.length > 0) {
root.setAttribute("data-duration", String(totalSec));
}
// V5-P2C §2.5: materialize the GSAP crossfade tweens in the persisted HTML
// (single static block; zero specs → block stripped).
let outHtml = upsertTransitionBlock(
document.toString(),
collectTransitionSpecs(document),
);
// V5-P4B (S19 §2.1): after a content swap, refresh the animation block so
// the fragment's data-anim-* specs ride the same materialized channel as
// inspector presets (V5-P1D). Without a swap the output is byte-identical
// to the pre-P4B endpoint.
if (replacedId !== null) {
outHtml = upsertAnimBlock(outHtml, collectAnimSpecs(document));
}
return { html: outHtml, applied, removed, replaced: replacedId };
}
/** Cap for a replace_scene_html fragment (defense in depth against abuse). */
export const SCENE_FRAGMENT_MAX_CHARS = 200_000;
/**
* V5-P4B (S19 §2.1): structural probe of a scene-content fragment BEFORE any
* mutation. Forbidden: <script> of any kind, .clip elements (scene structure
* belongs to the timeline payload — B2 invariant) and nested composition
* roots. Empty/oversized fragments are rejected as well.
*/
function assertReplaceableFragment(fragment) {
if (typeof fragment !== "string" || fragment.trim().length === 0) {
throw Object.assign(new Error("replace_scene_html.html must be a non-empty string"), { status: 400 });
}
if (fragment.length > SCENE_FRAGMENT_MAX_CHARS) {
throw Object.assign(
new Error(`replace_scene_html.html exceeds ${SCENE_FRAGMENT_MAX_CHARS} chars`),
{ status: 400 },
);
}
const { document } = parseHTML(`<body>${fragment}</body>`);
if (document.querySelector("script")) {
throw Object.assign(new Error("scene fragment must not contain script elements"), { status: 400 });
}
if (document.querySelector(".clip")) {
throw Object.assign(new Error("scene fragment must not contain clip elements"), { status: 400 });
}
if (document.querySelector("[data-composition-id]")) {
throw Object.assign(new Error("scene fragment must not contain a composition root"), { status: 400 });
}
}