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
61 changes: 61 additions & 0 deletions src/content/__tests__/buildFfmpegArgs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";

import { buildFfmpegArgs } from "../buildFfmpegArgs";

describe("buildFfmpegArgs", () => {
const baseCaptionLayout = {
lines: ["hello world"],
fontSize: 42,
lineHeight: 52,
position: "bottom" as const,
};

it("includes -map 0:a when hasAudio and hasOverlays", () => {
const args = buildFfmpegArgs({
videoPath: "/tmp/video.mp4",
audioPath: "/tmp/audio.mp3",
captionLayout: baseCaptionLayout,
outputPath: "/tmp/out.mp4",
audioStartSeconds: 0,
audioDurationSeconds: 10,
hasAudio: true,
overlayImagePaths: ["/tmp/overlay-0.png"],
});

expect(args).toContain("-map");
expect(args).toContain("[out]");
expect(args).toContain("0:a");
});

it("uses -vf filter when no overlays", () => {
const args = buildFfmpegArgs({
videoPath: "/tmp/video.mp4",
audioPath: "/tmp/audio.mp3",
captionLayout: baseCaptionLayout,
outputPath: "/tmp/out.mp4",
audioStartSeconds: 0,
audioDurationSeconds: 10,
hasAudio: false,
overlayImagePaths: [],
});

expect(args).toContain("-vf");
expect(args).not.toContain("-filter_complex");
});

it("uses -filter_complex when overlays are present", () => {
const args = buildFfmpegArgs({
videoPath: "/tmp/video.mp4",
audioPath: "/tmp/audio.mp3",
captionLayout: baseCaptionLayout,
outputPath: "/tmp/out.mp4",
audioStartSeconds: 5,
audioDurationSeconds: 15,
hasAudio: false,
overlayImagePaths: ["/tmp/overlay-0.png"],
});

expect(args).toContain("-filter_complex");
expect(args).not.toContain("-vf");
});
});
46 changes: 46 additions & 0 deletions src/content/__tests__/buildFilterComplex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";

import { buildFilterComplex } from "../buildFilterComplex";

describe("buildFilterComplex", () => {
it("builds crop + scale + overlay + caption pipeline", () => {
const result = buildFilterComplex({
overlayCount: 1,
captionFilters: ["drawtext=text='hello':fontsize=42"],
});

expect(result).toContain("[0:v]crop=ih*9/16:ih,scale=720:1280[video_base]");
expect(result).toContain("[1:v]scale=150:150[ovr_0]");
expect(result).toContain("overlay=30:30");
expect(result).toContain("[out]");
});

it("positions overlays in top-left stacked vertically", () => {
const result = buildFilterComplex({
overlayCount: 2,
captionFilters: ["drawtext=text='hi':fontsize=42"],
});

expect(result).toMatch(/overlay=30:30/);
expect(result).toMatch(/overlay=30:200/);
});

it("chains multiple overlays correctly", () => {
const result = buildFilterComplex({
overlayCount: 3,
captionFilters: [],
});

const overlayCount = (result.match(/overlay=/g) || []).length;
expect(overlayCount).toBe(3);
});

it("emits [out] label when captionFilters is empty", () => {
const result = buildFilterComplex({
overlayCount: 1,
captionFilters: [],
});

expect(result).toContain("[out]");
});
});
118 changes: 118 additions & 0 deletions src/content/__tests__/detectFace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("../../sandboxes/logStep", () => ({
logStep: vi.fn(),
}));

const mockFalSubscribe = vi.fn();
vi.mock("../falSubscribe", () => ({
falSubscribe: (...args: unknown[]) => mockFalSubscribe(...args),
}));

import { detectFace } from "../detectFace";

