接口开始5%

This commit is contained in:
rain
2026-07-22 17:31:33 +08:00
parent eced3d1e6e
commit 9b0ad62df4
426 changed files with 20111 additions and 28472 deletions
@@ -1,41 +0,0 @@
{
"schemaVersion": 2,
"page": "A01",
"runtime": {
"url": "http://localhost:5173/#/pages/auth/a01-entry",
"chromePort": 9222
},
"assets": [
{
"id": "a01-primary-button-v2",
"assetClass": "fixed-bitmap",
"source": "static/assets/foundation/opaque/a01-primary-button.png",
"output": "static/assets/foundation/transparent/a01-primary-button-v2.png",
"logicalSlot": { "widthRpx": 622, "heightRpx": 92 },
"outputPixels": { "width": 1866, "height": 276 },
"render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false },
"alpha": { "required": true, "transparentOuterPadding": 12, "cornerMaxAlpha": 0 },
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
"quality": { "maxBytes": 1000000, "symmetry": "horizontal", "colorSpace": "sRGB" },
"processing": { "trim": 8, "capWidth": 180 },
"runtime": { "selector": ".login-submit", "imageSelector": ".button-skin img" },
"consumers": ["pages/auth/a01-entry.vue"]
},
{
"id": "a01-secondary-button-v2",
"assetClass": "fixed-bitmap",
"source": "static/assets/foundation/opaque/a01-secondary-button.png",
"output": "static/assets/foundation/transparent/a01-secondary-button-v2.png",
"logicalSlot": { "widthRpx": 622, "heightRpx": 100 },
"outputPixels": { "width": 1866, "height": 300 },
"render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false },
"alpha": { "required": true, "transparentOuterPadding": 12, "cornerMaxAlpha": 0 },
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
"quality": { "maxBytes": 1200000, "symmetry": "horizontal", "colorSpace": "sRGB" },
"processing": { "trim": 12, "capWidth": 200 },
"runtime": { "selector": ".wechat-login", "imageSelector": ".button-skin img" },
"consumers": ["pages/auth/a01-entry.vue"]
}
]
}
@@ -1,70 +0,0 @@
{
"schemaVersion": 2,
"page": "A01",
"runtime": {
"url": "http://localhost:5173/#/pages/auth/a01-entry",
"chromePort": 9222
},
"assets": [
{
"id": "a01-scroll-primary-v3",
"assetClass": "fixed-bitmap",
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-primary-master-v3.png",
"output": "static/assets/foundation/transparent/a01-scroll-primary-v3.png",
"logicalSlot": { "widthRpx": 622, "heightRpx": 92 },
"outputPixels": { "width": 1866, "height": 276 },
"render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false },
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
"quality": { "maxBytes": 1800000, "symmetry": "horizontal", "colorSpace": "sRGB" },
"processing": { "trim": 1, "capWidth": 380, "keyColor": "#00FF00", "keyTolerance": 96 },
"runtime": { "selector": ".login-submit", "imageSelector": ".button-skin img" },
"consumers": ["pages/auth/a01-entry.vue"]
},
{
"id": "a01-scroll-secondary-v3",
"assetClass": "fixed-bitmap",
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-secondary-master-v3.png",
"output": "static/assets/foundation/transparent/a01-scroll-secondary-v3.png",
"logicalSlot": { "widthRpx": 622, "heightRpx": 100 },
"outputPixels": { "width": 1866, "height": 300 },
"render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false },
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
"quality": { "maxBytes": 1800000, "symmetry": "horizontal", "colorSpace": "sRGB" },
"processing": { "trim": 1, "capWidth": 380, "keyColor": "#00FF00", "keyTolerance": 96 },
"runtime": { "selector": ".wechat-login", "imageSelector": ".button-skin img" },
"consumers": ["pages/auth/a01-entry.vue"]
},
{
"id": "a01-scroll-toast-v3",
"assetClass": "nine-slice",
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-toast-master-v3.png",
"output": "static/assets/foundation/transparent/a01-scroll-toast-v3.png",
"logicalSlot": { "widthRpx": 590, "heightRpx": 82 },
"outputPixels": { "width": 1770, "height": 246 },
"render": { "scalePolicy": "nine-slice", "uniMode": "aspectFit", "allowDistortion": false },
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
"quality": { "maxBytes": 1600000, "symmetry": "horizontal", "colorSpace": "sRGB" },
"processing": { "trim": 1, "capWidth": 340, "keyColor": "#00FF00", "keyTolerance": 96 },
"runtime": { "selector": ".feedback-toast", "imageSelector": ".feedback-toast__skin img" },
"consumers": ["pages/auth/a01-entry.vue"]
},
{
"id": "a01-scroll-dialog-v3",
"assetClass": "fixed-bitmap",
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-dialog-master-v3-r2.png",
"output": "static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png",
"logicalSlot": { "widthRpx": 620, "heightRpx": 520 },
"outputPixels": { "width": 1860, "height": 1560 },
"render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false },
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
"quality": { "maxBytes": 3000000, "symmetry": "horizontal", "colorSpace": "sRGB" },
"processing": { "trim": 1, "capWidth": 260, "keyColor": "#00FF00", "keyTolerance": 96, "paletteColors": 128, "indexedPng": true },
"runtime": { "selector": ".verification-dialog", "imageSelector": ".verification-dialog__skin img" },
"consumers": ["pages/auth/a01-entry.vue"]
}
]
}
-119
View File
@@ -1,119 +0,0 @@
{
"schemaVersion": 1,
"canvas": {
"width": 412,
"height": 915
},
"shared": {
"brandSeal": "static/assets/foundation/transparent/brand-seal.png",
"lock": "static/assets/modules/auth/transparent/a01-icon-lock-v1.png",
"wechat": "static/assets/foundation/transparent/auth-wechat.png",
"agreementUnchecked": "static/assets/modules/auth/transparent/a02-agreement-unchecked.png"
},
"states": [
{ "name": "password-hidden", "mode": "password", "passwordVisible": false },
{ "name": "password-visible", "mode": "password", "passwordVisible": true },
{ "name": "sms-default", "mode": "sms", "countdown": 0 },
{ "name": "sms-countdown", "mode": "sms", "countdown": 56 }
],
"assets": [
{
"id": "header",
"source": "static/assets/modules/auth/opaque/a01-vnext-header-v1.png",
"output": "static/assets/modules/auth/opaque/a01-vnext-header-v1.png",
"width": 824,
"height": 340,
"alpha": false,
"maxBytes": 1200000
},
{
"id": "scroll",
"source": "static/assets/modules/auth/transparent/a01-vnext-scroll-v1.png",
"output": "static/assets/modules/auth/transparent/a01-vnext-scroll-v1.png",
"width": 784,
"height": 1544,
"alpha": true,
"maxBytes": 4500000
},
{
"id": "titleOrnament",
"source": "static/assets/modules/auth/transparent/a01-vnext-title-ornament-v1.png",
"output": "static/assets/modules/auth/transparent/a01-vnext-title-ornament-v1.png",
"width": 376,
"height": 130,
"alpha": true,
"maxBytes": 350000
},
{
"id": "divider",
"source": "static/assets/modules/auth/transparent/a01-vnext-divider-v1.png",
"output": "static/assets/modules/auth/transparent/a01-vnext-divider-v1.png",
"width": 540,
"height": 90,
"alpha": true,
"maxBytes": 350000
},
{
"id": "primaryButton",
"source": "static/assets/modules/auth/transparent/a01-vnext-primary-button-v1.png",
"output": "static/assets/modules/auth/transparent/a01-vnext-primary-button-v1.png",
"width": 584,
"height": 130,
"alpha": true,
"maxBytes": 350000
},
{
"id": "secondaryButton",
"source": "static/assets/modules/auth/transparent/a01-vnext-secondary-button-v1.png",
"output": "static/assets/modules/auth/transparent/a01-vnext-secondary-button-v1.png",
"width": 584,
"height": 120,
"alpha": true,
"maxBytes": 350000
},
{
"id": "phone",
"source": "static/assets/modules/auth/transparent/a01-vnext-phone-v1.png",
"output": "static/assets/modules/auth/transparent/a01-vnext-phone-v1.png",
"width": 120,
"height": 120,
"alpha": true,
"maxBytes": 120000
},
{
"id": "eyeOpen",
"source": "static/assets/modules/auth/transparent/a01-vnext-eye-open-v1.png",
"output": "static/assets/modules/auth/transparent/a01-vnext-eye-open-v1.png",
"width": 168,
"height": 128,
"alpha": true,
"maxBytes": 150000
},
{
"id": "eyeClosedPupil",
"source": "static/assets/modules/auth/transparent/a01-vnext-eye-closed-pupil-v1.png",
"output": "static/assets/modules/auth/transparent/a01-vnext-eye-closed-pupil-v1.png",
"width": 168,
"height": 140,
"alpha": true,
"maxBytes": 160000
},
{
"id": "smsThreeDots",
"source": "static/assets/modules/auth/transparent/a01-vnext-sms-three-dots-v1.png",
"output": "static/assets/modules/auth/transparent/a01-vnext-sms-three-dots-v1.png",
"width": 120,
"height": 120,
"alpha": true,
"maxBytes": 120000
}
],
"previews": [
{ "state": "password-hidden", "output": "design-pipeline/generated/a01/A01-password-412x915.png", "width": 412, "height": 915 },
{ "state": "sms-default", "output": "design-pipeline/generated/a01/A01-sms-412x915.png", "width": 412, "height": 915 },
{ "state": "contact", "output": "design-pipeline/generated/a01/A01-password-sms-contact-824x915.png", "width": 824, "height": 915 },
{ "state": "password-hidden", "output": "design-pipeline/generated/a01/A01-password-320x568.png", "width": 320, "height": 568 },
{ "state": "password-hidden", "output": "design-pipeline/generated/a01/A01-password-360x640.png", "width": 360, "height": 640 },
{ "state": "password-hidden", "output": "design-pipeline/generated/a01/A01-password-360x800.png", "width": 360, "height": 800 }
]
}
@@ -0,0 +1,60 @@
{
"schemaVersion": 1,
"kind": "runtime-asset-inventory",
"scope": "application-direct",
"imports": [],
"assets": [
{ "id": "app-foundation-opaque-root-header-cinnabar", "output": "static/assets/foundation/opaque/root-header-cinnabar.jpg", "width": 720, "height": 184, "alpha": false, "bytes": 9356, "sha256": "9561b1e4d11d3cdd005b902c6d989aadc48bd614bec5ddbd65a32699983b4b37", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-auth-title-cloud", "output": "static/assets/foundation/transparent/auth-title-cloud.png", "width": 200, "height": 120, "alpha": true, "bytes": 15833, "sha256": "c6dc6e367198e815b28b8f88c4012b850f2a21195d8ca4ecaf27c0204eba64f2", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-meta-admin", "output": "static/assets/foundation/transparent/meta-admin.png", "width": 96, "height": 96, "alpha": true, "bytes": 4705, "sha256": "d2363e3a4f990dcf5925f0b67ac51b8d3d0fe79d4e7d753ddd8df5b412a15517", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-meta-location", "output": "static/assets/foundation/transparent/meta-location.png", "width": 96, "height": 96, "alpha": true, "bytes": 3634, "sha256": "ed1285d91df018a6e7628cca944428e1582fb10ddeaf75ba7743481340adc12c", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-meta-member", "output": "static/assets/foundation/transparent/meta-member.png", "width": 96, "height": 96, "alpha": true, "bytes": 8931, "sha256": "c89824e8e19210dd746f56019a48c9a833abe4a210b222758a56c3d25dec9d0c", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-notice", "output": "static/assets/foundation/transparent/notice.png", "width": 96, "height": 96, "alpha": true, "bytes": 8775, "sha256": "a75ce2368afa661edb4b394d0a58bc514248fa994f9e732d1d6833caa79353e1", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-root-header-hall", "output": "static/assets/foundation/transparent/root-header-hall.png", "width": 750, "height": 300, "alpha": true, "bytes": 159144, "sha256": "a4b55282fb42f0200636e7d5a66cd1f237bea01521c312fa99a3b7278cf8ff49", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-tab-family-active", "output": "static/assets/foundation/transparent/tab-family-active.png", "width": 96, "height": 96, "alpha": true, "bytes": 2620, "sha256": "007f03d04927fbf6638fff027335f45db1331adb36979bcbca0d577bb28046f5", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-tab-family", "output": "static/assets/foundation/transparent/tab-family.png", "width": 96, "height": 96, "alpha": true, "bytes": 2621, "sha256": "c7e0083e4ca045b084dab6e77cbece8a26f490dec628e386689d68a57d03387a", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-tab-genealogy-active", "output": "static/assets/foundation/transparent/tab-genealogy-active.png", "width": 96, "height": 96, "alpha": true, "bytes": 3142, "sha256": "99ad57c8868eb618f62601882f7748edd5b87c81cf1d0fe5bf2324113930a88c", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-tab-genealogy", "output": "static/assets/foundation/transparent/tab-genealogy.png", "width": 96, "height": 96, "alpha": true, "bytes": 3141, "sha256": "88d5653db7521b846ef81736355e9ccbbb59daf6dfe92c9c2748bef6456af646", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-tab-profile-active", "output": "static/assets/foundation/transparent/tab-profile-active.png", "width": 96, "height": 96, "alpha": true, "bytes": 1973, "sha256": "5ff15db3b4cc64934e4ae9602e604345501c1f86160da10d30d9c0a80ef95d36", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-tab-profile", "output": "static/assets/foundation/transparent/tab-profile.png", "width": 96, "height": 96, "alpha": true, "bytes": 1973, "sha256": "45fc22b367807bdc465e6e17230a10276a4d7e453e27b7cbc064af7fdc255470", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-f08-f08-ancestral-home", "output": "static/assets/modules/family/f08/f08-ancestral-home.png", "width": 1448, "height": 1086, "alpha": true, "bytes": 3370777, "sha256": "b9339cac4fc6e2fe466ae64944140d8b332e019e10985fe900e06c06668391f1", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-f08-f08-ancestral-portrait", "output": "static/assets/modules/family/f08/f08-ancestral-portrait.png", "width": 1448, "height": 1086, "alpha": true, "bytes": 3227971, "sha256": "4b541960557d0caab15084348f1ecde5a9f93f7f791e1a7e881deb7a74e8496e", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-f08-f08-family-portrait", "output": "static/assets/modules/family/f08/f08-family-portrait.png", "width": 1448, "height": 1086, "alpha": true, "bytes": 4129091, "sha256": "dd221049eb552c098dfe8177e9ac8d4185b9865d7390f745ca91742b700bc68b", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-f08-f08-reunion-hero", "output": "static/assets/modules/family/f08/f08-reunion-hero.png", "width": 1672, "height": 940, "alpha": true, "bytes": 4110597, "sha256": "5b296d9254580b3565539813daacf74edaa5471495a3b30b06b512a5b2153dc9", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-f08-f08-reunion-table", "output": "static/assets/modules/family/f08/f08-reunion-table.png", "width": 1448, "height": 1086, "alpha": true, "bytes": 3765758, "sha256": "b241176cd3080e7b038b6b84e7cd3cb30f423c175df8cf2a78dc0cff1709e6eb", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-transparent-f01-family-letter-card", "output": "static/assets/modules/family/transparent/f01-family-letter-card.png", "width": 2003, "height": 581, "alpha": true, "bytes": 1513491, "sha256": "a3686f99e32cdc255c0fc130294aab1974392d66bc8a7991daeb8f67849015ee", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-transparent-module-content-frame", "output": "static/assets/modules/family/transparent/module-content-frame.png", "width": 2003, "height": 581, "alpha": true, "bytes": 1513491, "sha256": "a3686f99e32cdc255c0fc130294aab1974392d66bc8a7991daeb8f67849015ee", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-transparent-module-field-frame", "output": "static/assets/modules/family/transparent/module-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-opaque-g05-overview-surface", "output": "static/assets/modules/genealogy/opaque/g05-overview-surface.png", "width": 1122, "height": 1506, "alpha": false, "bytes": 2537499, "sha256": "2d908f53c7edb637c7ced7500822bd33d354410ad8e671871d1da3a4cbd0f4db", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-opaque-g06-search-button", "output": "static/assets/modules/genealogy/opaque/g06-search-button.png", "width": 300, "height": 132, "alpha": false, "bytes": 74664, "sha256": "bf0abc0a036c07322dd834fe5a9797413afd453b69d2ff8a895f3999fb8846cd", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-opaque-g06-search-input-wide", "output": "static/assets/modules/genealogy/opaque/g06-search-input-wide.png", "width": 1120, "height": 248, "alpha": false, "bytes": 319364, "sha256": "2a575eeb5582c8458cbdd04cd328afbbecbdaabf4db46e8f2133bfe2614faa80", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-add", "output": "static/assets/modules/genealogy/transparent/add.png", "width": 96, "height": 96, "alpha": true, "bytes": 5226, "sha256": "671846ee23f8df701e2e1dfc34e3520dfe9f719f106efce56e30d78b84a47a0b", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-create-cloud", "output": "static/assets/modules/genealogy/transparent/create-cloud.png", "width": 192, "height": 96, "alpha": true, "bytes": 10538, "sha256": "be01c1322181d91264641879162f6371d21ffda1feb971f5f31a791f32d02206", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-current-seal-frame", "output": "static/assets/modules/genealogy/transparent/current-seal-frame.png", "width": 144, "height": 208, "alpha": true, "bytes": 1214, "sha256": "6d9aa61d766eec077cd91fbd5b71079990e67bcdc213d631a1185714dfb09328", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-current-slip-frame", "output": "static/assets/modules/genealogy/transparent/current-slip-frame.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-g-form-field-frame", "output": "static/assets/modules/genealogy/transparent/g-form-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-g01-add-sheet-background-v3", "output": "static/assets/modules/genealogy/transparent/g01-add-sheet-background-v3.png", "width": 1536, "height": 1024, "alpha": true, "bytes": 1423794, "sha256": "181b9d9c95f0e8cce284dc6640adb4fa6c4fa9691306052c9e33558abf930e85", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-g01-dialog-close", "output": "static/assets/modules/genealogy/transparent/g01-dialog-close.png", "width": 1254, "height": 1254, "alpha": true, "bytes": 105163, "sha256": "909fe4badccbfad1ddcfe4a21292ba73fecde32efb914f1c7fcb717f32f9db3f", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-list-slip-frame", "output": "static/assets/modules/genealogy/transparent/list-slip-frame.png", "width": 720, "height": 144, "alpha": true, "bytes": 2992, "sha256": "1e39ed72c000e569340049833b1afca9e6aaeb077618a91e0c0f4f2758113005", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-row-seal-frame", "output": "static/assets/modules/genealogy/transparent/row-seal-frame.png", "width": 112, "height": 160, "alpha": true, "bytes": 875, "sha256": "f2f2b0260fe54d738a70168f28d3960622d4b3e57c4ef95a032716008de6626b", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-section-divider", "output": "static/assets/modules/genealogy/transparent/section-divider.png", "width": 720, "height": 30, "alpha": true, "bytes": 6645, "sha256": "925241a6cd2bd9897edad60e53eb255033603bef1028afbc189e87a29fddc6cd", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-shortcut-application", "output": "static/assets/modules/genealogy/transparent/shortcut-application.png", "width": 96, "height": 96, "alpha": true, "bytes": 8925, "sha256": "3842aec0357ee1013ddc230fe1030894ad5eb74101aa696bd8f986ad0c1bdd93", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-shortcut-generation-poem", "output": "static/assets/modules/genealogy/transparent/shortcut-generation-poem.png", "width": 96, "height": 96, "alpha": true, "bytes": 8169, "sha256": "688e593fa88925ea00acf37570852a2dca9e1a505ba3b8107e0ddd3d146a6f4e", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-shortcut-members", "output": "static/assets/modules/genealogy/transparent/shortcut-members.png", "width": 96, "height": 96, "alpha": true, "bytes": 8931, "sha256": "c89824e8e19210dd746f56019a48c9a833abe4a210b222758a56c3d25dec9d0c", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-shortcut-tree", "output": "static/assets/modules/genealogy/transparent/shortcut-tree.png", "width": 96, "height": 96, "alpha": true, "bytes": 6636, "sha256": "cafe12a3fa800ca79d6c559baa533d7e26124ec8ac0072482ddb89ac2167422d", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-notification-transparent-module-content-frame", "output": "static/assets/modules/notification/transparent/module-content-frame.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-notification-transparent-module-field-frame", "output": "static/assets/modules/notification/transparent/module-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-notification-transparent-n01-notice-card", "output": "static/assets/modules/notification/transparent/n01-notice-card.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-transparent-m01-profile-summary-card", "output": "static/assets/modules/profile/transparent/m01-profile-summary-card.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-transparent-module-content-frame", "output": "static/assets/modules/profile/transparent/module-content-frame.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-transparent-module-field-frame", "output": "static/assets/modules/profile/transparent/module-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-records-transparent-module-content-frame", "output": "static/assets/modules/records/transparent/module-content-frame.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-records-transparent-module-field-frame", "output": "static/assets/modules/records/transparent/module-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-records-transparent-r01-person-name-card", "output": "static/assets/modules/records/transparent/r01-person-name-card.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-t01-member-drawer", "output": "static/assets/modules/tree/transparent/t01-member-drawer.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-t01-member-node-selected", "output": "static/assets/modules/tree/transparent/t01-member-node-selected.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-t01-member-node-standard", "output": "static/assets/modules/tree/transparent/t01-member-node-standard.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-t01-state-panel", "output": "static/assets/modules/tree/transparent/t01-state-panel.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-t07-search-input-frame", "output": "static/assets/modules/tree/transparent/t07-search-input-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false }
]
}
@@ -0,0 +1,24 @@
{
"schemaVersion": 1,
"kind": "runtime-asset-inventory",
"scope": "auth",
"imports": [],
"assets": [
{ "id": "auth-page-paper", "output": "static/assets/foundation/opaque/auth-page-paper.jpg", "width": 750, "height": 1334, "alpha": false, "bytes": 60355, "sha256": "3eea1eefa815c306f0f4a95ce79878ca53f8f64a7c9b44144c99ca683a38e7b8", "provenance": "committed-binary", "rebuildable": false },
{ "id": "auth-divider-knot", "output": "static/assets/foundation/transparent/auth-divider-knot.png", "width": 160, "height": 96, "alpha": true, "bytes": 6751, "sha256": "d45dc052c8eb2ba214c7d4d5846f5b53f01686e00e671bfe0582ed7d9d4a2419", "provenance": "committed-binary", "rebuildable": false },
{ "id": "auth-login-outline", "output": "static/assets/foundation/transparent/auth-login-outline.png", "width": 96, "height": 96, "alpha": true, "bytes": 1973, "sha256": "45fc22b367807bdc465e6e17230a10276a4d7e453e27b7cbc064af7fdc255470", "provenance": "committed-binary", "rebuildable": false },
{ "id": "auth-wechat", "output": "static/assets/foundation/transparent/auth-wechat.png", "width": 96, "height": 96, "alpha": true, "bytes": 2215, "sha256": "5c69addac53afcd34064e48fffc19ef56a5d105c92f6130cd6045dd1591221d1", "provenance": "committed-binary", "rebuildable": false },
{ "id": "brand-seal", "output": "static/assets/foundation/transparent/brand-seal.png", "width": 240, "height": 288, "alpha": true, "bytes": 101898, "sha256": "865e15edd6a1b50aa298ccdff5babf0f3140906026ce76d3a64631027820a7ae", "provenance": "committed-binary", "rebuildable": false },
{ "id": "chevron-right", "output": "static/assets/foundation/transparent/chevron-right.png", "width": 96, "height": 96, "alpha": true, "bytes": 2106, "sha256": "82f4996e118832108dba4aa33d320131fee300970679f6ce8cf4b09bda62b700", "provenance": "committed-binary", "rebuildable": false },
{ "id": "auth-backdrop-v1", "output": "static/assets/modules/auth/opaque/a01-red-hall-ink-backdrop-v1.png", "width": 824, "height": 1830, "alpha": false, "bytes": 669577, "sha256": "478d0f56d669ac1597cfc2a1082740e73550f07d655286762f3306410c19eb11", "provenance": "committed-binary", "rebuildable": false },
{ "id": "auth-header-v1", "output": "static/assets/modules/auth/opaque/a01-vnext-header-v1.png", "width": 824, "height": 340, "alpha": false, "bytes": 504001, "sha256": "6ce03f4962e8bdd0272ed00a82efded21becdf350a1fb0880c595d7d9c15200d", "provenance": "committed-binary", "rebuildable": false },
{ "id": "auth-eye-closed-v2", "output": "static/assets/modules/auth/transparent/a01-icon-eye-closed-pupil-v2.png", "width": 96, "height": 96, "alpha": true, "bytes": 3906, "sha256": "b9304de963cacaa5ecd133a9835174f0c77761d9f264afec900460c9aa983b4f", "provenance": "committed-binary", "rebuildable": false },
{ "id": "auth-eye-open-v1", "output": "static/assets/modules/auth/transparent/a01-icon-eye-open-v1.png", "width": 96, "height": 96, "alpha": true, "bytes": 2868, "sha256": "aa6a78b3f0ecb2d47963b9aa04c98db1b2981497f52f58ff8c602cff80fab21a", "provenance": "committed-binary", "rebuildable": false },
{ "id": "auth-lock-v1", "output": "static/assets/modules/auth/transparent/a01-icon-lock-v1.png", "width": 96, "height": 96, "alpha": true, "bytes": 2649, "sha256": "c1b13de86ffee9a9f2e7534d58ddc3bf155d5341eeddbd5998ef3747d57fdea8", "provenance": "committed-binary", "rebuildable": false },
{ "id": "auth-phone-v1", "output": "static/assets/modules/auth/transparent/a01-icon-phone-v1.png", "width": 96, "height": 96, "alpha": true, "bytes": 925, "sha256": "5110cfb1cc84b00145506b3cda3fc35860400d16404e606428902dfe0fa7ec2e", "provenance": "committed-binary", "rebuildable": false },
{ "id": "auth-sms-code-v2", "output": "static/assets/modules/auth/transparent/a01-icon-sms-code-v2.png", "width": 1254, "height": 1254, "alpha": true, "bytes": 114061, "sha256": "e932900fa8ab0edcfeed0de0d52c637890e28f58f867aabfeacbb18df060a556", "provenance": "committed-binary", "rebuildable": false },
{ "id": "auth-divider-v1", "output": "static/assets/modules/auth/transparent/a01-vnext-divider-v1.png", "width": 540, "height": 90, "alpha": true, "bytes": 6278, "sha256": "10a3281723abfadcc2cec7b8e4437fe50ecdc36d885d54ebce11f46c46ed4af4", "provenance": "committed-binary", "rebuildable": false },
{ "id": "agreement-checked", "output": "static/assets/modules/auth/transparent/a02-agreement-checked.png", "width": 96, "height": 96, "alpha": true, "bytes": 10602, "sha256": "825fcb945c81330429abc1d3ec1d0c20df86a9634416e244896e452cb08644f4", "provenance": "committed-binary", "rebuildable": false },
{ "id": "agreement-unchecked", "output": "static/assets/modules/auth/transparent/a02-agreement-unchecked.png", "width": 96, "height": 96, "alpha": true, "bytes": 6014, "sha256": "d9843d1c3644272f6eeff9fb44e763c857f409f404551a5e1f032ddf1b07b69f", "provenance": "committed-binary", "rebuildable": false }
]
}
@@ -1,62 +0,0 @@
{
"schemaVersion": 1,
"page": "G01",
"status": "long-flagship-selected-for-genealogy-module",
"selectedCandidateId": "g01-list-background-long-flagship",
"runtimeOutput": "static/assets/modules/genealogy/opaque/genealogy-page-background-long.png",
"generatedDate": "2026-07-16",
"referenceAssets": [
"static/assets/foundation/transparent/footer-mountain-bamboo.png",
"tmp/g01-audit-02-application-gap-focused-412x915.png"
],
"determinism": {
"modelRegeneration": "direction-only-not-pixel-identical",
"savedMasterProcessing": "pixel-identical-with-locked-dependencies"
},
"chromaKey": {
"color": "#00ff00",
"dominanceStart": 30,
"dominanceEnd": 220,
"despillAllowance": 10
},
"candidates": [
{
"id": "g01-list-background-a",
"title": "上山下亭纵向框景",
"master": "docs/design/assets/g01-background/masters/g01-list-background-direction-a-chroma-master.png",
"generatedOutput": "design-pipeline/generated/g01-background/g01-list-background-direction-a.png",
"processingMode": "chroma-key",
"sourcePixels": { "width": 1024, "height": 1536 },
"prompt": "1024x1536 纵向 G01 列表背景独立资产;暖灰淡金中国水墨,远山从上方约 10% 开始,左中下亭台,右侧竹影贯穿,中央低对比度供列表文字阅读;无文字、无 UI、无卡片、无红色;非画面区域必须为纯 #00ff00 绿幕,画面本身不得使用绿色。"
},
{
"id": "g01-list-background-b",
"title": "上下山水环抱框景",
"master": "docs/design/assets/g01-background/masters/g01-list-background-direction-b-chroma-master.png",
"generatedOutput": "design-pipeline/generated/g01-background/g01-list-background-direction-b.png",
"processingMode": "chroma-key",
"sourcePixels": { "width": 1024, "height": 1536 },
"prompt": "1024x1536 纵向 G01 列表背景独立资产;上方连续远山与雾带、左中亭台、右侧淡金竹影、下方第二层山水,三个纵向区段都有内容但中央保持安静;无文字、无 UI、无卡片、无红色;非画面区域必须为纯 #00ff00 绿幕,画面本身不得使用绿色。"
},
{
"id": "g01-list-background-c",
"title": "云竹亭台连续宣纸景",
"master": "docs/design/assets/g01-background/masters/g01-list-background-direction-c-paper-master.png",
"generatedOutput": "design-pipeline/generated/g01-background/g01-list-background-direction-c.png",
"processingMode": "opaque-paper",
"sourcePixels": { "width": 1024, "height": 1536 },
"prompt": "1024x1536 纵向 G01 列表背景独立资产;暖宣纸底,顶部 5% 即出现云气、枝叶和淡金竹影,左侧约 40% 高度放置亭台,山水从中部连续延伸到底部,中央低对比度;无文字、无 UI、无卡片、无标志、无红色。"
},
{
"id": "g01-list-background-long-flagship",
"title": "旗舰长屏连续云竹亭台宣纸景",
"imageGenSource": "docs/design/assets/g01-background/masters/genealogy-page-background-long-flagship-imagegen-source.png",
"master": "docs/design/assets/g01-background/masters/genealogy-page-background-long-flagship-master.png",
"generatedOutput": "design-pipeline/generated/g01-background/genealogy-page-background-long-flagship.png",
"processingMode": "opaque-paper-resize",
"sourcePixels": { "width": 1536, "height": 3840 },
"outputPixels": { "width": 1440, "height": 3600 },
"prompt": "1536x3840 纵向 G 类型共享连续长背景;延续 C 方向的暖灰宣纸、淡金竹影和灰褐水墨,顶部疏竹淡云作为短屏安全裁切区,中段低对比留给页面内容,下半部连续布置亭台、湖面、远山和岸边竹影;无文字、无 Logo、无 UI、无卡片、无按钮、无红色元素、无横向接缝、无重复图案。"
}
]
}
@@ -0,0 +1,29 @@
{
"schemaVersion": 3,
"kind": "asset-build-manifest",
"family": "g01-state-frame-v3",
"assets": [
{
"id": "g01-empty-panel-frame",
"source": "docs/design/assets/g01-state/masters/g01-empty-panel-master.png",
"output": "static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png",
"sourcePixels": { "width": 1122, "height": 1402 },
"outputPixels": { "width": 1122, "height": 1402 },
"alpha": { "required": true, "transparentOuterPadding": 0, "cornerMaxAlpha": 0 },
"edge": { "forbidChromaResidue": false, "forbidLightFringe": false, "premultipliedAlphaCheck": true },
"quality": { "maxBytes": 2200000, "colorSpace": "sRGB" },
"processing": {
"mode": "warm-gold-frame-extract",
"borderBand": 110,
"redGreenMin": 15,
"greenBlueMin": 12,
"redBlueMin": 35,
"redMaxExclusive": 245,
"blueMaxExclusive": 180,
"alphaOffset": 25,
"alphaScale": 6,
"outputMode": "RGBA"
}
}
]
}
@@ -1,32 +0,0 @@
{
"version": 1,
"width": 1440,
"height": 3600,
"backgrounds": [
{
"module": "tree",
"source": "docs/design/assets/module-backgrounds/masters/tree-page-background-imagegen-source.png",
"output": "static/assets/modules/tree/opaque/tree-page-background-long.png"
},
{
"module": "family",
"source": "docs/design/assets/module-backgrounds/masters/family-page-background-imagegen-source.png",
"output": "static/assets/modules/family/opaque/family-page-background-long.png"
},
{
"module": "records",
"source": "docs/design/assets/module-backgrounds/masters/records-page-background-imagegen-source.png",
"output": "static/assets/modules/records/opaque/records-page-background-long.png"
},
{
"module": "notification",
"source": "docs/design/assets/module-backgrounds/masters/notification-page-background-imagegen-source.png",
"output": "static/assets/modules/notification/opaque/notification-page-background-long.png"
},
{
"module": "profile",
"source": "docs/design/assets/module-backgrounds/masters/profile-page-background-imagegen-source.png",
"output": "static/assets/modules/profile/opaque/profile-page-background-long.png"
}
]
}
@@ -0,0 +1,61 @@
{
"schemaVersion": 3,
"kind": "asset-build-manifest",
"family": "page-backgrounds-v3",
"assets": [
{
"id": "genealogy-page-background-long",
"source": "docs/design/assets/g01-background/masters/genealogy-page-background-long-flagship-master.png",
"output": "static/assets/modules/genealogy/opaque/genealogy-page-background-long.png",
"sourcePixels": { "width": 1536, "height": 3840 },
"outputPixels": { "width": 1440, "height": 3600 },
"quality": { "maxBytes": 7000000, "colorSpace": "sRGB" },
"processing": { "mode": "opaque-resize", "resample": "lanczos", "outputMode": "RGB" }
},
{
"id": "tree-page-background-long",
"source": "docs/design/assets/module-backgrounds/masters/tree-page-background-imagegen-source.png",
"output": "static/assets/modules/tree/opaque/tree-page-background-long.png",
"sourcePixels": { "width": 793, "height": 1983 },
"outputPixels": { "width": 1440, "height": 3600 },
"quality": { "maxBytes": 7000000, "colorSpace": "sRGB" },
"processing": { "mode": "opaque-cover-crop", "resample": "lanczos", "anchor": "center", "outputMode": "RGB" }
},
{
"id": "family-page-background-long",
"source": "docs/design/assets/module-backgrounds/masters/family-page-background-imagegen-source.png",
"output": "static/assets/modules/family/opaque/family-page-background-long.png",
"sourcePixels": { "width": 793, "height": 1983 },
"outputPixels": { "width": 1440, "height": 3600 },
"quality": { "maxBytes": 7000000, "colorSpace": "sRGB" },
"processing": { "mode": "opaque-cover-crop", "resample": "lanczos", "anchor": "center", "outputMode": "RGB" }
},
{
"id": "records-page-background-long",
"source": "docs/design/assets/module-backgrounds/masters/records-page-background-imagegen-source.png",
"output": "static/assets/modules/records/opaque/records-page-background-long.png",
"sourcePixels": { "width": 793, "height": 1983 },
"outputPixels": { "width": 1440, "height": 3600 },
"quality": { "maxBytes": 7000000, "colorSpace": "sRGB" },
"processing": { "mode": "opaque-cover-crop", "resample": "lanczos", "anchor": "center", "outputMode": "RGB" }
},
{
"id": "notification-page-background-long",
"source": "docs/design/assets/module-backgrounds/masters/notification-page-background-imagegen-source.png",
"output": "static/assets/modules/notification/opaque/notification-page-background-long.png",
"sourcePixels": { "width": 793, "height": 1983 },
"outputPixels": { "width": 1440, "height": 3600 },
"quality": { "maxBytes": 7000000, "colorSpace": "sRGB" },
"processing": { "mode": "opaque-cover-crop", "resample": "lanczos", "anchor": "center", "outputMode": "RGB" }
},
{
"id": "profile-page-background-long",
"source": "docs/design/assets/module-backgrounds/masters/profile-page-background-imagegen-source.png",
"output": "static/assets/modules/profile/opaque/profile-page-background-long.png",
"sourcePixels": { "width": 793, "height": 1983 },
"outputPixels": { "width": 1440, "height": 3600 },
"quality": { "maxBytes": 7000000, "colorSpace": "sRGB" },
"processing": { "mode": "opaque-cover-crop", "resample": "lanczos", "anchor": "center", "outputMode": "RGB" }
}
]
}
@@ -0,0 +1,13 @@
{
"schemaVersion": 1,
"kind": "runtime-asset-inventory",
"scope": "schema-v3",
"imports": [
"design-pipeline/manifests/auth-runtime-assets.json",
"design-pipeline/manifests/application-runtime-assets.json",
"design-pipeline/manifests/shared-scroll-skins-v3.json",
"design-pipeline/manifests/page-backgrounds-v3.json",
"design-pipeline/manifests/g01-state-frame-v3.json"
],
"assets": []
}
@@ -0,0 +1,51 @@
{
"schemaVersion": 3,
"kind": "asset-build-manifest",
"family": "shared-scroll-skins-v3",
"assets": [
{
"id": "shared-scroll-primary-v3",
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-primary-master-v3.png",
"output": "static/assets/foundation/transparent/a01-scroll-primary-v3.png",
"sourcePixels": { "width": 2172, "height": 724 },
"outputPixels": { "width": 1866, "height": 276 },
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
"quality": { "maxBytes": 1800000, "colorSpace": "sRGB" },
"processing": { "mode": "chroma-stretch", "capWidth": 380, "keyColor": "#00FF00", "keyTolerance": 96 }
},
{
"id": "shared-scroll-secondary-v3",
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-secondary-master-v3.png",
"output": "static/assets/foundation/transparent/a01-scroll-secondary-v3.png",
"sourcePixels": { "width": 2172, "height": 724 },
"outputPixels": { "width": 1866, "height": 300 },
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
"quality": { "maxBytes": 1800000, "colorSpace": "sRGB" },
"processing": { "mode": "chroma-stretch", "capWidth": 380, "keyColor": "#00FF00", "keyTolerance": 96 }
},
{
"id": "shared-scroll-toast-v3",
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-toast-master-v3.png",
"output": "static/assets/foundation/transparent/a01-scroll-toast-v3.png",
"sourcePixels": { "width": 2172, "height": 724 },
"outputPixels": { "width": 1770, "height": 246 },
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
"quality": { "maxBytes": 1600000, "colorSpace": "sRGB" },
"processing": { "mode": "chroma-stretch", "capWidth": 340, "keyColor": "#00FF00", "keyTolerance": 96 }
},
{
"id": "shared-scroll-dialog-v3",
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-dialog-master-v3-r2.png",
"output": "static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png",
"sourcePixels": { "width": 1370, "height": 1148 },
"outputPixels": { "width": 1860, "height": 1560 },
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
"quality": { "maxBytes": 3000000, "colorSpace": "sRGB" },
"processing": { "mode": "chroma-stretch", "capWidth": 260, "keyColor": "#00FF00", "keyTolerance": 96, "paletteColors": 128, "indexedPng": true }
}
]
}
+1 -599
View File
@@ -4,605 +4,7 @@
"requires": true,
"packages": {
"": {
"name": "jiapu-design-pipeline",
"dependencies": {
"sharp": "0.34.5"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
"name": "jiapu-design-pipeline"
}
}
}
+11 -13
View File
@@ -3,18 +3,16 @@
"private": true,
"type": "module",
"scripts": {
"build:a01": "node scripts/build-a01.mjs",
"test:v2": "node --test tests/*.test.mjs",
"validate:a01-buttons": "node scripts/validate-manifest-v2.mjs manifests/a01-buttons-v2.json",
"validate:a01-scroll-skins": "node scripts/validate-manifest-v2.mjs manifests/a01-scroll-skins-v3.json",
"rebuild:a01-buttons": "node scripts/rebuild-a01-buttons.mjs",
"verify:assets": "node scripts/verify-assets.mjs",
"build:a01-scroll-skins": "node scripts/build-a01-scroll-skins.mjs",
"verify:a01-scroll-skins": "node scripts/verify-a01-scroll-skins.mjs",
"build:g01-background-candidates": "node scripts/build-g01-backgrounds.mjs",
"build:g01-empty-frame": "node scripts/build-g01-empty-frame.mjs"
},
"dependencies": {
"sharp": "0.34.5"
"test": "npm run test:node && npm run test:python",
"test:node": "node --test tests/*.test.mjs",
"test:python": "node scripts/run-python-tests.mjs",
"validate:shared-scroll-skins": "node scripts/validate-asset-build-manifest.mjs design-pipeline/manifests/shared-scroll-skins-v3.json",
"validate:page-backgrounds": "node scripts/validate-asset-build-manifest.mjs design-pipeline/manifests/page-backgrounds-v3.json",
"validate:g01-state-frame": "node scripts/validate-asset-build-manifest.mjs design-pipeline/manifests/g01-state-frame-v3.json",
"validate:runtime-assets": "node scripts/validate-runtime-asset-inventory.mjs design-pipeline/manifests/runtime-assets.json",
"build:shared-scroll-skins": "node scripts/build-shared-scroll-skins.mjs",
"verify:shared-scroll-skins": "node scripts/verify-shared-scroll-skins.mjs",
"build:page-backgrounds": "node scripts/build-raster-assets.mjs design-pipeline/manifests/page-backgrounds-v3.json",
"build:g01-state-frame": "node scripts/build-raster-assets.mjs design-pipeline/manifests/g01-state-frame-v3.json"
}
}
@@ -0,0 +1,196 @@
import path from 'node:path'
const assertOnlyFields = (value, fields, label) => {
for (const field of Object.keys(value)) {
if (!fields.has(field)) throw new Error(`${label} contains unknown field: ${field}`)
}
}
const requireObject = (value, label) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${label} must be an object`)
}
return value
}
const requireString = (value, label) => {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`${label} must be a non-empty string`)
}
return value
}
const requirePositiveInteger = (value, label) => {
if (!Number.isInteger(value) || value <= 0) throw new Error(`${label} must be a positive integer`)
return value
}
const requireIntegerInRange = (value, minimum, maximum, label) => {
if (!Number.isInteger(value) || value < minimum || value > maximum) {
throw new Error(`${label} must be an integer from ${minimum} to ${maximum}`)
}
return value
}
const requireBoolean = (value, label) => {
if (typeof value !== 'boolean') throw new Error(`${label} must be boolean`)
return value
}
const requireExact = (value, expected, label) => {
if (value !== expected) throw new Error(`${label} must be ${expected}`)
return value
}
const resolveInsideWorkspace = (workspace, relativePath, label) => {
requireString(relativePath, label)
const absolutePath = path.resolve(workspace, relativePath)
const relative = path.relative(workspace, absolutePath)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`${label} escapes workspace: ${relativePath}`)
}
return absolutePath
}
const validatePixels = (value, label) => {
const pixels = requireObject(value, label)
assertOnlyFields(pixels, new Set(['width', 'height']), label)
requirePositiveInteger(pixels.width, `${label}.width`)
requirePositiveInteger(pixels.height, `${label}.height`)
}
const validateAlphaAndEdge = (asset, id) => {
const alpha = requireObject(asset.alpha, `${id}.alpha`)
assertOnlyFields(alpha, new Set(['required', 'transparentOuterPadding', 'cornerMaxAlpha']), `${id}.alpha`)
requireExact(alpha.required, true, `${id}.alpha.required`)
requireIntegerInRange(alpha.transparentOuterPadding, 0, 4096, `${id}.alpha.transparentOuterPadding`)
requireIntegerInRange(alpha.cornerMaxAlpha, 0, 255, `${id}.alpha.cornerMaxAlpha`)
const edge = requireObject(asset.edge, `${id}.edge`)
assertOnlyFields(
edge,
new Set(['forbidChromaResidue', 'forbidLightFringe', 'premultipliedAlphaCheck']),
`${id}.edge`,
)
requireBoolean(edge.forbidChromaResidue, `${id}.edge.forbidChromaResidue`)
requireBoolean(edge.forbidLightFringe, `${id}.edge.forbidLightFringe`)
requireBoolean(edge.premultipliedAlphaCheck, `${id}.edge.premultipliedAlphaCheck`)
}
const validateProcessing = (processing, id) => {
requireObject(processing, `${id}.processing`)
const mode = requireString(processing.mode, `${id}.processing.mode`)
if (mode === 'chroma-stretch') {
assertOnlyFields(
processing,
new Set(['mode', 'capWidth', 'keyColor', 'keyTolerance', 'paletteColors', 'indexedPng']),
`${id}.processing`,
)
requirePositiveInteger(processing.capWidth, `${id}.processing.capWidth`)
if (!/^#[A-Fa-f0-9]{6}$/.test(processing.keyColor)) {
throw new Error(`${id}.processing.keyColor must be a six-digit RGB color`)
}
requireIntegerInRange(processing.keyTolerance, 0, 441, `${id}.processing.keyTolerance`)
if (processing.paletteColors !== undefined) {
requireIntegerInRange(processing.paletteColors, 2, 256, `${id}.processing.paletteColors`)
}
if (processing.indexedPng !== undefined) {
requireBoolean(processing.indexedPng, `${id}.processing.indexedPng`)
}
return true
}
if (mode === 'opaque-resize') {
assertOnlyFields(processing, new Set(['mode', 'resample', 'outputMode']), `${id}.processing`)
requireExact(processing.resample, 'lanczos', `${id}.processing.resample`)
requireExact(processing.outputMode, 'RGB', `${id}.processing.outputMode`)
return false
}
if (mode === 'opaque-cover-crop') {
assertOnlyFields(processing, new Set(['mode', 'resample', 'anchor', 'outputMode']), `${id}.processing`)
requireExact(processing.resample, 'lanczos', `${id}.processing.resample`)
requireExact(processing.anchor, 'center', `${id}.processing.anchor`)
requireExact(processing.outputMode, 'RGB', `${id}.processing.outputMode`)
return false
}
if (mode === 'warm-gold-frame-extract') {
assertOnlyFields(
processing,
new Set([
'mode',
'borderBand',
'redGreenMin',
'greenBlueMin',
'redBlueMin',
'redMaxExclusive',
'blueMaxExclusive',
'alphaOffset',
'alphaScale',
'outputMode',
]),
`${id}.processing`,
)
requirePositiveInteger(processing.borderBand, `${id}.processing.borderBand`)
for (const field of ['redGreenMin', 'greenBlueMin', 'redBlueMin', 'alphaOffset']) {
requireIntegerInRange(processing[field], 0, 255, `${id}.processing.${field}`)
}
for (const field of ['redMaxExclusive', 'blueMaxExclusive']) {
requireIntegerInRange(processing[field], 1, 256, `${id}.processing.${field}`)
}
requirePositiveInteger(processing.alphaScale, `${id}.processing.alphaScale`)
requireExact(processing.outputMode, 'RGBA', `${id}.processing.outputMode`)
return true
}
throw new Error(`${id} has unsupported processing.mode: ${mode}`)
}
export const validateAssetBuildManifest = (manifest, workspace) => {
requireObject(manifest, 'manifest')
assertOnlyFields(manifest, new Set(['schemaVersion', 'kind', 'family', 'assets']), 'manifest')
if (manifest.schemaVersion !== 3) throw new Error('schemaVersion must be 3')
if (manifest.kind !== 'asset-build-manifest') throw new Error('kind must be asset-build-manifest')
requireString(manifest.family, 'family')
if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) {
throw new Error('assets must be a non-empty array')
}
const ids = new Set()
const outputs = new Set()
for (const asset of manifest.assets) {
requireObject(asset, 'asset')
const processing = requireObject(asset.processing, 'asset.processing')
const mode = requireString(processing.mode, 'asset.processing.mode')
const transparent = ['chroma-stretch', 'warm-gold-frame-extract'].includes(mode)
const fields = new Set(['id', 'source', 'output', 'sourcePixels', 'outputPixels', 'quality', 'processing'])
if (transparent) {
fields.add('alpha')
fields.add('edge')
}
assertOnlyFields(asset, fields, 'asset')
const id = requireString(asset.id, 'asset.id')
if (ids.has(id)) throw new Error(`duplicate asset id: ${id}`)
ids.add(id)
resolveInsideWorkspace(workspace, asset.source, `${id}.source`)
resolveInsideWorkspace(workspace, asset.output, `${id}.output`)
if (outputs.has(asset.output)) throw new Error(`duplicate output: ${asset.output}`)
outputs.add(asset.output)
validatePixels(asset.sourcePixels, `${id}.sourcePixels`)
validatePixels(asset.outputPixels, `${id}.outputPixels`)
const processingNeedsAlpha = validateProcessing(processing, id)
if (processingNeedsAlpha) validateAlphaAndEdge(asset, id)
const quality = requireObject(asset.quality, `${id}.quality`)
assertOnlyFields(quality, new Set(['maxBytes', 'colorSpace']), `${id}.quality`)
requirePositiveInteger(quality.maxBytes, `${id}.quality.maxBytes`)
requireExact(quality.colorSpace, 'sRGB', `${id}.quality.colorSpace`)
}
return manifest
}
+37 -8
View File
@@ -15,9 +15,14 @@ def _outer_ring_pixels(image: Image.Image, thickness: int):
yield image.getpixel((x, y))
def analyze_asset(path: Path, spec: dict[str, Any]) -> dict[str, Any]:
"""Analyze one PNG against its manifest v2 specification."""
path = Path(path)
def analyze_asset(path: Path, spec: dict[str, Any], workspace: Path) -> dict[str, Any]:
"""按 schema v3 物理规格分析单张 PNG,并只输出工作区相对路径。"""
workspace = Path(workspace).resolve()
path = Path(path).resolve()
try:
report_path = path.relative_to(workspace).as_posix()
except ValueError as error:
raise ValueError(f"asset path escapes workspace: {path}") from error
errors: list[str] = []
warnings: list[str] = []
metrics: dict[str, int] = {}
@@ -40,14 +45,34 @@ def analyze_asset(path: Path, spec: dict[str, Any]) -> dict[str, Any]:
errors.append(f"alpha-required asset has no alpha channel or transparency table, got {mode}")
padding = int(alpha.get("transparentOuterPadding", 0))
max_alpha = int(alpha.get("cornerMaxAlpha", 0))
opaque_padding = sum(1 for pixel in _outer_ring_pixels(image, padding) if pixel[3] > max_alpha)
max_alpha = int(alpha.get("cornerMaxAlpha", 255))
corners = (
image.getpixel((0, 0))[3],
image.getpixel((width - 1, 0))[3],
image.getpixel((0, height - 1))[3],
image.getpixel((width - 1, height - 1))[3],
)
corner_violations = sum(1 for value in corners if value > max_alpha)
metrics["cornerAlphaViolations"] = corner_violations
if corner_violations:
errors.append(f"corners contain {corner_violations} pixels above alpha {max_alpha}")
# 不透明长背景没有边缘/透明度扫描需求;避免无意义地把每张 1440×3600 图
# 展开成数百万个 Python 元组。透明资产仍完整执行原有像素级质量合同。
opaque_padding = 0
if padding > 0:
opaque_padding = sum(1 for pixel in _outer_ring_pixels(image, padding) if pixel[3] > max_alpha)
metrics["outerPaddingViolations"] = opaque_padding
if opaque_padding:
errors.append(f"outer padding contains {opaque_padding} pixels above alpha {max_alpha}")
edge = spec.get("edge", {})
pixels = list(image.get_flattened_data())
needs_edge_pixels = any(edge.get(field) for field in (
"forbidChromaResidue",
"forbidLightFringe",
"premultipliedAlphaCheck",
))
pixels = list(image.get_flattened_data()) if needs_edge_pixels else []
chroma_residue = sum(
1
@@ -83,11 +108,15 @@ def analyze_asset(path: Path, spec: dict[str, Any]) -> dict[str, Any]:
expected_color_space = spec.get("quality", {}).get("colorSpace")
if expected_color_space == "sRGB" and not ("srgb" in info or "icc_profile" in info):
warnings.append("PNG does not declare an sRGB chunk or ICC profile")
errors.append("PNG does not declare an sRGB chunk or ICC profile")
expected_mode = spec.get("processing", {}).get("outputMode")
if expected_mode and mode != expected_mode:
errors.append(f"output mode expected {expected_mode}, got {mode}")
return {
"id": spec.get("id", path.stem),
"path": str(path),
"path": report_path,
"width": width,
"height": height,
"mode": mode,
@@ -1,28 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { loadAndValidateManifest } from './manifest-v2.mjs'
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
const pipelineDirectory = path.resolve(scriptDirectory, '..')
const workspace = path.resolve(pipelineDirectory, '..')
const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-scroll-skins-v3.json')
const reportPath = path.join(pipelineDirectory, 'generated', 'a01-scroll-skins-v3', 'quality-report.json')
const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python')
await loadAndValidateManifest(manifestPath, workspace)
function run(script, args) {
const result = spawnSync(python, [path.join(scriptDirectory, script), ...args], {
cwd: workspace,
encoding: 'utf8',
stdio: 'inherit',
})
if (result.error) throw new Error(`无法启动 Python${python}):${result.error.message}`)
if (result.status !== 0) process.exit(result.status ?? 1)
}
run('build_scroll_skins.py', [manifestPath, '--workspace', workspace])
run('verify-assets.py', [manifestPath, '--workspace', workspace, '--report', reportPath])
-159
View File
@@ -1,159 +0,0 @@
import { createHash } from 'node:crypto'
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import sharp from 'sharp'
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
const workspace = path.resolve(scriptDirectory, '..', '..')
const manifestPath = path.join(workspace, 'design-pipeline', 'manifests', 'a01.json')
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
const generatedDirectory = path.join(workspace, 'design-pipeline', 'generated', 'a01')
const resolveWorkspacePath = (relativePath) => {
const absolutePath = path.resolve(workspace, relativePath)
const relative = path.relative(workspace, absolutePath)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`路径越出工作区:${relativePath}`)
}
return absolutePath
}
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex')
const writeVersionedFile = async (outputPath, buffer) => {
await mkdir(path.dirname(outputPath), { recursive: true })
try {
const current = await readFile(outputPath)
if (sha256(current) !== sha256(buffer)) {
throw new Error(`拒绝覆盖内容不同的版本化输出:${path.relative(workspace, outputPath)}`)
}
return
} catch (error) {
if (error.code !== 'ENOENT') throw error
}
await writeFile(outputPath, buffer)
}
const runtimeAssets = new Map()
const reportAssets = []
for (const asset of manifest.assets) {
const sourcePath = resolveWorkspacePath(asset.source)
const outputPath = resolveWorkspacePath(asset.output)
await stat(sourcePath)
const pipeline = sharp(sourcePath).resize(asset.width, asset.height, { fit: 'fill' })
if (!asset.alpha) pipeline.flatten({ background: '#f4eadc' })
const buffer = await pipeline.png({ compressionLevel: 9, adaptiveFiltering: true, palette: false }).toBuffer()
await writeVersionedFile(outputPath, buffer)
runtimeAssets.set(asset.id, buffer)
const metadata = await sharp(buffer).metadata()
reportAssets.push({
id: asset.id,
output: asset.output,
width: metadata.width,
height: metadata.height,
bytes: buffer.length,
sha256: sha256(buffer)
})
}
const loadShared = async (key) => readFile(resolveWorkspacePath(manifest.shared[key]))
const shared = {
brandSeal: await loadShared('brandSeal'),
lock: await loadShared('lock'),
wechat: await loadShared('wechat'),
agreementUnchecked: await loadShared('agreementUnchecked')
}
const svgLayer = (mode) => Buffer.from(`
<svg width="412" height="915" xmlns="http://www.w3.org/2000/svg">
<style>
.k { font-family: KaiTi, STKaiti, serif; }
.red { fill: #9f1717; }
.gold { fill: #a97936; }
.body { fill: #51443b; }
.muted { fill: #9a8e84; }
</style>
<text x="206" y="321" text-anchor="middle" class="k red" font-size="36">登录家谱</text>
<text x="130" y="389" text-anchor="middle" class="k ${mode === 'password' ? 'red' : 'gold'}" font-size="20">密码登录</text>
<text x="282" y="389" text-anchor="middle" class="k ${mode === 'sms' ? 'red' : 'gold'}" font-size="20">验证码登录</text>
<line x1="61" y1="404" x2="351" y2="404" stroke="#d1a965" stroke-width="1"/>
<line x1="${mode === 'password' ? 99 : 251}" y1="401" x2="${mode === 'password' ? 161 : 313}" y2="401" stroke="#bd151b" stroke-width="4"/>
<text x="112" y="449" class="k muted" font-size="18">手机号</text>
<line x1="61" y1="481" x2="351" y2="481" stroke="#d1a965" stroke-width="1"/>
<text x="112" y="559" class="k muted" font-size="18">${mode === 'password' ? '密码' : '验证码'}</text>
${mode === 'password'
? '<text x="349" y="590" text-anchor="end" class="k red" font-size="15">忘记密码</text>'
: '<text x="349" y="559" text-anchor="end" class="k red" font-size="15">获取验证码</text>'}
<line x1="61" y1="576" x2="351" y2="576" stroke="#d1a965" stroke-width="1"/>
<text x="206" y="646" text-anchor="middle" class="k" fill="#fffaf2" font-size="26">登录</text>
<line x1="62" y1="692" x2="151" y2="692" stroke="#d1a965"/>
<line x1="261" y1="692" x2="350" y2="692" stroke="#d1a965"/>
<text x="206" y="699" text-anchor="middle" class="k gold" font-size="15">其他登录方式</text>
<text x="219" y="761" text-anchor="middle" class="k body" font-size="20">微信登录</text>
<text x="206" y="812" text-anchor="middle" class="k body" font-size="14">还没有账号? <tspan class="red">注册账号</tspan></text>
<text x="93" y="851" class="k body" font-size="11">我已阅读并同意《用户协议》与《隐私政策》</text>
</svg>`)
const sized = async (input, width, height) => sharp(input).resize(width, height, { fit: 'fill' }).png().toBuffer()
const buildBasePreview = async (mode) => {
const stateIcon = mode === 'password' ? shared.lock : runtimeAssets.get('smsThreeDots')
const rightIcon = mode === 'password' ? runtimeAssets.get('eyeClosedPupil') : null
const layers = [
{ input: await sized(runtimeAssets.get('header'), 412, 170), left: 0, top: 0 },
{ input: await sized(runtimeAssets.get('scroll'), 392, 772), left: 10, top: 143 },
{ input: await sized(shared.brandSeal, 72, 86), left: 170, top: 35 },
{ input: await sized(runtimeAssets.get('titleOrnament'), 126, 44), left: 143, top: 235 },
{ input: await sized(runtimeAssets.get('divider'), 180, 30), left: 116, top: 330 },
{ input: await sized(runtimeAssets.get('phone'), 40, 40), left: 68, top: 420 },
{ input: await sized(stateIcon, 40, 40), left: 68, top: 522 },
{ input: await sized(runtimeAssets.get('primaryButton'), 292, 65), left: 60, top: 608 },
{ input: await sized(runtimeAssets.get('secondaryButton'), 292, 60), left: 60, top: 718 },
{ input: await sized(shared.wechat, 36, 36), left: 126, top: 730 },
{ input: await sized(shared.agreementUnchecked, 24, 24), left: 62, top: 831 },
{ input: svgLayer(mode), left: 0, top: 0 }
]
if (rightIcon) layers.splice(7, 0, { input: await sized(rightIcon, 42, 35), left: 312, top: 525 })
return sharp({ create: { width: 412, height: 915, channels: 4, background: '#f4eadc' } })
.composite(layers)
.png({ compressionLevel: 9, adaptiveFiltering: true })
.toBuffer()
}
await mkdir(generatedDirectory, { recursive: true })
const passwordPreview = await buildBasePreview('password')
const smsPreview = await buildBasePreview('sms')
const writeGenerated = async (relativePath, buffer) => {
const outputPath = resolveWorkspacePath(relativePath)
await mkdir(path.dirname(outputPath), { recursive: true })
await writeFile(outputPath, buffer)
}
await writeGenerated('design-pipeline/generated/a01/A01-password-412x915.png', passwordPreview)
await writeGenerated('design-pipeline/generated/a01/A01-sms-412x915.png', smsPreview)
const contact = await sharp({ create: { width: 824, height: 915, channels: 4, background: '#ffffff' } })
.composite([{ input: passwordPreview, left: 0, top: 0 }, { input: smsPreview, left: 412, top: 0 }])
.png({ compressionLevel: 9, adaptiveFiltering: true })
.toBuffer()
await writeGenerated('design-pipeline/generated/a01/A01-password-sms-contact-824x915.png', contact)
for (const preview of manifest.previews.filter((item) => item.width !== 412 && item.width !== 824)) {
const compact = await sharp(passwordPreview)
.resize(preview.width, preview.height, { fit: 'fill' })
.png({ compressionLevel: 9, adaptiveFiltering: true })
.toBuffer()
await writeGenerated(preview.output, compact)
}
const buildReport = {
schemaVersion: 1,
manifest: path.relative(workspace, manifestPath).replaceAll('\\', '/'),
assets: reportAssets,
generatedAt: new Date().toISOString()
}
await writeGenerated('design-pipeline/generated/a01/build-report.json', Buffer.from(`${JSON.stringify(buildReport, null, 2)}\n`))
console.log('A01-CODE-PIPELINE BUILD PASS')
@@ -1,33 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
const pipelineDirectory = path.resolve(scriptDirectory, '..')
const workspace = path.resolve(pipelineDirectory, '..')
const manifestPath = path.join(pipelineDirectory, 'manifests', 'g01-background-candidates.json')
const reportPath = path.join(pipelineDirectory, 'generated', 'g01-background', 'build-report.json')
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
if (manifest.schemaVersion !== 1 || manifest.page !== 'G01' || manifest.candidates?.length !== 4) {
throw new Error('G01 background manifest must declare exactly four schema v1 candidates')
}
for (const candidate of manifest.candidates) {
const source = path.resolve(workspace, candidate.master)
const relative = path.relative(workspace, source)
if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error(`${candidate.id} master escapes workspace`)
if (!fs.existsSync(source)) throw new Error(`${candidate.id} master is missing: ${candidate.master}`)
}
const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python')
const result = spawnSync(
python,
[path.join(scriptDirectory, 'build_g01_backgrounds.py'), manifestPath, '--workspace', workspace, '--report', reportPath],
{ cwd: workspace, encoding: 'utf8', stdio: 'inherit' }
)
if (result.error) throw new Error(`无法启动 Python${python}):${result.error.message}`)
if (result.status !== 0) process.exit(result.status ?? 1)
@@ -1,51 +0,0 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import sharp from 'sharp'
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
const projectRoot = path.resolve(scriptDirectory, '..', '..')
const sourcePath = path.join(projectRoot, 'static', 'assets', 'modules', 'genealogy', 'opaque', 'g01-empty-panel.png')
const outputPath = path.join(projectRoot, 'static', 'assets', 'modules', 'genealogy', 'transparent', 'g01-empty-panel-frame.png')
const borderBand = 110
const { data, info } = await sharp(sourcePath)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true })
for (let y = 0; y < info.height; y += 1) {
for (let x = 0; x < info.width; x += 1) {
const offset = (y * info.width + x) * info.channels
const red = data[offset]
const green = data[offset + 1]
const blue = data[offset + 2]
const alpha = data[offset + 3]
const distanceToEdge = Math.min(x, y, info.width - 1 - x, info.height - 1 - y)
const isWarmGold = red > green
&& green > blue
&& red - green >= 15
&& green - blue >= 12
&& red - blue >= 35
&& red < 245
&& blue < 180
if (distanceToEdge >= borderBand || !isWarmGold) {
data[offset + 3] = 0
continue
}
const edgeAlpha = Math.max(0, Math.min(255, (red - blue - 25) * 6))
data[offset + 3] = Math.min(alpha, edgeAlpha)
}
}
await sharp(data, {
raw: {
width: info.width,
height: info.height,
channels: info.channels
}
}).png().toFile(outputPath)
process.stdout.write(`BUILT ${path.relative(projectRoot, outputPath)}\n`)
@@ -0,0 +1,43 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { validateAssetBuildManifest } from './asset-build-manifest.mjs'
import { resolvePythonExecutable, runPythonCommand } from './python-runtime.mjs'
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
const pipelineDirectory = path.resolve(scriptDirectory, '..')
const workspace = path.resolve(pipelineDirectory, '..')
const argument = process.argv[2]
if (!argument) throw new Error('用法:node build-raster-assets.mjs <仓库相对清单路径>')
// 清单参数本身也必须留在工作区;source/output 的边界由 schema validator 逐项负责。
const manifestPath = path.resolve(workspace, argument)
const relativeManifest = path.relative(workspace, manifestPath)
if (relativeManifest.startsWith('..') || path.isAbsolute(relativeManifest)) {
throw new Error(`构建清单越出工作区:${argument}`)
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
validateAssetBuildManifest(manifest, workspace)
const supportedModes = new Set(['opaque-resize', 'opaque-cover-crop', 'warm-gold-frame-extract'])
for (const asset of manifest.assets) {
if (!supportedModes.has(asset.processing.mode)) {
throw new Error(`通用 raster 构建器不支持模式:${asset.processing.mode}`)
}
}
const python = resolvePythonExecutable({ pipelineDirectory })
const reportPath = path.join(pipelineDirectory, 'generated', manifest.family, 'quality-report.json')
// Node 只编排严格 schema、锁定的 Python 入口和质量报告;像素算法只存在于 Python。
runPythonCommand({
executable: python,
args: [path.join(scriptDirectory, 'build_raster_assets.py'), manifestPath, '--workspace', workspace],
cwd: workspace,
})
runPythonCommand({
executable: python,
args: [path.join(scriptDirectory, 'verify-assets.py'), manifestPath, '--workspace', workspace, '--report', reportPath],
cwd: workspace,
})
@@ -0,0 +1,28 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { validateAssetBuildManifest } from './asset-build-manifest.mjs'
import { resolvePythonExecutable, runPythonCommand } from './python-runtime.mjs'
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
const pipelineDirectory = path.resolve(scriptDirectory, '..')
const workspace = path.resolve(pipelineDirectory, '..')
const manifestPath = path.join(pipelineDirectory, 'manifests', 'shared-scroll-skins-v3.json')
const reportPath = path.join(pipelineDirectory, 'generated', 'shared-scroll-skins-v3', 'quality-report.json')
const python = resolvePythonExecutable({ pipelineDirectory })
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
validateAssetBuildManifest(manifest, workspace)
// Node 只负责编排锁定的清单和 Python 工具;像素处理与质量分析各自只有一个实现。
function run(script, args) {
runPythonCommand({
executable: python,
args: [path.join(scriptDirectory, script), ...args],
cwd: workspace,
})
}
run('build_scroll_skins.py', [manifestPath, '--workspace', workspace])
run('verify-assets.py', [manifestPath, '--workspace', workspace, '--report', reportPath])
@@ -1,128 +0,0 @@
"""Deterministically build G01 background candidates from saved ImageGen masters."""
import argparse
import hashlib
import json
from pathlib import Path
from typing import Any
from PIL import Image, PngImagePlugin
def process_chroma_image(source: Image.Image, config: dict[str, Any]) -> Image.Image:
"""Convert a green-screen master to clean RGBA with edge despill."""
start = int(config["dominanceStart"])
end = int(config["dominanceEnd"])
allowance = int(config["despillAllowance"])
if end <= start:
raise ValueError("dominanceEnd must be greater than dominanceStart")
output = Image.new("RGBA", source.size, (0, 0, 0, 0))
converted = []
for red, green, blue, source_alpha in source.convert("RGBA").get_flattened_data():
dominance = green - max(red, blue)
if green >= 100 and dominance > start:
removal = min(1.0, (dominance - start) / (end - start))
alpha = round(source_alpha * (1.0 - removal))
if alpha <= 2:
converted.append((0, 0, 0, 0))
continue
green = min(green, max(red, blue) + allowance)
converted.append((red, green, blue, alpha))
else:
converted.append((red, green, blue, source_alpha))
output.putdata(converted)
return output
def process_opaque_image(source: Image.Image, output_size: tuple[int, int] | None = None) -> Image.Image:
"""Normalize a paper-backed master to deterministic opaque RGBA."""
output = source.convert("RGBA")
if output_size is not None and output.size != output_size:
output = output.resize(output_size, Image.Resampling.LANCZOS)
output.putalpha(255)
return output
def save_png(image: Image.Image, path: Path) -> None:
"""Save a deterministic PNG with an explicit standard-sRGB chunk."""
path.parent.mkdir(parents=True, exist_ok=True)
png_info = PngImagePlugin.PngInfo()
png_info.add(b"sRGB", b"\x00")
image.save(path, format="PNG", optimize=True, compress_level=9, pnginfo=png_info)
def _resolve_inside(workspace: Path, relative_path: str) -> Path:
absolute = (workspace / relative_path).resolve()
try:
absolute.relative_to(workspace.resolve())
except ValueError as error:
raise ValueError(f"path escapes workspace: {relative_path}") from error
return absolute
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def build(manifest_path: Path, workspace: Path, report_path: Path) -> list[dict[str, Any]]:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
chroma = manifest["chromaKey"]
reports = []
for candidate in manifest["candidates"]:
source_path = _resolve_inside(workspace, candidate["master"])
output_path = _resolve_inside(workspace, candidate["generatedOutput"])
with Image.open(source_path) as opened:
expected = candidate["sourcePixels"]
if opened.size != (expected["width"], expected["height"]):
raise ValueError(
f"{candidate['id']} expected {expected['width']}x{expected['height']}, got {opened.width}x{opened.height}"
)
if candidate["processingMode"] == "chroma-key":
output = process_chroma_image(opened, chroma)
elif candidate["processingMode"] in {"opaque-paper", "opaque-paper-resize"}:
output_size = None
if candidate["processingMode"] == "opaque-paper-resize":
target = candidate["outputPixels"]
output_size = (target["width"], target["height"])
output = process_opaque_image(opened, output_size)
else:
raise ValueError(f"unsupported processingMode: {candidate['processingMode']}")
save_png(output, output_path)
reports.append(
{
"id": candidate["id"],
"mode": candidate["processingMode"],
"source": candidate["master"],
"output": candidate["generatedOutput"],
"width": output.width,
"height": output.height,
"bytes": output_path.stat().st_size,
"sha256": _sha256(output_path),
}
)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(reports, ensure_ascii=False, indent=2), encoding="utf-8")
return reports
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("manifest", type=Path)
parser.add_argument("--workspace", type=Path, required=True)
parser.add_argument("--report", type=Path, required=True)
args = parser.parse_args()
reports = build(args.manifest, args.workspace.resolve(), args.report)
for report in reports:
print(f"G01-BACKGROUND-BUILD PASS {report['id']} {report['width']}x{report['height']} {report['sha256']}")
if __name__ == "__main__":
main()
@@ -1,61 +0,0 @@
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parents[2]
MANIFEST = ROOT / "design-pipeline/manifests/module-page-backgrounds.json"
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def normalize(source: Path, output: Path, width: int, height: int) -> None:
with Image.open(source) as image:
image = image.convert("RGB")
scale = max(width / image.width, height / image.height)
resized = image.resize(
(round(image.width * scale), round(image.height * scale)),
Image.Resampling.LANCZOS,
)
left = max(0, (resized.width - width) // 2)
top = max(0, (resized.height - height) // 2)
normalized = resized.crop((left, top, left + width, top + height))
output.parent.mkdir(parents=True, exist_ok=True)
normalized.save(output, format="PNG", optimize=True)
def main() -> None:
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
width = int(manifest["width"])
height = int(manifest["height"])
report = []
for item in manifest["backgrounds"]:
source = ROOT / item["source"]
output = ROOT / item["output"]
if not source.is_file():
raise FileNotFoundError(source)
normalize(source, output, width, height)
report.append(
{
"module": item["module"],
"source": item["source"],
"output": item["output"],
"size": [width, height],
"sha256": sha256(output),
}
)
print(json.dumps(report, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1,132 @@
"""按 schema v3 清单确定性生成不透明长背景与 G01 暖金边框。"""
import argparse
import json
from pathlib import Path
from typing import Any
from PIL import Image, PngImagePlugin
def _resolve_inside(workspace: Path, relative_path: str) -> Path:
"""解析仓库相对路径,并在任何读写发生前拒绝目录逃逸。"""
absolute = (workspace / relative_path).resolve()
try:
absolute.relative_to(workspace.resolve())
except ValueError as error:
raise ValueError(f"path escapes workspace: {relative_path}") from error
return absolute
def build_opaque_resize(source: Image.Image, output_size: tuple[int, int]) -> Image.Image:
"""把同宽高比母版按 LANCZOS 直接缩放为无透明通道的 RGB 正式图。"""
image = source.convert("RGB")
if image.size != output_size:
image = image.resize(output_size, Image.Resampling.LANCZOS)
return image
def build_opaque_cover_crop(source: Image.Image, output_size: tuple[int, int]) -> Image.Image:
"""等比 cover 后从中心裁切;算法与旧五模块构建器的可见像素完全一致。"""
width, height = output_size
image = source.convert("RGB")
scale = max(width / image.width, height / image.height)
resized = image.resize(
(round(image.width * scale), round(image.height * scale)),
Image.Resampling.LANCZOS,
)
left = max(0, (resized.width - width) // 2)
top = max(0, (resized.height - height) // 2)
return resized.crop((left, top, left + width, top + height))
def extract_warm_gold_frame(source: Image.Image, config: dict[str, Any]) -> Image.Image:
"""复刻旧 Sharp 暖金边框阈值,同时清零全透明像素的隐藏 RGB。"""
image = source.convert("RGBA")
width, height = image.size
border_band = int(config["borderBand"])
output = Image.new("RGBA", image.size, (0, 0, 0, 0))
result: list[tuple[int, int, int, int]] = []
for index, (red, green, blue, source_alpha) in enumerate(image.get_flattened_data()):
x = index % width
y = index // width
distance_to_edge = min(x, y, width - 1 - x, height - 1 - y)
warm_gold = (
red > green > blue
and red - green >= int(config["redGreenMin"])
and green - blue >= int(config["greenBlueMin"])
and red - blue >= int(config["redBlueMin"])
and red < int(config["redMaxExclusive"])
and blue < int(config["blueMaxExclusive"])
)
if distance_to_edge >= border_band or not warm_gold:
result.append((0, 0, 0, 0))
continue
edge_alpha = max(
0,
min(
255,
(red - blue - int(config["alphaOffset"])) * int(config["alphaScale"]),
),
)
alpha = min(source_alpha, edge_alpha)
result.append((red, green, blue, alpha) if alpha else (0, 0, 0, 0))
output.putdata(result)
return output
def save_png(image: Image.Image, path: Path) -> None:
"""以固定压缩参数和标准 sRGB chunk 保存,保证同输入得到同字节。"""
path.parent.mkdir(parents=True, exist_ok=True)
png_info = PngImagePlugin.PngInfo()
png_info.add(b"sRGB", b"\x00")
image.save(path, format="PNG", optimize=True, compress_level=9, pnginfo=png_info)
def build_manifest(manifest_path: Path, workspace: Path) -> None:
"""执行已经由 Node 严格校验的清单,并再次核对真实母版像素尺寸。"""
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
for asset in manifest["assets"]:
source_path = _resolve_inside(workspace, asset["source"])
output_path = _resolve_inside(workspace, asset["output"])
expected_source = asset["sourcePixels"]
output_pixels = asset["outputPixels"]
output_size = (int(output_pixels["width"]), int(output_pixels["height"]))
with Image.open(source_path) as source:
expected_size = (int(expected_source["width"]), int(expected_source["height"]))
if source.size != expected_size:
raise ValueError(
f"{asset['id']} source expected {expected_size[0]}x{expected_size[1]}, "
f"got {source.width}x{source.height}"
)
mode = asset["processing"]["mode"]
if mode == "opaque-resize":
output = build_opaque_resize(source, output_size)
elif mode == "opaque-cover-crop":
output = build_opaque_cover_crop(source, output_size)
elif mode == "warm-gold-frame-extract":
output = extract_warm_gold_frame(source, asset["processing"])
else:
raise ValueError(f"unsupported raster processing mode: {mode}")
if output.size != output_size:
raise ValueError(f"{asset['id']} produced unexpected output size: {output.size}")
save_png(output, output_path)
print(f"BUILT {asset['id']} -> {asset['output']}")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("manifest", type=Path)
parser.add_argument("--workspace", type=Path, required=True)
args = parser.parse_args()
build_manifest(args.manifest.resolve(), args.workspace.resolve())
if __name__ == "__main__":
main()
+10 -1
View File
@@ -1,4 +1,4 @@
"""Build exact A01 scroll-skin outputs from chroma-backed visual masters."""
"""从锁定的色键母版确定性生成项目共享卷轴资产。"""
import argparse
import json
@@ -127,7 +127,16 @@ def build_asset(asset: dict, workspace: Path) -> None:
source_path = workspace / asset["source"]
output_path = workspace / asset["output"]
if processing["mode"] != "chroma-stretch":
raise ValueError(f"unsupported scroll processing mode: {processing['mode']}")
with Image.open(source_path) as source:
expected = asset["sourcePixels"]
if source.size != (expected["width"], expected["height"]):
raise ValueError(
f"{asset['id']} source expected {expected['width']}x{expected['height']}, "
f"got {source.width}x{source.height}"
)
cleaned = remove_chroma_background(
source,
parse_hex_color(processing["keyColor"]),
-121
View File
@@ -1,121 +0,0 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
const allowedAssetClasses = new Set([
'code-native',
'fixed-bitmap',
'transparent-overlay',
'nine-slice',
'tile-texture'
])
const requireObject = (value, label) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${label} must be an object`)
}
return value
}
const requireString = (value, label) => {
if (typeof value !== 'string' || value.trim() === '') throw new Error(`${label} must be a non-empty string`)
return value
}
const requirePositiveNumber = (value, label) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
throw new Error(`${label} must be a positive number`)
}
return value
}
const resolveWorkspacePath = (workspace, relativePath, label) => {
requireString(relativePath, label)
const absolutePath = path.resolve(workspace, relativePath)
const relative = path.relative(workspace, absolutePath)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`${label} escapes workspace: ${relativePath}`)
}
return absolutePath
}
const requireBoolean = (value, label) => {
if (typeof value !== 'boolean') throw new Error(`${label} must be boolean`)
return value
}
export const validateManifest = (manifest, workspace) => {
requireObject(manifest, 'manifest')
if (manifest.schemaVersion !== 2) throw new Error('schemaVersion must be 2')
requireString(manifest.page, 'page')
requireObject(manifest.runtime, 'runtime')
requireString(manifest.runtime.url, 'runtime.url')
requirePositiveNumber(manifest.runtime.chromePort, 'runtime.chromePort')
if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) throw new Error('assets must be a non-empty array')
const ids = new Set()
for (const asset of manifest.assets) {
requireObject(asset, 'asset')
const id = requireString(asset.id, 'asset.id')
if (ids.has(id)) throw new Error(`duplicate asset id: ${id}`)
ids.add(id)
if (!allowedAssetClasses.has(asset.assetClass)) throw new Error(`unsupported assetClass: ${asset.assetClass}`)
resolveWorkspacePath(workspace, asset.source, `${id}.source`)
resolveWorkspacePath(workspace, asset.output, `${id}.output`)
const logicalSlot = requireObject(asset.logicalSlot, `${id}.logicalSlot`)
const outputPixels = requireObject(asset.outputPixels, `${id}.outputPixels`)
const widthRpx = requirePositiveNumber(logicalSlot.widthRpx, `${id}.logicalSlot.widthRpx`)
const heightRpx = requirePositiveNumber(logicalSlot.heightRpx, `${id}.logicalSlot.heightRpx`)
const width = requirePositiveNumber(outputPixels.width, `${id}.outputPixels.width`)
const height = requirePositiveNumber(outputPixels.height, `${id}.outputPixels.height`)
const render = requireObject(asset.render, `${id}.render`)
requireString(render.scalePolicy, `${id}.render.scalePolicy`)
requireString(render.uniMode, `${id}.render.uniMode`)
requireBoolean(render.allowDistortion, `${id}.render.allowDistortion`)
if (render.scalePolicy === 'uniform-only' && render.uniMode === 'scaleToFill') {
throw new Error(`${id} uniform-only asset cannot use scaleToFill`)
}
const ratioDrift = Math.abs((width / height) / (widthRpx / heightRpx) - 1)
if (asset.assetClass === 'fixed-bitmap' && ratioDrift > 0.005) {
throw new Error(`${id} ratio drift exceeds 0.5%`)
}
const alpha = requireObject(asset.alpha, `${id}.alpha`)
requireBoolean(alpha.required, `${id}.alpha.required`)
requirePositiveNumber(alpha.transparentOuterPadding, `${id}.alpha.transparentOuterPadding`)
if (!Number.isInteger(alpha.cornerMaxAlpha) || alpha.cornerMaxAlpha < 0 || alpha.cornerMaxAlpha > 255) {
throw new Error(`${id}.alpha.cornerMaxAlpha must be an integer from 0 to 255`)
}
const edge = requireObject(asset.edge, `${id}.edge`)
requireBoolean(edge.forbidChromaResidue, `${id}.edge.forbidChromaResidue`)
requireBoolean(edge.forbidLightFringe, `${id}.edge.forbidLightFringe`)
requireBoolean(edge.premultipliedAlphaCheck, `${id}.edge.premultipliedAlphaCheck`)
const quality = requireObject(asset.quality, `${id}.quality`)
requirePositiveNumber(quality.maxBytes, `${id}.quality.maxBytes`)
requireString(quality.colorSpace, `${id}.quality.colorSpace`)
const processing = requireObject(asset.processing, `${id}.processing`)
requirePositiveNumber(processing.trim, `${id}.processing.trim`)
requirePositiveNumber(processing.capWidth, `${id}.processing.capWidth`)
const runtime = requireObject(asset.runtime, `${id}.runtime`)
requireString(runtime.selector, `${id}.runtime.selector`)
requireString(runtime.imageSelector, `${id}.runtime.imageSelector`)
if (!Array.isArray(asset.consumers) || asset.consumers.length === 0) {
throw new Error(`${id}.consumers must be a non-empty array`)
}
for (const consumer of asset.consumers) resolveWorkspacePath(workspace, consumer, `${id}.consumer`)
}
return manifest
}
export const loadAndValidateManifest = async (filePath, workspace) => {
const manifest = JSON.parse(await readFile(filePath, 'utf8'))
return validateManifest(manifest, workspace)
}
@@ -0,0 +1,74 @@
import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
function canRunPython(executable) {
const result = spawnSync(executable, ['--version'], {
encoding: 'utf8',
stdio: 'ignore',
})
return !result.error && result.status === 0
}
/**
* 解析设计构建管线唯一可用的 Python 入口。
*
* 顺序必须保持稳定:显式配置 > 项目虚拟环境 > Windows Python Manager >
* 系统命令。这样既尊重调用方选择,也不会把 WindowsApps 的零字节执行别名
* 误判为真实解释器。每个候选都必须实际执行 `--version`,文件存在本身不算可用。
*/
export function resolvePythonExecutable({
pipelineDirectory,
environment = process.env,
platform = process.platform,
pathExists = fs.existsSync,
canRun = canRunPython,
} = {}) {
if (!pipelineDirectory) throw new Error('解析 Python 入口时缺少 design-pipeline 目录')
const configured = environment.PYTHON?.trim()
if (configured) {
if (canRun(configured)) return configured
throw new Error(`PYTHON 指定的解释器不可用:${configured}`)
}
const pathApi = platform === 'win32' ? path.win32 : path.posix
const candidates = []
const virtualEnvironment = platform === 'win32'
? pathApi.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
: pathApi.join(pipelineDirectory, '.venv', 'bin', 'python')
if (pathExists(virtualEnvironment)) candidates.push(virtualEnvironment)
if (platform === 'win32' && environment.LOCALAPPDATA) {
const managerPython = pathApi.join(environment.LOCALAPPDATA, 'Python', 'bin', 'python.exe')
if (pathExists(managerPython)) candidates.push(managerPython)
}
candidates.push(platform === 'win32' ? 'python' : 'python3', 'python')
for (const candidate of new Set(candidates)) {
if (canRun(candidate)) return candidate
}
throw new Error(
'未找到可用的 Python。请设置 PYTHON 为真实解释器路径,或在 design-pipeline/.venv 中安装项目虚拟环境。',
)
}
/**
* 通过统一入口执行 Python,保证任何构建和测试都不会向源码目录写入 `.pyc`。
* 调用方只提供业务参数;`-B`、进程错误和退出码转换由这里集中负责。
*/
export function runPythonCommand({ executable, args, cwd, spawn = spawnSync } = {}) {
if (typeof executable !== 'string' || executable.trim() === '') throw new Error('执行 Python 时缺少解释器')
if (!Array.isArray(args)) throw new Error('执行 Python 时 args 必须为数组')
if (typeof cwd !== 'string' || cwd.trim() === '') throw new Error('执行 Python 时缺少工作目录')
const result = spawn(executable, ['-B', ...args], {
cwd,
encoding: 'utf8',
stdio: 'inherit',
})
if (result.error) throw new Error(`无法启动 Python${executable}):${result.error.message}`)
if (result.status !== 0) throw new Error(`Python 命令执行失败,退出码:${result.status ?? 'unknown'}`)
return result
}
@@ -1,31 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { loadAndValidateManifest } from './manifest-v2.mjs'
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
const pipelineDirectory = path.resolve(scriptDirectory, '..')
const workspace = path.resolve(pipelineDirectory, '..')
const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-buttons-v2.json')
const reportPath = path.join(pipelineDirectory, 'generated', 'a01-buttons-v2', 'quality-report.json')
await loadAndValidateManifest(manifestPath, workspace)
const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python')
function runPython(script, args) {
const result = spawnSync(python, [path.join(scriptDirectory, script), ...args], {
cwd: workspace,
encoding: 'utf8',
stdio: 'inherit',
})
if (result.error) {
throw new Error(`无法启动 Python${python}):${result.error.message}`)
}
if (result.status !== 0) process.exit(result.status ?? 1)
}
runPython('rebuild_a01_buttons.py', [manifestPath, '--workspace', workspace])
runPython('verify-assets.py', [manifestPath, '--workspace', workspace, '--report', reportPath])
@@ -1,89 +0,0 @@
"""Rebuild A01 button skins with deterministic horizontal three-slice scaling."""
import argparse
import json
from pathlib import Path
from PIL import Image, PngImagePlugin
def rebuild_button(
source_path: Path,
output_path: Path,
*,
output_size: tuple[int, int],
trim: int,
padding: int,
cap_width: int,
) -> None:
"""Trim contaminated edges, preserve both caps, and stretch only the center."""
with Image.open(source_path) as opened:
source = opened.convert("RGBA")
if trim < 0 or trim * 2 >= min(source.size):
raise ValueError(f"invalid trim {trim} for source size {source.size}")
if trim:
source = source.crop((trim, trim, source.width - trim, source.height - trim))
if cap_width <= 0 or cap_width * 2 >= source.width:
raise ValueError(f"invalid capWidth {cap_width} for cropped width {source.width}")
output_width, output_height = output_size
inner_width = output_width - padding * 2
inner_height = output_height - padding * 2
if inner_width <= 0 or inner_height <= 0:
raise ValueError(f"padding {padding} leaves no drawable area in {output_size}")
target_cap_width = max(1, round(cap_width * inner_height / source.height))
if target_cap_width * 2 >= inner_width:
raise ValueError("scaled caps leave no room for the center slice")
left = source.crop((0, 0, cap_width, source.height))
center = source.crop((cap_width, 0, source.width - cap_width, source.height))
right = source.crop((source.width - cap_width, 0, source.width, source.height))
resampling = Image.Resampling.LANCZOS
left = left.resize((target_cap_width, inner_height), resampling)
center = center.resize((inner_width - target_cap_width * 2, inner_height), resampling)
right = right.resize((target_cap_width, inner_height), resampling)
output = Image.new("RGBA", output_size, (0, 0, 0, 0))
output.alpha_composite(left, (padding, padding))
output.alpha_composite(center, (padding + target_cap_width, padding))
output.alpha_composite(right, (output_width - padding - target_cap_width, padding))
output_path.parent.mkdir(parents=True, exist_ok=True)
png_info = PngImagePlugin.PngInfo()
png_info.add(b"sRGB", b"\x00")
output.save(output_path, format="PNG", optimize=True, pnginfo=png_info)
def rebuild_manifest(manifest_path: Path, workspace_root: Path) -> None:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
for asset in manifest["assets"]:
processing = asset["processing"]
alpha = asset["alpha"]
pixels = asset["outputPixels"]
source = workspace_root / asset["source"]
output = workspace_root / asset["output"]
rebuild_button(
source,
output,
output_size=(pixels["width"], pixels["height"]),
trim=processing["trim"],
padding=alpha["transparentOuterPadding"],
cap_width=processing["capWidth"],
)
print(f"REBUILT {asset['id']} -> {asset['output']}")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("manifest", type=Path)
parser.add_argument("--workspace", type=Path, required=True)
args = parser.parse_args()
rebuild_manifest(args.manifest.resolve(), args.workspace.resolve())
if __name__ == "__main__":
main()
@@ -0,0 +1,14 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { resolvePythonExecutable, runPythonCommand } from './python-runtime.mjs'
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
const pipelineDirectory = path.resolve(scriptDirectory, '..')
const python = resolvePythonExecutable({ pipelineDirectory })
runPythonCommand({
executable: python,
args: ['-m', 'unittest', 'discover', '-s', 'tests', '-p', 'test_*.py'],
cwd: pipelineDirectory,
})
@@ -0,0 +1,153 @@
import { readFile, readdir } from 'node:fs/promises'
import path from 'node:path'
import { validateAssetBuildManifest } from './asset-build-manifest.mjs'
const formalManifestKinds = new Set(['asset-build-manifest', 'runtime-asset-inventory'])
const assertOnlyFields = (value, fields, label) => {
for (const field of Object.keys(value)) {
if (!fields.has(field)) throw new Error(`${label} contains unknown field: ${field}`)
}
}
const resolveInsideWorkspace = (workspace, candidate, label) => {
if (typeof candidate !== 'string' || candidate.trim() === '') {
throw new Error(`${label} must be a non-empty string`)
}
const absolutePath = path.resolve(workspace, candidate)
const relative = path.relative(workspace, absolutePath)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`${label} escapes workspace: ${candidate}`)
}
return absolutePath
}
const readManifest = async (manifestPath) => {
try {
return JSON.parse(await readFile(manifestPath, 'utf8'))
} catch (error) {
if (error.code === 'ENOENT') throw new Error(`missing manifest: ${manifestPath}`)
throw error
}
}
const validateDirectAsset = (asset, workspace) => {
if (!asset || typeof asset !== 'object' || Array.isArray(asset)) throw new Error('runtime asset must be an object')
assertOnlyFields(
asset,
new Set(['id', 'output', 'width', 'height', 'alpha', 'bytes', 'sha256', 'provenance', 'rebuildable']),
'runtime asset',
)
for (const key of ['id', 'output']) {
if (typeof asset[key] !== 'string' || asset[key].trim() === '') {
throw new Error(`runtime asset ${key} must be a non-empty string`)
}
}
resolveInsideWorkspace(workspace, asset.output, `${asset.id}.output`)
for (const key of ['width', 'height', 'bytes']) {
if (!Number.isInteger(asset[key]) || asset[key] <= 0) throw new Error(`${asset.id}.${key} must be a positive integer`)
}
if (typeof asset.alpha !== 'boolean') throw new Error(`${asset.id}.alpha must be boolean`)
if (asset.provenance !== 'committed-binary') {
throw new Error(`${asset.id}.provenance must be committed-binary`)
}
if (asset.rebuildable !== false) throw new Error(`${asset.id}.rebuildable must be false`)
if (!/^[a-f0-9]{64}$/.test(asset.sha256)) throw new Error(`${asset.id}.sha256 must be lowercase SHA256`)
return asset
}
export const expandRuntimeAssetInventory = async (rootManifestPath, workspace) => {
const workspacePath = path.resolve(workspace)
const loaded = new Set()
const stack = []
const ids = new Map()
const outputs = new Map()
const manifests = []
const addAsset = (asset, owner) => {
if (ids.has(asset.id)) {
throw new Error(`duplicate asset id: ${asset.id} (${ids.get(asset.id)}, ${owner})`)
}
if (outputs.has(asset.output)) {
throw new Error(`duplicate output: ${asset.output} (${outputs.get(asset.output).owner}, ${owner})`)
}
ids.set(asset.id, owner)
outputs.set(asset.output, { ...asset, owner })
}
// 递归展开只处理机器清单之间的依赖;页面消费者始终从源码扫描得到,避免双写。
const visit = async (manifestPath) => {
const absolutePath = resolveInsideWorkspace(workspacePath, manifestPath, 'manifest')
if (stack.includes(absolutePath)) {
throw new Error(`import cycle: ${[...stack, absolutePath].join(' -> ')}`)
}
if (loaded.has(absolutePath)) throw new Error(`duplicate import: ${absolutePath}`)
stack.push(absolutePath)
loaded.add(absolutePath)
manifests.push(absolutePath)
const manifest = await readManifest(absolutePath)
if (manifest.kind === 'asset-build-manifest') {
validateAssetBuildManifest(manifest, workspacePath)
for (const asset of manifest.assets) {
addAsset(
{
id: asset.id,
output: asset.output,
width: asset.outputPixels.width,
height: asset.outputPixels.height,
// 不透明构建模式依法不声明 alpha;只有透明模式存在该对象,避免把“字段缺失”误判为清单损坏。
alpha: asset.alpha?.required ?? false,
maxBytes: asset.quality.maxBytes,
provenance: 'generated-from-manifest',
rebuildable: true,
},
absolutePath,
)
}
} else if (manifest.kind === 'runtime-asset-inventory') {
assertOnlyFields(manifest, new Set(['schemaVersion', 'kind', 'scope', 'imports', 'assets']), 'runtime manifest')
if (manifest.schemaVersion !== 1) throw new Error('runtime inventory schemaVersion must be 1')
if (typeof manifest.scope !== 'string' || manifest.scope.trim() === '') throw new Error('runtime inventory scope is required')
if (!Array.isArray(manifest.imports) || !Array.isArray(manifest.assets)) {
throw new Error('runtime inventory imports and assets must be arrays')
}
for (const asset of manifest.assets) addAsset(validateDirectAsset(asset, workspacePath), absolutePath)
for (const imported of manifest.imports) await visit(imported)
} else {
throw new Error(`unsupported manifest kind: ${manifest.kind}`)
}
stack.pop()
}
await visit(path.relative(workspacePath, path.resolve(rootManifestPath)))
return { manifests, assets: [...outputs.values()] }
}
export const validateRuntimeAssetRegistry = async (rootManifestPath, workspace, manifestsDirectory) => {
const workspacePath = path.resolve(workspace)
const directory = resolveInsideWorkspace(workspacePath, manifestsDirectory, 'manifests directory')
const rootManifest = await readManifest(path.resolve(rootManifestPath))
if (rootManifest.kind !== 'runtime-asset-inventory' || rootManifest.scope !== 'schema-v3') {
throw new Error('registry root scope must be schema-v3')
}
const inventory = await expandRuntimeAssetInventory(rootManifestPath, workspacePath)
const registered = new Set(inventory.manifests.map((manifest) => path.resolve(manifest)))
// 顶层注册表必须覆盖目录内每一份正式 owner。这样新增清单若没有接入全局图会立即失败,
// output 与 id 的唯一性也就不再局限于某个业务域的 imports 闭包。
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (!entry.isFile() || path.extname(entry.name).toLowerCase() !== '.json') continue
const manifestPath = path.join(directory, entry.name)
const manifest = await readManifest(manifestPath)
if (formalManifestKinds.has(manifest.kind)) {
if (!registered.has(path.resolve(manifestPath))) throw new Error(`unregistered manifest: ${manifestPath}`)
continue
}
throw new Error(`undeclared legacy manifest: ${manifestPath}`)
}
return inventory
}
@@ -0,0 +1,15 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { validateAssetBuildManifest } from './asset-build-manifest.mjs'
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
const workspace = path.resolve(scriptDirectory, '..', '..')
const manifestArgument = process.argv[2]
if (!manifestArgument) throw new Error('Usage: node validate-asset-build-manifest.mjs <workspace-relative-manifest>')
const manifestPath = path.resolve(workspace, manifestArgument)
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
validateAssetBuildManifest(manifest, workspace)
process.stdout.write(`ASSET-BUILD-MANIFEST PASS ${path.relative(workspace, manifestPath).replaceAll('\\', '/')}\n`)
@@ -1,15 +0,0 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { loadAndValidateManifest } from './manifest-v2.mjs'
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
const pipelineDirectory = path.resolve(scriptDirectory, '..')
const workspace = path.resolve(pipelineDirectory, '..')
const manifestArgument = process.argv[2]
if (!manifestArgument) throw new Error('Usage: node validate-manifest-v2.mjs <manifest>')
const manifestPath = path.resolve(pipelineDirectory, manifestArgument)
await loadAndValidateManifest(manifestPath, workspace)
process.stdout.write(`MANIFEST-V2 PASS ${path.relative(workspace, manifestPath).replaceAll('\\', '/')}\n`)
@@ -0,0 +1,16 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { validateRuntimeAssetRegistry } from './runtime-asset-inventory.mjs'
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
const workspace = path.resolve(scriptDirectory, '..', '..')
const manifestArgument = process.argv[2]
if (!manifestArgument) throw new Error('Usage: node validate-runtime-asset-inventory.mjs <workspace-relative-manifest>')
const inventory = await validateRuntimeAssetRegistry(
path.resolve(workspace, manifestArgument),
workspace,
path.join(workspace, 'design-pipeline', 'manifests'),
)
process.stdout.write(`${JSON.stringify(inventory)}\n`)
@@ -1,22 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { loadAndValidateManifest } from './manifest-v2.mjs'
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
const pipelineDirectory = path.resolve(scriptDirectory, '..')
const workspace = path.resolve(pipelineDirectory, '..')
const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-scroll-skins-v3.json')
const reportPath = path.join(pipelineDirectory, 'generated', 'a01-scroll-skins-v3', 'quality-report.json')
const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python')
await loadAndValidateManifest(manifestPath, workspace)
const result = spawnSync(
python,
[path.join(scriptDirectory, 'verify-assets.py'), manifestPath, '--workspace', workspace, '--report', reportPath],
{ cwd: workspace, encoding: 'utf8', stdio: 'inherit' }
)
if (result.error) throw new Error(`无法启动 Python${python}):${result.error.message}`)
if (result.status !== 0) process.exit(result.status ?? 1)
-24
View File
@@ -1,24 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { loadAndValidateManifest } from './manifest-v2.mjs'
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
const pipelineDirectory = path.resolve(scriptDirectory, '..')
const workspace = path.resolve(pipelineDirectory, '..')
const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-buttons-v2.json')
const reportPath = path.join(pipelineDirectory, 'generated', 'a01-buttons-v2', 'quality-report.json')
await loadAndValidateManifest(manifestPath, workspace)
const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python')
const result = spawnSync(
python,
[path.join(scriptDirectory, 'verify-assets.py'), manifestPath, '--workspace', workspace, '--report', reportPath],
{ cwd: workspace, encoding: 'utf8', stdio: 'inherit' }
)
if (result.error) throw new Error(`无法启动 Python${python}):${result.error.message}`)
if (result.status !== 0) process.exit(result.status ?? 1)
+3 -3
View File
@@ -1,4 +1,4 @@
"""Verify generated assets declared in a manifest v2 file."""
"""校验 schema v3 生成型资产,并写出不含机器绝对路径的确定性报告。"""
import argparse
import json
@@ -23,13 +23,13 @@ def main() -> None:
if not output.is_file():
report = {
"id": asset["id"],
"path": str(output),
"path": Path(asset["output"]).as_posix(),
"errors": ["output file is missing"],
"warnings": [],
"metrics": {},
}
else:
report = analyze_asset(output, asset)
report = analyze_asset(output, asset, args.workspace)
reports.append(report)
if report["errors"]:
failed = True
@@ -0,0 +1,21 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { validateAssetBuildManifest } from './asset-build-manifest.mjs'
import { resolvePythonExecutable, runPythonCommand } from './python-runtime.mjs'
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
const pipelineDirectory = path.resolve(scriptDirectory, '..')
const workspace = path.resolve(pipelineDirectory, '..')
const manifestPath = path.join(pipelineDirectory, 'manifests', 'shared-scroll-skins-v3.json')
const reportPath = path.join(pipelineDirectory, 'generated', 'shared-scroll-skins-v3', 'quality-report.json')
const python = resolvePythonExecutable({ pipelineDirectory })
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
validateAssetBuildManifest(manifest, workspace)
runPythonCommand({
executable: python,
args: [path.join(scriptDirectory, 'verify-assets.py'), manifestPath, '--workspace', workspace, '--report', reportPath],
cwd: workspace,
})
@@ -0,0 +1,131 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { validateAssetBuildManifest } from '../scripts/asset-build-manifest.mjs'
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
const workspace = path.resolve(testDirectory, '..', '..')
const baseAsset = () => ({
id: 'test-asset',
source: 'docs/design/assets/source.png',
output: 'static/assets/output.png',
sourcePixels: { width: 1536, height: 3840 },
outputPixels: { width: 1440, height: 3600 },
quality: { maxBytes: 7000000, colorSpace: 'sRGB' },
})
// schema v3 以 processing.mode 为严格判别字段。每个分支只允许自身真正消费的
// 参数,避免给不透明背景伪造透明边、色键或切片字段来“凑齐”旧结构。
const chromaAsset = () => ({
...baseAsset(),
alpha: { required: true, transparentOuterPadding: 6, cornerMaxAlpha: 0 },
edge: { forbidChromaResidue: true, forbidLightFringe: true, premultipliedAlphaCheck: true },
processing: {
mode: 'chroma-stretch',
capWidth: 380,
keyColor: '#00FF00',
keyTolerance: 96,
},
})
const opaqueResizeAsset = () => ({
...baseAsset(),
processing: { mode: 'opaque-resize', resample: 'lanczos', outputMode: 'RGB' },
})
const opaqueCoverAsset = () => ({
...baseAsset(),
processing: {
mode: 'opaque-cover-crop',
resample: 'lanczos',
anchor: 'center',
outputMode: 'RGB',
},
})
const warmFrameAsset = () => ({
...baseAsset(),
alpha: { required: true, transparentOuterPadding: 0, cornerMaxAlpha: 0 },
edge: { forbidChromaResidue: false, forbidLightFringe: false, premultipliedAlphaCheck: true },
processing: {
mode: 'warm-gold-frame-extract',
borderBand: 110,
redGreenMin: 15,
greenBlueMin: 12,
redBlueMin: 35,
redMaxExclusive: 245,
blueMaxExclusive: 180,
alphaOffset: 25,
alphaScale: 6,
outputMode: 'RGBA',
},
})
const manifestWith = (asset) => ({
schemaVersion: 3,
kind: 'asset-build-manifest',
family: 'test-family-v3',
assets: [asset],
})
test('接受四种职责严格分离的 schema v3 处理模式', () => {
for (const factory of [chromaAsset, opaqueResizeAsset, opaqueCoverAsset, warmFrameAsset]) {
const manifest = manifestWith(factory())
assert.equal(validateAssetBuildManifest(manifest, workspace), manifest)
}
})
test('拒绝没有 processing.mode 的旧 schema v3 形状', () => {
const asset = chromaAsset()
delete asset.processing.mode
assert.throws(() => validateAssetBuildManifest(manifestWith(asset), workspace), /processing\.mode/i)
})
test('不透明分支拒绝透明度、边缘和其他模式的参数', () => {
const withAlpha = opaqueResizeAsset()
withAlpha.alpha = chromaAsset().alpha
assert.throws(() => validateAssetBuildManifest(manifestWith(withAlpha), workspace), /unknown field/i)
const withAnchor = opaqueResizeAsset()
withAnchor.processing.anchor = 'center'
assert.throws(() => validateAssetBuildManifest(manifestWith(withAnchor), workspace), /unknown field/i)
const withColorKey = opaqueCoverAsset()
withColorKey.processing.keyColor = '#00FF00'
assert.throws(() => validateAssetBuildManifest(manifestWith(withColorKey), workspace), /unknown field/i)
})
test('透明分支拒绝缺失 alpha/edge 及不属于自身的处理字段', () => {
const missingAlpha = chromaAsset()
delete missingAlpha.alpha
assert.throws(() => validateAssetBuildManifest(manifestWith(missingAlpha), workspace), /alpha/i)
const missingEdge = warmFrameAsset()
delete missingEdge.edge
assert.throws(() => validateAssetBuildManifest(manifestWith(missingEdge), workspace), /edge/i)
const wrongProcessing = warmFrameAsset()
wrongProcessing.processing.capWidth = 380
assert.throws(() => validateAssetBuildManifest(manifestWith(wrongProcessing), workspace), /unknown field/i)
})
test('拒绝重复输出、越界路径、未知字段和未知模式', () => {
const duplicate = manifestWith(chromaAsset())
duplicate.assets.push({ ...chromaAsset(), id: 'duplicate-output' })
assert.throws(() => validateAssetBuildManifest(duplicate, workspace), /duplicate output/i)
const escaped = manifestWith(opaqueResizeAsset())
escaped.assets[0].output = '../outside.png'
assert.throws(() => validateAssetBuildManifest(escaped, workspace), /escapes workspace/i)
const unknownField = manifestWith(opaqueResizeAsset())
unknownField.assets[0].logicalSlot = 'page'
assert.throws(() => validateAssetBuildManifest(unknownField, workspace), /unknown field/i)
const unknownMode = manifestWith(opaqueResizeAsset())
unknownMode.assets[0].processing.mode = 'future-magic'
assert.throws(() => validateAssetBuildManifest(unknownMode, workspace), /unsupported processing\.mode/i)
})
@@ -1,47 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
const workspace = path.resolve(testDirectory, '..', '..')
const manifestPath = path.join(workspace, 'design-pipeline', 'manifests', 'g01-background-candidates.json')
const wrapperPath = path.join(workspace, 'design-pipeline', 'scripts', 'build-g01-backgrounds.mjs')
test('G01 background candidate manifest preserves four portable masters and selects the approved long background', async () => {
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
assert.equal(manifest.schemaVersion, 1)
assert.equal(manifest.page, 'G01')
assert.equal(manifest.status, 'long-flagship-selected-for-genealogy-module')
assert.equal(manifest.selectedCandidateId, 'g01-list-background-long-flagship')
assert.equal(manifest.runtimeOutput, 'static/assets/modules/genealogy/opaque/genealogy-page-background-long.png')
assert.equal(manifest.candidates.length, 4)
const ids = new Set()
for (const candidate of manifest.candidates) {
assert.ok(!ids.has(candidate.id), `duplicate candidate id: ${candidate.id}`)
ids.add(candidate.id)
assert.match(candidate.prompt, /\d+x\d+/)
assert.ok(candidate.sourcePixels.width > 0 && candidate.sourcePixels.height > 0)
assert.ok(['chroma-key', 'opaque-paper', 'opaque-paper-resize'].includes(candidate.processingMode))
for (const key of ['master', 'generatedOutput']) {
const absolute = path.resolve(workspace, candidate[key])
const relative = path.relative(workspace, absolute)
assert.ok(!relative.startsWith('..') && !path.isAbsolute(relative), `${key} escapes workspace`)
}
}
const selected = manifest.candidates.find(({ id }) => id === manifest.selectedCandidateId)
assert.deepEqual(selected.sourcePixels, { width: 1536, height: 3840 })
assert.deepEqual(selected.outputPixels, { width: 1440, height: 3600 })
assert.equal(selected.processingMode, 'opaque-paper-resize')
})
test('G01 Node wrapper delegates pixel decoding to locked Pillow without Sharp', async () => {
const wrapper = await readFile(wrapperPath, 'utf8')
assert.doesNotMatch(wrapper, /from ['"]sharp['"]/)
assert.match(wrapper, /build_g01_backgrounds\.py/)
assert.match(wrapper, /candidates\?\.length !== 4/)
})
@@ -1,72 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { validateManifest } from '../scripts/manifest-v2.mjs'
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
const workspace = path.resolve(testDirectory, '..', '..')
const validAsset = () => ({
id: 'a01-primary-button-v2',
assetClass: 'fixed-bitmap',
source: 'static/assets/foundation/opaque/a01-primary-button.png',
output: 'static/assets/foundation/transparent/a01-primary-button-v2.png',
logicalSlot: { widthRpx: 622, heightRpx: 92 },
outputPixels: { width: 1866, height: 276 },
render: { scalePolicy: 'uniform-only', uniMode: 'aspectFit', allowDistortion: false },
alpha: { required: true, transparentOuterPadding: 12, cornerMaxAlpha: 0 },
edge: { forbidChromaResidue: true, forbidLightFringe: true, premultipliedAlphaCheck: true },
quality: { maxBytes: 500000, symmetry: 'horizontal', colorSpace: 'sRGB' },
processing: { trim: 8, capWidth: 180 },
runtime: { selector: '.login-submit', imageSelector: '.button-skin img' },
consumers: ['pages/auth/a01-entry.vue']
})
const validManifest = () => ({
schemaVersion: 2,
page: 'A01',
runtime: { url: 'http://localhost:5173/#/pages/auth/a01-entry', chromePort: 9222 },
assets: [validAsset()]
})
test('accepts a complete manifest v2', () => {
const manifest = validManifest()
assert.equal(validateManifest(manifest, workspace), manifest)
})
test('rejects duplicate asset ids', () => {
const manifest = validManifest()
manifest.assets.push(validAsset())
assert.throws(() => validateManifest(manifest, workspace), /duplicate asset id/i)
})
test('rejects paths that escape the workspace', () => {
const manifest = validManifest()
manifest.assets[0].output = '../outside.png'
assert.throws(() => validateManifest(manifest, workspace), /escapes workspace/i)
})
test('rejects a fixed bitmap whose output ratio differs from its slot', () => {
const manifest = validManifest()
manifest.assets[0].outputPixels.height = 300
assert.throws(() => validateManifest(manifest, workspace), /ratio drift/i)
})
test('rejects scaleToFill for uniform-only assets', () => {
const manifest = validManifest()
manifest.assets[0].render.uniMode = 'scaleToFill'
assert.throws(() => validateManifest(manifest, workspace), /scaleToFill/i)
})
test('rejects unknown asset classes', () => {
const manifest = validManifest()
manifest.assets[0].assetClass = 'mystery'
assert.throws(() => validateManifest(manifest, workspace), /assetClass/i)
})
test('rejects assets without consumers', () => {
const manifest = validManifest()
manifest.assets[0].consumers = []
assert.throws(() => validateManifest(manifest, workspace), /consumers/i)
})
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict'
import path from 'node:path'
import test from 'node:test'
import { resolvePythonExecutable, runPythonCommand } from '../scripts/python-runtime.mjs'
test('显式 PYTHON 配置优先于所有自动发现路径', () => {
const selected = resolvePythonExecutable({
pipelineDirectory: 'C:\\repo\\design-pipeline',
environment: {
PYTHON: 'D:\\tools\\python.exe',
LOCALAPPDATA: 'C:\\Users\\tester\\AppData\\Local',
},
platform: 'win32',
pathExists: () => true,
canRun: candidate => candidate === 'D:\\tools\\python.exe',
})
assert.equal(selected, 'D:\\tools\\python.exe')
})
test('Windows 环境在虚拟环境缺失时选择 Python Manager 的真实入口', () => {
const managerPython = path.win32.join(
'C:\\Users\\tester\\AppData\\Local',
'Python',
'bin',
'python.exe',
)
const selected = resolvePythonExecutable({
pipelineDirectory: 'C:\\repo\\design-pipeline',
environment: { LOCALAPPDATA: 'C:\\Users\\tester\\AppData\\Local' },
platform: 'win32',
pathExists: candidate => candidate === managerPython,
canRun: candidate => candidate === managerPython,
})
assert.equal(selected, managerPython)
})
test('所有候选解释器均不可用时给出可执行的中文修复指引', () => {
assert.throws(
() => resolvePythonExecutable({
pipelineDirectory: 'C:\\repo\\design-pipeline',
environment: {},
platform: 'win32',
pathExists: () => false,
canRun: () => false,
}),
/ Python.*PYTHON.*\.venv/s,
)
})
test('统一执行器始终把禁止字节码缓存参数放在首位', () => {
let invocation
runPythonCommand({
executable: 'python-test',
args: ['script.py', '--flag'],
cwd: 'C:\\repo',
spawn: (command, args, options) => {
invocation = { command, args, options }
return { status: 0 }
},
})
assert.equal(invocation.command, 'python-test')
assert.deepEqual(invocation.args, ['-B', 'script.py', '--flag'])
assert.equal(invocation.options.cwd, 'C:\\repo')
})
@@ -0,0 +1,65 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { validateAssetBuildManifest } from '../scripts/asset-build-manifest.mjs'
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
const pipelineDirectory = path.resolve(testDirectory, '..')
const workspace = path.resolve(pipelineDirectory, '..')
const readManifest = async (name) => JSON.parse(await readFile(
path.join(pipelineDirectory, 'manifests', name),
'utf8',
))
test('长页面背景只由一份清单拥有六张正式输出', async () => {
const manifest = await readManifest('page-backgrounds-v3.json')
validateAssetBuildManifest(manifest, workspace)
assert.equal(manifest.family, 'page-backgrounds-v3')
assert.deepEqual(
manifest.assets.map(({ id }) => id),
[
'genealogy-page-background-long',
'tree-page-background-long',
'family-page-background-long',
'records-page-background-long',
'notification-page-background-long',
'profile-page-background-long',
],
)
assert.equal(manifest.assets[0].processing.mode, 'opaque-resize')
for (const asset of manifest.assets.slice(1)) {
assert.equal(asset.processing.mode, 'opaque-cover-crop')
}
})
test('G01 空态边框拥有独立的暖金边框提取清单', async () => {
const manifest = await readManifest('g01-state-frame-v3.json')
validateAssetBuildManifest(manifest, workspace)
assert.equal(manifest.family, 'g01-state-frame-v3')
assert.equal(manifest.assets.length, 1)
assert.equal(manifest.assets[0].id, 'g01-empty-panel-frame')
assert.equal(manifest.assets[0].processing.mode, 'warm-gold-frame-extract')
assert.equal(manifest.assets[0].processing.borderBand, 110)
})
test('新构建入口不再保留候选构建和 Sharp 专用命令', async () => {
const packageJson = JSON.parse(await readFile(path.join(pipelineDirectory, 'package.json'), 'utf8'))
assert.equal(
packageJson.scripts['build:page-backgrounds'],
'node scripts/build-raster-assets.mjs design-pipeline/manifests/page-backgrounds-v3.json',
)
assert.equal(
packageJson.scripts['build:g01-state-frame'],
'node scripts/build-raster-assets.mjs design-pipeline/manifests/g01-state-frame-v3.json',
)
assert.equal(packageJson.scripts['build:g01-background-candidates'], undefined)
assert.equal(packageJson.scripts['build:module-page-backgrounds'], undefined)
assert.equal(packageJson.scripts['build:g01-empty-frame'], undefined)
assert.equal(packageJson.dependencies?.sharp, undefined)
})
@@ -0,0 +1,142 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { expandRuntimeAssetInventory } from '../scripts/runtime-asset-inventory.mjs'
const createWorkspace = async () => {
const workspace = await mkdtemp(path.join(os.tmpdir(), 'jiapu-runtime-assets-'))
await mkdir(path.join(workspace, 'design-pipeline', 'manifests'), { recursive: true })
return workspace
}
const writeManifest = async (workspace, name, value) => {
const filePath = path.join(workspace, 'design-pipeline', 'manifests', name)
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
return filePath
}
const directAsset = (id, output) => ({
id,
output,
width: 96,
height: 96,
alpha: true,
bytes: 100,
sha256: 'a'.repeat(64),
provenance: 'committed-binary',
rebuildable: false,
})
const runtimeManifest = (imports = [], assets = []) => ({
schemaVersion: 1,
kind: 'runtime-asset-inventory',
scope: 'auth',
imports,
assets,
})
test('展开直接资产与单一导入且不复制物理规格', async (t) => {
const workspace = await createWorkspace()
t.after(() => rm(workspace, { recursive: true, force: true }))
const imported = await writeManifest(
workspace,
'shared.json',
runtimeManifest([], [directAsset('shared', 'static/assets/shared.png')]),
)
const root = await writeManifest(
workspace,
'auth.json',
runtimeManifest(['design-pipeline/manifests/shared.json'], [directAsset('auth', 'static/assets/auth.png')]),
)
const result = await expandRuntimeAssetInventory(root, workspace)
assert.deepEqual(result.assets.map(({ id }) => id).sort(), ['auth', 'shared'])
assert(result.manifests.includes(imported))
})
test('拒绝缺失的导入文件', async (t) => {
const workspace = await createWorkspace()
t.after(() => rm(workspace, { recursive: true, force: true }))
const root = await writeManifest(workspace, 'auth.json', runtimeManifest(['design-pipeline/manifests/missing.json']))
await assert.rejects(() => expandRuntimeAssetInventory(root, workspace), /missing manifest/i)
})
test('拒绝循环导入', async (t) => {
const workspace = await createWorkspace()
t.after(() => rm(workspace, { recursive: true, force: true }))
const first = await writeManifest(workspace, 'first.json', runtimeManifest(['design-pipeline/manifests/second.json']))
await writeManifest(workspace, 'second.json', runtimeManifest(['design-pipeline/manifests/first.json']))
await assert.rejects(() => expandRuntimeAssetInventory(first, workspace), /import cycle/i)
})
test('拒绝同一清单被重复导入', async (t) => {
const workspace = await createWorkspace()
t.after(() => rm(workspace, { recursive: true, force: true }))
await writeManifest(workspace, 'shared.json', runtimeManifest([], [directAsset('shared', 'static/assets/shared.png')]))
const root = await writeManifest(
workspace,
'auth.json',
runtimeManifest([
'design-pipeline/manifests/shared.json',
'design-pipeline/manifests/shared.json',
]),
)
await assert.rejects(() => expandRuntimeAssetInventory(root, workspace), /duplicate import/i)
})
test('拒绝不同清单拥有同一正式输出', async (t) => {
const workspace = await createWorkspace()
t.after(() => rm(workspace, { recursive: true, force: true }))
await writeManifest(workspace, 'shared.json', runtimeManifest([], [directAsset('shared', 'static/assets/same.png')]))
const root = await writeManifest(
workspace,
'auth.json',
runtimeManifest(
['design-pipeline/manifests/shared.json'],
[directAsset('auth', 'static/assets/same.png')],
),
)
await assert.rejects(() => expandRuntimeAssetInventory(root, workspace), /duplicate output/i)
})
test('拒绝展开图中的重复资产 id', async (t) => {
const workspace = await createWorkspace()
t.after(() => rm(workspace, { recursive: true, force: true }))
await writeManifest(workspace, 'shared.json', runtimeManifest([], [directAsset('same-id', 'static/assets/shared.png')]))
const root = await writeManifest(
workspace,
'auth.json',
runtimeManifest(
['design-pipeline/manifests/shared.json'],
[directAsset('same-id', 'static/assets/auth.png')],
),
)
await assert.rejects(() => expandRuntimeAssetInventory(root, workspace), /duplicate asset id/i)
})
test('直接资产只接受 committed-binary 且拒绝未知字段', async (t) => {
const workspace = await createWorkspace()
t.after(() => rm(workspace, { recursive: true, force: true }))
const wrongProvenance = directAsset('wrong-provenance', 'static/assets/wrong.png')
wrongProvenance.provenance = 'manual-copy'
const wrongRoot = await writeManifest(workspace, 'wrong.json', runtimeManifest([], [wrongProvenance]))
await assert.rejects(() => expandRuntimeAssetInventory(wrongRoot, workspace), /committed-binary/i)
const unknownField = directAsset('unknown-field', 'static/assets/unknown.png')
unknownField.runtimeSelector = '.page'
const unknownRoot = await writeManifest(workspace, 'unknown.json', runtimeManifest([], [unknownField]))
await assert.rejects(() => expandRuntimeAssetInventory(unknownRoot, workspace), /unknown field/i)
})
test('运行时清单本身拒绝未知字段', async (t) => {
const workspace = await createWorkspace()
t.after(() => rm(workspace, { recursive: true, force: true }))
const manifest = runtimeManifest()
manifest.consumerList = []
const root = await writeManifest(workspace, 'auth.json', manifest)
await assert.rejects(() => expandRuntimeAssetInventory(root, workspace), /unknown field/i)
})
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { validateRuntimeAssetRegistry } from '../scripts/runtime-asset-inventory.mjs'
const runtimeManifest = (scope, imports = []) => ({
schemaVersion: 1,
kind: 'runtime-asset-inventory',
scope,
imports,
assets: [],
})
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
const pipelineDirectory = path.resolve(testDirectory, '..')
const workspaceDirectory = path.resolve(pipelineDirectory, '..')
const realManifestsDirectory = path.join(pipelineDirectory, 'manifests')
test('顶层注册表拒绝未进入导入闭包的正式 owner', async (t) => {
const workspace = await mkdtemp(path.join(os.tmpdir(), 'jiapu-runtime-registry-'))
t.after(() => rm(workspace, { recursive: true, force: true }))
const manifestsDirectory = path.join(workspace, 'design-pipeline', 'manifests')
await mkdir(manifestsDirectory, { recursive: true })
const write = (name, value) => writeFile(path.join(manifestsDirectory, name), `${JSON.stringify(value)}\n`, 'utf8')
await write('runtime-assets.json', runtimeManifest('schema-v3'))
await write('orphan.json', runtimeManifest('orphan'))
await assert.rejects(
() => validateRuntimeAssetRegistry(
path.join(manifestsDirectory, 'runtime-assets.json'),
workspace,
manifestsDirectory,
),
/unregistered manifest/i,
)
})
test('真实 schema v3 注册表覆盖直接资产与三类正式生成 owner', async () => {
const registry = JSON.parse(await readFile(path.join(realManifestsDirectory, 'runtime-assets.json'), 'utf8'))
const auth = JSON.parse(await readFile(path.join(realManifestsDirectory, 'auth-runtime-assets.json'), 'utf8'))
assert.equal(registry.scope, 'schema-v3')
assert.deepEqual(registry.imports, [
'design-pipeline/manifests/auth-runtime-assets.json',
'design-pipeline/manifests/application-runtime-assets.json',
'design-pipeline/manifests/shared-scroll-skins-v3.json',
'design-pipeline/manifests/page-backgrounds-v3.json',
'design-pipeline/manifests/g01-state-frame-v3.json',
])
assert.deepEqual(auth.imports, [])
await validateRuntimeAssetRegistry(
path.join(realManifestsDirectory, 'runtime-assets.json'),
workspaceDirectory,
realManifestsDirectory,
)
})
test('注册表根 scope 不是 schema-v3 时拒绝验证', async (t) => {
const workspace = await mkdtemp(path.join(os.tmpdir(), 'jiapu-runtime-registry-scope-'))
t.after(() => rm(workspace, { recursive: true, force: true }))
const manifestsDirectory = path.join(workspace, 'design-pipeline', 'manifests')
await mkdir(manifestsDirectory, { recursive: true })
const root = path.join(manifestsDirectory, 'runtime-assets.json')
await writeFile(root, `${JSON.stringify(runtimeManifest('auth'))}\n`, 'utf8')
await assert.rejects(
() => validateRuntimeAssetRegistry(root, workspace, manifestsDirectory),
/root scope must be schema-v3/i,
)
})
test('未声明的旧格式或未知 kind 清单不能被静默跳过', async (t) => {
const workspace = await mkdtemp(path.join(os.tmpdir(), 'jiapu-runtime-registry-unknown-'))
t.after(() => rm(workspace, { recursive: true, force: true }))
const manifestsDirectory = path.join(workspace, 'design-pipeline', 'manifests')
await mkdir(manifestsDirectory, { recursive: true })
const root = path.join(manifestsDirectory, 'runtime-assets.json')
await writeFile(root, `${JSON.stringify(runtimeManifest('schema-v3'))}\n`, 'utf8')
await writeFile(
path.join(manifestsDirectory, 'unknown.json'),
`${JSON.stringify({ schemaVersion: 1, kind: 'legacy-owner', outputs: [] })}\n`,
'utf8',
)
await assert.rejects(
() => validateRuntimeAssetRegistry(root, workspace, manifestsDirectory),
/undeclared legacy manifest/i,
)
})
@@ -1,30 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { validateManifest } from '../scripts/manifest-v2.mjs'
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
const pipelineDirectory = path.resolve(testDirectory, '..')
const workspace = path.resolve(pipelineDirectory, '..')
const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-scroll-skins-v3.json')
test('declares the approved A01 scroll-skin family', async () => {
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
validateManifest(manifest, workspace)
assert.equal(manifest.assets.length, 4)
assert.deepEqual(
manifest.assets.map(({ id }) => id),
['a01-scroll-primary-v3', 'a01-scroll-secondary-v3', 'a01-scroll-toast-v3', 'a01-scroll-dialog-v3']
)
assert.deepEqual(
manifest.assets.map(({ outputPixels }) => [outputPixels.width, outputPixels.height]),
[[1866, 276], [1866, 300], [1770, 246], [1860, 1560]]
)
assert.equal(manifest.assets[2].assetClass, 'nine-slice')
assert.equal(manifest.assets[2].render.scalePolicy, 'nine-slice')
assert.equal(manifest.assets[3].render.uniMode, 'aspectFit')
assert(manifest.assets.every(({ consumers }) => consumers.includes('pages/auth/a01-entry.vue')))
})
@@ -0,0 +1,31 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { validateAssetBuildManifest } from '../scripts/asset-build-manifest.mjs'
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
const pipelineDirectory = path.resolve(testDirectory, '..')
const workspace = path.resolve(pipelineDirectory, '..')
const manifestPath = path.join(pipelineDirectory, 'manifests', 'shared-scroll-skins-v3.json')
test('共享卷轴清单只维护四张正式生成资产', async () => {
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
validateAssetBuildManifest(manifest, workspace)
assert.equal(manifest.kind, 'asset-build-manifest')
assert.equal(manifest.family, 'shared-scroll-skins-v3')
assert.deepEqual(
manifest.assets.map(({ id }) => id),
['shared-scroll-primary-v3', 'shared-scroll-secondary-v3', 'shared-scroll-toast-v3', 'shared-scroll-dialog-v3'],
)
for (const asset of manifest.assets) {
assert.deepEqual(
Object.keys(asset).sort(),
['alpha', 'edge', 'id', 'output', 'outputPixels', 'processing', 'quality', 'source', 'sourcePixels'],
)
assert.equal(asset.processing.mode, 'chroma-stretch')
}
})
+20 -11
View File
@@ -3,7 +3,7 @@ import tempfile
import unittest
from pathlib import Path
from PIL import Image
from PIL import Image, PngImagePlugin
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS))
@@ -37,19 +37,23 @@ class AssetQualityTests(unittest.TestCase):
image.putpixel((x, y), (150, 20, 12, 255))
return image
def save(self, image, name="asset.png"):
def save(self, image, name="asset.png", include_srgb=True):
path = self.root / name
image.save(path, format="PNG")
png_info = PngImagePlugin.PngInfo()
if include_srgb:
png_info.add(b"sRGB", b"\x00")
image.save(path, format="PNG", pnginfo=png_info)
return path
def error_text(self, report):
return " ".join(report["errors"])
def test_accepts_clean_rgba_asset(self):
report = analyze_asset(self.save(self.clean_image()), self.spec())
report = analyze_asset(self.save(self.clean_image()), self.spec(), self.root)
self.assertEqual([], report["errors"])
self.assertEqual(32, report["width"])
self.assertEqual(16, report["height"])
self.assertEqual("asset.png", report["path"])
def test_accepts_indexed_png_with_real_transparency(self):
indexed = self.clean_image().quantize(
@@ -57,44 +61,49 @@ class AssetQualityTests(unittest.TestCase):
method=Image.Quantize.FASTOCTREE,
dither=Image.Dither.NONE,
)
report = analyze_asset(self.save(indexed), self.spec())
report = analyze_asset(self.save(indexed), self.spec(), self.root)
self.assertEqual([], report["errors"])
self.assertEqual("P", report["mode"])
def test_rejects_wrong_dimensions(self):
report = analyze_asset(self.save(Image.new("RGBA", (31, 16), (0, 0, 0, 0))), self.spec())
report = analyze_asset(self.save(Image.new("RGBA", (31, 16), (0, 0, 0, 0))), self.spec(), self.root)
self.assertIn("dimensions", self.error_text(report))
def test_rejects_opaque_outer_padding(self):
image = self.clean_image()
image.putpixel((0, 0), (120, 30, 20, 255))
report = analyze_asset(self.save(image), self.spec())
report = analyze_asset(self.save(image), self.spec(), self.root)
self.assertIn("outer padding", self.error_text(report))
def test_rejects_visible_green_residue(self):
image = self.clean_image()
image.putpixel((16, 8), (0, 255, 0, 255))
report = analyze_asset(self.save(image), self.spec())
report = analyze_asset(self.save(image), self.spec(), self.root)
self.assertIn("chroma residue", self.error_text(report))
def test_rejects_light_partially_transparent_fringe(self):
image = self.clean_image()
image.putpixel((2, 8), (250, 250, 250, 128))
report = analyze_asset(self.save(image), self.spec())
report = analyze_asset(self.save(image), self.spec(), self.root)
self.assertIn("light fringe", self.error_text(report))
def test_rejects_hidden_rgb_in_fully_transparent_pixels(self):
image = self.clean_image()
image.putpixel((0, 0), (255, 255, 255, 0))
report = analyze_asset(self.save(image), self.spec())
report = analyze_asset(self.save(image), self.spec(), self.root)
self.assertIn("transparent RGB", self.error_text(report))
def test_rejects_file_over_max_bytes(self):
spec = self.spec()
spec["quality"]["maxBytes"] = 8
report = analyze_asset(self.save(self.clean_image()), spec)
report = analyze_asset(self.save(self.clean_image()), spec, self.root)
self.assertIn("maxBytes", self.error_text(report))
def test_rejects_missing_declared_srgb_metadata(self):
path = self.save(self.clean_image(), include_srgb=False)
report = analyze_asset(path, self.spec(), self.root)
self.assertIn("sRGB", self.error_text(report))
if __name__ == "__main__":
unittest.main()
@@ -1,71 +0,0 @@
import sys
import tempfile
import unittest
from pathlib import Path
from PIL import Image
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS))
from build_g01_backgrounds import process_chroma_image, process_opaque_image, save_png
class G01BackgroundBuilderTests(unittest.TestCase):
def config(self):
return {
"dominanceStart": 30,
"dominanceEnd": 220,
"despillAllowance": 10,
}
def test_pure_green_becomes_clean_transparency(self):
source = Image.new("RGB", (2, 1), (0, 255, 0))
output = process_chroma_image(source, self.config())
self.assertEqual((0, 0, 0, 0), output.getpixel((0, 0)))
def test_warm_artwork_remains_opaque(self):
source = Image.new("RGB", (1, 1), (210, 190, 150))
output = process_chroma_image(source, self.config())
self.assertEqual((210, 190, 150, 255), output.getpixel((0, 0)))
def test_antialiased_green_edge_is_translucent_and_despilled(self):
source = Image.new("RGB", (1, 1), (100, 200, 90))
output = process_chroma_image(source, self.config())
red, green, blue, alpha = output.getpixel((0, 0))
self.assertGreater(alpha, 0)
self.assertLess(alpha, 255)
self.assertLessEqual(green, max(red, blue) + 10)
def test_opaque_master_is_normalized_to_rgba_without_transparency(self):
source = Image.new("RGB", (1, 1), (240, 235, 220))
output = process_opaque_image(source)
self.assertEqual("RGBA", output.mode)
self.assertEqual((240, 235, 220, 255), output.getpixel((0, 0)))
def test_opaque_master_can_be_resized_to_locked_runtime_dimensions(self):
source = Image.new("RGB", (2, 3), (240, 235, 220))
output = process_opaque_image(source, (4, 6))
self.assertEqual((4, 6), output.size)
self.assertEqual("RGBA", output.mode)
self.assertEqual((240, 235, 220, 255), output.getpixel((3, 5)))
def test_saved_png_declares_srgb_color_profile(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "asset.png"
save_png(Image.new("RGBA", (1, 1), (240, 235, 220, 255)), path)
with Image.open(path) as opened:
self.assertIn("srgb", opened.info)
def test_saved_png_bytes_are_deterministic(self):
with tempfile.TemporaryDirectory() as directory:
first = Path(directory) / "first.png"
second = Path(directory) / "second.png"
image = Image.new("RGBA", (2, 2), (240, 235, 220, 255))
save_png(image, first)
save_png(image, second)
self.assertEqual(first.read_bytes(), second.read_bytes())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,85 @@
import hashlib
import sys
import tempfile
import unittest
from pathlib import Path
from PIL import Image
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS))
from build_raster_assets import (
build_opaque_cover_crop,
build_opaque_resize,
extract_warm_gold_frame,
save_png,
)
class RasterAssetBuilderTests(unittest.TestCase):
"""锁定迁移后的两类背景处理与 G01 暖金边框提取语义。"""
def test_opaque_resize_uses_locked_output_size_and_rgb(self):
source = Image.new("RGBA", (2, 3), (120, 80, 40, 128))
output = build_opaque_resize(source, (4, 6))
self.assertEqual((4, 6), output.size)
self.assertEqual("RGB", output.mode)
def test_cover_crop_centers_the_scaled_source(self):
source = Image.new("RGB", (2, 1), (255, 0, 0))
source.putpixel((1, 0), (0, 0, 255))
output = build_opaque_cover_crop(source, (2, 2))
self.assertEqual((2, 2), output.size)
self.assertEqual("RGB", output.mode)
self.assertNotEqual(output.getpixel((0, 0)), output.getpixel((1, 0)))
def test_warm_gold_frame_keeps_only_the_edge_band(self):
source = Image.new("RGBA", (5, 5), (180, 140, 80, 255))
config = {
"borderBand": 1,
"redGreenMin": 15,
"greenBlueMin": 12,
"redBlueMin": 35,
"redMaxExclusive": 245,
"blueMaxExclusive": 180,
"alphaOffset": 25,
"alphaScale": 6,
}
output = extract_warm_gold_frame(source, config)
self.assertGreater(output.getpixel((0, 2))[3], 0)
self.assertEqual((0, 0, 0, 0), output.getpixel((2, 2)))
def test_warm_gold_frame_rejects_non_gold_and_clears_hidden_rgb(self):
source = Image.new("RGBA", (1, 1), (100, 150, 100, 255))
config = {
"borderBand": 1,
"redGreenMin": 15,
"greenBlueMin": 12,
"redBlueMin": 35,
"redMaxExclusive": 245,
"blueMaxExclusive": 180,
"alphaOffset": 25,
"alphaScale": 6,
}
output = extract_warm_gold_frame(source, config)
self.assertEqual((0, 0, 0, 0), output.getpixel((0, 0)))
def test_png_save_is_srgb_and_byte_deterministic(self):
with tempfile.TemporaryDirectory() as directory:
first = Path(directory) / "first.png"
second = Path(directory) / "second.png"
image = Image.new("RGB", (4, 4), (230, 220, 200))
save_png(image, first)
save_png(image, second)
self.assertEqual(first.read_bytes(), second.read_bytes())
self.assertEqual(
hashlib.sha256(first.read_bytes()).hexdigest(),
hashlib.sha256(second.read_bytes()).hexdigest(),
)
with Image.open(first) as opened:
self.assertIn("srgb", opened.info)
if __name__ == "__main__":
unittest.main()
@@ -1,3 +1,5 @@
import hashlib
import json
import sys
import tempfile
import unittest
@@ -8,7 +10,13 @@ from PIL import Image
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS))
from build_scroll_skins import build_asset, remove_chroma_background, sanitize_output_edges, stretch_safe_center
from asset_quality import analyze_asset
from build_scroll_skins import (
build_asset,
remove_chroma_background,
sanitize_output_edges,
stretch_safe_center,
)
class BuildScrollSkinsTests(unittest.TestCase):
@@ -20,9 +28,7 @@ class BuildScrollSkinsTests(unittest.TestCase):
def test_chroma_background_becomes_transparent_black(self):
image = Image.new("RGB", (4, 2), (0, 255, 0))
image.putpixel((1, 0), (180, 30, 20))
cleaned = remove_chroma_background(image, (0, 255, 0), tolerance=80)
self.assertEqual((0, 0, 0, 0), cleaned.getpixel((0, 0)))
self.assertEqual((180, 30, 20, 255), cleaned.getpixel((1, 0)))
@@ -33,13 +39,7 @@ class BuildScrollSkinsTests(unittest.TestCase):
image.putpixel((x, y), (220, 170, 40, 255))
image.putpixel((29 - x, y), (220, 170, 40, 255))
output = stretch_safe_center(
image,
output_size=(60, 20),
cap_width=5,
padding=2,
)
output = stretch_safe_center(image, output_size=(60, 20), cap_width=5, padding=2)
self.assertEqual((60, 20), output.size)
self.assertEqual((0, 0, 0, 0), output.getpixel((0, 0)))
self.assertEqual((220, 170, 40, 255), output.getpixel((3, 10)))
@@ -50,28 +50,28 @@ class BuildScrollSkinsTests(unittest.TestCase):
image = Image.new("RGBA", (3, 1), (248, 240, 220, 255))
image.putpixel((0, 0), (0, 255, 0, 128))
image.putpixel((1, 0), (250, 250, 250, 96))
cleaned = sanitize_output_edges(image)
self.assertEqual((0, 0, 0, 0), cleaned.getpixel((0, 0)))
self.assertEqual((0, 0, 0, 0), cleaned.getpixel((1, 0)))
self.assertEqual((248, 240, 220, 255), cleaned.getpixel((2, 0)))
def test_build_asset_honors_declared_palette_size(self):
source = Image.new("RGB", (32, 16), (0, 255, 0))
for y in range(1, 15):
for x in range(1, 31):
source.putpixel((x, y), (120 + (x % 30) * 4, 20 + y, 10))
source_path = self.root / "source.png"
output_path = self.root / "output.png"
source.save(source_path)
asset = {
"id": "palette-test",
def asset(self, asset_id):
"""返回与现行判别式 schema 一致的最小透明卷轴测试资产。"""
return {
"id": asset_id,
"source": "source.png",
"output": "output.png",
"sourcePixels": {"width": 32, "height": 16},
"outputPixels": {"width": 60, "height": 30},
"alpha": {"transparentOuterPadding": 1},
"alpha": {"required": True, "transparentOuterPadding": 1, "cornerMaxAlpha": 0},
"edge": {
"forbidChromaResidue": True,
"forbidLightFringe": True,
"premultipliedAlphaCheck": True,
},
"quality": {"maxBytes": 10000, "colorSpace": "sRGB"},
"processing": {
"mode": "chroma-stretch",
"capWidth": 4,
"keyColor": "#00FF00",
"keyTolerance": 80,
@@ -79,14 +79,37 @@ class BuildScrollSkinsTests(unittest.TestCase):
},
}
build_asset(asset, self.root)
def save_test_source(self):
source = Image.new("RGB", (32, 16), (0, 255, 0))
for y in range(1, 15):
for x in range(1, 31):
source.putpixel((x, y), (120 + (x % 30) * 4, 20 + y, 10))
source.save(self.root / "source.png")
with Image.open(output_path) as output:
def test_build_asset_honors_declared_palette_size(self):
self.save_test_source()
build_asset(self.asset("palette-test"), self.root)
with Image.open(self.root / "output.png") as output:
visible_colors = {
pixel[:3] for pixel in output.convert("RGBA").get_flattened_data() if pixel[3] > 0
}
self.assertLessEqual(len(visible_colors), 8)
def test_same_input_produces_identical_bytes_and_report_twice(self):
self.save_test_source()
asset = self.asset("deterministic-test")
snapshots = []
for _ in range(2):
build_asset(asset, self.root)
output = self.root / "output.png"
snapshots.append(
(
hashlib.sha256(output.read_bytes()).hexdigest(),
json.dumps(analyze_asset(output, asset, self.root), ensure_ascii=False, sort_keys=True),
)
)
self.assertEqual(snapshots[0], snapshots[1])
if __name__ == "__main__":
unittest.main()
@@ -1,49 +0,0 @@
import sys
import tempfile
import unittest
from pathlib import Path
from PIL import Image, ImageDraw
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS))
from rebuild_a01_buttons import rebuild_button
class RebuildA01ButtonTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
def make_source(self):
image = Image.new("RGBA", (60, 30), (0, 255, 0, 255))
draw = ImageDraw.Draw(image)
draw.rectangle((4, 4, 55, 25), fill=(150, 20, 12, 255))
draw.rectangle((4, 4, 13, 25), fill=(210, 160, 40, 255))
draw.rectangle((46, 4, 55, 25), fill=(210, 160, 40, 255))
return image
def test_rebuilds_three_slice_into_clean_transparent_canvas(self):
source = self.root / "source.png"
output = self.root / "output.png"
self.make_source().save(source)
rebuild_button(source, output, output_size=(180, 60), trim=4, padding=6, cap_width=10)
with Image.open(output) as image:
self.assertEqual("RGBA", image.mode)
self.assertEqual((180, 60), image.size)
self.assertEqual((0, 0, 0, 0), image.getpixel((0, 0)))
self.assertEqual((0, 0, 0, 0), image.getpixel((179, 59)))
visible_pixels = [pixel for pixel in image.get_flattened_data() if pixel[3] > 8]
self.assertFalse(
any(green > 120 and green - max(red, blue) > 80 for red, green, blue, green_alpha in visible_pixels)
)
self.assertGreater(image.getpixel((7, 30))[0], image.getpixel((90, 30))[0])
self.assertGreater(image.getpixel((172, 30))[0], image.getpixel((90, 30))[0])
if __name__ == "__main__":
unittest.main()