import type { SetStateAction } from 'react' import type { ParsedTimeline } from '../../lib/timeline-import' import type { SrtCue } from '../../lib/srt' import type { Asset, AssetBins, AssetTake, ColorCorrection, LetterboxSettings, Project, SubtitleClip, SubtitleStyle, TextOverlayStyle, Timeline, TimelineClip, Track, } from '../../types/project-model' import { createAssetBinId, createDefaultTimeline, DEFAULT_COLOR_CORRECTION, DEFAULT_LETTERBOX, DEFAULT_TEXT_STYLE, } from '../../types/project-model' import { resolveOverlaps, type EditorLayout, type ToolType } from './video-editor-utils' import { applyUndoSnapshot, createInitialEditorState, equalUndoSnapshot, getUndoSnapshot, type EditorModel, type EditorState, type TimelineGapSelection, } from './editor-state' import { getActiveTimelineFromEditorModel, selectActiveTimeline, selectActiveTimelineInPoint, selectActiveTimelineOutPoint, selectActiveTimelineId, selectAssetById, selectCanUseClipboard, selectClipById, selectClips, selectCurrentTime, selectLiveAssetForClip, selectSelectedClipIds, selectTracks, } from './editor-selectors' import { getEditorModel, updatedProject } from './editor-project-bridging' export interface PendingClipTakeUpdate { assetId: string clipIds: string[] newTakeIndex: number } export interface InsertAssetsToTimelineParams { assets: Asset[] trackIndex?: number startTime?: number } export interface AddTextClipParams { style?: Partial startTime?: number trackIndex?: number } export interface AddAdjustmentLayerParams { duration?: number trackIndex?: number startTime?: number } export interface MoveClipsParams { clipIds: string[] deltaTime?: number targetTrackIndex?: number } export interface ResizeClipParams { clipId: string edge: 'start' | 'end' deltaTime: number } export interface SlipClipParams { clipId: string deltaTime: number } export interface SlideClipParams { clipId: string deltaTime: number } export interface AddSubtitleParams { trackIndex: number text?: string startTime?: number endTime?: number } export interface InsertGeneratedGapAssetParams { gap: TimelineGapSelection asset: Asset createAudio: boolean } export interface SourceEditParams { asset: Asset sourceIn: number | null sourceOut: number | null sourceTime: number } export interface SelectClipMode { mode?: 'replace' | 'toggle' | 'add' } function makeId(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}` } function deleteBinEntry(bins: AssetBins, binId: string): AssetBins { const { [binId]: _removed, ...rest } = bins return rest } function markEditorModelDirty(state: EditorState): EditorState { if (state.projectSync.dirty) return state return { ...state, projectSync: { ...state.projectSync, dirty: true, }, } } function cloneClipIds(ids: Set): Set { return new Set(ids) } export function applyStateAction(value: SetStateAction, current: T): T { return typeof value === 'function' ? (value as (prevState: T) => T)(current) : value } function updateEditorModel(state: EditorState, updater: (editorModel: EditorModel) => EditorModel): EditorState { const nextEditorModel = updater(state.editorModel) if (nextEditorModel === state.editorModel) return state return markEditorModelDirty({ ...state, editorModel: nextEditorModel, }) } function updateSession(state: EditorState, updater: (session: EditorState['session']) => EditorState['session']): EditorState { const nextSession = updater(state.session) if (nextSession === state.session) return state return { ...state, session: nextSession, } } function withActiveTimeline(editorModel: EditorModel, updater: (timeline: Timeline) => Timeline): EditorModel { const activeTimeline = getActiveTimelineFromEditorModel(editorModel) if (!activeTimeline) return editorModel return { ...editorModel, timelines: editorModel.timelines.map(timeline => ( timeline.id === activeTimeline.id ? updater(timeline) : timeline )), } } function activeTrackStartTime(state: EditorState, trackIndex: number): number { return selectClips(state) .filter(clip => clip.trackIndex === trackIndex) .reduce((max, clip) => Math.max(max, clip.startTime + clip.duration), 0) } function createTimelineClipFromAsset(asset: Asset, trackIndex: number, startTime: number): TimelineClip { return { id: makeId('clip'), assetId: asset.id, type: asset.type === 'adjustment' ? 'adjustment' : asset.type, startTime, duration: asset.duration || (asset.type === 'adjustment' ? 10 : 5), trimStart: 0, trimEnd: 0, speed: 1, reversed: false, muted: false, volume: 1, trackIndex, asset, flipH: false, flipV: false, transitionIn: { type: 'none', duration: 0 }, transitionOut: { type: 'none', duration: 0 }, colorCorrection: { ...DEFAULT_COLOR_CORRECTION }, opacity: 100, } } type DroppedAssetInsertion = { tracks: Track[] clips: TimelineClip[] duration: number } function buildDroppedVisualClipInsertion( asset: Asset, trackIndex: number, startTime: number, tracks: Track[], ): DroppedAssetInsertion { const track = tracks[trackIndex] if (!track || track.locked) { return { tracks, clips: [], duration: 0 } } if (track.kind !== 'video') { return { tracks, clips: [], duration: 0 } } const trackPatched = track.sourcePatched !== false const isAdjustment = asset.type === 'adjustment' const isVideoAsset = asset.type === 'video' const isImageAsset = asset.type === 'image' const createVisualClip = (isVideoAsset || isImageAsset || isAdjustment) && trackPatched const needsLinkedAudioClip = isVideoAsset && !isAdjustment let nextTracks = tracks let audioTrackIndex = -1 if (needsLinkedAudioClip) { audioTrackIndex = nextTracks.findIndex( (candidate, index) => index > trackIndex && candidate.kind === 'audio' && !candidate.locked && candidate.sourcePatched !== false, ) if (audioTrackIndex < 0) { audioTrackIndex = nextTracks.findIndex( candidate => candidate.kind === 'audio' && !candidate.locked && candidate.sourcePatched !== false, ) } if (audioTrackIndex < 0) { const audioTrackCount = nextTracks.filter(candidate => candidate.kind === 'audio').length nextTracks = [ ...nextTracks, { id: makeId('track-audio'), name: `A${audioTrackCount + 1}`, muted: false, locked: false, kind: 'audio', }, ] audioTrackIndex = nextTracks.length - 1 } } const createAudioClip = needsLinkedAudioClip && audioTrackIndex >= 0 if (!createVisualClip && !createAudioClip) { return { tracks: nextTracks, clips: [], duration: 0 } } const duration = asset.duration || (isAdjustment ? 10 : 5) const visualClipId = makeId('clip') const audioClipId = makeId('clip-audio') const clips: TimelineClip[] = [] if (createVisualClip) { clips.push({ id: visualClipId, assetId: asset.id, type: isAdjustment ? 'adjustment' : isVideoAsset ? 'video' : 'image', startTime, duration, trimStart: 0, trimEnd: 0, speed: 1, reversed: false, muted: false, volume: 1, trackIndex, asset, flipH: false, flipV: false, transitionIn: { type: 'none', duration: isAdjustment ? 0 : 0.5 }, transitionOut: { type: 'none', duration: isAdjustment ? 0 : 0.5 }, colorCorrection: { ...DEFAULT_COLOR_CORRECTION }, opacity: 100, ...(isAdjustment ? { letterbox: { ...DEFAULT_LETTERBOX } } : {}), ...(createAudioClip ? { linkedClipIds: [audioClipId] } : {}), }) } if (createAudioClip && audioTrackIndex >= 0) { clips.push({ id: audioClipId, assetId: asset.id, type: 'audio', startTime, duration, trimStart: 0, trimEnd: 0, speed: 1, reversed: false, muted: false, volume: 1, trackIndex: audioTrackIndex, asset, flipH: false, flipV: false, transitionIn: { type: 'none', duration: 0.5 }, transitionOut: { type: 'none', duration: 0.5 }, colorCorrection: { ...DEFAULT_COLOR_CORRECTION }, opacity: 100, ...(createVisualClip ? { linkedClipIds: [visualClipId] } : {}), }) } return { tracks: nextTracks, clips, duration, } } function buildDroppedAudioClipInsertion( asset: Asset, trackIndex: number, startTime: number, tracks: Track[], ): DroppedAssetInsertion { const track = tracks[trackIndex] if (!track || track.locked) { return { tracks, clips: [], duration: 0 } } let nextTracks = tracks let audioTrackIndex = -1 if (track.kind === 'audio') { audioTrackIndex = trackIndex } else { audioTrackIndex = nextTracks.findIndex( candidate => candidate.kind === 'audio' && !candidate.locked && candidate.sourcePatched !== false, ) if (audioTrackIndex < 0) { const audioTrackCount = nextTracks.filter(candidate => candidate.kind === 'audio').length nextTracks = [ ...nextTracks, { id: makeId('track-audio'), name: `A${audioTrackCount + 1}`, muted: false, locked: false, kind: 'audio', }, ] audioTrackIndex = nextTracks.length - 1 } } if (audioTrackIndex < 0) { return { tracks: nextTracks, clips: [], duration: 0 } } const duration = asset.duration || 5 return { tracks: nextTracks, clips: [{ id: makeId('clip-audio'), assetId: asset.id, type: 'audio', startTime, duration, trimStart: 0, trimEnd: 0, speed: 1, reversed: false, muted: false, volume: 1, trackIndex: audioTrackIndex, asset, flipH: false, flipV: false, transitionIn: { type: 'none', duration: 0.5 }, transitionOut: { type: 'none', duration: 0.5 }, colorCorrection: { ...DEFAULT_COLOR_CORRECTION }, opacity: 100, }], duration, } } function buildDroppedAssetInsertion( asset: Asset, trackIndex: number, startTime: number, tracks: Track[], ): DroppedAssetInsertion { if (asset.type === 'audio') { return buildDroppedAudioClipInsertion(asset, trackIndex, startTime, tracks) } return buildDroppedVisualClipInsertion(asset, trackIndex, startTime, tracks) } function createTextClip(style?: Partial, startTime = 0, trackIndex = 0): TimelineClip { return { id: makeId('clip-text'), assetId: null, type: 'text', startTime, duration: 5, trimStart: 0, trimEnd: 0, speed: 1, reversed: false, muted: true, volume: 1, trackIndex, asset: null, flipH: false, flipV: false, transitionIn: { type: 'none', duration: 0 }, transitionOut: { type: 'none', duration: 0 }, colorCorrection: { ...DEFAULT_COLOR_CORRECTION }, opacity: 100, textStyle: { ...DEFAULT_TEXT_STYLE, ...(style || {}), }, } } function getNextAdjustmentLayerName(assets: Asset[]): string { const count = assets.filter(asset => asset.type === 'adjustment').length return count > 0 ? `Adjustment Layer ${count + 1}` : 'Adjustment Layer' } function createAdjustmentAsset(name = 'Adjustment Layer'): Asset { return { id: makeId('asset-adjustment'), type: 'adjustment', path: '', prompt: name, resolution: '', duration: 10, createdAt: Date.now(), } } function createAdjustmentClip(asset: Asset, startTime = 0, trackIndex = 0, duration = 10): TimelineClip { return { ...createTimelineClipFromAsset(asset, trackIndex, startTime), type: 'adjustment', duration, letterbox: { ...DEFAULT_LETTERBOX }, } } function buildSourceRequestClips(state: EditorState, params: SourceEditParams): { newClips: TimelineClip[] insertDuration: number targetTrackIndices: number[] time: number } | null { const { asset } = params const sourceIn = params.sourceIn ?? 0 const sourceDuration = asset.duration || 5 const sourceOut = params.sourceOut ?? sourceDuration const insertDuration = sourceOut - sourceIn if (insertDuration <= 0) return null const time = selectCurrentTime(state) const tracks = selectTracks(state) const isAudio = asset.type === 'audio' const videoTrack = !isAudio ? tracks.find(track => !track.locked && track.sourcePatched !== false && track.kind === 'video') : undefined const audioTrack = tracks.find(track => !track.locked && track.sourcePatched !== false && track.kind === 'audio') if (!videoTrack && !audioTrack) return null if (isAudio && !audioTrack) return null if (!isAudio && !videoTrack) return null const videoTrackIndex = videoTrack ? tracks.indexOf(videoTrack) : -1 const audioTrackIndex = audioTrack ? tracks.indexOf(audioTrack) : -1 const videoClipId = makeId('clip') const audioClipId = makeId('clip-audio') const baseClip = { assetId: asset.id, startTime: time, duration: insertDuration, trimStart: sourceIn, trimEnd: sourceDuration - sourceOut, speed: 1, reversed: false, muted: false, volume: 1, asset, flipH: false as const, flipV: false as const, transitionIn: { type: 'none' as const, duration: 0.5 }, transitionOut: { type: 'none' as const, duration: 0.5 }, colorCorrection: { ...DEFAULT_COLOR_CORRECTION }, opacity: 100, } const newClips: TimelineClip[] = [] const targetTrackIndices: number[] = [] if (isAudio) { newClips.push({ ...baseClip, id: audioClipId, type: 'audio', trackIndex: audioTrackIndex, }) targetTrackIndices.push(audioTrackIndex) } else { const needsAudio = asset.type === 'video' && audioTrackIndex >= 0 newClips.push({ ...baseClip, id: videoClipId, type: asset.type === 'video' ? 'video' : 'image', trackIndex: videoTrackIndex, ...(needsAudio ? { linkedClipIds: [audioClipId] } : {}), }) targetTrackIndices.push(videoTrackIndex) if (needsAudio) { newClips.push({ ...baseClip, id: audioClipId, type: 'audio', trackIndex: audioTrackIndex, linkedClipIds: [videoClipId], }) targetTrackIndices.push(audioTrackIndex) } } return { newClips, insertDuration, targetTrackIndices, time } } function replaceActiveTimeline(state: EditorState, updater: (timeline: Timeline) => Timeline): EditorState { return updateEditorModel(state, editorModel => withActiveTimeline(editorModel, updater)) } function mapClips(state: EditorState, mapper: (clip: TimelineClip) => TimelineClip): EditorState { return replaceActiveTimeline(state, timeline => ({ ...timeline, clips: timeline.clips.map(mapper), })) } function mapTracks(state: EditorState, mapper: (track: Track, index: number) => Track): EditorState { return replaceActiveTimeline(state, timeline => ({ ...timeline, tracks: timeline.tracks.map(mapper), })) } export function loadEditorDocument(state: EditorState, snapshot: EditorModel): EditorState { return markEditorModelDirty({ ...state, editorModel: snapshot, }) } export function replaceActiveTimelineDocument( state: EditorState, snapshot: Partial>, ): EditorState { return replaceActiveTimeline(state, timeline => ({ ...timeline, ...snapshot, })) } export function setTimelineClips(state: EditorState, value: SetStateAction): EditorState { return replaceActiveTimeline(state, timeline => ({ ...timeline, clips: applyStateAction(value, timeline.clips), })) } export function setTimelineTracks(state: EditorState, value: SetStateAction): EditorState { return replaceActiveTimeline(state, timeline => ({ ...timeline, tracks: applyStateAction(value, timeline.tracks), })) } export function setTimelineSubtitles(state: EditorState, value: SetStateAction): EditorState { return replaceActiveTimeline(state, timeline => ({ ...timeline, subtitles: applyStateAction(value, timeline.subtitles || []), })) } export function commitEditorDocument(state: EditorState): EditorState { return state } export function switchActiveTimeline(state: EditorState, timelineId: string | null): EditorState { return { ...markEditorModelDirty({ ...state, editorModel: { ...state.editorModel, activeTimelineId: timelineId, }, }), session: { ...state.session, selection: { ...state.session.selection, clipIds: new Set(), subtitleId: null, }, transport: { ...state.session.transport, currentTime: 0, isPlaying: false, playingInOut: false, }, ui: { ...state.session.ui, openTimelineIds: timelineId ? new Set([...state.session.ui.openTimelineIds, timelineId]) : new Set(state.session.ui.openTimelineIds), }, }, } } export function createTimeline(state: EditorState, name?: string): EditorState { const timeline = createDefaultTimeline(name) return { ...updateEditorModel(state, editorModel => ({ ...editorModel, timelines: [...editorModel.timelines, timeline], activeTimelineId: timeline.id, })), session: { ...state.session, ui: { ...state.session.ui, openTimelineIds: new Set([...state.session.ui.openTimelineIds, timeline.id]), }, selection: { ...state.session.selection, clipIds: new Set(), subtitleId: null, }, transport: { ...state.session.transport, currentTime: 0, isPlaying: false, playingInOut: false, }, }, } } export function duplicateTimeline(state: EditorState, timelineId: string): EditorState { const source = state.editorModel.timelines.find(timeline => timeline.id === timelineId) if (!source) return state const duplicate: Timeline = { ...source, id: makeId('timeline'), name: `${source.name} Copy`, createdAt: Date.now(), tracks: source.tracks.map(track => ({ ...track })), clips: source.clips.map(clip => ({ ...clip, id: makeId('clip') })), subtitles: source.subtitles?.map(subtitle => ({ ...subtitle, id: makeId('sub') })), } return { ...updateEditorModel(state, editorModel => ({ ...editorModel, timelines: [...editorModel.timelines, duplicate], activeTimelineId: duplicate.id, })), session: { ...state.session, ui: { ...state.session.ui, openTimelineIds: new Set([...state.session.ui.openTimelineIds, duplicate.id]), }, selection: { ...state.session.selection, clipIds: new Set(), subtitleId: null, }, transport: { ...state.session.transport, currentTime: 0, isPlaying: false, playingInOut: false, }, }, } } export function renameTimeline(state: EditorState, timelineId: string, name: string): EditorState { return updateEditorModel(state, editorModel => ({ ...editorModel, timelines: editorModel.timelines.map(timeline => ( timeline.id === timelineId ? { ...timeline, name } : timeline )), })) } export function deleteTimeline(state: EditorState, timelineId: string): EditorState { const nextTimelines = state.editorModel.timelines.filter(timeline => timeline.id !== timelineId) const nextActiveTimelineId = state.editorModel.activeTimelineId === timelineId ? nextTimelines[0]?.id ?? null : state.editorModel.activeTimelineId return { ...markEditorModelDirty({ ...state, editorModel: { ...state.editorModel, timelines: nextTimelines, activeTimelineId: nextActiveTimelineId, }, }), session: { ...state.session, ui: { ...state.session.ui, openTimelineIds: new Set([...state.session.ui.openTimelineIds].filter(id => nextTimelines.some(timeline => timeline.id === id))), }, }, } } export function importParsedTimeline(state: EditorState, parsed: ParsedTimeline): EditorState { const importedBinId = Object.entries(state.editorModel.bins).find(([, name]) => name === 'Imported')?.[0] ?? createAssetBinId() const importedAssets: Asset[] = parsed.mediaRefs.map(ref => ({ id: makeId('asset'), type: ref.type, path: ref.path, bigThumbnailPath: ref.bigThumbnailPath, smallThumbnailPath: ref.smallThumbnailPath, width: ref.width, height: ref.height, prompt: ref.name || ref.path.split(/[/\\]/).pop() || 'Imported media', resolution: ref.width && ref.height ? `${ref.width}x${ref.height}` : 'Unknown', duration: ref.duration || undefined, binId: importedBinId, createdAt: Date.now(), })) const assetByParsedId = new Map() parsed.mediaRefs.forEach((ref, index) => { assetByParsedId.set(ref.id, importedAssets[index]) }) const totalTracks = Math.max(parsed.videoTrackCount + parsed.audioTrackCount, 1) const tracks: Track[] = [] for (let index = 0; index < totalTracks; index++) { const isAudio = index >= parsed.videoTrackCount tracks.push({ id: makeId('track'), name: isAudio ? `A${index - parsed.videoTrackCount + 1}` : `V${index + 1}`, muted: false, locked: false, kind: isAudio ? 'audio' : 'video', }) } const clips: TimelineClip[] = [] const parsedIndexToClipId = new Map() for (let parsedIndex = 0; parsedIndex < parsed.clips.length; parsedIndex++) { const parsedClip = parsed.clips[parsedIndex] const asset = assetByParsedId.get(parsedClip.mediaRefId) if (!asset) continue const clipId = makeId('clip') parsedIndexToClipId.set(parsedIndex, clipId) clips.push({ id: clipId, assetId: asset.id, type: parsedClip.trackType === 'audio' ? 'audio' : asset.type === 'image' ? 'image' : 'video', startTime: parsedClip.startTime, duration: parsedClip.duration, trimStart: parsedClip.sourceIn || 0, trimEnd: 0, speed: parsedClip.speed || 1, reversed: parsedClip.reversed || false, muted: parsedClip.muted || false, volume: parsedClip.volume !== undefined ? Math.min(1, Math.max(0, parsedClip.volume)) : 1, trackIndex: Math.min(parsedClip.trackIndex, totalTracks - 1), asset, importedName: parsedClip.name, flipH: parsedClip.flipH || false, flipV: parsedClip.flipV || false, transitionIn: { type: 'none', duration: 0 }, transitionOut: { type: 'none', duration: 0 }, colorCorrection: { ...DEFAULT_COLOR_CORRECTION }, opacity: parsedClip.opacity !== undefined ? parsedClip.opacity : 100, }) } for (let parsedIndex = 0; parsedIndex < parsed.clips.length; parsedIndex++) { const parsedClip = parsed.clips[parsedIndex] if (parsedClip.linkedVideoClipIndex === undefined) continue const audioClipId = parsedIndexToClipId.get(parsedIndex) const videoClipId = parsedIndexToClipId.get(parsedClip.linkedVideoClipIndex) if (!audioClipId || !videoClipId) continue const audioClip = clips.find(clip => clip.id === audioClipId) const videoClip = clips.find(clip => clip.id === videoClipId) if (!audioClip || !videoClip) continue if (!audioClip.linkedClipIds) audioClip.linkedClipIds = [] if (!audioClip.linkedClipIds.includes(videoClipId)) audioClip.linkedClipIds.push(videoClipId) if (!videoClip.linkedClipIds) videoClip.linkedClipIds = [] if (!videoClip.linkedClipIds.includes(audioClipId)) videoClip.linkedClipIds.push(audioClipId) } const timeline: Timeline = { id: makeId('timeline'), name: parsed.name || 'Imported Timeline', createdAt: Date.now(), tracks, clips, subtitles: [], } return { ...updateEditorModel(state, editorModel => ({ ...editorModel, bins: { ...editorModel.bins, [importedBinId]: 'Imported', }, assets: [...importedAssets, ...editorModel.assets], timelines: [...editorModel.timelines, timeline], activeTimelineId: timeline.id, })), session: { ...state.session, selection: { ...state.session.selection, clipIds: new Set(), subtitleId: null, }, transport: { ...state.session.transport, currentTime: 0, isPlaying: false, playingInOut: false, }, ui: { ...state.session.ui, openTimelineIds: new Set([...state.session.ui.openTimelineIds, timeline.id]), }, }, } } export function insertAssetsToTimeline(state: EditorState, params: InsertAssetsToTimelineParams): EditorState { const trackIndex = params.trackIndex ?? 0 const activeTimeline = selectActiveTimeline(state) if (!activeTimeline) return state let cursor = params.startTime ?? activeTrackStartTime(state, trackIndex) let nextTracks = activeTimeline.tracks const insertedClips: TimelineClip[] = [] for (const asset of params.assets) { const insertion = buildDroppedAssetInsertion(asset, trackIndex, cursor, nextTracks) nextTracks = insertion.tracks insertedClips.push(...insertion.clips) cursor += insertion.duration } if (insertedClips.length === 0 && nextTracks === activeTimeline.tracks) { return state } const insertedIds = new Set(insertedClips.map(clip => clip.id)) return replaceActiveTimeline(state, timeline => ({ ...timeline, tracks: nextTracks, clips: resolveOverlaps([...timeline.clips, ...insertedClips], insertedIds), })) } export function overwriteAssetsOnTimeline(state: EditorState, params: InsertAssetsToTimelineParams): EditorState { const afterInsert = insertAssetsToTimeline(state, params) const activeTimeline = selectActiveTimeline(afterInsert) if (!activeTimeline) return afterInsert const insertedIds = new Set( activeTimeline.clips .slice(-params.assets.length * 2) .map((clip: TimelineClip) => clip.id), ) return replaceActiveTimeline(afterInsert, timeline => ({ ...timeline, clips: resolveOverlaps(timeline.clips, insertedIds), })) } export function insertSourceEdit(state: EditorState, params: SourceEditParams): EditorState { const result = buildSourceRequestClips(state, params) if (!result) return state return replaceActiveTimeline(state, timeline => ({ ...timeline, clips: [ ...timeline.clips.map(clip => ( result.targetTrackIndices.includes(clip.trackIndex) && clip.startTime >= result.time ? { ...clip, startTime: clip.startTime + result.insertDuration } : clip )), ...result.newClips, ], })) } export function overwriteSourceEdit(state: EditorState, params: SourceEditParams): EditorState { const result = buildSourceRequestClips(state, params) if (!result) return state const insertedIds = new Set(result.newClips.map(clip => clip.id)) return replaceActiveTimeline(state, timeline => ({ ...timeline, clips: resolveOverlaps([...timeline.clips, ...result.newClips], insertedIds), })) } export function addTextClip(state: EditorState, params: AddTextClipParams = {}): EditorState { const insertTime = params.startTime ?? selectCurrentTime(state) const tracks = selectTracks(state) const videoTrackIndices = tracks .map((track, index) => ({ track, index })) .filter(({ track }) => track.kind === 'video' && track.type !== 'subtitle') .map(({ index }) => index) const targetTrack = params.trackIndex ?? ( videoTrackIndices.length > 0 ? videoTrackIndices[videoTrackIndices.length - 1] : 0 ) const clip = createTextClip(params.style, insertTime, targetTrack) let next = replaceActiveTimeline(state, timeline => ({ ...timeline, clips: resolveOverlaps([...timeline.clips, clip], new Set([clip.id])), })) next = setSelectedClipIds(next, new Set([clip.id])) next = setCurrentTime(next, insertTime + 0.1) return next } export function createAdjustmentLayerAsset(state: EditorState): EditorState { const name = getNextAdjustmentLayerName(state.editorModel.assets) const asset = createAdjustmentAsset(name) return updateEditorModel(state, editorModel => ({ ...editorModel, assets: [asset, ...editorModel.assets], })) } export function addAdjustmentLayer(state: EditorState, params: AddAdjustmentLayerParams = {}): EditorState { const name = getNextAdjustmentLayerName(state.editorModel.assets) const asset = createAdjustmentAsset(name) const clip = createAdjustmentClip(asset, params.startTime ?? selectCurrentTime(state), params.trackIndex ?? 0, params.duration ?? 10) return updateEditorModel(replaceActiveTimeline(state, timeline => ({ ...timeline, clips: [...timeline.clips, clip], })), editorModel => ({ ...editorModel, assets: [asset, ...editorModel.assets], })) } export function duplicateClips(state: EditorState, clipIds: string[]): EditorState { const clipSet = new Set(clipIds) return replaceActiveTimeline(state, timeline => { const duplicates = timeline.clips .filter(clip => clipSet.has(clip.id)) .map(clip => ({ ...clip, id: makeId('clip'), startTime: clip.startTime + clip.duration, })) return { ...timeline, clips: [...timeline.clips, ...duplicates], } }) } export function unlinkClipGroup(state: EditorState, clipId: string): EditorState { const clip = selectClips(state).find(candidate => candidate.id === clipId) if (!clip?.linkedClipIds?.length) return state const linkedIds = new Set(clip.linkedClipIds) return replaceActiveTimeline(state, timeline => ({ ...timeline, clips: timeline.clips.map(candidate => { if (candidate.id === clipId) { return { ...candidate, linkedClipIds: undefined } } if (!linkedIds.has(candidate.id) || !candidate.linkedClipIds?.length) { return candidate } const remaining = candidate.linkedClipIds.filter(id => id !== clipId) return { ...candidate, linkedClipIds: remaining.length > 0 ? remaining : undefined } }), })) } export function deleteClips(state: EditorState, clipIds: string[]): EditorState { const deleteSet = new Set(clipIds) let next = replaceActiveTimeline(state, timeline => ({ ...timeline, clips: timeline.clips .filter(clip => !deleteSet.has(clip.id)) .map(clip => { if (!clip.linkedClipIds) return clip const remaining = clip.linkedClipIds.filter(id => !deleteSet.has(id)) return { ...clip, linkedClipIds: remaining.length > 0 ? remaining : undefined } }), })) next = updateSession(next, session => ({ ...session, selection: { ...session.selection, clipIds: new Set([...session.selection.clipIds].filter(id => !deleteSet.has(id))), }, })) return next } export function splitClipsAtTime(state: EditorState, clipIds: string[], time: number): EditorState { const clips = selectClips(state) const tracks = selectTracks(state) const splittable = clipIds.filter(id => { const clip = clips.find(candidate => candidate.id === id) if (!clip) return false if (tracks[clip.trackIndex]?.locked) return false const splitPoint = time - clip.startTime return splitPoint > 0.1 && splitPoint < clip.duration - 0.1 }) if (splittable.length === 0) return state return replaceActiveTimeline(state, timeline => { const alreadySplit = new Set() let newClips = [...timeline.clips] for (const splitId of splittable) { if (alreadySplit.has(splitId)) continue const clip = newClips.find(candidate => candidate.id === splitId) if (!clip) continue const splitPoint = time - clip.startTime if (splitPoint <= 0.1 || splitPoint >= clip.duration - 0.1) continue alreadySplit.add(splitId) const firstHalfId = clip.id const secondHalfId = makeId('clip') const linkedClips = (clip.linkedClipIds || []) .map(linkedId => newClips.find(candidate => candidate.id === linkedId)) .filter((linkedClip): linkedClip is TimelineClip => linkedClip != null) const firstHalf: TimelineClip = { ...clip, duration: splitPoint, trimEnd: clip.trimEnd + (clip.duration - splitPoint), } const secondHalf: TimelineClip = { ...clip, id: secondHalfId, startTime: clip.startTime + splitPoint, duration: clip.duration - splitPoint, trimStart: clip.trimStart + splitPoint, } newClips = newClips.map(candidate => candidate.id === splitId ? firstHalf : candidate).concat(secondHalf) const firstHalfLinkedIds: string[] = [] const secondHalfLinkedIds: string[] = [] for (const linkedClip of linkedClips) { alreadySplit.add(linkedClip.id) const linkedSplitPoint = time - linkedClip.startTime if (linkedSplitPoint <= 0.01 || linkedSplitPoint >= linkedClip.duration - 0.01) { firstHalfLinkedIds.push(linkedClip.id) continue } const linkedSecondId = makeId('clip') firstHalfLinkedIds.push(linkedClip.id) secondHalfLinkedIds.push(linkedSecondId) const linkedFirstHalf: TimelineClip = { ...linkedClip, duration: linkedSplitPoint, trimEnd: linkedClip.trimEnd + (linkedClip.duration - linkedSplitPoint), linkedClipIds: [firstHalfId], } const linkedSecondHalf: TimelineClip = { ...linkedClip, id: linkedSecondId, startTime: linkedClip.startTime + linkedSplitPoint, duration: linkedClip.duration - linkedSplitPoint, trimStart: linkedClip.trimStart + linkedSplitPoint, linkedClipIds: [secondHalfId], } newClips = newClips .map(candidate => candidate.id === linkedClip.id ? linkedFirstHalf : candidate) .concat(linkedSecondHalf) } firstHalf.linkedClipIds = firstHalfLinkedIds.length > 0 ? firstHalfLinkedIds : undefined secondHalf.linkedClipIds = secondHalfLinkedIds.length > 0 ? secondHalfLinkedIds : undefined newClips = newClips.map(candidate => ( candidate.id === firstHalfId ? firstHalf : candidate.id === secondHalfId ? secondHalf : candidate )) } return { ...timeline, clips: newClips, } }) } export function moveClips(state: EditorState, params: MoveClipsParams): EditorState { const clipSet = new Set(params.clipIds) return replaceActiveTimeline(state, timeline => ({ ...timeline, clips: timeline.clips.map(clip => ( clipSet.has(clip.id) ? { ...clip, startTime: Math.max(0, clip.startTime + (params.deltaTime ?? 0)), trackIndex: params.targetTrackIndex ?? clip.trackIndex, } : clip )), })) } export function resizeClip(state: EditorState, params: ResizeClipParams): EditorState { return mapClips(state, clip => { if (clip.id !== params.clipId) return clip if (params.edge === 'start') { const nextStart = Math.max(0, clip.startTime + params.deltaTime) const delta = nextStart - clip.startTime const nextDuration = Math.max(0.1, clip.duration - delta) return { ...clip, startTime: nextStart, duration: nextDuration, trimStart: Math.max(0, clip.trimStart + delta * clip.speed), } } return { ...clip, duration: Math.max(0.1, clip.duration + params.deltaTime), } }) } export function slipClip(state: EditorState, params: SlipClipParams): EditorState { return mapClips(state, clip => { if (clip.id !== params.clipId) return clip return { ...clip, trimStart: Math.max(0, clip.trimStart + params.deltaTime * clip.speed), trimEnd: Math.max(0, clip.trimEnd - params.deltaTime * clip.speed), } }) } export function slideClip(state: EditorState, params: SlideClipParams): EditorState { return moveClips(state, { clipIds: [params.clipId], deltaTime: params.deltaTime }) } export function updateClip(state: EditorState, clipId: string, patch: Partial): EditorState { return mapClips(state, clip => (clip.id === clipId ? { ...clip, ...patch } : clip)) } export function setClipStartTime(state: EditorState, clipId: string, startTime: number): EditorState { return updateClip(state, clipId, { startTime: Math.max(0, startTime) }) } export function setClipDuration(state: EditorState, clipId: string, duration: number): EditorState { return updateClip(state, clipId, { duration: Math.max(0.1, duration) }) } export function setClipSpeed(state: EditorState, clipId: string, speed: number): EditorState { return updateClip(state, clipId, { speed: Math.max(0.01, speed) }) } function resolveClipAudioTargetId(state: EditorState, clipId: string): string | null { const clip = selectClipById(state, clipId) if (!clip) return null if (clip.type === 'audio') return clip.id const linkedAudioClip = (clip.linkedClipIds || []) .map(linkedId => selectClipById(state, linkedId)) .find(candidate => candidate?.type === 'audio') return linkedAudioClip?.id ?? clip.id } export function setClipAudioLevel(state: EditorState, clipId: string, volume: number): EditorState { const targetClipId = resolveClipAudioTargetId(state, clipId) if (!targetClipId) return state const clampedVolume = Math.max(0, Math.min(1, volume)) return updateClip(state, targetClipId, { volume: clampedVolume, muted: false }) } export function setClipAudioMuted(state: EditorState, clipId: string, muted: boolean): EditorState { const targetClipId = resolveClipAudioTargetId(state, clipId) if (!targetClipId) return state return updateClip(state, targetClipId, { muted }) } export function setClipVolume(state: EditorState, clipId: string, volume: number): EditorState { return updateClip(state, clipId, { volume }) } export function toggleClipMute(state: EditorState, clipId: string): EditorState { const clip = selectClips(state).find(candidate => candidate.id === clipId) if (!clip) return state return updateClip(state, clipId, { muted: !clip.muted }) } export function toggleClipReverse(state: EditorState, clipId: string): EditorState { const clip = selectClips(state).find(candidate => candidate.id === clipId) if (!clip) return state return updateClip(state, clipId, { reversed: !clip.reversed }) } export function setClipOpacity(state: EditorState, clipId: string, opacity: number): EditorState { return updateClip(state, clipId, { opacity }) } export function setClipFlipH(state: EditorState, clipId: string, value: boolean): EditorState { return updateClip(state, clipId, { flipH: value }) } export function setClipFlipV(state: EditorState, clipId: string, value: boolean): EditorState { return updateClip(state, clipId, { flipV: value }) } export function setClipColorLabel(state: EditorState, clipId: string, colorLabel?: string): EditorState { return updateClip(state, clipId, { colorLabel }) } export function setClipTakeIndex(state: EditorState, clipId: string, takeIndex?: number): EditorState { return updateClip(state, clipId, { takeIndex }) } export function stepClipTake(state: EditorState, clipId: string, direction: 'prev' | 'next'): EditorState { const clip = selectClips(state).find(candidate => candidate.id === clipId) const asset = clip ? selectLiveAssetForClip(state, clip) : null if (!clip || !asset?.takes || asset.takes.length <= 1) return state const currentTakeIndex = clip.takeIndex ?? asset.activeTakeIndex ?? 0 const delta = direction === 'prev' ? -1 : 1 const nextTakeIndex = Math.max(0, Math.min(asset.takes.length - 1, currentTakeIndex + delta)) if (nextTakeIndex === currentTakeIndex) return state return setClipTakeIndex(state, clipId, nextTakeIndex) } export function setClipColorCorrectionField( state: EditorState, clipId: string, field: K, value: ColorCorrection[K], ): EditorState { const clip = selectClips(state).find(candidate => candidate.id === clipId) if (!clip) return state return updateClip(state, clipId, { colorCorrection: { ...(clip.colorCorrection || DEFAULT_COLOR_CORRECTION), [field]: value, }, }) } export function resetClipColorCorrection(state: EditorState, clipId: string): EditorState { return updateClip(state, clipId, { colorCorrection: { ...DEFAULT_COLOR_CORRECTION } }) } export function setClipLetterbox(state: EditorState, clipId: string, patch: Partial): EditorState { const clip = selectClips(state).find(candidate => candidate.id === clipId) if (!clip) return state return updateClip(state, clipId, { letterbox: { ...(clip.letterbox || DEFAULT_LETTERBOX), ...patch, }, }) } export function setClipTextStyleField( state: EditorState, clipId: string, field: K, value: TextOverlayStyle[K], ): EditorState { const clip = selectClips(state).find(candidate => candidate.id === clipId) if (!clip) return state return updateClip(state, clipId, { textStyle: { ...(clip.textStyle || DEFAULT_TEXT_STYLE), [field]: value, }, }) } export function setClipTextPosition( state: EditorState, clipId: string, positionX: number, positionY: number, ): EditorState { const clip = selectClips(state).find(candidate => candidate.id === clipId) if (!clip?.textStyle) return state const clamp = (value: number) => Math.max(0, Math.min(100, value)) const round1 = (value: number) => Math.round(value * 10) / 10 return updateClip(state, clipId, { textStyle: { ...clip.textStyle, positionX: round1(clamp(positionX)), positionY: round1(clamp(positionY)), }, }) } export function addCrossDissolve(state: EditorState, leftClipId: string, rightClipId: string): EditorState { return mapClips(state, clip => { if (clip.id === leftClipId) return { ...clip, transitionOut: { type: 'dissolve', duration: 0.5 } } if (clip.id === rightClipId) return { ...clip, transitionIn: { type: 'dissolve', duration: 0.5 } } return clip }) } export function removeCrossDissolve(state: EditorState, leftClipId: string, rightClipId: string): EditorState { return mapClips(state, clip => { if (clip.id === leftClipId) return { ...clip, transitionOut: { type: 'none', duration: 0.5 } } if (clip.id === rightClipId) return { ...clip, transitionIn: { type: 'none', duration: 0.5 } } return clip }) } export function addTrack(state: EditorState, kind: 'video' | 'audio'): EditorState { const tracks = selectTracks(state) const sameKindCount = tracks.filter(track => track.kind === kind && track.type !== 'subtitle').length const newTrack: Track = { id: makeId('track'), name: kind === 'audio' ? `A${sameKindCount + 1}` : `V${sameKindCount + 1}`, muted: false, locked: false, kind, } return replaceActiveTimeline(state, timeline => ({ ...timeline, tracks: [...timeline.tracks, newTrack], })) } export function deleteTrack(state: EditorState, trackId: string): EditorState { const tracks = selectTracks(state) const trackIndex = tracks.findIndex(track => track.id === trackId) if (trackIndex < 0 || tracks.length <= 1) return state return replaceActiveTimeline(state, timeline => ({ ...timeline, clips: timeline.clips .filter(clip => clip.trackIndex !== trackIndex) .map(clip => clip.trackIndex > trackIndex ? { ...clip, trackIndex: clip.trackIndex - 1 } : clip), subtitles: (timeline.subtitles || []) .filter(subtitle => subtitle.trackIndex !== trackIndex) .map(subtitle => subtitle.trackIndex > trackIndex ? { ...subtitle, trackIndex: subtitle.trackIndex - 1 } : subtitle), tracks: timeline.tracks.filter((_, index) => index !== trackIndex), })) } export function renameTrack(state: EditorState, trackId: string, name: string): EditorState { return mapTracks(state, track => (track.id === trackId ? { ...track, name } : track)) } export function toggleTrackLock(state: EditorState, trackId: string): EditorState { return mapTracks(state, track => (track.id === trackId ? { ...track, locked: !track.locked } : track)) } export function toggleTrackMute(state: EditorState, trackId: string): EditorState { return mapTracks(state, track => (track.id === trackId ? { ...track, muted: !track.muted } : track)) } export function toggleTrackEnabled(state: EditorState, trackId: string): EditorState { return mapTracks(state, track => (track.id === trackId ? { ...track, enabled: !(track.enabled ?? true) } : track)) } export function toggleTrackSolo(state: EditorState, trackId: string): EditorState { return mapTracks(state, track => (track.id === trackId ? { ...track, solo: !track.solo } : track)) } export function toggleTrackSourcePatched(state: EditorState, trackId: string): EditorState { return mapTracks(state, track => (track.id === trackId ? { ...track, sourcePatched: !(track.sourcePatched ?? true) } : track)) } export function addSubtitleTrack(state: EditorState): EditorState { const count = selectTracks(state).filter(track => track.type === 'subtitle').length const track: Track = { id: makeId('track-sub'), name: count > 0 ? `Subtitles ${count + 1}` : 'Subtitles', muted: false, locked: false, kind: 'video', type: 'subtitle', } return replaceActiveTimeline(state, timeline => ({ ...timeline, clips: timeline.clips.map(clip => ({ ...clip, trackIndex: clip.trackIndex + 1 })), subtitles: (timeline.subtitles || []).map(subtitle => ({ ...subtitle, trackIndex: subtitle.trackIndex + 1 })), tracks: [track, ...timeline.tracks], })) } export function importSrtCues(state: EditorState, cues: SrtCue[]): EditorState { if (cues.length === 0) return state let next = state let subtitleTrackIndex = selectTracks(next).findIndex(track => track.type === 'subtitle') if (subtitleTrackIndex === -1) { next = addSubtitleTrack(next) subtitleTrackIndex = 0 } const importedSubtitles: SubtitleClip[] = cues.map(cue => ({ id: `${makeId('sub')}-${cue.index}`, text: cue.text, startTime: cue.startTime, endTime: cue.endTime, trackIndex: subtitleTrackIndex, ...(cue.color ? { style: { color: cue.color } } : {}), })) return setTimelineSubtitles(next, prev => [ ...prev.filter(subtitle => subtitle.trackIndex !== subtitleTrackIndex), ...importedSubtitles, ]) } export function setSubtitleTrackStyle(state: EditorState, trackId: string, patch: Partial): EditorState { return mapTracks(state, track => ( track.id === trackId ? { ...track, subtitleStyle: { ...(track.subtitleStyle || {}), ...patch } } : track )) } export function addSubtitle(state: EditorState, params: AddSubtitleParams): EditorState { const subtitle: SubtitleClip = { id: makeId('sub'), text: params.text || 'New subtitle', startTime: params.startTime ?? selectCurrentTime(state), endTime: params.endTime ?? (selectCurrentTime(state) + 3), trackIndex: params.trackIndex, } return replaceActiveTimeline(state, timeline => ({ ...timeline, subtitles: [...(timeline.subtitles || []), subtitle], })) } export function deleteSubtitle(state: EditorState, subtitleId: string): EditorState { const next = replaceActiveTimeline(state, timeline => ({ ...timeline, subtitles: (timeline.subtitles || []).filter(subtitle => subtitle.id !== subtitleId), })) return updateSession(next, session => ({ ...session, selection: { ...session.selection, subtitleId: session.selection.subtitleId === subtitleId ? null : session.selection.subtitleId, editingSubtitleId: session.selection.editingSubtitleId === subtitleId ? null : session.selection.editingSubtitleId, }, })) } export function updateSubtitle(state: EditorState, subtitleId: string, patch: Partial): EditorState { return replaceActiveTimeline(state, timeline => ({ ...timeline, subtitles: (timeline.subtitles || []).map(subtitle => ( subtitle.id === subtitleId ? { ...subtitle, ...patch } : subtitle )), })) } export function setSubtitleText(state: EditorState, subtitleId: string, text: string): EditorState { return updateSubtitle(state, subtitleId, { text }) } export function setSubtitleStart(state: EditorState, subtitleId: string, startTime: number): EditorState { return updateSubtitle(state, subtitleId, { startTime }) } export function setSubtitleEnd(state: EditorState, subtitleId: string, endTime: number): EditorState { return updateSubtitle(state, subtitleId, { endTime }) } export function setSubtitleStyleField( state: EditorState, subtitleId: string, field: K, value: SubtitleStyle[K], ): EditorState { const subtitle = state.editorModel.timelines.flatMap(timeline => timeline.subtitles || []).find(candidate => candidate.id === subtitleId) if (!subtitle) return state return updateSubtitle(state, subtitleId, { style: { ...(subtitle.style || {}), [field]: value, }, }) } export function addAssetToEditor(state: EditorState, asset: Asset): EditorState { return updateEditorModel(state, editorModel => ({ ...editorModel, assets: [asset, ...editorModel.assets], })) } export function addAssetsToEditor(state: EditorState, assets: Asset[]): EditorState { return updateEditorModel(state, editorModel => ({ ...editorModel, assets: [...assets, ...editorModel.assets], })) } export function insertGeneratedGapAsset(state: EditorState, params: InsertGeneratedGapAssetParams): EditorState { let next = addAssetToEditor(state, params.asset) let audioTrackIndex = -1 if (params.createAudio) { audioTrackIndex = selectTracks(next).findIndex( track => track.kind === 'audio' && !track.locked && track.sourcePatched !== false, ) if (audioTrackIndex < 0) { next = addTrack(next, 'audio') audioTrackIndex = selectTracks(next).length - 1 } } const gapDuration = params.gap.endTime - params.gap.startTime const videoClipId = makeId('clip') const audioClipId = makeId('clip-audio') const newClips: TimelineClip[] = [{ id: videoClipId, assetId: params.asset.id, type: params.asset.type === 'image' ? 'image' : 'video', startTime: params.gap.startTime, duration: gapDuration, trimStart: 0, trimEnd: 0, speed: 1, reversed: false, muted: false, volume: 1, trackIndex: params.gap.trackIndex, asset: params.asset, flipH: false, flipV: false, transitionIn: { type: 'none', duration: 0 }, transitionOut: { type: 'none', duration: 0 }, colorCorrection: { ...DEFAULT_COLOR_CORRECTION }, opacity: 100, ...(params.createAudio && audioTrackIndex >= 0 ? { linkedClipIds: [audioClipId] } : {}), }] if (params.createAudio && audioTrackIndex >= 0) { newClips.push({ id: audioClipId, assetId: params.asset.id, type: 'audio', startTime: params.gap.startTime, duration: gapDuration, trimStart: 0, trimEnd: 0, speed: 1, reversed: false, muted: false, volume: 1, trackIndex: audioTrackIndex, asset: params.asset, flipH: false, flipV: false, transitionIn: { type: 'none', duration: 0 }, transitionOut: { type: 'none', duration: 0 }, colorCorrection: { ...DEFAULT_COLOR_CORRECTION }, opacity: 100, linkedClipIds: [videoClipId], }) } return setTimelineClips(next, prev => [...prev, ...newClips]) } export function deleteAssets(state: EditorState, assetIds: string[]): EditorState { const deleteSet = new Set(assetIds) return updateEditorModel(state, editorModel => ({ ...editorModel, assets: editorModel.assets.filter(asset => !deleteSet.has(asset.id)), timelines: editorModel.timelines.map(timeline => ({ ...timeline, clips: timeline.clips.filter(clip => !clip.assetId || !deleteSet.has(clip.assetId)), })), })) } export function updateAsset(state: EditorState, assetId: string, patch: Partial): EditorState { return updateEditorModel(state, editorModel => ({ ...editorModel, assets: editorModel.assets.map(asset => (asset.id === assetId ? { ...asset, ...patch } : asset)), })) } export function setAssetBin(state: EditorState, assetId: string, binId?: string): EditorState { return updateAsset(state, assetId, { binId }) } export function createBin(state: EditorState, binId: string, name: string): EditorState { const trimmedName = name.trim() if (!trimmedName) return state return updateEditorModel(state, editorModel => ( editorModel.bins[binId] || Object.values(editorModel.bins).includes(trimmedName) ? editorModel : { ...editorModel, bins: { ...editorModel.bins, [binId]: trimmedName, }, } )) } export function assignAssetsToBin(state: EditorState, assetIds: string[], binId?: string): EditorState { const assetSet = new Set(assetIds) return updateEditorModel(state, editorModel => ({ ...editorModel, assets: editorModel.assets.map(asset => (assetSet.has(asset.id) ? { ...asset, binId } : asset)), })) } export function renameBin(state: EditorState, binId: string, newName: string): EditorState { const trimmedName = newName.trim() if (!trimmedName) return state return updateEditorModel(state, editorModel => ( !editorModel.bins[binId] || Object.entries(editorModel.bins).some(([id, name]) => id !== binId && name === trimmedName) ? editorModel : { ...editorModel, bins: { ...editorModel.bins, [binId]: trimmedName, }, } )) } export function clearBin(state: EditorState, binId: string): EditorState { return updateEditorModel(state, editorModel => ({ ...editorModel, bins: deleteBinEntry(editorModel.bins, binId), assets: editorModel.assets.map(asset => (asset.binId === binId ? { ...asset, binId: undefined } : asset)), })) } export function toggleAssetFavorite(state: EditorState, assetId: string): EditorState { const asset = selectAssetById(state, assetId) if (!asset) return state return updateAsset(state, assetId, { favorite: !asset.favorite }) } export function setAssetColorLabel(state: EditorState, assetId: string, colorLabel?: string): EditorState { const next = updateAsset(state, assetId, { colorLabel }) return replaceActiveTimeline(next, timeline => ({ ...timeline, clips: timeline.clips.map(clip => ( clip.assetId === assetId ? { ...clip, colorLabel } : clip )), })) } export function addAssetTake(state: EditorState, assetId: string, take: AssetTake): EditorState { return updateEditorModel(state, editorModel => ({ ...editorModel, assets: editorModel.assets.map(asset => { if (asset.id !== assetId) return asset const existingTakes: AssetTake[] = asset.takes || [{ path: asset.path, bigThumbnailPath: asset.bigThumbnailPath, smallThumbnailPath: asset.smallThumbnailPath, width: asset.width, height: asset.height, createdAt: asset.createdAt, }] const nextTakes = [...existingTakes, take] const newIndex = nextTakes.length - 1 return { ...asset, takes: nextTakes, activeTakeIndex: newIndex, path: take.path, bigThumbnailPath: take.bigThumbnailPath, smallThumbnailPath: take.smallThumbnailPath, width: take.width, height: take.height, } }), })) } export function deleteAssetTake(state: EditorState, assetId: string, takeIndex: number): EditorState { const next = updateEditorModel(state, editorModel => ({ ...editorModel, assets: editorModel.assets.map(asset => { if (asset.id !== assetId || !asset.takes || asset.takes.length <= 1) return asset const nextTakes = asset.takes.filter((_, index) => index !== takeIndex) const nextActiveTakeIndex = Math.max(0, Math.min(asset.activeTakeIndex ?? (asset.takes.length - 1), nextTakes.length - 1)) const activeTake = nextTakes[nextActiveTakeIndex] return { ...asset, takes: nextTakes, activeTakeIndex: nextActiveTakeIndex, path: activeTake.path, bigThumbnailPath: activeTake.bigThumbnailPath, smallThumbnailPath: activeTake.smallThumbnailPath, width: activeTake.width, height: activeTake.height, } }), })) return repointClipsAfterTakeDelete(next, assetId, takeIndex) } export function deleteClipDisplayedTake(state: EditorState, clipId: string): EditorState { const clip = selectClips(state).find(candidate => candidate.id === clipId) const asset = clip ? selectLiveAssetForClip(state, clip) : null if (!clip || !asset?.takes || asset.takes.length <= 1) return state const takeIndex = clip.takeIndex ?? asset.activeTakeIndex ?? 0 return deleteAssetTake(state, asset.id, takeIndex) } export function setAssetActiveTake(state: EditorState, assetId: string, takeIndex: number): EditorState { return updateEditorModel(state, editorModel => ({ ...editorModel, assets: editorModel.assets.map(asset => { if (asset.id !== assetId || !asset.takes || !asset.takes[takeIndex]) return asset const take = asset.takes[takeIndex] return { ...asset, activeTakeIndex: takeIndex, path: take.path, bigThumbnailPath: take.bigThumbnailPath, smallThumbnailPath: take.smallThumbnailPath, width: take.width, height: take.height, } }), })) } export function repointClipsAfterTakeDelete(state: EditorState, assetId: string, deletedTakeIndex: number): EditorState { return updateEditorModel(state, editorModel => ({ ...editorModel, timelines: editorModel.timelines.map(timeline => ({ ...timeline, clips: timeline.clips.map(clip => { if (clip.assetId !== assetId) return clip const currentIndex = clip.takeIndex if (currentIndex === undefined) return clip if (currentIndex === deletedTakeIndex) return { ...clip, takeIndex: Math.max(0, deletedTakeIndex - 1) } if (currentIndex > deletedTakeIndex) return { ...clip, takeIndex: currentIndex - 1 } return clip }), })), })) } export function selectClip(state: EditorState, clipId: string, options: SelectClipMode = {}): EditorState { const mode = options.mode ?? 'replace' const current = state.session.selection.clipIds const next = cloneClipIds(current) if (mode === 'replace') { next.clear() next.add(clipId) } else if (mode === 'toggle') { if (next.has(clipId)) next.delete(clipId) else next.add(clipId) } else { next.add(clipId) } return updateSession(state, session => ({ ...session, selection: { ...session.selection, clipIds: next, subtitleId: null, }, })) } export function selectClipsById(state: EditorState, clipIds: string[], options: SelectClipMode = {}): EditorState { const mode = options.mode ?? 'replace' const next = mode === 'replace' ? new Set() : cloneClipIds(state.session.selection.clipIds) for (const clipId of clipIds) { if (mode === 'toggle') { if (next.has(clipId)) next.delete(clipId) else next.add(clipId) } else { next.add(clipId) } } return updateSession(state, session => ({ ...session, selection: { ...session.selection, clipIds: next, subtitleId: null, }, })) } export { selectClipsById as selectClips } export function selectLinkedGroup(state: EditorState, clipId: string): EditorState { const clips = selectClips(state) const first = clips.find(clip => clip.id === clipId) if (!first) return state const linkedIds = new Set([first.id]) const queue = [first] while (queue.length > 0) { const clip = queue.pop()! if (!clip.linkedClipIds) continue for (const linkedId of clip.linkedClipIds) { if (linkedIds.has(linkedId)) continue const linkedClip = clips.find(candidate => candidate.id === linkedId) if (!linkedClip) continue linkedIds.add(linkedId) queue.push(linkedClip) } } return selectClipsById(state, [...linkedIds]) } export function selectAllClips(state: EditorState): EditorState { return updateSession(state, session => ({ ...session, selection: { ...session.selection, clipIds: new Set(selectClips(state).map(clip => clip.id)), subtitleId: null, }, })) } export function clearClipSelection(state: EditorState): EditorState { return updateSession(state, session => ({ ...session, selection: { ...session.selection, clipIds: new Set(), }, })) } export function setSelectedClipIds(state: EditorState, value: SetStateAction>): EditorState { return updateSession(state, session => ({ ...session, selection: { ...session.selection, clipIds: applyStateAction(value, session.selection.clipIds), subtitleId: null, }, })) } export function setSelectedSubtitle(state: EditorState, subtitleId?: string): EditorState { return updateSession(state, session => ({ ...session, selection: { ...session.selection, subtitleId: subtitleId ?? null, clipIds: new Set(), }, })) } export function clearSelectedSubtitle(state: EditorState): EditorState { return updateSession(state, session => ({ ...session, selection: { ...session.selection, subtitleId: null, editingSubtitleId: null, }, })) } export function setEditingSubtitleId(state: EditorState, value: SetStateAction): EditorState { return updateSession(state, session => ({ ...session, selection: { ...session.selection, editingSubtitleId: applyStateAction(value, session.selection.editingSubtitleId), }, })) } export function setSelectedGap(state: EditorState, gap?: TimelineGapSelection): EditorState { return updateSession(state, session => ({ ...session, selection: { ...session.selection, gap: gap ?? null, }, })) } export function clearSelectedGap(state: EditorState): EditorState { return setSelectedGap(state, undefined) } export function setCurrentTime(state: EditorState, time: number): EditorState { return updateSession(state, session => ({ ...session, transport: { ...session.transport, currentTime: Math.max(0, time), }, })) } export function stepCurrentTime(state: EditorState, delta: number): EditorState { return setCurrentTime(state, Math.max(0, selectCurrentTime(state) + delta)) } export function play(state: EditorState): EditorState { return updateSession(state, session => ({ ...session, transport: { ...session.transport, isPlaying: true, }, })) } export function pause(state: EditorState): EditorState { return updateSession(state, session => ({ ...session, transport: { ...session.transport, isPlaying: false, }, })) } export function togglePlayPause(state: EditorState): EditorState { return updateSession(state, session => ({ ...session, transport: { ...session.transport, isPlaying: !session.transport.isPlaying, }, })) } export function setShuttleSpeed(state: EditorState, speed: number): EditorState { return updateSession(state, session => ({ ...session, transport: { ...session.transport, shuttleSpeed: speed, }, })) } export function stopShuttle(state: EditorState): EditorState { return setShuttleSpeed(pause(state), 0) } export function setTimelineInPoint(state: EditorState, time?: number | null): EditorState { const activeTimelineId = selectActiveTimelineId(state) || '' if (!activeTimelineId) return state const current = state.session.transport.timelineInOutMap[activeTimelineId] || { inPoint: null, outPoint: null } let inPoint = time ?? null if (inPoint !== null && current.outPoint !== null && inPoint >= current.outPoint) { inPoint = current.outPoint - 0.01 } return updateSession(state, session => ({ ...session, transport: { ...session.transport, timelineInOutMap: { ...session.transport.timelineInOutMap, [activeTimelineId]: { ...current, inPoint, }, }, }, })) } export function setTimelineOutPoint(state: EditorState, time?: number | null): EditorState { const activeTimelineId = selectActiveTimelineId(state) || '' if (!activeTimelineId) return state const current = state.session.transport.timelineInOutMap[activeTimelineId] || { inPoint: null, outPoint: null } let outPoint = time ?? null if (outPoint !== null && current.inPoint !== null && outPoint <= current.inPoint) { outPoint = current.inPoint + 0.01 } return updateSession(state, session => ({ ...session, transport: { ...session.transport, timelineInOutMap: { ...session.transport.timelineInOutMap, [activeTimelineId]: { ...current, outPoint, }, }, }, })) } export function clearTimelineInPoint(state: EditorState): EditorState { return setTimelineInPoint(state, null) } export function clearTimelineOutPoint(state: EditorState): EditorState { return setTimelineOutPoint(state, null) } export function clearTimelineMarks(state: EditorState): EditorState { let next = clearTimelineInPoint(state) next = clearTimelineOutPoint(next) return updateSession(next, session => ({ ...session, transport: { ...session.transport, playingInOut: false, }, })) } export function goToInPoint(state: EditorState): EditorState { const inPoint = selectActiveTimelineInPoint(state) const target = inPoint ?? (selectClips(state).length > 0 ? Math.min(...selectClips(state).map(clip => clip.startTime)) : 0) return stopShuttle(setCurrentTime(state, target)) } export function goToOutPoint(state: EditorState): EditorState { const outPoint = selectActiveTimelineOutPoint(state) const total = selectClips(state).length > 0 ? Math.max(...selectClips(state).map(clip => clip.startTime + clip.duration)) : 0 return stopShuttle(setCurrentTime(state, outPoint ?? total)) } export function goToPrevEdit(state: EditorState, anchorTime?: number): EditorState { const current = Math.round((anchorTime ?? selectCurrentTime(state)) * 1000) / 1000 const points = new Set([0]) for (const clip of selectClips(state)) { points.add(Math.round(clip.startTime * 1000) / 1000) points.add(Math.round((clip.startTime + clip.duration) * 1000) / 1000) } const sorted = Array.from(points).sort((a, b) => a - b) let target = sorted[0] ?? 0 for (const point of sorted) { if (point < current - 0.01) target = point else break } return setCurrentTime(state, target) } export function goToNextEdit(state: EditorState, anchorTime?: number): EditorState { const current = Math.round((anchorTime ?? selectCurrentTime(state)) * 1000) / 1000 const points = new Set() for (const clip of selectClips(state)) { points.add(Math.round(clip.startTime * 1000) / 1000) points.add(Math.round((clip.startTime + clip.duration) * 1000) / 1000) } const sorted = Array.from(points).sort((a, b) => a - b) let target = sorted.length > 0 ? sorted[sorted.length - 1] : selectCurrentTime(state) for (const point of sorted) { if (point > current + 0.01) { target = point break } } return setCurrentTime(state, target) } export function togglePlayInOut(state: EditorState): EditorState { return updateSession(state, session => ({ ...session, transport: { ...session.transport, playingInOut: !session.transport.playingInOut, }, })) } export function setPlayingInOut(state: EditorState, value: boolean): EditorState { return updateSession(state, session => ({ ...session, transport: { ...session.transport, playingInOut: value, }, })) } export function setSnapEnabled(state: EditorState, enabled: boolean): EditorState { return updateSession(state, session => ({ ...session, tools: { ...session.tools, snapEnabled: enabled, }, })) } export function setZoom(state: EditorState, zoom: number): EditorState { return updateSession(state, session => ({ ...session, tools: { ...session.tools, zoom, }, })) } export function zoomIn(state: EditorState): EditorState { return setZoom(state, Math.min(selectClips(state).length > 0 ? state.session.tools.zoom * 1.25 : 1.25, 10)) } export function zoomOut(state: EditorState): EditorState { return setZoom(state, Math.max(state.session.tools.zoom / 1.25, 0.1)) } export function fitTimelineToView(state: EditorState): EditorState { return state } export function toggleSnap(state: EditorState): EditorState { return updateSession(state, session => ({ ...session, tools: { ...session.tools, snapEnabled: !session.tools.snapEnabled, }, })) } export function setActiveTool(state: EditorState, tool: ToolType): EditorState { return updateSession(state, session => ({ ...session, tools: { ...session.tools, activeTool: tool, }, })) } export function setLastTrimTool(state: EditorState, tool: ToolType): EditorState { return updateSession(state, session => ({ ...session, tools: { ...session.tools, lastTrimTool: tool, }, })) } export function setShowSourceMonitor(state: EditorState, value: boolean): EditorState { return updateSession(state, session => ({ ...session, ui: { ...session.ui, showSourceMonitor: value, }, })) } export function closeSourceMonitor(state: EditorState): EditorState { return updateSession(state, session => ({ ...session, ui: { ...session.ui, showSourceMonitor: false, activeFocusArea: 'timeline', hasSourceAsset: false, }, })) } export function setShowPropertiesPanel(state: EditorState, value: boolean): EditorState { return updateSession(state, session => ({ ...session, ui: { ...session.ui, showPropertiesPanel: value, }, })) } export function setShowEffectsBrowser(state: EditorState, value: boolean): EditorState { return updateSession(state, session => ({ ...session, ui: { ...session.ui, showEffectsBrowser: value, }, })) } export function setActiveFocusArea(state: EditorState, area: 'source' | 'timeline'): EditorState { return updateSession(state, session => ({ ...session, ui: { ...session.ui, activeFocusArea: area, }, })) } export function setSourceSplitPercent(state: EditorState, percent: number): EditorState { return updateSession(state, session => ({ ...session, ui: { ...session.ui, sourceSplitPercent: percent, }, })) } export function setHasSourceAsset(state: EditorState, value: boolean): EditorState { return updateSession(state, session => ({ ...session, ui: { ...session.ui, hasSourceAsset: value, }, })) } export function openImportTimelineModal(state: EditorState): EditorState { return updateSession(state, session => ({ ...session, ui: { ...session.ui, showImportTimelineModal: true, }, })) } export function closeImportTimelineModal(state: EditorState): EditorState { return updateSession(state, session => ({ ...session, ui: { ...session.ui, showImportTimelineModal: false, }, })) } export function openExportModal(state: EditorState): EditorState { return updateSession(state, session => ({ ...session, ui: { ...session.ui, showExportModal: true, }, })) } export function closeExportModal(state: EditorState): EditorState { return updateSession(state, session => ({ ...session, ui: { ...session.ui, showExportModal: false, }, })) } export function setOpenTimelineIds(state: EditorState, ids: Set): EditorState { return updateSession(state, session => ({ ...session, ui: { ...session.ui, openTimelineIds: new Set(ids), }, })) } export function openTimelineTab(state: EditorState, timelineId: string): EditorState { return setOpenTimelineIds(state, new Set([...state.session.ui.openTimelineIds, timelineId])) } export function closeTimelineTab(state: EditorState, timelineId: string): EditorState { return setOpenTimelineIds(state, new Set([...state.session.ui.openTimelineIds].filter(id => id !== timelineId))) } export function startTimelineRename(state: EditorState, timelineId: string, source: 'tab' | 'panel'): EditorState { const timeline = state.editorModel.timelines.find(candidate => candidate.id === timelineId) return updateSession(state, session => ({ ...session, ui: { ...session.ui, renamingTimelineId: timelineId, renameValue: timeline?.name || '', renameSource: source, }, })) } export function setTimelineRenameValue(state: EditorState, value: string): EditorState { return updateSession(state, session => ({ ...session, ui: { ...session.ui, renameValue: value, }, })) } export function commitTimelineRename(state: EditorState): EditorState { const { renamingTimelineId, renameValue } = state.session.ui if (!renamingTimelineId || !renameValue.trim()) return cancelTimelineRename(state) return cancelTimelineRename(renameTimeline(state, renamingTimelineId, renameValue.trim())) } export function cancelTimelineRename(state: EditorState): EditorState { return updateSession(state, session => ({ ...session, ui: { ...session.ui, renamingTimelineId: null, renameValue: '', }, })) } export function setLayout(state: EditorState, layout: EditorLayout): EditorState { return updateSession(state, session => ({ ...session, ui: { ...session.ui, layout, }, })) } export function resetLayout(state: EditorState): EditorState { return setLayout(state, { leftPanelWidth: 288, rightPanelWidth: 256, timelineHeight: 224, assetsHeight: 0, }) } export function setSubtitleTrackStyleEditorTrack(state: EditorState, trackIdx?: number): EditorState { return updateSession(state, session => ({ ...session, ui: { ...session.ui, subtitleTrackStyleIdx: trackIdx ?? null, }, })) } export function updateSubtitleTrackStyle( state: EditorState, trackIndex: number, patch: Partial, ): EditorState { return mapTracks(state, (track, index) => ( index === trackIndex ? { ...track, subtitleStyle: { ...track.subtitleStyle, ...patch, }, } : track )) } export function clearSubtitleOverridesForTrack(state: EditorState, trackIndex: number): EditorState { return replaceActiveTimeline(state, timeline => ({ ...timeline, subtitles: (timeline.subtitles || []).map(subtitle => ( subtitle.trackIndex === trackIndex ? { ...subtitle, style: undefined } : subtitle )), })) } export function copySelection(state: EditorState): EditorState { const selectedIds = selectSelectedClipIds(state) const clips = selectClips(state).filter(clip => selectedIds.has(clip.id)) return updateSession(state, session => ({ ...session, clipboard: { kind: 'clips', clips, copiedFromTimelineId: selectActiveTimelineId(state), }, })) } export function cutSelection(state: EditorState): EditorState { const copied = copySelection(state) return deleteClips(copied, [...selectSelectedClipIds(copied)]) } export function pasteSelection(state: EditorState, atTime?: number): EditorState { if (!selectCanUseClipboard(state)) return state const clipboard = state.session.clipboard const earliestStart = clipboard.clips.reduce((min, clip) => Math.min(min, clip.startTime), Infinity) const pasteAt = atTime ?? selectCurrentTime(state) const duplicates = clipboard.clips .filter(clip => !clip.assetId || Boolean(selectAssetById(state, clip.assetId))) .map(clip => ({ ...clip, id: makeId('clip'), startTime: pasteAt + (clip.startTime - earliestStart), linkedClipIds: undefined, })) if (duplicates.length === 0) return state let next = replaceActiveTimeline(state, timeline => ({ ...timeline, clips: [...timeline.clips, ...duplicates], })) next = setSelectedClipIds(next, new Set(duplicates.map(clip => clip.id))) return next } export function undo(state: EditorState): EditorState { const previous = state.history.undoStack[state.history.undoStack.length - 1] if (!previous) return state const currentSnapshot = getUndoSnapshot(state) const next = applyUndoSnapshot(state, previous) if (equalUndoSnapshot(previous, currentSnapshot)) { return { ...next, history: { undoStack: state.history.undoStack.slice(0, -1), redoStack: state.history.redoStack, }, } } return { ...next, history: { undoStack: state.history.undoStack.slice(0, -1), redoStack: [...state.history.redoStack, currentSnapshot], }, } } export function redo(state: EditorState): EditorState { const next = state.history.redoStack[state.history.redoStack.length - 1] if (!next) return state const currentSnapshot = getUndoSnapshot(state) const restored = applyUndoSnapshot(state, next) if (equalUndoSnapshot(next, currentSnapshot)) { return { ...restored, history: { undoStack: state.history.undoStack, redoStack: state.history.redoStack.slice(0, -1), }, } } return { ...restored, history: { undoStack: [...state.history.undoStack, currentSnapshot], redoStack: state.history.redoStack.slice(0, -1), }, } } export function startClipRegeneration(state: EditorState, assetId: string, clipId?: string): EditorState { let next = updateSession(state, session => ({ ...session, regeneration: { ...session.regeneration, regeneratingAssetId: assetId, regeneratingClipId: clipId ?? null, preError: null, }, })) if (clipId) { next = updateClip(next, clipId, { isRegenerating: true }) } return next } export function cancelClipRegeneration(state: EditorState): EditorState { const clipId = state.session.regeneration.regeneratingClipId let next = updateSession(state, session => ({ ...session, regeneration: { ...session.regeneration, regeneratingAssetId: null, regeneratingClipId: null, }, })) if (clipId) next = updateClip(next, clipId, { isRegenerating: false }) return next } export function setClipRegenerating(state: EditorState, clipId: string, value: boolean): EditorState { return updateClip(state, clipId, { isRegenerating: value }) } export function applyGeneratedTake(state: EditorState, assetId: string, take: AssetTake, clipId?: string): EditorState { let next = addAssetTake(state, assetId, take) const asset = selectAssetById(next, assetId) const takeIndex = asset?.takes ? asset.takes.length - 1 : undefined if (clipId && takeIndex !== undefined) { next = updateClip(next, clipId, { takeIndex, isRegenerating: false, }) } return updateSession(next, session => ({ ...session, regeneration: { regeneratingAssetId: null, regeneratingClipId: null, preError: null, }, })) } export function clearRegenerationError(state: EditorState): EditorState { return updateSession(state, session => ({ ...session, regeneration: { ...session.regeneration, preError: null, }, })) } export function setRegenerationPreError(state: EditorState, message: string | null): EditorState { return updateSession(state, session => ({ ...session, regeneration: { ...session.regeneration, preError: message, }, })) } export function failClipRegeneration(state: EditorState, message: string): EditorState { return setRegenerationPreError(cancelClipRegeneration(state), message) } export function initializeFromProject(project: Project, layout?: EditorLayout): EditorState { return createInitialEditorState(getEditorModel(project), layout) } export function deriveProjectPatch(state: EditorState): EditorModel { return state.editorModel } export function commitToProject(state: EditorState, baseProject: Project): Project { return updatedProject(baseProject, state.editorModel) } export function setDirty(state: EditorState, dirty: boolean): EditorState { return { ...state, projectSync: { ...state.projectSync, dirty, }, } } export function markProjectDirty(state: EditorState): EditorState { return setDirty(state, true) } export function clearProjectDirty(state: EditorState): EditorState { return setDirty(state, false) } export { markProjectDirty as markDirty } export { clearProjectDirty as clearDirty } export function applyPendingClipTakeUpdate( editorModel: EditorModel, pendingUpdate: PendingClipTakeUpdate | null, ): EditorModel { if (!pendingUpdate) return editorModel return { ...editorModel, timelines: editorModel.timelines.map(timeline => ({ ...timeline, clips: timeline.clips.map(clip => { if (clip.assetId !== pendingUpdate.assetId) return clip if (!pendingUpdate.clipIds.includes(clip.id)) return clip return { ...clip, takeIndex: pendingUpdate.newTakeIndex } }), })), } }