@zzclub/pipeline 0.12.0 → 0.13.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.
Files changed (44) hide show
  1. package/README.md +3 -1
  2. package/package.json +2 -2
  3. package/src/adapter-types.ts +3 -0
  4. package/src/adapters/builtin-image-renderer.test.ts +32 -0
  5. package/src/adapters/builtin-image-renderer.ts +41 -73
  6. package/src/commands/config.ts +14 -1
  7. package/src/commands/imgx.ts +3 -3
  8. package/src/commands/ingest-handoff.ts +15 -0
  9. package/src/commands/init.ts +5 -0
  10. package/src/commands/render-theme.test.ts +115 -0
  11. package/src/commands/render.ts +27 -9
  12. package/src/config.ts +5 -0
  13. package/src/cover-theme-presets.json +59 -0
  14. package/src/imgx/assets/browser/cover.css +25 -0
  15. package/src/imgx/assets/browser/cover.js +40 -0
  16. package/src/imgx/assets/templates/longform-3-4.html +14 -36
  17. package/src/imgx/assets/templates/poster-3-4.html +15 -394
  18. package/src/imgx/assets/templates/wechat-cover-split.html +15 -334
  19. package/src/imgx/chrome-render.test.ts +38 -0
  20. package/src/imgx/chrome-render.ts +110 -0
  21. package/src/imgx/cover.test.ts +80 -0
  22. package/src/imgx/cover.ts +130 -0
  23. package/src/imgx/inline-text.ts +56 -0
  24. package/src/imgx/longform-theme.ts +74 -114
  25. package/src/imgx/obstacle-flow.test.ts +52 -0
  26. package/src/imgx/obstacle-flow.ts +177 -0
  27. package/src/imgx/pretext-adapter.ts +3 -0
  28. package/src/imgx/pretext-runtime.ts +0 -1
  29. package/src/imgx/render-article.test.ts +26 -1
  30. package/src/imgx/render-article.ts +165 -382
  31. package/src/imgx/render-card.ts +64 -204
  32. package/src/imgx/runtime.ts +0 -90
  33. package/src/render-branding.test.ts +26 -0
  34. package/src/render-branding.ts +24 -0
  35. package/src/runtime-paths.ts +12 -51
  36. package/src/schema/config.ts +15 -0
  37. package/src/schema/cover-theme.test.ts +54 -0
  38. package/src/schema/cover-theme.ts +164 -0
  39. package/src/schema/state.ts +2 -0
  40. package/src/text.test.ts +7 -7
  41. package/src/text.ts +2 -27
  42. package/src/workflow.test.ts +2 -1
  43. package/src/imgx/assets/browser/obstacle-flow.d.ts +0 -27
  44. package/src/imgx/assets/browser/obstacle-flow.js +0 -326
@@ -20,12 +20,15 @@ import { join } from "path";
20
20
  import { parseArgs, requireArg, flagArg } from "../args";
21
21
  import { printResult, renderRender } from "../output";
22
22
  import { loadConfig } from "../config";
23
+ import { resolveRenderBranding } from "../render-branding";
24
+ import { resolveCoverTheme } from "../schema/cover-theme";
23
25
  import { resolveImageRenderer } from "../adapter-loader";
