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
24 changes: 24 additions & 0 deletions src/draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = {
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");
Expand Down
56 changes: 56 additions & 0 deletions test/track-order.test.mjs
Original file line number Diff line number Diff line change
@@ -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"],
);
});
});
Loading