diff --git a/.gitattributes b/.gitattributes index 534c5b76..ffd576fd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,3 +6,4 @@ *.md text eol=lf *.json text eol=lf *.yml text eol=lf +constraints/*.txt text eol=lf diff --git a/freevideo_engine/adaln.py b/freevideo_engine/adaln.py index 33e99540..cdd73ab2 100644 --- a/freevideo_engine/adaln.py +++ b/freevideo_engine/adaln.py @@ -58,7 +58,7 @@ def forward(self, temb): class TableCache: """Persist fixed modulation constants by weights, schedule and layout.""" def __init__(self, root, source_id, steps, task='t2va', *, manifest=None, - timesteps=None, channels=5376, device='cuda'): + timesteps=None, channels=5376, device='cuda', fetch=True): from . import adaln_assets as assets from .monitoring import save times = schedule_timesteps(steps, device='cpu', task=task) if timesteps is None else timesteps @@ -72,6 +72,8 @@ def __init__(self, root, source_id, steps, task='t2va', *, manifest=None, self.optional_loaded = set() self.optional_downloaded = set() self.optional_download_bytes = 0 + self.fetch = fetch + self.placeholders = set() self.root = Path(root) / assets.directory(self.identity) self.producer = None self.shared = None @@ -120,6 +122,10 @@ def __init__(self, root, source_id, steps, task='t2va', *, manifest=None, self.producer = dict(old, migrated_from='verified-legacy-contract-v1') self.asset = dict(identity=self.identity, directory=candidate.name, files=files, producer=self.producer) return + if not fetch: + # Compiling ahead of a request reads the tables on disk and writes + # nothing; a table that is not there is not fetched (see load). + return self.root.mkdir(parents=True, exist_ok=True) marker = self.root / 'identity.json' if marker.exists(): @@ -144,6 +150,8 @@ def load(self, index, steps): if row is None: raise ValueError('Incomplete AdaLN model asset') elif not path.is_file() or not marker.is_file(): + if not self.fetch: + return self.placeholder(index) if self.optional_asset is None: return None path.parent.mkdir(parents=True, exist_ok=True) @@ -165,6 +173,19 @@ def load(self, index, steps): raise ValueError('Nonfinite AdaLN model asset') return [state[f'step_{i}'].chunk(6, dim=-1) for i in range(steps)] + def placeholder(self, index): + """Zeros in a missing table's shape and dtype, for compiling only. + + The fused block kernels compile for these shapes; no value reaches one. + A request installs or computes the real table before it samples. + """ + if self.identity['dtype'] != 'BF16': + raise ValueError('Unsupported AdaLN table dtype') + self.placeholders.add(index) + width = self.identity['channels'] * self.identity['components'] + return [torch.zeros(len(times) * self.identity['modality_rows'], width, dtype=torch.bfloat16, + device=self.device).chunk(6, dim=-1) for times in self.identity['timesteps']] + def save(self, index, module, *, producer_device=None): from . import adaln_assets as assets from .monitoring import save diff --git a/freevideo_engine/background_compile.py b/freevideo_engine/background_compile.py new file mode 100644 index 00000000..c61430f0 --- /dev/null +++ b/freevideo_engine/background_compile.py @@ -0,0 +1,153 @@ +"""Compile recent request shapes while an interactive session is idle. + +The first request after an update, a repair, a new installation or a GPU +driver update compiles its kernels; on a 12 GB Windows laptop that cost 50 s of +a 564 s request. Nobody waits for this compile here. Once ComfyUI has had no +work for START_DELAY seconds, precompile runs at the lowest CPU and disk +priority. A queued prompt stops it within POLL seconds, and a generation, a +prompt rewrite or a calibration stops it before taking the GPU. Kernels +compiled by then stay in the caches, and the request compiles the rest as it +always did. + +It runs only before the session's first generation. After that, the resident +encoder holds GPU memory that a request would release, so the plan would no +longer be the request's. An interrupted run writes no receipt. When the run +was stopped or found no room (background_room), the session waits twice as +long before the next try, up to MAX_DELAY; the next start tries again too. +""" +import logging +from pathlib import Path +import subprocess +import threading +import time + +from . import processes + +START_DELAY = 30. +MAX_DELAY = 600. +POLL = .25 +# Longer than the Linux supervisor takes to end every descendant (4.5 s): +# killing it first would orphan a worker in its own session. A tree that +# exits sooner returns as soon as it does. +STOP_GRACE = 6. +# Windows ends a stopped tree asynchronously: CUDA memory and the lease can +# outlive the top process by seconds. +RELEASE_SECONDS = 15. +ATTEMPTS = 8 + + +class BackgroundCompile: + def __init__(self): + self.guard = threading.Lock() + self.closed = threading.Event() + self.process = None + self.thread = None + self.lock = None + self.attempts = [] + + def start(self, command, *, environment, cwd, log, lock, busy, delay=START_DELAY): + """Run `command` once the session is quiet; False when this session already did. + + `lock` is the engine lease the compile holds while it runs. + """ + with self.guard: + if self.thread is not None or self.closed.is_set(): + return False + self.lock = Path(lock) + self.thread = threading.Thread(target=self._work, args=(command, environment, cwd, Path(log), busy, delay), + name='FreeVideo background compile', daemon=True) + self.thread.start() + return True + + def stop(self, *, final=False): + """Stop a running compile and return once its processes, GPU memory and lease are gone. + + `final` when a generation starts: the session compiles nothing more. + """ + if final: + self.closed.set() + tick = time.monotonic() # One deadline from here, a concurrent stop included. + with self.guard: + process, self.process = self.process, None + if process is None: + return None + row = self.attempts[-1] + if process.poll() is None: + # Marked before the signal: the worker thread then retries + # instead of reading the exit as a finished run. + row['stopped'] = True + processes.stop(process, grace=STOP_GRACE) + row['stop_seconds'] = time.monotonic() - tick + # Every process of the tree holds the lease; once it is free the + # tree is gone, even when the top process had already exited. On a + # timeout the request reports the busy lease. One deadline for all. + row['released'] = self._released(tick + RELEASE_SECONDS) + row['release_seconds'] = time.monotonic() - tick + if not row.get('stopped'): + return None + logging.info('[FreeVideo] Background compile stopped in %.1f s; the GPU lease is %s.', + row['release_seconds'], 'free' if row['released'] else 'still held') + return dict(row) + + def _released(self, deadline): + from .locking import runtime_lock + while True: + try: + with runtime_lock(self.lock, inherit=False): + return True + except BlockingIOError: + if time.monotonic() >= deadline: + return False + time.sleep(.1) + + def _quiet(self, busy, delay): + """True after `delay` seconds without work; False once closed.""" + since = time.monotonic() + while time.monotonic() - since < delay: + if self.closed.wait(1.): + return False + if busy(): + since = time.monotonic() + return True + + def _work(self, command, environment, cwd, log, busy, delay): + for attempt in range(ATTEMPTS): + if attempt: + delay = min(2 * delay, MAX_DELAY) + if not self._quiet(busy, delay): + return + with self.guard: + if self.closed.is_set(): + return + logging.info('[FreeVideo] Compiling GPU kernels for recent video sizes in the background; ' + 'a generation request stops it.') + row = dict(started=time.time()) + self.attempts.append(row) + with 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) + while process.poll() is None: + if busy(): + self.stop() + break + try: + process.wait(timeout=POLL) + except subprocess.TimeoutExpired: + pass + with self.guard: + # A stop in progress holds the guard and has marked the row. + stopped = row.get('stopped') + if not stopped and self.process is process: + # Ended by itself: confirm its tree let go before forgetting + # it, so a later stop never waits on someone else's lease. + row['released'] = self._released(time.monotonic() + RELEASE_SECONDS) + if row['released']: + self.process = None + row.update(returncode=process.poll(), seconds=time.time() - row['started']) + from .precompile import BUSY_EXIT + if not stopped and row['returncode'] != BUSY_EXIT: + return # Compiled, or failed with a receipt that says why. + + +COMPILE = BackgroundCompile() diff --git a/freevideo_engine/background_room.py b/freevideo_engine/background_room.py new file mode 100644 index 00000000..d696e900 --- /dev/null +++ b/freevideo_engine/background_room.py @@ -0,0 +1,310 @@ +"""Whether the user's other programs leave room for a background compile. + +The compile loads the model as a request would, so it needs the host memory a +request needs and the whole GPU. It starts only when the system has a recent +request's host peak plus RAM_MARGIN available, the GPU has been nearly idle +and other programs hold little of its memory. While it runs it gives way when +availability falls under RAM_FLOOR, or when another program starts using the +GPU, grows its memory there or keeps its cores busy. Measured on the machine, +never assumed: NVML for the GPU and the engine's own RAM accounting for the host. + +Our own plan fills the GPU and keeps it busy, so while compiling only +per-process readings can tell another program apart. Windows often reports +no per-process memory, and a game in a browser that was already open adds no +process; per-process utilization covers both where the driver reports it. +Our processes are this one, its descendants (workers, kernel checks, their +interpreters behind a venv launcher, compiler processes) and the +interactive owner; never a process merely running the same Python. +""" +import collections +import ctypes +import os +from pathlib import Path +import sys +import time + +GiB = 2**30 +RAM_MARGIN = 4 * GiB +RAM_FLOOR = 2 * GiB +# A request's host peak when this machine has measured none: the private +# working set of a 1344x768 request on a 12 GB Windows laptop was 4.0 GiB. +DEFAULT_RAM_NEED = 8 * GiB +GPU_IDLE_PERCENT = 10 +GPU_SAMPLES = 5 +GPU_OTHER_SHARE = .2 +GPU_OTHER_MINIMUM = 2 * GiB +GPU_GROWTH = GiB +# Another program's share of the GPU's cores that makes the compile give way. +OTHER_BUSY_PERCENT = 20 +NOT_FOUND = 6 +# Consecutive checks a new program must stay on the GPU before the compile +# gives way; a desktop process touching the GPU once does not count. +NEW_PROCESS_CHECKS = 3 +# Busy samples further apart than this start a new count: sparse moments of +# work are not a program keeping the GPU busy. +BUSY_GAP_MICROSECONDS = 3_000_000 +# Checks in a row without a process list before the compile gives way: it +# could no longer tell another program from its own work. +UNLISTED_CHECKS = 10 +# Other programs' per-process samples kept for precompile.log. A busy browser +# tab on Windows took about 14 s to make the compile give way; these show how +# often Windows reports such a process and how busy, before the rule changes. +TRACE_SAMPLES = 240 +CHECK_SECONDS = 1. +NOT_AVAILABLE = 0xFFFFFFFFFFFFFFFF +INSUFFICIENT_SIZE = 7 + + +class ProcessInfo(ctypes.Structure): + _fields_ = [('pid', ctypes.c_uint), ('used', ctypes.c_ulonglong), + ('gpu_instance', ctypes.c_uint), ('compute_instance', ctypes.c_uint)] + + +class UtilizationSample(ctypes.Structure): + _fields_ = [('pid', ctypes.c_uint), ('timestamp', ctypes.c_ulonglong), ('sm', ctypes.c_uint), + ('memory', ctypes.c_uint), ('encoder', ctypes.c_uint), ('decoder', ctypes.c_uint)] + + +class Gpu: + """NVML readings of the whole device, including other programs.""" + def __init__(self, device): + from .monitoring import nvml_device + self.lib, self.handle, self.device = nvml_device(device) + + def utilization(self): + from .monitoring import Utilization + value = Utilization() + return value.gpu if self.lib.nvmlDeviceGetUtilizationRates(self.handle, ctypes.byref(value)) == 0 else None + + def memory(self): + from .monitoring import Memory + value = Memory() + if self.lib.nvmlDeviceGetMemoryInfo(self.handle, ctypes.byref(value)): + return None + return value.used, value.total + + def processes(self): + """{pid: bytes or None} of compute and graphics processes; None when NVML cannot list them.""" + found = {} + for name in ('nvmlDeviceGetComputeRunningProcesses', 'nvmlDeviceGetGraphicsRunningProcesses'): + function = next((getattr(self.lib, name + suffix) for suffix in ('_v3', '_v2') + if hasattr(self.lib, name + suffix)), None) + if function is None: + return None + count = ctypes.c_uint(0) + rc = function(self.handle, ctypes.byref(count), None) + for _ in range(3): + # The list can grow between asking its size and reading it. + if rc != INSUFFICIENT_SIZE: + break + count = ctypes.c_uint(count.value + 16) + rows = (ProcessInfo * count.value)() + rc = function(self.handle, ctypes.byref(count), rows) + if rc == 0: + for row in rows[:count.value]: + used = None if row.used == NOT_AVAILABLE else row.used + found[row.pid] = used if found.get(row.pid) is None else found[row.pid] + if rc: + return None + return found + + def busy(self, since): + """({pid: (highest % of cores, encoder or decoder, its newest sample's stamp)}, newest stamp) + for samples after `since`, in microseconds; ({}, since) without new ones; None if unsupported.""" + 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(since)) + if rc == NOT_FOUND: + return {}, since + 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(since)) + if rc == NOT_FOUND: + return {}, since + if rc: + return None + found, newest = {}, since + for row in rows[:count.value]: + values = [value for value in (row.sm, row.encoder, row.decoder) if value <= 100] + if values: + value, stamp = found.get(row.pid, (0, 0)) + found[row.pid] = (max(value, *values), max(stamp, row.timestamp)) + newest = max(newest, row.timestamp) + return found, newest + + def close(self): + lib, self.lib = self.lib, None + if lib is not None: + lib.nvmlShutdown() + + +def parents(): + """{pid: parent pid} of every process on the machine.""" + if sys.platform == 'win32': + from .win32 import processes + return processes() + found = {} + for path in Path('/proc').iterdir(): + if path.name.isdigit(): + try: + found[int(path.name)] = int((path / 'stat').read_text(encoding='utf-8').rsplit(')', 1)[1].split()[1]) + except (OSError, ValueError, IndexError): + continue + return found + + +def family(root): + """`root` and every process descended from it; OSError when processes cannot be listed.""" + table = parents() + pids, frontier = {root}, {root} + while frontier: + frontier = {pid for pid, parent in table.items() if parent in frontier} - pids + pids |= frontier + return pids + + +def available_memory(sample=None): + """Host memory left for the compile: the request's accounting, within any cgroup limit.""" + from .hardware import cgroup_memory + from .system import effective_available, inference_headroom + available = inference_headroom(sample) if sample is not None else effective_available() + group = cgroup_memory() + # Clean page cache inside the group is reclaimed before the limit is hit. + group = group.get('reclaimable_available_bytes', group.get('available_bytes')) + return min(available, group) if group is not None else available + + +def recent_need(history): + """The largest host peak of this machine's recent requests, or DEFAULT_RAM_NEED.""" + try: + peaks = [row['observation'].get('ram_peak_bytes') for row in history.observations(limit=16, include_details=False)] + except Exception: + peaks = [] + peaks = [peak for peak in peaks if type(peak) is int and peak > 0] + return max(peaks) if peaks else DEFAULT_RAM_NEED + + +class Room: + def __init__(self, device, *, need_bytes, owner=None, gpu=None): + self.need = need_bytes + self.owner = owner + self.ours = set() + self.gpu = gpu + if self.gpu is None and device: + try: + self.gpu = Gpu(device) + except Exception: + self.gpu = None # Without NVML only memory is checked. + self.baseline = None + self.baseline_memory = 0 + self.seen = {} + self.busy = {} + self.unlisted = 0 + self.checked = 0. + self.sampled = int(time.time() * 1e6) + self.started = self.sampled / 1e6 + self.trace = collections.deque(maxlen=TRACE_SAMPLES) + + def refresh(self): + """Our processes now: this one, its descendants and the owner. + + When the system cannot list processes, the last known ones stay ours: + a worker must never turn into another program for want of a list. + """ + try: + ours = family(os.getpid()) + except OSError: + ours = self.ours or {os.getpid()} + self.ours = ours | ({self.owner} if self.owner else set()) + + def own(self, pid): + return pid in self.ours + + def start_reason(self): + """Why the compile should not start now, or None; also records the GPU's current users.""" + available = available_memory() + if available < self.need + RAM_MARGIN: + return 'memory: %.1f GiB available, %.1f GiB needed' % (available / GiB, (self.need + RAM_MARGIN) / GiB) + if self.gpu is None: + return None + samples = [] + for index in range(GPU_SAMPLES): + if index: + time.sleep(.5) + value = self.gpu.utilization() + if value is not None: + samples.append(value) + if samples and sum(samples) / len(samples) > GPU_IDLE_PERCENT: + return 'gpu busy: %.0f%% utilization' % (sum(samples) / len(samples)) + listed = self.gpu.processes() + if listed is None: + # Without the list nothing could tell another program from ours later. + return 'gpu: its processes cannot be listed now' + self.refresh() + memory = self.gpu.memory() + if memory is not None: + used, total = memory + own = sum(value for pid, value in listed.items() if value is not None and self.own(pid)) + if used - own > max(GPU_OTHER_MINIMUM, GPU_OTHER_SHARE * total): + return 'gpu memory: %.1f GiB held by other programs' % ((used - own) / GiB) + self.baseline = {pid for pid in listed if not self.own(pid)} + self.baseline_memory = sum(value for pid, value in listed.items() if value is not None and not self.own(pid)) + self.sampled = int(time.time() * 1e6) + return None + + def run_reason(self, sample=None): + """Why a running compile should give way now, or None. Call it often; the GPU is read once a second.""" + available = available_memory(sample) + if available < RAM_FLOOR: + return 'memory: %.1f GiB available' % (available / GiB) + if self.gpu is None or self.baseline is None or time.monotonic() - self.checked < CHECK_SECONDS: + return None + self.checked = time.monotonic() + listed = self.gpu.processes() + self.refresh() + if listed is None: + self.unlisted += 1 + if self.unlisted >= UNLISTED_CHECKS: + return 'gpu: its processes cannot be listed' + else: + self.unlisted = 0 + # A program that left and comes back, or a reused PID, is new again. + self.baseline &= set(listed) + other = {pid: value for pid, value in listed.items() if not self.own(pid)} + self.seen = {pid: self.seen.get(pid, 0) + 1 for pid in other if pid not in self.baseline} + if any(count >= NEW_PROCESS_CHECKS for count in self.seen.values()): + return 'gpu: another program started using it' + grown = sum(value for value in other.values() if value is not None) - self.baseline_memory + if grown > GPU_GROWTH: + return 'gpu memory: other programs took %.1f GiB more' % (grown / GiB) + sampled = self.gpu.busy(self.sampled) + if sampled is not None and sampled[0]: + # Only new samples move the count; a quiet second keeps it, and + # busy samples more than BUSY_GAP apart count from one again. + cores, self.sampled = sampled + busy = {} + for pid, (value, stamp) in cores.items(): + if not self.own(pid): + self.trace.append((time.time() - self.started, pid, value, stamp)) + if value >= OTHER_BUSY_PERCENT and not self.own(pid): + count, last = self.busy.get(pid, (0, 0)) + busy[pid] = (count + 1 if stamp - last <= BUSY_GAP_MICROSECONDS else 1, stamp) + self.busy = busy + if any(count >= NEW_PROCESS_CHECKS for count, _ in self.busy.values()): + return 'gpu: another program keeps it busy' + return None + + def samples(self): + """Other programs' samples for the log: [seconds since this room started, + pid, % of cores, seconds after the first sample's own stamp].""" + first = self.trace[0][3] if self.trace else 0 + return [[round(seconds, 1), pid, value, round((stamp - first) / 1e6, 2)] + for seconds, pid, value, stamp in self.trace] + + def close(self): + if self.gpu is not None: + self.gpu.close() diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index 295c9136..a901a598 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -14,6 +14,7 @@ import subprocess import sys import tarfile +import tempfile import time import threading import urllib.request @@ -122,23 +123,49 @@ def setup_memory_failure(pressure): return detail + ' Downloaded files are retained.' -def reinstall_download_bytes(dependencies, system): - """Bytes setup downloads again to replace changed PyTorch files: the pinned +def reinstall_download_bytes(dependencies, system, *, root=None, frontend_python=None, disk_mode='normal'): + """Bytes setup downloads again to replace missing or changed files: the pinned Windows wheel (torch_download), elsewhere the package's installed size, an - upper bound for its wheel. Marks each environment's damaged packages.""" - if system == 'Darwin': - return 0 + upper bound for its wheel. Marks each engine environment's damaged packages.""" from .environments import ENVIRONMENTS - from .runtime_libraries import damaged, installed_bytes + from .runtime_libraries import damaged, incomplete, installed_bytes from .torch_download import select total = 0 - for name, row in dependencies.items(): + seen = set() + environments = [] if system == 'Darwin' else [(name, row, False) for name, row in dependencies.items()] + if frontend_python: + # comfy_host matches the engine Python's packages after engine repair. + name = 'unified' if 'unified' in dependencies else 'engine' + environments.append((name, dict(python=str(frontend_python), exists=Path(frontend_python).is_file()), True)) + for name, row, frontend in environments: if not row.get('exists'): continue row['damaged'] = broken = damaged(row['python']) + if frontend: + broken = sorted(set(broken + incomplete(row['python']))) pins = ENVIRONMENTS.get(name, {}) - wheels = {w['package']: w['bytes'] for w in (select(pins['torch'], pins['cuda']) or [])} if system == 'Windows' and pins else {} - total += sum(wheels.get(package) or installed_bytes(row['python'], package) for package in broken) + wheels = {w['package']: w for w in (select(pins['torch'], pins['cuda']) or [])} if system == 'Windows' and pins else {} + for package in broken: + wheel = wheels.get(package) + if wheel is None: + total += installed_bytes(row['python'], package) + continue + if frontend and disk_mode == 'extreme': + # Engine setup clears these wheels before the launcher repairs ComfyUI. + total += wheel['bytes'] + continue + key = (wheel['package'], wheel['version']) + if key in seen: + continue + seen.add(key) + if root is not None: + cached = Path(root) / 'downloads' / 'torch-wheels' / wheel['filename'] + try: + if cached.is_file() and cached.stat().st_size == wheel['bytes'] and digest(cached) == wheel['sha256']: + continue + except OSError: + pass + total += wheel['bytes'] return total @@ -392,9 +419,9 @@ def plan(args, *, local_progress=None): errors.append('Native Windows uses the unified environment. Rerun with --environment unified.') layout = 'unified' if windows_target and getattr(args, 'rebuild_sage', False): - errors.append('Windows uses the pinned, verified Sage2 wheel; --rebuild-sage is a Linux source-build option.') + errors.append('Windows uses the Sage2 wheel FreeVideo tests; --rebuild-sage is a Linux source-build option.') if int(snapshot['selected_gpu']['driver_version'].split('.')[0]) < 580: - errors.append('NVIDIA driver 580 or newer is required by the pinned CUDA 13 encoder; update the driver first.') + errors.append('NVIDIA driver 580 or newer is required by the CUDA 13 encoder FreeVideo tests; update the driver first.') compatibility = hardware.cuda_compatibility() if compatibility['error']: errors.append(compatibility['error']) @@ -506,9 +533,16 @@ def plan(args, *, local_progress=None): groups = {} present = 0 model_entries = [] + from . import model_repair + mismatches = model_repair.read(root) + replace_models = [] + reserved = set() for row in files: path = model_target(row, model_dir, encoder_dir, prepared_dir) - size_matches = path.is_file() and path.stat().st_size == row['bytes'] + replacement = model_repair.replacement(path, row, mismatches, reserved) + if replacement: + replace_models.append(replacement) + size_matches = not replacement and path.is_file() and path.stat().st_size == row['bytes'] present += row['bytes'] if size_matches else 0 device = existing_parent(path.parent) entry = groups.setdefault(str(device), {'path': str(device), 'needed_bytes': 0, @@ -525,7 +559,6 @@ def plan(args, *, local_progress=None): # GiB; allow 52 GiB including the largest group in progress. Never credit # future source deletion toward the space needed to complete conversion. dependencies = dependency_status(root, layout, system) - package_download_bytes = reinstall_download_bytes(dependencies, system) environments_ready = all(r['exists'] and not r['missing'] and not r['mismatched'] for r in dependencies.values()) python_version = None if mac_target else bootstrap_versions( json.loads((PACKAGE / 'bootstrap_versions.json').read_text(encoding='utf-8')), system)['python'] @@ -546,6 +579,11 @@ def plan(args, *, local_progress=None): frontend_gib=frontend_allowance_gib(root, frontend['root'], mac_target) if frontend else disk_space.frontend_gib(mac_target)) extra = disk_plan['environment_cache_safety_gib'] errors.extend(disk_plan['errors']) + # A repair also reinstalls what is damaged in the ComfyUI environment the launcher made. + frontend_python = getattr(args, 'frontend_repair_python', None) + package_download_bytes = reinstall_download_bytes(dependencies, system, root=root, + frontend_python=str(frontend_python.expanduser().absolute()) if frontend_python else None, + disk_mode=disk_plan['mode']) if mac_target: errors.extend(disk_space.problem_messages([root, model_dir, encoder_dir] + ([frontend['root']] if frontend else []))) if reuse_cache and not cache_compatible(reuse_cache, **cache_format): @@ -586,6 +624,7 @@ def plan(args, *, local_progress=None): 'model_downloader': getattr(args, 'model_downloader', 'auto'), 'allow_model_restart': getattr(args, 'allow_model_restart', False), 'local_models': local_reuse, + 'replace_models': sorted(replace_models, key=lambda row: row['path']), 'model_groups': model_inventory(model_entries), 'environment_layout': layout, 'environment_count': len(environment_names(layout)), @@ -620,10 +659,10 @@ def plan(args, *, local_progress=None): 'test_artifacts_estimate_gib': '5–8 GiB for the standard five-case suite; all outputs retained', 'first_generation_note': 'Portable AdaLN tables follow model weights and the exact schedule, across GPUs and Torch/CUDA versions. New schedules or modulation-changing LoRAs require preparation. Existing environments stay on disk when switching.', 'estimates_are_not_capacity_guarantees': True, 'errors': errors, - 'steps': ['Install isolated uv/Python and ' + ('one shared CUDA environment' if layout == 'unified' else 'two pinned CUDA environments'), - 'Clone pinned VDN, patched Diffusers and the native H3 text-encoder library', + 'steps': ['Install isolated uv/Python and ' + ('one shared CUDA environment' if layout == 'unified' else 'two CUDA environments'), + 'Clone the VDN, patched Diffusers and native H3 text-encoder library versions FreeVideo tests', 'Install verified Windows Triton / Sage2 wheels' if windows_target else 'Install a local CUDA compiler and build Sage2 for the detected GPU only', - 'Download and verify pinned base, eight-step checkpoint and encoder weights', + 'Download and verify the base, eight-step checkpoint and encoder weights FreeVideo tests', 'Prepare or verify the architecture-compatible FP8 cache', 'Execute small attention/linear kernel probes and save machine configuration'], 'licenses': ['https://huggingface.co/OpenVDN/vdn-minimax-h3/blob/751739ee5b9e3ac802dca5d5111075fdaeb47885/LICENSE', @@ -634,6 +673,8 @@ def plan(args, *, local_progress=None): describe_plan(value) else: value['cuda_compatibility'] = compatibility + value['replace_models_text'] = {language: model_repair.review_lines(value['replace_models'], zh=language == 'zh') + for language in ('en', 'zh')} return value @@ -660,6 +701,9 @@ def display(value, ui=None, *, verbose=False): if value['model_download_bytes'] else 'No new model files expected · verify existing files')) if value.get('package_download_bytes'): rows.append(('Package download', '~%.1f GiB to replace changed PyTorch files' % (value['package_download_bytes']/GiB))) + from .model_repair import review_lines + for line in review_lines(value.get('replace_models', [])): + rows.extend((('Model repair', line['text']), ('Model repair path', line['path']))) if value.get('disk_mode') == 'extreme': rows.append(('Disk mode', 'Automatic space saver · sequential downloads · temporary package cache removed before models')) if value.get('local_models'): @@ -735,6 +779,9 @@ def display_details(value, ui=None): rows.append(('Installation RAM budget', '%.2f GiB · system growth reserve %.2f GiB' % (resources['ram_budget_bytes']/GiB, resources['ram_system_reserve_bytes']/GiB))) rows.append(('Install root', value['root'])) + from .model_repair import review_lines + for line in review_lines(value.get('replace_models', [])): + rows.extend((('Model repair', line['text']), ('Model repair path', line['path']))) rows.append(('Environment', value['environment_layout'] + ' · text encoder exits before video models load')) rows.append(('Kernel installation', value.get('kernel_install', 'Local Sage2 build'))) rows.append(('Git', value.get('git_install', 'Use detected Git'))) @@ -764,7 +811,7 @@ def display_details(value, ui=None): rows.append(('Source weights', value.get('storage_cleanup', 'Retain source weights'))) if value['reuse_cache']: rows.append(('Prepared cache', 'Reuse FP8 · skip original BF16/LoRA downloads and scans')) - rows.append(('Verification', value['verification'] + ' · auto reuses unchanged pinned receipts; full rereads tensors')) + rows.append(('Verification', value['verification'] + ' · auto reuses unchanged model receipts; full rereads tensors')) if value['build']: rows.append(('Sage2 build', 'Up to %d compiler jobs · ~%.1f GiB RAM estimate · reusable ABI/GPU wheel cache' % (value['build']['jobs'], value['build']['estimated_peak_bytes']/GiB))) @@ -788,8 +835,13 @@ def confirmed(args, value, ask=input, ui=None): if not args.yes or not args.accept_model_license: raise ValueError('--approved-plan requires explicit plan/license acceptance.') reviewed = json.loads(Path(reviewed_path).read_text(encoding='utf-8')) + from . import model_repair + model_repair.reuse_names(value.get('replace_models', []), reviewed.get('replace_models', [])) + value['replace_models_text'] = {language: model_repair.review_lines(value.get('replace_models', []), zh=language == 'zh') + for language in ('en', 'zh')} keys = ('root', 'engine_version', 'environment_layout', 'model_dir', 'encoder_dir', 'vram_gib', 'ram_gib', 'reuse_cache', 'storage', 'storage_cleanup', 'model_downloader', 'prepared_model', 'prepared_format', 'model_source', 'disk_mode', 'frontend', 'sampling_caches') changed = any(reviewed.get(key) != value.get(key) for key in keys) + changed |= reviewed.get('replace_models', []) != value.get('replace_models', []) changed |= reviewed.get('device_backend') != value.get('device_backend') if value.get('device_backend') == 'mps': changed |= reviewed.get('inventory', {}).get('selected_device') != value['inventory']['selected_device'] @@ -855,6 +907,43 @@ def unpack(archive, destination): tar.extractall(destination) +def unpack_folder(archive, destination, name): + """Unpack the archive's top-level folder `name` to destination/name, replacing what is there. + + A repair can find destination/name, or a file in it, replaced by a link to + elsewhere. Checked in place, the archive's members would resolve through + that link and be refused, and extracting would write through it. Unpack into + a new folder instead, then replace only the link or folder at destination/name, + never what a link points to. + """ + destination.mkdir(parents=True, exist_ok=True) + staging = Path(tempfile.mkdtemp(prefix='.unpack-', dir=destination)) + present = lambda path: path.is_symlink() or path.exists() + keep = False + try: + unpack(archive, staging) + unpacked = staging / name + if unpacked.is_symlink() or not unpacked.is_dir(): + raise ValueError('Archive has no folder ' + name) + target, previous = destination / name, staging / (name + '.previous') + try: + # Renaming a link moves the link itself, never what it points to. + if present(target): + os.replace(target, previous) + os.replace(unpacked, target) + except BaseException: + # A failed or interrupted repair puts the previous uv back. + if present(previous) and not present(target): + try: + os.replace(previous, target) + except OSError: + keep = True # Rather leave it in the staging folder than delete it. + raise + finally: + if not keep: + shutil.rmtree(staging, ignore_errors=True) + + def setup_task(label): """Describe the work independently of internal command IDs and log paths.""" routes = {'official': 'official source', 'nju': 'Nanjing University mirror', @@ -1667,7 +1756,7 @@ def prepare_tools(self): except OSError: shutil.copyfile(previous, archive) self.fetch(uv_spec['url'], archive, uv_spec['sha256']) - unpack(archive, self.root / 'tools') + unpack_folder(archive, self.root / 'tools', 'uv-x86_64-unknown-linux-gnu') uv = self.root / 'tools' / 'uv-x86_64-unknown-linux-gnu' / 'uv' return uv @@ -1841,20 +1930,21 @@ def main(argv=None): help='With --prepared-format: download and verify that model beside the installed one, which stays ' 'in use. No other setup step runs and machine.json is unchanged') parser.add_argument('--model-source', choices=('prepared', 'source'), default='prepared', - help='Default: download a pinned slim model matching the GPU format. source: explicitly download original weights and convert locally') + help='Default: download a slim model FreeVideo tests matching the GPU format. source: explicitly download original weights and convert locally') parser.add_argument('--encoder-models', type=Path, help='Directory containing text_encoders/') - parser.add_argument('--reuse-models', type=Path, help='Scan an existing model library, verify pinned content and import matching files; only missing models are downloaded') + parser.add_argument('--reuse-models', type=Path, help='Scan an existing model library, verify content against the version FreeVideo tests and import matching files; only missing models are downloaded') parser.add_argument('--reuse-models-manifest', type=Path, help='Versioned JSON containing model library roots, including ComfyUI extra model paths') parser.add_argument('--copy-existing-models', action='store_true', help='Copy verified local models instead of using same-disk hardlinks; source files are kept') parser.add_argument('--allow-model-restart', action='store_true', help='Permit a fresh download when an incomplete model cannot resume; retains old data and may use extra download/disk space') cache_options = parser.add_mutually_exclusive_group() cache_options.add_argument('--cache', type=Path, help='Verify and reuse an existing FP8 cache') - cache_options.add_argument('--rebuild-cache', action='store_true', help='Recreate FP8 from pinned source weights, downloading missing sources; preserve failed caches') + cache_options.add_argument('--rebuild-cache', action='store_true', help='Recreate FP8 from the source weights FreeVideo tests, downloading missing sources; preserve failed caches') parser.add_argument('--storage', choices=('compact', 'retain'), help='Default compact: stream FP8 and remove installer-owned conversion sources after verification; retain keeps sources for re-quantization') parser.add_argument('--frontend-root', type=Path, help=argparse.SUPPRESS) parser.add_argument('--frontend-separate', action='store_true', help=argparse.SUPPRESS) parser.add_argument('--frontend-download', action='store_true', help=argparse.SUPPRESS) - parser.add_argument('--verify', choices=('auto', 'full'), default='auto', help='auto reuses unchanged pinned receipts; full rereads every required tensor') + parser.add_argument('--frontend-repair-python', type=Path, help=argparse.SUPPRESS) + parser.add_argument('--verify', choices=('auto', 'full'), default='auto', help='auto reuses unchanged model receipts; full rereads every required tensor') parser.add_argument('--wheel-cache', type=Path, help='Reusable local Sage2 wheel cache, keyed by architecture and ABI') parser.add_argument('--rebuild-sage', action='store_true', help='Measure a fresh source build, retaining previous build artifacts') parser.add_argument('--vram-gib', type=float, help='Save an explicit nominal VRAM cap; default uses detected resources') diff --git a/freevideo_engine/comfy_assets.py b/freevideo_engine/comfy_assets.py index 9a4213a3..c8a7a652 100644 --- a/freevideo_engine/comfy_assets.py +++ b/freevideo_engine/comfy_assets.py @@ -1,8 +1,12 @@ """File-backed ComfyUI media bundles; no tensor or model imports.""" import json import math +import os from pathlib import Path, PurePosixPath, PureWindowsPath +from . import launcher_bridge from .monitoring import thermal_slowdown +from .paths import data_root +from .support_report import encoder_sizes def saved_video(output, output_directory): @@ -147,6 +151,21 @@ def output_summary(report, relative_video): video=relative_video.as_posix(), report=relative_video.with_suffix( '.debug.json' if report.get('diagnostic_file') == relative_video.with_suffix('.debug.json').name else '.request.json').as_posix()) + checkpoint = encoding.get('checkpoint') or {} + sizes = checkpoint.get('bytes'), checkpoint.get('expected_bytes') + # Reports are read back from disk; a malformed one gets no notice rather than failing the library. + if checkpoint.get('tested_size') is False and all( + isinstance(value, int) and not isinstance(value, bool) and value >= 0 for value in sizes): + try: + # Match bootstrap.offline_installation. + offline_installation = (data_root().parent / 'portable.json').is_file() + except OSError: + offline_installation = False + result['encoder_file'] = dict(bytes=checkpoint['bytes'], expected_bytes=checkpoint['expected_bytes'], + sizes=list(encoder_sizes(checkpoint['bytes'], checkpoint['expected_bytes'])), + offline_installation=offline_installation, launcher=bool(os.environ.get(launcher_bridge.ENV))) + if offline_installation and isinstance(encoding.get('encoder'), str): + result['encoder_file']['path'] = encoding['encoder'] if report.get('preview'): result['preview_geometry'] = report['preview'].get('geometry') if report.get('device_backend') == 'mps': diff --git a/freevideo_engine/comfy_host.py b/freevideo_engine/comfy_host.py index d2431176..69f7e7aa 100644 --- a/freevideo_engine/comfy_host.py +++ b/freevideo_engine/comfy_host.py @@ -160,15 +160,25 @@ def git_run(label, command, env): env_root = previous_environment(root, identity) or env_root python = env_root / ('Scripts/python.exe' if os.name == 'nt' else 'bin/python') receipt = env_root / 'freevideo-host.json' + repair_marker = env_root / 'freevideo-host-repair.json' + repair = repair or repair_marker.exists() # The receipt records the dependency contract, not that every package is - # still there: a repair installs and checks the packages again. uv only - # verifies what is already installed. - if repair or not (python.is_file() and receipt.is_file() and json.loads(receipt.read_text(encoding='utf-8')) == identity): + # still there: uv only checks versions, so repair checks the files too. + try: + recorded = python.is_file() and json.loads(receipt.read_text(encoding='utf-8')) == identity + except (OSError, ValueError): + recorded = False + if repair or not recorded: + if repair and not repair_marker.exists(): + save(repair_marker, dict(started=time.time())) uv = root / 'tools' / ('uv.exe' if os.name == 'nt' else 'uv' if sys.platform == 'darwin' else 'uv-x86_64-unknown-linux-gnu/uv') if not uv.is_file(): raise ValueError('The engine download tool is missing. Run engine repair first.') if not python.is_file(): - execute([uv, 'venv', '--python', machine['python'], env_root], env, 'Create ComfyUI Python environment') + create = [uv, 'venv', '--python', machine['python'], env_root] + if repair: + create.insert(2, '--allow-existing') + execute(create, env, 'Create ComfyUI Python environment') # Match the already validated CUDA packages. Hardlinked uv cache # entries share their disk bytes without sharing a writable env. pins = directory / ('native-constraints.txt' if sys.platform == 'darwin' else 'cuda-constraints.txt') @@ -176,6 +186,28 @@ def git_run(label, command, env): pins.write_text('\n'.join(rows) + '\n', encoding='utf-8') torch_version = importlib.metadata.version('torch') cuda = torch_version.split('+', 1)[1] if '+' in torch_version else 'cpu' + from .runtime_libraries import damaged, dependency_closure, incomplete, normalize_name, reinstall_arguments + reported = set() + reported_unknown = False + def broken_packages(): + nonlocal reported_unknown + required = dependency_closure(python, requirements, rows) + found = set(incomplete(python) + damaged(python)) + if required is None: + if not reported_unknown: + print('Could not determine all ComfyUI dependencies from requirements; no damaged packages will be excluded.', flush=True) + reported_unknown = True + return sorted(found) + outside = {name for name in found if normalize_name(name) not in required} + if outside - reported: + print("Not part of ComfyUI's requirements, left as is: " + ', '.join(sorted(outside - reported)), flush=True) + reported.update(outside) + return sorted(found - outside) + broken = broken_packages() + gpu = [name for name in broken if normalize_name(name) in + ('torch', 'torchvision', 'torchaudio', 'triton', 'triton-windows') + or normalize_name(name).startswith('nvidia-')] + packages = [name for name in broken if name not in gpu] ui.phase('Prepare ComfyUI GPU packages', stage_offset + 1, stage_total) def package_install(family, command, label): try: @@ -193,18 +225,23 @@ def check(): from .install_disk import check_floor check_floor((root, comfy)) prepared = install(uv, python, rows, cuda, root=root, networking=plan, env=env, ui=ui, - run=lambda args, environment: execute(args, environment, 'Prepare ComfyUI GPU packages'), check=check) + run=lambda args, environment: execute(args, environment, 'Prepare ComfyUI GPU packages'), + check=check, reinstall=gpu) if not prepared: package_install('pypi' if sys.platform == 'darwin' else 'torch-' + cuda, - lambda environment, _: execute([uv, 'pip', 'install', '--python', python, *rows], environment, 'Prepare ComfyUI GPU packages'), + lambda environment, _: execute([uv, 'pip', 'install', '--python', python, *reinstall_arguments(gpu), *rows], environment, 'Prepare ComfyUI GPU packages'), 'Prepare ComfyUI GPU packages') ui.phase('Install ComfyUI packages', stage_offset + 2, stage_total) package_install('pypi', lambda environment, _: execute( - [uv, 'pip', 'install', '--python', python, '-r', requirements, '-c', pins], environment, 'Install ComfyUI packages'), + [uv, 'pip', 'install', '--python', python, *reinstall_arguments(packages), '-r', requirements, '-c', pins], environment, 'Install ComfyUI packages'), 'Install ComfyUI packages') ui.phase('Check ComfyUI dependencies', stage_offset + 3, stage_total) execute([uv, 'pip', 'check', '--python', python], env, 'Check ComfyUI dependencies') + remaining = broken_packages() + if remaining: + raise RuntimeError('ComfyUI package files are still missing or damaged after reinstalling them: ' + ', '.join(remaining)) save(receipt, identity) + repair_marker.unlink(missing_ok=True) ui.phase('Finish ComfyUI setup', stage_offset + 4, stage_total) result = dict(python=str(python), environment=str(env_root), identity=identity) save(root / 'launcher' / 'comfy-host.json', result) diff --git a/freevideo_engine/comfy_launcher_runtime.py b/freevideo_engine/comfy_launcher_runtime.py index 3d497a7d..bf901267 100644 --- a/freevideo_engine/comfy_launcher_runtime.py +++ b/freevideo_engine/comfy_launcher_runtime.py @@ -25,14 +25,46 @@ 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 +from .desktop_runtime import materialize_source, source_release from .monitoring import save -from . import processes +from . import engine_binding, processes PROTOCOL = 1 TEMPLATE = 'FreeVideo-All-in-One.json' +class OlderLauncher(ValueError): + def __init__(self, launcher_label, installed_label): + self.launcher_label = launcher_label + self.installed_label = installed_label + super().__init__(f'This launcher ({launcher_label}) is older than the installed FreeVideo ' + f'({installed_label}); nothing was changed.') + + +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() + if not installed.is_dir() or source == installed: + return + candidate, current = source_release(source), source_release(installed) + if older(candidate, current): + raise OlderLauncher(label(candidate), label(current)) + + +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'): + try: + value = json.loads(receipt.read_text(encoding='utf-8')) + except (OSError, ValueError): + continue + if isinstance(value, dict): + check_source_release(source, value.get('source')) + + def disk_review(plan, engine, comfy, *, separate, new_comfy): """Add frontend costs to the right volume, including cross-drive installs.""" frontend = plan.get('frontend') @@ -76,6 +108,11 @@ def layout(path): portable=bool(python and python.parent.name in ('python_embeded', 'python_embedded'))) +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() + + def managed_python(engine, comfy): """Find our previously completed frontend without requiring its full disk budget again.""" try: @@ -323,11 +360,12 @@ def checkout_entry(data, folder): 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)) - shield_engine(engine) target = node_target(root) - target.mkdir(parents=True, exist_ok=True) 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')) + shield_engine(engine) + target.mkdir(parents=True, exist_ok=True) entry = target / '__init__.py' 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'] @@ -433,7 +471,10 @@ def _prepare_frontend(self, selected, *, download=False, repair=False): folders = SimpleNamespace(base_path=selected['root'], models_dir=str(Path(selected['root']) / 'models'), get_folder_paths=lambda _: []) self.setup = Setup(Path(selected['source']), folders, LauncherRunner) - if managed_python(Path(selected['engine']), Path(selected['root'])) is None: + # Anything that installs into this environment stops our idle server first + # and never runs beside a job, including a host repair resumed here. + if (repair or host_repair_pending(selected.get('python')) + or managed_python(Path(selected['engine']), Path(selected['root'])) is None): self._stop_server_for_setup(selected['url']) self.setup.state = dict(status='running', action='comfy-host') self.setup.events.reset() @@ -627,9 +668,9 @@ def _inspect(self, values): self.state = dict(status='running', action='inspect') fresh = values.get('new_comfy', False) descriptor = new_layout(values.get('destination', '')) if fresh else layout(values['comfy']) - node_target(descriptor['root']) default_parent = Path(descriptor['root']).parent if fresh else Path(descriptor['root']) engine = Path(values.get('engine') or default_parent / 'FreeVideo-engine').expanduser().resolve() + 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.') cached_python = managed_python(engine, Path(descriptor['root'])) @@ -638,7 +679,8 @@ def _inspect(self, values): descriptor.update(python=host.get('python'), separate=values.get('separate', False) or not host.get('ready')) # A repair also reinstalls the packages of the ComfyUI environment this # launcher made, in place; a user's own ComfyUI Python is never changed. - repair_comfy = bool(values.get('repair') and cached_python and host.get('python') + repair_pending = host_repair_pending(cached_python) + repair_comfy = bool((values.get('repair') or repair_pending) and cached_python and host.get('python') and Path(host['python']) == Path(cached_python)) # Validate before copying even the small launcher payload. folders = SimpleNamespace(base_path=descriptor['root'], models_dir=str(Path(descriptor['root']) / 'models'), @@ -651,7 +693,7 @@ def _inspect(self, values): self.setup.runner.token = validate(values.get('token', '')) url = local_url(values.get('url', '')) ready = False - if not values.get('repair'): + if not values.get('repair') and not repair_comfy: try: _, machine = installation(source, {'FREEVIDEO_HOME': str(engine)}) # With every quality level requested, set up again only while some are missing. @@ -671,7 +713,8 @@ def _inspect(self, values): extra = [p for p in dict.fromkeys(extra) if p.strip() and 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))) + frontend=dict(root=descriptor['root'], separate=descriptor['separate'], download=fresh, + repair_python=host['python'] if repair_comfy else None))) 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'], @@ -684,6 +727,7 @@ def _install(self, accepted): if not accepted or not self.selection: raise ValueError('Review and accept the current installation plan first') selected = dict(self.selection) + check_installed_sources(self.source, selected['root'], selected['engine']) frontend = selected['separate'] or selected.get('repair_comfy') self.sections = ([('engine', 8)] if not selected['ready'] else []) + ( [('comfy', 6 if selected.get('new_comfy') else 5)] if frontend else []) + [('nodes', 1), ('open', 1)] @@ -704,6 +748,17 @@ def _install(self, accepted): self._prepare_frontend(selected, download=bool(selected.get('new_comfy')), repair=bool(selected.get('repair_comfy'))) if self.cancelled.is_set(): 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')) + if engine_binding.pending(machine, selected['source']): + # Whoever started it (another launcher, the freevideo command), a ComfyUI of this installation + # that is generating keeps its packages: the switch waits for the job. + if queue_busy(selected['url']): + raise RuntimeError('ComfyUI is running a job, so the engine update waits. Try again after it finishes; ' + 'the job is left running.') + self._stop_server_for_setup(selected['url']) + engine_binding.rebind(selected['engine'], machine, selected['source']) self.stage('nodes', label='Install FreeVideo workflow') deployed = deploy(selected['root'], selected['source'], selected['engine']) selected['ready'] = True @@ -723,7 +778,7 @@ def _move_address(self, selected): return url, selected def _stop_server_for_setup(self, url): - """Setup reinstalls engine packages that our ComfyUI's resident worker + """Setup or source rebinding replaces packages that our ComfyUI's resident worker keeps loaded after a video (Windows refuses to replace SageAttention's _fused.pyd), and a model switch must not leave the old model in that worker. Stop our own idle server; connecting after setup starts it @@ -759,8 +814,9 @@ def _connect(self): url = selected['url'] frontend_error = None if (selected.get('python') and managed_frontend(selected) - and managed_python(Path(selected['engine']), Path(selected['root'])) is None): - # ComfyUI was updated since its separate environment was prepared. + and (managed_python(Path(selected['engine']), Path(selected['root'])) is None + or host_repair_pending(selected['python']))): + # Requirements changed, or a previous host repair was interrupted. self.sections = [('comfy', 5), ('open', 1)] self.stage('comfy', label='Update ComfyUI packages') try: @@ -873,6 +929,13 @@ def _connect(self): exit_code=server.returncode, context=context) if frontend_error: message += '\n\nComfyUI packages could not be updated first: ' + frontend_error + if managed: + # Only on this failure path: it reads every package's RECORD. + from .failure_details import ENVIRONMENT_FILES + from .runtime_libraries import incomplete + changed = incomplete(selected['python']) + if changed: + message += '\n\n' + ENVIRONMENT_FILES + ', '.join(changed) raise RuntimeError(message) info = server_info(url) if matches_server(info, selected['root'], selected['engine'], selected['source']): diff --git a/freevideo_engine/comfy_library.py b/freevideo_engine/comfy_library.py index 3f59a5ef..a081015b 100644 --- a/freevideo_engine/comfy_library.py +++ b/freevideo_engine/comfy_library.py @@ -71,6 +71,10 @@ def list_videos(output_directory, *, before=None, limit=24): summary['geometry'] = {k: v for k, v in summary['geometry'].items() if k in ('width', 'height', 'frames', 'fps', 'seconds') and type(v) in (int, float)} + if 'encoder_file' in summary: + # The library API exposes only relative paths. + summary['encoder_file'] = dict(summary['encoder_file']) + summary['encoder_file'].pop('path', None) if not (root / summary['report']).is_file(): summary['report'] = None rows.append(dict(summary, id=key[1], bytes=info.st_size, diff --git a/freevideo_engine/comfy_nodes.py b/freevideo_engine/comfy_nodes.py index 9f61b12f..ee144d5a 100644 --- a/freevideo_engine/comfy_nodes.py +++ b/freevideo_engine/comfy_nodes.py @@ -88,6 +88,9 @@ def execute(cls, text, width, height, seconds, seed, first=None, last=None, from .two_pass import validate_steps if not refine_from: validate_steps(base_steps, refine_steps, two_pass) + from .background_compile import COMPILE + # Returns once the idle compile released the GPU and the engine lease. + COMPILE.stop(final=True) from .comfy_media import export import folder_paths import comfy.model_management as memory @@ -96,6 +99,8 @@ def execute(cls, text, width, height, seconds, seed, first=None, last=None, from .comfy_progress import publish from .encoder_prewarm import IDLE from .resident_process import OWNER + from .prompt_vlm.service import stop_prestart_for_generation + stop_prestart_for_generation(PromptServer.instance) prewarm = {} result_reused = [False] node_id = cls.hidden.unique_id @@ -346,9 +351,37 @@ def execute(cls, video): return io.NodeOutput(*(torch.from_numpy(frame.copy()).float().div_(255)[None] for frame in (first, last))) +def start_background_compile(): + """Compile recent request shapes once this session is idle; see background_compile.""" + import os + from server import PromptServer + from .background_compile import COMPILE + if os.environ.get('FREEVIDEO_BACKGROUND_COMPILE', 'auto').lower() in ('0', 'off', 'false'): + return False + root, machine = comfy_bridge.installation() + if machine.get('device_backend') == 'mps' or machine.get('gpu_vendor') == 'amd': + return False + source = comfy_bridge.source_root() + environment = comfy_bridge.engine_environment(root, source) + # This process's own GPU use is not another program's (background_room). + environment.update(FREEVIDEO_HOME=str(root), PYTHONPATH=str(source), FREEVIDEO_BACKGROUND_OWNER=str(os.getpid())) + def busy(): + server = PromptServer.instance + return server is not None and server.prompt_queue.get_tasks_remaining() > 0 + return COMPILE.start([machine['python'], '-B', '-m', 'freevideo_engine.precompile', + '--config', str(root / 'machine.json'), '--background'], + environment=environment, cwd=str(source), log=root / 'precompile.log', + lock=environment.get('FREEVIDEO_LOCK_PATH') or root / 'engine.lock', busy=busy) + + class FreeVideoExtension(ComfyExtension): async def get_node_list(self): from .comfy_residency import install import comfy.model_management install(comfy.model_management) + try: + start_background_compile() + except Exception as error: + # Optional: the first request compiles what it needs, as before. + logging.warning('[FreeVideo] Background compile not started: %s', error) return [FreeVideoGenerate, FreeVideoMedia, FreeVideoReference, FreeVideoLoRA, FreeVideoLoRAStack, FreeVideoFrames] diff --git a/freevideo_engine/comfy_service.py b/freevideo_engine/comfy_service.py index 59a6f56f..71cabb59 100644 --- a/freevideo_engine/comfy_service.py +++ b/freevideo_engine/comfy_service.py @@ -26,6 +26,7 @@ import time from .monitoring import save +from . import engine_binding DEFAULT_PORT = 8188 RECORD = 'comfyui-cli.json' @@ -100,6 +101,15 @@ def install(root, source, machine, port, out=say): """The pinned ComfyUI in /ComfyUI with its own environment, and our node in it.""" from .comfy_launcher_runtime import deploy, managed_python from .comfy_source import new_layout + previous = installed(root) + switching = previous is not None and Path(previous['source']).resolve() != Path(source).resolve() + if switching: + from .comfy_launcher_runtime import queue_busy + # A ComfyUI of this installation started elsewhere (the desktop launcher) has no service lock here. + if previous.get('url') and queue_busy(previous['url']): + raise RuntimeError('ComfyUI must stop before switching the engine source; the running job is left running.') + if switching and stop(root, out=out): + raise RuntimeError('ComfyUI must stop before switching the engine source; the running job is left running.') layout = new_layout(str(root)) comfy = Path(layout['root']) python = managed_python(Path(root), comfy) if (comfy / 'main.py').is_file() else None @@ -120,6 +130,8 @@ def install(root, source, machine, port, out=say): python = managed_python(Path(root), comfy) if python is None: raise RuntimeError('ComfyUI was installed, but its environment is incomplete. Run the same command again.') + if switching: + engine_binding.rebind(root, machine, source) deploy(comfy, source, root) record = dict(root=str(comfy), engine=str(Path(root)), source=str(source), python=python, url='http://127.0.0.1:%d' % port) diff --git a/freevideo_engine/comfy_setup.py b/freevideo_engine/comfy_setup.py index 1643a3ae..46305604 100644 --- a/freevideo_engine/comfy_setup.py +++ b/freevideo_engine/comfy_setup.py @@ -264,6 +264,8 @@ def inspect(self, value): arguments.append('--frontend-separate') if frontend.get('download'): arguments.append('--frontend-download') + if frontend.get('repair_python'): + arguments += ['--frontend-repair-python', frontend['repair_python']] if isinstance(value.get('sampling_caches'), bool): arguments.append('--sampling-caches' if value['sampling_caches'] else '--no-sampling-caches') if value.get('prepared_format') in ('int8', 'fp8'): @@ -363,7 +365,9 @@ def calibrate(self, value): return self.status() def _stop_idle_prewarm(self): + from .background_compile import COMPILE from .encoder_prewarm import IDLE + COMPILE.stop() IDLE.stop() def _close_resident_worker(self): diff --git a/freevideo_engine/comfy_updates.py b/freevideo_engine/comfy_updates.py index e092c878..95ace1b5 100644 --- a/freevideo_engine/comfy_updates.py +++ b/freevideo_engine/comfy_updates.py @@ -106,8 +106,7 @@ def _check(self): or launcher_update.build_target(candidate) != launcher_update.build_target(self.current) or launcher_update.build_track(candidate) != track): raise ValueError('Update belongs to a different platform') - newer = (candidate['built_at'] > self.current['built_at'] - and candidate['revision'] != self.current['revision']) + newer = launcher_update.newer(candidate, self.current) available = public_details(candidate) if newer else None with self.lock: self.state.update(status='available' if available else 'current', available=available) diff --git a/freevideo_engine/component_licenses.py b/freevideo_engine/component_licenses.py new file mode 100644 index 00000000..15c26d58 --- /dev/null +++ b/freevideo_engine/component_licenses.py @@ -0,0 +1,130 @@ +"""Licences the user accepts before an installation or an offline first start. + +The consent keeps one "View licenses" link; it opens a list of these rows. +A row names the component and its licence in English and Chinese and links +to the licence. A text file on this computer comes first, so the licence +reads without a connection; a web page otherwise. +""" +from pathlib import Path + +HERE = Path(__file__).resolve().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' +NOTICES = 'THIRD_PARTY_NOTICES.txt' +# Where and how the model may be used, below its row; said, never asked to be confirmed. +MODEL_TERMS = ('The MiniMax H3 Community License Agreement grants rights only outside the European Union, the United Kingdom, ' + 'the Republic of Korea and the United States, and restricts how the model and its outputs may be used ' + '(for example, content posted publicly must be disclosed as AI-generated). See the agreement and its Exhibit A.', + 'MiniMax H3 社区许可协议只授权在欧盟、英国、韩国和美国以外的地区使用,并对模型及其生成内容的用途有限制' + '(例如公开发布生成的内容时须标明由 AI 生成),详见协议原文及附件 A。') +MODEL_URL = 'https://huggingface.co/OpenVDN/vdn-minimax-h3-edge/blob/main/LICENSE' +MODEL_TITLE = 'MiniMax H3 COMMUNITY LICENSE AGREEMENT' + + +def row(key, name, licence, link, text=None, terms=None): + """One licence: name, licence and terms are (English, Chinese); text names its full text in TEXTS.""" + return dict(key=key, name=name, license=licence, link=str(link), text=text, terms=terms) + + +MH3 = ('MiniMax H3 Community License Agreement', 'MiniMax H3 社区许可协议') +MODEL = row('model', ('MiniMax H3 model', 'MiniMax H3 模型'), MH3, MODEL_URL, terms=MODEL_TERMS) +ENCODER = row('encoder', ('H3 text encoder', 'H3 文本编码器'), + ('MiniMax H3 Community License Agreement, with the Qwen3-VL Apache License 2.0 notice', + 'MiniMax H3 社区许可协议,附 Qwen3-VL 的 Apache 2.0 声明'), + 'https://huggingface.co/t8star/Vdn-Minimax-H3-Comfy/blob/main/LICENSE') +# Libraries a runtime may carry, found by file name: versioned Windows names and Linux +# lib*.so.* alike. A CUDA build of Torch counts for CUDA, so a runtime whose CUDA files +# are named otherwise still lists it. +RUNTIME = ( + (row('cuda', ('NVIDIA CUDA', 'NVIDIA CUDA'), + ('NVIDIA CUDA Toolkit End User License Agreement', 'NVIDIA CUDA 工具包最终用户许可协议'), + 'https://docs.nvidia.com/cuda/eula/index.html', text='NVIDIA-CUDA-EULA.txt'), + ('cudart', 'cublas', 'cufft', 'curand', 'cusolver', 'cusparse', 'nvrtc', 'nvjitlink', 'cupti', 'nvtoolsext', + 'nvperf', 'ptxas', 'cuda.lib', 'torch_cuda', 'c10_cuda')), + (row('cudnn', ('NVIDIA cuDNN', 'NVIDIA cuDNN'), + ('NVIDIA cuDNN Software License Agreement', 'NVIDIA cuDNN 软件许可协议'), + 'https://docs.nvidia.com/deeplearning/cudnn/latest/reference/eula.html', text='NVIDIA-cuDNN-SLA.txt'), ('cudnn',)), + # Intel's page lists several licences; the row names the one that applies. + (row('intel', ('Intel OpenMP', 'Intel OpenMP'), + ('Intel End User License Agreement for Developer Tools', '英特尔开发者工具最终用户许可协议'), + 'https://www.intel.com/content/www/us/en/developer/articles/license/end-user-license-agreement.html', + text='Intel-EULA-Developer-Tools.txt'), ('libiomp5', 'libiompstubs5')), + (row('rocm', ('AMD ROCm', 'AMD ROCm'), ('ROCm component licenses', 'ROCm 各组件许可'), + 'https://rocm.docs.amd.com/en/latest/about/license.html'), ('amdhip64', 'rocblas', 'hipblas', 'miopen')), +) + + +def library_names(paths): + """Library file names from paths, lower case and without a leading 'lib' (Intel's keep it).""" + names = set() + for path in paths: + name = str(path).replace('\\', '/').rsplit('/', 1)[-1].lower() + if name.endswith(('.dll', '.exe', '.lib', '.dylib', '.so')) or '.so.' in name or '.' not in name: + names.add(name[3:] if name.startswith('lib') and not name.startswith('libiomp') else name) + return names + + +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() + except OSError: + return False + + +def local(value, *paths, title=None): + """The row opening the first of these files that exists (and starts with title), or as it was.""" + def fits(path): + if not file_exists(path): + return False + if title is None: + return True + try: + with 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 + + +def text(value): + """The row opening its full text on this computer when FreeVideo ships one.""" + return local(value, TEXTS / value['text']) if value['text'] else value + + +def bundle(root, value): + """What an offline runtime asks the user to accept: its models, libraries and notices. + + Read from the package's file list, so a runtime without NVIDIA's or Intel's + libraries does not ask the user to accept their terms. + """ + root = Path(root) + names = library_names(entry['path'] for entry in value['files']) + # A model folder may hold another LICENSE (a LoRA's, say): only the agreement itself opens. + # The text encoder is under the same agreement, Qwen3-VL's notice at its end. + model = local(MODEL, root / 'models/edge/LICENSE', root / value.get('model_root', 'models/base') / 'LICENSE', title=MODEL_TITLE) + rows = [model, dict(ENCODER, link=model['link']) if is_file(model['link']) else ENCODER, + *(text(licence) for licence, prefixes in RUNTIME if any(name.startswith(prefixes) for name in names))] + if any(licence['text'] for licence in rows[2:]) and file_exists(TEXTS / NOTICES): + # The rest of what the Windows runtime carries (Triton's tools, TinyCC) and where its source is. + rows.append(row('notices', ('Third-party notices', '第三方组件说明'), + ('Copyrights and licenses of the other components', '其余组件的版权和许可'), TEXTS / NOTICES)) + return rows + + +def named(url): + """A row for a licence link from an installation plan; one not listed here shows its site.""" + from urllib.parse import urlsplit + prefixes = ((MODEL, 'https://huggingface.co/OpenVDN/'), (ENCODER, 'https://huggingface.co/t8star/'), + *((value, value['link'].rsplit('/', 1)[0] + '/') for value, _ in RUNTIME)) + known = next((value for value, prefix in prefixes if url.startswith(prefix)), None) + if known: + return dict(known, link=url) + site = urlsplit(url).hostname or url + return row('link', (site, site), (url, url), url) + + +def is_file(link): + return not str(link).startswith(('https://', 'http://')) diff --git a/freevideo_engine/desktop_runtime.py b/freevideo_engine/desktop_runtime.py index 326bbf17..2aa83c5b 100644 --- a/freevideo_engine/desktop_runtime.py +++ b/freevideo_engine/desktop_runtime.py @@ -73,6 +73,27 @@ def matching_source(bundled, installed): return False +def source_release(source): + """The product version, build time and build number stamped into an engine 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')) + except (OSError, ValueError, TypeError): + return {} + if not isinstance(stamp, dict): + return {} + result = {} + if product_version(stamp.get('product_version')): + result['product_version'] = stamp['product_version'] + if type(stamp.get('built_at')) is int and stamp['built_at'] > 0: + result['built_at'] = stamp['built_at'] + if isinstance(stamp.get('version'), str) and len(stamp['version']) <= 64: + result['version'] = stamp['version'] + return result + + def check_launcher_dependencies(): """Exercise the native process extension, including inside the frozen EXE.""" import psutil diff --git a/freevideo_engine/desktop_shortcut.py b/freevideo_engine/desktop_shortcut.py index ea8f2e07..ce29ef64 100644 --- a/freevideo_engine/desktop_shortcut.py +++ b/freevideo_engine/desktop_shortcut.py @@ -15,6 +15,20 @@ from .system import windows +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'))) + except (OSError, ValueError, TypeError): + return {} + + +def _newer_copy(target, current): + from .release_notes import older + return older(current or {}, read_build(Path(target).parent / 'launcher-build.json')) + + def digest(path): value = hashlib.sha256() with Path(path).open('rb') as stream: @@ -156,7 +170,7 @@ def _complete(root, fingerprint, folder): return False -def launcher_target(engine, source, portable_root=None): +def launcher_target(engine, source, portable_root=None, *, prepare=True): if portable_root: target = Path(portable_root) / 'FreeVideo.exe' if not target.is_file(): @@ -165,12 +179,16 @@ def launcher_target(engine, source, portable_root=None): if getattr(sys, 'frozen', False): executable = Path(sys.executable).resolve() from .launcher_update import current_build - folder = (current_build() or {}).get('packaging') == 'onedir' + 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. @@ -182,6 +200,8 @@ def launcher_target(engine, source, portable_root=None): 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]) @@ -253,8 +273,8 @@ def _renamed(folder, entry, target): return None -def _place(folder, entry, target, arguments, icon): - """Write or refresh FreeVideo.lnk in one folder, keeping a user's own link of that name.""" +def _link_path(folder, entry, target): + """Select the managed link without changing it or preparing a launcher copy.""" path = folder / 'FreeVideo.lnk' old = Path(entry['path']) if entry.get('path') else None if old is not None and old.parent == folder and old.is_file() and _ours(old, entry, target): @@ -264,6 +284,12 @@ def _place(folder, entry, target, arguments, icon): index = 2 while path.name.startswith('FreeVideo') and path.exists() and not _ours(path, entry, target): path = folder / ('FreeVideo (%d).lnk' % index); index += 1 + return path + + +def _place(folder, entry, target, arguments, icon): + """Write or refresh FreeVideo.lnk in one folder, keeping a user's own link of that name.""" + path = _link_path(folder, entry, target) if path.is_file(): if (_same(entry.get('path'), path) and _same(entry.get('target'), target) and entry.get('arguments') == arguments and entry.get('sha256') == digest(path)): @@ -349,6 +375,10 @@ def appimage_target(engine, appimage): (stage / APPIMAGE_NAME).chmod(0o755) if digest(stage / APPIMAGE_NAME) != fingerprint: raise OSError('Launcher changed while preparing the desktop shortcut; retry installation') + from .launcher_update import current_build + identity = current_build() + 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]) @@ -365,6 +395,25 @@ def desktop_entry(target, icon): 'Categories=AudioVideo;Video;Graphics;\nStartupWMClass=FreeVideo\n' % (desktop_quote(target), icon)) +def desktop_entry_target(path): + """Read the executable in a FreeVideo desktop entry, including quoted paths.""" + import re + import shlex + try: + for line in Path(path).read_text(encoding='utf-8', errors='replace').splitlines(): + if line.startswith('Exec=') and 'FreeVideo' in line: + command = line[5:].replace('%%', '%').lstrip() + quoted = re.match(r'^"((?:\\.|[^"\\])*)"(?:\s|$)', command) + if quoted: + # Desktop Entry quoting also escapes $ and ` inside double + # quotes; shlex leaves those backslashes in the filename. + return Path(re.sub(r'\\([\\"`$])', r'\1', quoted[1])) + return Path(shlex.split(command)[0]) + except (OSError, ValueError, IndexError): + pass + return None + + def _write_entry(path, content, *, trusted=False): """Write or repair our entry; a file of the same name that starts something else stays.""" try: @@ -394,8 +443,15 @@ def create_linux(engine): if image is None: return dict(status='not-applicable') # Started from a checkout: ./setup.sh adds the freevideo command. engine = Path(engine) - target = appimage_target(engine, image) data = _xdg('XDG_DATA_HOME', Path.home() / '.local/share') + desktop = linux_desktop_directory() + from .launcher_update import current_build + current = current_build() + for path in (data / 'applications' / (APP_ID + '.desktop'), desktop / 'FreeVideo.desktop'): + named = desktop_entry_target(path) + if named is not None and _newer_copy(named, current): + return dict(status='present', path=str(path), target=str(named)) + target = appimage_target(engine, image) icon = data / 'icons/hicolor/512x512/apps' / (APP_ID + '.png') source_icon = Path(__file__).parent / 'assets/icon.png' if not icon.is_file() or digest(icon) != digest(source_icon): @@ -403,7 +459,6 @@ def create_linux(engine): shutil.copyfile(source_icon, icon) content = desktop_entry(target, icon) menu = _write_entry(data / 'applications' / (APP_ID + '.desktop'), content) - desktop = linux_desktop_directory() # A server has no desktop folder; never create one for it. result = (_write_entry(desktop / 'FreeVideo.desktop', content, trusted=True) if desktop.is_dir() else dict(status='not-applicable', path=str(desktop / 'FreeVideo.desktop'))) @@ -430,7 +485,6 @@ def create(engine, source, portable_root=None): return dict(status='not-applicable') engine = Path(engine) receipt = engine / 'launcher/desktop-shortcut.json' - target, arguments, icon = launcher_target(engine, source, portable_root) try: previous = json.loads(receipt.read_text(encoding='utf-8')) except (OSError, ValueError): @@ -438,6 +492,21 @@ def create(engine, source, portable_root=None): if not isinstance(previous, dict): previous = {} recorded = {key: value for key, value in previous.items() if key != 'start_menu'} + from .launcher_update import current_build + current = current_build() + if current: + # Check both places before copying an EXE or writing either link/receipt. + target, _, _ = launcher_target(engine, source, portable_root, prepare=False) + for directory, entry in ((start_menu_directory, previous.get('start_menu') or {}), + (desktop_directory, recorded)): + try: + path = _link_path(directory(), entry, target) + named = read_link_targets([path]).get(str(path)) + except (OSError, AttributeError, ValueError): + continue + if named and _newer_copy(named, current): + return dict(status='present', path=str(path), target=str(named)) + target, arguments, icon = launcher_target(engine, source, portable_root) # Each place on its own: a refused desktop must not cost the Start menu entry. try: menu = _place(start_menu_directory(), previous.get('start_menu') or {}, target, arguments, icon) diff --git a/freevideo_engine/diagnostic_summary.py b/freevideo_engine/diagnostic_summary.py index c83c9ce7..f068fcb5 100644 --- a/freevideo_engine/diagnostic_summary.py +++ b/freevideo_engine/diagnostic_summary.py @@ -169,7 +169,9 @@ def summary_report(report): checkpoint = diagnostic.mapping(encoding.get('checkpoint')) if checkpoint.get('state') in ('read_only_mmap', 'native_mmap', 'resident_reuse'): result['encoder_resources']['checkpoint'] = dict( - diagnostic.numbers(checkpoint, ('bytes',)), state=checkpoint['state']) + diagnostic.numbers(checkpoint, ('bytes', 'expected_bytes')), state=checkpoint['state']) + if type(checkpoint.get('tested_size')) is bool: + result['encoder_resources']['checkpoint']['tested_size'] = checkpoint['tested_size'] result['encoder_resources']['load_stages'] = [] result['encoder_resources']['gpu'] = dict(result['encoder_resources'].get('gpu', {}), **diagnostic.gpu_snapshot(encoding.get('gpu'))) diff --git a/freevideo_engine/encode_worker.py b/freevideo_engine/encode_worker.py index 3802a9e7..b99614ef 100644 --- a/freevideo_engine/encode_worker.py +++ b/freevideo_engine/encode_worker.py @@ -8,11 +8,60 @@ import json import os from pathlib import Path +import stat import sys import time from .monitoring import save from .processes import worker_signals +PACKAGE = Path(__file__).resolve().parent + + +def resolve_encoder(folder_paths, name): + files = json.loads((PACKAGE / 'model_files.json').read_text(encoding='utf-8')) + expected = next((row['bytes'] for row in files if row['file'] == 'text_encoders/' + name), None) + path = None + copies = {} + if expected is not None: + folders = folder_paths.get_folder_paths('text_encoders') + models_dir = getattr(folder_paths, 'models_dir', None) + if models_dir is not None: + models_dir = os.path.normcase(os.path.realpath(models_dir)) + freevideo, bundled = [], [] + # FreeVideo paths stay preferred after a resident worker registers them. + for folder in folders: + resolved = os.path.normcase(os.path.realpath(folder)) + try: + in_models = os.path.commonpath([models_dir, resolved]) == models_dir + except ValueError: + in_models = False # Different Windows drives cannot share a model root. + (bundled if in_models else freevideo).append(folder) + folders = freevideo + bundled + for folder in folders: + candidate = os.path.realpath(os.path.join(folder, name)) + if candidate in copies: + continue + try: + info = Path(candidate).stat() + except OSError: + continue + if not stat.S_ISREG(info.st_mode): + continue + copies[candidate] = info.st_size + if path is None and info.st_size == expected: + path = candidate + if path is None: + path = folder_paths.get_full_path_or_raise('text_encoders', name) + checkpoint = dict(state='read_only_mmap' if sys.platform == 'win32' else 'native_mmap', + bytes=Path(path).stat().st_size) + if expected is not None: + checkpoint.update(expected_bytes=expected, tested_size=checkpoint['bytes'] == expected) + selected = os.path.realpath(path) + mismatched = sum(size != expected for candidate, size in copies.items() if candidate != selected) + if mismatched: + checkpoint['mismatched_copies'] = mismatched + return path, checkpoint + def encoder_on_gpu(clip): patcher = clip.patcher @@ -253,9 +302,7 @@ def phase(name, **metrics): from freevideo_engine.conditioning import to_cache phase('encoder_lookup') name = request.get('encoder', 'qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors') - path = folder_paths.get_full_path_or_raise('text_encoders', name) - loading['checkpoint'] = dict(state='read_only_mmap' if sys.platform == 'win32' else 'native_mmap', - bytes=Path(path).stat().st_size) + path, loading['checkpoint'] = resolve_encoder(folder_paths, name) print(json.dumps({'event': 'encoder_cache_lookup', 'encoder': name}), flush=True) phase('encoder_load') # Same native loader and model type as CLIPLoader; CPU initialization prevents diff --git a/freevideo_engine/engine_binding.py b/freevideo_engine/engine_binding.py new file mode 100644 index 00000000..5e6732e1 --- /dev/null +++ b/freevideo_engine/engine_binding.py @@ -0,0 +1,183 @@ +"""Move managed editable installs only when an installation switches source.""" +import ast +import json +import os +from pathlib import Path +import re +import subprocess +import sys +from urllib.parse import urlsplit +from urllib.request import url2pathname + +from .monitoring import save + + +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')) + if not isinstance(value.get('url'), str): + raise ValueError('Editable install record without a URL: ' + str(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 + path = url2pathname('//' + url.netloc + url.path if url.netloc else url.path) + # On Windows url2pathname already handles drive letters. Also allow reading + # 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) + + +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() + 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', + 'lib/python3*/site-packages/freevideo_engine-*.dist-info/direct_url.json'): + for record in environment.glob(pattern): + try: + source = editable_source(record) + except (OSError, ValueError, KeyError, TypeError, AttributeError): + return None + if source is not None: + found.add(source) + return next(iter(found)) if len(found) == 1 else None + + +def source_version(source): + """Read the selected source's stamp without importing it into this process.""" + package = 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'] + if version: + return version + except (OSError, ValueError, TypeError): + pass + try: + version = (package / 'build-version.txt').read_text(encoding='utf-8').strip() + if version: + return version + except OSError: + pass + try: + for node in ast.parse((package / '__init__.py').read_text(encoding='utf-8')).body: + if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == '__version__' + for target in node.targets): + version = ast.literal_eval(node.value) + if isinstance(version, str) and version: + return version + except (OSError, ValueError, SyntaxError): + pass + raise RuntimeError('Cannot read the engine version from source: ' + str(source)) + + +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 + 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 + + +def _same_source(left, right): + return left is not None and Path(left).resolve() == Path(right).resolve() + + +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)' + + +def _bind(uv, python, source, run): + command = [str(uv), 'pip', 'install', '--python', str(python), '--no-deps', '--no-build-isolation', + '--reinstall-package', 'freevideo-engine', '-e', str(source)] + try: + result = run(command, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=180) + except (OSError, subprocess.SubprocessError) as error: + raise RuntimeError('Editable install failed for %s: %s\n%s' % (python, error, _tail(error))) from error + if result.returncode: + raise RuntimeError('Editable install failed for %s (uv exit %s):\n%s' % + (python, result.returncode, _tail(result))) + uv_tail = _tail(result) + try: + result = run([str(python), '-I', '-B', '-c', + 'import freevideo_engine,sys;print(freevideo_engine.__file__)'], + stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=30) + 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 _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: + raise RuntimeError('Editable verification failed for %s: %s\nuv output:\n%s' % + (python, error, uv_tail)) from error + + +def pending(machine, source): + """The environments (E, then T if separate) whose editable install points elsewhere.""" + source = Path(source).resolve() + result, seen = [], set() + for key in ('python', 'comfy_python'): + value = machine.get(key) + if not value: + continue + python = Path(value).absolute() + identity = os.path.normcase(os.path.abspath(python)) + if identity in seen or not python.is_file(): + continue + seen.add(identity) + old = bound_source(python) + if not _same_source(old, source): + result.append((python, old)) + return result + + +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() + 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(): + 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 + updated = dict(machine, source=str(source), engine_version=version) + attempted = [] + try: + for python, old in environments: + attempted.append((python, old)) + _bind(uv, python, source, run) + if updated != machine: + save(engine_root / 'machine.json', updated) + except Exception as error: + rollback = [] + 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(): + rollback.append('Cannot restore %s: previous source is missing or unknown (%s).' % (python, old)) + continue + try: + _bind(uv, python, old.resolve(), 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 + return updated diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index 70f888d5..8906e34c 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -93,13 +93,73 @@ def setup_command_failure(label, log, exit_code): ) -# Failures the launcher's Repair button fixes, and what to do where it has none -# (no installed engine yet, or a fixed offline environment). +from .model_repair import (UNCHANGED as MODEL_UNCHANGED, OFFLINE as MODEL_OFFLINE, BUSY as MODEL_BUSY, # noqa: E402 + FIRST_INSTALL as MODEL_FIRST_INSTALL, CHANGED_WHY, CHANGED_WAYS) + + +def _joined(head, tail): + return tuple(first + (' ' if zh == 0 else '') + rest for zh, (first, rest) in enumerate(zip(head, tail))) + + +def _model_unchanged(tail): + return _joined(MODEL_UNCHANGED, tail) + + +# Failures the launcher's Repair button fixes, and what to do where it has none, by +# why it has none (Session.repair_unavailable): a task still running ('busy'), an +# offline package ('bundled') or imported offline environment ('offline') it never +# repairs, or no installed engine yet ('none'). +WITHOUT_REPAIR_NONE = ('Return to Installation and check the same folder to restore missing dependencies.', + '返回安装设置,检查当前安装目录,补齐运行依赖。') +_KEPT = ('; the models and videos in the current folder are not deleted.', ';原文件夹里的模型和作品不会被删除。') +_EXTRACT = ('Extract the downloaded package again into a new folder and start FreeVideo from there' + _KEPT[0], + '请把下载的整合包重新解压到一个新文件夹,再从新文件夹启动 FreeVideo' + _KEPT[1]) +_IMPORT = ('On the Installation page in the sidebar, select Install ComfyUI, change Install location to a new folder, ' + 'then click Replace package… under Import environment package and choose the downloaded environment package; ' + 'if you are online, you can instead choose Automatic as the install method there. ' + 'The models and videos in the current folder are not deleted.', + '请在左侧“安装”页选择“帮我安装 ComfyUI”,把“安装位置”改为一个新文件夹,然后在“导入运行环境包”中点击“更换环境包…”' + '并选择下载的运行环境包;能联网时,也可以在那里把安装方式改为“自动安装”。原文件夹里的模型和作品不会被删除。') WITHOUT_REPAIR = { - 'dependencies': ('Return to Installation and check the same folder to restore missing dependencies.', - '返回安装设置,检查当前安装目录,补齐运行依赖。'), - 'runtime-libraries': ('Once the current task ends, open Settings › Environment and click Repair. FreeVideo installs a fresh copy of PyTorch.', - '当前任务结束后,请在“设置 → 环境”里点击“修复”,FreeVideo 会重新安装一份完整的 PyTorch。'), + # A model file that differs from the tested one; FreeVideo leaves it in place. + 'model-mismatch': dict( + busy=_model_unchanged(MODEL_BUSY), + bundled=_model_unchanged(('The portable package cannot switch back in one step. ' + _EXTRACT[0], + '整合包版不能一键换回。' + _EXTRACT[1])), + offline=_model_unchanged(MODEL_OFFLINE), + none=MODEL_FIRST_INSTALL), + # Confirmed, then something changed: setup stops (offline installations never get here). + **{kind: dict(busy=_joined(CHANGED_WHY[why], CHANGED_WAYS['busy']), + **dict.fromkeys(('bundled', 'offline', 'none'), _joined(CHANGED_WHY[why], CHANGED_WAYS['none']))) + for kind, why in (('plan-changed-files', 'files'), ('plan-changed', 'other'))}, + 'dependencies': dict( + busy=('When the current task ends, a Repair button appears here; it checks this installation and restores what is missing.', + '当前任务结束后,这里会出现“修复”按钮,点击后 FreeVideo 将检查这份安装并补装缺失的组件。'), + bundled=('The portable package is missing components it needs and cannot restore them in one step. ' + _EXTRACT[0], + '整合包缺少运行所需的组件,不能一键补齐。' + _EXTRACT[1]), + offline=('An imported offline environment is missing components it needs and cannot restore them in one step. ' + _IMPORT[0], + '离线导入的运行环境缺少运行所需的组件,不能一键补齐。' + _IMPORT[1]), + none=WITHOUT_REPAIR_NONE), + 'runtime-libraries': dict( + busy=('When the current task ends, a Repair button appears here; it reinstalls a complete copy of PyTorch and keeps your models and videos.', + '当前任务结束后,这里会出现“修复”按钮,点击后 FreeVideo 将重新安装完整的 PyTorch,模型和视频都会保留。'), + bundled=('The portable package cannot be repaired in one step. ' + _EXTRACT[0], + '整合包版不能一键修复。' + _EXTRACT[1]), + offline=('An imported offline environment cannot be repaired in one step. ' + _IMPORT[0], + '离线导入的运行环境不能一键修复。' + _IMPORT[1]), + none=WITHOUT_REPAIR_NONE), + # An engine update moved the editable install back after a failure (engine_binding). + # Offline and portable installations never reach it; their lines only say where things stand. + 'update-unfinished': dict( + busy=('FreeVideo is back on the previous version and works as before. When the current task ends, a Repair button appears here; ' + 'clicking it reinstalls the engine environment and updates again. The details give the reason.', + 'FreeVideo 已退回原来的版本,可以照常使用。当前任务结束后,这里会出现“修复”按钮;点击后会重新安装引擎环境并再次更新。原因写在详情里。'), + **dict.fromkeys(('bundled', 'offline'), ( + 'FreeVideo is back on the previous version and works as before; the details give the reason.', + 'FreeVideo 已退回原来的版本,可以照常使用;原因写在详情里。')), + none=('FreeVideo is back on the previous version and works as before. To update again, open Installation in the sidebar, keep the same folder, ' + 'click Continue and then Check & continue, and finally click Install & launch on the confirmation page; the details give the reason.', + 'FreeVideo 已退回原来的版本,可以照常使用。要再次更新,请在左栏的“安装”页保持同一个安装目录,依次点击“继续”和“检查并继续”,最后在确认页点击“安装并启动”;原因写在详情里。')), } @@ -111,6 +171,11 @@ def setup_command_failure(label, log, exit_code): ('Click Repair to check this installation and restore what is missing.', '请点击“修复”,FreeVideo 将检查这份安装并补装缺失的组件。')) +# Added by the launcher when ComfyUI exits during startup and the environment it +# made has packages whose recorded files are missing or have another size. A +# missing file often surfaces as some other package's ImportError instead. +ENVIRONMENT_FILES = 'Files are missing or changed in these ComfyUI environment packages: ' + def _stopped_on_missing_module(text): """ComfyUI itself stopped on an import, not a custom node that logged one and loaded on.""" @@ -121,18 +186,51 @@ def _stopped_on_missing_module(text): and not any('for custom nodes' in line for line in lines[errors[-1] + 1:]) -def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_repair=False, repairs_comfy=False): +def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_repair=False, repairs_comfy=False, + unavailable='none'): """Short guidance for known failures; callers retain the full error separately. other_disk: the launcher can offer another disk with enough room (macOS). disk_name: the disconnected disk the launcher found; the error's paths are redacted. can_repair: the launcher's Repair button can fix this installation. repairs_comfy: ComfyUI runs in the environment the launcher made, which Repair reinstalls. + unavailable: why Repair is missing when it cannot run (a key of WITHOUT_REPAIR's rows). """ if not value: return dict(title='', detail='', action='', kind='') text = str(value) summary = text.partition(STEP_OUTPUT)[0] + from .comfy_launcher_runtime import OlderLauncher + # Controller and offline Importer retain strings; direct callers keep the exception. + downgrade = re.search(r'This launcher \(([^\r\n]*?)\) is older than the installed FreeVideo ' + r'\(([^\r\n]*?)\); nothing was changed\.', summary) + if isinstance(value, OlderLauncher) or downgrade: + launcher, installed = ((value.launcher_label, value.installed_label) + if isinstance(value, OlderLauncher) else downgrade.groups()) + if launcher and installed: + detail = (f'已安装的 FreeVideo 是 {installed},这个启动器是 {launcher}。' if zh else + f'The installed FreeVideo is {installed} and this launcher is {launcher}. ') + else: + detail = ('已安装的 FreeVideo 比这个启动器新。' if zh else + 'The installed FreeVideo is newer than this launcher. ') + detail += ('为避免把 FreeVideo 换回旧版本,这次没有继续,已安装的文件均未改动。' + '请从桌面快捷方式打开 FreeVideo,或下载最新的启动器。' if zh else + 'To avoid replacing FreeVideo with an older version, nothing was done and no installed files were changed. ' + 'Open FreeVideo from its desktop shortcut, or download the latest launcher.') + return dict(title='启动器比已安装的 FreeVideo 旧' if zh else 'This launcher is older than the installed FreeVideo', + detail=detail, action='', kind='older-launcher', manual=True) + from .model_repair import MISMATCH, MISMATCH_PATH, FAILURE_TITLE, FAILURE, CHANGED_TITLE, CHANGED_REPAIR + mismatches = re.findall(MISMATCH + r': ([^\r\n]+)', text) + if mismatches: + rows = WITHOUT_REPAIR['model-mismatch'] + action = (FAILURE[zh].format(file=mismatches[-1], entry='修复' if zh else 'Repair') if can_repair else + rows.get(unavailable, rows['none'])[zh].format(file=mismatches[-1])) + failure = dict(title=FAILURE_TITLE[zh], detail='', kind='model-mismatch', action=action) + if not can_repair and unavailable == 'offline': + # The text points at the file below it. + paths = re.findall(MISMATCH_PATH + r': ([^\r\n]+)', text) + failure['path'] = paths[-1] if paths else mismatches[-1] + return failure if disk_name and 'on a disk that is not connected' in summary: return dict(title='这块硬盘没有连接' if zh else 'The disk is not connected', kind='disk-missing', detail='', action=('请接上“%s”后点击“重新检查”' % disk_name + (',或者改装到其他硬盘。' if other_disk else '。') if zh else @@ -157,7 +255,7 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep return dict(title='磁盘空间不足' if zh else 'Not enough disk space', kind='disk', detail=detail, action='重新检查' if zh else 'Check again') if text.startswith('ComfyUI could not start.'): - if can_repair and repairs_comfy and _stopped_on_missing_module(summary): + if can_repair and repairs_comfy and (_stopped_on_missing_module(summary) or ENVIRONMENT_FILES in summary): return dict(title=DEPENDENCIES[0][zh], detail='', action=DEPENDENCIES[1][zh], kind='dependencies') return dict(title='ComfyUI 启动失败' if zh else 'ComfyUI could not start', detail='ComfyUI 进程在启动时退出。' if zh else 'The ComfyUI process exited during startup.', @@ -176,6 +274,16 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep action='展开详情或导出报告,查看完整检查结果。' if zh else 'Open the details or export the report for the complete results.', kind='kernels') rules = ( + # An engine update waits for a video being generated, whoever started this installation's ComfyUI. + (r'ComfyUI is running a job, so the engine update waits', 'update-busy', + ('A video is still generating', '还有视频正在生成'), + ('Click Update now again when this video has finished. The video being generated is not interrupted.', + '这个视频生成完成后,再点击“立即更新”。正在生成的视频不会被打断。')), + # Moving the engine's editable install to the new source failed and was undone (engine_binding). + (r'Editable install failed for|Editable verification failed for|The engine.s uv is missing', 'update-unfinished', + ('The update did not finish', '这次更新没有完成'), + ('FreeVideo is back on the previous version and works as before. Click Repair to reinstall the engine environment and update again; the details give the reason.', + 'FreeVideo 已退回原来的版本,可以照常使用。点击“修复”会重新安装引擎环境并再次更新,原因写在详情里。')), (r'Git \d+\.\d+ is too old to download the pinned sources', 'git-version', ('Git is too old', 'Git 版本过旧'), ('Click Retry installation; FreeVideo then installs its own Git. On macOS, update the Command Line Tools.', @@ -250,6 +358,10 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep 'Open FreeVideo normally and import the offline packages again; the files already there are reused.', 'Windows 不允许当前账户读取它们,通常是因为这些文件是在“以管理员身份运行”时创建的。' '请正常打开 FreeVideo,重新导入一次离线包,已有的文件会直接复用。')), + (r'A file in the installation folder changed after the review', 'plan-changed-files', CHANGED_TITLE, + _joined(CHANGED_WHY['files'], CHANGED_REPAIR)), + (r'The installation plan changed\. Detect configuration again', 'plan-changed', CHANGED_TITLE, + _joined(CHANGED_WHY['other'], CHANGED_REPAIR)), (r'PermissionError|Access is denied|Permission denied|WinError 5\b', 'permission', ('This folder is not writable', '无法写入这个目录'), ('Choose an installation folder you can write to, then retry.', '选择有写入权限的安装目录后重试。')), @@ -266,7 +378,7 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep for pattern, kind, title, action in rules: if re.search(pattern, text, re.I): if kind in WITHOUT_REPAIR and not can_repair: - action = WITHOUT_REPAIR[kind] + action = WITHOUT_REPAIR[kind].get(unavailable) or WITHOUT_REPAIR[kind].get('none', WITHOUT_REPAIR_NONE) return dict(title=title[zh], detail='', action=action[zh], kind=kind) # Validation messages are already actionable. Show the cause, not a stack # trace or a guessed hardware diagnosis, and leave long details expandable. diff --git a/freevideo_engine/generate.py b/freevideo_engine/generate.py index d0f5e8d4..825b2034 100644 --- a/freevideo_engine/generate.py +++ b/freevideo_engine/generate.py @@ -307,6 +307,12 @@ def _runtime_libraries(error, record): pass +def encoder_size_mismatch(encoding): + # Cache hits retain the checkpoint with the original request's metrics. + checkpoint = encoding.get('checkpoint') or (encoding.get('cache_source_metrics') or {}).get('checkpoint') or {} + return checkpoint.get('tested_size') is False + + def run(args): """Also retain admission/import failures before the main request starts.""" output = Path(args.out).expanduser().resolve() @@ -512,11 +518,15 @@ def interrupted(signum, frame): encode_request = temporary / 'encode.json' encode_request.write_text(json.dumps(encoding) + '\n', encoding='utf-8') cached = input_cache.get(conditioning_key, condition) if input_cache else None - if cached and (cached.get('success') is not True or cached.get('conditioning_shape', [])[1:] != [5120]): + if cached and encoder_size_mismatch(cached): + cached = None + elif cached and (cached.get('success') is not True or cached.get('conditioning_shape', [])[1:] != [5120]): condition.rename(condition.with_name('conditioning.cache-rejected-' + str(time.time_ns()) + '.pt')) cached = None if not cached and state and task_for(media) == 't2va': cached = tuning.cached_conditioning(state, encoding['prompt'], condition) + if cached and encoder_size_mismatch(cached): + cached = None if cached: save(destination.with_suffix('.encoding.json'), cached) destination.with_suffix('.encoding.log').write_text('Reused validated local text conditioning; encoder model was not loaded.\n', encoding='utf-8') @@ -561,14 +571,15 @@ def interrupted(signum, frame): 'prompt_characters': len(encoding['prompt'])}) report['encoding'] = json.loads(destination.with_suffix('.encoding.json').read_text(encoding='utf-8')) report['encoder_process_exited_before_video'] = not bool(os.environ.get('FREEVIDEO_RESIDENT_SESSION')) - if input_cache and not cached: + cacheable = not cached and not encoder_size_mismatch(report['encoding']) + if input_cache and cacheable: try: report['input_cache']['conditioning_saved'] = input_cache.put(conditioning_key, condition, report['encoding']) if input_cache.note: report['input_cache']['note'] = input_cache.note except (OSError, ValueError) as error: report['input_cache']['note'] = str(error) - if state and not cached and task_for(media) == 't2va': + if state and cacheable and task_for(media) == 't2va': try: tuning.remember_conditioning(state, encoding['prompt'], condition, report['encoding']) tuning.save_state(state) diff --git a/freevideo_engine/install_disk.py b/freevideo_engine/install_disk.py index fccdd1ae..9bc360cc 100644 --- a/freevideo_engine/install_disk.py +++ b/freevideo_engine/install_disk.py @@ -128,12 +128,14 @@ def check_floor(paths): (parent, free / GiB)) -def check_models(entries): +def check_models(entries, replacements=()): """Recheck actual free disk AFTER package cleanup, before writing any models.""" disks = {} for row, path, _, local in entries: remaining = row['bytes'] - if path.is_file() and path.stat().st_size == remaining: + if str(path) in replacements: + pass # The approved original stays on disk; budget a full new download. + elif path.is_file() and path.stat().st_size == remaining: remaining = 0 # provision still verifies its pinned content. elif local and local.get('method') == 'hardlink': remaining = 0 diff --git a/freevideo_engine/installation_cleanup.py b/freevideo_engine/installation_cleanup.py index 6445578f..0a2d936c 100644 --- a/freevideo_engine/installation_cleanup.py +++ b/freevideo_engine/installation_cleanup.py @@ -35,6 +35,8 @@ def unreadable(error): raise error for directory, folders, files in os.walk(path, followlinks=False, onerror=unreadable): for name in [*folders, *files]: + if '.replaced-' in name: + raise ValueError('Cache contains a retained original; retained.') item = Path(directory) / name info = item.lstat() if stat.S_ISLNK(info.st_mode): diff --git a/freevideo_engine/kernel_capabilities.py b/freevideo_engine/kernel_capabilities.py index f9a0eb11..731b96a9 100644 --- a/freevideo_engine/kernel_capabilities.py +++ b/freevideo_engine/kernel_capabilities.py @@ -59,12 +59,8 @@ def readiness(rows): if row.get('status') != 'complete' and row['backend'] != 'linear']) -def available_backends(hardware, *, discovered=None, probe_missing=True): - """Reuse matching checks; re-probe after a GPU, driver or package/code change. - - Read-only planning may opt out of probing. Execution callers never turn an - unavailable requested backend into another backend or a math SDPA kernel. - """ +def matching_receipt(hardware, discovered=None): + """The saved kernel checks when they still match this GPU, driver and packages; else None.""" discovered = set(installed_backends() if discovered is None else discovered) expected = identity(hardware) # Normalize tuples exactly as on disk. @@ -82,6 +78,17 @@ def available_backends(hardware, *, discovered=None, probe_missing=True): receipt = None except (OSError, ValueError, KeyError, TypeError): receipt = None + return receipt + + +def available_backends(hardware, *, discovered=None, probe_missing=True): + """Reuse matching checks; re-probe after a GPU, driver or package/code change. + + Read-only planning may opt out of probing. Execution callers never turn an + unavailable requested backend into another backend or a math SDPA kernel. + """ + discovered = set(installed_backends() if discovered is None else discovered) + receipt = matching_receipt(hardware, discovered) if receipt is None: if not probe_missing: return discovered diff --git a/freevideo_engine/launcher/FButton.qml b/freevideo_engine/launcher/FButton.qml index b90f1475..0df36216 100644 --- a/freevideo_engine/launcher/FButton.qml +++ b/freevideo_engine/launcher/FButton.qml @@ -6,6 +6,8 @@ Button { property bool primary: false property bool selected: false property bool danger: false + // A text link: accent text, no frame. + property bool link: false implicitHeight: Math.max(theme.height, contentItem.implicitHeight + 14) implicitWidth: Math.max(80, contentItem.implicitWidth + leftPadding + rightPadding) leftPadding: 16; rightPadding: 16 @@ -16,6 +18,7 @@ Button { contentItem: Text { text: control.text; font: control.font color: control.primary ? theme.bg : control.danger ? theme.danger : control.selected ? theme.accent + : control.link ? (control.hovered ? theme.accentHover : theme.accent) : control.flat && !control.hovered ? theme.muted : theme.text horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter wrapMode: Text.Wrap diff --git a/freevideo_engine/launcher/Main.qml b/freevideo_engine/launcher/Main.qml index a1ddeddd..9bc79264 100644 --- a/freevideo_engine/launcher/Main.qml +++ b/freevideo_engine/launcher/Main.qml @@ -22,6 +22,7 @@ ApplicationWindow { property bool accepted: false property bool manualUpdate: false property bool releaseNotesOpen: false + property bool licensesOpen: false property string previousStatus: "" property string previousReview: "" property bool compact: width < 1000 @@ -41,6 +42,10 @@ ApplicationWindow { function releaseVersion(value) { return value && value.product_version ? "v" + value.product_version : value && value.version || "—" } function releaseSummary(value) { return value && value.release_notes ? value.release_notes[s.zh ? "zh" : "en"].summary : "" } readonly property var currentRelease: s.update.current_release || {version: s.update.current} + readonly property var engineRelease: s.update.engine_release || {} + readonly property string engineBuild: engineRelease.version || s.update.installed || "" + readonly property string engineVersion: releaseVersion({product_version: engineRelease.product_version, version: engineBuild}) + readonly property bool separateVersions: !!currentRelease.product_version && !!engineRelease.product_version && currentRelease.product_version !== engineRelease.product_version // One line for the current step: the installer's text when it has one (a download and its size, // a resumed download, a check), else the step's own detail (keeping the original file), else nothing. readonly property string stepText: s.progress_text || s.detail || "" @@ -86,6 +91,9 @@ ApplicationWindow { readonly property bool diskFailure: !!disk && (s.failure.kind || "").indexOf("disk") === 0 readonly property bool updateOffered: s.update.engine || (!!s.update.candidate && ["available", "downloading", "ready", "error", "cancelled"].indexOf(s.update.status) >= 0) // An engine update needs no download; offer it as the way to launch. + // A card whose way out is the user's own step (open a newer launcher, move a file) marks itself manual: + // retrying first cannot help, so it is not the primary action. + readonly property bool manualFailure: !!s.retry_kind && s.failure.manual === true readonly property bool updateFirst: s.page === "launcher" && s.update.engine && !s.update.phase && !s.busy && s.status !== "open" && s.status !== "restart-required" && !s.needs_consent && !s.repair_offered function percent(row) { return row && row.total ? Math.floor(100 * row.done / row.total) + "%" : "" } readonly property var upgrade: s.model_upgrade || ({}) @@ -171,7 +179,7 @@ ApplicationWindow { if (phase === "checking") return "" if (s.update.status === "error" && s.update.error) return s.update.error if (s.update.candidate) return t("Current version ", "当前版本 ") + releaseVersion(currentRelease) + t(". Updating keeps your models and settings and takes about a minute.", "。更新会保留模型和设置,约需 1 分钟。") - return t("Installed engine ", "已安装引擎 ") + (s.update.installed || "—") + t(". Updating takes about a minute and keeps your models and settings.", "。更新约需 1 分钟,模型和设置都会保留。") + return t("Installed engine ", "已安装引擎 ") + engineVersion + t(". Updating takes about a minute and keeps your models and settings.", "。更新约需 1 分钟,模型和设置都会保留。") } function primaryText() { if (s.retry_kind) return retryText() @@ -255,7 +263,20 @@ ApplicationWindow { } FNav { glyph: "terminal"; text: t("Terminal", "终端"); Layout.fillWidth: true; selected: terminalOpen; onClicked: terminalOpen = !terminalOpen } FNav { objectName: "settingsButton"; glyph: "settings"; text: t("Settings", "设置"); Layout.fillWidth: true; onClicked: settingsOpen = true } - FButton { objectName: "versionInfoButton"; text: releaseVersion(currentRelease); flat: true; font.pixelSize: 11; Layout.topMargin: 12; Accessible.name: t("Version & release notes", "版本与更新说明"); onClicked: releaseNotesOpen = true } + FButton { + id: versionInfoButton; objectName: "versionInfoButton" + text: (separateVersions ? t("Launcher ", "启动器 ") : "") + releaseVersion(currentRelease) + flat: true; font.pixelSize: 11; Layout.topMargin: 12 + Accessible.name: t("Version & release notes", "版本与更新说明") + onClicked: releaseNotesOpen = true + contentItem: ColumnLayout { + spacing: 2 + Text { objectName: "launcherVersionLabel"; text: versionInfoButton.text; font: versionInfoButton.font + color: versionInfoButton.hovered ? theme.text : theme.muted; horizontalAlignment: Text.AlignLeft; Layout.fillWidth: true } + Text { objectName: "engineVersionLabel"; visible: separateVersions; text: t("Engine ", "引擎 ") + engineVersion + font.pixelSize: 10; color: theme.muted; horizontalAlignment: Text.AlignLeft; Layout.fillWidth: true } + } + } } } @@ -367,6 +388,12 @@ ApplicationWindow { id: failureBody; x: 18; y: 18; width: parent.width - 36; spacing: 10 FText { text: s.failure.title || t("Something went wrong", "出现问题"); color: theme.danger; font.weight: Font.DemiBold; Layout.fillWidth: true } FText { visible: !!(s.failure.detail || s.failure.action); text: s.failure.detail || s.failure.action; color: theme.text; Layout.fillWidth: true } + TextEdit { + objectName: "failurePath"; visible: !!s.failure.path; text: s.failure.path || "" + readOnly: true; selectByMouse: true; wrapMode: Text.WrapAnywhere; textFormat: TextEdit.PlainText + color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true + selectionColor: theme.accentDim; selectedTextColor: theme.text + } // Repair sets up again from the launcher's own engine, so it brings the update along. FText { objectName: "repairUpdatesEngine"; visible: !!s.update.with_repair; Layout.fillWidth: true; color: theme.text text: t("Repair also updates the engine to " + releaseVersion(currentRelease) + ".", "修复时会一并更新到新版引擎 " + releaseVersion(currentRelease) + "。") } @@ -380,6 +407,8 @@ ApplicationWindow { FButton { objectName: "diskCleanupButton"; visible: s.failure.kind === "disk"; enabled: s.can_cleanup; text: t("Clean download cache", "清理下载缓存"); onClicked: { settingsTab = "general"; settingsOpen = true; backend.cleanupDownloads(false) } } FButton { visible: s.failure.kind === "download"; text: t("Change source", "切换下载源"); onClicked: { settingsTab = "downloads"; settingsOpen = true } } FButton { objectName: "repairCardButton"; primary: true; visible: !!s.repair_offered; text: t("Repair", "修复"); onClicked: backend.action("repair", false) } + FButton { objectName: "latestLauncherLink"; visible: s.failure.kind === "older-launcher"; flat: true + text: t("Download the latest ↗", "下载最新版 ↗"); onClicked: backend.link("https://github.com/FlashML-org/FreeVideo/releases/latest") } FButton { objectName: "copyError"; visible: !diskFailure; text: t("Copy full details", "复制完整详情"); onClicked: backend.copy(s.error) } FButton { objectName: "showError"; text: errorDetailsOpen ? t("Hide details", "收起详情") : t("Show details", "查看详情"); flat: true; onClicked: errorDetailsOpen = !errorDetailsOpen } FButton { objectName: "exportError"; visible: !diskFailure; text: t("Export report", "导出报告"); enabled: s.report.status !== "running"; onClicked: backend.exportReport() } @@ -655,8 +684,30 @@ ApplicationWindow { FText { text: s.review.engine; font.pixelSize: theme.micro + 1; Layout.fillWidth: true; elide: Text.ElideMiddle; maximumLineCount: 1 } } Rectangle { Layout.fillWidth: true; height: 1; color: theme.border } + ColumnLayout { + objectName: "replaceModels" + visible: s.review.replace_models.length > 0; Layout.fillWidth: true; spacing: 8 + Repeater { + model: s.review.replace_models + delegate: ColumnLayout { + id: replacement + required property int index + required property var modelData + Layout.fillWidth: true; spacing: 4 + FText { + objectName: "replaceModel-" + replacement.index + text: replacement.modelData.text; font.pixelSize: theme.micro + 1; Layout.fillWidth: true + } + FText { + objectName: "replaceModelPath-" + replacement.index + text: replacement.modelData.path; color: theme.muted; font.pixelSize: theme.micro + wrapMode: Text.WrapAnywhere; Layout.fillWidth: true + } + } + } + } FCheck { objectName: "installConsent"; text: s.consent; checked: accepted; onToggled: accepted = checked; Layout.fillWidth: true } - FButton { text: t("Read licenses ↗", "查看许可证 ↗"); flat: true; implicitHeight: theme.heightSm - 2; leftPadding: 32; font.pixelSize: theme.micro + 1; onClicked: backend.link("https://huggingface.co/OpenVDN/vdn-minimax-h3-edge/blob/main/LICENSE") } + FButton { objectName: "planLicensesButton"; text: t("View licenses", "查看许可证"); flat: true; implicitHeight: theme.heightSm - 2; leftPadding: 32; font.pixelSize: theme.micro + 1; onClicked: licensesOpen = true } } } @@ -707,12 +758,12 @@ ApplicationWindow { ColumnLayout { Layout.fillWidth: true; spacing: 2 FText { objectName: "updateHeadline"; text: updateHeadline(); font.weight: Font.DemiBold; Layout.fillWidth: true } - FText { visible: text !== ""; text: updateExplanation(); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } + FText { objectName: "updateExplanation"; visible: text !== ""; text: updateExplanation(); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } } FButton { objectName: "cancelUpdateButton"; visible: s.update.phase === "waiting"; flat: true; text: t("Cancel update", "取消更新"); onClicked: { manualUpdate = false; backend.dismissUpdate() } } FButton { // Repair and opening FreeVideo come first; the banner's colour already marks the update. - id: engineUpdate; objectName: "engineUpdateButton"; primary: !s.repair_offered && s.status !== "open"; visible: !s.update.phase; enabled: !s.busy + id: engineUpdate; objectName: "engineUpdateButton"; primary: !s.repair_offered && s.status !== "open" && !(updateFirst && !s.retry_kind); visible: !s.update.phase; enabled: !s.busy text: s.update.status === "error" ? t("Retry update", "重试更新") : s.update.status === "ready" && s.update.candidate ? t("Restart & update", "重启并更新") : t("Update now", "立即更新") onClicked: { manualUpdate = !!s.update.candidate; backend.update("") } } @@ -817,7 +868,7 @@ ApplicationWindow { FText { objectName: "archivesDone"; visible: !!s.archives_done; text: "✓ " + (s.archives_done || ""); color: theme.success; font.pixelSize: theme.micro; Layout.fillWidth: true } FText { objectName: "archivesFailed"; visible: !!s.archives_failed; text: s.archives_failed || ""; color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } FCheck { visible: s.needs_consent; text: s.consent; checked: accepted; onToggled: accepted = checked; Layout.fillWidth: true } - FButton { visible: s.needs_consent; text: t("Read licenses ↗", "查看许可证 ↗"); flat: true; implicitHeight: theme.heightSm - 2; leftPadding: 32; font.pixelSize: theme.micro + 1; onClicked: backend.link("https://huggingface.co/OpenVDN/vdn-minimax-h3-edge/blob/main/LICENSE") } + FButton { objectName: "bundleLicensesButton"; visible: s.needs_consent; text: t("View licenses", "查看许可证"); flat: true; implicitHeight: theme.heightSm - 2; leftPadding: 32; font.pixelSize: theme.micro + 1; onClicked: licensesOpen = true } } } @@ -836,7 +887,7 @@ ApplicationWindow { // Other retries (an import) start something else, so this one stays. FButton { objectName: "launchInstalledButton"; visible: updateFirst && s.retry_kind !== "launch"; flat: true; text: t("Launch current version", "启动当前版本"); onClicked: backend.action("primary", accepted) } FButton { - objectName: "primaryButton"; primary: !s.repair_offered && !(s.retry_kind === "launch" && engineUpdate.visible); implicitWidth: Math.max(160, contentItem.implicitWidth+40); implicitHeight: theme.heightLg + objectName: "primaryButton"; primary: !s.repair_offered && !(s.retry_kind === "launch" && engineUpdate.visible) && !manualFailure; implicitWidth: Math.max(160, contentItem.implicitWidth+40); implicitHeight: theme.heightLg text: s.busy ? t("Working…", "正在处理…") : primaryText() enabled: !s.busy && !(s.page === "launcher" && s.needs_consent && !accepted) && !(s.page === "progress" && s.status === "review" && !s.retry_kind && (!accepted || !!s.error)) @@ -1091,6 +1142,12 @@ ApplicationWindow { ColumnLayout { id: settingsError; x: 14; y: 14; width: parent.width - 28; spacing: 8 FText { text: s.failure.title + "\n" + (s.failure.action || s.failure.detail); color: theme.danger; font.pixelSize: theme.micro + 1; Layout.fillWidth: true } + TextEdit { + objectName: "settingsFailurePath"; visible: !!s.failure.path; text: s.failure.path || "" + readOnly: true; selectByMouse: true; wrapMode: Text.WrapAnywhere; textFormat: TextEdit.PlainText + color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true + selectionColor: theme.accentDim; selectedTextColor: theme.text + } Flow { Layout.fillWidth: true; spacing: 8 FButton { text: t("Copy full details", "复制完整详情"); implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1; onClicked: backend.copy(s.error) } @@ -1199,7 +1256,7 @@ ApplicationWindow { id: updateContents; width: updateScroll.availableWidth spacing: 14 FText { text: s.update.candidate ? t("Update available", "有可用更新") : s.update.engine ? t("Engine update", "引擎更新") : t("Updates", "更新"); font.pixelSize: theme.section + 2; font.weight: Font.DemiBold } - FText { objectName: "updateDialogText"; text: s.update.phase === "waiting" ? t("A video is still generating. FreeVideo restarts and updates as soon as it finishes.", "还有视频正在生成,完成后会自动重启并更新。") : s.update.status === "ready" && s.update.candidate ? t("Downloaded. Restart to finish; models and settings are kept.", "下载完成,重启即可完成更新,模型和设置都会保留。") : s.update.candidate ? t("FreeVideo ", "FreeVideo ") + releaseVersion(s.update.candidate) + t(" is available (current ", " 已发布(当前 ") + releaseVersion(currentRelease) + t("). Updating keeps your models and settings. FreeVideo restarts after the download; running videos finish first.", ")。更新会保留模型和设置,下载完成后自动重启;正在生成的视频会先完成。") : s.update.engine ? t("This launcher already includes engine ", "启动器已带有新版引擎 ") + releaseVersion(currentRelease) + t(" (installed ", "(已安装 ") + (s.update.installed || "—") + (s.status === "open" ? t("). Updating takes about a minute, restarts ComfyUI once and keeps your models and settings.", ")。更新约需 1 分钟,会重启一次 ComfyUI,模型和设置都会保留。") : t("). Updating takes about a minute, then FreeVideo starts; models and settings are kept.", ")。更新约需 1 分钟,完成后自动启动,模型和设置都会保留。")) : s.update.status === "current" ? t("You're up to date.", "已是最新版本。") : s.update.status === "development" ? t("Running from source. Update with Git.", "当前从源码运行,请通过 Git 更新。") : s.update.status === "error" ? t("Couldn't check for updates. Try again below.", "暂时无法检查更新,请重试。") : t("Checking the latest release…", "正在检查最新版本…"); color: theme.muted; Layout.fillWidth: true } + FText { objectName: "updateDialogText"; text: s.update.phase === "waiting" ? t("A video is still generating. FreeVideo restarts and updates as soon as it finishes.", "还有视频正在生成,完成后会自动重启并更新。") : s.update.status === "ready" && s.update.candidate ? t("Downloaded. Restart to finish; models and settings are kept.", "下载完成,重启即可完成更新,模型和设置都会保留。") : s.update.candidate ? t("FreeVideo ", "FreeVideo ") + releaseVersion(s.update.candidate) + t(" is available (current ", " 已发布(当前 ") + releaseVersion(currentRelease) + t("). Updating keeps your models and settings. FreeVideo restarts after the download; running videos finish first.", ")。更新会保留模型和设置,下载完成后自动重启;正在生成的视频会先完成。") : s.update.engine ? t("This launcher already includes engine ", "启动器已带有新版引擎 ") + releaseVersion(currentRelease) + t(" (installed: ", "(已安装 ") + engineVersion + (s.status === "open" ? t("). Updating takes about a minute, restarts ComfyUI once and keeps your models and settings.", ")。更新约需 1 分钟,会重启一次 ComfyUI,模型和设置都会保留。") : t("). Updating takes about a minute, then FreeVideo starts; models and settings are kept.", ")。更新约需 1 分钟,完成后自动启动,模型和设置都会保留。")) : s.update.status === "current" ? t("You're up to date.", "已是最新版本。") : s.update.status === "development" ? t("Running from source. Update with Git.", "当前从源码运行,请通过 Git 更新。") : s.update.status === "error" ? t("Couldn't check for updates. Try again below.", "暂时无法检查更新,请重试。") : t("Checking the latest release…", "正在检查最新版本…"); color: theme.muted; Layout.fillWidth: true } FMeter { Layout.fillWidth: true; visible: s.update.status === "downloading"; active: true; fraction: s.update.progress && s.update.progress.total ? s.update.progress.done/s.update.progress.total : -1 } FReleaseNotes { objectName: "updateReleaseNotes"; visible: !!availableRelease; Layout.fillWidth: true; release: availableRelease; earlier: availableEarlier; zh: s.zh; heading: t("What's new", "更新内容") + " · " + releaseVersion(availableRelease) } FButton { text: t("Version & release notes", "版本与更新说明"); flat: true; onClicked: releaseNotesOpen = true } @@ -1216,6 +1273,74 @@ ApplicationWindow { } } + // The licences a consent accepts, one row each, opened from "View licenses". + FPopup { + objectName: "licensesDialog"; visible: licensesOpen; onClosed: licensesOpen = false + width: Math.min(520, win.width - 40); closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + height: Math.min(licenseContents.implicitHeight + padding * 2 + 60, win.height - 48) + contentItem: ColumnLayout { + spacing: 14 + ScrollView { + id: licenseScroll; objectName: "licensesScroll"; Layout.fillWidth: true; Layout.fillHeight: true; clip: true; contentWidth: availableWidth + // Shown whenever the list runs past the window, so a row below the last divider is not missed. + readonly property bool overflows: height > 0 && licenseContents.implicitHeight > height + ScrollBar.vertical.policy: overflows ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff + ColumnLayout { + // Clear of the scroll bar, so it never covers a link. + id: licenseContents; width: licenseScroll.availableWidth - (licenseScroll.overflows ? 14 : 0); spacing: 12 + FText { text: t("Licenses", "许可证"); font.pixelSize: theme.section + 2; font.weight: Font.DemiBold; Layout.fillWidth: true; Layout.bottomMargin: 4 } + Repeater { + // Indexed, so a status refresh with an equal list keeps the rows (and their hover). + model: (s.licenses || []).length + delegate: ColumnLayout { + required property int index + readonly property var row: s.licenses[index] || ({}) + objectName: "license-" + (row.key || index) + Layout.fillWidth: true; spacing: 12 + FDivider { visible: index > 0 } + RowLayout { + Layout.fillWidth: true; spacing: 12 + ColumnLayout { + Layout.fillWidth: true; spacing: 2 + FText { id: licenseName; text: row.name || ""; font.pixelSize: theme.body - 1; font.weight: Font.DemiBold; Layout.fillWidth: true } + FText { text: row.license || ""; color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } + FText { objectName: "licenseTerms"; visible: !!row.terms; text: row.terms || ""; color: theme.muted; font.pixelSize: theme.micro - 1; Layout.fillWidth: true; Layout.topMargin: 2 } + } + FButton { + objectName: "licenseLink"; link: true; flat: true + text: row.file ? t("Open ↗", "打开 ↗") : t("View ↗", "查看 ↗") + Accessible.name: (row.file ? t("Open ", "打开 ") : t("View ", "查看 ")) + (row.name || "") + implicitHeight: theme.heightSm - 2; leftPadding: 8; rightPadding: 8 + implicitWidth: contentItem.implicitWidth + leftPadding + rightPadding; Layout.rightMargin: -8 + // Level with the component's name, however many lines follow it. + Layout.alignment: Qt.AlignTop + Layout.topMargin: Math.round((licenseName.implicitHeight / Math.max(1, licenseName.lineCount) - implicitHeight) / 2) + font.pixelSize: theme.micro + 1; onClicked: backend.link(row.url) + onActiveFocusChanged: { + if (activeFocus) { + const flick = licenseScroll.contentItem + const top = mapToItem(flick.contentItem, 0, 0).y + let y = flick.contentY + if (top < y) y = top + else if (top + height > y + flick.height) y = top + height - flick.height + else return + flick.contentY = Math.max(0, Math.min(y, flick.contentHeight - flick.height)) + } + } + } + } + } + } + } + } + RowLayout { + Layout.fillWidth: true + Item { Layout.fillWidth: true } + FButton { objectName: "closeLicensesButton"; text: t("Close", "关闭"); onClicked: licensesOpen = false } + } + } + } + FPopup { objectName: "releaseNotesDialog"; visible: releaseNotesOpen; onClosed: releaseNotesOpen = false width: Math.min(560, win.width - 40) @@ -1230,8 +1355,12 @@ ApplicationWindow { FText { text: t("Version & release notes", "版本与更新说明"); font.pixelSize: theme.section + 2; font.weight: Font.DemiBold; Layout.fillWidth: true } FReleaseNotes { visible: !!s.update.candidate; Layout.fillWidth: true; release: s.update.candidate; earlier: s.update.candidate_earlier || []; zh: s.zh; heading: t("Available update", "可用更新") + " · " + releaseVersion(s.update.candidate) } FDivider { visible: !!s.update.candidate } - FReleaseNotes { objectName: "currentReleaseNotes"; Layout.fillWidth: true; release: currentRelease; zh: s.zh; heading: t("Current version", "当前版本") + " · " + releaseVersion(currentRelease) } - FText { visible: !!s.update.installed; text: t("Installed engine build: ", "已安装引擎构建号:") + (s.update.installed || ""); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } + FReleaseNotes { objectName: "currentReleaseNotes"; Layout.fillWidth: true; release: currentRelease; zh: s.zh; heading: (separateVersions ? t("Launcher", "启动器") : t("Current version", "当前版本")) + " · " + releaseVersion(currentRelease) } + FText { objectName: "engineReleaseInfo"; visible: !!engineRelease.product_version || !!engineBuild + text: t("Engine in use: ", "正在使用的引擎:") + (engineRelease.product_version + ? engineVersion + (engineBuild ? t(" (build ", "(构建号 ") + engineBuild + t(")", ")") : "") + : t("build ", "构建号 ") + engineBuild) + color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } } } FButton { objectName: "closeReleaseNotesButton"; text: t("Close", "关闭"); onClicked: releaseNotesOpen = false } diff --git a/freevideo_engine/launcher_copy.py b/freevideo_engine/launcher_copy.py index 42297ebb..7878030a 100644 --- a/freevideo_engine/launcher_copy.py +++ b/freevideo_engine/launcher_copy.py @@ -2,6 +2,7 @@ import re from .terminal_ui import clean +from .model_repair import PROGRESS as MODEL_REPAIR_PROGRESS from .model_status import NAMES @@ -148,6 +149,7 @@ def display(value, zh=False): if ' · ' in value: return ' · '.join(display(part, True) for part in value.split(' · ')) for prefix, translated in ( + MODEL_REPAIR_PROGRESS, ('Switched current download to ', '已切换至 '), ('Connecting to ', '正在连接 '), ('Assemble downloaded parts', '合并已下载文件'), ('Reuse verified FP8 ', '复用模型缓存 '), ('Prepare FP8 ', '准备模型缓存 '), ('Verified ', '已校验 '), ('Verify ', '正在校验 '), diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index bd018abb..41cb23b7 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -30,6 +30,8 @@ class Session: + # A launcher started from an offline package (portable_launcher.PortableSession). + bundled = False # Update flow defaults; __init__ starts each launcher session from these. update_snoozed = {} update_intent = None @@ -43,7 +45,7 @@ class Session: bridge_written = (0., None) update_checked = 0. page = 'comfy' - selected = installed_versions = None + selected = installed_versions = engine_identity = bundled_identity = None release_details = None # The source whose earlier copies were removed once ComfyUI ran it. old_versions_retired = None @@ -79,6 +81,7 @@ def __init__(self, source=None, *, controller=None, store=None, updater=None, sm self.form['new_comfy'] = False self.language = saved['language'] if 'language' in saved else default_language() self.selected = saved.get('installation') + self.repair_request = None self.saved_setup = saved.get('setup', {}) self.page = 'comfy' self.token = '' @@ -185,6 +188,7 @@ def edit(self, key, value): raise ValueError('Unknown model download method') if self.form[key] == value: return + self.repair_request = None self.form[key] = value if key in ('new_comfy', 'destination', 'comfy'): # A remembered installation's engine belongs to that installation; @@ -239,6 +243,7 @@ def import_packages(self, paths): return if not paths: return + self.repair_request = None destination = (Path(self.form['destination']).expanduser().resolve() if self.form['new_comfy'] else self.engine_root().parent) self.importer.start(paths, destination) @@ -261,9 +266,10 @@ def activate_offline(self, root): from .offline_packages import check_runtime_platform check_runtime_platform() from .portable_launcher import PortableController - controller = PortableController(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.remember(controller.selection) @@ -289,14 +295,20 @@ def action(self, name, accepted=False): self.controller.cancel() return self.error = '' + if name in ('setup', 'launcher', 'launch', 'update-engine'): + self.repair_request = None if name == 'repair': # One click instead of Settings › repair switch › Installation › Check: # plan a repair of this installation. The review installs it, and # setup first stops this launcher's own idle ComfyUI. if not self.can_repair(): return + # Restore the selected target before freezing this session's repair; + # the editable setup form may currently describe another installation. + self.remember(self.selected, persist=False) + self.repair_request = dict(self.form, repair=True) self.persist() - self.controller.run('inspect', dict(self.form, token=self.token, repair=True)) + self.controller.run('inspect', dict(self.repair_request, token=self.token)) self.page = 'progress' self.model_groups = [] self.started = time.monotonic() @@ -316,7 +328,7 @@ def action(self, name, accepted=False): # Recompute the plan and live disk space; never install a stale # failed review or discard completed/resumable downloads. self.persist() - self.controller.run('inspect', dict(self.form, token=self.token)) + self.controller.run('inspect', dict(self.repair_request or self.form, token=self.token)) self.page = 'progress' self.started = time.monotonic() return @@ -340,6 +352,13 @@ def action(self, name, accepted=False): self.page = 'comfy' if self.page == 'models' else 'models' return if name == 'browser': + if self._host_repair_waiting(): + # Finish a repair interrupted earlier before opening the page. + # Connect stops this launcher's idle ComfyUI first and leaves a + # running job alone; the page opens when it is done. + self.browser_attempted = False + self.browser_error = '' + self.controller.run('connect'); return self.open_browser(); return if name == 'delete_archives': self.delete_archives(); return @@ -362,6 +381,8 @@ def action(self, name, accepted=False): self.browser_attempted = False self.port_notice_url = None self.browser_error = '' + 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' if not consent.exists(): @@ -380,7 +401,7 @@ def action(self, name, accepted=False): self.controller.run('connect') else: self.persist() - self.controller.run('inspect', dict(self.form, token=self.token)) + self.controller.run('inspect', dict(self.repair_request or self.form, token=self.token)) self.page = 'progress' self.model_groups = [] self.started = time.monotonic() @@ -391,6 +412,13 @@ def can_repair(self): and not (self.controller.busy or self.importer.busy or self.cleaner.busy or self.closing or self.engine_updating or self.upgrade.active or self.model_switch)) + def repair_unavailable(self): + """Why Repair is missing: it appears once a task ends, never in an offline + environment, and needs an installed engine (WITHOUT_REPAIR's reasons).""" + if getattr(self.controller, 'fixed_environment', False) is True: + return 'bundled' if self.bundled else 'offline' + return 'busy' if self.selected else 'none' + def repairs_comfy(self): """ComfyUI runs in the environment this launcher made, so Repair reinstalls its packages.""" selected = self.controller.selection or self.selected @@ -505,6 +533,7 @@ def _tick_model_upgrade(self, row, busy): upgrade.waiting_for('comfy') return self.model_switch = 'inspect' + self.repair_request = None upgrade.switching() self.controller.run('inspect', dict(self.form, token=self.token, repair=True, prepared_format='int8')) self.page = 'progress' @@ -546,6 +575,13 @@ def _browser_url(self, address): return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment)) + def _host_repair_waiting(self): + """A repair of the ComfyUI environment this launcher made and runs, not finished yet.""" + from .comfy_launcher_runtime import host_repair_pending + selected = self.controller.selection or {} + return (not self.controller.busy and self.controller.owns_server() and bool(selected.get('python')) + and managed_frontend(selected) and host_repair_pending(selected['python'])) + def open_browser(self): if self.controller.state.get('status') != 'open': return @@ -649,9 +685,11 @@ def engine_update_pending(self): An older launcher opened later would otherwise offer a downgrade.""" if not (self.selected and self.controller.state.get('engine_update_available')): return False - bundled = self.source_stamp(self.source)[1] - installed = self.source_stamp(self.selected.get('source'))[1] - return not (bundled and installed and installed > bundled) + # The order the installation check applies: an offered update is never refused there. + from .release_notes import older + self.bundled_identity = self.stamped(self.source, self.bundled_identity) + self.engine_identity = self.stamped((self.selected or {}).get('source'), self.engine_identity) + return not older(self.bundled_identity[1], self.engine_identity[1]) def update_key(self): """The current reminder: a newer launcher first, then the bundled engine.""" @@ -690,6 +728,7 @@ def update(self, token='', source='launcher'): if source == 'browser': self.bridge_pending = True return + self.repair_request = None if self.updater and token.strip(): self.updater.token = token.strip() self.update_source = source @@ -796,6 +835,7 @@ def _update_engine_when_idle(self): self.update_waiting = bool(busy) if busy is not False: return + self.repair_request = None self.update_intent = None self.engine_updating = self.engine_autoinstall = True # Open pages reload themselves after the restart; without any, open @@ -916,6 +956,26 @@ def source_stamp(self, source): def installed_version(self): return self.source_stamp((self.selected or {}).get('source'))[0] + @staticmethod + def stamped(source, cached): + """(key, stamp) of an engine source, read again only when its stamp file changes.""" + if not source: + return (None, {}) + try: + changed = (Path(source) / 'freevideo_engine' / 'build-identity.json').stat().st_mtime_ns + except OSError: + changed = None + key = (str(source), changed) + if cached is not None and cached[0] == key: + return cached + from .desktop_runtime import source_release + return (key, source_release(source)) + + def engine_release(self): + """The stamp of the engine the selected installation runs.""" + self.engine_identity = self.stamped((self.selected or {}).get('source'), self.engine_identity) + return self.engine_identity[1] + def installed_product_version(self): """The product version of the engine the selected installation runs, if it says.""" source = (self.selected or {}).get('source') @@ -935,6 +995,7 @@ def update_view(self): key = self.update_key() view.update(engine=self.engine_update_pending(), current=__version__, current_release=public_details(self.updater.current) if self.updater else self.release_details, + engine_release=self.engine_release(), installed=self.installed_version(), phase=self.update_phase(), key=key, channel=self.updater.current.get('channel') if self.updater else None, track=self.updater.current.get('track', 'stable') if self.updater else 'stable', @@ -973,6 +1034,7 @@ def _write_bridge_status(self, force=False): pass def close(self): + self.repair_request = None self.importer.cancelled.set() if getattr(self, '_closed', False): return @@ -1047,6 +1109,7 @@ def tick(self): self.model_groups = groups selection = row.get('selection') if not busy and selection and selection.get('ready') and (row.get('deployed') or row.get('status') in ('open', 'restart-required')): + self.repair_request = None if selection != self.selected: self.remember(selection) if row.get('action') == 'install' or row.get('status') in ('open', 'restart-required'): @@ -1188,6 +1251,17 @@ def archives_failed_text(self): return self.t('1 ZIP file could not be deleted.' if failed == 1 else '%d ZIP files could not be deleted.' % failed, '%d 个压缩包没能删除。' % failed) + def licenses(self, row): + """What the consent accepts, listed when the user reads the licences: an offline runtime's, or the installation plan's.""" + from .component_licenses import MODEL, is_file, named + if getattr(self.controller, 'fixed_environment', False) is True: + rows = self.controller.licenses + else: + rows = [named(url) for url in row.get('plan', {}).get('licenses') or ()] + return [dict(key=value['key'], name=self.t(*value['name']), license=self.t(*value['license']), + terms=self.t(*value['terms']) if value['terms'] else '', url=value['link'], file=is_file(value['link'])) + for value in rows or [MODEL]] + def snapshot(self): from .failure_details import REPAIR_KINDS, launcher_failure, redacted_launcher_error from .launcher_copy import display, progress_view, source_name @@ -1216,7 +1290,8 @@ def snapshot(self): shortcut = row.get('shortcut') or {} if shortcut.get('status') == 'failed': errors.append(shortcut.get('error_zh' if zh else 'error') or shortcut.get('error', '')) - error = redacted_launcher_error('\n'.join(str(e) for e in errors if e), + raw_error = '\n'.join(str(e) for e in errors if e) + error = redacted_launcher_error(raw_error, [(self.token, '')] if self.token else []) by_id = {r['id']: r for r in self.model_groups} ready = bool(row.get('selection', {}).get('ready') or row.get('status') == 'open') @@ -1271,6 +1346,8 @@ def snapshot(self): preferences = dict(source='auto', proxy_mode='auto') source = preferences['source'] plan = row.get('plan', {}) + from .model_repair import review_lines + replace_models = review_lines(plan.get('replace_models', []), zh=zh) estimate = [] if plan.get('disk_mode') == 'extreme': estimate.append(self.t('Space saver (automatic)', '极限省空间(自动启用)')) @@ -1297,7 +1374,14 @@ def snapshot(self): ok=entry.get('ok', False), rate=speed_text(entry, self.language.startswith('zh')))) from .sampling_assets import total_bytes failure = launcher_failure(error, zh=self.language.startswith('zh'), can_repair=self.can_repair(), - repairs_comfy=self.repairs_comfy()) + repairs_comfy=self.repairs_comfy(), unavailable=self.repair_unavailable()) + if failure.get('path'): + # The local repair instruction needs the actual file location; copied details stay redacted. + from .model_repair import MISMATCH_PATH + for line in reversed(raw_error.splitlines()): + if line.startswith(MISMATCH_PATH + ': ') and line[len(MISMATCH_PATH) + 2:]: + failure['path'] = line[len(MISMATCH_PATH) + 2:] + break disk = self.disk_view(row, failure) if disk and str(failure.get('kind', '')).startswith('disk'): failure = launcher_failure(error, zh=self.language.startswith('zh'), @@ -1344,7 +1428,8 @@ def snapshot(self): settings_path=str(self.store.primary), review_id=row.get('plan', {}).get('plan_id', ''), consent=self.t('I accept the installation plan and model / toolkit licenses.', '我同意安装计划及模型/工具包许可证。'), - portable=False, needs_consent=bool(getattr(self.controller, 'fixed_environment', False) is True + 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()), 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'])))) diff --git a/freevideo_engine/launcher_update.py b/freevideo_engine/launcher_update.py index 86a42c4d..839a599c 100644 --- a/freevideo_engine/launcher_update.py +++ b/freevideo_engine/launcher_update.py @@ -107,8 +107,12 @@ def manifest(value): def newer(candidate, current): + from .release_notes import older return (build_target(candidate) == build_target(current) - and candidate['channel'] == current['channel'] and build_track(candidate) == build_track(current) + and candidate.get('channel', CHANNEL) == current.get('channel', CHANNEL) + and build_track(candidate) == build_track(current) + and not older({'product_version': candidate.get('product_version')}, + {'product_version': current.get('product_version')}) and candidate['revision'] != current['revision'] and candidate['built_at'] > current['built_at']) diff --git a/freevideo_engine/licenses/runtime/Intel-EULA-Developer-Tools.txt b/freevideo_engine/licenses/runtime/Intel-EULA-Developer-Tools.txt new file mode 100644 index 00000000..2a254d75 --- /dev/null +++ b/freevideo_engine/licenses/runtime/Intel-EULA-Developer-Tools.txt @@ -0,0 +1,122 @@ +Intel End User License Agreement for Developer Tools (Version August 2024). Source: LICENSE.txt in the intel-openmp 2026.0.0 wheel, https://files.pythonhosted.org/packages/2f/48/32b1c24d3f1e4f47495d29bda38002384ae03a9ab709b575dda31842c0a4/intel_openmp-2026.0.0-py2.py3-none-win_amd64.whl + +Intel End User License Agreement for Developer Tools (Version August 2024) + +IMPORTANT NOTICE – PLEASE READ AND AGREE BEFORE DOWNLOADING, INSTALLING, COPYING OR USING + +This Agreement is between you, or the company or other legal entity that you represent and warrant you have the legal authority to bind, (each, “You” or “Your”) and Intel Corporation and its subsidiaries (collectively, “Intel”) regarding Your use of the Materials. By downloading, installing, copying or using the Materials, You agree to be bound by the terms of this Agreement. If You do not agree to the terms of this Agreement, or do not have legal authority or required age to agree to them, do not download, install, copy or use the Materials. + +1. LICENSE DEFINITIONS. + +A. “AI” means computer or machine technology that functions to simulate human intelligence, cognition, and problem-solving capabilities. + +B. “Cloud Provider” means a third party service provider offering a cloud-based platform, infrastructure, application or storage services, such as Microsoft Azure, Google Cloud or Amazon Web Services, which You may only utilize to host the Materials subject to the restrictions set forth in Section 2.3 B. + +C. “Derivative Work” means a derivative work, as defined in 17 U.S.C. § 101, of the Materials in Source Code. + +D. “Executable Code” means software in binary form suitable for machine execution by a processor without the intervening steps of interpretation or compilation. + +E. “GAI” means AI that functions to generate content based on inputs. + +F. "Materials" mean the software, documentation, and other collateral, including any updates, made available to You by Intel under this Agreement. Materials include Executable Code, Source Code, Sample Source Code, and Redistributables, and Pre-Release Materials, but do not include Third Party Software. + +G. “Pre-Release Materials” mean the Materials that are identified (in the product release notes, on Intel’s download website for the Materials or elsewhere) or labeled as pre-release, prototype, alpha or beta software (i) which may not be fully functional or tested and may contain bugs or errors; (ii) which Intel may substantially modify in its development of a production version; or (iii) for which Intel makes no assurances that it will ever develop or make a production version generally available. Pre-Release Materials are subject to the terms of Section 3.2 and not Section 2.1. + +H. “Reciprocal Open Source Software” means any software that is subject to a license which requires that (i) it must be distributed in Source Code form; (ii) it must be licensed under the same open source license terms; and (iii) its derivative works must be licensed under the same open source license terms. Examples of this type of license are the GNU General Public License or the Mozilla Public License. + +I. "Redistributables" mean the files (if any) listed in the “redist.txt,” “redist-rt.txt” or similarly-named text files that may be included in the Materials. Redistributables include Sample Source Code. + +J. “Sample Source Code” means the software portion of the Materials that are provided in Source Code and are identified as sample code or example code. Sample Source Code may not have been tested or validated by Intel and is provided purely as a programming example. + +K. “Source Code” means software in human readable format. + +L. “Third Party Software” mean the files (if any) listed in the “third-party-software.txt” or other similarly-named text file that may be included in the Materials for the applicable software. Third Party Software is subject to the terms of Section 2.2. + +M. “Your Product” means one or more applications, products or projects developed by or for You using the Materials. + +2. LICENSE GRANTS. + +2.1 License to the Materials. Subject to the terms and conditions of this Agreement, Intel grants You a non-exclusive, worldwide, non-assignable, non-sublicensable, limited right and license under its copyrights, to: + +A. reproduce internally a reasonable number of copies of the Materials for Your personal or business use; + +B. use the Materials solely for Your personal or business use to develop Your Product, in accordance with the documentation included as part of the Materials; + +C. modify or create Derivative Works of the Redistributables provided to You in Source Code; + +D. distribute (directly and through Your distributors, resellers, and other channel partners, if applicable), the Redistributables, including any modifications to or Derivative Works of the Redistributables made pursuant to Section 2.1.C, subject to the following conditions: + +(1) Any distribution of the Redistributables must only be as part of Your Product; + +(2) Any Redistributables provided to You by Intel in Executable Code can only be distributed (i) in Executable Code, and (ii) subject to a license agreement that prohibits reverse engineering, decompiling or disassembly of those Redistributables; + +(3) This distribution right includes a limited right to sublicense only the Intel copyrights in the Redistributables and only to the extent necessary to perform, display, and distribute the Redistributables (including Your modifications and Derivative Works of the Redistributables provided in Source Code) solely as incorporated in Your Product; and + +(4) You: (i) will be solely responsible to Your customers for any update, support obligation or other obligation or liability which may arise from the distribution of Your Product, (ii) will not make any statement that Your Product is "certified" or that its performance is guaranteed by Intel or its suppliers, (iii) will not use Intel's or its suppliers’ names or trademarks to market Your Product, (iv) will comply with any additional restrictions which are included in the text files with the Redistributables and in Section 3 below, (v) will indemnify, hold harmless, and defend Intel and its suppliers from and against any claims or lawsuits, costs, damages, and expenses, including attorney fees, that arise or result from (a) Your modifications or Derivative Works of the Materials or (b) Your distribution of Your Product. + +2.2 Third Party Software. Third Party Software, even if included with the distribution of the Materials, may be governed by separate license terms, including without limitation, third party license terms, open source software notices and terms, and/or other Intel software license terms. These separate license terms solely govern Your use of the Third Party Software. + +2.3 Third Party Use. + +A. If You are an entity, Your contractors may use the Materials under the license specified in Section 2, provided: (i) their use of the Materials is solely on behalf of and in support of Your business, (ii) they agree to the terms and conditions of this Agreement, and (iii) You are solely responsible for their use, misuse or disclosure of the Materials. + +B. You may utilize a Cloud Provider to host the Materials for You, provided: (i) the Cloud Provider may only host the Materials for Your exclusive use and may not use the Materials for any other purpose whatsoever, including the restriction set forth in Section 3.1(xi); (ii) the Cloud Provider’s use of the Materials must be solely on behalf of and in support of Your Product, and (iii) You will indemnify, hold harmless, and defend Intel and its suppliers from and against any claims or lawsuits, costs, damages, and expenses, including attorneys’ fees, that arise or result from Your Cloud Provider’s use, misuse or disclosure of the Materials. + +3. LICENSE CONDITIONS. + +3.1 Restrictions. Except as expressly provided in this Agreement, You may NOT: (i) use, reproduce, disclose, distribute, or publicly display the Materials; (ii) share, publish, rent or lease the Materials to any third party; (iii) assign this Agreement or transfer the Materials; (iv) modify, adapt, alter, or translate the Materials in whole or in part, including during execution; (v) reverse engineer, decompile, or disassemble the Materials, or otherwise attempt to derive the Source Code or operation of the Materials; (vi) work around any technical limitations in the Materials; (vii) distribute, sublicense or transfer any Materials in Source Code, or modifications or Derivative Works of any Materials in Source Code, to any third party; (viii) remove, minimize, block or modify any notices of Intel or its suppliers in the Materials; (ix) include the Redistributables in malicious, deceptive, or unlawful programs or products or use the Materials in any way that is against the law; (x) modify, create a Derivative Work, link, or distribute the Materials so that any part of it becomes Reciprocal Open Source Software; (xi) use the Materials directly or indirectly for SaaS services or service bureau purposes (i.e., a service that allows use of or access to the Materials by a third party as part of that service, such as the salesforce.com service business model). + +3.2 Pre-Release Materials. If You receive Pre-Release Materials, You may reproduce a reasonable number of copies and use the Pre-Release Materials for evaluation and testing purposes only. You may NOT (i) modify or incorporate the Pre-Release Materials into Your Product; (ii) continue to use the Pre-Release Materials once a production version is released; or (iii) disclose to any third party any benchmarks, performance results, or other information relating to the Pre-Release Materials. Intel may waive these restrictions in writing at its sole discretion; however, if You decide to use the Pre-Release Materials in Your Product (even with Intel’s waiver), You acknowledge and agree that You are fully responsible for any and all issues that result from such use. + +3.3 Safety-Critical, and Life-Saving Applications; Indemnity. The Materials may provide information relevant to safety-critical applications (“Safety-Critical Applications”) to allow compliance with functional safety standards or requirements. You acknowledge and agree that safety is Your responsibility. To the extent You use the Materials to create, or as part of, products used in Safety-Critical Applications, it is Your responsibility to design, manage, and ensure that there are system-level safeguards to anticipate, monitor, and control system failures, and You agree that You are solely responsible for all applicable regulatory standards and safety-related requirements concerning Your use of the Materials in Safety Critical Applications.  + +Should You use the Materials for Safety-Critical Applications or in any type of a system or application in which the failure of the Materials could create a situation where personal injury or death may occur (e.g., medical systems, life-sustaining or life-saving systems) (“Life-Saving Applications”), You agree to indemnify, defend, and hold Intel and its representatives harmless against any claims or lawsuits, costs, damages, and expenses, including reasonable attorney fees, arising in any way out of Your use of the Materials in Safety-Critical Applications or Life-Saving Applications and claims of product liability, personal injury or death associated with those applications; even if such claims allege that Intel was negligent or strictly liable regarding the design or manufacture of the Materials or its failure to warn regarding the Materials. + +3.4 Media Format Codecs and Digital Rights Management. You acknowledge and agree that Your use of the Materials or distribution of the Redistributables with Your Product as permitted by this Agreement may require You to procure license(s) from third parties that may hold intellectual property rights applicable to any media decoding, encoding or transcoding technology (e.g., the use of an audio or video codec) and/or digital rights management capabilities of the Materials, if any. Should any such additional licenses be required, You are solely responsible for obtaining any such licenses and agree to obtain any such licenses at Your own expense. + +3.5 Use of GAI and AI. +A. Use of GAI in Developing the Materials. Some Materials may have been developed using GAI. Intel’s use of GAI, if any, in the development of Materials complied with the then-current industry practices and laws applicable to the use of GAI at the time of development. +B. AI Features in the Materials. Some Materials may include or implement components, features, or functionality of AI technologies (“AI Features”). You can find more information about included AI Features in documentation accompanying the Materials. While Intel strives to ensure the quality, accuracy, and reliability of included AI Features, due to the nature of AI, such features and their output may contain inaccuracies, biases, and omissions. You are solely responsible for determining the fitness of the AI Features and their output for Your use cases, and for evaluating the accuracy and completeness of the output. Intel makes no warranty or representation as to the completeness, accuracy, or reliability of the AI Features, their output, or their suitability for any purpose. +C. Using the Materials to Develop Your AI Applications. You may not use the Materials to develop an AI application (“Your AI Application”) if the documentation accompanying the Materials prohibits such use. You can only use the Materials to develop Your AI Application in the absence of such prohibition and in accordance with standard industry practice and all applicable laws and regulations, and Your AI Application must not violate, or contribute to, the violation of an internationally recognized human right. +D. GAI and AI Features in Third Party Software. Some Third Party Software may have been developed using GAI or may include or implement components, features, or functionality of AI Features. Intel does not take any responsibility for Third Party Software, how it may have been developed using GAI or any AI Features therein. You can find more information about Third Party Software use of GAI and AI Features in the documentation accompanying the Third Party Software. + +4. DATA COLLECTION AND PRIVACY. + +4.1 Data Collection. The Materials may collect anonymous data and/or optional data (which may include other anonymous and/or non-anonymous data) about the Materials and/or Your development environment and transmit that data to Intel; however, optional data will not be transmitted to Intel without Your permission. All data collection by Intel is performed pursuant to relevant privacy laws, including notice and consent requirements. + +4.2 Intel’s Privacy Notice. Intel is committed to respecting Your privacy. To learn more about Intel’s privacy practices, please visit http://www.intel.com/privacy. + +5. OWNERSHIP. Title to the Materials and all copies remain with Intel or its suppliers. The Materials are protected by intellectual property rights, including without limitation, United States copyright laws and international treaty provisions. You will not remove any copyright or other proprietary notices from the Materials. Except as expressly provided herein, no license or right is granted to You directly or by implication, inducement, estoppel or otherwise; specifically, Intel does not grant any express or implied right to You under Intel patents, copyrights, trademarks, or trade secrets. + +6. NO WARRANTY AND NO SUPPORT. + +6.1 No Warranty. Disclaimer. Intel disclaims all warranties of any kind and the terms and remedies provided in this Agreement are instead of any other warranty or condition, express, implied or statutory, including those regarding merchantability, fitness for any particular purpose, non-infringement or any warranty arising out of any course of dealing, usage of trade, proposal, specification or sample. Intel does not assume (and does not authorize any person to assume on its behalf) any liability. + +6.2 No Support; Priority Support. Intel may make changes to the Materials, or to items referenced therein, at any time without notice, but is not obligated to support, update or provide training for the Materials under the terms of this Agreement. Intel offers free community and paid priority support options. More information on these support options can be found at: https://www.intel.com/content/www/us/en/developer/get-help/priority-support.html. + + +7. LIMITATION OF LIABILITY. + +7.1 Intel will not be liable for any of the following losses or damages (whether such losses or damages were foreseen, foreseeable, known or otherwise): (i) loss of revenue; (ii) loss of actual or anticipated profits; (iii) loss of the use of money; (iv) loss of anticipated savings; (v) loss of business; (vi) loss of opportunity; (vii) loss of goodwill; (viii) loss of use of the Materials; (ix) loss of reputation; (x) loss of, damage to, or corruption of data; or (xi) any indirect, incidental, special or consequential loss of damage however caused (including loss or damage of the type specified in this Section 7). + +7.2 Intel’s total cumulative liability to You, including for direct damages for claims relating to this Agreement, and whether for breach of contract, negligence, or for any other reason, will not exceed $100. + +7.3 You acknowledge that the limitations of liability provided in this Section 7 are an essential part of this Agreement. You agree that the limitations of liability provided in this Agreement with respect to Intel will be conveyed to and made binding upon any customer of Yours that acquires the Redistributables. + +8. USER SUBMISSIONS. Should you provide Intel with comments, modifications, corrections, enhancements or other input (“Feedback”) related to the Materials, Intel will be free to use, disclose, reproduce, license or otherwise distribute or exploit the Feedback in its sole discretion without any obligations or restrictions of any kind, including without limitation, intellectual property rights or licensing obligations. If You wish to provide Intel with information that You intend to be treated as confidential information, Intel requires that such confidential information be provided pursuant to a non-disclosure agreement (“NDA”); please contact Your Intel representative to ensure the proper NDA is in place. + +9. NON-DISCLOSURE. Information provided by Intel to You may include information marked as confidential. You must treat such information as confidential under the terms of the applicable NDA between Intel and You. If You have not entered into an NDA with Intel, You must not disclose, distribute or make use of any information marked as confidential, except as expressly authorized in writing by Intel. Intel retains all rights in and to its confidential information specifications, designs, engineering details, discoveries, inventions, patents, copyrights, trademarks, trade secrets, and other proprietary rights relating to the Materials. Any breach by You of the confidentiality obligations provided for in this Section 9 will cause irreparable injury to Intel for which money damages may be inadequate to compensate Intel for losses arising from such a breach. Intel may obtain equitable relief, including injunctive relief, if You breach or threaten to breach Your confidentiality obligations. + +10. TERM AND TERMINATION. This Agreement becomes effective on the date You accept this Agreement and will continue until terminated as provided for in this Agreement. This Agreement will terminate immediately if You are in breach of any of its terms and conditions. The term for Pre-Release Materials terminates immediately upon release of a production version or at any time upon notice from Intel. Upon termination, You will promptly destroy the Materials and all copies. In the event of termination of this Agreement, Your license to any Redistributables distributed by You in accordance with the terms and conditions of this Agreement, prior to the effective date of such termination, will survive any such termination of this Agreement. Sections 1, 2.1.D(4)(v), 2.2, 2.3.A(iii), 2.3.B(iii), 3.3, 3.5.B (last sentence), 5, 6, 7, 8, 9, 10 (with respect to these survival provisions in the last sentence), and 12 will survive expiration or termination of this Agreement. + +11. U.S. GOVERNMENT RESTRICTED RIGHTS. The technical data and computer software covered by this license is a “Commercial Item,” as such term is defined by the FAR 2.101 (48 C.F.R. 2.101) and is “commercial computer software” and “commercial computer software documentation” as specified under FAR 12.212 (48 C.F.R. 12.212) or DFARS 227.7202 (48 C.F.R. 227.7202), as applicable. This commercial computer software and related documentation is provided to end users for use by and on behalf of the U.S. Government with only those rights as are granted to all other end users pursuant to the terms and conditions of this Agreement. + +12. GENERAL PROVISIONS. + +12.1 ENTIRE AGREEMENT. This Agreement contains the complete and exclusive agreement and understanding between the parties concerning the subject matter of this Agreement, and supersedes all prior and contemporaneous proposals, agreements, understanding, negotiations, representations, warranties, conditions, and communications, oral or written, between the parties relating to the same subject matter. Each party acknowledges and agrees that in entering into this Agreement it has not relied on, and will not be entitled to rely on, any oral or written representations, warranties, conditions, understanding, or communications between the parties that are not expressly set forth in this Agreement. The express provisions of this Agreement control over any course of performance, course of dealing, or usage of the trade inconsistent with any of the provisions of this Agreement. The provisions of this Agreement will prevail notwithstanding any different, conflicting, or additional provisions that may appear on any purchase order, acknowledgement, invoice, or other writing issued by either party in connection with this Agreement. No modification or amendment to this Agreement will be effective unless in writing and signed by authorized representatives of each party, and must specifically identify this Agreement by its title and version (e.g., “Intel End User License Agreement for Developer Tools (Version August 2024)”). If You received a copy of this Agreement translated into another language, the English language version of this Agreement will prevail in the event of any conflict between versions. + +12.2 TRADE COMPLIANCE. You must comply with all applicable laws and regulations of the U.S. and other countries governing the export, re-export, import, transfer, distribution, use, and servicing (“Export”) of the Materials and all related materials provided by Intel. In particular, but without limitation, You must not, without first obtaining all authorizations required by all applicable laws and regulations, Export Materials or any related materials (a) to any prohibited or restricted entity or country; or (b) for the development, design, manufacture, or production of nuclear, missile, chemical, or biological weapons, or for any other purpose prohibited by all applicable governments. Upon Intel’s request, You will provide export classifications for all Materials and technical data. Intel will not be obligated to perform any of its obligations under this Agreement if performance would result in violation of any applicable trade or sanctions controls or restrictions. No failure or delay on the part of Intel to exercise any right under this clause will operate as a waiver of this clause. + +12.3 GOVERNING LAW, JURISDICTION, AND VENUE. All disputes arising out of or related to this Agreement, whether based on contract, tort, or any other legal or equitable theory, will in all respects be governed by, and construed and interpreted under, the laws of the United States of America and the State of Delaware, without reference to conflict of laws principles. The parties agree that the United Nations Convention on Contracts for the International Sale of Goods (1980) is specifically excluded from and will not apply to this Agreement. All disputes arising out of or related to this Agreement, whether based on contract, tort, or any other legal or equitable theory, will be subject to the exclusive jurisdiction of the courts of the State of Delaware or of the Federal courts sitting in that State. Each party submits to the personal jurisdiction of those courts and waives all objections to that jurisdiction and venue for those disputes. + +12.4 SEVERABILITY. The parties intend that if a court holds that any provision or part of this Agreement is invalid or unenforceable under applicable law, the court will modify the provision to the minimum extent necessary to make it valid and enforceable, or if it cannot be made valid and enforceable, the parties intend that the court will sever and delete the provision or part from this Agreement. Any change to or deletion of a provision or part of this Agreement under this Section will not affect the validity or enforceability of the remainder of this Agreement, which will continue in full force and effect. diff --git a/freevideo_engine/licenses/runtime/NVIDIA-CUDA-EULA.txt b/freevideo_engine/licenses/runtime/NVIDIA-CUDA-EULA.txt new file mode 100644 index 00000000..d6d560b9 --- /dev/null +++ b/freevideo_engine/licenses/runtime/NVIDIA-CUDA-EULA.txt @@ -0,0 +1,1208 @@ +NVIDIA CUDA Toolkit End User License Agreement, last updated January 26, 2026. Source: https://docs.nvidia.com/cuda/eula/index.html + +End User License Agreement + +NVIDIA Software License Agreement and CUDA Supplement to Software License Agreement. + +The CUDA Toolkit End User License Agreement applies to the NVIDIA CUDA Toolkit, the NVIDIA CUDA +Samples, the NVIDIA Display Driver, NVIDIA Nsight tools (Visual Studio Edition), and the associated +documentation on CUDA APIs, programming model and development tools. If you do not agree with the +terms and conditions of the license agreement, then do not download or use the software. + +Last updated: January 26, 2026 + +Preface + +The Software License Agreement in Chapter 1 and the Supplement in Chapter 2 contain license terms +and conditions that govern the use of NVIDIA CUDA toolkit. By accepting this agreement, you agree to +comply with all the terms and conditions applicable to the product(s) included herein. + +NVIDIA Driver + +Description + +This package contains the operating system driver and fundamental system software components for +NVIDIA GPUs. + +NVIDIA CUDA Toolkit + +Description + +The NVIDIA CUDA Toolkit provides command-line and graphical tools for building, debugging and +optimizing the performance of applications accelerated by NVIDIA GPUs, runtime and math libraries, +and documentation including programming guides, user manuals, and API references. + +Default Install Location of CUDA Toolkit + +Windows platform: + +%ProgramFiles%\NVIDIA GPU Computing Toolkit\CUDA\v#.# + +Linux platform: + +/usr/local/cuda-#.# + +Mac platform: + +/Developer/NVIDIA/CUDA-#.# + +NVIDIA CUDA Samples + +Description + +CUDA Samples are now located in https://github.com/nvidia/cuda-samples, which includes instructions +for obtaining, building, and running the samples. They are no longer included in the CUDA toolkit. + +NVIDIA Nsight Visual Studio Edition (Windows only) + +Description + +NVIDIA Nsight Development Platform, Visual Studio Edition is a development environment integrated +into Microsoft Visual Studio that provides tools for debugging, profiling, analyzing and optimizing +your GPU computing and graphics applications. + +Default Install Location of Nsight Visual Studio Edition + +Windows platform: + +%ProgramFiles(x86)%\NVIDIA Corporation\Nsight Visual Studio Edition #.# + +1. License Agreement for NVIDIA Software Development Kits + +Important Notice—Read before downloading, installing, copying or using the licensed software: + +This license agreement, including exhibits attached (“Agreement”) is a legal agreement between you +and NVIDIA Corporation (“NVIDIA”) and governs your use of a NVIDIA software development kit (“SDK”). + +Each SDK has its own set of software and materials, but here is a description of the types of items +that may be included in a SDK: source code, header files, APIs, data sets and assets (examples +include images, textures, models, scenes, videos, native API input/output files), binary software, +sample code, libraries, utility programs, programming code and documentation. + +This Agreement can be accepted only by an adult of legal age of majority in the country in which the +SDK is used. + +If you are entering into this Agreement on behalf of a company or other legal entity, you represent +that you have the legal authority to bind the entity to this Agreement, in which case “you” will +mean the entity you represent. + +If you don’t have the required age or authority to accept this Agreement, or if you don’t accept all +the terms and conditions of this Agreement, do not download, install or use the SDK. + +You agree to use the SDK only for purposes that are permitted by (a) this Agreement, and (b) any +applicable law, regulation or generally accepted practices or guidelines in the relevant +jurisdictions. + +1.1. License + +1.1.1. License Grant + +Subject to the terms of this Agreement, NVIDIA hereby grants you a non-exclusive, non-transferable +license, without the right to sublicense (except as expressly provided in this Agreement) to: + +1. Install and use the SDK, + +2. Modify and create derivative works of sample source code delivered in the SDK, and + +3. Distribute those portions of the SDK that are identified in this Agreement as distributable, as + incorporated in object code format into a software application that meets the distribution + requirements indicated in this Agreement. + +1.1.2. Distribution Requirements + +These are the distribution requirements for you to exercise the distribution grant: + +1. Your application must have material additional functionality, beyond the included portions of the + SDK. + +2. The distributable portions of the SDK shall only be accessed by your application. + +3. The following notice shall be included in modifications and derivative works of sample source + code distributed: “This software contains source code provided by NVIDIA Corporation.” + +4. Unless a developer tool is identified in this Agreement as distributable, it is delivered for + your internal use only. + +5. The terms under which you distribute your application must be consistent with the terms of this + Agreement, including (without limitation) terms relating to the license grant and license + restrictions and protection of NVIDIA’s intellectual property rights. Additionally, you agree + that you will protect the privacy, security and legal rights of your application users. + +6. You agree to notify NVIDIA in writing of any known or suspected distribution or use of the SDK + not in compliance with the requirements of this Agreement, and to enforce the terms of your + agreements with respect to distributed SDK. + +1.1.3. Authorized Users + +You may allow employees and contractors of your entity or of your subsidiary(ies) to access and use +the SDK from your secure network to perform work on your behalf. + +If you are an academic institution you may allow users enrolled or employed by the academic +institution to access and use the SDK from your secure network. + +You are responsible for the compliance with the terms of this Agreement by your authorized users. If +you become aware that your authorized users didn’t follow the terms of this Agreement, you agree to +take reasonable steps to resolve the non-compliance and prevent new occurrences. + +1.1.4. Pre-Release SDK + +The SDK versions identified as alpha, beta, preview or otherwise as pre-release, may not be fully +functional, may contain errors or design flaws, and may have reduced or different security, privacy, +accessibility, availability, and reliability standards relative to commercial versions of NVIDIA +software and materials. Use of a pre-release SDK may result in unexpected results, loss of data, +project delays or other unpredictable damage or loss. + +You may use a pre-release SDK at your own risk, understanding that pre-release SDKs are not intended +for use in production or business-critical systems. + +NVIDIA may choose not to make available a commercial version of any pre-release SDK. NVIDIA may also +choose to abandon development and terminate the availability of a pre-release SDK at any time +without liability. + +1.1.5. Updates + +NVIDIA may, at its option, make available patches, workarounds or other updates to this SDK. Unless +the updates are provided with their separate governing terms, they are deemed part of the SDK +licensed to you as provided in this Agreement. You agree that the form and content of the SDK that +NVIDIA provides may change without prior notice to you. While NVIDIA generally maintains +compatibility between versions, NVIDIA may in some cases make changes that introduce +incompatibilities in future versions of the SDK. + +1.1.6. Components Under Other Licenses + +The SDK may come bundled with, or otherwise include or be distributed with, NVIDIA or third-party +components with separate legal notices or terms as may be described in proprietary notices +accompanying the SDK. If and to the extent there is a conflict between the terms in this Agreement +and the license terms associated with the component, the license terms associated with the +components control only to the extent necessary to resolve the conflict. + +Subject to the other terms of this Agreement, you may use the SDK to develop and test applications +released under Open Source Initiative (OSI) approved open source software licenses. + +1.1.7. Reservation of Rights + +NVIDIA reserves all rights, title, and interest in and to the SDK, not expressly granted to you +under this Agreement. + +1.2. Limitations + +The following license limitations apply to your use of the SDK: + +1. You may not reverse engineer, decompile or disassemble, or remove copyright or other proprietary + notices from any portion of the SDK or copies of the SDK. + +2. Except as expressly provided in this Agreement, you may not copy, sell, rent, sublicense, + transfer, distribute, modify, or create derivative works of any portion of the SDK. For clarity, + you may not distribute or sublicense the SDK as a stand-alone product. + +3. Unless you have an agreement with NVIDIA for this purpose, you may not indicate that an + application created with the SDK is sponsored or endorsed by NVIDIA. + +4. You may not bypass, disable, or circumvent any encryption, security, digital rights management or + authentication mechanism in the SDK. + +5. You may not use the SDK in any manner that would cause it to become subject to an open source + software license. As examples, licenses that require as a condition of use, modification, and/or + distribution that the SDK be: + + 1. Disclosed or distributed in source code form; + + 2. Licensed for the purpose of making derivative works; or + + 3. Redistributable at no charge. + +6. You acknowledge that the SDK as delivered is not tested or certified by NVIDIA for use in + connection with the design, construction, maintenance, and/or operation of any system where the + use or failure of such system could result in a situation that threatens the safety of human life + or results in catastrophic damages (each, a “Critical Application”). Examples of Critical + Applications include use in avionics, navigation, autonomous vehicle applications, ai solutions + for automotive products, military, medical, life support or other life critical applications. + NVIDIA shall not be liable to you or any third party, in whole or in part, for any claims or + damages arising from such uses. You are solely responsible for ensuring that any product or + service developed with the SDK as a whole includes sufficient features to comply with all + applicable legal and regulatory standards and requirements. + +7. You agree to defend, indemnify and hold harmless NVIDIA and its affiliates, and their respective + employees, contractors, agents, officers and directors, from and against any and all claims, + damages, obligations, losses, liabilities, costs or debt, fines, restitutions and expenses + (including but not limited to attorney’s fees and costs incident to establishing the right of + indemnification) arising out of or related to products or services that use the SDK in or for + Critical Applications, and for use of the SDK outside of the scope of this Agreement or not in + compliance with its terms. + +8. You may not reverse engineer, decompile or disassemble any portion of the output generated using + SDK elements for the purpose of translating such output artifacts to target a non-NVIDIA + platform. + +1.3. Ownership + +1. NVIDIA or its licensors hold all rights, title and interest in and to the SDK and its + modifications and derivative works, including their respective intellectual property rights, + subject to your rights under Section 1.3.2. This SDK may include software and materials from + NVIDIA’s licensors, and these licensors are intended third party beneficiaries that may enforce + this Agreement with respect to their intellectual property rights. + +1. You hold all rights, title and interest in and to your applications and your derivative works of + the sample source code delivered in the SDK, including their respective intellectual property + rights, subject to NVIDIA’s rights under Section 1.3.1. + +2. You may, but don’t have to, provide to NVIDIA suggestions, feature requests or other feedback + regarding the SDK, including possible enhancements or modifications to the SDK. For any feedback + that you voluntarily provide, you hereby grant NVIDIA and its affiliates a perpetual, + non-exclusive, worldwide, irrevocable license to use, reproduce, modify, license, sublicense + (through multiple tiers of sublicensees), and distribute (through multiple tiers of distributors) + it without the payment of any royalties or fees to you. NVIDIA will use feedback at its choice. + NVIDIA is constantly looking for ways to improve its products, so you may send feedback to NVIDIA + through the developer portal at https://developer.nvidia.com. + +1.4. No Warranties + +THE SDK IS PROVIDED BY NVIDIA “AS IS” AND “WITH ALL FAULTS.” TO THE MAXIMUM EXTENT PERMITTED BY LAW, +NVIDIA AND ITS AFFILIATES EXPRESSLY DISCLAIM ALL WARRANTIES OF ANY KIND OR NATURE, WHETHER EXPRESS, +IMPLIED OR STATUTORY, INCLUDING, BUT NOT LIMITED TO, ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR +A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, OR THE ABSENCE OF ANY DEFECTS THEREIN, WHETHER LATENT +OR PATENT. NO WARRANTY IS MADE ON THE BASIS OF TRADE USAGE, COURSE OF DEALING OR COURSE OF TRADE. + +1.5. Limitation of Liability + +TO THE MAXIMUM EXTENT PERMITTED BY LAW, NVIDIA AND ITS AFFILIATES SHALL NOT BE LIABLE FOR ANY (I) +SPECIAL, INCIDENTAL, PUNITIVE OR CONSEQUENTIAL DAMAGES, OR (II) DAMAGES FOR (A) ANY LOST PROFITS, +LOSS OF USE, LOSS OF DATA OR LOSS OF GOODWILL, OR (B) THE COSTS OF PROCURING SUBSTITUTE PRODUCTS, +ARISING OUT OF OR IN CONNECTION WITH THIS AGREEMENT OR THE USE OR PERFORMANCE OF THE SDK, WHETHER +SUCH LIABILITY ARISES FROM ANY CLAIM BASED UPON BREACH OF CONTRACT, BREACH OF WARRANTY, TORT +(INCLUDING NEGLIGENCE), PRODUCT LIABILITY OR ANY OTHER CAUSE OF ACTION OR THEORY OF LIABILITY. IN NO +EVENT WILL NVIDIA’S AND ITS AFFILIATES TOTAL CUMULATIVE LIABILITY UNDER OR ARISING OUT OF THIS +AGREEMENT EXCEED US$10.00. THE NATURE OF THE LIABILITY OR THE NUMBER OF CLAIMS OR SUITS SHALL NOT +ENLARGE OR EXTEND THIS LIMIT. + +These exclusions and limitations of liability shall apply regardless if NVIDIA or its affiliates +have been advised of the possibility of such damages, and regardless of whether a remedy fails its +essential purpose. These exclusions and limitations of liability form an essential basis of the +bargain between the parties, and, absent any of these exclusions or limitations of liability, the +provisions of this Agreement, including, without limitation, the economic terms, would be +substantially different. + +1.6. Termination + +1. This Agreement will continue to apply until terminated by either you or NVIDIA as described + below. + +2. If you want to terminate this Agreement, you may do so by stopping to use the SDK. + +3. NVIDIA may, at any time, terminate this Agreement if: + + 1. (i) you fail to comply with any term of this Agreement and the non-compliance is not fixed + within thirty (30) days following notice from NVIDIA (or immediately if you violate NVIDIA’s + intellectual property rights); + + 2. (ii) you commence or participate in any legal proceeding against NVIDIA with respect to the + SDK; or + + 3. (iii) NVIDIA decides to no longer provide the SDK in a country or, in NVIDIA’s sole discretion, + the continued use of it is no longer commercially viable. + +4. Upon any termination of this Agreement, you agree to promptly discontinue use of the SDK and + destroy all copies in your possession or control. Your prior distributions in accordance with + this Agreement are not affected by the termination of this Agreement. Upon written request, you + will certify in writing that you have complied with your commitments under this section. Upon any + termination of this Agreement all provisions survive except for the license grant provisions. + +1.7. General + +If you wish to assign this Agreement or your rights and obligations, including by merger, +consolidation, dissolution or operation of law, contact NVIDIA to ask for permission. Any attempted +assignment not approved by NVIDIA in writing shall be void and of no effect. NVIDIA may assign, +delegate or transfer this Agreement and its rights and obligations, and if to a non-affiliate you +will be notified. + +You agree to cooperate with NVIDIA and provide reasonably requested information to verify your +compliance with this Agreement. + +This Agreement will be governed in all respects by the laws of the United States and of the State of +Delaware, without regard to the conflicts of laws principles. The United Nations Convention on +Contracts for the International Sale of Goods is specifically disclaimed. You agree to all terms of +this Agreement in the English language. + +The state or federal courts residing in Santa Clara County, California shall have exclusive +jurisdiction over any dispute or claim arising out of this Agreement. Notwithstanding this, you +agree that NVIDIA shall still be allowed to apply for injunctive remedies or an equivalent type of +urgent legal relief in any jurisdiction. + +If any court of competent jurisdiction determines that any provision of this Agreement is illegal, +invalid or unenforceable, such provision will be construed as limited to the extent necessary to be +consistent with and fully enforceable under the law and the remaining provisions will remain in full +force and effect. Unless otherwise specified, remedies are cumulative. + +Each party acknowledges and agrees that the other is an independent contractor in the performance of +this Agreement. + +The SDK has been developed entirely at private expense and is “commercial items” consisting of +“commercial computer software” and “commercial computer software documentation” provided with +RESTRICTED RIGHTS. Use, duplication or disclosure by the U.S. Government or a U.S. Government +subcontractor is subject to the restrictions in this Agreement pursuant to DFARS 227.7202-3(a) or as +set forth in subparagraphs (c)(1) and (2) of the Commercial Computer Software - Restricted Rights +clause at FAR 52.227-19, as applicable. Contractor/manufacturer is NVIDIA, 2788 San Tomas +Expressway, Santa Clara, CA 95051. + +The SDK is subject to United States export laws and regulations. You agree that you will not ship, +transfer or export the SDK into any country, or use the SDK in any manner, prohibited by the United +States Bureau of Industry and Security or economic sanctions regulations administered by the U.S. +Department of Treasury’s Office of Foreign Assets Control (OFAC), or any applicable export laws, +restrictions or regulations. These laws include restrictions on destinations, end users and end use. +By accepting this Agreement, you confirm that you are not located in a country currently embargoed +by the U.S. or otherwise prohibited from receiving the SDK under U.S. law. + +Any notice delivered by NVIDIA to you under this Agreement will be delivered via mail, email or fax. +You agree that any notices that NVIDIA sends you electronically will satisfy any legal communication +requirements. Please direct your legal notices or other correspondence to NVIDIA Corporation, 2788 +San Tomas Expressway, Santa Clara, California 95051, United States of America, Attention: Legal +Department. + +This Agreement and any exhibits incorporated into this Agreement constitute the entire agreement of +the parties with respect to the subject matter of this Agreement and supersede all prior +negotiations or documentation exchanged between the parties relating to this SDK license. Any +additional and/or conflicting terms on documents issued by you are null, void, and invalid. Any +amendment or waiver under this Agreement shall be in writing and signed by representatives of both +parties. + +2. CUDA Toolkit Supplement to Software License Agreement for NVIDIA Software Development Kits + +The terms in this supplement govern your use of the NVIDIA CUDA Toolkit SDK under the terms of your +license agreement (“Agreement”) as modified by this supplement. Capitalized terms used but not +defined below have the meaning assigned to them in the Agreement. + +This supplement is an exhibit to the Agreement and is incorporated as an integral part of the +Agreement. In the event of conflict between the terms in this supplement and the terms in the +Agreement, the terms in this supplement govern. + +2.1. License Scope + +The SDK is licensed for you to develop applications only for use in systems with NVIDIA GPUs. + +2.2. Distribution + +The portions of the SDK that are distributable under the Agreement are listed in Attachment A. + +2.3. Operating Systems + +Those portions of the SDK designed exclusively for use on the Linux or FreeBSD operating systems, or +other operating systems derived from the source code to these operating systems, may be copied and +redistributed for use in accordance with this Agreement, provided that the object code files are not +modified in any way (except for unzipping of compressed files). + +2.4. Audio and Video Encoders and Decoders + +You acknowledge and agree that it is your sole responsibility to obtain any additional third-party +licenses required to make, have made, use, have used, sell, import, and offer for sale your products +or services that include or incorporate any third-party software and content relating to audio +and/or video encoders and decoders from, including but not limited to, Microsoft, Thomson, +Fraunhofer IIS, Sisvel S.p.A., MPEG-LA, and Coding Technologies. NVIDIA does not grant to you under +this Agreement any necessary patent or other rights with respect to any audio and/or video encoders +and decoders. + +2.5. Licensing + +If the distribution terms in this Agreement are not suitable for your organization, or for any +questions regarding this Agreement, please contact NVIDIA at +nvidia-compute-license-questions@nvidia.com. + +2.6. Attachment A + +The following CUDA Toolkit files may be distributed with applications developed by you, including +certain variations of these files that have version number or architecture specific information +embedded in the file name - as an example only, for release version 9.0 of the 64-bit Windows +software, the file cudart64_90.dll is redistributable. + +Component | CUDA Runtime + +Windows | cudart.dll, cudart_static.lib, cudadevrt.lib + +Mac OSX | libcudart.dylib, libcudart_static.a, libcudadevrt.a + +Linux | libcudart.so, libcudart_static.a, libcudadevrt.a + +Android | libcudart.so, libcudart_static.a, libcudadevrt.a + +Component | CUDA FFT Library + +Windows | cufft.dll, cufftw.dll, cufft.lib, cufftw.lib + +Mac OSX | libcufft.dylib, libcufft_static.a, libcufftw.dylib, libcufftw_static.a + +Linux | libcufft.so, libcufft_static.a, libcufftw.so, libcufftw_static.a + +Android | libcufft.so, libcufft_static.a, libcufftw.so, libcufftw_static.a + +Component | CUDA BLAS Library + +Windows | cublas.dll, cublasLt.dll + +Mac OSX | libcublas.dylib, libcublasLt.dylib, libcublas_static.a, libcublasLt_static.a + +Linux | libcublas.so, libcublasLt.so, libcublas_static.a, libcublasLt_static.a + +Android | libcublas.so, libcublasLt.so, libcublas_static.a, libcublasLt_static.a + +Component | NVIDIA “Drop-in” BLAS Library + +Windows | nvblas.dll + +Mac OSX | libnvblas.dylib + +Linux | libnvblas.so + +Component | CUDA Sparse Matrix Library + +Windows | cusparse.dll, cusparse.lib + +Mac OSX | libcusparse.dylib, libcusparse_static.a + +Linux | libcusparse.so, libcusparse_static.a + +Android | libcusparse.so, libcusparse_static.a + +Component | CUDA Linear Solver Library + +Windows | cusolver.dll, cusolver.lib + +Mac OSX | libcusolver.dylib, libcusolver_static.a + +Linux | libcusolver.so, libcusolver_static.a + +Android | libcusolver.so, libcusolver_static.a + +Component | CUDA Random Number Generation Library + +Windows | curand.dll, curand.lib + +Mac OSX | libcurand.dylib, libcurand_static.a + +Linux | libcurand.so, libcurand_static.a + +Android | libcurand.so, libcurand_static.a + +Component | NVIDIA Performance Primitives Library + +Windows | nppc.dll, nppc.lib, nppial.dll, nppial.lib, nppicc.dll, nppicc.lib, nppicom.dll, +nppicom.lib, nppidei.dll, nppidei.lib, nppif.dll, nppif.lib, nppig.dll, nppig.lib, nppim.dll, +nppim.lib, nppist.dll, nppist.lib, nppisu.dll, nppisu.lib, nppitc.dll, nppitc.lib, npps.dll, +npps.lib + +Mac OSX | libnppc.dylib, libnppc_static.a, libnppial.dylib, libnppial_static.a, libnppicc.dylib, +libnppicc_static.a, libnppicom.dylib, libnppicom_static.a, libnppidei.dylib, libnppidei_static.a, +libnppif.dylib, libnppif_static.a, libnppig.dylib, libnppig_static.a, libnppim.dylib, +libnppisu_static.a, libnppitc.dylib, libnppitc_static.a, libnpps.dylib, libnpps_static.a + +Linux | libnppc.so, libnppc_static.a, libnppial.so, libnppial_static.a, libnppicc.so, +libnppicc_static.a, libnppicom.so, libnppicom_static.a, libnppidei.so, libnppidei_static.a, +libnppif.so, libnppif_static.a libnppig.so, libnppig_static.a, libnppim.so, libnppim_static.a, +libnppist.so, libnppist_static.a, libnppisu.so, libnppisu_static.a, libnppitc.so libnppitc_static.a, +libnpps.so, libnpps_static.a + +Android | libnppc.so, libnppc_static.a, libnppial.so, libnppial_static.a, libnppicc.so, +libnppicc_static.a, libnppicom.so, libnppicom_static.a, libnppidei.so, libnppidei_static.a, +libnppif.so, libnppif_static.a libnppig.so, libnppig_static.a, libnppim.so, libnppim_static.a, +libnppist.so, libnppist_static.a, libnppisu.so, libnppisu_static.a, libnppitc.so libnppitc_static.a, +libnpps.so, libnpps_static.a + +Component | NVIDIA JPEG Library + +Windows | nvjpeg.lib, nvjpeg.dll + +Linux | libnvjpeg.so, libnvjpeg_static.a + +Component | Internal common library required for statically linking to cuBLAS, cuSPARSE, cuFFT, +cuRAND, nvJPEG and NPP + +Mac OSX | libculibos.a + +Linux | libculibos.a + +Component | NVIDIA Runtime Compilation Library and Header + +All | nvrtc.h + +Windows | nvrtc.dll, nvrtc-builtins.dll + +Mac OSX | libnvrtc.dylib, libnvrtc-builtins.dylib + +Linux | libnvrtc.so, libnvrtc-builtins.so, libnvrtc_static.a, libnvrtc-builtins_static.a + +Component | NVIDIA Optimizing Compiler Library + +Windows | nvvm.dll + +Mac OSX | libnvvm.dylib + +Linux | libnvvm.so + +Component | NVIDIA JIT Linking Library + +Windows | libnvJitLink.dll, libnvJitLink.lib + +Linux | libnvJitLink.so, libnvJitLink_static.a + +Component | NVIDIA Common Device Math Functions Library + +Windows | libdevice.10.bc + +Mac OSX | libdevice.10.bc + +Linux | libdevice.10.bc + +Component | CUDA Occupancy Calculation Header Library + +All | cuda_occupancy.h + +Component | CUDA Floating Point Type Headers + +All | cuda_fp16.h, cuda_fp16.hpp, cuda_bf16.h, cuda_bf16.hpp, cuda_fp8.h, cuda_fp8.hpp, cuda_fp6.h, +cuda_fp6.hpp, cuda_fp4.h, cuda_fp4.hpp + +Component | CUDA Headers for Runtime Compilation + +All | crt/host_defines.h, cuComplex.h, cuda_awbarrier_helpers.h, cuda_awbarrier_primitives.h, +cuda_awbarrier.h, cuda_pipeline_helpers.h, cuda_pipeline_primitives.h, cuda_pipeline.h, +cuda_runtime_api.h, cuda.h, cuda/std/tuple, cuda/std/type_traits, cuda/std/type_traits, +cuda/std/utility, device_types.h, vector_functions.h, vector_types.h + +Component | Accelerated CUDA Libraries for Object Storage + +All | libcuobjclient.so, libcuobjserver.so, cuobjclient.h, cuobjrdma.h, cuobjrdmaparam.h, +cuobjserver.h, cuobjtelem.h + +Component | CUDA Profiling Tools Interface (CUPTI) Library + +Windows | cupti.dll + +Mac OSX | libcupti.dylib + +Linux | libcupti.so + +Component | NVIDIA Tools Extension Library + +Windows | nvToolsExt.dll, nvToolsExt.lib + +Mac OSX | libnvToolsExt.dylib + +Linux | libnvToolsExt.so, libnvtx3interop.so + +Component | NVIDIA CUDA Driver Libraries + +Linux | libcuda.so, libnvidia-ptxjitcompiler.so, libnvptxcompiler_static.a + +Component | NVIDIA CUDA File IO Libraries and Header + +All | cufile.h + +Linux | libcufile.so, libcufile_rdma.so, libcufile_static.a, libcufile_rdma_static.a + +In addition to the rights above, for parties that are developing software intended solely for use on +Jetson development kits or Jetson modules, and running Linux for Tegra software, the following shall +apply: + +- The SDK may be distributed in its entirety, as provided by NVIDIA, and without separation of its + components, for you and/or your licensees to create software development kits for use only on the + Jetson platform and running Linux for Tegra software. + +2.7. Attachment B + +Additional Licensing Obligations + +The following third party components included in the SOFTWARE are licensed to Licensee pursuant to +the following terms and conditions: + +1. Licensee’s use of the GDB third party component is subject to the terms and conditions of GNU GPL + v3: + + This product includes copyrighted third-party software licensed under the terms of the GNU + General Public License v3 ("GPL v3"). All third-party software packages are copyright by their + respective authors. GPL v3 terms and conditions are hereby incorporated into the Agreement by + this reference: http://www.gnu.org/licenses/gpl.txt + + Consistent with these licensing requirements, the software listed below is provided under the + terms of the specified open source software licenses. To obtain source code for software provided + under licenses that require redistribution of source code, including the GNU General Public + License (GPL) and GNU Lesser General Public License (LGPL), contact oss-requests@nvidia.com. This + offer is valid for a period of three (3) years from the date of the distribution of this product + by NVIDIA CORPORATION. + + Component License CUDA-GDB GPL v3 + +2. Licensee represents and warrants that any and all third party licensing and/or royalty payment + obligations in connection with Licensee’s use of the H.264 video codecs are solely the + responsibility of Licensee. + +3. Licensee’s use of the Thrust library is subject to the terms and conditions of the Apache License + Version 2.0. All third-party software packages are copyright by their respective authors. Apache + License Version 2.0 terms and conditions are hereby incorporated into the Agreement by this + reference. http://www.apache.org/licenses/LICENSE-2.0.html + + In addition, Licensee acknowledges the following notice: Thrust includes source code from the + Boost Iterator, Tuple, System, and Random Number libraries. + + Boost Software License - Version 1.0 - August 17th, 2003 . . . . Permission is hereby granted, + free of charge, to any person or organization obtaining a copy of the software and accompanying + documentation covered by this license (the "Software") to use, reproduce, display, distribute, + execute, and transmit the Software, and to prepare derivative works of the Software, and to + permit third-parties to whom the Software is furnished to do so, all subject to the following: + The copyright notices in the Software and this entire statement, including the above license + grant, this restriction and the following disclaimer, must be included in all copies of the + Software, in whole or in part, and all derivative works of the Software, unless such copies or + derivative works are solely in the form of machine-executable object code generated by a source + language processor. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR ANYONE + DISTRIBUTING THE SOFTWARE BE LIABLE FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT + OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + +4. Licensee’s use of the LLVM third party component is subject to the following terms and + conditions: + + ======================================================================================================== + All LLVM after 8.0 are distributed under Apache-2.0 with LLVM-exception license, an OSI-approved + license + ======================================================================================================== + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR + USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions + for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is + granting the License. "Legal Entity" shall mean the union of the acting entity and all other + entities that control, are controlled by, or are under common control with that entity. For the + purposes of this definition, "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or otherwise, or (ii) ownership of + fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such + entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted + by this License. "Source" form shall mean the preferred form for making modifications, including + but not limited to software source code, documentation source, and configuration files. "Object" + form shall mean any form resulting from mechanical transformation or translation of a Source + form, including but not limited to compiled object code, generated documentation, and conversions + to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, + made available under the License, as indicated by a copyright notice that is included in or + attached to the work (an example is provided in the Appendix below). "Derivative Works" shall + mean any work, whether in Source or Object form, that is based on (or derived from) the Work and + for which the editorial revisions, annotations, elaborations, or other modifications represent, + as a whole, an original work of authorship. For the purposes of this License, Derivative Works + shall not include works that remain separable from, or merely link (or bind by name) to the + interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of + authorship, including the original version of the Work and any modifications or additions to that + Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in + the Work by the copyright owner or by an individual or Legal Entity authorized to submit on + behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of + electronic, verbal, or written communication sent to the Licensor or its representatives, + including but not limited to communication on electronic mailing lists, source code control + systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the + purpose of discussing and improving the Work, but excluding communication that is conspicuously + marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a + Contribution has been received by Licensor and subsequently incorporated within the Work. 2. + Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor + hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, + sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant + of Patent License. Subject to the terms and conditions of this License, each Contributor hereby + grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except + as stated in this section) patent license to make, have made, use, offer to sell, sell, import, + and otherwise transfer the Work, where such license applies only to those patent claims + licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or + by combination of their Contribution(s) with the Work to which such Contribution(s) was + submitted. If You institute patent litigation against any entity (including a cross-claim or + counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work + constitutes direct or contributory patent infringement, then any patent licenses granted to You + under this License for that Work shall terminate as of the date such litigation is filed. 4. + Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof + in any medium, with or without modifications, and in Source or Object form, provided that You + meet the following conditions: (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and (b) You must cause any modified files to carry prominent + notices stating that You changed the files; and (c) You must retain, in the Source form of any + Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices + from the Source form of the Work, excluding those notices that do not pertain to any part of the + Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, + then any Derivative Works that You distribute must include a readable copy of the attribution + notices contained within such NOTICE file, excluding those notices that do not pertain to any + part of the Derivative Works, in at least one of the following places: within a NOTICE text file + distributed as part of the Derivative Works; within the Source form or documentation, if provided + along with the Derivative Works; or, within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents of the NOTICE file are for + informational purposes only and do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE + text from the Work, provided that such additional attribution notices cannot be construed as + modifying the License. You may add Your own copyright statement to Your modifications and may + provide additional or different license terms and conditions for use, reproduction, or + distribution of Your modifications, or for any such Derivative Works as a whole, provided Your + use, reproduction, and distribution of the Work otherwise complies with the conditions stated in + this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any + Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be + under the terms and conditions of this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate + license agreement you may have executed with Licensor regarding such Contributions. 6. + Trademarks. This License does not grant permission to use the trade names, trademarks, service + marks, or product names of the Licensor, except as required for reasonable and customary use in + describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer + of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the + Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR + CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties + or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. + You are solely responsible for determining the appropriateness of using or redistributing the + Work and assume any risks associated with Your exercise of permissions under this License. 8. + Limitation of Liability. In no event and under no legal theory, whether in tort (including + negligence), contract, or otherwise, unless required by applicable law (such as deliberate and + grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for + damages, including any direct, indirect, special, incidental, or consequential damages of any + character arising as a result of this License or out of the use or inability to use the Work + (including but not limited to damages for loss of goodwill, work stoppage, computer failure or + malfunction, or any and all other commercial damages or losses), even if such Contributor has + been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. + While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a + fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights + consistent with this License. However, in accepting such obligations, You may act only on Your + own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if + You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, + or claims asserted against, such Contributor by reason of your accepting any such warranty or + additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to + your work. To apply the Apache License to your work, attach the following boilerplate notice, + with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't + include the brackets!) The text should be enclosed in the appropriate comment syntax for the file + format. We also recommend that a file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier identification within third-party + archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version + 2.0 (the "License"); you may not use this file except in compliance with the License. You may + obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by + applicable law or agreed to in writing, software distributed under the License is distributed on + an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See + the License for the specific language governing permissions and limitations under the License. + + ----------------------------------------- LLVM Exceptions to the Apache 2.0 License + ----------------------------------------- As an exception, if, as a result of your compiling your + source code, portions of this Software are embedded into an Object form of such source code, you + may redistribute such embedded portions in such Object form without complying with the conditions + of Sections 4(a), 4(b) and 4(d) of the License. In addition, if you combine or link compiled + forms of this Software with software that is licensed under the GPLv2 ("Combined Software") and + if a court of competent jurisdiction determines that the patent provision (Section 3), the + indemnity provision (Section 9) or other Section of the License conflicts with the conditions of + the GPLv2, you may retroactively and prospectively choose to deem waived or otherwise exclude + such Section(s) of the License, but only in their entirety and only with respect to the Combined + Software. + + ======================================================== Software from third parties included in + the LLVM Project ======================================================== The LLVM Project + contains third party software which is under different license terms. All such code will be + identified clearly using at least one of two mechanisms: 1) It will be in a separate directory + tree with its own `LICENSE.txt` or `LICENSE` file at the top containing the specific license and + restrictions which apply to that software, or 2) It will contain specific license and restriction + terms at the top of every file. + + ================================================================================================== + LLVM releases prior to LLVM 8.0 was licensed under this University of Illinois Open Source + License + ================================================================================================== + University of Illinois/NCSA Open Source License Copyright (c) 2003-2019 University of Illinois at + Urbana-Champaign. All rights reserved. Developed by: LLVM Team University of Illinois at + Urbana-Champaign http://llvm.org Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation files (the "Software"), to deal + with the Software without restriction, including without limitation the rights to use, copy, + modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit + persons to whom the Software is furnished to do so, subject to the following conditions: * + Redistributions of source code must retain the above copyright notice, this list of conditions + and the following disclaimers. * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following disclaimers in the documentation + and/or other materials provided with the distribution. * Neither the names of the LLVM Team, + University of Illinois at Urbana-Champaign, nor the names of its contributors may be used to + endorse or promote products derived from this Software without specific prior written permission. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE SOFTWARE. + +5. Licensee’s use of the PCRE third party component is subject to the following terms and + conditions: + + ------------ PCRE LICENCE ------------ PCRE is a library of functions to support regular + expressions whose syntax and semantics are as close as possible to those of the Perl 5 language. + Release 8 of PCRE is distributed under the terms of the "BSD" licence, as specified below. The + documentation for PCRE, supplied in the "doc" directory, is distributed under the same terms as + the software itself. The basic library functions are written in C and are freestanding. Also + included in the distribution is a set of C++ wrapper functions, and a just- in-time compiler that + can be used to optimize pattern matching. These are both optional features that can be omitted + when the library is built. THE BASIC LIBRARY FUNCTIONS --------------------------- Written by: + Philip Hazel Email local part: ph10 Email domain: cam.ac.uk University of Cambridge Computing + Service, Cambridge, England. Copyright (c) 1997-2012 University of Cambridge All rights reserved. + PCRE JUST-IN-TIME COMPILATION SUPPORT ------------------------------------- Written by: Zoltan + Herczeg Email local part: hzmester Emain domain: freemail.hu Copyright(c) 2010-2012 Zoltan + Herczeg All rights reserved. STACK-LESS JUST-IN-TIME COMPILER -------------------------------- + Written by: Zoltan Herczeg Email local part: hzmester Emain domain: freemail.hu Copyright(c) + 2009-2012 Zoltan Herczeg All rights reserved. THE C++ WRAPPER FUNCTIONS ------------------------- + Contributed by: Google Inc. Copyright (c) 2007-2012, Google Inc. All rights reserved. + + THE "BSD" LICENCE ----------------- Redistribution and use in source and binary forms, with or + without modification, are permitted provided that the following conditions are met: * + Redistributions of source code must retain the above copyright notice, this list of conditions + and the following disclaimer. * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the documentation and/or other + materials provided with the distribution. * Neither the name of the University of Cambridge nor + the name of Google Inc. nor the names of their contributors may be used to endorse or promote + products derived from this software without specific prior written permission. THIS SOFTWARE IS + PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A + PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, + OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF + THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +6. Some of the cuBLAS library routines were written by or derived from code written by Vasily Volkov + and are subject to the Modified Berkeley Software Distribution License as follows: + + Copyright (c) 2007-2009, Regents of the University of California All rights reserved. + Redistribution and use in source and binary forms, with or without modification, are permitted + provided that the following conditions are met: * Redistributions of source code must retain the + above copyright notice, this list of conditions and the following disclaimer. * Redistributions + in binary form must reproduce the above copyright notice, this list of conditions and the + following disclaimer in the documentation and/or other materials provided with the distribution. + * Neither the name of the University of California, Berkeley nor the names of its contributors + may be used to endorse or promote products derived from this software without specific prior + written permission. THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" AND ANY EXPRESS OR IMPLIED + WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +7. Some of the cuBLAS library routines were written by or derived from code written by Davide + Barbieri and are subject to the Modified Berkeley Software Distribution License as follows: + + Copyright (c) 2008-2009 Davide Barbieri @ University of Rome Tor Vergata. All rights reserved. + Redistribution and use in source and binary forms, with or without modification, are permitted + provided that the following conditions are met: * Redistributions of source code must retain the + above copyright notice, this list of conditions and the following disclaimer. * Redistributions + in binary form must reproduce the above copyright notice, this list of conditions and the + following disclaimer in the documentation and/or other materials provided with the distribution. + * The name of the author may not be used to endorse or promote products derived from this + software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS + IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY + WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +8. Some of the cuBLAS library routines were derived from code developed by the University of + Tennessee and are subject to the Modified Berkeley Software Distribution License as follows: + + Copyright (c) 2010 The University of Tennessee. All rights reserved. Redistribution and use in + source and binary forms, with or without modification, are permitted provided that the following + conditions are met: * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. * Redistributions in binary form must reproduce + the above copyright notice, this list of conditions and the following disclaimer listed in this + license in the documentation and/or other materials provided with the distribution. * Neither the + name of the copyright holders nor the names of its contributors may be used to endorse or promote + products derived from this software without specific prior written permission. THIS SOFTWARE IS + PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A + PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, + OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF + THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +9. Some of the cuBLAS library routines were written by or derived from code written by Jonathan Hogg + and are subject to the Modified Berkeley Software Distribution License as follows: + + Copyright (c) 2012, The Science and Technology Facilities Council (STFC). All rights reserved. + Redistribution and use in source and binary forms, with or without modification, are permitted + provided that the following conditions are met: * Redistributions of source code must retain the + above copyright notice, this list of conditions and the following disclaimer. * Redistributions + in binary form must reproduce the above copyright notice, this list of conditions and the + following disclaimer in the documentation and/or other materials provided with the distribution. + * Neither the name of the STFC nor the names of its contributors may be used to endorse or + promote products derived from this software without specific prior written permission. THIS + SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED + WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE STFC BE LIABLE FOR ANY DIRECT, + INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +10. Some of the cuBLAS library routines were written by or derived from code written by Ahmad M. + Abdelfattah, David Keyes, and Hatem Ltaief, and are subject to the Apache License, Version 2.0, + as follows: + + -- (C) Copyright 2013 King Abdullah University of Science and Technology Authors: Ahmad + Abdelfattah (ahmad.ahmad@kaust.edu.sa) David Keyes (david.keyes@kaust.edu.sa) Hatem Ltaief + (hatem.ltaief@kaust.edu.sa) Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: * Redistributions of + source code must retain the above copyright notice, this list of conditions and the following + disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or other materials + provided with the distribution. * Neither the name of the King Abdullah University of Science + and Technology nor the names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED + BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A + PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, + OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT + OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE + +11. Some of the cuSPARSE library routines were written by or derived from code written by Li-Wen + Chang and are subject to the NCSA Open Source License as follows: + + Copyright (c) 2012, University of Illinois. All rights reserved. Developed by: IMPACT Group, + University of Illinois, http://impact.crhc.illinois.edu Permission is hereby granted, free of + charge, to any person obtaining a copy of this software and associated documentation files (the + "Software"), to deal with the Software without restriction, including without limitation the + rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + Software, and to permit persons to whom the Software is furnished to do so, subject to the + following conditions: * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. * Redistributions in binary form must + reproduce the above copyright notice, this list of conditions and the following disclaimers in + the documentation and/or other materials provided with the distribution. * Neither the names of + IMPACT Group, University of Illinois, nor the names of its contributors may be used to endorse + or promote products derived from this Software without specific prior written permission. THE + SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE SOFTWARE. + +12. Some of the cuRAND library routines were written by or derived from code written by Mutsuo Saito + and Makoto Matsumoto and are subject to the following license: + + Copyright (c) 2009, 2010 Mutsuo Saito, Makoto Matsumoto and Hiroshima University. All rights + reserved. Copyright (c) 2011 Mutsuo Saito, Makoto Matsumoto, Hiroshima University and University + of Tokyo. All rights reserved. Redistribution and use in source and binary forms, with or + without modification, are permitted provided that the following conditions are met: * + Redistributions of source code must retain the above copyright notice, this list of conditions + and the following disclaimer. * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. * Neither the name of the Hiroshima + University nor the names of its contributors may be used to endorse or promote products derived + from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE + COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT + NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +13. Some of the cuRAND library routines were derived from code developed by D. E. Shaw Research and + are subject to the following license: + + Copyright 2010-2011, D. E. Shaw Research. All rights reserved. Redistribution and use in source + and binary forms, with or without modification, are permitted provided that the following + conditions are met: * Redistributions of source code must retain the above copyright notice, + this list of conditions, and the following disclaimer. * Redistributions in binary form must + reproduce the above copyright notice, this list of conditions, and the following disclaimer in + the documentation and/or other materials provided with the distribution. * Neither the name of + D. E. Shaw Research nor the names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED + BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A + PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, + OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT + OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +14. Some of the Math library routines were written by or derived from code developed by Norbert + Juffa and are subject to the following license: + + Copyright (c) 2015-2017, Norbert Juffa All rights reserved. Redistribution and use in source and + binary forms, with or without modification, are permitted provided that the following conditions + are met: 1. Redistributions of source code must retain the above copyright notice, this list of + conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the + above copyright notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED + BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A + PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, + OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT + OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +15. Licensee’s use of the lz4 third party component is subject to the following terms and + conditions: + + Copyright (C) 2011-2013, Yann Collet. BSD 2-Clause License + (http://www.opensource.org/licenses/bsd-license.php) Redistribution and use in source and binary + forms, with or without modification, are permitted provided that the following conditions are + met: * Redistributions of source code must retain the above copyright notice, this list of + conditions and the following disclaimer. * Redistributions in binary form must reproduce the + above copyright notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED + BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A + PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, + OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT + OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +16. The NPP library uses code from the Boost Math Toolkit, and is subject to the following license: + + Boost Software License - Version 1.0 - August 17th, 2003 . . . . Permission is hereby granted, + free of charge, to any person or organization obtaining a copy of the software and accompanying + documentation covered by this license (the "Software") to use, reproduce, display, distribute, + execute, and transmit the Software, and to prepare derivative works of the Software, and to + permit third-parties to whom the Software is furnished to do so, all subject to the following: + The copyright notices in the Software and this entire statement, including the above license + grant, this restriction and the following disclaimer, must be included in all copies of the + Software, in whole or in part, and all derivative works of the Software, unless such copies or + derivative works are solely in the form of machine-executable object code generated by a source + language processor. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR + ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN + CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + OR OTHER DEALINGS IN THE SOFTWARE. + +17. Portions of the Nsight Eclipse Edition is subject to the following license: + + The Eclipse Foundation makes available all content in this plug-in ("Content"). Unless otherwise + indicated below, the Content is provided to you under the terms and conditions of the Eclipse + Public License Version 1.0 ("EPL"). A copy of the EPL is available at http:// + www.eclipse.org/legal/epl-v10.html. For purposes of the EPL, "Program" will mean the Content. If + you did not receive this Content directly from the Eclipse Foundation, the Content is being + redistributed by another party ("Redistributor") and different terms and conditions may apply to + your use of any object code in the Content. Check the Redistributor's license that was provided + with the Content. If no such license exists, contact the Redistributor. Unless otherwise + indicated below, the terms and conditions of the EPL still apply to any source code in the + Content and such source code may be obtained at http://www.eclipse.org. + +18. Some of the cuBLAS library routines uses code from OpenAI, which is subject to the following + license: + + License URL https://github.com/openai/openai-gemm/blob/master/LICENSE License Text The MIT + License Copyright (c) 2016 OpenAI (http://openai.com), 2016 Google Inc. Permission is hereby + granted, free of charge, to any person obtaining a copy of this software and associated + documentation files (the "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the Software is furnished to + do so, subject to the following conditions: The above copyright notice and this permission + notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS + PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO + THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO + EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +19. Licensee’s use of the Visual Studio Setup Configuration Samples is subject to the following + license: + + The MIT License (MIT) Copyright (C) Microsoft Corporation. All rights reserved. Permission is + hereby granted, free of charge, to any person obtaining a copy of this software and associated + documentation files (the "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the Software is furnished to + do so, subject to the following conditions: The above copyright notice and this permission + notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS + PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO + THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO + EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +20. Licensee’s use of linmath.h header for CPU functions for GL vector/matrix operations from lunarG + is subject to the Apache License Version 2.0. + +21. The DX12-CUDA sample uses the d3dx12.h header, which is subject to the MIT license . + +22. Components of the driver and compiler used for binary management, including nvFatBin, nvcc, and + cuobjdump, use the Zstandard library which is subject to the following license: + + BSD License For Zstandard software Copyright (c) Meta Platforms, Inc. and affiliates. All rights + reserved. Redistribution and use in source and binary forms, with or without modification, are + permitted provided that the following conditions are met: * Redistributions of source code must + retain the above copyright notice, this list of conditions and the following disclaimer. * + Redistributions in binary form must reproduce the above copyright notice, this list of + conditions and the following disclaimer in the documentation and/or other materials provided + with the distribution. * Neither the name Facebook, nor Meta, nor the names of its contributors + may be used to endorse or promote products derived from this software without specific prior + written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE + GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + +23. Components of the ctadvisor component, use the SQLite library which is subject to the following + license: + + All of the code and documentation in SQLite has been dedicated to the public domain by the + authors. All code authors, and representatives of the companies they work for, have signed + affidavits dedicating their contributions to the public domain and originals of those signed + affidavits are stored in a firesafe at the main offices of Hwaci. All contributors are citizens + of countries that allow creative works to be dedicated into the public domain. Anyone is free to + copy, modify, publish, use, compile, sell, or distribute the original SQLite code, either in + source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by + any means. + +24. Portions of support for math operations on 128-bit floating-point data type in compiler were + derived from SLEEF library which is subject to the following license: + + Boost Software License - Version 1.0 - August 17th, 2003 Permission is hereby granted, free of + charge, to any person or organization obtaining a copy of the software and accompanying + documentation covered by this license (the "Software") to use, reproduce, display, distribute, + execute, and transmit the Software, and to prepare derivative works of the Software, and to + permit third-parties to whom the Software is furnished to do so, all subject to the following: + The copyright notices in the Software and this entire statement, including the above license + grant, this restriction and the following disclaimer, must be included in all copies of the + Software, in whole or in part, and all derivative works of the Software, unless such copies or + derivative works are solely in the form of machine-executable object code generated by a source + language processor. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR + ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN + CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + OR OTHER DEALINGS IN THE SOFTWARE. + +25. Portions of support for math operations on 128-bit floating-point data type in compiler were + derived from SoftFloat library which is subject to the following license: + + The SoftFloat package was written by me, John R. Hauser. Release 3 of SoftFloat was a completely + new implementation supplanting earlier releases. The project to create Release 3 (now through + 3e) was done in the employ of the University of California, Berkeley, within the Department of + Electrical Engineering and Computer Sciences, first for the Parallel Computing Laboratory (Par + Lab) and then for the ASPIRE Lab. The work was officially overseen by Prof. Krste Asanovic, with + funding provided by these sources: Par Lab: Microsoft (Award #024263), Intel (Award #024894), + and U.C. Discovery (Award #DIG07-10227), with additional support from Par Lab affiliates Nokia, + NVIDIA, Oracle, and Samsung. ASPIRE Lab: DARPA PERFECT program (Award #HR0011-12-2-0016), with + additional support from ASPIRE industrial sponsor Intel and ASPIRE affiliates Google, Nokia, + NVIDIA, Oracle, and Samsung. The following applies to the whole of SoftFloat Release 3e as well + as to each source file individually. Copyright 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018 + The Regents of the University of California. All rights reserved. Redistribution and use in + source and binary forms, with or without modification, are permitted provided that the following + conditions are met: Redistributions of source code must retain the above copyright notice, this + list of conditions, and the following disclaimer. Redistributions in binary form must reproduce + the above copyright notice, this list of conditions, and the following disclaimer in the + documentation and/or other materials provided with the distribution. Neither the name of the + University nor the names of its contributors may be used to endorse or promote products derived + from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE + REGENTS AND CONTRIBUTORS "AS IS", AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, ARE + DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/freevideo_engine/licenses/runtime/NVIDIA-cuDNN-SLA.txt b/freevideo_engine/licenses/runtime/NVIDIA-cuDNN-SLA.txt new file mode 100644 index 00000000..a51475d9 --- /dev/null +++ b/freevideo_engine/licenses/runtime/NVIDIA-cuDNN-SLA.txt @@ -0,0 +1,312 @@ +NVIDIA cuDNN Software License Agreement (v. February 22, 2022), from the cuDNN documentation page last updated September 2, 2026. Source: https://docs.nvidia.com/deeplearning/cudnn/latest/reference/eula.html + +Software License Agreement + +LICENSE AGREEMENT FOR NVIDIA SOFTWARE DEVELOPMENT KITS + +This license agreement, including exhibits attached (“Agreement”) is a legal agreement between you +and NVIDIA Corporation (“NVIDIA”) and governs your use of a NVIDIA software development kit (“SDK”). + +Each SDK has its own set of software and materials, but here is a description of the types of items +that may be included in a SDK: source code, header files, APIs, data sets and assets (examples +include images, textures, models, scenes, videos, native API input/output files), binary software, +sample code, libraries, utility programs, programming code and documentation. + +This Agreement can be accepted only by an adult of legal age of majority in the country in which the +SDK is used. + +If you are entering into this Agreement on behalf of a company or other legal entity, you represent +that you have the legal authority to bind the entity to this Agreement, in which case “you” will +mean the entity you represent. + +If you don’t have the required age or authority to accept this Agreement, or if you don’t accept all +the terms and conditions of this Agreement, do not download, install or use the SDK. + +You agree to use the SDK only for purposes that are permitted by (a) this Agreement, and (b) any +applicable law, regulation or generally accepted practices or guidelines in the relevant +jurisdictions. + +License. + +Grant + +Subject to the terms of this Agreement, NVIDIA hereby grants you a non-exclusive, nontransferable +license, without the right to sublicense (except as expressly provided in this Agreement) to: + +1. Install and use the SDK, + +2. Modify and create derivative works of sample source code delivered in the SDK, and + +3. Distribute those portions of the SDK that are identified in this Agreement as distributable, as + incorporated in object code format into a software application that meets the distribution + requirements indicated in this Agreement. + +Distribution Requirements + +These are the distribution requirements for you to exercise the distribution grant: + +1. Your application must have material additional functionality, beyond the included portions of the + SDK. + +2. The distributable portions of the SDK shall only be accessed by your application. + +3. The following notice shall be included in modifications and derivative works of sample source + code distributed: “This software contains source code provided by NVIDIA Corporation.” + +4. Unless a developer tool is identified in this Agreement as distributable, it is delivered for + your internal use only. + +5. The terms under which you distribute your application must be consistent with the terms of this + Agreement, including (without limitation) terms relating to the license grant and license + restrictions and protection of NVIDIA’s intellectual property rights. Additionally, you agree + that you will protect the privacy, security and legal rights of your application users. + +6. You agree to notify NVIDIA in writing of any known or suspected distribution or use of the SDK + not in compliance with the requirements of this Agreement, and to enforce the terms of your + agreements with respect to distributed SDK. + +Authorized Users + +You may allow employees and contractors of your entity or of your subsidiary(ies) to access and use +the SDK from your secure network to perform work on your behalf. + +If you are an academic institution you may allow users enrolled or employed by the academic +institution to access and use the SDK from your secure network. + +You are responsible for the compliance with the terms of this Agreement by your authorized users. If +you become aware that your authorized users didn’t follow the terms of this Agreement, you agree to +take reasonable steps to resolve the non-compliance and prevent new occurrences. + +Pre-Release SDK + +The SDK versions identified as alpha, beta, preview or otherwise as pre-release, may not be fully +functional, may contain errors or design flaws, and may have reduced or different security, privacy, +accessibility, availability, and reliability standards relative to commercial versions of NVIDIA +software and materials. Use of a pre-release SDK may result in unexpected results, loss of data, +project delays or other unpredictable damage or loss. + +You may use a pre-release SDK at your own risk, understanding that pre-release SDKs are not intended +for use in production or business-critical systems. + +NVIDIA may choose not to make available a commercial version of any pre-release SDK. NVIDIA may also +choose to abandon development and terminate the availability of a prerelease SDK at any time without +liability. + +Updates + +NVIDIA may, at its option, make available patches, workarounds or other updates to this SDK. Unless +the updates are provided with their separate governing terms, they are deemed part of the SDK +licensed to you as provided in this Agreement. + +You agree that the form and content of the SDK that NVIDIA provides may change without prior notice +to you. While NVIDIA generally maintains compatibility between versions, NVIDIA may in some cases +make changes that introduce incompatibilities in future versions of the SDK. + +Components Under Other Licenses + +The SDK may come bundled with, or otherwise include or be distributed with, NVIDIA or third party +software licensed with separate legal notices or terms as may be described in proprietary notices +accompanying the SDK. If and to the extent there is a conflict between the terms in this Agreement +and the license terms associated with the component, the license terms associated with the +components control only to the extent necessary to resolve the conflict. + +Reservation of Rights + +NVIDIA reserves all rights, title and interest in and to the SDK not expressly granted to you under +this Agreement. + +Limitations. + +The following license limitations apply to your use of the SDK: + +2.1 You may not reverse engineer, decompile or disassemble, or remove copyright or other proprietary +notices from any portion of the SDK or copies of the SDK. + +2.2 Except as expressly provided in this Agreement, you may not copy, sell, rent, sublicense, +transfer, distribute, modify, or create derivative works of any portion of the SDK. + +2.3 Unless you have an agreement with NVIDIA for this purpose, you may not indicate that an +application created with the SDK is sponsored or endorsed by NVIDIA. + +2.4 You may not bypass, disable, or circumvent any encryption, security, digital rights management +or authentication mechanism in the SDK. + +2.5 You may not use the SDK in any manner that would cause it to become subject to an open source +software license. As examples, licenses that require as a condition of use, modification, and/or +distribution that the SDK be (i) disclosed or distributed in source code form; (ii) licensed for the +purpose of making derivative works; or (iii) redistributable at no charge. + +2.6 You acknowledge that the SDK as delivered is not tested or certified by NVIDIA for use in +connection with the design, construction, maintenance, and/or operation of any system where the use +or failure of such system could result in a situation that threatens the safety of human life or +results in catastrophic damages (each, a “Critical Application”). Examples of Critical Applications +include use in avionics, navigation, autonomous vehicle applications, ai solutions for automotive +products, military, medical, life support or other life critical applications. NVIDIA shall not be +liable to you or any third party, in whole or in part, for any claims or damages arising from such +uses. You are solely responsible for ensuring that any product or service developed with the SDK as +a whole includes sufficient features to comply with all applicable legal and regulatory standards +and requirements. + +2.7 You agree to defend, indemnify and hold harmless NVIDIA and its affiliates, and their respective +employees, contractors, agents, officers and directors, from and against any and all claims, +damages, obligations, losses, liabilities, costs or debt, fines, restitutions and expenses +(including but not limited to attorney’s fees and costs incident to establishing the right of +indemnification) arising out of or related to products or services that use the SDK in or for +Critical Applications, and for use of the SDK, outside of the scope of this Agreement or not in +compliance with its terms. + +Ownership. + +3.1 NVIDIA or its licensors hold all rights, title and interest in and to the SDK and its +modifications and derivative works, including their respective intellectual property rights, subject +to your rights under Section 3.2. This SDK may include software and materials from NVIDIA’s +licensors, and these licensors are intended third party beneficiaries that may enforce this +Agreement with respect to their intellectual property rights. + +3.2 You hold all rights, title and interest in and to your applications and your derivative works of +the sample source code delivered in the SDK, including their respective intellectual property +rights, subject to NVIDIA’s rights under section 3.1. + +3.3 You may, but don’t have to, provide to NVIDIA suggestions, feature requests or other feedback +regarding the SDK, including possible enhancements or modifications to the SDK. For any feedback +that you voluntarily provide, you hereby grant NVIDIA and its affiliates a perpetual, non-exclusive, +worldwide, irrevocable license to use, reproduce, modify, license, sublicense (through multiple +tiers of sublicensees), and distribute (through multiple tiers of distributors) it without the +payment of any royalties or fees to you. NVIDIA will use feedback at its choice. NVIDIA is +constantly looking for ways to improve its products, so you may send feedback to NVIDIA through the +developer portal at https://developer.nvidia.com. + +No Warranties. + +THE SDK IS PROVIDED BY NVIDIA “AS IS” AND “WITH ALL FAULTS.” TO THE MAXIMUM EXTENT PERMITTED BY LAW, +NVIDIA AND ITS AFFILIATES EXPRESSLY DISCLAIM ALL WARRANTIES OF ANY KIND OR NATURE, WHETHER EXPRESS, +IMPLIED OR STATUTORY, INCLUDING, BUT NOT LIMITED TO, ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR +A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, OR THE ABSENCE OF ANY DEFECTS THEREIN, WHETHER LATENT +OR PATENT. NO WARRANTY IS MADE ON THE BASIS OF TRADE USAGE, COURSE OF DEALING OR COURSE OF TRADE. + +Limitations of Liability. + +TO THE MAXIMUM EXTENT PERMITTED BY LAW, NVIDIA AND ITS AFFILIATES SHALL NOT BE LIABLE FOR ANY +SPECIAL, INCIDENTAL, PUNITIVE OR CONSEQUENTIAL DAMAGES, OR ANY LOST PROFITS, LOSS OF USE, LOSS OF +DATA OR LOSS OF GOODWILL, OR THE COSTS OF PROCURING SUBSTITUTE PRODUCTS, ARISING OUT OF OR IN +CONNECTION WITH THIS AGREEMENT OR THE USE OR PERFORMANCE OF THE SDK, WHETHER SUCH LIABILITY ARISES +FROM ANY CLAIM BASED UPON BREACH OF CONTRACT, BREACH OF WARRANTY, TORT (INCLUDING NEGLIGENCE), +PRODUCT LIABILITY OR ANY OTHER CAUSE OF ACTION OR THEORY OF LIABILITY. IN NO EVENT WILL NVIDIA’S AND +ITS AFFILIATES TOTAL CUMULATIVE LIABILITY UNDER OR ARISING OUT OF THIS AGREEMENT EXCEED US$10.00. +THE NATURE OF THE LIABILITY OR THE NUMBER OF CLAIMS OR SUITS SHALL NOT ENLARGE OR EXTEND THIS LIMIT. + +These exclusions and limitations of liability shall apply regardless if NVIDIA or its affiliates +have been advised of the possibility of such damages, and regardless of whether a remedy fails its +essential purpose. These exclusions and limitations of liability form an essential basis of the +bargain between the parties, and, absent any of these exclusions or limitations of liability, the +provisions of this Agreement, including, without limitation, the economic terms, would be +substantially different. + +Termination. + +6.1 This Agreement will continue to apply until terminated by either you or NVIDIA as described +below. + +6.2 If you want to terminate this Agreement, you may do so by stopping to use the SDK. + +6.3 NVIDIA may, at any time, terminate this Agreement if: (i) you fail to comply with any term of +this Agreement and the non-compliance is not fixed within thirty (30) days following notice from +NVIDIA (or immediately if you violate NVIDIA’s intellectual property rights); (ii) you commence or +participate in any legal proceeding against NVIDIA with respect to the SDK; or (iii) NVIDIA decides +to no longer provide the SDK in a country or, in NVIDIA’s sole discretion, the continued use of it +is no longer commercially viable. + +6.4 Upon any termination of this Agreement, you agree to promptly discontinue use of the SDK and +destroy all copies in your possession or control. Your prior distributions in accordance with this +Agreement are not affected by the termination of this Agreement. Upon written request, you will +certify in writing that you have complied with your commitments under this section. Upon any +termination of this Agreement all provisions survive except for the licenses granted to you. + +General. + +If you wish to assign this Agreement or your rights and obligations, including by merger, +consolidation, dissolution or operation of law, contact NVIDIA to ask for permission. Any attempted +assignment not approved by NVIDIA in writing shall be void and of no effect. NVIDIA may assign, +delegate or transfer this Agreement and its rights and obligations, and if to a non-affiliate you +will be notified. + +You agree to cooperate with NVIDIA and provide reasonably requested information to verify your +compliance with this Agreement. + +This Agreement will be governed in all respects by the laws of the United States and of the State of +Delaware as those laws are applied to contracts entered into and performed entirely within Delaware +by Delaware residents, without regard to the conflicts of laws principles. The United Nations +Convention on Contracts for the International Sale of Goods is specifically disclaimed. You agree to +all terms of this Agreement in the English language. + +The state or federal courts residing in Santa Clara County, California shall have exclusive +jurisdiction over any dispute or claim arising out of this Agreement. Notwithstanding this, you +agree that NVIDIA shall still be allowed to apply for injunctive remedies or an equivalent type of +urgent legal relief in any jurisdiction. + +If any court of competent jurisdiction determines that any provision of this Agreement is illegal, +invalid or unenforceable, such provision will be construed as limited to the extent necessary to be +consistent with and fully enforceable under the law and the remaining provisions will remain in full +force and effect. Unless otherwise specified, remedies are cumulative. + +Each party acknowledges and agrees that the other is an independent contractor in the performance of +this Agreement. + +The SDK has been developed entirely at private expense and is “commercial items” consisting of +“commercial computer software” and “commercial computer software documentation” provided with +RESTRICTED RIGHTS. Use, duplication or disclosure by the U.S. Government or a U.S. Government +subcontractor is subject to the restrictions in this Agreement pursuant to DFARS 227.7202-3(a) or as +set forth in subparagraphs (b)(1) and (2) of the Commercial Computer Software - Restricted Rights +clause at FAR 52.227-19, as applicable. Contractor/manufacturer is NVIDIA, 2788 San Tomas +Expressway, Santa Clara, CA 95051 + +The SDK is subject to United States export laws and regulations. You agree that you will not ship, +transfer or export the SDK into any country, or use the SDK in any manner, prohibited by the United +States Bureau of Industry and Security or economic sanctions regulations administered by the U.S. +Department of Treasury’s Office of Foreign Assets Control (OFAC), or any applicable export laws, +restrictions or regulations. These laws include restrictions on destinations, end users and end use. +By accepting this Agreement, you confirm that you are not a resident or citizen of any country +currently embargoed by the U.S. and that you are not otherwise prohibited from receiving the SDK. + +Any notice delivered by NVIDIA to you under this Agreement will be delivered via mail, email or fax. +You agree that any notices that NVIDIA sends you electronically will satisfy any legal communication +requirements. Please direct your legal notices or other correspondence to NVIDIA Corporation, 2788 +San Tomas Expressway, Santa Clara, California 95051, United States of America, Attention: Legal +Department. + +This Agreement and any exhibits incorporated into this Agreement constitute the entire agreement of +the parties with respect to the subject matter of this Agreement and supersede all prior +negotiations or documentation exchanged between the parties relating to this SDK license. Any +additional and/or conflicting terms on documents issued by you are null, void, and invalid. Any +amendment or waiver under this Agreement shall be in writing and signed by representatives of both +parties. + +(v. February 22, 2022) + +cuDNN SUPPLEMENT TO SOFTWARE LICENSE AGREEMENT FOR NVIDIA SOFTWARE DEVELOPMENT KITS + +The terms in this supplement govern your use of the NVIDIA cuDNN SDK under the terms of your license +agreement (“Agreement”) as modified by this supplement. Capitalized terms used but not defined below +have the meaning assigned to them in the Agreement. + +This supplement is an exhibit to the Agreement and is incorporated as an integral part of the +Agreement. In the event of conflict between the terms in this supplement and the terms in the +Agreement, the terms in this supplement govern. + +4.1 License Scope. The SDK is licensed for you to develop applications only for use in systems with +NVIDIA GPUs. + +2. Distribution. The following portions of the SDK are distributable under the Agreement: the +runtime files .so and .dll. + +In addition to the rights above, for parties that are developing software intended solely for use on +Jetson development kits or Jetson modules and running Linux for Tegra software the following shall +apply: the SDK may be distributed in its entirety, as provided by NVIDIA and without separation of +its components, for you and/or your licensees to create software development kits for use only on +the Jetson platform and running Linux for Tegra software. + +3. Licensing. If the distribution terms in this Agreement are not suitable for your organization, or +for any questions regarding this Agreement, please contact NVIDIA at +nvidia-compute-license-questions@nvidia.com. + +(v. February 22, 2022) diff --git a/freevideo_engine/licenses/runtime/ROCm-Device-Libs-NCSA.txt b/freevideo_engine/licenses/runtime/ROCm-Device-Libs-NCSA.txt new file mode 100644 index 00000000..6b214f66 --- /dev/null +++ b/freevideo_engine/licenses/runtime/ROCm-Device-Libs-NCSA.txt @@ -0,0 +1,45 @@ +University of Illinois/NCSA Open Source License of the ROCm Device Libraries. Source: https://github.com/ROCm/llvm-project/blob/amd-staging/amd/device-libs/LICENSE.TXT + +============================================================================== +ROCm-Device-Libs Release License +============================================================================== +University of Illinois/NCSA +Open Source License + +Copyright (c) 2014-2016, Advanced Micro Devices, Inc. +All rights reserved. + +Developed by: + + AMD Research and AMD HSA Software Development + + Advanced Micro Devices, Inc. + + www.amd.com + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal with +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimers. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimers in the + documentation and/or other materials provided with the distribution. + + * Neither the names of the LLVM Team, University of Illinois at + Urbana-Champaign, nor the names of its contributors may be used to + endorse or promote products derived from this Software without specific + prior written permission. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE +SOFTWARE. diff --git a/freevideo_engine/licenses/runtime/SOURCES.txt b/freevideo_engine/licenses/runtime/SOURCES.txt new file mode 100644 index 00000000..668383b9 --- /dev/null +++ b/freevideo_engine/licenses/runtime/SOURCES.txt @@ -0,0 +1,6 @@ +TinyCC (tcc.exe, libtcc.dll and its runtime files under python/Lib/site-packages/triton/runtime/tcc/ +of the FreeVideo Windows runtime) is licensed under the GNU LGPL v2.1, in TinyCC-LGPL-2.1.txt beside +this file. Its complete source code, TinyCC at commit 05bb79344d84983a4d4e7ddba36af0fdc098a3c8 (tcc +0.9.28rc 2026-03-07_mob@05bb793, as tcc.exe of triton-windows 3.7.1.post27 reports), is at +https://github.com/TinyCC/tinycc/tree/05bb79344d84983a4d4e7ddba36af0fdc098a3c8. We will also provide +it on request: https://github.com/FlashML-org/FreeVideo/issues diff --git a/freevideo_engine/licenses/runtime/THIRD_PARTY_NOTICES.txt b/freevideo_engine/licenses/runtime/THIRD_PARTY_NOTICES.txt new file mode 100644 index 00000000..58f9ed8d --- /dev/null +++ b/freevideo_engine/licenses/runtime/THIRD_PARTY_NOTICES.txt @@ -0,0 +1,177 @@ +Third-party components in the FreeVideo runtime + +FreeVideo's own Apache-2.0 license does not cover the components below. The NVIDIA and Intel +components are under their vendors' terms: they may not be reverse engineered and may not be +redistributed on their own, apart from FreeVideo. As a recipient of the Intel components, you are +bound by the limitations of liability in Section 7 of the Intel license +(Intel-EULA-Developer-Tools.txt, beside this file). The AMD and TinyCC components are open source +under the licenses named in their sections. + +The files listed are those of the FreeVideo Windows runtime, relative to the FreeVideo-Windows +folder of FreeVideo-运行环境.zip and FreeVideo-Windows-运行环境与ComfyUI-无模型.zip. The full license texts +named below are beside this file. + +NVIDIA CUDA libraries + License: NVIDIA CUDA Toolkit End User License Agreement; full text in NVIDIA-CUDA-EULA.txt + https://docs.nvidia.com/cuda/eula/index.html + Files: + python/Lib/site-packages/torch/lib/cublas64_13.dll + python/Lib/site-packages/torch/lib/cublasLt64_13.dll + python/Lib/site-packages/torch/lib/cudart64_13.dll + python/Lib/site-packages/torch/lib/cufft64_12.dll + python/Lib/site-packages/torch/lib/cufftw64_12.dll + python/Lib/site-packages/torch/lib/cupti64_2025.3.0.dll + python/Lib/site-packages/torch/lib/curand64_10.dll + python/Lib/site-packages/torch/lib/cusolver64_12.dll + python/Lib/site-packages/torch/lib/cusolverMg64_12.dll + python/Lib/site-packages/torch/lib/cusparse64_12.dll + python/Lib/site-packages/torch/lib/nvJitLink_130_0.dll + python/Lib/site-packages/torch/lib/nvToolsExt64_1.dll + python/Lib/site-packages/torch/lib/nvperf_host.dll + python/Lib/site-packages/torch/lib/nvrtc-builtins64_130.dll + python/Lib/site-packages/torch/lib/nvrtc64_130_0.alt.dll + python/Lib/site-packages/torch/lib/nvrtc64_130_0.dll + +NVIDIA cuDNN + License: NVIDIA cuDNN Software License Agreement; full text in NVIDIA-cuDNN-SLA.txt + https://docs.nvidia.com/deeplearning/cudnn/latest/reference/eula.html + Files: + python/Lib/site-packages/torch/lib/cudnn64_9.dll + python/Lib/site-packages/torch/lib/cudnn_adv64_9.dll + python/Lib/site-packages/torch/lib/cudnn_cnn64_9.dll + python/Lib/site-packages/torch/lib/cudnn_engines_precompiled64_9.dll + python/Lib/site-packages/torch/lib/cudnn_engines_runtime_compiled64_9.dll + python/Lib/site-packages/torch/lib/cudnn_graph64_9.dll + python/Lib/site-packages/torch/lib/cudnn_heuristic64_9.dll + python/Lib/site-packages/torch/lib/cudnn_ops64_9.dll + +NVIDIA CUDA tools in triton-windows + License: NVIDIA CUDA Toolkit End User License Agreement; full text in NVIDIA-CUDA-EULA.txt + https://docs.nvidia.com/cuda/eula/index.html + Files: + python/Lib/site-packages/triton/backends/nvidia/bin/ptxas.exe + python/Lib/site-packages/triton/backends/nvidia/include/cuda.h + python/Lib/site-packages/triton/backends/nvidia/lib/libdevice.10.bc + python/Lib/site-packages/triton/backends/nvidia/lib/x64/cuda.lib + +Intel OpenMP runtime + License: Intel End User License Agreement for Developer Tools (Version August 2024); full text in Intel-EULA-Developer-Tools.txt + https://www.intel.com/content/www/us/en/developer/articles/license/end-user-license-agreement.html + Files: + python/Lib/site-packages/torch/lib/libiomp5md.dll + python/Lib/site-packages/torch/lib/libiompstubs5md.dll + +AMD device libraries in triton-windows + License: University of Illinois/NCSA Open Source License; full text in ROCm-Device-Libs-NCSA.txt + https://github.com/ROCm/llvm-project/blob/amd-staging/amd/device-libs/LICENSE.TXT + Files: + python/Lib/site-packages/triton/backends/amd/lib/asanrtl.bc + python/Lib/site-packages/triton/backends/amd/lib/ockl.bc + python/Lib/site-packages/triton/backends/amd/lib/ocml.bc + +TinyCC in triton-windows + License: GNU Lesser General Public License v2.1; full text in TinyCC-LGPL-2.1.txt; source code: see SOURCES.txt + https://github.com/TinyCC/tinycc + Files: + python/Lib/site-packages/triton/runtime/tcc/include/_mingw.h + python/Lib/site-packages/triton/runtime/tcc/include/assert.h + python/Lib/site-packages/triton/runtime/tcc/include/conio.h + python/Lib/site-packages/triton/runtime/tcc/include/ctype.h + python/Lib/site-packages/triton/runtime/tcc/include/dir.h + python/Lib/site-packages/triton/runtime/tcc/include/direct.h + python/Lib/site-packages/triton/runtime/tcc/include/dirent.h + python/Lib/site-packages/triton/runtime/tcc/include/dos.h + python/Lib/site-packages/triton/runtime/tcc/include/errno.h + python/Lib/site-packages/triton/runtime/tcc/include/excpt.h + python/Lib/site-packages/triton/runtime/tcc/include/fcntl.h + python/Lib/site-packages/triton/runtime/tcc/include/fenv.h + python/Lib/site-packages/triton/runtime/tcc/include/float.h + python/Lib/site-packages/triton/runtime/tcc/include/inttypes.h + python/Lib/site-packages/triton/runtime/tcc/include/io.h + python/Lib/site-packages/triton/runtime/tcc/include/iso646.h + python/Lib/site-packages/triton/runtime/tcc/include/limits.h + python/Lib/site-packages/triton/runtime/tcc/include/locale.h + python/Lib/site-packages/triton/runtime/tcc/include/malloc.h + python/Lib/site-packages/triton/runtime/tcc/include/math.h + python/Lib/site-packages/triton/runtime/tcc/include/mem.h + python/Lib/site-packages/triton/runtime/tcc/include/memory.h + python/Lib/site-packages/triton/runtime/tcc/include/process.h + python/Lib/site-packages/triton/runtime/tcc/include/sec_api/conio_s.h + python/Lib/site-packages/triton/runtime/tcc/include/sec_api/crtdbg_s.h + python/Lib/site-packages/triton/runtime/tcc/include/sec_api/io_s.h + python/Lib/site-packages/triton/runtime/tcc/include/sec_api/mbstring_s.h + python/Lib/site-packages/triton/runtime/tcc/include/sec_api/search_s.h + python/Lib/site-packages/triton/runtime/tcc/include/sec_api/stdio_s.h + python/Lib/site-packages/triton/runtime/tcc/include/sec_api/stdlib_s.h + python/Lib/site-packages/triton/runtime/tcc/include/sec_api/stralign_s.h + python/Lib/site-packages/triton/runtime/tcc/include/sec_api/string_s.h + python/Lib/site-packages/triton/runtime/tcc/include/sec_api/sys/timeb_s.h + python/Lib/site-packages/triton/runtime/tcc/include/sec_api/tchar_s.h + python/Lib/site-packages/triton/runtime/tcc/include/sec_api/time_s.h + python/Lib/site-packages/triton/runtime/tcc/include/sec_api/wchar_s.h + python/Lib/site-packages/triton/runtime/tcc/include/setjmp.h + python/Lib/site-packages/triton/runtime/tcc/include/share.h + python/Lib/site-packages/triton/runtime/tcc/include/signal.h + python/Lib/site-packages/triton/runtime/tcc/include/stdalign.h + python/Lib/site-packages/triton/runtime/tcc/include/stdarg.h + python/Lib/site-packages/triton/runtime/tcc/include/stdatomic.h + python/Lib/site-packages/triton/runtime/tcc/include/stdbool.h + python/Lib/site-packages/triton/runtime/tcc/include/stddef.h + python/Lib/site-packages/triton/runtime/tcc/include/stdint.h + python/Lib/site-packages/triton/runtime/tcc/include/stdio.h + python/Lib/site-packages/triton/runtime/tcc/include/stdlib.h + python/Lib/site-packages/triton/runtime/tcc/include/stdnoreturn.h + python/Lib/site-packages/triton/runtime/tcc/include/string.h + python/Lib/site-packages/triton/runtime/tcc/include/sys/fcntl.h + python/Lib/site-packages/triton/runtime/tcc/include/sys/file.h + python/Lib/site-packages/triton/runtime/tcc/include/sys/locking.h + python/Lib/site-packages/triton/runtime/tcc/include/sys/stat.h + python/Lib/site-packages/triton/runtime/tcc/include/sys/time.h + python/Lib/site-packages/triton/runtime/tcc/include/sys/timeb.h + python/Lib/site-packages/triton/runtime/tcc/include/sys/types.h + python/Lib/site-packages/triton/runtime/tcc/include/sys/unistd.h + python/Lib/site-packages/triton/runtime/tcc/include/sys/utime.h + python/Lib/site-packages/triton/runtime/tcc/include/tcc/tcc_libm.h + python/Lib/site-packages/triton/runtime/tcc/include/tccdefs.h + python/Lib/site-packages/triton/runtime/tcc/include/tcclib.h + python/Lib/site-packages/triton/runtime/tcc/include/tchar.h + python/Lib/site-packages/triton/runtime/tcc/include/tgmath.h + python/Lib/site-packages/triton/runtime/tcc/include/time.h + python/Lib/site-packages/triton/runtime/tcc/include/uchar.h + python/Lib/site-packages/triton/runtime/tcc/include/unistd.h + python/Lib/site-packages/triton/runtime/tcc/include/vadefs.h + python/Lib/site-packages/triton/runtime/tcc/include/values.h + python/Lib/site-packages/triton/runtime/tcc/include/varargs.h + python/Lib/site-packages/triton/runtime/tcc/include/wchar.h + python/Lib/site-packages/triton/runtime/tcc/include/wctype.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/basetsd.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/basetyps.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/guiddef.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/poppack.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/pshpack1.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/pshpack2.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/pshpack4.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/pshpack8.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/qos.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/shellapi.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/winbase.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/wincon.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/windef.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/windows.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/winerror.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/wingdi.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/winnls.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/winnt.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/winreg.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/winsock2.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/winuser.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/winver.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/ws2ipdef.h + python/Lib/site-packages/triton/runtime/tcc/include/winapi/ws2tcpip.h + python/Lib/site-packages/triton/runtime/tcc/lib/gdi32.def + python/Lib/site-packages/triton/runtime/tcc/lib/kernel32.def + python/Lib/site-packages/triton/runtime/tcc/lib/libtcc1.a + python/Lib/site-packages/triton/runtime/tcc/lib/msvcrt.def + python/Lib/site-packages/triton/runtime/tcc/lib/user32.def + python/Lib/site-packages/triton/runtime/tcc/libtcc.dll + python/Lib/site-packages/triton/runtime/tcc/tcc.exe diff --git a/freevideo_engine/licenses/runtime/TinyCC-LGPL-2.1.txt b/freevideo_engine/licenses/runtime/TinyCC-LGPL-2.1.txt new file mode 100644 index 00000000..de6951f3 --- /dev/null +++ b/freevideo_engine/licenses/runtime/TinyCC-LGPL-2.1.txt @@ -0,0 +1,504 @@ +GNU Lesser General Public License v2.1, as in COPYING of TinyCC at commit 05bb79344d84983a4d4e7ddba36af0fdc098a3c8. Source: https://github.com/TinyCC/tinycc/blob/05bb79344d84983a4d4e7ddba36af0fdc098a3c8/COPYING + + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/freevideo_engine/macos_shortcut.py b/freevideo_engine/macos_shortcut.py index dc256c63..c5cd6d8e 100644 --- a/freevideo_engine/macos_shortcut.py +++ b/freevideo_engine/macos_shortcut.py @@ -9,7 +9,7 @@ import tempfile import uuid -from .desktop_shortcut import digest +from .desktop_shortcut import digest, read_build from .monitoring import save @@ -79,16 +79,28 @@ def valid(path): def create(engine): receipt = Path(engine) / 'launcher/desktop-shortcut.json' - target = retained_application(engine) desktop = desktop_directory().resolve() path = desktop / 'FreeVideo.app' try: previous = json.loads(receipt.read_text(encoding='utf-8')) except (OSError, ValueError): previous = {} + if not isinstance(previous, dict): + previous = {} old = Path(previous.get('path', str(path))) owned = (old.parent == desktop and old.is_symlink() and os.readlink(old) == previous.get('target')) + if owned: + from .launcher_update import current_build + from .release_notes import older + # build_macos.py adds the stamp as a root data file. PyInstaller puts it + # in Resources, cross-linked from Frameworks (the bootloader's _MEIPASS). + identity = read_build(old / 'Contents/Resources/launcher-build.json') + if not identity: + identity = read_build(old / 'Contents/Frameworks/launcher-build.json') + if older(current_build() or {}, identity): + return dict(previous, status='present') + target = retained_application(engine) if owned: path = old if previous['target'] == str(target): diff --git a/freevideo_engine/model_repair.py b/freevideo_engine/model_repair.py new file mode 100644 index 00000000..f7ab0043 --- /dev/null +++ b/freevideo_engine/model_repair.py @@ -0,0 +1,245 @@ +"""Review and retain local files that differ from the model FreeVideo tests.""" +import ctypes +import errno +import json +import os +from pathlib import Path +import stat +import sys +import time +import uuid + +from .disk_space import size_text +from .monitoring import save + + +# Shared copy for setup, the launcher and the browser plan. +REVIEW = ( + '{file} ({size}) differs from the version FreeVideo tests: it will be renamed to {keep_as} and kept, ' + 'and the tested version ({expected_size}) will be downloaded.', + '{file}({size})与 FreeVideo 测试过的版本不同:将改名为 {keep_as} 保留,' + '再下载测试过的版本({expected_size})。', +) +PROGRESS = ('Keeping the original file: ', '正在保留原文件:') +FAILURE_TITLE = ('An existing model differs from the version FreeVideo tests', '已有模型与 FreeVideo 测试过的版本不同') +FAILURE = ( + '{file} differs from the version FreeVideo tests and was left unchanged. Click {entry} again: ' + 'the confirmation page will list this file, and after you confirm, it will be renamed and kept, ' + 'and the tested version will be downloaded.', + '{file} 与 FreeVideo 测试过的版本不同,这次没有改动它。请再次点击“{entry}”:' + '确认页会列出这个文件,确认后将它改名保留,再下载测试过的版本。', +) +CLI_FAILURE = FAILURE[0].replace('Click {entry} again', 'Run setup again (./setup.sh, or setup.cmd on Windows)') +MISMATCH = 'MODEL_MISMATCH_REPAIR' +MISMATCH_PATH = 'MODEL_MISMATCH_PATH' +# Where the launcher offers no Repair: the first sentence of FAILURE, then one way out +# for each reason (failure_details.WITHOUT_REPAIR). +UNCHANGED = ('{file} differs from the version FreeVideo tests and was left unchanged.', + '{file} 与 FreeVideo 测试过的版本不同,这次没有改动它。') +BUSY = ('When the current task ends, a Repair button appears here; after you click it and confirm, ' + 'FreeVideo renames and keeps this file, then downloads the tested version.', + '当前任务结束后,这里会出现“修复”按钮;点击并确认后,FreeVideo 会把这个文件改名保留,再下载测试过的版本。') +OFFLINE = ('This offline-package installation cannot switch back in one step yet. Move or rename the file below, ' + 'then import the Text encoder package again on the Installation page.', + '离线包安装暂时不能一键换回。请把下面这个文件移走或改名,再在“安装”页重新导入“文本编码器”包。') +FIRST_INSTALL = ( + '{file} differs from the version FreeVideo tests and was left unchanged. Click Retry installation below: ' + 'the confirmation page will list this file, and after you confirm, it is renamed and kept, ' + 'and the tested version is downloaded.', + '{file} 与 FreeVideo 测试过的版本不同,这次没有改动它。请点击下方的“重试安装”:' + '确认页会列出这个文件,确认后会将它改名保留,再下载测试过的版本。', +) +CHANGED = 'The installation plan changed. Detect configuration again and review the new plan before installing.' +# A file the review listed changed before setup reached it (the confirmed plan +# can also differ in other ways: bootstrap.confirmed raises plain CHANGED). +FILE_CHANGED = 'A file in the installation folder changed after the review. ' + CHANGED +CHANGED_TITLE = ('The installation plan changed', '安装计划有变化') +CHANGED_WHY = dict( + files=('Files in the installation folder changed after you confirmed the plan, so FreeVideo stopped instead of ' + '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 因此停止,未按原计划继续。'), +) +CHANGED_REPAIR = ('Click Repair again to review the new plan.', '请再次点击“修复”,查看新的计划。') +CHANGED_WAYS = dict( + busy=('When the current task ends, a Repair button appears here; click it to review the new plan.', + '当前任务结束后,这里会出现“修复”按钮;点击后可以查看新的计划。'), + none=('Click Retry installation below to review the new plan.', '请点击下方的“重试安装”,查看新的计划。'), +) +IDENTITY = ('bytes', 'mtime_ns', 'st_dev', 'st_ino', 'parent_realpath') + + +def review_lines(rows, zh=False): + return [dict(text=REVIEW[zh].format(file=row['file'], size=size_text(row['bytes']), keep_as=row['keep_as'], + expected_size=size_text(row['expected_bytes'])), path=row['path']) for row in rows] + + +def identity(path): + try: + info = path.lstat() + except FileNotFoundError: + 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)) + return None + + +def same_identity(current, approved): + if not isinstance(current, dict) or not isinstance(approved, dict): + return False + keys = IDENTITY + if os.name == 'nt' and (current.get('st_ino') == 0 or approved.get('st_ino') == 0): + keys = ('bytes', 'mtime_ns', 'parent_realpath') + return all(key in current and key in approved and current[key] == approved[key] for key in keys) + + +def expected(row): + for algorithm in ('sha256', 'git_blob'): + if row.get(algorithm): + return dict(algorithm=algorithm, digest=row[algorithm]) + return None + + +def read(root): + try: + value = json.loads((Path(root) / 'logs/model-mismatches.json').read_text(encoding='utf-8')) + except (FileNotFoundError, ValueError): + return {} + return value if isinstance(value, dict) else {} + + +def replacement(path, row, mismatches, reserved): + current = identity(path) + if current is None: + return None + prior = mismatches.get(str(path)) + recorded = (same_identity(current, prior) and expected(row) is not None + and prior.get('expected') == expected(row)) + if current['bytes'] != row['bytes'] or recorded: + name = path.name + '.replaced-' + time.strftime('%Y%m%d') + target = path.with_name(name) + index = 2 + while target.exists() or target.is_symlink() or target in reserved: + target = path.with_name(name + '-' + str(index)) + index += 1 + reserved.add(target) + return dict(path=str(path), **current, expected_bytes=row['bytes'], file=path.name, keep_as=target.name) + return None + + +def record(root, path, row, before): + if before is not None and same_identity(identity(path), before): + values = read(root) + values[str(path)] = dict(before, expected=expected(row), + recorded_at=time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())) + save(Path(root) / 'logs/model-mismatches.json', values) + + +def check(path, approved): + if not same_identity(identity(path), approved): + raise ValueError(FILE_CHANGED + ' ' + str(path)) + + +def reuse_names(current, reviewed): + approved = {row['path']: row for row in reviewed} + for row in current: + prior = approved.get(row['path']) + if prior is None or row.get('keep_as') == prior.get('keep_as'): + continue + if {key: value for key, value in row.items() if key != 'keep_as'} != \ + {key: value for key, value in prior.items() if key != 'keep_as'}: + continue + name = prior.get('keep_as') + if valid_name(name) and not os.path.lexists(Path(row['path']).with_name(name)): + row['keep_as'] = name + + +def valid_name(name): + return isinstance(name, str) and name not in ('', '.', '..') and '/' not in name and '\\' not in name + + +def exclusive_rename(source, target): + """Rename in one call that refuses an existing target, where the system has + one (Linux renameat2, macOS renamex_np): True when moved, False when the + system or filesystem offers no such call.""" + try: + libc = ctypes.CDLL(None, use_errno=True) + if sys.platform == 'linux': + call = libc.renameat2 + call.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] + args = (-100, os.fsencode(source), -100, os.fsencode(target), 1) # AT_FDCWD, RENAME_NOREPLACE + elif sys.platform == 'darwin': + call = libc.renamex_np + call.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint] + args = (os.fsencode(source), os.fsencode(target), 4) # RENAME_EXCL + else: + return False + except (AttributeError, OSError): + return False + call.restype = ctypes.c_int + if call(*args) == 0: + return True + code = ctypes.get_errno() + if code in (errno.ENOSYS, errno.EINVAL, errno.ENOTSUP, errno.EOPNOTSUPP): + return False + raise OSError(code, os.strerror(code), str(target)) + + +def rename_noreplace(source, target): + """Move source to target without replacing target, and without removing a + file someone else puts at source meanwhile.""" + source, target = Path(source), Path(target) + if os.name == 'nt': + os.rename(source, target) # refuses an existing target + return + if exclusive_rename(source, target): + return + # No one-call rename here: link the file, move the old name aside under a + # private one, and remove that only while it is still the linked file. + os.link(source, target) + kept = os.lstat(target) + spare = source.with_name('.%s.%s.retaining' % (source.name, uuid.uuid4().hex[:12])) + os.rename(source, spare) + moved = os.lstat(spare) + if (moved.st_dev, moved.st_ino) != (kept.st_dev, kept.st_ino): + # Another file arrived at source in between: it goes back, and both stay. + os.link(spare, source) + os.unlink(spare) + raise ValueError(FILE_CHANGED + ' ' + str(source)) + try: + os.unlink(spare) + except OSError: + # Leave the original name in place rather than only the private one. + try: + os.link(spare, source) + except OSError: + pass + raise + + +def retain(root, path, approved): + name = approved.get('keep_as') + if not valid_name(name): + raise ValueError(CHANGED) + target = path.with_name(approved['keep_as']) + if target.exists() or target.is_symlink(): + raise ValueError(FILE_CHANGED + ' ' + str(target)) + check(path, approved) + try: + rename_noreplace(path, target) + except FileExistsError as error: + raise ValueError(FILE_CHANGED + ' ' + str(target)) from error + if not same_identity(identity(target), approved): + # Another file had replaced the approved one just before the move: put it back. + try: + rename_noreplace(target, path) + except (OSError, ValueError): + pass + raise ValueError(FILE_CHANGED + ' ' + str(path)) + values = read(root) + if str(path) in values: + del values[str(path)] + save(Path(root) / 'logs/model-mismatches.json', values) + return target diff --git a/freevideo_engine/modern_launcher.py b/freevideo_engine/modern_launcher.py index 63a031bc..dd5da355 100644 --- a/freevideo_engine/modern_launcher.py +++ b/freevideo_engine/modern_launcher.py @@ -218,8 +218,10 @@ def exportReport(self): @Slot(str) def link(self, value): - from .windows_ux import open_browser - self.invoke(open_browser, 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) @Slot(str) def selectSource(self, value): @@ -339,9 +341,24 @@ def smoke_test(app, engine, window, bridge, destination): window.setProperty('settingsOpen', True) app.processEvents() window.setProperty('settingsOpen', False) + # The licence list opens, and an offline runtime's licence texts open from the package itself. + from PySide6.QtCore import QObject + from . import component_licenses as licences + window.setProperty('licensesOpen', True) + app.processEvents() + dialog = window.findChild(QObject, 'licensesDialog') + if dialog is None or not dialog.property('visible'): + raise RuntimeError('Packaged launcher licence list does not open') + 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()} + if len(texts) != 4 or missing: + raise RuntimeError('Packaged licence texts are missing: %r' % (missing or texts)) session.page = 'comfy' bridge.refresh() - payload.update(wizard_pages=3, launcher_page=True, download_controls=True, renderer='Qt Quick/software') + payload.update(wizard_pages=3, launcher_page=True, download_controls=True, renderer='Qt Quick/software', + licence_texts=texts) build = current_build() if getattr(sys, 'frozen', False) and build is None: raise ValueError('Packaged launcher update identity is missing') diff --git a/freevideo_engine/offline_packages.py b/freevideo_engine/offline_packages.py index 91eb9414..89ea2dda 100644 --- a/freevideo_engine/offline_packages.py +++ b/freevideo_engine/offline_packages.py @@ -11,6 +11,7 @@ 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 PREFIX = 'FreeVideo-Windows/' @@ -306,6 +307,7 @@ 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) + check_bound_source(runtime, source) config = runtime_config(runtime) candidates = [] for root in models: @@ -377,10 +379,24 @@ def runtime_config(runtime): return config +def check_bound_source(runtime, source, value=None): + """Check the existing binding before copying code, models or encoder files.""" + runtime = Path(runtime) + if value is None: + try: + value = json.loads((runtime / 'portable.json').read_text(encoding='utf-8')) + except (OSError, ValueError): + return + if not isinstance(value, dict) or not value.get('source'): + return + check_source_release(source, inside(runtime, value['source'])) + + 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) + 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'] @@ -402,6 +418,18 @@ def finish(runtime, config, model, source, here=None): return runtime +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) + 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) + save(runtime / 'portable.json', value) + return code + + DOWNLOADING = '下载模型' @@ -416,6 +444,7 @@ def download(runtime, source, sampling_caches, progress=lambda **kw: None, cance from .desktop_runtime import materialize_source from . import processes runtime = 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' @@ -484,6 +513,9 @@ def update(**row): 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) @@ -502,6 +534,8 @@ def work(): self.state = dict(self.state, status='prepared', ready_root=str(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) diff --git a/freevideo_engine/offload.py b/freevideo_engine/offload.py index 6b850f93..85cefee7 100644 --- a/freevideo_engine/offload.py +++ b/freevideo_engine/offload.py @@ -415,6 +415,7 @@ def staging(count, dtype): # The weight source outlives this pass; report only this pass's view hits. self.host_view_hits_start = getattr(weight_source, 'host_view_hits', 0) self.host_view_hit_bytes_start = getattr(weight_source, 'host_view_hit_bytes', 0) + self.direct_reads_start = dict(getattr(weight_source, 'read_stats', None) or {}) self.hooks = [] self.expected = 0 self.closed = False @@ -740,6 +741,11 @@ def stats(self): 'host_view_hits': getattr(self.weight_source, 'host_view_hits', 0) - self.host_view_hits_start, 'host_view_hit_bytes': getattr(self.weight_source, 'host_view_hit_bytes', 0) - self.host_view_hit_bytes_start, 'host_view_released': getattr(self.weight_source, 'host_view_released', 0), + # Direct reads kept in the file cache between steps and those passing through. + 'cache_kept_layer_count': len(getattr(self.weight_source, 'cache_kept', None) or {}), + 'cache_kept_bytes': getattr(self.weight_source, 'cache_kept_bytes', 0), + **{'direct_' + name: value - self.direct_reads_start.get(name, 0) + for name, value in (getattr(self.weight_source, 'read_stats', None) or {}).items()}, 'host_prefetch': self.host_prefetch, 'host_prefetch_reads': self.host_prefetch_reads, 'host_prefetch_wait_seconds': self.host_prefetch_wait_seconds, diff --git a/freevideo_engine/package_cleanup.py b/freevideo_engine/package_cleanup.py index cb8608be..dd1949b2 100644 --- a/freevideo_engine/package_cleanup.py +++ b/freevideo_engine/package_cleanup.py @@ -143,7 +143,8 @@ def discard(path, root, running=None, dry_run=False): """ from .version_cleanup import started_from, plain_tree path, root = Path(path), Path(root) - if not local_path(root, path) or started_from(path, running) or (path.is_dir() and not plain_tree(path)): + 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 freed = unique_bytes(path) if dry_run: diff --git a/freevideo_engine/policy.py b/freevideo_engine/policy.py index 6309a3c8..00b48348 100644 --- a/freevideo_engine/policy.py +++ b/freevideo_engine/policy.py @@ -118,6 +118,7 @@ # head 8 with none (8.43 s, 4.18 GiB). WINDOWS_INT8_HEAD8_TOKENS = 72576 WINDOWS_INT8_HEAD8_EXTRA = int(1.15 * GiB) +WINDOWS_INT8_HEAD8_AMPERE_EXTRA = int(.3 * GiB) # Below that band an 8 GiB card stages the residual stream, and the staged # pass kept four heads. With the Windows allocator (no expandable segments) # capped at an 8 GiB laptop's 6.65 GiB budget, 1344x768x243 staged with its @@ -131,7 +132,16 @@ # eight heads against 81.8 with four, at 5.95 GB reserved. Eight heads are # charged the same extra over the Windows staged measurement and its margin, # not over the lower FP8-derived estimate, which leaves about 0.5 GiB over -# both peaks. Ampere has no Windows measurement with eight heads and keeps four. +# both peaks. Native Windows RTX 3090, held to an RTX 3070's 7.132 GB +# budget, 1344x768x243 with one image reference (552 rows), both passes staged: +# +# head 4 68.80/68.98 s/step 4.95 GB reserved +# head 8 65.22 6.25 GB (5.82 GiB) +# +# The first pass with eight heads reserved 2.75 GB. All four runs had no +# allocator retry, OOM or sample over the local budget. Ampere's second-pass +# peak is about 0.28 GiB above the RTX 5060 Ti's 5.95 GB (5.54 GiB), so charge +# another 0.3 GiB at 72576 rows, scaled by rows, to keep the same margin. WINDOWS_INT8_STAGED_HEAD8_TOKENS = 72576 + 4 * 1008 # Keeping the attention outputs on the GPU instead of in host memory costs this # much extra peak. See benchmarks/2026-09-15-consumer-capacity-grid.json. Measured at four corners of the band that uses the host @@ -974,7 +984,6 @@ def held(group): and canvas['frames'] <= WINDOWS_SMALL_HEAD8_FRAMES and WINDOWS_SMALL_HEAD8_TOKENS < effective_tokens <= WINDOWS_INT8_HEAD8_TOKENS) windows_int8_staged_head8 = (desktop and int8 and not lora and legs == ['sage2', 'sage2'] - and hardware.architecture != 'ampere' and effective_tokens is not None and canvas['frames'] <= WINDOWS_SMALL_HEAD8_FRAMES and effective_tokens <= WINDOWS_INT8_STAGED_HEAD8_TOKENS) @@ -1138,6 +1147,8 @@ def held(group): # heads there measured eight times slower. wider = (max(reserve, int8_gpu_output_need(effective_tokens, windows=True)) + int(WINDOWS_INT8_HEAD8_EXTRA * effective_tokens / WINDOWS_INT8_HEAD8_TOKENS)) + if hardware.architecture == 'ampere': + wider += int(WINDOWS_INT8_HEAD8_AMPERE_EXTRA * effective_tokens / WINDOWS_INT8_HEAD8_TOKENS) if gpu_budget >= wider: head, reserve = 8, wider else: @@ -1399,9 +1410,9 @@ def held(group): (canvas['width'], canvas['height'], canvas['frames'], canvas['video_tokens'], geometry_adjustment / GiB)) if desktop and small and head == 8: notes.append('Use eight-head groups for this small Windows canvas (%d generated and reference rows). ' - 'Its larger workspace allowance and GPU attention outputs fit inside the current budget; ' - 'weight residency pays for the wider group. Each pass is planned at its own total token count. ' - 'Measured speed benefit is from PRO 6000 Linux; native Windows throughput remains unmeasured.' + 'Its larger workspace allowance and GPU attention outputs fit inside the current budget. ' + 'Each pass is planned at its own total token count. ' + 'Eight heads measured faster on native Windows as well as on Linux.' % effective_tokens) if capacity_trial: notes.append('The %.2f GiB live budget is below the %.2f GiB working-set estimate. ' diff --git a/freevideo_engine/portable_launcher.py b/freevideo_engine/portable_launcher.py index b1cf9d9b..19fec1a9 100644 --- a/freevideo_engine/portable_launcher.py +++ b/freevideo_engine/portable_launcher.py @@ -5,7 +5,7 @@ import subprocess import time -from . import portable, processes +from . import component_licenses, portable, processes from .comfy_launcher import Launcher from .comfy_launcher_runtime import Controller, local_url, matches_server, server_info from .monitoring import save @@ -14,14 +14,19 @@ class PortableController(Controller): - def __init__(self, root): + def __init__(self, root, bundled=None): self.root = Path(root).resolve() self.bundle = portable.manifest(self.root) super().__init__(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 self.child = None self.initialization_log = None self.comfy_controller = None self.fixed_environment = True + # What the first start asks the user to accept, read once from the file list. + self.licenses = component_licenses.bundle(self.root, self.bundle) def restore(self, values): # Restoring the launcher is read-only. A bundled deployment is not yet @@ -31,10 +36,72 @@ def restore(self, values): engine=str(self.root/'engine'), source=str(self.source), python=str(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)) + self.state = dict(status='bundled', selection=dict(self.selection), + engine_update_available=self.engine_outdated()) self.restore_terminal(self.selection['engine']) return True + def engine_outdated(self): + """This launcher carries other engine code than the installation it assembled runs. + + A full bundle starts the code inside it, and an environment that no + 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(): + 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'))) + except (OSError, ValueError, AttributeError): + return False + return fits and not matching_source(self.bundled, self.source) + + def owns_server(self): + # The ComfyUI a launch started belongs to that connection's controller. + controller = self.comfy_controller + return controller is not None and controller.owns_server() + + def _inspect(self, values): + """The session's engine update asks first; nothing is downloaded or installed.""" + self.restore(values) + if not self.state['engine_update_available']: + raise ValueError('This offline installation already runs the engine this launcher carries') + self.update_values = dict(values) + self.state = dict(self.state, status='review', action='inspect', host=dict(ready=True), + selection=dict(self.selection, ready=True)) + + def _install(self, accepted): + """Start the launcher's engine code with this installation's environment and models. + + A start that fails puts the previous code back, so the next start works as before.""" + if not accepted or self.update_values is None: + raise ValueError('Review the engine update first') + values, self.update_values = self.update_values, None + if self.comfy_controller is not None: + # Our idle ComfyUI runs the previous code; a running job is never stopped. + self.comfy_controller._stop_server_for_setup(self.selection['url']) + previous = self.bundle + from .offline_packages import rebind + rebind(self.root, self.bundled) + try: + self.bundle = portable.manifest(self.root) + self.source = portable.inside(self.root, self.bundle['source']) + self._launch(values) + except BaseException: + try: + # Only this update's binding goes back: another launcher may have changed it since. + if portable.manifest(self.root).get('source') == self.bundle['source']: + save(self.root/'portable.json', previous) + self.bundle = portable.manifest(self.root) + self.source = portable.inside(self.root, self.bundle['source']) + # The selection and the offered update follow the code now bound, so Update now stays. + self.restore(values) + except (OSError, ValueError): + pass # The start's own failure is the one to report. + raise + def terminal_sources(self): sources = super().terminal_sources() if self.initialization_log: @@ -242,6 +309,8 @@ def portable_session(root): os.environ.update(portable.environment(root, portable.manifest(root))) os.environ['FREEVIDEO_LAUNCHER_HOME'] = str(root/'engine/launcher') class PortableSession(Session): + bundled = True + def __init__(self): controller = PortableController(root) self.consent_path = root/'engine/portable-consent.json' diff --git a/freevideo_engine/precompile.py b/freevideo_engine/precompile.py new file mode 100644 index 00000000..4d57ab74 --- /dev/null +++ b/freevideo_engine/precompile.py @@ -0,0 +1,496 @@ +"""Compile the sampling kernels of recent requests after the engine or the GPU driver changed. + +An engine update that changes a fused block kernel, an attention layout or an +int8 projection tile leaves the first request after it to compile them: on a +12 GB Windows laptop that request's first sampling step took 172 s instead of +65 s. The compiled kernels depend on the request's plan (the head group, the +frame layout, the projection tiles and the prompt's row count band), so this +plans the most recent distinct request shapes from the resource history -- or +the node's default request on a new installation -- exactly as a request would, +and runs each through precompile_worker until its second block. The kernels +land in the installation's Triton and inductor caches, where every later +process finds them. + +Nothing waits for it: background_compile starts it while an interactive +session is idle and stops it when work arrives. The identity it records holds +the code, the packages and the GPU driver, so any of them changing compiles +again at the next idle start. It writes no measurement or compatibility +record; opening the resource history applies the schema upgrade a request's +opening would. A failure or an interruption only leaves the request to +compile what is missing, as it always did. +""" +import argparse +import json +import os +from pathlib import Path +import re +import shutil +import sys +import tempfile +import threading +import time + +from .monitoring import save + +# The node's default request: 1344x768, 10 s, two passes of 8 + 3 steps. The +# default prompt encodes to 460 tokens. +DEFAULT_SHAPE = dict(task='t2va', width=1344, height=768, frames=243, two_pass=True, + base_steps=8, refine_steps=3, text_tokens=(460,)) +SHAPES = 2 +LENGTHS = 2 +TIMEOUT_SECONDS = 600 +# Another process holds the GPU work lease: try again at the next idle start. +BUSY_EXIT = 75 +# Set for the worker of a background run, which lowers its own disk priority. +BACKGROUND_ENV = 'FREEVIDEO_PRECOMPILE_LOW_PRIORITY' +# The interactive process that started a background run; its GPU use is not another program's. +OWNER_ENV = 'FREEVIDEO_BACKGROUND_OWNER' + + +class GiveWay(BlockingIOError): + """The user's other programs need the memory or the GPU: stop without a receipt.""" + + +# Set when the interactive session stops a background run. A signal can land +# inside any handler that recovers from ordinary errors, so it only sets this; +# the run gives way at its next checkpoint. +CANCELLED = threading.Event() + + +def checkpoint(room=None, sample=None): + """GiveWay when the session stopped this run or the room is needed now.""" + if CANCELLED.is_set(): + raise GiveWay('stopped by FreeVideo') + reason = room.run_reason(sample) if room is not None else None + if reason: + raise GiveWay(reason) + + +def recent_shapes(history, limit=SHAPES): + """Newest distinct text-to-video request shapes, each with its recent prompt lengths. + + A preview and its upscale count as their two-pass request. They keep no + resource observation, so their prompt length is the one their forecast used. + """ + shapes = {} + for row in reversed(history.requests(limit=64)): + geometry, observation = row.get('geometry') or {}, row.get('observation') or {} + if row.get('purpose') != 'generation' or geometry.get('task', 't2va') != 't2va': + continue + plan = geometry.get('sampling_plan') or {} + shape = dict(task='t2va', width=geometry.get('width'), height=geometry.get('height'), + frames=geometry.get('frames'), two_pass=bool(plan.get('enabled')), + base_steps=plan.get('base_steps', geometry.get('steps', 8)), + refine_steps=plan.get('refine_steps') or 3) + if not all(type(shape[name]) is int and shape[name] > 0 for name in ('width', 'height', 'frames', 'base_steps')): + continue + key = json.dumps(shape, sort_keys=True) + if key not in shapes and len(shapes) >= limit: + continue + lengths = shapes.setdefault(key, (shape, []))[1] + tokens = observation.get('text_tokens') + if tokens is None: + evidence = (row.get('details') or {}).get('decision_evidence') or {} + tokens = ((evidence.get('prediction') or {}).get('geometry') or {}).get('text_tokens') + if type(tokens) is int and tokens > 0 and tokens not in lengths and len(lengths) < LENGTHS: + lengths.append(tokens) + return [dict(shape, text_tokens=tuple(lengths) or DEFAULT_SHAPE['text_tokens']) + for shape, lengths in shapes.values()] + + +def plan_request(machine, shape, history, identity): + """The worker request a generation of this shape would start with.""" + from .compatibility import Store, apply + from .generate import automatic_profile + from .geometry import geometry + from .prediction_policy import select + from .adaptive import recovered_compute + from .two_pass import plan, first_pass_policy + canvas = geometry(shape['width'], shape['height'], frames=shape['frames']) + if shape['base_steps'] != 8: + canvas['steps'] = shape['base_steps'] + from .resource_settings import read as read_resources + # The installation's capacity caps and the user's saved reserve, as a + # request's managed command line carries them. + args = argparse.Namespace(vram_gib=machine.get('vram_gib'), ram_gib=machine.get('ram_gib'), attention='auto', + gpu_reserve_gib=None, ram_reserve_gib=None, task=shape['task'], + cache=Path(machine['cache']), prompt_file=None) + for field, value in read_resources(machine['root'], backend=machine.get('device_backend') or 'cuda').items(): + setattr(args, field, value) + hardware, _, profile = automatic_profile(args, canvas, stage='after-encoding', evidence=[]) + profile['engine'].update(steps=shape['base_steps'], task=shape['task'], + base=machine['base'], checkpoint=machine['checkpoint']) + sampling_plan = plan(canvas, shape['two_pass'], shape['task'], + base_steps=shape['base_steps'], refine_steps=shape['refine_steps']) + if sampling_plan['enabled'] or sampling_plan['version'] == 2: + canvas['sampling_plan'] = sampling_plan + profile = tuned(profile, machine, hardware, canvas, sampling_plan, shape['text_tokens'][0]) + level = None + if history is not None: + # An update changes the code identity, so its first request finds no + # matching placements; a request after a repair or on a fresh + # installation does, and so does this plan. + observations = history.observations(identity=identity, limit=512, include_details=False) + profile, _ = select(profile, canvas, hardware, observations, identity, text_tokens=shape['text_tokens'][0]) + profile, _ = recovered_compute(profile, history, identity, canvas) + level = Store(history.path.parent).status(identity) + profile, _ = apply(profile, level) + request = dict(cache=machine['cache'], gpu_budget_bytes=round(profile['gpu_budget_gb'] * 1e9), + gpu_reserve_bytes=profile.get('policy', {}).get('gpu_system_reserve_bytes', 0), + engine_options=profile['engine'], decoder_options=profile['decoder'], geometry=canvas, + sampling_plan=sampling_plan, + text_tokens=list(shape['text_tokens'])) + first = first_pass_policy(profile, canvas, sampling_plan) + if first is not None: + if level is not None: + first['profile'], _ = apply(first['profile'], level) + request['first_pass_policy'] = first + return request, profile + + +def tuned(profile, machine, hardware, canvas, sampling_plan, tokens): + """The validated local optimization a request of this canvas would apply, as generate does.""" + from . import tuning + try: + if sampling_plan['version'] not in (1, 3) or not tuning.state_path().is_file(): + return profile + state, _ = tuning.load_state(tuning.identity(machine, hardware)) + return tuning.apply_profile(state, profile, canvas, tokens)[0] if state else profile + except Exception: + return profile # The request finds no usable optimization either. + + +def run(machine, *, environment=None, timeout=TIMEOUT_SECONDS, background=False, owner=None): + """Compile each shape in its own worker; the receipt lists what ran and how long. + + In the background it starts only with room beside the user's other + programs and gives way when they need it (background_room): GiveWay. + """ + from .adaptive import history_path, local_identity + from .hardware import detect + from .resource_history import ResourceHistory + environment = dict(os.environ if environment is None else environment) + root = Path(machine['root']) + receipt = dict(success=False, started=time.time(), shapes=[]) + if machine.get('gpu_vendor') == 'amd': + # Not measured on AMD GPUs yet; their first request compiles as before. + return dict(receipt, skipped=True, reason='AMD GPU') + try: + hardware = detect() + history = ResourceHistory(history_path()) + identity = local_identity(hardware, Path(machine['cache'])) + shapes = recent_shapes(history) or [DEFAULT_SHAPE] + except Exception as error: + hardware, history, identity, shapes = None, None, None, [DEFAULT_SHAPE] + receipt['history_error'] = repr(error) + receipt.update(identity=identity, requested=shapes) + try: + previous = json.loads((root / 'precompile.json').read_text(encoding='utf-8')) + except (OSError, ValueError): + previous = {} + receipt['caches'] = compiler_caches(environment) + receipt['inputs'] = plan_inputs(machine, identity, history) + if (identity is not None and previous.get('success') and previous.get('identity') == identity + and previous.get('requested') == json.loads(json.dumps(shapes)) + and previous.get('inputs') == receipt['inputs'] + and previous.get('caches') == receipt['caches'] and marked(receipt['caches'], previous.get('started'))): + # Same code, packages, GPU, request shapes, plan inputs and compiler + # caches, which still hold what that run wrote: nothing to compile. + return dict(previous, skipped=True) + checkpoint() + room = None + if background: + from .background_room import DEFAULT_RAM_NEED, Room, recent_need + room = Room(machine.get('gpu_uuid'), owner=owner, + need_bytes=recent_need(history) if history is not None else DEFAULT_RAM_NEED) + reason = room.start_reason() + if reason: + room.close() + raise GiveWay(reason) + try: + checkpoint(room) + if hardware is not None: + # After a driver or package change the first request re-runs the GPU + # kernel checks (about 36 s on Windows) before it plans; do it here, + # and plan with the backends it leaves, as that request will. + try: + check_kernels(hardware, machine, environment, room) + except BlockingIOError: + raise + except Exception as error: + receipt['probe_error'] = repr(error) + return compile_shapes(machine, shapes, history, identity, receipt, environment, timeout, room) + finally: + if room is not None: + samples = room.samples() + if samples: + print(json.dumps(dict(event='gpu_samples', samples=samples)), flush=True) + room.close() + + +def plan_inputs(machine, identity, history): + """What decides a request's plan besides code and shape: the caps, the saved + reserve, validated tuning and the compatibility level.""" + import hashlib + from . import tuning + from .resource_settings import read as read_resources + value = dict(caps={name: machine.get(name) for name in ('vram_gib', 'ram_gib')}) + try: + value['resources'] = read_resources(machine['root'], backend=machine.get('device_backend') or 'cuda') + except Exception as error: + value['resources'] = repr(error) + try: + path = tuning.state_path() + value['tuning'] = hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None + except OSError as error: + value['tuning'] = repr(error) + try: + from .compatibility import Store + level = Store(history.path.parent).status(identity) if history is not None and identity is not None else {} + value['compatibility'] = {name: level.get(name) for name in ('level', 'automatic')} + except Exception as error: + value['compatibility'] = repr(error) + return json.loads(json.dumps(value, default=str)) + + +def plan_lengths(machine, shape, history, identity): + """[(prompt lengths, request, profile)]: one worker request per distinct plan. + + History placement and validated tuning both select by prompt length, so + two lengths can plan different kernels; lengths that plan alike share one. + """ + groups = {} + for tokens in shape['text_tokens']: + request, profile = plan_request(machine, dict(shape, text_tokens=(tokens,)), history, identity) + first = (request.get('first_pass_policy') or {}).get('profile', {}).get('engine') + key = json.dumps([request['engine_options'], request['decoder_options'], first], sort_keys=True, default=str) + if key in groups: + groups[key][0].append(tokens) + else: + groups[key] = ([tokens], request, profile) + for tokens, request, _ in groups.values(): + request['text_tokens'] = list(tokens) + return list(groups.values()) + + +def check_kernels(hardware, machine, environment, room): + """Run the GPU kernel checks when theirs no longer match this GPU, driver and packages. + + In the background they run in a child this process watches like a worker + and stops when the room is needed; an interrupted check publishes no receipt. + """ + from .kernel_capabilities import available_backends, matching_receipt + if room is None: + available_backends(hardware) + return + if matching_receipt(hardware) is not None: + return + # A forced stop skips the cleanup below; an hour-old folder is no live check. + for stale in Path(tempfile.gettempdir()).glob('freevideo-kernel-checks-*'): + try: + if stale.is_dir() and time.time() - stale.stat().st_mtime > 3600: + shutil.rmtree(stale, ignore_errors=True) + except OSError: + pass + # Not a precompile-* folder: those belong to the lease holder's cleanup. + with tempfile.TemporaryDirectory(prefix='freevideo-kernel-checks-', ignore_cleanup_errors=True) as folder: + log = Path(folder) / 'kernel-checks.log' + returncode = watch_worker([sys.executable, '-B', '-m', 'freevideo_engine.managed', '--root', str(machine['root']), + 'doctor', '--probe'], environment, None, log, TIMEOUT_SECONDS, room) + if returncode: + raise RuntimeError('Kernel checks exited with %d: %s' % ( + returncode, log.read_bytes()[-2000:].decode('utf-8', errors='replace'))) + + +def compile_shapes(machine, shapes, history, identity, receipt, environment, timeout, room): + from .locking import LOCK_ENV, runtime_lock + root = Path(machine['root']) + with runtime_lock() as descriptor, tempfile.TemporaryDirectory(prefix='precompile-', dir=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) + and re.fullmatch(r'precompile-[a-z0-9_]{8}', stale.name)): + shutil.rmtree(stale, ignore_errors=True) + index = 0 + for shape in shapes: + checkpoint(room) + tick = time.perf_counter() + try: + planned = plan_lengths(machine, shape, history, identity) + except BlockingIOError: + raise + except Exception as error: + receipt['shapes'].append(dict(shape=shape, error=repr(error), seconds=time.perf_counter() - tick)) + continue + for tokens, request, profile in planned: + checkpoint(room) + index += 1 + compile_plan(receipt, dict(shape, text_tokens=tuple(tokens)), request, profile, index, + Path(folder), environment, descriptor, timeout, room) + # 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']) + receipt['seconds'] = time.time() - receipt['started'] + if receipt['success']: + mark(receipt['caches'], receipt['started']) + save(root / 'precompile.json', receipt) + return receipt + + +def compile_plan(receipt, shape, request, profile, index, folder, environment, descriptor, timeout, room): + """Run one planned worker request and add its row to the receipt.""" + from .system import inference_environment + row = dict(shape=shape) + receipt['shapes'].append(row) + tick = time.perf_counter() + try: + request['conditioning'] = [str(zero_conditioning(folder, tokens)) for tokens in shape['text_tokens']] + request['receipt'] = str(folder / ('shape-%d.json' % index)) + path = folder / ('request-%d.json' % index) + save(path, request) + env = inference_environment(environment, profile['inference_ram_budget_gb'] * 1e9) + env.update(PYTORCH_ALLOC_CONF=profile['allocator_config'], PYTORCH_CUDA_ALLOC_CONF=profile['allocator_config']) + log = folder / ('worker-%d.log' % index) + returncode = watch_worker([sys.executable, '-m', 'freevideo_engine.precompile_worker', '--request', str(path)], + env, descriptor, log, timeout, room) + row.update(returncode=returncode, plan=dict( + {name: request['engine_options'].get(name) for name in ('head_chunk', 'prefetch', 'resident_blocks', 'linear_compute')}, + gpu_budget_gb=profile['gpu_budget_gb'])) + try: + row.update(worker=json.loads(Path(request['receipt']).read_text(encoding='utf-8'))) + except (OSError, ValueError): + pass + if returncode: + row['stderr_tail'] = log.read_bytes()[-4000:].decode('utf-8', errors='replace') + except BlockingIOError: + raise + except Exception as error: + row['error'] = repr(error) + row['seconds'] = time.perf_counter() - tick + + +# Compiler caches can be moved or emptied (a repair, a user, a new location). +# A marker beside the compiled files says this run's kernels are still there: +# in Triton's cache and in inductor's graph cache, which inductor clears on +# its own. The driver's cache is only compared by location. +CACHE_VARIABLES = ('TRITON_CACHE_DIR', 'TORCHINDUCTOR_CACHE_DIR', 'CUDA_CACHE_PATH') +MARKED = (('TRITON_CACHE_DIR', ''), ('TORCHINDUCTOR_CACHE_DIR', 'fxgraph')) +MARKER = 'freevideo-precompile.json' + + +def compiler_caches(environment): + return {name: environment.get(name) for name in CACHE_VARIABLES} + + +def markers(caches): + """The marker files, or None when a marked cache has no location.""" + if not all(caches.get(name) for name, _ in MARKED): + return None + return [Path(caches[name]) / folder / MARKER for name, folder in MARKED] + + +def mark(caches, started): + for path in markers(caches) or (): + try: + save(path, dict(started=started)) + except OSError: + pass # Unmarked: the next run compiles again. + + +def marked(caches, started): + try: + paths = markers(caches) + return paths is not None and all( + json.loads(path.read_text(encoding='utf-8')).get('started') == started for path in paths) + except (OSError, ValueError, AttributeError): + return False + + +def watch_worker(command, env, descriptor, log, timeout, room): + """Run one worker to its end; stop it when it times out or the room is needed.""" + import subprocess + from . import processes + from .ram import ProcessMemory + memory = ProcessMemory() if room is not None else None + checkpoint(room) # Never start a child after a stop or with the room needed. + with log.open('wb') as stream: + process = processes.popen(command, env=env, stdout=stream, stderr=subprocess.STDOUT, + pass_fds=(descriptor,) if descriptor is not None else (), start_new_session=True) + deadline = time.monotonic() + timeout + try: + while process.poll() is None: + checkpoint(room, memory.sample(process.pid) if room is not None else None) + if time.monotonic() > deadline: + raise subprocess.TimeoutExpired(command, timeout) + try: + process.wait(timeout=.5) + except subprocess.TimeoutExpired: + pass + except BaseException: + processes.stop(process, grace=1) + raise + return process.returncode + + +def zero_conditioning(folder, tokens): + """A text-to-video prompt of `tokens` rows; only its length reaches a kernel.""" + import torch + path = folder / ('zero-%d.pt' % tokens) + if not path.exists(): + torch.save({'prompt_embeds': torch.zeros(tokens, 5120, dtype=torch.bfloat16), + 'text_token_tags': torch.ones(tokens, dtype=torch.long), 'task': 't2va'}, path) + return path + + +def lower_priority(): + """Yield the CPU and the disk to anything the user is doing.""" + if sys.platform == 'win32': + import ctypes + kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) + process = kernel32.GetCurrentProcess() + # Idle class, which the worker and its compile processes inherit, and + # background mode for this process's disk and memory priority. + kernel32.SetPriorityClass(process, 0x40) + kernel32.SetPriorityClass(process, 0x00100000) + else: + os.nice(19) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--config', type=Path, required=True, help='The installation machine.json') + parser.add_argument('--background', action='store_true', help='Lowest priority; exit if the GPU is in use') + args = parser.parse_args(argv) + machine = json.loads(args.config.read_text(encoding='utf-8')) + if args.background: + lower_priority() + os.environ[BACKGROUND_ENV] = '1' + from .processes import termination_handler + def stopped(signum, frame): + # Stopped by the interactive session: give way at the next + # checkpoint, stopping the children and removing temporary folders + # on the way out, without a receipt. + CANCELLED.set() + termination_handler(stopped) + try: + from .testing import environment + # A request's own variables: the installation, its caches and its GPU. + os.environ.update(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: + if args.background and isinstance(error, BlockingIOError): + # The GPU or memory is in use: no receipt, so the next idle start tries again. + print(json.dumps(dict(event='give_way', reason=str(error), at=time.time())), flush=True) + return BUSY_EXIT + # FreeVideo works either way; record why. + receipt = dict(success=False, error=repr(error)) + save(Path(machine['root']) / 'precompile.json', receipt) + print(json.dumps({key: receipt.get(key) for key in ('success', 'skipped', 'seconds')})) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/freevideo_engine/precompile_worker.py b/freevideo_engine/precompile_worker.py new file mode 100644 index 00000000..686e4a7a --- /dev/null +++ b/freevideo_engine/precompile_worker.py @@ -0,0 +1,176 @@ +"""Compile a request shape's sampling kernels before its first generation. + +The first sampling step of a new engine compiles the fused block kernels and +tunes the int8 projections for the request's plan: the head group, the frame +layout and the projection tiles all reach the compiled shapes. All of that +happens in the first block of the first step, and it is the same in every +later block and step. On a 12 GB Windows laptop it took about 100 s of the +first request after an update; afterwards the Triton and inductor caches in +kernel-cache serve every process. + +This worker loads the engine with a planned request's placement, runs each +sampling pass until the second block is entered, and stops. Then, with the +engine closed as before a decode, it runs the latent upscale of a two-pass +plan and decodes one temporal clip at the request's canvas: the VAE decodes +clips of seven latent frames in spatial tiles laid out from the canvas, so a +22-frame video reaches every tile shape of a long one. On Windows the GPU +driver also finalizes each loaded kernel module once per driver version, which +these steps cover for the modules they load. + +The prompt is zeros of the encoder's width: row counts are compiled +dynamically, and the values never reach a kernel's shape. No video is kept, +and no resource history, compatibility or input cache is written. +""" +import argparse +from contextlib import contextmanager +import json +import os +from pathlib import Path +import time + +import torch + +from .locking import runtime_lock +from .monitoring import save +from .processes import worker_signals + + +class FirstBlockDone(Exception): + pass + + +@contextmanager +def first_block(engine): + """Stop sampling when the second transformer block is entered.""" + def stop(module, arguments): + if torch.cuda.is_initialized(): + torch.cuda.synchronize() + raise FirstBlockDone() + handle = engine.transformer.transformer_blocks[1].register_forward_pre_hook(stop) + try: + yield + finally: + handle.remove() + + +# The shortest video whose latents (seven frames) decode as one whole VAE clip. +CLIP_FRAMES = 22 + + +def zero_latents(channels, canvas): + """Zero video and audio latents in the shapes sampling produces for a canvas.""" + from diffusers.modular_pipelines.minimax_h3.modular_pipeline import ( + MINIMAX_H3_AUDIO_CHANNELS, align_num_frames, audio_latent_num_frames, video_latent_num_frames) + frames = align_num_frames(canvas['frames'], 17, 5) + video = torch.zeros((1, channels, video_latent_num_frames(frames, 17, 5), + canvas['height'] // 16, canvas['width'] // 16), device='cuda') + audio = torch.zeros((MINIMAX_H3_AUDIO_CHANNELS, 32, audio_latent_num_frames(frames)), device='cuda') + return video, audio + + +def upscale_once(plan, channels): + """A two-pass plan's latent upscale; None when setup has no upscaler checkpoint.""" + from .latent_upscale import upscale + from .two_pass import checkpoint_path + path = checkpoint_path() + if not path.is_file(): + return None + tick = time.perf_counter() + video, _ = zero_latents(channels, plan['first']) + upscale(video, str(path), plan['upscale_target']['width'], plan['upscale_target']['height']) + del video + torch.cuda.empty_cache() + return time.perf_counter() - tick + + +def decode_clip(base, decoder, channels, canvas, path): + """Decode one clip at the request's canvas with the request's decoder placement.""" + from .decode import decode_to_file + tick = time.perf_counter() + video, audio = zero_latents(channels, dict(canvas, frames=CLIP_FRAMES)) + decode_to_file(video, audio, str(path), base=base, **decoder) + del video, audio + torch.cuda.empty_cache() + return time.perf_counter() - tick + + +def first_pass(engine, conditioning, request): + """Run one pass until its second block; seconds taken.""" + tick = time.perf_counter() + try: + with first_block(engine): + engine.sample(conditioning, 0, **request) + except FirstBlockDone: + if torch.cuda.is_initialized(): + torch.cuda.empty_cache() + return time.perf_counter() - tick + raise RuntimeError('Sampling finished without entering a second block') + + +@torch.no_grad() +def compile_request(request): + from .runtime import Engine + from .worker import device_memory_report, prefetch_compiler_keys + torch.set_num_threads(8) + prefetch_compiler_keys() + receipt = dict(success=False, geometry=request['geometry'], passes=[]) + save(request['receipt'], receipt) + receipt['device_memory'] = device_memory_report(request['gpu_budget_bytes'], request.get('allocator_limit_bytes'), + reserve_bytes=request.get('gpu_reserve_bytes', 0)) + allocation = receipt['device_memory'].get('effective_allocator_limit_bytes') or request['gpu_budget_bytes'] + tick = time.perf_counter() + # A missing sampling table becomes zeros of its shape: it is installed by + # the request that uses it, never by setup. + engine = Engine(request['cache'], gpu_allocation_bytes=int(allocation), fetch_tables=False, + **dict(request['engine_options'], canvas=request['geometry'])) + receipt['load_seconds'] = time.perf_counter() - tick + plan = request['sampling_plan'] + channels, base = engine.transformer.config.in_channels, engine.base + final = plan['second'] if plan.get('enabled') else plan['first'] + try: + reserve = dict(gpu_reserve_bytes=request.get('gpu_reserve_bytes', 0)) + options = (request.get('first_pass_policy') or {}).get('profile', {}).get('engine') + # One prompt length per recent request: the int8 projections tune one + # kernel per power-of-two row band, and the prompt moves the bands of + # the tiles it falls into. Later lengths reuse every compiled graph. + for conditioning in request['conditioning']: + if plan.get('enabled'): + seconds = first_pass(engine, conditioning, dict(plan['first'], allow_smaller_canvas=True, + compute_options=options, pass_resident_blocks=options.get('resident_blocks') if options else None, + **reserve)) + receipt['passes'].append(dict(canvas=plan['first'], conditioning=conditioning, seconds=seconds)) + seconds = first_pass(engine, conditioning, dict(final, initial_latents=zero_latents(channels, final), + refine_steps=plan['refine_steps'], refine_schedule=plan.get('refine_schedule'), **reserve)) + else: + seconds = first_pass(engine, conditioning, dict(final, **reserve)) + receipt['passes'].append(dict(canvas=final, conditioning=conditioning, seconds=seconds)) + finally: + # Tables compiled with zeros in their shape; their requests install them. + receipt['table_placeholders'] = len(engine.table_placeholders) + receipt['table_downloaded_bytes'] = engine.config['adaln_downloaded_bytes'] + engine.close() + save(request['receipt'], receipt) + if plan.get('enabled') and plan.get('upscaler_sha256'): + receipt['upscale_seconds'] = upscale_once(plan, channels) + if request.get('decoder_options') is not None: + receipt['decode_seconds'] = decode_clip(base, request['decoder_options'], channels, final, + Path(request['receipt']).with_suffix('.mp4')) + receipt['success'] = True + save(request['receipt'], receipt) + return receipt + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--request', required=True) + args = parser.parse_args() + from .precompile import BACKGROUND_ENV, lower_priority + if os.environ.get(BACKGROUND_ENV): + lower_priority() + request = json.loads(Path(args.request).read_text(encoding='utf-8')) + with worker_signals(), runtime_lock(): + compile_request(request) + + +if __name__ == '__main__': + main() diff --git a/freevideo_engine/prompt_vlm/service.py b/freevideo_engine/prompt_vlm/service.py index ec3fd5f8..90e92500 100644 --- a/freevideo_engine/prompt_vlm/service.py +++ b/freevideo_engine/prompt_vlm/service.py @@ -1,7 +1,9 @@ """Optional rewrite jobs; prompts stay in memory and never enter job reports.""" import atexit import json +import os from pathlib import Path +import queue import secrets import subprocess import threading @@ -11,6 +13,61 @@ from . import assets, rules +class Worker: + """One pipe owner across idle imports and the subsequent rewrite.""" + def __init__(self, process): + self.process = process + self.started = time.monotonic() + self.claimed = False + self.rows = queue.Queue() + self.ready = threading.Event() + self.ready_at = None + self.import_seconds = None + self.pid = None + self.cuda_initialized = None + self.native = dict(stderr_characters=0, out_of_memory=False, cuda_error=False, fatal_python_error=False) + self.reader = threading.Thread(target=self.read, daemon=True) + self.error_reader = threading.Thread(target=self.read_errors, daemon=True) + self.reader.start() + self.error_reader.start() + + def read(self): + for line in self.process.stdout: + if len(line) > 128 * 1024: + continue + try: + row = json.loads(line) + except ValueError: + continue + if not isinstance(row, dict): + continue + if row.get('phase') == 'ready': + self.pid = row.get('pid') + self.cuda_initialized = row.get('cuda_initialized') + self.import_seconds = row.get('import_seconds') + self.ready_at = time.monotonic() + self.ready.set() + else: + self.rows.put(row) + + def read_errors(self): + tail = '' + for chunk in iter(lambda: self.process.stderr.read(1024), ''): + self.native['stderr_characters'] += len(chunk) + content = (tail + chunk).lower() + for key, phrase in [('out_of_memory', 'out of memory'), ('cuda_error', 'cuda error'), + ('fatal_python_error', 'fatal python error')]: + self.native[key] |= phrase in content + tail = content[-32:] + + def close(self): + self.reader.join(timeout=5) + self.error_reader.join(timeout=5) + for stream in (self.process.stdin, self.process.stdout, self.process.stderr): + if not stream.closed: + stream.close() + + class Service: def __init__(self, installation, input_directory): self.installation = installation @@ -24,8 +81,142 @@ def __init__(self, installation, input_directory): self.result_expires = 0. self.report = {} self.ram = None + self.prewarm = None + self.prewarm_state = dict(phase='idle', reason='none') + self.prestart_thread = None + self.prestart_cleanup_threads = set() + self.prestart_ticket = 0 + self.closed = False + self.request_epoch = None + self.active_worker = None atexit.register(self.close) + def prestart(self, root, machine, *, generating=False): + """Schedule imports without a lease or any GPU/model-cache yielding.""" + from ..resident_process import OWNER + with self.mutex: + reason = ('not_downloaded' if not assets.ready(root) else + 'disabled' if self.closed or os.environ.get('FREEVIDEO_PROMPT_PRESTART', '').lower() in ('0', 'off', 'false') else + 'generating' if generating or self.busy() or OWNER.active else None) + if reason: + self.prewarm_state = dict(phase='skipped', reason=reason) + return dict(self.prewarm_state) + if self.prewarm is not None and self.prewarm.process.poll() is None: + return dict(self.prewarm_state) + if self.prestart_thread is not None and self.prestart_thread.is_alive(): + return dict(self.prewarm_state) + if self.prewarm is not None: + expired, self.prewarm = self.prewarm, None + expired.process.wait() + expired.close() + self.prestart_ticket += 1 + ticket = self.prestart_ticket + self.prewarm_state = dict(phase='starting', reason='none') + self.prestart_thread = threading.Thread(target=self._prestart, args=(root, machine, ticket), + name='freevideo-prompt-imports', daemon=True) + self.prestart_thread.start() + return dict(self.prewarm_state) + + def _prestart(self, root, machine, ticket): + from ..resident_process import OWNER + from .worker import available_memory + try: + required = self.ram_bytes(machine) + 2 * 2**30 + available = available_memory() + env = self.environment(root) + with self.mutex: + if ticket != self.prestart_ticket: + return + reason = 'generating' if self.busy() or OWNER.active else 'memory' if available < required else None + if reason: + self.prewarm_state = dict(phase='skipped', reason=reason, + available_bytes=available, required_bytes=required) + return + from .worker import PRESTART_ENV + env[PRESTART_ENV] = '1' + session = Worker(self.spawn(root, machine, env)) + with self.mutex: + admitted = ticket == self.prestart_ticket + if admitted: + self.prewarm = session + self.prewarm_state = dict(phase='importing', reason='none') + if not admitted: + self._cleanup_prestart(session) + return + threading.Thread(target=self._watch_prestart, args=(session,), daemon=True).start() + except Exception: + with self.mutex: + if ticket == self.prestart_ticket: + self.prewarm_state = dict(phase='failed', reason='failed') + + def _watch_prestart(self, session): + while session.process.poll() is None: + with self.mutex: + if self.prewarm is not session: + return + if session.ready.is_set(): + self.prewarm_state = dict(phase='ready', reason='none', import_seconds=session.import_seconds) + session.ready.wait(.1) if not session.ready.is_set() else time.sleep(.1) + session.process.wait() + with self.mutex: + if self.prewarm is not session: + return + self.prewarm = None + reason = 'idle_exit' if session.process.returncode == 0 else 'failed' + self.prewarm_state = dict(phase='idle' if reason == 'idle_exit' else 'failed', reason=reason) + session.close() + + def stop_prestart(self, reason, *, wait=True): + with self.mutex: + self.prestart_ticket += 1 # Invalidate an admission check still in progress. + session, self.prewarm = self.prewarm, None + self.prewarm_state = dict(phase='stopped', reason=reason) + if session is not None and not wait: + cleanup = threading.Thread(target=self._cleanup_prestart, args=(session,), + name='freevideo-prompt-cleanup', daemon=True) + self.prestart_cleanup_threads.add(cleanup) + cleanup.start() + session = None + cleanups = tuple(self.prestart_cleanup_threads) if wait else () + admission = self.prestart_thread if wait else None + if session is not None: + self._cleanup_prestart(session) + for cleanup in cleanups: + cleanup.join() + if admission is not None and admission is not threading.current_thread(): + admission.join() # A spawn in progress must reap its invalidated worker first. + + def _cleanup_prestart(self, session): + from .. import processes + try: + processes.stop(session.process, grace=5) + session.process.wait() + session.close() + finally: + with self.mutex: + self.prestart_cleanup_threads.discard(threading.current_thread()) + + def environment(self, root): + from ..comfy_environment import isolated_environment + from ..comfy_bridge import source_root + from .worker import PRESTART_ENV + from .. import processes, resident_process + env = isolated_environment(root, source_root()) + # No inherited lease, handle, session or prestart mode in an idle worker. + for name in ('FREEVIDEO_RUNTIME_LOCK_FD', processes.HANDLE_ENV, resident_process.ENV, PRESTART_ENV): + env.pop(name, None) + return env + + @staticmethod + def spawn(root, machine, env, descriptor=None): + from .. import processes + command = processes.module_command('freevideo_engine.prompt_vlm.worker', assets.directory(root)) + command[0] = machine['comfy_python'] + return processes.popen(command, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, encoding='utf-8', errors='replace', + pass_fds=() if descriptor is None else (descriptor,), + supervise=True, start_new_session=True) + def info(self): root, machine = self.installation() spec = assets.catalog() @@ -70,6 +261,7 @@ def progress(self, row): self.state.update(row) def start(self, action, value): + received_epoch = time.time() if action not in ('install', 'rewrite'): raise ValueError('invalid_request') if self.busy(): @@ -91,14 +283,20 @@ def start(self, action, value): if media_kind(path) != 'image': raise ValueError('unsupported_media') row['path'] = path - self.key = secrets.token_urlsafe(24) - self.stop.clear() - self.result_expires = 0. - self.state = dict(phase='starting') - self.report = {} - self.thread = threading.Thread(target=self.work, args=(root, machine, action, value), - name='freevideo-prompt-vlm', daemon=True) - self.thread.start() + with self.mutex: + if self.busy(): + raise ValueError('busy') + if action == 'rewrite': + self.prestart_ticket += 1 # Invalidate pending imports before the rewrite starts. + self.key = secrets.token_urlsafe(24) + self.stop.clear() + self.result_expires = 0. + self.state = dict(phase='starting') + self.report = {} + self.request_epoch = received_epoch + self.thread = threading.Thread(target=self.work, args=(root, machine, action, value), + name='freevideo-prompt-vlm', daemon=True) + self.thread.start() return dict(job=self.key) def status(self, key): @@ -122,7 +320,9 @@ def cancel(self, key): return dict(phase='cancelling') def close(self): + self.closed = True self.stop.set() + self.stop_prestart('disabled') process = self.process if process is not None and process.poll() is None: from .. import processes @@ -133,6 +333,8 @@ def work(self, root, machine, action, value): from ..monitoring import save started = time.monotonic() try: + from ..background_compile import COMPILE + COMPILE.stop() if action == 'rewrite': from ..resident_process import OWNER if OWNER.active: @@ -158,6 +360,8 @@ def work(self, root, machine, action, value): else 'download_failed' if action == 'install' else 'rewrite_failed', exception=type(error).__name__)) finally: + if action == 'rewrite': + self.stop_prestart(self.prewarm_state.get('reason', 'none')) if self.stop.is_set(): self.progress(dict(phase='cancelled')) self.result_expires = time.monotonic() + 600 @@ -183,13 +387,27 @@ def work(self, root, machine, action, value): pass def infer(self, root, machine, descriptor, value): + # A claimed idle worker has no inherited lease. Keep the parent's lease + # through bounded cleanup, including failures before request submission. + try: + self._infer(root, machine, descriptor, value) + finally: + session = self.active_worker + if session is not None: + from .. import processes + processes.stop(session.process, grace=5) + session.process.wait() + session.close() + self.active_worker = None + self.process = None + + def _infer(self, root, machine, descriptor, value): from .. import processes - from ..comfy_environment import isolated_environment - from ..comfy_bridge import source_root from .. import resident_process from ..ram import ProcessMemory + from .worker import process_memory import psutil - env = isolated_environment(root, source_root()) + env = self.environment(root) owner = resident_process.OWNER if owner.process is not None and owner.process.poll() is None and owner.endpoint: env[resident_process.ENV] = str(owner.endpoint) @@ -199,11 +417,48 @@ def infer(self, root, machine, descriptor, value): except resident_process.SessionRestarted: pass # That worker already exited, which is the release we need. env.pop(resident_process.ENV, None) - command = processes.module_command('freevideo_engine.prompt_vlm.worker', assets.directory(root)) - command[0] = machine['comfy_python'] - child = processes.popen(command, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, text=True, encoding='utf-8', errors='replace', - pass_fds=(descriptor,), supervise=True, start_new_session=True) + expired = None + with self.mutex: + session, self.prewarm = self.prewarm, None + reason = self.prewarm_state.get('reason', 'none') + if reason == 'none' and os.environ.get('FREEVIDEO_PROMPT_PRESTART', '').lower() in ('0', 'off', 'false'): + reason = 'disabled' + if session is not None and session.process.poll() is not None: + expired, session = session, None + reason = 'idle_exit' if expired.process.returncode == 0 else 'failed' + if session is not None: + session.claimed = True + self.active_worker = session + self.process = session.process + if expired is not None: + expired.process.wait() + expired.close() + prewarm = 'cold' if session is None else 'used' if session.ready.is_set() else 'waited' + diagnostics = dict(prewarm=prewarm, prewarm_reason=reason if prewarm == 'cold' else 'none', + prewarm_age_seconds=time.monotonic() - session.started if session else None, + prewarm_import_seconds=session.import_seconds if session else None, + request_to_first_output_seconds=None) + footprint = dict(prewarm_rss_bytes=None, prewarm_private_bytes=None) + if session is not None: + try: + pid = session.pid + if pid is None: + children = psutil.Process(session.process.pid).children(recursive=True) + if not children: + raise RuntimeError('Worker PID not visible yet') + pid = children[0].pid + own = process_memory(pid) + footprint.update(prewarm_rss_bytes=own['rss_bytes'], prewarm_private_bytes=own['private_bytes'], + prewarm_memory_metric=own['metric']) + if 'private_working_set_bytes' in own: + footprint.update(prewarm_private_working_set_bytes=own['private_working_set_bytes'], + prewarm_private_commit_bytes=own['private_commit_bytes']) + except (psutil.Error, OSError, RuntimeError): + footprint['prewarm_memory_unavailable'] = True + else: + session = Worker(self.spawn(root, machine, env, descriptor)) + self.active_worker = session + child = session.process self.process = child memory = ProcessMemory() memory_errors = 0 @@ -211,41 +466,45 @@ def infer(self, root, machine, descriptor, value): observed = {} result = {} result_received_at = [] - native = dict(stderr_characters=0, out_of_memory=False, cuda_error=False, fatal_python_error=False) - def read_errors(): - # Native libraries can bypass the structured worker exception. Keep - # symptom flags, never stderr text (which can contain prompt pieces). - tail = '' - for chunk in iter(lambda: child.stderr.read(1024), ''): - native['stderr_characters'] += len(chunk) - content = (tail + chunk).lower() - for key, phrase in [('out_of_memory', 'out of memory'), ('cuda_error', 'cuda error'), - ('fatal_python_error', 'fatal python error')]: - native[key] |= phrase in content - tail = content[-32:] - def read(): - for line in child.stdout: - if len(line) > 128 * 1024: - continue + sent_at = time.monotonic() + write_errors = [] + def submit(): + try: + request = dict(value) + if prewarm != 'cold': + request['_prewarm'] = dict(sent_epoch=time.time()) + child.stdin.write(json.dumps(request, ensure_ascii=True)) + child.stdin.close() + except Exception as error: + write_errors.append(error) + writer = threading.Thread(target=submit, daemon=True) + def read_rows(): + while True: try: - row = json.loads(line) - except ValueError: - continue + row = session.rows.get_nowait() + except queue.Empty: + return + stats = dict(row.get('diagnostics') or {}) + if prewarm != 'cold': + waited = max(0., (session.ready_at or time.monotonic()) - sent_at) if prewarm == 'waited' else 0. + diagnostics.update(prewarm_import_seconds=session.import_seconds, + prewarm_wait_seconds=waited, import_seconds=waited) + if stats.get('first_output_epoch') is not None and self.request_epoch is not None: + diagnostics['request_to_first_output_seconds'] = max(0., stats['first_output_epoch'] - self.request_epoch) + row['diagnostics'] = dict(stats, **diagnostics) if row.get('phase') in ('complete', 'failed'): result.update(row) result_received_at[:] = [time.monotonic()] elif row.get('phase') in ('loading', 'rewriting'): self.progress(row) - reader = threading.Thread(target=read, daemon=True) - error_reader = threading.Thread(target=read_errors, daemon=True) - reader.start() - error_reader.start() try: - child.stdin.write(json.dumps(value, ensure_ascii=True)) - child.stdin.close() + writer.start() deadline = time.monotonic() + 600 while child.poll() is None: + read_rows() self.check() + if write_errors: + raise write_errors[0] if time.monotonic() > deadline: raise TimeoutError('timeout') if time.monotonic() >= next_sample: @@ -262,9 +521,10 @@ def read(): memory_errors += 1 next_sample = time.monotonic() + .5 self.stop.wait(.1) - reader.join(timeout=5) + session.reader.join(timeout=5) + read_rows() self.check() - if reader.is_alive() or not result or child.returncode and result.get('phase') == 'complete': + if session.reader.is_alive() or not result or child.returncode and result.get('phase') == 'complete': raise RuntimeError('worker_exit') if result.get('phase') == 'complete': rules.validate_output(result.get('text'), value) @@ -276,10 +536,8 @@ def read(): # cleanup. Stop the owned group/Windows Job and wait instead. processes.stop(child, grace=5) child.wait() - reader.join(timeout=5) - error_reader.join(timeout=5) - child.stdout.close() - child.stderr.close() + writer.join(timeout=5) + session.close() surviving = [] for process in observed.values(): try: @@ -297,8 +555,10 @@ def read(): resource = {key: item for key, item in memory.result().items() if item is None or type(item) in (int, float, bool) or key == 'ram_guard_metric'} resource.update(sample_errors=memory_errors, poll_interval_seconds=.5, - scope='Sampled worker tree, not an enforced RAM limit', native_exit=native) - self.progress(dict(resources=resource, worker_returncode=child.returncode, + scope='Sampled worker tree, not an enforced RAM limit', native_exit=session.native, + **footprint) + self.progress(dict(resources=resource, diagnostics=self.state.get('diagnostics') or diagnostics, + worker_returncode=child.returncode, cleanup=dict(supervisor_exited=child.poll() is not None, observed_descendants=len(observed), remaining_descendants=len(surviving), seconds=time.monotonic() - releasing, @@ -310,6 +570,12 @@ def read(): raise RuntimeError('worker_cleanup_failed') +def stop_prestart_for_generation(server): + service = getattr(server, '_freevideo_prompt_vlm', None) + if service is not None: + service.stop_prestart('generating', wait=False) + + def register(): from aiohttp import web from server import PromptServer @@ -344,6 +610,10 @@ async def action(request): result = service.status(value.get('job')) elif name == 'cancel': result = service.cancel(value.get('job')) + elif name == 'prestart': + running, pending = server.prompt_queue.get_current_queue() + root, machine = service.installation() + result = dict(state=service.prestart(root, machine, generating=bool(running or pending))) else: running, pending = server.prompt_queue.get_current_queue() if running or pending: diff --git a/freevideo_engine/prompt_vlm/worker.py b/freevideo_engine/prompt_vlm/worker.py index 46a6603f..15c8b9d0 100644 --- a/freevideo_engine/prompt_vlm/worker.py +++ b/freevideo_engine/prompt_vlm/worker.py @@ -6,6 +6,93 @@ import time import traceback +PRESTART_ENV = 'FREEVIDEO_PROMPT_PRESTART_WORKER' +PRESTART_TIMEOUT_ENV = 'FREEVIDEO_PROMPT_PRESTART_TIMEOUT_SECONDS' + + +def process_memory(pid=None): + """Import footprint only; Windows residency and commit are distinct.""" + import psutil + pid = os.getpid() if pid is None else pid + if os.name == 'nt': + from ..win32 import process_memory as counters + row = counters(pid) + return dict(rss_bytes=row['rss_bytes'], private_bytes=row['private_commit_bytes'], + private_working_set_bytes=row['private_working_set_bytes'], + private_commit_bytes=row['private_commit_bytes'], metric='windows_private') + process = psutil.Process(pid) + rss = process.memory_info().rss + try: + private = process.memory_full_info().uss + metric = 'uss' + except (psutil.Error, OSError, AttributeError): + private, metric = rss, 'rss_fallback' + return dict(rss_bytes=rss, private_bytes=private, metric=metric) + + +def request_memory(stats): + """Credit this waiting worker, rather than its stale launch-time headroom.""" + import psutil + own = process_memory() + physical = psutil.virtual_memory().available + stats.update(request_memory_metric=own['metric'], request_memory_physical_available_bytes=physical, + request_memory_rss_bytes=own['rss_bytes'], request_memory_private_bytes=own['private_bytes']) + if os.name == 'nt': + from ..win32 import memory_status + commit = memory_status()['commit_available_bytes'] + physical_credit = physical + own['private_working_set_bytes'] + commit_credit = commit + own['private_commit_bytes'] + stats.update(request_memory_commit_available_bytes=commit, + request_memory_private_working_set_bytes=own['private_working_set_bytes'], + request_memory_private_commit_bytes=own['private_commit_bytes'], + request_memory_physical_with_worker_bytes=physical_credit, + request_memory_commit_with_worker_bytes=commit_credit) + available = min(physical_credit, commit_credit) + else: + available = physical + own['private_bytes'] + stats['request_memory_with_worker_bytes'] = available + return available + + +def import_dependencies(): + # Resolve lazy Transformers classes here, without loading config or weights. + os.environ.update(HF_HUB_OFFLINE='1', TRANSFORMERS_OFFLINE='1', HF_HUB_DISABLE_TELEMETRY='1') + import torch + import transformers + from transformers import AutoProcessor, Qwen3VLForConditionalGeneration + return torch, transformers + + +def wait_request(timeout): + """A pipe read in a daemon thread works on Windows as well as POSIX. + + On timeout the process exits, dropping the blocked reader and its pipe. + EOF with no request is an ordinary idle exit. + """ + import queue + import threading + received = queue.Queue() + def read(): + try: + chunks, remaining = [], 1024 * 1024 + while remaining: + chunk = os.read(sys.stdin.fileno(), min(65536, remaining)) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + received.put(b''.join(chunks)) + except Exception as error: + received.put(error) + threading.Thread(target=read, daemon=True).start() + try: + value = received.get(timeout=timeout) + except queue.Empty: + return None + if isinstance(value, Exception): + raise value + return value or None + def available_memory(): import psutil @@ -49,15 +136,16 @@ def failure(error, mode='rewrite'): def begin(stats): """Imports and the available-RAM readings shared by every mode; returns checkpoint().""" started = time.monotonic() - stats['available_memory_at_start_bytes'] = available_memory() + if 'available_memory_at_start_bytes' not in stats: + stats['available_memory_at_start_bytes'] = available_memory() import hashlib stats['code_sha256'] = {name: hashlib.sha256(Path(__file__).with_name(name).read_bytes()).hexdigest() for name in ('core.py', 'worker.py', 'rules.py')} emit(phase='loading', diagnostics=dict(stats)) - os.environ.update(HF_HUB_OFFLINE='1', TRANSFORMERS_OFFLINE='1', HF_HUB_DISABLE_TELEMETRY='1') - import torch - import transformers - stats.update(import_seconds=time.monotonic() - started, torch_version=torch.__version__, + torch, transformers = import_dependencies() + if 'prewarm_import_seconds' not in stats: + stats['import_seconds'] = time.monotonic() - started + stats.update(torch_version=torch.__version__, transformers_version=transformers.__version__, cuda_version=torch.version.cuda, platform=sys.platform, python_version=sys.version.split()[0]) def checkpoint(stage, phase='loading', **extra): @@ -206,6 +294,7 @@ def __call__(self, input_ids, scores, **kwargs): now = time.monotonic() self.first = now timing['first_output_seconds'] = now - started + timing['first_output_epoch'] = time.time() timing.update(output_tokens=tokens, decode_seconds=now - self.first, decode_tokens_per_second=(tokens - 1) / (now - self.first) if now > self.first else None) on_token(timing) @@ -252,6 +341,7 @@ def on_token(timing): stats.update(output_tokens=timing['output_tokens'], decode_seconds=timing['decode_seconds'], decode_tokens_per_second=timing['decode_tokens_per_second']) stats['time_to_first_output_seconds'] = timing['first_output_seconds'] + stats.setdefault('first_output_epoch', timing['first_output_epoch']) if time.monotonic() - shown[0] > .5: checkpoint('decode', 'rewriting') shown[0] = time.monotonic() @@ -286,11 +376,34 @@ def on_token(timing): def main(): stats = {} value = {} + prestarting = os.environ.get(PRESTART_ENV) == '1' + requested = False try: - value = json.loads(sys.stdin.buffer.read(1024 * 1024)) + if prestarting: + started = time.monotonic() + torch, _ = import_dependencies() + # This is the only CUDA query allowed before a request; it neither + # opens the driver nor initializes a context. + if torch.cuda.is_initialized(): + raise RuntimeError('Prestart imports initialized CUDA') + imported = time.monotonic() - started + ready_epoch = time.time() + emit(phase='ready', import_seconds=imported, pid=os.getpid(), cuda_initialized=False) + data = wait_request(max(0., float(os.environ.get(PRESTART_TIMEOUT_ENV, '300')))) + if data is None: + return 0 + value = json.loads(data) + requested = True + metadata = value.pop('_prewarm', {}) + waited = max(0., ready_epoch - metadata.get('sent_epoch', ready_epoch)) + stats.update(prewarm_import_seconds=imported, import_seconds=waited) + stats['available_memory_at_start_bytes'] = request_memory(stats) + else: + value = json.loads(sys.stdin.buffer.read(1024 * 1024)) run(Path(sys.argv[1]), value, stats=stats) except Exception as error: - gpu_snapshot(stats) + if not prestarting or requested: + gpu_snapshot(stats) mode = value.get('mode') if isinstance(value, dict) else None emit(phase='failed', diagnostics=stats, **failure(error, mode)) return 1 diff --git a/freevideo_engine/provision.py b/freevideo_engine/provision.py index 71651f8b..e0e8f614 100644 --- a/freevideo_engine/provision.py +++ b/freevideo_engine/provision.py @@ -14,6 +14,7 @@ from . import network from .storage import record_download, compact_sources, fingerprint from . import model_transfer +from . import model_repair def model_headers(source): @@ -75,6 +76,18 @@ def models(plan): files += install_files(bool(plan.get('sampling_caches'))) mode = plan.get('verification', 'auto') from .local_models import key as local_key, import_file + lock = threading.RLock() + + def emit(event, **details): + with lock: + # One write keeps concurrent progress events on separate lines. + import sys + sys.stdout.write(json.dumps(dict(event=event, **details)) + '\n') + sys.stdout.flush() + + replacements = {entry['path']: entry for entry in plan.get('replace_models', [])} + if len(replacements) != len(plan.get('replace_models', [])): + raise ValueError(model_repair.CHANGED) local_files, missing = [], [] destinations = set() # Cheap metadata only: start known downloads without first hashing tens of @@ -85,15 +98,25 @@ def models(plan): if path in destinations: raise ValueError('Duplicate model destination in installation plan: ' + str(path)) destinations.add(path) + approved = replacements.get(str(path)) + if approved: + if approved.get('expected_bytes') != row['bytes'] or approved.get('file') != path.name: + raise ValueError(model_repair.CHANGED) + model_repair.check(path, approved) local = (plan.get('local_models') or {}).get('matches', {}).get(local_key(row)) entry = (row, path, hf_directory, local) - (local_files if path.exists() or path.is_symlink() or local else missing).append(entry) + (local_files if not approved and (path.exists() or path.is_symlink() or local) else missing).append(entry) + if set(replacements) - {str(path) for path in destinations}: + raise ValueError(model_repair.CHANGED) space_saver = plan.get('disk_mode') == 'extreme' if space_saver: from .install_disk import check_models - check_models(local_files + missing) + check_models(local_files + missing, replacements=replacements) + for name, approved in replacements.items(): + retained = model_repair.retain(root, Path(name), approved) + stamps.pop(name, None) + emit('model_retained', file=name, keep_as=approved['keep_as'], retained=str(retained), bytes=approved['bytes']) stopped = threading.Event() - lock = threading.RLock() failures = [] ready = [0] @@ -104,14 +127,6 @@ def check(): from .install_disk import check_floor check_floor((root, model_dir, encoder_dir)) - def emit(event, **details): - with lock: - # One write preserves JSON lines when the verifier and downloader - # both report progress. The only retained buffers are hash chunks. - import sys - sys.stdout.write(json.dumps(dict(event=event, **details)) + '\n') - sys.stdout.flush() - from .model_status import ModelProgress, NAMES, family model_progress = ModelProgress([(entry[0], 'found') for entry in local_files] + [(entry[0], 'download') for entry in missing], emit) @@ -147,10 +162,16 @@ def progress(done, total): last[0] = now progress(0, row['bytes']) prior = {str(path): stamps.get(str(path), {})} + before = model_repair.identity(path) if verified(path, row, prior, mode=mode, hf_directory=hf_directory, progress=progress): complete(row, path, prior[str(path)], 'verify') elif path.exists() or path.is_symlink(): - raise ValueError('Existing model failed pinned integrity check; preserved without overwrite: ' + str(path)) + if before is None: + raise ValueError('Existing model failed integrity check; preserved without overwrite: ' + str(path)) + model_repair.record(root, path, row, before) + raise ValueError(model_repair.MISMATCH + ': ' + path.name + '\n' + + model_repair.MISMATCH_PATH + ': ' + str(path) + '\n' + + model_repair.CLI_FAILURE.format(file=path.name)) elif local: def local_progress(value): check() diff --git a/freevideo_engine/refine_schedule.py b/freevideo_engine/refine_schedule.py index 1822d49e..8b147e77 100644 --- a/freevideo_engine/refine_schedule.py +++ b/freevideo_engine/refine_schedule.py @@ -50,8 +50,9 @@ def modulation(engine, schedule): manifest = json.loads((Path(engine.cache) / 'manifest.json').read_text(encoding='utf-8')) times = timesteps(device, engine.task) cursor = ScheduleCursor(times) + fetch = getattr(engine, 'fetch_tables', True) cache = TableCache(engine.cache, manifest['source_id'], 3, task=engine.task, - manifest=manifest, timesteps=times, channels=model.config.hidden_size, device='cpu') + manifest=manifest, timesteps=times, channels=model.config.hidden_size, device='cpu', fetch=fetch) originals = [] started, hits = time.perf_counter(), 0 try: @@ -84,6 +85,8 @@ def modulation(engine, schedule): block.adaln_proj = CachedModulation(values, cursor).to(device) del values del embeddings + if not fetch and cache.placeholders: + engine.table_placeholders.add(cache.root.name) engine.schedule_hook.remove() engine.cursor = cursor engine.schedule_hook = model.register_forward_pre_hook(cursor.before, with_kwargs=True) diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 75c3bb25..19624256 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,41 +1,72 @@ { "schema": 1, - "product_version": "0.3.23", + "product_version": "0.3.24", "en": { - "summary": "Generation and installation progress show one overall percentage with the details that matter, such as the current step and how much has been downloaded; the workspace fills the window; RTX 50 GPUs no longer keep pausing when the driver cache is full, and the first video after a start waits less.", + "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": [ - "The first video after FreeVideo starts waits up to about 15 seconds less for the text encoder. On RTX 40 and RTX 50 cards under Windows, and on Linux, more of the video decoder stays on the graphics card while decoding (all of it on 12 GB cards), so decoding is faster.", - "On Windows with RTX 50 GPUs, generation no longer keeps pausing once the graphics driver's compile cache is full.", - "On Windows, when the video model fits neither in GPU memory nor in RAM, generation waits less for the drive. When another program takes GPU memory during generation, decoding no longer starts over because it ran out of memory.", - "Generation progress shows one overall percentage, the current step and the elapsed time, with a small bar under slow stages, and the remaining time appears after the first two steps; during installation, repair and updates, a download shows what is being downloaded, how much and how fast. Loading the video model no longer hangs when the graphics driver does not answer, and after a long time without progress the page says that generation is still running and offers the report.", - "Error cards now say in one sentence what happened and what to do, with the raw error under Technical details; cases such as a reference video that is too short or a LoRA that cannot be used with the int8 model yet each have their own message. The compatibility setting now describes what each level does.", - "The creative workspace and the Creations window now fill the browser window, so the video preview grows with it. On windows at least 1680 px wide, a Recent videos column appears on the right; clicking a video plays it in the preview, and a button in the preview header hides the column.", - "A custom step count now downloads only a sampling table the first time, up to 1.2 GB, instead of 26 GB of model weights. Any download still needed, as for some LoRAs on FP8, shows its progress, size and speed instead of an unchanging “Loading model”.", - "On NVIDIA graphics cards with 8 GB or more, prompt enhancement needs about 4 GiB of free RAM instead of 11 GiB, and on 8 GB and 10 GB cards a rewrite takes tens of seconds instead of several minutes. While it runs, the status line shows how long it has taken.", - "Each saved video now takes far less disk space, about 40 MB instead of about 790 MB for a 10-second 1344 × 768 video, because the uncompressed video and audio are no longer kept. The first start after updating deletes them from earlier videos as well, and previews can still continue with Run second pass.", - "A new Repair button in the launcher fixes an installation in one step and keeps models and videos; it appears on the error card when the launcher finds a problem it can repair, and in Settings › Environment. A repair that only adds a few packages no longer asks for as much free disk space as a new installation.", - "On a Mac, when unified memory runs short, the second pass switches to a computation that uses less memory; if it still runs short, the first pass is kept and only the second pass runs again. When less memory is available than a video needs, FreeVideo says so as soon as generation starts.", - "Fixes: on Linux, desktops without xdg-open can open FreeVideo in the browser, and the background service starts when the folder name contains %, $, quotes or backslashes; when port 8188 is in use, the launcher starts ComfyUI on a free port and shows the new address; and Captions only in the image editor no longer leaves white specks when it sweeps a caption in several strokes. With a self-installed ComfyUI updated to a recent version, each video appears once in Assets instead of twice, and paths with hidden folders no longer show in the launcher terminal and diagnostic reports." + "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": "生成和安装的进度只显示一个总百分比,并给出在做什么、下了多少等必要的细节;创作面板铺满整个窗口;RTX 50 显卡不再因驱动缓存写满而反复停顿,启动后的第一个视频等待更短。", + "summary": "更新后的编译改在空闲时于后台完成,内存较小的 Windows 电脑和 8 GB 的 RTX 30 显卡生成更快;“修复”能处理更多情况,离线安装也能随启动器更新引擎。", "changes": [ - "启动后的第一个视频等文本编码器的时间更短,最多少等约 15 秒。在 Windows 上的 RTX 40、RTX 50 显卡以及 Linux 上,解码时更多的解码器留在显卡上(12 GB 显卡可以全部留下),解码更快。", - "在使用 RTX 50 系显卡的 Windows 电脑上,显卡驱动的编译缓存写满后,生成时不再反复停顿。", - "在 Windows 上,显存和内存都放不下整个视频模型时,生成时等硬盘的时间更短。生成期间其他程序占用了显存时,解码不再因显存不足而从头再来一遍。", - "生成进度只显示一个总百分比、正在做的那一步和已用时间,慢的阶段下面多一条小进度条,跑完两步后再显示预计剩余时间;安装、修复和更新时,下载会写明在下什么、下了多少和速度。加载视频模型时不再因显卡驱动没有响应而一直卡住;长时间没有新的进度时,页面会说明生成仍在进行,并可以下载报告。", - "出错时,卡片用一句话说明发生了什么、该怎么做,原始报错收进“技术详情”;参考视频太短、LoRA 暂时不能用于 int8 模型等情况各有自己的说明。兼容性设置也改成说明每一档的效果。", - "创作面板和作品窗口现在铺满浏览器窗口,视频预览随窗口一起变大。窗口宽 1680 像素以上时,右侧会多出“最近作品”一栏,点击其中的视频会在预览中播放,预览标题行的按钮可以收起这一栏。", - "选用自定义步数时,第一次只需下载一个采样表(最多 1.2 GB),不再先下载 26 GB 的模型权重。仍需下载时(例如 FP8 上的部分 LoRA),会显示进度、大小和速度,不再一直停在“加载模型”。", - "在 8 GB 及以上的 NVIDIA 显卡上,提示词增强只需约 4 GiB 可用内存(原来要 11 GiB),8 GB 和 10 GB 显卡上的改写从几分钟缩短到几十秒。改写时,状态栏会显示已用时间。", - "保存的视频占用的磁盘空间大幅减少:10 秒、1344 × 768 的视频从约 790 MB 降到约 40 MB,因为不再保留未压缩的画面和音频。更新后第一次启动时,也会删除之前视频里的这些文件;预览仍然可以点“继续二采”。", - "启动器新增“修复”按钮,一步修好安装,模型和视频都会保留;发现可以修复的问题时,出错卡片上会出现这个按钮,“设置 → 环境”里也有。只需补装几个包的修复,不再要求和全新安装一样多的空闲磁盘空间。", - "在 Mac 上,统一内存不足时,第二遍会改用占用内存更少的计算方式;仍然不足时,保留第一遍的结果,只重新运行第二遍。可用内存低于所需时,生成开始时就会给出提示。", - "修复:Linux 上,没有 xdg-open 的桌面也能在浏览器中打开 FreeVideo,文件夹名带 %、$、引号或反斜杠时后台服务也能启动;本机 8188 端口被占用时,启动器会改用空闲端口并说明新地址;图片编辑器的“只去字幕”分几笔扫过字幕时不再留下白点。自行将 ComfyUI 更新到较新版本后,每个视频在“资产”中不再出现两条记录;启动器终端和诊断报告里,隐去文件夹的路径不再显示为 。" + "更新、安装、修复或更新显卡驱动后,FreeVideo 会在空闲时于后台完成编译,约两三分钟,之后的第一次生成不用再等编译;开始生成时后台编译会立即停止。", + "清理系统临时文件后,下一次生成不再变慢:编译结果改存在 FreeVideo 的目录里。", + "内存较小的 Windows 电脑上,生成时每一步从硬盘读取的数据更少,两遍生成的第二遍也开始得更快;8 GB 的 RTX 30 系列显卡上,全尺寸视频的第二遍更快。", + "开启提示词增强时,改写开始前少等 3–4 秒。为此 FreeVideo 会在后台提前做好准备,约占 0.7 GiB 内存,不占显存,开始生成视频时自动退出。", + "“修复”也会补回 ComfyUI 环境里被删除或改动的包,并把和测试版本不同的模型文件改名保留、换回测试过的版本。生成时如果文本编码器目录里有同名但大小不同的文件,会改用测试过的那份。", + "用离线整合包装的 FreeVideo 也会随启动器更新引擎,不用下载;旧启动器不再覆盖新版 FreeVideo,两者版本不同时会分别显示。", + "浏览器扩展干扰 FreeVideo 时,出错卡片会写出是哪个扩展,并给出解决办法。", + "许可确认里的“查看许可证”会列出要同意的每一项许可,每项都能打开;用离线整合包安装时打开的是本机全文,断网也能看。", + "修复:引擎更新后,用引擎自带 Python 运行的命令也会用新版 FreeVideo;Linux 上安装目录里的 uv 被换成链接时,“安装 / 修复”也能完成。" ] }, "history": [ + { + "product_version": "0.3.23", + "en": { + "summary": "Generation and installation progress show one overall percentage with the details that matter, such as the current step and how much has been downloaded; the workspace fills the window; RTX 50 GPUs no longer keep pausing when the driver cache is full, and the first video after a start waits less.", + "changes": [ + "The first video after FreeVideo starts waits up to about 15 seconds less for the text encoder. On RTX 40 and RTX 50 cards under Windows, and on Linux, more of the video decoder stays on the graphics card while decoding (all of it on 12 GB cards), so decoding is faster.", + "On Windows with RTX 50 GPUs, generation no longer keeps pausing once the graphics driver's compile cache is full.", + "On Windows, when the video model fits neither in GPU memory nor in RAM, generation waits less for the drive. When another program takes GPU memory during generation, decoding no longer starts over because it ran out of memory.", + "Generation progress shows one overall percentage, the current step and the elapsed time, with a small bar under slow stages, and the remaining time appears after the first two steps; during installation, repair and updates, a download shows what is being downloaded, how much and how fast. Loading the video model no longer hangs when the graphics driver does not answer, and after a long time without progress the page says that generation is still running and offers the report.", + "Error cards now say in one sentence what happened and what to do, with the raw error under Technical details; cases such as a reference video that is too short or a LoRA that cannot be used with the int8 model yet each have their own message. The compatibility setting now describes what each level does.", + "The creative workspace and the Creations window now fill the browser window, so the video preview grows with it. On windows at least 1680 px wide, a Recent videos column appears on the right; clicking a video plays it in the preview, and a button in the preview header hides the column.", + "A custom step count now downloads only a sampling table the first time, up to 1.2 GB, instead of 26 GB of model weights. Any download still needed, as for some LoRAs on FP8, shows its progress, size and speed instead of an unchanging “Loading model”.", + "On NVIDIA graphics cards with 8 GB or more, prompt enhancement needs about 4 GiB of free RAM instead of 11 GiB, and on 8 GB and 10 GB cards a rewrite takes tens of seconds instead of several minutes. While it runs, the status line shows how long it has taken.", + "Each saved video now takes far less disk space, about 40 MB instead of about 790 MB for a 10-second 1344 × 768 video, because the uncompressed video and audio are no longer kept. The first start after updating deletes them from earlier videos as well, and previews can still continue with Run second pass.", + "A new Repair button in the launcher fixes an installation in one step and keeps models and videos; it appears on the error card when the launcher finds a problem it can repair, and in Settings › Environment. A repair that only adds a few packages no longer asks for as much free disk space as a new installation.", + "On a Mac, when unified memory runs short, the second pass switches to a computation that uses less memory; if it still runs short, the first pass is kept and only the second pass runs again. When less memory is available than a video needs, FreeVideo says so as soon as generation starts.", + "Fixes: on Linux, desktops without xdg-open can open FreeVideo in the browser, and the background service starts when the folder name contains %, $, quotes or backslashes; when port 8188 is in use, the launcher starts ComfyUI on a free port and shows the new address; and Captions only in the image editor no longer leaves white specks when it sweeps a caption in several strokes. With a self-installed ComfyUI updated to a recent version, each video appears once in Assets instead of twice, and paths with hidden folders no longer show in the launcher terminal and diagnostic reports." + ] + }, + "zh": { + "summary": "生成和安装的进度只显示一个总百分比,并给出在做什么、下了多少等必要的细节;创作面板铺满整个窗口;RTX 50 显卡不再因驱动缓存写满而反复停顿,启动后的第一个视频等待更短。", + "changes": [ + "启动后的第一个视频等文本编码器的时间更短,最多少等约 15 秒。在 Windows 上的 RTX 40、RTX 50 显卡以及 Linux 上,解码时更多的解码器留在显卡上(12 GB 显卡可以全部留下),解码更快。", + "在使用 RTX 50 系显卡的 Windows 电脑上,显卡驱动的编译缓存写满后,生成时不再反复停顿。", + "在 Windows 上,显存和内存都放不下整个视频模型时,生成时等硬盘的时间更短。生成期间其他程序占用了显存时,解码不再因显存不足而从头再来一遍。", + "生成进度只显示一个总百分比、正在做的那一步和已用时间,慢的阶段下面多一条小进度条,跑完两步后再显示预计剩余时间;安装、修复和更新时,下载会写明在下什么、下了多少和速度。加载视频模型时不再因显卡驱动没有响应而一直卡住;长时间没有新的进度时,页面会说明生成仍在进行,并可以下载报告。", + "出错时,卡片用一句话说明发生了什么、该怎么做,原始报错收进“技术详情”;参考视频太短、LoRA 暂时不能用于 int8 模型等情况各有自己的说明。兼容性设置也改成说明每一档的效果。", + "创作面板和作品窗口现在铺满浏览器窗口,视频预览随窗口一起变大。窗口宽 1680 像素以上时,右侧会多出“最近作品”一栏,点击其中的视频会在预览中播放,预览标题行的按钮可以收起这一栏。", + "选用自定义步数时,第一次只需下载一个采样表(最多 1.2 GB),不再先下载 26 GB 的模型权重。仍需下载时(例如 FP8 上的部分 LoRA),会显示进度、大小和速度,不再一直停在“加载模型”。", + "在 8 GB 及以上的 NVIDIA 显卡上,提示词增强只需约 4 GiB 可用内存(原来要 11 GiB),8 GB 和 10 GB 显卡上的改写从几分钟缩短到几十秒。改写时,状态栏会显示已用时间。", + "保存的视频占用的磁盘空间大幅减少:10 秒、1344 × 768 的视频从约 790 MB 降到约 40 MB,因为不再保留未压缩的画面和音频。更新后第一次启动时,也会删除之前视频里的这些文件;预览仍然可以点“继续二采”。", + "启动器新增“修复”按钮,一步修好安装,模型和视频都会保留;发现可以修复的问题时,出错卡片上会出现这个按钮,“设置 → 环境”里也有。只需补装几个包的修复,不再要求和全新安装一样多的空闲磁盘空间。", + "在 Mac 上,统一内存不足时,第二遍会改用占用内存更少的计算方式;仍然不足时,保留第一遍的结果,只重新运行第二遍。可用内存低于所需时,生成开始时就会给出提示。", + "修复:Linux 上,没有 xdg-open 的桌面也能在浏览器中打开 FreeVideo,文件夹名带 %、$、引号或反斜杠时后台服务也能启动;本机 8188 端口被占用时,启动器会改用空闲端口并说明新地址;图片编辑器的“只去字幕”分几笔扫过字幕时不再留下白点。自行将 ComfyUI 更新到较新版本后,每个视频在“资产”中不再出现两条记录;启动器终端和诊断报告里,隐去文件夹的路径不再显示为 。" + ] + } + }, { "product_version": "0.3.22", "en": { @@ -250,29 +281,6 @@ "在节点视图下载报告时,文件按保存时间命名,与创作面板一致。" ] } - }, - { - "product_version": "0.3.11", - "en": { - "summary": "Importing offline packages is faster and shows its progress throughout, setup works with an older Git, and a GPU slowed down by heat is named on the result.", - "changes": [ - "Offline packages are checked once, while they are unpacked. Importing a package again, Checking packages before installing and the first start no longer read every file again.", - "The Working card shows which package is being imported and how far it is; Checking packages and Verify local files show their progress as well.", - "After the Environment package is imported, the model step no longer asks for it again.", - "On Windows, when the Git already installed is older than 2.35, setup uses FreeVideo's own verified Git instead of stopping with \"'origin' does not appear to be a git repository\". On Linux and macOS, setup names the Git version and asks for 2.35 or newer.", - "When the GPU spends most of a generation in thermal slowdown, the result says so, with the temperature and how far the clock dropped, and diagnostic reports include it." - ] - }, - "zh": { - "summary": "离线包导入更快,全程都能看到进度;装有旧版 Git 的电脑可以正常安装;显卡因过热降频变慢时,结果下方会说明。", - "changes": [ - "离线包只在解压时校验一次;再次导入同一个包、安装前「检查配套包」和首次启动都不再把所有文件重读一遍。", - "左侧「正在处理」卡片会显示正在导入第几个包和进度;「检查配套包」和「校验本地文件」也有进度条。", - "导入运行环境包后,模型这一步不再提示还需要运行环境包。", - "Windows 上如果已安装的 Git 低于 2.35,安装时会改用 FreeVideo 自带并校验过的 Git,不再因“'origin' does not appear to be a git repository”中断;Linux 和 macOS 会写明 Git 版本,并提示更新到 2.35 或更高版本。", - "显卡在生成过程中大部分时间因过热降频时,结果下方会说明温度和降频幅度,诊断报告中也会包含。" - ] - } } ] } diff --git a/freevideo_engine/release_notes.py b/freevideo_engine/release_notes.py index 2926092b..b6deff86 100644 --- a/freevideo_engine/release_notes.py +++ b/freevideo_engine/release_notes.py @@ -71,6 +71,24 @@ def since(release, installed): return [row for row in rows if version_key(row['product_version']) > version_key(current)] +def older(candidate, installed): + """Whether candidate is an earlier release than installed, by their stamped identities. + + The product version decides; within one version, the build time. A build + that carries neither cannot be ordered, so it never counts as older.""" + first, second = product_version(candidate.get('product_version')), product_version(installed.get('product_version')) + if first and second and first != second: + return version_key(first) < version_key(second) + early, late = candidate.get('built_at'), installed.get('built_at') + return type(early) is int and type(late) is int and 0 < early < late + + +def label(identity): + """How a build names itself to people: its product version, else its build number.""" + version = product_version(identity.get('product_version')) + return 'v' + version if version else str(identity.get('version') or '') + + def optional_fields(value): result = {} if not isinstance(value, dict): diff --git a/freevideo_engine/resource_history.py b/freevideo_engine/resource_history.py index 0de809c9..7b6011fb 100644 --- a/freevideo_engine/resource_history.py +++ b/freevideo_engine/resource_history.py @@ -452,3 +452,19 @@ def observations(self, identity=None, config=None, geometry=None, *, limit=None, with self._transaction() as db: rows = [self._row(row) for row in db.execute(query, arguments)] return list(reversed(rows)) if limit is not None else rows + + def requests(self, *, limit): + """The newest successful generations, oldest first, measured or not. + + A preview and its upscale succeed without a resource observation, so + observations() leaves them out; their geometry and placement evidence + still say what was asked for. + """ + if type(limit) is not int or limit <= 0: + raise ValueError('Request limit must be a positive integer') + query = ("SELECT id,state,started,settled,purpose,phase,geometry_json,details_json,observation_json " + "FROM attempts WHERE state='success' AND purpose='generation' " + "ORDER BY started DESC,id DESC LIMIT ?") + with self._transaction() as db: + rows = [self._row(row) for row in db.execute(query, (limit,))] + return list(reversed(rows)) diff --git a/freevideo_engine/runtime.py b/freevideo_engine/runtime.py index 25f1d236..c28f1575 100644 --- a/freevideo_engine/runtime.py +++ b/freevideo_engine/runtime.py @@ -56,11 +56,16 @@ def __init__(self, cache, *, base=None, checkpoint=None, window_batch=1, linear_compute='native-fp8', input_cache_dir=None, fp8_gemm='auto', window_varlen=False, varlen_smooth_k=True, task='t2va', stream_weights=False, canvas=None, head_parallelism=1, - residual_offload=False, device_backend=None, gpu_allocation_bytes=None): + residual_offload=False, device_backend=None, gpu_allocation_bytes=None, + fetch_tables=True): self.device_backend = device_backend if device_backend is not None else get_backend() if gpu_allocation_bytes is not None and (type(gpu_allocation_bytes) is not int or gpu_allocation_bytes < 0): raise ValueError('GPU allocation limit must be a nonnegative byte count') self.gpu_allocation_bytes = gpu_allocation_bytes + # Compiling ahead of a request never fetches a sampling table: the + # directories of tables it compiled with zeros (adaln.TableCache). + self.fetch_tables = fetch_tables + self.table_placeholders = set() if self.device_backend.capabilities.name != 'cuda': raise NotImplementedError('Engine model execution currently requires the CUDA backend') from .media_request import TASKS @@ -117,6 +122,7 @@ def __init__(self, cache, *, base=None, checkpoint=None, self.stream_refiner_count = 0 self.cache_refined_text = cache_refined_text self.refiner_released = False + self.previous_refined_text = None started = time.perf_counter() manifest = json.loads((self.cache / 'manifest.json').read_text(encoding='utf-8')) online_lora = manifest.get('online_lora', {}) @@ -266,7 +272,8 @@ def prepare_streamed(block, index, unread=False): if adaln_cache: self.cursor = ScheduleCursor(schedule_timesteps(steps, task=task)) table_cache = (TableCache(self.cache, manifest['source_id'], steps, task=task, manifest=manifest, - timesteps=self.cursor.timesteps, channels=model.config.hidden_size, device='cpu') + timesteps=self.cursor.timesteps, channels=model.config.hidden_size, device='cpu', + fetch=fetch_tables) if adaln_cache and (adaln_disk_cache or portable_adaln) else None) table_cache_hits = 0 adaln_prepare_seconds = 0. @@ -452,6 +459,8 @@ def prepare_streamed(block, index, unread=False): 'host_preload_seconds': self.host_preload_seconds, 'remaining_load_seconds': self.load_seconds - adaln_prepare_seconds - pin_seconds - self.host_preload_seconds, 'scope': 'Host wall time within model load; AdaLN includes table reads or computation and writes.'} + if table_cache is not None and table_cache.placeholders: + self.table_placeholders.add(table_cache.root.name) fa4_module = sys.modules.get('flash_attn.cute.flash_fwd') fa4_path = Path(fa4_module.__file__).resolve() if fa4_module is not None else None self.config = {'device_backend': self.device_backend.capabilities.name, 'task': task, 'attention': attention, 'prefetch': prefetch, 'adaln_cache': adaln_cache, @@ -747,7 +756,20 @@ def _prepare_refined_text(self, prompt): from .fp8 import input_dtype started = time.perf_counter() refiner = self.transformer.token_refiner.refiner_blocks - cache, cache_key, note = None, None, None + cpu = prompt.detach().to('cpu').contiguous() + cache_key = str(cpu.dtype) + str(list(cpu.shape)) + hashlib.sha256(cpu.view(torch.uint8).numpy().tobytes()).hexdigest() + del cpu + # Pass-local tiling (pass_configuration) rewires only the video blocks + # and this engine's weights are fixed, so the second pass of a request + # reuses the first pass's tensor instead of reloading the refiner + # weights to compute the same values again. + memo = (cache_key, tuple(self.config.get(k) for k in ('attention', 'linear_compute', 'fp8_gemm', 'inference_kernels'))) + if self.previous_refined_text is not None and self.previous_refined_text[0] == memo: + text = self.previous_refined_text[1].to(self.device_backend.device) + self.transformer._freevideo_refined_text = text + return dict(seconds=time.perf_counter() - started, shape=list(text.shape), cache_hit=False, + previous_pass=True, refiner_weights_released=self.refiner_released) + cache, note = None, None if self.input_cache_dir: from .input_cache import InputCache, digest from .storage import fingerprint @@ -758,9 +780,6 @@ def _prepare_refined_text(self, prompt): arithmetic={k: self.config[k] for k in ('attention', 'linear_compute', 'fp8_gemm', 'inference_kernels', 'head_chunk', 'projection_chunk')}, **self.device_backend.arithmetic_identity()) cache = InputCache(identity, root=self.input_cache_dir, kind='refined-text') - cpu = prompt.detach().to('cpu').contiguous() - cache_key = str(cpu.dtype) + str(list(cpu.shape)) + hashlib.sha256(cpu.view(torch.uint8).numpy().tobytes()).hexdigest() - del cpu found = cache.lookup(cache_key) if found: path, entry = found @@ -772,6 +791,7 @@ def _prepare_refined_text(self, prompt): refiner.to('meta') self.refiner_released = True self.transformer._freevideo_refined_text = text + self.previous_refined_text = (memo, text.detach().cpu()) return dict(seconds=time.perf_counter() - started, shape=list(text.shape), cache_hit=True, refiner_weights_released=True, cache_receipt=str(cache.receipt(cache_key))) del text @@ -802,13 +822,14 @@ def _prepare_refined_text(self, prompt): refiner.to('meta') self.refiner_released = True self.transformer._freevideo_refined_text = text + self.previous_refined_text = (memo, text.detach().cpu()) saved = False - if cache is not None and cache_key: + if cache is not None: try: import uuid cache.root.mkdir(parents=True, exist_ok=True) temporary = cache.root / ('refined-' + uuid.uuid4().hex + '.partial') - torch.save(text.detach().cpu(), temporary) + torch.save(self.previous_refined_text[1], temporary) saved = cache.put(cache_key, temporary, dict(success=True, shape=list(text.shape), dtype=str(text.dtype)), move=True) note = cache.note or note except (OSError, ValueError) as error: @@ -825,6 +846,7 @@ def close(self): if self.schedule_hook is not None: self.schedule_hook.remove() self.transformer = None + self.previous_refined_text = None self.offload_layers = [] if self.stream_weight_source is not None: self.stream_weight_source.release_host_views() diff --git a/freevideo_engine/runtime_libraries.py b/freevideo_engine/runtime_libraries.py index 306aaf46..0c4c025f 100644 --- a/freevideo_engine/runtime_libraries.py +++ b/freevideo_engine/runtime_libraries.py @@ -17,6 +17,7 @@ import os from pathlib import Path import re +import shlex # ComfyUI messages choose between this and the page's own setup (repair_hint). from .comfy_launcher_api import LAUNCHER_REPAIR as REINSTALL @@ -195,6 +196,109 @@ def site_for(python): return site_packages(python.parent.parent) +def normalize_name(name): + return re.sub(r'[-_.]+', '-', name).lower() + + +def _requirement_rows(requirements): + pending, seen, rows = [Path(requirements)], set(), [] + while pending: + try: + path = pending.pop().resolve() + if path in seen: + continue + seen.add(path) + lines = path.read_text(encoding='utf-8-sig').splitlines() + except (OSError, UnicodeError, RuntimeError): + return None + continued = '' + for line in lines: + if line.lstrip().startswith('#'): + continue + continued += line + if continued.endswith('\\'): + continued = continued[:-1] + continue + row = re.split(r'\s+#', continued, maxsplit=1)[0].strip() + continued = '' + if not row: + continue + if row.startswith('-e') or re.match(r'^--editable(?:\s|=|$)', row): + return None + if row.startswith('-r') or re.match(r'^--requirement(?:\s|=|$)', row): + value = re.sub(r'^(?:-r\s*|--requirement(?:\s+|=)?)', '', row) + try: + paths = shlex.split(value, posix=False) + except ValueError: + return None + if len(paths) != 1 or '://' in paths[0]: + return None + pending.append(path.parent / paths[0].strip('\'"')) + elif not row.startswith('-'): + if re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*', row): + try: + if (path.parent / row).exists(): + return None + except OSError: + return None + rows.append(row) + if continued: + return None + return rows + + +def dependency_closure(python, requirements, additional=()): + """Required names from a requirements file and installed metadata, or None if uncertain.""" + rows = _requirement_rows(requirements) + if rows is None: + return None + try: + from packaging.requirements import Requirement + except ImportError: + Requirement = None + dependencies = {} + site = site_for(python) + if site is not None: + for distribution in importlib.metadata.distributions(path=[str(site)]): + try: + name = distribution.metadata['Name'] + if name: + dependencies.setdefault(normalize_name(name), []).extend(distribution.requires or []) + except (OSError, ValueError, UnicodeDecodeError, KeyError): + continue + pending = [(row, {''}) for row in [*rows, *additional]] + seen = {} + while pending: + row, parent_extras = pending.pop() + row = re.split(r'\s+#', row, maxsplit=1)[0].strip() + if re.fullmatch(r'[A-Za-z0-9._-]+\.(?:whl|zip|tar(?:\.(?:gz|bz2|xz))?)(?:\[[^]]+\])?(?:\s*;.*)?', row, re.I): + return None + extras = {''} + if Requirement is not None: + try: + requirement = Requirement(row) + if requirement.marker and not any(requirement.marker.evaluate({'extra': extra}) + for extra in parent_extras): + continue + name = normalize_name(requirement.name) + extras.update(requirement.extras) + except ValueError: + return None + else: + match = re.fullmatch(r'([A-Za-z0-9][A-Za-z0-9._-]*)(?:\s*\[([A-Za-z0-9._,\s-]+)\])?' + r'\s*(?:[<>=!~].*|@\s*\S+)?\s*(?:;.*)?', row) + if not match: + return None + name = normalize_name(match[1]) + extras.update(value.strip() for value in (match[2] or '').split(',')) + previous = seen.setdefault(name, set()) + if extras <= previous: + continue + previous.update(extras) + pending.extend((row, set(previous)) for row in dependencies.get(name, [])) + return set(seen) + + def damaged(python): """Distributions whose recorded cuDNN files differ in this Python's environment. @@ -210,6 +314,47 @@ def damaged(python): return sorted({owner for row in found['changed'] for owner in row['owners']}) +def incomplete(python): + """Distributions with missing or wrong-sized RECORD files, without hashing. + + Only files inside this environment with a recorded size are checked. + Unreadable records and checks that cannot run must never stop a setup. + """ + try: + site = site_for(python) + if site is None: + return [] + parent = Path(python).parent + root = _canonical(parent.parent if parent.name.lower() in ('bin', 'scripts') else parent) + found = set() + for distribution in importlib.metadata.distributions(path=[str(site)]): + try: + rows = list(csv.reader(io.StringIO(distribution.read_text('RECORD') or ''))) + name = distribution.metadata['Name'] + except (OSError, csv.Error, UnicodeDecodeError, KeyError): + continue + if not name: + continue + for row in rows: + if len(row) < 3 or not row[2].isdigit(): + continue + path = _canonical(os.path.join(str(site), row[0])) + if not path.startswith(root + os.sep): + continue + try: + size = os.path.getsize(path) + except (FileNotFoundError, NotADirectoryError): + size = None + except OSError: + continue + if size != int(row[2]): + found.add(name) + break + return sorted(found) + except Exception: + return [] + + def installed_bytes(python, name): """Installed size of one distribution in this Python's environment, from its RECORD.""" try: diff --git a/freevideo_engine/streamed_weights.py b/freevideo_engine/streamed_weights.py index f6dc19c3..064d3fd2 100644 --- a/freevideo_engine/streamed_weights.py +++ b/freevideo_engine/streamed_weights.py @@ -1,7 +1,9 @@ """Reopen immutable safetensor layer views only while filling an offload slot.""" from contextlib import contextmanager +import functools from pathlib import Path import threading +import time _TORCH_DTYPES = {'BOOL': 'bool', 'U8': 'uint8', 'I8': 'int8', 'I16': 'int16', @@ -277,6 +279,74 @@ def _reader_pool(): # so a request that grows (a larger refine pass, readouts, another program) # trades them for disk reads instead of pushing Windows toward its floor. HOST_VIEW_SHED = int(1.5 * 2**30) +# Direct reads of one step can exceed the memory left for the file cache. The +# cache then holds the layers read last and the next step starts with the +# first ones, so every layer comes from the drive again. Layers that fit beside +# one passing layer and this slack are kept: read at normal page priority. The +# rest are read at Windows' lowest page priority, whose pages the standby list +# gives up first, to this request's next read or to any other program. +CACHE_KEEP_SLACK = 512 * 2**20 +_MEMORY_PRIORITY_VERY_LOW = 1 + + +@functools.lru_cache(maxsize=None) +def _thread_page_priority(): + """Swap the calling thread's page priority on Windows: a function or None. + + The function sets the given priority and returns the previous one, or + returns None and changes nothing when Windows refuses. It is None on other + systems, on Windows versions without SetThreadInformation and with + FREEVIDEO_PAGE_PRIORITY=off, where every read keeps normal priority. + """ + import os + from .system import windows + if not windows() or os.environ.get('FREEVIDEO_PAGE_PRIORITY', 'auto').lower() in ('off', '0', 'false'): + return None + import ctypes + from ctypes import wintypes + try: + kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) + current, get, set_ = (kernel32.GetCurrentThread, kernel32.GetThreadInformation, + kernel32.SetThreadInformation) + except (AttributeError, OSError): + return None + current.restype = wintypes.HANDLE + current.argtypes = () + for function in (get, set_): + # THREAD_INFORMATION_CLASS ThreadMemoryPriority = 0, MEMORY_PRIORITY_INFORMATION. + function.argtypes = (wintypes.HANDLE, ctypes.c_int, ctypes.POINTER(wintypes.ULONG), wintypes.DWORD) + function.restype = wintypes.BOOL + + def swap(priority): + # A hint must never fail a read: any refusal leaves the priority as it was. + try: + info = wintypes.ULONG() + if not get(current(), 0, ctypes.byref(info), ctypes.sizeof(info)): + return None + previous = info.value + info.value = priority + return previous if set_(current(), 0, ctypes.byref(info), ctypes.sizeof(info)) else None + except (ctypes.ArgumentError, OSError, ValueError): + return None + + # Setting this thread's own priority again proves the calls work here. + previous = swap(_MEMORY_PRIORITY_VERY_LOW) + if previous is None: + return None + swap(previous) + return swap + + +@contextmanager +def _low_page_priority(enabled): + """Read at the lowest page priority inside this block, on this thread only.""" + swap = _thread_page_priority() if enabled else None + previous = swap(_MEMORY_PRIORITY_VERY_LOW) if swap is not None else None + try: + yield + finally: + if previous is not None: + swap(previous) def host_view_fits(size, reserve=HOST_VIEW_RESERVE): @@ -312,6 +382,13 @@ def __init__(self, paths, prefixes, *, intermediate_dtype=None, direct_read=None self.host_view_hits = 0 self.host_view_hit_bytes = 0 self.host_view_released = 0 + # Direct-read layers kept in the file cache between steps (index: bytes), + # and the direct reads of kept and passing layers. + self.cache_kept = {} + self.cache_kept_bytes = 0 + 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.) 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: @@ -426,17 +503,59 @@ def stage_into(self, index, layout, planes): target = buffers[dtype][begin:begin + size] pieces += [(path, start + at, target[at:at + _READ_PIECE]) for at in range(0, size, _READ_PIECE)] - self._read_pieces(pieces) + size = sum(row[1] for entries in reads.values() for row in entries) + # A layer about to become a view stays in this process, at normal priority. + keep = self._keeps_cached(index, size) or ( + self.host_views is not None and host_view_fits(size, self.host_view_reserve)) + started = time.monotonic() + self._read_pieces(pieces, low_page_priority=not keep) + kind = 'kept' if keep else 'passed' + with self.cache_lock: + self.read_stats[kind + '_layers'] += 1 + self.read_stats[kind + '_bytes'] += size + self.read_stats[kind + '_seconds'] += time.monotonic() - started if self.host_views is not None: - size = sum(row[1] for entries in reads.values() for row in entries) if host_view_fits(size, self.host_view_reserve): self._adopt(index, reads, size) + if index in self.host_views: + self._forget_cached(index) finally: for path in reads: self.check(path) return True - def _read_pieces(self, pieces): + def _keeps_cached(self, index, size): + """Whether this layer's direct read stays in the file cache for the next step. + + A layer is kept while the kept layers, one passing layer and + CACHE_KEEP_SLACK fit in available memory, which counts cached pages. + A kept layer is given up only once the kept layers and the slack no + longer fit, one layer of hysteresis. Without page priority every read + is kept, as before. + """ + if _thread_page_priority() is None: + return True + from .system import system_memory + memory = system_memory() + available = memory.get('physical_available_bytes') or memory['available_bytes'] + with self.cache_lock: + if index in self.cache_kept: + if self.cache_kept_bytes + CACHE_KEEP_SLACK <= available: + return True + self.cache_kept_bytes -= self.cache_kept.pop(index) + return False + if self.cache_kept_bytes + 2 * size + CACHE_KEEP_SLACK <= available: + self.cache_kept[index] = size + self.cache_kept_bytes += size + return True + return False + + def _forget_cached(self, index): + with self.cache_lock: + if index in self.cache_kept: + self.cache_kept_bytes -= self.cache_kept.pop(index) + + def _read_pieces(self, pieces, low_page_priority=False): """Read (path, position, destination) pieces on a few handles at once. One handle reading a streamed layer tensor by tensor leaves most of a @@ -447,7 +566,8 @@ def _read_pieces(self, pieces): Loading already reads its blocks like this (tensor_io.ParallelReads). Each piece fills exactly its bytes of the slot, and every handle is checked against the file it opened. FREEVIDEO_READ_THREADS=1 reads on - the calling thread alone. + the calling thread alone. With low_page_priority every reader reads at + the lowest page priority and restores its own afterwards. """ import os from contextlib import ExitStack @@ -466,7 +586,7 @@ def read(group): state['active'] += 1 try: streams = {} - with ExitStack() as opened: + with ExitStack() as opened, _low_page_priority(low_page_priority): for path, position, target in group: if state['stop']: return diff --git a/freevideo_engine/superseded_tables.py b/freevideo_engine/superseded_tables.py index a3bb705d..b981e55e 100644 --- a/freevideo_engine/superseded_tables.py +++ b/freevideo_engine/superseded_tables.py @@ -26,7 +26,8 @@ MANIFEST = Path(__file__).with_name('superseded_tables.json') LEFTOVER = re.compile(r'(?P\d{2})\.(?Psafetensors|json)' - r'(?:\.partial(?:-\d{1,20}|\.source\.json)?|\.[a-z]{1,16}-\d{1,20})?') + r'(?:\.partial(?:-\d{1,20}|\.source\.json)?|\.(?:hash-rejected|oversized-response|oversized|' + r'resume-unavailable|restart-approved|rejected|switch-retained|local-import)-\d{1,20})?') RECEIPT_LIMIT = 4096 IDENTITY_LIMIT = 65536 diff --git a/freevideo_engine/support_report.py b/freevideo_engine/support_report.py index 259403a7..eda2985e 100644 --- a/freevideo_engine/support_report.py +++ b/freevideo_engine/support_report.py @@ -1,4 +1,5 @@ """One small, torch-free report per request, including failed requests.""" +from decimal import Decimal, ROUND_HALF_UP import hashlib import json import math @@ -22,6 +23,17 @@ def mapping(value): return value if isinstance(value, dict) else {} +def encoder_sizes(actual_bytes, expected_bytes): + """GiB with enough precision to distinguish unequal byte counts.""" + values = (Decimal(actual_bytes) / 2**30, Decimal(expected_bytes) / 2**30) + for places in range(1, 11): + unit = Decimal(10) ** -places + sizes = tuple(f'{value.quantize(unit, rounding=ROUND_HALF_UP):f}' for value in values) + if actual_bytes == expected_bytes or sizes[0] != sizes[1]: + return sizes + return sizes + + def code_identity(): package = Path(__file__).parent names = ('generate.py', 'policy.py', 'runtime.py', 'attention.py', 'decode.py', 'worker.py', @@ -89,6 +101,15 @@ def summarize(report, engine, encoding): steps = steps if isinstance(steps, list) and all(number(x) is not None for x in steps) else [] kernels = kernel_receipt(engine) hints = [] + checkpoint = mapping(encoding.get('checkpoint')) + actual, expected = number(checkpoint.get('bytes')), number(checkpoint.get('expected_bytes')) + if checkpoint.get('tested_size') is False and actual is not None and expected is not None: + actual, expected = encoder_sizes(actual, expected) + hints.append(dict(kind='configuration', + message='The text encoder file used is %s GiB, not the %s GiB file with the same name that FreeVideo installs and tests. ' + 'Results and speed are not guaranteed to match the tested version.' % (actual, expected), + message_zh='这次使用的文本编码器文件为 %s GiB,不是 FreeVideo 安装并测试的同名文件(%s GiB)。' + '生成结果和速度都不保证与测试过的版本一致。' % (actual, expected))) if (mapping(engine.get('config')).get('resident_blocks') == 50 and number(offload.get('transfers')) == 0 and number(offload.get('h2d_bytes')) == 0): hints.append(dict(kind='measured', message='All 50 transformer blocks are configured on GPU and recorded weight transfers are zero. ' diff --git a/freevideo_engine/terminal_ui.py b/freevideo_engine/terminal_ui.py index 51767626..593c4141 100644 --- a/freevideo_engine/terminal_ui.py +++ b/freevideo_engine/terminal_ui.py @@ -428,6 +428,10 @@ def update(**values): update(done=event['blocks'], total=50, detail='Load cached blocks and upload resident weights') if event['blocks'] == 50: update(done=0, total=0, detail='Finish model placement') + elif kind == 'model_retained': + from .model_repair import PROGRESS + update(done=0, total=0, detail=PROGRESS[0] + Path(event['file']).name + ' → ' + event['keep_as'], + scope='model-repair', unit='items') elif kind == 'model_ready': if event.get('operation') == 'verify': continue # Local verification cannot reset download ETA. diff --git a/freevideo_engine/torch_download.py b/freevideo_engine/torch_download.py index 5558a279..b72691ae 100644 --- a/freevideo_engine/torch_download.py +++ b/freevideo_engine/torch_download.py @@ -70,7 +70,7 @@ def progress(done, total, speed): return path -def install(uv, python, pins, cuda, *, root, networking, env, ui, run, constraints=(), check=None): +def install(uv, python, pins, cuda, *, root, networking, env, ui, run, constraints=(), check=None, reinstall=()): """Return False for a pin set outside the verified catalog; caller keeps its route.""" selected = select(pins, cuda) if selected is None: @@ -83,7 +83,7 @@ def install(uv, python, pins, cuda, *, root, networking, env, ui, run, constrain # CUDNN_STATUS_SUBLIBRARY_VERSION_MISMATCH, and setup or repair would # otherwise find nothing to do. from .runtime_libraries import damaged, reinstall_arguments - broken = damaged(python) + broken = sorted(set(damaged(python)).union(reinstall)) if all(installed.get(row['package']) == row['version'] for row in selected) and not broken: return True family = 'torch-' + cuda + '-' + next(row['version'].split('+')[0] for row in selected if row['package'] == 'torch') diff --git a/freevideo_engine/triton_compat.py b/freevideo_engine/triton_compat.py index 08550933..ea157557 100644 --- a/freevideo_engine/triton_compat.py +++ b/freevideo_engine/triton_compat.py @@ -17,6 +17,29 @@ CUDA_CACHE_MAXSIZE = str(4 << 30) +def torch_default_inductor_cache(): + """torch.compile's own default, as torch._inductor.runtime.cache_dir_utils builds it. + + The first time torch needs this folder it writes it back into os.environ. + A ComfyUI process that has touched torch.compile then hands the temporary + folder to every child as if a caller had chosen it, and system cleanup of + temporary files deletes the compiled graphs. + """ + import getpass + import re + import tempfile + try: + username = getpass.getuser() + except (KeyError, ModuleNotFoundError, OSError): + getuid = getattr(os, 'getuid', None) + username = f'uid_{getuid()}' if callable(getuid) else 'unknown_user' + return os.path.join(tempfile.gettempdir(), 'torchinductor_' + re.sub(r'[\\/:*?"<>|]', '_', username)) + + +def _same_path(first, second): + return os.path.normcase(os.path.abspath(first)) == os.path.normcase(os.path.abspath(second)) + + def environment(root, environ): """Configure child compilers before imports, including encoder prewarming.""" env = dict(environ) @@ -26,6 +49,11 @@ def environment(root, environ): ('CUDA_CACHE_PATH', 'cuda')): if not env.get(key): env[key] = str(cache / folder) + # Generation and background precompiling must share one folder, and it + # belongs to the installation. Only torch's inherited default is replaced; + # a folder someone set on purpose stays. + if _same_path(env['TORCHINDUCTOR_CACHE_DIR'], torch_default_inductor_cache()): + env['TORCHINDUCTOR_CACHE_DIR'] = str(cache / 'inductor') if not env.get('CUDA_CACHE_MAXSIZE'): env['CUDA_CACHE_MAXSIZE'] = CUDA_CACHE_MAXSIZE if os.name == 'nt' and any(not os.path.abspath(env[key]).isascii() diff --git a/freevideo_engine/variant_cleanup.py b/freevideo_engine/variant_cleanup.py index f84a980b..c5642873 100644 --- a/freevideo_engine/variant_cleanup.py +++ b/freevideo_engine/variant_cleanup.py @@ -66,6 +66,8 @@ def inside(base, path): def unshared_file(path): + if '.replaced-' in path.name: + return None try: info = path.lstat() except OSError: diff --git a/freevideo_engine/version_cleanup.py b/freevideo_engine/version_cleanup.py index 72dcd958..7bc4ce91 100644 --- a/freevideo_engine/version_cleanup.py +++ b/freevideo_engine/version_cleanup.py @@ -78,12 +78,14 @@ def digest(path): def plain_tree(path): - """No links or junctions anywhere below path; anything unreadable counts as one.""" + """No redirects, retained originals or unreadable entries below path.""" def unreadable(error): raise error try: for directory, folders, files in os.walk(path, followlinks=False, onerror=unreadable): for name in [*folders, *files]: + if '.replaced-' in name: + return False info = os.lstat(os.path.join(directory, name)) if stat.S_ISLNK(info.st_mode) or getattr(info, 'st_file_attributes', 0) & 0x400: return False @@ -144,7 +146,8 @@ def finish_retired(parent, root, pattern): released = 0 for path in sorted(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): + if (match and pattern.fullmatch(match['name']) and path.is_dir() + and local_path(Path(root), path) and plain_tree(path)): size = tree_bytes(path) shutil.rmtree(path, ignore_errors=True) released += size if not path.exists() else 0 @@ -178,17 +181,15 @@ def unchanged_source(path): def editable_sources(engine): """Sources an environment installed in editable mode still imports from.""" - from urllib.parse import urlsplit - from urllib.request import url2pathname + 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): try: - value = json.loads(path.read_text(encoding='utf-8')) - url = urlsplit(value['url']) - if url.scheme == 'file' and (value.get('dir_info') or {}).get('editable'): - found.add(url2pathname(url.netloc and '//' + url.netloc + url.path or url.path)) + source = editable_source(path) + if source is not None: + found.add(str(source)) except (OSError, ValueError, KeyError, TypeError, AttributeError): found.add(None) # An unreadable record could name any source. return found @@ -262,6 +263,8 @@ def applications(parent, root, keep): continue try: names = sorted(item.name for item in path.iterdir()) + if 'launcher-build.json' in names: + names.remove('launcher-build.json') if names == ['FreeVideo.app']: # The Mac copy is named by its executable and Info.plist digests. contents = path / 'FreeVideo.app' / 'Contents' @@ -350,20 +353,13 @@ def shortcut_targets(): links.append(path) found |= {Path(target) for target in read_link_targets(links).values()} elif sys.platform == 'linux': - import shlex + from .desktop_shortcut import desktop_entry_target, linux_desktop_directory data = os.environ.get('XDG_DATA_HOME', '') - folders = [Path(data) if os.path.isabs(data) else Path.home() / '.local/share', Path.home()] - for path in [*(folders[0] / 'applications').glob('*.desktop'), *(folders[1] / 'Desktop').glob('*.desktop')]: - try: - lines = path.read_text(encoding='utf-8', errors='replace').splitlines() - except OSError: - continue - for line in lines: - if line.startswith('Exec=') and 'FreeVideo' in line: - try: - found.add(Path(shlex.split(line[5:].replace('%%', '%'))[0])) - except (ValueError, IndexError): - continue + folders = [Path(data) if os.path.isabs(data) else Path.home() / '.local/share', linux_desktop_directory()] + for path in [*(folders[0] / 'applications').glob('*.desktop'), *folders[1].glob('*.desktop')]: + target = desktop_entry_target(path) + if target is not None: + found.add(target) elif sys.platform == 'darwin': import plistlib from urllib.parse import unquote, urlsplit diff --git a/freevideo_engine/windows_ux.py b/freevideo_engine/windows_ux.py index ac77668c..e2623a88 100644 --- a/freevideo_engine/windows_ux.py +++ b/freevideo_engine/windows_ux.py @@ -49,6 +49,28 @@ def open_browser(url): return webbrowser.open(url, new=2) + +def open_text(path): + """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(): + if path.lower().endswith('.txt'): + import os + os.startfile(path) + else: + # LICENSE has no program of its own; Notepad reads it. + import subprocess + subprocess.Popen(['notepad.exe', path]) + return True + if sys.platform == 'darwin': + import subprocess + subprocess.Popen(['/usr/bin/open', '-t', path], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True) + return True + from pathlib import Path + return open_browser(Path(path).as_uri()) + def hidden_console(): """Hide a console and keep it inheritable by native helper processes.""" if not windows(): diff --git a/pyproject.toml b/pyproject.toml index 4f481dbb..584a5cb4 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"] +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"] diff --git a/scripts/test_freevideo_entry.mjs b/scripts/test_freevideo_entry.mjs index 1ba5d5e6..32d13d5b 100644 --- a/scripts/test_freevideo_entry.mjs +++ b/scripts/test_freevideo_entry.mjs @@ -8,7 +8,7 @@ class Element { constructor(tag) { this.tag = tag; this.children = []; this.dataset = {}; this.attributes = {}; this.classList = {toggle() {}, add() {}}; - this.style = {setProperty(name, value) { this[name] = value; }}; + this.style = {setProperty(name, value) { this[name] = value; }}; this.offsetHeight = 66; } append(...children) { this.children.push(...children); } prepend(...children) { this.children.unshift(...children); } @@ -17,6 +17,7 @@ class Element { removeAttribute(name) { delete this.attributes[name]; } querySelector() { return null; } querySelectorAll() { return []; } + getClientRects() { return this.unrendered ? [] : [{}]; } get childElementCount() { return this.children.length; } click() { this.clicked = true; } } @@ -46,11 +47,13 @@ test('main browser entry registers both views and preserves node hooks', async t progressView = {element: new Element('progress'), stall: new Element('stall'), report: new Element('report'), updateReport() {}, update() {}, hide() {}, dispose() {}, noticeCount: () => 0, stallShown: () => !progressView.stall.hidden, percentShown: () => progressView.percent !== false}; - progressView.stall.hidden = true; + progressView.stall.hidden = true; progressView.report.hidden = true; return progressView; }, referenceTrimText: row => `trimmed ${row.kind} ${row.number}`, thermalText: thermal => `thermal ${thermal.sm_clock_mean_mhz}`, + encoderFileText: file => `encoder ${file.sizes.join(' / ')} GiB`, + encoderFilePath: file => file?.offline_installation ? file.path || '' : '', createProgressConnection: () => ({start() {}, refresh() {}, reset() {}}), notifyCompatibility: async () => { compatibilityChecks++; }, startUpdateChecks: () => { updateChecks++; }, @@ -63,6 +66,7 @@ test('main browser entry registers both views and preserves node hooks', async t const previous = new Map(); for (const [name, value] of Object.entries({ document, navigator: {language: 'en'}, window: events, + getComputedStyle: element => ({marginTop: '8px', marginBottom: '8px', display: element.hidden ? 'none' : 'block', ...element.style}), CustomEvent: class extends Event { constructor(name, options) { super(name); this.detail = options?.detail; } }, requestAnimationFrame: callback => callback(), __freevideoEntryTest: dependencies, })) { @@ -76,7 +80,7 @@ test('main browser entry registers both views and preserves node hooks', async t .replace('import { app } from "../../scripts/app.js";', 'const {app} = globalThis.__freevideoEntryTest;') .replace('import { api } from "../../scripts/api.js";', 'const {api} = globalThis.__freevideoEntryTest;') .replace("import { openStudio, loraPanel, loraWarning, promptGuide } from \"./studio.js\";", 'const {openStudio,loraPanel,loraWarning,promptGuide} = globalThis.__freevideoEntryTest;') - .replace("import { createGenerationProgress, referenceTrimText, thermalText } from './generation_progress.js';", 'const {createGenerationProgress,referenceTrimText,thermalText} = globalThis.__freevideoEntryTest;') + .replace("import { createGenerationProgress, referenceTrimText, thermalText, encoderFileText, encoderFilePath } from './generation_progress.js';", 'const {createGenerationProgress,referenceTrimText,thermalText,encoderFileText,encoderFilePath} = globalThis.__freevideoEntryTest;') .replace("import { createProgressConnection } from './progress_connection.js';", 'const {createProgressConnection} = globalThis.__freevideoEntryTest;') .replace("import { notifyCompatibility } from './compatibility.js';", 'const {notifyCompatibility} = globalThis.__freevideoEntryTest;') .replace("import { startUpdateChecks } from './updates.js';", 'const {startUpdateChecks} = globalThis.__freevideoEntryTest;') @@ -165,10 +169,17 @@ test('main browser entry registers both views and preserves node hooks', async t assert.equal(links[0].href, '/freevideo/library/download?' + new URLSearchParams({id: '2026-10-03/'+'1'.repeat(32)})); assert.equal(links[0].download, '', 'Use the server filename instead of video.mp4'); assert.match(links[1].href, /^\/view\?/); - node.freevideoShowResult({freevideo_summary: [{video, result_cache_hit: true, sample_seconds: 99}]}); + const encoder_file = {bytes: 27141342152, expected_bytes: 15687142551, sizes: ['25.3', '14.6'], + launcher: true, offline_installation: false}; + const resultWidth = node.size[0]; + node.setSize([resultWidth, 100]); + node.freevideoShowResult({freevideo_summary: [{video, result_cache_hit: true, sample_seconds: 99, encoder_file}]}); const reused = node.dom.find(row => row.name === 'freevideo_result').element; assert.equal(reused.children[0].hidden, true, 'Do not show the old sampling time as current work'); assert.ok(reused.children[1].children.some(e => e.textContent === 'Reused previous result')); + assert.ok(reused.children.some(e => e.className === 'fv-note' && e.textContent === 'encoder 25.3 / 14.6 GiB'), + 'A cached result still came from the same encoder file'); + assert.deepEqual(node.size, [resultWidth, node.computeSize()[1]], 'Grow the node after appending the encoder notice'); const again = reused.children[1].children.at(-1); assert.equal(again.textContent, 'Regenerate'); let submitted; @@ -182,12 +193,63 @@ test('main browser entry registers both views and preserves node hooks', async t node.freevideoShowProgress({label: 'Preparing video', new_request: true, reset: true}); assert.ok(!reused.children.some(e => ['fv-stats', 'fv-links'].includes(e.className)), 'The new video must not display the previous result statistics or downloads'); + const progressHeight = node.size[1]; node.freevideoShowResult({freevideo_summary: [{video, sample_seconds: 5}]}); assert.equal(reused.children[0].hidden, false, 'Show statistics again only for a completed result'); - node.freevideoShowResult({freevideo_summary: [{video, sample_seconds: 5, + assert.ok(!reused.children.some(e => e.textContent === 'encoder 25.3 / 14.6 GiB'), 'Clear the previous encoder notice'); + assert.ok(node.computeSize()[1] < progressHeight); + assert.deepEqual(node.size, [resultWidth, progressHeight], 'Shorter results must not shrink the node'); + node.freevideoShowResult({freevideo_summary: [{video, sample_seconds: 5, encoder_file, thermal: {sm_clock_mean_mhz: 900}, reference_trims: [{kind: 'video', number: 1, seconds: 20.3, used_seconds: 5.17}]}]}); assert.deepEqual(reused.children.filter(e => e.className === 'fv-note fv-trim-note').map(e => e.textContent), ['trimmed video 1'], 'Say which reference was shortened, never cut silently'); + assert.deepEqual(reused.children.filter(e => e.className === 'fv-note' && !e.hidden).map(e => e.textContent), + ['thermal 900', 'encoder 25.3 / 14.6 GiB'], 'Show the encoder notice after the thermal notice'); + assert.ok(node.computeSize()[1] > progressHeight); + assert.deepEqual(node.size, [resultWidth, node.computeSize()[1]], 'Measure the result height after all notices are appended'); + + await t.test('result widget measures all visible content and keeps other state heights', () => { + const node = new GenerateNode(); node.onNodeCreated(); + const {element, options} = node.dom.find(row => row.name === 'freevideo_result'); + element.style = {paddingTop: '11px', paddingBottom: '17px', borderTopWidth: '1px', borderBottomWidth: '2px'}; + assert.equal(options.margin, 10); + assert.equal(options.getMinHeight(), 140); + node.freevideoShowResult({freevideo_summary: [{video}]}); + assert.equal(options.getMinHeight(), 215, 'Measure statistics, actions, margins, padding and borders'); + element.children[1].offsetHeight = 101; + element.children[1].style = {marginTop: '3px', marginBottom: '5px'}; + assert.equal(options.getMinHeight(), 242, 'Wrapped actions contribute their actual height'); + node.freevideoShowResult({freevideo_summary: [{video, encoder_file}]}); + assert.equal(options.getMinHeight(), 297, 'Include the notice and both margins'); + assert.equal(options.getMaxHeight(), options.getMinHeight()); + element.unrendered = true; + assert.equal(options.getMinHeight(), 180, 'Estimate the notices while the canvas is hidden'); + element.unrendered = false; + node.freevideoShowResult({freevideo_summary: [{video, encoder_file, result_cache_hit: true}]}); + assert.equal(options.getMinHeight(), 215, 'Hidden statistics do not reserve height or margins'); + const path = String.raw`D:\FreeVideo models\文本编码器.safetensors`; + node.freevideoShowResult({freevideo_summary: [{video, encoder_file: {...encoder_file, offline_installation: true, path}}]}); + const note = element.children.find(e => e.className === 'fv-note' && !e.hidden); + assert.equal(note.children[0].className, 'fv-encoder-path'); + assert.equal(note.children[0].textContent, path, 'Keep the selectable path separate and unchanged'); + note.offsetHeight = 112; + assert.equal(options.getMinHeight(), 343, 'Include the wrapped path in its note height'); + node.freevideoShowResult({freevideo_summary: [{video, encoder_file: {...encoder_file, offline_installation: true}}]}); + assert.equal(element.children.find(e => e.className === 'fv-note' && !e.hidden).children.length, 0, + 'A library result without a path has no path line'); + node.freevideoShowResult({freevideo_summary: [{video}]}); + assert.equal(options.getMinHeight(), 215, 'Replacing a result clears its measured notices'); + node.freevideoShowResult({freevideo_summary: [{video, encoder_file}]}); + node.freevideoShowProgress({label: 'Preparing video', new_request: true, reset: true}); + assert.equal(options.getMinHeight(), 330, 'Progress keeps its existing height'); + node.freevideoStopProgress(); + assert.equal(options.getMinHeight(), 140, 'Clearing a result drops its notice height'); + node.freevideoShowFailure({exception_message: 'Failed'}); + assert.equal(options.getMinHeight(), 420, 'Failure keeps its existing height'); + node.freevideoShowResult({freevideo_summary: [{video, low_memory: true, thermal: {sm_clock_mean_mhz: 900}, + reference_trims: [{kind: 'video', number: 1}]}]}); + assert.equal(options.getMinHeight(), 461, 'Measure memory, thermal and reference trim notices'); + }); class MediaNode extends GenerateNode { constructor() { diff --git a/web/browser_extension.js b/web/browser_extension.js new file mode 100644 index 00000000..dddba375 --- /dev/null +++ b/web/browser_extension.js @@ -0,0 +1,223 @@ +// Evidence and copy shared by every FreeVideo error card. No notice is shown +// merely because an extension is installed or fetch has been wrapped. +// Store listings checked 2026-10-10. Firefox UUIDs and Safari identifiers are +// deliberately not mapped: they do not identify a store installation. +const chrome = (id, name) => ({id, name, source: `https://chromewebstore.google.com/detail/${id}`}); +const edge = (id, name) => ({id, name, source: `https://microsoftedge.microsoft.com/addons/detail/${id}`}); +export const KNOWN_EXTENSIONS = Object.freeze([ + chrome('ndcooeababalnlpkfedmmbbbgkljhpjf', ['ScriptCat', '脚本猫 ScriptCat']), + edge('liilgpjgabokdklappibcjfablkpcekh', ['ScriptCat', '脚本猫 ScriptCat']), + chrome('dhdgffkkebhmkfjojejmpbldmpobfkfo', ['Tampermonkey', 'Tampermonkey']), + edge('iikmkjmpaadaobahmlepeloendndfphd', ['Tampermonkey', 'Tampermonkey']), + chrome('gcalenpjmijncebpfijmoaglllgpjagf', ['Tampermonkey BETA', 'Tampermonkey BETA']), + edge('fcmfnpggmnlmfebfghbfnillijihnkoh', ['Tampermonkey BETA', 'Tampermonkey BETA']), + chrome('jinjaccalgkegednnccohejagnlnfdag', ['Violentmonkey', 'Violentmonkey']), + edge('eeagobfjdenkkddmbclomhiblgggliao', ['Violentmonkey', 'Violentmonkey']), + chrome('jfedfbgedapdagkghmgibemcoggfppbb', ['cat-catch', '猫抓']), + edge('oohmdefbjalncfplafanlagojlakmjci', ['cat-catch', '猫抓']), + chrome('cjpalhdlnbpafiamejdnhcphjbkeiagm', ['uBlock Origin', 'uBlock Origin']), + edge('odfafepnkmbhccpbejgmiehpchacaeak', ['uBlock Origin', 'uBlock Origin']), + chrome('ddkjiahejlhfcafbddmgiahcphecmpfh', ['uBlock Origin Lite', 'uBlock Origin Lite']), + edge('cimighlppcgcoapaliogpjjdehbnofhn', ['uBlock Origin Lite', 'uBlock Origin Lite']), + chrome('bgnkhhnnamicmpeenaelnjfhikgbkllg', ['AdGuard', 'AdGuard']), + edge('pdffkfellgipmhklpdmokmckkkfcopbh', ['AdGuard', 'AdGuard']), + chrome('gighmmpiobklfepjocnamgkkbiglidom', ['AdBlock', 'AdBlock']), + edge('ndcileolkflehcjpmjnfbnaibdcgglog', ['AdBlock', 'AdBlock']), + chrome('cfhdojbkjhnklbpkdaibdccddilifddb', ['Adblock Plus', 'Adblock Plus']), + edge('gmgoamodcdcjnbaobigkjelfplakmdhh', ['Adblock Plus', 'Adblock Plus']), +]); +const byId = new Map(KNOWN_EXTENSIONS.map(row => [row.id, row])); +const marker = /(?:chrome|moz|safari-web)-extension:\/\/|\.user\.js\b|ERR_BLOCKED_BY_CLIENT/i; +const string = value => typeof value === 'string' ? value : ''; +export function extensionErrorText(error, seen = new WeakSet()) { + if (typeof error === 'string') return error; + if (!error || typeof error !== 'object') return ''; + if (seen.has(error)) return ''; + seen.add(error); + // Do not search workflow/input objects or arbitrary user text for evidence. + return [error.name, error.message, error.stack, error.details, error.error_details, + error.exception_type, error.exception_message, + ...(Array.isArray(error.traceback) ? error.traceback : [error.traceback]), + extensionErrorText(error.error, seen), extensionErrorText(error.response, seen), extensionErrorText(error.cause, seen)] + .map(string).filter(Boolean).join('\n'); +} +export function isNetworkTypeError(error) { + const type = error?.name || error?.exception_type; + const text = typeof error === 'string' ? error : string(error?.message) || string(error?.exception_message); + return (type === 'TypeError' || /^TypeError:\s*/i.test(text)) + && /Failed to fetch|fetch failed|NetworkError(?: when attempting to fetch resource)?|network request failed|Load failed/i.test(text); +} +export function nativeFetch(fetchFunction = globalThis.fetch) { + try { return typeof fetchFunction === 'function' && /\[native code\]/.test(Function.prototype.toString.call(fetchFunction)); } + catch { return false; } +} +const nativeXHR = () => { + try { + return ['open', 'send'].every(method => /\[native code\]/.test(Function.prototype.toString.call(XMLHttpRequest.prototype[method]))); + } catch { return false; } +}; +export function userScriptName(text) { + const file = [...String(text).matchAll(/[^\s"'<>()[\]]+/g)] + .map(match => match[0].split(/[?#]/)[0].replace(/:\d+(?::\d+)?$/, '').split(/[/\\]/).pop()) + .find(name => /^.+\.user\.js$/i.test(name)); + if (!file) return null; + let decoded = file; + try { decoded = decodeURIComponent(file); } catch { /* A malformed escape remains literal. */ } + // Count Unicode characters, without splitting surrogate pairs. + const chars = Array.from(decoded); + return chars.length > 40 ? chars.slice(0, 20).join('') + '…' + chars.slice(-19).join('') : decoded; +} + +// Keep only recent extension-related console errors. No normal logging or user +// data is retained, and an old failure must not label a later network outage. +const consoleEvidence = []; +let listening = false; +export function installExtensionEvidence() { + if (listening || typeof window === 'undefined') return; + listening = true; + const original = console.error; + console.error = function (...args) { + try { + const text = args.map(value => extensionErrorText(value)).join('\n'); + if (marker.test(text)) { + consoleEvidence.push({at: Date.now(), text: text.slice(0, 8192)}); + if (consoleEvidence.length > 20) consoleEvidence.shift(); + } + } catch { /* Never break logging. */ } + return original.apply(this, args); + }; +} +// An extension frame or a user script, as a stack line names it. +const extensionFrame = /(?:chrome|moz|safari-web)-extension:\/\/|\.user\.js\b/i; +// The frame that names the culprit: a user script inside an extension first. +const namingFrame = text => { + const lines = text.split(/\r?\n/); + return lines.find(line => /chrome-extension:\/\/[a-p]{32}\//i.test(line) && /\.user\.js\b/i.test(line)) + || lines.find(line => /chrome-extension:\/\/[a-p]{32}\//i.test(line)) + || lines.find(line => extensionFrame.test(line)) || null; +}; +// `scrub` removes text the request itself supplied (prompt, inputs) before the +// search, so an echoed input is never evidence. +export function detectBrowserExtension(error, {kind = 'unknown', known = null, blocked = false, + logs = consoleEvidence.filter(row => Date.now() - row.at < 10000).map(row => row.text), + fetchFunction = globalThis.fetch, scrub = text => text} = {}) { + if (known || !['unknown', 'network', 'blocked'].includes(kind)) return null; + const ownText = scrub(extensionErrorText(error)); + const logText = logs.map(value => scrub(extensionErrorText(value))).join('\n'); + const text = [ownText, logText].join('\n'); + const evidence = []; + if (extensionFrame.test(text)) evidence.push('extension-stack'); + if (blocked || /ERR_BLOCKED_BY_CLIENT/i.test(text)) evidence.push('blocked-resource'); + if (isNetworkTypeError(error) && !nativeFetch(fetchFunction)) evidence.push('wrapped-fetch'); + if (!evidence.length) return null; + // This error's own frame names the culprit. A recent console error is used + // only when this error has none, and the script and extension always come + // from one frame: never pair names from different sources. + const frame = namingFrame(ownText) ?? namingFrame(logText); + const id = frame?.match(/chrome-extension:\/\/([a-p]{32})\//i)?.[1]?.toLowerCase(); + return {evidence, extension: byId.get(id)?.name ?? null, script: frame ? userScriptName(frame) : null}; +} + +export const EXTENSION_COPY = Object.freeze({ + title: ['A browser extension is interfering with FreeVideo', '浏览器扩展干扰了 FreeVideo'], + // English uses straight quotes: curly ones widen in the CJK font stack. + both: ['The user script "{script}" in the browser extension "{extension}" interfered with this page, so FreeVideo could not complete this action.', + '浏览器扩展“{extension}”中的用户脚本“{script}”干扰了这个页面,FreeVideo 未能完成这次操作。'], + extension: ['The browser extension "{extension}" interfered with this page, so FreeVideo could not complete this action.', + '浏览器扩展“{extension}”干扰了这个页面,FreeVideo 未能完成这次操作。'], + unknown: ['An extension in this browser interfered with this page, so FreeVideo could not complete this action.', + '浏览器里的某个扩展干扰了这个页面,FreeVideo 未能完成这次操作。'], + // The browser says a client blocked the file: an ad-blocking extension, though not which one. + blocked: ['An ad-blocking extension in this browser, such as uBlock Origin, AdGuard or Adblock Plus, blocked one of FreeVideo\'s page files, so the page cannot work properly.', + '浏览器里的广告拦截类扩展(例如 uBlock Origin、AdGuard、Adblock Plus)拦下了 FreeVideo 的页面文件,页面无法正常运行。'], + manager: ['a user script manager', '用户脚本管理器'], + choose: ['Choose one of these, then reload the page:', '请任选一种做法,然后刷新页面:'], + steps: [ + ['Turn the extension off for this page, or set its site access to "On click".', '在这个页面关闭这个扩展,或把它的网站访问权限改成“点击时”。'], + ["Add this page's address {address} to the extension's exclusion list or allowlist.", '把这个页面的地址 {address} 加进扩展的排除列表或白名单。'], + ['Open FreeVideo in a window without extensions, such as a private window, or in another browser.', '用不运行扩展的窗口(例如无痕窗口)或另一个浏览器打开 FreeVideo。'], + ], +}); +export function browserExtensionAdvice(result, t, {blocked = false, host = globalThis.location?.host || ''} = {}) { + const extension = result.extension ? t(...result.extension) : t(...EXTENSION_COPY.manager); + const template = result.script ? EXTENSION_COPY.both : result.extension ? EXTENSION_COPY.extension + : blocked || result.evidence?.includes('blocked-resource') ? EXTENSION_COPY.blocked : EXTENSION_COPY.unknown; + const summary = t(...template).replace(/\{(extension|script)\}/g, (_, key) => key === 'extension' ? extension : result.script); + return {kind: 'browser-extension', title: t(...EXTENSION_COPY.title), summary, + action: t(...EXTENSION_COPY.choose), steps: EXTENSION_COPY.steps.map(row => t(...row)), host}; +} +export function extensionSteps(advice) { + const list = document.createElement('ol'); list.className = 'fv-extension-steps'; + Object.assign(list.style, {paddingLeft: '24px', margin: '8px 0', lineHeight: '1.6'}); + for (const text of advice.steps) { + const row = document.createElement('li'); row.style.margin = '8px 0'; + if (text.includes('{address}')) { + const [before, after] = text.split('{address}'); + const code = document.createElement('code'); code.textContent = advice.host; + Object.assign(code.style, {fontFamily: 'monospace', userSelect: 'all', background: '#ffffff12', padding: '1px 4px', borderRadius: '3px'}); + row.append(document.createTextNode(before), code, document.createTextNode(after)); + } else row.textContent = text; + list.append(row); + } + return list; +} + +// Only the queue controller's read-only GETs opt into this fallback. POSTs, +// including cancellation, cannot enter it. Preserve apiURL and the headers +// fetchApi adds (Comfy-User); options are passed in explicitly so mutations to +// the header object by fetchApi also survive. +// The representative sniffer can also replace XHR.open: real-browser testing +// showed both transports fail then. If open/send is wrapped, only show advice; +// this page's alternative transport is no longer a reliable fallback. +export async function fetchQueueRead(api, path, options = {}) { + const request = {...options, headers: new Headers(options.headers)}; + try { return await api.fetchApi(path, request); } + catch (error) { + if ((options.method || 'GET').toUpperCase() !== 'GET' || !/^\/(?:queue(?:\?|$)|history(?:[/?]|$))/.test(path) + || !isNetworkTypeError(error) || !detectBrowserExtension(error) + || typeof XMLHttpRequest === 'undefined') throw error; + if (!nativeXHR()) { + error.details = [string(error.details), 'XMLHttpRequest fallback not attempted: open/send is wrapped by page code.'].filter(Boolean).join('\n'); + throw error; + } + try { + const response = await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', api.apiURL(path), true); + request.headers.set('Comfy-User', api.user ?? ''); + // fetchApi defaults to no-cache; XHR has no cache option. + if (!request.headers.has('Cache-Control')) request.headers.set('Cache-Control', options.cache === 'no-store' ? 'no-store' : 'no-cache'); + for (const [name, value] of request.headers) xhr.setRequestHeader(name, value); + xhr.withCredentials = options.credentials === 'include'; + xhr.timeout = 10000; + const abort = () => xhr.abort(); + const finish = () => options.signal?.removeEventListener('abort', abort); + xhr.onload = () => { + finish(); + if (!xhr.status) { reject(new TypeError('Failed to fetch with XMLHttpRequest')); return; } + try { + const headers = new Headers(); + for (const line of xhr.getAllResponseHeaders().trim().split(/[\r\n]+/)) { + const colon = line.indexOf(':'); + if (colon > 0) headers.append(line.slice(0, colon), line.slice(colon + 1).trim()); + } + resolve(new Response([204, 205, 304].includes(xhr.status) ? null : xhr.responseText, + {status: xhr.status, statusText: xhr.statusText, headers})); + } catch (failure) { reject(failure); } + }; + xhr.onerror = xhr.ontimeout = xhr.onabort = () => { finish(); reject(new TypeError('Failed to fetch with XMLHttpRequest')); }; + if (options.signal?.aborted) { reject(new DOMException('Aborted', 'AbortError')); return; } + options.signal?.addEventListener('abort', abort, {once: true}); + try { xhr.send(); } catch (failure) { finish(); reject(failure); } + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response; + } catch (retryError) { + // Preserve the actual extension frame in the first exception. + // A successful fallback never publishes/logs the fetch failure. + error.details = [string(error.details), `XMLHttpRequest retry failed: ${retryError.name}: ${retryError.message}`].filter(Boolean).join('\n'); + throw error; + } + } +} +installExtensionEvidence(); diff --git a/web/error_panel.js b/web/error_panel.js index 6bd3af34..68f0e358 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -1,4 +1,5 @@ // Shared by the Studio and graph view. Text only; never render exception HTML. +import {detectBrowserExtension, browserExtensionAdvice, extensionSteps} from './browser_extension.js'; const reports = new WeakSet(); const captured = new WeakMap(); const diagnosticReports = new WeakSet(); @@ -59,6 +60,7 @@ export function errorText(detail) { if (scalar(detail?.node_id)) rows.unshift(`Node ${scalar(detail.node_id)} (${scalar(detail.node_type) || 'unknown'}):`); if (scalar(detail?.exception_type)) rows.unshift(detail.exception_type); if (scalar(error?.details)) rows.push(error.details); + if (scalar(detail?.details)) rows.push(detail.details); for (const [id, node] of Object.entries(record(response.node_errors ?? detail?.node_errors))) { rows.push(`Node ${id} (${scalar(node?.class_type) || 'unknown'}):`); for (const reason of list(node?.errors)) @@ -106,7 +108,7 @@ function reportRedactor(detail, context) { for (const node of Object.values(record(detail?.response?.node_errors ?? detail?.node_errors))) for (const reason of list(node?.errors)) remember(reason?.extra_info?.received_value); const values = [...hidden].sort((a, b) => b.length - a.length); - return value => { + const removeInputs = value => { let text = scalar(value); for (const secret of values) { // Short widget values (e.g. "a") must not erase every occurrence @@ -117,6 +119,10 @@ function reportRedactor(detail, context) { text = text.replace(new RegExp(`(^|[^\\p{L}\\p{N}_])${escaped}(?=$|[^\\p{L}\\p{N}_])`, 'gu'), '$1'); } } + return text; + }; + const redact = value => { + let text = removeInputs(value); text = text.replace(/\b(?:https?|wss?|ftp|file):\/\/[^\s<>"']+/gi, '') .replace(/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, '') .replace(/\b(?:hf_|sk-|gh[pousr]_|github_pat_)[A-Za-z0-9_-]{8,}\b/g, '') @@ -134,6 +140,9 @@ function reportRedactor(detail, context) { text = text.replace(/data:[^\s"'<>]+/gi, ''); return text; }; + // Echoed input text only, for deciding what the error is about. + redact.inputsOnly = removeInputs; + return redact; } export function createErrorReport(detail, context = {}) { @@ -167,13 +176,19 @@ export function createErrorReport(detail, context = {}) { for (const key of ['done', 'total']) if (Number.isFinite(context.progress?.[key])) progress[key] = context.progress[key]; const message = scalar(response.error?.message) || scalar(response.error) || scalar(response.message) || scalar(detail?.exception_message) || scalar(detail?.message) || scalar(detail) || 'FreeVideo generation failed.'; + const known = knownError(message); + const kind = failureAdvice(errorText(detail), (en) => en, known).kind; + const found = detectBrowserExtension(detail, {kind, known, scrub: clean.inputsOnly}); + // A script name the report would redact is not shown or saved either. + const extension = found && {...found, script: found.script && clean(found.script) === found.script ? found.script : null}; const report = { schema: 'freevideo.error-report', schema_version: 1, created_at: new Date().toISOString(), stage, message: clean(message), // Chosen from the original message before redaction removes any input // text from it, so a card shown again from this report keeps its advice. - known_error: knownError(message), - error_details: clean(scalar(response.error?.details)), + known_error: known, + browser_extension: extension, + error_details: clean(scalar(response.error?.details) || scalar(detail?.details)), exception_type: clean(scalar(detail?.exception_type) || scalar(response.error?.type) || scalar(detail?.name)), http_status: Number.isInteger(detail?.status) ? detail.status : null, nodes, settings, progress, details: clean(errorText(detail)), @@ -286,7 +301,18 @@ function showFailureDialog(report, t) { const close = document.createElement('button'); close.type = 'button'; close.textContent = t('Got it', '知道了'); close.autofocus = true; close.onclick = () => dialog.close(); - dialog.append(panel.element, close); + if (panel.element.freevideoKind === 'browser-extension') { + // The steps end with a reload: that is the main button. Copy and export + // sit at the left of the same row, so a long title keeps its own line. + const footer = document.createElement('div'); footer.className = 'fv-failure-footer'; + const actions = panel.element.querySelector('.fv-failure-actions'); + actions.querySelector('.fv-failure-reload')?.remove(); + const reload = document.createElement('button'); reload.type = 'button'; reload.className = 'fv-failure-primary'; + reload.textContent = t('Reload page', '刷新页面'); reload.autofocus = true; reload.onclick = () => globalThis.location?.reload(); + close.textContent = t('Close', '关闭'); close.autofocus = false; + footer.append(actions, close, reload); + dialog.append(panel.element, footer); + } else dialog.append(panel.element, close); dialog.onclose = () => { if (activeDialog === dialog) activeDialog = null; dialog.remove(); }; document.body.append(dialog); if (typeof dialog.showModal !== 'function') { dialog.remove(); return; } @@ -307,8 +333,10 @@ export function createErrorPanel(t) { .fv-failure button,.fv-failure-dialog>button{min-height:var(--fv-h-sm,32px);border:1px solid var(--fv-border,#45454a);border-radius:var(--fv-r-sm,8px);background:var(--fv-raised,#303138);color:var(--fv-ink,#eee);padding:5px 12px;font:inherit;font-weight:var(--fv-medium,500);cursor:pointer} .fv-failure p{font-size:14px;line-height:1.6;margin:8px 0}.fv-failure summary{cursor:pointer;margin-top:12px}.fv-failure .fv-failure-cause{white-space:pre-wrap;overflow-wrap:anywhere;max-height:240px;overflow:auto}.fv-failure .fv-failure-privacy{font-size:12px;color:var(--fv-muted,#bbb)} .fv-failure code{display:inline-block;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:inherit;white-space:nowrap;background:var(--fv-raised);border-radius:var(--fv-r-xs,3px);padding:0 3px} -.fv-failure-dialog{box-sizing:border-box;width:min(560px,94vw);max-height:88vh;overflow:auto;padding:22px 24px;border:1px solid var(--fv-border,#45454a);border-radius:var(--fv-r-lg,16px);background:var(--fv-bg,#202126);color:var(--fv-ink,#eee);box-shadow:0 24px 80px #0008} -.fv-failure-dialog::backdrop{background:#070a0db8;backdrop-filter:blur(6px)}.fv-failure-dialog>.fv-failure{border:0;margin:0 0 16px;padding:0}.fv-failure-dialog>button{display:block;margin-left:auto;background:var(--fv-accent,#6db8fa);border-color:var(--fv-accent,#6db8fa);color:var(--fv-bg,#111720);font-weight:var(--fv-semibold,600)}`; +.fv-failure-dialog{box-sizing:border-box;font-size:14px;width:min(560px,94vw);max-height:88vh;overflow:auto;padding:22px 24px;border:1px solid var(--fv-border,#45454a);border-radius:var(--fv-r-lg,16px);background:var(--fv-bg,#202126);color:var(--fv-ink,#eee);box-shadow:0 24px 80px #0008} +.fv-failure-dialog::backdrop{background:#070a0db8;backdrop-filter:blur(6px)}.fv-failure-dialog>.fv-failure{border:0;margin:0 0 16px;padding:0}.fv-failure-dialog>button,.fv-failure-dialog .fv-failure-primary{display:block;margin-left:auto;background:var(--fv-accent,#6db8fa);border-color:var(--fv-accent,#6db8fa);color:var(--fv-bg,#111720);font-weight:var(--fv-semibold,600)} +.fv-failure .fv-extension-steps{font-size:14px;line-height:1.6}.fv-failure-reload{order:-1}.fv-failure-footer{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.fv-failure-footer>.fv-failure-actions{margin-right:auto} +.fv-failure-footer button{min-height:var(--fv-h-sm,32px);border:1px solid var(--fv-border,#45454a);border-radius:var(--fv-r-sm,8px);background:var(--fv-raised,#303138);color:var(--fv-ink,#eee);padding:5px 12px;font:inherit;font-weight:var(--fv-medium,500);cursor:pointer}.fv-failure-footer .fv-failure-primary{margin-left:0}`; document.head.append(style); } const element = document.createElement('section'); element.className = 'fv-failure'; element.hidden = true; @@ -316,8 +344,12 @@ export function createErrorPanel(t) { const title = document.createElement('strong'); title.textContent = t('Generation failed', '生成失败'); const copy = document.createElement('button'); copy.type = 'button'; copy.textContent = t('Copy error', '复制错误'); const download = document.createElement('button'); download.type = 'button'; download.textContent = t('Export report', '导出报告'); + // Shown only for a browser extension, whose steps end with a reload. + const reload = document.createElement('button'); reload.type = 'button'; reload.className = 'fv-failure-reload'; + reload.textContent = t('Reload page', '刷新页面'); reload.hidden = true; reload.onclick = () => globalThis.location?.reload(); const actions = document.createElement('div'); actions.className = 'fv-failure-actions'; - actions.append(copy, download); + // Last in the DOM, first on screen (CSS order), so copy stays the first child. + actions.append(copy, download, reload); const explanation = document.createElement('p'), action = document.createElement('p'); const location = document.createElement('p'), cause = document.createElement('p'); cause.className = 'fv-failure-cause'; location.className = 'fv-failure-location'; @@ -367,7 +399,9 @@ export function createErrorPanel(t) { head.append(title, actions); element.append(head, explanation, location, cause, action, disclosure, privacy, downloadStatus); return {element, show(value, popup = true, context = {}) { const report = createErrorReport(value, context); currentReport = report; - const advice = failureAdvice(report.details, t, report.known_error ?? null); + const advice = report.browser_extension + ? browserExtensionAdvice(report.browser_extension, t) + : failureAdvice(report.details, t, report.known_error ?? null); if (report.stage === 'submission' && advice.kind === 'unknown') { advice.title = t('The task could not be submitted', '任务提交失败'); advice.summary = t('The task submission returned an error. Its details are shown below.', '提交任务时返回了错误,具体原因显示在下方。'); @@ -379,6 +413,9 @@ export function createErrorPanel(t) { if (advice.kind === 'unknown' && ['submission', 'queue'].includes(report.stage)) advice.action = t('Copy the error details when reporting the issue; they include the failed stage when available.', '反馈问题时请复制错误详情,其中会保留已记录的失败阶段。'); title.textContent = advice.title; explanation.textContent = advice.summary; action.textContent = advice.action; + element.querySelector('.fv-extension-steps')?.remove(); + if (advice.kind === 'browser-extension') action.after(extensionSteps(advice)); + element.freevideoKind = advice.kind; reload.hidden = advice.kind !== 'browser-extension'; if (advice.kind === 'fa4-update') { const command = 'freevideo setup --fa4-guard'; const [before, after] = advice.summary.split(command); @@ -394,7 +431,8 @@ export function createErrorPanel(t) { `${node.type || t('Node', '节点')} #${node.id}${error.input ? ' · ' + error.input : ''}: ${error.message}${error.details ? ': ' + error.details : ''}`)); const causeText = shownCause(reasons.length ? reasons.join('\n') : [report.message, report.error_details].filter(Boolean).join('\n')); const internal = advice.kind === 'unknown' && !['submission', 'queue'].includes(report.stage); - const causeInDetails = advice.kind !== 'validation' && !['submission', 'queue'].includes(report.stage); + const causeInDetails = advice.kind === 'browser-extension' + || advice.kind !== 'validation' && !['submission', 'queue'].includes(report.stage); location.hidden = causeInDetails; detailLocation.hidden = !causeInDetails; detailLocation.textContent = causeInDetails ? location.textContent : ''; if (causeInDetails) location.textContent = ''; diff --git a/web/freevideo.js b/web/freevideo.js index a39af6ae..fbe79a96 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -2,7 +2,7 @@ import { createErrorPanel, errorText } from './error_panel.js'; import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; import { openStudio, loraPanel, loraWarning, promptGuide } from "./studio.js"; -import { createGenerationProgress, referenceTrimText, thermalText } from './generation_progress.js'; +import { createGenerationProgress, referenceTrimText, thermalText, encoderFileText, encoderFilePath } from './generation_progress.js'; import { createProgressConnection } from './progress_connection.js'; import { notifyCompatibility } from './compatibility.js'; import { startUpdateChecks } from './updates.js'; @@ -351,12 +351,31 @@ function resultPanel(node) { if (panel.lastElementChild !== warning) panel.append(warning); }; panel.append(el("div", text("Video, audio and timings appear here after generation.", "生成后在这里查看视频、音频和耗时。"), "fv-note")); - // The 218-character English notice is 8 characters shorter than before. - // Reserve at least 90px; a narrow node or larger font may wrap further. - const panelHeight = () => (hasLoRA() ? 240 : 140) + (node.freevideoFailure ? 280 : 0) + (node.freevideoProgress ? (progress.percentShown?.() === false ? 142 : 190) + 40 * progress.noticeCount() : 0) - + (progress.stallShown() ? Math.max(90, (progress.stall.scrollHeight || 0) + 18) : 0) - + (showingResult ? 40 * (node.freevideoLastResult?.reference_trims?.length || 0) : 0); - node.addDOMWidget("freevideo_result", "freevideo_result", panel, {serialize: false, getMinHeight: panelHeight, getMaxHeight: panelHeight}); + const panelMargin = 10; + const panelHeight = () => { + // A hidden canvas (the creation panel is open) has no layout to measure; estimate 40 px per notice. + if (showingResult && !panel.getClientRects().length) { + const notes = [...panel.children].filter(child => child !== warning && !child.hidden && /\bfv-note\b/.test(child.className)).length; + return (hasLoRA() ? 240 : 140) + 40 * notes + (progress.stallShown() ? Math.max(90, (progress.stall.scrollHeight || 0) + 18) : 0); + } + if (showingResult) { + const style = getComputedStyle(panel); + // ComfyUI insets the DOM element by the widget margin on both sides. + let height = 2 * panelMargin + (parseFloat(style.borderTopWidth) || 0) + (parseFloat(style.borderBottomWidth) || 0) + + (parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0); + for (const child of panel.children) { + const style = getComputedStyle(child); + if (style.display === 'none') continue; + height += child.offsetHeight + (parseFloat(style.marginTop) || 0) + (parseFloat(style.marginBottom) || 0); + } + return height; + } + // The 218-character English notice is 8 characters shorter than before. + // Reserve at least 90px; a narrow node or larger font may wrap further. + return (hasLoRA() ? 240 : 140) + (node.freevideoFailure ? 280 : 0) + (node.freevideoProgress ? (progress.percentShown?.() === false ? 142 : 190) + 40 * progress.noticeCount() : 0) + + (progress.stallShown() ? Math.max(90, (progress.stall.scrollHeight || 0) + 18) : 0); + }; + node.addDOMWidget("freevideo_result", "freevideo_result", panel, {serialize: false, margin: panelMargin, getMinHeight: panelHeight, getMaxHeight: panelHeight}); const connected = node.onConnectionsChange, configured = node.onConfigure; node.onConnectionsChange = function (...args) { const result = connected?.apply(this, args); warn(); queueMicrotask(syncPrompts); return result; }; // Nodes made from now on carry the 8 + 3 default; a later choice of 8 + 2 is kept. @@ -401,7 +420,7 @@ function resultPanel(node) { node.freevideoClearFailure(); node.freevideoLastResult = value; window.dispatchEvent(new CustomEvent('freevideo-result', {detail: {node: node.id, value}})); - panel.replaceChildren(); const stats = el("div", undefined, "fv-stats"); + panel.replaceChildren(); const resultNotes = [], stats = el("div", undefined, "fv-stats"); stats.hidden = !!value.result_cache_hit; const number = (value, scale, unit) => Number.isFinite(value) && value >= 0 ? `${(value / scale).toFixed(1)} ${unit}` : "—"; const unified = value.memory_model === 'unified'; @@ -424,7 +443,7 @@ function resultPanel(node) { upscale.onclick = async () => { upscale.disabled = true; try { await node.freevideoUpscaleResult(value); upscale.textContent = text('Queued', '已加入队列'); } - catch (error) { upscale.disabled = false; node.freevideoShowFailure({exception_message: error.message}); } + catch (error) { upscale.disabled = false; node.freevideoShowFailure(error); } }; links.append(upscale); } @@ -433,18 +452,26 @@ function resultPanel(node) { again.onclick = async () => { again.disabled = true; try { await node.freevideoRegenerateResult(value); again.textContent = text('Queued', '已加入队列'); } - catch (error) { again.disabled = false; node.freevideoShowFailure({exception_message: error.message}); } + catch (error) { again.disabled = false; node.freevideoShowFailure(error); } }; links.append(again); } panel.append(stats, links, progress.stall, progress.report); for (const row of value.reference_trims || []) panel.append(el("div", referenceTrimText(row, text), "fv-note fv-trim-note")); - if (value.low_memory && !value.result_cache_hit) panel.append(el("div", text('This video used the low-memory mode for this GPU, so it took longer than usual. A lower resolution or a shorter video runs faster.', '这段视频使用了省显存方式,所以比平时慢。降低分辨率或缩短时长会更快。'), "fv-note")); - if (value.thermal && !value.result_cache_hit) panel.append(el("div", thermalText(value.thermal, text), "fv-note")); + if (value.low_memory && !value.result_cache_hit) resultNotes.push(el("div", text('This video used the low-memory mode for this GPU, so it took longer than usual. A lower resolution or a shorter video runs faster.', '这段视频使用了省显存方式,所以比平时慢。降低分辨率或缩短时长会更快。'), "fv-note")); + if (value.thermal && !value.result_cache_hit) resultNotes.push(el("div", thermalText(value.thermal, text), "fv-note")); + if (value.encoder_file) { + const note = el("div", encoderFileText(value.encoder_file, text), "fv-note"); + const path = encoderFilePath(value.encoder_file); + if (path) note.append(el('pre', path, 'fv-encoder-path')); + resultNotes.push(note); + } + panel.append(...resultNotes); warn(); panel.title = (unified ? text("Unified memory: device capacity. Process RAM is not total GPU memory, ", "统一内存为设备总量;进程内存不代表 GPU 内存总占用,") : text("VRAM: engine allocator peak. RAM: measured request processes, ", "显存为引擎分配器峰值,内存为请求进程实测,")) + (value.ram_metric || "unknown"); + fitPanel(); }; node.freevideoUpscaleResult = async value => { if (node.freevideoUpscaling) return; diff --git a/web/generation_progress.css b/web/generation_progress.css index d9c7e7d6..22bd4f97 100644 --- a/web/generation_progress.css +++ b/web/generation_progress.css @@ -35,6 +35,7 @@ .fv-studio .fv-stage[data-short=true] .fv-generation-progress.fv-generation-compact{padding:8px 16px} /* 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} .fv-report-download{display:block;align-self:center;width:fit-content;max-width:100%;margin:8px auto 0;font-size:12px} .fv-report-download[hidden],.fv-report-download[data-covered]{display:none!important} @keyframes fv-generation-sweep{0%{left:-20%;width:20%}50%{left:33%;width:34%}100%{left:100%;width:20%}} diff --git a/web/generation_progress.js b/web/generation_progress.js index caba3065..3b26d731 100644 --- a/web/generation_progress.js +++ b/web/generation_progress.js @@ -182,6 +182,28 @@ export function thermalText(thermal, t) { `生成这段视频时,显卡大部分时间因过热降频${detail ? `(${detail})` : ''},所以比平时慢很多。请改善散热;笔记本请接通电源并选择高性能模式。`); } +export function encoderFilePath(file) { + return file?.offline_installation === true && typeof file.path === 'string' ? file.path : ''; +} + +export function encoderFileText(file, t) { + const [actual, expected] = file.sizes; + let message = t(`This run used a ${actual} GiB text encoder file, not the ${expected} GiB file with the same name that FreeVideo tests. Results and speed are not guaranteed to match the tested version.`, + `这次使用的文本编码器文件(${actual} GiB)不是 FreeVideo 测试过的同名文件(${expected} GiB)。生成结果和速度都不保证与测试过的版本一致。`); + if (file.offline_installation === true) { + const target = encoderFilePath(file) ? t('the file below', '下面这个文件') : t('this file', '这个文件'); + message += t( + ` This offline-package installation cannot switch back in one step yet. Close FreeVideo, move or rename ${target}, then import the Text encoder package again on the Installation page of the launcher.`, + `离线包安装暂时不能一键换回。请先关闭 FreeVideo,把${target}移走或改名,再在启动器的“安装”页重新导入“文本编码器”包。`); + } else if (file.launcher === true) message += t( + ' In the FreeVideo launcher, open Settings › Environment and click Repair to switch back to the tested file; the current file is kept under a new name.', + '在 FreeVideo 启动器的“设置 → 环境”中点击“修复”,可以换回测试过的文件,原文件会改名保留。'); + else message += t( + " Click Install / repair in FreeVideo's settings on this page (or, if you installed with setup.sh, run ./setup.sh again); after you confirm, the tested file is restored and the current file is kept under a new name.", + '请在页面的 FreeVideo 设置中点击“安装/修复”(用 setup.sh 安装的,也可以重新运行 ./setup.sh);确认后会换回测试过的文件,原文件改名保留。'); + return message; +} + export function referenceTrimText(row, t) { const kind = row?.kind === 'audio' ? t('Reference audio', '参考音频') : t('Reference video', '参考视频'); const name = Number.isInteger(row?.number) ? `${kind} ${row.number}` : kind; diff --git a/web/health.js b/web/health.js index 88a0d5c4..97903f94 100644 --- a/web/health.js +++ b/web/health.js @@ -1,7 +1,10 @@ // Checks that FreeVideo's page loaded completely and says what is wrong when -// it did not: which file, why, and what to do. It imports nothing and styles -// itself inline, so it still runs when every other FreeVideo file fails. +// it did not: which file, why, and what to do. It imports nothing at load and +// styles itself inline, so it still runs when every other FreeVideo file fails. // Other FreeVideo files report through `window.__freevideoHealth.push(...)`. +// The shared browser-extension check and error advice load only once the page +// check runs, after the listeners are installed; each may fail on its own. +let extensionUI = null, errorUI = null; const base = new URL('./', import.meta.url); const query = new URLSearchParams(location.search); const language = query.get('freevideo_lang'); @@ -10,7 +13,7 @@ const t = (en, zh) => cn ? zh : en; // launcher.js removes this flag during setup; read it before that. const launchVisit = query.get('freevideo') === 'launch'; // Every script this folder ships; a test keeps the list in step with web/*.js. -export const MODULES = ['compatibility.js', 'error_panel.js', 'fonts.js', 'freevideo.js', 'generation_progress.js', +export const MODULES = ['browser_extension.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', @@ -36,7 +39,14 @@ const ours = url => typeof url === 'string' && url.startsWith(base.href); // ComfyUI that name the extension or file that failed. const seen = []; const note = (kind, text, extra = {}) => { if (seen.length < 60) seen.push({kind, text: String(text).slice(0, 500), ...extra}); }; -const describe = value => value instanceof Error ? `${value.name}: ${value.message}` +// Browser-extension frames without query or fragment, and user scripts by file +// name only: enough to name the culprit, never a private URL, path or token. +const extensionFrames = text => [...new Set(String(text || '').match( + /(?:chrome|moz|safari-web)-extension:\/\/[^\s)?#'"]+|[^\s()'"?#/\\]+\.user\.js\b|ERR_BLOCKED_BY_CLIENT/gi) || [])].slice(0, 5); +// Recent extension evidence for the shared check, kept apart from what is +// reported and timed so an old console error cannot explain a later failure. +const extensionSeen = []; +const describe = value => value instanceof Error ? [`${value.name}: ${value.message}`, ...extensionFrames(value.stack)].join('\n') : value && typeof value === 'object' && value.error instanceof Error ? describe(value.error) : typeof value === 'string' ? value : (() => { try { return JSON.stringify(value); } catch { return String(value); } })(); const consoleError = console.error; @@ -64,8 +74,14 @@ function install() { console.error = function (...args) { try { const text = args.map(describe).join(' '); + const frames = extensionFrames(text); + if (frames.length) { + extensionSeen.push({at: Date.now(), text: frames.join('\n')}); + if (extensionSeen.length > 20) extensionSeen.shift(); + } if (/Error loading extension|Error calling extension|already registered|Failed to fetch dynamically imported module|does not provide an export/i.test(text) || text.includes(base.pathname)) note('console', text); + else if (frames.length) note('console', frames.join('\n')); } catch { /* Never interfere with the page's own logging. */ } return consoleError.apply(this, args); }; @@ -93,7 +109,7 @@ async function probe(url) { const response = await fetch(url, {cache: 'no-store'}); return {status: response.status, ok: response.ok, type: (response.headers.get('content-type') || '').split(';')[0].trim()}; } catch (error) { - return {status: 0, ok: false, type: '', error: describe(error)}; + return {status: 0, ok: false, type: '', error: describe(error), exception: error}; } } @@ -115,8 +131,6 @@ const ADVICE = { cache: t('FreeVideo already reloaded this page once with fresh copies of its files. Press Ctrl+F5. If it still fails, turn off browser extensions that block scripts on this page or open the same address in another browser. If that does not help, copy the details and submit them in a GitHub issue; we will look into it and work on a fix for you.', 'FreeVideo 已经用重新下载的文件自动刷新过一次页面。请按 Ctrl+F5 强制刷新;仍然出现时,请停用会拦截此页面脚本的浏览器插件,或换一个浏览器打开同一地址。仍未解决时,请复制详情并提交到 GitHub issue,我们会专门排查,为您解决这个问题。'), relaunch: t('Click Start in the FreeVideo launcher again.', '请在 FreeVideo 启动器里重新点击“启动”。'), - blocked: t(`This is usually an ad blocker. Allow ${location.hostname} in it (uBlock Origin, AdGuard and Adblock Plus can all be turned off for this site), then reload the page.`, - `通常是广告拦截插件所致。请在插件里允许 ${location.hostname}(uBlock Origin、AdGuard、Adblock Plus 都可以对当前网站停用拦截),然后刷新页面。`), }; // Requests url the way a script (modulepreload) or a stylesheet (preload) is @@ -182,7 +196,7 @@ async function diagnoseModules() { if (!result.ok) { found.push({text: result.status ? t(`Script ${name} could not be loaded: the server answered HTTP ${result.status}.`, `脚本 ${name} 无法加载:服务器返回 HTTP ${result.status}。`) - : t(`Script ${name} could not be loaded: ${result.error}.`, `脚本 ${name} 无法加载:${result.error}。`), advice: 'files'}); + : t(`Script ${name} could not be loaded: ${result.error}.`, `脚本 ${name} 无法加载:${result.error}。`), advice: 'files', error: result.exception}); } else if (!/javascript|ecmascript/i.test(result.type)) { found.push({text: t(`Script ${name} was refused: the server sent it as “${result.type || 'no type'}”.`, `脚本 ${name} 被浏览器拒绝:服务器发送的类型是“${result.type || '未标明'}”。`), advice: 'type'}); @@ -257,7 +271,7 @@ async function diagnoseStyles() { const name = fileName(link.href), result = await probe(link.href); found.push(!result.ok ? {text: t(`Stylesheet ${name} could not be loaded: ${result.status ? `HTTP ${result.status}` : result.error}.`, - `样式文件 ${name} 无法加载:${result.status ? `HTTP ${result.status}` : result.error}。`), advice: 'files'} + `样式文件 ${name} 无法加载:${result.status ? `HTTP ${result.status}` : result.error}。`), advice: 'files', error: result.exception} : result.type !== 'text/css' ? {text: t(`Stylesheet ${name} was refused: the server sent it as “${result.type || 'no type'}”.`, `样式文件 ${name} 被浏览器拒绝:服务器发送的类型是“${result.type || '未标明'}”。`), advice: 'type'} @@ -381,7 +395,27 @@ function reportText(item) { progress: t('Live progress could not start', '生成进度无法更新'), updates: t('Update checks could not start', '更新检查无法启动'), launch: t('The FreeVideo workflow did not open', 'FreeVideo 工作流没有打开')}[item.where] || t('FreeVideo reported an error', 'FreeVideo 报错'); - return {text: `${where}${cn ? ':' : ': '}${describe(item.error)}`, advice: item.where === 'launch' ? 'relaunch' : 'reload'}; + return {text: `${where}${cn ? ':' : ': '}${describe(item.error)}`, error: item.error, advice: item.where === 'launch' ? 'relaunch' : 'reload'}; +} + +function extensionProblem(problem) { + if (!extensionUI) return null; + // Preserve the same known engine causes as the execution/queue cards. + // A missing error-panel module must not disable this page check itself. + if (problem.advice !== 'blocked' && errorUI) { + const cause = errorUI.failureAdvice(errorUI.errorText(problem.error || problem.text), t, + problem.error?.known_error ?? errorUI.createErrorReport(problem.error || problem.text).known_error); + if (cause.kind !== 'unknown') return null; + } + const kind = problem.advice === 'blocked' ? 'blocked' + : ['reload', 'cache', 'relaunch', 'plugin'].includes(problem.advice) ? 'unknown' + : extensionUI.isNetworkTypeError(problem.error) ? 'network' : problem.advice; + const options = {kind, blocked: problem.advice === 'blocked'}; + // blockedAs tells us a resource was stopped, not which extension did it. + if (options.blocked) return extensionUI.detectBrowserExtension('', {...options, logs: []}); + const logs = extensionSeen.filter(row => Date.now() - row.at < 10000).map(row => row.text); + return extensionUI.detectBrowserExtension(problem.error || problem.text, {...options, logs}) + || extensionUI.detectBrowserExtension(problem.error || problem.text, options); } const distinct = problems => problems.filter((problem, index) => problems.findIndex(other => other.text === problem.text) === index); @@ -428,7 +462,10 @@ function show(problems) { border: '1px solid #5a3440', boxShadow: '0 18px 48px rgba(0,0,0,.45)', font: '13px/1.55 "Segoe UI","Microsoft YaHei UI","Noto Sans CJK SC","Noto Sans SC",system-ui,sans-serif'}); const title = document.createElement('div'); - title.textContent = t('FreeVideo did not load completely', 'FreeVideo 没有完整加载'); + const extension = problems.map(extensionProblem).find(Boolean); + const extensionAdvice = extension && extensionUI.browserExtensionAdvice(extension, t, + {blocked: problems.some(p => p.advice === 'blocked')}); + title.textContent = extensionAdvice?.title || t('FreeVideo did not load completely', 'FreeVideo 没有完整加载'); Object.assign(title.style, {color: '#f09aa2', fontWeight: '600', fontSize: '16px', marginBottom: '8px'}); const list = document.createElement('ul'); Object.assign(list.style, {margin: '0 0 10px', paddingLeft: '18px'}); @@ -438,7 +475,7 @@ function show(problems) { list.append(row); } const advice = document.createElement('div'); - const adviceText = [...new Set(problems.map(p => ADVICE[p.advice] || ADVICE.reload))].join(' '); + const adviceText = [...new Set(problems.filter(p => !extensionProblem(p)).map(p => ADVICE[p.advice] || ADVICE.reload))].join(' '); for (const [index, text] of adviceText.split('GitHub issue').entries()) { if (index) { const link = document.createElement('a'); @@ -460,7 +497,8 @@ function show(problems) { actions.append(button); return button; }; action(t('Reload page', '刷新页面'), () => location.reload(), true); - const copy = action(t('Copy details', '复制详情'), async () => { + // The extension card's steps end with a reload; its copy button matches the other error cards. + const copy = action(extensionAdvice ? t('Copy error', '复制错误') : t('Copy details', '复制详情'), async () => { const text = details(problems); try { await navigator.clipboard.writeText(text); } catch { const area = document.createElement('textarea'); area.value = text; document.body.append(area); area.select(); @@ -469,7 +507,15 @@ function show(problems) { copy.textContent = t('Copied', '已复制'); }); action(t('Close', '关闭'), () => { card?.remove(); card = null; }); - card.append(title, list, advice, actions); + if (extensionAdvice) { + const first = document.createElement('p'); first.textContent = extensionAdvice.summary; + const choose = document.createElement('p'); choose.textContent = extensionAdvice.action; + const technical = document.createElement('details'), label = document.createElement('summary'); + label.textContent = t('Technical details', '技术详情'); technical.append(label, list); + technical.style.margin = '12px 0'; + // Full URLs/IDs and the blocked filename remain in technical details. + card.append(title, first, choose, extensionUI.extensionSteps(extensionAdvice), technical, advice, actions); + } else card.append(title, list, advice, actions); document.body.append(card); if (typeof card.showPopover === 'function') { card.popover = 'manual'; @@ -509,6 +555,8 @@ function send(state, problems) { } async function check() { + [extensionUI, errorUI] = await Promise.all([import('./browser_extension.js').catch(() => null), + import('./error_panel.js').catch(() => null)]); send('loading', []); const app = await waitFor(comfy, 60000); if (!app) { @@ -527,7 +575,7 @@ async function check() { const ui = window.FreeVideoUI || {}; if (!ui.ready) { problems.push(...await diagnoseModules()); - if (problems.length && problems.every(p => p.retry) && await reloadOnce(problems)) return; + if (problems.length && problems.every(p => p.retry && !extensionProblem(p)) && await reloadOnce(problems)) return; } if (!ui.ready && !problems.length) problems.push({text: t('FreeVideo’s scripts loaded, but ComfyUI did not start them.', 'FreeVideo 的脚本已下载,但 ComfyUI 没有启动它们。'), advice: 'reload'}); if (!problems.length) problems.push(...await diagnoseStyles()); diff --git a/web/prompt_enhance.js b/web/prompt_enhance.js index f633b778..49d9cb36 100644 --- a/web/prompt_enhance.js +++ b/web/prompt_enhance.js @@ -28,6 +28,40 @@ export class PromptVersions { select(name) { if (name === 'original' || this.value.rewritten) { this.value.selected = name; this.value.useOriginal = name === 'original'; } } } +// Best-effort imports have no UI state and never hold up enhancement or video. +export function promptPrestart(api, nodeId, enabled, alive = () => true) { + let pending = null, completed = false; + // Enhancement can be turned off, or the panel closed, while the token is fetched. + const ready = () => alive() && enabled(); + function request() { + if (!ready()) return Promise.resolve(); + if (pending) return pending; + pending = (async () => { + const reply = await api.fetchApi('/freevideo/prompt-vlm'); + if (!reply.ok || !ready()) return; + const info = await reply.json(); + if (!ready()) return; + await api.fetchApi('/freevideo/prompt-vlm/prestart', {method: 'POST', + headers: {'Content-Type': 'application/json', 'X-FreeVideo-Prompt': info.token}, body: '{}'}); + })().catch(() => {}).finally(() => { pending = null; }); + return pending; + } + const onExecuted = event => { if (String(event.detail?.node) === String(nodeId)) completed = true; }; + const onStatus = event => { + const remaining = (event.detail?.status || event.detail)?.exec_info?.queue_remaining; + if (completed && remaining === 0) { + completed = false; + if (enabled()) void request(); + } + }; + api.addEventListener('executed', onExecuted); + api.addEventListener('status', onStatus); + return {request, dispose() { + api.removeEventListener('executed', onExecuted); + api.removeEventListener('status', onStatus); + }}; +} + // An empty box is never rewritten, the untouched starter prompt included: // enhancement waits for the user's own scene. export function createPromptEnhancer({node, input, editor, api, context, setText, t, starter = '', onReport = () => {}, onChange = () => {}}) { @@ -74,6 +108,10 @@ export function createPromptEnhancer({node, input, editor, api, context, setText install.append(detail, actions); bar.append(label, tabs, redo, cancel); element.append(bar, editor || input, status, install); let disposed = false, token = '', job = '', running = null, consent = null, cancelling = false, waiting = false, replacing = false; + const prestart = promptPrestart(api, node.id, () => versions.value.enabled, () => !disposed && !input.disabled); + const onFocus = () => { if (versions.value.enabled) void prestart.request(); }; + input.addEventListener('focus', onFocus); + if (versions.value.enabled) void prestart.request(); // Available RAM the model needs; the server knows the usual case for this card. let ramBytes = 11 * 2 ** 30; const gib = value => String(Math.round(value / 2 ** 30)); @@ -215,7 +253,7 @@ export function createPromptEnhancer({node, input, editor, api, context, setText toggle.onchange = () => { versions.value.enabled = toggle.checked; if (!toggle.checked) { cancelJob().catch(() => {}); versions.select('original'); apply(); } - else { versions.value.useOriginal = false; save(); render(); enhance().catch(() => {}); } + else { void prestart.request(); versions.value.useOriginal = false; save(); render(); enhance().catch(() => {}); } }; redo.onclick = () => enhance().catch(() => {}); show(''); save(false); render(); @@ -229,7 +267,8 @@ export function createPromptEnhancer({node, input, editor, api, context, setText try { if (!versions.fresh(context())) await enhance(); } catch (error) { show(errorText(error), error?.message === 'cancelled' ? '' : 'error'); return false; } }, sync() { if (input.value !== versions.text()) changed(); }, dispose() { - disposed = true; cancelJob().catch(() => {}); input.removeEventListener('input', changed); + disposed = true; prestart.dispose(); cancelJob().catch(() => {}); input.removeEventListener('input', changed); input.removeEventListener('beforeinput', replace); + input.removeEventListener('focus', onFocus); }}; } diff --git a/web/setup.css b/web/setup.css index 1a3c0399..92c3a944 100644 --- a/web/setup.css +++ b/web/setup.css @@ -63,6 +63,7 @@ /* Text, plans and measurements. */ .fv-setup p{margin:8px 0} .fv-setup .fv-muted{color:var(--fv-muted);font-size:var(--fv-micro);white-space:pre-wrap} +.fv-setup .fv-model-repair-path{overflow-wrap:anywhere} .fv-setup progress{display:block;width:100%;height:6px;margin:6px 0} .fv-setup pre{margin:0;white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.6 Consolas,ui-monospace,monospace;color:var(--fv-muted)} .fv-setup .fv-facts{display:grid;grid-template-columns:auto minmax(0,1fr);gap:6px 18px;margin:4px 0 12px;font-size:var(--fv-body)} diff --git a/web/setup.js b/web/setup.js index b1c1bd9d..8e5338f9 100644 --- a/web/setup.js +++ b/web/setup.js @@ -13,6 +13,21 @@ const cn = languageOverride === 'zh' || (languageOverride !== 'en' && String(navigator.language || '').toLowerCase().startsWith('zh')); const t = (en, zh) => cn ? zh : en; const gib = n => Number.isFinite(n) ? (n / 2 ** 30).toFixed(1) + " GiB" : "—"; +const failureText = value => { + const text = String(value || ''), matches = [...text.matchAll(/MODEL_MISMATCH_REPAIR: ([^\r\n]+)/g)]; + if (!matches.length && text.includes('The installation plan changed. Detect configuration again')) { + const files = text.includes('A file in the installation folder changed after the review'); + return files + ? 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 因此停止,未按原计划继续。请先点击“检测并预览”,再点击“安装/修复”,查看新的计划。'); + } + if (!matches.length) return text; + const file = matches.at(-1)[1]; + return t(`${file} differs from the version FreeVideo tests and was left unchanged. Click Detect & review, then Install / repair: the plan will list this file, and after you confirm, it is renamed and kept, and the tested version is downloaded.`, + `${file} 与 FreeVideo 测试过的版本不同,这次没有改动它。请先点击“检测并预览”,再点击“安装/修复”:计划里会列出这个文件,确认后会把它改名保留,再下载测试过的版本。`); +}; let current; export async function openSetup() { @@ -137,7 +152,7 @@ export async function openSetup() { dialog.addEventListener('cancel', e => { e.preventDefault(); closeDialog(dialog); }); document.body.append(dialog); dialog.showModal(); let token, planId, busy = false, probeBusy = false, pollTimer, lastPlanId, planErrors = false, planStale = false, ready = false; - const fail = error => { installation.open = true; state.textContent = error.message; state.className = "fv-failure"; state.scrollIntoView({block:'nearest'}); }; + const fail = error => { installation.open = true; state.textContent = failureText(error.message); state.className = "fv-failure"; state.scrollIntoView({block:'nearest'}); }; const request = async (action, value) => { const response = await api.fetchApi("/freevideo/setup" + (action ? "/" + action : root.value ? '?root='+encodeURIComponent(root.value) : ""), action ? { method: "POST", headers: { "Content-Type": "application/json", "X-FreeVideo-Setup": token }, body: JSON.stringify(value || {}), @@ -171,7 +186,7 @@ export async function openSetup() { '正在运行。进度见下方;关闭面板不会中断。'); } catch (error) { calibrateStatus.className = 'fv-failure'; - calibrateStatus.textContent = error.message; + calibrateStatus.textContent = failureText(error.message); } finally { calibrateButton.disabled = false; } }; saveReserve.onclick = async () => { @@ -180,7 +195,7 @@ export async function openSetup() { try { showResources(await request('resources', {[reserveField]: reserveAuto.checked ? null : Number(reserve.value)})); reserveStatus.textContent = t('Saved · applies to the next request', '已保存 · 下次生成生效'); - } catch (error) { reserveStatus.textContent = error.message; } + } catch (error) { reserveStatus.textContent = failureText(error.message); } finally { saveReserve.disabled = false; } }; function showDownloads(value) { @@ -194,7 +209,7 @@ export async function openSetup() { probeBusy = value.probe?.status === 'running'; probeButton.disabled = probeBusy; probeButton.textContent = probeBusy ? t('Testing…','正在测速…') : t('Test source speeds','重新测速'); const progress = value.probe?.progress; - sourceStatus.textContent = value.probe?.error || (probeBusy ? t('Testing sources… ','正在测速… ') + `${progress?.done || 0}/${progress?.total || '…'}` : ''); + sourceStatus.textContent = failureText(value.probe?.error) || (probeBusy ? t('Testing sources… ','正在测速… ') + `${progress?.done || 0}/${progress?.total || '…'}` : ''); const measured = value.probe?.status === 'complete' ? value.probe : value.preferences?.probe; if (measured?.network_unavailable && !probeBusy) sourceStatus.textContent = t('No sources reachable. Check your network or connection mode.', '下载源均无法连接,请检查网络或切换连接模式。'); speeds.replaceChildren(); @@ -276,9 +291,14 @@ export async function openSetup() { const list = node('dl'); list.className = 'fv-facts'; for (const [key, shown] of facts) list.append(node('dt', key), node('dd', shown)); plan.append(list); + for (const line of value.replace_models_text?.[cn ? 'zh' : 'en'] || []) { + const replacement = node('div'); replacement.className = 'fv-model-repair'; + const path = note(line.path); path.classList.add('fv-model-repair-path'); + replacement.append(node('p', line.text), path); plan.append(replacement); + } if (value.prepared_model?.private) plan.append(note(t("Private repository: authorized Hugging Face login/token required.", "私有仓库:需要有访问权限的 Hugging Face 登录/token。"))); const licenses = node("details"); licenses.append(node("summary", t("Model and toolkit licenses", "模型与工具包许可证")), node("pre", value.licenses?.join("\n") || "")); plan.append(licenses); - if (value.errors?.length) { const e = node("p", value.errors.join("\n")); e.className = "fv-failure"; plan.append(e); } + if (value.errors?.length) { const e = node("p", value.errors.map(failureText).join("\n")); e.className = "fv-failure"; plan.append(e); } } function render(task) { busy = Boolean(task.busy); ready = Boolean(task.ready) || ready && task.status !== 'failed'; @@ -292,7 +312,7 @@ export async function openSetup() { acceptLabel.hidden = busy || !planId; const p = task.progress || {}; state.className = task.status === "failed" ? "fv-failure" : task.ready ? "fv-ready" : ""; - state.textContent = task.error || (task.ready ? t("Ready. Generate with your existing workflow.", "准备就绪,可使用现有工作流生成。") : busy ? p.label || t("Checking configuration and local models…", "正在检查配置和本地模型…") : task.status === "complete" && task.action === "plan" ? (planId ? t("Review the plan, then install.", "请确认计划后安装。") : "") : task.status === "cancelled" ? t("Stopped. Files retained for resuming.", "已停止,文件保留,可继续安装。") : ""); + state.textContent = failureText(task.error) || (task.ready ? t("Ready. Generate with your existing workflow.", "准备就绪,可使用现有工作流生成。") : busy ? p.label || t("Checking configuration and local models…", "正在检查配置和本地模型…") : task.status === "complete" && task.action === "plan" ? (planId ? t("Review the plan, then install.", "请确认计划后安装。") : "") : task.status === "cancelled" ? t("Stopped. Files retained for resuming.", "已停止,文件保留,可继续安装。") : ""); const phases = task.phase_progress || {}, valid = Number.isFinite(p.total) && p.total > 0 && Number.isFinite(p.done) && p.done >= 0 && p.done <= p.total; overall.hidden = !busy; overallBar.max = phases.total > 0 ? phases.total : 1; overallBar.value = phases.done || 0; @@ -303,7 +323,8 @@ export async function openSetup() { const eta = valid && Number.isFinite(p.remaining_seconds) && p.remaining_seconds > 0 ? `${p.unit === "bytes" ? t("File ETA", "本文件预计剩余") : t("ETA", "预计剩余")} ~${Math.ceil(p.remaining_seconds)} s` : t("Estimating remaining time…", "正在估算剩余时间…"); counters.textContent = busy ? `${count} · ${eta}` : ""; const others = (task.active_tasks || []).slice(1).map(p => p.label).filter(Boolean); - detail.textContent = [p.detail, p.resource, others.length ? `${t("Also running", "同时进行")}: ${others.join(" / ")}` : ""].filter(Boolean).join(" · "); + const progressDetail = (p.detail || '').replace(/^Keeping the original file: /, t('Keeping the original file: ', '正在保留原文件:')); + detail.textContent = [progressDetail, p.resource, others.length ? `${t("Also running", "同时进行")}: ${others.join(" / ")}` : ""].filter(Boolean).join(" · "); log.textContent = [task.log, task.tail].filter(Boolean).join("\n"); controls(); } async function poll() { diff --git a/web/studio.js b/web/studio.js index a64a8fa5..a572e0c3 100644 --- a/web/studio.js +++ b/web/studio.js @@ -3,7 +3,7 @@ import { app } from '../../scripts/app.js'; import { api } from '../../scripts/api.js'; import { openSetup } from './setup.js'; import { wordmark } from './wordmark.js'; -import { createGenerationProgress, referenceTrimText, thermalText } from './generation_progress.js'; +import { createGenerationProgress, referenceTrimText, thermalText, encoderFileText, encoderFilePath } from './generation_progress.js'; import { viewSwitch, viewChanged } from './view_navigation.js'; import { createUpdateNotice, createVersionInfo, productVersion } from './updates.js'; import { createPortNotice } from './port_notice.js'; @@ -500,18 +500,27 @@ export function openStudio(node) { const saved = result; regenerate.disabled = true; try { await node.freevideoRegenerateResult(saved); regenerate.textContent = t('Queued', '已加入队列'); await syncQueue(); } - catch (error) { regenerate.disabled = false; status.dataset.error = 'true'; status.textContent = error.message; } + catch (error) { regenerate.disabled = false; resultActionFailed(error); } }, 'fv-quiet'); reuseRow.append(reuseNotice, regenerate); + // The card's own diagnosis, so a known cause (e.g. out of GPU memory) keeps its advice. + // createErrorReport caches by error, so the card shown next uses the same report. + function extensionCause(error) { return !!createErrorReport(error, {stage: 'queue', node, graph: app.graph}).browser_extension; } + // A browser extension gets the shared card; other failures stay in the status line. + function resultActionFailed(error) { + if (extensionCause(error)) { showQueueError(error); return; } + status.dataset.error = 'true'; status.textContent = error.message; + } const trimNote = el('div', '', 'fv-note fv-trim-note'); trimNote.hidden = true; trimNote.setAttribute('role', 'status'); // Run second pass is the preview's primary action in the result bar. const secondPass = saved => async () => { try { await node.freevideoUpscaleResult(saved); await syncQueue(); } - catch (error) { status.dataset.error = 'true'; status.textContent = error.message; throw error; } + catch (error) { resultActionFailed(error); throw error; } }; const memoryNote = el('div', t('This video used the low-memory mode for this GPU, so it took longer than usual. A lower resolution or a shorter video runs faster.', '这段视频使用了省显存方式,所以比平时慢。降低分辨率或缩短时长会更快。'), 'fv-note'); memoryNote.hidden = true; const thermalNote = el('div', '', 'fv-note'); thermalNote.hidden = true; - output.append(status, reuseRow, trimNote, memoryNote, thermalNote, links, stats, budget, progress.stall, progress.report, prewarm); + const encoderNote = el('div', '', 'fv-note'); encoderNote.hidden = true; + output.append(status, reuseRow, trimNote, memoryNote, thermalNote, encoderNote, links, stats, budget, progress.stall, progress.report, prewarm); const failure = createErrorPanel(t); output.append(failure.element); // With the picture, the result's actions and numbers form one block, centred below the header. output.append(el('div', null, 'fv-preview-end')); @@ -540,6 +549,9 @@ export function openStudio(node) { trimNote.textContent = trims.map(row => referenceTrimText(row, t)).join('\n'); trimNote.hidden = !trims.length; memoryNote.hidden = !r.low_memory || !!r.result_cache_hit; thermalNote.textContent = r.thermal ? thermalText(r.thermal, t) : ''; thermalNote.hidden = !r.thermal || !!r.result_cache_hit; + encoderNote.textContent = r.encoder_file ? encoderFileText(r.encoder_file, t) : ''; encoderNote.hidden = !r.encoder_file; + const encoderPath = encoderFilePath(r.encoder_file); + if (encoderPath) encoderNote.append(el('pre', encoderPath, 'fv-encoder-path')); regenerate.disabled = false; regenerate.textContent = t('Regenerate', '重新生成'); const unified = r.memory_model === 'unified'; metricLabels[2].textContent = unified ? t('Unified memory', '统一内存总量') : t('VRAM peak', '显存峰值'); @@ -640,7 +652,9 @@ export function openStudio(node) { if (['loop_stopped', 'queue_unavailable'].includes(error?.message) && node.freevideoFailureReport && node.freevideoFailureReport.stage !== 'queue') return; status.textContent = error?.schema === 'freevideo.error-report' - ? t('Submission failed. See the details below.', '提交失败,具体原因见下方。') : queueError(error); + ? t('Submission failed. See the details below.', '提交失败,具体原因见下方。') + : extensionCause(error) ? t('A browser extension is interfering with FreeVideo. See details below.', '浏览器扩展干扰了 FreeVideo,详见下方。') + : queueError(error); const identity = error?.schema === 'freevideo.error-report' ? error : error?.message || error; if (lastQueueError === identity) return; lastQueueError = identity; diff --git a/web/studio_queue.js b/web/studio_queue.js index 8ff4e677..38b41f96 100644 --- a/web/studio_queue.js +++ b/web/studio_queue.js @@ -1,5 +1,6 @@ // ComfyUI owns execution. This controller only submits frozen workflows and // replenishes a loop after its previous request has actually completed. +import {fetchQueueRead, detectBrowserExtension} from './browser_extension.js'; const clone = value => structuredClone(value); // Repeat the completed request, not an editor draft changed while it was running. @@ -9,9 +10,12 @@ export async function regenerateResult(api, nodeId, result, currentPrompt) { const path = result.request_id ? '/history/' + encodeURIComponent(result.request_id) : '/history?max_items=64'; let rows = []; try { - const response = await api.fetchApi(path); + const response = await fetchQueueRead(api, path); if (response.ok) rows = Object.values(await response.json()); - } catch { /* Cleared/unavailable history is an ordinary new generation. */ } + } catch (error) { + if (detectBrowserExtension(error)) throw error; + /* Cleared/unavailable history is an ordinary new generation. */ + } rows.sort((a, b) => (b.prompt?.[0] || 0) - (a.prompt?.[0] || 0)); const saved = rows.find(row => row.outputs?.[nodeId]?.freevideo_summary?.[0]?.video === result.video && row.outputs[nodeId].freevideo_summary[0].result_cache_hit === true @@ -35,9 +39,12 @@ export async function upscaleResult(api, nodeId, result, currentPrompt) { const path = result.request_id ? '/history/' + encodeURIComponent(result.request_id) : '/history?max_items=64'; let rows = []; try { - const response = await api.fetchApi(path); + const response = await fetchQueueRead(api, path); if (response.ok) rows = Object.values(await response.json()); - } catch { /* Cleared/unavailable history: the bridge still uses the preview's request. */ } + } catch (error) { + if (detectBrowserExtension(error)) throw error; + /* Cleared/unavailable history: the bridge still uses the preview's request. */ + } rows.sort((a, b) => (b.prompt?.[0] || 0) - (a.prompt?.[0] || 0)); const saved = rows.find(row => row.outputs?.[nodeId]?.freevideo_summary?.[0]?.video === result.video && row.status?.status_str === 'success' && row.status?.completed @@ -124,7 +131,7 @@ export function createStudioQueue(api, nodeId, {random = randomSeed, pollMs = 20 session.waiting = reply.prompt_id; // Stop may be clicked while the server is acknowledging submission. if (!session.active) { - const response = await api.fetchApi('/queue'); + const response = await fetchQueueRead(api, '/queue'); if (response.ok && (await response.json()).queue_pending?.some(row => row[1] === reply.prompt_id)) await cancel(reply.prompt_id); } } @@ -135,7 +142,7 @@ export function createStudioQueue(api, nodeId, {random = randomSeed, pollMs = 20 const session = loop; if (!session?.active || session.sending || !session.waiting) return; if (running?.id === session.waiting || pending.some(row => row.id === session.waiting)) return; - const response = await api.fetchApi('/history/' + encodeURIComponent(session.waiting)); + const response = await fetchQueueRead(api, '/history/' + encodeURIComponent(session.waiting)); if (!response.ok) throw new Error('queue_unavailable'); const history = (await response.json())[session.waiting]; if (loop !== session || !session.active) return; @@ -150,7 +157,7 @@ export function createStudioQueue(api, nodeId, {random = randomSeed, pollMs = 20 finally { session.sending = false; } } async function readQueue() { - const response = await api.fetchApi('/queue'); + const response = await fetchQueueRead(api, '/queue'); if (!response.ok) throw new Error('queue_unavailable'); const queue = await response.json(); running = (queue.queue_running || []).filter(matches).map(entry)[0] || null; diff --git a/web/updates.js b/web/updates.js index 18acbdd7..78b205c2 100644 --- a/web/updates.js +++ b/web/updates.js @@ -150,7 +150,7 @@ export function createUpdateNotice(cn) { ? t('Switching to the faster model… ComfyUI restarts once and this page reconnects by itself.', '正在切换到更快的模型…ComfyUI 会重启一次,页面会自动恢复。') : model === 'waiting' ? t('The faster model is ready. FreeVideo switches after the current video; ComfyUI restarts once.', '更快的模型已下载好,当前视频生成完后自动切换,ComfyUI 会重启一次。') : offline || phase === 'restarting' || phase === 'engine' - ? t('Restarting FreeVideo to finish the update… this page refreshes by itself.', '正在重启 FreeVideo 完成更新…页面会自动刷新。') + ? t('Restarting FreeVideo to finish the update. The page refreshes by itself.', '正在重启 FreeVideo 完成更新,页面会自动刷新。') : phase === 'downloading' ? t('Downloading the update', '正在下载更新') + percent : phase === 'waiting' ? t('The update installs as soon as the current video finishes.', '当前视频生成完成后会自动更新。') : t('Checking for updates…', '正在检查更新…');