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
4 changes: 4 additions & 0 deletions docs/concepts/compositions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ You can embed one composition inside another in two ways: loading from an extern
data-composition-id="intro-anim"
data-composition-src="compositions/intro-anim.html"
data-start="0"
data-duration="4"
data-playback-start="0"
data-track-index="3"
></div>
```
Expand All @@ -68,6 +70,8 @@ You can embed one composition inside another in two ways: loading from an extern
</div>
</template>
```

`data-playback-start` selects the child timeline time shown when the host begins. It defaults to `0`. A left trim or split advances this source-time offset by the elapsed host time multiplied by `data-playback-rate`, so the nested animation remains continuous instead of restarting.
</Tab>
<Tab title="Inline">
Define a nested composition directly inside the parent. This is simpler for one-off compositions that do not need to be reused.
Expand Down
2 changes: 2 additions & 0 deletions docs/concepts/data-attributes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Hyperframes uses HTML data attributes to control timing, media playback, and [co
| Attribute | Example | Description |
|-----------|---------|-------------|
| `data-media-start` | `"2"` | Media playback offset / trim point in seconds. Default: `0` |
| `data-playback-start` | `"2"` | Source-time offset in seconds for media wrappers and nested composition hosts. Missing values default to `0`; Studio writes this attribute when a composition is trimmed or split. |
| `data-playback-rate` | `"1.5"` | Source playback multiplier, clamped to `0.1`–`5`. Source time advances by timeline elapsed time multiplied by the canonical rate; invalid values default to `1`. |
| `data-volume` | `"0.8"` | Audio/video volume, 0 to 1 |
| `data-has-audio` | `"true"` | Indicates video has an audio track |

Expand Down
4 changes: 3 additions & 1 deletion docs/reference/html-schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,11 @@ Common sizes:
| `id` | All | Yes | Unique identifier (e.g., `"el-1"`). Used for relative timing references and CSS targeting. |
| `class="clip"` | Visible elements | Yes | Enables runtime visibility management. Omit for audio-only clips. |
| `data-start` | All | Yes | Start time in seconds (e.g., `"0"`, `"5.5"`), or a clip ID reference for [relative timing](#relative-timing) (e.g., `"intro"`). |
| `data-duration` | video, img, audio | See below | Duration in seconds. **Required** for images. Optional for video/audio (defaults to source duration). Not used on compositions. |
| `data-duration` | video, img, audio, nested composition hosts | See below | Timeline slot duration in seconds. **Required** for images and nested composition hosts. Optional for video/audio (defaults to source duration). |
| `data-track-index` | All | Yes | Timeline track number. Controls z-ordering (higher = in front). Clips on the same track cannot overlap. |
| `data-media-start` | video, audio | No | Playback offset / trim point in source file (seconds). Default: `0`. See [Data Attributes](/concepts/data-attributes). |
| `data-playback-start` | video, audio, composition | No | Source-time offset in seconds. On a nested composition, this is the child timeline time shown at the host's `data-start`. Default: `0`. |
| `data-playback-rate` | video, audio, composition | No | Source playback multiplier clamped to `0.1`–`5`. Invalid values default to `1`. |
| `data-volume` | audio, video | No | Volume level from `0` to `1`. Default: `1`. |
| `data-composition-id` | div | On compositions | Unique composition ID. Must match the key used in `window.__timelines`. |
| `data-composition-src` | div | No | Path to external composition HTML file (for [nested compositions](#composition-clips)). |
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1097,6 +1097,33 @@ describe("initSandboxRuntimeModular", () => {
expect(hookHost.style.visibility).toBe("visible");
});

it("seeks child compositions in source time using host offset and playback rate", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "20");
document.body.appendChild(root);

const child = document.createElement("div");
child.setAttribute("data-composition-id", "child");
child.setAttribute("data-start", "3");
child.setAttribute("data-duration", "8");
child.setAttribute("data-playback-start", "1.5");
child.setAttribute("data-playback-rate", "2");
root.appendChild(child);

const childTimeline = createMockTimeline(6);
window.__timelines = { main: createMockTimeline(20), child: childTimeline };
initSandboxRuntimeModular();

window.__player?.renderSeek(5);
expect(childTimeline.time()).toBeCloseTo(5.5);

window.__player?.renderSeek(10);
expect(childTimeline.time()).toBe(6);
});

it("keeps the root GSAP render nudge for normal frames but not silent probes", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
Expand Down
32 changes: 15 additions & 17 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
import { forceDispatchSeekEvent } from "./adapters/seek-dispatch";
import { createWaapiAdapter } from "./adapters/waapi";
import {
readElementPlaybackRate,
readElementPlaybackStart,
refreshRuntimeMediaCache,
resolveRuntimeMediaClipDuration,
syncRuntimeMedia,
Expand Down Expand Up @@ -2632,17 +2634,15 @@ export function initSandboxRuntimeModular(): void {
if (!node) continue;
const start = resolveStartForElement(node, 0);
if (!Number.isFinite(start)) continue;
const authoredDuration = resolveDurationForElement(node, {
includeAuthoredTimingAttrs: true,
});
const timelineDuration = getTimelineDurationSeconds(timeline);
const duration =
authoredDuration != null && authoredDuration > 0 ? authoredDuration : timelineDuration;
const sourceTime =
readElementPlaybackStart(node) +
Math.max(0, timeSeconds - start) * readElementPlaybackRate(node);
const localTime = Math.max(
0,
duration != null && duration > 0
? Math.min(duration, timeSeconds - start)
: timeSeconds - start,
timelineDuration != null && timelineDuration > 0
? Math.min(timelineDuration, sourceTime)
: sourceTime,
);
seekRuntimeTimeline(timeline, localTime, "runtime.init.transport.childTimeline", options);
}
Expand Down Expand Up @@ -2785,15 +2785,13 @@ export function initSandboxRuntimeModular(): void {
} catch (err) {
swallow("runtime.init.transport.seek", err);
}
// Sibling timelines (registered in __timelines but not nested under
// the root) are paused alongside the master. We do NOT seek them to
// absolute position `t` here — child timelines nested under the root
// are already propagated via tl.totalTime(), and seeking them again
// at absolute `t` would clobber their offset-relative position.
// Play/pause propagation for siblings happens in the player.play()
// and player.pause() overrides via the adapter layer.
} else {
seekStandaloneRegisteredTimelines(t, opts);
// Root propagation cannot represent an authored child source offset or
// playback rate. Re-seek registered children below with their host's
// explicit source-time contract.
}
seekStandaloneRegisteredTimelines(t, opts);
if (tl && opts?.activateChildren) {
activateSiblingTimelines(tl);
}
for (const adapter of state.deterministicAdapters) {
if (adapter.name === "gsap" && tl) continue;
Expand Down
20 changes: 17 additions & 3 deletions packages/core/src/runtime/media.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import { swallow } from "./diagnostics";
import { interpolateVolumeGain, type VolumeKeyframe } from "./mediaVolumeEnvelope.js";
import { normalizePlaybackRate } from "./playbackRate.js";

export function readElementPlaybackRate(el: HTMLMediaElement): number {
const raw = el.defaultPlaybackRate;
return Number.isFinite(raw) && raw > 0 ? Math.max(0.1, Math.min(5, raw)) : 1;
export function readElementPlaybackRate(el: Element): number {
const authored = Number.parseFloat(el.getAttribute("data-playback-rate") ?? "");
const raw =
Number.isFinite(authored) && authored > 0
? authored
: el instanceof HTMLMediaElement
? el.defaultPlaybackRate
: 1;
return normalizePlaybackRate(raw);
}

export function readElementPlaybackStart(el: Element): number {
const raw = Number.parseFloat(
el.getAttribute("data-playback-start") ?? el.getAttribute("data-media-start") ?? "",
);
return Number.isFinite(raw) && raw >= 0 ? raw : 0;
}

/**
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/runtime/playbackRate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function normalizePlaybackRate(raw: number): number {
return Number.isFinite(raw) && raw > 0 ? Math.max(0.1, Math.min(5, raw)) : 1;
}
22 changes: 22 additions & 0 deletions packages/core/src/runtime/timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,10 +312,32 @@ describe("collectRuntimeTimelinePayload", () => {
comp.setAttribute("data-composition-id", "scene-1");
comp.setAttribute("data-start", "0");
comp.setAttribute("data-duration", "10");
comp.setAttribute("data-playback-start", "1.5");
comp.setAttribute("data-playback-rate", "2");
root.appendChild(comp);

const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips[0].kind).toBe("composition");
expect(result.clips[0].playbackStart).toBe(1.5);
expect(result.clips[0].playbackRate).toBe(2);
});

it("defaults a legacy composition host playback window to zero at unit rate", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "20");
document.body.appendChild(root);

const comp = document.createElement("div");
comp.id = "scene-legacy";
comp.setAttribute("data-composition-id", "scene-legacy");
comp.setAttribute("data-start", "0");
comp.setAttribute("data-duration", "10");
root.appendChild(comp);

const clip = collectRuntimeTimelinePayload(defaultParams).clips[0];
expect(clip.playbackStart).toBe(0);
expect(clip.playbackRate).toBe(1);
});

it("collects scenes from composition nodes", () => {
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/runtime/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
} from "./types";
import { stableClipId } from "./clipTree";
import { swallow } from "./diagnostics";
import { readElementPlaybackRate } from "./media";
import { readElementPlaybackRate, readElementPlaybackStart } from "./media";
import { resolveCssStackingContextId } from "./stackingContext";
import { createRuntimeStartTimeResolver } from "./startResolver";
import { isSceneLikeCompositionId } from "../slideshow/index.js";
Expand Down Expand Up @@ -412,6 +412,8 @@ export function collectRuntimeTimelinePayload(params: {
parentCompositionId: compositionContext.parentCompositionId,
nodePath: null,
compositionSrc: toAbsoluteAssetUrl(node.getAttribute("data-composition-src")),
playbackStart: readElementPlaybackStart(node),
playbackRate: readElementPlaybackRate(node),
assetUrl: resolveNodeAssetUrl(node),
timelineRole: node.getAttribute("data-timeline-role"),
timelineLabel: node.getAttribute("data-timeline-label"),
Expand Down Expand Up @@ -521,6 +523,8 @@ export function collectRuntimeTimelinePayload(params: {
parentCompositionId: rootCompositionIdForGsap,
nodePath: null,
compositionSrc: null,
playbackStart: readElementPlaybackStart(el),
playbackRate: readElementPlaybackRate(el),
assetUrl: null,
timelineRole: el.getAttribute("data-timeline-role"),
timelineLabel: el.getAttribute("data-timeline-label"),
Expand Down Expand Up @@ -576,6 +580,8 @@ export function collectRuntimeTimelinePayload(params: {
parentCompositionId: rootCompositionIdForGsap,
nodePath: null,
compositionSrc: null,
playbackStart: readElementPlaybackStart(el),
playbackRate: readElementPlaybackRate(el),
assetUrl: null,
timelineRole,
timelineLabel: el.getAttribute("data-timeline-label"),
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export type RuntimeTimelineClip = {
parentCompositionId: string | null;
nodePath: string | null;
compositionSrc: string | null;
playbackStart: number;
playbackRate: number;
assetUrl: string | null;
timelineRole: string | null;
timelineLabel: string | null;
Expand Down
Loading
Loading