feat: add asset file hash
This commit is contained in:
+2
-1
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }>>;
|
||||||
|
|||||||
@@ -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).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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, () => ({}));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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));
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user