wayBetter for X
This commit is contained in:
commit
2f1e35ba20
720 changed files with 147465 additions and 0 deletions
235
apps/web/store-media.ts
Normal file
235
apps/web/store-media.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
// 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/<app>/<kind>s/<id>/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/<app>/<kind>s/<id>/<file>
|
||||
// (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<string, unknown>;
|
||||
try { info = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>; } 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/<file>", "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/<file>.mp4" (or .webm), "poster": "media/<file>", "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];
|
||||
Loading…
Add table
Add a link
Reference in a new issue