From 9474c4db08bb584c01ec48df2d4255352ed7701a Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Wed, 3 Jun 2026 08:54:20 +0000 Subject: [PATCH] fix: normalize track array order on save (#21) Tracks were appended to draft.tracks in command-call order. CapCut lays out the timeline from the tracks-array order, not from per-segment render_index, so adding content in an arbitrary order produced a scrambled timeline on import. Sort tracks into the canonical bottom->top layer order (video, audio, sticker, effect, filter, text) in saveDraft, with a stable tiebreak so same-type tracks keep their authored order. Order derived from a real CapCut-authored draft ([video, audio, text]). Adds test/track-order.test.mjs. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/draft.ts | 24 +++++++++++++++++ test/track-order.test.mjs | 56 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 test/track-order.test.mjs diff --git a/src/draft.ts b/src/draft.ts index 3dcdd93..ddf367f 100644 --- a/src/draft.ts +++ b/src/draft.ts @@ -111,12 +111,36 @@ export function loadDraft(path: string): { draft: Draft; filePath: string } { return { draft, filePath }; } +// Canonical bottom->top layer order CapCut expects in the tracks array. +// Derived from a real CapCut-authored draft: [video, audio, text]. +// Tracks are pushed in command-call order as content is added, so without +// this normalization the array order (which drives CapCut's timeline layout) +// ends up scrambled. Unknown types are kept after the known ones. +const TRACK_RANK: Record = { + video: 0, + audio: 1, + sticker: 2, + effect: 3, + filter: 4, + text: 5, +}; + +// Sort tracks into the canonical layer order. Stable: tracks of the same type +// keep their authored order (tiebreak on original index). +export function sortTracks(draft: Draft): void { + draft.tracks = draft.tracks + .map((track, index) => ({ track, index })) + .sort((a, b) => (TRACK_RANK[a.track.type] ?? 99) - (TRACK_RANK[b.track.type] ?? 99) || a.index - b.index) + .map(({ track }) => track); +} + export function saveDraft(filePath: string, draft: Draft): void { const bakPath = filePath + ".bak"; if (existsSync(filePath)) { const original = rawOriginal ?? readFileSync(filePath, "utf-8"); writeFileSync(bakPath, original, "utf-8"); } + sortTracks(draft); // Detect original indent: if first line after { starts with tab use tab, else count spaces const indent = detectIndent(rawOriginal); writeFileSync(filePath, JSON.stringify(draft, null, indent), "utf-8"); diff --git a/test/track-order.test.mjs b/test/track-order.test.mjs new file mode 100644 index 0000000..17ed9b1 --- /dev/null +++ b/test/track-order.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { dirname, join } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DRAFT = join(__dirname, "..", "dist", "draft.js"); + +// Regression for #21: tracks are pushed in command-call order, so the tracks +// array (which drives CapCut's timeline layout) can come out scrambled. +// sortTracks() must normalize to the canonical bottom->top layer order. +describe("sortTracks (track-order normalization)", () => { + const track = (type, name = type) => ({ id: name, type, name, attribute: 0, segments: [] }); + + it("sorts scrambled tracks into canonical layer order", async () => { + const { sortTracks } = await import(DRAFT); + const draft = { + tracks: [track("text"), track("audio"), track("sticker"), track("video"), track("effect"), track("filter")], + }; + sortTracks(draft); + assert.deepEqual( + draft.tracks.map((t) => t.type), + ["video", "audio", "sticker", "effect", "filter", "text"], + ); + }); + + it("matches the [video, audio, text] order of a real CapCut draft", async () => { + const { sortTracks } = await import(DRAFT); + const draft = { tracks: [track("text"), track("video"), track("audio")] }; + sortTracks(draft); + assert.deepEqual( + draft.tracks.map((t) => t.type), + ["video", "audio", "text"], + ); + }); + + it("is stable for same-type tracks (keeps authored order)", async () => { + const { sortTracks } = await import(DRAFT); + const draft = { tracks: [track("video", "main"), track("text"), track("video", "overlay")] }; + sortTracks(draft); + assert.deepEqual( + draft.tracks.map((t) => t.name), + ["main", "overlay", "text"], + ); + }); + + it("keeps unknown track types after known ones, in order", async () => { + const { sortTracks } = await import(DRAFT); + const draft = { tracks: [track("mystery"), track("text"), track("video")] }; + sortTracks(draft); + assert.deepEqual( + draft.tracks.map((t) => t.type), + ["video", "text", "mystery"], + ); + }); +});