Skip to content

Commit afafca4

Browse files
feat: make creator media edits render-safe (#3322)
* feat: make creator media edits render-safe * fix: align media playback timing * docs: add creator editing recipes * docs: expand creator editing guidance * fix: unify media source offsets * fix: scale natural media duration * fix: preserve natural media zero spans * fix: align compiled natural media timing * test: classify compiler media test as integration * fix: drop inactive media windows * fix: unify literal timing parsing * fix: keep browser media parsing serializable * fix: keep page timing readers strict * fix: close remaining preview timing gaps * fix(core): preserve Studio voice pitch at playback speed * chore: keep creator contract source-neutral
1 parent 049f561 commit afafca4

42 files changed

Lines changed: 2695 additions & 307 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/core/src/compiler/timingCompiler.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,23 @@ it("source contains no raw NUL bytes", () => {
1919
});
2020

2121
describe("compileTimingAttrs", () => {
22+
it.each(["", " ", "0s", "0abc", "0px", "-1s", "Infinity", "NaN"])(
23+
"does not partially parse invalid literal data-duration=%j",
24+
(duration) => {
25+
const html = `<video id="v1" src="a.mp4" data-start="2" data-duration="${duration}">`;
26+
const { html: compiled } = compileTimingAttrs(html);
27+
28+
expect(compiled).not.toContain("data-end=");
29+
},
30+
);
31+
32+
it("uses Number semantics for hexadecimal literal timing", () => {
33+
const { html: compiled } = compileTimingAttrs(
34+
'<video id="v1" src="a.mp4" data-start="2" data-duration="0x10">',
35+
);
36+
expect(compiled).toContain('data-end="18"');
37+
});
38+
2239
it("adds data-end when data-start and data-duration are present on a video", () => {
2340
const html = '<video id="v1" src="a.mp4" data-start="2" data-duration="5">';
2441
const { html: compiled, unresolved } = compileTimingAttrs(html);

packages/core/src/compiler/timingCompiler.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@
1515
* and call injectDurations() to complete the compilation.
1616
*/
1717

18+
import {
19+
parseStrictFiniteTimingNumber,
20+
readElementPlaybackRate,
21+
readMediaStart,
22+
} from "../runtime/playbackRate.js";
23+
1824
// ── Types ────────────────────────────────────────────────────────────────
1925

2026
export interface UnresolvedElement {
@@ -25,6 +31,7 @@ export interface UnresolvedElement {
2531
end?: number;
2632
duration?: number;
2733
mediaStart: number;
34+
playbackRate: number;
2835
compositionSrc?: string;
2936
}
3037

@@ -40,6 +47,7 @@ export interface ResolvedMediaElement {
4047
start: number;
4148
duration: number;
4249
mediaStart: number;
50+
playbackRate: number;
4351
loop: boolean;
4452
}
4553

@@ -92,6 +100,11 @@ function injectAttr(tag: string, attr: string, value: string): string {
92100
return tag.replace(/>$/, ` ${attr}="${value}">`);
93101
}
94102

103+
function setAttr(tag: string, attr: string, value: string): string {
104+
if (!hasAttr(tag, attr)) return injectAttr(tag, attr, value);
105+
return tag.replace(new RegExp(`(${attr}=["'])[^"']*(["'])`), `$1${value}$2`);
106+
}
107+
95108
// Real media/timing elements never live inside comments, <script>, or <style>.
96109
// The tag regexes below aren't comment-aware, so a comment that merely mentions
97110
// `<video>`/`<audio>` gets rewritten as if it were a real element (issue #1938).
@@ -137,14 +150,16 @@ function compileTag(
137150
startStr = "0";
138151
}
139152
const start = parseFloat(startStr);
140-
const mediaStartStr = getAttr(result, "data-media-start");
141-
const mediaStart = mediaStartStr ? parseFloat(mediaStartStr) : 0;
153+
const attrReader = { getAttribute: (name: string) => getAttr(result, name) };
154+
const mediaStart = readMediaStart(attrReader);
155+
const playbackRate = readElementPlaybackRate(attrReader);
142156

143157
// 1. Compute data-end from data-start + data-duration
144158
if (!hasAttr(result, "data-end")) {
145159
const durationStr = getAttr(result, "data-duration");
146-
if (durationStr !== null) {
147-
const end = start + parseFloat(durationStr);
160+
const duration = parseStrictFiniteTimingNumber(durationStr);
161+
if (duration != null) {
162+
const end = start + duration;
148163
result = injectAttr(result, "data-end", String(end));
149164
} else if (id) {
150165
// No data-duration: mark as unresolved so caller can provide it
@@ -154,6 +169,7 @@ function compileTag(
154169
src: getAttr(result, "src") ?? undefined,
155170
start,
156171
mediaStart,
172+
playbackRate,
157173
};
158174
}
159175
}
@@ -215,6 +231,7 @@ export function compileTimingAttrs(html: string): CompilationResult {
215231
tagName: "div",
216232
start: startStr ? parseFloat(startStr) : 0,
217233
mediaStart: 0,
234+
playbackRate: 1,
218235
compositionSrc: compositionSrc ?? undefined,
219236
});
220237
}
@@ -241,8 +258,8 @@ export function injectDurations(html: string, resolutions: ResolvedDuration[]):
241258
let result = tag;
242259

243260
// Add data-duration if missing
244-
if (!hasAttr(result, "data-duration")) {
245-
result = injectAttr(result, "data-duration", String(duration));
261+
if (parseStrictFiniteTimingNumber(getAttr(result, "data-duration")) == null) {
262+
result = setAttr(result, "data-duration", String(duration));
246263
}
247264

248265
// Add data-end if missing
@@ -279,20 +296,21 @@ export function extractResolvedMedia(html: string): ResolvedMediaElement[] {
279296
const durationStr = getAttr(tag, "data-duration");
280297
if (!id || durationStr === null) continue;
281298

282-
const duration = parseFloat(durationStr);
283-
if (!Number.isFinite(duration) || duration <= 0) continue;
299+
const duration = parseStrictFiniteTimingNumber(durationStr);
300+
if (duration == null || duration <= 0) continue;
284301

285302
const isVideo = /^<video/i.test(tag);
286303
const startStr = getAttr(tag, "data-start");
287-
const mediaStartStr = getAttr(tag, "data-media-start");
304+
const attrReader = { getAttribute: (name: string) => getAttr(tag, name) };
288305

289306
resolved.push({
290307
id,
291308
tagName: isVideo ? "video" : "audio",
292309
src: getAttr(tag, "src") ?? undefined,
293310
start: startStr !== null ? parseFloat(startStr) : 0,
294311
duration,
295-
mediaStart: mediaStartStr ? parseFloat(mediaStartStr) : 0,
312+
mediaStart: readMediaStart(attrReader),
313+
playbackRate: readElementPlaybackRate(attrReader),
296314
loop: hasAttr(tag, "loop"),
297315
});
298316
}

packages/core/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,14 @@ export {
298298
// publishConfig entry points at a file the pack doesn't contain
299299
// (verify:packed-manifests catches exactly that).
300300
export { createRuntimeStartTimeResolver } from "./runtime/startResolver.js";
301+
export {
302+
normalizePlaybackRate,
303+
parseStrictFiniteTimingNumber,
304+
readElementPlaybackRate,
305+
readMediaStart,
306+
resolveNaturalMediaTimelineDuration,
307+
resolveNaturalMediaTimelineDurationFromValues,
308+
} from "./runtime/playbackRate.js";
301309

302310
// Variable validation (CLI / tooling-side)
303311
export {

packages/core/src/runtime/audioFx.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// @vitest-environment happy-dom
22
import { describe, expect, it } from "vitest";
33
import { attachElementFxChain } from "./audioFx.js";
4-
import { defaultAudioFxParams, HF_AUDIO_FX } from "../audioFx.js";
4+
import { defaultAudioFxParams, HF_AUDIO_FX, HF_AUDIO_FX_ATTR } from "../audioFx.js";
55

66
/**
77
* The DSP is proven in a real browser by the engine's render tests. What needs
@@ -107,7 +107,9 @@ describe("attachElementFxChain", () => {
107107
const dst = new Node();
108108
const handle = attachElementFxChain(
109109
ctx(),
110-
{ getAttribute: () => "{not json" },
110+
{
111+
getAttribute: (name: string) => (name === HF_AUDIO_FX_ATTR ? "{not json" : null),
112+
},
111113
src as never,
112114
dst as never,
113115
);

packages/core/src/runtime/clipTree.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ describe("createClipTree", () => {
2727
rootDuration: 10,
2828
};
2929

30+
it.each(["", " ", "0s", "0abc", "0px", "-1s", "Infinity", "NaN"])(
31+
"keeps a clip with invalid literal data-duration=%j on the fallback window",
32+
(duration) => {
33+
document.body.innerHTML = `
34+
<div data-composition-id="root" data-duration="10" data-start="0" id="root">
35+
<div data-start="0" data-duration="${duration}" id="clip"></div>
36+
</div>`;
37+
expect(createClipTree(params).roots.map((node) => node.id)).toContain("clip");
38+
},
39+
);
40+
3041
// Regression: id-less children (root index.html uses data-hf-id, not id) must
3142
// get their data-hf-id as the node id — not a synthetic `__clip-N` — so the
3243
// tree aligns with __clipManifest (which also keys on data-hf-id) and inline
@@ -47,4 +58,18 @@ describe("createClipTree", () => {
4758
expect(child!.id).not.toMatch(/^__clip-/);
4859
expect(child!.parentId).toBe("scene");
4960
});
61+
62+
it.each([10, 11])(
63+
"does not replace a known zero media span with root duration (start=%s)",
64+
(start) => {
65+
document.body.innerHTML = `
66+
<div data-composition-id="root" data-duration="100" data-start="0" id="root">
67+
<video id="at-eof" data-start="0" data-media-start="${start}"></video>
68+
</div>`;
69+
const video = document.querySelector("video")!;
70+
Object.defineProperty(video, "duration", { value: 10, configurable: true });
71+
72+
expect(createClipTree({ ...params, rootDuration: 100 }).roots).toEqual([]);
73+
},
74+
);
5075
});

packages/core/src/runtime/clipTree.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
*/
1212

1313
import type { RuntimeTimelineLike } from "./types";
14+
import { parseStrictFiniteTimingNumber, resolveNaturalMediaTimelineDuration } from "./playbackRate";
1415

1516
export interface ClipNode {
1617
readonly id: string;
@@ -51,9 +52,7 @@ interface StartResolverLike {
5152
}
5253

5354
function parseNum(value: string | null): number | null {
54-
if (value == null) return null;
55-
const n = Number(value);
56-
return Number.isFinite(n) ? n : null;
55+
return parseStrictFiniteTimingNumber(value);
5756
}
5857

5958
function durationFromTimeline(
@@ -68,11 +67,7 @@ function durationFromTimeline(
6867

6968
function durationFromMedia(el: Element): number | null {
7069
if (!(el instanceof HTMLMediaElement) || !Number.isFinite(el.duration)) return null;
71-
const mediaStart =
72-
parseNum(el.getAttribute("data-playback-start")) ??
73-
parseNum(el.getAttribute("data-media-start")) ??
74-
0;
75-
return el.duration > mediaStart ? el.duration - mediaStart : null;
70+
return resolveNaturalMediaTimelineDuration(el, el.duration);
7671
}
7772

7873
// Used only to filter out zero-duration (decorative) elements at build time.

packages/core/src/runtime/init.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,30 @@ describe("initSandboxRuntimeModular", () => {
110110
window.cancelAnimationFrame = (() => {}) as typeof window.cancelAnimationFrame;
111111
});
112112

113+
it.each([
114+
["2x", 5],
115+
["0x2", 10],
116+
])("derives a native-parsed natural media window for rate %s", (rate, expected) => {
117+
document.body.innerHTML = `<div data-composition-id="main" data-root="true"><video data-start="0" data-playback-rate="${rate}"></video></div>`;
118+
const video = document.querySelector("video")!;
119+
Object.defineProperty(video, "duration", { value: 10, configurable: true });
120+
window.__timelines = {};
121+
initSandboxRuntimeModular();
122+
expect(window.__player?.getDuration()).toBe(expected);
123+
});
124+
125+
it.each([10, 11])(
126+
"preserves a known zero natural media window at source EOF (start=%s)",
127+
(start) => {
128+
document.body.innerHTML = `<div data-composition-id="main" data-root="true"><video data-start="0" data-media-start="${start}"></video></div>`;
129+
const video = document.querySelector("video")!;
130+
Object.defineProperty(video, "duration", { value: 10, configurable: true });
131+
window.__timelines = {};
132+
initSandboxRuntimeModular();
133+
expect(window.__player?.getDuration()).toBe(0);
134+
},
135+
);
136+
113137
afterEach(() => {
114138
window.__hfRuntimeTeardown?.();
115139
document.body.innerHTML = "";

0 commit comments

Comments
 (0)