feat(ai): expose detailed poster editing and client rendering
This commit is contained in:
1 parent
03a0397a3e
commit
150f0e8de5
33 files changed
+1992
-257
No files matched your search
@@ -528,6 +528,7 @@ export function createCompanionTools(args: { db: DatabaseAdapter; scope: Compani
|
||||
}))),
|
||||
});
|
||||
}
|
||||
if (definition.name === "get_poster_capabilities" || definition.name === "get_poster_preview" || definition.name === "export_poster") return done(result);
|
||||
const serialized = JSON.stringify(result);
|
||||
const text = serialized.slice(0, Math.min(8000, session.metadataRemaining));
|
||||
session.metadataRemaining -= text.length;
|
||||
|
||||
@@ -50,7 +50,7 @@ When the user asks for a mind map, 思维导图, flowchart, 流程图, architect
|
||||
When the user asks to generate a poster or 海报笔记, call create_poster_memo with concise copy. It creates the actual editable poster type. Never substitute poster-style Markdown, manually encode source, or search local files for the format. Report tool failure honestly. If the user did not name a notebook, omit notebookId. Reply with the new note link.
|
||||
When the user asks for an infographic, 信息图, 資訊圖, or インフォグラフィック, call create_infographic_memo. Do not use create_diagram_memo, create_memo, Markdown, or Mermaid for that request. A share, proportion, or 占比 is a pie infographic: template chart-pie-donut-plain-text and numeric data.values, not a mind map. If the user did not supply the figures, say in data.desc that they are illustrative and are not an official disclosure. If the user did not name a notebook, omit notebookId. Reply with the new note link.
|
||||
When the user asks for a 多维表格, 多維表格, or structured table, call create_table_memo with explicit fields. Do not use create_memo or a Markdown table. If no records were supplied or requested, leave it empty. If records were requested, call get_table_records for the new table's field IDs and revision before add_table_record. Omit notebookId when no notebook was named. Reply with the new note link.
|
||||
To find or list posters/海报笔记, call search_memos or list_memos with poster=true; the type is not a title keyword. To read a poster's canvas, call get_poster. To change a poster/海报, call get_poster then update_poster. Edit only requested text, colors, typography or positions; preserve other elements and locked elements. Never replace poster content with update_memo. To delete a poster, use trash_memos with exact IDs from the query results; it goes to the recycle bin and can be restored with restore_memos.
|
||||
To find or list posters/海报笔记, call search_memos or list_memos with poster=true; the type is not a title keyword. To read a poster's canvas, call get_poster. For detailed poster design, read get_poster_capabilities for fonts, platform sizes, templates, operation fields and limits; then get_poster and update_poster. Operations support elements, layers, groups and canvas resizing, and typography, crop, stroke and shadow edits. apply_template replaces the layout: use only when the user asks. get_poster_preview and export_poster expose current saved previews and editable source; they do not render new images. For a fresh preview or full browser template, call request_poster_render with expectedRevision (and templateId only when requested), then get_poster_render_job at intervals of at least 5 seconds. An online desktop/Web workspace renders automatically. Pending means waiting for a client; only completed means rendered. After completion call get_poster_preview. Never claim a pending task succeeded. To change a poster/海报, call get_poster then update_poster. Edit only requested text, colors, typography or positions; preserve other elements and locked elements. Never replace poster content with update_memo. To delete a poster, use trash_memos with exact IDs from the query results; it goes to the recycle bin and can be restored with restore_memos.
|
||||
To change an existing diagram, call get_diagram then update_diagram. Do not create a second diagram unless the user asked for a new note. Do not use update_memo or Mermaid for diagrams.
|
||||
A structured table cannot be replaced with update_memo. Use get_table_records to read its structure and records. Do not rewrite its Markdown.
|
||||
You can list, create, update, use, and delete note templates, and list, create, update, and delete AI instructions. use_note_template creates a memo immediately. You cannot empty the trash, share notes, or upload files. Do not claim those tools exist.
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { DatabaseAdapter } from "./storage-contract";
|
||||
import type { CompanionScope, TurnRow } from "./companion-service";
|
||||
import type { AppContext } from "./api-context";
|
||||
import { companionToolDefinitions, createCompanionTools } from "./companion-agent-tools";
|
||||
import { posterToolImage } from "./poster-tool-output";
|
||||
import {
|
||||
companionAgentInstructions,
|
||||
companionExecutionReceipts,
|
||||
@@ -69,6 +70,16 @@ export const streamCompanion = async (args: {
|
||||
if (!selected) throw new Error(`Missing companion tool: ${definition.name}`);
|
||||
return selected.execute(input);
|
||||
},
|
||||
...(definition.name === "get_poster_preview" || definition.name === "export_poster" ? {
|
||||
toModelOutput: ({ output }: { output: unknown }) => {
|
||||
const visual = posterToolImage(definition.name, output);
|
||||
if (!visual) return { type: "text" as const, value: JSON.stringify(output) };
|
||||
return { type: "content" as const, value: [
|
||||
{ type: "text" as const, text: JSON.stringify(visual.metadata) },
|
||||
{ type: "file" as const, mediaType: visual.image.mimeType, data: { type: "data" as const, data: visual.image.data } },
|
||||
] };
|
||||
},
|
||||
} : {}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("shared companion MCP adapter", () => {
|
||||
expect(parsePosterDocument((await getMemoDetail(f.db, scope.workspaceId, created.id)).contentMarkdown).copy.title).toBe("Focus on what matters");
|
||||
});
|
||||
test("reuses the exact reviewed MCP definitions, with no administration or upload tools", () => {
|
||||
expect(COMPANION_MCP_TOOLS).toHaveLength(50);
|
||||
expect(COMPANION_MCP_TOOLS).toHaveLength(55);
|
||||
for (const definition of COMPANION_MCP_TOOLS) expect(MCP_TOOLS.includes(definition)).toBe(true);
|
||||
for (const name of ["upload_memo_image", "upload_memo_attachment", "empty_trash", "share_memo"]) {
|
||||
expect(() => validateCompanionTool(name, {})).toThrow();
|
||||
@@ -138,7 +138,7 @@ describe("shared companion MCP adapter", () => {
|
||||
test("read and dry-run tools reuse MCP without writing or requiring a proposal", async () => {
|
||||
const f = await setup();
|
||||
const tools = createCompanionTools({ ...f, scope, signal: new AbortController().signal, assertActive: async () => {}, sources: [] });
|
||||
expect(Object.keys(tools)).toHaveLength(52);
|
||||
expect(Object.keys(tools)).toHaveLength(57);
|
||||
expect(await tools.get_memo.execute({ memoId: f.notes[0].id })).toMatchObject({ content: "One original content" });
|
||||
expect(await tools.trash_memos.execute({ memoIds: [f.notes[0].id], dryRun: true })).toMatchObject({ dryRun: true });
|
||||
expect(await getMemoDetail(f.db, scope.workspaceId, f.notes[0].id)).not.toBeNull();
|
||||
|
||||
@@ -6,7 +6,7 @@ import { AppError } from "./app-error";
|
||||
// capabilities must be reviewed rather than silently acquiring user authority.
|
||||
const allowed = new Set([
|
||||
"get_current_user", "search_memos", "list_memos", "get_memo", "create_memo", "create_table_memo", "get_table_records", "add_table_record", "create_diagram_memo", "create_infographic_memo", "create_poster_memo", "get_diagram",
|
||||
"update_diagram", "get_poster", "update_poster",
|
||||
"update_diagram", "get_poster", "update_poster", "get_poster_capabilities", "request_poster_render", "get_poster_render_job", "get_poster_preview", "export_poster",
|
||||
"import_memos", "update_memo",
|
||||
"trash_memos", "restore_memos", "move_memos", "add_tags_to_memos", "remove_tags_from_memos",
|
||||
"rename_tag", "delete_tag", "merge_memos", "list_memo_resources", "list_resources",
|
||||
|
||||
@@ -88,6 +88,7 @@ import {
|
||||
uploadResourcePart,
|
||||
} from "./resource-upload-service";
|
||||
import { registerPluginDistributionRoutes } from "./plugin-distribution-routes";
|
||||
import { registerPosterRenderRoutes } from "./poster-render-routes";
|
||||
import { registerSyncRoutes } from "./sync-routes";
|
||||
import { registerMemoRoutes } from "./memo-routes";
|
||||
import { registerScheduledTaskRoutes } from "./scheduled-task-routes";
|
||||
@@ -331,6 +332,7 @@ registerSyncRoutes(app, {
|
||||
mapMemoDetail: (...args) => mapMemoDetail(...args),
|
||||
});
|
||||
registerTagRoutes(app);
|
||||
registerPosterRenderRoutes(app);
|
||||
registerPluginDistributionRoutes(app);
|
||||
registerScheduledTaskRoutes(app);
|
||||
registerWorkspaceExtensionRoutes(app, { isDemoMode: (...args) => isDemoMode(...args) });
|
||||
|
||||
@@ -68,6 +68,12 @@ export const getRequiredStringArray = (value: unknown, name: string) => {
|
||||
return items;
|
||||
};
|
||||
|
||||
export const encodeBase64Data = (bytes: Uint8Array) => {
|
||||
const chunks: string[] = [];
|
||||
for (let offset = 0; offset < bytes.length; offset += 4096) chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + 4096)));
|
||||
return btoa(chunks.join(""));
|
||||
};
|
||||
|
||||
export const decodeBase64Data = async (value: string) => {
|
||||
const [, dataUrlPayload] = value.match(/^data:[^;]+;base64,(.+)$/i) ?? [];
|
||||
const base64 = (dataUrlPayload ?? value).replace(/\s/g, "");
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { globSync, readFileSync } from "node:fs";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { claimPosterRender } from "./poster-render-service";
|
||||
import { registerPosterRenderRoutes } from "./poster-render-routes";
|
||||
import { Hono } from "hono";
|
||||
import { callMcpTool } from "./index.ts";
|
||||
import { createPosterDocument, serializePosterDocument, parsePosterDocument, addTableField, parseDiagramDocument, parseInfographicDocument, parseTableDocument, serializeTableDocument, createDefaultTableDocument } from "@edgeever/shared";
|
||||
|
||||
@@ -72,6 +75,119 @@ const createFixture = (scopes = ["read:memos", "write:memos"]) => {
|
||||
};
|
||||
|
||||
describe("MCP template and AI instruction management", () => {
|
||||
test("client render queue claims once, saves atomically, isolates workspaces and checks source revision", async () => {
|
||||
const { sqlite, auth, context } = createFixture(["read:memos", "write:memos", "write:resources"]);
|
||||
sqlite.query("INSERT INTO notebooks(id, workspace_id, name) VALUES ('nb_p', 'ws_mcp', 'Posters')").run();
|
||||
const created = await callMcpTool(context, auth, "create_poster_memo", { notebookId: "nb_p", title: "Poster", heading: "Title" });
|
||||
const input = { memoId: created.memo.id, expectedRevision: created.memo.revision };
|
||||
const pending = await callMcpTool(context, auth, "request_poster_render", input);
|
||||
expect(pending.status).toBe("pending");
|
||||
expect((await callMcpTool(context, auth, "request_poster_render", input)).id).toBe(pending.id);
|
||||
await expect(callMcpTool(context, { ...auth, scopes: ["write:memos"] }, "request_poster_render", input)).rejects.toMatchObject({code:"forbidden"});
|
||||
await expect(callMcpTool(context, { ...auth, workspaceId: "ws_other" }, "get_poster_render_job", {jobId: pending.id})).rejects.toMatchObject({code:"not_found"});
|
||||
expect(await claimPosterRender(context.env.storage.db, "ws_other")).toBeNull();
|
||||
const claim = await claimPosterRender(context.env.storage.db, auth.workspaceId);
|
||||
expect(claim.id).toBe(pending.id);
|
||||
expect(await claimPosterRender(context.env.storage.db, auth.workspaceId)).toBeNull();
|
||||
const app = new Hono();
|
||||
app.use("*", async (c,next) => { c.set("auth", {...auth, kind:"user", actorType:"user"}); await next(); });
|
||||
registerPosterRenderRoutes(app);
|
||||
const complete = body => app.request(`/api/v1/poster-render/${claim.id}/complete`, {method:"POST", headers:{"content-type":"application/json"},body:JSON.stringify(body)},context.env);
|
||||
const source = (await callMcpTool(context, auth, "get_poster", {memoId:created.memo.id})).poster;
|
||||
expect((await complete({claimToken:"wrong", document:source})).status).toBe(404);
|
||||
sqlite.query("INSERT INTO resources(id,memo_id,object_key,kind,mime_type,byte_size) VALUES ('preview', ?, 'preview', 'image', 'image/png', 3)").run(created.memo.id);
|
||||
const document = {...source,previewResourceId:"preview"};
|
||||
expect((await complete({claimToken:claim.claim_token,document:{...document,background:"#ff0000"}})).status).toBe(400);
|
||||
const response = await complete({claimToken:claim.claim_token, document});
|
||||
expect(response.status).toBe(200);
|
||||
expect((await response.json()).job.status).toBe("completed");
|
||||
expect((await complete({claimToken:claim.claim_token,document})).status).toBe(200);
|
||||
const saved = await callMcpTool(context, auth, "get_poster", {memoId:created.memo.id});
|
||||
expect(saved.poster.previewResourceId).toBe("preview");
|
||||
expect(saved.memo.revision).toBe(input.expectedRevision+1);
|
||||
const next = await callMcpTool(context,auth,"request_poster_render",{memoId:created.memo.id,expectedRevision:saved.memo.revision,templateId:"folio"});
|
||||
const second = await claimPosterRender(context.env.storage.db,auth.workspaceId);
|
||||
await callMcpTool(context,auth,"update_poster",{memoId:created.memo.id,expectedRevision:saved.memo.revision,background:"#aabbcc"});
|
||||
const conflict = await app.request(`/api/v1/poster-render/${second.id}/complete`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({claimToken:second.claim_token,document:{...document,templateId:"folio"}})},context.env);
|
||||
expect(conflict.status).toBe(409);
|
||||
sqlite.query("UPDATE poster_render_jobs SET expires_at = '2000-01-01' WHERE id = ?").run(next.id);
|
||||
expect((await callMcpTool(context,auth,"get_poster_render_job",{jobId:next.id})).status).toBe("failed");
|
||||
});
|
||||
|
||||
test("render commit guard rolls back when an edit arrives after validation", async () => {
|
||||
const { sqlite, auth, context } = createFixture(["read:memos", "write:memos", "write:resources"]);
|
||||
sqlite.query("INSERT INTO notebooks(id, workspace_id, name) VALUES ('nb_p', 'ws_mcp', 'Posters')").run();
|
||||
const created = await callMcpTool(context,auth,"create_poster_memo",{notebookId:"nb_p",title:"Guard",heading:"Guard"});
|
||||
const job = await callMcpTool(context,auth,"request_poster_render",{memoId:created.memo.id,expectedRevision:created.memo.revision});
|
||||
const [first,second] = await Promise.all([claimPosterRender(context.env.storage.db,auth.workspaceId),claimPosterRender(context.env.storage.db,auth.workspaceId)]);
|
||||
expect([first,second].filter(Boolean)).toHaveLength(1);
|
||||
const claim = first ?? second;
|
||||
const source = (await callMcpTool(context,auth,"get_poster",{memoId:created.memo.id})).poster;
|
||||
sqlite.query("INSERT INTO resources(id,memo_id,object_key,kind,mime_type,byte_size) VALUES ('preview', ?, 'preview', 'image', 'image/png', 3)").run(created.memo.id);
|
||||
const db = context.env.storage.db, originalBatch = db.batch.bind(db);
|
||||
let injected = false;
|
||||
db.batch = async statements => {
|
||||
if (!injected && statements.some(statement=>statement.sql.includes("commit_guard"))) {
|
||||
injected = true;
|
||||
await callMcpTool(context,auth,"update_poster",{memoId:created.memo.id,expectedRevision:created.memo.revision,background:"#aabbcc"});
|
||||
}
|
||||
return originalBatch(statements);
|
||||
};
|
||||
const app = new Hono();
|
||||
app.use("*", async(c,next)=>{c.set("auth",{...auth,kind:"user",actorType:"user"});await next();});
|
||||
registerPosterRenderRoutes(app);
|
||||
const response = await app.request(`/api/v1/poster-render/${job.id}/complete`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({claimToken:claim.claim_token,document:{...source,previewResourceId:"preview"}})},context.env);
|
||||
expect(response.status).toBe(409);
|
||||
const saved = await callMcpTool(context,auth,"get_poster",{memoId:created.memo.id});
|
||||
expect(saved.poster.background).toBe("#aabbcc");
|
||||
expect(saved.poster.previewResourceId).toBeUndefined();
|
||||
expect(saved.memo.revision).toBe(created.memo.revision+1);
|
||||
expect((await callMcpTool(context,auth,"get_poster_render_job",{jobId:job.id})).status).toBe("rendering");
|
||||
});
|
||||
|
||||
test("poster design operations validate image ownership before dry-run or save", async () => {
|
||||
const { sqlite, auth, context } = createFixture();
|
||||
sqlite.query("INSERT INTO notebooks(id, workspace_id, name) VALUES ('nb_p', 'ws_mcp', 'Posters')").run();
|
||||
const created = await callMcpTool(context, auth, "create_poster_memo", { notebookId: "nb_p", title: "Poster", heading: "Title" });
|
||||
const other = await callMcpTool(context, auth, "create_poster_memo", { notebookId: "nb_p", title: "Other", heading: "Other" });
|
||||
for (const [id, memoId, kind] of [["owned", created.memo.id, "image"], ["foreign", other.memo.id, "image"], ["attachment", created.memo.id, "attachment"]]) {
|
||||
sqlite.query("INSERT INTO resources(id,memo_id,object_key,kind,mime_type,byte_size) VALUES (?, ?, ?, ?, 'image/png', 1)").run(id, memoId, id, kind);
|
||||
}
|
||||
const operation = resourceId => ({ op: "add", element: { id: "image", name: "Image", type: "image", resourceId, x: 0, y: 0, width: 200, height: 100 } });
|
||||
const input = { memoId: created.memo.id, expectedRevision: created.memo.revision, operations: [operation("owned")] };
|
||||
for (const id of ["foreign", "attachment", "missing"]) {
|
||||
await expect(callMcpTool(context, auth, "update_poster", { ...input, dryRun: true, operations: [operation(id)] })).rejects.toMatchObject({ code: "invalid_params" });
|
||||
}
|
||||
const dry = await callMcpTool(context, auth, "update_poster", { ...input, dryRun: true });
|
||||
expect(dry.poster.elements.some(element => element.id === "image")).toBe(true);
|
||||
expect((await callMcpTool(context, auth, "get_poster", { memoId: created.memo.id })).poster.elements.some(element => element.id === "image")).toBe(false);
|
||||
const saved = await callMcpTool(context, auth, "update_poster", input);
|
||||
expect(saved.poster.elements.find(element => element.id === "image").resourceId).toBe("owned");
|
||||
await expect(callMcpTool(context, auth, "update_poster", input)).rejects.toMatchObject({ code: "revision_conflict" });
|
||||
});
|
||||
|
||||
test("poster preview/export returns actual owned image bytes and never a stale preview", async () => {
|
||||
const { sqlite, auth, context } = createFixture();
|
||||
sqlite.query("INSERT INTO notebooks(id, workspace_id, name) VALUES ('nb_p', 'ws_mcp', 'Posters')").run();
|
||||
const poster = { ...createPosterDocument("Original"), previewResourceId: "preview" };
|
||||
const created = await callMcpTool(context, auth, "create_memo", { notebookId: "nb_p", title: "Poster", contentMarkdown: serializePosterDocument(poster) });
|
||||
sqlite.query("INSERT INTO resources(id,memo_id,object_key,kind,mime_type,byte_size) VALUES ('preview', ?, 'preview', 'image', 'image/png', 3)").run(created.memo.id);
|
||||
let reads = 0;
|
||||
context.env.storage.resources.get = async () => { reads++; return { size: 3, body: new Blob([new Uint8Array([1, 2, 3])]).stream(), writeHttpMetadata() {} }; };
|
||||
const fullAuth = { ...auth, scopes: [...auth.scopes, "read:resources"] };
|
||||
await expect(callMcpTool(context, { ...auth, scopes: ["read:memos"] }, "get_poster_preview", { memoId: created.memo.id })).rejects.toMatchObject({ code: "forbidden" });
|
||||
expect(reads).toBe(0);
|
||||
const preview = await callMcpTool(context, fullAuth, "get_poster_preview", { memoId: created.memo.id });
|
||||
expect(preview).toMatchObject({ available: true, image: { data: "AQID", mimeType: "image/png" } });
|
||||
const source = await callMcpTool(context, { ...auth, scopes: ["read:memos"] }, "export_poster", { memoId: created.memo.id, format: "source" });
|
||||
expect(JSON.parse(source.content).elements).toEqual(poster.elements);
|
||||
const updated = await callMcpTool(context, auth, "update_poster", { memoId: created.memo.id, expectedRevision: created.memo.revision, edits: [{ id: "heading", patch: { text: "Updated" } }] });
|
||||
expect(updated.poster.previewResourceId).toBeUndefined();
|
||||
expect(await callMcpTool(context, fullAuth, "get_poster_preview", { memoId: created.memo.id })).toMatchObject({ available: false, reason: "browser_render_required" });
|
||||
await expect(callMcpTool(context, fullAuth, "export_poster", { memoId: created.memo.id, format: "saved_preview" })).rejects.toMatchObject({ code: "preview_unavailable" });
|
||||
expect(reads).toBe(1);
|
||||
expect((await callMcpTool(context, auth, "get_poster_capabilities", {})).operations).toContain("add");
|
||||
});
|
||||
test("poster queries filter before pagination and include existing posters without matching prose titles", async () => {
|
||||
const { sqlite, auth, context } = createFixture();
|
||||
sqlite.query("INSERT INTO notebooks (id, workspace_id, name) VALUES (?, ?, ?)").run("nb_posters", "ws_mcp", "Posters");
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { z } from "zod";
|
||||
import { PosterElementSchema, POSTER_TEMPLATES } from "@edgeever/shared";
|
||||
import { posterPatchSchema } from "./mcp-poster-tools";
|
||||
|
||||
// MCP and OpenAI-style function tools share the same field validation. Convert
|
||||
// nullable properties to JSON Schema's type array; avoid unsupported anyOf.
|
||||
function toolSchema(value: unknown): Record<string, any> {
|
||||
const schema = structuredClone(value) as Record<string, any>;
|
||||
delete schema.$schema;
|
||||
if (schema.anyOf?.length === 2) {
|
||||
const real = schema.anyOf.find((entry: Record<string, unknown>) => entry.type !== "null");
|
||||
const nil = schema.anyOf.find((entry: Record<string, unknown>) => entry.type === "null");
|
||||
if (real && nil) {
|
||||
delete schema.anyOf;
|
||||
Object.assign(schema, real, { type: [real.type, "null"] });
|
||||
}
|
||||
}
|
||||
for (const [key, child] of Object.entries(schema)) {
|
||||
if (Array.isArray(child)) schema[key] = child.map(entry => entry && typeof entry === "object" ? toolSchema(entry) : entry);
|
||||
else if (child && typeof child === "object") schema[key] = toolSchema(child);
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
export const posterPatchInputSchema = toolSchema(z.toJSONSchema(posterPatchSchema));
|
||||
const variants = PosterElementSchema.options.map(schema => toolSchema(z.toJSONSchema(schema)));
|
||||
export const posterElementInputSchema = {
|
||||
type: "object", additionalProperties: false,
|
||||
required: ["id", "name", "type", "x", "y", "width", "height"],
|
||||
properties: {
|
||||
...Object.assign({}, ...variants.map(schema => schema.properties)),
|
||||
type: { type: "string", enum: ["text", "rect", "ellipse", "path", "image"], description: "text additionally requires text, fill, fontSize, fontFamily, fontWeight and textAlign; rect/ellipse require fill; path requires fill and path; image requires resourceId from this memo. No arbitrary URLs or unknown type-specific properties." },
|
||||
},
|
||||
};
|
||||
export const posterOperationsInputSchema = {
|
||||
type: "array", minItems: 1, maxItems: 100,
|
||||
items: {
|
||||
type: "object", required: ["op"], additionalProperties: false,
|
||||
properties: {
|
||||
op: { type: "string", enum: ["add", "remove", "duplicate", "reorder", "group", "ungroup", "align", "lock", "apply_template"] },
|
||||
element: posterElementInputSchema,
|
||||
ids: { type: "array", minItems: 1, maxItems: 100, uniqueItems: true, items: { type: "string", minLength: 1 } },
|
||||
dx: { type: "number", minimum: -8192, maximum: 8192 }, dy: { type: "number", minimum: -8192, maximum: 8192 },
|
||||
position: { type: "string", enum: ["front", "back", "forward", "backward"] },
|
||||
alignment: { type: "string", enum: ["left", "center", "right", "top", "middle", "bottom"] },
|
||||
locked: { type: "boolean" }, template: { type: "string", enum: [...POSTER_TEMPLATES] },
|
||||
heading: { type: "string", minLength: 1, maxLength: 80 }, subtitle: { type: "string", maxLength: 240 },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,72 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createPosterDocument, POSTER_FORMATS, POSTER_TEMPLATES, parsePosterDocument, serializePosterDocument } from "@edgeever/shared";
|
||||
import { applyPosterEdits, compilePosterMemo } from "./mcp-poster-tools";
|
||||
import { applyPosterEdits, compilePosterMemo, posterCapabilities } from "./mcp-poster-tools";
|
||||
|
||||
test("detailed poster edits preserve unaffected elements and clear optional styles", () => {
|
||||
const poster = { ...createPosterDocument("Original"), previewResourceId: "preview" };
|
||||
const heading = poster.elements.find(element => element.type === "text");
|
||||
const input = { memoId: "memo", expectedRevision: 1, edits: [{ id: heading.id, patch: { fontFamily: "思源黑体", fontStyle: "italic", underline: true, lineHeight: 1.6, charSpacing: 40, stroke: "#16A06E", strokeWidth: 2, shadow: { color: "#07130B", blur: 8, offsetX: 2, offsetY: 3 }, visible: false } }] };
|
||||
const result = applyPosterEdits(input, poster);
|
||||
expect(result.document.elements.find(element => element.id === heading.id)).toMatchObject(input.edits[0].patch);
|
||||
expect(result.document.elements.filter(element => element.id !== heading.id)).toEqual(poster.elements.filter(element => element.id !== heading.id));
|
||||
expect(result.document.previewResourceId).toBeUndefined();
|
||||
const cleared = applyPosterEdits({ memoId: "memo", expectedRevision: 2, edits: [{ id: heading.id, patch: { shadow: null } }] }, result.document);
|
||||
expect(cleared.document.elements.find(element => element.id === heading.id).shadow).toBeUndefined();
|
||||
expect(poster.elements.find(element => element.id === heading.id).shadow).toBeUndefined();
|
||||
});
|
||||
|
||||
test("poster operations add, group, duplicate, reorder, align and remove transactionally", () => {
|
||||
const poster = createPosterDocument("Original");
|
||||
const operations = [
|
||||
{ op: "add", element: { id: "shape", name: "Shape", type: "rect", x: 20, y: 20, width: 100, height: 80, fill: "#16A06E" } },
|
||||
{ op: "group", ids: ["heading", "shape"] },
|
||||
{ op: "duplicate", ids: ["heading", "shape"], dx: 30, dy: 40 },
|
||||
{ op: "reorder", ids: ["shape"], position: "back" },
|
||||
{ op: "align", ids: ["shape"], alignment: "center" },
|
||||
{ op: "remove", ids: ["subtitle"] },
|
||||
];
|
||||
const result = applyPosterEdits({ memoId: "memo", expectedRevision: 1, operations }, poster);
|
||||
expect(result.document.elements).toHaveLength(5);
|
||||
expect(result.document.elements[0]).toMatchObject({ id: "shape", x: 490 });
|
||||
const grouped = result.document.elements.filter(element => element.groupId);
|
||||
expect(grouped).toHaveLength(4);
|
||||
expect(new Set(grouped.map(element => element.groupId)).size).toBe(2);
|
||||
expect(poster.elements).toHaveLength(3);
|
||||
expect(() => applyPosterEdits({ memoId: "memo", expectedRevision: 1, operations: [...operations, { op: "remove", ids: ["missing"] }] }, poster)).toThrow();
|
||||
expect(poster.elements).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("locked objects, invalid types, bad crops and limits cannot be bypassed", () => {
|
||||
const poster = createPosterDocument("Original");
|
||||
poster.elements[1].locked = true;
|
||||
const run = fields => applyPosterEdits({ memoId: "memo", expectedRevision: 1, ...fields }, poster);
|
||||
expect(() => run({ operations: [{ op: "remove", ids: ["heading"] }] })).toThrow("locked");
|
||||
expect(() => run({ canvas: { width: 800, height: 800 } })).toThrow("locked");
|
||||
expect(() => run({ operations: [{ op: "apply_template", template: "bold" }] })).toThrow("locked");
|
||||
expect(() => run({ edits: [{ id: "accent", patch: { fontFamily: "Inter" } }] })).toThrow("Typography");
|
||||
expect(() => run({ edits: [{ id: "accent", patch: { resourceId: "foreign" } }] })).toThrow("Image edits");
|
||||
expect(() => run({ operations: [{ op: "add", element: { id: "accent", name: "Duplicate", type: "rect", x: 0, y: 0, width: 10, height: 10, fill: "#07130B" } }] })).toThrow("Duplicate");
|
||||
expect(() => run({ operations: [{ op: "add", element: { id: "img", name: "Image", type: "image", resourceId: "res", x: 0, y: 0, width: 10, height: 10, crop: { x: 0.8, y: 0, width: 0.4, height: 1 } } }] })).toThrow("crop");
|
||||
const unlocked = run({ operations: [{ op: "lock", ids: ["heading"], locked: false }] });
|
||||
expect(unlocked.document.elements[1].locked).toBe(false);
|
||||
expect(() => applyPosterEdits({ memoId: "memo", expectedRevision: 2, operations: [{ op: "add", element: { id: "extra", name: "Extra", type: "rect", x: 0, y: 0, width: 10, height: 10, fill: "#07130B" } }] }, { ...poster, elements: Array.from({ length: 500 }, (_, i) => ({ ...poster.elements[0], id: `e_${i}` })) })).toThrow("limits");
|
||||
});
|
||||
|
||||
test("canvas/platform changes scale existing geometry and template replacement is explicit", () => {
|
||||
const poster = createPosterDocument("Original", "Subtitle");
|
||||
const scaled = applyPosterEdits({ memoId: "memo", expectedRevision: 1, canvas: { width: 540, height: 720 } }, poster);
|
||||
expect(scaled.document.elements[0].width).toBe(poster.elements[0].width / 2);
|
||||
const fixed = applyPosterEdits({ memoId: "memo", expectedRevision: 1, canvas: { width: 540, height: 720, scaleElements: false } }, poster);
|
||||
expect(fixed.document.elements).toEqual(poster.elements);
|
||||
const platform = applyPosterEdits({ memoId: "memo", expectedRevision: 1, platform: "youtube" }, poster);
|
||||
expect(platform.document).toMatchObject({ width: 1280, height: 720, platformId: "youtube" });
|
||||
expect(() => applyPosterEdits({ memoId: "memo", expectedRevision: 1, platform: "wrong" }, poster)).toThrow("Unknown platform");
|
||||
const template = applyPosterEdits({ memoId: "memo", expectedRevision: 1, operations: [{ op: "apply_template", template: "bold" }] }, poster);
|
||||
expect(template.document).toMatchObject({ templateId: "bold", background: "#07130b", copy: { title: "Original", subtitle: "Subtitle" } });
|
||||
const caps = posterCapabilities();
|
||||
expect(caps.fonts.some(font => font.family === "思源黑体")).toBe(true);
|
||||
expect(caps.operations).toContain("duplicate");
|
||||
});
|
||||
|
||||
test("poster creation round-trips every template and format with long CJK copy", () => {
|
||||
for (const template of POSTER_TEMPLATES) for (const format of POSTER_FORMATS) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { createPosterDocument, POSTER_FORMATS, POSTER_TEMPLATES, PosterDocumentSchema, PosterGradientSchema, type PosterDocument } from "@edgeever/shared";
|
||||
import { createPosterDocument, POSTER_FORMATS, POSTER_TEMPLATES, PosterDocumentSchema, PosterGradientSchema, PosterElementSchema, PosterPaintGradientSchema, resizePosterDocument, PLATFORMS, POSTER_FONTS, POSTER_DESIGN_TEMPLATES, type PosterDocument } from "@edgeever/shared";
|
||||
import { AppError } from "./app-error";
|
||||
|
||||
const creation = z.object({
|
||||
@@ -37,7 +37,26 @@ export function compilePosterMemo(input: unknown) {
|
||||
return { args, document: PosterDocumentSchema.parse(document) };
|
||||
}
|
||||
|
||||
const patch = z.object({
|
||||
export const posterPatchSchema = z.object({
|
||||
name: z.string().max(120).optional(),
|
||||
role: z.enum(["title", "subtitle", "badge", "decor"]).optional(),
|
||||
visible: z.boolean().optional(),
|
||||
flipX: z.boolean().optional(),
|
||||
flipY: z.boolean().optional(),
|
||||
stroke: z.string().max(80).regex(/^(?:#[0-9a-f]{3,8}|rgba?\([\d.,\s]+\)|transparent)$/i).optional(),
|
||||
strokeWidth: z.number().min(0).max(1024).optional(),
|
||||
shadow: z.object({ color: z.string().max(80).regex(/^(?:#[0-9a-f]{3,8}|rgba?\([\d.,\s]+\)|transparent)$/i), blur: z.number().min(0).max(300), offsetX: z.number().min(-300).max(300), offsetY: z.number().min(-300).max(300) }).strict().nullable().optional(),
|
||||
gradient: PosterPaintGradientSchema.nullable().optional(),
|
||||
fontFamily: z.string().min(1).max(100).regex(/^[\w\s\-\u3400-\u9fff]+$/).optional(),
|
||||
fontStyle: z.enum(["normal", "italic"]).optional(),
|
||||
underline: z.boolean().optional(),
|
||||
lineHeight: z.number().min(0.5).max(3).optional(),
|
||||
charSpacing: z.number().min(-300).max(1000).optional(),
|
||||
badge: z.object({ background: z.string().regex(/^#[0-9a-f]{6}$/i), padX: z.number().min(0).max(3), padY: z.number().min(0).max(3), radius: z.number().min(0).max(1) }).strict().nullable().optional(),
|
||||
radius: z.number().min(0).max(4096).optional(),
|
||||
resourceId: z.string().min(1).max(160).regex(/^[\w-]+$/).optional(),
|
||||
crop: z.object({ x: z.number().min(0).max(1), y: z.number().min(0).max(1), width: z.number().min(0.01).max(1), height: z.number().min(0.01).max(1) }).strict().refine(crop => crop.x + crop.width <= 1 && crop.y + crop.height <= 1).nullable().optional(),
|
||||
path: z.string().min(1).max(100000).regex(/^[MmLlHhVvCcSsQqTtAaZz\d\s,.eE+\-]+$/).optional(),
|
||||
text: z.string().max(8000).optional(),
|
||||
fill: z.string().regex(/^#[0-9a-f]{6}$/i).optional(),
|
||||
fontSize: z.number().finite().min(4).max(8192).optional(),
|
||||
@@ -50,52 +69,156 @@ const patch = z.object({
|
||||
rotation: z.number().finite().min(-360).max(360).optional(),
|
||||
opacity: z.number().finite().min(0).max(1).optional(),
|
||||
}).strict().refine(value => Object.keys(value).length > 0);
|
||||
const patch = posterPatchSchema;
|
||||
const typographyKeys = ["text", "fontSize", "fontFamily", "fontWeight", "fontStyle", "underline", "textAlign", "lineHeight", "charSpacing", "badge"];
|
||||
function fail(message: string): never { throw new AppError("invalid_params", message, 400); }
|
||||
const ids = z.array(z.string().min(1)).min(1).max(100).refine(ids => new Set(ids).size === ids.length);
|
||||
const operation = z.discriminatedUnion("op", [
|
||||
z.object({ op: z.literal("add"), element: z.union([PosterElementSchema.options[0].strict(), PosterElementSchema.options[1].strict(), PosterElementSchema.options[2].strict(), PosterElementSchema.options[3].strict()]) }).strict(),
|
||||
z.object({ op: z.literal("remove"), ids }).strict(),
|
||||
z.object({ op: z.literal("duplicate"), ids, dx: z.number().min(-8192).max(8192).default(24), dy: z.number().min(-8192).max(8192).default(24) }).strict(),
|
||||
z.object({ op: z.literal("reorder"), ids, position: z.enum(["front", "back", "forward", "backward"]) }).strict(),
|
||||
z.object({ op: z.literal("group"), ids }).strict(),
|
||||
z.object({ op: z.literal("ungroup"), ids }).strict(),
|
||||
z.object({ op: z.literal("align"), ids, alignment: z.enum(["left", "center", "right", "top", "middle", "bottom"]) }).strict(),
|
||||
z.object({ op: z.literal("lock"), ids, locked: z.boolean() }).strict(),
|
||||
z.object({ op: z.literal("apply_template"), template: z.enum(POSTER_TEMPLATES), heading: z.string().min(1).max(80).optional(), subtitle: z.string().max(240).optional() }).strict(),
|
||||
]);
|
||||
|
||||
const request = z.object({
|
||||
memoId: z.string().min(1),
|
||||
expectedRevision: z.number().int().nonnegative(),
|
||||
dryRun: z.boolean().optional(),
|
||||
background: z.string().regex(/^#[0-9a-f]{6}$/i).optional(),
|
||||
backgroundGradient: PosterGradientSchema.nullable().optional(),
|
||||
canvas: z.object({ width: z.number().int().min(320).max(4096), height: z.number().int().min(320).max(4096), scaleElements: z.boolean().default(true) }).strict().optional(),
|
||||
platform: z.string().min(1).max(80).optional(),
|
||||
operations: z.array(operation).min(1).max(100).optional(),
|
||||
edits: z.array(z.object({ id: z.string().min(1), patch }).strict()).max(100).optional(),
|
||||
}).strict().refine(value => Boolean(value.edits?.length || value.background || value.backgroundGradient !== undefined));
|
||||
}).strict().refine(value => Boolean(value.edits?.length || value.background || value.backgroundGradient !== undefined || value.canvas || value.platform || value.operations?.length));
|
||||
|
||||
export function applyPosterEdits(input: unknown, current: PosterDocument) {
|
||||
const parsed = request.safeParse(input);
|
||||
if (!parsed.success) throw new AppError("invalid_params", "Invalid poster edits", 400);
|
||||
if (!parsed.success) fail("Invalid poster edits. Read get_poster_capabilities for supported fields and operations.");
|
||||
const args = parsed.data;
|
||||
const edits = args.edits ?? [];
|
||||
if (new Set(edits.map(edit => edit.id)).size !== edits.length) throw new AppError("invalid_params", "Duplicate poster element IDs", 400);
|
||||
for (const edit of edits) {
|
||||
const element = current.elements.find(element => element.id === edit.id);
|
||||
if (!element || element.locked) throw new AppError("invalid_params", "Poster element is missing or locked", 400);
|
||||
if (element.type !== "text" && ["text", "fontSize", "fontWeight", "textAlign"].some(key => key in edit.patch)) {
|
||||
throw new AppError("invalid_params", "Typography edits require a text element", 400);
|
||||
}
|
||||
if (element.type === "image" && edit.patch.fill !== undefined) throw new AppError("invalid_params", "Image elements do not have a fill", 400);
|
||||
}
|
||||
const next = PosterDocumentSchema.safeParse({
|
||||
...current,
|
||||
...(args.background !== undefined ? { background: args.background } : {}),
|
||||
...(args.backgroundGradient !== undefined ? { backgroundGradient: args.backgroundGradient ?? undefined } : {}),
|
||||
elements: current.elements.map(element => {
|
||||
const edit = edits.find(edit => edit.id === element.id);
|
||||
if (!edit) return element;
|
||||
return { ...element, ...edit.patch, ...(edit.patch.text !== undefined ? { sourceText: edit.patch.text } : {}) };
|
||||
}),
|
||||
if (args.canvas && args.platform) fail("Choose canvas or platform, not both.");
|
||||
const document = structuredClone(PosterDocumentSchema.parse(current));
|
||||
const selected = (wanted: string[], allowLocked = false) => wanted.map(id => {
|
||||
const element = document.elements.find(element => element.id === id);
|
||||
if (!element || (element.locked && !allowLocked)) return fail("Poster element is missing or locked");
|
||||
return element;
|
||||
});
|
||||
if (!next.success) throw new AppError("invalid_params", "Poster edits exceed document limits", 400);
|
||||
const document = next.data;
|
||||
if (edits.some(edit => edit.patch.text !== undefined)) {
|
||||
document.copy = {
|
||||
title: current.copy?.title ?? "", subtitle: current.copy?.subtitle ?? "", badge: current.copy?.badge ?? "",
|
||||
};
|
||||
const edits = args.edits ?? [];
|
||||
if (new Set(edits.map(edit => edit.id)).size !== edits.length) fail("Duplicate poster element IDs");
|
||||
for (const edit of edits) {
|
||||
const [element] = selected([edit.id]);
|
||||
if (element.type !== "text" && typographyKeys.some(key => key in edit.patch)) fail("Typography edits require a text element");
|
||||
if (element.type === "image" && (edit.patch.fill !== undefined || edit.patch.gradient !== undefined)) fail("Image elements do not have a fill");
|
||||
if (element.type !== "image" && (edit.patch.resourceId !== undefined || edit.patch.crop !== undefined)) fail("Image edits require an image element");
|
||||
if (element.type !== "rect" && element.type !== "ellipse" && edit.patch.radius !== undefined) fail("Radius edits require a shape");
|
||||
if (element.type !== "path" && edit.patch.path !== undefined) fail("Path edits require a path element");
|
||||
const changes = { ...edit.patch } as Record<string, unknown>;
|
||||
for (const key of ["shadow", "gradient", "crop", "badge"]) if (changes[key] === null) changes[key] = undefined;
|
||||
Object.assign(element, changes, edit.patch.text !== undefined ? { sourceText: edit.patch.text } : {});
|
||||
}
|
||||
for (const op of args.operations ?? []) {
|
||||
if (op.op === "add") {
|
||||
if (document.elements.some(element => element.id === op.element.id)) fail("Duplicate poster element ID");
|
||||
if (op.element.type === "image" && op.element.crop && (op.element.crop.x + op.element.crop.width > 1 || op.element.crop.y + op.element.crop.height > 1)) fail("Image crop exceeds the image");
|
||||
document.elements.push(structuredClone(op.element));
|
||||
continue;
|
||||
}
|
||||
if (op.op === "apply_template") {
|
||||
if (document.elements.some(element => element.locked)) fail("Cannot replace a template with locked elements");
|
||||
const titleElement = document.elements.find(element => element.type === "text" && (element.role === "title" || element.id === "heading"));
|
||||
const title = op.heading ?? document.copy?.title ?? (titleElement?.type === "text" ? titleElement.sourceText ?? titleElement.text : "Poster");
|
||||
const subtitleElement = document.elements.find(element => element.type === "text" && (element.role === "subtitle" || element.id === "subtitle"));
|
||||
const subtitle = op.subtitle ?? document.copy?.subtitle ?? (subtitleElement?.type === "text" ? subtitleElement.sourceText ?? subtitleElement.text : "");
|
||||
const built = compilePosterMemo({ notebookId: "template", title: "Poster", heading: title, subtitle, template: op.template });
|
||||
const resized = resizePosterDocument(built.document, document.width, document.height);
|
||||
Object.assign(document, { elements: resized.elements, copy: resized.copy, templateId: op.template, background: resized.background, backgroundGradient: undefined });
|
||||
continue;
|
||||
}
|
||||
const elements = selected(op.ids, op.op === "lock");
|
||||
if (op.op === "lock") { elements.forEach(element => { element.locked = op.locked; }); continue; }
|
||||
if (op.op === "remove") document.elements = document.elements.filter(element => !op.ids.includes(element.id));
|
||||
if (op.op === "duplicate") {
|
||||
const groups = new Map<string, string>();
|
||||
document.elements.push(...elements.map(element => {
|
||||
if (element.groupId && !groups.has(element.groupId)) groups.set(element.groupId, crypto.randomUUID());
|
||||
return { ...structuredClone(element), id: crypto.randomUUID(), x: element.x + op.dx, y: element.y + op.dy, groupId: element.groupId ? groups.get(element.groupId) : undefined };
|
||||
}));
|
||||
}
|
||||
if (op.op === "group") {
|
||||
const groupId = crypto.randomUUID();
|
||||
elements.forEach(element => { element.groupId = groupId; });
|
||||
}
|
||||
if (op.op === "ungroup") elements.forEach(element => { element.groupId = undefined; });
|
||||
if (op.op === "align") {
|
||||
if (elements.some(element => element.rotation !== 0)) fail("Align requires unrotated elements; set rotation=0 first");
|
||||
for (const element of elements) {
|
||||
if (op.alignment === "left") element.x = 0;
|
||||
if (op.alignment === "center") element.x = (document.width - element.width) / 2;
|
||||
if (op.alignment === "right") element.x = document.width - element.width;
|
||||
if (op.alignment === "top") element.y = 0;
|
||||
if (op.alignment === "middle") element.y = (document.height - element.height) / 2;
|
||||
if (op.alignment === "bottom") element.y = document.height - element.height;
|
||||
}
|
||||
}
|
||||
if (op.op === "reorder") {
|
||||
const picked = document.elements.filter(element => op.ids.includes(element.id));
|
||||
const rest = document.elements.filter(element => !op.ids.includes(element.id));
|
||||
if (op.position === "front") document.elements = [...rest, ...picked];
|
||||
else if (op.position === "back") document.elements = [...picked, ...rest];
|
||||
else {
|
||||
const direction = op.position === "forward" ? 1 : -1;
|
||||
const order = direction === 1 ? [...document.elements].reverse() : [...document.elements];
|
||||
for (const element of order.filter(element => op.ids.includes(element.id))) {
|
||||
const index = document.elements.indexOf(element), target = index + direction;
|
||||
if (target >= 0 && target < document.elements.length && !op.ids.includes(document.elements[target].id)) {
|
||||
[document.elements[index], document.elements[target]] = [document.elements[target], document.elements[index]];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const platform = args.platform ? PLATFORMS.find(platform => platform.id === args.platform) : undefined;
|
||||
if (args.platform && !platform) fail("Unknown platform. Read get_poster_capabilities.");
|
||||
const size = args.canvas ?? (platform ? { width: platform.width, height: platform.height, scaleElements: true } : undefined);
|
||||
if (size && (size.width !== document.width || size.height !== document.height)) {
|
||||
if (document.elements.some(element => element.locked) && size.scaleElements) fail("Cannot scale locked elements");
|
||||
Object.assign(document, size.scaleElements ? resizePosterDocument(document, size.width, size.height) : { width: size.width, height: size.height });
|
||||
document.platformId = platform?.id;
|
||||
}
|
||||
if (platform) document.platformId = platform.id;
|
||||
if (args.background !== undefined) document.background = args.background;
|
||||
if (args.backgroundGradient !== undefined) document.backgroundGradient = args.backgroundGradient ?? undefined;
|
||||
const copyTouched = edits.some(edit => "text" in edit.patch || "role" in edit.patch) || args.operations?.some(op => ["remove", "add", "apply_template"].includes(op.op));
|
||||
if (copyTouched) {
|
||||
document.copy = { title: "", subtitle: "", badge: "" };
|
||||
for (const role of ["title", "subtitle", "badge"] as const) {
|
||||
if (!edits.some(edit => edit.patch.text !== undefined && current.elements.some(element => element.id === edit.id && element.role === role))) continue;
|
||||
document.copy[role] = [...new Set(document.elements.filter(element => element.type === "text" && element.role === role)
|
||||
document.copy[role] = [...new Set(document.elements.filter(element => element.type === "text" && (element.role === role || (role === "title" && element.id === "heading") || (role === "subtitle" && element.id === "subtitle")))
|
||||
.map(element => element.type === "text" ? element.sourceText ?? element.text : ""))].join("\n");
|
||||
}
|
||||
}
|
||||
const changed = JSON.stringify(document) !== JSON.stringify(PosterDocumentSchema.parse(current));
|
||||
if (changed) document.previewResourceId = undefined;
|
||||
return { document, changed };
|
||||
const safe = PosterDocumentSchema.safeParse(document);
|
||||
if (!safe.success) fail("Poster edits exceed document limits");
|
||||
const changed = JSON.stringify(safe.data) !== JSON.stringify(PosterDocumentSchema.parse(current));
|
||||
if (changed) safe.data.previewResourceId = undefined;
|
||||
return { document: safe.data, changed };
|
||||
}
|
||||
|
||||
export function posterCapabilities() {
|
||||
return {
|
||||
templates: POSTER_TEMPLATES.map(id => ({ id, sourceBuilder: "server", imageRenderer: "client" })),
|
||||
browserDesignTemplates: POSTER_DESIGN_TEMPLATES.filter(template => template.available).map(template => ({ ...template, renderer: "browser", instruction: "Use request_poster_render with templateId and expectedRevision. This ID is not a server apply_template ID." })),
|
||||
formats: POSTER_FORMATS,
|
||||
platforms: PLATFORMS,
|
||||
fonts: POSTER_FONTS.map(({ family, zh, en }) => ({ family, zh, en })),
|
||||
operations: ["add", "remove", "duplicate", "reorder", "group", "ungroup", "align", "lock", "apply_template"],
|
||||
editableFields: Object.keys(patch.shape),
|
||||
limits: { maxElements: 500, maxOperations: 100, canvasMin: 320, canvasMax: 4096 },
|
||||
rendering: { image: "browser", preview: "client_render_queue", export: ["source", "saved_preview"], instruction: "Request fresh PNG rendering or full design templates with request_poster_render. An online desktop/Web workspace handles the job; no poster editor mount is needed. Query get_poster_render_job, then get_poster_preview. Never call a pending or failed job rendered." },
|
||||
instruction: "Read get_poster before editing. Edits run first, then operations in order, then canvas/platform resize. Array order is back to front. Unlock in an earlier call before field edits; ordered operations can explicitly unlock before a later operation. apply_template replaces all elements; use only when requested. Image resource IDs must belong to this poster. dryRun does not save changes.",
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,15 @@ const createApp = (overrides = {}) => {
|
||||
return app;
|
||||
};
|
||||
|
||||
test("poster previews emit MCP image content without duplicating base64 in metadata", async () => {
|
||||
const app = createApp({ callTool: async () => ({ available: true, revision: 1, image: { data: "AQID", mimeType: "image/png" } }) });
|
||||
const response = await app.request("/mcp", mcpRequest({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "get_poster_preview", arguments: { memoId: "poster" } } }, { "MCP-Protocol-Version": "2025-11-25" }));
|
||||
const result = (await response.json()).result;
|
||||
expect(result.content[1]).toEqual({ type: "image", data: "AQID", mimeType: "image/png" });
|
||||
expect(result.content[0].text).not.toContain("AQID");
|
||||
expect(result.structuredContent.image).toBeUndefined();
|
||||
});
|
||||
|
||||
const mcpRequest = (payload, headers = {}) => ({
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type JsonRpcRequest,
|
||||
} from "./mcp-json-rpc";
|
||||
import { MCP_TOOLS } from "./mcp-tools";
|
||||
import { posterToolImage } from "./poster-tool-output";
|
||||
|
||||
const MODERN_MCP_PROTOCOL_VERSION = "2026-07-28" as const;
|
||||
const LEGACY_MCP_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"] as const;
|
||||
@@ -245,9 +246,13 @@ export const handleMcpMessage = async (
|
||||
|
||||
try {
|
||||
const result = await dependencies.callTool(context, auth, name, asRecord(params.arguments));
|
||||
const visual = posterToolImage(name, result);
|
||||
const toolResult = {
|
||||
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
||||
structuredContent: result,
|
||||
content: [
|
||||
{ type: "text", text: JSON.stringify(visual?.metadata ?? result, null, 2) },
|
||||
...(visual ? [{ type: "image", ...visual.image }] : []),
|
||||
],
|
||||
structuredContent: visual?.metadata ?? result,
|
||||
isError: false,
|
||||
};
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { applyPosterEdits, compilePosterMemo } from "./mcp-poster-tools";
|
||||
import { requestPosterRender, readPosterRenderJob, posterRenderJob } from "./poster-render-service";
|
||||
import { applyPosterEdits, compilePosterMemo, posterCapabilities } from "./mcp-poster-tools";
|
||||
import { resolveObjectStorage } from "./object-storage";
|
||||
import { hasPosterDocumentMarker, parsePosterDocument, serializePosterDocument } from "@edgeever/shared";
|
||||
import {
|
||||
AiPromptTemplateCreateSchema,
|
||||
@@ -42,6 +44,7 @@ import { isDemoModeEnabled } from "./demo-mode";
|
||||
import { createId, isoNow } from "./entity-utils";
|
||||
import {
|
||||
decodeBase64Data,
|
||||
encodeBase64Data,
|
||||
escapeMarkdownImageAlt,
|
||||
escapeMarkdownLinkLabel,
|
||||
getOptionalString,
|
||||
@@ -83,6 +86,8 @@ import {
|
||||
import {
|
||||
createAttachmentResource,
|
||||
createImageResource,
|
||||
getResourceRow,
|
||||
mapResource,
|
||||
inferImageExtension,
|
||||
listResourcesForMcp,
|
||||
listResourcesForMemo,
|
||||
@@ -523,6 +528,53 @@ export const callMcpTool = async (
|
||||
}, getAuditActor(c), getActorLabel(c));
|
||||
return { memo: { id: memo.id, title: memo.title, notebookId: memo.notebookId, revision: memo.revision }, poster: true, template: input.template, format: input.format };
|
||||
}
|
||||
case "request_poster_render": {
|
||||
assertScope(auth, "write:memos");
|
||||
assertScope(auth, "write:resources");
|
||||
return requestPosterRender(c.env.storage.db, auth.workspaceId, getRequiredString(args.memoId, "memoId"), args.expectedRevision as number, getOptionalString(args.templateId) ?? undefined);
|
||||
}
|
||||
case "get_poster_render_job": {
|
||||
assertScope(auth, "read:memos");
|
||||
const row = await readPosterRenderJob(c.env.storage.db, auth.workspaceId, getRequiredString(args.jobId, "jobId"));
|
||||
if (!row) throw new AppError("not_found", "Render task not found", 404);
|
||||
return posterRenderJob(row);
|
||||
}
|
||||
case "get_poster_capabilities": {
|
||||
assertScope(auth, "read:memos");
|
||||
return posterCapabilities();
|
||||
}
|
||||
case "get_poster_preview":
|
||||
case "export_poster": {
|
||||
assertScope(auth, "read:memos");
|
||||
const memo = await getMemoDetail(c.env.storage.db, auth.workspaceId, getRequiredString(args.memoId, "memoId"));
|
||||
if (!memo) throw new AppError("not_found", "Memo not found", 404);
|
||||
const poster = parsePosterDocument(memo.contentMarkdown);
|
||||
if (!poster) throw new AppError("not_poster", "Memo is not an editable poster", 400);
|
||||
if (name === "export_poster" && args.format === "source") {
|
||||
return { memoId: memo.id, revision: memo.revision, filename: `${(memo.title || "poster").replace(/[\\/:*?"<>|]/g, "_")}.json`, mimeType: "application/json", content: JSON.stringify(poster, null, 2) };
|
||||
}
|
||||
if (name === "export_poster" && args.format !== "saved_preview") throw new AppError("invalid_params", "Choose source or saved_preview", 400);
|
||||
assertScope(auth, "read:resources");
|
||||
const row = poster.previewResourceId ? await getResourceRow(c.env.storage.db, auth.workspaceId, poster.previewResourceId) : null;
|
||||
if (!row || row.memo_id !== memo.id || row.kind !== "image") {
|
||||
const unavailable = { available: false, memoId: memo.id, revision: memo.revision, reason: "browser_render_required", message: "No current rendered preview. Call request_poster_render with this revision and wait for an online desktop/Web workspace, or open and save the poster editor. Never present source or an old image as a current preview." };
|
||||
if (name === "export_poster") throw new AppError("preview_unavailable", unavailable.message, 409);
|
||||
return unavailable;
|
||||
}
|
||||
if (!["image/png", "image/jpeg", "image/webp"].includes(row.mime_type ?? "")) throw new AppError("preview_unavailable", "Preview is not a supported rendered image", 409);
|
||||
const maxPreviewBytes = 4 * 1024 * 1024;
|
||||
if (row.byte_size > maxPreviewBytes) throw new AppError("preview_too_large", "Preview exceeds 4 MiB. Export through the browser editor.", 413);
|
||||
const storage = await resolveObjectStorage(c.env, row.storage_config_id);
|
||||
const object = await storage.store.get(row.object_key);
|
||||
if (!object) throw new AppError("preview_unavailable", "The preview image file is missing", 409);
|
||||
if (object.size > maxPreviewBytes) {
|
||||
await object.body.cancel();
|
||||
throw new AppError("preview_too_large", "Preview exceeds 4 MiB. Export through the browser editor.", 413);
|
||||
}
|
||||
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
|
||||
if (!bytes.length || bytes.length > maxPreviewBytes) throw new AppError("preview_unavailable", "Invalid preview image size", 409);
|
||||
return { available: true, memoId: memo.id, revision: memo.revision, resource: mapResource(row), renderer: "browser", image: { data: encodeBase64Data(bytes), mimeType: row.mime_type! } };
|
||||
}
|
||||
case "get_poster": {
|
||||
assertScope(auth, "read:memos");
|
||||
const memo = await getMemoDetail(c.env.storage.db, auth.workspaceId, getRequiredString(args.memoId, "memoId"));
|
||||
@@ -540,6 +592,14 @@ export const callMcpTool = async (
|
||||
const current = parsePosterDocument(memo.contentMarkdown);
|
||||
if (!current) throw new AppError("not_poster", "Memo is not an editable poster", 400);
|
||||
const mutation = applyPosterEdits(args, current);
|
||||
const images = mutation.document.elements.filter(element => element.type === "image");
|
||||
if (images.length) {
|
||||
const owned = new Set((await listResourcesForMemo(c.env.storage.db, auth.workspaceId, memo.id))
|
||||
.filter(resource => resource.kind === "image").map(resource => resource.id));
|
||||
if (images.some(image => !owned.has(image.resourceId))) {
|
||||
throw new AppError("invalid_params", "Poster images must be active image resources belonging to this memo", 400);
|
||||
}
|
||||
}
|
||||
if (args.dryRun === true || !mutation.changed) return { dryRun: args.dryRun === true, memo, poster: mutation.document, changed: mutation.changed };
|
||||
const result = await updateMemoRecord(c.env.storage.db, auth.workspaceId, memoId, {
|
||||
expectedRevision: args.expectedRevision, contentMarkdown: serializePosterDocument(mutation.document),
|
||||
|
||||
+38
-10
@@ -1,3 +1,4 @@
|
||||
import { posterPatchInputSchema, posterOperationsInputSchema } from "./mcp-poster-schema";
|
||||
import { ARCHITECTURE_RESOURCE_ICONS, INFOGRAPHIC_NOTE_TEMPLATES, POSTER_FORMATS, POSTER_TEMPLATES, TABLE_FIELD_TYPES } from "@edgeever/shared";
|
||||
|
||||
const DIAGRAM_IR_NODE_TYPES = [
|
||||
@@ -379,6 +380,31 @@ const MCP_TOOL_DEFINITIONS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "request_poster_render",
|
||||
description: "Queue a fresh PNG preview on an online desktop/Web client. Read get_poster and pass expectedRevision. Optional templateId applies a full browser design template, preserving additional decorations; use only for an explicit template change. Requires write:memos and write:resources. Returns a job, not an image. No online client means pending; query get_poster_render_job and only claim rendered after completed, then get_poster_preview. Pending expires after 10 minutes; rendering after 2 minutes. Do not busy-poll.",
|
||||
inputSchema: { type: "object", required: ["memoId", "expectedRevision"], additionalProperties: false, properties: { memoId: { type: "string", minLength: 1 }, expectedRevision: { type: "integer", minimum: 0 }, templateId: { type: "string", description: "ID from browserDesignTemplates in get_poster_capabilities" } } },
|
||||
},
|
||||
{
|
||||
name: "get_poster_render_job",
|
||||
description: "Read client render status: pending, rendering, completed or failed. Completion provides resultRevision; read get_poster_preview for the current image. A pending task needs an online desktop/Web workspace. Poll at intervals of at least 5 seconds, never loop rapidly or claim success early.",
|
||||
inputSchema: { type: "object", required: ["jobId"], additionalProperties: false, properties: { jobId: { type: "string", minLength: 1 } } },
|
||||
},
|
||||
{
|
||||
name: "get_poster_capabilities",
|
||||
description: "Discover supported poster templates, fonts, platform dimensions, limits, detailed editing fields, operations and rendering/export availability. Read before designing or guessing parameter values.",
|
||||
inputSchema: { type: "object", additionalProperties: false, properties: {} },
|
||||
},
|
||||
{
|
||||
name: "get_poster_preview",
|
||||
description: "Read the current saved rendered preview of a poster. Returns available=false when it has not been rendered after edits. Never treat source data or an old preview as current. For a fresh image call request_poster_render; an online desktop/Web workspace renders it.",
|
||||
inputSchema: { type: "object", required: ["memoId"], additionalProperties: false, properties: { memoId: { type: "string", minLength: 1 } } },
|
||||
},
|
||||
{
|
||||
name: "export_poster",
|
||||
description: "Export a poster's editable JSON source or its current saved preview image. This does not render a new image; saved_preview fails clearly when no current browser-rendered preview exists. Return the source as a .json file, not as a raster image. Image URLs require normal authenticated resource access.",
|
||||
inputSchema: { type: "object", required: ["memoId", "format"], additionalProperties: false, properties: { memoId: { type: "string", minLength: 1 }, format: { type: "string", enum: ["source", "saved_preview"] } } },
|
||||
},
|
||||
{
|
||||
name: "get_poster",
|
||||
description: "Read an editable poster's canvas, element IDs, text, colors and geometry. Read before update_poster; never decode or replace poster Markdown.",
|
||||
@@ -386,7 +412,7 @@ const MCP_TOOL_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
name: "update_poster",
|
||||
description: "Edit an existing poster's text, colors, typography and element positions. Preserve unmentioned elements and locked elements. Read get_poster first and pass its expectedRevision. Use dryRun to preview. After changes the editor regenerates the preview image.",
|
||||
description: "Edit an existing poster: typography, image crop, shapes, strokes, shadows, visibility, elements, layers, groups, canvas and platform sizes. Read get_poster_capabilities for supported fields and operation arguments. Edits run first, operations next in order, resize last. Use operations lock=false in a separate call before editing locked elements. apply_template replaces all elements and is only for an explicit template change. Image resource IDs must belong to this poster. Preserve unmentioned elements and locked elements. Read get_poster first and pass its expectedRevision. Use dryRun to validate without saving. Changes invalidate the saved preview; call request_poster_render to have an online desktop/Web client render a fresh one.",
|
||||
inputSchema: {
|
||||
type: "object", required: ["memoId", "expectedRevision"], additionalProperties: false,
|
||||
properties: {
|
||||
@@ -395,17 +421,15 @@ const MCP_TOOL_DEFINITIONS = [
|
||||
backgroundGradient: { type: ["object", "null"], additionalProperties: false, required: ["kind", "from", "to"], properties: {
|
||||
kind: { type: "string", enum: ["linear", "radial"] }, from: { type: "string", pattern: "^#[0-9a-fA-F]{6}$" }, to: { type: "string", pattern: "^#[0-9a-fA-F]{6}$" }, angle: { type: "number", minimum: -360, maximum: 360 },
|
||||
} },
|
||||
canvas: { type: "object", required: ["width", "height"], additionalProperties: false, properties: {
|
||||
width: { type: "integer", minimum: 320, maximum: 4096 }, height: { type: "integer", minimum: 320, maximum: 4096 }, scaleElements: { type: "boolean" },
|
||||
} },
|
||||
platform: { type: "string", description: "Platform ID from get_poster_capabilities. Resizes the canvas and scales elements. Do not combine with canvas." },
|
||||
operations: posterOperationsInputSchema,
|
||||
edits: { type: "array", maxItems: 100, items: {
|
||||
type: "object", required: ["id", "patch"], additionalProperties: false,
|
||||
properties: { id: { type: "string", minLength: 1 }, patch: {
|
||||
type: "object", additionalProperties: false, properties: {
|
||||
text: { type: "string", maxLength: 8000 }, fill: { type: "string", pattern: "^#[0-9a-fA-F]{6}$" },
|
||||
fontSize: { type: "number", minimum: 4, maximum: 8192 }, fontWeight: { type: "string", enum: ["normal", "bold"] }, textAlign: { type: "string", enum: ["left", "center", "right"] },
|
||||
x: { type: "number", minimum: -8192, maximum: 8192 }, y: { type: "number", minimum: -8192, maximum: 8192 },
|
||||
width: { type: "number", minimum: 1, maximum: 8192 }, height: { type: "number", minimum: 1, maximum: 8192 },
|
||||
rotation: { type: "number", minimum: -360, maximum: 360 }, opacity: { type: "number", minimum: 0, maximum: 1 },
|
||||
},
|
||||
} },
|
||||
properties: { id: { type: "string", minLength: 1 }, patch: posterPatchInputSchema,
|
||||
},
|
||||
} },
|
||||
},
|
||||
},
|
||||
@@ -1027,6 +1051,10 @@ const READ_ONLY_MCP_TOOLS = new Set([
|
||||
"get_table_records",
|
||||
"get_diagram",
|
||||
"get_poster",
|
||||
"get_poster_capabilities",
|
||||
"get_poster_render_job",
|
||||
"get_poster_preview",
|
||||
"export_poster",
|
||||
"list_memo_resources",
|
||||
"list_resources",
|
||||
"list_memo_revisions",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { z } from 'zod';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { bodyLimit } from 'hono/body-limit';
|
||||
import type { Hono } from 'hono';
|
||||
import { PosterDocumentSchema, parsePosterDocument, serializePosterDocument } from '@edgeever/shared';
|
||||
import type { AppEnv } from './api-context';
|
||||
import { getWorkspaceId, requireUser, getAuditActor, getActorLabel } from './request-auth';
|
||||
import { apiError } from './http-errors';
|
||||
import { claimPosterRender, readPosterRenderJob, posterRenderJob } from './poster-render-service';
|
||||
import { getMemoDetail, updateMemoRecord } from './memo-service';
|
||||
import { getResourceRow, listResourcesForMemo } from './resource-service';
|
||||
import { isoNow } from './entity-utils';
|
||||
|
||||
export function registerPosterRenderRoutes(app: Hono<AppEnv>) {
|
||||
app.use('/api/v1/poster-render/*', bodyLimit({ maxSize: 1024 * 1024 }));
|
||||
app.use('/api/v1/poster-render/*', async (c, next) => {
|
||||
const denied = requireUser(c); if (denied) return denied;
|
||||
c.header('Cache-Control', 'no-store');
|
||||
await next();
|
||||
});
|
||||
app.post('/api/v1/poster-render/claim', async c => {
|
||||
const row = await claimPosterRender(c.env.storage.db, getWorkspaceId(c));
|
||||
return c.json({ job: row ? { ...posterRenderJob(row), claimToken: row.claim_token } : null });
|
||||
});
|
||||
const completion = z.object({ claimToken: z.string().min(1), document: PosterDocumentSchema.optional(), errorCode: z.enum(['render_failed', 'revision_conflict', 'client_timeout']).optional() }).strict();
|
||||
app.post('/api/v1/poster-render/:id/complete', zValidator('json', completion), async c => {
|
||||
const input = c.req.valid('json'), db = c.env.storage.db, workspace = getWorkspaceId(c);
|
||||
const row = await readPosterRenderJob(db, workspace, c.req.param('id'));
|
||||
if (!row || row.claim_token !== input.claimToken) return apiError(c, 'not_found', 'Render task not found', 404);
|
||||
if (row.status === 'completed') return c.json({ job: posterRenderJob(row) });
|
||||
if (row.status !== 'rendering' || row.expires_at <= isoNow()) return apiError(c, 'render_expired', 'Render lease expired', 409);
|
||||
if (input.errorCode) {
|
||||
await db.prepare("UPDATE poster_render_jobs SET status = 'failed', error_code = ? WHERE id = ? AND workspace_id = ? AND status = 'rendering' AND claim_token = ?").bind(input.errorCode, row.id, workspace, input.claimToken).run();
|
||||
return c.json({ job: posterRenderJob((await readPosterRenderJob(db, workspace, row.id))!) });
|
||||
}
|
||||
const document = input.document;
|
||||
if (!document?.previewResourceId) return apiError(c, 'invalid_params', 'Rendered preview is required', 400);
|
||||
const memo = await getMemoDetail(db, workspace, row.memo_id);
|
||||
const current = memo && parsePosterDocument(memo.contentMarkdown);
|
||||
if (!memo || !current || memo.revision !== row.expected_revision) return apiError(c, 'revision_conflict', 'Poster changed during rendering', 409);
|
||||
if (row.template_id && (document.templateId !== row.template_id || current.elements.some(element => element.locked))) return apiError(c, 'invalid_params', 'Invalid template result', 400);
|
||||
if (!row.template_id && JSON.stringify({ ...current, previewResourceId: undefined }) !== JSON.stringify({ ...document, previewResourceId: undefined })) return apiError(c, 'invalid_params', 'Render must preserve the source', 400);
|
||||
const preview = await getResourceRow(db, workspace, document.previewResourceId);
|
||||
if (!preview || preview.memo_id !== memo.id || preview.kind !== 'image' || preview.mime_type !== 'image/png' || preview.byte_size > 4 * 1024 * 1024) return apiError(c, 'invalid_params', 'Use a PNG preview belonging to this poster, at most 4 MiB', 400);
|
||||
const owned = new Set((await listResourcesForMemo(db, workspace, memo.id)).filter(resource => resource.kind === 'image').map(resource => resource.id));
|
||||
if (document.elements.some(element => element.type === 'image' && !owned.has(element.resourceId))) return apiError(c, 'invalid_params', 'Image resources must belong to this poster', 400);
|
||||
// CHECK failure aborts the entire memo batch if either the lease or source
|
||||
// changed between validation and commit. No read-then-write race window.
|
||||
try {
|
||||
const result = await updateMemoRecord(db, workspace, memo.id, { expectedRevision: row.expected_revision, contentMarkdown: serializePosterDocument(document) }, getAuditActor(c), getActorLabel(c), false, {
|
||||
before: [db.prepare(`UPDATE poster_render_jobs SET commit_guard = CASE WHEN status = 'rendering' AND claim_token = ? AND expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
AND EXISTS (SELECT 1 FROM memo_contents mc JOIN memos m ON m.id = mc.memo_id WHERE mc.memo_id = ? AND mc.revision = ? AND m.is_deleted = 0 AND m.workspace_id = ?) THEN 1 ELSE 0 END
|
||||
WHERE id = ? AND workspace_id = ?`).bind(input.claimToken, memo.id, row.expected_revision, workspace, row.id, workspace)],
|
||||
after: () => [db.prepare("UPDATE poster_render_jobs SET status = 'completed', result_revision = (SELECT revision FROM memo_contents WHERE memo_id = ?) WHERE id = ? AND workspace_id = ?").bind(memo.id, row.id, workspace)],
|
||||
});
|
||||
if (result.error) return apiError(c, result.error, result.message, 409);
|
||||
} catch (error) {
|
||||
if (String(error).includes('commit_guard')) return apiError(c, 'revision_conflict', 'Render lease or poster changed', 409);
|
||||
throw error;
|
||||
}
|
||||
return c.json({ job: posterRenderJob((await readPosterRenderJob(db, workspace, row.id))!) });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { parsePosterDocument, POSTER_DESIGN_TEMPLATES } from '@edgeever/shared';
|
||||
import type { DatabaseAdapter } from './storage-contract';
|
||||
import { AppError } from './app-error';
|
||||
import { createId, isoNow } from './entity-utils';
|
||||
import { getMemoDetail } from './memo-service';
|
||||
|
||||
export type PosterRenderJobRow = {
|
||||
id: string; workspace_id: string; memo_id: string; expected_revision: number;
|
||||
template_id: string | null; status: 'pending' | 'rendering' | 'completed' | 'failed';
|
||||
claim_token: string | null; expires_at: string; result_revision: number | null;
|
||||
error_code: string | null; created_at: string;
|
||||
};
|
||||
export const posterRenderJob = (row: PosterRenderJobRow) => ({
|
||||
id: row.id, memoId: row.memo_id, expectedRevision: row.expected_revision,
|
||||
templateId: row.template_id, status: (row.status === 'pending' || row.status === 'rendering') && row.expires_at <= isoNow() ? 'failed' : row.status,
|
||||
resultRevision: row.result_revision, errorCode: row.error_code ?? (row.expires_at <= isoNow() && ['pending', 'rendering'].includes(row.status) ? 'client_timeout' : null),
|
||||
expiresAt: row.expires_at, renderer: 'client',
|
||||
});
|
||||
export const readPosterRenderJob = (db: DatabaseAdapter, workspace: string, id: string) =>
|
||||
db.prepare('SELECT * FROM poster_render_jobs WHERE workspace_id = ? AND id = ?').bind(workspace, id).first<PosterRenderJobRow>();
|
||||
|
||||
export async function requestPosterRender(db: DatabaseAdapter, workspace: string, memoId: string, revision: number, templateId?: string) {
|
||||
if (!Number.isInteger(revision) || revision < 0) throw new AppError('invalid_params', 'expectedRevision is required', 400);
|
||||
if (templateId && !POSTER_DESIGN_TEMPLATES.some(template => template.available && template.id === templateId)) throw new AppError('invalid_params', 'Unknown design template; read get_poster_capabilities', 400);
|
||||
const memo = await getMemoDetail(db, workspace, memoId);
|
||||
if (!memo) throw new AppError('not_found', 'Memo not found', 404);
|
||||
const poster = parsePosterDocument(memo.contentMarkdown);
|
||||
if (!poster) throw new AppError('not_poster', 'Memo is not a poster', 400);
|
||||
if (memo.revision !== revision) throw new AppError('revision_conflict', 'Read the poster again', 409);
|
||||
if (templateId && poster.elements.some(element => element.locked)) throw new AppError('invalid_params', 'Cannot replace a template with locked elements', 400);
|
||||
const now = isoNow();
|
||||
await db.prepare("UPDATE poster_render_jobs SET status = 'failed', error_code = 'client_timeout' WHERE workspace_id = ? AND status IN ('pending','rendering') AND expires_at <= ?").bind(workspace, now).run();
|
||||
await db.prepare("DELETE FROM poster_render_jobs WHERE workspace_id = ? AND status IN ('completed','failed') AND created_at < ?").bind(workspace, new Date(Date.now() - 7 * 86400000).toISOString()).run();
|
||||
const output = (row: PosterRenderJobRow) => {
|
||||
if (row.expected_revision !== revision || row.template_id !== (templateId ?? null)) throw new AppError('render_busy', 'Another render is pending for this poster', 409);
|
||||
return { ...posterRenderJob(row), message: 'Client render task: pending waits for an online desktop/Web workspace; rendering is in progress. Poll get_poster_render_job; only completed means rendered. Then read get_poster_preview.' };
|
||||
};
|
||||
const existing = await db.prepare("SELECT * FROM poster_render_jobs WHERE workspace_id = ? AND memo_id = ? AND status IN ('pending','rendering')").bind(workspace, memoId).first<PosterRenderJobRow>();
|
||||
if (existing) return output(existing);
|
||||
const count = await db.prepare("SELECT COUNT(*) AS n FROM poster_render_jobs WHERE workspace_id = ? AND status IN ('pending','rendering')").bind(workspace).first<{n: number}>();
|
||||
if ((count?.n ?? 0) >= 20) throw new AppError('render_queue_full', 'Too many pending renders', 429);
|
||||
const id = createId('prj');
|
||||
await db.prepare("INSERT OR IGNORE INTO poster_render_jobs(id, workspace_id, memo_id, expected_revision, template_id, status, expires_at, created_at) SELECT ?, ?, ?, ?, ?, 'pending', ?, ? WHERE (SELECT COUNT(*) FROM poster_render_jobs WHERE workspace_id = ? AND status IN ('pending','rendering')) < 20")
|
||||
.bind(id, workspace, memoId, revision, templateId ?? null, new Date(Date.now() + 10 * 60000).toISOString(), now, workspace).run();
|
||||
const row = await db.prepare("SELECT * FROM poster_render_jobs WHERE workspace_id = ? AND memo_id = ? AND status IN ('pending','rendering')").bind(workspace, memoId).first<PosterRenderJobRow>();
|
||||
if (!row) throw new AppError('render_queue_full', 'Too many pending renders', 429);
|
||||
return output(row);
|
||||
}
|
||||
|
||||
export async function claimPosterRender(db: DatabaseAdapter, workspace: string) {
|
||||
const token = createId('claim');
|
||||
const now = isoNow();
|
||||
return db.prepare(`UPDATE poster_render_jobs SET status = 'rendering', claim_token = ?, expires_at = ?
|
||||
WHERE id = (SELECT id FROM poster_render_jobs WHERE workspace_id = ? AND status = 'pending' AND expires_at > ? ORDER BY created_at LIMIT 1)
|
||||
AND status = 'pending' RETURNING *`).bind(token, new Date(Date.now() + 2 * 60000).toISOString(), workspace, now).first<PosterRenderJobRow>();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Only the dedicated read/export tools can emit a visual tool result. The API
|
||||
// has already checked workspace ownership and resource scopes before reading it.
|
||||
export function posterToolImage(name: string, output: unknown) {
|
||||
if (name !== "get_poster_preview" && name !== "export_poster") return null;
|
||||
if (!output || typeof output !== "object") return null;
|
||||
const result = output as Record<string, unknown>;
|
||||
const image = result.image as { data?: unknown; mimeType?: unknown } | undefined;
|
||||
if (result.available !== true || !image || typeof image.data !== "string" || typeof image.mimeType !== "string"
|
||||
|| !["image/png", "image/jpeg", "image/webp"].includes(image.mimeType)) return null;
|
||||
const { image: _image, ...metadata } = result;
|
||||
return { image: { data: image.data, mimeType: image.mimeType }, metadata };
|
||||
}
|
||||
@@ -1003,7 +1003,7 @@ export function createAcpHostRuntime(options = {}) {
|
||||
if (mcpBridge) {
|
||||
content.blocks.splice(input.adapterId === "codex" ? 1 : 0, 0, {
|
||||
type: "text",
|
||||
text: "For EdgeEver note operations in this conversation, use only the session-provided MCP server named edgeever-current-workspace. It is connected to the account currently signed in to EdgeEver. Ignore any EdgeEver MCP server from your persistent configuration, which may target a different instance or account. For a request to generate a poster or 海报笔记, use create_poster_memo with concise copy; it produces the actual editable poster type. Never manually encode poster source, inspect local files to discover the format, or substitute poster-style Markdown. If the tool is unavailable or fails, report that clearly. To query posters, use search_memos or list_memos with poster=true rather than searching the title for 海报. Read and edit a poster with get_poster then update_poster. Delete exact queried IDs with trash_memos; restore them with restore_memos. When you create a note, include a clickable Markdown link to it in your final answer: [note title](#memo=exact_memo_id_from_tool_result). Do not show the note ID as plain text or use an https://edgeever.ai/memo/ URL; that URL opens an external website instead of the current workspace.",
|
||||
text: "For EdgeEver note operations in this conversation, use only the session-provided MCP server named edgeever-current-workspace. It is connected to the account currently signed in to EdgeEver. Ignore any EdgeEver MCP server from your persistent configuration, which may target a different instance or account. For a request to generate a poster or 海报笔记, use create_poster_memo with concise copy; it produces the actual editable poster type. Never manually encode poster source, inspect local files to discover the format, or substitute poster-style Markdown. If the tool is unavailable or fails, report that clearly. To query posters, use search_memos or list_memos with poster=true rather than searching the title for 海报. For detailed design, read get_poster_capabilities for valid fields, fonts, platform sizes and operations. Read and edit a poster with get_poster then update_poster. apply_template replaces the layout and must be requested. get_poster_preview and export_poster provide saved previews and editable source, not fresh rendering. Request new PNG rendering or a full browser template with request_poster_render and expectedRevision; an online desktop/Web client handles it. Query get_poster_render_job no faster than every 5 seconds; only completed means rendered, then get_poster_preview. Pending means waiting for a client; report that honestly. Delete exact queried IDs with trash_memos; restore them with restore_memos. When you create a note, include a clickable Markdown link to it in your final answer: [note title](#memo=exact_memo_id_from_tool_result). Do not show the note ID as plain text or use an https://edgeever.ai/memo/ URL; that URL opens an external website instead of the current workspace.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { startPosterClientRenderer } from "@/lib/poster-client-render";
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
@@ -590,6 +591,14 @@ export const WorkspaceApp = ({
|
||||
]);
|
||||
}, [queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (demoMode || (authRequired && !user) || isLoggingOut) return;
|
||||
return startPosterClientRenderer(async () => {
|
||||
await repository.sync();
|
||||
await invalidateWorkspaceQueries();
|
||||
}, i18n.language.startsWith("zh"));
|
||||
}, [demoMode, authRequired, user?.id, isLoggingOut, repository, invalidateWorkspaceQueries, i18n.language]);
|
||||
|
||||
const refreshWorkspaceFromServer = useCallback(async (mode: WorkspaceRefreshMode) => {
|
||||
if (isBrowserOffline()) {
|
||||
setIsOnline(false);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { parsePosterDocument, resizePosterDocument, type PosterDocument } from '@edgeever/shared';
|
||||
import { api, ApiRequestError } from './api';
|
||||
|
||||
export async function applyClientPosterTemplate(document: PosterDocument, templateId: string, zh: boolean) {
|
||||
const { buildPosterDesign, posterCopy, templatesFor } = await import('./poster-design/templates');
|
||||
if (!templatesFor(undefined).some(template => template.id === templateId)) throw new Error('Unknown poster template');
|
||||
if (document.elements.some(element => element.locked)) throw new Error('Locked poster elements');
|
||||
const built = await buildPosterDesign({ id: templateId, ...posterCopy(document), width: document.width, height: document.height, zh });
|
||||
const extras = document.elements.filter(element => !element.id.startsWith('design_') && !['heading', 'subtitle', 'accent'].includes(element.id));
|
||||
return { ...built, sourceMemoId: document.sourceMemoId, platformId: document.platformId,
|
||||
elements: [...built.elements, ...resizePosterDocument({ ...document, elements: extras }, document.width, document.height).elements] };
|
||||
}
|
||||
|
||||
// One task at a time per workspace window. Database claims serialize all windows
|
||||
// and devices; no editor mount or service-side Fabric runtime is required.
|
||||
type RenderClient = Pick<typeof api, 'claimPosterRender' | 'completePosterRender' | 'getMemo' | 'getResourceBlob' | 'uploadMemoResource' | 'deleteResource'>;
|
||||
export function startPosterClientRenderer(onSaved: () => Promise<void>, zh: boolean, client: RenderClient = api) {
|
||||
let stopped = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const tick = async () => {
|
||||
try {
|
||||
if (navigator.onLine === false || stopped) return;
|
||||
const { job } = await client.claimPosterRender();
|
||||
if (!job) return;
|
||||
let previewId: string | undefined;
|
||||
try {
|
||||
const { memo } = await client.getMemo(job.memoId);
|
||||
if (memo.revision !== job.expectedRevision) throw new ApiRequestError('Poster changed', 409, 'revision_conflict');
|
||||
const source = parsePosterDocument(memo.contentMarkdown);
|
||||
if (!source) throw new Error('Not a poster');
|
||||
if (stopped) throw new Error('Workspace closed');
|
||||
const document = job.templateId ? await applyClientPosterTemplate(source, job.templateId, zh) : source;
|
||||
const { renderPosterBlob } = await import('./poster-canvas');
|
||||
const preview = await renderPosterBlob(document, id => client.getResourceBlob(`/api/v1/resources/${encodeURIComponent(id)}/blob`), 'png', false, Math.min(1, 1600 / Math.max(document.width, document.height)));
|
||||
if (preview.size > 4 * 1024 * 1024) throw new Error('Preview exceeds 4 MiB');
|
||||
if (stopped || Date.now() >= Date.parse(job.expiresAt)) throw new Error('Render lease expired');
|
||||
const { resource } = await client.uploadMemoResource(memo.id, new File([preview], 'poster-preview.png', { type: 'image/png' }));
|
||||
previewId = resource.id;
|
||||
await client.completePosterRender(job.id, { claimToken: job.claimToken, document: { ...document, previewResourceId: previewId } });
|
||||
previewId = undefined;
|
||||
await onSaved();
|
||||
} catch (error) {
|
||||
// A lost completion response may follow a successful commit. Keep any
|
||||
// attachment referenced by the latest source before attempting cleanup.
|
||||
if (previewId) {
|
||||
const latest = await client.getMemo(job.memoId).catch(() => null);
|
||||
if (latest && parsePosterDocument(latest.memo.contentMarkdown)?.previewResourceId !== previewId) await client.deleteResource(previewId).catch(() => undefined);
|
||||
}
|
||||
await client.completePosterRender(job.id, { claimToken: job.claimToken, errorCode: error instanceof ApiRequestError && error.status === 409 ? 'revision_conflict' : 'render_failed' }).catch(() => undefined);
|
||||
}
|
||||
} catch {
|
||||
// Offline/login transitions are retried without presenting a false result.
|
||||
} finally {
|
||||
if (!stopped) timer = setTimeout(() => { void tick(); }, 15000);
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
return () => { stopped = true; if (timer) clearTimeout(timer); };
|
||||
}
|
||||
@@ -1,90 +1,5 @@
|
||||
export const POSTER_FONTS = [
|
||||
{
|
||||
family: "思源宋体",
|
||||
file: "notoserif",
|
||||
zh: "思源宋体 · 正文",
|
||||
en: "Source Han Serif",
|
||||
},
|
||||
{ family: "Anton", file: "anton", zh: "Anton · 冲击", en: "Anton" },
|
||||
{
|
||||
family: "抖音美好体",
|
||||
file: "douyin",
|
||||
zh: "抖音美好体",
|
||||
en: "Douyin Display",
|
||||
},
|
||||
{
|
||||
family: "马善政楷书",
|
||||
file: "mashan",
|
||||
zh: "马善政楷书",
|
||||
en: "Ma Shan Zheng",
|
||||
},
|
||||
{
|
||||
family: "江城圆体",
|
||||
file: "jiangcheng",
|
||||
zh: "江城圆体",
|
||||
en: "Jiangcheng Rounded",
|
||||
},
|
||||
{
|
||||
family: "站酷庆科黄油体",
|
||||
file: "huangyou",
|
||||
zh: "站酷庆科黄油体",
|
||||
en: "ZCOOL QingKe HuangYou",
|
||||
},
|
||||
{ family: "未来荧黑", file: "glow", zh: "未来荧黑", en: "Glow Sans" },
|
||||
{ family: "朱雀仿宋", file: "zhuque", zh: "朱雀仿宋", en: "Zhuque FangSong" },
|
||||
{
|
||||
family: "站酷快乐体",
|
||||
file: "kuaile",
|
||||
zh: "站酷快乐体",
|
||||
en: "ZCOOL KuaiLe",
|
||||
},
|
||||
|
||||
{
|
||||
family: "思源黑体",
|
||||
file: "notosans",
|
||||
zh: "思源黑体 · 正文",
|
||||
en: "Source Han Sans",
|
||||
},
|
||||
{
|
||||
family: "思源黑体 Heavy",
|
||||
file: "notosans-heavy",
|
||||
zh: "思源黑体 · 特重",
|
||||
en: "Source Han Sans Heavy",
|
||||
},
|
||||
{
|
||||
family: "思源宋体 Heavy",
|
||||
file: "notoserif-heavy",
|
||||
zh: "思源宋体 · 特重",
|
||||
en: "Source Han Serif Heavy",
|
||||
},
|
||||
{ family: "得意黑", file: "smiley", zh: "得意黑 · 标题", en: "Smiley Sans" },
|
||||
{
|
||||
family: "霞鹜文楷",
|
||||
file: "wenkai",
|
||||
zh: "霞鹜文楷 · 手写",
|
||||
en: "LXGW WenKai",
|
||||
},
|
||||
{ family: "Inter", file: "inter", zh: "Inter · 无衬线", en: "Inter" },
|
||||
{
|
||||
family: "DM Serif Display",
|
||||
file: "dmserif",
|
||||
zh: "DM Serif · 衬线",
|
||||
en: "DM Serif Display",
|
||||
},
|
||||
{ family: "Bebas Neue", file: "bebas", zh: "Bebas · 窄体", en: "Bebas Neue" },
|
||||
{
|
||||
family: "Space Grotesk",
|
||||
file: "spacegrotesk",
|
||||
zh: "Space Grotesk · 科技",
|
||||
en: "Space Grotesk",
|
||||
},
|
||||
{
|
||||
family: "JetBrains Mono",
|
||||
file: "jetbrains",
|
||||
zh: "JetBrains · 等宽",
|
||||
en: "JetBrains Mono",
|
||||
},
|
||||
] as const;
|
||||
import { POSTER_FONTS } from "@edgeever/shared";
|
||||
export { POSTER_FONTS };
|
||||
const loads = new Map<string, Promise<void>>();
|
||||
export function loadPosterFont(family: string) {
|
||||
const font = POSTER_FONTS.find((entry) => entry.family === family);
|
||||
|
||||
+8
-8
@@ -1,3 +1,4 @@
|
||||
import { posterDesignTemplate } from "@edgeever/shared";
|
||||
/**
|
||||
* Layouts drawn from the formats that dominate the Gaoding (稿定设计) recommended feed for Xiaohongshu and WeChat, kept to the
|
||||
* ones that live on type and flat shapes alone (no stock photo, no illustration), redrawn to the design rules: one focal
|
||||
@@ -7,7 +8,6 @@ import { Circle, FabricObject, Line, Path, Polygon, Rect, Shadow } from 'fabric'
|
||||
import { grain } from './kit';
|
||||
import { badge, dot, FABRIC_LINE, fitTitle, hasCjk, keep, lineW, metrics, meta, pill, rect, solid, sub, Template, textbox, textHeight, wrapLines, palette } from './templates';
|
||||
|
||||
const XHS = ['xhs', 'xhs-square', 'portrait', 'square']; const VIDEO = ['youtube', 'bilibili', 'bilibili-43', 'bilibili-hd', 'wide']; const BANNER = ['wechat', 'x'];
|
||||
/** Items for a checklist row: the points, or a subtitle written as a list. */
|
||||
const items = (points: string[] | undefined, subtitle: string, max: number): string[] => {
|
||||
const xs = (points?.length ? points : subtitle.split(/[\n,,、;;||]/)).map(s => s.trim()).filter(Boolean);
|
||||
@@ -34,7 +34,7 @@ function brushBlock(x: number, y: number, w: number, h: number, color: string, s
|
||||
|
||||
export const GAODING: Template[] = [
|
||||
{
|
||||
id: 'regeng', zh: '热梗卡', en: 'Daily drop', fit: [...XHS, 'wechat'], zhUse: '黑色话题条(#标签#)+ 三层错位描边卡片里的粗黑大字 + 左下“No.期号”;每日热梗、资讯速递、系列笔记,小红书辨识度很高', enUse: 'A black hashtag bar, a heavy headline in a stack of three offset outlined cards and an issue number; daily drops, news and series notes',
|
||||
...posterDesignTemplate("regeng"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#f2f2ef', bg2: '#f2f2ef', ink: '#111111', sub: '#4b4b4b', accent: '#e8432b', accentInk: '#ffffff' });
|
||||
const { u, m, w, h } = metrics(i); const side = w / h > 1.25; const bar = Math.max(56, 88 * u); const sw = Math.max(4, 6 * u); const off = Math.max(8, 14 * u);
|
||||
@@ -61,7 +61,7 @@ export const GAODING: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'interview', zh: '黑黄箭头', en: 'Black & yellow', fit: [...XHS, ...VIDEO, 'vertical'], zhUse: '黑底 + 黄色超粗标题 + 一条手绘大箭头指向标题 + 白框英文标签 + 角落笑脸;面试、考试、干货清单,强对比又不土', enUse: 'Black, a heavy yellow headline, one big hand-drawn arrow pointing at it, a boxed label and a corner smiley; interviews, exams and checklists',
|
||||
...posterDesignTemplate("interview"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#0f0f0f', bg2: '#0f0f0f', ink: '#ffe14a', sub: '#ffffff', accent: '#ffe14a', accentInk: '#0f0f0f' });
|
||||
const { u, m, w, h } = metrics(i); const side = w / h > 1.25; const x0 = m * 1.1; const tw = side ? w * 0.62 - x0 : w - x0 * 2;
|
||||
@@ -84,7 +84,7 @@ export const GAODING: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'brush', zh: '笔刷收藏', en: 'Brush highlight', fit: [...XHS, 'wechat', ...VIDEO], zhUse: '白底粗黑标题 + 最后一行压一道绿色笔刷 + 歪贴的“建议收藏!”标签 + 底部 ✓ 清单(points);副业、避坑、干货,最像小红书爆款笔记', enUse: 'Heavy black type on white, a green brush stroke under the last line, a tilted “save this” tag and a check row from the points; tips and side hustles',
|
||||
...posterDesignTemplate("brush"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#ffffff', bg2: '#ffffff', ink: '#111111', sub: '#4b4b4b', accent: '#3ddc84', accentInk: '#111111' });
|
||||
const { u, m, w, h, wide } = metrics(i); const x0 = m * 1.1; const tw = (w - x0 * 2) * (wide ? 0.82 : 1); const lh = 1.12;
|
||||
@@ -110,7 +110,7 @@ export const GAODING: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'calendar', zh: '日历页', en: 'Calendar page', fit: [...XHS, 'wechat', ...VIDEO], zhUse: '纯蓝底 + 一张带装订环的白色日历页 + 页里的粗黑大字;每日一句、打卡、待办、节日,干净有仪式感', enUse: 'Flat blue with a white calendar page and binding rings, a heavy headline on the page; daily lines, check-ins and to-dos',
|
||||
...posterDesignTemplate("calendar"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#2f6bff', bg2: '#2f6bff', ink: '#111111', sub: '#555555', accent: '#2f6bff', accentInk: '#ffffff' });
|
||||
const { u, m, w, h, wide } = metrics(i); const cw = wide ? w * 0.7 : w - m * 2.2; const ch = wide ? h - m * 2.4 : h * 0.66; const cx = (w - cw) / 2; const cy = (h - ch) / 2 + m * 0.3;
|
||||
@@ -131,7 +131,7 @@ export const GAODING: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'frame', zh: '框线大字', en: 'Framed type', fit: [...XHS, ...BANNER, ...VIDEO], zhUse: '纯色底 + 一圈细内框 + 撑满框的超大标题 + 一角出血的太阳圆;招聘、开学、通知、活动,最简单也最醒目', enUse: 'Flat colour, a thin inset frame, a headline that fills it and a sun disc bleeding off a corner; hiring, launches and notices',
|
||||
...posterDesignTemplate("frame"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#22b573', bg2: '#22b573', ink: '#0f2a1d', sub: '#0f2a1d', accent: '#f4e04d', accentInk: '#0f2a1d' });
|
||||
const { u, m, w, h, wide } = metrics(i); const f = m * 0.55; const pad = m * 0.9; const tw = w - (f + pad) * 2;
|
||||
@@ -152,7 +152,7 @@ export const GAODING: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kicker', zh: '粗细引题', en: 'Kicker headline', fit: [...XHS, 'wechat', ...VIDEO], zhUse: '浅灰底:副标题当细字引题写在上面,标题粗黑放大 + 双线胶囊标签 + 底部“右滑查看 ››››”;经验、问答、职场,信息感强', enUse: 'Light grey: the subtitle as a thin kicker above a heavy headline, a double-ring pill tag and a “swipe” cue; experience posts, Q&A, work',
|
||||
...posterDesignTemplate("kicker"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#ececea', bg2: '#ececea', ink: '#111111', sub: '#2b2b2b', accent: '#111111', accentInk: '#ffffff' });
|
||||
const { u, m, w, h, wide } = metrics(i); const x0 = m * 1.1; const tw = (w - x0 * 2) * (wide ? 0.72 : 1);
|
||||
@@ -174,7 +174,7 @@ export const GAODING: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'corner', zh: '斜角色块', en: 'Corner wedge', fit: [...XHS, ...VIDEO, 'wechat'], zhUse: '白底 + 右下一大块斜切薄荷色 + 左上粗黑大字 + 黑色胶囊副标题 + 一条弯箭头;计划、清单、自律、学习,清爽有节奏', enUse: 'White with a big mint wedge from the lower right, a heavy headline top left, a black pill subtitle and a curved arrow; plans, lists and study',
|
||||
...posterDesignTemplate("corner"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#ffffff', bg2: '#ffffff', ink: '#111111', sub: '#ffffff', accent: '#7ee0c3', accentInk: '#111111' });
|
||||
const { u, m, w, h, wide } = metrics(i); const x0 = m * 1.1; const tw = (w - x0 * 2) * (wide ? 0.6 : 0.86);
|
||||
|
||||
+2
-55
@@ -1,55 +1,2 @@
|
||||
/** Platform presets: sizes, feed overlays that cover parts of a cover, and file-size limits. */
|
||||
export interface Zone { x: number; y: number; w: number; h: number; zh: string; en: string }
|
||||
export interface Platform {
|
||||
id: string; zh: string; en: string; icon: string; group: 'social' | 'video' | 'general';
|
||||
width: number; height: number; zhHint: string; enHint: string;
|
||||
/** Inner text-safe margin as a fraction of the shorter side. */
|
||||
margin: number;
|
||||
/** Areas the platform usually draws over the cover (timestamps, counters, buttons). */
|
||||
avoid: Zone[];
|
||||
/** Upload limit in bytes when the platform has a documented one. */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
export const PLATFORMS: Platform[] = [
|
||||
{ id: 'xhs', zh: '小红书 · 3:4', en: 'Xiaohongshu · 3:4', icon: 'book-heart', group: 'social', width: 1080, height: 1440, margin: 0.06, avoid: [],
|
||||
zhHint: '图文笔记封面,3:4 竖版最常用', enHint: 'Note cover, 3:4 portrait is the standard' },
|
||||
{ id: 'xhs-square', zh: '小红书 · 1:1', en: 'Xiaohongshu · 1:1', icon: 'square', group: 'social', width: 1080, height: 1080, margin: 0.07, avoid: [],
|
||||
zhHint: '方形封面,信息流中更紧凑', enHint: 'Square cover, more compact in the feed' },
|
||||
{ id: 'youtube', zh: 'YouTube 缩略图', en: 'YouTube thumbnail', icon: 'play', group: 'video', width: 1280, height: 720, margin: 0.04, maxBytes: 2 * 1024 * 1024,
|
||||
avoid: [{ x: 0.82, y: 0.86, w: 0.18, h: 0.14, zh: '时长标签', en: 'Duration' }],
|
||||
zhHint: '1280×720,文件不超过 2MB,右下角会被时长遮挡', enHint: '1280×720, under 2MB; the duration badge covers the bottom-right corner' },
|
||||
{ id: 'bilibili', zh: 'B 站封面 · 16:10', en: 'Bilibili cover · 16:10', icon: 'tv', group: 'video', width: 1146, height: 717, margin: 0.04,
|
||||
avoid: [{ x: 0, y: 0.84, w: 1, h: 0.16, zh: '播放数据条', en: 'Stats bar' }],
|
||||
zhHint: '1146×717,信息流底部会叠加播放与弹幕数据', enHint: '1146×717; the feed overlays play counts along the bottom' },
|
||||
{ id: 'bilibili-43', zh: 'B 站投稿 · 4:3', en: 'Bilibili upload · 4:3', icon: 'tv', group: 'video', width: 1200, height: 900, margin: 0.05,
|
||||
avoid: [{ x: 0, y: 0, w: 1, h: 0.08, zh: '信息流裁切', en: 'Cropped in feed' }, { x: 0, y: 0.78, w: 1, h: 0.22, zh: '裁切 + 播放数据条', en: 'Crop + stats bar' }],
|
||||
zhHint: '1200×900 投稿封面;信息流里按 16:10 裁切,文字放在中间 80% 内', enHint: '1200×900 upload cover; the feed crops it to 16:10, keep type in the middle band' },
|
||||
{ id: 'bilibili-hd', zh: 'B 站 · 16:9 高清', en: 'Bilibili · 16:9 HD', icon: 'tv', group: 'video', width: 1920, height: 1080, margin: 0.04,
|
||||
avoid: [{ x: 0, y: 0.86, w: 1, h: 0.14, zh: '播放数据条', en: 'Stats bar' }],
|
||||
zhHint: '1920×1080,横版高清封面', enHint: '1920×1080 widescreen cover' },
|
||||
{ id: 'vertical', zh: '抖音 / 视频号 · 9:16', en: 'Douyin / Shorts · 9:16', icon: 'smartphone', group: 'video', width: 1080, height: 1920, margin: 0.06,
|
||||
avoid: [{ x: 0, y: 0.78, w: 0.8, h: 0.22, zh: '标题与账号', en: 'Caption' }, { x: 0.84, y: 0.5, w: 0.16, h: 0.4, zh: '互动按钮', en: 'Buttons' }],
|
||||
zhHint: '竖屏短视频封面,底部和右侧会被界面遮挡', enHint: 'Vertical video; captions and buttons cover the bottom and right' },
|
||||
{ id: 'wechat', zh: '公众号头图 · 2.35:1', en: 'WeChat banner · 2.35:1', icon: 'message-circle', group: 'social', width: 1410, height: 600, margin: 0.05, avoid: [],
|
||||
zhHint: '文章头图,列表中会被裁为 1:1 小图,主体放中间', enHint: 'Article banner; the feed crops a square from the centre' },
|
||||
{ id: 'x', zh: 'X 封面 · 5:2', en: 'X header · 5:2', icon: 'at-sign', group: 'social', width: 1500, height: 600, margin: 0.05, avoid: [{ x: 0, y: 0.55, w: 0.3, h: 0.45, zh: '头像', en: 'Avatar' }],
|
||||
zhHint: '个人主页横幅,头像会遮住左下角', enHint: 'Profile header; the avatar covers the lower-left' },
|
||||
{ id: 'square', zh: '方形 · 1:1', en: 'Square · 1:1', icon: 'square', group: 'general', width: 1080, height: 1080, margin: 0.06, avoid: [], zhHint: '通用方形', enHint: 'General square' },
|
||||
{ id: 'wide', zh: '横版 · 16:9', en: 'Landscape · 16:9', icon: 'rectangle-horizontal', group: 'general', width: 1920, height: 1080, margin: 0.05, avoid: [], zhHint: '通用横版', enHint: 'General landscape' },
|
||||
{ id: 'portrait', zh: '竖版 · 3:4', en: 'Portrait · 3:4', icon: 'rectangle-vertical', group: 'general', width: 1200, height: 1600, margin: 0.06, avoid: [], zhHint: '通用竖版', enHint: 'General portrait' },
|
||||
];
|
||||
|
||||
export const DEFAULT_PLATFORM = 'xhs';
|
||||
export function platformById(id: string | undefined): Platform | undefined { return PLATFORMS.find(p => p.id === id); }
|
||||
export function platformFor(width: number, height: number, id?: string): Platform | undefined {
|
||||
const exact = platformById(id);
|
||||
if (exact && exact.width === width && exact.height === height) return exact;
|
||||
return PLATFORMS.find(p => p.width === width && p.height === height);
|
||||
}
|
||||
export function platformName(p: Platform, zh: boolean): string { return zh ? p.zh : p.en; }
|
||||
export const GROUPS: { id: Platform['group']; zh: string; en: string }[] = [
|
||||
{ id: 'social', zh: '图文平台', en: 'Social' },
|
||||
{ id: 'video', zh: '视频平台', en: 'Video' },
|
||||
{ id: 'general', zh: '通用比例', en: 'General' },
|
||||
];
|
||||
export { PLATFORMS, platformFor } from "@edgeever/shared";
|
||||
export type { Platform, Zone } from "@edgeever/shared";
|
||||
+50
-50
@@ -1,3 +1,4 @@
|
||||
import { posterDesignTemplate } from "@edgeever/shared";
|
||||
import { BadgeBox } from './badge';
|
||||
import { arch, barcode, blob, burst, cropMarks, dotGrid, glow as kitGlow, grain, metaBlock, paper, rays, regMark, rule, seal, sideText, sparkle, stickerDot, tape, ticker } from './kit';
|
||||
import { ghost } from './kit';
|
||||
@@ -23,7 +24,6 @@ export interface Template {
|
||||
slot?(i: TemplateInput): Slot | undefined;
|
||||
build(i: TemplateInput): TemplateResult;
|
||||
}
|
||||
const XHS = ['xhs', 'xhs-square', 'portrait', 'square']; const VIDEO = ['youtube', 'bilibili', 'bilibili-43', 'bilibili-hd', 'wide']; const SHORT = ['vertical']; const BANNER = ['wechat', 'x'];
|
||||
|
||||
import { titleFace, typeFor } from './typeset';
|
||||
const SANS = 'sans-serif';
|
||||
@@ -176,7 +176,7 @@ const shadowHard = (u: number, c = '#000000', d = 8): Shadow => new Shadow({ col
|
||||
|
||||
export const TEMPLATES: Template[] = [
|
||||
{
|
||||
id: 'minimal', zh: '简约白', en: 'Minimal', fit: [...XHS, ...BANNER], zhUse: '干净克制,适合观点、读书、知识类', enUse: 'Clean and restrained; opinions, reading, knowledge',
|
||||
...posterDesignTemplate("minimal"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#ffffff', bg2: '#ffffff', ink: '#171717', sub: '#737373', accent: '#171717', accentInk: '#ffffff' });
|
||||
const { u, m, w, h, wide } = metrics(i); const inner = w - m * 2; const titleH = h * (wide ? 0.55 : 0.5);
|
||||
@@ -190,7 +190,7 @@ export const TEMPLATES: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'editorial', zh: '杂志', en: 'Editorial', fit: [...BANNER, ...XHS], zhUse: '衬线大标题 + 细线,有质感,适合深度内容与公众号', enUse: 'Serif headline with rules; essays and articles',
|
||||
...posterDesignTemplate("editorial"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#f4efe4', bg2: '#f4efe4', ink: '#171717', sub: '#404040', accent: '#171717', accentInk: '#f4efe4' });
|
||||
const { u, m, w, h } = metrics(i); const inner = w - m * 2;
|
||||
@@ -205,7 +205,7 @@ export const TEMPLATES: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'bold', zh: '醒目黄', en: 'Loud yellow', fit: [...XHS, ...VIDEO, ...SHORT], zhUse: '高饱和纯色 + 黑字,信息流里最抢眼', enUse: 'Saturated flat colour with black type; stops the scroll',
|
||||
...posterDesignTemplate("bold"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#ffe04b', bg2: '#ffe04b', ink: '#111111', sub: '#111111', accent: '#111111', accentInk: '#ffe04b' });
|
||||
const { u, m, w, h } = metrics(i); const inner = w - m * 2;
|
||||
@@ -219,7 +219,7 @@ export const TEMPLATES: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dark', zh: '深色科技', en: 'Dark tech', fit: [...VIDEO, ...BANNER, ...XHS], zhUse: '深底荧光点缀,适合 AI、编程、效率工具', enUse: 'Dark with a neon glow; AI, code, tools',
|
||||
...posterDesignTemplate("dark"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#0a0a0a', bg2: '#1c1c22', ink: '#fafafa', sub: '#a1a1aa', accent: '#5eead4', accentInk: '#0a0a0a' });
|
||||
const { u, m, w, h } = metrics(i); const inner = w - m * 2;
|
||||
@@ -233,7 +233,7 @@ export const TEMPLATES: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'poster', zh: '大字报', en: 'Poster', fit: [...XHS, ...SHORT], zhUse: '满屏居中大字,情绪强,适合金句、避坑、警告', enUse: 'Full-bleed centred type; punchy statements',
|
||||
...posterDesignTemplate("poster"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#e11d2e', bg2: '#e11d2e', ink: '#ffffff', sub: '#ffe4e6', accent: '#ffe04b', accentInk: '#111111' });
|
||||
const { u, m, w, h } = metrics(i); const inner = w - m * 2;
|
||||
@@ -246,7 +246,7 @@ export const TEMPLATES: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'split', zh: '左文右图', en: 'Split', fit: [...VIDEO, ...BANNER], zhUse: '左文案右主体,右侧可放人像或产品图', enUse: 'Copy left, subject right; leaves room for a portrait or product',
|
||||
...posterDesignTemplate("split"),
|
||||
slot: i => { const { w, h } = metrics(i); const x = w * 0.54; return { x, y: 0, w: w - x, h, radius: 0 }; },
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#fff7ed', bg2: '#fff7ed', ink: '#1c1917', sub: '#57534e', accent: '#f97316', accentInk: '#ffffff' });
|
||||
@@ -262,7 +262,7 @@ export const TEMPLATES: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sticker', zh: '便签', en: 'Sticky note', fit: XHS, zhUse: '粉底便签纸,生活感,适合种草、日常、分享', enUse: 'Pink backdrop, paper note; lifestyle posts',
|
||||
...posterDesignTemplate("sticker"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#fda4af', bg2: '#fda4af', ink: '#292524', sub: '#78716c', accent: '#fcd34d', accentInk: '#292524' });
|
||||
const { u, m, w, h } = metrics(i); const pad = m * 1.4; const cardW = w - m * 2; const cardH = h - m * 2;
|
||||
@@ -277,7 +277,7 @@ export const TEMPLATES: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gradient', zh: '渐变光感', en: 'Soft gradient', fit: [...VIDEO, ...BANNER, ...XHS], zhUse: '蓝青渐变 + 白字,通用、稳、不出错', enUse: 'Blue-cyan gradient with white type; safe and versatile',
|
||||
...posterDesignTemplate("gradient"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#2563eb', bg2: '#06b6d4', ink: '#ffffff', sub: '#e0f2fe', accent: '#ffffff', accentInk: '#1d4ed8' });
|
||||
const { u, m, w, h } = metrics(i); const inner = w - m * 2;
|
||||
@@ -291,7 +291,7 @@ export const TEMPLATES: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'center', zh: '居中标签', en: 'Centered', fit: [...XHS, ...VIDEO], zhUse: '细框居中,黄色高亮副标,通用百搭', enUse: 'Thin frame, centred, highlighted subtitle; all-purpose',
|
||||
...posterDesignTemplate("center"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#fafafa', bg2: '#fafafa', ink: '#171717', sub: '#262626', accent: '#ffe04b', accentInk: '#171717' });
|
||||
const { u, m, w, h } = metrics(i); const inner = w - m * 3.2;
|
||||
@@ -306,7 +306,7 @@ export const TEMPLATES: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'checklist', zh: '清单笔记', en: 'Checklist', fit: [...XHS], zhUse: '标题 + 3~4 条要点,信息密度高,小红书干货首选', enUse: 'Headline plus 3–4 points; dense and useful',
|
||||
...posterDesignTemplate("checklist"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#ecfdf3', bg2: '#ecfdf3', ink: '#052e16', sub: '#166534', accent: '#16a34a', accentInk: '#ffffff' });
|
||||
const { u, m, w, h } = metrics(i); const inner = w - m * 2;
|
||||
@@ -323,7 +323,7 @@ export const TEMPLATES: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'number', zh: '数字干货', en: 'Big number', fit: [...XHS, ...SHORT, 'youtube', 'bilibili', 'bilibili-43'], zhUse: '超大数字做钩子(7 个技巧 / 3 步),点击率高的榜单体', enUse: 'A huge numeral as the hook; listicles',
|
||||
...posterDesignTemplate("number"),
|
||||
slot: i => { const { w, h, wide } = metrics(i); return wide ? undefined : { x: 0, y: h * 0.68, w, h: h * 0.32, radius: 0 }; },
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#fff7e6', bg2: '#fff7e6', ink: '#1a1a1a', sub: '#6b6b6b', accent: '#ef4444', accentInk: '#ffffff' });
|
||||
@@ -349,7 +349,7 @@ export const TEMPLATES: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'impact', zh: '油管冲击', en: 'Impact', fit: ['youtube', 'bilibili', 'bilibili-43', 'bilibili-hd', 'wide', 'vertical'], zhUse: '黄字黑描边 + 红标,右侧留人像位;短标题(≤8 字)效果最好', enUse: 'Yellow type with a black outline, red tag, subject area on the right; keep titles short',
|
||||
...posterDesignTemplate("impact"),
|
||||
photo: true,
|
||||
slot: i => { const { w, h, wide } = metrics(i); return wide ? { x: w * 0.6, y: 0, w: w * 0.4, h, radius: 0 } : undefined; },
|
||||
build(i) {
|
||||
@@ -369,7 +369,7 @@ export const TEMPLATES: Template[] = [
|
||||
},
|
||||
|
||||
{
|
||||
id: 'compare', zh: '对比', en: 'Versus', fit: [...XHS, ...VIDEO], zhUse: '左右两栏对比:副标题用 “A | B” 或“前 | 后”,评测 / 前后效果', enUse: 'Two panels; subtitle as “A | B”; reviews and before/after',
|
||||
...posterDesignTemplate("compare"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#f8fafc', bg2: '#f8fafc', ink: '#0f172a', sub: '#334155', accent: '#16a34a', accentInk: '#ffffff' });
|
||||
const { u, m, w, h, wide } = metrics(i); const inner = w - m * 2;
|
||||
@@ -395,7 +395,7 @@ export const TEMPLATES: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'neo', zh: '新粗野', en: 'Neo-brutal', fit: [...XHS, ...VIDEO, ...SHORT], zhUse: '黑粗框 + 硬阴影 + 荧光底,年轻、有趣、辨识度高', enUse: 'Thick black borders, hard shadows, bright backdrop',
|
||||
...posterDesignTemplate("neo"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#a3e635', bg2: '#a3e635', ink: '#111111', sub: '#111111', accent: '#ff5fa2', accentInk: '#111111' });
|
||||
const { u, m, w, h } = metrics(i); const bw = Math.max(5, 8 * u); const cx = m * 0.9; const cw = w - cx * 2; const ch = h - cx * 2; const pad = m * 0.9;
|
||||
@@ -411,7 +411,7 @@ export const TEMPLATES: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'soft', zh: '柔雾莫兰迪', en: 'Soft haze', fit: [...XHS, ...BANNER], zhUse: '低饱和柔光,适合生活方式、情绪、穿搭、女性向', enUse: 'Muted haze; lifestyle, mood, fashion',
|
||||
...posterDesignTemplate("soft"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#f6e7e3', bg2: '#e4dcf3', ink: '#3b2f4a', sub: '#7a6b8a', accent: '#b794d6', accentInk: '#ffffff' });
|
||||
const { u, m, w, h } = metrics(i); const inner = w - m * 2; const size = fitTitle(i.title, inner, h * 0.46, 118 * u);
|
||||
@@ -434,7 +434,7 @@ const sub = (i: TemplateInput, left: number, top: number, width: number, size: n
|
||||
|
||||
const PREMIUM: Template[] = [
|
||||
{
|
||||
id: 'swiss', zh: '瑞士网格', en: 'Swiss grid', fit: [...XHS, ...BANNER, ...VIDEO], zhUse: '米白底 + 红圆 + 极粗无衬线 + 网格细线,国际主义风格', enUse: 'Off-white, a red disc, ultra-bold sans and grid hairlines',
|
||||
...posterDesignTemplate("swiss"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#f3f1ea', bg2: '#f3f1ea', ink: '#111111', sub: '#555555', accent: '#e63312', accentInk: '#ffffff' });
|
||||
const { u, m, w, h, wide } = metrics(i); const inner = w - m * 2; const tw = wide ? inner * 0.7 : inner;
|
||||
@@ -453,7 +453,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'acid', zh: '酸性渐变', en: 'Acid mesh', fit: [...XHS, ...VIDEO, ...SHORT], zhUse: '浅粉紫弥散光斑 + 颗粒 + 居中深紫大字 + 一枚闪光;潮流、音乐、AI 话题', enUse: 'Pastel mesh glows and grain, a centred deep-violet headline and one sparkle; trendy, music, AI',
|
||||
...posterDesignTemplate("acid"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#ffe3ef', bg2: '#ddd3ff', ink: '#24104a', sub: '#5b3f8c', accent: '#ff3d8b', accentInk: '#ffffff' });
|
||||
const { u, m, w, h, wide } = metrics(i); const inner = w - m * 2; const tw = wide ? inner * 0.82 : inner;
|
||||
@@ -471,7 +471,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'collage', zh: '拼贴手账', en: 'Paper collage', fit: [...XHS, 'bilibili', 'wechat'], zhUse: '牛皮纸 + 撕纸色块 + 胶带 + 贴纸,生活、手账、种草', enUse: 'Kraft paper, torn colour blocks, tape and stickers; lifestyle and journaling',
|
||||
...posterDesignTemplate("collage"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#e6d5b8', bg2: '#e6d5b8', ink: '#2b2118', sub: '#6e5b45', accent: '#ef6f4f', accentInk: '#ffffff' });
|
||||
const { u, m, w, h, wide } = metrics(i); const pw = w - m * 2.2; const ph = h * (wide ? 0.7 : 0.52); const px = (w - pw) / 2; const py = (h - ph) / 2;
|
||||
@@ -490,7 +490,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'mega', zh: '大字满版', en: 'Mega type', fit: [...XHS, ...SHORT, 'youtube'], zhUse: '标题放大到撑满画面,高饱和纯色底,最强的信息流冲击力', enUse: 'The headline fills the artboard on a saturated colour; maximum scroll-stopping force',
|
||||
...posterDesignTemplate("mega"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#ff4d2e', bg2: '#ff4d2e', ink: '#fff4e6', sub: '#1a1209', accent: '#1a1209', accentInk: '#fff4e6' });
|
||||
const { u, m, w, h } = metrics(i); const inner = w - m * 1.6;
|
||||
@@ -503,7 +503,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ticker', zh: '跑马灯', en: 'Ticker', fit: [...VIDEO, ...XHS], zhUse: '奶黄底 + 斜向滚动字带 + 硬阴影大字,B 站 / YouTube 的信息流利器', enUse: 'Cream with diagonal ticker bands and hard-shadow type; built for video feeds',
|
||||
...posterDesignTemplate("ticker"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#fff1c9', bg2: '#ffd9b3', ink: '#ffffff', sub: '#2a1d0c', accent: '#111111', accentInk: '#facc15' });
|
||||
const { u, m, w, h } = metrics(i); const inner = w - m * 2; const word = (i.badge?.trim() || i.subtitle.trim() || 'NEW').slice(0, 14); const bh = Math.max(70, 100 * u);
|
||||
@@ -517,7 +517,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'memo', zh: '便签笔记', en: 'Memo paper', fit: [...XHS, 'wechat'], zhUse: '横线笔记纸 + 红色页边线 + 荧光笔划重点,学习、笔记、干货', enUse: 'Ruled notebook paper with a margin line and a highlighter; study notes and tips',
|
||||
...posterDesignTemplate("memo"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#fbf8ef', bg2: '#fbf8ef', ink: '#1d2a44', sub: '#5b6a86', accent: '#fde047', accentInk: '#1d2a44' });
|
||||
const { u, m, w, h } = metrics(i); const left = m * 1.9; const inner = w - left - m; const size = fitTitle(i.title, inner, h * 0.42, 150 * u, 36, 1.12); const th = textHeight(i.title, inner, size, 1.12);
|
||||
@@ -535,7 +535,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pop', zh: '贴纸波普', en: 'Pop sticker', fit: [...XHS, ...VIDEO, ...SHORT], zhUse: '明黄底 + 黑色粗标题 + 几枚歪贴的粉、蓝、绿色块贴纸 + 白色胶囊副标题;年轻好玩,但不靠描边、硬投影和爆炸星', enUse: 'Yellow, heavy black type, a few tilted pink, blue and green stickers and a white pill subtitle; playful without outlines, hard shadows or bursts',
|
||||
...posterDesignTemplate("pop"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#ffd23f', bg2: '#ffd23f', ink: '#151515', sub: '#151515', accent: '#ff4f8b', accentInk: '#ffffff' });
|
||||
const { u, m, w, h, wide } = metrics(i); const x0 = m * 1.1; const tw = (w - x0 * 2) * (wide ? 0.7 : 1); const s0 = Math.min(w, h);
|
||||
@@ -553,7 +553,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'mag', zh: '杂志封面', en: 'Magazine', fit: [...BANNER, ...XHS, 'bilibili'], zhUse: '纸质底 + 衬线巨标题 + 巨型虚影期号 + 报头细线,像一本真杂志', enUse: 'Paper, a huge serif headline, a ghost issue numeral and masthead rules; a real magazine cover',
|
||||
...posterDesignTemplate("mag"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#efe7d8', bg2: '#e5dac4', ink: '#17130d', sub: '#6b5f4b', accent: '#b6402a', accentInk: '#fff7ea' });
|
||||
const { u, m, w, h, wide } = metrics(i); const inner = w - m * 2; const tw = wide ? inner * 0.62 : inner * 0.94; const topY = m * 0.7; const num = digits(i, false);
|
||||
@@ -575,7 +575,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'print', zh: '印刷海报', en: 'Print poster', fit: [...XHS, ...BANNER, ...VIDEO], zhUse: '黑 + 米白 + 一抹朱红,裁切线、套准标、沿边文字、条码,欧洲字体海报的语汇', enUse: 'Black, paper and one vermilion; crop marks, registration target, edge text and barcode: the European typographic poster vocabulary',
|
||||
...posterDesignTemplate("print"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#f2efe6', bg2: '#f2efe6', ink: '#0d0d0d', sub: '#4a4a4a', accent: '#ff3b1f', accentInk: '#ffffff' });
|
||||
const { u, m, w, h, wide } = metrics(i); const inner = w - m * 2.4; const x0 = m * 1.2; const tw = wide ? inner * 0.78 : inner;
|
||||
@@ -597,7 +597,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'glass', zh: '玻璃卡片', en: 'Glass card', fit: [...XHS, ...VIDEO, ...BANNER], zhUse: '粉蓝弥散背景 + 大幅磨砂玻璃卡片 + 悬浮小胶囊,现代、产品、App 感', enUse: 'Pastel mesh background, a large frosted glass card and floating pills; modern, product, app',
|
||||
...posterDesignTemplate("glass"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#ffd1e8', bg2: '#c7d7ff', ink: '#2b1b57', sub: 'rgba(43,27,87,0.78)', accent: '#2b1b57', accentInk: '#ffffff' });
|
||||
const { u, m, w, h, wide } = metrics(i); const cw = w - m * 1.6; const ch = h * (wide ? 0.8 : 0.64); const cx = (w - cw) / 2; const cy = (h - ch) / 2; const pad = m * 0.85; const inner = cw - pad * 2;
|
||||
@@ -617,7 +617,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'bento', zh: '便当清单', en: 'Bento list', fit: [...XHS, 'wechat'], zhUse: '深色标题卡 + 三张彩色要点卡拼成便当格,信息密度高又好看', enUse: 'A dark headline card and three colour cards in a bento grid; dense and good looking',
|
||||
...posterDesignTemplate("bento"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#ecebe4', bg2: '#ecebe4', ink: '#141414', sub: '#6b6b6b', accent: '#ffd23f', accentInk: '#141414' });
|
||||
const { u, m, w, h, wide } = metrics(i); const gap = 20 * u; const rows = (i.points?.length ? i.points : i.subtitle.split(/[\n,,、;;]/)).map(x => x.trim()).filter(Boolean).slice(0, 3);
|
||||
@@ -641,7 +641,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'neon', zh: '霓虹网格', en: 'Neon grid', fit: [...VIDEO, ...BANNER, ...XHS], zhUse: '深色透视网格 + 霓虹发光字 + 取景框角标,AI、编程、赛博', enUse: 'Dark perspective grid, neon glow type and viewfinder corners; AI, code, cyber',
|
||||
...posterDesignTemplate("neon"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#05060d', bg2: '#0b1226', ink: '#f8fafc', sub: '#7dd3fc', accent: '#22d3ee', accentInk: '#02131a' });
|
||||
const { u, m, w, h, wide } = metrics(i); const inner = w - m * 2; const tw = wide ? inner * 0.74 : inner;
|
||||
@@ -660,7 +660,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'calm', zh: '松弛留白', en: 'Calm paper', fit: [...XHS, ...BANNER], zhUse: '云舞白纸 + 大号衬线标题 + 竖线与弧线 + 沿边小字,克制的“冷静风”', enUse: 'Cloud-white paper, a large serif headline, a vertical rule, an arc and edge text; quiet and restrained',
|
||||
...posterDesignTemplate("calm"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#ebe8e0', bg2: '#ebe8e0', ink: '#24211b', sub: '#7a7466', accent: '#8a6f4d', accentInk: '#ffffff' });
|
||||
const { u, m, w, h, wide } = metrics(i); const inner = w - m * 2.4; const tw = wide ? inner * 0.62 : inner * 0.9; const x0 = m * 1.5;
|
||||
@@ -679,7 +679,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'folio', zh: '编辑大标题', en: 'Editorial headline', fit: [...XHS, ...BANNER, ...VIDEO], zhUse: '纸面编辑版式:顶部粗线 + 栏目名与期号、贴左黑体大标题、宋体导语、底部细线收边;只有一个焦点,留白占一半', enUse: 'An editorial page: a heavy top rule with section and issue, a flush-left heavy headline, a serif standfirst and a hairline foot; one focal point, half the page left empty',
|
||||
...posterDesignTemplate("folio"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#f3efe6', bg2: '#f3efe6', ink: '#191714', sub: '#5c574f', accent: '#c2410c', accentInk: '#ffffff' });
|
||||
const { u, m, w, h, wide } = metrics(i); const x0 = m * 1.3; const inner = w - x0 * 2; const tw = wide ? inner * 0.7 : inner;
|
||||
@@ -705,7 +705,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'numeral', zh: '单色大数字', en: 'Big numeral', fit: [...XHS, ...VIDEO, ...BANNER], zhUse: '一个巨型数字做钩子 + 其下小标题;单一强调色,适合清单、教程、数字干货', enUse: 'One oversized numeral as the hook, a quiet headline beneath; a single accent colour for lists, tutorials and number posts',
|
||||
...posterDesignTemplate("numeral"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#eef0ea', bg2: '#eef0ea', ink: '#1f2a24', sub: '#6b756e', accent: '#3f6b55', accentInk: '#ffffff' });
|
||||
const { u, m, w, h, wide } = metrics(i); const x0 = m * 1.3; const num = (/\d+/.exec(i.badge ?? '') ?? /\d+/.exec(i.title) ?? ['01'])[0].slice(0, 2);
|
||||
@@ -722,7 +722,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sage', zh: '纸质卡片', en: 'Paper card', fit: [...XHS, ...BANNER, ...VIDEO], zhUse: '低饱和鼠尾草底 + 一张米白纸卡,标题落在卡片上;温和、干净、有质感', enUse: 'A muted sage ground with one warm paper card carrying the headline; gentle, clean and tactile',
|
||||
...posterDesignTemplate("sage"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#d9e1d3', bg2: '#d9e1d3', ink: '#26302a', sub: '#6d786f', accent: '#b4572f', accentInk: '#ffffff' });
|
||||
const { u, m, w, h, wide } = metrics(i); const cx = m * 1.1; const cy = m * 1.1; const cw = w - cx * 2; const ch = h - cy * 2; const pad = m * 1.1;
|
||||
@@ -738,7 +738,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'seal', zh: '书法印章', en: 'Ink & seal', fit: [...XHS, ...BANNER, 'vertical'], zhUse: '宣纸底 + 淡墨巨圆 + 大号衬线标题 + 红印章;纯中文短标题自动改竖排', enUse: 'Rice paper, a huge ink wash disc, a big serif headline and a red seal; short Chinese titles run vertically',
|
||||
...posterDesignTemplate("seal"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#f1ead8', bg2: '#f1ead8', ink: '#1b1814', sub: '#5d5446', accent: '#c0392b', accentInk: '#f6efe0' });
|
||||
const { u, m, w, h, wide } = metrics(i); const flat = i.title.replace(/\s+/g, ''); const vertical = /^[㐀-鿿]{2,7}$/.test(flat); const stamp = (i.badge?.trim() || [...flat][0] || '印').slice(0, 2);
|
||||
@@ -759,7 +759,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'quote', zh: '金句海报', en: 'Quote poster', fit: [...XHS, ...BANNER], zhUse: '墨黑底 + 巨型金色引号 + 衬线金句,一句话撑满一张海报', enUse: 'Ink black, a giant gold quotation mark and a serif statement; one line carries the poster',
|
||||
...posterDesignTemplate("quote"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#121212', bg2: '#121212', ink: '#f5efe2', sub: '#b9ad94', accent: '#e8b960', accentInk: '#111111' });
|
||||
const { u, m, w, h, wide } = metrics(i); const inner = w - m * 2.2; const tw = wide ? inner * 0.8 : inner;
|
||||
@@ -781,7 +781,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'compare', zh: '对比', en: 'Versus', fit: [...XHS, ...VIDEO], zhUse: '斜切分屏:左边黯淡的“之前”,右边鲜亮的“之后”,中间一枚 VS;副标题写成「A | B」', enUse: 'A slanted split: dull “before” against vivid “after” with a VS burst; write the subtitle as “A | B”',
|
||||
...posterDesignTemplate("compare"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#0f0f12', bg2: '#0f0f12', ink: '#ffffff', sub: '#d4d4d8', accent: '#ffd23f', accentInk: '#111111' });
|
||||
const { u, m, w, h, wide } = metrics(i); const parts = i.subtitle.split(/\s*[||]\s*|\s+vs\.?\s+/i).map(x => x.trim()).filter(Boolean);
|
||||
@@ -806,7 +806,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cinema', zh: '电影感', en: 'Cinematic', fit: [...VIDEO, ...BANNER], zhUse: '青橙调光 + 镜头光晕 + 金色取景角 + 字距拉开的衬线片名,纪录片、Vlog、深度内容', enUse: 'Teal-and-orange light, lens flare, gold viewfinder corners and a wide-tracked serif title; documentary and vlog', photo: true,
|
||||
...posterDesignTemplate("cinema"), photo: true,
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#06141a', bg2: '#10303a', ink: '#f6ead2', sub: '#c9bda3', accent: '#e8b960', accentInk: '#111111' });
|
||||
const { u, m, w, h } = metrics(i); const bar = h * 0.13; const inner = w - m * 2;
|
||||
@@ -826,7 +826,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'split', zh: '左文右图', en: 'Arch split', fit: [...VIDEO, ...BANNER, ...XHS], zhUse: '左文案右拱门:奶油底 + 橙色拱门窗 + 太阳圆 + 细线,右侧放人像或产品最好看', enUse: 'Copy on the left, an orange arch window on the right for a portrait or product; cream paper, a sun disc and fine rules',
|
||||
...posterDesignTemplate("split"),
|
||||
slot: i => { const { w, h } = metrics(i); const x = w * 0.54; return { x, y: 0, w: w - x, h, radius: 0 }; },
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#f6efe3', bg2: '#f6efe3', ink: '#1c1917', sub: '#6b5f55', accent: '#ff7a3d', accentInk: '#1c1917' }); // white on this orange is 2.6:1
|
||||
@@ -847,7 +847,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'impact', zh: '油管冲击', en: 'Thumbnail punch', fit: ['youtube', 'bilibili', 'bilibili-43', 'bilibili-hd', 'wide', 'vertical'], zhUse: '深蓝放射光 + 黄字黑描边硬阴影 + 红色角标,右侧留主体位;短标题效果最好', enUse: 'Blue sunburst, yellow type with a black outline and hard shadow, a red arrow and burst badge; room for a subject on the right',
|
||||
...posterDesignTemplate("impact"),
|
||||
photo: true,
|
||||
slot: i => { const { w, h, wide } = metrics(i); return wide ? { x: w * 0.6, y: 0, w: w * 0.4, h, radius: 0 } : undefined; },
|
||||
build(i) {
|
||||
@@ -865,7 +865,7 @@ const PREMIUM: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'bili', zh: 'B 站知识区', en: 'Bilibili explainer', fit: ['bilibili', 'bilibili-43', 'bilibili-hd', 'youtube', 'wide', ...SHORT, ...XHS], zhUse: '浅灰底 + B 站粉蓝双色 + 粉色分区标签 + 黑色大标题 + 小电视画框(屏幕是图位);知识区、科普、测评的干净信息感', enUse: 'Light grey with Bilibili pink and blue, a section tag, a bold headline and a little-TV frame whose screen takes a picture; explainers and reviews',
|
||||
...posterDesignTemplate("bili"),
|
||||
slot: i => biliTv(i).screen,
|
||||
build(i) {
|
||||
// Bilibili's pink and blue, a shade deeper where they carry words: the brand hexes are under 3:1 on light grey.
|
||||
@@ -916,7 +916,7 @@ function biliTv(i: TemplateInput): { x: number; y: number; w: number; h: number;
|
||||
|
||||
const STUDIO: Template[] = [
|
||||
{
|
||||
id: 'highlight', zh: '荧光标题', en: 'Highlighter', fit: [...XHS, ...VIDEO, ...SHORT, 'wechat'], zhUse: '白纸黑字 + 每行标题下一道圆头荧光笔(每行微微不同的倾斜),像在笔记上划重点;干货、观点、教程都稳', enUse: 'Black type on white with a round-ended highlighter stroke under every line, each at its own slight tilt; tips, opinions and tutorials',
|
||||
...posterDesignTemplate("highlight"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#fffdf7', bg2: '#fffdf7', ink: '#111111', sub: '#4a4a4a', accent: '#ffe14d', accentInk: '#111111' });
|
||||
const { u, m, w, h, wide } = metrics(i); const x0 = m * 1.1; const tw = (w - x0 * 2) * (wide ? 0.8 : 1); const lh = 1.2;
|
||||
@@ -935,7 +935,7 @@ const STUDIO: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'notes', zh: '备忘录', en: 'Notes app', fit: [...XHS, 'wechat', ...VIDEO], zhUse: '手机备忘录截图:黄色返回键 + 日期 + 黑色大标题 + 勾选清单(要点写在 points 里);小红书“截图型”,亲切可信', enUse: 'A phone notes screenshot: yellow back button, date, bold headline and a checklist from the points; personal and trustworthy',
|
||||
...posterDesignTemplate("notes"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#fbfaf6', bg2: '#fbfaf6', ink: '#1c1c1e', sub: '#7c7c80', accent: '#e2a300', accentInk: '#ffffff' });
|
||||
const { u, m, w, h, wide } = metrics(i); const x0 = m; const inner = w - m * 2; const bar = Math.max(26, 44 * u); const dateS = Math.max(18, 26 * u);
|
||||
@@ -955,7 +955,7 @@ const STUDIO: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'chat', zh: '聊天截图', en: 'Chat bubbles', fit: [...XHS, ...VIDEO, ...SHORT], zhUse: '一问一答的聊天气泡:副标题是提问,标题是回答;对话感强,适合问答、AI、情感、职场', enUse: 'A chat exchange: the subtitle asks, the headline answers; Q&A, AI, relationships and work',
|
||||
...posterDesignTemplate("chat"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#ededed', bg2: '#ededed', ink: '#111111', sub: '#111111', accent: '#95ec69', accentInk: '#111111' });
|
||||
const { u, m, w, h, wide } = metrics(i); const av = Math.max(56, 92 * u); const gap = 22 * u; const pad = Math.max(22, 32 * u); const head = Math.max(22, 30 * u); const barH = head * 2.6;
|
||||
@@ -979,7 +979,7 @@ const STUDIO: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'keyword', zh: '一词冲击', en: 'Keyword punch', fit: ['youtube', 'bilibili', 'bilibili-43', 'bilibili-hd', 'wide', ...SHORT, ...XHS], zhUse: '缩略图式三词标题:饱和蓝底 + 超粗白字 + 一块黄色关键词标签(副标题)+ 主体位上一枚浅色圆盘(有图时主体站在上面);不靠描边和爆炸星也抢眼', enUse: 'A three-word thumbnail: saturated blue, heavy white type, one yellow keyword tag (the subtitle) and a pale disc where the subject stands; loud without outlines or bursts',
|
||||
...posterDesignTemplate("keyword"),
|
||||
photo: true,
|
||||
slot: i => { const { w, h } = metrics(i); return w / h > 1.2 ? { x: w * 0.56, y: 0, w: w * 0.44, h, radius: 0 } : { x: 0, y: h * 0.5, w, h: h * 0.5, radius: 0 }; },
|
||||
build(i) {
|
||||
@@ -1003,7 +1003,7 @@ const STUDIO: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'riso', zh: '孔版印刷', en: 'Risograph', fit: [...XHS, ...BANNER, ...VIDEO], zhUse: '再生纸 + 荧光粉与蓝两种专色叠印 + 轻微错版 + 颗粒;独立杂志、展览、文艺与潮流话题', enUse: 'Recycled paper, fluorescent pink and blue overprinting with slight misregistration and grain; zines, shows and culture',
|
||||
...posterDesignTemplate("riso"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#f3eee4', bg2: '#f3eee4', ink: '#1f4fd1', sub: '#1f4fd1', accent: '#ff4fa3', accentInk: '#ffffff' });
|
||||
const { u, m, w, h } = metrics(i); const side = w / h > 1.2; const x0 = m * 1.1;
|
||||
@@ -1027,7 +1027,7 @@ const STUDIO: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ticket', zh: '票根', en: 'Ticket stub', fit: [...XHS, ...BANNER, ...VIDEO], zhUse: '一张带缺口和齿孔的入场券:主券写标题,存根放标签与条码;活动、课程、展览、旅行', enUse: 'An admission ticket with notches and perforation: headline on the main part, label and barcode on the stub; events, courses, travel',
|
||||
...posterDesignTemplate("ticket"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#ff6a3d', bg2: '#ff6a3d', ink: '#1f1a17', sub: '#6b6158', accent: '#ff6a3d', accentInk: '#fff6ea' });
|
||||
const { u, m, w, h } = metrics(i); const side = w / h > 1.05; const card = '#fff6ea';
|
||||
@@ -1058,7 +1058,7 @@ const STUDIO: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'window', zh: '窗口教程', en: 'App window', fit: [...VIDEO, ...XHS, ...BANNER], zhUse: '柔和渐变底上一扇 macOS 风格窗口:红黄绿三点 + 文件名标签 + 窗口里的大标题;教程、工具、编程、效率', enUse: 'A macOS-style window on a soft gradient: traffic lights, a file tab and the headline inside; tutorials, tools and code',
|
||||
...posterDesignTemplate("window"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#dfe3fb', bg2: '#dfe3fb', ink: '#111827', sub: '#4b5563', accent: '#6366f1', accentInk: '#ffffff' });
|
||||
const { u, m, w, h, wide } = metrics(i); const cx = m * (wide ? 1.4 : 0.9); const cw = w - cx * 2; const ch = h * (wide ? 0.76 : 0.6); const cy = (h - ch) / 2;
|
||||
@@ -1079,7 +1079,7 @@ const STUDIO: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'newspaper', zh: '报纸头版', en: 'Front page', fit: [...BANNER, ...XHS, ...VIDEO], zhUse: '新闻纸 + 衬线报头(标签做报名)+ 双线 + 期号栏 + 分栏灰条;把观点做成“头版头条”,评论、深度、年度盘点', enUse: 'Newsprint, a serif masthead (the label), double rules, a dateline and greeked columns; opinion, analysis, annual reviews',
|
||||
...posterDesignTemplate("newspaper"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#efeadf', bg2: '#efeadf', ink: '#141210', sub: '#4d4740', accent: '#b3261e', accentInk: '#ffffff' });
|
||||
const { u, m, w, h } = metrics(i); const side = w / h > 1.2; const x0 = m; const inner = w - m * 2;
|
||||
@@ -1106,7 +1106,7 @@ const STUDIO: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'polaroid', zh: '拍立得', en: 'Polaroid', fit: [...XHS, ...VIDEO, 'wechat'], zhUse: '一张贴着胶带的拍立得相纸(相纸里是图位)+ 标签写在相纸下沿 + 旁边大标题;旅行、Vlog、生活记录', enUse: 'A taped instant photo (the print is a picture slot), the label on its lip and a big headline beside it; travel, vlogs, everyday life',
|
||||
...posterDesignTemplate("polaroid"),
|
||||
slot: i => polaroidFrame(i).photo,
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#e7e0d3', bg2: '#e7e0d3', ink: '#2a241d', sub: '#6e6457', accent: '#d9653b', accentInk: '#ffffff' });
|
||||
@@ -1128,7 +1128,7 @@ const STUDIO: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'stack', zh: '叠字海报', en: 'Echo stack', fit: [...XHS, ...SHORT, ...BANNER], zhUse: '同一句标题上下重复、逐层变淡,中间一行是红色实字;字体海报的节奏感,适合短标题、口号、金句', enUse: 'The headline repeated in fading layers around one solid red line; a rhythmic type poster for short titles and slogans',
|
||||
...posterDesignTemplate("stack"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#ece6da', bg2: '#ece6da', ink: '#16130f', sub: '#5f574c', accent: '#e8432b', accentInk: '#ffffff' });
|
||||
const { u, m, w, h, wide } = metrics(i); const x0 = m; const tw = w - m * 2; const lh = 0.98;
|
||||
@@ -1147,7 +1147,7 @@ const STUDIO: Template[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'serial', zh: '栏目头图', en: 'Column header', fit: [...BANNER, ...VIDEO, ...XHS], zhUse: '深绿栏目条(栏目名 + 大期号,标签写“周刊 12”)+ 标题;公众号系列文章、周刊、播客的固定头图,转发裁成方图时标题仍在中间', enUse: 'A dark column band with the series name and a big issue number (label like “Weekly 12”) beside the headline; recurring newsletters and podcasts',
|
||||
...posterDesignTemplate("serial"),
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#f4f1ea', bg2: '#f4f1ea', ink: '#1a1a1a', sub: '#6b6560', accent: '#1f4d3a', accentInk: '#f4f1ea' });
|
||||
const { u, m, w, h } = metrics(i); const side = w / h > 1.25; const num = digits(i, false).padStart(2, '0');
|
||||
|
||||
+13
-2
@@ -4,11 +4,22 @@
|
||||
|
||||
Choose **More types → Poster (Beta)** to create an editable poster. In an ordinary note, choose **Create poster** from the note actions to create a separate poster using the title and selected text (or the first 240 characters of the body). The original note remains intact.
|
||||
|
||||
Web and desktop open a dedicated design workspace with 46 real templates adapted from Qiaomu Design's MIT-licensed layout engine, 12 platform presets, bundled licensed Chinese and Latin fonts, headline/subtitle/badge reflow, and actual rendered template previews. The template renderer and its attribution are in `apps/web/src/lib/poster-design`; font licenses ship alongside the font files. Generated raster grain is omitted when adapting vector templates.
|
||||
Web and desktop open a dedicated design workspace with 40 selectable templates adapted from Qiaomu Design's MIT-licensed layout engine, 12 platform presets, bundled licensed Chinese and Latin fonts, headline/subtitle/badge reflow, and actual rendered template previews. The template renderer and its attribution are in `apps/web/src/lib/poster-design`; font licenses ship alongside the font files. Generated raster grain is omitted when adapting vector templates.
|
||||
|
||||
The canvas supports text, badges, shapes, paths, freehand drawing and images; multi-selection, grouping, alignment, snapping, zoom and pan; layers, visibility and locking; typography, outlines, shadows, gradients and image fit/crop/flip. Export PNG, JPEG or WebP at 1×–3× (up to 8192 pixels per side), copy a PNG, or insert an exported image into the source note. Edit text directly on the canvas; the template panel has no separate copy form. Applying a template rebuilds its layout from the current canvas copy and retains separately added elements; undo restores the previous layout. Editing individual elements preserves the existing layout.
|
||||
|
||||
AI uses the shared assistant sidebar, with no separate AI panel in the poster workspace. The `get_poster` and `update_poster` tools let agents read the canvas and edit text, colors, typography and element positions through the existing agent action flow. Edits preserve unmentioned and locked elements and reject stale revisions. The editor regenerates the preview after an agent edit. This feature does not generate images.
|
||||
AI uses the shared assistant sidebar, with no separate AI panel in the poster workspace. ACP, external MCP and the built-in assistant share the poster tools. This feature does not generate AI images.
|
||||
|
||||
## AI design tools
|
||||
|
||||
- `get_poster_capabilities` discovers the editor's shared font and platform catalogs, selectable browser design templates, supported server templates, editable fields, operation limits and rendering availability. Browser template IDs and the four basic server template IDs are separate capabilities; their layouts are not interchangeable.
|
||||
- `create_poster_memo` creates valid editable source from concise copy and a basic template. `list_memos` and `search_memos` accept `poster: true`; `get_poster` returns canvas geometry and element IDs.
|
||||
- `update_poster` requires the revision returned by `get_poster` and supports `dryRun`. `edits` includes font family/style, underline, line height, character spacing, visibility, image crop/flip, shapes, strokes, shadows and gradients. Set optional shadow, gradient, crop or badge to `null` to remove it. Image references must be active image resources belonging to this same note.
|
||||
- Ordered `operations` add, remove or duplicate elements; reorder layers; group/ungroup; align unrotated elements to the canvas; and explicitly lock/unlock elements. Edits run before operations, so unlock in an earlier call before patching a locked element. Operations can explicitly unlock before a later operation in the same call. `canvas` optionally scales elements; `platform` uses a shared platform preset. Scaling locked elements is rejected.
|
||||
- `operations: [{op: "apply_template", template: "bold"}]` replaces **all** elements with a basic server template, preserving the main copy. It requires an explicit template-change request and rejects locked elements. Unlike the browser editor's template application, this operation does not retain separately added decorations.
|
||||
- `get_poster_preview` returns the current saved preview as actual image content for MCP and multimodal assistant tools. Edits invalidate its reference. Missing previews report `browser_render_required`; the tool does not render a new image. `export_poster` exports editable JSON source or the current saved preview, not a full-resolution re-render. Preview reads require `read:resources` as well as `read:memos` and are limited to 4 MiB. Fresh rendering is delegated to online desktop/Web clients with their bundled fonts; the server never runs Fabric.
|
||||
- `request_poster_render` queues a fresh PNG preview (long edge up to 1600 px, at most 4 MiB); optional `templateId` applies a full design template and retains separately added decorations, matching the editor. Requires `write:memos` and `write:resources`; pass the revision from `get_poster`. An authenticated desktop/Web workspace polls every 15 seconds and renders without opening that poster. `get_poster_render_job` reports pending/rendering/completed/failed; only completion confirms rendering. No online client means waiting, with pending expiry at 10 minutes and claimed expiry at 2 minutes. Claims are exclusive across clients; saving checks both the lease and memo revision atomically. Conflicts never overwrite newer source. Locked elements prevent template changes. Completed/failed tasks are retained for seven days and cleaned when new tasks are requested. Full-resolution PNG/JPEG/WebP export remains available in the editor.
|
||||
- `trash_memos` moves exact queried IDs into the recycle bin; `restore_memos` restores them. Ordinary `update_memo` can update metadata but cannot replace poster content.
|
||||
|
||||
## Source and persistence
|
||||
|
||||
|
||||
@@ -4,11 +4,22 @@
|
||||
|
||||
通过「更多类型 → 海报(Beta)」创建可编辑海报。在普通笔记操作菜单选择「制作海报」,会用标题及选中文字(未选中时取正文前 240 字)创建独立海报笔记,原笔记保留。
|
||||
|
||||
Web 和桌面端打开独立设计工作台,提供 46 套真实模板、12 种平台画幅、内置的中英文字体、主标题/副标题/标签自动排版及真实渲染的模板预览。版式引擎改编自 MIT 许可的 Qiaomu Design,源码及署名位于 `apps/web/src/lib/poster-design`;字体许可随字体文件提供。转换矢量模板时省略生成的栅格颗粒纹理。
|
||||
Web 和桌面端打开独立设计工作台,提供 40 套可选模板、12 种平台画幅、内置的中英文字体、主标题/副标题/标签自动排版及真实渲染的模板预览。版式引擎改编自 MIT 许可的 Qiaomu Design,源码及署名位于 `apps/web/src/lib/poster-design`;字体许可随字体文件提供。转换矢量模板时省略生成的栅格颗粒纹理。
|
||||
|
||||
画布支持文字、标签、形状、曲线、手绘和图片;多选、编组、对齐吸附、缩放和平移;图层排序、显示隐藏和锁定;字体排版、描边、阴影、渐变及图片适应/裁切/翻转。可按 1~3 倍输出 PNG、JPEG 或 WebP(每边不超过 8192 像素),复制 PNG 或插入原笔记。直接在画布上编辑文字,模板面板不再提供独立文案表单。应用模板时沿用当前画布文案重新排版,并保留独立添加的元素;撤销可恢复之前的布局。直接编辑元素时保留当前排版。
|
||||
|
||||
AI 统一使用通用助手右侧栏,海报工作台不再提供独立 AI 面板。Agent 通过 `get_poster` 和 `update_poster` 读取画布、修改文案、配色、字体排版及元素位置,沿用现有 Agent 操作流程。修改保留未指定和已锁定的元素,版本过期时拒绝覆盖;Agent 修改后由编辑器重新生成预览。本功能不生成图片。
|
||||
AI 统一使用通用助手右侧栏,海报工作台不再提供独立 AI 面板。ACP、外部 MCP 和内置助手共用海报工具。本功能不生成 AI 图片。
|
||||
|
||||
## AI 设计工具
|
||||
|
||||
- `get_poster_capabilities` 查询与编辑器共用的字体、平台目录、可选浏览器设计模板,以及服务端模板、可编辑字段、操作限制和渲染可用性。浏览器模板编号与四种基础服务端模板属于不同能力,不能互换其版式。
|
||||
- `create_poster_memo` 用短文案和基础模板创建合法可编辑源稿。`list_memos`、`search_memos` 支持 `poster: true` 筛选;`get_poster` 返回画布几何与元素编号。
|
||||
- `update_poster` 必须传入 `get_poster` 返回的版本,支持 `dryRun` 预演。`edits` 开放字体、斜体、下划线、行距、字距、显示隐藏、图片裁剪/翻转、形状、描边、阴影及渐变;可将阴影、渐变、裁剪或标签样式设为 `null` 移除。图片必须是属于当前笔记的有效图片资源。
|
||||
- 有序 `operations` 支持元素新增、删除、复制,图层排序,编组/解除编组,未旋转元素相对画布对齐,以及显式锁定/解锁。先执行字段修改,再执行操作,因此修改锁定元素的字段前需在先前调用中解锁;同一次操作列表也可以显式解锁后再执行后续操作。`canvas` 可选择是否缩放元素,`platform` 使用共用平台规格;拒绝缩放锁定元素。
|
||||
- `operations: [{op: "apply_template", template: "bold"}]` 用基础服务端模板替换**全部**元素,保留主要文案。必须有用户明确换模板的请求,存在锁定元素时拒绝。与浏览器编辑器的模板应用不同,此操作不会保留独立添加的装饰元素。
|
||||
- `get_poster_preview` 向 MCP 和多模态助手返回当前已保存预览的真实图片内容,修改后清除旧预览引用。没有预览时返回 `browser_render_required`,工具不会重新渲染图片。`export_poster` 导出可编辑 JSON 源稿或当前已保存预览,不是全分辨率重渲染。读取预览需要 `read:resources` 和 `read:memos`,上限 4 MiB。新图片由在线桌面/Web 客户端和内置字体渲染,服务端不运行 Fabric。
|
||||
- `request_poster_render` 排队生成新 PNG 预览(长边最高 1600 像素、最多 4 MiB);可选 `templateId` 应用完整设计模板,像编辑器一样保留独立添加的装饰。需要 `write:memos` 和 `write:resources`,传入 `get_poster` 返回的版本。已登录桌面/Web 工作区每 15 秒领取任务,无需打开该海报。`get_poster_render_job` 返回 pending/rendering/completed/failed,只有完成才代表渲染成功。无在线客户端时等待,未领取任务 10 分钟过期,领取后 2 分钟过期。跨客户端独占领取,保存事务同时校验租约和笔记版本,冲突不会覆盖新源稿。锁定元素阻止更换模板。完成/失败任务保留七天,在创建新任务时清理。全分辨率 PNG/JPEG/WebP 导出仍使用编辑器。
|
||||
- `trash_memos` 按查询获得的准确编号移入回收站,`restore_memos` 恢复。普通 `update_memo` 可以修改元数据,不能替换海报内容。
|
||||
|
||||
## 源稿与保存
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE poster_render_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
memo_id TEXT NOT NULL REFERENCES memos(id) ON DELETE CASCADE,
|
||||
expected_revision INTEGER NOT NULL,
|
||||
template_id TEXT,
|
||||
status TEXT NOT NULL CHECK(status IN ('pending','rendering','completed','failed')),
|
||||
claim_token TEXT,
|
||||
expires_at TEXT NOT NULL,
|
||||
result_revision INTEGER,
|
||||
error_code TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
commit_guard INTEGER NOT NULL DEFAULT 1 CHECK(commit_guard = 1)
|
||||
);
|
||||
CREATE INDEX idx_poster_render_pending ON poster_render_jobs(workspace_id, status, created_at);
|
||||
CREATE UNIQUE INDEX idx_poster_render_active ON poster_render_jobs(workspace_id, memo_id)
|
||||
WHERE status IN ('pending','rendering');
|
||||
@@ -1826,6 +1826,8 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
|
||||
});
|
||||
},
|
||||
|
||||
claimPosterRender: () => request<{ job: null | { id: string; memoId: string; expectedRevision: number; templateId: string | null; claimToken: string; expiresAt: string } }>("/api/v1/poster-render/claim", { method: "POST", body: "{}" }),
|
||||
completePosterRender: (id: string, input: { claimToken: string; document?: import("@edgeever/shared").PosterDocument; errorCode?: "render_failed" | "revision_conflict" | "client_timeout" }) => request<{ job: { status: string } }>(`/api/v1/poster-render/${encodeURIComponent(id)}/complete`, { method: "POST", body: JSON.stringify(input) }),
|
||||
uploadMemoResourceParts,
|
||||
|
||||
updateMemo: (
|
||||
|
||||
@@ -63,3 +63,7 @@ export { attachDiagramScroll } from "./diagram-scroll";
|
||||
export * from "./front-matter";
|
||||
|
||||
export * from "./poster";
|
||||
|
||||
export * from "./poster-fonts";
|
||||
export * from "./poster-platforms";
|
||||
export * from "./poster-design-catalog";
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,87 @@
|
||||
export const POSTER_FONTS = [
|
||||
{
|
||||
family: "思源宋体",
|
||||
file: "notoserif",
|
||||
zh: "思源宋体 · 正文",
|
||||
en: "Source Han Serif",
|
||||
},
|
||||
{ family: "Anton", file: "anton", zh: "Anton · 冲击", en: "Anton" },
|
||||
{
|
||||
family: "抖音美好体",
|
||||
file: "douyin",
|
||||
zh: "抖音美好体",
|
||||
en: "Douyin Display",
|
||||
},
|
||||
{
|
||||
family: "马善政楷书",
|
||||
file: "mashan",
|
||||
zh: "马善政楷书",
|
||||
en: "Ma Shan Zheng",
|
||||
},
|
||||
{
|
||||
family: "江城圆体",
|
||||
file: "jiangcheng",
|
||||
zh: "江城圆体",
|
||||
en: "Jiangcheng Rounded",
|
||||
},
|
||||
{
|
||||
family: "站酷庆科黄油体",
|
||||
file: "huangyou",
|
||||
zh: "站酷庆科黄油体",
|
||||
en: "ZCOOL QingKe HuangYou",
|
||||
},
|
||||
{ family: "未来荧黑", file: "glow", zh: "未来荧黑", en: "Glow Sans" },
|
||||
{ family: "朱雀仿宋", file: "zhuque", zh: "朱雀仿宋", en: "Zhuque FangSong" },
|
||||
{
|
||||
family: "站酷快乐体",
|
||||
file: "kuaile",
|
||||
zh: "站酷快乐体",
|
||||
en: "ZCOOL KuaiLe",
|
||||
},
|
||||
|
||||
{
|
||||
family: "思源黑体",
|
||||
file: "notosans",
|
||||
zh: "思源黑体 · 正文",
|
||||
en: "Source Han Sans",
|
||||
},
|
||||
{
|
||||
family: "思源黑体 Heavy",
|
||||
file: "notosans-heavy",
|
||||
zh: "思源黑体 · 特重",
|
||||
en: "Source Han Sans Heavy",
|
||||
},
|
||||
{
|
||||
family: "思源宋体 Heavy",
|
||||
file: "notoserif-heavy",
|
||||
zh: "思源宋体 · 特重",
|
||||
en: "Source Han Serif Heavy",
|
||||
},
|
||||
{ family: "得意黑", file: "smiley", zh: "得意黑 · 标题", en: "Smiley Sans" },
|
||||
{
|
||||
family: "霞鹜文楷",
|
||||
file: "wenkai",
|
||||
zh: "霞鹜文楷 · 手写",
|
||||
en: "LXGW WenKai",
|
||||
},
|
||||
{ family: "Inter", file: "inter", zh: "Inter · 无衬线", en: "Inter" },
|
||||
{
|
||||
family: "DM Serif Display",
|
||||
file: "dmserif",
|
||||
zh: "DM Serif · 衬线",
|
||||
en: "DM Serif Display",
|
||||
},
|
||||
{ family: "Bebas Neue", file: "bebas", zh: "Bebas · 窄体", en: "Bebas Neue" },
|
||||
{
|
||||
family: "Space Grotesk",
|
||||
file: "spacegrotesk",
|
||||
zh: "Space Grotesk · 科技",
|
||||
en: "Space Grotesk",
|
||||
},
|
||||
{
|
||||
family: "JetBrains Mono",
|
||||
file: "jetbrains",
|
||||
zh: "JetBrains · 等宽",
|
||||
en: "JetBrains Mono",
|
||||
},
|
||||
] as const;
|
||||
@@ -0,0 +1,56 @@
|
||||
// Adapted from Qiaomu Design (MIT), same platform catalog used by the editor.
|
||||
/** Platform presets: sizes, feed overlays that cover parts of a cover, and file-size limits. */
|
||||
export interface Zone { x: number; y: number; w: number; h: number; zh: string; en: string }
|
||||
export interface Platform {
|
||||
id: string; zh: string; en: string; icon: string; group: 'social' | 'video' | 'general';
|
||||
width: number; height: number; zhHint: string; enHint: string;
|
||||
/** Inner text-safe margin as a fraction of the shorter side. */
|
||||
margin: number;
|
||||
/** Areas the platform usually draws over the cover (timestamps, counters, buttons). */
|
||||
avoid: Zone[];
|
||||
/** Upload limit in bytes when the platform has a documented one. */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
export const PLATFORMS: Platform[] = [
|
||||
{ id: 'xhs', zh: '小红书 · 3:4', en: 'Xiaohongshu · 3:4', icon: 'book-heart', group: 'social', width: 1080, height: 1440, margin: 0.06, avoid: [],
|
||||
zhHint: '图文笔记封面,3:4 竖版最常用', enHint: 'Note cover, 3:4 portrait is the standard' },
|
||||
{ id: 'xhs-square', zh: '小红书 · 1:1', en: 'Xiaohongshu · 1:1', icon: 'square', group: 'social', width: 1080, height: 1080, margin: 0.07, avoid: [],
|
||||
zhHint: '方形封面,信息流中更紧凑', enHint: 'Square cover, more compact in the feed' },
|
||||
{ id: 'youtube', zh: 'YouTube 缩略图', en: 'YouTube thumbnail', icon: 'play', group: 'video', width: 1280, height: 720, margin: 0.04, maxBytes: 2 * 1024 * 1024,
|
||||
avoid: [{ x: 0.82, y: 0.86, w: 0.18, h: 0.14, zh: '时长标签', en: 'Duration' }],
|
||||
zhHint: '1280×720,文件不超过 2MB,右下角会被时长遮挡', enHint: '1280×720, under 2MB; the duration badge covers the bottom-right corner' },
|
||||
{ id: 'bilibili', zh: 'B 站封面 · 16:10', en: 'Bilibili cover · 16:10', icon: 'tv', group: 'video', width: 1146, height: 717, margin: 0.04,
|
||||
avoid: [{ x: 0, y: 0.84, w: 1, h: 0.16, zh: '播放数据条', en: 'Stats bar' }],
|
||||
zhHint: '1146×717,信息流底部会叠加播放与弹幕数据', enHint: '1146×717; the feed overlays play counts along the bottom' },
|
||||
{ id: 'bilibili-43', zh: 'B 站投稿 · 4:3', en: 'Bilibili upload · 4:3', icon: 'tv', group: 'video', width: 1200, height: 900, margin: 0.05,
|
||||
avoid: [{ x: 0, y: 0, w: 1, h: 0.08, zh: '信息流裁切', en: 'Cropped in feed' }, { x: 0, y: 0.78, w: 1, h: 0.22, zh: '裁切 + 播放数据条', en: 'Crop + stats bar' }],
|
||||
zhHint: '1200×900 投稿封面;信息流里按 16:10 裁切,文字放在中间 80% 内', enHint: '1200×900 upload cover; the feed crops it to 16:10, keep type in the middle band' },
|
||||
{ id: 'bilibili-hd', zh: 'B 站 · 16:9 高清', en: 'Bilibili · 16:9 HD', icon: 'tv', group: 'video', width: 1920, height: 1080, margin: 0.04,
|
||||
avoid: [{ x: 0, y: 0.86, w: 1, h: 0.14, zh: '播放数据条', en: 'Stats bar' }],
|
||||
zhHint: '1920×1080,横版高清封面', enHint: '1920×1080 widescreen cover' },
|
||||
{ id: 'vertical', zh: '抖音 / 视频号 · 9:16', en: 'Douyin / Shorts · 9:16', icon: 'smartphone', group: 'video', width: 1080, height: 1920, margin: 0.06,
|
||||
avoid: [{ x: 0, y: 0.78, w: 0.8, h: 0.22, zh: '标题与账号', en: 'Caption' }, { x: 0.84, y: 0.5, w: 0.16, h: 0.4, zh: '互动按钮', en: 'Buttons' }],
|
||||
zhHint: '竖屏短视频封面,底部和右侧会被界面遮挡', enHint: 'Vertical video; captions and buttons cover the bottom and right' },
|
||||
{ id: 'wechat', zh: '公众号头图 · 2.35:1', en: 'WeChat banner · 2.35:1', icon: 'message-circle', group: 'social', width: 1410, height: 600, margin: 0.05, avoid: [],
|
||||
zhHint: '文章头图,列表中会被裁为 1:1 小图,主体放中间', enHint: 'Article banner; the feed crops a square from the centre' },
|
||||
{ id: 'x', zh: 'X 封面 · 5:2', en: 'X header · 5:2', icon: 'at-sign', group: 'social', width: 1500, height: 600, margin: 0.05, avoid: [{ x: 0, y: 0.55, w: 0.3, h: 0.45, zh: '头像', en: 'Avatar' }],
|
||||
zhHint: '个人主页横幅,头像会遮住左下角', enHint: 'Profile header; the avatar covers the lower-left' },
|
||||
{ id: 'square', zh: '方形 · 1:1', en: 'Square · 1:1', icon: 'square', group: 'general', width: 1080, height: 1080, margin: 0.06, avoid: [], zhHint: '通用方形', enHint: 'General square' },
|
||||
{ id: 'wide', zh: '横版 · 16:9', en: 'Landscape · 16:9', icon: 'rectangle-horizontal', group: 'general', width: 1920, height: 1080, margin: 0.05, avoid: [], zhHint: '通用横版', enHint: 'General landscape' },
|
||||
{ id: 'portrait', zh: '竖版 · 3:4', en: 'Portrait · 3:4', icon: 'rectangle-vertical', group: 'general', width: 1200, height: 1600, margin: 0.06, avoid: [], zhHint: '通用竖版', enHint: 'General portrait' },
|
||||
];
|
||||
|
||||
export const DEFAULT_PLATFORM = 'xhs';
|
||||
export function platformById(id: string | undefined): Platform | undefined { return PLATFORMS.find(p => p.id === id); }
|
||||
export function platformFor(width: number, height: number, id?: string): Platform | undefined {
|
||||
const exact = platformById(id);
|
||||
if (exact && exact.width === width && exact.height === height) return exact;
|
||||
return PLATFORMS.find(p => p.width === width && p.height === height);
|
||||
}
|
||||
export function platformName(p: Platform, zh: boolean): string { return zh ? p.zh : p.en; }
|
||||
export const GROUPS: { id: Platform['group']; zh: string; en: string }[] = [
|
||||
{ id: 'social', zh: '图文平台', en: 'Social' },
|
||||
{ id: 'video', zh: '视频平台', en: 'Video' },
|
||||
{ id: 'general', zh: '通用比例', en: 'General' },
|
||||
];
|
||||
Reference in new issue
Block a user