diff --git a/CHANGELOG.md b/CHANGELOG.md index 96630ea..c592173 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. ### 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. - **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 `.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. diff --git a/routes/conversation.py b/routes/conversation.py index 515e636..e0e96b9 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -3987,11 +3987,6 @@ async def character_generate_consistent_images(request: Request): idempotency_key = str(request.headers.get("idempotency-key") or "").strip() if len(idempotency_key) > 200: 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() book = str(data.get("book") or "").strip() sheet: dict = _character_form_sheet(data.get("sheet") or {}) @@ -4003,6 +3998,21 @@ async def character_generate_consistent_images(request: Request): visual_style = "cinematic" 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") + # 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() comfy_url = str(settings.get("comfyui_url") or "").strip() if not comfy_url: @@ -4047,6 +4057,7 @@ async def character_generate_consistent_images(request: Request): job_id = uuid.uuid4().hex _cij_new(job_id, name or "Character") with _consistent_image_jobs_lock: + _consistent_image_jobs[job_id]["request_fingerprint"] = request_fingerprint _consistent_image_jobs[job_id]["retry_data"] = { "name": name, "book": book, "sheet": _strip_image_payloads(sheet), "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["stale_seconds"] = round(now - payload.pop("updated"), 1) payload.pop("retry_data", None) + payload.pop("request_fingerprint", None) return payload diff --git a/tests/test_character_image_isolation.py b/tests/test_character_image_isolation.py new file mode 100644 index 0000000..9ae88d9 --- /dev/null +++ b/tests/test_character_image_isolation.py @@ -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()