Skip to content

fix(video): i2v silently routed to T2V, dropping frames (#125) - #128

Merged
ffroliva merged 6 commits into
developfrom
bugfix/issue-125-i2v-t2v
May 30, 2026
Merged

ffroliva merged 6 commits into
developfrom
bugfix/issue-125-i2v-t2v

Conversation

@ffroliva

Copy link
Copy Markdown
Owner

Summary

Fixes #125 — gflow video i2v silently produced text-to-video output, ignoring the start/end frames and burning a credit on every call.

There were two independent silent-T2V paths; both are closed, plus three defense-in-depth layers so an i2v can never again silently degrade to T2V.

Root causes

  1. omni-flash does not support i2v interpolation. It was the i2v default. When an i2v request carried frame refs under omni-flash, Flow's frontend dropped the bound mediaIds at submit and routed to batchAsyncGenerateVideoText with image_inputs: null.
  2. The Veo 3.1 - Lite model-picker selector on develop was broken — an exact match (:text-is('volume_upVeo 3.1 - Lite')) hardcoding a Material Symbols icon-ligature prefix. It never matched, so even when the user explicitly requested --model veo-lite, _select_video_model warned-and-continued and Flow stayed on omni-flash → T2V. (Introduced by PR feat(video): wire i2v/r2v + model/duration/count picker; fix image --model no-op #48 aa047a3; the feature/avatar-generation branch fixed it but it never reached develop.)

Every i2v paid run on v0.10.0 before this fix produced T2V output regardless of the supplied frames.

How it was diagnosed (all zero-credit)

page.route(..., abort) request interception (scripts/dev/capture_i2v_intercept_submit.py) proved omni-flash → T2V/null and veo-lite → StartAndEndImage with bound mediaIds. A human-session HAR confirmed the manual UI uses veo_3_1_interpolation_lite. capture_i2v_model_select_repro.py reproduced the selector failure in the production call order and confirmed the robust selector fixes it. The fix plan (.planning/issue-125-fix.md) went through the 5-persona predict council.

Changes

Domain (api/video.py)

  • VideoModel.supports_i2v_interpolation() (False only for omni-flash) + I2V_DEFAULT_MODEL = VEO_3_1_LITE.

Errors (errors.py)

  • ModelModeIncompatibilityError (exit 17) — incompatible model/mode.
  • VideoModelSelectionError (exit 18) — model-picker UI failure for i2v.

CLI (cli_video.py)

  • omni-flash removed from the i2v --model Choice; omitted --model → veo-lite; stale config with omni-flash → exit 17.

Transport (api/transports/ui_automation_video.py)

  • DTO guard: omni-flash + i2v → ModelModeIncompatibilityError before any browser interaction.
  • i2v defaults model=None → veo-lite so direct FlowApiClient callers can't inherit Flow's omni-flash default.
  • Robust veo-lite picker selector: [role='menuitem']:has-text('Veo 3.1 - Lite'):not(:has-text('[Lower Priority]')).
  • Layer 1: _select_video_model(required=True) for i2v retries then raises VideoModelSelectionError (exit 18) before submit — spends no credit.
  • Layer 2: post-submit, an i2v request observed routing to the T2V endpoint raises WireFormatError (fires ui_automation_video.i2v_routed_to_t2v) rather than reporting a fake-success i2v.

Tests

  • Unit: supports_i2v_interpolation matrix, exit-17/18 mapping + ordering invariant, i2v Choice excludes omni-flash, default resolution, DTO guard, Layer 1 (fatal/non-fatal/retry-succeeds), Layer 2 (i2v→T2V raises / i2v→StartAndEnd succeeds / t2v→T2V unaffected).
  • e2e: test_e2e_i2v_start_end_frame_attach defaulted to omni-flash → it was a false positive (frame_attached + terminal-success both hold for T2V). Now defaults veo-lite and asserts the captured URL is StartAndEndImage.

Dev tooling (scripts/dev/)

  • capture_i2v_intercept_submit.py, capture_i2v_post_bind_state.py, capture_i2v_model_select_repro.py — zero-credit reproducers for future regressions.

Verification

  • 152 scoped unit/integration tests pass; ruff + pyright clean.
  • Live paid run (veo-lite, the stickman frames): model_selected → batchAsyncGenerateVideoStartAndEndImage → SUCCESSFUL mp4 (720×1280, 8s). Start frame matches 01-t2i.jpg (desk, pre-dawn), end frame matches 02-i2i.jpg lighting (same desk, golden sunrise) — same character + 2D ink style, a genuine interpolation, not a T2V scene-swap. No model_mode_rejected, no i2v_routed_to_t2v.

Notes

Closes #125

🤖 Generated with Claude Code

ffroliva and others added 5 commits May 30, 2026 19:15
…2V (#125)

omni-flash does NOT support i2v interpolation. When an i2v request carries
start/end frame refs with model=omni-flash, Flow's frontend drops the bound
mediaIds at submit time and routes the call to batchAsyncGenerateVideoText
with image_inputs:null — charging a credit for a pure text-to-video
generation that ignores the supplied frames entirely. omni-flash was the
i2v default in most sessions, so every i2v paid run on v0.10.0 produced T2V
output regardless of the start/end frames.

Confirmed at zero credit via scripts/dev/capture_i2v_intercept_submit.py
(page.route abort): omni-flash -> T2V/null (both start-only and start+end);
veo-lite -> StartAndEndImage with bound mediaIds. Human-session HAR shows
the manual UI flow uses veo_3_1_interpolation_lite and routes correctly.

Fix:
- VideoModel.supports_i2v_interpolation() (False only for omni-flash) and
  I2V_DEFAULT_MODEL = VEO_3_1_LITE in api/video.py (domain invariant).
- ModelModeIncompatibilityError (exit code 17) in errors.py, registered in
  EXIT_CODE_MAP before its parent ConfigurationError.
- cli_video: omni-flash dropped from the i2v --model Choice; omitted --model
  resolves to veo-lite; a stale --config naming omni-flash raises exit 17.
- Transport defense-in-depth: _generate_video_locked raises the typed error
  (emitting ui_automation_video.model_mode_rejected) BEFORE any browser
  interaction, catching direct FlowApiClient callers that bypass the CLI.
- CHANGELOG: Fixed + Changed entries.

Placement and scope follow the 5-persona council review of the fix plan
(.planning/issue-125-fix.md). 145 scoped tests pass; ruff + pyright clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two zero-credit Playwright probes that drove and verify the #125 diagnosis:

- capture_i2v_post_bind_state.py — confirms FRAME_SLOTS_STRUCT.count()
  transitions 2->1->0 on bind, the submit button is unambiguous, and the
  slot stays bound after prompt typing (ruled out selector / wrong-textbox
  hypotheses).
- capture_i2v_intercept_submit.py — the definitive reproducer. Intercepts
  the submit XHR via page.route(..., abort) and inspects the request body:
  --model omni-flash -> T2V/null; --model veo-lite -> StartAndEndImage with
  bound mediaIds. Supports --start-only and --reorder for scope probing.

.planning/issue-125-fix.md is the council-reviewed fix plan.

(capture_i2v_upload_dialog_dom.py — probe v1 that refuted the selector
hypothesis — is intentionally NOT shipped per the Devil's Advocate review.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ting (#125)

test_e2e_i2v_start_end_frame_attach defaulted to omni-flash, which silently
routes i2v to T2V (issue #125). The test still passed because its assertions
(frame_attached fires, terminal success, mp4 on disk) all hold for a T2V
generation too — it never checked that the frames actually reached Veo. This
is precisely why the bug shipped undetected.

- Default GFLOW_CLI_E2E_VIDEO_MODEL is now veo-lite (was omni-flash).
- New assertion #4: the captured generate request URL must contain
  batchAsyncGenerateVideoStartAndEndImage (not batchAsyncGenerateVideoText),
  and ui_automation_video.model_mode_rejected must NOT have fired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…125)

The first fix closed "user picks omni-flash". A paid verify run exposed a
SECOND silent-T2V path: even with a valid i2v model (veo-lite), the UI
model-select can intermittently fail (`model_option_not_found`, logged as a
warning) and Flow falls back to its default (omni-flash) -> i2v routes to T2V
-> wasted credit. The model-option probe is flaky (a menu-render race after
the trigger click).

Three additional layers, all anchored on "i2v must never silently become T2V":

- _select_video_model: new `required` param. For i2v the caller passes
  required=True; a model-select miss is now FATAL (raises RuntimeError with a
  screenshot) BEFORE any frame attach or submit — spending no credit. Plus a
  reliability retry: click the trigger + probe the option up to twice, pressing
  Escape between attempts to reset the dropdown.
- _generate_video_locked: for i2v with frames and model=None, default to
  I2V_DEFAULT_MODEL (mirrors the CLI) so a direct caller can't leave the model
  unset and inherit Flow's omni-flash default.
- Layer 2 post-submit backstop: after the generate response is captured, if an
  i2v request routed to batchAsyncGenerateVideoText, raise WireFormatError
  instead of returning a fake-success VideoResult (credit already spent, but no
  bogus "successful i2v" recorded). Fires `ui_automation_video.i2v_routed_to_t2v`.

Tests: TestSelectVideoModelRequired (fatal-on-miss, non-fatal when not required,
retry-succeeds-2nd-attempt) + TestI2vT2vRoutingBackstop (i2v->T2V raises,
i2v->StartAndEndImage succeeds, t2v->T2V unaffected). The existing i2v routing
test now feeds a StartImage url + asserts the model defaults to veo-lite with
required=True. 151 scoped tests pass; ruff + pyright clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The real root cause of the model-select failure (second silent-T2V path): the
VEO_3_1_LITE picker selector on develop is an EXACT match that hardcodes a
Material Symbols icon ligature — `:text-is('volume_upVeo 3.1 - Lite')`. The menu
item's accessible text no longer matches that literal, so the option was never
found, `_select_video_model` warned-and-continued, Flow stayed on omni-flash,
and i2v routed to T2V. (The avatar branch independently fixed this selector but
it never reached develop; introduced by PR #48 `aa047a3`.)

- Selector is now a robust substring match:
  `[role='menuitem']:has-text('Veo 3.1 - Lite'):not(:has-text('[Lower Priority]'))`
  — matches regardless of the icon prefix, excludes the Lower-Priority sibling.
  Confirmed zero-credit via scripts/dev/capture_i2v_model_select_repro.py
  (production call order): model_selected veo_3_1_lite, no retry needed.
- The required-failure now raises the typed `VideoModelSelectionError`
  (exit code 18) instead of a bare RuntimeError, so the CLI surfaces the
  remediation ("retry; if it persists the picker UI changed — see #125")
  instead of a generic "UnexpectedError exit 1".

errors.py: VideoModelSelectionError(ConfigurationError) + EXIT_CODE_MAP[18]
(ordered before ConfigurationError per the isinstance-walk invariant).
Tests updated (exit-18 + VideoModelSelectionError expectation). CHANGELOG
documents the second path + the three guard layers. 152 scoped tests pass;
ruff + pyright clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Branch-review council (8 dimensions) returned YELLOW with 2 valid must-fixes
(D13 credit-safety must-fix was a verified false positive — the abort glob
'video:batchAsync' IS a substring of the real route URL, and the probe
empirically aborted every run).

- docs/USAGE.md (D8): the shared-video-flags block advertised omni-flash +
  --duration 10 for all three video subcommands; that's wrong for i2v since
  #125. Documented i2v's narrowed --model choices (veo-lite default) and
  --duration [4|6|8]. Added exit-code table rows 17 (ModelModeIncompatibility)
  and 18 (VideoModelSelection).
- CHANGELOG (D1 nice-to-have): note that i2v --duration 10 was dropped.
- _select_video_model (D1/D6 nice-to-have): the unreachable
  `option_sel is None` + required branch now raises VideoModelSelectionError
  (exit 18) instead of a bare RuntimeError, consistent with the other
  required-misses; docstring corrected (was "raises RuntimeError").

D5 memory finding (supersede the wf-004 "Veo drift" diagnosis) applied to
auto-memory out-of-band. 84 scoped tests pass; ruff + pyright clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ffroliva
ffroliva merged commit e47a8af into develop May 30, 2026
6 checks passed
@ffroliva
ffroliva deleted the bugfix/issue-125-i2v-t2v branch May 30, 2026 22:30
tranzmatt pushed a commit to tranzmatt/gflow-cli-unofficial that referenced this pull request Jun 3, 2026
omni-flash does not support i2v interpolation — it silently drops the
start/end frames and produces a text-only video (gflow-cli ffroliva#125, fixed in
ffroliva#128). The example's --video-model now defaults to veo-lite, and the help
text documents the constraint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
15990166686ll-crypto pushed a commit to 15990166686ll-crypto/gflow-cli that referenced this pull request Aug 24, 2026
fix(video): i2v silently routed to T2V, dropping frames (ffroliva#125)
15990166686ll-crypto pushed a commit to 15990166686ll-crypto/gflow-cli that referenced this pull request Aug 24, 2026
omni-flash does not support i2v interpolation — it silently drops the
start/end frames and produces a text-only video (gflow-cli ffroliva#125, fixed in
ffroliva#128). The example's --video-model now defaults to veo-lite, and the help
text documents the constraint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

video i2v silently routes to T2V endpoint — start/end frames dropped, every i2v call burns credits as T2V

1 participant