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
This commit is contained in:
Miguel Ángel
2026-08-18 10:17:02 -04:00
committed by GitHub
parent 049f5618d7
commit afafca4b96
42 changed files with 2695 additions and 307 deletions
@@ -19,6 +19,23 @@ it("source contains no raw NUL bytes", () => {
});
describe("compileTimingAttrs", () => {
it.each(["", " ", "0s", "0abc", "0px", "-1s", "Infinity", "NaN"])(
"does not partially parse invalid literal data-duration=%j",
(duration) => {
const html = `<video id="v1" src="a.mp4" data-start="2" data-duration="${duration}">`;
const { html: compiled } = compileTimingAttrs(html);
expect(compiled).not.toContain("data-end=");
},
);
it("uses Number semantics for hexadecimal literal timing", () => {
const { html: compiled } = compileTimingAttrs(
'<video id="v1" src="a.mp4" data-start="2" data-duration="0x10">',
);
expect(compiled).toContain('data-end="18"');
});
it("adds data-end when data-start and data-duration are present on a video", () => {
const html = '<video id="v1" src="a.mp4" data-start="2" data-duration="5">';
const { html: compiled, unresolved } = compileTimingAttrs(html);
+28 -10
View File
@@ -15,6 +15,12 @@
* and call injectDurations() to complete the compilation.
*/
import {
parseStrictFiniteTimingNumber,
readElementPlaybackRate,
readMediaStart,
} from "../runtime/playbackRate.js";
// ── Types ────────────────────────────────────────────────────────────────
export interface UnresolvedElement {
@@ -25,6 +31,7 @@ export interface UnresolvedElement {
end?: number;
duration?: number;
mediaStart: number;
playbackRate: number;
compositionSrc?: string;
}
@@ -40,6 +47,7 @@ export interface ResolvedMediaElement {
start: number;
duration: number;
mediaStart: number;
playbackRate: number;
loop: boolean;
}
@@ -92,6 +100,11 @@ function injectAttr(tag: string, attr: string, value: string): string {
return tag.replace(/>$/, ` ${attr}="${value}">`);
}
function setAttr(tag: string, attr: string, value: string): string {
if (!hasAttr(tag, attr)) return injectAttr(tag, attr, value);
return tag.replace(new RegExp(`(${attr}=["'])[^"']*(["'])`), `$1${value}$2`);
}
// Real media/timing elements never live inside comments, <script>, or <style>.
// The tag regexes below aren't comment-aware, so a comment that merely mentions
// `<video>`/`<audio>` gets rewritten as if it were a real element (issue #1938).
@@ -137,14 +150,16 @@ function compileTag(
startStr = "0";
}
const start = parseFloat(startStr);
const mediaStartStr = getAttr(result, "data-media-start");
const mediaStart = mediaStartStr ? parseFloat(mediaStartStr) : 0;
const attrReader = { getAttribute: (name: string) => getAttr(result, name) };
const mediaStart = readMediaStart(attrReader);
const playbackRate = readElementPlaybackRate(attrReader);
// 1. Compute data-end from data-start + data-duration
if (!hasAttr(result, "data-end")) {
const durationStr = getAttr(result, "data-duration");
if (durationStr !== null) {
const end = start + parseFloat(durationStr);
const duration = parseStrictFiniteTimingNumber(durationStr);
if (duration != null) {
const end = start + duration;
result = injectAttr(result, "data-end", String(end));
} else if (id) {
// No data-duration: mark as unresolved so caller can provide it
@@ -154,6 +169,7 @@ function compileTag(
src: getAttr(result, "src") ?? undefined,
start,
mediaStart,
playbackRate,
};
}
}
@@ -215,6 +231,7 @@ export function compileTimingAttrs(html: string): CompilationResult {
tagName: "div",
start: startStr ? parseFloat(startStr) : 0,
mediaStart: 0,
playbackRate: 1,
compositionSrc: compositionSrc ?? undefined,
});
}
@@ -241,8 +258,8 @@ export function injectDurations(html: string, resolutions: ResolvedDuration[]):
let result = tag;
// Add data-duration if missing
if (!hasAttr(result, "data-duration")) {
result = injectAttr(result, "data-duration", String(duration));
if (parseStrictFiniteTimingNumber(getAttr(result, "data-duration")) == null) {
result = setAttr(result, "data-duration", String(duration));
}
// Add data-end if missing
@@ -279,12 +296,12 @@ export function extractResolvedMedia(html: string): ResolvedMediaElement[] {
const durationStr = getAttr(tag, "data-duration");
if (!id || durationStr === null) continue;
const duration = parseFloat(durationStr);
if (!Number.isFinite(duration) || duration <= 0) continue;
const duration = parseStrictFiniteTimingNumber(durationStr);
if (duration == null || duration <= 0) continue;
const isVideo = /^<video/i.test(tag);
const startStr = getAttr(tag, "data-start");
const mediaStartStr = getAttr(tag, "data-media-start");
const attrReader = { getAttribute: (name: string) => getAttr(tag, name) };
resolved.push({
id,
@@ -292,7 +309,8 @@ export function extractResolvedMedia(html: string): ResolvedMediaElement[] {
src: getAttr(tag, "src") ?? undefined,
start: startStr !== null ? parseFloat(startStr) : 0,
duration,
mediaStart: mediaStartStr ? parseFloat(mediaStartStr) : 0,
mediaStart: readMediaStart(attrReader),
playbackRate: readElementPlaybackRate(attrReader),
loop: hasAttr(tag, "loop"),
});
}