import { activeSequence, addMediaToProject, applyEditCommand, createCommand, createProjectV2, type EditCommand, type MediaAssetV2, type ProjectSnapshotV2, type SequenceV2, type TimelineItem, } from "../../../packages/editor-core/src"; export type EditTool = "select" | "trim" | "blade"; export interface EditorState { project: ProjectSnapshotV2; projectPath?: string; selectedItemIds: string[]; selectedMediaId?: string; playheadFrame: number; playing: boolean; pixelsPerFrame: number; tool: EditTool; snapping: boolean; history: ProjectSnapshotV2[]; future: ProjectSnapshotV2[]; clipboard: TimelineItem[]; lastCoalesce?: { key: string; timestamp: number }; dirty: boolean; notice: string; } export type EditorAction = | { type: "LOAD_PROJECT"; project: ProjectSnapshotV2; projectPath?: string; notice?: string } | { type: "PROJECT_SAVED"; project: ProjectSnapshotV2; projectPath: string } | { type: "PROJECT_RECOVERED"; revision: number } | { type: "RENAME_PROJECT"; title: string } | { type: "SET_SEQUENCE"; patch: Partial } | { type: "IMPORT_MEDIA"; media: MediaAssetV2[] } | { type: "REPLACE_MEDIA"; media: MediaAssetV2 } | { type: "SELECT_MEDIA"; mediaId?: string } | { type: "APPLY_COMMAND"; command: EditCommand; select?: string[]; coalesceKey?: string } | { type: "SELECT_ITEMS"; itemIds: string[]; additive?: boolean } | { type: "SET_PLAYHEAD"; frame: number } | { type: "SET_PLAYING"; playing: boolean } | { type: "SET_ZOOM"; pixelsPerFrame: number } | { type: "SET_TOOL"; tool: EditTool } | { type: "TOGGLE_SNAPPING" } | { type: "COPY" } | { type: "PASTE" } | { type: "SET_NOTICE"; notice: string } | { type: "UNDO" } | { type: "REDO" }; export function createInitialEditorState(): EditorState { return { project: createProjectV2(), selectedItemIds: [], playheadFrame: 0, playing: false, pixelsPerFrame: 2.4, tool: "select", snapping: true, history: [], future: [], clipboard: [], dirty: false, notice: "Import footage to begin", }; } function commit(state: EditorState, project: ProjectSnapshotV2, notice: string, selection = state.selectedItemIds, coalesceKey?: string): EditorState { if (project === state.project) return { ...state, notice }; const now = Date.now(); const coalescing = Boolean(coalesceKey && state.lastCoalesce?.key === coalesceKey && now - state.lastCoalesce.timestamp < 900); return { ...state, project, selectedItemIds: selection, history: coalescing ? state.history : [...state.history.slice(-99), state.project], future: [], lastCoalesce: coalesceKey ? { key: coalesceKey, timestamp: now } : undefined, dirty: true, notice, }; } function applyCommand(state: EditorState, command: EditCommand, select?: string[], coalesceKey?: string): EditorState { const result = applyEditCommand(state.project, { ...command, baseRevision: state.project.revision }); return commit(state, result.project, result.notice, select || result.changedItemIds || state.selectedItemIds, coalesceKey); } export function editorReducer(state: EditorState, action: EditorAction): EditorState { switch (action.type) { case "LOAD_PROJECT": return { ...state, project: action.project, projectPath: action.projectPath, selectedItemIds: [], selectedMediaId: action.project.media[0]?.id, playheadFrame: 0, playing: false, history: [], future: [], clipboard: [], lastCoalesce: undefined, dirty: false, notice: action.notice || "Project opened", }; case "PROJECT_SAVED": // A slower disk/IndexedDB write must never replace edits made while that // write was in flight. Keep the newer in-memory revision dirty so the // next autosave persists it. if (action.project.revision < state.project.revision) { return { ...state, projectPath: action.projectPath, dirty: true, notice: "Saving latest edits…" }; } return { ...state, project: action.project, projectPath: action.projectPath, dirty: false, notice: "Saved" }; case "PROJECT_RECOVERED": // A recovery write protects the current untitled project just as a file // save does. Never clear dirty when a newer edit landed during the write. return action.revision < state.project.revision ? { ...state, dirty: true, notice: "Protecting latest edits…" } : { ...state, dirty: false, notice: "Unsaved project protected in recovery" }; case "RENAME_PROJECT": return commit(state, { ...state.project, title: action.title || "Untitled cut", revision: state.project.revision + 1, updatedAt: new Date().toISOString() }, "Project renamed"); case "SET_SEQUENCE": return applyCommand(state, createCommand(state.project, "set-sequence", { patch: action.patch }), state.selectedItemIds); case "IMPORT_MEDIA": return commit(state, addMediaToProject(state.project, action.media), `${action.media.length} source${action.media.length === 1 ? "" : "s"} imported`, state.selectedItemIds); case "REPLACE_MEDIA": return commit(state, { ...state.project, revision: state.project.revision + 1, updatedAt: new Date().toISOString(), media: state.project.media.map((media) => media.id === action.media.id ? action.media : media), }, action.media.proxyPath ? "Proxy ready" : "Source relinked"); case "SELECT_MEDIA": return { ...state, selectedMediaId: action.mediaId }; case "APPLY_COMMAND": return applyCommand(state, action.command, action.select, action.coalesceKey); case "SELECT_ITEMS": { const itemIds = action.additive ? [...new Set([...state.selectedItemIds, ...action.itemIds])] : action.itemIds; return { ...state, selectedItemIds: itemIds }; } case "SET_PLAYHEAD": return { ...state, playheadFrame: Math.max(0, Math.round(action.frame)) }; case "SET_PLAYING": return { ...state, playing: action.playing }; case "SET_ZOOM": return { ...state, pixelsPerFrame: Math.max(0.35, Math.min(12, action.pixelsPerFrame)) }; case "SET_TOOL": return { ...state, tool: action.tool, notice: `${action.tool[0].toUpperCase()}${action.tool.slice(1)} tool` }; case "TOGGLE_SNAPPING": return { ...state, snapping: !state.snapping, notice: `Snapping ${state.snapping ? "off" : "on"}` }; case "COPY": { const sequence = activeSequence(state.project); return { ...state, clipboard: sequence.items.filter((item) => state.selectedItemIds.includes(item.id)).map((item) => structuredClone(item)), notice: `${state.selectedItemIds.length} item${state.selectedItemIds.length === 1 ? "" : "s"} copied` }; } case "PASTE": { if (!state.clipboard.length) return { ...state, notice: "Nothing copied" }; let next = state; const earliest = Math.min(...state.clipboard.map((item) => item.timelineStartFrame)); const selected: string[] = []; for (const source of state.clipboard) { const copy = structuredClone(source); copy.id = `${copy.kind}-${crypto.randomUUID()}`; copy.timelineStartFrame = state.playheadFrame + source.timelineStartFrame - earliest; copy.parentId = undefined; copy.anchorOffsetFrames = undefined; const command = createCommand(next.project, copy.role === "storyline" ? "insert" : "connect", { item: copy, frame: copy.timelineStartFrame }); next = applyCommand(next, command, [...selected, copy.id]); selected.push(copy.id); } return { ...next, selectedItemIds: selected, notice: `${selected.length} item${selected.length === 1 ? "" : "s"} pasted` }; } case "SET_NOTICE": return { ...state, notice: action.notice }; case "UNDO": { const previous = state.history.at(-1); if (!previous) return { ...state, notice: "Nothing to undo" }; return { ...state, project: previous, history: state.history.slice(0, -1), future: [state.project, ...state.future].slice(0, 100), lastCoalesce: undefined, dirty: true, notice: "Undone" }; } case "REDO": { const next = state.future[0]; if (!next) return { ...state, notice: "Nothing to redo" }; return { ...state, project: next, history: [...state.history, state.project].slice(-100), future: state.future.slice(1), lastCoalesce: undefined, dirty: true, notice: "Redone" }; } default: return state; } }