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
67 changes: 67 additions & 0 deletions packages/studio/src/player/components/timelineZoom.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { describe, expect, it } from "vitest";
import {
clampTimelineZoomPercent,
computePinnedZoomPercent,
getNextTimelineZoomPercent,
getPinchTimelineZoomPercent,
getTimelinePixelsPerSecond,
getTimelineZoomPercent,
MAX_TIMELINE_ZOOM_PERCENT,
MIN_TIMELINE_ZOOM_PERCENT,
timelineZoomPercentToSlider,
timelineSliderToZoomPercent,
} from "./timelineZoom";

describe("clampTimelineZoomPercent", () => {
Expand Down Expand Up @@ -81,3 +84,67 @@ describe("getPinchTimelineZoomPercent", () => {
expect(getPinchTimelineZoomPercent(-10000, "manual", 100)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
});
});

describe("timelineZoomPercentToSlider", () => {
it("maps min zoom to slider position 0", () => {
expect(timelineZoomPercentToSlider(MIN_TIMELINE_ZOOM_PERCENT)).toBeCloseTo(0, 5);
});

it("maps max zoom to slider position 100", () => {
expect(timelineZoomPercentToSlider(MAX_TIMELINE_ZOOM_PERCENT)).toBeCloseTo(100, 5);
});

it("maps 100% to the log midpoint between 10 and 2000", () => {
const expected = ((Math.log(100) - Math.log(10)) / (Math.log(2000) - Math.log(10))) * 100;
expect(timelineZoomPercentToSlider(100)).toBeCloseTo(expected, 3);
});
});

describe("timelineSliderToZoomPercent", () => {
it("maps slider 0 to min zoom", () => {
expect(timelineSliderToZoomPercent(0)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
});

it("maps slider 100 to max zoom", () => {
expect(timelineSliderToZoomPercent(100)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
});
});

describe("computePinnedZoomPercent", () => {
it("returns 100 when current pps equals the fit pps (a no-op pin at the current fit)", () => {
expect(computePinnedZoomPercent(42, 42)).toBe(100);
});

it("reproduces the current pps: percent × fitPps / 100 === currentPps", () => {
const fitPps = 20;
const currentPps = 50; // user zoomed in 2.5×
const percent = computePinnedZoomPercent(currentPps, fitPps);
expect(percent).toBe(250);
// Round-trips through getTimelinePixelsPerSecond back to the on-screen pps.
expect(getTimelinePixelsPerSecond(fitPps, "manual", percent)).toBeCloseTo(currentPps, 5);
});

it("clamps a pin that would exceed the manual-zoom bounds", () => {
// currentPps 10000 / fitPps 1 = 1_000_000% → clamped to MAX.
expect(computePinnedZoomPercent(10000, 1)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
// Tiny ratio → clamped up to MIN.
expect(computePinnedZoomPercent(0.001, 1000)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
});

it("falls back to 100 for unusable inputs (a safe no-op pin)", () => {
expect(computePinnedZoomPercent(Number.NaN, 20)).toBe(100);
expect(computePinnedZoomPercent(50, 0)).toBe(100);
expect(computePinnedZoomPercent(-5, 20)).toBe(100);
expect(computePinnedZoomPercent(50, Number.POSITIVE_INFINITY)).toBe(100);
});
});

describe("timelineZoomPercentToSlider / timelineSliderToZoomPercent round-trip", () => {
for (const percent of [10, 100, 500, 2000]) {
it(`round-trips ${percent}% within ±1%`, () => {
const slider = timelineZoomPercentToSlider(percent);
const back = timelineSliderToZoomPercent(slider);
expect(Math.abs(back - percent) / percent).toBeLessThan(0.01);
});
}
});
51 changes: 51 additions & 0 deletions packages/studio/src/player/components/timelineZoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,34 @@ export function getTimelineZoomPercent(zoomMode: ZoomMode, manualZoomPercent: nu
return zoomMode === "fit" ? 100 : clampTimelineZoomPercent(manualZoomPercent);
}

/**
* The manual-zoom percent that, applied to `fitPixelsPerSecond`, reproduces the
* CURRENT on-screen pixels-per-second exactly. Used to PIN the timeline zoom on
* the first edit so a duration change (which recomputes fit-pps) no longer
* rescales every clip: we switch `zoomMode` to "manual" with this percent, so
* `getTimelinePixelsPerSecond` keeps returning today's pps regardless of the new
* fit basis.
*
* Since `pps = fitPps * (percent / 100)` in manual mode, and while fitting
* `pps === fitPps`, the pinned percent is `currentPps / fitPps * 100`. Clamped to
* the manual-zoom range so the pin can't land outside the slider's bounds; falls
* back to 100 (a no-op pin at the current fit) when either input is unusable.
*/
export function computePinnedZoomPercent(
currentPixelsPerSecond: number,
fitPixelsPerSecond: number,
): number {
if (
!Number.isFinite(currentPixelsPerSecond) ||
currentPixelsPerSecond <= 0 ||
!Number.isFinite(fitPixelsPerSecond) ||
fitPixelsPerSecond <= 0
) {
return 100;
}
return clampTimelineZoomPercent((currentPixelsPerSecond / fitPixelsPerSecond) * 100);
}

export function getTimelinePixelsPerSecond(
fitPixelsPerSecond: number,
zoomMode: ZoomMode,
Expand Down Expand Up @@ -47,3 +75,26 @@ export function getPinchTimelineZoomPercent(
if (!Number.isFinite(deltaY) || deltaY === 0) return current;
return clampTimelineZoomPercent(current * Math.exp(-deltaY * PINCH_ZOOM_SENSITIVITY));
}

const LOG_MIN = Math.log(MIN_TIMELINE_ZOOM_PERCENT);
const LOG_MAX = Math.log(MAX_TIMELINE_ZOOM_PERCENT);

/**
* Maps a zoom percent (10–2000) to a slider position (0–100) using a log scale.
* Log scale is used because the range spans 200×; linear would compress the
* low end (10–100%) into a tiny sliver of the slider.
*/
export function timelineZoomPercentToSlider(percent: number): number {
const clamped = clampTimelineZoomPercent(percent);
return ((Math.log(clamped) - LOG_MIN) / (LOG_MAX - LOG_MIN)) * 100;
}

/**
* Maps a slider position (0–100) to a zoom percent (10–2000) using a log scale.
* Inverse of `timelineZoomPercentToSlider`.
*/
export function timelineSliderToZoomPercent(slider: number): number {
const clampedSlider = Math.max(0, Math.min(100, slider));
const logValue = LOG_MIN + (clampedSlider / 100) * (LOG_MAX - LOG_MIN);
return clampTimelineZoomPercent(Math.exp(logValue));
}
219 changes: 219 additions & 0 deletions packages/studio/src/player/hooks/useTimelineSyncCallbacks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import {
resolveReloadSeekTime,
resolveTimelineTotalDuration,
revealIframe,
} from "./useTimelineSyncCallbacks";
import { readTimelineDurationFromDocument } from "../lib/timelineDOM";

// Minimal stand-in — revealIframe only touches `style.visibility`. Avoids
// depending on a DOM environment (this test file runs under node).
function fakeIframe(visibility: string): HTMLIFrameElement {
return { style: { visibility } } as unknown as HTMLIFrameElement;
}

describe("revealIframe", () => {
it("clears a hidden iframe's visibility (undoes refreshPlayer's hide)", () => {
const iframe = fakeIframe("hidden");
revealIframe(iframe);
expect(iframe.style.visibility).toBe("");
});

it("leaves an already-visible iframe untouched (idempotent)", () => {
const iframe = fakeIframe("");
revealIframe(iframe);
expect(iframe.style.visibility).toBe("");
});

it("no-ops on a null iframe", () => {
expect(() => revealIframe(null)).not.toThrow();
});
});

describe("resolveReloadSeekTime", () => {
it("restores the pending seek saved by refreshPlayer (the primary reload path)", () => {
expect(
resolveReloadSeekTime({
pendingSeek: 7.2,
requestedSeek: null,
storeCurrentTime: 7.2,
duration: 20,
}),
).toBe(7.2);
});

it("honors a deep-link seek request when no pending seek exists", () => {
expect(
resolveReloadSeekTime({
pendingSeek: null,
requestedSeek: 12.5,
storeCurrentTime: 0,
duration: 20,
}),
).toBe(12.5);
});

it("THE BUG: a second overlapping reload (pending seek already consumed) restores the store playhead, not 0", () => {
// Drop → reload #1 consumes pendingSeek and seeks/syncs to 7.2. A staggered
// second reload (refreshPreviewDocumentVersion 80/300ms bumps) then finds the
// slot empty — the old code reset the playhead to 0 here.
expect(
resolveReloadSeekTime({
pendingSeek: null,
requestedSeek: null,
storeCurrentTime: 7.2,
duration: 20,
}),
).toBe(7.2);
});

it("fresh project load starts at 0 (store resets currentTime on project switch)", () => {
expect(
resolveReloadSeekTime({
pendingSeek: null,
requestedSeek: null,
storeCurrentTime: 0,
duration: 20,
}),
).toBe(0);
});

it("clamps to duration when content shrank past the playhead (the one sanctioned move)", () => {
expect(
resolveReloadSeekTime({
pendingSeek: 18,
requestedSeek: null,
storeCurrentTime: 18,
duration: 9,
}),
).toBe(9);
});

it("a pending seek of 0 is an explicit position, not a missing value", () => {
expect(
resolveReloadSeekTime({
pendingSeek: 0,
requestedSeek: 12,
storeCurrentTime: 5,
duration: 20,
}),
).toBe(0);
});

it("returns the guarded target unclamped when the duration is non-finite (no seek(NaN))", () => {
// Mid-reload the adapter can report a NaN duration; Math.min(target, NaN) would
// be NaN and seek(NaN). The guarded target must pass through unclamped instead.
expect(
resolveReloadSeekTime({
pendingSeek: 7.2,
requestedSeek: null,
storeCurrentTime: 7.2,
duration: Number.NaN,
}),
).toBe(7.2);
// A zero/negative duration is equally unusable — pass the target through.
expect(
resolveReloadSeekTime({
pendingSeek: 7.2,
requestedSeek: null,
storeCurrentTime: 7.2,
duration: 0,
}),
).toBe(7.2);
});

it("guards against non-finite and negative targets", () => {
expect(
resolveReloadSeekTime({
pendingSeek: Number.NaN,
requestedSeek: null,
storeCurrentTime: 5,
duration: 20,
}),
).toBe(0);
expect(
resolveReloadSeekTime({
pendingSeek: -3,
requestedSeek: null,
storeCurrentTime: 5,
duration: 20,
}),
).toBe(0);
});
});

describe("resolveTimelineTotalDuration", () => {
it("THE BUG: a clip-manifest total shorter than the authored root duration never wins (stale '0:44/0:40')", () => {
// Fixture shape: root data-duration=44.5, furthest clip ends at 40. A runtime
// that measures the manifest from the furthest clip end reports 40s; playback
// still runs the full 44.5s, so the transport total must stay 44.5, not 40.
expect(
resolveTimelineTotalDuration({
manifestDurationSeconds: 40,
authoredRootDurationSeconds: 44.5,
}),
).toBe(44.5);
});

it("lets clips extend the total PAST the authored root (content can grow the timeline)", () => {
expect(
resolveTimelineTotalDuration({
manifestDurationSeconds: 50,
authoredRootDurationSeconds: 44.5,
}),
).toBe(50);
});

it("falls back to the manifest total when the root authors no duration", () => {
expect(
resolveTimelineTotalDuration({
manifestDurationSeconds: 40,
authoredRootDurationSeconds: 0,
}),
).toBe(40);
});

it("uses the authored root when the manifest is loop-inflated / non-finite", () => {
expect(
resolveTimelineTotalDuration({
manifestDurationSeconds: Number.POSITIVE_INFINITY,
authoredRootDurationSeconds: 44.5,
}),
).toBe(44.5);
expect(
resolveTimelineTotalDuration({
manifestDurationSeconds: 9000, // beyond the 7200s sanity cap
authoredRootDurationSeconds: 44.5,
}),
).toBe(44.5);
});

it("returns 0 when neither source yields a usable duration", () => {
expect(
resolveTimelineTotalDuration({
manifestDurationSeconds: Number.NaN,
authoredRootDurationSeconds: -1,
}),
).toBe(0);
});

it("floors the manifest at the authored root read from the fixture's DOM shape", () => {
// Reconstruct the fixture: root authored at 44.5s, last clip (v-letters)
// ends at 34 + 6 = 40s. readTimelineDurationFromDocument must report the
// authored 44.5, and flooring the 40s manifest total against it yields 44.5.
const doc = document.implementation.createHTMLDocument("fixture");
doc.body.innerHTML =
'<div id="main" data-composition-id="main" data-duration="44.5">' +
'<video class="clip" data-start="34" data-duration="6"></video>' +
"</div>";
const authoredRootDurationSeconds = readTimelineDurationFromDocument(doc);
expect(authoredRootDurationSeconds).toBe(44.5);
expect(
resolveTimelineTotalDuration({
manifestDurationSeconds: 1200 / 30, // durationInFrames measured from clip end
authoredRootDurationSeconds,
}),
).toBe(44.5);
});
});
Loading
Loading