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
This commit is contained in:
Miguel Ángel
2026-10-26 22:54:21 +00:00
committed by GitHub
parent 6b7d6dfb2e
commit 75bee42919
25 changed files with 249 additions and 181 deletions
+2 -133
View File
@@ -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";
+76
View File
@@ -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<FpsParseResult, { ok: false }>["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);
}
+40
View File
@@ -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 = `<div data-composition-id="main" data-root="true" data-start="0" data-duration="20" data-fps="${dataFps}" data-width="1920" data-height="1080"></div>`;
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");
+7 -1
View File
@@ -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<unknown>[], b: PromiseLike<unknown>[] | 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({
@@ -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",
+2 -1
View File
@@ -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 };
@@ -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<string,
// stripStudioEditsFromTarget in studio-api), so the keyframes must already
// include it. Subtracting it made the committed gesture play shoved off by the
// offset (the offset was removed twice).
const frame = secondsToFrame(time);
const sample = { time: frameToSeconds(frame), properties: { ...properties } };
const fps = useLinkedClipPreferences.getState().compositionFps;
const frame = secondsToFrame(time, fps);
const sample = { time: frameToSeconds(frame, fps), properties: { ...properties } };
const lastIndex = r.samples.length - 1;
const lastSample = r.samples[lastIndex];
// Gesture events follow the display refresh rate (often 60/120Hz), but the
// Studio preview is authored at 30fps. Keep the latest pointer state for each
// composition has its own frame rate. Keep the latest pointer state for each
// output frame so recording cannot create visually indistinguishable piles.
if (lastSample?.time === sample.time) {
r.samples[lastIndex] = sample;
@@ -6,6 +6,7 @@ import { liveTime, usePlayerStore } from "../store/playerStore";
import { trackStudioEvent } from "../../utils/studioTelemetry";
import { Tooltip } from "../../components/ui";
import { useMountEffect } from "../../hooks/useMountEffect";
import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
import { ShortcutsButton } from "./ShortcutsPanel";
import type { ShortcutSection } from "./studioShortcuts";
import { SpeedMenu } from "./SpeedMenu";
@@ -167,6 +168,8 @@ interface PlayerControlsProps {
shortcutSections?: readonly ShortcutSection[];
}
const compositionFps = () => 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;
};
@@ -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<HTMLDivElement>(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) => {
@@ -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");
});
});
@@ -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;
@@ -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)}
</span>
<div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
</div>
@@ -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}
@@ -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);
});
});
@@ -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 {
@@ -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);
}
@@ -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]);
@@ -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,
@@ -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),
};
}
@@ -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);
});
});
@@ -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],
);
+13 -13
View File
@@ -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);
});
});
+4 -4
View File
@@ -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`;
@@ -30,5 +30,7 @@ export const useLinkedClipPreferences = create<LinkedClipPreferences>((set) => (
},
}));
export const useCompositionFps = (): number => useLinkedClipPreferences((s) => s.compositionFps);
export const isLinkedSelectionOn = (): boolean =>
useLinkedClipPreferences.getState().linkedSelection;
+1 -1
View File
@@ -20,7 +20,7 @@
"unpacked": 192672
},
"@hyperframes/inspector": {
"packed": 808905,
"packed": 858046,
"unpacked": 3645432
},
"@hyperframes/lint": {