From 75bee42919f802d05e06199791a0ead9a0402d84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 26 Oct 2026 22:54:21 +0000 Subject: [PATCH] fix(core): paused preview seeks land on the composition's own frame rate (#6460) * fix(core): preview seeks land on the composition's own frame rate * fix(studio): frame step, frame readout and ruler use the composition's frame rate * fix(studio): an end trim previews the clip's last frame at the composition's frame rate * refactor(studio): ruler labels follow the ticks' frame rate and fps parsing splits by form * style(studio): format the ruler label helper and its test * chore(inspector): raise the package size budget to the measured size plus headroom --- packages/core/src/core.types.ts | 135 +----------------- packages/core/src/fps.ts | 76 ++++++++++ packages/core/src/runtime/init.test.ts | 40 ++++++ packages/core/src/runtime/init.ts | 8 +- packages/core/src/runtime/protocol.test.ts | 7 + packages/core/src/runtime/protocol.ts | 3 +- .../studio/src/hooks/useGestureRecording.ts | 8 +- .../src/player/components/PlayerControls.tsx | 7 +- .../src/player/components/ShortcutsPanel.tsx | 6 +- .../src/player/components/Timeline.test.ts | 12 ++ .../player/components/TimelineProvider.tsx | 1 + .../src/player/components/TimelineRuler.tsx | 11 +- .../player/components/TimelineRulerPart.tsx | 1 + .../timelineClipDragPreview.test.ts | 10 ++ .../components/timelineClipDragPreview.ts | 9 +- .../components/timelineRulerGeometry.ts | 17 ++- .../player/components/useTimelineClipDrag.ts | 7 +- .../components/useTimelineProviderState.tsx | 3 +- .../src/player/components/useTimelineTicks.ts | 5 +- .../player/hooks/usePlaybackKeyboard.test.ts | 19 +++ .../src/player/hooks/usePlaybackKeyboard.ts | 7 +- packages/studio/src/player/lib/time.test.ts | 26 ++-- packages/studio/src/player/lib/time.ts | 8 +- .../studio/src/utils/linkedClipPreferences.ts | 2 + scripts/package-size-budgets.json | 2 +- 25 files changed, 249 insertions(+), 181 deletions(-) create mode 100644 packages/core/src/fps.ts diff --git a/packages/core/src/core.types.ts b/packages/core/src/core.types.ts index abd72dc81e..210b224384 100644 --- a/packages/core/src/core.types.ts +++ b/packages/core/src/core.types.ts @@ -2,139 +2,8 @@ export type ExecutionMode = "planning" | "design" | "execution" | null; -// ── Frame rate ────────────────────────────────────────────────────────────── - -/** - * Frame rate as an exact rational. Carrying `{num, den}` end-to-end (rather - * than collapsing to `29.97`) lets us pass NTSC / drop-frame rates straight - * through to FFmpeg via `-r 30000/1001` without any decimal round-trip. - * - * Integer fps is represented with `den: 1` (e.g. `{ num: 30, den: 1 }`). - * - * Use {@link fpsToNumber} when arithmetic forces a decimal (e.g. `setTimeout` - * intervals) and {@link fpsToFfmpegArg} when emitting FFmpeg `-r` / - * `-framerate` strings. - */ -export interface Fps { - num: number; - den: number; -} - -export type FpsInput = number | Fps; - -export function toFps(input: FpsInput): Fps { - if (typeof input === "number") { - return { num: input, den: 1 }; - } - return input; -} - -/** - * Decimal value of an {@link Fps} rational. Used at sites that need a - * `number` for arithmetic (frame-index → time, frame intervals, telemetry - * payloads) where the small precision loss of the decimal is acceptable. - */ -export function fpsToNumber(fps: Fps): number { - return fps.num / fps.den; -} - -/** - * FFmpeg-style fps argument. Returns `"30"` for integer fps and `"30000/1001"` - * for rationals — both forms are accepted verbatim by FFmpeg's `-r` and - * `-framerate` flags. We keep integer fps as a bare integer so existing - * snapshot tests / log output don't churn for the common case. - */ -export function fpsToFfmpegArg(fps: Fps): string { - return fps.den === 1 ? String(fps.num) : `${fps.num}/${fps.den}`; -} - -/** - * Discriminated parse result for {@link parseFps}. Lets the CLI / route - * validation own its own error UX without losing the structured failure - * reason. - */ -export type FpsParseResult = - | { ok: true; value: Fps } - | { - ok: false; - reason: - | "empty" - | "not-a-number" - | "non-positive" - | "out-of-range" - | "invalid-fraction" - | "ambiguous-decimal"; - }; - -/** - * Parse a user-supplied fps spec into an {@link Fps} rational. - * - * Accepted forms: - * - integer string `"30"` → `{ num: 30, den: 1 }` - * - integer number `30` → `{ num: 30, den: 1 }` - * - rational string `"30000/1001"` → `{ num: 30000, den: 1001 }` (exact NTSC) - * - * Rejected: - * - empty / non-numeric input - * - decimals like `"29.97"` — callers must spell rationals with `/` so the - * exact denominator is unambiguous (FFmpeg treats `29.97` as a slightly - * different framerate than `30000/1001`). - * - division by zero, negative or zero numerator - * - decimal value outside `[1, 240]` — defensive bounds for "human" fps - * ranges (24, 25, 30, 50, 60, 120, 240, plus the NTSC trio). - */ -export function parseFps(input: string | number): FpsParseResult { - if (typeof input === "number") { - if (!Number.isFinite(input)) return { ok: false, reason: "not-a-number" }; - if (!Number.isInteger(input)) return { ok: false, reason: "ambiguous-decimal" }; - if (input <= 0) return { ok: false, reason: "non-positive" }; - if (input > 240) return { ok: false, reason: "out-of-range" }; - return { ok: true, value: { num: input, den: 1 } }; - } - const raw = input.trim(); - if (raw === "") return { ok: false, reason: "empty" }; - - if (raw.includes("/")) { - const parts = raw.split("/"); - if (parts.length !== 2) return { ok: false, reason: "invalid-fraction" }; - const num = Number(parts[0]); - const den = Number(parts[1]); - if (!Number.isFinite(num) || !Number.isFinite(den)) { - return { ok: false, reason: "not-a-number" }; - } - if (!Number.isInteger(num) || !Number.isInteger(den)) { - return { ok: false, reason: "invalid-fraction" }; - } - if (den <= 0) return { ok: false, reason: "invalid-fraction" }; - if (num <= 0) return { ok: false, reason: "non-positive" }; - const decimal = num / den; - if (decimal < 1 || decimal > 240) return { ok: false, reason: "out-of-range" }; - return { ok: true, value: { num, den } }; - } - - // Integer-only path — reject `"29.97"` so users are explicit about the - // exact rational they want. - if (!/^-?\d+$/.test(raw)) { - // Allow caller to differentiate "29.97" from "abc" if they want; both - // are user errors but the message can be friendlier for decimals. - if (/^-?\d*\.\d+$/.test(raw)) return { ok: false, reason: "ambiguous-decimal" }; - return { ok: false, reason: "not-a-number" }; - } - const n = Number(raw); - if (n <= 0) return { ok: false, reason: "non-positive" }; - if (n > 240) return { ok: false, reason: "out-of-range" }; - return { ok: true, value: { num: n, den: 1 } }; -} - -/** - * Convenience wrapper around {@link parseFps} for callsites that want the - * default-30-fps fallback when input is `undefined`. Does NOT swallow parse - * errors — those still surface via the discriminated result. - */ -export function parseFpsWithDefault(input: string | number | undefined): FpsParseResult { - if (input === undefined || input === "") return { ok: true, value: { num: 30, den: 1 } }; - return parseFps(input); -} +export type { Fps, FpsInput, FpsParseResult } from "./fps"; +export { toFps, fpsToNumber, fpsToFfmpegArg, parseFps, parseFpsWithDefault } from "./fps"; /** Video orientation / aspect ratio. */ export type Orientation = "16:9" | "9:16"; diff --git a/packages/core/src/fps.ts b/packages/core/src/fps.ts new file mode 100644 index 0000000000..55855266ab --- /dev/null +++ b/packages/core/src/fps.ts @@ -0,0 +1,76 @@ +// Exact rational, so NTSC rates reach FFmpeg as `30000/1001` with no decimal round trip. +export interface Fps { + num: number; + den: number; +} + +export type FpsInput = number | Fps; + +export function toFps(input: FpsInput): Fps { + if (typeof input === "number") { + return { num: input, den: 1 }; + } + return input; +} + +export function fpsToNumber(fps: Fps): number { + return fps.num / fps.den; +} + +export function fpsToFfmpegArg(fps: Fps): string { + return fps.den === 1 ? String(fps.num) : `${fps.num}/${fps.den}`; +} + +export type FpsParseResult = + | { ok: true; value: Fps } + | { + ok: false; + reason: + | "empty" + | "not-a-number" + | "non-positive" + | "out-of-range" + | "invalid-fraction" + | "ambiguous-decimal"; + }; + +type FpsFailure = Extract["reason"]; +const fail = (reason: FpsFailure): FpsParseResult => ({ ok: false, reason }); + +function rangedFps(num: number, den: number): FpsParseResult { + if (num <= 0) return fail("non-positive"); + const decimal = num / den; + if (decimal < 1 || decimal > 240) return fail("out-of-range"); + return { ok: true, value: { num, den } }; +} + +function numericFps(input: number): FpsParseResult { + if (!Number.isFinite(input)) return fail("not-a-number"); + if (!Number.isInteger(input)) return fail("ambiguous-decimal"); + return rangedFps(input, 1); +} + +function fractionFps(raw: string): FpsParseResult { + const parts = raw.split("/"); + if (parts.length !== 2) return fail("invalid-fraction"); + const num = Number(parts[0]); + const den = Number(parts[1]); + if (!Number.isFinite(num) || !Number.isFinite(den)) return fail("not-a-number"); + if (!Number.isInteger(num) || !Number.isInteger(den) || den <= 0) return fail("invalid-fraction"); + return rangedFps(num, den); +} + +// Decimals like "29.97" are rejected: FFmpeg reads them as a different rate than 30000/1001. +export function parseFps(input: string | number): FpsParseResult { + if (typeof input === "number") return numericFps(input); + const raw = input.trim(); + if (raw === "") return fail("empty"); + if (raw.includes("/")) return fractionFps(raw); + if (/^-?\d+$/.test(raw)) return rangedFps(Number(raw), 1); + return fail(/^-?\d*\.\d+$/.test(raw) ? "ambiguous-decimal" : "not-a-number"); +} + +export function parseFpsWithDefault(input: string | number | undefined): FpsParseResult { + if (input === undefined || input === "") return { ok: true, value: { num: 30, den: 1 } }; + return parseFps(input); +} diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 12baf9b877..8423788bdc 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -9,6 +9,7 @@ import { SKIPPED_CLIP, } from "./timedClipHide"; import { applyAuthoredTiming, STUDIO_PREVIEW_MARK_META } from "../studioPreviewMark"; +import { runtimeProtocolMetadata } from "./protocol"; import { collectRuntimeTimelinePayload } from "./timeline"; import { TYPEGPU_PRESENT_HEARTBEAT_MS } from "./adapters/typegpu"; import { WebAudioTransport } from "./webAudioTransport"; @@ -1432,6 +1433,45 @@ describe("initSandboxRuntimeModular", () => { }); }); + describe("a preview seek lands on the composition's own frame grid", () => { + // Frame 7 on a 30 fps grid would be 7/30: earlier than frame 7 at 25 and 29.97 fps. + const rates = [ + { dataFps: "25", fps: 25 }, + { dataFps: "30000/1001", fps: 30_000 / 1_001 }, + ]; + + function previewTimeline(dataFps: string): RuntimeTimelineLike { + document.body.innerHTML = `
`; + const timeline = createMockTimeline(20); + window.__timelines = { main: timeline }; + initSandboxRuntimeModular(); + return timeline; + } + + it.each(rates)("player.seek to frame 7 at $dataFps fps", ({ dataFps, fps }) => { + const timeline = previewTimeline(dataFps); + window.__player?.seek(7 / fps); + expect(timeline.time()).toBeCloseTo(7 / fps, 9); + }); + + it.each(rates)("a parent frame seek to frame 7 at $dataFps fps", ({ dataFps, fps }) => { + const timeline = previewTimeline(dataFps); + window.dispatchEvent( + new MessageEvent("message", { + source: window.parent, + data: { + source: "hf-parent", + type: "control", + action: "seek", + frame: 7, + ...runtimeProtocolMetadata(fps), + }, + }), + ); + expect(timeline.time()).toBeCloseTo(7 / fps, 9); + }); + }); + it("uses live child timeline duration when a composition host has no authored duration", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index b8fe396ff3..4ad661ba07 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -191,6 +191,7 @@ import type { SceneAnimation, } from "./types"; import type { PlayerAPI } from "../core.types"; +import { parseFps } from "../fps"; import { recordStartupError, SCENE_CALLBACK_ERROR_PREFIX, swallow } from "./diagnostics"; import { readTimelineDurationSeconds } from "./timelineDuration"; import { @@ -289,6 +290,11 @@ function resolveExportRenderFps(): ExportRenderFpsResolution { }; } +function compositionFps(): number | null { + const declared = parseFps(findRootCompositionElement()?.getAttribute("data-fps") ?? ""); + return declared.ok ? declared.value.num / declared.value.den : null; +} + function samePromiseSet(a: PromiseLike[], b: PromiseLike[] | null): boolean { return b !== null && a.length === b.length && a.every((p, i) => p === b[i]); } @@ -538,7 +544,7 @@ export function initSandboxRuntimeModular(): void { // captured frame ever sees the group as a layout item. ensureAudioGroupInertStyle(document); const exportRenderFps = resolveExportRenderFps(); - state.canonicalFps = exportRenderFps.fps ?? state.canonicalFps; + state.canonicalFps = exportRenderFps.fps ?? compositionFps() ?? state.canonicalFps; setRuntimeProtocolFps(state.canonicalFps); if (window.__HF_EXPORT_RENDER_SEEK_CONFIG) { const fpsDetail = JSON.stringify({ diff --git a/packages/core/src/runtime/protocol.test.ts b/packages/core/src/runtime/protocol.test.ts index 35790b9765..fc9c06f2a9 100644 --- a/packages/core/src/runtime/protocol.test.ts +++ b/packages/core/src/runtime/protocol.test.ts @@ -14,6 +14,13 @@ describe("runtime protocol", () => { }, ); + it.each([24_000, 30_000, 60_000])("states %s/1001 fps exactly", (numerator) => { + expect(runtimeProtocolFpsFromNumber(numerator / 1_001)).toEqual({ + numerator, + denominator: 1_001, + }); + }); + it("accepts protocol v1 metadata and exposes its fps", () => { expect(inspectRuntimeProtocol(runtimeProtocolMetadata(60))).toMatchObject({ status: "supported", diff --git a/packages/core/src/runtime/protocol.ts b/packages/core/src/runtime/protocol.ts index 71ed37cd43..a9ddc2d3cc 100644 --- a/packages/core/src/runtime/protocol.ts +++ b/packages/core/src/runtime/protocol.ts @@ -44,7 +44,8 @@ function greatestCommonDivisor(a: number, b: number): number { export function runtimeProtocolFpsFromNumber(value: number): RuntimeProtocolFps { const safe = Number.isFinite(value) && value > 0 ? value : 30; - const denominator = Number.isInteger(safe) ? 1 : 1_000_000; + const isNtscRate = Math.abs(safe * 1001 - Math.round(safe * 1001)) < 1e-6; + const denominator = Number.isInteger(safe) ? 1 : isNtscRate ? 1001 : 1_000_000; const numerator = Math.round(safe * denominator); const divisor = greatestCommonDivisor(numerator, denominator); return { numerator: numerator / divisor, denominator: denominator / divisor }; diff --git a/packages/studio/src/hooks/useGestureRecording.ts b/packages/studio/src/hooks/useGestureRecording.ts index e3251ccd58..8248b0fd2d 100644 --- a/packages/studio/src/hooks/useGestureRecording.ts +++ b/packages/studio/src/hooks/useGestureRecording.ts @@ -2,6 +2,7 @@ import { revealClip } from "@hyperframes/core/runtime/clip-reveal"; import { useCallback, useEffect, useRef, useState } from "react"; import { usePlayerStore, liveTime } from "../player/store/playerStore"; import { frameToSeconds, secondsToFrame } from "../player/lib/time"; +import { useLinkedClipPreferences } from "../utils/linkedClipPreferences"; export interface GestureSample { time: number; @@ -141,12 +142,13 @@ function recordSample(r: RecordingRefs, time: number, properties: Record useLinkedClipPreferences.getState().compositionFps; + export const PlayerControls = memo(function PlayerControls({ onTogglePlay, onSeek, @@ -202,7 +205,7 @@ export const PlayerControls = memo(function PlayerControls({ if (!timeDisplayRef.current) return; const t = currentTimeRef.current; timeDisplayRef.current.textContent = - timeDisplayMode === "frame" ? formatFrameTime(t, duration) : formatTime(t); + timeDisplayMode === "frame" ? formatFrameTime(t, duration, compositionFps()) : formatTime(t); }, [duration, timeDisplayMode]); useMountEffect(() => { @@ -212,7 +215,7 @@ export const PlayerControls = memo(function PlayerControls({ const currentDuration = durationRef.current; const text = timeDisplayModeRef.current === "frame" - ? formatFrameTime(time, currentDuration) + ? formatFrameTime(time, currentDuration, compositionFps()) : formatTime(time); if (timeDisplayRef.current.textContent !== text) timeDisplayRef.current.textContent = text; }; diff --git a/packages/studio/src/player/components/ShortcutsPanel.tsx b/packages/studio/src/player/components/ShortcutsPanel.tsx index 767c30cc25..6520129822 100644 --- a/packages/studio/src/player/components/ShortcutsPanel.tsx +++ b/packages/studio/src/player/components/ShortcutsPanel.tsx @@ -3,6 +3,7 @@ import { formatTime, frameToSeconds } from "../lib/time"; import { Popover, Tooltip } from "../../components/ui"; import { flatActive, flatIdle } from "../../components/timelineToolbarStyles"; import { usePlayerStore } from "../store/playerStore"; +import { useCompositionFps } from "../../utils/linkedClipPreferences"; import { DEFAULT_SHORTCUT_SECTIONS, type ShortcutSection } from "./studioShortcuts"; interface ShortcutsPanelProps { @@ -31,6 +32,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({ }: ShortcutsPanelProps) { const [showShortcuts, setShowShortcuts] = useState(false); const [jumpFrame, setJumpFrame] = useState(""); + const compositionFps = useCompositionFps(); // Focus lands on the scrolling body, not the frame input, so keyboard users can scroll and read it. const panelBodyRef = useRef(null); @@ -38,8 +40,8 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({ if (disabled) return; const frame = Number.parseInt(jumpFrame, 10); if (!Number.isFinite(frame) || duration <= 0) return; - onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame)))); - }, [disabled, duration, jumpFrame, onSeek]); + onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame), compositionFps))); + }, [compositionFps, disabled, duration, jumpFrame, onSeek]); const handleJumpSubmit = useCallback( (e: React.FormEvent) => { diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 6c532c6b25..cf79524e23 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -4,6 +4,7 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach } from "vitest"; import { describe, it, expect, vi } from "vitest"; +import { rulerTickText } from "./timelineRulerGeometry"; import { Timeline, formatTimelineTickLabel, @@ -1367,3 +1368,14 @@ describe("resolveTimelineAssetDrop", () => { ).toEqual({ start: 1.18, track: 8 }); }); }); + +describe("rulerTickText", () => { + it("counts frames at the rate the ticks were laid out at", () => { + expect(rulerTickText(2, 25, 10, 1)).toBe(50); + expect(rulerTickText(2, 30_000 / 1_001, 10, 1)).toBe(60); + }); + + it("shows time when the ruler is not counting frames", () => { + expect(rulerTickText(2, undefined, 10, 1)).toBe("00:02"); + }); +}); diff --git a/packages/studio/src/player/components/TimelineProvider.tsx b/packages/studio/src/player/components/TimelineProvider.tsx index afb6c9ea9a..03f7776a23 100644 --- a/packages/studio/src/player/components/TimelineProvider.tsx +++ b/packages/studio/src/player/components/TimelineProvider.tsx @@ -48,6 +48,7 @@ export type TimelineCanvasState = Omit< totalH: number; effectiveDuration: number; majorTickInterval: number; + tickFrameRate: number | undefined; rangeSelection: TimelineRangeSelection | null; marqueeRect: Rect | null; resizingClip: ResizingClipState | null; diff --git a/packages/studio/src/player/components/TimelineRuler.tsx b/packages/studio/src/player/components/TimelineRuler.tsx index 5d944c5c8d..d7044f2f2a 100644 --- a/packages/studio/src/player/components/TimelineRuler.tsx +++ b/packages/studio/src/player/components/TimelineRuler.tsx @@ -2,9 +2,7 @@ import { memo, type RefObject } from "react"; import { useSettledScrollLeft } from "./useSettledScrollLeft"; import type { TimelineTheme } from "./timelineTheme"; import { RULER_H, getTimelineBeatEntries } from "./timelineLayout"; -import { formatTimelineTickLabel } from "./timelineRulerGeometry"; -import { usePlayerStore } from "../store/playerStore"; -import { secondsToFrame } from "../lib/time"; +import { rulerTickText } from "./timelineRulerGeometry"; import type { MusicBeatAnalysis } from "@hyperframes/core/beats"; import type { TimelineTimeRange } from "../lib/timelineClipIndex"; @@ -16,6 +14,7 @@ interface TimelineRulerProps { totalH: number; effectiveDuration: number; majorTickInterval: number; + tickFrameRate?: number; theme: TimelineTheme; beatAnalysis?: MusicBeatAnalysis | null; contentOrigin: number; @@ -84,13 +83,13 @@ export const TimelineRuler = memo(function TimelineRuler({ totalH, effectiveDuration, majorTickInterval, + tickFrameRate, theme, beatAnalysis, contentOrigin, renderTimeRange, scrollRef, }: TimelineRulerProps) { - const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode); const beatTimes = beatAnalysis?.beatTimes ?? []; const beatStrengths = beatAnalysis?.beatStrengths ?? []; const beatEntries = getTimelineBeatEntries(beatTimes, beatStrengths, renderTimeRange); @@ -194,9 +193,7 @@ export const TimelineRuler = memo(function TimelineRuler({ fontSize: 10, }} > - {timeDisplayMode === "frame" - ? secondsToFrame(t) - : formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)} + {rulerTickText(t, tickFrameRate, effectiveDuration, majorTickInterval)}
diff --git a/packages/studio/src/player/components/TimelineRulerPart.tsx b/packages/studio/src/player/components/TimelineRulerPart.tsx index 9a6b4d40d2..0ac4e70895 100644 --- a/packages/studio/src/player/components/TimelineRulerPart.tsx +++ b/packages/studio/src/player/components/TimelineRulerPart.tsx @@ -13,6 +13,7 @@ export function TimelineRulerPart() { totalH={props.totalH} effectiveDuration={props.effectiveDuration} majorTickInterval={props.majorTickInterval} + tickFrameRate={props.tickFrameRate} theme={props.theme} beatAnalysis={props.beatAnalysis} contentOrigin={props.contentOrigin} diff --git a/packages/studio/src/player/components/timelineClipDragPreview.test.ts b/packages/studio/src/player/components/timelineClipDragPreview.test.ts index 63ac78380a..18fbd89052 100644 --- a/packages/studio/src/player/components/timelineClipDragPreview.test.ts +++ b/packages/studio/src/player/components/timelineClipDragPreview.test.ts @@ -5,8 +5,10 @@ import { computeResizePreview, guideIfSaved, getTimelineDragOverlayPosition, + trimPreviewTime, type DragPreviewContext, } from "./timelineClipDragPreview"; +import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences"; import type { DraggedClipState } from "./timelineClipDragTypes"; import { commitDraggedClipMove, @@ -937,3 +939,11 @@ describe("an audio clip stays inside its partner video", () => { expect(trim("end", 50, ["v"]).previewDuration).toBe(58); }); }); + +describe("trimPreviewTime", () => { + it.each([25, 60])("shows the last frame of the clip at %s fps", (fps) => { + useLinkedClipPreferences.getState().setCompositionFps(fps); + expect(trimPreviewTime("end", 1, 2)).toBeCloseTo(3 - 1 / fps, 9); + useLinkedClipPreferences.getState().setCompositionFps(30); + }); +}); diff --git a/packages/studio/src/player/components/timelineClipDragPreview.ts b/packages/studio/src/player/components/timelineClipDragPreview.ts index 1ac562bef7..6129983070 100644 --- a/packages/studio/src/player/components/timelineClipDragPreview.ts +++ b/packages/studio/src/player/components/timelineClipDragPreview.ts @@ -27,7 +27,7 @@ import { } from "./timelineGroupEditing"; import { groupMoveFloor, resolveGroupMovers } from "./timelineMultiDragPreview"; import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes"; -import { STUDIO_PREVIEW_FPS } from "../lib/time"; +import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences"; import { heldAudioShiftRange, heldPartnerVideoBounds } from "./audioClipLink"; /** Snap-target builder closure supplied by the hook (closes over refs + store). */ @@ -306,12 +306,11 @@ export function guideIfSaved( return off * pps < 1 ? target : null; } -/** One frame: the last visible frame of a clip sits just before its end time. */ -const TRIM_END_FRAME_LEAD_S = 1 / STUDIO_PREVIEW_FPS; - /** The composition time whose frame a trim shows: the edge being dragged. */ export function trimPreviewTime(edge: "start" | "end", start: number, duration: number): number { - return edge === "start" ? start : Math.max(start, start + duration - TRIM_END_FRAME_LEAD_S); + // One frame back: the last visible frame of a clip sits just before its end time. + const frame = 1 / useLinkedClipPreferences.getState().compositionFps; + return edge === "start" ? start : Math.max(start, start + duration - frame); } export interface ResizePreviewContext { diff --git a/packages/studio/src/player/components/timelineRulerGeometry.ts b/packages/studio/src/player/components/timelineRulerGeometry.ts index 0e819a8e0b..a2feeb19f2 100644 --- a/packages/studio/src/player/components/timelineRulerGeometry.ts +++ b/packages/studio/src/player/components/timelineRulerGeometry.ts @@ -1,4 +1,4 @@ -import { formatTime, STUDIO_PREVIEW_FPS } from "../lib/time"; +import { formatTime, secondsToFrame } from "../lib/time"; import type { TimelineTimeDisplayMode } from "../../utils/studioUiPreferences"; import type { TimelineTimeRange } from "../lib/timelineClipIndex"; @@ -94,8 +94,8 @@ function tickSpacing(duration: number, pixelsPerSecond?: number, frameRate?: num return { majorInterval, subdivisions }; } -export function rulerFrameRate(mode: TimelineTimeDisplayMode): number | undefined { - return mode === "frame" ? STUDIO_PREVIEW_FPS : undefined; +export function rulerFrameRate(mode: TimelineTimeDisplayMode, fps: number): number | undefined { + return mode === "frame" ? fps : undefined; } /** Seconds between neighbouring ruler lines (minor ticks when drawn, else major): the snap grid. */ @@ -161,3 +161,14 @@ export function formatTimelineTickLabel( } return formatTime(safeTime); } + +export function rulerTickText( + time: number, + frameRate: number | undefined, + duration: number, + majorInterval: number, +): string | number { + return frameRate + ? secondsToFrame(time, frameRate) + : formatTimelineTickLabel(time, duration, majorInterval); +} diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index c6ff55df72..525b629db5 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -31,7 +31,7 @@ import type { } from "./timelineClipDragTypes"; import { getTimelineElementIndexes } from "../lib/timelineElementIndexes"; import { dropMisalignedTrimPartners, linkedGestureKeys } from "./audioClipLink"; -import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences"; +import { isLinkedSelectionOn, useLinkedClipPreferences } from "../../utils/linkedClipPreferences"; import { useTimelineClipCapabilities } from "./timelineReadOnly"; import { timelineClipFocusId } from "./timelineNavigationIdentity"; import { @@ -184,7 +184,10 @@ export function useTimelineClipDrag({ // The ruler's line spacing at the current zoom; 0 with the magnet off, like the targets. const snapGridStep = useCallback(() => { if (!snapContextRef.current.enabled) return 0; - const frameRate = rulerFrameRate(usePlayerStore.getState().timeDisplayMode); + const frameRate = rulerFrameRate( + usePlayerStore.getState().timeDisplayMode, + useLinkedClipPreferences.getState().compositionFps, + ); return getTimelineGridStep(durationRef.current, ppsRef.current, frameRate); }, [durationRef, ppsRef]); diff --git a/packages/studio/src/player/components/useTimelineProviderState.tsx b/packages/studio/src/player/components/useTimelineProviderState.tsx index 4552b52615..0afae23688 100644 --- a/packages/studio/src/player/components/useTimelineProviderState.tsx +++ b/packages/studio/src/player/components/useTimelineProviderState.tsx @@ -444,7 +444,7 @@ export function useTimelineProviderState({ dragActive: draggedClip?.started === true || resizingClip != null, displayDuration, }); - const { major, minor, majorTickInterval } = useTimelineTicks( + const { major, minor, majorTickInterval, frameRate } = useTimelineTicks( displayDuration, pps, timeDisplayMode, @@ -460,6 +460,7 @@ export function useTimelineProviderState({ totalH: displayLayout.totalH, effectiveDuration, majorTickInterval, + tickFrameRate: frameRate, rangeSelection, marqueeRect, laneGapStrips, diff --git a/packages/studio/src/player/components/useTimelineTicks.ts b/packages/studio/src/player/components/useTimelineTicks.ts index 8129929244..9c4fdcce7b 100644 --- a/packages/studio/src/player/components/useTimelineTicks.ts +++ b/packages/studio/src/player/components/useTimelineTicks.ts @@ -1,5 +1,6 @@ import { useMemo } from "react"; import type { TimelineTimeDisplayMode } from "../../utils/studioUiPreferences"; +import { useCompositionFps } from "../../utils/linkedClipPreferences"; import type { TimelineTimeRange } from "../lib/timelineClipIndex"; import { generateTicks, @@ -13,13 +14,15 @@ export function useTimelineTicks( timeDisplayMode: TimelineTimeDisplayMode, renderTimeRange?: TimelineTimeRange, ) { - const frameRate = rulerFrameRate(timeDisplayMode); + const compositionFps = useCompositionFps(); + const frameRate = rulerFrameRate(timeDisplayMode, compositionFps); const ticks = useMemo( () => generateTicks(duration, pixelsPerSecond, frameRate, renderTimeRange), [duration, frameRate, pixelsPerSecond, renderTimeRange], ); return { ...ticks, + frameRate, majorTickInterval: getTimelineMajorTickInterval(duration, pixelsPerSecond, frameRate), }; } diff --git a/packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts b/packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts index 023c292970..cab6934094 100644 --- a/packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts +++ b/packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts @@ -6,12 +6,14 @@ import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; import { usePlaybackKeyboard } from "./usePlaybackKeyboard"; import { usePlayerStore } from "../store/playerStore"; +import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences"; globalThis.IS_REACT_ACT_ENVIRONMENT = true; afterEach(() => { document.body.innerHTML = ""; usePlayerStore.getState().reset(); + useLinkedClipPreferences.getState().setCompositionFps(30); }); interface Spies { @@ -304,3 +306,20 @@ describe("usePlaybackKeyboard — a focused native player owns its keys", () => expect(event.defaultPrevented).toBe(true); }); }); + +describe("usePlaybackKeyboard — frame step", () => { + it.each([ + [25, 3], + [30_000 / 1_001, 3], + ])("ArrowRight at %s fps steps one frame of the composition", (fps, frame) => { + const { dispatch, spies } = setupHook(); + useLinkedClipPreferences.getState().setCompositionFps(fps); + usePlayerStore.setState({ currentTime: frame / fps }); + + act(() => { + dispatch(keydown({ code: "ArrowRight", key: "ArrowRight" })); + }); + + expect(spies.seek).toHaveBeenCalledWith((frame + 1) / fps); + }); +}); diff --git a/packages/studio/src/player/hooks/usePlaybackKeyboard.ts b/packages/studio/src/player/hooks/usePlaybackKeyboard.ts index 17394ecb6d..1fea6fa86a 100644 --- a/packages/studio/src/player/hooks/usePlaybackKeyboard.ts +++ b/packages/studio/src/player/hooks/usePlaybackKeyboard.ts @@ -11,7 +11,8 @@ import { useCaptionStore } from "../../captions/store"; import { shouldIgnorePlaybackShortcutEvent, SHUTTLE_SPEEDS } from "../lib/playbackShortcuts"; import { canvasNudgeKeysClaimed } from "../../utils/canvasNudgeGate"; import { usePlayerStore } from "../store/playerStore"; -import { stepFrameTime, STUDIO_PREVIEW_FPS } from "../lib/time"; +import { stepFrameTime } from "../lib/time"; +import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences"; import type { PlaybackAdapter } from "../lib/playbackTypes"; interface UsePlaybackKeyboardParams { @@ -53,7 +54,9 @@ export function usePlaybackKeyboard({ (deltaFrames: number) => { const adapter = getAdapter(); const currentTime = adapter?.getTime() ?? usePlayerStore.getState().currentTime; - seek(stepFrameTime(currentTime, deltaFrames, STUDIO_PREVIEW_FPS)); + seek( + stepFrameTime(currentTime, deltaFrames, useLinkedClipPreferences.getState().compositionFps), + ); }, [getAdapter, seek], ); diff --git a/packages/studio/src/player/lib/time.test.ts b/packages/studio/src/player/lib/time.test.ts index ac319177b0..c86ed7fece 100644 --- a/packages/studio/src/player/lib/time.test.ts +++ b/packages/studio/src/player/lib/time.test.ts @@ -58,29 +58,29 @@ describe("formatTime", () => { }); describe("frame helpers", () => { - it("converts seconds to frames at the Studio preview rate", () => { - expect(secondsToFrame(0)).toBe(0); - expect(secondsToFrame(1)).toBe(30); - expect(secondsToFrame(1.5)).toBe(45); + it("converts seconds to frames at 30 fps", () => { + expect(secondsToFrame(0, 30)).toBe(0); + expect(secondsToFrame(1, 30)).toBe(30); + expect(secondsToFrame(1.5, 30)).toBe(45); }); - it("converts frames to seconds at the Studio preview rate", () => { - expect(frameToSeconds(0)).toBe(0); - expect(frameToSeconds(30)).toBe(1); - expect(frameToSeconds(45)).toBe(1.5); + it("converts frames to seconds at 30 fps", () => { + expect(frameToSeconds(0, 30)).toBe(0); + expect(frameToSeconds(30, 30)).toBe(1); + expect(frameToSeconds(45, 30)).toBe(1.5); }); it("formats current and total frame display", () => { - expect(formatFrameTime(1, 5)).toBe("30f / 150f"); + expect(formatFrameTime(1, 5, 30)).toBe("30f / 150f"); }); it("steps from a truncated runtime time by integer frame index", () => { - expect(stepFrameTime(0.0333333, 1)).toBe(2 / 30); - expect(stepFrameTime(0.0666666, 1)).toBe(3 / 30); - expect(stepFrameTime(0.0666666, -1)).toBe(1 / 30); + expect(stepFrameTime(0.0333333, 1, 30)).toBe(2 / 30); + expect(stepFrameTime(0.0666666, 1, 30)).toBe(3 / 30); + expect(stepFrameTime(0.0666666, -1, 30)).toBe(1 / 30); }); it("clamps frame stepping at zero", () => { - expect(stepFrameTime(0, -1)).toBe(0); + expect(stepFrameTime(0, -1, 30)).toBe(0); }); }); diff --git a/packages/studio/src/player/lib/time.ts b/packages/studio/src/player/lib/time.ts index 87febe432a..44f648d866 100644 --- a/packages/studio/src/player/lib/time.ts +++ b/packages/studio/src/player/lib/time.ts @@ -14,25 +14,25 @@ export function formatTime(time: number): string { return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; } -export function secondsToFrame(time: number, fps = STUDIO_PREVIEW_FPS): number { +export function secondsToFrame(time: number, fps: number): number { if (!Number.isFinite(time) || time <= 0) return 0; if (!Number.isFinite(fps) || fps <= 0) return 0; return Math.round(time * fps); } -export function frameToSeconds(frame: number, fps = STUDIO_PREVIEW_FPS): number { +export function frameToSeconds(frame: number, fps: number): number { if (!Number.isFinite(frame) || frame <= 0) return 0; if (!Number.isFinite(fps) || fps <= 0) return 0; return frame / fps; } -export function stepFrameTime(time: number, deltaFrames: number, fps = STUDIO_PREVIEW_FPS): number { +export function stepFrameTime(time: number, deltaFrames: number, fps: number): number { const currentFrame = secondsToFrame(time, fps); const nextFrame = Math.max(0, currentFrame + deltaFrames); return frameToSeconds(nextFrame, fps); } -export function formatFrameTime(time: number, duration: number, fps = STUDIO_PREVIEW_FPS): string { +export function formatFrameTime(time: number, duration: number, fps: number): string { const currentFrame = secondsToFrame(time, fps); const totalFrames = secondsToFrame(duration, fps); return `${currentFrame}f / ${totalFrames}f`; diff --git a/packages/studio/src/utils/linkedClipPreferences.ts b/packages/studio/src/utils/linkedClipPreferences.ts index acb8b99a7c..5ef97e76f1 100644 --- a/packages/studio/src/utils/linkedClipPreferences.ts +++ b/packages/studio/src/utils/linkedClipPreferences.ts @@ -30,5 +30,7 @@ export const useLinkedClipPreferences = create((set) => ( }, })); +export const useCompositionFps = (): number => useLinkedClipPreferences((s) => s.compositionFps); + export const isLinkedSelectionOn = (): boolean => useLinkedClipPreferences.getState().linkedSelection; diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index 5285327691..2e3d2b704a 100644 --- a/scripts/package-size-budgets.json +++ b/scripts/package-size-budgets.json @@ -20,7 +20,7 @@ "unpacked": 192672 }, "@hyperframes/inspector": { - "packed": 808905, + "packed": 858046, "unpacked": 3645432 }, "@hyperframes/lint": {