@open-dy/vibecoding-cli 0.1.11 → 0.1.12-beta.c8a9dde2128415a02c5abdbd0845a4b2b738f09cc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/release-provenance.json +7 -7
- package/src/cli.mjs +1 -1
- package/src/commands/build.mjs +3 -0
- package/src/commands/validate.mjs +5 -2
- package/src/validators/feed-card.cjs +35 -1
- package/src/validators/feed-card.mjs +15 -1
- package/src/validators/image-inline.mjs +82 -0
- package/src/validators/opencard-structure.mjs +1 -1
- package/src/validators/validate-card-background.py +216 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-dy/vibecoding-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12-beta.c8a9dde2128415a02c5abdbd0845a4b2b738f09cc.0",
|
|
4
4
|
"description": "Command orchestration, validation and packaging for Douyin vibecoding Taro and OpenCard projects.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
"ajv": "^8.18.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"@open-dy/vibecoding-sdk-taro": "0.1.
|
|
46
|
-
"@open-dy/vibecoding-sdk-opencard": "0.1.
|
|
45
|
+
"@open-dy/vibecoding-sdk-taro": "0.1.8-beta.c8a9dde2128415a02c5abdbd0845a4b2b738f09cc.0",
|
|
46
|
+
"@open-dy/vibecoding-sdk-opencard": "0.1.9"
|
|
47
47
|
},
|
|
48
48
|
"peerDependenciesMeta": {
|
|
49
49
|
"@open-dy/vibecoding-sdk-taro": {
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"license": "UNLICENSED",
|
|
60
60
|
"publishConfig": {
|
|
61
61
|
"registry": "https://registry.npmjs.org/",
|
|
62
|
-
"tag": "
|
|
62
|
+
"tag": "beta-c8a9dde2128415a02c5abdbd0845a4b2b738f09cc",
|
|
63
63
|
"access": "public"
|
|
64
64
|
}
|
|
65
65
|
}
|
package/release-provenance.json
CHANGED
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
"schema": 1,
|
|
3
3
|
"producer": "vibecoding-cli",
|
|
4
4
|
"packageName": "@open-dy/vibecoding-cli",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.12-beta.c8a9dde2128415a02c5abdbd0845a4b2b738f09cc.0",
|
|
6
6
|
"source": {
|
|
7
|
-
"digest": "sha256-
|
|
8
|
-
"files":
|
|
7
|
+
"digest": "sha256-9256701dfc7746d22e9714ce4af2bed022ce5f6ae9f72934edbfc28cd913f58f",
|
|
8
|
+
"files": 94
|
|
9
9
|
},
|
|
10
|
-
"payload": "sha256-
|
|
10
|
+
"payload": "sha256-7d71961d18d6392e208d1e47a6efa44ff09864adbcc5db028e70c6c1b9fbb702",
|
|
11
11
|
"toolchain": {
|
|
12
|
-
"node": "v22.22.
|
|
12
|
+
"node": "v22.22.0",
|
|
13
13
|
"runtime": {
|
|
14
|
-
"node": "v22.22.
|
|
14
|
+
"node": "v22.22.0",
|
|
15
15
|
"platform": "linux",
|
|
16
16
|
"arch": "x64",
|
|
17
|
-
"environment": "sha256-
|
|
17
|
+
"environment": "sha256-5b81e7cc338f03f0ff65566f99f994cca05b90cc836ff863506eed6afe9f5a24"
|
|
18
18
|
},
|
|
19
19
|
"packageManager": null
|
|
20
20
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -20,7 +20,7 @@ Commands:
|
|
|
20
20
|
build Build the selected target
|
|
21
21
|
lint Type-check, lint and validate source structure
|
|
22
22
|
verify Run static and/or runtime verification
|
|
23
|
-
validate <name> Run feed-card, routes, empty or opencard-structure
|
|
23
|
+
validate <name> Run feed-card, feed-background, routes, empty or opencard-structure
|
|
24
24
|
init-mysql-db Initialize the Taro server database
|
|
25
25
|
pack Build deployment archives
|
|
26
26
|
docs Locate the selected project's installed SDK reference
|
package/src/commands/build.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { readEnvFile } from '../core/environment.mjs'
|
|
|
5
5
|
import { resolveProjectBinary, resolveSdkTaroEntry, runProcess, runTasks } from '../core/runner.mjs'
|
|
6
6
|
import { writeWebConfig } from '../core/web-config.mjs'
|
|
7
7
|
import { validateRoutes } from '../validators/routes.mjs'
|
|
8
|
+
import { validateImageInline } from '../validators/image-inline.mjs'
|
|
8
9
|
|
|
9
10
|
function taroEnvironment(root, extra = {}) {
|
|
10
11
|
const home = path.join(root, '.home')
|
|
@@ -101,6 +102,7 @@ export async function runBuild(context, options = {}) {
|
|
|
101
102
|
}
|
|
102
103
|
if (context.target === 'opencard') {
|
|
103
104
|
await buildOpenCard(context, buildOptions)
|
|
105
|
+
if (!options.watch) validateImageInline(context)
|
|
104
106
|
return
|
|
105
107
|
}
|
|
106
108
|
|
|
@@ -138,4 +140,5 @@ export async function runBuild(context, options = {}) {
|
|
|
138
140
|
}
|
|
139
141
|
await runTasks(tasks, { label: 'build' })
|
|
140
142
|
validateRoutes(context)
|
|
143
|
+
if (!options.watch) validateImageInline(context)
|
|
141
144
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { UsageError } from '../core/errors.mjs'
|
|
2
|
-
import { validateFeedCard } from '../validators/feed-card.mjs'
|
|
2
|
+
import { validateFeedCard, validateProjectCardBackground } from '../validators/feed-card.mjs'
|
|
3
3
|
import { validateNonEmptyTaro } from '../validators/empty-project.mjs'
|
|
4
4
|
import { validateOpenCardStructure } from '../validators/opencard-structure.mjs'
|
|
5
5
|
import { validateRoutes } from '../validators/routes.mjs'
|
|
@@ -9,6 +9,9 @@ export async function runValidate(context, validator) {
|
|
|
9
9
|
case 'feed-card':
|
|
10
10
|
validateFeedCard(context)
|
|
11
11
|
return
|
|
12
|
+
case 'feed-background':
|
|
13
|
+
validateProjectCardBackground(context)
|
|
14
|
+
return
|
|
12
15
|
case 'routes':
|
|
13
16
|
validateRoutes(context)
|
|
14
17
|
return
|
|
@@ -20,7 +23,7 @@ export async function runValidate(context, validator) {
|
|
|
20
23
|
return
|
|
21
24
|
default:
|
|
22
25
|
throw new UsageError(
|
|
23
|
-
`Unknown validator: ${validator || '<missing>'}\nAvailable validators: feed-card, routes, empty, opencard-structure`,
|
|
26
|
+
`Unknown validator: ${validator || '<missing>'}\nAvailable validators: feed-card, feed-background, routes, empty, opencard-structure`,
|
|
24
27
|
)
|
|
25
28
|
}
|
|
26
29
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const fs = require("node:fs");
|
|
2
2
|
const path = require("node:path");
|
|
3
|
+
const { spawnSync } = require("node:child_process");
|
|
3
4
|
const Ajv = require("ajv");
|
|
4
5
|
|
|
5
6
|
function failFast(message) {
|
|
@@ -55,6 +56,37 @@ function resolveFeedCardSchema(projectRoot) {
|
|
|
55
56
|
return schemaPath;
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
function validateCardBackground(data) {
|
|
60
|
+
const validator = path.join(__dirname, "validate-card-background.py");
|
|
61
|
+
const result = spawnSync("python3", [
|
|
62
|
+
validator,
|
|
63
|
+
"--url", data.CardBackgroundUrl,
|
|
64
|
+
"--primary-color", data.PrimaryColor || "",
|
|
65
|
+
], { encoding: "utf8", timeout: 20_000 });
|
|
66
|
+
if (result.error) {
|
|
67
|
+
throw new Error(`Background image validation could not run: ${result.error.message}`);
|
|
68
|
+
}
|
|
69
|
+
if (result.status !== 0) {
|
|
70
|
+
throw new Error((result.stderr || result.stdout || "Background image validation failed.").trim());
|
|
71
|
+
}
|
|
72
|
+
return JSON.parse(result.stdout);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function validateProjectCardBackground(context) {
|
|
76
|
+
if (context.target !== "taro") {
|
|
77
|
+
throw new Error("Feed background validation requires --target taro");
|
|
78
|
+
}
|
|
79
|
+
const jsonPath = path.join(context.root, "feed/feed-card.json");
|
|
80
|
+
if (!fs.existsSync(jsonPath)) return { skipped: true };
|
|
81
|
+
let data;
|
|
82
|
+
try {
|
|
83
|
+
data = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
|
|
84
|
+
} catch (error) {
|
|
85
|
+
failFast(`Invalid JSON syntax in ${jsonPath}: ${error.message}`);
|
|
86
|
+
}
|
|
87
|
+
return validateCardBackground(data);
|
|
88
|
+
}
|
|
89
|
+
|
|
58
90
|
function validateFeedCard(context, options = {}) {
|
|
59
91
|
if (context.target !== "taro") {
|
|
60
92
|
throw new Error("Feed card validation requires --target taro");
|
|
@@ -140,6 +172,8 @@ function validateFeedCard(context, options = {}) {
|
|
|
140
172
|
reportAndExit(errors);
|
|
141
173
|
}
|
|
142
174
|
|
|
175
|
+
validateCardBackground(data);
|
|
176
|
+
|
|
143
177
|
if (options.touchTimestamp !== false) {
|
|
144
178
|
data.__timestamp = Date.now();
|
|
145
179
|
try {
|
|
@@ -153,4 +187,4 @@ function validateFeedCard(context, options = {}) {
|
|
|
153
187
|
return { skipped: false };
|
|
154
188
|
}
|
|
155
189
|
|
|
156
|
-
module.exports = { resolveFeedCardSchema, validateFeedCard };
|
|
190
|
+
module.exports = { resolveFeedCardSchema, validateFeedCard, validateCardBackground, validateProjectCardBackground };
|
|
@@ -25,8 +25,22 @@ export function resolveFeedCardSchema(projectRoot) {
|
|
|
25
25
|
* @params {object} context CLI 工程上下文。
|
|
26
26
|
* @params {object} [options] 校验选项。
|
|
27
27
|
* @return {unknown} 校验器返回的结果;发现错误时抛出 CliError。
|
|
28
|
-
* @example validateFeedCard(context
|
|
28
|
+
* @example validateFeedCard(context)
|
|
29
29
|
*/
|
|
30
30
|
export function validateFeedCard(context, options = {}) {
|
|
31
31
|
return runValidation(() => validator.validateFeedCard(context, options))
|
|
32
32
|
}
|
|
33
|
+
|
|
34
|
+
export function validateCardBackground(data) {
|
|
35
|
+
return runValidation(() => validator.validateCardBackground(data))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @desc 单独实测 feed-card.json 指向的最终背景图与 PrimaryColor 底部承接。
|
|
40
|
+
* @params {object} context CLI 工程上下文。
|
|
41
|
+
* @return {unknown} 图片尺寸和底部色彩/纹理指标;发现错误时抛出 CliError。
|
|
42
|
+
* @example validateProjectCardBackground(context)
|
|
43
|
+
*/
|
|
44
|
+
export function validateProjectCardBackground(context) {
|
|
45
|
+
return runValidation(() => validator.validateProjectCardBackground(context))
|
|
46
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readFileSync, statSync, realpathSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const IMAGE_INLINE_LIMIT = 4096;
|
|
5
|
+
const SKILL_GUIDANCE = "请查阅 microapp-optimize-interest-card/SKILL.md 中的图片规则";
|
|
6
|
+
|
|
7
|
+
/** Validate the image inventory after compilation. Findings only warn. */
|
|
8
|
+
export function validateImageInline(context) {
|
|
9
|
+
const cardRoot =
|
|
10
|
+
context.target === "opencard" ? context.root : context.opencardRoot;
|
|
11
|
+
if (!cardRoot || (context.target !== "opencard" && !context.hasOpenCard)) {
|
|
12
|
+
return { warnings: 0, unverified: 0 };
|
|
13
|
+
}
|
|
14
|
+
const reportFile = path.join(
|
|
15
|
+
cardRoot, "node_modules/.cache/vibecoding/image-inline-tt.json",
|
|
16
|
+
);
|
|
17
|
+
try {
|
|
18
|
+
const report = JSON.parse(readFileSync(reportFile, "utf8"));
|
|
19
|
+
if (report.schemaVersion !== 3 || report.status !== "complete" ||
|
|
20
|
+
report.target !== "tt" ||
|
|
21
|
+
realpathSync(report.projectRoot) !== realpathSync(cardRoot) ||
|
|
22
|
+
!Array.isArray(report.findings) || !Array.isArray(report.issues) ||
|
|
23
|
+
!report.findings.every(item => item && typeof item.file === "string" && Number.isInteger(item.line) && item.line > 0 &&
|
|
24
|
+
Number.isInteger(item.column) && item.column > 0 &&
|
|
25
|
+
(item.kind === "import" || item.kind === "data-url") &&
|
|
26
|
+
(item.kind === "import" ? typeof item.image === "string" : typeof item.dataUrl === "string")) ||
|
|
27
|
+
!report.issues.every(issue => typeof issue === "string")) {
|
|
28
|
+
throw new Error("图片检查报告格式或工程身份不匹配");
|
|
29
|
+
}
|
|
30
|
+
const warnings = [];
|
|
31
|
+
const issues = [...report.issues];
|
|
32
|
+
for (const item of report.findings) {
|
|
33
|
+
let bytes;
|
|
34
|
+
try {
|
|
35
|
+
if (item.kind === "import") {
|
|
36
|
+
const info = statSync(path.resolve(cardRoot, item.image));
|
|
37
|
+
if (!info.isFile()) throw new Error("图片路径不是文件");
|
|
38
|
+
bytes = info.size;
|
|
39
|
+
} else {
|
|
40
|
+
const match = /^data:image\/[a-z0-9.+-]+(?:;[a-z0-9_-]+=[^;,\s]+)*;base64,([a-z0-9+/]*={0,2})$/i.exec(item.dataUrl);
|
|
41
|
+
const encoded = match?.[1];
|
|
42
|
+
if (!encoded || encoded.length % 4 === 1) throw new Error("图片 Base64 编码无效");
|
|
43
|
+
const decoded = Buffer.from(encoded, "base64");
|
|
44
|
+
if (decoded.toString("base64").replace(/=+$/, "") !== encoded.replace(/=+$/, "") ||
|
|
45
|
+
(encoded.includes("=") && encoded.length % 4 !== 0)) {
|
|
46
|
+
throw new Error("图片 Base64 编码无效");
|
|
47
|
+
}
|
|
48
|
+
bytes = decoded.length;
|
|
49
|
+
}
|
|
50
|
+
} catch (error) {
|
|
51
|
+
issues.push(`${item.file}:${item.line}:${item.column} 检查未完成:${error.message}。`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (bytes < IMAGE_INLINE_LIMIT) continue;
|
|
55
|
+
warnings.push(` ${item.file}:${item.line}:${item.column}\n` +
|
|
56
|
+
(item.kind === "import"
|
|
57
|
+
? ` 类型:图片 import\n 原图:${item.image}\n 大小:${bytes} bytes\n`
|
|
58
|
+
: ` 类型:固定 Base64 图片\n 解码大小:${bytes} bytes\n`));
|
|
59
|
+
}
|
|
60
|
+
if (warnings.length || issues.length) console.warn("=== 图片性能检测报告 ===\n");
|
|
61
|
+
if (warnings.length) {
|
|
62
|
+
console.warn(`检测到以下图片 import 或固定 Base64 图片大小 ≥${IMAGE_INLINE_LIMIT} 字节,存在内联图片性能风险。${SKILL_GUIDANCE}并在确认问题后上传图片以修复以下图片性能问题:\n`);
|
|
63
|
+
for (const warning of warnings) console.warn(warning);
|
|
64
|
+
console.warn(`共 ${warnings.length} 条可能的图片性能问题`);
|
|
65
|
+
}
|
|
66
|
+
if (issues.length) {
|
|
67
|
+
console.warn("图片检查未完成:");
|
|
68
|
+
for (const issue of issues) console.warn(` ${issue}`);
|
|
69
|
+
if (!warnings.length) console.warn(`${SKILL_GUIDANCE}。`);
|
|
70
|
+
}
|
|
71
|
+
return { warnings: warnings.length, unverified: issues.length };
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.warn("=== 图片性能检测报告 ===\n");
|
|
74
|
+
console.warn(
|
|
75
|
+
"图片检查未完成:未取得有效报告。" +
|
|
76
|
+
"请检查 SDK 版本及构建日志。" +
|
|
77
|
+
` (${error.message})`,
|
|
78
|
+
);
|
|
79
|
+
console.warn(`${SKILL_GUIDANCE}。`);
|
|
80
|
+
return { warnings: 0, unverified: 1 };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -53,7 +53,7 @@ export function validateOpenCardStructure(context) {
|
|
|
53
53
|
`found ${preloadCount} preload call(s). ` +
|
|
54
54
|
'Use taro.preloadMiniApp({ appList }) on the Card instance provided by useCardTaro from @tarojs/taro (opencard-taro); ' +
|
|
55
55
|
'this is not a named export of @open-dy/vibecoding-sdk-opencard. ' +
|
|
56
|
-
'See ${
|
|
56
|
+
'See ${CAPABILITY_SKILL_DIR}/references/opencard/runtime.md, section "小程序跳转预加载", for the lifecycle guards and example.',
|
|
57
57
|
)
|
|
58
58
|
}
|
|
59
59
|
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate the bottom continuation of a Feed Card background.
|
|
3
|
+
|
|
4
|
+
The current composer emits a 1440x2560 RGB PNG with an exact baseColor fill
|
|
5
|
+
below its hero. Existing cards also contain earlier valid variants with a
|
|
6
|
+
same-hue darkened or softly feathered continuation. This compatibility gate
|
|
7
|
+
therefore samples the whole bottom 30%, requiring a low-texture, same-hue
|
|
8
|
+
continuation while still rejecting raw heroes or attachments that run to the
|
|
9
|
+
host-button safety area.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import struct
|
|
16
|
+
import sys
|
|
17
|
+
import urllib.request
|
|
18
|
+
import zlib
|
|
19
|
+
|
|
20
|
+
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
21
|
+
SAMPLE_W = 72
|
|
22
|
+
SAMPLE_H = 128
|
|
23
|
+
BOTTOM_START_RATIO = 0.70
|
|
24
|
+
MAX_HORIZONTAL_TEXTURE = 3.0
|
|
25
|
+
MAX_VERTICAL_TEXTURE = 4.0
|
|
26
|
+
MAX_HUE_DELTA = 12.0
|
|
27
|
+
LOW_SATURATION = 15.0
|
|
28
|
+
MAX_NEUTRAL_COLOR_DISTANCE = 24.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fail(message):
|
|
32
|
+
raise ValueError(message)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_color(value):
|
|
36
|
+
if not isinstance(value, str) or len(value) != 7 or not value.startswith("#"):
|
|
37
|
+
fail("PrimaryColor must be a #RRGGBB value for background validation.")
|
|
38
|
+
try:
|
|
39
|
+
return tuple(int(value[index:index + 2], 16) for index in (1, 3, 5))
|
|
40
|
+
except ValueError:
|
|
41
|
+
fail("PrimaryColor must be a #RRGGBB value for background validation.")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def download(url):
|
|
45
|
+
request = urllib.request.Request(url, headers={"User-Agent": "vibecoding-feed-background-validator/1"})
|
|
46
|
+
with urllib.request.urlopen(request, timeout=15) as response:
|
|
47
|
+
content = response.read(12 * 1024 * 1024 + 1)
|
|
48
|
+
if len(content) > 12 * 1024 * 1024:
|
|
49
|
+
fail("CardBackgroundUrl exceeds the 12 MiB validation download limit.")
|
|
50
|
+
return content
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def paeth(a, b, c):
|
|
54
|
+
p = a + b - c
|
|
55
|
+
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
|
|
56
|
+
return a if pa <= pb and pa <= pc else (b if pb <= pc else c)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def decode_png(content):
|
|
60
|
+
if not content.startswith(PNG_SIGNATURE):
|
|
61
|
+
fail("CardBackgroundUrl must point to the final PNG produced by the background pipeline; received a non-PNG image.")
|
|
62
|
+
position = len(PNG_SIGNATURE)
|
|
63
|
+
width = height = bit_depth = color_type = interlace = None
|
|
64
|
+
idat = []
|
|
65
|
+
while position < len(content):
|
|
66
|
+
if position + 12 > len(content):
|
|
67
|
+
fail("CardBackgroundUrl PNG is truncated.")
|
|
68
|
+
size = struct.unpack(">I", content[position:position + 4])[0]
|
|
69
|
+
chunk_type = content[position + 4:position + 8]
|
|
70
|
+
chunk = content[position + 8:position + 8 + size]
|
|
71
|
+
position += size + 12
|
|
72
|
+
if chunk_type == b"IHDR":
|
|
73
|
+
width, height, bit_depth, color_type, compression, filter_method, interlace = struct.unpack(">IIBBBBB", chunk)
|
|
74
|
+
if compression != 0 or filter_method != 0:
|
|
75
|
+
fail("CardBackgroundUrl PNG uses an unsupported PNG encoding.")
|
|
76
|
+
elif chunk_type == b"IDAT":
|
|
77
|
+
idat.append(chunk)
|
|
78
|
+
elif chunk_type == b"IEND":
|
|
79
|
+
break
|
|
80
|
+
if not width or not height or bit_depth != 8 or color_type not in (2, 6) or interlace != 0:
|
|
81
|
+
fail("CardBackgroundUrl must be a non-interlaced 8-bit RGB or RGBA PNG.")
|
|
82
|
+
channels = 4 if color_type == 6 else 3
|
|
83
|
+
row_bytes = width * channels
|
|
84
|
+
raw = zlib.decompress(b"".join(idat))
|
|
85
|
+
if len(raw) != height * (row_bytes + 1):
|
|
86
|
+
fail("CardBackgroundUrl PNG pixel data is invalid.")
|
|
87
|
+
rows, previous, cursor = [], bytearray(row_bytes), 0
|
|
88
|
+
for _ in range(height):
|
|
89
|
+
filter_type = raw[cursor]
|
|
90
|
+
cursor += 1
|
|
91
|
+
encoded = raw[cursor:cursor + row_bytes]
|
|
92
|
+
cursor += row_bytes
|
|
93
|
+
row = bytearray(row_bytes)
|
|
94
|
+
for index, value in enumerate(encoded):
|
|
95
|
+
left = row[index - channels] if index >= channels else 0
|
|
96
|
+
up = previous[index]
|
|
97
|
+
up_left = previous[index - channels] if index >= channels else 0
|
|
98
|
+
if filter_type == 0:
|
|
99
|
+
row[index] = value
|
|
100
|
+
elif filter_type == 1:
|
|
101
|
+
row[index] = (value + left) & 255
|
|
102
|
+
elif filter_type == 2:
|
|
103
|
+
row[index] = (value + up) & 255
|
|
104
|
+
elif filter_type == 3:
|
|
105
|
+
row[index] = (value + ((left + up) // 2)) & 255
|
|
106
|
+
elif filter_type == 4:
|
|
107
|
+
row[index] = (value + paeth(left, up, up_left)) & 255
|
|
108
|
+
else:
|
|
109
|
+
fail("CardBackgroundUrl PNG uses an unsupported row filter.")
|
|
110
|
+
rows.append(row)
|
|
111
|
+
previous = row
|
|
112
|
+
return width, height, channels, rows
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def rgb_to_hsl(rgb):
|
|
116
|
+
r, g, b = (channel / 255.0 for channel in rgb)
|
|
117
|
+
maximum, minimum = max(r, g, b), min(r, g, b)
|
|
118
|
+
lightness = (maximum + minimum) / 2.0
|
|
119
|
+
if maximum == minimum:
|
|
120
|
+
return 0.0, 0.0, lightness * 100.0
|
|
121
|
+
delta = maximum - minimum
|
|
122
|
+
saturation = delta / (2.0 - maximum - minimum) if lightness > 0.5 else delta / (maximum + minimum)
|
|
123
|
+
if maximum == r:
|
|
124
|
+
hue = ((g - b) / delta + (6.0 if g < b else 0.0)) / 6.0
|
|
125
|
+
elif maximum == g:
|
|
126
|
+
hue = ((b - r) / delta + 2.0) / 6.0
|
|
127
|
+
else:
|
|
128
|
+
hue = ((r - g) / delta + 4.0) / 6.0
|
|
129
|
+
return hue * 360.0, saturation * 100.0, lightness * 100.0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def hue_delta(first, second):
|
|
133
|
+
return min(abs(first - second), 360.0 - abs(first - second))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def color_distance(first, second):
|
|
137
|
+
return sum(abs(first[index] - second[index]) for index in range(3)) / 3.0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def inspect(content, primary):
|
|
141
|
+
width, height, channels, rows = decode_png(content)
|
|
142
|
+
if abs(width / height - 9 / 16) > 0.01:
|
|
143
|
+
fail(f"CardBackgroundUrl must be 9:16, got {width}x{height}.")
|
|
144
|
+
|
|
145
|
+
bottom_start = math.floor(height * BOTTOM_START_RATIO)
|
|
146
|
+
for y in range(bottom_start, height):
|
|
147
|
+
row = rows[y]
|
|
148
|
+
for x in range(width):
|
|
149
|
+
offset = x * channels
|
|
150
|
+
if channels == 4 and row[offset + 3] != 255:
|
|
151
|
+
fail("CardBackgroundUrl bottom continuation must be fully opaque.")
|
|
152
|
+
|
|
153
|
+
sample_x = [round(index * (width - 1) / (SAMPLE_W - 1)) for index in range(SAMPLE_W)]
|
|
154
|
+
sample_y = [round(index * (height - 1) / (SAMPLE_H - 1)) for index in range(SAMPLE_H)]
|
|
155
|
+
pixels = [
|
|
156
|
+
[tuple(rows[y][x * channels + channel] for channel in range(3)) for x in sample_x]
|
|
157
|
+
for y in sample_y
|
|
158
|
+
]
|
|
159
|
+
sample_start = math.floor(SAMPLE_H * BOTTOM_START_RATIO)
|
|
160
|
+
bottom = pixels[sample_start:]
|
|
161
|
+
horizontal = [color_distance(row[index], row[index - 1]) for row in bottom for index in range(1, SAMPLE_W)]
|
|
162
|
+
vertical = [
|
|
163
|
+
color_distance(bottom[row_index][column], bottom[row_index - 1][column])
|
|
164
|
+
for row_index in range(1, len(bottom))
|
|
165
|
+
for column in range(SAMPLE_W)
|
|
166
|
+
]
|
|
167
|
+
average = tuple(round(sum(pixel[channel] for row in bottom for pixel in row) / (len(bottom) * SAMPLE_W)) for channel in range(3))
|
|
168
|
+
average_hue, _, _ = rgb_to_hsl(average)
|
|
169
|
+
primary_hue, primary_saturation, _ = rgb_to_hsl(primary)
|
|
170
|
+
horizontal_texture = sum(horizontal) / len(horizontal)
|
|
171
|
+
vertical_texture = sum(vertical) / len(vertical)
|
|
172
|
+
issues = []
|
|
173
|
+
if horizontal_texture > MAX_HORIZONTAL_TEXTURE:
|
|
174
|
+
issues.append(f"horizontal texture={horizontal_texture:.1f}>{MAX_HORIZONTAL_TEXTURE}")
|
|
175
|
+
if vertical_texture > MAX_VERTICAL_TEXTURE:
|
|
176
|
+
issues.append(f"vertical texture={vertical_texture:.1f}>{MAX_VERTICAL_TEXTURE}")
|
|
177
|
+
if primary_saturation < LOW_SATURATION:
|
|
178
|
+
neutral_distance = color_distance(average, primary)
|
|
179
|
+
if neutral_distance > MAX_NEUTRAL_COLOR_DISTANCE:
|
|
180
|
+
issues.append(f"neutral color distance={neutral_distance:.1f}>{MAX_NEUTRAL_COLOR_DISTANCE}")
|
|
181
|
+
else:
|
|
182
|
+
delta = hue_delta(average_hue, primary_hue)
|
|
183
|
+
if delta > MAX_HUE_DELTA:
|
|
184
|
+
issues.append(f"hue delta={delta:.1f}>{MAX_HUE_DELTA}")
|
|
185
|
+
if issues:
|
|
186
|
+
fail(
|
|
187
|
+
"CardBackgroundUrl bottom 30% is not a low-texture PrimaryColor continuation (" +
|
|
188
|
+
", ".join(issues) +
|
|
189
|
+
"). Re-run the background four-step pipeline or regenerate a same-hue solid/gradient extension."
|
|
190
|
+
)
|
|
191
|
+
return {
|
|
192
|
+
"width": width,
|
|
193
|
+
"height": height,
|
|
194
|
+
"bottomStartY": bottom_start,
|
|
195
|
+
"bottomHorizontalTexture": round(horizontal_texture, 2),
|
|
196
|
+
"bottomVerticalTexture": round(vertical_texture, 2),
|
|
197
|
+
"bottomAverage": "#" + "".join(f"{channel:02x}" for channel in average),
|
|
198
|
+
"primaryColor": "#" + "".join(f"{channel:02x}" for channel in primary),
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def main():
|
|
203
|
+
parser = argparse.ArgumentParser()
|
|
204
|
+
parser.add_argument("--url", required=True)
|
|
205
|
+
parser.add_argument("--primary-color", required=True)
|
|
206
|
+
args = parser.parse_args()
|
|
207
|
+
result = inspect(download(args.url), parse_color(args.primary_color))
|
|
208
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
if __name__ == "__main__":
|
|
212
|
+
try:
|
|
213
|
+
main()
|
|
214
|
+
except Exception as error:
|
|
215
|
+
print(f"[Background Validation Failed] {error}", file=sys.stderr)
|
|
216
|
+
sys.exit(1)
|