From b828910197826341b4266888ad9057d232a9ae47 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 17:12:00 -0700 Subject: [PATCH 1/5] Leave the fake intro out of the CSV, and stop offset markers from cutting clips short Co-Authored-By: Claude Opus 5.5 (1M context) --- src/utils/markers.test.ts | 37 +++++++++++++++++++++++++++---------- src/utils/markers.ts | 21 ++++++++++++++++----- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/utils/markers.test.ts b/src/utils/markers.test.ts index 4cd2dc5..5cb0029 100644 --- a/src/utils/markers.test.ts +++ b/src/utils/markers.test.ts @@ -97,6 +97,7 @@ describe("buildSegments", () => { vodEnd: 600, startTime: 0, endTime: 610, + isIntro: true, }, { type: "start", @@ -156,7 +157,7 @@ describe("buildSegments", () => { marker(400, "On camera"), ], videoSeconds: 1000, - offsetSeconds: 350, + offsetSeconds: 400, bufferSeconds: 0, }); @@ -167,18 +168,36 @@ describe("buildSegments", () => { vodStart: 300, vodEnd: 400, startTime: 0, - endTime: 50, + endTime: 0, }, { type: "start", label: "On camera", vodStart: 400, vodEnd: 1000, - startTime: 50, - endTime: 650, + startTime: 0, + endTime: 600, }, ]); }); + + it("does not end a clip at an offset marker", () => { + const segments = buildSegments({ + markers: [ + marker(0, "Chrome"), + marker(300, "OFFSET 00:04:00"), + marker(600, "React"), + ], + videoSeconds: 900, + bufferSeconds: 0, + }); + + expect(segments.map((s) => [s.label, s.vodStart, s.vodEnd])).toEqual([ + ["Chrome", 0, 600], + ["00:04:00", 300, 600], + ["React", 600, 900], + ]); + }); }); describe("exports", () => { @@ -192,13 +211,11 @@ describe("exports", () => { videoSeconds: 27 * 3600, }); - it("writes only start clips to the CSV, quoting labels when needed", () => { + it("writes only marked start clips to the CSV, quoting labels when needed", () => { expect(toCsv(segments)).toBe( - [ - "0,15,Intro", - '0,70,"React, Vue, and ""Svelte"""', - "93590,97210,Late night", - ].join("\n") + ['0,100,"React, Vue, and ""Svelte"""', "93590,97210,Late night"].join( + "\n" + ) ); }); diff --git a/src/utils/markers.ts b/src/utils/markers.ts index aa055e7..ddb059a 100644 --- a/src/utils/markers.ts +++ b/src/utils/markers.ts @@ -21,6 +21,8 @@ export type Segment = { // Bounds in the exported footage, with offset and buffer applied startTime: number; endTime: number; + // Only set on the fake "Intro" clip that buildSegments adds + isIntro?: true; }; // Seconds of padding on each side of an exported clip @@ -92,7 +94,8 @@ export function formatSeconds(totalSeconds: number): string { )}:${pad(seconds % 60)}`; } -// Each marker runs until the next one (or the end of the VOD). +// Each marker runs until the next start or end marker (or the end of the VOD). +// Offset markers only set the offset, so they never end a clip. // Start clips that end before the offset are dropped, since they are not in // the offset footage. export function buildSegments(opts: { @@ -117,6 +120,7 @@ export function buildSegments(opts: { // Add a fake "Intro" start marker at 0, unless a real start marker is there. // It goes after other markers at 0, so it runs until the next real marker. + // YouTube chapters use it to label the start. The CSV leaves it out. const hasStartAtZero = markers.some( (m) => m.vodStart === 0 && m.type === "start" ); @@ -124,12 +128,18 @@ export function buildSegments(opts: { ? markers : [ ...markers.filter((m) => m.vodStart === 0), - { vodStart: 0, type: "start" as const, label: "Intro" }, + { + vodStart: 0, + type: "start" as const, + label: "Intro", + isIntro: true as const, + }, ...markers.filter((m) => m.vodStart > 0), ]; return withIntro.flatMap((marker, i) => { - const vodEnd = withIntro[i + 1]?.vodStart ?? opts.videoSeconds; + const nextMarker = withIntro.slice(i + 1).find((m) => m.type !== "offset"); + const vodEnd = nextMarker?.vodStart ?? opts.videoSeconds; if (marker.type === "start" && vodEnd <= offset) return []; return { @@ -144,10 +154,11 @@ export function buildSegments(opts: { const toCsvField = (value: string) => /[",\r\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value; -// CSV for LosslessCut: "start,end,label" per clip, in seconds +// CSV for LosslessCut: "start,end,label" per clip, in seconds. +// Only clips you marked. The fake intro is left out. export function toCsv(segments: Segment[]): string { return segments - .filter((s) => s.type === "start") + .filter((s) => s.type === "start" && !s.isIntro) .map((s) => `${s.startTime},${s.endTime},${toCsvField(s.label)}`) .join("\n"); } From 6a67ed62ede4ff1aec5d191a44e09ebe7b774c5d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 17:15:40 -0700 Subject: [PATCH 2/5] Test that the offset CSV keeps a clip that spans an OFFSET marker Co-Authored-By: Claude Opus 5.5 (1M context) --- src/utils/markers.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/utils/markers.test.ts b/src/utils/markers.test.ts index 5cb0029..684894a 100644 --- a/src/utils/markers.test.ts +++ b/src/utils/markers.test.ts @@ -189,6 +189,7 @@ describe("buildSegments", () => { marker(600, "React"), ], videoSeconds: 900, + offsetSeconds: 240, bufferSeconds: 0, }); @@ -197,6 +198,7 @@ describe("buildSegments", () => { ["00:04:00", 300, 600], ["React", 600, 900], ]); + expect(toCsv(segments)).toBe(["0,360,Chrome", "360,660,React"].join("\n")); }); }); From 7f5b6a91204108fcfda7c1d19d53af7bc9e450e4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 21:09:30 -0700 Subject: [PATCH 3/5] Make the offset test's OFFSET label match its offset The test applies a 400s offset, but its OFFSET marker said 00:05:00 (300s). It now says 00:06:40. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/utils/markers.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/markers.test.ts b/src/utils/markers.test.ts index 684894a..56f17c4 100644 --- a/src/utils/markers.test.ts +++ b/src/utils/markers.test.ts @@ -153,7 +153,7 @@ describe("buildSegments", () => { const segments = buildSegments({ markers: [ marker(100, "Before camera"), - marker(300, "OFFSET 00:05:00"), + marker(300, "OFFSET 00:06:40"), marker(400, "On camera"), ], videoSeconds: 1000, @@ -164,7 +164,7 @@ describe("buildSegments", () => { expect(segments).toEqual([ { type: "offset", - label: "00:05:00", + label: "00:06:40", vodStart: 300, vodEnd: 400, startTime: 0, From 2ec47196d276334626e1e7320f51407e757fb901 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 21:09:47 -0700 Subject: [PATCH 4/5] Add an Intro chapter when the offset skips the early clips With an offset, the first remaining clip can start after 00:00:00. Its start was moved to 00:00:00, so its label covered footage that was not part of that clip. Now an "Intro" chapter goes at 00:00:00, and the clip keeps its real start time. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/utils/markers.test.ts | 6 ++++-- src/utils/markers.ts | 14 ++++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/utils/markers.test.ts b/src/utils/markers.test.ts index 56f17c4..f17cb0d 100644 --- a/src/utils/markers.test.ts +++ b/src/utils/markers.test.ts @@ -221,7 +221,7 @@ describe("exports", () => { ); }); - it("starts YouTube chapters at 00:00:00 when the offset skips early clips", () => { + it("adds an Intro chapter when the offset skips early clips", () => { const offsetSegments = buildSegments({ markers: [ marker(100, "Before camera"), @@ -231,7 +231,9 @@ describe("exports", () => { videoSeconds: 1000, offsetSeconds: 350, }); - expect(toYouTubeChapters(offsetSegments)).toBe("00:00:00 On camera"); + expect(toYouTubeChapters(offsetSegments)).toBe( + ["00:00:00 Intro", "00:00:40 On camera"].join("\n") + ); }); it("keeps one YouTube chapter per timestamp", () => { diff --git a/src/utils/markers.ts b/src/utils/markers.ts index ddb059a..28b608e 100644 --- a/src/utils/markers.ts +++ b/src/utils/markers.ts @@ -166,13 +166,19 @@ export function toCsv(segments: Segment[]): string { // YouTube needs the first chapter at 00:00:00 and rejects chapters that // share a timestamp. When several clips start at the same time, only the // last one is kept. +// With an offset, the first clip can start after 00:00:00. Then an "Intro" +// chapter at 00:00:00 labels the footage before it. export function toYouTubeChapters(segments: Segment[]): string { const clips = segments.filter((s) => s.type === "start"); - return clips - .filter((clip, i) => clips[i + 1]?.startTime !== clip.startTime) + const intro = + (clips[0]?.startTime ?? 0) > 0 ? [{ startTime: 0, label: "Intro" }] : []; + const chapters = [...intro, ...clips]; + + return chapters + .filter((chapter, i) => chapters[i + 1]?.startTime !== chapter.startTime) .map( - (clip, i) => - `${formatSeconds(i === 0 ? 0 : clip.startTime)} ${clip.label}` + (chapter, i) => + `${formatSeconds(i === 0 ? 0 : chapter.startTime)} ${chapter.label}` ) .join("\n"); } From 14d658245d58db800bf589996e162f6b2a7779ba Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 21:10:23 -0700 Subject: [PATCH 5/5] Drop YouTube chapters that are shorter than 10 seconds YouTube ignores the whole chapter list when one chapter is shorter than 10 seconds. Chapter times include the 10s buffer, so a first marker at 0:12 gave "00:00:00 Intro" and "00:00:02 Chrome". Two markers less than 10s apart did the same. Now, when a chapter starts less than 10s after the one before it, the earlier one is dropped. If that was the first chapter, the next one moves to 00:00:00. The CSV does not change. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/utils/markers.test.ts | 17 +++++++++++++++++ src/utils/markers.ts | 16 ++++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/utils/markers.test.ts b/src/utils/markers.test.ts index f17cb0d..cb1e1fd 100644 --- a/src/utils/markers.test.ts +++ b/src/utils/markers.test.ts @@ -236,6 +236,23 @@ describe("exports", () => { ); }); + it("drops a YouTube chapter shorter than 10 seconds and keeps the later one", () => { + const shortSegments = buildSegments({ + markers: [ + // With the 10s buffer, Chrome starts 2s after the intro + marker(12, "Chrome"), + marker(100, "React"), + // 5s after React + marker(105, "Vue"), + marker(300, "Svelte"), + ], + videoSeconds: 600, + }); + expect(toYouTubeChapters(shortSegments)).toBe( + ["00:00:00 Chrome", "00:01:35 Vue", "00:04:50 Svelte"].join("\n") + ); + }); + it("keeps one YouTube chapter per timestamp", () => { expect(toYouTubeChapters(segments)).toBe( ['00:00:00 React, Vue, and "Svelte"', "25:59:50 Late night"].join("\n") diff --git a/src/utils/markers.ts b/src/utils/markers.ts index 28b608e..81cf996 100644 --- a/src/utils/markers.ts +++ b/src/utils/markers.ts @@ -163,11 +163,16 @@ export function toCsv(segments: Segment[]): string { .join("\n"); } -// YouTube needs the first chapter at 00:00:00 and rejects chapters that -// share a timestamp. When several clips start at the same time, only the -// last one is kept. +// YouTube ignores all chapters unless the first one is at 00:00:00 +// and each one is at least 10 seconds long. +const MIN_CHAPTER_SECONDS = 10; + +// One chapter per start clip. // With an offset, the first clip can start after 00:00:00. Then an "Intro" // chapter at 00:00:00 labels the footage before it. +// When a chapter starts less than 10 seconds after the one before it, the +// earlier one is dropped. If that was the first chapter, the next one moves +// to 00:00:00. export function toYouTubeChapters(segments: Segment[]): string { const clips = segments.filter((s) => s.type === "start"); const intro = @@ -175,7 +180,10 @@ export function toYouTubeChapters(segments: Segment[]): string { const chapters = [...intro, ...clips]; return chapters - .filter((chapter, i) => chapters[i + 1]?.startTime !== chapter.startTime) + .filter((chapter, i) => { + const next = chapters[i + 1]; + return !next || next.startTime - chapter.startTime >= MIN_CHAPTER_SECONDS; + }) .map( (chapter, i) => `${formatSeconds(i === 0 ? 0 : chapter.startTime)} ${chapter.label}`