@akairos/typeset-studio 0.2.1 → 0.2.3
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/README.md +91 -95
- package/dist/assets/index-3Ko1UCBU.css +1 -0
- package/dist/assets/index-C9J0f4Lf.js +317 -0
- package/dist/index.html +2 -2
- package/dist/lib/adapter.js +6 -7734
- package/dist/lib/canvas-renderer-cxnar75n.js +23 -0
- package/dist/lib/font-loader-j28mt43t.js +11 -0
- package/dist/lib/index-0370qz9e.js +303 -0
- package/dist/lib/index-37x76zdn.js +4 -0
- package/dist/lib/index-6c43c7h4.js +3880 -0
- package/dist/lib/index-t9w3p2p9.js +3366 -0
- package/dist/lib/index-zdhqg5f0.js +459 -0
- package/dist/lib/index.js +26 -7734
- package/dist/lib/render-xxd348m1.js +11 -0
- package/package.json +3 -7
- package/src/adapter.test.ts +117 -0
- package/src/adapter.ts +190 -281
- package/src/core/canvas-draw.ts +150 -47
- package/src/core/canvas-renderer.test.ts +58 -0
- package/src/core/canvas-renderer.ts +2 -1
- package/src/core/data-dir.ts +13 -0
- package/src/core/engine.ts +86 -56
- package/src/core/font-loader.test.ts +20 -0
- package/src/core/font-loader.ts +6 -2
- package/src/core/image-layout.ts +4 -0
- package/src/core/inline-text.ts +6 -4
- package/src/core/obstacle-flow.ts +29 -15
- package/src/core/page-images.test.ts +90 -0
- package/src/core/pretext.ts +4 -10
- package/src/core/template.test.ts +38 -1
- package/src/core/template.ts +53 -41
- package/src/core/types.ts +46 -0
- package/src/core/typography.ts +0 -14
- package/src/index.ts +6 -3
- package/src/server/auth.ts +18 -13
- package/src/server/index.ts +11 -16
- package/src/server/routes/fonts.ts +1 -1
- package/src/server/routes/render.ts +60 -17
- package/src/server/routes/slots.test.ts +96 -0
- package/src/server/routes/templates.ts +37 -48
- package/src/server/routes/upload.ts +2 -1
- package/src/web/App.tsx +15 -6
- package/src/web/components/CanvasViewport.tsx +56 -42
- package/src/web/components/EditorSidebar.tsx +59 -48
- package/src/web/components/Header.tsx +1 -1
- package/src/web/components/TemplateModal.tsx +25 -28
- package/src/web/hooks/image-layout.ts +31 -0
- package/src/web/hooks/useLocalFonts.ts +10 -1
- package/src/web/hooks/useTypeset.ts +139 -100
- package/data/templates/.gitkeep +0 -0
- package/data/templates/tpl_1791681833354_iofsk.json +0 -46
- package/data/templates/tpl_1791682603199_875ny.json +0 -66
- package/data/templates/tpl_1791682780929_qnuiy.json +0 -66
- package/data/templates/tpl_longform_paper_sage.json +0 -47
- package/data/templates/tpl_longform_vintage_news.json +0 -47
- package/data/templates/tpl_newspic_poster_cover.json +0 -64
- package/data/templates/tpl_square_quote_card.json +0 -46
- package/data/templates/tpl_wechat_cover_editorial.json +0 -64
- package/data/templates/tpl_wechat_cover_minimal.json +0 -64
- package/data/templates/tpl_wide_tech_banner.json +0 -46
- package/dist/assets/index-CZ352D7X.css +0 -1
- package/dist/assets/index-Dpnz-F2g.js +0 -317
package/src/adapter.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { FlowImage } from "./core/types";
|
|
6
|
-
import { resolveTypesetResult } from "./server/routes/render";
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { extname, join } from "node:path";
|
|
3
|
+
import type { FlowImage, PipelineCoverTheme } from "./core/types";
|
|
4
|
+
import type { RenderRequestBody } from "./server/routes/render";
|
|
7
5
|
|
|
8
6
|
// ── Typeset Studio & zzhub-pipeline compatible types ──────────────
|
|
9
7
|
|
|
@@ -15,8 +13,14 @@ export interface PipelinePluginDoctorCheck {
|
|
|
15
13
|
}
|
|
16
14
|
|
|
17
15
|
export interface ImageRenderInput {
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
/** Studio 模板与任务素材替换,旧插件可忽略。 */
|
|
17
|
+
studioTemplates?: { cover?: string; content?: string };
|
|
18
|
+
templateImageOverrides?: { cover?: Record<string, string>; content?: Record<string, string> };
|
|
19
|
+
coverTheme?: PipelineCoverTheme;
|
|
20
|
+
onProgress?: (progress: {
|
|
21
|
+
stage: string; message?: string; current?: number; total?: number;
|
|
22
|
+
unit?: "pages" | "files" | "bytes" | "targets"; route?: string; account?: string;
|
|
23
|
+
}) => void;
|
|
20
24
|
state: {
|
|
21
25
|
route: {
|
|
22
26
|
account?: string;
|
|
@@ -34,6 +38,7 @@ export interface ImageRenderInput {
|
|
|
34
38
|
pagination_mode?: string;
|
|
35
39
|
min_pages?: number;
|
|
36
40
|
max_pages?: number;
|
|
41
|
+
target_fill_ratio?: number;
|
|
37
42
|
page_specs?: Array<{
|
|
38
43
|
page: number;
|
|
39
44
|
image_markers: string[];
|
|
@@ -102,11 +107,13 @@ export interface ImageRenderPlugin {
|
|
|
102
107
|
|
|
103
108
|
// ── Helpers ───────────────────────────────────────────────────────
|
|
104
109
|
|
|
110
|
+
// 清理流水线标题中的 Markdown 标记。
|
|
105
111
|
function cleanTitle(raw: string): string {
|
|
106
112
|
if (!raw) return "未命名文章";
|
|
107
113
|
return raw.replace(/^#+\s*/, "").trim();
|
|
108
114
|
}
|
|
109
115
|
|
|
116
|
+
// 将流水线图片标记映射为页码和排版预设。
|
|
110
117
|
function mapBodyImages(
|
|
111
118
|
bodyImages?: Array<{ marker: string; path: string }>,
|
|
112
119
|
pageSpecs?: ImageRenderInput["pageSpecs"],
|
|
@@ -131,46 +138,76 @@ function mapBodyImages(
|
|
|
131
138
|
src: img.path,
|
|
132
139
|
alt: img.marker,
|
|
133
140
|
pageIndex: spec?.page,
|
|
134
|
-
layoutPreset: (spec?.imageLayout as
|
|
141
|
+
layoutPreset: (spec?.imageLayout as FlowImage["layoutPreset"]) || "mid-right",
|
|
135
142
|
};
|
|
136
143
|
});
|
|
137
144
|
}
|
|
138
145
|
|
|
139
146
|
// ── Typeset Studio Pipeline Adapter ───────────────────────────────
|
|
140
147
|
|
|
141
|
-
|
|
148
|
+
// 配置远程地址后,由独立服务负责图片渲染。
|
|
149
|
+
const remoteStudioUrl = () => process.env.TYPESET_STUDIO_URL?.replace(/\/$/, "");
|
|
142
150
|
|
|
151
|
+
// 下载远程生成的页面,并写入流水线输出目录。
|
|
143
152
|
async function renderRemoteBatch(
|
|
144
153
|
studioUrl: string,
|
|
145
|
-
body:
|
|
154
|
+
body: RenderRequestBody,
|
|
146
155
|
outputDir: string,
|
|
147
|
-
filenamePrefix: string,
|
|
148
156
|
route: "wechat-article" | "wechat-newspic",
|
|
149
157
|
kind: "cover" | "page",
|
|
150
|
-
): Promise<
|
|
158
|
+
): Promise<ImageRenderOutput> {
|
|
159
|
+
// 远程进程无法读取客户端文件,发送图片字节并保留来源摘要映射。
|
|
160
|
+
const sources = new Map<string, string>();
|
|
161
|
+
const originalImages = new Map([...(body.config?.images || []), ...(body.images || [])].map((image) => [image.id, image.src]));
|
|
162
|
+
const transfer = async (src: string) => {
|
|
163
|
+
if (/^(https?:|data:)/i.test(src) || src.startsWith("/uploads/")) return src;
|
|
164
|
+
const bytes = await readFile(src);
|
|
165
|
+
const mime = ({ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".svg": "image/svg+xml", ".webp": "image/webp", ".gif": "image/gif" } as Record<string, string>)[extname(src).toLowerCase()] || "image/png";
|
|
166
|
+
const dataUrl = `data:${mime};base64,${bytes.toString("base64")}`;
|
|
167
|
+
sources.set(dataUrl, src);
|
|
168
|
+
return dataUrl;
|
|
169
|
+
};
|
|
170
|
+
const requestBody = structuredClone(body);
|
|
171
|
+
for (const image of requestBody.images || []) image.src = await transfer(image.src);
|
|
172
|
+
// 保留模板样式时,备用配置不参与正常模板渲染,也不读取其本地素材。
|
|
173
|
+
if (!requestBody.preserveTemplateStyle) {
|
|
174
|
+
for (const image of requestBody.config?.images || []) image.src = await transfer(image.src);
|
|
175
|
+
}
|
|
176
|
+
for (const [key, src] of Object.entries(requestBody.templateImageOverrides || {})) {
|
|
177
|
+
try {
|
|
178
|
+
requestBody.templateImageOverrides![key] = await transfer(src);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
throw new Error(`模板素材 ${key} 加载失败:${error instanceof Error ? error.message : String(error)}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const coverTheme = requestBody.config?.coverTheme;
|
|
184
|
+
for (const image of requestBody.preserveTemplateStyle ? [] : [coverTheme?.background.image, coverTheme?.decoration.image]) {
|
|
185
|
+
if (image) image.src = await transfer(image.src);
|
|
186
|
+
}
|
|
151
187
|
const res = await fetch(`${studioUrl}/api/render/export`, {
|
|
152
188
|
method: "POST",
|
|
153
|
-
headers: { "Content-Type": "application/json" },
|
|
154
|
-
body: JSON.stringify(
|
|
189
|
+
headers: { "Content-Type": "application/json", "X-Admin-Key": process.env.TYPESET_ADMIN_KEY || "" },
|
|
190
|
+
body: JSON.stringify(requestBody),
|
|
155
191
|
});
|
|
156
192
|
if (!res.ok) {
|
|
157
|
-
|
|
193
|
+
const detail = (await res.text()).slice(0, 500);
|
|
194
|
+
throw new Error(`Cloud Typeset Studio returned HTTP ${res.status}: ${detail || res.statusText}`);
|
|
158
195
|
}
|
|
159
196
|
const data = (await res.json()) as {
|
|
160
197
|
ok: boolean;
|
|
161
198
|
pageCount: number;
|
|
162
|
-
pages: Array<{ pageNumber: number; dataUrl: string }>;
|
|
199
|
+
pages: Array<{ pageNumber: number; dataUrl: string; imageCount: number; imageSources: string[]; imageIds: string[]; bodyImageCount: number; bodyImageSources: string[]; bodyImageIds: string[] }>;
|
|
163
200
|
};
|
|
164
201
|
const assets: RenderAsset[] = [];
|
|
165
202
|
const pagesSummary: ImageRenderOutput["pages"] = [];
|
|
166
203
|
|
|
167
|
-
|
|
168
|
-
|
|
204
|
+
if (!data.ok || !Array.isArray(data.pages)) throw new Error("Invalid Typeset Studio export response");
|
|
205
|
+
for (const pageItem of (kind === "cover" ? data.pages.slice(0, 1) : data.pages)) {
|
|
169
206
|
const pageNum = pageItem.pageNumber;
|
|
170
207
|
const base64Data = pageItem.dataUrl.replace(/^data:image\/png;base64,/, "");
|
|
171
208
|
const buffer = Buffer.from(base64Data, "base64");
|
|
172
209
|
const filename =
|
|
173
|
-
kind === "cover" ? "cover.png" :
|
|
210
|
+
kind === "cover" ? "cover.png" : `article-${String(pageNum).padStart(2, "0")}.png`;
|
|
174
211
|
const filePath = join(outputDir, filename);
|
|
175
212
|
await writeFile(filePath, buffer);
|
|
176
213
|
|
|
@@ -182,30 +219,70 @@ async function renderRemoteBatch(
|
|
|
182
219
|
});
|
|
183
220
|
pagesSummary.push({
|
|
184
221
|
page: pageNum,
|
|
185
|
-
imageCount:
|
|
186
|
-
imageSources: [],
|
|
222
|
+
imageCount: pageItem.bodyImageCount,
|
|
223
|
+
imageSources: pageItem.bodyImageSources.map((src, index) => originalImages.get(pageItem.bodyImageIds[index]) || sources.get(src) || src),
|
|
187
224
|
});
|
|
188
225
|
}
|
|
189
226
|
|
|
190
|
-
return { assets, pageCount:
|
|
227
|
+
return { assets, pageCount: assets.length, pages: pagesSummary };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 本地渲染与远程服务共用请求,保留各自的图片输出方式。
|
|
231
|
+
async function renderLocalBatch(
|
|
232
|
+
body: RenderRequestBody,
|
|
233
|
+
outputDir: string,
|
|
234
|
+
route: RenderAsset["route"],
|
|
235
|
+
kind: RenderAsset["kind"],
|
|
236
|
+
): Promise<ImageRenderOutput> {
|
|
237
|
+
const [{ resolveTypesetResult }, { renderTypesetResultToPngs }] = await Promise.all([
|
|
238
|
+
import("./server/routes/render"), import("./core/canvas-renderer"),
|
|
239
|
+
]);
|
|
240
|
+
const { result } = resolveTypesetResult(body);
|
|
241
|
+
const pngs = await renderTypesetResultToPngs(result);
|
|
242
|
+
const outputs = kind === "cover" ? pngs.slice(0, 1) : pngs;
|
|
243
|
+
if (kind === "cover" && outputs.length === 0) {
|
|
244
|
+
throw new Error("Typeset Studio failed to render cover buffer");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const assets: RenderAsset[] = [];
|
|
248
|
+
const pages: ImageRenderOutput["pages"] = [];
|
|
249
|
+
for (const [index, output] of outputs.entries()) {
|
|
250
|
+
const pageNumber = index + 1;
|
|
251
|
+
const filename = kind === "cover"
|
|
252
|
+
? "cover.png"
|
|
253
|
+
: `article-${String(pageNumber).padStart(2, "0")}.png`;
|
|
254
|
+
const path = join(outputDir, filename);
|
|
255
|
+
await writeFile(path, output.buffer);
|
|
256
|
+
assets.push({ kind, route, path, ...(kind === "page" ? { index: pageNumber } : {}) });
|
|
257
|
+
const images = result.pages[index].images.filter((image) => !image.templateAssetKey);
|
|
258
|
+
pages.push({
|
|
259
|
+
page: pageNumber,
|
|
260
|
+
imageCount: images.length,
|
|
261
|
+
imageSources: images.map((image) => image.src),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return { assets, pageCount: outputs.length, pages };
|
|
191
265
|
}
|
|
192
266
|
|
|
193
267
|
export const typesetStudioAdapter: ImageRenderPlugin = {
|
|
194
268
|
name: "typeset-studio",
|
|
195
|
-
version: "0.2.
|
|
269
|
+
version: "0.2.2",
|
|
196
270
|
|
|
197
271
|
async doctor(): Promise<PipelinePluginDoctorCheck[]> {
|
|
198
272
|
const checks: PipelinePluginDoctorCheck[] = [];
|
|
199
273
|
|
|
200
|
-
|
|
274
|
+
const REMOTE_STUDIO_URL = remoteStudioUrl();
|
|
201
275
|
if (REMOTE_STUDIO_URL) {
|
|
202
276
|
try {
|
|
203
|
-
const res = await fetch(`${REMOTE_STUDIO_URL}/api/
|
|
204
|
-
|
|
277
|
+
const res = await fetch(`${REMOTE_STUDIO_URL}/api/auth/verify`, {
|
|
278
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
279
|
+
body: JSON.stringify({ key: process.env.TYPESET_ADMIN_KEY || "" }),
|
|
280
|
+
});
|
|
281
|
+
const data = (await res.json()) as { ok?: boolean };
|
|
205
282
|
checks.push({
|
|
206
283
|
name: "remote-service",
|
|
207
|
-
ok: res.ok,
|
|
208
|
-
message: `
|
|
284
|
+
ok: res.ok && data.ok === true,
|
|
285
|
+
message: data.ok ? `Authenticated with Typeset Studio at ${REMOTE_STUDIO_URL}` : "Remote admin key is missing or invalid",
|
|
209
286
|
});
|
|
210
287
|
} catch (err) {
|
|
211
288
|
checks.push({
|
|
@@ -216,7 +293,6 @@ export const typesetStudioAdapter: ImageRenderPlugin = {
|
|
|
216
293
|
}
|
|
217
294
|
}
|
|
218
295
|
|
|
219
|
-
// 1. Skia engine check (for local/in-process rendering)
|
|
220
296
|
try {
|
|
221
297
|
const { createCanvas } = await import("@napi-rs/canvas");
|
|
222
298
|
const canvas = createCanvas(10, 10);
|
|
@@ -231,15 +307,15 @@ export const typesetStudioAdapter: ImageRenderPlugin = {
|
|
|
231
307
|
} catch (err) {
|
|
232
308
|
checks.push({
|
|
233
309
|
name: "@napi-rs/canvas",
|
|
234
|
-
ok:
|
|
310
|
+
ok: Boolean(REMOTE_STUDIO_URL),
|
|
235
311
|
message: REMOTE_STUDIO_URL
|
|
236
312
|
? "@napi-rs/canvas not installed locally (using Cloud Typeset Studio microservice)"
|
|
237
313
|
: `@napi-rs/canvas initialization failed: ${err}`,
|
|
238
314
|
});
|
|
239
315
|
}
|
|
240
316
|
|
|
241
|
-
// 2. Local fonts check
|
|
242
317
|
try {
|
|
318
|
+
const { scanLocalFonts } = await import("./core/font-loader");
|
|
243
319
|
const localFonts = scanLocalFonts();
|
|
244
320
|
checks.push({
|
|
245
321
|
name: "local-fonts",
|
|
@@ -258,266 +334,99 @@ export const typesetStudioAdapter: ImageRenderPlugin = {
|
|
|
258
334
|
},
|
|
259
335
|
|
|
260
336
|
async render(input: ImageRenderInput): Promise<ImageRenderOutput> {
|
|
261
|
-
const { route, outputDir,
|
|
337
|
+
const { route, outputDir, bodyText, state } = input;
|
|
262
338
|
await mkdir(outputDir, { recursive: true });
|
|
339
|
+
if (route === "blog") return { assets: [], pageCount: 0, pages: [] };
|
|
263
340
|
|
|
341
|
+
const REMOTE_STUDIO_URL = remoteStudioUrl();
|
|
264
342
|
const account = state.route.account || "default";
|
|
265
|
-
const
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
title: effectiveTitle,
|
|
281
|
-
markdown: `# ${effectiveTitle}`,
|
|
282
|
-
template: input.template,
|
|
283
|
-
images: coverImages,
|
|
284
|
-
highlightWords,
|
|
285
|
-
config: { footerText, watermarkText },
|
|
286
|
-
},
|
|
287
|
-
outputDir,
|
|
288
|
-
"cover",
|
|
289
|
-
"wechat-article",
|
|
290
|
-
"cover",
|
|
291
|
-
);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
if (route === "wechat-newspic") {
|
|
295
|
-
const newspicSpec = state.intent?.newspic_render;
|
|
296
|
-
const paginationMode =
|
|
297
|
-
input.template === "longform-3-4"
|
|
298
|
-
? "multi"
|
|
299
|
-
: input.template === "poster-3-4"
|
|
300
|
-
? "single"
|
|
301
|
-
: newspicSpec?.pagination_mode || "multi";
|
|
302
|
-
|
|
303
|
-
const isMultiPage = paginationMode === "multi";
|
|
304
|
-
const coverImages = mapBodyImages(input.bodyImages, input.pageSpecs);
|
|
305
|
-
|
|
306
|
-
// Remote cover
|
|
307
|
-
const coverRes = await renderRemoteBatch(
|
|
308
|
-
REMOTE_STUDIO_URL,
|
|
309
|
-
{
|
|
310
|
-
slot: "newspic_cover",
|
|
311
|
-
route: "wechat-newspic",
|
|
312
|
-
kind: "cover",
|
|
313
|
-
account,
|
|
314
|
-
title: effectiveTitle,
|
|
315
|
-
markdown: `# ${effectiveTitle}`,
|
|
316
|
-
template: input.template,
|
|
317
|
-
images: coverImages,
|
|
318
|
-
highlightWords,
|
|
319
|
-
config: { footerText, watermarkText },
|
|
320
|
-
},
|
|
321
|
-
outputDir,
|
|
322
|
-
"cover",
|
|
323
|
-
"wechat-newspic",
|
|
324
|
-
"cover",
|
|
325
|
-
);
|
|
326
|
-
|
|
327
|
-
if (!isMultiPage) {
|
|
328
|
-
return coverRes;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Remote content pages
|
|
332
|
-
const contentImages = mapBodyImages(input.bodyImages, input.pageSpecs);
|
|
333
|
-
const contentRes = await renderRemoteBatch(
|
|
334
|
-
REMOTE_STUDIO_URL,
|
|
335
|
-
{
|
|
336
|
-
slot: "newspic_content",
|
|
337
|
-
route: "wechat-newspic",
|
|
338
|
-
kind: "content",
|
|
339
|
-
account,
|
|
340
|
-
title: effectiveTitle,
|
|
341
|
-
markdown: bodyText || `# ${effectiveTitle}`,
|
|
342
|
-
template: input.template,
|
|
343
|
-
images: contentImages,
|
|
344
|
-
highlightWords,
|
|
345
|
-
config: { footerText, watermarkText },
|
|
346
|
-
},
|
|
347
|
-
outputDir,
|
|
348
|
-
"article",
|
|
349
|
-
"wechat-newspic",
|
|
350
|
-
"page",
|
|
351
|
-
);
|
|
352
|
-
|
|
353
|
-
return {
|
|
354
|
-
assets: [...coverRes.assets, ...contentRes.assets],
|
|
355
|
-
pageCount: contentRes.pageCount,
|
|
356
|
-
pages: contentRes.pages,
|
|
357
|
-
};
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// ── 1. Route: wechat-article (2.35:1 Cover only) ─────────────
|
|
362
|
-
if (route === "wechat-article") {
|
|
363
|
-
const coverImages = mapBodyImages(input.bodyImages, input.pageSpecs);
|
|
364
|
-
|
|
365
|
-
const resolved = resolveTypesetResult({
|
|
366
|
-
slot: "article_cover",
|
|
367
|
-
route: "wechat-article",
|
|
368
|
-
account,
|
|
369
|
-
title: effectiveTitle,
|
|
370
|
-
markdown: `# ${effectiveTitle}`,
|
|
371
|
-
template: input.template,
|
|
372
|
-
images: coverImages,
|
|
373
|
-
highlightWords,
|
|
374
|
-
config: {
|
|
375
|
-
footerText: footerText ?? undefined,
|
|
376
|
-
watermarkText: watermarkText ?? undefined,
|
|
377
|
-
},
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
const pngOutputs = await renderTypesetResultToPngs(resolved.result);
|
|
381
|
-
const coverBuffer = pngOutputs[0]?.buffer;
|
|
382
|
-
if (!coverBuffer) {
|
|
383
|
-
throw new Error("Typeset Studio failed to render cover buffer");
|
|
384
|
-
}
|
|
385
|
-
const coverPath = join(outputDir, "cover.png");
|
|
386
|
-
await writeFile(coverPath, coverBuffer);
|
|
387
|
-
|
|
388
|
-
const asset: RenderAsset = {
|
|
389
|
-
kind: "cover",
|
|
390
|
-
route: "wechat-article",
|
|
391
|
-
path: coverPath,
|
|
392
|
-
};
|
|
393
|
-
|
|
394
|
-
return {
|
|
395
|
-
assets: [asset],
|
|
396
|
-
pageCount: 1,
|
|
397
|
-
pages: [
|
|
398
|
-
{
|
|
399
|
-
page: 1,
|
|
400
|
-
imageCount: resolved.result.pages[0]?.images.length || 0,
|
|
401
|
-
imageSources: (resolved.result.pages[0]?.images || []).map((img) => img.src),
|
|
402
|
-
},
|
|
403
|
-
],
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// ── 2. Route: blog (no image rendering required) ──────────────
|
|
408
|
-
if (route === "blog") {
|
|
409
|
-
return {
|
|
410
|
-
assets: [],
|
|
411
|
-
pageCount: 0,
|
|
412
|
-
pages: [],
|
|
413
|
-
};
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// ── 3. Route: wechat-newspic (Poster Cover + Multi-page Content) ─
|
|
417
|
-
const newspicSpec = state.intent?.newspic_render;
|
|
418
|
-
const paginationMode =
|
|
419
|
-
input.template === "longform-3-4"
|
|
420
|
-
? "multi"
|
|
421
|
-
: input.template === "poster-3-4"
|
|
422
|
-
? "single"
|
|
423
|
-
: newspicSpec?.pagination_mode || "multi";
|
|
424
|
-
|
|
425
|
-
const isMultiPage = paginationMode === "multi";
|
|
426
|
-
const assets: RenderAsset[] = [];
|
|
427
|
-
const pagesSummary: ImageRenderOutput["pages"] = [];
|
|
428
|
-
|
|
429
|
-
// 3.1 Render Poster Cover (3:4)
|
|
430
|
-
const coverImages = mapBodyImages(input.bodyImages, input.pageSpecs);
|
|
431
|
-
const resolvedCover = resolveTypesetResult({
|
|
432
|
-
slot: "newspic_cover",
|
|
433
|
-
route: "wechat-newspic",
|
|
434
|
-
kind: "cover",
|
|
343
|
+
const saved = state.intent?.newspic_render;
|
|
344
|
+
const pageSpecs = input.pageSpecs ?? saved?.page_specs?.map((spec) => ({
|
|
345
|
+
page: spec.page, imageMarkers: spec.image_markers, imageLayout: spec.image_layout, targetFillRatio: spec.target_fill_ratio,
|
|
346
|
+
}));
|
|
347
|
+
const visual = input.accountVisualParams || {};
|
|
348
|
+
const stateVisual = state.route.account_visual_params;
|
|
349
|
+
const footer = visual.footer ?? stateVisual?.footer;
|
|
350
|
+
const bg = visual.bg ?? stateVisual?.bg;
|
|
351
|
+
const highlight = visual.highlight ?? stateVisual?.highlight;
|
|
352
|
+
const fallbackIcon = visual.fallbackIcon ?? stateVisual?.fallback_icon;
|
|
353
|
+
// pipeline 的模式标记只选择分页方式,具体模板名才参与模板解析。
|
|
354
|
+
const template = ["wechat-cover-split", "poster-3-4", "longform-3-4"].includes(input.template || "") ? undefined : input.template;
|
|
355
|
+
input.onProgress?.({ stage: "typeset", message: "开始排版", route, account });
|
|
356
|
+
const title = cleanTitle(input.title || state.metadata?.title || "");
|
|
357
|
+
const common: RenderRequestBody = {
|
|
435
358
|
account,
|
|
436
|
-
title
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
highlightWords,
|
|
359
|
+
title,
|
|
360
|
+
preserveTemplateStyle: true,
|
|
361
|
+
images: mapBodyImages(input.bodyImages, pageSpecs),
|
|
362
|
+
highlightWords: state.route.highlight_words || [],
|
|
441
363
|
config: {
|
|
442
|
-
footerText:
|
|
443
|
-
watermarkText:
|
|
364
|
+
...(footer !== undefined ? { footerText: footer } : {}),
|
|
365
|
+
watermarkText: `@${account}`,
|
|
366
|
+
...((bg || highlight) ? { theme: {
|
|
367
|
+
...(bg ? { bgColor: bg } : {}), ...(highlight ? { accentColor: highlight } : {}),
|
|
368
|
+
} } : {}),
|
|
444
369
|
},
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
{
|
|
467
|
-
page: 1,
|
|
468
|
-
imageCount: resolvedCover.result.pages[0]?.images.length || 0,
|
|
469
|
-
imageSources: (resolvedCover.result.pages[0]?.images || []).map((img) => img.src),
|
|
470
|
-
},
|
|
471
|
-
],
|
|
370
|
+
};
|
|
371
|
+
const articleCover = route === "wechat-article";
|
|
372
|
+
const renderRoute = articleCover ? "wechat-article" : "wechat-newspic";
|
|
373
|
+
|
|
374
|
+
// 统一封面和正文的请求构造,传输方式只在这里分流。
|
|
375
|
+
const renderBatch = (body: RenderRequestBody, kind: RenderAsset["kind"]) =>
|
|
376
|
+
REMOTE_STUDIO_URL && (route === "wechat-article" || route === "wechat-newspic")
|
|
377
|
+
? renderRemoteBatch(REMOTE_STUDIO_URL, body, outputDir, renderRoute, kind)
|
|
378
|
+
: renderLocalBatch(body, outputDir, renderRoute, kind);
|
|
379
|
+
|
|
380
|
+
const coverTheme = input.coverTheme;
|
|
381
|
+
const coverConfig = { ...common.config, algorithm: { targetFillRatio: 1 } };
|
|
382
|
+
if (coverTheme) {
|
|
383
|
+
coverConfig.coverTheme = coverTheme;
|
|
384
|
+
if (footer) coverConfig.chrome = { showHeader: false, showFooter: true, showFooterDivider: false, showFooterText: true };
|
|
385
|
+
coverConfig.algorithm = { ...coverTheme.layout, targetFillRatio: 1 };
|
|
386
|
+
coverConfig.theme = {
|
|
387
|
+
bgColor: coverTheme.background.fill.type === "solid" ? coverTheme.background.fill.color : "transparent",
|
|
388
|
+
textColor: coverTheme.colors.text, headingColor: coverTheme.colors.text,
|
|
389
|
+
accentColor: coverTheme.colors.accent, quoteColor: coverTheme.colors.muted,
|
|
390
|
+
captionColor: coverTheme.colors.muted, watermarkColor: coverTheme.colors.muted,
|
|
472
391
|
};
|
|
473
392
|
}
|
|
393
|
+
const firstPageImages = (common.images || []).filter((image) => !image.pageIndex || image.pageIndex === 1)
|
|
394
|
+
.map((image) => ({ ...image, pageIndex: 1 }));
|
|
395
|
+
if (fallbackIcon) coverConfig.images = [{
|
|
396
|
+
id: "account_icon", src: fallbackIcon, alt: "账号标识", isAbsolute: true,
|
|
397
|
+
x: 0, y: 0, width: 48, height: 48, zIndex: 20, pageIndex: 1,
|
|
398
|
+
templateAssetKey: "account_icon",
|
|
399
|
+
}];
|
|
400
|
+
const cover = await renderBatch({
|
|
401
|
+
...common, config: coverConfig, images: firstPageImages,
|
|
402
|
+
template: input.studioTemplates?.cover || template,
|
|
403
|
+
templateImageOverrides: input.templateImageOverrides?.cover,
|
|
404
|
+
route: renderRoute, slot: articleCover ? "article_cover" : "newspic_cover",
|
|
405
|
+
kind: "cover", maxPages: 1, markdown: `# ${title}`,
|
|
406
|
+
}, "cover");
|
|
407
|
+
input.onProgress?.({ stage: "cover", message: "封面已生成", current: 1, total: 1, unit: "pages", route, account });
|
|
408
|
+
const paginationMode = input.template === "longform-3-4"
|
|
409
|
+
? "multi"
|
|
410
|
+
: input.template === "poster-3-4"
|
|
411
|
+
? "single"
|
|
412
|
+
: state.intent?.newspic_render?.pagination_mode || "multi";
|
|
413
|
+
if (articleCover || paginationMode !== "multi") return cover;
|
|
474
414
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const resolvedContent = resolveTypesetResult({
|
|
478
|
-
slot: "newspic_content",
|
|
415
|
+
const content = await renderBatch({
|
|
416
|
+
...common,
|
|
479
417
|
route: "wechat-newspic",
|
|
418
|
+
template: input.studioTemplates?.content || template,
|
|
419
|
+
templateImageOverrides: input.templateImageOverrides?.content,
|
|
480
420
|
kind: "content",
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
},
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
const contentPngs = await renderTypesetResultToPngs(resolvedContent.result);
|
|
494
|
-
|
|
495
|
-
for (let i = 0; i < contentPngs.length; i++) {
|
|
496
|
-
const pageNum = i + 1;
|
|
497
|
-
const fileName = `article-${String(pageNum).padStart(2, "0")}.png`;
|
|
498
|
-
const pagePath = join(outputDir, fileName);
|
|
499
|
-
await writeFile(pagePath, contentPngs[i].buffer);
|
|
500
|
-
|
|
501
|
-
assets.push({
|
|
502
|
-
kind: "page",
|
|
503
|
-
route: "wechat-newspic",
|
|
504
|
-
index: pageNum,
|
|
505
|
-
path: pagePath,
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
const flowPage = resolvedContent.result.pages[i];
|
|
509
|
-
pagesSummary.push({
|
|
510
|
-
page: pageNum,
|
|
511
|
-
imageCount: flowPage?.images.length || 0,
|
|
512
|
-
imageSources: (flowPage?.images || []).map((img) => img.src),
|
|
513
|
-
});
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
return {
|
|
517
|
-
assets,
|
|
518
|
-
pageCount: contentPngs.length,
|
|
519
|
-
pages: pagesSummary,
|
|
520
|
-
};
|
|
421
|
+
slot: "newspic_content",
|
|
422
|
+
markdown: bodyText || `# ${title}`,
|
|
423
|
+
config: { ...common.config, ...(saved?.target_fill_ratio != null ? { algorithm: { targetFillRatio: saved.target_fill_ratio } } : {}) },
|
|
424
|
+
minPages: input.minPages ?? saved?.min_pages,
|
|
425
|
+
maxPages: input.maxPages ?? saved?.max_pages,
|
|
426
|
+
pageFillRatios: Object.fromEntries((pageSpecs || []).filter((spec) => spec.targetFillRatio != null).map((spec) => [spec.page, spec.targetFillRatio!])),
|
|
427
|
+
}, "page");
|
|
428
|
+
input.onProgress?.({ stage: "content", message: "正文已生成", current: content.pageCount, total: content.pageCount, unit: "pages", route, account });
|
|
429
|
+
return { ...content, assets: [...cover.assets, ...content.assets] };
|
|
521
430
|
},
|
|
522
431
|
};
|
|
523
432
|
|