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
7 changes: 0 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
run could not acquire it and spiralled into rapid `about:blank` tabs +
`TargetClosedError`. Context close + driver stop are now shared by
`__aenter__`'s guard and `__aexit__` via `_close_browser_resources`.
- `gflow image i2i --ref <local-file>` now binds the reference through the
editor's media dialog instead of the REST `uploadImage` endpoint (which 401s —
same root as #15/#39). Local-path refs ride a new `GenerateImageRequest.ref_paths`
field and are attached via the inherited R2V `_attach_references` (the image-mode
add-media dialog is the same `add_2` surface). Live-verified end-to-end: upload
200, `imageInputs[]` populated, image returned. Bare-UUID `--ref` still flows
through `refs` unchanged.
- `gflow image t2i/i2i --model` now actually selects the requested model. It was
a no-op under `ui_automation` (the wire field was set but the model picker was
never clicked, so Flow used its UI default). Adds `_select_image_model`.
Expand Down
5 changes: 0 additions & 5 deletions src/gflow_cli/api/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
from collections.abc import Mapping
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
from types import MappingProxyType
from typing import Any

Expand Down Expand Up @@ -182,10 +181,6 @@ class GenerateImageRequest:
aspect: Aspect = Aspect.PORTRAIT
model: Model = Model.NARWHAL
refs: tuple[ImageRef, ...] = ()
# Local reference-image files for I2I — attached through the editor's media
# dialog by the ui_automation transport (the REST uploadImage path 401s).
# The common i2i case; `refs` (pre-uploaded UUIDs) would need library-select.
ref_paths: tuple[Path, ...] = ()
recaptcha_token: str = "" # populated by caller right before send; "" means unminted
# number of images to generate (1–4); UI transport uses this to set Flow's count tab
count: int = 1
Expand Down
7 changes: 0 additions & 7 deletions src/gflow_cli/api/transports/ui_automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1502,13 +1502,6 @@ async def _generate_images_locked(
page, aspect_cli, request.count, model=request.model
)

# I2I: bind local reference images through the editor's media dialog —
# the same add_2 dialog as video R2V, via the inherited _attach_references.
# The REST uploadImage path 401s, and passive capture needs the refs IN
# the UI (not just a wire body), so we attach + let Flow's JS include them.
if request.ref_paths:
await self._attach_references(page, list(request.ref_paths), out_dir=out_dir)

# Attach the response listener SYNCHRONOUSLY before any prompt
# action. asyncio.create_task is unsafe here: it defers the listener
# registration until the new task gets event-loop scheduling, which
Expand Down
51 changes: 38 additions & 13 deletions src/gflow_cli/cli_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,9 @@ def _classify_ref(ref: str) -> ImageRef | Path:
(raised by ``strict=True``) which we re-raise as :class:`click.UsageError`
for an exit-2 + friendly message.

Centralized here so the ``i2i`` Click callback validates upfront and
``_run_i2i`` can split UUID refs (wire) from local paths (UI-attached)
without duplicating the UUID regex check.
Centralized here so the ``i2i`` Click callback (upfront validation) and
the ``_resolve_refs`` async helper (dispatch) share one implementation
instead of duplicating the UUID regex check.

Raises:
click.UsageError: if *ref* is neither a UUID nor an existing path.
Expand Down Expand Up @@ -761,6 +761,38 @@ def i2i(
)


async def _resolve_refs(
client: FlowApiClient,
project_id: str,
classified_refs: list[ImageRef | Path],
) -> tuple[ImageRef, ...]:
"""Resolve a pre-classified ref list into a tuple of :class:`ImageRef`.

The input list is produced by :func:`_classify_ref` at the CLI boundary,
so this helper only has to dispatch on type:

* :class:`ImageRef` — append verbatim (already-uploaded asset).
* :class:`Path` — upload and wrap the returned UUID.

Uploads are sequential — parallel uploads are tempting but Flow's web UI
uploads serially and we don't want to surprise the rate limiter. Order is
preserved so the resulting ``imageInputs[]`` matches the order the user
specified on the command line.
"""
resolved: list[ImageRef] = []
for item in classified_refs:
if isinstance(item, ImageRef):
resolved.append(item)
continue
# Per-file progress feedback. Acceptable Rich `console.print` inside
# this async helper because cli_image.py *is* the CLI layer; structlog
# will replace this when it lands in Phase 1.
console.print(f" Uploading {item.name}...")
asset = await client.upload_image(project_id, item)
resolved.append(ImageRef(name=asset.name))
return tuple(resolved)


