diff --git a/.gitignore b/.gitignore index c09426eb..72a6a098 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,8 @@ vendor/ downloads/ prepared/ kernel-cache/ +# Dedicated compiler scratch files, also under engine in portable installations. +tmp/ wheels/ machine.json prepared-cache.json @@ -61,3 +63,4 @@ engine-gpu-*.lock # Optional local prompt models, runtime binaries and private rewrite caches. /optional/ *.gguf +/out/ diff --git a/freevideo.ps1 b/freevideo.ps1 index ecaa21f5..f112fbf4 100644 --- a/freevideo.ps1 +++ b/freevideo.ps1 @@ -92,8 +92,6 @@ try { -EngineRoot $freevideoRoot -LogPath $bootstrapLog -GithubSources $routes ` -ProxyRoutes $proxyRoutes -OfficialOnly:$officialOnly $env:UV_PYTHON_INSTALL_DIR = Join-Path $bootstrapRoot 'python' - $env:UV_HTTP_TIMEOUT = '20' - $env:UV_HTTP_RETRIES = '1' $env:UV_NO_CONFIG = '1' $installed = $false $pythonRoutes = @($routes | ForEach-Object { "$_/astral-sh/python-build-standalone/releases/download" }) @@ -101,18 +99,40 @@ try { $pythonRoutes = @($freevideoSavedEnvironment['UV_PYTHON_INSTALL_MIRROR']) + $pythonRoutes } $attempt = 0 - foreach ($route in $pythonRoutes) { - foreach ($proxyRoute in $proxyRoutes) { - if ($installed) { break } - $attempt++ - $env:UV_PYTHON_INSTALL_MIRROR = $route - Write-Progress -Activity 'FreeVideo bootstrap' -Status ('Install isolated Python ' + $versions.python) - $pythonExit = Invoke-FreeVideoBootstrapCommand -Executable $uv ` - -Arguments @('python', 'install', '--no-bin', '--no-registry', '--no-progress', '--color', 'never', $versions.python) ` - -LogPath $bootstrapLog -Label ("Install Python $($versions.python), attempt $attempt ($proxyRoute connection)") ` - -Direct:($proxyRoute -eq 'direct') - if ($pythonExit -eq 0) { $installed = $true; break } - Write-Host "Python install source $attempt returned exit code $pythonExit. Details retained in $bootstrapLog" + $allNetworkFailures = $true + # Keep these defaults and the notice in sync with network.py (CPU tests). + $pythonRetryProfiles = @( + @{Mode='fast'; UV_HTTP_TIMEOUT='20'; UV_HTTP_RETRIES='1'}, + @{Mode='patient'; UV_HTTP_TIMEOUT='120'; UV_HTTP_RETRIES='5'} + ) + foreach ($profile in $pythonRetryProfiles) { + if ($installed) { break } + if ($profile.Mode -eq 'patient') { + if (-not $allNetworkFailures) { break } + Write-Host 'The network is slow. Downloading again; this may take a while.' + } + foreach ($name in @('UV_HTTP_TIMEOUT', 'UV_HTTP_RETRIES')) { + $value = $freevideoSavedEnvironment[$name] + if ($null -eq $value) { $value = $profile[$name] } + [Environment]::SetEnvironmentVariable($name, $value, 'Process') + } + foreach ($route in $pythonRoutes) { + foreach ($proxyRoute in $proxyRoutes) { + if ($installed) { break } + $attempt++ + $env:UV_PYTHON_INSTALL_MIRROR = $route + Write-Progress -Activity 'FreeVideo bootstrap' -Status ('Install isolated Python ' + $versions.python) + $pythonOutput = '' + $pythonLabel = "Install Python $($versions.python), attempt $attempt ($proxyRoute connection)" + if ($profile.Mode -eq 'patient') { $pythonLabel += ", $($profile.Mode)" } + $pythonExit = Invoke-FreeVideoBootstrapCommand -Executable $uv ` + -Arguments @('python', 'install', '--no-bin', '--no-registry', '--no-progress', '--color', 'never', $versions.python) ` + -LogPath $bootstrapLog -Label $pythonLabel ` + -Direct:($proxyRoute -eq 'direct') -OutputTail ([ref]$pythonOutput) + if ($pythonExit -eq 0) { $installed = $true; break } + if (-not (Test-FreeVideoSlowDownloadFailure $pythonOutput)) { $allNetworkFailures = $false } + Write-Host "Python install source $attempt returned exit code $pythonExit. Details retained in $bootstrapLog" + } } } if (-not $installed) { throw "Python installation failed (uv exit $pythonExit). Check your network or connection mode in Downloads. See $bootstrapLog and retry." } @@ -123,6 +143,11 @@ try { Write-Progress -Activity 'FreeVideo bootstrap' -Completed } } + # Restore only our download defaults before Python determines user overrides. + # A null saved value removes the variable from the child environment. + foreach ($name in @('UV_HTTP_TIMEOUT', 'UV_HTTP_RETRIES')) { + [Environment]::SetEnvironmentVariable($name, $freevideoSavedEnvironment[$name], 'Process') + } & $freevideoPython @freevideoPythonPrefix -B -X utf8 -m freevideo_engine.managed @freevideoArguments $freevideoExit = $LASTEXITCODE } catch { diff --git a/freevideo_engine/__init__.py b/freevideo_engine/__init__.py index 2beccf77..1d6a451c 100644 --- a/freevideo_engine/__init__.py +++ b/freevideo_engine/__init__.py @@ -15,5 +15,7 @@ _build_version = _io_path(_build_version) from .long_paths import start_process as _start_process _start_process() + from .triton_compat import own_temporary_environment as _temporary_environment + _temporary_environment() if _build_version.is_file(): __version__ = _build_version.read_text(encoding='utf-8').strip() diff --git a/freevideo_engine/access_proxy.py b/freevideo_engine/access_proxy.py index 4f70e422..c382951a 100644 --- a/freevideo_engine/access_proxy.py +++ b/freevideo_engine/access_proxy.py @@ -1,6 +1,6 @@ """Token-protected access to a ComfyUI that listens on this computer only. -`freevideo server --listen ADDRESS` runs this in front of ComfyUI. The access +The launcher's LAN switch or `freevideo server --listen ADDRESS` runs this in front of ComfyUI. The access link carries the token once; the page then keeps it in a cookie, and API clients send it as a bearer token. Everything else gets 401, so ComfyUI is never reachable from other computers without it, and not at all when this @@ -27,9 +27,9 @@ FreeVideo

-This FreeVideo server opens from its access link

To show the link, run -freevideo status on the server.

请使用访问链接打开这台 FreeVideo 服务器

-

在服务器上运行 freevideo status 即可查看链接。

""" +This FreeVideo server opens from its access link

Copy the link in the launcher under Settings → Preferences → Local network. On a Linux server, run +freevideo status to see it.

请使用访问链接打开这台 FreeVideo 服务器

+

请在启动器的“设置 → 偏好 → 局域网”中复制链接。Linux 服务器上运行 freevideo status 可以看到链接。

""" def matches(value, token): diff --git a/freevideo_engine/adaln_reclaim.py b/freevideo_engine/adaln_reclaim.py new file mode 100644 index 00000000..b95112aa --- /dev/null +++ b/freevideo_engine/adaln_reclaim.py @@ -0,0 +1,437 @@ +"""Reclaim the 26 GB of optional AdaLN projection sources that tables made unused. + +Before 0.4.0 a slim installation that ran a sampling schedule without a +published table fetched the 50 original projection files (adaln/00..49) and +computed its own tables from them, on the step grid of that version. A newer +engine uses a different grid on some platforms, so those tables are never read +again and the sources only occupy disk. They can be fetched again on demand +(restore_projections), so removing them is recoverable. Everything below must +hold, otherwise nothing is deleted: + +1. the installation is a slim export. In a full installation the files are part + of the model itself; +2. the 50 files are exactly the ones the manifest lists: same names, sizes and + SHA-256, regular, unshared, reached without links or junctions; +3. at least one complete table of this model, computed on the old step grid, + is in the cache (its timesteps are the old grid and not the engine grid); +4. no LoRA variant that changes modulation sits beside the cache. Those hardlink + or copy the projection groups. An unreadable variant counts as one. + +Nothing is removed on the engine's own initiative. The background precompile +only checks the conditions and leaves a small offer file; the launcher asks the +user, and the removal runs only when the user agrees. A "later" answer is +recorded against the release line (0.4, 0.5, ...) and the offer comes back with +the next one. + +The old-grid tables go after the sources, under the same conditions, one file +at a time and only when the folder holds nothing but that table's files. A +removal that was interrupted resumes where it stopped. Hashing 26 GB is read +only and may be interrupted at any file; it resumes from a receipt of the files +already verified. The removal itself runs under the GPU-work lock, never during +a generation. Nothing here fails a request. +""" +import json +import os +from pathlib import Path +import re +import stat +import time + +from . import adaln_assets as assets +from .long_paths import io_path +from .variant_cleanup import inside, redirected + +RECEIPT = 'adaln-reclaim.json' +OFFER = 'adaln-reclaim-offer.json' +SOURCES = 'adaln' +COUNT = 50 +SOURCE_NAME = re.compile(r'adaln/(\d{2})\.safetensors') +VARIANT = re.compile(r'vdn-fp8-lora-[0-9a-f]{24}') +TABLE_FOLDER = re.compile(r'adaln-tables-v2-[0-9a-f]{24}') +TABLE_FILE = re.compile(r'identity\.json|producer\.json|(\d{2})\.(safetensors|json)') +IDENTITY_LIMIT = 65536 +BLOCK = 8 * 1024 * 1024 + + +class Stopped(Exception): + """The caller asked to give way; nothing was deleted.""" + + +def _json(path, limit=IDENTITY_LIMIT): + path = io_path(path) + info = path.lstat() + if redirected(info) or not stat.S_ISREG(info.st_mode) or info.st_size > limit: + raise ValueError('Not a small regular file') + return json.loads(path.read_text(encoding='utf-8')) + + +def _regular(path): + try: + info = io_path(path).lstat() + except OSError: + return None + return None if redirected(info) or not stat.S_ISREG(info.st_mode) else info + + +def _contained(cache, path): + """True when path, fully resolved, lies strictly inside this installation's own resolved cache.""" + try: + root, real = (os.path.realpath(io_path(item)) for item in (cache, path)) + return real != root and os.path.commonpath([root, real]) == root + except (OSError, ValueError): + return False + + +def engine_schedules(steps): + """(task, old grid rows, engine grid rows) for every task of one step count.""" + from .adaln import schedule_timesteps + from .media_request import TASKS + rows = [] + for task in TASKS: + old, new = (schedule_timesteps(steps, device='cpu', task=task, upstream_grid=grid) for grid in (True, False)) + rows.append((task, [t.float().tolist() for t in old], [t.float().tolist() for t in new])) + return rows + + +def _recording(schedules, consulted): + """`schedules` that also keeps what it answered, for the offer: the launcher has no torch to ask again.""" + def answer(steps): + rows = schedules(steps) + consulted[str(steps)] = [[task, old, new] for task, old, new in rows] + return rows + return answer + + +def _offered_schedules(cache): + """The grids recorded with the standing offer when they were computed by this very engine release, else None.""" + value = offer(cache) + release, stored = value.get('engine_release'), value.get('schedules') + if not isinstance(release, str) or release != installed_release() or not isinstance(stored, dict): + return None + def answer(steps): + rows = stored.get(str(steps)) + if not isinstance(rows, list) or not rows: + raise KeyError(steps) + return [(task, old, new) for task, old, new in rows] + return answer + + +def old_grid_tables(cache, manifest, schedules=None): + """Folders of this model's tables that sit on the old step grid, as (folder, complete).""" + schedules = schedules or engine_schedules + weights = assets.weight_identity(manifest) + count = len(assets.projection_groups(manifest)) + found, memo = [], {} + for folder in sorted(io_path(cache).glob('adaln-tables-v2-*')): + if not TABLE_FOLDER.fullmatch(folder.name) or not inside(cache, folder) or not folder.is_dir(): + continue + try: + value = _json(folder / 'identity.json') + steps = len(value['timesteps']) + if value.get('weights') != weights or assets.directory(value) != folder.name: + continue + if steps not in memo: + memo[steps] = schedules(steps) + if any(value['timesteps'] == new for _, _, new in memo[steps]): + continue # A table the engine reads: not ours to remove. + if not any(value['timesteps'] == old != new for _, old, new in memo[steps]): + continue + complete = all(_regular(folder / ('%02d.safetensors' % i)) is not None + and _regular(folder / ('%02d.json' % i)) is not None for i in range(count)) + except (OSError, ValueError, KeyError, TypeError): + continue + found.append((folder, complete)) + return found + + +def modulation_variants(cache): + """True when a LoRA variant beside the cache may hold or share the projection groups.""" + parent = io_path(cache).parent + try: + entries = list(parent.iterdir()) + except OSError: + return True + for entry in entries: + if not VARIANT.fullmatch(entry.name): + continue + try: + if (entry / SOURCES).exists() or (entry / SOURCES).is_symlink(): + return True + manifest = _json(entry / 'manifest.json', 64 * 1024 * 1024) + if any(str(row.get('group', '')).startswith('adaln/') for row in manifest.get('groups', [])): + return True + except (OSError, ValueError, TypeError, AttributeError): + return True # Cannot tell: keep. + return False + + +def release_line(version): + """'0.4' for '0.4.0' (None when the version is not one): a decline holds for this line.""" + match = re.match(r'(\d{1,4})\.(\d{1,4})(?:\.|$)', version) if isinstance(version, str) else None + return '%s.%s' % match.groups() if match else None + + +def installed_release(): + """The product version this engine was released as, or None.""" + try: + value = _json(Path(__file__).with_name('release_notes.json'), 64 * 1024 * 1024) + except (OSError, ValueError): + return None + return value.get('product_version') if isinstance(value, dict) else None + + +def offer(cache): + """The standing offer {'bytes', 'manifest', 'declined'} or {}; small regular file only.""" + try: + value = _json(Path(cache) / OFFER) + except (OSError, ValueError): + return {} + return value if isinstance(value, dict) else {} + + +def _write_offer(cache, **fields): + from .monitoring import save + save(Path(cache) / OFFER, dict(format=1, **fields)) + + +def decline(cache, version): + """Record "later" for this release line; the offer stays visible in the file, not in the dialog.""" + current = {key: value for key, value in offer(cache).items() if key != 'format'} + current.update(bytes=current.get('bytes', 0), manifest=current.get('manifest'), declined=release_line(version)) + _write_offer(cache, **current) + + +def _tables_size(folders): + total = 0 + for folder, _ in folders: + try: + total += sum(entry.lstat().st_size for entry in io_path(folder).iterdir() if _regular(entry) is not None) + except OSError: + continue + return total + + +def _source_paths(cache, manifest): + rows = assets.projection_groups(manifest) + names = sorted(row.get('file') for row in rows) + if names != ['adaln/%02d.safetensors' % i for i in range(COUNT)]: + return None + return [(row, io_path(cache) / row['file']) for row in sorted(rows, key=lambda r: r['file'])] + + +def _hash(path, should_stop): + import hashlib + digest = hashlib.sha256() + with io_path(path).open('rb') as stream: + for block in iter(lambda: stream.read(BLOCK), b''): + if should_stop(): + raise Stopped() + digest.update(block) + return digest.hexdigest() + + +def _receipt(cache): + try: + value = _json(Path(cache) / RECEIPT) + return value if isinstance(value, dict) else {} + except (OSError, ValueError): + return {} + + +def _save(cache, value): + from .monitoring import save + save(Path(cache) / RECEIPT, value) + + +def verify_sources(cache, manifest, should_stop=lambda: False): + """None when the 50 files are exactly the manifest's, otherwise the reason.""" + from .storage import fingerprint + listed = _source_paths(cache, manifest) + if listed is None: + return 'unexpected_manifest' + receipt, stamps = _receipt(cache), {} + previous = receipt.get('verified', {}) if receipt.get('manifest') == assets.weight_identity(manifest) else {} + present = [path for _, path in listed if _regular(path) is not None] + if not present: + return 'absent' + if len(present) != len(listed) and not receipt.get('deleting'): + return 'partial' + if not inside(cache, io_path(cache) / SOURCES) or not _contained(cache, io_path(cache) / SOURCES): + return 'redirected' + if any(not _contained(cache, path) for _, path in listed if _regular(path) is not None): + return 'redirected' + for row, path in listed: + info = _regular(path) + if info is None: + continue # Removed by an interrupted run of this cleanup. + if info.st_size != row['bytes'] or info.st_nlink != 1: + return 'changed' if info.st_size != row['bytes'] else 'shared' + stamps[row['file']] = dict(fingerprint(path), sha256=row['sha256']) + verified = {} + for row, path in listed: + if _regular(path) is None: + continue + stamp = stamps[row['file']] + saved = previous.get(row['file']) + if saved is not None and {k: v for k, v in saved.items()} == stamp: + verified[row['file']] = saved + continue + if should_stop(): + raise Stopped() + if _hash(path, should_stop) != row['sha256']: + return 'changed' + verified[row['file']] = stamp + _save(cache, dict(manifest=assets.weight_identity(manifest), verified=verified, + deleting=bool(receipt.get('deleting')))) + return None + + +def _remove_tables(cache, folders): + removed = 0 + for folder, _ in folders: + if not _contained(cache, folder): + continue + files = [entry for entry in io_path(folder).iterdir()] + if any(_regular(entry) is None or not TABLE_FILE.fullmatch(entry.name) or not _contained(cache, entry) + for entry in files): + continue # Anything else in it keeps the whole folder. + size = 0 + for entry in sorted(files, key=lambda p: p.suffix == '.json'): # Receipts last. + size += entry.lstat().st_size + entry.unlink() + folder.rmdir() + removed += size + return removed + + +def reclaim(cache, *, should_stop=lambda: False, schedules=None, lock=None, ask=False, release=None): + """Remove the sources and the old-grid tables when every condition holds. + + Returns {action: 'deleted'|'offered'|'kept', reason, bytes, tables_bytes}. `lock` + is a context-manager factory held while deleting (the GPU-work lock). With + `ask` nothing is deleted: the conditions are checked, the offer file is + written and the result is 'offered'. `release` is the installed version, + which a recorded "later" is compared against. + """ + cache = Path(cache) + kept = lambda reason: dict(action='kept', reason=reason, bytes=0, tables_bytes=0) + try: + manifest = _json(cache / 'manifest.json', 64 * 1024 * 1024) + except (OSError, ValueError): + return kept('no_manifest') + if manifest.get('format') not in assets.SLIM_FORMATS: + return kept('full_install') # The files are part of the model. + if ask and release_line(release) is not None and offer(cache).get('declined') == release_line(release): + return kept('declined') + try: + listed = _source_paths(cache, manifest) + if listed is None: + return kept('unexpected_manifest') + if modulation_variants(cache): + return kept('modulation_lora') + consulted = {} + tables = old_grid_tables(cache, manifest, _recording(schedules or engine_schedules, consulted)) + if not any(complete for _, complete in tables): + return kept('no_old_grid_table') + reason = verify_sources(cache, manifest, should_stop) + except Stopped: + return kept('stopped') + if reason not in (None, 'absent'): + if ask: + _forget_offer(cache) + return kept(reason) + if ask: + present = sum(row['bytes'] for row, path in listed if _regular(path) is not None) + tables_bytes = _tables_size(tables) + _write_offer(cache, bytes=present, tables_bytes=tables_bytes, manifest=assets.weight_identity(manifest), + declined=offer(cache).get('declined'), engine_release=release, schedules=consulted) + return dict(action='offered', reason='ask', bytes=present, tables_bytes=tables_bytes) + freed = 0 + try: + with (lock() if lock is not None else _nothing()): + # Nothing may have changed while hashing: decide again under the lock. + try: + if should_stop(): + return kept('stopped') + if modulation_variants(cache): + return kept('modulation_lora') + if reason is None: + again = verify_sources(cache, manifest, should_stop) + if again not in (None, 'absent'): + return kept(again) + except Stopped: + return kept('stopped') + if reason is None: + _save(cache, dict(_receipt(cache), manifest=assets.weight_identity(manifest), deleting=True)) + for row, path in listed: + info = _regular(path) + if info is None: + continue + if info.st_size != row['bytes'] or info.st_nlink != 1: + return kept('changed') + if not _contained(cache, path): + return kept('redirected') + freed += info.st_size + io_path(path).unlink() + try: + (io_path(cache) / SOURCES).rmdir() + except OSError: + pass # Other content stays. + tables_bytes = _remove_tables(cache, old_grid_tables(cache, manifest, schedules)) + io_path(Path(cache) / RECEIPT).unlink(missing_ok=True) + _forget_offer(cache) + except BlockingIOError: + return kept('busy') + return dict(action='deleted', reason='reclaimed', bytes=freed, tables_bytes=tables_bytes) + + +def _forget_offer(cache): + try: + io_path(Path(cache) / OFFER).unlink(missing_ok=True) + except OSError: + pass + + +class _nothing: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + +def apply(machine, *, should_stop=lambda: False, schedules=None): + """The user agreed: check everything again under the installation's GPU-work lock and delete. Never raises. + + The launcher has no torch, so which grids are old comes from the offer the engine wrote; an offer + from another engine release, or without them, is dropped and made again by the next background check. + """ + from .locking import runtime_lock + cache = machine['cache'] + path = Path(machine['root']) / 'engine.lock' if machine.get('root') else None + schedules = schedules or _offered_schedules(cache) + if schedules is None: + _forget_offer(cache) + return dict(action='kept', reason='stale_offer', bytes=0, tables_bytes=0) + try: + result = reclaim(cache, should_stop=should_stop, schedules=schedules, + lock=lambda: runtime_lock(path, inherit=False)) + except Exception as error: + return dict(action='kept', reason='error:' + type(error).__name__, bytes=0, tables_bytes=0) + if result['action'] != 'deleted' and result['reason'] not in ('busy', 'stopped'): + _forget_offer(cache) # The conditions no longer hold: there is nothing to offer. + return result + + +def run(machine, *, should_stop=lambda: False): + """Entry point of the background precompile: check and leave the offer, report one event, never raise.""" + started = time.time() + try: + result = reclaim(machine['cache'], should_stop=should_stop, ask=True, release=installed_release()) + except Exception as error: # Best effort: the installation works either way. + result = dict(action='kept', reason='error:' + type(error).__name__, bytes=0, tables_bytes=0) + if result['action'] == 'kept' and result['reason'] not in ('declined', 'stopped', 'busy') \ + and not result['reason'].startswith('error:'): + _forget_offer(machine['cache']) # An offer is only standing while every condition holds. + result['seconds'] = round(time.time() - started, 1) + return result diff --git a/freevideo_engine/agent/client.py b/freevideo_engine/agent/client.py index ca61ef39..aae09919 100644 --- a/freevideo_engine/agent/client.py +++ b/freevideo_engine/agent/client.py @@ -77,6 +77,11 @@ def origin(url): return value.scheme, value.hostname.lower(), value.port or (443 if value.scheme == 'https' else 80) +def one_job(answer): + """The job of a single-job answer, without the copy the server adds under 'job'.""" + return {key: value for key, value in answer.items() if key != 'job'} + + class LocalProxyHandler(urllib.request.ProxyHandler): def proxy_open(self, req, proxy, type): host = urllib.parse.urlsplit(req.full_url).hostname @@ -246,8 +251,8 @@ def batch(self, jobs, *, ref=None): def job(self, identity, wait=0): wait = max(0, min(25, wait)) - return self.request('GET', '/freevideo/agent/v1/jobs/' + identity, query={'wait': wait} if wait else None, - timeout=max(self.timeout, wait + 5)) + return one_job(self.request('GET', '/freevideo/agent/v1/jobs/' + identity, query={'wait': wait} if wait else None, + timeout=max(self.timeout, wait + 5))) def jobs(self, status=None, project=None, kind=None, limit=None): return self.get('/jobs', status=status, project=project, kind=kind, limit=limit)['jobs'] @@ -264,10 +269,10 @@ def wait(self, identity, timeout=None, on_progress=None): return view def cancel(self, identity): - return self.post('/jobs/%s/cancel' % identity, {}) + return one_job(self.post('/jobs/%s/cancel' % identity, {})) def resume(self, identity, *, ref=None): - return self.post('/jobs/%s/resume' % identity, {'ref': ref} if ref else {}) + return one_job(self.post('/jobs/%s/resume' % identity, {'ref': ref} if ref else {})) def asset(self, identity, target, *, thumbnail=False): """Retrieve a library file through authenticated HTTP only.""" diff --git a/freevideo_engine/agent/comfy.py b/freevideo_engine/agent/comfy.py index 3b2ff7d3..3fafea3e 100644 --- a/freevideo_engine/agent/comfy.py +++ b/freevideo_engine/agent/comfy.py @@ -169,6 +169,10 @@ def editor_exports(self): """The editor page's export queue (editor_export.register), shared so exports take turns.""" return getattr(self.server, '_freevideo_editor_export', None) + def editor_ears(self): + """The auto-subtitle ears queue (subtitle_ears.register): time lines against the sound or write down speech.""" + return getattr(self.server, '_freevideo_subtitle_ears', None) + def queue_busy(self): running, pending = self.snapshot() return bool(running or pending) @@ -188,7 +192,10 @@ def progress(self, node): from ..comfy_progress import STATE with STATE.lock: row = STATE.rows.get(str(node)) - return json.loads(json.dumps(row['message'])) if row else None + if not row: + return None + # How old the message is, as a reopened page gets it, so a bar can go on from where the engine is now. + return dict(json.loads(json.dumps(row['message'])), age_seconds=max(0., STATE.clock() - row['measured'])) async def submit(self, graph, extra): if self.handler is None: diff --git a/freevideo_engine/agent/editor_runtime.py b/freevideo_engine/agent/editor_runtime.py index bdb703d7..6af6b6f5 100644 --- a/freevideo_engine/agent/editor_runtime.py +++ b/freevideo_engine/agent/editor_runtime.py @@ -103,7 +103,7 @@ // before; the first rejection stops the batch and nothing is returned to save. globalThis.__batch = text => { const {project, operations} = JSON.parse(text), model = __load('editor_model'), actions = __load('editor_actions'); - const by = {by: 'agent'}, results = []; + const texts = __load('editor_text_model'), by = {by: 'agent'}, results = []; const ids = record => new Set(record.tracks.flatMap(track => track.clips.map(clip => clip.id))); let record; try { record = model.parse(project); } @@ -138,6 +138,8 @@ case 'describe': record = model.describe(record, actions.linkedIds(record, op.clip_ids), op.text, by); break; case 'set_ref': record = model.setRef(record, actions.linkedIds(record, op.clip_ids), op.ref); break; case 'use_version': record = model.restoreVersion(record, op.clip_id, op.index, by); break; + // One undo on the page; rejects as subtitles / range / text / locked / overlap. + case 'subtitles': record = texts.addSubtitles(record, op.cues, {replace: op.replace === true}); break; default: throw new model.EditError('invalid', 'unknown operation'); } } catch (error) { diff --git a/freevideo_engine/agent/errors.py b/freevideo_engine/agent/errors.py index bbfad5e2..b5b3fdbc 100644 --- a/freevideo_engine/agent/errors.py +++ b/freevideo_engine/agent/errors.py @@ -106,6 +106,10 @@ 'image_changed': (409, False, 'The image changed since you read it.', 'Apply your operations to error.image and send its revision.'), 'layer_locked': (409, False, 'An operation changes a layer the user locked.', 'Leave the locked layer unchanged; ask the user to unlock it if it must change.'), 'export_failed': (409, True, 'The export did not finish.', 'Read the project, then submit the export again with a new ref.'), + 'no_speech_model': (409, False, 'No speech model is installed on this machine.', + 'Do not retry. Write the subtitle lines yourself with the subtitles edit, or tell the user the model is not installed.'), + 'listen_failed': (409, True, 'Listening to the sound did not finish.', + 'Read the project, then submit the listening job again with a new ref; if it fails again, write the lines yourself.'), 'clip_source_unusable': (409, False, 'A clip\'s source is missing, or cannot be read as far as the clip uses it.', 'Read the project and check each clip\'s source: it must exist and play, and in + frames must stay ' 'within its real length. Correct the project, then export the new revision.'), diff --git a/freevideo_engine/agent/guide.py b/freevideo_engine/agent/guide.py index de47497b..85d2feb6 100644 --- a/freevideo_engine/agent/guide.py +++ b/freevideo_engine/agent/guide.py @@ -79,8 +79,9 @@ 'agent_jobs': [ 'GET /capabilities reads the studio cache with the same size/task/material query parameters. ' 'status=computing includes retry_after_seconds; unknown plan estimates have seconds=null.', - 'GET /jobs/{id}?wait=25 waits at most 25 seconds and returns poll_after_ms. Poll the same ID; ' - 'disconnecting a wait or SSE does not cancel generation.', + 'GET /jobs/{id}?wait=25 waits at most 25 seconds and returns poll_after_ms. Like POST /jobs, the answer ' + 'has the job under job (its fields are also at the top level). Poll the same ID until job.status is ' + 'succeeded, failed, cancelled or interrupted; disconnecting a wait or SSE does not cancel generation.', 'Jobs wait behind an active studio chain. GET /queue gives Agent/UI ownership and counts ahead, ' 'without studio prompts. POST /jobs/{id}/cancel cancels only this job.', 'POST /jobs/{id}/resume continues failed, interrupted or cancelled generation; successful blocks stay. ' @@ -93,6 +94,7 @@ 'POST /projects/{id}/operations sends revision and 1-64 operations, using whole frames at 24 fps. The batch saves once or changes nothing.', 'On 409 project_changed, recompute edits from error.project and its current_revision, then send a new request. On clip_busy wait for generation to finish.', 'POST /projects/{id}/generate reserves pending parts for clip_ids or a start/frames range and fills them as the returned job finishes. POST /projects/{id}/export renders the revision you read; wait for its job.', + 'Subtitles: POST /projects/{id}/listen with mode transcribe (writes down what is said) or align (times the lines you give) and the revision you read; wait for the job and read result.lines, each with start and end frames. Then add them with a subtitles operation of cues {start, frames, text}; replace true swaps the whole track. With no speech model the job fails with no_speech_model: write the lines yourself.', 'Reuse ref or Idempotency-Key only for the identical request. Source video/audio/thumbnail URLs require the same Agent token.', ], 'project_routes': [dict(operation=name, method=method, path=path, description=summary) @@ -149,7 +151,8 @@ RULES = [ 'Finish the job. When the user asks for a video, keep going until the finished video exists. Decide ordinary ' 'things yourself: how to split shots, how many takes, which take to keep. Tell the user what you decided in ' - 'one or two sentences at the end, not along the way.', + 'one or two sentences at the end, not along the way. Name every video you submit: label is a short name of what ' + 'it shows, in the user\'s language (2 to 8 Chinese characters, or 1 to 4 words), for example "Seaside at dawn".', 'Solve problems yourself first. Every error has a next field, and often details with alternatives; these are ' 'steps that work on this computer. Not enough graphics memory: use a listed alternative (a lower resolution ' 'or a shorter video) and try again. A take is not good: make another; if it is still not good after two more, ' @@ -157,7 +160,7 @@ 'job, never in a tight loop. The connection dropped or FreeVideo restarted: connect again, read the job list, ' 'and continue; resend an unanswered request with the same ref, and never generate again what is already done.', 'Do not stop to report progress. A long task is not a reason to stop or to ask whether to go on.', - 'When a video is done, tell the user it is in "Creations" in FreeVideo, in plain words. Do not read out job, library ' + 'When a video is done, tell the user it is in "Your creations" in FreeVideo, in plain words. Do not read out job, library ' 'or project ids; give a file path only when the user asks for one. If the length differs from what was asked, ' 'say the real length in seconds and do not explain frame counts.', 'Ask the user only when you would have to change the content or style they asked for, when the work will ' @@ -182,14 +185,15 @@ RULES_ZH = [ '把任务做完。用户要求生成视频时,持续推进,直到成片生成。如何拆分镜头、拍几条、选哪一条等普通决定,' - '由你自己做。最后用一两句话告诉用户你做了哪些决定,不要每做一步都请用户决定。', + '由你自己做。最后用一两句话告诉用户你做了哪些决定,不要每做一步都请用户决定。' + '提交的每条视频都起个名字:label 写拍的是什么,用用户的语言,2 到 8 个字,例如“清晨海边”。', '先自己解决问题。每个错误都有 next 字段,details 里也常有可选方案;这些是在本机可行的处理步骤。' '显存不足时,从列出的方案中选择较低分辨率或较短时长重试。一条拍得不好就再拍;再拍两条仍不满意,' '换一种描述,不要反复提交同一个要求。队列繁忙时,用 wait=25 等待该任务,不要不停查询。' '连接中断或 FreeVideo 重启后,重新连接,读取任务列表并继续;未收到响应的请求用相同的 ref 重发,' '已经完成的内容不要重新生成。', '不要为了汇报进度而停下。任务耗时较长,不是停止工作或询问是否继续的理由。', - '视频做好后,用通俗的话告诉用户它已经放进 FreeVideo 的“作品”里。不要念任务、作品或剪辑的编号;' + '视频做好后,用通俗的话告诉用户它已经放进 FreeVideo 的“我的作品”里。不要念任务、作品或剪辑的编号;' '文件路径在用户问起时再给。实际长度和要求的不一样时,只说实际约几秒,不解释帧数。', '只有需要改变用户要求的内容或风格、预计耗时明显超过开始时告诉用户的时间,' '或者需要删除或覆盖用户自己的作品或修改时,才询问用户。', @@ -230,7 +234,7 @@ ('GET', '/library/{day}/{run}/thumbnail'): '查看已保存的视频缩略图', ('GET', '/queue'): '查看队列顺序及任务属于 Agent 还是用户', ('GET', '/editor/sources/content'): '下载剪辑项目的源素材或缩略图;使用 GET /projects/{id} 返回的素材标识', - ('GET', '/inbox'): '等待当前工作会话中用户的消息或答复,最多 25 秒', + ('GET', '/inbox'): '等待当前工作会话中用户的消息或答复,最多 50 秒;again=true 表示暂时没有,再调一次', ('POST', '/drama/plan'): '预览多镜头影片的剧本和各镜头拍摄条数,不保存、不提交。' '多镜头影片可以逐镜选片;单条视频(最长 60 秒)用一个 generate 任务', ('POST', '/drama'): '确认计划后开始拍摄,进度会保存;查看各条画面并逐镜选片', @@ -262,6 +266,9 @@ '先读取 revision 和片段标识;所选部分会立即预留,返回的任务完成后填入结果', ('POST', '/projects/{id}/export'): '将保存的某个版本导出为 MP4,存入已保存视频。' '先读取 revision,等待返回的任务完成,再读取结果视频', + ('POST', '/projects/{id}/listen'): '听剪辑工程的声音,得到字幕行及其时间:对给定字幕行定时,或直接转写。' + '先读取 revision,等待返回的任务完成,再用 subtitles 编辑把字幕行加入时间线;' + '没有安装语音模型时返回 no_speech_model,改为自己写字幕行', ('POST', '/images'): '像图片编辑页面一样打开图片;提交 source,会话会保留源图片副本和图层文档', ('GET', '/images/{id}'): '读取图片编辑会话的图层文档、选区和 revision;编辑前先读取 revision 和图层标识', ('PUT', '/images/{id}'): '替换整个图片文档;先读取会话并提交其 revision。' @@ -353,10 +360,18 @@ def connection_guide(root, base, lang=None, offers=None): 'call("POST", "/connect/hello", {"client": "Local Agent", "via": "http"})', 'print(call("GET", ""))', '# Only after the user asks for a video:', - '# job = call("POST", "/jobs", {"prompt": "USER_REQUEST", "ref": "shot-1"})["job"]', - '# job = call("GET", "/jobs/" + job["id"] + "?wait=25")', - '# Poll the same ID until succeeded. Retry a lost submission using the same ref.', + '# label is the short name the user sees on the job, in their language: what the video shows, 2 to 8 characters.', + '# seconds and aspect are what the user asked for (10 seconds and 16:9 when they said nothing): send them the first time.', + '# ref is a new name for every new request, with the date and time in it; the same ref again only repeats a submission whose answer was lost.', + '# job = call("POST", "/jobs", {"prompt": "USER_REQUEST", "seconds": 5, "aspect": "16:9", "label": "SHORT NAME",', + '# "ref": "A-NEW-NAME-FOR-THIS-REQUEST"})["job"]', + '# while job["status"] not in ("succeeded", "failed", "cancelled", "interrupted"):', + '# job = call("GET", "/jobs/" + job["id"] + "?wait=25")["job"]', '# Download base origin + job["result"]["video"]["url"] with the same Authorization header.', + '# The conversation inside FreeVideo (see below): each wait is its own command; when it returns, run the next one.', + '# box = call("GET", "/inbox?wait=50") # {"session": ..., "inbox": [what the user said], "again": true if nothing yet}', + '# call("POST", "/sessions/" + box["session"] + "/events", {"type": "message", "text": "YOUR WORDS"})', + '# Post what you say first, then wait: never one command that waits and then posts, your words would be up to 50 s late.', ]) return dict(language=lang, api=base + PREFIX, token_file=token_file, claude_code=config['claude_code'], codex=config['codex'], mcp_json=config['mcp_json'], command=config['command'], @@ -380,6 +395,43 @@ def connection_guide(root, base, lang=None, offers=None): '"Introduce these", only those, and give two or three requests they can say. Do not quote version ' 'numbers or endpoint names. Do not start any task yourself; wait for the user to ask.', introduce=introduction(zh, offers or {}), + conversation='用户会在 FreeVideo 的“AI 全自动”页里直接和你说话,你的话也显示在那里。' + '连接后先调一次等待接口 GET ' + base + PREFIX + '/inbox?wait=50(MCP 用不带 id 的 freevideo_wait),' + '回应里的 session 就是这段对话;往 POST ' + base + PREFIX + '/sessions/{session}/events 发 ' + '{"type":"message","text":"…"}(MCP 用 freevideo_session_post)。在这里先只说一句话,不超过 20 个字,' + '不列条目、不举例(页面上已经有例句),例如“连上了。告诉我想做什么视频。”' + '你自己窗口里的那段介绍照常说,但它不是这一轮的结尾:说完紧接着去调等待接口。' + '然后守在这段对话里:再调等待接口,它最多约 50 秒返回;"again": true 表示用户还没说话,' + '马上再调下一次,一次接一次,不限次数。没有消息不是结束的理由:不要结束这一轮回复,' + '不要停下来问用户——你一停,用户在 FreeVideo 里说的话就没人听了。' + '用命令行调接口时,每次等待单独发一条命令(一条命令里不写循环,超时设到 60 秒以上),' + '返回后接着发下一条。' + '收到话(inbox 里每条的 text)先回一句再动手,例如“好的,开始生成。”:只一句,不复述尺寸、' + '时长这些参数,也不说预计时间(任务卡上都有)。做完再说一句短的,例如“好了。”,' + '或者一句用户需要知道的话,发回同一个 session,再接着等。先把话发出去,再调等待接口;不要写成“等待返回后才发话”,那样这句话会晚到将近一分钟。中文用全角标点。' + '做完一条视频也不是结尾:这句话就是汇报,不要再在自己窗口里写总结(一写总结这一轮就结束了,' + '用户的下一句话没人听),紧接着再调等待接口,空等多少轮都接着等。' + '这段对话里不要新建会话。只有用户明确让你停,才停。' if zh else + 'The user talks to you inside FreeVideo, in the Autopilot view, and your words are shown there. ' + 'After connecting, call the wait once: GET ' + base + PREFIX + '/inbox?wait=50 (MCP: freevideo_wait ' + 'without id). Its "session" is this conversation; POST ' + base + PREFIX + '/sessions/{session}/events ' + 'with {"type":"message","text":"..."} (MCP: freevideo_session_post). Say only one short sentence ' + 'there first, at most 12 words, no list and no examples (the page already shows examples), for example ' + '"Connected. Tell me what video you want.", in the language the user writes to you in. Give the introduction in your own window as usual, but it ' + 'does not end your turn: right after it, call the wait. Then stay in this conversation: call the wait again; it answers within about 50 seconds, and ' + '"again": true means the user has not spoken yet, so call it again at once, time after time, with no ' + 'limit. Nothing new is not a reason to finish: do not end your reply and do not stop to ask the ' + 'user, because once you stop nobody hears what the user says in FreeVideo. From a command line, ' + 'each wait is its own command (no loop inside one command, timeout above 60 seconds); when it ' + 'returns, run the next one. ' + 'When words arrive (text of each inbox row), answer with one sentence before you start, for example ' + '"OK, starting.": one sentence only, without repeating size or length and without an estimate (the ' + 'job card shows them). When it is done say one short sentence, for example "Done.", or one thing the ' + 'user needs to know, to the same session, and wait again. Post your words first and call the wait after; never wait and then post, or they reach the user up to a minute late. Chinese text takes full-width punctuation. ' + 'A finished video is not the end either: that sentence is the report, so write no summary in your own ' + 'window (a summary ends your turn, and then nobody hears the next thing the user says); call the wait ' + 'again right away, and keep waiting however many waits come back empty. Do not create another session for this conversation. ' + 'Stop only when the user clearly tells you to.', rules=RULES_ZH if zh else RULES, fallback='如果你无法执行命令、读取本地文件或连接到这台电脑,请如实告诉用户:' '“我连接不到你电脑上的 FreeVideo,请改用安装在这台电脑上的 AI 助手。”' @@ -404,6 +456,7 @@ def connection_text(value): *['- ' + row['description'] for row in value['capabilities']], '', '先介绍这几样:' if zh else 'Introduce these:', *['- ' + row for row in value['introduce']], '', value['first_reply'], value['fallback'], '', + '在 FreeVideo 里对话:' if zh else 'The conversation inside FreeVideo:', value['conversation'], '', '用户提出要求后的做事规则:' if zh else 'How to work once the user has asked for something:', *['%d. %s' % (number, rule) for number, rule in enumerate(value['rules'], 1)], '', ('详细使用指南:' if zh else 'More detail: ') + value['docs']['guide'], diff --git a/freevideo_engine/agent/jobs.py b/freevideo_engine/agent/jobs.py index 46819452..a7631a9c 100644 --- a/freevideo_engine/agent/jobs.py +++ b/freevideo_engine/agent/jobs.py @@ -395,10 +395,10 @@ def load(self): records = list(latest.values()) for row in records: if (isinstance(row, dict) and specs.JOB_ID.fullmatch(str(row.get('id'))) - and row.get('kind') in ('generate', 'assemble', 'export', 'image_edit') + GPU_KINDS + and row.get('kind') in ('generate', 'assemble', 'export', 'listen', 'image_edit') + GPU_KINDS and row.get('status') in ACTIVE + DONE and isinstance(row.get('spec'), dict) and type(row.get('created_at')) in (int, float) and math.isfinite(row['created_at'])): - if row.get('status') == 'running' and row['kind'] in ('assemble', 'export', 'image_edit') + GPU_KINDS: + if row.get('status') == 'running' and row['kind'] in ('assemble', 'export', 'listen', 'image_edit') + GPU_KINDS: # This process restarted while the work ran in it; nothing continues it. self.finish(row, 'failed', error=dict(code='interrupted', message='ComfyUI restarted while this job ran; submit it again'), save=False) @@ -967,6 +967,32 @@ def progress(self, row): return dict(phase=row.get('_phase') or ('running' if row['status'] == 'running' else 'queued'), **({'detail': row['_detail']} if row.get('_detail') else {})) + def estimate(self, row): + """The engine's own timing of the current phase, for a page that moves its bar between two messages + the way the creation panel does. Only keys the engine sent.""" + if row['status'] not in ('queued', 'running', 'cancelling'): + return None + try: + if row.get('chain_id'): + # A video in several parts has one bar over all of them; the engine times each part by itself. + chain = self.chain(row['chain_id']) + message = self.port.progress(chain['node']) or {} if chain['count'] == 1 and chain.get('node') else {} + elif row['kind'] == 'generate' and row.get('comfy'): + message = self.port.progress(row['comfy']['node']) or {} + else: + return None + except Exception: + return None + value = {key: message[key] for key in ('age_seconds', 'phase', 'done', 'total', 'display_fraction', + 'step_elapsed_seconds', 'estimated_step_seconds') if message.get(key) is not None} + overall = message.get('overall') if isinstance(message.get('overall'), dict) else {} + overall = {key: overall[key] for key in ('fraction', 'estimated', 'status', 'phase_start_fraction', 'phase_weight', + 'phase_seconds', 'phase_elapsed_seconds', 'sampling_time_weighted') + if overall.get(key) is not None} + if overall: + value['overall'] = overall + return value or None + # Scheduling ---------------------------------------------------------------------------------------- def active(self): return [r for r in self.rows() if r['status'] in ACTIVE] @@ -1094,6 +1120,9 @@ def run(): if row['kind'] == 'export': from .projects import run_export result = run_export(self.port, row) + elif row['kind'] == 'listen': + from .projects import run_listen + result = run_listen(self.port, row) elif row['kind'] == 'image_edit': from .images import run_render result = run_render(self, row) diff --git a/freevideo_engine/agent/mcp.py b/freevideo_engine/agent/mcp.py index 3b672c2e..c59d2599 100644 --- a/freevideo_engine/agent/mcp.py +++ b/freevideo_engine/agent/mcp.py @@ -47,7 +47,7 @@ def tool_list(): dict(name='freevideo_generate', title='Generate a video', description='Generate one video from an H3 prompt, up to 60 seconds: a longer request continues and joins into one file automatically. Prefer a new shot with its reference pictures wherever the film can cut; continue only a take, action or sound that must stay unbroken. Use this for any single video, also after freevideo_enhance_prompt; the drama tools are only for a film of several separately chosen shots. Optionally from first/last frames or references (files, local paths, or frames of earlier jobs). Returns the job; with wait, its result (video path and URL).', annotations=write), dict(name='freevideo_submit_batch', title='Submit a storyboard', description='Submit several jobs at once. Each row is a job object with "kind" (generate default, assemble, vlm, enhance) and an optional "ref"; later rows use "@ref" wherever a job id is expected (from_job, after, clips, refine_from). FreeVideo runs them in dependency order.', annotations=write), dict(name='freevideo_job', title='Job status', description='Status, progress, result or error of a job.', annotations=read), - dict(name='freevideo_wait', title='Wait for a job or the user', description="Wait at most 25 seconds for a job (with id) or, without id, for the user's words in this session. Returns early when the user says something (inbox). If the job is still active, call it again with the same ID; do not resubmit.", annotations=read), + dict(name='freevideo_wait', title='Wait for a job or the user', description="Wait at most 25 seconds for a job (with id) or, without id, at most 50 seconds for what the user says in FreeVideo (inbox). again=true means nothing new yet: call it again. A job wait returns early when the user says something. If the job is still active, call it again with the same ID; do not resubmit.", annotations=read), dict(name='freevideo_jobs', title='List jobs', description='Recent agent jobs, optionally by status (comma-separated), project or kind.', annotations=read), dict(name='freevideo_cancel', title='Cancel a job', description='Cancel a waiting, queued or running job.', annotations={'readOnlyHint': False, 'destructiveHint': True, 'openWorldHint': False}), dict(name='freevideo_resume', title='Resume generation', description='Resume a failed, interrupted or cancelled generation; only unfinished blocks run. Poll the same job ID.', annotations=write), @@ -330,8 +330,8 @@ def tool(self, identity, name, args, token): return c.job(args['id'], **({'wait': wait} if wait is not None else {})), [] if name == 'freevideo_wait': if not args.get('id'): - return c.get('/inbox', wait=min(25., float(args.get('timeout', 25)))), [] - return self.wait(identity, args['id'], args.get('timeout', 25), token), [] + return c.get('/inbox', wait=min(50., float(args.get('timeout', 50)))), [] + return self.wait(identity, args['id'], min(25., float(args.get('timeout', 25))), token), [] if name == 'freevideo_jobs': return dict(jobs=c.jobs(**args)), [] if name == 'freevideo_cancel': diff --git a/freevideo_engine/agent/projects.py b/freevideo_engine/agent/projects.py index 4d6f5f3c..348d5b8a 100644 --- a/freevideo_engine/agent/projects.py +++ b/freevideo_engine/agent/projects.py @@ -114,6 +114,7 @@ class Projects: def __init__(self, root, *, directory=None): self.root = root # The ComfyUI output folder, where the page keeps FreeVideo/.editor self.lock = threading.Lock() + self.listen_lock = threading.Lock() self.receipts = {} # (client, ref) -> {digest, result}; in memory, bounded; the page save keeps the durable one. self.prepared = {} # job id -> the reserved project, until its chain id is written into it. @@ -341,6 +342,24 @@ def export(self, identity, value, *, jobs, ref=None, client=None): self._remember(ref, digest, result, client) return result + def listen(self, identity, value, *, jobs, ref=None, client=None): + """Queue a listening job (align or transcribe) for the revision the caller read; a stale revision starts nothing.""" + schema.validate(schema.project_schema('listen'), value) + if (value['mode'] == 'align') != ('lines' in value): + raise _invalid('/lines', 'lines for mode align, and none for transcribe', value.get('lines')) + with self.listen_lock: # the same key sent twice at once queues one job + digest, replay = self._receipt('projects:listen', dict(id=identity, body=value), ref, client) + if replay is not None: + return replay + stored = self._load(identity)['project'] + if stored['revision'] != value['revision']: + raise ProjectError('project_changed', project=stored) + job = jobs.submit(dict(kind='listen', editor_project=identity, + **{k: v for k, v in value.items() if k != 'ref'}), client=client)[0] + result = dict(job=jobs.view(job), jobs=[jobs.view(job)]) + self._remember(ref, digest, result, client) + return result + def _update(self, identity, change, *, tries=4): """Read, change and save; a page save in between makes it read again. None when nothing changed.""" for _ in range(tries): @@ -448,3 +467,49 @@ def run_export(port, row, *, poll=.25): relative = view['path'] return dict(library_id=library_id(relative), video=library_view(port, relative), editor_project=spec['editor_project'], revision=spec['revision']) + + +# Listen ---------------------------------------------------------------------------------------------- +LISTEN_REASONS = {'no_engine': 'no_speech_model', 'language': 'no_speech_model', 'no_sound': 'invalid_request', 'too_long': 'invalid_request', + 'source_missing': 'clip_source_unusable', 'unreadable': 'clip_source_unusable', + 'disk_full': 'storage_unavailable'} +LISTEN_MESSAGES = { + 'no_engine': 'No speech model is installed on this machine; write the subtitle lines yourself with the subtitles edit', + 'no_sound': 'The timeline has no sound to listen to', + 'too_long': 'The sound is too long for one listening job; give a shorter range'} + + +def run_listen(port, row, *, poll=.25): + """A listening job: the page's subtitle ears (align or transcribe) over one saved revision.""" + spec = row['spec'] + queue = port.editor_ears() + if queue is None: + raise JobError('not_ready', 'The subtitle service is not running') + request = dict(kind=spec['mode'], id=spec['editor_project'], revision=spec['revision'], language=spec['language']) + request.update({k: spec[k] for k in ('lines', 'range') if k in spec}) + try: + view = queue.submit(request) + except FileNotFoundError as error: + raise JobError('not_found', 'The project does not exist') from error + except (ValueError, TypeError) as error: + raise JobError('invalid_request', 'This project cannot be listened to') from error + except Exception as error: + if getattr(error, 'reason', None) is None: + raise + raise JobError('busy', 'The subtitle queue is full; retry shortly') from error + identity, cancelled = view['id'], False + row['_phase'] = 'listening' + while view['state'] == 'running': + if row.get('_cancel') and not cancelled: + view, cancelled = queue.cancel(identity), True + continue + row['_detail'] = json.dumps(dict(fraction=round(view['fraction'], 3))) + time.sleep(poll) + view = queue.get(identity) + if view['state'] == 'cancelled': + raise JobError('cancelled', 'Cancelled') + if view['state'] != 'done': + reason = view.get('reason') or 'failed' + raise JobError(LISTEN_REASONS.get(reason, 'listen_failed'), + LISTEN_MESSAGES.get(reason) or 'Listening failed (%s)' % reason) + return dict(editor_project=spec['editor_project'], revision=spec['revision'], **view['result']) diff --git a/freevideo_engine/agent/routes.py b/freevideo_engine/agent/routes.py index aee2b259..2638001c 100644 --- a/freevideo_engine/agent/routes.py +++ b/freevideo_engine/agent/routes.py @@ -13,6 +13,7 @@ import logging import math import os +import re from pathlib import Path import secrets import stat @@ -153,7 +154,7 @@ def features(continuation, vlm): 'queue.shared': True, 'queue.busy': True, 'chain.auto': bool(continuation), 'enhance': bool(vlm.get('ready')), 'drama.film': True, 'images.edit': rules, 'images.layers': rules, 'images.heal': rules, 'images.render': rules, 'projects.read': True, - **{'projects.' + name: rules for name in ('insert', 'split', 'move', 'trim', 'remove', 'export', 'generate')}} + **{'projects.' + name: rules for name in ('insert', 'split', 'move', 'trim', 'remove', 'export', 'generate', 'listen')}} def capabilities(port, jobs): @@ -191,6 +192,7 @@ def capabilities(port, jobs): 'vlm': 'Ask the local vision-language model about images or sampled video frames', 'enhance': 'Rewrite a rough idea into a full H3 prompt with the local VLM', 'export': 'Render a saved revision of an editing project to a video', + 'listen': 'Listen to the sound of an editing project for subtitle lines and times', 'image_edit': 'Render a saved revision of an image session to a picture'}, limits=dict(width=[256, 4096, 32], height=[256, 4096, 32], seconds=[1.625, 60 if continuation else BLOCK_SECONDS], seconds_tested=[4, 15], @@ -244,6 +246,11 @@ def plan(port, value, jobs=None): return result +def job_answer(view): + """One job answers the way a submission does: the job is also under 'job'.""" + return dict(view, job=view) + + async def wait_for_job(jobs, row, seconds, *, clock=time.monotonic, sleep=asyncio.sleep): if not math.isfinite(seconds) or seconds < 0: raise chain_error('invalid_wait', 'wait must be a finite nonnegative number of seconds.', @@ -378,6 +385,8 @@ def reply(value, status=200): contracts = contract.contracts() page_sessions = {} connection = dict(connected=False, client=None, via=None, last_seen_at=None) + # The conversation in the Autopilot view: the page and the connected agent talk in the agent's latest session. + chat = dict(client=None, waiting=0, folds={}) server._freevideo_agent_connection = connection def problem(code, message=None, status=None, **details): @@ -432,6 +441,7 @@ async def respond(request): connection['client'] = connection['client'] or request.headers.get('X-FreeVideo-Client', 'HTTP client')[:80] connection['via'] = connection['via'] or 'http' request['agent_client'] = request.headers.get('X-FreeVideo-Client', '')[:80] or 'HTTP client' + chat['client'] = request['agent_client'] if definition.get('path'): contract.validate(definition['path'], dict(request.match_info)) for key in request.query: @@ -667,6 +677,223 @@ async def page_session(request): path=('/api' if request.path.startswith('/api/') else '') + '/freevideo/agent', max_age=12 * 3600) return response + def activity_item(row, *, card=False): + view = jobs.view(row, detail=False) + value = {k: view.get(k) for k in ('id', 'kind', 'status', 'label', 'project', 'client', 'take', + 'created_at', 'finished_at')} + progress = view.get('progress') or {} + value['progress'] = {k: progress[k] for k in ('phase', 'step', 'fraction', 'remaining_seconds', + 'queue_position') + (('block',) if card else ()) if progress.get(k) is not None} + if view.get('waiting_for'): + value['waiting_for'] = len(view['waiting_for']) + if view.get('error'): + value['error'] = view['error'] + video = (view.get('result') or {}).get('video') or {} + if video.get('url'): + value['video'] = video['url'] + if (view.get('result') or {}).get('library_id'): + # The work this video became, for the page to show it in My works. + value['work'] = view['result']['library_id'] + if card: + # A conversation card: the page plays and pictures the saved work itself, without the agent's token. + from urllib.parse import quote + value.pop('client') + work = value.setdefault('work', None) + seconds = row['spec'].get('seconds') + size = [row['spec'].get(k) if type(row['spec'].get(k)) is int else None for k in ('width', 'height')] + value.update(seconds=seconds if isinstance(seconds, (int, float)) and not isinstance(seconds, bool) else None, + width=size[0], height=size[1], + thumbnail='/freevideo/library/thumbnail?id=' + quote(work, safe='') if work else None, + play='/freevideo/agent/chat/video?id=' + quote(work, safe='') if work else None) + return value + + def agent_state(): + seen, now = connection['last_seen_at'], time.time() + connected = seen is not None and 0 <= now - seen < 600 + name = (connection['client'] or '').strip() + # Programs report their own identifiers; the page shows the name a person knows. + shown = ('Claude Code' if name.lower() in ('claude-code', 'claude code', 'claude') else + 'Codex' if name.lower().startswith('codex') else name or None) + # Every answer to the agent carries the user's unread words, so a recent call will hear them too. + return dict(connected=connected, name=shown, listening=connected and (chat['waiting'] > 0 or now - seen < 90)) + + def chat_name(said): + """A job the agent left unnamed is called by what the user asked for: the first words after a colon.""" + text = re.split(r'[::]', said or '', maxsplit=1)[-1].strip() + text = re.split(r'[,。!?;、,.!?;\n]', text, maxsplit=1)[0].strip() or text + if not text: + return None + if text.isascii(): + return text if len(text) <= 24 else text[:25].rsplit(' ', 1)[0][:24] + return text[:8] + + wide = str.maketrans(',:;!?', ',:;!?') + + def chat_text(text): + """Chinese an agent writes keeps Chinese punctuation: a mark right after a Chinese character is the wide one.""" + return re.sub(r'(?<=[\u3400-\u4dbf\u4e00-\u9fff])[,:;!?]', lambda found: found.group().translate(wide), text) + + def chat_card(row, fold): + card = activity_item(row, card=True) + if not card.get('label') and fold is not None: + card['label'] = fold['names'].get(row['id']) + estimate = jobs.estimate(row) + if estimate: + card['progress']['estimate'] = estimate + return card + + def chat_fold(store, sid): + """The session as the page shows it: sentences, the jobs made along the way, and questions.""" + fold = chat['folds'].get(sid) + if fold is None: + while len(chat['folds']) >= 8: + chat['folds'].pop(next(iter(chat['folds']))) + fold = chat['folds'][sid] = dict(seq=0, items=[], jobs={}, asked={}, sent={}, ids={}, names={}, said=None) + while store.get(sid)['last_seq'] > fold['seq']: + rows = store.events(sid, after=fold['seq'], limit=500) + if not rows: + break + for row in rows: + fold['seq'] = row['seq'] + base = dict(seq=row['seq'], at=row['at'], type=row['type']) + if row['type'] == 'message': + fold['items'].append(dict(base, role='user' if row['by'] == 'user' else 'agent', text=row['text'])) + if row['by'] == 'user': + fold['said'] = row['text'] + elif row['type'] == 'step' and row['state'] == 'done': + job = (row.get('refs') or {}).get('job') + if job and job not in fold['jobs']: + fold['jobs'][job] = row['seq'] + fold['names'][job] = chat_name(fold['said']) + fold['items'].append(dict(base, type='job', job=job)) + elif row['type'] == 'confirm': + if row['state'] == 'asked': + item = dict(base, id=row['id'], question=row.get('question'), + estimate_seconds=row.get('estimate_seconds'), state='asked') + fold['asked'][row['id']] = item + fold['items'].append(item) + elif row['id'] in fold['asked']: + fold['asked'][row['id']]['state'] = row['state'] + return fold + + def chat_current(rows, sid, fold): + mine = [r for r in rows if r.get('session') == sid or (fold is not None and r['id'] in fold['jobs'])] + active = sorted((r for r in mine if r['status'] in ACTIVE), + key=lambda r: (r['status'] in ('running', 'cancelling'), r['created_at'])) + return mine, active[-1] if active else None + + def chat_session(store): + return store.latest_for(chat['client']) if store is not None and chat['client'] is not None else None + + @routes.get('/freevideo/agent/chat') + @guarded + async def chat_read(request): + after = request[query_key].get('after') + def read(): + store = work_sessions() + sid = chat_session(store) + rows = jobs.rows() + value = dict(agent=agent_state(), session=sid, last_seq=0, read_seq=0, items=[], jobs={}, confirms={}, current=None, + active=sum(1 for r in rows if r['status'] in ACTIVE)) + if sid is None: + return value + fold = chat_fold(store, sid) + known = {r['id']: r for r in rows} + items = [i for i in fold['items'] if i['type'] != 'job' or i['job'] in known] + if after is None: + items, more = items[-200:], False + else: + items = [i for i in items if i['seq'] > after] + items, more = items[:200], len(items) > 200 + mark = store.read_until(sid, chat['client']) + for item in items: + item = dict(item) + if item['type'] == 'message' and item['role'] == 'user': + item['read'] = item['seq'] <= mark + if item['seq'] in fold['ids']: + item['id'] = fold['ids'][item['seq']] + value['items'].append(item) + value['last_seq'] = items[-1]['seq'] if more else fold['seq'] + # For what the page already holds: how far the agent has read, and how each question stands now. + value['read_seq'] = mark + value['confirms'] = {identity: item['state'] for identity, item in list(fold['asked'].items())[-50:]} + mine, current = chat_current(rows, sid, fold) + now = time.time() + shown = {r['id']: r for r in mine if r['status'] in ACTIVE or (r.get('finished_at') or 0) > now - 1800} + shown.update((i['job'], known[i['job']]) for i in items if i['type'] == 'job') + value['jobs'] = {identity: chat_card(row, fold) for identity, row in shown.items()} + value['current'] = current['id'] if current else None + if current and current['id'] not in value['jobs']: + value['jobs'][current['id']] = chat_card(current, fold) + return value + return reply(await asyncio.to_thread(read)) + + @routes.get('/freevideo/agent/chat/now') + @guarded + async def chat_now(request): + def read(): + store = work_sessions() + sid = chat_session(store) + rows = jobs.rows() + fold = chat_fold(store, sid) if sid else None + current = chat_current(rows, sid, fold)[1] if sid else None + return dict(agent=agent_state(), current=chat_card(current, fold) if current else None, + active=sum(1 for r in rows if r['status'] in ACTIVE)) + return reply(await asyncio.to_thread(read)) + + @routes.post('/freevideo/agent/chat') + @guarded + async def chat_say(request): + value = await body(request) + def write(): + store, client = work_sessions(), chat['client'] + if store is None or client is None: + return None + sid = store.latest_for(client) or store.create(client=client, by='user')['id'] + fold = chat_fold(store, sid) + if value['id'] in fold['sent']: + seq = fold['sent'][value['id']] + first = next(i for i in fold['items'] if i['seq'] == seq) + return 200, dict(seq=seq, id=value['id'], at=first['at'], session=sid, + read=seq <= store.read_until(sid, client), agent=agent_state()) + if store.stopped(sid): + # Speaking again continues a conversation the user stopped earlier. + store.append(sid, dict(type='control', action='resumed'), by='user') + row = store.append(sid, dict(type='message', text=value['text']), by='user') + fold['sent'][value['id']] = row['seq'] + fold['ids'][row['seq']] = value['id'] + while len(fold['sent']) > 500: + fold['ids'].pop(fold['sent'].pop(next(iter(fold['sent']))), None) + return 201, dict(seq=row['seq'], id=value['id'], at=row['at'], session=sid, read=False, agent=agent_state()) + done = await asyncio.to_thread(write) + if done is None: + # Nothing is kept: no agent has connected since FreeVideo started, so nobody would read it. + return reply({'error': dict(code='not_connected', message='No agent is connected.', retryable=True, + details=[], next='Connect an agent in the Autopilot view, then say it again.', + request_id=errors.request_id())}, 409) + return reply(done[1], done[0]) + + @routes.post('/freevideo/agent/chat/confirm') + @guarded + async def chat_confirm(request): + value = await body(request) + def write(): + store = work_sessions() + sid = chat_session(store) + if sid is None: + raise JobError('not_found', 'No conversation') + row = store.append(sid, dict(type='confirm', id=value['id'], + state='approved' if value['approve'] else 'declined'), by='user') + return dict(id=row['id'], state=row['state']) + return reply(await asyncio.to_thread(write)) + + @routes.get('/freevideo/agent/chat/video') + @guarded + async def chat_video(request): + # The finished video of a work, for the local page to play in the conversation. + path = await asyncio.to_thread(port.library_video, request[query_key]['id']) + return web.FileResponse(path, headers={'Content-Type': 'video/mp4', 'Cache-Control': 'private, no-store'}) + @routes.get('/freevideo/agent/activity') @guarded async def activity(request): @@ -675,22 +902,7 @@ def read(): now = time.time() rows = sorted(jobs.rows(), key=lambda r: r['created_at'], reverse=True) shown = [r for r in rows if r['status'] in ACTIVE or (r.get('finished_at') or 0) > now - 1800][:12] - def item(row): - view = jobs.view(row, detail=False) - value = {k: view.get(k) for k in ('id', 'kind', 'status', 'label', 'project', 'client', 'take', - 'created_at', 'finished_at')} - progress = view.get('progress') or {} - value['progress'] = {k: progress[k] for k in ('phase', 'step', 'fraction', 'remaining_seconds', - 'queue_position') if progress.get(k) is not None} - if view.get('waiting_for'): - value['waiting_for'] = len(view['waiting_for']) - if view.get('error'): - value['error'] = view['error'] - video = (view.get('result') or {}).get('video') or {} - if video.get('url'): - value['video'] = video['url'] - return value - return dict(active=sum(1 for r in rows if r['status'] in ACTIVE), jobs=[item(r) for r in shown]) + return dict(active=sum(1 for r in rows if r['status'] in ACTIVE), jobs=[activity_item(r) for r in shown]) return reply(await asyncio.to_thread(read)) @routes.post('/freevideo/agent/activity/cancel') @@ -709,7 +921,7 @@ async def activity_cancel(request): @routes.get('/freevideo/agent/connect') @guarded async def connect(request): - # For Settings: how to connect an agent on this computer. The token itself stays in its file. + # For the Autopilot view: how to connect an agent on this computer. The token itself stays in its file. from ..comfy_bridge import installation_root, source_root from .guide import page_language from .mcp import client_config, linux_wrapper_supported, python_shell_command, shell_quote @@ -848,14 +1060,14 @@ def read(): @guarded async def get_job(request): row = await asyncio.to_thread(jobs.get, request.match_info['id']) - return reply(await wait_for_job(jobs, row, float(request[query_key].get('wait', '0') or 0))) + return reply(job_answer(await wait_for_job(jobs, row, float(request[query_key].get('wait', '0') or 0)))) @routes.post(PREFIX + '/jobs/{id}/cancel') @guarded async def cancel(request): def work(): return jobs.view(jobs.cancel(request.match_info['id'])) - return reply(await asyncio.to_thread(work)) + return reply(job_answer(await asyncio.to_thread(work))) @routes.post(PREFIX + '/jobs/{id}/resume') @guarded @@ -864,7 +1076,7 @@ async def resume(request): key=request.headers.get('Idempotency-Key'), value=await body(request), replay_result=True, client=request.headers.get('X-FreeVideo-Client', '')[:80] or None) await jobs.tick() - return reply(await asyncio.to_thread(jobs.view, row)) + return reply(job_answer(await asyncio.to_thread(jobs.view, row))) @routes.get(PREFIX + '/jobs/{id}/events') @guarded @@ -979,6 +1191,8 @@ async def session_post(request): raise JobError('forbidden', 'Only the page user may stop or continue') if value['action'] == 'stopped': return reply(await stop_session(sid), 201) + if by == 'agent' and value['type'] == 'message' and isinstance(value.get('text'), str): + value['text'] = chat_text(value['text']) return reply(await asyncio.to_thread(sessions.append, sid, value, by=by, client=session_client(request, by)), 201) @@ -1263,23 +1477,35 @@ async def library_thumbnail(request): async def inbox(request): # The hook already attached this request to the caller's session; answer with its unread user words. store, sid = work_sessions(), request.get('agent_session') - deadline = time.monotonic() + min(25., float(request[query_key].get('wait', '0') or 0)) - while True: - if store is not None and sid is not None: - # Mark before reading so words arriving in between wake the wait below. - def read(): - mark = store.get(sid)['last_seq'] - return mark, store.unread(sid, request['agent_client']) - mark, pending = await asyncio.to_thread(read) - if pending: - return reply(dict(session=sid, inbox=pending[-20:])) - left = deadline - time.monotonic() - if left <= 0: - return reply(dict(session=sid, inbox=[])) - if store is None or sid is None: - await asyncio.sleep(min(left, 1.)) - else: - await store.wait(sid, after=mark, seconds=left, by='user') + client = request['agent_client'] + if store is not None and sid is None: + # An agent that only listens has no session yet: give it one, so the page can speak into it. + sid = await asyncio.to_thread(lambda: store.latest_for(client) or store.create(client=client)['id']) + request['agent_session'] = sid + deadline = time.monotonic() + min(50., float(request[query_key].get('wait', '0') or 0)) + chat['waiting'] += 1 + try: + while True: + if store is not None and sid is not None: + # Mark before reading so words arriving in between wake the wait below. + def read(): + mark = store.get(sid)['last_seq'] + return mark, store.unread(sid, client) + mark, pending = await asyncio.to_thread(read) + if pending: + return reply(dict(session=sid, inbox=pending[-20:])) + left = deadline - time.monotonic() + if left <= 0: + # again: nothing new this time; the caller simply waits once more. + return reply(dict(session=sid, inbox=[], again=True)) + if store is None or sid is None: + await asyncio.sleep(min(left, 1.)) + else: + await store.wait(sid, after=mark, seconds=min(left, 25.), by='user') + finally: + chat['waiting'] -= 1 + # The agent was here for the whole wait, not only when it began. + connection['last_seen_at'] = time.time() @routes.get(PREFIX + '/queue') @guarded @@ -1408,7 +1634,7 @@ async def handler(request): raise JobError('idempotency_conflict', 'Conflicting keys') args = [] if operation == 'create' else [identity] extra = dict(client=request.headers.get('X-FreeVideo-Client', '')[:80] or None) - if operation in ('generate', 'export'): + if operation in ('generate', 'export', 'listen'): extra['jobs'] = jobs if operation == 'generate': def capability(size): @@ -1418,9 +1644,9 @@ def capability(size): return None extra['capability'] = capability result = await asyncio.to_thread(getattr(projects, operation), *args, value, ref=key or ref, **extra) - if operation in ('generate', 'export'): + if operation in ('generate', 'export', 'listen'): await jobs.tick() - return reply(result, 201 if operation in ('create', 'generate', 'export') else 200) + return reply(result, 201 if operation in ('create', 'generate', 'export', 'listen') else 200) return guarded(handler) for operation, (method, suffix, _) in contract.PROJECT_OPERATIONS.items(): diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py index 76adf728..38410e1a 100644 --- a/freevideo_engine/agent/schema.py +++ b/freevideo_engine/agent/schema.py @@ -58,6 +58,9 @@ def enum(values, **kw): FRAME = {'oneOf': [enum(('first', 'last')), number(0)]} WAIT = number(0, default=0, description='x-clamp caps accepted wait values at 25 seconds without rejecting larger values.', **{'x-clamp': 25}) CLIENT_WAIT = number(0, 25, default=25) +# Waiting for the user's words is longer: a person types slowly, and each wait is one tool call. +INBOX_WAIT = number(0, default=0, description='x-clamp caps accepted wait values at 50 seconds without rejecting larger values.', **{'x-clamp': 50}) +CHAT_ID = string(64, pattern=r'^[A-Za-z0-9_-]{1,64}$') EMPTY = obj() @@ -206,6 +209,23 @@ def export(*, local=False): **{'x-job-kind': 'export'}) +LISTEN_FIELDS = dict( + mode=enum(('align', 'transcribe'), description='align: time the lines you give against the sound; transcribe: write down what is said.'), + language=string(8, pattern=r'^(auto|[a-z]{2,3})$', default='auto', description='Spoken language, e.g. zh or en; auto detects it.'), + lines=array(string(2000), 1, 1000, description='align only: the lines to time, in order.'), + range=obj(dict(start=EDITOR_FRAME, frames=number(1, 10**7, integer=True)), ('start', 'frames'), + description='Timeline frames to listen to; omit for the whole timeline.')) + + +def listen(*, local=False): + return obj(dict(kind={'const': 'listen', 'type': 'string'}, + editor_project=string(34, pattern=r'^e-[0-9a-f]{32}$', description='The editing project whose sound to listen to (GET /projects).'), + revision=number(1, 2**53 - 1, integer=True, description='The saved revision you read; a later save fails the job with project_changed.'), + **LISTEN_FIELDS, **common()), ('kind', 'editor_project', 'revision', 'mode'), + description='Listen to the timeline sound with the local speech model. The result lists each line with its start and end frame; nothing is saved.', + **{'x-job-kind': 'listen'}) + + def image_edit(*, local=False): return obj(dict(kind={'const': 'image_edit', 'type': 'string'}, image=dict(IMAGE, description='The image session to render (POST /images).'), @@ -215,7 +235,7 @@ def image_edit(*, local=False): **{'x-job-kind': 'image_edit'}) -KINDS = dict(generate=generate, assemble=assemble, vlm=vlm, enhance=enhance, export=export, image_edit=image_edit) +KINDS = dict(generate=generate, assemble=assemble, vlm=vlm, enhance=enhance, export=export, listen=listen, image_edit=image_edit) def job_schema(*, local=False, refs=False): @@ -291,8 +311,8 @@ def route(summary, body=None, query=None, **kw): ('POST', '/connect/hello'): route('Report a connected client', body=obj(dict(client=string(80), via=enum(('mcp', 'http'))), ('client', 'via'))), ('GET', '/connect/status'): route('Read recent connection status', auth=False, local=True), - ('GET', '/inbox'): route("Wait at most 25 seconds for the user's words or answers in this session", - query=obj({'wait': WAIT})), + ('GET', '/inbox'): route("Wait at most 50 seconds for the user's words or answers in this session; " + "again=true means nothing new yet, call it again", query=obj({'wait': INBOX_WAIT})), } for name, (method, suffix, summary) in DRAMA_OPERATIONS.items(): shape = drama_schema(name) @@ -317,7 +337,7 @@ def route(summary, body=None, query=None, **kw): if method == 'GET': extra['query'] = shape else: - extra.update(body=shape, idempotent=True, **({'status': 201} if name in ('create', 'generate', 'export') else {})) + extra.update(body=shape, idempotent=True, **({'status': 201} if name in ('create', 'generate', 'export', 'listen') else {})) routes[method, suffix] = route(summary, **extra) for name, (method, suffix, summary) in IMAGE_OPERATIONS.items(): shape = image_schema(name) @@ -333,8 +353,14 @@ def route(summary, body=None, query=None, **kw): body=obj({'ref': REF}), path=obj({'id': PRODUCTION}, ('id',)), auth=False, local=True, idempotent=True) for suffix, method, body in (('', 'GET', None), ('/session', 'GET', None), ('/activity', 'GET', None), ('/activity/cancel', 'POST', obj({'id': JOB}, ('id',))), ('/connect', 'GET', None), - ('/connect/reset', 'POST', EMPTY)): + ('/connect/reset', 'POST', EMPTY), ('/chat', 'GET', None), ('/chat/now', 'GET', None), + ('/chat/video', 'GET', None), + ('/chat', 'POST', obj({'text': string(8000), 'id': CHAT_ID}, ('text', 'id'))), + ('/chat/confirm', 'POST', obj({'id': string(12, pattern=r'^cf[0-9a-f]{10}$'), 'approve': {'type': 'boolean'}}, + ('id', 'approve')))): routes[(method, '/freevideo/agent' + suffix)] = route('Studio Agent controls', body=body, auth=False, local=True) + routes['GET', '/freevideo/agent/chat']['query'] = obj({'after': number(0, integer=True)}) + routes['GET', '/freevideo/agent/chat/video']['query'] = obj({'id': string(43, pattern=r'^[0-9]{4}-[0-9]{2}-[0-9]{2}/[0-9a-f]{32}$')}, ('id',)) for suffix in ('', '/connect'): routes['GET', '/freevideo/agent' + suffix]['query'] = obj({'lang': enum(('zh', 'en'))}) for name, (method, suffix, summary) in SESSION_OPERATIONS.items(): @@ -614,10 +640,12 @@ def image_schema(name): 'operations': ('POST', '/projects/{id}/operations', 'Apply 1-64 timeline edits atomically and save once. First get revision and clip IDs; on project_changed recompute from error.project, and on clip_busy wait for generation to finish.'), 'generate': ('POST', '/projects/{id}/generate', 'Make new picture and sound for selected clips or a frame range, as the page Generate does. First get revision and clip IDs; the parts are reserved at once and fill in as the returned job finishes.'), 'export': ('POST', '/projects/{id}/export', 'Render one saved revision to an MP4 in the saved videos. First get the revision; wait for the returned job, then read its result video.'), + 'listen': ('POST', '/projects/{id}/listen', 'Listen to the timeline sound for subtitles: mode align times lines you give, transcribe writes down what is said. First get the revision; wait for the returned job, then add the lines with the subtitles edit. Nothing is saved; with no speech model for the language the call fails with no_speech_model, so write the lines by hand.'), } PROJECT_TOOLS = dict(list='freevideo_projects', get='freevideo_project_read', create='freevideo_project_create', write='freevideo_project_write', operations='freevideo_editor_operations', - generate='freevideo_project_generate', export='freevideo_export') + generate='freevideo_project_generate', export='freevideo_export', + listen='freevideo_project_listen') SESSION_OPERATIONS = { 'create': ('POST', '/sessions', 'Create a work conversation for messages and plans shared with the user.'), @@ -718,6 +746,8 @@ def project_schema(name): settings=settings, ref=REF), ('revision', 'target')) if name == 'export': return obj(dict(revision=revision, ref=REF), ('revision',)) + if name == 'listen': + return obj(dict(revision=revision, **LISTEN_FIELDS, ref=REF), ('revision', 'mode')) if name != 'operations': return EMPTY fields = { @@ -732,6 +762,9 @@ def project_schema(name): 'describe': (dict(clip_ids=ids, text=dict(type='string', maxLength=8000)), ('clip_ids', 'text'), 'Set the description of selected clips. Use clip IDs from this revision and one paragraph of text.'), 'set_ref': (dict(clip_ids=ids, ref=dict(type='string', maxLength=128, pattern=r'^[A-Za-z0-9_-]{0,128}$')), ('clip_ids', 'ref'), 'Set an outside shot reference on selected clips. Use clip IDs from this revision; an empty ref removes it.'), 'use_version': (dict(clip_id=CLIP, index=number(0, 49, integer=True)), ('clip_id', 'index'), 'Restore a stored clip version. First get the clip ID and its versions array; index is zero based.'), + 'subtitles': (dict(cues=array(obj(dict(start=EDITOR_FRAME, frames=number(1, 10**7, integer=True), text=dict(type='string', minLength=1, maxLength=400)), + ('start', 'frames', 'text')), 1, 500), replace=dict(type='boolean')), ('cues',), + 'Add subtitle lines as one undo step on the subtitle track (created if missing). Each cue is a start frame, a length in frames and one or two lines of text; cues may not overlap each other or lines already there. replace: true swaps the whole track.'), } operations = [obj(dict(op={'const': op}, **props), ('op', *required), description=description) for op, (props, required, description) in fields.items()] @@ -1167,7 +1200,7 @@ def openapi(): elif suffix == '/jobs' and method == 'GET': response_schema = obj({'jobs': {'type': 'array', 'items': {'$ref': '#/components/schemas/Job'}}}) elif suffix in ('/jobs/{id}', '/jobs/{id}/cancel', '/jobs/{id}/resume'): - response_schema = {'$ref': '#/components/schemas/Job'} + response_schema = {'$ref': '#/components/schemas/JobAnswer'} elif '/drama/{id}' in suffix and not suffix.endswith('/sheet') or suffix == '/drama' and method == 'POST': response_schema = production_response() elif suffix == '/confirmations': @@ -1197,16 +1230,18 @@ def openapi(): if suffix == '/connect' and method == 'GET': success['content']['text/plain'] = {'schema': {'type': 'string'}} paths.setdefault(path, {})[method.lower()] = operation + job_view = {'type': 'object', 'properties': dict(id=JOB, kind=enum(KINDS), + status=enum(('waiting', 'queued', 'running', 'cancelling', 'succeeded', 'failed', 'cancelled', 'interrupted')), + chain_id=string(), last_heartbeat_at={'type': ['string', 'null']}, poll_after_ms={'type': ['integer', 'null']}, + spec={'type': 'object'}, progress={'type': 'object'}, result={'type': 'object'}, + error={'$ref': '#/components/schemas/Error'})} return dict(openapi='3.1.0', info=dict(title='FreeVideo Agent API', version=str(API_VERSION)), paths=paths, components=dict(securitySchemes={'bearer': {'type': 'http', 'scheme': 'bearer'}, 'page': {'type': 'apiKey', 'in': 'cookie', 'name': 'freevideo_agent_page'}}, schemas={ 'Session': session_view(), 'SessionEvent': session_event(stored=True), - 'JobSpec': schemas()['job'], 'Batch': batch_schema(), - 'Job': {'type': 'object', 'properties': dict(id=JOB, kind=enum(KINDS), - status=enum(('waiting', 'queued', 'running', 'cancelling', 'succeeded', 'failed', 'cancelled', 'interrupted')), - chain_id=string(), last_heartbeat_at={'type': ['string', 'null']}, poll_after_ms={'type': ['integer', 'null']}, - spec={'type': 'object'}, progress={'type': 'object'}, result={'type': 'object'}, - error={'$ref': '#/components/schemas/Error'})}, + 'JobSpec': schemas()['job'], 'Batch': batch_schema(), 'Job': job_view, + # One job answers the way a submission does: the same job is also under 'job'. + 'JobAnswer': dict(job_view, properties=dict(job_view['properties'], job={'$ref': '#/components/schemas/Job'})), 'Submitted': obj(dict(job={'$ref': '#/components/schemas/Job'}, jobs={'type': 'array', 'items': {'$ref': '#/components/schemas/Job'}})), 'Error': obj(dict(code=enum(ERRORS), @@ -1363,7 +1398,7 @@ def _tool_schema(name): value['required'].append('shot') if name == 'freevideo_wait': value['properties'].pop('wait') - value['properties']['timeout'] = CLIENT_WAIT + value['properties']['timeout'] = number(0, 50, default=25, description='With id at most 25 seconds; without id (the user) up to 50, default 50.') value['required'] = [k for k in value['required'] if k != 'id'] return value if name == 'freevideo_asset': diff --git a/freevideo_engine/agent/sessions.py b/freevideo_engine/agent/sessions.py index 7febb1e7..5d48ab05 100644 --- a/freevideo_engine/agent/sessions.py +++ b/freevideo_engine/agent/sessions.py @@ -316,6 +316,12 @@ def latest_for(self, client) -> str | None: rows = [row for sid, row in self._meta.items() if client in self._clients[sid]] return max(rows, key=lambda r: (r['updated_at'], r['id']))['id'] if rows else None + def read_until(self, sid, reader) -> int: + """The last event this reader has been given; unread() moves it.""" + with self.lock: + self._get(sid) + return self._readers.get((sid, reader), self._initial_seq[sid]) + def stopped(self, sid) -> bool: with self.lock: return self._get(sid)['stopped'] diff --git a/freevideo_engine/agent/spec.py b/freevideo_engine/agent/spec.py index d2f75c6f..8a9608c5 100644 --- a/freevideo_engine/agent/spec.py +++ b/freevideo_engine/agent/spec.py @@ -213,6 +213,9 @@ def normalize(value): result.update({k: value[k] for k in ('format', 'max_tokens', 'system') if k in value}) elif kind == 'export': result.update(editor_project=value['editor_project'], revision=value['revision']) + elif kind == 'listen': + result.update(editor_project=value['editor_project'], revision=value['revision'], mode=value['mode'], + language=value.get('language', 'auto'), **{k: value[k] for k in ('lines', 'range') if k in value}) elif kind == 'image_edit': result.update(image=value['image'], revision=value['revision']) else: diff --git a/freevideo_engine/background_compile.py b/freevideo_engine/background_compile.py index 4ec6bbb1..3b6b998e 100644 --- a/freevideo_engine/background_compile.py +++ b/freevideo_engine/background_compile.py @@ -125,6 +125,8 @@ def _work(self, command, environment, cwd, log, busy, delay): row = dict(started=time.time()) self.attempts.append(row) with io_path(log).open('ab') as stream: + from .triton_compat import temporary_environment + environment = temporary_environment(environment.get('FREEVIDEO_HOME') or self.lock.parent, environment) process = self.process = processes.popen( command, env=environment, cwd=cwd, stdin=subprocess.DEVNULL, stdout=stream, stderr=subprocess.STDOUT, start_new_session=True, supervise=True) diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index 1363e978..9243ab0a 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -23,7 +23,7 @@ from . import execution_paths from .hardware import Hardware, GiB, cgroup_capacity -from . import __version__ +from . import __version__, subprocess_output from .policy import resource_budget from .monitoring import save from .install_tuning import build_parallelism, required_models, cache_compatible, wheel_key @@ -211,7 +211,7 @@ def dependency_status(root, layout='unified', system=None): if python.exists(): code = ('import importlib.metadata as m,json; ' 'print(json.dumps({d.metadata["Name"].lower().replace("_","-"):d.version for d in m.distributions()}))') - check = execution_paths.run([str(python), '-B', '-c', code], capture_output=True, text=True) + check = subprocess_output.run_python([str(python), '-B', '-c', code], capture_output=True) if check.returncode: row['error'] = check.stderr[-1000:] else: @@ -280,8 +280,8 @@ def digest(path, algorithm='sha256', git_blob=False): def inventory(gpu=None): smi = nvidia_smi() query = 'index,uuid,name,compute_cap,memory.total,memory.free,driver_version,pci.bus_id' - result = subprocess.run([smi, '--query-gpu=' + query, '--format=csv,noheader,nounits'], - check=True, capture_output=True, text=True, timeout=20) + result = subprocess_output.run_external([smi, '--query-gpu=' + query, '--format=csv,noheader,nounits'], + check=True, capture_output=True, timeout=20) rows = [dict(zip(query.split(','), map(str.strip, row))) for row in csv.reader(result.stdout.splitlines())] selector = gpu if gpu is not None else os.environ.get('CUDA_VISIBLE_DEVICES', '0').split(',')[0] matches = [r for r in rows if r['index'] == selector or r['uuid'] == selector] @@ -396,7 +396,36 @@ def offline_installation(root): return (root.parent / 'portable.json').is_file() -def plan(args, *, local_progress=None): +PREPARE_KEY = 'install-prepare' + + +def prepare_step(ui, name, done=None, total=None): + """One running task for the silent steps between the model scan and the first real task. + + Without it the card keeps the last finished event (the model scan) for as long as the + environment, the network and the download tools are examined. + """ + with ui._lock: + if PREPARE_KEY not in ui.tasks: + ui.begin(PREPARE_KEY, 'Preparing installation') + elif ui.tasks[PREPARE_KEY]['state'] != 'running': + return # Finished: a late step must not bring the card back to it. + if ui.tasks[PREPARE_KEY].get('step') != name: + ui.tasks[PREPARE_KEY]['event_tick'] = 0. # Short steps must each reach the card. + ui.tasks[PREPARE_KEY]['step'] = name + shown = '%s %d / %d' % (name, done or 0, total) if total else name + ui.update(PREPARE_KEY, activity='note', name=shown, detail=shown, unit='items', + done=done or 0, total=total or 0) + + +def prepare_end(ui, success=True): + """Finish the step once; a repeated end would bring its finished line back as the card's last event.""" + with ui._lock: + if ui.tasks.get(PREPARE_KEY, {}).get('state') == 'running': + ui.end(PREPARE_KEY, success=success) + + +def plan(args, *, local_progress=None, stage=None): root = args.root.expanduser().resolve() saved = json.loads((root / 'machine.json').read_text(encoding='utf-8')) if (root / 'machine.json').is_file() else {} storage = getattr(args, 'storage', None) or saved.get('storage', 'compact') @@ -569,6 +598,8 @@ def plan(args, *, local_progress=None): # Stream directly into final FP8 groups. The pinned model occupies ~45.3 # GiB; allow 52 GiB including the largest group in progress. Never credit # future source deletion toward the space needed to complete conversion. + if stage: + stage('Checking the environment') dependencies = dependency_status(root, layout, system) environments_ready = all(r['exists'] and not r['missing'] and not r['mismatched'] for r in dependencies.values()) python_version = None if mac_target else bootstrap_versions( @@ -592,6 +623,8 @@ def plan(args, *, local_progress=None): errors.extend(disk_plan['errors']) # A repair also reinstalls what is damaged in the ComfyUI environment the launcher made. frontend_python = getattr(args, 'frontend_repair_python', None) + if stage: + stage('Checking the environment') package_download_bytes = reinstall_download_bytes(dependencies, system, root=root, frontend_python=str(frontend_python.expanduser().absolute()) if frontend_python else None, disk_mode=disk_plan['mode']) @@ -600,18 +633,23 @@ def plan(args, *, local_progress=None): if reuse_cache and not cache_compatible(reuse_cache, **cache_format): errors.append('--cache must contain a prepared FP8 cache compatible with this GPU scale format.') from .download_settings import read as download_preferences + if stage: + stage('Testing download sources') networking = network.plan(json.loads((PACKAGE / 'dependencies.json').read_text(encoding='utf-8')), bootstrap_versions(json.loads((PACKAGE / 'bootstrap_versions.json').read_text(encoding='utf-8')), system), layout, mode=getattr(args, 'network', 'auto'), timeout=getattr(args, 'network_timeout', 5), offline=bool(args.hardware_json or errors), env=dict(os.environ, FREEVIDEO_HOME=str(root)), - proxy_mode=download_preferences(root / 'download-settings.json')['proxy_mode']) + proxy_mode=download_preferences(root / 'download-settings.json')['proxy_mode'], + progress=(lambda message: stage('Testing download sources', message['done'], message['total'])) if stage else None) networking['download_settings_path'] = str(root / 'download-settings.json') prepared_missing = prepared and any( not model_target(row, model_dir, encoder_dir, prepared_dir).is_file() for row in files if row.get('prepared') and not (local_reuse or {}).get('matches', {}).get(row['repo'] + '/' + row['file'])) if prepared_missing and not args.hardware_json and not errors: + if stage: + stage('Testing download sources') error = prepared_model.access_error(prepared, networking) if error: errors.append(error) @@ -1058,6 +1096,8 @@ def initialize(self, value, ui): self.damaged_before = [] if self.system != 'Darwin' and self.pythons['engine'].is_file(): from .runtime_libraries import damaged + if ui is not None: + prepare_step(ui, 'Checking the environment') self.damaged_before = damaged(self.pythons['engine']) # Moving a working installation to int8 checks the int8 kernels first # (execute), so a GPU that fails them keeps its configuration as it was. @@ -1262,6 +1302,8 @@ def command(self, label, args, env=None, cwd=None): from .package_progress import PackageOutput if self.cancel.is_set(): raise RuntimeError(self.state.get('resource_guard', 'Installation cancelled.')) + from .triton_compat import temporary_environment + env = temporary_environment(self.root, env or self.env) args, cwd = uv_file_arguments(args, cwd) with self.state_lock: index = len(self.state['steps']) @@ -1477,7 +1519,8 @@ def clone(self, name, url, commit, sparse=None): def packages(self, label, args, family='pypi'): return network.package_command(self.network, family, - lambda env, source: self.command(label if source == 'official' else label + '-' + source, args, env=env), self.env) + lambda env, source: self.command(label if source == 'official' else label + '-' + source, args, env=env), + self.env, notice=lambda message: self.ui.write(message + '\n')) def python_install(self, uv): from .system import bootstrap_root @@ -1488,38 +1531,35 @@ def python_install(self, uv): # and unpack another copy. Existing installations keep their own base. if prepared.is_dir() and not any(current.glob('cpython-' + self.versions['python'] + '-*')): env = dict(self.env, UV_PYTHON_INSTALL_DIR=str(prepared)) - found = execution_paths.run([str(uv), '--no-config', 'python', 'find', '--managed-python', + found = subprocess_output.run_external([str(uv), '--no-config', 'python', 'find', '--managed-python', '--no-python-downloads', self.versions['python']], - env=env, capture_output=True, text=True, timeout=30) + env=env, capture_output=True, timeout=30) if found.returncode == 0: candidate = Path(found.stdout.strip()) if candidate.is_file() and candidate.resolve().is_relative_to(prepared.resolve()): - check = execution_paths.run([str(candidate), '-I', '-B', '-c', - 'import platform; print(platform.python_version())'], capture_output=True, text=True, timeout=30) + check = subprocess_output.run_python([str(candidate), '-I', '-B', '-c', + 'import platform; print(platform.python_version())'], capture_output=True, timeout=30) if check.returncode == 0 and check.stdout.strip() == self.versions['python']: self.env['UV_PYTHON_INSTALL_DIR'] = str(prepared) self.command('python-bootstrap-reuse', [candidate, '-I', '-B', '-c', 'import sys; print("Reusing verified bootstrap Python: " + sys.executable)']) return custom = self.env.get('UV_PYTHON_INSTALL_MIRROR') if self.network.get('mode') != 'official' else None - sources = (['user-python'] if custom else []) + network.ordered(self.network, 'github') isolation = ['--no-bin', '--no-registry'] - for source in sources: - for route in network.route_order(self.network, 'github', source, self.env): - env = network.route_environment(self.network, 'github', source, self.env, route) - env.update(UV_HTTP_TIMEOUT='20', UV_HTTP_RETRIES='1') - env['UV_PYTHON_INSTALL_MIRROR'] = custom if source == 'user-python' else network.source_url('github', source, self.env) + '/astral-sh/python-build-standalone/releases/download' - try: - result = self.command('python-' + source + '-' + route, [uv, 'python', 'install', *isolation, self.versions['python']], env=env) - network.route_health(self.network, 'github', source, route) - return result - except RuntimeError as error: - if not network.retryable(error): - raise - network.event(self.network, category='python', source=source, route=route, action='fallback', reason='network-download-failed') - if not network.connection_failure(error): - break - raise RuntimeError('Python download failed on every route; see retained logs') + def attempt(source, route, retry_mode): + env = network.download_environment( + network.route_environment(self.network, 'github', source, self.env, route), retry_mode) + env['UV_PYTHON_INSTALL_MIRROR'] = custom if source == 'user-python' else network.source_url('github', source, self.env) + '/astral-sh/python-build-standalone/releases/download' + return self.command('python-' + source + '-' + route, + [uv, 'python', 'install', *isolation, self.versions['python']], env=env) + try: + return network.download_command(self.network, 'github', attempt, self.env, category='python', + extra_sources=['user-python'] if custom else [], + notice=lambda message: self.ui.write(message + '\n')) + except network.PackageSourcesError as error: + failure = RuntimeError('Python download failed on every route; see retained logs') + failure.package_attempts = error.attempts + raise failure from error def install_sage(self, uv, python): if self.system == 'Windows': @@ -1528,10 +1568,10 @@ def install_sage(self, uv, python): 'python="cp"+str(sys.version_info.major)+str(sys.version_info.minor), ' 'torch=str(torch.__version__),cuda=torch.version.cuda,cxx11_abi=torch.compiled_with_cxx11_abi(), ' 'machine=platform.machine(),glibc=platform.libc_ver())))') - identity = json.loads(subprocess.check_output([str(python), '-c', code], env=self.env, text=True)) + identity = json.loads(subprocess_output.output_python([str(python), '-c', code], env=self.env)) identity.update(arch='.'.join(map(str, self.plan['inventory']['hardware']['capability'])), - source=self.spec['sageattention']['commit'], compiler=subprocess.check_output( - ['g++', '--version'], text=True).splitlines()[0]) + source=self.spec['sageattention']['commit'], compiler=subprocess_output.output_external( + ['g++', '--version']).splitlines()[0]) wheel_dir = Path(self.plan['wheel_cache']) / wheel_key(identity) manifest = wheel_dir / 'manifest.json' record = json.loads(manifest.read_text(encoding='utf-8')) if manifest.is_file() else None @@ -1556,7 +1596,7 @@ def install_sage(self, uv, python): save(stamp, identity) header_code = ('from pathlib import Path; import site; ' 'print(":".join(str(p) for s in site.getsitepackages() for p in (Path(s)/"nvidia").glob("*/include")))') - headers = subprocess.check_output([str(python), '-c', header_code], env=self.env, text=True).strip() + headers = subprocess_output.output_python([str(python), '-c', header_code], env=self.env).strip() build = self.plan['build'] build_env = dict(self.env, CUDA_HOME=str(toolkit), PATH=str(toolkit / 'bin') + os.pathsep + self.env['PATH'], CPATH=headers, TORCH_CUDA_ARCH_LIST=identity['arch'], MAX_JOBS=str(build['jobs']), @@ -1799,6 +1839,7 @@ def execute(self): require_int8_kernels(self.plan, report) self.mark_unfinished() self.ui.phase('Prepare download tools', 0, 8) + prepare_step(self.ui, 'Preparing download tools') uv = self.prepare_tools() self.env['FREEVIDEO_UV'] = str(uv) from .install_schedule import run @@ -1814,6 +1855,7 @@ def publish(): weighted['shown'] = done = max(weighted['shown'], 1 + units) self.ui.phase(phase, done, total) def scheduling(name, status, completed, count): + prepare_end(self.ui) # The first real task shows from here on. with self.state_lock: self.state.setdefault('schedule', {})[name] = dict(status=status, after=tasks[name]['after'], exclusive_writers=tasks[name]['writes'], epoch=time.time()) @@ -1943,6 +1985,16 @@ def run_prefetch(value, ui): def main(argv=None): + shown = [] + try: + return run_main(argv, shown) + finally: + # An interrupt or an unexpected error anywhere must not leave the step running on the card. + for ui in shown: + prepare_end(ui, False) + + +def run_main(argv, shown): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--root', type=Path, default=Path(os.environ.get('FREEVIDEO_HOME', DEFAULT_ROOT))) parser.add_argument('--gpu', help='Physical nvidia-smi index or full GPU UUID') @@ -2011,6 +2063,7 @@ def main(argv=None): parser.error('--prefetch-only needs --prepared-format int8 or fp8') ui = TerminalUI(platform.system() + ' setup', plain=args.plain, no_color=args.no_color, verbose=args.verbose, show_location=args.verbose) + shown.append(ui) try: from .local_models import progress_output if platform.system() == 'Windows' and not args.plan: @@ -2018,27 +2071,34 @@ def main(argv=None): def curl_progress(message): print(json.dumps(message), flush=True) os.environ['FREEVIDEO_CURL'] = execution_paths.execution_path(ensure(args.root, progress=curl_progress)) - value = plan(args, local_progress=progress_output if os.environ.get('FREEVIDEO_UI_EVENTS') == '1' or not args.json else None) + value = plan(args, local_progress=progress_output if os.environ.get('FREEVIDEO_UI_EVENTS') == '1' or not args.json else None, + stage=None if args.plan else lambda *step: prepare_step(ui, *step)) except (OSError, RuntimeError, ValueError, subprocess.CalledProcessError) as error: + prepare_end(ui, False) print('Preflight failed: ' + str(error), file=sys.stderr) return 1 print(json.dumps(value, indent=2)) if args.json else display(value, ui, verbose=args.verbose) if args.plan: return 1 if value['errors'] else 0 if value['errors']: + prepare_end(ui, False) return 1 try: accepted = confirmed(args, value, ui=ui) except KeyboardInterrupt: + prepare_end(ui, False) print('\nCancelled. No engine or model installation was started.') return 130 except ValueError as error: + prepare_end(ui, False) print(str(error), file=sys.stderr) return 1 if not accepted: + prepare_end(ui, False) print('Cancelled. No engine or model installation was started.') return 0 if args.prefetch_only: + prepare_end(ui) return run_prefetch(value, ui) try: installer_class = Installer @@ -2046,6 +2106,7 @@ def curl_progress(message): from .macos_setup import Installer as installer_class installer = installer_class(value, ui) except BlockingIOError: + prepare_end(ui, False) print('Setup, testing or generation is using this installation/GPU lock. Retry after it finishes.', file=sys.stderr) from .locking import lock_holders owners = lock_holders([Path(value['root']) / 'setup.lock', os.environ.get('FREEVIDEO_LOCK_PATH', str(Path(value['root']) / 'engine.lock'))]) @@ -2053,6 +2114,7 @@ def curl_progress(message): print('Processes with open lock handles: %s. Stop the old task before retrying; do not delete lock files.' % ', '.join(map(str, owners)), file=sys.stderr) return 1 except (OSError, ValueError) as error: + prepare_end(ui, False) print('Setup could not start: ' + str(error), file=sys.stderr) return 1 def interrupted(signum, frame): @@ -2061,10 +2123,13 @@ def interrupted(signum, frame): try: ui.start(installer.run_dir) installer.start_monitor() + if value.get('device_backend') == 'mps': + prepare_end(ui) # The Mac installer does not schedule through execute() above. installer.execute() installer.state['status'] = 'complete' except BaseException as error: installer.state.update(status='failed', error='%s: %s' % (type(error).__name__, error)) + prepare_end(ui, False) ui.phase('Setup interrupted' if isinstance(error, KeyboardInterrupt) else 'Setup failed', ui.done, ui.total) finally: try: diff --git a/freevideo_engine/comfy_environment.py b/freevideo_engine/comfy_environment.py index 34d4443c..4b0ea9e7 100644 --- a/freevideo_engine/comfy_environment.py +++ b/freevideo_engine/comfy_environment.py @@ -34,4 +34,5 @@ def isolated_environment(root, source, environ=None): env.pop(name, None) env.update(FREEVIDEO_HOME=str(root), FREEVIDEO_PROXY_MODE=proxy_mode, PYTHONPATH=str(source), PYTHONNOUSERSITE='1', PYTHONUTF8='1', PYTHONIOENCODING='utf-8', NO_COLOR='1', FREEVIDEO_UI_EVENTS='1') - return env + from .triton_compat import temporary_environment + return temporary_environment(root, env) diff --git a/freevideo_engine/comfy_launcher.py b/freevideo_engine/comfy_launcher.py index b9093477..406ec782 100644 --- a/freevideo_engine/comfy_launcher.py +++ b/freevideo_engine/comfy_launcher.py @@ -19,6 +19,9 @@ from .setup_progress import number, progress_text +FIREWALL_HINT = ('If Windows asks about the firewall the first time, choose Allow.', 'Windows 首次询问防火墙权限时,请选择“允许”。') + + class Launcher: def __init__(self, window, source=None, controller=None, update_client=None): import tkinter as tk @@ -52,6 +55,9 @@ def __init__(self, window, source=None, controller=None, update_client=None): self.error_text = '' self.python = tk.StringVar(value=settings.get('python', '')) self.url = tk.StringVar(value=settings.get('url', 'http://127.0.0.1:8188')) + self.lan_access = tk.BooleanVar(value=settings.get('lan_access', False)) + self.lan_message = tk.StringVar() + self.access_link = tk.StringVar() self.token = tk.StringVar() # Never stored in settings, commands or receipts. self.separate = tk.BooleanVar(value=settings.get('separate', False)) self.repair, self.accept = tk.BooleanVar(), tk.BooleanVar() @@ -73,6 +79,7 @@ def __init__(self, window, source=None, controller=None, update_client=None): self.terminal_operation = None self.options = None self.options_inputs = [] + self.listen_entry = None self.download_dialog = None self.download_probes = {} if self.controller.restore(dict(settings, engine=self.engine.get())): @@ -92,7 +99,7 @@ def __init__(self, window, source=None, controller=None, update_client=None): if update_client is not None: from .launcher_update_ui import UpdateUI self.updates = UpdateUI(self, update_client, self.update_row) - for variable in (self.mode, self.destination, self.comfy, self.engine, self.models, self.model_method, self.python, self.url, self.token, self.separate, self.repair): + for variable in (self.mode, self.destination, self.comfy, self.engine, self.models, self.model_method, self.python, self.url, self.lan_access, self.token, self.separate, self.repair): variable.trace_add('write', self.changed) window.protocol('WM_DELETE_WINDOW', self.close) window.bind('', self.cancel_timers, add='+') @@ -128,7 +135,7 @@ def language(self): self.queue_save() def values(self): - return dict({name: getattr(self, name).get() for name in ('comfy', 'destination', 'engine', 'models', 'python', 'url', 'token', 'separate', 'repair')}, + return dict({name: getattr(self, name).get() for name in ('comfy', 'destination', 'engine', 'models', 'python', 'url', 'lan_access', 'token', 'separate', 'repair')}, new_comfy=self.mode.get() == 'new', model_dirs=list(self.model_dirs), model_method=self.model_method.get()) def model_download_link(self, component): @@ -185,6 +192,8 @@ def changed(self, *_): if self.loading_settings: return self.queue_save() + if self.launch_selection and _ and _[0] in (str(self.url), str(self.lan_access)): + return # Apply a valid listening change after the saved draft settles. if self.controller.busy: return self.controller.selection = None @@ -229,14 +238,31 @@ def show_options(self): if name in ('engine', 'models', 'python'): browse = self.button(row, '…', '…', lambda n=name: self.browse(getattr(self, n), n == 'python')) browse.pack(side='right', padx=(6, 0)); self.options_inputs.append(browse) + if name == 'url': + self.listen_entry = entry + self.label(body, "With 0.0.0.0 or this computer's LAN IP, devices on the same network can open FreeVideo without the access link.", + '填 0.0.0.0 或本机的局域网 IP 后,同一网络中的设备不需要访问链接就能打开 FreeVideo。', + style='Muted.TLabel', wraplength=580).pack(fill='x') + if sys.platform == 'win32': + self.label(body, FIREWALL_HINT[0], FIREWALL_HINT[1], style='Muted.TLabel', wraplength=580).pack(fill='x') for variable, en, zh in ( + (self.lan_access, 'LAN access', '局域网访问'), (self.separate, 'Use a separate ComfyUI environment', '使用独立 ComfyUI 环境'), (self.repair, 'Repair the engine environment', '修复引擎环境')): - if fixed_environment: + if fixed_environment and variable is not self.lan_access: continue check = self.ttk.Checkbutton(body, variable=variable, text=self.t(en, zh)) self.labels.append((check, en, zh)); check.pack(anchor='w', pady=(3, 3)) self.options_inputs.append(check) + if variable is self.lan_access: + self.ttk.Label(body, textvariable=self.lan_message, style='Muted.TLabel').pack(fill='x') + self.label(body, 'When on, other devices on the same network can open FreeVideo with the link below. Anyone with the link can use it, so keep it private.', + '开启后,同一网络中的其他设备可以用下面的链接打开 FreeVideo。拿到链接的人都能使用,请勿公开。', + style='Muted.TLabel', wraplength=580).pack(fill='x') + if sys.platform == 'win32': + self.label(body, FIREWALL_HINT[0], FIREWALL_HINT[1], style='Muted.TLabel', wraplength=580).pack(fill='x') + self.ttk.Entry(body, textvariable=self.access_link, state='readonly').pack(fill='x', pady=(8, 0)) + self.button(body, 'Copy link', '复制链接', self.copy_access_link).pack(anchor='w') self.button(body, 'Done', '完成', dialog.destroy).pack(side='bottom', anchor='e', pady=(10, 0)) compatibility = self.button(body, 'Compatibility settings', '兼容性设置', self.compatibility_settings) compatibility.pack(side='bottom', anchor='w'); self.options_inputs.append(compatibility) @@ -310,6 +336,7 @@ def save_settings(self): from .comfy_launcher_runtime import local_url try: selected['url'] = local_url(self.url.get()) + selected['lan_access'] = self.lan_access.get() if self.python.get() and io_path(self.python.get()).is_file(): selected['python'] = self.python.get() except ValueError: @@ -320,6 +347,12 @@ def save_settings(self): written, failures = self.settings_store.write(self.values(), 'zh' if self.zh else 'en', self.launch_selection, self.saved_setup) self.settings_path = written[0] + if (self.launch_selection and row.get('status') == 'open' and not self.controller.busy + and self.launch_selection != self.controller.selection): + if self.launch_selection['url'] != self.controller.selection['url']: + self.browser_attempted = False + self.controller.run('configure_access', dict(url=self.launch_selection['url'], + lan_access=self.launch_selection.get('lan_access') is True)) def queue_save(self): if self.save_timer is not None: @@ -342,6 +375,7 @@ def remember_installation(self, selection): getattr(self, name).set(selection.get(key) or '') self.mode.set('existing') self.separate.set(selection.get('separate', False)) + self.lan_access.set(selection.get('lan_access', False)) self.repair.set(False) finally: self.loading_settings = False @@ -477,6 +511,10 @@ def copy_browser_address(self): self.window.clipboard_clear() self.window.clipboard_append(self.browser_address.get()) + def copy_access_link(self): + self.window.clipboard_clear() + self.window.clipboard_append(self.access_link.get()) + def create_shortcut(self): if self.controller.busy or not self.controller.selection: return @@ -528,7 +566,8 @@ def refresh(self): self.model_list.configure(height=max(2, min(6, len(self.model_dirs)))) self.error_text = '\n'.join(str(e) for e in row.get('errors', [])) if row.get('error'): - self.error_text += ('\n' if self.error_text else '') + str(row['error']) + from .launcher_copy import lan_error + self.error_text += ('\n' if self.error_text else '') + lan_error(row['error'], self.zh) if self.browser_error: self.error_text += ('\n' if self.error_text else '') + self.browser_error if row.get('shortcut', {}).get('status') == 'failed': @@ -553,6 +592,12 @@ def refresh(self): self.launch_paths.configure(text='ComfyUI · ' + selected.get('root', '') + '\n' + self.t('Engine', '引擎') + ' · ' + selected.get('engine', '')) self.browser_address.set(row.get('url', '') if state == 'open' else '') + links = self.controller.access_links + self.lan_message.set(self.t('LAN access has stopped. Turn it on again.', '局域网访问已停止,请重新开启。') + if row.get('lan_status') == 'stopped' else + self.t('No LAN address found. Connect to Wi-Fi or a wired network first.', '未找到局域网地址,请先连接 Wi-Fi 或有线网络。') + if row.get('lan_status') == 'no-address' else '') + self.access_link.set(links[0] if state == 'open' and links else '') if state == 'open': self.browser_controls.pack(fill='x', pady=(16, 0)) else: @@ -586,6 +631,8 @@ def refresh(self): for widget in (self.entry, self.browse_button, self.add_model_button, self.remove_model_button, *self.mode_buttons, *self.options_inputs): if widget.winfo_exists(): widget.state(['disabled'] if busy else ['!disabled']) + if self.listen_entry is not None and self.listen_entry.winfo_exists(): + self.listen_entry.state(['readonly'] if self.lan_access.get() else ['!readonly']) if busy: self.stop_button.pack(side='right', padx=(0, 10)) else: @@ -646,6 +693,8 @@ def show_details(self): self.button(dialog, 'Model licenses', '模型许可证', lambda: webbrowser.open('https://huggingface.co/OpenVDN/vdn-minimax-h3-edge/blob/main/LICENSE')).pack(pady=8) def poll(self): + if not self.controller.busy: + self.controller.poll_access_proxy() if self.poll_timer is not None: self.window.after_cancel(self.poll_timer) self.poll_timer = None @@ -665,7 +714,7 @@ def poll(self): self.window.destroy(); return selected = row.get('selection') if (selected and selected.get('ready') and - (row.get('deployed') or row.get('status') in ('open', 'restart-required')) and + (row.get('deployed') or row.get('lan_status') == 'stopped' or row.get('status') in ('open', 'restart-required')) and row.get('status') != 'running'): if selected != self.launch_selection: self.remember_installation(selected) @@ -677,7 +726,8 @@ def poll(self): self.open_browser() # Include busy separately: the task's final state arrives just before # the thread exits, and the action must become enabled at that boundary. - key = (row.get('status'), self.controller.busy, row.get('error'), id(row.get('plan')), row.get('overall', {}).get('label')) + key = (row.get('status'), self.controller.busy, row.get('error'), id(row.get('plan')), + row.get('overall', {}).get('label'), row.get('lan_status'), tuple(self.controller.access_links)) if key != self.last_state: self.last_state = key if row.get('action') == 'install': diff --git a/freevideo_engine/comfy_launcher_api.py b/freevideo_engine/comfy_launcher_api.py index 5e7296ca..b2a6270d 100644 --- a/freevideo_engine/comfy_launcher_api.py +++ b/freevideo_engine/comfy_launcher_api.py @@ -123,6 +123,8 @@ def register(): root = Path(folder_paths.__file__).resolve().parent info = dict(protocol=1, source=str(source), engine_root=str(installation_root(source)), comfy_root=str(root), data_root=str(Path(folder_paths.base_path).resolve())) + from comfy.cli_args import args + info['listen'] = args.listen from .comfy_console import install try: info['console_log'] = str(install(info['engine_root'])) diff --git a/freevideo_engine/comfy_launcher_runtime.py b/freevideo_engine/comfy_launcher_runtime.py index 2f1d42d4..03c9418b 100644 --- a/freevideo_engine/comfy_launcher_runtime.py +++ b/freevideo_engine/comfy_launcher_runtime.py @@ -6,6 +6,7 @@ """ import errno import hashlib +import ipaddress import json import os from pathlib import Path @@ -37,6 +38,10 @@ # daemon's join is a wall-clock bound even if a server trickles HTTP bytes. SHUTDOWN_CANCEL_SECONDS = 3. SHUTDOWN_REQUEST_SECONDS = .5 +# After this long the card says ComfyUI is still loading; the wait goes on while the process lives, up to +# the limit. The log is no signal: importing Torch and its libraries can print nothing for minutes. +START_WAIT_SECONDS = 180 +START_WAIT_LIMIT_SECONDS = 600 SHUTDOWN_POLL_SECONDS = .1 @@ -300,18 +305,55 @@ def probe_host(descriptor, python=None): def local_url(value): value = value.strip() or 'http://127.0.0.1:8188' parsed = urlsplit(value if '://' in value else 'http://' + value) - if (parsed.scheme != 'http' or parsed.hostname not in ('localhost', '127.0.0.1', '::1') + host = parsed.hostname + try: + ip = ipaddress.ip_address(host) if host != 'localhost' else None + except ValueError: + raise ValueError('Enter localhost or an IP address of this computer, for example http://127.0.0.1:8188.') from None + if (parsed.scheme != 'http' or not host or (ip is not None and ip.is_multicast) or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in ('', '/')): - raise ValueError('Use a local ComfyUI address, for example http://127.0.0.1:8188.') - port = parsed.port or 80 + raise ValueError('Enter localhost or an IP address of this computer, for example http://127.0.0.1:8188.') + port = parsed.port if parsed.port is not None else 80 if not 1 <= port <= 65535: raise ValueError('Invalid ComfyUI port') return urlunsplit(('http', parsed.netloc, '', '', '')) +def loopback_url(value): + """The launcher's browser and control requests always use IPv4 loopback.""" + parsed = urlsplit(value) + return urlunsplit(('http', '127.0.0.1:%d' % (parsed.port or 80), parsed.path, parsed.query, parsed.fragment)) + + +def loopback_host(hostname): + try: + return hostname == 'localhost' or ipaddress.ip_address(hostname).is_loopback + except ValueError: + return False + + +def listen_hosts(hostname): + # A specific interface (including IPv6 loopback) does not accept IPv4 + # loopback. ComfyUI supports a comma-separated list of listening addresses. + if hostname in ('localhost', '127.0.0.1', '0.0.0.0'): + return (hostname,) + return ('127.0.0.1', hostname) + + +def matches_listener(info, url): + """An external server must already listen on the requested interfaces.""" + wanted = urlsplit(url).hostname + hosts = set((info.get('listen') or '').split(',')) + if hosts == {''}: + return wanted in ('localhost', '127.0.0.1', '::1') + hosts = {'127.0.0.1' if host == 'localhost' else host for host in hosts} + wanted = '127.0.0.1' if wanted == 'localhost' else wanted + return wanted in hosts or ('::' if ':' in wanted else '0.0.0.0') in hosts + + def get_json(url, timeout=2): # Proxy settings must never send loopback requests to an external proxy. - with build_opener(ProxyHandler({})).open(Request(url, headers={'Accept': 'application/json'}), timeout=timeout) as reply: + with build_opener(ProxyHandler({})).open(Request(loopback_url(url), headers={'Accept': 'application/json'}), timeout=timeout) as reply: value = reply.read(2 * 1024 * 1024 + 1) if len(value) > 2 * 1024 * 1024: raise ValueError('ComfyUI response is too large') @@ -319,6 +361,7 @@ def get_json(url, timeout=2): def server_info(url): + url = loopback_url(url) try: value = get_json(url + '/freevideo/launcher') if (isinstance(value, dict) and value.get('protocol') == PROTOCOL @@ -339,10 +382,12 @@ def server_info(url): def bind_check(hostname, port): """Raise OSError unless a new server could listen on this local address now.""" - hosts = ('127.0.0.1', '::1') if hostname == 'localhost' else (hostname,) + hosts = ('127.0.0.1', '::1') if hostname == 'localhost' else listen_hosts(hostname) + if hostname in ('0.0.0.0', '::'): + hosts = (*hosts, '127.0.0.1' if hostname == '0.0.0.0' else '::1') for host in hosts: try: - with socket.socket(socket.AF_INET6 if host == '::1' else socket.AF_INET, socket.SOCK_STREAM) as check: + with socket.socket(socket.AF_INET6 if ':' in host else socket.AF_INET, socket.SOCK_STREAM) as check: if sys.platform != 'win32': # Tolerate TIME_WAIT, as ComfyUI does. On Windows this # option would let two servers share the port. @@ -367,7 +412,7 @@ def next_free_address(url, count=12): except OSError: continue candidate = address(parsed.hostname, port) - if server_info(candidate)['status'] == 'offline': + if server_info(loopback_url(candidate))['status'] == 'offline': return candidate return None @@ -402,7 +447,7 @@ def launcher_comfy(selected, info): def queue_busy(url): """Whether ComfyUI is running or holding jobs. An unreadable queue counts as idle.""" try: - value = get_json(url + '/queue', timeout=3) + value = get_json(loopback_url(url) + '/queue', timeout=3) return bool(value.get('queue_running') or value.get('queue_pending')) except (OSError, ValueError, AttributeError): return False @@ -529,9 +574,15 @@ def __init__(self, source, *, child_environment=None): self.state = dict(status='idle') self.selection = self.setup = None self.thread = None + self._access_poll_thread = None self.cancelled = threading.Event() self.server = None self.server_url = None + self.server_address = None + self.access_proxy = None + self.access_configuration = None + self.access_links = [] + self.access_polled = 0 self._server_lock = threading.RLock() self._closed = False self.server_log = None @@ -605,6 +656,7 @@ def owns_server(self): def stop_owned_server(self, *, grace=5): with self._server_lock: + self.stop_access_proxy() if self.server is not None: if self.server.poll() is None and self.server_url: try: @@ -615,6 +667,75 @@ def stop_owned_server(self, *, grace=5): pass processes.stop(self.server, grace=grace) self.server = None + self.server_address = None + + def stop_access_proxy(self): + with self._server_lock: + if self.access_proxy is not None: + processes.stop(self.access_proxy, grace=3) + self.access_proxy = None + self.access_configuration = None + self.access_links = [] + self.state = dict(self.state, lan_status='') + + def _access_stopped(self): + self.stop_access_proxy() + self.selection = dict(self.selection, lan_access=False) + self.state = dict(self.state, selection=dict(self.selection), lan_status='stopped') + + def refresh_access_links(self): + from .comfy_service import token + from .proxy import local_addresses + self.access_polled = time.monotonic() + secret = token(self.selection['engine']) + self.access_links = [address(host, self.selection['lan_port']) + '/?freevideo=launch&token=' + secret + for host in local_addresses()] + self.state = dict(self.state, lan_status='' if self.access_links else 'no-address') + + def check_access_proxy(self): + """Poll child lifetimes and refresh network links without moving ComfyUI.""" + if self.access_proxy is None: + return + if self.server is not None and self.server.poll() is not None: + self._access_stopped() + self.state = dict(self.state, status='ready') + elif self.access_proxy.poll() is not None: + self._access_stopped() + elif time.monotonic() - self.access_polled >= 5: + if self.server is None and server_info(self.selection['url'])['status'] == 'offline': + self._access_stopped() + self.state = dict(self.state, status='ready') + else: + self.refresh_access_links() + + def poll_access_proxy(self): + """Keep network/file probes and bounded process cleanup off the UI thread. + + The check is not a launcher task: the window stays as it is while it + runs. A task started meanwhile waits for it in run(). + """ + poll = self._access_poll_thread + if self._closed or self.busy or (poll is not None and poll.is_alive()): + return + child = getattr(self, 'comfy_controller', None) or self + proxy = child.access_proxy + if proxy is None: + return + exited = ((child.server is not None and child.server.poll() is not None) + or proxy.poll() is not None) + if not exited and time.monotonic() - child.access_polled < 5: + return + def work(): + try: + self.check_access_proxy() + except Exception: + # A failed token/interface read withdraws the unusable link, keeps + # the local page and lets the user turn access on again. + child._access_stopped() + if child is not self: + self._sync_access(False) + self._access_poll_thread = threading.Thread(target=work, name='freevideo-access-poll', daemon=True) + self._access_poll_thread.start() def terminal_sources(self): sources = [] @@ -689,6 +810,8 @@ def restore(self, values): return False self.selection = dict(descriptor, source=str(source), engine=str(engine), python=plain(python), url=local_url(record.get('url') or values.get('url', '')), ready=True, + lan_access=record.get('lan_access', values.get('lan_access', False)) is True, + lan_port=record.get('lan_port'), separate=record.get('separate', values.get('separate', False))) from .desktop_runtime import matching_source self.state = dict(status='ready', engine_update_available=not matching_source(self.source, source), @@ -730,21 +853,31 @@ def run(self, operation, *args): raise RuntimeError('The launcher is closing') if self.busy: raise ValueError('A launcher task is already running') - if operation not in ('inspect', 'install', 'connect', 'launch', 'shortcut'): + if operation not in ('inspect', 'install', 'connect', 'launch', 'shortcut', 'configure_access'): raise ValueError('Unknown launcher action') if operation == 'install' and (self.state.get('status') != 'review' or self.state.get('errors')): raise ValueError('Inspect and review a valid installation plan first') + poll = self._access_poll_thread + if poll is not None: + # A link check in flight finishes first, so it cannot overwrite this task's state. + poll.join() self.cancelled.clear() self.state = dict(self.state, status='running', action=operation, error=None, exception=None, task={}, overall={}, model_groups=[] if operation == 'inspect' else self.state.get('model_groups', [])) - if operation in ('connect', 'launch'): + if operation in ('connect', 'launch', 'configure_access'): self.sections = [('open', 1)] def work(): try: getattr(self, '_' + operation)(*args) except Exception as error: from .diagnostic_resources import exception_details - self.state = dict(self.state, status='cancelled' if self.cancelled.is_set() else 'failed', + opened = self.state.get('status') == 'open' + if operation == 'configure_access': + # The address change may already have stopped the old server. + # A remembered URL alone cannot prove the local page is usable. + opened = bool(self.state.get('url')) and server_info(self.state['url'].split('/?')[0])['status'] == 'freevideo' + status = 'open' if opened else 'failed' + self.state = dict(self.state, status='cancelled' if self.cancelled.is_set() else status, error=str(error), exception=exception_details(error)) self.thread = threading.Thread(target=work, daemon=True) self.thread.start() @@ -822,7 +955,8 @@ def _inspect(self, values): ready = not values.get('sampling_caches') or sampling_installed(machine) except (OSError, ValueError, KeyError): pass - self.selection = dict(descriptor, engine=str(engine), source=str(source), url=url, ready=ready, repair_comfy=repair_comfy) + self.selection = dict(descriptor, engine=str(engine), source=str(source), url=url, ready=ready, + repair_comfy=repair_comfy, lan_access=values.get('lan_access') is True) self.state = dict(self.state, selection=dict(self.selection), host=host) if not ready: extra = values.get('model_dirs', []) @@ -875,7 +1009,7 @@ def _install(self, accepted): if engine_binding.pending(machine, selected['source']): # Whoever started it (another launcher, the freevideo command), a ComfyUI of this installation # that is generating keeps its packages: the switch waits for the job. - if queue_busy(selected['url']): + if queue_busy(loopback_url(self.server_address or self.selection['url'])): raise RuntimeError('ComfyUI is running a job, so the engine update waits. Try again after it finishes; ' 'the job is left running.') self._stop_server_for_setup(selected['url']) @@ -896,7 +1030,7 @@ def _move_address(self, selected): self.port_move = urlsplit(selected['url']).port or 80 selected = self.selection = dict(selected, url=url) self.state = dict(self.state, selection=dict(selected)) - return url, selected + return loopback_url(url), selected def _stop_server_for_setup(self, url): """Setup or source rebinding replaces packages that our ComfyUI's resident worker @@ -906,7 +1040,7 @@ def _stop_server_for_setup(self, url): again. A running job is never stopped.""" if not self.owns_server(): return - if queue_busy(url): + if queue_busy(loopback_url(self.server_address or url)): raise RuntimeError('ComfyUI is running a job. Try again after it finishes; the job is left running.') self.stop_owned_server() @@ -921,8 +1055,75 @@ def _shortcut(self, previous_status='ready'): self.ensure_shortcut() self.state = dict(self.state, status='open' if previous_status == 'open' else 'ready') + def _configure_access(self, values, *, connect=False): + if not self.selection or not self.selection.get('ready'): + raise ValueError('Choose a completed installation first') + enabled = values.get('lan_access') is True + url = local_url(values.get('url') or self.selection['url']) + if enabled and not loopback_host(urlsplit(url).hostname): + url = loopback_url(url) + changed = url != self.selection['url'] or (self.owns_server() and self.server_address != url) + self.selection = dict(self.selection, url=url, lan_access=False) + self.state = dict(self.state, selection=dict(self.selection)) + if changed: + self.stop_access_proxy() + if changed or connect: + self._connect_comfy() + if self.state.get('status') not in ('open', 'running'): + return + if enabled: + self._start_access_proxy() + else: + self.stop_access_proxy() + self.state = dict(self.state, status='open', selection=dict(self.selection)) + @plain_errors def _connect(self): + self._configure_access(dict(lan_access=(self.selection or {}).get('lan_access')), connect=True) + + def _start_access_proxy(self): + from .comfy_service import TOKEN, check_proxy, folder, start_proxy, token + self.selection = selected = dict(self.selection, lan_access=False) + self.state = dict(self.state, selection=dict(selected)) + if not self.owns_server(): + info = server_info(selected['url']) + hosts = (info.get('listen') or '').split(',') + if not hosts or not all(loopback_host(host) for host in hosts): + raise ValueError('The ComfyUI already running is open to the network directly. After its jobs finish, restart it on 127.0.0.1, then turn on LAN access.') + configuration = tuple(selected[k] for k in ('root', 'engine', 'source', 'python', 'url')) + if (self.access_configuration != configuration or + self.access_proxy is not None and self.access_proxy.poll() is not None): + self.stop_access_proxy() + if self.access_proxy is None: + port = selected.get('lan_port') + if type(port) is not int or not 1 <= port <= 65535: + port = (urlsplit(selected['url']).port or 80) + 1 + candidate = next_free_address(address('0.0.0.0', port - 1)) if port <= 65535 else None + if candidate is None: + raise ValueError('The ports for LAN access are in use. Choose another ComfyUI port.') + port = urlsplit(candidate).port + self.selection = selected = dict(selected, lan_port=port) + self.state = dict(self.state, selection=dict(selected)) + secret = token(selected['engine']) + from .windows_ux import external_python + with self._server_lock, external_python(): + if self._closed or self.cancelled.is_set(): + raise RuntimeError('LAN access startup cancelled') + self.access_proxy = start_proxy(selected, listen='0.0.0.0', port=port, + upstream=loopback_url(selected['url']), token_file=folder(selected['engine']) / TOKEN, tls=None) + try: + check_proxy(self.access_proxy, scheme='http', port=port, secret=secret) + if self._closed or self.cancelled.is_set(): + raise RuntimeError('LAN access startup cancelled') + except Exception: + self._access_stopped() + raise + self.access_configuration = configuration + self.selection = dict(selected, lan_access=True) + self.state = dict(self.state, selection=dict(self.selection)) + self.refresh_access_links() + + def _connect_comfy(self): self.state = {key: value for key, value in self.state.items() if key != 'moved_from'} if not self.selection or not self.selection['ready']: raise ValueError('Choose and inspect ComfyUI first') @@ -933,7 +1134,7 @@ def _connect(self): # existing server, as well as the first installation. self.ensure_shortcut() shield_engine(selected['engine']) - url = selected['url'] + url = loopback_url(selected['url']) frontend_error = None managed = bool(selected.get('python') and managed_frontend(selected)) if managed and (managed_python(Path(selected['engine']), Path(selected['root'])) is None @@ -963,7 +1164,19 @@ def _connect(self): node_packages_failed(Path(selected['engine'])) self.stage('open', label='Open ComfyUI') info = server_info(url) - if matches_server(info, selected['root'], selected['engine'], selected['source']): + matched = matches_server(info, selected['root'], selected['engine'], selected['source']) + if matched and not self.owns_server() and not matches_listener(info, selected['url']): + self.state = dict(self.state, status='restart-required', url=url, + error='The ComfyUI already running does not use this address. After its jobs finish, restart it with this address and connect again.') + return + if (self.owns_server() and self.server_address is not None + and self.server_address != selected['url']): + if queue_busy(loopback_url(self.server_address or self.selection['url'])): + raise RuntimeError('A generation is running. Change the address after it finishes.') + self.stop_owned_server() + info = dict(status='offline') + matched = False + if matched: self.attach_console(info) self.stage('open', done=1, label='Ready') self.state = dict(self.state, status='open', url=url + '/?freevideo=launch') @@ -982,14 +1195,14 @@ def _connect(self): self.state = dict(self.state, status='restart-required', url=url, error='ComfyUI is already running. Restart it once to load the installed FreeVideo nodes, then click Connect. Existing jobs are left running.') return - if queue_busy(url): + if queue_busy(loopback_url(self.server_address or self.selection['url'])): self.state = dict(self.state, status='restart-required', url=url, error='ComfyUI is still running a job. Click Connect after it finishes to restart with the update; the job is left running.') return # Our own idle server still runs the previous source: restart it. self.stage('open', label='Restart ComfyUI') self.stop_owned_server() - parsed = urlsplit(url) + parsed = urlsplit(selected['url']) deadline = time.monotonic() + 10 while True: try: @@ -1004,8 +1217,10 @@ def _connect(self): # A program that is not ComfyUI holds the address. url, selected = self._move_address(selected) moves += 1 - parsed = urlsplit(url) + parsed = urlsplit(selected['url']) continue + if error.errno == errno.EADDRNOTAVAIL: + raise ValueError('This address does not belong to this computer. Enter one of its IP addresses or 0.0.0.0.') from error raise ValueError('This port is in use by another application. Set a different local ComfyUI address.') from error if not selected.get('python') or not io_path(selected['python']).is_file(): raise ValueError('ComfyUI Python is missing. Inspect again to prepare a separate environment.') @@ -1019,12 +1234,15 @@ def _connect(self): env.pop('PYTHONPATH', None) env['PYTHONDONTWRITEBYTECODE'] = '1' env.update(PYTHONUNBUFFERED='1', PYTHONUTF8='1', PYTHONIOENCODING='utf-8') + from .proxy import bypass_local + if not loopback_host(parsed.hostname): + env = bypass_local(env, parsed.hostname) env['FREEVIDEO_COMFY_LOG'] = str(self.server_log) from .launcher_bridge import ENV as BRIDGE env.pop(BRIDGE, None) if self.update_bridge: env[BRIDGE] = str(self.update_bridge) - command = [selected['python'], '-u', '-B', str(Path(selected['root']) / 'main.py'), '--listen', parsed.hostname, + command = [selected['python'], '-u', '-B', str(Path(selected['root']) / 'main.py'), '--listen', ','.join(listen_hosts(parsed.hostname)), '--port', str(parsed.port or 80), '--disable-auto-launch'] managed = managed_frontend(selected) if managed: @@ -1062,10 +1280,13 @@ def _connect(self): stdout=log, stderr=subprocess.STDOUT, start_new_session=True, supervise=True) self.server = server self.server_url = url + self.server_address = selected['url'] save(directory / 'launch.json', dict(command=command, pid=server.pid, root=selected['root'], url=url, **context)) self.state = dict(self.state, task=dict(progress=dict(label='Starting ComfyUI', detail='Loading nodes and the web interface'), log=plain(directory))) - deadline = time.monotonic() + 180 - while time.monotonic() < deadline: + waiting_since = time.monotonic() + extended = False + while True: + now = time.monotonic() if self.cancelled.is_set(): with self._server_lock: if self.server is server: @@ -1093,6 +1314,13 @@ def _connect(self): self.state = dict(self.state, moved_from=self.port_move) self.port_move = None return + if now - waiting_since >= START_WAIT_SECONDS: + if now - waiting_since >= START_WAIT_LIMIT_SECONDS: + break + if not extended: + extended = True + self.state = dict(self.state, task=dict(progress=dict( + label='Starting ComfyUI', detail='Still loading. This may take a few more minutes.'), log=plain(directory))) self.cancelled.wait(.5) from .failure_details import startup_failure raise RuntimeError(startup_failure('ComfyUI is still starting or could not load FreeVideo; retry Connect.', directory / 'comfy.log')) diff --git a/freevideo_engine/comfy_service.py b/freevideo_engine/comfy_service.py index 9881096f..81428416 100644 --- a/freevideo_engine/comfy_service.py +++ b/freevideo_engine/comfy_service.py @@ -293,7 +293,10 @@ def start_proxy(record, *, listen, port, upstream, token_file, tls, hop_secret=N env[ENV] = hop_secret for name in ('PYTHONPATH', 'PYTHONHOME', 'VIRTUAL_ENV'): env.pop(name, None) + from .proxy import bypass_local + env = bypass_local(env, listen) log = folder(record['engine']) / 'access-proxy.log' + log.parent.mkdir(parents=True, exist_ok=True) with log.open('ab') as output: return processes.popen(command, cwd=record['engine'], env=env, stdin=subprocess.DEVNULL, stdout=output, stderr=subprocess.STDOUT, start_new_session=True, supervise=True) diff --git a/freevideo_engine/diagnostic_resources.py b/freevideo_engine/diagnostic_resources.py index fdcb190b..7b9d3536 100644 --- a/freevideo_engine/diagnostic_resources.py +++ b/freevideo_engine/diagnostic_resources.py @@ -611,6 +611,15 @@ def sampling_passes(value): preparation = mapping(row.get('refinement_schedule_preparation')) if preparation.get('schedule') == 'community-sigma3-v1': item['schedule_cache'] = numbers(preparation, ('seconds', 'table_cache_hits')) + pressure = mapping(row.get('step_pressure')) + if pressure: + item['step_pressure'] = dict(numbers(pressure, ('planned_head_chunk', 'head_chunk_at_end', 'polls', 'unread', + 'over_polls', 'held_up_layers', 'wide_peak_reserved_bytes')), + narrowed_at_end=pressure.get('narrowed_at_end') is True, + actions=[dict(action=row['action'], **numbers(row, ('step', 'layer', 'budget_bytes', 'usage_bytes', + 'freed_bytes', 'head_chunk_from', 'head_chunk_to', 'layer_seconds'))) + for row in map(mapping, sequence(pressure.get('actions'))[:32]) + if row.get('action') in ('release_idle', 'narrow', 'restore', 'nothing_left')]) item['step_seconds'] = [duration for duration in sequence(row.get('step_seconds'))[:32] if type(duration) in (int, float) and math.isfinite(duration) and duration >= 0] if row.get('device_backend') == 'mps': diff --git a/freevideo_engine/doctor.py b/freevideo_engine/doctor.py index 20939871..ccd492b1 100644 --- a/freevideo_engine/doctor.py +++ b/freevideo_engine/doctor.py @@ -6,12 +6,14 @@ from .hardware import detect, installed_backends from .paths import vdn_root, base_path, checkpoint_path, comfy_root from .locking import LOCK_ENV, runtime_lock -from .kernel_capabilities import identity, package_versions, readiness, receipt_path +from .kernel_capabilities import identity, package_versions, readiness, receipt_path, view_root, temp_root from .monitoring import save -from . import processes +from . import processes, subprocess_output def doctor(*, probe=False, hardware=None, backends=None): + from .triton_compat import own_temporary_environment + own_temporary_environment() versions = package_versions() hardware = hardware or detect() backends = set(installed_backends() if backends is None else backends) @@ -29,8 +31,8 @@ def doctor(*, probe=False, hardware=None, backends=None): for backend in [*sorted(backends), *linear]: command = [sys.executable, '-m', 'freevideo_engine.probe', backend] try: - child = processes.run(command, capture_output=True, text=True, timeout=180, - env=env, pass_fds=(descriptor,)) + child = subprocess_output.run_python(command, capture_output=True, timeout=180, env=env, + pass_fds=(descriptor,), runner=processes.run) try: row = json.loads(child.stdout.strip().splitlines()[-1]) if not isinstance(row, dict) or row.get('backend') != backend: @@ -44,13 +46,16 @@ def doctor(*, probe=False, hardware=None, backends=None): row['warnings'] = child.stderr[-8000:] except subprocess.TimeoutExpired as error: def tail(value): - return (value.decode('utf-8', errors='replace') if isinstance(value, bytes) else value or '')[-8000:] + return (subprocess_output.decode_output(value, 'utf-8') or '')[-8000:] row = {'backend': backend, 'status': 'error', 'error': 'Kernel probe exceeded 180 seconds', 'stdout_tail': tail(error.stdout), 'stderr_tail': tail(error.stderr)} except OSError as error: row = {'backend': backend, 'status': 'error', 'error': 'Could not start kernel probe: ' + str(error)} result['kernel_probes'].append(row) - result.update(readiness(result['kernel_probes']), identity=identity(hardware, versions)) + result.update(readiness(result['kernel_probes']), identity=identity(hardware, versions), + view_root=view_root()) + if temp_root(): + result['temp_root'] = temp_root() # absent unless the installation's own temporary folder is used result['receipt'] = str(receipt_path()) result['validation'] = 'Small kernels executed on the listed GPU; this does not validate full-video speed, quality or capacity.' save(receipt_path(), result) diff --git a/freevideo_engine/engine_binding.py b/freevideo_engine/engine_binding.py index b5480b39..46fabc5b 100644 --- a/freevideo_engine/engine_binding.py +++ b/freevideo_engine/engine_binding.py @@ -9,6 +9,7 @@ from urllib.parse import urlsplit from urllib.request import url2pathname +from . import subprocess_output from .monitoring import save from .long_paths import io_path, resolved, plain, plain_text, plain_errors from .runtime_alias import checked_real_path @@ -110,7 +111,7 @@ def _bind(uv, python, source, run): command = [str(uv), 'pip', 'install', '--python', str(python), '--no-deps', '--no-build-isolation', '--reinstall-package', 'freevideo-engine', '-e', str(source)] try: - result = run(command, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=180) + result = subprocess_output.run_external(command, stdin=subprocess.DEVNULL, capture_output=True, timeout=180, runner=run) except (OSError, subprocess.SubprocessError) as error: raise RuntimeError('Editable install failed for %s: %s\n%s' % (python, error, _tail(error))) from error if result.returncode: @@ -118,9 +119,9 @@ def _bind(uv, python, source, run): (python, result.returncode, _tail(result))) uv_tail = _tail(result) try: - result = run([str(python), '-I', '-B', '-c', + result = subprocess_output.run_python([str(python), '-I', '-B', '-c', 'import freevideo_engine,sys;print(freevideo_engine.__file__)'], - stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=30) + stdin=subprocess.DEVNULL, capture_output=True, timeout=30, runner=run) lines = (result.stdout or '').strip().splitlines() imported = Path(lines[-1]) if lines else None if (result.returncode or imported is None or not imported.is_absolute() diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index 0d93d7c3..c9f18d9a 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -463,8 +463,9 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep detail = causes[-1] if causes else next((line for line in reversed(lines) if not line.startswith(('File ', 'Traceback', '^', 'All files retained.', 'Rerun the same setup command'))), '') detail = re.sub(r'^(?:ValueError|RuntimeError|OSError):\s*', '', detail) + from .launcher_copy import lan_error return dict(title='暂时未能完成' if zh else 'Couldn’t complete this step', - detail=detail, action='', kind='unknown') + detail=lan_error(detail, zh), action='', kind='unknown') def _read(path, limit, *, tail=False): diff --git a/freevideo_engine/gpu_budget.py b/freevideo_engine/gpu_budget.py index efb1bc49..83a7420b 100644 --- a/freevideo_engine/gpu_budget.py +++ b/freevideo_engine/gpu_budget.py @@ -278,6 +278,20 @@ def device_free(self): self._error(error) return self.device_headroom() + def local_memory(self): + """This process's local budget and usage as the driver reports them now, or None.""" + if self.reader is None: + return None + try: + local = self.reader.sample().get('local') + except (OSError, RuntimeError, AttributeError, ValueError) as error: + self._error(error) + return None + if (not isinstance(local, dict) or type(local.get('budget_bytes')) is not int + or type(local.get('usage_bytes')) is not int or local['budget_bytes'] <= 0): + return None + return dict(budget_bytes=local['budget_bytes'], usage_bytes=local['usage_bytes']) + def _settle(self, workspace): """Wait for a driver budget that again covers `workspace` twice in a row.""" deadline = self.clock() + self.settle_seconds diff --git a/freevideo_engine/hardware.py b/freevideo_engine/hardware.py index 22a90140..49b1e988 100644 --- a/freevideo_engine/hardware.py +++ b/freevideo_engine/hardware.py @@ -177,8 +177,9 @@ def detect(): import json import subprocess import sys + from . import subprocess_output code = 'import json; from freevideo_engine.hardware import _detect_local; print(json.dumps(_detect_local().to_dict()))' - result = subprocess.run([sys.executable, '-c', code], capture_output=True, text=True) + result = subprocess_output.run_python([sys.executable, '-c', code], capture_output=True) if result.returncode: error = RuntimeError('Hardware detection failed: ' + result.stderr[-2000:]) # The probe imports torch; a damaged library can end it without a word @@ -200,6 +201,7 @@ def detect(): def _detect_local(): from .system import system_memory, nvidia_smi + from . import subprocess_output import subprocess import torch if not torch.cuda.is_available(): @@ -216,7 +218,7 @@ def _detect_local(): query = [nvidia_smi(), '--query-gpu=driver_version', '--format=csv,noheader'] if uuid: query += ['--id=' + uuid] - driver = subprocess.check_output(query, text=True, stderr=subprocess.DEVNULL, timeout=5).strip().splitlines()[0] + driver = subprocess_output.output_external(query, stderr=subprocess.DEVNULL, timeout=5).strip().splitlines()[0] except (OSError, RuntimeError, subprocess.SubprocessError, IndexError): pass return Hardware(torch.cuda.get_device_name(index), torch.cuda.get_device_capability(index), diff --git a/freevideo_engine/install.py b/freevideo_engine/install.py index 2cd38902..73385e8f 100644 --- a/freevideo_engine/install.py +++ b/freevideo_engine/install.py @@ -8,7 +8,7 @@ import time from . import execution_paths from .paths import vdn_root -from . import network +from . import network, subprocess_output def install(root=None, *, packages=False): @@ -16,8 +16,8 @@ def install(root=None, *, packages=False): spec = dependencies['vdn'] root = (root or vdn_root()).expanduser().resolve() network.clone(root, spec['url'], spec['commit']) - head = execution_paths.check_output(['git', 'rev-parse', 'HEAD'], cwd=root, text=True).strip() - dirty = execution_paths.check_output(['git', 'status', '--porcelain', '--untracked-files=no'], cwd=root, text=True) + head = subprocess_output.output_external(['git', 'rev-parse', 'HEAD'], cwd=root).strip() + dirty = subprocess_output.output_external(['git', 'status', '--porcelain', '--untracked-files=no'], cwd=root) if head != spec['commit'] or dirty: raise RuntimeError('Existing VDN checkout does not match the clean pinned dependency; use another --vdn-root.') diffusers = prepare_diffusers(root, dependencies) @@ -30,8 +30,10 @@ def install_packages(env, source): # support; avoid pip's optional PySocks dependency during bootstrap. command = ([env['FREEVIDEO_UV'], 'pip', 'install', '--python', sys.executable] if env.get('FREEVIDEO_UV') else [sys.executable, '-m', 'pip', 'install']) - result = execution_paths.run([*command, '-e', str(diffusers), '--no-deps'], - env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + # uv is a native program; pip runs in this interpreter's own Python. + run = subprocess_output.run_external if env.get('FREEVIDEO_UV') else subprocess_output.run_python + result = run([*command, '-e', str(diffusers), '--no-deps'], + env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) print(result.stdout, flush=True) if result.returncode: raise RuntimeError(result.stdout[-2500:]) @@ -56,14 +58,14 @@ def prepare_diffusers(root, dependencies): raise RuntimeError('Expected the two pinned VDN Diffusers patches') for path in patches: execution_paths.run(['git', 'apply', '--index', str(path)], cwd=checkout, check=True) - tree = execution_paths.check_output(['git', 'write-tree'], cwd=checkout, text=True).strip() + tree = subprocess_output.output_external(['git', 'write-tree'], cwd=checkout).strip() if tree != spec['patched_tree']: raise RuntimeError('Patched Diffusers source tree does not match the tested dependency') execution_paths.run(['git', '-c', 'user.name=VDN Engine dependency setup', '-c', 'user.email=engine@localhost', 'commit', '--quiet', '-m', 'Apply pinned OpenVDN MiniMax H3 patches'], cwd=checkout, check=True) checkout.rename(target) - tree = execution_paths.check_output(['git', 'rev-parse', 'HEAD^{tree}'], cwd=target, text=True).strip() - dirty = execution_paths.check_output(['git', 'status', '--porcelain', '--untracked-files=no'], cwd=target, text=True) + tree = subprocess_output.output_external(['git', 'rev-parse', 'HEAD^{tree}'], cwd=target).strip() + dirty = subprocess_output.output_external(['git', 'status', '--porcelain', '--untracked-files=no'], cwd=target) if tree != spec['patched_tree'] or dirty: raise RuntimeError('Existing Diffusers differs from the clean tested tree; use another VDN dependency directory.') return target diff --git a/freevideo_engine/kernel_capabilities.py b/freevideo_engine/kernel_capabilities.py index 731b96a9..dec6b940 100644 --- a/freevideo_engine/kernel_capabilities.py +++ b/freevideo_engine/kernel_capabilities.py @@ -7,6 +7,7 @@ from .hardware import installed_backends from .paths import data_root +from .triton_compat import temp_root PACKAGES = ('torch', 'triton', 'triton-windows', 'diffusers', 'sageattention', 'flash-attn', 'flash-attn-4') COMPUTE_FILES = ('attention.py', 'backends/__init__.py', 'backends/base.py', 'backends/cuda.py', @@ -47,9 +48,23 @@ def receipt_path(): return data_root() / 'kernel-capabilities.json' +def view_root(environ=None): + """The short folder this process runs the installation from; None from the installation's own.""" + from . import launch_view + view = launch_view.current(environ) or launch_view.current() + return str(view.root) if view is not None and view.used else None + + LINEAR_PROBES = ('linear', 'linear-int8') +def needs_path_recheck(receipt, environ=None): + """Retry failed checks once after changing the installation or temporary view.""" + return (not readiness(receipt['kernel_probes'])['ready'] + and (receipt.get('view_root') != view_root(environ) + or receipt.get('temp_root') != temp_root(environ))) + + def readiness(rows): passed = {row['backend'] for row in rows if row.get('status') == 'complete'} attention = sorted(passed - set(LINEAR_PROBES)) @@ -89,6 +104,10 @@ def available_backends(hardware, *, discovered=None, probe_missing=True): """ discovered = set(installed_backends() if discovered is None else discovered) receipt = matching_receipt(hardware, discovered) + if receipt is not None and probe_missing and needs_path_recheck(receipt): + # They failed while the installation ran from another folder, and compilers take some + # paths only as that folder spells them. What passed is kept wherever it ran from. + receipt = None if receipt is None: if not probe_missing: return discovered diff --git a/freevideo_engine/launcher/Main.qml b/freevideo_engine/launcher/Main.qml index 3a1b1b40..1d05b042 100644 --- a/freevideo_engine/launcher/Main.qml +++ b/freevideo_engine/launcher/Main.qml @@ -13,6 +13,7 @@ ApplicationWindow { property bool settingsOpen: false property bool terminalOpen: false property bool confirmArchives: false // the in-place "delete the imported ZIPs?" step + property bool archivesExpanded: false property string settingsTab: "downloads" property string modelInfo: "video" property bool modelInfoOpen: false @@ -21,6 +22,7 @@ ApplicationWindow { property bool releaseConfirm: false property bool accepted: false property bool manualUpdate: false + readonly property bool updateDialogWanted: !s.busy && (s.update.remind || manualUpdate) property bool releaseNotesOpen: false property bool licensesOpen: false property string previousStatus: "" @@ -836,6 +838,19 @@ ApplicationWindow { FButton { text: t("Copy address", "复制地址"); onClicked: backend.copy(s.url) } FButton { text: t("Show terminal", "查看终端"); flat: true; onClicked: terminalOpen = true } } + Repeater { + model: (s.access_links || []).slice(0, 1) + delegate: ColumnLayout { + required property string modelData + Layout.fillWidth: true; spacing: 6 + FText { objectName: "lanAccessLinkLabel"; text: t("LAN access", "局域网访问"); color: theme.muted; font.pixelSize: theme.micro } + RowLayout { + Layout.fillWidth: true; spacing: 8 + FField { objectName: "lanAccessLink"; Layout.fillWidth: true; readOnly: true; text: modelData; onTextChanged: cursorPosition = 0; Component.onCompleted: cursorPosition = 0 } + FButton { objectName: "copyLanAccessLink"; text: t("Copy link", "复制链接"); onClicked: backend.copy(modelData) } + } + } + } } FCard { Layout.fillWidth: true; padding: 22; spacing: 12 @@ -854,20 +869,7 @@ ApplicationWindow { } FText { objectName: "oldVersions"; visible: !!s.old_versions; text: "✓ " + s.old_versions; color: theme.success; font.pixelSize: theme.micro; Layout.fillWidth: true } FText { objectName: "copiedModels"; visible: !!s.copied_models; text: s.copied_models || ""; color: theme.muted; font.pixelSize: theme.micro; wrapMode: Text.WordWrap; Layout.fillWidth: true } - RowLayout { - visible: !!s.archives && !confirmArchives; spacing: 12; Layout.fillWidth: true - FText { objectName: "archivesOffer"; text: s.archives || ""; color: theme.muted; font.pixelSize: theme.micro; wrapMode: Text.WordWrap; Layout.fillWidth: true } - FButton { objectName: "deleteArchives"; implicitWidth: contentItem.implicitWidth + leftPadding + rightPadding; Layout.rightMargin: -rightPadding; text: t("Delete ZIP files", "删除压缩包"); flat: true; implicitHeight: theme.heightSm - 2; font.pixelSize: theme.micro + 1; enabled: !s.busy; onClicked: confirmArchives = true } - } - // The user's own files, and not recoverable: confirm in place, no dialog. - RowLayout { - visible: !!s.archives && confirmArchives; spacing: 8; Layout.fillWidth: true - FText { objectName: "archivesConfirm"; text: s.archives_confirm || ""; color: theme.text; font.pixelSize: theme.micro; wrapMode: Text.WordWrap; Layout.fillWidth: true } - FButton { objectName: "confirmDeleteArchives"; text: t("Delete", "删除"); danger: true; implicitHeight: theme.heightSm - 2; font.pixelSize: theme.micro + 1; enabled: !s.busy; onClicked: { confirmArchives = false; backend.action("delete_archives", false) } } - FButton { objectName: "cancelDeleteArchives"; implicitWidth: contentItem.implicitWidth + leftPadding + rightPadding; Layout.rightMargin: -rightPadding; text: t("Cancel", "取消"); flat: true; implicitHeight: theme.heightSm - 2; font.pixelSize: theme.micro + 1; onClicked: confirmArchives = false } - } - FText { objectName: "archivesDone"; visible: !!s.archives_done; text: "✓ " + (s.archives_done || ""); color: theme.success; font.pixelSize: theme.micro; Layout.fillWidth: true } - FText { objectName: "archivesFailed"; visible: !!s.archives_failed; text: s.archives_failed || ""; color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } + FText { objectName: "archivesDone"; visible: !!s.archives_toast; text: s.archives_toast || ""; color: theme.success; font.pixelSize: theme.micro; Layout.fillWidth: true } FCheck { visible: s.needs_consent; text: s.consent; checked: accepted; onToggled: accepted = checked; Layout.fillWidth: true } FButton { objectName: "bundleLicensesButton"; visible: s.needs_consent; text: t("View licenses", "查看许可证"); flat: true; implicitHeight: theme.heightSm - 2; leftPadding: 32; font.pixelSize: theme.micro + 1; onClicked: licensesOpen = true } } @@ -939,6 +941,23 @@ ApplicationWindow { } } + FPopup { + id: adalnDialog; objectName: "adalnDialog" + visible: s.adaln_dialog.remind && !settings.visible && !modelDialog.visible && !updateDialog.visible && !(s.archives_dialog && s.archives_dialog.remind) && !s.notice + width: Math.min(480, win.width - 40); closePolicy: Popup.NoAutoClose + contentItem: ColumnLayout { + spacing: 14 + FText { objectName: "adalnDialogTitle"; text: s.adaln_dialog.title; font.pixelSize: theme.section + 2; font.weight: Font.DemiBold; Layout.fillWidth: true } + FText { objectName: "adalnDialogText"; text: s.adaln_dialog.body; color: theme.muted; Layout.fillWidth: true } + RowLayout { + Layout.fillWidth: true; spacing: 8 + FButton { objectName: "adalnLaterButton"; text: s.adaln_dialog.later_text; flat: true; enabled: !s.adaln_dialog.busy; onClicked: backend.action("keep_adaln", false) } + Item { Layout.fillWidth: true } + FButton { objectName: "adalnDeleteButton"; text: s.adaln_dialog.delete_text; primary: true; enabled: !s.adaln_dialog.busy; onClicked: backend.action("delete_adaln", false) } + } + } + } + FPopup { id: settings; objectName: "settingsDialog" visible: settingsOpen; onClosed: settingsOpen = false @@ -954,6 +973,8 @@ ApplicationWindow { ScrollView { id: settingsScroll Layout.fillWidth: true; Layout.fillHeight: true; contentWidth: availableWidth; clip: true + // The list never scrolls sideways; its hidden bar would take the clicks along the bottom edge. + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff ColumnLayout { width: settingsScroll.availableWidth; spacing: 18 ColumnLayout { @@ -1034,6 +1055,22 @@ ApplicationWindow { } FButton { text: t("Version & release notes", "版本与更新说明") + " · " + releaseVersion(currentRelease); flat: true; Layout.fillWidth: true; onClicked: releaseNotesOpen = true } } + FGroup { + Layout.fillWidth: true; title: t("Local network", "局域网") + FSwitch { objectName: "lanAccessSwitch"; Layout.fillWidth: true; text: t("LAN access", "局域网访问"); checked: !!s.form.lan_access; enabled: !s.busy; onToggled: backend.edit("lan_access", checked) } + FText { objectName: "lanAccessMessage"; text: s.lan_message || ""; visible: !!text; color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } + FText { objectName: "lanAccessHint"; text: t("When on, other devices on the same network can open FreeVideo with the link below. Anyone with the link can use it, so keep it private.", "开启后,同一网络中的其他设备可以用下面的链接打开 FreeVideo。拿到链接的人都能使用,请勿公开。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } + FText { objectName: "lanFirewallHint"; visible: Qt.platform.os === "windows"; text: t("If Windows asks about the firewall the first time, choose Allow.", "Windows 首次询问防火墙权限时,请选择“允许”。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } + Repeater { + model: s.access_links || [] + delegate: RowLayout { + required property string modelData + Layout.fillWidth: true; spacing: 8 + FField { Layout.fillWidth: true; readOnly: true; text: modelData; onTextChanged: cursorPosition = 0; Component.onCompleted: cursorPosition = 0 } + FButton { text: t("Copy link", "复制链接"); onClicked: backend.copy(modelData) } + } + } + } FGroup { Layout.fillWidth: true; title: t("Storage", "存储") RowLayout { @@ -1067,6 +1104,27 @@ ApplicationWindow { } } FText { visible: !!s.cleanup.error; text: s.cleanup.error || ""; color: theme.danger; font.pixelSize: theme.micro; Layout.fillWidth: true } + FDivider { objectName: "archivesStorageDivider"; visible: !!s.archives } + RowLayout { + objectName: "archivesStorageRow" + visible: !!s.archives + spacing: 12; Layout.fillWidth: true + ColumnLayout { + Layout.fillWidth: true; spacing: 2 + FText { objectName: "archivesStorageTitle"; text: t("Offline package ZIPs", "离线包压缩包"); Layout.fillWidth: true } + FText { objectName: "archivesStorageOffer"; text: s.archives || ""; color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } + } + FButton { objectName: "deleteArchives"; visible: !confirmArchives; text: s.archives_busy ? t("Deleting…", "正在删除…") : t("Delete", "删除"); implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1; enabled: s.can_delete_archives; onClicked: confirmArchives = true } + } + // The user's own files, and not recoverable: confirm in place. + RowLayout { + visible: !!s.archives && confirmArchives; spacing: 8; Layout.fillWidth: true + FText { objectName: "archivesConfirm"; text: s.archives_confirm || ""; font.pixelSize: theme.micro; Layout.fillWidth: true } + FButton { objectName: "confirmDeleteArchives"; text: t("Delete", "删除"); danger: true; implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1; enabled: s.can_delete_archives; onClicked: { confirmArchives = false; backend.action("delete_archives", false) } } + FButton { objectName: "cancelDeleteArchives"; text: t("Cancel", "取消"); flat: true; implicitHeight: theme.heightSm; enabled: !s.archives_busy; onClicked: confirmArchives = false } + } + FText { objectName: "archivesStorageDone"; visible: !!s.archives_done; text: s.archives_done || ""; color: theme.success; font.pixelSize: theme.micro; Layout.fillWidth: true } + FText { objectName: "archivesStorageFailed"; visible: !!s.archives_failed; text: s.archives_failed || ""; color: theme.danger; font.pixelSize: theme.micro; Layout.fillWidth: true } RowLayout { objectName: "modelStorageRow" visible: ["available", "low-disk", "downloading", "downloaded", "switching", "releasing", "releasable", "complete", "failed"].indexOf(upgrade.status) >= 0 @@ -1107,20 +1165,22 @@ ApplicationWindow { FButton { text: t("Qt notices & licenses ↗", "Qt 组件与许可证 ↗"); flat: true; implicitHeight: theme.heightSm; leftPadding: 4; font.pixelSize: theme.micro; onClicked: backend.link("https://doc.qt.io/qt-6/licenses-used-in-qt.html") } } ColumnLayout { - Layout.fillWidth: true; visible: settingsTab === "advanced"; spacing: 20; enabled: !s.busy && !s.portable + Layout.fillWidth: true; visible: settingsTab === "advanced"; spacing: 20; enabled: !s.busy FGroup { Layout.fillWidth: true; title: t("ComfyUI address", "ComfyUI 地址") - FField { Layout.fillWidth: true; text: s.form.url; onEditingFinished: backend.edit("url", text) } + FField { objectName: "listenAddress"; Layout.fillWidth: true; text: s.form.url; enabled: !s.form.lan_access; onEditingFinished: backend.edit("url", text) } + FText { objectName: "listenAddressHint"; text: t("With 0.0.0.0 or this computer's LAN IP, devices on the same network can open FreeVideo without the access link.", "填 0.0.0.0 或本机的局域网 IP 后,同一网络中的设备不需要访问链接就能打开 FreeVideo。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } + FText { visible: Qt.platform.os === "windows"; text: t("If Windows asks about the firewall the first time, choose Allow.", "Windows 首次询问防火墙权限时,请选择“允许”。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } } FGroup { - Layout.fillWidth: true; title: t("Folders · optional", "目录 · 可选") + Layout.fillWidth: true; title: t("Folders · optional", "目录 · 可选"); enabled: !s.portable FText { text: t("Engine folder", "引擎目录"); font.pixelSize: theme.micro; color: theme.muted } RowLayout { Layout.fillWidth: true; spacing: 8; FField { Layout.fillWidth: true; text: s.form.engine; placeholderText: t("Automatic", "自动选择"); onEditingFinished: backend.edit("engine", text) } FButton { text: t("Browse…", "浏览…"); implicitHeight: theme.height + 4; onClicked: backend.browse("engine") } } FText { text: t("ComfyUI Python", "ComfyUI Python"); font.pixelSize: theme.micro; color: theme.muted; Layout.topMargin: 4 } FField { Layout.fillWidth: true; text: s.form.python; placeholderText: t("Path to python.exe", "python.exe 的路径"); onEditingFinished: backend.edit("python", text) } } FGroup { - Layout.fillWidth: true; title: t("Maintenance", "维护") + Layout.fillWidth: true; title: t("Maintenance", "维护"); enabled: !s.portable FSwitch { Layout.fillWidth: true; text: t("Create a separate ComfyUI environment", "创建独立的 ComfyUI 环境"); checked: s.form.separate; onToggled: backend.edit("separate", checked) } FDivider {} RowLayout { @@ -1242,7 +1302,68 @@ ApplicationWindow { } FPopup { - id: updateDialog; visible: !s.busy && (s.update.remind || manualUpdate) + id: archivesDialog; objectName: "archivesDialog" + visible: s.archives_dialog.remind && !updateDialogWanted && !settingsOpen && !releaseNotesOpen && !modelInfoOpen && !closePending && !s.notice + width: Math.min(480, win.width - 40); closePolicy: Popup.NoAutoClose + onClosed: archivesExpanded = false + height: Math.min(archivesContents.implicitHeight + padding * 2 + archivesActions.implicitHeight + archivesFootnote.implicitHeight + 28, win.height - 48) + contentItem: ColumnLayout { + spacing: 14 + ScrollView { + id: archivesScroll; Layout.fillWidth: true; Layout.fillHeight: true; clip: true; contentWidth: availableWidth + ColumnLayout { + id: archivesContents; width: archivesScroll.availableWidth; spacing: 14 + FText { objectName: "archivesDialogTitle"; text: s.archives_dialog.title; font.pixelSize: theme.section + 2; font.weight: Font.DemiBold; Layout.fillWidth: true } + FText { + objectName: "archivesDialogText"; color: theme.muted; Layout.fillWidth: true + visible: !!s.archives_dialog.body; text: s.archives_dialog.body + } + FButton { objectName: "archivesFilesToggle"; visible: !s.archives_dialog.retry; text: t("Show these files (" + s.archives_dialog.count + ")", "查看这些文件(" + s.archives_dialog.count + " 个)") + (archivesExpanded ? " −" : " +"); flat: true; leftPadding: 0; onClicked: archivesExpanded = !archivesExpanded } + ColumnLayout { + objectName: "archivesFileList"; visible: archivesExpanded && !s.archives_dialog.retry; Layout.fillWidth: true; spacing: 12 + Repeater { + model: s.archives_dialog.groups + delegate: ColumnLayout { + id: archiveGroup + required property var modelData + required property int index + Layout.fillWidth: true; spacing: 8 + FText { + objectName: "archivesFolder-" + archiveGroup.index; text: archiveGroup.modelData.folder + color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true + wrapMode: Text.NoWrap; elide: Text.ElideMiddle; maximumLineCount: 1 + MouseArea { id: folderHover; anchors.fill: parent; hoverEnabled: true; acceptedButtons: Qt.NoButton } + ToolTip { objectName: "archivesFolderTooltip-" + archiveGroup.index; text: archiveGroup.modelData.folder; visible: folderHover.containsMouse; delay: 300 } + } + Repeater { + model: archiveGroup.modelData.files + delegate: RowLayout { + required property var modelData + required property int index + Layout.fillWidth: true; Layout.leftMargin: 12; spacing: 12 + FText { objectName: "archivesFile-" + archiveGroup.index + "-" + index; text: modelData.name; Layout.fillWidth: true; font.pixelSize: theme.micro + 1; wrapMode: Text.WrapAnywhere } + FText { objectName: "archivesFileSize-" + archiveGroup.index + "-" + index; text: modelData.size; font.pixelSize: theme.micro + 1 } + } + } + } + } + } + FText { objectName: "archivesDialogFailure"; visible: s.archives_dialog.retry && !!s.archives_failed; text: s.archives_failed || ""; color: theme.danger; font.pixelSize: theme.micro; Layout.fillWidth: true } + } + } + RowLayout { + id: archivesActions; Layout.fillWidth: true; spacing: 8 + // As the update dialog: the secondary choice on the left, the primary on the right. + FButton { objectName: "archivesKeepButton"; text: s.archives_dialog.keep_text; flat: true; enabled: !s.archives_busy; onClicked: backend.action("keep_archives", false) } + Item { Layout.fillWidth: true } + FButton { objectName: "archivesDeleteButton"; text: s.archives_dialog.delete_text; primary: true; enabled: s.can_delete_archives; onClicked: backend.action("delete_archives_dialog", false) } + } + FText { id: archivesFootnote; objectName: "archivesDialogFootnote"; text: s.archives_dialog.footnote; color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } + } + } + + FPopup { + id: updateDialog; visible: updateDialogWanted && !archivesDialog.visible objectName: "updateDialog" width: Math.min(540,win.width-40); closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside // State changes also close it (an update started from the banner or a diff --git a/freevideo_engine/launcher_copy.py b/freevideo_engine/launcher_copy.py index 7878030a..6e8b991c 100644 --- a/freevideo_engine/launcher_copy.py +++ b/freevideo_engine/launcher_copy.py @@ -15,6 +15,8 @@ ZH = dict([ ('Prepare Python', '准备 Python'), ('Install Python', '安装 Python'), ('Prepare download tools', '准备下载工具'), ('Tsinghua mirror', '清华镜像'), + ('Preparing installation', '准备安装'), ('Checking the environment', '正在检查运行环境'), + ('Testing download sources', '正在测试下载源'), ('Preparing download tools', '正在准备下载工具'), ('GitHub mirror', 'GitHub 镜像'), ('attempt', '正在连接'), ('fallback', '正在切换下载源'), ('route-retry', '正在切换连接'), ('verifying', '正在校验'), ('no-progress', '下载无进展'), @@ -54,6 +56,7 @@ ('Install FreeVideo workflow', '安装 FreeVideo 工作流'), ('Open ComfyUI', '打开 ComfyUI'), ('Starting ComfyUI', '启动 ComfyUI'), ('Loading nodes and the web interface', '加载节点与界面'), + ('Still loading. This may take a few more minutes.', '仍在加载,可能还需要几分钟'), ('Checking configuration', '检查配置'), ('Check installation', '检查安装'), ('Verify bundled files', '校验离线包'), ('Check local files', '检查本地文件'), ('All bundled files verified', '离线包校验完成'), @@ -134,6 +137,25 @@ def source_name(value, zh=False): return SOURCES.get(value, (value, value))[bool(zh)] +LAN_ERRORS = { + 'Enter localhost or an IP address of this computer, for example http://127.0.0.1:8188.': '请填写 localhost 或本机的 IP 地址,例如 http://127.0.0.1:8188。', + 'This address does not belong to this computer. Enter one of its IP addresses or 0.0.0.0.': '这个地址不属于本机。请填写本机的 IP 地址或 0.0.0.0。', + 'The ports for LAN access are in use. Choose another ComfyUI port.': '局域网访问要用的端口已被占用,请换一个 ComfyUI 端口。', + 'A generation is running. Change the address after it finishes.': '正在生成,暂时不能修改地址。请在生成完成后再改。', + 'The ComfyUI already running does not use this address. After its jobs finish, restart it with this address and connect again.': '已在运行的 ComfyUI 没有使用这个地址。请在任务完成后用这个地址重新启动 ComfyUI,再重新连接。', + 'The ComfyUI already running is open to the network directly. After its jobs finish, restart it on 127.0.0.1, then turn on LAN access.': '已在运行的 ComfyUI 正在直接对局域网开放。请在任务完成后改用 127.0.0.1 重新启动它,再开启局域网访问。', + 'LAN access startup cancelled': '已取消开启局域网访问。', +} + + +def lan_error(value, zh=False): + value = str(value) + if zh: + for english, chinese in LAN_ERRORS.items(): + value = value.replace(english, chinese) + return value + + def display(value, zh=False): value = clean(value) if not zh: @@ -161,6 +183,7 @@ def display(value, zh=False): (r'Downloading packages · about (.+) across (\d+) files', r'下载组件 · 约 \1,共 \2 个文件'), (r'about (.+) across (\d+) files', r'约 \1,共 \2 个文件'), (r'(\d+) files / folders scanned', r'已扫描 \1 个文件和目录'), + (r'Testing download sources (\d+ / \d+)', r'正在测试下载源 \1'), (r'(\d+ / \d+) files', r'\1 个文件'), (r'shard (\d+ / \d+)', r'分片 \1'), (r'([\d.]+ MiB) retained', r'已保留 \1'), (r'([\d.]+ GiB) released', r'已释放 \1'), (r'([\d.]+ MiB) downloaded', r'已下载 \1'), diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index 3214463b..3ca99ac6 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -10,7 +10,7 @@ from . import __version__ from . import disk_space -from .comfy_launcher_runtime import Controller, layout, local_url, managed_frontend, new_layout +from .comfy_launcher_runtime import Controller, layout, local_url, loopback_url, managed_frontend, new_layout from .desktop_runtime import launcher_root, materialize_source from .download_settings import Probe, read as download_preferences, speed_text from .launcher_settings import Store, default_language @@ -21,6 +21,7 @@ from .setup_progress import progress_text from .terminal_ui import clean, duration from .model_upgrade import TARGET +from .runtime_alias import checked_real_path # Background launcher release checks while the window stays open. CHECK_SECONDS = 30 * 60 @@ -30,6 +31,15 @@ RECEIPT_POLL_SECONDS = 5 +# package_cleanup.failure_reason codes; the raw error stays in the details. +ARCHIVE_FAILURES = { + 'changed': ('The file changed after the check, so it was kept.', '文件在检查后有改动,已保留。'), + 'busy': ('An installation or a video is in progress. Try again when it finishes.', '有安装或生成正在进行,完成后可以再删除。'), + 'in-use': ('Another program is using it, or this account does not have permission to delete it.', '正被其他程序使用,或当前账户没有删除权限。'), + 'failed': ('It could not be deleted.', '未能删除。'), +} + + class Session: # A launcher started from an offline package (portable_launcher.PortableSession). bundled = False @@ -48,8 +58,12 @@ class Session: page = 'comfy' selected = installed_versions = engine_identity = bundled_identity = None release_details = None + adaln_thread = adaln_result = adaln_found = None + adaln_polled = 0. + adaln_hidden = adaln_retry = False # The source whose earlier copies were removed once ComfyUI ran it. old_versions_retired = None + archives_busy = False def __init__(self, source=None, *, controller=None, store=None, updater=None, smoke=False): from .offline_packages import Importer @@ -69,7 +83,8 @@ def __init__(self, source=None, *, controller=None, store=None, updater=None, sm self.form = dict(comfy='', destination=str(home / 'FreeVideo'), engine='', python='', url='http://127.0.0.1:8188', models='', model_dirs=[], model_method='auto', environment_method='auto', separate=False, repair=False, new_comfy=True, offline_runtime='', offline_models=[], - imported_archives=[], sampling_caches=not saved.get('installation')) + imported_archives=[], kept_archives=[], sampling_caches=not saved.get('installation')) + self.form['lan_access'] = False self.form.update({k: v for k, v in saved.items() if k in self.form}) if 'environment_method' not in saved and self.form['offline_runtime']: self.form['environment_method'] = 'manual' @@ -105,6 +120,14 @@ def __init__(self, source=None, *, controller=None, store=None, updater=None, sm self.page_error = '' self.started = None self.closing = False + self.archives_busy = False + self.archives_from_dialog = False + self.archives_thread = self.archives_result = self.archives_deleting_offer = None + self.archives_failures = [] + self.archives_retry_dialog = False + self.archives_prompt_offer = None + self.archives_freed = 0 + self.archives_toast_until = 0. self.smoke = smoke self.reported_setup = None # An explicit update continues from tick() through update_intent: @@ -165,6 +188,13 @@ def installation_engine(self): return engine def persist(self): + if self.selected and self.form['comfy'] == self.selected.get('root') and self.form['engine'] == self.selected.get('engine'): + try: + self.selected['url'] = local_url(self.form['url']) + if 'lan_access' in self.selected or self.form.get('lan_access') is True: + self.selected['lan_access'] = self.form.get('lan_access') is True + except ValueError: + pass # Save the editable draft while retaining a valid installation. self.store.write(self.form, self.language, self.selected, self.saved_setup) def remember(self, selection, persist=True): @@ -172,6 +202,7 @@ def remember(self, selection, persist=True): self.form.update(comfy=selection['root'], engine=selection['engine'], python=selection.get('python') or '', url=selection['url'], separate=selection.get('separate', False), new_comfy=False, repair=False) + self.form['lan_access'] = selection.get('lan_access') is True if persist: self.persist() @@ -183,10 +214,12 @@ def edit(self, key, value): raise ValueError(self.t('Pause installation before changing the token.', '请先暂停安装,再修改 Token。')) from .hf_auth import validate self.token = validate(str(value)); return - if key not in self.form or self.controller.busy or self.importer.busy or self.cleaner.busy: + if key not in self.form or self.controller.busy or self.importer.busy or self.cleaner.busy or self.archives_busy: return - if key in ('separate', 'repair', 'new_comfy', 'sampling_caches'): + if key in ('separate', 'repair', 'new_comfy', 'sampling_caches', 'lan_access'): value = bool(value) + if key == 'url': + value = local_url(str(value)) if key == 'environment_method' and value not in ('auto', 'manual'): raise ValueError('Unknown environment installation method') if key == 'environment_method' and value == 'manual' and not runtime_packages_supported(): @@ -199,6 +232,23 @@ def edit(self, key, value): return self.repair_request = None self.form[key] = value + if (key in ('url', 'lan_access') and self.selected and not self.form['new_comfy'] + and self.form['comfy'] == self.selected.get('root') and self.form['engine'] == self.selected.get('engine')): + selected = dict(self.controller.selection or self.selected, url=local_url(self.form['url']), + lan_access=self.form.get('lan_access') is True) + previous_status = self.controller.state.get('status') + self.selected = selected + if key == 'url': + self.browser_attempted = False + self.browser_error = self.error = '' + self.persist() + if previous_status == 'open': + self.controller.run('configure_access', dict(url=selected['url'], lan_access=selected['lan_access'])) + else: + self.controller.selection = dict(selected) + self.controller.state = dict(status='ready', selection=dict(selected), engine_update_available=bool( + self.controller.state.get('engine_update_available'))) + return if key in ('new_comfy', 'destination', 'comfy'): # A remembered installation's engine belongs to that installation; # another location installs into its own FreeVideo-engine. @@ -215,7 +265,7 @@ def edit(self, key, value): def use_disk(self, folder): """Move a new installation to another disk, then show the location page to confirm it.""" - if not self.form['new_comfy'] or self.controller.busy or self.importer.busy or self.cleaner.busy: + if not self.form['new_comfy'] or self.controller.busy or self.importer.busy or self.cleaner.busy or self.archives_busy: return self.edit('destination', str(folder)) self.page = 'comfy' @@ -248,7 +298,7 @@ def clear_runtime(self): def import_packages(self, paths): - if self.controller.busy or self.importer.busy or self.cleaner.busy: + if self.controller.busy or self.importer.busy or self.cleaner.busy or self.archives_busy: return if not paths: return @@ -266,6 +316,8 @@ def prepare_offline(self): Imported model packages are always used; the download is for an environment imported without any. """ + if self.archives_busy: + return if self.form['model_method'] == 'auto' and not self.form['offline_models']: self.importer.download(self.form['offline_runtime'], self.source, self.form['sampling_caches']) else: @@ -287,7 +339,7 @@ def activate_offline(self, root): self.persist() def action(self, name, accepted=False): - if self.cleaner.busy: + if self.cleaner.busy or self.archives_busy: return # The model download holds the setup lease; launching and opening # FreeVideo continue, anything that would start setup waits for it. @@ -358,6 +410,10 @@ def action(self, name, accepted=False): self.queue_state = None self._update_engine_when_idle() return + if name == 'keep_adaln': + self.keep_adaln(); return + if name == 'delete_adaln': + self.delete_adaln(); return if name == 'back': self.page = 'comfy' if self.page == 'models' else 'models' return @@ -372,6 +428,10 @@ def action(self, name, accepted=False): self.open_browser(); return if name == 'delete_archives': self.delete_archives(); return + if name == 'delete_archives_dialog': + self.delete_archives(dialog=True); return + if name == 'keep_archives': + self.keep_archives(); return if name == 'shortcut': self.controller.run('shortcut', self.controller.state.get('status')); return if name not in ('primary', 'launch'): @@ -400,7 +460,7 @@ def action(self, name, accepted=False): raise ValueError(self.t('Accept the installation plan and licenses.', '请先同意安装计划及许可证。')) from .monitoring import save save(consent, dict(accepted_at=time.time())) - self.controller.run('launch', {'url': self.form['url']}) + self.controller.run('launch', {'url': self.form['url'], 'lan_access': self.form.get('lan_access') is True}) elif self.page == 'launcher': self.controller.run('launch', {'installation': self.selected}) elif self.page == 'progress' and state == 'review': @@ -419,7 +479,7 @@ def action(self, name, accepted=False): def can_repair(self): """An installed engine this launcher can check and reinstall again.""" return (bool(self.selected) and getattr(self.controller, 'fixed_environment', False) is not True - and not (self.controller.busy or self.importer.busy or self.cleaner.busy or self.closing + and not (self.controller.busy or self.importer.busy or self.cleaner.busy or self.archives_busy or self.closing or self.engine_updating or self.upgrade.active or self.model_switch)) def repair_unavailable(self): @@ -453,7 +513,7 @@ def retry_kind(self): return '' def cleanup_downloads(self, remove=False): - if (self.controller.busy or self.importer.busy or self.closing or self.upgrade.active or self.model_switch + if (self.controller.busy or self.importer.busy or self.archives_busy or self.closing or self.upgrade.active or self.model_switch or self.update_intent or self.engine_updating or self.updater and self.updater.busy): return self.cleaner.start(self.engine_root(), remove=remove) @@ -470,7 +530,7 @@ def upgrade_model(self): """ state = self.upgrade.state if (state.get('status') not in ('available', 'failed', 'releasable') or self.upgrade.busy or self.model_switch - or self.closing or self.controller.busy or self.importer.busy or self.cleaner.busy or self.engine_updating): + or self.closing or self.controller.busy or self.importer.busy or self.cleaner.busy or self.archives_busy or self.engine_updating): return root = self.engine_root() # Started from Settings after "Later": show the card again. @@ -505,7 +565,7 @@ def dismiss_model_upgrade(self): def _tick_model_upgrade(self, row, busy): upgrade = self.upgrade state = upgrade.state - if self.closing or upgrade.busy: + if self.closing or upgrade.busy or self.archives_busy: return if (state.get('status') == 'idle' and self.page == 'launcher' and not busy and row.get('status') in ('open', 'ready')): @@ -760,7 +820,7 @@ def update(self, token='', source='launcher'): """One explicit update: the newest launcher if one is known, otherwise the engine bundled with this launcher. Downloading, waiting for running ComfyUI jobs and restarting continue from tick().""" - if self.controller.busy or self.importer.busy or self.cleaner.busy or self.closing: + if self.controller.busy or self.importer.busy or self.cleaner.busy or self.archives_busy or self.closing: if source == 'browser': self.bridge_pending = True return @@ -840,6 +900,8 @@ def _installation_ready(self): return False def _restart_when_idle(self): + if self.archives_busy: + return row = self.updater.state if row.get('status') != 'ready' or self.update_restarting: return @@ -865,7 +927,7 @@ def _restart_when_idle(self): self.closing = True def _update_engine_when_idle(self): - if self.controller.busy or self.importer.busy or self.upgrade.active or self.model_switch: + if self.controller.busy or self.importer.busy or self.archives_busy or self.upgrade.active or self.model_switch: return busy = self._queue_busy() self.update_waiting = bool(busy) @@ -1082,6 +1144,11 @@ def close(self): if getattr(self, '_closed', False): return try: + # A finished deletion is recorded (and saved) inside the same guarantee: + # a failed save never skips closing the engine. + if self.archives_thread: + self.archives_thread.join() + self._finish_archives() self.persist() finally: self.closing = True @@ -1093,7 +1160,83 @@ def close(self): remove(self.bridge) self._closed = True + # -- AdaLN sources the engine no longer reads: the user decides --------------------- + def adaln_offer(self): + """The background check's standing offer for this installation, or None; read at most every 5 s.""" + now = time.monotonic() + if now - self.adaln_polled < 5: + return self.adaln_found + self.adaln_polled, self.adaln_found = now, None + try: + from . import adaln_reclaim + machine = json.loads((io_path(self.engine_root()) / 'machine.json').read_text(encoding='utf-8')) + offer = adaln_reclaim.offer(machine['cache']) + line = adaln_reclaim.release_line(self.installed_product_version() or adaln_reclaim.installed_release()) + size = sum(offer.get(key, 0) for key in ('bytes', 'tables_bytes')) + if (all(type(offer.get(key, 0)) is int for key in ('bytes', 'tables_bytes')) and size >= 2**30 + and offer.get('manifest') and (line is None or offer.get('declined') != line)): + self.adaln_found = dict(bytes=size, machine=machine, line=line) + except (OSError, ValueError, KeyError, TypeError, AttributeError): + pass + return self.adaln_found + + def adaln_idle(self): + return (self.controller.state.get('status') == 'open' + and not (self.controller.busy or self.importer.busy or self.cleaner.busy or self.closing + or self.update_intent or self.engine_updating or self.update_restarting + or self.updater and self.updater.busy or self.upgrade.busy or self.model_switch) + and self._queue_busy() is False) + + def keep_adaln(self): + """"Later": the question does not come back before the next release line.""" + offer = self.adaln_offer() + if self.adaln_thread is not None or not offer: + return + if offer['line']: + from . import adaln_reclaim + try: + adaln_reclaim.decline(offer['machine']['cache'], offer['line']) + except OSError: + pass + self.adaln_hidden = True + + def delete_adaln(self): + offer = self.adaln_offer() + if self.adaln_thread is not None or not offer or not self.adaln_idle(): + return + machine = offer['machine'] + def work(): + from . import adaln_reclaim + self.adaln_result = adaln_reclaim.apply(machine) + self.adaln_retry = False + self.adaln_thread = threading.Thread(target=work, name='freevideo-adaln-cleanup', daemon=True) + self.adaln_thread.start() + + def adaln_dialog_view(self, error, update): + thread = self.adaln_thread + if thread is not None and not thread.is_alive(): + result, self.adaln_thread, self.adaln_result = self.adaln_result or {}, None, None + thread = None + # Busy GPU: ask again; anything else (deleted, or the conditions stopped holding) ends the question. + self.adaln_retry = result.get('reason') in ('busy', 'stopped') + self.adaln_hidden = not self.adaln_retry + self.adaln_polled, self.adaln_found = 0., None + offer = None if self.adaln_hidden else self.adaln_offer() + remind = bool(offer and self.page == 'launcher' and not error and not update['remind'] + and (thread is not None or self.adaln_idle())) + size = size_text((offer or {}).get('bytes', 0), decimal_sizes=sys.platform == 'darwin') + return dict(remind=remind, busy=thread is not None, + title=self.t('Delete model files no longer in use?', '删除不再使用的模型文件?'), + body=self.t('This frees %s of disk space. If the files are needed later, they will be downloaded again automatically.', + '可释放 %s 磁盘空间。以后如需要,会自动重新下载。') % size, + delete_text=(self.t('Deleting…', '正在删除…') if thread is not None else + self.t('Try again', '再试一次') if self.adaln_retry else self.t('Delete', '删除')), + later_text=self.t('Not now', '暂不删除')) + def tick(self): + if isinstance(self.controller, Controller) and not self.controller.busy: + self.controller.poll_access_proxy() + self._finish_archives() imported = self.importer.state if not self.importer.busy and imported is not self.imported_batch: self.imported_batch = imported @@ -1151,7 +1294,7 @@ def tick(self): if groups: self.model_groups = groups selection = row.get('selection') - if not busy and selection and selection.get('ready') and (row.get('deployed') or row.get('status') in ('open', 'restart-required')): + if not busy and selection and selection.get('ready') and (row.get('deployed') or row.get('lan_status') == 'stopped' or row.get('status') in ('open', 'restart-required')): self.repair_request = None if selection != self.selected: self.remember(selection) @@ -1244,55 +1387,161 @@ def archive_offer(self): return None def identity(path): try: - info = os.stat(io_path(path)) - return path, info.st_size, info.st_mtime_ns + # An alias slot is authenticated before anything below it is read. + info = os.lstat(io_path(checked_real_path(path))) + return path, info.st_size, info.st_mtime_ns, info.st_ino, info.st_dev, info.st_mode, info.st_nlink except OSError: return path, None, None # A ZIP moved, changed or deleted while the launcher is open is noticed on the next view. - key = (engine, tuple(identity(r['path']) for r in self.form['imported_archives'])) + key = (engine, tuple((tuple(r.items()), identity(r['path'])) for r in self.form['imported_archives']), + tuple(identity(str(Path(engine) / name)) for name in ('verified-models.json', 'portable-verified.json'))) if getattr(self, 'archive_view', None) is None or self.archive_view[0] != key: from .package_cleanup import archives self.archive_view = (key, archives(self.form['imported_archives'], engine)) return self.archive_view[1] - def delete_archives(self): + def archives_idle(self): + """Use the engine update's queue check, including its unknown/wait state.""" + return (not (self.controller.busy or self.importer.busy or self.cleaner.busy or self.archives_busy + or self.closing or self.update_intent or self.engine_updating or self.update_restarting + or self.updater and self.updater.busy or self.upgrade.busy or self.model_switch) + and self._queue_busy() is False) + + @staticmethod + def archive_path(path): + """The authenticated real spelling that offers and removals report.""" + try: + return plain(checked_real_path(path)) + except (OSError, KeyError, TypeError, ValueError): + return path + + @classmethod + def archive_identity(cls, row): + return dict(path=cls.archive_path(row['path']), bytes=row['bytes'], mtime_ns=row['mtime_ns']) + + @staticmethod + def archive_size(value): + return size_text(value, decimal_sizes=sys.platform == 'darwin') + + def keep_archives(self): + if self.archives_busy: + return + offer = self.archives_prompt_offer or self.archive_offer() or {} + recorded = [self.archive_identity(row) for row in self.form['imported_archives']] + kept = [row for row in self.form['kept_archives'] if row in recorded] + shown = [self.archive_identity(row) for row in offer.get('files', [])] + self.form['kept_archives'] = kept + [row for row in shown if row not in kept] + self.archives_retry_dialog = False + self.archives_prompt_offer = None + self.persist() + + def delete_archives(self, dialog=False): from .package_cleanup import delete_archives engine = (self.controller.selection or {}).get('engine') - if self.controller.state.get('status') != 'open' or not engine: + if self.controller.state.get('status') != 'open' or not engine or not self.archives_idle(): return - offered = len((self.archive_offer() or {}).get('paths', [])) - freed, removed = delete_archives(self.form['imported_archives'], engine) - self.archives_failed = max(0, offered - len(removed)) - self.form['imported_archives'] = [r for r in self.form['imported_archives'] if r['path'] not in removed] + offer = self.archives_prompt_offer if dialog and self.archives_prompt_offer else self.archive_offer() + if not offer or not offer['paths']: + return + self.archives_busy = True + self.archives_from_dialog = dialog + self.archives_deleting_offer = offer + self.archives_failures = [] + records = [dict(r) for r in self.form['imported_archives']] + def work(): + try: + self.archives_result = delete_archives(records, engine, offered=offer) + except Exception as error: + self.archives_result = (0, [], [dict(path=p, reason='failed', detail=str(error)) for p in offer['paths']]) + self.archives_thread = threading.Thread(target=work, name='freevideo-delete-archives') + self.archives_thread.start() + + def _finish_archives(self): + if self.archives_result is None: + return + freed, removed, failures = self.archives_result + offered = {r['path']: r for r in (self.archives_deleting_offer or {}).get('files', [])} + # Keep the checked size even if the file changed or is no longer eligible. + self.archives_failures = [dict(row, bytes=offered.get(row['path'], {}).get('bytes', 0)) + for row in failures] + self.archives_result = None + self.archives_busy = False + self.archives_retry_dialog = bool(self.archives_failures) and self.archives_from_dialog + if self.archives_retry_dialog: + failed_paths = {r['path'] for r in self.archives_failures} + remaining = [r for r in offered.values() if r['path'] in failed_paths] + self.archives_prompt_offer = dict(bytes=sum(r['bytes'] for r in remaining), files=remaining, + paths=[r['path'] for r in remaining]) + else: + self.archives_prompt_offer = None + self.archives_deleting_offer = None + gone = set(removed) + self.form['imported_archives'] = [r for r in self.form['imported_archives'] + if r['path'] not in gone and self.archive_path(r['path']) not in gone] self.archive_view = None - self.archives_freed = (getattr(self, 'archives_freed', 0) or 0) + freed + self.archives_freed = freed + self.archives_toast_until = time.monotonic() + 3 if freed else 0. self.persist() + def archives_dialog_view(self, offer, idle, error, update): + files = (offer or {}).get('files', []) + kept = self.form['kept_archives'] + new = any(self.archive_identity(row) not in kept for row in files) + remind = (self.page == 'launcher' and not error and (not update['remind'] or self.archives_busy) + and ((idle and ((offer or {}).get('bytes', 0) >= 2**30 and new or self.archives_retry_dialog)) + or self.archives_busy and self.archives_from_dialog)) + if remind and not self.archives_busy and not self.archives_retry_dialog: + self.archives_prompt_offer = offer + groups = {} + for row in files: + folder = str(Path(row['path']).parent) + groups.setdefault(folder, []).append(dict(name=Path(row['path']).name, size=self.archive_size(row['bytes']))) + size, count = self.archive_size((offer or {}).get('bytes', 0)), len(files) + retry = self.archives_retry_dialog + if retry: + failed_title = self.t('%d file could not be deleted' if count == 1 else '%d files could not be deleted', + '%d 个文件未能删除') % count + title = (self.t('%s freed. ', '已释放 %s,') % self.archive_size(self.archives_freed) + if self.archives_freed else '') + failed_title + else: + title = self.t('Free up %s of disk space', '可以释放 %s 磁盘空间') % size + return dict(remind=bool(remind), size=size, count=count, retry=retry, title=title, + body='' if retry else self.t( + 'The imported offline package ZIPs are still on the drive, and FreeVideo no longer needs them. ' + 'Deleting them does not affect models, the runtime or your videos.', + '导入过的离线包压缩包仍保留在磁盘上,FreeVideo 已不再需要。删除后,模型、运行环境和作品都不受影响。'), + delete_text=(self.t('Deleting…', '正在删除…') if self.archives_busy else + self.t('Try again', '再试一次') if retry else + self.t('Delete and free %s', '删除并释放 %s') % size), + keep_text=self.t('Keep for now', '暂时保留'), + footnote=self.t('You can also delete them later in Settings › Preferences › Storage.', + '以后也可以在“设置 → 偏好 → 存储”里删除。'), + groups=[dict(folder=folder, files=rows) for folder, rows in groups.items()]) + def archives_text(self): offer = self.archive_offer() - if offer and offer['bytes'] >= 2**20: - return self.t('The offline package ZIP files you imported still take %s. FreeVideo no longer needs them.', - '导入用过的离线包压缩包还占着 %s,FreeVideo 已经用不到它们。') % size_text(offer['bytes']) + if offer and offer['bytes'] > 0: + return self.t('The ZIPs used for importing are no longer needed. Deleting them frees %s.', + '导入用的压缩包已不再需要,删除后可释放 %s。') % self.archive_size(offer['bytes']) return '' def archives_confirm_text(self): offer = self.archive_offer() - if not offer or offer['bytes'] < 2**20: + if not offer or offer['bytes'] <= 0: return '' count = len(offer['paths']) english = 'Delete 1 ZIP file (%s)? This cannot be undone.' if count == 1 else 'Delete %d ZIP files (%%s)? This cannot be undone.' % count - return self.t(english % size_text(offer['bytes']), '删除 %d 个压缩包(%s)?删除后不能恢复。' % (count, size_text(offer['bytes']))) + return self.t(english % self.archive_size(offer['bytes']), '删除 %d 个压缩包(%s)?删除后不能恢复。' % (count, self.archive_size(offer['bytes']))) def archives_done_text(self): freed = getattr(self, 'archives_freed', 0) or 0 - return self.t('ZIP files deleted · %s freed', '已删除压缩包 · 释放 %s') % size_text(freed) if freed >= 2**20 else '' + return self.t('ZIP files deleted · %s freed', '已删除压缩包 · 释放 %s') % self.archive_size(freed) if freed > 0 else '' def archives_failed_text(self): - failed = getattr(self, 'archives_failed', 0) or 0 - if not failed: - return '' - return self.t('1 ZIP file could not be deleted.' if failed == 1 else '%d ZIP files could not be deleted.' % failed, - '%d 个压缩包没能删除。' % failed) + return '\n'.join(self.t('%s (%s): %s', '%s(%s):%s') % ( + Path(row['path']).name, self.archive_size(row['bytes']), + self.t(*ARCHIVE_FAILURES.get(row.get('reason'), ARCHIVE_FAILURES['failed']))) + for row in self.archives_failures) def licenses(self, row): """What the consent accepts, listed when the user reads the licences: an offline runtime's, or the installation plan's.""" @@ -1312,7 +1561,7 @@ def snapshot(self): row = self.controller.state task = row.get('task', {}) port_from = int(row['moved_from']) if row.get('status') == 'open' and 'moved_from' in row else 0 - port_to = (urlsplit(row['url']).port or 80) if port_from else 0 + port_to = (urlsplit(row.get('selection', {}).get('url') or row['url']).port or 80) if port_from else 0 progress = progress_view(task.get('progress') or {}, zh) overall = progress_view(row.get('overall') or task.get('phase_progress') or {}, zh) if self.importer.busy: @@ -1342,6 +1591,10 @@ def snapshot(self): 'verifying': ('Downloaded; verifying', '已下载,正在校验'), 'paused': ('Paused', '已暂停')} review = row.get('status') == 'review' + # The model files are checked once the environment is installed; until their step + # reports, say that instead of a bare "waiting". + before_models = (row.get('action') == 'install' and row.get('status') == 'running' + and not (row.get('task', {}).get('model_groups') or row.get('model_groups'))) for name in FAMILIES: if name == 'sampling' and name not in by_id and not self.form['sampling_caches']: continue @@ -1361,8 +1614,12 @@ def snapshot(self): total = item.get('total_bytes', 0) done = total if state == 'ready' else min(total, item.get('verified_bytes', 0)+item.get('downloaded_bytes', 0)) + if state == 'waiting' and before_models: + shown = ('Checked after setup', '安装环境后检查') + else: + shown = states.get(state, states['waiting']) models.append(dict(id=name, title=self.t(*NAMES[name]), state=state, - detail=self.t(*states.get(state, states['waiting'])), done=done, total=total, + detail=self.t(*shown), done=done, total=total, found=item.get('existing_bytes', 0), download=item.get('download_bytes', 0), rate='%.1f MiB/s' % (item['bytes_per_second']/2**20) if item.get('bytes_per_second') else '')) # A model download names its group, so it shows the group's download, as in the plan and the rows below. @@ -1447,11 +1704,15 @@ def snapshot(self): model_upgrade = dict(self.upgrade.state, busy=self.upgrade.busy) if alias_failure(model_upgrade.get('error', '')): model_upgrade['error'] = display_launcher_error(model_upgrade['error'], zh=zh) + archive_offer = (self.archives_deleting_offer if self.archives_busy else + self.archives_prompt_offer if self.archives_retry_dialog else self.archive_offer()) + archives_idle = bool(archive_offer and archive_offer['paths']) and self.archives_idle() + archive_dialog = self.archives_dialog_view(archive_offer, archives_idle, error, update) return plain_data(dict(sampling_cache_bytes=total_bytes(), version=__version__, disk=disk, decimal_sizes=sys.platform == 'darwin', zh=self.language.startswith('zh'), form=dict(self.form), page=self.page, status=row.get('status', 'idle'), busy=self.controller.busy or self.importer.busy or self.cleaner.busy, cleanup=cleanup, - can_cleanup=not (self.controller.busy or self.importer.busy or self.cleaner.busy or self.closing + can_cleanup=not (self.controller.busy or self.importer.busy or self.cleaner.busy or self.archives_busy or self.closing or self.update_intent or self.engine_updating or self.updater and self.updater.busy or self.upgrade.active or self.model_switch), model_upgrade=dict(model_upgrade, switching=bool(self.model_switch)), @@ -1460,6 +1721,7 @@ def snapshot(self): models=len(self.form['offline_models']), guide=package_instructions(self.form['new_comfy'] and self.form['environment_method'] == 'manual' and not self.form['offline_runtime'], zh)), video_model_guide=video_instructions(zh), + adaln_dialog=self.adaln_dialog_view(error, update), selected=bool(self.selected), can_repair=self.can_repair(), repair_offered=repair_offered, error=error, retry_kind=self.retry_kind(), notice=self.notice, # Qt reads a list as an array; LEVELS is a tuple. compatibility=dict(self.compatibility, levels=list(self.compatibility.get('levels') or ())), @@ -1472,10 +1734,19 @@ def snapshot(self): probe=probe, speeds=speeds, token_set=bool(self.token), log=self.tail.text, logs=[dict(label=display(n, zh), path=str(p)) for n, p in self.controller.terminal_sources()], url=(self._browser_url(row.get('url', '')) if row.get('status') == 'open' else ''), shortcut=shortcut, + access_links=(list(self.controller.access_links) if row.get('status') == 'open' + and isinstance(self.controller.access_links, list) else []), + lan_message=(self.t('LAN access has stopped. Turn it on again.', '局域网访问已停止,请重新开启。') + if row.get('lan_status') == 'stopped' else + self.t('No LAN address found. Connect to Wi-Fi or a wired network first.', '未找到局域网地址,请先连接 Wi-Fi 或有线网络。') + if row.get('lan_status') == 'no-address' else ''), port_from=port_from, port_to=port_to, old_versions=self.old_versions_text(), copied_models=self.copied_models_text(), archives=self.archives_text(), archives_confirm=self.archives_confirm_text(), archives_done=self.archives_done_text(), archives_failed=self.archives_failed_text(), + archives_dialog=archive_dialog, archives_busy=self.archives_busy, can_delete_archives=archives_idle, + archives_toast=(self.t('Freed %s', '已释放 %s') % self.archive_size(self.archives_freed) + if self.archives_freed and time.monotonic() < self.archives_toast_until else ''), can_shortcut=bool(self.controller.selection and self.controller.selection.get('ready')), update=update, engine_update_available=bool(row.get('engine_update_available')), settings_path=str(self.store.primary), diff --git a/freevideo_engine/launcher_settings.py b/freevideo_engine/launcher_settings.py index e765097a..186b40f8 100644 --- a/freevideo_engine/launcher_settings.py +++ b/freevideo_engine/launcher_settings.py @@ -11,6 +11,7 @@ TEXT = ('comfy', 'destination', 'engine', 'models', 'python', 'url', 'language', 'offline_runtime') SELECTION = ('root', 'engine', 'source', 'python', 'url') ARCHIVE = ('path', 'bytes', 'mtime_ns', 'kind', 'root') +ARCHIVE_IDENTITY = ('path', 'bytes', 'mtime_ns') def default_language(): @@ -48,10 +49,19 @@ def sanitize(value): {k: row[k] for k in ARCHIVE} for row in archives if isinstance(row, dict) and all(isinstance(row.get(k), str) for k in ('path', 'root', 'kind')) and all(type(row.get(k)) is int for k in ('bytes', 'mtime_ns'))][-64:] + kept = value.get('kept_archives') + if isinstance(kept, list): + result['kept_archives'] = [ + {k: row[k] for k in ARCHIVE_IDENTITY} for row in kept + if isinstance(row, dict) and isinstance(row.get('path'), str) + and all(type(row.get(k)) is int for k in ('bytes', 'mtime_ns'))][-64:] record = value.get('installation') if isinstance(record, dict) and all(isinstance(record.get(k), str) and record[k] for k in SELECTION): result['installation'] = {k: record[k] for k in SELECTION} result['installation']['separate'] = record.get('separate') is True + result['installation']['lan_access'] = record.get('lan_access') is True + if type(record.get('lan_port')) is int and 1 <= record['lan_port'] <= 65535: + result['installation']['lan_port'] = record['lan_port'] if type(value.get('saved_at_ns')) is int: result['saved_at_ns'] = value['saved_at_ns'] resume = value.get('setup') diff --git a/freevideo_engine/licenses/runtime/THIRD_PARTY_NOTICES.txt b/freevideo_engine/licenses/runtime/THIRD_PARTY_NOTICES.txt index 58f9ed8d..651321f9 100644 --- a/freevideo_engine/licenses/runtime/THIRD_PARTY_NOTICES.txt +++ b/freevideo_engine/licenses/runtime/THIRD_PARTY_NOTICES.txt @@ -4,12 +4,13 @@ FreeVideo's own Apache-2.0 license does not cover the components below. The NVID components are under their vendors' terms: they may not be reverse engineered and may not be redistributed on their own, apart from FreeVideo. As a recipient of the Intel components, you are bound by the limitations of liability in Section 7 of the Intel license -(Intel-EULA-Developer-Tools.txt, beside this file). The AMD and TinyCC components are open source -under the licenses named in their sections. +(Intel-EULA-Developer-Tools.txt, beside this file). The AMD, TinyCC, ComfyUI, comfy_aimdo and +comfy_kitchen components are open source under the licenses named in their sections. The files listed are those of the FreeVideo Windows runtime, relative to the FreeVideo-Windows folder of FreeVideo-运行环境.zip and FreeVideo-Windows-运行环境与ComfyUI-无模型.zip. The full license texts -named below are beside this file. +named below are beside this file, except those of ComfyUI, comfy_aimdo and comfy_kitchen, which are +among those components' own files at the paths named in their sections. NVIDIA CUDA libraries License: NVIDIA CUDA Toolkit End User License Agreement; full text in NVIDIA-CUDA-EULA.txt @@ -175,3 +176,91 @@ TinyCC in triton-windows python/Lib/site-packages/triton/runtime/tcc/lib/user32.def python/Lib/site-packages/triton/runtime/tcc/libtcc.dll python/Lib/site-packages/triton/runtime/tcc/tcc.exe + +ComfyUI + License: GNU General Public License v3.0 + https://github.com/comfy-org/ComfyUI + Full text: ComfyUI/LICENSE + ComfyUI at commit 421a1c245c682c04d4325ba365f40c834c66f5b0. The files of this folder are its source + code, except custom_nodes/FreeVideo, which is FreeVideo's own; it is also at + https://github.com/comfy-org/ComfyUI/tree/421a1c245c682c04d4325ba365f40c834c66f5b0. + Files: + ComfyUI/ (the ComfyUI files at this commit) + +comfy_aimdo + License: GNU General Public License v3.0 + https://github.com/Comfy-Org/comfy-aimdo + Full text: python/Lib/site-packages/comfy_aimdo-0.5.3.dist-info/licenses/LICENSE + comfy_aimdo 0.5.3; its compiled libraries (the .dll files listed) are object code. Their complete + source code, tag v0.5.3 (commit 6d5211d60b5f897ca915b9bf181c024779dc3318), is at + https://github.com/Comfy-Org/comfy-aimdo/tree/v0.5.3. We will also provide it on request: + https://github.com/FlashML-org/FreeVideo/issues + Files: + python/Lib/site-packages/comfy_aimdo/_version.py + python/Lib/site-packages/comfy_aimdo/aimdo.dll + python/Lib/site-packages/comfy_aimdo/aimdo_rocm.dll + python/Lib/site-packages/comfy_aimdo/control.py + python/Lib/site-packages/comfy_aimdo/host_buffer.py + python/Lib/site-packages/comfy_aimdo/malloc_graph.py + python/Lib/site-packages/comfy_aimdo/model_mmap.py + python/Lib/site-packages/comfy_aimdo/model_vbar.py + python/Lib/site-packages/comfy_aimdo/torch.py + python/Lib/site-packages/comfy_aimdo/vram_buffer.py + +comfy_kitchen + License: Apache License 2.0 + https://github.com/Comfy-Org/comfy-kitchen + Full text: python/Lib/site-packages/comfy_kitchen-0.2.33.dist-info/licenses/LICENSE + Notices: python/Lib/site-packages/comfy_kitchen-0.2.33.dist-info/licenses/NOTICE + Its NOTICE says its compiled modules link the NVIDIA CUDA runtime statically, under the NVIDIA CUDA + Toolkit End User License Agreement (NVIDIA-CUDA-EULA.txt, beside this file). + Files: + python/Lib/site-packages/comfy_kitchen/__init__.py + python/Lib/site-packages/comfy_kitchen/_rope_utils.py + python/Lib/site-packages/comfy_kitchen/backends/_activations.py + python/Lib/site-packages/comfy_kitchen/backends/_modulation.py + python/Lib/site-packages/comfy_kitchen/backends/cuda/_C.abi3.pyd + python/Lib/site-packages/comfy_kitchen/backends/cuda/__init__.py + python/Lib/site-packages/comfy_kitchen/backends/cuda/cublaslt_runtime.h + python/Lib/site-packages/comfy_kitchen/backends/cuda/dtype_dispatch.cuh + python/Lib/site-packages/comfy_kitchen/backends/cuda/float_utils.cuh + python/Lib/site-packages/comfy_kitchen/backends/cuda/input_act_codes.h + python/Lib/site-packages/comfy_kitchen/backends/cuda/utils.cuh + python/Lib/site-packages/comfy_kitchen/backends/eager/__init__.py + python/Lib/site-packages/comfy_kitchen/backends/eager/adaln.py + python/Lib/site-packages/comfy_kitchen/backends/eager/awq.py + python/Lib/site-packages/comfy_kitchen/backends/eager/convrot_w4a4.py + python/Lib/site-packages/comfy_kitchen/backends/eager/na.py + python/Lib/site-packages/comfy_kitchen/backends/eager/quantization.py + python/Lib/site-packages/comfy_kitchen/backends/eager/rope.py + python/Lib/site-packages/comfy_kitchen/backends/eager/sol_attn.py + python/Lib/site-packages/comfy_kitchen/backends/eager/svdquant.py + python/Lib/site-packages/comfy_kitchen/backends/eager/w4a8_int8.py + python/Lib/site-packages/comfy_kitchen/backends/hip/_C.abi3.pyd + python/Lib/site-packages/comfy_kitchen/backends/hip/__init__.py + python/Lib/site-packages/comfy_kitchen/backends/hip/architectures.json + python/Lib/site-packages/comfy_kitchen/backends/triton/__init__.py + python/Lib/site-packages/comfy_kitchen/backends/triton/adaln.py + python/Lib/site-packages/comfy_kitchen/backends/triton/na.py + python/Lib/site-packages/comfy_kitchen/backends/triton/quantization.py + python/Lib/site-packages/comfy_kitchen/backends/triton/rms_rope.py + python/Lib/site-packages/comfy_kitchen/backends/triton/rope.py + python/Lib/site-packages/comfy_kitchen/backends/triton/w4a8_int8.py + python/Lib/site-packages/comfy_kitchen/constraints.py + python/Lib/site-packages/comfy_kitchen/exceptions.py + python/Lib/site-packages/comfy_kitchen/flash_attention.py + python/Lib/site-packages/comfy_kitchen/float_utils.py + python/Lib/site-packages/comfy_kitchen/registry.py + python/Lib/site-packages/comfy_kitchen/sage_attention.py + python/Lib/site-packages/comfy_kitchen/scaled_mm_v2.py + python/Lib/site-packages/comfy_kitchen/tensor/__init__.py + python/Lib/site-packages/comfy_kitchen/tensor/awq_w4a16.py + python/Lib/site-packages/comfy_kitchen/tensor/base.py + python/Lib/site-packages/comfy_kitchen/tensor/convrot_w4a4.py + python/Lib/site-packages/comfy_kitchen/tensor/fp8.py + python/Lib/site-packages/comfy_kitchen/tensor/int8.py + python/Lib/site-packages/comfy_kitchen/tensor/int8_utils.py + python/Lib/site-packages/comfy_kitchen/tensor/mxfp8.py + python/Lib/site-packages/comfy_kitchen/tensor/nvfp4.py + python/Lib/site-packages/comfy_kitchen/tensor/svdquant_w4a4.py + python/Lib/site-packages/comfy_kitchen/tensor/w4a8_int8.py diff --git a/freevideo_engine/modern_launcher.py b/freevideo_engine/modern_launcher.py index 5ed626d6..5122321f 100644 --- a/freevideo_engine/modern_launcher.py +++ b/freevideo_engine/modern_launcher.py @@ -331,6 +331,11 @@ def shutdown(self): return app, engine, window, bridge +def save_screenshot(window, bridge, path): + if not bridge.value.get('access_links'): + window.grabWindow().save(str(path)) + + def smoke_test(app, engine, window, bridge, destination): """Exercise the packaged QML, manifests and managed-Python entry point.""" import queue @@ -355,7 +360,7 @@ def smoke_test(app, engine, window, bridge, destination): raise RuntimeError('Packaged launcher action is not visible: ' + page) if page == 'progress': # The last setup step shows the finished ones with a check mark; keep it for the font check. - window.grabWindow().save(str(destination / 'launcher-steps.png')) + save_screenshot(window, bridge, destination / 'launcher-steps.png') window.setProperty('settingsOpen', True) app.processEvents() window.setProperty('settingsOpen', False) @@ -401,7 +406,7 @@ def finish(): save(destination / 'smoke.json', dict(success=bool(result and result['status'] == 'complete' and not imported), task=result, interface='Qt Quick launcher', build=build, source=str(session.source), payload=payload, model_modules_imported=imported)) - window.grabWindow().save(str(destination / 'launcher.png')) + save_screenshot(window, bridge, destination / 'launcher.png') if runner.busy: runner.cancel() timer.stop() @@ -440,7 +445,7 @@ def step(): info = server_info(state['url'].split('/?')[0]) if status == 'open' else {} save(destination / 'connect.json', dict(success=status == 'open' and info.get('status') == 'freevideo', status=status, error=state.get('error'), url=state.get('url'), server=info)) - window.grabWindow().save(str(destination / 'connected.png')) + save_screenshot(window, bridge, destination / 'connected.png') timer.stop() app.quit() timer.timeout.connect(step) diff --git a/freevideo_engine/network.py b/freevideo_engine/network.py index 966a25c1..f72c702e 100644 --- a/freevideo_engine/network.py +++ b/freevideo_engine/network.py @@ -16,6 +16,7 @@ import shutil import subprocess from . import execution_paths +from . import subprocess_output from .windows_ux import external_python, hidden_console import threading import time @@ -42,6 +43,10 @@ SPEED_SAMPLE_BYTES = 32 * 2**20 SPEED_SAMPLE_SECONDS = 3. MIN_TRANSFER_LIMIT = 1024 * 1024 +# One quick pass over sources/routes, followed by at most one patient pass. +# These are inactivity timeouts, not deadlines for an entire installation. +DOWNLOAD_RETRY_PROFILES = (('fast', '20', '1'), ('patient', '120', '5')) +PATIENT_DOWNLOAD_NOTICE = 'The network is slow. Downloading again; this may take a while.' def proxy_environment(environ=None): @@ -299,7 +304,7 @@ def probe(family, name, spec, versions, timeout, env, *, measure_speed=False): origin_proxy = execution_paths.run(['git', 'config', '--get', 'remote.origin.proxy'], env=env, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=3, **hidden_console()) if origin_proxy.returncode == 0: - settings['remote.' + remote + '.proxy'] = origin_proxy.stdout.decode().rstrip('\r\n') + settings['remote.' + remote + '.proxy'] = subprocess_output.decode_output(origin_proxy.stdout).rstrip('\r\n') elif origin_proxy.returncode != 1: raise RuntimeError('Git proxy configuration could not be read') git_env = proxy.git_environment(env, settings) @@ -564,57 +569,136 @@ def _package_attempt(error, source, route): returncode=getattr(error, 'returncode', None), detail=detail, summary=summary) -def package_environment(network, family, name, environ=None, *, route=None): +def download_environment(environ, retry_mode='fast'): + """Apply shared uv/pip defaults without replacing any user setting.""" + _, timeout, retries = next(row for row in DOWNLOAD_RETRY_PROFILES if row[0] == retry_mode) + env = dict(environ) + for key, value in (('UV_HTTP_TIMEOUT', timeout), ('UV_HTTP_RETRIES', retries), + ('PIP_DEFAULT_TIMEOUT', timeout), ('PIP_RETRIES', retries)): + env.setdefault(key, value) + return env + + +# Only a validation failure counts: a package or service that merely has +# "checksum" in its name must stay eligible for the slow-network retry. Keep in +# sync with fv_slow_download_failure in scripts/bootstrap_linux.sh. +_INTEGRITY_FAILURE = re.compile( + r"\b(?:invalid|bad|wrong|incorrect|mismatched?)\s+checksums?\b" + r"|\bchecksums?\s+(?:mismatch|error|failed|failure)\b" + r"|\bchecksums?\s+(?:verification|validation)\s+(?:failed|failure|error)\b" + r"|\bchecksums?\s+(?:does not|doesn't|did not|didn't|do not|don't)\s+match\b" + r"|\b(?:does not|doesn't|did not|didn't|do not|don't)\s+match\s+(?:the\s+|its\s+|expected\s+)?(?:checksums?|hash(?:es)?)\b" + r"|\bhash\s+(?:mismatch|check\s+failed|does\s+not\s+match)\b|\bhashes\s+(?:do\s+not|didn't)\s+match\b" + r"|\bsha-?256\s+mismatch\b|\bdigest\s+mismatch\b" + r"|\bintegrity\s+(?:check|verification)\s+failed\b") + + +def _download_integrity_failure(detail): + # URL paths may contain validation words without a validation failure. + detail = re.sub(r'https?://\S+', '', detail.lower()) + return bool(_INTEGRITY_FAILURE.search(detail)) + + +def slow_download_failure(detail): + """Mirror fallback is broader than permission to retry a whole slow pass.""" + return (connection_failure(detail) and not _download_integrity_failure(detail) + and not re.search(r'\b(?:401|403|404|410)\b', detail) + and not any(word in detail.lower() for word in + ('certificate verify failed', 'certificate_verify_failed', + 'invalid peer certificate', 'unknown issuer', 'not found', + 'resolutionimpossible', 'failed to build', 'invalid wheel', + 'permission denied', 'no space left on device', 'disk full'))) + + +def package_environment(network, family, name, environ=None, *, route=None, retry_mode='fast'): env = route_environment(network, family, name, environ, route) + env = download_environment(env, retry_mode) endpoint = source_url(family, name, environ) if family.startswith('torch-'): endpoint += '/' + family.split('-')[1] - env.update(UV_DEFAULT_INDEX=endpoint, UV_INDEX_URL=endpoint, PIP_INDEX_URL=endpoint, - UV_HTTP_TIMEOUT='20', UV_HTTP_RETRIES='1', PIP_DEFAULT_TIMEOUT='20', PIP_RETRIES='1', - UV_NO_CONFIG='1') + env.update(UV_DEFAULT_INDEX=endpoint, UV_INDEX_URL=endpoint, PIP_INDEX_URL=endpoint, UV_NO_CONFIG='1') for key in ('UV_INDEX', 'UV_EXTRA_INDEX_URL', 'PIP_EXTRA_INDEX_URL'): env.pop(key, None) return env -def package_command(network, family, run, env=None): +def download_command(network, family, run, env=None, *, category=None, extra_sources=(), notice=None): + """Run a source/route callback with the same bounded policy for uv and pip. + + Callbacks receive (source, route, retry_mode); each builds its child env from + the original environment, so our fast defaults never become user overrides. + """ + category = category or family + operation = 'python' if category == 'python' else 'package' attempts = [] - for name in ordered(network, family): - for route in route_order(network, family, name, env): - event(network, category=family, source=name, route=route, action='package-attempt') - try: - attempt_env = package_environment(network, family, name, env, route=route) - # The host setup uses these private markers to retain one log - # per source/route instead of overwriting the first failure. - # They are never included in a report or sent to a package - # manager as configuration. - attempt_env['FREEVIDEO_PACKAGE_SOURCE'] = name - attempt_env['FREEVIDEO_PACKAGE_ROUTE'] = route - result = run(attempt_env, name) - source_health(network, family, name, True) - route_health(network, family, name, route) - return result - except (RuntimeError, subprocess.CalledProcessError) as error: - attempt = _package_attempt(error, name, route) - attempts.append(attempt) - event(network, category=family, source=name, route=route, action='package-failed', - error_type=attempt['error_type'], returncode=attempt['returncode'], - detail=attempt['detail'], summary=attempt['summary']) - detail = attempt['detail'] - if not retryable(detail): - # Preserve the original exception type and traceback for - # build/configuration errors, but make the attempt data - # available to the local report collector. - try: - error.package_attempts = attempts - except Exception: - pass - raise - event(network, category=family, source=name, route=route, action='package-fallback', reason='network-or-unavailable-pinned-package') - if not connection_failure(detail): - break - source_health(network, family, name, False) - raise PackageSourcesError(family, attempts) + for retry_mode, timeout, retries in DOWNLOAD_RETRY_PROFILES: + if retry_mode == 'patient': + if not attempts or not all(slow_download_failure(row['detail']) for row in attempts): + break + event(network, category=category, action='download-round-result', retry_mode='fast', + result='failed', attempts=len(attempts)) + event(network, category=category, action='download-patient-retry', retry_mode=retry_mode, + default_timeout_seconds=int(timeout), default_retries=int(retries)) + if notice is None: + print(PATIENT_DOWNLOAD_NOTICE, flush=True) + else: + notice(PATIENT_DOWNLOAD_NOTICE) + round_start = len(attempts) + retry_details = {'retry_mode': retry_mode} if retry_mode == 'patient' else {} + if retry_mode == 'patient': + event(network, category=category, action='download-round-start', retry_mode=retry_mode) + # Re-read health and allowed routes after the fast pass. Equal failures + # retain the existing probe order; no new connectivity probes are made. + for name in [*extra_sources, *ordered(network, family)]: + for route in route_order(network, family, name, env): + if operation == 'package' or retry_mode == 'patient': + event(network, category=category, source=name, route=route, + action=operation + '-attempt', **retry_details) + try: + result = run(name, route, retry_mode) + source_health(network, family, name, True) + route_health(network, family, name, route) + if retry_mode == 'patient': + event(network, category=category, action='download-round-result', retry_mode=retry_mode, + result='success', attempts=len(attempts)-round_start+1) + return result + except (RuntimeError, subprocess.CalledProcessError) as error: + attempt = dict(_package_attempt(error, name, route), retry_mode=retry_mode) + attempts.append(attempt) + event(network, category=category, source=name, route=route, action=operation + '-failed', + error_type=attempt['error_type'], returncode=attempt['returncode'], + detail=attempt['detail'], summary=attempt['summary'], **retry_details) + detail = attempt['detail'] + if (not retryable(detail) + or (retry_mode == 'patient' and not slow_download_failure(detail))): + if retry_mode == 'patient': + event(network, category=category, action='download-round-result', retry_mode=retry_mode, + result='failed', attempts=len(attempts)-round_start, reason='non-network-error') + # Retain original build/configuration/integrity errors. + try: + error.package_attempts = attempts + except Exception: + pass + raise + event(network, category=category, source=name, route=route, action=operation + '-fallback', + reason='network-or-unavailable-pinned-package', **retry_details) + if not connection_failure(detail): + break + source_health(network, family, name, False) + if retry_mode == 'patient': + event(network, category=category, action='download-round-result', retry_mode=retry_mode, + result='failed', attempts=len(attempts)-round_start) + raise PackageSourcesError(category, attempts) + + +def package_command(network, family, run, env=None, *, notice=None): + def attempt(name, route, retry_mode): + attempt_env = package_environment(network, family, name, env, route=route, retry_mode=retry_mode) + # Private markers retain per-source/route logs; no package configuration. + attempt_env['FREEVIDEO_PACKAGE_SOURCE'] = name + attempt_env['FREEVIDEO_PACKAGE_ROUTE'] = route + return run(attempt_env, name) + return download_command(network, family, attempt, env, notice=notice) def hash_file(path, algorithm='sha256', git_blob=False, *, discard_cache=False, progress=None): @@ -886,8 +970,8 @@ def remember_source(): def git_version(executable, env=None): """(major, minor) of a Git executable, or None when it cannot be run or read.""" try: - output = execution_paths.run([str(executable), '--version'], env=env, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, text=True, timeout=30, **hidden_console()).stdout + output = subprocess_output.run_external([str(executable), '--version'], env=env, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=30, **hidden_console()).stdout except (OSError, ValueError, subprocess.SubprocessError): return None found = re.search(r'git version (\d+)\.(\d+)', output or '') @@ -918,14 +1002,14 @@ def execute(label, command): command = [git, *command[1:]] if run: return run(label, command, env=env) - result = execution_paths.run(list(map(str, command)), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **hidden_console()) + result = subprocess_output.run_external(list(map(str, command)), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **hidden_console()) if result.returncode: raise RuntimeError(result.stdout[-2500:]) if target.exists(): if not (target / '.git').exists(): raise ValueError('Existing dependency is not a Git checkout of the pinned source: ' + str(target)) - head = execution_paths.check_output([git, '-C', str(target), 'rev-parse', 'HEAD'], env=env, text=True, **hidden_console()).strip() - dirty = execution_paths.check_output([git, '-C', str(target), 'status', '--porcelain', '--untracked-files=no'], env=env, text=True, **hidden_console()) + head = subprocess_output.output_external([git, '-C', str(target), 'rev-parse', 'HEAD'], env=env, **hidden_console()).strip() + dirty = subprocess_output.output_external([git, '-C', str(target), 'status', '--porcelain', '--untracked-files=no'], env=env, **hidden_console()) if head != commit or dirty: raise ValueError('Existing dependency differs from the pinned clean source: ' + str(target)) return target @@ -961,7 +1045,7 @@ def execute(label, command): finally: env = original execute(target.name + '-checkout', ['git', '-C', temporary, 'checkout', '--detach', commit]) - actual = execution_paths.check_output([git, '-C', str(temporary), 'rev-parse', 'HEAD'], env=env, text=True, **hidden_console()).strip() + actual = subprocess_output.output_external([git, '-C', str(temporary), 'rev-parse', 'HEAD'], env=env, **hidden_console()).strip() if actual != commit: raise ValueError('Downloaded source did not match its pinned commit') temporary.rename(target) diff --git a/freevideo_engine/optimize.py b/freevideo_engine/optimize.py index fd42a8b4..46bc8c73 100644 --- a/freevideo_engine/optimize.py +++ b/freevideo_engine/optimize.py @@ -522,6 +522,8 @@ def search_plan(row, base, maximum, seconds, *, reject=None): def run(args, machine, ui): from .testing import environment, run_process, artifact_manifest, artifact_estimate + from .triton_compat import own_temporary_environment + own_temporary_environment(machine.get('root')) env = environment(machine) os.environ.update(execution_environment(env)) if args.disable: @@ -784,6 +786,8 @@ def interrupted(signum, frame): if not machine.get('ready'): raise ValueError('Setup is incomplete') from .testing import environment + from .triton_compat import own_temporary_environment + own_temporary_environment(machine.get('root')) os.environ.update(execution_environment(environment(machine))) with nullcontext() if args.plan else runtime_lock() as descriptor: previous = os.environ.get(LOCK_ENV) diff --git a/freevideo_engine/package_cleanup.py b/freevideo_engine/package_cleanup.py index 4b87c36c..19ce3691 100644 --- a/freevideo_engine/package_cleanup.py +++ b/freevideo_engine/package_cleanup.py @@ -364,21 +364,23 @@ def run(engine, comfy_root=None, running=None, dry_run=False): return result -OPTIONAL_PREFIX = 'models/base/sampling-cache/' - - def archive_unchanged(record): try: - info = os.stat(io_path(checked_real_path(record['path']))) - except (OSError, KeyError, TypeError): + path = checked_real_path(record['path']) + if not path.is_absolute() or path.suffix.lower() != '.zip' or not local_path(Path(path.anchor), path): + return False + info = os.lstat(io_path(path)) + except (OSError, KeyError, TypeError, ValueError): return False - return stat.S_ISREG(info.st_mode) and info.st_size == record.get('bytes') and info.st_mtime_ns == record.get('mtime_ns') + return (stat.S_ISREG(info.st_mode) and info.st_nlink == 1 + and info.st_size == record.get('bytes') and info.st_mtime_ns == record.get('mtime_ns')) def archive_rows(record): """The files a recorded ZIP provides: from its extraction, or else from the ZIP's own record.""" try: - value = load(io_path(checked_real_path(record['root'])) / MODELS_MARKER) + value = load(io_path(checked_real_path(record['root'])) + / (RUNTIME_MARKER if record.get('kind') == 'runtime' else MODELS_MARKER)) except AliasUnavailable: return None if not isinstance(value, dict): @@ -399,44 +401,101 @@ def archives(records, engine, index=None): index = verified_files(engine) if index is None else index runtime = runtime_root(engine) runtime = io_path(runtime) if runtime is not None else None - paths, total = [], 0 + paths, files, total = [], [], 0 for record in records: try: checked_real_path(record['root']) except AliasUnavailable: continue - if not archive_unchanged(record): + try: + named = plain(checked_real_path(record['path'])) + except (OSError, KeyError, TypeError, ValueError): continue + # One ZIP is one offer, whichever spelling its records carry. + if record.get('kind') not in ('runtime', 'models') or named in paths or not archive_unchanged(record): + continue + rows = archive_rows(record) + held = bool(rows) and all(row.get('sha256') in index for row in rows) if record.get('kind') == 'runtime': - # The environment package was unpacked into this very installation. - held = runtime is not None and resolved(checked_real_path(record['root'])) == resolved(runtime) - else: - rows = archive_rows(record) - - def installed(row): - if row.get('sha256') in index: - return True - # Optional sampling tables an offline installation placed itself: present at full size. - placed = runtime / str(row.get('path', '')) if runtime is not None else None - return (placed is not None and str(row.get('path', '')).startswith(OPTIONAL_PREFIX) - and placed.is_file() and placed.stat().st_size == row.get('bytes')) - held = bool(rows) and all(installed(row) for row in rows) + # Root identity alone is insufficient: the current environment must + # still hold every imported file with its verified hash and stamp. + try: + held = held and runtime is not None and resolved(checked_real_path(record['root'])) == resolved(runtime) + except AliasUnavailable: + continue if held: - paths.append(plain(checked_real_path(record['path']))) + try: + real = checked_real_path(record['path']) + info = os.lstat(io_path(real)) + except OSError: + continue + if not archive_unchanged(record): + continue + paths.append(plain(real)) total += record['bytes'] - return dict(bytes=total, paths=paths) + files.append(dict(path=plain(real), bytes=record['bytes'], mtime_ns=record['mtime_ns'], + inode=info.st_ino, device=info.st_dev)) + return dict(bytes=total, paths=paths, files=files) + + +def failure_reason(error): + """A failure code the launcher words for the user; the raw error stays in detail.""" + if isinstance(error, ValueError): + return 'changed' + if isinstance(error, BlockingIOError): + return 'busy' # Setup or a video holds the installation's lease. + if isinstance(error, PermissionError): + return 'in-use' # Windows reports a file open elsewhere as access denied. + return 'failed' + +def delete_archives(records, engine, *, offered=None): + """Revalidate imported ZIPs, then delete; return (freed bytes, removed paths, failures). -def delete_archives(records, engine): - """Delete the user's imported ZIPs this installation holds; returns (bytes freed, paths removed).""" - offer = archives(records, engine) - freed, removed = 0, [] - for path in offer['paths']: + Each failure is dict(path, reason, detail): reason is 'changed', 'busy', + 'in-use' or 'failed' (failure_reason). A ZIP already gone counts as removed. + """ + from .locking import runtime_lock + engine = io_path(checked_real_path(engine)) + offer = archives(records, engine) if offered is None else offered + freed, removed, failed = 0, [], [] + by_path = {} + for item in records: try: - info = os.lstat(io_path(path)) - os.unlink(io_path(path)) - except OSError: - continue # In use or read-only: kept, offered again next time. - removed.append(path) - freed += info.st_size if info.st_nlink == 1 else 0 - return freed, removed + by_path[plain(checked_real_path(item['path']))] = item + except (AliasUnavailable, KeyError, TypeError): + continue + try: + # Exclude setup/generation from other launchers as well as this UI. + with runtime_lock(engine / 'setup.lock', inherit=False), \ + runtime_lock(engine / 'engine.lock', inherit=False), \ + runtime_lock(engine / 'launcher' / 'host-setup.lock', inherit=False): + current = set(archives(records, engine)['paths']) + for row in offer['files']: + path = row['path'] + try: + if not os.path.lexists(io_path(path)): + removed.append(path) # Deleted elsewhere meanwhile: nothing left to free. + continue + record = by_path.get(path) + if path not in current or not record or not archive_unchanged(record): + # A ZIP another program holds open cannot be read to verify it: + # that is "in use", not "changed". + with open(io_path(path), 'rb'): + pass + raise ValueError('File or installed contents changed; kept for safety.') + info = os.lstat(io_path(path)) + if (info.st_size, info.st_mtime_ns, info.st_ino, info.st_dev) != ( + row['bytes'], row['mtime_ns'], row['inode'], row['device']): + raise ValueError('File changed since the confirmation; kept for safety.') + os.unlink(io_path(path)) + except (OSError, ValueError) as error: + failed.append(dict(path=path, reason=failure_reason(error), detail=str(error))) + continue + removed.append(path) + # A name that was linked after the check frees nothing. + freed += info.st_size if info.st_nlink == 1 else 0 + except (OSError, ValueError) as error: + failed.extend(dict(path=row['path'], reason=failure_reason(error), detail=str(error)) for row in offer['files'] + if row['path'] not in removed and row['path'] not in {r['path'] for r in failed}) + return freed, removed, failed diff --git a/freevideo_engine/portable.py b/freevideo_engine/portable.py index d182f36f..f58309bf 100644 --- a/freevideo_engine/portable.py +++ b/freevideo_engine/portable.py @@ -170,6 +170,8 @@ def own_environment(root, value): runtime through that folder, as the environment it was started with did: what it starts with the inherited environment imports and loads from there. """ + from .triton_compat import own_temporary_environment + own_temporary_environment(Path(plain(root)) / 'engine') from .execution_paths import execution_environment return execution_environment(environment(root, value)) @@ -240,7 +242,7 @@ def initialize(root, progress=lambda **kw: None): def connect(root, value, machine, progress=lambda **kw: None, cancelled=None, *, url='http://127.0.0.1:8188', - on_controller=None, previous=None): + on_controller=None, previous=None, lan_access=False, lan_port=None): """Use the existing ComfyUI connection/startup behavior without installing. previous: the controller of an earlier attempt, whose ComfyUI may still run. @@ -252,10 +254,6 @@ def connect(root, value, machine, progress=lambda **kw: None, cancelled=None, *, # launcher source it was imported with backs one, as in any installation. deploy(comfy, machine['source'], machine['root']) controller = Controller(machine['source'], child_environment=lambda: environment(root, value)) - if previous is not None and previous.owns_server(): - # A ComfyUI this launcher started for an earlier attempt, e.g. one that - # came up without FreeVideo: connecting restarts it instead of asking to. - controller.server, controller.server_log = previous.server, previous.server_log controller.shortcut_root = Path(plain(root)) if not machine['gpu_uuid'].startswith(('GPU-', 'MIG-')): raise ValueError('The CUDA device did not provide a valid GPU identity; rerun the GPU check.') @@ -266,9 +264,17 @@ def connect(root, value, machine, progress=lambda **kw: None, cancelled=None, *, controller.cancelled = cancelled if cancelled.is_set(): raise RuntimeError('Startup cancelled') + if previous is not None and previous.owns_server(): + # A ComfyUI this launcher started for an earlier attempt, e.g. one that + # came up without FreeVideo: connecting restarts it instead of asking to. + controller.server, controller.server_log = previous.server, previous.server_log + if isinstance(previous, Controller): + for name in ('server_address', 'server_url', 'access_proxy', 'access_configuration', 'access_links', 'access_polled'): + setattr(controller, name, getattr(previous, name)) + previous.server = previous.access_proxy = None controller.selection = dict(ready=True, root=plain(comfy), engine=machine['root'], source=machine['source'], python=machine['python'], url=url, - portable=False, separate=False) + portable=False, separate=False, lan_access=lan_access is True, lan_port=lan_port) controller.restore_terminal(machine['root']) if on_controller: on_controller(controller) diff --git a/freevideo_engine/portable_launcher.py b/freevideo_engine/portable_launcher.py index f5a16230..71e296bf 100644 --- a/freevideo_engine/portable_launcher.py +++ b/freevideo_engine/portable_launcher.py @@ -7,7 +7,7 @@ from . import component_licenses, launch_view, portable, processes from .comfy_launcher import Launcher -from .comfy_launcher_runtime import Controller, local_url, matches_server, server_info +from .comfy_launcher_runtime import Controller, local_url, loopback_url, matches_listener, matches_server, server_info from .monitoring import save from .long_paths import io_path, resolved, plain, plain_text @@ -36,7 +36,9 @@ def restore(self, values): self.selection = dict(root=plain(portable.inside(self.root, self.bundle['comfy'])), engine=str(self.root/'engine'), source=str(self.source), python=plain(portable.inside(self.root, self.bundle['python'])), - url=local_url(url or 'http://127.0.0.1:8188'), ready=False, separate=False) + url=local_url(url or 'http://127.0.0.1:8188'), ready=False, separate=False, + lan_access=(values.get('installation') or {}).get('lan_access', values.get('lan_access', False)) is True, + lan_port=(values.get('installation') or {}).get('lan_port', (self.selection or {}).get('lan_port'))) self.state = dict(status='bundled', selection=dict(self.selection), engine_update_available=self.engine_outdated()) self.restore_terminal(self.selection['engine']) @@ -133,10 +135,28 @@ def _launch(self, values): url = self.selection['url'] info = server_info(url) if matches_server(info, self.selection['root'], self.selection['engine'], self.source): + if self.comfy_controller is not None and self.comfy_controller.owns_server(): + # Our own server on this port: its controller moves the listening + # address and the LAN proxy. A server left on the previous source + # does not match and restarts below, as before. + self.selection['ready'] = True + self.ensure_shortcut() + self.attach_console(info) + try: + return self.comfy_controller._configure_access( + dict(url=url, lan_access=self.selection['lan_access']), connect=True) + finally: + self._sync_access() + if not matches_listener(info, url): + self.state = dict(self.state, status='restart-required', url=loopback_url(url), + error='The ComfyUI already running does not use this address. After its jobs finish, restart it with this address and connect again.') + return self.selection['ready'] = True self.ensure_shortcut() self.attach_console(info) - self.state = dict(self.state, status='open', url=url+'/?freevideo=launch', selection=dict(self.selection)) + self.state = dict(self.state, status='open', url=loopback_url(url)+'/?freevideo=launch', selection=dict(self.selection)) + if self.selection['lan_access']: + self._configure_access(dict(lan_access=True)) return # Windows keeps a folder made by a run as administrator, or under another # account, from this account; the bundled Python would only report a @@ -159,6 +179,10 @@ def _launch(self, values): 'import sys;sys.path.insert(0,sys.argv[1]);' 'from freevideo_engine.portable import main;' 'sys.exit(main(sys.argv[2], ["--initialize"]))', str(self.source), str(self.root)] + # -I ignores PYTHONUTF8, so name the mode on the command line: the start + # process then reads and writes UTF-8 like every other FreeVideo process, + # whatever the system code page is. + command[1:1] = ['-X', 'utf8'] tail = '' view = launch_view.view_for(self.root) self.progress(dict(stage='verify', done=0, total=None, detail='Check local files')) @@ -201,12 +225,54 @@ def _launch(self, values): self.progress(dict(stage='comfy', detail='Open FreeVideo workflow')) opened = portable.connect(self.root, self.bundle, machine, self.progress_keywords, self.cancelled, url=url, on_controller=self.retain_comfy_controller, - previous=self.comfy_controller) + previous=self.comfy_controller, lan_access=self.selection['lan_access'], + lan_port=self.selection.get('lan_port')) + if self.comfy_controller is not None: + self._sync_access() self.selection['ready'] = True self.ensure_shortcut() self.state = dict(self.state, status='open', url=opened, selection=dict(self.selection), + lan_status=self.comfy_controller.state.get('lan_status', '') if self.comfy_controller else '', overall=dict(done=3, total=3, label='Ready')) + def _sync_access(self, status=True): + child = self.comfy_controller + self.selection.update({key: child.selection[key] for key in ('url', 'lan_access', 'lan_port') + if key in child.selection}) + self.access_links = child.access_links + self.state = dict(self.state, selection=dict(self.selection), lan_status=child.state.get('lan_status', '')) + if status: + self.state = dict(self.state, status=child.state['status'], url=child.state.get('url', '')) + + def _configure_access(self, values, *, connect=False): + if connect: + return self._launch(dict(installation=dict(self.selection, **values))) + if self.comfy_controller is None: + pending = self.state.get('engine_update_available', False) + try: + return super()._configure_access(values) + finally: + self.state = dict(self.state, engine_update_available=pending) + try: + self.comfy_controller._configure_access(values) + finally: + self._sync_access() + + def stop_owned_server(self, *, grace=5): + if self.comfy_controller is not None: + self.comfy_controller.stop_owned_server(grace=grace) + super().stop_owned_server(grace=grace) + + def check_access_proxy(self): + if self.comfy_controller is None: + return super().check_access_proxy() + if self.comfy_controller.access_proxy is None: + return + status = self.comfy_controller.state.get('status') + self.comfy_controller.check_access_proxy() + # The package's own state is copied only when this check saw ComfyUI stop. + self._sync_access(self.comfy_controller.state.get('status') != status) + def ensure_shortcut(self): from .desktop_shortcut import create_after_install self.state = dict(self.state, shortcut=create_after_install( @@ -218,6 +284,10 @@ def retain_comfy_controller(self, controller): if self._closed or self.cancelled.is_set(): controller.close() raise RuntimeError('Startup cancelled; all files retained') + # An external connection can own a proxy directly on the package + # controller. Retire it before handing startup to the inner controller; + # later switch-off and polling operate through that inner controller. + self.stop_access_proxy() self.comfy_controller = controller def close(self): @@ -266,8 +336,10 @@ def changed(self, *_): if self.loading_settings: return self.queue_save() + if self.launch_selection and _ and _[0] in (str(self.url), str(self.lan_access)): + return # The shared settings saver applies the connection change. if not self.controller.busy: - self.controller.restore({'url': self.url.get()}) + self.controller.restore({'url': self.url.get(), 'lan_access': self.lan_access.get()}) self.launch_selection = dict(self.controller.selection) self.reset_browser() self.refresh() @@ -309,6 +381,8 @@ def gui(root): def portable_session(root): from .launcher_session import Session root = Path(plain(resolved(root))) + from .triton_compat import own_temporary_environment + own_temporary_environment(root / 'engine') os.environ.update(portable.environment(root, portable.manifest(root))) os.environ['FREEVIDEO_LAUNCHER_HOME'] = str(root/'engine/launcher') class PortableSession(Session): @@ -322,12 +396,7 @@ def __init__(self): self.page = 'launcher' def edit(self, key, value): - if key == 'url' and not self.controller.busy: - value = local_url(value) - self.controller.restore({'url': value}) - self.remember(self.controller.selection) - self.browser_attempted = False - elif key in ('language', 'token'): + if key in ('url', 'lan_access', 'language', 'token'): super().edit(key, value) def action(self, name, accepted=False): diff --git a/freevideo_engine/precompile.py b/freevideo_engine/precompile.py index f12f8a3b..74959029 100644 --- a/freevideo_engine/precompile.py +++ b/freevideo_engine/precompile.py @@ -172,6 +172,9 @@ def run(machine, *, environment=None, timeout=TIMEOUT_SECONDS, background=False, from .resource_history import ResourceHistory environment = dict(os.environ if environment is None else environment) root = Path(machine['root']) + from .triton_compat import own_temporary_environment, temporary_environment + own_temporary_environment(root) + environment = temporary_environment(root, environment) receipt = dict(success=False, started=time.time(), shapes=[]) if machine.get('gpu_vendor') == 'amd': # Not measured on AMD GPUs yet; their first request compiles as before. @@ -281,11 +284,15 @@ def check_kernels(hardware, machine, environment, room): In the background they run in a child this process watches like a worker and stops when the room is needed; an interrupted check publishes no receipt. """ - from .kernel_capabilities import available_backends, matching_receipt + from .kernel_capabilities import available_backends, matching_receipt, needs_path_recheck + from .triton_compat import own_temporary_environment, temporary_environment + own_temporary_environment(machine['root']) + environment = temporary_environment(machine['root'], environment) if room is None: available_backends(hardware) return - if matching_receipt(hardware) is not None: + receipt = matching_receipt(hardware) + if receipt is not None and not needs_path_recheck(receipt, environment): return # A forced stop skips the cleanup below; an hour-old folder is no live check. for stale in Path(tempfile.gettempdir()).glob('freevideo-kernel-checks-*'): @@ -442,6 +449,11 @@ def watch_worker(command, env, descriptor, log, timeout, room): from .ram import ProcessMemory memory = ProcessMemory() if room is not None else None checkpoint(room) # Never start a child after a stop or with the room needed. + from .triton_compat import temporary_environment, temp_root + from .system import install_root + managed_temp = temp_root(env) + root = env.get('FREEVIDEO_HOME') or (Path(managed_temp).parent if managed_temp else install_root()) + env = temporary_environment(root, env) with log.open('wb') as stream: process = processes.popen(command, env=env, stdout=stream, stderr=subprocess.STDOUT, pass_fds=(descriptor,) if descriptor is not None else (), start_new_session=True) @@ -496,6 +508,16 @@ def lower_priority(): os.nice(19) +def reclaim(machine): + """Free the AdaLN sources a table made unused; disk only, so the GPU may be busy (adaln_reclaim).""" + from .adaln_reclaim import run as reclaim_sources + result = reclaim_sources(machine, should_stop=CANCELLED.is_set) + if result['action'] == 'deleted' or result['reason'] not in ('full_install', 'no_manifest', + 'no_old_grid_table', 'unexpected_manifest'): + print(json.dumps(dict(event='adaln_reclaim', **result)), flush=True) + return result + + def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--config', type=Path, required=True, help='The installation machine.json') @@ -515,8 +537,11 @@ def stopped(signum, frame): try: from .testing import environment # A request's own variables: the installation, its caches and its GPU. + from .triton_compat import own_temporary_environment + own_temporary_environment(machine.get('root')) os.environ.update(execution_environment(environment(machine))) owner = os.environ.get(OWNER_ENV) + reclaim(machine) receipt = run(machine, background=args.background, owner=int(owner) if owner else None) except Exception as error: if args.background and isinstance(error, BlockingIOError): diff --git a/freevideo_engine/progress_timeline.py b/freevideo_engine/progress_timeline.py index bc755be5..bb2bab87 100644 --- a/freevideo_engine/progress_timeline.py +++ b/freevideo_engine/progress_timeline.py @@ -168,6 +168,7 @@ 'Prepare ComfyUI environment', 'Prepare FreeVideo', 'Prepare download tools', + 'Preparing installation', 'Preparing # s video + audio · # × #', 'Preparing offloaded weight storage', 'Preparing offloaded weight storage · # / # blocks', diff --git a/freevideo_engine/proxy.py b/freevideo_engine/proxy.py index 73c09e17..c3c1de35 100644 --- a/freevideo_engine/proxy.py +++ b/freevideo_engine/proxy.py @@ -3,6 +3,7 @@ import re import shlex import shutil +import socket import subprocess import sys from urllib import request @@ -15,6 +16,62 @@ GIT_PROXY_PATTERN = r'^(http\..*proxy|https\.proxy|remote\..*\.proxy|core\.gitproxy)$' +def local_addresses(): + """IPv4 interfaces served by the launcher's 0.0.0.0 proxy, on every OS.""" + import ipaddress + import psutil + found = [] + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as route: + route.connect(('8.8.8.8', 80)) + found.append(route.getsockname()[0]) + except OSError: + pass + try: + found.extend(row.address for rows in psutil.net_if_addrs().values() for row in rows + if row.family == socket.AF_INET) + except OSError: + pass + result = [] + for host in found: + try: + ip = ipaddress.ip_address(host) + except ValueError: + continue + if ip.version == 4 and not (ip.is_loopback or ip.is_unspecified or ip.is_link_local): + result.append(str(ip)) + return list(dict.fromkeys(result)) + + +def bypass_local(environ, *addresses): + """Keep this computer's UI traffic local without changing download routes.""" + env = dict(environ) + if not configured(env): + # A lone NO_PROXY makes urllib and its users skip the proxy saved in the + # operating system, so add exceptions only beside a proxy variable. + return env + import ipaddress + import psutil + hosts = ['localhost', '127.0.0.1', '::1', '0.0.0.0', '::', *addresses] + try: + hosts.extend(row.address.split('%', 1)[0] for rows in psutil.net_if_addrs().values() + for row in rows if row.family in (socket.AF_INET, socket.AF_INET6)) + except OSError: + pass + existing = ','.join(env.get(key, '') for key in ('no_proxy', 'NO_PROXY')) + entries = [value.strip() for value in existing.split(',') if value.strip()] + for host in hosts: + if host: + entries.append(host) + try: + if ipaddress.ip_address(host).version == 6: + entries.append('[' + host + ']') + except ValueError: + pass + env['no_proxy'] = env['NO_PROXY'] = ','.join(dict.fromkeys(entries)) + return env + + def inherited(environ=None): from .linux_bundle import child_environment env = child_environment(environ) diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 9c77e76b..40987da1 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -5,34 +5,34 @@ "summary": "Adds an editing view, long videos generated one part after another, and AI agent connections, and makes generation faster.", "changes": [ "New editing view: arrange generated clips on a timeline, add text and subtitles, change speed or reverse a clip, and choose the size and quality when exporting.", - "Long videos can be generated one part after another: select a range on the timeline in the editing view and press Generate, and each part continues from the one before.", - "Clips can be extended in the editing view: drag a new part out of the end of a clip and press Generate, and it continues from the clip.", - "A \"First-frame reference\" switch reduces drift when a clip is extended.", + "Long videos can be generated one part after another: select a range on the timeline in the editing view and press Generate, and each part continues from the one before. A 30-second video is generated in two parts.", + "Clips can be extended in the editing view: drag a new part out of the end of a clip and press Generate, and it continues from the clip. A \"First-frame reference\" switch reduces drift when a clip is extended.", "The editing view can create subtitles automatically: it recognizes Chinese, English, Japanese, Korean and Cantonese speech and gives each line its text and timing.", "A step-by-step guide appears the first time FreeVideo opens, and can be viewed again from Settings.", "Connect an AI agent and let it generate, edit and export videos.", + "A new \"LAN access\" switch in Settings lets phones and other computers on the same network open FreeVideo with a link.", + "After an offline package is imported, FreeVideo offers to delete the ZIP files that are no longer needed, freeing up disk space.", "LoRAs can be added directly in the creation panel.", - "A 30-second video is generated in two parts.", "The FreeVideo · Media node accepts several reference images.", "Faster generation: the wait before decoding is shorter; when generating several videos in a row, the models load faster for the later ones. On computers with less memory, background compiling after an update uses less memory.", - "Fixes: generating with several large reference images no longer runs out of memory; 16:9 videos can be saved as share videos; making the full video from a preview no longer stalls on a single step now and then; when setup cannot find a graphics driver, the error says so; when security software removes one of FreeVideo's files, the error says why and how to restore it." + "Fixes: generating with several large reference images no longer runs out of memory; 16:9 videos can be saved as share videos; making the full video from a preview no longer stalls on a single step now and then; when setup cannot find a graphics driver, the error says so; setup shows the current step; when security software removes one of FreeVideo's files, the error says why and how to restore it; on a slow network, setup waits longer and tries once more when no download source responds; on English Windows, FreeVideo starts when the install folder or the user name contains Chinese characters." ] }, "zh": { "summary": "新增剪辑视图、一段接一段的长视频生成,并可以连接 AI Agent;生成也更快。", "changes": [ "新增剪辑视图:把生成的片段排在时间线上剪辑,可以加文字和字幕、变速和倒放;导出时可以选画面大小和画质。", - "长视频可以一段接一段生成:在剪辑视图的时间线上选好范围再点生成,每一段都接着前一段往下生成。", - "剪辑视图里可以延长片段:从片段尾巴拖出一段再点生成,接在原片段后面往下生成。", - "延长片段时可以打开“首帧参考”,减小画面漂移。", + "长视频可以一段接一段生成:在剪辑视图的时间线上选好范围再点生成,每一段都接着前一段往下生成;30 秒的视频分两段生成。", + "剪辑视图里可以延长片段:从片段尾巴拖出一段再点生成,接在原片段后面往下生成;延长片段时可以打开“首帧参考”,减小画面漂移。", "剪辑视图可以自动生成字幕:识别中文、英文、日文、韩文和粤语语音,得到每句的文字和时间。", "第一次打开时有分步指引,设置里可以再看一遍。", "可以连接 AI Agent,让它生成、剪辑和导出视频。", + "设置里新增“局域网访问”开关,打开后,同一网络里的手机和其他电脑可以用链接打开 FreeVideo。", + "导入离线包以后,可以删掉用不到的压缩包,释放磁盘空间。", "创作面板里可以直接添加 LoRA。", - "30 秒的视频分两段生成。", "FreeVideo · Media 节点可以接多张参考图。", "生成更快:解码前的等待更短;连续生成时,后面的视频加载模型更快。内存较小的电脑上,更新后的后台编译占用的内存更少。", - "修复:用多张较大的参考图生成时不再内存不足;16:9 的视频可以保存分享视频;先出预览再生成正式视频时,不再偶尔在某一步停留很久;安装时没有找到显卡驱动,会直接说明;FreeVideo 的文件被安全软件移走时,会说明原因和恢复的办法。" + "修复:用多张较大的参考图生成时不再内存不足;16:9 的视频可以保存分享视频;先出预览再生成正式视频时,不再偶尔在某一步停留很久;安装时没有找到显卡驱动,会直接说明;安装时会显示当前步骤;FreeVideo 的文件被安全软件移走时,会说明原因和恢复的办法;网络较慢、下载源都连不上时,安装会多等一会儿再试一轮;英文 Windows 上,安装文件夹或用户名带中文时也能正常启动。" ] }, "history": [ @@ -250,25 +250,6 @@ "在 Windows 上,只要显存预算有余量,注意力改为每次计算 16 个头(原来每次 8 个)。12–20 GB 显卡的第一遍,以及大多数 16 GB 和 20 GB 显卡的第二遍都会用上。在 16 GB 显卡上生成 10 秒 1344×768 视频,第一遍每步缩短约 6%,第二遍每步缩短约 2%,显存占用没有增加。FP8 模型、使用 LoRA 的生成、Linux 和 12 GB 以下的显卡保持现有设置。同一个种子生成的视频可能与以前略有不同,就像在显存不同的显卡上本来就会有差异一样。" ] } - }, - { - "product_version": "0.3.16", - "en": { - "summary": "On Windows, FreeVideo repairs its desktop shortcut each time the launcher opens, and keeps starting normally after it was once run as administrator.", - "changes": [ - "On Windows, the launcher checks its desktop shortcut each time it opens and repairs it in place, and FreeVideo is now also added to the Start menu. A shortcut left by an earlier installation, or rewritten by Windows, is updated instead of a second \"FreeVideo (2)\" appearing next to it; a shortcut you renamed keeps its name, and one you gave another icon is left as it is. If Windows does not allow writing to the desktop (for example Controlled folder access in Windows Security), the launcher says so and how to allow FreeVideo, and FreeVideo is still in the Start menu.", - "After FreeVideo was once run as administrator, its desktop shortcut still opens it: the launcher copy behind the shortcut stays readable without administrator rights, and an unreadable copy left by an earlier version is replaced the next time the launcher opens.", - "Offline installations also keep starting after FreeVideo was once run as administrator. If Windows denies this account access to FreeVideo's program files, the launcher says so and how to fix it, instead of reporting a missing module." - ] - }, - "zh": { - "summary": "在 Windows 上,每次打开启动器都会检查并修复桌面快捷方式;以管理员身份运行过一次之后,FreeVideo 也能正常启动。", - "changes": [ - "在 Windows 上,启动器每次打开时都会检查桌面快捷方式并就地修复,开始菜单里现在也会有 FreeVideo。以前安装留下的、或被 Windows 改写过的快捷方式会直接更新,不会再在旁边多出一个“FreeVideo (2)”;改过名字的快捷方式保留原名,换过图标的保持不变。如果 Windows 不允许写入桌面(例如开启了“Windows 安全中心”的“受控文件夹访问”),启动器会说明原因和放行办法,开始菜单里仍然可以找到 FreeVideo。", - "以管理员身份运行过一次之后,桌面快捷方式仍能打开 FreeVideo:快捷方式指向的启动器副本不需要管理员权限也能读取;之前版本留下的读不了的副本,会在下次打开启动器时换掉。", - "以管理员身份运行过一次之后,离线安装也能正常启动;如果 Windows 不允许当前账户读取程序文件,启动器会说明原因和处理办法,不再只报缺少模块。" - ] - } } ] } diff --git a/freevideo_engine/resident_process.py b/freevideo_engine/resident_process.py index a16607c8..e76366bd 100644 --- a/freevideo_engine/resident_process.py +++ b/freevideo_engine/resident_process.py @@ -484,6 +484,8 @@ def request_watch(): environment = execution_environment(request['environment']) os.environ.clear() os.environ.update(environment) + from .triton_compat import own_temporary_environment + own_temporary_environment() os.environ.pop(processes.HANDLE_ENV, None) os.environ['FREEVIDEO_RUNTIME_LOCK_FD'] = str(descriptor) from .locking import runtime_lock diff --git a/freevideo_engine/runtime.py b/freevideo_engine/runtime.py index 4b24f8e4..cda824ba 100644 --- a/freevideo_engine/runtime.py +++ b/freevideo_engine/runtime.py @@ -658,6 +658,8 @@ def append(self, value): if prefix is not None: prefix.restore_video() super().append(value) + if watch is not None: + watch.step_done(pass_cache) count = getattr(offloader, 'drive_reads', None) total = count() if count is not None else None if total is None: @@ -679,6 +681,19 @@ def append(self, value): live_budget = budget_refresh('sample', lambda limit: offloader.cache_between_steps( min(cached, max(0, limit - base_peak - 512 * 2**20)), allow_growth=False), workspace=base_peak) + if watch is not None and type(live_budget) is int: + # After the refresh: a cut since the last layer's read + # is in `live_budget`. The planned group's workspace + # replaces the narrower one's, and the cache this pass + # grew beside the narrower group gives back what that + # workspace needs. + wide = watch.restore_if_room(live_budget) + if wide is not None: + base_peak = max(base_peak, wide) + pass_cache['workspace_peak_bytes'] = base_peak + offloader.cache_between_steps( + min(offloader.cached_bytes(), max(0, live_budget - base_peak - 512 * 2**20)), + allow_growth=False) if len(self) < active_steps: from .two_pass import pass_cache_allowance from .system import system_memory @@ -751,12 +766,22 @@ def append(self, value): if active_steps == 1: trigger_prefetch() checkpoint_seconds = 0. + # A budget cut inside a step (Windows): see step_pressure. The watch is + # the innermost context, so a narrowed group is put back before the + # offloader and the progress hooks leave. + from contextlib import nullcontext from .continuation import sampling + planned, watch = self.config, None + source = getattr(budget_refresh, 'pressure', None) + if source is not None: + from .step_pressure import LayerWatch + watch = LayerWatch(self, source, pass_cache=pass_cache) try: with progress.layers(self.transformer.transformer_blocks), self.device_backend.make_offloader( self.offload_layers, prefetch=self.prefetch, weight_source=self.stream_weight_source) as offloader, sampling( self.transformer, continuation, seed, self.device_backend.device, - **({'video_offset': continuation_offset} if continuation_offset else {})) as prefix: + **({'video_offset': continuation_offset} if continuation_offset else {})) as prefix, \ + (watch.layers(self.transformer.transformer_blocks) if watch is not None else nullcontext()): latents, audio = generate_latents(self.transformer, prompt, tags, frames, self.steps, seed, self.device_backend.device, step_seconds=step_seconds, conditions=conditions) @@ -769,7 +794,7 @@ def append(self, value): sampled = {'sample_seconds': time.perf_counter() - started, 'step_seconds': list(step_seconds), 'offload': stats, 'residual_offload_steps': residual_steps, 'pass_cache_admission': pass_cache, - 'compute_configuration': {key: self.config.get(key) for key in + 'compute_configuration': {key: planned.get(key) for key in ('head_chunk', 'window_batch', 'ff_chunk', 'projection_chunk', 'prefetch', 'attention_cpu_outputs', 'grouped_attention_outputs', 'residual_offload', 'fp8_ff_recompute', 'resident_blocks', 'stream_weights', 'pin_host_gb')}, @@ -785,6 +810,10 @@ def append(self, value): 'text_refinement': refinement, 'torch_peak_allocated_bytes': self.device_backend.max_memory_allocated(), 'torch_peak_reserved_bytes': self.device_backend.max_memory_reserved()} + if watch is not None: + sampled['step_pressure'] = watch.result() + sampled['torch_peak_allocated_bytes'], sampled['torch_peak_reserved_bytes'] = watch.peaks( + sampled['torch_peak_allocated_bytes'], sampled['torch_peak_reserved_bytes']) if refining: sampled['refinement'] = dict(base_steps=self.steps, steps=refine_steps, start_index=1 if refine_schedule else self.steps - refine_steps, restart_seed=seed, @@ -801,8 +830,11 @@ def append(self, value): self.device_backend.synchronize() elapsed = time.perf_counter() - started - checkpoint_seconds self.device_backend.empty_cache() - sampled.update(sample_seconds=elapsed, torch_peak_allocated_bytes=self.device_backend.max_memory_allocated(), - torch_peak_reserved_bytes=self.device_backend.max_memory_reserved()) + allocated, reserved = self.device_backend.max_memory_allocated(), self.device_backend.max_memory_reserved() + if watch is not None: + sampled['step_pressure'] = watch.result() + allocated, reserved = watch.peaks(allocated, reserved) + sampled.update(sample_seconds=elapsed, torch_peak_allocated_bytes=allocated, torch_peak_reserved_bytes=reserved) return latents, audio, sampled @torch.no_grad() diff --git a/freevideo_engine/runtime_alias.py b/freevideo_engine/runtime_alias.py index 02c93c7c..68b41422 100644 --- a/freevideo_engine/runtime_alias.py +++ b/freevideo_engine/runtime_alias.py @@ -149,6 +149,10 @@ class _WinAPI: def __init__(self): dll = ctypes.WinDLL('kernel32', use_last_error=True) declarations = { + 'GetACP': ([], _DWORD), + 'WideCharToMultiByte': ([_DWORD, _DWORD, ctypes.c_wchar_p, ctypes.c_int, + ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p, + ctypes.POINTER(_BOOL)], ctypes.c_int), 'CreateDirectoryW': ([ctypes.c_wchar_p, ctypes.c_void_p], _BOOL), 'CreateFileW': ([ctypes.c_wchar_p, _DWORD, _DWORD, ctypes.c_void_p, _DWORD, _DWORD, _HANDLE], _HANDLE), @@ -256,6 +260,22 @@ def long_paths(self): except (ImportError, AttributeError, OSError, ValueError): return False + def ansi_path(self, path): + """Whether the system code page spells this path without substitution.""" + try: + api = _win_api() + code_page = api.GetACP() + if code_page == 65001: + return True + substituted = _BOOL() + # WC_NO_BEST_FIT_CHARS: even a similar character names a different file. + api.check(api.WideCharToMultiByte(code_page, 0x400, str(path), -1, + None, 0, None, ctypes.byref(substituted))) + return not substituted.value + except (AttributeError, OSError, ValueError, TypeError, ctypes.ArgumentError): + # Not knowing is no reason for a short folder: the length decides, as it did. + return True + def locations(self): return [Path(plain(value, windows=True)) / 'FV' for key in ('ProgramData', 'LOCALAPPDATA') if (value := os.environ.get(key)) and Path(plain(value, windows=True)).is_absolute()] @@ -505,6 +525,11 @@ def needs_view(root, longest=None) -> bool: return False if os.environ.get('FREEVIDEO_RUNTIME_ALIAS') == '1': return True + # The kernel compiler reads its cache folder and finds its own headers through the system's + # ANSI code page. A folder that code page cannot spell is one it cannot use, whatever its + # length; the short folder is plain letters. + if not _backend.ansi_path(root): + return True # An installation that runs from where it is keeps doing so; only one beyond its budget is # given the short folder. if longest is None and system_long_paths(): diff --git a/freevideo_engine/step_pressure.py b/freevideo_engine/step_pressure.py new file mode 100644 index 00000000..e11084ea --- /dev/null +++ b/freevideo_engine/step_pressure.py @@ -0,0 +1,267 @@ +"""React inside a sampling step when Windows cuts this process's video-memory budget. + +Windows moves a process's video memory to system memory while the process +stays over the budget the driver gives it, and every layer then waits for that +memory to come back. The budget was only read between steps, so a cut that +arrived inside a step slowed the whole step several times over: one step took +ten times as long as its neighbours. + +Between layers this reads the budget and how long the layer just finished +took. When the pass is over its budget and held up, it first releases what the +pass does not need (idle models), then halves the attention head group for the +layers that follow. Between steps the planned group returns, once per pass, +when every read of a whole step left room for what the pass held with it. + +While the budget covers the pass nothing changes: the hooks read two counters +and a clock, no tensor is written and the installed forwards stay as they +were, so outputs are bit-identical. A pass that was narrowed computes its +remaining layers with the narrower group, which rounds differently: the same +seed then gives a slightly different video, as it does on a card whose plan +takes the narrower group from the start. +""" +from contextlib import ExitStack, contextmanager +import gc +import json +import time + +MiB, GiB = 1 << 20, 1 << 30 +# Readings within this of the budget are not over it: the driver's counters +# and the allocator's pool move by a few MiB between two reads. +OVER_BYTES = 32 * MiB +# A layer is held up when it took this many times as long as the same layer of +# the previous step, and at least this much longer. Layers read from a slow +# drive are slow in every step and never count. +SLOW_FACTOR, SLOW_SECONDS = 3., 5. +# The first step of a pass has no earlier step to compare with. There a layer +# counts when the pass is over its budget by this much and the layer took at +# least SLOW_SECONDS: a layer of a fitting pass takes a fraction of that. +CUT_BYTES = GiB +# Time spent in held-up layers before anything is done, and again before the +# next measure: a budget that dips for a few seconds comes back by itself. +SUSTAIN_SECONDS = 15. +# The planned group returns when the budget stayed this far above what the +# pass used with it, at every read of one whole step. +RESTORE_BYTES = 256 * MiB +# Narrower groups than this were measured several times slower with the +# attention outputs in host memory, and save little. +MIN_HEAD = 4 + + +def emit_event(event): + print(json.dumps(event), flush=True) + + +class LayerWatch: + """One pass's reaction to a budget cut. `source` supplies the readings: + + local() -> {'budget_bytes', 'usage_bytes'} for this process, or None + release() -> bytes freed by dropping idle models (may be 0) + limit() -> the allocator's current ceiling in bytes, or None + """ + def __init__(self, engine, source, *, clock=time.monotonic, emit=emit_event, pass_cache=None): + self.engine, self.source, self.clock, self.emit = engine, source, clock, emit + self.pass_cache = pass_cache # the pass's cache record, shared with the sampler + self.planned = engine.config.get('head_chunk') + self.stack = None # open while a narrower group is installed + self.previous = {} # layer -> seconds it took in the last completed step + self.current = {} # layer -> seconds in the running step + self.entered = None # (layer, time) of the layer being dispatched + self.pressed = 0. # seconds of held-up layers since the last measure + self.released = False + self.exhausted = False + self.wide_usage = 0 # most this process held with the planned group + self.wide_peak = 0 # allocator peaks with the planned group, kept across the reset + self.wide_cache = 0 # largest cache the pass held before its first narrowing + self.boundary = None # (lowest budget read, a reading failed) of the step just ended + self.low_budget = None # lowest budget read in the running step + self.unread_step = False # a reading of the running step failed + self.pass_allocated = 0 # allocator peaks of the whole pass, kept across every reset + self.pass_reserved = 0 + self.reset_workspace = False + self.restored = False # the planned group came back once in this pass + self.step = 1 + self.receipt = dict(planned_head_chunk=self.planned, polls=0, unread=0, over_polls=0, + held_up_layers=0, actions=[]) + + def read(self): + try: + sample = self.source.local() + except (OSError, RuntimeError, AttributeError, ValueError): + sample = None + if (not isinstance(sample, dict) or type(sample.get('budget_bytes')) is not int + or type(sample.get('usage_bytes')) is not int or sample['budget_bytes'] <= 0): + self.receipt['unread'] += 1 + return None + self.receipt['polls'] += 1 + return sample + + def enter(self, index): + """Before layer `index` (1-based) is dispatched.""" + now = self.clock() + took = None + if self.entered is not None and self.entered[0] == index - 1: + took = now - self.entered[1] + self.current[index - 1] = took + self.entered = (index, now) + sample = self.read() + if sample is None: + self.pressed = 0. + self.unread_step = True + return + budget, usage = sample['budget_bytes'], sample['usage_bytes'] + self.low_budget = budget if self.low_budget is None else min(self.low_budget, budget) + if self.stack is None: + self.wide_usage = max(self.wide_usage, usage) + over = usage - budget + if over >= OVER_BYTES: + self.receipt['over_polls'] += 1 + if over < OVER_BYTES or took is None: + self.pressed = 0. + return + before = self.previous.get(index - 1) + if before is not None: + held_up = took >= max(SLOW_FACTOR * before, before + SLOW_SECONDS) + else: + held_up = over >= CUT_BYTES and took >= SLOW_SECONDS + if not held_up: + self.pressed = 0. + return + self.receipt['held_up_layers'] += 1 + self.pressed += took + if self.pressed >= SUSTAIN_SECONDS: + self.pressed = 0. + self.act(index, sample, took) + + def record(self, action, index, sample, **fields): + row = dict(action=action, step=self.step, layer=index, + budget_bytes=sample['budget_bytes'] if sample else None, + usage_bytes=sample['usage_bytes'] if sample else None, **fields) + if len(self.receipt['actions']) < 32: + self.receipt['actions'].append(row) + self.emit(dict(event='step_pressure', **row)) + + def act(self, index, sample, took): + if not self.released: + self.released = True + try: + freed = int(self.source.release() or 0) + except (OSError, RuntimeError, AttributeError, ValueError): + freed = 0 + if freed > 0: + self.record('release_idle', index, sample, freed_bytes=freed, layer_seconds=took) + return + self.narrow(index, sample, took) + + def narrow(self, index, sample, took): + config = self.engine.config + head = config.get('head_chunk') + if (not config.get('inference_kernels') or type(head) is not int or head // 2 < MIN_HEAD): + if not self.exhausted: + self.exhausted = True + self.record('nothing_left', index, sample, head_chunk=head, layer_seconds=took) + return + from .pass_configuration import configuration + from . import head_chunk + backend = self.engine.device_backend + reserved, allocated = backend.max_memory_reserved(), backend.max_memory_allocated() + if self.stack is None: + self.stack = ExitStack() + self.wide_peak = max(self.wide_peak, reserved) + # The cache of later steps is not part of that peak: remember what + # the peak held, so the planned group's own workspace is not read + # as the peak minus a cache that grew beside the narrower group. + held = (self.pass_cache or {}).get('peak_cache_bytes', 0) + self.wide_cache = max(self.wide_cache, min(held, reserved)) + # Every reset below forgets what the group before it reached. + self.pass_reserved = max(self.pass_reserved, reserved) + self.pass_allocated = max(self.pass_allocated, allocated) + self.stack.enter_context(configuration(self.engine, dict(head_chunk=head // 2))) + # The narrower group's first call compiles: collect after each of its + # groups again, or the frames that compile leaves behind stay beside + # the next group exactly when this pass must hold less. + head_chunk.COLLECTED_LENGTHS.clear() + gc.collect() + backend.empty_cache() + # The next step boundary asks for the budget to cover the pass as it now runs. + backend.reset_peak_memory_stats() + self.reset_workspace = True + # Times of the wider group no longer say what a layer should take. + self.previous, self.current = {}, {} + self.record('narrow', index, sample, head_chunk_from=head, head_chunk_to=head // 2, + layer_seconds=took) + + def restore(self, sample=None): + stack, self.stack = self.stack, None + if stack is not None: + self.restored = True + stack.close() + self.previous, self.current = {}, {} + self.record('restore', 0, sample, head_chunk_to=self.engine.config.get('head_chunk')) + + def step_done(self, pass_cache): + """At a step boundary, before the budget refresh reads the pass's workspace.""" + self.previous, self.current, self.entered, self.pressed = self.current, {}, None, 0. + self.step += 1 + self.pass_cache = pass_cache + if self.reset_workspace: + # Measured again from the allocator's peak since the group narrowed. + self.reset_workspace = False + pass_cache['workspace_peak_bytes'] = 0 + low, self.low_budget = self.low_budget, None + # A step with a failed reading did not show room at every read. + unread, self.unread_step = self.unread_step, False + self.boundary = (low, unread) + + def restore_if_room(self, limit): + """After the boundary's budget refresh: the planned group returns when the + step's readings and the refreshed ceiling `limit` both leave room for it. + + Returns the planned group's workspace in bytes when it came back, else + None. The caller then holds the pass cache to what that workspace leaves. + """ + boundary, self.boundary = self.boundary, None + if boundary is None: + return None + low, unread = boundary + # Once per pass: a budget that comes back and is cut again would + # otherwise switch groups at every boundary, recompiling each time. + if self.stack is None or low is None or unread or self.restored: + return None + if low < self.wide_usage + RESTORE_BYTES: + return None + # The ceiling the refresh just set: a cut after the last layer's read + # is already in it. + if type(limit) is not int: + try: + limit = self.source.limit() + except (OSError, RuntimeError, AttributeError, ValueError): + limit = None + if type(limit) is not int or limit < self.wide_peak: + return None + self.restore(dict(budget_bytes=low, usage_bytes=self.wide_usage)) + return max(0, self.wide_peak - self.wide_cache) + + @contextmanager + def layers(self, blocks): + handles = [] + try: + for index, block in enumerate(blocks, 1): + def entered(module, inputs, index=index): + self.enter(index) + handles.append(block.register_forward_pre_hook(entered)) + yield self + finally: + for handle in handles: + handle.remove() + stack, self.stack = self.stack, None + self.receipt['narrowed_at_end'] = stack is not None + self.receipt['head_chunk_at_end'] = self.engine.config.get('head_chunk') + if stack is not None: + stack.close() + + def peaks(self, allocated, reserved): + """The pass's allocator peaks, including the part before a narrowing reset them.""" + return max(allocated, self.pass_allocated), max(reserved, self.pass_reserved) + + def result(self): + return dict(self.receipt, wide_peak_reserved_bytes=self.wide_peak or None) diff --git a/freevideo_engine/subprocess_output.py b/freevideo_engine/subprocess_output.py new file mode 100644 index 00000000..263f8f1c --- /dev/null +++ b/freevideo_engine/subprocess_output.py @@ -0,0 +1,110 @@ +"""Explicit pipe encodings for Python workers and byte capture for native tools.""" +import locale +import os +import subprocess +import sys + +from . import execution_paths + + +def python_environment(environ=None): + """Copy per-child settings without changing the launcher's environment.""" + env = dict(os.environ if environ is None else environ) + env.update(PYTHONUTF8='1', PYTHONIOENCODING='utf-8') + return env + + +def python_command(command): + command = list(command) + # The frozen executable has its own argument parser, not Python's. Its + # --freevideo-module entry configures the streams before dispatching. + if getattr(sys, 'frozen', False) and str(command[0]) == sys.executable: + return command + # -I/-E and Windows portable ._pth ignore PYTHON* environment settings. + # The command-line mode still makes redirected stdout/stderr UTF-8. + return [command[0], '-X', 'utf8', *command[1:]] + + +def _python_options(options): + options = dict(options) + options['env'] = python_environment(options.get('env')) + options.pop('universal_newlines', None) + options.update(text=True, encoding='utf-8', errors='backslashreplace') + return options + + +def run_python(command, *, runner=None, **options): + return (runner or execution_paths.run)(python_command(command), **_python_options(options)) + + +def output_python(command, **options): + return execution_paths.check_output(python_command(command), **_python_options(options)) + + +def decode_output(value, encoding=None): + """Prefer UTF-8, then the native code page; preserve undecodable bytes. + + Native programs do not share Python's UTF-8 contract. In particular Git + and uv commonly emit UTF-8 even on legacy Windows installations. An + unknown byte is displayed as \\xNN, never discarded as empty output. + """ + if value is None or isinstance(value, str): + return value + try: + text = value.decode('utf-8') + except UnicodeDecodeError: + # Support the stdlib-only diagnostic entry on older bootstrap Python, + # while 3.12 uses the native code page even in Python UTF-8 mode. + native_encoding = (locale.getencoding() if hasattr(locale, 'getencoding') + else locale.getpreferredencoding(False)) + text = value.decode(encoding or native_encoding, errors='backslashreplace') + return text.replace('\r\n', '\n').replace('\r', '\n') + + +def _byte_options(options): + options = dict(options) + encoding = options.pop('encoding', None) + for name in ('text', 'universal_newlines', 'errors'): + options.pop(name, None) + if isinstance(options.get('input'), str): + options['input'] = options['input'].encode(encoding or 'utf-8') + return options, encoding + + +def _decode_error(error, encoding): + # TimeoutExpired retains partial bytes even for text-mode subprocesses. + error.output = decode_output(error.output, encoding) + error.stderr = decode_output(error.stderr, encoding) + + +def run_external(command, *, runner=None, **options): + options, encoding = _byte_options(options) + check = options.pop('check', False) + + def decoded(command, **options): + try: + result = (runner or subprocess.run)(command, **options) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: + _decode_error(error, encoding) + raise + result.stdout = decode_output(getattr(result, 'stdout', None), encoding) + result.stderr = decode_output(getattr(result, 'stderr', None), encoding) + return result + # Decode first: short installation paths are put back in text, not in bytes. + # A caller's own runner already starts its child from the short folder. + result = decoded(command, **options) if runner else execution_paths.call(decoded, command, **options) + if check: + result.check_returncode() + return result + + +def output_external(command, **options): + options, encoding = _byte_options(options) + + def decoded(command, **options): + try: + return decode_output(subprocess.check_output(command, **options), encoding) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: + _decode_error(error, encoding) + raise + return execution_paths.call(decoded, command, **options) diff --git a/freevideo_engine/triton_compat.py b/freevideo_engine/triton_compat.py index ea157557..e07160af 100644 --- a/freevideo_engine/triton_compat.py +++ b/freevideo_engine/triton_compat.py @@ -4,6 +4,132 @@ from pathlib import Path +TEMP_KEYS = ('TMPDIR', 'TEMP', 'TMP') +TEMP_ROOT_ENV = 'FREEVIDEO_COMPILER_TEMP_ROOT' + + +def temp_root(environ=None): + """The managed compiler temporary folder, or None for caller/system folders.""" + from . import runtime_alias + if not runtime_alias._backend.windows(): + return None + env = os.environ if environ is None else environ + root = env.get(TEMP_ROOT_ENV) + return root if root and all(env.get(key) == root for key in TEMP_KEYS) else None + + +def _writable_temporary(path): + import tempfile + from .long_paths import io_path + # dir= avoids consulting or caching the parent's default temporary folder. + with tempfile.TemporaryFile(prefix='freevideo-temp-check-', dir=io_path(path)) as stream: + stream.write(b'ok') + stream.flush() + + +def _usable_folder(path): + # Nothing is created or written to a folder that is only being looked at. + return os.path.isdir(path) and os.access(path, os.W_OK) + + +def _unsafe_temporary(environ): + from . import runtime_alias + explicit = [os.path.abspath(environ[key]) for key in TEMP_KEYS if environ.get(key)] + # Native compilers can prefer TMP over TEMP, unlike Python's TMPDIR first. + if any(not runtime_alias._backend.ansi_path(path) for path in explicit): + return True + for path in explicit: + if _usable_folder(path): + return False # a folder that is there and writable stays exactly as it is + # Python's Windows fallbacks, after unusable or missing explicit choices. + profile = environ.get('USERPROFILE') or os.path.expanduser('~') + candidates = [os.path.join(profile, 'AppData', 'Local', 'Temp'), + os.path.join(environ.get('SYSTEMROOT', r'C:\Windows'), 'Temp'), + r'c:\temp', r'c:\tmp', r'\temp', r'\tmp', os.curdir] + for path in candidates: + path = os.path.abspath(path) + if not runtime_alias._backend.ansi_path(path): + return True + if _usable_folder(path): + return False + return False + + +def _clean_temporary(path): + """Remove day-old children of this dedicated folder, without following links.""" + import shutil + import time + cutoff = time.time() - 86400 + for child in path.iterdir(): + try: + if child.lstat().st_mtime >= cutoff: + continue + if child.is_symlink(): + child.unlink() + elif child.is_junction(): + child.rmdir() + elif child.is_dir(): + shutil.rmtree(child) + else: + child.unlink() + except OSError: + pass # A compiler may still hold it; try again on the next start. + + +def temporary_environment(root, environ): + """Keep spellable temporary choices; otherwise use this installation's tmp.""" + import logging + from . import launch_view, runtime_alias + from .long_paths import io_path, path_chars, plain + if not runtime_alias._backend.windows(): + return environ + try: + # A spellable folder that is there stays as it is, whoever chose it (also another installation's); + # a managed one that has been removed is made again, for compilers that read TMP directly. + managed = temp_root(environ) + if not _unsafe_temporary(environ) and not (managed and not os.path.isdir(managed)): + return environ + # Keep the lexical short spelling: resolve() would undo the junction. + root = Path(os.path.abspath(plain(root))) + view = launch_view.current(environ) or launch_view.current() or launch_view.for_engine(root) + temporary = Path(launch_view.value(root, view)) / 'tmp' + if not runtime_alias._backend.ansi_path(temporary): + raise OSError('installation temporary path cannot be represented by the system code page') + if path_chars(temporary) > 200: + raise OSError('installation temporary path exceeds 200 characters') + folder = io_path(temporary) + folder.mkdir(parents=True, exist_ok=True) + if folder.is_symlink() or folder.is_junction(): + raise OSError('installation temporary folder is a link') + _writable_temporary(temporary) + _clean_temporary(folder) + return dict(environ, **{key: str(temporary) for key in (*TEMP_KEYS, TEMP_ROOT_ENV)}) + except Exception as error: # A temporary folder choice never blocks the engine from starting. + logging.warning('[FreeVideo] Compiler temporary folder unchanged: %s', + str(error).replace('\r', ' ').replace('\n', ' ')) + return environ + + +def own_temporary_environment(root=None): + """Apply before third-party imports and invalidate an already cached tempdir.""" + from . import runtime_alias + if not runtime_alias._backend.windows(): + return + import logging + import tempfile + try: + from .system import install_root + env = temporary_environment(root if root is not None else install_root(), os.environ) + if env is not os.environ: + os.environ.update(env) + tempfile.tempdir = None + elif tempfile.tempdir and not runtime_alias._backend.ansi_path(tempfile.tempdir): + tempfile.tempdir = None # a cached folder the system code page cannot spell is looked up again + except Exception as error: # A temporary folder choice never blocks the engine from starting. + logging.warning('[FreeVideo] Compiler temporary folder unchanged: %s', + str(error).replace('\r', ' ').replace('\n', ' ')) + + COMPILER_ENVIRONMENT_KEYS = ( 'TRITON_CACHE_DIR', 'TORCHINDUCTOR_CACHE_DIR', 'CUDA_CACHE_PATH', 'CUDA_CACHE_MAXSIZE', 'TORCHINDUCTOR_USE_STATIC_CUDA_LAUNCHER', @@ -42,7 +168,7 @@ def _same_path(first, second): def environment(root, environ): """Configure child compilers before imports, including encoder prewarming.""" - env = dict(environ) + env = dict(temporary_environment(root, environ)) cache = Path(root) / 'kernel-cache' for key, folder in (('TRITON_CACHE_DIR', 'triton'), ('TORCHINDUCTOR_CACHE_DIR', 'inductor'), diff --git a/freevideo_engine/tuning.py b/freevideo_engine/tuning.py index f989186b..f4b0533a 100644 --- a/freevideo_engine/tuning.py +++ b/freevideo_engine/tuning.py @@ -13,7 +13,7 @@ import time from urllib.parse import unquote, urlsplit -from . import execution_paths +from . import subprocess_output from .hardware import GiB from .policy import BLOCK_BYTES from .monitoring import save @@ -57,7 +57,7 @@ def state_path(): def package_versions(python=None): if python and os.path.abspath(python) != os.path.abspath(sys.executable): code = 'import importlib.metadata as m,json; print(json.dumps({d.metadata["Name"].lower().replace("_","-"):d.version for d in m.distributions()}))' - result = execution_paths.run([str(python), '-B', '-c', code], capture_output=True, text=True, timeout=15, check=True) + result = subprocess_output.run_python([str(python), '-B', '-c', code], capture_output=True, timeout=15, check=True) installed = json.loads(result.stdout) else: installed = {d.metadata['Name'].lower().replace('_', '-'): d.version for d in importlib.metadata.distributions()} @@ -103,8 +103,8 @@ def identity(machine, hardware): for checkout, ref, expected in ((machine['vdn_root'], 'HEAD', specs['vdn']['commit']), (machine['comfy_root'], 'HEAD', specs['encoder']['comfy_commit']), (str(Path(machine['vdn_root']) / 'diffusers'), 'HEAD^{tree}', specs['diffusers']['patched_tree'])): - observed_ref = execution_paths.check_output(['git', '-C', checkout, 'rev-parse', ref], text=True, timeout=10).strip() - dirty = execution_paths.check_output(['git', '-C', checkout, 'status', '--porcelain', '--untracked-files=no'], text=True, timeout=10) + observed_ref = subprocess_output.output_external(['git', '-C', checkout, 'rev-parse', ref], timeout=10).strip() + dirty = subprocess_output.output_external(['git', '-C', checkout, 'status', '--porcelain', '--untracked-files=no'], timeout=10) if observed_ref != expected or dirty: raise ValueError('Pinned model code changed; saved tuning cannot be reused: ' + checkout) return {'schema': SCHEMA, 'system': hardware.system, diff --git a/freevideo_engine/windows_bootstrap.ps1 b/freevideo_engine/windows_bootstrap.ps1 index 7040f995..b1bcb68b 100644 --- a/freevideo_engine/windows_bootstrap.ps1 +++ b/freevideo_engine/windows_bootstrap.ps1 @@ -5,7 +5,8 @@ function Invoke-FreeVideoBootstrapCommand { [Parameter(Mandatory=$true)][string[]]$Arguments, [Parameter(Mandatory=$true)][string]$LogPath, [Parameter(Mandatory=$true)][string]$Label, - [switch]$Direct + [switch]$Direct, + [ref]$OutputTail ) # Windows PowerShell 5.1 turns redirected native stderr into ErrorRecords. # uv writes normal progress there. Let the child finish and inspect its @@ -24,6 +25,7 @@ function Invoke-FreeVideoBootstrapCommand { $log = [IO.StreamWriter]::new($LogPath, $true, $encoding) $log.AutoFlush = $true $previousExitCode = $global:LASTEXITCODE + $tail = [Collections.Generic.Queue[string]]::new() $savedProxy = @{} if ($Direct) { foreach ($name in @('HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY')) { @@ -37,7 +39,14 @@ function Invoke-FreeVideoBootstrapCommand { # LASTEXITCODE would shadow their result and keep a stale/null value. $global:LASTEXITCODE = $null $log.WriteLine(('[{0}] {1}' -f [DateTime]::UtcNow.ToString('o'), $Label)) - & $Executable @Arguments 2>&1 | ForEach-Object { $log.WriteLine($_.ToString()) } + & $Executable @Arguments 2>&1 | ForEach-Object { + $line = $_.ToString() + $log.WriteLine($line) + if ($OutputTail) { + $tail.Enqueue($line.Substring([Math]::Max(0, $line.Length - 4096))) + if ($tail.Count -gt 12) { [void]$tail.Dequeue() } + } + } $nativeExit = $global:LASTEXITCODE if ($null -eq $nativeExit) { throw "Native command did not return an exit code: $Executable" } $log.WriteLine("Exit code: $nativeExit") @@ -46,6 +55,7 @@ function Invoke-FreeVideoBootstrapCommand { $log.WriteLine('Invocation failed: ' + $_.Exception.Message) throw } finally { + if ($OutputTail) { $OutputTail.Value = ($tail.ToArray() -join "`n") } foreach ($name in $savedProxy.Keys) { [Environment]::SetEnvironmentVariable($name, $savedProxy[$name], 'Process') } @@ -54,6 +64,14 @@ function Invoke-FreeVideoBootstrapCommand { } } +function Test-FreeVideoSlowDownloadFailure { + param([string]$Detail) + # Keep the patient-round gate aligned with network.slow_download_failure. + return ($Detail -match 'timed out|timeout|connection|could not resolve|failed to download|failed to fetch|network is unreachable|ssl|tls|http status|status code|requested url returned error|rpc failed|early eof|unable to access|remote end hung up|couldn''t connect|proxy' -and + $Detail -notmatch '\b(401|403|404|410)\b' -and + $Detail -notmatch 'no solution found|no matching distribution|could not find a version|checksum|hash mismatch|hashes do not match|hashes didn''t match|hash does not match|hash check failed|sha256 mismatch|sha-256 mismatch|digest mismatch|integrity|certificate verify failed|certificate_verify_failed|invalid peer certificate|unknown issuer|not found|resolutionimpossible|failed to build|invalid wheel|permission denied|no space left on device|disk full') +} + function Test-FreeVideoUvArchive { param([string]$Path, [string]$Hash, [long]$Bytes) return ((Test-Path -LiteralPath $Path -PathType Leaf) -and diff --git a/freevideo_engine/worker.py b/freevideo_engine/worker.py index c98667fc..ddb2d773 100644 --- a/freevideo_engine/worker.py +++ b/freevideo_engine/worker.py @@ -140,6 +140,20 @@ def release(target): if live_budget is not None: # The pass cache grows only into memory no other process holds. refresh_budget.device_headroom = live_budget.device_headroom + + def release_idle(): + freed = 0 + if resident is not None: + for role in tuple(resident.entries): + if role != 'engine': + held = torch.cuda.memory_allocated() + resident.drop(role, 'Video memory budget was cut during sampling') + freed += max(0, held - torch.cuda.memory_allocated()) + return freed + # Read between layers: a cut inside a step is answered there, not at the next step. + from types import SimpleNamespace + refresh_budget.pressure = SimpleNamespace(local=live_budget.local_memory, release=release_idle, + limit=lambda: live_budget.limit) if resident is not None: # Idle models stay beside sampling only where the device holds both, # with this request's growth reserve left over. diff --git a/scripts/bootstrap_linux.sh b/scripts/bootstrap_linux.sh index b5ebc2f2..32bcc153 100644 --- a/scripts/bootstrap_linux.sh +++ b/scripts/bootstrap_linux.sh @@ -9,6 +9,30 @@ FV_PYTHON_VERSION=3.12.3 fv_note() { printf 'FreeVideo: %s\n' "$*" >&2; } fv_fail() { fv_note "$*"; return 1; } +# Match network.slow_download_failure: unavailable artifacts and local errors +# may try another source, but must not earn a longer download round. +fv_slow_download_failure() { + # Keep in sync with slow_download_failure in freevideo_engine/network.py. + # Only a validation failure blocks the slow retry: a package or service + # with "checksum" in its name is still a plain network failure. + local detail="${1,,}" sp='[[:space:]]+' edge='(^|[^[:alnum:]_])' q="'" + while [[ "$detail" =~ https?://[^[:space:]]+ ]]; do detail=${detail//"${BASH_REMATCH[0]}"/}; done + local aux="(does${sp}not|doesn${q}t|did${sp}not|didn${q}t|do${sp}not|don${q}t)" end='($|[^[:alnum:]_])' + local integrity="${edge}(invalid|bad|wrong|incorrect|mismatched?)${sp}checksums?${end}" + integrity+="|${edge}checksums?${sp}(mismatch|error|failed|failure)${end}" + integrity+="|${edge}checksums?${sp}(verification|validation)${sp}(failed|failure|error)${end}" + integrity+="|${edge}checksums?${sp}${aux}${sp}match${end}" + integrity+="|${edge}${aux}${sp}match${sp}(the${sp}|its${sp}|expected${sp})?(checksums?|hash(es)?)${end}" + integrity+="|${edge}hash${sp}(mismatch|check${sp}failed|does${sp}not${sp}match)${end}" + integrity+="|${edge}hashes${sp}(do${sp}not|didn${q}t)${sp}match${end}" + integrity+="|${edge}(sha-?256|digest)${sp}mismatch${end}" + integrity+="|${edge}integrity${sp}(check|verification)${sp}failed${end}" + [[ "$detail" =~ timed\ out|timeout|connection|could\ not\ resolve|failed\ to\ download|failed\ to\ fetch|network\ is\ unreachable|ssl|tls|http\ status|status\ code|requested\ url\ returned\ error|rpc\ failed|early\ eof|unable\ to\ access|remote\ end\ hung\ up|couldn\'t\ connect|proxy ]] && + [[ ! "$detail" =~ (^|[^[:alnum:]_])(401|403|404|410)($|[^[:alnum:]_]) ]] && + [[ ! "$detail" =~ $integrity ]] && + [[ ! "$detail" =~ no\ solution\ found|no\ matching\ distribution|could\ not\ find\ a\ version|certificate\ verify\ failed|certificate_verify_failed|invalid\ peer\ certificate|unknown\ issuer|not\ found|resolutionimpossible|failed\ to\ build|invalid\ wheel|permission\ denied|no\ space\ left\ on\ device|disk\ full ]] +} + fv_python_ok() { [[ -n "$1" ]] && command -v -- "$1" >/dev/null 2>&1 && "$1" -I -B -c 'import sys; sys.exit(0 if sys.version_info >= (3, 9) else 1)' >/dev/null 2>&1 @@ -222,6 +246,12 @@ fv_install_python() ( fv_note "Files and log: $FREEVIDEO_BOOTSTRAP_ROOT" fv_download_uv || exit 1 local uv="$FREEVIDEO_BOOTSTRAP_ROOT/tools/uv-x86_64-unknown-linux-gnu/uv" source route attempt=0 + local retry_mode timeout retries attempt_log attempt_dir attempt_detail python_exit tee_exit all_network_failures=1 + local -a pipe_status + # The capture only classifies failures: without a writable place for it the + # install still runs, it just never starts the slow-network round. + attempt_dir=$(mktemp -d 2>/dev/null) || attempt_dir=$(mktemp -d "$FREEVIDEO_BOOTSTRAP_ROOT/.attempt.XXXXXX" 2>/dev/null) || attempt_dir='' + trap '[[ -z "$attempt_dir" ]] || rm -rf -- "$attempt_dir"' EXIT local -a fv_routes=() fv_proxy_routes local -a sources=(https://github.com/astral-sh/python-build-standalone/releases/download) @@ -231,17 +261,53 @@ fv_install_python() ( sources+=(https://ghfast.top/https://github.com/astral-sh/python-build-standalone/releases/download) fi export UV_PYTHON_INSTALL_DIR="$FREEVIDEO_BOOTSTRAP_ROOT/python" UV_CACHE_DIR="$FREEVIDEO_BOOTSTRAP_ROOT/cache" - export UV_HTTP_TIMEOUT=20 UV_HTTP_RETRIES=1 - for source in "${sources[@]}"; do - for route in "${fv_routes[@]}"; do - ((attempt+=1)) - fv_note "Installing private Python, attempt $attempt ($route connection)…" - printf 'Python source %s\n' "$attempt" >>"$FREEVIDEO_BOOTSTRAP_ROOT/bootstrap.log" - if UV_PYTHON_INSTALL_MIRROR="$source" fv_with_route "$route" "$uv" --no-config python install --no-bin --no-registry \ - --no-progress --color never "$FV_PYTHON_VERSION" >>"$FREEVIDEO_BOOTSTRAP_ROOT/bootstrap.log" 2>&1; then - fv_python_ok "$UV_PYTHON_INSTALL_DIR/cpython-$FV_PYTHON_VERSION-linux-x86_64-gnu/bin/python3.12" && exit 0 - fi - fv_note 'Python preparation failed; retrying the next source. Existing files retained.' + # Keep these defaults and the notice in sync with network.py (CPU tests). + for retry_mode in fast patient; do + timeout=20 retries=1 + if [[ "$retry_mode" == patient ]]; then + (( all_network_failures )) || break + timeout=120 retries=5 + fv_note 'The network is slow. Downloading again; this may take a while.' + fi + for source in "${sources[@]}"; do + for route in "${fv_routes[@]}"; do + ((attempt+=1)) + fv_note "Installing private Python, attempt $attempt ($route connection)…" + if [[ "$retry_mode" == patient ]]; then + printf 'Python source %s (%s)\n' "$attempt" "$retry_mode" >>"$FREEVIDEO_BOOTSTRAP_ROOT/bootstrap.log" + else + printf 'Python source %s\n' "$attempt" >>"$FREEVIDEO_BOOTSTRAP_ROOT/bootstrap.log" + fi + attempt_log='' + [[ -z "$attempt_dir" ]] || attempt_log=$(mktemp "$attempt_dir/output.XXXXXX" 2>/dev/null) || attempt_log='' + # Command-local defaults never turn our fast values into user settings. + # Classify this attempt independently of main-log truncation/rotation. + if UV_HTTP_TIMEOUT="${UV_HTTP_TIMEOUT-$timeout}" UV_HTTP_RETRIES="${UV_HTTP_RETRIES-$retries}" \ + UV_PYTHON_INSTALL_MIRROR="$source" fv_with_route "$route" "$uv" --no-config python install --no-bin --no-registry \ + --no-progress --color never "$FV_PYTHON_VERSION" 2>&1 | \ + tee -a -- "${attempt_log:-/dev/null}" >>"$FREEVIDEO_BOOTSTRAP_ROOT/bootstrap.log"; then + pipe_status=("${PIPESTATUS[@]}") + else + pipe_status=("${PIPESTATUS[@]}") + fi + python_exit=${pipe_status[0]} tee_exit=${pipe_status[1]:-0} + attempt_detail='' + [[ -z "$attempt_log" ]] || attempt_detail=$(tail -n 12 -- "$attempt_log") + [[ -z "$attempt_log" ]] || rm -f -- "$attempt_log" + if (( tee_exit != 0 )); then + # The main log could not take the output: keep what uv said on screen. + [[ -z "$attempt_detail" ]] || printf '%s\n' "$attempt_detail" >&2 + fv_fail "Could not retain Python installation output. Check $FREEVIDEO_BOOTSTRAP_ROOT/bootstrap.log and retry the same command." + exit 1 + fi + if (( python_exit == 0 )); then + fv_python_ok "$UV_PYTHON_INSTALL_DIR/cpython-$FV_PYTHON_VERSION-linux-x86_64-gnu/bin/python3.12" && exit 0 + all_network_failures=0 + elif ! fv_slow_download_failure "$attempt_detail"; then + all_network_failures=0 + fi + fv_note 'Python preparation failed; retrying the next source. Existing files retained.' + done done done fv_fail "Could not prepare Python. Check $FREEVIDEO_BOOTSTRAP_ROOT/bootstrap.log and retry the same command." diff --git a/scripts/test_freevideo_entry.mjs b/scripts/test_freevideo_entry.mjs index 764e3b45..0b867324 100644 --- a/scripts/test_freevideo_entry.mjs +++ b/scripts/test_freevideo_entry.mjs @@ -93,7 +93,8 @@ test('main browser entry registers both views and preserves node hooks', async t .replace("import { rememberPromptDraft, savePromptDraft } from './prompt_draft.js';", 'const {rememberPromptDraft,savePromptDraft} = globalThis.__freevideoEntryTest;') .replace("import { regenerateResult, upscaleResult } from './studio_queue.js';", 'const {regenerateResult,upscaleResult} = globalThis.__freevideoEntryTest;') .replace("import { openImageEditor, icon } from './image_editor.js';", 'const {openImageEditor,icon} = globalThis.__freevideoEntryTest;') - .replace("import { installNavigation, refreshNavigation, preferredView } from './view_navigation.js';", 'const {installNavigation,refreshNavigation,preferredView} = globalThis.__freevideoEntryTest;'); + .replace("import { installNavigation, refreshNavigation, preferredView } from './view_navigation.js';", 'const {installNavigation,refreshNavigation,preferredView} = globalThis.__freevideoEntryTest;') + .replace("import { agentInterfaceEnabled } from './agent_connect.js';", 'const agentInterfaceEnabled = async () => false;'); await import('data:text/javascript;base64,' + Buffer.from(source).toString('base64')); const extension = extensions.find(row => row.name === 'FreeVideo.UnifiedMedia'); assert.ok(extension, 'the main extension must register'); diff --git a/web/agent_activity.css b/web/agent_activity.css index ee777e96..094555cb 100644 --- a/web/agent_activity.css +++ b/web/agent_activity.css @@ -17,7 +17,6 @@ @keyframes fv-agent-in{from{opacity:0;transform:translateY(-6px)}} .fv-agent-panel header{padding:16px 18px 12px;border-bottom:1px solid var(--fv-border)} .fv-agent-panel h2{margin:0;font-size:var(--fv-strong);font-weight:var(--fv-semibold)} -.fv-agent-panel header p{margin:6px 0 0;font-size:var(--fv-micro);line-height:1.5;color:var(--fv-muted);text-wrap:balance} .fv-agent-panel ol{list-style:none;margin:0;padding:6px;overflow:auto;display:grid;gap:2px} .fv-agent-job{display:grid;grid-template-columns:20px minmax(0,1fr);gap:12px;padding:12px;border-radius:var(--fv-r-sm)} .fv-agent-job:hover{background:var(--fv-hover)} @@ -48,3 +47,9 @@ @media(max-width:820px){.fv-studio .fv-header:has(>.fv-view-switch):has(.fv-agent-pill:not([hidden])){height:auto;min-height:98px;grid-template-columns:1fr auto;padding-block:10px}.fv-studio .fv-header:has(.fv-agent-pill:not([hidden]))>.fv-view-switch{grid-column:1/-1;grid-row:2;justify-self:center}.fv-studio .fv-header:has(.fv-agent-pill:not([hidden]))>.fv-header-actions{grid-column:2;grid-row:1}} @media(max-width:480px){.fv-agent-panel{top:104px;left:16px;right:16px;width:auto}} .fv-agent-panel:focus{outline:none} +/* The same rows as an always-open list inside a view. */ +.fv-agent-list{display:grid;gap:10px;min-width:0} +.fv-agent-list[hidden],.fv-agent-list [hidden]{display:none} +.fv-agent-list h2{margin:0;font-size:var(--fv-strong);font-weight:var(--fv-semibold)} +.fv-agent-list ol{list-style:none;margin:0 -12px;padding:0;display:grid;gap:2px} +.fv-agent-empty{margin:0;font-size:var(--fv-body);line-height:1.5;color:var(--fv-muted)} diff --git a/web/agent_activity.js b/web/agent_activity.js index d495006a..811c7bd5 100644 --- a/web/agent_activity.js +++ b/web/agent_activity.js @@ -1,4 +1,4 @@ -import {agentPost} from './agent_session.js'; +import {agentGet, agentPost} from './agent_session.js'; // What agents connected to this FreeVideo are doing. Their requests share the // studio's queue, so people can see them, follow progress and stop them. const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./agent_activity.css', import.meta.url).href; document.head.append(css); @@ -6,10 +6,10 @@ const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = n const KINDS = { generate: ['Generating', '生成中', 'M4 7h11a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2zm13 4 5-3v8l-5-3'], assemble: ['Assembling', '拼接中', 'M3 6h7v12H3zM14 6h7v12h-7zM10 12h4'], - vlm: ['Reviewing', '审看中', 'M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6z'], + vlm: ['Checking', '检查中', 'M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6z'], enhance: ['Writing prompt', '润色提示词', 'M4 20l4-1 10-10-3-3L5 16zM14 7l3 3M18 3l1 2 2 1-2 1-1 2-1-2-2-1 2-1z'], }; -const NAMES = {generate: ['Video', '视频'], assemble: ['Sequence', '成片'], vlm: ['Review', '审看'], enhance: ['Prompt', '提示词']}; +const NAMES = {generate: ['Video', '视频'], assemble: ['Sequence', '成片'], vlm: ['Check', '检查'], enhance: ['Prompt', '提示词']}; // Short reasons for the failures people can act on; the engine's own message stays in the tooltip. const REASONS = { @@ -27,19 +27,162 @@ function glyph(path) { const p = document.createElementNS('http://www.w3.org/2000/svg', 'path'); p.setAttribute('d', path); svg.append(p); return svg; } -const seconds = (s, t) => t(`${Math.max(1, Math.round(s))} s`, `${Math.max(1, Math.round(s))} 秒`); +// The same two wordings as the conversation's cards. +const left = (s, t) => s >= 60 ? t(`About ${Math.ceil(s / 60)} min`, `约 ${Math.ceil(s / 60)} 分钟`) : t('Under 1 min', '不到 1 分钟'); // The engine's phases in the reader's language. function phaseText(p, t) { const step = p.step; - if (p.phase === 'sampling' && step?.total) return t(`Sampling ${step.done} / ${step.total}`, `采样 ${step.done} / ${step.total}`); - return {encoding: t('Reading prompt and media', '编码提示词与素材'), load: t('Loading the model', '加载模型'), + // The first step is still getting ready: the line does not count from zero. + if (p.phase === 'sampling' && step?.total) return step.done >= 1 ? t(`Step ${step.done} / ${step.total}`, `生成 ${step.done} / ${step.total}`) : t('Preparing', '准备中'); + return {encoding: t('Reading prompt and media', '读取提示词和素材'), load: t('Loading the model', '加载模型'), decode: t('Saving video and audio', '保存视频与音频'), starting: t('Starting', '启动中'), - loading: t('Loading the VLM', '加载视觉模型'), answering: t('Answering', '回答中'), rewriting: t('Writing', '改写中'), + loading: t('Loading the model', '加载模型'), answering: t('Answering', '回答中'), rewriting: t('Writing', '改写中'), assembling: t('Joining clips', '拼接片段'), 'preparing images': t('Preparing images', '准备图片'), finishing: t('Finishing', '收尾中')}[p.phase] || ''; } -export function createAgentActivity(t, api, host, {interval = [1500, 8000]} = {}) { +function status(job, t) { + const [en, zh] = KINDS[job.kind] || KINDS.generate; + if (job.status === 'running') return t(en, zh); + if (job.status === 'queued') return job.progress?.queue_position + ? t(`In queue · #${job.progress.queue_position}`, `排队 · 第 ${job.progress.queue_position} 位`) : t('Queued', '排队中'); + if (job.status === 'waiting') return job.waiting_for ? t('Waiting for earlier steps', '等待前序任务') : t('Waiting for the GPU', '等待显卡'); + return {succeeded: t('Done', '已完成'), failed: t('Failed', '失败'), cancelled: t('Cancelled', '已取消')}[job.status] || job.status; +} +function title(job, t) { + const name = NAMES[job.kind] || NAMES.generate; + return job.label || (t(name[0], name[1]) + (job.take ? ' · ' + t(`Take ${job.take}`, `第 ${job.take} 条`) : '')); +} +function row(job, t, api, refresh, open) { + const item = el('li', 'fv-agent-job'); item.dataset.status = job.status; item.dataset.job = job.id; + item.append(glyph((KINDS[job.kind] || KINDS.generate)[2])); + const body = el('div', 'fv-agent-body'); + const head = el('div', 'fv-agent-head'); + head.append(el('strong', null, title(job, t)), el('span', 'fv-agent-state', status(job, t))); + body.append(head); + const p = job.progress || {}; + if (job.status === 'running' || job.status === 'queued') { + const bar = el('div', 'fv-agent-bar'), fill = el('i'); + const fraction = Math.max(0, Math.min(1, Number(p.fraction) || 0)); + fill.style.transform = `scaleX(${job.status === 'queued' ? 0 : Math.max(.02, fraction)})`; + bar.append(fill); bar.setAttribute('role', 'progressbar'); bar.setAttribute('aria-valuemin', '0'); + bar.setAttribute('aria-valuemax', '100'); bar.setAttribute('aria-valuenow', String(Math.round(fraction * 100))); + body.append(bar); + } + const meta = []; + if (job.status === 'running' && phaseText(p, t)) meta.push(phaseText(p, t)); + if (job.status === 'running' && p.remaining_seconds > 0) meta.push(left(p.remaining_seconds, t)); + const reason = job.status === 'failed' && REASONS[job.error?.code]; + if (reason) meta.push(t(...reason)); + else if (job.error?.message && job.status === 'failed') meta.push(job.error.message); + if (job.project) meta.push(job.project); + const foot = el('div', 'fv-agent-foot'); + const details = el('p', 'fv-agent-meta', meta.join(' · ')); + if (reason && job.error?.message) details.title = job.error.message; + foot.append(details); + if (['waiting', 'queued', 'running'].includes(job.status)) { + const stop = el('button', 'fv-agent-stop', t('Stop', '停止')); stop.type = 'button'; + stop.setAttribute('aria-label', t('Stop ', '停止 ') + title(job, t)); + stop.onclick = async () => { + stop.disabled = true; + try { + await agentPost(api, '/freevideo/agent/activity/cancel', {id: job.id}); + } finally { refresh(); } + }; + foot.append(stop); + } + if (job.work) { + // The result is a work: show that one in Creations. (The file itself needs the Agent's token.) + const show = el('button', 'fv-agent-open', t('Open', '打开')); show.type = 'button'; + show.setAttribute('aria-label', t('Open in Creations: ', '在作品中打开:') + title(job, t)); + show.onclick = () => open(job); + foot.append(show); + } + body.append(foot); + item.append(body); + return item; +} +// Running first, then the rest in the order they will run; finished ones after, newest first. +function rows(jobs, t, api, refresh, open) { + const rank = {running: 0, queued: 1, waiting: 2}; + const active = jobs.filter(j => j.status in rank) + .sort((a, b) => rank[a.status] - rank[b.status] || String(a.created_at).localeCompare(String(b.created_at))); + const done = jobs.filter(j => !(j.status in rank)) + .sort((a, b) => String(b.finished_at).localeCompare(String(a.finished_at))); + const items = active.map(job => row(job, t, api, refresh, open)); + if (done.length) { + const divider = el('li', 'fv-agent-divider', t('Last 30 minutes', '最近 30 分钟')); + divider.setAttribute('role', 'presentation'); + items.push(divider, ...done.map(job => row(job, t, api, refresh, open))); + } + return items; +} +// A re-render keeps keyboard focus on the same control. +function refill(root, list, items) { + const focused = root.contains(document.activeElement) ? document.activeElement : null; + const key = focused && [focused.closest('[data-job]')?.dataset.job, focused.className]; + list.replaceChildren(...items); + return key; +} + +// "Open" on a finished video leads to that work in Creations. +async function showWork(t, job) { + (await import('./library.js')).openLibrary(t, {selectedWork: job.work}); +} + +// The same jobs as a list that is always open, for a view of its own: what the agents are doing, how far +// along, where the result is, and a stop on each; open(job) can replace where a finished video opens. It shows +// once an agent is connected or has jobs, and asks nothing while it is out of sight. +export function agentActivityList(t, api, {interval = [1500, 8000], open = job => showWork(t, job)} = {}) { + const element = el('section', 'fv-agent-list'); element.hidden = true; + const heading = el('h2', null, t('Jobs', '任务')), list = el('ol'); + const empty = el('p', 'fv-agent-empty', t('Videos the agent makes will appear here.', 'Agent 生成的视频会显示在这里。')); + element.append(heading, list, empty); + let timer = null, stopped = false, on = null, failures = 0, active = 0; + // Hidden itself, the list is in sight when the view that holds it is. + const inSight = () => element.isConnected && (element.hidden ? element.parentElement : element)?.getClientRects().length > 0; + async function connected() { + try { + const response = await agentGet(api, '/freevideo/agent/v1/connect/status'); + return response.ok && (await response.json()).connected === true; + } catch { return false; } + } + async function poll() { + clearTimeout(timer); timer = null; + if (stopped) return; + const shown = inSight(); + if (shown && on === null) { + // Agent features can be off in this FreeVideo: ask once. + try { + const response = await api.fetchApi('/freevideo/agent', {cache: 'no-store'}); + on = response.ok && (await response.json()).enabled === true; + } catch { on = false; } + } + if (stopped || on === false) return; + if (shown && on) { + try { + const response = await api.fetchApi('/freevideo/agent/activity', {cache: 'no-store'}); + if (response.ok) { + const data = await response.json(), jobs = data.jobs || []; + const show = jobs.length > 0 || await connected(); + if (stopped) return; + failures = 0; active = data.active || 0; + const key = refill(element, list, rows(jobs, t, api, poll, open)); + list.hidden = !jobs.length; empty.hidden = !!jobs.length; element.hidden = !show; + if (key) list.querySelector(`[data-job="${key[0]}"] .${key[1].split(' ')[0]}`)?.focus?.(); + } else failures++; + } catch { failures++; } + } + if (stopped) return; + // Fast while agents work; every 3 s while empty, like the connection card, so connecting shows at once. + const wait = !shown ? 500 : active ? interval[0] : list.hidden ? Math.min(3000, interval[1]) : interval[1] * Math.min(4, 1 + failures); + timer = setTimeout(poll, wait); + } + poll(); + return {element, refresh: poll, stop() { stopped = true; clearTimeout(timer); }}; +} + +export function createAgentActivity(t, api, host, {interval = [1500, 8000], open = job => showWork(t, job)} = {}) { const element = el('button', 'fv-agent-pill'); element.type = 'button'; element.hidden = true; element.setAttribute('aria-haspopup', 'dialog'); element.setAttribute('aria-expanded', 'false'); const dot = el('span', 'fv-agent-dot'), label = el('span', 'fv-agent-label'); @@ -49,90 +192,12 @@ export function createAgentActivity(t, api, host, {interval = [1500, 8000]} = {} host.append(panel); let data = {active: 0, jobs: []}, timer = null, disposed = false, failures = 0; - function status(job) { - const [en, zh] = KINDS[job.kind] || KINDS.generate; - if (job.status === 'running') return t(en, zh); - if (job.status === 'queued') return job.progress?.queue_position - ? t(`In queue · #${job.progress.queue_position}`, `排队 · 第 ${job.progress.queue_position} 位`) : t('Queued', '排队中'); - if (job.status === 'waiting') return job.waiting_for ? t('Waiting for earlier steps', '等待前序任务') : t('Waiting for the GPU', '等待显卡'); - return {succeeded: t('Done', '已完成'), failed: t('Failed', '失败'), cancelled: t('Cancelled', '已取消')}[job.status] || job.status; - } - function title(job) { - const name = NAMES[job.kind] || NAMES.generate; - return job.label || (t(name[0], name[1]) + (job.take ? ' · ' + t(`Take ${job.take}`, `第 ${job.take} 条`) : '')); - } - function row(job) { - const item = el('li', 'fv-agent-job'); item.dataset.status = job.status; item.dataset.job = job.id; - item.append(glyph((KINDS[job.kind] || KINDS.generate)[2])); - const body = el('div', 'fv-agent-body'); - const head = el('div', 'fv-agent-head'); - head.append(el('strong', null, title(job)), el('span', 'fv-agent-state', status(job))); - body.append(head); - const p = job.progress || {}; - if (job.status === 'running' || job.status === 'queued') { - const bar = el('div', 'fv-agent-bar'), fill = el('i'); - const fraction = Math.max(0, Math.min(1, Number(p.fraction) || 0)); - fill.style.transform = `scaleX(${job.status === 'queued' ? 0 : Math.max(.02, fraction)})`; - bar.append(fill); bar.setAttribute('role', 'progressbar'); bar.setAttribute('aria-valuemin', '0'); - bar.setAttribute('aria-valuemax', '100'); bar.setAttribute('aria-valuenow', String(Math.round(fraction * 100))); - body.append(bar); - } - const meta = []; - if (job.status === 'running' && phaseText(p, t)) meta.push(phaseText(p, t)); - if (job.status === 'running' && p.remaining_seconds > 0) meta.push(t(`about ${seconds(p.remaining_seconds, t)} left`, `约剩 ${seconds(p.remaining_seconds, t)}`)); - const reason = job.status === 'failed' && REASONS[job.error?.code]; - if (reason) meta.push(t(...reason)); - else if (job.error?.message && job.status === 'failed') meta.push(job.error.message); - if (job.project) meta.push(job.project); - if (job.client) meta.push(job.client); - const foot = el('div', 'fv-agent-foot'); - const details = el('p', 'fv-agent-meta', meta.join(' · ')); - if (reason && job.error?.message) details.title = job.error.message; - foot.append(details); - if (['waiting', 'queued', 'running'].includes(job.status)) { - const stop = el('button', 'fv-agent-stop', t('Stop', '停止')); stop.type = 'button'; - stop.setAttribute('aria-label', t('Stop ', '停止 ') + title(job)); - stop.onclick = async () => { - stop.disabled = true; - try { - await agentPost(api, '/freevideo/agent/activity/cancel', {id: job.id}); - } finally { poll(); } - }; - foot.append(stop); - } - if (job.video) { - const open = el('a', 'fv-agent-open', t('Open', '打开')); - open.href = api.apiURL ? api.apiURL(job.video) : job.video; open.target = '_blank'; open.rel = 'noopener'; - open.setAttribute('aria-label', t('Open video of ', '打开视频:') + title(job)); - foot.append(open); - } - body.append(foot); - item.append(body); - return item; - } function renderPanel() { - // Keep keyboard focus on the same control across a refresh. - const focused = panel.contains(document.activeElement) ? document.activeElement : null; - const key = focused && [focused.closest('[data-job]')?.dataset.job, focused.className]; - panel.replaceChildren(); const header = el('header'); - header.append(el('h2', null, t('Agent jobs', 'Agent 任务')), - el('p', null, t('Requests from AI agents connected to FreeVideo. They share the queue with your videos.', - '已连接的 AI Agent 提交的任务,与您的生成共用同一个队列。'))); - // Running first, then the rest in the order they will run; finished ones after, newest first. - const rank = {running: 0, queued: 1, waiting: 2}; - const active = data.jobs.filter(j => j.status in rank) - .sort((a, b) => rank[a.status] - rank[b.status] || String(a.created_at).localeCompare(String(b.created_at))); - const done = data.jobs.filter(j => !(j.status in rank)) - .sort((a, b) => String(b.finished_at).localeCompare(String(a.finished_at))); + header.append(el('h2', null, t('Agent jobs', 'Agent 任务'))); const list = el('ol'); - for (const job of active) list.append(row(job)); - if (done.length) { - const divider = el('li', 'fv-agent-divider', t('Last 30 minutes', '最近 30 分钟')); - divider.setAttribute('role', 'presentation'); - list.append(divider); - for (const job of done) list.append(row(job)); - } + const key = refill(panel, list, rows(data.jobs, t, api, poll, job => { setOpen(false); open(job); })); + panel.replaceChildren(); panel.append(header, list); if (key) (panel.querySelector(`[data-job="${key[0]}"] .${key[1].split(' ')[0]}`) || panel).focus?.(); } diff --git a/web/agent_connect.css b/web/agent_connect.css index e8c9632b..3a62b07c 100644 --- a/web/agent_connect.css +++ b/web/agent_connect.css @@ -1,12 +1,38 @@ -/* Connect an agent: one sentence, one action, one status. The card carries its own layout wherever it is placed. */ -.fv-agent-connect-body{display:grid;gap:12px;min-width:0} -.fv-agent-connect-body>p{margin:0} -.fv-agent-connect-body .fv-agent-sentence{padding:10px 12px;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm);background:var(--fv-bg);color:var(--fv-ink);font-size:var(--fv-body);overflow-wrap:anywhere;user-select:all} -.fv-agent-connect-body .fv-agent-sentence:empty{display:none} -.fv-agent-connect-body .fv-agent-note{font-size:var(--fv-micro);line-height:1.5;color:var(--fv-muted)} -.fv-agent-connect-body .fv-agent-note[hidden]{display:none} -.fv-agent-connect-body .fv-agent-connect-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap} -.fv-agent-connect-body .fv-agent-connect-status{display:flex;align-items:center;gap:8px;min-width:0;font-size:var(--fv-body);color:var(--fv-muted)} -.fv-agent-connect-body .fv-agent-connect-dot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--fv-disabled)} -.fv-agent-connect-body .fv-agent-connect-dot[data-color=green]{background:var(--fv-success)} -.fv-setup .fv-agent-connect[hidden]{display:none} +/* Connect an agent: one card, one action. The card carries its own layout wherever it is placed. */ +.fv-agent-connect-body{box-sizing:border-box;width:min(440px,100%);display:grid;justify-items:start;gap:16px;padding:28px;border:1px solid var(--fv-accent-dim);border-radius:14px;background:var(--fv-card);box-shadow:0 0 0 1px color-mix(in srgb,var(--fv-accent-dim) 35%,transparent),0 18px 40px -20px color-mix(in srgb,var(--fv-accent) 40%,transparent)} +.fv-agent-connect-body[hidden],.fv-agent-connect-body [hidden]{display:none} +.fv-agent-connect-body>*{margin:0;min-width:0} +.fv-agent-connect-icon{width:44px;height:44px;border-radius:11px;display:grid;place-items:center;background:var(--fv-selected);color:var(--fv-accent-hover)} +.fv-agent-connect-body svg{width:24px;height:24px;fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round} +.fv-agent-connect-body>h2{font-size:19px;line-height:1.3;font-weight:700;color:var(--fv-ink)} +.fv-agent-connect-body>.fv-muted{margin-top:-10px;font-size:13.5px;color:var(--fv-muted)} +.fv-agent-connect-actions{display:flex;align-items:center;gap:12px;margin-top:10px;flex-wrap:wrap} +.fv-studio .fv-agent-connect-actions button{padding:9px 20px;font-size:14px}.fv-agent-connect-actions button svg{width:16px;height:16px} +.fv-agent-connect-arrow{color:var(--fv-disabled)}.fv-agent-connect-hint{color:var(--fv-muted);font-size:13.5px} +.fv-agent-connect-body .fv-agent-sentence{justify-self:stretch;padding:10px 12px;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm);background:var(--fv-bg);color:var(--fv-ink);font-size:var(--fv-body);overflow-wrap:anywhere;user-select:all} +/* The Autopilot page: the card in the very middle, or one conversation column under the top bar. */ +.fv-studio.fv-autopilot{display:grid;grid-template-rows:auto minmax(0,1fr);overflow:hidden} +.fv-studio.fv-autopilot .fv-header{position:relative} +.fv-autopilot .fv-autopilot-body{box-sizing:border-box;min-height:0;min-width:0;display:grid;grid-template-columns:minmax(0,1fr)} +.fv-autopilot .fv-autopilot-body[data-view=connect]{place-items:center;padding:16px;overflow:auto} +.fv-studio .fv-autopilot-lamp{min-height:0;gap:8px;padding:6px 13px;border:1px solid var(--fv-border);border-radius:99px;background:transparent;color:var(--fv-muted);font-size:13.5px;font-weight:var(--fv-regular);white-space:nowrap;flex:none} +.fv-studio .fv-autopilot-lamp:disabled{opacity:1}.fv-autopilot-lamp[hidden]{display:none} +.fv-autopilot-lamp>i{width:8px;height:8px;border-radius:50%;background:var(--fv-disabled)} +.fv-studio .fv-autopilot-lamp[data-color=green]{color:var(--fv-ink);border-color:#245a45;background:#0f2019} +.fv-autopilot-lamp[data-color=green]>i{background:var(--fv-success);box-shadow:0 0 0 3px color-mix(in srgb,var(--fv-success) 20%,transparent)} +.fv-autopilot-now{display:flex;align-items:center;min-width:0;border:1px solid var(--fv-accent-dim);background:color-mix(in srgb,var(--fv-selected) 55%,var(--fv-bg));border-radius:99px;font-size:13.5px;font-variant-numeric:tabular-nums} +.fv-autopilot-now[hidden]{display:none} +.fv-studio .fv-autopilot-now button{min-height:0;border:0;background:transparent;border-radius:99px} +.fv-studio .fv-autopilot-now-go{gap:9px;padding:6px 4px 6px 13px;min-width:0;color:var(--fv-ink);font-weight:var(--fv-regular);white-space:nowrap} +.fv-autopilot-now-go>i{flex:none;width:16px;height:16px;border-radius:50%;background:conic-gradient(var(--fv-accent) calc(var(--p,0)*1%),#24364a 0);-webkit-mask:radial-gradient(circle,transparent 5px,#000 5.5px);mask:radial-gradient(circle,transparent 5px,#000 5.5px)} +.fv-autopilot-now-name{overflow:hidden;text-overflow:ellipsis;max-width:220px}.fv-autopilot-now-go>:empty:not(i){display:none} +.fv-autopilot-now-go>span:not(.fv-autopilot-now-name),.fv-autopilot-now-go>em{font-style:normal;color:var(--fv-muted)}.fv-autopilot-now-go>b{font-weight:var(--fv-semibold)} +.fv-studio .fv-autopilot-now-stop{flex:none;width:30px;height:30px;padding:0} +.fv-autopilot-now-stop::after{content:"";width:18px;height:18px;border-radius:5px;background:#1b2a3a radial-gradient(circle,var(--fv-danger) 0 0) center/7px 7px no-repeat} +.fv-studio .fv-autopilot-now-stop:hover::after{background-color:var(--fv-danger-subtle)} +.fv-autopilot-line{position:absolute;left:0;bottom:-1px;height:2px;border-radius:0 2px 2px 0;background:linear-gradient(90deg,var(--fv-accent),var(--fv-accent-hover));transition:width .6s var(--fv-ease),opacity .4s;pointer-events:none} +.fv-autopilot-line[data-on=false]{opacity:0} +@media(max-width:1050px){.fv-autopilot-now-go>em{display:none}.fv-autopilot-now-name{max-width:120px}} +@media(max-width:820px){.fv-studio.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden])){height:auto;min-height:98px;grid-template-columns:auto minmax(0,1fr);padding-block:10px}.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden]))>.fv-view-switch{grid-column:1/-1;grid-row:2;justify-self:center}.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden]))>.fv-header-actions{grid-column:2;grid-row:1}} +@media(max-width:480px){.fv-studio.fv-autopilot .fv-header{grid-template-columns:auto minmax(0,1fr)}.fv-autopilot .fv-header-actions>.fv-quiet{display:none}.fv-autopilot-now{font-size:12.5px}.fv-autopilot-now-name{max-width:92px}.fv-studio .fv-autopilot-now-go{padding:5px 2px 5px 10px}.fv-studio .fv-autopilot-lamp{padding:4px 10px;font-size:12.5px}.fv-autopilot-now:not([hidden])+.fv-autopilot-lamp[data-color=green]>span{display:none}.fv-agent-connect-body{padding:22px}} +@media(prefers-reduced-motion:reduce){.fv-autopilot-line{transition:none}} diff --git a/web/agent_connect.js b/web/agent_connect.js index 3df2af5e..b8f8e9f1 100644 --- a/web/agent_connect.js +++ b/web/agent_connect.js @@ -1,15 +1,9 @@ -import {agentGet} from './agent_session.js'; - function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; } -// null means no connection has been seen; '' remembers a connection without a name. -export function connectionState(copied, status, previousName, t) { - if (status?.connected === true) return {color: 'green', text: status.client?.trim() || t('Connected', '已连接'), buttonText: t('Copy', '复制')}; - if (previousName != null) { - const name = previousName || 'Agent'; - return {color: 'gray', text: t(`${name} disconnected`, `${name} 已断开`), buttonText: t('Reconnect', '重新连接')}; - } - return {color: 'gray', text: copied ? t('Waiting for connection…', '等待连接…') : t('Not connected', '未连接'), buttonText: t('Copy', '复制')}; +// The one place a page says whether an agent is connected: its name while it is, never a client id. +export function connectionLabel(agent, t) { + if (agent?.connected !== true) return {color: 'gray', text: t('Not connected', '未连接')}; + return {color: 'green', text: String(agent.name || '').trim() || t('Connected', '已连接')}; } export function connectionSentence(address, t) { @@ -17,11 +11,6 @@ export function connectionSentence(address, t) { return t(`Connect to FreeVideo: open ${url} and follow the instructions.`, `请连接 FreeVideo:打开 ${url} 并按说明操作。`); } -// Shown under the status line, only when the service says editing cannot run here; connecting still works. -export function editingNote(status, t) { - return status?.editing === false ? t('The agent cannot edit videos or images for now.', 'Agent 暂时不能剪辑和修图。') : ''; -} - // Whether this FreeVideo has the Agent interface on. Any failure counts as off: the surface shows nothing. export async function agentInterfaceEnabled(api) { try { @@ -30,42 +19,46 @@ export async function agentInterfaceEnabled(api) { } catch { return false; } } -// The card alone: who can connect, the sentence, one light and one button. It belongs to whichever surface -// shows it. shown() says whether the user can see it now; the surface calls sync() whenever that may have changed. +const ICONS = {plug: ['M9 7V3M15 7V3M7 7h10v4a5 5 0 0 1-10 0V7zM12 16v5', 1.8], copy: ['M11 9h7a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2zM5 15V5a2 2 0 0 1 2-2h8', 2]}; +function icon(name) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true'); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', ICONS[name][0]); + svg.setAttribute('stroke-width', String(ICONS[name][1])); svg.append(path); + return svg; +} + +// The card alone: who can connect and one button that copies the sentence to paste to the agent. The sentence +// stays out of sight; it shows, selected, only when the browser refuses the clipboard. shown() says whether the +// user can see the card now; the surface calls sync() whenever that may have changed. export function agentConnectCard(t, api, shown = () => true) { const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./agent_connect.css', import.meta.url).href; document.head.append(css); - const body = el('div', 'fv-agent-connect-body'); + const body = el('section', 'fv-agent-connect-body'); + const mark = el('span', 'fv-agent-connect-icon'); mark.append(icon('plug')); + const title = el('h2', null, t('Connect an agent', '连接 Agent')); const subtitle = el('p', 'fv-muted', t('Claude Code, Codex, FreeToken and others', 'Claude Code、Codex、FreeToken 等')); - const sentence = el('p', 'fv-agent-sentence'); - const note = el('p', 'fv-agent-note'); note.hidden = true; const actions = el('div', 'fv-agent-connect-actions'); - const status = el('div', 'fv-agent-connect-status'); status.setAttribute('role', 'status'); - const dot = el('span', 'fv-agent-connect-dot'); dot.setAttribute('aria-hidden', 'true'); - const label = el('span'); status.append(dot, label); const button = el('button', 'fv-primary'); button.type = 'button'; button.disabled = true; - actions.append(status, button); body.append(subtitle, sentence, actions, note); + const label = el('span'); button.append(icon('copy'), label); + const arrow = el('span', 'fv-agent-connect-arrow', '→'); arrow.setAttribute('aria-hidden', 'true'); + actions.append(button, arrow, el('span', 'fv-agent-connect-hint', t('Paste to your agent', '粘贴给 Agent'))); + const sentence = el('p', 'fv-agent-sentence'); sentence.hidden = true; + body.append(mark, title, subtitle, actions, sentence); - let copied = false, feedback = false, previousName = null, value = null; + let feedback = false, text = ''; let timer, feedbackTimer, controller, generation = 0; const active = () => body.isConnected && shown(); - function render() { - const state = connectionState(copied, value, previousName, t); - dot.dataset.color = state.color; label.textContent = state.text; - button.textContent = feedback ? t('Copied', '已复制') : state.buttonText; - note.textContent = editingNote(value, t); note.hidden = !note.textContent; - } + const render = () => { label.textContent = feedback ? t('Copied', '已复制') : t('Copy', '复制'); }; render(); button.onclick = async () => { - const text = sentence.textContent; - try { await navigator.clipboard.writeText(text); } + try { await navigator.clipboard.writeText(text); sentence.hidden = true; } catch { + sentence.textContent = text; sentence.hidden = false; const range = document.createRange(); range.selectNodeContents(sentence); const selection = getSelection(); selection.removeAllRanges(); selection.addRange(range); } if (!active()) return; - copied = true; - if (value?.connected !== true) previousName = null; feedback = true; render(); clearTimeout(feedbackTimer); feedbackTimer = setTimeout(() => { feedback = false; render(); }, 2000); @@ -76,71 +69,30 @@ export function agentConnectCard(t, api, shown = () => true) { controller?.abort(); controller = null; clearTimeout(feedbackTimer); feedback = false; render(); } - async function poll() { + // Only the sentence is read here, until it is known; who is connected comes from the surface that holds the card. + async function load() { const current = generation; const request = new AbortController(); controller = request; try { - if (!sentence.textContent) { - // The sentence carries no parameter: the server gives the agent the language this card showed. - const response = await api.fetchApi('/freevideo/agent/connect?lang=' + t('en', 'zh'), {cache: 'no-store', signal: request.signal}); - if (!response.ok) return; - const connect = await response.json(); - if (current !== generation || !active()) return; - if (!connect.address) return; - sentence.textContent = connectionSentence(connect.address, t); - button.disabled = false; - } - const response = await agentGet(api, '/freevideo/agent/v1/connect/status', {signal: request.signal}); - if (!response.ok) return; - const next = await response.json(); - if (current !== generation || !active()) return; - value = next; - if (value.connected === true) previousName = value.client?.trim() || previousName || ''; - render(); - } catch { /* Keep the last observed state and retry while expanded. */ } + // The sentence carries no parameter: the server gives the agent the language this card showed. + const response = await api.fetchApi('/freevideo/agent/connect?lang=' + t('en', 'zh'), {cache: 'no-store', signal: request.signal}); + const connect = response.ok ? await response.json() : null; + if (current !== generation || !connect?.address) return; + text = connectionSentence(connect.address, t); + button.disabled = false; + } catch { /* Try again while the card is in sight. */ } finally { if (current === generation) { controller = null; - if (active()) timer = setTimeout(poll, 3000); + if (!text && active()) timer = setTimeout(load, 3000); } } } function sync() { if (!active()) { stop(); return; } - if (!controller && !timer) poll(); + if (!text && !controller && !timer) load(); } // Resolves to whether this FreeVideo has the Agent interface on; a surface shows nothing when it is off. const enabled = agentInterfaceEnabled(api); - return {element: body, title: t('Connect an agent', '连接 Agent'), enabled, sync, stop}; -} - -// Settings: the card as one collapsible section of the dialog. -export function agentConnect(t, api) { - let observer, dialog; - const section = el('details', 'fv-agent-connect'); - section.hidden = true; - const card = agentConnectCard(t, api, () => !section.hidden && section.open && section.dataset.expanded !== 'false' - && (!dialog || dialog.open && dialog.dataset.closing !== 'true')); - section.append(el('summary', null, card.title), card.element); - function close() { - card.stop(); observer?.disconnect(); observer = null; - dialog?.removeEventListener('close', close); dialog = null; - } - section.addEventListener('toggle', () => { - if (!observer) { - dialog = section.closest('dialog'); - observer = new MutationObserver(card.sync); - observer.observe(section, {attributes: true, attributeFilter: ['open', 'hidden', 'data-expanded']}); - if (dialog) { - observer.observe(dialog, {attributes: true, attributeFilter: ['open', 'data-closing']}); - dialog.addEventListener('close', close); - } - } - card.sync(); - }); - card.enabled.then(on => { - if (on) section.hidden = false; - else { close(); section.remove(); } - }); - return section; + return {element: body, enabled, sync, stop}; } diff --git a/web/autopilot.js b/web/autopilot.js new file mode 100644 index 00000000..4cfe7d03 --- /dev/null +++ b/web/autopilot.js @@ -0,0 +1,94 @@ +// The Agent's own page, beside the studio and the editing view: the card that connects one, then the conversation with it. +import { api } from '../../scripts/api.js'; +import { agentConnectCard, connectionLabel } from './agent_connect.js'; +import { about, agentChat, jobName } from './autopilot_chat.js'; +import { openEditorView } from './editor_loader.js'; +import { closeDialog } from './motion.js'; +import { openSetup } from './setup.js'; +import { extendVideo, openStudio, showNodes } from './studio.js'; +import { viewChanged, viewSwitch } from './view_navigation.js'; +import { wordmark } from './wordmark.js'; + +const languageOverride = typeof location !== 'undefined' + ? new URLSearchParams(location.search).get('freevideo_lang') : null; +const cn = languageOverride === 'zh' || (languageOverride !== 'en' + && String(navigator.language || '').toLowerCase().startsWith('zh')); +const t = (en, zh) => cn ? zh : en; +const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }; + +let current = null; +// Opens above the studio when one is open, as the editing view does, so going back finds it as it was. +export function openAutopilotView(node, {studio = null} = {}) { + if (current?.open) { viewChanged('autopilot', node); return current; } + studio?.querySelectorAll('video,audio').forEach(media => media.pause()); + const dialog = el('dialog', 'fv-studio fv-autopilot'); dialog.setAttribute('aria-label', t('Autopilot', 'AI 全自动')); + const header = el('header', 'fv-header'), brand = el('div', 'fv-brand'), tools = el('div', 'fv-header-actions'); + brand.append(wordmark()); + const settings = el('button', 'fv-quiet', t('Settings', '设置')); settings.type = 'button'; settings.onclick = openSetup; + // The one place this page says who is connected, and beside it what that agent is making now. + const lamp = el('button', 'fv-autopilot-lamp'), lampText = el('span'); lamp.type = 'button'; lamp.append(el('i'), lampText); + const now = el('div', 'fv-autopilot-now'), go = el('button', 'fv-autopilot-now-go'), halt = el('button', 'fv-autopilot-now-stop'); now.hidden = true; + const ring = el('i'), name = el('span', 'fv-autopilot-now-name'), more = el('span'), percent = el('b'), eta = el('em'); + go.type = halt.type = 'button'; go.append(ring, name, more, percent, eta); now.append(go, halt); + halt.title = t('Stop all', '全部停止'); halt.setAttribute('aria-label', halt.title); + const line = el('i', 'fv-autopilot-line'); + tools.append(now, lamp, settings); + // The view chosen next takes over once this page has closed; Escape goes back to what is underneath. + let next = () => studio?.open ? viewChanged('studio', node) : viewChanged('nodes', node); + const leave = action => { next = action; closeDialog(dialog); }; + const navigation = viewSwitch('autopilot', () => leave(() => studio?.open ? viewChanged('studio', node) : openStudio(node)), + () => leave(() => showNodes(node, studio)), () => leave(() => openEditorView(node, {studio: studio?.open ? studio : undefined})), () => {}); + header.append(brand, navigation, tools, line); + const inSight = () => dialog.open && dialog.dataset.closing !== 'true'; + const body = el('div', 'fv-autopilot-body'); + const card = agentConnectCard(t, api, () => inSight() && !card.element.hidden); + // Leaving for another view waits until this page has closed, so the view underneath is never covered twice. + const closed = () => dialog.open ? new Promise(done => { dialog.addEventListener('close', done, {once: true}); closeDialog(dialog); }) : null; + const chat = agentChat(t, api, {shown: inSight, + // A finished video opens as its work in Creations. Continuing it there belongs to the studio, so this page gives way first. + open: async job => (await import('./library.js')).openLibrary(t, {selectedWork: job.work, back: t('Back to chat', '返回对话'), + extend: async record => { await closed(); return extendVideo(record, node); }}), + edit: job => leave(() => openEditorView(node, {studio: studio?.open ? studio : undefined, work: {id: job.work, video: `FreeVideo/${job.work}/video.mp4`, geometry: {width: job.width, height: job.height, seconds: job.seconds}}})), + changed: view => { last = view; show(); }}); + // With no agent the card stands alone in the middle. Once one has connected the conversation stays, also after + // it disconnects; the light then leads back to the card. + let last = {known: false, agent: {}, current: null, active: 0, any: false}, on = null, wantCard = false, currentId = null; + function show() { + const connected = last.agent.connected === true, label = connectionLabel(last.agent, t); + if (connected) wantCard = false; + const ready = on === true && last.known, talking = ready && (connected || (last.any && !wantCard)); + // The card gives way to the conversation: typing can start at once. + const arrived = talking && chat.element.hidden && (card.element.contains(document.activeElement) || document.activeElement === lamp); + card.element.hidden = !ready || talking; chat.element.hidden = !talking; + body.dataset.view = talking ? 'chat' : 'connect'; + if (talking) chat.sync(); + if (arrived) chat.input.focus(); + lamp.hidden = !ready; lamp.dataset.color = label.color; lampText.textContent = label.text; lamp.disabled = connected || !last.any; + const job = talking ? last.current : null; + now.hidden = !job; line.dataset.on = String(!!job); + if (job) { + const fraction = last.fraction || 0, running = job.status === 'running'; + currentId = job.id; name.textContent = jobName(job, t); more.textContent = last.active > 1 ? `+${last.active - 1}` : ''; + percent.textContent = running && fraction >= .01 ? `${Math.floor(fraction * 100)}%` : ''; + eta.textContent = running && last.left != null ? about(last.left, t) : ''; + ring.style.setProperty('--p', String(fraction * 100)); line.style.width = `${fraction * 100}%`; + } + card.sync(); + } + lamp.onclick = () => { wantCard = !wantCard; show(); }; + go.onclick = () => chat.scrollToJob(currentId); + halt.onclick = () => chat.stopAll(); + body.append(card.element, chat.element); + card.enabled.then(value => { on = value; show(); if (value) chat.sync(); }); + show(); + dialog.append(header, body); + dialog.addEventListener('cancel', event => { event.preventDefault(); closeDialog(dialog); }); + dialog.onclose = () => { + card.stop(); chat.stop(); dialog.remove(); + if (current === dialog) current = null; + next(); + }; + current = dialog; document.body.append(dialog); dialog.showModal(); viewChanged('autopilot', node); show(); + navigation.querySelector('[data-view=autopilot]').focus(); + return dialog; +} diff --git a/web/autopilot_chat.css b/web/autopilot_chat.css new file mode 100644 index 00000000..eea7bf42 --- /dev/null +++ b/web/autopilot_chat.css @@ -0,0 +1,74 @@ +/* The conversation with an agent: one column, the box to type in fixed under it. */ +.fv-chat{box-sizing:border-box;position:relative;min-height:0;min-width:0;display:grid;grid-template-rows:minmax(0,1fr) auto;grid-template-columns:minmax(0,1fr);gap:16px;padding:0 40px 22px} +.fv-chat[hidden],.fv-chat [hidden]{display:none} +.fv-chat-scroll{min-height:0;overflow:auto;margin:0 -40px;padding:24px 40px 2px;scrollbar-gutter:stable both-edges} +.fv-chat-log{list-style:none;margin:0 auto;padding:0;width:100%;max-width:720px;display:grid;grid-template-columns:minmax(0,1fr);align-content:start;gap:18px} +.fv-chat-log>li{min-width:0}.fv-chat-log p{margin:0;line-height:1.6} +.fv-chat-me{justify-self:end;max-width:min(500px,88%)} +.fv-chat-me>p{background:#1d3550;border-radius:14px 14px 4px 14px;padding:10px 14px;white-space:pre-wrap;overflow-wrap:anywhere;transition:opacity var(--fv-base) var(--fv-ease)} +.fv-chat-me[data-taken=false]>p{opacity:.7} +.fv-chat-ai>p{max-width:600px;white-space:pre-wrap;overflow-wrap:anywhere} +.fv-chat-hello{padding-top:72px}.fv-chat-hello>p{font-size:24px;line-height:1.5;font-weight:var(--fv-semibold);letter-spacing:.2px} +.fv-chat-hello+.fv-chat-examples{margin-top:-2px} +.fv-chat-examples>div{display:grid;gap:10px;margin-top:12px}.fv-chat-meta{color:var(--fv-muted);font-size:12.5px;font-variant-numeric:tabular-nums} +.fv-chat-examples>.fv-chat-meta{font-size:14px} +.fv-studio .fv-chat-examples button{justify-content:flex-start;text-align:left;min-height:0;padding:12px 16px;border:1px solid var(--fv-border);background:#121b26;border-radius:11px;font-size:15px;font-weight:var(--fv-regular);color:var(--fv-ink)} +.fv-studio .fv-chat-examples button:hover:not(:disabled){border-color:var(--fv-accent-dim);background:#14243a} +/* A job the agent started: the frame fills like water while it is made, then holds the video. */ +.fv-chat-job{box-sizing:border-box;justify-self:start;width:100%;max-width:560px;display:grid;grid-template-columns:auto minmax(0,1fr);gap:14px;padding:12px;border:1px solid var(--fv-border);border-radius:14px;background:var(--fv-card)} +.fv-chat-job:is([data-mode=stopped],[data-mode=failed],[data-mode=quiet]){width:auto;display:flex;align-items:center;gap:10px;padding:0;border:0;background:none;color:var(--fv-muted);font-size:13.5px} +.fv-chat-preview{position:relative;align-self:start;width:124px;aspect-ratio:16/9;border-radius:9px;overflow:hidden;background:#0c121a} +.fv-chat-preview[data-shape=wide]{width:240px} +.fv-chat-preview>:is(img,video){position:absolute;inset:0;width:100%;height:100%;object-fit:contain;display:block} +.fv-chat-preview>img{animation:fv-chat-in .3s var(--fv-ease)}.fv-chat-preview[data-video]>img{display:none} +@keyframes fv-chat-in{from{opacity:0}} +.fv-chat-water{position:absolute;left:0;right:0;bottom:0;height:var(--level,0%);background:linear-gradient(180deg,#4f9dff66,#2a6fc955 60%,#1d4f9566);transition:height .6s var(--fv-ease)} +.fv-chat-water::before{content:"";position:absolute;left:0;right:0;top:-18px;height:18px;background:linear-gradient(180deg,transparent,#4f9dff2e)} +.fv-chat-water::after{content:"";position:absolute;left:0;right:0;top:0;height:2px;background:linear-gradient(90deg,#9fd2ff,#e3f2ff 45%,#9fd2ff) 0 0/200% 100%;box-shadow:0 0 10px #7cc4ff;animation:fv-chat-shine 5s linear infinite} +@keyframes fv-chat-shine{to{background-position:-200% 0}} +.fv-chat-percent{position:absolute;left:0;right:0;bottom:calc(var(--level,0%) + 6px);transition:bottom .6s var(--fv-ease);text-align:center;font-size:26px;line-height:1;font-weight:700;font-variant-numeric:tabular-nums;text-shadow:0 2px 10px #000a} +.fv-chat-length{position:absolute;right:7px;bottom:6px;font-size:11.5px;line-height:1.5;color:#fff;background:#0009;border-radius:4px;padding:0 5px;font-variant-numeric:tabular-nums;pointer-events:none} +.fv-chat-preview[data-under=true] .fv-chat-percent{bottom:calc(var(--level,0%) - 6px - 1em)} +.fv-chat-preview[data-video] .fv-chat-length{bottom:16px} +.fv-studio .fv-chat-play{position:absolute;inset:0;width:100%;height:100%;min-height:0;padding:0;border:0;border-radius:0;background:transparent} +.fv-studio .fv-chat-play:hover{background:transparent} +.fv-chat-play::before{content:"";width:46px;height:46px;border-radius:50%;background:#0b1118b8;border:1px solid #ffffff3d;transition:opacity var(--fv-quick)} +.fv-chat-play::after{content:"";position:absolute;left:50%;top:50%;margin:-8px 0 0 -4px;border-left:13px solid #fff;border-top:8px solid transparent;border-bottom:8px solid transparent;transition:opacity var(--fv-quick)} +.fv-chat-preview[data-playing=true] .fv-chat-play::before,.fv-chat-preview[data-playing=true] .fv-chat-play::after{opacity:0} +.fv-chat-seek{position:absolute;left:8px;right:8px;bottom:5px;width:auto;height:10px;min-height:0;margin:0;padding:0;border:0;background:none;appearance:none;-webkit-appearance:none;cursor:pointer} +.fv-studio .fv-chat-seek{min-height:0;border-radius:9px} +.fv-chat-seek::-webkit-slider-runnable-track{height:3px;border-radius:9px;background:#ffffff40} +.fv-chat-seek::-webkit-slider-thumb{-webkit-appearance:none;width:9px;height:9px;margin-top:-3px;border-radius:50%;background:#fff} +.fv-chat-seek::-moz-range-track{height:3px;border-radius:9px;background:#ffffff40}.fv-chat-seek::-moz-range-thumb{width:9px;height:9px;border:0;border-radius:50%;background:#fff} +.fv-chat-info{display:grid;align-content:space-between;gap:12px;min-width:0;padding:4px 4px 2px 0} +.fv-chat-info h3{margin:0;font-size:15px;line-height:1.4;font-weight:var(--fv-semibold);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.fv-chat-info .fv-chat-meta{margin-top:3px} +.fv-chat-parts{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);gap:4px;margin-bottom:7px} +.fv-chat-parts>i{position:relative;height:6px;border-radius:9px;background:#1f2c3b;overflow:hidden} +.fv-chat-parts>i::after{content:"";position:absolute;left:0;top:0;bottom:0;width:var(--w,0%);background:var(--fv-accent);transition:width .6s var(--fv-ease)} +.fv-chat-line{display:flex;justify-content:space-between;gap:10px;color:var(--fv-muted);font-size:12.5px;font-variant-numeric:tabular-nums} +.fv-chat-actions{display:flex;align-items:center;gap:8px;min-width:0}.fv-chat-left{flex:1;min-width:0;color:var(--fv-muted);font-size:12.5px;font-variant-numeric:tabular-nums} +.fv-studio .fv-chat-log :is(.fv-chat-actions,.fv-chat-job)>button{flex:none;min-height:0;padding:5px 14px;border-radius:7px;font-size:13px;white-space:nowrap} +.fv-studio .fv-chat-stop::before{content:"";width:8px;height:8px;border-radius:2px;background:var(--fv-danger)} +.fv-studio .fv-chat-stop:disabled{opacity:1;border-color:transparent;background:none;color:var(--fv-muted)}.fv-studio .fv-chat-stop:disabled::before{display:none} +.fv-chat-confirm{box-sizing:border-box;justify-self:start;width:100%;max-width:440px;display:grid;gap:12px;padding:14px 14px 12px;border:1px solid var(--fv-border);border-radius:14px;background:var(--fv-card)} +.fv-chat-confirm>p:first-child{font-size:14.5px;line-height:1.55;overflow-wrap:anywhere}.fv-chat-confirm>.fv-chat-meta{margin-top:-6px} +.fv-chat-confirm[data-state=answered]{width:auto;padding:0;border:0;background:none;color:var(--fv-muted);font-size:13.5px} +/* The agent has the sentence and has not answered yet. */ +.fv-chat-dots{display:inline-flex;gap:5px;align-items:center;height:22px} +.fv-chat-dots>i,.fv-chat-dots:empty::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--fv-muted);animation:fv-chat-breathe 1.4s var(--fv-ease) infinite} +.fv-chat-dots>i:nth-child(2){animation-delay:.2s}.fv-chat-dots>i:nth-child(3){animation-delay:.4s} +@keyframes fv-chat-breathe{0%,100%{opacity:.25}40%{opacity:1}} +.fv-studio .fv-chat-down{position:absolute;right:max(40px,calc(50% - 360px));bottom:96px;width:34px;height:34px;min-height:0;padding:0;border-radius:50%;background:var(--fv-raised);box-shadow:0 6px 18px #0008} +.fv-chat svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round} +.fv-chat-input{box-sizing:border-box;justify-self:center;width:100%;max-width:720px;min-height:54px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:end;border:1px solid var(--fv-border);background:#0e151e;border-radius:14px;padding:8px 8px 8px 16px;transition:border-color var(--fv-quick),box-shadow var(--fv-quick)} +.fv-chat-input:focus-within{border-color:var(--fv-accent-dim);box-shadow:0 0 0 3px color-mix(in srgb,var(--fv-accent) 13%,transparent)} +.fv-studio.fv-autopilot .fv-chat-input textarea,.fv-chat-input textarea{box-sizing:border-box;width:100%;height:36px;min-height:36px;max-height:none;padding:7px 0;border:0;border-radius:0;background:none;color:var(--fv-ink);font-size:14.5px;line-height:22px;resize:none;outline:none;overflow-y:auto} +.fv-chat-input textarea::placeholder{color:var(--fv-disabled)} +.fv-studio .fv-chat-send{width:36px;height:36px;min-height:0;padding:0;border:0;border-radius:9px;background:var(--fv-accent);color:var(--fv-bg)} +.fv-studio .fv-chat-send:hover:not(:disabled){background:var(--fv-accent-hover)} +.fv-studio .fv-chat-send:disabled{opacity:1;background:#22344a;color:#9fb3c8} +.fv-studio .fv-chat-send:focus-visible{outline-offset:2px} +@media(max-width:480px){.fv-chat{padding:0 14px 14px}.fv-chat-scroll{margin:0 -14px;padding:16px 14px 2px}.fv-chat-hello{padding-top:32px}.fv-chat-job{max-width:none;gap:12px}.fv-chat-confirm{max-width:none} + .fv-chat-preview,.fv-chat-preview[data-shape=wide]{width:98px}.fv-chat-percent{font-size:22px}.fv-studio.fv-autopilot .fv-chat-input textarea{font-size:16px}.fv-studio .fv-chat-down{right:14px;bottom:84px}} +@media(prefers-reduced-motion:reduce){.fv-chat-water,.fv-chat-percent,.fv-chat-parts>i::after{transition:none}.fv-chat-water::after,.fv-chat-dots>i,.fv-chat-preview>img{animation:none}} diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js new file mode 100644 index 00000000..f39a5ca9 --- /dev/null +++ b/web/autopilot_chat.js @@ -0,0 +1,439 @@ +// The conversation with the connected agent: what the user says, what the agent answers, and the agent's jobs as +// cards at the place they happened. Reads and sends use the page session; the agent's token never enters the page. +import {agentGet, agentPost} from './agent_session.js'; +import { estimatedFraction, remainingShown } from './generation_progress.js'; + +const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./autopilot_chat.css', import.meta.url).href; document.head.append(css); + +function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; } +function glyph(path, width = 2.2) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('stroke-width', String(width)); + const p = document.createElementNS('http://www.w3.org/2000/svg', 'path'); p.setAttribute('d', path); svg.append(p); + return svg; +} +const press = (cls, text, action) => { const b = el('button', cls, text); b.type = 'button'; b.onclick = action; return b; }; + +const WORKING = ['running', 'queued', 'waiting'], VIDEOS = ['generate', 'assemble']; +// What a job with no picture of its own is doing, in the reader's language. +const QUIET = {vlm: ['Checking', '检查中'], enhance: ['Polishing', '润色中']}; + +export const about = (seconds, t) => seconds >= 60 ? t(`About ${Math.ceil(seconds / 60)} min`, `约 ${Math.ceil(seconds / 60)} 分钟`) : t('Under 1 min', '不到 1 分钟'); +export function clock(seconds) { const s = Math.max(0, Math.round(seconds)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; } +export const jobName = (job, t) => job.label || t('Video', '视频'); +export const jobFraction = job => Math.max(0, Math.min(1, Number(job.progress?.fraction) || 0)); +// What a job's last progress stands for `age` seconds after it was made, by the creation panel's rule. While the +// engine has no timing for what it is doing (a model loading for the first time, a video in several parts), the +// level still rises over the seconds the reported fraction has stood (`still`): at the pace of the time the engine +// said is left, slowing down, and by no more than a quarter of what remains. +const DRIFT = .25; +export function jobEstimate(job, age, still = age) { + const p = job.progress || {}, e = p.estimate, overall = e?.overall, left = p.remaining_seconds; + const sent = overall ? estimatedFraction(e, overall, age, age) : null; + if (overall?.estimated === true && overall.phase_weight > 0) return sent ?? jobFraction(job); + const base = Math.max(jobFraction(job), sent ?? 0); + return left > 0 ? base + (1 - base) * DRIFT * (1 - Math.exp(-Math.max(0, still) / (DRIFT * left))) : base; +} +// working, done, stopped or failed: which card a job shows. +export const jobMode = job => WORKING.includes(job.status) ? 'working' : job.status === 'succeeded' ? 'done' : job.status === 'cancelled' ? 'stopped' : 'failed'; +// The one line that says where a job is. No step count is written unless the engine gave one. +export function jobLine(job, t) { + const p = job.progress || {}; + if (job.status !== 'running') return t('Queued', '排队中'); + if (p.phase === 'sampling' && p.step?.total && p.step.done >= 1) return t(`Step ${p.step.done} / ${p.step.total}`, `生成 ${p.step.done} / ${p.step.total}`); + if (['decode', 'finishing', 'assembling'].includes(p.phase)) return t('Saving', '保存中'); + if (p.phase === 'encoding') return t('Reading the prompt', '读取提示词'); + if (['load', 'loading'].includes(p.phase)) return t('Loading the model', '加载模型'); + return t('Preparing', '准备中'); +} +export function jobMeta(job, t) { + const parts = []; + if (job.width > 0 && job.height > 0) parts.push(`${job.width} × ${job.height}`); + if (job.seconds > 0) parts.push(t(`${job.seconds} s`, `${job.seconds} 秒`)); + return parts.join(' · '); +} +// One number per part of a long video: 1 when made, how far along for the one being made, 0 for the rest. +export function jobParts(job) { + const block = job.progress?.block, step = job.progress?.step; + if (!(block?.total > 1)) return []; + const inside = job.progress.phase === 'sampling' && step?.total ? Math.min(1, step.done / step.total) : 0; + return Array.from({length: block.total}, (_, i) => i < (block.done || 0) ? 1 : i === block.index - 1 ? inside : 0); +} + +export const emptyChat = () => ({session: undefined, items: new Map(), jobs: {}, confirms: {}, last: 0, read: 0, agent: {}, current: null, active: 0}); +// One answer from the server folded into what the page already has: entries add up by their number, jobs are +// replaced by id (a card keeps its last state after the server stops listing its job), and so are the answers to its questions. +export function chatMerge(state, data) { + const items = new Map(state.items); + for (const item of data.items || []) if (['message', 'job', 'confirm'].includes(item.type) && Number.isFinite(item.seq)) items.set(item.seq, item); + const read = Math.max(state.read, Number(data.read_seq) || 0, ...(data.items || []).filter(item => item.read === true).map(item => item.seq)); + return {session: data.session ?? null, items, jobs: {...state.jobs, ...(data.jobs || {})}, confirms: {...state.confirms, ...(data.confirms || {})}, read, + last: Math.max(state.last, Number(data.last_seq) || 0, ...items.keys()), agent: data.agent || {}, + current: data.current || null, active: Number(data.active) || 0}; +} +// The entries in order. A user's sentence counts as taken once the agent has read it or has said or done anything after it. +export function chatEntries(state) { + const list = [...state.items.values()].sort((a, b) => a.seq - b.seq); + let answered = 0; + for (const item of list) if (item.type !== 'message' || item.role !== 'user') answered = item.seq; + return list.map(item => item.type === 'message' && item.role === 'user' + ? {...item, taken: item.read === true || item.seq <= state.read || item.seq < answered} : item); +} +// Whether a sentence sent now reaches anyone: the agent is waiting for one, or is at work and will come back for it. +export const canSend = state => state.agent?.connected === true && (state.agent.listening !== false || state.active > 0); + +function newId() { + const bytes = crypto.getRandomValues(new Uint8Array(12)); + return 'm-' + [...bytes].map(b => b.toString(36).padStart(2, '0')).join('').slice(0, 20); +} + +// open(job) shows a finished video in Creations, edit(job) takes it to the editing view. shown() says whether the +// user can see the conversation; the page calls sync() whenever that may have changed. changed(view) follows every +// answer: {known, agent, current, active, any}. +export function agentChat(t, api, {open, edit, shown = () => true, changed = () => {}, interval = 1500} = {}) { + const element = el('section', 'fv-chat'), scroller = el('div', 'fv-chat-scroll'), log = el('ol', 'fv-chat-log'); + log.setAttribute('role', 'log'); log.setAttribute('aria-label', t('Conversation', '对话')); + const down = press('fv-chat-down', null, () => { scroller.scrollTo({top: scroller.scrollHeight, behavior: 'smooth'}); }); + down.append(glyph('M12 5v14M5 12l7 7 7-7')); down.setAttribute('aria-label', t('Latest', '最新')); down.hidden = true; + const form = el('form', 'fv-chat-input'), input = el('textarea'), sendButton = el('button', 'fv-chat-send'); + input.rows = 1; input.maxLength = 8000; input.setAttribute('aria-label', t('Message the agent', '对 Agent 说')); + sendButton.type = 'submit'; sendButton.setAttribute('aria-label', t('Send', '发送')); sendButton.append(glyph('M12 19V5M5 12l7-7 7 7')); + form.append(input, sendButton); scroller.append(log); element.append(scroller, down, form); + + let state = emptyChat(), pending = [], known = false, stopped = false, timer = null, busy = false, failures = 0; + // Whether the reader is at the newest entry. It holds while the conversation is out of sight, which forgets where it was scrolled. + let pinned = true; + const closed = new AbortController(); + const nodes = new Map(), answers = new Map(), stopping = new Set(); + // The fraction each job shows: the estimate, never backwards, short of 100% until the video is saved. + const levels = new Map(); + function fractionOf(job) { + const p = job.progress || {}, {age_seconds: sent, ...sample} = p.estimate || {fraction: p.fraction}; + const key = JSON.stringify(sample), time = performance.now() / 1000; + let row = levels.get(job.id); + if (!row) levels.set(job.id, row = {key: null, at: 0, job: null, value: 0, raw: null, since: time}); + // remaining_seconds counts down between samples; the one that came with the sample is the one to move by. + if (row.key !== key) { row.key = key; row.at = time - (sent > 0 ? sent : 0); row.job = job; } + // A loading model sends its own counts on and on while the fraction stands: that standing time is its own clock. + if (row.raw !== p.fraction) { row.raw = p.fraction; row.since = time; } + if (job.status === 'running') row.value = Math.max(row.value, Math.min(.99, jobEstimate(row.job, time - row.at, time - row.since))); + return row.value; + } + // The time left each job shows, by the creation panel's rule: never raised, and gone once its last minute has passed. + const times = new Map(); + function leftOf(job) { + const time = performance.now() / 1000, sent = job.progress?.remaining_seconds; + if (job.status !== 'running') { times.delete(job.id); return null; } + let row = times.get(job.id); + if (!row) { + if (!(sent > 0)) return null; + times.set(job.id, row = {seconds: sent, at: time, minute: null, last: 0}); + } + if (row.seconds === null) return null; + row.seconds = remainingShown(row.seconds, sent > 0 ? sent : null, time - row.at); row.at = time; + if (row.seconds < 60 && row.minute === null) { row.minute = time; row.last = row.seconds; } + if (row.seconds <= 0 || row.minute !== null && time - row.minute >= row.last) row.seconds = null; + return row.seconds; + } + const active = () => !stopped && element.isConnected && !document.hidden && shown(); + + // ---- one entry of the conversation each ---- + function message(role) { + const item = el('li', role === 'user' ? 'fv-chat-me' : 'fv-chat-ai'), text = el('p'); item.append(text); + return {element: item, update(entry) { text.textContent = entry.text; if (role === 'user') item.dataset.taken = String(entry.taken !== false); }}; + } + function preview(job) { + const box = el('div', 'fv-chat-preview'); + const shape = (w, h) => { if (w > 0 && h > 0) { box.style.aspectRatio = `${w} / ${h}`; box.dataset.shape = w > h * 1.1 ? 'wide' : 'tall'; } }; + shape(job.width, job.height); + return {box, shape}; + } + function working(item, job) { + const {box} = preview(job), water = el('i', 'fv-chat-water'), percent = el('b', 'fv-chat-percent'); box.append(water, percent); + const info = el('div', 'fv-chat-info'), head = el('div'), name = el('h3'), meta = el('p', 'fv-chat-meta'); head.append(name, meta); + const middle = el('div', 'fv-chat-where'), parts = el('div', 'fv-chat-parts'), line = el('p', 'fv-chat-line'), part = el('span'), step = el('span'); line.append(part, step); + middle.append(parts, line); + const foot = el('div', 'fv-chat-actions'), left = el('span', 'fv-chat-left'); + const stop = press('fv-chat-stop', null, async () => { + stopping.add(job.id); render(); + try { await agentPost(api, '/freevideo/agent/activity/cancel', {id: job.id}); } catch { stopping.delete(job.id); } + poll(); + }); + foot.append(left, stop); info.append(head, middle, foot); item.replaceChildren(box, info); + // The number rides on the water, and goes under the line once there is no room left above it. + const level = next => { + const fraction = fractionOf(next); + box.style.setProperty('--level', `${fraction * 100}%`); + percent.textContent = next.status === 'running' && fraction >= .01 ? `${Math.floor(fraction * 100)}%` : ''; + const high = box.clientHeight; + box.dataset.under = String(high > 0 && high * (1 - fraction) < percent.offsetHeight + 10); + const seconds = leftOf(next); + left.textContent = seconds === null ? '' : about(seconds, t); + }; + item.tick = () => level(state.jobs[job.id] || job); + return next => { + const cells = jobParts(next); + level(next); + name.textContent = jobName(next, t); meta.textContent = jobMeta(next, t); meta.hidden = !meta.textContent; + while (parts.children.length > cells.length) parts.lastChild.remove(); + while (parts.children.length < cells.length) parts.append(el('i')); + cells.forEach((value, index) => parts.children[index].style.setProperty('--w', `${value * 100}%`)); + parts.hidden = !cells.length; + const block = next.progress?.block; + part.textContent = cells.length ? t(`Part ${block.index} / ${block.total}`, `第 ${block.index} / ${block.total} 段`) : jobLine(next, t); + step.textContent = cells.length ? jobLine(next, t) : ''; + const going = stopping.has(next.id); + stop.disabled = going; stop.textContent = going ? t('Stopping', '正在停止') : t('Stop', '停止'); + stop.setAttribute('aria-label', t('Stop ', '停止 ') + jobName(next, t)); + }; + } + function done(item, job) { + const info = el('div', 'fv-chat-info'), head = el('div'), name = el('h3'), meta = el('p', 'fv-chat-meta'); head.append(name, meta); + const foot = el('div', 'fv-chat-actions'); + const show = press('fv-chat-open', t('Open', '打开'), () => open(job)); + const cut = press('fv-chat-edit', t('Edit', '剪辑'), () => edit(job)); + if (job.work) foot.append(show, cut); + info.append(head, foot); + const children = [info]; + if (job.thumbnail || job.play) { + const {box, shape} = preview(job), length = el('span', 'fv-chat-length', job.seconds > 0 ? clock(job.seconds) : ''); + if (job.thumbnail) { + const image = el('img'); image.alt = ''; image.src = api.apiURL(job.thumbnail); + image.onload = () => { if (!box.style.aspectRatio) shape(image.naturalWidth, image.naturalHeight); }; + box.append(image); + } + if (job.play) { + // The video plays inside its frame, with its sound; the line under it follows and can be dragged. + let video = null; + const toggle = press('fv-chat-play', null, () => { + if (!video) { + video = el('video'); video.playsInline = true; video.src = api.apiURL(job.play); + const seek = el('input', 'fv-chat-seek'); seek.type = 'range'; seek.min = '0'; seek.max = '1000'; seek.value = '0'; + seek.setAttribute('aria-label', t('Position', '播放位置')); + video.onloadedmetadata = () => shape(video.videoWidth, video.videoHeight); + video.ontimeupdate = () => { if (video.duration) seek.value = String(Math.round(video.currentTime / video.duration * 1000)); }; + video.onplay = video.onpause = video.onended = () => { box.dataset.playing = String(!video.paused && !video.ended); label(); }; + seek.oninput = () => { if (video.duration) video.currentTime = seek.value / 1000 * video.duration; }; + box.prepend(video); box.append(seek); box.dataset.video = 'true'; + } + if (video.paused || video.ended) void video.play().catch(() => {}); else video.pause(); + }); + const label = () => toggle.setAttribute('aria-label', (box.dataset.playing === 'true' ? t('Pause ', '暂停 ') : t('Play ', '播放 ')) + jobName(job, t)); + label(); box.append(toggle); + } + if (length.textContent) box.append(length); + children.unshift(box); + } + item.replaceChildren(...children); + return (next, latest) => { + name.textContent = jobName(next, t); meta.textContent = jobMeta(next, t); meta.hidden = !meta.textContent; + show.classList.toggle('fv-primary', latest); + }; + } + function jobCard() { + const item = el('li', 'fv-chat-job'); + let mode = null, apply = null; + return {element: item, update(job, {latest, retry}) { + // Jobs without a picture (a check, a prompt) are one quiet line while they run, and nothing after. + const next = VIDEOS.includes(job.kind || 'generate') ? jobMode(job) + (jobMode(job) === 'done' ? `:${job.play}:${job.thumbnail}:${job.work}` : '') + : WORKING.includes(job.status) ? 'quiet' : 'none'; + if (next !== mode) { + mode = next; item.dataset.job = job.id; item.dataset.mode = next.split(':')[0]; item.hidden = next === 'none'; + if (next !== 'working') stopping.delete(job.id); + if (next === 'working') apply = working(item, job); + else if (next.startsWith('done')) apply = done(item, job); + else if (next === 'quiet') { item.replaceChildren(el('span', 'fv-chat-dots'), el('span', null, t(...(QUIET[job.kind] || ['Working', '进行中'])))); apply = null; } + else if (next === 'stopped') { item.replaceChildren(el('span', null, t('Stopped', '已停止'))); apply = null; } + else if (next === 'failed') { + item.replaceChildren(el('span', null, t('Could not generate', '没有生成出来'))); + // Trying again says the same sentence to the agent once more; why it failed is the agent's to say. + if (retry) item.append(press('fv-chat-retry', t('Retry', '重试'), retry)); + apply = null; + } else { item.replaceChildren(); apply = null; } + } + apply?.(job, latest); + const again = item.querySelector('.fv-chat-retry'); if (again) again.disabled = !canSend(state); + }}; + } + function confirmCard() { + const item = el('li', 'fv-chat-confirm'); + let shownState = null; + return {element: item, update(entry) { + const value = answers.get(entry.id) || state.confirms[entry.id] || entry.state || 'asked'; + if (value === shownState) return; + shownState = value; item.dataset.state = value === 'asked' ? 'asked' : 'answered'; + if (value !== 'asked') { item.replaceChildren(el('span', null, value === 'approved' ? t('Started', '已开始') : t('Not now', '先不拍'))); return; } + const answer = approve => async () => { + item.querySelectorAll('button').forEach(button => { button.disabled = true; }); + try { + const response = await agentPost(api, '/freevideo/agent/chat/confirm', {id: entry.id, approve}); + if (response.ok) answers.set(entry.id, (await response.json()).state || (approve ? 'approved' : 'declined')); + } catch { /* The card stays as asked. */ } + shownState = null; render(); poll(); + }; + const buttons = el('div', 'fv-chat-actions'); + buttons.append(press('fv-chat-yes fv-primary', t('Start', '开始'), answer(true)), press('fv-chat-no', t('Not now', '先不'), answer(false))); + const question = el('p', null, entry.question); + const estimate = el('p', 'fv-chat-meta', entry.estimate_seconds > 0 ? about(entry.estimate_seconds, t) : ''); estimate.hidden = !estimate.textContent; + item.replaceChildren(question, estimate, buttons); + }}; + } + // Before anything is said: what the agent can do, and sentences that send themselves when clicked. + function hello() { + const item = el('li', 'fv-chat-hello'); + item.append(el('p', null, t('I can make videos for you,', '我可以替你生成视频,')), el('p', null, t('or turn a story into a short film.', '也可以把一个故事拍成短片。'))); + return {element: item, update() {}}; + } + function examples() { + const item = el('li', 'fv-chat-examples'), list = el('div'); + const lines = [t('Make a 10-second video: a rainy night, a girl squeezes onto the last bus', '生成一段 10 秒的视频:雨夜,女孩挤上末班车'), + t('Make a 1-minute vertical short film: a day in a coffee shop', '做一条 1 分钟的竖屏短片:咖啡店的一天')]; + list.append(...lines.map(line => press(null, line, () => send(line)))); + item.append(el('p', 'fv-chat-meta', t('Try saying:', '可以这样说:')), list); + return {element: item, update() { list.querySelectorAll('button').forEach(button => { button.disabled = !canSend(state); }); }}; + } + function dots() { + const item = el('li', 'fv-chat-thinking'), mark = el('span', 'fv-chat-dots'); mark.append(el('i'), el('i'), el('i')); + item.setAttribute('role', 'status'); item.setAttribute('aria-label', t('Thinking', '正在思考')); item.append(mark); + return {element: item, update() {}}; + } + const MAKE = {hello, examples, dots, user: () => message('user'), agent: () => message('agent'), job: jobCard, confirm: confirmCard}; + + function render(follow = false) { + const entries = chatEntries(state), ids = new Set(entries.map(entry => entry.id).filter(Boolean)); + pending = pending.filter(entry => !ids.has(entry.id)); + const said = entries.some(entry => entry.type !== 'message' || entry.role === 'user') || pending.length > 0; + const finished = entries.filter(entry => entry.type === 'job' && state.jobs[entry.job]?.status === 'succeeded' && state.jobs[entry.job].work).at(-1); + const wanted = []; + if (!entries.length && !pending.length) wanted.push(['hello', 'hello', null]); + let lastUser = null; + for (const entry of entries) { + if (entry.type === 'message') { + if (entry.role === 'user') lastUser = entry; + wanted.push([entry.role === 'user' && entry.id ? `m:${entry.id}` : `s:${entry.seq}`, entry.role === 'user' ? 'user' : 'agent', entry]); + } else if (entry.type === 'job') { + const job = state.jobs[entry.job]; if (!job) continue; + const text = lastUser?.text; + wanted.push([`s:${entry.seq}`, 'job', job, {latest: entry === finished, retry: text ? () => send(text) : null}]); + } else wanted.push([`s:${entry.seq}`, 'confirm', entry]); + } + for (const entry of pending) wanted.push([`m:${entry.id}`, 'user', {...entry, taken: false}]); + if (!said) wanted.push(['examples', 'examples', null]); + // The agent has the sentence, or is waiting for one, and has not answered yet. + const tail = pending.at(-1) || entries.at(-1); + if (tail?.type !== 'job' && tail?.type !== 'confirm' && (pending.length || tail?.role === 'user') && state.agent.connected === true + && (state.agent.listening !== false || tail.taken === true)) wanted.push(['dots', 'dots', null]); + + const stick = follow || pinned; + const before = log.children.length, keep = new Set(wanted.map(([key]) => key)); + for (const [key, node] of nodes) if (!keep.has(key)) { node.element.remove(); nodes.delete(key); } + wanted.forEach(([key, kind, value, extra], index) => { + let node = nodes.get(key); + if (!node) { node = MAKE[kind](); nodes.set(key, node); } + node.update(value, extra || {}); + if (log.children[index] !== node.element) log.insertBefore(node.element, log.children[index] || null); + }); + const sendable = canSend(state); + input.disabled = state.agent.connected !== true; + input.placeholder = state.agent.connected === true && !sendable ? t('Ask your agent to keep listening', '回到 Agent 那边让它继续') : t('Message the agent…', '对 Agent 说…'); + sendButton.disabled = !sendable || !input.value.trim(); + if (stick) { pinned = true; scroller.scrollTop = scroller.scrollHeight; down.hidden = true; } + else if (log.children.length > before) down.hidden = false; + } + scroller.addEventListener('scroll', () => { + if (!scroller.clientHeight) return; + pinned = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 48; + if (pinned) down.hidden = true; + }, {passive: true}); + + function report() { + const current = state.current ? state.jobs[state.current] || null : null; + changed({known, agent: state.agent, current, fraction: current ? fractionOf(current) : 0, left: current ? leftOf(current) : null, active: state.active, + any: state.items.size > 0 || pending.length > 0}); + } + async function send(text) { + text = String(text).trim(); + if (!text || !canSend(state)) return false; + const entry = {id: newId(), text}; pending.push(entry); render(true); report(); + let response = null; + try { response = await agentPost(api, '/freevideo/agent/chat', entry); } catch { /* Handled below as not sent. */ } + if (response?.ok) { const value = await response.json().catch(() => ({})); if (value.agent) state.agent = value.agent; poll(); return true; } + // Not sent: the sentence goes back to the box. An agent that was never connected leads back to the card. + pending = pending.filter(item => item !== entry); + if (!input.value) { input.value = text; grow(); } + if (response?.status === 409) state = {...state, agent: {connected: false, name: null}}; + render(); report(); + return false; + } + function grow() { + input.style.height = 'auto'; + // At most five lines; the box scrolls after that. + const line = parseFloat(getComputedStyle(input).lineHeight) || 22; + input.style.height = `${Math.min(input.scrollHeight, Math.round(line * 5) + 14)}px`; + sendButton.disabled = !canSend(state) || !input.value.trim(); + } + input.addEventListener('input', grow); + // Enter sends, Shift+Enter starts a new line; Enter that confirms an input-method candidate does neither. + input.addEventListener('keydown', event => { + if (event.key !== 'Enter' || event.shiftKey || event.isComposing || event.keyCode === 229) return; + event.preventDefault(); form.requestSubmit(); + }); + form.addEventListener('submit', event => { + event.preventDefault(); + const text = input.value; + if (!text.trim() || !canSend(state)) return; + input.value = ''; grow(); void send(text); + }); + + async function poll() { + clearTimeout(timer); timer = null; + if (stopped || busy) return; + if (!active()) return; + busy = true; + let again = false; + try { + const after = state.session ? state.last : 0; + const response = await agentGet(api, '/freevideo/agent/chat' + (after ? `?after=${after}` : ''), {signal: closed.signal}); + if (stopped) return; + if (response.ok) { + const data = await response.json(); + // Another conversation began: forget this one and read the new one whole. + if (after && (data.session ?? null) !== state.session) { state = emptyChat(); again = true; } + else { + if ((data.session ?? null) !== state.session) state = emptyChat(); + state = chatMerge(state, data); failures = 0; + } + } else failures++; + } catch { failures++; } + finally { busy = false; } + if (stopped) return; + if (again) return poll(); + known = true; + // The next read is booked before the page hears of this one, so the page's sync() does not start another. + const idle = state.agent.connected !== true && !state.active; + timer = setTimeout(poll, (idle ? Math.max(interval, 3000) : interval) * Math.min(4, 1 + failures)); + render(); report(); + } + // Between reads the estimate keeps moving, on the cards and in the page's header alike. + const moving = setInterval(() => { + if (!active() || !Object.values(state.jobs).some(job => job.status === 'running')) return; + for (const item of log.children) item.tick?.(); + report(); + }, 250); + const visible = () => { if (!document.hidden) sync(); }; + document.addEventListener('visibilitychange', visible); + function sync() { + if (pinned) scroller.scrollTop = scroller.scrollHeight; + if (active() && !timer && !busy) poll(); + } + render(); + return {element, input, sync, refresh: poll, + scrollToJob(id) { log.querySelector(`[data-job="${CSS.escape(id)}"]`)?.scrollIntoView({block: 'center', behavior: 'smooth'}); }, + async stopAll() { + const ids = Object.values(state.jobs).filter(job => WORKING.includes(job.status)).map(job => job.id); + ids.forEach(id => stopping.add(id)); render(); + await Promise.allSettled(ids.map(id => agentPost(api, '/freevideo/agent/activity/cancel', {id}))); + poll(); + }, + stop() { stopped = true; closed.abort(); clearInterval(moving); clearTimeout(timer); timer = null; document.removeEventListener('visibilitychange', visible); log.querySelectorAll('video').forEach(video => video.pause()); }}; +} diff --git a/web/editor.js b/web/editor.js index 026a02f5..c5d2f9fc 100644 --- a/web/editor.js +++ b/web/editor.js @@ -103,7 +103,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given transport = el('div', 'fv-ed-transport'), genHost = el('div', 'fv-ed-genbar'), tracksHost = el('div', 'fv-ed-tracks'), zoomHost = el('div', 'fv-ed-zoom'); const adjustHost = el('aside', 'fv-ed-adjust-host'); const button = (label, action, cls = '') => { const value = el('button', cls, label); value.type = 'button'; value.onclick = action; return value; }; - const navigation = viewSwitch('editor', () => handle.switchView('studio'), () => handle.switchView('nodes'), () => {}); + const navigation = viewSwitch('editor', () => handle.switchView('studio'), () => handle.switchView('nodes'), () => {}, () => handle.switchView('autopilot')); for (const control of navigation.querySelectorAll('button')) control.removeAttribute('title'); const brand = el('div', 'fv-brand'); const headerActions = el('div', 'fv-header-actions'); diff --git a/web/editor_entry.js b/web/editor_entry.js index d6ae04f6..82081776 100644 --- a/web/editor_entry.js +++ b/web/editor_entry.js @@ -3,7 +3,7 @@ import {openEditor} from './editor.js'; import {api} from '../../scripts/api.js'; import {app} from '../../scripts/app.js'; import {openStudio, showNodes} from './studio.js'; -import {viewChanged} from './view_navigation.js'; +import {openAutopilot, viewChanged} from './view_navigation.js'; function editorPanel(node) { const values = Object.fromEntries((node.widgets || []).filter(widget => widget.name).map(widget => [widget.name, widget.value])); return {node: node.id, width: values.width, height: values.height, text: values.text || '', settings: Object.fromEntries(Object.entries({ @@ -15,7 +15,7 @@ function editorPanel(node) { return {node: String(node.id), prompt: value.output, extra_data: {extra_pnginfo: {workflow: value.workflow || {}}}}; }}; } -export function openEditorView(node, {studio} = {}) { +export function openEditorView(node, {studio, work = null} = {}) { studio?.querySelectorAll('video,audio').forEach(media => media.pause()); const panel = editorPanel(node); const progressElement = studio?.querySelector('.fv-generation-progress'); @@ -27,8 +27,9 @@ export function openEditorView(node, {studio} = {}) { return number ? {percent: Number(number[1])} : {percent: 0}; }; } - const handle = openEditor({api, app, panel, onView(view) { + const handle = openEditor({api, app, panel, from: work ? {work} : null, onView(view) { if (view === 'nodes') showNodes(node, studio); + else if (view === 'autopilot') openAutopilot(node, {studio}); else if (studio?.open) viewChanged('studio', node); else openStudio(node); }}); diff --git a/web/first_run_guide.js b/web/first_run_guide.js index e1957077..4efc7c76 100644 --- a/web/first_run_guide.js +++ b/web/first_run_guide.js @@ -20,7 +20,7 @@ export const CHAPTERS = [ {id: 'longer', anchor: '[data-fv-guide="longer"]', added: true, en: 'For a longer video, or to change part of one, choose "Edit".', zh: '要做更长的视频,或者修\u2060改\u2060其\u2060中\u2060一\u2060段,点“剪辑视图”。'}, {id: 'agent', anchor: '[data-fv-guide="agent"]', added: true, - en: 'Connect Claude Code, Codex or another agent in Settings and let it make videos.', zh: '在设置里连接 Claude Code、Codex 等 Agent,让它生成视频。'}, + en: 'To have an AI agent such as Claude Code or Codex make videos for you, choose "Autopilot".', zh: '要让 Claude Code、Codex 等 Agent 替你生\u2060成\u2060视\u2060频,点“AI 全自动”。'}, ]; export function guideSteps({record, available = [], returning = false, blockers = {}, all = false}) { diff --git a/web/freevideo.js b/web/freevideo.js index 7e39aa77..8a962606 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -8,6 +8,7 @@ import { createProgressConnection } from './progress_connection.js'; import { notifyCompatibility } from './compatibility.js'; import { startUpdateChecks } from './updates.js'; import { installNavigation, refreshNavigation, preferredView } from './view_navigation.js'; +import { agentInterfaceEnabled } from './agent_connect.js'; import { attachReferencePicker, referenceItems, syncReferencePrompt, referenceInputs } from './prompt_references.js'; import { outputDownloadURL } from './output_download.js'; import { shareButton } from './share.js'; @@ -564,7 +565,7 @@ app.registerExtension({ beforeConfigureGraph() { configuringGraph = true; }, async setup() { // One failing part must not take the others down; health.js shows what failed. - for (const [where, start] of [['progress', () => progressConnection.start()], ['navigation', () => installNavigation(showStudio)], + for (const [where, start] of [['progress', () => progressConnection.start()], ['navigation', () => installNavigation(showStudio, agentInterfaceEnabled(api))], ['updates', startUpdateChecks]]) { try { start(); } catch (error) { health({type: 'error', where, error}); } } diff --git a/web/generation_progress.js b/web/generation_progress.js index f14a1dbc..2fee372b 100644 --- a/web/generation_progress.js +++ b/web/generation_progress.js @@ -216,6 +216,56 @@ export function referenceTrimText(row, t) { `${name} 比生成的视频长,已截取前 ${used} 秒${rule}`); } +// The time left on show `dt` seconds on, given the engine's newest estimate (null when it sent none). Never raised: +// an estimate at or above the shown time holds it, so a slower run does not count down to "less than a minute" +// early. A lower one is eased into. +export function remainingShown(shown, target, dt) { + if (target === null) return Math.max(0, shown - dt); + if (target < shown - dt) { + const counted = Math.max(0, shown - dt); + return counted + (target - counted) * (1 - Math.exp(-dt / 15)); + } + return target < shown ? target : shown; +} +// The whole-video fraction the engine's last message stands for now: its estimate moved on by the seconds since the +// step sample (stateAge) and the whole-video sample (overallAge) arrived. Null when the message carries none. +export function estimatedFraction(state, overall, stateAge, overallAge) { + const fraction = value => Math.min(1, Math.max(0, value)); + const counted = Number.isInteger(state.total) && state.total > 0 && Number.isInteger(state.done) + && state.done >= 0 && state.done <= state.total; + let candidate = valid(overall.fraction) ? fraction(overall.fraction) : null; + const canEstimate = overall.status !== 'failed' && overall.estimated === true + && valid(overall.phase_start_fraction) && valid(overall.phase_weight); + if (canEstimate) { + let local = null; + if (state.phase === 'sampling' && counted && !overall.sampling_time_weighted) { + local = valid(state.display_fraction) ? fraction(state.display_fraction) : state.done / state.total; + if (state.done < state.total && valid(state.estimated_step_seconds) && state.estimated_step_seconds > 0) { + const stepElapsed = (valid(state.step_elapsed_seconds) ? state.step_elapsed_seconds : 0) + + Math.max(0, stateAge); + local = Math.max(local, (state.done + Math.min(.88, .88 * stepElapsed / state.estimated_step_seconds)) / state.total); + } else if (valid(overall.phase_seconds) && overall.phase_seconds > 0 + && valid(overall.phase_elapsed_seconds)) { + // Before the first warm NFE supplies a per-step duration, + // use the report-weighted sampling estimate as a smooth + // visual guide. The synchronized NFE counter remains the + // source of truth and this estimate stops short of 100%. + const phaseElapsed = overall.phase_elapsed_seconds + + Math.max(0, overallAge); + local = Math.max(local, Math.min(.95, phaseElapsed / overall.phase_seconds)); + } + } else if (valid(overall.phase_seconds) && overall.phase_seconds > 0 && valid(overall.phase_elapsed_seconds)) { + const phaseElapsed = overall.phase_elapsed_seconds + Math.max(0, overallAge); + // Time can move the estimate, but only an engine transition + // finishes a phase. Long phases stop short of their boundary. + local = Math.min(.95, phaseElapsed / overall.phase_seconds); + } + if (local !== null) candidate = Math.max(candidate ?? 0, + canEstimate ? overall.phase_start_fraction + overall.phase_weight * local : local); + } + return candidate; +} + export function createGenerationProgress(t, now = () => Date.now(), {compact = false, api = null, onLayout = null} = {}) { const element = el('section', 'fv-generation-progress'); element.hidden = true; if (compact) element.classList.add('fv-generation-compact'); @@ -330,36 +380,7 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f return; } const complete = overall.status === 'complete'; - let candidate = valid(overall.fraction) ? fraction(overall.fraction) : null; - const canEstimate = overall.status !== 'failed' && overall.estimated === true - && valid(overall.phase_start_fraction) && valid(overall.phase_weight); - if (canEstimate) { - let local = null; - if (state.phase === 'sampling' && counted(state) && !overall.sampling_time_weighted) { - local = valid(state.display_fraction) ? fraction(state.display_fraction) : state.done / state.total; - if (state.done < state.total && valid(state.estimated_step_seconds) && state.estimated_step_seconds > 0) { - const stepElapsed = (valid(state.step_elapsed_seconds) ? state.step_elapsed_seconds : 0) - + Math.max(0, (now() - sampledAt) / 1000); - local = Math.max(local, (state.done + Math.min(.88, .88 * stepElapsed / state.estimated_step_seconds)) / state.total); - } else if (valid(overall.phase_seconds) && overall.phase_seconds > 0 - && valid(overall.phase_elapsed_seconds)) { - // Before the first warm NFE supplies a per-step duration, - // use the report-weighted sampling estimate as a smooth - // visual guide. The synchronized NFE counter remains the - // source of truth and this estimate stops short of 100%. - const phaseElapsed = overall.phase_elapsed_seconds - + Math.max(0, (now() - overallAt) / 1000); - local = Math.max(local, Math.min(.95, phaseElapsed / overall.phase_seconds)); - } - } else if (valid(overall.phase_seconds) && overall.phase_seconds > 0 && valid(overall.phase_elapsed_seconds)) { - const phaseElapsed = overall.phase_elapsed_seconds + Math.max(0, (now() - overallAt) / 1000); - // Time can move the estimate, but only an engine transition - // finishes a phase. Long phases stop short of their boundary. - local = Math.min(.95, phaseElapsed / overall.phase_seconds); - } - if (local !== null) candidate = Math.max(candidate ?? 0, - canEstimate ? overall.phase_start_fraction + overall.phase_weight * local : local); - } + const candidate = estimatedFraction(state, overall, (now() - sampledAt) / 1000, (now() - overallAt) / 1000); // A retry or a new phase cannot rewind the whole-video bar. Even a // complete sampling phase is not a completed/saved video. if (complete) shownFraction = 1; @@ -614,15 +635,7 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f elapsed.textContent = `${t('Elapsed', '已用时')} ${duration(Math.max(0, seconds))}`; if (ended) remainingSeconds = null; if (remainingSeconds !== null) { - // Never raised. An estimate at or above the shown time holds it, so - // a slower run does not count down to "less than a minute" early. - const dt = Math.max(0, (now() - remainingAt) / 1000); - const target = rawRemaining(); - if (target === null) remainingSeconds = Math.max(0, remainingSeconds - dt); - else if (target < remainingSeconds - dt) { - const counted = Math.max(0, remainingSeconds - dt); - remainingSeconds = counted + (target - counted) * (1 - Math.exp(-dt / 15)); - } else if (target < remainingSeconds) remainingSeconds = target; + remainingSeconds = remainingShown(remainingSeconds, rawRemaining(), Math.max(0, (now() - remainingAt) / 1000)); if (remainingSeconds < 60 && lastMinuteAt === null) { lastMinuteAt = now(); lastMinute = remainingSeconds; } // A run slower than estimated would leave "less than a minute" up for minutes: // once that minute has passed, or the time reaches zero, stop showing it. diff --git a/web/health.js b/web/health.js index 3a808b46..39870fe3 100644 --- a/web/health.js +++ b/web/health.js @@ -13,7 +13,7 @@ const t = (en, zh) => cn ? zh : en; // launcher.js removes this flag during setup; read it before that. const launchVisit = query.get('freevideo') === 'launch'; // Every script this folder ships; a test keeps the list in step with web/*.js. -export const MODULES = ['agent_activity.js', 'agent_connect.js', 'agent_session.js', 'browser_extension.js', 'chain_support.js', 'capability_view.js', 'compatibility.js', 'error_panel.js', 'first_run_guide.js', 'fonts.js', 'freevideo.js', 'generation_progress.js', +export const MODULES = ['agent_activity.js', 'agent_connect.js', 'agent_session.js', 'autopilot.js', 'autopilot_chat.js', 'browser_extension.js', 'chain_support.js', 'capability_view.js', 'compatibility.js', 'error_panel.js', 'first_run_guide.js', 'fonts.js', 'freevideo.js', 'generation_progress.js', 'editor.js', 'editor_actions.js', 'editor_adjust.js', 'editor_adjust_picture.js', 'editor_picture_fields.js', 'editor_picture_session.js', 'editor_preview_picture.js', 'editor_entry.js', 'editor_export_dialog.js', 'editor_first_frame.js', 'editor_genbar.js', 'editor_geometry.js', 'editor_library.js', 'editor_menu.js', 'editor_model.js', 'editor_frame.js', 'editor_player.js', 'editor_room.js', 'editor_select.js', 'editor_state.js', 'editor_store.js', 'editor_tracks.js', 'editor_generation.js', 'editor_transport.js', 'editor_transport_model.js', 'editor_audio.js', 'editor_audio_model.js', 'editor_audio_notes.js', 'editor_audio_replace.js', 'editor_audio_rules.js', 'editor_speed.js', 'editor_speed_model.js', 'editor_loader.js', 'editor_text_coverage.js', 'editor_text.js', 'editor_text_model.js', 'editor_text_layout.js', 'editor_notes.js', 'editor_icons.js', 'dialog_keys.js', 'frame_sizes.js', 'health.js', 'image_commands.js', 'image_editor.js', 'image_editor_engine.js', 'launcher.js', 'length_plan.js', 'library.js', 'motion.js', 'output_download.js', 'port_notice.js', 'preview_scene.js', 'progress_connection.js', 'prompt_draft.js', 'prompt_enhance.js', 'prompt_references.js', 'report_issue.js', 'studio_recent.js', diff --git a/web/library.js b/web/library.js index a57faad8..adf7d66d 100644 --- a/web/library.js +++ b/web/library.js @@ -86,12 +86,13 @@ async function workflowBackfill(collection, t) { } // ``upscale`` queues the second pass of a first-pass preview (the studio's node). -export function openLibrary(t, {upscale = null, selectedWork = null, extend = null} = {}) { +export function openLibrary(t, {upscale = null, selectedWork = null, extend = null, back: backLabel = null} = {}) { if (opened?.open) { if (selectedWork) opened.close(); else { opened.focus(); return; } } const dialog = el('dialog', null, 'fv-studio fv-library'); opened = dialog; dialog.setAttribute('aria-label', t('Your creations', '我的作品')); const header = el('header', null, 'fv-library-header'); - const back = button(t('Back to create', '返回创作'), () => closeDialog(dialog), 'fv-quiet'); + // Closing returns to the view this was opened from; that view names itself on the button. + const back = button(backLabel || t('Back to create', '返回创作'), () => closeDialog(dialog), 'fv-quiet'); const title = el('h2', t('Your creations', '我的作品')); const refresh = button(t('Refresh', '刷新'), () => load(false), 'fv-quiet'); header.append(back, title, refresh); diff --git a/web/setup.js b/web/setup.js index bc3461eb..dd48e8a4 100644 --- a/web/setup.js +++ b/web/setup.js @@ -2,7 +2,6 @@ import { api } from "../../scripts/api.js"; import { wordmark } from './wordmark.js'; import { openCompatibility } from './compatibility.js'; import { animateDetails, closeDialog } from './motion.js'; -import { agentConnect } from './agent_connect.js'; import { createGuideReplayEntry } from './first_run_guide.js'; const css = document.createElement('link'); @@ -153,7 +152,7 @@ export async function openSetup() { const {openCreativeWorkspace} = await import('./view_navigation.js'); return openCreativeWorkspace(); }}); - dialog.append(heading, installation, downloads, agentConnect(t, api), advanced, guideReplay); + dialog.append(heading, installation, downloads, advanced, guideReplay); const motions = [...dialog.querySelectorAll('details')].map(animateDetails); dialog.addEventListener('cancel', e => { e.preventDefault(); closeDialog(dialog); }); document.body.append(dialog); dialog.showModal(); diff --git a/web/studio.js b/web/studio.js index c9dc348d..a2731268 100644 --- a/web/studio.js +++ b/web/studio.js @@ -4,7 +4,8 @@ import { api } from '../../scripts/api.js'; import { openSetup } from './setup.js'; import { wordmark } from './wordmark.js'; import { createGenerationProgress, referenceTrimText, thermalText, encoderFileText, encoderFilePath } from './generation_progress.js'; -import { viewSwitch, viewChanged } from './view_navigation.js'; +import { viewSwitch, viewChanged, agentTabReady, openAutopilot } from './view_navigation.js'; +import { agentInterfaceEnabled } from './agent_connect.js'; import { createUpdateNotice, createVersionInfo, productVersion } from './updates.js'; import { createPortNotice } from './port_notice.js'; import { reportIssue, reportTipClosed, showReportTip } from './report_issue.js'; @@ -204,14 +205,13 @@ export function openStudio(node, {extendFrom = null} = {}) { const tools = el('div', null, 'fv-header-actions'); const agents = createAgentActivity(t, api, dialog); tools.append(agents.element); cleanup.push(agents.dispose); const versionInfo = createVersionInfo(cn); tools.append(versionInfo.element); cleanup.push(versionInfo.dispose); - const settingsButton = button(t('Settings', '设置'), openSetup, 'fv-quiet'); - // The Agent connection card lives in Settings, so the guide's last step circles this button when the card is there. - // The guide starts after this settles: it fixes its steps from the anchors present at that moment. - const agentDiscovery = api.fetchApi('/freevideo/agent', {cache: 'no-store'}).then(reply => reply.ok ? reply.json() : null) - .then(found => { if (found?.enabled === true) settingsButton.dataset.fvGuide = 'agent'; }).catch(() => {}); - tools.append(settingsButton); + tools.append(button(t('Settings', '设置'), openSetup, 'fv-quiet')); + // The Agent's page is the fourth tab, shown where the Agent interface is on; the guide's last step circles it. + agentTabReady(agentInterfaceEnabled(api)); const navigation = viewSwitch('studio', () => {}, () => showNodes(node, dialog), - () => openEditorView(node, {studio: dialog})); + () => openEditorView(node, {studio: dialog}), () => openAutopilot(node, {studio: dialog})); + // The guide starts after the tab settles: it fixes its steps from the anchors present at that moment. + const agentDiscovery = navigation.ready; header.append(brand, navigation, tools); const body = el('div', null, 'fv-body'), controls = el('div', null, 'fv-controls'), output = el('div', null, 'fv-preview-column'); dialog.append(header, body); body.append(controls, output); diff --git a/web/view_navigation.css b/web/view_navigation.css index 4317746b..30fd60fe 100644 --- a/web/view_navigation.css +++ b/web/view_navigation.css @@ -4,6 +4,7 @@ .fv-view-switch button,.fv-view-navigation button{font:inherit;cursor:pointer;border:1px solid transparent;color:var(--fv-muted);background:transparent;border-radius:var(--fv-r-sm);padding:6px 14px;font-weight:var(--fv-medium);white-space:nowrap;transition:background .2s,color .2s,border-color .2s} .fv-view-switch button[aria-pressed=true],.fv-studio .fv-view-switch button[aria-pressed=true]{background:var(--fv-raised);color:var(--fv-ink);font-weight:var(--fv-semibold);box-shadow:0 1px 3px #0006}.fv-view-switch button:hover{color:var(--fv-ink)} .fv-view-switch button:focus-visible,.fv-view-navigation button:focus-visible{outline:2px solid var(--fv-accent);outline-offset:2px} +.fv-view-switch button[hidden],.fv-studio .fv-view-switch button[hidden]{display:none} /* First-run tip: a tinted callout, not another line of grey text. */ .fv-view-guide{display:flex;align-items:center;gap:10px;max-width:560px;margin-top:10px;padding:8px 8px 8px 12px;border-radius:var(--fv-r-sm);background:var(--fv-selected);font-size:12px;line-height:1.5;color:var(--fv-ink)} .fv-view-guide::before{content:'i';flex:none;display:grid;place-items:center;width:18px;height:18px;border-radius:50%;background:var(--fv-accent);color:var(--fv-bg);font:600 11px/1 Georgia,serif} diff --git a/web/view_navigation.js b/web/view_navigation.js index 8a2ec168..f9f375d9 100644 --- a/web/view_navigation.js +++ b/web/view_navigation.js @@ -11,7 +11,7 @@ const cn = languageOverride === 'zh' || (languageOverride !== 'en' && String(navigator.language || '').toLowerCase().startsWith('zh')); const t = (en, zh) => cn ? zh : en; const preference = 'freevideo.view'; -let toolbar, open, selected, currentView = 'nodes'; +let toolbar, open, selected, currentView = 'nodes', agentTab = null; const style = document.createElement('link'); style.rel = 'stylesheet'; style.href = new URL('./view_navigation.css', import.meta.url).href; document.head.append(style); @@ -27,15 +27,34 @@ export function viewChanged(view, node) { refreshNavigation(); } -export function viewSwitch(view, onStudio, onNodes, onEditor) { +// Whether this FreeVideo has the Agent interface on. The surfaces that open first (the toolbar, the studio) ask +// and tell it here; a view switch shows its fourth tab only once the answer is yes. +export function agentTabReady(answer) { + agentTab = Promise.resolve(answer).then(on => on === true, () => false); + return agentTab; +} + +// The Autopilot page loads on demand, as the editing view does; a failure leaves the current view as it is. +export async function openAutopilot(node, options) { + try { return (await import('./autopilot.js')).openAutopilotView(node, options); } + catch (error) { console.warn('FreeVideo autopilot view:', error); return null; } +} + +export function viewSwitch(view, onStudio, onNodes, onEditor, onAutopilot) { const group = document.createElement('div'); group.className = 'fv-view-switch'; group.setAttribute('role', 'group'); group.setAttribute('aria-label', t('FreeVideo view', 'FreeVideo 视图')); - for (const [id, label, action] of [['studio', t('Create', '创作面板'), onStudio], ['nodes', t('Nodes', '节点视图'), onNodes], ['editor', t('Edit', '剪辑视图'), onEditor]]) { + for (const [id, label, action] of [['studio', t('Create', '创作面板'), onStudio], ['nodes', t('Nodes', '节点视图'), onNodes], ['editor', t('Edit', '剪辑视图'), onEditor], + ['autopilot', t('Autopilot', 'AI 全自动'), onAutopilot]]) { const button = document.createElement('button'); button.type = 'button'; const text = document.createElement('span'); text.textContent = label; button.append(text); button.dataset.label = label; button.dataset.view = id; if (id === 'editor') button.dataset.fvGuide = 'longer'; button.setAttribute('aria-pressed', String(view === id)); button.onclick = action; + if (id === 'autopilot') { + button.hidden = view !== id; + // Settled once the tab is shown or known to stay away; the first-run guide waits for it. + group.ready = (agentTab || Promise.resolve(false)).then(on => { if (on) { button.hidden = false; button.dataset.fvGuide = 'agent'; } }); + } group.append(button); } return group; @@ -60,8 +79,9 @@ export function openCreativeWorkspace() { return true; } -export function installNavigation(openStudio) { +export function installNavigation(openStudio, agent = false) { open = openStudio; + if (!toolbar) agentTabReady(agent); if (toolbar) { refreshNavigation(); return; } toolbar = document.createElement('aside'); toolbar.className = 'fv-view-navigation'; toolbar.setAttribute('aria-label', t('FreeVideo workspace', 'FreeVideo 工作区')); @@ -76,7 +96,7 @@ export function installNavigation(openStudio) { const settings = document.createElement('button'); settings.type = 'button'; settings.textContent = t('Settings', '设置'); settings.className = 'fv-view-settings'; settings.onclick = openSetup; row.append(grip, brand, viewSwitch('nodes', openCreativeWorkspace, () => {}, () => choose(node => - openEditorView(node))), createVersionInfo(cn).element, settings); toolbar.append(row); + openEditorView(node)), () => choose(node => openAutopilot(node))), createVersionInfo(cn).element, settings); toolbar.append(row); let shown = false; try { shown = localStorage.getItem('freevideo.view-guide') === '1'; } catch { /* Show once this session. */ } if (!shown) {