Files
LTX-DeskWEB/frontend/views/editor/editor-actions.ts
T

2652 lines
80 KiB
TypeScript

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<TextOverlayStyle>
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<string>): Set<string> {
return new Set(ids)
}
export function applyStateAction<T>(value: SetStateAction<T>, 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<TextOverlayStyle>, 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<Pick<Timeline, 'tracks' | 'clips' | 'subtitles'>>,
): EditorState {
return replaceActiveTimeline(state, timeline => ({
...timeline,
...snapshot,
}))
}
export function setTimelineClips(state: EditorState, value: SetStateAction<TimelineClip[]>): EditorState {
return replaceActiveTimeline(state, timeline => ({
...timeline,
clips: applyStateAction(value, timeline.clips),
}))
}
export function setTimelineTracks(state: EditorState, value: SetStateAction<Track[]>): EditorState {
return replaceActiveTimeline(state, timeline => ({
...timeline,
tracks: applyStateAction(value, timeline.tracks),
}))
}
export function setTimelineSubtitles(state: EditorState, value: SetStateAction<SubtitleClip[]>): 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<string, Asset>()
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<number, string>()
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<string>(
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<string>()
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<TimelineClip>): 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<K extends keyof ColorCorrection>(
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<LetterboxSettings>): 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<K extends keyof TextOverlayStyle>(
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<SubtitleStyle>): 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<SubtitleClip>): 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<K extends keyof SubtitleStyle>(
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<Asset>): 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<string>() : 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<Set<string>>): 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<string | null>): 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<number>([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<number>()
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<string>): 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<SubtitleStyle>,
): 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 }
}),
})),
}
}