mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"unpacked": 192672
|
||||
},
|
||||
"@hyperframes/inspector": {
|
||||
"packed": 808905,
|
||||
"packed": 858046,
|
||||
"unpacked": 3645432
|
||||
},
|
||||
"@hyperframes/lint": {
|
||||
|
||||
Reference in New Issue
Block a user