async def _run_i2i(
*,
profile_dir: Path,
Expand All @@ -783,23 +815,16 @@ async def _run_i2i(
project = await client.create_project(title="gflow-cli i2i")
console.print(f" Project: [dim]{project.project_id}[/dim]")

# Local-file refs are attached through the editor's media dialog by the
# ui_automation transport (the REST uploadImage path 401s — see #15/#39).
# Already-uploaded UUID refs go on `refs` (best-effort; binding a library
# asset by UUID via the UI is not wired yet — local files are the path).
uuid_refs = tuple(r for r in classified_refs if isinstance(r, ImageRef))
local_ref_paths = tuple(r for r in classified_refs if isinstance(r, Path))
resolved_refs = await _resolve_refs(client, project.project_id, classified_refs)
req = GenerateImageRequest(
prompt=prompt,
aspect=aspect,
model=model,
refs=uuid_refs,
ref_paths=local_ref_paths,
refs=resolved_refs,
)

n_refs = len(uuid_refs) + len(local_ref_paths)
console.print(
f" Generating {count} image(s) with {n_refs} ref(s) "
f" Generating {count} image(s) with {len(resolved_refs)} ref(s) "
f"({req.model.value}, {req.aspect.value})..."
)
if count == 1:
Expand Down
43 changes: 0 additions & 43 deletions tests/api/transports/test_ui_automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1049,49 +1049,6 @@ async def test_200_with_no_parseable_media_raises(self) -> None:
):
await t.generate_images(project_id="x", request=_req())

@pytest.mark.asyncio
async def test_i2i_ref_paths_bound_via_attach_references(self) -> None:
"""I2I local refs (request.ref_paths) bind through the editor media
dialog — _generate_images_locked awaits the inherited _attach_references
with the local paths. Without this, the i2i bind is only covered at the
CLI layer (mirrors the R2V transport-attach coverage)."""
t = UiAutomationTransport()
t._setup_done = True # type: ignore[attr-defined]
t._page = MagicMock() # type: ignore[attr-defined]
ref = Path("hero.png")
req = GenerateImageRequest(prompt="stylize", model=Model.NARWHAL, ref_paths=(ref,))

with (
patch.object(t, "_enter_editor", new=AsyncMock()),
patch.object(t, "_send_prompt", new=AsyncMock()),
patch.object(t, "_attach_references", new=AsyncMock()) as attach,
patch.object(t, "_await_captured", new=AsyncMock(return_value=[_flow_200_capture()])),
):
await t.generate_images(project_id="x", request=req)

attach.assert_awaited_once()
call = attach.await_args
assert call is not None
# The local path is forwarded to the attach helper (positional arg 1).
assert list(call.args[1]) == [ref]

@pytest.mark.asyncio
async def test_t2i_without_ref_paths_skips_attach(self) -> None:
"""T2I (no ref_paths) must NOT touch _attach_references."""
t = UiAutomationTransport()
t._setup_done = True # type: ignore[attr-defined]
t._page = MagicMock() # type: ignore[attr-defined]

with (
patch.object(t, "_enter_editor", new=AsyncMock()),
patch.object(t, "_send_prompt", new=AsyncMock()),
patch.object(t, "_attach_references", new=AsyncMock()) as attach,
patch.object(t, "_await_captured", new=AsyncMock(return_value=[_flow_200_capture()])),
):
await t.generate_images(project_id="x", request=_req())

attach.assert_not_awaited()


# ---------------------------------------------------------------------------
# Unit 3.10 — refresh_auth (no-op)
Expand Down
35 changes: 18 additions & 17 deletions tests/cli/test_cli_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,12 +312,13 @@ async def _fake_download(image: GeneratedImage, out_path: Path) -> Path:


class TestImageI2I:
def test_i2i_attaches_local_ref_paths(self, runner: CliRunner, tmp_path: Path) -> None:
"""Local --ref path lands on req.ref_paths for UI media-dialog attach —
it must NOT hit the REST uploadImage endpoint (which 401s, see #15/#39)."""
def test_i2i_uploads_local_ref_paths(self, runner: CliRunner, tmp_path: Path) -> None:
"""Local --ref path triggers upload_image; the returned UUID lands in
the GenerateImageRequest.refs and downstream imageInputs[]."""
png = tmp_path / "hero.png"
png.write_bytes(b"\x89PNG\r\n\x1a\n")
client = _make_i2i_client(upload_uuid="should-not-be-used")
upload_uuid = "ddb6ef97-262d-49f4-8269-4a28c0fae6a2"
client = _make_i2i_client(upload_uuid=upload_uuid)
out_dir = tmp_path / "out"

with (
Expand All @@ -334,14 +335,14 @@ def test_i2i_attaches_local_ref_paths(self, runner: CliRunner, tmp_path: Path) -
)

assert result.exit_code == 0, result.output
client.upload_image.assert_not_called()
# Local file goes to req.ref_paths (UI-attached); refs (wire UUIDs) stays empty.
client.upload_image.assert_awaited_once()
# Inspect the GenerateImageRequest passed to generate_image — UUID
# from upload_image should appear in req.refs.
call = client.generate_image.await_args
assert call is not None
req = call.kwargs["req"]
assert req.refs == ()
assert len(req.ref_paths) == 1
assert req.ref_paths[0] == png.resolve()
assert len(req.refs) == 1
assert req.refs[0].name == upload_uuid

def test_i2i_passes_through_uuid_refs(self, runner: CliRunner, tmp_path: Path) -> None:
"""Bare-UUID --ref must NOT trigger upload_image and must be used verbatim."""
Expand Down Expand Up @@ -370,13 +371,13 @@ def test_i2i_passes_through_uuid_refs(self, runner: CliRunner, tmp_path: Path) -
assert len(req.refs) == 1
assert req.refs[0].name == uuid_str

def test_i2i_splits_paths_and_uuids(self, runner: CliRunner, tmp_path: Path) -> None:
"""Mixed `--ref path --ref uuid` → local path on ref_paths (UI-attached),
bare UUID on refs (wire). No REST upload for either."""
def test_i2i_mixes_paths_and_uuids(self, runner: CliRunner, tmp_path: Path) -> None:
"""Mixed `--ref path --ref uuid` → one upload, two ordered entries."""
png = tmp_path / "hero.png"
png.write_bytes(b"\x89PNG\r\n\x1a\n")
uploaded_uuid = "11111111-1111-1111-1111-111111111111"
passthrough_uuid = "22222222-2222-2222-2222-222222222222"
client = _make_i2i_client(upload_uuid="should-not-be-used")
client = _make_i2i_client(upload_uuid=uploaded_uuid)
out_dir = tmp_path / "out"

with (
Expand All @@ -403,13 +404,13 @@ def test_i2i_splits_paths_and_uuids(self, runner: CliRunner, tmp_path: Path) ->
)

assert result.exit_code == 0, result.output
client.upload_image.assert_not_called()
# Exactly one upload (for the path) — the bare UUID must be passed through.
assert client.upload_image.await_count == 1
call = client.generate_image.await_args
assert call is not None
req = call.kwargs["req"]
# Local path → ref_paths; bare UUID → refs.
assert [p for p in req.ref_paths] == [png.resolve()]
assert [r.name for r in req.refs] == [passthrough_uuid]
# Order matters: path → uploaded_uuid first, then passthrough_uuid.
assert [r.name for r in req.refs] == [uploaded_uuid, passthrough_uuid]

def test_i2i_errors_on_no_refs(self, runner: CliRunner) -> None:
"""`image i2i` without any --ref must exit 2 with a clear message."""
Expand Down
Loading