Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **`video chain --out-file PATH` (and the deprecated `-o`) now writes the clips there.** It was
accepted and ignored. Link i's clip goes to `<stem>_<i><suffix>` next to PATH
(`out/clip.mp4` gives `out/clip_1.mp4`, `out/clip_2.mp4`), numbered over the whole manifest so
`--resume-from` keeps each link's name; `--json`, the chain table and the videos catalog report
those paths, and seed frames stay under `--out-dir`. An existing file or folder at a numbered
path is never replaced, and a clip whose move fails is never lost: either way it stays where it
downloaded, is recorded there, and a warning names the link, its media id and the path. A
FILE under an existing file is a usage error before the cost prompt. The stderr notice is gone
([#1293](https://github.com/ffroliva/gflow-cli/issues/1293)).
- **`video chain --resume-from` continues the chain instead of restarting it.** The first resumed
link was numbered 0: it overwrote link 0's chain row, went out as an unseeded text-to-video,
and left the chain one row short, so a later resume billed a finished link again. Resumed links
now keep their manifest index (chain table, `--json` `links[].index`, `--out-file` number), the
first one is an image-to-video seeded from the last completed clip, and a resume whose clip is
gone is refused with exit 20 naming the file, before anything is billed
([#1300](https://github.com/ffroliva/gflow-cli/issues/1300)).

- **`gflow update` says one thing about the result
([#1163](https://github.com/ffroliva/gflow-cli/issues/1163)).** It no longer shows the
cached "update available" banner, which could name an older release than the one it then
Expand Down
5 changes: 3 additions & 2 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -639,11 +639,12 @@ waits for each link in turn, so total wallclock is the sum of all link waits.
| `--max-links N` | unset | Cap link count; exit 11 (`ConfigurationError`) if the manifest has more. A spend guardrail. |
| `-y` / `--yes` | off | Skip the pending video operation confirmation prompt. |
| `--dry-run` | off | Print the pending video operation plan and submit nothing. |
| `--resume-from CHAIN_ID` | unset | Resume a prior chain by its id; already-completed links are skipped (not regenerated). |
| `--resume-from CHAIN_ID` | unset | Resume a prior chain by its id; already-completed links are skipped (not regenerated), and the next link is an i2v seeded from the last completed clip, which must still be at its recorded path (exit 20 otherwise, before anything is billed; [#1300](https://github.com/ffroliva/gflow-cli/issues/1300)). |
| `--jitter F` | `0.0` | Random `0..F` second pause **between** links (anti-bot cadence; never before link 0). |
| `--seed-offset MS` | `0` | Extract the seed frame this many ms before EOF (fade-to-black guard). |
| `--aspect` | `9:16` | `9:16` / `16:9`. Applied uniformly to every link (continuity requirement). |
| `--out-dir DIR` | output root | Directory for the link mp4s + `linkN_lastframe.jpg` seed frames. |
| `--out-file FILE` | unset | Link i's clip goes to `<stem>_<i><suffix>` next to FILE (`clip.mp4` gives `clip_1.mp4`, `clip_2.mp4`), numbered over the whole manifest so `--resume-from` keeps each name; seed frames stay under `--out-dir`. An existing file or folder there is never replaced: that clip, like one whose move fails, stays where it downloaded, recorded there, with a warning. A FILE under an existing file is a usage error (exit 2) before anything runs. Deprecated spelling: `-o/--output` ([#1293](https://github.com/ffroliva/gflow-cli/issues/1293)). |
| `--out-dir DIR` | output root | Directory for the `linkN_lastframe.jpg` seed frames, and for the link mp4s unless `--out-file` is given. |
| `--profile NAME` | default profile | Per-subcommand profile override. |
| `--json` | off | Emit a machine-readable JSON result. |

Expand Down
4 changes: 2 additions & 2 deletions docs/PROJECT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ rate limit, content policy, network, wire format and timeout to `40`–`44` and
into `3`. The default stays the legacy codes; `--json` errors carry both `exit_code` and
`exit_code_legacy`. The table is in [ERRORS.md](ERRORS.md).

**Changed.** `video chain` has never written to `-o PATH`; it now says so on stderr instead of staying
silent (#1293 tracks writing the file).
**Changed.** In this release `video chain` does not write to `-o PATH` and says so on stderr instead of
staying silent; writing the file is fixed on `develop` for the next release (#1293).
Internally, `UiAutomationTransport` and `MigratedComposer` are split into single-purpose mixins
(#1249 wave 2).

Expand Down
20 changes: 14 additions & 6 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -735,8 +735,8 @@ All prompts in a batch share one Flow project. The editor is opened once and sta
> instead of proceeding on Flow's sticky default (typically x2) and silently
> changing what the run bills.
> `t2v`, `i2v`, `r2v`, `video chain`, and `video extend` additionally take `--out-file PATH` (explicit file destination).
> `video chain` does not apply it yet: it says so on stderr and the clips land under `--out-dir` ([#1293](https://github.com/ffroliva/gflow-cli/issues/1293)).
> The mp4 lands at `<output_file>` or `<out-dir>/<media_id>.mp4`.
> `video chain` writes link i's clip to `<stem>_<i><suffix>` next to PATH (`--out-file out/clip.mp4` gives `out/clip_1.mp4`, `out/clip_2.mp4`, …), numbered over the whole manifest even for one link, so `--resume-from` writes the remaining links under the names a full run would; seed frames stay under `--out-dir`, and `--json` and the catalog report the numbered paths. An existing file or folder at a numbered path is never replaced: that clip, like one whose move fails, stays at its `--out-dir` path, is recorded there, and a warning names it ([#1293](https://github.com/ffroliva/gflow-cli/issues/1293)).
> For `t2v`, `i2v` and `r2v` the mp4 lands at `<output_file>` or `<out-dir>/<media_id>.mp4`.
>
> **`i2v` model rules (issues #125 / #626):** every model — `omni-flash`
> included — supports i2v with a **start frame** and with an **end frame**
Expand Down Expand Up @@ -1191,14 +1191,19 @@ Options:
--dry-run Resolve the manifest, print the plan + credit cost,
and spend nothing.
--resume-from CHAIN_ID Resume a prior chain by its id; already-paid links
are skipped (not re-billed).
are skipped (not re-billed), and the next link is
seeded from the last paid clip, which must still be
at its recorded path (exit 20 otherwise).
--jitter FLOAT Random 0..JITTER second pause between links
(anti-bot cadence). [default: 0.0]
--seed-offset INTEGER Extract the seed frame this many ms before EOF
(fade-to-black guard). [default: 0]
--aspect [9:16|16:9] Uniform aspect for every link. [default: 9:16]
--profile NAME Profile name (overrides default).
--out-dir DIR Directory for the link mp4s + seed frames.
--out-file FILE Write link i's clip to FILE's stem plus _i
(clip.mp4 -> clip_1.mp4, clip_2.mp4).
--out-dir DIR Directory for the seed frames, and for the link
mp4s unless --out-file is given.
--json Emit a machine-readable JSON result.
```

Expand Down Expand Up @@ -1237,7 +1242,10 @@ the manifest is currently informational).
### Output — N clips, not one file

Each link is saved as its own mp4 (plus a `linkN_lastframe.jpg` seed frame
between links) under `--out-dir` (or the default output dir). **Stitching the
between links) under `--out-dir` (or the default output dir). With
`--out-file out/clip.mp4` link i's clip is written to `out/clip_<i>.mp4`
instead (1-based over the whole manifest, so a `--resume-from` run keeps each
link's number); the seed frames stay under `--out-dir`. **Stitching the
clips into a single video is a separate step — use `gflow scene` to
concatenate them server-side.** Chain does not auto-concat: see the
*deferred auto-concat* entry in
Expand Down Expand Up @@ -2372,7 +2380,7 @@ The table below is the default (legacy) set. `GFLOW_CLI_EXIT_CODES=2` opts in to
| `17` | `ModelModeIncompatibilityError` | The chosen video model can't do the requested mode — today that is `omni-flash` or `omni-1.2-flash` for `chain` (issues #125, #626) | Use a Veo 3.1 model (`veo-lite` / `veo-fast` / `veo-quality` / `veo-lite-lp`) for `chain`. Single-clip `i2v` with omni-flash, `--end-frame` included, is accepted |
| `18` | `VideoModelSelectionError` | gflow could not select the requested video model in Flow's editor for an `i2v` run (model-picker option not found) | Usually transient — retry; if it persists, Flow's model-picker UI changed (report referencing #125) |
| `19` | `SceneConcatError` | Server-side scene render/concat failed (`gflow scene --out-file`) | Retry; the recorded compose survives, so re-render is safe |
| `20` | `FrameExtractionError` | Could not extract the last frame for a video chain link | Check the source video downloaded intact; retry the link |
| `20` | `FrameExtractionError` | Could not extract the last frame for a video chain link, or a `video chain --resume-from` whose last completed clip is no longer at its recorded path (refused before anything is billed, [#1300](https://github.com/ffroliva/gflow-cli/issues/1300)) | Check the source video downloaded intact; retry the link. For a resume, put the clip back at the path named, or start a new chain |
| `21` | `ChainPartialError` | A video chain stopped mid-way; earlier links completed | Resume from the last completed link shown in the error |
| `22` | `UpscaleUnavailableError` | 4K image upscale is gated to Flow **Ultra** accounts; or requested video export quality is disabled | For images, use `--scale 2k` or upgrade the Flow plan; for videos, check available options in Flow |
| `23` | `UiSelectorDriftError` | A Flow editor control could not be located — Google changed the frontend (issues #183, #493) — **or** a blocking announcement overlay survived dismissal, so no control below it can be clicked (probe `overlay_close_button`, #593). On the migrated host a missing submit control is checked against the wallet first and reported as **37** when Flow swapped in its insufficient-credits warning, so a short balance no longer arrives here | If the detail says the model picker is **hidden**, nothing was submitted: turn the **Agent** chip off in a browser and re-run ([#788](https://github.com/ffroliva/gflow-cli/issues/788)). Otherwise update gflow-cli; file a bug with the probe name + the diagnostics JSON / debug screenshot referenced in the error message, plus the incident bundle's `report.md` when one was written. For `overlay_close_button`: open the project once in Chrome and dismiss the announcement — the dismissal persists on your account |
Expand Down
2 changes: 1 addition & 1 deletion plugins/gflow/skills/gflow-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ gflow video upscale <mediaId> --scale {1080p|720p|270p} [--project ID] [--out DI
# removed. For multi-clip runs, loop `gflow video t2v`/`i2v` from the shell.
gflow video chain <manifest.jsonl> [--out-dir DIR] [--dry-run] \
[--max-links N] [--resume-from N] # last-frame I2V chaining; veo models only
# --out-file is not applied by chain yet (stderr notice, #1293): clips go to --out-dir
# --out-file out/clip.mp4 writes link i to out/clip_<i>.mp4 (#1293)

# Characters (reusable, project-scoped subjects)
gflow character create --project <id> --name "<name>" --face-prompt "<prompt>" \
Expand Down
2 changes: 1 addition & 1 deletion skills/gflow-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ gflow video upscale <mediaId> --scale {1080p|720p|270p} [--project ID] [--out DI
# removed. For multi-clip runs, loop `gflow video t2v`/`i2v` from the shell.
gflow video chain <manifest.jsonl> [--out-dir DIR] [--dry-run] \
[--max-links N] [--resume-from N] # last-frame I2V chaining; veo models only
# --out-file is not applied by chain yet (stderr notice, #1293): clips go to --out-dir
# --out-file out/clip.mp4 writes link i to out/clip_<i>.mp4 (#1293)

# Characters (reusable, project-scoped subjects)
gflow character create --project <id> --name "<name>" --face-prompt "<prompt>" \
Expand Down
75 changes: 69 additions & 6 deletions src/gflow_cli/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
from gflow_cli.media import extract_last_frame

if TYPE_CHECKING:
from collections.abc import Sequence
from collections.abc import Callable, Sequence
from pathlib import Path

from gflow_cli.api.client import FlowApiClient
Expand Down Expand Up @@ -325,6 +325,46 @@ def _build_link_result(
)


async def _relocate_guarded(
relocate: Callable[[int, VideoResult], VideoResult], index: int, result: VideoResult
) -> VideoResult:
"""Run the ``--out-file`` move off the loop; on any failure keep the clip where it is.

The clip is already paid for, so it must reach the recorder whatever the hook does: a
link without a chain row is billed again on ``--resume-from``. Same rule as
:func:`_notify_link_failed`.
"""
try:
return await asyncio.to_thread(relocate, index, result)
except Exception as exc: # noqa: BLE001 — see docstring
_log.warning(
"chain_clip_kept_at_download_path",
link=index + 1,
media_id=result.status.media_id,
kept_at=str(result.local_path),
reason=str(exc),
)
return result


async def _resume_seed(
extractor: FrameExtractor,
*,
start_index: int,
seed_clip: Path | None,
out_dir: Path,
offset_ms: int,
) -> Path | None:
"""The seed frame for ``links[0]``: none at link 0, else re-extracted from ``seed_clip``."""
if start_index == 0:
return None
if seed_clip is None:
msg = f"link {start_index} continues a chain: pass seed_clip, the clip of the link before"
raise ValueError(msg)
dst = out_dir / f"link{start_index - 1}_lastframe.jpg"
return await asyncio.to_thread(extractor, src=seed_clip, dst=dst, offset_ms=offset_ms)


async def run_chain(
*,
client: FlowApiClient,
Expand All @@ -339,6 +379,9 @@ async def run_chain(
aspect: Aspect = Aspect.PORTRAIT,
seed_offset_ms: int = 0,
jitter: float = 0.0,
relocate: Callable[[int, VideoResult], VideoResult] | None = None,
start_index: int = 0,
seed_clip: Path | None = None,
) -> list[ChainLinkResult]:
"""Render ``links`` as a sequential last-frame I2V chain.

Expand Down Expand Up @@ -368,6 +411,16 @@ async def run_chain(
before EOF (the fade-to-black mitigation).
jitter: When > 0, sleep a random ``[0, jitter)`` seconds BETWEEN links
(anti-bot cadence); never before link 0.
relocate: Optional ``(index, result) -> result`` that moves a downloaded
clip (``--out-file``, #1293). It runs off the event loop before
anything reads the path, so the recorders, the seed extraction and
the return value all see where the clip ended up. If it raises, the
clip stays at its download path and a warning says so. A result with
no ``local_path`` must come back unchanged.
start_index: The manifest index of ``links[0]`` (``--resume-from``, #1300). Request
mode, ``ChainLinkResult.index`` and the recorded ``link_index`` all count from it.
seed_clip: The clip of link ``start_index - 1``; its last frame seeds ``links[0]``.
Required when ``start_index`` is past 0, so a resumed link is never sent as t2v.

Returns:
One :class:`ChainLinkResult` per link, in order.
Expand All @@ -380,18 +433,26 @@ async def run_chain(
ChainPartialError: A link failed with a ``WireFormatError`` (i2v routed
to the t2v backstop) or ``WafRejectionError`` (403). Carries the
``Path`` of every link completed before the failure.
ValueError: ``start_index`` is past 0 but no ``seed_clip`` was given.
"""
reject_unusable_links(model=model, links=links)

results: list[ChainLinkResult] = []
completed_paths: list[Path] = []
prev_frame: Path | None = None
prev_frame = await _resume_seed(
extractor,
start_index=start_index,
seed_clip=seed_clip,
out_dir=out_dir,
offset_ms=seed_offset_ms,
)
last_index = start_index + len(links) - 1

for index, spec in enumerate(links):
if index > 0 and jitter > 0:
for index, spec in enumerate(links, start=start_index):
if index > start_index and jitter > 0:
await asyncio.sleep(random.uniform(0.0, jitter)) # noqa: S311 # cadence, not crypto

_log.info("chain_link_started", index=index, total_links=len(links))
_log.info("chain_link_started", index=index, total_links=last_index + 1)
req = _build_link_request(
spec=spec, index=index, model=model, aspect=aspect, prev_frame=prev_frame
)
Expand All @@ -405,6 +466,8 @@ async def run_chain(
on_link_started=on_link_started,
on_link_failed=on_link_failed,
)
if relocate is not None: # a missing clip is left as is and refused just below
result = await _relocate_guarded(relocate, index, result)

if result.local_path is None:
msg = f"link {index} returned no local_path (download failed)"
Expand All @@ -419,7 +482,7 @@ async def run_chain(
_notify_link_failed(on_link_failed, req, exc)
raise exc

is_last = index == len(links) - 1
is_last = index == last_index

# RECORD-BEFORE-EXTRACT: persist the downloaded clip before decoding it.
# The frame_path is the planned seed-frame destination; it is filled in
Expand Down
4 changes: 2 additions & 2 deletions src/gflow_cli/cli_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@
from gflow_cli.image_batch import (
MIN_COUNT as _MIN_COUNT,
)
from gflow_cli.paths import image_output_path, resolve_batch_output_dir
from gflow_cli.paths import image_output_path, numbered_path, resolve_batch_output_dir
from gflow_cli.profile_store import account_locale_for
from gflow_cli.refresh_resolver import (
_build_name_resolver as _build_name_resolver, # pyright: ignore[reportPrivateUsage]
Expand Down Expand Up @@ -1127,7 +1127,7 @@ async def _download_images(
if len(images) == 1:
target = output_file
else:
target = output_file.parent / f"{output_file.stem}_{i}{output_file.suffix}"
target = numbered_path(output_file, i)
elif out is not None:
target = out / f"{img.media_name}_{i}.png"
else:
Expand Down
Loading
Loading