Skip to content

Commit 0263d36

Browse files
fix(core): treat unparsable timing attributes as missing instead of NaN
A typo like data-start="abc" flowed through parseFloat unguarded in both the timing compiler and the HTML parser. The compiler serialized it into the compiled output as data-end="NaN"; the parser turned a garbage data-end into a NaN duration via Math.max(0, NaN), which is NaN. Neither lint nor compile surfaced anything to the user. compileTag now normalizes an unparsable data-start to 0 in the output, strips unparsable data-end/data-duration so they take the existing missing-attribute paths (recompute or resolver), and the parser falls back to its own defaults. Same policy extractResolvedMedia already applied to data-duration, and the same validation the composition-level duration already gets in extractCompositionMetadata.
1 parent 0870394 commit 0263d36

4 files changed

Lines changed: 158 additions & 22 deletions

File tree

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
injectDurations,
55
extractResolvedMedia,
66
clampDurations,
7+
parseTimingAttr,
78
} from "./timingCompiler.js";
89

910
describe("compileTimingAttrs", () => {
@@ -185,3 +186,63 @@ describe("clampDurations", () => {
185186
expect(result).toContain('data-end="7"');
186187
});
187188
});
189+
190+
// ── Unparsable timing attributes ──
191+
//
192+
// A typo like data-start="abc" must not reach the compiled output: it would
193+
// otherwise be serialized as data-end="NaN" and parsed downstream as a NaN
194+
// duration. Unparsable values are treated as missing (data-start → 0,
195+
// data-duration/data-end → resolver path), mirroring the Number.isFinite
196+
// guard extractResolvedMedia already applies to data-duration.
197+
198+
describe("unparsable timing attributes", () => {
199+
it("normalizes an unparsable data-start to 0 and computes data-end from it", () => {
200+
const html = '<video id="v1" src="a.mp4" data-start="abc" data-duration="5">';
201+
const { html: compiled, unresolved } = compileTimingAttrs(html);
202+
203+
expect(compiled).toContain('data-start="0"');
204+
expect(compiled).toContain('data-end="5"');
205+
expect(compiled).not.toContain("NaN");
206+
expect(unresolved).toHaveLength(0);
207+
});
208+
209+
it("drops an unparsable data-duration and marks the element unresolved", () => {
210+
const html = '<video id="v1" src="a.mp4" data-start="1" data-duration="oops">';
211+
const { html: compiled, unresolved } = compileTimingAttrs(html);
212+
213+
expect(compiled).not.toContain("data-duration");
214+
expect(compiled).not.toContain("NaN");
215+
expect(unresolved).toHaveLength(1);
216+
expect(unresolved[0].start).toBe(1);
217+
});
218+
219+
it("strips an unparsable data-end and recomputes it from data-duration", () => {
220+
const html = '<video id="v1" src="a.mp4" data-start="2" data-duration="3" data-end="oops">';
221+
const { html: compiled, unresolved } = compileTimingAttrs(html);
222+
223+
expect(compiled).toContain('data-end="5"');
224+
expect(compiled).not.toContain("oops");
225+
expect(unresolved).toHaveLength(0);
226+
});
227+
228+
it("treats an unparsable data-media-start as 0 on unresolved elements", () => {
229+
const html = '<video id="v1" src="a.mp4" data-start="1" data-media-start="bad">';
230+
const { unresolved } = compileTimingAttrs(html);
231+
232+
expect(unresolved).toHaveLength(1);
233+
expect(unresolved[0].mediaStart).toBe(0);
234+
});
235+
});
236+
237+
describe("parseTimingAttr", () => {
238+
it("parses finite values", () => {
239+
expect(parseTimingAttr("2.5", 0)).toBe(2.5);
240+
expect(parseTimingAttr("0", 7)).toBe(0);
241+
});
242+
243+
it("falls back on null, unparsable, and non-finite values", () => {
244+
expect(parseTimingAttr(null, 3)).toBe(3);
245+
expect(parseTimingAttr("abc", 3)).toBe(3);
246+
expect(parseTimingAttr("Infinity", 3)).toBe(3);
247+
});
248+
});

packages/core/src/compiler/timingCompiler.ts

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,20 @@ function injectAttr(tag: string, attr: string, value: string): string {
7070
return tag.replace(/>$/, ` ${attr}="${value}">`);
7171
}
7272

