289 lines
8.4 KiB
TypeScript
289 lines
8.4 KiB
TypeScript
/**
|
|
* Makes what the server derives from a file dump's upload with ffmpeg: a
|
|
* video's still and an image's scaled-down copy, which the thumbnails route
|
|
* serves, and an audio file's waveform, which the waveform route serves. Each
|
|
* is made on its first request and kept in `THUMBNAILS_DIR` under a name this
|
|
* module gives, until dump-service replaces or deletes the upload and
|
|
* {@link removeGeneratedMedia} drops them.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { THUMBNAILS_DIR } from "../config.ts";
|
|
import { type DumpWaveform, peaksOf } from "../../shared/waveform.ts";
|
|
import type { APIResponse } from "../../shared/api.ts";
|
|
|
|
/**
|
|
* Spares a process per uncached request: whether ffmpeg runs is probed once
|
|
* per process, and kept here.
|
|
*/
|
|
let ffmpegProbe: Promise<boolean> | undefined;
|
|
|
|
function ffmpegAvailable(): Promise<boolean> {
|
|
ffmpegProbe ??= (async () => {
|
|
try {
|
|
const { success } = await new Deno.Command("ffmpeg", {
|
|
args: ["-version"],
|
|
stderr: "null",
|
|
stdout: "null",
|
|
}).output();
|
|
return success;
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
return ffmpegProbe;
|
|
}
|
|
|
|
/**
|
|
* Lists the container formats ffmpeg may read a video as. The upload's type is
|
|
* only what the uploader claimed, and ffmpeg guesses the format from the bytes,
|
|
* so a "video" could be an HLS or concat playlist pointing it at local files or
|
|
* internal URLs. With this list, and `-protocol_whitelist file`, ffmpeg reads
|
|
* nothing but the file, and only as a video.
|
|
*/
|
|
const VIDEO_FORMATS =
|
|
"mov,mp4,m4a,3gp,3g2,mj2,matroska,webm,avi,flv,mpegts,mpeg,ogg,asf,h264,hevc";
|
|
/**
|
|
* Lists the formats ffmpeg may read an image as, for the reason given at
|
|
* {@link VIDEO_FORMATS}.
|
|
*/
|
|
const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe";
|
|
/**
|
|
* Lists the formats ffmpeg may read an audio file as, for the reason given at
|
|
* {@link VIDEO_FORMATS}; an `.m4a` or a `.webm` is read by its container's.
|
|
*/
|
|
const AUDIO_FORMATS =
|
|
"mp3,ogg,flac,wav,aiff,aac,asf,caf,w64,wv,ape,mov,mp4,m4a,matroska,webm";
|
|
|
|
/**
|
|
* Maps the image types that get a scaled-down preview to the preview's format.
|
|
* A GIF is served as it is, to keep its animation.
|
|
*/
|
|
export const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = {
|
|
"image/jpeg": "jpg",
|
|
"image/bmp": "jpg",
|
|
// May carry transparency, which JPEG would fill with black.
|
|
"image/png": "png",
|
|
"image/webp": "png",
|
|
};
|
|
|
|
/**
|
|
* Caps an image preview's width. It covers the widest feed card on a
|
|
* high-density screen, at a fraction of a phone photo's bytes.
|
|
*/
|
|
const PREVIEW_WIDTH = 960;
|
|
|
|
/** Returns where the media generated from dump `dumpId`'s upload are kept. */
|
|
export function generatedPaths(dumpId: string): {
|
|
still: string;
|
|
preview: Record<"jpg" | "png", string>;
|
|
waveform: string;
|
|
} {
|
|
const base = `${THUMBNAILS_DIR}/${dumpId}`;
|
|
return {
|
|
still: `${base}.jpg`,
|
|
preview: { jpg: `${base}.preview.jpg`, png: `${base}.preview.png` },
|
|
waveform: `${base}.waveform.json`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Deletes what was generated from dump `dumpId`'s upload, once dump-service
|
|
* has replaced or deleted it. The routes make them again from a new one.
|
|
*/
|
|
export async function removeGeneratedMedia(dumpId: string): Promise<void> {
|
|
const { still, preview, waveform } = generatedPaths(dumpId);
|
|
await Promise.all(
|
|
[still, preview.jpg, preview.png, waveform].map((path) =>
|
|
Deno.remove(path).catch(() => {})
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Writes `outPath` with `write`, which gets a path beside it to fill, renamed
|
|
* over `outPath` once done, so a concurrent reader never gets half a file.
|
|
*/
|
|
async function writeAtomically(
|
|
outPath: string,
|
|
write: (tmpPath: string) => Promise<void>,
|
|
): Promise<void> {
|
|
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
|
|
const tmpPath = `${outPath}.${crypto.randomUUID()}.tmp`;
|
|
try {
|
|
await write(tmpPath);
|
|
await Deno.rename(tmpPath, outPath);
|
|
} catch (err) {
|
|
await Deno.remove(tmpPath).catch(() => {});
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Runs ffmpeg with `args`, writing to `outPath`.
|
|
*
|
|
* @throws {Error} When ffmpeg fails.
|
|
*/
|
|
function runFfmpeg(args: string[], outPath: string): Promise<void> {
|
|
return writeAtomically(outPath, async (tmpPath) => {
|
|
const { success } = await new Deno.Command("ffmpeg", {
|
|
args: ["-protocol_whitelist", "file", ...args, "-y", tmpPath],
|
|
stdout: "null",
|
|
stderr: "null",
|
|
}).output();
|
|
if (!success) throw new Error("ffmpeg failed");
|
|
});
|
|
}
|
|
|
|
/** Writes a 320-pixel-wide JPEG still of a video, one second in. */
|
|
export function videoStill(srcPath: string, outPath: string): Promise<void> {
|
|
return runFfmpeg([
|
|
"-format_whitelist",
|
|
VIDEO_FORMATS,
|
|
"-ss",
|
|
"00:00:01",
|
|
"-i",
|
|
srcPath,
|
|
"-frames:v",
|
|
"1",
|
|
"-vf",
|
|
"scale=320:-1",
|
|
"-f",
|
|
"image2",
|
|
"-c:v",
|
|
"mjpeg",
|
|
], outPath);
|
|
}
|
|
|
|
/** Writes a copy of an image at most {@link PREVIEW_WIDTH} wide. */
|
|
export function imagePreview(srcPath: string, outPath: string): Promise<void> {
|
|
return runFfmpeg([
|
|
"-format_whitelist",
|
|
IMAGE_FORMATS,
|
|
"-i",
|
|
srcPath,
|
|
"-frames:v",
|
|
"1",
|
|
"-vf",
|
|
`scale='min(${PREVIEW_WIDTH},iw)':-2`,
|
|
"-f",
|
|
"image2",
|
|
...(outPath.endsWith(".png")
|
|
? ["-c:v", "png"]
|
|
: ["-c:v", "mjpeg", "-q:v", "4"]),
|
|
], outPath);
|
|
}
|
|
|
|
/**
|
|
* The sample rate the audio is decoded at for its waveform. A bar stands for
|
|
* a second or more of a song, so 8 kHz keeps every peak worth drawing at a
|
|
* sixth of the samples.
|
|
*/
|
|
const PEAKS_SAMPLE_RATE = 8000;
|
|
/** How many samples each running peak covers before `peaksOf` buckets them. */
|
|
const PEAKS_WINDOW = 256;
|
|
|
|
/**
|
|
* Decodes the audio of `srcPath` to its waveform's peaks. ffmpeg streams the
|
|
* samples, mono at {@link PEAKS_SAMPLE_RATE}, and only the peak of each
|
|
* {@link PEAKS_WINDOW} is kept, so an hour of audio holds a few hundred
|
|
* kilobytes at most.
|
|
*
|
|
* @throws {Error} When ffmpeg fails or the file holds no audio.
|
|
*/
|
|
async function audioPeaks(srcPath: string): Promise<number[]> {
|
|
const child = new Deno.Command("ffmpeg", {
|
|
args: [
|
|
"-protocol_whitelist",
|
|
"file",
|
|
"-format_whitelist",
|
|
AUDIO_FORMATS,
|
|
"-i",
|
|
srcPath,
|
|
"-vn",
|
|
"-ac",
|
|
"1",
|
|
"-ar",
|
|
String(PEAKS_SAMPLE_RATE),
|
|
"-f",
|
|
"s16le",
|
|
"-",
|
|
],
|
|
stdin: "null",
|
|
stdout: "piped",
|
|
stderr: "null",
|
|
}).spawn();
|
|
|
|
const windows: number[] = [];
|
|
let peak = 0;
|
|
let count = 0;
|
|
// A chunk may end halfway through a sample, whose first byte waits here.
|
|
let pending: number | null = null;
|
|
const take = (sample: number) => {
|
|
peak = Math.max(peak, Math.abs(sample) / 32768);
|
|
if (++count === PEAKS_WINDOW) {
|
|
windows.push(peak);
|
|
peak = 0;
|
|
count = 0;
|
|
}
|
|
};
|
|
for await (const chunk of child.stdout) {
|
|
let i = 0;
|
|
if (pending !== null) {
|
|
take(((chunk[0] << 24) >> 16) | pending);
|
|
pending = null;
|
|
i = 1;
|
|
}
|
|
for (; i + 1 < chunk.length; i += 2) {
|
|
// Little-endian, signed: the high byte is shifted into the sign bit
|
|
// and back.
|
|
take(((chunk[i + 1] << 24) >> 16) | chunk[i]);
|
|
}
|
|
if (i < chunk.length) pending = chunk[i];
|
|
}
|
|
if (count > 0) windows.push(peak);
|
|
|
|
const { success } = await child.status;
|
|
if (!success || windows.length === 0) throw new Error("ffmpeg failed");
|
|
return peaksOf(windows);
|
|
}
|
|
|
|
/**
|
|
* Writes the waveform of the audio file `srcPath` as the waveform route's
|
|
* whole response, so the route serves it as a file, with its ETag.
|
|
*/
|
|
export function audioWaveform(srcPath: string, outPath: string): Promise<void> {
|
|
return writeAtomically(outPath, async (tmpPath) => {
|
|
const body: APIResponse<DumpWaveform> = {
|
|
success: true,
|
|
data: { peaks: await audioPeaks(srcPath) },
|
|
};
|
|
await Deno.writeTextFile(tmpPath, JSON.stringify(body));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Lets a feed full of cards asking for the same new still at once wait on a
|
|
* single ffmpeg run. It holds the generations under way, by output path.
|
|
*/
|
|
const inFlight = new Map<string, Promise<void>>();
|
|
|
|
/**
|
|
* Makes `outPath` with `generate` unless it exists. Resolves false when it
|
|
* can't be made, because ffmpeg is missing or failed.
|
|
*/
|
|
export async function ensureGenerated(
|
|
outPath: string,
|
|
generate: () => Promise<void>,
|
|
): Promise<boolean> {
|
|
if (await Deno.stat(outPath).then(() => true, () => false)) return true;
|
|
if (!await ffmpegAvailable()) return false;
|
|
let pending = inFlight.get(outPath);
|
|
if (!pending) {
|
|
pending = generate().finally(() => inFlight.delete(outPath));
|
|
inFlight.set(outPath, pending);
|
|
}
|
|
return await pending.then(() => true, () => false);
|
|
}
|