diff --git a/src/utils/markers.test.ts b/src/utils/markers.test.ts index 4cd2dc5..cb1e1fd 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", @@ -152,33 +153,53 @@ 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, - offsetSeconds: 350, + offsetSeconds: 400, bufferSeconds: 0, }); expect(segments).toEqual([ { type: "offset", - label: "00:05:00", + label: "00:06:40", 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, + offsetSeconds: 240, + bufferSeconds: 0, + }); + + expect(segments.map((s) => [s.label, s.vodStart, s.vodEnd])).toEqual([ + ["Chrome", 0, 600], + ["00:04:00", 300, 600], + ["React", 600, 900], + ]); + expect(toCsv(segments)).toBe(["0,360,Chrome", "360,660,React"].join("\n")); + }); }); describe("exports", () => { @@ -192,17 +213,15 @@ 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" + ) ); }); - 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"), @@ -212,7 +231,26 @@ 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("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", () => { diff --git a/src/utils/markers.ts b/src/utils/markers.ts index aa055e7..81cf996 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,24 +154,39 @@ 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"); } -// 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"); - 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) => { + const next = chapters[i + 1]; + return !next || next.startTime - chapter.startTime >= MIN_CHAPTER_SECONDS; + }) .map( - (clip, i) => - `${formatSeconds(i === 0 ? 0 : clip.startTime)} ${clip.label}` + (chapter, i) => + `${formatSeconds(i === 0 ? 0 : chapter.startTime)} ${chapter.label}` ) .join("\n"); }