describe("detectFace", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("returns true when a person label is detected", async () => {
mockFalSubscribe.mockResolvedValue({
data: {
results: {
bboxes: [[10, 20, 100, 200]],
labels: ["person"],
},
},
});

const result = await detectFace("https://example.com/headshot.png");

expect(result).toBe(true);
expect(mockFalSubscribe).toHaveBeenCalledWith(
"fal-ai/florence-2-large/object-detection",
{ image_url: "https://example.com/headshot.png" },
);
});

it("returns true when a face label is detected among other objects", async () => {
mockFalSubscribe.mockResolvedValue({
data: {
results: {
bboxes: [[0, 0, 50, 50], [10, 20, 100, 200]],
labels: ["chair", "human face"],
},
},
});

const result = await detectFace("https://example.com/photo.png");

expect(result).toBe(true);
});

it("returns false when no person or face labels are detected", async () => {
mockFalSubscribe.mockResolvedValue({
data: {
results: {
bboxes: [[0, 0, 300, 300]],
labels: ["album cover"],
},
},
});

const result = await detectFace("https://example.com/album-cover.png");

expect(result).toBe(false);
});

it("returns false when results are empty", async () => {
mockFalSubscribe.mockResolvedValue({
data: {
results: {
bboxes: [],
labels: [],
},
},
});

const result = await detectFace("https://example.com/blank.png");

expect(result).toBe(false);
});

it("returns false when detection fails", async () => {
mockFalSubscribe.mockRejectedValue(new Error("Detection failed"));

const result = await detectFace("https://example.com/broken.png");

expect(result).toBe(false);
});

it("does not false-positive on labels containing face words as substrings", async () => {
mockFalSubscribe.mockResolvedValue({
data: {
results: {
bboxes: [[0, 0, 200, 200]],
labels: ["ottoman", "mannequin", "womanizer"],
},
},
});

const result = await detectFace("https://example.com/furniture.png");

expect(result).toBe(false);
});

it("logs the error when detection fails", async () => {
const { logStep } = await import("../../sandboxes/logStep");
mockFalSubscribe.mockRejectedValue(new Error("Rate limit exceeded"));

await detectFace("https://example.com/broken.png");

expect(logStep).toHaveBeenCalledWith(
"Face detection failed, assuming no face",
false,
expect.objectContaining({ error: "Rate limit exceeded" }),
);
});
});
39 changes: 39 additions & 0 deletions src/content/__tests__/downloadOverlayImages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("../../sandboxes/logStep", () => ({
logStep: vi.fn(),
}));

vi.mock("node:fs/promises", () => ({
writeFile: vi.fn(),
}));

import { downloadOverlayImages } from "../downloadOverlayImages";

describe("downloadOverlayImages", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});

it("skips failed downloads without aborting remaining images", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");

// First fetch throws a network error
fetchSpy.mockRejectedValueOnce(new Error("DNS resolution failed"));

// Second fetch succeeds
fetchSpy.mockResolvedValueOnce(
new Response(new ArrayBuffer(8), { status: 200 }),
);

const result = await downloadOverlayImages(
["https://example.com/fail.png", "https://example.com/ok.png"],
"/tmp/test",
);

// Should have one successful path, not zero (which would happen if exception aborted the loop)
expect(result).toHaveLength(1);
expect(result[0]).toContain("overlay-1.png");
});
});
65 changes: 65 additions & 0 deletions src/content/__tests__/escapeDrawtext.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, it, expect } from "vitest";

import { escapeDrawtext } from "../escapeDrawtext";

describe("escapeDrawtext", () => {
it("replaces straight apostrophes with escaped quote safe for drawtext", () => {
const result = escapeDrawtext("didn't");

expect(result).not.toContain("'");
// Must NOT use U+02BC (modifier letter apostrophe) — most fonts lack this glyph
expect(result).not.toContain("\u02BC");
expect(result).toContain("didn");
});

it("preserves curly right single quotation marks as-is", () => {
const result = escapeDrawtext("didn\u2019t");

// U+2019 is the replacement char — it should remain
expect(result).toContain("\u2019");
expect(result).not.toContain("\u02BC");
});

it("replaces curly left single quotation marks with right single quotation mark", () => {
const result = escapeDrawtext("\u2018hello\u2019");

expect(result).not.toContain("\u2018");
expect(result).not.toContain("\u02BC");
// Both should become U+2019
expect(result).toBe("\u2019hello\u2019");
});

it("escapes colons for ffmpeg", () => {
const result = escapeDrawtext("caption: hello");

expect(result).toContain("\\\\:");
});

it("escapes percent to %% for a single literal % in ffmpeg drawtext", () => {
const result = escapeDrawtext("100%");

// ffmpeg drawtext: %% renders as single %. So "100%" should become "100%%".
expect(result).toBe("100%%");
});

it("escapes backslashes", () => {
const result = escapeDrawtext("back\\slash");

expect(result).toContain("\\\\\\\\");
});

it("strips newlines and carriage returns", () => {
expect(escapeDrawtext("line1\nline2")).toBe("line1 line2");
expect(escapeDrawtext("line1\r\nline2")).toBe("line1 line2");
expect(escapeDrawtext("line1\rline2")).toBe("line1line2");
});

it("handles a real caption with apostrophes and special chars", () => {
const result = escapeDrawtext("didn't think anyone would hear this: it's real");

// Should not contain raw single quotes, left curly quotes, or U+02BC
expect(result).not.toMatch(/['\u2018\u2032\u02BC]/);
// Should contain escaped colon
expect(result).toContain("\\\\:");
});
});
Loading
Loading