Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ export type {

export { parseSlideshowManifest, resolveSlideshow } from "./slideshow/index.js";

export type {
AuthoredTimingValue,
RawAuthoredTiming,
AuthoredTimingWindow,
} from "./runtime/authoredTiming.js";
export { resolveAuthoredTimingWindow } from "./runtime/authoredTiming.js";

export {
CANVAS_DIMENSIONS,
VALID_CANVAS_RESOLUTIONS,
Expand Down
82 changes: 82 additions & 0 deletions packages/core/src/runtime/authoredTiming.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";

const timingModule = await import("./authoredTiming.js").catch(() => null);

describe("resolveAuthoredTimingWindow", () => {
const resolve = (values: {
start?: string | null;
duration?: string | null;
authoredDuration?: string | null;
end?: string | null;
authoredEnd?: string | null;
}) => {
expect(timingModule, "canonical authored timing helper must exist").not.toBeNull();
return timingModule?.resolveAuthoredTimingWindow(values) ?? null;
};

it("uses public timing before preserved timing and duration before end", () => {
expect(
resolve({
start: "2",
duration: "3",
authoredDuration: "4",
end: "20",
authoredEnd: "30",
}),
).toEqual({ start: 2, duration: 3, end: 5 });
});

it("falls back through preserved duration, public end, and preserved end", () => {
expect(resolve({ start: "1", duration: "", authoredDuration: "2.5" })).toEqual({
start: 1,
duration: 2.5,
end: 3.5,
});
expect(resolve({ start: "1", duration: "0", end: "4", authoredEnd: "8" })).toEqual({
start: 1,
duration: 3,
end: 4,
});
expect(resolve({ start: "1", duration: "-1", end: "NaN", authoredEnd: "6" })).toEqual({
start: 1,
duration: 5,
end: 6,
});
});

it("rejects unusable values and preserves a usable start-only window", () => {
expect(resolve({ start: null, duration: "2" })).toBeNull();
expect(resolve({ start: "Infinity", duration: "2" })).toBeNull();
expect(resolve({ start: "1.25", duration: "NaN", end: "1.25" })).toEqual({
start: 1.25,
duration: null,
end: null,
});
});

it.each([
["blank public duration", { start: "0", duration: " ", authoredDuration: "2" }, 2],
["invalid public duration", { start: "0", duration: "NaN", authoredDuration: "2" }, 2],
["infinite public duration", { start: "0", duration: "Infinity", authoredDuration: "2" }, 2],
["zero public duration", { start: "0", duration: "0", authoredDuration: "2" }, 2],
["negative public duration", { start: "0", duration: "-2", authoredDuration: "2" }, 2],
])("uses a usable preserved duration after %s", (_label, values, expectedEnd) => {
expect(resolve(values)).toEqual({ start: 0, duration: 2, end: expectedEnd });
});

it("uses preserved end when public end does not create a positive window", () => {
expect(resolve({ start: "4", end: "3", authoredEnd: "5.5" })).toEqual({
start: 4,
duration: 1.5,
end: 5.5,
});
});

it("clamps a finite negative absolute start before deriving the window", () => {
expect(resolve({ start: "-1", duration: "3" })).toEqual({
start: 0,
duration: 3,
end: 3,
});
});
});
50 changes: 50 additions & 0 deletions packages/core/src/runtime/authoredTiming.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
export type AuthoredTimingValue = string | number | null | undefined;

export interface RawAuthoredTiming {
start?: AuthoredTimingValue;
duration?: AuthoredTimingValue;
authoredDuration?: AuthoredTimingValue;
end?: AuthoredTimingValue;
authoredEnd?: AuthoredTimingValue;
}

export interface AuthoredTimingWindow {
start: number;
duration: number | null;
end: number | null;
}

function finiteNumber(value: AuthoredTimingValue): number | null {
if (value == null) return null;
if (typeof value === "string" && value.trim() === "") return null;
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? parsed : null;
}

export function resolveAuthoredTimingWindow(
values: RawAuthoredTiming,
): AuthoredTimingWindow | null {
const parsedStart = finiteNumber(values.start);
if (parsedStart == null) return null;
const start = Math.max(0, parsedStart);

const publicDuration = finiteNumber(values.duration);
const preservedDuration = finiteNumber(values.authoredDuration);
const duration =
publicDuration != null && publicDuration > 0
? publicDuration
: preservedDuration != null && preservedDuration > 0
? preservedDuration
: null;
if (duration != null) return { start, duration, end: start + duration };

const publicEnd = finiteNumber(values.end);
const preservedEnd = finiteNumber(values.authoredEnd);
const end =
publicEnd != null && publicEnd > start
? publicEnd
: preservedEnd != null && preservedEnd > start
? preservedEnd
: null;
return { start, duration: end == null ? null : end - start, end };
}
55 changes: 21 additions & 34 deletions packages/core/src/runtime/startResolver.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,9 @@
import type { RuntimeTimelineLike } from "./types";
import { swallow } from "./diagnostics";
import { resolveAuthoredTimingWindow } from "./authoredTiming";
import { readElementPlaybackRate } from "./media";
import { readMediaStart } from "./playbackRate";
import { parseNumeric, parseStartExpression } from "./startExpression";

const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
const AUTHORED_END_ATTR = "data-hf-authored-end";

function parseDurationAttr(element: Element): number | null {
return parseNumeric(element.getAttribute("data-duration"));
}

function parseEndAttr(element: Element): number | null {
return parseNumeric(element.getAttribute("data-end"));
}

function parseAuthoredDurationAttr(element: Element): number | null {
return parseNumeric(element.getAttribute(AUTHORED_DURATION_ATTR));
}

function parseAuthoredEndAttr(element: Element): number | null {
return parseNumeric(element.getAttribute(AUTHORED_END_ATTR));
}
import { parseStartExpression } from "./startExpression";

export function createRuntimeStartTimeResolver(params: {
timelineRegistry?: Record<string, RuntimeTimelineLike | undefined>;
Expand Down Expand Up @@ -64,22 +46,27 @@ export function createRuntimeStartTimeResolver(params: {
const cached = durationCache.get(element);
if (cached !== undefined) return cached;
let resolved: number | null = null;
const durationAttr =
parseDurationAttr(element) ??
(includeAuthoredTimingAttrs ? parseAuthoredDurationAttr(element) : null);
if (durationAttr != null && durationAttr > 0) {
resolved = durationAttr;
const durationTiming = resolveAuthoredTimingWindow({
start: 0,
duration: element.getAttribute("data-duration"),
authoredDuration: includeAuthoredTimingAttrs
? element.getAttribute("data-hf-authored-duration")
: null,
});
if (durationTiming?.duration != null && durationTiming.duration > 0) {
resolved = durationTiming.duration;
}
if (resolved == null || resolved <= 0) {
const endAttr =
parseEndAttr(element) ??
(includeAuthoredTimingAttrs ? parseAuthoredEndAttr(element) : null);
if (endAttr != null) {
const start = resolveStartForElementInternal(element, 0);
const delta = endAttr - start;
if (Number.isFinite(delta) && delta > 0) {
resolved = delta;
}
const start = resolveStartForElementInternal(element, 0);
const endTiming = resolveAuthoredTimingWindow({
start,
end: element.getAttribute("data-end"),
authoredEnd: includeAuthoredTimingAttrs
? element.getAttribute("data-hf-authored-end")
: null,
});
if (endTiming?.duration != null && endTiming.duration > 0) {
resolved = endTiming.duration;
}
}
if ((resolved == null || resolved <= 0) && isMediaElement(element)) {
Expand Down
47 changes: 46 additions & 1 deletion packages/core/src/runtime/timeline.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,39 @@
import { describe, it, expect, afterEach } from "vitest";
import { collectRuntimeTimelinePayload } from "./timeline";

type TimelineTestWindow = Window & {
__timelines?: Record<string, { duration: () => number }>;
};

describe("collectRuntimeTimelinePayload", () => {
afterEach(() => {
document.body.innerHTML = "";
delete (window as any).__timelines;
delete (window as TimelineTestWindow).__timelines;
});

const defaultParams = { canonicalFps: 30 };

function appendTimedCompositionClip(
id: string,
duration: string,
authoredDuration?: string,
): HTMLDivElement {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const clip = document.createElement("div");
clip.id = id;
clip.setAttribute("data-composition-id", id);
clip.setAttribute("data-start", "0");
clip.setAttribute("data-duration", duration);
if (authoredDuration != null) {
clip.setAttribute("data-hf-authored-duration", authoredDuration);
}
root.appendChild(clip);
return clip;
}

it("returns minimal payload for empty document", () => {
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.source).toBe("hf-preview");
Expand Down Expand Up @@ -664,6 +689,26 @@ describe("collectRuntimeTimelinePayload", () => {
expect(result.durationInFrames).toBe(42 * 30);
});

it("uses preserved duration when a normalized timeline clip retains public zero", () => {
const clip = appendTimedCompositionClip("normalized-zero", "0", "3.5");

const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips.find((candidate) => candidate.id === clip.id)?.duration).toBe(3.5);
});

it.each(["0", "-2"])(
"drops an explicit nonpositive duration %s before timeline fallback",
(duration) => {
const clip = appendTimedCompositionClip("invalid-window", duration);
(window as TimelineTestWindow).__timelines = {
"invalid-window": { duration: () => 5 },
};

const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips.find((candidate) => candidate.id === clip.id)).toBeUndefined();
},
);

it("discovers GSAP-animated scene elements via timeline introspection", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
Expand Down
26 changes: 18 additions & 8 deletions packages/core/src/runtime/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
RuntimeTimelineLike,
} from "./types";
import { stableClipId } from "./clipTree";
import { resolveAuthoredTimingWindow } from "./authoredTiming";
import { swallow } from "./diagnostics";
import { readElementPlaybackRate, readElementPlaybackStart } from "./media";
import { parseStrictFiniteTimingNumber, resolveNaturalMediaTimelineDuration } from "./playbackRate";
Expand All @@ -14,23 +15,32 @@ import { isSceneLikeCompositionId } from "../slideshow/index.js";
import { COMPOSITION_CONTRACT_VERSION } from "../compositionContract.js";
import { runtimeProtocolMetadata } from "./protocol.js";

const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
const AUTHORED_END_ATTR = "data-hf-authored-end";

function parseNum(value: string | null | undefined): number | null {
return parseStrictFiniteTimingNumber(value);
}

function parseElementDurationAttr(element: Element): number | null {
return (
parseNum(element.getAttribute("data-duration")) ??
parseNum(element.getAttribute(AUTHORED_DURATION_ATTR))
);
const publicDuration = element.getAttribute("data-duration");
const authoredDuration = element.getAttribute("data-hf-authored-duration");
const resolved = resolveAuthoredTimingWindow({
start: 0,
duration: publicDuration,
authoredDuration,
})?.duration;
if (resolved != null) return resolved;
const hasExplicitNonpositive = [publicDuration, authoredDuration]
.map(parseNum)
.some((duration) => duration != null && duration <= 0);
return hasExplicitNonpositive ? 0 : null;
}

function parseElementEndAttr(element: Element): number | null {
return (
parseNum(element.getAttribute("data-end")) ?? parseNum(element.getAttribute(AUTHORED_END_ATTR))
resolveAuthoredTimingWindow({
start: 0,
end: element.getAttribute("data-end"),
authoredEnd: element.getAttribute("data-hf-authored-end"),
})?.end ?? null
);
}

Expand Down
1 change: 1 addition & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export {
isMemoryExhaustionError,
type BeforeCaptureHook,
type DiscardWarmupInnerCapture,
type StaticVerificationOutcome,
} from "./services/frameCapture.js";
export {
CaptureFailure,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { computeAuthoredClipBoundaryFrames } from "./frameCapture.js";

describe("computeClipBoundaryFrames", () => {
it("protects the normalized authored-duration disappearance neighborhood", async () => {
const frames = computeAuthoredClipBoundaryFrames(
[
{
start: "0",
duration: null,
authoredDuration: "3.5",
end: null,
authoredEnd: null,
},
],
25,
);

expect([...frames].sort((a, b) => a - b)).toEqual([0, 1, 87, 88, 89]);
});

it("rounds fractional-fps start and end edges and applies precedence", async () => {
const frames = computeAuthoredClipBoundaryFrames(
[
{
start: "0.1",
duration: "0",
authoredDuration: "0.2",
end: "9",
authoredEnd: "10",
},
],
23.976,
);

expect(frames).toEqual(new Set([1, 2, 3, 6, 7, 8]));
});

it("matches runtime clamping for a negative absolute start", () => {
const frames = computeAuthoredClipBoundaryFrames([{ start: "-1", duration: "3" }], 25);
expect(frames).toEqual(new Set([0, 1, 74, 75, 76]));
});
});
Loading
Loading