/** * # 脚本 回填 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 是文本( 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();