Skip to content

feat(preview): retain the latest denoise frame server-side and replay it on reconnect - #215

Merged
lstein merged 4 commits into
mainfrom
feat/preview-snapshot
Sep 6, 2026
Merged

feat(preview): retain the latest denoise frame server-side and replay it on reconnect#215
lstein merged 4 commits into
mainfrom
feat/preview-snapshot

Conversation

@lstein

@lstein lstein commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Phase 2 of the preview restructuring, stacked on #214 (base branch fix/preview-coordinator; merge that first, then retarget this to main).

Progress frames travelled over the socket as base64 data URLs and existed nowhere else. A client whose socket was dropped while its tab was hidden, or a page reloaded mid-run, had nothing to show until the next denoising step. This adds the two server-side pieces JPPhoto's proposal got right, without the ephemeral file store or URL-based delivery: the server keeps the last emitted frame per running item in memory with a monotonic revision, and hands it out on reconnect.

What changes

Backend (additive)

  • invokeai/app/services/progress_previews/: MemoryProgressPreviews, one entry per running queue item, lock-guarded (multi-GPU workers). Cleared in _on_after_run_session whatever the outcome. Registered on InvocationServices as progress_previews, default-constructed when omitted so existing test constructors keep working.
  • InvocationProgressEvent.revision: int | None, monotonic per item + session on image-bearing frames. emit_invocation_progress takes it and returns the event.
  • signal_progress: image frames reserve a revision from the store; frames inside 100 ms of the previous one for the same item are dropped before the JPEG encode (final percentage >= 1 and indeterminate frames always pass). Imageless progress is untouched.
  • subscribe_queue replays the user's stored frames to the subscribing socket only; a socket that never lost them drops the duplicates via the revision gate.
  • GET /api/v1/queue/{queue_id}/previewslist[ProgressPreviewDTO], owner-scoped (progress is personal UI, so even admins see only their own). A dedicated DTO rather than the event model: using the event as a response model made FastAPI emit a second schema variant that turned every field optional in the legacy client's generated types.
  • The stored frame is dropped as soon as its node completes or errors (_on_after_run_node / _on_node_error), so a replay never re-marks a finished denoise node as running. The revision counter survives clear (bounded to 4096 frameless counters), so a workflow-call parent that suspends on waiting and resumes with the same item + session keeps issuing higher revisions than any a client holds. The processor-error path clears the item as well.
  • SD/SDXL PreviewExt now reports steps completed (step_index + 1) rather than the 0-based index, so its last frame reads 100% like the FLUX/SD3 callbacks do and is never throttled as a mid-run frame. Side effect: the SD progress bar reaches 100% at the last step.
  • Legacy web schema.ts regenerated; diff is purely additive and its tsc passes.

webv2

  • QueueBackendPort.readProgressPreviews, getProgressPreviews in the server API, and coordinator.refreshProgressPreviews(): fetched when the tab becomes visible (alongside the sweep) and after reconcile() re-adopts runs on reload. Each payload goes through the same handler as the socket event, where the revision gate from fix(preview): keep the last denoise frame through completion, reconnect and tab hiding #214 drops anything the live stream already delivered.

Contract (from the Discord thread)

  • Preview state: best-effort and disposable (memory only, restart clears).
  • Final image and queue outcome: durable and recoverable (unchanged; already true).
  • Reconnect: snapshot + reconcile, not replay of arbitrary old events.

Test plan

  • ruff check / ruff format --check clean
  • New tests: store (revisions, throttle, per-user listing, clear), signal_progress wiring, socket replay on subscribe, REST route scoping (admin sees only own; 401 unauthenticated)
  • Backend: tests/app/services, session-queue router tests, socket tests, tests/test_session_queue.py, tests/test_nodes.py
  • webv2: pnpm lint, pnpm test (7597), coordinator tests for the snapshot on visibility and after reload
  • Legacy web: tsc --noEmit against the regenerated schema
  • Adversarial fresh-context review of the diff; 4 confirmed findings fixed (revision restart on workflow-call resume, frame outliving its node, leak on the processor-error path, final SD frame throttled) plus test-stub updates
  • Manual: hide the tab for >1 min mid-batch; on return the preview shows the current frame before the next step arrives. Reload mid-run: the frame appears after reconcile.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DoHBJ9QPWfq1qMTfzdzuke

