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
2 changes: 1 addition & 1 deletion docs/reference/html-schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ unbounded animation, and timeline-free compositions need an explicit duration.
| --- | --- | --- |
| `id` | Yes | Stable identifier for timing, editing, and animation |
| `data-start` | Yes | Start in seconds or a relative timing expression |
| `data-duration` | Yes for DOM, image, and nested-composition clips | Visible slot length in seconds |
| `data-duration` | Yes for DOM and nested-composition clips; optional for image (3 s default), video and audio (source length) | Visible slot length in seconds |
| `data-track-index` | No | Studio timeline lane, display only. The render never reads it and it does not prevent overlap |
| `class="clip"` | Recommended for authored timed DOM and image elements | Layout and tooling convention. Visibility is keyed off `data-start`, but the shared `.clip` rule supplies the full-frame box |

Expand Down
2 changes: 1 addition & 1 deletion packages/core/docs/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ A clip is any discrete block on the timeline. We represent clips as HTML element

- `id` — Unique identifier (e.g., "el-1")
- `data-start` — Start time in seconds, or a clip `id` reference. See [Relative Timing](#relative-timing).
- `data-duration` — Duration in seconds. Required for `<img>` clips. Optional for `<video>` and `<audio>` (defaults to the source media's full duration). Not used on compositions.
- `data-duration` — Duration in seconds. Optional for `<img>` (defaults to 3 seconds), `<video>` and `<audio>` (default to the source media's length, less any playback offset, over the playback rate; an authored value trims). Not used on compositions.
- `data-track-index` — Timeline track number. Tracks serve two purposes: they determine visual layering (higher tracks render in front) and they group clips into rows on the timeline. Clips on the same track **cannot overlap in time**.

### Media Clips (video, audio)
Expand Down
4 changes: 1 addition & 3 deletions packages/core/src/playbackRateBounds.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
/** The one clamp for a playback rate: the constant attribute and the rate lane alike. */
export const MIN_PLAYBACK_RATE = 0.1;
export const MAX_PLAYBACK_RATE = 10;
export { MIN_PLAYBACK_RATE, MAX_PLAYBACK_RATE } from "@hyperframes/parsers/media-duration";
11 changes: 11 additions & 0 deletions packages/core/src/runtime/clipTree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ describe("createClipTree", () => {
expect(child!.parentId).toBe("scene");
});

it("keeps a timed image that starts at the end of the root, since it has its own default length", () => {
document.body.innerHTML = `
<div data-composition-id="root" data-duration="10" data-start="0" id="root">
<img id="late" data-start="10" />
</div>`;
const late = { resolveStartForElement: () => 10 };
expect(createClipTree({ ...params, startResolver: late }).roots.map((n) => n.id)).toContain(
"late",
);
});

it.each([10, 11])(
"does not replace a known zero media span with root duration (start=%s)",
(start) => {
Expand Down
14 changes: 11 additions & 3 deletions packages/core/src/runtime/clipTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@
*/

import type { RuntimeTimelineLike } from "./types";
import { parseStrictFiniteTimingNumber, resolveNaturalMediaTimelineDuration } from "./playbackRate";
import {
parseStrictFiniteTimingNumber,
resolveNaturalMediaTimelineDuration,
resolveTimedImageDurationSeconds,
} from "./playbackRate";
import { isMediaElement } from "./domRealm";

export interface ClipNode {
Expand Down Expand Up @@ -67,8 +71,12 @@ function durationFromTimeline(
}

function durationFromMedia(el: Element): number | null {
if (!isMediaElement(el) || !Number.isFinite(el.duration)) return null;
return resolveNaturalMediaTimelineDuration(el, el.duration);
if (isMediaElement(el)) {
return Number.isFinite(el.duration)
? resolveNaturalMediaTimelineDuration(el, el.duration)
: null;
}
return resolveTimedImageDurationSeconds(el);
}

// Used only to filter out zero-duration (decorative) elements at build time.
Expand Down
72 changes: 72 additions & 0 deletions packages/core/src/runtime/playbackRate.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { describe, expect, it } from "vitest";
import * as parsersBounds from "@hyperframes/parsers/media-duration";
import { MEDIA_DURATION_FIXTURES } from "@hyperframes/parsers/media-duration-fixtures";
import {
resolveMediaElementDurationSeconds,
resolveNaturalMediaTimelineDuration,
resolveNaturalMediaTimelineDurationFromValues,
resolveTimedImageDurationSeconds,
} from "./playbackRate";
import * as coreBounds from "../playbackRateBounds";

function elementWith(attributes: Record<string, string>): Pick<Element, "getAttribute"> {
return {
Expand Down Expand Up @@ -51,4 +56,71 @@ describe("rate lane duration", () => {
2,
);
});

it("keeps a rate lane's arithmetic when the shared resolver reports a media length", () => {
const el = {
...elementWith({
"data-automation": JSON.stringify({
version: 1,
lanes: [
{
target: "rate",
points: [
{ t: 0, v: 1 },
{ t: 2, v: 3 },
],
},
],
}),
}),
duration: 10,
};
expect(resolveMediaElementDurationSeconds(el)).toBeCloseTo(2 + (10 - 3.641) / 3, 2);
});
});

describe("resolveMediaElementDurationSeconds over the shared media-duration fixtures", () => {
// The runtime reader only sees video and audio; an image has no source to probe.
for (const fixture of MEDIA_DURATION_FIXTURES.filter((f) => f.tag !== "img")) {
it(fixture.name, () => {
const el = {
...elementWith(fixture.attrs),
duration: fixture.sourceDurationSeconds ?? Number.NaN,
};
expect(resolveMediaElementDurationSeconds(el)).toBe(fixture.expected.seconds);
});
}
});

describe("resolveTimedImageDurationSeconds over the shared media-duration fixtures", () => {
const imgWith = (attrs: Readonly<Record<string, string>>) => {
const img = document.createElement("img");
for (const [name, value] of Object.entries(attrs)) img.setAttribute(name, value);
return img;
};

for (const fixture of MEDIA_DURATION_FIXTURES.filter((f) => f.tag === "img")) {
it(fixture.name, () => {
expect(resolveTimedImageDurationSeconds(imgWith(fixture.attrs))).toBe(
fixture.expected.seconds,
);
});
}

it("leaves a bare image, with no timing attribute, as a static layer", () => {
expect(resolveTimedImageDurationSeconds(imgWith({}))).toBeNull();
});

it("does not time anything that is not an image", () => {
expect(resolveTimedImageDurationSeconds(document.createElement("div"))).toBeNull();
});
});

describe("the playback rate bound", () => {
it("has one owner: core's bounds are the parsers bounds", () => {
expect([coreBounds.MIN_PLAYBACK_RATE, coreBounds.MAX_PLAYBACK_RATE]).toEqual([
parsersBounds.MIN_PLAYBACK_RATE,
parsersBounds.MAX_PLAYBACK_RATE,
]);
});
});
93 changes: 54 additions & 39 deletions packages/core/src/runtime/playbackRate.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,28 @@
import { MAX_PLAYBACK_RATE, MIN_PLAYBACK_RATE } from "../playbackRateBounds";
import {
clampPlaybackRate,
readAuthoredDurationSeconds,
readDataDurationSeconds,
readMediaOffsetSeconds,
readPlaybackRate,
resolveMediaDuration,
resolveNaturalDurationSeconds,
} from "@hyperframes/parsers/media-duration";
import { resolveRateSpec, timeAtSourceTime, type RateSpec } from "../speedRamp";
import { isMediaElement } from "./domRealm";
import { isImageElement, isMediaElement } from "./domRealm";
import { parseNumeric } from "./startExpression";

export function normalizePlaybackRate(raw: number): number {
return Number.isFinite(raw) && raw > 0
? Math.max(MIN_PLAYBACK_RATE, Math.min(MAX_PLAYBACK_RATE, raw))
: 1;
}
export const normalizePlaybackRate = clampPlaybackRate;

/** Parse a literal numeric timing attribute without accepting trailing units or garbage. */
export function parseStrictFiniteTimingNumber(raw: string | null | undefined): number | null {
return parseNumeric(raw);
}

export function readElementPlaybackRate(el: Pick<Element, "getAttribute">): number {
const authored = Number.parseFloat(el.getAttribute("data-playback-rate") ?? "");
const raw =
Number.isFinite(authored) && authored > 0
? authored
: isMediaElement(el)
? el.defaultPlaybackRate
: 1;
return normalizePlaybackRate(raw);
return readPlaybackRate(
(name) => el.getAttribute(name),
isMediaElement(el) ? el.defaultPlaybackRate : 1,
);
}

/** The clip's rate: its `rate` lane when present, otherwise the constant rate. */
Expand All @@ -30,15 +31,9 @@ export function readElementRateSpec(el: Pick<Element, "getAttribute">): RateSpec
}

export function readMediaStart(el: Pick<Element, "getAttribute">): number {
const parse = (raw: string | null): number | null => {
const value = parseStrictFiniteTimingNumber(raw);
if (value == null) return null;
return Number.isFinite(value) && value >= 0 ? value : null;
};
return (
parse(el.getAttribute("data-playback-start")) ?? parse(el.getAttribute("data-media-start")) ?? 0
);
return readMediaOffsetSeconds((name) => el.getAttribute(name));
}

export function resolveNaturalMediaTimelineDuration(
el: Pick<Element, "getAttribute">,
sourceDuration: number,
Expand All @@ -51,32 +46,52 @@ export function resolveNaturalMediaTimelineDuration(
}

/**
* How long a media element occupies the timeline: an explicit `data-duration`
* trim if authored, otherwise the natural source length adjusted for playback
* start and rate. `null` when the source has not reported a duration yet.
*
* Single owner for the media-window scan run by BOTH the runtime's duration
* floor and the clip manifest.
* How long a media element occupies the timeline: an authored `data-duration` trim, otherwise
* the source's natural length adjusted for playback start and rate (lane-aware). `null` while
* the source has not reported a duration yet. The authored/pending decision is the shared
* parsers resolver's; only a `rate` lane's arithmetic stays here, because lanes live in core.
*/
export function resolveMediaElementDurationSeconds(
el: Pick<Element, "getAttribute"> & { duration: number },
): number | null {
const declaredDuration = parseStrictFiniteTimingNumber(el.getAttribute("data-duration"));
if (declaredDuration != null && declaredDuration > 0) return declaredDuration;
if (Number.isFinite(el.duration)) return resolveNaturalMediaTimelineDuration(el, el.duration);
return null;
const resolved = resolveMediaDuration({
tag: "video", // video and audio resolve identically
authoredDurationSeconds: readDataDurationSeconds((name) => el.getAttribute(name)),
sourceDurationSeconds: Number.isFinite(el.duration) ? el.duration : null,
mediaStartSeconds: readMediaStart(el),
playbackRate: readElementPlaybackRate(el),
});
return resolved.source === "media"
? resolveNaturalMediaTimelineDuration(el, el.duration)
: resolved.seconds;
}

/** A timed `<img>` (`data-start` or `data-track-index`) gets the dropped-image default unless
* trimmed by `data-duration` or `data-end`; a bare `<img>` is a static layer. `null` otherwise. */
export function resolveTimedImageDurationSeconds(el: Element, startSeconds = 0): number | null {
if (!isImageElement(el)) return null;
if (!el.hasAttribute("data-start") && !el.hasAttribute("data-track-index")) return null;
return resolveMediaDuration({
tag: "img",
authoredDurationSeconds: readAuthoredDurationSeconds(
(name) => el.getAttribute(name),
startSeconds,
),
sourceDurationSeconds: null,
mediaStartSeconds: 0,
playbackRate: 1,
}).seconds;
}

/** A constant rate goes through the shared arithmetic; a `rate` lane integrates over its points. */
export function resolveNaturalMediaTimelineDurationFromValues(
sourceDuration: number,
mediaStart: number,
playbackRate: RateSpec,
): number | null {
if (typeof playbackRate === "number") {
return resolveNaturalDurationSeconds(sourceDuration, mediaStart, playbackRate);
}
if (!Number.isFinite(sourceDuration)) return null;
const remaining = Math.max(0, sourceDuration - mediaStart);
return timeAtSourceTime(
typeof playbackRate === "number" ? normalizePlaybackRate(playbackRate) : playbackRate,
remaining,
);
return timeAtSourceTime(playbackRate, Math.max(0, sourceDuration - mediaStart));
}
import { parseNumeric } from "./startExpression";
21 changes: 21 additions & 0 deletions packages/core/src/runtime/startResolver.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect, afterEach, beforeAll } from "vitest";
import { DEFAULT_IMAGE_TIMELINE_DURATION_SECONDS } from "@hyperframes/parsers/media-duration";
import { createRuntimeStartTimeResolver } from "./startResolver";

// jsdom doesn't provide CSS.escape — polyfill it
Expand Down Expand Up @@ -316,6 +317,26 @@ describe("createRuntimeStartTimeResolver", () => {
expect(resolver.resolveDurationForElement(el)).toBe(6);
});

it.each([["data-start"], ["data-track-index"]])(
"gives an img with only %s the dropped-image default",
(attr) => {
const el = document.createElement("img");
el.setAttribute(attr, "1");
document.body.appendChild(el);
const resolver = createRuntimeStartTimeResolver({});
expect(resolver.resolveDurationForElement(el)).toBe(
DEFAULT_IMAGE_TIMELINE_DURATION_SECONDS,
);
},
);

it("leaves a bare img with no length", () => {
const el = document.createElement("img");
document.body.appendChild(el);
const resolver = createRuntimeStartTimeResolver({});
expect(resolver.resolveDurationForElement(el)).toBeNull();
});

it("returns null when no duration info available", () => {
const el = document.createElement("div");
document.body.appendChild(el);
Expand Down
16 changes: 4 additions & 12 deletions packages/core/src/runtime/startResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ import { resolveAuthoredTimingWindow } from "./authoredTiming";
// would be an import cycle.
import {
parseStrictFiniteTimingNumber,
readElementRateSpec,
readMediaStart,
resolveNaturalMediaTimelineDurationFromValues,
resolveNaturalMediaTimelineDuration,
resolveTimedImageDurationSeconds,
} from "./playbackRate";
import { isMediaElement } from "./domRealm";
import { parseStartExpression } from "./startExpression";
Expand Down Expand Up @@ -72,16 +71,9 @@ export function createRuntimeStartTimeResolver(params: {
}
}
if ((resolved == null || resolved <= 0) && isMediaElement(element)) {
const playbackStart = readMediaStart(element);
if (Number.isFinite(element.duration) && element.duration > playbackStart) {
resolved =
resolveNaturalMediaTimelineDurationFromValues(
element.duration,
playbackStart,
readElementRateSpec(element),
) ?? 0;
}
resolved = resolveNaturalMediaTimelineDuration(element, element.duration);
}
if (resolved == null || resolved <= 0) resolved = resolveTimedImageDurationSeconds(element);
if (resolved == null || resolved <= 0) {
const compositionId = element.getAttribute("data-composition-id");
if (compositionId) {
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/runtime/timeline.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DEFAULT_IMAGE_TIMELINE_DURATION_SECONDS } from "@hyperframes/parsers/media-duration";
import { describe, it, expect, afterEach } from "vitest";
import { collectRuntimeTimelinePayload } from "./timeline";

Expand Down Expand Up @@ -326,6 +327,41 @@ describe("collectRuntimeTimelinePayload", () => {
expect(result.clips[0].kind).toBe("image");
});

it("gives a timed image with no data-duration the dropped-image default, not the composition remainder", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);

const timed = document.createElement("img");
timed.id = "timed";
timed.setAttribute("data-start", "2");
root.appendChild(timed);

const timedClip = collectRuntimeTimelinePayload(defaultParams).clips.find(
(c) => c.id === "timed",
);
expect([timedClip?.start, timedClip?.duration]).toEqual([
2,
DEFAULT_IMAGE_TIMELINE_DURATION_SECONDS,
]);
});

it("trims a timed image with data-end and no data-duration to end minus start", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const img = document.createElement("img");
img.id = "trimmed";
img.setAttribute("data-start", "2");
img.setAttribute("data-end", "6");
root.appendChild(img);

const clip = collectRuntimeTimelinePayload(defaultParams).clips.find((c) => c.id === "trimmed");
expect([clip?.start, clip?.duration]).toEqual([2, 4]);
});

it("identifies composition clips", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
Expand Down
Loading
Loading