v4: an audio file's waveform is made once by the server, and the player seeks on one accessible scrubber — every player downloaded the whole file a second time and decoded all of it in the browser to draw 150 bars, so a long FLAC cost tens of megabytes of memory per page, and the waveform could only be clicked: no drag, no keyboard, no slider for a screen reader. GET /api/dumps/:id/waveform now answers an audio dump's peaks, which lib/ffmpeg.ts decodes with ffmpeg on first request, mono at 8 kHz and streamed, keeps beside the thumbnails as the response itself, and serves with an ETag (a ten-minute MP3 takes about a second, then a few hundred bytes). The peaks are taken by shared/waveform.ts's peaksOf, scaled to the loudest so a quiet recording fills the track, and the dump form's preview of a file not yet uploaded decodes it with the same function, at 8 kHz in an OfflineAudioContext. Scrubber replaces Waveform and SeekBar: a role="slider" that seeks where a pointer lets go, follows a drag (the clock shows where it is held), takes arrows, Home and End, and draws the bars as one path with the played part clipped by a nested svg, so it moves smoothly and a timeupdate redraws two attributes instead of 150 rects; audio without a waveform, and video, get a plain bar. MediaPlayer reads whether it plays from the element's events alone, so a click no longer reports the change twice, its handles for PlayerProvider are stable callbacks instead of refs to refs, the drag no longer reattaches every listener, and the <source> children that src made dead are gone, with the mime prop. The file, thumbnail and waveform routes resolve their dump, token and caching through lib/dump-media.ts, and what is generated from an upload is named and removed in lib/ffmpeg.ts, the custom thumbnail by dump-service's customThumbnailPath, instead of paths spelled out in each. The test setup's play() and pause() fire their events as a browser does. 277 backend tests and 684 frontend tests pass, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked in the app with a real FLAC on its dump page and in the global player at 1280 and 390px, seeking by keyboard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
46
api/lib/dump-media.ts
Normal file
46
api/lib/dump-media.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Resolves the dump whose media a route under `/api/dumps/:dumpId/` serves:
|
||||
* its file (`routes/files.ts`), its thumbnail (`routes/thumbnails.ts`) or its
|
||||
* waveform (`routes/waveforms.ts`). Media elements and `<img>`s can't send an
|
||||
* Authorization header, so these routes take the viewer's media token from
|
||||
* the query string, and are cached by the file's version.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Context } from "@oak/oak";
|
||||
import { APIException } from "./api-exception.ts";
|
||||
import { authenticateMedia } from "./auth.ts";
|
||||
import { type Dump, dumpFileVersion } from "../../shared/dump.ts";
|
||||
import { UUID_RE } from "../../shared/validation.ts";
|
||||
import { getDump } from "../services/dump-service.ts";
|
||||
|
||||
/**
|
||||
* Returns dump `dumpId` as the viewer of `ctx`'s `?token=` may see it, and the
|
||||
* Cache-Control its media get: kept for good under a URL naming the file's
|
||||
* current version (`?v=`, as `dumpFileVersion` gives), revalidated under any
|
||||
* other, and never in a shared cache for a private dump. It also keeps the
|
||||
* token out of the Referer of whatever the response links to.
|
||||
*
|
||||
* @throws {APIException} 400 when `dumpId` isn't an id, which names files on
|
||||
* disk, and 404 when the viewer can't see the dump.
|
||||
*/
|
||||
export async function resolveMediaDump(
|
||||
ctx: Context,
|
||||
dumpId: string,
|
||||
): Promise<{ dump: Dump; cacheControl: string }> {
|
||||
if (!UUID_RE.test(dumpId)) throw new APIException(400, "Invalid dump ID");
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
const viewerId = token ? await authenticateMedia(token) : null;
|
||||
const dump = getDump(dumpId, viewerId ?? undefined);
|
||||
ctx.response.headers.set("Referrer-Policy", "no-referrer");
|
||||
const scope = dump.isPrivate ? "private" : "public";
|
||||
const current = ctx.request.url.searchParams.get("v") ===
|
||||
dumpFileVersion(dump);
|
||||
return {
|
||||
dump,
|
||||
cacheControl: current
|
||||
? `${scope}, max-age=31536000, immutable`
|
||||
: `${scope}, no-cache`,
|
||||
};
|
||||
}
|
||||
288
api/lib/ffmpeg.ts
Normal file
288
api/lib/ffmpeg.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import remoteRouter from "./routes/remote.ts";
|
||||
import searchRouter from "./routes/search.ts";
|
||||
import sessionsRouter from "./routes/sessions.ts";
|
||||
import thumbnailsRouter from "./routes/thumbnails.ts";
|
||||
import waveformsRouter from "./routes/waveforms.ts";
|
||||
import unsubscribeRouter from "./routes/unsubscribe.ts";
|
||||
import usersRouter from "./routes/users.ts";
|
||||
import wsRouter from "./routes/ws.ts";
|
||||
@@ -76,6 +77,7 @@ for (
|
||||
dumpsRouter,
|
||||
filesRouter,
|
||||
thumbnailsRouter,
|
||||
waveformsRouter,
|
||||
commentsRouter,
|
||||
playlistsRouter,
|
||||
categoriesRouter,
|
||||
|
||||
@@ -16,10 +16,9 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
import { type Dump, dumpFileVersion } from "../../shared/dump.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
import { DUMPS_DIR } from "../config.ts";
|
||||
import { UUID_RE } from "../../shared/validation.ts";
|
||||
import { authenticateMedia } from "../lib/auth.ts";
|
||||
import { resolveMediaDump } from "../lib/dump-media.ts";
|
||||
import { can } from "../lib/permissions.ts";
|
||||
import { serveFile } from "../lib/serve-file.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
@@ -66,17 +65,7 @@ function isInlineSafe(mime: string): boolean {
|
||||
|
||||
router.get("/:dumpId/file", async (ctx) => {
|
||||
const { dumpId } = ctx.params;
|
||||
|
||||
// The id names a file on disk: nothing but an id's characters gets through.
|
||||
if (!UUID_RE.test(dumpId)) {
|
||||
throw new APIException(400, "Invalid dump ID");
|
||||
}
|
||||
|
||||
// Media elements can't send an Authorization header, so the viewer's media
|
||||
// token comes in the query string.
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
const viewerId = token ? await authenticateMedia(token) : null;
|
||||
const dump = getDump(dumpId, viewerId ?? undefined);
|
||||
const { dump, cacheControl } = await resolveMediaDump(ctx, dumpId);
|
||||
|
||||
if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) {
|
||||
throw new APIException(404, "No file for this dump");
|
||||
@@ -98,21 +87,9 @@ router.get("/:dumpId/file", async (ctx) => {
|
||||
"sandbox; default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'",
|
||||
);
|
||||
}
|
||||
// The URL may carry a media token; don't pass it on to wherever the file
|
||||
// links.
|
||||
ctx.response.headers.set("Referrer-Policy", "no-referrer");
|
||||
|
||||
// A URL naming the file's current version (dumpFileVersion) always serves the
|
||||
// same bytes, so it is cached for good; any other is revalidated against the
|
||||
// ETag. A private dump's bytes must not sit in a shared cache.
|
||||
const scope = dump.isPrivate ? "private" : "public";
|
||||
const current = ctx.request.url.searchParams.get("v") ===
|
||||
dumpFileVersion(dump);
|
||||
const served = await serveFile(ctx, path, {
|
||||
mime: dump.fileMime,
|
||||
cacheControl: current
|
||||
? `${scope}, max-age=31536000, immutable`
|
||||
: `${scope}, no-cache`,
|
||||
cacheControl,
|
||||
ranges: true,
|
||||
});
|
||||
if (!served) {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Serves and changes dumps' thumbnails, a sub-resource of their dump. The
|
||||
* cards and pages that show a file dump build the URL with
|
||||
* `dumpThumbnailUrl`, and the edit page uploads a custom one. Thumbnails made
|
||||
* from an upload are generated with ffmpeg on first request and kept in
|
||||
* `THUMBNAILS_DIR`.
|
||||
* from an upload are generated on first request by lib/ffmpeg.ts, which keeps
|
||||
* them.
|
||||
*
|
||||
* - `GET /api/dumps/:dumpId/thumbnail?v=&token=` — public; a private dump's
|
||||
* thumbnail needs its poster's media `token`. Serves the dump's custom
|
||||
@@ -22,10 +22,10 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
import { type Dump, dumpFileVersion } from "../../shared/dump.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
import {
|
||||
customThumbnailPath,
|
||||
getDump,
|
||||
getDumpThumbnailInfo,
|
||||
removeDumpThumbnail,
|
||||
setDumpThumbnail,
|
||||
} from "../services/dump-service.ts";
|
||||
@@ -35,9 +35,15 @@ import {
|
||||
serveUploadedFile,
|
||||
validateImageUpload,
|
||||
} from "../lib/upload.ts";
|
||||
import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
|
||||
import { UUID_RE } from "../../shared/validation.ts";
|
||||
import { authenticateMedia } from "../lib/auth.ts";
|
||||
import { DUMPS_DIR } from "../config.ts";
|
||||
import { resolveMediaDump } from "../lib/dump-media.ts";
|
||||
import {
|
||||
ensureGenerated,
|
||||
generatedPaths,
|
||||
imagePreview,
|
||||
PREVIEWABLE_IMAGES,
|
||||
videoStill,
|
||||
} from "../lib/ffmpeg.ts";
|
||||
import { can } from "../lib/permissions.ts";
|
||||
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
@@ -47,178 +53,20 @@ import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/dumps" });
|
||||
|
||||
/**
|
||||
* Spares a process per uncached thumbnail 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";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* Runs ffmpeg with `args`, writing to `outPath`.
|
||||
*
|
||||
* @throws {Error} When ffmpeg fails.
|
||||
*/
|
||||
async function runFfmpeg(args: string[], outPath: string): Promise<void> {
|
||||
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
|
||||
// Written beside and renamed over, so a concurrent reader never gets half.
|
||||
const tmpPath = `${outPath}.${crypto.randomUUID()}.tmp`;
|
||||
const { success } = await new Deno.Command("ffmpeg", {
|
||||
args: ["-protocol_whitelist", "file", ...args, "-y", tmpPath],
|
||||
stdout: "null",
|
||||
stderr: "null",
|
||||
}).output();
|
||||
if (!success) {
|
||||
await Deno.remove(tmpPath).catch(() => {});
|
||||
throw new Error("ffmpeg failed");
|
||||
}
|
||||
await Deno.rename(tmpPath, outPath);
|
||||
}
|
||||
|
||||
/** Writes a 320-pixel-wide JPEG still of a video, one second in. */
|
||||
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. */
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
router.get("/:dumpId/thumbnail", async (ctx) => {
|
||||
const { dumpId } = ctx.params;
|
||||
const { dump, cacheControl: generatedCache } = await resolveMediaDump(
|
||||
ctx,
|
||||
dumpId,
|
||||
);
|
||||
|
||||
// The id names files on disk: nothing but an id's characters gets through.
|
||||
if (!UUID_RE.test(dumpId)) {
|
||||
throw new APIException(400, "Invalid dump ID");
|
||||
}
|
||||
|
||||
// Media elements can't send an Authorization header, so the viewer's media
|
||||
// token comes in the query string. This privacy check covers the custom
|
||||
// thumbnail too, since getDumpThumbnailInfo makes none.
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
const viewerId = token ? await authenticateMedia(token) : null;
|
||||
const dump = getDump(dumpId, viewerId ?? undefined);
|
||||
// The URL may carry a media token, which must not leak through a Referer.
|
||||
ctx.response.headers.set("Referrer-Policy", "no-referrer");
|
||||
// What is generated from the file is cached for good under a URL naming its
|
||||
// current version (dumpFileVersion), and revalidated under any other. A
|
||||
// private dump's thumbnail must not sit in a shared cache.
|
||||
const scope = dump.isPrivate ? "private" : "public";
|
||||
const generatedCache =
|
||||
ctx.request.url.searchParams.get("v") === dumpFileVersion(dump)
|
||||
? `${scope}, max-age=31536000, immutable`
|
||||
: `${scope}, no-cache`;
|
||||
|
||||
const customThumb = getDumpThumbnailInfo(dumpId);
|
||||
if (customThumb) {
|
||||
if (dump.thumbnailMime) {
|
||||
// Replacing it doesn't change the file's version, so it is always
|
||||
// revalidated.
|
||||
const served = await serveUploadedFile(
|
||||
ctx,
|
||||
`${THUMBNAILS_DIR}/${customThumb.id}-custom`,
|
||||
customThumb.mime,
|
||||
customThumbnailPath(dump.id),
|
||||
dump.thumbnailMime,
|
||||
0,
|
||||
dump.isPrivate,
|
||||
);
|
||||
@@ -227,9 +75,10 @@ router.get("/:dumpId/thumbnail", async (ctx) => {
|
||||
|
||||
const mime = dump.kind === "file" ? dump.fileMime ?? "" : "";
|
||||
const srcPath = `${DUMPS_DIR}/${dump.id}`;
|
||||
const generated = generatedPaths(dump.id);
|
||||
|
||||
if (mime.startsWith("video/")) {
|
||||
const thumbPath = `${THUMBNAILS_DIR}/${dump.id}.jpg`;
|
||||
const thumbPath = generated.still;
|
||||
if (
|
||||
!await ensureGenerated(thumbPath, () => videoStill(srcPath, thumbPath))
|
||||
) {
|
||||
@@ -249,7 +98,7 @@ router.get("/:dumpId/thumbnail", async (ctx) => {
|
||||
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
const ext = PREVIEWABLE_IMAGES[mime];
|
||||
if (ext) {
|
||||
const previewPath = `${THUMBNAILS_DIR}/${dump.id}.preview.${ext}`;
|
||||
const previewPath = generated.preview[ext];
|
||||
if (
|
||||
await ensureGenerated(
|
||||
previewPath,
|
||||
@@ -303,7 +152,7 @@ router.put(
|
||||
|
||||
// Written before setDumpThumbnail announces it, or clients would fetch it
|
||||
// too early.
|
||||
await replaceUploadedFile(`${THUMBNAILS_DIR}/${dump.id}-custom`, data);
|
||||
await replaceUploadedFile(customThumbnailPath(dump.id), data);
|
||||
const responseBody: APIResponse<Dump> = {
|
||||
success: true,
|
||||
data: setDumpThumbnail(dump.id, mime),
|
||||
|
||||
48
api/routes/waveforms.ts
Normal file
48
api/routes/waveforms.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Serves an audio file dump's waveform, a sub-resource of its dump, for the
|
||||
* player to seek on. The frontend builds the URL with `dumpWaveformUrl`.
|
||||
* lib/ffmpeg.ts makes it from the upload on first request and keeps it, so a
|
||||
* listener downloads a few hundred bytes of peaks rather than the file a
|
||||
* second time to decode it.
|
||||
*
|
||||
* - `GET /api/dumps/:dumpId/waveform?v=&token=` — public; a private dump's
|
||||
* waveform needs its poster's media `token`. Answers a `DumpWaveform`; 404
|
||||
* when the dump isn't an audio file or its audio can't be read.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import { DUMPS_DIR } from "../config.ts";
|
||||
import { resolveMediaDump } from "../lib/dump-media.ts";
|
||||
import {
|
||||
audioWaveform,
|
||||
ensureGenerated,
|
||||
generatedPaths,
|
||||
} from "../lib/ffmpeg.ts";
|
||||
import { serveFile } from "../lib/serve-file.ts";
|
||||
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
* An `APIException` thrown by a handler becomes the JSON error response there.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/dumps" });
|
||||
|
||||
router.get("/:dumpId/waveform", async (ctx) => {
|
||||
const { dump, cacheControl } = await resolveMediaDump(ctx, ctx.params.dumpId);
|
||||
if (dump.kind !== "file" || !dump.fileMime?.startsWith("audio/")) {
|
||||
throw new APIException(404, "No waveform for this dump");
|
||||
}
|
||||
|
||||
const outPath = generatedPaths(dump.id).waveform;
|
||||
const srcPath = `${DUMPS_DIR}/${dump.id}`;
|
||||
if (
|
||||
!await ensureGenerated(outPath, () => audioWaveform(srcPath, outPath)) ||
|
||||
!await serveFile(ctx, outPath, { mime: "application/json", cacheControl })
|
||||
) {
|
||||
throw new APIException(404, "Could not generate waveform");
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from "./notification-service.ts";
|
||||
import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts";
|
||||
import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
||||
import { removeGeneratedMedia } from "../lib/ffmpeg.ts";
|
||||
import {
|
||||
DUMP_ALLOWED_MIME_PREFIXES,
|
||||
DUMP_ALLOWED_MIME_TYPES,
|
||||
@@ -236,19 +237,6 @@ export async function createFileDump(
|
||||
return dump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the thumbnails that `routes/thumbnails.ts` generated from a dump's
|
||||
* upload, once the upload is replaced or deleted. The route makes them again
|
||||
* from a new one.
|
||||
*/
|
||||
async function removeGeneratedThumbnails(dumpId: string): Promise<void> {
|
||||
await Promise.all(
|
||||
[`${dumpId}.jpg`, `${dumpId}.preview.jpg`, `${dumpId}.preview.png`].map(
|
||||
(name) => Deno.remove(`${THUMBNAILS_DIR}/${name}`).catch(() => {}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns dump `dumpId`, without its categories, or throws a 404. It doesn't
|
||||
* check privacy: the routes do, with {@link getDump}.
|
||||
@@ -671,7 +659,7 @@ export async function replaceFileDump(
|
||||
throw err;
|
||||
}
|
||||
if (hadOld) await Deno.remove(backupPath).catch(() => {});
|
||||
await removeGeneratedThumbnails(dumpId);
|
||||
await removeGeneratedMedia(dumpId);
|
||||
|
||||
if (categoryIds !== undefined) {
|
||||
dump.categoryIds = [...new Set(categoryIds)];
|
||||
@@ -811,19 +799,12 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a dump's id and custom thumbnail type, or undefined without one, for
|
||||
* the thumbnail route. It doesn't check privacy: the route calls
|
||||
* {@link getDump} first.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump.
|
||||
* Returns where dump `dumpId`'s custom thumbnail is kept: the thumbnail route
|
||||
* writes and serves it, and this service deletes it with the thumbnail or the
|
||||
* dump.
|
||||
*/
|
||||
export function getDumpThumbnailInfo(
|
||||
dumpId: string,
|
||||
): { id: string; mime: string } | undefined {
|
||||
const dump = fetchDump(dumpId);
|
||||
return dump.thumbnailMime
|
||||
? { id: dump.id, mime: dump.thumbnailMime }
|
||||
: undefined;
|
||||
export function customThumbnailPath(dumpId: string): string {
|
||||
return `${THUMBNAILS_DIR}/${dumpId}-custom`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -853,7 +834,7 @@ export async function removeDumpThumbnail(dumpId: string): Promise<Dump> {
|
||||
const dump = fetchDump(dumpId);
|
||||
db.prepare(`UPDATE dumps SET custom_thumbnail_mime = NULL WHERE id = ?;`)
|
||||
.run(dump.id);
|
||||
await Deno.remove(`${THUMBNAILS_DIR}/${dump.id}-custom`).catch(() => {});
|
||||
await Deno.remove(customThumbnailPath(dump.id)).catch(() => {});
|
||||
const updatedDump: Dump = { ...dump, thumbnailMime: undefined };
|
||||
broadcastDumpChange(dump, updatedDump);
|
||||
return updatedDump;
|
||||
@@ -876,10 +857,10 @@ export async function deleteDump(dumpId: string): Promise<void> {
|
||||
|
||||
if (dump.kind === "file") {
|
||||
await Deno.remove(`${DUMPS_DIR}/${dump.id}`).catch(() => {});
|
||||
await removeGeneratedThumbnails(dump.id);
|
||||
await removeGeneratedMedia(dump.id);
|
||||
}
|
||||
if (dump.thumbnailMime) {
|
||||
await Deno.remove(`${THUMBNAILS_DIR}/${dump.id}-custom`).catch(() => {});
|
||||
await Deno.remove(customThumbnailPath(dump.id)).catch(() => {});
|
||||
}
|
||||
|
||||
broadcastDumpChange(dump, null);
|
||||
|
||||
108
api/test/routes/waveforms.test.ts
Normal file
108
api/test/routes/waveforms.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { createUser, get, insertDump, test, TEST_DIR } from "../helpers.ts";
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { WAVEFORM_PEAKS } from "../../../shared/waveform.ts";
|
||||
|
||||
async function hasFfmpeg(): Promise<boolean> {
|
||||
try {
|
||||
return (await new Deno.Command("ffmpeg", {
|
||||
args: ["-version"],
|
||||
stdout: "null",
|
||||
stderr: "null",
|
||||
}).output()).success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a mono 16-bit WAV of a 440 Hz tone, quiet for its first `seconds`
|
||||
* and four times louder for as long again.
|
||||
*/
|
||||
function wav(seconds: number, rate = 8000): Uint8Array {
|
||||
const n = seconds * rate * 2;
|
||||
const bytes = new Uint8Array(44 + n * 2);
|
||||
const view = new DataView(bytes.buffer);
|
||||
const ascii = (at: number, s: string) =>
|
||||
[...s].forEach((c, i) => view.setUint8(at + i, c.charCodeAt(0)));
|
||||
ascii(0, "RIFF");
|
||||
view.setUint32(4, 36 + n * 2, true);
|
||||
ascii(8, "WAVEfmt ");
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true); // PCM
|
||||
view.setUint16(22, 1, true); // mono
|
||||
view.setUint32(24, rate, true);
|
||||
view.setUint32(28, rate * 2, true);
|
||||
view.setUint16(32, 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
ascii(36, "data");
|
||||
view.setUint32(40, n * 2, true);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const gain = i < n / 2 ? 4000 : 16000;
|
||||
view.setInt16(
|
||||
44 + i * 2,
|
||||
gain * Math.sin((2 * Math.PI * 440 * i) / rate),
|
||||
true,
|
||||
);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
test("GET /api/dumps/:id/waveform: an audio file's peaks, made once, with privacy", async () => {
|
||||
if (!await hasFfmpeg()) return;
|
||||
const owner = await createUser();
|
||||
const song = insertDump({
|
||||
userId: owner.id,
|
||||
fileName: "s.wav",
|
||||
fileMime: "audio/wav",
|
||||
fileData: wav(2),
|
||||
});
|
||||
const res = await get<{ peaks: number[] }>(`/api/dumps/${song.id}/waveform`);
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.headers.get("content-type"), "application/json");
|
||||
assertEquals(res.headers.get("cache-control"), "public, no-cache");
|
||||
const { peaks } = res.data;
|
||||
assertEquals(peaks.length, WAVEFORM_PEAKS);
|
||||
// The loud half reaches the top; the quiet half a quarter of it.
|
||||
assertEquals(Math.max(...peaks), 1);
|
||||
assert(Math.abs(peaks[10] - 0.25) < 0.02, `quiet peak ${peaks[10]}`);
|
||||
assert(peaks[140] > 0.98, `loud peak ${peaks[140]}`);
|
||||
assert(
|
||||
[...Deno.readDirSync(`${TEST_DIR}/uploads/thumbnails`)].some((e) =>
|
||||
e.name === `${song.id}.waveform.json`
|
||||
),
|
||||
);
|
||||
// The kept file is served again, with its ETag.
|
||||
const etag = res.headers.get("etag")!;
|
||||
const again = await get(`/api/dumps/${song.id}/waveform`, {
|
||||
headers: { "If-None-Match": etag },
|
||||
});
|
||||
assertEquals(again.status, 304);
|
||||
|
||||
const secret = insertDump({
|
||||
userId: owner.id,
|
||||
fileName: "p.wav",
|
||||
fileMime: "audio/wav",
|
||||
fileData: wav(1),
|
||||
isPrivate: true,
|
||||
});
|
||||
assertEquals((await get(`/api/dumps/${secret.id}/waveform`)).status, 404);
|
||||
assertEquals(
|
||||
(await get(`/api/dumps/${secret.id}/waveform?token=${owner.mediaToken}`))
|
||||
.status,
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
test("GET /api/dumps/:id/waveform: 404 for anything but readable audio", async () => {
|
||||
const owner = await createUser();
|
||||
const text = insertDump({ userId: owner.id, fileName: "t.txt" });
|
||||
assertEquals((await get(`/api/dumps/${text.id}/waveform`)).status, 404);
|
||||
const noise = insertDump({
|
||||
userId: owner.id,
|
||||
fileName: "n.mp3",
|
||||
fileMime: "audio/mpeg",
|
||||
fileData: new TextEncoder().encode("not audio at all"),
|
||||
});
|
||||
assertEquals((await get(`/api/dumps/${noise.id}/waveform`)).status, 404);
|
||||
assertEquals((await get(`/api/dumps/nope/waveform`)).status, 400);
|
||||
});
|
||||
Reference in New Issue
Block a user