From 981341de3fc7201e663e8ae102dd731ed048e0bd Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Mon, 21 Sep 2026 10:32:47 +0200 Subject: [PATCH] feat(characters): check every render, retry with a new seed, and take the sheet's face from the profile The job checks the portrait crop, then the turnaround, story panel and same-person against the sheet, re-renders on failure (up to 3 attempts) and keeps the attempt with the fewest faults. ReActor transfers the profile's face onto the turnaround and the largest face in the story panel. If the checker is down, retries stop and the pair is marked unverified. The outcome is saved with the pair and shown on the character page. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 + architecture/character-visual-identity.md | 27 ++++ core/character_image_check.py | 31 +++++ routes/conversation.py | 158 +++++++++++++++++++--- static/js/library-characters.js | 25 +++- tests/characters.spec.js | 31 +++++ tests/test_character_image_check.py | 130 ++++++++++++++++++ 7 files changed, 384 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecf755e..14849c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Added +- **Character images are checked and regenerated automatically.** Each profile picture and character sheet is inspected after it is rendered; a portrait or sheet that fails (wrong gender, three front views, cut-off feet, elf ears on a human, wounds, scenery behind a studio shot, a different face from the profile picture) is rendered again with a new seed, up to three times, and the attempt with the fewest faults is kept. The character page shows the outcome: checked with no faults, faults still found (listed), or not checked because the checker was unavailable — in which case it stops retrying rather than re-rendering blindly. +- **The character sheet now shows the profile picture's face.** The turnaround and the story moment take their face from the profile portrait (face transfer after rendering), so the portrait and the sheet show the same person; in the story moment only the main character's face is replaced, not the extras'. - **Automatic check for generated character images** (`POST /api/character-image-check`). A vision model reports what it actually sees in a portrait, turnaround or scene — figure count, which way each figure faces, whether heads and feet are in frame, the apparent gender, pointed or round ears, wounds, plain or scenery background, and whether two images show the same person — and the app compares that with the character sheet. An unavailable checker or an unreadable answer counts as a failure, never a pass. It uses the LLM that is already loaded (no model swapping); calibrated at 14/14 on panels with known defects. - **LiteLLM model-group aliases in the model list.** The Active Language Model / OCR model dropdown now also lists the gateway's router aliases (ocr, default, cheap, vision, local-stt …), which LiteLLM's `/models` omits; they are read from `/router/settings` and shown first. Gateways without that endpoint are unaffected. diff --git a/architecture/character-visual-identity.md b/architecture/character-visual-identity.md index 0b6d424..20143c9 100644 --- a/architecture/character-visual-identity.md +++ b/architecture/character-visual-identity.md @@ -95,3 +95,30 @@ studio portrait, and one correct same-person pair), run twice: Ling never judged wrongly but on every turnaround ignored the JSON-only instruction and answered in prose. It also has free-tier rate limits and sends the images to a third party. + + +## Generation with checks and retries (face from the profile) + +The generation job (`POST /api/character-generate-consistent-images`) now runs: + +1. **Identity image** (text-to-image, full body). The profile picture is cut from it. **Portrait check** + on exactly that crop (one person, gender, ears, wounds, plain background). On failure: a new seed, + up to `image_check_max_attempts` (default 3), keeping the attempt with the fewest faults. +2. **Sheet** (turnaround triptych + story moment + props, one ComfyUI run). The profile's face is then + transferred with **ReActor** (`inswapper_128`, CodeFormer 0.5 restore): turnaround faces left to right + (front and side; the rear has none), and in the story panel only the *largest* face — the character in + the foreground, not an extra. **Checks:** turnaround, story panel, and same-person (profile vs. the front + view). Gender is judged *after* the face transfer, on what a viewer actually sees. Retried the same way. +3. The result carries `image_check` = `{verified, passed, attempts, failures[], checks{}}`, saved on the + sheet and shown on the character page: verified with no faults / faults still found (listed) / not + verified. + +**If the checker itself is unavailable, retries stop at once** and the pair is marked *not verified*: +re-rolling images cannot fix a missing checker, and would burn up to three renders per character. + +Why face transfer and not rendering the views from the portrait: the edit model keeps the source pose +(three front views, the pose guide copied into the costume — tried and reverted 2026-09-21). The +text-to-image triptych gets the poses right; swapping only the face afterwards fixes the identity. + +Settings: `image_check_enabled` (default on), `image_check_max_attempts` (1–5, default 3), +`image_check_model` (default: the active LLM). diff --git a/core/character_image_check.py b/core/character_image_check.py index 3de5c2f..2cf2b4e 100644 --- a/core/character_image_check.py +++ b/core/character_image_check.py @@ -176,3 +176,34 @@ def judge(kind: str, observed: dict, expected: dict) -> dict: failures.append(f"unknown panel kind: {kind}") return {"passed": not failures, "failures": failures, "observed": observed} + + +CHECKER_UNAVAILABLE = "checker unavailable" + + +def checker_unavailable(verdict: dict) -> bool: + """True when the verdict says nothing about the image because the checker could not run. + + Re-rendering cannot fix that, so the retry loop must stop instead of re-rolling images. + """ + return any(str(f).startswith(CHECKER_UNAVAILABLE) for f in (verdict or {}).get("failures") or []) + + +def failure_count(verdicts: dict) -> int: + """How far an attempt is from passing; lower is better. Missing verdicts count as one each.""" + return sum(len((v or {}).get("failures") or []) or (0 if (v or {}).get("passed") else 1) + for v in verdicts.values()) + + +def summarise(verdicts: dict, attempts: int, verified: bool) -> dict: + """What gets stored with the image pair and shown in the UI.""" + failures = [f"{kind}: {f}" for kind, v in verdicts.items() for f in ((v or {}).get("failures") or [])] + return { + "verified": verified, + "passed": verified and not failures, + "attempts": attempts, + "failures": failures, + "checks": {kind: {"passed": bool((v or {}).get("passed")), "failures": (v or {}).get("failures") or [], + "observed": (v or {}).get("observed") or {}} + for kind, v in verdicts.items()}, + } diff --git a/routes/conversation.py b/routes/conversation.py index ae195ed..6017d28 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -35,6 +35,7 @@ from core.voice import ( from core.voice_index import rebuild_voice_index from core.database import background_job_create, background_job_get, background_job_store_artifact, background_job_update from core.retry import request_with_retries +from core.character_image_check import checker_unavailable, failure_count, summarise from core.attribution_spans import ( SpanRequestError, build_messages as _span_build_messages, max_tokens_for as _span_max_tokens, parse_response as _span_parse_response, validate_spans as _span_validate, @@ -2695,6 +2696,42 @@ def _image_check_run(kind: str, image_uris: list[str], sheet: dict, settings: di return dict(verdict, expected=expected, model=model) +def _profile_crop_uri(identity_uri: str) -> str: + """The profile picture as the consistent-image workflow cuts it (node 5: 768x768 at x=128, + y=0, then scaled to 1024), so the portrait check sees exactly what the user will see.""" + raw = base64.b64decode(identity_uri.split(",", 1)[1]) + with Image.open(io.BytesIO(raw)) as source: + crop = source.convert("RGB").crop((128, 0, 896, 768)).resize((1024, 1024), Image.Resampling.LANCZOS) + out = io.BytesIO() + crop.save(out, format="PNG") + return "data:image/png;base64," + base64.b64encode(out.getvalue()).decode("ascii") + + +def _front_panel_uri(turnaround_uri: str) -> str: + """The left third of the turnaround: the front view, used for the same-person check.""" + raw = base64.b64decode(turnaround_uri.split(",", 1)[1]) + with Image.open(io.BytesIO(raw)) as source: + img = source.convert("RGB") + front = img.crop((0, 0, img.width // 3, img.height)) + out = io.BytesIO() + front.save(out, format="PNG") + return "data:image/png;base64," + base64.b64encode(out.getvalue()).decode("ascii") + + +def _check_sheet_panels(panels: dict, sheet: dict, settings: dict) -> dict: + """Check the rendered sheet panels. The story-moment gender is judged after the face + transfer, on what a viewer actually sees.""" + verdicts = {} + if panels.get("turnaround"): + verdicts["turnaround"] = _image_check_run("turnaround", [panels["turnaround"]], sheet, settings) + if panels.get("scene"): + verdicts["scene"] = _image_check_run("scene", [panels["scene"]], sheet, settings) + if panels.get("profile") and panels.get("turnaround"): + verdicts["same_person"] = _image_check_run( + "same_person", [panels["profile"], _front_panel_uri(panels["turnaround"])], sheet, settings) + return verdicts + + @router.post("/api/character-image-check") async def character_image_check(request: Request): """Check one generated panel against the character sheet. @@ -3420,12 +3457,41 @@ def _compose_character_reference(images: dict[str, bytes], prop_count: int) -> s return "data:image/png;base64," + base64.b64encode(out.getvalue()).decode("ascii") +def _graph_add_face_transfer(graph: dict) -> None: + """Put the profile portrait's face onto the turnaround and the story panel (ReActor). + + The turnaround and the story moment are text-to-image renders that never see the portrait, + so the face drifted between the profile picture and the sheet. Drawing them *from* the + portrait with the edit model was tried and reverted (three front views, pose guide copied + into the costume); swapping only the face after the text-to-image render keeps the poses and + fixes the identity. Turnaround: faces left to right (the rear view has none). Story panel: + the largest face, i.e. the character in the foreground rather than an extra. + """ + def swap(target: str, order: str, index: str, key: str) -> str: + graph[f"{key}_face_options"] = {"class_type": "ReActorOptions", "inputs": { + "input_faces_order": order, "input_faces_index": index, "detect_gender_input": "no", + "source_faces_order": "large-small", "source_faces_index": "0", "detect_gender_source": "no", + "console_log_level": 1, "restore_swapped_only": True, + }} + graph[f"{key}_face"] = {"class_type": "ReActorFaceSwapOpt", "inputs": { + "enabled": True, "input_image": [target, 0], "source_image": ["7", 0], + "swap_model": "inswapper_128.onnx", "facedetection": "retinaface_resnet50", + "face_restore_model": "codeformer-v0.1.0.pth", "face_restore_visibility": 1.0, + "codeformer_weight": 0.5, "options": [f"{key}_face_options", 0], + }} + return f"{key}_face" + if "turnaround_save" in graph and "turnaround_decode" in graph: + graph["turnaround_save"]["inputs"]["images"] = [swap("turnaround_decode", "left-right", "0,1", "turnaround"), 0] + if "scene_t2i_save" in graph and "scene_t2i_decode" in graph: + graph["scene_t2i_save"]["inputs"]["images"] = [swap("scene_t2i_decode", "large-small", "0", "scene"), 0] + + def _comfyui_run_consistent_images(comfyui_url: str, workflow_json: str, identity_image: str, profile_prompt: str, front_prompt: str, side_prompt: str, back_prompt: str, scene_prompt: str, prop_prompts: list[str], style_key: str = "", - progress=None) -> dict: + progress=None, face_transfer: bool = True) -> dict: """Generate independently controlled views, scene and unique prop tiles.""" try: workflow = json.loads(workflow_json) @@ -3462,6 +3528,8 @@ def _comfyui_run_consistent_images(comfyui_url: str, workflow_json: str, graph["68"].setdefault("inputs", {})["seed"] = view_seed("identity-turnaround") for node_id, label in (("26", "prop-1"), ("52", "prop-2"), ("56", "prop-3")): graph[node_id].setdefault("inputs", {})["seed"] = view_seed(label) + if face_transfer: + _graph_add_face_transfer(graph) client_id = str(uuid.uuid4()) base = comfyui_url.rstrip("/") try: @@ -3535,6 +3603,11 @@ def _comfyui_run_consistent_images(comfyui_url: str, workflow_json: str, if key == "profile": result["profile"] = f"data:{vr.headers.get('Content-Type', 'image/png')};base64,{base64.b64encode(vr.content).decode('ascii')}" result["reference"] = _compose_character_reference(raw_images, len(prop_prompts)) + # Internal: the job checks these, then drops them before anything is persisted or returned. + result["_panels"] = { + key: "data:image/png;base64," + base64.b64encode(raw_images[key]).decode("ascii") + for key in ("profile", "turnaround", "scene") if key in raw_images + } return result @@ -3749,26 +3822,75 @@ async def character_generate_consistent_images(request: Request): identity_image = "data:" + (local_image.headers.get("Content-Type") or "image/png") + ";base64," + base64.b64encode(local_image.content).decode("ascii") except Exception as exc: raise RuntimeError(f"Could not load the existing identity image: {exc}") + # Just a heartbeat (refreshes "updated" so /status's stale_seconds doesn't fire a false + # "may be stuck" warning) — the elapsed counter itself is computed client-side. + heartbeat = lambda elapsed: (_cij_check_cancelled(job_id), _cij_set(job_id)) + settings_now = _load_settings() + checking = settings_now.get("image_check_enabled", True) is not False + max_attempts = max(1, min(5, int(settings_now.get("image_check_max_attempts") or 3))) if checking else 1 + verdicts: dict = {} + verified = checking + attempts_used = 0 + + # 1) The identity image, from which the profile picture is cut. It is checked first + # because every later panel takes its face from it. if not identity_image: + best = None + for attempt in range(max_attempts): + _cij_check_cancelled(job_id) + _cij_set(job_id, stage="identity", + detail=f"Generating the identity portrait (attempt {attempt + 1} of {max_attempts})…") + candidate = _comfyui_run( + comfy_url, _bundled_character_identity_workflow(), "5", "text", "identity_save", + profile_prompt, f"{generation_key}|identity|{attempt}", progress=heartbeat, + ) + if not checking: + best = (0, candidate, None) + break + _cij_set(job_id, stage="identity", detail=f"Checking the portrait (attempt {attempt + 1})…") + verdict = _image_check_run("portrait", [_profile_crop_uri(candidate)], sheet, settings_now) + if checker_unavailable(verdict): + best, verified = (0, candidate, verdict), False + break + score = len(verdict.get("failures") or []) + if best is None or score < best[0]: + best = (score, candidate, verdict) + if verdict.get("passed"): + break + identity_image = best[1] + if best[2] is not None: + verdicts["portrait"] = best[2] + + # 2) The sheet: turnaround, story moment and props, with the portrait's face transferred. + best_sheet = None + for attempt in range(max_attempts): _cij_check_cancelled(job_id) - _cij_set(job_id, stage="identity", detail="Generating shared identity portrait…") - identity_image = _comfyui_run( - comfy_url, _bundled_character_identity_workflow(), - "5", "text", "identity_save", profile_prompt, generation_key, - # Just a heartbeat (refreshes "updated" so /status's - # stale_seconds doesn't fire a false "may be stuck" - # warning) — the elapsed counter itself is computed - # client-side from "started", not baked into this text. - progress=lambda elapsed: (_cij_check_cancelled(job_id), _cij_set(job_id)), + attempts_used = attempt + 1 + _cij_set(job_id, stage="render", + detail=f"Rendering turnaround, scene & props (attempt {attempt + 1} of {max_attempts})…") + candidate = _comfyui_run_consistent_images( + comfy_url, _bundled_consistent_comfyui_workflow(), identity_image, + profile_prompt, front_prompt, side_prompt, back_prompt, scene_prompt, prop_prompts, + f"{generation_key}|sheet|{attempt}", progress=heartbeat, ) - _cij_check_cancelled(job_id) - _cij_set(job_id, stage="render", detail="Rendering profile, turnaround, scene & props…") - result = _comfyui_run_consistent_images( - comfy_url, _bundled_consistent_comfyui_workflow(), identity_image, - profile_prompt, front_prompt, side_prompt, back_prompt, scene_prompt, prop_prompts, - generation_key, - progress=lambda elapsed: (_cij_check_cancelled(job_id), _cij_set(job_id)), - ) + panels = candidate.pop("_panels", {}) + if not (checking and verified): + best_sheet = (0, candidate, {}) + break + _cij_set(job_id, stage="render", detail=f"Checking the character sheet (attempt {attempt + 1})…") + sheet_verdicts = _check_sheet_panels(panels, sheet, settings_now) + if any(checker_unavailable(v) for v in sheet_verdicts.values()): + best_sheet, verified = (0, candidate, sheet_verdicts), False + break + score = failure_count(sheet_verdicts) + if best_sheet is None or score < best_sheet[0]: + best_sheet = (score, candidate, sheet_verdicts) + if score == 0: + break + result = best_sheet[1] + verdicts.update(best_sheet[2]) + if checking: + result["image_check"] = summarise(verdicts, attempts_used, verified) result = _consistent_image_result_metadata(sheet, result) result = _persist_consistent_image_artifacts(job_id, result) _cij_set(job_id, stage="done", detail="Done", done=True, result=result) diff --git a/static/js/library-characters.js b/static/js/library-characters.js index a62c85c..4a5aadb 100644 --- a/static/js/library-characters.js +++ b/static/js/library-characters.js @@ -1470,6 +1470,7 @@ async function _charDetailPage(rec, allChars, opts) { + (visualPairNeedsReview ? '
Dieses Bildpaar stammt aus einer älteren Prompt-Version und sollte für konsistente Identität neu erzeugt werden.
' : '') + + _libImageCheckNote(sh.image_check) + (sh.concept_art_image ? '
Concept art — ' + escHtml(rec.name) + '
' : '
' @@ -3774,6 +3775,26 @@ async function _cijPollJob(jobId, panel) { // The backend creates a clean identity anchor from structured book/character // facts. A legacy portrait is deliberately not reused because its hats, // clothing, props, or scenery would be inherited by the new image pair. +// What the automatic image check found for this pair: verified, still-faulty (with the +// faults listed), or not verified because the checker could not run. +function _libImageCheckNote(check) { + if (!check || typeof check !== 'object') return ''; + const attempts = Number(check.attempts) || 1; + const tries = attempts > 1 ? ' (' + attempts + ' Versuche)' : ''; + if (check.verified === false) { + return '
' + + ' Automatische Bildprüfung nicht verfügbar – Bilder ungeprüft.
'; + } + if (check.passed) { + return '
' + + ' Automatisch geprüft' + escHtml(tries) + ': keine Fehler gefunden.
'; + } + const failures = Array.isArray(check.failures) ? check.failures : []; + return ''; +} + async function _charAutoGenerateConsistentImages(rec, visualStyle) { const form = _libActiveCharacterForm(rec); const sh = form ? Object.assign({}, rec.sheet || {}, form) : (rec.sheet || {}); @@ -3801,7 +3822,7 @@ async function _charAutoGenerateConsistentImages(rec, visualStyle) { panel.setJobId(job_id); const d = await _cijPollJob(job_id, panel); if (!d.profile || !d.reference) throw new Error('ComfyUI returned an incomplete image pair'); - const visualFields = { image: d.profile, concept_art_image: d.reference, consistent_prompt_version: d.prompt_version || 'v3', consistent_identity_fingerprint: d.identity_fingerprint || '', profile_asset_digest: d.profile_asset_digest || '', reference_asset_digest: d.reference_asset_digest || '', identity_consistent: d.identity_consistent !== false }; + const visualFields = { image: d.profile, concept_art_image: d.reference, consistent_prompt_version: d.prompt_version || 'v3', consistent_identity_fingerprint: d.identity_fingerprint || '', profile_asset_digest: d.profile_asset_digest || '', reference_asset_digest: d.reference_asset_digest || '', identity_consistent: d.identity_consistent !== false, image_check: d.image_check || null }; await _libSaveCharacterVisual(rec, visualFields); document.querySelectorAll('.lib-char-avatar[data-char-id="' + CSS.escape(rec.id) + '"]').forEach(function (av) { av.innerHTML = '' + escHtml(rec.name) + ''; @@ -3939,7 +3960,7 @@ window._openConceptArtLightbox = _openConceptArtLightbox; window.libraryRenderCharacters = libraryRenderCharacters; Object.assign(root, { - _libStr, _charAutoGenerateConsistentImages, _libSetSheetPrompt, _libIdentityFingerprint, + _libStr, _charAutoGenerateConsistentImages, _libSetSheetPrompt, _libIdentityFingerprint, _libImageCheckNote, _libAddForm, _libRenameForm, _libDeleteForm, _libFormPairState, _libSetFormField, _libGenerateFormImages, }); root.TTVC = root.TTVC || {}; diff --git a/tests/characters.spec.js b/tests/characters.spec.js index 1750ace..415c404 100644 --- a/tests/characters.spec.js +++ b/tests/characters.spec.js @@ -411,3 +411,34 @@ test('the forms panel adds, renames and deletes a form and saves each change', a expect(saved.active_form_id).toBe(''); expect(state.rec.voice).toBe('de_bob'); // the character and its voice are untouched }); + +test('the automatic image check result is saved with the pair', async ({ page }) => { + const state = await mockRecord(page, { id: 'c-check', book: 'Smoke Book', name: 'Checked', tags: '', sheet: { physical: 'tall', image_prompt: 'p', concept_art_prompt: 'r' } }); + const imageCheck = { verified: true, passed: false, attempts: 3, failures: ['turnaround: feet cut off'], checks: {} }; + await page.route('**/api/character-generate-consistent-images', route => route.fulfill({ json: { job_id: 'job-check' } })); + await page.route('**/api/character-generate-consistent-images/status/job-check', route => route.fulfill({ json: { + stage: 'done', detail: 'Done', elapsed: 1, stale_seconds: 0, done: true, error: null, + result: { profile: PNG_A, reference: PNG_A + 'AA', prompt_version: 'v3', identity_fingerprint: 'abc', identity_consistent: true, image_check: imageCheck }, + } })); + await openLibrary(page); + await page.evaluate(() => window._charAutoGenerateConsistentImages({ id: 'c-check', book: 'Smoke Book', name: 'Checked', sheet: { physical: 'tall', image_prompt: 'p', concept_art_prompt: 'r' } }, 'cinematic')); + expect(state.puts[state.puts.length - 1].sheet.image_check).toEqual(imageCheck); +}); + +test('the image check note tells passed, failed and unverified apart', async ({ page }) => { + await mockRecord(page, { id: 'c-note', book: 'Smoke Book', name: 'Note', tags: '', sheet: {} }); + await openLibrary(page); + const notes = await page.evaluate(() => ({ + none: window._libImageCheckNote(null), + passed: window._libImageCheckNote({ verified: true, passed: true, attempts: 2, failures: [] }), + failed: window._libImageCheckNote({ verified: true, passed: false, attempts: 3, failures: ['scene: gender: shows male, sheet says female', 'x'] }), + unverified: window._libImageCheckNote({ verified: false, passed: false, attempts: 1, failures: ['portrait: checker unavailable'] }), + })); + expect(notes.none).toBe(''); + expect(notes.passed).toContain('keine Fehler'); + expect(notes.passed).toContain('2 Versuche'); + expect(notes.failed).toContain('role="alert"'); + expect(notes.failed).toContain('scene: gender: shows male, sheet says female'); + expect(notes.failed).not.toContain('x'); // failure text is escaped + expect(notes.unverified).toContain('ungeprüft'); +}); diff --git a/tests/test_character_image_check.py b/tests/test_character_image_check.py index 53ee2d2..32e0dd5 100644 --- a/tests/test_character_image_check.py +++ b/tests/test_character_image_check.py @@ -157,5 +157,135 @@ class RouteTests(unittest.TestCase): llm.assert_not_called() +def _png_uri(w, h, colour=(200, 180, 160)): + import base64, io + from PIL import Image + buf = io.BytesIO(); Image.new("RGB", (w, h), colour).save(buf, "PNG") + return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() + + +class FaceTransferGraphTests(unittest.TestCase): + def test_turnaround_and_scene_take_the_profile_face(self): + import copy, json + from pathlib import Path + graph = json.loads((Path(conversation.__file__).resolve().parent.parent + / "workflows/comfyui-character-consistent-images.json").read_text()) + conversation._graph_add_face_transfer(graph) + turn = graph[graph["turnaround_save"]["inputs"]["images"][0]] + scene = graph[graph["scene_t2i_save"]["inputs"]["images"][0]] + for node in (turn, scene): + self.assertEqual(node["class_type"], "ReActorFaceSwapOpt") + self.assertEqual(node["inputs"]["source_image"], ["7", 0]) # node 7 is the profile picture + self.assertEqual(turn["inputs"]["input_image"], ["turnaround_decode", 0]) + self.assertEqual(graph["turnaround_face_options"]["inputs"]["input_faces_order"], "left-right") + self.assertEqual(graph["scene_face_options"]["inputs"]["input_faces_order"], "large-small") + self.assertEqual(graph["scene_face_options"]["inputs"]["input_faces_index"], "0") + + +class GenerationRetryTests(unittest.TestCase): + """The generation job: check each render, retry with a new seed, keep the best attempt.""" + + def setUp(self): + app = FastAPI() + app.include_router(conversation.router) + self.client = TestClient(app) + self.settings = {"comfyui_url": "http://comfy.test", "llm_url": "http://llm.test", "llm_model": "m"} + + def run_job(self, identity_verdicts, sheet_verdicts, settings=None): + """identity_verdicts: list of portrait verdicts per attempt. + sheet_verdicts: list of {kind: verdict} dicts per sheet attempt.""" + import time + identity_calls, sheet_calls, portrait_i = [], [], iter(identity_verdicts) + sheet_i = iter(sheet_verdicts) + + def fake_identity(*args, **kw): + identity_calls.append(args[6]); return _png_uri(1024, 1536) + + def fake_sheet(*args, **kw): + sheet_calls.append(args[9]) + return {"profile": _png_uri(8, 8), "reference": _png_uri(8, 8), + "_panels": {"profile": _png_uri(64, 64), "turnaround": _png_uri(96, 64), "scene": _png_uri(48, 32)}} + + def fake_check(kind, images, sheet, settings, model="", timeout=180): + return next(portrait_i) # only the portrait is checked directly; the sheet goes through _check_sheet_panels + + def fake_sheet_checks(panels, sheet, settings): + return next(sheet_i) + + patches = [ + mock.patch.object(conversation, "_load_settings", return_value=settings or self.settings), + mock.patch.object(conversation, "_comfyui_run", side_effect=fake_identity), + mock.patch.object(conversation, "_comfyui_run_consistent_images", side_effect=fake_sheet), + mock.patch.object(conversation, "_image_check_run", side_effect=fake_check), + mock.patch.object(conversation, "_check_sheet_panels", side_effect=fake_sheet_checks), + mock.patch.object(conversation, "_persist_consistent_image_artifacts", side_effect=lambda job, r: r), + ] + for p in patches: p.start() + try: + job = self.client.post("/api/character-generate-consistent-images", json={ + "name": "Karyla", "sheet": {"gender": "female"}, "profile_prompt": "p", "reference_prompt": "r"}).json()["job_id"] + for _ in range(200): + status = self.client.get(f"/api/character-generate-consistent-images/status/{job}").json() + if status.get("done"): + break + time.sleep(0.05) + finally: + for p in patches: p.stop() + return status, identity_calls, sheet_calls + + OK = {"passed": True, "failures": [], "observed": {}} + MAN = {"passed": False, "failures": ["gender: shows male, sheet says female"], "observed": {}} + FEET = {"passed": False, "failures": ["feet cut off"], "observed": {}} + DOWN = {"passed": False, "failures": ["checker unavailable: down"], "observed": {}} + + def all_ok(self): + return {"turnaround": self.OK, "scene": self.OK, "same_person": self.OK} + + def test_everything_passes_first_time(self): + status, identity, sheets = self.run_job([self.OK], [self.all_ok()]) + self.assertIsNone(status.get("error"), status) + self.assertEqual((len(identity), len(sheets)), (1, 1)) + self.assertTrue(status["result"]["image_check"]["passed"]) + + def test_a_failed_portrait_is_regenerated_with_a_new_seed(self): + status, identity, _ = self.run_job([self.MAN, self.OK], [self.all_ok()]) + self.assertEqual(len(identity), 2) + self.assertNotEqual(identity[0], identity[1]) # a different seed key per attempt + self.assertTrue(status["result"]["image_check"]["passed"]) + + def test_a_failed_sheet_is_regenerated_until_it_passes(self): + bad = dict(self.all_ok(), scene=self.MAN) + status, _, sheets = self.run_job([self.OK], [bad, self.all_ok()]) + self.assertEqual(len(sheets), 2) + self.assertNotEqual(sheets[0], sheets[1]) + self.assertTrue(status["result"]["image_check"]["passed"]) + + def test_after_the_last_attempt_the_best_one_is_kept_and_its_faults_are_reported(self): + worse = dict(self.all_ok(), scene=self.MAN, turnaround=self.FEET) + better = dict(self.all_ok(), turnaround=self.FEET) + status, _, sheets = self.run_job([self.OK], [worse, better, worse]) + self.assertEqual(len(sheets), 3) + check = status["result"]["image_check"] + self.assertFalse(check["passed"]) + self.assertTrue(check["verified"]) + self.assertEqual(check["failures"], ["turnaround: feet cut off"]) # the better attempt was kept + + def test_an_unavailable_checker_stops_the_retries_and_marks_the_result_unverified(self): + status, identity, sheets = self.run_job([self.DOWN], [self.all_ok()]) + self.assertEqual(len(identity), 1) + self.assertEqual(len(sheets), 1) + self.assertFalse(status["result"]["image_check"]["verified"]) + self.assertFalse(status["result"]["image_check"]["passed"]) + + def test_with_the_check_switched_off_nothing_is_checked(self): + status, identity, sheets = self.run_job([], [], settings=dict(self.settings, image_check_enabled=False)) + self.assertEqual((len(identity), len(sheets)), (1, 1)) + self.assertNotIn("image_check", status["result"]) + + def test_internal_panels_never_reach_the_client(self): + status, _, _ = self.run_job([self.OK], [self.all_ok()]) + self.assertNotIn("_panels", status["result"]) + + if __name__ == "__main__": unittest.main()