/** * AAF (Advanced Authoring Format) Exporter * * Базовая поддержка экспорта в AAF XML формат * Примечание: Это упрощенная реализация AAF XML, не полный бинарный AAF */ import { TimelineClip, TimelineProject, TimelineTrack } from "../../../types/timeline" import { Exporter, ExportOptions } from "../types" export class AAFExporter implements Exporter { private frameRate = 30 async export(project: TimelineProject, options: ExportOptions): Promise { this.frameRate = project.fps || 30 this.projectWidth = project.settings.resolution.width this.projectHeight = project.settings.resolution.height this.mobIDCounter = 1 const xml = this.generateAAFXML(project, options) return xml } getFileExtension(): string { return ".aaf" } getMimeType(): string { return "application/xml" } private generateAAFXML(project: TimelineProject, options: ExportOptions): string { const lines: string[] = [] // XML Declaration lines.push('') lines.push("") // AAF Root lines.push("") lines.push("
") lines.push(" Timeline Studio AAF Export 1.0") lines.push(` ${new Date().toISOString()}`) lines.push("
") lines.push("") // Dictionary (определения) lines.push(" ") lines.push(" ") lines.push(" ") lines.push("") // Content Storage lines.push(" ") // Создаем CompositionMob для проекта this.generateCompositionMob(project, options, lines) // Создаем SourceMobs для медиафайлов this.generateSourceMobs(project, options, lines) lines.push(" ") lines.push("") lines.push("
") return lines.join("\n") } private generateCompositionMob(project: TimelineProject, options: ExportOptions, lines: string[]): void { const compositionMobID = this.generateMobID() lines.push(" ") lines.push(` ${compositionMobID}`) lines.push(` ${this.escapeXml(project.name)}`) lines.push(` ${project.createdAt.toISOString()}`) lines.push(` ${project.updatedAt.toISOString()}`) lines.push("") // Slots (треки) lines.push(" ") const allTracks = [...project.globalTracks, ...project.sections.flatMap((s) => s.tracks)] const sortedTracks = allTracks.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) let slotID = 1 for (const track of sortedTracks) { if (!options.includeDisabledClips && (track.isMuted || track.isHidden)) { continue } this.generateTimelineMobSlot(track, slotID++, options, lines) } lines.push(" ") lines.push(" ") lines.push("") } private generateTimelineMobSlot(track: TimelineTrack, slotID: number, options: ExportOptions, lines: string[]): void { lines.push(" ") lines.push(` ${slotID}`) lines.push(` ${slotID}`) lines.push(` ${this.escapeXml(track.name)}`) lines.push(` ${slotID}`) lines.push(` ${this.frameRate}/1`) lines.push("") // Segment (последовательность клипов) lines.push(" ") lines.push(" ") lines.push(` ${this.getDataDefinition(track.type)}`) lines.push(` ${this.calculateTrackLength(track)}`) lines.push("") lines.push(" ") // Сортируем клипы по времени const sortedClips = [...track.clips].sort((a, b) => a.startTime - b.startTime) let currentTime = 0 for (const clip of sortedClips) { // Добавляем Filler если есть пробел if (clip.startTime > currentTime) { const gapDuration = clip.startTime - currentTime this.generateFiller(gapDuration, lines, 8) currentTime = clip.startTime } // Добавляем клип this.generateSourceClip(clip, options, lines, 8) currentTime = clip.startTime + clip.duration } lines.push(" ") lines.push(" ") lines.push(" ") lines.push(" ") } private generateSourceClip(clip: TimelineClip, options: ExportOptions, lines: string[], indent: number): void { const indentStr = " ".repeat(indent) const sourceID = clip.mediaFile?.id || this.generateMobID() lines.push(`${indentStr}`) lines.push(`${indentStr} ${this.escapeXml(clip.name)}`) lines.push(`${indentStr} ${clip.mediaFile?.isVideo ? "Picture" : "Sound"}`) lines.push(`${indentStr} ${Math.round(clip.duration * this.frameRate)}`) lines.push(`${indentStr} ${sourceID}`) lines.push(`${indentStr} 1`) lines.push(`${indentStr} ${Math.round((clip.mediaStartTime || 0) * this.frameRate)}`) // Добавляем эффекты если указано if (options.includeEffects && clip.effects && clip.effects.length > 0) { lines.push(`${indentStr} `) for (const effect of clip.effects) { if (effect.enabled) { lines.push(`${indentStr} `) } } lines.push(`${indentStr} `) } // Добавляем переходы если указано if (options.includeTransitions && clip.transitions && clip.transitions.length > 0) { lines.push(`${indentStr} `) for (const transition of clip.transitions) { if (transition.isEnabled) { lines.push( `${indentStr} `, ) } } lines.push(`${indentStr} `) } lines.push(`${indentStr}`) } private generateFiller(duration: number, lines: string[], indent: number): void { const indentStr = " ".repeat(indent) const frames = Math.round(duration * this.frameRate) lines.push(`${indentStr}`) lines.push(`${indentStr} Unknown`) lines.push(`${indentStr} ${frames}`) lines.push(`${indentStr}`) } private generateSourceMobs(project: TimelineProject, _options: ExportOptions, lines: string[]): void { const processedMedia = new Set() // Собираем все уникальные медиафайлы const allTracks = [...project.globalTracks, ...project.sections.flatMap((s) => s.tracks)] for (const track of allTracks) { for (const clip of track.clips) { if (clip.mediaFile && !processedMedia.has(clip.mediaFile.id)) { processedMedia.add(clip.mediaFile.id) this.generateSourceMob(clip.mediaFile, lines) } } } } private generateSourceMob(mediaFile: any, lines: string[]): void { lines.push(" ") lines.push(` ${mediaFile.id}`) lines.push(` ${this.escapeXml(mediaFile.name)}`) lines.push(` ${mediaFile.createdAt?.toISOString() || new Date().toISOString()}`) lines.push("") // EssenceDescriptor lines.push(" ") lines.push(` ${mediaFile.hasVideo ? "FileDescriptor" : "WaveAudioDescriptor"}`) lines.push(` ${this.escapeXml(mediaFile.path)}`) if (mediaFile.hasVideo && mediaFile.width && mediaFile.height) { lines.push(` ${mediaFile.width}`) lines.push(` ${mediaFile.height}`) lines.push(` ${this.calculateAspectRatio(mediaFile.width, mediaFile.height)}`) } if (mediaFile.hasAudio) { lines.push(" 48000/1") lines.push(" 2") } lines.push(" ") lines.push(" ") lines.push("") } private calculateTrackLength(track: TimelineTrack): number { if (track.clips.length === 0) return 0 const lastClip = track.clips.reduce((latest, clip) => { const clipEnd = clip.startTime + clip.duration const latestEnd = latest.startTime + latest.duration return clipEnd > latestEnd ? clip : latest }) return Math.round((lastClip.startTime + lastClip.duration) * this.frameRate) } private getDataDefinition(trackType: string): string { switch (trackType) { case "video": return "Picture" case "audio": case "music": case "voiceover": case "sfx": case "ambient": return "Sound" case "subtitle": return "Subtitle" default: return "Unknown" } } private generateMobID(): string { // Упрощенная генерация MobID const timestamp = Date.now().toString(16) const counter = (this.mobIDCounter++).toString(16).padStart(4, "0") return `urn:smpte:umid:060a2b34.01010101.01010f00.13000000.${timestamp}${counter}0000` } private calculateAspectRatio(width: number, height: number): string { const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b)) const divisor = gcd(width, height) return `${width / divisor}:${height / divisor}` } private escapeXml(str: string): string { return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") } }