Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion lib/serializeTimeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/(<project\b[^>]*?)\s+modDate="[^"]*"/g, "$1");
}

export function serializeTimelineXml(
options: TimelineExportOptions,
format: Exclude<TimelineExportFormat, "aaf">
Expand All @@ -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(
Expand Down
66 changes: 66 additions & 0 deletions tests/serialize-timeline-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
buildNleTimeline,
mediaFileUrl,
serializeTimelineXml,
stripFcpxmlModDate,
} from "../lib/serializeTimeline";
import {
AAF_MAX_CLIPS,
Expand Down Expand Up @@ -134,9 +135,74 @@ async function main() {
assert(fcpx.includes("<fcpxml"), "fcpxml root");
assert(fcpx.includes("<spine>") || fcpx.includes("<spine "), "fcpxml spine");
assert(fcpx.includes("asset-clip") || fcpx.includes("asset"), "fcpxml assets");
// FCP fails DTD validation on an unparseable modDate, so we omit it.
assert(!fcpx.includes("modDate"), "fcpxml has no modDate");
// V1/A1 are the same media over the same ranges; a single asset-clip per
// range carries both, so no duplicate connected clips in lane 1.
assert(!fcpx.includes("lane="), "fcpxml has no connected-clip lanes");
assert(
fcpx.match(/<asset-clip/g)?.length === keeps.length,
"one fcpxml asset-clip per keep range"
);
console.log("fcpxml: ok");
}

{
// Audio-only projects have a single track and must still land on the spine.
const fcpx = serializeTimelineXml(
{
keepRanges: keeps,
duration: 5,
mediaFileName: "podcast.m4a",
frameRate: "30",
withVideo: false,
withAudio: true,
},
"fcpx"
);
assert(fcpx.includes('hasAudio="1"'), "fcpxml audio-only asset");
assert(!fcpx.includes('hasVideo="1"'), "fcpxml audio-only has no video");
assert(
fcpx.match(/<asset-clip/g)?.length === keeps.length,
"audio-only fcpxml asset-clips"
);
console.log("fcpxml audio-only: ok");
}

{
assert(
stripFcpxmlModDate('<project name="a" modDate="2026-01-01 00:00:00 UTC">') ===
'<project name="a">',
"strips modDate"
);
assert(
stripFcpxmlModDate('<project modDate="x" uid="u"/>') === '<project uid="u"/>',
"strips modDate mid-tag"
);
// Only the project tag; a clip named "modDate=..." must survive untouched.
const kept = '<asset-clip name="my modDate=&quot;x&quot; take.mp4"/>';
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("<video>"), "premiere keeps video track");
assert(premiere.includes("<audio>"), "premiere keeps audio track");
console.log("xmeml tracks intact: ok");
}

{
// Audio-only timeline
const xml = serializeTimelineXml(
Expand Down
Loading