fix(characters): isolate image generation per character

Bind the consistent-image Idempotency-Key to the character request so a reused
key cannot return another character's job; add back-to-back and concurrent
two-character regression tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-02 22:34:07 +02:00
parent 4d4dc334e7
commit 084a99af2c
3 changed files with 186 additions and 5 deletions

View File

@ -17,6 +17,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- **Driving mode shows three lines of text instead of the whole article.** The previous sentence (small), the current one (large, with the spoken word marked) and the next one (small) sit in a fixed-height box, so the controls below never jump. Lines fade in smoothly (no animation when your device asks for reduced motion), the text is not a live region so a screen reader does not read out every sentence, it is readable at 375 px, and tapping the previous or next line jumps to that sentence. Pictures work as before. - **Driving mode shows three lines of text instead of the whole article.** The previous sentence (small), the current one (large, with the spoken word marked) and the next one (small) sit in a fixed-height box, so the controls below never jump. Lines fade in smoothly (no animation when your device asks for reduced motion), the text is not a live region so a screen reader does not read out every sentence, it is readable at 375 px, and tapping the previous or next line jumps to that sentence. Pictures work as before.
### Fixed ### Fixed
- **Character image runs are isolated per character.** The profile + reference-sheet job (`POST /api/character-generate-consistent-images`) could hand back an earlier character's job when the same `Idempotency-Key` was reused for a different character; the key is now bound to a fingerprint of the character's name, book, identity fields, style and prompts, and a mismatch is rejected with 409 instead of returning the other character's images. The fingerprint is not exposed in the status payload. New regression tests (`tests/test_character_image_isolation.py`) generate a dwarf and an elf back to back and concurrently and check that each ComfyUI request carries only its own prompts, props, identity image, seed family and result, and that the request data is not mutated.
- **Characters table on phones: the rating stars can be tapped again.** At 320-414 px the pinned checkbox, picture and name columns (328 px) covered the stars of the voice rating; on phones they now scroll with the table, the rating column is wide enough for five 44 px stars, and each star has a *Rate n of 5* label. Browser tests check at 320, 375 and 414 px that no star touches a pinned column, is hit-testable, is at least 44 px and that the page itself does not scroll sideways; the phone case of the profile/rating test is no longer skipped. - **Characters table on phones: the rating stars can be tapped again.** At 320-414 px the pinned checkbox, picture and name columns (328 px) covered the stars of the voice rating; on phones they now scroll with the table, the rating column is wide enough for five 44 px stars, and each star has a *Rate n of 5* label. Browser tests check at 320, 375 and 414 px that no star touches a pinned column, is hit-testable, is at least 44 px and that the page itself does not scroll sideways; the phone case of the profile/rating test is no longer skipped.
- **Highlight stays in step through seeking, speed changes and background tabs.** The highlighted sentence is now read from the audio's own position on every animation frame (and immediately on seeking, seeked, rate change, play, pause and when the tab becomes visible) using a binary search over the sentence start times, so there is no cached position to go stale; a jump into a silent gap keeps the sentence just spoken, and the marked word follows the sentence's own start and end, weighted by word length. New browser tests cover seeking back and forward, 1.5x and 2x, speed changes and rapid repeated seeks. - **Highlight stays in step through seeking, speed changes and background tabs.** The highlighted sentence is now read from the audio's own position on every animation frame (and immediately on seeking, seeked, rate change, play, pause and when the tab becomes visible) using a binary search over the sentence start times, so there is no cached position to go stale; a jump into a silent gap keeps the sentence just spoken, and the marked word follows the sentence's own start and end, weighted by word length. New browser tests cover seeking back and forward, 1.5x and 2x, speed changes and rapid repeated seeks.
- **Audio and text now line up when the voice actually starts.** Measured on real rendered articles (an 8-sentence and a 71-sentence MP3): the recorded sentence boundaries are exact (no drift over 10 minutes, no encoder-delay offset: the MP3 carries a LAME/Info header, so browsers trim the encoder delay), but the voice engine pads every sentence with 0.4 to 0.9 s of silence before the first word, so the highlight lit up about 0.7 s early. New renders now record where speech begins in each sentence (`onsets` in `<id>.timings.json`), older audio is measured from the decoded MP3 on request (in memory, nothing is written), and `/timings` returns it as `speech` per sentence. - **Audio and text now line up when the voice actually starts.** Measured on real rendered articles (an 8-sentence and a 71-sentence MP3): the recorded sentence boundaries are exact (no drift over 10 minutes, no encoder-delay offset: the MP3 carries a LAME/Info header, so browsers trim the encoder delay), but the voice engine pads every sentence with 0.4 to 0.9 s of silence before the first word, so the highlight lit up about 0.7 s early. New renders now record where speech begins in each sentence (`onsets` in `<id>.timings.json`), older audio is measured from the decoded MP3 on request (in memory, nothing is written), and `/timings` returns it as `speech` per sentence.