… it on reconnect

Progress frames travelled over the socket as base64 data URLs and existed
nowhere else. A client whose socket was dropped while its tab was hidden, or a
page that reloaded mid-run, had nothing to show until the next denoising step,
and nothing at all for a run that finished while it was away.

Phase 2 of the preview restructuring (additive, no schema removals):

- New `progress_previews` service on InvocationServices: an in-memory,
  lock-guarded map of the last emitted `invocation_progress` event per running
  queue item. Cleared when the session ends, whatever the outcome. No files, no
  TTL sweeps: a process restart empties it, which is the intended epoch.
- `InvocationProgressEvent.revision`: monotonic per queue item and session on
  image-bearing frames. The client drops any frame at or below the revision it
  has shown, so a replay can never move the preview backwards.
- `signal_progress` throttles image-bearing frames to one per 100 ms per item
  (final and indeterminate frames always pass), before the JPEG encode.
- `subscribe_queue` replays the user's stored frames to the subscribing socket.
- `GET /queue/{queue_id}/previews` returns the caller's frames as
  `ProgressPreviewDTO` (a dedicated model: exposing the event as a response
  model gave it a second OpenAPI schema variant that made every field optional
  in the generated client types).
- webv2: the queue coordinator fetches the snapshot when the tab becomes
  visible and after re-adopting runs on reload, feeding each payload through
  the socket handler's revision gate. The gate reopens when an item goes back
  to pending/waiting.
- A frame is dropped from the store as soon as its node completes or errors,
  so a replay never shows a finished denoise as running; the revision counter
  survives `clear` (bounded) so a workflow-call parent resuming with the same
  item and session keeps issuing higher revisions than any a client holds;
  the processor-error path clears the item too.
- The SD/SDXL preview extension now reports steps completed rather than the
  index of the step just run, so its last frame reads 100% like the FLUX and
  SD3 callbacks and the throttle never drops it as a mid-run frame.

Legacy `web` schema regenerated (additive only); its typecheck passes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DoHBJ9QPWfq1qMTfzdzuke
…ew store

The openapi-checks workflow diffs `invokeai/frontend/web/openapi.json`
(separate from the typegen check's `schema.ts`); regenerated for the new
previews route and the `revision` field. The `ProgressImage` component only
reorders keys.

`test_socket_privilege_revocation.py` binds `ApiDependencies` to hand-built
services stubs; the subscribe replay now reads `progress_previews` from them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DoHBJ9QPWfq1qMTfzdzuke
Base automatically changed from fix/preview-coordinator to main September 6, 2026 21:05
With two slots live — a long video render next to a quick image batch — the
single-frame preview read the store-wide "latest" frame and matched it to the
followed placeholder. The latest belonged to whichever slot stepped last, and
releasing that slot when its batch finished nulled it outright. The video
slot's own frame was still stored per slot (the gallery cell kept showing
it), but the preview rendered an empty card until the video's next step,
minutes away. Reloading fixed it only because reconcile re-fed the frame.

LivePreview now reads its followed slot's frame directly, and the store's
latest falls back to the most recently updated remaining slot instead of
null so the header toggle and the editor's Current Image node do not blank
either. The store-wide matcher is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DoHBJ9QPWfq1qMTfzdzuke
(cherry picked from commit 0f36ead)
@lstein
lstein enabled auto-merge September 6, 2026 22:13
@lstein
lstein merged commit f6c9b38 into main Sep 6, 2026
19 checks passed
@lstein
lstein deleted the feat/preview-snapshot branch September 6, 2026 22:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant