hyapp-h5/scripts/check-activity-week-star-fami-layer-assets.mjs
2026-07-17 11:54:43 +08:00

706 lines
22 KiB
JavaScript

import { createHash } from 'node:crypto';
import { readFile, readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDirectory, '..');
const assetRoot = path.join(
repoRoot,
'activity',
'week-star',
'fami',
'assets'
);
const layerDirectory = path.join(assetRoot, 'layers');
const fixtureDirectory = path.join(assetRoot, 'fixtures');
const manifestPath = path.join(layerDirectory, 'manifest.json');
const requiredRuntimeFiles = new Set([
'body-watermark.webp',
'gift-frame.webp',
'gift-glow.webp',
'medal-1.webp',
'medal-2.webp',
'medal-3.webp',
'reward-heading.webp',
'reward-panel.webp',
'tab-active.webp',
'tab-inactive.webp',
]);
const requiredFixtureFiles = new Set([
'../fixtures/mock-avatar-top.webp',
'../fixtures/mock-avatar-user.webp',
'../fixtures/mock-gift.webp',
]);
const requiredAggregateNodes = new Set([
'721:2016',
'837:4472',
'837:4545',
'837:4615',
'837:4475',
'837:4491',
'837:4507',
'837:4548',
'837:4564',
'837:4579',
'837:4618',
'837:4634',
'837:4649',
'837:4721',
]);
// WebP bitstreams expose their codec, decoded dimensions, and alpha presence,
// but do not serialize the cwebp -q/-alpha_q command values. Keep the approved
// export recipe independent from the manifest so an edited manifest cannot
// silently redefine either the Figma source pixels or production compression.
const requiredAssetMetadata = new Map(
Object.entries({
'body-watermark.webp': {
original: [841, 1870],
exported: [841, 1870],
exportFormat: 'webp-lossy',
quality: 88,
alphaQuality: null,
hasAlpha: false,
},
'gift-frame.webp': {
original: [1169, 1175],
exported: [514, 516],
exportFormat: 'webp-lossy',
quality: 86,
alphaQuality: 100,
hasAlpha: true,
},
'gift-glow.webp': {
original: [1183, 1183],
exported: [438, 438],
exportFormat: 'webp-lossy',
quality: 84,
alphaQuality: 100,
hasAlpha: true,
},
'medal-1.webp': {
original: [625, 553],
exported: [207, 183],
exportFormat: 'webp-lossy',
quality: 90,
alphaQuality: 100,
hasAlpha: true,
},
'medal-2.webp': {
original: [642, 582],
exported: [207, 188],
exportFormat: 'webp-lossy',
quality: 90,
alphaQuality: 100,
hasAlpha: true,
},
'medal-3.webp': {
original: [619, 595],
exported: [207, 199],
exportFormat: 'webp-lossy',
quality: 90,
alphaQuality: 100,
hasAlpha: true,
},
'reward-heading.webp': {
original: [632, 98],
exported: [632, 98],
exportFormat: 'webp-lossy',
quality: 90,
alphaQuality: 100,
hasAlpha: true,
},
'reward-panel.webp': {
original: [931, 1522],
exported: [931, 1522],
exportFormat: 'webp-lossy',
quality: 85,
alphaQuality: 100,
hasAlpha: true,
},
'tab-active.webp': {
original: [1567, 697],
exported: [672, 298],
exportFormat: 'webp-lossy',
quality: 86,
alphaQuality: 100,
hasAlpha: true,
},
'tab-inactive.webp': {
original: [1024, 455],
exported: [672, 298],
exportFormat: 'webp-lossy',
quality: 86,
alphaQuality: 100,
hasAlpha: true,
},
'../fixtures/mock-avatar-top.webp': {
original: [1200, 1200],
exported: [1200, 1200],
exportFormat: 'webp-lossless',
quality: null,
alphaQuality: null,
hasAlpha: false,
},
'../fixtures/mock-avatar-user.webp': {
original: [768, 1024],
exported: [768, 1024],
exportFormat: 'webp-lossless',
quality: null,
alphaQuality: null,
hasAlpha: false,
},
'../fixtures/mock-gift.webp': {
original: [144, 144],
exported: [144, 144],
exportFormat: 'webp-lossless',
quality: null,
alphaQuality: null,
hasAlpha: true,
},
})
);
function invariant(condition, message) {
if (!condition) {
throw new Error(`[week-star-fami-layer-audit] ${message}`);
}
}
function sameSet(actual, expected) {
return (
actual.size === expected.size &&
[...actual].every((item) => expected.has(item))
);
}
function webpMetadata(bytes, file) {
invariant(
bytes.length >= 20 &&
bytes.toString('ascii', 0, 4) === 'RIFF' &&
bytes.toString('ascii', 8, 12) === 'WEBP',
`${file}: file is not WebP`
);
invariant(
bytes.readUInt32LE(4) + 8 === bytes.length,
`${file}: RIFF length mismatch`
);
let width;
let height;
let encoding;
let hasAlpha = false;
const setDimensions = (nextWidth, nextHeight, chunk) => {
invariant(
nextWidth > 0 && nextHeight > 0,
`${file}: invalid ${chunk} dimensions`
);
if (width === undefined) {
width = nextWidth;
height = nextHeight;
return;
}
invariant(
width === nextWidth && height === nextHeight,
`${file}: container and ${chunk} dimensions disagree`
);
};
let offset = 12;
while (offset + 8 <= bytes.length) {
const chunk = bytes.toString('ascii', offset, offset + 4);
const chunkSize = bytes.readUInt32LE(offset + 4);
const payloadStart = offset + 8;
const payloadEnd = payloadStart + chunkSize;
invariant(
payloadEnd <= bytes.length,
`${file}: truncated ${chunk} chunk`
);
if (chunk === 'VP8X') {
invariant(chunkSize >= 10, `${file}: invalid VP8X chunk`);
hasAlpha ||= Boolean(bytes[payloadStart] & 0x10);
setDimensions(
1 + bytes.readUIntLE(payloadStart + 4, 3),
1 + bytes.readUIntLE(payloadStart + 7, 3),
chunk
);
} else if (chunk === 'ALPH') {
hasAlpha = true;
} else if (chunk === 'VP8L') {
invariant(chunkSize >= 5, `${file}: invalid VP8L chunk`);
invariant(
bytes[payloadStart] === 0x2f,
`${file}: invalid VP8L signature`
);
const packed = bytes.readUInt32LE(payloadStart + 1);
invariant(packed >>> 29 === 0, `${file}: unsupported VP8L version`);
setDimensions(
1 + (packed & 0x3fff),
1 + ((packed >>> 14) & 0x3fff),
chunk
);
hasAlpha ||= Boolean((packed >>> 28) & 1);
invariant(
!encoding || encoding === 'lossless',
`${file}: conflicting WebP codecs`
);
encoding = 'lossless';
} else if (chunk === 'VP8 ') {
invariant(chunkSize >= 10, `${file}: invalid VP8 chunk`);
invariant(
bytes[payloadStart + 3] === 0x9d &&
bytes[payloadStart + 4] === 0x01 &&
bytes[payloadStart + 5] === 0x2a,
`${file}: invalid VP8 frame header`
);
setDimensions(
bytes.readUInt16LE(payloadStart + 6) & 0x3fff,
bytes.readUInt16LE(payloadStart + 8) & 0x3fff,
chunk
);
invariant(
!encoding || encoding === 'lossy',
`${file}: conflicting WebP codecs`
);
encoding = 'lossy';
}
offset = payloadEnd + (chunkSize % 2);
}
invariant(offset === bytes.length, `${file}: invalid RIFF chunk padding`);
invariant(width && height && encoding, `${file}: image payload is missing`);
return { width, height, encoding, hasAlpha };
}
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
invariant(manifest.schemaVersion === 2, 'unsupported manifest schema');
invariant(
manifest.figma?.fileKey === 'OTOg5cwbhVNWxkImGmzTJl',
'unexpected Figma file key'
);
invariant(manifest.figma?.canvas?.id === '721:2016', 'unexpected Figma canvas');
invariant(
JSON.stringify(manifest.figma?.entryFrames) ===
JSON.stringify({
history: {
id: '837:4472',
name: '历史前三',
width: 1080,
height: 2651,
},
current: {
id: '837:4545',
name: '历史前三',
width: 1080,
height: 2460,
},
rewards: {
id: '837:4615',
name: '历史前三',
width: 1080,
height: 3532,
},
}),
'entry-frame mapping changed'
);
const heroReference = manifest.figma?.references?.hero;
invariant(heroReference?.groupNode === '837:4721', 'hero group trace missing');
invariant(heroReference?.maskNode === '837:4722', 'hero mask trace missing');
invariant(heroReference?.imageNode === '837:4723', 'hero image trace missing');
invariant(
heroReference?.runtimeExported === false,
'hero must remain a non-runtime reference'
);
const forbiddenAggregateNodes = new Set(
manifest.policy?.forbiddenAggregateNodes || []
);
for (const node of requiredAggregateNodes) {
invariant(
forbiddenAggregateNodes.has(node),
`required aggregate-node ban is missing: ${node}`
);
}
const allowedSourceTypes = new Set(manifest.policy?.allowedSourceTypes || []);
invariant(
allowedSourceTypes.size === 1 && allowedSourceTypes.has('RECTANGLE'),
'only isolated rectangle image-fill nodes may be exported'
);
const assets = manifest.assets;
invariant(Array.isArray(assets), 'manifest assets must be an array');
invariant(
assets.length === 13,
'manifest must trace 10 runtime layers and 3 fixtures'
);
invariant(
assets.length === requiredAssetMetadata.size,
'asset metadata recipe and manifest inventory disagree'
);
const assetsByFile = new Map();
const sourceOwners = new Map();
for (const asset of assets) {
invariant(
typeof asset.file === 'string' && !assetsByFile.has(asset.file),
`duplicate or missing asset file: ${asset.file}`
);
assetsByFile.set(asset.file, asset);
const expectedMetadata = requiredAssetMetadata.get(asset.file);
invariant(expectedMetadata, `${asset.file}: export recipe is missing`);
invariant(
/^\d+:\d+$/.test(asset.sourceNode || ''),
`${asset.file}: source node is required`
);
invariant(
!forbiddenAggregateNodes.has(asset.sourceNode),
`${asset.file}: aggregate Figma node export is forbidden`
);
invariant(
allowedSourceTypes.has(asset.sourceType),
`${asset.file}: source type must be an isolated visual leaf`
);
invariant(
asset.sourceName && asset.purpose,
`${asset.file}: source name and purpose are required`
);
invariant(
!sourceOwners.has(asset.sourceNode),
`${asset.file}: source node is already owned by ${sourceOwners.get(asset.sourceNode)}`
);
sourceOwners.set(asset.sourceNode, asset.file);
invariant(
Array.isArray(asset.equivalentNodes),
`${asset.file}: equivalentNodes must be an array`
);
for (const node of asset.equivalentNodes) {
invariant(
/^\d+:\d+$/.test(node),
`${asset.file}: invalid equivalent node ${node}`
);
invariant(
!forbiddenAggregateNodes.has(node),
`${asset.file}: aggregate equivalent node is forbidden: ${node}`
);
}
invariant(
String(asset.kind || '').startsWith('single-'),
`${asset.file}: asset is not classified as one visual layer`
);
invariant(
asset.bounds?.width > 0 && asset.bounds?.height > 0,
`${asset.file}: Figma bounds are required`
);
invariant(
asset.original?.width > 0 && asset.original?.height > 0,
`${asset.file}: original pixel dimensions are required`
);
invariant(
asset.original.width === expectedMetadata.original[0] &&
asset.original.height === expectedMetadata.original[1],
`${asset.file}: Figma source pixel dimensions changed`
);
invariant(
asset.exported?.width === expectedMetadata.exported[0] &&
asset.exported?.height === expectedMetadata.exported[1],
`${asset.file}: exported pixel dimensions changed`
);
invariant(
asset.exportFormat === expectedMetadata.exportFormat,
`${asset.file}: export format changed`
);
invariant(
asset.quality === expectedMetadata.quality,
`${asset.file}: lossy quality changed`
);
invariant(
asset.alphaQuality === expectedMetadata.alphaQuality,
`${asset.file}: alpha quality changed`
);
invariant(
asset.hasAlpha === expectedMetadata.hasAlpha,
`${asset.file}: declared alpha presence changed`
);
if (asset.exportFormat === 'webp-lossy') {
invariant(
Number.isInteger(asset.quality) &&
asset.quality >= 1 &&
asset.quality <= 100,
`${asset.file}: lossy quality must be between 1 and 100`
);
invariant(
asset.hasAlpha
? Number.isInteger(asset.alphaQuality) &&
asset.alphaQuality >= 1 &&
asset.alphaQuality <= 100
: asset.alphaQuality === null,
`${asset.file}: alpha quality does not match alpha usage`
);
} else {
invariant(
asset.exportFormat === 'webp-lossless' &&
asset.quality === null &&
asset.alphaQuality === null,
`${asset.file}: lossless exports must not declare lossy quality`
);
}
invariant(
Number.isInteger(asset.bytes) && asset.bytes > 0,
`${asset.file}: byte size is required`
);
invariant(
/^[a-f0-9]{64}$/.test(asset.sha256 || ''),
`${asset.file}: SHA-256 is required`
);
invariant(
Array.isArray(asset.dependencies),
`${asset.file}: dependencies must be an array`
);
for (const flag of [
'containsText',
'containsControls',
'containsDynamicMedia',
'mockOnly',
'dynamic',
]) {
invariant(
typeof asset[flag] === 'boolean',
`${asset.file}: ${flag} must be boolean`
);
}
invariant(!asset.containsText, `${asset.file}: rendered text is forbidden`);
invariant(
!asset.containsControls,
`${asset.file}: rendered controls are forbidden`
);
const resolvedPath = path.resolve(layerDirectory, asset.file);
invariant(
resolvedPath.startsWith(`${assetRoot}${path.sep}`),
`${asset.file}: asset escapes the week-star asset root`
);
if (asset.scope === 'runtime') {
invariant(
path.dirname(resolvedPath) === layerDirectory,
`${asset.file}: runtime assets must stay in assets/layers`
);
invariant(
path.posix.basename(asset.file) === asset.file,
`${asset.file}: runtime file must not use a nested path`
);
invariant(
!asset.containsDynamicMedia && !asset.mockOnly && !asset.dynamic,
`${asset.file}: runtime layers cannot contain mock or dynamic media`
);
for (const pattern of manifest.policy.forbiddenRuntimeNamePatterns ||
[]) {
invariant(
!asset.file
.toLowerCase()
.includes(String(pattern).toLowerCase()),
`${asset.file}: forbidden aggregate-style runtime name ${pattern}`
);
}
} else if (asset.scope === 'fixture') {
invariant(
path.dirname(resolvedPath) === fixtureDirectory,
`${asset.file}: fixtures must stay in assets/fixtures`
);
invariant(
asset.file.startsWith('../fixtures/'),
`${asset.file}: fixture path must be explicit`
);
invariant(
asset.containsDynamicMedia && asset.mockOnly && asset.dynamic,
`${asset.file}: sample media must be marked mockOnly and dynamic`
);
} else {
invariant(false, `${asset.file}: unsupported scope ${asset.scope}`);
}
const [bytes, fileInfo] = await Promise.all([
readFile(resolvedPath),
stat(resolvedPath),
]);
const digest = createHash('sha256').update(bytes).digest('hex');
const metadata = webpMetadata(bytes, asset.file);
invariant(
fileInfo.size === asset.bytes,
`${asset.file}: byte size mismatch`
);
invariant(digest === asset.sha256, `${asset.file}: SHA-256 mismatch`);
invariant(
metadata.width === asset.exported.width &&
metadata.height === asset.exported.height,
`${asset.file}: decoded pixels do not match exported dimensions`
);
invariant(
asset.exportFormat === `webp-${metadata.encoding}`,
`${asset.file}: decoded WebP codec does not match export format`
);
invariant(
metadata.hasAlpha === asset.hasAlpha,
`${asset.file}: decoded alpha presence does not match manifest`
);
}
const actualRuntimeFiles = new Set(
assets
.filter((asset) => asset.scope === 'runtime')
.map((asset) => asset.file)
);
const actualFixtureFiles = new Set(
assets
.filter((asset) => asset.scope === 'fixture')
.map((asset) => asset.file)
);
invariant(
sameSet(actualRuntimeFiles, requiredRuntimeFiles),
'runtime layer inventory changed'
);
invariant(
sameSet(actualFixtureFiles, requiredFixtureFiles),
'fixture inventory changed'
);
const diskRuntimeFiles = new Set(
(await readdir(layerDirectory)).filter((file) => file.endsWith('.webp'))
);
const diskFixtureFiles = new Set(
(await readdir(fixtureDirectory))
.filter((file) => file.endsWith('.webp'))
.map((file) => `../fixtures/${file}`)
);
invariant(
sameSet(diskRuntimeFiles, requiredRuntimeFiles),
'every runtime layer must have exactly one manifest entry'
);
invariant(
sameSet(diskFixtureFiles, requiredFixtureFiles),
'every visual fixture must have exactly one manifest entry'
);
for (const asset of assets) {
for (const dependency of asset.dependencies) {
const target = assetsByFile.get(dependency);
invariant(target, `${asset.file}: unknown dependency ${dependency}`);
invariant(
dependency !== asset.file,
`${asset.file}: self dependency is forbidden`
);
if (asset.scope === 'runtime') {
invariant(
target.scope === 'runtime',
`${asset.file}: runtime assets cannot depend on fixtures`
);
}
}
}
const visiting = new Set();
const visited = new Set();
function visit(file) {
if (visited.has(file)) return;
invariant(!visiting.has(file), `asset dependency cycle includes ${file}`);
visiting.add(file);
for (const dependency of assetsByFile.get(file).dependencies)
visit(dependency);
visiting.delete(file);
visited.add(file);
}
for (const file of assetsByFile.keys()) visit(file);
const compositions = manifest.compositions;
invariant(
compositions && typeof compositions === 'object',
'layer compositions are required'
);
const compositionSlots = new Set();
const composedRuntimeFiles = new Set();
for (const [name, composition] of Object.entries(compositions)) {
invariant(
Array.isArray(composition.layers) && composition.layers.length > 0,
`${name}: composition layers are required`
);
invariant(
Array.isArray(composition.dynamicSlots),
`${name}: dynamicSlots must be an array`
);
for (const file of composition.layers) {
const asset = assetsByFile.get(file);
invariant(asset, `${name}: unknown composition layer ${file}`);
invariant(
asset.scope === 'runtime',
`${name}: production composition cannot include fixture ${file}`
);
composedRuntimeFiles.add(file);
}
for (const slot of composition.dynamicSlots) {
invariant(
typeof slot === 'string' && slot.length > 0,
`${name}: invalid dynamic slot`
);
compositionSlots.add(slot);
}
}
invariant(
sameSet(composedRuntimeFiles, requiredRuntimeFiles),
'every runtime layer must belong to a declared composition'
);
const fixtureBindings = manifest.fixtureBindings;
invariant(
Array.isArray(fixtureBindings) && fixtureBindings.length === 3,
'three explicit fixture bindings are required'
);
const boundFixtures = new Set();
for (const binding of fixtureBindings) {
const asset = assetsByFile.get(binding.file);
invariant(
asset?.scope === 'fixture',
`invalid fixture binding ${binding.file}`
);
invariant(
binding.mockOnly === true,
`${binding.file}: binding must be mockOnly`
);
invariant(
compositionSlots.has(binding.slot),
`${binding.file}: fixture slot is not declared by a composition`
);
invariant(
!boundFixtures.has(binding.file),
`${binding.file}: duplicate fixture binding`
);
boundFixtures.add(binding.file);
}
invariant(
sameSet(boundFixtures, requiredFixtureFiles),
'every fixture must bind exactly one dynamic slot'
);
const shippedSourceNodes = new Set(assets.map((asset) => asset.sourceNode));
for (const node of [
heroReference.groupNode,
heroReference.maskNode,
heroReference.imageNode,
]) {
invariant(
!shippedSourceNodes.has(node),
`hero reference node was shipped: ${node}`
);
}
console.log(
`[week-star-fami-layer-audit] passed: ${requiredRuntimeFiles.size} isolated runtime layers, ${requiredFixtureFiles.size} mock-only dynamic fixtures`
);