@leejungkiin/awkit 1.3.8 → 1.4.2
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.
- package/bin/awk.js +630 -52
- package/bin/claude-generators.js +122 -0
- package/core/AGENTS.md +54 -0
- package/core/CLAUDE.md +155 -0
- package/core/GEMINI.md +44 -9
- package/core/GEMINI.md.bak +126 -199
- package/package.json +1 -1
- package/skills/ai-sprite-maker/SKILL.md +81 -0
- package/skills/ai-sprite-maker/scripts/animate_sprite.py +102 -0
- package/skills/ai-sprite-maker/scripts/process_sprites.py +140 -0
- package/skills/awf-session-restore/SKILL.md +12 -2
- package/skills/brainstorm-agent/SKILL.md +11 -8
- package/skills/code-review/SKILL.md +21 -33
- package/skills/gitnexus/gitnexus-cli/SKILL.md +82 -0
- package/skills/gitnexus/gitnexus-debugging/SKILL.md +89 -0
- package/skills/gitnexus/gitnexus-exploring/SKILL.md +78 -0
- package/skills/gitnexus/gitnexus-guide/SKILL.md +64 -0
- package/skills/gitnexus/gitnexus-impact-analysis/SKILL.md +97 -0
- package/skills/gitnexus/gitnexus-refactoring/SKILL.md +121 -0
- package/skills/lucylab-tts/SKILL.md +64 -0
- package/skills/lucylab-tts/resources/voices_library.json +908 -0
- package/skills/lucylab-tts/scripts/.env +1 -0
- package/skills/lucylab-tts/scripts/lucylab_tts.py +506 -0
- package/skills/nm-memory-sync/SKILL.md +14 -1
- package/skills/orchestrator/SKILL.md +5 -38
- package/skills/ship-to-code/SKILL.md +115 -0
- package/skills/short-maker/SKILL.md +150 -0
- package/skills/short-maker/_backup/storyboard.html +106 -0
- package/skills/short-maker/_backup/video_mixer.py +296 -0
- package/skills/short-maker/outputs/fitbite-promo/background.jpg +0 -0
- package/skills/short-maker/outputs/fitbite-promo/final/promo-final.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/script.md +19 -0
- package/skills/short-maker/outputs/fitbite-promo/segments/scene-01.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/segments/scene-02.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/segments/scene-03.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/segments/scene-04.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/storyboard/scene-01.png +0 -0
- package/skills/short-maker/outputs/fitbite-promo/storyboard/scene-02.png +0 -0
- package/skills/short-maker/outputs/fitbite-promo/storyboard/scene-03.png +0 -0
- package/skills/short-maker/outputs/fitbite-promo/storyboard/scene-04.png +0 -0
- package/skills/short-maker/outputs/fitbite-promo/storyboard.html +133 -0
- package/skills/short-maker/outputs/fitbite-promo/storyboard.json +38 -0
- package/skills/short-maker/outputs/fitbite-promo/temp/merged_chroma.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/temp/merged_crossfaded.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/temp/ready_00.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/temp/ready_01.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/temp/ready_02.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/temp/ready_03.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/tts/manifest.json +31 -0
- package/skills/short-maker/outputs/fitbite-promo/tts/scene-01.wav +0 -0
- package/skills/short-maker/outputs/fitbite-promo/tts/scene-02.wav +0 -0
- package/skills/short-maker/outputs/fitbite-promo/tts/scene-03.wav +0 -0
- package/skills/short-maker/outputs/fitbite-promo/tts/scene-04.wav +0 -0
- package/skills/short-maker/outputs/fitbite-promo/tts_script.txt +11 -0
- package/skills/short-maker/scripts/google-flow-cli/.project-identity +41 -0
- package/skills/short-maker/scripts/google-flow-cli/.trae/rules/project_rules.md +52 -0
- package/skills/short-maker/scripts/google-flow-cli/CODEBASE.md +67 -0
- package/skills/short-maker/scripts/google-flow-cli/GoogleFlowCli.code-workspace +29 -0
- package/skills/short-maker/scripts/google-flow-cli/README.md +168 -0
- package/skills/short-maker/scripts/google-flow-cli/docs/specs/PROJECT.md +12 -0
- package/skills/short-maker/scripts/google-flow-cli/docs/specs/REQUIREMENTS.md +22 -0
- package/skills/short-maker/scripts/google-flow-cli/docs/specs/ROADMAP.md +16 -0
- package/skills/short-maker/scripts/google-flow-cli/docs/specs/TECH-SPEC.md +13 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/__init__.py +3 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/api/__init__.py +19 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/api/client.py +1921 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/api/models.py +64 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/api/rpc_ids.py +98 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/auth/__init__.py +15 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/auth/browser_auth.py +692 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/auth/humanizer.py +417 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/auth/proxy_ext.py +120 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/auth/recaptcha.py +482 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/batchexecute/__init__.py +5 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/batchexecute/client.py +414 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/cli/__init__.py +1 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/cli/main.py +1075 -0
- package/skills/short-maker/scripts/google-flow-cli/pyproject.toml +36 -0
- package/skills/short-maker/scripts/google-flow-cli/script.txt +22 -0
- package/skills/short-maker/scripts/google-flow-cli/tests/__init__.py +0 -0
- package/skills/short-maker/scripts/google-flow-cli/tests/test_batchexecute.py +113 -0
- package/skills/short-maker/scripts/google-flow-cli/tests/test_client.py +190 -0
- package/skills/short-maker/templates/aida_script.md +40 -0
- package/skills/short-maker/templates/mimic_analyzer.md +29 -0
- package/skills/single-flow-task-execution/SKILL.md +412 -0
- package/skills/single-flow-task-execution/code-quality-reviewer-prompt.md +20 -0
- package/skills/single-flow-task-execution/implementer-prompt.md +78 -0
- package/skills/single-flow-task-execution/spec-reviewer-prompt.md +61 -0
- package/skills/skill-creator/SKILL.md +44 -0
- package/skills/spm-build-analysis/SKILL.md +92 -0
- package/skills/spm-build-analysis/references/build-optimization-sources.md +155 -0
- package/skills/spm-build-analysis/references/recommendation-format.md +85 -0
- package/skills/spm-build-analysis/references/spm-analysis-checks.md +105 -0
- package/skills/spm-build-analysis/scripts/check_spm_pins.py +118 -0
- package/skills/symphony-enforcer/SKILL.md +83 -97
- package/skills/symphony-orchestrator/SKILL.md +1 -1
- package/skills/trello-sync/SKILL.md +52 -45
- package/skills/verification-gate/SKILL.md +13 -2
- package/skills/xcode-build-benchmark/SKILL.md +88 -0
- package/skills/xcode-build-benchmark/references/benchmark-artifacts.md +94 -0
- package/skills/xcode-build-benchmark/references/benchmarking-workflow.md +67 -0
- package/skills/xcode-build-benchmark/schemas/build-benchmark.schema.json +230 -0
- package/skills/xcode-build-benchmark/scripts/benchmark_builds.py +308 -0
- package/skills/xcode-build-fixer/SKILL.md +218 -0
- package/skills/xcode-build-fixer/references/build-settings-best-practices.md +216 -0
- package/skills/xcode-build-fixer/references/fix-patterns.md +290 -0
- package/skills/xcode-build-fixer/references/recommendation-format.md +85 -0
- package/skills/xcode-build-fixer/scripts/benchmark_builds.py +308 -0
- package/skills/xcode-build-orchestrator/SKILL.md +156 -0
- package/skills/xcode-build-orchestrator/references/benchmark-artifacts.md +94 -0
- package/skills/xcode-build-orchestrator/references/build-settings-best-practices.md +216 -0
- package/skills/xcode-build-orchestrator/references/orchestration-report-template.md +143 -0
- package/skills/xcode-build-orchestrator/references/recommendation-format.md +85 -0
- package/skills/xcode-build-orchestrator/scripts/benchmark_builds.py +308 -0
- package/skills/xcode-build-orchestrator/scripts/diagnose_compilation.py +273 -0
- package/skills/xcode-build-orchestrator/scripts/generate_optimization_report.py +533 -0
- package/skills/xcode-compilation-analyzer/SKILL.md +89 -0
- package/skills/xcode-compilation-analyzer/references/build-optimization-sources.md +155 -0
- package/skills/xcode-compilation-analyzer/references/code-compilation-checks.md +106 -0
- package/skills/xcode-compilation-analyzer/references/recommendation-format.md +85 -0
- package/skills/xcode-compilation-analyzer/scripts/diagnose_compilation.py +273 -0
- package/skills/xcode-project-analyzer/SKILL.md +76 -0
- package/skills/xcode-project-analyzer/references/build-optimization-sources.md +155 -0
- package/skills/xcode-project-analyzer/references/build-settings-best-practices.md +216 -0
- package/skills/xcode-project-analyzer/references/project-audit-checks.md +101 -0
- package/skills/xcode-project-analyzer/references/recommendation-format.md +85 -0
- package/templates/CODEBASE.md +26 -42
- package/templates/configs/trello-config.json +2 -2
- package/templates/workflow_dual_mode_template.md +5 -5
- package/workflows/_uncategorized/conductor-codex.md +125 -0
- package/workflows/_uncategorized/conductor.md +97 -0
- package/workflows/_uncategorized/ship-to-code.md +85 -0
- package/workflows/_uncategorized/trello-sync.md +52 -0
- package/workflows/context/codebase-sync.md +10 -87
- package/workflows/quality/visual-debug.md +66 -12
|
@@ -0,0 +1,1075 @@
|
|
|
1
|
+
"""
|
|
2
|
+
gflow CLI — Command-line interface to Google Flow.
|
|
3
|
+
|
|
4
|
+
Uses Google's Imagen 4 and Veo 3.1 via the reverse-engineered
|
|
5
|
+
aisandbox-pa.googleapis.com endpoints with cookie-based auth.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
|
|
22
|
+
from gflow import __version__
|
|
23
|
+
from gflow.auth import BrowserAuth, load_env, save_env, refresh_access_token, kill_auth_browser
|
|
24
|
+
from gflow.auth.browser_auth import clear_env, AuthError
|
|
25
|
+
from gflow.api.client import FlowClient, FlowAPIError
|
|
26
|
+
from gflow.api.models import (
|
|
27
|
+
ExtendVideoRequest,
|
|
28
|
+
GenerateImageRequest,
|
|
29
|
+
GenerateVideoRequest,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
console = Console()
|
|
33
|
+
logger = logging.getLogger("gflow")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_client(debug: bool = False) -> FlowClient:
|
|
37
|
+
"""Create an authenticated FlowClient, auto-launching auth if needed."""
|
|
38
|
+
auth = load_env()
|
|
39
|
+
if not auth or not auth.is_valid:
|
|
40
|
+
console.print("[yellow]Not authenticated — launching browser login...[/yellow]")
|
|
41
|
+
browser_auth = BrowserAuth(debug=debug)
|
|
42
|
+
try:
|
|
43
|
+
auth = browser_auth.get_auth(interactive=True)
|
|
44
|
+
save_env(auth)
|
|
45
|
+
except AuthError as e:
|
|
46
|
+
console.print(f"[red]Authentication failed:[/red] {e}")
|
|
47
|
+
sys.exit(1)
|
|
48
|
+
|
|
49
|
+
return FlowClient(
|
|
50
|
+
cookies=auth.cookies,
|
|
51
|
+
debug=debug,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# =============================================================
|
|
56
|
+
# Root CLI group
|
|
57
|
+
# =============================================================
|
|
58
|
+
|
|
59
|
+
@click.group()
|
|
60
|
+
@click.version_option(version=__version__, prog_name="gflow")
|
|
61
|
+
@click.option("--debug", is_flag=True, help="Enable debug output")
|
|
62
|
+
@click.pass_context
|
|
63
|
+
def cli(ctx: click.Context, debug: bool):
|
|
64
|
+
"""gflow - CLI for Google Flow (AI image & video generation)."""
|
|
65
|
+
ctx.ensure_object(dict)
|
|
66
|
+
ctx.obj["debug"] = debug
|
|
67
|
+
|
|
68
|
+
if debug:
|
|
69
|
+
logging.basicConfig(level=logging.DEBUG, format="%(name)s: %(message)s")
|
|
70
|
+
else:
|
|
71
|
+
logging.basicConfig(level=logging.WARNING)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# =============================================================
|
|
75
|
+
# Auth
|
|
76
|
+
# =============================================================
|
|
77
|
+
|
|
78
|
+
@cli.command()
|
|
79
|
+
@click.option("--profile", default=None, help="Chrome profile directory")
|
|
80
|
+
@click.option("--clear", "do_clear", is_flag=True, help="Clear saved credentials")
|
|
81
|
+
@click.option("--status", "show_status", is_flag=True, help="Show auth status")
|
|
82
|
+
@click.pass_context
|
|
83
|
+
def auth(ctx: click.Context, profile, do_clear, show_status):
|
|
84
|
+
"""Authenticate with Google Flow via browser login."""
|
|
85
|
+
debug = ctx.obj["debug"]
|
|
86
|
+
|
|
87
|
+
if do_clear:
|
|
88
|
+
clear_env()
|
|
89
|
+
console.print("[green]Credentials cleared.[/green]")
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
if show_status:
|
|
93
|
+
data = load_env()
|
|
94
|
+
if data and data.is_valid:
|
|
95
|
+
try:
|
|
96
|
+
session = refresh_access_token(data.cookies, debug=debug)
|
|
97
|
+
user = session.get("user", {})
|
|
98
|
+
console.print(f"[green]Authenticated[/green] as {user.get('name', '?')} ({user.get('email', '?')})")
|
|
99
|
+
console.print(f"Token: {session['access_token'][:25]}...")
|
|
100
|
+
console.print(f"Expires: {session.get('expires', '?')}")
|
|
101
|
+
except AuthError as e:
|
|
102
|
+
console.print(f"[yellow]Cookies saved but session expired:[/yellow] {e}")
|
|
103
|
+
console.print("Run 'gflow auth --clear && gflow auth' to re-authenticate.")
|
|
104
|
+
else:
|
|
105
|
+
console.print("[yellow]Not authenticated.[/yellow] Run 'gflow auth' to log in.")
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
browser_auth = BrowserAuth(debug=debug)
|
|
109
|
+
try:
|
|
110
|
+
data = browser_auth.get_auth(profile=profile, interactive=True)
|
|
111
|
+
save_env(data)
|
|
112
|
+
console.print("[green]Authentication successful![/green]")
|
|
113
|
+
console.print("Credentials saved to ~/.gflow/env")
|
|
114
|
+
except AuthError as e:
|
|
115
|
+
console.print(f"[red]Authentication failed:[/red] {e}")
|
|
116
|
+
sys.exit(1)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# =============================================================
|
|
120
|
+
# Close background Chrome
|
|
121
|
+
# =============================================================
|
|
122
|
+
|
|
123
|
+
@cli.command("close")
|
|
124
|
+
@click.pass_context
|
|
125
|
+
def close_browser(ctx):
|
|
126
|
+
"""Close the Chrome browser that was kept alive for reCAPTCHA.
|
|
127
|
+
|
|
128
|
+
\b
|
|
129
|
+
After 'gflow auth', Chrome stays open so that image/video generation
|
|
130
|
+
can obtain reCAPTCHA tokens. Run this command when you're done.
|
|
131
|
+
"""
|
|
132
|
+
kill_auth_browser()
|
|
133
|
+
console.print("[green]Chrome session closed.[/green]")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# =============================================================
|
|
137
|
+
# Image generation
|
|
138
|
+
# =============================================================
|
|
139
|
+
|
|
140
|
+
@cli.command("generate-image")
|
|
141
|
+
@click.argument("prompt")
|
|
142
|
+
@click.option("--aspect-ratio", default="landscape",
|
|
143
|
+
type=click.Choice(["square", "portrait", "landscape", "4:3", "1:1", "16:9", "9:16"]),
|
|
144
|
+
help="Image aspect ratio")
|
|
145
|
+
@click.option("--seed", default=None, type=int, help="Random seed for reproducibility")
|
|
146
|
+
@click.option("--num", default=1, type=click.IntRange(1, 8), help="Number of images (1-8)")
|
|
147
|
+
@click.option("-o", "--output", default=None, help="Output file path (auto-named if omitted)")
|
|
148
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
149
|
+
@click.pass_context
|
|
150
|
+
def generate_image(ctx, prompt, aspect_ratio, seed, num, output, as_json):
|
|
151
|
+
"""Generate images from a text prompt using Imagen 4.
|
|
152
|
+
|
|
153
|
+
\b
|
|
154
|
+
Examples:
|
|
155
|
+
gflow generate-image "a cat astronaut floating in space"
|
|
156
|
+
gflow generate-image "sunset over mountains" --aspect-ratio landscape --num 4
|
|
157
|
+
gflow generate-image "logo design" --aspect-ratio square -o logo.png
|
|
158
|
+
"""
|
|
159
|
+
client = _get_client(ctx.obj["debug"])
|
|
160
|
+
|
|
161
|
+
req = GenerateImageRequest(
|
|
162
|
+
prompt=prompt,
|
|
163
|
+
aspect_ratio=aspect_ratio,
|
|
164
|
+
seed=seed,
|
|
165
|
+
num_images=num,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
console.print("[dim]Connecting to auth browser for reCAPTCHA...[/dim]")
|
|
170
|
+
assets = client.generate_image(req)
|
|
171
|
+
except FlowAPIError as e:
|
|
172
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
173
|
+
client.close()
|
|
174
|
+
sys.exit(1)
|
|
175
|
+
|
|
176
|
+
if not assets:
|
|
177
|
+
console.print("[yellow]No images generated.[/yellow]")
|
|
178
|
+
client.close()
|
|
179
|
+
sys.exit(1)
|
|
180
|
+
|
|
181
|
+
# Save images to disk
|
|
182
|
+
saved_files = []
|
|
183
|
+
for i, asset in enumerate(assets):
|
|
184
|
+
if output and len(assets) == 1:
|
|
185
|
+
filepath = output
|
|
186
|
+
elif output:
|
|
187
|
+
p = Path(output)
|
|
188
|
+
filepath = str(p.parent / f"{p.stem}-{i}{p.suffix}")
|
|
189
|
+
else:
|
|
190
|
+
filepath = f"gflow-image-{i}.png"
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
path = client.save_image(asset, filepath)
|
|
194
|
+
saved_files.append(str(path))
|
|
195
|
+
console.print(f"[green]Saved:[/green] {path}")
|
|
196
|
+
except FlowAPIError as e:
|
|
197
|
+
console.print(f"[yellow]Could not save image {i}:[/yellow] {e}")
|
|
198
|
+
|
|
199
|
+
client.close()
|
|
200
|
+
|
|
201
|
+
if as_json:
|
|
202
|
+
result = []
|
|
203
|
+
for asset in assets:
|
|
204
|
+
d = asset.model_dump()
|
|
205
|
+
if "encodedImage" in d.get("raw", {}):
|
|
206
|
+
enc = d["raw"]["encodedImage"]
|
|
207
|
+
d["raw"]["encodedImage"] = f"<{len(enc)} chars base64>"
|
|
208
|
+
result.append(d)
|
|
209
|
+
click.echo(json.dumps({"images": result, "saved_files": saved_files}, indent=2))
|
|
210
|
+
else:
|
|
211
|
+
console.print(f"\n[bold]Generated {len(assets)} image(s)[/bold] for: {prompt}")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# =============================================================
|
|
215
|
+
# Video generation
|
|
216
|
+
# =============================================================
|
|
217
|
+
|
|
218
|
+
@cli.command("generate-video")
|
|
219
|
+
@click.argument("prompt")
|
|
220
|
+
@click.option("--aspect-ratio", default="landscape",
|
|
221
|
+
type=click.Choice(["square", "portrait", "landscape", "16:9", "9:16", "1:1"]))
|
|
222
|
+
@click.option("--seed", default=None, type=int)
|
|
223
|
+
@click.option("--wait/--no-wait", default=True, help="Wait for video to finish rendering")
|
|
224
|
+
@click.option("--timeout", default=300, type=int, help="Max wait seconds (default: 300)")
|
|
225
|
+
@click.option("-o", "--output", default=None, help="Output file path")
|
|
226
|
+
@click.option("-i", "--image", default=None, help="Starting frame image path (image-to-video)")
|
|
227
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
228
|
+
@click.pass_context
|
|
229
|
+
def generate_video(ctx, prompt, aspect_ratio, seed, wait, timeout, output, image, as_json):
|
|
230
|
+
"""Generate a video from a text prompt (or image + prompt) using Veo 3.1.
|
|
231
|
+
|
|
232
|
+
\b
|
|
233
|
+
Video generation is async — it takes 1-3 minutes.
|
|
234
|
+
By default, gflow will poll until the video is ready.
|
|
235
|
+
|
|
236
|
+
\b
|
|
237
|
+
Examples:
|
|
238
|
+
gflow generate-video "a timelapse of a flower blooming"
|
|
239
|
+
gflow generate-video "drone flyover of a city" --aspect-ratio landscape
|
|
240
|
+
gflow generate-video "ocean waves" -o waves.mp4
|
|
241
|
+
gflow generate-video "cat walking" --no-wait # just submit, don't wait
|
|
242
|
+
gflow generate-video "person dancing" -i frame.png # image-to-video
|
|
243
|
+
"""
|
|
244
|
+
client = _get_client(ctx.obj["debug"])
|
|
245
|
+
|
|
246
|
+
# Validate image path if provided
|
|
247
|
+
if image:
|
|
248
|
+
img_path = Path(image)
|
|
249
|
+
if not img_path.exists():
|
|
250
|
+
console.print(f"[red]Error: Image file not found:[/red] {image}")
|
|
251
|
+
sys.exit(1)
|
|
252
|
+
image = str(img_path.resolve()) # Use absolute path
|
|
253
|
+
console.print(f"[cyan]🖼️ Image-to-video mode:[/cyan] {img_path.name}")
|
|
254
|
+
|
|
255
|
+
req = GenerateVideoRequest(
|
|
256
|
+
prompt=prompt,
|
|
257
|
+
aspect_ratio=aspect_ratio,
|
|
258
|
+
seed=seed,
|
|
259
|
+
start_image=image,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
mode_label = "image-to-video (I2V)" if image else "text-to-video (T2V)"
|
|
263
|
+
try:
|
|
264
|
+
with console.status(f"[bold green]Submitting {mode_label} generation (Veo 3.1)..."):
|
|
265
|
+
assets = client.generate_video(req)
|
|
266
|
+
except FlowAPIError as e:
|
|
267
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
268
|
+
sys.exit(1)
|
|
269
|
+
|
|
270
|
+
if not assets:
|
|
271
|
+
console.print("[yellow]No video operation returned.[/yellow]")
|
|
272
|
+
sys.exit(1)
|
|
273
|
+
|
|
274
|
+
# Get operation names for polling
|
|
275
|
+
op_names = [a.id for a in assets if a.id]
|
|
276
|
+
console.print(f"[bold]Video submitted.[/bold] Operations: {len(op_names)}")
|
|
277
|
+
|
|
278
|
+
if wait and op_names:
|
|
279
|
+
try:
|
|
280
|
+
with console.status("[bold green]Rendering video (this takes 1-3 minutes)..."):
|
|
281
|
+
final_assets = client.wait_for_video(op_names, timeout=timeout)
|
|
282
|
+
except FlowAPIError as e:
|
|
283
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
284
|
+
sys.exit(1)
|
|
285
|
+
|
|
286
|
+
for i, asset in enumerate(final_assets):
|
|
287
|
+
out = output or f"gflow-video-{i}.mp4"
|
|
288
|
+
try:
|
|
289
|
+
path = client.save_video(asset, out)
|
|
290
|
+
console.print(f"[green]Saved:[/green] {path}")
|
|
291
|
+
except Exception as e:
|
|
292
|
+
console.print(f"[yellow]Download failed:[/yellow] {e}")
|
|
293
|
+
if asset.url:
|
|
294
|
+
console.print(f"Video URL: {asset.url}")
|
|
295
|
+
sys.exit(1)
|
|
296
|
+
|
|
297
|
+
if as_json:
|
|
298
|
+
click.echo(json.dumps([a.model_dump() for a in final_assets], indent=2, default=str))
|
|
299
|
+
else:
|
|
300
|
+
console.print(f"\n[bold]Video rendered[/bold] for: {prompt}")
|
|
301
|
+
else:
|
|
302
|
+
for op in op_names:
|
|
303
|
+
console.print(f" Operation: {op}")
|
|
304
|
+
console.print("Run with --wait or use 'gflow wait <op-name>' to check status.")
|
|
305
|
+
|
|
306
|
+
if as_json:
|
|
307
|
+
click.echo(json.dumps([a.model_dump() for a in assets], indent=2, default=str))
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# =============================================================
|
|
311
|
+
# Video extend
|
|
312
|
+
# =============================================================
|
|
313
|
+
|
|
314
|
+
@cli.command("extend-video")
|
|
315
|
+
@click.argument("media_id")
|
|
316
|
+
@click.argument("prompt")
|
|
317
|
+
@click.option("--aspect-ratio", default="landscape",
|
|
318
|
+
type=click.Choice(["square", "portrait", "landscape", "16:9", "9:16", "1:1"]))
|
|
319
|
+
@click.option("--seed", default=None, type=int)
|
|
320
|
+
@click.option("--wait/--no-wait", default=True, help="Wait for video to finish rendering")
|
|
321
|
+
@click.option("--timeout", default=300, type=int, help="Max wait seconds (default: 300)")
|
|
322
|
+
@click.option("-o", "--output", default=None, help="Output file path")
|
|
323
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
324
|
+
@click.pass_context
|
|
325
|
+
def extend_video(ctx, media_id, prompt, aspect_ratio, seed, wait, timeout, output, as_json):
|
|
326
|
+
"""Extend an existing video with a continuation prompt.
|
|
327
|
+
|
|
328
|
+
\b
|
|
329
|
+
MEDIA_ID is the media name/ID of the video to extend.
|
|
330
|
+
You can get it from generate-video --json output.
|
|
331
|
+
|
|
332
|
+
\b
|
|
333
|
+
Examples:
|
|
334
|
+
gflow extend-video abc123-def456 "the cat jumps onto a couch"
|
|
335
|
+
gflow extend-video abc123 "camera pans left" -o extended.mp4
|
|
336
|
+
"""
|
|
337
|
+
client = _get_client(ctx.obj["debug"])
|
|
338
|
+
|
|
339
|
+
req = ExtendVideoRequest(
|
|
340
|
+
prompt=prompt,
|
|
341
|
+
media_id=media_id,
|
|
342
|
+
aspect_ratio=aspect_ratio,
|
|
343
|
+
seed=seed,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
with console.status("[bold green]Submitting video extend (Veo 3.1)..."):
|
|
348
|
+
assets = client.extend_video(req)
|
|
349
|
+
except FlowAPIError as e:
|
|
350
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
351
|
+
sys.exit(1)
|
|
352
|
+
|
|
353
|
+
if not assets:
|
|
354
|
+
console.print("[yellow]No video operation returned.[/yellow]")
|
|
355
|
+
sys.exit(1)
|
|
356
|
+
|
|
357
|
+
op_names = [a.id for a in assets if a.id]
|
|
358
|
+
console.print(f"[bold]Extend submitted.[/bold] Operations: {len(op_names)}")
|
|
359
|
+
|
|
360
|
+
if wait and op_names:
|
|
361
|
+
try:
|
|
362
|
+
with console.status("[bold green]Rendering extended video (this takes 1-3 minutes)..."):
|
|
363
|
+
final_assets = client.wait_for_video(op_names, timeout=timeout)
|
|
364
|
+
except FlowAPIError as e:
|
|
365
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
366
|
+
sys.exit(1)
|
|
367
|
+
|
|
368
|
+
for i, asset in enumerate(final_assets):
|
|
369
|
+
out = output or f"gflow-extend-{i}.mp4"
|
|
370
|
+
try:
|
|
371
|
+
path = client.save_video(asset, out)
|
|
372
|
+
console.print(f"[green]Saved:[/green] {path}")
|
|
373
|
+
except Exception as e:
|
|
374
|
+
console.print(f"[yellow]Download failed:[/yellow] {e}")
|
|
375
|
+
if asset.url:
|
|
376
|
+
console.print(f"Video URL: {asset.url}")
|
|
377
|
+
|
|
378
|
+
if as_json:
|
|
379
|
+
click.echo(json.dumps([a.model_dump() for a in final_assets], indent=2, default=str))
|
|
380
|
+
else:
|
|
381
|
+
console.print("\n[bold]Extended video rendered.[/bold]")
|
|
382
|
+
# Print the new media ID for chaining
|
|
383
|
+
for asset in final_assets:
|
|
384
|
+
console.print(f" New media ID: {asset.id}")
|
|
385
|
+
else:
|
|
386
|
+
for op in op_names:
|
|
387
|
+
console.print(f" Operation: {op}")
|
|
388
|
+
|
|
389
|
+
if as_json:
|
|
390
|
+
click.echo(json.dumps([a.model_dump() for a in assets], indent=2, default=str))
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# =============================================================
|
|
394
|
+
# Long video (auto-extend loop)
|
|
395
|
+
# =============================================================
|
|
396
|
+
|
|
397
|
+
@cli.command("long-video")
|
|
398
|
+
@click.argument("prompt")
|
|
399
|
+
@click.option("--extend-prompt", "-e", multiple=True,
|
|
400
|
+
help="Prompt(s) for each extension. Can specify multiple times. If fewer than --extensions, last prompt is reused.")
|
|
401
|
+
@click.option("--extensions", "-n", default=4, type=click.IntRange(1, 50),
|
|
402
|
+
help="Number of times to extend (default: 4, each ~8s = ~40s total)")
|
|
403
|
+
@click.option("--aspect-ratio", default="landscape",
|
|
404
|
+
type=click.Choice(["square", "portrait", "landscape", "16:9", "9:16", "1:1"]))
|
|
405
|
+
@click.option("--seed", default=None, type=int)
|
|
406
|
+
@click.option("--timeout", default=300, type=int, help="Max wait per segment (default: 300)")
|
|
407
|
+
@click.option("-o", "--output-dir", default=".", help="Output directory for segments")
|
|
408
|
+
@click.option("--prefix", default="gflow-long", help="Filename prefix for segments")
|
|
409
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
410
|
+
@click.pass_context
|
|
411
|
+
def long_video(ctx, prompt, extend_prompt, extensions, aspect_ratio, seed,
|
|
412
|
+
timeout, output_dir, prefix, as_json):
|
|
413
|
+
"""Generate a long video by auto-extending multiple times.
|
|
414
|
+
|
|
415
|
+
\b
|
|
416
|
+
First generates a base video from PROMPT, then extends it
|
|
417
|
+
N times (default 4). Each Veo 3.1 segment is ~8 seconds,
|
|
418
|
+
so 4 extensions = ~40 seconds total.
|
|
419
|
+
|
|
420
|
+
\b
|
|
421
|
+
Use -e to specify different prompts for each extension,
|
|
422
|
+
or leave blank to auto-continue with the original prompt.
|
|
423
|
+
|
|
424
|
+
\b
|
|
425
|
+
Examples:
|
|
426
|
+
gflow long-video "a cat exploring a garden" -n 6
|
|
427
|
+
gflow long-video "drone flying over city" -e "camera dives down" -e "flies through streets"
|
|
428
|
+
gflow long-video "ocean waves" -n 10 -o ./my-video --prefix ocean
|
|
429
|
+
"""
|
|
430
|
+
import time as _time
|
|
431
|
+
|
|
432
|
+
client = _get_client(ctx.obj["debug"])
|
|
433
|
+
out_dir = Path(output_dir)
|
|
434
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
435
|
+
|
|
436
|
+
all_assets = []
|
|
437
|
+
segment_paths = []
|
|
438
|
+
|
|
439
|
+
# ---- Prompt sanitization for policy violations ----
|
|
440
|
+
_POLICY_KEYWORDS = re.compile(
|
|
441
|
+
r'\b(explosion|exploding|blood|bloody|gore|gory|weapon|weapons|gun|guns|'
|
|
442
|
+
r'rifle|pistol|bullet|bullets|missile|bomb|bombing|grenade|'
|
|
443
|
+
r'kill|killing|murder|dead body|corpse|decapitat|dismember|'
|
|
444
|
+
r'nude|naked|sexual|erotic|'
|
|
445
|
+
r'drug|cocaine|heroin|meth|'
|
|
446
|
+
r'torture|torment|mutilat|'
|
|
447
|
+
r'suicide|self.harm|'
|
|
448
|
+
r'terrorist|terrorism|extremist|'
|
|
449
|
+
r'child|children|minor|kid|infant|baby|toddler)\b',
|
|
450
|
+
re.IGNORECASE
|
|
451
|
+
)
|
|
452
|
+
_POLICY_REPLACEMENTS = {
|
|
453
|
+
'explosion': 'bright energy burst', 'exploding': 'rapidly expanding',
|
|
454
|
+
'blood': 'red fluid', 'bloody': 'dramatic', 'gore': 'intensity', 'gory': 'intense',
|
|
455
|
+
'weapon': 'tool', 'weapons': 'tools', 'gun': 'device', 'guns': 'devices',
|
|
456
|
+
'rifle': 'long device', 'pistol': 'small device',
|
|
457
|
+
'bullet': 'projectile', 'bullets': 'projectiles',
|
|
458
|
+
'missile': 'fast-moving object', 'bomb': 'impact', 'bombing': 'impact event',
|
|
459
|
+
'grenade': 'small object',
|
|
460
|
+
'kill': 'overcome', 'killing': 'overcoming', 'murder': 'conflict',
|
|
461
|
+
'fire': 'warm glow', 'flame': 'warm light', 'flames': 'warm lights', 'burning': 'glowing',
|
|
462
|
+
'nuclear': 'powerful', 'war': 'conflict', 'warfare': 'conflict',
|
|
463
|
+
'death': 'end', 'dead': 'still', 'die': 'fade', 'dying': 'fading',
|
|
464
|
+
}
|
|
465
|
+
_GENERIC_FALLBACKS = [
|
|
466
|
+
"Cinematic slow aerial shot of a serene mountain landscape at golden hour, soft clouds, documentary style, 4K",
|
|
467
|
+
"Smooth dolly shot through a lush forest with sunlight filtering through trees, peaceful atmosphere, cinematic",
|
|
468
|
+
"Aerial tracking shot over a calm ocean at sunset, gentle waves reflecting golden light, documentary cinematic",
|
|
469
|
+
"Slow pan across a stunning nebula in deep space, vibrant colors, stars twinkling, cinematic documentary",
|
|
470
|
+
"Cinematic wide shot of rolling hills with wildflowers swaying in the breeze, warm golden sunlight, peaceful",
|
|
471
|
+
"Smooth tracking shot through an ancient library with dramatic lighting, dust particles in light beams, cinematic",
|
|
472
|
+
]
|
|
473
|
+
|
|
474
|
+
def _is_policy_violation(error_str):
|
|
475
|
+
"""Check if an error is a content policy/safety violation."""
|
|
476
|
+
lower = error_str.lower()
|
|
477
|
+
return any(kw in lower for kw in [
|
|
478
|
+
'safety', 'policy', 'blocked', 'violat', 'prohibited',
|
|
479
|
+
'harmful', 'inappropriate', 'content filter', 'moderation',
|
|
480
|
+
'responsible ai', 'rai_blocked', 'rai policy',
|
|
481
|
+
'could not generate', 'generation was blocked',
|
|
482
|
+
])
|
|
483
|
+
|
|
484
|
+
def _sanitize_prompt(orig_prompt, attempt):
|
|
485
|
+
"""Clean a prompt that triggered a policy violation."""
|
|
486
|
+
if attempt >= 2:
|
|
487
|
+
# Third attempt — use a generic safe fallback
|
|
488
|
+
import hashlib
|
|
489
|
+
idx = int(hashlib.md5(orig_prompt.encode()).hexdigest(), 16) % len(_GENERIC_FALLBACKS)
|
|
490
|
+
return _GENERIC_FALLBACKS[idx]
|
|
491
|
+
# Second attempt — strip/replace flagged words and add safe prefix
|
|
492
|
+
cleaned = orig_prompt
|
|
493
|
+
for word, replacement in _POLICY_REPLACEMENTS.items():
|
|
494
|
+
cleaned = re.sub(r'\b' + re.escape(word) + r'\b', replacement, cleaned, flags=re.IGNORECASE)
|
|
495
|
+
# Remove any remaining flagged words not in replacements dict
|
|
496
|
+
cleaned = _POLICY_KEYWORDS.sub('', cleaned)
|
|
497
|
+
# Clean up double spaces
|
|
498
|
+
cleaned = re.sub(r'\s{2,}', ' ', cleaned).strip()
|
|
499
|
+
# Add documentary/cinematic prefix for safety
|
|
500
|
+
if not cleaned.lower().startswith(('cinematic', 'documentary', 'artistic', 'aerial')):
|
|
501
|
+
cleaned = f"Cinematic documentary shot of {cleaned}"
|
|
502
|
+
return cleaned
|
|
503
|
+
|
|
504
|
+
# ---- Step 1: Generate base video (with retry) ----
|
|
505
|
+
console.print(f"\n[bold]Step 1/{extensions + 1}:[/bold] Generating base video...")
|
|
506
|
+
|
|
507
|
+
base_asset = None
|
|
508
|
+
_base_last_error = None
|
|
509
|
+
for base_attempt in range(3):
|
|
510
|
+
base_prompt = prompt
|
|
511
|
+
|
|
512
|
+
if base_attempt > 0:
|
|
513
|
+
wait = 10 * base_attempt
|
|
514
|
+
console.print(f" [yellow]Base video retry {base_attempt}/2 after {wait}s...[/yellow]")
|
|
515
|
+
_time.sleep(wait)
|
|
516
|
+
|
|
517
|
+
# If previous failure looked like a policy violation, sanitize
|
|
518
|
+
if _base_last_error is not None and _base_last_error.is_policy:
|
|
519
|
+
base_prompt = _sanitize_prompt(prompt, base_attempt)
|
|
520
|
+
console.print(f" [cyan]Sanitized prompt:[/cyan] {base_prompt[:80]}...")
|
|
521
|
+
|
|
522
|
+
class _BaseError:
|
|
523
|
+
is_policy = False
|
|
524
|
+
msg = ""
|
|
525
|
+
_base_last_error = _BaseError()
|
|
526
|
+
|
|
527
|
+
try:
|
|
528
|
+
req = GenerateVideoRequest(
|
|
529
|
+
prompt=base_prompt,
|
|
530
|
+
aspect_ratio=aspect_ratio,
|
|
531
|
+
seed=seed,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
try:
|
|
535
|
+
with console.status("[bold green]Submitting base video..."):
|
|
536
|
+
assets = client.generate_video(req)
|
|
537
|
+
except FlowAPIError as e:
|
|
538
|
+
err_str = str(e)
|
|
539
|
+
_base_last_error.msg = err_str
|
|
540
|
+
_base_last_error.is_policy = _is_policy_violation(err_str)
|
|
541
|
+
console.print(f" [red]Error submitting base video:[/red] {err_str[:120]}")
|
|
542
|
+
continue
|
|
543
|
+
|
|
544
|
+
if not assets:
|
|
545
|
+
_base_last_error.msg = "No operation returned"
|
|
546
|
+
console.print(" [yellow]No video operation returned.[/yellow]")
|
|
547
|
+
continue
|
|
548
|
+
|
|
549
|
+
op_names = [a.id for a in assets if a.id]
|
|
550
|
+
try:
|
|
551
|
+
with console.status("[bold green]Rendering base video (1-3 min)..."):
|
|
552
|
+
final_assets = client.wait_for_video(op_names, timeout=timeout)
|
|
553
|
+
except FlowAPIError as e:
|
|
554
|
+
err_str = str(e)
|
|
555
|
+
_base_last_error.msg = err_str
|
|
556
|
+
_base_last_error.is_policy = _is_policy_violation(err_str)
|
|
557
|
+
console.print(f" [red]Error rendering base video:[/red] {err_str[:120]}")
|
|
558
|
+
continue
|
|
559
|
+
|
|
560
|
+
if not final_assets:
|
|
561
|
+
_base_last_error.msg = "No assets returned"
|
|
562
|
+
_base_last_error.is_policy = True
|
|
563
|
+
console.print(" [yellow]Base video returned no assets (possibly blocked).[/yellow]")
|
|
564
|
+
continue
|
|
565
|
+
|
|
566
|
+
base_asset = final_assets[0]
|
|
567
|
+
all_assets.append(base_asset)
|
|
568
|
+
break # success
|
|
569
|
+
|
|
570
|
+
except (requests.exceptions.ConnectionError, ConnectionResetError, OSError) as e:
|
|
571
|
+
_base_last_error.msg = str(e)
|
|
572
|
+
console.print(f" [red]Connection error on base video:[/red] {str(e)[:120]}")
|
|
573
|
+
continue
|
|
574
|
+
|
|
575
|
+
except Exception as e:
|
|
576
|
+
_base_last_error.msg = str(e)
|
|
577
|
+
_base_last_error.is_policy = _is_policy_violation(str(e))
|
|
578
|
+
console.print(f" [red]Unexpected error on base video:[/red] {str(e)[:120]}")
|
|
579
|
+
continue
|
|
580
|
+
|
|
581
|
+
if base_asset is None:
|
|
582
|
+
console.print("[red bold]Base video failed after 3 attempts. Cannot continue.[/red bold]")
|
|
583
|
+
sys.exit(1)
|
|
584
|
+
|
|
585
|
+
# Save base segment
|
|
586
|
+
seg_path = out_dir / f"{prefix}-seg0.mp4"
|
|
587
|
+
try:
|
|
588
|
+
path = client.save_video(base_asset, seg_path)
|
|
589
|
+
segment_paths.append(path)
|
|
590
|
+
console.print(f" [green]Segment 0 saved:[/green] {path}")
|
|
591
|
+
except Exception as e:
|
|
592
|
+
console.print(f" [yellow]Download failed:[/yellow] {e}")
|
|
593
|
+
|
|
594
|
+
# Use primaryMediaId from workflow (this is what extend needs to find the source video)
|
|
595
|
+
current_media_id = client.get_primary_media_id() or client.get_media_name_for_op(base_asset.id)
|
|
596
|
+
workflow_id = client._workflow_id or ""
|
|
597
|
+
console.print(f" Media ID (primaryMedia): {current_media_id}")
|
|
598
|
+
|
|
599
|
+
# Finalize the workflow (PATCH displayName) — the Flow UI does this
|
|
600
|
+
# after every generation and it may be needed for extend to find the media
|
|
601
|
+
if workflow_id:
|
|
602
|
+
prompt_short = prompt[:30].replace('"', '')
|
|
603
|
+
client.update_workflow(workflow_id, display_name=prompt_short)
|
|
604
|
+
|
|
605
|
+
# ---- Step 2+: Extend loop ----
|
|
606
|
+
MAX_SEGMENT_RETRIES = 3
|
|
607
|
+
for i in range(extensions):
|
|
608
|
+
step = i + 2
|
|
609
|
+
console.print(f"\n[bold]Step {step}/{extensions + 1}:[/bold] Extending video (segment {i + 1})...")
|
|
610
|
+
|
|
611
|
+
# Pick the extend prompt
|
|
612
|
+
if extend_prompt and i < len(extend_prompt):
|
|
613
|
+
ext_prompt_orig = extend_prompt[i]
|
|
614
|
+
elif extend_prompt:
|
|
615
|
+
ext_prompt_orig = extend_prompt[-1] # Reuse last prompt
|
|
616
|
+
else:
|
|
617
|
+
ext_prompt_orig = prompt # Reuse original prompt
|
|
618
|
+
|
|
619
|
+
segment_success = False
|
|
620
|
+
_segment_last_error = None
|
|
621
|
+
for attempt in range(MAX_SEGMENT_RETRIES):
|
|
622
|
+
ext_prompt = ext_prompt_orig
|
|
623
|
+
|
|
624
|
+
if attempt > 0:
|
|
625
|
+
wait = 10 * attempt
|
|
626
|
+
console.print(f" [yellow]Segment {i + 1} retry {attempt}/{MAX_SEGMENT_RETRIES - 1} after {wait}s...[/yellow]")
|
|
627
|
+
_time.sleep(wait)
|
|
628
|
+
|
|
629
|
+
# If previous failure was a policy violation, sanitize the prompt
|
|
630
|
+
if _segment_last_error is not None and _segment_last_error.is_policy:
|
|
631
|
+
ext_prompt = _sanitize_prompt(ext_prompt_orig, attempt)
|
|
632
|
+
console.print(f" [cyan]Sanitized prompt:[/cyan] {ext_prompt[:80]}...")
|
|
633
|
+
|
|
634
|
+
# Track error info for this attempt
|
|
635
|
+
class _SegError:
|
|
636
|
+
is_policy = False
|
|
637
|
+
msg = ""
|
|
638
|
+
_segment_last_error = _SegError()
|
|
639
|
+
|
|
640
|
+
try:
|
|
641
|
+
ext_req = ExtendVideoRequest(
|
|
642
|
+
prompt=ext_prompt,
|
|
643
|
+
media_id=current_media_id,
|
|
644
|
+
aspect_ratio=aspect_ratio,
|
|
645
|
+
workflow_id=workflow_id,
|
|
646
|
+
seed=(seed + i + 1) if seed is not None else None,
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
# Submit extend
|
|
650
|
+
ext_assets = None
|
|
651
|
+
try:
|
|
652
|
+
with console.status(f"[bold green]Submitting extend ({ext_prompt[:40]})..."):
|
|
653
|
+
ext_assets = client.extend_video(ext_req)
|
|
654
|
+
except FlowAPIError as e:
|
|
655
|
+
err_str = str(e)
|
|
656
|
+
_segment_last_error.msg = err_str
|
|
657
|
+
_segment_last_error.is_policy = _is_policy_violation(err_str)
|
|
658
|
+
if _segment_last_error.is_policy:
|
|
659
|
+
console.print(f" [red]Policy violation on segment {i + 1}:[/red] {err_str[:120]}")
|
|
660
|
+
else:
|
|
661
|
+
console.print(f" [red]Error submitting segment {i + 1}:[/red] {err_str[:120]}")
|
|
662
|
+
continue # retry
|
|
663
|
+
|
|
664
|
+
if not ext_assets:
|
|
665
|
+
_segment_last_error.msg = "No operation returned"
|
|
666
|
+
console.print(f" [yellow]No operation returned for segment {i + 1}.[/yellow]")
|
|
667
|
+
continue # retry
|
|
668
|
+
|
|
669
|
+
# Poll/render
|
|
670
|
+
ext_op_names = [a.id for a in ext_assets if a.id]
|
|
671
|
+
try:
|
|
672
|
+
with console.status(f"[bold green]Rendering segment {i + 1} (1-3 min)..."):
|
|
673
|
+
ext_final = client.wait_for_video(ext_op_names, timeout=timeout)
|
|
674
|
+
except FlowAPIError as e:
|
|
675
|
+
err_str = str(e)
|
|
676
|
+
_segment_last_error.msg = err_str
|
|
677
|
+
_segment_last_error.is_policy = _is_policy_violation(err_str)
|
|
678
|
+
if _segment_last_error.is_policy:
|
|
679
|
+
console.print(f" [red]Policy violation rendering segment {i + 1}:[/red] {err_str[:120]}")
|
|
680
|
+
else:
|
|
681
|
+
console.print(f" [red]Error rendering segment {i + 1}:[/red] {err_str[:120]}")
|
|
682
|
+
continue # retry
|
|
683
|
+
|
|
684
|
+
if not ext_final:
|
|
685
|
+
_segment_last_error.msg = "No assets returned"
|
|
686
|
+
_segment_last_error.is_policy = True # often means blocked
|
|
687
|
+
console.print(f" [yellow]Segment {i + 1} returned no assets (possibly blocked).[/yellow]")
|
|
688
|
+
continue # retry
|
|
689
|
+
|
|
690
|
+
ext_asset = ext_final[0]
|
|
691
|
+
all_assets.append(ext_asset)
|
|
692
|
+
|
|
693
|
+
# Save segment
|
|
694
|
+
seg_path = out_dir / f"{prefix}-seg{i + 1}.mp4"
|
|
695
|
+
try:
|
|
696
|
+
path = client.save_video(ext_asset, seg_path)
|
|
697
|
+
segment_paths.append(path)
|
|
698
|
+
console.print(f" [green]Segment {i + 1} saved:[/green] {path}")
|
|
699
|
+
except Exception as e:
|
|
700
|
+
console.print(f" [yellow]Download failed:[/yellow] {e}")
|
|
701
|
+
|
|
702
|
+
# Update workflow for next segment chaining
|
|
703
|
+
new_media_name = client.get_media_name_for_op(ext_asset.id) or ext_asset.id
|
|
704
|
+
if client._workflow_id:
|
|
705
|
+
workflow_id = client._workflow_id
|
|
706
|
+
if workflow_id and new_media_name:
|
|
707
|
+
client.update_workflow(workflow_id, primary_media_id=new_media_name)
|
|
708
|
+
current_media_id = new_media_name
|
|
709
|
+
console.print(f" Media ID (primaryMedia): {current_media_id}")
|
|
710
|
+
|
|
711
|
+
segment_success = True
|
|
712
|
+
break # segment done, move to next
|
|
713
|
+
|
|
714
|
+
except (requests.exceptions.ConnectionError, ConnectionResetError, OSError) as e:
|
|
715
|
+
_segment_last_error.msg = str(e)
|
|
716
|
+
console.print(f" [red]Connection error on segment {i + 1}:[/red] {str(e)[:120]}")
|
|
717
|
+
continue # retry
|
|
718
|
+
|
|
719
|
+
except Exception as e:
|
|
720
|
+
_segment_last_error.msg = str(e)
|
|
721
|
+
_segment_last_error.is_policy = _is_policy_violation(str(e))
|
|
722
|
+
console.print(f" [red]Unexpected error on segment {i + 1}:[/red] {str(e)[:120]}")
|
|
723
|
+
continue # retry
|
|
724
|
+
|
|
725
|
+
if not segment_success:
|
|
726
|
+
console.print(f" [red bold]Segment {i + 1} failed after {MAX_SEGMENT_RETRIES} attempts — skipping.[/red bold]")
|
|
727
|
+
# Don't break — try remaining segments. The video will have a gap but at least continues.
|
|
728
|
+
# For chaining to work, we keep the same current_media_id (last successful segment)
|
|
729
|
+
continue
|
|
730
|
+
|
|
731
|
+
# ---- Summary ----
|
|
732
|
+
console.print(f"\n[bold green]Done![/bold green] Generated {len(all_assets)} segments.")
|
|
733
|
+
for p in segment_paths:
|
|
734
|
+
console.print(f" {p}")
|
|
735
|
+
|
|
736
|
+
if as_json:
|
|
737
|
+
click.echo(json.dumps([a.model_dump() for a in all_assets], indent=2, default=str))
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
# =============================================================
|
|
741
|
+
# Caption (image-to-text)
|
|
742
|
+
# =============================================================
|
|
743
|
+
|
|
744
|
+
@cli.command("caption")
|
|
745
|
+
@click.argument("image_path", type=click.Path(exists=True))
|
|
746
|
+
@click.option("--count", default=1, type=click.IntRange(1, 5), help="Number of captions to generate")
|
|
747
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
748
|
+
@click.pass_context
|
|
749
|
+
def caption_image(ctx, image_path, count, as_json):
|
|
750
|
+
"""Generate a caption/description from an image file.
|
|
751
|
+
|
|
752
|
+
\b
|
|
753
|
+
Examples:
|
|
754
|
+
gflow caption photo.png
|
|
755
|
+
gflow caption my-image.jpg --count 3
|
|
756
|
+
"""
|
|
757
|
+
client = _get_client(ctx.obj["debug"])
|
|
758
|
+
|
|
759
|
+
try:
|
|
760
|
+
with console.status("[bold green]Generating caption..."):
|
|
761
|
+
captions = client.caption_image(image_path, count=count)
|
|
762
|
+
except FlowAPIError as e:
|
|
763
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
764
|
+
sys.exit(1)
|
|
765
|
+
|
|
766
|
+
if as_json:
|
|
767
|
+
click.echo(json.dumps({"captions": captions}, indent=2))
|
|
768
|
+
else:
|
|
769
|
+
for i, cap in enumerate(captions):
|
|
770
|
+
console.print(f"[bold]Caption {i+1}:[/bold] {cap}")
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
# =============================================================
|
|
774
|
+
# Fetch media by ID
|
|
775
|
+
# =============================================================
|
|
776
|
+
|
|
777
|
+
@cli.command("fetch")
|
|
778
|
+
@click.argument("media_id")
|
|
779
|
+
@click.option("-o", "--output", default=None, help="Save to file")
|
|
780
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
781
|
+
@click.pass_context
|
|
782
|
+
def fetch_media(ctx, media_id, output, as_json):
|
|
783
|
+
"""Fetch a previously generated image/video by its media ID.
|
|
784
|
+
|
|
785
|
+
\b
|
|
786
|
+
Example:
|
|
787
|
+
gflow fetch <media-id> -o fetched-image.png
|
|
788
|
+
"""
|
|
789
|
+
client = _get_client(ctx.obj["debug"])
|
|
790
|
+
|
|
791
|
+
try:
|
|
792
|
+
asset = client.fetch_media(media_id)
|
|
793
|
+
except FlowAPIError as e:
|
|
794
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
795
|
+
sys.exit(1)
|
|
796
|
+
|
|
797
|
+
if output and asset.raw.get("encodedImage"):
|
|
798
|
+
import base64
|
|
799
|
+
img_bytes = base64.b64decode(asset.raw["encodedImage"])
|
|
800
|
+
Path(output).write_bytes(img_bytes)
|
|
801
|
+
console.print(f"[green]Saved:[/green] {output}")
|
|
802
|
+
elif output and asset.url:
|
|
803
|
+
path = client.download_asset(asset.url, output)
|
|
804
|
+
console.print(f"[green]Saved:[/green] {path}")
|
|
805
|
+
|
|
806
|
+
if as_json:
|
|
807
|
+
d = asset.model_dump()
|
|
808
|
+
if "encodedImage" in d.get("raw", {}):
|
|
809
|
+
d["raw"]["encodedImage"] = f"<{len(d['raw']['encodedImage'])} chars>"
|
|
810
|
+
click.echo(json.dumps(d, indent=2, default=str))
|
|
811
|
+
else:
|
|
812
|
+
console.print(f"[bold]Media:[/bold] {asset.id}")
|
|
813
|
+
console.print(f" Type: {asset.asset_type.value}")
|
|
814
|
+
console.print(f" Prompt: {asset.prompt}")
|
|
815
|
+
console.print(f" Model: {asset.model}")
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
# =============================================================
|
|
819
|
+
# User info
|
|
820
|
+
# =============================================================
|
|
821
|
+
|
|
822
|
+
@cli.command("whoami")
|
|
823
|
+
@click.pass_context
|
|
824
|
+
def whoami(ctx):
|
|
825
|
+
"""Show the currently authenticated user."""
|
|
826
|
+
client = _get_client(ctx.obj["debug"])
|
|
827
|
+
|
|
828
|
+
try:
|
|
829
|
+
user = client.get_user_info()
|
|
830
|
+
except (FlowAPIError, AuthError) as e:
|
|
831
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
832
|
+
sys.exit(1)
|
|
833
|
+
|
|
834
|
+
console.print(f"[bold]{user.get('name', 'Unknown')}[/bold]")
|
|
835
|
+
console.print(f" Email: {user.get('email', '?')}")
|
|
836
|
+
if user.get("image"):
|
|
837
|
+
console.print(f" Avatar: {user['image']}")
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
# =============================================================
|
|
841
|
+
# Raw request (discovery mode)
|
|
842
|
+
# =============================================================
|
|
843
|
+
|
|
844
|
+
@cli.command("raw")
|
|
845
|
+
@click.argument("method", type=click.Choice(["GET", "POST"], case_sensitive=False))
|
|
846
|
+
@click.argument("path")
|
|
847
|
+
@click.option("--data", "payload", default=None, help="JSON payload for POST")
|
|
848
|
+
@click.pass_context
|
|
849
|
+
def raw_request(ctx, method, path, payload):
|
|
850
|
+
"""Make a raw API request (for endpoint discovery).
|
|
851
|
+
|
|
852
|
+
\b
|
|
853
|
+
Examples:
|
|
854
|
+
gflow raw GET https://labs.google/fx/api/auth/session
|
|
855
|
+
gflow raw POST /v1:runImageFx --data '{"userInput":{"prompts":["test"]}}'
|
|
856
|
+
"""
|
|
857
|
+
client = _get_client(ctx.obj["debug"])
|
|
858
|
+
|
|
859
|
+
parsed_payload = None
|
|
860
|
+
if payload:
|
|
861
|
+
try:
|
|
862
|
+
parsed_payload = json.loads(payload)
|
|
863
|
+
except json.JSONDecodeError as e:
|
|
864
|
+
console.print(f"[red]Invalid JSON:[/red] {e}")
|
|
865
|
+
sys.exit(1)
|
|
866
|
+
|
|
867
|
+
try:
|
|
868
|
+
result = client.raw_request(method.upper(), path, parsed_payload)
|
|
869
|
+
click.echo(json.dumps(result, indent=2, default=str))
|
|
870
|
+
except FlowAPIError as e:
|
|
871
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
872
|
+
sys.exit(1)
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
# =============================================================
|
|
876
|
+
# Network sniffer (discover what APIs Flow actually uses)
|
|
877
|
+
# =============================================================
|
|
878
|
+
|
|
879
|
+
@cli.command("sniff")
|
|
880
|
+
@click.option("--duration", default=120, type=int, help="How many seconds to capture (default: 120)")
|
|
881
|
+
@click.option("-o", "--output", default="gflow-network-capture.json", help="Output file for captured requests")
|
|
882
|
+
@click.pass_context
|
|
883
|
+
def sniff_network(ctx, duration, output):
|
|
884
|
+
"""Sniff Flow's network traffic to discover real API endpoints.
|
|
885
|
+
|
|
886
|
+
\b
|
|
887
|
+
Opens Flow in a browser and captures all API requests while you
|
|
888
|
+
interact with it. Generate an image or video in the browser, then
|
|
889
|
+
come back here to see exactly what API calls Flow made.
|
|
890
|
+
|
|
891
|
+
\b
|
|
892
|
+
Steps:
|
|
893
|
+
1. Run: gflow sniff
|
|
894
|
+
2. In the browser, generate an image or video
|
|
895
|
+
3. Wait for it to finish, then press Ctrl+C or let the timer expire
|
|
896
|
+
4. Check the output file for captured API calls
|
|
897
|
+
"""
|
|
898
|
+
from selenium import webdriver
|
|
899
|
+
from selenium.webdriver.chrome.options import Options
|
|
900
|
+
from selenium.webdriver.chrome.service import Service
|
|
901
|
+
from webdriver_manager.chrome import ChromeDriverManager
|
|
902
|
+
from gflow.auth.browser_auth import ENV_DIR, FLOW_URL
|
|
903
|
+
|
|
904
|
+
print()
|
|
905
|
+
print("=" * 60)
|
|
906
|
+
print(" Google Flow Network Sniffer")
|
|
907
|
+
print("=" * 60)
|
|
908
|
+
print()
|
|
909
|
+
print(" A browser will open with Flow loaded.")
|
|
910
|
+
print(" Perform any action (generate image, video, etc.)")
|
|
911
|
+
print(f" Network traffic will be captured for {duration}s.")
|
|
912
|
+
print()
|
|
913
|
+
print(" Press Ctrl+C to stop early.")
|
|
914
|
+
print()
|
|
915
|
+
|
|
916
|
+
chrome_options = Options()
|
|
917
|
+
chrome_options.add_argument("--no-first-run")
|
|
918
|
+
chrome_options.add_argument("--no-default-browser-check")
|
|
919
|
+
chrome_options.add_argument("--disable-blink-features=AutomationControlled")
|
|
920
|
+
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
|
921
|
+
chrome_options.add_experimental_option("useAutomationExtension", False)
|
|
922
|
+
|
|
923
|
+
# Reuse the persistent profile (already logged in)
|
|
924
|
+
profile_dir = str(ENV_DIR / "chrome-profile")
|
|
925
|
+
chrome_options.add_argument(f"--user-data-dir={profile_dir}")
|
|
926
|
+
|
|
927
|
+
# Enable performance logging for network capture
|
|
928
|
+
chrome_options.set_capability("goog:loggingPrefs", {"performance": "ALL"})
|
|
929
|
+
|
|
930
|
+
driver = None
|
|
931
|
+
captured_requests = []
|
|
932
|
+
|
|
933
|
+
try:
|
|
934
|
+
service = Service(ChromeDriverManager().install())
|
|
935
|
+
driver = webdriver.Chrome(service=service, options=chrome_options)
|
|
936
|
+
|
|
937
|
+
# Enable CDP network domain
|
|
938
|
+
driver.execute_cdp_cmd("Network.enable", {})
|
|
939
|
+
|
|
940
|
+
driver.get(FLOW_URL)
|
|
941
|
+
print(" Browser opened. Perform your actions now...")
|
|
942
|
+
print()
|
|
943
|
+
|
|
944
|
+
import time as _time
|
|
945
|
+
start = _time.time()
|
|
946
|
+
request_bodies = {}
|
|
947
|
+
|
|
948
|
+
try:
|
|
949
|
+
while _time.time() - start < duration:
|
|
950
|
+
_time.sleep(2)
|
|
951
|
+
|
|
952
|
+
try:
|
|
953
|
+
logs = driver.get_log("performance")
|
|
954
|
+
except Exception:
|
|
955
|
+
logs = []
|
|
956
|
+
|
|
957
|
+
for entry in logs:
|
|
958
|
+
try:
|
|
959
|
+
msg = json.loads(entry["message"])["message"]
|
|
960
|
+
method = msg.get("method", "")
|
|
961
|
+
params = msg.get("params", {})
|
|
962
|
+
|
|
963
|
+
if method == "Network.requestWillBeSent":
|
|
964
|
+
request = params.get("request", {})
|
|
965
|
+
url = request.get("url", "")
|
|
966
|
+
http_method = request.get("method", "")
|
|
967
|
+
headers = request.get("headers", {})
|
|
968
|
+
post_data = request.get("postData", "")
|
|
969
|
+
request_id = params.get("requestId", "")
|
|
970
|
+
|
|
971
|
+
if any(ext in url for ext in [".js", ".css", ".png", ".jpg", ".svg", ".woff", ".ico"]):
|
|
972
|
+
continue
|
|
973
|
+
if any(h in url for h in ["google-analytics", "doubleclick", "googletagmanager", "play.google.com/log"]):
|
|
974
|
+
continue
|
|
975
|
+
|
|
976
|
+
req_data = {
|
|
977
|
+
"requestId": request_id,
|
|
978
|
+
"method": http_method,
|
|
979
|
+
"url": url,
|
|
980
|
+
"headers": dict(headers),
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
if post_data:
|
|
984
|
+
req_data["postData"] = post_data
|
|
985
|
+
if "batchexecute" in url:
|
|
986
|
+
req_data["type"] = "batchexecute"
|
|
987
|
+
elif "aisandbox" in url or "runImageFx" in url or "runVideoFx" in url:
|
|
988
|
+
req_data["type"] = "ai_sandbox"
|
|
989
|
+
try:
|
|
990
|
+
req_data["payload"] = json.loads(post_data)
|
|
991
|
+
except Exception:
|
|
992
|
+
pass
|
|
993
|
+
elif "/api/" in url:
|
|
994
|
+
req_data["type"] = "labs_api"
|
|
995
|
+
try:
|
|
996
|
+
req_data["payload"] = json.loads(post_data)
|
|
997
|
+
except Exception:
|
|
998
|
+
pass
|
|
999
|
+
|
|
1000
|
+
if request_id:
|
|
1001
|
+
request_bodies[request_id] = req_data
|
|
1002
|
+
|
|
1003
|
+
captured_requests.append(req_data)
|
|
1004
|
+
|
|
1005
|
+
if any(kw in url for kw in [
|
|
1006
|
+
"batchexecute", "aisandbox", "runImageFx", "runVideoFx",
|
|
1007
|
+
"googleapis", "/api/", "trpc",
|
|
1008
|
+
]):
|
|
1009
|
+
elapsed = _time.time() - start
|
|
1010
|
+
console.print(f" [{elapsed:.0f}s] [cyan]{http_method}[/cyan] {url[:100]}")
|
|
1011
|
+
|
|
1012
|
+
elif method == "Network.responseReceived":
|
|
1013
|
+
request_id = params.get("requestId", "")
|
|
1014
|
+
response = params.get("response", {})
|
|
1015
|
+
if request_id in request_bodies:
|
|
1016
|
+
request_bodies[request_id]["response_status"] = response.get("status")
|
|
1017
|
+
|
|
1018
|
+
except (json.JSONDecodeError, KeyError):
|
|
1019
|
+
continue
|
|
1020
|
+
|
|
1021
|
+
except KeyboardInterrupt:
|
|
1022
|
+
print("\n Stopped by user.")
|
|
1023
|
+
|
|
1024
|
+
except Exception as e:
|
|
1025
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
1026
|
+
sys.exit(1)
|
|
1027
|
+
finally:
|
|
1028
|
+
if driver:
|
|
1029
|
+
try:
|
|
1030
|
+
driver.quit()
|
|
1031
|
+
except Exception:
|
|
1032
|
+
pass
|
|
1033
|
+
|
|
1034
|
+
# Filter interesting requests
|
|
1035
|
+
interesting = [r for r in captured_requests if any(
|
|
1036
|
+
kw in r.get("url", "") for kw in [
|
|
1037
|
+
"batchexecute", "aisandbox", "runImageFx", "runVideoFx",
|
|
1038
|
+
"googleapis.com/v", "/api/", "trpc", "labs.google/fx",
|
|
1039
|
+
]
|
|
1040
|
+
)]
|
|
1041
|
+
|
|
1042
|
+
output_data = {
|
|
1043
|
+
"total_requests": len(captured_requests),
|
|
1044
|
+
"interesting_requests": len(interesting),
|
|
1045
|
+
"interesting": interesting,
|
|
1046
|
+
"all_requests": captured_requests,
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
Path(output).write_text(json.dumps(output_data, indent=2, default=str))
|
|
1050
|
+
console.print(f"\n[green]Saved {len(captured_requests)} requests ({len(interesting)} interesting) to {output}[/green]")
|
|
1051
|
+
|
|
1052
|
+
if interesting:
|
|
1053
|
+
print()
|
|
1054
|
+
table = Table(title="Interesting API Calls Found")
|
|
1055
|
+
table.add_column("Method", style="cyan", width=6)
|
|
1056
|
+
table.add_column("URL", style="white", max_width=80)
|
|
1057
|
+
table.add_column("Type", style="magenta")
|
|
1058
|
+
|
|
1059
|
+
for r in interesting[:20]:
|
|
1060
|
+
table.add_row(
|
|
1061
|
+
r.get("method", "?"),
|
|
1062
|
+
r.get("url", "")[:80],
|
|
1063
|
+
r.get("type", ""),
|
|
1064
|
+
)
|
|
1065
|
+
console.print(table)
|
|
1066
|
+
else:
|
|
1067
|
+
console.print("[yellow]No interesting API calls captured.[/yellow]")
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
# =============================================================
|
|
1071
|
+
# Entry point
|
|
1072
|
+
# =============================================================
|
|
1073
|
+
|
|
1074
|
+
if __name__ == "__main__":
|
|
1075
|
+
cli()
|