feat: add asset file hash

This commit is contained in:
Menci
2025-11-19 11:48:55 +08:00
parent ba1b876cb5
commit 727b57c81e
9 changed files with 29 additions and 23 deletions
+2 -1
View File
@@ -18,6 +18,7 @@ export const Config = z.object({
z.record(z.preprocess(Number, z.enum(MaimaiMajorVersionId)), z.string()), z.record(z.preprocess(Number, z.enum(MaimaiMajorVersionId)), z.string()),
).optional(), ).optional(),
assetsDir: z.string().optional(), assetsDir: z.string().optional(),
hashSalt: z.string().optional(),
outputDir: z.string(), outputDir: z.string(),
}); });
export type Config = z.infer<typeof Config>; export type Config = z.infer<typeof Config>;
@@ -31,7 +32,7 @@ if (command === 'metadata') {
await runMetadata(config.inputs, config.outputDir); await runMetadata(config.inputs, config.outputDir);
} else if (command === 'thumb') { } else if (command === 'thumb') {
if (!config.assetsDir) throw new Error('config.assetsDir is required'); if (!config.assetsDir) throw new Error('config.assetsDir is required');
await runThumb(config.assetsDir, config.outputDir); await runThumb(config.assetsDir, config.hashSalt ?? '', config.outputDir);
} else { } else {
throw new Error(`Unknown command: ${command}`); throw new Error(`Unknown command: ${command}`);
} }
+1
View File
@@ -7,3 +7,4 @@ export type MaimaiMetadataKind = (typeof maimaiMetadataKinds)[number];
export const maimaiThumbKinds = ['music', 'frame', 'icon', 'plate'] as const; export const maimaiThumbKinds = ['music', 'frame', 'icon', 'plate'] as const;
export type MaimaiThumbKind = (typeof maimaiThumbKinds)[number]; export type MaimaiThumbKind = (typeof maimaiThumbKinds)[number];
export type ThumbCache = Record<MaimaiThumbKind, Record<number, { thumbhash: string; hash: string }>>;
+1 -1
View File
@@ -50,7 +50,7 @@ export interface MaimaiMusicMetadataRegionalInfo {
export type MaimaiMusicLevelChangeLog = Partial<Record<MaimaiMajorVersionId, number>>[]; export type MaimaiMusicLevelChangeLog = Partial<Record<MaimaiMajorVersionId, number>>[];
export interface MaimaiMusicMetadata extends MaimaiMusicMetadataBase { export interface MaimaiMusicMetadata extends MaimaiMusicMetadataBase {
jacketThumbHash: string; jacket?: { thumbhash: string; hash: string };
/** /**
* The level change log per chart (BASIC, ADVANCED, EXPERT, MASTER, Re:MASTER). * The level change log per chart (BASIC, ADVANCED, EXPERT, MASTER, Re:MASTER).
*/ */
+4 -4
View File
@@ -3,7 +3,7 @@ import path from 'node:path';
import Tinypool from 'tinypool'; import Tinypool from 'tinypool';
import type { MaimaiMajorVersionId, MaimaiMetadataKind, MaimaiRegion, MaimaiThumbKind } from '../interfaces'; import type { MaimaiMajorVersionId, MaimaiMetadataKind, MaimaiRegion, ThumbCache } from '../interfaces';
import { maimaiMetadataKinds, maimaiThumbKinds } from '../interfaces'; import { maimaiMetadataKinds, maimaiThumbKinds } from '../interfaces';
import { createLogger } from '../logger'; import { createLogger } from '../logger';
import { basicDataTypes } from './processors/basic'; import { basicDataTypes } from './processors/basic';
@@ -20,15 +20,15 @@ const pool = new Tinypool({
}); });
export type IntermediateDataMap<TIntermediateData> = Map<MaimaiRegion, Map<MaimaiMajorVersionId, TIntermediateData>>; export type IntermediateDataMap<TIntermediateData> = Map<MaimaiRegion, Map<MaimaiMajorVersionId, TIntermediateData>>;
export type MetadataMerger<TIntermediateData, TResult> = (intermediateDataMap: IntermediateDataMap<TIntermediateData>, thumbCache: Record<MaimaiThumbKind, Record<number, string>>) => TResult; export type MetadataMerger<TIntermediateData, TResult> = (intermediateDataMap: IntermediateDataMap<TIntermediateData>, thumbCache: ThumbCache) => TResult;
export const runMetadata = async (inputs: Record<MaimaiRegion, Record<MaimaiMajorVersionId, string>>, outputDir: string) => { export const runMetadata = async (inputs: Record<MaimaiRegion, Record<MaimaiMajorVersionId, string>>, outputDir: string) => {
await fs.promises.mkdir(outputDir, { recursive: true }); await fs.promises.mkdir(outputDir, { recursive: true });
const thumbCacheFilePath = path.resolve(outputDir, 'thumb.json'); const thumbCacheFilePath = path.resolve(outputDir, 'thumb.json');
let thumbCache: Record<MaimaiThumbKind, Record<number, string>>; let thumbCache: ThumbCache;
if (await fs.promises.stat(thumbCacheFilePath).catch(() => false)) { if (await fs.promises.stat(thumbCacheFilePath).catch(() => false)) {
thumbCache = JSON.parse(await fs.promises.readFile(thumbCacheFilePath, 'utf-8')) as Record<MaimaiThumbKind, Record<number, string>>; thumbCache = JSON.parse(await fs.promises.readFile(thumbCacheFilePath, 'utf-8')) as ThumbCache;
} else { } else {
logger.warn('Thumb cache not found, generating metadata with empty thumb hashes'); logger.warn('Thumb cache not found, generating metadata with empty thumb hashes');
thumbCache = arrayToObject(maimaiThumbKinds, () => ({})); thumbCache = arrayToObject(maimaiThumbKinds, () => ({}));
+3 -3
View File
@@ -34,9 +34,9 @@ const defineDataType = <TExtra = {}>(
const netOpenDate = parseNetOpenDate(xmlData.netOpenName.str); const netOpenDate = parseNetOpenDate(xmlData.netOpenName.str);
result[id] = { name, netOpenDate, ...parseExtraFields(xmlData) }; result[id] = { name, netOpenDate, ...parseExtraFields(xmlData) };
if (thumbKind) { if (thumbKind) {
const thumbHash = ctx.thumbCache[thumbKind][id]; const assetImage = ctx.thumbCache[thumbKind][id];
if (!thumbHash) logger.warn(`Thumb hash not found for ${thumbKind} ${id}`); if (!assetImage) logger.warn(`Asset image ${thumbKind} ${id} not found in thumb cache`);
(result[id] as { thumbHash?: string }).thumbHash = thumbHash ?? ''; (result[id] as { assetImage?: { thumbhash: string; hash: string } }).assetImage = assetImage;
} }
})); }));
return result; return result;
+3 -3
View File
@@ -129,15 +129,15 @@ export const mergeMusic: MetadataMerger<IntermediateData, Record<number, MaimaiM
const lowsetSeenVersionId: Record<number, MaimaiMajorVersionId> = {}; const lowsetSeenVersionId: Record<number, MaimaiMajorVersionId> = {};
forEachRegionAndVersion(dataMap, 'jpnFirst', 'newFirst', (region, version, musics) => Object.entries(musics).forEach(([idStr, music]) => { forEachRegionAndVersion(dataMap, 'jpnFirst', 'newFirst', (region, version, musics) => Object.entries(musics).forEach(([idStr, music]) => {
const id = Number(idStr); const id = Number(idStr);
const thumbHash = thumbCache.music[id % 10000]; const jacket = thumbCache.music[id % 10000];
if (!thumbHash) logger.warn(`Thumb hash not found for music ${id}`); if (!jacket) logger.warn(`Jacket not found for music ${id}`);
const resultMusic = result[id] ??= { const resultMusic = result[id] ??= {
name: music.name, name: music.name,
artist: music.artist, artist: music.artist,
genre: music.genre, genre: music.genre,
bpm: music.bpm, bpm: music.bpm,
charts: music.charts, charts: music.charts,
jacketThumbHash: thumbHash ?? '', jacket,
levelChangeLog: [], levelChangeLog: [],
regionalInfo: {}, regionalInfo: {},
}; };
+2 -2
View File
@@ -1,6 +1,6 @@
import fs from 'node:fs'; import fs from 'node:fs';
import type { MaimaiMetadataKind, MaimaiRegion, MaimaiMajorVersionId, MaimaiThumbKind } from '../interfaces'; import type { MaimaiMetadataKind, MaimaiRegion, MaimaiMajorVersionId, ThumbCache } from '../interfaces';
import { createLogger } from '../logger'; import { createLogger } from '../logger';
import { basicDataTypes } from './processors/basic'; import { basicDataTypes } from './processors/basic';
import { processMusic } from './processors/music'; import { processMusic } from './processors/music';
@@ -10,7 +10,7 @@ import { globAxxxDirs } from '../utils/fs';
const logger = createLogger('Worker'); const logger = createLogger('Worker');
export interface WorkerArguments { export interface WorkerArguments {
thumbCache: Record<MaimaiThumbKind, Record<number, string>>; thumbCache: ThumbCache;
region: MaimaiRegion; region: MaimaiRegion;
version: MaimaiMajorVersionId; version: MaimaiMajorVersionId;
streamingAssetsPath: string; streamingAssetsPath: string;
+5 -5
View File
@@ -4,7 +4,7 @@ import path from 'node:path';
import { glob } from 'glob'; import { glob } from 'glob';
import Tinypool from 'tinypool'; import Tinypool from 'tinypool';
import type { MaimaiThumbKind } from '../interfaces'; import type { ThumbCache } from '../interfaces';
import { maimaiThumbKinds } from '../interfaces'; import { maimaiThumbKinds } from '../interfaces';
import { createLogger } from '../logger'; import { createLogger } from '../logger';
import type { WorkerArguments } from './worker'; import type { WorkerArguments } from './worker';
@@ -21,10 +21,10 @@ const pool = new Tinypool({
const NUM_WORKERS = 32; const NUM_WORKERS = 32;
const ASSET_SUFFIX = '.png'; const ASSET_SUFFIX = '.png';
export const runThumb = async (assetsDir: string, outputDir: string) => { export const runThumb = async (assetsDir: string, hashSalt: string, outputDir: string) => {
await fs.promises.mkdir(path.resolve(outputDir, 'thumb'), { recursive: true }); await fs.promises.mkdir(path.resolve(outputDir, 'thumb'), { recursive: true });
const workerArgs: WorkerArguments[] = Array.from({ length: NUM_WORKERS }).map((_, i) => ({ tasks: [], outputFile: path.resolve(outputDir, 'thumb', `${i}.json`) })); const workerArgs: WorkerArguments[] = Array.from({ length: NUM_WORKERS }).map((_, i) => ({ hashSalt, tasks: [], outputFile: path.resolve(outputDir, 'thumb', `${i}.json`) }));
let i = 0; let i = 0;
for (const kind of maimaiThumbKinds) { for (const kind of maimaiThumbKinds) {
const files = await glob(`${assetsDir}/${kind}/*${ASSET_SUFFIX}`); const files = await glob(`${assetsDir}/${kind}/*${ASSET_SUFFIX}`);
@@ -42,9 +42,9 @@ export const runThumb = async (assetsDir: string, outputDir: string) => {
await Promise.all(workerArgs.map(args => pool.run(args))); await Promise.all(workerArgs.map(args => pool.run(args)));
const result: Record<MaimaiThumbKind, Record<number, string>> = arrayToObject(maimaiThumbKinds, () => ({})); const result: ThumbCache = arrayToObject(maimaiThumbKinds, () => ({}));
for (const args of workerArgs) { for (const args of workerArgs) {
const data = JSON.parse(await fs.promises.readFile(args.outputFile, 'utf-8')) as Record<MaimaiThumbKind, Record<number, string>>; const data = JSON.parse(await fs.promises.readFile(args.outputFile, 'utf-8')) as ThumbCache;
for (const [kind, entries] of objectEntries(data)) { for (const [kind, entries] of objectEntries(data)) {
Object.assign(result[kind], entries); Object.assign(result[kind], entries);
} }
+8 -4
View File
@@ -1,9 +1,10 @@
import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import { createCanvas, loadImage } from '@napi-rs/canvas'; import { createCanvas, loadImage } from '@napi-rs/canvas';
import { rgbaToThumbHash } from 'thumbhash-node'; import { rgbaToThumbHash } from 'thumbhash-node';
import type { MaimaiThumbKind } from '../interfaces'; import type { MaimaiThumbKind, ThumbCache } from '../interfaces';
import { maimaiThumbKinds } from '../interfaces'; import { maimaiThumbKinds } from '../interfaces';
import { createLogger } from '../logger'; import { createLogger } from '../logger';
import { arrayToObject } from '../utils/base'; import { arrayToObject } from '../utils/base';
@@ -11,6 +12,7 @@ import { arrayToObject } from '../utils/base';
const logger = createLogger('Worker'); const logger = createLogger('Worker');
export type WorkerArguments = { export type WorkerArguments = {
hashSalt: string;
tasks: { kind: MaimaiThumbKind; id: number; filePath: string }[]; tasks: { kind: MaimaiThumbKind; id: number; filePath: string }[];
outputFile: string; outputFile: string;
}; };
@@ -18,9 +20,11 @@ export type WorkerArguments = {
const MAX_SIZE = 100; const MAX_SIZE = 100;
export default async (args: WorkerArguments) => { export default async (args: WorkerArguments) => {
const result: Record<MaimaiThumbKind, Record<number, string>> = arrayToObject(maimaiThumbKinds, () => ({})); const result: ThumbCache = arrayToObject(maimaiThumbKinds, () => ({}));
await Promise.all(args.tasks.map(async ({ kind, id, filePath }) => { await Promise.all(args.tasks.map(async ({ kind, id, filePath }) => {
const image = await loadImage(filePath); const buffer = await fs.promises.readFile(filePath);
const hash = crypto.createHash('sha256').update(args.hashSalt).update(buffer).digest().subarray(8, 24).toString('base64url');
const image = await loadImage(buffer);
const width = image.width; const width = image.width;
const height = image.height; const height = image.height;
const scale = Math.min(MAX_SIZE / width, MAX_SIZE / height); const scale = Math.min(MAX_SIZE / width, MAX_SIZE / height);
@@ -33,7 +37,7 @@ export default async (args: WorkerArguments) => {
const rgba = new Uint8Array(imageData.data.buffer); const rgba = new Uint8Array(imageData.data.buffer);
const thumbhash = rgbaToThumbHash(resizedWidth, resizedHeight, rgba); const thumbhash = rgbaToThumbHash(resizedWidth, resizedHeight, rgba);
logger.log(`Generated thumbhash for ${filePath}`); logger.log(`Generated thumbhash for ${filePath}`);
result[kind][id] = Buffer.from(thumbhash).toString('base64url'); result[kind][id] = { thumbhash: Buffer.from(thumbhash).toString('base64url'), hash };
})); }));
await fs.promises.writeFile(args.outputFile, JSON.stringify(result, null, 2)); await fs.promises.writeFile(args.outputFile, JSON.stringify(result, null, 2));
}; };