1082 lines
42 KiB
TypeScript
1082 lines
42 KiB
TypeScript
/**
|
|
* 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 <filter>/<effect> 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 <enabled>FALSE</enabled> → 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 <compositemode> 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<string, ParsedMediaRef>()
|
|
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 <enabled>FALSE</enabled>)
|
|
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<string, ParsedClip[]>()
|
|
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<number, number>()
|
|
|
|
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 <timeMap> or <conform-rate>, flips via <filter-video> transform,
|
|
// opacity as an attribute, etc.
|
|
function parseFcpxmlClipEffects(el: Element): Partial<ParsedClipEffects> {
|
|
const result: Partial<ParsedClipEffects> = {}
|
|
|
|
// ── Enabled attribute (enabled="0" = muted/disabled) ──
|
|
const enabled = el.getAttribute('enabled')
|
|
if (enabled === '0') {
|
|
result.muted = true
|
|
}
|
|
|
|
// ── Speed: check for <timeMap> 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 <conform-rate> ──
|
|
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 <adjust-transform> ──
|
|
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 <filter-video> 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 <adjust-blend> ──
|
|
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<string, ParsedMediaRef>()
|
|
|
|
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<string, ParsedClip[]>()
|
|
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<number, number>()
|
|
|
|
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('<xmeml') || content.includes('<!DOCTYPE xmeml')) return 'fcp7xml'
|
|
if (content.includes('<fcpxml')) return 'fcpxml'
|
|
|
|
if (ext === 'fcpxml') return 'fcpxml'
|
|
if (ext === 'xml') return 'fcp7xml' // default XML to FCP7
|
|
|
|
return 'unknown'
|
|
}
|
|
|
|
export function parseTimelineXml(content: string, filename: string): ParsedTimeline | null {
|
|
const format = detectFormat(content, filename)
|
|
|
|
if (format === 'aaf') {
|
|
throw new Error(
|
|
'AAF files cannot be imported directly. Please export your timeline as FCP 7 XML (.xml) from your editing software:\n\n' +
|
|
'- Premiere Pro: File → Export → Final Cut Pro XML\n' +
|
|
'- DaVinci Resolve: File → Export Timeline → FCP 7 XML\n' +
|
|
'- Avid Media Composer: File → Export → FCP 7 XML'
|
|
)
|
|
}
|
|
|
|
// Parse XML
|
|
const parser = new DOMParser()
|
|
const doc = parser.parseFromString(content, 'text/xml')
|
|
|
|
// Check for parse errors
|
|
const parseError = doc.querySelector('parsererror')
|
|
if (parseError) {
|
|
throw new Error('Invalid XML file: ' + (parseError.textContent?.slice(0, 200) || 'Parse error'))
|
|
}
|
|
|
|
if (format === 'fcpxml') {
|
|
const result = parseFcpXml(doc)
|
|
if (result) return result
|
|
}
|
|
|
|
if (format === 'fcp7xml' || format === 'unknown') {
|
|
const result = parseFcp7Xml(doc)
|
|
if (result) return result
|
|
}
|
|
|
|
// Try both parsers
|
|
const fcpxml = parseFcpXml(doc)
|
|
if (fcpxml) return fcpxml
|
|
|
|
const fcp7 = parseFcp7Xml(doc)
|
|
if (fcp7) return fcp7
|
|
|
|
throw new Error('Could not parse timeline. The file format is not recognized as FCP 7 XML or FCPXML.')
|
|
}
|
|
|
|
/**
|
|
* Export the current timeline as FCP 7 XML
|
|
*/
|
|
export function exportFcp7Xml(options: {
|
|
name: string
|
|
fps: number
|
|
width: number
|
|
height: number
|
|
clips: Array<{
|
|
name: string
|
|
filePath: string
|
|
trackIndex: number
|
|
type: 'video' | 'audio' | 'image'
|
|
startTime: number
|
|
duration: number
|
|
trimStart: number
|
|
sourceDuration: number
|
|
width?: number
|
|
height?: number
|
|
}>
|
|
}): string {
|
|
const { name, fps, width, height, clips } = options
|
|
|
|
// Collect unique files
|
|
const files = new Map<string, { id: string; path: string; name: string; duration: number; type: string; width?: number; height?: number }>()
|
|
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, '"')
|
|
.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<number, typeof videoClips>()
|
|
videoClips.forEach(c => {
|
|
const arr = videoTrackMap.get(c.trackIndex) || []
|
|
arr.push(c)
|
|
videoTrackMap.set(c.trackIndex, arr)
|
|
})
|
|
|
|
const audioTrackMap = new Map<number, typeof audioClips>()
|
|
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 = `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE xmeml>\n<xmeml version="4">\n <sequence id="sequence-1">\n <name>${escXml(name)}</name>\n <duration>${toFrames(totalDuration)}</duration>\n <rate>\n <timebase>${Math.round(fps)}</timebase>\n <ntsc>${Math.abs(fps - Math.round(fps)) > 0.01 ? 'TRUE' : 'FALSE'}</ntsc>\n </rate>\n <media>\n <video>\n <format>\n <samplecharacteristics>\n <width>${width}</width>\n <height>${height}</height>\n </samplecharacteristics>\n </format>\n`
|
|
|
|
// Video tracks
|
|
for (const [, trackClips] of videoTrackMap) {
|
|
xml += ` <track>\n`
|
|
for (const clip of trackClips) {
|
|
const file = files.get(clip.filePath)!
|
|
const inFrame = toFrames(clip.trimStart)
|
|
const outFrame = toFrames(clip.trimStart + clip.duration)
|
|
xml += ` <clipitem id="clipitem-${Math.random().toString(36).slice(2, 8)}">\n`
|
|
xml += ` <name>${escXml(clip.name)}</name>\n`
|
|
xml += ` <duration>${toFrames(clip.sourceDuration)}</duration>\n`
|
|
xml += ` <rate><timebase>${Math.round(fps)}</timebase></rate>\n`
|
|
xml += ` <start>${toFrames(clip.startTime)}</start>\n`
|
|
xml += ` <end>${toFrames(clip.startTime + clip.duration)}</end>\n`
|
|
xml += ` <in>${inFrame}</in>\n`
|
|
xml += ` <out>${outFrame}</out>\n`
|
|
xml += ` <file id="${file.id}">\n`
|
|
xml += ` <name>${escXml(file.name)}</name>\n`
|
|
xml += ` <pathurl>${escXml(toPathUrl(file.path))}</pathurl>\n`
|
|
xml += ` <duration>${toFrames(file.duration)}</duration>\n`
|
|
xml += ` <rate><timebase>${Math.round(fps)}</timebase></rate>\n`
|
|
if (file.width && file.height) {
|
|
xml += ` <media><video><samplecharacteristics><width>${file.width}</width><height>${file.height}</height></samplecharacteristics></video></media>\n`
|
|
}
|
|
xml += ` </file>\n`
|
|
xml += ` </clipitem>\n`
|
|
}
|
|
xml += ` </track>\n`
|
|
}
|
|
|
|
xml += ` </video>\n <audio>\n`
|
|
|
|
// Audio tracks
|
|
for (const [, trackClips] of audioTrackMap) {
|
|
xml += ` <track>\n`
|
|
for (const clip of trackClips) {
|
|
const file = files.get(clip.filePath)!
|
|
const inFrame = toFrames(clip.trimStart)
|
|
const outFrame = toFrames(clip.trimStart + clip.duration)
|
|
xml += ` <clipitem id="clipitem-${Math.random().toString(36).slice(2, 8)}">\n`
|
|
xml += ` <name>${escXml(clip.name)}</name>\n`
|
|
xml += ` <duration>${toFrames(clip.sourceDuration)}</duration>\n`
|
|
xml += ` <rate><timebase>${Math.round(fps)}</timebase></rate>\n`
|
|
xml += ` <start>${toFrames(clip.startTime)}</start>\n`
|
|
xml += ` <end>${toFrames(clip.startTime + clip.duration)}</end>\n`
|
|
xml += ` <in>${inFrame}</in>\n`
|
|
xml += ` <out>${outFrame}</out>\n`
|
|
xml += ` <file id="${file.id}">\n`
|
|
xml += ` <name>${escXml(file.name)}</name>\n`
|
|
xml += ` <pathurl>${escXml(toPathUrl(file.path))}</pathurl>\n`
|
|
xml += ` <duration>${toFrames(file.duration)}</duration>\n`
|
|
xml += ` <rate><timebase>${Math.round(fps)}</timebase></rate>\n`
|
|
xml += ` </file>\n`
|
|
xml += ` </clipitem>\n`
|
|
}
|
|
xml += ` </track>\n`
|
|
}
|
|
|
|
xml += ` </audio>\n </media>\n </sequence>\n</xmeml>\n`
|
|
|
|
return xml
|
|
}
|