@gobing-ai/knowledge-kit 0.0.27 → 0.0.29
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/.claude-plugin/marketplace.json +1 -1
- package/package.json +1 -1
- package/plugins/generations/daily-article-gen/dist/index.js +27 -4
- package/plugins/generations/daily-article-gen/src/index.ts +34 -3
- package/plugins/generations/dailynews-gen/dist/index.js +42 -19
- package/plugins/generations/dailynews-gen/src/index.ts +29 -1
- package/plugins/generations/dailynews-gen/src/script-builder.ts +33 -21
- package/plugins/generations/episode-plan-gen/dist/index.js +7 -0
- package/plugins/generations/episode-plan-gen/src/index.ts +12 -0
- package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Bold.otf +0 -0
- package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Regular.otf +0 -0
- package/plugins/generations/image-gen/assets/fonts/OFL.txt +92 -0
- package/plugins/generations/image-gen/assets/fonts/SOURCE.md +15 -0
- package/plugins/generations/image-gen/dist/index.js +597 -16
- package/plugins/generations/image-gen/package.json +1 -0
- package/plugins/generations/image-gen/presets/formats/daily-news-cover.yaml +2 -2
- package/plugins/generations/image-gen/presets/formats/xhs-card.yaml +19 -11
- package/plugins/generations/image-gen/presets/styles/xhs-knowledge.yaml +2 -1
- package/plugins/generations/image-gen/scripts/compose-e2e-artifact.ts +135 -0
- package/plugins/generations/image-gen/src/bytes.ts +28 -0
- package/plugins/generations/image-gen/src/compose/card.ts +294 -0
- package/plugins/generations/image-gen/src/compose/chromium.ts +147 -0
- package/plugins/generations/image-gen/src/compose/cmap.ts +177 -0
- package/plugins/generations/image-gen/src/index.ts +167 -9
- package/plugins/generations/image-gen/src/job.ts +6 -0
- package/plugins/generations/image-gen/src/presets.ts +53 -13
- package/plugins/generations/image-gen/src/providers/google.ts +9 -1
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/daily-voice-style.zh.md +25 -12
- package/plugins/kk/scripts/kk-workflow-stages.ts +344 -11
- package/plugins/kk/scripts/topic-boundary.json +105 -0
- package/plugins/kk/scripts/validate-voicescript.ts +15 -0
- package/plugins/kk/skills/content-safety/SKILL.md +163 -0
- package/plugins/kk/skills/content-safety/references/platforms/apple-podcasts.md +36 -0
- package/plugins/kk/skills/content-safety/references/platforms/netease-music.md +31 -0
- package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +32 -0
- package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +35 -0
- package/plugins/kk/skills/xhs-images/SKILL.md +11 -7
- package/plugins/kk/skills/xhs-images/references/card-layouts.md +4 -4
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +118 -16
- package/plugins/publishings/podcast-pub/dist/index.js +4 -0
- package/plugins/publishings/podcast-pub/src/index.ts +2 -0
- package/plugins/publishings/podcast-pub/src/show-notes.ts +6 -0
- package/plugins/publishings/wechatmp-pub/dist/index.js +272 -115
- package/plugins/publishings/wechatmp-pub/src/index.ts +336 -40
- package/plugins/publishings/wechatmp-pub/src/profile-check.ts +21 -9
- package/plugins/publishings/xhs-pub/dist/index.js +28 -22
- package/plugins/publishings/xhs-pub/src/index.ts +30 -22
|
@@ -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);
|
|
@@ -367,17 +456,86 @@ export async function processGeneratorIO(
|
|
|
367
456
|
);
|
|
368
457
|
}
|
|
369
458
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
459
|
+
|
|
460
|
+
let artifactPathValue: string;
|
|
461
|
+
let artifactBytes: number;
|
|
462
|
+
let artifactWidth: number | undefined;
|
|
463
|
+
let artifactHeight: number | undefined;
|
|
464
|
+
let illustrationMeta: ImageArtifact['illustration'] | undefined;
|
|
465
|
+
|
|
466
|
+
if (format.compose !== undefined) {
|
|
467
|
+
// Task 0180 (F616 R5): compose path.
|
|
468
|
+
// 1. Write the raw provider illustration.
|
|
469
|
+
const illustrationArtifactPath = artifactPath(
|
|
470
|
+
outputPath,
|
|
471
|
+
stem,
|
|
472
|
+
`${format.name}-illustration`,
|
|
473
|
+
index + 1,
|
|
474
|
+
result.extension,
|
|
475
|
+
);
|
|
476
|
+
await writeBytes(illustrationArtifactPath, result.bytes);
|
|
477
|
+
written.push(illustrationArtifactPath);
|
|
478
|
+
|
|
479
|
+
// 2. Compose the card over the illustration.
|
|
480
|
+
const vars = spec.vars ?? {};
|
|
481
|
+
const headline = vars.headline ?? format.variables.headline?.default ?? '';
|
|
482
|
+
const pointsRaw = vars.points ?? format.variables.points?.default ?? '';
|
|
483
|
+
const points = pointsRaw.split('\n').filter((l: string) => l.trim() !== '');
|
|
484
|
+
if (format.width == null || format.height == null) {
|
|
485
|
+
throw new Error(
|
|
486
|
+
`image-gen: format '${format.name}' declares no canvas size; compose requires width and height`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
const cardInput = {
|
|
490
|
+
headline,
|
|
491
|
+
points,
|
|
492
|
+
role: vars.role ?? format.variables.role?.default ?? 'content',
|
|
493
|
+
compose: format.compose,
|
|
494
|
+
width: format.width,
|
|
495
|
+
height: format.height,
|
|
496
|
+
illustration: { bytes: result.bytes, mime: `image/${result.extension}` },
|
|
497
|
+
};
|
|
498
|
+
const composer = await resolveComposer();
|
|
499
|
+
const cardBytes = await composer.compose(cardInput);
|
|
500
|
+
|
|
501
|
+
// 3. Write the composed card.
|
|
502
|
+
const cardPath = artifactPath(outputPath, stem, format.name, index + 1, 'png');
|
|
503
|
+
await writeBytes(cardPath, cardBytes);
|
|
504
|
+
written.push(cardPath);
|
|
505
|
+
|
|
506
|
+
// 4. Measure the card dimensions via imageDimensions and assert canvas.
|
|
507
|
+
const dims = imageDimensions(cardBytes);
|
|
508
|
+
if (dims.width !== format.width || dims.height !== format.height) {
|
|
509
|
+
throw new Error(
|
|
510
|
+
`compose: card dimensions ${dims.width}x${dims.height} do not match format canvas ${format.width}x${format.height}`,
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
artifactPathValue = cardPath;
|
|
515
|
+
artifactBytes = cardBytes.byteLength;
|
|
516
|
+
artifactWidth = dims.width;
|
|
517
|
+
artifactHeight = dims.height;
|
|
518
|
+
illustrationMeta = { path: illustrationArtifactPath, bytes: result.bytes.byteLength };
|
|
519
|
+
} else {
|
|
520
|
+
// Non-compose: existing path (R6).
|
|
521
|
+
const path = artifactPath(outputPath, stem, format.name, index + 1, result.extension);
|
|
522
|
+
await writeBytes(path, result.bytes);
|
|
523
|
+
written.push(path);
|
|
524
|
+
artifactPathValue = path;
|
|
525
|
+
artifactBytes = result.bytes.byteLength;
|
|
526
|
+
}
|
|
527
|
+
|
|
373
528
|
const artifact: ImageArtifact = {
|
|
374
|
-
path,
|
|
529
|
+
path: artifactPathValue,
|
|
375
530
|
format: format.name,
|
|
376
531
|
style: style.name,
|
|
377
532
|
provider: result.provider,
|
|
378
533
|
model: result.model,
|
|
379
534
|
prompt,
|
|
380
|
-
bytes:
|
|
535
|
+
bytes: artifactBytes,
|
|
536
|
+
...(artifactWidth !== undefined ? { width: artifactWidth } : {}),
|
|
537
|
+
...(artifactHeight !== undefined ? { height: artifactHeight } : {}),
|
|
538
|
+
...(illustrationMeta !== undefined ? { illustration: illustrationMeta } : {}),
|
|
381
539
|
};
|
|
382
540
|
if (spec.seed !== undefined) {
|
|
383
541
|
artifact.seed = spec.seed;
|
|
@@ -386,10 +544,10 @@ export async function processGeneratorIO(
|
|
|
386
544
|
if (anchor !== undefined) {
|
|
387
545
|
const parts = body.split(anchor);
|
|
388
546
|
const altText = `Illustration ${index + 1}`;
|
|
389
|
-
const markdown = `${anchor}\n\n`;
|
|
390
548
|
captions.push({
|
|
391
549
|
index: index + 1,
|
|
392
|
-
path,
|
|
550
|
+
path: artifactPathValue,
|
|
393
551
|
caption: anchor,
|
|
394
552
|
altText,
|
|
395
553
|
anchor,
|
|
@@ -79,6 +79,12 @@ export interface ImageArtifact {
|
|
|
79
79
|
width?: number;
|
|
80
80
|
height?: number;
|
|
81
81
|
bytes: number;
|
|
82
|
+
/**
|
|
83
|
+
* Task 0180 (F616 R5): when the format has a compose block, the raw provider
|
|
84
|
+
* illustration is recorded here. `path` on the parent artifact is the composed
|
|
85
|
+
* card. `xhs-pub` reads only `.path`, so the publish DTO is unchanged.
|
|
86
|
+
*/
|
|
87
|
+
illustration?: { path: string; width?: number; height?: number; bytes: number };
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
/** One illustration caption, the wt captions.json payload reduced to what consumers read (R4). */
|
|
@@ -31,6 +31,28 @@ export interface FormatVariable {
|
|
|
31
31
|
default: string;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Optional compose block in a format preset (task 0180, F616).
|
|
36
|
+
* When present, image-gen typesets the on-card copy from Noto Sans SC via
|
|
37
|
+
* headless Chromium instead of generating text as pixels. Requires `width`
|
|
38
|
+
* and `height` on the format (enforced by FormatPresetSchema refine).
|
|
39
|
+
*/
|
|
40
|
+
export interface ComposeConfig {
|
|
41
|
+
/** The only v1 engine value; zod literal enforces it. */
|
|
42
|
+
engine: 'text-card';
|
|
43
|
+
/** Safe-area inset in px on all sides. */
|
|
44
|
+
safeArea: number;
|
|
45
|
+
/** Headline minimum font size in px. */
|
|
46
|
+
headlineMinPx: number;
|
|
47
|
+
/** Body/bullets minimum font size in px. */
|
|
48
|
+
bodyMinPx: number;
|
|
49
|
+
/** Height in px of the illustration band (top); text panel takes the rest. */
|
|
50
|
+
illustrationHeight: number;
|
|
51
|
+
palette: { background: string; text: string; accent: string };
|
|
52
|
+
/** Font filenames under `assets/fonts/`. */
|
|
53
|
+
fonts: { headline: string; body: string };
|
|
54
|
+
}
|
|
55
|
+
|
|
34
56
|
/** R1 — `formats/<name>.yaml`: the job skeleton (template, keywords, variables). */
|
|
35
57
|
export interface FormatPreset {
|
|
36
58
|
/** Filename stem must equal this. */
|
|
@@ -51,6 +73,8 @@ export interface FormatPreset {
|
|
|
51
73
|
variables: Record<string, FormatVariable>;
|
|
52
74
|
/** Prompt body; YAML block scalar, embedded newlines preserved. */
|
|
53
75
|
template: string;
|
|
76
|
+
/** Optional compose block (0180): when present, image-gen typesets card copy from a real font. */
|
|
77
|
+
compose?: ComposeConfig;
|
|
54
78
|
}
|
|
55
79
|
|
|
56
80
|
/** R1 — `styles/<name>.yaml`; `modifiers` is the only required prompt field. */
|
|
@@ -76,21 +100,37 @@ const PresetSourceSchema = z.object({
|
|
|
76
100
|
confidence: z.enum(['high', 'medium', 'low']).optional(),
|
|
77
101
|
});
|
|
78
102
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
keywords: z.array(z.string()),
|
|
89
|
-
outputFilename: z.string(),
|
|
90
|
-
variables: z.record(z.string(), z.object({ description: z.string(), default: z.string() })),
|
|
91
|
-
template: z.string(),
|
|
103
|
+
/** The zod schema for the optional compose block (0180). */
|
|
104
|
+
const ComposeConfigSchema = z.object({
|
|
105
|
+
engine: z.literal('text-card'),
|
|
106
|
+
safeArea: z.number(),
|
|
107
|
+
headlineMinPx: z.number(),
|
|
108
|
+
bodyMinPx: z.number(),
|
|
109
|
+
illustrationHeight: z.number(),
|
|
110
|
+
palette: z.object({ background: z.string(), text: z.string(), accent: z.string() }),
|
|
111
|
+
fonts: z.object({ headline: z.string(), body: z.string() }),
|
|
92
112
|
});
|
|
93
113
|
|
|
114
|
+
/** Parse gate for `formats/<name>.yaml`. */
|
|
115
|
+
export const FormatPresetSchema = z
|
|
116
|
+
.object({
|
|
117
|
+
name: z.string(),
|
|
118
|
+
description: z.string(),
|
|
119
|
+
job: z.enum(['free', 'cover', 'illustration']),
|
|
120
|
+
aspectRatio: z.string(),
|
|
121
|
+
width: z.number().optional(),
|
|
122
|
+
height: z.number().optional(),
|
|
123
|
+
defaultStyle: z.string(),
|
|
124
|
+
keywords: z.array(z.string()),
|
|
125
|
+
outputFilename: z.string(),
|
|
126
|
+
variables: z.record(z.string(), z.object({ description: z.string(), default: z.string() })),
|
|
127
|
+
template: z.string(),
|
|
128
|
+
compose: ComposeConfigSchema.optional(),
|
|
129
|
+
})
|
|
130
|
+
.refine((data) => data.compose === undefined || (data.width !== undefined && data.height !== undefined), {
|
|
131
|
+
message: 'A format preset with a compose block requires both width and height to be declared',
|
|
132
|
+
});
|
|
133
|
+
|
|
94
134
|
/** Parse gate for `styles/<name>.yaml`. */
|
|
95
135
|
export const StylePresetSchema = z.object({
|
|
96
136
|
name: z.string(),
|
|
@@ -174,6 +174,9 @@ export const createGoogle: ProviderFactory = (options?: ProviderOptions): ImageP
|
|
|
174
174
|
// the key and the proxy password into logs. `-sS` (not `-s`) keeps curl's own diagnostic on
|
|
175
175
|
// stderr, so `details` no longer falls back to that argv-bearing message.
|
|
176
176
|
//
|
|
177
|
+
// `stdio: ['pipe', 'pipe', 'pipe']` is explicit because Bun's `execFileSync` otherwise
|
|
178
|
+
// tees the child's stderr to the parent's stderr even though it also captures it.
|
|
179
|
+
//
|
|
177
180
|
// File IO goes through the `@gobing-ai/ts-runtime` seam (`no-direct-fs-io`): the seam is
|
|
178
181
|
// text-only with no `mode` argument, and the file lives in `os.tmpdir()`, which is a
|
|
179
182
|
// per-user directory (0700 on macOS).
|
|
@@ -199,7 +202,12 @@ export const createGoogle: ProviderFactory = (options?: ProviderOptions): ImageP
|
|
|
199
202
|
'@-',
|
|
200
203
|
url,
|
|
201
204
|
],
|
|
202
|
-
{
|
|
205
|
+
{
|
|
206
|
+
input: JSON.stringify(body),
|
|
207
|
+
encoding: 'utf8',
|
|
208
|
+
timeout: 310_000,
|
|
209
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
210
|
+
},
|
|
203
211
|
);
|
|
204
212
|
} catch (error: unknown) {
|
|
205
213
|
const e = error as { message?: string; stderr?: string | Buffer };
|