Create standalone browser port of LTX Athena with authenticated native API transport
This commit is contained in:
commit
1333b0c36b
202 files changed
+63054
No files matched your search
@@ -0,0 +1,940 @@
|
||||
import {
|
||||
COLOR_LABELS,
|
||||
CUT_POINT_TOLERANCE,
|
||||
type ToolType,
|
||||
} from './video-editor-utils'
|
||||
import {
|
||||
DEFAULT_SUBTITLE_STYLE,
|
||||
DEFAULT_TRACKS,
|
||||
} from '../../types/project-model'
|
||||
import type {
|
||||
Asset,
|
||||
SubtitleClip,
|
||||
SubtitleStyle,
|
||||
Timeline,
|
||||
TimelineClip,
|
||||
Track,
|
||||
} from '../../types/project-model'
|
||||
import {
|
||||
namedResolutionDisplayName,
|
||||
namedResolutionTier,
|
||||
} from '../../lib/video-resolution'
|
||||
import type {
|
||||
AssetListFilters,
|
||||
AssetTakeView,
|
||||
ClipCapabilities,
|
||||
ClipDimensions,
|
||||
ClipMetadata,
|
||||
ClipResolutionInfo,
|
||||
EditorModel,
|
||||
EditorState,
|
||||
KeyboardCommandContext,
|
||||
MenuState,
|
||||
OrderedTrackEntry,
|
||||
SelectedClipPropertiesModel,
|
||||
SelectedSubtitleEditorModel,
|
||||
SubtitleTrackStyleEditorModel,
|
||||
TimelineCutPoint,
|
||||
TimelineGapSelection,
|
||||
TimelineInOutRange,
|
||||
TimelineListItem,
|
||||
} from './editor-state'
|
||||
|
||||
export interface ExportLetterbox {
|
||||
ratio: number
|
||||
color: string
|
||||
opacity: number
|
||||
}
|
||||
|
||||
export interface ExportClipData {
|
||||
path: string
|
||||
type: string
|
||||
startTime: number
|
||||
duration: number
|
||||
trimStart: number
|
||||
speed: number
|
||||
reversed: boolean
|
||||
flipH: boolean
|
||||
flipV: boolean
|
||||
opacity: number
|
||||
trackIndex: number
|
||||
muted: boolean
|
||||
volume: number
|
||||
}
|
||||
|
||||
export interface ExportSubtitleData {
|
||||
text: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
style: SubtitleStyle
|
||||
}
|
||||
|
||||
export interface ExportModalModel {
|
||||
timeline: Timeline | null
|
||||
clips: TimelineClip[]
|
||||
tracks: Track[]
|
||||
exportClips: ExportClipData[]
|
||||
subtitleData: ExportSubtitleData[]
|
||||
letterbox: ExportLetterbox | null
|
||||
}
|
||||
|
||||
export interface ClipAudioControlsModel {
|
||||
targetClipId: string
|
||||
muted: boolean
|
||||
volume: number
|
||||
}
|
||||
|
||||
const EMPTY_CLIPS: TimelineClip[] = []
|
||||
const EMPTY_SUBTITLES: SubtitleClip[] = []
|
||||
const DEFAULT_TIMELINE_TRACKS: Track[] = DEFAULT_TRACKS.map(track => ({ ...track }))
|
||||
const EMPTY_TIMELINE_IN_OUT_RANGE: TimelineInOutRange = { inPoint: null, outPoint: null }
|
||||
const LETTERBOX_RATIO_MAP: Record<string, number> = {
|
||||
'2.35:1': 2.35,
|
||||
'2.39:1': 2.39,
|
||||
'2.76:1': 2.76,
|
||||
'1.85:1': 1.85,
|
||||
'4:3': 4 / 3,
|
||||
}
|
||||
|
||||
function parseResolutionHeight(resolution?: string): number {
|
||||
const match = resolution?.match(/(\d+)/)
|
||||
return match ? parseInt(match[1], 10) : 0
|
||||
}
|
||||
|
||||
export function getActiveTimelineFromEditorModel(editorModel: EditorModel): Timeline | null {
|
||||
if (editorModel.timelines.length === 0) return null
|
||||
return editorModel.timelines.find(timeline => timeline.id === editorModel.activeTimelineId)
|
||||
|| editorModel.timelines[0]
|
||||
}
|
||||
|
||||
export function selectEditorModel(state: EditorState): EditorModel {
|
||||
return state.editorModel
|
||||
}
|
||||
|
||||
export function selectSession(state: EditorState): EditorState['session'] {
|
||||
return state.session
|
||||
}
|
||||
|
||||
export function selectHistory(state: EditorState): EditorState['history'] {
|
||||
return state.history
|
||||
}
|
||||
|
||||
export function selectCanUndo(state: EditorState): boolean {
|
||||
return state.history.undoStack.length > 0
|
||||
}
|
||||
|
||||
export function selectCanRedo(state: EditorState): boolean {
|
||||
return state.history.redoStack.length > 0
|
||||
}
|
||||
|
||||
export function selectAssets(state: EditorState): Asset[] {
|
||||
return state.editorModel.assets
|
||||
}
|
||||
|
||||
export function selectTimelines(state: EditorState): Timeline[] {
|
||||
return state.editorModel.timelines
|
||||
}
|
||||
|
||||
export function selectActiveTimelineId(state: EditorState): string | null {
|
||||
return state.editorModel.activeTimelineId
|
||||
}
|
||||
|
||||
export function selectActiveTimeline(state: EditorState): Timeline | null {
|
||||
return getActiveTimelineFromEditorModel(state.editorModel)
|
||||
}
|
||||
|
||||
export function selectTimelineById(state: EditorState, timelineId: string): Timeline | undefined {
|
||||
return state.editorModel.timelines.find(timeline => timeline.id === timelineId)
|
||||
}
|
||||
|
||||
export function selectClipById(state: EditorState, clipId: string | null | undefined): TimelineClip | undefined {
|
||||
if (!clipId) return undefined
|
||||
return selectClips(state).find(clip => clip.id === clipId)
|
||||
}
|
||||
|
||||
export function selectClips(state: EditorState): TimelineClip[] {
|
||||
return selectActiveTimeline(state)?.clips || EMPTY_CLIPS
|
||||
}
|
||||
|
||||
export function selectTracks(state: EditorState): Track[] {
|
||||
return selectActiveTimeline(state)?.tracks || DEFAULT_TIMELINE_TRACKS
|
||||
}
|
||||
|
||||
export function selectSubtitles(state: EditorState): SubtitleClip[] {
|
||||
return selectActiveTimeline(state)?.subtitles || EMPTY_SUBTITLES
|
||||
}
|
||||
|
||||
export function selectActiveTimelineClips(state: EditorState, timelineId?: string): TimelineClip[] {
|
||||
const timeline = timelineId ? selectTimelineById(state, timelineId) : selectActiveTimeline(state)
|
||||
return timeline?.clips || EMPTY_CLIPS
|
||||
}
|
||||
|
||||
export function selectActiveTimelineTracks(state: EditorState, timelineId?: string): Track[] {
|
||||
const timeline = timelineId ? selectTimelineById(state, timelineId) : selectActiveTimeline(state)
|
||||
return timeline?.tracks || DEFAULT_TIMELINE_TRACKS
|
||||
}
|
||||
|
||||
export function selectActiveTimelineSubtitles(state: EditorState, timelineId?: string): SubtitleClip[] {
|
||||
const timeline = timelineId ? selectTimelineById(state, timelineId) : selectActiveTimeline(state)
|
||||
return timeline?.subtitles || EMPTY_SUBTITLES
|
||||
}
|
||||
|
||||
export function selectSelectedClipIds(state: EditorState): Set<string> {
|
||||
return state.session.selection.clipIds
|
||||
}
|
||||
|
||||
export function selectSelectedSubtitleId(state: EditorState): string | null {
|
||||
return state.session.selection.subtitleId
|
||||
}
|
||||
|
||||
export function selectEditingSubtitleId(state: EditorState): string | null {
|
||||
return state.session.selection.editingSubtitleId
|
||||
}
|
||||
|
||||
export function selectSelectedGap(state: EditorState): TimelineGapSelection | null {
|
||||
return state.session.selection.gap
|
||||
}
|
||||
|
||||
export function selectSelectedClips(state: EditorState): TimelineClip[] {
|
||||
const selectedIds = selectSelectedClipIds(state)
|
||||
return selectClips(state).filter(clip => selectedIds.has(clip.id))
|
||||
}
|
||||
|
||||
export function selectSingleSelectedClip(state: EditorState): TimelineClip | null {
|
||||
const selectedIds = [...selectSelectedClipIds(state)]
|
||||
if (selectedIds.length !== 1) return null
|
||||
return selectClips(state).find(clip => clip.id === selectedIds[0]) ?? null
|
||||
}
|
||||
|
||||
export function selectSelectedLinkedGroup(state: EditorState, clipId: string): TimelineClip[] {
|
||||
const clips = selectClips(state)
|
||||
const first = clips.find(clip => clip.id === clipId)
|
||||
if (!first) return []
|
||||
|
||||
const linkedGroup = new Set([first.id])
|
||||
const queue = [first.id]
|
||||
while (queue.length > 0) {
|
||||
const id = queue.pop()!
|
||||
const clip = clips.find(candidate => candidate.id === id)
|
||||
if (!clip?.linkedClipIds) continue
|
||||
for (const linkedId of clip.linkedClipIds) {
|
||||
if (!linkedGroup.has(linkedId) && clips.some(candidate => candidate.id === linkedId)) {
|
||||
linkedGroup.add(linkedId)
|
||||
queue.push(linkedId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return clips.filter(clip => linkedGroup.has(clip.id))
|
||||
}
|
||||
|
||||
export function selectSelectedClipForProperties(state: EditorState): TimelineClip | null {
|
||||
const clips = selectClips(state)
|
||||
const selectedClipIds = selectSelectedClipIds(state)
|
||||
if (selectedClipIds.size === 0) return null
|
||||
if (selectedClipIds.size === 1) return clips.find(clip => clip.id === [...selectedClipIds][0]) ?? null
|
||||
|
||||
const selectedIds = [...selectedClipIds]
|
||||
const first = clips.find(clip => clip.id === selectedIds[0])
|
||||
if (!first) return null
|
||||
|
||||
const linkedGroup = selectSelectedLinkedGroup(state, first.id)
|
||||
const linkedIdSet = new Set(linkedGroup.map(clip => clip.id))
|
||||
const allInGroup = selectedIds.every(id => linkedIdSet.has(id)) && linkedIdSet.size === selectedClipIds.size
|
||||
if (!allInGroup) return null
|
||||
|
||||
return linkedGroup.find(clip => clip.type === 'video' || clip.type === 'image') ?? first
|
||||
}
|
||||
|
||||
export function selectClipAudioControls(
|
||||
state: EditorState,
|
||||
clipId: string | null | undefined,
|
||||
): ClipAudioControlsModel | null {
|
||||
const clip = selectClipById(state, clipId)
|
||||
if (!clip) return null
|
||||
|
||||
if (clip.type === 'audio') {
|
||||
return {
|
||||
targetClipId: clip.id,
|
||||
muted: clip.muted || false,
|
||||
volume: clip.volume ?? 1,
|
||||
}
|
||||
}
|
||||
|
||||
const linkedAudioClip = (clip.linkedClipIds || [])
|
||||
.map(linkedId => selectClipById(state, linkedId))
|
||||
.find(candidate => candidate?.type === 'audio')
|
||||
|
||||
const targetClip = linkedAudioClip ?? clip
|
||||
return {
|
||||
targetClipId: targetClip.id,
|
||||
muted: targetClip.muted || false,
|
||||
volume: targetClip.volume ?? 1,
|
||||
}
|
||||
}
|
||||
|
||||
export function selectSelectedClipAudioControls(state: EditorState): ClipAudioControlsModel | null {
|
||||
const clip = selectSelectedClipForProperties(state)
|
||||
if (!clip) return null
|
||||
return selectClipAudioControls(state, clip.id)
|
||||
}
|
||||
|
||||
export function selectSelectedSubtitle(state: EditorState): SubtitleClip | null {
|
||||
const subtitleId = selectSelectedSubtitleId(state)
|
||||
if (!subtitleId) return null
|
||||
return selectSubtitles(state).find(subtitle => subtitle.id === subtitleId) ?? null
|
||||
}
|
||||
|
||||
export function selectOpenTimelines(state: EditorState): Timeline[] {
|
||||
const openIds = state.session.ui.openTimelineIds
|
||||
return state.editorModel.timelines.filter(timeline => openIds.has(timeline.id))
|
||||
}
|
||||
|
||||
export function selectOpenTimelineIds(state: EditorState): Set<string> {
|
||||
return state.session.ui.openTimelineIds
|
||||
}
|
||||
|
||||
export function selectTimelineListItems(state: EditorState): TimelineListItem[] {
|
||||
const activeTimelineId = selectActiveTimelineId(state)
|
||||
const openTimelineIds = state.session.ui.openTimelineIds
|
||||
const renamingTimelineId = state.session.ui.renamingTimelineId
|
||||
return state.editorModel.timelines.map(timeline => ({
|
||||
timeline,
|
||||
isActive: timeline.id === activeTimelineId,
|
||||
isOpen: openTimelineIds.has(timeline.id),
|
||||
isRenaming: renamingTimelineId === timeline.id,
|
||||
clipCount: timeline.clips.length,
|
||||
duration: timeline.clips.reduce((max, clip) => Math.max(max, clip.startTime + clip.duration), 0),
|
||||
}))
|
||||
}
|
||||
|
||||
export function selectCurrentTime(state: EditorState): number {
|
||||
return state.session.transport.currentTime
|
||||
}
|
||||
|
||||
export function selectIsPlaying(state: EditorState): boolean {
|
||||
return state.session.transport.isPlaying
|
||||
}
|
||||
|
||||
export function selectShuttleSpeed(state: EditorState): number {
|
||||
return state.session.transport.shuttleSpeed
|
||||
}
|
||||
|
||||
export function selectPlayingInOut(state: EditorState): boolean {
|
||||
return state.session.transport.playingInOut
|
||||
}
|
||||
|
||||
export function selectTimelineInOutMap(state: EditorState): Record<string, TimelineInOutRange> {
|
||||
return state.session.transport.timelineInOutMap
|
||||
}
|
||||
|
||||
export function selectActiveTimelineInOutRange(state: EditorState): TimelineInOutRange {
|
||||
const activeTimelineId = selectActiveTimelineId(state) || ''
|
||||
return state.session.transport.timelineInOutMap[activeTimelineId] || EMPTY_TIMELINE_IN_OUT_RANGE
|
||||
}
|
||||
|
||||
export function selectActiveTimelineInPoint(state: EditorState): number | null {
|
||||
return selectActiveTimelineInOutRange(state).inPoint
|
||||
}
|
||||
|
||||
export function selectActiveTimelineOutPoint(state: EditorState): number | null {
|
||||
return selectActiveTimelineInOutRange(state).outPoint
|
||||
}
|
||||
|
||||
export function selectTotalDuration(state: EditorState): number {
|
||||
return Math.max(
|
||||
selectClips(state).reduce((max, clip) => Math.max(max, clip.startTime + clip.duration), 0),
|
||||
30,
|
||||
)
|
||||
}
|
||||
|
||||
export function selectZoom(state: EditorState): number {
|
||||
return state.session.tools.zoom
|
||||
}
|
||||
|
||||
export function selectPixelsPerSecond(state: EditorState): number {
|
||||
return 100 * selectZoom(state)
|
||||
}
|
||||
|
||||
export function selectSnapEnabled(state: EditorState): boolean {
|
||||
return state.session.tools.snapEnabled
|
||||
}
|
||||
|
||||
export function selectActiveTool(state: EditorState): ToolType {
|
||||
return state.session.tools.activeTool
|
||||
}
|
||||
|
||||
export function selectLastTrimTool(state: EditorState): ToolType {
|
||||
return state.session.tools.lastTrimTool
|
||||
}
|
||||
|
||||
export function selectShowSourceMonitor(state: EditorState): boolean {
|
||||
return state.session.ui.showSourceMonitor
|
||||
}
|
||||
|
||||
export function selectShowImportTimelineModal(state: EditorState): boolean {
|
||||
return state.session.ui.showImportTimelineModal
|
||||
}
|
||||
|
||||
export function selectShowExportModal(state: EditorState): boolean {
|
||||
return state.session.ui.showExportModal
|
||||
}
|
||||
|
||||
export function selectShowPropertiesPanel(state: EditorState): boolean {
|
||||
return state.session.ui.showPropertiesPanel
|
||||
}
|
||||
|
||||
export function selectShowEffectsBrowser(state: EditorState): boolean {
|
||||
return state.session.ui.showEffectsBrowser
|
||||
}
|
||||
|
||||
export function selectActiveFocusArea(state: EditorState): 'source' | 'timeline' {
|
||||
return state.session.ui.activeFocusArea
|
||||
}
|
||||
|
||||
export function selectSourceSplitPercent(state: EditorState): number {
|
||||
return state.session.ui.sourceSplitPercent
|
||||
}
|
||||
|
||||
export function selectHasSourceAsset(state: EditorState): boolean {
|
||||
return state.session.ui.hasSourceAsset
|
||||
}
|
||||
|
||||
export function selectTimelineRenameState(state: EditorState): Pick<EditorState['session']['ui'], 'renamingTimelineId' | 'renameValue' | 'renameSource'> {
|
||||
return {
|
||||
renamingTimelineId: state.session.ui.renamingTimelineId,
|
||||
renameValue: state.session.ui.renameValue,
|
||||
renameSource: state.session.ui.renameSource,
|
||||
}
|
||||
}
|
||||
|
||||
export function selectLayout(state: EditorState) {
|
||||
return state.session.ui.layout
|
||||
}
|
||||
|
||||
export function selectSubtitleTrackStyleIdx(state: EditorState): number | null {
|
||||
return state.session.ui.subtitleTrackStyleIdx
|
||||
}
|
||||
|
||||
export function selectSubtitleTrackStyleEditorModel(state: EditorState): SubtitleTrackStyleEditorModel | null {
|
||||
const trackIndex = selectSubtitleTrackStyleIdx(state)
|
||||
if (trackIndex === null) return null
|
||||
const track = selectTracks(state)[trackIndex]
|
||||
if (!track || track.type !== 'subtitle') return null
|
||||
return {
|
||||
trackIndex,
|
||||
track,
|
||||
style: {
|
||||
...DEFAULT_SUBTITLE_STYLE,
|
||||
...(track.subtitleStyle || {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function selectGapGenerateMode(state: EditorState): EditorState['session']['ui']['gapGenerateMode'] {
|
||||
return state.session.ui.gapGenerateMode
|
||||
}
|
||||
|
||||
export function selectAssetById(state: EditorState, assetId: string | null | undefined): Asset | undefined {
|
||||
if (!assetId) return undefined
|
||||
return state.editorModel.assets.find(asset => asset.id === assetId)
|
||||
}
|
||||
|
||||
function findAssetById(assets: Asset[], assetId: string | null | undefined): Asset | undefined {
|
||||
if (!assetId) return undefined
|
||||
return assets.find(asset => asset.id === assetId)
|
||||
}
|
||||
|
||||
export function selectLiveAssetForClipFromAssets(
|
||||
assets: Asset[],
|
||||
clip: TimelineClip | null | undefined,
|
||||
): Asset | null | undefined {
|
||||
if (!clip) return null
|
||||
if (!clip.assetId) return clip.asset
|
||||
return findAssetById(assets, clip.assetId) || clip.asset
|
||||
}
|
||||
|
||||
export function selectLiveAssetForClip(state: EditorState, clip: TimelineClip | null | undefined): Asset | null | undefined {
|
||||
return selectLiveAssetForClipFromAssets(selectAssets(state), clip)
|
||||
}
|
||||
|
||||
export function selectClipPathFromAssets(assets: Asset[], clip: TimelineClip | null | undefined): string {
|
||||
if (!clip) return ''
|
||||
const liveAsset = selectLiveAssetForClipFromAssets(assets, clip)
|
||||
let src = clip.asset?.path || ''
|
||||
if (liveAsset) {
|
||||
if (liveAsset.takes && liveAsset.takes.length > 0 && clip.takeIndex !== undefined) {
|
||||
const idx = Math.max(0, Math.min(clip.takeIndex, liveAsset.takes.length - 1))
|
||||
src = liveAsset.takes[idx].path
|
||||
} else {
|
||||
src = liveAsset.path
|
||||
}
|
||||
}
|
||||
return src || ''
|
||||
}
|
||||
|
||||
export function selectClipPath(state: EditorState, clip: TimelineClip | null | undefined): string {
|
||||
return selectClipPathFromAssets(selectAssets(state), clip)
|
||||
}
|
||||
|
||||
export function selectClipDimensionsFromAssets(assets: Asset[], clip: TimelineClip): ClipDimensions | null {
|
||||
if (clip.type === 'audio') return null
|
||||
const liveAsset = selectLiveAssetForClipFromAssets(assets, clip)
|
||||
if (!liveAsset) return null
|
||||
|
||||
const takeIndex = clip.takeIndex ?? liveAsset.activeTakeIndex
|
||||
if (liveAsset.takes && liveAsset.takes.length > 0 && takeIndex !== undefined) {
|
||||
const idx = Math.max(0, Math.min(takeIndex, liveAsset.takes.length - 1))
|
||||
const take = liveAsset.takes[idx]
|
||||
if (take.width && take.height) {
|
||||
return { width: take.width, height: take.height }
|
||||
}
|
||||
}
|
||||
|
||||
if (liveAsset.width && liveAsset.height) {
|
||||
return { width: liveAsset.width, height: liveAsset.height }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function selectClipDimensions(state: EditorState, clip: TimelineClip): ClipDimensions | null {
|
||||
return selectClipDimensionsFromAssets(selectAssets(state), clip)
|
||||
}
|
||||
|
||||
export function selectClipResolutionFromAssets(assets: Asset[], clip: TimelineClip): ClipResolutionInfo | null {
|
||||
const dims = selectClipDimensionsFromAssets(assets, clip)
|
||||
if (!dims) return null
|
||||
const tier = namedResolutionTier(Math.min(dims.width, dims.height))
|
||||
const displayName = namedResolutionDisplayName(tier)
|
||||
const color = tier >= 2160 ? '#22c55e' : tier >= 1080 ? '#3b82f6' : tier >= 720 ? '#f59e0b' : '#ef4444'
|
||||
return {
|
||||
label: `${displayName} (${dims.width}x${dims.height})`,
|
||||
color,
|
||||
height: dims.height,
|
||||
displayName,
|
||||
}
|
||||
}
|
||||
|
||||
export function selectClipResolution(state: EditorState, clip: TimelineClip): ClipResolutionInfo | null {
|
||||
return selectClipResolutionFromAssets(selectAssets(state), clip)
|
||||
}
|
||||
|
||||
export function selectClipColorLabel(state: EditorState, clip: TimelineClip): string | undefined {
|
||||
const liveAsset = selectLiveAssetForClip(state, clip)
|
||||
const label = clip.colorLabel || liveAsset?.colorLabel || clip.asset?.colorLabel
|
||||
return COLOR_LABELS.find((color) => color.id === label)?.id
|
||||
}
|
||||
|
||||
export function selectClipMaxDurationFromAssets(assets: Asset[], clip: TimelineClip): number {
|
||||
const liveAsset = selectLiveAssetForClipFromAssets(assets, clip)
|
||||
if (clip.type !== 'video' || !liveAsset?.duration) return Infinity
|
||||
const mediaDuration = liveAsset.duration
|
||||
const usableMedia = mediaDuration - clip.trimStart - clip.trimEnd
|
||||
return Math.max(0.5, usableMedia / clip.speed)
|
||||
}
|
||||
|
||||
export function selectClipMaxDuration(state: EditorState, clip: TimelineClip): number {
|
||||
return selectClipMaxDurationFromAssets(selectAssets(state), clip)
|
||||
}
|
||||
|
||||
export function selectOrderedTracks(state: EditorState): OrderedTrackEntry[] {
|
||||
const tracks = selectTracks(state)
|
||||
const videoTracks: { track: Track; realIndex: number }[] = []
|
||||
const audioTracks: { track: Track; realIndex: number }[] = []
|
||||
const subtitleTracks: { track: Track; realIndex: number }[] = []
|
||||
|
||||
tracks.forEach((track, realIndex) => {
|
||||
if (track.type === 'subtitle') subtitleTracks.push({ track, realIndex })
|
||||
else if (track.kind === 'audio') audioTracks.push({ track, realIndex })
|
||||
else videoTracks.push({ track, realIndex })
|
||||
})
|
||||
|
||||
videoTracks.reverse()
|
||||
return [...subtitleTracks, ...videoTracks, ...audioTracks].map((entry, displayRow) => ({
|
||||
...entry,
|
||||
displayRow,
|
||||
}))
|
||||
}
|
||||
|
||||
export function selectTrackDisplayRows(state: EditorState): Map<number, number> {
|
||||
const map = new Map<number, number>()
|
||||
selectOrderedTracks(state).forEach(entry => map.set(entry.realIndex, entry.displayRow))
|
||||
return map
|
||||
}
|
||||
|
||||
export function selectAudioDividerDisplayRow(state: EditorState): number {
|
||||
const firstAudio = selectOrderedTracks(state).find(entry => entry.track.kind === 'audio')
|
||||
return firstAudio?.displayRow ?? -1
|
||||
}
|
||||
|
||||
export function selectTimelineGaps(state: EditorState): TimelineGapSelection[] {
|
||||
const clips = selectClips(state)
|
||||
const tracks = selectTracks(state)
|
||||
const gaps: TimelineGapSelection[] = []
|
||||
|
||||
tracks.forEach((track, trackIndex) => {
|
||||
if (track.type === 'subtitle') return
|
||||
const trackClips = clips
|
||||
.filter(clip => clip.trackIndex === trackIndex)
|
||||
.sort((a, b) => a.startTime - b.startTime)
|
||||
|
||||
if (trackClips.length === 0) return
|
||||
|
||||
if (trackClips[0].startTime > 0.05) {
|
||||
gaps.push({ trackIndex, startTime: 0, endTime: trackClips[0].startTime })
|
||||
}
|
||||
|
||||
for (let i = 0; i < trackClips.length - 1; i++) {
|
||||
const currentEnd = trackClips[i].startTime + trackClips[i].duration
|
||||
const nextStart = trackClips[i + 1].startTime
|
||||
if (nextStart - currentEnd > 0.05) {
|
||||
gaps.push({ trackIndex, startTime: currentEnd, endTime: nextStart })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return gaps
|
||||
}
|
||||
|
||||
export function selectCutPoints(state: EditorState): TimelineCutPoint[] {
|
||||
const clips = selectClips(state)
|
||||
const points: TimelineCutPoint[] = []
|
||||
const byTrack = new Map<number, TimelineClip[]>()
|
||||
|
||||
for (const clip of clips) {
|
||||
if (!byTrack.has(clip.trackIndex)) byTrack.set(clip.trackIndex, [])
|
||||
byTrack.get(clip.trackIndex)!.push(clip)
|
||||
}
|
||||
|
||||
for (const [trackIndex, trackClips] of byTrack) {
|
||||
const sorted = [...trackClips].sort((a, b) => a.startTime - b.startTime)
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const leftClip = sorted[i]
|
||||
const rightClip = sorted[i + 1]
|
||||
const leftEnd = leftClip.startTime + leftClip.duration
|
||||
if (Math.abs(leftEnd - rightClip.startTime) < CUT_POINT_TOLERANCE) {
|
||||
points.push({
|
||||
leftClip,
|
||||
rightClip,
|
||||
time: leftEnd,
|
||||
trackIndex,
|
||||
hasDissolve: (leftClip.transitionOut?.type === 'dissolve') || (rightClip.transitionIn?.type === 'dissolve'),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
export function selectKeyboardCommandContext(state: EditorState): KeyboardCommandContext {
|
||||
return {
|
||||
clips: selectClips(state),
|
||||
selectedClipIds: selectSelectedClipIds(state),
|
||||
totalDuration: selectTotalDuration(state),
|
||||
currentTime: selectCurrentTime(state),
|
||||
inPoint: selectActiveTimelineInPoint(state),
|
||||
outPoint: selectActiveTimelineOutPoint(state),
|
||||
}
|
||||
}
|
||||
|
||||
export function selectMenuState(state: EditorState): MenuState {
|
||||
return {
|
||||
selectedClip: selectSelectedClipForProperties(state),
|
||||
selectedClipIds: selectSelectedClipIds(state),
|
||||
clips: selectClips(state),
|
||||
tracks: selectTracks(state),
|
||||
subtitles: selectSubtitles(state),
|
||||
snapEnabled: selectSnapEnabled(state),
|
||||
showEffectsBrowser: selectShowEffectsBrowser(state),
|
||||
showSourceMonitor: selectShowSourceMonitor(state),
|
||||
showPropertiesPanel: selectShowPropertiesPanel(state),
|
||||
hasSourceAsset: selectHasSourceAsset(state),
|
||||
activeTool: selectActiveTool(state),
|
||||
activeTimeline: selectActiveTimeline(state),
|
||||
timelines: selectTimelines(state),
|
||||
}
|
||||
}
|
||||
|
||||
export function selectCanUseClipboard(state: EditorState): boolean {
|
||||
return state.session.clipboard.kind === 'clips' && state.session.clipboard.clips.length > 0
|
||||
}
|
||||
|
||||
export function selectCanInsertEdit(state: EditorState): boolean {
|
||||
return selectHasSourceAsset(state)
|
||||
}
|
||||
|
||||
export function selectCanOverwriteEdit(state: EditorState): boolean {
|
||||
return selectHasSourceAsset(state)
|
||||
}
|
||||
|
||||
export function selectClipMetadata(state: EditorState, clip: TimelineClip): ClipMetadata {
|
||||
const liveAsset = selectLiveAssetForClip(state, clip)
|
||||
const resolution = selectClipResolution(state, clip)
|
||||
const dimensions = selectClipDimensions(state, clip)
|
||||
const generationParams = liveAsset?.generationParams
|
||||
const totalTakes = liveAsset?.takes?.length || 1
|
||||
const currentTakeIdx = clip.takeIndex ?? (liveAsset?.activeTakeIndex ?? (totalTakes - 1))
|
||||
const displayTakeNum = Math.min(currentTakeIdx, totalTakes - 1) + 1
|
||||
let filePath = liveAsset?.path || ''
|
||||
if (liveAsset?.takes && liveAsset.takes.length > 0 && clip.takeIndex !== undefined) {
|
||||
const idx = Math.max(0, Math.min(clip.takeIndex, liveAsset.takes.length - 1))
|
||||
filePath = liveAsset.takes[idx].path
|
||||
}
|
||||
const originalRes = liveAsset?.generationParams?.resolution
|
||||
const isUpscaled = dimensions && originalRes
|
||||
? namedResolutionTier(Math.min(dimensions.width, dimensions.height)) > parseInt(originalRes, 10)
|
||||
: false
|
||||
|
||||
return {
|
||||
liveAsset,
|
||||
dimensions,
|
||||
resolution,
|
||||
generationParams,
|
||||
totalTakes,
|
||||
currentTakeIdx,
|
||||
displayTakeNum,
|
||||
filePath,
|
||||
isUpscaled,
|
||||
}
|
||||
}
|
||||
|
||||
export function selectClipCapabilities(state: EditorState, clip: TimelineClip): ClipCapabilities {
|
||||
const liveAsset = selectLiveAssetForClip(state, clip)
|
||||
return {
|
||||
isVideo: clip.type === 'video',
|
||||
isImage: clip.type === 'image',
|
||||
isAudio: clip.type === 'audio',
|
||||
isAdjustment: clip.type === 'adjustment',
|
||||
isText: clip.type === 'text',
|
||||
canCreateVideoFromImage: clip.type === 'image',
|
||||
canCreateVideoFromAudio: clip.type === 'audio' && !clip.linkedClipIds?.length,
|
||||
canRegenerate: Boolean(liveAsset?.generationParams),
|
||||
canRetake: clip.type === 'video',
|
||||
canUseIcLora: clip.type === 'video',
|
||||
}
|
||||
}
|
||||
|
||||
export function selectSelectedClipProperties(state: EditorState): SelectedClipPropertiesModel | null {
|
||||
const clip = selectSelectedClipForProperties(state)
|
||||
if (!clip) return null
|
||||
return {
|
||||
clip,
|
||||
metadata: selectClipMetadata(state, clip),
|
||||
capabilities: selectClipCapabilities(state, clip),
|
||||
}
|
||||
}
|
||||
|
||||
export function selectSelectedSubtitleEditorModel(state: EditorState): SelectedSubtitleEditorModel | null {
|
||||
const subtitle = selectSelectedSubtitle(state)
|
||||
if (!subtitle) return null
|
||||
const track = selectTracks(state)[subtitle.trackIndex]
|
||||
const effectiveStyle: Partial<SubtitleStyle> = {
|
||||
...(track?.subtitleStyle || {}),
|
||||
...(subtitle.style || {}),
|
||||
}
|
||||
return { subtitle, track, effectiveStyle }
|
||||
}
|
||||
|
||||
export function selectExportLetterbox(state: EditorState): ExportLetterbox | null {
|
||||
const clips = selectClips(state)
|
||||
const tracks = selectTracks(state)
|
||||
const adjustmentClips = clips.filter(
|
||||
clip =>
|
||||
clip.type === 'adjustment'
|
||||
&& clip.letterbox?.enabled
|
||||
&& tracks[clip.trackIndex]?.enabled !== false,
|
||||
)
|
||||
if (adjustmentClips.length === 0) return null
|
||||
|
||||
const best = adjustmentClips.reduce((currentBest, candidate) => (
|
||||
candidate.duration > currentBest.duration ? candidate : currentBest
|
||||
))
|
||||
const letterbox = best.letterbox!
|
||||
return {
|
||||
ratio: letterbox.aspectRatio === 'custom'
|
||||
? (letterbox.customRatio || 2.35)
|
||||
: (LETTERBOX_RATIO_MAP[letterbox.aspectRatio] || 2.35),
|
||||
color: letterbox.color || '#000000',
|
||||
opacity: (letterbox.opacity ?? 100) / 100,
|
||||
}
|
||||
}
|
||||
|
||||
export function selectExportClipData(state: EditorState): ExportClipData[] {
|
||||
const tracks = selectTracks(state)
|
||||
return selectClips(state)
|
||||
.filter(clip => clip.type === 'video' || clip.type === 'image' || clip.type === 'audio')
|
||||
.filter(clip => tracks[clip.trackIndex]?.enabled !== false)
|
||||
.map(clip => ({
|
||||
path: selectClipPath(state, clip),
|
||||
type: clip.type,
|
||||
startTime: clip.startTime,
|
||||
duration: clip.duration,
|
||||
trimStart: clip.trimStart,
|
||||
speed: clip.speed || 1,
|
||||
reversed: clip.reversed || false,
|
||||
flipH: clip.flipH || false,
|
||||
flipV: clip.flipV || false,
|
||||
opacity: clip.opacity ?? 100,
|
||||
trackIndex: clip.trackIndex,
|
||||
muted: clip.muted || false,
|
||||
volume: clip.volume ?? 1,
|
||||
}))
|
||||
}
|
||||
|
||||
export function selectExportSubtitleData(state: EditorState): ExportSubtitleData[] {
|
||||
const subtitles = selectSubtitles(state)
|
||||
const tracks = selectTracks(state)
|
||||
return subtitles.map(subtitle => {
|
||||
const track = tracks[subtitle.trackIndex]
|
||||
return {
|
||||
text: subtitle.text,
|
||||
startTime: subtitle.startTime,
|
||||
endTime: subtitle.endTime,
|
||||
style: {
|
||||
...DEFAULT_SUBTITLE_STYLE,
|
||||
...(track?.subtitleStyle || {}),
|
||||
...(subtitle.style || {}),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function selectExportModalModel(state: EditorState): ExportModalModel {
|
||||
return {
|
||||
timeline: selectActiveTimeline(state),
|
||||
clips: selectClips(state),
|
||||
tracks: selectTracks(state),
|
||||
exportClips: selectExportClipData(state),
|
||||
subtitleData: selectExportSubtitleData(state),
|
||||
letterbox: selectExportLetterbox(state),
|
||||
}
|
||||
}
|
||||
|
||||
export function selectRegenerationState(state: EditorState): EditorState['session']['regeneration'] {
|
||||
return state.session.regeneration
|
||||
}
|
||||
|
||||
export function selectRegeneratingAssetId(state: EditorState): string | null {
|
||||
return state.session.regeneration.regeneratingAssetId
|
||||
}
|
||||
|
||||
export function selectRegeneratingClipId(state: EditorState): string | null {
|
||||
return state.session.regeneration.regeneratingClipId
|
||||
}
|
||||
|
||||
export function selectRegenerationPreError(state: EditorState): string | null {
|
||||
return state.session.regeneration.preError
|
||||
}
|
||||
|
||||
export function selectRegenerationTargetClip(state: EditorState): TimelineClip | null {
|
||||
const clipId = state.session.regeneration.regeneratingClipId
|
||||
if (!clipId) return null
|
||||
return selectClips(state).find(clip => clip.id === clipId) ?? null
|
||||
}
|
||||
|
||||
export function selectRegenerationTargetAsset(state: EditorState): Asset | undefined {
|
||||
const clip = selectRegenerationTargetClip(state)
|
||||
const assetId = state.session.regeneration.regeneratingAssetId || clip?.assetId
|
||||
return selectAssetById(state, assetId)
|
||||
}
|
||||
|
||||
export function selectCanRegenerateClip(state: EditorState, clipId: string): boolean {
|
||||
const clip = selectClips(state).find(candidate => candidate.id === clipId)
|
||||
if (!clip) return false
|
||||
return Boolean(selectLiveAssetForClip(state, clip)?.generationParams)
|
||||
}
|
||||
|
||||
export interface AssetBinListItem {
|
||||
id: string
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export function selectAssetBins(state: EditorState): AssetBinListItem[] {
|
||||
const assetCounts = state.editorModel.assets.reduce((counts, asset) => {
|
||||
if (!asset.binId) return counts
|
||||
counts.set(asset.binId, (counts.get(asset.binId) ?? 0) + 1)
|
||||
return counts
|
||||
}, new Map<string, number>())
|
||||
|
||||
return Object.entries(state.editorModel.bins)
|
||||
.map(([id, name]) => ({
|
||||
id,
|
||||
name,
|
||||
count: assetCounts.get(id) ?? 0,
|
||||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
export function equalAssetBins(left: AssetBinListItem[], right: AssetBinListItem[]): boolean {
|
||||
if (left === right) return true
|
||||
if (left.length !== right.length) return false
|
||||
|
||||
return left.every((bin, index) => {
|
||||
const other = right[index]
|
||||
return (
|
||||
bin.id === other.id
|
||||
&& bin.name === other.name
|
||||
&& bin.count === other.count
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function selectAssetTakesView(state: EditorState, assetId: string): AssetTakeView {
|
||||
const asset = selectAssetById(state, assetId)
|
||||
return {
|
||||
asset,
|
||||
takes: asset?.takes || [],
|
||||
activeTakeIndex: asset?.activeTakeIndex ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function selectFilteredAssets(state: EditorState, filters: AssetListFilters): Asset[] {
|
||||
let result = selectAssets(state)
|
||||
if (filters.assetFilter && filters.assetFilter !== 'all') {
|
||||
result = result.filter(asset => asset.type === filters.assetFilter)
|
||||
}
|
||||
if (filters.selectedBinId !== undefined && filters.selectedBinId !== null) {
|
||||
result = result.filter(asset => asset.binId === filters.selectedBinId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function selectSortedAssets(state: EditorState, filters: AssetListFilters): Asset[] {
|
||||
const filteredAssets = selectFilteredAssets(state, filters)
|
||||
if (filters.assetViewMode !== 'list') return filteredAssets
|
||||
|
||||
const sorted = [...filteredAssets]
|
||||
const dir = filters.listSortDir === 'desc' ? -1 : 1
|
||||
sorted.sort((a, b) => {
|
||||
switch (filters.listSortCol) {
|
||||
case 'type':
|
||||
return dir * a.type.localeCompare(b.type)
|
||||
case 'duration':
|
||||
return dir * ((a.duration ?? 0) - (b.duration ?? 0))
|
||||
case 'resolution':
|
||||
return dir * (parseResolutionHeight(a.resolution) - parseResolutionHeight(b.resolution))
|
||||
case 'date':
|
||||
return dir * (a.createdAt - b.createdAt)
|
||||
case 'color': {
|
||||
const order = COLOR_LABELS.map((color) => color.id)
|
||||
const idxA = a.colorLabel ? order.indexOf(a.colorLabel) : order.length
|
||||
const idxB = b.colorLabel ? order.indexOf(b.colorLabel) : order.length
|
||||
return dir * (idxA - idxB)
|
||||
}
|
||||
case 'name':
|
||||
default: {
|
||||
const nameA = (a.path?.split(/[/\\]/).pop() || a.type || '').toLowerCase()
|
||||
const nameB = (b.path?.split(/[/\\]/).pop() || b.type || '').toLowerCase()
|
||||
return dir * nameA.localeCompare(nameB)
|
||||
}
|
||||
}
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
export function selectVisibleAssets(state: EditorState, filters: AssetListFilters): Asset[] {
|
||||
return selectSortedAssets(state, filters)
|
||||
}
|
||||
Reference in new issue
Block a user