// An item's own pictures and video: a cover, screenshots and one short video, kept next to the item // in the Store's source (store//s//media/…, named in its item.json). They are for // the website only: the signed catalog does not mention them, so adding or replacing media never // needs a new signature. The site build copies them to /store-media//s// // (store-pages.ts), `bun run check:store` validates them (scripts/check-store.mjs). // // "cover": { "src": "media/cover.webp", "alt": "…" } // "screenshots": [{ "src": "media/1.webp", "alt": "…", "caption": "…" }] // "video": { "src": "media/demo.mp4", "poster": "media/demo.webp", "caption": "…" } import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; export const MEDIA_LIMITS = { imageBytes: 400 * 1024, imageWidth: 1600, imageMinWidth: 640, videoBytes: 6 * 1024 * 1024, videoSeconds: 20, videoWidth: 1600, screenshots: 6, alt: 200, caption: 160, }; /** The cover fills a card's 16:10 frame. */ const COVER_RATIO = 1.6; export type Picture = { src: string; file: string; alt: string; caption?: string; width: number; height: number; bytes: number; type: string }; export type Video = { src: string; file: string; caption?: string; width: number; height: number; bytes: number; type: string; seconds: number; poster: Picture }; export type ItemMedia = { cover?: Picture; screenshots: Picture[]; video?: Video }; export type MediaRead = { media: ItemMedia; problems: string[]; files: { from: string; to: string }[] }; type Size = { width: number; height: number }; type ImageInfo = Size & { type: string }; type VideoInfo = Size & { type: string; seconds: number; audio: boolean; codec: string }; // ---- Reading sizes from the files themselves (no dependencies) ------------------------------------ export function imageInfo(bytes: Buffer): ImageInfo | null { if (bytes.length > 24 && bytes.readUInt32BE(0) === 0x89504e47 && bytes.toString('latin1', 12, 16) === 'IHDR') { return { type: 'image/png', width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }; } if (bytes.length > 30 && bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP') { const chunk = bytes.toString('latin1', 12, 16); if (chunk === 'VP8X') return { type: 'image/webp', width: 1 + bytes.readUIntLE(24, 3), height: 1 + bytes.readUIntLE(27, 3) }; if (chunk === 'VP8 ') return { type: 'image/webp', width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff }; if (chunk === 'VP8L') { const bits = bytes.readUInt32LE(21); return { type: 'image/webp', width: 1 + (bits & 0x3fff), height: 1 + ((bits >>> 14) & 0x3fff) }; } return null; } if (bytes.length > 4 && bytes[0] === 0xff && bytes[1] === 0xd8) { let at = 2; while (at + 9 < bytes.length) { if (bytes[at] !== 0xff) { at += 1; continue; } const marker = bytes[at + 1]!; // Start-of-frame markers carry the size (C4, C8 and CC are tables, not frames). if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) return { type: 'image/jpeg', height: bytes.readUInt16BE(at + 5), width: bytes.readUInt16BE(at + 7) }; if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01 || marker === 0xff) { at += marker === 0xff ? 1 : 2; continue; } at += 2 + bytes.readUInt16BE(at + 2); } } return null; } function mp4Info(bytes: Buffer): VideoInfo | null { const info: VideoInfo = { type: 'video/mp4', width: 0, height: 0, seconds: 0, audio: false, codec: '' }; const CONTAINERS = new Set(['moov', 'trak', 'mdia', 'minf', 'stbl']); let found = false; const walk = (start: number, end: number, track: { handler: string; width: number; height: number } | null) => { let at = start; while (at + 8 <= end) { let size = bytes.readUInt32BE(at); const type = bytes.toString('latin1', at + 4, at + 8); let header = 8; if (size === 1) { size = Number(bytes.readBigUInt64BE(at + 8)); header = 16; } else if (size === 0) size = end - at; if (size < header || at + size > end) return; const body = at + header; if (type === 'mvhd') { const v1 = bytes[body] === 1; const timescale = bytes.readUInt32BE(body + (v1 ? 20 : 12)); const duration = v1 ? Number(bytes.readBigUInt64BE(body + 24)) : bytes.readUInt32BE(body + 16); if (timescale) info.seconds = duration / timescale; found = true; } else if (type === 'trak') { const mine = { handler: '', width: 0, height: 0 }; walk(body, at + size, mine); if (mine.handler === 'soun') info.audio = true; if (mine.handler === 'vide' && !info.width) { info.width = mine.width; info.height = mine.height; } } else if (type === 'tkhd' && track) { track.width = bytes.readUInt32BE(at + size - 8) >>> 16; track.height = bytes.readUInt32BE(at + size - 4) >>> 16; } else if (type === 'hdlr' && track) { track.handler = bytes.toString('latin1', body + 8, body + 12); } else if (type === 'stsd' && track?.handler === 'vide') { info.codec = bytes.toString('latin1', body + 12, body + 16); } else if (CONTAINERS.has(type)) walk(body, at + size, track); at += size; } }; walk(0, bytes.length, null); return found ? info : null; } function webmInfo(bytes: Buffer): VideoInfo | null { const info: VideoInfo = { type: 'video/webm', width: 0, height: 0, seconds: 0, audio: false, codec: '' }; const MASTERS = new Set([0x18538067, 0x1549a966, 0x1654ae6b, 0xae, 0xe0]); let scale = 1_000_000; let duration = 0; const vint = (at: number, keepMarker: boolean): { value: number; length: number } | null => { const first = bytes[at]; if (first === undefined || first === 0) return null; const length = Math.clz32(first) - 23; if (at + length > bytes.length) return null; let value = keepMarker ? first : first & (0xff >> length); for (let i = 1; i < length; i += 1) value = value * 256 + bytes[at + i]!; return { value, length }; }; const walk = (start: number, end: number) => { let at = start; while (at < end) { const id = vint(at, true); if (!id) return; const size = vint(at + id.length, false); if (!size) return; const body = at + id.length + size.length; // A live-muxed file writes "unknown size" for the segment: read on to the end of the file. const unknown = size.value >= 2 ** (7 * size.length) - 1; const stop = unknown ? end : Math.min(end, body + size.value); const number = () => { let value = 0; for (let i = body; i < stop; i += 1) value = value * 256 + bytes[i]!; return value; }; if (MASTERS.has(id.value)) walk(body, stop); else if (id.value === 0x2ad7b1) scale = number(); else if (id.value === 0x4489) duration = stop - body === 4 ? bytes.readFloatBE(body) : bytes.readDoubleBE(body); else if (id.value === 0xb0) info.width = number(); else if (id.value === 0xba) info.height = number(); else if (id.value === 0x83 && number() === 2) info.audio = true; else if (id.value === 0x86 && !info.codec) info.codec = bytes.toString('latin1', body, stop); // Clusters hold the frames: everything this needs comes before them. if (id.value === 0x1f43b675) return; at = stop; } }; if (bytes.length < 4 || bytes.readUInt32BE(0) !== 0x1a45dfa3) return null; walk(0, bytes.length); info.seconds = duration * scale / 1e9; return info; } export function videoInfo(bytes: Buffer): VideoInfo | null { if (bytes.length > 12 && bytes.toString('latin1', 4, 8) === 'ftyp') return mp4Info(bytes); return webmInfo(bytes); } // ---- An item's media ------------------------------------------------------------------------------ const FILE = /^media\/[a-z0-9][a-z0-9-]*\.(png|webp|jpe?g|mp4|webm)$/; const text = (value: unknown, max: number): value is string => typeof value === 'string' && value.trim().length > 0 && [...value].length <= max && !/[\u0000-\u001f<>]/.test(value); /** * Reads and checks the media an item's item.json names. `dir` is the item's source folder, * `base` its public folder ("/store-media/x/plugins/thread-reader/"). */ export function readItemMedia(dir: string, base: string, where: string): MediaRead { const out: MediaRead = { media: { screenshots: [] }, problems: [], files: [] }; const path = join(dir, 'item.json'); if (!existsSync(path)) return out; let info: Record; try { info = JSON.parse(readFileSync(path, 'utf8')) as Record; } catch { return out; } if (!info || typeof info !== 'object') return out; const bad = (message: string) => { out.problems.push(`${where}: ${message}`); }; const picture = (value: unknown, label: string, needsAlt: boolean): Picture | undefined => { if (!value || typeof value !== 'object') { bad(`${label} must be { "src": "media/", "alt": "…" }`); return undefined; } const { src, alt, caption } = value as { src?: unknown; alt?: unknown; caption?: unknown }; if (typeof src !== 'string' || !FILE.test(src) || /\.(mp4|webm)$/.test(src)) { bad(`${label}: "src" must be a .png, .webp or .jpg in media/ (lower-case letters, digits and hyphens)`); return undefined; } if (needsAlt && !text(alt, MEDIA_LIMITS.alt)) bad(`${label}: "alt" is needed: say what the picture shows, in at most ${MEDIA_LIMITS.alt} characters`); if (caption !== undefined && !text(caption, MEDIA_LIMITS.caption)) bad(`${label}: "caption" is plain text of at most ${MEDIA_LIMITS.caption} characters`); const from = join(dir, src); if (!existsSync(from)) { bad(`${label}: ${src} is not in the folder`); return undefined; } const bytes = readFileSync(from); const size = imageInfo(bytes); if (!size) { bad(`${label}: ${src} is not a PNG, WebP or JPEG picture`); return undefined; } if (!(size.type === 'image/jpeg' ? /\.jpe?g$/ : new RegExp(`\\.${size.type.slice(6)}$`)).test(src)) bad(`${label}: ${src} is a ${size.type.slice(6).toUpperCase()} file with another extension`); if (bytes.length > MEDIA_LIMITS.imageBytes) bad(`${label}: ${src} is ${Math.round(bytes.length / 1024)} KB (at most ${MEDIA_LIMITS.imageBytes / 1024} KB)`); if (size.width > MEDIA_LIMITS.imageWidth) bad(`${label}: ${src} is ${size.width} px wide (at most ${MEDIA_LIMITS.imageWidth})`); if (size.width < MEDIA_LIMITS.imageMinWidth) bad(`${label}: ${src} is ${size.width} px wide (at least ${MEDIA_LIMITS.imageMinWidth})`); const file = src.slice('media/'.length); out.files.push({ from, to: `${base.slice(1)}${file}` }); return { src: `${base}${file}`, file, alt: typeof alt === 'string' ? alt.trim() : '', ...(typeof caption === 'string' ? { caption: caption.trim() } : {}), width: size.width, height: size.height, bytes: bytes.length, type: size.type }; }; if (info.cover !== undefined) { const cover = picture(info.cover, 'cover', true); if (cover) { if (Math.abs(cover.width / cover.height - COVER_RATIO) > 0.02) bad(`cover: ${cover.file} is ${cover.width}×${cover.height}; a cover is 16:10 (for example 1600×1000) so cards do not crop it`); out.media.cover = cover; } } if (info.screenshots !== undefined) { if (!Array.isArray(info.screenshots)) bad('screenshots must be a list'); else { if (info.screenshots.length > MEDIA_LIMITS.screenshots) bad(`screenshots: at most ${MEDIA_LIMITS.screenshots}`); info.screenshots.slice(0, MEDIA_LIMITS.screenshots).forEach((entry, index) => { const shot = picture(entry, `screenshots[${index}]`, true); if (shot) out.media.screenshots.push(shot); }); } } if (info.video !== undefined) { const value = info.video as { src?: unknown; poster?: unknown; caption?: unknown } | null; if (!value || typeof value !== 'object' || typeof value.src !== 'string' || !FILE.test(value.src) || !/\.(mp4|webm)$/.test(value.src)) bad('video must be { "src": "media/.mp4" (or .webm), "poster": "media/", "caption": "…" }'); else { if (value.caption !== undefined && !text(value.caption, MEDIA_LIMITS.caption)) bad(`video: "caption" is plain text of at most ${MEDIA_LIMITS.caption} characters`); const poster = typeof value.poster === 'string' ? picture({ src: value.poster }, 'video poster', false) : (bad('video: "poster" is needed: the picture shown before it plays'), undefined); const from = join(dir, value.src); if (!existsSync(from)) bad(`video: ${value.src} is not in the folder`); else { const bytes = readFileSync(from); const video = videoInfo(bytes); if (!video || !video.width || !video.height) bad(`video: ${value.src} is not an MP4 or WebM video this check can read`); else { if (!value.src.endsWith(video.type.slice(6))) bad(`video: ${value.src} is a ${video.type.slice(6).toUpperCase()} file with another extension`); if (bytes.length > MEDIA_LIMITS.videoBytes) bad(`video: ${value.src} is ${(bytes.length / 1024 / 1024).toFixed(1)} MB (at most ${MEDIA_LIMITS.videoBytes / 1024 / 1024} MB)`); if (!video.seconds) bad(`video: ${value.src} does not say how long it is (re-mux it: ffmpeg -i in -c copy out)`); else if (video.seconds > MEDIA_LIMITS.videoSeconds + 0.25) bad(`video: ${value.src} is ${video.seconds.toFixed(1)} s long (at most ${MEDIA_LIMITS.videoSeconds} s)`); if (video.width > MEDIA_LIMITS.videoWidth) bad(`video: ${value.src} is ${video.width} px wide (at most ${MEDIA_LIMITS.videoWidth})`); // It plays muted, in a loop, inline: a sound track is dead weight and blocks autoplay rules. if (video.audio) bad(`video: ${value.src} has a sound track; remove it (ffmpeg -i in -an -c:v copy out)`); if (video.type === 'video/mp4' && video.codec && !/^avc[13]$/.test(video.codec)) bad(`video: ${value.src} is encoded as ${video.codec}; use H.264 so every browser plays it`); if (poster) { const file = value.src.slice('media/'.length); out.files.push({ from, to: `${base.slice(1)}${file}` }); out.media.video = { src: `${base}${file}`, file, ...(typeof value.caption === 'string' ? { caption: value.caption.trim() } : {}), width: video.width, height: video.height, bytes: bytes.length, type: video.type, seconds: video.seconds, poster }; } } } } } return out; } export const hasMedia = (media: ItemMedia | undefined): media is ItemMedia => Boolean(media && (media.cover || media.screenshots.length || media.video)); /** The pictures of an item in gallery order: cover, then screenshots. */ export const pictures = (media: ItemMedia): Picture[] => [...(media.cover ? [media.cover] : []), ...media.screenshots];