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
2 changes: 1 addition & 1 deletion docs/backlog/drawtext-on-linux.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# drawtext on Linux

The Linux `ffmpeg-static` 5.3.0 binary is built without libfreetype, so it has no `drawtext` filter (CI: "No such filter: 'drawtext'"). `add_text`, `add_captions`, `add_title_card` and timeline text clips fail on Linux unless `$MAKEMYCLIP_FFMPEG_PATH` points at an FFmpeg that has it; detect that up front with an actionable error, or bundle a build that includes drawtext.
Text tools now stop with `MissingFilterError` instead of FFmpeg's "No such filter", but text still doesn't render with the default Linux install. `ffmpeg-static` 5.3.0 downloads John Van Sickle's FFmpeg 7.0.2 build, which has libfreetype but not libharfbuzz, and drawtext needs both since FFmpeg 6.1 (upstream issue eugeneware/ffmpeg-static#147; the fix in #158 is unmerged). Decided 2026-10-29: keep `ffmpeg-static` and the fail-fast error, and don't swap the binary source for now. Revisit when `ffmpeg-static` ships a Linux build with drawtext.
53 changes: 53 additions & 0 deletions src/ffmpeg/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,56 @@ export async function runFfmpeg(args: string[]): Promise<FfmpegResult> {
});
return { stdout, stderr, durationMs: Date.now() - start };
}

/** Thrown before a run when the resolved FFmpeg lacks a filter the edit needs. */
export class MissingFilterError extends Error {
readonly filter: string;
readonly ffmpegPath: string;

constructor(filter: string, ffmpegPath: string, message: string) {
super(message);
this.name = 'MissingFilterError';
this.filter = filter;
this.ffmpegPath = ffmpegPath;
}
}

/** Filter names from `ffmpeg -filters`; rows look like ` T.C drawtext V->V Draw text…`. */
export function parseFilterList(stdout: string): Set<string> {
return new Set([...stdout.matchAll(/^ \S+ (\S+)\s+\S*->\S*\s/gm)].map((m) => m[1] as string));
}

let filterList: Promise<Set<string>> | undefined;

/** The resolved FFmpeg's filters, listed once per process. */
export function listFilters(): Promise<Set<string>> {
filterList ??= runFfmpeg(['-hide_banner', '-filters']).then(
({ stdout }) => parseFilterList(stdout),
(err: unknown) => {
// A failed listing is retried on the next call, not cached for the
// life of a long-running `clip ui` or `clip mcp` process.
filterList = undefined;
throw err;
},
);
return filterList;
}