View File

@ -3987,11 +3987,6 @@ async def character_generate_consistent_images(request: Request):
idempotency_key = str(request.headers.get("idempotency-key") or "").strip() idempotency_key = str(request.headers.get("idempotency-key") or "").strip()
if len(idempotency_key) > 200: if len(idempotency_key) > 200:
raise HTTPException(400, "Idempotency-Key is too long") raise HTTPException(400, "Idempotency-Key is too long")
if idempotency_key:
with _consistent_image_jobs_lock:
previous_job_id = _consistent_image_idempotency.get(idempotency_key)
if previous_job_id and previous_job_id in _consistent_image_jobs:
return {"job_id": previous_job_id, "deduplicated": True}
name = str(data.get("name") or "").strip() name = str(data.get("name") or "").strip()
book = str(data.get("book") or "").strip() book = str(data.get("book") or "").strip()
sheet: dict = _character_form_sheet(data.get("sheet") or {}) sheet: dict = _character_form_sheet(data.get("sheet") or {})
@ -4003,6 +3998,21 @@ async def character_generate_consistent_images(request: Request):
visual_style = "cinematic" visual_style = "cinematic"
if not saved_profile_prompt or not saved_reference_prompt: if not saved_profile_prompt or not saved_reference_prompt:
raise HTTPException(400, "Both a profile image prompt and a character reference prompt are required") raise HTTPException(400, "Both a profile image prompt and a character reference prompt are required")
# An idempotency key may only ever return a job for the SAME character
# request. Bind it to a fingerprint of this character's identity inputs so
# a reused/stale key can never hand back another character's job (and so
# its profile/reference images).
request_fingerprint = hashlib.sha256(json.dumps(
[name, book, _character_identity_fingerprint(sheet), visual_style, saved_profile_prompt, saved_reference_prompt],
ensure_ascii=False).encode("utf-8")).hexdigest()
if idempotency_key:
with _consistent_image_jobs_lock:
previous_job_id = _consistent_image_idempotency.get(idempotency_key)
previous_job = _consistent_image_jobs.get(previous_job_id) if previous_job_id else None
if previous_job is not None:
if previous_job.get("request_fingerprint") != request_fingerprint:
raise HTTPException(409, "Idempotency-Key was already used for a different character request")
return {"job_id": previous_job_id, "deduplicated": True}
settings = _load_settings() settings = _load_settings()
comfy_url = str(settings.get("comfyui_url") or "").strip() comfy_url = str(settings.get("comfyui_url") or "").strip()
if not comfy_url: if not comfy_url:
@ -4047,6 +4057,7 @@ async def character_generate_consistent_images(request: Request):
job_id = uuid.uuid4().hex job_id = uuid.uuid4().hex
_cij_new(job_id, name or "Character") _cij_new(job_id, name or "Character")
with _consistent_image_jobs_lock: with _consistent_image_jobs_lock:
_consistent_image_jobs[job_id]["request_fingerprint"] = request_fingerprint
_consistent_image_jobs[job_id]["retry_data"] = { _consistent_image_jobs[job_id]["retry_data"] = {
"name": name, "book": book, "sheet": _strip_image_payloads(sheet), "name": name, "book": book, "sheet": _strip_image_payloads(sheet),
"profile_prompt": saved_profile_prompt, "reference_prompt": saved_reference_prompt, "profile_prompt": saved_profile_prompt, "reference_prompt": saved_reference_prompt,
@ -4216,6 +4227,7 @@ async def character_generate_consistent_images_status(job_id: str):
payload["elapsed"] = round(now - payload.pop("started", payload.get("created", now)), 1) payload["elapsed"] = round(now - payload.pop("started", payload.get("created", now)), 1)
payload["stale_seconds"] = round(now - payload.pop("updated"), 1) payload["stale_seconds"] = round(now - payload.pop("updated"), 1)
payload.pop("retry_data", None) payload.pop("retry_data", None)
payload.pop("request_fingerprint", None)
return payload return payload

View File

@ -0,0 +1,168 @@
"""Per-character isolation of the consistent-image (profile + reference sheet) generation path.
Two deliberately dissimilar characters are generated back to back and concurrently; each ComfyUI
request must carry only its own prompt, seed family, identity image and result.
"""
from __future__ import annotations
import os
import tempfile
import threading
import time
import unittest
from unittest import mock
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-charisol-test-config-"))
from fastapi import FastAPI
from fastapi.testclient import TestClient
import routes.conversation as conv
DWARF = {
"name": "Borin", "book": "Isolation Book",
"sheet": {"race_species": "Zwerg", "gender": "male", "age": "60", "physical": "stocky dwarf with braided red beard",
"clothing": "heavy forged-iron mail", "items": ["war hammer"]},
}
ELF = {
"name": "Lirael", "book": "Isolation Book",
"sheet": {"race_species": "Elf", "gender": "female", "age": "30", "physical": "tall slender elf with silver hair",
"clothing": "light green linen robe", "items": ["longbow"]},
}
def _payload(char: dict) -> dict:
sheet = char["sheet"]
return {
"name": char["name"], "book": char["book"], "sheet": sheet, "visual_style": "cinematic",
"book_profile": {},
"profile_prompt": f"Profile portrait of {char['name']}: {sheet['physical']}",
"reference_prompt": f"Reference sheet of {char['name']}: {sheet['physical']}",
}
def _uri(tag: str) -> str:
return "data:image/png;base64,UE5H" + tag.encode().hex()
class ConsistentImageIsolationTests(unittest.TestCase):
def setUp(self):
self.identity_calls: list[dict] = []
self.sheet_calls: list[dict] = []
self.lock = threading.Lock()
self.delay = 0.0
conv._consistent_image_jobs.clear()
conv._consistent_image_idempotency.clear()
def fake_identity(url, workflow, node, field, out, prompt, seed_key="", progress=None):
time.sleep(self.delay)
with self.lock:
self.identity_calls.append({"prompt": prompt, "seed_key": seed_key})
return _uri("identity:" + seed_key.split("|")[3])
def fake_sheet(url, workflow, identity_image, profile_prompt, front, side, back, scene, props,
style_key="", progress=None, face_transfer=True):
time.sleep(self.delay)
with self.lock:
self.sheet_calls.append({"identity": identity_image, "profile": profile_prompt, "front": front,
"side": side, "back": back, "scene": scene, "props": list(props),
"style_key": style_key})
owner = style_key.split("|")[3]
return {"profile": _uri("profile:" + owner), "reference": _uri("reference:" + owner), "_panels": {}}
patches = [
mock.patch.object(conv, "_load_settings", return_value={"comfyui_url": "http://comfy.invalid",
"image_check_enabled": False}),
mock.patch.object(conv, "_comfyui_run", side_effect=fake_identity),
mock.patch.object(conv, "_comfyui_run_consistent_images", side_effect=fake_sheet),
mock.patch.object(conv, "_persist_consistent_image_artifacts", side_effect=lambda job, result: dict(result)),
mock.patch.object(conv, "background_job_create"),
mock.patch.object(conv, "background_job_update"),
mock.patch.object(conv, "background_job_get", return_value=None),
]
for p in patches:
p.start()
self.addCleanup(p.stop)
app = FastAPI()
app.include_router(conv.router)
self.client = TestClient(app)
def _run(self, char: dict, headers: dict | None = None) -> dict:
resp = self.client.post("/api/character-generate-consistent-images", json=_payload(char), headers=headers or {})
self.assertEqual(resp.status_code, 200, resp.text)
return self._wait(resp.json()["job_id"])
def _wait(self, job_id: str) -> dict:
for _ in range(200):
status = self.client.get(f"/api/character-generate-consistent-images/status/{job_id}").json()
if status.get("done"):
self.assertIsNone(status.get("error"), status)
return status
time.sleep(0.05)
self.fail("job did not finish")
def _assert_separate(self, dwarf_result: dict, elf_result: dict) -> None:
self.assertEqual(len(self.sheet_calls), 2)
by_owner = {c["style_key"].split("|")[3]: c for c in self.sheet_calls}
dwarf_call, elf_call = by_owner["Borin"], by_owner["Lirael"]
# prompts: each request holds only its own character's traits
for key in ("profile", "front", "side", "back", "scene"):
self.assertNotIn("Lirael", dwarf_call[key]); self.assertNotIn("elf", dwarf_call[key].lower().replace("self", ""))
self.assertNotIn("Borin", elf_call[key]); self.assertNotIn("dwarf", elf_call[key].lower())
self.assertNotIn("longbow", " ".join(dwarf_call["props"]).lower())
self.assertNotIn("hammer", " ".join(elf_call["props"]).lower())
# identity reference image and seed family are per-character
self.assertIn("identity:Borin".encode().hex(), dwarf_call["identity"])
self.assertIn("identity:Lirael".encode().hex(), elf_call["identity"])
self.assertNotEqual(dwarf_call["style_key"], elf_call["style_key"])
self.assertNotEqual(dwarf_call["identity"], elf_call["identity"])
for call in self.identity_calls:
owner = call["seed_key"].split("|")[3]
other = "Lirael" if owner == "Borin" else "Borin"
self.assertNotIn(other, call["prompt"])
# results
self.assertEqual(dwarf_result["result"]["profile"], _uri("profile:Borin"))
self.assertEqual(dwarf_result["result"]["reference"], _uri("reference:Borin"))
self.assertEqual(elf_result["result"]["profile"], _uri("profile:Lirael"))
self.assertEqual(elf_result["result"]["reference"], _uri("reference:Lirael"))
self.assertNotEqual(dwarf_result["result"]["identity_fingerprint"], elf_result["result"]["identity_fingerprint"])
def test_back_to_back_runs_do_not_share_prompts_refs_or_results(self):
first = self._run(DWARF)
second = self._run(ELF)
self._assert_separate(first, second)
def test_concurrent_runs_do_not_share_prompts_refs_or_results(self):
self.delay = 0.15
results: dict[str, str] = {}
def submit(char):
r = self.client.post("/api/character-generate-consistent-images", json=_payload(char))
results[char["name"]] = r.json()["job_id"]
threads = [threading.Thread(target=submit, args=(c,)) for c in (DWARF, ELF)]
[t.start() for t in threads]; [t.join() for t in threads]
self.assertNotEqual(results["Borin"], results["Lirael"])
self._assert_separate(self._wait(results["Borin"]), self._wait(results["Lirael"]))
def test_source_sheet_and_payload_are_not_mutated(self):
payload = _payload(DWARF)
before = repr(payload)
self.client.post("/api/character-generate-consistent-images", json=payload)
self.assertEqual(repr(payload), before)
self.assertEqual(repr(DWARF["sheet"]), repr(_payload(DWARF)["sheet"]))
def test_idempotency_key_never_returns_another_characters_job(self):
headers = {"Idempotency-Key": "reused-key"}
first = self.client.post("/api/character-generate-consistent-images", json=_payload(DWARF), headers=headers)
job_id = first.json()["job_id"]
same = self.client.post("/api/character-generate-consistent-images", json=_payload(DWARF), headers=headers)
self.assertEqual(same.json(), {"job_id": job_id, "deduplicated": True})
other = self.client.post("/api/character-generate-consistent-images", json=_payload(ELF), headers=headers)
self.assertEqual(other.status_code, 409)
self._wait(job_id)
self.assertEqual({c["style_key"].split("|")[3] for c in self.sheet_calls}, {"Borin"})
if __name__ == "__main__":
unittest.main()