diff --git a/api/derive.ts b/api/derive.ts index 928aa9b..2244b1c 100644 --- a/api/derive.ts +++ b/api/derive.ts @@ -55,12 +55,25 @@ export const LADDER = [320, 640, 960, 1280, 1920, 2560] as const; /** WebP quality. 82 is the usual knee: visually clean on photographs, well off the top of the curve. */ const WEBP_QUALITY = 82; -/** Video renditions, as av-club preset names paired with the source height each one needs to be worth making. */ +/** + * Video renditions, tallest first. `height` is what the preset scales TO. + * + * A rung is made only when the source is at least that tall — the same no-upscaling rule the image + * ladder follows, and it was got wrong here first: `web-1080` was being asked for from a 720p source, + * which ffmpeg obligingly did. The result was a 14.6 MB file that looks worse than the 7.8 MB one + * beside it, and 84 seconds of a single core to make it. Under a running sync that overran av-club's + * idle timeout and the video failed outright, which is how the bug surfaced at all. + * + * `web-480` is the floor, not a rung: it exists so a source smaller than 720 still gets something, + * rather than nothing or an upscale. + */ const VIDEO_RUNGS = [ - { file: '1080.mp4', preset: 'web-1080', minSourceHeight: 720 }, - { file: '720.mp4', preset: 'web-720', minSourceHeight: 0 }, + { file: '1080.mp4', preset: 'web-1080', height: 1080 }, + { file: '720.mp4', preset: 'web-720', height: 720 }, ] as const; +const VIDEO_FLOOR = { file: '480.mp4', preset: 'web-480', height: 480 } as const; + const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'tif', 'tiff', 'heic', 'heif']); const VIDEO_EXT = new Set(['mp4', 'mov', 'm4v', 'webm', 'mkv', 'avi', 'wmv', 'flv', 'mpg', 'mpeg', '3gp', 'm2ts', 'mts']); @@ -330,10 +343,17 @@ async function buildVideo( const variants: Variant[] = []; - for (const rung of VIDEO_RUNGS) { - // A 720p source re-encoded as "1080" is bigger and no better. The lowest rung has no floor, so - // every video gets at least one rendition however small it started. - if (height && height < rung.minSourceHeight) continue; + /* + Every rung the source is actually tall enough for, and the floor when it is tall enough for none. + + An unreadable height (0) is treated as "make everything": a probe that told us nothing is not + evidence the video is small, and the encoder will decline to enlarge on its own long before we + should decide on its behalf. + */ + const wanted = height ? VIDEO_RUNGS.filter((rung) => height >= rung.height) : [...VIDEO_RUNGS]; + const rungs = wanted.length ? wanted : [VIDEO_FLOOR]; + + for (const rung of rungs) { const result = await post<{ path: string; bytes: number }>('/v1/ffmpeg', email, { inputPath: sourcePath, outputPath: join(staging, rung.file),