diff --git a/docs/reference/html-schema.mdx b/docs/reference/html-schema.mdx
index 883888ad3d..e11aa8d93f 100644
--- a/docs/reference/html-schema.mdx
+++ b/docs/reference/html-schema.mdx
@@ -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 |
diff --git a/packages/core/docs/core.md b/packages/core/docs/core.md
index 262094d20d..2ec22dfdc1 100644
--- a/packages/core/docs/core.md
+++ b/packages/core/docs/core.md
@@ -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 ` ` clips. Optional for `` and `` (defaults to the source media's full duration). Not used on compositions.
+- `data-duration` — Duration in seconds. Optional for ` ` (defaults to 3 seconds), `` and `` (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)
diff --git a/packages/core/src/playbackRateBounds.ts b/packages/core/src/playbackRateBounds.ts
index a82cb0eaf3..24814be7bf 100644
--- a/packages/core/src/playbackRateBounds.ts
+++ b/packages/core/src/playbackRateBounds.ts
@@ -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";
diff --git a/packages/core/src/runtime/clipTree.test.ts b/packages/core/src/runtime/clipTree.test.ts
index 27b0f34449..dc0ec720cd 100644
--- a/packages/core/src/runtime/clipTree.test.ts
+++ b/packages/core/src/runtime/clipTree.test.ts
@@ -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 = `
+
+
+
`;
+ 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) => {
diff --git a/packages/core/src/runtime/clipTree.ts b/packages/core/src/runtime/clipTree.ts
index db265c77a7..1e6dcae23f 100644
--- a/packages/core/src/runtime/clipTree.ts
+++ b/packages/core/src/runtime/clipTree.ts
@@ -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 {
@@ -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.
diff --git a/packages/core/src/runtime/playbackRate.test.ts b/packages/core/src/runtime/playbackRate.test.ts
index 71f2613f31..1a1b4c9383 100644
--- a/packages/core/src/runtime/playbackRate.test.ts
+++ b/packages/core/src/runtime/playbackRate.test.ts
@@ -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): Pick {
return {
@@ -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>) => {
+ 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,
+ ]);
+ });
});
diff --git a/packages/core/src/runtime/playbackRate.ts b/packages/core/src/runtime/playbackRate.ts
index 82df1e9cc6..01c72f4a4a 100644
--- a/packages/core/src/runtime/playbackRate.ts
+++ b/packages/core/src/runtime/playbackRate.ts
@@ -1,12 +1,17 @@
-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 {
@@ -14,14 +19,10 @@ export function parseStrictFiniteTimingNumber(raw: string | null | undefined): n
}
export function readElementPlaybackRate(el: Pick): 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. */
@@ -30,15 +31,9 @@ export function readElementRateSpec(el: Pick): RateSpec
}
export function readMediaStart(el: Pick): 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,
sourceDuration: number,
@@ -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 & { 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 ` ` (`data-start` or `data-track-index`) gets the dropped-image default unless
+ * trimmed by `data-duration` or `data-end`; a bare ` ` 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";
diff --git a/packages/core/src/runtime/startResolver.test.ts b/packages/core/src/runtime/startResolver.test.ts
index 5e7a0ef22f..8b42c9b390 100644
--- a/packages/core/src/runtime/startResolver.test.ts
+++ b/packages/core/src/runtime/startResolver.test.ts
@@ -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
@@ -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);
diff --git a/packages/core/src/runtime/startResolver.ts b/packages/core/src/runtime/startResolver.ts
index d2b3b0c744..3c9395404a 100644
--- a/packages/core/src/runtime/startResolver.ts
+++ b/packages/core/src/runtime/startResolver.ts
@@ -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";
@@ -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) {
diff --git a/packages/core/src/runtime/timeline.test.ts b/packages/core/src/runtime/timeline.test.ts
index 4cdd1372d7..ae4e4b87e2 100644
--- a/packages/core/src/runtime/timeline.test.ts
+++ b/packages/core/src/runtime/timeline.test.ts
@@ -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";
@@ -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");
diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts
index b02dcb0308..0c75165c71 100644
--- a/packages/core/src/runtime/timeline.ts
+++ b/packages/core/src/runtime/timeline.ts
@@ -12,6 +12,7 @@ import {
parseStrictFiniteTimingNumber,
resolveMediaElementDurationSeconds,
resolveNaturalMediaTimelineDuration,
+ resolveTimedImageDurationSeconds,
} from "./playbackRate";
import { resolveCssStackingContextId } from "./stackingContext";
import { createRuntimeStartTimeResolver } from "./startResolver";
@@ -362,6 +363,7 @@ export function collectRuntimeTimelinePayload(params: {
duration = resolveNaturalMediaTimelineDuration(node, node.duration);
}
}
+ if (duration == null) duration = resolveTimedImageDurationSeconds(node, start);
if (duration == null) {
const inheritedDuration = compositionContext.inheritedDuration;
if (inheritedDuration != null && inheritedDuration > 0) {
diff --git a/packages/lint/src/rules/structure.catalog.test.ts b/packages/lint/src/rules/structure.catalog.test.ts
index 08b26a7b46..bf153cefd3 100644
--- a/packages/lint/src/rules/structure.catalog.test.ts
+++ b/packages/lint/src/rules/structure.catalog.test.ts
@@ -7,7 +7,6 @@ const REPO_ROOT = resolve(__dirname, "../../../..");
const STRUCTURE_CODES = new Set([
"nested_structure_needs_subcomposition",
"timeline_element_missing_timing",
- "media_missing_duration",
"caption_track_kind_missing",
"multiple_caption_tracks",
]);
diff --git a/packages/lint/src/rules/structure.test.ts b/packages/lint/src/rules/structure.test.ts
index f4d4f619f1..4f4b16de61 100644
--- a/packages/lint/src/rules/structure.test.ts
+++ b/packages/lint/src/rules/structure.test.ts
@@ -12,7 +12,6 @@ async function codes(body: string, options: HyperframeLinterOptions = {}) {
const STRUCTURE = new Set([
"nested_structure_needs_subcomposition",
"timeline_element_missing_timing",
- "media_missing_duration",
"caption_track_kind_missing",
"multiple_caption_tracks",
]);
@@ -94,26 +93,18 @@ describe("legacy data-end", () => {
});
});
-describe("media_missing_duration", () => {
- it("flags a timed img without data-duration and passes it with one", async () => {
- const bare = await codes(' ');
- expect(has(bare, "media_missing_duration")).toBe(true);
- const timed = await codes(' ');
- expect(has(timed, "media_missing_duration")).toBe(false);
- });
-
- it("takes video and audio length from the file, so data-start alone passes", async () => {
- for (const tag of ["video", "audio"]) {
+describe("media length", () => {
+ it("takes length from the file or the image default, so data-start alone passes", async () => {
+ for (const tag of ["video", "audio", "img"]) {
const found = await codes(`<${tag} src="a" data-start="0" data-track-index="1">${tag}>`);
- expect(has(found, "media_missing_duration")).toBe(false);
expect(has(found, "timeline_element_missing_timing")).toBe(false);
}
});
-});
-describe("static media", () => {
it("does not flag a bare img with no timing attributes", async () => {
- expect(has(await codes(' '), "media_missing_duration")).toBe(false);
+ expect(has(await codes(' '), "timeline_element_missing_timing")).toBe(
+ false,
+ );
});
});
diff --git a/packages/lint/src/rules/structure.ts b/packages/lint/src/rules/structure.ts
index 37f065afd3..e95869b6da 100644
--- a/packages/lint/src/rules/structure.ts
+++ b/packages/lint/src/rules/structure.ts
@@ -45,8 +45,8 @@ const OPAQUE_TAGS = new Set([
]);
// Never layout: their content is code or inert markup.
const NON_LAYOUT_TAGS = new Set(["style", "script", "template", "noscript"]);
-// Their length comes from the media file, so data-start alone is enough.
-const FILE_LENGTH_TAGS = new Set(["video", "audio"]);
+// Media has a default length (the file's, or the dropped-image default), so data-start alone is enough.
+const MEDIA_TAGS = new Set(["video", "audio", "img"]);
const NODE_ATTRS = [
"id",
"class",
@@ -125,32 +125,21 @@ function nestedStructureFindings(rows: TagNode[], severity: Severity): Hyperfram
}
function missingDurationFindings(rows: TagNode[], severity: Severity): HyperframeLintFinding[] {
- // A bare img with no timing is a static layer, not a timeline clip.
- const intendedClip = (row: TagNode) =>
- row.tag !== "img" ||
- row.attrs["data-start"] !== undefined ||
- row.attrs["data-track-index"] !== undefined;
return rows
.filter(
(row) =>
- !FILE_LENGTH_TAGS.has(row.tag) &&
+ !MEDIA_TAGS.has(row.tag) &&
row.attrs["data-duration"] === undefined &&
row.attrs["data-end"] === undefined &&
- !isSubCompositionHost(row) &&
- intendedClip(row),
+ !isSubCompositionHost(row),
)
- .map((row) => {
- const isImage = row.tag === "img";
- return {
- code: isImage ? "media_missing_duration" : "timeline_element_missing_timing",
- severity,
- message: isImage
- ? `${describe(row)} is an image on the timeline without data-duration, and an image has no length of its own.`
- : `${describe(row)} is a timeline element without data-duration, so the timeline cannot draw where it ends.`,
- elementId: row.attrs.id,
- fixHint: `Add data-duration (in seconds) to ${describe(row)}.`,
- };
- });
+ .map((row) => ({
+ code: "timeline_element_missing_timing",
+ severity,
+ message: `${describe(row)} is a timeline element without data-duration, so the timeline cannot draw where it ends.`,
+ elementId: row.attrs.id,
+ fixHint: `Add data-duration (in seconds) to ${describe(row)}.`,
+ }));
}
function captionFindings(rows: TagNode[], severity: Severity): HyperframeLintFinding[] {
diff --git a/packages/parsers/package-subpaths.json b/packages/parsers/package-subpaths.json
index bcbe8501bf..0ccb3cdf24 100644
--- a/packages/parsers/package-subpaths.json
+++ b/packages/parsers/package-subpaths.json
@@ -80,6 +80,18 @@
"types": "./dist/compositionContract.d.ts",
"environments": ["browser", "bun", "node"]
},
+ "./media-duration": {
+ "source": "./src/mediaDuration.ts",
+ "runtime": "./dist/mediaDuration.js",
+ "types": "./dist/mediaDuration.d.ts",
+ "environments": ["browser", "bun", "node"]
+ },
+ "./media-duration-fixtures": {
+ "source": "./src/mediaDurationFixtures.ts",
+ "runtime": "./dist/mediaDurationFixtures.js",
+ "types": "./dist/mediaDurationFixtures.d.ts",
+ "environments": ["browser", "bun", "node"]
+ },
"./top-level-elements": {
"source": "./src/topLevelElements.ts",
"runtime": "./dist/topLevelElements.js",
diff --git a/packages/parsers/package.json b/packages/parsers/package.json
index 03cf242303..5db7df41d1 100644
--- a/packages/parsers/package.json
+++ b/packages/parsers/package.json
@@ -88,6 +88,18 @@
"import": "./src/compositionContract.ts",
"types": "./src/compositionContract.ts"
},
+ "./media-duration": {
+ "bun": "./src/mediaDuration.ts",
+ "node": "./dist/mediaDuration.js",
+ "import": "./src/mediaDuration.ts",
+ "types": "./src/mediaDuration.ts"
+ },
+ "./media-duration-fixtures": {
+ "bun": "./src/mediaDurationFixtures.ts",
+ "node": "./dist/mediaDurationFixtures.js",
+ "import": "./src/mediaDurationFixtures.ts",
+ "types": "./src/mediaDurationFixtures.ts"
+ },
"./top-level-elements": {
"bun": "./src/topLevelElements.ts",
"node": "./dist/topLevelElements.js",
@@ -171,6 +183,14 @@
"import": "./dist/compositionContract.js",
"types": "./dist/compositionContract.d.ts"
},
+ "./media-duration": {
+ "import": "./dist/mediaDuration.js",
+ "types": "./dist/mediaDuration.d.ts"
+ },
+ "./media-duration-fixtures": {
+ "import": "./dist/mediaDurationFixtures.js",
+ "types": "./dist/mediaDurationFixtures.d.ts"
+ },
"./top-level-elements": {
"import": "./dist/topLevelElements.js",
"types": "./dist/topLevelElements.d.ts"
diff --git a/packages/parsers/src/index.ts b/packages/parsers/src/index.ts
index e7f60a6c7a..2b548aed3f 100644
--- a/packages/parsers/src/index.ts
+++ b/packages/parsers/src/index.ts
@@ -10,6 +10,7 @@ export * from "./compositionContract.js";
export * from "./canvasScaffoldPatterns.js";
export * from "./topLevelElements.js";
export * from "./timingMismatches.js";
+export * from "./mediaDuration.js";
// Pure, browser-safe composition primitives shared by the linter (so it can
// consume them without depending on @hyperframes/core). The Node-only asset
diff --git a/packages/parsers/src/mediaDuration.test.ts b/packages/parsers/src/mediaDuration.test.ts
new file mode 100644
index 0000000000..fd4d2a5e41
--- /dev/null
+++ b/packages/parsers/src/mediaDuration.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, it } from "vitest";
+import {
+ DEFAULT_IMAGE_TIMELINE_DURATION_SECONDS,
+ MAX_PLAYBACK_RATE,
+ MIN_PLAYBACK_RATE,
+ PENDING_MEDIA_DURATION_READERS,
+ readAuthoredDurationSeconds,
+ readMediaOffsetSeconds,
+ readPlaybackRate,
+ resolveMediaDuration,
+ resolveNaturalDurationSeconds,
+} from "./mediaDuration.js";
+import { MEDIA_DURATION_FIXTURES, type MediaDurationFixture } from "./mediaDurationFixtures.js";
+
+// The parsers reader of the shared fixture table: attributes in, resolved length out.
+const resolveFixture = (f: MediaDurationFixture) => {
+ const getAttr = (name: string) => f.attrs[name];
+ const start = Number(f.attrs["data-start"] ?? 0);
+ return resolveMediaDuration({
+ tag: f.tag,
+ authoredDurationSeconds: readAuthoredDurationSeconds(getAttr, start),
+ sourceDurationSeconds: f.sourceDurationSeconds,
+ mediaStartSeconds: readMediaOffsetSeconds(getAttr),
+ playbackRate: readPlaybackRate(getAttr),
+ });
+};
+
+describe("resolveMediaDuration contract", () => {
+ for (const fixture of MEDIA_DURATION_FIXTURES) {
+ it(fixture.name, () => {
+ expect(resolveFixture(fixture)).toEqual(fixture.expected);
+ });
+ }
+
+ it("uses the one exported image default", () => {
+ expect(DEFAULT_IMAGE_TIMELINE_DURATION_SECONDS).toBe(3);
+ });
+
+ it("lists at least one pending reader, so the list can only shrink from here", () => {
+ expect(PENDING_MEDIA_DURATION_READERS.length).toBeGreaterThan(0);
+ });
+});
+
+describe("readAuthoredDurationSeconds", () => {
+ const attrs = (values: Record) => (name: string) => values[name];
+
+ it("prefers data-duration over data-end", () => {
+ expect(readAuthoredDurationSeconds(attrs({ "data-duration": "3", "data-end": "9" }), 1)).toBe(
+ 3,
+ );
+ });
+
+ it("falls back to data-end minus the element's start", () => {
+ expect(readAuthoredDurationSeconds(attrs({ "data-end": "9" }), 4)).toBe(5);
+ });
+
+ it("returns null when nothing is authored", () => {
+ expect(readAuthoredDurationSeconds(attrs({}), 0)).toBeNull();
+ });
+});
+
+describe("readMediaOffsetSeconds", () => {
+ const attrs = (values: Record) => (name: string) => values[name];
+
+ it("prefers data-playback-start over the older data-media-start", () => {
+ expect(
+ readMediaOffsetSeconds(attrs({ "data-playback-start": "2", "data-media-start": "9" })),
+ ).toBe(2);
+ });
+
+ it("falls back to data-media-start", () => {
+ expect(readMediaOffsetSeconds(attrs({ "data-media-start": "3" }))).toBe(3);
+ });
+
+ it("skips a negative data-playback-start and uses data-media-start", () => {
+ expect(
+ readMediaOffsetSeconds(attrs({ "data-playback-start": "-1", "data-media-start": "3" })),
+ ).toBe(3);
+ });
+
+ it("defaults to 0 for a negative or absent offset", () => {
+ expect(readMediaOffsetSeconds(attrs({ "data-media-start": "-1" }))).toBe(0);
+ expect(readMediaOffsetSeconds(attrs({}))).toBe(0);
+ });
+});
+
+describe("readPlaybackRate", () => {
+ const attrs = (values: Record) => (name: string) => values[name];
+
+ it("clamps an authored rate to the shared bounds", () => {
+ expect(readPlaybackRate(attrs({ "data-playback-rate": "50" }))).toBe(MAX_PLAYBACK_RATE);
+ expect(readPlaybackRate(attrs({ "data-playback-rate": "0.001" }))).toBe(MIN_PLAYBACK_RATE);
+ expect([MIN_PLAYBACK_RATE, MAX_PLAYBACK_RATE]).toEqual([0.1, 10]);
+ });
+
+ it("falls back to the given default (a media element's own rate) when none is authored", () => {
+ expect(readPlaybackRate(attrs({}), 2)).toBe(2);
+ });
+
+ it("reads a native-style rate like 2x as 2", () => {
+ expect(readPlaybackRate(attrs({ "data-playback-rate": "2x" }))).toBe(2);
+ });
+
+ it("defaults to 1 for a missing or non-positive rate", () => {
+ expect(readPlaybackRate(attrs({}))).toBe(1);
+ expect(readPlaybackRate(attrs({ "data-playback-rate": "0" }))).toBe(1);
+ });
+});
+
+describe("resolveNaturalDurationSeconds", () => {
+ it("subtracts the offset and divides by the rate", () => {
+ expect(resolveNaturalDurationSeconds(20, 5, 2)).toBe(7.5);
+ });
+
+ it("never goes negative when the offset exceeds the source", () => {
+ expect(resolveNaturalDurationSeconds(5, 10, 1)).toBe(0);
+ });
+
+ it("returns null for a non-finite source", () => {
+ expect(resolveNaturalDurationSeconds(Number.NaN, 0, 1)).toBeNull();
+ expect(resolveNaturalDurationSeconds(Number.POSITIVE_INFINITY, 0, 1)).toBeNull();
+ });
+});
diff --git a/packages/parsers/src/mediaDuration.ts b/packages/parsers/src/mediaDuration.ts
new file mode 100644
index 0000000000..a1a5c14136
--- /dev/null
+++ b/packages/parsers/src/mediaDuration.ts
@@ -0,0 +1,129 @@
+/** The one rule for a media element's timeline length; hosts only supply the source length. */
+
+import { parseNumeric } from "./compositionContract.js";
+
+export type MediaTag = "video" | "audio" | "img";
+
+/** Reads a named attribute's raw string value. Works for a DOM Element's `getAttribute`
+ * (browser or linkedom) or a plain attrs-record lookup -- callers pick whichever they have. */
+export type AttrReader = (name: string) => string | null | undefined;
+
+const nonNegative = (raw: string | null | undefined): number | null => {
+ const value = parseNumeric(raw);
+ return value !== null && value >= 0 ? value : null;
+};
+
+/** Playback offset into the source: `data-playback-start`, falling back to the older
+ * `data-media-start`; an invalid or negative value falls through to the next. One reader for
+ * every host -- the runtime and `htmlParser` used to read this two different ways. */
+export function readMediaOffsetSeconds(getAttr: AttrReader): number {
+ return (
+ nonNegative(getAttr("data-playback-start")) ?? nonNegative(getAttr("data-media-start")) ?? 0
+ );
+}
+
+/** The one bound on a clip's playback rate; core re-exports it for the rate lane. */
+export const MIN_PLAYBACK_RATE = 0.1;
+export const MAX_PLAYBACK_RATE = 10;
+
+/** A playback rate clamped to the bounds above; anything non-positive or non-finite is 1. */
+export function clampPlaybackRate(raw: number): number {
+ return Number.isFinite(raw) && raw > 0
+ ? Math.max(MIN_PLAYBACK_RATE, Math.min(MAX_PLAYBACK_RATE, raw))
+ : 1;
+}
+
+/** `data-playback-rate`, parsed like the browser parses a native rate (`"2x"` is 2), else
+ * `fallback` (a media element's own `defaultPlaybackRate` in the browser), clamped. */
+export function readPlaybackRate(getAttr: AttrReader, fallback = 1): number {
+ const authored = Number.parseFloat(getAttr("data-playback-rate") ?? "");
+ return clampPlaybackRate(Number.isFinite(authored) && authored > 0 ? authored : fallback);
+}
+
+/** `data-duration` when it is a positive number, else null. */
+export function readDataDurationSeconds(getAttr: AttrReader): number | null {
+ const duration = parseNumeric(getAttr("data-duration"));
+ return duration !== null && duration > 0 ? duration : null;
+}
+
+/** An authored trim: `data-duration` if positive, else `data-end - start` if `data-end` is
+ * authored, else null (nothing authored). */
+export function readAuthoredDurationSeconds(
+ getAttr: AttrReader,
+ startSeconds: number,
+): number | null {
+ const duration = readDataDurationSeconds(getAttr);
+ if (duration !== null) return duration;
+ const end = parseNumeric(getAttr("data-end"));
+ return end !== null ? end - startSeconds : null;
+}
+
+/** The dropped-image default: what Studio gives a freshly dropped image before any trim.
+ * One owner; everything needing an untrimmed image length imports this. */
+export const DEFAULT_IMAGE_TIMELINE_DURATION_SECONDS = 3;
+
+/** The arithmetic alone: no attribute reading, no DOM, no probing. */
+export function resolveNaturalDurationSeconds(
+ sourceDurationSeconds: number,
+ mediaStartSeconds: number,
+ playbackRate: number,
+): number | null {
+ if (!Number.isFinite(sourceDurationSeconds)) return null;
+ const remaining = Math.max(0, sourceDurationSeconds - mediaStartSeconds);
+ return remaining / clampPlaybackRate(playbackRate);
+}
+
+export type MediaDurationSource = "authored" | "media" | "default" | "pending";
+
+export interface MediaDurationResult {
+ seconds: number | null;
+ source: MediaDurationSource;
+ /** Set only when source is "pending": why nothing could be resolved yet. */
+ reason?: string;
+}
+
+export interface ResolveMediaDurationInput {
+ tag: MediaTag;
+ /** From `readAuthoredDurationSeconds`, or null when nothing is authored. */
+ authoredDurationSeconds: number | null;
+ /** The source's own length once a probe has answered, else null. Ignored for `img`. */
+ sourceDurationSeconds: number | null;
+ mediaStartSeconds: number;
+ playbackRate: number;
+}
+
+export function resolveMediaDuration(input: ResolveMediaDurationInput): MediaDurationResult {
+ const { tag, authoredDurationSeconds, sourceDurationSeconds, mediaStartSeconds, playbackRate } =
+ input;
+ if (authoredDurationSeconds !== null && authoredDurationSeconds > 0) {
+ return { seconds: authoredDurationSeconds, source: "authored" };
+ }
+ if (tag === "img") {
+ return { seconds: DEFAULT_IMAGE_TIMELINE_DURATION_SECONDS, source: "default" };
+ }
+ if (sourceDurationSeconds === null) {
+ return { seconds: null, source: "pending", reason: "source duration not yet probed" };
+ }
+ const seconds = resolveNaturalDurationSeconds(
+ sourceDurationSeconds,
+ mediaStartSeconds,
+ playbackRate,
+ );
+ if (seconds === null) {
+ return { seconds: null, source: "pending", reason: "source reported a non-finite duration" };
+ }
+ return { seconds, source: "media" };
+}
+
+/** Readers not yet converted to `resolveMediaDuration`; each reader PR removes its entry, so this only shrinks. */
+export const PENDING_MEDIA_DURATION_READERS = [
+ "core/src/runtime/init.ts (visibility end, clock, seek, WebAudio scheduling)",
+ "core/src/compiler/timingCompiler.ts compileTag",
+ "core/src/compiler/htmlCompiler.ts compileHtml",
+ "producer/src/services/htmlCompiler.ts resolveMediaDuration",
+ "engine/src/services/videoFrameExtractor.ts (extraction window)",
+ "engine/src/services/audioMixer.ts",
+ "parsers/src/htmlParser.ts (defaults to 5s, reads only data-media-start)",
+ "studio (timelineDOM.ts, timelineElementHelpers.ts, useTimelineSyncCallbacks.ts)",
+ "cli/src/timeline/describeProject.ts describeRow (#4138)",
+] as const;
diff --git a/packages/parsers/src/mediaDurationFixtures.ts b/packages/parsers/src/mediaDurationFixtures.ts
new file mode 100644
index 0000000000..a4b76d0a18
--- /dev/null
+++ b/packages/parsers/src/mediaDurationFixtures.ts
@@ -0,0 +1,91 @@
+import type { MediaDurationResult, MediaTag } from "./mediaDuration.js";
+
+/** The one fixture table every media-length reader is proven against: an authored
+ * element plus the source length a probe would report, and the `expected` result. */
+export interface MediaDurationFixture {
+ name: string;
+ tag: MediaTag;
+ attrs: Readonly>;
+ sourceDurationSeconds: number | null;
+ expected: MediaDurationResult;
+}
+
+export const MEDIA_DURATION_FIXTURES: readonly MediaDurationFixture[] = [
+ {
+ name: "authored data-duration wins over a known source length",
+ tag: "video",
+ attrs: { "data-start": "0", "data-duration": "4" },
+ sourceDurationSeconds: 20,
+ expected: { seconds: 4, source: "authored" },
+ },
+ {
+ name: "video with only data-start resolves to the source length",
+ tag: "video",
+ attrs: { "data-start": "0" },
+ sourceDurationSeconds: 12,
+ expected: { seconds: 12, source: "media" },
+ },
+ {
+ name: "video resolves to source minus offset over rate",
+ tag: "video",
+ attrs: { "data-start": "0", "data-playback-start": "5", "data-playback-rate": "2" },
+ sourceDurationSeconds: 20,
+ expected: { seconds: 7.5, source: "media" },
+ },
+ {
+ name: "the older data-media-start offset still applies",
+ tag: "audio",
+ attrs: { "data-start": "0", "data-media-start": "4" },
+ sourceDurationSeconds: 10,
+ expected: { seconds: 6, source: "media" },
+ },
+ {
+ name: "an offset past the end of the source is a known zero span",
+ tag: "audio",
+ attrs: { "data-start": "0", "data-media-start": "12" },
+ sourceDurationSeconds: 10,
+ expected: { seconds: 0, source: "media" },
+ },
+ {
+ name: "a rate above the 10x bound is clamped",
+ tag: "video",
+ attrs: { "data-start": "0", "data-playback-rate": "20" },
+ sourceDurationSeconds: 10,
+ expected: { seconds: 1, source: "media" },
+ },
+ {
+ name: "a native-style rate like 2x reads as 2",
+ tag: "video",
+ attrs: { "data-start": "0", "data-playback-rate": "2x" },
+ sourceDurationSeconds: 10,
+ expected: { seconds: 5, source: "media" },
+ },
+ {
+ name: "a zero authored duration is not authored",
+ tag: "video",
+ attrs: { "data-start": "0", "data-duration": "0" },
+ sourceDurationSeconds: 8,
+ expected: { seconds: 8, source: "media" },
+ },
+ {
+ name: "video with no probed source length is pending, not a guess",
+ tag: "video",
+ attrs: { "data-start": "0" },
+ sourceDurationSeconds: null,
+ expected: { seconds: null, source: "pending", reason: "source duration not yet probed" },
+ },
+ {
+ name: "image with no authored duration takes the dropped-image default",
+ tag: "img",
+ attrs: { "data-start": "0" },
+ sourceDurationSeconds: null,
+ expected: { seconds: 3, source: "default" },
+ },
+ {
+ name: "image with an authored duration is trimmed like any other media",
+ tag: "img",
+ attrs: { "data-start": "0", "data-duration": "1.5" },
+ sourceDurationSeconds: null,
+ expected: { seconds: 1.5, source: "authored" },
+ },
+];
diff --git a/packages/parsers/tsup.config.ts b/packages/parsers/tsup.config.ts
index 499c6228a7..90ce049021 100644
--- a/packages/parsers/tsup.config.ts
+++ b/packages/parsers/tsup.config.ts
@@ -14,6 +14,8 @@ export default defineConfig({
assets: "src/assets.ts",
composition: "src/composition.ts",
compositionContract: "src/compositionContract.ts",
+ mediaDuration: "src/mediaDuration.ts",
+ mediaDurationFixtures: "src/mediaDurationFixtures.ts",
topLevelElements: "src/topLevelElements.ts",
colorGradingContract: "src/colorGradingContract.ts",
subCompositionValidity: "src/subCompositionValidity.ts",
diff --git a/skills-manifest.json b/skills-manifest.json
index b1b072e380..0f9e21c193 100644
--- a/skills-manifest.json
+++ b/skills-manifest.json
@@ -34,7 +34,7 @@
"files": 11
},
"hyperframes-core": {
- "hash": "0347ec802ad1bbe3",
+ "hash": "ca3bc11c4e163225",
"files": 11
},
"hyperframes-creative": {
diff --git a/skills/hyperframes-core/references/data-attributes.md b/skills/hyperframes-core/references/data-attributes.md
index 2ce28d18d5..c3dac793df 100644
--- a/skills/hyperframes-core/references/data-attributes.md
+++ b/skills/hyperframes-core/references/data-attributes.md
@@ -26,15 +26,15 @@ The root should be `position: relative`, have explicit pixel dimensions, and hid
**Nesting is allowed.** A timed element inside a wrapper is still timed, and a timed ancestor clamps its descendants: a child cannot be visible while its timed ancestor is hidden. Direct children of the root get automatic layout (see "Root-level clips get automatic layout" below); nested ones do not, so give them their own positioning.
-| Attribute | Required | Meaning |
-| ------------------ | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `id` | Yes on ``/``, else recommended | `lint` errors with `media_missing_id` on media without one, and an id-less `` is never mixed, so the render is **silent**. Elsewhere it is a warning (`studio_missing_editable_id`): Studio needs a stable edit target, and timeline targets reference it. |
-| `data-start` | Yes | Start time in seconds, or a supported clip-time reference. This attribute is what marks the element as timed. |
-| `data-duration` | Required for `div`, `img`, and sub-compositions | Duration in seconds. Video/audio can default to media duration when known. Without any resolvable duration the element has no end and stays visible for the rest of the composition. |
-| `data-track-index` | No | Studio timeline lane, display only. The render never reads it, and clips on one track may overlap in time. Absent, the parser defaults it and Studio lays out one lane per clip. Two `` elements on the same index that overlap in time raise a `lint` warning. |
-| `data-media-start` | No | Offset into the media source, in seconds. |
-| `data-volume` | No | Static audio gain, default `1` (0 dB). `0` is silence and values above `1` boost, up to `3.98` (+12 dB) — Studio's fader writes this. For fades and ducking, use the `data-automation` volume lane (see `creator-editing-recipes.md`). |
-| `data-has-audio` | No (`` only) | `"true"` to declare the video carries an audio track when auto-detection would miss it. |
+| Attribute | Required | Meaning |
+| ------------------ | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `id` | Yes on ``/``, else recommended | `lint` errors with `media_missing_id` on media without one, and an id-less `` is never mixed, so the render is **silent**. Elsewhere it is a warning (`studio_missing_editable_id`): Studio needs a stable edit target, and timeline targets reference it. |
+| `data-start` | Yes | Start time in seconds, or a supported clip-time reference. This attribute is what marks the element as timed. |
+| `data-duration` | Required for `div` and sub-compositions | Duration in seconds. An `img` with `data-start` defaults to 3 s; video/audio default to the source length (less the playback offset, over the rate) once known; an authored value trims. Without any resolvable duration the element has no end and stays visible for the rest of the composition. |
+| `data-track-index` | No | Studio timeline lane, display only. The render never reads it, and clips on one track may overlap in time. Absent, the parser defaults it and Studio lays out one lane per clip. Two `` elements on the same index that overlap in time raise a `lint` warning. |
+| `data-media-start` | No | Offset into the media source, in seconds. |
+| `data-volume` | No | Static audio gain, default `1` (0 dB). `0` is silence and values above `1` boost, up to `3.98` (+12 dB) — Studio's fader writes this. For fades and ducking, use the `data-automation` volume lane (see `creator-editing-recipes.md`). |
+| `data-has-audio` | No (`` only) | `"true"` to declare the video carries an audio track when auto-detection would miss it. |
**The visibility window is half-open: `[start, start + duration)`.** A clip shows while `start ≤ t < start + duration` and is hidden at exactly `t = start + duration`. Land an animation's resolved end state slightly **before** `data-duration`, not on it, or its last frame is never rendered. Two clips can therefore be authored back to back (`b.start === a.start + a.duration`) with no overlapping frame.