From d781a21bdcb0af1ff88efe293f00ccb8b0eb37a0 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Mon, 12 Oct 2026 01:12:41 +0000 Subject: [PATCH 1/3] Offline packages without long-path support and no slowdown after one interruption (0.3.25) --- __init__.py | 2 + freevideo_engine/__init__.py | 11 + freevideo_engine/adaln.py | 16 +- freevideo_engine/adaptive.py | 9 +- freevideo_engine/background_compile.py | 5 +- freevideo_engine/bootstrap.py | 25 +- freevideo_engine/capabilities.py | 451 ++++++++++ freevideo_engine/comfy_bridge.py | 32 +- freevideo_engine/comfy_capabilities.py | 307 +++++++ freevideo_engine/comfy_launcher.py | 20 +- freevideo_engine/comfy_launcher_runtime.py | 247 ++++-- freevideo_engine/comfy_library.py | 58 +- freevideo_engine/comfy_service.py | 5 +- freevideo_engine/comfy_setup.py | 33 +- freevideo_engine/comfy_source.py | 16 +- freevideo_engine/compatibility.py | 144 +++- freevideo_engine/compatibility_copy.py | 10 + freevideo_engine/compatibility_ui.py | 14 +- freevideo_engine/component_licenses.py | 10 +- freevideo_engine/curl_windows.py | 3 +- freevideo_engine/desktop.py | 39 +- freevideo_engine/desktop_runtime.py | 58 +- freevideo_engine/desktop_shortcut.py | 87 +- freevideo_engine/diagnostic_resources.py | 65 ++ freevideo_engine/diagnostic_summary.py | 12 + freevideo_engine/diagnostics.py | 38 +- freevideo_engine/disk_space.py | 17 +- freevideo_engine/download_settings.py | 7 +- freevideo_engine/effort_forecast.py | 210 ++++- freevideo_engine/engine_binding.py | 63 +- freevideo_engine/execution_paths.py | 96 +++ freevideo_engine/failure_details.py | 18 +- freevideo_engine/file_ops.py | 5 +- freevideo_engine/generate.py | 2 +- freevideo_engine/geometry.py | 6 +- freevideo_engine/install.py | 19 +- freevideo_engine/install_disk.py | 10 +- freevideo_engine/installation_cleanup.py | 62 +- freevideo_engine/installation_diagnostics.py | 12 +- freevideo_engine/launch_view.py | 174 ++++ freevideo_engine/launcher/Main.qml | 2 +- freevideo_engine/launcher_bridge.py | 31 +- freevideo_engine/launcher_session.py | 104 ++- freevideo_engine/launcher_settings.py | 5 +- freevideo_engine/launcher_terminal.py | 7 +- freevideo_engine/launcher_update.py | 42 +- freevideo_engine/local_models.py | 50 +- freevideo_engine/long_paths.py | 234 +++++ freevideo_engine/macos_conditions.py | 202 +++++ freevideo_engine/macos_runtime.py | 40 +- freevideo_engine/macos_stages.py | 7 + freevideo_engine/model_guidance.py | 7 +- freevideo_engine/model_repair.py | 5 +- freevideo_engine/model_upgrade.py | 19 +- freevideo_engine/modern_launcher.py | 34 +- freevideo_engine/monitoring.py | 207 ++++- freevideo_engine/network.py | 19 +- freevideo_engine/offline_errors.py | 75 ++ freevideo_engine/offline_packages.py | 185 ++-- freevideo_engine/offload.py | 156 +++- freevideo_engine/optimize.py | 5 +- freevideo_engine/package_cleanup.py | 178 +++- freevideo_engine/package_paths.py | 69 ++ freevideo_engine/portable.py | 82 +- freevideo_engine/portable_launcher.py | 45 +- freevideo_engine/precompile.py | 28 +- freevideo_engine/prepared_model.py | 15 +- freevideo_engine/processes.py | 16 +- freevideo_engine/proxy.py | 3 +- freevideo_engine/reference_sampler.py | 7 +- freevideo_engine/refine.py | 4 +- freevideo_engine/release_notes.json | 130 ++- freevideo_engine/release_notes.py | 6 +- freevideo_engine/resident_process.py | 4 +- freevideo_engine/resource_history.py | 15 +- freevideo_engine/runtime.py | 17 +- freevideo_engine/runtime_alias.py | 781 +++++++++++++++++ freevideo_engine/runtime_libraries.py | 21 +- freevideo_engine/sampling_assets.py | 8 +- freevideo_engine/stage_estimate.py | 522 ++++++++++++ freevideo_engine/stage_estimate_params.json | 847 +++++++++++++++++++ freevideo_engine/step_grid.py | 52 ++ freevideo_engine/storage.py | 69 +- freevideo_engine/streamed_weights.py | 36 +- freevideo_engine/support_report.py | 14 +- freevideo_engine/testing.py | 3 +- freevideo_engine/torch_download.py | 4 +- freevideo_engine/tuning.py | 7 +- freevideo_engine/variant_cleanup.py | 108 ++- freevideo_engine/version_cleanup.py | 152 +++- freevideo_engine/windows_ux.py | 16 +- pyproject.toml | 2 +- scripts/test_freevideo_entry.mjs | 4 +- web/capability_view.css | 28 + web/capability_view.js | 351 ++++++++ web/compatibility.js | 6 +- web/error_panel.js | 4 +- web/generation_progress.css | 9 +- web/generation_progress.js | 26 +- web/health.js | 4 +- web/setup.js | 2 +- web/studio.js | 31 +- web/studio_size.js | 17 + 103 files changed, 6730 insertions(+), 867 deletions(-) create mode 100644 freevideo_engine/capabilities.py create mode 100644 freevideo_engine/comfy_capabilities.py create mode 100644 freevideo_engine/compatibility_copy.py create mode 100644 freevideo_engine/execution_paths.py create mode 100644 freevideo_engine/launch_view.py create mode 100644 freevideo_engine/long_paths.py create mode 100644 freevideo_engine/macos_conditions.py create mode 100644 freevideo_engine/offline_errors.py create mode 100644 freevideo_engine/package_paths.py create mode 100644 freevideo_engine/runtime_alias.py create mode 100644 freevideo_engine/stage_estimate.py create mode 100644 freevideo_engine/stage_estimate_params.json create mode 100644 freevideo_engine/step_grid.py create mode 100644 web/capability_view.css create mode 100644 web/capability_view.js create mode 100644 web/studio_size.js diff --git a/__init__.py b/__init__.py index 9615c5ca..98f88f8c 100644 --- a/__init__.py +++ b/__init__.py @@ -17,6 +17,8 @@ async def comfy_entrypoint(): update_routes() from .freevideo_engine.comfy_library import register as library_routes library_routes() + from .freevideo_engine.comfy_capabilities import register as capabilities_routes + capabilities_routes() from .freevideo_engine.comfy_share import register as share_routes share_routes() from .freevideo_engine.comfy_metadata import register as workflow_backfill diff --git a/freevideo_engine/__init__.py b/freevideo_engine/__init__.py index b55665f0..2beccf77 100644 --- a/freevideo_engine/__init__.py +++ b/freevideo_engine/__init__.py @@ -4,5 +4,16 @@ # Release builds stamp one UTC version into both the GUI and deployed engine. from pathlib import Path as _Path _build_version = _Path(__file__).with_name('build-version.txt') +try: + from .long_paths import io_path as _io_path +except ModuleNotFoundError as _error: + # Version-stamp probes may copy only this initializer, without the engine. + # A complete launcher always has long_paths; do not hide its import errors. + if _error.name != __name__ + '.long_paths': + raise +else: + _build_version = _io_path(_build_version) + from .long_paths import start_process as _start_process + _start_process() if _build_version.is_file(): __version__ = _build_version.read_text(encoding='utf-8').strip() diff --git a/freevideo_engine/adaln.py b/freevideo_engine/adaln.py index cdd73ab2..28c2e8ad 100644 --- a/freevideo_engine/adaln.py +++ b/freevideo_engine/adaln.py @@ -106,7 +106,12 @@ def __init__(self, root, source_id, steps, task='t2va', *, manifest=None, from src.inference.render import KEYFRAME_NOISE_AUG if old.get('keyframe_noise_aug') != KEYFRAME_NOISE_AUG: continue - legacy_times = [t.tolist() for t in schedule_timesteps(steps, device='cpu', task=old['task'])] + # A legacy table follows the grid this machine's own linspace + # gave the version that wrote it. Where that is not the + # engine's grid the table belongs to other timesteps: keep + # it on disk and do not relabel it. + legacy_times = [t.tolist() for t in schedule_timesteps(steps, device='cpu', task=old['task'], + upstream_grid=True)] if legacy_times != rows: continue files = [] @@ -219,12 +224,15 @@ def save(self, index, module, *, producer_device=None): @torch.no_grad() -def schedule_timesteps(steps, video_shift=12., audio_shift=3., device='cuda', task='t2va'): +def schedule_timesteps(steps, video_shift=12., audio_shift=3., device='cuda', task='t2va', upstream_grid=False): from .media_request import TASKS if task not in TASKS: raise ValueError('Unsupported AdaLN task') - from diffusers import MiniMaxH3Scheduler - video, audio = MiniMaxH3Scheduler(shift=video_shift), MiniMaxH3Scheduler(shift=audio_shift) + if upstream_grid: + from diffusers import MiniMaxH3Scheduler as scheduler + else: + from .step_grid import scheduler + video, audio = scheduler(shift=video_shift), scheduler(shift=audio_shift) video.set_timesteps(steps, device=device) audio.set_timesteps(steps, device=device) return modality_timesteps(video.timesteps, audio.timesteps, task) diff --git a/freevideo_engine/adaptive.py b/freevideo_engine/adaptive.py index 8e508b80..e3e3a830 100644 --- a/freevideo_engine/adaptive.py +++ b/freevideo_engine/adaptive.py @@ -637,9 +637,16 @@ def execute_attempts(history, identity, profile, canvas, launch, *, on_retry(index, row, current, decision) continue # Failure to commit success must not trigger another GPU attempt. - history.finish(attempt, 'success', observation=observation, + settled = history.finish(attempt, 'success', observation=observation, details={'reason': 'Complete request, retained output and metrics verified.', 'prediction_error': report.get('resource_prediction_error')}) + settlement = (settled or {}).get('details', {}) + if settlement.get('compatibility') is not None: + # Preserve the level/changes actually used by this request. Recovery + # applies to the next request, but belongs in this one's diagnostics. + report.setdefault('compatibility', {}).update(settlement['compatibility']) + if 'compatibility_error' in settlement: + row['compatibility_error'] = settlement['compatibility_error'] row.update(state='success', phase='complete', observation=observation, resources=telemetry, seconds=time.perf_counter() - started, metrics=attempt_metrics(metrics)) report['profile'] = current diff --git a/freevideo_engine/background_compile.py b/freevideo_engine/background_compile.py index c61430f0..4ec6bbb1 100644 --- a/freevideo_engine/background_compile.py +++ b/freevideo_engine/background_compile.py @@ -22,6 +22,7 @@ import time from . import processes +from .long_paths import io_path START_DELAY = 30. MAX_DELAY = 600. @@ -93,7 +94,7 @@ def _released(self, deadline): from .locking import runtime_lock while True: try: - with runtime_lock(self.lock, inherit=False): + with runtime_lock(io_path(self.lock), inherit=False): return True except BlockingIOError: if time.monotonic() >= deadline: @@ -123,7 +124,7 @@ def _work(self, command, environment, cwd, log, busy, delay): 'a generation request stops it.') row = dict(started=time.time()) self.attempts.append(row) - with log.open('ab') as stream: + with io_path(log).open('ab') as stream: 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 a901a598..f60466cb 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -21,6 +21,7 @@ import zipfile from urllib.parse import unquote, urlsplit +from . import execution_paths from .hardware import Hardware, GiB, cgroup_capacity from . import __version__ from .policy import resource_budget @@ -34,6 +35,7 @@ from . import network from . import processes from .system import install_root, venv_python, system_memory, nvidia_smi, memory_sample, curl_executable, missing_curl_message +from .system import inference_headroom PACKAGE = Path(__file__).resolve().parent SOURCE = PACKAGE.parent @@ -61,8 +63,9 @@ def _permission_limited_memory_sample(row): return all(type(error) is dict and error.get('winerror') == 5 for error in errors) -def setup_memory_status(row, verbose=False): - available = row.get('effective_available_bytes', row['system_available_bytes']) +def setup_memory_status(row, verbose=False, *, system=None): + available = (inference_headroom(row) if (system or platform.system()) == 'Linux' + else row.get('effective_available_bytes', row['system_available_bytes'])) if memory_sample(row) is None: if row.get('monitor_status') == 'degraded': return 'Process RAM partially unavailable · %.1f GiB available · continuing with system floor' % (available/GiB) @@ -88,11 +91,13 @@ def setup_memory_pressure(row, budget, system): The budget already excludes the OS reserve. Model SDKs additionally switch to bounded streaming below 2 GiB; stopping the whole installer at that same threshold prevented the low-memory path from continuing. - Linux retains its existing container/cache floor. + Linux uses inference's clean-file-cache headroom, retaining the 1 GiB + floor and the separate process budget. Raw cgroup charges remain recorded. """ reserve = (2 if system == 'Windows' else 1) * GiB floor = min(256 * 2**20, int(budget * .05)) if system == 'Windows' else reserve - available = row.get('effective_available_bytes', row['system_available_bytes']) + available = (inference_headroom(row) if system == 'Linux' + else row.get('effective_available_bytes', row['system_available_bytes'])) commit = row.get('system_commit_available_bytes') used = memory_sample(row) reasons = [] @@ -206,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 = subprocess.run([str(python), '-B', '-c', code], capture_output=True, text=True) + check = execution_paths.run([str(python), '-B', '-c', code], capture_output=True, text=True) if check.returncode: row['error'] = check.stderr[-1000:] else: @@ -286,7 +291,7 @@ def inventory(gpu=None): # Unknown must not be presented as an unsupported old GPU architecture. capability = (0, 0) ram = system_memory() - limit, available = cgroup_capacity() + limit, available = cgroup_capacity(include_reclaimable=True) hardware = Hardware(selected['name'], capability, int(float(selected['memory.total']) * 2**20), int(float(selected['memory.free']) * 2**20), ram['total_bytes'], min(ram['available_bytes'], available) if available is not None else ram['available_bytes'], @@ -1172,7 +1177,7 @@ def sample(): row['installation_guard'] = pressure stream.write(json.dumps(dict(row, elapsed_seconds=time.monotonic()-self.started, epoch_seconds=time.time())) + '\n') available = pressure['available_bytes'] - self.ui.resource = setup_memory_status(row, self.ui.verbose) + self.ui.resource = setup_memory_status(row, self.ui.verbose, system=self.system) # Windows helpers can exit/change while their tree is # sampled. Allow two resamples, while checking the live # exhaustion floor on every attempt. The larger planning @@ -1459,13 +1464,13 @@ 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 = subprocess.run([str(uv), '--no-config', 'python', 'find', '--managed-python', + found = execution_paths.run([str(uv), '--no-config', 'python', 'find', '--managed-python', '--no-python-downloads', self.versions['python']], env=env, capture_output=True, text=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 = subprocess.run([str(candidate), '-I', '-B', '-c', + check = execution_paths.run([str(candidate), '-I', '-B', '-c', 'import platform; print(platform.python_version())'], capture_output=True, text=True, timeout=30) if check.returncode == 0 and check.stdout.strip() == self.versions['python']: self.env['UV_PYTHON_INSTALL_DIR'] = str(prepared) @@ -1988,7 +1993,7 @@ def main(argv=None): from .curl_windows import ensure def curl_progress(message): print(json.dumps(message), flush=True) - os.environ['FREEVIDEO_CURL'] = ensure(args.root, progress=curl_progress) + 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) except (OSError, RuntimeError, ValueError, subprocess.CalledProcessError) as error: print('Preflight failed: ' + str(error), file=sys.stderr) diff --git a/freevideo_engine/capabilities.py b/freevideo_engine/capabilities.py new file mode 100644 index 00000000..6d256f68 --- /dev/null +++ b/freevideo_engine/capabilities.py @@ -0,0 +1,451 @@ +"""Static, CPU-only admission and whole-request estimates from saved facts. + +No hardware probing, model imports or report IO. Admission uses saved local +occupancy or a default baseline, with the planner's reserves; it is not a +guarantee of physical capacity. +""" +import hashlib +import json +import math +from statistics import median +from types import SimpleNamespace + +from .effort_forecast import positive, stage_forecast +from .geometry import geometry +from .hardware import GiB, Hardware +from .media_encoding import reference_frames, reference_size +from .media_request import TASKS +from .policy import BLOCK_BYTES, ResourceBudgetError, choose +from .stage_estimate import STAGES, _params, gpu_key, predict_prior +from .two_pass import first_pass_policy, plan as sampling_plan + +# Shared by all queries, including the default summary; parity with the editor +# is checked in test_capabilities (JS uses positive Math.round, not bankers' rounding). +SIZE_PRESETS = (.5, .75, .98, 1., 1.5, 2.) +TIERS = {'light': (8, True), 'medium': (12, False), + 'high': (16, False), 'max': (20, False)} +PLAN_NAMES = {'-1': 'refused', '0': 'normal', '1': 'slower_streaming', + '2': 'admitted_tight_memory'} +# WholeVideoProgress.for_request starts an ordinary new-prompt request with +# these flags. Unobserved encoder/model residency stays cold; compilation is +# unknown (the prior's half-cold default), even later in the same session. +TYPICAL_FLAGS = dict(encode_cached=None) +IDLE_IN_USE = {'Windows': {'vram': int(1.2 * GiB), 'ram': 0}, + 'Linux': {'vram': int(1.2 * GiB), 'ram': 0}, + 'Darwin': {'vram': 0, 'ram': 0}} +UNAVAILABLE = {'version': 1, 'status': 'unavailable'} + + +def normalize_references(task, references=None): + """Panel metadata, never tokens or file identities; immutable cache key. + + None means the smallest layout for the task, with canvas-sized stills + and output-length clips. An explicit list describes every reference. + """ + if task not in TASKS: + raise ValueError('Unsupported task.') + if references is None: + return None + if not isinstance(references, list) or len(references) > 32: + raise ValueError('Use at most 32 reference items.') + rows = [] + for row in references: + if not isinstance(row, dict): + raise ValueError('Invalid reference metadata.') + kind = row.get('kind') + allowed = {'kind', 'seconds'} if kind == 'audio' else {'kind', 'width', 'height'} + if kind == 'video': + allowed |= {'seconds', 'audio_seconds'} + if kind not in ('image', 'video', 'audio') or set(row) - allowed: + raise ValueError('Unsupported reference metadata.') + width = height = seconds = audio = None + if kind != 'audio': + width, height = row.get('width'), row.get('height') + if any(type(n) is not int or not 1 <= n <= 65536 for n in (width, height)): + raise ValueError('Specify the original reference width and height.') + if kind != 'image': + seconds = row.get('seconds') + if not positive(seconds): + raise ValueError('Specify positive finite reference seconds.') + if kind == 'audio' and seconds < 1 / 32000: + raise ValueError('Reference audio is empty after normalization.') + if kind == 'video' and math.ceil(min(seconds, 15.) * 24) < 22: + raise ValueError('The reference video needs at least 22 normalized frames.') + if 'audio_seconds' in row: + audio = row['audio_seconds'] + if not positive(audio): + raise ValueError('Specify positive finite soundtrack seconds, or omit it for silent video.') + if audio < 1 / 32000: + raise ValueError('Reference audio is empty after normalization.') + rows.append((kind, width, height, seconds, audio)) + visual = any(r[0] != 'audio' for r in rows) + audio = any(r[0] == 'audio' or r[4] is not None for r in rows) + expected = 'ref2va_av' if visual and audio else 'ref2va_audio' if audio else 'ref2va' if visual else None + if (task.startswith('ref2va') and task != expected) or (not task.startswith('ref2va') and rows): + raise ValueError('Reference modalities must match the task; keyframes and references cannot be mixed.') + return tuple(rows) + + +def reference_parameters(references): + """JSON form of a normalized cache key, also used in notifications.""" + if references is None: + return None + return [{k: v for k, v in zip(('kind', 'width', 'height', 'seconds', 'audio_seconds'), row) + if v is not None} for row in references] + + +def valid_reference_rows(value): + return (isinstance(value, dict) and set(value) == {'video', 'audio'} + and all(type(value[k]) is int and value[k] >= 0 for k in ('video', 'audio'))) + + +def _material(canvas, task, references): + """Mirror raw-media normalization with scalar facts only. + + Still encoding is one legacy media unit (= 5 reference seconds). Clip + units use the actual retained duration, including a soundtrack encoder. + Metadata duration assumes a continuous 24 fps normalized timeline. + """ + patch = (canvas['height'] // 32) * (canvas['width'] // 32) + if task in ('t2va', 'i2va', 'l2va', 'fl2va'): + count = {'t2va': 0, 'i2va': 1, 'l2va': 1, 'fl2va': 2}[task] + return dict(video=count * patch, audio=0), 5. * count + if references is None: + seconds = canvas['frames'] / 24. + references = (('image', canvas['width'], canvas['height'], None, None),) if task == 'ref2va' else ( + ('audio', None, None, seconds, None),) if task == 'ref2va_audio' else ( + ('video', canvas['width'], canvas['height'], seconds, seconds),) + video_rows = audio_rows = 0 + duration = 0. + limit = reference_frames(canvas) + for kind, width, height, seconds, soundtrack in references: + if kind != 'audio': + width, height = reference_size(width, height, canvas) + patches = (width // 32) * (height // 32) + if kind == 'image': + video_rows += patches + duration += 5. + else: + frames = min(limit, math.ceil(min(seconds, 15.) * 24)) + frames -= (frames - 5) % 17 + video_rows += ((frames - 5) // 17 * 5 + 2) * patches + duration += frames / 24. + audio_seconds = seconds if kind == 'audio' else soundtrack + if audio_seconds is not None: + used = min(audio_seconds, limit / 24.) + samples = int(used * 32000) + if samples < 1: + raise ValueError('Reference audio is empty after normalization.') + audio_rows += 2 * ((samples + 799) // 800) + duration += samples / 32000. + return dict(video=video_rows, audio=audio_rows), duration + + +def content_revision(response): + content = {k: v for k, v in response.items() if k != 'revision'} + return hashlib.sha256(json.dumps(content, sort_keys=True, separators=(',', ':'), + ensure_ascii=False, allow_nan=False).encode()).hexdigest()[:12] + + +def sizes_for(aspect='16:9', size=None): + if size is not None: + width, height = size + if any(type(n) is not int or n > 4096 for n in size): + raise ValueError('Use integer dimensions up to 4096.') + geometry(width, height) + return [{'pixels': width * height / 1024**2, 'width': width, 'height': height}] + try: + a, b = (float(n) for n in aspect.split(':')) + if not positive(a) or not positive(b): + raise ValueError + ratio = a / b + result, seen = [], set() + for pixels in SIZE_PRESETS: + width, height = (32 * math.floor(math.sqrt(pixels * 1024**2 * r) / 32 + .5) + for r in (ratio, 1 / ratio)) + if not 256 <= width <= 4096 or not 256 <= height <= 4096: + raise ValueError + if (width, height) not in seen: + result.append(dict(pixels=pixels, width=width, height=height)) + seen.add((width, height)) + return result + except (ValueError, TypeError, AttributeError, OverflowError, ZeroDivisionError) as error: + raise ValueError('Use a positive width:height aspect with supported dimensions.') from error + + +def _machine(machine, in_use=None): + """Normalize physical totals; configured budget caps are not device totals.""" + if not isinstance(machine, dict): + return None + observed = machine.get('hardware') or {} + device = machine.get('device_identity') or {} + if not isinstance(observed, dict) or not isinstance(device, dict): + return None + name = observed.get('gpu_name') or machine.get('gpu_name') or device.get('name') + system = observed.get('system') or machine.get('system') + mac = machine.get('device_backend') == 'mps' or device.get('backend') == 'mps' + ram = observed.get('ram_total') or device.get('unified_ram_bytes') + vram = ram if mac else observed.get('vram_total') + cap = observed.get('capability') + if (not isinstance(name, str) or not name.strip() or system not in ('Windows', 'Linux', 'Darwin') + or not positive(ram) or not positive(vram)): + return None + if mac: + if system != 'Darwin' or not name.startswith('Apple '): + return None + elif (not isinstance(cap, (list, tuple)) or len(cap) != 2 + or any(type(n) is not int for n in cap) or cap[0] < 8 or not 0 <= cap[1] <= 9): + return None + precision = machine.get('prepared_format', 'int8') + if precision not in ('int8', 'fp8'): + return None + # Only use an already saved Metal observation, never create a Metal device. + metal = observed.get('metal_recommended_working_set_bytes') + if metal is not None and not positive(metal): + return None + idle = IDLE_IN_USE[system] if in_use is None else in_use + if not isinstance(idle, dict) or any(type(idle.get(k)) is not int or idle[k] < 0 for k in ('vram', 'ram')): + raise ValueError('Use nonnegative byte counts for vram and ram in use.') + idle = {k: idle[k] for k in ('vram', 'ram')} + if mac: + idle = dict(IDLE_IN_USE['Darwin']) # Preserve the unified-memory path. + return dict(name=name, system=system, mac=mac, ram=int(ram), vram=int(vram), + vram_free=max(0, int(vram) - idle['vram']), + ram_available=max(0, int(ram) - idle['ram']), idle_in_use=idle, + cap=tuple(cap) if not mac else None, precision=precision, + metal=int(metal) if metal is not None else None) + + +def _request(width, height, frames, steps, two_pass, task='t2va', refine=3, + references=None, reference_rows=None): + """Scalar request shared by admission and offline, recorded-row reviews.""" + canvas = dict(geometry(width, height, frames=frames), task=task, steps=steps) + if reference_rows is not None: + if not valid_reference_rows(reference_rows): + raise ValueError('Invalid recorded reference rows.') + rows, duration = reference_rows, None # History cannot describe media encoding units. + else: + rows, duration = _material(canvas, task, references) + if task != 't2va' or rows['video'] or rows['audio']: + canvas.update(reference_video_tokens=rows['video'], reference_audio_tokens=rows['audio']) + if duration is not None: + canvas['reference_seconds'] = duration + sampling = sampling_plan(canvas, two_pass, task, base_steps=steps, refine_steps=refine) + request = dict(canvas, base_steps=steps, refine_steps=refine if two_pass else 0, + two_pass=two_pass, flow='full', + sampling_geometry={k: sampling[k] for k in ('first', 'second') if sampling[k]}) + return canvas, sampling, request + + +def _admit(machine, width, height, frames, steps, two_pass, task='t2va', refine=3, + references=None, reference_rows=None): + canvas, sampling, request = _request(width, height, frames, steps, two_pass, task, refine, + references, reference_rows) + rows = dict(video=canvas.get('reference_video_tokens', 0), audio=canvas.get('reference_audio_tokens', 0)) + try: + if machine['mac']: + from .macos_generate import policy as mac_policy + from .macos_compute import request_admission, plan as mac_plan + resource = mac_policy(SimpleNamespace(), dict(total_bytes=machine['ram'], + available_bytes=machine['ram'])) + admission = request_admission(resource, sampling, working_set_bytes=machine['metal']) + if admission['state'] == 'refuse': + return None, -1, 'memory' + plans = [mac_plan(min(resource['allocator_bytes'], machine['metal'] or resource['allocator_bytes']), + geometry(**g)['video_tokens'] + 1024 + rows['video'] + rows['audio'], + frames=geometry(**g)['latent_frames'], allow_bounded=True) + for g in (sampling['first'], sampling['second'] or sampling['first'])] + request.update(pass1_resident_blocks=plans[0]['retained_layers'], + pass2_resident_blocks=plans[1]['retained_layers']) + code = 0 if all(p['retained_layers'] == 50 for p in plans) else 1 + if admission['state'] == 'tight': + code = 2 + return request, code, None + hardware = Hardware(machine['name'], machine['cap'], machine['vram'], machine['vram_free'], + machine['ram'], machine['ram_available'], system=machine['system']) + options = dict(precision=machine['precision'], attention='sage2/sage2', available_backends={'sage2'}) + choose(hardware, canvas=canvas, stage='encoding', **options) + final = choose(hardware, canvas=canvas, **options) + first = first_pass_policy(final.legacy_profile(), canvas, sampling) + engines = [first['profile']['engine'] if first else final.engine, final.engine] + request.update(pass1_resident_blocks=engines[0]['resident_blocks'], + pass2_resident_blocks=final.engine['resident_blocks'], + decoder_resident_blocks=final.decoder['resident_blocks'], + decoder_offload=final.decoder['offload']) + disk_risk = any(e['stream_weights'] and e['pin_host_gb'] * 1e9 + 1e6 + < (50 - e['resident_blocks']) * BLOCK_BYTES for e in engines) + transfers = any(e['residual_offload'] or e['attention_cpu_outputs'] for e in engines) + return request, int(disk_risk or transfers), None + except ResourceBudgetError as error: + # One field cannot encode both failures: prefer the system RAM limit. + return None, -1, 'memory' if 'ram' in error.details['insufficient'] else 'gpu_memory' + except MemoryError: + return None, -1, 'memory' + + +def _forecast_hardware(machine): + return dict(gpu_name=machine['name'], os=machine['system'], + ram_total_gib=machine['ram'] / GiB, vram_total_gib=machine['vram'] / GiB, + vram_free_gib_start=machine['vram_free'] / GiB) + + +def _recent(records): + rows = [r for r in records[:48] if isinstance(r, dict)] + if rows and all(positive(r.get('recorded_epoch')) for r in rows): + rows.sort(key=lambda r: r['recorded_epoch'], reverse=True) + return rows + + +def _local_in_use(machine, records, version): + """Use the calibration's ordered, version-preferred window of saved bytes.""" + def values(rows): + return [r['in_use'] for r in rows if isinstance(r.get('in_use'), dict) + and all(type(r['in_use'].get(k)) is int for k in ('vram', 'ram'))][:5] + rows = values([r for r in records if version and r.get('version') == version]) or values(records) + return {k: max(0, min(machine[k], int(median(r[k] for r in rows)))) + for k in ('vram', 'ram')} if rows else None + + +def _calibration(machine, hardware, records, params, version): + def ratios(rows): + found = [] + for row in rows: + try: + if row.get('flow', 'full') != 'full' or not positive(row.get('request_seconds')) or row['request_seconds'] > 1e6: + continue + shape = row['shape'] + canvas = geometry(**{k: shape[k] for k in ('width', 'height', 'frames')}) + if canvas['frames'] != shape['frames'] or type(row['two_pass']) is not bool: + continue + refs = row.get('reference_rows') + if row['task'] != 't2va' and not valid_reference_rows(refs): + continue # Legacy material records do not describe their rows. + request, _, _ = _admit(machine, shape['width'], shape['height'], shape['frames'], + row['base_steps'], row['two_pass'], row['task'], + row['refine_steps'] if row['two_pass'] else 3, + reference_rows=refs) + if request is None: + continue + request['lora_count'] = int(bool(row.get('adapters'))) + flags = dict(TYPICAL_FLAGS, encode_cached=bool(row.get('conditioning_cache_hit')), + model_cold=not bool(row.get('model_resident'))) + prior = predict_prior(hardware, request, flags, params)['total']['seconds'] + if positive(prior): + found.append(row['request_seconds'] / prior) + if len(found) == 5: + break + except (LookupError, TypeError, ValueError, ArithmeticError, AttributeError): + continue + return found + values = ratios([r for r in records if version and r.get('version') == version]) or ratios(records) + return {'ratio': max(.5, min(2., median(values))), 'runs': len(values)} if values else None + + +def _whole_records(records, request, adapters): + # stage_forecast can extrapolate nearby shapes/step counts for live ETA. + # A heat-map point uses local seconds only for its own complete settings; + # every other point follows the machine-wide calibration ratio. + shape = {k: request[k] for k in ('width', 'height', 'frames')} + rows = dict(video=request.get('reference_video_tokens', 0), audio=request.get('reference_audio_tokens', 0)) + return [r for r in records if r.get('shape') == shape + and r.get('flow', 'full') == 'full' and r.get('task') == request['task'] + and r.get('adapters', False) == adapters and r.get('two_pass') == request['two_pass'] + and r.get('base_steps') == request['base_steps'] and r.get('refine_steps') == request['refine_steps'] + and ((request['task'] == 't2va' and r.get('reference_rows') is None) + or (valid_reference_rows(r.get('reference_rows')) and r['reference_rows'] == rows))] + + +def build(machine, records, *, aspect='16:9', size=None, task='t2va', adapters=False, version=None, + in_use=None, longest_wait_seconds=None, references=None): + """Return selectable whole seconds and the separate legal-frame boundary. + + Enumerating every legal frame count also finds boundaries between whole + seconds. A panel-limited row ends at 1450 frames (60 s input). + """ + machine = _machine(machine, in_use) + if machine is None: + return dict(UNAVAILABLE) + records = _recent(records) + idle_basis = 'local' if in_use is not None and not machine['mac'] else 'default' + if in_use is None and machine['system'] in ('Windows', 'Linux') and not machine['mac']: + idle = _local_in_use(machine, records, version) + if idle is not None: + machine.update(idle_in_use=idle, vram_free=machine['vram'] - idle['vram'], + ram_available=machine['ram'] - idle['ram']) + idle_basis = 'local' + if task not in TASKS or type(adapters) is not bool: + raise ValueError('Unsupported task or adapters flag.') + references = normalize_references(task, references) + if longest_wait_seconds is not None and not positive(longest_wait_seconds): + raise ValueError('Use a positive longest wait in seconds.') + sizes = sizes_for(aspect, size) + lengths = [dict(seconds=s, frames=geometry(seconds=s)['frames']) for s in range(2, 61)] + length_frames = {l['frames'] for l in lengths} + params = _params(None) + hardware = _forecast_hardware(machine) + calibration = _calibration(machine, hardware, records, params, version) + ratio = calibration['ratio'] if calibration else 1. + item = next((v for k, v in params['gpu_speed_table'].items() + if gpu_key(k).casefold() == gpu_key(machine['name']).casefold()), {}) + fitted = item.get('training_requests', 0) > 0 and 'G' in item + # The shipped fit counts requests, not distinct community reports. Do not + # relabel 77 training requests as 77 reports; unknown support stays null. + reports = item.get('model_reports') + basis = 'local' if calibration else 'same_model' if fitted else 'near_model' + + def estimate(request): + request['lora_count'] = int(adapters) + prior = predict_prior(hardware, request, TYPICAL_FLAGS, params) + weights = {k: prior['stages'][k]['seconds'] for k in STAGES} + matches = _whole_records(records, request, adapters) + local = (stage_forecast(matches, request, TYPICAL_FLAGS, weights, version=version) + if matches else {}) + return local.get('seconds', prior['total']['seconds'] * ratio) + + tiers = {} + for name, (steps, two_pass) in TIERS.items(): + times, codes, maxima, frame_maxima, limits = [], [], [], [], [] + for size_row in sizes: + admitted = {f: _admit(machine, size_row['width'], size_row['height'], f, steps, two_pass, task, + references=references) + for f in range(39, lengths[-1]['frames'] + 1, 17)} + estimates = {} + for f, (request, _, _) in admitted.items(): + if request is not None and (longest_wait_seconds is not None + or f in length_frames): + estimates[f] = estimate(request) + if longest_wait_seconds is not None and estimates[f] > longest_wait_seconds: + admitted[f] = (None, -1, 'time') + frames = [f for f, (request, _, _) in admitted.items() if request is not None] + maximum = max(frames) if frames else None + frame_maxima.append(maximum) + maxima.append(max((l['seconds'] for l in lengths if admitted[l['frames']][0] is not None), + default=None)) + next_frame = maximum + 17 if maximum is not None else 39 + limits.append('panel' if next_frame > lengths[-1]['frames'] else admitted[next_frame][2]) + row_times, row_codes = [], [] + for length in lengths: + request, code, _ = admitted[length['frames']] + seconds = None + if request is not None: + seconds = max(1, math.floor(estimates[length['frames']] + .5)) + row_times.append(seconds) + row_codes.append(code) + times.append(row_times) + codes.append(row_codes) + tiers[name] = dict(steps=steps, two_pass=two_pass, estimate_seconds=times, + max_seconds=maxima, max_frames=frame_maxima, limit=limits, plan=codes) + default = next((i for i, s in enumerate(sizes) if s['pixels'] == .98), 0) + result = dict(version=1, status='ready', + machine=dict(gpu_name=machine['name'], vram_gib=machine['vram'] / GiB, + ram_gib=machine['ram'] / GiB, system=machine['system']), + aspect=aspect, sizes=sizes, lengths=lengths, tiers=tiers, + summary=dict(width=sizes[default]['width'], height=sizes[default]['height'], tier='light', + max_seconds=tiers['light']['max_seconds'][default], + max_frames=tiers['light']['max_frames'][default]), + diagnostics=dict(basis=basis, model_reports=reports, calibration=calibration, + idle_basis=idle_basis, + idle_in_use_gib={k: round(v / GiB, 3) for k, v in machine['idle_in_use'].items()}, + plan_names=dict(PLAN_NAMES))) + result['revision'] = content_revision(result) + return result diff --git a/freevideo_engine/comfy_bridge.py b/freevideo_engine/comfy_bridge.py index 03160bfc..447e9908 100644 --- a/freevideo_engine/comfy_bridge.py +++ b/freevideo_engine/comfy_bridge.py @@ -12,6 +12,7 @@ import time import uuid +from .long_paths import io_path, resolved, plain, plain_data, plain_errors from . import processes from .geometry import geometry from .comfy_environment import isolated_environment @@ -246,8 +247,9 @@ def source_root(): return Path(__file__).resolve().parents[1] +@plain_errors def installation_root(source=None, environ=None): - source = Path(source or source_root()) + source = io_path(source or source_root()) environ = os.environ if environ is None else environ selected = environ.get('FREEVIDEO_HOME') config = source / 'comfyui.json' @@ -255,17 +257,18 @@ def installation_root(source=None, environ=None): selected = json.loads(config.read_text(encoding='utf-8')).get('installation') if not isinstance(selected, str) or not selected.strip(): raise ValueError('comfyui.json must contain an installation directory.') - root = Path(selected).expanduser() if selected else source + root = io_path(Path(selected).expanduser()) if selected else source if not root.is_absolute(): root = source / root - return root.resolve() + return Path(plain(resolved(root))) +@plain_errors def installation(source=None, environ=None): from .comfy_launcher_api import repair_hint root = installation_root(source, environ) try: - machine = json.loads((root / 'machine.json').read_text(encoding='utf-8')) + machine = json.loads((io_path(root) / 'machine.json').read_text(encoding='utf-8')) except FileNotFoundError as error: raise ValueError('FreeVideo setup is missing. ' + repair_hint() + ' Or run setup.cmd (Windows) or setup.sh (Linux) ' 'in the FreeVideo folder, or set FREEVIDEO_HOME to your existing installation. ' @@ -277,11 +280,11 @@ def installation(source=None, environ=None): identity = 'device_identity' if machine.get('device_backend') == 'mps' else 'gpu_uuid' if not machine.get(identity): raise ValueError('FreeVideo device configuration is missing. ' + repair_hint()) - if Path(machine['root']).expanduser().resolve() != root: + if resolved(Path(machine['root']).expanduser()) != resolved(root): raise ValueError('The FreeVideo installation was moved. Rerun setup in the selected folder.') - if not Path(machine['python']).is_file(): + if not io_path(machine['python']).is_file(): raise ValueError('The FreeVideo Python environment is missing. Rerun setup in %s.' % root) - return root, machine + return root, plain_data(machine) def validate_request(prompt, width, height, seconds, seed): @@ -417,13 +420,17 @@ def progress_message(event): # crossing a soft estimate while memory is available needs no action. return None if name == 'compatibility': - from .compatibility import LEVELS + from .compatibility import LEVELS, RECOVERY_SUCCESSES level = event.get('level') if type(level) is not int or not 1 <= level <= 3: return None return {'compatibility': dict(level=level, name_en=LEVELS[level]['en'].split(' · ', 1)[0], - name_zh=LEVELS[level]['zh'].split(' · ', 1)[0])} + name_zh=LEVELS[level]['zh'].split(' · ', 1)[0], + origin=event.get('origin'), + consecutive_successes=event.get('consecutive_successes', 0), + recovery_successes_required=event.get('recovery_successes_required', RECOVERY_SUCCESSES), + recovery_disabled=event.get('recovery_disabled', False))} if name == 'compute_device': return {'label': 'Compute device · %s · %s' % (event.get('backend', ''), event.get('name', '')), 'device': event} @@ -823,6 +830,13 @@ def store_result(check): state['result_cache'].update(stored=stored is True, store_inspection=timing) send_progress({'label': 'Preview saved' if preview else 'Video + audio saved', 'phase': 'complete', 'done': completed_steps, 'total': completed_steps}) + try: + # The validated request report is on disk. This advisory refresh + # must never change a successful generation's result. + from .comfy_capabilities import changed + changed() + except Exception: + pass return output except BaseException as error: cancelled = isinstance(error, KeyboardInterrupt) or type(error).__name__ in ('InterruptProcessingException', 'CancelledError') diff --git a/freevideo_engine/comfy_capabilities.py b/freevideo_engine/comfy_capabilities.py new file mode 100644 index 00000000..a2daa4d6 --- /dev/null +++ b/freevideo_engine/comfy_capabilities.py @@ -0,0 +1,307 @@ +"""Advisory capabilities route: bounded history IO and a CPU background cache.""" +import asyncio +from collections import OrderedDict +from concurrent.futures import Future +import hashlib +import json +import logging +from pathlib import Path +import platform +import sys +import threading +import time + +from . import __version__ +from .capabilities import (UNAVAILABLE, build, content_revision, normalize_references, + reference_parameters, sizes_for) +from .effort_forecast import local_records, same_device +from .media_request import TASKS + +COMPUTING = {'version': 1, 'status': 'computing', 'retry_after_seconds': 1} +DEFAULT_QUERY = ('16:9', None, 't2va', False, None) +LONGEST_WAIT_SECONDS = None # Product decision pending; no time cutoff today. + + +def _hash(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':'), + ensure_ascii=False, allow_nan=False).encode()).hexdigest()[:12] + + +def _context(output_directory): + from .comfy_bridge import installation + _, machine = installation() + machine = dict(machine) + device = machine.get('device_identity') if machine.get('device_backend') == 'mps' else machine.get('gpu_uuid') + # Older machine.json stores totals only in its setup plan. Match the saved + # device before using that snapshot, just as start_context does for ETA. + observed = {} + if machine.get('setup_run'): + try: + with (Path(machine['setup_run']) / 'plan.json').open('rb') as stream: + raw = stream.read(1024 * 1024 + 1) + if len(raw) <= 1024 * 1024: + found = json.loads(raw).get('inventory', {}).get('hardware', {}) + if same_device(found, device): + observed = {k: found[k] for k in ('gpu_name', 'capability', 'ram_total', 'vram_total', + 'system', 'metal_recommended_working_set_bytes') if k in found} + except (OSError, ValueError, TypeError, AttributeError): + pass + if isinstance(machine.get('hardware'), dict): + observed.update(machine['hardware']) + machine['hardware'] = observed + # Do not inherit the sampling-estimate route's 15-second history cache: + # reopening the grid after a generation must notice the new timing record. + rows = local_records(output_directory, device, system=machine.get('system'), stage_details=True, refresh=True) + return machine, rows + + +def _apple_silicon(): + return platform.system() == 'Darwin' and platform.machine().lower() in ('arm64', 'aarch64') + + +class CapabilityCache: + """One builder per server, at most 32 query variants, no foreground builds.""" + def __init__(self, output_directory, server=None): + self.output_directory = output_directory + self.server = server + self.lock = threading.Condition() + self.entries = OrderedDict([(DEFAULT_QUERY, dict(COMPUTING))]) + self.pending = OrderedDict() + self.snapshot = None + self.signature = None + self.generation = 0 + self.initialized = False + self.failed = False + self.logged = False + self.check_task = None + self.checked_at = 0. + self.refresh_requested = True + self.refresh_done = Future() + self.active_refresh = None + threading.Thread(target=self._work, name='freevideo-capabilities', daemon=True).start() + + def _log_once(self): + with self.lock: + if self.logged: + return + self.logged = True + # Exception messages can contain installation/report paths. Keep this + # advisory failure private and never let it interfere with generation. + logging.warning('FreeVideo capabilities are unavailable; background estimation failed.') + + def refresh(self): + """History/saved snapshot IO only; called in a thread, never the loop.""" + try: + machine, records = _context(self.output_directory) + # Count/time alone misses old records lacking chronology, or a + # replaced record at the same count: hash the allowlisted timings. + signature = _hash((machine, records, __version__)) + with self.lock: + if signature != self.signature or self.failed: + self.signature = signature + self.snapshot = (machine, records) + self.generation += 1 + self.failed = False + mac = machine.get('device_backend') == 'mps' or (machine.get('device_identity') or {}).get('backend') == 'mps' + for key in self.entries: + if mac: + self.entries[key] = dict(UNAVAILABLE) + elif self.entries[key]['status'] != 'ready': + self.entries[key] = dict(COMPUTING) + if not mac: + self.pending[key] = None + if mac: + self.pending.clear() + self.lock.notify() + except Exception: + self._log_once() + with self.lock: + self.failed = True + self.generation += 1 + self.pending.clear() + for key in self.entries: + if self.entries[key]['status'] != 'ready': + self.entries[key] = dict(UNAVAILABLE) + finally: + with self.lock: + self.initialized = True + + def _request_refresh(self, *, join_active=False): + with self.lock: + if join_active and self.active_refresh is not None: + return self.active_refresh + if not self.refresh_requested: + self.refresh_requested = True + self.refresh_done = Future() + self.lock.notify() + return self.refresh_done + + def changed(self): + """Best-effort wakeup from generation; IO/builds stay on the worker. + + Calls coalesce into one pending read. A change during a read requests + one more read so a report written mid-snapshot cannot be missed. + """ + try: + self._request_refresh() + except Exception: + pass + + def _notify(self, key, result): + aspect, size, task, adapters, references = key + try: + if self.server is not None: + # ComfyUI's send_sync schedules delivery on its event loop, + # exactly as comfy_progress.publish does from worker threads. + self.server.send_sync('freevideo_capabilities', dict( + version=1, revision=result['revision'], aspect=aspect, + width=size[0] if size else None, height=size[1] if size else None, + task=task, adapters=adapters, references=reference_parameters(references))) + except Exception: + pass + + def _work(self): + while True: + with self.lock: + while not self.pending and not self.refresh_requested: + self.lock.wait() + if self.refresh_requested: + self.refresh_requested = False + done = self.active_refresh = self.refresh_done + else: + done = None + if done is not None: + key = None + else: + key, _ = self.pending.popitem(last=False) + generation = self.generation + machine, records = self.snapshot + if done is not None: + self.refresh() + with self.lock: + self.active_refresh = None + done.set_result(None) + continue + try: + aspect, size, task, adapters, references = key + result = build(machine, records, aspect=aspect, size=size, task=task, + adapters=adapters, version=__version__, + references=reference_parameters(references), + longest_wait_seconds=LONGEST_WAIT_SECONDS) + if result['status'] == 'ready': + result = dict(result, revision=content_revision(result)) + built = True + except Exception: + self._log_once() + result = dict(UNAVAILABLE) + built = False + notify = False + with self.lock: + if key in self.entries and generation == self.generation: + previous = self.entries[key] + if built or previous['status'] != 'ready': + self.entries[key] = result + notify = (previous['status'] == result['status'] == 'ready' + and previous['revision'] != result['revision']) + if notify: + self._notify(key, result) + + async def check(self): + # Await a worker snapshot for at most 50 ms; no IO on the event loop. + # shield prevents timeout from cancelling the shared completion. + if not self.initialized: + return + if self.check_task is None or self.check_task.done(): + if time.monotonic() - self.checked_at < 1: + return + self.checked_at = time.monotonic() + done = self._request_refresh(join_active=True) + async def wait_snapshot(): + await asyncio.shield(asyncio.wrap_future(done)) + self.check_task = asyncio.create_task(wait_snapshot()) + try: + await asyncio.wait_for(asyncio.shield(self.check_task), timeout=.05) + except asyncio.TimeoutError: + pass + + def get(self, key): + with self.lock: + if self.snapshot is not None: + machine = self.snapshot[0] + if machine.get('device_backend') == 'mps' or (machine.get('device_identity') or {}).get('backend') == 'mps': + return dict(UNAVAILABLE) + if key not in self.entries: + if len(self.entries) == 32: + old, _ = self.entries.popitem(last=False) + self.pending.pop(old, None) + self.entries[key] = dict(UNAVAILABLE if self.failed else COMPUTING) + if self.initialized and not self.failed: + self.pending[key] = None + self.lock.notify() + self.entries.move_to_end(key) + return self.entries[key] + + +def _query(query): + aspect = query.get('aspect', '16:9') + size = None + if 'width' in query or 'height' in query: + if 'width' not in query or 'height' not in query: + raise ValueError('Specify both width and height.') + size = (int(query['width']), int(query['height'])) + task = query.get('task', 't2va') + if task not in TASKS or query.get('adapters', '0') not in ('0', '1'): + raise ValueError('Unsupported task or adapters flag.') + sizes_for(aspect, size) + references = None + if 'references' in query: + raw = query['references'] + if len(raw) > 16384: + raise ValueError('Reference metadata is too large.') + references = json.loads(raw) + if references is None: + raise ValueError('Use a reference metadata list.') + for forbidden in ('reference_video_tokens', 'reference_audio_tokens', 'reference_rows'): + if forbidden in query: + raise ValueError('Send material metadata, not row counts.') + return aspect, size, task, query.get('adapters') == '1', normalize_references(task, references) + + +def register(): + from aiohttp import web + import folder_paths + from server import PromptServer + server = PromptServer.instance + if server is None or getattr(server, '_freevideo_capabilities', False): + return + mac = _apple_silicon() + cache = None if mac else CapabilityCache(folder_paths.get_output_directory(), server) + server._freevideo_capabilities = cache if cache is not None else True + + @server.routes.get('/freevideo/capabilities') + async def capabilities(request): + if mac: + return web.json_response(dict(UNAVAILABLE), headers={'Cache-Control': 'no-store'}) + try: + key = _query(request.query) + except (ValueError, TypeError, OverflowError, RecursionError) as error: + raise web.HTTPBadRequest(text=str(error)) from error + try: + await cache.check() + result = cache.get(key) + except Exception: + cache._log_once() + result = dict(UNAVAILABLE) + return web.json_response(result, headers={'Cache-Control': 'no-store'}) + + +def changed(): + """Notify only an already registered route, without affecting generation.""" + try: + module = sys.modules.get('server') + server = module.PromptServer.instance if module is not None else None + cache = getattr(server, '_freevideo_capabilities', None) + if isinstance(cache, CapabilityCache): + cache.changed() + except Exception: + pass diff --git a/freevideo_engine/comfy_launcher.py b/freevideo_engine/comfy_launcher.py index 4b2676cb..b9093477 100644 --- a/freevideo_engine/comfy_launcher.py +++ b/freevideo_engine/comfy_launcher.py @@ -9,6 +9,7 @@ import webbrowser from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +from .long_paths import io_path, resolved, plain, plain_text, plain_errors from . import __version__ from . import branding from .comfy_launcher_runtime import Controller @@ -245,6 +246,7 @@ def show_options(self): for widget in self.options_inputs: widget.state(['disabled'] if self.controller.busy else ['!disabled']) + @plain_errors def download_settings(self): from .download_settings import Probe from .download_settings_ui import DownloadSettings @@ -254,7 +256,7 @@ def download_settings(self): root = Path(selected['engine']) if selected else self.compatibility_root() if root is None: raise ValueError(self.t('Choose the installation folder first.', '请先选择安装位置。')) - root = root.resolve() + root = Path(plain(resolved(root))) old = self.download_dialog if old and old.window.winfo_exists(): if old.root == root: @@ -264,7 +266,7 @@ def download_settings(self): self.download_dialog = DownloadSettings(self.window, root, self.zh, probe, token=self.token, busy=lambda: self.controller.busy) except (OSError, ValueError) as error: - messagebox.showerror('FreeVideo', str(error), parent=self.window) + messagebox.showerror('FreeVideo', plain_text(error), parent=self.window) def compatibility_root(self): if self.engine.get().strip(): @@ -290,12 +292,13 @@ def compatibility_settings(self): from tkinter import messagebox try: root = self.compatibility_root() - if root is None or not (root/'machine.json').is_file(): + if root is None or not (io_path(root)/'machine.json').is_file(): raise ValueError(self.t('Choose an installed FreeVideo engine first.', '请先选择已安装的 FreeVideo 引擎。')) open_settings(self.window, root, self.zh) except (OSError, ValueError) as error: - messagebox.showerror('FreeVideo', str(error), parent=self.window) + messagebox.showerror('FreeVideo', plain_text(error), parent=self.window) + @plain_errors def save_settings(self): if self.save_timer is not None: self.window.after_cancel(self.save_timer) @@ -307,7 +310,7 @@ def save_settings(self): from .comfy_launcher_runtime import local_url try: selected['url'] = local_url(self.url.get()) - if self.python.get() and Path(self.python.get()).is_file(): + if self.python.get() and io_path(self.python.get()).is_file(): selected['python'] = self.python.get() except ValueError: pass # Keep an editable draft without breaking the saved launcher. @@ -756,11 +759,12 @@ def cancel_timers(self, event): pass setattr(self, name, None) + @plain_errors def callback_error(self, kind, value, trace): path = launcher_root() / 'errors' / ('launcher-' + str(time.time_ns()) + '.log') - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(''.join(traceback.format_exception(kind, value, trace)), encoding='utf-8') - self.status.set(str(value)) + io_path(path.parent).mkdir(parents=True, exist_ok=True) + io_path(path).write_text(plain_text(''.join(traceback.format_exception(kind, value, trace))), encoding='utf-8') + self.status.set(plain_text(value)) def legacy_main(): diff --git a/freevideo_engine/comfy_launcher_runtime.py b/freevideo_engine/comfy_launcher_runtime.py index bf901267..d38f4f05 100644 --- a/freevideo_engine/comfy_launcher_runtime.py +++ b/freevideo_engine/comfy_launcher_runtime.py @@ -20,17 +20,74 @@ from urllib.parse import urlsplit, urlunsplit from urllib.request import ProxyHandler, Request, build_opener +from . import execution_paths from .comfy_bridge import installation -from . import disk_space +from . import disk_space, launch_view from .comfy_environment import isolated_environment from .comfy_setup import Setup, SetupRunner from .comfy_source import RECEIPT, SOURCE_DISK_BYTES, new_layout, validate_target from .desktop_runtime import materialize_source, source_release from .monitoring import save +from .long_paths import io_path, resolved, plain, plain_text, plain_error, plain_errors from . import engine_binding, processes PROTOCOL = 1 TEMPLATE = 'FreeVideo-All-in-One.json' +# Best-effort cancellation must not hold application shutdown hostage. The +# daemon's join is a wall-clock bound even if a server trickles HTTP bytes. +SHUTDOWN_CANCEL_SECONDS = 3. +SHUTDOWN_REQUEST_SECONDS = .5 +SHUTDOWN_POLL_SECONDS = .1 + + +def cancel_running_generation(url): + """Use the page's request-local cancel route, then wait until those requests end. + + The bridge writes its existing durable cancellation receipt before stopping + the CLI, so a request has its receipt once it leaves the running queue. No + receipt is invented here when ComfyUI cannot answer. Requests still waiting + in the queue never launched a worker; stopping the server discards them. + """ + deadline = time.monotonic() + SHUTDOWN_CANCEL_SECONDS + stopped = threading.Event() + + def timeout(): + remaining = deadline - time.monotonic() + if stopped.is_set() or remaining <= 0: + raise TimeoutError('Shutdown cancellation deadline') + return min(SHUTDOWN_REQUEST_SECONDS, remaining) + + def cancel_and_wait(): + try: + requested = set() + while True: + queue = get_json(url + '/queue', timeout=timeout()) + running = queue['queue_running'] + targets = [(row[1], str(node)) for row in running + for node, value in row[2].items() + if value.get('class_type') == 'FreeVideoGenerate'] + fresh = [target for target in targets if target not in requested] + # Done once nothing new is generating and every cancelled request has + # ended. An idle queue (or another node type) needs no wait at all. + if not fresh and not any(target in requested for target in targets): + return + for prompt_id, node_id in fresh: + request = Request(url + '/freevideo/cancel', + data=json.dumps(dict(prompt_id=prompt_id, node_id=node_id)).encode(), + headers={'Content-Type': 'application/json'}, method='POST') + with build_opener(ProxyHandler({})).open(request, timeout=timeout()) as reply: + reply.read(4096) + requested.add((prompt_id, node_id)) + stopped.wait(min(SHUTDOWN_POLL_SECONDS, timeout())) + except Exception: + # Missing route, malformed queue, timeout and HTTP errors all fall + # back to the same process-tree stop. Shutdown never depends on it. + return + + worker = threading.Thread(target=cancel_and_wait, name='FreeVideo shutdown cancellation', daemon=True) + worker.start() + worker.join(max(0., deadline - time.monotonic())) + stopped.set() class OlderLauncher(ValueError): @@ -41,12 +98,13 @@ def __init__(self, launcher_label, installed_label): f'({installed_label}); nothing was changed.') +@plain_errors def check_source_release(source, installed): """Reject only a provably older source, before any installation writes.""" from .release_notes import label, older if not isinstance(installed, (str, Path)) or not installed: return - source, installed = Path(source).resolve(), Path(installed).resolve() + source, installed = resolved(source), resolved(installed) if not installed.is_dir() or source == installed: return candidate, current = source_release(source), source_release(installed) @@ -54,9 +112,10 @@ def check_source_release(source, installed): raise OlderLauncher(label(candidate), label(current)) +@plain_errors def check_installed_sources(source, root, engine): """Either the node receipt or machine record can identify the installed code.""" - for receipt in (node_target(root) / 'freevideo-launcher.json', Path(engine) / 'machine.json'): + for receipt in (io_path(node_target(root)) / 'freevideo-launcher.json', io_path(engine) / 'machine.json'): try: value = json.loads(receipt.read_text(encoding='utf-8')) except (OSError, ValueError): @@ -65,10 +124,11 @@ def check_installed_sources(source, root, engine): check_source_release(source, value.get('source')) +@plain_errors def disk_review(plan, engine, comfy, *, separate, new_comfy): """Add frontend costs to the right volume, including cross-drive installs.""" frontend = plan.get('frontend') - if frontend and frontend['root'] == str(Path(comfy).resolve()) and frontend['separate'] == separate: + if frontend and frontend['root'] == plain(resolved(comfy)) and frontend['separate'] == separate: from .install_disk import errors disks = [dict(disk, paths=list(disk['paths']), free_bytes=disk_space.free_bytes(disk['paths'][0])) for disk in plan.get('disks', [])] @@ -83,7 +143,7 @@ def disk_review(plan, engine, comfy, *, separate, new_comfy): if not amount: continue parent = disk_space.existing(path) - disk = disks.setdefault(disk_space.disk_key(parent), dict(paths=[str(parent)], needed_bytes=0)) + disk = disks.setdefault(disk_space.disk_key(parent), dict(paths=[plain(parent)], needed_bytes=0)) disk['free_bytes'] = disk_space.free_bytes(parent) disk['needed_bytes'] += amount extra += amount @@ -92,57 +152,63 @@ def disk_review(plan, engine, comfy, *, separate, new_comfy): def layout(path): - selected = Path(path).expanduser().resolve() - root = selected / 'ComfyUI' if (selected / 'ComfyUI' / 'main.py').is_file() else selected - if not (root / 'main.py').is_file() or not (root / 'folder_paths.py').is_file(): - raise ValueError('Select the ComfyUI folder, or the portable folder containing ComfyUI.') - if not (root / 'comfy_api' / 'latest').is_dir(): - raise ValueError('This ComfyUI is too old for native VIDEO / V3 nodes. Update ComfyUI first; its files have not been changed.') - candidates = [] - for parent in (root, root.parent): - for name in ('python_embeded', 'python_embedded', '.venv', 'venv', 'env', 'python'): - directory = parent / name - candidates.extend([directory / 'python.exe', directory / 'Scripts/python.exe', directory / 'bin/python']) - python = next((p.resolve() for p in candidates if p.is_file()), None) - return dict(root=str(root), python=str(python) if python else None, - portable=bool(python and python.parent.name in ('python_embeded', 'python_embedded'))) + try: + selected = resolved(Path(path).expanduser()) + root = selected / 'ComfyUI' if (selected / 'ComfyUI' / 'main.py').is_file() else selected + if not (root / 'main.py').is_file() or not (root / 'folder_paths.py').is_file(): + raise ValueError('Select the ComfyUI folder, or the portable folder containing ComfyUI.') + if not (root / 'comfy_api' / 'latest').is_dir(): + raise ValueError('This ComfyUI is too old for native VIDEO / V3 nodes. Update ComfyUI first; its files have not been changed.') + candidates = [] + for parent in (root, root.parent): + for name in ('python_embeded', 'python_embedded', '.venv', 'venv', 'env', 'python'): + directory = parent / name + candidates.extend([directory / 'python.exe', directory / 'Scripts/python.exe', directory / 'bin/python']) + python = next((p.resolve() for p in candidates if p.is_file()), None) + return dict(root=plain(root), python=plain(python) if python else None, + portable=bool(python and python.parent.name in ('python_embeded', 'python_embedded'))) + except (OSError, ValueError) as error: + plain_error(error) + raise def host_repair_pending(python): """A host repair started in this environment and has not finished (comfy_host's marker).""" - return bool(python) and (Path(python).parent.parent / 'freevideo-host-repair.json').exists() + return bool(python) and (io_path(python).parent.parent / 'freevideo-host-repair.json').exists() def managed_python(engine, comfy): """Find our previously completed frontend without requiring its full disk budget again.""" + engine, comfy = io_path(engine), io_path(comfy) try: record = json.loads((engine / 'launcher/comfy-host.json').read_text(encoding='utf-8')) identity = record['identity'] - environment, python = Path(record['environment']), Path(record['python']) + environment, python = io_path(record['environment']), io_path(record['python']) machine = json.loads((engine / 'machine.json').read_text(encoding='utf-8')) - if (identity['comfy'] != str(comfy) or identity['engine_python'] != machine['python'] + if (identity['comfy'] != plain(comfy) or identity['engine_python'] != machine['python'] or identity['requirements'] != hashlib.sha256((comfy / 'requirements.txt').read_bytes()).hexdigest() or environment.resolve().parent != (engine / 'envs').resolve() or python != environment / ('Scripts/python.exe' if os.name == 'nt' else 'bin/python') or json.loads((environment / 'freevideo-host.json').read_text(encoding='utf-8')) != identity or not python.is_file()): return None - return str(python) + return plain(python) except (OSError, ValueError, KeyError, TypeError): return None +@plain_errors def managed_frontend(selected): """Identify our frontend even after reopening makes `separate` false. Do not resolve the executable: venv Python is a symlink on Linux, but its environment and installed packages still belong to the venv directory. """ - python = Path(os.path.abspath(selected['python'])) + python = io_path(os.path.abspath(selected['python'])) environment = python.parent.parent return (python.parent.name in ('Scripts', 'bin') and environment.name.startswith('comfyui-') - and environment.parent.resolve() == (Path(selected['engine']) / 'envs').resolve()) + and environment.parent.resolve() == (io_path(selected['engine']) / 'envs').resolve()) # Run with the host's Python, without importing Torch or creating __pycache__. @@ -179,7 +245,7 @@ def probe_host(descriptor, python=None): try: from .windows_ux import external_python with external_python(): - result = subprocess.run([str(python), '-I', '-B', '-c', HOST_PROBE, descriptor['root']], + result = execution_paths.run([str(python), '-I', '-B', '-c', HOST_PROBE, descriptor['root']], cwd=descriptor['root'], env=env, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=45, **({'creationflags': subprocess.CREATE_NO_WINDOW} if os.name == 'nt' else {})) @@ -267,20 +333,21 @@ def next_free_address(url, count=12): return None +@plain_errors def launcher_comfy(selected, info): """Whether the launcher downloaded this ComfyUI and runs it in its own environment, and the server at its address is not this ComfyUI. Such a server belongs to someone else, so this ComfyUI can move to another port instead of asking for that server to be closed.""" - root = Path(selected['root']) + root = io_path(selected['root']) if not (selected.get('python') and managed_frontend(selected) and (root / RECEIPT).is_file()): return False if info['status'] == 'freevideo': - return Path(info['comfy_root']).resolve() != root.resolve() + return resolved(info['comfy_root']) != root.resolve() if info['status'] == 'offline': return True main = info.get('main') or '' if Path(main).is_absolute(): - return Path(main).resolve() != (root / 'main.py').resolve() + return resolved(main) != (root / 'main.py').resolve() try: # ComfyUI answers {kind: [absolute folders]}; its custom_nodes folder names its installation. folders = get_json(selected['url'] + '/internal/folder_paths', timeout=1) @@ -288,7 +355,7 @@ def launcher_comfy(selected, info): if (not isinstance(nodes, list) or not nodes or not all(isinstance(path, str) and Path(path).is_absolute() for path in nodes)): return False - return all(Path(path).resolve() != (root / 'custom_nodes').resolve() for path in nodes) + return all(resolved(path) != (root / 'custom_nodes').resolve() for path in nodes) except (OSError, ValueError): return False @@ -302,14 +369,15 @@ def queue_busy(url): return False +@plain_errors def matches_server(info, root, engine, source): return (info.get('status') == 'freevideo' - and all(info.get(k) and Path(info[k]).resolve() == Path(p).resolve() + and all(info.get(k) and resolved(info[k]) == resolved(p) for k, p in (('comfy_root', root), ('engine_root', engine), ('source', source)))) def node_target(root): - parent = Path(root) / 'custom_nodes' + parent = io_path(root) / 'custom_nodes' found = [] if parent.is_dir(): for path in parent.iterdir(): @@ -323,7 +391,7 @@ def node_target(root): target = found[0] if found else parent / 'FreeVideo' if target.exists() and not found and any(target.iterdir()): raise ValueError('custom_nodes/FreeVideo already contains unrelated files. Choose another ComfyUI or rename that folder first.') - return target + return Path(plain(target)) GIT_IGNORE = ('# FreeVideo engine: environments, models and caches, not part of any git checkout.\n' @@ -338,7 +406,7 @@ def shield_engine(engine): git add -A, git clean) would copy it into git or move it away. Only the launcher's engine folders get the marker, never a source checkout. """ - engine = Path(engine) + engine = io_path(engine) marker = engine / '.gitignore' if (not engine.is_dir() or os.path.lexists(marker) or (engine / '.git').exists() or (engine / 'freevideo_engine').is_dir()): @@ -354,13 +422,13 @@ def checkout_entry(data, folder): """A FreeVideo checkout's own entry point, put back by git, a node updater or a copied repository.""" text = data.decode('utf-8', 'replace') return ('async def comfy_entrypoint' in text and 'from .freevideo_engine.' in text - and (Path(folder) / 'freevideo_engine' / 'comfy_nodes.py').is_file()) + and (io_path(folder) / 'freevideo_engine' / 'comfy_nodes.py').is_file()) def deploy(root, source, engine): """Update only our entry point; preserve source edits, libraries and templates.""" - root, source, engine = map(lambda p: Path(p).resolve(), (root, source, engine)) - target = node_target(root) + root, source, engine = map(resolved, (root, source, engine)) + target = io_path(node_target(root)) receipt = target / 'freevideo-launcher.json' previous = json.loads(receipt.read_text(encoding='utf-8')) if receipt.is_file() else {} check_source_release(source, previous.get('source')) @@ -370,13 +438,15 @@ def deploy(root, source, engine): old = entry.read_bytes() if entry.is_file() else None if (old and previous.get('entry_sha256') and hashlib.sha256(old).hexdigest() != previous['entry_sha256'] and not checkout_entry(old, target)): - raise ValueError(f'The managed FreeVideo entry point ({entry}) was edited. Changes are retained; ' + raise ValueError(f'The managed FreeVideo entry point ({plain(entry)}) was edited. Changes are retained; ' 'undo them or delete that file before updating.') # The small loader may be replaced; all original checkout files remain intact. code = ("# Installed by FreeVideo launcher. Original entry points are retained in the engine launcher/backups folder.\n" "import importlib.util as _util\nimport json as _json\nfrom pathlib import Path as _Path\nimport sys as _sys\n" "_record = _json.loads((_Path(__file__).parent / 'freevideo-launcher.json').read_text(encoding='utf-8'))\n" "_source = _Path(_record['source'])\n" + # The receipt keeps the real folder; beyond the path limit the source is read from the short root. + + launch_view.entry_lines(launch_view.for_engine(engine)) + "_spec = _util.spec_from_file_location(__name__ + '._engine', _source / '__init__.py', submodule_search_locations=[str(_source)])\n" "_engine = _util.module_from_spec(_spec)\n_sys.modules[_spec.name] = _engine\n_spec.loader.exec_module(_engine)\n" "WEB_DIRECTORY = str(_source / 'web')\ncomfy_entrypoint = _engine.comfy_entrypoint\n").encode() @@ -385,8 +455,8 @@ def deploy(root, source, engine): backup.parent.mkdir(parents=True, exist_ok=True) if not backup.exists(): backup.write_bytes(old) - save(source / 'comfyui.json', dict(installation=str(engine))) - save(receipt, dict(protocol=PROTOCOL, source=str(source), engine_root=str(engine), entry_sha256=hashlib.sha256(code).hexdigest())) + save(source / 'comfyui.json', dict(installation=plain(engine))) + save(receipt, dict(protocol=PROTOCOL, source=plain(source), engine_root=plain(engine), entry_sha256=hashlib.sha256(code).hexdigest())) temporary = entry.with_suffix('.py.launcher-tmp') temporary.write_bytes(code) os.replace(temporary, entry) @@ -400,8 +470,8 @@ def deploy(root, source, engine): file = parent / ('FreeVideo-All-in-One-' + digest + '.json') if not file.exists(): file.write_bytes(template) - saved.append(str(file)) - return dict(node=str(target), workflows=saved) + saved.append(plain(file)) + return dict(node=plain(target), workflows=saved) class LauncherRunner(SetupRunner): @@ -422,6 +492,7 @@ def __init__(self, source, *, child_environment=None): self.thread = None self.cancelled = threading.Event() self.server = None + self.server_url = None self._server_lock = threading.RLock() self._closed = False self.server_log = None @@ -460,6 +531,7 @@ def work(): self._old_versions_thread = threading.Thread(target=work, name='freevideo-old-versions', daemon=True) self._old_versions_thread.start() + @plain_errors def _prepare_frontend(self, selected, *, download=False, repair=False): """Bring the separate ComfyUI environment up to date with this ComfyUI. @@ -485,16 +557,23 @@ def _prepare_frontend(self, selected, *, download=False, repair=False): arguments.append('--repair') self.setup.runner.start('comfy-host', selected['engine'], arguments) self._wait_setup() - descriptor = json.loads((Path(selected['engine']) / 'launcher' / 'comfy-host.json').read_text(encoding='utf-8')) - selected['python'] = descriptor['python'] + descriptor = json.loads((io_path(selected['engine']) / 'launcher' / 'comfy-host.json').read_text(encoding='utf-8')) + selected['python'] = plain(descriptor['python']) def owns_server(self): return self.server is not None and self.server.poll() is None - def stop_owned_server(self): + def stop_owned_server(self, *, grace=5): with self._server_lock: if self.server is not None: - processes.stop(self.server, grace=5) + if self.server.poll() is None and self.server_url: + try: + cancel_running_generation(self.server_url) + except Exception: + # Even failure to start the bounded helper must not + # prevent the original process-tree cleanup. + pass + processes.stop(self.server, grace=grace) self.server = None def terminal_sources(self): @@ -516,22 +595,26 @@ def restore_terminal(self, engine): # authority over an existing server. Never scan entire run directories. self.server_log = None try: - root = Path(engine) / 'launcher' + root = io_path(engine) / 'launcher' value = json.loads((root / 'comfy-console.json').read_text(encoding='utf-8')) path = (root / value['log']).resolve() path.relative_to((root / 'comfy-runs').resolve()) if path.is_file(): - self.server_log = path + self.server_log = Path(plain(path)) except (OSError, ValueError, KeyError, TypeError): pass def attach_console(self, info): if info.get('console_log'): - root = Path(self.selection['engine']) / 'launcher' - path = Path(info['console_log']).resolve() - path.relative_to((root / 'comfy-runs').resolve()) - self.server_log = path - save(root / 'comfy-console.json', dict(log=str(path.relative_to(root.resolve())))) + try: + root = io_path(self.selection['engine']) / 'launcher' + path = resolved(info['console_log']) + path.relative_to((root / 'comfy-runs').resolve()) + self.server_log = Path(plain(path)) + save(root / 'comfy-console.json', dict(log=str(path.relative_to(root.resolve())))) + except (OSError, ValueError) as error: + plain_error(error) + raise elif info.get('console_error'): self.state = dict(self.state, error=info['console_error']) @@ -550,21 +633,21 @@ def restore(self, values): return False selected_engine = values.get('engine') or str(Path(layout(selected_root)['root']) / 'FreeVideo-engine') descriptor = layout(selected_root) - engine = Path(selected_engine).expanduser().resolve() - receipt_path = node_target(descriptor['root']) / 'freevideo-launcher.json' + engine = Path(plain(resolved(Path(selected_engine).expanduser()))) + receipt_path = io_path(node_target(descriptor['root'])) / 'freevideo-launcher.json' receipt = json.loads(receipt_path.read_text(encoding='utf-8')) - source = Path(receipt['source']).resolve() - if (receipt.get('protocol') != PROTOCOL or Path(receipt['engine_root']).resolve() != engine - or not (source / 'freevideo_engine/comfy_nodes.py').is_file() - or not (source / '__init__.py').is_file() + source = Path(plain(resolved(receipt['source']))) + if (receipt.get('protocol') != PROTOCOL or Path(plain(resolved(receipt['engine_root']))) != engine + or not io_path(source / 'freevideo_engine/comfy_nodes.py').is_file() + or not io_path(source / '__init__.py').is_file() or hashlib.sha256((receipt_path.parent / '__init__.py').read_bytes()).hexdigest() != receipt.get('entry_sha256')): return False installation(source, {'FREEVIDEO_HOME': str(engine)}) python = (record.get('python') or values.get('python') or managed_python(engine, Path(descriptor['root'])) or descriptor.get('python')) - if not python or not Path(python).is_file(): + if not python or not io_path(python).is_file(): return False - self.selection = dict(descriptor, source=str(source), engine=str(engine), python=str(python), + 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, separate=record.get('separate', values.get('separate', False))) from .desktop_runtime import matching_source @@ -638,10 +721,7 @@ def close(self): try: self.cancel() finally: - with self._server_lock: - if self.server is not None: - processes.stop(self.server, grace=3) - self.server = None + self.stop_owned_server(grace=3) def _wait_setup(self): # The Setup service and its runner stay the single source of truth. @@ -662,6 +742,7 @@ def _wait_setup(self): return row self.cancelled.wait(.1) + @plain_errors def _inspect(self, values): self.selection = None self.sections, self.section = [], None @@ -669,7 +750,7 @@ def _inspect(self, values): fresh = values.get('new_comfy', False) descriptor = new_layout(values.get('destination', '')) if fresh else layout(values['comfy']) default_parent = Path(descriptor['root']).parent if fresh else Path(descriptor['root']) - engine = Path(values.get('engine') or default_parent / 'FreeVideo-engine').expanduser().resolve() + engine = Path(plain(resolved(Path(values.get('engine') or default_parent / 'FreeVideo-engine').expanduser()))) check_installed_sources(self.source, descriptor['root'], engine) if fresh and Path(descriptor['root']) in engine.parents: raise ValueError('Place the engine beside the new ComfyUI folder, so ComfyUI can be downloaded atomically.') @@ -710,7 +791,7 @@ def _inspect(self, values): extra = extra + ([values['models']] if values.get('models') else []) # A saved folder that no longer exists (for example a removed offline # package) has nothing to reuse; it must not stop the installation. - extra = [p for p in dict.fromkeys(extra) if p.strip() and Path(p).expanduser().is_dir()] + extra = [p for p in dict.fromkeys(extra) if p.strip() and io_path(Path(p).expanduser()).is_dir()] self.setup.inspect(dict(root=str(engine), extra_libraries=extra, copy=False, sampling_caches=bool(values.get('sampling_caches')), prepared_format=values.get('prepared_format'), frontend=dict(root=descriptor['root'], separate=descriptor['separate'], download=fresh, @@ -718,7 +799,7 @@ def _inspect(self, values): row = self._wait_setup() self.state = dict(self.state, plan=row['plan']) extra_disk, disks, disk_errors = disk_review(self.state.get('plan', {}), engine, descriptor['root'], - separate=descriptor['separate'], new_comfy=fresh and not Path(descriptor['root']).exists()) + separate=descriptor['separate'], new_comfy=fresh and not io_path(descriptor['root']).exists()) errors = list(self.state.get('plan', {}).get('errors', [])) self.state = dict(self.state, status='review', extra_disk_bytes=extra_disk, disks=disks, errors=list(dict.fromkeys(errors + disk_errors))) @@ -750,7 +831,7 @@ def _install(self, accepted): raise RuntimeError('Stopped; files retained') # The engine's Python imports the source its editable install names (commands, direct runs): # move it to this source too. Only a real switch stops our idle ComfyUI first. - machine = json.loads((Path(selected['engine']) / 'machine.json').read_text(encoding='utf-8')) + machine = json.loads((io_path(selected['engine']) / 'machine.json').read_text(encoding='utf-8')) 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. @@ -800,6 +881,7 @@ def _shortcut(self, previous_status='ready'): self.ensure_shortcut() self.state = dict(self.state, status='open' if previous_status == 'open' else 'ready') + @plain_errors def _connect(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']: @@ -872,11 +954,11 @@ def _connect(self): parsed = urlsplit(url) continue 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 Path(selected['python']).is_file(): + 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.') - directory = Path(selected['engine']) / 'launcher' / 'comfy-runs' / (time.strftime('%Y%m%dT%H%M%S') + '-' + secrets.token_hex(4)) + directory = io_path(selected['engine']) / 'launcher' / 'comfy-runs' / (time.strftime('%Y%m%dT%H%M%S') + '-' + secrets.token_hex(4)) directory.mkdir(parents=True) - self.server_log = directory / 'comfy.log' + self.server_log = Path(plain(directory / 'comfy.log')) save(Path(selected['engine']) / 'launcher/comfy-console.json', dict(log=str(self.server_log.relative_to(Path(selected['engine']) / 'launcher')))) env = (self.child_environment() if self.child_environment is not None else @@ -901,8 +983,19 @@ def _connect(self): custom_nodes='FreeVideo' if managed else 'all') if selected.get('portable') and not selected['separate']: command.append('--windows-standalone-build') + view = launch_view.for_engine(selected['engine']) + started = command + if view.used: + # ComfyUI otherwise finds its folders from its own resolved file, + # which leads back to the long path. + command += ['--base-directory', selected['root']] + from . import runtime_alias + # Its main imports a library that loads by a resolved path before any FreeVideo node. + started = (execution_paths.engine_first(command, selected['source']) + if runtime_alias.system_long_paths() else command) + env = launch_view.environment_view(env, view) from .windows_ux import external_python - with self._server_lock, external_python(), self.server_log.open('wb') as log: + with self._server_lock, external_python(), io_path(self.server_log).open('wb') as log: if self._closed or self.cancelled.is_set(): raise RuntimeError('ComfyUI startup cancelled') if managed: @@ -912,16 +1005,18 @@ def _connect(self): # Keep durable embedded output, but own the whole tree: Windows # Job / Linux supervisor also reclaim independently grouped model # workers when the launcher dies without running Python cleanup. - server = processes.popen(command, cwd=selected['root'], env=env, stdin=subprocess.DEVNULL, + server = processes.popen(started, cwd=selected['root'], env=env, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, start_new_session=True, supervise=True) self.server = server + self.server_url = 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=str(directory))) + 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: if self.cancelled.is_set(): with self._server_lock: - processes.stop(server, grace=3) + if self.server is server: + self.stop_owned_server(grace=3) raise RuntimeError('ComfyUI startup cancelled') if server.poll() is not None: from .failure_details import startup_failure diff --git a/freevideo_engine/comfy_library.py b/freevideo_engine/comfy_library.py index a081015b..ae3881fa 100644 --- a/freevideo_engine/comfy_library.py +++ b/freevideo_engine/comfy_library.py @@ -12,6 +12,9 @@ from pathlib import Path from .comfy_assets import output_summary +from .runtime_alias import is_junction, checked_real_path, AliasUnavailable +from .long_paths import io_path, plain +from .system import windows _ID = re.compile(r'\d{4}-\d{2}-\d{2}/[0-9a-f]{32}') @@ -159,6 +162,7 @@ def _plain(path): def _foreign(folder): """Names in a run folder that FreeVideo did not write, or that are links.""" + folder = io_path(checked_real_path(folder)) kept = [] for entry in sorted(folder.iterdir()): if not _plain(entry): @@ -171,6 +175,7 @@ def _foreign(folder): for name in folders + files: if not _plain(Path(parent) / name): kept.append((Path(parent) / name).relative_to(folder).as_posix()) + folders[:] = [name for name in folders if not is_junction(Path(parent) / name)] elif not _OWNED_FILE.fullmatch(entry.name): kept.append(entry.name) return kept @@ -178,27 +183,57 @@ def _foreign(folder): def _remove(folder): """Remove a verified folder bottom-up; return bytes freed and paths left behind.""" + try: + folder = io_path(checked_real_path(folder)) + except AliasUnavailable: + return 0, [plain(folder)] freed, left = 0, [] - for parent, folders, files in os.walk(folder, topdown=False): + if is_junction(folder): + return 0, [plain(folder)] + if windows(): + # Bottom-up os.walk cannot prune before entering a Windows junction. + # Collect a pruned listing, then keep os.walk's original bottom-up + # sibling order when removing ordinary rows. + rows = {} + for parent, folders, files in os.walk(folder): + linked = [name for name in folders if is_junction(Path(parent) / name)] + left.extend(plain(Path(parent) / name) for name in linked) + folders[:] = [name for name in folders if name not in linked] + rows[Path(parent)] = (parent, folders[:], files) + ordered, pending = [], [(Path(folder), False)] + while pending: + current, visited = pending.pop() + row = rows.get(current) + if row is None: + continue + if visited: + ordered.append(row) + else: + pending.append((current, True)) + pending.extend((current / name, False) for name in reversed(row[1])) + walk = ordered + else: + walk = os.walk(folder, topdown=False) + for parent, folders, files in walk: for name in files: path = Path(parent) / name try: if not _plain(path): - left.append(str(path)); continue + left.append(plain(path)); continue size = path.stat().st_size path.unlink() freed += size except OSError: - left.append(str(path)) + left.append(plain(path)) for name in folders: try: (Path(parent) / name).rmdir() except OSError: - left.append(str(Path(parent) / name)) + left.append(plain(Path(parent) / name)) try: Path(folder).rmdir() except OSError: - left.append(str(folder)) + left.append(plain(folder)) return freed, left @@ -222,9 +257,9 @@ def release_decoded(folder): or any link, is left alone, as delete_video leaves it. Paths Windows still has open are listed. """ - folder = Path(folder) - artifacts = folder / _OWNED_FOLDER try: + folder = io_path(checked_real_path(folder)) + artifacts = folder / _OWNED_FOLDER if not _plain(folder) or _foreign(folder): return dict(freed_bytes=0, left=[], skipped='foreign-files') if not artifacts.is_dir(): @@ -258,6 +293,7 @@ def release_decoded(folder): def release_later(output_directory): """Have the next start finish a release that Windows kept open.""" from .monitoring import save + output_directory = checked_real_path(output_directory) with _RELEASING: save(Path(output_directory).resolve() / 'FreeVideo' / _RELEASED, dict(schema=1, pending=True)) @@ -270,6 +306,10 @@ def release_earlier(output_directory): it while a file Windows kept open is left. """ from .monitoring import save + try: + output_directory = checked_real_path(output_directory) + except AliasUnavailable: + return None root = Path(output_directory).resolve() / 'FreeVideo' marker = root / _RELEASED if not root.is_dir(): @@ -319,6 +359,10 @@ def delete_video(output_directory, identity): The folder is first renamed into FreeVideo/.deleted, so it leaves the library at once, or, when Windows still has a file open, nothing changes. """ + try: + output_directory = checked_real_path(output_directory) + except AliasUnavailable as error: + raise DeleteRefused('not-freevideo') from error path = _video(output_directory, identity) _report(path) root = Path(output_directory).resolve() / 'FreeVideo' diff --git a/freevideo_engine/comfy_service.py b/freevideo_engine/comfy_service.py index 71cabb59..69a3176f 100644 --- a/freevideo_engine/comfy_service.py +++ b/freevideo_engine/comfy_service.py @@ -25,6 +25,7 @@ import threading import time +from . import execution_paths from .monitoring import save from . import engine_binding @@ -124,7 +125,7 @@ def install(root, source, machine, port, out=say): env = dict(os.environ, FREEVIDEO_HOME=str(root), PYTHONUTF8='1', PYTHONIOENCODING='utf-8') for name in ('PYTHONPATH', 'PYTHONHOME', 'VIRTUAL_ENV', 'FREEVIDEO_UI_EVENTS'): env.pop(name, None) - if subprocess.run(command, cwd=str(root), env=env, stdin=subprocess.DEVNULL).returncode: + if execution_paths.run(command, cwd=str(root), env=env, stdin=subprocess.DEVNULL).returncode: raise RuntimeError('ComfyUI could not be installed. Run the same command again to continue; ' 'downloaded files are kept.') python = managed_python(Path(root), comfy) @@ -385,7 +386,7 @@ def spawn(root, *, port, listen='127.0.0.1', tls=None): with (folder(root) / 'service.log').open('ab') as output: output.write(('\n[%s] freevideo service starting\n' % time.strftime('%Y-%m-%d %H:%M:%S')).encode()) output.flush() - return subprocess.Popen(command, cwd=str(root), env=env, stdin=subprocess.DEVNULL, stdout=output, + return execution_paths.popen(command, cwd=str(root), env=env, stdin=subprocess.DEVNULL, stdout=output, stderr=subprocess.STDOUT, start_new_session=True, close_fds=True) diff --git a/freevideo_engine/comfy_setup.py b/freevideo_engine/comfy_setup.py index 46305604..9e881cc8 100644 --- a/freevideo_engine/comfy_setup.py +++ b/freevideo_engine/comfy_setup.py @@ -13,6 +13,7 @@ import threading import uuid +from .long_paths import io_path, resolved, plain, plain_text, plain_errors from .comfy_bridge import installation, installation_root, source_root from .environments import ENVIRONMENTS from .desktop_runtime import Runner, preflight_json @@ -50,9 +51,9 @@ def discover_libraries(folder_paths, environ=None): existing = [] for value in candidates: try: - path = Path(value).expanduser() + path = io_path(Path(value).expanduser()) if path.is_dir(): - existing.append(str(path)) + existing.append(plain(path)) except (OSError, RuntimeError): pass # A folder registered under several model types is listed once; finding @@ -126,6 +127,7 @@ def drain(self): def owned_comfy(root, host): """./setup.sh puts the ComfyUI it installs in /ComfyUI; that one is the engine's own.""" from .comfy_source import RECEIPT, specification + root, host = io_path(root), io_path(host) try: return host == root / 'ComfyUI' and json.loads((host / RECEIPT).read_text(encoding='utf-8')) == specification() except (OSError, ValueError): @@ -133,8 +135,9 @@ def owned_comfy(root, host): class Setup: + @plain_errors def __init__(self, source, folder_paths, runner_factory=SetupRunner): - self.source = Path(source).resolve() + self.source = Path(plain(resolved(source))) self.folder_paths = folder_paths self.events = Events() self.logs = self.source / '.freevideo' / 'comfy-setup' @@ -213,14 +216,15 @@ def compatibility_settings(self, value, action): raise ValueError('Unknown compatibility action') return result + @plain_errors def validate_root(self, value): if not isinstance(value, str) or not value.strip() or len(value) > 4096: raise ValueError('Choose an engine installation folder') - root = Path(value).expanduser().resolve() - host = Path(self.folder_paths.base_path).resolve() + root = resolved(Path(value).expanduser()) + host = resolved(self.folder_paths.base_path) if root == Path(root.anchor) or root == host or (root in host.parents and not owned_comfy(root, host)): raise ValueError('Use a dedicated FreeVideo folder, not the ComfyUI root or its parent') - prefix = Path(sys.prefix).resolve() + prefix = resolved(sys.prefix) # Refuse to pip into the interpreter that is running, and nothing more. # The earlier test asked whether the live interpreter lived anywhere # under `root/envs`, which is true of every working installation the @@ -235,10 +239,11 @@ def validate_root(self, value): 'running in. Launch ComfyUI from its own environment, or run ' 'setup from the FreeVideo launcher instead of this panel.') selected = os.environ.get('FREEVIDEO_HOME') - if selected and installation_root(self.source) != root: + if selected and resolved(installation_root(self.source)) != root: raise ValueError('FREEVIDEO_HOME selects another installation. Change it before starting ComfyUI.') - return root + return Path(plain(root)) + @plain_errors def inspect(self, value): self.status() if self.runner.busy: @@ -251,7 +256,7 @@ def inspect(self, value): # automatically fill the remaining places. chosen = library_roots(extra) libraries = library_roots(chosen + discover_libraries(self.folder_paths), keep_first=True) - self.logs.mkdir(parents=True, exist_ok=True) + io_path(self.logs).mkdir(parents=True, exist_ok=True) manifest = self.logs / ('libraries-' + uuid.uuid4().hex + '.json') save(manifest, dict(version=1, roots=libraries)) # A download that cannot resume (for example a Xet partial) starts again @@ -259,7 +264,7 @@ def inspect(self, value): arguments = ['setup', '--reuse-models-manifest', str(manifest), '--allow-model-restart'] frontend = value.get('frontend') if frontend: - arguments += ['--frontend-root', str(Path(frontend['root']).expanduser().resolve())] + arguments += ['--frontend-root', plain(resolved(Path(frontend['root']).expanduser()))] if frontend.get('separate'): arguments.append('--frontend-separate') if frontend.get('download'): @@ -279,10 +284,11 @@ def inspect(self, value): self.runner.start('plan', root, [*arguments, '--plan', '--json']) return self.status() + @plain_errors def bind(self, root): root = self.validate_root(root) installation(self.source, dict(os.environ, FREEVIDEO_HOME=str(root))) - config = self.source / 'comfyui.json' + config = io_path(self.source) / 'comfyui.json' value = json.loads(config.read_text(encoding='utf-8')) if config.is_file() else {} value['installation'] = str(root) save(config, value) @@ -381,6 +387,7 @@ def _release_comfy_models(self): memory.unload_all_models() memory.soft_empty_cache() + @plain_errors def status(self): for kind, value in self.events.drain(): if kind == 'started': @@ -395,7 +402,7 @@ def status(self): # Older/bootstrap failures may not emit a structured error. # Recover the complete retained output once, after exit. try: - self.state['error'] = (Path(value['log']) / 'launcher.log').read_text(encoding='utf-8', errors='replace') + self.state['error'] = plain_text((io_path(value['log']) / 'launcher.log').read_text(encoding='utf-8', errors='replace')) except OSError: self.state['error'] = value.get('output') or self.state.get('tail', '') if value['action'] == 'plan' and value['status'] in ('complete', 'failed'): @@ -410,7 +417,7 @@ def status(self): self.bind(value['root']) self.state['ready'] = True except (OSError, ValueError) as error: - self.state.update(status='failed', error=str(error)) + self.state.update(status='failed', error=plain_text(error)) return dict(self.state, **self.events.snapshot(), busy=self.runner.busy) diff --git a/freevideo_engine/comfy_source.py b/freevideo_engine/comfy_source.py index aa5f6f75..b81a38be 100644 --- a/freevideo_engine/comfy_source.py +++ b/freevideo_engine/comfy_source.py @@ -2,6 +2,7 @@ import json from pathlib import Path +from .long_paths import io_path, resolved, plain, plain_errors from . import network from .monitoring import save @@ -9,15 +10,17 @@ SOURCE_DISK_BYTES = 2**30 +@plain_errors def specification(): # The same revision already supplies the native H3 encoder. Fetch the full # application separately; never expand or modify the encoder's sparse tree. - spec = json.loads((Path(__file__).parent / 'dependencies.json').read_text(encoding='utf-8'))['encoder'] + spec = json.loads((io_path(Path(__file__).parent) / 'dependencies.json').read_text(encoding='utf-8'))['encoder'] return dict(url=spec['comfy_url'], commit=spec['comfy_commit']) +@plain_errors def validate_target(target): - target = Path(target) + target = io_path(target) if not target.exists() and not target.is_symlink(): return try: @@ -25,18 +28,19 @@ def validate_target(target): except (OSError, ValueError): owned = False if not owned or target.is_symlink(): - raise ValueError('This folder already contains ComfyUI or other files. Choose Use existing ComfyUI or another installation folder: ' + str(target)) + raise ValueError('This folder already contains ComfyUI or other files. Choose Use existing ComfyUI or another installation folder: ' + plain(target)) +@plain_errors def new_layout(directory): if not isinstance(directory, str) or not directory.strip(): raise ValueError('Choose a folder for the new ComfyUI installation') - parent = Path(directory).expanduser().resolve() + parent = resolved(Path(directory).expanduser()) if parent.exists() and not parent.is_dir(): - raise ValueError('The installation folder is a file: ' + str(parent)) + raise ValueError('The installation folder is a file: ' + plain(parent)) target = parent / 'ComfyUI' validate_target(target) - return dict(root=str(target), python=None, portable=False, separate=True, + return dict(root=plain(target), python=None, portable=False, separate=True, new_comfy=True, source_spec=specification()) diff --git a/freevideo_engine/compatibility.py b/freevideo_engine/compatibility.py index 17e1e317..c5066fcc 100644 --- a/freevideo_engine/compatibility.py +++ b/freevideo_engine/compatibility.py @@ -13,14 +13,19 @@ import sqlite3 import sys import time +from urllib.parse import quote + +from .long_paths import io_path, plain, PREFIX LEVELS = ( dict(level=0, en='Off · automatic performance', zh='关闭 · 自动性能'), dict(level=1, en='Light · the GPU handles a little less at a time', zh='轻度 · 显卡每次处理的数据减少一些', head=4), dict(level=2, en='Compatible · the GPU handles less at a time; slower', zh='兼容 · 显卡每次处理的数据更少,生成更慢', head=2), - dict(level=3, en='Strong · the least at a time, with no read-ahead; slowest', zh='加强 · 每次处理的数据最少,并且不提前读取,生成最慢', head=1), + dict(level=3, en='Strong · the GPU handles the least at a time; slowest', zh='加强 · 显卡每次处理的数据最少,生成最慢', head=1), ) +RECOVERY_SUCCESSES = 3 +SUCCESS_RECEIPTS = 64 @lru_cache(maxsize=1) @@ -31,9 +36,9 @@ def policy_revision(): also cover a git checkout whose package version has not changed. """ from . import __version__ - root = Path(__file__).parent + root = io_path(__file__).parent if getattr(sys, 'frozen', False) and getattr(sys, '_MEIPASS', None): - root = Path(sys._MEIPASS) / 'engine-source' / 'freevideo_engine' + root = io_path(Path(sys._MEIPASS) / 'engine-source' / 'freevideo_engine') digest = hashlib.sha256(__version__.encode('utf-8')) for name in ('compatibility.py', 'policy.py', 'resident_models.py', 'worker.py', 'runtime.py', 'attention.py', 'adaptive.py', 'resource_history.py'): @@ -63,6 +68,12 @@ def current_settings(value): origin = 'manual' if manual_level else 'automatic' value['notice'] = None value.pop('trigger', None) + for field in ('unknown_interruptions', 'consecutive_successes', 'recovery_disabled', + 'last_counted_interruption', 'last_counted_batch', 'last_escalation', 'recovery'): + value.pop(field, None) + value.setdefault('unknown_interruptions', 0) + value.setdefault('consecutive_successes', 0) + value.setdefault('recovery_disabled', False) value.update(origin=origin, manual_level=manual_level, policy_revision=revision) return value @@ -84,14 +95,24 @@ def unavailable(): level=0, automatic=False, notice=None, levels=LEVELS) +def _read_uri(path, *, windows=None): + path = io_path(path, windows=windows) + if str(path).startswith(PREFIX): + # WindowsPath.as_uri treats the prefix as a UNC authority. Encode the + # filename itself so SQLite receives it unchanged, still with mode=ro. + return 'file:' + quote(str(path), safe='/:') + '?mode=ro' + return path.resolve().as_uri() + '?mode=ro' + + class Store: def __init__(self, root): - self.path = Path(root) / 'compatibility.sqlite3' + self.path = Path(plain(root)) / 'compatibility.sqlite3' @contextmanager def transaction(self): - self.path.parent.mkdir(parents=True, exist_ok=True) - with closing(sqlite3.connect(str(self.path), timeout=10, isolation_level=None)) as db: + path = io_path(self.path) + path.parent.mkdir(parents=True, exist_ok=True) + with closing(sqlite3.connect(str(path), timeout=10, isolation_level=None)) as db: db.row_factory = sqlite3.Row db.execute('PRAGMA synchronous=FULL') db.execute('BEGIN IMMEDIATE') @@ -117,9 +138,9 @@ def status(self, identity, *, persist=False): key = device_key(identity) if key is None: return unavailable() - if not self.path.exists(): + if not io_path(self.path).exists(): return dict(current_settings(dict(level=0, automatic=True, notice=None)), levels=LEVELS, available=True) - with closing(sqlite3.connect(self.path.resolve().as_uri()+'?mode=ro', uri=True, timeout=10)) as db: + with closing(sqlite3.connect(_read_uri(self.path), uri=True, timeout=10)) as db: value = self.read_row(db, key) selected = current_settings(value) if persist and selected != value: @@ -137,7 +158,8 @@ def set(self, identity, level, automatic): return unavailable() with self.transaction() as db: value = dict(level=level, automatic=automatic, notice=None, origin='manual', - manual_level=level, policy_revision=policy_revision()) + manual_level=level, policy_revision=policy_revision(), + unknown_interruptions=0, consecutive_successes=0, recovery_disabled=False) self.write_row(db, key, value) return dict(value, levels=LEVELS, available=True) @@ -152,7 +174,7 @@ def acknowledge(self, identity, notice_id): self.write_row(db, key, value) return dict(value, levels=LEVELS, available=True) - def incident(self, row, reason): + def incident(self, row, reason, batch=None): # Only a launched video worker can be a load-related interrupted attempt. if row.get('purpose') != 'generation' or not row.get('worker'): return @@ -167,21 +189,105 @@ def incident(self, row, reason): revision = (row.get('details') or {}).get('compatibility_revision') incident = dict(id=row['id'], reason=reason, phase=row.get('phase'), config=row['config'], geometry=row['geometry'], diagnosis='unknown', time=time.time(), - policy_revision=revision, applicable=revision == policy_revision()) - db.execute('INSERT INTO incidents VALUES (?,?,?,?)', (row['id'], key, incident['time'], json.dumps(incident))) - # Do not re-enable a user's explicit opt-out. One incident takes the - # default to 2; another while compatible takes it to 3, at most once. + policy_revision=revision, applicable=revision == policy_revision(), + counted=False, level_changed=False) + escalate = False if value['automatic'] and incident['applicable']: + if reason == 'cuda_error': + escalate = True + incident['counted'] = True + value['unknown_interruptions'] = 0 + # One event may leave several unsettled requests: those recovered together count + # once, and so does one that was already running at the last counted interruption. + # A request is registered after recovery in the same call, often within one tick + # of a coarse system clock, so an equal time counts. + elif (reason in ('unfinished_request_after_owner_and_worker_exit', 'unknown_worker_exit') + and row['started'] >= value.get('last_counted_interruption', 0) + and (batch is None or batch != value.get('last_counted_batch'))): + incident['counted'] = True + value['unknown_interruptions'] += 1 + escalate = value['unknown_interruptions'] >= 2 + if escalate: + value['unknown_interruptions'] = 0 + if incident['counted']: + value['last_counted_interruption'] = incident['time'] + value['last_counted_batch'] = batch + value['consecutive_successes'] = 0 + if escalate: + previous = value['level'] value['level'] = min(3, max(2, value['level'] + 1)) + incident['level_changed'] = value['level'] != previous value['origin'] = 'automatic' + value['last_escalation'] = incident['time'] + if value.get('recovery'): + value['recovery_disabled'] = True value.pop('reset', None) value['notice'] = dict(id=row['id'], level=value['level'], reason=reason, phase=row.get('phase'), diagnosis='unknown', policy_revision=revision) value['trigger'] = dict(value['notice']) + db.execute('INSERT INTO incidents VALUES (?,?,?,?)', (row['id'], key, incident['time'], json.dumps(incident))) self.write_row(db, key, value) db.execute('DELETE FROM incidents WHERE device=? AND id NOT IN ' '(SELECT id FROM incidents WHERE device=? ORDER BY time DESC LIMIT 64)', (key, key)) + def success(self, row): + """Account for one settled generation; the caller validates its outcome.""" + if row.get('purpose') != 'generation' or not row.get('worker'): + return + key = device_key(row.get('identity')) + if key is None or not self.path.exists(): + # No state was ever stored here: no streak to clear, no level to restore. + return + with self.transaction() as db: + # History and settings are separate databases. Retain a receipt so + # retrying after a history commit failure cannot count success twice. + if db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='successes'").fetchone(): + receipt = db.execute('SELECT value FROM successes WHERE id=?', (row['id'],)).fetchone() + if receipt: + return json.loads(receipt[0]) + value = current_settings(self.read_row(db, key)) + revision = (row.get('details') or {}).get('compatibility_revision') + if (not value['automatic'] or revision != policy_revision() + or row['started'] < value.get('last_escalation', 0)): + return + restoring = (value['origin'] == 'automatic' and value['level'] > 0 + and not value['recovery_disabled']) + if not value['unknown_interruptions'] and not restoring: + # The usual case: a success changes nothing, so nothing is written. + return + value['unknown_interruptions'] = 0 + recovery = None + if restoring: + value['consecutive_successes'] += 1 + if value['consecutive_successes'] >= RECOVERY_SUCCESSES: + previous = value['level'] + value['level'] = max(value['manual_level'], 2 if previous == 3 else 0) + if value['level'] < previous: + recovery = dict(id=row['id'], reason='consecutive_successes', previous_level=previous, + level=value['level'], successes=RECOVERY_SUCCESSES, + policy_revision=revision, time=time.time()) + value['recovery'] = recovery + if value['manual_level'] and value['level'] == value['manual_level']: + # Back on the level the user chose: it is their setting again. + value['origin'] = 'manual' + value['consecutive_successes'] = 0 + if not value['level']: + value['notice'] = None + elif value.get('notice'): + value['notice']['level'] = value['level'] + result = dict(unknown_interruptions=value['unknown_interruptions'], + consecutive_successes=value['consecutive_successes'], + recovery_successes_required=RECOVERY_SUCCESSES, + recovery_disabled=value['recovery_disabled']) + if recovery: + result['recovery'] = recovery + self.write_row(db, key, value) + db.execute('CREATE TABLE IF NOT EXISTS successes (id TEXT PRIMARY KEY, value TEXT NOT NULL)') + db.execute('INSERT INTO successes VALUES (?,?)', (row['id'], json.dumps(result))) + db.execute('DELETE FROM successes WHERE rowid NOT IN ' + '(SELECT rowid FROM successes ORDER BY rowid DESC LIMIT ?)', (SUCCESS_RECEIPTS,)) + return result + def apply(profile, state): """Cap work grouping, never the request or backend; settings are explicit consent.""" @@ -210,7 +316,11 @@ def apply(profile, state): reason='User compatibility setting; smaller work groups and optional serialized transfers. ' 'No power-spike or crash-prevention guarantee; geometry, steps and backend unchanged.', numerical_class='chunking-may-change-floating-point-results' if changes else 'unchanged') - for field in ('origin', 'policy_revision', 'reset', 'notice', 'trigger'): + decision.update(unknown_interruptions=state.get('unknown_interruptions', 0), + consecutive_successes=state.get('consecutive_successes', 0), + recovery_successes_required=RECOVERY_SUCCESSES, + recovery_disabled=state.get('recovery_disabled', False)) + for field in ('origin', 'policy_revision', 'reset', 'notice', 'trigger', 'recovery'): if field in state: decision[field] = copy.deepcopy(state[field]) if state.get('origin') == 'automatic' and level: @@ -223,7 +333,7 @@ def apply(profile, state): def installed_identity(root): try: - machine = json.loads((Path(root)/'machine.json').read_text(encoding='utf-8')) + machine = json.loads((io_path(root)/'machine.json').read_text(encoding='utf-8')) except (OSError, ValueError): # Setup may not yet have written this optional settings metadata. return {} @@ -237,7 +347,7 @@ def installed_identity(root): def check_installation(root): """Called at app startup, without querying or initializing a GPU.""" - root = Path(root) + root = io_path(root) identity = installed_identity(root) if device_key(identity) is None: return unavailable() diff --git a/freevideo_engine/compatibility_copy.py b/freevideo_engine/compatibility_copy.py new file mode 100644 index 00000000..40eebeaf --- /dev/null +++ b/freevideo_engine/compatibility_copy.py @@ -0,0 +1,10 @@ +"""Startup wording shared by the native launchers.""" +from .compatibility import RECOVERY_SUCCESSES + + +def notice_text(state, zh=False): + if state.get('recovery_disabled'): + return ('为了稳定,生成已自动放慢;可在设置里调整。' if zh else + 'Generation is slowed automatically for stability; you can change this in Settings.') + return (f'为了稳定,生成已自动放慢;连续完成 {RECOVERY_SUCCESSES} 次后会加快一些,也可在设置里调整。' if zh else + f'Generation is slowed automatically for stability; it speeds up after {RECOVERY_SUCCESSES} successful generations in a row, or you can change this in Settings.') diff --git a/freevideo_engine/compatibility_ui.py b/freevideo_engine/compatibility_ui.py index 45e1b9b0..afb4d2dc 100644 --- a/freevideo_engine/compatibility_ui.py +++ b/freevideo_engine/compatibility_ui.py @@ -1,26 +1,24 @@ """Small native compatibility controls shared by the EXE and portable launcher.""" from pathlib import Path +from .long_paths import io_path, plain_text, plain_errors from .compatibility import Store, installed_identity, check_installation, LEVELS +@plain_errors def notify(window, root, zh=True): from tkinter import messagebox root = Path(root) - if not (root/'machine.json').is_file(): + if not (io_path(root)/'machine.json').is_file(): return try: value = check_installation(root) if value.get('notice'): - level = value['level'] - messagebox.showinfo('FreeVideo', - ('上次生成未正常结束,已自动开启兼容性设置(第 %d 档)。\n' - '可在“兼容性设置”中调节或关闭。分辨率、时长和步数保持不变。' % level) if zh else - ('The previous generation ended unexpectedly. Compatibility level %d is now enabled.\n' - 'You can adjust or disable it in Compatibility settings. Resolution, duration and steps are unchanged.' % level), parent=window) + from .compatibility_copy import notice_text + messagebox.showinfo('FreeVideo', notice_text(value, zh), parent=window) Store(root).acknowledge(installed_identity(root), value['notice']['id']) except Exception as error: - messagebox.showerror('FreeVideo', str(error), parent=window) + messagebox.showerror('FreeVideo', plain_text(error), parent=window) def open_settings(window, root, zh=True): diff --git a/freevideo_engine/component_licenses.py b/freevideo_engine/component_licenses.py index 15c26d58..6bc54f06 100644 --- a/freevideo_engine/component_licenses.py +++ b/freevideo_engine/component_licenses.py @@ -7,7 +7,9 @@ """ from pathlib import Path -HERE = Path(__file__).resolve().parent +from .long_paths import io_path, resolved, plain + +HERE = Path(plain(resolved(__file__))).parent # The full texts of what the Windows runtime package carries, shipped with every launcher, so # a runtime package built before them still opens them without a connection. TEXTS = HERE / 'licenses/runtime' @@ -68,7 +70,7 @@ def library_names(paths): def file_exists(path): """Whether path is a file; False also when it cannot be looked at (a folder the user may not open).""" try: - return path.is_file() + return io_path(path).is_file() except OSError: return False @@ -81,12 +83,12 @@ def fits(path): if title is None: return True try: - with path.open('rb') as file: + with io_path(path).open('rb') as file: return file.read(256).decode('utf-8', 'replace').lstrip(' \r\n').startswith(title) except OSError: return False path = next((Path(p) for p in paths if p and fits(Path(p))), None) - return dict(value, link=str(path)) if path else value + return dict(value, link=plain(path)) if path else value def text(value): diff --git a/freevideo_engine/curl_windows.py b/freevideo_engine/curl_windows.py index c0db1543..e6b97f46 100644 --- a/freevideo_engine/curl_windows.py +++ b/freevideo_engine/curl_windows.py @@ -18,6 +18,7 @@ import uuid import zipfile +from . import execution_paths from . import proxy from .system import bootstrap_root @@ -89,7 +90,7 @@ def probe(path, env): return dict(cached[1]) from .windows_ux import external_python, hidden_console with external_python(): - result = subprocess.run([str(path), '--disable', '--version'], env=env, + result = execution_paths.run([str(path), '--disable', '--version'], env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=3, **hidden_console()) code = result.returncode & 0xffffffff diff --git a/freevideo_engine/desktop.py b/freevideo_engine/desktop.py index 206ac8a2..a2e9dfc9 100644 --- a/freevideo_engine/desktop.py +++ b/freevideo_engine/desktop.py @@ -10,6 +10,7 @@ import uuid import webbrowser +from .long_paths import io_path, resolved, plain, plain_text, plain_errors from . import __version__, branding from .desktop_runtime import Runner, launcher_root, materialize_source, preflight_json from .monitoring import save @@ -33,7 +34,7 @@ def __init__(self, window, source=None): self.progress_rows = {} self.zh = (locale.getlocale()[0] or '').lower().startswith('zh') try: - settings = json.loads((launcher_root() / 'settings.json').read_text(encoding='utf-8')) + settings = json.loads((io_path(launcher_root()) / 'settings.json').read_text(encoding='utf-8')) if not isinstance(settings, dict): settings = {} except (OSError, ValueError): @@ -268,7 +269,7 @@ def generate_page(self, page): def test_page(self, page): _, page = self.scrolled(page, 2) - self.label(page, 'Find out how your machine performs.', '看看这台机器的实际表现。', + self.label(page, 'Measure the actual generation speed.', '测试实际的生成速度。', style='Section.TLabel').pack(anchor='w') self.label(page, 'Five requests: first, repeat, new prompt, resolution, duration.', '连续五轮:首次、重复、更换提示词、分辨率、时长。', @@ -296,10 +297,11 @@ def reports_page(self, page): ('Open launcher logs', '打开启动器日志', self.open_logs)]: self.button(page, en, zh, command, managed=en == 'Export diagnostics').pack(anchor='w', pady=6) + @plain_errors def root(self): if not self.install_path.get().strip(): raise ValueError(self.t('Choose an installation folder.', '请选择安装目录。')) - return Path(self.install_path.get()).expanduser().resolve() + return Path(plain(resolved(Path(self.install_path.get()).expanduser()))) def root_changed(self, *_): self.plan = None @@ -409,7 +411,7 @@ def install(self): def ready_action(self, action, arguments): from tkinter import messagebox try: - machine = json.loads((self.root() / 'machine.json').read_text(encoding='utf-8')) + machine = json.loads((io_path(self.root()) / 'machine.json').read_text(encoding='utf-8')) ready = machine.get('ready') is True and machine.get('source') == str(self.source) except (OSError, ValueError): ready = False @@ -420,6 +422,7 @@ def ready_action(self, action, arguments): self.begin(action, arguments) return True + @plain_errors def generate(self): from tkinter import messagebox prompt = self.prompt.get('1.0', 'end-1c').strip() @@ -428,8 +431,8 @@ def generate(self): return run = self.root() / 'outputs' / ('gui-' + time.strftime('%Y%m%d-%H%M%S') + '-' + uuid.uuid4().hex[:6]) # Prompt text stays local and is excluded from the launcher command/log. - run.mkdir(parents=True) - (run / 'prompt.txt').write_text(prompt, encoding='utf-8') + io_path(run).mkdir(parents=True) + (io_path(run) / 'prompt.txt').write_text(prompt, encoding='utf-8') width, height = self.resolution.get().split(' × ') self.ready_action('generate', ['generate', '--prompt-file', str(run / 'prompt.txt'), '--width', width, '--height', height, '--seconds', self.duration.get(), '--out', str(run / 'video.mp4')]) @@ -507,23 +510,26 @@ def show_plan(self): widget.pack(fill='x', pady=(0, branding.space(3)), before=self.consent) self.plan_text.pack_configure(pady=(0, branding.space(2)) if self.plan['errors'] else 0) + @plain_errors def open_path(self, path): from tkinter import messagebox - if path and Path(path).exists(): - os.startfile(str(path)) + if path and io_path(path).exists(): + os.startfile(plain(path)) else: messagebox.showinfo('FreeVideo', self.t('No result yet.', '目前还没有结果。'), parent=self.window) + @plain_errors def open_report(self): - reports = sorted((self.root() / 'test-runs').glob('*/report.html')) - self.open_path(reports[-1] if reports else None) + reports = sorted((io_path(self.root()) / 'test-runs').glob('*/report.html')) + self.open_path(Path(plain(reports[-1])) if reports else None) def open_outputs(self): self.open_path(self.root()) + @plain_errors def open_video(self): - videos = sorted((self.root() / 'outputs').glob('gui-*/video.mp4')) - self.open_path(videos[-1] if videos else None) + videos = sorted((io_path(self.root()) / 'outputs').glob('gui-*/video.mp4')) + self.open_path(Path(plain(videos[-1])) if videos else None) def open_logs(self): self.open_path(self.last_log or launcher_root() / 'runs') @@ -693,14 +699,15 @@ def stop_progress(self): for row in self.progress_rows.values(): row['bar'].stop() + @plain_errors def callback_error(self, kind, value, trace): from tkinter import messagebox - detail = ''.join(traceback.format_exception(kind, value, trace)) + detail = plain_text(''.join(traceback.format_exception(kind, value, trace))) self.append_log(detail) path = launcher_root() / 'errors' / ('error-' + uuid.uuid4().hex + '.log') - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(detail, encoding='utf-8') - messagebox.showerror('FreeVideo', str(value) + '\n' + str(path), parent=self.window) + io_path(path.parent).mkdir(parents=True, exist_ok=True) + io_path(path).write_text(detail, encoding='utf-8') + messagebox.showerror('FreeVideo', plain_text(value) + '\n' + plain(path), parent=self.window) def main(): diff --git a/freevideo_engine/desktop_runtime.py b/freevideo_engine/desktop_runtime.py index 2aa83c5b..76815e37 100644 --- a/freevideo_engine/desktop_runtime.py +++ b/freevideo_engine/desktop_runtime.py @@ -14,8 +14,9 @@ import traceback import uuid -from . import __version__, processes +from . import __version__, launch_view, processes from .monitoring import save +from .long_paths import io_path, resolved, plain, plain_text, plain_errors from .system import install_root, windows @@ -58,7 +59,7 @@ def matching_source(bundled, installed): Read source only: neither editable metadata nor an old receipt is proof that the running launcher and the selected engine contain the same code. """ - bundled, installed = Path(bundled).resolve(), Path(installed).resolve() + bundled, installed = resolved(bundled), resolved(installed) if bundled == installed: return True try: @@ -79,7 +80,7 @@ def source_release(source): A source checkout has no stamp and returns an empty identity.""" from .release_notes import product_version try: - stamp = json.loads((Path(source) / 'freevideo_engine' / 'build-identity.json').read_text(encoding='utf-8')) + stamp = json.loads((io_path(source) / 'freevideo_engine' / 'build-identity.json').read_text(encoding='utf-8')) except (OSError, ValueError, TypeError): return {} if not isinstance(stamp, dict): @@ -123,10 +124,12 @@ def check_launcher_reopen(destination): return dict(dependencies, installed_history=True) +@plain_errors def check_launcher_payload(source, destination): """Exercise installation metadata in the actual EXE, without installing anything.""" from . import comfy_source, network - package = Path(__file__).parent + package = io_path(Path(__file__).parent) + source, destination = io_path(source), io_path(destination) files = package_data_files(package.parent) # A missing manifest must fail even if the frozen package contains no data. for name in ('dependencies.json', 'bootstrap_versions.json', 'model_files.json', 'prepared_models.json', 'test_prompts.json'): @@ -135,8 +138,8 @@ def check_launcher_payload(source, destination): relative = path.relative_to(package.parent) if path.read_bytes() != (source / relative).read_bytes(): raise ValueError('Packaged resource differs from installation source: ' + str(relative)) - layout = comfy_source.new_layout(str(destination / 'new-install')) - if Path(layout['root']).exists(): + layout = comfy_source.new_layout(plain(destination / 'new-install')) + if io_path(layout['root']).exists(): raise ValueError('Launcher smoke inspection created a ComfyUI installation') return dict(resources=[p.relative_to(package.parent).as_posix() for p in files], comfy_source=layout['source_spec'], model_repo=network.model_spec()['vdn_repo']) @@ -145,11 +148,11 @@ def check_launcher_payload(source, destination): def materialize_source(bundle=None, destination=None): """A one-file executable's temporary extraction must never back an editable install.""" if bundle is None and not getattr(sys, 'frozen', False): - return Path(__file__).resolve().parents[1] - bundle = Path(bundle or Path(sys._MEIPASS) / 'engine-source') + return Path(plain(resolved(__file__).parents[1])) + bundle = io_path(bundle or Path(sys._MEIPASS) / 'engine-source') rows = {p.relative_to(bundle).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest() for p in source_files(bundle)} identity = hashlib.sha256(json.dumps(rows, sort_keys=True).encode()).hexdigest()[:16] - parent = Path(destination or launcher_root() / 'source') + parent = io_path(destination or launcher_root() / 'source') target = parent / (__version__ + '-' + identity) def complete(path): @@ -160,7 +163,7 @@ def complete(path): return False if complete(target): - return target + return Path(plain(target)) parent.mkdir(parents=True, exist_ok=True) # Not mkdtemp: on Windows its folder is readable only by its owner, and a # run as administrator makes Administrators the owner, so the same person's @@ -176,13 +179,13 @@ def complete(path): while True: if target.exists(): if complete(target): - return target + return Path(plain(target)) # Retain edits and incomplete deployments without overwriting them. target = parent / (__version__ + '-' + identity + '-' + uuid.uuid4().hex[:8]) continue try: stage.rename(target) - return target + return Path(plain(target)) except OSError as error: # Another launcher can publish between exists() and rename(). # Windows reports ERROR_ALREADY_EXISTS; POSIX uses ENOTEMPTY. @@ -230,8 +233,8 @@ def local_model_ui(message): class Runner: def __init__(self, source, events, logs=None): - self.source, self.events = Path(source), events - self.logs = Path(logs or launcher_root() / 'runs') + self.source, self.events = Path(plain(source)), events + self.logs = Path(plain(logs or launcher_root() / 'runs')) self.process = self.thread = None self.cancelled = threading.Event() self.lock = threading.Lock() @@ -273,18 +276,28 @@ def prepare(self, action, root, env, progress): return env def _run_awake(self, action, root, arguments): - run = self.logs / (time.strftime('%Y%m%dT%H%M%SZ', time.gmtime()) + '-' + uuid.uuid4().hex[:8]) + run = io_path(self.logs) / (time.strftime('%Y%m%dT%H%M%SZ', time.gmtime()) + '-' + uuid.uuid4().hex[:8]) code, output, error = None, '', None started = time.monotonic() try: run.mkdir(parents=True) command = self.command(root, arguments) save(run / 'status.json', dict(action=action, command=command, root=str(root), status='running')) - self.events.put(('started', str(run))) + self.events.put(('started', plain(run))) env = self.environment(root) with (run / 'launcher.log').open('w', encoding='utf-8', buffering=1) as log: + def plain_message(value): + if isinstance(value, str): + return plain_text(value) + if isinstance(value, dict): + return {key: plain_message(item) for key, item in value.items()} + if isinstance(value, list): + return [plain_message(item) for item in value] + return value + def progress(message): nonlocal output + message = plain_message(message) line = json.dumps(message) + '\n' log.write(line) output = (output + line)[-2 * 1024 * 1024:] @@ -292,6 +305,8 @@ def progress(message): if message.get('event') == 'freevideo_ui': self.events.put(('progress', message)) env = self.prepare(action, root, env, progress) + view = launch_view.for_engine(root) + env = launch_view.environment_view(env, view) if self.cancelled.is_set(): raise RuntimeError('Stopped; files retained') with self.lock: @@ -315,12 +330,13 @@ def progress(message): if self.cancelled.is_set(): processes.stop(process) for line in process.stdout: + line = launch_view.line_back(plain_text(line), view) log.write(line) output = (output + line)[-2 * 1024 * 1024:] self.events.put(('log', line)) if line.startswith('{'): try: - message = json.loads(line) + message = plain_message(json.loads(line)) if message.get('event') == 'freevideo_ui': if message.get('kind') == 'failure' and isinstance(message.get('error'), str): error = message['error'] @@ -332,8 +348,8 @@ def progress(message): code = process.wait(timeout=15) process.stdout.close() except BaseException as failure: - error = repr(failure) - detail = '\n' + traceback.format_exc() + error = plain_text(repr(failure)) + detail = plain_text('\n' + traceback.format_exc()) output += detail try: with (run / 'launcher.log').open('a', encoding='utf-8') as log: @@ -345,11 +361,11 @@ def progress(message): processes.stop(self.process) finally: result = dict(action=action, status='cancelled' if self.cancelled.is_set() else 'complete' if code == 0 else 'failed', - returncode=code, error=error, wall_seconds=time.monotonic()-started, root=str(root), log=str(run)) + returncode=code, error=error, wall_seconds=time.monotonic()-started, root=str(root), log=plain(run)) try: save(run / 'status.json', result) except OSError as failure: - result.update(status='failed', error=str(failure)) + result.update(status='failed', error=plain_text(failure)) with self.lock: self.process = None self.events.put(('done', dict(result, output=output))) diff --git a/freevideo_engine/desktop_shortcut.py b/freevideo_engine/desktop_shortcut.py index ce29ef64..2b0dea9a 100644 --- a/freevideo_engine/desktop_shortcut.py +++ b/freevideo_engine/desktop_shortcut.py @@ -12,6 +12,7 @@ import uuid from .monitoring import save +from .long_paths import io_path, resolved, plain, plain_text, plain_error, plain_errors from .system import windows @@ -19,7 +20,7 @@ def read_build(path): """A launcher stamp, or an unknown identity for legacy/unreadable copies.""" from .launcher_update import build_identity try: - return build_identity(json.loads(Path(path).read_text(encoding='utf-8'))) + return build_identity(json.loads(io_path(path).read_text(encoding='utf-8'))) except (OSError, ValueError, TypeError): return {} @@ -170,56 +171,65 @@ def _complete(root, fingerprint, folder): return False +@plain_errors def launcher_target(engine, source, portable_root=None, *, prepare=True): if portable_root: - target = Path(portable_root) / 'FreeVideo.exe' + target = io_path(portable_root) / 'FreeVideo.exe' if not target.is_file(): - raise FileNotFoundError('Bundle launcher is missing: ' + str(target)) + raise FileNotFoundError('Bundle launcher is missing: ' + plain(target)) + target = Path(plain(target)) return target, [], target if getattr(sys, 'frozen', False): - executable = Path(sys.executable).resolve() - from .launcher_update import current_build - identity = current_build() or {} - folder = identity.get('packaging') == 'onedir' - fingerprint = digest(executable) - parent = copies_folder(engine, fingerprint) - for root in [parent / fingerprint, *sorted(parent.glob(fingerprint + '-*'))]: - if _complete(root, fingerprint, folder): - return root / 'FreeVideo.exe', [], root / 'FreeVideo.exe' - if not prepare: - target = parent / fingerprint / 'FreeVideo.exe' - return target, [], target - parent.mkdir(parents=True, exist_ok=True) - # Not tempfile.mkdtemp: since Python 3.12.4 that folder admits only its owner, so a copy - # made while running as administrator could not be opened from the shortcut afterwards. - stage = parent / ('launcher-' + uuid.uuid4().hex) - stage.mkdir() try: - shutil.copy2(executable, stage / 'FreeVideo.exe') - if folder: - shutil.copytree(executable.parent / '_internal', stage / '_internal') - if digest(stage / 'FreeVideo.exe') != fingerprint: - raise OSError('Launcher changed while preparing the desktop shortcut; retry installation') - if identity: - save(stage / 'launcher-build.json', identity) - root = parent / fingerprint - if os.path.lexists(root): - root = root.with_name(fingerprint + '-' + uuid.uuid4().hex[:8]) - stage.rename(root) - finally: - if stage.exists(): shutil.rmtree(stage, ignore_errors=True) # Every start retries: no partial copies. - return root / 'FreeVideo.exe', [], root / 'FreeVideo.exe' - python = Path(sys.executable).resolve() + executable = resolved(sys.executable) + from .launcher_update import current_build + identity = current_build() or {} + folder = identity.get('packaging') == 'onedir' + fingerprint = digest(executable) + parent = io_path(copies_folder(engine, fingerprint)) + for root in [parent / fingerprint, *sorted(parent.glob(fingerprint + '-*'))]: + if _complete(root, fingerprint, folder): + target = Path(plain(root / 'FreeVideo.exe')) + return target, [], target + if not prepare: + target = Path(plain(parent / fingerprint / 'FreeVideo.exe')) + return target, [], target + parent.mkdir(parents=True, exist_ok=True) + # Not tempfile.mkdtemp: since Python 3.12.4 that folder admits only its owner, so a copy + # made while running as administrator could not be opened from the shortcut afterwards. + stage = parent / ('launcher-' + uuid.uuid4().hex) + stage.mkdir() + try: + shutil.copy2(executable, stage / 'FreeVideo.exe') + if folder: + shutil.copytree(executable.parent / '_internal', stage / '_internal') + if digest(stage / 'FreeVideo.exe') != fingerprint: + raise OSError('Launcher changed while preparing the desktop shortcut; retry installation') + if identity: + save(stage / 'launcher-build.json', identity) + root = parent / fingerprint + if os.path.lexists(root): + root = root.with_name(fingerprint + '-' + uuid.uuid4().hex[:8]) + stage.rename(root) + finally: + if stage.exists(): shutil.rmtree(stage, ignore_errors=True) # Every start retries: no partial copies. + target = Path(plain(root / 'FreeVideo.exe')) + return target, [], target + except (OSError, ValueError) as error: + plain_error(error) + raise + python = resolved(sys.executable) if python.with_name('pythonw.exe').is_file(): python = python.with_name('pythonw.exe') code = "import runpy,sys;sys.path.insert(0,sys.argv[1]);runpy.run_module('freevideo_engine.comfy_launcher',run_name='__main__')" - return python, ['-c', code, str(Path(source).resolve())], Path(source) / 'freevideo_engine/assets/icon.ico' + return Path(plain(python)), ['-c', code, plain(resolved(source))], Path(plain(source)) / 'freevideo_engine/assets/icon.ico' def _same(first, second): return bool(first) and bool(second) and os.path.normcase(str(first)) == os.path.normcase(str(second)) +@plain_errors def _ours(path, entry, target): """A shortcut this or an earlier FreeVideo installation wrote, not one of the user's own. @@ -246,7 +256,7 @@ def _ours(path, entry, target): if _same(named, target) or _same(named, entry.get('target')): return True return named.name.lower() == 'freevideo.exe' and ( - not named.exists() or named.parent.parent.name.lower() in ('application', 'application.noindex')) + not io_path(named).exists() or named.parent.parent.name.lower() in ('application', 'application.noindex')) def _starts(path, target): @@ -475,6 +485,7 @@ def create_linux(engine): return result +@plain_errors def create(engine, source, portable_root=None): if not windows(): if sys.platform == 'darwin': @@ -484,7 +495,7 @@ def create(engine, source, portable_root=None): return create_linux(engine) return dict(status='not-applicable') engine = Path(engine) - receipt = engine / 'launcher/desktop-shortcut.json' + receipt = io_path(engine) / 'launcher/desktop-shortcut.json' try: previous = json.loads(receipt.read_text(encoding='utf-8')) except (OSError, ValueError): diff --git a/freevideo_engine/diagnostic_resources.py b/freevideo_engine/diagnostic_resources.py index 883f5a5d..508411c0 100644 --- a/freevideo_engine/diagnostic_resources.py +++ b/freevideo_engine/diagnostic_resources.py @@ -1,6 +1,7 @@ """Bounded diagnostic evidence without tensor imports, paths or exception text.""" import math import re +import statistics import sys @@ -439,6 +440,12 @@ def resources(value): if isinstance(observed.get('clock_reason_counts'), dict): clean['clock_reason_counts'] = numbers(observed['clock_reason_counts'], tuple(Activity.clock_reasons)) result['gpu'][key] = clean + if isinstance(gpu.get('other_programs'), dict): + result['gpu']['other_programs'] = numbers(gpu['other_programs'], ('checks', 'shared_checks', 'check_seconds', + 'share_percent', 'other_mean_percent', 'other_peak_percent', 'own_mean_percent')) + for key in ('supported', 'own_reported'): + if type(gpu['other_programs'].get(key)) is bool: + result['gpu']['other_programs'][key] = gpu['other_programs'][key] return result @@ -520,6 +527,54 @@ def sampling(value): return result +def mac_condition(value): + """Fixed Mac hardware fields; preserve nulls without arbitrary metadata.""" + if value is None: + return None + from .macos_conditions import THERMAL_NAMES, PRESSURE_NAMES, VM_COUNTERS + value = mapping(value) + thermal, pressure = value.get('thermal_state'), value.get('memory_pressure_level') + thermal = thermal if type(thermal) is int and thermal in THERMAL_NAMES else None + pressure = pressure if type(pressure) is int and pressure in PRESSURE_NAMES else None + result = dict(thermal_state=thermal, thermal_state_name=THERMAL_NAMES.get(thermal), + memory_pressure_level=pressure, memory_pressure_name=PRESSURE_NAMES.get(pressure), + low_power_mode=value.get('low_power_mode') if type(value.get('low_power_mode')) is bool else None, + power_source=value.get('power_source') if value.get('power_source') in ('ac', 'battery') else None) + for key in ('swap_used_bytes', *VM_COUNTERS): + item = value.get(key) + result[key] = item if type(item) is int and item >= 0 else None + return result + + +def mac_conditions_summary(passes): + """Observed maxima and per-step host timing, with unknowns kept explicit.""" + result = [] + for row in passes: + if 'conditions' not in row and 'block_seconds' not in row: + continue + samples = sequence(row.get('conditions')) + states = [mapping(sample) for sample in samples] + def highest(key): + values = [sample[key] for sample in states if type(sample.get(key)) is int] + return max(values) if values else None + low_power = [sample['low_power_mode'] for sample in states if type(sample.get('low_power_mode')) is bool] + timings = [] + for step, blocks in enumerate(sequence(row.get('block_seconds')), 1): + values = [item for item in sequence(blocks) if type(item) in (int, float)] + timings.append(dict(step=step, blocks=len(values), + median_seconds=statistics.median(values) if values else None, + max_seconds=max(values) if values else None)) + result.append(dict(index=row['index'], highest_thermal_state=highest('thermal_state'), + highest_memory_pressure_level=highest('memory_pressure_level'), + low_power_mode_seen=any(low_power) if low_power else None, + power_sources=sorted({sample['power_source'] for sample in states + if sample.get('power_source') in ('ac', 'battery')}), + swap_used_start_bytes=mapping(samples[0]).get('swap_used_bytes') if samples else None, + swap_used_end_bytes=mapping(samples[-1]).get('swap_used_bytes') if samples else None, + block_step_statistics=timings)) + return result + + def sampling_passes(value): """Per-canvas execution evidence with no paths, prompts or tensor values.""" result = [] @@ -545,6 +600,16 @@ def sampling_passes(value): item['schedule_cache'] = numbers(preparation, ('seconds', 'table_cache_hits')) 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': + from .macos_conditions import BLOCK_SECONDS_SCOPE + if 'conditions' in row: + item['conditions'] = [mac_condition(sample) for sample in sequence(row['conditions'])[:33]] + if 'block_seconds' in row: + item['block_seconds'] = [[duration if type(duration) in (int, float) + and math.isfinite(duration) and duration >= 0 else None + for duration in sequence(step)[:50]] for step in sequence(row['block_seconds'])[:32]] + if row.get('block_seconds_scope') == BLOCK_SECONDS_SCOPE: + item['block_seconds_scope'] = BLOCK_SECONDS_SCOPE result.append(item) return result diff --git a/freevideo_engine/diagnostic_summary.py b/freevideo_engine/diagnostic_summary.py index f068fcb5..e80bb530 100644 --- a/freevideo_engine/diagnostic_summary.py +++ b/freevideo_engine/diagnostic_summary.py @@ -41,9 +41,21 @@ def summary_report(report): result['hardware'].update(_measurements(hardware, ('vram_total','vram_free','ram_total','ram_available'))) result['offload'] = diagnostic.weight_placement(summary.get('weight_placement')) result['sampling_passes'] = diagnostic.sampling_passes(summary.get('sampling_passes') or engine.get('sampling_passes')) + if engine.get('device_backend') == 'mps': + conditions = diagnostic.mac_conditions_summary(result['sampling_passes']) + decode = diagnostic.mapping(engine.get('decode')) + if 'conditions' not in decode: + decode = diagnostic.mapping(diagnostic.mapping(engine.get('stages')).get('decode')) + if conditions or 'conditions' in decode: + result['mac_conditions'] = dict(sampling_passes=conditions) + if 'conditions' in decode: + samples = [diagnostic.mac_condition(sample) for sample in diagnostic.sequence(decode['conditions'])[:2]] + result['mac_conditions']['decode_conditions'] = samples result['latent_upscale'] = diagnostic.latent_upscale(summary.get('latent_upscale') or engine.get('latent_upscale')) decoder = summary.get('decoder_options') or {} result['decoder'] = _measurements(decoder, ('resident_blocks',)) + if 'mac_conditions' in result and 'decode_conditions' in result['mac_conditions']: + result['decoder']['conditions'] = result['mac_conditions']['decode_conditions'] for key in ('offload','prefetch','stream_weights','stream_output','pin_weights','preload','tile_group', 'linear_compute_cache'): if type(decoder.get(key)) is bool: diff --git a/freevideo_engine/diagnostics.py b/freevideo_engine/diagnostics.py index 89cc543a..5c732154 100644 --- a/freevideo_engine/diagnostics.py +++ b/freevideo_engine/diagnostics.py @@ -16,6 +16,8 @@ import zipfile import platform +from . import execution_paths +from .long_paths import io_path, resolved, plain, plain_text from . import __version__ from .bootstrap import DEFAULT_ROOT from .system import windows, venv_python, system_memory, nvidia_smi, bootstrap_root @@ -72,7 +74,7 @@ def structured(self, value): return value def text(self, value): - value = str(value) + value = plain_text(value) for prompt in sorted(self.prompts, key=len, reverse=True): value = value.replace(prompt, '') for secret in sorted(self.secrets, key=len, reverse=True): @@ -146,6 +148,7 @@ def clean(item): def read_bounded(path, limit=FILE_LIMIT): + path = io_path(path) if windows(): from .win32 import open_regular descriptor = open_regular(path) @@ -165,6 +168,7 @@ def read_bounded(path, limit=FILE_LIMIT): def read_complete(path): """Read one regular diagnostic file in full for an explicit user export.""" + path = io_path(path) if windows(): from .win32 import open_regular descriptor = open_regular(path) @@ -221,14 +225,14 @@ def web_files(root, machine): for source in sources: if not isinstance(source, str) or not source: continue - source = Path(source) + source = io_path(source) try: rows = json.loads((source / 'launcher-source.json').read_text(encoding='utf-8'))['sha256'] rows = rows if isinstance(rows, dict) else None except (OSError, ValueError, KeyError, TypeError): rows = None if not (source / 'web').is_dir(): - return dict(source=str(source), error='The web folder is missing') + return dict(source=plain(source), error='The web folder is missing') present = {p.relative_to(source).as_posix(): p for p in (source / 'web').rglob('*') if p.is_file()} expected = {k: v for k, v in (rows or {}).items() if k.startswith('web/')} changed = [] @@ -238,14 +242,16 @@ def web_files(root, machine): changed.append(name) except OSError: changed.append(name) - return dict(source=str(source), manifest=rows is not None, files=len(present), + return dict(source=plain(source), manifest=rows is not None, files=len(present), missing=sorted(set(expected) - set(present)), changed=changed, extra=sorted(set(present) - set(expected)) if rows is not None else []) return dict(error='No FreeVideo source is recorded') def collect(root, config, run, output, *, complete=False, extra_files=(), notes=''): - root, config = root.expanduser().resolve(), config.expanduser().absolute() + root, config = resolved(root.expanduser()), io_path(config.expanduser().absolute()) + run = io_path(run) if run is not None else None + output = io_path(output) if run is not None and (not run.is_dir() or is_link(run)): raise ValueError('--run must be a real report directory') if output.suffix.lower() != '.zip': @@ -255,8 +261,8 @@ def collect(root, config, run, output, *, complete=False, extra_files=(), notes= setup = latest(root, 'setup-runs') selected = run.resolve() if run else latest(root, 'test-runs') optimized = latest(root, 'optimization-runs') - redactor = Redactor([(Path.home(), ''), (root, ''), - *((p, '<' + label.upper() + '>') for label, p in [('setup', setup), ('run', selected), ('optimization', optimized)] if p)]) + redactor = Redactor([(Path.home(), ''), (plain(root), ''), + *((plain(p), '<' + label.upper() + '>') for label, p in [('setup', setup), ('run', selected), ('optimization', optimized)] if p)]) manifest = {'schema_version': 1, 'engine_version': __version__, 'created_epoch': time.time(), 'files': [], 'omitted': [], 'errors': [], 'payload_bytes': 0, 'scope': ('Complete eligible local diagnostics. ' if complete else 'Local bounded diagnostics. ') + @@ -337,10 +343,10 @@ def add_path(name, path): if (p := venv_python(root / 'envs' / name)).is_file()) code = 'import importlib.metadata as m,json,sys; print(json.dumps({"python":sys.version,"packages":{d.metadata["Name"]:d.version for d in m.distributions()}}))' for index, python in enumerate(sorted(pythons)): - commands.append(('packages-' + str(index), [python, '-B', '-c', code])) + commands.append(('packages-' + str(index), [plain(python), '-B', '-c', code])) for name, command in commands: try: - result = subprocess.run(command, capture_output=True, text=True, timeout=15, encoding='utf-8', errors='replace') + result = execution_paths.run(command, capture_output=True, text=True, timeout=15, encoding='utf-8', errors='replace') value = {'returncode': result.returncode, 'stdout': result.stdout if complete else result.stdout[-FILE_LIMIT:], 'stderr': result.stderr if complete else result.stderr[-FILE_LIMIT:]} @@ -350,19 +356,19 @@ def add_path(name, path): value = {'error': str(error)} manifest['errors'].append({'command': name, 'error': redactor.text(str(error))}) add('current/' + name + '.json', json.dumps(value).encode()) - bootstrap = bootstrap_root(root) + bootstrap = io_path(bootstrap_root(root)) if not is_link(bootstrap) and (bootstrap / 'bootstrap.log').is_file(): add_path('bootstrap/bootstrap.log', bootstrap / 'bootstrap.log') if windows(): from .curl_windows import inspect as inspect_curl add_path('bootstrap/curl-diagnostic.json', bootstrap / 'curl-diagnostic.json') try: - curl_state = inspect_curl(dict(os.environ, FREEVIDEO_HOME=str(root))) + curl_state = inspect_curl(dict(os.environ, FREEVIDEO_HOME=plain(root))) except (OSError, ValueError, RuntimeError) as error: curl_state = dict(status='CURL_DIAGNOSTIC_FAILED', exception=type(error).__name__) add('current/curl.json', json.dumps(curl_state).encode()) from .desktop_runtime import launcher_root - gui_runs = launcher_root() / 'runs' + gui_runs = io_path(launcher_root()) / 'runs' if gui_runs.is_dir() and not is_link(gui_runs): recent = sorted((p for p in gui_runs.iterdir() if p.is_dir() and not is_link(p)), key=lambda p: p.name)[-3:] for gui_run in recent: @@ -374,7 +380,7 @@ def add_path(name, path): 'memory': system_memory(), 'cpu': platform.processor(), 'executables': {name: shutil.which(name) for name in ('git', 'curl', 'cl', 'nvcc')}, 'cuda_paths': {k: v for k, v in os.environ.items() if k == 'CUDA_PATH' or k.startswith('CUDA_PATH_V')}, - 'bootstrap_log': str(bootstrap / 'bootstrap.log')}).encode()) + 'bootstrap_log': plain(bootstrap / 'bootstrap.log')}).encode()) except OSError as error: manifest['errors'].append({'command': 'windows-memory', 'error': redactor.text(str(error))}) else: @@ -401,8 +407,8 @@ def add_path(name, path): if (root / 'logs' / name).is_file(): add_path('logs/' + name, root / 'logs' / name) add('source/web-files.json', json.dumps(web_files(root, machine)).encode()) - add_path('source/dependencies.json', Path(__file__).with_name('dependencies.json')) - source = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(Path(__file__).parent.glob('*.py'))} + add_path('source/dependencies.json', io_path(__file__).with_name('dependencies.json')) + source = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(io_path(__file__).parent.glob('*.py'))} add('source/hashes.json', json.dumps(source).encode()) # Read structured reports first so their secrets are also redacted # when they occur in a launcher's plain-text error or log. @@ -414,7 +420,7 @@ def add_path(name, path): add_path('launcher/selected-%d.log' % index, Path(path)) disk = shutil.disk_usage(output.parent) add('current/collector.json', json.dumps({'python': sys.version, 'disk_free_bytes': disk.free, - 'selected_run': str(selected) if selected else None}).encode()) + 'selected_run': plain(selected) if selected else None}).encode()) archive.writestr('manifest.json', json.dumps(manifest, indent=2)) return manifest diff --git a/freevideo_engine/disk_space.py b/freevideo_engine/disk_space.py index 0e9ad9b1..ceaaf791 100644 --- a/freevideo_engine/disk_space.py +++ b/freevideo_engine/disk_space.py @@ -15,14 +15,17 @@ import threading import time +from .long_paths import io_path, plain, plain_errors + _HIDDEN_MOUNTS = ('/System/Volumes/', '/private/var/vm', '/Library/Developer/CoreSimulator', '/Volumes/.timemachine') +@plain_errors def existing(path): - path = Path(path).expanduser() + path = io_path(Path(path).expanduser()) while not path.exists() and path != path.parent: path = path.parent - return path + return Path(plain(path)) if sys.platform == 'darwin': @@ -181,23 +184,25 @@ def volume(path): return value +@plain_errors def free_bytes(path): """Bytes that can be written at path, counting space the system frees on demand.""" if sys.platform != 'darwin': - return shutil.disk_usage(existing(path)).free + return shutil.disk_usage(io_path(existing(path))).free try: return volume(path)['free_bytes'] except (OSError, ValueError): - return shutil.disk_usage(existing(path)).free + return shutil.disk_usage(io_path(existing(path))).free +@plain_errors def disk_key(path): """Paths with the same key draw on the same free space.""" - path = existing(path) + path = io_path(existing(path)) if sys.platform != 'darwin': return path.stat().st_dev try: - return _describe(_stat(path), str(path), details=False)['key'] + return _describe(_stat(path), plain(path), details=False)['key'] except (OSError, ValueError): return path.stat().st_dev diff --git a/freevideo_engine/download_settings.py b/freevideo_engine/download_settings.py index 374f1038..6a8a1e58 100644 --- a/freevideo_engine/download_settings.py +++ b/freevideo_engine/download_settings.py @@ -6,6 +6,7 @@ import time import uuid from .monitoring import save +from .long_paths import io_path, plain from .proxy import MODES CHOICES = ('auto', 'official', 'hf-mirror', 'modelscope') @@ -25,11 +26,11 @@ def speed_text(row, zh=False): def read(path): try: - value=json.loads(Path(path).read_text(encoding='utf-8')) + value=json.loads(io_path(path).read_text(encoding='utf-8')) except FileNotFoundError: return dict(source='auto', proxy_mode='auto') if not isinstance(value,dict) or value.get('source') not in CHOICES: - raise ValueError('Invalid model download settings: '+str(path)) + raise ValueError('Invalid model download settings: '+plain(path)) value.setdefault('proxy_mode', 'auto') if value['proxy_mode'] not in MODES: raise ValueError('Invalid download connection mode') @@ -106,7 +107,7 @@ def work(): try: from . import network from .environments import bootstrap_versions - package=Path(__file__).parent + package=io_path(Path(__file__).parent) spec=json.loads((package/'dependencies.json').read_text()) versions=bootstrap_versions(json.loads((package/'bootstrap_versions.json').read_text())) def progress(value): diff --git a/freevideo_engine/effort_forecast.py b/freevideo_engine/effort_forecast.py index 0cdf6e22..a94108ca 100644 --- a/freevideo_engine/effort_forecast.py +++ b/freevideo_engine/effort_forecast.py @@ -26,7 +26,7 @@ def same_device(hardware, device): return bool(device) and hardware.get('gpu_uuid') == device -def timing_record(report, gpu_uuid, system): +def timing_record(report, gpu_uuid, system, *, stage_details=True): if not isinstance(report, dict) or report.get('success') is not True: return None video = report.get('video') or {} @@ -56,6 +56,8 @@ def timing_record(report, gpu_uuid, system): if not isinstance(times, list) or len(times) < 2 or not all(positive(v) for v in times): return None measured.append(dict(steps=len(times), seconds=sum(times), incremental=median(times[1:]))) + if stage_details: + measured[-1]['first'] = times[0] if sum(p['seconds'] for p in measured) > total * 1.01: return None base = plan.get('base_steps', video.get('config', {}).get('steps', 8)) @@ -65,7 +67,7 @@ def timing_record(report, gpu_uuid, system): encoding = report.get('encoding') or {} cached = bool(encoding.get('cache_hit') or report.get('input_cache', {}).get('conditioning_hit') or report.get('tuning', {}).get('conditioning_cache_hit')) - return dict(shape={k: shape[k] for k in ('width', 'height', 'frames')}, + record = dict(shape={k: shape[k] for k in ('width', 'height', 'frames')}, two_pass=bool(plan.get('enabled')), base_steps=base, refine_steps=refine, passes=measured, request_seconds=total, conditioning_cache_hit=cached, @@ -74,6 +76,53 @@ def timing_record(report, gpu_uuid, system): task=video.get('config', {}).get('task', 't2va'), adapters=bool(report.get('profile', {}).get('engine', {}).get('loras') or report.get('lora') or video.get('config', {}).get('online_lora'))) + if not stage_details: + return record + upscale = video.get('latent_upscale') or {} + upscale_seconds = (sum(upscale[k] for k in ('load_seconds', 'compute_seconds')) + if all(type(upscale.get(k)) in (int, float) and math.isfinite(upscale[k]) + and upscale[k] >= 0 for k in ('load_seconds', 'compute_seconds')) else None) + stages = {'encode': encoding.get('work_seconds') if not cached else 0., + 'load': video.get('load_seconds'), + 'pass1': measured[0]['seconds'], + 'upscale': upscale_seconds if refine else 0., + 'pass2': measured[1]['seconds'] if refine else 0., + 'decode_save': video.get('decode_save_seconds')} + if all(type(v) in (int, float) and math.isfinite(v) and v >= 0 for v in stages.values()): + # Time outside the reported stages (request preparation, cache checks, + # the model handover and final checks) falls about evenly before the + # video model starts and around saving, where the bridge counts it. + # A cached encode has no first-stage work to attach it to. + extra = max(0., total - sum(stages.values())) + first = 0. if cached else extra / 2 + stages['encode'] += first + stages['decode_save'] += extra - first + # Optional report chronology only; legacy readers already supply newest + # first by file mtime. Never retain a path, prompt or request identifier. + recorded_epoch = report.get('completed_epoch', report.get('started_epoch')) + version = (report.get('runtime_code') or {}).get('version') if isinstance(report.get('runtime_code'), dict) else None + # Saved planner input, after encoding/idle-cache credit, is in whole bytes. + memory = [hardware.get(k) for k in ('vram_total', 'vram_free', 'ram_total', 'ram_available')] + in_use = None + if all((type(v) is int and v > 0) or + (type(v) is float and positive(v) and v.is_integer()) for v in memory): + vram, free, ram, available = map(int, memory) + if free <= vram and available <= ram: + in_use = dict(vram=vram - free, ram=ram - available) + # Only the report's own final geometry describes retained conditioning. + # This field is evidence for capability calibration, never a live ETA input. + counts = [shape.get(k) for k in ('reference_video_tokens', 'reference_audio_tokens')] + reference_rows = (dict(zip(('video', 'audio'), counts)) + if all(type(n) is int and n >= 0 for n in counts) else None) + record.update(recorded_epoch=recorded_epoch if positive(recorded_epoch) else None, + version=version if isinstance(version, str) and len(version) <= 64 else None, + in_use=in_use, + reference_rows=reference_rows, + flow='preview' if report.get('preview') or plan.get('preview') else 'full', stages=stages, + sampling_geometry={k: plan[k] for k in ('first', 'second') if plan.get(k)}, + references=task_has_references(video.get('config', {}).get('task', 't2va')), + model_resident=video.get('resident_engine_cache_hit')) + return record def estimate(records, canvas, *, base_steps=8, refine_steps=3, two_pass=True, task='t2va', adapters=False): @@ -124,14 +173,162 @@ def estimate(records, canvas, *, base_steps=8, refine_steps=3, two_pass=True, ta _CACHE = {} -def local_records(output_directory, gpu_uuid, *, system=None): +def task_has_references(task): + return task not in (None, 't2va') + + +# No measured request, stage or step takes this long (about twelve days). A +# larger number in a record is damage, and would overflow the medians and sums +# of a forecast. Times extrapolated to more steps are not held to it. +_LONGEST_SECONDS = 1e6 + + +def _believable(row): + values = [row.get('request_seconds'), *(row.get('stages') or {}).values()] + for part in row.get('passes') or (): + values += [part.get(key) for key in ('seconds', 'first', 'incremental')] + return all(value <= _LONGEST_SECONDS for value in values if type(value) in (int, float)) + + +def _near(left, right): + return left > 0 and abs(right / left - 1.) <= .1000001 + + +def stage_forecast(records, request, cold_flags, prior, version=None): + """Prefer runs of this FreeVideo version; older runs stay in use until then. + + An update can change stage times, so when any run of the current version + matches the whole request, only runs of that version are used. + """ + if version: + same = [row for row in records if row.get('version') == version] + if same: + result = _stage_forecast(same, request, cold_flags, prior) + if result.get('stages'): + return result + return _stage_forecast(records, request, cold_flags, prior) + + +def _stage_forecast(records, request, cold_flags, prior): + """One whole match can seed ETA; each typical still needs two similar runs. + + Only timing_record's allowlisted successful, same-device/OS observations + enter here. Typical step costs keep first/warm and pass identity separate. + A partial record can teach a typical without claiming a whole local ETA. + Use the latest three matches for each stage, not three arbitrary reports. + Callers supply newest-first records when report chronology is unavailable. + """ + from .stage_estimate import STAGES, canvases, token_count, step_counts + first, second, decode = canvases(request) + counts = step_counts(request) + samples = {key: [] for key in (*STAGES, 'pass1_first', 'pass1_warm', 'pass2_first', 'pass2_warm')} + whole = [] + recent = records[:48] + if recent and all(positive(row.get('recorded_epoch')) for row in recent): + recent = sorted(recent, key=lambda row: row['recorded_epoch'], reverse=True) + cached = cold_flags.get('encode_cached') is True + for row in recent: + # A damaged record must not stop the estimate, nor leave part of itself + # behind: its samples are kept only when the whole record computes. + found, total = [], None + try: + if not _believable(row): + continue + same_task = row.get('task') == request.get('task', 't2va') + same_lora = row.get('adapters', False) == bool(request.get('lora_count')) + refs = row.get('references', task_has_references(row.get('task'))) + same_refs = refs == bool(request.get('reference_count') or task_has_references(request.get('task', 't2va'))) + r1, r2, rd = canvases(dict(row['shape'], two_pass=row['two_pass'], + sampling_geometry=row.get('sampling_geometry'), flow=row.get('flow', 'full'))) + stages = row.get('stages') or {} + if same_task and same_refs and row.get('text_measured') and positive(stages.get('encode')): + found.append(('encode', stages['encode'])) + if same_lora and row.get('model_resident') is False and positive(stages.get('load')): + found.append(('load', stages['load'])) + if _near(decode['width'] * decode['height'] * decode['frames'], rd['width'] * rd['height'] * rd['frames']): + if positive(stages.get('decode_save')): + found.append(('decode_save', stages['decode_save'])) + similar_passes = row['two_pass'] == request.get('two_pass', True) + for i, (target, observed, count) in enumerate(zip((first, second), (r1, r2), counts), 1): + near = _near(token_count(target), token_count(observed)) + similar_passes = similar_passes and (near or not count) + if not (count and same_task and same_lora and near and i <= len(row['passes'])): + continue + part = row['passes'][i-1] + seconds = part['seconds'] + (count-part['steps']) * part['incremental'] + if positive(seconds): + found.append((f'pass{i}', seconds)) + if positive(part.get('first')): + found.append((f'pass{i}_first', part['first'])) + if positive(part['incremental']): + found.append((f'pass{i}_warm', part['incremental'])) + if similar_passes and same_task and same_lora and positive(stages.get('upscale')): + found.append(('upscale', stages['upscale'])) + if (request.get('flow', 'full') == row.get('flow', 'full') and similar_passes and same_task and same_lora + and same_refs): + total = row['request_seconds'] + (counts[0] - row['base_steps']) * row['passes'][0]['incremental'] + if counts[1]: + total += (counts[1] - row['refine_steps']) * row['passes'][1]['incremental'] + # Reusing the prompt cache removes the first stage and little + # else, so a run in the other state can still describe this + # request: without its first stage, or with one added below. + hit = bool(row.get('conditioning_cache_hit', False)) + if cached and not hit: + total = total - stages['encode'] if positive(stages.get('encode')) else None + total = (total, hit and not cached, hit == cached) if total is not None and positive(total) else None + except (LookupError, TypeError, ValueError, ArithmeticError, AttributeError): + continue + for key, value in found: + samples[key].append(value) + if total is not None: + whole.append(total) + samples = {key: values[:3] for key, values in samples.items()} + # Runs in the request's own prompt-cache state come first; converted runs + # are used only when there is none. + whole = ([item for item in whole if item[2]] or whole)[:3] + typical = {} + for key, values in samples.items(): + if len(values) < 2: + continue + seconds = median(values) + unit = 5 if seconds < 60 else 10 if seconds < 600 else 60 + rounded = math.floor(seconds / unit + .5) * unit + if rounded >= 10: + typical[key] = rounded + result = dict(samples=len(whole), typical=typical) + if whole: + weights = {key: (median(samples[key]) if samples[key] else prior[key]) for key in STAGES} + if cached: + weights['encode'] = 0. + # A run that reused the prompt cache lacks the encode this request will do. + seconds = median([total + (weights['encode'] if encodes else 0.) for total, encodes, _ in whole]) + # Preserve whole-request observed overhead, including sparse old reports. + # It is mostly request preparation before the video model starts (the + # first stage in the bridge), so it is not spread over later stages. + # A cached encode has no first-stage work, so its share goes to saving. + extra = seconds - sum(weights.values()) + if extra >= 0: + stage = 'decode_save' if cached else 'encode' + stages = dict(weights, **{stage: weights[stage] + extra}) + else: + stages = {k: v * seconds / max(sum(weights.values()), .001) for k, v in weights.items()} + result.update(seconds=seconds, stages=stages) + # Unrounded warm step costs, so live steps can scale this machine's history. + result['warm'] = {key: median(samples[key + '_warm']) for key in ('pass1', 'pass2') + if samples[key + '_warm']} + return result + + +def local_records(output_directory, gpu_uuid, *, system=None, stage_details=False, refresh=False): """Bounded recent JSON reads, cached between slider changes; never open MP4s.""" system = system or platform.system() root = Path(output_directory).resolve() / 'FreeVideo' key = (str(root), json.dumps(gpu_uuid, sort_keys=True), system) + if stage_details: + key += (True,) now = time.monotonic() cached = _CACHE.get(key) - if cached and now - cached[0] < 15: + if cached and not refresh and now - cached[0] < 15: return cached[1] paths = [] for path in root.glob('*/*/video.request.json'): @@ -147,11 +344,12 @@ def local_records(output_directory, gpu_uuid, *, system=None): data = stream.read(4 * 1024 * 1024 + 1) if len(data) > 4 * 1024 * 1024: continue - row = timing_record(json.loads(data), gpu_uuid, system) + row = timing_record(json.loads(data), gpu_uuid, system, stage_details=stage_details) if row: records.append(row) except (OSError, ValueError, TypeError, AttributeError, KeyError): continue - _CACHE.clear() + if not stage_details: + _CACHE.clear() _CACHE[key] = (now, records) return records diff --git a/freevideo_engine/engine_binding.py b/freevideo_engine/engine_binding.py index 5e6732e1..b5480b39 100644 --- a/freevideo_engine/engine_binding.py +++ b/freevideo_engine/engine_binding.py @@ -10,13 +10,17 @@ from urllib.request import url2pathname from .monitoring import save +from .long_paths import io_path, resolved, plain, plain_text, plain_errors +from .runtime_alias import checked_real_path +from .system import windows +@plain_errors def editable_source(record): """Read one direct_url record; unreadable records raise for cleanup's safety.""" - value = json.loads(Path(record).read_text(encoding='utf-8')) + value = json.loads(io_path(record).read_text(encoding='utf-8')) if not isinstance(value.get('url'), str): - raise ValueError('Editable install record without a URL: ' + str(record)) # Unknown: cleanup keeps every source. + raise ValueError('Editable install record without a URL: ' + plain(record)) # Unknown: cleanup keeps every source. url = urlsplit(value['url']) if url.scheme != 'file' or not (value.get('dir_info') or {}).get('editable'): return None @@ -25,16 +29,17 @@ def editable_source(record): # a Windows record on a POSIX host without treating /C:/ as a POSIX root. if re.match(r'^/[A-Za-z]:/', path): path = path[1:] - return Path(path) + return Path(plain(path)) +@plain_errors def bound_source(python): """The environment's editable source, or None if missing or ambiguous. Do not resolve the Python executable: venv/bin/python commonly links to the system Python, but its metadata belongs to the venv beside bin. """ - python = Path(python).absolute() + python = io_path(python).absolute() environment = python.parent.parent if python.parent.name in ('bin', 'Scripts') else python.parent found = set() for pattern in ('Lib/site-packages/freevideo_engine-*.dist-info/direct_url.json', @@ -49,9 +54,10 @@ def bound_source(python): return next(iter(found)) if len(found) == 1 else None +@plain_errors def source_version(source): """Read the selected source's stamp without importing it into this process.""" - package = Path(source) / 'freevideo_engine' + package = io_path(source) / 'freevideo_engine' try: from .launcher_update import build_identity version = build_identity(json.loads((package / 'build-identity.json').read_text(encoding='utf-8')))['version'] @@ -77,27 +83,29 @@ def source_version(source): raise RuntimeError('Cannot read the engine version from source: ' + str(source)) +@plain_errors def _uv(engine_root, machine): system = machine.get('system') or ('Windows' if os.name == 'nt' else 'Darwin' if sys.platform == 'darwin' else 'Linux') relative = {'Windows': 'uv.exe', 'Darwin': 'uv', 'Linux': 'uv-x86_64-unknown-linux-gnu/uv'}.get(system) - tool = Path(engine_root) / 'tools' / relative if relative else None + tool = io_path(engine_root) / 'tools' / relative if relative else None if tool is None or not tool.is_file(): raise RuntimeError('The engine\'s uv is missing from %s/tools (%s); run Install / repair. ' 'Source rebinding does not download tools.' % (engine_root, system)) - return tool + return Path(plain(tool)) def _same_source(left, right): - return left is not None and Path(left).resolve() == Path(right).resolve() + return left is not None and resolved(left) == resolved(right) def _tail(result): parts = [getattr(result, name, '') or '' for name in ('stdout', 'stderr')] - return '\n'.join('\n'.join(part.decode(errors='replace') if isinstance(part, bytes) else part - for part in parts).strip().splitlines()[-6:]) or '(no output)' + return plain_text('\n'.join('\n'.join(part.decode(errors='replace') if isinstance(part, bytes) else part + for part in parts).strip().splitlines()[-6:]) or '(no output)') +@plain_errors 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)] @@ -116,7 +124,7 @@ def _bind(uv, python, source, 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() - or not imported.resolve().is_relative_to(source) + or not resolved(imported).is_relative_to(resolved(source)) or not _same_source(bound_source(python), source)): raise RuntimeError('isolated import or editable metadata did not match %s:\n%s' % (source, _tail(result))) except (OSError, ValueError, subprocess.SubprocessError, RuntimeError) as error: @@ -124,17 +132,18 @@ def _bind(uv, python, source, run): (python, error, uv_tail)) from error +@plain_errors def pending(machine, source): """The environments (E, then T if separate) whose editable install points elsewhere.""" - source = Path(source).resolve() + source = Path(plain(resolved(source))) result, seen = [], set() for key in ('python', 'comfy_python'): value = machine.get(key) if not value: continue - python = Path(value).absolute() + python = Path(plain(io_path(value).absolute())) identity = os.path.normcase(os.path.abspath(python)) - if identity in seen or not python.is_file(): + if identity in seen or not io_path(python).is_file(): continue seen.add(identity) old = bound_source(python) @@ -143,22 +152,36 @@ def pending(machine, source): return result +@plain_errors def rebind(engine_root, machine, source, *, uv=None, run=subprocess.run): """Rebind E/T, verify fresh imports, then atomically record the new source. A failure restores every attempted environment whose previous source still exists, including a failed uv install that may have partially replaced it. """ - engine_root, source = Path(engine_root), Path(source).resolve() + engine_root = Path(plain(io_path(checked_real_path(engine_root)))) + source = Path(plain(resolved(checked_real_path(source)))) version = source_version(source) environments = pending(machine, source) if environments: try: - uv = Path(uv) if uv is not None else _uv(engine_root, machine) - if not uv.is_file(): + uv = Path(plain(uv)) if uv is not None else _uv(engine_root, machine) + if not io_path(uv).is_file(): raise RuntimeError('The engine\'s uv is missing: ' + str(uv)) except RuntimeError as error: raise RuntimeError('%s\nEnvironments: %s' % (error, ', '.join(str(py) for py, _ in environments))) from error + shown = str + if windows(): + from . import launch_view, processes + view = launch_view.for_engine(engine_root) + if view.used: + runner = processes.run if run is subprocess.run else run + def viewed_run(command, **kwargs): + command, environment, _ = launch_view.execution(command, os.environ, None, view) + return runner(command, env=environment, **kwargs) + run = viewed_run + # A failure quotes what the tools printed: the same files, in the folder the user knows. + shown = lambda text: launch_view.text_back(text, view) updated = dict(machine, source=str(source), engine_version=version) attempted = [] try: @@ -172,12 +195,12 @@ def rebind(engine_root, machine, source, *, uv=None, run=subprocess.run): for python, old in reversed(attempted): # Even unchanged direct_url metadata cannot prove an interrupted uv # install left the finder and console scripts intact. - if old is None or not old.is_dir(): + if old is None or not io_path(old).is_dir(): rollback.append('Cannot restore %s: previous source is missing or unknown (%s).' % (python, old)) continue try: - _bind(uv, python, old.resolve(), run) + _bind(uv, python, Path(plain(resolved(old))), run) except Exception as restore_error: rollback.append('Cannot restore %s: %s' % (python, restore_error)) - raise RuntimeError(str(error) + ('\n' + '\n'.join(rollback) if rollback else '')) from error + raise RuntimeError(shown(str(error) + ('\n' + '\n'.join(rollback) if rollback else ''))) from error return updated diff --git a/freevideo_engine/execution_paths.py b/freevideo_engine/execution_paths.py new file mode 100644 index 00000000..813fc397 --- /dev/null +++ b/freevideo_engine/execution_paths.py @@ -0,0 +1,96 @@ +"""Short installation paths only at native-program and child-process boundaries.""" +import os +from pathlib import PurePath +import subprocess + +from . import launch_view + + +def execution_path(path): + """Keep the original object without a runtime view; never touch the disk.""" + view = launch_view.current() + if view is None: + return path + moved = launch_view.value(path, view) + return type(path)(moved) if moved is not path and isinstance(path, PurePath) else moved + + +def execution_environment(env): + """Environment for this process's compilers and Python children, not receipts.""" + view = launch_view.current(env) or launch_view.current() + return env if view is None else launch_view.execution((), env, None, view)[1] + + +def _execution(command, kwargs): + view = launch_view.current(kwargs.get('env')) or launch_view.current() + if view is None: + return command, kwargs, None + # CreateProcess cannot use a long cwd even when its caller supports long paths. + command, env, cwd = launch_view.execution( + command, kwargs.get('env'), kwargs.get('cwd') or os.getcwd(), view) + return command, dict(kwargs, env=env, cwd=cwd), view + + +def _text_back(value, view): + return launch_view.text_back(value, view) if view is not None and isinstance(value, str) else value + + +def _call(function, command, kwargs): + original = command + command, kwargs, view = _execution(command, kwargs) + try: + result = function(command, **kwargs) + except subprocess.CalledProcessError as error: + if view is not None: + error.cmd = original + error.output = _text_back(error.output, view) + error.stderr = _text_back(error.stderr, view) + raise + if view is not None and isinstance(result, subprocess.CompletedProcess): + # Tool output and receipts keep the user's real paths. + result.args = original + result.stdout = _text_back(result.stdout, view) + result.stderr = _text_back(result.stderr, view) + return _text_back(result, view) + + +def run(command, **kwargs): + # Preserve startupinfo, creationflags and detached lifetimes of native helpers. + return _call(subprocess.run, command, kwargs) + + +def popen(command, **kwargs): + command, kwargs, _ = _execution(command, kwargs) + return subprocess.Popen(command, **kwargs) + + +def check_output(command, **kwargs): + return _call(subprocess.check_output, command, kwargs) + + +def call(function, command, **kwargs): + """Run a caller's own spawn function with the same paths in and real paths back.""" + return _call(function, command, kwargs) + + +# Run as "python -c": import the engine, leave no trace of that import, then run the script as +# python would have. Arguments: the engine's source folder, the script, the script's own arguments. +_ENGINE_FIRST = ( + "import os,runpy,sys;source=sys.argv.pop(1);sys.path.insert(0,source);import freevideo_engine;" + "sys.path.remove(source);" + "[sys.modules.pop(name) for name in list(sys.modules) if name.partition('.')[0]=='freevideo_engine'];" + "del sys.argv[0];folder=os.path.dirname(sys.argv[0]);" + "sys.path[0:1]=[folder] if sys.path[:1]==[''] else [folder]+sys.path[:1];" + "runpy.run_path(sys.argv[0],run_name='__main__')") + + +def engine_first(command, source): + """A script's command that imports the engine before the script runs. + + Where the system's long-path support is on, a program is long-path aware from its first + line, and a library it loads by a resolved path fails until the engine's import has put + library loading through the short folder. A script that imports such a library before + the engine, as ComfyUI's main does, is started this way. + """ + entry = next(index for index, item in enumerate(command) if str(item).endswith('.py')) + return [*command[:entry], '-c', _ENGINE_FIRST, str(source), *command[entry:]] diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index 8906e34c..f2b69027 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -4,6 +4,7 @@ import re import sys +from .long_paths import io_path, plain_text from .diagnostics import Redactor @@ -24,6 +25,14 @@ def redacted_launcher_error(value, replacements=()): return redactor.text(value) +def display_launcher_error(value, *, zh=False, replacements=()): + """UI text only; diagnostics retain redacted_launcher_error's full cause.""" + from .offline_errors import alias_failure, guidance + if alias_failure(value): + return guidance('runtime-alias', zh) + return redacted_launcher_error(value, replacements) + + def setup_command_failure(label, log, exit_code): """Keep the complete failed step, with required GPU failures before metadata.""" log = Path(log) @@ -198,6 +207,10 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep """ if not value: return dict(title='', detail='', action='', kind='') + from .offline_errors import alias_failure, guidance + if alias_failure(value): + return dict(title=guidance('title', zh), detail=guidance('runtime-alias', zh), + action='', kind='runtime-alias') text = str(value) summary = text.partition(STEP_OUTPUT)[0] from .comfy_launcher_runtime import OlderLauncher @@ -392,6 +405,7 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep def _read(path, limit, *, tail=False): + path = io_path(path) try: with path.open('rb') as stream: if tail: @@ -399,9 +413,9 @@ def _read(path, limit, *, tail=False): start = max(0, stream.tell() - limit) stream.seek(start) value = stream.read(limit).decode('utf-8', errors='replace') - return value.partition('\n')[2] if start and '\n' in value else value + return plain_text(value.partition('\n')[2] if start and '\n' in value else value) raw = stream.read(limit + 1) - return raw.decode('utf-8', errors='replace') if len(raw) <= limit else '' + return plain_text(raw.decode('utf-8', errors='replace')) if len(raw) <= limit else '' except OSError: return '' diff --git a/freevideo_engine/file_ops.py b/freevideo_engine/file_ops.py index 594f94ed..24950722 100644 --- a/freevideo_engine/file_ops.py +++ b/freevideo_engine/file_ops.py @@ -2,13 +2,14 @@ from pathlib import Path import time from .system import windows +from .long_paths import io_path, plain def publish(source, destination): - source, destination = Path(source), Path(destination) + source, destination = io_path(source), io_path(destination) for attempt in range(8): if destination.exists() or destination.is_symlink(): - raise FileExistsError('Destination already exists; both files retained: ' + str(destination)) + raise FileExistsError('Destination already exists; both files retained: ' + plain(destination)) try: source.rename(destination) return diff --git a/freevideo_engine/generate.py b/freevideo_engine/generate.py index 825b2034..55b42f64 100644 --- a/freevideo_engine/generate.py +++ b/freevideo_engine/generate.py @@ -55,7 +55,7 @@ def child(command, env, descriptor, log, *, ram_budget_bytes=None, gpu=None, on_ on_start(process.pid) if gpu: from .monitoring import Monitor - monitor = Monitor(log.with_suffix('.gpu.csv'), device=gpu).start() + monitor = Monitor(log.with_suffix('.gpu.csv'), device=gpu, pid=process.pid).start() while process.poll() is None: if memory: sample = memory.sample(process.pid) diff --git a/freevideo_engine/geometry.py b/freevideo_engine/geometry.py index d51a748f..a1014d64 100644 --- a/freevideo_engine/geometry.py +++ b/freevideo_engine/geometry.py @@ -29,10 +29,14 @@ def sampler_for_canvas(sampler, width, height): Engine.sample supplies no_grad. Never edit the upstream file or mutate its module globals: two Engine instances can use different canvases safely. + The upstream sampler builds its schedulers by name; it gets the engine's, + whose step grid is the one the modulation tables were made with. """ + from .step_grid import scheduler original = inspect.unwrap(sampler) bound = types.FunctionType(original.__code__, - dict(original.__globals__, LATENT_H=height // 16, LATENT_W=width // 16), + dict(original.__globals__, LATENT_H=height // 16, LATENT_W=width // 16, + MiniMaxH3Scheduler=scheduler), original.__name__, original.__defaults__, original.__closure__) bound.__kwdefaults__ = original.__kwdefaults__ return bound diff --git a/freevideo_engine/install.py b/freevideo_engine/install.py index ae2f0284..2cd38902 100644 --- a/freevideo_engine/install.py +++ b/freevideo_engine/install.py @@ -6,6 +6,7 @@ import subprocess import sys import time +from . import execution_paths from .paths import vdn_root from . import network @@ -15,21 +16,21 @@ def install(root=None, *, packages=False): spec = dependencies['vdn'] root = (root or vdn_root()).expanduser().resolve() network.clone(root, spec['url'], spec['commit']) - head = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=root, text=True).strip() - dirty = subprocess.check_output(['git', 'status', '--porcelain', '--untracked-files=no'], cwd=root, text=True) + 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) 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) if packages: # Install Torch explicitly first; preserve that CUDA build here. if not os.environ.get('FREEVIDEO_UV') and importlib.util.find_spec('pip') is None: - subprocess.run([sys.executable, '-m', 'ensurepip', '--upgrade'], check=True) + execution_paths.run([sys.executable, '-m', 'ensurepip', '--upgrade'], check=True) def install_packages(env, source): # The managed installer already has uv, including native SOCKS # 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 = subprocess.run([*command, '-e', str(diffusers), '--no-deps'], + result = execution_paths.run([*command, '-e', str(diffusers), '--no-deps'], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) print(result.stdout, flush=True) if result.returncode: @@ -54,15 +55,15 @@ def prepare_diffusers(root, dependencies): if len(patches) != 2: raise RuntimeError('Expected the two pinned VDN Diffusers patches') for path in patches: - subprocess.run(['git', 'apply', '--index', str(path)], cwd=checkout, check=True) - tree = subprocess.check_output(['git', 'write-tree'], cwd=checkout, text=True).strip() + 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() if tree != spec['patched_tree']: raise RuntimeError('Patched Diffusers source tree does not match the tested dependency') - subprocess.run(['git', '-c', 'user.name=VDN Engine dependency setup', '-c', 'user.email=engine@localhost', + 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 = subprocess.check_output(['git', 'rev-parse', 'HEAD^{tree}'], cwd=target, text=True).strip() - dirty = subprocess.check_output(['git', 'status', '--porcelain', '--untracked-files=no'], cwd=target, text=True) + 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) 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/install_disk.py b/freevideo_engine/install_disk.py index 9bc360cc..b4567449 100644 --- a/freevideo_engine/install_disk.py +++ b/freevideo_engine/install_disk.py @@ -3,6 +3,7 @@ import hashlib import json +from .long_paths import io_path, resolved from . import disk_space GiB = 1 << 30 @@ -62,11 +63,12 @@ def frontend_ready(root, frontend): def frontend_in_place(root, comfy): """The launcher's ComfyUI environment for this ComfyUI exists: setup repairs or updates it in place.""" try: - record = json.loads((Path(root) / 'launcher' / 'comfy-host.json').read_text(encoding='utf-8')) - environment = Path(record['environment']) + root, comfy = io_path(root), io_path(comfy) + record = json.loads((root / 'launcher' / 'comfy-host.json').read_text(encoding='utf-8')) + environment = io_path(record['environment']) identity = json.loads((environment / 'freevideo-host.json').read_text(encoding='utf-8')) - return (environment.resolve().parent == (Path(root) / 'envs').resolve() and Path(record['python']).is_file() - and Path(identity['comfy']).resolve() == Path(comfy).resolve()) + return (environment.resolve().parent == (root / 'envs').resolve() and io_path(record['python']).is_file() + and resolved(identity['comfy']) == comfy.resolve()) except (OSError, ValueError, KeyError, TypeError): return False diff --git a/freevideo_engine/installation_cleanup.py b/freevideo_engine/installation_cleanup.py index 0a2d936c..bb88a0bd 100644 --- a/freevideo_engine/installation_cleanup.py +++ b/freevideo_engine/installation_cleanup.py @@ -11,11 +11,25 @@ from .locking import runtime_lock from .storage import fingerprint +from .long_paths import io_path, resolved, plain +from .runtime_alias import is_junction, checked_real_path, AliasUnavailable + + +def root_path(root, path): + """Resolve a selected root, keeping candidate redirects visible to lstat.""" + chosen, path = io_path(checked_real_path(root)).absolute(), io_path(checked_real_path(path)).absolute() + root = resolved(chosen) + try: + path = root / path.relative_to(chosen) + except ValueError: + pass # A caller may already supply the physical candidate path. + return root, path def local_path(root, path): """Refuse redirected cache roots, including Windows junctions.""" try: + root, path = root_path(root, path) path.relative_to(root) current = path while current != root: @@ -30,6 +44,9 @@ def local_path(root, path): def cache_bytes(path): """Estimate reclaimable storage, counting cache-only hardlinks just once.""" + selected = Path(path) + path = checked_real_path(path) + path = resolved(path) if path != selected else io_path(path) inodes = {} def unreadable(error): raise error @@ -55,16 +72,23 @@ def unreadable(error): def uv_path(root): + selected = Path(root) + root = checked_real_path(root) + root = resolved(root) if root != selected else io_path(root) relative = ('uv.exe' if os.name == 'nt' else 'uv' if sys.platform == 'darwin' else 'uv-x86_64-unknown-linux-gnu/uv') path = root / 'tools' / relative - return path if local_path(root, path) and path.is_file() else None + return Path(plain(path)) if local_path(root, path) and path.is_file() else None def scan(root): + try: + root = checked_real_path(root) + except AliasUnavailable: + return dict(root=plain(root), cache=None, wheels=[], bytes=0) from .torch_download import catalog - root = Path(root).absolute().resolve() - plan = dict(root=str(root), cache=None, wheels=[], bytes=0) + root = resolved(root) + plan = dict(root=plain(root), cache=None, wheels=[], bytes=0) cache = root / 'downloads' / 'uv-cache' if uv_path(root) and local_path(root, cache) and cache.is_dir(): try: @@ -73,7 +97,7 @@ def scan(root): pass # Uncertain ownership is not permission to delete it. else: if size: - plan['cache'] = dict(bytes=size, path=str(cache)) + plan['cache'] = dict(bytes=size, path=plain(cache)) plan['bytes'] += size for row in catalog(): path = root / 'downloads' / 'torch-wheels' / row['filename'] @@ -85,7 +109,7 @@ def scan(root): stamp = fingerprint(path) if 'change_time_ns' in stamp and stamp['change_time_ns'] is None: continue - plan['wheels'].append(dict(path=str(path), stamp=stamp, bytes=info.st_size)) + plan['wheels'].append(dict(path=plain(path), stamp=stamp, bytes=info.st_size)) plan['bytes'] += info.st_size return plan @@ -93,7 +117,14 @@ def scan(root): def clean(plan): from . import processes from .torch_download import catalog - root = Path(plan['root']) + # Authenticate the entire saved plan before taking leases or changing files. + selected = checked_real_path(plan['root']) + cache = plan.get('cache') + cache_path = checked_real_path(cache['path']) if cache else None + wheels = [(row, checked_real_path(row['path'])) for row in plan['wheels']] + root = resolved(selected) + cache_path = root_path(selected, cache_path)[1] if cache else None + wheels = [(row, root_path(selected, path)[1]) for row, path in wheels] released = skipped = 0 # Other launchers and CLI installers also hold these leases. Do not race # package installation, generation or a resumed download from another UI. @@ -103,21 +134,20 @@ def clean(plan): cache = plan.get('cache') if cache: path, uv = root / 'downloads' / 'uv-cache', uv_path(root) - if str(path) != cache['path'] or not uv or not local_path(root, path): + if plain(path) != plain(cache_path) or not uv or not local_path(root, path): raise ValueError('Package cache changed; check storage again.') before = cache_bytes(path) # Recheck redirects before invoking uv. - result = processes.run([str(uv), 'cache', 'clean', '--cache-dir', str(path)], + result = processes.run([plain(uv), 'cache', 'clean', '--cache-dir', plain(path)], capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=120) if result.returncode: raise RuntimeError(result.stderr or 'Package cache cleanup did not complete.') after = cache_bytes(path) if path.exists() else 0 released += max(0, before - after) - allowed = {str(root / 'downloads' / 'torch-wheels' / row['filename']) + allowed = {plain(root / 'downloads' / 'torch-wheels' / row['filename']) for row in catalog()} - for row in plan['wheels']: - path = Path(row['path']) + for row, path in wheels: try: - if (str(path) not in allowed or not local_path(root, path) or path.stat().st_nlink != 1 + if (plain(path) not in allowed or not local_path(root, path) or path.stat().st_nlink != 1 or fingerprint(path) != row['stamp']): skipped += 1 continue @@ -141,7 +171,13 @@ def busy(self): def start(self, root, *, remove=False): if self.busy: return - root = str(Path(root).absolute().resolve()) + try: + root = checked_real_path(root) + except AliasUnavailable as error: + from .failure_details import redacted_launcher_error + self.state = dict(status='error', bytes=0, error=redacted_launcher_error(str(error))) + return + root = plain(resolved(root)) if remove and (not self.plan or self.plan['root'] != root): raise ValueError('Check storage in the selected installation first.') plan = self.plan diff --git a/freevideo_engine/installation_diagnostics.py b/freevideo_engine/installation_diagnostics.py index d20479df..b4ea83fc 100644 --- a/freevideo_engine/installation_diagnostics.py +++ b/freevideo_engine/installation_diagnostics.py @@ -2,20 +2,22 @@ import json from pathlib import Path +from .long_paths import io_path, resolved, plain_data, plain_errors from . import diagnostic_resources as d +@plain_errors def _json(path, limit=2*1024*1024): - with path.open('rb') as stream: + with io_path(path).open('rb') as stream: raw = stream.read(limit+1) - return d.mapping(json.loads(raw)) if len(raw) <= limit else {} + return plain_data(d.mapping(json.loads(raw))) if len(raw) <= limit else {} def collect(root, since): """Never pick a different/old installation merely because it is the latest.""" try: - root = Path(root).resolve() - run = Path(_json(root/'machine.json')['setup_run']).resolve() + root = resolved(root) + run = resolved(_json(root/'machine.json')['setup_run']) if run.parent != (root/'setup-runs').resolve() or (run/'plan.json').stat().st_mtime < since-1: return {} state = _json(run/'status.json') @@ -59,7 +61,7 @@ def collect(root, since): ('source', 'route', 'error_type', 'returncode', 'summary') if key in item}) except OSError: continue - return dict(state=state, samples=rows, package_attempts=package_attempts[-16:]) + return plain_data(dict(state=state, samples=rows, package_attempts=package_attempts[-16:])) except (OSError, ValueError, KeyError, TypeError): return {} diff --git a/freevideo_engine/launch_view.py b/freevideo_engine/launch_view.py new file mode 100644 index 00000000..dc490683 --- /dev/null +++ b/freevideo_engine/launch_view.py @@ -0,0 +1,174 @@ +"""Children of an offline runtime start from its short root. + +Where Windows limits path length, runtime_alias gives the runtime folder a short +junction. The launcher marks a child's environment with that view, and +processes.popen then spells the command, the environment and the working folder +through it, for that child and for every process it starts in turn. Nothing +else changes: settings, receipts and everything on screen keep the real paths. +""" +from collections import namedtuple +import json +import os +from pathlib import Path +import re + +from .long_paths import io_path, plain + +VIEW_ENV = 'FREEVIDEO_RUNTIME_VIEW' + + +class View(namedtuple('View', 'real root rules', defaults=(os.path,))): + """real: the runtime folder; root: what children see; rules: os.path or ntpath.""" + __slots__ = () + + @property + def used(self): + return _key(self.real, self.rules) != _key(self.root, self.rules) + + +def _key(path, rules): + return rules.normcase(rules.normpath(os.fspath(path))) + + +def _within(path, root, rules): + path, root = _key(path, rules), _key(root, rules) + if not rules.isabs(path): + return False + try: + return rules.commonpath((path, root)) == root + except ValueError: # Another drive. + return False + + +def _moved(path, source, target, rules): + relative = rules.relpath(rules.normpath(os.fspath(path)), os.fspath(source)) + return rules.normpath(rules.join(os.fspath(target), relative)) + + +def value(item, view): + """A path inside the runtime, spelled under the view; anything else as given.""" + if not isinstance(item, (str, os.PathLike)): + return item + # What resolving a deep file returns on Windows names the same file. + path = plain(item, windows=view.rules.__name__ == 'ntpath') + if not _within(path, view.real, view.rules): + return item + return _moved(path, view.real, view.root, view.rules) + + +def view_for(real_root) -> View: + """May create the junction; AliasUnavailable when one is needed and cannot be made.""" + from . import runtime_alias + real = Path(plain(real_root)) + return View(real, runtime_alias.runtime_view(real)) + + +def for_engine(engine_root) -> View: + """The view of the offline runtime around this engine folder, if it needs one.""" + from . import runtime_alias + engine = Path(plain(engine_root)) + root = engine.parent + # The first two tests read nothing from disk wherever no junction is needed. + # The third asks by the extended path: where a junction is needed, the plain + # one is not found beyond 259 characters. + if (engine.name.casefold() != 'engine' or not runtime_alias.needs_view(root) + or not io_path(root / 'portable.json').is_file()): + return View(engine, engine) + return view_for(root) + + +def for_file(path): + """The view of the offline runtime that holds this file, or None. + + The file belongs to the nearest folder above it that has a runtime's + manifest, whichever installation the launcher itself runs from or shows. + """ + from . import runtime_alias + path = Path(plain(path)) + for root in path.parents: + try: + if runtime_alias.needs_view(root) and io_path(root / 'portable.json').is_file(): + return view_for(root) + except OSError: # Includes a junction that cannot be made: opened as it is. + return None + return None + + +def entry_lines(view): + """For the node's entry file, which runs before any module of ours can be + imported: the source its receipt names, taken from the view the process has.""" + if not view.used: + return '' + return ("import os as _os\n" + "_view = _os.environ.get(%r, '').split('|')\n" + "if len(_view) == 2 and _source.is_relative_to(_view[1]):\n" + " _source = _Path(_view[0]) / _source.relative_to(_view[1])\n") % VIEW_ENV + + +def current(environ=None): + parts = (os.environ if environ is None else environ).get(VIEW_ENV, '').split('|') + if len(parts) != 2 or not all(os.path.isabs(part) for part in parts): + return None + return View(Path(parts[1]), Path(parts[0])) + + +def environment_view(env, view): + """Mark a child's environment; processes.popen does the rest.""" + return dict(env, **{VIEW_ENV: '%s|%s' % (view.root, view.real)}) if view.used else env + + +def execution(command, env, cwd, view): + """One child's command, environment and folder, spelled through the view.""" + separator = ';' if view.rules.__name__ == 'ntpath' else ':' + + def argument(item): + if isinstance(item, str) and item.startswith('--') and '=' in item: + flag, path = item.split('=', 1) + return flag + '=' + value(path, view) + return value(item, view) + + def variable(text): + if separator not in text: + return value(text, view) + return separator.join(value(part, view) for part in text.split(separator)) + + environment = {key: variable(text) for key, text in (os.environ if env is None else env).items()} + environment[VIEW_ENV] = '%s|%s' % (view.root, view.real) + return [argument(item) for item in command], environment, value(cwd, view) + + +def text_back(text, view): + """What a child printed, with the real folder in place of the view.""" + if not view.used: + return text + windows = view.rules.__name__ == 'ntpath' + # A JSON string doubles each backslash; the replacement keeps that form. + pattern = r'[/\\]+'.join(re.escape(part) for part in re.split(r'[/\\]+', str(view.root))) + real = str(view.real) + + def replace(match): + return real.replace('\\', '\\\\') if '\\\\' in match.group(0) else real + + return re.sub(r'(? 86400: + if not is_junction(old) and old.is_dir() and time.time() - old.stat().st_mtime > 86400: shutil.rmtree(old, ignore_errors=True) except OSError: pass path = parent / ('%d-%s' % (os.getpid(), secrets.token_hex(4))) path.mkdir() - return path + return Path(plain(path)) def remove(path): - if path: + try: + path = io_path(checked_real_path(path)) if path else path + except AliasUnavailable: + return + if path and not is_junction(path) and not is_junction(Path(path).parent): shutil.rmtree(path, ignore_errors=True) def write_status(path, value): - save(Path(path) / 'status.json', dict(value, updated_at=time.time())) + save(io_path(checked_real_path(path)) / 'status.json', dict(value, updated_at=time.time())) def read_status(path, *, max_age=FRESH_SECONDS): """A recent launcher status, or None when no launcher is listening.""" try: - value = json.loads((Path(path) / 'status.json').read_text(encoding='utf-8')) + value = json.loads((io_path(checked_real_path(path)) / 'status.json').read_text(encoding='utf-8')) if isinstance(value, dict) and abs(time.time() - float(value.get('updated_at', 0))) <= max_age: return value except (OSError, ValueError, TypeError): @@ -56,11 +68,14 @@ def read_status(path, *, max_age=FRESH_SECONDS): def request(path, action='update'): if action not in ('update', 'cancel'): raise ValueError('Unknown launcher request') - save(Path(path) / 'request.json', dict(action=action, at=time.time())) + save(io_path(checked_real_path(path)) / 'request.json', dict(action=action, at=time.time())) def take_request(path): - file = Path(path) / 'request.json' + try: + file = io_path(checked_real_path(path)) / 'request.json' + except AliasUnavailable: + return None try: value = json.loads(file.read_text(encoding='utf-8')) except FileNotFoundError: diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index 41cb23b7..c9905cfb 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -2,6 +2,7 @@ import json import os from pathlib import Path +from .long_paths import io_path, resolved, plain, plain_text, plain_data import sys import threading import time @@ -138,7 +139,7 @@ def __init__(self, source=None, *, controller=None, store=None, updater=None, sm except OSError: self.bridge = None if self.form['environment_method'] == 'manual' and self.form['offline_runtime'] and not controller: - root = Path(self.form['offline_runtime']) + root = io_path(self.form['offline_runtime']) if (root / 'portable.json').is_file(): self.activate_offline(root) self.inspect_compatibility() @@ -148,13 +149,21 @@ def t(self, en, zh): def engine_root(self): if self.form['engine'].strip(): - return Path(self.form['engine']).expanduser().resolve() + return Path(plain(resolved(Path(self.form['engine']).expanduser()))) if self.form['new_comfy']: - return Path(self.form['destination']).expanduser().resolve() / 'FreeVideo-engine' + return Path(plain(resolved(Path(self.form['destination']).expanduser()))) / 'FreeVideo-engine' if self.form['comfy'].strip(): return Path(layout(self.form['comfy'])['root']) / 'FreeVideo-engine' raise ValueError(self.t('Choose your installation folder first.', '请先选择安装位置。')) + def installation_engine(self): + """The engine folder of the installation in use; None before one is chosen.""" + selected = self.selected or self.controller.selection or {} + engine = selected.get('engine') + if engine is None and getattr(self.controller, 'fixed_environment', False) is True: + engine = self.controller.root / 'engine' + return engine + def persist(self): self.store.write(self.form, self.language, self.selected, self.saved_setup) @@ -222,14 +231,14 @@ def disk_view(self, row, failure): def add_folder(self, folder): from .local_models import library_roots # Saved folders that were deleted since are dropped instead of blocking the new one. - kept = [p for p in self.form['model_dirs'] if Path(p).expanduser().is_dir()] + kept = [p for p in self.form['model_dirs'] if io_path(Path(p).expanduser()).is_dir()] self.edit('model_dirs', library_roots(kept + [folder])) def remove_folder(self, index): removed = self.form['model_dirs'][index] self.edit('model_dirs', [p for i, p in enumerate(self.form['model_dirs']) if i != index]) self.edit('offline_models', [p for p in self.form['offline_models'] - if Path(p) / 'models' != Path(removed)]) + if io_path(p) / 'models' != io_path(removed)]) def clear_runtime(self): # Detach it from this plan; imported files remain available for reuse. @@ -244,7 +253,7 @@ def import_packages(self, paths): if not paths: return self.repair_request = None - destination = (Path(self.form['destination']).expanduser().resolve() if self.form['new_comfy'] + destination = (Path(plain(resolved(Path(self.form['destination']).expanduser()))) if self.form['new_comfy'] else self.engine_root().parent) self.importer.start(paths, destination) self.import_retry = ('import', list(paths), destination) @@ -266,12 +275,13 @@ def activate_offline(self, root): from .offline_packages import check_runtime_platform check_runtime_platform() from .portable_launcher import PortableController + root = Path(plain(root)) controller = PortableController(root, bundled=self.source) controller.restore({'url': self.form['url']}) self.controller.close() self.repair_request = None self.controller = controller - self.form['offline_runtime'] = str(root) + self.form['offline_runtime'] = plain(root) self.remember(controller.selection) self.page = 'launcher' self.persist() @@ -384,7 +394,7 @@ def action(self, name, accepted=False): if self.page == 'launcher': self.repair_request = None if self.page == 'launcher' and getattr(self.controller, 'fixed_environment', False) is True: - consent = self.controller.root / 'engine/portable-consent.json' + consent = io_path(self.controller.root) / 'engine/portable-consent.json' if not consent.exists(): if not accepted: raise ValueError(self.t('Accept the installation plan and licenses.', '请先同意安装计划及许可证。')) @@ -625,11 +635,11 @@ def inspect_compatibility(self): from .compatibility import check_installation try: root = self.engine_root() - if (root / 'machine.json').is_file(): + if (io_path(root) / 'machine.json').is_file(): self.compatibility = check_installation(root) if self.compatibility.get('notice'): - self.notice = self.t('Compatibility settings were enabled after an interrupted generation. You can change them in Settings.', - '上次生成中断后已开启兼容性设置,可在设置中调整或关闭。') + from .compatibility_copy import notice_text + self.notice = notice_text(self.compatibility, self.language.startswith('zh')) except (OSError, ValueError, RuntimeError): self.compatibility = dict(available=False, level=0, automatic=False) @@ -639,6 +649,11 @@ def set_compatibility(self, level, automatic): Store(root).set(installed_identity(root), level, automatic) self.inspect_compatibility() + def set_compatibility_level(self, level): + # Only moving the slider to Off clears automatic activation. The + # independent switch can still enable it again while the level is 0. + self.set_compatibility(level, False if level == 0 else self.compatibility['automatic']) + def dismiss_notice(self): from .compatibility import Store, installed_identity if self.compatibility.get('notice'): @@ -651,10 +666,29 @@ def full_log(self): from .failure_details import redacted_launcher_error if self.tail.path is None: return self.tail.text - raw, _ = read_complete(self.tail.path) - return redacted_launcher_error(raw.decode('utf-8-sig', errors='replace'), + raw, _ = read_complete(io_path(self.tail.path)) + return redacted_launcher_error(plain_text(raw.decode('utf-8-sig', errors='replace')), [(self.token, '')] if self.token else []) + def _raw_error(self): + row = self.controller.state + errors = [self.error, self.browser_error, self.page_error, row.get('error', ''), *row.get('errors', [])] + shortcut = row.get('shortcut') or {} + if shortcut.get('status') == 'failed': + errors.append(shortcut.get('error_zh' if self.language.startswith('zh') else 'error') or shortcut.get('error', '')) + return plain_text('\n'.join(str(e) for e in errors if e)) + + def diagnostic_error(self): + """Full retained causes, including alias failures shown in storage settings.""" + from .failure_details import redacted_launcher_error + from .offline_errors import alias_failure + raw = self._raw_error() + for state in (self.cleaner.state, self.importer.state, self.upgrade.state): + value = state.get('error') or '' + if alias_failure(value) and value not in raw: + raw += ('\n' if raw else '') + value + return redacted_launcher_error(raw, [(self.token, '')] if self.token else []) + def export_report(self, output): if self.report['status'] == 'running': return @@ -662,7 +696,7 @@ def export_report(self, output): root = self.engine_root() # Capture the failure and selected logs before another action changes # the screen. Collection does not need a working Python/GPU runtime. - notes = self.snapshot()['error'] + notes = self.diagnostic_error() logs = tuple(path for _, path in self.controller.terminal_sources()) replacements = [(self.token, '')] if self.token else [] self.report = dict(status='running', path=str(output), error='') @@ -799,7 +833,7 @@ def poll(): def _installation_ready(self): try: - return json.loads((self.engine_root() / 'machine.json').read_text(encoding='utf-8')).get('ready') is True + return json.loads((io_path(self.engine_root()) / 'machine.json').read_text(encoding='utf-8')).get('ready') is True except (OSError, ValueError, AttributeError): return False @@ -941,7 +975,7 @@ def source_stamp(self, source): if self.installed_versions is None: self.installed_versions = {} if str(source) not in self.installed_versions: - package = Path(source) / 'freevideo_engine' + package = io_path(source) / 'freevideo_engine' try: identity = json.loads((package / 'build-identity.json').read_text(encoding='utf-8')) stamp = str(identity['version']), int(identity['built_at']) @@ -962,10 +996,10 @@ def stamped(source, cached): if not source: return (None, {}) try: - changed = (Path(source) / 'freevideo_engine' / 'build-identity.json').stat().st_mtime_ns + changed = (io_path(source) / 'freevideo_engine' / 'build-identity.json').stat().st_mtime_ns except OSError: changed = None - key = (str(source), changed) + key = (plain(source), changed) if cached is not None and cached[0] == key: return cached from .desktop_runtime import source_release @@ -980,7 +1014,7 @@ def installed_product_version(self): """The product version of the engine the selected installation runs, if it says.""" source = (self.selected or {}).get('source') try: - value = json.loads((Path(source) / 'freevideo_engine/release_notes.json').read_text(encoding='utf-8')) + value = json.loads((io_path(source) / 'freevideo_engine/release_notes.json').read_text(encoding='utf-8')) except (OSError, ValueError, TypeError): return None return value.get('product_version') if isinstance(value, dict) else None @@ -1201,7 +1235,7 @@ def archive_offer(self): return None def identity(path): try: - info = os.stat(path) + info = os.stat(io_path(path)) return path, info.st_size, info.st_mtime_ns except OSError: return path, None, None @@ -1263,7 +1297,7 @@ def licenses(self, row): for value in rows or [MODEL]] def snapshot(self): - from .failure_details import REPAIR_KINDS, launcher_failure, redacted_launcher_error + from .failure_details import REPAIR_KINDS, launcher_failure, redacted_launcher_error, display_launcher_error from .launcher_copy import display, progress_view, source_name zh = self.language.startswith('zh') row = self.controller.state @@ -1286,11 +1320,8 @@ def snapshot(self): if state.get('detail'): label += ' · ' + display(state['detail'], zh) overall = dict(label=label, done=state.get('done'), total=state.get('total')) - errors = [self.error, self.browser_error, self.page_error, row.get('error', ''), *row.get('errors', [])] shortcut = row.get('shortcut') or {} - if shortcut.get('status') == 'failed': - errors.append(shortcut.get('error_zh' if zh else 'error') or shortcut.get('error', '')) - raw_error = '\n'.join(str(e) for e in errors if e) + raw_error = self._raw_error() error = redacted_launcher_error(raw_error, [(self.token, '')] if self.token else []) by_id = {r['id']: r for r in self.model_groups} @@ -1382,6 +1413,12 @@ def snapshot(self): if line.startswith(MISMATCH_PATH + ': ') and line[len(MISMATCH_PATH) + 2:]: failure['path'] = line[len(MISMATCH_PATH) + 2:] break + reasons = self.importer.state.get('write_reasons') or [] + if (self.importer.state.get('status') == 'error' and reasons + and self.error == self.importer.state.get('error') and failure.get('kind') != 'runtime-alias'): + from .offline_errors import guidance + failure = dict(title=guidance('title', zh), + detail=guidance(reasons[0], zh), action='', kind='offline-write') disk = self.disk_view(row, failure) if disk and str(failure.get('kind', '')).startswith('disk'): failure = launcher_failure(error, zh=self.language.startswith('zh'), @@ -1394,14 +1431,21 @@ def snapshot(self): repair_offered = bool(error) and self.can_repair() and failure.get('kind') in REPAIR_KINDS update = dict(update, remind=update['remind'] and not error, with_repair=repair_offered and bool(update.get('engine'))) - return dict(sampling_cache_bytes=total_bytes(), version=__version__, + cleanup = dict(self.cleaner.state, busy=self.cleaner.busy) + from .offline_errors import alias_failure + if alias_failure(cleanup.get('error', '')): + cleanup['error'] = display_launcher_error(cleanup['error'], zh=zh) + 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) + 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=dict(self.cleaner.state, busy=self.cleaner.busy), + cleanup=cleanup, can_cleanup=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.updater and self.updater.busy or self.upgrade.active or self.model_switch), - model_upgrade=dict(self.upgrade.state, busy=self.upgrade.busy, switching=bool(self.model_switch)), + model_upgrade=dict(model_upgrade, switching=bool(self.model_switch)), offline=dict(progress_view(self.importer.state, zh), runtime=bool(self.form['offline_runtime']), runtime_supported=runtime_packages_supported(), models=len(self.form['offline_models']), guide=package_instructions(self.form['new_comfy'] and self.form['environment_method'] == 'manual' @@ -1429,7 +1473,7 @@ def snapshot(self): review_id=row.get('plan', {}).get('plan_id', ''), consent=self.t('I accept the installation plan and model / toolkit licenses.', '我同意安装计划及模型/工具包许可证。'), licenses=self.licenses(row), portable=False, needs_consent=bool(getattr(self.controller, 'fixed_environment', False) is True - and not (self.controller.root / 'engine/portable-consent.json').exists()), + and not (io_path(self.controller.root) / 'engine/portable-consent.json').exists()), review=dict(engine=str(row.get('selection', {}).get('engine', self.form['engine'])), replace_models=replace_models, - comfy=str(row.get('selection', {}).get('root', self.form['comfy'])))) + comfy=str(row.get('selection', {}).get('root', self.form['comfy']))))) diff --git a/freevideo_engine/launcher_settings.py b/freevideo_engine/launcher_settings.py index bb314cee..be5da6a6 100644 --- a/freevideo_engine/launcher_settings.py +++ b/freevideo_engine/launcher_settings.py @@ -6,6 +6,7 @@ import time from .monitoring import save +from .long_paths import io_path, plain_data TEXT = ('comfy', 'destination', 'engine', 'models', 'python', 'url', 'language', 'offline_runtime') SELECTION = ('root', 'engine', 'source', 'python', 'url') @@ -26,7 +27,7 @@ def default_language(): def sanitize(value): - value = value if isinstance(value, dict) else {} + value = plain_data(value) if isinstance(value, dict) else {} result = {k: value[k] for k in TEXT if isinstance(value.get(k), str)} for key in ('separate', 'new_comfy', 'sampling_caches'): if type(value.get(key)) is bool: @@ -61,7 +62,7 @@ def sanitize(value): def read(path): try: - return sanitize(json.loads(Path(path).read_text(encoding='utf-8'))) + return sanitize(json.loads(io_path(path).read_text(encoding='utf-8'))) except (OSError, ValueError): return {} diff --git a/freevideo_engine/launcher_terminal.py b/freevideo_engine/launcher_terminal.py index d8d78c20..05d112a3 100644 --- a/freevideo_engine/launcher_terminal.py +++ b/freevideo_engine/launcher_terminal.py @@ -4,6 +4,7 @@ from pathlib import Path from .terminal_ui import ESCAPES +from .long_paths import io_path, plain_text class Tail: @@ -39,7 +40,7 @@ def clear(self): self.discard_line = False if self.path is not None: try: - info = self.path.stat() + info = io_path(self.path).stat() self.identity = (info.st_dev, info.st_ino) self.offset = info.st_size except OSError: @@ -49,7 +50,7 @@ def read(self, final=False): if self.path is None: return '' try: - with self.path.open('rb') as stream: + with io_path(self.path).open('rb') as stream: import os info = os.fstat(stream.fileno()) identity = (info.st_dev, info.st_ino) @@ -86,7 +87,7 @@ def read(self, final=False): line = ' · '.join(str(event[k]) for k in ('label', 'detail') if event.get(k)) line = ESCAPES.sub('', line) line = ''.join(c for c in line if c.isprintable() or c == '\t') - output.append(self.redactor.text(line)) + output.append(self.redactor.text(plain_text(line))) if len(self.pending) > self.READ_BYTES: self.pending = '' self.discard_line = True diff --git a/freevideo_engine/launcher_update.py b/freevideo_engine/launcher_update.py index 839a599c..802ed835 100644 --- a/freevideo_engine/launcher_update.py +++ b/freevideo_engine/launcher_update.py @@ -16,8 +16,10 @@ import urllib.parse import urllib.request +from . import execution_paths from .diagnostics import Redactor from .monitoring import save +from .long_paths import io_path, resolved, plain, plain_text, plain_errors from . import disk_space REPOSITORY = 'FlashML-org/FreeVideo' @@ -90,7 +92,7 @@ def current_build(): if not getattr(sys, 'frozen', False): return None try: - return build_identity(json.loads((Path(sys._MEIPASS) / 'launcher-build.json').read_text(encoding='utf-8'))) + return build_identity(json.loads((io_path(sys._MEIPASS) / 'launcher-build.json').read_text(encoding='utf-8'))) except (OSError, ValueError, TypeError): return None @@ -226,9 +228,10 @@ def check(current, token=''): return candidate if newer(candidate, current) else None +@plain_errors def checksum(path): digest = hashlib.sha256() - with path.open('rb') as stream: + with io_path(path).open('rb') as stream: for block in iter(lambda: stream.read(256*1024), b''): digest.update(block) return digest.hexdigest() @@ -236,24 +239,27 @@ def checksum(path): def verified(path, asset): try: + path = io_path(path) return (path.is_file() and not path.is_symlink() and path.stat().st_size == asset['bytes'] and checksum(path) == asset['sha256']) except OSError: return False +@plain_errors def executable_path(root, candidate): filename = RELEASE_ASSETS[candidate['channel']][0] - return Path(root).absolute().resolve() / 'updates' / candidate['asset']['sha256'] / filename + return Path(plain(resolved(root))) / 'updates' / candidate['asset']['sha256'] / filename +@plain_errors def download(candidate, root, token='', *, progress=None, cancel=None): candidate = manifest(candidate) asset = candidate['asset'] target = executable_path(root, candidate) if verified(target, asset): return target - target.parent.mkdir(parents=True, exist_ok=True) + io_path(target.parent).mkdir(parents=True, exist_ok=True) # Each attempt owns one stage. Failed stages cannot be resumed, so discard # only ours; never touch another attempt, the original launcher or models. import tempfile @@ -264,8 +270,8 @@ def download(candidate, root, token='', *, progress=None, cancel=None): stage = None try: with open_download(API + '/releases/assets/' + str(asset['id']), token, binary=True) as response: - with tempfile.NamedTemporaryFile(prefix='download-', suffix='.partial', dir=target.parent, delete=False) as stream: - stage = Path(stream.name) + with tempfile.NamedTemporaryFile(prefix='download-', suffix='.partial', dir=io_path(target.parent), delete=False) as stream: + stage = Path(plain(stream.name)) digest = hashlib.sha256() for block in _bytes(response, asset['bytes'], cancel, seconds=1800): stream.write(block); digest.update(block); done += len(block) @@ -280,13 +286,13 @@ def download(candidate, root, token='', *, progress=None, cancel=None): if cancel is not None and cancel.is_set(): raise InterruptedError('Update stopped before activation') if build_target(candidate) == 'linux-x86_64': - stage.chmod(0o755) # An AppImage runs itself. - stage.replace(target) + io_path(stage).chmod(0o755) # An AppImage runs itself. + io_path(stage).replace(io_path(target)) return target finally: if stage is not None: try: - stage.unlink(missing_ok=True) + io_path(stage).unlink(missing_ok=True) except OSError: pass # A cleanup failure must not mask the download error. @@ -295,6 +301,7 @@ class DownloadedLauncherUnavailable(ValueError): """The cached executable needs downloading again before it can be started.""" +@plain_errors def launch_download(candidate, root, *, token='', popen=None): candidate = manifest(candidate) if build_target(candidate) == 'macos-arm64': @@ -303,7 +310,7 @@ def launch_download(candidate, root, *, token='', popen=None): if not verified(path, candidate['asset']): raise DownloadedLauncherUnavailable('Downloaded launcher is missing or changed; download again') from .linux_bundle import child_environment - env = dict(child_environment(), FREEVIDEO_LAUNCHER_HOME=str(Path(root).absolute().resolve())) + env = dict(child_environment(), FREEVIDEO_LAUNCHER_HOME=plain(resolved(root))) if token: env['GITHUB_TOKEN'] = token # Session only; never stored in a receipt or command. kwargs = dict(cwd=path.parent, env=env, close_fds=True) @@ -311,7 +318,7 @@ def launch_download(candidate, root, *, token='', popen=None): kwargs['creationflags'] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP else: kwargs['start_new_session'] = True - process = (popen or subprocess.Popen)([str(path)], **kwargs) + process = (popen or execution_paths.popen)([str(path)], **kwargs) # Start first: a spawn error must not replace the approved startup receipt. save(Path(root) / 'updates' / 'active.json', dict(approved=True, candidate=candidate)) return process @@ -322,13 +329,13 @@ def forward_approved(current, root): if current.get('packaging') in ('onedir', 'app'): return False # Keep the user's folder distribution; do not switch to onefile. try: - receipt = json.loads((Path(root)/'updates/active.json').read_text(encoding='utf-8')) + receipt = json.loads((io_path(root)/'updates/active.json').read_text(encoding='utf-8')) if receipt.get('approved') is not True: return False candidate = manifest(receipt.get('candidate')) if not newer(candidate, current): return False - if executable_path(root, candidate).resolve() == Path(sys.executable).resolve(): + if resolved(executable_path(root, candidate)) == resolved(sys.executable): return False # A bad release identity must never recursively launch itself. launch_download(candidate, root) return True @@ -351,7 +358,7 @@ def resumed_update(current, root, *, seconds=600): original shortcut later forwards here again, so a marker prevents a second automatic engine update for the same build. """ - updates = Path(root) / 'updates' + updates = io_path(root) / 'updates' try: receipt = updates / 'active.json' value = json.loads(receipt.read_text(encoding='utf-8')) @@ -378,10 +385,11 @@ def resumed_update(current, root, *, seconds=600): class UpdateClient: + @plain_errors def __init__(self, current, root, token=None): # Python 3.9 on Windows can leave a nonexistent relative path relative # in resolve(). Updates must survive changing cwd to the EXE directory. - self.current, self.root = build_identity(current), Path(root).absolute().resolve() + self.current, self.root = build_identity(current), Path(plain(resolved(root))) self.token = token if token is not None else os.environ.get('GITHUB_TOKEN') or os.environ.get('GH_TOKEN', '') self.state = dict(status='idle') self.available = None @@ -389,7 +397,7 @@ def __init__(self, current, root, token=None): # cached reminder; a known newer version never prevents using this one. for name in ('available.json', 'required.json'): try: - candidate = manifest(json.loads((self.root / 'updates' / name).read_text(encoding='utf-8'))) + candidate = manifest(json.loads((io_path(self.root) / 'updates' / name).read_text(encoding='utf-8'))) if newer(candidate, self.current) and (self.available is None or newer(candidate, self.available)): self.available = candidate except (OSError, ValueError, TypeError): @@ -430,7 +438,7 @@ def progress(value): except Exception as error: if quiet: return # The next periodic check retries without a reminder. - message = Redactor([(self.token, '')] if self.token else []).text(str(error)) + message = Redactor([(self.token, '')] if self.token else []).text(plain_text(error)) if isinstance(error, urllib.error.HTTPError) and error.code in (401, 403, 404): message = 'GitHub update access unavailable. A private repository needs a GitHub token with read access; also check API rate limits.' self.state = dict(status='cancelled' if self.cancelled.is_set() else 'error', error=message, diff --git a/freevideo_engine/local_models.py b/freevideo_engine/local_models.py index a01bdaf6..38713651 100644 --- a/freevideo_engine/local_models.py +++ b/freevideo_engine/local_models.py @@ -7,6 +7,7 @@ from .download_cache import discard_read_cache from .storage import fingerprint +from .long_paths import io_path, resolved, plain BLOCK = 4 * 2**20 MAX_ENTRIES = 200_000 @@ -28,9 +29,9 @@ def library_roots(values, *, keep_first=False): for value in values: if not isinstance(value, str) or not value.strip() or len(value) > 4096: raise ValueError('Invalid model library path') - path = Path(value).expanduser().resolve() + path = resolved(Path(value).expanduser()) if not path.is_dir() or path == Path(path.anchor): - raise ValueError('Choose an existing model folder, not an entire drive: ' + str(path)) + raise ValueError('Choose an existing model folder, not an entire drive: ' + plain(path)) if path not in roots: roots.append(path) # Explicit symlink/junction roots are resolved above. Internal directory @@ -40,7 +41,7 @@ def library_roots(values, *, keep_first=False): if not keep_first: raise ValueError('Select at most 64 model libraries') roots = roots[:MAX_ROOTS] - return [str(path) for path in roots] + return [plain(path) for path in roots] def scan_many(directories, rows, *, prior=None, callback=None): @@ -71,8 +72,8 @@ def expected(row): def can_link(source, destination_parent): """A read-only plan: never create a probe file before consent.""" - source_device = Path(source).stat().st_dev - if not source_device or source_device != Path(destination_parent).stat().st_dev: + source_device = io_path(source).stat().st_dev + if not source_device or source_device != io_path(destination_parent).stat().st_dev: return False if os.name != 'nt': return True @@ -91,7 +92,7 @@ def volume_filesystem(path): ctypes.POINTER(wintypes.DWORD), ctypes.POINTER(wintypes.DWORD), ctypes.POINTER(wintypes.DWORD), wintypes.LPWSTR, wintypes.DWORD] volume, filesystem = ctypes.create_unicode_buffer(32768), ctypes.create_unicode_buffer(64) - if not kernel.GetVolumePathNameW(str(path), volume, len(volume)): + if not kernel.GetVolumePathNameW(str(io_path(path)), volume, len(volume)): return None if not kernel.GetVolumeInformationW(volume.value, None, 0, None, None, None, filesystem, len(filesystem)): return None @@ -122,9 +123,10 @@ def hash_source(path, row, progress=None, output=None): import hashlib from .model_status import NAMES, family name = NAMES[family(row)][0] + path = io_path(path) before = fingerprint(path) if before['bytes'] != row['bytes'] or not stat.S_ISREG(path.stat().st_mode): - raise ValueError('Local model size/type changed: ' + str(path)) + raise ValueError('Local model size/type changed: ' + plain(path)) digest = hashlib.new('sha256' if row.get('sha256') else 'sha1') if not row.get('sha256'): digest.update(('blob %d\0' % row['bytes']).encode()) @@ -136,7 +138,7 @@ def hash_source(path, row, progress=None, output=None): with path.open('rb') as stream: for block in iter(lambda: stream.read(BLOCK), b''): if done+len(block) > row['bytes']: - raise ValueError('Local model grew while being read: ' + str(path)) + raise ValueError('Local model grew while being read: ' + plain(path)) digest.update(block) if output is not None: output.write(block) @@ -144,12 +146,13 @@ def hash_source(path, row, progress=None, output=None): done += len(block) progress.send(phase, path, done, row['bytes'], name=name) if fingerprint(path) != before or done != row['bytes']: - raise ValueError('Local model changed while being read: ' + str(path)) + raise ValueError('Local model changed while being read: ' + plain(path)) progress.send(phase, path, done, row['bytes'], force=True, name=name) return digest.hexdigest(), before def files(directory, progress): + directory = io_path(directory) seen = 0 def failed(error): raise OSError('Cannot read part of the selected model folder; choose an accessible folder') from error @@ -174,17 +177,17 @@ def failed(error): resolved = path.resolve(strict=True) info = resolved.stat() if stat.S_ISREG(info.st_mode): - yield resolved, info + yield Path(plain(resolved)), info except (OSError, RuntimeError): continue def scan(directory, rows, *, prior=None, callback=None): - directory = Path(directory).expanduser().resolve() + directory = resolved(Path(directory).expanduser()) if not rows: - return dict(root=str(directory), matches={}, candidate_files=0, rejected_count=0, rejected=[], reused_bytes=0) + return dict(root=plain(directory), matches={}, candidate_files=0, rejected_count=0, rejected=[], reused_bytes=0) if not directory.is_dir(): - raise ValueError('Existing model folder is missing or unreadable: ' + str(directory)) + raise ValueError('Existing model folder is missing or unreadable: ' + plain(directory)) progress = Progress(callback) progress.send('scan', force=True) by_size = {} @@ -192,7 +195,8 @@ def scan(directory, rows, *, prior=None, callback=None): by_size.setdefault(row['bytes'], []).append(row) candidates, seen = [], set() for path, info in files(directory, progress): - identity = (info.st_dev, info.st_ino) if info.st_ino else str(path) + path = io_path(path) + identity = (info.st_dev, info.st_ino) if info.st_ino else plain(path) if info.st_size in by_size and identity not in seen: candidates.append(path) seen.add(identity) @@ -200,8 +204,8 @@ def scan(directory, rows, *, prior=None, callback=None): raise ValueError('Too many possible models. Select a narrower model folder.') # Prefer expected names/known HF blobs; still verify the actual content. names = {Path(row['file']).name for row in rows} | {expected(row) for row in rows} - candidates.sort(key=lambda path: (path.name not in names, str(path))) - previous = (prior or {}).get('matches', {}) if (prior or {}).get('root') == str(directory) else {} + candidates.sort(key=lambda path: (path.name not in names, plain(path))) + previous = (prior or {}).get('matches', {}) if (prior or {}).get('root') == plain(directory) else {} old_paths = {value.get('source'): value for value in previous.values() if isinstance(value, dict)} matches, rejected = {}, [] for path in candidates: @@ -211,7 +215,7 @@ def scan(directory, rows, *, prior=None, callback=None): continue # One file can match multiple identical configs. Hash once per algorithm. computed = {} - old = old_paths.get(str(path), {}) + old = old_paths.get(plain(path), {}) for row in options: algorithm = 'sha256' if row.get('sha256') else 'git_blob' if algorithm not in computed: @@ -224,19 +228,19 @@ def scan(directory, rows, *, prior=None, callback=None): computed[algorithm] = hash_source(path, row, progress) digest, stamp = computed[algorithm] if digest == expected(row): - matches[key(row)] = dict(source=str(path), fingerprint=stamp, digest=digest, + matches[key(row)] = dict(source=plain(path), fingerprint=stamp, digest=digest, algorithm=algorithm, bytes=row['bytes']) - if not any(record['source'] == str(path) for record in matches.values()): + if not any(record['source'] == plain(path) for record in matches.values()): rejected.append(dict(file=path.name, reason='Content does not match this engine revision')) progress.send('ready', done=len(rows), total=len(rows), force=True) - return dict(root=str(directory), matches=matches, candidate_files=len(candidates), + return dict(root=plain(directory), matches=matches, candidate_files=len(candidates), rejected_count=len(rejected), rejected=rejected[:20], reused_bytes=sum(record['bytes'] for record in matches.values())) def import_file(row, target, record, *, callback=None): """Create an installation-owned reference/copy; never modify the source.""" - source, target = Path(record['source']), Path(target) + source, target = io_path(record['source']), io_path(target) if record.get('digest') != expected(row) or record.get('bytes') != row['bytes']: raise ValueError('Local model selection no longer matches the pinned manifest') progress = Progress(callback) @@ -244,9 +248,9 @@ def import_file(row, target, record, *, callback=None): if current != record.get('fingerprint'): digest, current = hash_source(source, row, progress) if digest != expected(row): - raise ValueError('Selected local model changed; inspect the folder again: ' + str(source)) + raise ValueError('Selected local model changed; inspect the folder again: ' + plain(source)) if target.exists() or target.is_symlink(): - raise ValueError('Model destination already exists; both files retained: ' + str(target)) + raise ValueError('Model destination already exists; both files retained: ' + plain(target)) target.parent.mkdir(parents=True, exist_ok=True) temporary = target.with_name(target.name + '.local-import-' + str(time.time_ns())) method = record['method'] diff --git a/freevideo_engine/long_paths.py b/freevideo_engine/long_paths.py new file mode 100644 index 00000000..4fc07d32 --- /dev/null +++ b/freevideo_engine/long_paths.py @@ -0,0 +1,234 @@ +"""Installer filesystem paths; runtime aliases belong to a separate change. + +Only absolute drive/UNC paths enter the Windows extended namespace. Keep the +ordinary spelling at configuration, UI and subprocess boundaries. +""" +import ntpath +import os +import re +from functools import wraps +from pathlib import Path + +from .system import windows as is_windows + +PREFIX = '\\\\?\\' +UNC_PREFIX = PREFIX + 'UNC\\' +# launch_view marks with this the children of a runtime that starts from its short folder. +VIEW_ENV = 'FREEVIDEO_RUNTIME_VIEW' +_system_setting = None # What Windows said for this process before long_path_process changed it. + + +def long_path_process(): + """Have Windows accept ordinary paths beyond 259 characters from this process. + + Windows decides this once, when a process starts, from the program's manifest + and the system setting, and keeps the answer in the process. A runtime deeper + than the limit starts from a short folder (runtime_alias), but its processes + read the real location back from settings and from resolving paths. With the + answer changed those paths open like any other, in the libraries as well. + Returns whether Windows now says so. + """ + global _system_setting + if not is_windows(): + return False + try: + import ctypes + ntdll = ctypes.WinDLL('ntdll') + ntdll.RtlAreLongPathsEnabled.argtypes = [] + ntdll.RtlAreLongPathsEnabled.restype = ctypes.c_ubyte # BOOLEAN, not Win32 BOOL + ntdll.RtlGetCurrentPeb.argtypes = [] + ntdll.RtlGetCurrentPeb.restype = ctypes.c_void_p + if _system_setting is None: + _system_setting = bool(ntdll.RtlAreLongPathsEnabled()) + # PEB.BitField, bit 7: IsLongPathAwareProcess (Windows 10 1607 on; unused before). + field = ctypes.c_ubyte.from_address(ntdll.RtlGetCurrentPeb() + 3) + field.value |= 0x80 + return bool(ntdll.RtlAreLongPathsEnabled()) + except (AttributeError, OSError, ValueError, TypeError): + return False + + +def system_setting(): + """What Windows said before long_path_process; None in a process that never called it.""" + return _system_setting + + +def long_paths_unavailable(): + """In a process started from a short folder: whether its real paths still cannot be opened.""" + return is_windows() and bool(os.environ.get(VIEW_ENV)) and not long_path_process() + + +def libraries_through_the_view(): + """Load a library named by its real path under the runtime through the short folder. + + Windows loads no library from an ordinary path beyond 259 characters, whatever the process + accepts elsewhere, and resolving a library's path before loading it, as some packages do, + gives exactly that in a process long_path_process has marked. The short folder names the + same file; any other name is passed on as given. + """ + import ctypes + from . import launch_view + view = launch_view.current() + start = ctypes.CDLL.__init__ + if view is None or getattr(start, 'through_view', False): + return + + def __init__(self, name, *arguments, **options): + start(self, launch_view.value(name, view), *arguments, **options) + __init__.through_view = True + ctypes.CDLL.__init__ = __init__ + + +def start_process(): + """For the package's import: a process started from a runtime's short folder accepts its real paths.""" + if is_windows() and os.environ.get(VIEW_ENV): + long_path_process() + libraries_through_the_view() + + +def plain(path, *, windows=None): + value = os.fspath(path) + if not (is_windows() if windows is None else windows): + return value + if value[:len(UNC_PREFIX)].upper() == UNC_PREFIX.upper(): + return '\\\\' + value[len(UNC_PREFIX):] + return value[len(PREFIX):] if value.startswith(PREFIX) else value + + +def extended(path, *, windows=None): + value = os.fspath(path) + if not (is_windows() if windows is None else windows): + return value + original = value + explicit = value.startswith(PREFIX) + value = plain(value, windows=True).replace('/', '\\') + drive, tail = ntpath.splitdrive(value) + unc = (drive.startswith('\\\\') and len(drive[2:].split('\\')) == 2 + and all(part and part not in ('.', '..') for part in drive[2:].split('\\'))) + disk = len(drive) == 2 and drive[0] in 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' and drive[1] == ':' and tail.startswith('\\') + if value.startswith(('\\\\.\\', '\\\\?\\')) or '\0' in value or (explicit and not (unc or disk)): + raise ValueError('An absolute drive or UNC path is required') + # Prefixing disables Win32 spelling rules. Keep those inputs in their + # original namespace, including drive-relative and other relative paths. + devices = {'CON', 'PRN', 'AUX', 'NUL', 'CONIN$', 'CONOUT$', + *('COM' + n for n in '123456789¹²³'), *('LPT' + n for n in '123456789¹²³')} + parts = value.split('\\') + if not explicit and (not (unc or disk) or any( + part not in ('', '.', '..') and (part.endswith(('.', ' ')) or + part.split('.')[0].rstrip(' ').upper() in devices) for part in parts)): + return original + value = ntpath.normpath(value) + return UNC_PREFIX + value[2:] if unc else PREFIX + value + + +def io_path(path, *, windows=None): + """Normalize once at an I/O entry; descendants keep the extended prefix. + + Do not resolve an unprefixed Windows path: resolve itself accesses disk. + Non-Windows callers retain pathlib's existing relative-path semantics. + """ + path = Path(path) + return Path(extended(path, windows=True)) if (is_windows() if windows is None else windows) else path + + +def resolved(path): + return io_path(io_path(Path(path).absolute()).resolve()) + + +def plain_data(value): + """Configuration paths include absolute ledger keys as well as values.""" + if isinstance(value, str): + return plain(value) + if isinstance(value, dict): + return {plain(k) if isinstance(k, str) else k: plain_data(v) for k, v in value.items()} + if isinstance(value, list): + return [plain_data(v) for v in value] + if isinstance(value, tuple): + return tuple(plain_data(v) for v in value) + return value + + +def plain_text(value): + """Also remove prefixes embedded in errors, including repr-escaped names.""" + value = str(value) + if not is_windows(): + return value + # Keep the UNC leading separators at the same escaping level, including + # repr strings nested in JSON output. Only the namespace prefix changes. + for escaping in (4, 2, 1): + prefix = PREFIX.replace('\\', '\\' * escaping) + unc = UNC_PREFIX.replace('\\', '\\' * escaping) + replacement = '\\' * (2 * escaping) + value = re.sub(re.escape(unc), lambda match: replacement, value, flags=re.IGNORECASE) + value = value.replace(prefix, '') + return value + + +def plain_error(error): + """Best-effort, in-place Windows display cleanup; never replace the error. + + Exception subclasses may have read-only fields or arbitrary getters/setters. + A failure to clean one field must not hide the original exception or prevent + cleaning other fields and shared/cyclic cause/context chains. + """ + def clean(value, active=None): + if isinstance(value, str): + return plain_text(value) + if isinstance(value, Path): + return type(value)(plain(value)) + if isinstance(value, (tuple, list)): + active = set() if active is None else active + if id(value) in active: + return value + active.add(id(value)) + try: + items = [clean(item, active) for item in value] + return tuple(items) if isinstance(value, tuple) else items + finally: + active.remove(id(value)) + return value + + try: + if not is_windows(): + return error + pending, seen = [error], set() + while pending: + current = pending.pop() + if id(current) in seen: + continue + seen.add(id(current)) + for name in ('args', 'filename', 'filename2', 'strerror', '__notes__'): + try: + value = getattr(current, name) + cleaned = clean(value) + if cleaned is not value: + setattr(current, name, cleaned) + except BaseException: + pass # Even an unusual setter must not replace the error. + for name in ('__cause__', '__context__'): + try: + link = getattr(current, name) + if link is not None: + pending.append(link) + except BaseException: + pass + except BaseException: + pass + return error + + +def plain_errors(function): + """Keep filesystem namespace spelling out of exceptions at I/O boundaries.""" + @wraps(function) + def call(*args, **kwargs): + try: + return function(*args, **kwargs) + except (OSError, ValueError, RuntimeError) as error: + plain_error(error) + raise + return call + + +def path_chars(path): + """Windows budgets count UTF-16 code units (a non-BMP character uses two).""" + return len(str(path).encode('utf-16-le', errors='surrogatepass')) // 2 diff --git a/freevideo_engine/macos_conditions.py b/freevideo_engine/macos_conditions.py new file mode 100644 index 00000000..eb0ee305 --- /dev/null +++ b/freevideo_engine/macos_conditions.py @@ -0,0 +1,202 @@ +"""Best-effort Mac hardware observations, without packages or subprocesses. + +These are system-wide states and cumulative page counters, not per-process +attribution. Each optional read fails independently; unavailable libraries are +cached so a missing API is not loaded again on every sampling step. +""" +import ctypes +from functools import lru_cache +import sys +import threading +import time + +from . import macos_memory + + +THERMAL_NAMES = {0: 'nominal', 1: 'fair', 2: 'serious', 3: 'critical'} +PRESSURE_NAMES = {1: 'normal', 2: 'warn', 4: 'critical'} +VM_COUNTERS = ('swapouts', 'compressions', 'decompressions', 'swapins') +BLOCK_SECONDS_SCOPE = ('Host block-start intervals; each block ends at the next block start, ' + 'and the last ends at the existing step-completion callback. ' + 'Includes host dispatch/waits and the final step tail; no added GPU synchronization.') +# A whole snapshot takes 1-2 ms on an M5; the wait covers a busy generation +# process, and at one sample per step it never adds a visible delay. +_READ_BUDGET_SECONDS = .05 +_observing = threading.Lock() + + +def _optional(read): + try: + return read() + except Exception: + return None + + +@lru_cache(maxsize=1) +def _foundation(): + try: + foundation = ctypes.CDLL('/System/Library/Frameworks/Foundation.framework/Foundation') + objc = ctypes.CDLL('/usr/lib/libobjc.A.dylib') + objc.objc_getClass.argtypes, objc.objc_getClass.restype = [ctypes.c_char_p], ctypes.c_void_p + objc.sel_registerName.argtypes, objc.sel_registerName.restype = [ctypes.c_char_p], ctypes.c_void_p + objc.objc_autoreleasePoolPush.argtypes, objc.objc_autoreleasePoolPush.restype = [], ctypes.c_void_p + objc.objc_autoreleasePoolPop.argtypes, objc.objc_autoreleasePoolPop.restype = [ctypes.c_void_p], None + # Separate signatures are essential: pointers/NSInteger are 64-bit, + # while BOOL uses only the low byte. Never mutate objc_msgSend's type. + pointer = ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p)(('objc_msgSend', objc)) + integer = ctypes.CFUNCTYPE(ctypes.c_long, ctypes.c_void_p, ctypes.c_void_p)(('objc_msgSend', objc)) + boolean = ctypes.CFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)(('objc_msgSend', objc)) + supports = ctypes.CFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p, + ctypes.c_void_p)(('objc_msgSend', objc)) + selectors = {name: objc.sel_registerName(name) for name in ( + b'processInfo', b'thermalState', b'isLowPowerModeEnabled', b'respondsToSelector:')} + cls = objc.objc_getClass(b'NSProcessInfo') + if not cls or not all(selectors.values()): + return None + return foundation, objc, cls, selectors, pointer, integer, boolean, supports + except Exception: + return None + + +def _process_info_value(name): + api = _foundation() + if api is None: + return None + _, _, cls, selectors, pointer, integer, boolean, supports = api + pool = api[1].objc_autoreleasePoolPush() + try: + info = pointer(cls, selectors[b'processInfo']) + selector = selectors[name] + # Avoid an Objective-C unrecognized-selector exception on older macOS. + if not info or not supports(info, selectors[b'respondsToSelector:'], selector): + return None + return (boolean if name == b'isLowPowerModeEnabled' else integer)(info, selector) + finally: + api[1].objc_autoreleasePoolPop(pool) + + +@lru_cache(maxsize=1) +def _power_libraries(): + try: + cf = ctypes.CDLL('/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation') + io = ctypes.CDLL('/System/Library/Frameworks/IOKit.framework/IOKit') + cf.CFRelease.argtypes, cf.CFRelease.restype = [ctypes.c_void_p], None + cf.CFStringGetCString.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_long, ctypes.c_uint32] + cf.CFStringGetCString.restype = ctypes.c_bool + io.IOPSCopyPowerSourcesInfo.argtypes, io.IOPSCopyPowerSourcesInfo.restype = [], ctypes.c_void_p + io.IOPSGetProvidingPowerSourceType.argtypes = [ctypes.c_void_p] + io.IOPSGetProvidingPowerSourceType.restype = ctypes.c_void_p + return cf, io + except Exception: + return None + + +def _power_source(): + api = _power_libraries() + if api is None: + return None + cf, io = api + info = io.IOPSCopyPowerSourcesInfo() + if not info: + return None + try: + source = io.IOPSGetProvidingPowerSourceType(info) + if not source: + return None + value = ctypes.create_string_buffer(32) + if not cf.CFStringGetCString(source, value, len(value), 0x08000100): + return None + # IOKit reports AC Power on desktops without batteries too. A missing + # snapshot or an unsupported type (e.g. UPS) is unknown, never assumed AC. + return {b'AC Power': 'ac', b'Battery Power': 'battery'}.get(value.value) + finally: + cf.CFRelease(info) # The providing-source string is borrowed, not owned. + + +def _vm_statistics(): + lib = macos_memory._library() + storage = (ctypes.c_uint32 * 1024)() + count = ctypes.c_uint32(len(storage)) + host = lib.mach_host_self() + if not host: + return None + try: + if lib.host_statistics64(host, 4, storage, ctypes.byref(count)): + return None + if count.value * 4 < macos_memory.VMStatistics.swapouts.offset + 8: + return None + return macos_memory.VMStatistics.from_buffer_copy(storage) + finally: + task = ctypes.c_uint32.in_dll(lib, 'mach_task_self_').value + lib.mach_port_deallocate(task, host) + + +def _swap_used(): + status = macos_memory.memory_status() + total, free = status.get('swap_total_bytes'), status.get('swap_free_bytes') + if type(total) is int and type(free) is int and 0 <= free <= total: + return total - free + return None + + +def _empty_conditions(): + return dict.fromkeys(('thermal_state', 'thermal_state_name', 'low_power_mode', + 'power_source', 'memory_pressure_level', 'memory_pressure_name', 'swap_used_bytes', *VM_COUNTERS)) + + +def _collect(result): + # Publish inexpensive kernel fields first. Framework initialization and the + # IOKit powerd IPC can be slow; only those unread fields remain null on timeout. + pressure = _optional(lambda: macos_memory._sysctl( + 'kern.memorystatus_vm_pressure_level', ctypes.c_int()).value) + pressure = pressure if type(pressure) is int and pressure in PRESSURE_NAMES else None + result.update(memory_pressure_level=pressure, memory_pressure_name=PRESSURE_NAMES.get(pressure)) + stats = _optional(_vm_statistics) + result.update({name: _optional(lambda name=name: getattr(stats, name)) for name in VM_COUNTERS}) + result['swap_used_bytes'] = _optional(_swap_used) + thermal = _optional(lambda: _process_info_value(b'thermalState')) + thermal = thermal if type(thermal) is int and thermal in THERMAL_NAMES else None + result.update(thermal_state=thermal, thermal_state_name=THERMAL_NAMES.get(thermal)) + low_power = _optional(lambda: _process_info_value(b'isLowPowerModeEnabled')) + result['low_power_mode'] = low_power if type(low_power) is bool else None + result['power_source'] = _optional(_power_source) + + +def runtime_conditions(): + """Small fixed-schema snapshot; None off Darwin, null fields on read errors. + + Wait at most 50 ms for native reads, including cold framework loads/powerd + IPC. A single daemon reader may finish late; its late values never mutate a + returned snapshot or get reused at a later step. While it is still busy, + subsequent samples are null rather than starting unbounded reader threads. + No retries, tensor reads, GPU fences or identifying/error text are retained. + """ + if sys.platform != 'darwin': + return None + try: + return _snapshot() + except Exception: + return _empty_conditions() + + +def _snapshot(): + result = _empty_conditions() + deadline = time.perf_counter() + _READ_BUDGET_SECONDS + done = threading.Event() + if not _observing.acquire(blocking=False): + return result + def read(): + try: + _collect(result) + except Exception: + pass + finally: + _observing.release() + done.set() + try: + threading.Thread(target=read, name='mac-conditions', daemon=True).start() + except Exception: + _observing.release() + return result + done.wait(max(0., deadline - time.perf_counter())) + return dict(result) diff --git a/freevideo_engine/macos_runtime.py b/freevideo_engine/macos_runtime.py index 4e0f0bbd..9d29056a 100644 --- a/freevideo_engine/macos_runtime.py +++ b/freevideo_engine/macos_runtime.py @@ -12,6 +12,41 @@ import os import time +from .macos_conditions import BLOCK_SECONDS_SCOPE, runtime_conditions +from .sampling_progress import SamplingProgress + + +class MacSamplingProgress(SamplingProgress): + """Retain every existing host block-start callback, including throttled events.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.block_seconds = [] + self.conditions = [] + self._block_started = None + self._blocks = [] + + def start(self): + self.conditions.append(runtime_conditions()) + super().start() + + def layer(self, index): + if len(self.completed) >= self.steps: + return + now = self.clock() + if self._block_started is not None: + self._blocks.append(round(now - self._block_started, 2)) + self._block_started = now + super().layer(index) + + def complete(self, seconds): + now = self.clock() + if self._block_started is not None: + self._blocks.append(round(now - self._block_started, 2)) + self.block_seconds.append(self._blocks) + self._blocks, self._block_started = [], None + self.conditions.append(runtime_conditions()) + super().complete(seconds) + def upscale_latents(video, sampling, base, backend, checkpoint=None): """The same learned upscale/crop for resident and process-separated stages.""" @@ -453,7 +488,6 @@ def _sample(self, conditioning, seed, *, width, height, frames, progress_offset= initial_latents=None, refine_steps=None, refine_schedule=None): import torch from .geometry import geometry, sampler_for_canvas - from .sampling_progress import SamplingProgress from .system import system_memory from src.inference.render import generate_latents if self.closed: @@ -484,7 +518,7 @@ def _sample(self, conditioning, seed, *, width, height, frames, progress_offset= active_steps = refine_steps if refining else self.steps canvas = geometry(width, height, frames=frames) sampler = sampler_for_canvas(generate_latents, width, height) - progress = SamplingProgress(active_steps, len(self.model.transformer_blocks), + progress = MacSamplingProgress(active_steps, len(self.model.transformer_blocks), offset=progress_offset, total=progress_total) class StepTimes(list): def append(owner, duration): @@ -550,6 +584,8 @@ def host_guard(module, inputs): attention_calls={name: value - previous_calls[name] for name, value in self.attention.backend_calls.items()}, step_seconds=list(durations), + block_seconds=progress.block_seconds, block_seconds_scope=BLOCK_SECONDS_SCOPE, + conditions=progress.conditions, load_seconds=self.load_seconds, residency=self._residency_metrics(residency, previous_residency), text_refiner_residency=text_residency, residency_scope='Repeated video blocks; one-use prompt weights reported separately', diff --git a/freevideo_engine/macos_stages.py b/freevideo_engine/macos_stages.py index 9edc0f4b..cfc0ad07 100644 --- a/freevideo_engine/macos_stages.py +++ b/freevideo_engine/macos_stages.py @@ -319,6 +319,9 @@ def worker(value): resource, sampling = value['resources'], value['sampling'] artifacts, phase = Path(value['artifacts']), value['phase'] result = dict(success=False, device_backend='mps', phase=phase) + if phase == 'decode': + from .macos_conditions import runtime_conditions + result['conditions'] = [runtime_conditions()] started = time.monotonic() try: sampling_lifetime(resource) @@ -431,6 +434,10 @@ def first_saved(video, audio, metrics): result.update(error_type=type(error).__name__, error_message=str(error), exception=traceback.format_exc()) raise finally: + if phase == 'decode': + result['conditions'].append(runtime_conditions()) + if isinstance(result.get('decode'), dict): + result['decode']['conditions'] = result['conditions'] result['worker_seconds'] = time.monotonic() - started save(Path(value['report']), result) diff --git a/freevideo_engine/model_guidance.py b/freevideo_engine/model_guidance.py index 84579716..2c50da50 100644 --- a/freevideo_engine/model_guidance.py +++ b/freevideo_engine/model_guidance.py @@ -3,6 +3,7 @@ from pathlib import Path import sys from urllib.parse import quote, urlsplit +from .long_paths import io_path, plain_errors def runtime_packages_supported(): @@ -47,8 +48,9 @@ def video_instructions(zh=False): '按模型页说明选择显卡版本,保留下载的目录结构。')[bool(zh)] +@plain_errors def links(): - root = Path(__file__).parent + root = io_path(Path(__file__).parent) spec = json.loads((root / 'dependencies.json').read_text(encoding='utf-8'))['models'] edge = json.loads((root / 'prepared_models.json').read_text(encoding='utf-8')) return { @@ -64,9 +66,10 @@ def links(): } +@plain_errors def cloud_models(): """Only publisher-approved or release-verified shares become download links.""" - path = Path(__file__).with_name('model_shares.json') + path = io_path(Path(__file__).with_name('model_shares.json')) if not path.exists(): return [] value = json.loads(path.read_text(encoding='utf-8')) diff --git a/freevideo_engine/model_repair.py b/freevideo_engine/model_repair.py index f7ab0043..421dad95 100644 --- a/freevideo_engine/model_repair.py +++ b/freevideo_engine/model_repair.py @@ -11,6 +11,7 @@ from .disk_space import size_text from .monitoring import save +from .long_paths import io_path, plain # Shared copy for setup, the launcher and the browser plan. @@ -59,7 +60,7 @@ 'continuing with it.', '确认计划之后,安装位置里的文件又有变化,FreeVideo 因此停止,未按原计划继续。'), other=('Something changed after you confirmed the plan (for example the settings, the GPU or the free disk ' 'space), so FreeVideo stopped instead of continuing with it.', - '确认计划之后,这台电脑的情况又有变化(例如设置、显卡或可用磁盘空间),FreeVideo 因此停止,未按原计划继续。'), + '确认计划之后,设置、显卡或可用磁盘空间发生了变化,FreeVideo 因此停止,未按原计划继续。'), ) CHANGED_REPAIR = ('Click Repair again to review the new plan.', '请再次点击“修复”,查看新的计划。') CHANGED_WAYS = dict( @@ -82,7 +83,7 @@ def identity(path): return None if stat.S_ISREG(info.st_mode): return dict(bytes=info.st_size, mtime_ns=info.st_mtime_ns, st_dev=info.st_dev, st_ino=info.st_ino, - parent_realpath=os.path.realpath(path.parent)) + parent_realpath=plain(os.path.realpath(io_path(path.parent)))) return None diff --git a/freevideo_engine/model_upgrade.py b/freevideo_engine/model_upgrade.py index 0df413de..e426aef7 100644 --- a/freevideo_engine/model_upgrade.py +++ b/freevideo_engine/model_upgrade.py @@ -23,6 +23,7 @@ import shutil import threading import time +from .long_paths import io_path, resolved, plain_errors from . import disk_space GiB = 1 << 30 @@ -31,6 +32,7 @@ RETRY_SECONDS = 30 +@plain_errors def offer(root): """Whether this installation should move to int8 and what that costs, or which model files it no longer uses. Reads only.""" @@ -38,7 +40,7 @@ def offer(root): from .kernel_capabilities import readiness from .prepared_model import catalog as load_catalog, files, preferred_format, select from . import variant_cleanup - root = Path(root) + root = io_path(root) try: machine = json.loads((root / 'machine.json').read_text(encoding='utf-8')) stamp = kernel_receipt(root) @@ -81,6 +83,7 @@ def offer(root): required_bytes=download + MARGIN, lora_variants=fused, int8_checked=checked) +@plain_errors def setup_download(root, machine, selection): """Bytes the int8 setup run will download, counted as bootstrap.plan counts them. @@ -92,25 +95,25 @@ def setup_download(root, machine, selection): from .install_tuning import required_models from .prepared_model import files from .sampling_assets import install_files - rows = required_models(json.loads((PACKAGE / 'model_files.json').read_text(encoding='utf-8')), selection) + rows = required_models(json.loads((io_path(PACKAGE) / 'model_files.json').read_text(encoding='utf-8')), selection) rows += files(selection) prior = {} if machine.get('setup_run'): try: - prior = json.loads((Path(machine['setup_run']) / 'plan.json').read_text(encoding='utf-8')) + prior = json.loads((io_path(machine['setup_run']) / 'plan.json').read_text(encoding='utf-8')) except (OSError, ValueError): prior = {} # The same choice setup makes without --sampling-caches: the previous # setup's, otherwise none for an installation that is already ready. everything = prior['sampling_caches'] if isinstance(prior.get('sampling_caches'), bool) else not machine.get('ready') rows += install_files(bool(everything)) - model_dir = Path(machine.get('model_root') or root / 'models' / 'vdn').expanduser().resolve() - encoder_dir = Path(machine.get('encoder_model_root') or prior.get('encoder_dir') - or root / 'models' / 'encoder').expanduser().resolve() + model_dir = resolved(Path(machine.get('model_root') or root / 'models' / 'vdn').expanduser()) + encoder_dir = resolved(Path(machine.get('encoder_model_root') or prior.get('encoder_dir') + or root / 'models' / 'encoder').expanduser()) present = 0 for row in rows: try: - path = model_target(row, model_dir, encoder_dir, selection['directory']) + path = io_path(model_target(row, model_dir, encoder_dir, selection['directory'])) if path.is_file() and path.stat().st_size == row['bytes']: present += row['bytes'] except (OSError, ValueError): @@ -121,7 +124,7 @@ def setup_download(root, machine, selection): def kernel_receipt(root): """Identifies the GPU check receipt as written; it changes when the checks run again.""" try: - info = (Path(root) / 'kernel-capabilities.json').stat() + info = (io_path(root) / 'kernel-capabilities.json').stat() return [info.st_mtime_ns, info.st_size] except OSError: return None diff --git a/freevideo_engine/modern_launcher.py b/freevideo_engine/modern_launcher.py index dd5da355..5ed626d6 100644 --- a/freevideo_engine/modern_launcher.py +++ b/freevideo_engine/modern_launcher.py @@ -6,6 +6,8 @@ import time import traceback +from .long_paths import io_path, plain + def theme(): """branding.py's palette for QML, so the launcher and ComfyUI views match. @@ -63,8 +65,8 @@ def create_ui(session, *, show=True): # The Linux AppImage carries the launcher's Chinese characters for desktops # without a Chinese font; a complete system font still comes first. from PySide6.QtGui import QFontDatabase - for path in sorted((Path(__file__).parent / 'assets/fonts').glob('*.otf')): - for family in QFontDatabase.applicationFontFamilies(QFontDatabase.addApplicationFont(str(path))): + for path in sorted((io_path(Path(__file__).parent) / 'assets/fonts').glob('*.otf')): + for family in QFontDatabase.applicationFontFamilies(QFontDatabase.addApplicationFont(plain(path))): if family not in families: families.append(family) font.setFamilies(families + ['sans-serif']) @@ -88,9 +90,9 @@ def __init__(self): def state(self): return self.value - def invoke(self, function, *args): + def invoke(self, function, *args, **kwargs): try: - function(*args) + function(*args, **kwargs) except Exception: session.error = traceback.format_exc() self.refresh() @@ -117,7 +119,14 @@ def refresh(self): except Exception: # A failed status read must remain copyable and not kill the UI. session.error = traceback.format_exc() - self.value = dict(self.value, error=session.error) + from .offline_errors import alias_failure + if alias_failure(session.error): + # The card says what to do; the details keep the cause. + from .failure_details import launcher_failure + zh = session.language.startswith('zh') + self.value = dict(self.value, error=session.error, failure=launcher_failure(session.error, zh=zh)) + else: + self.value = dict(self.value, error=session.error) self.changed.emit() @Slot(str, 'QVariant') @@ -221,7 +230,12 @@ def link(self, value): # A licence may be a text file on this computer rather than a web page. from .component_licenses import is_file from .windows_ux import open_browser, open_text - self.invoke(open_text if is_file(value) else open_browser, value) + if is_file(value): + # A text inside the installation in use is opened through that installation's folder. + engine = session.installation_engine() + self.invoke(open_text, value, **(dict(engine=engine) if engine else {})) + else: + self.invoke(open_browser, value) @Slot(str) def selectSource(self, value): @@ -239,6 +253,10 @@ def speedTest(self): def compatibility(self, level, automatic): self.invoke(session.set_compatibility, level, automatic) + @Slot(int) + def compatibilityLevel(self, level): + self.invoke(session.set_compatibility_level, level) + @Slot(str) def update(self, token): self.invoke(session.update, token) @@ -352,7 +370,7 @@ def smoke_test(app, engine, window, bridge, destination): window.setProperty('licensesOpen', False) rows = licences.bundle(destination, dict(files=[dict(path=name) for name in ('cudart64_13.dll', 'cudnn64_9.dll', 'libiomp5md.dll')])) texts = {row['key']: row['link'] for row in rows[2:]} - missing = {key: link for key, link in texts.items() if not licences.is_file(link) or not Path(link).is_file()} + missing = {key: link for key, link in texts.items() if not licences.is_file(link) or not io_path(link).is_file()} if len(texts) != 4 or missing: raise RuntimeError('Packaged licence texts are missing: %r' % (missing or texts)) session.page = 'comfy' @@ -470,7 +488,7 @@ def main(session=None): connect = json.loads(sys.argv[sys.argv.index('--smoke-connect') + 1]) if smoke and '--smoke-connect' in sys.argv else None from .desktop_runtime import launcher_root portable_root = Path(sys.executable).parent - if session is None and getattr(sys, 'frozen', False) and (portable_root / 'portable.json').is_file() and not smoke: + if session is None and getattr(sys, 'frozen', False) and (io_path(portable_root) / 'portable.json').is_file() and not smoke: from .portable_launcher import portable_session session = portable_session(portable_root) if not smoke and session is None: diff --git a/freevideo_engine/monitoring.py b/freevideo_engine/monitoring.py index 229db125..d68f769a 100644 --- a/freevideo_engine/monitoring.py +++ b/freevideo_engine/monitoring.py @@ -9,17 +9,18 @@ import time import tempfile from .system import windows +from .long_paths import io_path, plain_data def save(path, value): - path = Path(path) + path = io_path(path) path.parent.mkdir(parents=True, exist_ok=True) # Unique, closed staging file; Windows readers/antivirus may briefly hold # the destination. Keep the previous good report and the failed staging file. with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', newline='\n', prefix=path.name + '.', suffix='.tmp', dir=path.parent, delete=False) as stream: temporary = Path(stream.name) - stream.write(json.dumps(value, ensure_ascii=False, indent=2) + '\n') + stream.write(json.dumps(plain_data(value), ensure_ascii=False, indent=2) + '\n') for attempt in range(8): try: temporary.replace(path) @@ -103,6 +104,198 @@ def thermal_slowdown(measured, *, share=.5, clock_ratio=.7, minimum_samples=200) temperature_mean_c=round(temperature, 1) if isinstance(temperature, (int, float)) else None) +class OtherPrograms: + """Other programs' share of the GPU's cores while a worker computes. + + A game, a browser tab or another generation on the same GPU slows every + step, yet the device still reads fully busy at its full clock, exactly as + when the worker has it alone. NVML's per-process samples tell the two + apart where the driver reports them. Our processes are the one that + monitors, the worker and their descendants. Constant space: counts and + sums over the checks made while the worker was computing. + """ + seconds = 1. + refresh_seconds = 5. + share_percent = 20 + # The worker's own share below which a check is not counted: while it + # loads files, another program's work delays nothing of ours. + own_percent = 15 + failures_allowed = 5 + + def __init__(self, lib, handle, pid=None): + self.lib, self.handle, self.pid = lib, handle, pid + self.since = int(time.time() * 1e6) + self.ours, self.seen = set(), set() + self.checked = self.refreshed = 0. + self.supported = None + self.own_reported = False + self.failures = 0 + self.checks = self.shared = 0 + self.other_total = self.own_total = self.other_peak = 0. + + def read(self): + """{pid: mean % of cores} over the sample periods newer than the last ones read; None when unavailable. + + The driver leaves a process out of a period in which it did nothing, + so the mean is over the periods the busiest reporter has, not over a + process's own rows. + """ + from .background_room import UtilizationSample, NOT_FOUND, INSUFFICIENT_SIZE + function = getattr(self.lib, 'nvmlDeviceGetProcessUtilization', None) + if function is None: + return None + count = ctypes.c_uint(0) + rc = function(self.handle, None, ctypes.byref(count), ctypes.c_ulonglong(self.since)) + if rc == NOT_FOUND: + return {} + if rc != INSUFFICIENT_SIZE: + return None + count = ctypes.c_uint(count.value + 16) + rows = (UtilizationSample * count.value)() + rc = function(self.handle, rows, ctypes.byref(count), ctypes.c_ulonglong(self.since)) + if rc == NOT_FOUND: + return {} + if rc: + return None + totals, periods = {}, {} + for row in rows[:count.value]: + if row.sm <= 100: + totals[row.pid] = totals.get(row.pid, 0) + row.sm + periods[row.pid] = periods.get(row.pid, 0) + 1 + self.since = max(self.since, row.timestamp) + number = max(periods.values(), default=1) + return {pid: total / number for pid, total in totals.items()} + + def refresh(self): + """A worker must never turn into another program for want of a process list: the last known ones stay ours.""" + from .background_room import family + try: + self.ours = family(os.getpid()) | (family(self.pid) if self.pid else set()) + except OSError: + self.ours = self.ours or {pid for pid in (os.getpid(), self.pid) if pid} + + def check(self, util): + """Read once a second; count the reading when the worker is computing on a busy device.""" + now = time.monotonic() + if self.supported is False or now - self.checked < self.seconds: + return + self.checked = now + found = self.read() + if found is None: + self.failures += 1 + if self.supported is None and self.failures >= self.failures_allowed: + self.supported = False + return + self.supported = True + if not found or util is None or util < 50: + return + # A process we have not seen may be a child the worker just started. + if now - self.refreshed >= self.refresh_seconds or any(pid not in self.seen for pid in found): + self.refresh() + self.refreshed, self.seen = now, set(found) + other = min(100., sum(value for pid, value in found.items() if pid not in self.ours)) + own = min(100., sum(value for pid, value in found.items() if pid in self.ours)) + self.own_reported = self.own_reported or own > 0 + if not self.own_reported: + # Some drivers report no samples for a compute process: what the + # device did beyond the other programs was the worker's. + own = max(0., util - other) + if own < self.own_percent: + return + self.checks += 1 + self.shared += other >= self.share_percent + self.other_total += other + self.own_total += own + self.other_peak = max(self.other_peak, other) + + def result(self): + result = dict(supported=bool(self.supported), own_reported=self.own_reported, checks=self.checks, + shared_checks=self.shared, check_seconds=self.seconds, share_percent=self.share_percent) + if self.checks: + result.update(other_mean_percent=self.other_total / self.checks, other_peak_percent=self.other_peak, + own_mean_percent=self.own_total / self.checks) + return result + + +def shared_gpu(measured, *, share=.5, minimum_checks=30): + """Other programs holding a real part of the GPU for most of the time the worker computed, or None. + + Counted only when they took at least `share_percent` of the cores in at + least `share` of the checks, so a desktop that draws now and then or a + short video is not reported. + """ + programs = measured.get('other_programs') if isinstance(measured, dict) else None + if not isinstance(programs, dict): + return None + checks, shared, mean = (programs.get(key) for key in ('checks', 'shared_checks', 'other_mean_percent')) + if (any(type(value) not in (int, float) or not value >= 0 for value in (checks, shared, mean)) + or checks < minimum_checks or shared < share * checks): + return None + return dict(share=round(shared / checks, 3), other_mean_percent=round(mean)) + + +def _count(value): + return value if type(value) in (int, float) and 0 <= value < float('inf') else 0 + + +def combined(rows): + """Several attempts' busy-time clock reasons and other programs' checks as one observation. + + Only what thermal_slowdown and shared_gpu read: counts add up, means are + weighted by their samples and peaks are the highest. + """ + result = {} + busy = [row['busy_activity'] for row in rows if isinstance(row.get('busy_activity'), dict)] + if busy: + merged = dict(clock_reason_samples=sum(_count(item.get('clock_reason_samples')) for item in busy), + clock_reason_counts={key: sum(_count(item['clock_reason_counts'].get(key)) for item in busy + if isinstance(item.get('clock_reason_counts'), dict)) + for key in Activity.clock_reasons}) + for field in ('sm_clock_mhz', 'temperature_c'): + parts = [(item[field], _count(item[field].get('samples')) or _count(item.get('clock_reason_samples'))) + for item in busy if isinstance(item.get(field), dict) and _count(item[field].get('mean'))] + weight = sum(samples for _, samples in parts) + if weight: + merged[field] = dict(mean=sum(_count(part.get('mean')) * samples for part, samples in parts) / weight, + maximum=max(_count(part.get('maximum')) for part, _ in parts)) + result['busy_activity'] = merged + programs = [row['other_programs'] for row in rows if isinstance(row.get('other_programs'), dict)] + if programs: + counted = [(item, _count(item.get('checks'))) for item in programs] + checks = sum(number for _, number in counted) + merged = dict(supported=any(item.get('supported') is True for item in programs), + own_reported=any(item.get('own_reported') is True for item in programs), + checks=checks, shared_checks=sum(_count(item.get('shared_checks')) for item in programs)) + if checks: + merged.update(other_mean_percent=sum(_count(item.get('other_mean_percent')) * number for item, number in counted) / checks, + other_peak_percent=max(_count(item.get('other_peak_percent')) for item in programs), + own_mean_percent=sum(_count(item.get('own_mean_percent')) * number for item, number in counted) / checks) + result['other_programs'] = merged + return result + + +def worker_gpu(report): + """The video worker's GPU telemetry for a whole request, or None. + + A request's report keeps it in `resources.gpu` and a written diagnostic + report in `memory.video.gpu`, but that is the last attempt's alone: after + a retry that only decodes, the attempt that sampled is in + `resource_attempts`. Several attempts are combined, so a share of the + request's busy time is a share of all of it. + """ + report = report if isinstance(report, dict) else {} + attempts = report.get('resource_attempts') + rows = [row['resources']['gpu'] for row in attempts if isinstance(row, dict) and isinstance(row.get('resources'), dict) + and isinstance(row['resources'].get('gpu'), dict) and row['resources']['gpu']] if isinstance(attempts, list) else [] + if len(rows) > 1: + return combined(rows) + video = report.get('memory').get('video') if isinstance(report.get('memory'), dict) else None + for section in (report.get('resources'), video): + if isinstance(section, dict) and isinstance(section.get('gpu'), dict) and section['gpu']: + return section['gpu'] + return None + + def nvml_device(device=None): """Initialize NVML and select one GPU by UUID or index; returns (library, handle, device).""" if windows(): @@ -154,9 +347,10 @@ def close(self): class Monitor: - def __init__(self, path, interval=0.05, device=None): + def __init__(self, path, interval=0.05, device=None, pid=None): self.lib, self.handle, device = nvml_device(device) self.device = device + self.programs = OtherPrograms(self.lib, self.handle, pid) self.path, self.interval = Path(path), interval self.stop_event = threading.Event() self.peak = 0 @@ -231,6 +425,7 @@ def loop(self): if auxiliary[1] is not None: self.max_power_mw = max(self.max_power_mw or 0, auxiliary[1]) writer.writerow([time.time(), time.monotonic(), used, total, util, *auxiliary, reasons]) + self.programs.check(util) except Exception as e: if str(e) not in self.errors and len(self.errors) < 20: self.errors.append(str(e)) @@ -248,6 +443,12 @@ def stop(self): 'activity_scope': 'Whole device during this worker request, including load and decode. ' 'Sample means, not duration-weighted measurements. Busy subset has GPU utilization >= 50%; ' 'clock reasons are driver observations, not attribution of elapsed time.', + 'other_programs': self.programs.result(), + 'other_programs_scope': 'Per-process driver samples read once a second while the worker was computing on a ' + 'busy device: the share of its cores used by processes outside this worker and its ' + 'monitor. shared_checks counts readings at or above share_percent. Without own_reported ' + "the worker's share is the device's utilization beyond the other programs. A driver " + 'without per-process samples reports no checks.', 'sampling_interval_seconds': self.interval, 'samples': self.count, 'sampling_errors': self.errors, 'sample_csv': str(self.path)} diff --git a/freevideo_engine/network.py b/freevideo_engine/network.py index 598a54f5..966a25c1 100644 --- a/freevideo_engine/network.py +++ b/freevideo_engine/network.py @@ -15,11 +15,13 @@ import re import shutil import subprocess +from . import execution_paths from .windows_ux import external_python, hidden_console import threading import time from urllib.parse import quote, unquote, urlencode, urljoin, urlsplit from . import proxy +from .long_paths import io_path, plain_errors from .system import curl_executable, missing_curl_message, windows @@ -64,9 +66,10 @@ def source_url(family, name, env=None): return SOURCES[family][name] +@plain_errors def model_spec(spec=None): return (spec if spec is not None else - json.loads(Path(__file__).with_name('dependencies.json').read_text(encoding='utf-8')))['models'] + json.loads(io_path(Path(__file__).with_name('dependencies.json')).read_text(encoding='utf-8')))['models'] def modelscope_mapping(row, spec=None): @@ -137,7 +140,7 @@ def curl_process(command, **kwargs): # otherwise healthy external curl binaries (including Git's copy). try: with external_python(): - return subprocess.Popen(command, **kwargs, **hidden_console()) + return execution_paths.popen(command, **kwargs, **hidden_console()) except OSError as error: if not windows(): raise @@ -293,7 +296,7 @@ def probe(family, name, spec, versions, timeout, env, *, measure_speed=False): # select its existing SSH URL instead of the HTTPS URL being tested. # A private temporary name avoids that; carry origin's proxy policy # so the probe still matches the subsequent dependency fetch. - origin_proxy = subprocess.run(['git', 'config', '--get', 'remote.origin.proxy'], env=env, + 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') @@ -883,7 +886,7 @@ 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 = subprocess.run([str(executable), '--version'], env=env, stdout=subprocess.PIPE, + output = execution_paths.run([str(executable), '--version'], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30, **hidden_console()).stdout except (OSError, ValueError, subprocess.SubprocessError): return None @@ -915,14 +918,14 @@ def execute(label, command): command = [git, *command[1:]] if run: return run(label, command, env=env) - result = subprocess.run(list(map(str, command)), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **hidden_console()) + result = execution_paths.run(list(map(str, command)), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **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 = subprocess.check_output([git, '-C', str(target), 'rev-parse', 'HEAD'], env=env, text=True, **hidden_console()).strip() - dirty = subprocess.check_output([git, '-C', str(target), 'status', '--porcelain', '--untracked-files=no'], env=env, text=True, **hidden_console()) + 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()) if head != commit or dirty: raise ValueError('Existing dependency differs from the pinned clean source: ' + str(target)) return target @@ -958,7 +961,7 @@ def execute(label, command): finally: env = original execute(target.name + '-checkout', ['git', '-C', temporary, 'checkout', '--detach', commit]) - actual = subprocess.check_output([git, '-C', str(temporary), 'rev-parse', 'HEAD'], env=env, text=True, **hidden_console()).strip() + actual = execution_paths.check_output([git, '-C', str(temporary), 'rev-parse', 'HEAD'], env=env, text=True, **hidden_console()).strip() if actual != commit: raise ValueError('Downloaded source did not match its pinned commit') temporary.rename(target) diff --git a/freevideo_engine/offline_errors.py b/freevideo_engine/offline_errors.py new file mode 100644 index 00000000..ade3d1b8 --- /dev/null +++ b/freevideo_engine/offline_errors.py @@ -0,0 +1,75 @@ +"""Bilingual copy for offline import failures; the full OS details stay in the log and the report.""" +import errno +import os +import re + +from .long_paths import plain, path_chars +from .system import windows as is_windows + +COPY = { + 'title': ('Couldn’t complete this step', '暂时未能完成'), + 'runtime-alias': ("The installation folder's path is too long. " + 'Install FreeVideo in a folder with a shorter path, such as D:\\FreeVideo.', + '安装文件夹的路径太长。请把 FreeVideo 安装到路径较短的文件夹,例如 D:\\FreeVideo。'), + 'permission': ('The offline package could not be imported: this account cannot write to the chosen folder. ' + 'Choose another folder and import again.', + '离线包未能导入:当前账户没有所选文件夹的写入权限。请换一个文件夹再导入。'), + 'disk': ('The offline package could not be imported: the drive is full. ' + 'Free up space or choose a folder on another drive, then import again.', + '离线包未能导入:磁盘空间不足。请腾出空间或换一个磁盘上的文件夹,再导入。'), + 'path': ('The offline package could not be imported: the path of the chosen folder is too long to save some of ' + 'the files. Choose a folder with a shorter path%s and import again.', + '离线包未能导入:所选文件夹的路径太长,部分文件无法保存。请换一个路径更短的文件夹再导入%s。'), + 'other': ('The offline package could not be imported. Choose another folder and import again.', + '离线包未能导入。请换一个文件夹再导入。'), +} +# The example folder is a Windows path; other systems leave it out. +PATH_EXAMPLE = (', such as D:\\FreeVideo,', ',例如 D:\\FreeVideo') + + +def alias_failure(value): + """Recognize the exception after Controller/Runner have retained it as text.""" + from .runtime_alias import AliasUnavailable + return isinstance(value, AliasUnavailable) or bool(re.search( + r'Runtime alias unavailable \((?:location|filesystem|invalid|other)\):', str(value))) + + +def write_reason(error, *, windows=None): + win = is_windows() if windows is None else windows + code = getattr(error, 'winerror', None) + # Prefer the Windows code over its lossy errno translation. The 2/3 (or + # errno-only ENOENT) heuristic can misclassify genuinely missing files or + # parents, dangling links and disconnected shares as "path too long", + # even when the failed access already used the extended namespace. + if code is not None: + if code in (5, 19): + return 'permission' + if code in (39, 112): + return 'disk' + if code == 206: + return 'path' + missing = code in (2, 3) + else: + if error.errno in (errno.EACCES, errno.EPERM, errno.EROFS): + return 'permission' + if error.errno in (errno.ENOSPC, errno.EDQUOT): + return 'disk' + if error.errno == errno.ENAMETOOLONG: + return 'path' + missing = error.errno == errno.ENOENT + if win and missing: + try: + paths = [plain(os.fsdecode(p), windows=win) for p in + (getattr(error, 'filename', None), getattr(error, 'filename2', None)) if p] + if any(path_chars(p) >= 248 for p in paths): + return 'path' + except Exception: + pass + return 'other' + + +def guidance(reason, zh=False, *, windows=None): + text = COPY[reason][bool(zh)] + if reason == 'path': + text %= PATH_EXAMPLE[bool(zh)] if (is_windows() if windows is None else windows) else '' + return text diff --git a/freevideo_engine/offline_packages.py b/freevideo_engine/offline_packages.py index 89ea2dda..e02e93e0 100644 --- a/freevideo_engine/offline_packages.py +++ b/freevideo_engine/offline_packages.py @@ -8,11 +8,17 @@ import threading import time import zipfile +import traceback +import logging from .monitoring import save from .portable import ENCODER_LIBRARY, bundle_name, inside, listing from .comfy_launcher_runtime import OlderLauncher, check_source_release from . import disk_space +from .long_paths import io_path, resolved, plain, plain_text, path_chars, plain_errors +from .runtime_alias import checked_real_path +from .offline_errors import write_reason +from .package_paths import runtime_budget PREFIX = 'FreeVideo-Windows/' # Optional packs (reference-audio tables) carry sampling tables outside the @@ -26,6 +32,38 @@ MODEL_VARIANTS = ('common', 'rowwise', 'per_tensor', 'int8_convrot') +def failure_record(error, root=None, *, log_subdir=None): + # InterruptedError is deliberate cancellation, not an unknown write failure. + if root is not None: + detail = None + try: + detail = plain_text(''.join(traceback.format_exception(error))) + folder = io_path(root) + if log_subdir: + folder /= log_subdir + log = folder / 'import-errors.log' + log.parent.mkdir(parents=True, exist_ok=True) + with log.open('a', encoding='utf-8') as stream: + stream.write('%s\n%s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), detail)) + except Exception: + # A full/read-only disk may prevent the retained log too; keep the + # complete OS error in state and the launcher's normal log handler. + try: + logging.getLogger(__name__).error('%s', detail if detail is not None else error) + except Exception: + pass + try: + return dict(write_reasons=[write_reason(error)]) if isinstance(error, OSError) and not isinstance(error, InterruptedError) else {} + except Exception: + return {} + + +def import_budget(packages, destination, *, runtime_root_length=None): + """Pre-extraction batch data; B supplies the selected runtime/alias length.""" + root = resolved(destination) / '.freevideo-packages' / ('0' * 64) / 'FreeVideo-Windows' + return runtime_budget(packages, path_chars(plain(root)) if runtime_root_length is None else runtime_root_length) + + def pack_name(path): """The download pack a model file belongs to, as the delivery note names it.""" parts = path.replace('\\', '/').split('/') @@ -38,7 +76,7 @@ def pack_name(path): def dependency_id(): """Hash of the whole dependency files; earlier EXEs compare Environment ZIPs by it.""" - root = Path(__file__).resolve().parent + root = resolved(__file__).parent digest = hashlib.sha256() for path in (root / 'dependencies.json', root / 'bootstrap_versions.json', root.parent / 'constraints/windows-portable.txt'): @@ -70,7 +108,7 @@ class RuntimeMismatch(ValueError): def runtime_id(): """What the current version needs inside an Environment ZIP, independent of models.""" - root = Path(__file__).resolve().parent + root = resolved(__file__).parent dependencies = json.loads((root / 'dependencies.json').read_text(encoding='utf-8')) value = dict(dependencies={key: dependencies[key] for key in RUNTIME_KEYS if key in dependencies}, bootstrap=json.loads((root / 'bootstrap_versions.json').read_text(encoding='utf-8')), @@ -113,6 +151,7 @@ def check_runtime_platform(): def inspect_archive(path): """Read only bounded metadata; file contents are checked during import.""" + path = io_path(path) with zipfile.ZipFile(path) as archive: names = archive.namelist() marker = next((name for name in ('offline-package.json', 'runtime.json') @@ -170,14 +209,14 @@ def inspect_archive(path): def stamps(root): try: - value = json.loads((Path(root) / VERIFIED).read_text(encoding='utf-8')) + value = json.loads((io_path(root) / VERIFIED).read_text(encoding='utf-8')) return value if isinstance(value, dict) else {} except (OSError, ValueError): return {} def stamp(path): - info = Path(path).stat() + info = io_path(path).stat() return [info.st_size, info.st_mtime_ns] @@ -205,13 +244,13 @@ def import_archive(path, destination, progress=lambda **kw: None, cancelled=lamb A file is written under a temporary name and renamed only when complete, so a file found under its own name was checked when it was written. """ - path = Path(path).resolve() + path = resolved(path) progress(done=0, total=None, detail=path.name) archive = path.stat() # Which ZIP this was, so the launcher can later offer to delete it once installed. - origin = dict(archive=str(path), archive_bytes=archive.st_size, archive_mtime_ns=archive.st_mtime_ns) + origin = dict(archive=plain(path), archive_bytes=archive.st_size, archive_mtime_ns=archive.st_mtime_ns) value = inspect_archive(path) - root = Path(destination).resolve() / '.freevideo-packages' / value['id'] / 'FreeVideo-Windows' + root = resolved(destination) / '.freevideo-packages' / value['id'] / 'FreeVideo-Windows' total = sum(r['bytes'] for r in value['files']) found = listing(root) if root.is_dir() else {} present = {row['path']: found[row['path']] for row in value['files'] if row['path'] in found} @@ -227,14 +266,14 @@ def import_archive(path, destination, progress=lambda **kw: None, cancelled=lamb if current is None: continue if current[0] != row['bytes'] or not unchanged(root / row['path'], row, known, current): - raise ValueError('已有导入文件已改变,未覆盖:' + str(root / row['path'])) + raise ValueError('已有导入文件已改变,未覆盖:' + plain(root / row['path'])) verified[row['path']] = current marker = root / value['marker'] if not missing and marker.is_file() and marker.read_bytes() == value['raw']: # Imported before and complete: nothing to read again. save(root / VERIFIED, verified) progress(done=total, total=total, detail=path.name) - return dict(root=str(root), kind=value['kind'], variant=value['variant'], name=path.name, **origin) + return dict(root=plain(root), kind=value['kind'], variant=value['variant'], name=path.name, **origin) done = sum(r['bytes'] for r in value['files'] if r['path'] in verified) root.mkdir(parents=True, exist_ok=True) progress(done=done, total=total, detail=path.name) @@ -268,7 +307,7 @@ def import_archive(path, destination, progress=lambda **kw: None, cancelled=lamb verified[row['path']] = stamp(target) save(root / VERIFIED, verified) marker.write_bytes(value['raw']) - return dict(root=str(root), kind=value['kind'], variant=value['variant'], name=path.name, **origin) + return dict(root=plain(root), kind=value['kind'], variant=value['variant'], name=path.name, **origin) ENCODER_COPY = 'engine/vendor/h3-text-encoder' @@ -282,6 +321,7 @@ def encoder_library(runtime, config, present): files unchanged since import; if any changed, the bundled ComfyUI is used as before. Returns the copy's folder (or None) and its inventory rows. """ + runtime = io_path(runtime) comfy = config['configuration']['comfy'].rstrip('/') + '/' known = stamps(runtime) rows = [] @@ -306,14 +346,15 @@ def encoder_library(runtime, config, present): def assemble(runtime, models, source, progress=lambda **kw: None): """Bind imported components to the current EXE's engine, without running it.""" - runtime = Path(runtime) + runtime = io_path(runtime) check_bound_source(runtime, source) config = runtime_config(runtime) + models = [io_path(root) for root in models] candidates = [] for root in models: - path = Path(root) / 'models/model-pack.json' + path = io_path(root) / 'models/model-pack.json' if path.is_file(): - candidates.append((Path(root), json.loads(path.read_text(encoding='utf-8')))) + candidates.append((io_path(root), json.loads(path.read_text(encoding='utf-8')))) variants = {value['variant'] for _, value in candidates} if not candidates: raise ValueError('还需导入「视频模型」包。') @@ -333,8 +374,8 @@ def assemble(runtime, models, source, progress=lambda **kw: None): def source_for(row): for root in models: current = walks[root].get(row['path']) - if current and unchanged(Path(root) / row['path'], row, known[root], current): - return Path(root) / row['path'] + if current and unchanged(io_path(root) / row['path'], row, known[root], current): + return io_path(root) / row['path'] return None def place(src, target): @@ -350,30 +391,30 @@ def place(src, target): raise ValueError('还需导入「%s」包,缺少文件:%s' % (pack_name(row['path']), row['path'])) if row['path'] in here: if not os.path.samefile(target, src) and hash_file(target) != row['sha256']: - raise ValueError('离线安装已有不同模型,未覆盖:' + str(target)) + raise ValueError('离线安装已有不同模型,未覆盖:' + plain(target)) else: place(src, target) done += row['bytes'] progress(done=done, total=total, detail='检查配套包') listed = {row['path'] for row in model['files']} for root in models: - package = Path(root) / 'offline-package.json' + package = io_path(root) / 'offline-package.json' if not package.is_file(): continue for row in json.loads(package.read_text(encoding='utf-8')).get('files', []): if row['path'] in listed or not row['path'].startswith(OPTIONAL_PREFIX) or row['path'] in here: continue current = walks[root].get(row['path']) - if not current or not unchanged(Path(root) / row['path'], row, known[root], current): + if not current or not unchanged(io_path(root) / row['path'], row, known[root], current): continue # The engine fetches a table it cannot find when a request needs it. - place(Path(root) / row['path'], runtime / row['path']) + place(io_path(root) / row['path'], runtime / row['path']) listed.add(row['path']) return finish(runtime, config, model, source, here) def runtime_config(runtime): check_runtime_platform() - config = json.loads((Path(runtime) / 'runtime.json').read_text(encoding='utf-8')) + config = json.loads((io_path(runtime) / 'runtime.json').read_text(encoding='utf-8')) if not runtime_fits(config): raise RuntimeMismatch(SKIPPED_RUNTIME) return config @@ -381,7 +422,7 @@ def runtime_config(runtime): def check_bound_source(runtime, source, value=None): """Check the existing binding before copying code, models or encoder files.""" - runtime = Path(runtime) + runtime = io_path(runtime) if value is None: try: value = json.loads((runtime / 'portable.json').read_text(encoding='utf-8')) @@ -395,14 +436,14 @@ def check_bound_source(runtime, source, value=None): def finish(runtime, config, model, source, here=None): """Write portable.json for verified models, give the encoder its library copy and trust both.""" from .desktop_runtime import materialize_source - runtime = Path(runtime) + runtime = io_path(runtime) check_bound_source(runtime, source) library, copies = encoder_library(runtime, config, listing(runtime) if here is None else here) code = materialize_source(source, runtime / 'engine/launcher/source') variant = model['variant'] from .prepared_model import catalog value = dict(config['configuration'], schema_version=1, variant=variant, - source=str(code.relative_to(runtime).as_posix()), + source=str(io_path(code).relative_to(runtime).as_posix()), source_commit=code.name, cache='models/edge/' + catalog()['variants'][variant]['cache_prefix'], files=config['files'] + model['files'] + copies, **({'encoder_library': library} if library else {})) inventory(value['files']) @@ -415,17 +456,18 @@ def finish(runtime, config, model, source, here=None): save(runtime / 'engine' / 'portable-verified.json', {row['path']: dict(bytes=final[row['path']][0], mtime_ns=final[row['path']][1], sha256=row['sha256']) for row in value['files'] if row['path'] in trusted and row['path'] in final}) - return runtime + return Path(plain(runtime)) +@plain_errors def rebind(runtime, source): """Run another launcher's engine code in an assembled installation; environment and models stay.""" from .desktop_runtime import materialize_source - runtime = Path(runtime) + runtime = io_path(checked_real_path(runtime)) value = json.loads((runtime / 'portable.json').read_text(encoding='utf-8')) check_bound_source(runtime, source, value) code = materialize_source(source, runtime / 'engine/launcher/source') - value.update(source=code.relative_to(runtime).as_posix(), source_commit=code.name) + value.update(source=io_path(code).relative_to(runtime).as_posix(), source_commit=code.name) save(runtime / 'portable.json', value) return code @@ -442,30 +484,31 @@ def download(runtime, source, sampling_caches, progress=lambda **kw: None, cance import subprocess from .comfy_environment import isolated_environment from .desktop_runtime import materialize_source - from . import processes - runtime = Path(runtime) + from . import launch_view, processes + runtime = io_path(runtime) check_bound_source(runtime, source) config = runtime_config(runtime) code = materialize_source(source, runtime / 'engine/launcher/source') out = runtime / 'engine' / 'downloaded-models.json' out.unlink(missing_ok=True) - env = isolated_environment(runtime / 'engine', code) + env = isolated_environment(Path(plain(runtime / 'engine')), Path(plain(code))) for key in ('HF_HUB_OFFLINE', 'TRANSFORMERS_OFFLINE', 'PYTHONHOME'): env.pop(key, None) - env.update(PYTHONPATH=str(code), PYTHONNOUSERSITE='1', PYTHONIOENCODING='utf-8', - HF_HOME=str(runtime / 'engine/cache/huggingface')) - command = [str(inside(runtime, config['configuration']['python'])), '-B', '-m', 'freevideo_engine.portable_models', - '--root', str(runtime), '--out', str(out)] + (['--sampling-caches'] if sampling_caches else []) + env.update(PYTHONPATH=plain(code), PYTHONNOUSERSITE='1', PYTHONIOENCODING='utf-8', + HF_HOME=plain(runtime / 'engine/cache/huggingface')) + command = [plain(inside(runtime, config['configuration']['python'])), '-B', '-m', 'freevideo_engine.portable_models', + '--root', plain(runtime), '--out', plain(out)] + (['--sampling-caches'] if sampling_caches else []) log = runtime / 'engine' / 'model-download.log' log.parent.mkdir(parents=True, exist_ok=True) progress(done=0, total=None, detail=DOWNLOADING) tail = '' with log.open('w', encoding='utf-8') as stream: - child = processes.popen(command, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + view = launch_view.view_for(runtime) + child = processes.popen(command, env=launch_view.environment_view(env, view), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding='utf-8', errors='replace', start_new_session=True, supervise=True) try: for line in child.stdout: - stream.write(line); stream.flush() + stream.write(plain_text(line)); stream.flush() tail = (tail + line)[-4000:] if cancelled(): processes.stop(child) @@ -484,7 +527,7 @@ def download(runtime, source, sampling_caches, progress=lambda **kw: None, cance processes.stop(child) child.stdout.close() if child.wait() != 0 or not out.is_file(): - raise RuntimeError('模型下载未完成,已下载的文件保留,可以重试。日志:%s\n%s' % (log, tail[-1500:])) + raise RuntimeError('模型下载未完成,已下载的文件保留,可以重试。日志:%s\n%s' % (plain(log), plain_text(tail[-1500:]))) model = json.loads(out.read_text(encoding='utf-8')) inventory(model['files']) return finish(runtime, config, model, source) @@ -500,6 +543,27 @@ def __init__(self): def busy(self): return self.thread is not None and self.thread.is_alive() + def _work(self, work, root, *, log_subdir=None): + failure = None + try: + work() + except Exception as error: + failure = error + finally: + if failure is not None: + # Set the final state before any best-effort diagnostics. The + # thread then exits, so busy becomes False even if logging fails. + self.state = dict(self.state, status='error') + try: + self.state['error'] = str(failure) + except Exception: + self.state['error'] = type(failure).__name__ + try: + self.state.update(error=plain_text(failure)) + self.state.update(failure_record(failure, root, log_subdir=log_subdir)) + except Exception: + pass + def prepare(self, runtime, models, source): if self.busy: return @@ -509,16 +573,14 @@ def update(**row): self.state = dict(self.state, **row) try: root = assemble(runtime, models, source, update) - self.state = dict(self.state, status='prepared', ready_root=str(root)) + self.state = dict(self.state, status='prepared', ready_root=plain(root)) except RuntimeMismatch as error: # An earlier import no longer fits after an update: the models stay. self.state = dict(self.state, status='complete', runtime_skipped=True, detail=str(error)) except OlderLauncher as error: # Session routes error to the localized card; QML also displays detail verbatim. - self.state = dict(self.state, status='error', error=str(error), detail='', done=0, total=None) - except Exception as error: - self.state = dict(self.state, status='error', error=str(error)) - self.thread = threading.Thread(target=work, name='freevideo-offline-prepare', daemon=True) + self.state = dict(self.state, status='error', error=plain_text(error), detail='', done=0, total=None) + self.thread = threading.Thread(target=lambda: self._work(work, runtime), name='freevideo-offline-prepare', daemon=True) self.thread.start() def download(self, runtime, source, sampling_caches): @@ -531,14 +593,12 @@ def update(**row): def work(): try: root = download(runtime, source, sampling_caches, update, self.cancelled.is_set) - self.state = dict(self.state, status='prepared', ready_root=str(root)) + self.state = dict(self.state, status='prepared', ready_root=plain(root)) except RuntimeMismatch as error: self.state = dict(self.state, status='complete', runtime_skipped=True, detail=str(error)) except OlderLauncher as error: - self.state = dict(self.state, status='error', error=str(error), detail='', done=0, total=None) - except Exception as error: - self.state = dict(self.state, status='error', error=str(error)) - self.thread = threading.Thread(target=work, name='freevideo-model-download', daemon=True) + self.state = dict(self.state, status='error', error=plain_text(error), detail='', done=0, total=None) + self.thread = threading.Thread(target=lambda: self._work(work, runtime), name='freevideo-model-download', daemon=True) self.thread.start() def start(self, paths, destination): @@ -550,21 +610,42 @@ def update(**row): self.state = dict(self.state, **row) def work(): # Take every ZIP that fits; one that does not never holds back the others. - packages, skipped, failed = [], False, [] - for index, path in enumerate(paths): + packages, skipped, failed, reasons = [], False, [], [] + def record(error): + try: + reasons.extend(failure_record(error, destination, log_subdir='.freevideo-packages').get('write_reasons', [])) + except Exception: + pass + plans = [] + for path in paths: + try: + plans.append((path, inspect_archive(path))) + except RuntimeMismatch: + skipped = True + except Exception as error: + failed.append('%s:%s' % (Path(path).name, plain_text(error))) + record(error) + # Data only: alias selection/admission is deliberately left to B. + try: + update(path_budget=import_budget([value for _, value in plans], destination)) + except Exception as error: + update(path_budget=dict(error=plain_text(error))) + for index, (path, value) in enumerate(plans): update(index=index + 1, count=len(paths)) try: packages.append(import_archive(path, destination, update, self.cancelled.is_set)) except RuntimeMismatch: skipped = True except InterruptedError as error: - failed.append(str(error)) + failed.append(plain_text(error)) break except Exception as error: - failed.append('%s:%s' % (Path(path).name, error)) + failed.append('%s:%s' % (Path(path).name, plain_text(error))) + record(error) update(packages=list(packages)) if skipped: update(runtime_skipped=True, detail=SKIPPED_RUNTIME) - update(**(dict(status='error', error='\n'.join(failed)) if failed else dict(status='complete'))) - self.thread = threading.Thread(target=work, name='freevideo-package-import', daemon=True) + update(**(dict(status='error', error='\n'.join(failed), write_reasons=reasons) if failed else dict(status='complete'))) + self.thread = threading.Thread(target=lambda: self._work(work, destination, log_subdir='.freevideo-packages'), + name='freevideo-package-import', daemon=True) self.thread.start() diff --git a/freevideo_engine/offload.py b/freevideo_engine/offload.py index 85cefee7..c500541d 100644 --- a/freevideo_engine/offload.py +++ b/freevideo_engine/offload.py @@ -5,6 +5,7 @@ import json import os import re +import threading import time import weakref @@ -184,6 +185,16 @@ class Slot: compute_done: object = None +@dataclass +class _HostRead: + """One layer being prepared ahead in a staging block.""" + + slot: Slot + future: object + from_drive: bool # Not held in RAM as a view when it was queued. + lane: str = None # 'drive' or 'ram' once the two are read ahead separately. + + # Live RAM that must stay free beside the one prepared block host read-ahead # holds. It used to borrow the 8 GiB weight-cache allowance, which no 16 GiB # machine reaches while sampling: a 16 GiB RTX 3060 Laptop reread all 21.6 GB @@ -199,6 +210,15 @@ class Slot: HOST_PREFETCH_HEADROOM = 1 * 2**30 +# A read from the drive that keeps the inference thread waiting this long means +# the drive, not the GPU, sets the pace of those layers. From then on the next +# layer that comes from the drive is read into the extra block as soon as that +# block is free, past the layers RAM already holds, which keep going through the +# staging buffer. Until a read is that slow, read-ahead stays one layer deep on +# alternating blocks, as before. +HOST_FILE_READ_WAIT = 0.2 + + def _host_prefetch_fits(extra_bytes): """Speculative staging must fit both live physical RAM and Windows commit.""" from .system import system_memory @@ -444,6 +464,13 @@ def staging(count, dtype): self.host_prefetch_reads = 0 self.host_prefetch_wait_seconds = 0. self.host_memory_checked = 0. + # Reads of drive-backed layers past the next streamed layer: their own + # thread, and the extra block while it is not being handed back. + self.host_file_ahead = bool(getattr(weight_source, 'reads_delay_compute', False)) + self.host_file_executor = None + self.host_file_ahead_reads = 0 + self.host_extra_returning = False + self.host_counters = threading.Lock() if (requested and not prefetch and sum(not p for p in self.pinned_layers) > 1 and getattr(weight_source, 'direct_read', False) and getattr(weight_source, 'intermediate_dtype', None) is None @@ -539,10 +566,11 @@ def _fill_host(self, index, slot): # CPU writes must wait for the previous H2D read of this host plane. # They need not wait for GPU computation using the separate device plane. with torch.cuda.device(self.device): + waited = 0. if slot.copy_done is not None: tick = time.monotonic() slot.copy_done.synchronize() - self.host_buffer_wait_seconds += time.monotonic() - tick + waited = time.monotonic() - tick started = time.monotonic() if self.read_ahead is not None: self.read_ahead.before(index) @@ -552,22 +580,27 @@ def _fill_host(self, index, slot): with self.weight_source.layer(index, self.layout[index]) as weights: for _, name, dtype, offset, count, _ in self.layout[index]: slot.host[dtype][offset:offset + count].copy_(weights[name].reshape(-1)) + read = 0 for _, _, dtype, offset, count, _ in self.layout[index]: counts[dtype] = offset + count if direct: - self.direct_read_bytes += count * slot.host[dtype].element_size() - if direct: - self.direct_read_layers += 1 + read += count * slot.host[dtype].element_size() + # The two read-ahead threads and the inference thread all fill. + with self.host_counters: + self.host_buffer_wait_seconds += waited + self.direct_read_bytes += read + self.direct_read_layers += int(bool(direct)) if self.read_ahead is not None: self.read_ahead.consumed(index) return slot, counts, time.monotonic() - started def _stop_host_prefetch(self): - if self.host_executor is not None: - for _, future in self.host_pending.values(): - future.cancel() - self.host_executor.shutdown(wait=True, cancel_futures=True) - self.host_executor = None + for read in self.host_pending.values(): + read.future.cancel() + for executor in (self.host_executor, self.host_file_executor): + if executor is not None: + executor.shutdown(wait=True, cancel_futures=True) + self.host_executor = self.host_file_executor = None for slot in self.host_slots: if slot.copy_done is not None: slot.copy_done.synchronize() @@ -575,8 +608,30 @@ def _stop_host_prefetch(self): self.host_slots.clear() self.host_prefetch = False + def _free_host_slot(self): + """A staging block no queued read is filling, in the order blocks alternate.""" + for _ in self.host_slots: + slot = self.host_slots[self.host_cursor % len(self.host_slots)] + self.host_cursor += 1 + if all(slot is not read.slot for read in self.host_pending.values()): + return slot + raise RuntimeError('Every staging block is held by a queued read') + + def _host_views(self): + return getattr(self.weight_source, 'host_views', None) or {} + + def _submit_host(self, index, slot, lane=None): + executor = self.host_executor + if lane == 'drive': + if self.host_file_executor is None: + self.host_file_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix='freevideo-host-read') + executor = self.host_file_executor + self.host_pending[index] = _HostRead(slot, executor.submit(self._fill_host, index, slot), + index not in self._host_views(), lane) + self.host_prefetch_reads += 1 + def _queue_host(self, start): - if not self.host_prefetch or self.host_pending: + if not self.host_prefetch: return now = time.monotonic() if len(self.host_slots) > 1 and now - self.host_memory_checked >= 1.: @@ -586,21 +641,53 @@ def _queue_host(self, start): # through the shared staging buffer holds no more memory than # reading on demand does. self.host_prefetch_shared_reason = 'live_host_pressure' - extra = self.host_slots.pop() - if extra.copy_done is not None: - extra.copy_done.synchronize() - del extra - self.host_cursor = 0 - from .torch_compat import empty_host_cache - empty_host_cache(torch) - for index in range(start, len(self.layers)): - if self.pinned_layers[index] or index in self.cached: - continue - slot = self.host_slots[self.host_cursor % len(self.host_slots)] - self.host_cursor += 1 - self.host_pending[index] = (slot, self.host_executor.submit(self._fill_host, index, slot)) - self.host_prefetch_reads += 1 - break + self.host_extra_returning = True + if self.host_extra_returning and all(read.slot is not self.host_slots[-1] for read in self.host_pending.values()): + # A read already queued into the block is used first. + self.host_extra_returning = False + extra = self.host_slots.pop() + if extra.copy_done is not None: + extra.copy_done.synchronize() + del extra + self.host_cursor = 0 + from .torch_compat import empty_host_cache + empty_host_cache(torch) + ahead = [index for index in range(start, len(self.layers)) + if not self.pinned_layers[index] and index not in self.cached] + if not ahead: + return + if not (self.host_file_ahead and len(self.host_slots) > 1 and not self.host_extra_returning + and self.read_ahead is None): + if not self.host_pending: + self._submit_host(ahead[0], self._free_host_slot()) + return + # Layers RAM holds are copied in a fraction of a layer's compute, so + # the staging buffer alone keeps them one layer ahead. The extra block + # goes to the drive: the next layer that has to be read from it starts + # as soon as the block's last upload is done, however many RAM-backed + # layers run in between, and on a thread of its own so that those + # layers do not queue behind the read. One drive read runs at a time. + # With 10 of 50 layers read from the drive every step, spread through + # the model by loading, and each read taking 3.3 times a second-pass + # layer's compute, 1344x768x243 went from 26.4-27.6 to 18.2-20.1 s per + # second-pass step (18.2 s without the reads) and from 16.4 to 12.5 s + # per first-pass step, where the ten reads alone take 12 s, on an RTX + # PRO 6000 with identical output. One layer ahead, each of those reads + # overlapped a single layer's compute and the GPU waited for the rest. + views = self._host_views() + shared, extra = self.host_slots[0], self.host_slots[-1] + near = ahead[0] + target = next((index for index in ahead if index not in views), None) + if (target is not None and target not in self.host_pending + and not any(read.lane == 'drive' for read in self.host_pending.values())): + if target == near and not self.host_pending: + self._submit_host(target, self._free_host_slot(), 'drive') + elif all(read.slot is not extra for read in self.host_pending.values()): + self._submit_host(target, extra, 'drive') + self.host_file_ahead_reads += int(target != near) + if (near in views and near not in self.host_pending + and all(read.slot is not shared and read.lane == 'drive' for read in self.host_pending.values())): + self._submit_host(near, shared, 'ram') def _stage(self, index): slot = self.slots[index % len(self.slots)] @@ -627,14 +714,18 @@ def _stage(self, index): and self.weight_source.stage_into(index, self.layout[index], slot.host)) if staged: if index in self.host_pending: - _, future = self.host_pending.pop(index) + read = self.host_pending.pop(index) tick = time.monotonic() - host_slot, counts, host_seconds = future.result() - self.host_prefetch_wait_seconds += time.monotonic() - tick + host_slot, counts, host_seconds = read.future.result() + waited = time.monotonic() - tick + self.host_prefetch_wait_seconds += waited + if read.from_drive and waited > HOST_FILE_READ_WAIT and not self.host_file_ahead: + self.host_file_ahead = True + if hasattr(self.weight_source, 'reads_delay_compute'): + # The next pass of this request starts with it on. + self.weight_source.reads_delay_compute = True else: - host_slot = self.host_slots[self.host_cursor % len(self.host_slots)] - self.host_cursor += 1 - host_slot, counts, host_seconds = self._fill_host(index, host_slot) + host_slot, counts, host_seconds = self._fill_host(index, self._free_host_slot()) elif direct: self.direct_read_layers += 1 for _, _, dtype, offset, count, _ in self.layout[index]: @@ -751,6 +842,9 @@ def stats(self): 'host_prefetch_wait_seconds': self.host_prefetch_wait_seconds, 'host_prefetch_buffer_bytes': sum(t.numel() * t.element_size() for slot in self.host_slots[1:] for t in slot.host.values()), + # Drive-backed layers read ahead past RAM-backed ones. + 'host_prefetch_file_ahead': self.host_file_ahead, + 'host_prefetch_file_ahead_reads': self.host_file_ahead_reads, 'host_prefetch_disabled_reason': self.host_prefetch_disabled_reason, 'host_prefetch_shared_reason': self.host_prefetch_shared_reason, 'disk_read_ahead': self.read_ahead.stats() if self.read_ahead is not None else {'enabled': False}, diff --git a/freevideo_engine/optimize.py b/freevideo_engine/optimize.py index 437132c2..fd42a8b4 100644 --- a/freevideo_engine/optimize.py +++ b/freevideo_engine/optimize.py @@ -13,6 +13,7 @@ import time import traceback +from .execution_paths import execution_environment from .bootstrap import DEFAULT_ROOT from .diagnostics import latest from .hardware import detect, GiB @@ -522,7 +523,7 @@ def search_plan(row, base, maximum, seconds, *, reject=None): def run(args, machine, ui): from .testing import environment, run_process, artifact_manifest, artifact_estimate env = environment(machine) - os.environ.update(env) + os.environ.update(execution_environment(env)) if args.disable: state, reason = tuning.load_state() if state: @@ -783,7 +784,7 @@ def interrupted(signum, frame): if not machine.get('ready'): raise ValueError('Setup is incomplete') from .testing import environment - os.environ.update(environment(machine)) + os.environ.update(execution_environment(environment(machine))) with nullcontext() if args.plan else runtime_lock() as descriptor: previous = os.environ.get(LOCK_ENV) if descriptor is not None: diff --git a/freevideo_engine/package_cleanup.py b/freevideo_engine/package_cleanup.py index dd1949b2..4b87c36c 100644 --- a/freevideo_engine/package_cleanup.py +++ b/freevideo_engine/package_cleanup.py @@ -26,7 +26,10 @@ import stat import uuid -from .installation_cleanup import local_path +from .installation_cleanup import local_path, root_path +from .long_paths import io_path, resolved, plain +from .runtime_alias import is_junction, checked_real_path, AliasUnavailable +from .system import windows PACKAGES = '.freevideo-packages' STAGES = '.freevideo-downloads' @@ -43,9 +46,17 @@ def unique_bytes(path): """Bytes that deleting path frees: regular files no other name links to.""" - path, total = Path(path), 0 + try: + path = io_path(checked_real_path(path)) + if path.is_dir(): + path = resolved(path) + except AliasUnavailable: + return 0 + total = 0 entries = [(path.parent, [], [path.name])] if path.is_file() else os.walk(path, followlinks=False) - for directory, _, files in entries: + for directory, folders, files in entries: + # os.walk(followlinks=False) still follows Windows directory junctions. + folders[:] = [name for name in folders if not is_junction(Path(directory) / name)] for name in files: try: info = os.lstat(os.path.join(directory, name)) @@ -58,19 +69,30 @@ def unique_bytes(path): def runtime_root(engine): """The offline installation's root, when this engine belongs to one.""" - root = Path(engine).parent - return root if (root / 'portable.json').is_file() else None + try: + root = resolved(checked_real_path(engine)).parent + except AliasUnavailable: + return None + return Path(plain(root)) if (root / 'portable.json').is_file() else None def verified_files(engine): """sha256 -> installed files recorded with that hash and unchanged since.""" - engine, found = Path(engine), {} + found = {} + try: + engine = resolved(checked_real_path(engine)) + except AliasUnavailable: + return found def add(path, record): + try: + path = checked_real_path(path) + except AliasUnavailable: + return if not isinstance(record, dict) or not isinstance(record.get('sha256'), str): return try: - info = os.stat(path) + info = os.stat(io_path(path)) except OSError: return if not stat.S_ISREG(info.st_mode) or info.st_size != record.get('bytes'): @@ -79,7 +101,7 @@ def add(path, record): return if record.get('inode') and info.st_ino != record['inode']: return - found.setdefault(record['sha256'], []).append(Path(path).absolute()) + found.setdefault(record['sha256'], []).append(Path(plain(io_path(path).absolute()))) ledger = engine / 'verified-models.json' rows = load(ledger) @@ -90,20 +112,25 @@ def add(path, record): rows = load(engine / 'portable-verified.json') for path, record in (rows.items() if isinstance(rows, dict) else ()): if not Path(path).is_absolute() and '..' not in Path(path).parts: - add(root / path, record) + add(io_path(root) / path, record) return found def load(path): try: - return json.loads(Path(path).read_text(encoding='utf-8')) + path = checked_real_path(path) + return json.loads(io_path(path).read_text(encoding='utf-8')) except (OSError, ValueError): return None def installed_at(engine): """When the current installation was last set up; packages imported later may be waiting for it.""" - engine, times = Path(engine), [] + times = [] + try: + engine = resolved(checked_real_path(engine)) + except AliasUnavailable: + return None for path in (engine / 'machine.json', engine.parent / 'portable.json'): try: times.append(path.stat().st_mtime) @@ -115,24 +142,32 @@ def installed_at(engine): def package_roots(engine, comfy_root=None): """Folders an import may have unpacked into: the destination beside the engine, the ComfyUI folder, and, for an offline installation, the destination holding its own package folder.""" - engine = Path(engine).absolute() + try: + engine = checked_real_path(engine) + comfy_root = checked_real_path(comfy_root) if comfy_root else None + except AliasUnavailable: + return [] + engine = resolved(engine) + comfy_root = resolved(comfy_root) if comfy_root else None roots = [engine.parent] if comfy_root: - roots += [Path(comfy_root).absolute(), Path(comfy_root).absolute().parent] + roots += [io_path(comfy_root).absolute(), io_path(comfy_root).absolute().parent] roots += [parent.parent for parent in engine.parents if parent.name == PACKAGES] found = [] for root in roots: folder = root / PACKAGES - if folder.is_dir() and folder not in found: - found.append(folder) + ordinary = Path(plain(folder)) + if (ordinary not in found and folder.is_dir() + and (not windows() or local_path(root, folder))): + found.append(ordinary) return found def inside(path, folder): try: - Path(path).absolute().relative_to(Path(folder).absolute()) + io_path(checked_real_path(path)).absolute().relative_to(io_path(checked_real_path(folder)).absolute()) return True - except ValueError: + except (OSError, ValueError): return False @@ -142,7 +177,13 @@ def discard(path, root, running=None, dry_run=False): A dry run applies every check and reports what would be freed, deleting nothing. """ from .version_cleanup import started_from, plain_tree - path, root = Path(path), Path(root) + try: + path, root = checked_real_path(path), checked_real_path(root) + if is_junction(path): + return None + root, path = root_path(root, path) + except AliasUnavailable: + return None if ('.replaced-' in path.name or not local_path(root, path) or started_from(path, running) or (path.is_dir() and not plain_tree(path))): return None @@ -165,30 +206,48 @@ def discard(path, root, running=None, dry_run=False): def packages(folder): - for pack in sorted(Path(folder).iterdir()) if Path(folder).is_dir() else (): - if not pack.is_dir() or '.retired-' in pack.name: + try: + folder = checked_real_path(folder) + except AliasUnavailable: + return + if is_junction(folder): + return + for pack in sorted(io_path(folder).iterdir()) if io_path(folder).is_dir() else (): + if (not pack.is_dir() or '.retired-' in pack.name + or (windows() and not local_path(io_path(folder), pack))): continue inner = pack / INNER + if windows() and not local_path(pack, inner): + continue if (inner / RUNTIME_MARKER).is_file() or (inner / 'portable.json').is_file(): - yield pack, 'runtime', None + yield Path(plain(pack)), 'runtime', None else: value = load(inner / MODELS_MARKER) - yield pack, 'models', value if isinstance(value, dict) else None + yield Path(plain(pack)), 'models', value if isinstance(value, dict) else None def prune_packages(engine, comfy_root=None, index=None, running=None, dry_run=False): """Model package extractions the installation no longer needs.""" result = dict(released_bytes=0, removed=[], kept=[]) + try: + engine = checked_real_path(engine) + if comfy_root: + comfy_root = checked_real_path(comfy_root) + except AliasUnavailable: + result['kept'].append(dict(kind='installation', reason='invalid runtime alias')) + return result + engine = resolved(engine) index = verified_files(engine) if index is None else index installed = installed_at(engine) - holders = [path for paths in index.values() for path in paths] + holders = [io_path(checked_real_path(path)) for paths in index.values() for path in paths] for folder in package_roots(engine, comfy_root): for pack, kind, value in packages(folder): + pack = io_path(pack) if kind == 'runtime' or inside(engine, pack): - result['kept'].append(dict(kind='offline-package', path=str(pack), reason='environment package')) + result['kept'].append(dict(kind='offline-package', path=plain(pack), reason='environment package')) continue if any(inside(path, pack) for path in holders): - result['kept'].append(dict(kind='offline-package', path=str(pack), reason='installed files live here')) + result['kept'].append(dict(kind='offline-package', path=plain(pack), reason='installed files live here')) continue rows = [row for row in (value or {}).get('files', []) if isinstance(row, dict) and row.get('path') != PACK_INVENTORY @@ -199,14 +258,14 @@ def prune_packages(engine, comfy_root=None, index=None, running=None, dry_run=Fa except OSError: stale = installed is not None if not covered and not stale: - result['kept'].append(dict(kind='offline-package', path=str(pack), reason='imported after this installation')) + result['kept'].append(dict(kind='offline-package', path=plain(pack), reason='imported after this installation')) continue freed = discard(pack, folder, running, dry_run) if freed is None: - result['kept'].append(dict(kind='offline-package', path=str(pack), reason='in use')) + result['kept'].append(dict(kind='offline-package', path=plain(pack), reason='in use')) continue result['released_bytes'] += freed - result['removed'].append(dict(kind='offline-package', path=str(pack), bytes=freed, + result['removed'].append(dict(kind='offline-package', path=plain(pack), bytes=freed, reason='installed and verified' if covered else 'not used by this installation')) return result @@ -214,18 +273,24 @@ def prune_packages(engine, comfy_root=None, index=None, running=None, dry_run=Fa def prune_downloads(engine, index=None, running=None, dry_run=False): """Staging folders and kept partial copies of model files that are installed and verified.""" result = dict(released_bytes=0, removed=[], kept=[]) + try: + engine = resolved(checked_real_path(engine)) + except AliasUnavailable: + result['kept'].append(dict(kind='installation', reason='invalid runtime alias')) + return result index = verified_files(engine) if index is None else index - engine = Path(engine).absolute() + engine = io_path(checked_real_path(engine)).absolute() top = runtime_root(engine) or engine prefixes = {sha[:16] for sha in index} stages, siblings = set(), {} for sha, paths in index.items(): for path in paths: + path = io_path(checked_real_path(path)) siblings.setdefault(path.parent, set()).add(path.name) for parent in path.parents: if not inside(parent, top): break - if (parent / STAGES).is_dir(): + if (parent / STAGES).is_dir() and (not windows() or local_path(top, parent / STAGES)): stages.add(parent / STAGES) for folder in sorted(stages): for stage in sorted(folder.iterdir()): @@ -234,11 +299,13 @@ def prune_downloads(engine, index=None, running=None, dry_run=False): continue freed = discard(stage, folder, running, dry_run) if freed is None: - result['kept'].append(dict(kind='download-leftover', path=str(stage), reason='in use')) + result['kept'].append(dict(kind='download-leftover', path=plain(stage), reason='in use')) else: result['released_bytes'] += freed - result['removed'].append(dict(kind='download-leftover', path=str(stage), bytes=freed)) + result['removed'].append(dict(kind='download-leftover', path=plain(stage), bytes=freed)) for directory, names in siblings.items(): + if windows() and not local_path(top, directory): + continue try: entries = sorted(directory.iterdir()) except OSError: @@ -249,16 +316,20 @@ def prune_downloads(engine, index=None, running=None, dry_run=False): continue freed = discard(entry, directory, running, dry_run) if freed is None: - result['kept'].append(dict(kind='download-leftover', path=str(entry), reason='in use')) + result['kept'].append(dict(kind='download-leftover', path=plain(entry), reason='in use')) else: result['released_bytes'] += freed - result['removed'].append(dict(kind='download-leftover', path=str(entry), bytes=freed)) + result['removed'].append(dict(kind='download-leftover', path=plain(entry), bytes=freed)) return result def copied_models(engine): """Model files imported from a library the installation could not link to, as (bytes, files).""" - rows = load(Path(engine) / 'verified-models.json') + try: + engine = checked_real_path(engine) + except AliasUnavailable: + return 0, 0 + rows = load(io_path(engine) / 'verified-models.json') copied = [record for record in (rows.values() if isinstance(rows, dict) else ()) if isinstance(record, dict) and record.get('method') == 'verified_local_copy'] return sum(int(record.get('bytes') or 0) for record in copied), len(copied) @@ -266,14 +337,23 @@ def copied_models(engine): def copy_reason(engine): """Why imports were copied: 'exfat' when the installation's own disk cannot hold links, else 'other-disk'.""" + engine = checked_real_path(engine) from .local_models import volume_filesystem filesystem = volume_filesystem(engine) return 'exfat' if filesystem and filesystem != 'NTFS' else 'other-disk' def run(engine, comfy_root=None, running=None, dry_run=False): - index = verified_files(engine) result = dict(released_bytes=0, removed=[], kept=[], dry_run=dry_run) + try: + engine = checked_real_path(engine) + comfy_root = checked_real_path(comfy_root) if comfy_root else None + except AliasUnavailable: + result['kept'].append(dict(kind='installation', reason='invalid runtime alias')) + result.update(copied_model_bytes=0, copied_model_files=0, copied_model_reason=None) + return result + engine = resolved(engine) + index = verified_files(engine) for part in (prune_packages(engine, comfy_root, index, running, dry_run), prune_downloads(engine, index, running, dry_run)): result['released_bytes'] += part['released_bytes'] @@ -289,7 +369,7 @@ def run(engine, comfy_root=None, running=None, dry_run=False): def archive_unchanged(record): try: - info = os.stat(record['path']) + info = os.stat(io_path(checked_real_path(record['path']))) except (OSError, KeyError, TypeError): 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') @@ -297,11 +377,14 @@ def archive_unchanged(record): def archive_rows(record): """The files a recorded ZIP provides: from its extraction, or else from the ZIP's own record.""" - value = load(Path(record['root']) / MODELS_MARKER) + try: + value = load(io_path(checked_real_path(record['root'])) / MODELS_MARKER) + except AliasUnavailable: + return None if not isinstance(value, dict): try: from .offline_packages import inspect_archive - value = inspect_archive(record['path']) + value = inspect_archive(checked_real_path(record['path'])) except Exception: # A ZIP that cannot be read is never offered. return None return [row for row in value.get('files', []) if isinstance(row, dict) and row.get('path') != PACK_INVENTORY] @@ -309,15 +392,24 @@ def archive_rows(record): def archives(records, engine, index=None): """The recorded ZIPs whose contents this installation holds, verified: dict(bytes, paths).""" + try: + engine = checked_real_path(engine) + except AliasUnavailable: + return dict(bytes=0, paths=[]) 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 for record in records: + try: + checked_real_path(record['root']) + except AliasUnavailable: + continue if not archive_unchanged(record): continue if record.get('kind') == 'runtime': # The environment package was unpacked into this very installation. - held = runtime is not None and Path(record['root']).resolve() == runtime.resolve() + held = runtime is not None and resolved(checked_real_path(record['root'])) == resolved(runtime) else: rows = archive_rows(record) @@ -330,7 +422,7 @@ def installed(row): and placed.is_file() and placed.stat().st_size == row.get('bytes')) held = bool(rows) and all(installed(row) for row in rows) if held: - paths.append(record['path']) + paths.append(plain(checked_real_path(record['path']))) total += record['bytes'] return dict(bytes=total, paths=paths) @@ -341,8 +433,8 @@ def delete_archives(records, engine): freed, removed = 0, [] for path in offer['paths']: try: - info = os.lstat(path) - os.unlink(path) + 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) diff --git a/freevideo_engine/package_paths.py b/freevideo_engine/package_paths.py new file mode 100644 index 00000000..9c3d9d66 --- /dev/null +++ b/freevideo_engine/package_paths.py @@ -0,0 +1,69 @@ +"""Read-only package/runtime path budgets, shared by importer and delivery.""" +import zipfile + +from .long_paths import io_path, path_chars + +WINDOWS_PATH_LIMIT = 259 +ALIAS_ROOT_RESERVE = 40 +# Longest relative file in the shipped runtime inventory; fallback when a +# particular installation's portable.json cannot be read. +KNOWN_RUNTIME_PATH_CHARS = 198 +# Full runtime filename: alias root + one separator + package-relative name. +PACKAGE_PATH_BUDGET = WINDOWS_PATH_LIMIT - ALIAS_ROOT_RESERVE - 1 # 218 +PREFIX = 'FreeVideo-Windows/' + + +def longest_paths(names, limit=5): + return [dict(path=name, chars=path_chars(name)) for name in + sorted(set(names), key=lambda name: (-path_chars(name), name))[:limit]] + + +def check_package_paths(names, *, budget=PACKAGE_PATH_BUDGET): + longest = longest_paths(names) + if longest and longest[0]['chars'] > budget: + raise ValueError('Package path budget exceeded (%d characters):\n%s' % + (budget, '\n'.join('%d: %s' % (r['chars'], r['path']) for r in longest))) + return longest + + +def zip_paths(path, limit=5): + """Central directory only; no extraction, content reads or writes.""" + with zipfile.ZipFile(io_path(path)) as archive: + return longest_paths((row.filename.removeprefix(PREFIX) + for row in archive.infolist() if not row.is_dir()), limit) + + +def runtime_budget(packages, runtime_root_length): + """Conservative known runtime files, including the encoder library copy. + + `packages` are inspect_archive results for the entire batch. B can replace + the real root length with its alias length. This function never blocks an + import; dynamic compiler/cache/output names require separate validation. + """ + if type(runtime_root_length) is not int or runtime_root_length < 0: + raise ValueError('runtime_root_length must be a nonnegative integer') + from .portable import ENCODER_LIBRARY + names = set() + for package in packages: + names.update(row['path'] for row in package['files']) + marker = package.get('marker') + if marker: + names.add(marker) + if package.get('kind') == 'runtime': + configuration = package.get('configuration') + comfy = configuration.get('comfy') if isinstance(configuration, dict) else None + if not isinstance(comfy, str) or not comfy: + continue + comfy = comfy.rstrip('/') + '/' + for row in package['files']: + inner = row['path'][len(comfy):] if row['path'].startswith(comfy) else '' + if inner and any(inner == item or (item.endswith('/') and inner.startswith(item)) + for item in ENCODER_LIBRARY): + names.add('engine/vendor/h3-text-encoder/' + inner) + longest = longest_paths(names) + relative = longest[0]['chars'] if longest else 0 + length = runtime_root_length + 1 + relative if longest else runtime_root_length + return dict(runtime_root_length=runtime_root_length, longest=longest, + longest_relative_chars=relative, longest_path_chars=length, + limit=WINDOWS_PATH_LIMIT, exceeds_limit=length > WINDOWS_PATH_LIMIT, + scope='Known package files and encoder copies; dynamic runtime paths are not bounded') diff --git a/freevideo_engine/portable.py b/freevideo_engine/portable.py index b9b38762..d182f36f 100644 --- a/freevideo_engine/portable.py +++ b/freevideo_engine/portable.py @@ -10,6 +10,8 @@ import traceback from .monitoring import save +from .long_paths import io_path, resolved, plain, plain_data, plain_text +from .runtime_alias import checked_real_path @contextmanager @@ -44,14 +46,14 @@ def bundle_name(name): def inside(root, name): bundle_name(name) - root = Path(root).resolve() + root = resolved(root) path = root / name if any(p.is_symlink() or (hasattr(p, 'is_junction') and p.is_junction()) for p in [path, *path.parents] if p != root and root in p.parents): raise ValueError('Bundle files must not redirect outside their directory: ' + name) - if root not in path.resolve().parents: + if root not in resolved(path).parents: raise ValueError('Bundle path escapes its directory: ' + name) - return path + return Path(plain(path)) def listing(root): @@ -61,7 +63,7 @@ def listing(root): each of ~58,000 files (for stat or its NTFS change time) took minutes under real-time scanning. Links are refused. """ - root = os.path.join(str(root), '') + root = os.path.join(str(resolved(checked_real_path(root))), '') found, pending = {}, [root] while pending: current = pending.pop() @@ -72,7 +74,7 @@ def listing(root): with entries: for entry in entries: if entry.is_symlink() or entry.is_junction(): - raise ValueError('Bundle files must not redirect outside their directory: ' + entry.path) + raise ValueError('Bundle files must not redirect outside their directory: ' + plain_text(entry.path)) if entry.is_dir(follow_symlinks=False): pending.append(entry.path) elif entry.is_file(follow_symlinks=False): @@ -82,7 +84,7 @@ def listing(root): def manifest(root): - value = json.loads((Path(root) / 'portable.json').read_text(encoding='utf-8')) + value = json.loads((io_path(root) / 'portable.json').read_text(encoding='utf-8')) if value.get('schema_version') != 1 or value.get('variant') not in ('rowwise', 'per_tensor', 'int8_convrot'): raise ValueError('Invalid FreeVideo portable bundle') seen = set() @@ -102,7 +104,7 @@ def manifest(root): def verify(root, value, progress=lambda **kw: None): """Hash a file only when it is not as last verified (size, modification time).""" from .network import hash_file - root = Path(root) + root = resolved(checked_real_path(root)) ledger = root / 'engine' / 'portable-verified.json' try: prior = json.loads(ledger.read_text(encoding='utf-8')) @@ -127,7 +129,7 @@ def report(detail, current=0): # Earlier ledgers kept more fields; size, time and digest decide. known = isinstance(entry, dict) and all(entry.get(k) == v for k, v in stamp.items()) if not known or found[1] >= time.time_ns()-10**9: - path = inside(root, row['path']) + path = io_path(inside(root, row['path'])) if hash_file(path, discard_cache=True, progress=lambda current, length: report(row['path'], current)) != row['sha256']: raise ValueError('Bundled file failed verification: ' + row['path']) info = path.stat() @@ -142,27 +144,38 @@ def report(detail, current=0): def environment(root, value, environ=None): from .comfy_environment import isolated_environment - root = Path(root).resolve() - env = isolated_environment(root/'engine', inside(root, value['source']), environ) - env.update(FREEVIDEO_MODEL_ROOT=str(inside(root, value['model_root'])), - FREEVIDEO_VDN_ROOT=str(inside(root, value['vdn'])), - FREEVIDEO_COMFY_ROOT=str(inside(root, value.get('encoder_library') or value['comfy'])), - TORCHINDUCTOR_CACHE_DIR=str(root/'engine/cache/inductor'), - TRITON_CACHE_DIR=str(root/'engine/cache/triton'), - HF_HOME=str(root/'engine/cache/huggingface'), + root = resolved(root) + env = isolated_environment(Path(plain(root/'engine')), Path(plain(inside(root, value['source']))), environ) + env.update(FREEVIDEO_MODEL_ROOT=plain(inside(root, value['model_root'])), + FREEVIDEO_VDN_ROOT=plain(inside(root, value['vdn'])), + FREEVIDEO_COMFY_ROOT=plain(inside(root, value.get('encoder_library') or value['comfy'])), + TORCHINDUCTOR_CACHE_DIR=plain(root/'engine/cache/inductor'), + TRITON_CACHE_DIR=plain(root/'engine/cache/triton'), + HF_HOME=plain(root/'engine/cache/huggingface'), HF_HUB_OFFLINE='1', TRANSFORMERS_OFFLINE='1') # Bundled Python/Triton provide their DLLs and compiler. An unrelated CUDA # installation or build compiler must not override this sealed runtime. for key in ('PYTHONHOME', 'CUDA_HOME', 'CUDA_PATH', 'CC', 'CXX'): env.pop(key, None) python = inside(root, value['python']).parent - env['PATH'] = os.pathsep.join([str(python), str(python/'Scripts'), str(python/'Lib/site-packages/bin'), env.get('PATH', '')]) + env['PATH'] = os.pathsep.join([plain(python), plain(python/'Scripts'), plain(python/'Lib/site-packages/bin'), env.get('PATH', '')]) from .triton_compat import environment as compiler_environment - return compiler_environment(root/'engine', env) + return compiler_environment(Path(plain(root/'engine')), env) + + +def own_environment(root, value): + """environment() for the process that sets it on itself. + + A process started from the runtime's short folder (launch_view) names the + 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 .execution_paths import execution_environment + return execution_environment(environment(root, value)) def configuration(root, value, hardware): - root = Path(root).resolve() + root = resolved(root) from .prepared_model import preferred_format # The GPU's own FP8 granularity, and int8 where it is the faster format. allowed = {'per_tensor' if hardware.capability[0] >= 10 else 'rowwise'} @@ -172,13 +185,13 @@ def configuration(root, value, hardware): raise ValueError('This GPU needs the %s bundle; this archive contains %s. No models were downloaded or changed.' % (' or '.join(sorted(allowed)), value['variant'])) from . import __version__ - spec = json.loads((Path(__file__).with_name('dependencies.json')).read_text(encoding='utf-8')) + spec = json.loads((io_path(__file__).with_name('dependencies.json')).read_text(encoding='utf-8')) models = inside(root, value['model_root']) - return dict(schema_version=1, root=str(root/'engine'), source=str(inside(root, value['source'])), + return plain_data(dict(schema_version=1, root=str(root/'engine'), source=str(inside(root, value['source'])), environment_layout='unified', system=hardware.system, engine_version=__version__, python=str(inside(root, value['python'])), comfy_python=str(inside(root, value['python'])), # The encoder's own copy when assembly made one; the bundled ComfyUI otherwise. - comfy_root=str(inside(root, value.get('encoder_library') or value['comfy'])), vdn_root=str(inside(root, value['vdn'])), + comfy_root=plain(inside(root, value.get('encoder_library') or value['comfy'])), vdn_root=plain(inside(root, value['vdn'])), model_root=str(models), encoder_model_root=str(inside(root, value['encoder_root'])), base=str(models/'h3-base'), checkpoint=str(models/'stage-dmd-step-250'), cache=str(inside(root, value['cache'])), encoder=spec['models']['encoder_file'].split('/')[-1], @@ -187,13 +200,18 @@ def configuration(root, value, hardware): gpu_uuid=hardware.gpu_uuid, gpu_name=hardware.gpu_name, model_revision=spec['models']['vdn_revision'], vram_gib=None, ram_gib=None, ready=False, portable=True, portable_revision=value['source_commit'], setup_run=str(root/'engine/portable-runs'), - platform_validation='Local kernel checks only; full-video diagnostics remain separate.') + platform_validation='Local kernel checks only; full-video diagnostics remain separate.')) def initialize(root, progress=lambda **kw: None): - root = Path(root).resolve() + root = resolved(root) value = manifest(root) - os.environ.update(environment(root, value)) + os.environ.update(own_environment(root, value)) + from .long_paths import long_paths_unavailable + if long_paths_unavailable(): + # Generating would stop at the first file under the real folder; say so before anything is prepared. + from .runtime_alias import AliasUnavailable + raise AliasUnavailable('other', OSError('This Windows does not open long paths for the process')) from .locking import runtime_lock with runtime_lock(root/'engine/engine.lock', inherit=False) as descriptor, child_lease(descriptor): verify(root, value, progress) @@ -209,10 +227,10 @@ def initialize(root, progress=lambda **kw: None): from .kernel_capabilities import available_backends, readiness, receipt_path available_backends(hardware) if machine['prepared_format'] == 'int8_convrot': - rows = json.loads(receipt_path().read_text(encoding='utf-8')).get('kernel_probes', []) + rows = json.loads(io_path(receipt_path()).read_text(encoding='utf-8')).get('kernel_probes', []) if not readiness(rows).get('int8_ready'): raise ValueError('The int8 model needs int8 GPU kernels, and they did not pass on this GPU. ' - 'No models were changed. Details: ' + str(receipt_path())) + 'No models were changed. Details: ' + plain(receipt_path())) machine['ready'] = True save(root/'engine/machine.json', machine) save(root/'engine/prepared-cache.json', dict(cache=machine['cache'], @@ -228,8 +246,8 @@ def connect(root, value, machine, progress=lambda **kw: None, cancelled=None, *, previous: the controller of an earlier attempt, whose ComfyUI may still run. """ from .comfy_launcher_runtime import Controller, deploy, node_target - comfy = inside(root, value['comfy']) - if node_target(comfy).resolve() != Path(machine['source']).resolve(): + comfy = io_path(inside(root, value['comfy'])) + if resolved(node_target(comfy)) != resolved(machine['source']): # An environment package brings ComfyUI without the FreeVideo node: the # launcher source it was imported with backs one, as in any installation. deploy(comfy, machine['source'], machine['root']) @@ -238,7 +256,7 @@ def connect(root, value, machine, progress=lambda **kw: None, cancelled=None, *, # 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 = root + 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.') # CUDA UUIDs survive a Windows display-adapter order change. The host and @@ -248,7 +266,7 @@ def connect(root, value, machine, progress=lambda **kw: None, cancelled=None, *, controller.cancelled = cancelled if cancelled.is_set(): raise RuntimeError('Startup cancelled') - controller.selection = dict(ready=True, root=str(comfy), engine=machine['root'], + controller.selection = dict(ready=True, root=plain(comfy), engine=machine['root'], source=machine['source'], python=machine['python'], url=url, portable=False, separate=False) controller.restore_terminal(machine['root']) @@ -274,7 +292,7 @@ def main(root=None, argv=None): root = Path(root or Path(sys.argv[0]).resolve().parent) if args.smoke: value = manifest(root) - os.environ.update(environment(root,value)) + os.environ.update(own_environment(root,value)) os.environ['CUDA_VISIBLE_DEVICES']='' import importlib import importlib.metadata diff --git a/freevideo_engine/portable_launcher.py b/freevideo_engine/portable_launcher.py index 19fec1a9..f5a16230 100644 --- a/freevideo_engine/portable_launcher.py +++ b/freevideo_engine/portable_launcher.py @@ -5,19 +5,20 @@ import subprocess import time -from . import component_licenses, portable, processes +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 .monitoring import save +from .long_paths import io_path, resolved, plain, plain_text UNREADABLE = 'FreeVideo cannot read its program files' class PortableController(Controller): def __init__(self, root, bundled=None): - self.root = Path(root).resolve() + self.root = Path(plain(resolved(root))) self.bundle = portable.manifest(self.root) - super().__init__(portable.inside(self.root, self.bundle['source'])) + super().__init__(Path(plain(portable.inside(self.root, self.bundle['source'])))) # The launcher's own engine code, which an installation it assembled can move to. self.bundled = Path(bundled) if bundled else None self.update_values = None @@ -32,9 +33,9 @@ def restore(self, values): # Restoring the launcher is read-only. A bundled deployment is not yet # a GPU-ready machine; only initialize() may certify local kernels. url = (values.get('installation') or {}).get('url') or values.get('url') - self.selection = dict(root=str(portable.inside(self.root, self.bundle['comfy'])), + self.selection = dict(root=plain(portable.inside(self.root, self.bundle['comfy'])), engine=str(self.root/'engine'), source=str(self.source), - python=str(portable.inside(self.root, self.bundle['python'])), + python=plain(portable.inside(self.root, self.bundle['python'])), url=local_url(url or 'http://127.0.0.1:8188'), ready=False, separate=False) self.state = dict(status='bundled', selection=dict(self.selection), engine_update_available=self.engine_outdated()) @@ -48,12 +49,12 @@ def engine_outdated(self): longer fits this launcher's engine keeps the code it has. Whether the launcher's code is the newer one is the session's comparison, as for any installation.""" - if self.bundled is None or (self.root/'start_portable.py').is_file(): + if self.bundled is None or (io_path(self.root)/'start_portable.py').is_file(): return False from .desktop_runtime import matching_source from .offline_packages import runtime_fits try: - fits = runtime_fits(json.loads((self.root/'runtime.json').read_text(encoding='utf-8'))) + fits = runtime_fits(json.loads((io_path(self.root)/'runtime.json').read_text(encoding='utf-8'))) except (OSError, ValueError, AttributeError): return False return fits and not matching_source(self.bundled, self.source) @@ -105,7 +106,7 @@ def _install(self, accepted): def terminal_sources(self): sources = super().terminal_sources() if self.initialization_log: - sources.insert(0, ('initialize', str(self.initialization_log))) + sources.insert(0, ('initialize', plain(self.initialization_log))) if self.comfy_controller: sources = [row for row in sources if row[0] != 'comfy'] + self.comfy_controller.terminal_sources() return sources @@ -142,16 +143,16 @@ def _launch(self, values): # missing module. for folder in (self.source, Path(self.selection['python']).parent): try: - os.listdir(folder) + os.listdir(io_path(folder)) except PermissionError: raise RuntimeError('%s: Windows denies this account access to %s' % (UNREADABLE, folder)) from None except OSError: pass # Missing files are for the bundle check to name. - log_path = self.root/'engine/portable-runs'/('%s-%s.log' % (time.time_ns(), os.getpid())) + log_path = io_path(self.root)/'engine/portable-runs'/('%s-%s.log' % (time.time_ns(), os.getpid())) log_path.parent.mkdir(parents=True, exist_ok=True) - self.initialization_log = log_path + self.initialization_log = Path(plain(log_path)) command = [self.selection['python'], '-I', '-u', str(self.root/'start_portable.py'), '--initialize'] - if not (self.root/'start_portable.py').is_file(): + if not (io_path(self.root)/'start_portable.py').is_file(): # Independent EXE + environment ZIP: use this launcher's materialized # source, without requiring an older launcher inside the archive. command = [self.selection['python'], '-I', '-u', '-c', @@ -159,6 +160,7 @@ def _launch(self, values): 'from freevideo_engine.portable import main;' 'sys.exit(main(sys.argv[2], ["--initialize"]))', str(self.source), str(self.root)] tail = '' + view = launch_view.view_for(self.root) self.progress(dict(stage='verify', done=0, total=None, detail='Check local files')) try: if self.cancelled.is_set(): @@ -167,11 +169,12 @@ def _launch(self, values): with self._server_lock: if self._closed or self.cancelled.is_set(): raise RuntimeError('Startup cancelled; all files retained') - self.child = processes.popen(command, env=portable.environment(self.root, self.bundle), + self.child = processes.popen(command, env=launch_view.environment_view(portable.environment(self.root, self.bundle), view), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding='utf-8', start_new_session=True, supervise=True) for line in self.child.stdout: - log.write(line); log.flush() + line = launch_view.line_back(line, view) + log.write(plain_text(line)); log.flush() tail = (tail + line)[-12000:] try: row = json.loads(line) @@ -186,7 +189,7 @@ def _launch(self, values): if self.cancelled.is_set(): raise RuntimeError('Startup cancelled; all files retained') if code: - raise RuntimeError('Bundle startup failed (exit %s)\n%s\nLog: %s' % (code, tail, log_path)) + raise RuntimeError('Bundle startup failed (exit %s)\n%s\nLog: %s' % (code, plain_text(tail), plain(log_path))) finally: if self.child is not None: if self.child.poll() is None: @@ -194,7 +197,7 @@ def _launch(self, values): if self.child.stdout: self.child.stdout.close() self.child = None - machine = json.loads((self.root/'engine/machine.json').read_text(encoding='utf-8')) + machine = json.loads((io_path(self.root)/'engine/machine.json').read_text(encoding='utf-8')) 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, @@ -208,7 +211,7 @@ def ensure_shortcut(self): from .desktop_shortcut import create_after_install self.state = dict(self.state, shortcut=create_after_install( self.selection['engine'], self.source, - portable_root=self.root if (self.root/'FreeVideo.exe').is_file() else None)) + portable_root=self.root if (io_path(self.root)/'FreeVideo.exe').is_file() else None)) def retain_comfy_controller(self, controller): with self._server_lock: @@ -237,8 +240,8 @@ def cancel(self): class PortableLauncher(Launcher): def __init__(self, window, root): - self.bundle_root = Path(root).resolve() - self.consent_path = self.bundle_root/'engine/portable-consent.json' + self.bundle_root = Path(plain(resolved(root))) + self.consent_path = io_path(self.bundle_root)/'engine/portable-consent.json' controller = PortableController(self.bundle_root) super().__init__(window, controller.source, controller=controller) self.loading_settings = True @@ -305,7 +308,7 @@ def gui(root): def portable_session(root): from .launcher_session import Session - root = Path(root).resolve() + root = Path(plain(resolved(root))) os.environ.update(portable.environment(root, portable.manifest(root))) os.environ['FREEVIDEO_LAUNCHER_HOME'] = str(root/'engine/launcher') class PortableSession(Session): @@ -313,7 +316,7 @@ class PortableSession(Session): def __init__(self): controller = PortableController(root) - self.consent_path = root/'engine/portable-consent.json' + self.consent_path = io_path(root)/'engine/portable-consent.json' super().__init__(controller.source, controller=controller, smoke=True) self.smoke = False self.page = 'launcher' diff --git a/freevideo_engine/precompile.py b/freevideo_engine/precompile.py index 4d57ab74..d81eca98 100644 --- a/freevideo_engine/precompile.py +++ b/freevideo_engine/precompile.py @@ -30,6 +30,7 @@ import threading import time +from .execution_paths import execution_environment from .monitoring import save # The node's default request: 1344x768, 10 s, two passes of 8 + 3 steps. The @@ -303,14 +304,22 @@ def check_kernels(hardware, machine, environment, room): def compile_shapes(machine, shapes, history, identity, receipt, environment, timeout, room): + from . import launch_view from .locking import LOCK_ENV, runtime_lock + from .long_paths import io_path root = Path(machine['root']) - with runtime_lock() as descriptor, tempfile.TemporaryDirectory(prefix='precompile-', dir=root, + view = launch_view.current() or launch_view.for_engine(root) + # torch and the worker must see a short path; keep the machine and final + # receipt rooted at the real installation. Never resolve the temporary view. + work_root = Path(launch_view.value(root, view)) if view.used else root + with runtime_lock() as descriptor, tempfile.TemporaryDirectory(prefix='precompile-', dir=work_root, ignore_cleanup_errors=True) as folder: environment[LOCK_ENV] = str(descriptor) # An interrupted run leaves its folder behind; only this lease writes them. - for stale in root.glob('precompile-*'): - if (stale.is_dir() and stale != Path(folder) + # Found and removed under the real folder: nothing is deleted through a short + # folder, which may by now lead somewhere else. + for stale in io_path(root).glob('precompile-*'): + if (stale.is_dir() and stale.name != Path(folder).name and re.fullmatch(r'precompile-[a-z0-9_]{8}', stale.name)): shutil.rmtree(stale, ignore_errors=True) index = 0 @@ -329,6 +338,17 @@ def compile_shapes(machine, shapes, history, identity, receipt, environment, tim index += 1 compile_plan(receipt, dict(shape, text_tokens=tuple(tokens)), request, profile, index, Path(folder), environment, descriptor, timeout, room) + if view.used: + # Only the temporary execution paths and their diagnostics + # return from the worker; persisted identities stay real. + row = receipt['shapes'][-1] + for name in ('error', 'stderr_tail'): + if name in row: + row[name] = launch_view.text_back(row[name], view) + back = launch_view.View(view.root, view.real, view.rules) + for sample in row.get('worker', {}).get('passes', ()): + if 'conditioning' in sample: + sample['conditioning'] = launch_view.value(sample['conditioning'], back) # A stop arriving after the last worker finished finds the work done; the # receipt records it, so the next start does not compile it again. receipt['success'] = all(row.get('returncode') == 0 for row in receipt['shapes']) @@ -477,7 +497,7 @@ def stopped(signum, frame): try: from .testing import environment # A request's own variables: the installation, its caches and its GPU. - os.environ.update(environment(machine)) + os.environ.update(execution_environment(environment(machine))) owner = os.environ.get(OWNER_ENV) receipt = run(machine, background=args.background, owner=int(owner) if owner else None) except Exception as error: diff --git a/freevideo_engine/prepared_model.py b/freevideo_engine/prepared_model.py index 117feaff..aaa4b260 100644 --- a/freevideo_engine/prepared_model.py +++ b/freevideo_engine/prepared_model.py @@ -10,13 +10,16 @@ import re import subprocess +from . import execution_paths +from .long_paths import io_path, resolved, plain, plain_errors from . import network CATALOG = Path(__file__).with_name('prepared_models.json') +@plain_errors def catalog(): - value = json.loads(CATALOG.read_text(encoding='utf-8')) + value = json.loads(io_path(CATALOG).read_text(encoding='utf-8')) if (value.get('schema_version') != 1 or not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', value.get('repo', '')) or not re.fullmatch('[0-9a-f]{40}', value.get('revision', ''))): @@ -56,6 +59,7 @@ def preferred_format(hardware): return None +@plain_errors def select(capability, root, *, scale_granularity=None): value = catalog() scale = scale_granularity or ('per_tensor' if capability[0] >= 10 else 'rowwise') @@ -67,12 +71,13 @@ def select(capability, root, *, scale_granularity=None): # Adding another format must not move an existing installation to a new # directory or redownload its unchanged model. Variants may pin separately. revision = variant.get('revision', value['revision']) - folder = Path(root).expanduser().resolve() / 'prepared' / ('edge-' + revision[:16]) + folder = resolved(Path(root).expanduser()) / 'prepared' / ('edge-' + revision[:16]) return dict(repo=value['repo'], revision=revision, scale_granularity=scale, - directory=str(folder), cache=str(folder / variant['cache_prefix']), + directory=plain(folder), cache=plain(folder / variant['cache_prefix']), private=value.get('private', False), format=value['format']) +@plain_errors def files(selection): if not selection: return [] @@ -80,7 +85,7 @@ def files(selection): if any(selection.get(k) != value[k] for k in ('repo', 'format')): raise ValueError('Prepared model selection changed; review setup again') variant = value['variants'].get(selection['scale_granularity']) - if variant is None or Path(selection['cache']).resolve() != (Path(selection['directory']) / variant['cache_prefix']).resolve(): + if variant is None or resolved(selection['cache']) != resolved(Path(selection['directory']) / variant['cache_prefix']): raise ValueError('Invalid prepared model cache path') revision = variant.get('revision', value['revision']) if selection.get('revision') != revision: @@ -128,7 +133,7 @@ def access_error(selection, networking, *, environ=None): headers = ['Authorization: Bearer ' + secret] if secret and source == 'official' else [] for route in network.route_order(networking, family, source, env): try: - result = subprocess.run([ + result = execution_paths.run([ 'curl', '--disable', '--silent', '--show-error', '--location', '--head', '--output', os.devnull, '--write-out', '%{http_code}', '--connect-timeout', '5', '--max-time', '12', '--config', '-'], diff --git a/freevideo_engine/processes.py b/freevideo_engine/processes.py index e3675fb0..b91069f5 100644 --- a/freevideo_engine/processes.py +++ b/freevideo_engine/processes.py @@ -7,6 +7,7 @@ import threading from contextlib import contextmanager +from . import launch_view from .system import windows HANDLE_ENV = 'FREEVIDEO_RUNTIME_LOCK_HANDLE' @@ -40,8 +41,12 @@ def module_command(module, *arguments): return [sys.executable, '--freevideo-module', module, *map(str, arguments)] code = ("import runpy,sys;sys.path.insert(0,sys.argv.pop(1));" "runpy.run_module(sys.argv.pop(1),run_name='__main__',alter_sys=True)") - return [sys.executable, '-B', '-X', 'utf8', '-c', code, - str(Path(__file__).resolve().parents[1]), module, *map(str, arguments)] + source = Path(__file__).resolve().parents[1] + view = launch_view.current() + if view is not None: + # Resolving leads back to the real folder; the child imports from the short one. + source = launch_view.value(source, view) + return [sys.executable, '-B', '-X', 'utf8', '-c', code, str(source), module, *map(str, arguments)] class WindowsPopen(subprocess.Popen): @@ -128,6 +133,13 @@ def terminate(self): def popen(command, *, pass_fds=(), supervise=False, **kwargs): + # A child of an offline runtime that needs its short root, or of a process + # already running from it, starts from that root (see launch_view). + view = launch_view.current(kwargs.get('env')) or launch_view.current() + if view is not None: + command, kwargs['env'], cwd = launch_view.execution(command, kwargs.get('env'), kwargs.get('cwd'), view) + if cwd is not None: + kwargs['cwd'] = cwd if windows(): return WindowsPopen(command, pass_fds=pass_fds, **kwargs) if sys.platform == 'darwin': diff --git a/freevideo_engine/proxy.py b/freevideo_engine/proxy.py index cc97e974..73c09e17 100644 --- a/freevideo_engine/proxy.py +++ b/freevideo_engine/proxy.py @@ -6,6 +6,7 @@ import subprocess import sys from urllib import request +from . import execution_paths PROXY_KEYS = ('http_proxy', 'https_proxy', 'all_proxy', 'no_proxy', @@ -47,7 +48,7 @@ def git_proxy_keys(env): if not shutil.which('git', path=env.get('PATH')): return [] try: - result = subprocess.run(['git', 'config', '--null', '--name-only', '--get-regexp', GIT_PROXY_PATTERN], + result = execution_paths.run(['git', 'config', '--null', '--name-only', '--get-regexp', GIT_PROXY_PATTERN], env=env, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=3) return list(dict.fromkeys(k for k in result.stdout.decode('utf-8', errors='replace').split('\0') if k and '\n' not in k and '\r' not in k)) if result.returncode == 0 else [] diff --git a/freevideo_engine/reference_sampler.py b/freevideo_engine/reference_sampler.py index fe34624c..48496b65 100644 --- a/freevideo_engine/reference_sampler.py +++ b/freevideo_engine/reference_sampler.py @@ -9,7 +9,6 @@ import torch -from diffusers import MiniMaxH3Scheduler from diffusers.modular_pipelines.minimax_h3.before_denoise import ( MiniMaxH3Ref2VAPrepareLayoutStep, patchify_video_latents) from diffusers.modular_pipelines.minimax_h3.modular_pipeline import ( @@ -21,6 +20,8 @@ from src.inference.render import KEYFRAME_NOISE_AUG from src.models.hybrid_transform import iter_hybrids, set_layout +from . import step_grid + # Rebound per Engine request by sampler_for_canvas. LATENT_H, LATENT_W = 48, 84 @@ -75,8 +76,8 @@ def generate_latents(transformer, prompt_embeds, text_token_tags, num_frames, nu text_indices=text_indices, )) - scheduler = MiniMaxH3Scheduler(shift=video_shift) - audio_scheduler = MiniMaxH3Scheduler(shift=audio_shift) + scheduler = step_grid.scheduler(shift=video_shift) + audio_scheduler = step_grid.scheduler(shift=audio_shift) scheduler.set_timesteps(num_steps, device=device) audio_scheduler.set_timesteps(num_steps, device=device) diff --git a/freevideo_engine/refine.py b/freevideo_engine/refine.py index 424ca564..c180a252 100644 --- a/freevideo_engine/refine.py +++ b/freevideo_engine/refine.py @@ -23,7 +23,7 @@ def validate_tail(base_steps, refine_steps): def generate_latents(transformer, prompt_embeds, text_token_tags, num_frames, num_steps, seed, device, *, initial_latents, refine_steps, step_seconds=None, conditions=None, video_shift=12., audio_shift=3., refine_schedule=None): - from diffusers import MiniMaxH3Scheduler + from .step_grid import scheduler from diffusers.modular_pipelines.minimax_h3.before_denoise import ( MiniMaxH3PrepareLayoutStep, MiniMaxH3Ref2VAPrepareLayoutStep, patchify_video_latents) from diffusers.modular_pipelines.minimax_h3.modular_pipeline import ( @@ -88,7 +88,7 @@ def generate_latents(transformer, prompt_embeds, text_token_tags, num_frames, nu video_scheduler, audio_scheduler = schedulers(device) start = 1 else: - video_scheduler, audio_scheduler = MiniMaxH3Scheduler(shift=video_shift), MiniMaxH3Scheduler(shift=audio_shift) + video_scheduler, audio_scheduler = scheduler(shift=video_shift), scheduler(shift=audio_shift) video_scheduler.set_timesteps(num_steps, device=device) audio_scheduler.set_timesteps(num_steps, device=device) start = num_steps - refine_steps diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 19624256..804c244f 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,35 +1,62 @@ { "schema": 1, - "product_version": "0.3.24", + "product_version": "0.3.25", "en": { - "summary": "Compiling after an update now runs in the background while FreeVideo is idle, and Windows PCs with less RAM and 8 GB RTX 30 cards generate faster; Repair handles more cases and offline installations update their engine with the launcher.", + "summary": "Offline packages now work on Windows PCs where long-path support is off, and a generation interrupted once no longer slows down later ones.", "changes": [ - "After an update, an installation, a repair or a graphics driver update, FreeVideo compiles in the background while it is idle, which takes two to three minutes, and the first video after that no longer waits for compiling; the background work stops as soon as a video starts.", - "Clearing the system's temporary files no longer slows the next video: compiled results are now kept in the FreeVideo folder.", - "On Windows PCs with less RAM, each step reads less from the drive, and the second pass of a two-pass video starts sooner; 8 GB RTX 30-series cards also run the second pass of full-size videos faster.", - "With prompt enhancement on, a rewrite starts 3–4 seconds sooner. The background preparation uses about 0.7 GiB of RAM and no GPU memory, and it stops when a video starts generating.", - "Repair also restores ComfyUI packages whose files were deleted or changed, and replaces model files that differ from the tested version, keeping the original under a new name. When the text encoder folder holds a same-named file of a different size, generation uses the tested one.", - "FreeVideo installed from an offline package now updates its engine with the launcher without downloading; an older launcher no longer overwrites a newer FreeVideo, and both versions are shown when they differ.", - "When a browser extension interferes with FreeVideo, the error card names the extension and lists ways to fix it.", - "View licenses in the license step lists each license you accept, and each one opens; in installations from an offline package the full texts open from this computer, without a connection.", - "Fixes: after an engine update, commands run with the engine's own Python use the new FreeVideo; on Linux, Install / repair completes when the uv tool in the installation folder is a link to another location." + "On Windows PCs where long-path support is off, which is the Windows default, offline packages now import, start and generate normally with no change to system settings, including in deeply nested folders with very long paths. A folder left half imported by the previous version needs no cleanup: import the package again after updating.", + "The Total pixels list in the creation panel no longer offers 1.00 MP, which gave the same size as 0.98 MP · Native. Workflows saved at 1.00 MP open at their original size.", + "On Windows PCs whose RAM cannot hold the whole video model, the part of the model that comes from the drive is now read ahead while the rest is computing, so a slow drive holds up each sampling step much less.", + "On Windows, the first video at the Max quality level, or at some custom step counts, no longer downloads about 26 GB of extra model files.", + "A generation interrupted once no longer slows down later generations. The compatibility level is raised automatically only after two interruptions in a row or a graphics card error, and goes back down after three generations complete normally.", + "Closing FreeVideo during a generation now cancels it instead of counting as an interruption. While the compatibility level is raised automatically, the progress says so and shows how many more completed generations are needed before it speeds up.", + "The creation panel has a new Available sizes and lengths row under Frame & duration. It opens a map of the picture sizes and lengths that can be generated; hovering shows the estimated time, and clicking fills in the size and length. Not shown on Mac." ] }, "zh": { - "summary": "更新后的编译改在空闲时于后台完成,内存较小的 Windows 电脑和 8 GB 的 RTX 30 显卡生成更快;“修复”能处理更多情况,离线安装也能随启动器更新引擎。", + "summary": "离线包在没有开启长路径支持的 Windows 电脑上也能使用;生成被打断一次后,之后的生成不再变慢。", "changes": [ - "更新、安装、修复或更新显卡驱动后,FreeVideo 会在空闲时于后台完成编译,约两三分钟,之后的第一次生成不用再等编译;开始生成时后台编译会立即停止。", - "清理系统临时文件后,下一次生成不再变慢:编译结果改存在 FreeVideo 的目录里。", - "内存较小的 Windows 电脑上,生成时每一步从硬盘读取的数据更少,两遍生成的第二遍也开始得更快;8 GB 的 RTX 30 系列显卡上,全尺寸视频的第二遍更快。", - "开启提示词增强时,改写开始前少等 3–4 秒。为此 FreeVideo 会在后台提前做好准备,约占 0.7 GiB 内存,不占显存,开始生成视频时自动退出。", - "“修复”也会补回 ComfyUI 环境里被删除或改动的包,并把和测试版本不同的模型文件改名保留、换回测试过的版本。生成时如果文本编码器目录里有同名但大小不同的文件,会改用测试过的那份。", - "用离线整合包装的 FreeVideo 也会随启动器更新引擎,不用下载;旧启动器不再覆盖新版 FreeVideo,两者版本不同时会分别显示。", - "浏览器扩展干扰 FreeVideo 时,出错卡片会写出是哪个扩展,并给出解决办法。", - "许可确认里的“查看许可证”会列出要同意的每一项许可,每项都能打开;用离线整合包安装时打开的是本机全文,断网也能看。", - "修复:引擎更新后,用引擎自带 Python 运行的命令也会用新版 FreeVideo;Linux 上安装目录里的 uv 被换成链接时,“安装 / 修复”也能完成。" + "在没有开启长路径支持的 Windows 电脑上(Windows 的默认设置),离线包现在可以正常导入、启动和生成,不需要更改系统设置;层级很深、路径很长的文件夹也可以。上一版没有导完的文件夹不需要清理,更新后可以直接重新导入。", + "创作面板“总像素”的选项里去掉了“1.00 MP”,它和“0.98 MP · 原生”是同一个尺寸;以前用 1.00 MP 保存的工作流打开后尺寸不变。", + "内存不足以容纳整个视频模型的 Windows 电脑上,需要从硬盘读取的那部分模型现在会在其余部分计算的同时提前读好,硬盘较慢时每个采样步等待的时间明显缩短。", + "在 Windows 上第一次用“极致”档(以及部分自定义步数)生成时,不再额外下载约 26 GB 的模型文件。", + "生成被意外中断一次后,之后的生成不再因此变慢:只有连续两次中断或显卡报错,才会自动提高兼容档位;之后连续三次正常完成,档位会自动调回。", + "生成过程中关掉 FreeVideo,现在会取消这次生成,不算作中断;兼容档位被自动提高时,生成进度上会说明这一点,并显示再完成几次后会加快。", + "创作面板“画幅与时长”下面新增“可生成范围”:展开是一张图,显示可以生成的画面大小和时长;鼠标移到图上会显示预计用时,点击图中的位置会填入对应的大小和时长。Mac 上不显示。" ] }, "history": [ + { + "product_version": "0.3.24", + "en": { + "summary": "Compiling after an update now runs in the background while FreeVideo is idle, and Windows PCs with less RAM and 8 GB RTX 30 cards generate faster; Repair handles more cases and offline installations update their engine with the launcher.", + "changes": [ + "After an update, an installation, a repair or a graphics driver update, FreeVideo compiles in the background while it is idle, which takes two to three minutes, and the first video after that no longer waits for compiling; the background work stops as soon as a video starts.", + "Clearing the system's temporary files no longer slows the next video: compiled results are now kept in the FreeVideo folder.", + "On Windows PCs with less RAM, each step reads less from the drive, and the second pass of a two-pass video starts sooner; 8 GB RTX 30-series cards also run the second pass of full-size videos faster.", + "With prompt enhancement on, a rewrite starts 3–4 seconds sooner. The background preparation uses about 0.7 GiB of RAM and no GPU memory, and it stops when a video starts generating.", + "Repair also restores ComfyUI packages whose files were deleted or changed, and replaces model files that differ from the tested version, keeping the original under a new name. When the text encoder folder holds a same-named file of a different size, generation uses the tested one.", + "FreeVideo installed from an offline package now updates its engine with the launcher without downloading; an older launcher no longer overwrites a newer FreeVideo, and both versions are shown when they differ.", + "When a browser extension interferes with FreeVideo, the error card names the extension and lists ways to fix it.", + "View licenses in the license step lists each license you accept, and each one opens; in installations from an offline package the full texts open from this computer, without a connection.", + "Fixes: after an engine update, commands run with the engine's own Python use the new FreeVideo; on Linux, Install / repair completes when the uv tool in the installation folder is a link to another location." + ] + }, + "zh": { + "summary": "更新后的编译改在空闲时于后台完成,内存较小的 Windows 电脑和 8 GB 的 RTX 30 显卡生成更快;“修复”能处理更多情况,离线安装也能随启动器更新引擎。", + "changes": [ + "更新、安装、修复或更新显卡驱动后,FreeVideo 会在空闲时于后台完成编译,约两三分钟,之后的第一次生成不用再等编译;开始生成时后台编译会立即停止。", + "清理系统临时文件后,下一次生成不再变慢:编译结果改存在 FreeVideo 的目录里。", + "内存较小的 Windows 电脑上,生成时每一步从硬盘读取的数据更少,两遍生成的第二遍也开始得更快;8 GB 的 RTX 30 系列显卡上,全尺寸视频的第二遍更快。", + "开启提示词增强时,改写开始前少等 3–4 秒。为此 FreeVideo 会在后台提前做好准备,约占 0.7 GiB 内存,不占显存,开始生成视频时自动退出。", + "“修复”也会补回 ComfyUI 环境里被删除或改动的包,并把和测试版本不同的模型文件改名保留、换回测试过的版本。生成时如果文本编码器目录里有同名但大小不同的文件,会改用测试过的那份。", + "用离线整合包装的 FreeVideo 也会随启动器更新引擎,不用下载;旧启动器不再覆盖新版 FreeVideo,两者版本不同时会分别显示。", + "浏览器扩展干扰 FreeVideo 时,出错卡片会写出是哪个扩展,并给出解决办法。", + "许可确认里的“查看许可证”会列出要同意的每一项许可,每项都能打开;用离线整合包安装时打开的是本机全文,断网也能看。", + "修复:引擎更新后,用引擎自带 Python 运行的命令也会用新版 FreeVideo;Linux 上安装目录里的 uv 被换成链接时,“安装 / 修复”也能完成。" + ] + } + }, { "product_version": "0.3.23", "en": { @@ -220,67 +247,6 @@ "在 Windows 上把模型权重放进内存时,FreeVideo 现在会为本次请求之后还要分配的显存预留虚拟内存(提交内存)。之前页面文件较小的电脑可能在生成中途耗尽虚拟内存:剩下的步骤会关闭权重预读,或者请求中止后减少内存占用再重新开始。在固定页面文件、显存限制为 7 GB 的测试中,原来中途中止重来的请求(851 秒)现在一次完成(622 秒)。" ] } - }, - { - "product_version": "0.3.14", - "en": { - "summary": "When a FreeVideo script fails to load once, the page now recovers by itself, and when the creative workspace cannot be seen, the page or the node says why.", - "changes": [ - "If the browser fails to load one of FreeVideo's scripts while the ComfyUI page opens, the page refreshes its copies of FreeVideo's files and reloads itself once, returning to the address it was opened with. Before, the page stayed incomplete until a hard reload. The automatic reload is skipped once you have clicked or typed on the page.", - "If the scripts still fail after that reload, the message says so and suggests Ctrl+F5 or checking browser extensions, and the copied details include what the browser cache held for each failed file.", - "If a FreeVideo node is on the canvas but neither the creative workspace nor the toolbar that opens it can be seen, the page now says so and names the cause, such as a stylesheet that did not load. This is checked on every visit and whenever another workflow comes to the front, not only when the launcher opens the page.", - "If FreeVideo’s page scripts do not run in the browser at all, the generating node now shows the cause and what to do in its progress text, since the page itself cannot." - ] - }, - "zh": { - "summary": "FreeVideo 的脚本在页面打开时偶尔加载失败,现在页面会自行恢复;看不到创作面板时,页面或节点会说明原因。", - "changes": [ - "打开 ComfyUI 页面时,如果浏览器有一个 FreeVideo 脚本没能加载,页面会重新下载 FreeVideo 的文件并自动刷新一次,回到最初打开的地址。之前页面会一直不完整,直到手动强制刷新。如果你已经在页面上点击或输入过,就不会自动刷新。", - "自动刷新后仍然失败时,提示会说明已经刷新过,并建议按 Ctrl+F5 或检查浏览器插件;复制的详情里还会列出每个失败文件在浏览器缓存中的情况。", - "画布上有 FreeVideo 节点、却看不到创作面板和打开它的工具栏时,页面会提示并写明原因,例如某个样式文件没有加载。每次打开页面、切换工作流时都会检查,不再只在从启动器打开时检查。", - "如果浏览器完全没有运行 FreeVideo 的页面脚本,页面本身无法提示,生成节点会在进度文字里说明原因和处理办法。" - ] - } - }, - { - "product_version": "0.3.13", - "en": { - "summary": "Choosing another location for an automatic installation after an offline one now installs there.", - "changes": [ - "After an installation from offline packages, an automatic installation set up in another folder installs into that folder. It no longer reuses the offline installation's engine folder and stops at \"Failed step: vdn-source\".", - "An automatic installation pointed at an offline installation's own folder is explained before it starts, with the choice to open that installation or pick another folder." - ] - }, - "zh": { - "summary": "离线安装之后改用自动安装并选择其他位置时,会安装到新选的位置。", - "changes": [ - "用离线包安装后,在安装设置里换一个位置进行自动安装,会安装到新选的位置;不再沿用离线安装的引擎目录,也不再停在“Failed step: vdn-source”。", - "如果自动安装指向离线安装所在的位置,安装计划页会在开始前说明,并提示在启动页直接打开这份安装或换一个位置。" - ] - } - }, - { - "product_version": "0.3.12", - "en": { - "summary": "Linux setup accepts the Git that current distributions ship and installs its own when needed, offline installs can download the models, the offline text encoder no longer depends on your ComfyUI, and update notes cover every version since yours.", - "changes": [ - "Linux setup no longer stops with \"Missing git\" when Git is older than 2.35. Git 2.17 or newer on the system is used as it is (Ubuntu 22.04 ships 2.34, Ubuntu 20.04 ships 2.25). Without Git, or with an older one, setup downloads Git 2.56 from conda-forge (about 75 MB, every file checked against a pinned SHA-256) into the installation folder; the system is not changed.", - "After importing the Environment package, the models step can download the models (49.1 GB) instead of importing model packages. Downloads resume after a stop, and model packages already imported are used first.", - "An offline installation's text encoder uses its own copy of ComfyUI's library from the Environment package, so updating or editing that ComfyUI no longer changes prompt encoding.", - "When an update skips versions, its notes include each version since the one you have, newest first.", - "Downloading the report from the node panel names the file by when it was saved, as the creation panel does." - ] - }, - "zh": { - "summary": "Linux 安装可直接使用各发行版自带的 Git,必要时自行安装;离线安装可以改为自动下载模型;离线安装的文本编码器不再受 ComfyUI 改动影响;更新说明会列出从当前版本以来的每个版本。", - "changes": [ - "Linux 安装不再因 Git 低于 2.35 而提示「缺少 git」。系统中 2.17 及以上的 Git 直接使用(Ubuntu 22.04 自带 2.34,Ubuntu 20.04 自带 2.25);没有 Git 或版本更低时,安装程序从 conda-forge 下载 Git 2.56(约 75 MB,每个文件按固定的 SHA-256 校验)装到安装目录内,不改动系统。", - "导入运行环境包后,模型这一步可以选择自动下载(49.1 GB),不必再导入模型包。下载中断后可以续传,已导入的模型包优先使用。", - "离线安装的文本编码器改用运行环境包内 ComfyUI 库的独立副本,更新或修改该 ComfyUI 不再影响提示词编码。", - "跨版本更新时,更新说明会按从新到旧列出当前版本之后的每个版本。", - "在节点视图下载报告时,文件按保存时间命名,与创作面板一致。" - ] - } } ] } diff --git a/freevideo_engine/release_notes.py b/freevideo_engine/release_notes.py index b6deff86..122e5325 100644 --- a/freevideo_engine/release_notes.py +++ b/freevideo_engine/release_notes.py @@ -7,6 +7,7 @@ import json from pathlib import Path import re +from .long_paths import io_path, plain_errors PRODUCT_VERSION = re.compile(r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)') MAX_NOTES_BYTES = 24000 @@ -104,9 +105,10 @@ def optional_fields(value): return result +@plain_errors def catalog(package=None): """Builds must fail on incomplete translations, before uploading anything.""" - package = Path(package) if package is not None else Path(__file__).parent + package = io_path(package if package is not None else Path(__file__).parent) value = json.loads((package / 'release_notes.json').read_text(encoding='utf-8')) version, release = product_version(value.get('product_version')), notes(value) if not version or not release: @@ -127,7 +129,7 @@ def public_details(value): def installed_details(package, fallback=None): - package = Path(package) + package = io_path(package) try: from .launcher_update import build_identity return public_details(build_identity(json.loads((package / 'build-identity.json').read_text(encoding='utf-8')))) diff --git a/freevideo_engine/resident_process.py b/freevideo_engine/resident_process.py index 54fd5b28..d456407f 100644 --- a/freevideo_engine/resident_process.py +++ b/freevideo_engine/resident_process.py @@ -17,6 +17,7 @@ import threading import time +from .execution_paths import execution_environment from . import processes from .monitoring import save @@ -472,8 +473,9 @@ def request_watch(): try: descriptor = _receive_lease(connection) phase = 'runtime lease validation' + environment = execution_environment(request['environment']) os.environ.clear() - os.environ.update(request['environment']) + os.environ.update(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/resource_history.py b/freevideo_engine/resource_history.py index 7b6011fb..1b19e3ad 100644 --- a/freevideo_engine/resource_history.py +++ b/freevideo_engine/resource_history.py @@ -289,6 +289,8 @@ def _request_was_cancelled(row): def _recover(self, db): recovered=[] + # Requests found unsettled together were cut short by the same event. + batch=uuid.uuid4().hex for row in db.execute("SELECT * FROM attempts WHERE state='pending'").fetchall(): if process_state(json.loads(row['owner_json'])) != 'dead': continue @@ -303,7 +305,7 @@ def _recover(self, db): (time.time(), _json(details), row['id'])) continue from .compatibility import Store - Store(self.path.parent).incident(record, 'unfinished_request_after_owner_and_worker_exit') + Store(self.path.parent).incident(record, 'unfinished_request_after_owner_and_worker_exit', batch=batch) db.execute('DELETE FROM attempts WHERE id=?',(row['id'],)) return recovered @@ -407,6 +409,17 @@ def finish(self, attempt_id, outcome, *, details=None, observation=None): observed = self._observation(observation, row) merged = json.loads(row['details_json']) merged.update(details or {}) + if outcome == 'success': + from .compatibility import Store + try: + compatibility = Store(self.path.parent).success(self._row(row)) + if compatibility is not None: + merged['compatibility'] = compatibility + except Exception as error: + # Optional compatibility bookkeeping, whatever is wrong with its + # stored state, cannot lose a completed video or turn its settlement + # into another failed attempt. + merged['compatibility_error'] = repr(error) db.execute('UPDATE attempts SET state=?,settled=?,details_json=?,observation_json=? WHERE id=?', (outcome, time.time(), _json(merged), observed, attempt_id)) return self._row(db.execute('SELECT * FROM attempts WHERE id=?', (attempt_id,)).fetchone()) diff --git a/freevideo_engine/runtime.py b/freevideo_engine/runtime.py index c28f1575..1ad0f923 100644 --- a/freevideo_engine/runtime.py +++ b/freevideo_engine/runtime.py @@ -280,7 +280,16 @@ def prepare_streamed(block, index, unread=False): self.resident_weight_bytes = 0 block_bind_seconds = 0. upload_submit_seconds = 0. - for index, block in enumerate(model.transformer_blocks): + block_order = list(range(len(model.transformer_blocks))) + if streamed_source is not None and streamed_source.direct_read: + # Pins and views are granted in the order blocks are prepared, and + # the blocks RAM cannot keep are read from the drive every step. + # Prepared in index order those were the last blocks of the model. + from .streamed_weights import spread_layer_order + block_order[resident_blocks:] = [resident_blocks + index for index in + spread_layer_order(len(block_order) - resident_blocks)] + for prepared, index in enumerate(block_order, 1): + block = model.transformer_blocks[index] prefix = f'transformer_blocks.{index}.' adaln_started = time.perf_counter() table = table_cache.load(index, steps) if table_cache is not None else None @@ -343,10 +352,10 @@ def prepare_streamed(block, index, unread=False): # A block whose sampling table is computed here, for a step count # without stored tables, takes far longer than a cached one; report # each of those so the load visibly moves. - if (index + 1) % 5 == 0 or (adaln_cache and not cache_hit): - print(json.dumps({'event': 'prepared_blocks', 'blocks': index + 1, + if prepared % 5 == 0 or (adaln_cache and not cache_hit): + print(json.dumps({'event': 'prepared_blocks', 'blocks': prepared, 'adaln_cache': adaln_cache, 'adaln_reused_blocks': table_cache_hits, - 'resident_uploaded_blocks': min(index + 1, resident_blocks), + 'resident_uploaded_blocks': min(prepared, resident_blocks), 'phase': 'Load cached blocks and upload resident weights', 'seconds': time.perf_counter() - started}), flush=True) if adaln_cache: diff --git a/freevideo_engine/runtime_alias.py b/freevideo_engine/runtime_alias.py new file mode 100644 index 00000000..02c93c7c --- /dev/null +++ b/freevideo_engine/runtime_alias.py @@ -0,0 +1,781 @@ +"""Short Windows runtime roots; persistent configuration keeps the real paths. + +The private backend is replaceable in tests. Selected roots are resolved using +extended I/O; alias descendants are translated only after authentication. +""" +import ctypes +from contextlib import contextmanager +import errno +from functools import lru_cache +import hashlib +import json +import ntpath +import os +from pathlib import Path +import re +import stat +import struct + +from .long_paths import extended, io_path, path_chars, plain, resolved +from .package_paths import ALIAS_ROOT_RESERVE, KNOWN_RUNTIME_PATH_CHARS, WINDOWS_PATH_LIMIT + +RESERVE = 40 +PROGRAMS = ('.exe',) +LOADED = PROGRAMS + ('.dll', '.pyd') # What Windows starts or loads, never from beyond the limit. +# The licence texts a package carries are a runtime's deepest files, and no program opens them. +LICENCES = '.dist-info/licenses/' +_NAME = re.compile(r'(?:[0-9a-f]{8}|[0-9a-f]{12}|[0-9a-f]{16})', re.IGNORECASE) + + +class AliasUnavailable(OSError): + """An alias cannot be used; original_error and __cause__ retain the cause.""" + def __init__(self, reason, original_error=None): + if reason not in ('location', 'filesystem', 'other', 'invalid'): + raise ValueError('Unknown alias failure reason') + self.reason = reason + self.original_error = original_error + super().__init__(getattr(original_error, 'errno', None), + 'Runtime alias unavailable (%s): %s' % (reason, original_error or reason)) + + +def _rtl_long_paths(): + from .long_paths import system_setting + if system_setting() is not None: + # This process changed its own answer (long_paths.long_path_process); the question + # here is the system's, which every other program this one starts still gets. + return system_setting() + function = ctypes.WinDLL('ntdll').RtlAreLongPathsEnabled + function.argtypes = [] + function.restype = ctypes.c_ubyte # BOOLEAN, not Win32 BOOL + return bool(function()) + + +def _registry_long_paths(): + import winreg + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, + r'SYSTEM\CurrentControlSet\Control\FileSystem') as key: + value, kind = winreg.QueryValueEx(key, 'LongPathsEnabled') + return kind == winreg.REG_DWORD and value == 1 + + +# Windows SDK: winnt.h, winbase.h, fileapi.h and winioctl.h. Fixed-width +# fields keep the layouts testable on hosts where C long is not a DWORD. +_DWORD = ctypes.c_uint32 +_BOOL = ctypes.c_int32 +_HANDLE = ctypes.c_void_p +_DIRECTORY = 0x10 +_REPARSE = 0x400 +_MOUNT_POINT = 0xA0000003 +_READ_ATTRIBUTES = 0x80 +_LIST_DIRECTORY = 0x1 +_DELETE = 0x10000 +_GENERIC_WRITE = 0x40000000 +_SHARE_READ = 1 +_OPEN_EXISTING = 3 +_OPEN_REPARSE = 0x00200000 +_BACKUP_SEMANTICS = 0x02000000 +_GET_REPARSE = 0x000900A8 +_SET_REPARSE = 0x000900A4 +_MAX_REPARSE = 16384 +_FILE_DISPOSITION_INFO = 4 +_FILE_ID_INFO = 18 +_FILE_FULL_DIRECTORY_INFO = 14 +_FILE_FULL_DIRECTORY_RESTART_INFO = 15 +_NO_MORE_FILES = 18 + + +class _FileTime(ctypes.Structure): + _fields_ = [('low', _DWORD), ('high', _DWORD)] + + +class _FileInformation(ctypes.Structure): + _fields_ = [('attributes', _DWORD), ('creation', _FileTime), + ('access', _FileTime), ('write', _FileTime), ('volume', _DWORD), + ('size_high', _DWORD), ('size_low', _DWORD), ('links', _DWORD), + ('id_high', _DWORD), ('id_low', _DWORD)] + + +class _FileId(ctypes.Structure): + _fields_ = [('volume', ctypes.c_uint64), ('id', ctypes.c_ubyte * 16)] + + +class _Disposition(ctypes.Structure): + _fields_ = [('DeleteFile', ctypes.c_ubyte)] # BOOLEAN (one byte) + + +def _mount_buffer(root): + target = plain(root, windows=True) + drive, tail = ntpath.splitdrive(target) + if (len(drive) != 2 or drive[1] != ':' or not tail.startswith('\\') + or '\0' in target): + raise AliasUnavailable('filesystem', OSError(errno.ENOTSUP, 'A local drive target is required')) + substitute = ('\\??\\' + target).encode('utf-16-le') + display = target.encode('utf-16-le') + paths = substitute + b'\0\0' + display + b'\0\0' + size = 8 + len(paths) # mount fields, excluding the common 8-byte header + if 8 + size > _MAX_REPARSE: + raise AliasUnavailable('filesystem', OSError(errno.ENOTSUP, 'Mount point data exceeds 16 KB')) + return struct.pack(' len(data) or size + 8 > _MAX_REPARSE: + raise OSError(errno.EIO, 'Invalid reparse data length') + for start, count in ((offset, length), (print_offset, print_length)): + if start % 2 or count % 2 or start + count > size - 8: + raise OSError(errno.EIO, 'Invalid mount point name range') + try: + target = data[16 + offset:16 + offset + length].decode('utf-16-le') + except UnicodeError as error: + raise OSError(errno.EIO, 'Invalid mount point encoding') from error + if not target.startswith('\\??\\'): + raise OSError(errno.ENOTSUP, 'Unsupported mount point namespace') + target = target[4:] + if target.startswith('UNC\\'): + target = '\\\\' + target[4:] + if '\0' in target or not ntpath.isabs(target): + raise OSError(errno.EIO, 'Invalid mount point target') + return target + + +class _WinAPI: + """Lazy bindings: importing this module on Linux/Mac has no Windows effects.""" + def __init__(self): + dll = ctypes.WinDLL('kernel32', use_last_error=True) + declarations = { + '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), + 'CloseHandle': ([_HANDLE], _BOOL), + 'DeviceIoControl': ([_HANDLE, _DWORD, ctypes.c_void_p, _DWORD, + ctypes.c_void_p, _DWORD, ctypes.POINTER(_DWORD), ctypes.c_void_p], _BOOL), + 'GetFileInformationByHandle': ([_HANDLE, ctypes.POINTER(_FileInformation)], _BOOL), + 'GetFileInformationByHandleEx': ([_HANDLE, ctypes.c_int, ctypes.c_void_p, _DWORD], _BOOL), + 'SetFileInformationByHandle': ([_HANDLE, ctypes.c_int, ctypes.c_void_p, _DWORD], _BOOL), + 'CreateMutexW': ([ctypes.c_void_p, _BOOL, ctypes.c_wchar_p], _HANDLE), + 'WaitForSingleObject': ([_HANDLE, _DWORD], _DWORD), + 'ReleaseMutex': ([_HANDLE], _BOOL), + } + for name, (arguments, result) in declarations.items(): + function = getattr(dll, name) + function.argtypes, function.restype = arguments, result + setattr(self, name, function) + + def check(self, result): + if not result: + raise ctypes.WinError(ctypes.get_last_error()) + return result + + def open(self, path, access=_READ_ATTRIBUTES, share=7, reparse=True): + handle = self.CreateFileW(str(path), access, share, None, _OPEN_EXISTING, + _BACKUP_SEMANTICS | (_OPEN_REPARSE if reparse else 0), None) + if handle == ctypes.c_void_p(-1).value: + raise ctypes.WinError(ctypes.get_last_error()) + return handle + + def info(self, handle): + info = _FileInformation() + self.check(self.GetFileInformationByHandle(handle, ctypes.byref(info))) + return info + + def identity(self, path, follow=True): + handle = self.open(extended(path, windows=True), reparse=not follow) + try: + info = _FileId() + self.check(self.GetFileInformationByHandleEx(handle, _FILE_ID_INFO, + ctypes.byref(info), ctypes.sizeof(info))) + return info.volume, bytes(info.id) + finally: + self.CloseHandle(handle) + + def empty(self, handle): + # FILE_FULL_DIR_INFO enumeration stays on the opened object, even if + # its name was replaced. FileName starts at byte 68 (fixed SDK fields). + header = struct.Struct(' len(buffer): + raise OSError(errno.EIO, 'Invalid directory information') + fields = header.unpack_from(buffer.raw, offset) + following, length = fields[0], fields[-2] + end = offset + header.size + length + if not length or length % 2 or end > len(buffer): + raise OSError(errno.EIO, 'Invalid directory filename') + if buffer.raw[offset + header.size:end] not in (b'.\0', b'.\0.\0'): + return False + if not following: + break + if following % 8 or following < header.size + length: + raise OSError(errno.EIO, 'Invalid directory entry offset') + offset += following + kind = _FILE_FULL_DIRECTORY_INFO + + def target(self, handle): + buffer = ctypes.create_string_buffer(_MAX_REPARSE) + count = _DWORD() + self.check(self.DeviceIoControl(handle, _GET_REPARSE, None, 0, + buffer, len(buffer), ctypes.byref(count), None)) + return _mount_target(buffer.raw[:count.value]) + + def dispose(self, handle): + disposition = _Disposition(1) + self.check(self.SetFileInformationByHandle(handle, _FILE_DISPOSITION_INFO, + ctypes.byref(disposition), ctypes.sizeof(disposition))) + + +@lru_cache(maxsize=1) +def _win_api(): + return _WinAPI() + + +class _Backend: + """Windows effects only; tests substitute symlinks and temporary locations.""" + def windows(self): + return os.name == 'nt' + + def long_paths(self): + try: + return _rtl_long_paths() + except (AttributeError, OSError): + try: + return _registry_long_paths() + except (ImportError, AttributeError, OSError, ValueError): + return False + + 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()] + + def io(self, path): + return io_path(path) + + def mkdir(self, path): + path.mkdir(parents=True, exist_ok=True) + + @contextmanager + def coordinate(self, root): + # Kernel mutexes disappear with the last process handle; no lock files. + api = _win_api() + name = 'Local\\FreeVideo.RuntimeAlias.' + hashlib.sha1(_normal(root).encode('utf-8')).hexdigest() + mutex = api.check(api.CreateMutexW(None, False, name)) + acquired = False + try: + result = api.WaitForSingleObject(mutex, 10000) + acquired = result in (0, 0x80) # WAIT_OBJECT_0 / WAIT_ABANDONED + if not acquired: + if result == 0xFFFFFFFF: + raise ctypes.WinError(ctypes.get_last_error()) + raise OSError(errno.EBUSY, 'Runtime alias creation is busy') + yield + finally: + if acquired: + api.ReleaseMutex(mutex) + api.CloseHandle(mutex) + + @contextmanager + def parent(self, path): + api = _win_api() + # Do not follow FV. Deny writes/deletion while using its children, so + # FV cannot be renamed or converted to a redirect after the check. + # Attribute-only access is exempt from share checks. Include directory + # read access so this handle really excludes other writers/deleters. + handle = api.open(self.io(path), _READ_ATTRIBUTES | _LIST_DIRECTORY, share=_SHARE_READ) + try: + attributes = api.info(handle).attributes + if not attributes & _DIRECTORY or attributes & _REPARSE: + raise FileExistsError(errno.EEXIST, 'Alias parent is redirected or not a directory', str(path)) + yield + finally: + api.CloseHandle(handle) + + def validate_root(self, root): + _mount_buffer(root) # Unsupported UNC/oversize targets fail before I/O. + api = _win_api() + handle = api.open(extended(root, windows=True)) + try: + attributes = api.info(handle).attributes + if not attributes & _DIRECTORY or attributes & _REPARSE: + raise OSError(errno.ENOTDIR, 'Runtime root must be an ordinary directory', str(root)) + finally: + api.CloseHandle(handle) + + def create(self, root, alias): + data = _mount_buffer(root) # Reject UNC / >16 KB before creating anything. + self.validate_root(root) + api = _win_api() + with self.parent(alias.parent): + api.check(api.CreateDirectoryW(str(alias), None)) + try: + handle = api.open(alias, _GENERIC_WRITE | _DELETE | _READ_ATTRIBUTES | _LIST_DIRECTORY, + share=_SHARE_READ) + except OSError as error: + # CreateDirectoryW does not return an object handle. With no + # held identity a path-based cleanup could delete a replacement. + error.add_note('No alias handle was acquired; the placeholder was retained: ' + str(alias)) + raise + ordinary = False + try: + attributes = api.info(handle).attributes + if not attributes & _DIRECTORY or attributes & _REPARSE: + raise OSError(errno.EIO, 'New alias directory changed before opening') + if not api.empty(handle): + raise OSError(errno.ENOTEMPTY, 'New alias directory is not empty') + ordinary = True + buffer = ctypes.create_string_buffer(data, len(data)) + count = _DWORD() + api.check(api.DeviceIoControl(handle, _SET_REPARSE, buffer, len(data), + None, 0, ctypes.byref(count), None)) + return handle # Held through verification, including rollback. + except OSError as error: + try: + if ordinary: + self.remove_created(handle, root) + except OSError as cleanup_error: + error.add_note('Could not remove the new directory: ' + str(cleanup_error)) + api.CloseHandle(handle) + raise + + def readlink(self, path): + api = _win_api() + handle = api.open(path) + try: + return api.target(handle) + finally: + api.CloseHandle(handle) + + def target(self, handle): + return _win_api().target(handle) + + def identity(self, path, follow=True): + return _win_api().identity(path, follow) + + def close(self, handle): + _win_api().CloseHandle(handle) + + def junction(self, path): + info = os.lstat(path) + # lstat reads the reparse point itself, including dangling junctions. + return (getattr(info, 'st_reparse_tag', 0) == stat.IO_REPARSE_TAG_MOUNT_POINT + or (getattr(info, 'st_reparse_tag', 0) == stat.IO_REPARSE_TAG_SYMLINK + and bool(getattr(info, 'st_file_attributes', 0) & stat.FILE_ATTRIBUTE_DIRECTORY))) + + def stat(self, path): + return path.stat() + + def _delete_checked(self, handle, root, *, created=False): + api = _win_api() + attributes = api.info(handle).attributes + if not attributes & _DIRECTORY: + return False + if attributes & _REPARSE: + # target() rejects every tag except MOUNT_POINT. Check and dispose + # use this same exclusive / non-delete-shared object handle. + if not _same_target(api.target(handle), root): + return False + elif not created: + return False + api.dispose(handle) + return True + + def remove_created(self, handle, root): + # Only create()'s continuously held handle can authorize a plain + # placeholder. There is deliberately no path-based rollback fallback. + return self._delete_checked(handle, root, created=True) + + def remove(self, path, root): + api = _win_api() + with self.parent(path.parent): + handle = api.open(path, _DELETE | _READ_ATTRIBUTES, share=0) + try: + return self._delete_checked(handle, root) + finally: + api.CloseHandle(handle) + + +_backend = _Backend() + + +@lru_cache(maxsize=1) +def system_long_paths() -> bool: + return not _backend.windows() or _backend.long_paths() + + +def _ordinary(path): + return Path(plain(path, windows=True)) + + +def _normal(path): + # Lexical normalization only; ntpath also makes Windows comparisons testable + # on Linux. Absolute conversion never looks up the target on disk. + value = os.path.abspath(plain(path, windows=True)) + return ntpath.normcase(ntpath.normpath(value)).rstrip('\\/').lower() + + +def _same_target(left, right): + if _normal(left) == _normal(right): + return True + try: + left_id, right_id = _backend.identity(_ordinary(left)), _backend.identity(_ordinary(right)) + return left_id is not None and left_id == right_id + except (OSError, ValueError): + return False # No physical identity is never permission to reuse/delete. + + +def _same_object(left, right): + """The same directory entry itself; a redirect is compared, never followed.""" + try: + left_id, right_id = (_backend.identity(_ordinary(item), follow=False) for item in (left, right)) + return left_id is not None and left_id == right_id + except (OSError, ValueError): + return False + + +def _leads_to(directory, location): + """Whether a directory may be the alias location reached some other way. + + A link straight to the location is followed, as file operations through it + are. The location itself is not: the target of a redirected location stays + ordinary. Without an identity only the folder's own name is left to go by. + """ + try: + there = _backend.identity(_ordinary(location), follow=False) + here = _backend.identity(_ordinary(directory)) + except (FileNotFoundError, NotADirectoryError): + return False # One of them is not there: nothing leads from one into the other. + except (OSError, ValueError): + here = there = None + if here is None or there is None: + return directory.name.lower() == location.name.lower() + return here == there + + +def _candidates(root, parent): + identity = hashlib.sha1(_normal(root).encode('utf-8')).hexdigest() + return [parent / identity[:length] for length in (8, 12, 16)] + + +@lru_cache(maxsize=32) +def _manifest_longest(path, mtime_ns, size, suffixes=()): + try: + value = json.loads(_backend.io(Path(path)).read_text(encoding='utf-8')) + rows = value['files'] + if not isinstance(rows, list) or not rows: + return KNOWN_RUNTIME_PATH_CHARS + names = [row['path'] for row in rows] + if any(not isinstance(name, str) or not name for name in names): + return KNOWN_RUNTIME_PATH_CHARS + if suffixes: + names = [name for name in names if name.lower().endswith(suffixes)] + else: + names = [name for name in names if LICENCES not in name.replace('\\', '/').lower()] + return max(map(path_chars, names), default=0) + except (OSError, ValueError, KeyError, TypeError, UnicodeError): + return KNOWN_RUNTIME_PATH_CHARS + + +def _longest(root, suffixes=()): + """The longest path among the files a running installation opens; with suffixes, among those that end in one.""" + path = _backend.io(root / 'portable.json') + try: + info = path.stat() + except OSError: + return KNOWN_RUNTIME_PATH_CHARS + return _manifest_longest(str(path), info.st_mtime_ns, info.st_size, suffixes) + + +def needs_view(root, longest=None) -> bool: + if not _backend.windows(): + return False + root = _ordinary(resolved(checked_real_path(root))) + if os.environ.get('FREEVIDEO_RUNTIME_ALIAS') == '0': + return False + if os.environ.get('FREEVIDEO_RUNTIME_ALIAS') == '1': + 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(): + # With the system's long-path support on, files open at any length. A program or a + # library beyond the limit still does not start or load: those decide, with no margin. + return path_chars(root) + 1 + _longest(root, LOADED) > WINDOWS_PATH_LIMIT + # With it off, every file the installation opens counts, and RESERVE for those it makes. + longest = _longest(root) if longest is None else longest + return path_chars(root) + 1 + longest + RESERVE > WINDOWS_PATH_LIMIT + + +def is_junction(path) -> bool: + """Any Windows directory junction or directory symlink, wherever it lives.""" + if not _backend.windows(): + return False + try: + return _backend.junction(_backend.io(_ordinary(path))) + except OSError: + return False + + +def _view_location(path): + path = Path(os.path.abspath(_ordinary(path))) + # Keep the input lexical: resolving it would erase an invalid alias boundary. + # Ordinary names incur no filesystem calls. Check outer digest ancestors first. + for candidate in reversed((path, *path.parents)): + if not _NAME.fullmatch(candidate.name): + continue + parents = [_ordinary(parent).absolute() for parent in _backend.locations()] + for parent in parents: + if _normal(candidate.parent) == _normal(parent): + return candidate, parent / candidate.name + for parent in parents: + try: + # Both spellings must exist and themselves be ordinary directories. + # Ancestor junctions, 8.3 names and subst may still name the same FV. + with _backend.parent(candidate.parent), _backend.parent(parent): + if _same_target(candidate.parent, parent): + return candidate, parent / candidate.name + except (OSError, ValueError): + # A redirected FV is never trusted, and its target never becomes alias + # space. Another spelling of that same redirect is still this location, + # so the name goes on to authentication, which refuses it. + if _same_object(candidate.parent, parent): + return candidate, parent / candidate.name + if any(_leads_to(candidate.parent, parent) for parent in parents): + # Not a spelling of the location, and not known to be elsewhere: + # never translated, and never taken for an ordinary folder. + return candidate, None + return None, None + + +def _view_root(path): + return _view_location(path)[0] + + +def is_view(path) -> bool: + if not _backend.windows(): + return False + root, target = _authenticated_view(path) + return target is not None and _normal(path) == _normal(root) + + +def _authenticated_view(path): + # Membership must precede target resolution. Lexical slots remain alias + # inputs even when FV is redirected/unreadable or the occupant is ordinary. + root, slot = _view_location(path) + if root is None or slot is None: + return root, None + try: + with _backend.parent(slot.parent): + if not _backend.junction(_backend.io(slot)): + return root, None + # Native readlink opens the object and accepts only MOUNT_POINT. + target = _ordinary(_backend.readlink(_backend.io(slot))) + if not target.is_absolute(): + target = slot.parent / target + _backend.validate_root(target) # Exists, ordinary directory, no chain. + candidates = _candidates(target, slot.parent) + if not any(_normal(slot) == _normal(item) for item in candidates): + # GetFinalPathName (via extended resolve) expands an 8.3 target. + # Alternate spelling alone must never authorize a different ID. + physical = _ordinary(resolved(target)) + if not _same_target(target, physical) or not any( + _normal(slot) == _normal(item) for item in _candidates(physical, slot.parent)): + return root, None + return root, target + except (OSError, ValueError): + return root, None + + +def real_path(path) -> Path: + if not _backend.windows(): + return Path(path) + original = Path(path) + absolute = Path(os.path.abspath(_ordinary(path))) + root, target = _authenticated_view(absolute) + if target is not None: + return target / absolute.relative_to(root) + return original + + +def checked_real_path(path) -> Path: + """Translate authenticated aliases; reject occupants of invalid alias slots.""" + if not _backend.windows(): + return Path(path) + root, target = _authenticated_view(path) + if root is None: + return Path(path) + if target is None: + raise AliasUnavailable('invalid') + absolute = Path(os.path.abspath(_ordinary(path))) + return target / absolute.relative_to(root) + + +def _matches(alias, root): + if not is_junction(alias): + return False + target = _ordinary(_backend.readlink(_backend.io(alias))) + if not target.is_absolute(): + target = alias.parent / target + return _same_target(target, root) + + +def _reusable(alias, root): + _, target = _authenticated_view(alias) + return target is not None and _same_target(target, root) + + +def _probe(root): + for name in ('portable.json', 'runtime.json', 'python/python.exe', + 'python/bin/python', 'ComfyUI/main.py', 'engine/machine.json'): + if _backend.io(root / name).is_file(): + return Path(name) + with os.scandir(_backend.io(root)) as entries: + for entry in entries: + if entry.is_file(follow_symlinks=False): + return Path(entry.name) + raise FileNotFoundError(errno.ENOENT, 'No known runtime file to verify through the alias', str(root)) + + +def _reason(error, default='other'): + if getattr(error, 'winerror', None) in (1, 50, 87, 4390, 4392, 4393) or error.errno in ( + errno.ENOSYS, errno.ENOTSUP): + return 'filesystem' + if isinstance(error, (PermissionError, FileExistsError)) or error.errno in ( + errno.EACCES, errno.EPERM, errno.EROFS, errno.ENOSPC): + return 'location' + return default + + +def _verify(alias, root, probe, created): + try: + matches = (_same_target(_backend.target(created), root) if created is not None + else _matches(alias, root)) + if not matches: + raise OSError(errno.EIO, 'Junction target changed during creation', str(alias)) + _backend.stat(alias / probe) # Deliberately ordinary runtime spelling. + except OSError as error: + failure = AliasUnavailable(_reason(error), error) + if created is not None: + try: + _backend.remove_created(created, root) + except OSError as cleanup_error: + failure.cleanup_error = cleanup_error + failure.add_note('Could not remove the new junction: ' + str(cleanup_error)) + raise failure from error + + +def _starts_as_it_is(root): + return (os.environ.get('FREEVIDEO_RUNTIME_ALIAS') != '1' + and path_chars(root) + 1 + _longest(root, PROGRAMS) <= WINDOWS_PATH_LIMIT) + + +def runtime_view(root, longest=None) -> Path: + if not _backend.windows(): + return Path(root) + try: + root = _ordinary(resolved(checked_real_path(root))) + if not needs_view(root, longest): + return root + try: + with _backend.coordinate(root): + return _runtime_view(root) + except OSError: + # No short folder can be made. A runtime whose programs start from where they are + # runs as it did before there was a short folder; only one that cannot is refused. + if longest is None and _starts_as_it_is(root): + return root + raise + except AliasUnavailable: + raise + except OSError as error: + raise AliasUnavailable(_reason(error), error) from error + + +def _runtime_view(root): + try: + _backend.validate_root(root) + probe = _probe(root) + except AliasUnavailable: + raise + except OSError as error: + raise AliasUnavailable(_reason(error), error) from error + last = OSError(errno.ENOENT, 'No alias location is configured') + reason = 'location' + for parent in _backend.locations(): + parent = _ordinary(parent) + if is_junction(parent): + last = FileExistsError(errno.EEXIST, 'Alias parent is redirected', str(parent)) + reason = 'location' + continue + for alias in _candidates(root, parent): + if path_chars(alias) > ALIAS_ROOT_RESERVE: + last = OSError(errno.ENAMETOOLONG, 'Alias root exceeds its reserved path budget', str(alias)) + reason = 'location' + break + created = None + try: + if not _backend.io(parent).exists(): + _backend.mkdir(parent) + with _backend.parent(parent): + # Pin FV before inspecting children, including reuse. + if _reusable(alias, root): + _verify(alias, root, probe, None) + return _ordinary(alias) + if os.path.lexists(_backend.io(alias)): + last = FileExistsError(errno.EEXIST, 'Alias name is occupied', str(alias)) + reason = 'location' + continue + try: + created = _backend.create(root, alias) + except OSError: + # Another launcher may have won between lexists/create. + if not _reusable(alias, root): + raise + try: + _verify(alias, root, probe, created) + return _ordinary(alias) + finally: + if created is not None: + _backend.close(created) + except AliasUnavailable: + raise # A failed verification is not a name/location collision. + except OSError as error: + last, reason = error, _reason(error) + continue + raise AliasUnavailable(reason, last) from last + + +def drop_view(root): + if not _backend.windows(): + return + given = Path(os.path.abspath(real_path(_ordinary(root)))) + roots = [given] + try: + # runtime_view names the junction after the physical folder; the caller may + # spell the same folder differently (an 8.3 name, a link of the user's own). + physical = _ordinary(resolved(given)) + if _normal(physical) != _normal(given): + roots.insert(0, physical) + except (OSError, ValueError): + pass # The folder is gone; only the given spelling can be matched. + for parent in _backend.locations(): + parent = _ordinary(parent) + if is_junction(parent): + continue + for target in roots: + for alias in _candidates(target, parent): + try: + _backend.remove(alias, target) + except OSError: + # Missing, busy or unreadable objects remain untouched. + pass diff --git a/freevideo_engine/runtime_libraries.py b/freevideo_engine/runtime_libraries.py index 0c4c025f..1cd0e7d6 100644 --- a/freevideo_engine/runtime_libraries.py +++ b/freevideo_engine/runtime_libraries.py @@ -19,6 +19,8 @@ import re import shlex +from .long_paths import io_path, plain, resolved + # ComfyUI messages choose between this and the page's own setup (repair_hint). from .comfy_launcher_api import LAUNCHER_REPAIR as REINSTALL @@ -46,11 +48,11 @@ def mismatch(error): def site_packages(environment): """The site-packages folder of a virtual environment, Windows or POSIX.""" - environment = Path(environment) + environment = io_path(environment) if (environment / 'Lib' / 'site-packages').is_dir(): - return environment / 'Lib' / 'site-packages' + return Path(plain(environment / 'Lib' / 'site-packages')) found = sorted(environment.glob('lib/python*/site-packages')) - return found[-1] if found else None + return Path(plain(found[-1])) if found else None def _digest(path, algorithm): @@ -63,8 +65,8 @@ def _digest(path, algorithm): def _canonical(path): """One spelling per file for comparisons: links resolved, case folded on Windows.""" - text = os.path.realpath(str(path)) - return os.path.normcase(text[4:] if text.startswith('\\\\?\\') else text) + text = os.path.realpath(io_path(path)) + return os.path.normcase(plain(text)) def check(site): @@ -189,10 +191,10 @@ def summary(result, repair=REINSTALL): def site_for(python): """site-packages of the environment a Python executable runs: a virtual environment (Scripts/ or bin/ beside it) or an embedded Windows Python.""" - python = Path(python) + python = io_path(python) for candidate in (python.parent / 'Lib' / 'site-packages', python.parent.parent / 'Lib' / 'site-packages'): if candidate.is_dir(): - return candidate + return Path(plain(candidate)) return site_packages(python.parent.parent) @@ -204,7 +206,7 @@ def _requirement_rows(requirements): pending, seen, rows = [Path(requirements)], set(), [] while pending: try: - path = pending.pop().resolve() + path = resolved(pending.pop()) if path in seen: continue seen.add(path) @@ -324,6 +326,7 @@ def incomplete(python): site = site_for(python) if site is None: return [] + site = io_path(site) parent = Path(python).parent root = _canonical(parent.parent if parent.name.lower() in ('bin', 'scripts') else parent) found = set() @@ -342,7 +345,7 @@ def incomplete(python): if not path.startswith(root + os.sep): continue try: - size = os.path.getsize(path) + size = os.path.getsize(io_path(path)) except (FileNotFoundError, NotADirectoryError): size = None except OSError: diff --git a/freevideo_engine/sampling_assets.py b/freevideo_engine/sampling_assets.py index bee8f514..e6114dd0 100644 --- a/freevideo_engine/sampling_assets.py +++ b/freevideo_engine/sampling_assets.py @@ -9,14 +9,16 @@ from pathlib import Path import time from . import disk_space +from .long_paths import io_path, plain_errors COMMUNITY_PREFIX = 'community-sigma3-' WORKERS = 6 +@plain_errors def tables(): from .prepared_model import CATALOG - catalog = json.loads(CATALOG.read_text(encoding='utf-8')) + catalog = json.loads(io_path(CATALOG).read_text(encoding='utf-8')) banks = catalog.get('optional_adaln_sets', []) + [catalog.get('optional_adaln', {})] seen, result = set(), [] for bank in banks: @@ -67,7 +69,7 @@ def usable_with(cache): """Published tables match prepared weights; a locally converted cache may not.""" from . import adaln_assets as assets try: - manifest = json.loads((Path(cache) / 'manifest.json').read_text(encoding='utf-8')) + manifest = json.loads((io_path(cache) / 'manifest.json').read_text(encoding='utf-8')) weights = assets.weight_identity(manifest) except (OSError, ValueError, KeyError, TypeError): return False @@ -78,7 +80,7 @@ def installed(machine): """Every quality level is on disk, or none applies to these weights.""" if not usable_with(machine['cache']): return True - folders = (cache_root(machine['model_root']), Path(machine['cache'])) + folders = (io_path(cache_root(machine['model_root'])), io_path(machine['cache'])) def present(row): return any((folder / row['sampling_file']).is_file() and (folder / row['sampling_file']).stat().st_size == row['bytes'] for folder in folders) diff --git a/freevideo_engine/stage_estimate.py b/freevideo_engine/stage_estimate.py new file mode 100644 index 00000000..c06f1cbd --- /dev/null +++ b/freevideo_engine/stage_estimate.py @@ -0,0 +1,522 @@ +"""Presentation-only port of the 2026-10-10 fitted stage model v2. + +Standard library only; coefficients and GPU table live in JSON. This is not a +resource planner. Unknown devices/flags retain the fit's broad fallback. +The six disjoint UI stages are STAGES; decode/video_decode/save_other remain +available for parity and diagnostics and must not also be summed with decode_save. + +JSON v2 retains v1 keys and adds v2.selection (predictor names), v2.ratio +(log-ratio coefficients), v2.physical_cs (C and streaming coefficients), +v2.decode (free/log_prior/log_live fits), and v2.warmup (singleton corrections). +Selected ratio_offload uses (50 - pass2_resident_blocks) / 50; missing placement +keeps resident()'s documented coarse VRAM fallback. Decoder fits use the same +unknown-residency fallback as v1. No resource plan is inferred by this module. +""" +from functools import lru_cache +import json +import math +from pathlib import Path +import re +from statistics import median + +TOKEN_UNIT = 72576. +DEFAULT_INTERVAL = [.45, 2.2] +STAGES = ('encode', 'load', 'pass1', 'upscale', 'pass2', 'decode_save') +PARAMS_PATH = Path(__file__).with_name('stage_estimate_params.json') + + +@lru_cache(maxsize=2) +def _read_params(path, modified): + return json.loads(Path(path).read_text(encoding='utf-8')) + + +def _params(params): + if params is not None: + return params + return _read_params(str(PARAMS_PATH), PARAMS_PATH.stat().st_mtime_ns) + + +def _num(obj, key, default): + value = obj.get(key) + return default if value is None else value + + +def _dot(left, right): + return sum(a * b for a, b in zip(left, right)) + + +def gpu_key(name): + """Driver/marketing aliases, preserving mobile and power-limited variants.""" + key = ' '.join(str(name or 'unknown').replace('®', '').replace('™', '').split()) + key = re.sub(r'^(?:(?:NVIDIA(?: Corporation)?|GeForce|Quadro)\s+)+', '', key, flags=re.I) + key = re.sub(r'\b(?:Server|Workstation)\s+Edition\b|\bEdition\b', '', key, flags=re.I) + mobile = bool(re.search(r'\bLaptop(?:\s+GPU)?\b|\bMax[- ]?Q(?:\s+Design)?\b', key, re.I)) + # The PRO 6000 Max-Q is a 300 W desktop, not a laptop; retain its own prior. + desktop_max_q = mobile and bool(re.search(r'\bRTX\s+PRO\s+6000\s+Blackwell\b', key, re.I)) and 'laptop' not in key.lower() + key = re.sub(r'\bLaptop(?:\s+GPU)?\b|\bMax[- ]?Q(?:\s+Design)?\b', '', key, flags=re.I) + key = ' '.join(key.split()) + return key + (' Max-Q' if desktop_max_q else ' Laptop GPU' if mobile else '') + + +def _gpu_item(h, p): + key = _gpu(h) + table = p['gpu_speed_table'] + if key in table: + return table[key] + normalized = key.casefold() + return next((item for name, item in table.items() if gpu_key(name).casefold() == normalized), None) + + +def _decode_save(result): + # Marginal endpoint sums, not a newly calibrated joint interval. + stages = result['stages'] + stages['decode_save'] = {k: stages['decode'][k] + stages['save_other'][k] + for k in ('seconds', 'p20', 'p80')} + return result + + +def ram_tier(h): + ram = _num(h, 'effective_ram_gib', _num(h, 'ram_total_gib', 32.)) + # Consumer 64 GB installations often expose ~56-63.9 GiB. The middle + # bucket also contains 24/48 GiB machines; 48 GiB boundary is explicit. + return 0 if ram <= 16.5 else 1 if ram < 48 else 2 + + +def _gpu(h): + return gpu_key(h.get('physical_gpu') or h.get('real_gpu') or h.get('gpu_name')) + + +def _apple(h): + return 'Apple' in _gpu(h) or h.get('os') == 'Darwin' + + +def speed(h, p, spec_only=False): + key = _gpu(h) + if _apple(h): + return 1. + item = _gpu_item(h, p) + if item: + return item.get('G_spec' if spec_only else 'G', item['G_spec']) + if h.get('tensor_fp16_dense_tflops'): + return max(.05, float(h['tensor_fp16_dense_tflops'])/94.8 * (.8 if _laptop(h) else 1.)) + if h.get('tensor_int8_dense_tops'): + return max(.05, float(h['tensor_int8_dense_tops'])/189.6 * (.8 if _laptop(h) else 1.)) + return 1. # Explicit unknown-device fallback; surfaced in warnings. + + +def canvases(r): + w, h = int(_num(r, 'width', 1344)), int(_num(r, 'height', 768)) + f = int(_num(r, 'frames', math.ceil(_num(r, 'seconds', 10.)*24))) + f += (5-f) % 17 + if w <= 0 or h <= 0 or w % 32 or h % 32 or f < 39: + raise ValueError('Use positive 32-aligned canvases and at least 39 H3 frames.') + full = dict(width=w, height=h, frames=f) + sg = r.get('sampling_geometry') or {} + two = bool(_num(r, 'two_pass', True)) + if sg.get('first'): + first = dict(full, **sg['first']) + elif two and min(w, h) >= 512: + first = dict(full, width=((w+63)//64)*32, height=((h+63)//64)*32) + elif two: + d = math.gcd(w//32, h//32) + uw, uh = w//32//d, h//32//d + m = max((d+1)//2, (8+uw-1)//uw, (8+uh-1)//uh) + first = dict(full, width=32*uw*m, height=32*uh*m) + else: + first = dict(full) + second = dict(full, **(sg.get('second') or {})) + decode = first if r.get('flow') == 'preview' else full + return first, second, decode + + +def token_count(canvas): + f = int(canvas['frames']) + # Verified in engine/geometry.py and refine.py: H3 is 17 frames -> 5, + # with 2 latent frames for the initial 5. H/32*W/32 is patch-token grid. + if (f-5) % 17: + f += (5-f) % 17 + return ((f-5)//17*5+2)*(canvas['width']//32)*(canvas['height']//32) + + +def step_counts(r): + n1 = int(_num(r, 'base_steps', 8)) + n2 = int(_num(r, 'refine_steps', 3)) if _num(r, 'two_pass', True) else 0 + if r.get('flow') == 'preview': + n2 = 0 + if r.get('flow') == 'upscale': + n1 = 0 + return max(0, n1), max(0, n2) + + +def resident(h, r, i): + value = r.get(f'pass{i}_resident_blocks') + if value is not None: + return float(min(50, max(0, value))) + if _apple(h): + return 50. + # Deliberately coarse missing-plan fallback, not a copied product planner. + v = float(_num(h, 'vram_free_gib_start', _num(h, 'vram_total_gib', 8.))) + return float(min(44, max(0, int((v-6.)*2.48)))) + + +def stream_features(h, r, i): + if _apple(h): + return [1., 0., 0., 0., 0.] + f = (50-resident(h, r, i))/50. + # PCIe and OS effects lack independent identification in this corpus. + # Keep weak fixed priors; measured link bandwidth may override. + os_factor = .85 if h.get('os') == 'Linux' else 1. + link = float(_num(h, 'pcie_bandwidth_gbs', 16.)) # decimal GB/s, not Gb/s + f *= os_factor * 16./max(1., link) + tiers = [0., 0., 0.] + tiers[ram_tier(h)] = f + laptop = float('Laptop' in _gpu(h) or bool(h.get('laptop'))) + return [1.] + tiers + [f*laptop] + + +def step_parts(h, r, i, p): + canvas = canvases(r)[i-1] + x = token_count(canvas)/TOKEN_UNIT + # Reference tokens affect the actual attention sequence (policy.py:697). + # No guessed duration is substituted when unavailable. + ref = float(_num(r, 'reference_video_tokens', 0.)) + float(_num(r, 'reference_audio_tokens', 0.)) + x += ref/TOKEN_UNIT + if _apple(h): + b, c = p['apple']['step_bc'] + return p['apple']['step_fixed'], x*(b+c*x) + b, c = p['step']['bc_normalized'] + s = _dot(stream_features(h, r, i), p['step']['s']) + return max(0., s), x*(b+c*x)/speed(h, p) + + +def _cold_level(flags, i): + specific = flags.get(f'pass{i}_compile_cold') + if specific is not None: + return float(bool(specific)) + if flags.get(f'pass{i}_canvas_compiled') is True: + return 0. + pre = str(flags.get('precompile_state') or '').lower() + if 'disable_caches=1' in pre: + return 1. + if ('after precompile' in pre or 'repeat after precompile' in pre or pre == 'warm'): + return 0. + c = flags.get('compile_cold') + return .5 if c is None else float(bool(c)) + + +def first_extra(step, flags, i, p): + a, alpha, cold = p['first'][str(i)] + return max(0., a+alpha*step+cold*_cold_level(flags, i)) + + +def _decoder_stream(h, r): + off = r.get('decoder_offload') + if off is False or _apple(h): + return 0. + blocks = r.get('decoder_resident_blocks') + if blocks is None: + v = _num(h, 'vram_free_gib_start', _num(h, 'vram_total_gib', 8.)) + blocks = max(0., min(36., (v-3.25)*2**30/268574720)) + return max(0., (36-float(blocks))/36) + + +def _media_units(r): + if not any(str(r.get('task') or '').startswith(t) for t in ['ref2va', 'i2va', 'l2va', 'fl2va']): + return 0. + duration = r.get('reference_seconds') + return max(.1, float(duration)/5.) if duration is not None else 1. + + +def _encode_features(h, r, flags): + if flags.get('encode_cached') is True: + return [0.] * 4 + a = [0., 0., 0.] + if flags.get('encoder_cold') is not False: + a[ram_tier(h)] = 1. + return a+[_media_units(r)] + + +def _load_features(h, r, flags): + a = [0., 0., 0.] + cold = flags.get('model_cold') is not False + if cold: + a[ram_tier(h)] = 1. + return a+[float(not cold), float(_num(r, 'lora_count', 0))] + + +def _raw_base(h, r, flags, p, factor=1., mode='compute', factor_pass=1): + stages, diagnostics = {}, {} + n1, n2 = step_counts(r) + for i, n in [(1, n1), (2, n2)]: + s, comp = step_parts(h, r, i, p) + warm = (s+comp)*factor if mode == 'whole' else s+comp*factor + extra = first_extra(warm, flags, i, p) + stages[f'pass{i}'] = n*warm+extra+p['pass_wall_overhead'][str(i)] if n else 0. + diagnostics[f'pass{i}_warm_step'] = warm + diagnostics[f'pass{i}_first_extra'] = extra + diagnostics[f'pass{i}_fixed'] = s + diagnostics[f'pass{i}_compute'] = comp + _, full, dec = canvases(r) + mp = full['width']*full['height']/1e6 + up = _dot([1., mp, float(ram_tier(h)==0)], p['upscale']) if n2 else 0. + # Upscale scales with output pixels, but observed streaming factor from the + # transformer does not identify it. Leave unchanged in compute mode. + stages['upscale'] = up*factor if mode == 'whole' else up + m = dec['width']*dec['height']*dec['frames']/1e6 + for name in ['video_decode', 'decode']: + if _apple(h): + a, d = p['apple'][name] + value = a+m*d*factor + else: + a, d, stream = p[name]['coef'] + ram = p[name]['ram16_multiplier'] if ram_tier(h)==0 else 1. + value = a+m*(d/speed(h,p)*factor+stream*_decoder_stream(h,r))*ram + if mode == 'whole': + value = (a+m*(d/speed(h,p)+stream*_decoder_stream(h,r))*ram)*factor + stages[name] = max(0., value) + stages['decode'] = max(stages['decode'], stages['video_decode']) + stages['encode'] = _dot(_encode_features(h,r,flags), p['encode']) + stages['load'] = _dot(_load_features(h,r,flags), p['load']) + stages['save_other'] = p['save_other'] + stages['total'] = sum(stages[k] for k in ['encode','load','pass1','upscale','pass2','decode','save_other']) + diagnostics['G'] = speed(h,p) + diagnostics['machine_factor'] = factor + return stages, diagnostics + + +def _clip(value, low, high): + return min(high, max(low, value)) + + +def _laptop(h): + return float('Laptop' in _gpu(h) or bool(h.get('laptop'))) + + +def _ratio_features(h, r, offload=False): + x2 = token_count(canvases(r)[1]) / TOKEN_UNIT + v = [1., math.log(max(x2, 1e-6)), float(ram_tier(h) == 0), _laptop(h)] + if offload: + v.append((50-resident(h, r, 2))/50.) + return v + + +def _area(r): + a, b, _ = canvases(r) + return b['width']*b['height']/(a['width']*a['height']) + + +def _physical_transfer(h, r, p, measured, coef): + b = p['step']['bc_normalized'][0] + c, ss = coef[0], coef[1:] + ref = float(_num(r, 'reference_video_tokens', 0.)) + float(_num(r, 'reference_audio_tokens', 0.)) + x = [(token_count(g)+ref)/TOKEN_UNIT for g in canvases(r)[:2]] + s = [_dot(stream_features(h, r, i), ss) for i in [1, 2]] + if _apple(h): + s1, comp1 = step_parts(h, r, 1, p) + s2, comp2 = step_parts(h, r, 2, p) + return s2+comp2*float(_clip(max(.02, measured-s1)/max(comp1, .01), .1, 10.)) + return max(.01, s[1]+max(.02, measured-s[0])*x[1]*(b+c*x[1])/max(x[0]*(b+c*x[0]), .01)) + + +def _decode_features(h, r, p, slowdown=None): + d = canvases(r)[2] + m = d['width']*d['height']*d['frames']/1e6 + v = [1., math.log(max(m,.01)), -math.log(speed(h,p)), + float(ram_tier(h)==0), _decoder_stream(h,r), _laptop(h)] + if slowdown is not None: + v.append(math.log(max(.05,min(20.,slowdown)))) + return v + + +def _pass2(h,r,p,warm1,method): + if method == 'original': + s1,c1 = step_parts(h,r,1,p); s2,c2 = step_parts(h,r,2,p) + return s2+c2*float(_clip(max(.02,warm1-s1)/max(c1,.01),.1,10.)) + if method == 'physical_cs': + return _physical_transfer(h,r,p,warm1,p['v2']['physical_cs']) + if method == 'area': + return warm1*_area(r) + name = 'ratio_offload' if method == 'ratio_offload' else 'ratio' + coef = p['v2']['ratio'][name] + ratio = float(math.exp(_clip(_dot(_ratio_features(h,r,name=='ratio_offload'),coef),math.log(.25),math.log(32.)))) + if method.startswith('blend'): + alpha = float(method[5:])/100. + ratio = math.exp(alpha*math.log(ratio)+(1-alpha)*math.log(_area(r))) + return warm1*ratio + + +def _decode_value(h,r,p,name,method,factor=1.,slowdown=1.,warm2=None,live=False): + if method == 'product': + return 1.9*warm2 + if _apple(h): + d=canvases(r)[2]; m=d['width']*d['height']*d['frames']/1e6 + a,b=p['apple'][name] + return a+m*b*factor + spec = p['v2']['decode'][name] + if method in ['log_prior','log_live']: + use_live = method=='log_live' and live + feat = _decode_features(h,r,p,slowdown if use_live else None) + return float(math.exp(_clip(_dot(feat,spec['log_live' if use_live else 'log_prior']),-10.,20.))) + d=canvases(r)[2]; m=d['width']*d['height']*d['frames']/1e6 + dec = spec['free'] if method in ['free_compute','free_static'] else p[name] + a,b,stream = dec['coef'] + mult = dec['ram16_multiplier'] if ram_tier(h)==0 else 1. + f = 1. if method=='free_static' else factor + return max(0.,a+m*(b/speed(h,p)*f+stream*_decoder_stream(h,r))*mult) + + +def _set_pass2(v,d,h,r,flags,p,warm): + d['pass2_warm_step'] = warm + d['pass2_first_extra'] = first_extra(warm,flags,2,p) + n = step_counts(r)[1] + v['pass2'] = n*warm+d['pass2_first_extra']+p['pass_wall_overhead']['2'] if n else 0. + + +def _sum_total(v): + v['decode'] = max(v['decode'], v['video_decode']) + v['total'] = sum(v[k] for k in ['encode','load','pass1','upscale','pass2','decode','save_other']) + + +def _raw(h,r,flags,p,factor=1.,mode='compute',factor_pass=1): + v,d = _raw_base(h,r,flags,p,factor,mode,factor_pass) + if 'v2' not in p: + return v,d + select = p['v2']['selection'] + if select['prior_pass2']!='original': + _set_pass2(v,d,h,r,flags,p,_pass2(h,r,p,d['pass1_warm_step'],select['prior_pass2'])) + decode_step=d['pass2_warm_step'] if step_counts(r)[1] else d['pass1_warm_step'] + for name in ['video_decode','decode']: + v[name] = _decode_value(h,r,p,name,select['prior_'+name],warm2=decode_step) + _sum_total(v) + return v,d + + +def _warm(steps,h,r,i,p,method): + if len(steps)<2: + s,c=step_parts(h,r,i,p) + return s+c,0 + if method=='median': + return float(median(steps[1:])),len(steps)-1 + if len(steps)>=3: + return float(median(steps[2:])),len(steps)-2 + measured=float(steps[1]) + if method=='skip_corrected': + return measured*p['v2']['warmup'][str(i)],1 + alpha = .5 if method=='skip_shrink50' else .75 + s,c=step_parts(h,r,i,p) + return math.exp(alpha*math.log(measured)+(1-alpha)*math.log(max(s+c,.01))),1 + + +def _interval(name,value,p,kind='prior',wide=False): + bounds=p.get('uncertainty',{}).get(kind,{}).get(name,DEFAULT_INTERVAL) + lo,hi=bounds[:2] + if wide:lo,hi=min(lo,.2),max(hi,5.) + return {'seconds':float(max(0,value)), 'p20':float(max(0,value*lo)), + 'p80':float(max(0,value*hi))} + + +def _warnings(h,r,p): + out=[] + if _apple(h):out.append('Apple backend has one physical-machine report; external prior accuracy is unvalidated.') + if _gpu_item(h,p) is None:out.append('Unseen GPU: supplied dense tensor rate or G=1 fallback; wide uncertainty.') + if not (r.get('sampling_geometry') or {}).get('first'):out.append('Sampling geometry inferred from current H3 engine rules.') + if any(r.get(f'pass{i}_resident_blocks') is None for i in [1,2]):out.append('Missing planned transformer residency: coarse VRAM fallback.') + if r.get('reference_seconds') is None and _media_units(r):out.append('Reference duration unavailable; pooled one-reference encoding cost.') + return out + + +def predict_prior(hardware, request, cold_flags, params=None): + """Per-stage {seconds,p20,p80}, total, diagnostics and warnings. + + video_decode is a diagnostic SUBSET of decode: never add it to total. + Input hardware RAM/VRAM use GiB. pcie_bandwidth_gbs is decimal GB/s. + request uses schema names plus optional pass1_resident_blocks, reference_seconds. + Cold flags may include pass1/2_canvas_compiled and pass1/2_compile_cold. + Unknown flags must be None, never inferred from future observed first steps. + """ + p=_params(params) + v,d=_raw(hardware,request,cold_flags or {},p) + wide=_apple(hardware) or _gpu_item(hardware,p) is None + return _decode_save({'stages':{k:_interval(k,x,p,wide=wide) for k,x in v.items() if k!='total'}, + 'total':_interval('total',v['total'],p,wide=wide), 'diagnostics':d, + 'warnings':_warnings(hardware,request,p)}) + + +def predict_live(state, params=None): + """Same v1 state/output contract; completed steps only, no future timings. + + v2.selection selects each stage; no extra state keys are required. mode is + retained for the v1 whole/compute comparison. v2 ratio and log-decode models + have their own calibrated speed transfer, independent of that ablation. + """ + p = _params(params) + h,r = state['hardware'],state['request'] + flags = state.get('cold_flags') or {} + mode = state.get('mode',p.get('live_mode','compute')) + if mode not in ['compute','whole']: + raise ValueError('mode must be compute or whole') + a = {i:list(state.get(f'pass{i}_steps') or []) for i in [1,2]} + if any(not math.isfinite(float(t)) or t<=0 for steps in a.values() for t in steps): + raise ValueError('Completed step times must be positive finite seconds') + select = p.get('v2',{}).get('selection',{}) + warmup = select.get('warmup','median') + i = 2 if len(a[2])>=2 else 1 + measured,used = _warm(a[i],h,r,i,p,warmup) + s,c = step_parts(h,r,i,p) + slowdown = measured/max(s+c,.01) + factor = measured/max(s+c,.01) if mode=='whole' else max(.02,measured-s)/max(c,.01) + factor = float(_clip(factor,.1,10.)) if used else 1. + v,d = _raw_base(h,r,flags,p,factor,mode,i) + if 'v2' in p: + p2method = select['live_pass2'] if used else select['prior_pass2'] + if i==1 and p2method!='original': + _set_pass2(v,d,h,r,flags,p,_pass2(h,r,p,measured,p2method)) + elif i==2: + _set_pass2(v,d,h,r,flags,p,measured) + decode_step=d['pass2_warm_step'] if step_counts(r)[1] else measured + for name in ['video_decode','decode']: + method = select[('live_' if used else 'prior_')+name] + if method=='original' and mode=='whole': + continue + v[name] = _decode_value(h,r,p,name,method,factor,slowdown,decode_step,bool(used)) + counts = step_counts(r) + done = set(state.get('completed_stages') or []) + if 'decode_save' in done: + done.update(['decode', 'save_other']) + if a[1] or a[2]: + done.update(['encode','load']) + if a[2]: + done.update(['pass1','upscale']) + for j in [1,2]: + key=f'pass{j}'; n=counts[j-1]; seen=len(a[j]) + warm = _warm(a[j],h,r,j,p,warmup)[0] if seen>=2 else d[key+'_warm_step'] + if seen>=2: + d[key+'_warm_step'] = warm + rem = max(0,n-seen)*warm + if n and not seen: + rem += d[key+'_first_extra'] + if n>seen and key not in (state.get('pass_wall_overhead_done') or []): + rem += p['pass_wall_overhead'][str(j)] + if state.get('current_stage')==key: + rem = max(0.,rem-float(state.get('step_elapsed_seconds') or 0)) + v[key]=rem + _sum_total(v) + for key in done: + if key in v: + v[key]=0. + if 'decode' in done: + v['video_decode']=0. + for key,elapsed in (state.get('stage_elapsed_seconds') or {}).items(): + if key in v and key not in ['pass1','pass2','total']: + v[key]=max(0.,v[key]-float(elapsed)) + v['total']=sum(v[k] for k in ['encode','load','pass1','upscale','pass2','decode','save_other']) + kind='live_pass2' if i==2 else 'live_pass1' + wide=_apple(h) or _gpu_item(h,p) is None + result={'stages':{k:_interval(k,x,p,kind,wide) for k,x in v.items() if k!='total'}, + 'total_remaining':_interval('total',v['total'],p,kind,wide), + 'diagnostics':dict(d,calibration_pass=i,warm_steps_used=used,warmup_method=warmup), + 'warnings':_warnings(h,r,p)} + if state.get('elapsed_seconds') is not None: + elapsed=float(state['elapsed_seconds']) + result['total_request']={k:x+elapsed for k,x in result['total_remaining'].items()} + return _decode_save(result) diff --git a/freevideo_engine/stage_estimate_params.json b/freevideo_engine/stage_estimate_params.json new file mode 100644 index 00000000..69fe9f5f --- /dev/null +++ b/freevideo_engine/stage_estimate_params.json @@ -0,0 +1,847 @@ +{ + "format_version": 2, + "gpu_speed_table": { + "RTX 3060": { + "fp16_dense_tflops": 51.0, + "int8_dense_tops": 102.0, + "G_spec": 0.5379746835443038, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 0.5207888197855812, + "training_requests": 1, + "training_groups": 1 + }, + "RTX 3060 Ti": { + "fp16_dense_tflops": 64.8, + "int8_dense_tops": 129.6, + "G_spec": 0.6835443037974683, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 0.593229315742704, + "training_requests": 1, + "training_groups": 1 + }, + "RTX 3070": { + "fp16_dense_tflops": 81.3, + "int8_dense_tops": 162.6, + "G_spec": 0.8575949367088608, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 0.7130407414807, + "training_requests": 1, + "training_groups": 1 + }, + "RTX 3070 Ti": { + "fp16_dense_tflops": 87.0, + "int8_dense_tops": 174.0, + "G_spec": 0.9177215189873418, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 0.8190431847292093, + "training_requests": 2, + "training_groups": 1 + }, + "RTX 3080": { + "fp16_dense_tflops": 119.1, + "int8_dense_tops": 238.2, + "G_spec": 1.2563291139240507, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 1.2563291139240507, + "training_requests": 0, + "training_groups": 0 + }, + "RTX 3090": { + "fp16_dense_tflops": 142.3, + "int8_dense_tops": 284.6, + "G_spec": 1.5010548523206753, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 1.2688664948864776, + "training_requests": 8, + "training_groups": 1 + }, + "RTX 4060": { + "fp16_dense_tflops": 60.5, + "int8_dense_tops": 121.0, + "G_spec": 0.6381856540084389, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 0.5648065462023033, + "training_requests": 1, + "training_groups": 1 + }, + "RTX 4060 Ti": { + "fp16_dense_tflops": 88.2, + "int8_dense_tops": 176.4, + "G_spec": 0.9303797468354431, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 0.7682598263137621, + "training_requests": 8, + "training_groups": 1 + }, + "RTX 4070": { + "fp16_dense_tflops": 116.6, + "int8_dense_tops": 233.2, + "G_spec": 1.229957805907173, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 0.9722768246324283, + "training_requests": 1, + "training_groups": 1 + }, + "RTX 4070 SUPER": { + "fp16_dense_tflops": 141.9, + "int8_dense_tops": 283.8, + "G_spec": 1.4968354430379749, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 1.3674053723296804, + "training_requests": 1, + "training_groups": 1 + }, + "RTX 4070 Ti": { + "fp16_dense_tflops": 160.4, + "int8_dense_tops": 320.8, + "G_spec": 1.6919831223628694, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 1.6919831223628694, + "training_requests": 0, + "training_groups": 0 + }, + "RTX 4080": { + "fp16_dense_tflops": 195.0, + "int8_dense_tops": 390.0, + "G_spec": 2.0569620253164556, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 2.0569620253164556, + "training_requests": 0, + "training_groups": 0 + }, + "RTX 4090": { + "fp16_dense_tflops": 330.3, + "int8_dense_tops": 660.6, + "G_spec": 3.4841772151898738, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 3.4841772151898738, + "training_requests": 0, + "training_groups": 0 + }, + "RTX 5050": { + "fp16_dense_tflops": 42.0, + "int8_dense_tops": 84.0, + "G_spec": 0.44303797468354433, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 0.44303797468354433, + "training_requests": 0, + "training_groups": 0 + }, + "RTX 5060": { + "fp16_dense_tflops": 76.0, + "int8_dense_tops": 152.0, + "G_spec": 0.8016877637130801, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 0.6355560226806366, + "training_requests": 2, + "training_groups": 1 + }, + "RTX 5060 Ti": { + "fp16_dense_tflops": 94.8, + "int8_dense_tops": 189.6, + "G_spec": 1.0, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 1.0, + "training_requests": 77, + "training_groups": 1 + }, + "RTX 5070": { + "fp16_dense_tflops": 123.5, + "int8_dense_tops": 247.0, + "G_spec": 1.3027426160337554, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 1.3027426160337554, + "training_requests": 0, + "training_groups": 0 + }, + "RTX 5070 Ti": { + "fp16_dense_tflops": 175.8, + "int8_dense_tops": 351.6, + "G_spec": 1.8544303797468356, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 1.7506137720138786, + "training_requests": 1, + "training_groups": 1 + }, + "RTX 5080": { + "fp16_dense_tflops": 225.1, + "int8_dense_tops": 450.2, + "G_spec": 2.3744725738396624, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 2.3744725738396624, + "training_requests": 0, + "training_groups": 0 + }, + "RTX 5090": { + "fp16_dense_tflops": 419.0, + "int8_dense_tops": 838.0, + "G_spec": 4.419831223628692, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 4.253227592576185, + "training_requests": 1, + "training_groups": 1 + }, + "RTX PRO 5000 72GB Blackwell": { + "fp16_dense_tflops": 258.0, + "int8_dense_tops": 516.0, + "G_spec": 2.7215189873417724, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 2.7812599045046094, + "training_requests": 1, + "training_groups": 1 + }, + "RTX 3060 Laptop GPU": { + "fp16_dense_tflops": 42.0, + "int8_dense_tops": 84.0, + "G_spec": 0.3544303797468355, + "laptop": true, + "laptop_utilization_prior": 0.8, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 0.3544303797468355, + "training_requests": 0, + "training_groups": 0 + }, + "RTX 3070 Laptop GPU": { + "fp16_dense_tflops": 66.4, + "int8_dense_tops": 132.8, + "G_spec": 0.5603375527426161, + "laptop": true, + "laptop_utilization_prior": 0.8, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 0.3234973037813407, + "training_requests": 1, + "training_groups": 1 + }, + "RTX A5000 Laptop GPU": { + "fp16_dense_tflops": 77.9, + "int8_dense_tops": 155.8, + "G_spec": 0.6573839662447258, + "laptop": true, + "laptop_utilization_prior": 0.8, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 0.6075734032022507, + "training_requests": 1, + "training_groups": 1 + }, + "RTX 4060 Laptop GPU": { + "fp16_dense_tflops": 58.2, + "int8_dense_tops": 116.4, + "G_spec": 0.4911392405063292, + "laptop": true, + "laptop_utilization_prior": 0.8, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 0.4630326335248195, + "training_requests": 1, + "training_groups": 1 + }, + "RTX 4070 Laptop GPU": { + "fp16_dense_tflops": 80.2, + "int8_dense_tops": 160.4, + "G_spec": 0.6767932489451478, + "laptop": true, + "laptop_utilization_prior": 0.8, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 0.5684578106515071, + "training_requests": 3, + "training_groups": 1 + }, + "RTX 5050 Laptop GPU": { + "fp16_dense_tflops": 40.0, + "int8_dense_tops": 80.0, + "G_spec": 0.3375527426160338, + "laptop": true, + "laptop_utilization_prior": 0.8, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 0.14655919362074646, + "training_requests": 1, + "training_groups": 1 + }, + "RTX 5060 Laptop GPU": { + "fp16_dense_tflops": 66.7, + "int8_dense_tops": 133.4, + "G_spec": 0.5628691983122364, + "laptop": true, + "laptop_utilization_prior": 0.8, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 0.3519458489403197, + "training_requests": 1, + "training_groups": 1 + }, + "RTX 5070 Laptop GPU": { + "fp16_dense_tflops": 79.3, + "int8_dense_tops": 158.6, + "G_spec": 0.6691983122362869, + "laptop": true, + "laptop_utilization_prior": 0.8, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 0.6163691196524563, + "training_requests": 1, + "training_groups": 1 + }, + "RTX 5070 Ti Laptop GPU": { + "fp16_dense_tflops": 119.1, + "int8_dense_tops": 238.2, + "G_spec": 1.0050632911392405, + "laptop": true, + "laptop_utilization_prior": 0.8, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 1.0058995347831903, + "training_requests": 1, + "training_groups": 1 + }, + "RTX 5080 Laptop GPU": { + "fp16_dense_tflops": 152.1, + "int8_dense_tops": 304.2, + "G_spec": 1.2835443037974683, + "laptop": true, + "laptop_utilization_prior": 0.8, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 1.3812355985451874, + "training_requests": 1, + "training_groups": 1 + }, + "RTX 5090 Laptop GPU": { + "fp16_dense_tflops": 202.0, + "int8_dense_tops": 404.0, + "G_spec": 1.7046413502109705, + "laptop": true, + "laptop_utilization_prior": 0.8, + "provenance": "rounded architecture-derived nominal public-spec prior; offline, unverified", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 1.7046413502109705, + "training_requests": 0, + "training_groups": 0 + }, + "Apple M4 Max": { + "fp16_dense_tflops": null, + "int8_dense_tops": null, + "G_spec": 1.0, + "laptop": false, + "provenance": "separate Metal backend; no CUDA tensor-rate equivalence", + "G": 1.0, + "training_requests": 1, + "training_groups": 1 + }, + "RTX PRO 6000 Blackwell": { + "fp16_dense_tflops": 500.0, + "int8_dense_tops": 1000.0, + "G_spec": 5.274261603375527, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "architecture-derived nominal NVIDIA public-spec prior; no measured training requests", + "comment": "NVIDIA nominal boost 4000 sparse FP4 AI TOPS; dense FP16 = 4000/8 = 500.0 TFLOPS, dense INT8 = 2*FP16; G_spec = FP16/94.8. Source: https://www.nvidia.com/en-us/products/workstations/professional-desktop-gpus/rtx-pro-6000/ Server Edition publishes 1 PFLOP sparse FP16/BF16 (500 dense TFLOPS): https://www.nvidia.com/en-us/data-center/rtx-pro-6000-blackwell-server-edition/ . Server/workstation share this nominal compute prior.", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 5.274261603375527, + "training_requests": 0, + "training_groups": 0 + }, + "RTX PRO 6000 Blackwell Max-Q": { + "fp16_dense_tflops": 438.875, + "int8_dense_tops": 877.75, + "G_spec": 4.62948312236287, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "architecture-derived nominal NVIDIA public-spec prior; no measured training requests", + "comment": "NVIDIA nominal boost 3511 sparse FP4 AI TOPS; dense FP16 = 3511/8 = 438.875 TFLOPS, dense INT8 = 2*FP16; G_spec = FP16/94.8. Source: https://www.nvidia.com/en-us/products/workstations/professional-desktop-gpus/rtx-pro-6000-max-q/", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 4.62948312236287, + "training_requests": 0, + "training_groups": 0 + }, + "RTX PRO 5000 Blackwell": { + "fp16_dense_tflops": 258.0, + "int8_dense_tops": 516.0, + "G_spec": 2.7215189873417724, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "architecture-derived nominal NVIDIA public-spec prior; no measured training requests", + "comment": "NVIDIA nominal boost 2064 sparse FP4 AI TOPS; dense FP16 = 2064/8 = 258.0 TFLOPS, dense INT8 = 2*FP16; G_spec = FP16/94.8. Source: https://www.nvidia.com/en-us/products/workstations/professional-desktop-gpus/rtx-pro-5000/", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 2.7215189873417724, + "training_requests": 0, + "training_groups": 0 + }, + "RTX PRO 4500 Blackwell": { + "fp16_dense_tflops": 202.125, + "int8_dense_tops": 404.25, + "G_spec": 2.132120253164557, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "architecture-derived nominal NVIDIA public-spec prior; no measured training requests", + "comment": "NVIDIA nominal boost 1617 sparse FP4 AI TOPS; dense FP16 = 1617/8 = 202.125 TFLOPS, dense INT8 = 2*FP16; G_spec = FP16/94.8. Source: https://www.nvidia.com/content/dam/en-zz/Solutions/data-center/rtx-pro-4500-blackwell/workstation-datasheet-blackwell-rtx-pro-4500-we-nvidia-us-5108623-web.pdf", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 2.132120253164557, + "training_requests": 0, + "training_groups": 0 + }, + "RTX PRO 4000 Blackwell": { + "fp16_dense_tflops": 161.25, + "int8_dense_tops": 322.5, + "G_spec": 1.7009493670886076, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "architecture-derived nominal NVIDIA public-spec prior; no measured training requests", + "comment": "NVIDIA nominal boost 1290 sparse FP4 AI TOPS; dense FP16 = 1290/8 = 161.25 TFLOPS, dense INT8 = 2*FP16; G_spec = FP16/94.8. Source: https://www.nvidia.com/en-us/products/workstations/professional-desktop-gpus/rtx-pro-4000/", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 1.7009493670886076, + "training_requests": 0, + "training_groups": 0 + }, + "RTX PRO 4000 Blackwell SFF": { + "fp16_dense_tflops": 96.25, + "int8_dense_tops": 192.5, + "G_spec": 1.015295358649789, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "architecture-derived nominal NVIDIA public-spec prior; no measured training requests", + "comment": "NVIDIA nominal boost 770 sparse FP4 AI TOPS; dense FP16 = 770/8 = 96.25 TFLOPS, dense INT8 = 2*FP16; G_spec = FP16/94.8. Source: https://www.nvidia.com/en-us/products/workstations/professional-desktop-gpus/rtx-pro-4000-sff/", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 1.015295358649789, + "training_requests": 0, + "training_groups": 0 + }, + "RTX PRO 2000 Blackwell": { + "fp16_dense_tflops": 68.125, + "int8_dense_tops": 136.25, + "G_spec": 0.7186181434599156, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "architecture-derived nominal NVIDIA public-spec prior; no measured training requests", + "comment": "NVIDIA nominal boost 545 sparse FP4 AI TOPS; dense FP16 = 545/8 = 68.125 TFLOPS, dense INT8 = 2*FP16; G_spec = FP16/94.8. Source: https://www.nvidia.com/en-us/products/workstations/professional-desktop-gpus/rtx-pro-2000/", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 0.7186181434599156, + "training_requests": 0, + "training_groups": 0 + }, + "RTX 4080 SUPER": { + "fp16_dense_tflops": 209.0, + "int8_dense_tops": 418.0, + "G_spec": 2.2046413502109705, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "architecture-derived nominal NVIDIA public-spec prior; no measured training requests", + "comment": "NVIDIA nominal boost 836 sparse INT8 AI TOPS; dense FP16 = 836/4 = 209.0 TFLOPS, dense INT8 = 2*FP16; G_spec = FP16/94.8. Source: https://www.nvidia.com/en-us/geforce/graphics-cards/compare/", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 2.2046413502109705, + "training_requests": 0, + "training_groups": 0 + }, + "RTX 4070 Ti SUPER": { + "fp16_dense_tflops": 176.5, + "int8_dense_tops": 353.0, + "G_spec": 1.8618143459915613, + "laptop": false, + "laptop_utilization_prior": 1.0, + "provenance": "architecture-derived nominal NVIDIA public-spec prior; no measured training requests", + "comment": "NVIDIA nominal boost 706 sparse INT8 AI TOPS; dense FP16 = 706/4 = 176.5 TFLOPS, dense INT8 = 2*FP16; G_spec = FP16/94.8. Source: https://www.nvidia.com/en-us/geforce/graphics-cards/compare/", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 1.8618143459915613, + "training_requests": 0, + "training_groups": 0 + }, + "RTX 4090 Laptop GPU": { + "fp16_dense_tflops": 171.5, + "int8_dense_tops": 343.0, + "G_spec": 1.4472573839662448, + "laptop": true, + "laptop_utilization_prior": 0.8, + "provenance": "architecture-derived nominal NVIDIA public-spec prior; no measured training requests", + "comment": "NVIDIA nominal boost 686 sparse INT8 AI TOPS; dense FP16 = 686/4 = 171.5 TFLOPS, dense INT8 = 2*FP16; G_spec = FP16/94.8*0.8 laptop utilization prior. Source: https://www.nvidia.com/en-us/geforce/laptops/compare/", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 1.4472573839662448, + "training_requests": 0, + "training_groups": 0 + }, + "RTX 4080 Laptop GPU": { + "fp16_dense_tflops": 135.5, + "int8_dense_tops": 271.0, + "G_spec": 1.1434599156118146, + "laptop": true, + "laptop_utilization_prior": 0.8, + "provenance": "architecture-derived nominal NVIDIA public-spec prior; no measured training requests", + "comment": "NVIDIA nominal boost 542 sparse INT8 AI TOPS; dense FP16 = 542/4 = 135.5 TFLOPS, dense INT8 = 2*FP16; G_spec = FP16/94.8*0.8 laptop utilization prior. Source: https://www.nvidia.com/en-us/geforce/laptops/compare/", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 1.1434599156118146, + "training_requests": 0, + "training_groups": 0 + }, + "RTX 4050 Laptop GPU": { + "fp16_dense_tflops": 48.5, + "int8_dense_tops": 97.0, + "G_spec": 0.409282700421941, + "laptop": true, + "laptop_utilization_prior": 0.8, + "provenance": "architecture-derived nominal NVIDIA public-spec prior; no measured training requests", + "comment": "NVIDIA nominal boost 194 sparse INT8 AI TOPS; dense FP16 = 194/4 = 48.5 TFLOPS, dense INT8 = 2*FP16; G_spec = FP16/94.8*0.8 laptop utilization prior. Source: https://www.nvidia.com/en-us/geforce/laptops/compare/", + "rate_uncertainty": "approximately +/-25%; laptop effective speed often wider", + "G": 0.409282700421941, + "training_requests": 0, + "training_groups": 0 + } + }, + "step": { + "bc_normalized": [ + 53.02633687336147, + 18.512278324043034 + ], + "s": [ + 0.3628463125584906, + 5.673596543037182, + 0.0, + 1.5771193358383282, + 5.146406599311984 + ], + "b_seconds_per_token": 0.000730631846248918, + "c_seconds_per_token_squared": 3.51458308076312e-09 + }, + "first": { + "1": [ + 5.667274570715307, + 0.0, + 2.555895622421369 + ], + "2": [ + 1.4333251445342854, + 0.02535875399143411, + 4.571770191909305 + ] + }, + "pass_wall_overhead": { + "1": 1.1343244000090635, + "2": 0.807833600032609 + }, + "upscale": [ + 1.3925898807884207, + 3.767907218745735, + 2.3609589947869782 + ], + "video_decode": { + "coef": [ + 3.1540430242918114, + 0.34777095886325715, + 0.0 + ], + "ram16_multiplier": 3.0 + }, + "decode": { + "coef": [ + 12.236062438391375, + 0.3768752240637105, + 0.0 + ], + "ram16_multiplier": 3.0 + }, + "encode": [ + 58.970469477191244, + 23.365413646684626, + 25.767529484087184, + 5.895970153707906 + ], + "load": [ + 18.861309755267882, + 20.484668698553154, + 14.860735681704988, + 0.5, + 2.6514297905683555 + ], + "save_other": 15.606161100004101, + "apple": { + "step_bc": [ + 518.3697621308827, + 455.7612479359653 + ], + "step_fixed": 0.0, + "video_decode": [ + 0.0, + 1.8049468919344624 + ], + "decode": [ + 0.0, + 1.9429229278016131 + ], + "training_requests": 1 + }, + "uncertainty": { + "prior": { + "encode": [ + 0.8667104381892639, + 2.6829023101372345, + 86 + ], + "load": [ + 0.7756754568021302, + 2.167389699204247, + 118 + ], + "upscale": [ + 0.8423626023578648, + 2.0377246042595116, + 88 + ], + "decode": [ + 0.9481693673968302, + 1.5792274827930786, + 118 + ], + "video_decode": [ + 1.0070416456117262, + 1.404106089855182, + 118 + ], + "total": [ + 1.0831900128935448, + 1.9821958506492763, + 118 + ], + "pass1": [ + 1.0702964473871903, + 1.898445893716351, + 116 + ], + "pass1_warm_step": [ + 1.0758933411562723, + 1.7922893590696019, + 116 + ], + "pass2": [ + 0.9866929787145726, + 1.5046582403128717, + 88 + ], + "pass2_warm_step": [ + 0.9551794655619846, + 1.394761697130706, + 88 + ] + }, + "live_pass1": { + "upscale": [ + 0.9094411502542656, + 2.052016278392315, + 86 + ], + "pass2": [ + 0.7710499588340138, + 1.2045052366185338, + 86 + ], + "video_decode": [ + 0.8638387668907984, + 1.1587356142037288, + 116 + ], + "decode": [ + 0.8059200577286844, + 1.1538384121282341, + 116 + ], + "pass1": [ + 0.9122358585825523, + 1.0035965736783319, + 116 + ], + "pass2_warm_step": [ + 0.6548425387738285, + 1.2216660426106098, + 86 + ], + "total": [ + 0.9007780259129602, + 1.1870504377616715, + 116 + ] + }, + "live_pass2": { + "video_decode": [ + 0.9080252507974729, + 1.2697535287824175, + 88 + ], + "decode": [ + 0.9010158541995446, + 1.2684269376672286, + 88 + ] + } + }, + "live_mode": "compute", + "fit_counts": { + "requests": 118, + "warm_pass_observations": 204, + "groups": 24, + "residual_p20_p50_p80": [ + 12.707398799987278, + 15.606161100004101, + 22.069393300002865 + ] + }, + "v2": { + "ratio": { + "ratio": [ + 1.3990689533765748, + 0.23860511677329715, + -0.3821800848897214, + -0.31456848433651297 + ], + "ratio_offload": [ + 1.568865168066507, + 0.24968661209666881, + -0.3712029383386699, + -0.3082697221309762, + -0.18946933080231615 + ] + }, + "physical_cs": [ + 9.671942288440452, + 0.7163619375584906, + 10.394206166736087, + 0.0, + 1.4501953125, + 7.704532535884055 + ], + "decode": { + "decode": { + "free": { + "coef": [ + 8.747419662845598, + 0.4260787400368224, + 0.0 + ], + "ram16_multiplier": 2.0954275789625574 + }, + "log_prior": [ + 0.2627808950946773, + 0.8282273967516215, + 0.931088837662092, + 0.3163177103103201, + -0.0952843851055781, + 0.05797116119613941 + ], + "log_live": [ + 0.358156201743166, + 0.809232829195133, + 0.8824411920706745, + 0.26864789494125835, + -0.12646376216683672, + 0.03167687172195593, + 0.4445769667207636 + ] + }, + "video_decode": { + "free": { + "coef": [ + 0.897942832464085, + 0.3935869705580935, + 0.0 + ], + "ram16_multiplier": 1.9175162950078457 + }, + "log_prior": [ + -0.6053916109261319, + 0.9502890056312934, + 1.046248334043838, + 0.3380746097246319, + -0.02258427868230822, + 0.07600251230300796 + ], + "log_live": [ + -0.5443348813940208, + 0.9373105484268757, + 0.9921500532796445, + 0.2770921303984208, + -0.045634813241097354, + 0.05168492663118808, + 0.39668263768508455 + ] + } + }, + "warmup": { + "1": 0.9596869464322852, + "2": 1.0 + }, + "selection": { + "prior_pass2": "original", + "live_pass2": "ratio_offload", + "prior_decode": "log_prior", + "live_decode": "log_live", + "prior_video_decode": "free_static", + "live_video_decode": "log_live", + "warmup": "median" + }, + "selection_rule": "mean of report/test-box median ARE; mean p90 tie-break; priors LOGO GPU+OS, live LORO; SELECTION.md" + }, + "uncertainty_note": "OOF GPU+OS-balanced marginal 20/80 actual/predicted quantiles; exploratory selection, no independent coverage guarantee." +} diff --git a/freevideo_engine/step_grid.py b/freevideo_engine/step_grid.py new file mode 100644 index 00000000..a748276c --- /dev/null +++ b/freevideo_engine/step_grid.py @@ -0,0 +1,52 @@ +"""The H3 step grid, computed the same way on every CPU. + +The scheduler builds linspace(1, 0, steps + 1) in float32. PyTorch computes the +first half of a linspace as start + step * i, and whether that multiply and add +are fused depends on the CPU kernel it dispatches to and on the compiler that +built it. The two results differ by one float32 unit at some points: at 20 +steps point 9 is 0.55000001 fused and 0.54999995 unfused, and the audio +timestep of step 10 is then 0.21428561 fused and 0.21428573 unfused. Published +sampling tables are selected by their exact timesteps, so on a machine that +does not fuse, the tables for 9, 13, 15, 17, 18, 20, 21, 23, 24 and 26 to 30 +steps never matched: the request downloaded the 26 GB of original projections +and recomputed them. + +Every schedule the engine builds therefore takes its grid from here. The +values are the fused ones, which the published tables were made with. +""" +from functools import lru_cache + + +def uniform(steps): + """linspace(1, 0, steps + 1) in float32, each point rounded once from its exact value.""" + import torch + if type(steps) is not int or steps < 1: + raise ValueError('A step grid needs at least one step') + points = steps + 1 + step = torch.tensor(-1. / steps, dtype=torch.float32).item() + halfway = points // 2 + # A float32 step times a small integer, and one plus that, are exact in a + # Python float (below 2**28 steps), so the conversion to float32 is the only + # rounding. That is what a fused multiply-add returns. + return torch.tensor([1. + step * index if index < halfway else 0. - step * (points - 1 - index) + for index in range(points)], dtype=torch.float32) + + +@lru_cache(maxsize=1) +def _scheduler_class(): + from diffusers import MiniMaxH3Scheduler + + class Scheduler(MiniMaxH3Scheduler): + def set_timesteps(self, num_inference_steps=None, device=None, sigmas=None): + if sigmas is None and type(num_inference_steps) is int and num_inference_steps >= 1: + base = uniform(num_inference_steps) + # The scheduler's own shift, applied to a grid it did not build. + sigmas = self._shift * base / (1 + (self._shift - 1) * base) + super().set_timesteps(num_inference_steps, device=device, sigmas=sigmas) + + return Scheduler + + +def scheduler(shift=12.): + """A MiniMaxH3Scheduler whose step grid is `uniform`; explicit sigmas pass through.""" + return _scheduler_class()(shift=shift) diff --git a/freevideo_engine/storage.py b/freevideo_engine/storage.py index 2358eb17..9d58d050 100644 --- a/freevideo_engine/storage.py +++ b/freevideo_engine/storage.py @@ -6,6 +6,8 @@ import time from .monitoring import save +from .runtime_alias import is_junction, checked_real_path, AliasUnavailable +from .long_paths import io_path, resolved, plain, plain_text def conversion_source(row): @@ -14,6 +16,7 @@ def conversion_source(row): def fingerprint(path): + path = io_path(checked_real_path(path)) info = path.stat() result = dict(bytes=info.st_size, mtime_ns=info.st_mtime_ns, ctime_ns=info.st_ctime_ns, inode=info.st_ino, device=info.st_dev) @@ -25,8 +28,14 @@ def fingerprint(path): def managed_model(root, path): """Only regular, unshared files beneath this installation's models directory.""" - root, path = Path(root).resolve(), Path(path).absolute() try: + selected = Path(root) + root, path = checked_real_path(root), checked_real_path(path) + if root != selected: + from .installation_cleanup import root_path + root, path = root_path(root, path) # Also expand an authenticated 8.3 target prefix. + else: + root, path = resolved(root), io_path(path).absolute() path.relative_to(root / 'models') if path.resolve() != path: return False @@ -43,23 +52,35 @@ def managed_model(root, path): def ownership(root): - path = Path(root) / '.freevideo' / 'downloaded-models.json' + selected = Path(root) + root = checked_real_path(root) + if root != selected: + root = resolved(root) + path = io_path(root) / '.freevideo' / 'downloaded-models.json' if not path.exists(): - return path, {} + return Path(plain(path)), {} value = json.loads(path.read_text(encoding='utf-8')) - if not isinstance(value, dict) or value.get('root') != str(Path(root).resolve()) or not isinstance(value.get('files'), dict): + if not isinstance(value, dict) or value.get('root') != plain(resolved(root)) or not isinstance(value.get('files'), dict): raise ValueError('Invalid model ownership record; models retained') - return path, value['files'] + return Path(plain(path)), value['files'] def record_download(root, path, row, *, origin='download'): + selected = Path(root) + try: + root, path = checked_real_path(root), checked_real_path(path) + except AliasUnavailable: + return + if root != selected: + from .installation_cleanup import root_path + root, path = root_path(root, path) # Called only after a previously absent target was downloaded/copied and verified. if not conversion_source(row) or not managed_model(root, path): return ledger, files = ownership(root) - files[str(Path(path).absolute())] = dict(fingerprint(path), sha256=row.get('sha256'), + files[plain(io_path(path).absolute())] = dict(fingerprint(path), sha256=row.get('sha256'), git_blob=row.get('git_blob'), source_file=row['file'], origin=origin) - save(ledger, {'root': str(Path(root).resolve()), 'files': files}) + save(ledger, {'root': plain(resolved(root)), 'files': files}) def compact_sources(plan, rows, report): @@ -69,17 +90,31 @@ def compact_sources(plan, rows, report): download receipt. External directories, preexisting/shared/replaced files, decoder/encoder weights, configs, failed attempts and outputs are retained. """ - from .network import hash_file - root, model_dir = Path(plan['root']).resolve(), Path(plan['model_dir']).resolve() result = {'mode': plan.get('storage', 'compact'), 'deleted': [], 'retained': [], 'released_bytes': 0, 'started_epoch': time.time()} + try: + plan = dict(plan, root=checked_real_path(plan['root']), model_dir=checked_real_path(plan['model_dir'])) + report = checked_real_path(report) + except AliasUnavailable as error: + result['retained'].append({'reason': str(error)}) + return result # Even the report path may be inside the invalid alias. + from .network import hash_file + from .installation_cleanup import root_path, local_path + root, chosen_models = root_path(plan['root'], plan['model_dir']) + model_dir = resolved(chosen_models) + internal_redirect = (chosen_models.is_relative_to(root / 'models') + and not local_path(root, chosen_models)) + if internal_redirect or is_junction(root / 'models') or not model_dir.is_relative_to(root / 'models'): + result['retained'].append({'reason': 'Model directory is outside installation models or redirected'}) + save(report, result) + return result if result['mode'] != 'compact': save(report, result) return result try: ledger, files = ownership(root) except (OSError, ValueError, TypeError) as error: - result['retained'].append({'reason': str(error)}) + result['retained'].append({'reason': plain_text(error)}) save(report, result) return result # Record intent before modifying any file; update after each successful unlink. @@ -90,14 +125,14 @@ def compact_sources(plan, rows, report): path = model_dir / row['file'] if not path.exists(): continue - expected = files.get(str(path), {}) + expected = files.get(plain(path), {}) current = fingerprint(path) if (not isinstance(expected, dict) or not managed_model(root, path) or 'change_time_ns' in current and current['change_time_ns'] is None or expected.get('source_file') != row['file'] or expected.get('bytes') != row['bytes'] or expected.get('sha256') != row.get('sha256') or expected.get('git_blob') != row.get('git_blob') or any(expected.get(k) != v for k, v in current.items())): - result['retained'].append({'file': str(path), 'reason': 'Not an unchanged, unshared file downloaded by this installation'}) + result['retained'].append({'file': plain(path), 'reason': 'Not an unchanged, unshared file downloaded by this installation'}) continue try: # Destructive cleanup cannot trust a metadata cache hit. Equal-size @@ -110,7 +145,7 @@ def compact_sources(plan, rows, report): raise OSError('No pinned content hash; source retained') if not matches: raise OSError('Source content changed; retained without deletion') - result['pending'] = {'file': str(path), 'bytes': row['bytes']} + result['pending'] = {'file': plain(path), 'bytes': row['bytes']} save(report, result) # Hashing can take time for model shards. Recheck both identity and # sharing immediately before unlink, after saving deletion intent. @@ -118,16 +153,16 @@ def compact_sources(plan, rows, report): raise OSError('File changed or became shared during cleanup') path.unlink() except OSError as error: - result['retained'].append({'file': str(path), 'reason': str(error)}) + result['retained'].append({'file': plain(path), 'reason': plain_text(error)}) result['pending'] = None save(report, result) continue result['pending'] = None - result['deleted'].append({'file': str(path), 'bytes': row['bytes'], 'source_file': row['file']}) + result['deleted'].append({'file': plain(path), 'bytes': row['bytes'], 'source_file': row['file']}) result['released_bytes'] += row['bytes'] - files.pop(str(path), None) + files.pop(plain(path), None) save(report, result) - save(ledger, {'root': str(root), 'files': files}) + save(ledger, {'root': plain(root), 'files': files}) result['completed_epoch'] = time.time() save(report, result) return result diff --git a/freevideo_engine/streamed_weights.py b/freevideo_engine/streamed_weights.py index 064d3fd2..87ed2578 100644 --- a/freevideo_engine/streamed_weights.py +++ b/freevideo_engine/streamed_weights.py @@ -70,6 +70,22 @@ def interleaved_layer_order(count): return order +def spread_layer_order(count): + """Layers ordered so that any first part and any last part lie spread out. + + Loading keeps streamed layers in RAM in this order until RAM runs out, so + the layers left on the drive are apart from each other wherever it stops, + and each can be read while the layers between them compute. Kept in index + order they were the last layers of the model, one after another, and the + drive stood idle through the rest of every step. Reversing the bits of + the index gives the order: 0, then the middle, then the quarters. + """ + if type(count) is not int or count < 0: + raise ValueError('Layer count must be a non-negative integer') + bits = max(1, (count - 1).bit_length()) + return sorted(range(count), key=lambda index: int(format(index, f'0{bits}b')[::-1], 2)) + + def release_file_pages(paths): """Advise away consumed clean pages; never remove or rewrite a weight file.""" import os @@ -389,6 +405,9 @@ def __init__(self, paths, prefixes, *, intermediate_dtype=None, direct_read=None self.cache_lock = threading.Lock() self.read_stats = dict(kept_layers=0, kept_bytes=0, kept_seconds=0., passed_layers=0, passed_bytes=0, passed_seconds=0.) + # A read from the drive has kept the inference thread waiting; the + # offloader then reads drive-backed layers further ahead (offload.py). + self.reads_delay_compute = False for path in sorted(set(Path(p).resolve() for p in paths)): self.files[path] = self.identity(path) with _open_safetensors(path, framework='np') as handle: @@ -485,8 +504,9 @@ def stage_into(self, index, layout, planes): finally: for path in reads: self.check(path) - self.host_view_hits += 1 - self.host_view_hit_bytes += size + with self.cache_lock: + self.host_view_hits += 1 + self.host_view_hit_bytes += size del entry, views if not host_view_fits(0, HOST_VIEW_SHED): self._release(index) @@ -718,19 +738,21 @@ def _adopt(self, index, reads, size): # streamed reads recycle the standby list. for source in views.values(): source[::mmap.PAGESIZE].sum() - self.host_views[index] = (views, mappings, size) - self.host_view_bytes += size + with self.cache_lock: + self.host_views[index] = (views, mappings, size) + self.host_view_bytes += size def _release(self, index): - views, mappings, size = self.host_views.pop(index) + with self.cache_lock: + views, mappings, size = self.host_views.pop(index) + self.host_view_bytes -= size + self.host_view_released += 1 del views for mapping in mappings: try: mapping.close() except BufferError: pass # A tensor view is still referenced; the mapping closes when it is collected. - self.host_view_bytes -= size - self.host_view_released += 1 def release_host_views(self): for index in list(self.host_views or ()): diff --git a/freevideo_engine/support_report.py b/freevideo_engine/support_report.py index eda2985e..244655e5 100644 --- a/freevideo_engine/support_report.py +++ b/freevideo_engine/support_report.py @@ -9,7 +9,7 @@ import time from . import __version__ -from .monitoring import thermal_slowdown +from .monitoring import OtherPrograms, shared_gpu, thermal_slowdown, worker_gpu from .diagnostics import Redactor, is_link, read_bounded from .diagnostic_resources import kernel_receipt from .monitoring import save @@ -45,7 +45,7 @@ def code_identity(): 'backends/mps_delta.py', 'backends/mps_features.py', 'backends/mps_qk.py', 'backends/mps_mlx_attention.py', 'backends/mps_vae_encode.py', 'backends/mps_modulation.py', 'macos_compute.py', 'macos_decode_tiles.py', - 'macos_memory.py', 'macos_process_memory.py', + 'macos_memory.py', 'macos_conditions.py', 'macos_process_memory.py', 'attention.py', 'refine.py', 'reference_sampler.py', 'adaln.py', 'latent_upscale.py', 'media_encoding.py', 'media_conditioning.py', 'encoder_checkpoint.py', 'system.py') values = {} @@ -156,7 +156,8 @@ def summarize(report, engine, encoding): hints.append(dict(kind='measured', message='This process exceeded its Windows local video-memory budget during sampling. ' 'Compare the per-step nonlocal memory and latency; this is pressure evidence, not a measured PCIe paging rate.', message_zh='采样时进程超过了 Windows 分配的本地显存预算。请对照每步共享内存与耗时;这是压力证据,不是 PCIe 换页速率测量。')) - thermal = thermal_slowdown(mapping(mapping(report.get('memory')).get('video')).get('gpu')) + gpu = worker_gpu(report) + thermal = thermal_slowdown(gpu) if thermal: hints.append(dict(kind='measured', message='The GPU was in thermal slowdown for %d%% of its busy time: %d of %d MHz on ' 'average%s. Cooling limited this request, not the plan.' % (round(100 * thermal['share']), thermal['sm_clock_mean_mhz'], @@ -164,6 +165,13 @@ def summarize(report, engine, encoding): message_zh='显卡在 %d%% 的计算时间里处于温控降频,平均 %d/%d MHz%s。这次用时受散热限制,不是规划问题。' % ( round(100 * thermal['share']), thermal['sm_clock_mean_mhz'], thermal['sm_clock_max_mhz'], ',%.0f °C' % thermal['temperature_mean_c'] if thermal['temperature_mean_c'] else ''))) + shared = shared_gpu(gpu) + if shared: + hints.append(dict(kind='measured', message='Other programs used about %d%% of the GPU while this request was computing, ' + 'and at least %d%% of it for %d%% of that time, so it ran slower than with the GPU to itself.' % ( + shared['other_mean_percent'], OtherPrograms.share_percent, round(100 * shared['share'])), + message_zh='这次请求计算期间,其他程序占用了约 %d%% 的显卡,其中 %d%% 的时间里不低于 %d%%,所以比独占显卡时慢。' % ( + shared['other_mean_percent'], round(100 * shared['share']), OtherPrograms.share_percent))) attempts = report.get('resource_attempts', []) failures = [dict(state=r.get('state'), failure=r.get('failure')) for r in attempts if isinstance(r, dict) and r.get('failure')] if isinstance(attempts, list) else [] diff --git a/freevideo_engine/testing.py b/freevideo_engine/testing.py index 149467af..f2ebf3bd 100644 --- a/freevideo_engine/testing.py +++ b/freevideo_engine/testing.py @@ -14,6 +14,7 @@ import traceback from urllib.parse import quote +from . import execution_paths from .bootstrap import inventory, digest, GiB, PACKAGE, DEFAULT_ROOT, existing_parent from .geometry import geometry from .locking import runtime_lock, LOCK_ENV @@ -361,7 +362,7 @@ def provenance(machine, env): ('nvidia_smi', [nvidia_smi(), '-q']), ('engine_packages', [machine['python'], '-m', 'pip', 'freeze']), ('encoder_packages', [machine['comfy_python'], '-m', 'pip', 'freeze'])]: - p = subprocess.run(command, capture_output=True, text=True, env=env, timeout=30, encoding='utf-8', errors='replace') + p = execution_paths.run(command, capture_output=True, text=True, env=env, timeout=30, encoding='utf-8', errors='replace') result[name] = {'returncode': p.returncode, 'stdout': p.stdout, 'stderr': p.stderr} return result diff --git a/freevideo_engine/torch_download.py b/freevideo_engine/torch_download.py index b72691ae..77170c3d 100644 --- a/freevideo_engine/torch_download.py +++ b/freevideo_engine/torch_download.py @@ -4,10 +4,12 @@ from pathlib import Path from . import network +from .long_paths import io_path, plain_errors +@plain_errors def catalog(): - return json.loads(Path(__file__).with_name('torch_downloads.json').read_text(encoding='utf-8'))['windows_cp312'] + return json.loads(io_path(Path(__file__).with_name('torch_downloads.json')).read_text(encoding='utf-8'))['windows_cp312'] def select(pins, cuda): diff --git a/freevideo_engine/tuning.py b/freevideo_engine/tuning.py index 4b67dab7..f989186b 100644 --- a/freevideo_engine/tuning.py +++ b/freevideo_engine/tuning.py @@ -13,6 +13,7 @@ import time from urllib.parse import unquote, urlsplit +from . import execution_paths from .hardware import GiB from .policy import BLOCK_BYTES from .monitoring import save @@ -56,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 = subprocess.run([str(python), '-B', '-c', code], capture_output=True, text=True, timeout=15, check=True) + result = execution_paths.run([str(python), '-B', '-c', code], capture_output=True, text=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()} @@ -102,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 = subprocess.check_output(['git', '-C', checkout, 'rev-parse', ref], text=True, timeout=10).strip() - dirty = subprocess.check_output(['git', '-C', checkout, 'status', '--porcelain', '--untracked-files=no'], text=True, timeout=10) + 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) 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/variant_cleanup.py b/freevideo_engine/variant_cleanup.py index c5642873..19b0a7c1 100644 --- a/freevideo_engine/variant_cleanup.py +++ b/freevideo_engine/variant_cleanup.py @@ -31,8 +31,10 @@ import stat import time +from .long_paths import io_path, plain, plain_errors from .locking import runtime_lock from .storage import fingerprint +from .runtime_alias import checked_real_path, AliasUnavailable SIDECAR = '.partial.source.json' SIDECAR_LIMIT = 4096 @@ -50,8 +52,10 @@ def redirected(info): return stat.S_ISLNK(info.st_mode) or bool(getattr(info, 'st_file_attributes', 0) & REPARSE_POINT) +@plain_errors def inside(base, path): """True when path is base or below it and nothing between them is a link or junction.""" + base, path = io_path(base), io_path(path) try: path.relative_to(base) current = path @@ -65,7 +69,9 @@ def inside(base, path): return False +@plain_errors def unshared_file(path): + path = io_path(path) if '.replaced-' in path.name: return None try: @@ -82,25 +88,29 @@ def variant_folder(root, catalog, name): return root / 'prepared' / ('edge-' + variant.get('revision', catalog['revision'])[:16]) +@plain_errors def active_variant(root, catalog): """The catalog variant whose cache machine.json names, or None when that is unknown.""" + root = io_path(root) try: record = json.loads((root / 'machine.json').read_text(encoding='utf-8')) - cache = Path(record['cache']) + cache = io_path(record['cache']) except (OSError, ValueError, KeyError, TypeError): return None, None for name, variant in catalog['variants'].items(): expected = variant_folder(root, catalog, name) / variant['cache_prefix'] try: if cache.absolute() == expected or cache.resolve() == expected.resolve(): - return name, expected + return name, Path(plain(expected)) except OSError: continue - return None, cache + return None, Path(plain(cache)) +@plain_errors def complete(root, catalog, name): """Every catalog file of the variant is present, regular, local and of its catalogued size.""" + root = io_path(root) folder = variant_folder(root, catalog, name) prepared = root / 'prepared' for row in catalog['variants'][name]['files']: @@ -114,7 +124,9 @@ def complete(root, catalog, name): return True +@plain_errors def _sidecar_matches(path, row): + path = io_path(path) info = unshared_file(path) if info is None or info.st_size > SIDECAR_LIMIT: return False @@ -125,8 +137,10 @@ def _sidecar_matches(path, row): return isinstance(value, dict) and value.get('expected') == row['sha256'] +@plain_errors def _stage(folder, prepared, stage): """Files and directories of one native staging folder, or None when anything is redirected or shared.""" + prepared, stage = io_path(prepared), io_path(stage) if not inside(prepared, stage) or not stage.is_dir(): return None files, directories = [], [stage] @@ -144,12 +158,14 @@ def _stage(folder, prepared, stage): if unshared_file(path) is None: return None files.append(path) - return files, directories + return [Path(plain(path)) for path in files], [Path(plain(path)) for path in directories] +@plain_errors def _generated_tables(prepared, table): """Files of one engine-generated AdaLN table folder, or None unless it is bound to its identity.""" from .adaln_assets import FORMAT, directory + prepared, table = io_path(prepared), io_path(table) if not ADALN_DIRECTORY.fullmatch(table.name) or not inside(prepared, table) or not table.is_dir(): return None entries = list(table.iterdir()) @@ -165,20 +181,24 @@ def _generated_tables(prepared, table): return None except (OSError, ValueError, TypeError): return None - return entries + return [Path(plain(path)) for path in entries] +@plain_errors def _plan_file(path, kind, entries): - entries.append(dict(path=str(path), kind=kind, bytes=path.lstat().st_size, stamp=fingerprint(path))) + path = io_path(path) + entries.append(dict(path=plain(path), kind=kind, bytes=path.lstat().st_size, stamp=fingerprint(path))) +@plain_errors def retire(root, catalog, name): """Owned files of one retired variant, and what stays in its folder.""" + root = io_path(root) folder = variant_folder(root, catalog, name) prepared = root / 'prepared' entries, kept, planned, stage_directories = [], [], set(), [] if not folder.is_dir() or not inside(prepared, folder): - return dict(variant=name, folder=str(folder), files=entries, kept=kept, bytes=0, kept_bytes=0, + return dict(variant=name, folder=plain(folder), files=entries, kept=kept, bytes=0, kept_bytes=0, stage_directories=stage_directories, present=folder.exists() or folder.is_symlink()) stages = folder / STAGES for row in catalog['variants'][name]['files']: @@ -208,17 +228,19 @@ def retire(root, catalog, name): if found is None: continue for path_in_stage in found[0]: + path_in_stage = io_path(path_in_stage) if path_in_stage not in planned: _plan_file(path_in_stage, 'stage', entries) planned.add(path_in_stage) for directory in found[1]: - if str(directory) not in stage_directories: - stage_directories.append(str(directory)) + if plain(directory) not in stage_directories: + stage_directories.append(plain(directory)) cache = folder / catalog['variants'][name]['cache_prefix'] if cache.is_dir() and inside(prepared, cache): for table in sorted(cache.iterdir()): found = _generated_tables(prepared, table) for path in found or (): + path = io_path(path) if path not in planned: _plan_file(path, 'adaln', entries) planned.add(path) @@ -229,7 +251,7 @@ def retire(root, catalog, name): try: if redirected(path.lstat()): names.remove(name_) - kept.append(dict(path=str(path), bytes=0, reason='link or junction')) + kept.append(dict(path=plain(path), bytes=0, reason='link or junction')) except OSError: names.remove(name_) for name_ in files: @@ -241,32 +263,40 @@ def retire(root, catalog, name): except OSError: size = 0 kept_bytes += size - kept.append(dict(path=str(path), bytes=size, reason='not placed by this installer for this variant')) - return dict(variant=name, folder=str(folder), files=entries, kept=kept, present=True, + kept.append(dict(path=plain(path), bytes=size, reason='not placed by this installer for this variant')) + return dict(variant=name, folder=plain(folder), files=entries, kept=kept, present=True, stage_directories=stage_directories, bytes=sum(row['bytes'] for row in entries), kept_bytes=kept_bytes) +@plain_errors def fused_lora_variants(root, catalog, name): """Fused LoRA variants built from this variant (they live beside its cache).""" - beside = (variant_folder(root, catalog, name) / catalog['variants'][name]['cache_prefix']).parent + beside = (variant_folder(io_path(root), catalog, name) / catalog['variants'][name]['cache_prefix']).parent try: - return sorted(str(path) for path in beside.iterdir() if path.name.startswith(FUSED_LORA)) + return sorted(plain(path) for path in beside.iterdir() if path.name.startswith(FUSED_LORA)) except OSError: return [] +@plain_errors def scan(root, catalog=None, expect_active=None): """What removing the variants that are not in use would release; nothing is changed. expect_active names the variant the caller has just switched to; a scan that finds any other variant in use plans nothing. """ + try: + root = checked_real_path(root) + except AliasUnavailable: + return dict(root=plain(root), active=None, cache=None, expect_active=expect_active, + retired=[], kept_variants=[], bytes=0, kept_bytes=0, + blockers=['invalid runtime alias']) from .prepared_model import catalog as load_catalog - root = Path(root).absolute() + root = io_path(Path(root).absolute()) catalog = catalog or load_catalog() active, cache = active_variant(root, catalog) - plan = dict(root=str(root), active=active, cache=str(cache) if cache else None, expect_active=expect_active, + plan = dict(root=plain(root), active=active, cache=plain(cache) if cache else None, expect_active=expect_active, retired=[], kept_variants=[], bytes=0, kept_bytes=0, blockers=[]) try: if redirected((root / 'prepared').lstat()): @@ -296,7 +326,7 @@ def scan(root, catalog=None, expect_active=None): continue fused = fused_lora_variants(root, catalog, name) if fused: - plan['kept_variants'].append(dict(variant=name, folder=str(folder), reason='fused-lora-variants', + plan['kept_variants'].append(dict(variant=name, folder=plain(folder), reason='fused-lora-variants', lora_variants=fused)) continue retired = retire(root, catalog, name) @@ -363,11 +393,30 @@ def stage_directory_permitted(directory, folder, rows): and any(kind == 'catalog' and row['sha256'][:16] == identity for kind, row in rows.values())) +@plain_errors def clean(plan, catalog=None, receipt=None): """Remove exactly the planned files that are still unchanged; report everything else.""" + # Authenticate every saved path before resolving, enumerating, or taking leases. + # Copy the plan so refusal leaves the caller's plan unchanged as well. + def checked(value): + return plain(checked_real_path(value)) + plan = dict(plan, root=checked(plan['root'])) + if plan.get('cache') is not None: + plan['cache'] = checked(plan['cache']) + retired = [] + for row in plan['retired']: + retired.append(dict(row, folder=checked(row['folder']), + files=[dict(entry, path=checked(entry['path'])) for entry in row['files']], + kept=[dict(entry, path=checked(entry['path'])) for entry in row.get('kept', [])], + stage_directories=[checked(value) for value in row.get('stage_directories', [])])) + plan['retired'] = retired + plan['kept_variants'] = [dict(row, folder=checked(row['folder']), + lora_variants=[checked(value) for value in row.get('lora_variants', [])]) + for row in plan.get('kept_variants', [])] + receipt = checked_real_path(receipt) if receipt is not None else None from .prepared_model import catalog as load_catalog catalog = catalog or load_catalog() - root = Path(plan['root']) + root = io_path(plan['root']) removed, skipped, released = [], [], 0 if plan.get('blockers'): raise ValueError('Retired model cleanup is blocked: ' + ', '.join(plan['blockers'])) @@ -383,14 +432,15 @@ def clean(plan, catalog=None, receipt=None): name = retired['variant'] if name == active or name not in catalog['variants']: raise ValueError('Retired model plan names the model in use; nothing was removed.') - folder, rows = allowed(root, catalog, name) - if str(folder) != retired['folder'] or folder == variant_folder(root, catalog, active): + folder, rows = allowed(Path(plain(root)), catalog, name) + folder = io_path(folder) + if plain(folder) != plain(retired['folder']) or folder == variant_folder(root, catalog, active): raise ValueError('Retired model folder changed; check storage again.') directories = set() for entry in retired['files']: path = Path(entry['path']) reason = None - if not permitted(path, entry['kind'], folder, rows, catalog['variants'][name]['cache_prefix']): + if not permitted(path, entry['kind'], Path(plain(folder)), rows, catalog['variants'][name]['cache_prefix']): reason = 'not a planned name' elif not inside(prepared, path) or unshared_file(path) is None: reason = 'link, junction, shared or missing' @@ -401,16 +451,16 @@ def clean(plan, catalog=None, receipt=None): except OSError: reason = 'missing' if reason: - skipped.append(dict(path=str(path), reason=reason)) + skipped.append(dict(path=plain(path), reason=reason)) continue try: - path.unlink() + io_path(path).unlink() except OSError as error: - skipped.append(dict(path=str(path), reason=type(error).__name__)) + skipped.append(dict(path=plain(path), reason=type(error).__name__)) continue - removed.append(dict(path=str(path), bytes=entry['bytes'], kind=entry['kind'])) + removed.append(dict(path=plain(path), bytes=entry['bytes'], kind=entry['kind'])) released += entry['bytes'] - current = path.parent + current = io_path(path).parent while current != folder and folder in current.parents: directories.add(current) current = current.parent @@ -418,9 +468,10 @@ def clean(plan, catalog=None, receipt=None): # them only when empty, deepest first, each named under its stage. for value in sorted(retired.get('stage_directories', []), key=lambda p: len(Path(p).parts), reverse=True): directory = Path(value) - if not stage_directory_permitted(directory, folder, rows): + if not stage_directory_permitted(directory, Path(plain(folder)), rows): continue try: + directory = io_path(directory) if inside(prepared, directory) and directory.is_dir() and not any(directory.iterdir()): directory.rmdir() except OSError: @@ -428,6 +479,7 @@ def clean(plan, catalog=None, receipt=None): # Only directories that held removed files, deepest first, and only when empty. for directory in sorted(directories, key=lambda p: len(p.parts), reverse=True) + [folder / STAGES, folder]: try: + directory = io_path(directory) if inside(prepared, directory) and directory.is_dir() and not any(directory.iterdir()): directory.rmdir() except OSError: @@ -435,7 +487,7 @@ def clean(plan, catalog=None, receipt=None): result = dict(released_bytes=released, removed_files=len(removed), skipped=skipped, kept_bytes=plan.get('kept_bytes', 0), active=plan['active']) if receipt is not None: - receipt = Path(receipt) + receipt = io_path(receipt) receipt.parent.mkdir(parents=True, exist_ok=True) receipt.write_text(json.dumps(dict(result, removed=removed, finished=time.time()), indent=1), encoding='utf-8') return result diff --git a/freevideo_engine/version_cleanup.py b/freevideo_engine/version_cleanup.py index 7bc4ce91..e5682787 100644 --- a/freevideo_engine/version_cleanup.py +++ b/freevideo_engine/version_cleanup.py @@ -45,9 +45,11 @@ import time import uuid -from .installation_cleanup import local_path +from .installation_cleanup import local_path, root_path from .monitoring import save +from .long_paths import io_path, resolved as resolved_io, plain, plain_text, plain_data from .system import windows +from .runtime_alias import is_junction, real_path, checked_real_path, AliasUnavailable VERSION = re.compile(r'\d{4}\.\d{1,2}\.\d{1,2}\.\d{1,6}-[0-9a-f]{16}(?:-[0-9a-f]{8})?') DIGEST = re.compile(r'[0-9a-f]{64}(?:-[0-9a-f]{8})?') @@ -57,8 +59,13 @@ def tree_bytes(path): + try: + path = resolved_io(checked_real_path(path)) + except AliasUnavailable: + return 0 total = 0 for directory, folders, files in os.walk(path, followlinks=False): + folders[:] = [name for name in folders if not is_junction(Path(directory) / name)] for name in files: try: info = os.lstat(os.path.join(directory, name)) @@ -70,6 +77,7 @@ def tree_bytes(path): def digest(path): + path = io_path(checked_real_path(path)) value = hashlib.sha256() with Path(path).open('rb') as stream: for block in iter(lambda: stream.read(1 << 20), b''): @@ -82,6 +90,7 @@ def plain_tree(path): def unreadable(error): raise error try: + path = resolved_io(checked_real_path(path)) for directory, folders, files in os.walk(path, followlinks=False, onerror=unreadable): for name in [*folders, *files]: if '.replaced-' in name: @@ -109,7 +118,7 @@ def references(): values += (process.environ().get('PYTHONPATH') or '').split(os.pathsep) except (psutil.Error, OSError): pass # Another user's process cannot run our copies. - found += [os.path.normcase(str(value)) for value in values if value] + found += [os.path.normcase(plain_text(value)) for value in values if value] return found @@ -117,9 +126,22 @@ def started_from(path, running): """Whether a running process uses path, by the references captured for this run.""" if running is None: return True - prefix = os.path.normcase(os.path.abspath(path)) + path = real_path(path) + prefix = os.path.normcase(os.path.abspath(plain(path))) + def reference(value): + value = plain_text(value) + if windows(): + # Process arguments can name the same installation through FV. + # Keep the option prefix so the existing suffix/child test applies. + option, separator, target = value.partition('=') + part = target if separator and option.startswith('-') else value + translated = real_path(part) + if translated != Path(part): + return option + separator + str(translated) if part == target else str(translated) + return value # The folder itself, a path inside it, or an option ending with it (--root=...). - return any(value.endswith(prefix) or prefix + os.sep in value for value in running) + values = (os.path.normcase(plain_text(reference(value))) for value in running) + return any(value.endswith(prefix) or prefix + os.sep in value for value in values) def remove(path, root, running=None): @@ -128,7 +150,13 @@ def remove(path, root, running=None): A deletion that stops part-way leaves the renamed folder, which a later run finishes; nothing else ever has that name. """ - path, root = Path(path), Path(root) + try: + path, root = checked_real_path(path), checked_real_path(root) + if is_junction(path) or started_from(io_path(path), running): + return 0 + root, path = root_path(root, path) + except AliasUnavailable: + return 0 if not local_path(root, path) or not plain_tree(path) or started_from(path, running): return 0 size = tree_bytes(path) @@ -143,8 +171,14 @@ def remove(path, root, running=None): def finish_retired(parent, root, pattern): """Folders an earlier run renamed but could not finish deleting.""" + try: + root, parent = root_path(root, parent) + except AliasUnavailable: + return 0 released = 0 - for path in sorted(Path(parent).glob('*.retired-*')): + if not local_path(root, parent): + return released + for path in sorted(io_path(parent).glob('*.retired-*')): match = RETIRED.fullmatch(path.name) if (match and pattern.fullmatch(match['name']) and path.is_dir() and local_path(Path(root), path) and plain_tree(path)): @@ -157,11 +191,14 @@ def finish_retired(parent, root, pattern): def unchanged_source(path): """A materialized source exactly as deployed: launcher-source.json still describes every file.""" try: + path = resolved_io(checked_real_path(path)) rows = json.loads((path / 'launcher-source.json').read_text(encoding='utf-8'))['sha256'] if not isinstance(rows, dict) or not rows: return False seen = set() for directory, folders, files in os.walk(path, followlinks=False): + if any(is_junction(Path(directory) / name) for name in folders): + return False # Refuse the source before walking a redirected child. for name in files: item = Path(directory) / name relative = item.relative_to(path).as_posix() @@ -181,11 +218,15 @@ def unchanged_source(path): def editable_sources(engine): """Sources an environment installed in editable mode still imports from.""" + try: + engine = resolved_io(checked_real_path(engine)) + except AliasUnavailable: + return {None} from .engine_binding import editable_source found = set() for pattern in ('envs/*/Lib/site-packages/freevideo_engine-*.dist-info/direct_url.json', 'envs/*/lib/python3*/site-packages/freevideo_engine-*.dist-info/direct_url.json'): - for path in Path(engine).glob(pattern): + for path in io_path(engine).glob(pattern): try: source = editable_source(path) if source is not None: @@ -197,7 +238,7 @@ def editable_sources(engine): def resolved(value): try: - return Path(value).resolve() if value else None + return resolved_io(checked_real_path(value)) if value else None except (OSError, ValueError, TypeError): return None @@ -217,27 +258,35 @@ def sources(parent, root, keep, current): an earlier version of this one. """ rows = [] - parent = Path(parent) + try: + root, parent = root_path(root, parent) + except AliasUnavailable: + return rows newest = version(current) if current else None if newest is None or not parent.is_dir() or not local_path(Path(root), parent): return rows keep = {path for path in map(resolved, keep) if path} for path in sorted(parent.iterdir()): - if not VERSION.fullmatch(path.name) or path.is_symlink() or not path.is_dir() or path.resolve() in keep: + if (not VERSION.fullmatch(path.name) or path.is_symlink() or is_junction(path) + or not path.is_dir() or path.resolve() in keep): continue if (version(path) or newest) < newest and unchanged_source(path): - rows.append(path) + rows.append(Path(plain(path))) return rows def downloads(parent, root, keep): """Launcher updates other than the running and the approved one.""" rows = [] - parent = Path(parent) + try: + root, parent = root_path(root, parent) + except AliasUnavailable: + return rows if not parent.is_dir() or not local_path(Path(root), parent): return rows for path in sorted(parent.iterdir()): - if not DIGEST.fullmatch(path.name) or path.is_symlink() or not path.is_dir() or path.name[:64] in keep: + if (not DIGEST.fullmatch(path.name) or path.is_symlink() or is_junction(path) + or not path.is_dir() or path.name[:64] in keep): continue try: names = [item.name for item in path.iterdir()] @@ -247,19 +296,23 @@ def downloads(parent, root, keep): continue except OSError: continue # Unreadable: kept. - rows.append(path) + rows.append(Path(plain(path))) return rows def applications(parent, root, keep): """Launchers behind earlier desktop shortcuts, checked against the digest that names them.""" rows = [] - parent = Path(parent) + try: + root, parent = root_path(root, parent) + except AliasUnavailable: + return rows if not parent.is_dir() or not local_path(Path(root), parent): return rows keep = {path for path in map(resolved, keep) if path} for path in sorted(parent.iterdir()): - if not DIGEST.fullmatch(path.name) or path.is_symlink() or not path.is_dir() or path.resolve() in keep: + if (not DIGEST.fullmatch(path.name) or path.is_symlink() or is_junction(path) + or not path.is_dir() or path.resolve() in keep): continue try: names = sorted(item.name for item in path.iterdir()) @@ -278,7 +331,7 @@ def applications(parent, root, keep): except OSError: continue # Unreadable: kept. if actual == path.name[:64]: - rows.append(path) + rows.append(Path(plain(path))) return rows @@ -286,11 +339,15 @@ def environments(engine, current): """Separate ComfyUI environments for the current ComfyUI folder that the current one replaced.""" rows = [] try: + engine, current = checked_real_path(engine), checked_real_path(current) + engine, current = resolved_io(engine), io_path(current) current_identity = json.loads((current / 'freevideo-host.json').read_text(encoding='utf-8')) except (OSError, ValueError, TypeError): return rows + if not local_path(engine, engine / 'envs'): + return rows for path in sorted((engine / 'envs').glob('comfyui-*')): - if (not ENVIRONMENT.fullmatch(path.name) or path.is_symlink() or not path.is_dir() + if (not ENVIRONMENT.fullmatch(path.name) or path.is_symlink() or is_junction(path) or not path.is_dir() or path.resolve() == current.resolve()): continue try: @@ -298,16 +355,22 @@ def environments(engine, current): except (OSError, ValueError): continue # Without a receipt the owner is uncertain. if isinstance(identity, dict) and identity.get('comfy') == current_identity.get('comfy'): - rows.append(path) + rows.append(Path(plain(path))) return rows def torch_wheels(engine, pythons): """Downloaded PyTorch wheels, once every environment has exactly those versions installed.""" + try: + engine = checked_real_path(engine) + pythons = [checked_real_path(path) for path in pythons] + except AliasUnavailable: + return [] + engine = resolved_io(engine) if not windows(): return [] # Other systems install PyTorch from the package cache. from .torch_download import catalog, installed_versions - directory = engine / 'downloads' / 'torch-wheels' + directory = io_path(engine) / 'downloads' / 'torch-wheels' if not pythons or not directory.is_dir() or not local_path(engine, directory): return [] rows = catalog() @@ -323,7 +386,7 @@ def torch_wheels(engine, pythons): except OSError: continue if stat.S_ISREG(info.st_mode) and info.st_nlink == 1: - found.append(path) + found.append(Path(plain(path))) return found @@ -391,7 +454,11 @@ def superseded(candidate, current): def approved_digests(launcher_root, current=None): """The approved update, and a found one the running launcher would still offer.""" keep = set() - updates = Path(launcher_root) / 'updates' + try: + launcher_root = resolved_io(checked_real_path(launcher_root)) + except AliasUnavailable: + return keep + updates = io_path(launcher_root) / 'updates' for name, read in (('active.json', lambda value: value['candidate']), ('available.json', lambda value: value)): try: found = read(json.loads((updates / name).read_text(encoding='utf-8'))) @@ -409,25 +476,36 @@ def approved_digests(launcher_root, current=None): def run(*, launcher_root=None, launcher_source=None, launcher_build=None, running=(), engine=None, comfy_root=None, engine_source=None, server_source=None, receipt=True): """Remove what the current launcher and installation no longer use; report what was freed.""" - from .locking import runtime_lock - # download_bytes: installer downloads this installation no longer needs (PyTorch wheels), part of released_bytes. result = dict(released_bytes=0, download_bytes=0, removed=[], kept=[]) + try: + launcher_root = checked_real_path(launcher_root) if launcher_root else None + engine = checked_real_path(engine) if engine else None + comfy_root = checked_real_path(comfy_root) if comfy_root else None + except AliasUnavailable: + result['kept'].append(dict(kind='installation', reason='invalid runtime alias')) + return result + launcher_root = resolved_io(launcher_root) if launcher_root else None + engine = resolved_io(engine) if engine else None + comfy_root = resolved_io(comfy_root) if comfy_root else None + from .locking import runtime_lock snapshot = references() + engine = io_path(engine).absolute() if engine else None def drop(path, root, kind): + path = io_path(path) released = remove(path, root, snapshot) if not Path(path).exists(): result['released_bytes'] += released - result['removed'].append(dict(kind=kind, path=str(path), bytes=released)) + result['removed'].append(dict(kind=kind, path=plain(path), bytes=released)) else: - result['kept'].append(dict(kind=kind, path=str(path), reason='in use')) + result['kept'].append(dict(kind=kind, path=plain(path), reason='in use')) running = [path for path in map(resolved, running) if path] linked = shortcut_targets() if linked is None: result['kept'].append(dict(kind='launcher-copies', reason='shortcuts unreadable')) if launcher_root: - launcher_root = Path(launcher_root).absolute() + launcher_root = io_path(launcher_root).absolute() keep = approved_digests(launcher_root, launcher_build) | {path.parent.name[:64] for path in [*running, *(linked or ())]} for path in downloads(launcher_root / 'updates', launcher_root, keep) if linked is not None else (): drop(path, launcher_root, 'launcher-update') @@ -443,7 +521,7 @@ def drop(path, root, kind): # installation's record starts, and the copy of a running launcher's own version. keep = {path.parent for path in [*running, *linked]} try: - keep.add(Path(json.loads((Path(engine) / 'launcher' / 'desktop-shortcut.json').read_text(encoding='utf-8'))['target']).parent) + keep.add(io_path(json.loads((io_path(engine) / 'launcher' / 'desktop-shortcut.json').read_text(encoding='utf-8'))['target']).parent) except (OSError, ValueError, KeyError, TypeError): pass versions = {path.parent.name[:64] for path in running} @@ -452,12 +530,11 @@ def drop(path, root, kind): drop(path, launcher_root, 'launcher-copy') result['released_bytes'] += finish_retired(launcher_root / 'application', launcher_root, DIGEST) if engine: - engine = Path(engine).absolute() receipt_source = None if comfy_root: try: from .comfy_launcher_runtime import node_target - receipt_source = json.loads((node_target(comfy_root) / 'freevideo-launcher.json') + receipt_source = json.loads((io_path(node_target(comfy_root)) / 'freevideo-launcher.json') .read_text(encoding='utf-8')).get('source') except (OSError, ValueError, TypeError): pass @@ -473,7 +550,7 @@ def drop(path, root, kind): except (OSError, ValueError, TypeError): target = None if target and linked is not None: - keep = {Path(target).parent, *(path.parent for path in [*running, *linked])} + keep = {io_path(target).parent, *(path.parent for path in [*running, *linked])} for folder in ('application', 'application.noindex'): for path in applications(engine / 'launcher' / folder, engine, keep): drop(path, engine, 'launcher-copy') @@ -485,13 +562,13 @@ def drop(path, root, kind): pythons, current = [], None try: machine = json.loads((engine / 'machine.json').read_text(encoding='utf-8')) - pythons.append(Path(machine['python'])) + pythons.append(io_path(machine['python'])) except (OSError, ValueError, KeyError, TypeError): machine = None try: host = json.loads((engine / 'launcher' / 'comfy-host.json').read_text(encoding='utf-8')) - current = Path(host['environment']) - pythons.append(Path(host['python'])) + current = io_path(host['environment']) + pythons.append(io_path(host['python'])) except (OSError, ValueError, KeyError, TypeError): pass if current is not None and current.is_dir() and local_path(engine, current): @@ -508,16 +585,17 @@ def drop(path, root, kind): result['copied_model_reason'] = leftovers['copied_model_reason'] if machine and machine.get('ready') is True and all(python.is_file() for python in pythons): for path in torch_wheels(engine, pythons): + path = io_path(path) try: size = path.stat().st_size path.unlink() result['released_bytes'] += size result['download_bytes'] += size - result['removed'].append(dict(kind='torch-wheel', path=str(path), bytes=size)) + result['removed'].append(dict(kind='torch-wheel', path=plain(path), bytes=size)) except OSError: - result['kept'].append(dict(kind='torch-wheel', path=str(path), reason='in use')) + result['kept'].append(dict(kind='torch-wheel', path=plain(path), reason='in use')) except OSError as error: # Includes BlockingIOError: an installation holds the lease. result['kept'].append(dict(kind='installation', reason=type(error).__name__)) if receipt and result['removed']: save(engine / 'logs' / ('cleanup-' + time.strftime('%Y%m%dT%H%M%SZ', time.gmtime()) + '.json'), result) - return result + return plain_data(result) diff --git a/freevideo_engine/windows_ux.py b/freevideo_engine/windows_ux.py index e2623a88..0a0b5f47 100644 --- a/freevideo_engine/windows_ux.py +++ b/freevideo_engine/windows_ux.py @@ -50,11 +50,25 @@ def open_browser(url): -def open_text(path): +def _viewer_path(path, engine): + """A text in an installation deeper than the viewer can open, named through that installation's short folder.""" + from . import launch_view + from .long_paths import plain + path = plain(path) + try: + # The file's own runtime first: it can be another one than the selected. + view = launch_view.for_file(path) or (launch_view.for_engine(engine) if engine else launch_view.current()) + except OSError: + return path # No short folder can be made: the viewer gets the path as it is. + return path if view is None else str(launch_view.value(path, view)) + + +def open_text(path, *, engine=None): """Show a text file on this computer (a licence, the notices) in the system's text viewer.""" path = str(path) with external_python(): if windows(): + path = _viewer_path(path, engine) if path.lower().endswith('.txt'): import os os.startfile(path) diff --git a/pyproject.toml b/pyproject.toml index 584a5cb4..74c62c1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,4 +32,4 @@ include = ["freevideo_engine*"] [tool.setuptools.package-data] "freevideo_engine.prompt_vlm" = ["model.json"] -freevideo_engine = ["build-version.txt", "build-identity.json", "release_notes.json", "dependencies.json", "model_files.json", "model_shares.json", "prepared_models.json", "macos_reference_catalog.json", "superseded_tables.json", "bootstrap_versions.json", "curl_windows.json", "git_linux.json", "uv_downloads.json", "torch_downloads.json", "test_prompts.json", "windows_bootstrap.ps1", "assets/*.png", "assets/*.ico", "launcher/*.qml", "launcher/licenses/*.txt", "licenses/*.txt", "licenses/runtime/*.txt"] +freevideo_engine = ["stage_estimate_params.json", "build-version.txt", "build-identity.json", "release_notes.json", "dependencies.json", "model_files.json", "model_shares.json", "prepared_models.json", "macos_reference_catalog.json", "superseded_tables.json", "bootstrap_versions.json", "curl_windows.json", "git_linux.json", "uv_downloads.json", "torch_downloads.json", "test_prompts.json", "windows_bootstrap.ps1", "assets/*.png", "assets/*.ico", "launcher/*.qml", "launcher/licenses/*.txt", "licenses/*.txt", "licenses/runtime/*.txt"] diff --git a/scripts/test_freevideo_entry.mjs b/scripts/test_freevideo_entry.mjs index 32d13d5b..fd77dc89 100644 --- a/scripts/test_freevideo_entry.mjs +++ b/scripts/test_freevideo_entry.mjs @@ -312,9 +312,9 @@ test('main browser entry registers both views and preserves node hooks', async t removalNode = new GenerateNode(); removalNode.id = 10; removalNode.onNodeCreated(); removalNode.freevideoShowProgress({phase: 'load', received_at: 0}); const progress = progressView; - assert.deepEqual(progress.element.children.slice(0, 7).map(child => child.className), [ + assert.deepEqual(progress.element.children.slice(0, 8).map(child => child.className), [ 'fv-generation-heading', 'fv-generation-track', 'fv-generation-label', - 'fv-generation-mini', 'fv-generation-times', 'fv-generation-status', 'fv-generation-live']); + 'fv-generation-mini', 'fv-generation-times', 'fv-generation-compatibility', 'fv-generation-status', 'fv-generation-live']); const activity = progress.snapshot().label; removalNode.freevideoShowProgress({compatibility: {level: 2, name_en: 'Compatible', name_zh: '兼容'}}); assert.equal(progress.snapshot().label, activity, 'The node must not replace the activity for settings-only events'); diff --git a/web/capability_view.css b/web/capability_view.css new file mode 100644 index 00000000..06936859 --- /dev/null +++ b/web/capability_view.css @@ -0,0 +1,28 @@ +.fv-range[hidden],.fv-range-chart[hidden]{display:none} +.fv-studio .fv-range-entry{display:flex;align-items:center;gap:8px;width:100%;padding:8px 0;min-height:36px;border:0;background:transparent;text-align:left;font-size:12px} +.fv-range-entry>span:first-child{flex:1;min-width:0} +.fv-range-longest{color:var(--fv-muted);white-space:nowrap;font-size:11px;font-variant-numeric:tabular-nums} +.fv-range-arrow{width:6px;height:6px;flex:none;margin-right:3px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(45deg);transition:transform .2s} +.fv-range-entry[aria-expanded=true] .fv-range-arrow{transform:rotate(225deg)} +.fv-range-chart{position:relative;display:grid;grid-template-columns:34px minmax(0,1fr);padding-top:8px;margin-bottom:14px;scroll-margin-bottom:100px;font-variant-numeric:tabular-nums;user-select:none} +.fv-range-y{position:relative} +.fv-range-y>span{position:absolute;right:8px;transform:translateY(-50%);color:var(--fv-muted);font-size:11px;white-space:nowrap;line-height:1} +.fv-range-plot{position:relative;height:200px;min-width:0;border-radius:12px;cursor:pointer;touch-action:none} +.fv-range-plot:focus-visible{outline:2px solid var(--fv-accent);outline-offset:4px} +.fv-range-plot>canvas{position:absolute;inset:0;width:100%;height:100%;display:block;border-radius:12px;pointer-events:none} +.fv-range-previous{opacity:0} +.fv-range-x{grid-column:2;position:relative;height:42px;text-align:center} +.fv-range-size{position:absolute;top:9px;transform:translateX(-50%);font-size:11px;color:var(--fv-muted);white-space:nowrap;line-height:1.4} +.fv-range-size small{display:block;margin-top:3px;font-size:10px;letter-spacing:-.3px;color:inherit;line-height:1.4} +.fv-range-size.is-current,.fv-range-size.is-hover{color:var(--fv-ink)} +.fv-range-x.is-narrow .fv-range-size:not(.is-current):not(.is-hover) small{visibility:hidden} +.fv-range-now,.fv-range-mark{position:absolute;left:0;top:0;border-radius:50%;pointer-events:none} +.fv-range-now{width:7px;height:7px;margin:-3.5px;background:#f4f8fd;box-shadow:0 0 0 2px #0a0f166b} +.fv-range-now[hidden]{display:none} +.fv-range-mark{width:16px;height:16px;margin:-8px;box-sizing:border-box;border:2px solid #f4f8fd;box-shadow:0 0 0 1.5px #0a0f1661,inset 0 0 0 1.5px #0a0f1638,0 3px 10px #0005;opacity:0} +.fv-range-tip{position:absolute;left:0;top:0;z-index:3;max-width:100%;padding:9px 12px;border:1px solid var(--fv-border);border-radius:11px;background:#0b1017f0;box-shadow:0 10px 28px #0006;pointer-events:none;opacity:0;white-space:nowrap} +.fv-range-tip strong{display:block;font-size:15px;line-height:1.3;font-weight:650;color:var(--fv-ink)} +.fv-range-tip>span{display:block;margin-top:2px;font-size:12px;line-height:1.35;color:var(--fv-muted)} +.fv-range-plot.is-hover .fv-range-mark,.fv-range-plot.is-hover .fv-range-tip{opacity:1} +@media(max-width:480px){.fv-range-size small{letter-spacing:-.4px}} +@media(prefers-reduced-motion:reduce){.fv-range-arrow{transition:none}} diff --git a/web/capability_view.js b/web/capability_view.js new file mode 100644 index 00000000..9c7449fd --- /dev/null +++ b/web/capability_view.js @@ -0,0 +1,351 @@ +import { referenceItems } from './prompt_references.js'; + +const PRESETS = [.5, .75, .98, 1.5, 2]; +const COLUMN_SPACING = 'equal'; // 'equal' or 'width' +const FADE = .17; +const clamp = (n, a, b) => Math.max(a, Math.min(b, n)); +const positive = n => Number.isFinite(n) && n > 0; +const el = (tag, cls, text) => { const e = document.createElement(tag); e.className = cls; if (text != null) e.textContent = text; return e; }; +const value = (node, name) => node?.widgets?.find(w => w.name === name)?.value; +const linked = (node, name) => node?.inputs?.some(i => i.name === name && i.link != null); +const upstream = (node, name) => node?.graph?.getNodeById(node.graph.links?.[node.inputs?.find(i => i.name === name)?.link]?.origin_id); + +// Monotone cubic interpolation keeps both the surface and its edge continuous. +export function curve(xs, ys) { + if (xs.length === 1) return () => ys[0]; + const h = xs.slice(1).map((x, i) => x - xs[i]); + const d = h.map((v, i) => (ys[i + 1] - ys[i]) / v); + const m = [d[0]]; + for (let i = 1; i < xs.length - 1; i++) { + const a = 2 * h[i] + h[i - 1], b = h[i] + 2 * h[i - 1]; + m[i] = d[i - 1] * d[i] <= 0 ? 0 : (a + b) / (a / d[i - 1] + b / d[i]); + } + m.push(d.at(-1)); + return x => { + // Clamp outside known points; never invent a hover estimate. + if (x <= xs[0]) return ys[0]; + if (x >= xs.at(-1)) return ys.at(-1); + let i = 0; while (x > xs[i + 1]) i++; + const t = (x - xs[i]) / h[i], t2 = t * t, t3 = t2 * t; + return (2 * t3 - 3 * t2 + 1) * ys[i] + (t3 - 2 * t2 + t) * h[i] * m[i] + + (-2 * t3 + 3 * t2) * ys[i + 1] + (t3 - t2) * h[i] * m[i + 1]; + }; +} + +export function aboutTime(seconds, t) { + if (seconds < 55) { const n = Math.max(10, Math.round(seconds / 10) * 10); return t(`About ${n} s`, `约 ${n} 秒`); } + const minutes = Math.round(seconds / 60); + if (minutes < 100) return t(`About ${minutes} min`, `约 ${minutes} 分钟`); + const hours = Math.round(seconds / 1800) / 2; + return t(`About ${hours} h`, `约 ${hours} 小时`); +} + +// Send material facts only. An incomplete reference list uses the server's +// documented task fallback, rather than pretending the known subset is all of it. +export function capabilityContext(node, state) { + if (linked(node, 'conditioning') || linked(node, 'width') || linked(node, 'height') + || ['base_steps', 'refine_steps', 'two_pass'].some(name => linked(node, name))) return null; + let items, assets; + try { items = referenceItems(node); assets = JSON.parse(value(upstream(node, 'media'), 'assets') || '[]'); } + catch { return null; } + const refs = items.filter(r => !r.role), first = items.some(r => r.role === 'first'), last = items.some(r => r.role === 'last'); + const rows = refs.map(item => { + const source = assets[item.assetIndex] || {}, row = {kind: item.kind}; + if (item.kind !== 'audio') { row.width = source.width; row.height = source.height; } + if (item.kind !== 'image') row.seconds = source.seconds; + if (item.kind === 'video' && positive(source.audio_seconds)) row.audio_seconds = source.audio_seconds; + return row; + }); + const audio = refs.some((r, i) => r.kind === 'audio' || positive(rows[i].audio_seconds)); + const visual = refs.some(r => r.kind !== 'audio'); + const task = audio ? visual ? 'ref2va_av' : 'ref2va_audio' : visual ? 'ref2va' + : first && last ? 'fl2va' : first ? 'i2va' : last ? 'l2va' : 't2va'; + const lora = upstream(node, 'loras'); + let adapters = false; + try { + if (lora?.widgets?.some(w => w.name === 'adapters')) adapters = JSON.parse(value(lora, 'adapters') || '[]') + .some(r => r.enabled !== false && Number(r.strength ?? 1) !== 0); + else if (lora) adapters = Boolean(value(lora, 'lora') && value(lora, 'lora') !== 'None' && Number(value(lora, 'strength') ?? 1) !== 0); + } catch { return null; } + const query = new URLSearchParams({task, adapters: adapters ? '1' : '0'}); + if (state.custom) { query.set('width', value(node, 'width')); query.set('height', value(node, 'height')); } + else { if (!positive(state.ratio)) return null; query.set('aspect', `${state.ratio}:1`); } + const complete = rows.length && rows.every(r => (r.kind === 'audio' || [r.width, r.height].every(n => Number.isInteger(n) && n > 0 && n <= 65536)) + && (r.kind === 'image' || positive(r.seconds))); + if (complete) query.set('references', JSON.stringify(rows)); + return query.toString(); +} + +export function capabilitySurface(grid, state) { + if (grid?.version !== 1 || grid.status !== 'ready' || typeof grid.revision !== 'string' || !grid.machine?.system + || /darwin|mac|apple/i.test(grid.machine.system) + || !Array.isArray(grid.sizes) || !grid.sizes.length || !Array.isArray(grid.lengths)) return null; + const tier = Object.values(grid.tiers || {}).find(v => v.steps === state.steps && v.two_pass === state.twoPass + && (!v.two_pass || state.refine === 3)); + if (!tier || !Array.isArray(tier.estimate_seconds) || !Array.isArray(tier.max_seconds)) return null; + const lengths = grid.lengths.map(l => l.seconds); + if (!lengths.length || lengths.some((s, i) => !Number.isInteger(s) || s < 2 || (i && s <= lengths[i - 1]))) return null; + const columns = grid.sizes.map((size, i) => { + if (!positive(size.pixels) || [size.width, size.height].some(n => !Number.isInteger(n) || n < 256 || n > 4096 || n % 32)) return null; + const row = tier.estimate_seconds[i], max = tier.max_seconds[i]; + if (!Array.isArray(row) || row.length !== lengths.length) return null; + const points = lengths.flatMap((s, j) => positive(row[j]) && s <= max ? [{seconds: s, estimate: row[j]}] : []); + return {...size, points, max: points.at(-1)?.seconds || 0}; + }); + if (columns.some(c => !c) || !columns.some(c => c.points.length)) return null; + // Only the five chart targets are shown, even if the API includes 1.00 MP. + const sizes = state.custom ? columns : PRESETS.map(pixels => { + const width = Math.round(Math.sqrt(pixels * 1024 ** 2 * state.ratio) / 32) * 32; + const height = Math.round(Math.sqrt(pixels * 1024 ** 2 / state.ratio) / 32) * 32; + const source = columns.find(c => c.width === width && c.height === height); + return source ? {...source, pixels} : null; + }); + if (sizes.some(s => !s)) return null; + const widths = sizes.map(s => s.width), span = widths.at(-1) - widths[0]; + const xs = sizes.map((s, i) => COLUMN_SPACING === 'width' && span + ? .075 + .85 * (s.width - widths[0]) / span : (i + .5) / sizes.length); + const top = Math.max(...sizes.map(s => s.max)) > 15 ? 30 : 15; + const edge = curve(xs, sizes.map(s => s.max)); + const curves = sizes.map(s => s.points.length ? curve(s.points.map(p => p.seconds), s.points.map(p => Math.log(p.estimate))) : null); + const available = sizes.map((s, i) => s.max ? i : -1).filter(i => i >= 0); + function snap(x, y) { + if (x < 0 || x > 1 || y < 0 || y > 1) return null; + const seconds = top * (1 - y); + if (seconds >= edge(x) + FADE * top) return null; + const i = xs.reduce((best, v, j) => Math.abs(v - x) < Math.abs(xs[best] - x) ? j : best, 0); + if (!sizes[i].points.length) return null; + const points = sizes[i].points.filter(p => p.seconds <= top); + const point = points.reduce((best, p) => Math.abs(p.seconds - seconds) < Math.abs(best.seconds - seconds) ? p : best); + return {...point, width: sizes[i].width, height: sizes[i].height, pixels: sizes[i].pixels, size: i}; + } + return {sizes, xs, top, edge, snap, at: seconds => curve(available.map(i => xs[i]), available.map(i => curves[i](seconds)))}; +} + +// Iris's blue ramp in OKLab, interpolated in linear light; transparent beyond +// the edge so the heatmap dissolves into whichever panel contains it. +const stops = [[0, .845, .085, 240], [.30, .760, .115, 245], [.64, .605, .140, 252], [1, .440, .128, 261]]; +const lab = stops.map(([, l, c, h]) => [l, c * Math.cos(h * Math.PI / 180), c * Math.sin(h * Math.PI / 180)]); +const ramps = [0, 1, 2].map(k => curve(stops.map(s => s[0]), lab.map(v => v[k]))); +const gamma = c => { c = clamp(c, 0, 1); return c <= .0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - .055; }; +const colors = Array.from({length: 1025}, (_, i) => { + const [L, a, b] = ramps.map(f => f(i / 1024)); + const l = (L + .3963377774 * a + .2158037573 * b) ** 3; + const m = (L - .1055613458 * a - .0638541728 * b) ** 3; + const s = (L - .0894841775 * a - 1.291485548 * b) ** 3; + return [4.0767416621 * l - 3.3077115913 * m + .2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - .3413193965 * s, + -.0041960863 * l - .7034186147 * m + 1.707614701 * s].map(c => gamma(c) * 255); +}); +export function surfacePixels(surface, width, height) { + const pixels = new Uint8ClampedArray(width * height * 4), edge = Array.from({length: width}, (_, x) => surface.edge((x + .5) / width)); + for (let y = 0; y < height; y++) { + const seconds = surface.top * (1 - (y + .5) / height), across = surface.at(Math.max(2, seconds)); + for (let x = 0; x < width; x++) { + const v = clamp((across((x + .5) / width) - Math.log(90)) / Math.log(320), 0, 1) * 1024; + const i = Math.min(1023, Math.floor(v)), mix = v - i; + const f = clamp((seconds - edge[x]) / (FADE * surface.top), 0, 1); + // An entirely unavailable column stays clear all the way to zero. + const a = (1 - f ** 3 * (f * (6 * f - 15) + 10)) * clamp(edge[x] / 2, 0, 1); + const offset = (y * width + x) * 4; + const noise = (((x * 1973 + y * 9277) % 101) / 100 - .5) * .75; + for (let k = 0; k < 3; k++) pixels[offset + k] = colors[i][k] + (colors[i + 1][k] - colors[i][k]) * mix + noise; + pixels[offset + 3] = 255 * a; + } + } + return pixels; +} + +let serial = 0; +export function createCapabilityView({node, api, t, getState, onPick, onEstimate}) { + if (!document.querySelector('link[data-fv-capability]')) { + const css = el('link', ''); css.rel = 'stylesheet'; css.dataset.fvCapability = ''; + css.href = new URL('./capability_view.css', import.meta.url).href; document.head.append(css); + } + const element = el('div', 'fv-range'); element.hidden = true; + const entry = el('button', 'fv-range-entry'); entry.type = 'button'; entry.dataset.fvGuide = 'range'; + entry.append(el('span', '', t('Available sizes and lengths', '可生成范围'))); + const longest = el('span', 'fv-range-longest'), arrow = el('i', 'fv-range-arrow'); arrow.setAttribute('aria-hidden', 'true'); entry.append(longest, arrow); + const chart = el('div', 'fv-range-chart'), yAxis = el('div', 'fv-range-y'), plot = el('div', 'fv-range-plot'), xAxis = el('div', 'fv-range-x'); + chart.id = `fv-range-${++serial}`; entry.setAttribute('aria-controls', chart.id); + const canvas = el('canvas', ''), previous = el('canvas', 'fv-range-previous'); + const now = el('i', 'fv-range-now'), mark = el('i', 'fv-range-mark'), tip = el('div', 'fv-range-tip'), time = el('strong', ''), configuration = el('span', ''); + for (const item of [canvas, previous, now, mark]) item.setAttribute('aria-hidden', 'true'); + tip.setAttribute('role', 'status'); tip.append(time, configuration); plot.append(canvas, previous, now, mark, tip); + plot.tabIndex = 0; plot.setAttribute('role', 'group'); plot.setAttribute('aria-label', t('Available sizes and lengths', '可生成范围')); + chart.append(yAxis, plot, xAxis); element.append(entry, chart); + let opened = false; + try { opened = localStorage.getItem('freevideo.range-open') === '1'; } catch {} + const fold = () => { chart.hidden = !opened; entry.setAttribute('aria-expanded', String(opened)); }; + fold(); + let disposed = false, query = '', grid = null, surface = null, hover = null, controller = null, retry = null, request = 0, drawn = '', pointer = null; + const position = (item, x, y) => { item.style.transform = `translate(${x}px,${y}px)`; }; + const hideHover = () => { hover = null; plot.classList.remove('is-hover'); highlight(); }; + function show(point, x, y) { + hover = point; position(mark, x, y); + time.textContent = aboutTime(point.estimate, t); + const duration = point.seconds === Math.min(30, surface.sizes[point.size].max) + ? t(`up to ${point.seconds} s`, `单次最长 ${point.seconds} 秒`) : t(`${point.seconds} s`, `${point.seconds} 秒`); + configuration.textContent = `${point.width} × ${point.height} · ${duration}`; + plot.classList.add('is-hover'); + highlight(); + const tw = tip.offsetWidth, th = tip.offsetHeight; + const tx = clamp(x - tw / 2, 0, Math.max(0, plot.clientWidth - tw)); + const ty = y - th - 16 >= 0 ? y - th - 16 : Math.min(plot.clientHeight - th, y + 16); + position(tip, tx, Math.max(0, ty)); + } + function currentSize() { + const matches = s => s.width === Number(value(node, 'width')) && s.height === Number(value(node, 'height')); + const state = getState(); + // The chart marker and limit share the native column for old 1.00 MP sizes. + if (!state.custom && matches({width: Math.round(Math.sqrt(1024 ** 2 * state.ratio) / 32) * 32, + height: Math.round(Math.sqrt(1024 ** 2 / state.ratio) / 32) * 32})) return surface.sizes.findIndex(s => s.pixels === .98); + const target = Number(node.properties?.freevideo_pixels); + const exact = surface.sizes.findIndex(s => matches(s) && s.pixels === target); + return exact >= 0 ? exact : surface.sizes.findIndex(matches); + } + function highlight() { + const i = surface ? currentSize() : -1; + [...xAxis.children].forEach((label, j) => { + label.classList.toggle('is-current', j === i); + label.classList.toggle('is-hover', j === hover?.size); + }); + } + function current() { + if (!surface) return; + const i = currentSize(); + const seconds = Number(value(node, 'seconds')); + const point = i >= 0 && surface.sizes[i].points.find(p => p.seconds === Math.round(seconds)); + now.hidden = !point || seconds > surface.top || seconds > surface.sizes[i].max; + if (!now.hidden) position(now, surface.xs[i] * plot.clientWidth, (1 - seconds / surface.top) * plot.clientHeight); + const max = i >= 0 ? Math.min(30, surface.sizes[i].max) : 0; + longest.textContent = max ? t(`Up to ${max} s`, `单次最长 ${max} 秒`) : ''; + plot.setAttribute('aria-disabled', String(getState().disabled)); + highlight(); + } + function paint(animate = false) { + if (!surface || !opened || element.hidden || !plot.clientWidth) return; + const dpr = Math.max(1, window.devicePixelRatio || 1), width = Math.round(plot.clientWidth * dpr), height = Math.round(plot.clientHeight * dpr); + const key = `${grid.revision}:${getState().steps}:${getState().twoPass}:${width}:${height}:${query}`; + if (key === drawn) { current(); return; } + drawn = key; + if (animate && canvas.width && !matchMedia('(prefers-reduced-motion: reduce)').matches) { + previous.width = canvas.width; previous.height = canvas.height; + previous.getContext('2d').drawImage(canvas, 0, 0); + previous.getAnimations().forEach(a => a.cancel()); + previous.animate([{opacity: 1}, {opacity: 0}], {duration: 200}); + } + canvas.width = width; canvas.height = height; + const ctx = canvas.getContext('2d'), img = ctx.createImageData(width, height); + img.data.set(surfacePixels(surface, width, height)); ctx.putImageData(img, 0, 0); + yAxis.replaceChildren(); xAxis.replaceChildren(); + for (let s = 0; s <= surface.top; s += surface.top / 3) { + const label = el('span', '', t(`${s} s`, `${s} 秒`)); label.style.top = `${(1 - s / surface.top) * 100}%`; yAxis.append(label); + } + surface.sizes.forEach((size, i) => { + const label = el('span', 'fv-range-size', `${size.pixels.toFixed(2)} MP`); + label.style.left = `${surface.xs[i] * 100}%`; + label.append(el('small', '', `${size.width} × ${size.height}`)); xAxis.append(label); + }); + xAxis.classList.toggle('is-narrow', plot.clientWidth / surface.sizes.length < 60); + current(); hideHover(); + } + function applyGrid(animate = false) { + surface = capabilitySurface(grid, getState()); element.hidden = !surface; + onEstimate?.(); + if (!surface) { hideHover(); return; } + current(); paint(animate); + } + async function fetchGrid() { + if (!query || disposed) return; + clearTimeout(retry); controller?.abort(); controller = new AbortController(); + const signal = controller.signal, key = query, id = ++request; + try { + const reply = await api.fetchApi('/freevideo/capabilities?' + key, {signal}); + if (!reply.ok) throw new Error('unavailable'); + const next = await reply.json(); + if (disposed || signal.aborted || query !== key || request !== id) return; + if (next.status === 'computing') { + grid = null; applyGrid(); + retry = setTimeout(fetchGrid, clamp(Number(next.retry_after_seconds) || 1, .1, 30) * 1000); return; + } + grid = next; applyGrid(true); + } catch { + if (!disposed && !signal.aborted && query === key && request === id) { grid = null; applyGrid(); } + } + } + function update() { + if (disposed) return; + const next = capabilityContext(node, getState()) || ''; + if (next !== query) { + query = next; controller?.abort(); clearTimeout(retry); grid = null; drawn = ''; applyGrid(); + if (query) fetchGrid(); + } else applyGrid(true); + } + entry.onclick = () => { + opened = !opened; fold(); + try { localStorage.setItem('freevideo.range-open', opened ? '1' : '0'); } catch {} + if (opened) { + paint(); fetchGrid(); + chart.scrollIntoView({block: 'nearest', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth'}); + } + else hideHover(); + }; + function point(event) { + if (!surface) return; + const box = plot.getBoundingClientRect(), x = clamp(event.clientX - box.left, 0, box.width), y = clamp(event.clientY - box.top, 0, box.height); + const snapped = surface.snap(x / box.width, y / box.height); + if (snapped) show(snapped, x, y); else hideHover(); + } + const pick = () => { if (hover && !getState().disabled) { const point = hover; onPick(point); current(); } }; + plot.onpointermove = point; + plot.onpointerdown = event => { + if (event.button !== 0) return; + point(event); pointer = event.pointerId; plot.setPointerCapture(pointer); + }; + plot.onpointerup = event => { + if (event.pointerId !== pointer) return; + point(event); pointer = null; plot.releasePointerCapture(event.pointerId); pick(); + }; + plot.onpointercancel = () => { pointer = null; hideHover(); }; + plot.onlostpointercapture = () => { pointer = null; }; + plot.onpointerleave = event => { if (pointer === null && event.pointerType !== 'touch') hideHover(); }; + plot.onblur = hideHover; + plot.onkeydown = event => { + if (!surface) return; + if (['Enter', ' '].includes(event.key)) { event.preventDefault(); pick(); return; } + const delta = {ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1]}[event.key]; + if (!delta) return; + event.preventDefault(); + const base = hover || {size: Math.max(0, currentSize()), seconds: Math.round(Number(value(node, 'seconds')))}; + let i = clamp(base.size + delta[0], 0, surface.sizes.length - 1); + while (!surface.sizes[i].points.length && delta[0] && i + delta[0] >= 0 && i + delta[0] < surface.sizes.length) i += delta[0]; + if (!surface.sizes[i].points.length) return; + const seconds = clamp(base.seconds + delta[1], 2, Math.min(surface.top, surface.sizes[i].max)); + const x = surface.xs[i], y = 1 - seconds / surface.top, snapped = surface.snap(x, y); + if (snapped) show(snapped, x * plot.clientWidth, y * plot.clientHeight); + }; + function changed(event) { + if (event.detail?.revision === grid?.revision) return; + const params = new URLSearchParams(query), detail = event.detail || {}; + if (!query || detail.task !== params.get('task') || Boolean(detail.adapters) !== (params.get('adapters') === '1')) return; + if ((params.has('width') && (String(detail.width) !== params.get('width') || String(detail.height) !== params.get('height'))) + || (!params.has('width') && (detail.width != null || detail.aspect !== params.get('aspect')))) return; + if (JSON.stringify(detail.references ?? null) !== JSON.stringify(params.has('references') ? JSON.parse(params.get('references')) : null)) return; + fetchGrid(); + } + const observer = new ResizeObserver(() => paint()); observer.observe(plot); + api.addEventListener('freevideo_capabilities', changed); + const outside = event => { if (!plot.contains(event.target)) hideHover(); }; + node && window.addEventListener('pointerdown', outside); + return {element, update, estimate(steps, twoPass, refine) { + if (!grid || !surface) return ''; + const tier = Object.values(grid.tiers || {}).find(v => v.steps === steps && v.two_pass === twoPass && (!twoPass || refine === 3)); + const i = grid.sizes.findIndex(s => s.width === Number(value(node, 'width')) && s.height === Number(value(node, 'height'))); + const j = grid.lengths.findIndex(l => l.seconds === Number(value(node, 'seconds'))); + const seconds = tier?.estimate_seconds?.[i]?.[j]; + return positive(seconds) ? aboutTime(seconds, t) : ''; + }, dispose() { + disposed = true; controller?.abort(); clearTimeout(retry); observer.disconnect(); + api.removeEventListener('freevideo_capabilities', changed); window.removeEventListener('pointerdown', outside); + }}; +} diff --git a/web/compatibility.js b/web/compatibility.js index e137e177..24ebf3f1 100644 --- a/web/compatibility.js +++ b/web/compatibility.js @@ -89,8 +89,10 @@ export async function notifyCompatibility() { const request=await client(), value=await request('check'); if (!value.available || !value.notice) return; const panel = dialog(t('Compatibility enabled', '已开启兼容性设置')); - const message = element('p', t(`The previous generation ended unexpectedly. Compatibility level ${value.level} is now enabled. You can adjust or disable it in settings.`, - `上次生成未正常结束,已自动开启兼容性设置(第 ${value.level} 档)。你可以在设置里调节或关闭。`)); + const message = element('p', value.recovery_disabled + ? t('Generation is slowed automatically for stability; you can change this in Settings.', '为了稳定,生成已自动放慢;可在设置里调整。') + : t('Generation is slowed automatically for stability; it speeds up after 3 successful generations in a row, or you can change this in Settings.', + '为了稳定,生成已自动放慢;连续完成 3 次后会加快一些,也可在设置里调整。')); panel.actions.before(message); const finish = async (settings) => { try { await request('ack', {notice_id: value.notice.id}); panel.close(); if (settings) await openCompatibility(); } diff --git a/web/error_panel.js b/web/error_panel.js index 68f0e358..3caab523 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -20,7 +20,7 @@ const PLAIN_ERRORS = [ summary: ['This LoRA changes parts of the model that the int8 version cannot merge yet, so it cannot be used for now. LoRAs that change only linear layers work.', '这个 LoRA 修改了 int8 模型目前还不能合并的部分,暂时无法使用。只修改线性层的 LoRA 可以正常使用。']}, {key: 'fa4-update', message: 'The attention speed-up component on this computer needs an update. Run freevideo setup --fa4-guard in a terminal, then restart FreeVideo.', title: ['The attention speed-up component needs an update', '需要更新注意力加速组件'], - summary: ['The attention speed-up component on this computer needs an update. Run freevideo setup --fa4-guard in a terminal, then restart FreeVideo.', '这台电脑的注意力加速组件需要更新。请在终端运行 freevideo setup --fa4-guard,然后重新启动 FreeVideo。']}, + summary: ['The attention speed-up component on this computer needs an update. Run freevideo setup --fa4-guard in a terminal, then restart FreeVideo.', '注意力加速组件需要更新。请在终端运行 freevideo setup --fa4-guard,然后重新启动 FreeVideo。']}, {key: 'duration-too-short', message: 'The video must be at least 1.625 s long.', title: ['The video is too short', '视频时长太短'], summary: ['The video must be at least 1.625 s long.', '视频时长不能短于 1.625 秒。']}, @@ -233,7 +233,7 @@ export function failureAdvice(value, t, known = knownError(value)) { return row('resources', ['This video is too large for the free GPU memory', '这段视频超出了当前可用显存'], ['Before generating, FreeVideo estimated the GPU memory this video needs, and it is more than the GPU has free right now.', 'FreeVideo 在开始生成前估算了所需显存,这段视频超过了显卡现在空闲的显存。'], [`This computer can make ${en} right now. Change the duration or total pixels and generate again; closing other programs that use the GPU also frees memory.`, - `这台电脑现在可以生成:${zh}。请调整时长或总像素后重新生成;关闭其他占用显卡的程序也可以腾出显存。`]); + `现在可以生成:${zh}。请调整时长或总像素后重新生成;关闭其他占用显卡的程序也可以腾出显存。`]); } return row('resources', ['The request exceeds available memory', '当前任务的可用内存不足'], ['Before generating, FreeVideo estimated the GPU memory and RAM this task needs, and it is more than is available right now.', 'FreeVideo 在开始生成前估算了所需的显存和内存,这次任务超过了现在可用的部分。'], diff --git a/web/generation_progress.css b/web/generation_progress.css index 22bd4f97..c1807310 100644 --- a/web/generation_progress.css +++ b/web/generation_progress.css @@ -22,6 +22,8 @@ .fv-generation-times>span[hidden],.fv-generation-status[hidden],.fv-generation-times[hidden]{display:none!important} /* No reserved blank rows or clipped copy. Browser checks measure actual lines. */ .fv-generation-status{box-sizing:border-box;width:100%;flex:none;margin-top:10px;font-size:12px;line-height:18px;color:var(--fv-muted,#a2abba);text-align:center;text-wrap:balance} +.fv-generation-compatibility{box-sizing:border-box;width:100%;flex:none;margin-top:10px;font-size:12px;line-height:18px;color:var(--fv-ink,#e8edf5);text-align:center;text-wrap:balance} +.fv-generation-compatibility[hidden]{display:none!important} .fv-generation-live{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0} /* Studio shows progress with the preview's rising wave, so its bar stays hidden (studio.css); the node view keeps the bar under the percentage. */ @@ -33,6 +35,11 @@ The wave reads --fv-progress from this element, not its size. */ .fv-studio .fv-stage .fv-generation-progress.fv-generation-compact{inset:auto;top:50%;left:50%;transform:translate(-50%,-50%);box-sizing:border-box;width:max-content;min-width:min(100%,320px);max-width:100%;padding:18px 40px;background:radial-gradient(ellipse 60% 60% at 50% 50%,rgba(7,19,32,.55),transparent 72%)} .fv-studio .fv-stage[data-short=true] .fv-generation-progress.fv-generation-compact{padding:8px 16px} +/* A narrow preview must grow for the extra persistent sentence, including a + simultaneous retry hint. Return to the video's aspect ratio after it ends. */ +.fv-studio .fv-preview-space:has(.fv-generation-compatibility:not([hidden])){min-height:min-content;max-height:none} +.fv-studio .fv-stage:has(.fv-generation-compatibility:not([hidden])){min-width:0;min-height:min-content} +.fv-studio .fv-stage .fv-generation-progress.fv-generation-compact:has(.fv-generation-compatibility:not([hidden])){position:relative;inset:auto;transform:none;width:100%;min-width:0} /* The small bar keeps its 160 px in the narrower box. */ .fv-studio .fv-stage .fv-generation-compact .fv-generation-mini{max-width:100%} .fv-encoder-path{margin:6px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;font:11px/1.6 ui-monospace,monospace;color:var(--fv-muted);user-select:text} @@ -47,7 +54,7 @@ /* The extra class also overrides Studio's quiet button styling. */ .fv-report-download.fv-quiet[data-emphasis=true]{background:transparent;border:1px solid var(--fv-border,#ffffff2e);color:var(--fv-ink);padding:5px 12px;border-radius:var(--fv-r-sm,6px);font:inherit;font-size:12px;cursor:pointer;flex-shrink:0} .fv-report-download.fv-quiet[data-emphasis=true]:hover:not(:disabled){border-color:var(--fv-muted,#a2abba)} -.fv-generation-progress:lang(zh) :is(.fv-generation-label,.fv-generation-status){word-break:keep-all;overflow-wrap:anywhere} +.fv-generation-progress:lang(zh) :is(.fv-generation-label,.fv-generation-status,.fv-generation-compatibility){word-break:keep-all;overflow-wrap:anywhere} /* Small bar under the stage line: slow stages only, no number. A light track shows on the dark card and on Studio's wave alike. */ .fv-generation-mini{position:relative;width:160px;max-width:60%;height:3px;margin:8px auto 0;border-radius:999px;background:rgba(255,255,255,.14);overflow:hidden} diff --git a/web/generation_progress.js b/web/generation_progress.js index 3b26d731..f14a1dbc 100644 --- a/web/generation_progress.js +++ b/web/generation_progress.js @@ -231,13 +231,14 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f const times = el('div', 'fv-generation-times'); const elapsed = el('span', ''), eta = el('span', ''); times.append(elapsed, eta); const status = el('div', 'fv-generation-status'); status.hidden = true; + const compatibilityNote = el('div', 'fv-generation-compatibility'); compatibilityNote.hidden = true; const live = el('div', 'fv-generation-live'); live.setAttribute('aria-live', 'polite'); live.setAttribute('aria-atomic', 'true'); const stall = el('p', 'fv-generation-note fv-generation-stall'); stall.hidden = true; stall.setAttribute('role', 'status'); const stallText = el('span', ''); stall.append(stallText); const trims = new Map(); - element.append(heading, track, label, mini, times, status, live); + element.append(heading, track, label, mini, times, compatibilityNote, status, live); let recovery = '', lowMemory = false, compatibility = null, samplingPlan = {}, secondPass = false, references = false; let hasHeartbeat = false; // Mac: the unified-memory notice of this request, and whether its first pass has ended. @@ -527,8 +528,23 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f if (label.textContent !== base) label.textContent = base; drawMini(age, ended); const firstSlow = state.phase === 'sampling' && state.stage !== 'latent_upscale' && state.done === 0 && paused; + const automatic = compatibility?.origin === 'automatic' && compatibility.level > 0; + const required = Number.isInteger(compatibility?.recovery_successes_required) && compatibility.recovery_successes_required > 0 + ? compatibility.recovery_successes_required : 3; + const successes = Number.isInteger(compatibility?.consecutive_successes) ? compatibility.consecutive_successes : 0; + const remaining = Math.max(0, required - Math.max(0, successes)); + const compatibilityText = !ended && automatic ? compatibility.recovery_disabled + ? t('Generation is slowed automatically for stability; you can change this in Settings.', '为了稳定,生成已自动放慢;可在设置里调整。') + : t(remaining === 1 ? 'Generation is slowed automatically for stability; it speeds up after one more successful generation.' + : `Generation is slowed automatically for stability; it speeds up after ${remaining} more successful generations.`, + `为了稳定,生成已自动放慢;再连续完成 ${remaining} 次后会加快一些。`) : ''; + if (compatibilityNote.textContent !== compatibilityText) { + compatibilityNote.textContent = compatibilityText; + compatibilityNote.hidden = !compatibilityText; + layout(); + } const level = ended ? '' : recovery ? 'retry' : firstSlow ? 'first-step' - : lowMemory ? 'memory' : memoryTight ? 'unified-memory' : compatibility ? 'compatibility' : trims.size ? 'notice' : ''; + : lowMemory ? 'memory' : memoryTight ? 'unified-memory' : compatibility && !automatic && compatibility.level > 0 ? 'compatibility' : trims.size ? 'notice' : ''; const explanation = {retry: recovery, 'first-step': t('The first run at this size, or after an update, takes longer to start.', '首次使用这个尺寸或更新后,开头要多花一些时间。'), @@ -549,8 +565,9 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f status.hidden = !explanation; status.dataset.level = level; const announcements = []; if (paused && !pauseAnnounced) { announcements.push(base); pauseAnnounced = true; } - if (explanation !== lastExplanation && explanation && !announcedMessages.has(explanation)) { - announcements.push(explanation); announcedMessages.add(explanation); + for (const text of [compatibilityText, explanation !== lastExplanation ? explanation : '']) { + if (!text || announcedMessages.has(text)) continue; + announcements.push(text); announcedMessages.add(text); if (announcementRecord) { announcementRecord.messages = [...announcedMessages]; try { sessionStorage.setItem(announcementKey, JSON.stringify(Object.fromEntries(requestAnnouncements))); } @@ -737,6 +754,7 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f } function hide() { element.hidden = true; status.hidden = true; recovery = ''; trims.clear(); lowMemory = false; compatibility = null; references = false; hasHeartbeat = false; + compatibilityNote.hidden = true; compatibilityNote.textContent = ''; memoryTight = null; firstPassOver = false; elapsedShown = 0; if (timer !== null) clearInterval(timer); timer = null; state = {}; overall = {}; shownFraction = null; diff --git a/web/health.js b/web/health.js index 97903f94..43de1d97 100644 --- a/web/health.js +++ b/web/health.js @@ -13,10 +13,10 @@ 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 = ['browser_extension.js', 'compatibility.js', 'error_panel.js', 'fonts.js', 'freevideo.js', 'generation_progress.js', +export const MODULES = ['browser_extension.js', 'capability_view.js', 'compatibility.js', 'error_panel.js', 'fonts.js', 'freevideo.js', 'generation_progress.js', 'health.js', 'image_editor.js', 'image_editor_engine.js', 'launcher.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', - 'result_actions.js', 'sampling_effort.js', 'setup.js', 'share.js', 'studio.js', 'studio_queue.js', + 'result_actions.js', 'sampling_effort.js', 'setup.js', 'share.js', 'studio.js', 'studio_queue.js', 'studio_size.js', 'toolbar_position.js', 'updates.js', 'view_navigation.js', 'wordmark.js']; const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); // A file the browser failed to load stays failed for the page's lifetime even diff --git a/web/setup.js b/web/setup.js index 8e5338f9..723225ee 100644 --- a/web/setup.js +++ b/web/setup.js @@ -21,7 +21,7 @@ const failureText = value => { ? t('The installation plan changed: files in the installation folder changed after you confirmed the plan, so FreeVideo stopped instead of continuing with it. Click Detect & review, then Install / repair to review the new plan.', '安装计划有变化:确认计划之后,安装位置里的文件又有变化,FreeVideo 因此停止,未按原计划继续。请先点击“检测并预览”,再点击“安装/修复”,查看新的计划。') : t('The installation plan changed: something changed after you confirmed the plan (for example the settings, the GPU or the free disk space), so FreeVideo stopped instead of continuing with it. Click Detect & review, then Install / repair to review the new plan.', - '安装计划有变化:确认计划之后,这台电脑的情况又有变化(例如设置、显卡或可用磁盘空间),FreeVideo 因此停止,未按原计划继续。请先点击“检测并预览”,再点击“安装/修复”,查看新的计划。'); + '安装计划有变化:确认计划之后,设置、显卡或可用磁盘空间发生了变化,FreeVideo 因此停止,未按原计划继续。请先点击“检测并预览”,再点击“安装/修复”,查看新的计划。'); } if (!matches.length) return text; const file = matches.at(-1)[1]; diff --git a/web/studio.js b/web/studio.js index a572e0c3..c3a097ec 100644 --- a/web/studio.js +++ b/web/studio.js @@ -18,6 +18,8 @@ import { createSamplingEffort } from './sampling_effort.js'; import { resultActions, previewBadge } from './result_actions.js'; import { createPromptEnhancer } from './prompt_enhance.js'; import { rememberPromptDraft } from './prompt_draft.js'; +import { canvasDimensions, imageSizeSelection } from './studio_size.js'; +import { createCapabilityView } from './capability_view.js'; const languageOverride = typeof location !== 'undefined' ? new URLSearchParams(location.search).get('freevideo_lang') : null; @@ -206,13 +208,18 @@ export function openStudio(node) { ? node.properties.freevideo_pixels : (initialWidth * initialHeight) / 1024 ** 2; let disposed = false; const mp = el('select'); mp.setAttribute('aria-label', t('Total pixels', '总像素')); - for (const [n, label] of [[.5, '0.50 MP'], [.75, '0.75 MP'], [.98, t('0.98 MP · Native', '0.98 MP · 原生')], [1, '1.00 MP'], [1.5, '1.50 MP'], [2, '2.00 MP']]) { const option = el('option', label); option.value = n; mp.append(option); } - const nearest = [...mp.options].find(o => Math.abs(Number(o.value) - pixels) < .03); - if (nearest) mp.value = nearest.value; else { const option = el('option', `${pixels.toFixed(2)} MP`); option.value = pixels; mp.append(option); mp.value = pixels; } + const presetRatio = {'16:9': 16 / 9, '9:16': 9 / 16, '1:1': 1}[selected] || ratio; + const sizeSelection = imageSizeSelection(pixels, selected === 'custom' ? null : presetRatio, Number(initialWidth), Number(initialHeight)); + if (pixels === 1 && sizeSelection.value === .98) pixels = .98; + for (const n of sizeSelection.options) { const option = el('option', n === .98 ? t('0.98 MP · Native', '0.98 MP · 原生') : `${n.toFixed(2)} MP`); option.value = n; mp.append(option); } + mp.value = sizeSelection.value; + const legacyOption = [...mp.options].find(o => Number(o.value) === 1); mp.title = t('1 MP = 1024 × 1024, as in ComfyUI.', '1 MP = 1024 × 1024,与 ComfyUI 一致。'); mp.disabled = dimensionsLinked; const seconds = el('input'); seconds.type = 'number'; seconds.min = '1.625'; seconds.max = '60'; seconds.step = 'any'; seconds.value = value(node, 'seconds'); seconds.disabled = linked(node, 'seconds'); seconds.setAttribute('aria-label', t('Duration in seconds', '时长(秒)')); const fields = el('div', null, 'fv-fields'); fields.append(field(t('Total pixels', '总像素'), mp), field(t('Duration · seconds', '时长 · 秒'), seconds)); canvas.append(fields); const canvasNote = el('div', null, 'fv-canvas-note'), dimensions = el('span'), duration = el('span'); canvasNote.append(dimensions, duration); canvas.append(canvasNote); + const rangeMount = el('div'); canvas.append(rangeMount); + let capabilityView; // State of the two-pass choice below; the radio group is what is shown. const twoPass = el('input'); twoPass.type = 'checkbox'; twoPass.checked = value(node, 'two_pass') !== false; twoPass.disabled = linked(node, 'two_pass') || !widget(node, 'two_pass'); @@ -360,6 +367,8 @@ export function openStudio(node) { syncSamplingSteps(); advanced.append(samplingFields, samplingWarning); function showEffortEstimate(steps = Number(baseSteps.value), twoPassPlan = twoPass.checked) { + const rangeEstimate = capabilityView?.estimate(steps, twoPassPlan, arguments.length ? 3 : Number(refineSteps.value)); + if (rangeEstimate) { effort.setEstimate(rangeEstimate); return; } const mode = twoPassPlan ? 'two_pass' : 'single'; const estimate = effortEstimate[mode]?.[steps]; // A previewed level always refines three steps; the current plan may not. @@ -377,6 +386,7 @@ export function openStudio(node) { '根据本机完整生成记录估算,包含加载与解码;首次准备或其他程序占用可能延长时间。')); } function refreshEffortEstimate(force = false) { + capabilityView?.update(); let task = 'external'; try { if (!linked(node, 'conditioning')) { @@ -785,8 +795,7 @@ export function openStudio(node) { } function applySize() { if (dimensionsLinked) return; - const total = pixels * 1024 ** 2; - const w = Math.round(Math.sqrt(total * ratio) / 32) * 32, h = Math.round(Math.sqrt(total / ratio) / 32) * 32; + const [w, h] = canvasDimensions(pixels, ratio); if (w < 256 || h < 256 || w > 4096 || h > 4096) throw new Error(t('This aspect ratio and pixel count exceed the supported dimensions. Use custom dimensions.', '该比例与像素数超出尺寸范围,请使用自定义宽高。')); set(node, 'width', w); set(node, 'height', h); node.properties.freevideo_aspect = selected; node.properties.freevideo_pixels = pixels; updateCanvas(); } @@ -805,7 +814,7 @@ export function openStudio(node) { b.dataset.ratio = id; b.setAttribute('aria-label', label); b.disabled = dimensionsLinked; b.append(el('span', null, 'fv-shape-icon'), el('span', label)); shapes.append(b); } - mp.onchange = () => { try { pixels = Number(mp.value); applySize(); } catch (error) { status.dataset.error = 'true'; status.textContent = error.message; } }; + mp.onchange = () => { try { pixels = Number(mp.value); applySize(); if (pixels !== 1) legacyOption?.remove(); } catch (error) { status.dataset.error = 'true'; status.textContent = error.message; } }; seconds.onchange = () => { if (seconds.value && seconds.reportValidity()) { set(node, 'seconds', Number(seconds.value)); updateCanvas(); } }; const mediaChanged = async e => { if (String(e.detail) !== String(mediaNode?.id)) return; refreshEffortEstimate(); if (selected !== 'input') return; try { await inputRatio(); } catch (error) { status.textContent = error.message; } }; const lorasChanged = e => { if (String(e.detail) === String(loraNode?.id)) refreshEffortEstimate(); }; @@ -858,6 +867,16 @@ export function openStudio(node) { const escape = e => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); generate.click(); } }; dialog.addEventListener('keydown', escape); dialog.addEventListener('cancel', e => { e.preventDefault(); closeDialog(dialog); }); dialog.onclose = () => { disposed = true; for (const f of cleanup) f(); stage.querySelector('video')?.pause(); dialog.remove(); if (current?.dialog === dialog) { current = null; viewChanged('nodes', node); } }; + capabilityView = createCapabilityView({node, api, t, getState: () => ({ratio, + custom: selected === 'custom', steps: Number(baseSteps.value), twoPass: twoPass.checked, + refine: Number(refineSteps.value), disabled: dimensionsLinked || seconds.disabled}), onEstimate: () => showEffortEstimate(), onPick: point => { + pixels = point.pixels; mp.value = String(pixels); + set(node, 'width', point.width); set(node, 'height', point.height); + node.properties.freevideo_pixels = pixels; + seconds.value = point.seconds; set(node, 'seconds', point.seconds); updateCanvas(); + }}); + rangeMount.append(capabilityView.element); cleanup.push(capabilityView.dispose); + controls.addEventListener('change', () => capabilityView.update()); document.body.append(dialog); dialog.showModal(); viewChanged('studio', node); updateCanvas(); const recentLoad = recent.refresh(); if (node.freevideoProgress) showProgress(node.freevideoProgress); diff --git a/web/studio_size.js b/web/studio_size.js new file mode 100644 index 00000000..b2cae24c --- /dev/null +++ b/web/studio_size.js @@ -0,0 +1,17 @@ +export function canvasDimensions(pixels, ratio) { + const total = pixels * 1024 ** 2; + return [Math.round(Math.sqrt(total * ratio) / 32) * 32, Math.round(Math.sqrt(total / ratio) / 32) * 32]; +} + +export function imageSizeSelection(pixels, ratio, width, height) { + const options = [.5, .75, .98, 1.5, 2]; + const matches = target => { + const [w, h] = canvasDimensions(target, ratio); + return w === width && h === height; + }; + const legacy = ratio > 0 && (pixels === 1 || pixels === width * height / 1024 ** 2) && matches(1) && !matches(.98); + if (legacy) options.splice(3, 0, 1); + const nearest = legacy ? 1 : options.find(n => Math.abs(n - pixels) < .03); + if (nearest === undefined) options.push(pixels); + return {options, value: nearest ?? pixels}; +} From 530e6bbb5a1bd1937659fcc6f54b007c0cd5453d Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Mon, 12 Oct 2026 07:55:32 +0000 Subject: [PATCH 2/3] Capability map: recognise every saved int8 and fp8 model format --- freevideo_engine/capabilities.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/freevideo_engine/capabilities.py b/freevideo_engine/capabilities.py index 6d256f68..ff295ea6 100644 --- a/freevideo_engine/capabilities.py +++ b/freevideo_engine/capabilities.py @@ -196,8 +196,10 @@ def _machine(machine, in_use=None): elif (not isinstance(cap, (list, tuple)) or len(cap) != 2 or any(type(n) is not int for n in cap) or cap[0] < 8 or not 0 <= cap[1] <= 9): return None - precision = machine.get('prepared_format', 'int8') - if precision not in ('int8', 'fp8'): + # Setup records the prepared variant (for example 'int8_convrot'); planning takes its family. + saved = machine.get('prepared_format', 'int8') + precision = next((p for p in ('int8', 'fp8') if isinstance(saved, str) and saved.startswith(p)), None) + if precision is None: return None # Only use an already saved Metal observation, never create a Metal device. metal = observed.get('metal_recommended_working_set_bytes') From 593c0c16eabaf42b824ec100226b77b9c39d36e8 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Mon, 12 Oct 2026 08:06:58 +0000 Subject: [PATCH 3/3] README: platform names in the top links and a link to community results --- README.md | 2 +- README.zh-CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bfdc6f6a..591b0d89 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

-| Download for Windows | Download for macOS | Download for Linux | Gallery | Discord | QQ Group | WeChat Group | +| Windows | macOS | Linux | Community Results | Gallery | Discord | QQ Group | WeChat Group |

English · 中文

diff --git a/README.zh-CN.md b/README.zh-CN.md index 6c547eb7..c8daf3c7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,7 +6,7 @@

-| Windows 下载 | macOS 下载 | Linux 下载 | 作品展示 | Discord | QQ 群 | 微信群 | +| Windows | macOS | Linux | 社区实测 | 作品展示 | Discord | QQ 群 | 微信群 |

English · 中文