From 47cb7646f75cc8b0eb8ac67c2fa87271d1dc1c29 Mon Sep 17 00:00:00 2001 From: elasticdotventures Date: Tue, 19 May 2026 09:58:50 +0000 Subject: [PATCH 1/5] Update comic rendering pipeline --- cast/characters.ts | 186 +++++- functions/api/vote.ts | 6 +- functions/lib/agentic-comic-workflow.ts | 387 ++++++++++++- functions/lib/cast.ts | 3 + functions/lib/comic-generator.ts | 222 +++++++- functions/lib/ledgrrr-wasm/index.ts | 2 +- ...=> ledger_workflow_wasm_bg.wasm.types.txt} | 0 functions/lib/local-bootstrap-comic.ts | 16 +- functions/lib/svg-prompt-generator.ts | 30 +- functions/lib/svg-renderer.ts | 531 ++++++++++++++++-- scripts/test-api-contracts.mjs | 9 + src/components/ComicViewer.vue | 30 + src/components/TheXTerm.vue | 116 +++- src/main.ts | 16 +- src/types/vue-web-terminal.d.ts | 4 + wrangler.toml | 13 +- 16 files changed, 1458 insertions(+), 113 deletions(-) rename functions/lib/ledgrrr-wasm/{ledger_workflow_wasm_bg.wasm.d.ts => ledger_workflow_wasm_bg.wasm.types.txt} (100%) diff --git a/cast/characters.ts b/cast/characters.ts index 2754fbe..1833329 100644 --- a/cast/characters.ts +++ b/cast/characters.ts @@ -10,6 +10,21 @@ const castData = [ 'plain stick body', 'neutral posture' ], + behaviors: [ + 'asks one dangerously underspecified question', + 'points at the wrong abstraction', + 'mistakes a symptom for the root cause' + ], + idea_space: [ + 'requirements gaps', + 'ambiguous prompts', + 'production surprises' + ], + drawable_features: [ + 'round head with expressive brows', + 'pointing arm', + 'worried sweat mark' + ], sample_image: '/cast/samples/user.svg' }, { @@ -24,19 +39,54 @@ const castData = [ 'minimal stick body', 'cloud thought bubble for internal logs' ], + behaviors: [ + 'shows a compact internal log trail', + 'confidently optimizes the wrong objective', + 'turns uncertainty into plausible procedure' + ], + idea_space: [ + 'inference drift', + 'tool calling mistakes', + 'context loss', + 'reward hacking' + ], + drawable_features: [ + 'square robot head', + 'antenna', + 'monospace thought bubble', + 'robot eyes and mouth' + ], sample_image: '/cast/samples/robot.svg' }, { id: 'simon', name: 'Simon', role: 'BOFH system administrator', - description: 'Stick figure with a fedora and a grey goatee. Arrives late, judges instantly, and ends scenes with one-line operational truth.', + description: 'Stick figure with square glasses, a fedora, and a grey goatee. Arrives late, judges instantly, and ends scenes with one-line operational truth.', voice: 'Dry, cynical, and surgically concise.', visual_traits: [ 'fedora', + 'square glasses', 'grey goatee', 'deadpan posture' ], + behaviors: [ + 'corrects the premise in one sentence', + 'refuses magical thinking', + 'names the operational failure everyone is avoiding' + ], + idea_space: [ + 'postmortems', + 'access control', + 'on-call reality', + 'logs versus facts' + ], + drawable_features: [ + 'square glasses', + 'fedora brim', + 'grey goatee', + 'flat deadpan mouth' + ], sample_image: '/cast/samples/simon.svg' }, { @@ -50,6 +100,23 @@ const castData = [ 'animated arm pose', 'boardroom energy' ], + behaviors: [ + 'turns an outage into a KPI', + 'asks for autonomy without ownership', + 'treats dashboards as reality' + ], + idea_space: [ + 'AI strategy decks', + 'cost theater', + 'meeting artifacts', + 'risk laundering' + ], + drawable_features: [ + 'tie', + 'raised arms', + 'smug smile', + 'slide deck or status table' + ], sample_image: '/cast/samples/boss.svg' }, { @@ -63,7 +130,124 @@ const castData = [ 'raised claws', 'background cameo' ], + behaviors: [ + 'silently appears where memory safety matters', + 'signals panic by raising claws', + 'acts as a tiny systems-level conscience' + ], + idea_space: [ + 'memory safety', + 'ownership', + 'compiler errors', + 'fearless concurrency' + ], + drawable_features: [ + 'small crab body', + 'claws', + 'tiny eyes', + 'panic marks' + ], sample_image: '/cast/samples/ferris.svg' + }, + { + id: 'tux', + name: 'Tux', + role: 'Linux penguin infra mascot', + description: 'A compact black-and-white penguin with a white belly, flipper arms, and tiny feet. Calmly represents Linux, kernels, packages, filesystems, and server pragmatism.', + voice: 'Calm, literal, and command-line practical.', + visual_traits: [ + 'penguin silhouette', + 'white belly', + 'flipper arms', + 'small feet' + ], + behaviors: [ + 'reduces drama to a shell command', + 'cares about permissions, packages, kernels, and filesystems', + 'stares blankly at cloud abstractions that forgot the host' + ], + idea_space: [ + 'Linux permissions', + 'package managers', + 'systemd timers', + 'kernel limits', + 'filesystem reality' + ], + drawable_features: [ + 'black penguin outline', + 'white belly oval', + 'flippers', + 'small feet', + 'beak' + ], + sample_image: '/cast/samples/tux.svg' + }, + { + id: 'python', + name: 'Python', + role: 'Python snake runtime', + description: 'A long snake character with a forked tongue and looped body. Friendly until dependency resolution, virtual environments, or whitespace semantics enter the panel.', + voice: 'Helpful, sly, and slightly too comfortable with dynamic behavior.', + visual_traits: [ + 'curving snake body', + 'forked tongue', + 'small expressive eyes', + 'looped posture' + ], + behaviors: [ + 'wraps around dependencies or stack traces', + 'suggests a tiny script that becomes infrastructure', + 'makes dynamic behavior sound reasonable' + ], + idea_space: [ + 'virtual environments', + 'dependency pins', + 'notebooks in production', + 'indentation', + 'runtime surprises' + ], + drawable_features: [ + 'curving snake path', + 'forked tongue', + 'round eyes', + 'coiled tail', + 'tiny frown or grin' + ], + sample_image: '/cast/samples/python.svg' + }, + { + id: 'kube_captain', + name: 'Kubernetes Captain', + role: 'container orchestration pirate captain', + description: 'A Kubernetes-themed captain with a pirate captain hat, peg leg, and swagger. Talks in nautical orchestration metaphors without losing technical specificity.', + voice: 'Commanding, nautical, and alarmingly comfortable with YAML.', + visual_traits: [ + 'pirate captain hat', + 'peg leg', + 'captain coat', + 'orchestration swagger' + ], + behaviors: [ + 'orders pods around like a nervous crew', + 'blames YAML, probes, and rollout strategy before admitting mutiny', + 'turns cluster incidents into nautical chain-of-command problems' + ], + idea_space: [ + 'pods', + 'deployments', + 'readiness probes', + 'rollouts', + 'service meshes', + 'cluster drift' + ], + drawable_features: [ + 'large pirate captain hat', + 'peg leg', + 'coat outline', + 'pointing captain arm', + 'tiny ship-wheel or pod label' + ], + sample_image: '/cast/samples/kube-captain.svg' } ] as const; diff --git a/functions/api/vote.ts b/functions/api/vote.ts index 2e3ff7c..79d6de8 100644 --- a/functions/api/vote.ts +++ b/functions/api/vote.ts @@ -25,7 +25,7 @@ export async function onRequestPost(context: any) { } const comic = await env.DB.prepare( - 'SELECT day FROM comics WHERE day = ?' + 'SELECT day, model_a, model_b FROM comics WHERE day = ?' ).bind(body.day).first(); if (!comic) { @@ -63,6 +63,10 @@ export async function onRequestPost(context: any) { return Response.json({ success: true, + selected: { + variant: body.variant, + model: body.variant === 'a' ? comic.model_a : comic.model_b + }, votes: { a: votesA, b: votesB } }); diff --git a/functions/lib/agentic-comic-workflow.ts b/functions/lib/agentic-comic-workflow.ts index 3254b38..63cfb8e 100644 --- a/functions/lib/agentic-comic-workflow.ts +++ b/functions/lib/agentic-comic-workflow.ts @@ -1,11 +1,22 @@ import { CAST, getCharacterById, pickCharactersExcluding, type CastCharacter } from './cast.ts'; -import { generateComicScript, type ComicScript } from './comic-generator.ts'; +import { generateComicScript, type ComicImprovMenu, type ComicScript } from './comic-generator.ts'; import { renderComicToSVG } from './svg-renderer.ts'; import { invokeWorkflow, type AuditEntry } from './ledgrrr-mcp-client.ts'; const DEFAULT_SCRIPT_MODEL_A = '@cf/deepseek-ai/deepseek-r1-distill-qwen-32b'; const DEFAULT_SCRIPT_MODEL_B = '@cf/meta/llama-3.3-70b-instruct-fp8-fast'; const DEFAULT_TOPIC_MODEL = '@cf/qwen/qwen3-30b-a3b-fp8'; +const DEFAULT_SCRIPT_MODEL_LINEUP = [ + '@cf/openai/gpt-oss-120b', + '@cf/moonshotai/kimi-k2.6', + '@cf/nvidia/nemotron-3-120b-a12b', + '@cf/qwen/qwq-32b', + '@cf/qwen/qwen3-30b-a3b-fp8', + '@cf/deepseek-ai/deepseek-r1-distill-qwen-32b', + '@cf/meta/llama-3.3-70b-instruct-fp8-fast', + '@cf/meta/llama-4-scout-17b-16e-instruct', + '@cf/google/gemma-4-26b-a4b-it' +]; const FALLBACK_TOPICS = [ 'prompt injection incident in production', @@ -17,7 +28,147 @@ const FALLBACK_TOPICS = [ 'mysterious authentication timeout after lunch', 'cost optimization meeting that increases costs', 'SRE on-call handoff gone sideways', - 'passive aggressive status page update' + 'passive aggressive status page update', + 'Python notebook becomes the production scheduler', + 'Linux permissions explain the cloud outage', + 'readiness probe passes by avoiding the service', + 'virtual environment copied into a container image', + 'runbook optimized until no steps remain', + 'service mesh debug session with too many mirrors', + 'cron job timezone argument at the postmortem', + 'dependency resolver negotiates with yesterday', + 'feature flag dashboard lies by omission', + 'secret rotation scheduled during incident response', + 'observability bill outgrows the application', + 'YAML anchor creates a leadership structure' +]; + +const SCENARIO_SETUPS = [ + { + id: 'postmortem-whiteboard', + label: 'postmortem whiteboard with a wrong causal arrow', + sceneHints: ['whiteboard', 'incident_room', 'meeting'], + prop: 'wrong causal arrow', + tension: 'everyone agrees on the remediation before identifying the cause' + }, + { + id: 'deploy-terminal', + label: 'deploy terminal beside a suspiciously cheerful status page', + sceneHints: ['terminal', 'incident_room', 'network'], + prop: 'green status page', + tension: 'the dashboard is healthy because the broken service stopped reporting' + }, + { + id: 'architecture-review', + label: 'architecture review where every box is labeled temporary', + sceneHints: ['whiteboard', 'network', 'meeting'], + prop: 'temporary boxes', + tension: 'the workaround has more governance than the system' + }, + { + id: 'standup-escalation', + label: 'standup meeting with a single ticket spanning the wall', + sceneHints: ['meeting', 'desk', 'terminal'], + prop: 'oversized ticket', + tension: 'the estimate is precise because nobody understands the work' + }, + { + id: 'cache-mystery', + label: 'network diagram where the cache is drawn as a trap door', + sceneHints: ['network', 'whiteboard', 'terminal'], + prop: 'cache trap door', + tension: 'the fix works only for requests that already worked' + }, + { + id: 'prompt-lab', + label: 'prompt debugging desk covered in tiny failed hypotheses', + sceneHints: ['desk', 'terminal', 'whiteboard'], + prop: 'failed hypotheses', + tension: 'the prompt is stable until it reads the requirements' + }, + { + id: 'filesystem-autopsy', + label: 'server desk where permission bits are bigger than the cloud diagram', + sceneHints: ['desk', 'terminal', 'whiteboard'], + prop: 'oversized permission bits', + tension: 'the expensive platform problem is actually chmod' + }, + { + id: 'dependency-knot', + label: 'dependency graph knotted around a production notebook', + sceneHints: ['whiteboard', 'desk', 'terminal'], + prop: 'dependency knot', + tension: 'the one-line helper script has become the release process' + }, + { + id: 'cluster-bridge', + label: 'Kubernetes bridge where pods are labeled like an anxious crew', + sceneHints: ['network', 'incident_room', 'terminal'], + prop: 'mutinying pods', + tension: 'the rollout strategy assumes the containers will obey orders' + }, + { + id: 'billing-forensics', + label: 'cost dashboard projected over a tiny useful service', + sceneHints: ['meeting', 'network', 'whiteboard'], + prop: 'ballooning invoice', + tension: 'the monitoring is more available than the product' + }, + { + id: 'secret-rotation', + label: 'incident room where every sticky note says rotated?', + sceneHints: ['incident_room', 'terminal', 'desk'], + prop: 'rotated secret notes', + tension: 'nobody knows which credential is old enough to trust' + }, + { + id: 'timezone-cron', + label: 'desk calendar arguing with a cron log', + sceneHints: ['desk', 'terminal', 'incident_room'], + prop: 'timezone calendar', + tension: 'the job ran exactly on schedule in the wrong reality' + }, + { + id: 'flag-museum', + label: 'feature flag dashboard arranged like an archaeological dig', + sceneHints: ['whiteboard', 'meeting', 'terminal'], + prop: 'ancient feature flags', + tension: 'every safety switch is load-bearing' + } +]; + +const PROP_ENTROPY = [ + 'packet capture printout', + 'sticky-note causal chain', + 'tiny pager with huge alarm lines', + 'half-erased runbook', + 'labeled blast-radius circle', + 'dependency lockfile scroll', + 'service map with crossed arrows', + 'rotating secret key tag', + 'permission matrix', + 'pod manifest wanted poster', + 'shell history receipt', + 'dashboard with one honest metric', + 'queue depth ruler', + 'cache key family tree', + 'timezone wall clock', + 'rollback lever', + 'invoice taller than the server', + 'token bucket bucket', + 'SLO gravestone', + 'staging/prod light switch' +]; + +const SCENARIO_MODIFIERS = [ + 'seen from the person who has to clean it up', + 'where the obvious prop contradicts the dialogue', + 'with the root cause visible but ignored', + 'as a physical room full of software artifacts', + 'with one tiny object carrying the whole joke', + 'where the dashboard and terminal disagree', + 'with the cast arguing over definitions instead of facts', + 'where the fix is visually worse than the bug' ]; export interface WorkflowStepLog { @@ -37,6 +188,8 @@ export interface ComicWorkflowResult { cast: CastCharacter[]; topic_candidates: string[]; selected_topic: string; + scenario_setup: ScenarioSetup; + improv_menu: ComicImprovMenu; model_a: string; model_b: string; prompt_a: string; @@ -47,6 +200,7 @@ export interface ComicWorkflowResult { log: string; cast: string; topics: string; + improv_menu: string; prompt_a: string; prompt_b: string; }; @@ -66,10 +220,20 @@ interface ComicPlan { cast: CastCharacter[]; topic_candidates: string[]; selected_topic: string; + scenario_setup: ScenarioSetup; + improv_menu: ComicImprovMenu; prompt_a: string; prompt_b: string; } +interface ScenarioSetup { + id: string; + label: string; + sceneHints: string[]; + prop: string; + tension: string; +} + export async function previewAgenticPromptPlan(env: any, options: { day: string; force_topic?: string; trigger: 'cron' | 'manual'; }) { const workflowLog: WorkflowStepLog[] = []; const plan = await buildComicPlan(env, options, workflowLog); @@ -82,8 +246,8 @@ export async function previewAgenticPromptPlan(env: any, options: { day: string; export async function runAgenticComicWorkflow(env: any, options: { day: string; force_topic?: string; trigger: 'cron' | 'manual'; }) { const workflowLog: WorkflowStepLog[] = []; const plan = await buildComicPlan(env, options, workflowLog); - const modelA = env.SCRIPT_MODEL_A || env.COMIC_MODEL_A || env.IMAGE_MODEL_A || DEFAULT_SCRIPT_MODEL_A; - const modelB = env.SCRIPT_MODEL_B || env.COMIC_MODEL_B || env.IMAGE_MODEL_B || DEFAULT_SCRIPT_MODEL_B; + const [modelA, modelB] = pickScriptModels(env, plan.run_id); + workflowLog.push(makeStep('select-script-models', 'ok', `Selected variant models: A=${modelA}, B=${modelB}.`)); const variantA = await generateScriptVariant(env, modelA, plan, workflowLog, 'variant-a', 'prioritize the cleanest joke structure and readable dialogue.', modelB); const variantB = await generateScriptVariant(env, modelB, plan, workflowLog, 'variant-b', 'prioritize sharper escalation and a meaner final punchline.', modelA); @@ -100,6 +264,7 @@ export async function runAgenticComicWorkflow(env: any, options: { day: string; env.COMICS_BUCKET.put(`${artifactPrefix}/workflow-log.json`, JSON.stringify(workflowLog, null, 2), { httpMetadata: { contentType: 'application/json' } }), env.COMICS_BUCKET.put(`${artifactPrefix}/cast.json`, JSON.stringify(plan.cast, null, 2), { httpMetadata: { contentType: 'application/json' } }), env.COMICS_BUCKET.put(`${artifactPrefix}/topics.json`, JSON.stringify(plan.topic_candidates, null, 2), { httpMetadata: { contentType: 'application/json' } }), + env.COMICS_BUCKET.put(`${artifactPrefix}/improv-menu.json`, JSON.stringify(plan.improv_menu, null, 2), { httpMetadata: { contentType: 'application/json' } }), env.COMICS_BUCKET.put(`${artifactPrefix}/prompt-a.txt`, plan.prompt_a, { httpMetadata: { contentType: 'text/plain; charset=utf-8' } }), env.COMICS_BUCKET.put(`${artifactPrefix}/prompt-b.txt`, plan.prompt_b, { httpMetadata: { contentType: 'text/plain; charset=utf-8' } }), env.COMICS_BUCKET.put(`${artifactPrefix}/script-a.json`, JSON.stringify(variantA.script, null, 2), { httpMetadata: { contentType: 'application/json' } }), @@ -200,6 +365,7 @@ export async function runAgenticComicWorkflow(env: any, options: { day: string; cast: plan.cast, topic_candidates: plan.topic_candidates, selected_topic: plan.selected_topic, + scenario_setup: plan.scenario_setup, model_a: variantA.script.model, model_b: variantB.script.model, prompt_a: plan.prompt_a, @@ -210,6 +376,7 @@ export async function runAgenticComicWorkflow(env: any, options: { day: string; log: `${artifactPrefix}/workflow-log.json`, cast: `${artifactPrefix}/cast.json`, topics: `${artifactPrefix}/topics.json`, + improv_menu: `${artifactPrefix}/improv-menu.json`, prompt_a: `${artifactPrefix}/prompt-a.txt`, prompt_b: `${artifactPrefix}/prompt-b.txt` }, @@ -244,12 +411,16 @@ async function buildComicPlan( const topicCandidates = await suggestTopics(env, chosenCast, panelCount, random, workflowLog); const selectedTopic = options.force_topic || topicCandidates[randomInt(random, 0, topicCandidates.length - 1)]; + const scenarioSetup = buildScenarioSetup(random, selectedTopic, chosenCast); + const improvMenu = buildImprovMenu(random, selectedTopic, chosenCast, scenarioSetup); const title = makeComicTitle(selectedTopic); const promptBase = buildStandardPrompt({ panelCount, cast: chosenCast, - topic: selectedTopic + topic: selectedTopic, + scenario: scenarioSetup, + improvMenu, }); const promptA = `${promptBase}\nVariant directive: prioritize crisp setup, exact terminology, and readable punchlines.`; @@ -266,6 +437,8 @@ async function buildComicPlan( cast: chosenCast, topic_candidates: topicCandidates, selected_topic: selectedTopic, + scenario_setup: scenarioSetup, + improv_menu: improvMenu, prompt_a: promptA, prompt_b: promptB }; @@ -285,11 +458,14 @@ async function suggestTopics( } const model = env.TOPIC_MODEL || env.SCRIPT_MODEL_A || DEFAULT_TOPIC_MODEL; - const systemPrompt = 'You are a technical humor prompt planner. Return JSON only: {"topics":["...", "...", "..."]}'; + const systemPrompt = 'You are a technical humor prompt planner. Return JSON only: {"topics":["...", "...", "...", "...", "...", "..."]}'; const userPrompt = [ - `Create 3 concise comic topic candidates for an xkcd-style technical comic.`, + `Create 6 diverse comic topic candidates for an xkcd-style technical comic.`, `Panel count: ${panelCount}`, `Cast: ${cast.map((c) => `${c.name} (${c.role})`).join(', ')}`, + `Character idea spaces: ${cast.flatMap((c) => c.idea_space || []).slice(0, 18).join(', ')}`, + 'Favor unusual but drawable technical situations: physicalized software artifacts, contradictory dashboards, awkward operational rituals, strange props, and specific failure modes.', + 'Avoid repeating postmortem/standup/cache/setup templates unless the topic has a fresh visual hook.', `Rules: each topic must be specific, practical, and under 12 words.` ].join('\n'); @@ -306,10 +482,10 @@ async function suggestTopics( const raw = response?.response || JSON.stringify(response); const parsed = parseJsonFromText(raw); const topics = Array.isArray(parsed?.topics) - ? parsed.topics.map((topic: any) => String(topic).trim()).filter(Boolean).slice(0, 3) + ? parsed.topics.map((topic: any) => String(topic).trim()).filter(Boolean).slice(0, 6) : []; - if (topics.length === 3) { + if (topics.length >= 3) { workflowLog.push(makeStep('suggest-topics', 'ok', `Generated topic candidates using ${model}.`)); return topics; } @@ -326,14 +502,14 @@ function pickFallbackTopics(random: () => number, cast: CastCharacter[]): string const pool = [...FALLBACK_TOPICS]; const selected: string[] = []; - while (selected.length < 3 && pool.length > 0) { + while (selected.length < 6 && pool.length > 0) { const idx = Math.floor(random() * pool.length); selected.push(pool[idx]); pool.splice(idx, 1); } - if (selected.length < 3) { - while (selected.length < 3) { + if (selected.length < 6) { + while (selected.length < 6) { selected.push(`unexpected ${cast[0]?.name || 'robot'} behavior in production`); } } @@ -341,6 +517,150 @@ function pickFallbackTopics(random: () => number, cast: CastCharacter[]): string return selected; } +function buildScenarioSetup(random: () => number, topic: string, cast: CastCharacter[]): ScenarioSetup { + const base = SCENARIO_SETUPS[randomInt(random, 0, SCENARIO_SETUPS.length - 1)]; + const characterIdeas = cast.flatMap((character) => character.idea_space || []); + const idea = characterIdeas.length > 0 + ? characterIdeas[randomInt(random, 0, characterIdeas.length - 1)] + : topic; + const prop = randomChoice(random, [ + base.prop, + ...PROP_ENTROPY, + ...cast.flatMap((character) => character.drawable_features || []), + ]); + const modifier = randomChoice(random, SCENARIO_MODIFIERS); + const scenes = shuffle(random, [...base.sceneHints, ...pickExtraScenes(random)]); + + return { + id: `${base.id}-${hashToUInt32(`${topic}:${prop}:${modifier}`).toString(16).slice(0, 6)}`, + label: `${base.label}, ${modifier}`, + sceneHints: scenes.slice(0, 4), + prop, + tension: `${base.tension}; pressure comes from ${idea}` + }; +} + +function buildImprovMenu(random: () => number, topic: string, cast: CastCharacter[], scenario: ScenarioSetup): ComicImprovMenu { + const optionalCharacters = cast + .map((character) => character.id) + .filter((id) => !['user', 'robot', 'ferris'].includes(id)); + const characterChoices = uniqueStrings([ + 'user', + 'robot', + ...shuffle(random, optionalCharacters).slice(0, 2), + ]); + const toolChoices = shuffle(random, [ + 'shell history', + 'kubectl rollout', + 'readiness probe', + 'dependency resolver', + 'virtualenv', + 'systemd timer', + 'feature flag', + 'secret rotator', + 'packet capture', + 'cost dashboard', + 'runbook', + 'lockfile', + ]).slice(0, 4); + const subjectChoices = uniqueStrings(shuffle(random, [ + topic, + scenario.tension, + ...cast.flatMap((character) => character.idea_space || []), + ]).slice(0, 4)); + const propChoices = uniqueStrings(shuffle(random, [ + scenario.prop, + ...PROP_ENTROPY, + ...cast.flatMap((character) => character.drawable_features || []), + ]).slice(0, 5)); + const runningGags = shuffle(random, [ + 'dashboard disagrees with terminal', + 'tiny helper becomes infrastructure', + 'fix works by hiding evidence', + 'root cause is visible in panel one', + 'manager renames failure as autonomy', + 'animal mascot notices the real bug', + 'the safest option is least impressive', + ]).slice(0, 3); + + return { + characters: characterChoices, + tools: toolChoices, + subjects: subjectChoices, + props: propChoices, + runningGags, + cameoChoices: ['ferris'], + }; +} + +function pickScriptModels(env: any, seedInput: string): [string, string] { + const pinnedA = normalizeModelName(env.SCRIPT_MODEL_A || env.COMIC_MODEL_A || env.IMAGE_MODEL_A); + const pinnedB = normalizeModelName(env.SCRIPT_MODEL_B || env.COMIC_MODEL_B || env.IMAGE_MODEL_B); + + if (pinnedA && pinnedB && pinnedA !== pinnedB) { + return [pinnedA, pinnedB]; + } + + const lineup = parseModelLineup(env.SCRIPT_MODEL_LINEUP || env.COMIC_MODEL_LINEUP); + const models = uniqueModels([ + ...(pinnedA ? [pinnedA] : []), + ...(pinnedB ? [pinnedB] : []), + ...lineup, + ...DEFAULT_SCRIPT_MODEL_LINEUP, + DEFAULT_SCRIPT_MODEL_A, + DEFAULT_SCRIPT_MODEL_B, + ]); + + if (models.length === 1) { + return [models[0], models[0]]; + } + + const random = createSeededRng(hashToUInt32(`script-models:${seedInput}`)); + const firstIndex = randomInt(random, 0, models.length - 1); + let secondIndex = randomInt(random, 0, models.length - 2); + if (secondIndex >= firstIndex) secondIndex += 1; + + return [models[firstIndex], models[secondIndex]]; +} + +function parseModelLineup(input: unknown): string[] { + if (Array.isArray(input)) { + return input.map(normalizeModelName).filter(Boolean) as string[]; + } + + const raw = String(input || '').trim(); + if (!raw) return []; + + if (raw.startsWith('[')) { + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.map(normalizeModelName).filter(Boolean) as string[]; + } + } catch { + // Fall through to delimiter parsing. + } + } + + return raw + .split(/[\n,]+/) + .map(normalizeModelName) + .filter(Boolean) as string[]; +} + +function uniqueModels(models: string[]): string[] { + return [...new Set(models.map(normalizeModelName).filter(Boolean) as string[])]; +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} + +function normalizeModelName(input: unknown): string | undefined { + const model = String(input || '').trim(); + return model || undefined; +} + async function generateScriptVariant( env: any, model: string, @@ -365,6 +685,7 @@ async function generateScriptVariant( panelCount: plan.panel_count, cast: plan.cast, variantDirective, + improvMenu: plan.improv_menu, }); workflowLog.push(makeStep(stepName, 'ok', `Generated scripted SVG comic with ${script.model}.`)); return { script }; @@ -374,11 +695,15 @@ async function generateScriptVariant( } } -function buildStandardPrompt(input: { panelCount: number; cast: CastCharacter[]; topic: string; }): string { +function buildStandardPrompt(input: { panelCount: number; cast: CastCharacter[]; topic: string; scenario: ScenarioSetup; improvMenu: ComicImprovMenu; }): string { const castLines = input.cast.map((char, idx) => ( `${idx + 1}. ${char.name} (${char.role})` + `\n Description: ${char.description}` + + `\n Voice: ${char.voice}` + `\n Visual cues: ${char.visual_traits.join(', ')}` + + (char.behaviors?.length ? `\n Character behaviors to use: ${char.behaviors.join('; ')}` : '') + + (char.idea_space?.length ? `\n Topic territory: ${char.idea_space.join(', ')}` : '') + + (char.drawable_features?.length ? `\n Drawable features: ${char.drawable_features.join(', ')}` : '') + `\n Sample reference: ${char.sample_image}` )).join('\n'); @@ -389,12 +714,28 @@ function buildStandardPrompt(input: { panelCount: number; cast: CastCharacter[]; `Layout: exactly ${input.panelCount} panels.`, 'Each panel should advance the joke and remain easy to typeset.', `Topic: ${input.topic}`, + `Scenario setup: ${input.scenario.label}.`, + `Scenario tension: ${input.scenario.tension}.`, + `Required recurring visual motif or prop: ${input.scenario.prop}.`, + `Preferred scene progression: ${input.scenario.sceneHints.join(' -> ')}.`, + 'Both model variants receive this same limited improv menu. Pick the funniest coherent subset instead of inventing from the full universe.', + `Character choices: ${input.improvMenu.characters.join(', ')}.`, + `Tool choices: ${input.improvMenu.tools.join(', ')}.`, + `Subject choices: ${input.improvMenu.subjects.join(', ')}.`, + `Prop choices: ${input.improvMenu.props.join(', ')}.`, + `Running gag choices: ${input.improvMenu.runningGags.join(', ')}.`, + `Cameo choices: ${input.improvMenu.cameoChoices.join(', ')}.`, + 'Entropy requirement: each panel needs a distinct visible prop or staging idea; do not solve every setup with a whiteboard, terminal, meeting, or status page.', + 'Character requirement: optional cast members must change the joke mechanics through their behaviors, not merely appear as labels.', 'Recurring cast bible:', '- The User is a plain round-head stick figure who asks vague, underspecified questions.', '- The LLM Robot is a square-head stick figure with an antenna. Its internal monologue appears in a cloud thought bubble using a technical monospace style.', - '- Simon is a BOFH sysadmin with a fedora and grey goatee. He is dry, cynical, and usually lands the correction or punchline.', + '- Simon is a BOFH sysadmin with square glasses, a fedora, and grey goatee. He is dry, cynical, and usually lands the correction or punchline.', '- The Boss wears a tie and talks like an AI hype manager.', '- Ferris is a silent crab cameo or panic signal in the background.', + '- Tux is a Linux penguin: use host/filesystem/package/kernel pragmatism and draw penguin features.', + '- Python is a snake: use dependency/runtime/notebook/indentation traps and draw a curving snake body.', + '- Kubernetes Captain wears a pirate captain hat and has a peg leg: use pod/rollout/probe/YAML nautical command logic.', 'Characters to include:', castLines, 'Scene requirements:', @@ -403,6 +744,7 @@ function buildStandardPrompt(input: { panelCount: number; cast: CastCharacter[]; '- Keep Simon deadpan if Simon is present.', '- Use dry systems-thinking humor about failure modes, architecture, operations, or specification gaps.', '- Prefer concrete nouns: deploy, cache key, rollback, runbook, timeout, queue, incident.', + '- Prefer concrete visual nouns beyond the usual set: lockfiles, keys, clocks, levers, invoices, manifests, buckets, probes, flags, receipts, labels.', '- Avoid generic "AI is weird" jokes.', '- No watermark, no sponsor copy, no unrelated text.' ].join('\n'); @@ -439,6 +781,23 @@ function randomInt(rand: () => number, min: number, max: number): number { return Math.floor(rand() * (max - min + 1)) + min; } +function randomChoice(rand: () => number, values: T[]): T { + return values[randomInt(rand, 0, values.length - 1)]; +} + +function shuffle(rand: () => number, values: T[]): T[] { + const copy = [...values]; + for (let index = copy.length - 1; index > 0; index -= 1) { + const swapIndex = randomInt(rand, 0, index); + [copy[index], copy[swapIndex]] = [copy[swapIndex], copy[index]]; + } + return copy; +} + +function pickExtraScenes(rand: () => number): string[] { + return shuffle(rand, ['terminal', 'whiteboard', 'incident_room', 'meeting', 'network', 'desk', 'plain']).slice(0, 2); +} + function hashToUInt32(input: string): number { let h = 2166136261; for (let i = 0; i < input.length; i += 1) { diff --git a/functions/lib/cast.ts b/functions/lib/cast.ts index dbc8e0e..c8d86cf 100644 --- a/functions/lib/cast.ts +++ b/functions/lib/cast.ts @@ -7,6 +7,9 @@ export interface CastCharacter { description: string; voice: string; visual_traits: string[]; + behaviors?: string[]; + idea_space?: string[]; + drawable_features?: string[]; sample_image: string; } diff --git a/functions/lib/comic-generator.ts b/functions/lib/comic-generator.ts index e8a9962..5216fcf 100644 --- a/functions/lib/comic-generator.ts +++ b/functions/lib/comic-generator.ts @@ -7,6 +7,11 @@ export interface ComicPanel { robotThought?: string; action?: string; pose?: string; + scene?: ComicScene; + beat?: ComicBeat; + visualFocus?: string; + expression?: ComicExpression; + cameo?: string; } export interface ComicScript { @@ -16,6 +21,25 @@ export interface ComicScript { model: string; } +export type ComicScene = 'terminal' | 'whiteboard' | 'incident_room' | 'meeting' | 'network' | 'desk' | 'plain'; +export type ComicBeat = 'setup' | 'escalation' | 'reversal' | 'callback' | 'punchline' | 'silent'; +export type ComicExpression = 'neutral' | 'confused' | 'worried' | 'deadpan' | 'smug' | 'panicked' | 'annoyed' | 'delighted' | 'thinking' | 'blank'; + +const COMIC_SCENES: ComicScene[] = ['terminal', 'whiteboard', 'incident_room', 'meeting', 'network', 'desk', 'plain']; +const COMIC_BEATS: ComicBeat[] = ['setup', 'escalation', 'reversal', 'callback', 'punchline', 'silent']; +const COMIC_EXPRESSIONS: ComicExpression[] = ['neutral', 'confused', 'worried', 'deadpan', 'smug', 'panicked', 'annoyed', 'delighted', 'thinking', 'blank']; + +const CHARACTER_EXPRESSION_GUIDE: Record = { + user: ['confused', 'worried', 'thinking', 'panicked', 'neutral'], + robot: ['thinking', 'smug', 'confused', 'panicked', 'blank'], + simon: ['deadpan', 'annoyed', 'smug', 'neutral'], + boss: ['smug', 'delighted', 'panicked', 'annoyed', 'neutral'], + ferris: ['panicked', 'annoyed', 'delighted', 'blank'], + tux: ['neutral', 'thinking', 'deadpan', 'worried', 'delighted'], + python: ['smug', 'thinking', 'confused', 'delighted', 'worried'], + kube_captain: ['smug', 'panicked', 'annoyed', 'delighted', 'thinking'], +}; + interface GenerateComicScriptOptions { ai: any; model: string; @@ -25,9 +49,19 @@ interface GenerateComicScriptOptions { panelCount: number; cast: CastCharacter[]; variantDirective: string; + improvMenu?: ComicImprovMenu; fallbackModel?: string; } +export interface ComicImprovMenu { + characters: string[]; + tools: string[]; + subjects: string[]; + props: string[]; + runningGags: string[]; + cameoChoices: string[]; +} + const JSON_MODE_MODELS = new Set([ '@cf/qwen/qwen3-30b-a3b-fp8', '@cf/meta/llama-3.3-70b-instruct-fp8-fast', @@ -68,8 +102,16 @@ async function generateComicScriptOnce(options: GenerateComicScriptOptions): Pro ].join(' '); const castGuide = options.cast.map((character) => ( - `- ${character.id}: ${character.name}. Role: ${character.role}. Voice: ${character.voice}. Description: ${character.description}.` + [ + `- ${character.id}: ${character.name}. Role: ${character.role}. Voice: ${character.voice}. Description: ${character.description}.`, + ` Available expressions: ${getAllowedExpressions(character.id).join(', ')}.`, + character.behaviors?.length ? ` Behaviors: ${character.behaviors.join('; ')}.` : '', + character.idea_space?.length ? ` Idea space: ${character.idea_space.join(', ')}.` : '', + character.drawable_features?.length ? ` Drawable features: ${character.drawable_features.join(', ')}.` : '', + ].filter(Boolean).join('\n') )).join('\n'); + const allowedSpeakers = uniqueStrings(['user', 'robot', ...options.cast.map((character) => character.id)]); + const improvMenu = options.improvMenu ? formatImprovMenu(options.improvMenu) : ''; const userPrompt = [ `Write a ${options.panelCount}-panel comic script for day ${options.day}.`, @@ -78,17 +120,31 @@ async function generateComicScriptOnce(options: GenerateComicScriptOptions): Pro `Variant direction: ${options.variantDirective}`, 'Cast in scope:', castGuide, + improvMenu ? 'Shared improv menu for both competing models:' : '', + improvMenu, 'Rules:', + '- You are competing against another model on the same limited improv menu. Pick the funniest coherent subset; do not try to use every item.', + '- Choose characters, tools, subjects, props, and running gags from the shared improv menu when it is provided.', + '- Maintain continuity: reuse one chosen subject and one chosen visual motif across the strip, with escalation.', '- Include the User and the Robot somewhere in the strip.', '- At least one panel must contain the robot internal monologue in `robotThought`.', '- Keep every dialogue line short: target 4-10 words and never more than 65 characters.', '- Keep every robotThought block under 3 short lines.', '- Avoid verbose panel narration. `action` should be 2-6 words only (pose note, not a sentence).', + '- Assign every panel a `scene` and `beat`; vary scenes unless the joke needs repetition.', + '- Use `visualFocus` for one concrete visible prop, artifact, diagram, or physical gag, 2-5 words.', + '- Do not reuse the same scene type, prop category, or character reaction pattern across panels unless it is the joke.', + '- Every optional character should contribute a specific behavior from the cast guide, not just stand in the background.', + '- Assign every panel an `expression` chosen from that character\'s available expressions.', + '- Use `cameo` only for a non-speaking visual cameo from cameo choices, especially ferris when available.', '- Ferris is usually a silent cameo, not the main speaker.', '- Return valid JSON with keys: title, panels.', - '- panels must be an array of objects using: panelNumber, speaker, dialogue?, robotThought?, action?, pose?.', + '- panels must be an array of objects using: panelNumber, speaker, dialogue?, robotThought?, action?, pose?, scene?, beat?, visualFocus?, expression?, cameo?.', '- pose should be one of: neutral, leaning, pointing, facepalm, slumped, hands_up, typing, smug, uncertain, deadpan.', - '- speaker must be one of: user, robot, simon, boss, ferris.', + `- scene should be one of: ${COMIC_SCENES.join(', ')}.`, + `- beat should be one of: ${COMIC_BEATS.join(', ')}.`, + `- expression should be one of: ${COMIC_EXPRESSIONS.join(', ')}.`, + `- speaker must be one of: ${allowedSpeakers.join(', ')}.`, '- Do not wrap the JSON in markdown.', ].join('\n'); @@ -121,6 +177,11 @@ async function generateComicScriptOnce(options: GenerateComicScriptOptions): Pro robotThought: { type: 'string' }, action: { type: 'string' }, pose: { type: 'string' }, + scene: { type: 'string' }, + beat: { type: 'string' }, + visualFocus: { type: 'string' }, + expression: { type: 'string' }, + cameo: { type: 'string' }, }, required: ['panelNumber', 'speaker'], additionalProperties: false, @@ -171,6 +232,11 @@ function normalizeComicScript(raw: any, options: GenerateComicScriptOptions): Co const robotThought = sanitizeThought(panel.robotThought); const action = sanitizeAction(panel.action); const pose = sanitizePose(panel.pose, panel.action, speaker); + const scene = sanitizeScene(panel.scene, action, dialogue, robotThought); + const beat = sanitizeBeat(panel.beat, index, options.panelCount); + const visualFocus = sanitizeVisualFocus(panel.visualFocus, scene, action); + const expression = sanitizeExpression(panel.expression, speaker, pose, robotThought, dialogue); + const cameo = sanitizeCameo(panel.cameo, speaker, options); normalizedPanels.push({ panelNumber: index + 1, @@ -179,6 +245,11 @@ function normalizeComicScript(raw: any, options: GenerateComicScriptOptions): Co robotThought, action, pose, + scene, + beat, + visualFocus, + expression, + cameo, }); } @@ -204,8 +275,8 @@ function normalizeComicScript(raw: any, options: GenerateComicScriptOptions): Co } function normalizeSpeaker(input: unknown, cast: CastCharacter[]): string { - const value = String(input || '').trim().toLowerCase(); - const allowed = new Set(['user', 'robot', 'simon', 'boss', 'ferris', ...cast.map((item) => item.id)]); + const value = String(input || '').trim().toLowerCase().replace(/[\s-]+/g, '_'); + const allowed = new Set(['user', 'robot', ...cast.map((item) => item.id)]); if (allowed.has(value)) return value; if (['human', 'developer', 'customer', 'founder'].includes(value)) return 'user'; @@ -213,6 +284,9 @@ function normalizeSpeaker(input: unknown, cast: CastCharacter[]): string { if (['manager', 'executive'].includes(value)) return 'boss'; if (['admin', 'sysadmin', 'operator'].includes(value)) return 'simon'; if (['crab'].includes(value)) return 'ferris'; + if (['linux', 'penguin'].includes(value)) return 'tux'; + if (['snake', 'python_snake', 'py'].includes(value)) return 'python'; + if (['kubernetes', 'k8s', 'captain', 'kube'].includes(value)) return 'kube_captain'; return 'user'; } @@ -245,6 +319,56 @@ function sanitizeAction(input: unknown): string | undefined { return clean; } +function sanitizeVisualFocus(input: unknown, scene: ComicScene, action: string | undefined): string { + const clean = sanitizeLine(input, 34); + if (clean) return clean; + if (action) return action; + + const defaults: Record = { + terminal: 'terminal output', + whiteboard: 'wrong diagram', + incident_room: 'incident clock', + meeting: 'status table', + network: 'packet path', + desk: 'keyboard', + plain: 'blank expression', + }; + return defaults[scene]; +} + +function sanitizeExpression(input: unknown, speaker: string, pose?: string, robotThought?: string, dialogue?: string): ComicExpression { + const raw = String(input || '').trim().toLowerCase().replace(/[\s-]+/g, '_'); + const allowed = getAllowedExpressions(speaker); + if ((allowed as string[]).includes(raw)) return raw as ComicExpression; + if ((COMIC_EXPRESSIONS as string[]).includes(raw)) return raw as ComicExpression; + + const hint = `${pose || ''} ${robotThought || ''} ${dialogue || ''}`.toLowerCase(); + if (/\bpanic|fail|outage|sev|fire|rollback|broken\b/.test(hint)) return allowed.includes('panicked') ? 'panicked' : allowed[0]; + if (/\bthink|parse|trace|debug|why|how\b/.test(hint)) return allowed.includes('thinking') ? 'thinking' : allowed[0]; + if (/\bconfus|uncertain|maybe|what\b/.test(hint)) return allowed.includes('confused') ? 'confused' : allowed[0]; + if (/\bsmug|yes|success|autonomous\b/.test(hint)) return allowed.includes('smug') ? 'smug' : allowed[0]; + if (speaker === 'simon') return 'deadpan'; + if (speaker === 'boss') return 'smug'; + if (speaker === 'robot') return 'thinking'; + if (speaker === 'tux') return 'deadpan'; + if (speaker === 'python') return 'smug'; + if (speaker === 'kube_captain') return 'smug'; + return allowed[0] || 'neutral'; +} + +function sanitizeCameo(input: unknown, speaker: string, options: GenerateComicScriptOptions): string | undefined { + const raw = String(input || '').trim().toLowerCase().replace(/[\s-]+/g, '_'); + if (!raw || raw === speaker || raw === 'none') return undefined; + const cameoChoices = new Set(options.improvMenu?.cameoChoices || ['ferris']); + if (cameoChoices.has(raw)) return raw; + if (raw === 'crab' && cameoChoices.has('ferris')) return 'ferris'; + return undefined; +} + +function getAllowedExpressions(speaker: string): ComicExpression[] { + return CHARACTER_EXPRESSION_GUIDE[speaker] || COMIC_EXPRESSIONS; +} + function sanitizePose(input: unknown, action: unknown, speaker: string): string | undefined { const allowed = new Set([ 'neutral', @@ -269,18 +393,43 @@ function sanitizePose(input: unknown, action: unknown, speaker: string): string if (/\bhands up|arms up|panic\b/.test(hint)) return 'hands_up'; if (speaker === 'simon') return 'deadpan'; if (speaker === 'robot') return 'uncertain'; + if (speaker === 'kube_captain') return 'pointing'; + if (speaker === 'python') return 'leaning'; return 'neutral'; } +function sanitizeScene(input: unknown, action?: string, dialogue?: string, robotThought?: string): ComicScene { + const raw = String(input || '').trim().toLowerCase().replace(/[\s-]+/g, '_'); + if ((COMIC_SCENES as string[]).includes(raw)) return raw as ComicScene; + + const hint = `${action || ''} ${dialogue || ''} ${robotThought || ''}`.toLowerCase(); + if (/\bwhiteboard|diagram|arrow|architecture|box|flow|schema|chart\b/.test(hint)) return 'whiteboard'; + if (/\bincident|outage|pager|status|sev|war room|rollback|postmortem\b/.test(hint)) return 'incident_room'; + if (/\bmeeting|standup|roadmap|kpi|slide|stakeholder|executive\b/.test(hint)) return 'meeting'; + if (/\bdns|tcp|packet|cache|cdn|api|queue|service|network\b/.test(hint)) return 'network'; + if (/\bterminal|shell|deploy|build|compile|keyboard|monitor|logs?|ssh|kubectl|merge|commit|branch|prod|production|code\b/.test(hint)) return 'terminal'; + if (/\bdesk|laptop|coffee|keyboard|chair\b/.test(hint)) return 'desk'; + return 'plain'; +} + +function sanitizeBeat(input: unknown, index: number, panelCount: number): ComicBeat { + const raw = String(input || '').trim().toLowerCase().replace(/[\s-]+/g, '_'); + if ((COMIC_BEATS as string[]).includes(raw)) return raw as ComicBeat; + if (index === 0) return 'setup'; + if (index === panelCount - 1) return 'punchline'; + if (index === panelCount - 2) return 'reversal'; + return 'escalation'; +} + function buildFallbackComicScript(options: GenerateComicScriptOptions, error: string): ComicScript { const castIds = new Set(options.cast.map((character) => character.id)); - const closer = castIds.has('simon') ? 'simon' : 'boss'; + const optionalCloser = ['simon', 'tux', 'python', 'kube_captain', 'boss'].find((id) => castIds.has(id)) || 'user'; const fallbackPanels: ComicPanel[] = [ - { panelNumber: 1, speaker: 'user', dialogue: `Can we rebuild ${options.topic}?`, pose: 'pointing' }, - { panelNumber: 2, speaker: 'robot', robotThought: '> rewriting premise\n> preserving punchline\n> no gibberish', pose: 'typing' }, - { panelNumber: 3, speaker: 'robot', dialogue: 'Yes. Words are readable now.', pose: 'smug' }, - { panelNumber: 4, speaker: closer, dialogue: 'Low bar. Still progress.', pose: 'deadpan' }, + { panelNumber: 1, speaker: 'user', dialogue: `Can we rebuild ${options.topic}?`, pose: 'pointing', scene: 'whiteboard', beat: 'setup', visualFocus: 'architecture sketch', expression: 'confused' }, + { panelNumber: 2, speaker: 'robot', robotThought: '> rewriting premise\n> preserving punchline\n> no gibberish', pose: 'typing', scene: 'terminal', beat: 'escalation', visualFocus: 'log tail', expression: 'thinking' }, + { panelNumber: 3, speaker: castIds.has('kube_captain') ? 'kube_captain' : 'robot', dialogue: castIds.has('kube_captain') ? 'The pods mutinied politely.' : 'Yes. Words are readable now.', pose: 'smug', scene: 'incident_room', beat: 'reversal', visualFocus: 'incident timer', expression: 'smug' }, + { panelNumber: 4, speaker: optionalCloser, dialogue: fallbackCloserLine(optionalCloser), pose: optionalCloser === 'kube_captain' ? 'pointing' : 'deadpan', scene: 'meeting', beat: 'punchline', visualFocus: fallbackVisualFocus(optionalCloser), expression: fallbackExpression(optionalCloser) }, ].slice(0, options.panelCount); while (fallbackPanels.length < options.panelCount) { @@ -289,6 +438,11 @@ function buildFallbackComicScript(options: GenerateComicScriptOptions, error: st speaker: 'user', dialogue: 'So we stopped asking image models to typeset?', pose: 'leaning', + scene: 'desk', + beat: 'callback', + visualFocus: 'keyboard', + expression: 'thinking', + cameo: castIds.has('ferris') ? 'ferris' : undefined, }); } @@ -304,6 +458,54 @@ function buildFallbackComicScript(options: GenerateComicScriptOptions, error: st }; } +function fallbackCloserLine(speaker: string): string { + const lines: Record = { + simon: 'Low bar. Still progress.', + tux: 'Check the host first.', + python: 'Tiny scripts grow teeth.', + kube_captain: 'That is not a rollout. That is a boarding action.', + boss: 'Can we call that autonomous?', + }; + return lines[speaker] || 'That explains the outage.'; +} + +function fallbackVisualFocus(speaker: string): string { + const focus: Record = { + tux: 'permission bit', + python: 'dependency knot', + kube_captain: 'mutinying pods', + boss: 'KPI slide', + simon: 'empty KPI chart', + }; + return focus[speaker] || 'root cause'; +} + +function fallbackExpression(speaker: string): ComicExpression { + const expressions: Record = { + tux: 'deadpan', + python: 'smug', + kube_captain: 'annoyed', + boss: 'smug', + simon: 'deadpan', + }; + return expressions[speaker] || 'neutral'; +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values)]; +} + +function formatImprovMenu(menu: ComicImprovMenu): string { + return [ + `- Character choices: ${menu.characters.join(', ')}`, + `- Tool choices: ${menu.tools.join(', ')}`, + `- Subject choices: ${menu.subjects.join(', ')}`, + `- Prop choices: ${menu.props.join(', ')}`, + `- Running gag choices: ${menu.runningGags.join(', ')}`, + `- Cameo choices: ${menu.cameoChoices.join(', ')}`, + ].join('\n'); +} + function parseJsonFromText(raw: string): any { try { return JSON.parse(raw); diff --git a/functions/lib/ledgrrr-wasm/index.ts b/functions/lib/ledgrrr-wasm/index.ts index 5b59a69..64f8109 100644 --- a/functions/lib/ledgrrr-wasm/index.ts +++ b/functions/lib/ledgrrr-wasm/index.ts @@ -16,7 +16,7 @@ import init, { workflow_to_mermaid, workflow_to_rhai, workflow_to_rust_enum, -} from "./ledger_workflow_wasm"; +} from "./ledger_workflow_wasm.js"; // Re-export the wasm module initialization export { init }; diff --git a/functions/lib/ledgrrr-wasm/ledger_workflow_wasm_bg.wasm.d.ts b/functions/lib/ledgrrr-wasm/ledger_workflow_wasm_bg.wasm.types.txt similarity index 100% rename from functions/lib/ledgrrr-wasm/ledger_workflow_wasm_bg.wasm.d.ts rename to functions/lib/ledgrrr-wasm/ledger_workflow_wasm_bg.wasm.types.txt diff --git a/functions/lib/local-bootstrap-comic.ts b/functions/lib/local-bootstrap-comic.ts index 16941cf..1ddab55 100644 --- a/functions/lib/local-bootstrap-comic.ts +++ b/functions/lib/local-bootstrap-comic.ts @@ -10,10 +10,10 @@ export async function ensureLocalBootstrapComic(env: any, day: string) { day, model: '@local/bootstrap-a', panels: [ - { panelNumber: 1, speaker: 'human', dialogue: 'Robot, quick status update?' }, - { panelNumber: 2, speaker: 'robot', robotThought: '> loading local mode\n> confidence: 0.62\n> still dramatic' }, - { panelNumber: 3, speaker: 'robot', dialogue: 'System stable, panic optional.' }, - { panelNumber: 4, speaker: 'simon', dialogue: 'Optional panic is still panic.' } + { panelNumber: 1, speaker: 'user', dialogue: 'Why is prod green?', pose: 'pointing', scene: 'incident_room', beat: 'setup', visualFocus: 'green status page', expression: 'confused' }, + { panelNumber: 2, speaker: 'tux', dialogue: 'The host stopped answering.', pose: 'deadpan', scene: 'terminal', beat: 'escalation', visualFocus: 'permission matrix', expression: 'deadpan', cameo: 'ferris' }, + { panelNumber: 3, speaker: 'robot', robotThought: '> metrics absent\n> therefore healthy\n> concise lie', pose: 'typing', scene: 'network', beat: 'reversal', visualFocus: 'broken telemetry', expression: 'thinking' }, + { panelNumber: 4, speaker: 'simon', dialogue: 'It stopped reporting.', pose: 'deadpan', scene: 'whiteboard', beat: 'punchline', visualFocus: 'missing arrow', expression: 'deadpan' } ] }; @@ -22,10 +22,10 @@ export async function ensureLocalBootstrapComic(env: any, day: string) { day, model: '@local/bootstrap-b', panels: [ - { panelNumber: 1, speaker: 'human', dialogue: 'Why is prod slow again?' }, - { panelNumber: 2, speaker: 'robot', robotThought: '> tracing request path\n> found 37 middleware layers\n> neat' }, - { panelNumber: 3, speaker: 'robot', dialogue: 'Latency appears artisanal.' }, - { panelNumber: 4, speaker: 'simon', dialogue: 'Hand-crafted delays cost extra.' } + { panelNumber: 1, speaker: 'kube_captain', dialogue: 'The pods mutinied politely.', pose: 'pointing', scene: 'network', beat: 'setup', visualFocus: 'mutinying pods', expression: 'annoyed' }, + { panelNumber: 2, speaker: 'python', dialogue: 'I brought one tiny helper.', pose: 'leaning', scene: 'desk', beat: 'escalation', visualFocus: 'dependency knot', expression: 'smug', cameo: 'ferris' }, + { panelNumber: 3, speaker: 'robot', robotThought: '> install helper\n> helper installs fleet\n> fleet requests budget', pose: 'typing', scene: 'whiteboard', beat: 'reversal', visualFocus: 'lockfile scroll', expression: 'panicked' }, + { panelNumber: 4, speaker: 'simon', dialogue: 'That is a supply chain.', pose: 'deadpan', scene: 'incident_room', beat: 'punchline', visualFocus: 'blast-radius circle', expression: 'annoyed' } ] }; diff --git a/functions/lib/svg-prompt-generator.ts b/functions/lib/svg-prompt-generator.ts index 0805442..77d9d02 100644 --- a/functions/lib/svg-prompt-generator.ts +++ b/functions/lib/svg-prompt-generator.ts @@ -41,6 +41,22 @@ export function generateSvgDescription(script: ComicScript): string { lines.push(` Pose: ${panel.pose}`); } + if (panel.scene) { + lines.push(` Scene: ${panel.scene}`); + } + + if (panel.beat) { + lines.push(` Story beat: ${panel.beat}`); + } + + if (panel.visualFocus) { + lines.push(` Visual focus: ${panel.visualFocus}`); + } + + if (panel.expression) { + lines.push(` Expression: ${panel.expression}`); + } + return lines.join('\n'); }); @@ -48,7 +64,7 @@ export function generateSvgDescription(script: ComicScript): string { Comic: "${script.title}" Day: ${script.day} Panel Layout: ${panelCount}-panel grid -Style: Retro technical webcomic, dry humor, ASCII-art influenced +Style: xkcd-like black-and-white technical comic, sparse stick figures, expressive diagrams, dry humor ${panelDescriptions.join('\n\n')} `.trim(); @@ -69,11 +85,13 @@ Retro technical webcomic style image for: ${svgDescription} Style Guide: -- Retro ASCII-art inspired comic panels -- Technical/minimalist aesthetic +- xkcd-inspired black-and-white line art, hand-drawn but readable +- Rich technical props: whiteboards, terminals, dashboards, network arrows, incident timers, tickets +- Each panel should have a distinct visible scene setup and one concrete prop - Clear character silhouettes +- Visible emotive faces: eyes, brows, mouths, sweat marks, deadpan eyelids as appropriate - Readable speech bubbles -- Dark background, bright text +- White background, black ink, restrained gray shading only - 1024x768 resolution optimized for web ${variantStyle ? `- Variant: ${variantStyle}` : ''} `.trim(); @@ -104,8 +122,8 @@ export function generateVariantPrompts(script: ComicScript): { } { const svgDescription = generateSvgDescription(script); - const promptA = generateImagePrompt(svgDescription, 'High contrast, bold lines'); - const promptB = generateImagePrompt(svgDescription, 'Soft colors, detailed'); + const promptA = generateImagePrompt(svgDescription, 'clean xkcd line economy, precise diagrams, high contrast'); + const promptB = generateImagePrompt(svgDescription, 'richer panel staging, more props, still sparse black-and-white'); return { promptA, promptB }; } diff --git a/functions/lib/svg-renderer.ts b/functions/lib/svg-renderer.ts index 51e854a..3f15195 100644 --- a/functions/lib/svg-renderer.ts +++ b/functions/lib/svg-renderer.ts @@ -1,4 +1,4 @@ -import type { ComicPanel, ComicScript } from './comic-generator.ts'; +import type { ComicExpression, ComicPanel, ComicScene, ComicScript } from './comic-generator.ts'; const PANEL_WIDTH = 320; const PANEL_HEIGHT = 260; @@ -141,11 +141,9 @@ function renderPanel( const scene = detectScene(panel); const pose = normalizePose(panel.pose); const figureX = x + width / 2; - const figureY = y + height - (scene === 'terminal' ? 90 : 84); + const figureY = y + height - (scene === 'terminal' || scene === 'desk' ? 90 : 84); - if (scene === 'terminal') { - content += drawTerminalScene(x + 30, y + height - 106, width - 60, panelSeed); - } + content += drawSceneBackdrop(scene, x, y, width, height, panel, panelSeed); if (panel.dialogue) { const bubbleX = getBubbleX(panel, x, width, panelSeed); @@ -166,24 +164,34 @@ function renderPanel( content += drawBossFigure(figureX, figureY - 24, panel, script.day, pose); } else if (panel.speaker === 'ferris') { content += drawFerrisFigure(figureX, figureY + 10, panel, script.day); + } else if (panel.speaker === 'tux') { + content += drawTuxFigure(figureX, figureY - 4, panel, script.day); + } else if (panel.speaker === 'python') { + content += drawPythonFigure(figureX, figureY + 18, panel, script.day); + } else if (panel.speaker === 'kube_captain') { + content += drawKubeCaptainFigure(figureX, figureY - 24, panel, script.day, pose); } else { content += drawHumanFigure(figureX, figureY - 24, panel, script.day, pose); } + if (panel.cameo === 'ferris' && panel.speaker !== 'ferris') { + content += drawFerrisFigure(x + width - 42, y + height - 62, { ...panel, expression: 'delighted' }, script.day); + } + return content; } function drawHumanFigure(x: number, y: number, panel: ComicPanel, day: string, pose: string): string { const ctx = createCharacterContext('user', panel, day); const skeleton = buildStandingPose(x, y, ctx, 0.9, 1.28, pose); - return `${drawHumanLikeFigure(ctx, skeleton)}`; + return `${drawHumanLikeFigure(ctx, skeleton, normalizeExpression(panel.expression, 'user'))}`; } function drawSimonFigure(x: number, y: number, panel: ComicPanel, day: string, pose: string): string { const ctx = createCharacterContext('simon', panel, day); const skeleton = buildStandingPose(x - 2, y - 1, ctx, 0.72, 1.22, pose === 'neutral' ? 'deadpan' : pose); let content = ``; - content += drawHumanLikeFigure(ctx, skeleton); + content += drawHumanLikeFigure(ctx, skeleton, normalizeExpression(panel.expression, 'simon')); const hatRng = createRng(hashString(`simon-hat|${day}|${panel.panelNumber}`)); const brimY = skeleton.headCenter.y - 16; @@ -221,6 +229,8 @@ function drawSimonFigure(x: number, y: number, panel: ComicPanel, day: string, p ); } + content += drawSquareGlasses(ctx, skeleton.headCenter); + content += ``; return content; } @@ -229,7 +239,7 @@ function drawBossFigure(x: number, y: number, panel: ComicPanel, day: string, po const ctx = createCharacterContext('boss', panel, day); const skeleton = buildStandingPose(x + 2, y, ctx, 1.15, 1.25, poseFromBoss(pose)); let content = ``; - content += drawHumanLikeFigure(ctx, skeleton); + content += drawHumanLikeFigure(ctx, skeleton, normalizeExpression(panel.expression, 'boss')); const tieRng = createRng(hashString(`boss-tie|${day}|${panel.panelNumber}`)); content += renderClosedSketch( @@ -276,6 +286,7 @@ function drawRobotFigure(x: number, y: number, panel: ComicPanel, day: string, p const ctx = createCharacterContext('robot', panel, day); const rng = ctx.frameRng; const leanBase = pose === 'smug' ? 2.8 : pose === 'uncertain' ? -1.8 : pose === 'typing' ? 3.6 : 0.8; + const expression = normalizeExpression(panel.expression, 'robot'); const lean = leanBase + jitter(ctx.identityRng, 1.7); const headX = x + lean; const headY = y + jitter(rng, 0.8) + (pose === 'typing' ? 3 : 0); @@ -312,28 +323,7 @@ function drawRobotFigure(x: number, y: number, panel: ComicPanel, day: string, p rng, { fill: 'white', width: 2.3, opacity: 0.98 }, ); - content += renderClosedSketch( - roughLoopPoints(headX - 6.4, headY - 2, 2.1, 2.3, rng, ctx.controls, { pointCount: 8 }), - rng, - { fill: 'black', width: 1.0, opacity: 0.95, doubleStroke: false }, - ); - content += renderClosedSketch( - roughLoopPoints(headX + 6.2, headY - 1.8, 2.1, 2.1, rng, ctx.controls, { pointCount: 8 }), - rng, - { fill: 'black', width: 1.0, opacity: 0.95, doubleStroke: false }, - ); - content += renderOpenSketch( - roughLinePoints( - { x: headX - 8.3, y: headY + 8.6 }, - { x: headX + 8.1, y: headY + 9.1 + jitter(rng, 0.4) }, - rng, - ctx.controls, - 4, - 0.05, - ), - rng, - { width: 1.5, opacity: 0.88, doubleStroke: false }, - ); + content += drawRobotFace(ctx, headX, headY, expression); content += renderOpenSketch(roughLinePoints(neck, hip, rng, ctx.controls, 4, 0.08), rng, { width: 2.25, opacity: 0.98 }); content += renderOpenSketch(roughLinePoints(shoulder, leftHand, rng, ctx.controls, 4, 0.09), rng, { width: 2.1, opacity: 0.97 }); content += renderOpenSketch(roughLinePoints(shoulder, rightHand, rng, ctx.controls, 4, 0.09), rng, { width: 2.1, opacity: 0.97 }); @@ -346,6 +336,7 @@ function drawRobotFigure(x: number, y: number, panel: ComicPanel, day: string, p function drawFerrisFigure(x: number, y: number, panel: ComicPanel, day: string): string { const ctx = createCharacterContext('ferris', panel, day); const rng = ctx.frameRng; + const expression = normalizeExpression(panel.expression, 'ferris'); const body = roughLoopPoints(x, y, 18.5, 12.5, rng, ctx.controls, { pointCount: 12, asymmetry: 0.022, @@ -382,11 +373,96 @@ function drawFerrisFigure(x: number, y: number, panel: ComicPanel, day: string): rng, { fill: 'black', width: 0.9, opacity: 0.95, doubleStroke: false }, ); + if (expression === 'panicked') { + content += drawEmotionMark(ctx, x + 20, y - 22, 'sweat'); + } else if (expression === 'annoyed') { + content += drawEmotionMark(ctx, x + 19, y - 18, 'tick'); + } else if (expression === 'delighted') { + content += drawEmotionMark(ctx, x + 20, y - 19, 'spark'); + } content += ``; return content; } -function drawHumanLikeFigure(ctx: CharacterContext, pose: ReturnType): string { +function drawTuxFigure(x: number, y: number, panel: ComicPanel, day: string): string { + const ctx = createCharacterContext('tux', panel, day); + const rng = ctx.frameRng; + const expression = normalizeExpression(panel.expression, 'tux'); + let content = ``; + content += renderClosedSketch(roughLoopPoints(x, y + 12, 24, 38, rng, ctx.controls, { pointCount: 14, radialVariance: 0.08 }), rng, { fill: '#111', width: 2.1, opacity: 0.95 }); + content += renderClosedSketch(roughLoopPoints(x, y + 22, 14, 25, rng, ctx.controls, { pointCount: 12, radialVariance: 0.07 }), rng, { fill: 'white', width: 1.3, opacity: 0.94, doubleStroke: false }); + content += renderClosedSketch(roughLoopPoints(x, y - 22, 18, 17, rng, ctx.controls, { pointCount: 12, radialVariance: 0.08 }), rng, { fill: '#111', width: 2.0, opacity: 0.96 }); + content += renderClosedSketch(roughPolygonPoints([{ x: x - 5, y: y - 18 }, { x: x + 6, y: y - 18 }, { x: x + 1, y: y - 11 }], rng, 0.5), rng, { fill: 'white', width: 0.9, opacity: 0.96, doubleStroke: false }); + content += drawSimpleFace(ctx, { x, y: y - 22 }, expression, 0.82); + content += renderOpenSketch(roughLinePoints({ x: x - 19, y: y + 1 }, { x: x - 34, y: y + 27 }, rng, ctx.controls, 4, 0.08), rng, { width: 2.0, opacity: 0.9 }); + content += renderOpenSketch(roughLinePoints({ x: x + 19, y: y + 2 }, { x: x + 34, y: y + 25 }, rng, ctx.controls, 4, 0.08), rng, { width: 2.0, opacity: 0.9 }); + content += renderOpenSketch(roughLinePoints({ x: x - 10, y: y + 52 }, { x: x - 27, y: y + 57 }, rng, ctx.controls, 3, 0.03), rng, { width: 1.6, opacity: 0.75 }); + content += renderOpenSketch(roughLinePoints({ x: x + 10, y: y + 52 }, { x: x + 27, y: y + 57 }, rng, ctx.controls, 3, 0.03), rng, { width: 1.6, opacity: 0.75 }); + content += ``; + return content; +} + +function drawPythonFigure(x: number, y: number, panel: ComicPanel, day: string): string { + const ctx = createCharacterContext('python', panel, day); + const rng = ctx.frameRng; + const expression = normalizeExpression(panel.expression, 'python'); + const coil = [ + { x: x - 55, y: y + 30 }, + { x: x - 30, y: y + 47 }, + { x: x + 12, y: y + 43 }, + { x: x + 39, y: y + 19 }, + { x: x + 22, y: y - 2 }, + { x: x - 16, y: y + 7 }, + { x: x - 3, y: y + 27 }, + { x: x + 44, y: y + 22 }, + ]; + let content = ``; + const bodyPath = pathFromPoints(roughSnakePoints(coil, rng), false); + content += buildSketchPath(bodyPath, rng, { width: 16.5, stroke: '#143d20', opacity: 0.96, doubleStroke: false }); + content += buildSketchPath(bodyPath, rng, { width: 11.8, stroke: '#4f9f45', opacity: 0.98, doubleStroke: false }); + content += buildSketchPath(bodyPath, rng, { width: 3.1, stroke: '#bfe8a8', opacity: 0.52, doubleStroke: false }); + const head = { x: x + 52, y: y + 20 }; + content += renderClosedSketch(roughLoopPoints(head.x, head.y, 18, 13, rng, ctx.controls, { pointCount: 11, radialVariance: 0.08 }), rng, { fill: '#5eaa46', stroke: '#143d20', width: 2.0, opacity: 0.98 }); + content += drawSimpleFace(ctx, head, expression, 0.75); + content += renderOpenSketch(roughLinePoints({ x: head.x + 15, y: head.y + 3 }, { x: head.x + 26, y: head.y + 2 }, rng, ctx.controls, 2, 0.01), rng, { width: 0.9, stroke: '#2f5b2b', opacity: 0.72, doubleStroke: false }); + content += renderOpenSketch(roughLinePoints({ x: head.x + 26, y: head.y + 2 }, { x: head.x + 31, y: head.y - 3 }, rng, ctx.controls, 2, 0.01), rng, { width: 0.75, stroke: '#2f5b2b', opacity: 0.68, doubleStroke: false }); + content += renderOpenSketch(roughLinePoints({ x: head.x + 26, y: head.y + 2 }, { x: head.x + 31, y: head.y + 7 }, rng, ctx.controls, 2, 0.01), rng, { width: 0.75, stroke: '#2f5b2b', opacity: 0.68, doubleStroke: false }); + content += ``; + return content; +} + +function drawKubeCaptainFigure(x: number, y: number, panel: ComicPanel, day: string, pose: string): string { + const ctx = createCharacterContext('kube_captain', panel, day); + const skeleton = buildStandingPose(x, y, ctx, 1.12, 1.25, pose === 'neutral' ? 'pointing' : pose); + const rng = ctx.frameRng; + let content = ``; + content += drawHumanLikeFigure(ctx, skeleton, normalizeExpression(panel.expression, 'kube_captain')); + const hatY = skeleton.headCenter.y - 19; + content += renderClosedSketch( + roughPolygonPoints([ + { x: skeleton.headCenter.x - 25, y: hatY + 4 }, + { x: skeleton.headCenter.x - 12, y: hatY - 13 }, + { x: skeleton.headCenter.x + 2, y: hatY - 7 }, + { x: skeleton.headCenter.x + 17, y: hatY - 14 }, + { x: skeleton.headCenter.x + 26, y: hatY + 4 }, + ], rng, 1.1), + rng, + { fill: 'white', width: 2.05, opacity: 0.97 }, + ); + content += renderTinyLabel(skeleton.headCenter.x + 2, hatY + 1, 'K8s', 'middle'); + content += renderOpenSketch(roughLinePoints({ x: skeleton.hip.x + 8, y: skeleton.hip.y + 6 }, { x: skeleton.hip.x + 14, y: skeleton.hip.y + 48 }, rng, ctx.controls, 4, 0.05), rng, { width: 3.0, opacity: 0.9 }); + content += renderOpenSketch(roughLinePoints({ x: skeleton.hip.x + 14, y: skeleton.hip.y + 48 }, { x: skeleton.hip.x + 24, y: skeleton.hip.y + 48 }, rng, ctx.controls, 2, 0.02), rng, { width: 2.2, opacity: 0.85 }); + content += renderClosedSketch(roughPolygonPoints([ + { x: skeleton.torsoTop.x - 13, y: skeleton.torsoTop.y + 2 }, + { x: skeleton.torsoTop.x + 15, y: skeleton.torsoTop.y + 5 }, + { x: skeleton.hip.x + 20, y: skeleton.hip.y + 12 }, + { x: skeleton.hip.x - 18, y: skeleton.hip.y + 12 }, + ], rng, 1.0), rng, { fill: 'none', width: 1.35, opacity: 0.65, doubleStroke: false }); + content += ``; + return content; +} + +function drawHumanLikeFigure(ctx: CharacterContext, pose: ReturnType, expression: ComicExpression): string { const rng = ctx.frameRng; let content = ''; @@ -403,6 +479,7 @@ function drawHumanLikeFigure(ctx: CharacterContext, pose: ReturnType = { + user: ['neutral', 'confused', 'worried', 'thinking', 'panicked'], + robot: ['blank', 'thinking', 'confused', 'smug', 'panicked'], + simon: ['deadpan', 'annoyed', 'smug', 'neutral'], + boss: ['smug', 'delighted', 'annoyed', 'panicked', 'neutral'], + ferris: ['blank', 'annoyed', 'delighted', 'panicked'], + tux: ['neutral', 'thinking', 'deadpan', 'worried', 'delighted'], + python: ['smug', 'thinking', 'confused', 'delighted', 'worried'], + kube_captain: ['smug', 'panicked', 'annoyed', 'delighted', 'thinking'], + }; + const allowed = allowedBySpeaker[speaker] || ['neutral', 'confused', 'worried', 'deadpan', 'smug', 'panicked', 'annoyed', 'delighted', 'thinking', 'blank']; + if ((allowed as string[]).includes(value)) return value as ComicExpression; + + if (speaker === 'robot') return 'thinking'; + if (speaker === 'simon') return 'deadpan'; + if (speaker === 'boss') return 'smug'; + if (speaker === 'ferris') return 'blank'; + if (speaker === 'tux') return 'deadpan'; + if (speaker === 'python') return 'smug'; + if (speaker === 'kube_captain') return 'smug'; + return 'neutral'; +} + function poseFromBoss(pose: string): string { if (pose === 'deadpan' || pose === 'typing' || pose === 'pointing') return pose; if (pose === 'neutral') return 'smug'; @@ -592,7 +834,31 @@ function drawThoughtBubble(x: number, y: number, text: string, maxWidth: number, `; } -function drawTerminalScene(x: number, y: number, width: number, seed: number): string { +function drawSceneBackdrop(scene: ComicScene, x: number, y: number, width: number, height: number, panel: ComicPanel, seed: number): string { + if (scene === 'terminal' || scene === 'desk') { + return drawTerminalScene(x + 30, y + height - 106, width - 60, seed, panel.visualFocus); + } + + if (scene === 'whiteboard') { + return drawWhiteboardScene(x + 25, y + 66, width - 50, seed, panel.visualFocus); + } + + if (scene === 'incident_room') { + return drawIncidentScene(x + 24, y + 67, width - 48, height, seed, panel.visualFocus); + } + + if (scene === 'meeting') { + return drawMeetingScene(x + 28, y + height - 96, width - 56, seed, panel.visualFocus); + } + + if (scene === 'network') { + return drawNetworkScene(x + 28, y + 76, width - 56, seed, panel.visualFocus); + } + + return drawPlainScene(x, y, width, height, seed, panel.beat); +} + +function drawTerminalScene(x: number, y: number, width: number, seed: number, focus = 'logs'): string { const rng = createRng(hashString(`terminal|${seed}|${width}`)); const deskLeft = { x, y: y + 26 + jitter(rng, 0.9) }; const deskRight = { x: x + width, y: y + 23 + jitter(rng, 1.2) }; @@ -669,10 +935,149 @@ function drawTerminalScene(x: number, y: number, width: number, seed: number): s rng, { stroke: '#747474', width: 0.85, opacity: 0.5, doubleStroke: false }, ); + content += renderTinyLabel(monitorX, monitorY + 8, focus, 'middle'); content += ``; return content; } +function drawWhiteboardScene(x: number, y: number, width: number, seed: number, focus = 'causal arrow'): string { + const rng = createRng(hashString(`whiteboard|${seed}|${focus}`)); + let content = ``; + content += renderClosedSketch( + roughRectanglePoints(x, y, width, 88, rng, SKETCH_CONTROLS, 2.4), + rng, + { fill: 'white', stroke: '#6a6a6a', width: 1.35, opacity: 0.55, doubleStroke: false }, + ); + + const boxes = [ + { x: x + width * 0.18, y: y + 30, label: 'spec' }, + { x: x + width * 0.50, y: y + 22, label: 'model' }, + { x: x + width * 0.78, y: y + 40, label: 'prod' }, + ]; + + for (const box of boxes) { + content += renderClosedSketch( + roughRectanglePoints(box.x - 22, box.y - 11, 44, 22, rng, SKETCH_CONTROLS, 1.4), + rng, + { fill: 'white', stroke: '#777', width: 1.1, opacity: 0.58, doubleStroke: false }, + ); + content += renderTinyLabel(box.x, box.y + 4, box.label, 'middle'); + } + + content += renderArrow({ x: boxes[0].x + 25, y: boxes[0].y }, { x: boxes[1].x - 25, y: boxes[1].y }, rng, 0.48); + content += renderArrow({ x: boxes[1].x + 25, y: boxes[1].y + 2 }, { x: boxes[2].x - 25, y: boxes[2].y }, rng, 0.48); + content += renderTinyLabel(x + width / 2, y + 78, focus, 'middle'); + content += ``; + return content; +} + +function drawIncidentScene(x: number, y: number, width: number, height: number, seed: number, focus = 'sev clock'): string { + const rng = createRng(hashString(`incident|${seed}|${focus}`)); + const clockX = x + width * 0.18; + const boardX = x + width * 0.62; + let content = ``; + content += renderClosedSketch( + roughLoopPoints(clockX, y + 24, 20, 20, rng, SKETCH_CONTROLS, { pointCount: 12, radialVariance: 0.08 }), + rng, + { fill: 'white', stroke: '#666', width: 1.35, opacity: 0.62, doubleStroke: false }, + ); + content += renderOpenSketch(roughLinePoints({ x: clockX, y: y + 24 }, { x: clockX + 1, y: y + 10 }, rng, SKETCH_CONTROLS, 3, 0.04), rng, { stroke: '#777', width: 1.0, opacity: 0.55, doubleStroke: false }); + content += renderOpenSketch(roughLinePoints({ x: clockX, y: y + 24 }, { x: clockX + 12, y: y + 27 }, rng, SKETCH_CONTROLS, 3, 0.04), rng, { stroke: '#777', width: 1.0, opacity: 0.55, doubleStroke: false }); + content += renderTinyLabel(clockX, y + 52, focus, 'middle'); + + content += renderClosedSketch( + roughRectanglePoints(boardX - 54, y + 4, 108, 64, rng, SKETCH_CONTROLS, 2.0), + rng, + { fill: 'white', stroke: '#6d6d6d', width: 1.2, opacity: 0.54, doubleStroke: false }, + ); + for (let index = 0; index < 4; index += 1) { + const rowY = y + 18 + index * 11; + content += renderOpenSketch(roughLinePoints({ x: boardX - 43, y: rowY }, { x: boardX + 42, y: rowY + jitter(rng, 0.6) }, rng, SKETCH_CONTROLS, 3, 0.03), rng, { stroke: '#777', width: 0.9, opacity: 0.44, doubleStroke: false }); + } + + content += renderOpenSketch(roughLinePoints({ x, y: y + height - 132 }, { x: x + width, y: y + height - 134 + jitter(rng, 0.8) }, rng, SKETCH_CONTROLS, 4, 0.04), rng, { stroke: '#777', width: 1.25, opacity: 0.42, doubleStroke: false }); + content += ``; + return content; +} + +function drawMeetingScene(x: number, y: number, width: number, seed: number, focus = 'status table'): string { + const rng = createRng(hashString(`meeting|${seed}|${focus}`)); + let content = ``; + content += renderClosedSketch( + roughPolygonPoints([ + { x, y: y + 22 }, + { x: x + width, y: y + 18 }, + { x: x + width - 24, y: y + 45 }, + { x: x + 24, y: y + 50 }, + ], rng, 1.6), + rng, + { fill: 'white', stroke: '#686868', width: 1.35, opacity: 0.54, doubleStroke: false }, + ); + for (let index = 0; index < 3; index += 1) { + const mugX = x + width * (0.27 + index * 0.22); + content += renderClosedSketch(roughLoopPoints(mugX, y + 29 + jitter(rng, 1), 5, 4, rng, SKETCH_CONTROLS, { pointCount: 8 }), rng, { fill: 'white', stroke: '#777', width: 0.8, opacity: 0.48, doubleStroke: false }); + } + content += renderClosedSketch(roughRectanglePoints(x + width * 0.58, y - 44, 62, 38, rng, SKETCH_CONTROLS, 1.5), rng, { fill: 'white', stroke: '#777', width: 1.0, opacity: 0.46, doubleStroke: false }); + content += renderTinyLabel(x + width * 0.58 + 31, y - 22, focus, 'middle'); + content += ``; + return content; +} + +function drawNetworkScene(x: number, y: number, width: number, seed: number, focus = 'cache path'): string { + const rng = createRng(hashString(`network|${seed}|${focus}`)); + const nodes = [ + { x: x + width * 0.16, y: y + 34, label: 'user' }, + { x: x + width * 0.42, y: y + 14, label: 'cdn' }, + { x: x + width * 0.63, y: y + 54, label: 'api' }, + { x: x + width * 0.84, y: y + 27, label: 'db' }, + ]; + let content = ``; + content += renderArrow(nodes[0], nodes[1], rng, 0.45); + content += renderArrow(nodes[1], nodes[2], rng, 0.45); + content += renderArrow(nodes[2], nodes[3], rng, 0.45); + content += renderArrow(nodes[2], { x: nodes[1].x + 4, y: nodes[1].y + 31 }, rng, 0.34); + for (const node of nodes) { + content += renderClosedSketch(roughLoopPoints(node.x, node.y, 16, 11, rng, SKETCH_CONTROLS, { pointCount: 10, radialVariance: 0.09 }), rng, { fill: 'white', stroke: '#707070', width: 1.0, opacity: 0.54, doubleStroke: false }); + content += renderTinyLabel(node.x, node.y + 4, node.label, 'middle'); + } + content += renderTinyLabel(x + width / 2, y + 82, focus, 'middle'); + content += ``; + return content; +} + +function drawPlainScene(x: number, y: number, width: number, height: number, seed: number, beat?: string): string { + const rng = createRng(hashString(`plain|${seed}|${beat || ''}`)); + const floorY = y + height - 42 + jitter(rng, 0.7); + let content = ``; + content += renderOpenSketch(roughLinePoints({ x: x + 26, y: floorY }, { x: x + width - 26, y: floorY + jitter(rng, 0.8) }, rng, SKETCH_CONTROLS, 4, 0.03), rng, { stroke: '#777', width: 1.0, opacity: 0.34, doubleStroke: false }); + if (beat === 'punchline' || beat === 'reversal') { + content += renderOpenSketch(roughLinePoints({ x: x + width * 0.72, y: y + 74 }, { x: x + width * 0.82, y: y + 74 + jitter(rng, 0.5) }, rng, SKETCH_CONTROLS, 3, 0.03), rng, { stroke: '#777', width: 1.0, opacity: 0.36, doubleStroke: false }); + } + content += ``; + return content; +} + +function renderArrow(start: Point, end: Point, rng: () => number, opacity: number): string { + let content = renderOpenSketch(roughLinePoints(start, end, rng, SKETCH_CONTROLS, 4, 0.05), rng, { stroke: '#777', width: 1.0, opacity, doubleStroke: false }); + const angle = Math.atan2(end.y - start.y, end.x - start.x); + const left = { x: end.x - Math.cos(angle - 0.55) * 7, y: end.y - Math.sin(angle - 0.55) * 7 }; + const right = { x: end.x - Math.cos(angle + 0.55) * 7, y: end.y - Math.sin(angle + 0.55) * 7 }; + content += renderOpenSketch(roughLinePoints(left, end, rng, SKETCH_CONTROLS, 2, 0.02), rng, { stroke: '#777', width: 0.95, opacity, doubleStroke: false }); + content += renderOpenSketch(roughLinePoints(right, end, rng, SKETCH_CONTROLS, 2, 0.02), rng, { stroke: '#777', width: 0.95, opacity, doubleStroke: false }); + return content; +} + +function renderTinyLabel(x: number, y: number, text: string, anchor: 'start' | 'middle' = 'start'): string { + const safe = escapeXml(truncateLabel(text, 16)); + return `${safe}`; +} + +function truncateLabel(text: string, maxLength: number): string { + const clean = text.replace(/\s+/g, ' ').trim(); + if (clean.length <= maxLength) return clean; + return clean.slice(0, maxLength - 1).trim(); +} + function createCharacterContext(kind: string, panel: ComicPanel, day: string): CharacterContext { return { controls: SKETCH_CONTROLS, @@ -682,10 +1087,20 @@ function createCharacterContext(kind: string, panel: ComicPanel, day: string): C }; } -function detectScene(panel: ComicPanel): 'plain' | 'terminal' { - const haystack = `${panel.action || ''} ${panel.dialogue || ''} ${panel.robotThought || ''}`.toLowerCase(); - const terminalPattern = /\b(type|typing|terminal|deploy|build|compile|keyboard|cursor|shell|screen|monitor|reply|debug|logs?|ssh|kubectl|merge|commit|branch|cache|prod|production|api|prompt|code)\b/; - return terminalPattern.test(haystack) ? 'terminal' : 'plain'; +function detectScene(panel: ComicPanel): ComicScene { + const explicit = String(panel.scene || '').trim().toLowerCase().replace(/[\s-]+/g, '_'); + if (['terminal', 'whiteboard', 'incident_room', 'meeting', 'network', 'desk', 'plain'].includes(explicit)) { + return explicit as ComicScene; + } + + const haystack = `${panel.action || ''} ${panel.dialogue || ''} ${panel.robotThought || ''} ${panel.visualFocus || ''}`.toLowerCase(); + if (/\bwhiteboard|diagram|arrow|architecture|schema|chart\b/.test(haystack)) return 'whiteboard'; + if (/\bincident|outage|pager|status|sev|war room|rollback|postmortem\b/.test(haystack)) return 'incident_room'; + if (/\bmeeting|standup|roadmap|kpi|slide|stakeholder|executive\b/.test(haystack)) return 'meeting'; + if (/\bdns|tcp|packet|cache|cdn|api|queue|service|network\b/.test(haystack)) return 'network'; + if (/\b(type|typing|terminal|deploy|build|compile|keyboard|cursor|shell|screen|monitor|reply|debug|logs?|ssh|kubectl|merge|commit|branch|prod|production|prompt|code)\b/.test(haystack)) return 'terminal'; + if (/\bdesk|laptop|coffee|keyboard|chair\b/.test(haystack)) return 'desk'; + return 'plain'; } function buildFilters(renderSeed: number, panelCount: number): string { @@ -703,6 +1118,9 @@ function buildFilters(renderSeed: number, panelCount: number): string { filters.push(buildFilter('character-wobble-simon', renderSeed + 271, 0.0108, 0.58)); filters.push(buildFilter('character-wobble-boss', renderSeed + 301, 0.0106, 0.57)); filters.push(buildFilter('character-wobble-ferris', renderSeed + 331, 0.0112, 0.6)); + filters.push(buildFilter('character-wobble-tux', renderSeed + 361, 0.0109, 0.58)); + filters.push(buildFilter('character-wobble-python', renderSeed + 391, 0.0114, 0.62)); + filters.push(buildFilter('character-wobble-kube_captain', renderSeed + 421, 0.0108, 0.6)); return filters.join('\n'); } @@ -835,6 +1253,30 @@ function roughLoopPoints( return points; } +function roughArcPoints( + cx: number, + cy: number, + rx: number, + ry: number, + startAngle: number, + endAngle: number, + rng: () => number, +): Point[] { + const points: Point[] = []; + const segments = 7; + + for (let index = 0; index <= segments; index += 1) { + const t = index / segments; + const angle = lerp(startAngle, endAngle, t) + jitter(rng, 0.025); + points.push({ + x: cx + Math.cos(angle) * rx + jitter(rng, 0.35), + y: cy + Math.sin(angle) * ry + jitter(rng, 0.35), + }); + } + + return points; +} + function roughCloudPoints(cx: number, cy: number, rx: number, ry: number, rng: () => number): Point[] { const points: Point[] = []; const lobes = 16; @@ -894,6 +1336,23 @@ function roughPolygonPoints(points: Point[], rng: () => number, drift: number): return roughPoints; } +function roughPolyline(points: Point[], rng: () => number, controls: SketchControls): Point[] { + const roughPoints: Point[] = []; + for (let index = 0; index < points.length - 1; index += 1) { + const segment = roughLinePoints(points[index], points[index + 1], rng, controls, 4, 0.06); + if (index > 0) segment.shift(); + roughPoints.push(...segment); + } + return roughPoints; +} + +function roughSnakePoints(points: Point[], rng: () => number): Point[] { + return points.map((point, index) => ({ + x: point.x + jitter(rng, index === 0 || index === points.length - 1 ? 0.45 : 1.0), + y: point.y + jitter(rng, index === 0 || index === points.length - 1 ? 0.45 : 1.0), + })); +} + function pathFromPoints(points: Point[], closed: boolean): string { if (points.length === 0) return ''; if (points.length === 1) return `M ${fmt(points[0].x)} ${fmt(points[0].y)}`; diff --git a/scripts/test-api-contracts.mjs b/scripts/test-api-contracts.mjs index 8d7d146..04d6023 100644 --- a/scripts/test-api-contracts.mjs +++ b/scripts/test-api-contracts.mjs @@ -303,6 +303,7 @@ async function main() { assert.equal(response.status, 200); let payload = await readJson(response); assert.deepEqual(payload.votes, { a: 1, b: 0 }); + assert.deepEqual(payload.selected, { variant: 'a', model: '@cf/model-a' }); const response2 = await voteApi.onRequestPost({ env, @@ -315,6 +316,7 @@ async function main() { assert.equal(response2.status, 200); payload = await readJson(response2); assert.deepEqual(payload.votes, { a: 0, b: 1 }); + assert.deepEqual(payload.selected, { variant: 'b', model: '@cf/model-b' }); } { @@ -403,6 +405,13 @@ async function main() { assert.ok(castIds.includes('user')); assert.ok(castIds.includes('robot')); assert.ok(plan.panel_count >= 3 && plan.panel_count <= 4); + assert.ok(plan.scenario_setup?.id); + assert.ok(plan.improv_menu?.characters?.includes('user')); + assert.ok(plan.improv_menu?.cameoChoices?.includes('ferris')); + assert.ok(plan.prompt_a.includes('Scenario setup:')); + assert.ok(plan.prompt_b.includes('Required recurring visual motif or prop:')); + assert.ok(plan.prompt_b.includes('Both model variants receive this same limited improv menu.')); + assert.ok(plan.workflow_log.some((entry) => entry.step === 'sample-structure')); } console.log('API and workflow contract checks passed'); diff --git a/src/components/ComicViewer.vue b/src/components/ComicViewer.vue index fbe050b..3e51190 100644 --- a/src/components/ComicViewer.vue +++ b/src/components/ComicViewer.vue @@ -26,6 +26,7 @@ const comic = ref(null); const loading = ref(true); const error = ref(null); const voted = ref(null); +const votedModel = ref(null); const usingFallback = ref(false); const fallbackReason = ref(''); const props = defineProps<{ @@ -50,6 +51,8 @@ async function fetchToday() { loading.value = true; usingFallback.value = false; fallbackReason.value = ''; + voted.value = null; + votedModel.value = null; const endpoint = props.day ? `/api/day?day=${encodeURIComponent(props.day)}` : '/api/today'; @@ -94,6 +97,7 @@ async function vote(variant: 'a' | 'b') { const result = await response.json(); voted.value = variant; + votedModel.value = result.selected?.model || comic.value?.variants[variant].model || null; // Update vote counts if (comic.value && result.votes) { @@ -103,6 +107,7 @@ async function vote(variant: 'a' | 'b') { } catch (err: any) { if (usingFallback.value && comic.value) { voted.value = variant; + votedModel.value = comic.value.variants[variant].model; comic.value.variants[variant].votes += 1; return; } @@ -121,6 +126,15 @@ function scoreLabel() { return `A ${comic.value.variants.a.votes} - ${comic.value.variants.b.votes} B`; } +function votedVariantLabel() { + return voted.value ? voted.value.toUpperCase() : ''; +} + +function votedModelLabel() { + if (!comic.value || !voted.value) return ''; + return votedModel.value || comic.value.variants[voted.value as 'a' | 'b'].model; +} + function getPanelCount(script: ComicScript) { if (!script || typeof script === 'string') return 0; if (typeof script.panel_count === 'number') return script.panel_count; @@ -363,6 +377,10 @@ function escapeXml(text: string) {

โœ… Thank you for voting! Come back tomorrow for the next comic.

+

+ You picked Variant {{ votedVariantLabel() }}, + generated by {{ votedModelLabel() }}. +

Current score: {{ scoreLabel() }} ({{ totalVotes() }} total votes)

@@ -411,6 +429,18 @@ function escapeXml(text: string) { font-size: 13px; } +.model-reveal { + margin: 8px 0 0; + font-size: 13px; +} + +.model-reveal code { + font-family: 'Courier New', monospace; + font-size: 12px; + color: #222; + word-break: break-word; +} + .fallback-note { margin: 0 0 12px 0; color: #9b5f00; diff --git a/src/components/TheXTerm.vue b/src/components/TheXTerm.vue index d192721..bc91ed9 100644 --- a/src/components/TheXTerm.vue +++ b/src/components/TheXTerm.vue @@ -7,7 +7,7 @@ FILE: src/components/TheXTerm.vue