feat(POSTV1-ASSET-ALL-16): 生成并部署全量复杂素材
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@
|
||||
"package:portable": "node scripts/build-portable.mjs",
|
||||
"assets:manifest": "pnpm build:workspace-packages && node scripts/generate-runtime-asset-manifest.mjs",
|
||||
"assets:deploy": "pnpm build:workspace-packages && node scripts/deploy-runtime-assets.mjs",
|
||||
"assets:browser-catalog": "pnpm build:workspace-packages && node scripts/generate-complex-browser-catalog.mjs",
|
||||
"generate:openapi": "node scripts/generate-openapi.mjs",
|
||||
"check:openapi": "node scripts/check-openapi.mjs",
|
||||
"validate:tdd-trace": "node scripts/validate-tdd-trace.mjs",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
P0A_DYNAMIC_STICKER_IDS,
|
||||
P0A_REQUIRED_FONT_PANEL_IDS,
|
||||
P0A_TEXT_TEMPLATE_IDS,
|
||||
} from "../packages/template-registry/dist/index.js";
|
||||
|
||||
function option(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
function oneDirectoryWithPrefix(root, prefix) {
|
||||
const matches = readdirSync(root, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith(`${prefix}_`));
|
||||
if (matches.length !== 1) throw new Error(`complex_catalog_directory_invalid:${prefix}`);
|
||||
return join(root, matches[0].name);
|
||||
}
|
||||
|
||||
function sha256(path) {
|
||||
return createHash("sha256").update(readFileSync(path)).digest("hex").toUpperCase();
|
||||
}
|
||||
|
||||
function textTemplateDirectory(root, templateId) {
|
||||
const family = templateId.startsWith("FLOWER") ? "花字"
|
||||
: templateId.startsWith("SIMPLE") ? "简约"
|
||||
: templateId.startsWith("TAG") ? "标签"
|
||||
: "标题";
|
||||
return join(root, family, "templates", templateId);
|
||||
}
|
||||
|
||||
function normalizedTextCategory(value) {
|
||||
if (value === "花字") return "flower";
|
||||
if (value === "简约") return "simple";
|
||||
if (value === "标签") return "tag";
|
||||
return "title";
|
||||
}
|
||||
|
||||
function normalizedDynamicCategory(value) {
|
||||
if (value === "user") return "identity";
|
||||
if (value === "location" || value === "time") return value;
|
||||
return "other";
|
||||
}
|
||||
|
||||
const replicationRoot = resolve(option("--replication-root") ?? join(homedir(), "Desktop", "sticker_web_replication_assets"));
|
||||
if (!isAbsolute(replicationRoot) || !existsSync(replicationRoot)) throw new Error("replication_asset_root_unavailable");
|
||||
const outputPath = resolve(option("--output") ?? "apps/web/src/generated/complex-assets.json");
|
||||
const fontPackagesRoot = join(replicationRoot, "sticker_text", "字体", "面板全量采集", "font_panel_full_20260722", "resources", "font_packages");
|
||||
const textRoot = join(replicationRoot, "sticker_text", "模板", "单模板归档");
|
||||
const dynamicRoot = join(replicationRoot, "sticker_interactive", "单模板归档", "templates");
|
||||
|
||||
const fontHashes = new Map();
|
||||
const fontPanelItems = P0A_REQUIRED_FONT_PANEL_IDS.map((fontId, displayOrder) => {
|
||||
const metadata = readJson(join(oneDirectoryWithPrefix(fontPackagesRoot, fontId), "metadata.json"));
|
||||
if (typeof metadata.local_sha256 !== "string") throw new Error(`complex_catalog_font_hash_missing:${fontId}`);
|
||||
fontHashes.set(metadata.local_sha256.toUpperCase(), fontId);
|
||||
return {
|
||||
display_name: String(metadata.display_name ?? fontId),
|
||||
display_order: displayOrder,
|
||||
font_id: fontId,
|
||||
};
|
||||
});
|
||||
|
||||
const textTemplates = P0A_TEXT_TEMPLATE_IDS.map((templateId, catalogOrder) => {
|
||||
const directory = textTemplateDirectory(textRoot, templateId);
|
||||
const metadata = readJson(join(directory, "metadata.json"));
|
||||
const fontReferences = Array.isArray(metadata.files?.fonts) ? metadata.files.fonts : [];
|
||||
const matchedFontIds = [...new Set(fontReferences
|
||||
.map((reference) => fontHashes.get(sha256(join(directory, ...reference.split("/")))))
|
||||
.filter(Boolean))];
|
||||
const previewReference = typeof metadata.files?.preview === "string" ? metadata.files.preview : undefined;
|
||||
const hasPreview = previewReference ? existsSync(join(directory, ...previewReference.split("/"))) : false;
|
||||
return {
|
||||
available: true,
|
||||
catalog_order: catalogOrder,
|
||||
category: normalizedTextCategory(metadata.category),
|
||||
default_font_id: matchedFontIds[0] ?? "FONT081",
|
||||
default_font_size: 48,
|
||||
default_text: String(metadata.default_text ?? metadata.display_name ?? templateId),
|
||||
display_name: String(metadata.display_name || metadata.default_text || templateId),
|
||||
font_match_status: matchedFontIds.length > 0 ? "exact_panel_hash" : "catalog_fallback",
|
||||
...(hasPreview ? { preview_asset_id: `TEXT-PREVIEW-${templateId}` } : {}),
|
||||
resource_class: metadata.resource_class === "parameter_only" ? "parameter_only" : "zip_template",
|
||||
template_id: templateId,
|
||||
};
|
||||
});
|
||||
|
||||
const dynamicStickers = P0A_DYNAMIC_STICKER_IDS.map((templateId, catalogOrder) => {
|
||||
const metadata = readJson(join(dynamicRoot, templateId, "metadata.json"));
|
||||
const requiredFields = Array.isArray(metadata.dynamic_keys) ? metadata.dynamic_keys.map(String) : [];
|
||||
const fontIds = Array.isArray(metadata.files?.fonts)
|
||||
? metadata.files.fonts.map((reference) => basename(reference))
|
||||
: [];
|
||||
return {
|
||||
catalog_order: catalogOrder,
|
||||
category: normalizedDynamicCategory(metadata.category),
|
||||
display_name: String(metadata.display_name ?? templateId),
|
||||
font_ids: fontIds.length > 0 ? fontIds : ["FONT081"],
|
||||
required_fields: requiredFields,
|
||||
requires_location_consent: requiredFields.includes("latitude") || requiredFields.includes("longitude"),
|
||||
source_candidate_id: String(metadata.source_candidate_id ?? metadata.display_name ?? templateId),
|
||||
template_id: templateId,
|
||||
};
|
||||
});
|
||||
|
||||
const catalog = {
|
||||
dynamic_stickers: dynamicStickers,
|
||||
font_panel_items: fontPanelItems,
|
||||
schema_version: "DadaComplexBrowserCatalog/v1",
|
||||
text_templates: textTemplates,
|
||||
};
|
||||
|
||||
const serialized = `${JSON.stringify(catalog, null, 2)}\n`;
|
||||
if (/[A-Z]:[\\/]/i.test(serialized)) throw new Error("complex_catalog_absolute_path_detected");
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, serialized);
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
dynamic_stickers: dynamicStickers.length,
|
||||
font_panel_items: fontPanelItems.length,
|
||||
output: outputPath,
|
||||
text_previews: textTemplates.filter((item) => item.preview_asset_id).length,
|
||||
text_templates: textTemplates.length,
|
||||
})}\n`);
|
||||
@@ -64,6 +64,7 @@ function derivedCounts(entries) {
|
||||
dynamic_images: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^DYN\d{3}-/.test(entry.assetId)).length,
|
||||
font_panel_items: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^FONT\d{3}$/.test(entry.assetId)).length,
|
||||
static_stickers: entries.filter((entry) => entry.resourceVersion === "p0a-static-v1" && /^STK\d{3,4}$/.test(entry.assetId)).length,
|
||||
text_previews: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^TEXT-PREVIEW-/.test(entry.assetId)).length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -196,6 +197,12 @@ function entryFor(sourcePath, assetId, resourceVersion, relativePath, mimeType)
|
||||
};
|
||||
}
|
||||
|
||||
function safeRuntimeComponent(value) {
|
||||
const normalized = value.normalize("NFKD").replaceAll(/[^A-Za-z0-9_-]+/g, "-").replaceAll(/^-+|-+$/g, "");
|
||||
if (normalized === value) return normalized;
|
||||
return `${normalized || "asset"}-${sha256(value).slice(0, 8).toLowerCase()}`;
|
||||
}
|
||||
|
||||
function dynamicMetadata(templateRoot, descriptor, field) {
|
||||
const templateDirectory = join(templateRoot, descriptor.templateId);
|
||||
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
|
||||
@@ -205,6 +212,14 @@ function dynamicMetadata(templateRoot, descriptor, field) {
|
||||
return templateDirectory;
|
||||
}
|
||||
|
||||
function textTemplateDirectory(root, templateId) {
|
||||
const family = templateId.startsWith("FLOWER") ? "花字"
|
||||
: templateId.startsWith("SIMPLE") ? "简约"
|
||||
: templateId.startsWith("TAG") ? "标签"
|
||||
: "标题";
|
||||
return join(root, family, "templates", templateId);
|
||||
}
|
||||
|
||||
export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
|
||||
const [{ compileStaticStickerCatalog }, registry] = await Promise.all([
|
||||
import("../../packages/asset-compiler/dist/index.js"),
|
||||
@@ -230,6 +245,19 @@ export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
|
||||
if (entry.sha256 !== item.sha256.toLowerCase()) throw new Error(`static_sticker_hash_invalid:${item.stable_id}`);
|
||||
return { entry, sourcePath };
|
||||
});
|
||||
const resourceKeys = new Map(resources.map((resource) => [`${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`, resource]));
|
||||
const addResource = (resource) => {
|
||||
const key = `${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`;
|
||||
const existing = resourceKeys.get(key);
|
||||
if (existing) {
|
||||
if (existing.entry.sha256 !== resource.entry.sha256 || existing.entry.mimeType !== resource.entry.mimeType) {
|
||||
throw new Error(`runtime_asset_duplicate_conflict:${resource.entry.assetId}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
resourceKeys.set(key, resource);
|
||||
resources.push(resource);
|
||||
};
|
||||
|
||||
const fontPackagesRoot = join(
|
||||
replicationRoot,
|
||||
@@ -244,7 +272,7 @@ export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
|
||||
const packageDirectory = oneDirectoryWithPrefix(fontPackagesRoot, assetId);
|
||||
const sourcePath = oneSupportedFont(join(packageDirectory, "font_files"));
|
||||
const extension = extname(sourcePath).toLowerCase();
|
||||
resources.push({
|
||||
addResource({
|
||||
entry: entryFor(
|
||||
sourcePath,
|
||||
assetId,
|
||||
@@ -257,33 +285,59 @@ export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
|
||||
}
|
||||
|
||||
const templateRoot = join(replicationRoot, "sticker_interactive", "单模板归档", "templates");
|
||||
for (const descriptor of registry.P0A_DYNAMIC_RUNTIME_FONT_SOURCES) {
|
||||
const templateDirectory = dynamicMetadata(templateRoot, descriptor, "fonts");
|
||||
const sourcePath = oneSupportedFont(join(templateDirectory, ...descriptor.sourceReference.split("/")));
|
||||
const extension = extname(sourcePath).toLowerCase();
|
||||
resources.push({
|
||||
entry: entryFor(
|
||||
for (const templateId of registry.P0A_DYNAMIC_STICKER_IDS) {
|
||||
const templateDirectory = join(templateRoot, templateId);
|
||||
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
|
||||
for (const sourceReference of metadata.files?.fonts ?? []) {
|
||||
const sourcePath = oneSupportedFont(join(templateDirectory, ...sourceReference.split("/")));
|
||||
const extension = extname(sourcePath).toLowerCase();
|
||||
const assetId = basename(sourceReference);
|
||||
addResource({
|
||||
entry: entryFor(
|
||||
sourcePath,
|
||||
assetId,
|
||||
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}${extension}`,
|
||||
fontMimeTypes.get(extension),
|
||||
),
|
||||
sourcePath,
|
||||
descriptor.assetId,
|
||||
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${descriptor.assetId}${extension}`,
|
||||
fontMimeTypes.get(extension),
|
||||
),
|
||||
sourcePath,
|
||||
});
|
||||
}
|
||||
for (const descriptor of registry.P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES) {
|
||||
const templateDirectory = dynamicMetadata(templateRoot, descriptor, "images");
|
||||
const sourcePath = join(templateDirectory, ...descriptor.sourceReference.split("/"));
|
||||
if (!existsSync(sourcePath) || extname(sourcePath).toLowerCase() !== ".png") {
|
||||
throw new Error(`runtime_dynamic_image_invalid:${descriptor.assetId}`);
|
||||
});
|
||||
}
|
||||
resources.push({
|
||||
for (const sourceReference of metadata.files?.images ?? []) {
|
||||
const sourcePath = join(templateDirectory, ...sourceReference.split("/"));
|
||||
const extension = extname(sourcePath).toLowerCase();
|
||||
const assetId = `${templateId}-${safeRuntimeComponent(basename(sourceReference, extension))}`;
|
||||
if (!existsSync(sourcePath) || extension !== ".png") throw new Error(`runtime_dynamic_image_invalid:${assetId}`);
|
||||
addResource({
|
||||
entry: entryFor(
|
||||
sourcePath,
|
||||
assetId,
|
||||
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}.png`,
|
||||
"image/png",
|
||||
),
|
||||
sourcePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const textRoot = join(replicationRoot, "sticker_text", "模板", "单模板归档");
|
||||
for (const templateId of registry.P0A_TEXT_TEMPLATE_IDS) {
|
||||
const templateDirectory = textTemplateDirectory(textRoot, templateId);
|
||||
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
|
||||
const previewReference = metadata.files?.preview;
|
||||
if (typeof previewReference !== "string" || previewReference.length === 0) continue;
|
||||
const sourcePath = join(templateDirectory, ...previewReference.split("/"));
|
||||
if (!existsSync(sourcePath) || extname(sourcePath).toLowerCase() !== ".png") {
|
||||
throw new Error(`runtime_text_preview_invalid:${templateId}`);
|
||||
}
|
||||
const assetId = `TEXT-PREVIEW-${templateId}`;
|
||||
addResource({
|
||||
entry: entryFor(
|
||||
sourcePath,
|
||||
descriptor.assetId,
|
||||
assetId,
|
||||
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${descriptor.assetId}.png`,
|
||||
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}.png`,
|
||||
"image/png",
|
||||
),
|
||||
sourcePath,
|
||||
@@ -292,12 +346,7 @@ export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
|
||||
|
||||
const manifestPath = join(replicationRoot, "sticker_web_handoff", "sticker_web_catalog_manifest.json");
|
||||
const manifest = createRuntimeAssetManifest({
|
||||
counts: {
|
||||
dynamic_fonts: registry.P0A_DYNAMIC_RUNTIME_FONT_SOURCES.length,
|
||||
dynamic_images: registry.P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES.length,
|
||||
font_panel_items: registry.P0A_REQUIRED_FONT_PANEL_IDS.length,
|
||||
static_stickers: staticResult.catalog.count,
|
||||
},
|
||||
counts: derivedCounts(resources.map((resource) => resource.entry)),
|
||||
entries: resources.map((resource) => resource.entry),
|
||||
sourceManifestSha256: fileSha256(manifestPath),
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ function fixture() {
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
};
|
||||
const manifest = {
|
||||
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
|
||||
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1, text_previews: 0 },
|
||||
entries: [entry],
|
||||
root_ref: "p0a_runtime_assets",
|
||||
schema_version: "DadaRuntimeAssets/v1",
|
||||
|
||||
@@ -15,12 +15,13 @@ import {
|
||||
test("committed P0-A runtime manifest covers the frozen first-version binary assets", () => {
|
||||
const manifest = readRuntimeAssetManifest("config/runtime-assets-manifest.json");
|
||||
assert.deepEqual(manifest.counts, {
|
||||
dynamic_fonts: 7,
|
||||
dynamic_images: 8,
|
||||
font_panel_items: 11,
|
||||
dynamic_fonts: 18,
|
||||
dynamic_images: 43,
|
||||
font_panel_items: 86,
|
||||
static_stickers: 1407,
|
||||
text_previews: 261,
|
||||
});
|
||||
assert.equal(manifest.entries.length, 1433);
|
||||
assert.equal(manifest.entries.length, 1815);
|
||||
assert.doesNotMatch(serializeRuntimeAssetManifest(manifest), /[A-Za-z]:[\\/]/);
|
||||
});
|
||||
|
||||
@@ -42,7 +43,7 @@ test("runtime asset deployment creates verified hardlinks and a path-free manife
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
};
|
||||
const manifest = createRuntimeAssetManifest({
|
||||
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
|
||||
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1, text_previews: 0 },
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
@@ -75,7 +76,7 @@ test("runtime asset deployment refuses a mismatched existing target", async (t)
|
||||
sha256: createHash("sha256").update("expected").digest("hex"),
|
||||
};
|
||||
const manifest = createRuntimeAssetManifest({
|
||||
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
|
||||
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1, text_previews: 0 },
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
|
||||
@@ -114,12 +114,13 @@ test("portable package serves the product and keeps SQLite data across API resta
|
||||
assert.ok(existsSync(join(packageRoot, "web", "index.html")));
|
||||
const runtimeAssetManifest = JSON.parse(await readFile(join(packageRoot, "asset-metadata", "manifest.json"), "utf8"));
|
||||
assert.deepEqual(runtimeAssetManifest.counts, {
|
||||
dynamic_fonts: 7,
|
||||
dynamic_images: 8,
|
||||
font_panel_items: 11,
|
||||
dynamic_fonts: 18,
|
||||
dynamic_images: 43,
|
||||
font_panel_items: 86,
|
||||
static_stickers: 1407,
|
||||
text_previews: 261,
|
||||
});
|
||||
assert.equal(runtimeAssetManifest.entries.length, 1433);
|
||||
assert.equal(runtimeAssetManifest.entries.length, 1815);
|
||||
const packagedWorker = await readFile(join(packageRoot, "server", "worker", "dist", "worker.js"), "utf8");
|
||||
const packagedOneApiAdapter = await readFile(join(packageRoot, "server", "worker", "dist", "oneapi-generation-adapter.js"), "utf8");
|
||||
assert.match(packagedWorker, /GenerationProcessor/);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import complexAssetCatalog from "../../apps/web/src/generated/complex-assets.json";
|
||||
import {
|
||||
P0A_COLOR_CARD_IDS,
|
||||
P0A_DYNAMIC_STICKER_IDS,
|
||||
@@ -25,4 +26,14 @@ describe("POSTV1-ASSET-ALL-16 complete asset catalog", () => {
|
||||
expect(P0A_DYNAMIC_STICKER_IDS[0]).toBe("DYN001");
|
||||
expect(P0A_DYNAMIC_STICKER_IDS.at(-1)).toBe("DYN035");
|
||||
});
|
||||
|
||||
it("generates a sanitized browser catalog for every text, font, and dynamic definition", () => {
|
||||
expect(complexAssetCatalog.schema_version).toBe("DadaComplexBrowserCatalog/v1");
|
||||
expect(complexAssetCatalog.text_templates).toHaveLength(332);
|
||||
expect(complexAssetCatalog.font_panel_items).toHaveLength(86);
|
||||
expect(complexAssetCatalog.dynamic_stickers).toHaveLength(35);
|
||||
expect(complexAssetCatalog.text_templates.filter((item) => item.preview_asset_id)).toHaveLength(261);
|
||||
expect(complexAssetCatalog.text_templates.every((item) => item.available)).toBe(true);
|
||||
expect(JSON.stringify(complexAssetCatalog)).not.toMatch(/[A-Z]:[\\/]/i);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user