fix(studio): resolve recent timeline regressions

This commit is contained in:
Miguel Ángel
2026-07-01 03:18:45 +00:00
parent 602590b44d
commit 9b311588be
11 changed files with 339 additions and 13 deletions
+3 -2
View File
@@ -228,6 +228,9 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
}
if (event.key === "s" && !event.altKey) {
// Reserve bare `s` for Split even when the current selection cannot split,
// so secondary listeners do not reinterpret the same key as Snap toggle.
event.preventDefault();
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
if (selectedElementId) {
const el = elements.find((e) => (e.key ?? e.id) === selectedElementId);
@@ -237,7 +240,6 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
currentTime > el.start &&
currentTime < el.start + el.duration
) {
event.preventDefault();
void cb.handleTimelineElementSplit(el, currentTime);
return;
}
@@ -245,7 +247,6 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
// that isn't in the raw `elements` list, so the s-key can't resolve them.
// Nudge toward the razor tool instead of failing silently.
if (!el && selectedElementId.includes("#")) {
event.preventDefault();
cb.showToast("Use the razor tool (B) to split clips inside a sub-composition", "info");
return;
}
@@ -1,5 +1,18 @@
import { describe, expect, it } from "vitest";
// @vitest-environment happy-dom
import React, { act, isValidElement, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { AudioWaveform } from "../player/components/AudioWaveform";
import type { TimelineElement } from "../player/store/playerStore";
import { normalizeCompositionSrc } from "./useRenderClipContent";
import { useRenderClipContent } from "./useRenderClipContent";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
});
describe("normalizeCompositionSrc", () => {
const origin = "http://localhost:5190";
@@ -48,3 +61,43 @@ describe("normalizeCompositionSrc", () => {
expect(result).toBe("compositions/scenes/hero.html");
});
});
describe("useRenderClipContent", () => {
function renderClipContent(el: TimelineElement): ReactNode {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
let content: ReactNode = null;
function Harness() {
const render = useRenderClipContent({
projectIdRef: { current: "my-project" },
compIdToSrc: new Map(),
activePreviewUrl: "/api/projects/my-project/preview",
effectiveTimelineDuration: 12,
});
content = render(el, { clip: "#222", label: "#fff" });
return null;
}
act(() => {
root.render(React.createElement(Harness));
});
act(() => root.unmount());
return content;
}
it("renders audio clips as waveforms even when a composition preview URL is active", () => {
const content = renderClipContent({
id: "voiceover",
tag: "audio",
start: 1,
duration: 4,
track: 1,
src: "assets/voiceover.mp3",
});
expect(isValidElement(content)).toBe(true);
if (isValidElement(content)) expect(content.type).toBe(AudioWaveform);
});
});
@@ -110,6 +110,13 @@ export function useRenderClipContent({
});
}
// Audio clips — waveform visualization. Resolve these before the generic
// activePreviewUrl thumbnail branch; audio rows need waveform data, not a
// captured frame from the currently drilled composition preview.
if (el.tag === "audio") {
return renderAudioClip(el, pid, style.label);
}
// When drilled into a composition, render all inner elements via
// CompositionThumbnail at their start time — most accurate visual.
if (activePreviewUrl && el.duration > 0) {
@@ -131,11 +138,6 @@ export function useRenderClipContent({
el.duration < effectiveTimelineDuration * 0.92 &&
!/(backdrop|background|overlay|scrim|mask)/i.test(el.id);
// Audio clips — waveform visualization
if (el.tag === "audio") {
return renderAudioClip(el, pid, style.label);
}
if ((el.tag === "video" || el.tag === "img") && el.src) {
const mediaSrc = el.src.startsWith("http")
? el.src