Download comic.js from tomdacatto1/pollinations-comic-maker: direct link, hf CLI and curl.
- Browser
- Download file 5.63 kB
-
https://huggingface.co/spaces/tomdacatto1/pollinations-comic-maker/resolve/main/comic.js
- Command line
-
hf download hf://spaces/tomdacatto1/pollinations-comic-maker/comic.js
-
curl -L -o comic.js https://huggingface.co/spaces/tomdacatto1/pollinations-comic-maker/resolve/main/comic.js
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}`; | |
| } | |