gflow-cli 0.2.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flow_cli/__init__.py +3 -0
- flow_cli/__main__.py +6 -0
- flow_cli/api/__init__.py +18 -0
- flow_cli/api/client.py +246 -0
- flow_cli/api/dto.py +137 -0
- flow_cli/api/recaptcha.py +107 -0
- flow_cli/api/routes.py +37 -0
- flow_cli/api/video.py +112 -0
- flow_cli/auth.py +87 -0
- flow_cli/cli.py +184 -0
- flow_cli/cli_video.py +322 -0
- flow_cli/config.py +116 -0
- flow_cli/manifest.py +58 -0
- flow_cli/paths.py +82 -0
- flow_cli/profile_store.py +226 -0
- gflow_cli-0.2.0a1.dist-info/METADATA +404 -0
- gflow_cli-0.2.0a1.dist-info/RECORD +20 -0
- gflow_cli-0.2.0a1.dist-info/WHEEL +4 -0
- gflow_cli-0.2.0a1.dist-info/entry_points.txt +3 -0
- gflow_cli-0.2.0a1.dist-info/licenses/LICENSE +21 -0
flow_cli/auth.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Auth — capture/refresh Google session via Playwright persistent context.
|
|
2
|
+
|
|
3
|
+
Sessions live under `Settings.home / profile_<name>/`. Default location is
|
|
4
|
+
the OS-native user-data-dir (via `platformdirs`):
|
|
5
|
+
|
|
6
|
+
* Windows: `%LOCALAPPDATA%\\flow-cli\\profile_<name>`
|
|
7
|
+
* macOS: `~/Library/Application Support/flow-cli/profile_<name>`
|
|
8
|
+
* Linux: `~/.local/share/flow-cli/profile_<name>` (XDG)
|
|
9
|
+
|
|
10
|
+
Override the root with `FLOW_CLI_HOME`. See `docs/AUTHENTICATION.md`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from playwright.async_api import async_playwright
|
|
19
|
+
|
|
20
|
+
from flow_cli.config import get_settings
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
GEMINI_URL = "https://labs.google/fx/tools/flow?hl=en"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def default_profile_root() -> Path:
|
|
28
|
+
"""Root dir under which `profile_<name>/` subdirectories live.
|
|
29
|
+
|
|
30
|
+
Returns `Settings.home`. Reads env via `get_settings()` so changes to
|
|
31
|
+
`FLOW_CLI_HOME` after import are honoured (provided the cache is reset).
|
|
32
|
+
"""
|
|
33
|
+
return get_settings().home
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def profile_dir(name: str = "default") -> Path:
|
|
37
|
+
return get_settings().profile_subdir(name)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def login(name: str = "default") -> Path:
|
|
41
|
+
"""Open a HEADED Chromium window, let user sign into Google, persist session.
|
|
42
|
+
|
|
43
|
+
Returns the profile directory path. On subsequent runs the saved cookies
|
|
44
|
+
are reused; if Google's session expires, calling this again re-captures it.
|
|
45
|
+
"""
|
|
46
|
+
pdir = profile_dir(name)
|
|
47
|
+
pdir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
logger.info("login: launching browser, profile=%s", pdir)
|
|
49
|
+
async with async_playwright() as pw:
|
|
50
|
+
ctx = await pw.chromium.launch_persistent_context(
|
|
51
|
+
user_data_dir=str(pdir),
|
|
52
|
+
headless=False,
|
|
53
|
+
viewport={"width": 1280, "height": 800},
|
|
54
|
+
)
|
|
55
|
+
try:
|
|
56
|
+
page = ctx.pages[0] if ctx.pages else await ctx.new_page()
|
|
57
|
+
await page.goto(GEMINI_URL, wait_until="domcontentloaded", timeout=60_000)
|
|
58
|
+
print(
|
|
59
|
+
"\n Sign into your Google account in the open window.\n"
|
|
60
|
+
" Once you reach the Flow editor, close the window to save the session.\n"
|
|
61
|
+
)
|
|
62
|
+
try:
|
|
63
|
+
await ctx.wait_for_event("close", timeout=600_000) # pyright: ignore[reportUnknownMemberType]
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
finally:
|
|
67
|
+
try:
|
|
68
|
+
await ctx.close()
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
return pdir
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def status(name: str = "default") -> dict[str, object]:
|
|
75
|
+
"""Lightweight check — does the profile dir exist and have cookies file?"""
|
|
76
|
+
pdir = profile_dir(name)
|
|
77
|
+
cookies_file: Path | None = None
|
|
78
|
+
for candidate in (pdir / "Default" / "Cookies", pdir / "Cookies"):
|
|
79
|
+
if candidate.exists():
|
|
80
|
+
cookies_file = candidate
|
|
81
|
+
break
|
|
82
|
+
return {
|
|
83
|
+
"profile": str(pdir),
|
|
84
|
+
"exists": pdir.exists(),
|
|
85
|
+
"cookies_present": cookies_file is not None,
|
|
86
|
+
"cookies_path": str(cookies_file) if cookies_file else None,
|
|
87
|
+
}
|
flow_cli/cli.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""CLI entry point — Click app exposing the gflow commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from flow_cli import __version__, profile_store
|
|
14
|
+
from flow_cli import auth as auth_mod
|
|
15
|
+
from flow_cli.cli_video import video as _video_group
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _render_profiles_table(profiles: list[profile_store.ProfileMeta]) -> None:
|
|
21
|
+
"""Pretty-print the profile inventory."""
|
|
22
|
+
if not profiles:
|
|
23
|
+
console.print("[yellow]No profiles found.[/yellow]")
|
|
24
|
+
return
|
|
25
|
+
root = auth_mod.default_profile_root()
|
|
26
|
+
console.print(f"\n[bold]Profiles in[/bold] {root}\n")
|
|
27
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
28
|
+
table.add_column("Default", justify="center")
|
|
29
|
+
table.add_column("Name", style="bold")
|
|
30
|
+
table.add_column("Session")
|
|
31
|
+
table.add_column("Last used (UTC)")
|
|
32
|
+
table.add_column("Profile dir", overflow="fold")
|
|
33
|
+
for p in profiles:
|
|
34
|
+
marker = "[bold green]●[/bold green]" if p.is_default else ""
|
|
35
|
+
session = "[green]present[/green]" if p.cookies_present else "[red]missing[/red]"
|
|
36
|
+
last = p.last_used_at.strftime("%Y-%m-%d %H:%M:%S") if p.last_used_at else "-"
|
|
37
|
+
table.add_row(marker, p.name, session, last, str(p.profile_dir))
|
|
38
|
+
console.print(table)
|
|
39
|
+
console.print("\nUse [bold]gflow auth use <name>[/bold] to set the default profile.")
|
|
40
|
+
console.print(
|
|
41
|
+
"Use [bold]gflow auth login --profile <name>[/bold] to add or refresh a profile.\n"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@click.group()
|
|
46
|
+
@click.version_option(__version__, "-V", "--version")
|
|
47
|
+
@click.option("--verbose", "-v", is_flag=True, help="Verbose logging.")
|
|
48
|
+
@click.pass_context
|
|
49
|
+
def main(ctx: click.Context, verbose: bool) -> None:
|
|
50
|
+
"""gflow — drive Google Flow Veo I2V from the terminal."""
|
|
51
|
+
logging.basicConfig(
|
|
52
|
+
level=logging.DEBUG if verbose else logging.INFO,
|
|
53
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
54
|
+
)
|
|
55
|
+
ctx.ensure_object(dict)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --- auth -------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@main.group(invoke_without_command=True)
|
|
62
|
+
@click.pass_context
|
|
63
|
+
def auth(ctx: click.Context) -> None:
|
|
64
|
+
"""Manage Google sessions for Flow.
|
|
65
|
+
|
|
66
|
+
Bare `gflow auth` shows the profile inventory. If no profiles exist yet,
|
|
67
|
+
it kicks off `gflow auth login` automatically.
|
|
68
|
+
"""
|
|
69
|
+
if ctx.invoked_subcommand is not None:
|
|
70
|
+
return
|
|
71
|
+
profiles = profile_store.list_profiles()
|
|
72
|
+
if not profiles:
|
|
73
|
+
console.print("[yellow]No profiles found.[/yellow] Launching first-time login...\n")
|
|
74
|
+
ctx.invoke(auth_login)
|
|
75
|
+
return
|
|
76
|
+
_render_profiles_table(profiles)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@auth.command("login")
|
|
80
|
+
@click.option(
|
|
81
|
+
"--profile",
|
|
82
|
+
default=None,
|
|
83
|
+
help="Profile name. Defaults to the resolved default (env > config > auto).",
|
|
84
|
+
)
|
|
85
|
+
def auth_login(profile: str | None) -> None:
|
|
86
|
+
"""One-time interactive sign-in. Opens a browser window."""
|
|
87
|
+
name = profile or _resolve_or_prompt(default_for_first_run="default")
|
|
88
|
+
pdir = asyncio.run(auth_mod.login(name))
|
|
89
|
+
console.print(f"[green]Session saved.[/green] Profile dir: {pdir}")
|
|
90
|
+
# If this was the very first profile, set it as default automatically so
|
|
91
|
+
# subsequent commands work without explicit --profile / FLOW_CLI_PROFILE.
|
|
92
|
+
profiles = profile_store.list_profiles()
|
|
93
|
+
if len(profiles) == 1:
|
|
94
|
+
profile_store.set_default_profile(profiles[0].name)
|
|
95
|
+
console.print(f"[dim]Set [bold]{profiles[0].name}[/bold] as default profile.[/dim]")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@auth.command("status")
|
|
99
|
+
@click.option("--profile", default=None)
|
|
100
|
+
def auth_status(profile: str | None) -> None:
|
|
101
|
+
"""Show whether a specific profile has a saved session."""
|
|
102
|
+
name = profile or _resolve_or_exit()
|
|
103
|
+
s = auth_mod.status(name)
|
|
104
|
+
if s["exists"] and s["cookies_present"]:
|
|
105
|
+
console.print(f"[green]Profile '{name}' is configured.[/green]")
|
|
106
|
+
else:
|
|
107
|
+
console.print(
|
|
108
|
+
f"[yellow]Profile '{name}' has no session.[/yellow] "
|
|
109
|
+
f"Run [bold]gflow auth login --profile {name}[/bold]."
|
|
110
|
+
)
|
|
111
|
+
for k, v in s.items():
|
|
112
|
+
console.print(f" {k}: {v}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@auth.command("list")
|
|
116
|
+
def auth_list() -> None:
|
|
117
|
+
"""List every profile and indicate the current default."""
|
|
118
|
+
profiles = profile_store.list_profiles()
|
|
119
|
+
_render_profiles_table(profiles)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@auth.command("use")
|
|
123
|
+
@click.argument("name")
|
|
124
|
+
def auth_use(name: str) -> None:
|
|
125
|
+
"""Set NAME as the default profile."""
|
|
126
|
+
try:
|
|
127
|
+
cfg = profile_store.set_default_profile(name)
|
|
128
|
+
except FileNotFoundError as e:
|
|
129
|
+
console.print(f"[red]{e}[/red]")
|
|
130
|
+
sys.exit(2)
|
|
131
|
+
console.print(
|
|
132
|
+
f"[green]Default profile set to[/green] [bold]{name}[/bold]\n[dim]Persisted in {cfg}[/dim]"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@auth.command("logout")
|
|
137
|
+
@click.option("--profile", default=None)
|
|
138
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip the confirmation prompt.")
|
|
139
|
+
def auth_logout(profile: str | None, yes: bool) -> None:
|
|
140
|
+
"""Delete a profile's saved session (irreversible)."""
|
|
141
|
+
name = profile or _resolve_or_exit()
|
|
142
|
+
if not yes:
|
|
143
|
+
click.confirm(
|
|
144
|
+
f"Delete profile '{name}' and all cookies/state?",
|
|
145
|
+
abort=True,
|
|
146
|
+
)
|
|
147
|
+
try:
|
|
148
|
+
deleted = profile_store.delete_profile(name)
|
|
149
|
+
except FileNotFoundError as e:
|
|
150
|
+
console.print(f"[red]{e}[/red]")
|
|
151
|
+
sys.exit(2)
|
|
152
|
+
console.print(f"[yellow]Profile '{name}' removed.[/yellow]\n[dim]Deleted dir: {deleted}[/dim]")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _resolve_or_exit() -> str:
|
|
156
|
+
"""Resolve the active profile or print a friendly error and exit."""
|
|
157
|
+
try:
|
|
158
|
+
return profile_store.resolve_profile(None)
|
|
159
|
+
except profile_store.NoProfilesError as e:
|
|
160
|
+
console.print(f"[yellow]{e}[/yellow]")
|
|
161
|
+
sys.exit(2)
|
|
162
|
+
except profile_store.NoDefaultProfileError as e:
|
|
163
|
+
console.print(f"[yellow]{e}[/yellow]")
|
|
164
|
+
sys.exit(2)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _resolve_or_prompt(default_for_first_run: str) -> str:
|
|
168
|
+
"""Like _resolve_or_exit but for `auth login` — accept any name to create."""
|
|
169
|
+
try:
|
|
170
|
+
return profile_store.resolve_profile(None)
|
|
171
|
+
except profile_store.NoProfilesError:
|
|
172
|
+
return default_for_first_run
|
|
173
|
+
except profile_store.NoDefaultProfileError:
|
|
174
|
+
return click.prompt(
|
|
175
|
+
"Multiple profiles exist; pick a name to login or refresh",
|
|
176
|
+
default=default_for_first_run,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
main.add_command(_video_group)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
main()
|
flow_cli/cli_video.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""`gflow video` command group — t2v subcommand (text-to-video).
|
|
2
|
+
|
|
3
|
+
Helper functions `_resolve_profile` and `_make_provider_dir` are thin wrappers
|
|
4
|
+
over the same profile/auth machinery used by the rest of cli.py, kept as
|
|
5
|
+
named module-level functions so the test suite can patch them cleanly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
|
|
17
|
+
from flow_cli import auth as auth_mod
|
|
18
|
+
from flow_cli import profile_store
|
|
19
|
+
from flow_cli.api.client import FlowApiClient
|
|
20
|
+
from flow_cli.api.video import Aspect, GenerateVideoRequest
|
|
21
|
+
from flow_cli.config import get_settings
|
|
22
|
+
from flow_cli.manifest import ManifestEntry, parse_manifest
|
|
23
|
+
from flow_cli.paths import video_output_path
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
# Terminal statuses that end the polling loop.
|
|
28
|
+
_TERMINAL = frozenset(
|
|
29
|
+
[
|
|
30
|
+
"MEDIA_GENERATION_STATUS_COMPLETED",
|
|
31
|
+
"MEDIA_GENERATION_STATUS_FAILED",
|
|
32
|
+
]
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_profile(profile: str | None) -> str:
|
|
37
|
+
"""Return the active profile name or exit with a friendly message."""
|
|
38
|
+
if profile:
|
|
39
|
+
return profile
|
|
40
|
+
try:
|
|
41
|
+
return profile_store.resolve_profile(None)
|
|
42
|
+
except profile_store.NoProfilesError as exc:
|
|
43
|
+
console.print(f"[yellow]{exc}[/yellow]")
|
|
44
|
+
sys.exit(2)
|
|
45
|
+
except profile_store.NoDefaultProfileError as exc:
|
|
46
|
+
console.print(f"[yellow]{exc}[/yellow]")
|
|
47
|
+
sys.exit(2)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _make_provider_dir(profile_name: str) -> Path:
|
|
51
|
+
"""Return the Playwright profile dir for *profile_name*, or exit if absent."""
|
|
52
|
+
pdir = auth_mod.profile_dir(profile_name)
|
|
53
|
+
if not pdir.exists():
|
|
54
|
+
console.print(
|
|
55
|
+
f"[red]No session for profile '{profile_name}'.[/red] "
|
|
56
|
+
"Run [bold]gflow auth login[/bold] first."
|
|
57
|
+
)
|
|
58
|
+
sys.exit(2)
|
|
59
|
+
return pdir
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Click group
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@click.group()
|
|
68
|
+
def video() -> None:
|
|
69
|
+
"""Generate and manage videos via Google Flow Veo."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# t2v subcommand
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@video.command("t2v")
|
|
78
|
+
@click.argument("prompt")
|
|
79
|
+
@click.option(
|
|
80
|
+
"-o",
|
|
81
|
+
"--output",
|
|
82
|
+
"output",
|
|
83
|
+
default=None,
|
|
84
|
+
type=click.Path(path_type=Path),
|
|
85
|
+
help="Where to save the mp4. Defaults to <output_dir>/videos/<date>/<media>.mp4.",
|
|
86
|
+
)
|
|
87
|
+
@click.option(
|
|
88
|
+
"--aspect",
|
|
89
|
+
default="9:16",
|
|
90
|
+
show_default=True,
|
|
91
|
+
type=click.Choice(["9:16", "16:9", "1:1"]),
|
|
92
|
+
help="Video aspect ratio.",
|
|
93
|
+
)
|
|
94
|
+
@click.option("--seed", default=None, type=int, help="RNG seed for reproducibility.")
|
|
95
|
+
@click.option("--profile", default=None, help="Profile name (overrides default).")
|
|
96
|
+
@click.option(
|
|
97
|
+
"--poll-interval",
|
|
98
|
+
default=5.0,
|
|
99
|
+
show_default=True,
|
|
100
|
+
type=float,
|
|
101
|
+
help="Seconds between status polls.",
|
|
102
|
+
)
|
|
103
|
+
def t2v(
|
|
104
|
+
prompt: str,
|
|
105
|
+
output: Path | None,
|
|
106
|
+
aspect: str,
|
|
107
|
+
seed: int | None,
|
|
108
|
+
profile: str | None,
|
|
109
|
+
poll_interval: float,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Generate a video from PROMPT (text-to-video)."""
|
|
112
|
+
profile_name = _resolve_profile(profile)
|
|
113
|
+
provider_dir = _make_provider_dir(profile_name)
|
|
114
|
+
settings = get_settings()
|
|
115
|
+
asyncio.run(
|
|
116
|
+
_run_t2v(
|
|
117
|
+
profile_dir=provider_dir,
|
|
118
|
+
headless=settings.headless,
|
|
119
|
+
prompt=prompt,
|
|
120
|
+
output=output,
|
|
121
|
+
aspect=Aspect.from_cli(aspect),
|
|
122
|
+
seed=seed,
|
|
123
|
+
poll_interval=poll_interval,
|
|
124
|
+
output_root=settings.output_dir,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def _run_t2v(
|
|
130
|
+
*,
|
|
131
|
+
profile_dir: Path,
|
|
132
|
+
headless: bool,
|
|
133
|
+
prompt: str,
|
|
134
|
+
output: Path | None,
|
|
135
|
+
aspect: Aspect,
|
|
136
|
+
seed: int | None,
|
|
137
|
+
poll_interval: float,
|
|
138
|
+
output_root: Path,
|
|
139
|
+
) -> None:
|
|
140
|
+
async with FlowApiClient(profile_dir=profile_dir, headless=headless) as client:
|
|
141
|
+
console.print(" Creating project...")
|
|
142
|
+
project = await client.create_project()
|
|
143
|
+
console.print(f" Project: {project.project_id}")
|
|
144
|
+
req = GenerateVideoRequest(prompt=prompt, aspect=aspect)
|
|
145
|
+
console.print(" Submitting generation...")
|
|
146
|
+
op = await client.generate_video(project_id=project.project_id, req=req, seed=seed)
|
|
147
|
+
console.print(f" Operation: {op.operation_name}")
|
|
148
|
+
await _poll_and_download(
|
|
149
|
+
client=client,
|
|
150
|
+
project_id=project.project_id,
|
|
151
|
+
media_name=op.media_name,
|
|
152
|
+
output=output or video_output_path(output_root, job_id=op.media_name),
|
|
153
|
+
poll_interval=poll_interval,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# Shared polling helper
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def _poll_and_download(
|
|
163
|
+
*,
|
|
164
|
+
client: FlowApiClient,
|
|
165
|
+
project_id: str,
|
|
166
|
+
media_name: str,
|
|
167
|
+
output: Path,
|
|
168
|
+
poll_interval: float,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Poll until terminal status, then download the result to *output*."""
|
|
171
|
+
while True:
|
|
172
|
+
statuses = await client.get_video_status(project_id, [media_name])
|
|
173
|
+
if not statuses:
|
|
174
|
+
console.print("[red]No status returned from API.[/red]")
|
|
175
|
+
sys.exit(1)
|
|
176
|
+
st = statuses[0]
|
|
177
|
+
console.print(f" Status: {st.status}")
|
|
178
|
+
if st.status in _TERMINAL:
|
|
179
|
+
break
|
|
180
|
+
await asyncio.sleep(poll_interval)
|
|
181
|
+
|
|
182
|
+
if not st.succeeded:
|
|
183
|
+
console.print(f"[red]Generation failed (status={st.status}).[/red]")
|
|
184
|
+
sys.exit(1)
|
|
185
|
+
|
|
186
|
+
saved = await client.download(media_name, output)
|
|
187
|
+
console.print(f"[green]Saved[/green] {saved}")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# i2v subcommand
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@video.command("i2v")
|
|
196
|
+
@click.argument("image", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
197
|
+
@click.argument("prompt")
|
|
198
|
+
@click.option("-o", "--output", type=click.Path(path_type=Path), default=None)
|
|
199
|
+
@click.option("--aspect", type=click.Choice(["9:16", "16:9", "1:1"]), default="9:16")
|
|
200
|
+
@click.option("--seed", type=int, default=None)
|
|
201
|
+
@click.option("--profile", default=None)
|
|
202
|
+
@click.option("--poll-interval", type=float, default=5.0)
|
|
203
|
+
def i2v(
|
|
204
|
+
image: Path,
|
|
205
|
+
prompt: str,
|
|
206
|
+
output: Path | None,
|
|
207
|
+
aspect: str,
|
|
208
|
+
seed: int | None,
|
|
209
|
+
profile: str | None,
|
|
210
|
+
poll_interval: float,
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Generate a video from a START IMAGE + text prompt."""
|
|
213
|
+
profile_name = _resolve_profile(profile)
|
|
214
|
+
pdir = _make_provider_dir(profile_name)
|
|
215
|
+
settings = get_settings()
|
|
216
|
+
asyncio.run(
|
|
217
|
+
_run_i2v(
|
|
218
|
+
profile_dir=pdir,
|
|
219
|
+
headless=settings.headless,
|
|
220
|
+
image=image,
|
|
221
|
+
prompt=prompt,
|
|
222
|
+
output=output,
|
|
223
|
+
aspect=Aspect.from_cli(aspect),
|
|
224
|
+
seed=seed,
|
|
225
|
+
poll_interval=poll_interval,
|
|
226
|
+
output_root=settings.output_dir,
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def _run_i2v(
|
|
232
|
+
*,
|
|
233
|
+
profile_dir: Path,
|
|
234
|
+
headless: bool,
|
|
235
|
+
image: Path,
|
|
236
|
+
prompt: str,
|
|
237
|
+
output: Path | None,
|
|
238
|
+
aspect: Aspect,
|
|
239
|
+
seed: int | None,
|
|
240
|
+
poll_interval: float,
|
|
241
|
+
output_root: Path,
|
|
242
|
+
) -> None:
|
|
243
|
+
async with FlowApiClient(profile_dir=profile_dir, headless=headless) as client:
|
|
244
|
+
console.print(" Creating project...")
|
|
245
|
+
project = await client.create_project()
|
|
246
|
+
console.print(f" Uploading {image.name}...")
|
|
247
|
+
asset = await client.upload_image(project.project_id, image)
|
|
248
|
+
console.print(f" Asset: [dim]{asset.name}[/dim]")
|
|
249
|
+
req = GenerateVideoRequest(prompt=prompt, aspect=aspect, start_asset_uuid=asset.name)
|
|
250
|
+
console.print(" Submitting generation...")
|
|
251
|
+
op = await client.generate_video(project_id=project.project_id, req=req, seed=seed)
|
|
252
|
+
await _poll_and_download(
|
|
253
|
+
client=client,
|
|
254
|
+
project_id=project.project_id,
|
|
255
|
+
media_name=op.media_name,
|
|
256
|
+
output=output or video_output_path(output_root, job_id=op.media_name),
|
|
257
|
+
poll_interval=poll_interval,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
# batch subcommand
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@video.command("batch")
|
|
267
|
+
@click.argument("manifest", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
268
|
+
@click.option("--out-dir", type=click.Path(path_type=Path), default=None)
|
|
269
|
+
@click.option("--profile", default=None)
|
|
270
|
+
@click.option("--poll-interval", type=float, default=5.0, show_default=True)
|
|
271
|
+
def batch(
|
|
272
|
+
manifest: Path,
|
|
273
|
+
out_dir: Path | None,
|
|
274
|
+
profile: str | None,
|
|
275
|
+
poll_interval: float,
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Run a TSV manifest of video generations."""
|
|
278
|
+
profile_name = _resolve_profile(profile)
|
|
279
|
+
pdir = _make_provider_dir(profile_name)
|
|
280
|
+
settings = get_settings()
|
|
281
|
+
entries = parse_manifest(manifest)
|
|
282
|
+
out_root = out_dir or settings.output_dir
|
|
283
|
+
asyncio.run(
|
|
284
|
+
_run_batch(
|
|
285
|
+
profile_dir=pdir,
|
|
286
|
+
headless=settings.headless,
|
|
287
|
+
entries=entries,
|
|
288
|
+
out_root=out_root,
|
|
289
|
+
poll_interval=poll_interval,
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
async def _run_batch(
|
|
295
|
+
*,
|
|
296
|
+
profile_dir: Path,
|
|
297
|
+
headless: bool,
|
|
298
|
+
entries: list[ManifestEntry],
|
|
299
|
+
out_root: Path,
|
|
300
|
+
poll_interval: float,
|
|
301
|
+
) -> None:
|
|
302
|
+
async with FlowApiClient(profile_dir=profile_dir, headless=headless) as client:
|
|
303
|
+
console.print(f" Creating project for {len(entries)} clips...")
|
|
304
|
+
project = await client.create_project()
|
|
305
|
+
for i, e in enumerate(entries, start=1):
|
|
306
|
+
console.print(f" [{i}/{len(entries)}] [bold]{e.prompt[:60]}[/bold]")
|
|
307
|
+
start_uuid = None
|
|
308
|
+
if e.start_image:
|
|
309
|
+
asset = await client.upload_image(project.project_id, e.start_image)
|
|
310
|
+
start_uuid = asset.name
|
|
311
|
+
req = GenerateVideoRequest(
|
|
312
|
+
prompt=e.prompt, aspect=e.aspect, start_asset_uuid=start_uuid
|
|
313
|
+
)
|
|
314
|
+
op = await client.generate_video(project_id=project.project_id, req=req)
|
|
315
|
+
output = e.output_path or video_output_path(out_root, job_id=op.media_name)
|
|
316
|
+
await _poll_and_download(
|
|
317
|
+
client=client,
|
|
318
|
+
project_id=project.project_id,
|
|
319
|
+
media_name=op.media_name,
|
|
320
|
+
output=output,
|
|
321
|
+
poll_interval=poll_interval,
|
|
322
|
+
)
|