diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5f7e5ba5..49829717 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -20,6 +20,9 @@ FreeVideo code uses [Apache-2.0](LICENSE). Its dependencies and models retain th | [ONNX Runtime](https://github.com/microsoft/onnxruntime) 1.28.2 (CPU), © Microsoft Corporation; its third-party notices are shipped inside the sherpa-onnx wheel | MIT | | [Eigen](https://gitlab.com/libeigen/eigen) 5.0.1, compiled into sherpa-onnx and ONNX Runtime; source: https://gitlab.com/libeigen/eigen/-/tree/5.0.1 | MPL-2.0 | | [Silero VAD](https://github.com/snakers4/silero-vad) v5.0 voice-activity model, © 2020-present Silero Team (optional download with subtitles, license file downloaded with the model; the onnx-community export is downloaded with the music model) | [MIT](freevideo_engine/licenses/Silero-VAD-MIT.txt) | +| [ACE-Step 1.5](https://github.com/ace-step/ACE-Step-1.5) XL turbo music model, © 2026 ACEStep (optional download for music; inference subset adapted in `freevideo_engine/music/acestep_model.py`) | [MIT](freevideo_engine/licenses/ACE-Step-MIT.txt) | +| [vector-quantize-pytorch](https://github.com/lucidrains/vector-quantize-pytorch), © 2020 Phil Wang (residual FSQ decoding adapted in `freevideo_engine/music/acestep_model.py`) | [MIT](freevideo_engine/licenses/Vector-Quantize-PyTorch-MIT.txt) | +| [Qwen3-Embedding-0.6B](https://huggingface.co/Qwen/Qwen3-Embedding-0.6B), © Alibaba Cloud (text encoder of the music model, downloaded with it) | [Apache-2.0](freevideo_engine/licenses/Qwen3-Embedding-Apache-2.0.txt) | | [NVIDIA NeMo stt_en_conformer_ctc_small](https://huggingface.co/nvidia/stt_en_conformer_ctc_small), © NVIDIA, exported to ONNX and quantized to int8 by the sherpa-onnx project, changed from the original; not endorsed by NVIDIA (optional download for English subtitles; [notice](freevideo_engine/licenses/NeMo-stt_en_conformer_ctc_small-NOTICE.txt)) | [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/) | | [OpenAI Whisper tiny](https://github.com/openai/whisper), © 2022 OpenAI, exported to ONNX and quantized to int8 by the sherpa-onnx project (optional download for spoken-language detection) | [MIT](freevideo_engine/licenses/Whisper-MIT.txt) | | [SenseVoiceSmall](https://huggingface.co/FunAudioLLM/SenseVoiceSmall) by FunASR / FunAudioLLM (Alibaba Group), converted to ONNX and quantized to int8 by the sherpa-onnx project, used unchanged under its original name (optional download for multilingual subtitles; [notice](freevideo_engine/licenses/SenseVoice-NOTICE.txt)) | [FunASR Model Open Source License Agreement v1.1](freevideo_engine/licenses/FunASR-MODEL_LICENSE-v1.1.txt) | diff --git a/freevideo.ps1 b/freevideo.ps1 index 5a3e24a8..468d1149 100644 --- a/freevideo.ps1 +++ b/freevideo.ps1 @@ -137,10 +137,14 @@ try { if ($pythonExit -eq 0) { $installed = $true; break } if (-not (Test-FreeVideoSlowDownloadFailure $pythonOutput)) { $allNetworkFailures = $false } Write-Host "Python install source $attempt returned exit code $pythonExit. Details retained in $bootstrapLog" + $pythonReason = Get-FreeVideoFailureReason $pythonOutput + if ($pythonReason) { Write-Host "uv reported: $pythonReason" } } } } - if (-not $installed) { throw "Python installation failed (uv exit $pythonExit). Check your network or connection mode in Downloads. See $bootstrapLog and retry." } + $pythonDetail = '' + if ($pythonReason) { $pythonDetail = " uv reported: $pythonReason." } + if (-not $installed) { throw "Python installation failed (uv exit $pythonExit).$pythonDetail Check your network or connection mode in Downloads. See $bootstrapLog and retry." } $freevideoPython = (& $uv python find --managed-python $versions.python | Select-Object -Last 1).Trim() if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $freevideoPython)) { throw 'Managed Python could not be located.' } } finally { diff --git a/freevideo_engine/activation_staging.py b/freevideo_engine/activation_staging.py index 9d2f2cd1..48c8c09d 100644 --- a/freevideo_engine/activation_staging.py +++ b/freevideo_engine/activation_staging.py @@ -1,6 +1,75 @@ """Contiguous D2H head groups with double-buffered CPU projection tiles.""" +import collections import torch +# Every refused locked-page copy of this process, residual stream and head +# readouts alike, so a report shows whether the fallback fired, which copy was +# refused and what CUDA said. The newest entries are kept; the count is exact. +_REFUSED = collections.deque(maxlen=32) +_refused_total = 0 + + +def record_refusal(source, copy, error): + global _refused_total + lines = str(error).strip().splitlines() + _REFUSED.append(dict(source=source, copy=copy, error=(lines[0] if lines else '')[:200])) + _refused_total += 1 + + +def refusals_mark(): + return _refused_total + + +def refusals_since(mark): + """Refusals recorded after `mark`, as plain dicts for the sampling metrics.""" + count = min(_refused_total - mark, len(_REFUSED)) + return [dict(row) for row in list(_REFUSED)[len(_REFUSED) - count:]] if count > 0 else [] + + +class LockedCopies: + """Asynchronous copies through page-locked buffers that continue in pageable memory. + + Windows can refuse a queued copy with "invalid argument" when the system + takes locked pages back. The queued work is finished first, so a sticky + error is raised as itself rather than hidden by the retry. The copy then + goes through a pageable temporary, which holds the same bytes, and the + remaining copies of these buffers skip the locked path. A failed device + allocation is not a refusal: it is raised for the caller to recover from, + and the buffers stay locked. + """ + + def __init__(self): + self.locked = True + self.fallbacks = 0 + + def _leave(self, device, copy, error): + record_refusal('readout', copy, error) + if device.type == 'cuda': + torch.cuda.synchronize(device) + self.locked = False + self.fallbacks += 1 + + def to_host(self, destination, source): + if self.locked: + try: + destination.copy_(source, non_blocking=True) + return + except torch.cuda.OutOfMemoryError: + raise + except RuntimeError as error: + self._leave(source.device, 'to_host', error) + destination.copy_(source.to('cpu')) + + def to_device(self, source, device): + if self.locked: + try: + return source.to(device, non_blocking=True) + except torch.cuda.OutOfMemoryError: + raise + except RuntimeError as error: + self._leave(torch.device(device), 'to_device', error) + return source.clone().to(device) + class HeadReadouts: def __init__(self, rows, head_widths, dtype, tile_rows): @@ -11,6 +80,7 @@ def __init__(self, rows, head_widths, dtype, tile_rows): self.groups = [torch.empty((self.capacity, width), dtype=dtype, pin_memory=True) for width in head_widths] self.tiles = [torch.empty((tile_rows, sum(head_widths)), dtype=dtype, pin_memory=True) for _ in range(2)] self.events = [None, None] + self.copies = LockedCopies() def supports(self, rows, head_widths, dtype, tile_rows): return rows <= self.capacity and tuple(head_widths) == self.widths and dtype == self.dtype and tile_rows == self.tile_rows @@ -18,7 +88,7 @@ def supports(self, rows, head_widths, dtype, tile_rows): def store(self, group, values): if values.shape[1] != self.widths[group] or not values.is_contiguous(): raise ValueError('Head readouts must be contiguous [rows, head channels]') - self.groups[group][:len(values)].copy_(values, non_blocking=True) + self.copies.to_host(self.groups[group][:len(values)], values) def project(self, module, output, rows): # The caller synchronizes once after all D2H groups have been written. @@ -33,7 +103,7 @@ def project(self, module, output, rows): for group, width in zip(self.groups, self.widths): tile[:, offset:offset + width].copy_(group[start:start + count]) offset += width - device_values = tile.to(output.device, non_blocking=True) + device_values = self.copies.to_device(tile, output.device) done = torch.cuda.Event() done.record(stream) self.events[slot] = done diff --git a/freevideo_engine/adaptive.py b/freevideo_engine/adaptive.py index 8a371cd7..9963b8ef 100644 --- a/freevideo_engine/adaptive.py +++ b/freevideo_engine/adaptive.py @@ -363,6 +363,9 @@ def nonlocal_exhausted(failure): Page-locked host memory counts against that budget. A 20 s request failed with non-local usage at 13.14 of 13.24 GiB, 10.68 GiB of VRAM free and no caching-allocator OOM; the failed allocation itself is not in the usage. + A 1920x1088 request failed with usage at 13.9 of 15.2 GiB: the allocation + that did not fit was the 2 GiB residual buffer, so the margin is the pin + reserve rather than one GiB. """ gpu = failure.get('gpu') if isinstance(failure.get('gpu'), dict) else {} values = [gpu.get(key) for key in ('nonlocal_budget_bytes', 'nonlocal_usage_bytes', @@ -370,7 +373,8 @@ def nonlocal_exhausted(failure): if not all(type(value) is int and value >= 0 for value in values) or not values[0]: return False budget, usage, local_budget, local_usage = values - return (usage >= budget - 2**30 and local_usage <= local_budget - 2 * 2**30 + from .windows_gpu_memory import NONLOCAL_PIN_RESERVE + return (usage >= budget - NONLOCAL_PIN_RESERVE and local_usage <= local_budget - 2 * 2**30 and not gpu.get('allocator_ooms')) diff --git a/freevideo_engine/agent/comfy.py b/freevideo_engine/agent/comfy.py index d9afb865..b27f6317 100644 --- a/freevideo_engine/agent/comfy.py +++ b/freevideo_engine/agent/comfy.py @@ -354,7 +354,8 @@ def image_start(self, action, value): code = getattr(error, 'code', str(error)) code = code if code in IMAGE_ERRORS else 'invalid_request' raise JobError(code, IMAGE_ERRORS[code], retryable=code in IMAGE_WAIT_ERRORS, - kind='image', report_id=getattr(error, 'report_id', None)) from None + kind='image', report_id=getattr(error, 'report_id', None), + other_usage=getattr(error, 'status', {}).get('other_usage')) from None def image_status(self, key): return self.image.status(key) diff --git a/freevideo_engine/agent/errors.py b/freevideo_engine/agent/errors.py index 97d362e2..6c03fe6b 100644 --- a/freevideo_engine/agent/errors.py +++ b/freevideo_engine/agent/errors.py @@ -12,6 +12,7 @@ REQUEST_ID = ContextVar('agent_request_id', default=None) # The work session a token request belongs to (sessions.py); jobs and productions record it. SESSION = ContextVar('agent_session', default=None) +_UNREAD_USAGE = object() # The operation digest a confirmation approves: method, route, path values and body without ref. CONFIRM = ContextVar('agent_confirm', default=None) # code: HTTP status, retryable, public message, concrete recovery action @@ -128,6 +129,8 @@ # Static bilingual image reasons; backend exception strings never enter the API. IMAGE_REASONS = { + 'small_memory': ('This Mac has less than 16 GB of memory and cannot generate pictures.', + '这台 Mac 的内存不足 16 GB,无法生成图片。'), 'no_memory': ('Not enough GPU memory, even at the smallest size. Close other apps using the GPU, then try again.', '显存不够,最小尺寸也没能生成。关掉其他占用显卡的程序后再试。'), 'busy_video': ('A video is generating. Try again when it finishes.', '视频正在生成,完成后再试。'), @@ -164,6 +167,7 @@ def image_failure(code, report_id=None): # Recovery is a function of the committed task and phase, not just an error code. IMAGE_ERRORS = { **LORA_MESSAGE_EN, + 'small_memory': IMAGE_REASONS['small_memory'][0], 'invalid_request': 'The picture request contains invalid fields.', 'invalid_prompt': 'The picture prompt must contain text and be at most 2000 characters.', 'invalid_dimensions': 'Picture width and height must be integers from 256 to 2048.', @@ -413,7 +417,26 @@ def failure_reason_en(error): def reasons(error): - return dict(error_zh=failure_reason(error), error_en=failure_reason_en(error)) + from ..other_usage import fact, join + result = dict(error_zh=failure_reason(error), error_en=failure_reason_en(error)) + for key, zh in (('error_zh', True), ('error_en', False)): + suffix = fact(error.get('other_usage'), zh=zh) + if suffix and suffix not in result[key]: + result[key] = join(result[key], suffix, zh=zh) + return result + + +def capture_other_usage(error): + from ..other_usage import at_failure, fact, join, MEMORY_ERRORS + if error and error.get('code') in MEMORY_ERRORS and 'other_usage' not in error: + error = dict(error, other_usage=at_failure(error['code'])) + if error and error.get('other_usage') and error.get('reason'): + error = dict(error, reason=dict(error['reason'])) + for lang in ('en', 'zh'): + suffix = fact(error['other_usage'], zh=lang == 'zh') + if suffix and suffix not in error['reason'].get(lang, ''): + error['reason'][lang] = join(error['reason'].get(lang, ''), suffix, zh=lang == 'zh') + return error def with_reason(error): @@ -422,7 +445,7 @@ def with_reason(error): def build(code, *, details=None, request=None, validation=False, message=None, next_action=None, action_required=True, retry_after_seconds=None, owner=None, kind=None, stage='request', resumable=True, - report_id=None, memory=None): + report_id=None, memory=None, other_usage=_UNREAD_USAGE): code = code if code in ERRORS else 'internal_error' _, retryable, default_message, _ = ERRORS[code] action = recovery(code, kind=kind, stage=stage, resumable=resumable) @@ -438,6 +461,9 @@ def build(code, *, details=None, request=None, validation=False, message=None, n result.update(image_failure(code, report_id)) if code == 'no_memory' and memory == 'host': result['memory'] = 'host' + if other_usage is not _UNREAD_USAGE: + result['other_usage'] = other_usage + result = capture_other_usage(result) if action_required: result['next'] = next_action if validation and next_action else action if code == 'busy': diff --git a/freevideo_engine/agent/jobs.py b/freevideo_engine/agent/jobs.py index 51648bbe..9cd7488e 100644 --- a/freevideo_engine/agent/jobs.py +++ b/freevideo_engine/agent/jobs.py @@ -88,7 +88,8 @@ def view(self): return errors.build(self.code, details=self.details.get('details'), owner=self.details.get('owner'), retry_after_seconds=self.details.get('retry_after_seconds'), - kind=self.details.get('kind'), report_id=self.details.get('report_id')) + kind=self.details.get('kind'), report_id=self.details.get('report_id'), + **({'other_usage': self.details['other_usage']} if 'other_usage' in self.details else {})) class ManagedByProduction(JobError): @@ -681,16 +682,19 @@ def touch(self, row, **changes): def finish(self, row, status, *, result=None, error=None, save=True): if error is not None: + error = errors.capture_other_usage(error) code = error.get('code', 'internal_error') report_id = error.get('report_id') if code == 'invalid_json' and row['kind'] in ('vlm', 'enhance'): code = 'vlm_invalid_json' - reason_zh, reason_en = errors.failure_reason(dict(error, code=code)), errors.failure_reason_en(dict(error, code=code)) + failure_reasons = errors.reasons(dict(error, code=code)) + reason_zh, reason_en = failure_reasons['error_zh'], failure_reasons['error_en'] if code != error.get('code') or not error.get('request_id') or row['kind'] == 'image': error = errors.build(code, details=error.get('details'), request=row.get('request_id'), action_required=code != 'cancelled', kind=row['kind'], stage='terminal', resumable=not row['spec'].get('refine_from'), - report_id=error.get('report_id'), memory=error.get('memory')) + report_id=error.get('report_id'), memory=error.get('memory'), + other_usage=error.get('other_usage')) if row['kind'] == 'image': reason_zh, reason_en = error['error_zh'], error['error_en'] if error is not None: @@ -1235,7 +1239,8 @@ def view(self, row, *, detail=True): if row['kind'] == 'image': result['error'] = errors.build(result['error']['code'], kind='image', stage='terminal', details=result['error'].get('details'), request=row.get('request_id'), - action_required=result['error']['code'] != 'cancelled', report_id=row['error'].get('report_id')) + action_required=result['error']['code'] != 'cancelled', report_id=row['error'].get('report_id'), + other_usage=row['error'].get('other_usage')) else: result['error'] = dict(result['error'], **errors.reasons(row['error'])) report_id = row['error'].get('report_id') @@ -2098,7 +2103,8 @@ def poll_image(self, row): code = failure.get('code') if isinstance(failure, dict) else failure code = code or 'generation_failed' if not self.wait_image(row, code): - self.finish(row, 'failed', error=dict(code=code, report_id=state.get('report_id'), memory=state.get('memory'))) + self.finish(row, 'failed', error=dict(code=code, report_id=state.get('report_id'), memory=state.get('memory'), + other_usage=state.get('other_usage'))) # Local VLM ----------------------------------------------------------------------------------------- def start_vlm(self, row): diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py index 57fa1ab4..905a4d0e 100644 --- a/freevideo_engine/agent/schema.py +++ b/freevideo_engine/agent/schema.py @@ -1538,6 +1538,9 @@ def openapi(): 'description': 'Image diagnostic report ID, or null when no report exists.'}, request_id=string(), docs=string(), retry_after_seconds=number(0), card={'type': 'object'}, job=JOB, project={'type': 'object'}, current_revision=number(1, integer=True), + other_usage=nullable(obj(dict(vram_gib=number(0), ram_gib=number(0), + kinds={'type': 'array', 'items': enum(('gpu', 'ram'))}, signature=string()), + ('vram_gib', 'ram_gib', 'kinds', 'signature'))), error_zh=string(), error_en=string()), ('code', 'message', 'details', 'retryable', 'request_id', 'docs'))})) diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index 368106c4..a42794dd 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -1234,6 +1234,8 @@ def reusable_kernel_receipt(path, hardware_data): class Installer: def __init__(self, value, ui=None): self.locks = ExitStack() + self.uv_lock = threading.Lock() + self.uv_failure = None try: self.initialize(value, ui) except BaseException: @@ -1470,6 +1472,32 @@ def feedback(row): raise self.ui.end(key, detail='Verified and ready') + def ensure_uv(self): + """Fetch uv again when security software or a cleaner removed it after it was prepared.""" + uv = Path(self.env['FREEVIDEO_UV']) + def usable(): + try: + return uv.is_file() and uv.stat().st_size > 0 + except OSError: + return False + if usable(): + return uv + with self.uv_lock: + # One attempt per installation: concurrent steps and later commands report the same failure. + if self.uv_failure: + raise RuntimeError(self.uv_failure) + if not usable(): + self.ui.event('setup_cache', key='uv', detail='Downloading the uv tool again; it was missing') + try: + self.env['FREEVIDEO_UV'] = str(self.prepare_tools()) + except BaseException: + self.uv_failure = 'The uv tool is missing at %s and could not be restored.' % uv + raise + if not usable(): + self.uv_failure = 'The uv tool is missing at %s and could not be restored.' % uv + raise RuntimeError(self.uv_failure) + return uv + def command(self, label, args, env=None, cwd=None): from .ram import ProcessMemory from .package_progress import PackageOutput @@ -1480,6 +1508,8 @@ def command(self, label, args, env=None, cwd=None): env = dict(env, FREEVIDEO_INSTALL_PROGRESS='1') env = with_private_compiler(self.root, env) args, cwd = uv_file_arguments(args, cwd) + if args and str(args[0]) == self.env.get('FREEVIDEO_UV'): + self.ensure_uv() env = uv_build_environment(args, self.root, env) with self.state_lock: index = len(self.state['steps']) @@ -1792,6 +1822,7 @@ def python_install(self, uv): # and unpack another copy. Existing installations keep their own base. if prepared.is_dir() and not any(current.glob('cpython-' + self.versions['python'] + '-*')): env = dict(self.env, UV_PYTHON_INSTALL_DIR=str(prepared)) + uv = self.ensure_uv() if str(uv) == self.env.get('FREEVIDEO_UV') else uv found = subprocess_output.run_external([str(uv), '--no-config', 'python', 'find', '--managed-python', '--no-python-downloads', self.versions['python']], env=env, capture_output=True, timeout=30) diff --git a/freevideo_engine/comfy_capabilities.py b/freevideo_engine/comfy_capabilities.py index 4ad9c07a..677c7b19 100644 --- a/freevideo_engine/comfy_capabilities.py +++ b/freevideo_engine/comfy_capabilities.py @@ -27,7 +27,7 @@ def _hash(value): ensure_ascii=False, allow_nan=False).encode()).hexdigest()[:12] -def _context(output_directory): +def _machine_context(): from .background_compile import compile_owed from .comfy_bridge import installation root, machine = installation() @@ -52,6 +52,12 @@ def _context(output_directory): machine['hardware'] = observed # Receipt read only: tells the page the first run still compiles. machine['compile_owed'] = compile_owed(root) + return machine + + +def _context(output_directory): + machine = _machine_context() + device = machine.get('device_identity') if machine.get('device_backend') == 'mps' else machine.get('gpu_uuid') # Do not inherit the sampling-estimate route's 15-second history cache: # reopening the grid after a generation must notice the new timing record. rows = local_records(output_directory, device, system=machine.get('system'), stage_details=True, refresh=True) @@ -270,6 +276,56 @@ def _query(query): return aspect, size, task, query.get('adapters') == '1', normalize_references(task, references) +def _usage_query(query): + """Same canvas/material fields as capabilities, plus this request's duration/profile.""" + from .geometry import geometry + from .tiers import TIERS, STEP_CACHES + kind = query.get('kind', 'video') + if kind not in ('video', 'image'): + raise ValueError('Unsupported generation kind.') + if kind == 'image': + width, height = int(query.get('width', 1024)), int(query.get('height', 1024)) + if not 256 <= width <= 2048 or not 256 <= height <= 2048: + raise ValueError('Use image dimensions from 256 to 2048.') + count = int(query.get('image_references', '0')) + if not 0 <= count <= 4: + raise ValueError('Use at most four image references.') + # Image preflight normalizes each reference to 1024**2 pixels. + return dict(kind=kind, width=width, height=height, reference_pixels=count * 1024**2) + _, size, task, _, references = _query(query) + width, height = size or (1344, 768) + tier = query.get('tier', 'light') + if tier not in (*TIERS, 'custom'): + raise ValueError('Unsupported tier.') + defaults = TIERS.get(tier, TIERS['light']) + flag = query.get('two_pass', '1' if defaults['two_pass'] else '0') + if flag not in ('0', '1'): + raise ValueError('Use a boolean two_pass flag.') + steps, refine = int(query.get('base_steps', defaults['steps'])), int(query.get('refine_steps', 3)) + cache = query.get('step_cache', defaults['step_cache']) + cache = None if cache in (None, 'none', '') else cache + upscale = float(query.get('upscale_factor', defaults['upscale'])) + if not 1 <= steps <= 32 or not 0 <= refine <= 31 or cache not in STEP_CACHES or upscale not in (1.2, 1.5, 2): + raise ValueError('Unsupported sampling profile.') + if 'frames' in query and 'seconds' in query: + raise ValueError('Specify frames or seconds, not both.') + shape = geometry(width, height, frames=int(query['frames'])) if 'frames' in query else geometry( + width, height, seconds=float(query.get('seconds', 10.125))) + return dict(kind=kind, width=width, height=height, frames=shape['frames'], steps=steps, + two_pass=flag == '1', task=task, refine=refine, references=reference_parameters(references), + step_cache=cache, upscale=upscale) + + +def _other_usage(params, failure=False): + from . import other_usage + try: + machine = _machine_context() + usage = other_usage.read(machine) + return other_usage.notice(other_usage.net(usage, machine)) if failure else other_usage.for_request(machine, usage, **params) + except Exception: + return None + + def register(): from aiohttp import web import folder_paths @@ -281,6 +337,17 @@ def register(): cache = None if mac else CapabilityCache(folder_paths.get_output_directory(), server) server._freevideo_capabilities = cache if cache is not None else True + @server.routes.get('/freevideo/other-usage') + async def other_usage(request): + result = None + if not mac: + try: + params = _usage_query(request.query) + except (ValueError, TypeError, OverflowError, RecursionError) as error: + raise web.HTTPBadRequest(text=str(error)) from error + result = await asyncio.to_thread(_other_usage, params, request.query.get('failure') == '1') + return web.json_response(dict(status='ok', notice=result), headers={'Cache-Control': 'no-store'}) + @server.routes.get('/freevideo/capabilities') async def capabilities(request): if mac: diff --git a/freevideo_engine/comfy_launcher_runtime.py b/freevideo_engine/comfy_launcher_runtime.py index c78a85a9..367c3120 100644 --- a/freevideo_engine/comfy_launcher_runtime.py +++ b/freevideo_engine/comfy_launcher_runtime.py @@ -424,6 +424,13 @@ def bind_check(hostname, port): raise +def lan_error(reason, message): + """The launcher's message, with the lan_page reason code the phone panel shows.""" + error = ValueError(message) + error.lan_reason = reason + return error + + def address(hostname, port): return 'http://%s:%d' % ('[%s]' % hostname if ':' in hostname else hostname, port) @@ -1026,7 +1033,7 @@ def run(self, operation, *args): # A link check in flight finishes first, so it cannot overwrite this task's state. poll.join() self.cancelled.clear() - self.state = dict(self.state, status='running', action=operation, error=None, exception=None, task={}, overall={}, + self.state = dict(self.state, status='running', action=operation, error=None, exception=None, denied=None, task={}, overall={}, model_groups=[] if operation == 'inspect' else self.state.get('model_groups', [])) if operation in ('connect', 'launch', 'configure_access'): self.sections = [('open', 1)] @@ -1041,8 +1048,13 @@ def work(): # A remembered URL alone cannot prove the local page is usable. opened = bool(self.state.get('url')) and server_info(self.state['url'].split('/?')[0])['status'] == 'freevideo' status = 'open' if opened else 'failed' + from .denied_access import describe + # Inspection fails before a selection exists, so the requested folder stands in. + engine = (self.selection or {}).get('engine') or ( + args[0].get('engine') if args and isinstance(args[0], dict) else None) + roots = [('INSTALL', engine)] if engine else [] self.state = dict(self.state, status='cancelled' if self.cancelled.is_set() else status, - error=str(error), exception=exception_details(error)) + error=str(error), exception=exception_details(error), denied=describe(error, roots)) self.thread = threading.Thread(target=work, daemon=True) self.thread.start() @@ -1258,6 +1270,18 @@ def _connect(self): self._configure_access(dict(lan_access=(self.selection or {}).get('lan_access')), connect=True) def _start_access_proxy(self): + from . import lan_page + engine = self.selection['engine'] + try: + self._open_access_proxy() + except Exception as error: + # The page's phone panel shows why instead of waiting; a launcher closing is not a failure. + if not (self._closed or self.cancelled.is_set()): + lan_page.note_failure(engine, getattr(error, 'lan_reason', 'failed')) + raise + lan_page.forget_failure(engine) + + def _open_access_proxy(self): from .comfy_service import TOKEN, check_proxy, folder, start_proxy, token self.selection = selected = dict(self.selection, lan_access=False) self.state = dict(self.state, selection=dict(selected)) @@ -1265,7 +1289,7 @@ def _start_access_proxy(self): info = server_info(selected['url']) hosts = (info.get('listen') or '').split(',') if not hosts or not all(loopback_host(host) for host in hosts): - raise ValueError('The ComfyUI already running is open to the network directly. After its jobs finish, restart it on 127.0.0.1, then turn on LAN access.') + raise lan_error('comfy_on_network', 'The ComfyUI already running is open to the network directly. After its jobs finish, restart it on 127.0.0.1, then turn on LAN access.') configuration = tuple(selected[k] for k in ('root', 'engine', 'source', 'python', 'url')) if (self.access_configuration != configuration or self.access_proxy is not None and self.access_proxy.poll() is not None): @@ -1276,7 +1300,7 @@ def _start_access_proxy(self): port = (urlsplit(selected['url']).port or 80) + 1 candidate = next_free_address(address('0.0.0.0', port - 1)) if port <= 65535 else None if candidate is None: - raise ValueError('The ports for LAN access are in use or reserved by the system. Choose another ComfyUI port.') + raise lan_error('ports_in_use', 'The ports for LAN access are in use or reserved by the system. Choose another ComfyUI port.') port = urlsplit(candidate).port self.selection = selected = dict(selected, lan_port=port) self.state = dict(self.state, selection=dict(selected)) diff --git a/freevideo_engine/decode_resume.py b/freevideo_engine/decode_resume.py index 635497ff..1f2eeeb0 100644 --- a/freevideo_engine/decode_resume.py +++ b/freevideo_engine/decode_resume.py @@ -20,7 +20,7 @@ COMPUTE_PARTITION = frozenset(('head_chunk', 'window_batch', 'ff_chunk', 'projection_chunk', 'head_parallelism', 'attention_cpu_outputs', 'grouped_attention_outputs', 'fp8_ff_recompute', 'residual_offload', 'query_chunk')) SAMPLE_FIELDS = ('config', 'sample_seconds', 'step_seconds', 'finite_latents', 'geometry', - 'conditioning_info', 'conditioning_shape', 'offload', 'residual_offload_steps', + 'conditioning_info', 'conditioning_shape', 'offload', 'residual_offload_steps', 'locked_copy_refusals', 'attention_backend_calls', 'head_execution', 'text_refinement', 'sampling_memory', 'load_seconds', 'load_breakdown', 'load_peak_allocated_bytes', 'load_peak_reserved_bytes', 'torch_peak_allocated_bytes', 'torch_peak_reserved_bytes', 'latent_save_seconds', 'sample_metrics_scope', diff --git a/freevideo_engine/denied_access.py b/freevideo_engine/denied_access.py new file mode 100644 index 00000000..890e75b9 --- /dev/null +++ b/freevideo_engine/denied_access.py @@ -0,0 +1,158 @@ +"""Where an access-denied failure happened, worded without a user name or a private folder. + +Windows reports "[WinError 5] Access is denied" for many different operations, and when the call +names no file the message says nothing else. The launcher keeps this description next to the +message: the folder as the drive letter plus the part inside FreeVideo's own folders, and the last +FreeVideo functions that were running. +""" +import errno +import os +import re +import tempfile + +DENIED_ERRNO = (errno.EACCES, errno.EPERM) +# Folders that stay in the description when the denied path is inside one of them. +OWN_NAME = re.compile(r'^(?:freevideo|fv$)', re.I) +# The installation folder the launcher creates by default; it is kept even where no installation root is known. +ENGINE_NAME = re.compile(r'^freevideo-engine$', re.I) +# Folders named below FreeVideo's own folder: user-named files (models, inputs, outputs) sit deeper. +INSIDE = 2 +STEPS = 3 +SOCKET_DENIED = 10013 + + +def is_denied(error): + if getattr(error, 'winerror', None) == SOCKET_DENIED: + # A port that Windows reserved is not a refused file or folder. + return False + return isinstance(error, PermissionError) or (isinstance(error, OSError) and ( + getattr(error, 'winerror', None) == 5 or error.errno in DENIED_ERRNO)) + + +def _segments(path): + path = str(path) + if path.startswith('\\\\?\\') and not path.startswith('\\\\?\\UNC\\'): + path = path[4:] + drive = '' + match = re.match(r'([A-Za-z]):(?:[\\/]|$)', path) + if match: + drive, path = match.group(1).upper() + ':', path[match.end():] + return drive, [part for part in re.split(r'[\\/]+', path) if part] + + +def _under(parts, root): + return len(parts) >= len(root) and all(a.casefold() == b.casefold() for a, b in zip(parts, root)) + + +def _inside(parts): + """The first folders below one of FreeVideo's own, then an ellipsis where more follow.""" + return parts[:INSIDE] + (['…'] if len(parts) > INSIDE else []) + + +def default_roots(): + """The user's own folders, which are named by a placeholder and never spelled out.""" + roots = [] + for label, value in (('HOME', os.path.expanduser('~')), ('TEMP', tempfile.gettempdir()), + ('PROGRAMDATA', os.environ.get('ProgramData', ''))): + if value and value != '~': + roots.append((label, value)) + return roots + + +def location(path, roots=()): + """A path as "[D:] INSTALL\\launcher": the drive letter and a placeholder in place of every folder that + is not FreeVideo's own. The installation root is the engine folder itself, so no folder above it is named, + and nothing below it deeper than INSIDE folders. The text does not look like a path to the launcher's redaction.""" + path = str(path or '') + if not path: + return '' + if path.startswith(('\\\\', '//')) and (not path.startswith('\\\\?\\') or path.upper().startswith('\\\\?\\UNC\\')): + return '[network share]' + drive, parts = _segments(path) + best = None + for label, root in list(roots) + default_roots(): + root_drive, root_parts = _segments(root) + # A root with no folder (a whole drive) would make every path on it FreeVideo's own. + if root_parts and root_drive == drive and _under(parts, root_parts) and ( + best is None or len(root_parts) > len(best[1])): + best = (label, root_parts) + prefix = '[%s] ' % drive if drive else '' + if best is None: + # A name outside FreeVideo's folders may be a person, a project or a video, so none is kept, + # except the engine folder the launcher creates by default. + own = next((index for index, part in enumerate(parts) if ENGINE_NAME.match(part)), None) + if own is not None: + return prefix + '\\'.join(['…', parts[own]] + _inside(parts[own + 1:])) + return prefix + '…' if parts else prefix.strip() + label, root_parts = best + rest = parts[len(root_parts):] + if label in ('HOME', 'TEMP'): + # Only what FreeVideo created below the user's folder is named. + own = next((index for index, part in enumerate(rest) if OWN_NAME.match(part)), None) + rest = ['…'] + ([rest[own]] + _inside(rest[own + 1:]) if own is not None else []) + elif label == 'INSTALL': + rest = _inside(rest) + return prefix + '\\'.join([label] + rest) + + +def _chain(error): + seen = set() + while isinstance(error, BaseException) and id(error) not in seen: + seen.add(id(error)) + yield error + error = error.__cause__ or (None if error.__suppress_context__ else error.__context__) + + +def steps(error): + """The innermost FreeVideo functions of the traceback, newest first: "processes.popen:211".""" + rows, tb = [], error.__traceback__ + while tb is not None: + module = str(tb.tb_frame.f_globals.get('__name__') or '') + if module == 'freevideo_engine' or module.startswith('freevideo_engine.'): + rows.append('%s.%s:%d' % (module.partition('.')[2] or module, tb.tb_frame.f_code.co_name, tb.tb_lineno)) + tb = tb.tb_next + return rows[-STEPS:][::-1] + + +def describe(error, roots=()): + """The access-denied error in this exception chain as plain data, or None when there is none.""" + for item in _chain(error): + if is_denied(item): + winerror = getattr(item, 'winerror', None) + result = dict(code=winerror or item.errno, windows=bool(winerror), steps=steps(item)) + paths = [location(name, roots) for name in (item.filename, item.filename2) if name] + if paths: + result['path'] = ' → '.join(paths) + return result + return None + + +def text(info, zh=False): + """The line kept in the report: what was denied, where, and the FreeVideo steps that were running.""" + if not info: + return '' + label = (('Windows 错误 %s' if zh else 'Windows error %s') if info.get('windows') else ('错误 %s' if zh else 'error %s')) + code = ('(%s)' if zh else ' (%s)') % (label % info['code']) if info.get('code') else '' + if info.get('path'): + head = ('访问被拒绝%s:%s' if zh else 'Access denied%s: %s') % (code, info['path']) + else: + head = ('访问被拒绝%s,系统没有说明是哪个文件或文件夹' if zh else + 'Access denied%s; the system did not name a file or folder') % code + if info.get('steps'): + head += (';步骤:' if zh else '; steps: ') + ' < '.join(info['steps']) + return head + + +PLACEHOLDERS = (('INSTALL', '[安装目录]', '[installation folder]'), ('HOME', '[用户文件夹]', '[user folder]'), + ('TEMP', '[临时文件夹]', '[temp folder]'), ('[network share]', '[网络共享]', '[network share]')) + + +def card_text(info, zh=False): + """The line on the failure card: only where access was denied, with readable names for the placeholders.""" + value = (info or {}).get('path') + if not value: + return '' + for token, chinese, english in PLACEHOLDERS: + value = re.sub(r'(? list[tuple[Task, Device]]: if self._running: break elif not self._ledger.try_reserve(task.id, task.envelope): - continue + if self._running or binding is not None: + continue + # Alone on an idle host the loose whole-task envelope may exceed + # every byte the machine has. The task then runs exclusively, as on + # a single card, and its own planner admits or refuses it. A retry keeps waiting. + task = replace(task, envelope=None) self._running[task.id] = (task, device) self._bindings[task.id] = device.uuid del self._pending[task.id] diff --git a/freevideo_engine/head_chunk.py b/freevideo_engine/head_chunk.py index ccf61b89..246a06bd 100644 --- a/freevideo_engine/head_chunk.py +++ b/freevideo_engine/head_chunk.py @@ -59,7 +59,7 @@ def release_readouts(policy): a 16 GiB machine failed cudaHostAlloc at the refine pass's first layer. """ held = [getattr(policy, name, None) for name in ('group_readouts', 'host_outputs')] - for name in ('group_readouts', 'host_outputs', 'host_output_key'): + for name in ('group_readouts', 'host_outputs', 'host_output_key', 'host_copies'): if hasattr(policy, name): delattr(policy, name) if any(value is not None for value in held): @@ -135,12 +135,14 @@ def forward(attn, x, rotary): for rows in (len(x), linear_shape[0])) soft, linear = policy.group_readouts elif cpu_outputs: + from .activation_staging import LockedCopies key = (soft_shape, linear_shape, x.dtype) if getattr(policy, 'host_output_key', None) != key: release_readouts(policy) policy.host_outputs = (torch.empty(soft_shape, dtype=x.dtype, pin_memory=True), torch.empty(linear_shape, dtype=x.dtype, pin_memory=True)) policy.host_output_key = key + policy.host_copies = LockedCopies() soft, linear = policy.host_outputs elif reuses_input(attn, x, projections): # Measured second-pass peak: the head groups, with x, both output planes, @@ -201,8 +203,10 @@ def group(start): soft.store(start // chunk, attended.flatten(1)) elif taken: soft.write(channels, attended.flatten(1)) + elif cpu_outputs: + policy.host_copies.to_host(soft[:, channels], attended.flatten(1)) else: - soft[:, channels].copy_(attended.flatten(1), non_blocking=cpu_outputs) + soft[:, channels].copy_(attended.flatten(1)) del attended if not taken: beta = torch.sigmoid(F.linear(x[video], branch.beta_proj.weight[heads])) @@ -221,8 +225,10 @@ def group(start): linear_scale.update(readout) if grouped_outputs: linear.store(start // chunk, readout.contiguous()) + elif cpu_outputs: + policy.host_copies.to_host(linear[:, linear_channels], readout) else: - linear[:, linear_channels].copy_(readout, non_blocking=cpu_outputs) + linear[:, linear_channels].copy_(readout) del raw, readout, gate, beta, text_beta # Keep the exact projection/head/window shapes. Merely widening the @@ -293,13 +299,13 @@ def group(start): out = x.new_empty((len(x), output_width)) for row in range(0, len(x), projection_chunk): rows = slice(row, row + projection_chunk) - part = soft[rows].to(x.device, non_blocking=True) + part = policy.host_copies.to_device(soft[rows], x.device) scale = row_scale(soft_scale, rows) if soft_scale is not None else None out[rows] = orig.to_out[1](project_with_scale(orig.to_out[0], part, scale)) del part for row in range(0, len(linear), projection_chunk): rows = slice(row, row + projection_chunk) - part = linear[rows].to(x.device, non_blocking=True) + part = policy.host_copies.to_device(linear[rows], x.device) scale = row_scale(linear_scale, rows) if linear_scale is not None else None out[video][rows].add_(project_with_scale(attn.to_out_linear, part, scale)) del part diff --git a/freevideo_engine/image_gen/failure.py b/freevideo_engine/image_gen/failure.py index 08823270..35c23c2a 100644 --- a/freevideo_engine/image_gen/failure.py +++ b/freevideo_engine/image_gen/failure.py @@ -66,6 +66,15 @@ def host_memory_shortage(error): return commit_exhausted(error) +def append_other_usage(result, machine): + from ..other_usage import at_failure, fact, join + value = at_failure(result.get('error'), machine) + result['other_usage'] = value + if value and result.get('message_zh'): + result['message_zh'] = join(result['message_zh'], fact(value, zh=True), zh=True) + return result + + class FailureError(ValueError): """Admission rejection with the same safe status as the HTTP reply.""" def __init__(self, status): diff --git a/freevideo_engine/image_gen/image_pack.py b/freevideo_engine/image_gen/image_pack.py index 4b252f07..4fdd0c33 100644 --- a/freevideo_engine/image_gen/image_pack.py +++ b/freevideo_engine/image_gen/image_pack.py @@ -229,7 +229,8 @@ def _owned_intact(path, stamp): b'https://github.com/facebookresearch/sam2\n') MUSIC_NOTICE_TEXT = (b'ACE-Step 1.5, Copyright (c) 2026 ACEStep. Licensed under the MIT License; see ' b'freevideo_engine/licenses/ACE-Step-MIT.txt.\nhttps://github.com/ace-step/ACE-Step-1.5\n' - b'Qwen3-Embedding-0.6B, Copyright (c) Alibaba Cloud. Licensed under Apache-2.0.\n' + b'Qwen3-Embedding-0.6B, Copyright (c) Alibaba Cloud. Licensed under Apache-2.0; see ' + b'freevideo_engine/licenses/Qwen3-Embedding-Apache-2.0.txt.\nhttps://huggingface.co/Qwen/Qwen3-Embedding-0.6B\n' b'Silero VAD, Copyright (c) 2020-present Silero Team. Licensed under the MIT License; see ' b'freevideo_engine/licenses/Silero-VAD-MIT.txt.\nhttps://github.com/snakers4/silero-vad\n') NOTICES = {'image': ('NOTICE.txt', NOTICE_TEXT), 'segment': ('SAM2-NOTICE.txt', SEGMENT_NOTICE_TEXT), @@ -471,6 +472,7 @@ def verify(root=None, progress=None, check=None, *, group='image'): def _verify(folder, root, progress, check, full=False, *, group='image'): ledger = _read(folder, LEDGER) + snapshot = json.dumps(ledger, sort_keys=True) todo = [] total = sum(r['bytes'] for r in rows(group=group)) done = 0 @@ -494,7 +496,8 @@ def checked(current, size): model_repair.record(folder, path, row, before) todo.append(row) done += row['bytes'] - save(_path(folder, LEDGER), ledger) + if json.dumps(ledger, sort_keys=True) != snapshot or not _path(folder, LEDGER).is_file(): + save(_path(folder, LEDGER), ledger) return ledger, todo @@ -536,6 +539,8 @@ def event(value): details.update(verification='mismatch', method='sha256') if diagnostic: diagnostic(dict(phase='diagnostic', diagnostic=details)) + if value.get('action') == 'verifying': + diagnostic(dict(phase='verify')) # A finished file is being hashed: say so, not "downloading". if prior_event: prior_event(value) plan['event_callback'] = event diff --git a/freevideo_engine/image_gen/image_runner.py b/freevideo_engine/image_gen/image_runner.py index 8957c7d6..1f8649e8 100644 --- a/freevideo_engine/image_gen/image_runner.py +++ b/freevideo_engine/image_gen/image_runner.py @@ -17,6 +17,7 @@ from .image_stream import ResidentStack, StreamedStack GiB = 2**30 +COMMIT_FLOOR = 2 * GiB # commit that must remain after private copies of the weights _DIT_FILE = 'diffusion_models/qwen_image_2.1_turbo_int8_convrot.safetensors' _TE_FILE = 'text_encoders/qwen3vl_8b_int8_convrot.safetensors' _VAE_FILE = 'vae/qwen_image_2.1_vae_bf16.safetensors' @@ -141,9 +142,15 @@ def path(directory, name): # An Apple GPU shares the host's memory: its layers stay views of the mapped files (mapped.py). self.mapped = self.device.type == 'mps' self.full_card = not self.mapped and self._fits_whole_model() + # The weights are read from read-only mappings of the files, not copied into private memory, + # on a Mac and on a Windows machine whose commit cannot hold the copies. Such a machine pins + # nothing: the commit left after the weights is what the card's own allocations are charged to. + low_commit = not self.mapped and self._copies_exceed_commit() + mapped_files = {'mapped': True} if self.mapped or low_commit else {} + self.low_commit = low_commit self.transformer = load_dit(self.pack_dir / _DIT_FILE, device=self.device if self.full_card else 'cpu', progress=lambda done, total: loading.report('dit', done, total), - **({'mapped': True} if self.mapped else {})) + **mapped_files) self.load_seconds['dit'] = time.monotonic() - started loading.complete('dit') started = time.monotonic() @@ -156,7 +163,7 @@ def path(directory, name): processor_dir = self.pack_dir / 'processor' text = load_text_encoder(self.pack_dir / _TE_FILE, config_path=config_dir / 'config.json', device='cpu', progress=lambda done, total: loading.report('text', done, total), - **({'mapped': True} if self.mapped else {})) + **mapped_files) from transformers import Qwen3VLProcessor processor = Qwen3VLProcessor.from_pretrained(str(processor_dir), local_files_only=True) self.load_seconds['text'] = time.monotonic() - started @@ -206,10 +213,10 @@ def pin_progress(index): else: with torch.no_grad(): # LayerOffloader refuses to be built with autograd on self.dit_stack = StreamedStack(stacks[0], name='dit', device=self.device, - progress=pin_progress(0)) + pin=not low_commit, progress=pin_progress(0)) loading.report('pin', stack_bytes[0], pin_total) self.te_stack = StreamedStack(stacks[1], name='text_encoder', device=self.device, - progress=pin_progress(1)) + pin=not low_commit, progress=pin_progress(1)) self.load_seconds['pin'] = time.monotonic() - started loading.complete('pin') @@ -224,6 +231,25 @@ def pin_progress(index): self.last_plan = None loading.finish() + def _copies_exceed_commit(self): + """True on Windows when private copies of the DiT and encoder would leave too little commit. + + System commit is charged for every private byte: with 15.8 GB of RAM, 11.7 GB of commit free + and 16.5 GB of weights, the encoder's read failed with an access violation. A read-only + mapping costs no commit; the pages are re-read from the file when the system needs the + memory, and pinning (which checks live commit itself) still locks what fits. + """ + from ..system import windows + if not windows(): + return False + try: + from ..system import system_memory + commit = system_memory().get('commit_available_bytes') + copies = sum((self.pack_dir / name).stat().st_size for name in (_DIT_FILE, _TE_FILE, _VAE_FILE)) + except Exception: + return False + return commit is not None and commit < copies + COMMIT_FLOOR + def _lookup_on_host(self, embedding): device = self.device @@ -320,7 +346,8 @@ def at_boundary(self): def stats(self): return dict(dit=self.dit_stack.stats(), text_encoder=self.te_stack.stats(), - plan=self.last_plan.to_dict() if self.last_plan else None, load_seconds=self.load_seconds) + plan=self.last_plan.to_dict() if self.last_plan else None, load_seconds=self.load_seconds, + low_commit_load=self.low_commit) def close(self): for hook in self._embed_hooks: diff --git a/freevideo_engine/image_gen/qwen_te_weights.py b/freevideo_engine/image_gen/qwen_te_weights.py index 7765a9fd..7d4a4d1f 100644 --- a/freevideo_engine/image_gen/qwen_te_weights.py +++ b/freevideo_engine/image_gen/qwen_te_weights.py @@ -20,6 +20,16 @@ _CONVROT = {"format": "int8_tensorwise", "convrot": True, "convrot_groupsize": 256} +def _open(path): + """The encoder file, opened like the DiT and VAE (streamed_weights._open_safetensors). + + Windows charges a copy-on-write mapping of a whole file to system commit, 9.35 GB for this + one, however little of it is read; the pread backend reads only the requested tensors. + """ + from ..system import windows + return safe_open(str(path), framework="pt", device="cpu", **({"backend": "pread"} if windows() else {})) + + def _quant_metadata(st, name): value = st.get_tensor(name + ".comfy_quant") if value.dtype != torch.uint8 or value.ndim != 1: @@ -168,6 +178,7 @@ def load_text_encoder(path, *, config=None, config_path=None, device="cpu", dtyp ``load_vision(device='cpu')`` method installs only BF16 visual tensors and returns that module, allowing the caller's placement policy to own moves. CPU supports loading/inspection; Int8Linear execution requires CUDA. + ``mapped`` leaves tensors stored as they are on disk as views of a read-only mapping of the file. ``progress(done_bytes, total_bytes)`` counts language source bytes, including vocabulary chunks, and excludes the deferred vision tensors and logits head. """ @@ -194,7 +205,7 @@ def load_text_encoder(path, *, config=None, config_path=None, device="cpu", dtyp vision_type = type(model.model.visual) model.model.visual = None model.lm_head = torch.nn.Identity() - with safe_open(str(path), framework="pt", device="cpu") as st: + with _open(path) as st: reads = (TensorProgress(st, progress, (key for key in st.keys() if not key.startswith(("model.visual.", "lm_head.")))) if progress is not None else None) if mapped: # CPU only: tensors stored as they are on disk stay views of the mapped file @@ -212,7 +223,7 @@ def load_vision(device="cpu"): return owner.model.visual with init_empty_weights(): visual = vision_type(config.vision_config) - with safe_open(str(path), framework="pt", device="cpu") as st: + with _open(path) as st: visual_keys = [key for key in st.keys() if key.startswith("model.visual.")] for key in sorted(visual_keys): value = st.get_tensor(key) diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index 4c1b57bc..70142767 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -763,6 +763,9 @@ def failed(self, code, context, *, row=None, error=None, video_busy=None): if code == 'no_memory' and (isinstance(row, dict) and row.get('memory') == 'host' or error is not None and host_memory_shortage(error)): result.update(memory='host', message_zh=HOST_MESSAGE_ZH) + if code in ('no_memory', 'small_memory'): + from .failure import append_other_usage + append_other_usage(result, context.machine) if code not in NO_REPORT: if video_busy is None: try: @@ -1053,9 +1056,12 @@ def fitted(length): return True @staticmethod - def worker_gone(session, heard, requested_stop=False): - """The error for a worker pipe that ended; it carries the exit code for the report.""" - error = Service.worker_gone_error(session, heard) + def worker_gone(session, heard, requested_stop=False, loading=False): + """The error for a worker pipe that ended; it carries the exit code for the report. + + ``loading`` is true while the worker has reported nothing beyond loading its weights. + """ + error = Service.worker_gone_error(session, heard, loading=loading, requested_stop=requested_stop) code = getattr(error, 'exit_code', None) if code is not None: # The worker cannot report its memory once it is gone: read this process's view now. @@ -1069,7 +1075,7 @@ def worker_gone(session, heard, requested_stop=False): return error @staticmethod - def worker_gone_error(session, heard): + def worker_gone_error(session, heard, loading=False, requested_stop=False): try: code = session.process.wait(timeout=2) except subprocess.TimeoutExpired: @@ -1088,7 +1094,11 @@ def worker_gone_error(session, heard): message = 'worker_exit (exit code %d%s)' % (code, ', ' + kind if kind else '') # A worker that ended with an error before answering never ran the request; one # fresh worker gets it, and a second exit is reported with its own last words. - error = (WorkerEarlyExit if not heard and code > 0 else RuntimeError)(message) + # A native crash while the weights were still loading ran nothing of the request either. + from ..exit_diagnostics import exit_reading + crashed_loading = (loading and code != 0 and not requested_stop + and exit_reading(code).get('exit_class') == 'native_crash') + error = (WorkerEarlyExit if (not heard and code > 0) or crashed_loading else RuntimeError)(message) error.exit_code = code return error @@ -1100,6 +1110,7 @@ def infer(self, session, value): keep.note('image', habit) # How long the worker may wait for the next picture; it applies that only with the whole model on the card. value['keep_seconds'] = keep.keep_seconds(habit) + loading = True try: session.send(action='generate', **value) except OSError: @@ -1121,11 +1132,12 @@ def infer(self, session, value): except queue.Empty: continue if row is None: - raise self.worker_gone(session, heard, self.stop.is_set()) + raise self.worker_gone(session, heard, self.stop.is_set(), loading=loading) heard = True phase = row.get('phase') if phase == 'progress': stage, fraction = row.get('stage'), row.get('fraction') + loading = loading and stage in ('start', 'load') if (stage in STAGES and type(fraction) in (int, float) and math.isfinite(fraction) and 0 <= fraction <= 1): # The limit is for a worker that stops reporting, not for a slow first load. @@ -1278,7 +1290,7 @@ async def action(request): raise ValueError('invalid_request') result = await asyncio.to_thread(service.history_open) else: - result = service.start(name, value) + result = await asyncio.to_thread(service.start, name, value) return web.json_response(result) except FailureError as error: return web.json_response(error.status, status=400) diff --git a/freevideo_engine/lan_page.py b/freevideo_engine/lan_page.py index ecdae418..34e763b0 100644 --- a/freevideo_engine/lan_page.py +++ b/freevideo_engine/lan_page.py @@ -1,10 +1,46 @@ """Local page LAN settings, independent of the HTTP server.""" +import json +from pathlib import Path import socket import time from . import lan_auth, lan_password from .launcher_settings import Store, default_language +# Why the launcher could not open phone access, for the page's phone panel. +# The launcher writes it when opening fails; turning the switch from the page clears it. +FAILURE_FILE = 'lan-failure.json' +REASONS = ('ports_in_use', 'comfy_on_network', 'failed') +# An older note belongs to an earlier session; the panel then just shows the switch off. +FAILURE_SECONDS = 600 + + +def note_failure(engine, reason): + try: + path = Path(engine) / 'launcher' / FAILURE_FILE + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(dict(reason=reason if reason in REASONS else 'failed')), encoding='utf-8') + except OSError: + pass + + +def forget_failure(engine): + try: + (Path(engine) / 'launcher' / FAILURE_FILE).unlink() + except OSError: + pass + + +def failure(engine): + path = Path(engine) / 'launcher' / FAILURE_FILE + try: + if not 0 <= time.time() - path.stat().st_mtime <= FAILURE_SECONDS: + return '' + reason = json.loads(path.read_text(encoding='utf-8')).get('reason') + except (OSError, ValueError, AttributeError): + return '' + return reason if reason in REASONS else '' + class LanUnavailable(RuntimeError): """The launcher has not recorded an installation on this computer.""" @@ -35,9 +71,12 @@ def read(engine): from .comfy_launcher_runtime import lan_access_links links = lan_access_links(engine, port, password_set=password_set) link = links[0] if links else '' - return dict(available=installation is not None, enabled=enabled, starting=flag and not enabled, + # Listening without a network address, or the launcher's last attempt failed. + reason = ('no_address' if enabled and not link else '' if enabled or installation is None + else failure(engine)) + return dict(available=installation is not None, enabled=enabled, starting=flag and not enabled and not reason, link=link, password_set=password_set, devices=len(auth.devices(time.time())), - min_length=lan_password.MIN_LENGTH) + min_length=lan_password.MIN_LENGTH, reason=reason) def apply(engine, body): @@ -68,6 +107,8 @@ def apply(engine, body): else: lan_password.set_password(engine, body['password']) if 'enabled' in body: + # A new attempt (or turning it off) replaces the last failure. + forget_failure(engine) installation = dict(installation, lan_access=body['enabled']) # access.lan_enabled() checks the current top-level permission copies. saved['lan_access'] = body['enabled'] diff --git a/freevideo_engine/launcher/Main.qml b/freevideo_engine/launcher/Main.qml index 60d998e7..f1a5bdf4 100644 --- a/freevideo_engine/launcher/Main.qml +++ b/freevideo_engine/launcher/Main.qml @@ -465,12 +465,13 @@ ApplicationWindow { text: t("Other disks…", "其他硬盘…"); onClicked: diskDialogOpen = true } 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 { objectName: "changeSource"; visible: s.failure.kind === "download" || s.failure.kind === "model-source"; text: t("Change source", "切换下载源"); onClicked: { settingsTab = "downloads"; settingsOpen = true } } + FButton { objectName: "changeAddress"; visible: s.failure.kind === "port-taken"; text: t("Change address", "更改地址"); onClicked: { settingsTab = "advanced"; 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 details", "复制详情"); onClicked: backend.copy(s.error) } FButton { objectName: "showError"; text: errorDetailsOpen ? t("Hide details", "收起详情") : t("Show details", "查看详情"); flat: true; onClicked: errorDetailsOpen = !errorDetailsOpen } - FButton { objectName: "reportProblem"; visible: !diskFailure; text: t("Report a problem", "提交问题"); onClicked: backend.reportProblem(s.error, s.failure.title || "") } + FButton { objectName: "reportProblem"; visible: !diskFailure && s.failure.kind !== "port-taken"; text: t("Report a problem", "提交问题"); onClicked: backend.reportProblem(s.error, s.failure.title || "") } FButton { objectName: "exportError"; visible: !diskFailure; text: t("Export report", "导出报告"); enabled: s.report.status !== "running"; onClicked: backend.exportReport() } } ScrollView { @@ -481,6 +482,7 @@ ApplicationWindow { } FText { + objectName: "reportStatus" visible: s.report.status !== "idle"; Layout.fillWidth: true; font.pixelSize: theme.micro color: s.report.status === "error" ? theme.danger : theme.muted text: s.report.status === "running" ? t("Exporting full logs…", "正在导出完整日志…") : diff --git a/freevideo_engine/launcher_copy.py b/freevideo_engine/launcher_copy.py index 72674181..cc581f30 100644 --- a/freevideo_engine/launcher_copy.py +++ b/freevideo_engine/launcher_copy.py @@ -188,6 +188,7 @@ def source_name(value, zh=False): 'This address does not belong to this computer. Enter one of its IP addresses or 0.0.0.0.': '这个地址不属于本机。请填写本机的 IP 地址或 0.0.0.0。', 'This port and the next ones are in use or reserved by the system. Set a different local ComfyUI address.': '这个端口和后面的端口都已被占用或被系统保留,请换一个本机 ComfyUI 地址。', 'The ports for LAN access are in use or reserved by the system. Choose another ComfyUI port.': '局域网访问要用的端口已被占用或被系统保留,请换一个 ComfyUI 端口。', + 'This port is in use by another application. Set a different local ComfyUI address.': '这个端口已被其他程序占用,请换一个本机 ComfyUI 地址。', 'A generation is running. Change the address after it finishes.': '正在生成,完成后可修改地址。', 'The ComfyUI already running does not use this address. After its jobs finish, restart it with this address and connect again.': '已在运行的 ComfyUI 没有使用这个地址。请在任务完成后用这个地址重新启动 ComfyUI,再重新连接。', 'The ComfyUI already running is open to the network directly. After its jobs finish, restart it on 127.0.0.1, then turn on LAN access.': '已在运行的 ComfyUI 正在直接对局域网开放。请在任务完成后改用 127.0.0.1 重新启动它,再开启局域网访问。', diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index 865d8b34..38c0f2b7 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -9,6 +9,7 @@ import sys import threading import time +import uuid from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from . import __version__ @@ -45,6 +46,28 @@ 'in-use': ('Another program is using it, or this account does not have permission to delete it.', '正被其他程序使用,或当前账户没有删除权限。'), 'failed': ('It could not be deleted.', '未能删除。'), } +# Report exports that failed for a reason the user can act on; anything else keeps its own words. +EXPORT_FAILURES = { + 'exists': ('A file with this name already exists. Choose another name.', '已有同名文件,请换一个文件名。'), + 'permission': ('This folder cannot be written to. Choose another folder.', '该文件夹无法写入,请换一个位置保存。'), + 'in-use': ('The existing file is open in another program or is read-only, so it was not replaced. ' + 'Close that program, or choose another name.', + '原文件正被其他程序打开或为只读,未被替换。请关闭该程序,或换一个文件名。'), + 'disk': ('Not enough disk space. Free up space, then export again.', '磁盘空间不足,请释放空间后再导出。'), +} + + +def export_reason(error): + """The EXPORT_FAILURES key for an export error, or '' when its words are the only explanation.""" + import errno + code, winerror = getattr(error, 'errno', None), getattr(error, 'winerror', None) + if isinstance(error, FileExistsError): + return 'exists' + if code in (errno.ENOSPC, getattr(errno, 'EDQUOT', None)) or winerror in (39, 112): + return 'disk' + if isinstance(error, PermissionError) or code in (errno.EACCES, errno.EPERM, errno.EROFS): + return 'permission' + return '' @@ -812,6 +835,8 @@ def image_pack_view(self): labels = {'install': ('Downloading', '正在下载'), 'repair': ('Repairing', '正在修复'), 'remove': ('Removing', '正在删除'), 'verify': ('Checking', '正在检查')} detail = self.t(*labels[active['action']]) + if active.get('action') == 'install' and active.get('phase') == 'verify': + detail = self.t('Checking', '正在校验') elif active.get('state') == 'paused_for_generation': detail = self.t('Paused while generating', '生成时暂停') elif active.get('state') == 'cancelled': @@ -1140,7 +1165,15 @@ def _raw_error(self): shortcut = row.get('shortcut') or {} if shortcut.get('status') == 'failed': errors.append(shortcut.get('error_zh' if self.language.startswith('zh') else 'error') or shortcut.get('error', '')) - return plain_text('\n'.join(str(e) for e in errors if e)) + message = plain_text('\n'.join(str(e) for e in errors if e)) + if row.get('error') and row.get('denied'): + from .denied_access import text + from .failure_details import launcher_failure + # Windows words many refusals alike and often names no file; where it happened travels with the message. + # Only a permission failure takes the line: the other cards read the first lines of the message. + if launcher_failure(message)['kind'] == 'permission': + message = plain_text(text(row['denied'], self.language.startswith('zh')) + '\n' + message) + return message def diagnostic_error(self): """Full retained causes, including alias failures shown in storage settings.""" @@ -1159,7 +1192,8 @@ def diagnostic_error(self): raw += ('\n\n' if raw else '') + 'Image model diagnostic report:\n' + json.dumps(pack_report, ensure_ascii=False, indent=2) return redacted_launcher_error(raw, [(self.token, '')] if self.token else []) - def export_report(self, output): + def export_report(self, output, overwrite=False): + """Write the report ZIP; overwrite replaces a file the save dialog already asked about.""" if self.report['status'] == 'running': return from .diagnostics import collect, Redactor @@ -1172,14 +1206,30 @@ def export_report(self, output): self.report = dict(status='running', path=str(output), error='') def work(): + target = Path(output) + # Collection never writes over a file; a confirmed replacement is built beside it and swapped in whole. + partial = target.with_name('.%s.%s.partial' % (target.name, uuid.uuid4().hex[:8])) if overwrite else target + replacing = False try: - result = collect(root, root / 'machine.json', None, Path(output), + result = collect(root, root / 'machine.json', None, partial, complete=True, extra_files=logs, notes=notes) + if partial != target: + replacing = True + os.replace(io_path(partial), io_path(target)) self.report = dict(status='complete', path=str(output), error='', collection_errors=len(result['errors'])) except Exception as error: - self.report = dict(status='error', path=str(output), + # The new file was already written beside the old one, so the folder is writable: + # Windows refuses the swap when the old file is open elsewhere or read-only. + reason = 'in-use' if replacing and isinstance(error, PermissionError) else export_reason(error) + self.report = dict(status='error', path=str(output), reason=reason, error=Redactor(replacements).text(str(error))) + finally: + if partial != target: + try: + io_path(partial).unlink() + except OSError: + pass threading.Thread(target=work, name='freevideo-export-report', daemon=True).start() @@ -2297,6 +2347,12 @@ def snapshot(self): if line.startswith(MISMATCH_PATH + ': ') and line[len(MISMATCH_PATH) + 2:]: failure['path'] = line[len(MISMATCH_PATH) + 2:] break + if failure.get('kind') == 'permission' and row.get('error') and row.get('denied'): + from .denied_access import card_text + # The card shows the detail instead of the action, so the action goes under the location. + location = card_text(row['denied'], zh) + if location: + failure = dict(failure, detail=location + '\n' + failure['action'], action='') reasons = self.importer.state.get('write_reasons') or [] if (self.importer.state.get('status') == 'error' and reasons and self.error == self.importer.state.get('error') and failure.get('kind') != 'runtime-alias'): @@ -2355,7 +2411,8 @@ def snapshot(self): field_error=dict(self.field_error or {}), retry_kind=self.retry_kind(), notice=self.notice, # Qt reads a list as an array; LEVELS is a tuple. compatibility=dict(self.compatibility, levels=list(self.compatibility.get('levels') or ())), - report=dict(self.report), + report=dict(self.report, error=self.t(*EXPORT_FAILURES[self.report['reason']]) + if self.report.get('reason') in EXPORT_FAILURES else self.report.get('error', '')), models=models, overall=overall, progress=progress, detail=detail, progress_text=file_text, remaining_seconds=remaining, # The elapsed row is only shown while working. An idle clock would diff --git a/freevideo_engine/licenses/Qwen3-Embedding-Apache-2.0.txt b/freevideo_engine/licenses/Qwen3-Embedding-Apache-2.0.txt new file mode 100644 index 00000000..1add3429 --- /dev/null +++ b/freevideo_engine/licenses/Qwen3-Embedding-Apache-2.0.txt @@ -0,0 +1,204 @@ +Qwen3-Embedding-0.6B (https://huggingface.co/Qwen/Qwen3-Embedding-0.6B) +Copyright (c) Alibaba Cloud. Licensed under the Apache License, Version 2.0. + + 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. diff --git a/freevideo_engine/modern_launcher.py b/freevideo_engine/modern_launcher.py index 5aef4a38..a730092c 100644 --- a/freevideo_engine/modern_launcher.py +++ b/freevideo_engine/modern_launcher.py @@ -286,9 +286,11 @@ def exportReport(self): 'freevideo-diagnostics-' + time.strftime('%Y%m%dT%H%M%SZ', time.gmtime()) + '.zip', 'ZIP (*.zip)') if path: - if not path.lower().endswith('.zip'): + # The dialog asked before replacing the name it returned; a name we extend was never asked about. + confirmed = path.lower().endswith('.zip') + if not confirmed: path += '.zip' - self.invoke(session.export_report, path) + self.invoke(session.export_report, path, confirmed) @Slot(str) def link(self, value): diff --git a/freevideo_engine/other_usage.py b/freevideo_engine/other_usage.py new file mode 100644 index 00000000..ca39caee --- /dev/null +++ b/freevideo_engine/other_usage.py @@ -0,0 +1,238 @@ +"""Best-effort, anonymous occupancy and its effect on this request's placement. + +No process names, model loads or CUDA initialization. Call readings off the +event loop; an incomplete reading never becomes a claim about other programs. +""" +import os +import platform +import sys + +from .background_room import Gpu, available_memory, family +from .capabilities import IDLE_IN_USE, _admit, _machine +from .hardware import GiB +from .system import system_memory + +THRESHOLD = GiB +CONTEXT_BYTES = GiB // 2 +# What the system and desktop use with no other program running. Readings are +# whole-machine use minus this program's own, so they include it; only the part +# above it counts as other programs. Host memory gets a generous allowance so +# the notice stays quiet unless something else is clearly running. +SYSTEM_RAM = {'Windows': 6 * GiB, 'Linux': 2 * GiB} +MEMORY_ERRORS = frozenset(('no_memory', 'small_memory', 'insufficient_memory', + 'out_of_memory', 'ram_space')) + + +def _mac(machine): + return (platform.system() == 'Darwin' or machine.get('device_backend') == 'mps' + or (machine.get('device_identity') or {}).get('backend') == 'mps' + or (machine.get('hardware') or {}).get('system') == 'Darwin') + + +def _running(): + """Unknown activity is unsafe for the WDDM allocator approximation.""" + from .resident_process import OWNER + from .background_compile import COMPILE + if OWNER.active or COMPILE.process is not None and COMPILE.process.poll() is None: + return True + module = sys.modules.get('server') + server = module.PromptServer.instance if module is not None else None + if server is not None: + service = getattr(server, '_freevideo_image_gen', None) + if service is not None and service.busy(): + return True + queue = getattr(server, 'prompt_queue', None) + if queue is not None and queue.get_current_queue()[0]: + return True + return False + + +def _allocator_credit(pids): + """WDDM has no per-process bytes. Only trust idle, already loaded allocators. + + A child with a persistent pool cannot be accounted for by the parent's + torch allocator. Suppress the approximation while such a worker lives. + """ + if _running(): + return None + from .resident_process import OWNER + module = sys.modules.get('server') + server = module.PromptServer.instance if module is not None else None + service = getattr(server, '_freevideo_image_gen', None) + for process in (OWNER.process, getattr(service, 'process', None)): + if process is not None and process.poll() is None: + return None + torch = sys.modules.get('torch') + if torch is None or not torch.cuda.is_initialized(): + return None + return int(torch.cuda.memory_reserved()) + CONTEXT_BYTES + + +def _rss(pids): + import psutil + total = 0 + for pid in pids: + try: + total += psutil.Process(pid).memory_info().rss + except psutil.NoSuchProcess: + continue # A child that exited holds no memory now. + return total + + +def read(machine=None): + """{'vram': bytes, 'ram': bytes}, or None if either cannot be established.""" + gpu = None + try: + if platform.system() == 'Darwin': + return None + if machine is None: + from .comfy_bridge import installation + _, machine = installation() + if (_mac(machine) or machine.get('device_backend', 'cuda') != 'cuda' + or machine.get('gpu_uuid') is None): + return None + pids = family(os.getpid()) + gpu = Gpu(machine.get('gpu_uuid')) + memory = gpu.memory() + if memory is None: + return None + used, total = memory + if not total or not 0 <= used <= total: + return None + rows = gpu.processes() + if rows is None or any(size is None for size in rows.values()): + if platform.system() != 'Windows': + return None + own = _allocator_credit(pids) + if own is None: + return None + else: + own = sum(size for pid, size in rows.items() if pid in pids) + host_total, available = system_memory()['total_bytes'], available_memory() + if host_total <= 0 or not 0 <= available <= host_total: + return None + return dict(vram=max(0, used - own), ram=max(0, host_total - available - _rss(pids))) + except Exception: + return None + finally: + if gpu is not None: + try: + gpu.close() + except Exception: + pass + + +def net(usage, machine): + """The reading above what the system and desktop use by themselves.""" + if not isinstance(usage, dict) or not isinstance(machine, dict): + return None + hardware = machine.get('hardware') or {} + system = hardware.get('system') or machine.get('system') + if system not in SYSTEM_RAM or any(type(usage.get(k)) is not int or usage[k] < 0 for k in ('vram', 'ram')): + return None + return dict(vram=max(0, usage['vram'] - IDLE_IN_USE[system]['vram']), + ram=max(0, usage['ram'] - SYSTEM_RAM[system])) + + +def notice(usage): + """Only anonymous numbers cross the UI boundary; sub-GiB noise stays quiet.""" + if not isinstance(usage, dict) or any(type(usage.get(k)) is not int or usage[k] < 0 + for k in ('vram', 'ram')): + return None + kinds = [kind for key, kind in (('vram', 'gpu'), ('ram', 'ram')) if usage[key] >= THRESHOLD] + if not kinds: + return None + return dict(vram_gib=usage['vram'] / GiB, ram_gib=usage['ram'] / GiB, + kinds=kinds, signature='%d:%d' % (usage['vram'] // GiB, usage['ram'] // GiB)) + + +def _worse(current, idle): + request, code, _ = current + baseline, base_code, _ = idle + if baseline is None: + return False # Closing other programs cannot admit this request either. + if request is None: + return False # A refused request is reported by the failure card, not as slower. + return (code > base_code + or any(request[key] < baseline[key] for key in ( + 'pass1_resident_blocks', 'pass2_resident_blocks', 'decoder_resident_blocks')) + or request['decoder_offload'] != baseline['decoder_offload']) + + +def placements(machine, usage, *, kind='video', width=1344, height=768, frames=243, + steps=8, two_pass=True, task='t2va', refine=3, references=None, + step_cache=None, upscale=2, reference_pixels=0): + """Identical request and reserves, with and without the measured other usage.""" + idle = _machine(machine) + usage = net(usage, machine) + if idle is None or idle['mac'] or _mac(machine) or notice(usage) is None: + return None + current = dict(idle, vram_free=max(0, idle['vram_free'] - usage['vram']), + ram_available=max(0, idle['ram_available'] - usage['ram'])) + if kind == 'image': + from .image_gen.image_policy import plan_image + def plan(value): + return plan_image(gpu_budget_bytes=value['vram_free'], host_available_bytes=value['ram_available'], + width=width, height=height, reference_pixels=reference_pixels).to_dict() + base, occupied = plan(idle), plan(current) + worse = base['feasible'] and occupied['feasible'] and (any( + occupied[key] < base[key] for key in ('dit_resident_layers', 'te_resident_layers', + 'dit_kept_layers', 'vae_tile_size')) + or occupied['vae_tiling'] != base['vae_tiling'] + or occupied['host_mode'] != base['host_mode']) + else: + params = dict(width=width, height=height, frames=frames, steps=steps, two_pass=two_pass, + task=task, refine=refine, references=references, step_cache=step_cache, upscale=upscale) + base, occupied = _admit(idle, **params), _admit(current, **params) + worse = _worse(occupied, base) + return dict(idle=base, occupied=occupied, worse=bool(worse)) + + +def for_request(machine, usage, **request): + try: + comparison = placements(machine, usage, **request) + return notice(net(usage, machine)) if comparison and comparison['worse'] else None + except Exception: + return None + + +def at_failure(code, machine=None): + """Fresh reading at failure, independent of session deduplication/admission.""" + if code not in MEMORY_ERRORS: + return None + try: + if machine is None: + from .comfy_bridge import installation + _, machine = installation() + return notice(net(read(machine), machine)) + except Exception: + return None + + +FACTS = { + 'gpu': ('Other programs are using {vram_gib} GB of GPU memory.', '其他程序占用了 {vram_gib} GB 显存。'), + 'ram': ('Other programs are using {ram_gib} GB of RAM.', '其他程序占用了 {ram_gib} GB 内存。'), + 'both': ('Other programs are using {vram_gib} GB of GPU memory and {ram_gib} GB of RAM.', + '其他程序占用了 {vram_gib} GB 显存和 {ram_gib} GB 内存。'), +} + + +def join(text, suffix, zh=False): + """Append the fact as its own sentence: no space in Chinese, one in English.""" + text = (text or '').rstrip() + if not text: + return suffix + if text[-1] not in ('。!?' if zh else '.!?'): + text += '。' if zh else '.' + return text + suffix if zh else text + ' ' + suffix + + +def fact(value, zh=False): + if not isinstance(value, dict): + return '' + kinds = value.get('kinds') + key = 'both' if kinds == ['gpu', 'ram'] else 'gpu' if kinds == ['gpu'] else 'ram' if kinds == ['ram'] else None + try: + return FACTS[key][int(zh)].format(**{k: format(value[k], '.1f') for k in ('vram_gib', 'ram_gib')}) if key else '' + except (KeyError, TypeError, ValueError): + return '' diff --git a/freevideo_engine/pass_configuration.py b/freevideo_engine/pass_configuration.py index 1785e6bc..191bf539 100644 --- a/freevideo_engine/pass_configuration.py +++ b/freevideo_engine/pass_configuration.py @@ -1,4 +1,5 @@ """Change pass-local compute tiling while preserving loaded model weights.""" +import sys from contextlib import contextmanager @@ -62,6 +63,20 @@ def configuration(engine, selected): attention.current_plan = attention.offsets = attention.batches = None # Canvas-specific host buffers must not survive alongside the next # pass's differently shaped buffers. Immutable weights remain untouched. - for name in ('host_outputs', 'host_output_key', 'group_readouts'): - if hasattr(attention, name): - delattr(attention, name) + # Dropping them here without waiting for the device returned locked + # blocks that the pass's last copies could still be using, and the + # next pass's release then found nothing left to wait for. + try: + from .head_chunk import release_readouts + except ModuleNotFoundError: + # No torch, so no page-locked readouts can exist to return. + def release_readouts(policy): + for name in ('group_readouts', 'host_outputs', 'host_output_key', 'host_copies'): + if hasattr(policy, name): + delattr(policy, name) + pending = sys.exc_info()[0] + try: + release_readouts(attention) + except RuntimeError: + if pending is None: + raise diff --git a/freevideo_engine/pool_resources.py b/freevideo_engine/pool_resources.py index 1b213898..c90eff5c 100644 --- a/freevideo_engine/pool_resources.py +++ b/freevideo_engine/pool_resources.py @@ -96,6 +96,10 @@ def _checkpoint_storage(path, expected): return expected, expanded +# Longest request one prompt can carry: a 30 s video, the chain planner's first split. +MAX_FRAMES = 721 + 22 + + class PromptEnvelope: """Estimate literal, text-only FreeVideo requests up to the policy reference. @@ -122,34 +126,38 @@ def _estimate(self, prompt): if len(generators) != 1: return None node = generators[0] - empty_media = set() + media_nodes, assets = set(), [] for identity, other in prompt.items(): if other is node: continue media = other.get('inputs', {}) if (other.get('class_type') != 'FreeVideoMedia' or set(media) - {'assets'} - or not isinstance(media.get('assets', '[]'), str) - or json.loads(media.get('assets', '[]')) != []): + or not isinstance(media.get('assets', '[]'), str)): return None - empty_media.add(identity) + rows = json.loads(media.get('assets', '[]')) + if (not isinstance(rows, list) or len(rows) > 32 + or any(not isinstance(row, dict) or not isinstance(row.get('file'), str) for row in rows)): + return None + assets += [row for row in rows if row.get('enabled', True) is True] + media_nodes.add(identity) inputs = dict(node['inputs']) link = inputs.get('media') - if isinstance(link, list) and len(link) == 2 and link[0] in empty_media and link[1] == 0: + if isinstance(link, list) and len(link) == 2 and link[0] in media_nodes and link[1] == 0: del inputs['media'] + elif link is not None or media_nodes: + return None allowed = {'text', 'width', 'height', 'seconds', 'seed', 'two_pass', 'base_steps', 'refine_steps', 'force_regenerate', 'preview', 'frames', 'step_cache', 'upscale_factor', 'refine_from', 'continue_from', 'first_frame_reference', - 'voice_reference', 'hide_tail'} + 'voice_reference', 'hide_tail', 'allow_empty_prompt'} if (set(inputs) - allowed or any(isinstance(v, (list, dict)) for v in inputs.values()) - or inputs.get('refine_from') or inputs.get('continue_from') - or inputs.get('hide_tail') or inputs.get('first_frame_reference') - or inputs.get('voice_reference') or inputs.get('step_cache', 'none') != 'none'): + or inputs.get('refine_from') or inputs.get('step_cache', 'none') != 'none'): return None from .geometry import geometry canvas = geometry(inputs.get('width', 1344), inputs.get('height', 768), frames=inputs['frames'] if inputs.get('frames') else None, seconds=None if inputs.get('frames') else inputs.get('seconds', 10.)) - if canvas['width'] > 1344 or canvas['height'] > 768 or canvas['frames'] > 243: + if (canvas['width'] * canvas['height'] > 1920 * 1088 or canvas['frames'] > MAX_FRAMES): return None machine = json.loads((self.root / 'machine.json').read_text(encoding='utf-8')) manifest = json.loads(Path(__file__).with_name('model_files.json').read_text(encoding='utf-8')) @@ -207,6 +215,14 @@ def _estimate(self, prompt): workspace = (HOST_WEIGHT_HEADROOM + ENCODER_HOST_BYTES + residual + decoder_workspace(canvas) + 3 * output + UPSCALER_HOST_BYTES + adaln_extra_bytes(canvas)) + # Every picture, clip or sound keeps a full set of decoded frames and may add + # a reference residual of its own. A continued part also decodes its overlap. + from .comfy_assets import media_kind + for row in assets: + kind = media_kind(row['file']) + workspace += output + (0 if kind == 'audio' else residual) + (2 * output if kind == 'video' else 0) + if inputs.get('continue_from'): + workspace += output # Charge every private/workspace byte as potentially pinned, including # attention readouts and decoder/encoder transfer buffers. pinned = expanded + prepared + workspace diff --git a/freevideo_engine/prompt_vlm/rules.py b/freevideo_engine/prompt_vlm/rules.py index 2c6384c4..bb1e8eaf 100644 --- a/freevideo_engine/prompt_vlm/rules.py +++ b/freevideo_engine/prompt_vlm/rules.py @@ -170,7 +170,7 @@ def validate_output(text, value): section = text.split(marker + ':', 1)[1].split('\n\n', 1)[0].strip() if not section: raise InvalidOutput('empty_section', text, value) - for minutes, seconds in re.findall(r'\bAt (\d{2}):(\d{2}\.\d{3})', text): + for minutes, seconds in TIME_POINT.findall(text): if int(minutes) * 60 + float(seconds) >= value['seconds']: raise InvalidOutput('timestamp_not_before_duration', text, value) # Only the labels the scene itself uses: the media given, plus audio and connected media it cannot see. @@ -352,6 +352,30 @@ def missing_lines(text, scene): DIALOGUE_RETRY = ('Your rewrite left out these spoken lines. Rewrite the whole prompt again in the same format and ' 'include each line once, verbatim, at the moment it is spoken, as (S1) [Language] ...:\n') +TIME_POINT = re.compile(r'\bAt (\d{2}):(\d{2}\.\d{3})') +# A rewritten prompt that keeps time points from an earlier, longer version ("At 00:07.000" in a 5 s video) +# is rejected; one correction turn asks for the same story fitted inside the requested length. +TIMESTAMP_RETRY = ('Your rewrite has time points at or after the end of the %g-second video. Rewrite the whole prompt ' + 'again in the same format with the same story fitted inside %g seconds: every "At MM:SS.mmm" time ' + 'point must be earlier than %g seconds, so move later actions earlier or merge them. Keep every ' + 'other detail and the required section format.\n') + + +def fit_time_points(text, seconds): + """The same text with every "At MM:SS.mmm" scaled so the latest one falls at 90% of the video, order kept. + + Used only for a rewrite that was rejected for time points at or after the end of the video and still has + them after the correction turn; the story and every other word are untouched. + """ + points = TIME_POINT.findall(text) + latest = max((int(minutes) * 60 + float(second) for minutes, second in points), default=0.) + if latest < seconds: + return text + def scaled(match): + milliseconds = round((int(match.group(1)) * 60 + float(match.group(2))) * .9 * seconds / latest * 1000) + return 'At %02d:%06.3f' % (milliseconds // 60000, milliseconds % 60000 / 1000) + return TIME_POINT.sub(scaled, text) + def user_text(value): """The user's scene, plus the spoken lines the rewrite must keep verbatim.""" diff --git a/freevideo_engine/prompt_vlm/service.py b/freevideo_engine/prompt_vlm/service.py index 423248fa..430b1b45 100644 --- a/freevideo_engine/prompt_vlm/service.py +++ b/freevideo_engine/prompt_vlm/service.py @@ -617,7 +617,7 @@ def read_rows(): except rules.InvalidOutput as error: stats = result.get('diagnostics') or {} error.details['retried'] = bool(stats.get('media_retry') or stats.get('picture_retry') - or stats.get('dialogue_retry')) + or stats.get('dialogue_retry') or stats.get('timestamp_retry')) self.progress(dict(diagnostics=stats)) raise self.progress(dict(result, worker_returncode=child.returncode)) diff --git a/freevideo_engine/prompt_vlm/worker.py b/freevideo_engine/prompt_vlm/worker.py index fe358a04..508621fd 100644 --- a/freevideo_engine/prompt_vlm/worker.py +++ b/freevideo_engine/prompt_vlm/worker.py @@ -387,7 +387,7 @@ def rewrite(folder, value, stats): persistent_model=False) torch, checkpoint = begin(stats) from transformers import AutoProcessor - from .rules import DIALOGUE_RETRY, InvalidOutput, instruction, label, language, missing_lines, user_text + from .rules import DIALOGUE_RETRY, TIMESTAMP_RETRY, InvalidOutput, fit_time_points, instruction, label, language, missing_lines, user_text checkpoint('preprocess') preparing = time.monotonic() processor = AutoProcessor.from_pretrained(folder, local_files_only=True, trust_remote_code=False) @@ -423,14 +423,18 @@ def on_token(timing): text = processor.decode(ids, skip_special_tokens=True) missing = missing_lines(text, value['text']) rewritten, rejected = None, None - references = [] + references, late, last = [], False, text try: rewritten = validate_rewrite(text, value, stats) except InvalidOutput as error: rejected = error if error.details['condition'] == 'missing_picture_reference': references = error.details['missing_references'] - if missing or references: + late = error.details['condition'] == 'timestamp_not_before_duration' + if late: + # Time points are checked first; ask for any media reference the draft also left out. + references = error.details['missing_references'] + if missing or references or late: # Both corrections share one turn and the existing context/cache allowance. correction = '' if references: @@ -438,6 +442,8 @@ def on_token(timing): correction = (f'Your rewrite omitted these required media references: {labels}. ' 'Preserve all existing content and add each missing reference where it belongs. ' 'Return the complete prompt in the same required section format.\n') + if late: + correction += TIMESTAMP_RETRY % ((value['seconds'],) * 3) if missing: correction += DIALOGUE_RETRY + '\n'.join('- [%s] %s' % (language(line), line) for line in missing) retry = messages + [{'role': 'assistant', 'content': text}, {'role': 'user', 'content': correction}] @@ -445,31 +451,49 @@ def on_token(timing): if retry_allowed(retry_inputs.input_ids.shape[-1], stats): again, _ = generate(torch, model, retry_inputs.to(device), 1800, time.monotonic(), lambda timing: None) second = processor.decode(again, skip_special_tokens=True) + # A cut-off correction is never used, not even to scale: fall back to the first draft. + last = second if len(again) < 1800 else text stats.update(retry_output_tokens=len(again), rewrite_seconds=time.monotonic() - loaded) if missing: stats['dialogue_retry'] = True + if late: + stats['timestamp_retry'] = True if references: stats['media_retry'] = True if any(each.startswith('= 1800: + if (references or late) and len(again) >= 1800: error = InvalidOutput('output_token_limit', second, value) error.details['retried'] = True stats['output_rejections'].append(error.details) raise error - if len(again) < 1800 and (references or len(missing_lines(second, value['text'])) < len(missing)): + if len(again) < 1800 and (references or late or len(missing_lines(second, value['text'])) < len(missing)): rewritten = candidate except InvalidOutput: - if references: + if references and not late: raise - elif references: - stats['media_retry_skipped'] = stats.get('dialogue_retry_skipped', 'context_length') - if any(each.startswith('