"""Video-generation methods for UiAutomationTransport. Mixed into `UiAutomationTransport` via `VideoGenerationMixin` — kept in its own module because `ui_automation.py` is already over the 800-line cap. Video generation mirrors `generate_images`: the transport drives the Flow editor UI and Flow's own JavaScript builds the request, sends it, and mints reCAPTCHA on submit — the transport never POSTs a generate body. The status endpoint returns HTTP 401 to `page.request.post`, so polling captures Flow's own `batchCheckAsyncVideoGenerationStatus` responses instead of issuing the POST (spec §5.5). """ from __future__ import annotations import asyncio import contextlib import json import random import re import time from collections.abc import AsyncGenerator, Callable from functools import partial from pathlib import Path from typing import TYPE_CHECKING, Any, cast import structlog from gflow_cli.api import routes from gflow_cli.api._retry import parse_retry_after from gflow_cli.api.transports._common import ( close_menu, count_visible, expired_link_hint, extract_project_id, flow_host_kind, generation_error, get_signed_media, migrated_route, offered_menu_labels, raise_if_known_landing, raise_if_migrated, recoverable_clip_hint, ) from gflow_cli.api.transports.drivers.factory import AGENTIC_INDICATOR_SELECTORS from gflow_cli.api.video import ( I2V_DEFAULT_MODEL, Aspect, GenerateVideoRequest, Mode, VideoModel, VideoResult, VideoStarted, VideoStartedCallback, VideoStatus, media_name_from_generate_response, operation_name_from_generate_response, parse_video_status, workflow_id_from_generate_response, ) from gflow_cli.errors import ( AuthExpiredError, ConfigurationError, FlowAgentUiError, FlowAppError, FlowHostMigratedError, MediaDownloadError, MediaUploadRejectedError, RateLimitError, ReferenceNotFoundError, TransportTimeoutError, UiSelectorDriftError, VideoModelSelectionError, WafRejectionError, WireFormatError, ) from gflow_cli.file_integrity import matches_recorded_file from gflow_cli.storage import AnyPath, storage_path, write_asset_async if TYPE_CHECKING: from playwright.async_api import Locator, Page log = structlog.get_logger(__name__) # Type aliases for JSON-shaped ``cast(...)`` targets. Extracted so the quoted # cast strings are not duplicated (SonarCloud S1192); module-level aliases keep # ruff's TC006 happy since call sites pass a bare name, not a subscript. _JsonObj = dict[str, Any] _JsonObjList = list[dict[str, Any]] # The three mode-specific generate routes (spec §2.1). The listener filters on # these substrings only — video generate URLs carry no /projects/{id}/ path # segment, so a project-id URL filter is impossible (deviation from §5.4). VIDEO_GENERATE_ROUTES = ( "batchAsyncGenerateVideoText", "batchAsyncGenerateVideoStartImage", "batchAsyncGenerateVideoStartAndEndImage", "batchAsyncGenerateVideoReferenceImages", ) # The pure text-to-video route. An i2v request that lands here had its frame # refs silently dropped (issue #125) — used by the Layer-2 post-submit backstop. _T2V_GENERATE_ROUTE = "batchAsyncGenerateVideoText" # The first+last interpolation route. A request carrying an end frame that lands # anywhere else had that frame dropped at submit (#626) — same backstop. _START_AND_END_GENERATE_ROUTE = "batchAsyncGenerateVideoStartAndEndImage" # Substring, not a path glob: the real endpoints carry a namespaced final segment # (`.../flowMedia:batchGenerateImages`), which `**/batchGenerateImages` can never # match. The VIDEO endpoint is namespaced too (`.../video:batchAsyncGenerateVideoText`), # so `**/batchAsyncGenerateVideo*` was equally dead — #615 names only the image one. # See `_intercept_reference_entities` for why it is registered on the context. _GENERATION_ROUTE_RE = re.compile(r"batchGenerateImages|batchAsyncGenerateVideo") # Status-poll route — Flow's SPA polls this itself while a generation runs. VIDEO_STATUS_ROUTE = "batchCheckAsyncVideoGenerationStatus" # Mode switching is a 2-step dropdown (spec §6, §10.5). The trigger is the # unified generation-settings button — the only button[aria-haspopup='menu'] # carrying an aspect-ratio crop_* icon; clicking it opens a role='menu' with # the Imagem/Vídeo role='tablist' (the tabs are not in the DOM until it opens). # Default per-selector wait in `_probe_selector_cascade`, read at call time (not # bound as a default argument) so the hermetic e2e can shorten it. _PROBE_TIMEOUT_MS = 4000 MODE_SWITCH_TRIGGER_SELECTORS = ( "button[aria-haspopup='menu']:has(i.google-symbols:text('crop_16_9'))", "button[aria-haspopup='menu']:has(i.google-symbols:text('crop_9_16'))", "button[aria-haspopup='menu']:has(i.google-symbols:text('crop_square'))", "button[aria-haspopup='menu']:has(i.google-symbols:text('crop_portrait'))", "button[aria-haspopup='menu']:has(i.google-symbols:text('crop_landscape'))", "button[aria-haspopup='menu']:has(i.google-symbols:text('crop_original'))", ) # SOT (flow-editor-map.json): the VIDEO mode tab id ends with '-trigger-VIDEO' # (radix prefix is dynamic — match the suffix). aria-controls ends with # '-content-VIDEO'. Both are EXACT (ends-with), so they do NOT match the # sub-mode tabs '-trigger-VIDEO_FRAMES' / '-trigger-VIDEO_REFERENCES'. Icon + # id-suffix are locale-independent; the localized-text fallbacks come last. VIDEO_TAB_IN_MENU_SELECTORS = ( "[role='tab'][id$='-trigger-VIDEO']", "[role='tab'][aria-controls$='-content-VIDEO']", "[role='menu'] [role='tab']:has(i:text('play_circle'))", "[role='menu'] [role='tab']:has-text('Vídeo')", "[role='menu'] [role='tab']:has-text('Video')", ) # Composer "Agent" mode toggle. Flow's newer editor puts a pill toggle next to # the prompt box: a ``