feat: add consistent image job cancellation

This commit is contained in:
mARTin-B78 2026-09-04 08:34:35 +02:00
parent 2cda7df636
commit 4ffe6e9012
7 changed files with 146 additions and 10 deletions

View File

@ -55,6 +55,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
original temporary result; reusing a key for different request data is rejected.
- **Casting Idempotency.** Character-to-voice matching now applies the same bounded, expiring idempotency contract,
preventing repeated clicks from issuing duplicate LLM casting requests.
- **Image Job Cancellation.** Consistent profile/reference jobs now expose a cooperative cancellation endpoint and
progress-panel Cancel control; queued work can be cancelled without interrupting unrelated ComfyUI jobs.
- **Preview Blob Lifecycle.** Character-library and Rehearser local voice previews now revoke replaced or completed
object URLs, reducing retained browser memory during repeated auditioning.
- **Character Library Smoke Coverage.** Added deterministic browser coverage for opening Characters / Cast, profile

View File

@ -44,8 +44,8 @@ the first block before investing in architecture or polish.
2,227 segments, 1,043 dialogue segments and 585 newline-fused segments, but zero
adjacent same-speaker dialogue pairs for `_audiobookMergeAdjacentDialogue` to merge.
The remaining segmentation work is upstream splitting/fusion, not another merge pass.
- [ ] **S — Finish the remaining Playwright smoke checks.** Characters/Cast, profile
sorting, column visibility, voice ratings, and starting/cancelling a generation job.
- [x] **S — Finish the remaining Playwright smoke checks.** Characters/Cast, profile
sorting, column visibility, voice ratings, and starting/cancelling a generation job are covered.
- [x] **M — Fix the Schwester Larissa unclosed-guillemet split.** Attribution normalization now repairs dialogue
split before mixed German guillemet closes (`»…‹` / `«…›`), with an epigraph regression test.
- [ ] **M — Make profile portraits and character sheets use one canonical identity.**
@ -120,7 +120,8 @@ the first block before investing in architecture or polish.
## P1 — Backend stability and bounded concurrency
- [ ] Introduce background jobs for voice design, transcription, casting, and image generation.
- [ ] Give every job an ID, progress, cancellation, timeout, retry, and idempotency key.
- [ ] Give every job an ID, progress, cancellation, timeout, retry, and idempotency key. Consistent image jobs now
provide all except retry; voice design and casting remain synchronous request paths.
- [x] Prevent duplicate generation when a user clicks twice for image, voice-design, and casting submissions.
- [ ] Centralize provider health checks and error handling.
- [x] Limit concurrency to 1–2 image generations, 1–2 voice generations, and 1 STT request per user. Consistent
@ -170,7 +171,7 @@ the first block before investing in architecture or polish.
- [x] Sort the profile column.
- [x] Hide and show columns.
- [x] Rate a voice.
- [ ] Start and cancel a generation job.
- [x] Start and cancel a generation job.
## P2 — Observability

View File

