diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4113765..ef2193f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,9 @@ jobs: - name: UI localization tests run: npm run test:i18n + - name: Timeline export tests + run: npm run test:timeline + - name: Build Next.js run: npm run build diff --git a/README.md b/README.md index 14ce6ff..5cab08c 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 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 6969706..7819c24 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, @@ -24,12 +25,20 @@ 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"; import { useI18n } from "./I18nProvider"; import { localizeRuntimeMessage } from "@/lib/i18n"; import { en } from "@/lib/i18n/messages/en"; -type ExportTab = "video" | "audio" | "transcript" | "subtitles"; +type ExportTab = "video" | "audio" | "transcript" | "subtitles" | "timeline"; const VIDEO_FORMATS: { value: VideoExportFormat; label: string }[] = [ { value: "mp4", label: "MP4" }, @@ -83,6 +92,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); @@ -91,7 +105,14 @@ 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 dialogBusy = exporting || timelineBusy; const hasWords = words.length > 0; // Fall back when the remembered tab isn't valid for this project. @@ -100,11 +121,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; @@ -248,6 +271,49 @@ 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, + }); + trackEvent("export_completed", { kind: "timeline", format: timelineFormat }); + } catch (err) { + setError(err instanceof Error ? err.message : en["error.timelineExport"]); + } finally { + setTimelineBusy(false); + } + }, [ + videoFile, + cuts, + duration, + timelineFormat, + timelineFrameRate, + baseName, + isAudioProject, + hasAudioTrack, + ]); + if (!open) return null; const tabs: { @@ -287,6 +353,12 @@ export default function ExportDialog() { disabled: !hasWords, title: !hasWords ? t("export.noWordsFirst") : undefined, }, + { + id: "timeline", + label: t("export.timeline"), + icon: Clapperboard, + title: t("export.timelineTitle"), + }, ]; // app-no-drag: the backdrop covers the draggable top bar, so it needs to take @@ -294,7 +366,7 @@ export default function ExportDialog() { return (
!exporting && setOpen(false)} + onClick={() => !dialogBusy && setOpen(false)} >
@@ -326,7 +398,7 @@ export default function ExportDialog() { type="button" role="tab" aria-selected={selected} - disabled={disabled || exporting} + disabled={disabled || dialogBusy} title={title} onClick={() => selectTab(id)} className={`flex h-9 items-center justify-center gap-1.5 rounded-[0.625rem] text-xs font-medium transition disabled:cursor-not-allowed disabled:opacity-40 ${ @@ -342,7 +414,9 @@ export default function ExportDialog() { })}
- {(activeTab === "video" || activeTab === "audio") && ( + {(activeTab === "video" || + activeTab === "audio" || + activeTab === "timeline") && (
@@ -417,6 +491,72 @@ export default function ExportDialog() {
)} + {activeTab === "timeline" && ( +
+ ({ + value, + label, + }))} + disabled={timelineBusy} + onChange={setTimelineFormat} + /> +
+

+ {t("export.frameRate")} +

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

+ {t("export.timelineHelp")}{" "} + {t( + timelineFormat === "aaf" + ? "export.timelineHelpAaf" + : timelineFormat === "fcpx" + ? "export.timelineHelpFcpx" + : timelineFormat === "premiere" + ? "export.timelineHelpPremiere" + : "export.timelineHelpResolve" + )} +

+ {aafOverCap && ( +

+ {t("export.aafOverCap", { + count: keepRangeCount, + max: AAF_MAX_CLIPS, + })} +

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

{localizeRuntimeMessage(error, t)} @@ -497,6 +637,23 @@ export default function ExportDialog() { {t("export.downloadFormat", { format: subtitleFormat })} )} + + {activeTab === "timeline" && ( + + )}

); diff --git a/lib/aaf/patchAaf.ts b/lib/aaf/patchAaf.ts new file mode 100644 index 0000000..ea41a45 --- /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 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/i18n/messages/de.ts b/lib/i18n/messages/de.ts index 5c48e62..fdb4784 100644 --- a/lib/i18n/messages/de.ts +++ b/lib/i18n/messages/de.ts @@ -145,6 +145,19 @@ export const de: Record = { "export.downloadFile": "{name} herunterladen", "export.downloadFormat": ".{format} herunterladen", "export.exportFormat": "{format} exportieren", + "export.timeline": "Timeline", + "export.timelineTitle": "NLE-Sequenz exportieren (Resolve, Premiere, Final Cut, Pro Tools)", + "export.nle": "NLE", + "export.frameRate": "Bildrate", + "export.timelineHelp": + "Die Sequenz verweist auf das Originalmedium per Dateiname — nach dem Import im NLE neu verknüpfen.", + "export.timelineHelpAaf": "Pro Tools / Logic AAF (nur Metadaten).", + "export.timelineHelpFcpx": "Final Cut Pro FCPXML.", + "export.timelineHelpPremiere": "Adobe Premiere Pro XML (xmeml).", + "export.timelineHelpResolve": "DaVinci Resolve XML (xmeml).", + "export.aafOverCap": + "Dieser Schnitt hat {count} Clips; AAF unterstützt bis zu {max}. Nutze Resolve, Premiere oder Final Cut.", + "export.preparing": "Wird vorbereitet…", "import.reading": "Transkript wird gelesen…", "import.importing": "Import läuft…", "import.failed": "Import fehlgeschlagen", @@ -185,6 +198,9 @@ export const de: Record = { "error.videoExport": "Export beim Rendern des Videos fehlgeschlagen.", "error.audioExport": "Export beim Rendern des Audios fehlgeschlagen.", "error.export": "Export fehlgeschlagen.", + "error.timelineExport": "Timeline-Export fehlgeschlagen.", + "error.timelineEmpty": "Nichts auf die Timeline zu setzen.", + "error.aafTemplate": "AAF-Exportvorlage konnte nicht geladen werden.", "error.emptyTranscript": "Diese Transkriptdatei ist leer.", "error.noTimedWords": "Keine Wörter mit Zeitangaben in diesem Transkript gefunden.", "error.parseJson": "Dieses JSON-Transkript konnte nicht geparst werden.", diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 7268395..7f0b6fa 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -144,6 +144,19 @@ export const en = { "export.downloadFile": "Download {name}", "export.downloadFormat": "Download .{format}", "export.exportFormat": "Export {format}", + "export.timeline": "Timeline", + "export.timelineTitle": "Export an NLE sequence (Resolve, Premiere, Final Cut, Pro Tools)", + "export.nle": "NLE", + "export.frameRate": "Frame rate", + "export.timelineHelp": + "Sequence references your original media by filename — relink in the NLE after import.", + "export.timelineHelpAaf": "Pro Tools / Logic AAF (metadata-only).", + "export.timelineHelpFcpx": "Final Cut Pro FCPXML.", + "export.timelineHelpPremiere": "Adobe Premiere Pro XML (xmeml).", + "export.timelineHelpResolve": "DaVinci Resolve XML (xmeml).", + "export.aafOverCap": + "This edit has {count} clips; AAF supports up to {max}. Use Resolve, Premiere, or Final Cut instead.", + "export.preparing": "Preparing…", "import.reading": "Reading transcript…", "import.importing": "Import…", "import.failed": "Import failed", @@ -185,6 +198,9 @@ export const en = { "error.videoExport": "Export failed while rendering the video.", "error.audioExport": "Export failed while rendering the audio.", "error.export": "Export failed.", + "error.timelineExport": "Timeline export failed.", + "error.timelineEmpty": "Nothing to put on the timeline.", + "error.aafTemplate": "Could not load the AAF export template.", "error.emptyTranscript": "That transcript file is empty.", "error.noTimedWords": "No timed words found in that transcript.", "error.parseJson": "Could not parse that JSON transcript.", diff --git a/lib/i18n/messages/es.ts b/lib/i18n/messages/es.ts index 693fbbc..9d2a533 100644 --- a/lib/i18n/messages/es.ts +++ b/lib/i18n/messages/es.ts @@ -145,6 +145,19 @@ export const es: Record = { "export.downloadFile": "Descargar {name}", "export.downloadFormat": "Descargar .{format}", "export.exportFormat": "Exportar {format}", + "export.timeline": "Línea de tiempo", + "export.timelineTitle": "Exportar una secuencia NLE (Resolve, Premiere, Final Cut, Pro Tools)", + "export.nle": "NLE", + "export.frameRate": "Velocidad de fotogramas", + "export.timelineHelp": + "La secuencia referencia el medio original por nombre de archivo — vuelve a vincularlo en el NLE después de importar.", + "export.timelineHelpAaf": "AAF de Pro Tools / Logic (solo metadatos).", + "export.timelineHelpFcpx": "FCPXML de Final Cut Pro.", + "export.timelineHelpPremiere": "XML de Adobe Premiere Pro (xmeml).", + "export.timelineHelpResolve": "XML de DaVinci Resolve (xmeml).", + "export.aafOverCap": + "Esta edición tiene {count} clips; AAF admite hasta {max}. Usa Resolve, Premiere o Final Cut.", + "export.preparing": "Preparando…", "import.reading": "Leyendo transcripción…", "import.importing": "Importando…", "import.failed": "Error al importar", @@ -185,6 +198,9 @@ export const es: Record = { "error.videoExport": "La exportación falló al renderizar el video.", "error.audioExport": "La exportación falló al renderizar el audio.", "error.export": "Error al exportar.", + "error.timelineExport": "Error al exportar la línea de tiempo.", + "error.timelineEmpty": "No hay nada que poner en la línea de tiempo.", + "error.aafTemplate": "No se pudo cargar la plantilla de exportación AAF.", "error.emptyTranscript": "Ese archivo de transcripción está vacío.", "error.noTimedWords": "No se encontraron palabras con tiempo en esa transcripción.", "error.parseJson": "No se pudo analizar esa transcripción JSON.", diff --git a/lib/i18n/messages/fr.ts b/lib/i18n/messages/fr.ts index 0cb4074..09c0578 100644 --- a/lib/i18n/messages/fr.ts +++ b/lib/i18n/messages/fr.ts @@ -145,6 +145,19 @@ export const fr: Record = { "export.downloadFile": "Télécharger {name}", "export.downloadFormat": "Télécharger .{format}", "export.exportFormat": "Exporter {format}", + "export.timeline": "Timeline", + "export.timelineTitle": "Exporter une séquence NLE (Resolve, Premiere, Final Cut, Pro Tools)", + "export.nle": "NLE", + "export.frameRate": "Cadence", + "export.timelineHelp": + "La séquence référence le média original par nom de fichier — reliez-le dans le NLE après l’import.", + "export.timelineHelpAaf": "AAF Pro Tools / Logic (métadonnées uniquement).", + "export.timelineHelpFcpx": "FCPXML Final Cut Pro.", + "export.timelineHelpPremiere": "XML Adobe Premiere Pro (xmeml).", + "export.timelineHelpResolve": "XML DaVinci Resolve (xmeml).", + "export.aafOverCap": + "Ce montage a {count} clips ; AAF en prend en charge jusqu’à {max}. Utilisez Resolve, Premiere ou Final Cut.", + "export.preparing": "Préparation…", "import.reading": "Lecture de la transcription…", "import.importing": "Importation…", "import.failed": "Échec de l’import", @@ -185,6 +198,9 @@ export const fr: Record = { "error.videoExport": "L’export a échoué pendant le rendu de la vidéo.", "error.audioExport": "L’export a échoué pendant le rendu de l’audio.", "error.export": "Échec de l’export.", + "error.timelineExport": "Échec de l’export de la timeline.", + "error.timelineEmpty": "Rien à placer sur la timeline.", + "error.aafTemplate": "Impossible de charger le modèle d’export AAF.", "error.emptyTranscript": "Ce fichier de transcription est vide.", "error.noTimedWords": "Aucun mot horodaté trouvé dans cette transcription.", "error.parseJson": "Impossible d’analyser cette transcription JSON.", diff --git a/lib/i18n/messages/ja.ts b/lib/i18n/messages/ja.ts index fe5bef3..5dfbefb 100644 --- a/lib/i18n/messages/ja.ts +++ b/lib/i18n/messages/ja.ts @@ -145,6 +145,19 @@ export const ja: Record = { "export.downloadFile": "{name} をダウンロード", "export.downloadFormat": ".{format} をダウンロード", "export.exportFormat": "{format} を書き出し", + "export.timeline": "タイムライン", + "export.timelineTitle": "NLE シーケンスを書き出す(Resolve、Premiere、Final Cut、Pro Tools)", + "export.nle": "NLE", + "export.frameRate": "フレームレート", + "export.timelineHelp": + "シーケンスは元のメディアをファイル名で参照します — 読み込み後に NLE で再リンクしてください。", + "export.timelineHelpAaf": "Pro Tools / Logic 用 AAF(メタデータのみ)。", + "export.timelineHelpFcpx": "Final Cut Pro FCPXML。", + "export.timelineHelpPremiere": "Adobe Premiere Pro XML(xmeml)。", + "export.timelineHelpResolve": "DaVinci Resolve XML(xmeml)。", + "export.aafOverCap": + "この編集には {count} クリップあります。AAF は最大 {max} までです。Resolve、Premiere、または Final Cut を使ってください。", + "export.preparing": "準備中…", "import.reading": "文字起こしを読み込み中…", "import.importing": "インポート中…", "import.failed": "インポートに失敗しました", @@ -185,6 +198,9 @@ export const ja: Record = { "error.videoExport": "動画のレンダリング中に書き出しに失敗しました。", "error.audioExport": "音声のレンダリング中に書き出しに失敗しました。", "error.export": "書き出しに失敗しました。", + "error.timelineExport": "タイムラインの書き出しに失敗しました。", + "error.timelineEmpty": "タイムラインに配置するものがありません。", + "error.aafTemplate": "AAF 書き出しテンプレートを読み込めませんでした。", "error.emptyTranscript": "その文字起こしファイルは空です。", "error.noTimedWords": "その文字起こしにタイミング付きの単語がありません。", "error.parseJson": "その JSON 文字起こしを解析できませんでした。", diff --git a/lib/i18n/messages/ko.ts b/lib/i18n/messages/ko.ts index adbe0ad..7ad0e0b 100644 --- a/lib/i18n/messages/ko.ts +++ b/lib/i18n/messages/ko.ts @@ -145,6 +145,19 @@ export const ko: Record = { "export.downloadFile": "{name} 다운로드", "export.downloadFormat": ".{format} 다운로드", "export.exportFormat": "{format} 내보내기", + "export.timeline": "타임라인", + "export.timelineTitle": "NLE 시퀀스 내보내기 (Resolve, Premiere, Final Cut, Pro Tools)", + "export.nle": "NLE", + "export.frameRate": "프레임 레이트", + "export.timelineHelp": + "시퀀스는 원본 미디어를 파일 이름으로 참조합니다 — 가져온 뒤 NLE에서 다시 연결하세요.", + "export.timelineHelpAaf": "Pro Tools / Logic AAF (메타데이터만).", + "export.timelineHelpFcpx": "Final Cut Pro FCPXML.", + "export.timelineHelpPremiere": "Adobe Premiere Pro XML (xmeml).", + "export.timelineHelpResolve": "DaVinci Resolve XML (xmeml).", + "export.aafOverCap": + "이 편집에는 클립이 {count}개입니다. AAF는 최대 {max}개까지 지원합니다. Resolve, Premiere 또는 Final Cut을 사용하세요.", + "export.preparing": "준비 중…", "import.reading": "자막 읽는 중…", "import.importing": "가져오는 중…", "import.failed": "가져오기 실패", @@ -185,6 +198,9 @@ export const ko: Record = { "error.videoExport": "동영상을 렌더링하는 중 내보내기에 실패했습니다.", "error.audioExport": "오디오를 렌더링하는 중 내보내기에 실패했습니다.", "error.export": "내보내기에 실패했습니다.", + "error.timelineExport": "타임라인 내보내기에 실패했습니다.", + "error.timelineEmpty": "타임라인에 넣을 내용이 없습니다.", + "error.aafTemplate": "AAF 내보내기 템플릿을 불러올 수 없습니다.", "error.emptyTranscript": "해당 자막 파일이 비어 있습니다.", "error.noTimedWords": "해당 자막에서 시간 정보가 있는 단어를 찾지 못했습니다.", "error.parseJson": "해당 JSON 자막을 파싱할 수 없습니다.", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index c0c2e9a..adb12e8 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -145,6 +145,18 @@ export const zhCN: Record = { "export.downloadFile": "下载 {name}", "export.downloadFormat": "下载 .{format}", "export.exportFormat": "导出 {format}", + "export.timeline": "时间轴", + "export.timelineTitle": "导出 NLE 序列(Resolve、Premiere、Final Cut、Pro Tools)", + "export.nle": "NLE", + "export.frameRate": "帧率", + "export.timelineHelp": "序列按文件名引用原始媒体 — 导入后请在 NLE 中重新链接。", + "export.timelineHelpAaf": "Pro Tools / Logic AAF(仅元数据)。", + "export.timelineHelpFcpx": "Final Cut Pro FCPXML。", + "export.timelineHelpPremiere": "Adobe Premiere Pro XML(xmeml)。", + "export.timelineHelpResolve": "DaVinci Resolve XML(xmeml)。", + "export.aafOverCap": + "此编辑有 {count} 个片段;AAF 最多支持 {max} 个。请改用 Resolve、Premiere 或 Final Cut。", + "export.preparing": "正在准备…", "import.reading": "正在读取转录文本…", "import.importing": "正在导入…", "import.failed": "导入失败", @@ -186,6 +198,9 @@ export const zhCN: Record = { "error.videoExport": "渲染视频时导出失败。", "error.audioExport": "渲染音频时导出失败。", "error.export": "导出失败。", + "error.timelineExport": "时间轴导出失败。", + "error.timelineEmpty": "时间轴上没有可放入的内容。", + "error.aafTemplate": "无法加载 AAF 导出模板。", "error.emptyTranscript": "转录文本文件为空。", "error.noTimedWords": "转录文本中没有带时间信息的文字。", "error.parseJson": "无法解析 JSON 转录文本。", diff --git a/lib/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts index dccaf88..f432be8 100644 --- a/lib/i18n/messages/zh-TW.ts +++ b/lib/i18n/messages/zh-TW.ts @@ -145,6 +145,18 @@ export const zhTW: Record = { "export.downloadFile": "下載 {name}", "export.downloadFormat": "下載 .{format}", "export.exportFormat": "匯出 {format}", + "export.timeline": "時間軸", + "export.timelineTitle": "匯出 NLE 序列(Resolve、Premiere、Final Cut、Pro Tools)", + "export.nle": "NLE", + "export.frameRate": "幀率", + "export.timelineHelp": "序列依檔名引用原始媒體 — 匯入後請在 NLE 中重新連結。", + "export.timelineHelpAaf": "Pro Tools / Logic AAF(僅中繼資料)。", + "export.timelineHelpFcpx": "Final Cut Pro FCPXML。", + "export.timelineHelpPremiere": "Adobe Premiere Pro XML(xmeml)。", + "export.timelineHelpResolve": "DaVinci Resolve XML(xmeml)。", + "export.aafOverCap": + "此編輯有 {count} 個片段;AAF 最多支援 {max} 個。請改用 Resolve、Premiere 或 Final Cut。", + "export.preparing": "正在準備…", "import.reading": "正在讀取逐字稿…", "import.importing": "正在匯入…", "import.failed": "匯入失敗", @@ -185,6 +197,9 @@ export const zhTW: Record = { "error.videoExport": "算出影片時匯出失敗。", "error.audioExport": "算出音訊時匯出失敗。", "error.export": "匯出失敗。", + "error.timelineExport": "時間軸匯出失敗。", + "error.timelineEmpty": "時間軸上沒有可放入的內容。", + "error.aafTemplate": "無法載入 AAF 匯出範本。", "error.emptyTranscript": "該逐字稿檔案是空的。", "error.noTimedWords": "該逐字稿中找不到帶時間的文字。", "error.parseJson": "無法解析該 JSON 逐字稿。", diff --git a/lib/i18n/runtimeMessages.ts b/lib/i18n/runtimeMessages.ts index ee7a020..de1e84d 100644 --- a/lib/i18n/runtimeMessages.ts +++ b/lib/i18n/runtimeMessages.ts @@ -28,6 +28,9 @@ const runtimeMessageKeyList = [ "error.videoExport", "error.audioExport", "error.export", + "error.timelineExport", + "error.timelineEmpty", + "error.aafTemplate", "error.emptyTranscript", "error.noTimedWords", "error.parseJson", 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 2132311..780bf74 100644 --- a/next.config.ts +++ b/next.config.ts @@ -24,8 +24,9 @@ const nextConfig: NextConfig = { // Inlined into the client bundle at build time (both targets are static, so // there is no runtime env to read this from). env: { NEXT_PUBLIC_APP_VERSION: version }, - // parakeet.js ships as raw ESM from src/; transpile for the worker bundle. - transpilePackages: ["parakeet.js"], + // parakeet.js ships as raw ESM from src/; timeline/CFB helpers ship modern + // syntax. Transpile all three for the Next bundler. + transpilePackages: ["@chatoctopus/timeline", "cfb", "parakeet.js"], ...(isExport ? { output: "export" as const, diff --git a/package-lock.json b/package-lock.json index d6c2792..d5efe44 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", @@ -19,6 +20,7 @@ "@sentry/electron": "^7.16.0", "@sentry/react": "^10.69.0", "@vercel/analytics": "^2.0.1", + "cfb": "^1.2.2", "lucide-react": "^1.27.0", "next": "16.2.12", "parakeet.js": "^1.4.4", @@ -417,6 +419,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", @@ -2414,6 +2431,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", @@ -4145,6 +4174,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", @@ -4226,6 +4264,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", @@ -5006,6 +5056,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", @@ -5208,6 +5271,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", @@ -6772,6 +6847,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", @@ -7970,6 +8084,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", @@ -9511,6 +9637,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", @@ -10796,6 +10937,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", @@ -11621,6 +11777,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 6f1c84b..edb10e4 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "start": "next start", "lint": "eslint", "test:i18n": "tsx tests/i18n-test.ts", + "test:timeline": "tsx tests/serialize-timeline-test.ts", "postinstall": "patch-package && node scripts/copy-assets.mjs", "build:electron": "node scripts/build-electron.mjs", "typecheck:electron": "tsc -p electron/tsconfig.json --noEmit", @@ -34,6 +35,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", @@ -43,6 +45,7 @@ "@sentry/electron": "^7.16.0", "@sentry/react": "^10.69.0", "@vercel/analytics": "^2.0.1", + "cfb": "^1.2.2", "lucide-react": "^1.27.0", "next": "16.2.12", "parakeet.js": "^1.4.4", diff --git a/scripts/copy-assets.mjs b/scripts/copy-assets.mjs index 7a000cf..1d18193 100644 --- a/scripts/copy-assets.mjs +++ b/scripts/copy-assets.mjs @@ -4,6 +4,7 @@ * - @ffmpeg/core-mt -> public/vendor/ffmpeg/ (audio extraction + export) * - onnxruntime-web -> public/vendor/ort/ (transformers.js inference) * - parakeet.js ORT -> public/vendor/ort-parakeet/ (Parakeet TDT inference) + * - assets/aaf -> public/vendor/aaf/ (Pro Tools / Logic AAF scaffold) * Runs automatically on `npm install` (postinstall). */ import { @@ -102,4 +103,14 @@ function patchParakeetWasmPaths() { patchParakeetWasmPaths(); -console.log("[copy-assets] ffmpeg core + onnxruntime wasm copied to public/"); +// 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 + 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); +});