/**
* Fail before rendering text when FFmpeg can't draw it. Since FFmpeg 6.1
* drawtext needs libharfbuzz as well as libfreetype; the Linux binary
* `ffmpeg-static` installs has no harfbuzz, so it has no drawtext and FFmpeg
* itself would only say "No such filter".
*/
export async function requireDrawtext(): Promise<void> {
if ((await listFilters()).has('drawtext')) return;
throw new MissingFilterError(
'drawtext',
ffmpegPath,
`The FFmpeg at ${ffmpegPath} has no drawtext filter (it needs libfreetype and libharfbuzz), ` +
'so it cannot render text for add_text, add_captions, add_title_card or timeline text clips. ' +
'Set MAKEMYCLIP_FFMPEG_PATH to an FFmpeg that lists drawtext in `ffmpeg -hide_banner -filters`. ' +
'On Debian or Ubuntu: `sudo apt install ffmpeg`, then `export MAKEMYCLIP_FFMPEG_PATH=/usr/bin/ffmpeg` ' +
'(for `clip mcp`, set it in the env of the MCP client config).',
);
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export {
probe,
type VideoStream,
} from './ffmpeg/probe.js';
export { runFfmpeg } from './ffmpeg/run.js';
export { MissingFilterError, runFfmpeg } from './ffmpeg/run.js';
export {
appendOp,
atomicWriteFile,
Expand Down
4 changes: 3 additions & 1 deletion src/timeline/run-plan.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { unlink, writeFile } from 'node:fs/promises';
import { runFfmpeg } from '../ffmpeg/run.js';
import { requireDrawtext, runFfmpeg } from '../ffmpeg/run.js';
import type { FfmpegPlan } from './compile.js';

export interface RunPlanResult {
Expand All @@ -16,6 +16,8 @@ export interface RunPlanResult {
*/
export async function runPlan(plan: FfmpegPlan): Promise<RunPlanResult> {
const start = Date.now();
// Only text segments carry text files; check before the first step renders.
if (plan.steps.some((step) => step.textFiles.length > 0)) await requireDrawtext();
const cleanup: string[] = [];
try {
for (const step of plan.steps) {
Expand Down
3 changes: 2 additions & 1 deletion src/tools/add-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { unlink, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { z } from 'zod';
import { buildAddTextArgs, type NamedPosition } from '../ffmpeg/args/add-text.js';
import { runFfmpeg } from '../ffmpeg/run.js';
import { requireDrawtext, runFfmpeg } from '../ffmpeg/run.js';
import { ensureWorkspace, getWorkspace, newOutputPath, resolveInput } from '../workspace.js';

const NAMED_POSITIONS = [
Expand Down Expand Up @@ -58,6 +58,7 @@ export interface AddTextResult {

export async function addText(rawInput: AddTextInputType): Promise<AddTextResult> {
const input = AddTextInput.parse(rawInput);
await requireDrawtext();

await ensureWorkspace();
const workspace = getWorkspace();
Expand Down
3 changes: 2 additions & 1 deletion src/tools/add-title-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { resolve } from 'node:path';
import { z } from 'zod';
import { quoteFilterArg } from '../ffmpeg/escape.js';
import { probe } from '../ffmpeg/probe.js';
import { runFfmpeg } from '../ffmpeg/run.js';
import { requireDrawtext, runFfmpeg } from '../ffmpeg/run.js';
import { ensureWorkspace, getWorkspace, newOutputPath, resolveInput } from '../workspace.js';
import { concat } from './concat.js';

Expand All @@ -30,6 +30,7 @@ export interface AddTitleCardResult {

export async function addTitleCard(rawInput: AddTitleCardInputType): Promise<AddTitleCardResult> {
const input = AddTitleCardInput.parse(rawInput);
await requireDrawtext();

await ensureWorkspace();
const workspace = getWorkspace();
Expand Down
27 changes: 18 additions & 9 deletions tests/integration/filter-escape.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it } from 'vitest';
import { describe, expect, it } from 'vitest';
import { buildAddTextArgs } from '../../src/ffmpeg/args/add-text.js';
import { buildConcatArgs } from '../../src/ffmpeg/args/concat.js';
import { quoteFilterArg } from '../../src/ffmpeg/escape.js';
import { runFfmpeg } from '../../src/ffmpeg/run.js';
import { listFilters, MissingFilterError, runFfmpeg } from '../../src/ffmpeg/run.js';
import { addText } from '../../src/tools/add-text.js';
import { buildConcatListContent } from '../../src/tools/concat.js';

// The unit tests read escaped output back through a port of FFmpeg's parser;
Expand All @@ -22,15 +23,11 @@ const NAMES = [
String.raw`back\slash'quote`,
];

// `movie` ships in every FFmpeg build; drawtext needs libfreetype, which the
// Linux ffmpeg-static binary leaves out, so it only runs where it exists.
// `movie` ships in every FFmpeg build; drawtext needs libfreetype and
// libharfbuzz, and the Linux ffmpeg-static binary has no harfbuzz, so it only
// runs where it exists.
const filters = IS_CI ? await listFilters() : new Set<string>();

async function listFilters(): Promise<Set<string>> {
const { stdout } = await runFfmpeg(['-hide_banner', '-filters']);
return new Set([...stdout.matchAll(/^ \S{3} (\S+)\s+\S+->\S+/gm)].map((m) => m[1] as string));
}

async function withTempDir(run: (dir: string) => Promise<void>): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), 'makemyclip-escape-'));
try {
Expand All @@ -55,6 +52,10 @@ async function writeClip(path: string): Promise<void> {
}

describe.skipIf(!IS_CI)('path escaping against real FFmpeg', () => {
it('lists the filters this FFmpeg was built with', () => {
expect(filters.has('movie')).toBe(true);
});

it.each(NAMES)('the movie filter opens %j', async (name) => {
await withTempDir(async (dir) => {
const clip = join(dir, name);
Expand Down Expand Up @@ -84,6 +85,14 @@ describe.skipIf(!IS_CI)('path escaping against real FFmpeg', () => {
});
});

describe.skipIf(filters.has('drawtext'))('without drawtext', () => {
it('add_text stops with MissingFilterError', async () => {
await expect(
addText({ input: 'unused.mp4', text: 'Hi', startSec: 0, endSec: 1 }),
).rejects.toThrow(MissingFilterError);
});
});

it('the concat demuxer reads a list with quotes, commas and brackets in the name', async () => {
await withTempDir(async (dir) => {
const clip = join(dir, "O'Brien, [clip]; take 2.mp4");
Expand Down
142 changes: 142 additions & 0 deletions tests/require-drawtext.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { existsSync } from 'node:fs';
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { parseFilterList } from '../src/ffmpeg/run.js';
import type { FfmpegPlan } from '../src/timeline/compile.js';

const LIST_ARGS = ['-hide_banner', '-filters'];

// Rows as FFmpeg 6/7 print them, legend included.
const LIST_WITHOUT_DRAWTEXT = [
'Filters:',
' T.. = Timeline support',
' A = Audio input/output',
' | = Source or sink filter',
' ..C color |->V Provide an uniformly colored input.',
' TSC scale V->V Scale the input video size and/or convert the image format.',
' ... nullsink V->| Do absolutely nothing with the input video.',
' ..C movie |->N Read from a movie source.',
'',
].join('\n');
const LIST_WITH_DRAWTEXT = `${LIST_WITHOUT_DRAWTEXT} T.C drawtext V->V Draw text on top of video frames using libfreetype library.\n`;

describe('parseFilterList', () => {
it('reads filter names and skips the legend', () => {
expect(parseFilterList(LIST_WITH_DRAWTEXT)).toEqual(
new Set(['color', 'scale', 'nullsink', 'movie', 'drawtext']),
);
});
});

// A stand-in FFmpeg that records each argv as a JSON line, prints `filterList`
// when asked for filters, and otherwise succeeds without writing anything.
async function installFakeFfmpeg(dir: string, filterList: string): Promise<string> {
const bin = join(dir, 'ffmpeg');
await writeFile(
bin,
[
'#!/usr/bin/env node',
"const { appendFileSync } = require('node:fs');",
`appendFileSync(${JSON.stringify(join(dir, 'calls.jsonl'))}, JSON.stringify(process.argv.slice(2)) + '\\n');`,
`if (process.argv.includes('-filters')) process.stdout.write(${JSON.stringify(filterList)});`,
'',
].join('\n'),
);
await chmod(bin, 0o755);
return bin;
}

describe.skipIf(process.platform === 'win32')('requireDrawtext', () => {
let dir: string;
const saved = {
ffmpeg: process.env.MAKEMYCLIP_FFMPEG_PATH,
workspace: process.env.MAKEMYCLIP_WORKSPACE,
};

beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'mmc-drawtext-'));
process.env.MAKEMYCLIP_WORKSPACE = join(dir, 'workspace');
// run.ts resolves the binary and caches the filter list at module scope.
vi.resetModules();
});

afterEach(async () => {
for (const [key, value] of [
['MAKEMYCLIP_FFMPEG_PATH', saved.ffmpeg],
['MAKEMYCLIP_WORKSPACE', saved.workspace],
] as const) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
await rm(dir, { recursive: true, force: true });
});

async function load(filterList: string) {
const bin = await installFakeFfmpeg(dir, filterList);
process.env.MAKEMYCLIP_FFMPEG_PATH = bin;
const { MissingFilterError } = await import('../src/ffmpeg/run.js');
const { addText } = await import('../src/tools/add-text.js');
const { addCaptions } = await import('../src/tools/add-captions.js');
const { addTitleCard } = await import('../src/tools/add-title-card.js');
const { runPlan } = await import('../src/timeline/run-plan.js');
const calls = async (): Promise<string[][]> => {
const log = await readFile(join(dir, 'calls.jsonl'), 'utf8').catch(() => '');
return log
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line) as string[]);
};
return { bin, MissingFilterError, addText, addCaptions, addTitleCard, runPlan, calls };
}

function plan(textFiles: FfmpegPlan['steps'][number]['textFiles']): FfmpegPlan {
const output = join(dir, 'out.mp4');
const args = ['-y', '-f', 'lavfi', '-i', 'color=c=black', output];
return { steps: [{ label: 'segment:a', args, output, textFiles }], output, durationSec: 1 };
}

it('stops every text path before it renders, listing filters once', async () => {
const { bin, MissingFilterError, addText, addCaptions, addTitleCard, runPlan, calls } =
await load(LIST_WITHOUT_DRAWTEXT);
const missing = {
name: 'MissingFilterError',
filter: 'drawtext',
ffmpegPath: bin,
message: expect.stringContaining('MAKEMYCLIP_FFMPEG_PATH'),
};
const textfile = join(dir, 'title.txt');

await expect(addText({ input: 'in.mp4', text: 'Hi', startSec: 0, endSec: 1 })).rejects.toThrow(
MissingFilterError,
);
await expect(
addCaptions({ input: 'in.mp4', cues: [{ text: 'Hi', startSec: 0, endSec: 1 }] }),
).rejects.toMatchObject(missing);
await expect(addTitleCard({ input: 'in.mp4', text: 'Title' })).rejects.toMatchObject(missing);
await expect(runPlan(plan([{ path: textfile, content: 'Hi' }]))).rejects.toMatchObject(missing);

expect(await calls()).toEqual([LIST_ARGS]);
expect(existsSync(textfile)).toBe(false);
});

it('leaves plans without text alone', async () => {
const { runPlan, calls } = await load(LIST_WITHOUT_DRAWTEXT);
const noText = plan([]);

await runPlan(noText);

expect(await calls()).toEqual([noText.steps[0]?.args]);
});

it('renders text when the filter is there', async () => {
const { addText, calls } = await load(LIST_WITH_DRAWTEXT);

await addText({ input: 'in.mp4', text: 'Hi', startSec: 0, endSec: 1 });

const [listing, render] = await calls();
expect(listing).toEqual(LIST_ARGS);
expect(render?.slice(0, 4)).toEqual(['-y', '-i', join(process.cwd(), 'in.mp4'), '-vf']);
});
});
Loading