@ -60,6 +60,7 @@ _attribution_llm_lock = threading.Lock()
# entries are pruned an hour after completion so this can't grow unbounded.
_consistent_image_jobs: dict[str, dict] = {}
_consistent_image_jobs_lock = threading.Lock()
_consistent_image_cancel_events: dict[str, threading.Event] = {}
_consistent_image_idempotency: dict[str, str] = {}
_consistent_image_slots = threading.BoundedSemaphore(2)
_CAST_IDEMPOTENCY: dict[str, tuple[str, dict, float]] = {}
@ -89,9 +90,11 @@ def _cij_new(job_id: str, name: str) -> None:
"name": name, "stage": "queued", "detail": "Queued…",
"started": now, "updated": now, "done": False, "error": None, "result": None,
}
_consistent_image_cancel_events[job_id] = threading.Event()
cutoff = now - 3600
for stale_id in [j for j, v in _consistent_image_jobs.items() if v.get("done") and v.get("updated", 0) < cutoff]:
_consistent_image_jobs.pop(stale_id, None)
_consistent_image_cancel_events.pop(stale_id, None)
for key, mapped_id in list(_consistent_image_idempotency.items()):
if mapped_id == stale_id:
_consistent_image_idempotency.pop(key, None)
@ -106,6 +109,17 @@ def _cij_set(job_id: str, **fields) -> None:
job["updated"] = time.time()
class _ConsistentImageCancelled(Exception):
"""Internal cooperative cancellation signal for a consistent-image worker."""
def _cij_check_cancelled(job_id: str) -> None:
with _consistent_image_jobs_lock:
event = _consistent_image_cancel_events.get(job_id)
if event is not None and event.is_set():
raise _ConsistentImageCancelled()
@contextlib.contextmanager
def _watchdog_close(resp, timeout_seconds):
"""Force-close `resp`'s connection if the wrapped block hasn't finished
@ -3191,12 +3205,27 @@ async def character_generate_consistent_images(request: Request):
_consistent_image_idempotency[idempotency_key] = job_id
def _worker() -> None:
acquired = _consistent_image_slots.acquire(timeout=900)
acquired = False
try:
for _ in range(900):
_cij_check_cancelled(job_id)
if _consistent_image_slots.acquire(timeout=1):
acquired = True
break
except _ConsistentImageCancelled:
_cij_set(job_id, stage="cancelled", detail="Cancelled", done=True,
cancelled=True, error="Generation cancelled")
with _consistent_image_jobs_lock:
_consistent_image_cancel_events.pop(job_id, None)
return
if not acquired:
_cij_set(job_id, stage="error", detail="Image queue timed out", done=True,
error="No image-generation slot became available within 15 minutes")
with _consistent_image_jobs_lock:
_consistent_image_cancel_events.pop(job_id, None)
return
try:
_cij_check_cancelled(job_id)
identity_image = identity_image_input
if identity_image.startswith("/"):
# Library records store same-origin image URLs rather than
@ -3210,6 +3239,7 @@ async def character_generate_consistent_images(request: Request):
except Exception as exc:
raise RuntimeError(f"Could not load the existing identity image: {exc}")
if not identity_image:
_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(),
@ -3218,14 +3248,15 @@ async def character_generate_consistent_images(request: Request):
# 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_set(job_id),
progress=lambda elapsed: (_cij_check_cancelled(job_id), _cij_set(job_id)),
)
_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_set(job_id),
progress=lambda elapsed: (_cij_check_cancelled(job_id), _cij_set(job_id)),
)
result = dict(result or {})
result["prompt_version"] = _CHARACTER_VISUAL_PROMPT_VERSION
@ -3234,10 +3265,15 @@ async def character_generate_consistent_images(request: Request):
_cij_set(job_id, stage="done", detail="Done", done=True, result=result)
except HTTPException as exc:
_cij_set(job_id, stage="error", detail="Failed", done=True, error=str(exc.detail))
except _ConsistentImageCancelled:
_cij_set(job_id, stage="cancelled", detail="Cancelled", done=True,
cancelled=True, error="Generation cancelled")
except Exception as exc:
_cij_set(job_id, stage="error", detail="Failed", done=True, error=str(exc))
finally:
_consistent_image_slots.release()
with _consistent_image_jobs_lock:
_consistent_image_cancel_events.pop(job_id, None)
threading.Thread(target=_worker, daemon=True, name=f"consistent-images-{job_id}").start()
return {"job_id": job_id}
@ -3261,6 +3297,27 @@ async def character_generate_consistent_images_status(job_id: str):
return payload
@router.post("/api/character-generate-consistent-images/cancel/{job_id}")
async def character_generate_consistent_images_cancel(job_id: str):
"""Request cooperative cancellation of a queued or running image job.
ComfyUI does not provide a safe per-client interrupt in all supported
versions, so the worker stops at its next stage/poll checkpoint without
interrupting another user's GPU job.
"""
with _consistent_image_jobs_lock:
job = _consistent_image_jobs.get(job_id)
event = _consistent_image_cancel_events.get(job_id)
if job is None:
raise HTTPException(404, "Unknown or expired job id")
if job.get("done"):
return {"ok": True, "already_done": True, "cancelled": bool(job.get("cancelled"))}
if event is not None:
event.set()
job.update({"stage": "cancelling", "detail": "Cancelling…", "updated": time.time()})
return {"ok": True, "cancelled": True}
def _character_generate_image_sync(provider: str, model: str, prompt: str, keys: dict, _settings: dict, image_kind: str = "portrait") -> dict:
if provider == "openai":
api_key = (keys.get("openai_image") or "").strip()

File diff suppressed because one or more lines are too long

View File

@ -3312,6 +3312,7 @@ function _cijShowPanel(name) {
panel.innerHTML = '<div class="cij-progress-head">'
+ '<span class="mdi mdi-face-recognition"></span>'
+ '<span class="cij-progress-title">Generating images — ' + escHtml(name || 'Character') + '</span>'
+ '<button type="button" class="btn-secondary btn-sm cij-progress-cancel">Cancel</button>'
+ '<button type="button" class="cij-progress-close" title="Hide (keeps running in the background)"><span class="mdi mdi-close"></span></button>'
+ '</div>'
+ '<div class="cij-progress-steps">' + stepsHtml + '</div>'
@ -3319,6 +3320,19 @@ function _cijShowPanel(name) {
+ '<div class="cij-progress-elapsed">Elapsed 0:00</div>';
document.body.appendChild(panel);
panel.querySelector('.cij-progress-close').addEventListener('click', function () { panel.remove(); });
const cancelButton = panel.querySelector('.cij-progress-cancel');
cancelButton.addEventListener('click', async function () {
cancelButton.disabled = true;
cancelButton.textContent = 'Cancelling…';
try {
const response = await fetch('/api/character-generate-consistent-images/cancel/' + encodeURIComponent(panel.dataset.jobId), { method: 'POST' });
if (!response.ok) throw new Error('Cancellation request failed');
} catch (error) {
cancelButton.disabled = false;
cancelButton.textContent = 'Cancel';
toast(error.message || 'Could not cancel generation', 'error');
}
});
return {
update: function (stage, detail, elapsed, warn) {
const p = document.getElementById('cij-progress-panel');
@ -3356,6 +3370,7 @@ function _cijShowPanel(name) {
p.classList.add(ok ? 'cij-progress-ok' : 'cij-progress-err');
setTimeout(function () { p.remove(); }, ok ? 2500 : 7000);
},
setJobId: function (jobId) { panel.dataset.jobId = jobId; },
};
}
@ -3366,7 +3381,8 @@ function _cijFmtElapsed(seconds) {
}
// Polls the background job until it finishes, updating the panel each tick.
// Flagged "stuck" (not aborted — there's no server-side cancel wired up yet)
// Flagged "stuck" while the backend reports no progress; cancellation is
// cooperative and completes at the next provider polling checkpoint.
// once 60s pass with no backend-side progress update, since the poll interval
// itself is only ~1.5s so any longer gap means the worker thread stopped
// reporting, not that ComfyUI is merely slow.
@ -3420,6 +3436,7 @@ async function _charAutoGenerateConsistentImages(rec, visualStyle) {
if (!r.ok) { const e = await r.json().catch(function () { return {}; }); throw new Error(e.detail || r.statusText); }
const { job_id } = await r.json();
const panel = _cijShowPanel(rec.name);
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');
if (typeof clSetImage === 'function') await clSetImage(rec.id, d.profile);

View File

@ -57,3 +57,35 @@ test('Characters / Cast supports profile sorting, column visibility, and voice r
await aliceRating.click();
await expect(aliceRating).toHaveClass(/is-on/);
});
test('consistent image generation can be cancelled from its progress panel', async ({ page }) => {
let cancelled = false;
let cancelRequests = 0;
await page.route('**/api/characters*', route => route.fulfill({ json: { characters: [] } }));
await page.route('**/api/voices*', route => route.fulfill({ json: [] }));
await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
await page.route('**/api/character-generate-consistent-images', route => route.fulfill({ json: { job_id: 'job-cancel' } }));
await page.route('**/api/character-generate-consistent-images/cancel/job-cancel', async route => {
cancelRequests += 1;
cancelled = true;
await route.fulfill({ json: { ok: true, cancelled: true } });
});
await page.route('**/api/character-generate-consistent-images/status/job-cancel', route => route.fulfill({
json: cancelled
? { stage: 'cancelled', detail: 'Cancelled', elapsed: 1, stale_seconds: 0, done: true, cancelled: true, error: 'Generation cancelled', result: null }
: { stage: 'identity', detail: 'Generating shared identity portrait…', elapsed: 0, stale_seconds: 0, done: false, error: null, result: null },
}));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => typeof window._charAutoGenerateConsistentImages === 'function', null, { timeout: 15_000 });
await page.evaluate(() => {
window.__cancelRun = window._charAutoGenerateConsistentImages({
id: 'cancel-character', name: 'Cancel Character', book: 'Smoke Book',
sheet: { image_prompt: 'portrait', concept_art_prompt: 'reference' },
}, 'cinematic').catch(() => null);
});
await expect(page.locator('#cij-progress-panel')).toBeVisible({ timeout: 10_000 });
await page.locator('#cij-progress-panel .cij-progress-cancel').click();
await expect.poll(() => cancelRequests).toBe(1);
await expect(page.locator('#cij-progress-panel')).toHaveClass(/cij-progress-err/, { timeout: 5_000 });
});

View File

@ -7,7 +7,14 @@ import unittest
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-conversation-test-config-"))
from routes.conversation import _attribution_parse, _fallback_attribute_response
from routes.conversation import (
_attribution_parse,
_cij_check_cancelled,
_cij_new,
_consistent_image_cancel_events,
_consistent_image_jobs,
_fallback_attribute_response,
)
class AttributionParserTests(unittest.TestCase):
@ -29,6 +36,26 @@ class AttributionParserTests(unittest.TestCase):
self.assertTrue(dialogue["text"].endswith("Land. Lieblich bisweilen, mild und voll der guten Menschen. ‹"))
self.assertEqual(narration["text"], "Zitiert aus dem Buch.")
def test_consistent_image_job_can_be_cancelled(self):
from fastapi.testclient import TestClient
from server import app
job_id = "cancel-test"
_cij_new(job_id, "Test character")
try:
with TestClient(app) as client:
response = client.post(
f"/api/character-generate-consistent-images/cancel/{job_id}",
headers={"origin": "http://testserver"},
)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["cancelled"])
with self.assertRaises(Exception):
_cij_check_cancelled(job_id)
finally:
_consistent_image_jobs.pop(job_id, None)
_consistent_image_cancel_events.pop(job_id, None)
if __name__ == "__main__":
unittest.main()