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
66 changes: 52 additions & 14 deletions src/utils/markers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ describe("buildSegments", () => {
vodEnd: 600,
startTime: 0,
endTime: 610,
isIntro: true,
},
{
type: "start",
Expand Down Expand Up @@ -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,
});
Comment thread
t3dotgg marked this conversation as resolved.

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", () => {
Expand All @@ -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"),
Expand All @@ -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", () => {
Expand Down
49 changes: 37 additions & 12 deletions src/utils/markers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand All @@ -117,19 +120,26 @@ 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"
);
const withIntro = hasStartAtZero
? 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 {
Expand All @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
.map(
(clip, i) =>
`${formatSeconds(i === 0 ? 0 : clip.startTime)} ${clip.label}`
(chapter, i) =>
`${formatSeconds(i === 0 ? 0 : chapter.startTime)} ${chapter.label}`
)
.join("\n");
}
Loading