/** * Timeline Import: Parse FCP7 XML and FCPXML files from Premiere Pro, DaVinci Resolve, Final Cut Pro * * Supported formats: * - FCP 7 XML (.xml) - Universal interchange format (Premiere, DaVinci, FCP7) * - FCPXML (.fcpxml) - Final Cut Pro X / DaVinci Resolve * * AAF is a binary format and cannot be parsed in JavaScript. * Users should export as XML from their NLE instead. */ // Intermediate representation of a parsed timeline export interface ParsedMediaRef { id: string name: string path: string // Effective local file path used for import bigThumbnailPath?: string smallThumbnailPath?: string duration: number // in seconds type: 'video' | 'audio' | 'image' width?: number height?: number fps?: number } export interface ParsedClip { name: string mediaRefId: string // references a ParsedMediaRef trackIndex: number trackType: 'video' | 'audio' startTime: number // position on timeline in seconds duration: number // duration on timeline in seconds sourceIn: number // source in point in seconds sourceOut: number // source out point in seconds speed?: number // playback speed multiplier (1 = normal, 2 = 2x, 0.5 = half) reversed?: boolean // true if clip is playing in reverse volume?: number // audio volume 0-1 muted?: boolean // true if audio is muted / clip is disabled flipH?: boolean // horizontal flip flipV?: boolean // vertical flip opacity?: number // opacity 0-100 (100 = fully visible) linkedVideoClipIndex?: number // For audio clips: index of the matching video clip in the clips array (for establishing links) } export interface ParsedTimeline { name: string fps: number duration: number // total duration in seconds width?: number height?: number videoTrackCount: number audioTrackCount: number mediaRefs: ParsedMediaRef[] clips: ParsedClip[] format: 'fcp7xml' | 'fcpxml' | 'unknown' } // ─── Helper: decode pathurl to local file path ───────────────────────────── function decodePathUrl(pathUrl: string): string { if (!pathUrl) return '' let decoded = pathUrl.trim() // Remove file:// or file:/// or file://localhost/ prefix decoded = decoded .replace(/^file:\/\/localhost\//i, '/') .replace(/^file:\/\/\//i, '/') .replace(/^file:\/\//i, '/') // URL decode try { decoded = decodeURIComponent(decoded) } catch { // ignore decode errors } // On Windows, paths start with /C: → remove leading slash if (/^\/[A-Za-z]:/.test(decoded)) { decoded = decoded.slice(1) } // Normalize slashes to OS-appropriate if (typeof navigator !== 'undefined' && navigator.platform?.startsWith('Win')) { decoded = decoded.replace(/\//g, '\\') } return decoded } // ─── Helper: get text content of a child element ─────────────────────────── function getChildText(parent: Element, tagName: string): string { const el = parent.querySelector(`:scope > ${tagName}`) return el?.textContent?.trim() || '' } function getChildNumber(parent: Element, tagName: string): number { const text = getChildText(parent, tagName) return text ? parseFloat(text) : 0 } // ─── Helper: get timebase (fps) from a rate element ──────────────────────── function getTimebase(parent: Element): number { const rateEl = parent.querySelector(':scope > rate') if (!rateEl) return 24 const tb = getChildNumber(rateEl, 'timebase') const ntsc = getChildText(rateEl, 'ntsc').toLowerCase() === 'true' // NTSC drop-frame: 30 → 29.97, 24 → 23.976, 60 → 59.94 if (ntsc && tb > 0) { return tb * (1000 / 1001) } return tb || 24 } // ─── Helper: detect media type from file extension ───────────────────────── function detectMediaType(filename: string): 'video' | 'audio' | 'image' { const ext = filename.split('.').pop()?.toLowerCase() || '' const videoExts = ['mp4', 'mov', 'avi', 'mkv', 'wmv', 'flv', 'webm', 'mxf', 'r3d', 'braw', 'ari'] const audioExts = ['mp3', 'wav', 'aac', 'flac', 'ogg', 'm4a', 'wma', 'aiff', 'aif'] const imageExts = ['jpg', 'jpeg', 'png', 'tiff', 'tif', 'bmp', 'gif', 'webp', 'exr', 'dpx', 'psd'] if (audioExts.includes(ext)) return 'audio' if (imageExts.includes(ext)) return 'image' if (videoExts.includes(ext)) return 'video' return 'video' // default } // ─── Helper: parse clip effects (FCP7) ─────────────────────────────────── // Extracts speed, reverse, flips, opacity, muted from / and // other clip-level elements in FCP 7 XML. interface ParsedClipEffects { speed: number reversed: boolean flipH: boolean flipV: boolean opacity: number // 0-100 muted: boolean } function parseFcp7ClipEffects(clipEl: Element): ParsedClipEffects { const result: ParsedClipEffects = { speed: 1, reversed: false, flipH: false, flipV: false, opacity: 100, muted: false, } // ── Check FALSE → clip is disabled/muted ── const enabled = getChildText(clipEl, 'enabled') if (enabled.toUpperCase() === 'FALSE') { result.muted = true } // ── Collect all effects ── const effects = clipEl.querySelectorAll(':scope > filter > effect') effects.forEach(effect => { const effectId = getChildText(effect, 'effectid').toLowerCase() const effectName = getChildText(effect, 'name').toLowerCase() // ── Speed / Time Remap ── if (effectId.includes('timeremap') || effectId.includes('speed') || effectName.includes('time remap') || effectName.includes('speed')) { // Get all parameters const params = effect.querySelectorAll(':scope > parameter') params.forEach(param => { const paramId = getChildText(param, 'parameterid').toLowerCase() const paramName = getChildText(param, 'name').toLowerCase() if (paramId === 'speed' || paramName === 'speed') { const val = getChildText(param, 'value') if (val) { // Speed is stored as percentage (100 = normal, 200 = 2x, 50 = half) const speedPercent = parseFloat(val) if (!isNaN(speedPercent) && speedPercent !== 0) { // Negative speed = reverse (in some exports) if (speedPercent < 0) { result.speed = Math.abs(speedPercent) / 100 result.reversed = true } else { result.speed = speedPercent / 100 } } } } if (paramId === 'reverse' || paramName === 'reverse') { const val = getChildText(param, 'value').toUpperCase() if (val === 'TRUE' || val === '1') { result.reversed = true } } }) } // ── Horizontal Flip ── if (effectId.includes('horizflip') || effectId.includes('hflip') || effectName.includes('horizontal flip') || effectName.includes('flip horizontal')) { result.flipH = true } // ── Vertical Flip ── if (effectId.includes('vertflip') || effectId.includes('vflip') || effectName.includes('vertical flip') || effectName.includes('flip vertical')) { result.flipV = true } // ── Basic Motion — check for negative scale (flip via scale) ── if (effectId === 'basic' || effectName.includes('basic motion') || effectName.includes('motion')) { const params = effect.querySelectorAll(':scope > parameter') params.forEach(param => { const paramId = getChildText(param, 'parameterid').toLowerCase() // Scale X negative = horizontal flip if (paramId === 'scalex' || paramId === 'scale_x') { const val = parseFloat(getChildText(param, 'value')) if (!isNaN(val) && val < 0) result.flipH = true } // Scale Y negative = vertical flip if (paramId === 'scaley' || paramId === 'scale_y') { const val = parseFloat(getChildText(param, 'value')) if (!isNaN(val) && val < 0) result.flipV = true } }) } // ── Opacity ── if (effectId === 'opacity' || effectId.includes('opacity') || effectName.includes('opacity')) { const params = effect.querySelectorAll(':scope > parameter') params.forEach(param => { const paramId = getChildText(param, 'parameterid').toLowerCase() if (paramId === 'opacity' || paramId === 'level') { const val = getChildText(param, 'value') if (val) { const opacityVal = parseFloat(val) if (!isNaN(opacityVal)) { result.opacity = Math.max(0, Math.min(100, opacityVal)) } } } }) } }) // ── Check for opacity (alternative location) ── const compositeOpacity = clipEl.querySelector(':scope > compositemode > opacity > value') if (compositeOpacity) { const val = parseFloat(compositeOpacity.textContent || '') if (!isNaN(val)) { result.opacity = Math.max(0, Math.min(100, val)) } } return result } // ═══════════════════════════════════════════════════════════════════════════ // FCP 7 XML Parser // ═══════════════════════════════════════════════════════════════════════════ function parseFcp7Xml(doc: Document): ParsedTimeline | null { // Look for sequence element const sequence = doc.querySelector('xmeml > sequence') || doc.querySelector('xmeml > project > children > sequence') || doc.querySelector('sequence') if (!sequence) return null const name = getChildText(sequence, 'name') || 'Imported Timeline' const fps = getTimebase(sequence) const totalDuration = getChildNumber(sequence, 'duration') / fps // Sequence format (resolution) const seqFormat = sequence.querySelector(':scope > media > video > format > samplecharacteristics') const seqWidth = seqFormat ? getChildNumber(seqFormat, 'width') : undefined const seqHeight = seqFormat ? getChildNumber(seqFormat, 'height') : undefined // Collect all file references (deduplicating by id) const mediaRefs = new Map() const fileElements = sequence.querySelectorAll('file') fileElements.forEach(fileEl => { const fileId = fileEl.getAttribute('id') || '' if (!fileId || mediaRefs.has(fileId)) return // Some file elements are just references (no children) - skip those const xmlPath = getChildText(fileEl, 'pathurl') if (!xmlPath && fileEl.children.length === 0) return const decodedPath = decodePathUrl(xmlPath) const fileName = getChildText(fileEl, 'name') || decodedPath.split(/[/\\]/).pop() || 'Unknown' const fileFps = getTimebase(fileEl) const fileDuration = getChildNumber(fileEl, 'duration') / (fileFps || fps) // Get media dimensions const mediaVideo = fileEl.querySelector(':scope > media > video > samplecharacteristics') const width = mediaVideo ? getChildNumber(mediaVideo, 'width') : undefined const height = mediaVideo ? getChildNumber(mediaVideo, 'height') : undefined const type = detectMediaType(fileName) mediaRefs.set(fileId, { id: fileId, name: fileName, path: decodedPath, duration: fileDuration, type, width, height, fps: fileFps, }) }) // Parse clips from video tracks const clips: ParsedClip[] = [] let videoTrackCount = 0 let audioTrackCount = 0 // Video tracks const videoTracks = sequence.querySelectorAll(':scope > media > video > track') videoTracks.forEach((trackEl, trackIdx) => { videoTrackCount++ const clipItems = trackEl.querySelectorAll(':scope > clipitem') clipItems.forEach(clipEl => { const clipName = getChildText(clipEl, 'name') const clipFps = getTimebase(clipEl) || fps const startFrame = getChildNumber(clipEl, 'start') const endFrame = getChildNumber(clipEl, 'end') const inFrame = getChildNumber(clipEl, 'in') const outFrame = getChildNumber(clipEl, 'out') // Find the file reference const fileEl = clipEl.querySelector(':scope > file') let mediaRefId = fileEl?.getAttribute('id') || '' // If file element has no children, it's a reference - look it up if (fileEl && fileEl.children.length === 0 && mediaRefId) { // Just a reference to an existing file } else if (fileEl && mediaRefId) { // Full file definition inline - make sure it's in our map if (!mediaRefs.has(mediaRefId)) { const xmlPath = getChildText(fileEl, 'pathurl') const decodedPath = decodePathUrl(xmlPath) const fileName = getChildText(fileEl, 'name') || decodedPath.split(/[/\\]/).pop() || clipName mediaRefs.set(mediaRefId, { id: mediaRefId, name: fileName, path: decodedPath, duration: (outFrame - inFrame) / clipFps, type: detectMediaType(fileName), }) } } // Parse all clip effects (speed, reverse, flips, opacity, muted) const effects = parseFcp7ClipEffects(clipEl) if (startFrame >= 0 && endFrame > startFrame && mediaRefId) { clips.push({ name: clipName, mediaRefId, trackIndex: trackIdx, trackType: 'video', startTime: startFrame / clipFps, duration: (endFrame - startFrame) / clipFps, sourceIn: inFrame / clipFps, sourceOut: outFrame / clipFps, speed: effects.speed !== 1 ? effects.speed : undefined, reversed: effects.reversed || undefined, flipH: effects.flipH || undefined, flipV: effects.flipV || undefined, opacity: effects.opacity !== 100 ? effects.opacity : undefined, muted: effects.muted || undefined, }) } }) }) // Audio tracks const audioTracks = sequence.querySelectorAll(':scope > media > audio > track') audioTracks.forEach((trackEl, audioIdx) => { audioTrackCount++ const clipItems = trackEl.querySelectorAll(':scope > clipitem') clipItems.forEach(clipEl => { const clipName = getChildText(clipEl, 'name') const clipFps = getTimebase(clipEl) || fps const startFrame = getChildNumber(clipEl, 'start') const endFrame = getChildNumber(clipEl, 'end') const inFrame = getChildNumber(clipEl, 'in') const outFrame = getChildNumber(clipEl, 'out') const fileEl = clipEl.querySelector(':scope > file') const mediaRefId = fileEl?.getAttribute('id') || '' // Parse effects (muted via FALSE) const effects = parseFcp7ClipEffects(clipEl) // Check volume from audio level filter let volume: number | undefined const volFilter = Array.from(clipEl.querySelectorAll('filter > effect')).find(e => getChildText(e, 'effectid')?.toLowerCase().includes('audiolevel') || getChildText(e, 'name')?.toLowerCase().includes('level') ) if (volFilter) { const volParam = volFilter.querySelector('parameter > value') if (volParam) { volume = parseFloat(volParam.textContent || '0') // FCP7 volume is in dB, convert rough approximation: 0dB=1, -inf=0 volume = Math.pow(10, (volume || 0) / 20) } } // Find matching video clip (same file, same timeline position) to establish a link const linkedVideoIndex = clips.findIndex(c => c.mediaRefId === mediaRefId && c.trackType === 'video' && Math.abs(c.startTime - startFrame / clipFps) < 0.01 ) if (startFrame >= 0 && endFrame > startFrame && mediaRefId) { clips.push({ name: clipName, mediaRefId, trackIndex: videoTrackCount + audioIdx, // offset by video tracks trackType: 'audio', startTime: startFrame / clipFps, duration: (endFrame - startFrame) / clipFps, sourceIn: inFrame / clipFps, sourceOut: outFrame / clipFps, volume, muted: effects.muted || undefined, linkedVideoClipIndex: linkedVideoIndex >= 0 ? linkedVideoIndex : undefined, }) } }) }) // ── Merge stereo pairs: Premiere exports stereo as two mono audio tracks ── // If multiple audio clips reference the same source file at the same timeline // position, they are channel splits of a single stereo clip. Keep only one // clip per unique (mediaRefId, startTime) and collapse the track count. const audioClips = clips.filter(c => c.trackType === 'audio') const nonAudioClips = clips.filter(c => c.trackType !== 'audio') // Group audio clips by (mediaRefId + startTime) to detect stereo pairs const audioGroups = new Map() for (const ac of audioClips) { const key = `${ac.mediaRefId}|${ac.startTime.toFixed(4)}` if (!audioGroups.has(key)) audioGroups.set(key, []) audioGroups.get(key)!.push(ac) } // Build merged audio clips: keep the first clip of each group, discard duplicates const mergedAudioClips: ParsedClip[] = [] let mergedAudioTrackIdx = 0 // Track which original track indices have been assigned a merged index const trackRemap = new Map() for (const group of audioGroups.values()) { const representative = group[0] // keep first channel's metadata (volume, link, etc.) // Determine the merged track index for this clip const origTrack = representative.trackIndex if (!trackRemap.has(origTrack)) { trackRemap.set(origTrack, videoTrackCount + mergedAudioTrackIdx) mergedAudioTrackIdx++ } representative.trackIndex = trackRemap.get(origTrack)! // If any channel in the group has a linked video, preserve it if (representative.linkedVideoClipIndex === undefined) { const linked = group.find(c => c.linkedVideoClipIndex !== undefined) if (linked) representative.linkedVideoClipIndex = linked.linkedVideoClipIndex } mergedAudioClips.push(representative) } // Reassemble clips and update track count const finalClips = [...nonAudioClips, ...mergedAudioClips] const finalAudioTrackCount = mergedAudioTrackIdx || (audioTrackCount > 0 ? 1 : 0) return { name, fps, duration: totalDuration || Math.max(...finalClips.map(c => c.startTime + c.duration), 0), width: seqWidth, height: seqHeight, videoTrackCount, audioTrackCount: finalAudioTrackCount, mediaRefs: Array.from(mediaRefs.values()), clips: finalClips, format: 'fcp7xml', } } // ─── Helper: parse clip effects from FCPXML elements ───────────────────── // FCPXML stores speed in or , flips via transform, // opacity as an attribute, etc. function parseFcpxmlClipEffects(el: Element): Partial { const result: Partial = {} // ── Enabled attribute (enabled="0" = muted/disabled) ── const enabled = el.getAttribute('enabled') if (enabled === '0') { result.muted = true } // ── Speed: check for with remapped speed ── const timeMap = el.querySelector(':scope > timeMap') if (timeMap) { const timepts = timeMap.querySelectorAll(':scope > timept') // Simple constant speed: 2 timepts → calculate speed ratio if (timepts.length >= 2) { const parseFcpxmlTimeLocal = (timeStr: string | null): number => { if (!timeStr) return 0 const rational = timeStr.trim().match(/^(\d+)\/(\d+)s$/) if (rational) return parseInt(rational[1]) / parseInt(rational[2]) const simple = timeStr.trim().match(/^([\d.]+)s$/) if (simple) return parseFloat(simple[1]) return 0 } const first = timepts[0] const last = timepts[timepts.length - 1] const srcDur = parseFcpxmlTimeLocal(last.getAttribute('time')) - parseFcpxmlTimeLocal(first.getAttribute('time')) const dstDur = parseFcpxmlTimeLocal(last.getAttribute('value')) - parseFcpxmlTimeLocal(first.getAttribute('value')) if (srcDur > 0 && dstDur > 0) { result.speed = dstDur / srcDur } } } // ── Speed: check for ── const conformRate = el.querySelector(':scope > conform-rate') if (conformRate) { const scaleEnabled = conformRate.getAttribute('scaleEnabled') if (scaleEnabled === '1') { const srcFrameDur = conformRate.getAttribute('srcFrameDuration') const frameDur = conformRate.getAttribute('frameDuration') if (srcFrameDur && frameDur) { const parseFcpxmlTimeLocal = (timeStr: string): number => { const rational = timeStr.trim().match(/^(\d+)\/(\d+)s$/) if (rational) return parseInt(rational[1]) / parseInt(rational[2]) const simple = timeStr.trim().match(/^([\d.]+)s$/) if (simple) return parseFloat(simple[1]) return 0 } const src = parseFcpxmlTimeLocal(srcFrameDur) const dst = parseFcpxmlTimeLocal(frameDur) if (src > 0 && dst > 0) { result.speed = src / dst } } } } // ── Flips and opacity: check ── const adjustTransform = el.querySelector(':scope > adjust-transform') if (adjustTransform) { // Flips: scaleX="-100" or scaleY="-100" const scaleX = parseFloat(adjustTransform.getAttribute('scaleX') || '100') const scaleY = parseFloat(adjustTransform.getAttribute('scaleY') || '100') if (scaleX < 0) result.flipH = true if (scaleY < 0) result.flipV = true } // ── Flips: check with "Flipped" or transform ── const filterVideos = el.querySelectorAll(':scope > filter-video') filterVideos.forEach(fv => { const filterName = (fv.getAttribute('name') || '').toLowerCase() const filterRef = (fv.getAttribute('ref') || '').toLowerCase() if (filterName.includes('flipped') || filterName.includes('flip') || filterRef.includes('flip')) { // Check params for which axis const params = fv.querySelectorAll(':scope > param') let hasSpecificAxis = false params.forEach(p => { const pName = (p.getAttribute('name') || '').toLowerCase() if (pName.includes('horizontal') || pName.includes('horiz')) { result.flipH = true hasSpecificAxis = true } if (pName.includes('vertical') || pName.includes('vert')) { result.flipV = true hasSpecificAxis = true } }) // If no specific axis param found, assume horizontal flip if (!hasSpecificAxis) result.flipH = true } }) // ── Opacity: check ── const adjustBlend = el.querySelector(':scope > adjust-blend') if (adjustBlend) { const amount = adjustBlend.getAttribute('amount') if (amount) { const opacityVal = parseFloat(amount) * 100 // FCPXML uses 0-1 range if (!isNaN(opacityVal)) { result.opacity = Math.max(0, Math.min(100, opacityVal)) } } } return result } // ═══════════════════════════════════════════════════════════════════════════ // FCPXML Parser (Final Cut Pro X / DaVinci Resolve) // ═══════════════════════════════════════════════════════════════════════════ function parseFcpXml(doc: Document): ParsedTimeline | null { const fcpxml = doc.querySelector('fcpxml') if (!fcpxml) return null // Find the first project/event/sequence const project = fcpxml.querySelector('project') || fcpxml.querySelector('event > project') const sequence = project?.querySelector('sequence') || fcpxml.querySelector('sequence') if (!sequence) return null const name = sequence.getAttribute('name') || project?.getAttribute('name') || 'Imported Timeline' const format = sequence.getAttribute('format') || '' // Parse duration (FCPXML uses rational time: "86400/2400s" or "10s") const parseFcpxmlTime = (timeStr: string | null): number => { if (!timeStr) return 0 timeStr = timeStr.trim() // Format: "86400/2400s" (rational) const rational = timeStr.match(/^(\d+)\/(\d+)s$/) if (rational) return parseInt(rational[1]) / parseInt(rational[2]) // Format: "10s" (simple seconds) const simple = timeStr.match(/^([\d.]+)s$/) if (simple) return parseFloat(simple[1]) return 0 } const totalDuration = parseFcpxmlTime(sequence.getAttribute('duration')) // Get format info (resolution, fps) let fps = 24 let seqWidth: number | undefined let seqHeight: number | undefined if (format) { // Format is defined as a resource reference const formatEl = fcpxml.querySelector(`resources > format[id="${format}"]`) if (formatEl) { seqWidth = parseInt(formatEl.getAttribute('width') || '0') || undefined seqHeight = parseInt(formatEl.getAttribute('height') || '0') || undefined const frameDur = formatEl.getAttribute('frameDuration') if (frameDur) { const dur = parseFcpxmlTime(frameDur) if (dur > 0) fps = 1 / dur } } } // Collect media references from resources const mediaRefs = new Map() const assets = fcpxml.querySelectorAll('resources > asset') assets.forEach(assetEl => { const assetId = assetEl.getAttribute('id') || '' if (!assetId) return const assetName = assetEl.getAttribute('name') || '' const src = assetEl.getAttribute('src') || '' const dur = parseFcpxmlTime(assetEl.getAttribute('duration')) const hasVideo = assetEl.getAttribute('hasVideo') !== '0' const hasAudio = assetEl.getAttribute('hasAudio') !== '0' // Get format from the asset's format ref const assetFormat = assetEl.getAttribute('format') let width: number | undefined let height: number | undefined if (assetFormat) { const fmtEl = fcpxml.querySelector(`resources > format[id="${assetFormat}"]`) if (fmtEl) { width = parseInt(fmtEl.getAttribute('width') || '0') || undefined height = parseInt(fmtEl.getAttribute('height') || '0') || undefined } } const decodedPath = decodePathUrl(src) const type = !hasVideo && hasAudio ? 'audio' : detectMediaType(assetName || src) mediaRefs.set(assetId, { id: assetId, name: assetName || src.split('/').pop() || 'Unknown', path: decodedPath, duration: dur, type, width, height, fps, }) }) // Parse spine (main timeline) and lanes const clips: ParsedClip[] = [] let videoTrackCount = 0 let audioTrackCount = 0 // FCPXML has a "spine" which is the main track, with clips that can have "lane" attributes const spine = sequence.querySelector(':scope > spine') if (spine) { let currentOffset = 0 const spineChildren = spine.children for (let i = 0; i < spineChildren.length; i++) { const el = spineChildren[i] const tagName = el.tagName.toLowerCase() if (tagName === 'asset-clip' || tagName === 'clip' || tagName === 'video' || tagName === 'audio') { const ref = el.getAttribute('ref') || '' const clipName = el.getAttribute('name') || '' const clipDuration = parseFcpxmlTime(el.getAttribute('duration')) const clipOffset = parseFcpxmlTime(el.getAttribute('offset')) || currentOffset const clipStart = parseFcpxmlTime(el.getAttribute('start')) const lane = parseInt(el.getAttribute('lane') || '0') const trackIdx = Math.max(0, lane) videoTrackCount = Math.max(videoTrackCount, trackIdx + 1) if (ref && mediaRefs.has(ref)) { const fx = parseFcpxmlClipEffects(el) clips.push({ name: clipName, mediaRefId: ref, trackIndex: trackIdx, trackType: tagName === 'audio' ? 'audio' : 'video', startTime: clipOffset, duration: clipDuration, sourceIn: clipStart, sourceOut: clipStart + clipDuration, speed: fx.speed && fx.speed !== 1 ? fx.speed : undefined, reversed: fx.speed !== undefined && fx.speed < 0 ? true : undefined, flipH: fx.flipH || undefined, flipV: fx.flipV || undefined, opacity: fx.opacity !== undefined && fx.opacity !== 100 ? fx.opacity : undefined, muted: fx.muted || undefined, }) } // If no lane attribute (lane 0 / spine), advance the offset if (lane === 0 || !el.hasAttribute('lane')) { currentOffset += clipDuration } // Check for attached clips (connected clips / B-roll) const attached = el.querySelectorAll(':scope > asset-clip, :scope > clip, :scope > audio, :scope > video') attached.forEach(att => { const attRef = att.getAttribute('ref') || '' const attName = att.getAttribute('name') || '' const attDur = parseFcpxmlTime(att.getAttribute('duration')) const attOffset = parseFcpxmlTime(att.getAttribute('offset')) const attStart = parseFcpxmlTime(att.getAttribute('start')) const attLane = parseInt(att.getAttribute('lane') || '1') const attTrackIdx = Math.max(0, attLane) videoTrackCount = Math.max(videoTrackCount, attTrackIdx + 1) if (attRef && mediaRefs.has(attRef)) { const isAudio = att.tagName.toLowerCase() === 'audio' const attFx = parseFcpxmlClipEffects(att) clips.push({ name: attName, mediaRefId: attRef, trackIndex: attTrackIdx, trackType: isAudio ? 'audio' : 'video', startTime: clipOffset + attOffset, duration: attDur, sourceIn: attStart, sourceOut: attStart + attDur, speed: attFx.speed && attFx.speed !== 1 ? attFx.speed : undefined, reversed: attFx.speed !== undefined && attFx.speed < 0 ? true : undefined, flipH: attFx.flipH || undefined, flipV: attFx.flipV || undefined, opacity: attFx.opacity !== undefined && attFx.opacity !== 100 ? attFx.opacity : undefined, muted: attFx.muted || undefined, }) } }) } else if (tagName === 'gap') { // Empty space const gapDuration = parseFcpxmlTime(el.getAttribute('duration')) currentOffset += gapDuration // Check for clips attached to the gap const attached = el.querySelectorAll(':scope > asset-clip, :scope > clip, :scope > audio, :scope > video') attached.forEach(att => { const attRef = att.getAttribute('ref') || '' const attName = att.getAttribute('name') || '' const attDur = parseFcpxmlTime(att.getAttribute('duration')) const attOffset = parseFcpxmlTime(att.getAttribute('offset')) const attStart = parseFcpxmlTime(att.getAttribute('start')) const attLane = parseInt(att.getAttribute('lane') || '1') const attTrackIdx = Math.max(0, attLane) videoTrackCount = Math.max(videoTrackCount, attTrackIdx + 1) if (attRef && mediaRefs.has(attRef)) { const attFx = parseFcpxmlClipEffects(att) clips.push({ name: attName, mediaRefId: attRef, trackIndex: attTrackIdx, trackType: att.tagName.toLowerCase() === 'audio' ? 'audio' : 'video', startTime: (currentOffset - gapDuration) + attOffset, duration: attDur, sourceIn: attStart, sourceOut: attStart + attDur, speed: attFx.speed && attFx.speed !== 1 ? attFx.speed : undefined, reversed: attFx.speed !== undefined && attFx.speed < 0 ? true : undefined, flipH: attFx.flipH || undefined, flipV: attFx.flipV || undefined, opacity: attFx.opacity !== undefined && attFx.opacity !== 100 ? attFx.opacity : undefined, muted: attFx.muted || undefined, }) } }) } } } if (videoTrackCount === 0) videoTrackCount = 1 // ── Merge stereo pairs (same logic as FCP7 XML) ── // FCPXML can also represent stereo as two separate audio clips from the same // source at the same position. Collapse them into a single stereo clip. const fcpxAudioClips = clips.filter(c => c.trackType === 'audio') const fcpxNonAudioClips = clips.filter(c => c.trackType !== 'audio') const fcpxAudioGroups = new Map() for (const ac of fcpxAudioClips) { const key = `${ac.mediaRefId}|${ac.startTime.toFixed(4)}` if (!fcpxAudioGroups.has(key)) fcpxAudioGroups.set(key, []) fcpxAudioGroups.get(key)!.push(ac) } const fcpxMergedAudio: ParsedClip[] = [] let fcpxMergedTrackIdx = 0 const fcpxTrackRemap = new Map() for (const group of fcpxAudioGroups.values()) { const representative = group[0] const origTrack = representative.trackIndex if (!fcpxTrackRemap.has(origTrack)) { fcpxTrackRemap.set(origTrack, videoTrackCount + fcpxMergedTrackIdx) fcpxMergedTrackIdx++ } representative.trackIndex = fcpxTrackRemap.get(origTrack)! fcpxMergedAudio.push(representative) } const fcpxFinalClips = [...fcpxNonAudioClips, ...fcpxMergedAudio] const fcpxFinalAudioCount = fcpxMergedTrackIdx || (audioTrackCount > 0 ? 1 : 0) return { name, fps, duration: totalDuration || Math.max(...fcpxFinalClips.map(c => c.startTime + c.duration), 0), width: seqWidth, height: seqHeight, videoTrackCount, audioTrackCount: fcpxFinalAudioCount, mediaRefs: Array.from(mediaRefs.values()), clips: fcpxFinalClips, format: 'fcpxml', } } // ═══════════════════════════════════════════════════════════════════════════ // Public API // ═══════════════════════════════════════════════════════════════════════════ export type ImportFormat = 'fcp7xml' | 'fcpxml' | 'aaf' | 'unknown' export function detectFormat(content: string, filename: string): ImportFormat { const ext = filename.split('.').pop()?.toLowerCase() || '' if (ext === 'aaf') return 'aaf' // Check XML content if (content.includes(' }): string { const { name, fps, width, height, clips } = options // Collect unique files const files = new Map() clips.forEach((clip, i) => { if (!files.has(clip.filePath)) { files.set(clip.filePath, { id: `file-${i + 1}`, path: clip.filePath, name: clip.name, duration: clip.sourceDuration, type: clip.type, width: clip.width, height: clip.height, }) } }) const toFrames = (seconds: number) => Math.round(seconds * fps) // Escape XML special characters const escXml = (s: string) => s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') // Build file path URL const toPathUrl = (p: string) => { let normalized = p.replace(/\\/g, '/') if (!normalized.startsWith('/')) normalized = '/' + normalized return `file://localhost${encodeURI(normalized)}` } // Group clips by video/audio tracks const videoClips = clips.filter(c => c.type !== 'audio') const audioClips = clips.filter(c => c.type === 'audio') // Group by track index const videoTrackMap = new Map() videoClips.forEach(c => { const arr = videoTrackMap.get(c.trackIndex) || [] arr.push(c) videoTrackMap.set(c.trackIndex, arr) }) const audioTrackMap = new Map() audioClips.forEach(c => { const arr = audioTrackMap.get(c.trackIndex) || [] arr.push(c) audioTrackMap.set(c.trackIndex, arr) }) const totalDuration = Math.max(...clips.map(c => c.startTime + c.duration), 1) let xml = `\n\n\n \n ${escXml(name)}\n ${toFrames(totalDuration)}\n \n ${Math.round(fps)}\n ${Math.abs(fps - Math.round(fps)) > 0.01 ? 'TRUE' : 'FALSE'}\n \n \n \n \n \n \n\n` return xml }