Convert Audio, Extract Audio, Convert Video. ported from the versions that ran on
alpha for months, which is where the rules come from — they are not invented:
skip a file already in the target format (re-encoding mp3 to mp3 loses quality
for nothing)
never overwrite an existing output (the file there may be one somebody made)
delete the source only AFTER the output exists, and only when asked
`inline: ask`, so the modal offers running it now or as a job — a few files inline,
a discography as a job. that was the affordance the old ones had and the reason the
choice exists.
THIN CLIENTS. every one calls /v1/ffmpeg; none of them runs ffmpeg. what to tell
ffmpeg lives once in av-club rather than three times in bash, which is the whole
point of the plugin existing. the three files nevertheless repeat their walk and
their reporting, because the task runner materialises ONLY index.ts and a script
task cannot import a sibling — that duplication is the platform's shape, not a
choice, and each file says so.
CONVERT VIDEO PROBES BEFORE IT DECIDES. if the codecs already fit the container it
asks for `c:v copy, c:a copy` — a remux, lossless, roughly a hundred times faster
than re-encoding. guessing from the extension is how a "fast remux" silently
re-encodes, since plenty of .avi holds h264. that needed a path probe, so
/v1/probe?path= is added beside it, same containment, read-only.
EXTRACT AUDIO HAS NO delete_source, deliberately. extracting is additive; offering
to delete would be offering to trade a video for its soundtrack. a video with no
audio track is reported as a skip rather than a failure — in a folder of screen
recordings that is most of them.
what is NOT here: track selection, subtitle handling, downmixing, per-file
percentage. those belong with jellyfin rather than with basic conversion and are
left where they are rather than half-ported. convert-video's TASK.md says so.
VERIFIED by running each task the way the runner does — temp dir, env vars, argv:
flac→mp3 on a folder; the re-run skipping both files for the two different reasons;
extraction skipping the silent clip and converting the other; mp4→mkv copying h264
and aac through untouched; mp4→webm re-encoding to vp9/opus; delete_source removing
a source after success and LEAVING it after a failure.
the skill tells an agent to use the endpoint and never to shell out to ffmpeg —
everything that makes this safe is on the far side of it — and to offer the
right-click tasks first when somebody is at a screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
204 lines
8.1 KiB
TypeScript
204 lines
8.1 KiB
TypeScript
/**
|
|
* Convert Video — a thin client of av-club's `/v1/ffmpeg`.
|
|
*
|
|
* ── Why this file repeats itself against its two siblings ──
|
|
*
|
|
* The task runner materialises ONLY `index.ts` into a temp directory, so a script task cannot import
|
|
* a sibling. Extract Audio and Convert Video therefore carry their own copies of the walk, the
|
|
* skip rules and the reporting. That duplication is the platform's shape, not a choice — and the part
|
|
* that actually matters, what ffmpeg is told, lives once in av-club rather than three times here.
|
|
*
|
|
* ── The rules are the old task's, kept because they were right ──
|
|
*
|
|
* Skip a file already in the target format; skip when the output exists; never overwrite. All three
|
|
* come from the version of this that ran on alpha for months, and each exists because of something
|
|
* that went wrong without it.
|
|
*/
|
|
|
|
const API = process.env.OFFICER_API_URL;
|
|
const HOST = process.env.OFFICER_API_HOST;
|
|
const TOKEN = process.env.OFFICER_AUTH_TOKEN;
|
|
|
|
if (!API || !TOKEN) {
|
|
console.error('No OFFICER_API_URL / OFFICER_AUTH_TOKEN — this only runs as a task.');
|
|
process.exit(1);
|
|
}
|
|
|
|
const target = (process.env.INPUT_TARGET_FORMAT || 'mp3').toLowerCase();
|
|
const deleteSource = (process.env.INPUT_DELETE_SOURCE || 'false') === 'true';
|
|
const start = process.argv[2] || process.env.INPUT_FILE_PATH;
|
|
|
|
if (!start) {
|
|
console.error('No file or folder given.');
|
|
process.exit(1);
|
|
}
|
|
|
|
const SOURCE_EXT = new Set([
|
|
'mp4', 'mkv', 'webm', 'mov', 'avi', 'wmv', 'flv', 'm4v', 'mpg',
|
|
'mpeg', 'm2ts', 'mts', '3gp', 'ogv', 'vob', 'divx', 'asf', 'f4v',
|
|
]);
|
|
|
|
/**
|
|
* What each container can hold as-is, and what to encode to when it cannot.
|
|
*
|
|
* `copy` is the whole point: if the codecs already fit, this is a REMUX — the bytes are moved into a
|
|
* new container, losslessly, in about the time it takes to read the file. Re-encoding a two-hour film
|
|
* that only needed rewrapping is an hour of CPU and a quality loss for nothing.
|
|
*
|
|
* CRF 21 rather than a bitrate for the cases that do need encoding: constant quality, so a busy scene
|
|
* gets the bits it needs and a static one does not waste them. Resolution and frame rate are never
|
|
* touched — this converts containers, it does not resize video.
|
|
*/
|
|
const CONTAINERS: Record<string, { video: string[]; audio: string[]; encode: Record<string, unknown> }> = {
|
|
mp4: {
|
|
video: ['h264', 'hevc', 'mpeg4'],
|
|
audio: ['aac', 'mp3'],
|
|
encode: { 'c:v': 'libx264', crf: 21, preset: 'medium', pix_fmt: 'yuv420p', 'c:a': 'aac', 'b:a': '192k', movflags: '+faststart' },
|
|
},
|
|
mov: {
|
|
video: ['h264', 'hevc', 'prores'],
|
|
audio: ['aac', 'pcm_s16le'],
|
|
encode: { 'c:v': 'libx264', crf: 21, preset: 'medium', pix_fmt: 'yuv420p', 'c:a': 'aac', 'b:a': '192k' },
|
|
},
|
|
mkv: {
|
|
// Matroska carries essentially anything, so a conversion INTO it is nearly always a remux.
|
|
video: ['h264', 'hevc', 'vp8', 'vp9', 'av1', 'mpeg4', 'mpeg2video', 'theora'],
|
|
audio: ['aac', 'mp3', 'opus', 'vorbis', 'flac', 'ac3', 'eac3', 'dts', 'pcm_s16le'],
|
|
encode: { 'c:v': 'libx264', crf: 21, preset: 'medium', 'c:a': 'aac', 'b:a': '192k' },
|
|
},
|
|
webm: {
|
|
video: ['vp8', 'vp9', 'av1'],
|
|
audio: ['opus', 'vorbis'],
|
|
encode: { 'c:v': 'libvpx-vp9', crf: 32, 'c:a': 'libopus', 'b:a': '128k' },
|
|
},
|
|
};
|
|
|
|
if (!CONTAINERS[target]) {
|
|
console.error(`Cannot convert to ${target}. Choose one of: ${Object.keys(CONTAINERS).join(', ')}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
/**
|
|
* Ask av-club what is inside, so the copy-or-encode decision is made on the actual codecs.
|
|
*
|
|
* Guessing from the extension is what makes a "fast remux" silently re-encode: plenty of .avi holds
|
|
* h264 and plenty of .mp4 holds something mp4 cannot legally carry.
|
|
*/
|
|
async function streams(path: string): Promise<{ video: string | null; audio: string | null } | null> {
|
|
const res = await fetch(`${API}/api/av-club/v1/probe?path=${encodeURIComponent(path)}`, { headers });
|
|
if (!res.ok) return null;
|
|
const body = (await res.json()) as { streams?: Array<{ codec_type?: string; codec_name?: string }> };
|
|
const find = (kind: string) => body.streams?.find((s) => s.codec_type === kind)?.codec_name ?? null;
|
|
return { video: find('video'), audio: find('audio') };
|
|
}
|
|
|
|
const { readdir, stat, unlink } = await import('node:fs/promises');
|
|
const { join, extname, dirname, basename } = await import('node:path');
|
|
|
|
const headers: Record<string, string> = {
|
|
Authorization: `Bearer ${TOKEN}`,
|
|
'content-type': 'application/json',
|
|
...(HOST ? { Host: HOST } : {}),
|
|
};
|
|
|
|
const exists = async (p: string) => !!(await stat(p).catch(() => null));
|
|
|
|
/** Every audio file under a folder, deepest-last, sorted so a run is reproducible. */
|
|
async function collect(dir: string): Promise<string[]> {
|
|
const found: string[] = [];
|
|
for (const entry of (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
const full = join(dir, entry.name);
|
|
if (entry.isDirectory()) found.push(...(await collect(full)));
|
|
else if (SOURCE_EXT.has(extname(entry.name).slice(1).toLowerCase())) found.push(full);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
const info = await stat(start).catch(() => null);
|
|
if (!info) {
|
|
console.error(`Not there: ${start}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const files = info.isDirectory() ? await collect(start) : [start];
|
|
if (files.length === 0) {
|
|
console.log(`No video files in ${start}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
let done = 0;
|
|
let skipped = 0;
|
|
let failed = 0;
|
|
|
|
for (const [index, input] of files.entries()) {
|
|
const name = basename(input);
|
|
const ext = extname(input).slice(1).toLowerCase();
|
|
const output = join(dirname(input), `${basename(input, extname(input))}.${target}`);
|
|
|
|
// The sentinel the jobs view reads for its progress bar. Printed before the work, so a long
|
|
// conversion shows which file it is on rather than jumping when it finishes.
|
|
console.log(`@@officer:progress@@ ${JSON.stringify({ current: index + 1, total: files.length, label: name })}`);
|
|
|
|
if (ext === target) {
|
|
console.log(`Skipping ${name} — already ${target}`);
|
|
skipped++;
|
|
continue;
|
|
}
|
|
if (await exists(output)) {
|
|
console.log(`Skipping ${name} — ${basename(output)} already exists`);
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
// What is actually inside decides copy-or-encode. Unprobeable → encode, which is always correct
|
|
// and merely slower; assuming copy on an unknown codec produces a file that will not play.
|
|
const inside = await streams(input);
|
|
const spec = CONTAINERS[target]!;
|
|
const canCopyVideo = !!inside?.video && spec.video.includes(inside.video);
|
|
const canCopyAudio = !inside?.audio || spec.audio.includes(inside.audio);
|
|
|
|
const outputOptions: Record<string, unknown> = canCopyVideo
|
|
? { 'c:v': 'copy', 'c:a': canCopyAudio ? 'copy' : (spec.encode['c:a'] as string), ...(canCopyAudio ? {} : { 'b:a': spec.encode['b:a'] }) }
|
|
: { ...spec.encode };
|
|
if (target === 'mp4' && canCopyVideo) outputOptions.movflags = '+faststart';
|
|
|
|
console.log(` ${canCopyVideo ? (canCopyAudio ? 'remux (copying both streams)' : 'remux, re-encoding audio') : 're-encoding'}`);
|
|
|
|
try {
|
|
const res = await fetch(`${API}/api/av-club/v1/ffmpeg`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({ inputPath: input, outputPath: output, format: target, outputOptions }),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const body = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
|
|
console.error(`Failed ${name}: ${body.error?.message ?? `HTTP ${res.status}`}`);
|
|
failed++;
|
|
continue;
|
|
}
|
|
|
|
const result = (await res.json()) as { bytes: number };
|
|
console.log(`Converted ${name} → ${basename(output)} (${(result.bytes / 1_048_576).toFixed(1)} MB)`);
|
|
done++;
|
|
|
|
/*
|
|
Deleted only AFTER a successful write, and only when asked.
|
|
|
|
av-club writes into a temp file and moves it into place, so reaching this line means the output
|
|
is complete and on disk. That ordering is the whole reason deleting is safe to offer at all.
|
|
*/
|
|
if (deleteSource) {
|
|
await unlink(input);
|
|
console.log(` removed ${name}`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed ${name}: ${(error as Error).message}`);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
console.log('');
|
|
console.log(`${done} converted, ${skipped} skipped, ${failed} failed.`);
|
|
process.exit(failed > 0 ? 1 : 0);
|