24
26
  import {
25
27
  acquireStateOperationLock,
26
28
  defaultBodyInputs,
27
29
  normalizeNewspicRenderSpec,
28
30
  readResolvedState,
31
+ reenterRender,
29
32
  validateForPhase,
30
33
  writeState,
31
34
  type NewspicRenderSpec,
@@ -59,6 +62,7 @@ Usage: zzhub-pipeline render [options]
59
62
  Options:
60
63
  --state Path to state JSON (required)
61
64
  --skip-render Plan only, don't invoke renderer (optional)
65
+ --cover-theme Cover theme ID; saves the task choice (optional)
62
66
  `.trim());
63
67
  return;
64
68
  }
@@ -88,6 +92,20 @@ Options:
88
92
  // ── Image Plan ──────────────────────────────────────────────────
89
93
 
90
94
  const routePrimary = state.route.primary;
95
+ const config = loadConfig();
96
+ const requestedTheme = parsed["cover-theme"] === undefined ? undefined : requireArg(parsed, "cover-theme", "cover theme ID").trim();
97
+ const coverTheme = routePrimary === "blog" ? undefined : resolveCoverTheme(
98
+ config.render.cover,
99
+ routePrimary === "wechat-article" ? "wechat-cover-split" : "poster-3-4",
100
+ state.route.account,
101
+ requestedTheme ?? state.intent.cover_theme,
102
+ );
103
+ if (requestedTheme !== undefined && requestedTheme !== state.intent.cover_theme) {
104
+ if (!coverTheme) throw new Error("blog 路由不需要封面主题");
105
+ state.intent.cover_theme = requestedTheme;
106
+ reenterRender(state);
107
+ await writeState(statePath, state);
108
+ }
91
109
 
92
110
  if (routePrimary === "blog") {
93
111
  // Blog doesn't need images by default
@@ -143,6 +161,7 @@ Options:
143
161
  template,
144
162
  cover_template: coverTemplate,
145
163
  cover_title: coverTitle,
164
+ cover_theme: coverTheme?.id ?? null,
146
165
  output_dir: outputDir,
147
166
  preview_required: false,
148
167
  status: "planned",
@@ -203,7 +222,6 @@ Options:
203
222
 
204
223
  // ── Invoke image renderer adapter ───────────────────────────────
205
224
 
206
- const config = loadConfig();
207
225
  const imageRenderer = await resolveImageRenderer(config);
208
226
  reportProgress({ stage: "render.adapter", message: "开始生成图片" });
209
227
 
@@ -214,7 +232,9 @@ Options:
214
232
  ? state.images.body_inputs.received
215
233
  : [];
216
234
 
235
+ const branding = resolveRenderBranding(config, state.route.account);
217
236
  const renderResult = await imageRenderer.render({
237
+ coverTheme,
218
238
  onProgress: reportProgress,
219
239
  state,
220
240
  bodyText: cleanBody,
@@ -231,14 +251,12 @@ Options:
231
251
  bodyImages,
232
252
  minPages: newspicRenderSpec.min_pages,
233
253
  maxPages: newspicRenderSpec.max_pages,
234
- accountVisualParams: state.route.account_visual_params
235
- ? {
236
- footer: state.route.account_visual_params.footer,
237
- bg: state.route.account_visual_params.bg,
238
- highlight: state.route.account_visual_params.highlight,
239
- fallbackIcon: state.route.account_visual_params.fallback_icon,
240
- }
241
- : undefined,
254
+ accountVisualParams: {
255
+ footer: branding.footerText,
256
+ bg: state.route.account_visual_params?.bg,
257
+ highlight: state.route.account_visual_params?.highlight,
258
+ fallbackIcon: branding.logo,
259
+ },
242
260
  theme: routePrimary === "wechat-newspic" ? getLongformTheme(state.route.account) : undefined,
243
261
  template,
244
262
  });
package/src/config.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  import { homedir, platform } from "os";
11
11
  import { dirname, isAbsolute, join, resolve } from "path";
12
12
  import { PipelineConfigSchema } from "./schema/config";
13
+ import { mergeCoverSettings } from "./schema/cover-theme";
13
14
 
14
15
  import type {
15
16
  PipelineConfig,
@@ -196,6 +197,7 @@ export function normalizeConfig(value: unknown, legacyValue?: unknown): Pipeline
196
197
  cos: mergedCos,
197
198
  plugins: mergedPlugins,
198
199
  imgx: mergedImgx,
200
+ render: mergeCoverSettings(legacy.render ?? {}, source.render ?? {}),
199
201
  });
200
202
  }
201
203
 
@@ -403,6 +405,9 @@ export function setConfigValue(
403
405
  ): PipelineConfig {
404
406
  const next = JSON.parse(JSON.stringify(config)) as PipelineConfig;
405
407
  const segments = key.split(".").filter(Boolean);
408
+ if (segments.some((segment) => ["__proto__", "constructor", "prototype"].includes(segment))) {
409
+ throw new Error("invalid config key");
410
+ }
406
411
  if (segments.length === 0) {
407
412
  throw new Error("config key cannot be empty");
408
413
  }
@@ -0,0 +1,59 @@
1
+ {
2
+ "business": {
3
+ "name": "商务", "description": "浅灰与深蓝,左对齐标题与细线",
4
+ "colors": { "text": "#13243A", "accent": "#2457A7", "muted": "#586A80" },
5
+ "background": { "fill": { "type": "solid", "color": "#F1F5F9" } },
6
+ "decoration": { "pattern": "lines", "color": "#2457A7", "opacity": 0.18 }
7
+ },
8
+ "technology": {
9
+ "name": "科技", "description": "深蓝渐变与青色网格",
10
+ "colors": { "text": "#EAF4FF", "accent": "#67E8F9", "muted": "#A6BDD8" },
11
+ "background": { "fill": { "type": "linear", "angle": 135, "stops": [{ "color": "#0B1027", "offset": 0 }, { "color": "#182B5B", "offset": 100 }] } },
12
+ "decoration": { "pattern": "grid", "color": "#67E8F9", "opacity": 0.1, "size": 48 }
13
+ },
14
+ "minimal": {
15
+ "name": "简约", "description": "白底黑字,宽留白",
16
+ "colors": { "text": "#18181B", "accent": "#52525B", "muted": "#71717A" },
17
+ "background": { "fill": { "type": "solid", "color": "#FFFFFF" } },
18
+ "layout": { "safeArea": { "left": 108, "right": 108, "top": 108, "bottom": 72 } }
19
+ },
20
+ "solid": {
21
+ "name": "纯色", "description": "钴蓝与白色大字,黄色强调",
22
+ "colors": { "text": "#FFFFFF", "accent": "#FFE08A", "muted": "#D9E1FF" },
23
+ "background": { "fill": { "type": "solid", "color": "#3155D9" } },
24
+ "layout": { "textAlign": "center" }
25
+ },
26
+ "cute": {
27
+ "name": "可爱", "description": "粉黄渐变、手写字与轻盈圆点",
28
+ "typography": { "title": { "fontFamily": "LXGWWenKai", "fontWeight": 700 }, "subtitle": { "fontFamily": "LXGWWenKai", "fontSize": 56, "minFontSize": 40, "fontWeight": 400, "lineHeight": 1.4 } },
29
+ "colors": { "text": "#623C47", "accent": "#D64B78", "muted": "#856675" },
30
+ "background": { "fill": { "type": "linear", "angle": 150, "stops": [{ "color": "#FFF1F4", "offset": 0 }, { "color": "#FFF6DF", "offset": 100 }] } },
31
+ "decoration": { "pattern": "dots", "color": "#E4658C", "opacity": 0.12, "size": 58 },
32
+ "layout": { "textAlign": "center" }
33
+ },
34
+ "serious": {
35
+ "name": "严肃", "description": "米白纸面、墨色宋体与暗红强调",
36
+ "typography": { "title": { "fontFamily": "LXGWNeoZhiSongPlus", "fontWeight": 700 } },
37
+ "colors": { "text": "#202020", "accent": "#8A302B", "muted": "#655E54" },
38
+ "background": { "fill": { "type": "solid", "color": "#F3F0E8" } },
39
+ "decoration": { "pattern": "lines", "color": "#8A302B", "opacity": 0.18 }
40
+ },
41
+ "abstract": {
42
+ "name": "抽象", "description": "淡紫与冰蓝,边缘错位圆环",
43
+ "colors": { "text": "#302456", "accent": "#7653D5", "muted": "#695A87" },
44
+ "background": { "fill": { "type": "radial", "position": { "x": 20, "y": 10 }, "stops": [{ "color": "#EEE6FF", "offset": 0 }, { "color": "#E1F2FF", "offset": 100 }] } },
45
+ "decoration": { "pattern": "rings", "color": "#7653D5", "opacity": 0.16, "size": 180 }
46
+ },
47
+ "entertainment": {
48
+ "name": "娱乐", "description": "明黄底色、深紫大字与粉色几何装饰",
49
+ "colors": { "text": "#241341", "accent": "#B52D74", "muted": "#62532B" },
50
+ "background": { "fill": { "type": "solid", "color": "#FFF06A" } },
51
+ "decoration": { "pattern": "burst", "color": "#ED4F9A", "opacity": 0.32, "size": 144 }
52
+ },
53
+ "fresh-sage": { "name": "清新绿", "description": "早早集市原有绿色配色" },
54
+ "soft-rose": {
55
+ "name": "柔和粉", "description": "古一账号原有粉色配色",
56
+ "colors": { "text": "#1A1A1A", "accent": "#CA6093", "muted": "#795C6D" },
57
+ "background": { "fill": { "type": "solid", "color": "#FAF5F8" } }
58
+ }
59
+ }
@@ -0,0 +1,25 @@
1
+ * { box-sizing: border-box; }
2
+ html, body { margin: 0; }
3
+ .cover { width: var(--width); height: var(--height); position: relative; overflow: hidden; background: var(--background); color: var(--text); }
4
+ .layer { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
5
+ .pattern { opacity: var(--decoration-opacity); }
6
+ .pattern-lines { border-top: 2px solid var(--decoration); border-bottom: 2px solid var(--decoration); inset: 24px; width: auto; height: auto; }
7
+ .pattern-grid { background-image: linear-gradient(var(--decoration) 1px, transparent 1px), linear-gradient(90deg, var(--decoration) 1px, transparent 1px); background-size: var(--decoration-size) var(--decoration-size); }
8
+ .pattern-dots { background-image: radial-gradient(circle, var(--decoration) 2px, transparent 3px); background-size: var(--decoration-size) var(--decoration-size); }
9
+ .pattern-rings { background: repeating-radial-gradient(circle at 105% 8%, transparent 0, transparent calc(var(--decoration-size) - 3px), var(--decoration) calc(var(--decoration-size) - 2px), var(--decoration) var(--decoration-size)); }
10
+ .pattern-burst { background: repeating-conic-gradient(from 15deg at 105% -5%, var(--decoration) 0deg 8deg, transparent 8deg 28deg); mask-image: radial-gradient(circle at 100% 0, black 0, transparent 65%); }
11
+ .copy { position: absolute; inset: 0 auto 0 0; width: var(--copy-width); }
12
+ .text-area { position: absolute; top: var(--top); right: var(--right); bottom: calc(var(--bottom) + 80px); left: var(--left); display: flex; flex-direction: column; justify-content: var(--vertical); text-align: var(--align); }
13
+ .text-stack { flex: 0 0 auto; width: 100%; }
14
+ .title, .subtitle { margin: 0; white-space: pre-wrap; text-wrap: balance; overflow-wrap: anywhere; }
15
+ .title { font-family: var(--title-font), sans-serif; font-size: var(--title-size); font-weight: var(--title-weight); line-height: var(--title-leading); letter-spacing: var(--title-tracking); }
16
+ .subtitle { margin-top: var(--block-gap); font-family: var(--subtitle-font), serif; font-size: var(--subtitle-size); font-weight: var(--subtitle-weight); line-height: var(--subtitle-leading); letter-spacing: var(--subtitle-tracking); }
17
+ .subtitle:empty { display: none; }
18
+ .highlight { color: var(--accent); }
19
+ .signature { position: absolute; left: var(--left); right: var(--right); bottom: var(--bottom); display: flex; align-items: center; gap: 12px; color: var(--muted); }
20
+ .signature span { min-width: 0; overflow-wrap: anywhere; font-family: var(--footer-font), sans-serif; font-size: var(--footer-size); font-weight: var(--footer-weight); line-height: var(--footer-leading); letter-spacing: var(--footer-tracking); }
21
+ .signature img { flex: 0 0 auto; width: 36px; height: 36px; border-radius: 50%; object-fit: contain; }
22
+ .logo-panel { display: none; }
23
+ .cover-banner .signature img { display: none; }
24
+ .cover-banner .logo-panel { display: flex; position: absolute; right: 0; top: 0; width: 400px; height: 400px; align-items: center; justify-content: center; }
25
+ .logo-panel img { width: 216px; height: 216px; border-radius: 40px; object-fit: contain; }
@@ -0,0 +1,40 @@
1
+ // 字号测量和最终成图都使用同一个 DOM,避免二次换行偏差。
2
+ window.__zzhubRenderReady = (async function fitCover() {
3
+ try {
4
+ const config = JSON.parse(document.getElementById("cover-config").textContent);
5
+ const cover = document.querySelector(".cover");
6
+ const area = document.querySelector(".text-area");
7
+ const stack = document.querySelector(".text-stack");
8
+ const signature = document.querySelector(".signature");
9
+ const title = document.querySelector(".title");
10
+ const subtitle = document.querySelector(".subtitle");
11
+ // 大型 CJK 字体必须加载完成,不能用回退字体计算字号。
12
+ await Promise.all(Object.values(config.typography).map(font => document.fonts.load(`${font.fontWeight} ${font.fontSize}px "${font.fontFamily}"`)));
13
+ await document.fonts.ready;
14
+ await Promise.all([...document.images].map(image => image.decode()));
15
+ area.style.bottom = `${config.layout.safeArea.bottom + signature.getBoundingClientRect().height + 32}px`;
16
+ if (area.clientWidth <= 0 || area.clientHeight <= 0) throw new Error("封面安全区无法容纳文字和署名");
17
+ function fit(ratio) {
18
+ for (const role of ["title", "subtitle"]) {
19
+ const font = config.typography[role];
20
+ cover.style.setProperty(`--${role}-size`, `${font.minFontSize + (font.fontSize - font.minFontSize) * ratio}px`);
21
+ }
22
+ return stack.getBoundingClientRect().height <= area.clientHeight + 0.5
23
+ && title.scrollWidth <= title.clientWidth + 1
24
+ && subtitle.scrollWidth <= subtitle.clientWidth + 1;
25
+ }
26
+ if (!fit(0)) throw new Error("完整标题在最小字号下仍超出安全区,请缩短标题或调整主题字号、安全区");
27
+ let low = 0;
28
+ let high = 1;
29
+ for (let iteration = 0; iteration < 12; iteration++) {
30
+ const middle = (low + high) / 2;
31
+ if (fit(middle)) low = middle;
32
+ else high = middle;
33
+ }
34
+ fit(low);
35
+ document.documentElement.dataset.coverStatus = "ready";
36
+ } catch (error) {
37
+ document.documentElement.dataset.coverStatus = "error";
38
+ document.documentElement.dataset.coverError = encodeURIComponent(error instanceof Error ? error.message : String(error));
39
+ }
40
+ })();
@@ -9,8 +9,7 @@
9
9
  Parameters:
10
10
  {{THEME_CSS_VARS}} — 主题变量
11
11
  {{FOOTER_TEXT}} — 底部文字
12
- {{ICON_PATH}} — 顶部 logo
13
- {{AVATAR_PATH}} — 底部头像
12
+ {{ICON_PATH}} — 署名图标
14
13
  {{FONT_PATH}} — 顶部/底部字体
15
14
  {{BODY_FONT_PATH}} — 正文字体
16
15
  {{LONGFORM_STAGE_HTML}} — 已排版页 HTML
@@ -46,18 +45,6 @@
46
45
  padding: var(--body-padding-y) var(--body-padding-x) 0;
47
46
  flex-shrink: 0;
48
47
  }
49
- .deco-top {
50
- width: 100%;
51
- height: var(--logo-size);
52
- display: flex;
53
- align-items: flex-start;
54
- }
55
- .deco-top img {
56
- width: var(--logo-size);
57
- height: var(--logo-size);
58
- border-radius: 50%;
59
- object-fit: cover;
60
- }
61
48
  .content {
62
49
  min-height: 0;
63
50
  width: 100%;
@@ -78,31 +65,23 @@
78
65
  position: absolute;
79
66
  left: 0;
80
67
  font-family: "LXGWNeoZhiSongPlus", "PingFang SC", "Noto Serif SC", serif;
81
- font-size: 32px;
82
- line-height: 1.75;
68
+
83
69
  color: var(--body-color);
84
70
  white-space: pre;
85
71
  }
86
- .body-heading {
72
+ .body-heading, .body-subheading, .body-minor-heading {
87
73
  position: absolute;
88
- left: 0;
89
- font-family: "AlimamaShuHeiTi", "PingFang SC", sans-serif;
90
- font-size: 38px;
91
- line-height: 1.47;
92
74
  color: var(--accent-color);
93
75
  white-space: pre;
94
76
  }
95
77
  .body-quote {
96
78
  position: absolute;
97
79
  left: 0;
98
- padding-left: 20px;
99
- border-left: 4px solid color-mix(in srgb, var(--accent-color) 38%, white);
100
80
  font-family: "LXGWNeoZhiSongPlus", "PingFang SC", "Noto Serif SC", serif;
101
- font-size: 30px;
102
- line-height: 1.73;
103
81
  color: var(--quote-color);
104
82
  white-space: pre;
105
83
  }
84
+ .body-quote::before { content: ""; position: absolute; left: -24px; top: 8px; bottom: 8px; border-left: 3px solid var(--accent-color); }
106
85
  .body-list-bullet {
107
86
  position: absolute;
108
87
  min-width: 24px;
@@ -118,9 +97,9 @@
118
97
  .body-caption {
119
98
  position: absolute;
120
99
  left: 0;
121
- font-family: "AlimamaShuHeiTi", "PingFang SC", sans-serif;
122
- font-size: 20px;
123
- line-height: 1.35;
100
+ font-family: "LXGWNeoZhiSongPlus", "PingFang SC", serif;
101
+ font-size: 28px;
102
+ line-height: 40px;
124
103
  color: var(--caption-color);
125
104
  white-space: normal;
126
105
  text-align: center;
@@ -150,20 +129,18 @@
150
129
  border-radius: 50%;
151
130
  object-fit: cover;
152
131
  }
132
+ .watermark .page-number { margin-left: auto; font-variant-numeric: tabular-nums; }
153
133
  .watermark span {
154
- font-size: 24px;
134
+ font-size: 26px;
135
+ line-height: 36px;
155
136
  color: var(--watermark-color);
156
137
  font-family: "AlimamaShuHeiTi", "PingFang SC", sans-serif;
157
- letter-spacing: 1px;
138
+ letter-spacing: 0;
158
139
  }
159
140
  </style>
160
141
  </head>
161
142
  <body>
162
- <div class="header-shell">
163
- <div class="deco-top">
164
- <img src="{{ICON_PATH}}" alt="icon">
165
- </div>
166
- </div>
143
+ <div class="header-shell"></div>
167
144
 
168
145
  <main class="content">
169
146
  <div class="content-stage" id="content-stage">{{LONGFORM_STAGE_HTML}}</div>
@@ -171,8 +148,9 @@
171
148
 
172
149
  <div class="footer-shell">
173
150
  <div class="watermark">
174
- <img src="{{AVATAR_PATH}}" alt="Jinx">
151
+ {{BRAND_IMAGE}}
175
152
  <span>{{FOOTER_TEXT}}</span>
153
+ <span class="page-number">{{PAGE_LABEL}}</span>
176
154
  </div>
177
155
  </div>
178
156
  </body>