fix(studio): read and write a clip's in-point the way playback does (#4624)

* fix(studio): read and write a clip's in-point the way playback does

* fix(studio): a group head trim keeps the minimum clip length off the grid

* fix(studio): a group head trim stops where a single-clip trim does and never goes below zero

* fix(studio): group members on the grid move together when a trim stops at its limit
This commit is contained in:
Miguel Ángel
2026-09-28 03:08:13 +00:00
committed by GitHub
parent a845c322b9
commit 2ebfcc04a3
16 changed files with 273 additions and 53 deletions
@@ -1,5 +1,6 @@
import { parseHTML } from "linkedom";
import { removeElementWithGsapCascade } from "@hyperframes/parsers";
import { readMediaOffsetSeconds, readPlaybackRate } from "@hyperframes/parsers/media-duration";
import postcss from "postcss";
import selectorParser from "postcss-selector-parser";
import { isAllowedHtmlAttribute, isSafeAttributeValue } from "@hyperframes/core/html-attr-safety";
@@ -402,12 +403,14 @@ export function splitElementInHtml(
? "data-media-start"
: null;
if (playbackStartAttr) {
const authoredTrim = parseFloat(el.getAttribute(playbackStartAttr) ?? "");
const currentTrim = authoredTrim || fallbackTiming?.playbackStart || 0;
const rateRaw = parseFloat(el.getAttribute("data-playback-rate") ?? "");
const rate =
Number.isFinite(rateRaw) && rateRaw > 0 ? rateRaw : (fallbackTiming?.playbackRate ?? 1);
if (authoredTrim !== currentTrim)
const readAttr = (name: string) => el.getAttribute(name);
const authoredTrim = el.getAttribute(playbackStartAttr);
const currentTrim =
authoredTrim !== null
? readMediaOffsetSeconds(readAttr)
: (fallbackTiming?.playbackStart ?? 0);
const rate = readPlaybackRate(readAttr, fallbackTiming?.playbackRate);
if (authoredTrim === null || Number(authoredTrim) !== currentTrim)
el.setAttribute(playbackStartAttr, String(Math.round(currentTrim * 1000) / 1000));
clone.setAttribute(
playbackStartAttr,
@@ -1,6 +1,7 @@
// fallow-ignore-file code-duplication
import { parseHTML } from "linkedom";
import { describe, expect, it } from "vitest";
import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
import {
splitElementInHtml,
unwrapElementsFromHtml,
@@ -170,6 +171,23 @@ describe("splitElementInHtml", () => {
expect(document.getElementById("media")?.getAttribute("data-media-start")).toBe("3.296375");
});
it.each([
['data-playback-start="-1" data-media-start="2"', "data-playback-start", 2, 4],
['data-playback-start="abc" data-media-start="2"', "data-playback-start", 2, 4],
['data-media-start="-1"', "data-media-start", 0, 2],
['data-media-start="junk"', "data-media-start", 0, 2],
['data-media-start="1.5s"', "data-media-start", 0, 2],
])("splits %s where playback reads it", (inPoint, attr, left, right) => {
const mediaSource = `<!DOCTYPE html><html><body><div data-composition-id="root"><video id="media" class="clip" src="asset.mp4" data-start="0" data-duration="6" ${inPoint}></video></div></body></html>`;
const { document } = parseHTML(splitElementInHtml(mediaSource, { id: "media" }, 2, "b").html);
const playbackReads = (id: string) =>
readMediaOffsetSeconds((name) => document.getElementById(id)?.getAttribute(name));
expect([playbackReads("media"), playbackReads("b")]).toEqual([left, right]);
expect(document.getElementById("b")?.getAttribute(attr)).toBe(String(right));
});
it("does not add a media in-point to non-media elements", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
@@ -468,8 +468,8 @@ describe("AudioFxGroup dynamic carve", () => {
*/
it("levels the part of the file the clip plays, not the file from its start", async () => {
// A lane's `t` is seconds from the start of the CLIP, but the decode is the
// whole file — so a trimmed clip got an envelope offset by exactly
// `media-start`, and every correction landed early.
// whole file — so a trimmed clip got an envelope offset by exactly the
// in-point, and every correction landed early.
//
// The file is loud 0-2s, quiet 2-5s, loud again 5-8s, and the clip trims the
// first 2s. Measured from the clip's own zero, t=0.5 sits in the quiet
@@ -495,7 +495,8 @@ describe("AudioFxGroup dynamic carve", () => {
const { host, onSetAttributeQuiet } = mount({
"fx-chain": CHAIN,
"media-start": "2",
"playback-start": "2",
"media-start": "0",
duration: "6",
});
document.getElementById("bed")?.setAttribute("src", "bed.wav");
@@ -4,7 +4,10 @@ import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FlatMediaSection } from "./propertyPanelFlatMediaSection";
import { MediaSection } from "./propertyPanelMediaSection";
import type { DomEditSelection } from "./domEditing";
import { formatTimingValue } from "./propertyPanelHelpers";
import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -608,6 +611,65 @@ describe("FlatMediaSection — audio clips", () => {
act(() => root.unmount());
});
it.each<Record<string, string>>([
{ "playback-start": "1", "media-start": "5" },
{ "playback-start": "-1", "media-start": "1" },
{ "media-start": "1" },
{},
])("shows and edits the in-point playback reads (%o)", (inPoint) => {
const attrs: Record<string, string> = { "source-duration": "45", ...inPoint };
const playbackReads = () => readMediaOffsetSeconds((name) => attrs[name.slice(5)]);
const onSetAttribute = vi.fn((name: string, value: string) => {
attrs[name] = value;
});
const { host, root } = renderWithRate(makeAudioElement(attrs), onSetAttribute);
const readout = host
.querySelector('[data-flat-slider-track="true"][aria-label="Media start"]')
?.parentElement?.querySelector<HTMLElement>('[data-flat-slider-value="true"]');
if (!readout) throw new Error("expected Media start readout");
expect(readout.textContent).toBe(formatTimingValue(playbackReads()));
act(() => readout.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const input = host.querySelector<HTMLInputElement>('[data-flat-slider-input="true"]');
if (!input) throw new Error("expected Media start input");
act(() => {
typeInto(input, "3");
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(playbackReads()).toBe(3);
act(() => root.unmount());
});
it.each<Record<string, string>>([
{ "playback-start": "1", "media-start": "5" },
{ "media-start": "1" },
])("the Design panel's Media start slider writes where playback reads (%o)", (inPoint) => {
const attrs: Record<string, string> = { "source-duration": "45", ...inPoint };
const onSetAttribute = vi.fn((name: string, value: string) => {
attrs[name] = value;
});
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<MediaSection
projectDir={null}
element={makeAudioElement(attrs)}
styles={{}}
onSetStyle={vi.fn()}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={vi.fn()}
/>,
);
});
const slider = host.querySelector<HTMLInputElement>('input[aria-label="Media start"]');
if (!slider) throw new Error("expected Media start slider");
act(() => typeInto(slider, "300"));
act(() => slider.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })));
expect(readMediaOffsetSeconds((name) => attrs[name.slice(5)])).toBe(3);
act(() => root.unmount());
});
it("commits a typed volume in dB and a typed fade in seconds", () => {
const { host, root, onSetAttribute } = renderWithRate(makeAudioElement());
const readouts = host.querySelectorAll<HTMLElement>('[data-flat-slider-value="true"]');
@@ -8,6 +8,7 @@ import {
formatNumericValue,
formatTimingValue,
parseNumericValue,
readClipInPoint,
stripQueryAndHash,
} from "./propertyPanelHelpers";
import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
@@ -88,10 +89,7 @@ export function FlatMediaSection({
? automatedVolumeValue
: (parseNumericValue(element.dataAttributes.volume ?? "") ?? 1);
const volumeFaderPosition = audioGainToFaderPosition(volume);
const mediaStart =
Number.parseFloat(
element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0",
) || 0;
const { mediaStart, mediaStartAttr } = readClipInPoint(element.dataAttributes);
const constantRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
const playbackRate =
rate?.automated && rate.automatedValue !== undefined ? rate.automatedValue : constantRate;
@@ -340,11 +338,11 @@ export function FlatMediaSection({
max={mediaStartMax * 100}
tier={mediaStart === 0 ? "default" : "explicitCustom"}
displayValue={formatTimingValue(mediaStart)}
onCommit={(next) => void onSetAttribute("media-start", (next / 100).toFixed(2))}
onCommit={(next) => void onSetAttribute(mediaStartAttr, (next / 100).toFixed(2))}
onCommitText={(text) => {
const seconds = parseSecondsInput(text);
if (seconds === null) return false;
void onSetAttribute("media-start", Math.min(seconds, mediaStartMax).toFixed(2));
void onSetAttribute(mediaStartAttr, Math.min(seconds, mediaStartMax).toFixed(2));
return true;
}}
/>
@@ -5,6 +5,10 @@ import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
import type { TimelineElement } from "../../player";
import { roundToCenti } from "../../utils/rounding";
import { findPreviewNode } from "./domEditingElement";
import {
playbackStartAttributeForElement,
readPlaybackStartAttributes,
} from "../../player/lib/timelineElementHelpers";
export type {
BackgroundRemovalProgress,
@@ -225,6 +229,17 @@ export function parseNumericValue(value: string | undefined): number | null {
return Number.isFinite(parsed) ? parsed : null;
}
export function readClipInPoint(dataAttributes: Record<string, string> | undefined): {
mediaStart: number;
mediaStartAttr: string;
} {
const inPoint = readPlaybackStartAttributes((name) => dataAttributes?.[name.slice(5)]);
return {
mediaStart: inPoint.playbackStart ?? 0,
mediaStartAttr: playbackStartAttributeForElement(inPoint).slice(5),
};
}
export function formatTimingValue(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0.00s";
return `${seconds.toFixed(2)}s`;
@@ -8,6 +8,7 @@ import {
formatTimingValue,
LABEL,
parseNumericValue,
readClipInPoint,
RESPONSIVE_GRID,
stripQueryAndHash,
} from "./propertyPanelHelpers";
@@ -57,10 +58,7 @@ export function MediaSection({
const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1;
const volumeFaderPosition = audioGainToFaderPosition(volume);
const mediaStart =
Number.parseFloat(
element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0",
) || 0;
const { mediaStart, mediaStartAttr } = readClipInPoint(element.dataAttributes);
const hasLoop = el.hasAttribute("loop");
const hasMuted = el.hasAttribute("muted");
@@ -297,7 +295,7 @@ export function MediaSection({
displayValue={formatTimingValue(mediaStart)}
formatDisplayValue={(next) => formatTimingValue(next / 100)}
onCommit={(next) => {
void onSetAttribute("media-start", (next / 100).toFixed(2));
void onSetAttribute(mediaStartAttr, (next / 100).toFixed(2));
}}
/>
</div>
@@ -23,6 +23,7 @@ import {
} from "./propertyPanelAutomation";
import { trackLeveller } from "./audioFxTelemetry.js";
import type { DomEditSelection } from "./domEditingTypes";
import { readClipInPoint } from "./propertyPanelHelpers";
import { useAuditionTransport } from "./useAuditionTransport.js";
/**
@@ -80,12 +81,12 @@ export function useFxLevelling(
*
* A lane's `t` is seconds from the start of the CLIP, but the decode is the
* whole file from its first sample — so measuring a trimmed clip produced an
* envelope offset by the trim, and every correction landed early by exactly
* `media-start`. Slicing here is what puts the two clocks back on the same
* envelope offset by the trim, and every correction landed early by exactly the
* in-point. Slicing here is what puts the two clocks back on the same
* zero.
*/
const clipWindow = (audio: { samples: Float32Array; sampleRate: number }) => {
const mediaStart = positiveFinite(Number(element.dataAttributes?.["media-start"] ?? 0));
const { mediaStart } = readClipInPoint(element.dataAttributes);
const duration = positiveFinite(Number(element.dataAttributes?.["duration"] ?? Number.NaN));
const from = mediaStart
? Math.min(audio.samples.length, Math.floor(mediaStart * audio.sampleRate))
@@ -12,6 +12,7 @@ import {
type PersistTimelineBatchChange,
} from "./timelineEditingHelpers";
import type { TimelineElement } from "../player/store/playerStore";
import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
import { usePlayerStore } from "../player/store/playerStore";
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
import { timelineKeyframeSelectionKey } from "../player/components/timelineKeyframeIdentity";
@@ -244,6 +245,26 @@ describe("buildTimelineResizeTimingPatch", () => {
});
});
describe("buildTimelineResizeTimingPatch — source in-point read as playback reads it", () => {
it.each([
['data-playback-start="-1" data-media-start="2"', 4.5, 1.5],
['data-playback-start="2"', 4.5, 1.5],
['data-media-start="junk"', 5.5, 0.5],
['data-media-start="1.5s"', 5.5, 0.5],
])("%s, head to %s", (inPoint, start, expected) => {
const source = `<div id="root"><video id="a" class="clip" data-start="5" data-duration="3" ${inPoint}></video></div>`;
const element = el({ id: "a", tag: "video", domId: "a", start: 5, duration: 3 });
const host = document.createElement("div");
host.innerHTML = buildTimelineResizeTimingPatch(source, { id: "a" }, element, {
start,
duration: 8 - start,
playbackStart: undefined,
});
const video = host.querySelector("#a")!;
expect(readMediaOffsetSeconds((name) => video.getAttribute(name))).toBe(expected);
});
});
describe("persistTimelineBatchEdit", () => {
const SOURCE = `<div id="root"><video id="a" class="clip" data-start="1" data-track-index="0"></video><video id="b" class="clip" data-start="2" data-track-index="1"></video></div>`;
@@ -15,7 +15,10 @@ import { getElementZIndex } from "../player/lib/layerOrdering";
import {
furthestClipEndFromSource,
getTimelineElementIdentity,
playbackStartAttributeForElement,
readPlaybackStartAttributes,
} from "../player/lib/timelineElementHelpers";
import { resolveTimelinePlaybackRate } from "../player/components/timelineGroupEditing";
import {
saveProjectFilesWithHistory,
writeProjectFilesWithHistoryInQueue,
@@ -199,13 +202,6 @@ export function removeIframeTimelineElements(
findTimelineElementInIframe(iframe, element, activeCompositionPath)?.remove();
}
export function playbackStartAttributeForElement(
element: Pick<TimelineElement, "kind" | "playbackStartAttr">,
): "data-media-start" | "data-playback-start" {
return element.playbackStartAttr === "playback-start" || element.kind === "composition"
? "data-playback-start"
: "data-media-start";
}
// fallow-ignore-next-line complexity
function resolveResizePlaybackStart(
original: string,
@@ -219,15 +215,19 @@ function resolveResizePlaybackStart(
}
const trimDelta = updates.start - element.start;
if (trimDelta === 0) return null;
const raw =
readAttributeByTarget(original, target, "playback-start") ??
readAttributeByTarget(original, target, "media-start");
const current = raw != null ? parseFloat(raw) : undefined;
if (current == null || !Number.isFinite(current)) return null;
const attrName = playbackStartAttributeForElement(element).slice("data-".length);
const source = readPlaybackStartAttributes((name) =>
readAttributeByTarget(original, target, name),
);
if (source.playbackStart == null) return null;
const attrName = playbackStartAttributeForElement({ kind: element.kind, ...source }).slice(
"data-".length,
);
return {
attrName,
value: Math.max(0, current + trimDelta * Math.max(element.playbackRate ?? 1, 0.1)),
value: Math.max(
0,
source.playbackStart + trimDelta * resolveTimelinePlaybackRate(element.playbackRate),
),
};
}
@@ -8,7 +8,6 @@ import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
import {
applyTimelineStackingReorder,
patchIframeDomTiming,
playbackStartAttributeForElement,
persistTimelineEdit,
formatTimelineAttributeNumber,
formatTimelineMediaOffset,
@@ -16,6 +15,7 @@ import {
buildTimelineMoveTimingPatch,
buildTimelineResizeTimingPatch,
} from "./timelineEditingHelpers";
import { playbackStartAttributeForElement } from "../player/lib/timelineElementHelpers";
import {
captureDurationRollback,
finishClipTimingFallback,
@@ -15,11 +15,11 @@ import {
formatTimelineAttributeNumber,
formatTimelineMediaOffset,
patchIframeDomTiming,
playbackStartAttributeForElement,
persistTimelineBatchEdit,
type PersistTimelineBatchChange,
type RecordEditInput,
} from "./timelineEditingHelpers";
import { playbackStartAttributeForElement } from "../player/lib/timelineElementHelpers";
import {
captureDurationRollback,
finishGroupTimingGsapFallback,
@@ -880,6 +880,61 @@ describe("resolveTimelineResize", () => {
expect(next.start).toBe(2);
});
function headTrimmedFullyRight(clip: { start: number; duration: number }) {
const single = resolveTimelineResize(
{ ...clip, originClientX: 0, pixelsPerSecond: 100, minStart: 0, maxEnd: 10 },
"start",
1000,
);
const group = resolveTimelineGroupResize([clip], "start", 10).members[0]!;
return { single: single.duration, group: group.duration };
}
it.each([
{ start: 1, duration: 0.625 },
{ start: 1.005, duration: 2 },
])("keeps the minimum duration when a head trim runs off the grid (%o)", (clip) => {
const { single, group } = headTrimmedFullyRight(clip);
expect(Math.min(single, group)).toBeGreaterThanOrEqual(0.1);
});
it("keeps the minimum duration for any off-grid start and length on a 1 ms grid", () => {
const short: string[] = [];
for (let i = 0; i < 20; i++) {
for (let j = 0; j < 18; j++) {
const clip = { start: (1000 + i * 13) / 1000, duration: (100 + j * 17) / 1000 };
const { single, group } = headTrimmedFullyRight(clip);
if (Math.min(single, group) < 0.1 - 1e-9) short.push(`${clip.start}+${clip.duration}`);
}
}
expect(short).toEqual([]);
});
it("stops a group head trim where the single-clip trim stops", () => {
const { single, group } = headTrimmedFullyRight({ start: 1.003, duration: 2 });
expect(group).toBe(single);
});
it("moves on-grid group members together when the tightest member sits 5 ms off the grid", () => {
const members = [
{ start: 1.005, duration: 2 },
{ start: 0, duration: 5 },
{ start: 0.24, duration: 5 },
];
const next = resolveTimelineGroupResize(members, "start", 10).members;
expect(next.map((m) => m.start)).toEqual([2.9, 1.89, 2.13]);
});
it("never moves a group head trim left of zero near the minimum length", () => {
const [member] = resolveTimelineGroupResize(
[{ start: 0.004, duration: 0.1 }],
"start",
10,
).members;
expect(member!.start).toBeGreaterThanOrEqual(0);
expect(member!.duration).toBeGreaterThanOrEqual(0.1);
});
it("moves every group member by the same amount when one reaches its media start", () => {
const members = [
{ start: 2, duration: 3, playbackStart: 1, playbackRate: 1.5 },
@@ -1,3 +1,4 @@
import { clampPlaybackRate } from "@hyperframes/parsers/media-duration";
import { roundToCenti } from "../../utils/rounding";
import type { TimelineElement } from "../store/playerStore";
import { getTimelineEditCapabilities } from "./timelineEditCapabilities";
@@ -19,13 +20,17 @@ function ceilTimelineTime(value: number): number {
return Math.ceil((value - SAVED_MEDIA_OFFSET_TOLERANCE) * 100) / 100;
}
function floorTimelineTime(value: number): number {
return Math.floor((value + SAVED_MEDIA_OFFSET_TOLERANCE) * 100) / 100;
}
export function resolveTimelineMinDuration(minDuration?: number): number {
return Math.max(ABSOLUTE_TIMELINE_MIN_DURATION, minDuration ?? DEFAULT_TIMELINE_MIN_DURATION);
}
/** Playback rate never drops to zero (would make media-in-point math divide by ~0). */
function resolveTimelinePlaybackRate(rate?: number): number {
return Math.max(0.1, rate ?? 1);
export function resolveTimelinePlaybackRate(rate?: number): number {
return clampPlaybackRate(rate ?? 1);
}
interface TimelineStartTrimClip {
@@ -38,8 +43,8 @@ interface TimelineStartTrimClip {
/**
* Delta bounds for trimming a clip's START edge (shared by single-clip and group
* resize). Left-bounded by how far the start can move toward `minStart` and by the
* media in-point (`playbackStart / playbackRate`); right-bounded by `minDuration`.
* Returned deltas are unrounded; applyClipStartTrimDelta does the rounding.
* media in-point (`playbackStart / playbackRate`); right-bounded by `minDuration`, the start
* floored onto the centisecond grid. Otherwise unrounded; applyClipStartTrimDelta rounds.
*/
export function clipStartTrimDeltaBounds(
clip: TimelineStartTrimClip,
@@ -51,7 +56,7 @@ export function clipStartTrimDeltaBounds(
clip.playbackStart != null ? clip.playbackStart / playbackRate : Number.POSITIVE_INFINITY;
return {
minDelta: -Math.min(clip.start - minStart, maxLeftExtensionFromMedia),
maxDelta: clip.duration - minDuration,
maxDelta: floorTimelineTime(clip.start + clip.duration - minDuration) - clip.start,
};
}
@@ -137,7 +142,9 @@ export function clampTimelineGroupResizeDelta(
const bounds = members.map((member) => clipStartTrimDeltaBounds(member, 0, minDuration));
const minDelta = ceilTimelineTime(Math.max(...bounds.map((b) => b.minDelta)));
const maxDelta = Math.min(...bounds.map((b) => b.maxDelta));
return roundTimelineTime(clamp(rawDelta, minDelta, maxDelta));
const delta = roundTimelineTime(clamp(rawDelta, minDelta, maxDelta));
const roundedPastTightestMember = delta > maxDelta + SAVED_MEDIA_OFFSET_TOLERANCE;
return roundedPastTightestMember ? maxDelta - SAVED_MEDIA_OFFSET_TOLERANCE : delta;
}
export function resolveTimelineGroupResize(
@@ -9,6 +9,7 @@ import {
import { isTimelineIgnoredElement } from "./timelineElementHelpers";
import { computeResizePreview } from "../components/timelineClipDragPreview";
import type { TimelineElement } from "../store/playerStore";
import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
function el(id: string, extra: Partial<TimelineElement> = {}): TimelineElement {
return { id, tag: "img", start: 0, duration: 5, track: 0, ...extra };
@@ -46,6 +47,26 @@ describe("parseTimelineFromDOM — media in-point", () => {
});
});
describe("parseTimelineFromDOM — in-point read as playback reads it", () => {
it.each([
['data-playback-start="-1" data-media-start="2"', 2, "playback-start"],
['data-playback-start="abc" data-media-start="2"', 2, "playback-start"],
['data-media-start="junk"', 0, "media-start"],
['data-media-start="1.5s"', 0, "media-start"],
])("%s", (inPoint, playbackStart, playbackStartAttr) => {
const doc = makeDoc(
`<div data-composition-id="root"><video id="v" class="clip" data-start="0" data-duration="4" ${inPoint}></video></div>`,
);
const element = parseTimelineFromDOM(doc, 10).find((e) => e.domId === "v")!;
const video = doc.getElementById("v")!;
expect(element.playbackStart).toBe(readMediaOffsetSeconds((n) => video.getAttribute(n)));
expect([element.playbackStart, element.playbackStartAttr]).toEqual([
playbackStart,
playbackStartAttr,
]);
});
});
describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
it("bridges a real GSAP transition marker onto both named clips", () => {
document.body.innerHTML = `
@@ -13,7 +13,7 @@ import { isFinitePositive } from "./playbackAdapter";
import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
import { readElementFades } from "@hyperframes/core/audio-fade";
import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
import { type AttrReader, readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
// ---------------------------------------------------------------------------
// Layer-reveal lift transparency
@@ -168,14 +168,34 @@ export function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageEl
: null;
}
/** The in-point as playback reads it, and the attribute holding it; empty when neither is authored. */
export function readPlaybackStartAttributes(
getAttr: AttrReader,
): Pick<TimelineElement, "playbackStart" | "playbackStartAttr"> {
const playbackStartAttr =
getAttr("data-playback-start") != null
? "playback-start"
: getAttr("data-media-start") != null
? "media-start"
: undefined;
return playbackStartAttr
? { playbackStart: readMediaOffsetSeconds(getAttr), playbackStartAttr }
: {};
}
export function playbackStartAttributeForElement(
element: Pick<TimelineElement, "kind" | "playbackStartAttr">,
): "data-media-start" | "data-playback-start" {
return element.playbackStartAttr === "playback-start" || element.kind === "composition"
? "data-playback-start"
: "data-media-start";
}
function applyPlaybackMetadataFromElement(entry: TimelineElement, el: Element): void {
const playbackStartValue = el.getAttribute("data-playback-start");
const legacyMediaStartValue = el.getAttribute("data-media-start");
const mediaStartValue = playbackStartValue ?? legacyMediaStartValue;
if (mediaStartValue != null)
entry.playbackStart = readMediaOffsetSeconds((n) => el.getAttribute(n));
if (playbackStartValue != null) entry.playbackStartAttr = "playback-start";
else if (legacyMediaStartValue != null) entry.playbackStartAttr = "media-start";
Object.assign(
entry,
readPlaybackStartAttributes((n) => el.getAttribute(n)),
);
const authoredPlaybackRate = Number.parseFloat(el.getAttribute("data-playback-rate") ?? "");
if (Number.isFinite(authoredPlaybackRate) && authoredPlaybackRate > 0) {