89 lines
4.1 KiB
TypeScript
89 lines
4.1 KiB
TypeScript
/**
|
||
* # 脚本 回填 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();
|