Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f7475ec
feat(studio): a pure Premiere style placement rule with overwrite and…
miguel-heygen Sep 19, 2026
118aeea
feat(studio): dragging a clip over another overwrites it, alt inserts…
miguel-heygen Sep 19, 2026
079976e
test(studio): overwrite fold tests keep untouched lane clips in the e…
miguel-heygen Sep 19, 2026
96d5455
fix(studio): a wrong-zone aim still creates a track and a group drag …
miguel-heygen Sep 19, 2026
67dd388
test(studio): alt at pointer-up drives an insert through the drag lif…
miguel-heygen Sep 19, 2026
e8295b4
test(studio): cut and delete record under the drop's fold key, delete…
miguel-heygen Sep 19, 2026
45ffaf6
test(studio): the overwrite delete test removes a clip so history rec…
miguel-heygen Sep 19, 2026
3dc7b9b
refactor(studio): drop a redundant type annotation in the placement c…
miguel-heygen Sep 19, 2026
22fd4e0
chore(studio): shorten the placement rule comment to the four line limit
miguel-heygen Sep 19, 2026
8e597cc
refactor(studio): reuse existing overlap, identity and resize types i…
miguel-heygen Sep 19, 2026
1be0825
fix(studio): a drop toasts on total failure and refuses a too-thin trim
miguel-heygen Sep 19, 2026
339cdaf
fix(studio): a drop never shows the timeline an older state
miguel-heygen Sep 19, 2026
68090ee
fix(studio): editing a clip while playing keeps the playhead running
miguel-heygen Sep 19, 2026
46e916b
refactor(studio): share test setup in the placement drop tests
miguel-heygen Sep 19, 2026
8e706c4
fix(studio): an overwrite drop no longer rewrites the timeline from a…
miguel-heygen Sep 19, 2026
95e3404
fix(studio): mark a not-yet-reloaded split tail with a field, not an …
miguel-heygen Sep 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,7 @@ export function StudioApp() {
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
handleRazorSplit={timelineEditing.handleRazorSplit}
handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
placementOps={timelineEditing.placementOps}
onCopyClip={handleCopy}
onPasteClip={handlePaste}
onDuplicateClip={handleDuplicate}
Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/components/EditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export function EditorShell({
handleTimelineElementSplit,
handleRazorSplit,
handleRazorSplitAll,
placementOps,
onCopyClip,
onPasteClip,
onDuplicateClip,
Expand Down Expand Up @@ -151,6 +152,7 @@ export function EditorShell({
handleTimelineElementSplit,
handleRazorSplit,
handleRazorSplitAll,
placementOps,
});

return (
Expand Down
8 changes: 5 additions & 3 deletions packages/studio/src/components/nle/TimelinePane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export function forwardRebasedTimelineMoveElements(
);
}

type TimelineResizeCommitOptions = { coalesceKey?: string; coalesceMs?: number };

type TimelineResizeChange = {
element: TimelineElement;
start: number;
Expand All @@ -49,10 +51,10 @@ type TimelineResizeChange = {

export function forwardRebasedTimelineResizeElements(
changes: TimelineResizeChange[],
options: { coalesceKey?: string } | undefined,
options: TimelineResizeCommitOptions | undefined,
onResizeElements: (
changes: TimelineResizeChange[],
options?: { coalesceKey?: string },
options?: TimelineResizeCommitOptions,
) => Promise<void> | void,
) {
return onResizeElements(
Expand Down Expand Up @@ -222,7 +224,7 @@ export function TimelinePane({
duration: number;
playbackStart?: number;
}>,
options?: { coalesceKey?: string },
options?: TimelineResizeCommitOptions,
) => {
if (!onResizeElements) return;
if (changes.some(({ element }) => element.expandedParentStart !== undefined)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
splitTimelineElementKey,
} from "../../player/lib/timelineElementHelpers";
import type { TimelineKeyframeTarget } from "../../player/components/timelineKeyframeIdentity";
import type { PlacementOps } from "../../player/components/timelinePlacementCommit";

export interface TimelineEditCallbackDeps {
handleTimelineElementMove: (
Expand All @@ -46,6 +47,8 @@ export interface TimelineEditCallbackDeps {
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
/** Split and remove writes for a clip drop that overwrites a neighbour. */
placementOps?: PlacementOps;
/** C1's ungrouped-track FX pointer — same auto-grouping write B6's carve uses. */
handleGroupClips?: (
clipIds: readonly string[],
Expand Down Expand Up @@ -124,6 +127,7 @@ export function useTimelineEditCallbacks({
handleTimelineElementSplit,
handleRazorSplit,
handleRazorSplitAll,
placementOps,
handleGroupClips,
setElementFxAttribute,
}: TimelineEditCallbackDeps): TimelineEditCallbacks {
Expand Down Expand Up @@ -207,6 +211,7 @@ export function useTimelineEditCallbacks({
onMoveElements: handleTimelineElementsMove,
onResizeElement: handleTimelineElementResize,
onResizeElements: handleTimelineGroupResize,
onPlacementOps: placementOps,
onToggleTrackHidden: handleToggleTrackHidden,
onSetAudioGroupAttributeLive: setAudioGroupAttribute.setLive,
onSetAudioGroupAttributeQuiet: setAudioGroupAttribute.setQuiet,
Expand Down Expand Up @@ -405,6 +410,7 @@ export function useTimelineEditCallbacks({
handleTimelineElementsMove,
handleTimelineElementResize,
handleTimelineGroupResize,
placementOps,
handleToggleTrackHidden,
setAudioGroupAttribute,
handleGroupClips,
Expand Down
1 change: 1 addition & 0 deletions packages/studio/src/contexts/TimelineEditContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function TimelineEditProvider({
value.onMoveElement,
value.onMoveElements,
value.onResizeElement,
value.onPlacementOps,
value.onToggleTrackHidden,
value.onSetAudioGroupAttributeLive,
value.onSetAudioGroupAttributeQuiet,
Expand Down
59 changes: 56 additions & 3 deletions packages/studio/src/hooks/useRazorSplit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { createPersistentEditHistoryStore } from "./usePersistentEditHistory";
import { createEmptyEditHistory } from "../utils/editHistory";
import type { EditHistoryStorageAdapter } from "../utils/editHistoryStorage";
import { createSplitFetchMock, mountProbe } from "./useRazorSplit.testHelpers";
import type { PlacementFold } from "../player/components/timelinePlacementCommit";

(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

Expand Down Expand Up @@ -49,11 +50,19 @@ interface SplitRequest {

type SingleSplit = (element: TimelineElement, splitTime: number) => Promise<void>;
type SplitAll = (splitTime: number) => Promise<void>;
type PlacementSplit = (
element: TimelineElement,
splitTime: number,
fold: PlacementFold,
) => Promise<boolean>;

interface Harness {
splitRequests: SplitRequest[];
singleRef: { current: SingleSplit | undefined };
allRef: { current: SplitAll | undefined };
placementRef: { current: PlacementSplit | undefined };
reloadPreview: ReturnType<typeof vi.fn>;
forceReloadSdkSession: ReturnType<typeof vi.fn>;
root: ReturnType<typeof mountProbe>;
}

Expand All @@ -76,25 +85,38 @@ function mountRazorSplit(): Harness {

const singleRef: { current: SingleSplit | undefined } = { current: undefined };
const allRef: { current: SplitAll | undefined } = { current: undefined };
const placementRef: { current: PlacementSplit | undefined } = { current: undefined };
const reloadPreview = vi.fn();
const forceReloadSdkSession = vi.fn();

function Component() {
const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({
const { handleRazorSplit, handleRazorSplitAll, handlePlacementSplit } = useRazorSplit({
projectId: "p1",
activeCompPath: ROOT_FILE,
showToast: () => {},
writeProjectFile: async (path, content) => {
disk[path] = content;
},
recordEdit: async () => {},
reloadPreview: () => {},
reloadPreview,
forceReloadSdkSession,
});
singleRef.current = handleRazorSplit;
allRef.current = handleRazorSplitAll;
placementRef.current = handlePlacementSplit;
return null;
}

const root = mountProbe(Component);
return { splitRequests, singleRef, allRef, root };
return {
splitRequests,
singleRef,
allRef,
placementRef,
reloadPreview,
forceReloadSdkSession,
root,
};
}

afterEach(() => {
Expand Down Expand Up @@ -162,6 +184,37 @@ describe("useRazorSplit — sub-comp coordinate rebasing", () => {
});
});

// A drop reloads the preview once, at its end; an interior split must not reload on its own.
describe("useRazorSplit — preview reload folded into a drop", () => {
let harness: Harness;
beforeEach(() => {
harness = mountRazorSplit();
});
afterEach(() => {
act(() => harness.root.unmount());
});

it("skips reloading the preview for a split folded into a drop, but still refreshes the sdk session", async () => {
await act(async () => {
await harness.placementRef.current!(rootElement, 4, {
coalesceKey: "clip-overwrite:1",
coalesceMs: Number.POSITIVE_INFINITY,
});
});

expect(harness.reloadPreview).not.toHaveBeenCalled();
expect(harness.forceReloadSdkSession).toHaveBeenCalledTimes(1);
});

it("still reloads the preview for a plain (non-drop) split", async () => {
await act(async () => {
await harness.singleRef.current!(rootElement, 4);
});

expect(harness.reloadPreview).toHaveBeenCalledTimes(1);
});
});

// ── Bug 1: split must resync the SDK session so undo isn't refused ────────────

const memoryStorage = (): EditHistoryStorageAdapter => {
Expand Down
63 changes: 46 additions & 17 deletions packages/studio/src/hooks/useRazorSplit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { trackStudioRazorSplit } from "../telemetry/events";
import { canSplitElementAt, selectSplittableElements } from "../utils/timelineElementSplit";
import { buildAtomicCutIntents, runAtomicCutTransaction } from "../utils/razorSplitTransaction";
import type { RecordEditInput } from "./timelineEditingHelpers";
import type { PlacementFold } from "../player/components/timelinePlacementCommit";
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";

interface UseRazorSplitOptions {
projectId: string | null;
Expand Down Expand Up @@ -34,23 +36,36 @@ export function useRazorSplit({
const projectIdRef = useRef(projectId);
projectIdRef.current = projectId;

const synchronize = useCallback(() => {
let failure: unknown;
try {
forceReloadSdkSession?.();
} catch (error) {
failure = error;
}
try {
reloadPreview();
} catch (error) {
failure ??= error;
}
if (failure) throw failure;
}, [forceReloadSdkSession, reloadPreview]);
// skipPreviewReload: true when this cut is one step folded into a drop, whose
// own single reload (after every step lands) replaces this one — otherwise the
// preview would show this step's DOM before the next step makes it stale.
const synchronize = useCallback(
(skipPreviewReload: boolean) => {
let failure: unknown;
try {
forceReloadSdkSession?.();
} catch (error) {
failure = error;
}
if (!skipPreviewReload) {
try {
reloadPreview();
} catch (error) {
failure ??= error;
}
}
if (failure) throw failure;
},
[forceReloadSdkSession, reloadPreview],
);

const runCut = useCallback(
async (elements: readonly TimelineElement[], splitTime: number, mode: "single" | "all") => {
async (
elements: readonly TimelineElement[],
splitTime: number,
mode: "single" | "all",
fold?: PlacementFold,
) => {
const pid = projectIdRef.current;
if (!pid || elements.length === 0) return;
const intents = buildAtomicCutIntents(elements, splitTime, activeCompPath);
Expand All @@ -64,10 +79,11 @@ export function useRazorSplit({
projectId: pid,
intents,
label,
...fold,
writeProjectFile,
recordEdit,
observeProjectFileVersion,
synchronize,
synchronize: () => synchronize(Boolean(fold)),
});
trackStudioRazorSplit({ mode, count: result.splitCount });
if (result.syncFailed) {
Expand Down Expand Up @@ -114,6 +130,19 @@ export function useRazorSplit({
[isRecordingRef, runCut, showToast],
);

/** One cut inside a clip drop: recorded under the drop's fold key, true once it landed. */
const handlePlacementSplit = useCallback(
async (element: TimelineElement, splitTime: number, fold: PlacementFold) => {
try {
return (await runCut([element], splitTime, "single", fold)) !== undefined;
} catch (error) {
showToast(getStudioSaveErrorMessage(error), "error");
return false;
}
},
[runCut, showToast],
);

const handleRazorSplitAll = useCallback(
async (splitTime: number) => {
if (isRecordingRef?.current) {
Expand All @@ -135,5 +164,5 @@ export function useRazorSplit({
[isRecordingRef, runCut, showToast],
);

return { handleRazorSplit, handleRazorSplitAll };
return { handleRazorSplit, handleRazorSplitAll, handlePlacementSplit };
}
Loading
Loading