feat: complete TASK-WP5-02 sticker catalog
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
"compile": "node dist/cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dada/static-sticker-catalog": "workspace:*",
|
||||
"csv-parse": "7.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { compileAssetArchive } from "./index.js";
|
||||
import { compileAssetArchive, compileStaticStickerCatalog } from "./index.js";
|
||||
|
||||
function argument(name: string): string {
|
||||
const index = process.argv.indexOf(name);
|
||||
@@ -8,11 +8,19 @@ function argument(name: string): string {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = compileAssetArchive({
|
||||
manifestPath: argument("--manifest"),
|
||||
outputDirectory: argument("--output"),
|
||||
releaseVersion: argument("--release-version"),
|
||||
});
|
||||
const expectedCount = process.argv.includes("--expected-count") ? Number(argument("--expected-count")) : undefined;
|
||||
const result = process.argv.includes("--catalog")
|
||||
? compileStaticStickerCatalog({
|
||||
...(expectedCount === undefined ? {} : { expectedCount }),
|
||||
outputDirectory: argument("--output"),
|
||||
releaseVersion: argument("--release-version"),
|
||||
sourceRoot: argument("--source-root"),
|
||||
})
|
||||
: compileAssetArchive({
|
||||
manifestPath: argument("--manifest"),
|
||||
outputDirectory: argument("--output"),
|
||||
releaseVersion: argument("--release-version"),
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify(result.report, null, 2)}\n`);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : "asset compilation failed"}\n`);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
||||
import { isAbsolute, dirname, join, relative, resolve, sep } from "node:path";
|
||||
|
||||
import { parse } from "csv-parse/sync";
|
||||
|
||||
import type { StaticStickerCatalog, StaticStickerCatalogItem } from "@dada/static-sticker-catalog";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
type CsvRow = Record<string, string>;
|
||||
|
||||
@@ -30,6 +32,24 @@ export interface AssetCompilerResult {
|
||||
report: AssetCompilerReport;
|
||||
}
|
||||
|
||||
export interface StaticStickerCatalogCompilerOptions {
|
||||
sourceRoot: string;
|
||||
outputDirectory: string;
|
||||
releaseVersion: string;
|
||||
expectedCount?: number;
|
||||
}
|
||||
|
||||
export interface StaticStickerCatalogCompilerReport {
|
||||
schema_version: "static-sticker-catalog-report/v1";
|
||||
release_version: string;
|
||||
source_files_read: number;
|
||||
source_mutations: number;
|
||||
copied_source_files: 0;
|
||||
status: "passed";
|
||||
count: number;
|
||||
duplicate_sha256_groups: number;
|
||||
}
|
||||
|
||||
interface SourceRoot {
|
||||
id: string;
|
||||
path: string;
|
||||
@@ -277,6 +297,164 @@ function writeJson(path: string, value: unknown) {
|
||||
writeFileSync(path, stableJson(value));
|
||||
}
|
||||
|
||||
function staticStickerId(index: number) {
|
||||
return `STK${String(index).padStart(index < 1_000 ? 3 : 4, "0")}`;
|
||||
}
|
||||
|
||||
function pngDimensions(bytes: Buffer, label: string): { width: number; height: number } {
|
||||
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
if (bytes.length < 45 || !bytes.subarray(0, 8).equals(signature)) throw new Error(`${label} is not a PNG`);
|
||||
let offset = 8;
|
||||
let sawHeader = false;
|
||||
let sawEnd = false;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
while (offset + 12 <= bytes.length) {
|
||||
const length = bytes.readUInt32BE(offset);
|
||||
const type = bytes.subarray(offset + 4, offset + 8).toString("ascii");
|
||||
const chunkEnd = offset + 12 + length;
|
||||
if (chunkEnd > bytes.length) throw new Error(`${label} has a truncated PNG chunk`);
|
||||
if (!sawHeader && type !== "IHDR") throw new Error(`${label} has no PNG header`);
|
||||
if (type === "IHDR") {
|
||||
if (sawHeader || length !== 13) throw new Error(`${label} has an invalid PNG header`);
|
||||
width = bytes.readUInt32BE(offset + 8);
|
||||
height = bytes.readUInt32BE(offset + 12);
|
||||
if (width < 1 || height < 1) throw new Error(`${label} has invalid PNG dimensions`);
|
||||
sawHeader = true;
|
||||
}
|
||||
if (type === "IEND") {
|
||||
if (length !== 0) throw new Error(`${label} has an invalid PNG end chunk`);
|
||||
sawEnd = true;
|
||||
break;
|
||||
}
|
||||
offset = chunkEnd;
|
||||
}
|
||||
if (!sawHeader || !sawEnd) throw new Error(`${label} is not a complete PNG`);
|
||||
return { height, width };
|
||||
}
|
||||
|
||||
function staticStickerSourceSnapshot(sourceRoot: string, path: string, bytes: Buffer) {
|
||||
const stats = statSync(path);
|
||||
return {
|
||||
bytes: stats.size,
|
||||
mtime_ms: stats.mtimeMs,
|
||||
path: `sticker_source/${relative(sourceRoot, path).replaceAll("\\", "/")}`,
|
||||
sha256: sha256(bytes),
|
||||
};
|
||||
}
|
||||
|
||||
export function compileStaticStickerCatalog(options: StaticStickerCatalogCompilerOptions): { catalog: StaticStickerCatalog; report: StaticStickerCatalogCompilerReport } {
|
||||
if (!releaseVersionPattern.test(options.releaseVersion)) throw new Error("release version is unsafe");
|
||||
const sourceRoot = realDirectory(options.sourceRoot, "sticker source");
|
||||
const outputDirectory = resolve(options.outputDirectory);
|
||||
if (inside(outputDirectory, sourceRoot)) throw new Error("output directory is inside a source root");
|
||||
const items: StaticStickerCatalogItem[] = [];
|
||||
const sourceBefore: Array<{ bytes: number; mtime_ms: number; path: string; sha256: string }> = [];
|
||||
const partCounts: Record<string, number> = {};
|
||||
for (let part = 1; part <= 25; part += 1) {
|
||||
const partDirectory = resolve(sourceRoot, `sticker_part${part}`);
|
||||
if (!existsSync(partDirectory) || !statSync(partDirectory).isDirectory()) throw new Error(`sticker_part${part} directory is unavailable`);
|
||||
const actualPartDirectory = realpathSync(partDirectory);
|
||||
if (!inside(actualPartDirectory, sourceRoot)) throw new Error(`sticker_part${part} resolves outside the source root`);
|
||||
const files = readdirSync(actualPartDirectory, { withFileTypes: true })
|
||||
.filter((entry) => entry.name.toLowerCase().endsWith(".png"))
|
||||
.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
||||
partCounts[String(part)] = files.length;
|
||||
for (const [partOrder, entry] of files.entries()) {
|
||||
if (!entry.isFile()) throw new Error(`sticker_part${part}/${entry.name} is not a regular PNG file`);
|
||||
const path = resolve(actualPartDirectory, entry.name);
|
||||
const actualPath = realpathSync(path);
|
||||
if (!inside(actualPath, sourceRoot)) throw new Error(`sticker_part${part}/${entry.name} resolves outside the source root`);
|
||||
const bytes = readFileSync(actualPath);
|
||||
const dimensions = pngDimensions(bytes, `sticker_part${part}/${entry.name}`);
|
||||
const stableId = staticStickerId(items.length + 1);
|
||||
sourceBefore.push(staticStickerSourceSnapshot(sourceRoot, actualPath, bytes));
|
||||
items.push({
|
||||
enabled: true,
|
||||
height: dimensions.height,
|
||||
mime: "image/png",
|
||||
mime_type: "image/png",
|
||||
order: partOrder + 1,
|
||||
original_filename: entry.name,
|
||||
original_reference: `/api/v1/assets/public/${encodeURIComponent(options.releaseVersion)}/${encodeURIComponent(stableId)}`,
|
||||
origin: "bundled_read_only",
|
||||
part,
|
||||
relative_path: `sticker_part${part}/${entry.name}`,
|
||||
resource_version: options.releaseVersion,
|
||||
sha256: sha256(bytes),
|
||||
stable_id: stableId,
|
||||
thumbnail_reference: {
|
||||
media: "thumbnail",
|
||||
resource_id: stableId,
|
||||
resource_version: options.releaseVersion,
|
||||
url: `/api/v1/assets/public/${encodeURIComponent(options.releaseVersion)}/${encodeURIComponent(stableId)}?variant=thumbnail`,
|
||||
},
|
||||
width: dimensions.width,
|
||||
});
|
||||
}
|
||||
}
|
||||
const duplicateSha256Groups = [...new Map(items.reduce((groups, item) => {
|
||||
const ids = groups.get(item.sha256) ?? [];
|
||||
ids.push(item.stable_id);
|
||||
groups.set(item.sha256, ids);
|
||||
return groups;
|
||||
}, new Map<string, string[]>())).entries()]
|
||||
.filter(([, stableIds]) => stableIds.length > 1)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([hash, stableIds]) => ({ sha256: hash, stable_ids: stableIds }));
|
||||
const payload = {
|
||||
count: items.length,
|
||||
duplicate_sha256_groups: duplicateSha256Groups,
|
||||
items,
|
||||
part_counts: partCounts,
|
||||
release_version: options.releaseVersion,
|
||||
schema_version: "StaticStickerCatalog/v1" as const,
|
||||
};
|
||||
const catalog: StaticStickerCatalog = { ...payload, manifest_sha256: sha256(stableJson(payload)) };
|
||||
const sourceAfter = sourceBefore.map((entry) => {
|
||||
const actualPath = resolve(sourceRoot, entry.path.slice("sticker_source/".length));
|
||||
const bytes = readFileSync(actualPath);
|
||||
return staticStickerSourceSnapshot(sourceRoot, actualPath, bytes);
|
||||
});
|
||||
const sourceMutations = sourceBefore.reduce((count, entry, index) => {
|
||||
const after = sourceAfter[index];
|
||||
return count + (after?.sha256 !== entry.sha256 || after?.mtime_ms !== entry.mtime_ms || after?.bytes !== entry.bytes ? 1 : 0);
|
||||
}, 0);
|
||||
if (options.expectedCount !== undefined && options.expectedCount !== items.length) throw new Error(`static sticker count mismatch: expected ${options.expectedCount}, received ${items.length}`);
|
||||
if (sourceMutations > 0) throw new Error("sticker source changed during catalog compilation");
|
||||
const duplicateGroups = duplicateSha256Groups.length;
|
||||
const report: StaticStickerCatalogCompilerReport = {
|
||||
copied_source_files: 0,
|
||||
count: items.length,
|
||||
duplicate_sha256_groups: duplicateGroups,
|
||||
release_version: options.releaseVersion,
|
||||
schema_version: "static-sticker-catalog-report/v1",
|
||||
source_files_read: sourceBefore.length,
|
||||
source_mutations: sourceMutations,
|
||||
status: "passed",
|
||||
};
|
||||
mkdirSync(outputDirectory, { recursive: true });
|
||||
writeJson(join(outputDirectory, "static-sticker-catalog.json"), catalog);
|
||||
writeJson(join(outputDirectory, "catalog-validation.json"), {
|
||||
count: items.length,
|
||||
duplicate_sha256_groups: duplicateGroups,
|
||||
expected_count: options.expectedCount ?? null,
|
||||
order_valid: items.every((item, index) => item.stable_id === staticStickerId(index + 1)),
|
||||
part_counts: partCounts,
|
||||
schema_version: "static-sticker-catalog-validation/v1",
|
||||
});
|
||||
writeJson(join(outputDirectory, "source-before.json"), { files: sourceBefore, schema_version: "source-snapshot/v1" });
|
||||
writeJson(join(outputDirectory, "source-after.json"), { files: sourceAfter, schema_version: "source-snapshot/v1" });
|
||||
writeJson(join(outputDirectory, "compiler-report.json"), report);
|
||||
writeJson(join(outputDirectory, "output-manifest.json"), {
|
||||
catalog_reference: "static-sticker-catalog.json",
|
||||
catalog_sha256: sha256(stableJson(catalog)),
|
||||
release_version: options.releaseVersion,
|
||||
schema_version: "asset-release-compiler/v1",
|
||||
});
|
||||
return { catalog, report };
|
||||
}
|
||||
|
||||
export function compileAssetArchive(options: AssetCompilerOptions): AssetCompilerResult {
|
||||
if (!releaseVersionPattern.test(options.releaseVersion)) throw new Error("release version is unsafe");
|
||||
const manifestPath = realpathSync(options.manifestPath);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@dada/static-sticker-catalog",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./dist/index.js"
|
||||
},
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
export const STATIC_STICKER_CATALOG_SCHEMA_VERSION = "StaticStickerCatalog/v1" as const;
|
||||
export const STATIC_STICKER_CATALOG_ORIGIN = "bundled_read_only" as const;
|
||||
|
||||
export interface StaticStickerThumbnailReference {
|
||||
media: "thumbnail";
|
||||
resource_id: string;
|
||||
resource_version: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface StaticStickerCatalogItem {
|
||||
stable_id: string;
|
||||
part: number;
|
||||
order: number;
|
||||
original_filename: string;
|
||||
relative_path: string;
|
||||
width: number;
|
||||
height: number;
|
||||
mime_type: "image/png";
|
||||
mime: "image/png";
|
||||
sha256: string;
|
||||
original_reference: string;
|
||||
thumbnail_reference: StaticStickerThumbnailReference;
|
||||
resource_version: string;
|
||||
enabled: boolean;
|
||||
origin: "bundled_read_only" | "admin_uploaded";
|
||||
}
|
||||
|
||||
export interface StaticStickerCatalog {
|
||||
schema_version: typeof STATIC_STICKER_CATALOG_SCHEMA_VERSION;
|
||||
release_version: string;
|
||||
manifest_sha256: string;
|
||||
count: number;
|
||||
part_counts: Record<string, number>;
|
||||
duplicate_sha256_groups: Array<{ sha256: string; stable_ids: string[] }>;
|
||||
items: StaticStickerCatalogItem[];
|
||||
}
|
||||
|
||||
export interface VirtualStickerWindowOptions {
|
||||
itemHeight: number;
|
||||
viewportHeight: number;
|
||||
columns: number;
|
||||
overscanScreens: number;
|
||||
scrollTop: number;
|
||||
}
|
||||
|
||||
export interface VirtualStickerWindow<T extends { stable_id: string }> {
|
||||
start: number;
|
||||
end: number;
|
||||
top_spacer_px: number;
|
||||
bottom_spacer_px: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
export function stableStickerId(index: number): string {
|
||||
if (!Number.isInteger(index) || index < 1) throw new Error("sticker_index_invalid");
|
||||
return `STK${String(index).padStart(index < 1_000 ? 3 : 4, "0")}`;
|
||||
}
|
||||
|
||||
export function getVirtualStickerWindow<T extends { stable_id: string }>(items: readonly T[], options: VirtualStickerWindowOptions): VirtualStickerWindow<T> {
|
||||
const itemHeight = Math.max(1, options.itemHeight);
|
||||
const viewportHeight = Math.max(1, options.viewportHeight);
|
||||
const columns = Math.max(1, Math.floor(options.columns));
|
||||
const overscanScreens = Math.max(0, options.overscanScreens);
|
||||
const scrollTop = Math.max(0, options.scrollTop);
|
||||
const totalRows = Math.ceil(items.length / columns);
|
||||
const visibleRows = Math.max(1, Math.ceil(viewportHeight / itemHeight));
|
||||
const overscanRows = Math.ceil((visibleRows * overscanScreens) / 2);
|
||||
const firstVisibleRow = Math.min(totalRows, Math.floor(scrollTop / itemHeight));
|
||||
const startRow = Math.max(0, firstVisibleRow - overscanRows);
|
||||
const endRow = Math.min(totalRows, firstVisibleRow + visibleRows + overscanRows);
|
||||
const start = startRow * columns;
|
||||
const end = Math.min(items.length, endRow * columns);
|
||||
return {
|
||||
bottom_spacer_px: Math.max(0, (totalRows - endRow) * itemHeight),
|
||||
end,
|
||||
items: items.slice(start, end) as T[],
|
||||
start,
|
||||
top_spacer_px: startRow * itemHeight,
|
||||
};
|
||||
}
|
||||
|
||||
export function staticStickerThumbnailUrl(item: Pick<StaticStickerCatalogItem, "resource_version" | "stable_id">): string {
|
||||
return `/api/v1/assets/public/${encodeURIComponent(item.resource_version)}/${encodeURIComponent(item.stable_id)}?variant=thumbnail`;
|
||||
}
|
||||
|
||||
export function staticStickerOriginalUrl(item: Pick<StaticStickerCatalogItem, "resource_version" | "stable_id">): string {
|
||||
return `/api/v1/assets/public/${encodeURIComponent(item.resource_version)}/${encodeURIComponent(item.stable_id)}`;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2024"],
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user