From 619c07a194f044f27545886d1726b8c16e5d4c93 Mon Sep 17 00:00:00 2001 From: Wassim Gharbi Date: Sat, 29 Aug 2026 12:39:46 -0700 Subject: [PATCH] Fix FCPXML import failing DTD validation in Final Cut Pro Final Cut Pro rejected every exported .fcpxml with "The XML document sent from application "(null)" could not be imported. DTD validation failed." Two problems in the FCPXML we emit: - `` was stamped with an IANA zone name (`2026-08-29 12:37:45 America/Los_Angeles`) where FCP wants a numeric UTC offset (`... -0700`). FCP 10.6.x is stricter still and only accepts the clock format matching the user's system 12/24-hour setting, so even a well-formed value is a coin flip. modDate is optional and we have nothing meaningful to put there, so drop it. - We hand the writer parallel V1/A1 tracks (needed for XMEML and AAF), but a single FCP asset-clip already carries both the video and audio of its asset. The writer turned A1 into connected clips in lane 1, which imports as a duplicate video overlay with doubled audio. Collapse to one track for the FCPXML path only. Premiere/Resolve XMEML and AAF output are unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- lib/serializeTimeline.ts | 27 ++++++++++++- tests/serialize-timeline-test.ts | 66 ++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/lib/serializeTimeline.ts b/lib/serializeTimeline.ts index e2afdf1..dee8933 100644 --- a/lib/serializeTimeline.ts +++ b/lib/serializeTimeline.ts @@ -175,6 +175,23 @@ export function timelineExtension(format: TimelineExportFormat): string { return TIMELINE_FORMATS.find((f) => f.value === format)?.ext ?? format; } +/** + * Drop `modDate` from the exported project. + * + * Final Cut Pro rejects the entire document with "DTD validation failed" when + * it cannot parse this attribute. The upstream writer stamps an IANA zone name + * (`2026-08-29 12:37:45 America/Los_Angeles`) where FCP wants a numeric UTC + * offset (`-0700`), and FCP 10.6.x additionally only accepts the clock format + * matching the user's system 12/24-hour setting. modDate is optional and we + * have nothing meaningful to put in it, so the safe answer is to omit it. + * + * Attribute values are XML-escaped by the writer, so `"` and `>` cannot appear + * inside one — matching up to the tag's `>` is safe even for odd file names. + */ +export function stripFcpxmlModDate(xml: string): string { + return xml.replace(/(]*?)\s+modDate="[^"]*"/g, "$1"); +} + export function serializeTimelineXml( options: TimelineExportOptions, format: Exclude @@ -193,7 +210,15 @@ export function serializeTimelineXml( return writeXMEML(timeline); } if (format === "premiere") return writeXMEML(timeline); - return writeFCPXML(timeline); + + // A single FCP asset-clip carries both the video and the audio of its asset. + // Our parallel V1/A1 tracks are the same media over the same ranges, so + // keeping both makes the writer attach A1 as connected clips in lane 1 — + // FCP imports that as a duplicate video overlay with doubled audio. + if (timeline.tracks.length > 1) { + timeline.tracks = timeline.tracks.filter((t) => t.kind === "video"); + } + return stripFcpxmlModDate(writeFCPXML(timeline)); } export async function serializeTimelineAaf( diff --git a/tests/serialize-timeline-test.ts b/tests/serialize-timeline-test.ts index 77cd6b6..adf5fae 100644 --- a/tests/serialize-timeline-test.ts +++ b/tests/serialize-timeline-test.ts @@ -8,6 +8,7 @@ import { buildNleTimeline, mediaFileUrl, serializeTimelineXml, + stripFcpxmlModDate, } from "../lib/serializeTimeline"; import { AAF_MAX_CLIPS, @@ -134,9 +135,74 @@ async function main() { assert(fcpx.includes("") || fcpx.includes("') === + '', + "strips modDate" + ); + assert( + stripFcpxmlModDate('') === '', + "strips modDate mid-tag" + ); + // Only the project tag; a clip named "modDate=..." must survive untouched. + const kept = ''; + assert(stripFcpxmlModDate(kept) === kept, "leaves non-project tags alone"); + console.log("strip modDate: ok"); +} + +{ + // Premiere/Resolve still need the parallel audio track. + const premiere = serializeTimelineXml( + { + keepRanges: keeps, + duration: 5, + mediaFileName: "interview.mp4", + frameRate: "24", + withVideo: true, + withAudio: true, + }, + "premiere" + ); + assert(premiere.includes("