tomdacatto1's picture
Add the comic maker
69944da verified
Raw History Blame Contribute Delete
5.63 kB
// Pure comic logic: parsing and repairing the script, page layout, text
// wrapping and prompts. No DOM and no network, so it is unit-tested directly.
export class ScriptError extends Error {}
const clean = (value, max) => (typeof value === "string" ? value.replace(/\s+/g, " ").trim().slice(0, max) : "");
// Take the outermost JSON object out of a model reply. Models sometimes add
// code fences, prose or trailing commas; this removes the first two and the commas.
export function extractObject(raw) {
const text = String(raw ?? "").replace(/```(?:json)?/gi, "");
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
if (start < 0 || end < start) throw new ScriptError("no JSON object in the reply");
return text.slice(start, end + 1).replace(/,(\s*[}\]])/g, "$1");
}
// Validate and normalise a script. Lengths are clamped, optional fields
// default, extra panels are dropped and invalid characters or speech are removed.
// Throws ScriptError when the script cannot be used, so the caller can retry.
export function parseScript(raw, panelCount) {
let data;
try {
data = JSON.parse(extractObject(raw));
} catch (err) {
if (err instanceof ScriptError) throw err;
throw new ScriptError(`invalid JSON (${err.message})`);
}
if (!data || typeof data !== "object") throw new ScriptError("the reply is not a JSON object");
const panels = Array.isArray(data.panels) ? data.panels : [];
if (panels.length < panelCount) {
throw new ScriptError(`expected ${panelCount} panels, got ${panels.length}`);
}
const characters = (Array.isArray(data.characters) ? data.characters : [])
.map((c) => ({ name: clean(c?.name, 24), look: clean(c?.look, 240) }))
.filter((c) => c.name && c.look)
.slice(0, 3);
return {
title: clean(data.title, 60) || "Untitled comic",
characters,
panels: panels.slice(0, panelCount).map((panel, i) => {
const scene = clean(panel?.scene, 280);
if (!scene) throw new ScriptError(`panel ${i + 1} has no scene`);
const speech = panel.speech && typeof panel.speech === "object" ? panel.speech : null;
const text = clean(speech?.text, 120);
return {
scene,
caption: clean(panel.caption, 140),
speech: text ? { who: clean(speech.who, 24), text } : null,
};
}),
};
}
export function scriptMessages({ idea, style, panelCount, system }) {
return [
{ role: "system", content: system },
{
role: "user",
content: `Idea: ${idea}\nLook: ${style.label}.\nWrite exactly ${panelCount} panels.`,
},
];
}
// After an unusable reply, show the model its own answer and why it was rejected.
export function retryMessages(messages, rejectedReply, reason) {
return [
...messages,
{ role: "assistant", content: rejectedReply },
{
role: "user",
content: `That reply was rejected: ${reason}. Reply again with only the corrected JSON object, in the same shape.`,
},
];
}
// Each panel's prompt repeats the style and every character's look so the
// same people and palette appear in each panel.
export function panelPrompt(script, index, style) {
const cast = script.characters.map((c) => `${c.name}: ${c.look}`).join("; ");
return [
`${style.look}.`,
cast ? `Characters, keep them identical in every panel: ${cast}.` : "",
`Scene: ${script.panels[index].scene}.`,
"One single illustration with no text, no lettering and no speech bubbles.",
]
.filter(Boolean)
.join(" ");
}
// Grid placement for a comic page. The last panel stretches across the row
// when the panel count does not fill it, so the page never has a gap.
export function layoutPage(count, columns = 2) {
const cells = Array.from({ length: count }, (_, i) => ({
row: Math.floor(i / columns),
col: i % columns,
span: 1,
}));
const left = count % columns;
if (count && left) cells[count - 1].span = columns - left + 1;
return cells;
}
// Speech bubbles alternate sides so the reading order zigzags down the page.
export const bubbleSide = (index) => (index % 2 ? "right" : "left");
// Greedy word wrap. `measure(text)` returns the width of a string, so the
// function stays independent of the canvas or the DOM. A word wider than the
// line is broken by character.
export function wrapText(text, maxWidth, measure) {
const lines = [];
let line = "";
for (const word of String(text).split(/\s+/).filter(Boolean)) {
const candidate = line ? `${line} ${word}` : word;
if (measure(candidate) <= maxWidth) {
line = candidate;
continue;
}
if (line) lines.push(line);
let rest = word;
while (measure(rest) > maxWidth && rest.length > 1) {
let cut = rest.length - 1;
while (cut > 1 && measure(rest.slice(0, cut)) > maxWidth) cut--;
lines.push(rest.slice(0, cut));
rest = rest.slice(cut);
}
line = rest;
}
if (line) lines.push(line);
return lines;
}
// Share text carries the title, the first line of narration and the Space's
// own URL. It never includes a key.
export function shareText(script, url) {
const line = script.panels.map((p) => p.caption || p.speech?.text || "").find(Boolean);
return `${script.title}${line ? `: "${line}"` : ""}\nMade from one prompt with Pollinations: ${url}`;
}