@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/index.js +114 -38
- package/package.json +1 -1
- package/plugins/generations/daily-article-gen/dist/index.js +45 -5
- package/plugins/generations/daily-article-gen/src/index.ts +59 -4
- package/plugins/generations/dailynews-gen/dist/index.js +46 -9
- package/plugins/generations/dailynews-gen/src/index.ts +36 -1
- package/plugins/generations/dailynews-gen/src/script-builder.ts +15 -12
- package/plugins/generations/dailynews-gen/src/script-plan.ts +21 -3
- package/plugins/generations/episode-plan-gen/dist/index.js +55 -4
- package/plugins/generations/episode-plan-gen/src/index.ts +80 -2
- 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 +700 -22
- 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 +116 -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 +211 -13
- package/plugins/generations/image-gen/src/job.ts +12 -0
- package/plugins/generations/image-gen/src/presets.ts +53 -13
- package/plugins/generations/image-gen/src/providers/minimax.ts +12 -4
- package/plugins/generations/image-gen/src/providers/types.ts +12 -1
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/daily-voice-style.zh.md +27 -14
- package/plugins/kk/scripts/itc-stages.ts +21 -3
- package/plugins/kk/scripts/kk-workflow-stages.ts +461 -10
- package/plugins/kk/scripts/topic-boundary.json +96 -0
- package/plugins/kk/scripts/validate-voicescript.ts +15 -0
- package/plugins/kk/skills/content-judge/SKILL.md +30 -0
- package/plugins/kk/skills/content-judge/references/workflow-integration.md +9 -2
- 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/publish/SKILL.md +1 -1
- 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/judge-gated-publish-example.yaml +18 -10
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +139 -24
- package/plugins/kk/workflows/kk-itc.yaml +21 -8
- 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/xhs-pub/dist/index.js +1 -1
- package/plugins/publishings/xhs-pub/src/automation-guard.ts +2 -2
- package/plugins/publishings/xhs-pub/src/index.ts +1 -1
- package/plugins/publishings/xhs-pub/src/profile-check.ts +2 -2
- package/plugins/sp/lib/env.ts +0 -46
- package/plugins/sp/lib/inline-run.generated.mjs +0 -1489
- package/plugins/sp/scripts/batch-preflight.mjs +0 -346
- package/plugins/sp/scripts/batch-preflight.ts +0 -459
- package/plugins/sp/scripts/daily-summary/daily-summary.mjs +0 -615
- package/plugins/sp/scripts/daily-summary/daily-summary.ts +0 -846
- package/plugins/sp/scripts/daily-summary/logger.ts +0 -28
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +0 -223
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +0 -367
- package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +0 -132
- package/plugins/sp/scripts/dogfood-testing/validate-report.ts +0 -169
- package/plugins/sp/scripts/feature-dev-precheck.mjs +0 -171
- package/plugins/sp/scripts/feature-dev-precheck.ts +0 -238
- package/plugins/sp/scripts/feature-sync-bounded.mjs +0 -285
- package/plugins/sp/scripts/feature-sync-bounded.ts +0 -478
- package/plugins/sp/scripts/feature-verification-steps.ts +0 -275
- package/plugins/sp/scripts/history-anatomy-cache.mjs +0 -902
- package/plugins/sp/scripts/history-anatomy-cache.ts +0 -1028
- package/plugins/sp/scripts/idea-handoff.mjs +0 -22
- package/plugins/sp/scripts/idea-handoff.ts +0 -44
- package/plugins/sp/scripts/inline-pipeline-parity-check.ts +0 -185
- package/plugins/sp/scripts/inline-run-setup.ts +0 -722
- package/plugins/sp/scripts/pr-reviewing.mjs +0 -769
- package/plugins/sp/scripts/pr-reviewing.ts +0 -925
- package/plugins/sp/scripts/quality-gate.mjs +0 -179
- package/plugins/sp/scripts/quality-gate.ts +0 -217
- package/plugins/sp/scripts/script-contract-check.ts +0 -319
- package/plugins/sp/scripts/stage-registry-adapter.ts +0 -1533
- package/plugins/sp/scripts/surface-drift-inventory.ts +0 -929
- package/plugins/sp/scripts/task-evidence-precheck.ts +0 -181
- package/plugins/sp/scripts/task-size-precheck.ts +0 -175
- package/plugins/sp/scripts/transition-shim-check.ts +0 -238
- package/plugins/sp/scripts/validate-commands.ts +0 -689
- package/plugins/sp/scripts/validate-flag-contracts.ts +0 -878
- package/plugins/sp/scripts/verify-answer-lint.ts +0 -530
- package/plugins/sp/scripts/workflow-step-profile.mjs +0 -316
- package/plugins/sp/scripts/workflow-step-profile.ts +0 -456
- package/plugins/sp/scripts/wrapup-drift-probe.mjs +0 -190
- package/plugins/sp/scripts/wrapup-drift-probe.ts +0 -258
- package/plugins/sp/scripts/wrapup-steps.mjs +0 -444
- 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 (
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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:
|
|
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`;
|
|
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). */
|