feat(tools): 2.0 phase 5 wave 3b - audio depth (14 tools) (#222)

This commit is contained in:
SnapOtter
2026-06-13 10:19:06 +08:00
parent 5f98b48593
commit 638288e196
81 changed files with 7646 additions and 718 deletions
+51 -2
View File
@@ -32,6 +32,55 @@ export function audioContentType(ext: string): string {
return EXT_AUDIO_CONTENT_TYPES[ext.toLowerCase()] || "audio/mpeg";
}
/** atempo only accepts 0.5..100; chain factors of 0.5 until in range. */
export function buildAtempoChain(factor: number): string {
const parts: string[] = [];
let f = factor;
while (f < 0.5) {
parts.push("atempo=0.5");
f /= 0.5;
}
parts.push(`atempo=${f}`);
return parts.join(",");
}
export interface AudioOutput {
ext: string;
contentType: string;
encodeArgs: string[];
}
const AUDIO_OUTPUTS: Record<string, AudioOutput> = {
".mp3": {
ext: ".mp3",
contentType: "audio/mpeg",
encodeArgs: ["-c:a", "libmp3lame", "-b:a", "192k"],
},
".wav": { ext: ".wav", contentType: "audio/wav", encodeArgs: ["-c:a", "pcm_s16le"] },
".ogg": {
ext: ".ogg",
contentType: "audio/ogg",
encodeArgs: ["-c:a", "libvorbis", "-b:a", "192k"],
},
".opus": {
ext: ".opus",
contentType: "audio/opus",
encodeArgs: ["-c:a", "libopus", "-b:a", "128k"],
},
".flac": { ext: ".flac", contentType: "audio/flac", encodeArgs: ["-c:a", "flac"] },
".m4a": { ext: ".m4a", contentType: "audio/mp4", encodeArgs: ["-c:a", "aac", "-b:a", "192k"] },
".aac": { ext: ".m4a", contentType: "audio/mp4", encodeArgs: ["-c:a", "aac", "-b:a", "192k"] },
".aiff": { ext: ".aiff", contentType: "audio/aiff", encodeArgs: ["-c:a", "pcm_s16be"] },
};
/**
* Encoder selection for filtered (re-encoded) audio outputs, keyed by the
* SOURCE extension. Decode-only sources (wma/amr/ape/...) fall back to mp3.
*/
export function audioOutputFor(srcExt: string): AudioOutput {
return AUDIO_OUTPUTS[srcExt.toLowerCase()] ?? AUDIO_OUTPUTS[".mp3"];
}
export interface MediaRunResult {
outPath: string;
durationS: number | null;
@@ -57,9 +106,9 @@ export async function runFfmpegWithProgress(
args: string[],
durationS: number | null,
opts: { timeoutMs?: number } = {},
): Promise<void> {
): Promise<string> {
ctx.report(5, "Preparing");
await runFfmpeg(args, {
return runFfmpeg(args, {
signal: ctx.signal,
timeoutMs: opts.timeoutMs ?? 30 * 60_000,
onProgress: (p) => {
+28
View File
@@ -209,12 +209,15 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
let clientJobId: string | null = null;
let fileCount = 0;
const received: ReceivedUpload[] = [];
// Track last part for post-loop field recovery
let lastPart: { fields?: Record<string, unknown> } | undefined;
// Parse multipart parts (file parts stream to object storage)
try {
const parts = request.parts();
for await (const part of parts) {
lastPart = part as { fields?: Record<string, unknown> };
if (part.type === "file") {
fileCount++;
if (fileCount > maxInputs) {
@@ -248,6 +251,31 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
}
}
}
// The upstream parts iterator can terminate before yielding trailing
// fields, but busboy has already populated part.fields on every part.
if (lastPart?.fields) {
const recover = (name: string): string | null => {
const f = lastPart?.fields?.[name];
const entry = Array.isArray(f) ? f[0] : f;
if (entry != null && typeof (entry as { value?: unknown }).value === "string") {
return (entry as { value: string }).value;
}
return null;
};
if (settingsRaw === null) {
settingsRaw = recover("settings");
}
if (fileId === null) {
fileId = recover("fileId");
}
if (clientJobId === null) {
const raw = recover("clientJobId");
if (raw !== null && raw.length > 0 && raw.length <= 128) {
clientJobId = raw;
}
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
@@ -0,0 +1,51 @@
import { extname, join } from "node:path";
import { probeMedia } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { audioOutputFor, runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
mode: z.enum(["stereo-to-mono", "mono-to-stereo", "swap"]),
});
export function registerAudioChannels(app: FastifyInstance) {
createToolRoute(app, {
toolId: "audio-channels",
settingsSchema,
process: async () => {
throw new Error("audio-channels is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const origExt = extname(ctx.inputs[0].filename) || ".mp3";
const out = audioOutputFor(origExt);
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}_channels${out.ext}`;
const [inPath] = await stageMediaInputs(ctx);
const info = await probeMedia(inPath);
const ch = info.streams.find((s) => s.type === "audio")?.channels ?? 0;
if ((settings.mode === "stereo-to-mono" || settings.mode === "swap") && ch < 2) {
throw new InputValidationError("This mode needs a stereo input", 422);
}
const outPath = join(ctx.scratchDir, "media", outName);
let args: string[];
if (settings.mode === "stereo-to-mono") {
args = ["-i", inPath, "-ac", "1", ...out.encodeArgs, outPath];
} else if (settings.mode === "mono-to-stereo") {
args = ["-i", inPath, "-ac", "2", ...out.encodeArgs, outPath];
} else {
// swap
args = ["-i", inPath, "-af", "pan=stereo|c0=c1|c1=c0", ...out.encodeArgs, outPath];
}
await runFfmpegWithProgress(ctx, args, info.durationS);
return { scratchPath: outPath, filename: outName, contentType: out.contentType };
},
});
}
@@ -0,0 +1,67 @@
import { extname, join } from "node:path";
import { probeMedia } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { audioContentType, runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
strip: z.boolean().default(false),
title: z.string().max(500).optional(),
artist: z.string().max(500).optional(),
album: z.string().max(500).optional(),
});
export function registerAudioMetadata(app: FastifyInstance) {
createToolRoute(app, {
toolId: "audio-metadata",
settingsSchema,
process: async () => {
throw new Error("audio-metadata is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const srcExt = extname(ctx.inputs[0].filename) || ".mp3";
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}_metadata${srcExt}`;
const contentType = audioContentType(srcExt);
const [inPath] = await stageMediaInputs(ctx);
const info = await probeMedia(inPath);
const args = ["-i", inPath];
if (settings.strip) {
args.push("-map_metadata", "-1");
}
for (const [key, value] of [
["title", settings.title],
["artist", settings.artist],
["album", settings.album],
] as const) {
if (value !== undefined) {
args.push("-metadata", `${key}=${value}`);
}
}
args.push("-c", "copy");
const outPath = join(ctx.scratchDir, "media", outName);
args.push(outPath);
await runFfmpegWithProgress(ctx, args, info.durationS);
return {
scratchPath: outPath,
filename: outName,
contentType,
resultPayload: {
metadata: {
container: info.container,
durationS: info.durationS,
bitrateKbps: info.bitrateKbps,
tags: info.tags ?? {},
},
},
};
},
});
}
+31
View File
@@ -0,0 +1,31 @@
import { extname } from "node:path";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { audioOutputFor, buildAtempoChain, runMediaTool } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
factor: z.number().min(0.25).max(4).default(1.5),
});
export function registerAudioSpeed(app: FastifyInstance) {
createToolRoute(app, {
toolId: "audio-speed",
settingsSchema,
process: async () => {
throw new Error("audio-speed is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const origExt = extname(ctx.inputs[0].filename) || ".mp3";
const out = audioOutputFor(origExt);
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}_speed${out.ext}`;
const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => {
return ["-i", inPath, "-af", buildAtempoChain(settings.factor), ...out.encodeArgs, outPath];
});
return { scratchPath: outPath, filename: outName, contentType: out.contentType };
},
});
}
+62
View File
@@ -0,0 +1,62 @@
import { extname, join } from "node:path";
import { probeMedia } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { audioOutputFor, runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z
.object({
fadeInS: z.number().min(0).max(30).default(1),
fadeOutS: z.number().min(0).max(30).default(1),
})
.refine((s) => s.fadeInS > 0 || s.fadeOutS > 0, {
message: "Set a fade-in or fade-out",
});
export function registerFadeAudio(app: FastifyInstance) {
createToolRoute(app, {
toolId: "fade-audio",
settingsSchema,
process: async () => {
throw new Error("fade-audio is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const origExt = extname(ctx.inputs[0].filename) || ".mp3";
const out = audioOutputFor(origExt);
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}_fade${out.ext}`;
const [inPath] = await stageMediaInputs(ctx);
const info = await probeMedia(inPath);
const d = info.durationS ?? 0;
if ((d === 0 || info.durationS === null) && settings.fadeOutS > 0) {
throw new InputValidationError("Could not determine audio duration for fade-out");
}
const fin = Math.min(settings.fadeInS, d);
const fout = Math.min(settings.fadeOutS, d);
const parts: string[] = [];
if (fin > 0) {
parts.push(`afade=t=in:st=0:d=${fin}`);
}
if (fout > 0) {
parts.push(`afade=t=out:st=${Math.max(0, d - fout)}:d=${fout}`);
}
const chain = parts.join(",");
const outPath = join(ctx.scratchDir, "media", outName);
await runFfmpegWithProgress(
ctx,
["-i", inPath, "-af", chain, ...out.encodeArgs, outPath],
info.durationS,
);
return { scratchPath: outPath, filename: outName, contentType: out.contentType };
},
});
}
+29 -1
View File
@@ -5,6 +5,9 @@ import { db, schema } from "../../db/index.js";
import { registerColorAdjustments } from "./adjust-colors.js";
import { registerAiCanvasExpand } from "./ai-canvas-expand.js";
import { registerAspectPad } from "./aspect-pad.js";
import { registerAudioChannels } from "./audio-channels.js";
import { registerAudioMetadata } from "./audio-metadata.js";
import { registerAudioSpeed } from "./audio-speed.js";
import { registerBarcodeRead } from "./barcode-read.js";
import { registerBeautify } from "./beautify.js";
import { registerBlurFaces } from "./blur-faces.js";
@@ -39,6 +42,7 @@ import { registerEraseObject } from "./erase-object.js";
import { registerExtractAudio } from "./extract-audio.js";
import { registerExtractPages } from "./extract-pages.js";
import { registerExtractSubtitles } from "./extract-subtitles.js";
import { registerFadeAudio } from "./fade-audio.js";
import { registerFavicon } from "./favicon.js";
import { registerFindDuplicates } from "./find-duplicates.js";
import { registerFlattenPdf } from "./flatten-pdf.js";
@@ -56,10 +60,13 @@ import { registerJsonXml } from "./json-xml.js";
import { registerLinearizePdf } from "./linearize-pdf.js";
import { registerMarkdownToPdf } from "./markdown-to-pdf.js";
import { registerMemeGenerator } from "./meme-generator.js";
import { registerMergeAudio } from "./merge-audio.js";
import { registerMergePdf } from "./merge-pdf.js";
import { registerMergeVideos } from "./merge-videos.js";
import { registerMuteVideo } from "./mute-video.js";
import { registerNoiseReduction } from "./noise-reduction.js";
import { registerNoiseRemoval } from "./noise-removal.js";
import { registerNormalizeAudio } from "./normalize-audio.js";
import { registerNupPdf } from "./nup-pdf.js";
import { registerOcr } from "./ocr.js";
import { registerOptimizeForWeb } from "./optimize-for-web.js";
@@ -71,6 +78,7 @@ import { registerPdfToImage } from "./pdf-to-image.js";
import { registerPdfToText } from "./pdf-to-text.js";
import { registerPdfToWord } from "./pdf-to-word.js";
import { registerPdfaConvert } from "./pdfa-convert.js";
import { registerPitchShift } from "./pitch-shift.js";
import { registerProtectPdf } from "./protect-pdf.js";
import { registerQrGenerate } from "./qr-generate.js";
import { registerRedEyeRemoval } from "./red-eye-removal.js";
@@ -83,13 +91,17 @@ import { registerReplaceColor } from "./replace-color.js";
import { registerResize } from "./resize.js";
import { registerResizeVideo } from "./resize-video.js";
import { registerRestorePhoto } from "./restore-photo.js";
import { registerReverseAudio } from "./reverse-audio.js";
import { registerReverseVideo } from "./reverse-video.js";
import { registerRingtoneMaker } from "./ringtone-maker.js";
import { registerRotate } from "./rotate.js";
import { registerRotatePdf } from "./rotate-pdf.js";
import { registerRotateVideo } from "./rotate-video.js";
import { registerSharpening } from "./sharpening.js";
import { registerSilenceRemoval } from "./silence-removal.js";
import { registerSmartCrop } from "./smart-crop.js";
import { registerSplit } from "./split.js";
import { registerSplitAudio } from "./split-audio.js";
import { registerSplitCsv } from "./split-csv.js";
import { registerSplitPdf } from "./split-pdf.js";
import { registerStabilizeVideo } from "./stabilize-video.js";
@@ -110,10 +122,12 @@ import { registerVideoSpeed } from "./video-speed.js";
import { registerVideoToFrames } from "./video-to-frames.js";
import { registerVideoToGif } from "./video-to-gif.js";
import { registerVideoToWebp } from "./video-to-webp.js";
import { registerVolumeAdjust } from "./volume-adjust.js";
import { registerWatermarkImage } from "./watermark-image.js";
import { registerWatermarkPdf } from "./watermark-pdf.js";
import { registerWatermarkText } from "./watermark-text.js";
import { registerWatermarkVideo } from "./watermark-video.js";
import { registerWaveformImage } from "./waveform-image.js";
import { registerWordToPdf } from "./word-to-pdf.js";
/**
@@ -229,9 +243,23 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
{ id: "watermark-video", register: registerWatermarkVideo },
// Audio
{ id: "audio-channels", register: registerAudioChannels },
{ id: "audio-metadata", register: registerAudioMetadata },
{ id: "audio-speed", register: registerAudioSpeed },
{ id: "convert-audio", register: registerConvertAudio },
{ id: "trim-audio", register: registerTrimAudio },
{ id: "extract-audio", register: registerExtractAudio },
{ id: "fade-audio", register: registerFadeAudio },
{ id: "merge-audio", register: registerMergeAudio },
{ id: "noise-reduction", register: registerNoiseReduction },
{ id: "normalize-audio", register: registerNormalizeAudio },
{ id: "pitch-shift", register: registerPitchShift },
{ id: "reverse-audio", register: registerReverseAudio },
{ id: "ringtone-maker", register: registerRingtoneMaker },
{ id: "silence-removal", register: registerSilenceRemoval },
{ id: "split-audio", register: registerSplitAudio },
{ id: "trim-audio", register: registerTrimAudio },
{ id: "volume-adjust", register: registerVolumeAdjust },
{ id: "waveform-image", register: registerWaveformImage },
// PDF & Documents
{ id: "merge-pdf", register: registerMergePdf },
+85
View File
@@ -0,0 +1,85 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { probeMedia } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
format: z.enum(["mp3", "wav", "flac", "m4a"]).default("mp3"),
});
const CONTENT_TYPES: Record<string, string> = {
mp3: "audio/mpeg",
wav: "audio/wav",
flac: "audio/flac",
m4a: "audio/mp4",
};
const ENCODERS: Record<string, string[]> = {
mp3: ["-c:a", "libmp3lame", "-b:a", "192k"],
wav: ["-c:a", "pcm_s16le"],
flac: ["-c:a", "flac"],
m4a: ["-c:a", "aac", "-b:a", "192k"],
};
export function registerMergeAudio(app: FastifyInstance) {
createToolRoute(app, {
toolId: "merge-audio",
maxInputs: 10,
settingsSchema,
process: async () => {
throw new Error("merge-audio is v2-only");
},
processV2: async (ctx) => {
if (ctx.inputs.length < 2) {
throw new InputValidationError("Merging needs at least two audio files");
}
const settings = settingsSchema.parse(ctx.settings);
const paths = await stageMediaInputs(ctx);
// Probe each for total duration (progress mapping)
let totalS = 0;
for (const p of paths) {
const info = await probeMedia(p);
totalS += info.durationS ?? 0;
}
// Build per-input normalize chains + audio-only concat
const parts: string[] = [];
const refs: string[] = [];
for (let i = 0; i < paths.length; i++) {
parts.push(
`[${i}:a]aresample=44100,aformat=sample_fmts=fltp:channel_layouts=stereo[a${i}]`,
);
refs.push(`[a${i}]`);
}
const filter = `${parts.join(";")};\n${refs.join("")}concat=n=${paths.length}:v=0:a=1[a]`;
const dir = join(ctx.scratchDir, "media");
await mkdir(dir, { recursive: true });
const outPath = join(dir, `merged.${settings.format}`);
const args = [
...paths.flatMap((p) => ["-i", p]),
"-filter_complex",
filter,
"-map",
"[a]",
...ENCODERS[settings.format],
outPath,
];
await runFfmpegWithProgress(ctx, args, totalS || null);
return {
scratchPath: outPath,
filename: `merged.${settings.format}`,
contentType: CONTENT_TYPES[settings.format],
};
},
});
}
@@ -0,0 +1,40 @@
import { extname } from "node:path";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { audioOutputFor, runMediaTool } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const NR_MAP: Record<string, number> = {
light: 6,
medium: 12,
strong: 24,
};
const settingsSchema = z.object({
strength: z.enum(["light", "medium", "strong"]).default("medium"),
});
export function registerNoiseReduction(app: FastifyInstance) {
createToolRoute(app, {
toolId: "noise-reduction",
settingsSchema,
process: async () => {
throw new Error("noise-reduction is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const origExt = extname(ctx.inputs[0].filename) || ".mp3";
const out = audioOutputFor(origExt);
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}_denoised${out.ext}`;
const nr = NR_MAP[settings.strength];
const chain = `afftdn=nr=${nr}:nf=-50`;
const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => {
return ["-i", inPath, "-af", chain, ...out.encodeArgs, outPath];
});
return { scratchPath: outPath, filename: outName, contentType: out.contentType };
},
});
}
@@ -0,0 +1,28 @@
import { extname } from "node:path";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { audioOutputFor, runMediaTool } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerNormalizeAudio(app: FastifyInstance) {
createToolRoute(app, {
toolId: "normalize-audio",
settingsSchema,
process: async () => {
throw new Error("normalize-audio is v2-only");
},
processV2: async (ctx) => {
const origExt = extname(ctx.inputs[0].filename) || ".mp3";
const out = audioOutputFor(origExt);
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}_normalized${out.ext}`;
const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => {
return ["-i", inPath, "-af", "loudnorm=I=-16:TP=-1.5:LRA=11", ...out.encodeArgs, outPath];
});
return { scratchPath: outPath, filename: outName, contentType: out.contentType };
},
});
}
+35
View File
@@ -0,0 +1,35 @@
import { extname } from "node:path";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { audioOutputFor, runMediaTool } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z
.object({
semitones: z.number().int().min(-12).max(12).default(3),
})
.refine((s) => s.semitones !== 0, { message: "Semitones must be nonzero" });
export function registerPitchShift(app: FastifyInstance) {
createToolRoute(app, {
toolId: "pitch-shift",
settingsSchema,
process: async () => {
throw new Error("pitch-shift is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const origExt = extname(ctx.inputs[0].filename) || ".mp3";
const out = audioOutputFor(origExt);
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}_pitch${out.ext}`;
const ratio = (2 ** (settings.semitones / 12)).toFixed(6);
const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => {
return ["-i", inPath, "-af", `rubberband=pitch=${ratio}`, ...out.encodeArgs, outPath];
});
return { scratchPath: outPath, filename: outName, contentType: out.contentType };
},
});
}
@@ -0,0 +1,28 @@
import { extname } from "node:path";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { audioOutputFor, runMediaTool } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerReverseAudio(app: FastifyInstance) {
createToolRoute(app, {
toolId: "reverse-audio",
settingsSchema,
process: async () => {
throw new Error("reverse-audio is v2-only");
},
processV2: async (ctx) => {
const origExt = extname(ctx.inputs[0].filename) || ".mp3";
const out = audioOutputFor(origExt);
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}_reversed${out.ext}`;
const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => {
return ["-i", inPath, "-af", "areverse", ...out.encodeArgs, outPath];
});
return { scratchPath: outPath, filename: outName, contentType: out.contentType };
},
});
}
@@ -0,0 +1,58 @@
import { join } from "node:path";
import { probeMedia } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
startS: z.number().min(0).default(0),
durationS: z.number().min(1).max(30).default(30),
});
export function registerRingtoneMaker(app: FastifyInstance) {
createToolRoute(app, {
toolId: "ringtone-maker",
settingsSchema,
process: async () => {
throw new Error("ringtone-maker is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}.m4r`;
const [inPath] = await stageMediaInputs(ctx);
const info = await probeMedia(inPath);
if (settings.startS >= (info.durationS ?? Infinity)) {
throw new InputValidationError("Start is beyond the end of the audio");
}
const outPath = join(ctx.scratchDir, "media", outName);
await runFfmpegWithProgress(
ctx,
[
"-ss",
String(settings.startS),
"-t",
String(settings.durationS),
"-i",
inPath,
"-vn",
"-c:a",
"aac",
"-b:a",
"192k",
"-f",
"ipod",
outPath,
],
info.durationS,
);
return { scratchPath: outPath, filename: outName, contentType: "audio/mp4" };
},
});
}
@@ -0,0 +1,38 @@
import { extname } from "node:path";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { audioOutputFor, runMediaTool } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
thresholdDb: z.number().min(-80).max(-20).default(-50),
minSilenceS: z.number().min(0.1).max(5).default(0.5),
});
export function registerSilenceRemoval(app: FastifyInstance) {
createToolRoute(app, {
toolId: "silence-removal",
settingsSchema,
process: async () => {
throw new Error("silence-removal is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const origExt = extname(ctx.inputs[0].filename) || ".mp3";
const out = audioOutputFor(origExt);
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}_nosilence${out.ext}`;
const t = settings.thresholdDb;
const d = settings.minSilenceS;
const chain =
`silenceremove=start_periods=1:start_threshold=${t}dB:start_silence=0.1` +
`:stop_periods=-1:stop_threshold=${t}dB:stop_duration=${d}`;
const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => {
return ["-i", inPath, "-af", chain, ...out.encodeArgs, outPath];
});
return { scratchPath: outPath, filename: outName, contentType: out.contentType };
},
});
}
+145
View File
@@ -0,0 +1,145 @@
import { createWriteStream } from "node:fs";
import { mkdir, readdir } from "node:fs/promises";
import { extname, join } from "node:path";
import { probeMedia, runFfmpeg } from "@snapotter/media-engine";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { stageMediaInputs } from "../../lib/media-tool.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
mode: z.enum(["time", "parts", "silence"]).default("time"),
segmentS: z.number().min(1).max(3600).default(60),
parts: z.number().int().min(2).max(20).default(2),
thresholdDb: z.number().min(-80).max(-20).default(-40),
minSilenceS: z.number().min(0.1).max(10).default(0.3),
});
export function registerSplitAudio(app: FastifyInstance) {
createToolRoute(app, {
toolId: "split-audio",
settingsSchema,
process: async () => {
throw new Error("split-audio is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const srcExt = extname(ctx.inputs[0].filename) || ".mp3";
const [inPath] = await stageMediaInputs(ctx);
const info = await probeMedia(inPath);
const duration = info.durationS;
const framesDir = join(ctx.scratchDir, "media", "parts");
await mkdir(framesDir, { recursive: true });
if (settings.mode === "time" || settings.mode === "parts") {
let segLen: number;
if (settings.mode === "parts") {
if (duration === null || duration === undefined) {
throw new InputValidationError("Could not determine audio duration");
}
segLen = Math.max(0.1, duration / settings.parts + 0.001);
} else {
segLen = settings.segmentS;
}
ctx.report(5, "Splitting audio");
await runFfmpeg(
[
"-i",
inPath,
"-f",
"segment",
"-segment_time",
String(segLen),
"-c",
"copy",
join(framesDir, `part-%03d${srcExt}`),
],
{ signal: ctx.signal, timeoutMs: 10 * 60_000 },
);
} else {
// silence mode: pass 1 - detect silence regions
ctx.report(5, "Detecting silence");
const nullOut = join(ctx.scratchDir, "media", "null.out");
const stderr = await runFfmpeg(
[
"-i",
inPath,
"-af",
`silencedetect=noise=${settings.thresholdDb}dB:d=${settings.minSilenceS}`,
"-f",
"null",
nullOut,
],
{ signal: ctx.signal, timeoutMs: 10 * 60_000 },
);
// Parse silence_start / silence_end pairs and compute midpoints
const starts = [...stderr.matchAll(/silence_start:\s*([\d.]+)/g)].map((m) => Number(m[1]));
const ends = [...stderr.matchAll(/silence_end:\s*([\d.]+)/g)].map((m) => Number(m[1]));
const cuts: number[] = [];
for (let i = 0; i < Math.min(starts.length, ends.length); i++) {
cuts.push((starts[i] + ends[i]) / 2);
}
if (cuts.length === 0) {
throw new InputValidationError("No silence found to split at");
}
// Build segment boundaries
const d = duration ?? 0;
const boundaries = [0, ...cuts, d];
const total = boundaries.length - 1;
for (let seg = 0; seg < total; seg++) {
const from = boundaries[seg];
const to = boundaries[seg + 1];
const partPath = join(framesDir, `part-${String(seg).padStart(3, "0")}${srcExt}`);
ctx.report(
Math.min(90, 10 + Math.round(((seg + 1) / total) * 80)),
`Extracting segment ${seg + 1}/${total}`,
);
await runFfmpeg(
["-ss", String(from), "-to", String(to), "-i", inPath, "-c", "copy", partPath],
{
signal: ctx.signal,
timeoutMs: 60_000,
},
);
}
}
// Collect produced parts
const files = (await readdir(framesDir)).filter((f) => f.endsWith(srcExt));
files.sort();
if (files.length < 2) {
throw new InputValidationError("Nothing to split: output would be a single segment");
}
// Zip parts (same pattern as video-to-frames)
ctx.report(92, "Creating archive");
const zipPath = join(ctx.scratchDir, "media", `${base}_parts.zip`);
await new Promise<void>((resolve, reject) => {
const output = createWriteStream(zipPath);
const archive = archiver("zip", { zlib: { level: 5 } });
output.on("close", () => resolve());
archive.on("error", (err: Error) => reject(err));
archive.pipe(output);
for (const f of files) {
archive.file(join(framesDir, f), { name: f });
}
void archive.finalize();
});
return {
scratchPath: zipPath,
filename: `${base}_parts.zip`,
contentType: "application/zip",
};
},
});
}
+6 -13
View File
@@ -2,7 +2,12 @@ import { extname, join } from "node:path";
import { probeMedia, resolveEncoder } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
import {
buildAtempoChain,
runFfmpegWithProgress,
stageMediaInputs,
videoContentType,
} from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -10,18 +15,6 @@ const settingsSchema = z.object({
keepPitch: z.boolean().default(true),
});
/** atempo only accepts 0.5..100; chain factors of 0.5 until in range. */
export function buildAtempoChain(factor: number): string {
const parts: string[] = [];
let f = factor;
while (f < 0.5) {
parts.push("atempo=0.5");
f /= 0.5;
}
parts.push(`atempo=${f}`);
return parts.join(",");
}
export function registerVideoSpeed(app: FastifyInstance) {
createToolRoute(app, {
toolId: "video-speed",
@@ -0,0 +1,31 @@
import { extname } from "node:path";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { audioOutputFor, runMediaTool } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
gainDb: z.number().min(-30).max(30).default(3),
});
export function registerVolumeAdjust(app: FastifyInstance) {
createToolRoute(app, {
toolId: "volume-adjust",
settingsSchema,
process: async () => {
throw new Error("volume-adjust is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const origExt = extname(ctx.inputs[0].filename) || ".mp3";
const out = audioOutputFor(origExt);
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}_volume${out.ext}`;
const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => {
return ["-i", inPath, "-af", `volume=${settings.gainDb}dB`, ...out.encodeArgs, outPath];
});
return { scratchPath: outPath, filename: outName, contentType: out.contentType };
},
});
}
@@ -0,0 +1,46 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runMediaTool } from "../../lib/media-tool.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
width: z.number().int().min(256).max(3840).default(1024),
height: z.number().int().min(64).max(1080).default(256),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#4f46e5"),
});
export function registerWaveformImage(app: FastifyInstance) {
createToolRoute(app, {
toolId: "waveform-image",
settingsSchema,
process: async () => {
throw new Error("waveform-image is v2-only");
},
processV2: async (ctx) => {
const settings = settingsSchema.parse(ctx.settings);
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
const outName = `${base}_waveform.png`;
const c = settings.color.replace("#", "0x");
const w = settings.width;
const h = settings.height;
const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => {
return [
"-i",
inPath,
"-filter_complex",
`showwavespic=s=${w}x${h}:colors=${c}`,
"-frames:v",
"1",
outPath,
];
});
return { scratchPath: outPath, filename: outName, contentType: "image/png" };
},
});
}