mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user