From 1b1d3732dcf239b36266342ef43519f283ff02a3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 1 Jul 2026 22:01:20 -0400 Subject: [PATCH 1/8] feat(agent): store ffmpeg's actual failure reason in the job's error field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sample_frames_from_url now returns (frames, reason) — reason carries the SPECIFIC cause on failure (ffmpeg's stderr tail, e.g. "moov atom not found", or the timeout) instead of only logging it agent-side. The worker folds it into the failure it reports, so curator's GpuJob.error reads e.g. no frames sampled from video — ffmpeg exit 183: moov atom not found ... instead of the bare "(unprocessable)". The errored-jobs list becomes self-describing: after a retry sweep, surviving errors name their real defect without needing the agent log. Return-value plumbing (not shared state) so concurrent downloaders stay isolated. Agent VERSION → 2026-07-02.2. Co-Authored-By: Claude Fable 5 --- agent/fc_agent/app.py | 2 +- agent/fc_agent/media.py | 29 ++++++++++++++++++----------- agent/fc_agent/worker.py | 13 ++++++++----- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/agent/fc_agent/app.py b/agent/fc_agent/app.py index 51de0b6..0642b37 100644 --- a/agent/fc_agent/app.py +++ b/agent/fc_agent/app.py @@ -21,7 +21,7 @@ log = logging.getLogger("fc_agent.app") # Bump on every agent change. The page embeds this and /status reports it; the UI # warns to reload when they differ — so a stale browser-cached page can't be # mistaken for "the new image didn't deploy". (Belt-and-braces with no-store.) -VERSION = "2026-07-02.1 · short videos sample again (select, not fps) + ffmpeg errors logged" +VERSION = "2026-07-02.2 · job.error now carries ffmpeg's actual failure reason" logbuf.install() cfg = Config.from_env() diff --git a/agent/fc_agent/media.py b/agent/fc_agent/media.py index 48c9ed5..99c49d7 100644 --- a/agent/fc_agent/media.py +++ b/agent/fc_agent/media.py @@ -125,7 +125,7 @@ def _terminate(proc: subprocess.Popen) -> None: def sample_frames_from_url( url: str, interval_seconds: float, max_frames: int, *, headers: str = "", timeout: float = 1200.0, should_stop=None, -) -> list[tuple[float, Image.Image]]: +) -> tuple[list[tuple[float, Image.Image]], str | None]: """Sample frames by pointing ffmpeg STRAIGHT at the media URL — it Range-reads only the video index + up to max_frames worth of content, so the agent never downloads the whole file (VR/4K originals run 800MB+ and would buffer ~1GB in @@ -133,7 +133,12 @@ def sample_frames_from_url( the timeout is the per-video ceiling (a slow/reconnecting stream can otherwise run for minutes). `should_stop` is polled while ffmpeg runs so a Stop KILLS the subprocess at once — otherwise a downloader stuck in a long decode keeps the - agent "working" long after Stop. Empty on failure / stop / timeout.""" + agent "working" long after Stop. + + Returns (frames, reason): frames is empty on failure/stop/timeout, and + `reason` then carries the SPECIFIC cause (ffmpeg's stderr tail / timeout) so + the caller can put it in the job's error — a bare "no frames" hid a filter + bug as "unprocessable" for weeks. None reason on success.""" interval = max(0.5, float(interval_seconds or 4.0)) cap = max(1, int(max_frames or 64)) hdr = ["-headers", headers] if headers else [] @@ -174,17 +179,19 @@ def sample_frames_from_url( stopped = should_stop and should_stop() if stopped or (time.monotonic() - start > timeout): _terminate(proc) - if not stopped: - log.warning("ffmpeg timed out after %.0fs: %s", - timeout, url) - return [] - except (OSError, ValueError): - return [] + if stopped: + return [], "stopped" + log.warning("ffmpeg timed out after %.0fs: %s", + timeout, url) + return [], f"ffmpeg timed out after {timeout:.0f}s" + except (OSError, ValueError) as exc: + return [], f"ffmpeg not runnable: {exc}" frames = _collect_frames(tmp, interval, cap) if not frames: - log.warning("ffmpeg produced no frames (exit %s) for %s — stderr: %s", - proc.returncode, url, _tail(errpath)) - return frames + reason = f"ffmpeg exit {proc.returncode}: {_tail(errpath)}" + log.warning("ffmpeg produced no frames for %s — %s", url, reason) + return [], reason + return frames, None def _tail(path: str, limit: int = 300) -> str: diff --git a/agent/fc_agent/worker.py b/agent/fc_agent/worker.py index 2841845..3c54b3d 100644 --- a/agent/fc_agent/worker.py +++ b/agent/fc_agent/worker.py @@ -632,7 +632,7 @@ class Worker: # mid-download was the failure loop). Environment-agnostic + resilient. url = f"{self.cfg.fc_url}{job['image_url']}" with self._timed("decode"): - frames = media.sample_frames_from_url( + frames, ffmpeg_err = media.sample_frames_from_url( url, job.get("frame_interval_seconds", 4.0), job.get("max_frames", 64), headers=self._auth_header, timeout=self.cfg.ffmpeg_timeout, @@ -644,11 +644,14 @@ class Worker: if self._stopped(stop_evt): raise requests.ConnectionError("stopped during video sampling") # Else couldn't sample. If curator is up, the file is unprocessable - # → a job fault (fail it, don't re-lease forever). If curator is - # unreachable, it's transient → let the loop back off + retry - # (survives a redeploy). ConnectionError is caught as transient. + # → a job fault: fail it WITH ffmpeg's reason, so the job's stored + # error says e.g. "moov atom not found" instead of a bare + # "unprocessable". If curator is unreachable, it's transient → let + # the loop back off + retry (ConnectionError is caught as such). if self.client.is_reachable(): - raise RuntimeError("no frames sampled from video (unprocessable)") + raise RuntimeError( + f"no frames sampled from video — {ffmpeg_err or 'unknown reason'}" + ) raise requests.ConnectionError("curator unreachable during video sampling") # Temporal dedup: a near-static video re-runs the whole detect+embed # chain on ~identical frames — drop near-dups HERE (CPU) pre-GPU. From 3d6201734c5ebca8e6389549c606a417eadf9187 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 1 Jul 2026 22:28:52 -0400 Subject: [PATCH 2/8] fix(settings): maintenance tiles start collapsed; remember manual open state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GpuAgentCard was hardcoded :open=true, HeadsCard opened whenever any head existed, TagEvalCard whenever a persisted run existed — so a fresh Settings load greeted the operator with several tiles already expanded. All three now force-open only while their task is actually running (the #877 resurface behavior on the busy-driven tiles is untouched). MaintenanceTile additionally persists MANUAL expand/collapse per tile in localStorage, so the section reloads the way the operator left it; a forced open while a task runs stays transient and is never saved as a preference. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM --- .../src/components/common/MaintenanceTile.vue | 19 +++++++++++++++---- .../src/components/settings/GpuAgentCard.vue | 1 - .../src/components/settings/HeadsCard.vue | 2 +- .../src/components/settings/TagEvalCard.vue | 2 +- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/common/MaintenanceTile.vue b/frontend/src/components/common/MaintenanceTile.vue index fd0c54b..ad63e45 100644 --- a/frontend/src/components/common/MaintenanceTile.vue +++ b/frontend/src/components/common/MaintenanceTile.vue @@ -7,8 +7,10 @@ Usage: wrap a card's action body in the default slot; pass icon/title/blurb. `destructive` tints the icon error-red for delete actions. `open` can be forced - (e.g. keep a running task's tile expanded). Keyboard accessible: the header is a - real