@open-dy/vibecoding-cli 0.1.10 → 0.1.12

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.
@@ -45,14 +45,14 @@
45
45
  "minLength": 2,
46
46
  "maxLength": 4,
47
47
  "pattern": "^[a-zA-Z0-9\\u4e00-\\u9fa5]+$",
48
- "description": "结合内容特点生成推荐理由前缀,与 Suffix 拼接后共同表达完整推荐理由,避免重复;Prefix 与 Suffix 拼接后的总长度必须在 2~10 个字之间(即两者长度之和 ≥ 2 且 ≤ 10),仅支持中英文、数字,不可输入特殊符号"
48
+ "description": "Feed 卡顶部标题前缀,与 Suffix 拼接成完整标题。完整标题须具体可辨识,从用户视角描述实际功能,让用户看懂用途与操作方向;不能仅用地名、行业/品类、抽象概念或缺少功能描述的主题短语,不用内部术语、黑话。正例:生成今日穿搭、记录每日步数;反例:北京、美食、生活、穿搭灵感、星座。长度和格式按本 Schema:前缀 2~4 字,拼接后 4~10 字,仅中英文、数字,无特殊符号"
49
49
  },
50
50
  "RecommendReasonSuffix": {
51
51
  "type": "string",
52
52
  "minLength": 2,
53
53
  "maxLength": 6,
54
54
  "pattern": "^[a-zA-Z0-9\\u4e00-\\u9fa5]+$",
55
- "description": "结合内容特点生成推荐理由后缀,强调内容的吸引力和浏览价值;Prefix 与 Suffix 拼接后的总长度必须在 2~10 个字之间(即两者长度之和 ≥ 2 且 ≤ 10),仅支持中英文、数字,不可输入特殊符号"
55
+ "description": "Feed 卡顶部标题后缀,与 Prefix 拼接成完整标题,命名要求同 Prefix,避免语义重复。后缀 2~6 字,仅中英文、数字,无特殊符号"
56
56
  },
57
57
  "PrimaryColor": {
58
58
  "type": "string",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-dy/vibecoding-cli",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
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.6",
46
- "@open-dy/vibecoding-sdk-opencard": "0.1.7"
45
+ "@open-dy/vibecoding-sdk-taro": "0.1.8",
46
+ "@open-dy/vibecoding-sdk-opencard": "0.1.9"
47
47
  },
48
48
  "peerDependenciesMeta": {
49
49
  "@open-dy/vibecoding-sdk-taro": {
@@ -2,14 +2,20 @@
2
2
  "schema": 1,
3
3
  "producer": "vibecoding-cli",
4
4
  "packageName": "@open-dy/vibecoding-cli",
5
- "version": "0.1.10",
5
+ "version": "0.1.12",
6
6
  "source": {
7
- "digest": "sha256-67661ec96badefe62c83e1f1f62602e2766897dab993c8d48f723f370e37f47a",
8
- "files": 91
7
+ "digest": "sha256-6e84e2900ac5ac471406d9b2def86714f18d18835d64c2d98ad2a95b28a43276",
8
+ "files": 92
9
9
  },
10
- "payload": "sha256-166f8c77d57596054c06af8b82afe6fed582eeaa51bcbc5d0d8c6bc245a8317e",
10
+ "payload": "sha256-056cfdfc4a2230f18190f7724c528784fe6cad6b53328ff49a8706cad4181b53",
11
11
  "toolchain": {
12
12
  "node": "v22.22.3",
13
+ "runtime": {
14
+ "node": "v22.22.3",
15
+ "platform": "linux",
16
+ "arch": "x64",
17
+ "environment": "sha256-d835dde975a8a25241996191388be38b0dc9c1e5aa8b518b778f4ccdf7a5136a"
18
+ },
13
19
  "packageManager": null
14
20
  }
15
21
  }
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
@@ -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, { full: true })
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,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)