73+
/**
74+
* Parse a timing attribute (data-start, data-end, data-media-start),
75+
* treating unparsable values as `fallback`. Without the guard a typo like
76+
* data-start="abc" flows through as NaN and is serialized back into the
77+
* compiled HTML as data-end="NaN", which the parser then turns into a NaN
78+
* duration. Same policy extractResolvedMedia already applies to
79+
* data-duration.
80+
*/
81+
export function parseTimingAttr(value: string | null, fallback: number): number {
82+
if (value === null) return fallback;
83+
const parsed = parseFloat(value);
84+
return Number.isFinite(parsed) ? parsed : fallback;
85+
}
86+
7387
// ── Core compilation ─────────────────────────────────────────────────────
7488

7589
function compileTag(
@@ -91,15 +105,33 @@ function compileTag(
91105
result = injectAttr(result, "data-hf-auto-start", "");
92106
startStr = "0";
93107
}
94-
const start = parseFloat(startStr);
95-
const mediaStartStr = getAttr(result, "data-media-start");
96-
const mediaStart = mediaStartStr ? parseFloat(mediaStartStr) : 0;
108+
let start = parseFloat(startStr);
109+
if (!Number.isFinite(start)) {
110+
// Unparsable data-start: normalize the attribute to 0 in the output so
111+
// downstream consumers (parser, runtime) never see the garbage value.
112+
result = result.replace(/data-start=["'][^"']*["']/, 'data-start="0"');
113+
start = 0;
114+
}
115+
const mediaStart = parseTimingAttr(getAttr(result, "data-media-start"), 0);
116+
117+
// Strip an unparsable data-end so the normal missing-data-end flow below
118+
// recomputes or resolves it instead of leaving the garbage value in place.
119+
const dataEndStr = getAttr(result, "data-end");
120+
if (dataEndStr !== null && !Number.isFinite(parseFloat(dataEndStr))) {
121+
result = result.replace(/\s*data-end=["'][^"']*["']/, "");
122+
}
97123

98124
// 1. Compute data-end from data-start + data-duration
99125
if (!hasAttr(result, "data-end")) {
100126
const durationStr = getAttr(result, "data-duration");
101-
if (durationStr !== null) {
102-
const end = start + parseFloat(durationStr);
127+
const duration = durationStr !== null ? parseFloat(durationStr) : NaN;
128+
if (durationStr !== null && !Number.isFinite(duration)) {
129+
// Unparsable data-duration: drop it and fall through to the
130+
// unresolved path so the caller's resolver supplies the real one.
131+
result = result.replace(/\s*data-duration=["'][^"']*["']/, "");
132+
}
133+
if (Number.isFinite(duration)) {
134+
const end = start + duration;
103135
result = injectAttr(result, "data-end", String(end));
104136
} else if (id) {
105137
// No data-duration: mark as unresolved so caller can provide it
@@ -165,7 +197,7 @@ export function compileTimingAttrs(html: string): CompilationResult {
165197
unresolved.push({
166198
id,
167199
tagName: "div",
168-
start: startStr ? parseFloat(startStr) : 0,
200+
start: parseTimingAttr(startStr, 0),
169201
mediaStart: 0,
170202
compositionSrc: compositionSrc ?? undefined,
171203
});
@@ -199,8 +231,7 @@ export function injectDurations(html: string, resolutions: ResolvedDuration[]):
199231

200232
// Add data-end if missing
201233
if (!hasAttr(result, "data-end")) {
202-
const startStr = getAttr(result, "data-start");
203-
const start = startStr ? parseFloat(startStr) : 0;
234+
const start = parseTimingAttr(getAttr(result, "data-start"), 0);
204235
result = injectAttr(result, "data-end", String(start + duration));
205236
}
206237

@@ -241,9 +272,9 @@ export function extractResolvedMedia(html: string): ResolvedMediaElement[] {
241272
id,
242273
tagName: isVideo ? "video" : "audio",
243274
src: getAttr(tag, "src") ?? undefined,
244-
start: startStr !== null ? parseFloat(startStr) : 0,
275+
start: parseTimingAttr(startStr, 0),
245276
duration,
246-
mediaStart: mediaStartStr ? parseFloat(mediaStartStr) : 0,
277+
mediaStart: parseTimingAttr(mediaStartStr, 0),
247278
loop: hasAttr(tag, "loop"),
248279
});
249280
}
@@ -265,8 +296,7 @@ export function clampDurations(html: string, clamps: ResolvedDuration[]): string
265296
tag = tag.replace(/data-duration=["'][^"']*["']/, `data-duration="${duration}"`);
266297

267298
// Recompute data-end from data-start + clamped duration
268-
const startStr = getAttr(tag, "data-start");
269-
const start = startStr ? parseFloat(startStr) : 0;
299+
const start = parseTimingAttr(getAttr(tag, "data-start"), 0);
270300
tag = tag.replace(/data-end=["'][^"']*["']/, `data-end="${start + duration}"`);
271301

272302
return tag;

packages/core/src/parsers/htmlParser.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,3 +640,50 @@ describe("extractCompositionMetadata", () => {
640640
expect(meta.variables[1].type).toBe("number");
641641
});
642642
});
643+
644+
describe("parseHtml unparsable timing attributes", () => {
645+
it("treats an unparsable data-start as 0", () => {
646+
const html = `
647+
<html>
648+
<body>
649+
<div id="stage">
650+
<div id="t1" data-start="abc" data-end="3"><div>x</div></div>
651+
</div>
652+
</body>
653+
</html>
654+
`;
655+
const result = parseHtml(html);
656+
657+
expect(result.elements[0].startTime).toBe(0);
658+
expect(result.elements[0].duration).toBe(3);
659+
});
660+
661+
it("falls back to the 5s default duration for an unparsable data-end", () => {
662+
const html = `
663+
<html>
664+
<body>
665+
<div id="stage">
666+
<div id="t1" data-start="1" data-end="oops"><div>x</div></div>
667+
</div>
668+
</body>
669+
</html>
670+
`;
671+
const result = parseHtml(html);
672+
673+
// Math.max(0, NaN) is NaN — without the guard this propagated a NaN
674+
// duration into the timeline model.
675+
expect(result.elements[0].startTime).toBe(1);
676+
expect(result.elements[0].duration).toBe(5);
677+
});
678+
});
679+
680+
describe("updateElementInHtml with unparsable data-start", () => {
681+
it("recomputes data-end from 0 instead of writing NaN", () => {
682+
const html =
683+
'<html><body><div id="stage"><div id="t1" data-start="abc" data-end="9"><div>x</div></div></div></body></html>';
684+
const updated = updateElementInHtml(html, "t1", { duration: 4 });
685+
686+
expect(updated).toContain('data-end="4"');
687+
expect(updated).not.toContain("NaN");
688+
});
689+
});

packages/core/src/parsers/htmlParser.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
CompositionVariable,
1212
} from "../core.types";
1313
import { validateCompositionGsap } from "./gsapSerialize";
14+
import { parseTimingAttr } from "../compiler/timingCompiler";
1415
import type { ValidationResult } from "../core.types";
1516

