diff --git a/README.md b/README.md index 8c1ceb4..2cbc0f4 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ builds auto-update from GitHub Releases. Prefer the browser? Use the - πŸ”’ **Private by design** β€” no server, no auth, no uploads; all media processing happens on-device - πŸ“ **Word-level editing** β€” select words, press ⌫, the cut follows the text - πŸ“₯ **Import your own transcript** β€” skip Whisper and edit with an SRT, VTT, or JSON caption file -- πŸ“€ **Export hub** β€” video (MP4/WebM, 720p–4K), audio (M4A/MP3/WAV), transcript (TXT/MD), or subtitles (SRT/VTT/JSON) +- πŸ“€ **Export hub** β€” video (MP4/WebM, 720p–4K), audio (M4A/MP3/WAV), transcript (TXT/MD), subtitles (SRT/VTT/JSON), or NLE timeline (Resolve/Premiere/FCP/AAF) - 🧹 **Filler removal** β€” one-click cut of "um", "uh", and similar fillers - πŸ”‡ **Silence removal** β€” one-click cut of pauses and dead air (β‰₯0.3s) - πŸ—£οΈ **Speaker diarization** β€” the transcript is grouped by speaker @@ -44,6 +44,7 @@ builds auto-update from GitHub Releases. Prefer the browser? Use the timestamps; double-click to reset - ⚑ **Live preview** β€” playback skips your cuts in real time - πŸ“¦ **In-browser / desktop export** β€” frame-accurate re-encode with ffmpeg.wasm +- 🎞️ **NLE timeline export** β€” DaVinci Resolve / Premiere XML, Final Cut FCPXML, Pro Tools/Logic AAF - 🎧 **Audio files** β€” edit podcasts, voice notes, and interviews the same way as video - πŸ–₯️ **Desktop app** β€” macOS, Windows, and Linux via Electron (signed + notarized on Mac) diff --git a/assets/aaf/scaffold.aaf b/assets/aaf/scaffold.aaf new file mode 100644 index 0000000..140912c Binary files /dev/null and b/assets/aaf/scaffold.aaf differ diff --git a/assets/aaf/scaffold.meta.json b/assets/aaf/scaffold.meta.json new file mode 100644 index 0000000..7fa9e10 --- /dev/null +++ b/assets/aaf/scaffold.meta.json @@ -0,0 +1,11 @@ +{ + "maxClips": 64, + "editRate": 30, + "markerUrl": "file:///RESCRIPT_MEDIA_PLACEHOLDER", + "markerName": "RESCRIPT_MEDIA_PLACEHOLDER", + "sourceMobId": "urn:smpte:umid:060a2b34.01010105.01010f20.13000000.040078e3.beb94430.b7ae5183.e5fe733a", + "masterMobId": "urn:smpte:umid:060a2b34.01010105.01010f20.13000000.c28cf923.35fa45b3.ae4a1560.34ae9733", + "compMobId": "urn:smpte:umid:060a2b34.01010105.01010f20.13000000.5829115b.bdc94197.ad19d12f.d41e33d4", + "masterPictureSlot": 1, + "masterSoundSlot": 2 +} \ No newline at end of file diff --git a/components/ExportDialog.tsx b/components/ExportDialog.tsx index a78d440..249968b 100644 --- a/components/ExportDialog.tsx +++ b/components/ExportDialog.tsx @@ -3,6 +3,7 @@ import { useCallback, useMemo, useState } from "react"; import { Captions, + Clapperboard, Download, FileText, Film, @@ -23,9 +24,17 @@ import { type SubtitleFormat, type TranscriptDocFormat, } from "@/lib/serializeTranscript"; +import { + downloadTimelineExport, + TIMELINE_FORMATS, + TIMELINE_FRAME_RATES, + type TimelineExportFormat, + type TimelineFrameRate, +} from "@/lib/serializeTimeline"; +import { AAF_MAX_CLIPS } from "@/lib/aaf/patchAaf"; import { useCutRanges } from "@/hooks/useCutRanges"; -type ExportTab = "video" | "audio" | "transcript" | "subtitles"; +type ExportTab = "video" | "audio" | "transcript" | "subtitles" | "timeline"; const VIDEO_FORMATS: { value: VideoExportFormat; label: string }[] = [ { value: "mp4", label: "MP4" }, @@ -77,6 +86,11 @@ export default function ExportDialog() { const [transcriptFormat, setTranscriptFormat] = useState("txt"); const [subtitleFormat, setSubtitleFormat] = useState("srt"); + const [timelineFormat, setTimelineFormat] = + useState("resolve"); + const [timelineFrameRate, setTimelineFrameRate] = + useState("30"); + const [timelineBusy, setTimelineBusy] = useState(false); const [progress, setProgress] = useState(0); const [error, setError] = useState(null); @@ -85,6 +99,12 @@ export default function ExportDialog() { () => getEditedDuration(cuts, duration), [cuts, duration] ); + const keepRangeCount = useMemo( + () => getKeepRanges(cuts, duration).length, + [cuts, duration] + ); + const aafOverCap = + timelineFormat === "aaf" && keepRangeCount > AAF_MAX_CLIPS; const exporting = status === "exporting"; const hasWords = words.length > 0; @@ -94,11 +114,13 @@ export default function ExportDialog() { ? "audio" : tab === "audio" && !hasAudioTrack ? isAudioProject - ? "transcript" + ? "timeline" : "video" : (tab === "transcript" || tab === "subtitles") && !hasWords ? isAudioProject - ? "audio" + ? hasAudioTrack + ? "audio" + : "timeline" : "video" : tab; @@ -234,6 +256,48 @@ export default function ExportDialog() { ] ); + const exportTimeline = useCallback(async () => { + if (!videoFile) return; + setTimelineBusy(true); + setError(null); + try { + const keeps = getKeepRanges(cuts, duration); + const videoEl = useEditorStore.getState().videoEl; + const width = + videoEl && "videoWidth" in videoEl + ? (videoEl as HTMLVideoElement).videoWidth || 1920 + : 1920; + const height = + videoEl && "videoHeight" in videoEl + ? (videoEl as HTMLVideoElement).videoHeight || 1080 + : 1080; + await downloadTimelineExport(timelineFormat, { + keepRanges: keeps, + duration, + mediaFileName: videoFile.name, + projectName: baseName, + frameRate: timelineFrameRate, + withVideo: !isAudioProject, + withAudio: hasAudioTrack, + width, + height, + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Timeline export failed."); + } finally { + setTimelineBusy(false); + } + }, [ + videoFile, + cuts, + duration, + timelineFormat, + timelineFrameRate, + baseName, + isAudioProject, + hasAudioTrack, + ]); + if (!open) return null; const tabs: { @@ -273,6 +337,12 @@ export default function ExportDialog() { disabled: !hasWords, title: !hasWords ? "Transcribe or import a transcript first" : undefined, }, + { + id: "timeline", + label: "Timeline", + icon: Clapperboard, + title: "Export an NLE sequence (Resolve, Premiere, Final Cut, Pro Tools)", + }, ]; // app-no-drag: the backdrop covers the draggable top bar, so it needs to take @@ -300,7 +370,7 @@ export default function ExportDialog() {
@@ -328,7 +398,9 @@ export default function ExportDialog() { })}
- {(activeTab === "video" || activeTab === "audio") && ( + {(activeTab === "video" || + activeTab === "audio" || + activeTab === "timeline") && (
@@ -396,6 +468,69 @@ export default function ExportDialog() {
)} + {activeTab === "timeline" && ( +
+ ({ + value, + label, + }))} + disabled={timelineBusy} + onChange={setTimelineFormat} + /> +
+

+ Frame rate +

+
+ {TIMELINE_FRAME_RATES.map((opt) => { + const selected = timelineFrameRate === opt.value; + return ( + + ); + })} +
+
+

+ Sequence references your original media by filename β€” relink in + the NLE after import.{" "} + {timelineFormat === "aaf" + ? "Pro Tools / Logic AAF (metadata-only)." + : timelineFormat === "fcpx" + ? "Final Cut Pro FCPXML." + : timelineFormat === "premiere" + ? "Adobe Premiere Pro XML (xmeml)." + : "DaVinci Resolve XML (xmeml)."} +

+ {aafOverCap && ( +

+ This edit has {keepRangeCount} clips; AAF supports up to{" "} + {AAF_MAX_CLIPS}. Use Resolve, Premiere, or Final Cut instead. +

+ )} +
+ )} + {error && (

{error} @@ -474,6 +609,19 @@ export default function ExportDialog() { Download .{subtitleFormat} )} + + {activeTab === "timeline" && ( + + )} ); diff --git a/lib/aaf/patchAaf.ts b/lib/aaf/patchAaf.ts new file mode 100644 index 0000000..a8378f7 --- /dev/null +++ b/lib/aaf/patchAaf.ts @@ -0,0 +1,354 @@ +/** + * Patch the vendored AAF scaffold into a composition for the current edit. + * + * The scaffold (public/vendor/aaf/scaffold.aaf) is a TopLevel CompositionMob + * with 64 pre-allocated Picture + Sound SourceClips. We rewrite clip + * start/length, truncate the component index to the keep-range count, swap the + * media URL/name (fixed-width UTF-16), and optionally retarget the edit rate. + */ + +import * as CFB from "cfb"; +import type { TimeRange } from "../types"; + +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; +const SCAFFOLD_URL = `${BASE_PATH}/vendor/aaf/scaffold.aaf`; + +/** Must match scripts/generate-aaf-scaffold.py */ +export const AAF_MAX_CLIPS = 64; +const MARKER_NAME = "RESCRIPT_MEDIA_PLACEHOLDER"; // 26 chars β€” keep in sync with scaffold +const SCAFFOLD_EDIT_RATE = 30; + +export type AafFrameRate = + | "23.976" + | "24" + | "25" + | "29.97" + | "30" + | "50" + | "59.94" + | "60"; + +const FRAME_RATE_RATIONAL: Record = { + "23.976": { num: 24000, den: 1001 }, + "24": { num: 24, den: 1 }, + "25": { num: 25, den: 1 }, + "29.97": { num: 30000, den: 1001 }, + "30": { num: 30, den: 1 }, + "50": { num: 50, den: 1 }, + "59.94": { num: 60000, den: 1001 }, + "60": { num: 60, den: 1 }, +}; + +export interface AafExportInput { + keepRanges: TimeRange[]; + /** Original media duration in seconds (for source length). */ + duration: number; + mediaFileName: string; + frameRate: AafFrameRate; + /** Include a picture track (false for audio-only projects). */ + withVideo: boolean; + /** Include a sound track. */ + withAudio: boolean; +} + +let scaffoldPromise: Promise | null = null; + +async function loadScaffold(): Promise { + if (!scaffoldPromise) { + scaffoldPromise = fetch(SCAFFOLD_URL) + .then((r) => { + if (!r.ok) throw new Error("Could not load the AAF export template."); + return r.arrayBuffer(); + }) + .catch((err) => { + scaffoldPromise = null; + throw err; + }); + } + return scaffoldPromise; +} + +/** Encode a JS string as UTF-16LE without BOM. */ +function utf16le(s: string): Uint8Array { + const out = new Uint8Array(s.length * 2); + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + out[i * 2] = c & 0xff; + out[i * 2 + 1] = c >> 8; + } + return out; +} + +/** + * Pad / truncate to the scaffold placeholder width so we can patch in place. + * When truncating, keep the file extension so NLE relink-by-name still works. + */ +export function fitAafMediaName(fileName: string): string { + const base = fileName || "media"; + const width = MARKER_NAME.length; + if (base.length === width) return base; + if (base.length < width) return base.padEnd(width, " "); + + const lastDot = base.lastIndexOf("."); + const ext = lastDot > 0 ? base.slice(lastDot) : ""; + const stem = lastDot > 0 ? base.slice(0, lastDot) : base; + if (!ext || ext.length >= width) return base.slice(0, width); + return (stem.slice(0, width - ext.length) + ext).padEnd(width, " "); +} + +/** file:// URL for AAF NetworkLocator (percent-encoded; variable-length rewrite). */ +export function aafMediaFileUrl(fileName: string): string { + const encoded = (fileName || "media") + .split("/") + .map((p) => encodeURIComponent(p)) + .join("/"); + return `file:///${encoded}`; +} + +export function secondsToFrames(seconds: number, frameRate: AafFrameRate): number { + const { num, den } = FRAME_RATE_RATIONAL[frameRate]; + return Math.max(0, Math.round((seconds * num) / den)); +} + +function replaceUtf16InPlace(buf: Uint8Array, from: string, to: string): number { + if (from.length !== to.length) { + throw new Error("AAF in-place replace requires equal-length strings."); + } + const needle = utf16le(from); + const replacement = utf16le(to); + let hits = 0; + outer: for (let i = 0; i <= buf.length - needle.length; i++) { + for (let j = 0; j < needle.length; j++) { + if (buf[i + j] !== needle[j]) continue outer; + } + buf.set(replacement, i); + hits++; + i += needle.length - 1; + } + return hits; +} + +function writeU32LE(buf: Uint8Array, offset: number, value: number): void { + buf[offset] = value & 0xff; + buf[offset + 1] = (value >> 8) & 0xff; + buf[offset + 2] = (value >> 16) & 0xff; + buf[offset + 3] = (value >> 24) & 0xff; +} + +function writeI64LE(buf: Uint8Array, offset: number, value: number): void { + const lo = value >>> 0; + const hi = Math.floor(value / 0x1_0000_0000); + writeU32LE(buf, offset, lo); + writeU32LE(buf, offset + 4, hi); +} + +function writeI32LE(buf: Uint8Array, offset: number, value: number): void { + writeU32LE(buf, offset, value >>> 0); +} + +/** Parse an AAF `properties` stream and return byte offsets of SF_DATA payloads by pid. */ +function dataOffsetsByPid(props: Uint8Array): Map { + if (props.length < 4 || props[0] !== 0x4c) { + throw new Error("Invalid AAF property stream."); + } + const entryCount = props[2] | (props[3] << 8); + const map = new Map(); + let header = 4; + let data = 4 + entryCount * 6; + for (let i = 0; i < entryCount; i++) { + const pid = props[header] | (props[header + 1] << 8); + const size = props[header + 4] | (props[header + 5] << 8); + map.set(pid, data); + header += 6; + data += size; + } + return map; +} + +function patchClipProperties( + props: Uint8Array, + startFrames: number, + lengthFrames: number +): void { + const offsets = dataOffsetsByPid(props); + const lengthOff = offsets.get(0x0202); // Length + const startOff = offsets.get(0x1201); // StartTime + if (lengthOff == null || startOff == null) { + throw new Error("SourceClip is missing Length/StartTime."); + } + writeI64LE(props, lengthOff, lengthFrames); + writeI64LE(props, startOff, startFrames); +} + +function writeComponentsIndex(count: number, maxClips: number): Uint8Array { + const buf = new Uint8Array(12 + count * 4); + writeU32LE(buf, 0, count); + writeU32LE(buf, 4, maxClips); + writeU32LE(buf, 8, 0xffffffff); + for (let i = 0; i < count; i++) writeU32LE(buf, 12 + i * 4, i); + return buf; +} + +function patchEditRate(slotProps: Uint8Array, num: number, den: number): void { + const oldNum = SCAFFOLD_EDIT_RATE; + const oldDen = 1; + for (let i = 0; i <= slotProps.length - 8; i++) { + const n = + slotProps[i] | + (slotProps[i + 1] << 8) | + (slotProps[i + 2] << 16) | + (slotProps[i + 3] << 24); + const d = + slotProps[i + 4] | + (slotProps[i + 5] << 8) | + (slotProps[i + 6] << 16) | + (slotProps[i + 7] << 24); + if (n === oldNum && d === oldDen) { + writeI32LE(slotProps, i, num); + writeI32LE(slotProps, i + 4, den); + return; + } + } + throw new Error( + `AAF scaffold edit rate ${oldNum}/${oldDen} not found; cannot retarget to ${num}/${den}.` + ); +} + +function writeLocatorProperties(url: string): Uint8Array { + const data = utf16le(url); + // property header: byte_order, version, entry_count=1 + // entry: pid=0x4001 (URLString), format=0x82 (SF_DATA), size + const out = new Uint8Array(4 + 6 + data.length); + out[0] = 0x4c; + out[1] = 0x20; // PROPERTY_VERSION + out[2] = 1; + out[3] = 0; // entry_count = 1 + out[4] = 0x01; + out[5] = 0x40; // pid 0x4001 + out[6] = 0x82; + out[7] = 0x00; // SF_DATA + out[8] = data.length & 0xff; + out[9] = (data.length >> 8) & 0xff; + out.set(data, 10); + return out; +} + +function ensureContent(content: CFB.CFB$Blob | undefined | null): Uint8Array { + if (content == null) return new Uint8Array(); + return content instanceof Uint8Array + ? new Uint8Array(content) + : Uint8Array.from(content); +} + +/** + * Build a metadata-only AAF composition. The NLE will ask the user to relink + * to the original media file by name. + */ +export async function writeAafComposition(input: AafExportInput): Promise { + const { keepRanges, duration, mediaFileName, frameRate, withVideo, withAudio } = + input; + if (keepRanges.length === 0) { + throw new Error("Everything has been deleted β€” nothing to export."); + } + if (keepRanges.length > AAF_MAX_CLIPS) { + throw new Error( + `AAF export supports up to ${AAF_MAX_CLIPS} clips (this edit has ${keepRanges.length}).` + ); + } + if (!withVideo && !withAudio) { + throw new Error("Nothing to put on the AAF timeline."); + } + + const scaffold = await loadScaffold(); + const cfb = CFB.parse(new Uint8Array(scaffold)); + + const fittedName = fitAafMediaName(mediaFileName); + const realUrl = aafMediaFileUrl(mediaFileName); + + for (let i = 0; i < cfb.FileIndex.length; i++) { + const entry = cfb.FileIndex[i]; + const path = cfb.FullPaths[i] ?? ""; + if (!entry?.content || entry.content.length === 0) continue; + + // NetworkLocator URL β€” variable-length rewrite so the path stays exact. + if (/Locator-2f01\{0\}\/properties$/.test(path)) { + entry.content = writeLocatorProperties(realUrl); + continue; + } + + const buf = ensureContent(entry.content); + replaceUtf16InPlace(buf, MARKER_NAME, fittedName); + // Leave MARKER_URL alone here; locator stream handled above. + entry.content = buf; + } + + const rate = FRAME_RATE_RATIONAL[frameRate]; + const sourceFrames = Math.max(1, secondsToFrames(duration, frameRate)); + const clips = keepRanges.map((r) => { + const start = secondsToFrames(r.start, frameRate); + const end = secondsToFrames(r.end, frameRate); + return { start, length: Math.max(1, end - start) }; + }); + const n = clips.length; + + const pictureCount = withVideo ? n : 0; + const soundCount = withAudio ? n : 0; + + for (let i = 0; i < cfb.FullPaths.length; i++) { + const path = cfb.FullPaths[i]; + const entry = cfb.FileIndex[i]; + if (!entry) continue; + + const clipMatch = path.match( + /Mobs-1901\{2\}\/Slots-4403\{([01])\}\/Segment-4803\/Components-1001\{([0-9a-f]+)\}\/properties$/ + ); + if (clipMatch) { + const slot = Number(clipMatch[1]); + const key = parseInt(clipMatch[2], 16); + const count = slot === 0 ? pictureCount : soundCount; + if (key < count) { + const buf = ensureContent(entry.content); + patchClipProperties(buf, clips[key].start, clips[key].length); + entry.content = buf; + } + continue; + } + + const indexMatch = path.match( + /Mobs-1901\{2\}\/Slots-4403\{([01])\}\/Segment-4803\/Components-1001 index$/ + ); + if (indexMatch) { + const slot = Number(indexMatch[1]); + const count = slot === 0 ? pictureCount : soundCount; + entry.content = writeComponentsIndex(count, AAF_MAX_CLIPS); + continue; + } + + if (/Slots-4403\{\d+\}\/properties$/.test(path)) { + const buf = ensureContent(entry.content); + patchEditRate(buf, rate.num, rate.den); + entry.content = buf; + continue; + } + + const srcLenMatch = path.match( + /Mobs-1901\{0\}\/Slots-4403\{([01])\}\/Segment-4803\/properties$/ + ); + if (srcLenMatch) { + const buf = ensureContent(entry.content); + const offsets = dataOffsetsByPid(buf); + const lengthOff = offsets.get(0x0202); + if (lengthOff != null) { + writeI64LE(buf, lengthOff, sourceFrames); + entry.content = buf; + } + } + } + + const out = CFB.write(cfb, { type: "array" }) as number[] | Uint8Array; + const bytes = + out instanceof Uint8Array ? out : Uint8Array.from(out as number[]); + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return new Blob([copy], { type: "application/octet-stream" }); +} diff --git a/lib/serializeTimeline.ts b/lib/serializeTimeline.ts new file mode 100644 index 0000000..e2afdf1 --- /dev/null +++ b/lib/serializeTimeline.ts @@ -0,0 +1,250 @@ +/** + * Build NLE timeline interchange files from the editor's keep ranges. + * + * XML / FCPXML go through @chatoctopus/timeline writers (imported from dist + * subpaths to avoid pulling the Node-only ffprobe helper into the browser + * bundle). AAF is produced by patching a vendored metadata-only scaffold. + */ + +import { writeFCPXML } from "../node_modules/@chatoctopus/timeline/dist/fcpxml/writer.js"; +import { writeXMEML } from "../node_modules/@chatoctopus/timeline/dist/xmeml/writer.js"; +import { + FRAME_RATES, + rational, + ZERO, +} from "../node_modules/@chatoctopus/timeline/dist/time.js"; +import type { Timeline } from "@chatoctopus/timeline"; +import { + writeAafComposition, + type AafFrameRate, +} from "@/lib/aaf/patchAaf"; +import type { TimeRange } from "@/lib/types"; + +export type TimelineExportFormat = "resolve" | "premiere" | "fcpx" | "aaf"; + +export type TimelineFrameRate = AafFrameRate; + +export const TIMELINE_FRAME_RATES: { + value: TimelineFrameRate; + label: string; +}[] = [ + { value: "23.976", label: "23.976" }, + { value: "24", label: "24" }, + { value: "25", label: "25" }, + { value: "29.97", label: "29.97" }, + { value: "30", label: "30" }, + { value: "50", label: "50" }, + { value: "59.94", label: "59.94" }, + { value: "60", label: "60" }, +]; + +export const TIMELINE_FORMATS: { + value: TimelineExportFormat; + label: string; + ext: string; +}[] = [ + { value: "resolve", label: "Resolve", ext: "xml" }, + { value: "premiere", label: "Premiere", ext: "xml" }, + { value: "fcpx", label: "Final Cut", ext: "fcpxml" }, + { value: "aaf", label: "Pro Tools", ext: "aaf" }, +]; + +export interface TimelineExportOptions { + keepRanges: TimeRange[]; + duration: number; + mediaFileName: string; + projectName?: string; + frameRate: TimelineFrameRate; + /** false for audio-only projects */ + withVideo: boolean; + withAudio: boolean; + width?: number; + height?: number; + audioRate?: number; +} + +function frameRateRational(frameRate: TimelineFrameRate) { + return FRAME_RATES[frameRate] ?? FRAME_RATES["30"]; +} + +function secondsToRational(seconds: number, frameRate: TimelineFrameRate) { + const fr = frameRateRational(frameRate); + const frames = Math.max(0, Math.round(seconds * (fr.num / fr.den))); + return rational(frames * fr.den, fr.num); +} + +/** file:// URL that NLEs can attempt to resolve; users usually relink by name. */ +export function mediaFileUrl(fileName: string, forResolve = false): string { + const encoded = fileName + .split("/") + .map((p) => encodeURIComponent(p)) + .join("/"); + return forResolve + ? `file://localhost/${encoded}` + : `file:///${encoded}`; +} + +export function buildNleTimeline(options: TimelineExportOptions): Timeline { + const { + keepRanges, + duration, + mediaFileName, + projectName, + frameRate, + withVideo, + withAudio, + width = 1920, + height = 1080, + audioRate = 48000, + } = options; + + if (keepRanges.length === 0) { + throw new Error("Everything has been deleted β€” nothing to export."); + } + + const fr = frameRateRational(frameRate); + const available = { + startTime: ZERO, + duration: secondsToRational(Math.max(duration, 0.001), frameRate), + }; + + const makeClip = (range: TimeRange, index: number, kind: "video" | "audio") => { + const startTime = secondsToRational(range.start, frameRate); + const clipDur = secondsToRational( + Math.max(range.end - range.start, 1 / 120), + frameRate + ); + return { + kind: "clip" as const, + name: `${mediaFileName} ${index + 1}`, + mediaReference: { + type: "external" as const, + name: mediaFileName, + targetUrl: mediaFileUrl(mediaFileName, false), + mediaKind: kind === "video" ? ("video" as const) : ("audio" as const), + availableRange: available, + streamInfo: { + hasVideo: withVideo, + hasAudio: withAudio, + width, + height, + frameRate: fr, + audioRate, + audioChannels: withAudio ? 2 : 0, + }, + }, + sourceRange: { startTime, duration: clipDur }, + }; + }; + + const tracks: Timeline["tracks"] = []; + if (withVideo) { + tracks.push({ + kind: "video", + name: "V1", + items: keepRanges.map((r, i) => makeClip(r, i, "video")), + }); + } + if (withAudio) { + tracks.push({ + kind: "audio", + name: "A1", + items: keepRanges.map((r, i) => makeClip(r, i, "audio")), + }); + } + if (tracks.length === 0) { + throw new Error("Nothing to put on the timeline."); + } + + return { + name: projectName || mediaFileName.replace(/\.[^.]+$/, "") || "Rescript Edit", + format: { + width, + height, + frameRate: fr, + audioRate, + audioChannels: withAudio ? 2 : 0, + audioLayout: "stereo", + colorSpace: "1-1-1 (Rec. 709)", + }, + tracks, + }; +} + +export function timelineExtension(format: TimelineExportFormat): string { + return TIMELINE_FORMATS.find((f) => f.value === format)?.ext ?? format; +} + +export function serializeTimelineXml( + options: TimelineExportOptions, + format: Exclude +): string { + const timeline = buildNleTimeline(options); + if (format === "resolve") { + for (const track of timeline.tracks) { + for (const item of track.items) { + if (item.kind !== "clip") continue; + const ref = item.mediaReference; + if (ref.type === "external") { + ref.targetUrl = mediaFileUrl(ref.name || options.mediaFileName, true); + } + } + } + return writeXMEML(timeline); + } + if (format === "premiere") return writeXMEML(timeline); + return writeFCPXML(timeline); +} + +export async function serializeTimelineAaf( + options: TimelineExportOptions +): Promise { + return writeAafComposition({ + keepRanges: options.keepRanges, + duration: options.duration, + mediaFileName: options.mediaFileName, + frameRate: options.frameRate, + withVideo: options.withVideo, + withAudio: options.withAudio, + }); +} + +/** Trigger a browser download for an XML/FCPXML string or AAF blob. */ +export function downloadTimelineBlob( + data: string | Blob, + filename: string, + mime: string +): void { + const blob = + typeof data === "string" + ? new Blob([data], { type: `${mime};charset=utf-8` }) + : data; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +export async function downloadTimelineExport( + format: TimelineExportFormat, + options: TimelineExportOptions +): Promise { + const base = (options.projectName || options.mediaFileName || "edited").replace( + /\.[^.]+$/, + "" + ); + const ext = timelineExtension(format); + const filename = `${base}.edited.${ext}`; + + if (format === "aaf") { + const blob = await serializeTimelineAaf(options); + downloadTimelineBlob(blob, filename, "application/octet-stream"); + return; + } + + const xml = serializeTimelineXml(options, format); + const mime = format === "fcpx" ? "application/xml" : "text/xml"; + downloadTimelineBlob(xml, filename, mime); +} diff --git a/next.config.ts b/next.config.ts index 53bb86e..e6d470d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -9,8 +9,8 @@ const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; const nextConfig: NextConfig = { reactStrictMode: true, - // parakeet.js ships as raw ESM from src/; transpile for the worker bundle. - transpilePackages: ["parakeet.js"], + // ESM timeline / CFB helpers and parakeet.js ship modern/raw ESM; transpile for Next's bundler. + transpilePackages: ["@chatoctopus/timeline", "cfb", "parakeet.js"], ...(isExport ? { output: "export" as const, diff --git a/package-lock.json b/package-lock.json index b3642fe..c8c51ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { + "@chatoctopus/timeline": "^0.3.0", "@ffmpeg/core-mt": "^0.12.10", "@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/util": "^0.12.2", @@ -17,6 +18,7 @@ "@huggingface/transformers": "^4.2.0", "@next/third-parties": "^16.2.12", "@vercel/analytics": "^2.0.1", + "cfb": "^1.2.2", "coi-serviceworker": "^0.1.7", "lucide-react": "^1.27.0", "next": "16.2.12", @@ -372,6 +374,21 @@ "node": ">=6.9.0" } }, + "node_modules/@chatoctopus/timeline": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@chatoctopus/timeline/-/timeline-0.3.0.tgz", + "integrity": "sha512-0Pn52Q3Y2n3gKM3pt/4Z6HhrhZjzH5iSh8qg/NaONfZjWsJVpd8yVuJ04Y2Ex+jD6jWatv65M+SVnuQh0VXmNQ==", + "license": "MIT", + "dependencies": { + "fast-xml-parser": "^5.2.0" + }, + "bin": { + "timeline": "dist/cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@electron-internal/extract-zip": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", @@ -2370,6 +2387,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3647,6 +3676,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/adm-zip": { "version": "0.5.18", "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", @@ -3728,6 +3766,18 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/app-builder-lib": { "version": "26.15.3", "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", @@ -4499,6 +4549,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4701,6 +4764,18 @@ "dev": true, "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", @@ -6262,6 +6337,45 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", + "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz", + "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.1", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -7420,6 +7534,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-unsafe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -8832,6 +8958,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -10089,6 +10230,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -10914,6 +11070,21 @@ "dev": true, "license": "ISC" }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index 2b72b95..0088028 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "notes:preview": "tsx scripts/generate-release-notes.ts HEAD \"$(git tag --sort=-v:refname | head -1)\"" }, "dependencies": { + "@chatoctopus/timeline": "^0.3.0", "@ffmpeg/core-mt": "^0.12.10", "@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/util": "^0.12.2", @@ -39,6 +40,7 @@ "@huggingface/transformers": "^4.2.0", "@next/third-parties": "^16.2.12", "@vercel/analytics": "^2.0.1", + "cfb": "^1.2.2", "coi-serviceworker": "^0.1.7", "lucide-react": "^1.27.0", "next": "16.2.12", diff --git a/scripts/copy-assets.mjs b/scripts/copy-assets.mjs index 732388f..4f270f1 100644 --- a/scripts/copy-assets.mjs +++ b/scripts/copy-assets.mjs @@ -120,6 +120,14 @@ writeFileSync( coiPrelude + readFileSync(coiSrc, "utf8") ); +// Metadata-only AAF scaffold for Pro Tools / Logic timeline export. +const aafSrc = join(root, "assets/aaf"); +const aafDst = join(root, "public/vendor/aaf"); +mkdirSync(aafDst, { recursive: true }); +for (const f of readdirSync(aafSrc)) { + cpSync(join(aafSrc, f), join(aafDst, f)); +} + console.log( - "[copy-assets] ffmpeg core + onnxruntime wasm + coi-serviceworker copied to public/" + "[copy-assets] ffmpeg core + onnxruntime wasm + coi-serviceworker + aaf scaffold copied to public/" ); diff --git a/scripts/generate-aaf-scaffold.py b/scripts/generate-aaf-scaffold.py new file mode 100644 index 0000000..090fbd9 --- /dev/null +++ b/scripts/generate-aaf-scaffold.py @@ -0,0 +1,97 @@ +/** + * Regenerate the patchable AAF scaffold used by browser-side AAF export. + * Run: python3 scripts/generate-aaf-scaffold.py + */ +from __future__ import annotations + +import json +import os +from pathlib import Path + +import aaf2 +from aaf2.auid import AUID + +ROOT = Path(__file__).resolve().parents[1] +OUT_DIR = ROOT / "assets" / "aaf" + +MAX_CLIPS = 64 +EDIT_RATE = 30 +MEDIA_FRAMES = 10_000_000 +MARKER_URL = "file:///RESCRIPT_MEDIA_PLACEHOLDER" +MARKER_NAME = "RESCRIPT_MEDIA_PLACEHOLDER" # 26 chars β€” keep in sync with lib/aaf/patchAaf.ts + + +def main() -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + out = OUT_DIR / "scaffold.aaf" + + with aaf2.open(str(out), "w") as f: + src = f.create.SourceMob() + src.name = MARKER_NAME + desc = f.create.ImportDescriptor() + loc = f.create.NetworkLocator() + loc["URLString"].value = MARKER_URL + desc["Locator"].value = [loc] + src.descriptor = desc + f.content.mobs.append(src) + + pic = src.create_picture_slot(EDIT_RATE) + pic.segment.length = MEDIA_FRAMES + snd = src.create_sound_slot(EDIT_RATE) + snd.segment.length = MEDIA_FRAMES + + master = f.create.MasterMob() + master.name = MARKER_NAME + f.content.mobs.append(master) + mps = master.create_timeline_slot(EDIT_RATE) + mps.segment = src.create_source_clip( + slot_id=pic.slot_id, start=0, length=MEDIA_FRAMES, media_kind="Picture" + ) + mss = master.create_timeline_slot(EDIT_RATE) + mss.segment = src.create_source_clip( + slot_id=snd.slot_id, start=0, length=MEDIA_FRAMES, media_kind="Sound" + ) + + comp = f.create.CompositionMob("Rescript Edit") + comp["UsageCode"].value = AUID("0d010102-0101-0700-060e-2b3404010101") + f.content.mobs.append(comp) + + for kind, master_slot in [("Picture", mps), ("Sound", mss)]: + seq = f.create.Sequence(media_kind=kind) + for i in range(MAX_CLIPS): + seq.components.append( + master.create_source_clip( + slot_id=master_slot.slot_id, + start=i, + length=1, + media_kind=kind, + ) + ) + slot = comp.create_timeline_slot(EDIT_RATE) + slot.segment = seq + + meta = { + "maxClips": MAX_CLIPS, + "editRate": EDIT_RATE, + "markerUrl": MARKER_URL, + "markerName": MARKER_NAME, + "sourceMobId": str(src.mob_id), + "masterMobId": str(master.mob_id), + "compMobId": str(comp.mob_id), + "masterPictureSlot": mps.slot_id, + "masterSoundSlot": mss.slot_id, + } + + with aaf2.open(str(out), "r") as f: + tops = [m.name for m in f.content.toplevel()] + if tops != ["Rescript Edit"]: + raise SystemExit(f"expected TopLevel composition, got {tops!r}") + + meta_path = OUT_DIR / "scaffold.meta.json" + meta_path.write_text(json.dumps(meta, indent=2) + "\n") + print(f"wrote {out} ({os.path.getsize(out)} bytes)") + print(f"wrote {meta_path}") + + +if __name__ == "__main__": + main() diff --git a/tests/serialize-timeline-test.ts b/tests/serialize-timeline-test.ts new file mode 100644 index 0000000..77cd6b6 --- /dev/null +++ b/tests/serialize-timeline-test.ts @@ -0,0 +1,266 @@ +/** + * Unit tests for NLE timeline export (XML / FCPXML / AAF). + * Run: npx tsx tests/serialize-timeline-test.ts + */ +import { readFileSync, writeFileSync } from "fs"; +import { resolve } from "path"; +import { + buildNleTimeline, + mediaFileUrl, + serializeTimelineXml, +} from "../lib/serializeTimeline"; +import { + AAF_MAX_CLIPS, + aafMediaFileUrl, + fitAafMediaName, + secondsToFrames, + writeAafComposition, +} from "../lib/aaf/patchAaf"; + +// Node 18+ has fetch; polyfill scaffold loading from disk for AAF tests. +const scaffoldPath = resolve("assets/aaf/scaffold.aaf"); +const scaffoldBuf = readFileSync(scaffoldPath); +const realFetch = globalThis.fetch; +globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("scaffold.aaf")) { + return new Response(scaffoldBuf, { status: 200 }); + } + return realFetch(input); +}) as typeof fetch; + +function assert(cond: boolean, msg: string) { + if (!cond) throw new Error(msg); +} + +const keeps = [ + { start: 0, end: 1 }, + { start: 2, end: 3.5 }, +]; + +async function main() { +{ + const width = "RESCRIPT_MEDIA_PLACEHOLDER".length; + assert(fitAafMediaName("a.mp4").length === width, "fit pads"); + assert(fitAafMediaName("a".repeat(40)).length === width, "fit truncates"); + const longNamed = fitAafMediaName("My Long Interview Recording Final.mp4"); + assert(longNamed.length === width, "long name width"); + assert(longNamed.trimEnd().endsWith(".mp4"), "long name keeps extension"); + assert( + !fitAafMediaName("short.wav").includes("\0"), + "padded name has no nulls" + ); + assert(mediaFileUrl("clip.mp4").startsWith("file:///"), "file url"); + assert( + mediaFileUrl("clip.mp4", true).startsWith("file://localhost/"), + "resolve url" + ); + assert( + mediaFileUrl("my clip.mp4").includes("my%20clip.mp4"), + "xml url encodes spaces" + ); + assert( + aafMediaFileUrl("my clip.mp4") === "file:///my%20clip.mp4", + "aaf url encodes spaces" + ); + assert(secondsToFrames(1, "30") === 30, "30fps frames"); + assert(secondsToFrames(1, "25") === 25, "25fps frames"); + console.log("helpers: ok"); +} + +{ + const timeline = buildNleTimeline({ + keepRanges: keeps, + duration: 5, + mediaFileName: "interview.mp4", + frameRate: "30", + withVideo: true, + withAudio: true, + }); + assert(timeline.tracks.length === 2, "v+a tracks"); + assert(timeline.tracks[0].items.length === 2, "two video clips"); + assert(timeline.tracks[1].items.length === 2, "two audio clips"); + console.log("build timeline: ok"); +} + +{ + const premiere = serializeTimelineXml( + { + keepRanges: keeps, + duration: 5, + mediaFileName: "interview.mp4", + frameRate: "24", + withVideo: true, + withAudio: true, + }, + "premiere" + ); + assert(premiere.includes("") || fcpx.includes(""), "audio track present"); + console.log("audio-only xml: ok"); +} + +{ + const blob = await writeAafComposition({ + keepRanges: keeps, + duration: 5, + mediaFileName: "interview.mp4", + frameRate: "30", + withVideo: true, + withAudio: true, + }); + assert(blob.size > 100_000, `aaf size ${blob.size}`); + const buf = Buffer.from(await blob.arrayBuffer()); + // Compound File magic / CFB signature often starts with D0 CF 11 E0 + assert(buf[0] === 0xd0 && buf[1] === 0xcf, "cfb magic"); + assert(buf.includes(Buffer.from("interview.mp4", "utf16le")), "aaf has filename"); + assert( + buf.includes(Buffer.from("file:///interview.mp4", "utf16le")), + "aaf has encoded-safe url" + ); + writeFileSync("/tmp/rescript-test.aaf", buf); + console.log("aaf write: ok"); +} + +{ + const spaced = await writeAafComposition({ + keepRanges: keeps, + duration: 5, + mediaFileName: "my clip.mp4", + frameRate: "24", + withVideo: true, + withAudio: true, + }); + const spacedBuf = Buffer.from(await spaced.arrayBuffer()); + assert( + spacedBuf.includes(Buffer.from("file:///my%20clip.mp4", "utf16le")), + "aaf encodes spaces in locator url" + ); + console.log("aaf url encoding: ok"); +} + +{ + const tooMany = Array.from({ length: AAF_MAX_CLIPS + 1 }, (_, i) => ({ + start: i, + end: i + 0.5, + })); + let threw = false; + try { + await writeAafComposition({ + keepRanges: tooMany, + duration: AAF_MAX_CLIPS + 2, + mediaFileName: "interview.mp4", + frameRate: "30", + withVideo: true, + withAudio: true, + }); + } catch (err) { + threw = err instanceof Error && err.message.includes(String(AAF_MAX_CLIPS)); + } + assert(threw, "aaf rejects >64 clips"); + console.log("aaf clip cap: ok"); +} + +// Validate with pyaaf2 when available (optional β€” skip if not installed). +{ + const { spawnSync } = await import("child_process"); + const probe = spawnSync( + "python3", + ["-c", "import aaf2"], + { encoding: "utf8" } + ); + if (probe.status !== 0) { + console.warn("SKIP: pyaaf2 not installed; AAF round-trip check skipped"); + } else { + const py = spawnSync( + "python3", + [ + "-c", + ` +import aaf2, sys +with aaf2.open("/tmp/rescript-test.aaf", "r") as f: + tops = list(f.content.toplevel()) + assert len(tops) == 1, tops + comp = tops[0] + slots = list(comp.slots) + assert len(slots) == 2, len(slots) + for slot in slots: + comps = list(slot.segment.components) + assert len(comps) == 2, len(comps) + assert comps[0].length == 30, comps[0].length + assert comps[1].start == 60, comps[1].start + assert comps[1].length == 45, comps[1].length +print("pyaaf2: ok") +`, + ], + { encoding: "utf8" } + ); + if (py.status !== 0) { + console.error(py.stdout, py.stderr); + throw new Error("pyaaf2 validation failed"); + } + process.stdout.write(py.stdout); + } +} + +console.log("ALL SERIALIZE TIMELINE TESTS PASSED"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});