Files
dusuniot/scripts/backfill-media-dimensions.ts
T

89 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* # 脚本 回填 media 表缺失的宽高(width/height 为 null 的行)
*
* 背景:早期导入管线部分媒体行未填 width/height(8210 行),posts 卡片
* fit-height 判定(图片高宽比 < cards_item_ratio 时按高适配)依赖该字段。
* 本脚本扫描 public/uploads 下物理文件,解析 PNG/JPEG/GIF/WebP 文件头取尺寸
* 回填;文件不存在的行跳过(由 download-missing-media.ts 补拉)。
*
* 用法:bun scripts/backfill-media-dimensions.ts [--dry] [limit=N]
*/
import { SQL } from "bun";
import { readdir } from "node:fs/promises";
const sql = new SQL(process.env.DATABASE_URL || "postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot", { max: 1 });
const dry = process.argv.includes("--dry");
const limitArg = Number(process.argv.find((a) => a.startsWith("limit="))?.slice(6) || 0);
const UPLOADS = process.cwd() + "/public/uploads";
/** # 函数 解析图片文件头取宽高(PNG/JPEG/GIF/WebP 四格式)
* 参数 buf 文件头部字节(≥ 64 字节足够)
* 返回值 [宽, 高];无法解析返回 null
*/
function imageSize(buf: Uint8Array): [number, number] | null {
const u8 = buf, dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
// PNG: 8 字节签名 + IHDR(宽高在 16/20 偏移)
if (u8[0] === 0x89 && u8[1] === 0x50 && u8[2] === 0x4e && u8[3] === 0x47)
return [dv.getUint32(16), dv.getUint32(20)];
// GIF: "GIF8" 宽高在 6/8(小端)
if (u8[0] === 0x47 && u8[1] === 0x49 && u8[2] === 0x46)
return [dv.getUint16(6, true), dv.getUint16(8, true)];
// WebP: "RIFF....WEBP",VP8/VP8L/VP8X 三子格式
if (u8[0] === 0x52 && u8[1] === 0x49 && u8[2] === 0x46 && u8[3] === 0x46 && u8[8] === 0x57 && u8[9] === 0x45) {
const fourcc = String.fromCharCode(u8[12], u8[13], u8[14], u8[15]);
if (fourcc === "VP8 ") return [dv.getUint16(26, true) & 0x3fff, dv.getUint16(28, true) & 0x3fff];
if (fourcc === "VP8L") {
const b = dv.getUint32(21, true);
return [(b & 0x3fff) + 1, ((b >> 14) & 0x3fff) + 1];
}
if (fourcc === "VP8X") {
// canvas 宽/高各 3 字节小端(24-26 宽、27-29 高),getUint32 会读到相邻字段越界
const w = (u8[24] | (u8[25] << 8) | (u8[26] << 16)) + 1;
const h = (u8[27] | (u8[28] << 8) | (u8[29] << 16)) + 1;
return [w, h];
}
return null;
}
// JPEG: 扫 SOFn 段(FFC0-FFCF 除 FFD8/FF01/FFD0-FFD7)
if (u8[0] === 0xff && u8[1] === 0xd8) {
let i = 2;
while (i + 9 < u8.length) {
if (u8[i] !== 0xff) { i++; continue; }
const marker = u8[i + 1];
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue; }
const len = dv.getUint16(i + 2);
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc)
return [dv.getUint16(i + 7), dv.getUint16(i + 9)];
i += 2 + len;
}
return null;
}
// SVG 是文本(<svg width/height 或 viewBox),跳过( rarity 低)
return null;
}
/** # 函数 从 URL/路径还原 uploads 物理路径(/uploads/2023/01/x.jpg → 2023/01/x.jpg) */
function physicalPath(url: string | null): string | null {
if (!url) return null;
const i = url.indexOf("/uploads/");
return i === -1 ? null : url.slice(i + "/uploads/".length);
}
const rows = await sql`select id, url, path from media where width is null order by id limit ${limitArg || 100000}`;
console.log("待回填:", rows.length);
let ok = 0, miss = 0, fail = 0;
for (const r of rows) {
const rel = physicalPath(r.path) || physicalPath(r.url);
if (!rel) { miss++; continue; }
const f = Bun.file(`${UPLOADS}/${rel}`);
if (!(await f.exists())) { miss++; continue; }
const head = new Uint8Array(await f.slice(0, 1024 * 64).arrayBuffer());
const size = imageSize(head);
// 防御:解析出异常尺寸(如文件头误读)不写库
if (!size || !size[0] || !size[1] || size[0] > 100000 || size[1] > 100000) { fail++; continue; }
if (!dry) await sql`update media set width = ${size[0]}, height = ${size[1]} where id = ${r.id}`;
ok++;
}
console.log(`回填 ${ok}${dry ? "(dry)" : ""},文件缺失 ${miss},解析失败 ${fail}`);
await sql.end();