@gobing-ai/knowledge-kit 0.0.28 → 0.0.30

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 (97) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +114 -38
  3. package/package.json +1 -1
  4. package/plugins/generations/daily-article-gen/dist/index.js +45 -5
  5. package/plugins/generations/daily-article-gen/src/index.ts +59 -4
  6. package/plugins/generations/dailynews-gen/dist/index.js +46 -9
  7. package/plugins/generations/dailynews-gen/src/index.ts +36 -1
  8. package/plugins/generations/dailynews-gen/src/script-builder.ts +15 -12
  9. package/plugins/generations/dailynews-gen/src/script-plan.ts +21 -3
  10. package/plugins/generations/episode-plan-gen/dist/index.js +55 -4
  11. package/plugins/generations/episode-plan-gen/src/index.ts +80 -2
  12. package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Bold.otf +0 -0
  13. package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Regular.otf +0 -0
  14. package/plugins/generations/image-gen/assets/fonts/OFL.txt +92 -0
  15. package/plugins/generations/image-gen/assets/fonts/SOURCE.md +15 -0
  16. package/plugins/generations/image-gen/dist/index.js +700 -22
  17. package/plugins/generations/image-gen/package.json +1 -0
  18. package/plugins/generations/image-gen/presets/formats/daily-news-cover.yaml +2 -2
  19. package/plugins/generations/image-gen/presets/formats/xhs-card.yaml +19 -11
  20. package/plugins/generations/image-gen/presets/styles/xhs-knowledge.yaml +2 -1
  21. package/plugins/generations/image-gen/scripts/compose-e2e-artifact.ts +135 -0
  22. package/plugins/generations/image-gen/src/bytes.ts +116 -0
  23. package/plugins/generations/image-gen/src/compose/card.ts +294 -0
  24. package/plugins/generations/image-gen/src/compose/chromium.ts +147 -0
  25. package/plugins/generations/image-gen/src/compose/cmap.ts +177 -0
  26. package/plugins/generations/image-gen/src/index.ts +211 -13
  27. package/plugins/generations/image-gen/src/job.ts +12 -0
  28. package/plugins/generations/image-gen/src/presets.ts +53 -13
  29. package/plugins/generations/image-gen/src/providers/minimax.ts +12 -4
  30. package/plugins/generations/image-gen/src/providers/types.ts +12 -1
  31. package/plugins/kk/plugin.json +1 -1
  32. package/plugins/kk/scripts/daily-voice-style.zh.md +27 -14
  33. package/plugins/kk/scripts/itc-stages.ts +21 -3
  34. package/plugins/kk/scripts/kk-workflow-stages.ts +461 -10
  35. package/plugins/kk/scripts/topic-boundary.json +96 -0
  36. package/plugins/kk/scripts/validate-voicescript.ts +15 -0
  37. package/plugins/kk/skills/content-judge/SKILL.md +30 -0
  38. package/plugins/kk/skills/content-judge/references/workflow-integration.md +9 -2
  39. package/plugins/kk/skills/content-safety/SKILL.md +163 -0
  40. package/plugins/kk/skills/content-safety/references/platforms/apple-podcasts.md +36 -0
  41. package/plugins/kk/skills/content-safety/references/platforms/netease-music.md +31 -0
  42. package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +32 -0
  43. package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +35 -0
  44. package/plugins/kk/skills/publish/SKILL.md +1 -1
  45. package/plugins/kk/skills/xhs-images/SKILL.md +11 -7
  46. package/plugins/kk/skills/xhs-images/references/card-layouts.md +4 -4
  47. package/plugins/kk/workflows/judge-gated-publish-example.yaml +18 -10
  48. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +139 -24
  49. package/plugins/kk/workflows/kk-itc.yaml +21 -8
  50. package/plugins/publishings/podcast-pub/dist/index.js +4 -0
  51. package/plugins/publishings/podcast-pub/src/index.ts +2 -0
  52. package/plugins/publishings/podcast-pub/src/show-notes.ts +6 -0
  53. package/plugins/publishings/xhs-pub/dist/index.js +1 -1
  54. package/plugins/publishings/xhs-pub/src/automation-guard.ts +2 -2
  55. package/plugins/publishings/xhs-pub/src/index.ts +1 -1
  56. package/plugins/publishings/xhs-pub/src/profile-check.ts +2 -2
  57. package/plugins/sp/lib/env.ts +0 -46
  58. package/plugins/sp/lib/inline-run.generated.mjs +0 -1489
  59. package/plugins/sp/scripts/batch-preflight.mjs +0 -346
  60. package/plugins/sp/scripts/batch-preflight.ts +0 -459
  61. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +0 -615
  62. package/plugins/sp/scripts/daily-summary/daily-summary.ts +0 -846
  63. package/plugins/sp/scripts/daily-summary/logger.ts +0 -28
  64. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +0 -223
  65. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +0 -367
  66. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +0 -132
  67. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +0 -169
  68. package/plugins/sp/scripts/feature-dev-precheck.mjs +0 -171
  69. package/plugins/sp/scripts/feature-dev-precheck.ts +0 -238
  70. package/plugins/sp/scripts/feature-sync-bounded.mjs +0 -285
  71. package/plugins/sp/scripts/feature-sync-bounded.ts +0 -478
  72. package/plugins/sp/scripts/feature-verification-steps.ts +0 -275
  73. package/plugins/sp/scripts/history-anatomy-cache.mjs +0 -902
  74. package/plugins/sp/scripts/history-anatomy-cache.ts +0 -1028
  75. package/plugins/sp/scripts/idea-handoff.mjs +0 -22
  76. package/plugins/sp/scripts/idea-handoff.ts +0 -44
  77. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +0 -185
  78. package/plugins/sp/scripts/inline-run-setup.ts +0 -722
  79. package/plugins/sp/scripts/pr-reviewing.mjs +0 -769
  80. package/plugins/sp/scripts/pr-reviewing.ts +0 -925
  81. package/plugins/sp/scripts/quality-gate.mjs +0 -179
  82. package/plugins/sp/scripts/quality-gate.ts +0 -217
  83. package/plugins/sp/scripts/script-contract-check.ts +0 -319
  84. package/plugins/sp/scripts/stage-registry-adapter.ts +0 -1533
  85. package/plugins/sp/scripts/surface-drift-inventory.ts +0 -929
  86. package/plugins/sp/scripts/task-evidence-precheck.ts +0 -181
  87. package/plugins/sp/scripts/task-size-precheck.ts +0 -175
  88. package/plugins/sp/scripts/transition-shim-check.ts +0 -238
  89. package/plugins/sp/scripts/validate-commands.ts +0 -689
  90. package/plugins/sp/scripts/validate-flag-contracts.ts +0 -878
  91. package/plugins/sp/scripts/verify-answer-lint.ts +0 -530
  92. package/plugins/sp/scripts/workflow-step-profile.mjs +0 -316
  93. package/plugins/sp/scripts/workflow-step-profile.ts +0 -456
  94. package/plugins/sp/scripts/wrapup-drift-probe.mjs +0 -190
  95. package/plugins/sp/scripts/wrapup-drift-probe.ts +0 -258
  96. package/plugins/sp/scripts/wrapup-steps.mjs +0 -444
  97. package/plugins/sp/scripts/wrapup-steps.ts +0 -552
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Task 0180 (F616) — Chromium-based card composer (Playwright).
3
+ *
4
+ * Default `CardComposer` implementation. Launches Chromium headless, renders
5
+ * the HTML card, measures every [data-field] box against the safe area (R2),
6
+ * and screenshots the result. The browser is always closed in `finally`.
7
+ *
8
+ * Font loading: uses `page.route()` to serve fonts from disk by URL, keeping
9
+ * the HTML small. The HTML references fonts by a stable `kk-font://` URL
10
+ * scheme; the route intercept fulfils each request with the disk bytes.
11
+ *
12
+ * The transport is Playwright's default; under Bun 1.3.14 a Chromium launch
13
+ * cycle can occasionally hang (leaving the browser's process tree behind),
14
+ * so the E2E test skips under `bun test` and the evidence script runs each
15
+ * variant pair in its own subprocess with a bounded timeout + retry.
16
+ *
17
+ * Missing Playwright or Chromium surfaces as an actionable error naming the
18
+ * install command.
19
+ */
20
+
21
+ import { join } from 'node:path';
22
+ import { readBytes } from '../bytes';
23
+ import type { CardComposer, CardInput } from './card';
24
+ import { cardHtml } from './card';
25
+
26
+ const FONTS_DIR = join(import.meta.dir, '..', '..', 'assets', 'fonts');
27
+
28
+ /** Stable URL prefix for font routes; never hits the network. */
29
+ const FONT_ROUTE_BASE = 'http://kk-fonts.local/';
30
+
31
+ /**
32
+ * Build a map of fontName → route URL. The HTML references these URLs;
33
+ * `page.route()` intercepts and returns the disk bytes.
34
+ */
35
+ function fontRouteUrls(fontNames: string[]): Map<string, string> {
36
+ const map = new Map<string, string>();
37
+ for (const name of fontNames) {
38
+ map.set(name, `${FONT_ROUTE_BASE}${encodeURIComponent(name)}`);
39
+ }
40
+ return map;
41
+ }
42
+
43
+ /**
44
+ * Playwright-based card composer. Inject in tests; the default export is used
45
+ * in production via `makeChromiumComposer()`.
46
+ */
47
+ export class ChromiumComposer implements CardComposer {
48
+ async compose(input: CardInput): Promise<Uint8Array> {
49
+ // Dynamic import so missing Playwright gives a clear error at call time.
50
+ let chromium: import('playwright').BrowserType;
51
+ try {
52
+ const pw = await import('playwright');
53
+ chromium = pw.chromium;
54
+ } catch (err: unknown) {
55
+ throw new Error(
56
+ `compose: Playwright is not installed. Run: bunx playwright install chromium\n(${err instanceof Error ? err.message : String(err)})`,
57
+ );
58
+ }
59
+
60
+ const fontNames = [input.compose.fonts.headline, input.compose.fonts.body];
61
+ const fontUrls = fontRouteUrls(fontNames);
62
+
63
+ // Build HTML with font URLs (small — no base64 inline).
64
+ const html = cardHtml(input, fontUrls);
65
+
66
+ let browser: import('playwright').Browser | undefined;
67
+ try {
68
+ try {
69
+ browser = await chromium.launch({ headless: true });
70
+ } catch (err: unknown) {
71
+ const msg = err instanceof Error ? err.message : String(err);
72
+ throw new Error(`compose: Chromium browser not found. Run: bunx playwright install chromium\n(${msg})`);
73
+ }
74
+
75
+ const page = await browser.newPage({
76
+ viewport: { width: input.width, height: input.height },
77
+ deviceScaleFactor: 1,
78
+ });
79
+
80
+ // Serve fonts from disk via route interception; no network, no base64 blob.
81
+ await page.route(`${FONT_ROUTE_BASE}**`, async (route) => {
82
+ const url = new URL(route.request().url());
83
+ const fontName = decodeURIComponent(url.pathname.slice(1));
84
+ const fontPath = join(FONTS_DIR, fontName);
85
+ try {
86
+ const bytes = await readBytes(fontPath);
87
+ await route.fulfill({
88
+ status: 200,
89
+ contentType: 'font/otf',
90
+ body: Buffer.from(bytes),
91
+ });
92
+ } catch {
93
+ await route.abort();
94
+ }
95
+ });
96
+
97
+ await page.setContent(html, { waitUntil: 'domcontentloaded' });
98
+ // Wait for fonts to load; passed as a string expression to avoid DOM lib dependency.
99
+ await page.evaluate('document.fonts.ready');
100
+
101
+ // R2: measure every [data-field] box against the safe area.
102
+ const { safeArea, illustrationHeight } = input.compose;
103
+ const safeLeft = safeArea;
104
+ const safeTop = illustrationHeight + safeArea;
105
+ const safeRight = input.width - safeArea;
106
+ const safeBottom = input.height - safeArea;
107
+
108
+ // Passed as a string to avoid requiring DOM lib in tsconfig; args injected by value.
109
+ const overflowExpr = `
110
+ (function(safeLeft, safeTop, safeRight, safeBottom) {
111
+ var results = [];
112
+ var els = Array.from(document.querySelectorAll('[data-field]'));
113
+ for (var i = 0; i < els.length; i++) {
114
+ var el = els[i];
115
+ var field = el.dataset.field || '';
116
+ var rect = el.getBoundingClientRect();
117
+ if (rect.left < safeLeft || rect.top < safeTop || rect.right > safeRight || rect.bottom > safeBottom) {
118
+ results.push({ field: field, rect: { top: rect.top, left: rect.left, bottom: rect.bottom, right: rect.right } });
119
+ }
120
+ }
121
+ return results;
122
+ })(${safeLeft}, ${safeTop}, ${safeRight}, ${safeBottom})
123
+ `;
124
+ const overflows = (await page.evaluate(overflowExpr)) as Array<{
125
+ field: string;
126
+ rect: { top: number; left: number; bottom: number; right: number };
127
+ }>;
128
+
129
+ if (overflows.length > 0) {
130
+ const fields = overflows.map((o) => o.field).join(', ');
131
+ throw new Error(
132
+ `compose: text overflows the safe area for field(s): ${fields} (safe area: ${safeArea}px inset)`,
133
+ );
134
+ }
135
+
136
+ const screenshot = await page.screenshot({ type: 'png', animations: 'disabled' });
137
+ return new Uint8Array(screenshot);
138
+ } finally {
139
+ await browser?.close();
140
+ }
141
+ }
142
+ }
143
+
144
+ /** Factory function that returns the default Chromium composer singleton. */
145
+ export function makeChromiumComposer(): CardComposer {
146
+ return new ChromiumComposer();
147
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Task 0180 (F616 R4) — pure sfnt cmap reader, no IO.
3
+ *
4
+ * Parses the OpenType/TrueType table directory and the cmap table to build a
5
+ * code-point → boolean coverage function. Format 12 (full Unicode BMP + SMP,
6
+ * preferred for CJK) is selected when present; format 4 (BMP only) is the
7
+ * fallback. A truncated font or a missing cmap throws so callers cannot silently
8
+ * get a false-negative from a partial read.
9
+ */
10
+
11
+ function u16(view: DataView, offset: number): number {
12
+ return view.getUint16(offset, false);
13
+ }
14
+
15
+ function u32(view: DataView, offset: number): number {
16
+ return view.getUint32(offset, false);
17
+ }
18
+
19
+ function tag(view: DataView, offset: number): string {
20
+ return String.fromCharCode(
21
+ view.getUint8(offset),
22
+ view.getUint8(offset + 1),
23
+ view.getUint8(offset + 2),
24
+ view.getUint8(offset + 3),
25
+ );
26
+ }
27
+
28
+ function assertBytes(view: DataView, offset: number, needed: number, context: string): void {
29
+ if (offset + needed > view.byteLength) {
30
+ throw new Error(`compose: truncated font — expected ${needed} bytes at offset ${offset} (${context})`);
31
+ }
32
+ }
33
+
34
+ /** Locate the cmap table in the sfnt directory and return its absolute offset + length. */
35
+ function findCmapTable(view: DataView): { offset: number; length: number } {
36
+ assertBytes(view, 0, 12, 'sfnt header');
37
+ // sfnt magic: 0x00010000 (TrueType), 0x4F54544F ('OTTO' — CFF/OTF), or 0x74727565 ('true').
38
+ const magic = u32(view, 0);
39
+ if (magic !== 0x00010000 && magic !== 0x4f54544f && magic !== 0x74727565) {
40
+ throw new Error(`compose: not a valid sfnt font (magic 0x${magic.toString(16).padStart(8, '0')})`);
41
+ }
42
+ const numTables = u16(view, 4);
43
+ assertBytes(view, 12, numTables * 16, 'sfnt table directory');
44
+ for (let i = 0; i < numTables; i++) {
45
+ const base = 12 + i * 16;
46
+ if (tag(view, base) === 'cmap') {
47
+ const offset = u32(view, base + 8);
48
+ const length = u32(view, base + 12);
49
+ return { offset, length };
50
+ }
51
+ }
52
+ throw new Error('compose: truncated font — cmap table not found in sfnt directory');
53
+ }
54
+
55
+ /** Build a coverage function from cmap format 4 (BMP segmented). */
56
+ function coverageFormat4(view: DataView, subtableOffset: number): (cp: number) => boolean {
57
+ assertBytes(view, subtableOffset, 14, 'cmap format 4 header');
58
+ const length = u16(view, subtableOffset + 2);
59
+ assertBytes(view, subtableOffset, length, 'cmap format 4 body');
60
+ const segCount = u16(view, subtableOffset + 6) / 2;
61
+ const endCodesBase = subtableOffset + 14;
62
+ assertBytes(
63
+ view,
64
+ endCodesBase,
65
+ segCount * 2 + 2 + segCount * 2 + segCount * 2 + segCount * 2,
66
+ 'cmap format 4 segments',
67
+ );
68
+ const startCodesBase = endCodesBase + segCount * 2 + 2; // skip reservedPad
69
+ const idDeltaBase = startCodesBase + segCount * 2;
70
+ const idRangeOffsetBase = idDeltaBase + segCount * 2;
71
+
72
+ return (cp: number): boolean => {
73
+ if (cp > 0xffff) return false;
74
+ // Binary search over endCode array.
75
+ let lo = 0;
76
+ let hi = segCount - 1;
77
+ while (lo <= hi) {
78
+ const mid = (lo + hi) >>> 1;
79
+ const endCode = u16(view, endCodesBase + mid * 2);
80
+ if (endCode < cp) {
81
+ lo = mid + 1;
82
+ } else {
83
+ hi = mid - 1;
84
+ }
85
+ }
86
+ if (lo >= segCount) return false;
87
+ const startCode = u16(view, startCodesBase + lo * 2);
88
+ if (cp < startCode) return false;
89
+ const idRangeOffset = u16(view, idRangeOffsetBase + lo * 2);
90
+ if (idRangeOffset === 0) {
91
+ const idDelta = u16(view, idDeltaBase + lo * 2);
92
+ return ((cp + idDelta) & 0xffff) !== 0;
93
+ }
94
+ // Use the idRangeOffset to index into glyphIdArray.
95
+ const glyphIdAddr = idRangeOffsetBase + lo * 2 + idRangeOffset + (cp - startCode) * 2;
96
+ if (glyphIdAddr + 2 > view.byteLength) return false;
97
+ const glyphId = u16(view, glyphIdAddr);
98
+ return glyphId !== 0;
99
+ };
100
+ }
101
+
102
+ /** Build a coverage function from cmap format 12 (full Unicode, sequential groups). */
103
+ function coverageFormat12(view: DataView, subtableOffset: number): (cp: number) => boolean {
104
+ assertBytes(view, subtableOffset, 16, 'cmap format 12 header');
105
+ const numGroups = u32(view, subtableOffset + 12);
106
+ assertBytes(view, subtableOffset + 16, numGroups * 12, 'cmap format 12 groups');
107
+ const groupsBase = subtableOffset + 16;
108
+
109
+ return (cp: number): boolean => {
110
+ // Binary search over startCharCode.
111
+ let lo = 0;
112
+ let hi = numGroups - 1;
113
+ while (lo <= hi) {
114
+ const mid = (lo + hi) >>> 1;
115
+ const base = groupsBase + mid * 12;
116
+ const startChar = u32(view, base);
117
+ const endChar = u32(view, base + 4);
118
+ if (endChar < cp) {
119
+ lo = mid + 1;
120
+ } else if (startChar > cp) {
121
+ hi = mid - 1;
122
+ } else {
123
+ // cp is in [startChar, endChar]; glyph is startGlyph + (cp - startChar).
124
+ const startGlyph = u32(view, base + 8);
125
+ return startGlyph + (cp - startChar) !== 0;
126
+ }
127
+ }
128
+ return false;
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Parse the cmap table and return a `(cp: number) => boolean` coverage
134
+ * predicate. Format 12 is preferred (covers all Unicode); format 4 is the
135
+ * fallback (BMP only). A truncated font or a missing usable subtable throws.
136
+ */
137
+ export function glyphCoverage(fontBytes: Uint8Array): (cp: number) => boolean {
138
+ if (fontBytes.byteLength < 12) {
139
+ throw new Error('compose: truncated font — too small for sfnt header');
140
+ }
141
+ const view = new DataView(fontBytes.buffer, fontBytes.byteOffset, fontBytes.byteLength);
142
+ const { offset: cmapOffset } = findCmapTable(view);
143
+
144
+ assertBytes(view, cmapOffset, 4, 'cmap table header');
145
+ const numSubtables = u16(view, cmapOffset + 2);
146
+ assertBytes(view, cmapOffset + 4, numSubtables * 8, 'cmap encoding records');
147
+
148
+ let fmt4Offset: number | undefined;
149
+ let fmt12Offset: number | undefined;
150
+
151
+ for (let i = 0; i < numSubtables; i++) {
152
+ const recBase = cmapOffset + 4 + i * 8;
153
+ const platformId = u16(view, recBase);
154
+ const encodingId = u16(view, recBase + 2);
155
+ const subtableOffset = cmapOffset + u32(view, recBase + 4);
156
+
157
+ if (subtableOffset + 2 > view.byteLength) continue;
158
+ const fmt = u16(view, subtableOffset);
159
+
160
+ // Prefer platform 3 (Windows) or platform 0 (Unicode) for format 12.
161
+ // Accept any platform for format 4 as BMP fallback.
162
+ if (fmt === 12 && (platformId === 3 || platformId === 0)) {
163
+ fmt12Offset = subtableOffset;
164
+ }
165
+ if (fmt === 4 && (platformId === 3 || platformId === 0) && encodingId !== 10) {
166
+ if (fmt4Offset === undefined) fmt4Offset = subtableOffset;
167
+ }
168
+ }
169
+
170
+ if (fmt12Offset !== undefined) {
171
+ return coverageFormat12(view, fmt12Offset);
172
+ }
173
+ if (fmt4Offset !== undefined) {
174
+ return coverageFormat4(view, fmt4Offset);
175
+ }
176
+ throw new Error('compose: truncated font — no usable cmap subtable (format 4 or 12) found');
177
+ }
@@ -1,9 +1,12 @@
1
- import { dirname } from 'node:path';
1
+ import { dirname, join } from 'node:path';
2
2
  import { parseArgs } from 'node:util';
3
3
  import { type Content, ContentSchema, type Doc, DocListSchema } from '@gobing-ai/kk-core';
4
4
  import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
5
5
  import { echoError } from '@gobing-ai/ts-utils';
6
- import { writeBytes } from './bytes';
6
+ import { imageDimensions, readBytes, writeBytes } from './bytes';
7
+ import type { CardComposer } from './compose/card';
8
+ import { assertCoverage, assertTextFreePrompt, type TextFreeSource } from './compose/card';
9
+ import { glyphCoverage } from './compose/cmap';
7
10
  import {
8
11
  type CaptionEntry,
9
12
  type ImageArtifact,
@@ -26,6 +29,7 @@ import {
26
29
  selectProvider,
27
30
  } from './providers/types';
28
31
 
32
+ export type { CardComposer, CardInput } from './compose/card';
29
33
  export * from './job';
30
34
  export * from './paths';
31
35
  export * from './presets';
@@ -192,6 +196,12 @@ export async function processGeneratorIO(
192
196
  providerResolver?: ProviderResolver;
193
197
  /** D6 0166: builds fallback candidates by name (tests inject stubs); defaults to the registry. */
194
198
  createProvider?: (name: string) => ImageProvider;
199
+ /**
200
+ * Task 0180: injectable card composer. Tests supply a stub; the default is the Chromium
201
+ * composer (same seam style as `providerResolver`). Resolved lazily so missing Playwright
202
+ * surfaces only when a compose-format job is actually requested.
203
+ */
204
+ composer?: CardComposer;
195
205
  } = {},
196
206
  ): Promise<void> {
197
207
  const fs = createNodeFileSystem();
@@ -229,6 +239,85 @@ export async function processGeneratorIO(
229
239
  }
230
240
  if (spec.job === 'illustration') assertAnchorPlan(doc?.body ?? '', positions, prompts.length);
231
241
 
242
+ // Task 0180 (F616 R4/R5): when the format has a compose block, run pre-spend checks
243
+ // (coverage + a dry compose with no illustration). This runs on BOTH the dry-run and
244
+ // spend branches so a missing-glyph or overflow error never bills the provider.
245
+ let _chromiumComposer: CardComposer | undefined;
246
+ const resolveComposer = async (): Promise<CardComposer> => {
247
+ if (options.composer !== undefined) return options.composer;
248
+ if (_chromiumComposer !== undefined) return _chromiumComposer;
249
+ // Lazy import: Playwright is a runtime dep, not always needed.
250
+ const { makeChromiumComposer } = await import('./compose/chromium');
251
+ _chromiumComposer = makeChromiumComposer();
252
+ return _chromiumComposer;
253
+ };
254
+
255
+ if (format.compose !== undefined) {
256
+ const cfg = format.compose;
257
+ if (spec.job !== 'free') {
258
+ throw new Error(
259
+ `compose: format '${format.name}' has a compose block but the job is '${spec.job}' — compose formats require job: free`,
260
+ );
261
+ }
262
+ const vars = spec.vars ?? {};
263
+ const headline = vars.headline ?? format.variables.headline?.default ?? '';
264
+ if (!headline.trim()) {
265
+ throw new Error(`compose: format '${format.name}' requires a non-empty vars.headline`);
266
+ }
267
+ const pointsRaw = vars.points ?? format.variables.points?.default ?? '';
268
+ const points = pointsRaw.split('\n').filter((l: string) => l.trim() !== '');
269
+
270
+ // Task 0181 (F616 R1): the provider prompt must stay text-free — card copy is
271
+ // typeset by the composer. Refusal happens before any font IO, compose, or spend
272
+ // and names the offending segment (template, style preset, extra, or a named var).
273
+ const promptVars: Record<string, string> = { subject: doc?.body ?? '', ...(spec.vars ?? {}) };
274
+ const textFreeSources: TextFreeSource[] = [
275
+ { source: 'the format template', text: format.template },
276
+ {
277
+ source: `the style preset '${style.name}'`,
278
+ text: [style.modifiers, style.negative].filter((part) => part !== '').join(', '),
279
+ },
280
+ { source: 'spec.extra', text: spec.extra ?? '' },
281
+ ...Object.entries(promptVars)
282
+ .filter(([name]) => name !== 'headline' && name !== 'points' && name !== 'index')
283
+ .map(([name, value]) => ({ source: `vars.${name}`, text: String(value) })),
284
+ ];
285
+ assertTextFreePrompt(prompts[0] ?? '', { headline, points }, textFreeSources);
286
+
287
+ // Load font bytes once for coverage check.
288
+ const fontsDir = join(import.meta.dir, '..', 'assets', 'fonts');
289
+ const fontNames = [cfg.fonts.headline, cfg.fonts.body];
290
+ const fontBytesMap = new Map<string, Uint8Array>();
291
+ for (const name of fontNames) {
292
+ fontBytesMap.set(name, new Uint8Array(await readBytes(join(fontsDir, name))));
293
+ }
294
+ const coverageMap = new Map<string, (cp: number) => boolean>();
295
+ for (const [name, bytes] of fontBytesMap) {
296
+ coverageMap.set(name, glyphCoverage(bytes));
297
+ }
298
+
299
+ if (format.width == null || format.height == null) {
300
+ throw new Error(
301
+ `image-gen: format '${format.name}' declares no canvas size; compose requires width and height`,
302
+ );
303
+ }
304
+ const cardInput = {
305
+ headline,
306
+ points,
307
+ role: vars.role ?? format.variables.role?.default ?? 'content',
308
+ compose: cfg,
309
+ width: format.width,
310
+ height: format.height,
311
+ };
312
+
313
+ // assertCoverage: fails loud naming field + code point (R4).
314
+ assertCoverage(cardInput, coverageMap);
315
+
316
+ // Dry compose (no illustration): proves Chromium is available and layout fits (R5).
317
+ const composer = await resolveComposer();
318
+ await composer.compose(cardInput);
319
+ }
320
+
232
321
  // Selection must succeed (or fail loud naming the provider) even when nothing is spent.
233
322
  const resolveProvider: ProviderResolver = options.providerResolver ?? defaultProviderResolver;
234
323
  const primary = resolveProvider(spec);
@@ -301,7 +390,10 @@ export async function processGeneratorIO(
301
390
  const captions: CaptionEntry[] = [];
302
391
  const attempts: ProviderAttempt[] = [];
303
392
  let at = 0; // sticky index into `order`
304
- const generateWithFallback = async (image: number, prompt: string): Promise<ImageResult> => {
393
+ const generateWithFallback = async (
394
+ image: number,
395
+ prompt: string,
396
+ ): Promise<{ result: ImageResult; sizeRequest: 'exact' | 'aspect' }> => {
305
397
  const start = attempts.length; // this image's failed attempts begin here (R5 summarises the slice)
306
398
  for (;;) {
307
399
  const name = order[at];
@@ -312,17 +404,26 @@ export async function processGeneratorIO(
312
404
  const provider = at === 0 ? primary : createCandidate(name);
313
405
  // spec.model is provider-specific (D6): only the requested provider receives it.
314
406
  const model = at === 0 ? (spec.model ?? provider.defaultModel()) : provider.defaultModel();
407
+ // 0182 R1/R4: the exact-size gate runs per candidate, with that candidate's own
408
+ // model and its own capability report. Without it the request keeps today's
409
+ // aspect-only shape (R2); on the exact path aspectRatio is omitted (R1 — MiniMax
410
+ // reads width/height only when aspect_ratio is absent).
411
+ const exact =
412
+ format.width !== undefined &&
413
+ format.height !== undefined &&
414
+ provider.exactSize?.(model, format.width, format.height) === true;
315
415
  const request: ImageRequest = {
316
416
  prompt,
317
417
  model,
318
- aspectRatio: format.aspectRatio,
418
+ ...(exact ? { width: format.width, height: format.height } : { aspectRatio: format.aspectRatio }),
319
419
  ...(spec.referenceImages !== undefined && spec.referenceImages.length > 0
320
420
  ? { referenceImages: spec.referenceImages }
321
421
  : {}),
322
422
  };
323
423
  try {
324
424
  provider.validate?.(request);
325
- return await provider.generate(request);
425
+ const result = await provider.generate(request);
426
+ return { result, sizeRequest: exact ? 'exact' : 'aspect' };
326
427
  } catch (err: unknown) {
327
428
  if (spec.fallback === undefined) {
328
429
  throw err; // D6 R1: strict mode propagates the original error, unwrapped.
@@ -352,7 +453,22 @@ export async function processGeneratorIO(
352
453
  };
353
454
  let body = doc?.body ?? '';
354
455
  for (const [index, prompt] of prompts.entries()) {
355
- const result = await generateWithFallback(index + 1, prompt);
456
+ const { result, sizeRequest } = await generateWithFallback(index + 1, prompt);
457
+ // 0182 R3: measure the raw provider delivery once — both branches read it. A declared
458
+ // canvas that came back different (or unparseable) warns and never fails: the bytes
459
+ // are already paid for, and failing would delete them through the cleanup path.
460
+ const delivered = imageDimensions(result.bytes);
461
+ if (format.width !== undefined && format.height !== undefined) {
462
+ if (delivered === null) {
463
+ echoError(
464
+ `WARN image-gen: ${result.provider}/${result.model} delivered unparseable image bytes; dimensions omitted`,
465
+ );
466
+ } else if (delivered.width !== format.width || delivered.height !== format.height) {
467
+ echoError(
468
+ `WARN image-gen: ${result.provider}/${result.model} delivered ${delivered.width}x${delivered.height}; format '${format.name}' declares ${format.width}x${format.height}`,
469
+ );
470
+ }
471
+ }
356
472
  // Re-check against the spliced body; the pre-spend plan check is assertAnchorPlan (0169).
357
473
  let anchor: string | undefined;
358
474
  if (spec.job === 'illustration') {
@@ -367,17 +483,99 @@ export async function processGeneratorIO(
367
483
  );
368
484
  }
369
485
  }
370
- const path = artifactPath(outputPath, stem, format.name, index + 1, result.extension);
371
- await writeBytes(path, result.bytes);
372
- written.push(path);
486
+
487
+ let artifactPathValue: string;
488
+ let artifactBytes: number;
489
+ let artifactWidth: number | undefined;
490
+ let artifactHeight: number | undefined;
491
+ let illustrationMeta: ImageArtifact['illustration'] | undefined;
492
+
493
+ if (format.compose !== undefined) {
494
+ // Task 0180 (F616 R5): compose path.
495
+ // 1. Write the raw provider illustration.
496
+ const illustrationArtifactPath = artifactPath(
497
+ outputPath,
498
+ stem,
499
+ `${format.name}-illustration`,
500
+ index + 1,
501
+ result.extension,
502
+ );
503
+ await writeBytes(illustrationArtifactPath, result.bytes);
504
+ written.push(illustrationArtifactPath);
505
+
506
+ // 2. Compose the card over the illustration.
507
+ const vars = spec.vars ?? {};
508
+ const headline = vars.headline ?? format.variables.headline?.default ?? '';
509
+ const pointsRaw = vars.points ?? format.variables.points?.default ?? '';
510
+ const points = pointsRaw.split('\n').filter((l: string) => l.trim() !== '');
511
+ if (format.width == null || format.height == null) {
512
+ throw new Error(
513
+ `image-gen: format '${format.name}' declares no canvas size; compose requires width and height`,
514
+ );
515
+ }
516
+ const cardInput = {
517
+ headline,
518
+ points,
519
+ role: vars.role ?? format.variables.role?.default ?? 'content',
520
+ compose: format.compose,
521
+ width: format.width,
522
+ height: format.height,
523
+ illustration: { bytes: result.bytes, mime: `image/${result.extension}` },
524
+ };
525
+ const composer = await resolveComposer();
526
+ const cardBytes = await composer.compose(cardInput);
527
+
528
+ // 3. Write the composed card.
529
+ const cardPath = artifactPath(outputPath, stem, format.name, index + 1, 'png');
530
+ await writeBytes(cardPath, cardBytes);
531
+ written.push(cardPath);
532
+
533
+ // 4. Measure the card dimensions via imageDimensions and assert canvas.
534
+ const dims = imageDimensions(cardBytes);
535
+ if (dims === null) {
536
+ throw new Error(
537
+ `compose: composed card dimensions are unparseable; format '${format.name}' requires ${format.width}x${format.height}`,
538
+ );
539
+ }
540
+ if (dims.width !== format.width || dims.height !== format.height) {
541
+ throw new Error(
542
+ `compose: card dimensions ${dims.width}x${dims.height} do not match format canvas ${format.width}x${format.height}`,
543
+ );
544
+ }
545
+
546
+ artifactPathValue = cardPath;
547
+ artifactBytes = cardBytes.byteLength;
548
+ artifactWidth = dims.width;
549
+ artifactHeight = dims.height;
550
+ illustrationMeta = {
551
+ path: illustrationArtifactPath,
552
+ bytes: result.bytes.byteLength,
553
+ // 0182 R3/R5: the raw provider delivery, measured from its own bytes.
554
+ ...(delivered !== null ? { width: delivered.width, height: delivered.height } : {}),
555
+ };
556
+ } else {
557
+ // Non-compose: existing path (R6); the artifact records the delivered size (0182 R3).
558
+ const path = artifactPath(outputPath, stem, format.name, index + 1, result.extension);
559
+ await writeBytes(path, result.bytes);
560
+ written.push(path);
561
+ artifactPathValue = path;
562
+ artifactBytes = result.bytes.byteLength;
563
+ artifactWidth = delivered?.width;
564
+ artifactHeight = delivered?.height;
565
+ }
566
+
373
567
  const artifact: ImageArtifact = {
374
- path,
568
+ path: artifactPathValue,
375
569
  format: format.name,
376
570
  style: style.name,
377
571
  provider: result.provider,
378
572
  model: result.model,
379
573
  prompt,
380
- bytes: result.bytes.byteLength,
574
+ bytes: artifactBytes,
575
+ sizeRequest,
576
+ ...(artifactWidth !== undefined ? { width: artifactWidth } : {}),
577
+ ...(artifactHeight !== undefined ? { height: artifactHeight } : {}),
578
+ ...(illustrationMeta !== undefined ? { illustration: illustrationMeta } : {}),
381
579
  };
382
580
  if (spec.seed !== undefined) {
383
581
  artifact.seed = spec.seed;
@@ -386,10 +584,10 @@ export async function processGeneratorIO(
386
584
  if (anchor !== undefined) {
387
585
  const parts = body.split(anchor);
388
586
  const altText = `Illustration ${index + 1}`;
389
- const markdown = `${anchor}\n\n![${altText}](${path})`;
587
+ const markdown = `${anchor}\n\n![${altText}](${artifactPathValue})`;
390
588
  captions.push({
391
589
  index: index + 1,
392
- path,
590
+ path: artifactPathValue,
393
591
  caption: anchor,
394
592
  altText,
395
593
  anchor,
@@ -76,9 +76,21 @@ export interface ImageArtifact {
76
76
  /** The exact prompt string sent. */
77
77
  prompt: string;
78
78
  seed?: number;
79
+ /**
80
+ * 0182 R3: what the provider was asked for — 'exact' when the request carried the
81
+ * preset's declared width/height (the provider reported it could honour them for this
82
+ * model), 'aspect' for the aspect-only shape. Present on every written artifact.
83
+ */
84
+ sizeRequest?: 'exact' | 'aspect';
79
85
  width?: number;
80
86
  height?: number;
81
87
  bytes: number;
88
+ /**
89
+ * Task 0180 (F616 R5): when the format has a compose block, the raw provider
90
+ * illustration is recorded here. `path` on the parent artifact is the composed
91
+ * card. `xhs-pub` reads only `.path`, so the publish DTO is unchanged.
92
+ */
93
+ illustration?: { path: string; width?: number; height?: number; bytes: number };
82
94
  }
83
95
 
84
96
  /** One illustration caption, the wt captions.json payload reduced to what consumers read (R4). */