1617
const MEDIA_TYPES = new Set<string>(["video", "image", "audio"]);
@@ -180,15 +181,12 @@ export function parseHtml(html: string): ParsedHtml {
180181
const type = getElementType(el);
181182
if (!type) return;
182183

183-
const start = parseFloat(el.getAttribute("data-start") || "0");
184-
const dataEnd = el.getAttribute("data-end");
185-
186-
let duration: number;
187-
if (dataEnd) {
188-
duration = Math.max(0, parseFloat(dataEnd) - start);
189-
} else {
190-
duration = 5;
191-
}
184+
const start = parseTimingAttr(el.getAttribute("data-start"), 0);
185+
// Missing and unparsable data-end both fall back to the 5s default —
186+
// Math.max(0, NaN) is NaN, so a garbage data-end would otherwise
187+
// propagate a NaN duration into the timeline model.
188+
const end = parseTimingAttr(el.getAttribute("data-end"), start + 5);
189+
const duration = Math.max(0, end - start);
192190

193191
const id = el.id || `element-${++idCounter}`;
194192
const name = getElementName(el);
@@ -520,7 +518,7 @@ export function updateElementInHtml(
520518
}
521519

522520
if (updates.duration !== undefined) {
523-
const start = parseFloat(el.getAttribute("data-start") || "0");
521+
const start = parseTimingAttr(el.getAttribute("data-start"), 0);
524522
el.setAttribute("data-end", String(start + updates.duration));
525523
el.removeAttribute("data-duration"); // Clean up legacy
526524
}

0 commit comments

Comments
 (0)