diff --git a/freevideo_engine/agent/cast.py b/freevideo_engine/agent/cast.py index a3cce5e7..948f2f3d 100644 --- a/freevideo_engine/agent/cast.py +++ b/freevideo_engine/agent/cast.py @@ -118,7 +118,8 @@ def view(card): def thumbnail(card): images = card['images'] - chosen = next((row for row in images if row['label'] == 'front'), images[0] if images else None) + # The close-up when the card has one, else the front picture; shown small, a full-length picture is only a coat. + chosen = next((row for label in ('face', 'front') for row in images if row['label'] == label), images[0] if images else None) return media_view(chosen['media']) if chosen else None diff --git a/freevideo_engine/agent/errors.py b/freevideo_engine/agent/errors.py index 59974fad..982299d8 100644 --- a/freevideo_engine/agent/errors.py +++ b/freevideo_engine/agent/errors.py @@ -408,7 +408,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): + report_id=None, memory=None): code = code if code in ERRORS else 'internal_error' _, retryable, default_message, _ = ERRORS[code] action = recovery(code, kind=kind, stage=stage, resumable=resumable) @@ -422,6 +422,8 @@ def build(code, *, details=None, request=None, validation=False, message=None, n docs='/freevideo/agent/v1/guide#errors') if kind == 'image': result.update(image_failure(code, report_id)) + if code == 'no_memory' and memory == 'host': + result['memory'] = 'host' if action_required: result['next'] = next_action if validation and next_action else action if code == 'busy': @@ -441,9 +443,13 @@ def validation(errors, code='invalid_request'): def text(error): + """One line for the Agent; a plain-text error (a failed white-model scene) is its own message.""" + if not isinstance(error, dict): + return str(error).strip() or 'failed: The request did not finish.' reason = error.get('reason') or {} - message = reason.get('en') or error['message'] - diagnostic = image_failure(error['code'], error.get('report_id'))['report_id'] if reason else None - return '%s: %s%s%s' % (error['code'], message, + code = error.get('code') or 'failed' + message = reason.get('en') or error.get('message') or '' + diagnostic = image_failure(code, error.get('report_id'))['report_id'] if reason else None + return '%s: %s%s%s' % (code, message, ' Diagnostic report: /freevideo/image-gen/report/' + diagnostic if diagnostic else '', ' Next: ' + error['next'] if error.get('next') else '') diff --git a/freevideo_engine/agent/jobs.py b/freevideo_engine/agent/jobs.py index b1930b27..9f645f0d 100644 --- a/freevideo_engine/agent/jobs.py +++ b/freevideo_engine/agent/jobs.py @@ -687,7 +687,7 @@ def finish(self, row, status, *, result=None, error=None, save=True): 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')) + report_id=error.get('report_id'), memory=error.get('memory')) if row['kind'] == 'image': reason_zh, reason_en = error['error_zh'], error['error_en'] if error is not None: @@ -1979,7 +1979,7 @@ 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'))) + self.finish(row, 'failed', error=dict(code=code, report_id=state.get('report_id'), memory=state.get('memory'))) # Local VLM ----------------------------------------------------------------------------------------- def start_vlm(self, row): diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py index 21cd016c..a9660bb7 100644 --- a/freevideo_engine/agent/schema.py +++ b/freevideo_engine/agent/schema.py @@ -741,7 +741,7 @@ def image_document(): def whitebox_schema(name): if name == 'submit': - return obj(dict(code=string(65536, description='JavaScript that defines build(THREE) and returns {scene, camera, update(t)}.'), + return obj(dict(code=string(65536, description='An ES module with export function build(THREE) that returns {scene, camera, update(t)}.'), summary=string(500, description='One sentence on what the scene shows, for the confirm card.')), ('code',)) return obj({'wait': WAIT}) diff --git a/freevideo_engine/backends/mps_vae_encode.py b/freevideo_engine/backends/mps_vae_encode.py index b4e55125..fc747ce1 100644 --- a/freevideo_engine/backends/mps_vae_encode.py +++ b/freevideo_engine/backends/mps_vae_encode.py @@ -1,15 +1,224 @@ -"""Bound the pending Metal work of the original FP32 input-video encoder.""" +"""Bound the pending Metal work and the live tensors of the original FP32 input-video encoder.""" from contextlib import contextmanager +def _lean_conv_ok(conv): + return (getattr(conv, 'spatial_padding', None) == 1 and getattr(conv, 'temporal_padding', None) in (0, 2) + and getattr(conv, 'spatial_padding_mode', None) == 'reflect' and conv.groups == 1 + and tuple(conv.padding) == (0, 0, 0) and conv.padding_mode == 'zeros') + + +def _is_resnet(module): + names = ('norm1', 'conv1', 'norm2', 'conv2', 'conv_shortcut') + return (type(module).__name__ == 'MiniMaxH3VideoResnetBlock3d' and all(hasattr(module, n) for n in names) + and _lean_conv_ok(module.conv1) and _lean_conv_ok(module.conv2) + and type(module.norm1).__name__ == 'MiniMaxH3VideoGroupNorm' + and type(module.norm2).__name__ == 'MiniMaxH3VideoGroupNorm') + + +class _Hold: + """The only reference to a tile-sized activation between two encoder stages. + + A caller's own variable or argument would keep the previous activation alive + through the whole stage; the stage takes it out of here and drops it. + """ + __slots__ = ('value',) + + def __init__(self, value): + self.value = value + + def take(self): + value, self.value = self.value, None + return value + + +def _norm_silu(norm, box): + """silu(norm(x)) as a (B, T, C, H, W) tensor viewed back as (B, C, T, H, W). + + `box` is a one-element list holding x; its copy is released as soon as the + folded input exists, unless the caller keeps another reference. + + The pinned group norm folds the frame axis into the batch; the same call is + made on the same values, only the intermediate copies are released early and + the elementwise SiLU runs in place. + """ + import torch + x = box.pop() + batch, channels, frames, height, width = x.shape + folded = x.permute(0, 2, 1, 3, 4).contiguous().view(batch * frames, channels, 1, height, width) + del x + normed = torch.nn.GroupNorm.forward(norm, folded) + del folded + normed = normed.view(batch, frames, channels, height, width) + return torch.nn.functional.silu(normed, inplace=True).permute(0, 2, 1, 3, 4) + + +def _padded(source, temporal): + """`source` with a 1-pixel reflect border and `temporal` leading zero frames in one buffer. + + Same values as reflect pad followed by a constant temporal pad, written + without the intermediate spatially padded copy. + """ + import torch + batch, channels, frames, height, width = source.shape + out = torch.empty((batch, channels, frames + temporal, height + 2, width + 2), + dtype=source.dtype, device=source.device) + if temporal: + out[:, :, :temporal] = 0 + body = out[:, :, temporal:] + body[:, :, :, 1:-1, 1:-1] = source + body[:, :, :, 0, 1:-1] = source[:, :, :, 1, :] + body[:, :, :, -1, 1:-1] = source[:, :, :, -2, :] + body[:, :, :, :, 0] = body[:, :, :, :, 2] + body[:, :, :, :, -1] = body[:, :, :, :, -3] + return out + + +def _downsample_ok(module): + conv = getattr(module, 'conv', None) + return (type(module).__name__ == 'MiniMaxH3VideoDownsample3d' and conv is not None + and getattr(module, 'spatial_stride', None) == 2 and getattr(module, 'spatial_padding_mode', None) == 'reflect' + and getattr(conv, 'spatial_padding', None) == 0 and getattr(conv, 'temporal_padding', None) == 2 + and conv.groups == 1 and tuple(conv.padding) == (0, 0, 0) and conv.padding_mode == 'zeros') + + +def _lean_downsample(module, fence): + """Reflect pad of one bottom/right pixel and the causal temporal zero pad in one buffer.""" + import torch + def forward(hidden_states): + if isinstance(hidden_states, _Hold): + hidden_states = hidden_states.take() + batch, channels, frames, height, width = hidden_states.shape + padded = torch.empty((batch, channels, frames + 2, height + 1, width + 1), + dtype=hidden_states.dtype, device=hidden_states.device) + padded[:, :, :2] = 0 + body = padded[:, :, 2:] + body[:, :, :, :-1, :-1] = hidden_states + body[:, :, :, -1, :-1] = hidden_states[:, :, :, -2, :] + del hidden_states + body[:, :, :, :, -1] = body[:, :, :, :, -3] + hidden = torch.nn.Conv3d.forward(module.conv, padded) + del padded, body + fence() + return hidden + return forward + + +def _lean_resnet(block, fence, stats): + """The original resnet forward with at most three activation-sized tensors alive. + + Every convolution still receives a contiguous, identically padded tensor and + the same weights; the residual sum is the same two operands. With + `stats['residual_offload']` the block input waits in host memory while the + block runs (an exact copy), which keeps one more tile-sized tensor off the + device at the cost of a copy there and back. + """ + import torch + def forward(hidden_states): + if isinstance(hidden_states, _Hold): + hidden_states = hidden_states.take() + device = hidden_states.device + offload = bool(stats.get('residual_offload')) and block.conv_shortcut is None + residual = hidden_states.to('cpu', copy=True) if offload else hidden_states + box = [hidden_states] + del hidden_states + activated = _norm_silu(block.norm1, box) + padded = _padded(activated, block.conv1.temporal_padding) + del activated + hidden = torch.nn.Conv3d.forward(block.conv1, padded) + del padded + fence() + box = [hidden] + del hidden + activated = _norm_silu(block.norm2, box) + padded = _padded(activated, block.conv2.temporal_padding) + del activated + hidden = torch.nn.Conv3d.forward(block.conv2, padded) + del padded + fence() + if block.conv_shortcut is not None: + residual = block.conv_shortcut(residual) + return hidden.add_(residual.to(device) if offload else residual) + return forward + + +def _lean_encoder_ok(model): + encoder = getattr(model, 'encoder', None) + names = ('conv_in', 'down_blocks', 'norm_out', 'conv_out') + if encoder is None or not all(hasattr(encoder, n) for n in names) or getattr(model, 'quant_conv', None) is None: + return False + blocks = list(encoder.down_blocks) + return bool(blocks) and all(hasattr(b, 'resnets') and hasattr(b, 'downsamplers') and len(b.resnets) + and all(_is_resnet(r) for r in b.resnets) + and all(_downsample_ok(d) for d in (b.downsamplers or ())) for b in blocks) + + +def _lean_block(block): + def forward(held): + for resnet in block.resnets: + held.value = resnet(held) + if block.downsamplers is not None: + for downsampler in block.downsamplers: + held.value = downsampler(held) + return held + return forward + + +def _lean_encoder(encoder, torch): + def forward(hidden_states): + held = _Hold(encoder.conv_in(hidden_states)) + for block in encoder.down_blocks: + block(held) + hidden_states = held.take() + hidden_states = torch.nn.functional.silu(encoder.norm_out(hidden_states)) + return encoder.conv_out(hidden_states) + return forward + + +def _residency(model, device, torch): + """Hooks that hold each encoder stage's weights on `device` only while it runs. + + The weights are the same FP32 values read from the checkpoint; only the + stage being computed is resident on the device, so the large late-stage + weights are not alive while the early stages hold the largest activations. + Returns (hooks, host copies still on the device, uploaded byte counter). + """ + encoder = model.encoder + units = [encoder.conv_in, *encoder.down_blocks, encoder.norm_out, encoder.conv_out, model.quant_conv] + covered = {id(p) for unit in units for p in unit.parameters()} + if any(id(p) not in covered for p in model.parameters()): + return None + host, uploaded, handles = {}, [0], [] + def upload(unit, inputs): + for parameter in unit.parameters(): + # A failed run leaves its stage here with the device copy in place; + # the host copy kept from the first upload stays the source. + host.setdefault(parameter, parameter.data) + parameter.data = host[parameter].to(device) + uploaded[0] += parameter.numel() * parameter.element_size() + def release(unit, inputs, output): + for parameter in unit.parameters(): + parameter.data = host.pop(parameter) + for unit in units: + handles.append(unit.register_forward_pre_hook(upload)) + handles.append(unit.register_forward_hook(release)) + return handles, host, uploaded + + @contextmanager -def bounded_encoder(model): +def bounded_encoder(model, device=None): """Finish each convolution before submitting the next encoder operation. MPS can retain large temporary allocations for pending 3D convolutions even after their Python tensors are gone. Fencing the original operators bounds that lifetime without changing weights, precision, tiles or posterior noise. - Hooks belong to this VAE instance and are always removed. + The residual blocks additionally build each padded convolution input in one + buffer, run the SiLU in place, add the residual in place and let each stage + release the previous activation: the same values, with fewer tile-sized + tensors alive at the peak. With `device`, the model's weights stay on the + host (the checkpoint mapping) and each stage uploads its own for its run. + Hooks and instance forwards belong to this VAE instance and are always + removed. """ import torch if torch.is_grad_enabled(): @@ -17,17 +226,47 @@ def bounded_encoder(model): modules = [module for module in model.modules() if isinstance(module, torch.nn.Conv3d)] if not modules: raise ValueError('Expected the H3 input encoder convolution modules') - handles = [] + handles, patched, host, staged, uploaded = [], [], {}, None, [0] stats = dict(policy='Complete original FP32 convolutions and release unused MPS allocations', - convolution_completions=0, convolution_modules=len(modules)) - def complete(module, inputs, output): + convolution_completions=0, convolution_modules=len(modules), lean_resnet_blocks=0, + staged_weight_bytes=0) + def fence(): torch.mps.synchronize() torch.mps.empty_cache() stats['convolution_completions'] += 1 + def complete(module, inputs, output): + fence() try: for module in modules: handles.append(module.register_forward_hook(complete)) + if _lean_encoder_ok(model): + for block in model.encoder.down_blocks: + for resnet in block.resnets: + resnet.forward = _lean_resnet(resnet, fence, stats) + patched.append(resnet) + for downsampler in block.downsamplers or (): + downsampler.forward = _lean_downsample(downsampler, fence) + patched.append(downsampler) + block.forward = _lean_block(block) + patched.append(block) + model.encoder.forward = _lean_encoder(model.encoder, torch) + patched.append(model.encoder) + stats['lean_resnet_blocks'] = sum(len(b.resnets) for b in model.encoder.down_blocks) + if device is not None: + staged = _residency(model, device, torch) + if staged is not None: + hooks, host, uploaded = staged + handles.extend(hooks) + if device is not None and staged is None: + # Not the pinned structure: whole model on the device, fences only. + model.to(device) yield stats finally: + if staged is not None: + stats['staged_weight_bytes'] = uploaded[0] for handle in handles: handle.remove() + for parameter, data in host.items(): + parameter.data = data + for module in patched: + module.__dict__.pop('forward', None) diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index 8dba831f..34b94f44 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -1777,7 +1777,8 @@ def attempt(source, route, retry_mode): return network.download_command(self.network, 'github', attempt, self.env, category='python', extra_sources=['user-python'] if custom else [], notice=notice) except network.PackageSourcesError as error: - failure = RuntimeError('Python download failed on every route; see retained logs') + failure = RuntimeError('Python download failed on every route; see retained logs' + + network.sources_text(error.attempts)) failure.package_attempts = error.attempts raise failure from error diff --git a/freevideo_engine/comfy_host.py b/freevideo_engine/comfy_host.py index 84317379..10b44d95 100644 --- a/freevideo_engine/comfy_host.py +++ b/freevideo_engine/comfy_host.py @@ -185,7 +185,7 @@ def git_run(label, command, env): [uv, 'pip', 'install', '--python', python, *NODE_PACKAGES], environment, 'Install ComfyUI packages'), env) except network.PackageSourcesError as error: error.log_files = list(package_logs) - error.args = (str(error) + '. Attempt logs: ' + ', '.join(package_logs),) + error.args = (str(error) + '\nAttempt logs: ' + ', '.join(package_logs),) raise save(receipt, identity) elif repair or not recorded: @@ -233,7 +233,7 @@ def package_install(family, command, label): return network.package_command(plan, family, command, env) except network.PackageSourcesError as error: error.log_files = list(package_logs) - error.args = (str(error) + '. Attempt logs: ' + ', '.join(package_logs),) + error.args = (str(error) + '\nAttempt logs: ' + ', '.join(package_logs),) raise prepared = False diff --git a/freevideo_engine/dependencies.json b/freevideo_engine/dependencies.json index 74dadd13..e8f33637 100644 --- a/freevideo_engine/dependencies.json +++ b/freevideo_engine/dependencies.json @@ -36,6 +36,13 @@ "encoder_revision": "d6aa5dfc669a749dc9364a36f3b6d4d1545a53e8", "encoder_file": "text_encoders/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors", "encoder_sha256": "35a88d51044231fe332301d7a62aa81e3f2cba62febeb446e2c1e3e0ef76f2c6", + "encoder_modelscope": { + "repo": "LAAASSS/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors", + "revision": "526349f8fbb32e5fcb0cb5e9235b30f45f1e1482", + "hf_revision": "d6aa5dfc669a749dc9364a36f3b6d4d1545a53e8", + "file": "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors", + "probe_file": "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors" + }, "edge_modelscope": { "repo": "OpenVDN/vdn-minimax-h3-edge", "revision": "c247caf6f9f54fab9240e78fc7e0ec233016e47a", diff --git a/freevideo_engine/diagnostic_resources.py b/freevideo_engine/diagnostic_resources.py index c53a7657..d8198617 100644 --- a/freevideo_engine/diagnostic_resources.py +++ b/freevideo_engine/diagnostic_resources.py @@ -52,13 +52,17 @@ def commit_exhausted(error): Rust-backed file readers raise a plain OSError carrying only the localized system message plus "(os error 1455)", so the numeric attributes are unset. + A different numeric code wins over the text, which may be a file name. """ seen = set() while isinstance(error, BaseException) and id(error) not in seen: seen.add(id(error)) - if getattr(error, 'winerror', None) == 1455 or re.search(r'(?:os error|winerror)\s*1455\b', str(error), re.I): + code = getattr(error, 'winerror', None) + if code == 1455: return True - error = error.__cause__ or error.__context__ + if code is None and re.search(r'(?:os error|winerror)\s*1455\b', str(error), re.I): + return True + error = error.__cause__ or (None if error.__suppress_context__ else error.__context__) return False @@ -770,6 +774,12 @@ def planning(rows): item['idle_cache']['accounting'] = numbers(idle.get('ram_accounting'), ('raw_available_bytes', 'credited_available_bytes', 'pinned_model_bytes', 'reused_encoder_working_bytes')) + kernels = mapping(row.get('attention_backends')) + if kernels: + # Whether the attention choice rested on this GPU's own kernel checks. + item['attention_backends'] = dict(kernel_receipt=name(kernels.get('kernel_receipt')), + offered=[value for value in sequence(kernels.get('offered')) if name(value)][:8], + withheld=sorted(value for value in mapping(kernels.get('withheld')) if name(value))[:8]) result.append(item) return result diff --git a/freevideo_engine/diagnostics.py b/freevideo_engine/diagnostics.py index dedd74be..b33fa147 100644 --- a/freevideo_engine/diagnostics.py +++ b/freevideo_engine/diagnostics.py @@ -17,6 +17,7 @@ import platform from . import execution_paths +from . import long_paths from .long_paths import io_path, resolved, plain, plain_text from . import __version__ from .bootstrap import DEFAULT_ROOT @@ -38,16 +39,8 @@ # elsewhere, so replacing only the current installation root is insufficient. PATH_KEYS = frozenset(('base', 'checkpoint', 'encoder', 'output', 'directory', 'retained', 'sample_csv', 'path', 'root', 'command')) -ABSOLUTE_PATH = re.compile( - # The preceding character guard prevents URL slashes from being treated - # as local paths while still matching a drive path after prose such as - # ``at C:\\Users\\...`` or a quoted traceback location. A slash followed by - # whitespace is prose (``8.0 / SM80``), not the start of a path. - # Consume explicit relative paths too, spaces included, so an interior - # slash (after e.g. a hyphen) cannot subsequently be mistaken for an - # absolute path. Only our own ``./setup.sh`` command stays as written. - r'''(?\./setup\.sh(?![\w/\\-]|\.\w))|\.{1,2}/[^"'<>;\r\n,\)\]}]*|''' - r'''(?:(?:[A-Za-z]:[\\/])|(?:\\\\[^\\/\s"'<>]+[\\/])|/(?![/\s]))[^"'<>;\r\n,\)\]}]*)''') +# Shared with the download error text, which is redacted before it reaches a report. +ABSOLUTE_PATH = long_paths.ABSOLUTE_PATH class Redactor: @@ -99,28 +92,16 @@ def text(self, value): value = ABSOLUTE_PATH.sub(self._path_match, value) return value - @classmethod - def _path_match(cls, match): - value = match.group(0) - if match.group('command') is not None: - return value - # Text is often redacted again (a worker's log inside a report, the - # launcher terminal). The ``/name`` after an earlier ```` is that - # pass's own output, unless it still has a directory in it. - if match.string.endswith('', 0, match.start()) and not re.search(r'[\\/]', value[1:].rstrip('\\/')): - return value - return cls._path_placeholder(value) + @staticmethod + def _path_match(match): + return long_paths.path_match(match) @staticmethod def _path_key(key): lowered = str(key).lower() return lowered in PATH_KEYS or lowered.endswith(('_path', '_paths', '_dir', '_root', '_report')) - @staticmethod - def _path_placeholder(value): - normalized = str(value).rstrip('\\/') - name = re.split(r'[\\/]', normalized)[-1] - return '/' + name if name else '' + _path_placeholder = staticmethod(long_paths.path_placeholder) def path(self, value): """Return a slash-normalized relative path safe for a shared report.""" diff --git a/freevideo_engine/display_adapters.py b/freevideo_engine/display_adapters.py new file mode 100644 index 00000000..a8d08dce --- /dev/null +++ b/freevideo_engine/display_adapters.py @@ -0,0 +1,105 @@ +"""Names of the graphics adapters the operating system lists, without any vendor driver. + +Used only to say what was seen on a setup card. None means the lookup itself failed, +which is different from a computer that lists no NVIDIA adapter. +""" +import os +import re +import subprocess + +DISPLAY_CLASS = r'SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11cf-bfc1-08002be10318}' +BASIC = re.compile(r'basic (?:display|render)', re.I) +NVIDIA = 'NVIDIA' +NVIDIA_NAME = re.compile(r'nvidia|geforce|quadro|rtx|tesla', re.I) +OTHER_VENDOR = re.compile(r'\bamd\b|radeon|\bati\b|intel', re.I) +VIRTUAL = re.compile(r'basic (?:display|render)|remote|virtual|indirect|mirror|hyper-v|vmware|parsec', re.I) + + +def windows_names(): + import winreg + names = [] + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, DISPLAY_CLASS) as head: + index = 0 + while True: + try: + slot = winreg.EnumKey(head, index) + except OSError: + break + index += 1 + if not slot.isdigit(): + continue + try: + with winreg.OpenKey(head, slot) as key: + value, _ = winreg.QueryValueEx(key, 'DriverDesc') + try: + ids, _ = winreg.QueryValueEx(key, 'MatchingDeviceId') + except OSError: + ids = None + except OSError: + continue + names.append((str(value), None if ids is None else str(ids))) + return names + + +def linux_names(): + out = subprocess.run(['lspci', '-mm'], capture_output=True, text=True, timeout=10, check=True).stdout + names = [] + for line in out.splitlines(): + fields = re.findall(r'"([^"]*)"', line) + if len(fields) >= 3 and re.search(r'VGA|3D|Display', fields[0]): + names.append(' '.join(fields[1:3])) + return names + + +def adapters(): + """Adapter names in system order, or None when the system could not be asked.""" + try: + names = windows_names() if os.name == 'nt' else linux_names() + except (ImportError, OSError, ValueError, subprocess.SubprocessError): + return None + seen, unknown = [], False + for entry in names: + name, ids = (entry, '') if isinstance(entry, str) else entry + name = ' '.join(name.split()) + if BASIC.search(name): + # Without a driver a card is listed by this name; its PCI vendor says whose it is. + # The matched ID of a driverless card is often only the display class (PCI\CC_0300), without a vendor. + if ids is None or 'ven_' not in ids.lower(): + unknown = True + continue + if 'ven_10de' in ids.lower(): + name = NVIDIA + else: + continue + if name and (name == NVIDIA or not VIRTUAL.search(name)) and name not in seen: + seen.append(name) + # An adapter of unknown vendor may be the missing NVIDIA card, so "none detected" would mislead. + if unknown and not any(NVIDIA_NAME.search(name) for name in seen): + return None + return seen + + +def detected_line(zh): + return line(zh, adapters()) + + +def only_other_vendors(names): + """True when the system lists an AMD or Intel adapter and no NVIDIA one; False when the lookup failed.""" + return bool(names) and not any(NVIDIA_NAME.search(name) for name in names) \ + and any(OTHER_VENDOR.search(name) for name in names) + + +def line(zh, names): + """The one line for a "no NVIDIA driver" card; empty when detection itself failed.""" + if names is None: + return '' + nvidia = sorted((name for name in names if NVIDIA_NAME.search(name)), + key=lambda name: name == NVIDIA) + if nvidia == [NVIDIA]: + return '检测到:NVIDIA 显卡' if zh else 'Detected: NVIDIA graphics card' + if nvidia: + return ('检测到:' if zh else 'Detected: ') + nvidia[0] + other = '、'.join(names[:2]) if zh else ', '.join(names[:2]) + if zh: + return '未检测到 NVIDIA 显卡' + ('(%s)' % other if other else '') + return 'No NVIDIA graphics card detected' + (' (%s)' % other if other else '') diff --git a/freevideo_engine/download_settings.py b/freevideo_engine/download_settings.py index a06fb78e..6f0e431a 100644 --- a/freevideo_engine/download_settings.py +++ b/freevideo_engine/download_settings.py @@ -12,7 +12,7 @@ CHOICES = ('auto', 'official', 'hf-mirror', 'modelscope') # Shown as is by the Tk window and mapped to Chinese by the setup page. SPEED_TEST_FAILED = 'The speed test did not finish. Click "Test source speeds".' -FAMILIES = ('edge-models', 'vdn-models', 'models', 'image-models') +FAMILIES = ('edge-models', 'vdn-models', 'encoder-models', 'models', 'image-models') def speed_text(row, zh=False): @@ -117,8 +117,8 @@ def order(network, family, names): measured=list(dict.fromkeys(r['id'] for r in rows if r.get('ok') and r.get('id') in names)) names=measured+[n for n in names if n not in measured] selected=value['source'] - # ModelScope has no verified encoder mapping. That family retains its - # supported HF sources instead of constructing an invalid download URL. + # A source selection only applies to families with a supported route; the + # remaining families keep their measured and configured HF source order. return [selected]+[n for n in names if n!=selected] if family in FAMILIES and selected in names else names diff --git a/freevideo_engine/download_settings_ui.py b/freevideo_engine/download_settings_ui.py index 2424b6bb..d30cc032 100644 --- a/freevideo_engine/download_settings_ui.py +++ b/freevideo_engine/download_settings_ui.py @@ -129,6 +129,7 @@ def refresh(self): self.rendered = measured self.speeds.delete(*self.speeds.get_children()) families = {'edge-models': self.t('Video model', '视频模型'), 'vdn-models': self.t('Decoder', '解码器'), + 'encoder-models': self.t('Text encoder', '文本编码器'), 'models': self.t('Prompt model', '提示词模型'), 'pypi': self.t('Python packages', 'Python 依赖'), 'github': self.t('Tools', '安装工具'), 'git': 'Git', 'cuda': 'CUDA'} for family, rows in (measured or {}).get('sources', {}).items(): diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index 286c6300..8ddf096f 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -237,6 +237,12 @@ def environment_python_removed(args, pythons): 'Choose a folder on an APFS or Mac OS Extended disk.', '它不能安装 FreeVideo。' '请选择其他硬盘上的文件夹。')), + (r'The installation folder is on a disk that does not support file links', 'disk-format', + ('This disk cannot hold the FreeVideo environment', '这块硬盘装不了 FreeVideo 的运行环境'), + ("It does not support the file operations that FreeVideo's Python environment needs. " + "Choose a folder on the Mac's built-in disk.", + '它不能安装 FreeVideo。' + '请选择 Mac 内置硬盘上的文件夹。')), (r'The installation folder is on a disk that is not connected', 'disk-missing', ('The disk is not connected', '这块硬盘没有连接'), ('Connect the disk that holds the installation folder, then check again, or choose another folder.', @@ -361,8 +367,105 @@ def _without_ready_curl_diagnostics(text): return ''.join(lines) -def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_repair=False, repairs_comfy=False, - unavailable='none'): +# Shown instead of the missing-driver card when the system lists only AMD or Intel graphics. +UNSUPPORTED_VENDOR = ( + ('FreeVideo supports NVIDIA graphics cards only', + 'Installing a driver cannot change this. Use FreeVideo on a computer with an NVIDIA graphics card.'), + ('FreeVideo 只支持 NVIDIA 显卡', + '安装驱动无法改变这一点,请在装有 NVIDIA 显卡的电脑上使用 FreeVideo。')) + + +# What each download source returned, as one short line per host and route on +# the failure card. The full lines stay in the copied error and the report. +SOURCE_REASONS = { + 'timeout': ('Connection timed out', '连接超时'), + 'dns': ('Host name could not be resolved', '无法解析域名'), + 'refused': ('Connection refused', '连接被拒绝'), + 'connect': ('Could not connect', '无法连接'), + 'reset': ('Connection interrupted', '连接中断'), + 'unreachable': ('Network unreachable', '网络不可达'), + 'proxy': ('Proxy connection failed', '代理连接失败'), + 'tls': ('Certificate check failed', '证书验证失败'), + 'http': ('Server returned HTTP {status}', '服务器返回 HTTP {status}'), + 'disk-full': ('Not enough disk space', '磁盘空间不足'), + 'write': ('Could not write the file', '无法写入文件'), + 'access-denied': ('Access denied, possibly by security software', '访问被拒绝,可能被安全软件拦截'), + 'no-progress': ('No data received for a long time', '长时间没有收到数据'), + 'slow': ('Download too slow', '下载速度过低'), + 'integrity': ('File checksum did not match', '文件校验不符'), + 'size': ('File size did not match', '文件大小不符'), + 'no-resume': ('Resume not supported', '不支持续传'), + 'faster-source': ('Dropped for a faster source', '速度较慢,已换用其他源'), + 'redirect': ('Redirect refused', '重定向被拒绝'), + 'exit': ('Exit code {code}', '退出代码 {code}'), + 'other': ('Other error', '其他错误'), +} +SOURCE_ROUTES = {'direct': ('direct', '直连'), 'inherited': ('current connection', '当前连接')} +SOURCE_CUSTOM = ('custom source', '自定义源') +SOURCE_CARD_LINES = 6 +_SOURCE_LINE = re.compile(r'^\s*- (?P[^/\s]+)/(?P[^\s:(]+)(?: \((?P[^)\s]*)\))?: ' + r'(?P[\w-]+)(?P.*)$') + + +def _sources_block(text): + """(text without the "Sources tried" block, its source lines).""" + found = re.search(r'\nSources tried(?: for [^\n:]{1,80})?:\n', text) + if not found: + return text, [] + head, tail = text[:found.start()], text[found.end():] + lines = tail.split('\n') + count = next((i for i, line in enumerate(lines) if not line.startswith(' - ')), len(lines)) + rest = '\n'.join(lines[count:]) + return head + ('\n' + rest if rest.strip() else ''), lines[:count] + + +def source_lines(lines, zh=False): + """One short line per host and route: what failed and how long it took.""" + rows = {} + for line in lines: + found = _SOURCE_LINE.match(line) + if not found: + continue + rest = found['rest'] + status = re.search(r'\bHTTP (\d{3})\b|\b(?:error|status)\D{0,4}([1-5]\d\d)\b', rest) + code = re.search(r'\bexit (-?\d+)\b|\(exit code (-?\d+)\)|\bcurl-(\d+)\b', rest) + seconds = re.search(r', (\d+(?:\.\d+)?) s(?::|$)', rest) + name = found['cls'] if found['cls'] in SOURCE_REASONS else 'other' + if name == 'http' and not status: + name = 'other' + if name == 'exit' and not code: + name = 'other' + reason = SOURCE_REASONS[name][zh].format(status=status and (status[1] or status[2]), + code=code and next(filter(None, code.groups()), '')) + host = found['host'] or (SOURCE_CUSTOM[zh] if found['source'].startswith('user') else found['source']) + route = SOURCE_ROUTES.get(found['route'], (found['route'],) * 2)[zh] + if seconds: + value = float(seconds[1]) + value = ('%.1f' % value) if value < 10 else '%d' % round(value) + reason += ('(%s 秒)' if zh else ' (%s s)') % value + rows.pop((host, route), None) + rows[host, route] = ('%s · %s:%s' if zh else '%s · %s: %s') % (host, route, reason) + return list(rows.values())[-SOURCE_CARD_LINES:] + + +def launcher_failure(value, *, zh=False, **options): + """Short guidance; a download that failed on every source also lists what each source returned.""" + text, lines = _sources_block(str(value)) if value else ('', []) + if not lines: + return _launcher_failure(value, zh=zh, **options) + # The source lines never change which guidance applies, except a full disk: + # no other source can help, so the card keeps the disk space guidance. + if any((found := _SOURCE_LINE.match(line)) and found['cls'] == 'disk-full' for line in lines): + text += '\nNot enough disk space' + failure = _launcher_failure(text, zh=zh, **options) + sources = source_lines(lines, zh) + if sources: + failure['sources'] = '\n'.join(sources) + return failure + + +def _launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_repair=False, repairs_comfy=False, + unavailable='none'): """Short guidance for known failures; callers retain the full error separately. other_disk: the launcher can offer another disk with enough room (macOS). @@ -556,12 +659,16 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep (r'NVIDIA driver/nvidia-smi is missing\.', 'gpu-driver-missing', ('No NVIDIA driver was found', '未找到 NVIDIA 显卡驱动'), ('If this computer has an NVIDIA graphics card, install NVIDIA driver 580 or newer, ' - 'restart the computer, then click Retry installation.', + 'restart the computer, then click "Retry installation".', '如果这台电脑有 NVIDIA 显卡,请安装 580 或更新版本的 NVIDIA 显卡驱动,重启电脑后点击“重试安装”。')), (r'NVIDIA driver(?: [\w.]+)?(?: \([^)]*\))? is older than 580', 'gpu-driver-old', ('The NVIDIA driver is too old', 'NVIDIA 显卡驱动版本过低'), ('Update the NVIDIA driver to version 580 or newer, restart the computer, then click "Retry installation".', '请将 NVIDIA 显卡驱动更新到 580 或更新版本,重启电脑后点击“重试安装”。')), + (r"GPU detection failed\. nvidia-smi: .*(?:exit status 9|couldn.t communicate)|cuInit failed", 'gpu-driver-not-loaded', + ('The NVIDIA driver is installed but not running', 'NVIDIA 显卡驱动已安装但未运行'), + ('Restart the computer, then click "Retry installation". If it still fails after the restart, reinstall the NVIDIA driver.', + '请重启电脑后点击“重试安装”。重启后仍然失败时,请重新安装 NVIDIA 显卡驱动。')), (r'CUDA_ARCH_UNSUPPORTED', 'gpu-architecture', ('This GPU is not supported', '这张显卡不受支持'), ('FreeVideo requires a GPU with compute capability 8.0 or higher, such as an NVIDIA GeForce RTX 30 series card or newer. ' @@ -626,7 +733,7 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep (ENVIRONMENT_PYTHON_REMOVED, 'environment-python-removed', ('A program file is missing', '缺少一个程序文件'), ('It may have been quarantined by security software. Restore the file or allow the FreeVideo folder, ' - 'then click Retry installation.', + 'then click "Retry installation".', '可能被安全软件隔离了。恢复这个文件或允许 FreeVideo 所在的文件夹后,点“重试安装”。')), # Windows refuses PyTorch's unsigned DLLs under Smart App Control; reinstalling changes nothing. (r'WinError 4551|Application Control policy has blocked', 'app-control', diff --git a/freevideo_engine/generate.py b/freevideo_engine/generate.py index 85489c83..1a84f8ae 100644 --- a/freevideo_engine/generate.py +++ b/freevideo_engine/generate.py @@ -191,7 +191,10 @@ def automatic_profile(args, canvas, *, stage, evidence, descriptor=None, environ if getattr(args, 'step_cache', None): # The planner keeps the first-block cache only where it fits. planning_canvas['step_cache'] = args.step_cache - options.update(available_backends=available_backends(hardware, probe_missing=False), canvas=planning_canvas, + kernels = {} + offered = available_backends(hardware, probe_missing=False, evidence=kernels) + row['attention_backends'] = dict(kernels, offered=sorted(offered)) + options.update(available_backends=offered, canvas=planning_canvas, demonstrated_ram_bytes=demonstrated_local_ram(hardware, args, canvas)) cache = getattr(args, 'cache', None) if cache is not None and (Path(cache) / 'manifest.json').is_file(): diff --git a/freevideo_engine/image_gen/failure.py b/freevideo_engine/image_gen/failure.py index 6aeb6ef9..57af20d7 100644 --- a/freevideo_engine/image_gen/failure.py +++ b/freevideo_engine/image_gen/failure.py @@ -11,6 +11,7 @@ from .. import __version__ from ..disk_space import ran_out from ..exit_diagnostics import clean_reading, clean_sample +from ..diagnostic_resources import commit_exhausted from ..failure_details import Redactor @@ -55,6 +56,12 @@ _LIMIT = 16 * 1024 # On a Mac the GPU's memory is the machine's: the same failure, in its words. UNIFIED_MESSAGE_ZH = {'no_memory': '内存不够,最小尺寸也没能生成。关掉其他程序后再试。'} +HOST_MESSAGE_ZH = '虚拟内存不足,无法载入图像模型。请关闭其他程序,或在系统设置中调大虚拟内存,然后重试。' + + +def host_memory_shortage(error): + """The machine's own memory ran out, not the card's.""" + return commit_exhausted(error) class FailureError(ValueError): @@ -75,6 +82,8 @@ def classify_exception(error, *, stage='start', cancelled=False): seen.add(id(error)) chain.append(error) error = error.__cause__ or (None if error.__suppress_context__ else error.__context__) + if chain and commit_exhausted(chain[0]): + return 'no_memory' for item in chain: names = {cls.__name__ for cls in type(item).__mro__} if isinstance(item, MemoryError) or 'OutOfMemoryError' in names or _MPS_MEMORY.search(str(item)): diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index 3ff148ff..3255b9fc 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -18,7 +18,7 @@ from . import image_pack from ..download_settings import observed_speed -from .failure import (Context, ERRORS, FailureError, MESSAGE_ZH, NO_REPORT, STAGES, UNIFIED_MESSAGE_ZH, +from .failure import (Context, HOST_MESSAGE_ZH, host_memory_shortage, ERRORS, FailureError, MESSAGE_ZH, NO_REPORT, STAGES, UNIFIED_MESSAGE_ZH, classify_exception) BODY_LIMIT = 64 * 1024 @@ -353,9 +353,9 @@ def show(): if failure: raise failure[0] else: - from .. import processes - processes.popen(['xdg-open', folder], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, start_new_session=True) + from ..windows_ux import open_folder + if not open_folder(folder): + return dict(opened=False, folder=folder) # the page tells the user where the images are return dict(opened=True) def close_segment_worker(self): @@ -748,6 +748,9 @@ def failed(self, code, context, *, row=None, error=None, video_busy=None): result['message_zh'] = MESSAGE_ZH[code] if context.machine.get('device_backend') == 'mps' and code in UNIFIED_MESSAGE_ZH: result['message_zh'] = UNIFIED_MESSAGE_ZH[code] + 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 not in NO_REPORT: if video_busy is None: try: diff --git a/freevideo_engine/image_gen/worker.py b/freevideo_engine/image_gen/worker.py index 469acf6f..f9c565a3 100644 --- a/freevideo_engine/image_gen/worker.py +++ b/freevideo_engine/image_gen/worker.py @@ -9,7 +9,7 @@ import threading import time -from .failure import Context, classify_exception, exception_details, safe_plan +from .failure import Context, classify_exception, exception_details, host_memory_shortage, safe_plan class Inbox: @@ -170,6 +170,8 @@ def generate(seed): result = dict(phase='error', code=code, stage=context.timing.stage, reloaded=reload, seconds=context.timing.seconds(), stages=context.timing.snapshot(), plan=plan, error=exception_details(error, context.redactor, code)) + if code == 'no_memory' and host_memory_shortage(error): + result['memory'] = 'host' try: result.update(attention_report(runner, context.redactor)) except Exception: diff --git a/freevideo_engine/installation_diagnostics.py b/freevideo_engine/installation_diagnostics.py index b4ea83fc..b8a186c5 100644 --- a/freevideo_engine/installation_diagnostics.py +++ b/freevideo_engine/installation_diagnostics.py @@ -36,36 +36,52 @@ def collect(root, since): pass except OSError: pass - # ComfyUI dependency preparation runs in launcher/host-runs rather - # than setup-runs. Keep only structured package failure events; the - # command output remains available in the local manual bundle. + # What each download source returned, from structured network events: + # Python and Git downloads in the setup run, and ComfyUI dependency + # preparation in launcher/host-runs. The command output remains in the + # local manual bundle. package_attempts = [] + folders = [run] host_root = root / 'launcher' / 'host-runs' if host_root.is_dir(): - candidates = sorted((path for path in host_root.iterdir() if path.is_dir()), - key=lambda path: path.name)[-3:] - for host in candidates: - try: - if host.stat().st_mtime < since - 1: - continue - with (host / 'network.jsonl').open('rb') as stream: - lines = stream.read(256 * 1024).splitlines()[-64:] - for line in lines: - try: - item = d.mapping(json.loads(line)) - except (ValueError, UnicodeError): - continue - if item.get('action') != 'package-failed': - continue - package_attempts.append({key: item[key] for key in - ('source', 'route', 'error_type', 'returncode', 'summary') if key in item}) - except OSError: + folders += sorted((path for path in host_root.iterdir() if path.is_dir()), + key=lambda path: path.name)[-3:] + for folder in folders: + try: + if folder != run and folder.stat().st_mtime < since - 1: continue + package_attempts += source_failures(folder / 'network.jsonl') + except OSError: + continue return plain_data(dict(state=state, samples=rows, package_attempts=package_attempts[-16:])) except (OSError, ValueError, KeyError, TypeError): return {} +SOURCE_KEYS = ('category', 'source', 'route', 'host', 'error_class', 'message', 'elapsed_seconds', + 'error_type', 'returncode', 'summary') + + +def source_failures(path): + """The last failed download attempts in a network event log, one row per source and route tried.""" + with io_path(path).open('rb') as stream: + size = stream.seek(0, 2) + stream.seek(max(0, size - 256 * 1024)) + lines = stream.read(256 * 1024).splitlines() + if size > 256 * 1024: + lines = lines[1:] + rows = [] + for line in lines: + try: + item = d.mapping(json.loads(line)) + except (ValueError, UnicodeError): + continue + if item.get('action') in ('package-failed', 'python-failed') or ( + item.get('category') == 'git' and item.get('action') == 'fallback' and 'error_class' in item): + rows.append({key: item[key] for key in SOURCE_KEYS if key in item}) + return rows[-16:] + + def _errors(rows): result = [] for row in d.sequence(rows)[:16]: @@ -141,7 +157,7 @@ def enrich(result, value): for row in d.sequence(value.get('package_attempts'))[-16:]: row = d.mapping(row) item = dict(kind='package_source_failure') - for key in ('source', 'route', 'error_type', 'returncode', 'summary'): + for key in SOURCE_KEYS: if key in row and (key == 'returncode' or isinstance(row[key], (str, int, float, bool))): item[key] = row[key] result['errors'].append(item) diff --git a/freevideo_engine/kernel_capabilities.py b/freevideo_engine/kernel_capabilities.py index dec6b940..d6b2e311 100644 --- a/freevideo_engine/kernel_capabilities.py +++ b/freevideo_engine/kernel_capabilities.py @@ -74,49 +74,111 @@ def readiness(rows): if row.get('status') != 'complete' and row['backend'] != 'linear']) -def matching_receipt(hardware, discovered=None): - """The saved kernel checks when they still match this GPU, driver and packages; else None.""" - discovered = set(installed_backends() if discovered is None else discovered) - expected = identity(hardware) - # Normalize tuples exactly as on disk. - expected = json.loads(json.dumps(expected)) +def _saved_receipt(): try: receipt = json.loads(receipt_path().read_text(encoding='utf-8')) + except (OSError, ValueError): + return None + return receipt if isinstance(receipt, dict) else None + + +def _expected_identity(hardware): + # Normalize tuples exactly as on disk. + return json.loads(json.dumps(identity(hardware))) + + +def _matching(receipt, expected, discovered): + try: if receipt['identity'] != expected or set(receipt['installed_attention_packages']) != discovered: - receipt = None - elif (not isinstance(receipt.get('kernel_probes'), list) - or not discovered | {'linear'} <= {row.get('backend') for row in receipt['kernel_probes'] if isinstance(row, dict)} - <= discovered | set(LINEAR_PROBES) - or len(receipt['kernel_probes']) != len({row.get('backend') for row in receipt['kernel_probes'] if isinstance(row, dict)}) - or any(not isinstance(row, dict) or row.get('status') not in ('complete', 'error') - for row in receipt['kernel_probes'])): - receipt = None - except (OSError, ValueError, KeyError, TypeError): - receipt = None + return None + if (not isinstance(receipt.get('kernel_probes'), list) + or not discovered | {'linear'} <= {row.get('backend') for row in receipt['kernel_probes'] if isinstance(row, dict)} + <= discovered | set(LINEAR_PROBES) + or len(receipt['kernel_probes']) != len({row.get('backend') for row in receipt['kernel_probes'] if isinstance(row, dict)}) + or any(not isinstance(row, dict) or row.get('status') not in ('complete', 'error') + for row in receipt['kernel_probes'])): + return None + except (KeyError, TypeError): + return None return receipt -def available_backends(hardware, *, discovered=None, probe_missing=True): +def matching_receipt(hardware, discovered=None): + """The saved kernel checks when they still match this GPU, driver and packages; else None.""" + discovered = set(installed_backends() if discovered is None else discovered) + receipt = _saved_receipt() + return None if receipt is None else _matching(receipt, _expected_identity(hardware), discovered) + + +# Compute capabilities SageAttention 2.x has kernels for (its sageattn dispatch); +# on any other GPU its first call raises "Unsupported CUDA architecture". +SAGE2_CAPABILITIES = frozenset({(8, 0), (8, 6), (8, 9), (9, 0), (12, 0), (12, 1)}) + + +def withheld_without_receipt(hardware, discovered, receipt=None, expected=None): + """Installed backends known not to run here although this GPU has no matching checks. + + Planning without a matching receipt offers what is installed, so the first + request after a driver or FreeVideo update, or on another card in the same + machine, keeps the backend its checks chose before. Two kinds of backend + are left out: one whose package has no kernels for this compute capability, + and one whose last check failed on the same architecture with the same + packages (only the card, driver or FreeVideo's own files differ). Returns + {backend: reason}. + """ + withheld = {} + capability = tuple(hardware.capability) if isinstance(hardware.capability, (tuple, list)) else None + if 'sage2' in discovered and capability not in SAGE2_CAPABILITIES: + withheld['sage2'] = 'no SageAttention 2 kernels for compute capability %s' % ( + '.'.join(map(str, capability)) if capability else 'unknown') + try: + saved = receipt['identity'] + expected = _expected_identity(hardware) if expected is None else expected + comparable = (all(saved['hardware'][key] == expected['hardware'][key] + for key in ('system', 'capability', 'torch_version', 'cuda_version')) + and saved['packages'] == expected['packages'] + and saved.get('fa4_overlay_sha256') == expected['fa4_overlay_sha256'] + and set(receipt['installed_attention_packages']) == discovered) + if comparable: + for row in receipt['kernel_probes']: + if isinstance(row, dict) and row.get('status') == 'error' and row.get('backend') in discovered: + withheld.setdefault(row['backend'], 'failed its last check on this architecture with these packages') + except (KeyError, TypeError, AttributeError): + pass + return withheld + + +def available_backends(hardware, *, discovered=None, probe_missing=True, evidence=None): """Reuse matching checks; re-probe after a GPU, driver or package/code change. - Read-only planning may opt out of probing. Execution callers never turn an - unavailable requested backend into another backend or a math SDPA kernel. + Read-only planning may opt out of probing; it then offers the installed + backends except those known not to run here (``withheld_without_receipt``). + Execution callers never turn an unavailable requested backend into another + backend or a math SDPA kernel. ``evidence``, a dict, receives which checks + the answer rests on. """ discovered = set(installed_backends() if discovered is None else discovered) - receipt = matching_receipt(hardware, discovered) + saved = _saved_receipt() + expected = _expected_identity(hardware) if saved is not None else None + receipt = None if saved is None else _matching(saved, expected, discovered) if receipt is not None and probe_missing and needs_path_recheck(receipt): # They failed while the installation ran from another folder, and compilers take some # paths only as that folder spells them. What passed is kept wherever it ran from. receipt = None if receipt is None: if not probe_missing: - return discovered + withheld = withheld_without_receipt(hardware, discovered, saved, expected) + if evidence is not None: + evidence.update(kernel_receipt='unmatched' if saved is not None else 'missing', withheld=withheld) + return discovered - set(withheld) from .doctor import doctor receipt = doctor(probe=True, hardware=hardware, backends=discovered) checked = readiness(receipt['kernel_probes']) if not checked['ready']: raise RuntimeError('GPU kernel checks did not find a working linear and attention path. ' 'Run freevideo doctor --probe; details: ' + str(receipt_path())) + if evidence is not None: + evidence.update(kernel_receipt='matching', withheld={}) return discovered.intersection(checked['usable_attention_backends']) diff --git a/freevideo_engine/launcher/Main.qml b/freevideo_engine/launcher/Main.qml index 8f724186..49a6e8cb 100644 --- a/freevideo_engine/launcher/Main.qml +++ b/freevideo_engine/launcher/Main.qml @@ -65,6 +65,24 @@ ApplicationWindow { readonly property var availableRelease: s.update.candidate || (s.update.engine ? currentRelease : null) readonly property var availableEarlier: s.update.candidate ? (s.update.candidate_earlier || []) : s.update.engine ? (s.update.engine_earlier || []) : [] function sourceName(value) { return ({"auto": t("Automatic", "自动选择"), "official": "Hugging Face", "hf-mirror": t("HF Mirror", "HF 镜像"), "modelscope": t("ModelScope", "魔搭")})[value] || value } + // A source line wraps inside the host, after the host or the route, after a comma, or before + // the time, and an English reason also between words. Qt text has no keep-all or nowrap span, + // so word joiners and no-break spaces hold the rest. + function sourceLines(value) { + function hold(part, spaces) { + var out = "", prev = "" + for (var c of part) { + if (prev && prev !== "," && prev !== "," && (spaces || !/\s/.test(prev + c))) + out += "\u2060" + out += spaces && c === " " ? "\u00a0" : c; prev = c + } + return out + } + return (value || "").split("\n").map(function (line) { + var m = /^(.*? · )([^::]*([::]))( ?)(.*?)( ?)([((][\d.]+ ?(?:秒|s)[))])?$/.exec(line) + return m ? m[1] + hold(m[2], true) + m[4] + hold(m[5], m[3] === ":") + m[6] + hold(m[7] || "", true) : line + }).join("\n") + } function number(n) { return typeof n === "number" && isFinite(n) } function fraction(row) { return row && number(row.total) && row.total > 0 && number(row.done) && row.done <= row.total ? row.done / row.total : -1 } function gb(n) { return n >= 1e9 ? (n / 1e9).toFixed(1) + "\u00a0GB" : Math.max(1, Math.round(n / 1e6)) + "\u00a0MB" } @@ -426,6 +444,9 @@ ApplicationWindow { id: failureBody; x: 18; y: 18; width: parent.width - 36; spacing: 10 FText { text: s.failure.title || t("Something went wrong", "出现问题"); color: theme.danger; font.weight: Font.DemiBold; Layout.fillWidth: true } FText { visible: !!(s.failure.detail || s.failure.action); text: s.failure.detail || s.failure.action; color: theme.text; Layout.fillWidth: true } + FText { visible: !!s.failure.note; text: s.failure.note || ""; color: theme.muted; Layout.fillWidth: true } + // What each download source returned: one short line per source. + FText { objectName: "failureSources"; visible: !!s.failure.sources; text: sourceLines(s.failure.sources); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } TextEdit { objectName: "failurePath"; visible: !!s.failure.path; text: s.failure.path || "" readOnly: true; selectByMouse: true; wrapMode: Text.WrapAnywhere; textFormat: TextEdit.PlainText @@ -1441,6 +1462,7 @@ ApplicationWindow { ColumnLayout { id: settingsError; x: 14; y: 14; width: parent.width - 28; spacing: 8 FText { text: s.failure.title + "\n" + (s.failure.action || s.failure.detail); color: theme.danger; font.pixelSize: theme.micro + 1; Layout.fillWidth: true } + FText { objectName: "settingsFailureSources"; visible: !!s.failure.sources; text: sourceLines(s.failure.sources); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } TextEdit { objectName: "settingsFailurePath"; visible: !!s.failure.path; text: s.failure.path || "" readOnly: true; selectByMouse: true; wrapMode: Text.WrapAnywhere; textFormat: TextEdit.PlainText diff --git a/freevideo_engine/launcher_copy.py b/freevideo_engine/launcher_copy.py index 65e54d43..72674181 100644 --- a/freevideo_engine/launcher_copy.py +++ b/freevideo_engine/launcher_copy.py @@ -9,6 +9,8 @@ ACTION_COPY = { 'download_missing': ('The download folder no longer exists. Export the list again.', '下载目录已不存在,请重新导出清单'), + 'folder_open_failed': ('The folder could not be opened automatically. Open it in a file manager.', + '无法自动打开该文件夹,请在文件管理器中手动打开'), 'installation_missing': ('There is no installation information yet. Complete installation first.', '还没有安装信息,请先完成安装'), 'installation_unavailable': ('The saved ComfyUI folder could not be opened. It may have been moved or renamed, or its drive may be disconnected. Choose its current location below, or install ComfyUI.', diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index 3d7b7188..f71147fe 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -2242,6 +2242,7 @@ def snapshot(self): for group, entries in probe.get('sources', {}).items(): for entry in entries: names = {'edge-models': ('Video model', '视频模型'), 'vdn-models': ('Decoder', '解码器'), + 'encoder-models': ('Text encoder', '文本编码器'), 'image-models': NAMES['image'], 'models': NAMES['text'], 'pypi': ('Python packages', 'Python 依赖'), 'github': ('Tools', '安装工具'), 'git': ('Git', 'Git'), 'cuda': ('CUDA', 'CUDA')} @@ -2253,6 +2254,15 @@ def snapshot(self): from .sampling_assets import total_bytes failure = launcher_failure(error, zh=self.language.startswith('zh'), can_repair=self.can_repair(), repairs_comfy=self.repairs_comfy(), unavailable=self.repair_unavailable()) + if failure.get('kind') == 'gpu-driver-missing': + from .display_adapters import adapters, line, only_other_vendors + zh = self.language.startswith('zh') + names = adapters() + if line(zh, names): + failure['note'] = line(zh, names) + if only_other_vendors(names): + from .failure_details import UNSUPPORTED_VENDOR + failure['title'], failure['action'] = UNSUPPORTED_VENDOR[1 if zh else 0] if failure.get('path'): # The local repair instruction needs the actual file location; copied details stay redacted. from .model_repair import MISMATCH_PATH diff --git a/freevideo_engine/launcher_settings.py b/freevideo_engine/launcher_settings.py index c502e7d1..c597f439 100644 --- a/freevideo_engine/launcher_settings.py +++ b/freevideo_engine/launcher_settings.py @@ -2,6 +2,7 @@ from contextlib import ExitStack, contextmanager import json import os +import re from pathlib import Path import shutil import sys @@ -139,9 +140,34 @@ def documents_directory(): function.restype = ctypes.c_long if function(None, 5, None, 0, buffer) == 0 and buffer.value: return Path(buffer.value) + if os.name != 'nt': + configured = xdg_documents_directory() + if configured: + return configured return Path.home() / 'Documents' +def xdg_documents_directory(): + """The Documents folder the desktop chose (localized or moved), from the XDG user-dirs file.""" + value = os.environ.get('XDG_DOCUMENTS_DIR') + if not value: + base = Path(os.environ.get('XDG_CONFIG_HOME') or Path.home() / '.config') + try: + text = (base / 'user-dirs.dirs').read_text(encoding='utf-8') + except (OSError, UnicodeError): + return None + match = re.search(r'^\s*XDG_DOCUMENTS_DIR\s*=\s*"([^"\n]+)"', text, re.M) + value = match.group(1) if match else None + if not value: + return None + value = value.replace('$HOME', str(Path.home())) + path = Path(value) + # An unset folder is written as $HOME itself; that is not a Documents folder. + if not path.is_absolute() or path == Path.home(): + return None + return path + + class Store: def __init__(self, legacy_root, documents=None): self.local = Path(legacy_root) / 'comfy-launcher.json' diff --git a/freevideo_engine/long_paths.py b/freevideo_engine/long_paths.py index 4fc07d32..3f3bb837 100644 --- a/freevideo_engine/long_paths.py +++ b/freevideo_engine/long_paths.py @@ -232,3 +232,35 @@ def call(*args, **kwargs): def path_chars(path): """Windows budgets count UTF-16 code units (a non-BMP character uses two).""" return len(str(path).encode('utf-16-le', errors='surrogatepass')) // 2 + + +ABSOLUTE_PATH = re.compile( + # The preceding character guard prevents URL slashes from being treated + # as local paths while still matching a drive path after prose such as + # ``at C:\\Users\\...`` or a quoted traceback location. A slash followed by + # whitespace is prose (``8.0 / SM80``), not the start of a path. + # Consume explicit relative paths too, spaces included, so an interior + # slash (after e.g. a hyphen) cannot subsequently be mistaken for an + # absolute path. Only our own ``./setup.sh`` command stays as written. + r'''(?\./setup\.sh(?![\w/\\-]|\.\w))|\.{1,2}/[^"'<>;\r\n,\)\]}]*|''' + r'''(?:(?:[A-Za-z]:[\\/])|(?:\\\\[^\\/\s"'<>]+[\\/])|/(?![/\s]))[^"'<>;\r\n,\)\]}]*)''') + + +def path_placeholder(value): + """``/`` and the last component: no usernames, drives, shares or parent directories.""" + normalized = str(value).rstrip('\\/') + name = re.split(r'[\\/]', normalized)[-1] + return '/' + name if name else '' + + +def path_match(match): + """Replacement for an ``ABSOLUTE_PATH`` match. Spaces, UNC shares and ``\\\\?\\`` prefixes count.""" + value = match.group(0) + if match.group('command') is not None: + return value + # Text is often redacted again (a worker's log inside a report, the + # launcher terminal). The ``/name`` after an earlier ```` is that + # pass's own output, unless it still has a directory in it. + if match.string.endswith('', 0, match.start()) and not re.search(r'[\\/]', value[1:].rstrip('\\/')): + return value + return path_placeholder(value) diff --git a/freevideo_engine/macos_bootstrap.py b/freevideo_engine/macos_bootstrap.py index 2f1b0ef1..2fb7f192 100644 --- a/freevideo_engine/macos_bootstrap.py +++ b/freevideo_engine/macos_bootstrap.py @@ -4,6 +4,7 @@ This prepares one tool; it does not declare a model installation ready. """ from pathlib import Path +import errno import json import math import os @@ -38,6 +39,40 @@ def versions(value): uv=dict(UV, url=UV['sources']['official']), github_probe_url=value['uv']['url']) +LOCATION_UNSUPPORTED = 'The installation folder is on a disk that does not support file links' +# What a volume answers when it lacks the operation, as opposed to a permission or space problem. +UNSUPPORTED = (errno.ENOTSUP, errno.EOPNOTSUPP, errno.ENOSYS, errno.EPERM, errno.EXDEV, errno.EINVAL) + + +def probe_links(root): + """'ok', 'copy' (no hard links) or 'blocked' (no symbolic links or renames) for a folder. + + Any other failure, such as a missing permission, says nothing about the volume and counts as 'ok'.""" + folder = Path(root) / ('.freevideo-link-probe-%d' % os.getpid()) + try: + folder.mkdir(parents=True, exist_ok=True) + source = folder / 'source' + source.write_bytes(b'x') + result = 'ok' + for name, make in (('symlink', lambda: os.symlink(source.name, folder / 'symbolic')), + ('rename', lambda: os.rename(source, folder / 'renamed'))): + try: + make() + except OSError as error: + if error.errno in UNSUPPORTED: + return 'blocked' + return 'ok' + try: + os.link(folder / 'renamed', folder / 'hard') + except OSError as error: + result = 'copy' if error.errno in UNSUPPORTED else 'ok' + return result + except OSError: + return 'ok' + finally: + shutil.rmtree(folder, ignore_errors=True) + + GIT_UNAVAILABLE = 'Git is unavailable. Check the network connection, or install Git (Apple Command Line Tools), then retry.' diff --git a/freevideo_engine/macos_setup.py b/freevideo_engine/macos_setup.py index cb010837..5f409ee8 100644 --- a/freevideo_engine/macos_setup.py +++ b/freevideo_engine/macos_setup.py @@ -12,7 +12,13 @@ def __init__(self, value, ui=None): super().__init__(value, ui) def device_environment(self, env): - return native.environment(self.root, env) + env = native.environment(self.root, env) + links = native.probe_links(self.root) + if links == 'blocked': + raise RuntimeError(native.LOCATION_UNSUPPORTED) + if links == 'copy': + env['UV_LINK_MODE'] = 'copy' + return env def device_configuration(self): return dict(device_backend='mps', device_identity=self.plan['inventory']['selected_device']) diff --git a/freevideo_engine/media_encoding.py b/freevideo_engine/media_encoding.py index 7171ecd9..c2da401e 100644 --- a/freevideo_engine/media_encoding.py +++ b/freevideo_engine/media_encoding.py @@ -67,6 +67,28 @@ def encode_visual(vae, pixels, *, device='cuda', posterior='sample'): return (latent - mean) / std +def _retry_in_host_memory(memory, encode): + """Run `encode`; if MPS runs out of memory, run it once more with each residual block's input held in host memory. + + The copy is exact, so the latent is the same; `memory` is the stats of + bounded_encoder, which is None on CUDA. + """ + if memory is None: + return encode() + try: + return encode() + except RuntimeError as error: + if 'MPS backend out of memory' not in str(error) or memory.get('residual_offload'): + raise + failure = str(error)[:300] + import gc + import torch + gc.collect() + torch.mps.empty_cache() + memory.update(residual_offload=True, memory_retry=failure) + return encode() + + def _audio(path, destination, seconds): """The clip's first `seconds` at 32 kHz stereo, as the official pipeline truncates reference audio. @@ -326,22 +348,28 @@ def encode_latents(normalized, value, base, canvas, *, resident=None, device='cu print(json.dumps({'event': 'media_encode_phase', 'phase': 'Encoding image/video references'}), flush=True) # Conditioning never decodes: the 9.0 GiB decoder in the shared shards # is not read. The independent output decoder is loaded after sampling. + on_mps = torch.device(device).type == 'mps' + # On MPS the weights stay in the checkpoint mapping and each encoder + # stage uploads its own while it runs (bounded_encoder). vae, _ = load_video_encoder(base, before_upload=None if resident is None else - lambda model: resident.input_vae_room(model, canvas), device=device) + lambda model: resident.input_vae_room(model, canvas), + device='cpu' if on_mps else device) lifetime = nullcontext() - if torch.device(device).type == 'mps': + if on_mps: from .backends.mps_vae_encode import bounded_encoder - lifetime = bounded_encoder(vae) + lifetime = bounded_encoder(vae, device=device) with lifetime as memory: for index, (row, ref) in enumerate(zip(normalized, refs)): if row.get('pixels') and index not in hits: mapped = np.load(row['pixels'], mmap_mode='r', allow_pickle=False) if row['frames'] == 1: pixels = torch.from_numpy(np.array(mapped[:1])).to(device).permute(3, 0, 1, 2)[None] - ref['latent'] = encode_vae_condition(vae, pixels, PIXEL_MEAN, PIXEL_STD, 42).cpu() + ref['latent'] = _retry_in_host_memory(memory, lambda: encode_vae_condition( + vae, pixels, PIXEL_MEAN, PIXEL_STD, 42)).cpu() del pixels else: - ref['latent'] = encode_visual(vae, mapped[:row['frames']], device=device) + ref['latent'] = _retry_in_host_memory( + memory, lambda: encode_visual(vae, mapped[:row['frames']], device=device)) del mapped if cache is not None and index in keys: _remember_latent(cache, keys[index], ref['latent'], Path(row['pixels']).parent) diff --git a/freevideo_engine/model_transfer.py b/freevideo_engine/model_transfer.py index ab7b42e2..a818676b 100644 --- a/freevideo_engine/model_transfer.py +++ b/freevideo_engine/model_transfer.py @@ -255,8 +255,11 @@ def get(*args, **kwargs): ms_parallel(_download,network.model_url(row,source),target,row['bytes'],headers,None, [Callback(row['file'],row['bytes'])]) else: + remote_file = mirror.get('file', row['file']) + local_dir = (stage / Path(row['file']).parent + if Path(remote_file).parent != Path(row['file']).parent else stage) HubApi(endpoint=network.source_url(mirror['family'], source), token='').downloader.download_file( - mirror['repo'], 'model', row['file'], revision=mirror['revision'], local_dir=stage, + mirror['repo'], 'model', remote_file, revision=mirror['revision'], local_dir=local_dir, file_size=row['bytes'], expected_sha256=None, progress_callbacks=[Callback]) finally: _download.requests.get = original_get diff --git a/freevideo_engine/modern_launcher.py b/freevideo_engine/modern_launcher.py index 60a05121..e7815247 100644 --- a/freevideo_engine/modern_launcher.py +++ b/freevideo_engine/modern_launcher.py @@ -314,6 +314,12 @@ def openDownloadFolder(self): if folder and Path(folder).is_dir(): if session.action_notice == 'download_missing': session.action_notice = '' + if sys.platform.startswith('linux'): + from .windows_ux import open_folder + if not open_folder(Path(folder).resolve()): + session.action_notice = 'folder_open_failed' + self.refresh() + return self.invoke(QDesktopServices.openUrl, QUrl.fromLocalFile(str(Path(folder).resolve()))) else: session.action_notice = 'download_missing' diff --git a/freevideo_engine/network.py b/freevideo_engine/network.py index b25ea670..0c59af3b 100644 --- a/freevideo_engine/network.py +++ b/freevideo_engine/network.py @@ -24,7 +24,7 @@ import time from urllib.parse import quote, unquote, urlencode, urljoin, urlsplit from . import proxy -from .long_paths import io_path, is_windows, path_chars, plain, plain_errors +from .long_paths import io_path, is_windows, path_chars, plain, plain_errors, ABSOLUTE_PATH, path_match from .system import curl_executable, curl_ca_arguments, missing_curl_message, windows @@ -36,6 +36,8 @@ 'modelscope': 'https://www.modelscope.cn'}, 'edge-models': {'official': 'https://huggingface.co', 'hf-mirror': 'https://hf-mirror.com', 'modelscope': 'https://www.modelscope.cn'}, + 'encoder-models': {'official': 'https://huggingface.co', 'hf-mirror': 'https://hf-mirror.com', + 'modelscope': 'https://www.modelscope.cn'}, 'image-models': {'official': 'https://huggingface.co', 'hf-mirror': 'https://hf-mirror.com', 'modelscope': 'https://www.modelscope.cn'}, 'github': {'official': 'https://github.com', 'ghfast': 'https://ghfast.top/https://github.com'}, @@ -69,6 +71,7 @@ def proxy_environment(environ=None): def configured_source(family, env): names = {'pypi': ('UV_DEFAULT_INDEX', 'UV_INDEX_URL', 'PIP_INDEX_URL'), 'models': ('HF_ENDPOINT',), 'vdn-models': ('HF_ENDPOINT',), 'edge-models': ('HF_ENDPOINT',), + 'encoder-models': ('HF_ENDPOINT',), 'github': ('FREEVIDEO_GITHUB_MIRROR',)}.get(family, ()) return next((env[k].rstrip('/') for k in names if env.get(k)), None) @@ -94,7 +97,12 @@ def modelscope_mapping(row, spec=None): """Return only a published, verified HF-to-ModelScope revision pair.""" settings = model_spec(spec) # A ModelScope commit is not a Hugging Face commit. Only the verified pair - # shares paths/content; an encoder or a future HF pin needs its own route. + # shares content; unrelated or newer HF pins must keep using Hugging Face. + encoder = settings.get('encoder_modelscope', {}) + if (encoder and row['repo'] == settings.get('encoder_repo') and + row['revision'] == settings.get('encoder_revision') == encoder.get('hf_revision') and + row.get('file') == settings.get('encoder_file')): + return dict(encoder, family='encoder-models') for prefix in ('vdn', 'edge'): mirror = settings.get(prefix + '_modelscope', {}) if (mirror and row['repo'] == settings.get(prefix + '_repo', mirror['repo']) and @@ -123,7 +131,7 @@ def model_url(row, name, env=None, *, spec=None): if not mirror: raise ValueError('No verified ModelScope mapping for this model revision') return (source_url(mirror['family'], name, env) + '/api/v1/models/' + quote(mirror['repo'], safe='/') + - '/repo?' + urlencode({'Revision': mirror['revision'], 'FilePath': row['file']})) + '/repo?' + urlencode({'Revision': mirror['revision'], 'FilePath': mirror.get('file', row['file'])})) return (source_url('models', name, env) + '/' + quote(row['repo'], safe='/') + '/resolve/' + quote(row['revision'], safe='') + '/' + quote(row['file'], safe='/')) @@ -309,11 +317,15 @@ def probe(family, name, spec, versions, timeout, env, *, measure_speed=False): elif family == 'image-models': from .image_gen.image_pack import rows url = model_url(rows()[0], name, env) - elif family in ('vdn-models', 'edge-models'): + elif family in ('vdn-models', 'edge-models', 'encoder-models'): settings = spec['models'] - mirror = settings[family.split('-')[0] + '_modelscope'] - url = model_url({'repo': mirror['repo'], 'revision': mirror['hf_revision'], - 'file': mirror['probe_file']}, name, env, spec=spec) + if family == 'encoder-models': + url = model_url({'repo': settings['encoder_repo'], 'revision': settings['encoder_revision'], + 'file': settings['encoder_file']}, name, env, spec=spec) + else: + mirror = settings[family.split('-')[0] + '_modelscope'] + url = model_url({'repo': mirror['repo'], 'revision': mirror['hf_revision'], + 'file': mirror['probe_file']}, name, env, spec=spec) elif family == 'github': url = versions.get('github_probe_url', versions['uv']['url']).replace(SOURCES['github']['official'], base, 1) elif family == 'git': @@ -347,7 +359,7 @@ def probe(family, name, spec, versions, timeout, env, *, measure_speed=False): component = versions['cuda_toolkits']['13.0']['components']['cuda_cudart'] url = base + '/' + component['relative_path'] headers = () - if (family in ('models', 'vdn-models', 'edge-models', 'image-models') and name == 'official' + if (family in ('models', 'vdn-models', 'edge-models', 'encoder-models', 'image-models') and name == 'official' and urlsplit(url).scheme == 'https' and urlsplit(url).netloc == 'huggingface.co'): token = env.get('HF_TOKEN') or env.get('HUGGING_FACE_HUB_TOKEN') if token: @@ -404,6 +416,8 @@ def plan(spec, versions, layout='unified', *, mode='auto', timeout=5, offline=Fa proxy_configured = proxy.configured(env) git_keys = [] if offline or model_only or not git_available else proxy.git_proxy_keys(env) families = ['pypi', 'models', 'vdn-models', 'image-models', 'github', 'git', 'cuda'] + if spec['models'].get('encoder_modelscope'): + families.insert(2, 'encoder-models') if spec['models'].get('edge_modelscope'): families.insert(3, 'edge-models') if versions.get('target_system') in ('Windows', 'Darwin'): @@ -414,7 +428,7 @@ def plan(spec, versions, layout='unified', *, mode='auto', timeout=5, offline=Fa if family not in families: families.append(family) if model_only: - families=[family for family in families if family in ('models','vdn-models','edge-models','image-models')] + families=[family for family in families if family in ('models','encoder-models','vdn-models','edge-models','image-models')] selections, jobs = {}, [] for family in families: group = 'torch' if family.startswith('torch-') else 'github' if family == 'git' else family @@ -560,18 +574,44 @@ class PackageSourcesError(RuntimeError): def __init__(self, family, attempts): self.family = family self.attempts = [dict(row) for row in attempts] - summaries = [] - for row in self.attempts: - detail = row.get('summary') or row.get('detail') or row.get('error_type') or 'unknown failure' - summaries.append('%s/%s: %s' % (row.get('source', 'unknown'), - row.get('route', 'unknown'), detail)) - message = 'All package sources failed for %s. Check your network or connection mode in Downloads. Attempts: %s' % (family, '; '.join(summaries)) - super().__init__(message) + self.package_attempts = self.attempts + message = 'All package sources failed for %s. Check your network or connection mode in Downloads.' % family + super().__init__(message + sources_text(self.attempts)) _ANSI_ESCAPE = re.compile(r'\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') +def _redact(value): + """Report-safe text: no credentials, URL parameters, tokens or local paths.""" + if isinstance(value, bytes): + value = value.decode('utf-8', errors='replace') + value = _ANSI_ESCAPE.sub('', str(value)).replace('\x00', '') + # User information in any URL (https, socks5, a token alone before @), up to + # the last @ before the host, so a password holding an @ goes as a whole. + value = re.sub(r'(?i)\b([a-z][a-z0-9+.-]*://)[^\s/\x27\x22]+@', r'\1@', value) + # A proxy written without a scheme: user:password@host[:port]. + value = re.sub(r'(?@', value) + # Query strings routinely carry signed URLs and access tokens. + value = re.sub(r'(?i)(https?://[^\s?\x27\x22]+)\?[^\s\x27\x22]+', r'\1', value) + value = re.sub(r'(?i)(\b(?:Bearer|Basic)\s+)[A-Za-z0-9._~+/=-]+', r'\1', value) + # Hugging Face, GitHub and API keys, wherever they appear (a URL path, a header). + value = re.sub(r'\b(?:hf_|sk-|gh[pousr]_|github_pat_)[A-Za-z0-9_-]{8,}\b', '', value) + value = re.sub(r'(?i)(\b[\w-]{0,64}(?:token|password|secret|api[_-]?key)\s*[=:]\s*)[^\s,;&]+', + r'\1', value) + # Keep a stable path marker without exposing usernames, drives, shares or + # mounts; spaces, UNC and \\?\ paths included, as in a diagnostic report. + # A marker already in place stays as it is, so redacting twice changes nothing. + return ABSOLUTE_PATH.sub(_path_match, value) + + +def _path_match(match): + # The rest of a URL whose token was removed (``...//file``). + if match.string.endswith('', 0, match.start()) and not re.search(r'[\\/]', match.group(0)[1:]): + return match.group(0) + return path_match(match) + + def _package_failure(error): """Return a bounded, report-safe package error detail. @@ -580,30 +620,125 @@ def _package_failure(error): tokens and machine-specific absolute paths before it reaches events or a shared diagnostic report. """ - value = getattr(error, 'stderr', None) or getattr(error, 'stdout', None) or str(error) - if isinstance(value, bytes): - value = value.decode('utf-8', errors='replace') - value = _ANSI_ESCAPE.sub('', str(value)).replace('\x00', '') - value = re.sub(r'(?i)(https?://)[^\s/@]+:[^\s/@]+@', r'\1@', value) - value = re.sub(r'(?i)(Bearer\s+)[A-Za-z0-9._~+/-]+', r'\1', value) - value = re.sub(r'(?i)(\b[\w-]{0,64}(?:token|password|secret|api[_-]?key)\s*[=:]\s*)[^\s,;&]+', - r'\1', value) - # Keep a stable path marker without exposing usernames, drives or mounts. - value = re.sub(r'(?\.{1,2}/[^\s"\'<>;,)\]}]*)|' - r'(?:[A-Za-z]:[\\/]|/)([^\s"\'<>;,)\]}]*))', - lambda match: match.group('relative') if match.group('relative') is not None - and not re.search(r'[\\/]', match.group('relative').split('/', 1)[1]) else - '/' + re.split(r'[\\/]', match.group(0).rstrip('\\/'))[-1], value) + value = _redact(getattr(error, 'stderr', None) or getattr(error, 'stdout', None) or str(error)) lines = [line.strip() for line in value.splitlines() if line.strip()] detail = '\n'.join(lines[-12:])[-4096:] if lines else 'no package-manager output' summary = next((line for line in lines if line), detail).strip()[:320] return detail, summary -def _package_attempt(error, source, route): +# What each source returned when a download fails on every source: Git sources, +# the Python runtime, packages and files. One short, redacted line per source +# and route keeps a report actionable (network, proxy, certificate, security +# software or disk) without the full command output. +SOURCE_LINES = 8 +SOURCES_TRIED = '\nSources tried:' +# Checked in order on each output line; the last line that names a cause wins. +FAILURE_CLASSES = ( + ('disk-full', r'no space left on device|winerror 112\b|disk (?:is )?full|not enough space on the disk|errno 28\b'), + ('proxy', r'proxy|\b407\b'), + ('dns', r'could not resolve|name or service not known|nodename nor servname|getaddrinfo|dns error|' + r'failed to lookup address|name resolution|no such host|winerror 11001\b'), + ('timeout', r'timed out|timeout|operation too slow'), + ('refused', r'connection refused|actively refused|winerror 10061\b|econnrefused'), + ('unreachable', r'network is unreachable|no route to host|host is unreachable|winerror 1005[01]\b|winerror 10065\b'), + ('reset', r'connection reset|reset by peer|winerror 10054\b|forcibly closed|remote end hung up|early eof|' + r'unexpected eof|rpc failed|ssl_error_syscall|connection (?:was )?(?:closed|aborted)'), + ('tls', r'certificate|ssl|tls|schannel|secure channel|handshake|sec_e_|crypt_e_'), + ('connect', r"failed to connect|couldn't connect|could not connect|unable to connect|connect error|\(connect\)"), + ('access-denied', r'access is denied|permission denied|operation not permitted|winerror 5\b|winerror 225\b|virus'), + ('http', r'(?:returned error|status(?: code)?|http status [a-z ]+error)\D{0,4}\(?[1-5]\d\d\b'), +) +_CURL_CLASSES = dict([*((code, 'tls') for code in (35, 51, 53, 54, 58, 59, 60, 64, 66, 77, 80, 82, 83, 90, 91)), + *((code, 'reset') for code in (16, 18, 52, 55, 56, 92, 95)), + (5, 'proxy'), (97, 'proxy'), (6, 'dns'), (7, 'connect'), (28, 'timeout'), (22, 'http'), + (23, 'write'), (47, 'redirect'), (63, 'size')]) +# download_here's own reasons for stopping a transfer. +_REASON_CLASSES = {'no-progress': 'no-progress', 'slow-transfer': 'slow', 'faster-source': 'faster-source', + 'integrity-mismatch': 'integrity', 'response-size-exceeded': 'size', + 'source-does-not-support-range': 'no-resume', 'redirect-refused': 'redirect'} + + +def _host(address): + """The host alone: never the user information, path or query of a URL.""" + try: + return (urlsplit(str(address)).hostname or '')[:80] + except ValueError: + return '' + + +def source_host(family, name, env=None): + try: + return _host(source_url(family, name, env)) + except (KeyError, ValueError): + return '' + + +def exit_code(error): + """A failed command's exit code: its own, or the one a failed setup step names.""" + if not isinstance(error, str) and getattr(error, 'returncode', None) is not None: + return error.returncode + found = re.match(r'(?:[\w.]+: )?Failed step: [^\n]*\(exit code (-?\d+)\)', str(error)) + return int(found[1]) if found else None + + +def failure_reason(error): + """(class, one short redacted line) for a failed source attempt.""" + text = error if isinstance(error, str) else (getattr(error, 'stderr', None) or getattr(error, 'stdout', None) or str(error)) + if isinstance(text, (bytes, bytearray)): + text = bytes(text).decode('utf-8', errors='replace') + head, _, output = str(text).partition('\n\nFull step output:\n') + lines = [line.strip() for line in _redact(output or head).splitlines() if line.strip()] + returncode = exit_code(error) + # A word that held a credential or a variable's value goes as a whole. + lines = [re.sub(r'\S*\S*', '', line) for line in lines] + for line in reversed(lines): + for name, pattern in FAILURE_CLASSES: + if re.search(pattern, line, re.I): + return name, line[:160] + plain_lines = [line for line in lines if not re.match(r'(?:Failed step|Retained step log|Full step output|Stopped):', line)] + return ('exit' if returncode not in (None, 0) else 'other'), (plain_lines[-1][:160] if plain_lines else '') + + +def _source_row(source, route, host, error_class, message, seconds, code='', **extra): + row = dict(source=source, route=route, host=host, error_class=error_class, message=message, + elapsed_seconds=round(max(0., seconds), 1)) + if code: + row['code'] = code + row.update(extra) + return row + + +def sources_text(rows, item=''): + """The lines a copied error and a report carry: one per source and route tried. + + item names what was downloaded (a dependency folder's name), never a path. + """ + lines = [] + for row in list(rows)[-SOURCE_LINES:]: + head = '%s/%s' % (row.get('source', 'unknown'), row.get('route', 'unknown')) + if row.get('host'): + head += ' (%s)' % row['host'] + parts = [row.get('error_class') or 'other'] + if row.get('code'): + parts.append(str(row['code'])) + elif row.get('returncode') not in (None, 0): + parts.append('exit %s' % row['returncode']) + parts.append('%.1f s' % float(row.get('elapsed_seconds') or 0)) + message = str(row.get('message') or '') + lines.append(' - %s: %s' % (head, ', '.join(parts)) + (': ' + message if message else '')) + if not lines: + return '' + return (SOURCES_TRIED[:-1] + (' for %s:' % item if item else ':')) + '\n' + '\n'.join(lines) + + +def _package_attempt(error, source, route, host='', seconds=0.): detail, summary = _package_failure(error) - return dict(source=source, route=route, error_type=type(error).__name__, - returncode=getattr(error, 'returncode', None), detail=detail, summary=summary) + error_class, message = failure_reason(error) + returncode = getattr(error, 'returncode', None) + code = exit_code(error) if returncode is None else None + return _source_row(source, route, host, error_class, message, seconds, '' if code is None else 'exit %d' % code, + error_type=type(error).__name__, returncode=returncode, detail=detail, summary=summary) def download_environment(environ, retry_mode='fast'): @@ -691,6 +826,7 @@ def download_command(network, family, run, env=None, *, category=None, extra_sou if operation == 'package' or retry_mode == 'patient': event(network, category=category, source=name, route=route, action=operation + '-attempt', **retry_details) + started = time.monotonic() try: result = run(name, route, retry_mode) source_health(network, family, name, True) @@ -700,11 +836,14 @@ def download_command(network, family, run, env=None, *, category=None, extra_sou result='success', attempts=len(attempts)-round_start+1) return result except (RuntimeError, subprocess.CalledProcessError) as error: - attempt = dict(_package_attempt(error, name, route), retry_mode=retry_mode) + attempt = dict(_package_attempt(error, name, route, source_host(family, name, env), + time.monotonic() - started), retry_mode=retry_mode) attempts.append(attempt) event(network, category=category, source=name, route=route, action=operation + '-failed', error_type=attempt['error_type'], returncode=attempt['returncode'], - detail=attempt['detail'], summary=attempt['summary'], **retry_details) + detail=attempt['detail'], summary=attempt['summary'], host=attempt['host'], + error_class=attempt['error_class'], message=attempt['message'], + elapsed_seconds=attempt['elapsed_seconds'], **retry_details) detail = attempt['detail'] if (not retryable(detail) or (retry_mode == 'patient' and not slow_download_failure(detail))): @@ -898,12 +1037,21 @@ def download_here(candidates, path, expected, progress=None, *, network=None, en path = Path(path) updating = category == 'launcher-update' integrity_failed = False - attempts = {} + attempts, failures, spent = {}, {}, {} - def note_failure(source, route, reason, status=''): + def note_failure(source, route, reason, status='', address='', message=''): # The last reason each source and route gave, for the final error. attempts.pop((source, route), None) # a repeat moves to the end, so the last 8 are the latest - attempts[source, route] = reason + (' (HTTP %s)' % status if str(status).isdigit() and status != '000' else '') + http = str(status).isdigit() and status != '000' + attempts[source, route] = reason + (' (HTTP %s)' % status if http else '') + code = int(reason[5:]) if re.fullmatch(r'curl-\d+', reason) else None + error_class = (_REASON_CLASSES.get(reason) or ('http' if http and code == 22 else _CURL_CLASSES.get(code)) + or (failure_reason(message)[0] if message else 'exit')) + failures.pop((source, route), None) + failures[source, route] = _source_row(source, route, _host(address), error_class, + failure_reason(message)[1] if message else '', + spent.get((source, route), 0.), attempts[source, route], + returncode=code, http_status=status if http else '') failed_sources = set() transferred, high_water, last_rate = 0, 0, 0. @@ -939,7 +1087,7 @@ def publish(candidate): if updating and progress: progress(path.stat().st_size, size or 0, last_rate) - model = category in ('models', 'vdn-models', 'edge-models', 'image-models') + model = category in ('models', 'vdn-models', 'edge-models', 'encoder-models', 'image-models') stall_seconds = stall_seconds if stall_seconds is not None else 120 if model else 20 def matches(candidate, source='local'): @@ -955,7 +1103,7 @@ def verification(done, total): check() if model: report(done, total) - matched = hash_file(candidate, algorithm, git_blob, discard_cache=category in ('models', 'vdn-models', 'image-models'), + matched = hash_file(candidate, algorithm, git_blob, discard_cache=category in ('models', 'vdn-models', 'encoder-models', 'image-models'), progress=verification if model or check else None) == expected if matched and verify is not None: matched = verify(candidate) @@ -1145,7 +1293,8 @@ def remember_source(): status = process.stdout.read(65536 if validate_url else 16).decode(errors='replace') process.stdout.close() # --silent --show-error keeps this to a line or two. - curl_message = _package_failure(SimpleNamespace(stderr=process.stderr.read(2048)))[1] if process.returncode else '' + stderr = process.stderr.read(2048) if process.returncode else b'' + curl_message = _package_failure(SimpleNamespace(stderr=stderr))[1] if stderr.strip() else '' process.stderr.close() if validate_url: status, destination = _redirect_fields(status) @@ -1157,6 +1306,7 @@ def remember_source(): url = urljoin(url, destination) continue current_size = partial.stat().st_size if partial.exists() else 0 + spent[name, route] = spent.get((name, route), 0.) + time.monotonic() - started if slow_seconds is not None: # A later failure at another address of the same source and # route must not erase the rate it was measured at. @@ -1177,7 +1327,7 @@ def remember_source(): integrity_failed = True failed_sources.add(name) source_health(network, category, name, False) - note_failure(name, route, 'response-size-exceeded') + note_failure(name, route, 'response-size-exceeded', address=url) event(network, category=category, source=name, file=path.name, action='fallback', method='curl', reason='response-size-exceeded', observed_bytes=oversized, expected_bytes=size, resume_bytes=offset) @@ -1205,12 +1355,12 @@ def remember_source(): continue failed_sources.add(name) source_health(network, category, name, False) - note_failure(name, route, 'integrity-mismatch') + note_failure(name, route, 'integrity-mismatch', address=url) event(network, category=category, source=name, file=path.name, action='fallback', reason='integrity-mismatch') break if offset and (process.returncode == 33 or process.returncode == 22 and status == '416') and restart == 0: if model or keep_partial: - note_failure(name, route, 'source-does-not-support-range') + note_failure(name, route, 'source-does-not-support-range', address=url) event(network, category=category, source=name, file=path.name, action='resume-refused', resume_bytes=offset, reason='source-does-not-support-range') break @@ -1231,7 +1381,7 @@ def remember_source(): if updating and stalled: failed_sources.add(name) reason = 'no-progress' if stalled else 'slow-transfer' if slow else 'faster-source' if faster else 'curl-' + str(process.returncode) - note_failure(name, route, reason, status) + note_failure(name, route, reason, status, url, curl_message) alternatives = route_order(network, category, name, env) if alternatives.index(route) + 1 < len(alternatives): event(network, category=category, source=name, route=alternatives[alternatives.index(route)+1], @@ -1248,11 +1398,9 @@ def remember_source(): retained_bytes=partial.stat().st_size, reason='resume-unavailable') retain_partial(partial, 'restart-approved') message = 'Every download source failed for %s. Check your network or choose another connection mode in Downloads. Partial files retained' % path.name - if attempts: - message += '. Attempts: ' + '; '.join('%s/%s: %s' % (source, route, reason) - for (source, route), reason in list(attempts.items())[-8:]) - error = DownloadError(message) + error = DownloadError(message + sources_text(failures.values())) error.attempts = [dict(source=source, route=route, reason=reason) for (source, route), reason in attempts.items()] + error.package_attempts = list(failures.values()) error.integrity = integrity_failed raise error @@ -1647,7 +1795,9 @@ def execute(label, command): return run(label, command, env=env) result = subprocess_output.run_external(list(map(str, command)), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **hidden_console()) if result.returncode: - raise RuntimeError(result.stdout[-2500:]) + failure = RuntimeError(result.stdout[-2500:]) + failure.returncode = result.returncode + raise failure io_path(target.parent).mkdir(parents=True, exist_ok=True) candidates = urls(dict(network, sources=dict(network.get('sources', {}), github=network.get('sources', {}).get('git', [{'id': 'official'}]))), 'github', url, env) configured_env = env @@ -1657,6 +1807,7 @@ def execute(label, command): remove_tree(stale) except OSError: pass # Held by another program; the download goes on and a later run tries again. + attempts = [] for name, endpoint, route in [(name, endpoint, route) for name, endpoint in candidates for route in route_order(network, 'git', name, configured_env)]: env = route_environment(network, 'git', name, configured_env, route) @@ -1664,6 +1815,7 @@ def execute(label, command): event(network, category='git', source=name, route=route, file=target.name, action='attempt', commit=commit) scratch = git_scratch_folder() if git_folder_too_deep(temporary) else None work = scratch / 'repo' if scratch else temporary + started = time.monotonic() try: execute(target.name + '-init-' + name, ['git', 'init', '--quiet', work]) # Never depend on a user's global CRLF conversion or long-path @@ -1722,7 +1874,13 @@ def execute(label, command): if not retryable(error): raise source_health(network, 'git', name, False) - event(network, category='git', source=name, route=route, file=target.name, action='fallback', reason='network-fetch-failed') + error_class, message = failure_reason(error) + attempt = _source_row(name, route, _host(endpoint), error_class, message, time.monotonic() - started, + returncode=exit_code(error)) + attempts.append(attempt) + event(network, category='git', source=name, route=route, file=target.name, action='fallback', + reason='network-fetch-failed', host=attempt['host'], error_class=error_class, message=message, + returncode=attempt['returncode'], elapsed_seconds=attempt['elapsed_seconds']) finally: # Best effort: a file still held by antivirus or git.exe must not replace the # error being handled or stop the fallback to the next source. @@ -1732,4 +1890,7 @@ def execute(label, command): remove_tree(leftover) except OSError: pass - raise RuntimeError('All Git sources failed. Check your network or connection mode in Downloads.') + failure = RuntimeError('All Git sources failed. Check your network or connection mode in Downloads.' + + sources_text(attempts, target.name)) + failure.package_attempts = attempts + raise failure diff --git a/freevideo_engine/processes.py b/freevideo_engine/processes.py index 54c3dcef..0fa00e5b 100644 --- a/freevideo_engine/processes.py +++ b/freevideo_engine/processes.py @@ -243,6 +243,28 @@ def restore_handlers(previous): signal.signal(name, handler) +def pin_attention_priority(): + """Fix the order in which native attention tries its kernels. + + The text encoder's attention changes this process-wide order and never + restores it, so the text refiner picked flash attention after an encode in + the same process but cuDNN attention in a fresh one on Blackwell, with + different bf16 results for the same input. Call it once CUDA is + initialized, before native attention runs: flash, memory-efficient, math, + cuDNN. The first kernel selection on a device installs the default order + and overrides an earlier setting, so one selection is made first. + """ + try: + import torch + if torch.cuda.is_available(): + probe = torch.empty(1, 1, 16, 64, device='cuda', dtype=torch.bfloat16) + torch._fused_sdp_choice(probe, probe, probe) + torch._C._set_sdp_priority_order([2, 1, 0, 3, 4]) + torch._C._set_sdp_priority_order([1, 2, 0, 3, 4]) + except (ImportError, AttributeError, RuntimeError, TypeError): + pass + + @contextmanager def worker_signals(): """Let Windows workers finalize stage reports before the Job timeout.""" diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 5d87a38b..c201f9c7 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,39 +1,76 @@ { "schema": 1, - "product_version": "0.4.25", + "product_version": "0.4.26", "en": { - "summary": "An audio clip can be used as is, so the video carries that clip as its sound; a tutorial and feature examples explain each feature with an example that can be filled in directly; after a video finishes, the model stays loaded and the next video starts without reloading.", + "summary": "Recent videos open at every window width, when every download source fails the launcher shows the reason for each source, and replacing a person runs on 16 GB Macs.", "changes": [ - "Studio: audio cards offer \"Reference\" or \"Use as is\". With \"Use as is\", the clip is written into the video unchanged as its sound, the picture follows it, and the duration follows the clip and is locked. \"Use as is\" is not available with \"Preview first\" or when extending a video. Replace person downloads its model when it is missing.", - "Guides: \"Tutorial\" in Settings has chapters with pictures for each feature, and \"Try it\" fills a chapter's example into the creation panel. \"Feature examples\" shows each feature as a looping preview of the input and the result, with \"Use this example\". The examples are shown once on first use, and after an update only the new ones are shown.", - "Memory and speed: after a video finishes, the model stays on the graphics card and in memory with no time limit, so the next video starts without reloading. It is released when memory runs low, when another program starts using more video memory, or before the picture or prompt model starts; when memory is tight, as much of it is kept as still leaves room for the next request.", - "Speed: the first video of a new length or size starts sooner, because three of the model's kernels are compiled once for every length, size and head split. Requests with LoRA adapters run faster, because each adapter's input projection is computed once per layer instead of once for every group of attention heads. A slowdown switched on automatically after the app was closed or stopped unexpectedly now lifts after one completed generation instead of three; a slowdown caused by a reported GPU error still needs three.", - "Generation: pressing Generate with an empty prompt shows \"The prompt is empty.\" next to the button, with \"Generate anyway\". In the editor, broken characters left in a description by a cut or a paste are removed before the part is sent. On a computer with several graphics cards, queued videos run at the same time, one on each card.", - "Workflows: a saved workflow opens ready to run when a value on its FreeVideo node is no longer accepted; that value returns to its default and the rest of the workflow is unchanged.", - "Creations: a video can be marked as a favorite with the heart button, and \"All\" and \"Favorites\" at the top switch between every video and favorites only; the choice is kept for the next time Creations opens.", - "Autopilot: a chat can be renamed with \"Rename\" in its menu. The name is kept after a restart, and clearing it shows the first message as the title again.", - "Install: on Linux, setup from the launcher downloads Git and a C++ compiler privately when they are missing instead of asking for sudo; only basic tools such as curl still come from the system, and the launcher shows the command to install them. A graphics driver older than 580 gets its notice with the detected card even when nvidia-smi cannot answer the setup query. When the system is older than 2022 or lacks the graphics libraries its window needs, the Linux desktop app shows the reason and what to do instead of closing without a message.", - "Updates: on every platform, update downloads can use the mirror in China, and a slow source is switched automatically, continuing the download.", - "Diagnostics: on Mac, a generation refused at the start for lack of free memory has its own row in the error panel, and the report records the memory at that moment. Feedback for a video in Creations that cannot be opened includes the player's error code and the file's response. Diagnostic reports record which graphics card the run used." + "Creation panel: \"Recent videos\" can be opened at every window width; in a narrow window the list opens over the preview. The @ character menu shows each character's face close-up instead of the full-length picture. When a part length is refused, the length panel says whether it is too short or too long instead of asking to try again. When the connection to FreeVideo drops briefly, the queue no longer reports browser extension interference; a loop that cannot submit its next job stops with the error instead of retrying.", + "Install: when every source for a download fails, the launcher's error card lists each source with its reason and time, and the copied details and the diagnostic report include the same list. Git errors name the folder of the dependency. Account names, passwords, tokens and local paths are removed from the listed addresses. The video text encoder can also be downloaded from ModelScope, so a first install no longer depends on Hugging Face alone for this file; a download that fails the integrity check falls back to Hugging Face.", + "Prompt enhancement: when the connection to FreeVideo is lost, a connection-lost message is shown instead of the browser's raw error, and a failed progress check is retried before the rewrite stops. After a rewrite, characters mentioned with @ are still shown by name in the prompt box. When the prompt or references change during a rewrite, a note asks for a new rewrite instead of an error with a report button.", + "Prompt enhancement: the rewrite tab shows the scene description followed by a separate \"Sound:\" section instead of the model's field names. With several shots, each shot is its own paragraph labelled with its number and start time. A rewrite that is not edited is submitted exactly as the model wrote it. The default prompt in a prompt box that has not been edited uses the same layout and is also submitted unchanged. A prompt filled in with \"Reuse settings\" or opened from a workflow saved by an earlier version also uses this layout and, when not edited, is submitted unchanged.", + "Editor: when the last edit can no longer be found, a blank edit opens with a message saying so; clicking the edit name at the top opens another edit.", + "Settings: a new AI Agent section chooses whether an agent asks before starting a film and before a long reshoot, and how many minutes make a reshoot long. Both ask by default. When an agent's white-model scene fails, the agent receives the reason instead of a generic connection error.", + "Install: when the NVIDIA driver is installed but not running, for example right after it was installed, the launcher's error card asks for a restart instead of showing the raw detection error. When the NVIDIA driver is missing, the card adds one line naming the graphics card Windows lists; when Windows lists only AMD or Intel graphics, the card says FreeVideo supports NVIDIA graphics cards only.", + "Video generation: when a generation falls back to a freshly started worker, the result is the same as in the worker that was already running. After a driver or FreeVideo update, or on another graphics card in the same computer, the first generation no longer fails because an attention method the card cannot run was chosen.", + "Image generation: when Windows runs out of virtual memory while loading the image model, the failure card says so and suggests closing other apps or increasing virtual memory.", + "Linux: when no file manager can open a folder, the launcher and the image history show a message, and the image history also shows the images folder. The default save location follows the desktop's Documents folder. Curly apostrophes in English text are drawn at normal width instead of full width.", + "Mac: encoding a reference video (replace person, extending a video) needs about half the unified memory per tile, so it runs on 16 GB Macs where it stopped with a unified-memory error, including at the lowest resolution. Results are unchanged.", + "Mac: before installing, the launcher checks whether the disk of the chosen folder supports the file operations the Python environment needs. Without hard links the installation copies files instead; when links or renaming are not supported, a card asks for a folder on the Mac's built-in disk before anything is installed." ] }, "zh": { - "summary": "音频可以原样使用,视频直接以这段音频为声音;新增教程和功能示例,逐项介绍功能,示例可以直接填入;视频完成后模型保留,下一条视频无需重新加载。", + "summary": "任何窗口宽度下都能打开“最近作品”,依赖下载在所有源都失败时,启动器列出每个源的原因;16 GB 的 Mac 上也能替换人物。", "changes": [ - "工作室:音频卡片可选“参考”或“原样使用”。选择“原样使用”时,这段音频原样写入视频作为声音,画面随之生成,时长跟随音频并锁定。开启“先出预览”或接着生成时,不能选择“原样使用”。替换人物缺少模型时会自动下载。", - "指引:设置中的“教程”按功能分章,配有图片,点“试一试”可把该章的示例填入创作面板。“功能示例”逐项以循环预览展示输入和成片,并可点“用这个示例”。首次使用时会依次展示一遍,更新后只展示新增的示例。", - "内存与速度:视频完成后,模型会一直保留在显卡和内存中,不再有时间限制,下一条视频无需重新加载。内存不足、其他程序占用的显存增加,或图片和提示词模型启动前,模型会被释放;内存紧张时,在保证下一次请求放得下的前提下尽量多保留。", - "速度:换新的时长或尺寸后,第一条视频更早开始,模型的三个内核对所有时长、尺寸和分头方式只编译一次。挂 LoRA 的请求更快,每个适配器的输入降维乘法每层只算一次,不再每个注意力头组各算一次。应用被意外关闭或结束后自动开启的放慢,完成 1 次生成就解除,不再需要 3 次;显卡报告错误引起的放慢仍需 3 次。", - "生成:提示词为空时点生成,按钮旁会显示“提示词为空。”,并可点“继续生成”。剪辑视图中,剪切或粘贴在描述里留下的残缺字符会在发送前去掉。有多张显卡的电脑上,排队的视频会同时生成,每张显卡一条。", - "工作流:另存的工作流中,FreeVideo 节点上有不再接受的取值时,打开即可运行;该取值恢复为默认值,工作流其余内容不变。", - "作品库:可以用心形按钮收藏视频,顶部的“全部”和“收藏”可切换显示全部视频或只显示收藏;下次打开作品库时保留所选。", - "AI 全自动:对话可以在菜单中“重命名”。名称在重启后保留,清空名称后重新以第一句话作为标题。", - "安装:Linux 上从启动器安装时,缺少 Git 和 C++ 编译器会改为私有下载,不再需要 sudo;只有 curl 等基础工具仍需系统提供,启动器会给出安装命令。即使 nvidia-smi 无法响应安装检查的查询,低于 580 的显卡驱动也会显示带检测到显卡的提示。系统早于 2022 年或缺少窗口所需的图形库时,Linux 桌面版会说明原因和处理办法,不再无提示地退出。", - "更新:所有平台的更新下载都可走国内镜像,下载源过慢时自动换源续传。", - "诊断:Mac 上因可用内存不足而在开始前被拒绝的生成,在错误面板中单独一行说明,报告会记录当时的内存。作品库中视频打不开时,反馈会附上播放器错误码和文件响应。诊断报告会记录这次运行使用的显卡。" + "创作面板:任何窗口宽度下都能打开“最近作品”;窗口较窄时,列表盖在预览上打开。输入 @ 时,角色头像改为脸部特写,不再是全身图。每段时长被拒绝时,长度面板会说明是过短还是过长,不再提示重试。与 FreeVideo 的连接短暂中断时,队列不再提示浏览器扩展干扰;循环生成提交下一个任务失败时,循环停止并显示错误,不再重试。", + "安装:依赖下载在所有源都失败时,启动器错误卡片逐个列出每个源的失败原因和耗时,复制的详情和诊断报告中也附有这份列表。Git 报错会写明对应依赖的文件夹。列出的地址会去掉账号、密码、令牌和本地路径。视频文本编码器增加魔搭下载源,首次安装下载这个文件时不再只依赖 Hugging Face;下载的文件校验不通过时改从 Hugging Face 下载。", + "提示词增强:与 FreeVideo 的连接中断时,显示连接断开的提示,而不是浏览器的原始错误;改写进度读取失败时先重试,不再立即中止。改写后,人物仍以“@人物名”显示在提示词框中。改写过程中原文或素材有改动时,显示一条提醒,请按新内容重新改写,不再显示错误和报告按钮。", + "提示词增强:“改写”页签只显示画面描述,下面单独列出“声音:”段,不再显示模型的字段名;有多个镜头时,每个镜头单独成段,标出镜头编号和开始时间。未修改的改写结果按模型原文提交。提示词框里未改动的默认提示词也按同样方式显示,提交时仍用原文。用“复用设置”填入的提示词和旧版本保存的工作流里的提示词同样按这种方式显示,未修改时按原文提交。", + "剪辑视图:上次的剪辑已找不到时,打开空白剪辑并给出说明;点击顶部的剪辑名可打开其他剪辑。", + "设置:新增“AI Agent”区块,可选择 Agent 在开拍整部片前、以及耗时较长的重拍前是否先询问,并设置重拍达到多少分钟算较长。两项默认都询问。Agent 搭建的白模场景失败时,Agent 收到具体原因,不再是笼统的连接错误。", + "安装:NVIDIA 显卡驱动已安装但未运行时(例如刚装好还未重启),启动器错误卡片提示重启电脑,不再显示原始的检测失败信息。未找到 NVIDIA 显卡驱动时,提示卡新增一行,写出系统列出的显卡;系统只列出 AMD 或 Intel 显卡时,卡片说明 FreeVideo 只支持 NVIDIA 显卡。", + "视频生成:生成改由新启动的进程完成时,结果与原先运行中的进程相同。驱动或 FreeVideo 更新后,或换用同一台电脑里的另一张显卡时,首次生成不再因选用这张显卡无法运行的注意力方式而失败。", + "图像生成:Windows 虚拟内存不足、无法载入图像模型时,失败卡片会说明原因,并建议关闭其他程序或调大虚拟内存。", + "Linux:没有可用的文件管理器打开文件夹时,启动器和图片历史给出提示,图片历史同时显示图片文件夹的路径;默认保存位置跟随桌面设置的文档文件夹。英文中的弯撇号按西文宽度显示,不再显示为全角。", + "Mac:编码参考视频(替换人物、接着生成)时,每个瓦片所需的统一内存约减半,在 16 GB 的 Mac 上不再因统一内存不足而中止,最低分辨率下也是如此;结果不变。", + "Mac:安装开始前,启动器先检查所选文件夹所在磁盘是否支持 Python 运行环境所需的文件操作。不支持硬链接时改用复制继续安装;不支持链接或改名时,在安装任何内容之前提示选择 Mac 内置硬盘上的文件夹。" ] }, "history": [ + { + "product_version": "0.4.25", + "en": { + "summary": "An audio clip can be used as is, so the video carries that clip as its sound; a tutorial and feature examples explain each feature with an example that can be filled in directly; after a video finishes, the model stays loaded and the next video starts without reloading.", + "changes": [ + "Studio: audio cards offer \"Reference\" or \"Use as is\". With \"Use as is\", the clip is written into the video unchanged as its sound, the picture follows it, and the duration follows the clip and is locked. \"Use as is\" is not available with \"Preview first\" or when extending a video. Replace person downloads its model when it is missing.", + "Guides: \"Tutorial\" in Settings has chapters with pictures for each feature, and \"Try it\" fills a chapter's example into the creation panel. \"Feature examples\" shows each feature as a looping preview of the input and the result, with \"Use this example\". The examples are shown once on first use, and after an update only the new ones are shown.", + "Memory and speed: after a video finishes, the model stays on the graphics card and in memory with no time limit, so the next video starts without reloading. It is released when memory runs low, when another program starts using more video memory, or before the picture or prompt model starts; when memory is tight, as much of it is kept as still leaves room for the next request.", + "Speed: the first video of a new length or size starts sooner, because three of the model's kernels are compiled once for every length, size and head split. Requests with LoRA adapters run faster, because each adapter's input projection is computed once per layer instead of once for every group of attention heads. A slowdown switched on automatically after the app was closed or stopped unexpectedly now lifts after one completed generation instead of three; a slowdown caused by a reported GPU error still needs three.", + "Generation: pressing Generate with an empty prompt shows \"The prompt is empty.\" next to the button, with \"Generate anyway\". In the editor, broken characters left in a description by a cut or a paste are removed before the part is sent. On a computer with several graphics cards, queued videos run at the same time, one on each card.", + "Workflows: a saved workflow opens ready to run when a value on its FreeVideo node is no longer accepted; that value returns to its default and the rest of the workflow is unchanged.", + "Creations: a video can be marked as a favorite with the heart button, and \"All\" and \"Favorites\" at the top switch between every video and favorites only; the choice is kept for the next time Creations opens.", + "Autopilot: a chat can be renamed with \"Rename\" in its menu. The name is kept after a restart, and clearing it shows the first message as the title again.", + "Install: on Linux, setup from the launcher downloads Git and a C++ compiler privately when they are missing instead of asking for sudo; only basic tools such as curl still come from the system, and the launcher shows the command to install them. A graphics driver older than 580 gets its notice with the detected card even when nvidia-smi cannot answer the setup query. When the system is older than 2022 or lacks the graphics libraries its window needs, the Linux desktop app shows the reason and what to do instead of closing without a message.", + "Updates: on every platform, update downloads can use the mirror in China, and a slow source is switched automatically, continuing the download.", + "Diagnostics: on Mac, a generation refused at the start for lack of free memory has its own row in the error panel, and the report records the memory at that moment. Feedback for a video in Creations that cannot be opened includes the player's error code and the file's response. Diagnostic reports record which graphics card the run used." + ] + }, + "zh": { + "summary": "音频可以原样使用,视频直接以这段音频为声音;新增教程和功能示例,逐项介绍功能,示例可以直接填入;视频完成后模型保留,下一条视频无需重新加载。", + "changes": [ + "工作室:音频卡片可选“参考”或“原样使用”。选择“原样使用”时,这段音频原样写入视频作为声音,画面随之生成,时长跟随音频并锁定。开启“先出预览”或接着生成时,不能选择“原样使用”。替换人物缺少模型时会自动下载。", + "指引:设置中的“教程”按功能分章,配有图片,点“试一试”可把该章的示例填入创作面板。“功能示例”逐项以循环预览展示输入和成片,并可点“用这个示例”。首次使用时会依次展示一遍,更新后只展示新增的示例。", + "内存与速度:视频完成后,模型会一直保留在显卡和内存中,不再有时间限制,下一条视频无需重新加载。内存不足、其他程序占用的显存增加,或图片和提示词模型启动前,模型会被释放;内存紧张时,在保证下一次请求放得下的前提下尽量多保留。", + "速度:换新的时长或尺寸后,第一条视频更早开始,模型的三个内核对所有时长、尺寸和分头方式只编译一次。挂 LoRA 的请求更快,每个适配器的输入降维乘法每层只算一次,不再每个注意力头组各算一次。应用被意外关闭或结束后自动开启的放慢,完成 1 次生成就解除,不再需要 3 次;显卡报告错误引起的放慢仍需 3 次。", + "生成:提示词为空时点生成,按钮旁会显示“提示词为空。”,并可点“继续生成”。剪辑视图中,剪切或粘贴在描述里留下的残缺字符会在发送前去掉。有多张显卡的电脑上,排队的视频会同时生成,每张显卡一条。", + "工作流:另存的工作流中,FreeVideo 节点上有不再接受的取值时,打开即可运行;该取值恢复为默认值,工作流其余内容不变。", + "作品库:可以用心形按钮收藏视频,顶部的“全部”和“收藏”可切换显示全部视频或只显示收藏;下次打开作品库时保留所选。", + "AI 全自动:对话可以在菜单中“重命名”。名称在重启后保留,清空名称后重新以第一句话作为标题。", + "安装:Linux 上从启动器安装时,缺少 Git 和 C++ 编译器会改为私有下载,不再需要 sudo;只有 curl 等基础工具仍需系统提供,启动器会给出安装命令。即使 nvidia-smi 无法响应安装检查的查询,低于 580 的显卡驱动也会显示带检测到显卡的提示。系统早于 2022 年或缺少窗口所需的图形库时,Linux 桌面版会说明原因和处理办法,不再无提示地退出。", + "更新:所有平台的更新下载都可走国内镜像,下载源过慢时自动换源续传。", + "诊断:Mac 上因可用内存不足而在开始前被拒绝的生成,在错误面板中单独一行说明,报告会记录当时的内存。作品库中视频打不开时,反馈会附上播放器错误码和文件响应。诊断报告会记录这次运行使用的显卡。" + ] + } + }, { "product_version": "0.4.24", "en": { @@ -185,70 +222,6 @@ "对话中修改了正在等待 Agent 的消息后,提示不再按错误显示、不再带“报告问题”按钮,改为普通状态提示。" ] } - }, - { - "product_version": "0.4.18", - "en": { - "summary": "FreeVideo can be installed into deeply nested folders on Windows; many failure messages state the cause and the next step; Windows computers with little free memory can complete generation; consecutive requests run faster on computers with ample memory.", - "changes": [ - "Install: FreeVideo can be installed into deeply nested folders on Windows; when an update cannot be installed and the previous version is restored, the notice follows the interface language.", - "Launcher: errors state the step that failed and what to do next, and Report a problem includes a line about the environment; when the saved installation folder cannot be opened, Start opens the setup page with the saved paths and an explanation; a graphics driver older than 580 gets a notice with the installed version and the update steps; a damaged launcher settings file is kept as a copy before it is replaced; the launcher keeps working when the prompt model file list is missing, and that row appears once the list is available.", - "Model downloads: slow sources are retried with a longer time limit, and the last failure reason of each source and route is listed; when a source has problems, the launcher shows a dedicated card with a button to switch sources; the image model has its own download list with links and checksums, exported to a separate folder.", - "Failure messages: a failed video generation names the stage where it stopped (loading, sampling or decoding); failure feedback starts with the last error; automatic subtitles, subtitle downloads, media import and subtitle import in the editor state the reason when they fail, and GBK-encoded SRT subtitles can be imported; image generation, adding workflows and share export state when the disk is full; an unreadable queued reference image prompts for a different image; a failed part of a long video shows a short status, with details in the error card and the name of any missing file.", - "Generation: fixed an error when submitting a generation; when first/last frames and references cannot be combined, the failure card says which media to turn off; Generate with AI explains when the media list is full at 32 items; the estimate before generation marks first-time preparation, and a slow estimate gives a reason and a remedy; a queued request shows its wait in minutes or the number of requests ahead; a queued export shows Queued on its button; LoRA files in more naming styles are recognized, and an acceleration LoRA made for the original model gets its own explanation.", - "Prompt enhancement: scenes with audio, connected nodes or videos can be enhanced, with audio and connected nodes skipped and one frame read from each video; with reference images, a rewrite that leaves out an image reference is sent back to the model once to add it.", - "Reference images: uploading and reading show Uploading reference image and Reading reference image N%; the + button in the editor shows a spinner while loading; library import shows uploading, queued and processing in turn, and finishes once progress reaches 100%.", - "Autopilot: it can be stopped while waiting for a reply and shows its current action; after three minutes without a progress update it says generation is still running; a background tab title starts with Confirmation needed, Failed or Done; Edit prompt puts the summary back in the input box; the confirmation card shows the size and duration; when the entry check fails, the entry stays and the check can be retried; an unreadable saved conversation or film leaves the rest available; failure reasons are readable in the English interface.", - "Multi-shot films: for long videos and multi-shot films made by Autopilot, music is added on request: when requested, one continuous score is laid over the whole film after the shots are joined, and a music file from the user can be used directly.", - "Create and Your creations: saving or loading a prompt can be retried after a failure; key presses stay inside the creation panel; in the image editor, the History entry stays visible after a picture is placed from the history; the end of the Your creations list shows how many records cannot be read.", - "Memory and speed: when a Windows computer is refused only because of the default memory reserve, part of the reserve is used, keeping at least 1 GiB, so generation can start with about 5 GiB free; on computers with ample memory, consecutive requests skip reloading the models and run faster.", - "Mac: encoding with many reference images is much faster; accelerated computation falls back to the standard method when its results look wrong; after a one-click update, a new version that starts slowly is kept." - ] - }, - "zh": { - "summary": "Windows 上可以安装到层级较深的文件夹;多处失败提示写明原因和下一步操作;可用内存较少的 Windows 电脑可以继续完成生成;内存宽裕的电脑连续生成时速度更快。", - "changes": [ - "安装:Windows 上可以安装到层级较深的文件夹;更新未能装上并换回旧版时,提示按界面语言显示。", - "启动器:出错时写明出错的步骤和下一步操作,“报告问题”附带一行环境信息;已保存的安装文件夹无法打开时,点击“启动”会进入安装页,显示已保存的路径和说明;显卡驱动版本低于 580 时,显示当前驱动版本和更新步骤;启动设置文件损坏时,先保留一份副本再保存;提示词模型的文件清单缺失时,启动器照常工作,相应一行在清单可用后显示。", - "模型下载:下载源较慢时以更长的时限重试,并分别列出每个下载源和线路最近一次失败的原因;下载源出现问题时,启动器显示专门的卡片和切换下载源的按钮;图像模型有单独的下载清单,列出链接和校验值,并导出到单独的文件夹。", - "失败提示:视频生成失败时写明出错的阶段(载入、采样或解码);生成失败后的反馈以最后一条错误开头;剪辑页的自动字幕、字幕下载、素材导入和字幕导入失败时写明原因,GBK 编码的 SRT 字幕可以导入;图像生成、补写工作流和分享导出遇到磁盘已满时写明原因;排队中的参考图无法读取时,提示更换图片;长视频的某一段失败时显示简短状态,详细内容在错误卡片中,并写出缺失文件的名称。", - "生成:修复了部分情况下提交生成时报错的问题;首尾帧与参考素材不能同时使用时,失败卡片说明原因并提示关闭哪一类素材;素材列表已满 32 项时,“AI 生成”说明原因;生成前的时间估计注明“含首次准备”,预计较慢时给出一个原因和一个处理办法;排队时显示“约 N 分钟后开始”或“前面还有 N 个”;导出排队时按钮显示“排队中”;更多命名方式的 LoRA 文件可以正确识别,为原版模型制作的加速 LoRA 会显示专门说明。", - "提示词增强:场景中有音频、连接节点或视频时也可以增强,音频和连接节点会被跳过,视频读取一帧画面;带参考图时,改写结果遗漏图片引用,会请模型补上一次。", - "参考图:上传和读取时显示“正在上传参考图”和“正在读取参考图 N%”;剪辑页的“+”按钮在加载时显示转圈;素材库导入依次显示上传中、排队中和处理中,进度到 100% 后直接完成。", - "AI 全自动:等待回复时可以停止,并显示当前动作;三分钟没有进度更新时,提示仍在生成中;后台标签页的标题前显示“待确认”“失败”或“已完成”;“改描述”会把摘要放回输入框;确认卡片显示尺寸和时长;入口检查失败时保留入口,可以重试;某个已保存的对话或影片无法读取时,其余内容照常可用;英文界面下的失败原因清楚可读。", - "多镜头作品:AI 全自动制作长视频和多镜头作品时,配乐按需添加:需要配乐时,在各镜头拼接完成后为整片铺一段连贯的配乐,用户提供的音乐文件也可以直接使用。", - "创作面板与我的作品:提示词保存或读取失败时可以重试;按键操作只作用于创作面板;图像编辑中从历史记录放入画布后,“历史”入口保持可见;“我的作品”列表末尾显示无法读取的记录数量。", - "内存与速度:Windows 电脑只因默认预留空间不足而无法开始生成时,会使用部分预留空间(至少保留 1 GiB),可用内存约 5 GiB 时也可以开始生成;连续生成多个请求时,内存宽裕的电脑无需重复加载模型,速度更快。", - "Mac:使用大量参考图时,编码速度明显提升;加速计算的结果异常时,自动改用标准计算;一键更新后,启动较慢的新版本同样会保留。" - ] - } - }, - { - "product_version": "0.4.17", - "en": { - "summary": "Image generation and editing come to Apple silicon Macs, a Mac update that fails to open is rolled back, and phones on the same network connect more reliably.", - "changes": [ - "Mac: Macs with Apple silicon and at least 16 GB of memory can now generate and edit images.", - "Mac: in later updates, if the new version stops right after opening, the previous version is put back. The previous version is kept in a hidden folder so it no longer shows up in search or Launchpad. When an update fails, the reason and both buttons now appear right below the description.", - "Phone access: the phone page connects when phone access is switched on in the launcher, and also works on older phone browsers.", - "Queue: a single failed read of the queue no longer shows the connection lost card. The card appears after three failures in a row and goes away when the connection returns, and a running loop is kept through a short outage.", - "Launcher: when the chosen folder is not a ComfyUI folder, the launcher names a ComfyUI folder found nearby. Windows: the engine now starts when its usual port falls inside a range that Windows has reserved.", - "Installation: an installation with no generation on record now also compiles the standard size in the background.", - "Failures: when the model file changed during generation, the failure text names what changed." - ] - }, - "zh": { - "summary": "Apple 芯片的 Mac 可以生成和编辑图片;Mac 更新后打不开时会还原旧版;同一网络下的手机连接更稳定。", - "changes": [ - "Mac:Apple 芯片、内存 16 GB 及以上的 Mac 可以生成和编辑图片。", - "Mac:以后的更新中,新版刚打开就停止时会自动换回旧版。旧版保存在隐藏文件夹里,不再出现在搜索和启动台中。更新失败时,失败原因和两个按钮紧跟在说明下方显示。", - "手机访问:在启动器中打开手机访问后,手机页面即可连接,较旧的手机浏览器也能使用。", - "队列:读取队列只失败一次时不再弹出“连接已断开”卡片;连续失败三次才出现,连接恢复后自动消失;短时间断线时,正在运行的循环会保留。", - "启动器:所选文件夹不是 ComfyUI 文件夹时,会指出附近找到的 ComfyUI 文件夹。Windows:引擎常用端口落在 Windows 保留的端口段里时,也能正常启动。", - "安装:没有任何生成记录的安装,后台也会预编译标准尺寸。", - "失败提示:生成过程中模型文件发生变化时,失败文字会写明变化的内容。" - ] - } } ] } diff --git a/freevideo_engine/runtime.py b/freevideo_engine/runtime.py index 0939b547..44bb57aa 100644 --- a/freevideo_engine/runtime.py +++ b/freevideo_engine/runtime.py @@ -15,6 +15,7 @@ from .backends import get_backend from .weights import skeleton from .paths import base_path, checkpoint_path +from .processes import pin_attention_priority from .torch_compat import empty_host_cache @@ -1007,6 +1008,7 @@ def release(): self.refiner_released = False # load_prompt returns [tokens, width]; generate_latents adds the batch # dimension before the original transformer call. + pin_attention_priority() prompt = prompt.unsqueeze(0) if prompt.ndim == 2 else prompt text = self.transformer.context_embedder(prompt.to(input_dtype(self.transformer.context_embedder))) with self.device_backend.make_offloader(refiner, prefetch=False, diff --git a/freevideo_engine/terminal_ui.py b/freevideo_engine/terminal_ui.py index e344a55b..92045536 100644 --- a/freevideo_engine/terminal_ui.py +++ b/freevideo_engine/terminal_ui.py @@ -530,7 +530,7 @@ def update(**values): name=event.get('name') or model_download_name(event['file']).split(' · ')[0], detail={'verify_model': 'Verify ', 'download_model': 'Download ', 'assemble_model': 'Assemble downloaded parts · '}[kind] + model_download_name(event['file'])) - elif kind == 'network' and event.get('category') in ('models', 'vdn-models', 'edge-models') and event.get('action') == 'attempt': + elif kind == 'network' and event.get('category') in ('models', 'vdn-models', 'edge-models', 'encoder-models') and event.get('action') == 'attempt': self.model_backend = '' self.model_source = {'modelscope': 'ModelScope', 'official': 'Hugging Face', 'hf-mirror': 'HF Mirror', 'user': 'Custom source'}.get(event['source'], 'Model source') @@ -566,7 +566,7 @@ def update(**values): update(done=0, total=0, rate=None, scope=action, unit='bytes', detail=detail) if action in ('retry', 'route-retry'): update(activity='resume', name='') - elif kind == 'network' and event.get('category') in ('models', 'vdn-models', 'edge-models') and event.get('action') in ('fallback', 'verifying'): + elif kind == 'network' and event.get('category') in ('models', 'vdn-models', 'edge-models', 'encoder-models') and event.get('action') in ('fallback', 'verifying'): action = event['action'] self.model_backend = '' detail = ('Verify %s · ' % event.get('algorithm', 'integrity').upper() if action == 'verifying' diff --git a/freevideo_engine/windows_ux.py b/freevideo_engine/windows_ux.py index 0a0b5f47..d7a1b7ef 100644 --- a/freevideo_engine/windows_ux.py +++ b/freevideo_engine/windows_ux.py @@ -15,6 +15,31 @@ def taskbar_identity(): function('FlashML.FreeVideo.Launcher') +def folder_opener(): + """The command that shows a folder in the file manager, or None when the system has none.""" + import shutil + for name, extra in (('xdg-open', []), ('gio', ['open'])): + found = shutil.which(name) + if found: + return [found] + extra + return None + + +def open_folder(folder): + """Show a folder in the file manager on Linux; False when no opener exists or it cannot start.""" + import subprocess + from .linux_bundle import child_environment + command = folder_opener() + if not command: + return False + try: + subprocess.Popen(command + [str(folder)], env=child_environment(), stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + except OSError: + return False + return True + + def open_browser(url): """Use Windows' URL association directly and retain its actionable errors.""" with external_python(): diff --git a/scripts/test_freevideo_entry.mjs b/scripts/test_freevideo_entry.mjs index d237cbe6..f7c357c6 100644 --- a/scripts/test_freevideo_entry.mjs +++ b/scripts/test_freevideo_entry.mjs @@ -116,6 +116,7 @@ test('main browser entry registers both views and preserves node hooks', async t source = source.replace("import { beginWorkspaceRestore, finishWorkspaceRestore, waitForLauncherRestore } from './workspace_restore.js';", 'const {beginWorkspaceRestore,finishWorkspaceRestore,waitForLauncherRestore} = globalThis.__freevideoEntryTest;'); source = source.replace("import { openEditorView } from './editor_loader.js';", 'const openEditorView = (...args) => globalThis.__freevideoEntryTest.openEditorView?.(...args);'); source = source.replace("from './issue_button.js'", `from '${new URL('../web/issue_button.js', import.meta.url).href}'`); + source = source.replace("from './prompt_sections.js'", `from '${new URL('../web/prompt_sections.js', import.meta.url).href}'`); source = source .replace("import { createErrorPanel, errorText, createErrorReport, adviceFor } from './error_panel.js';", 'const {createErrorPanel,errorText,createErrorReport,adviceFor} = globalThis.__freevideoEntryTest;') .replace('import { app } from "../../scripts/app.js";', 'const {app} = globalThis.__freevideoEntryTest;') diff --git a/web/agent_confirm_settings.js b/web/agent_confirm_settings.js new file mode 100644 index 00000000..fba15397 --- /dev/null +++ b/web/agent_confirm_settings.js @@ -0,0 +1,91 @@ +import {agentGet, agentSend} from './agent_session.js'; +import {agentInterfaceState} from './agent_connect.js'; + +const PATH = '/freevideo/agent/v1/confirmations'; +const DEFAULT_SECONDS = 300, MAX_MINUTES = 1440; +function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; } + +// What the controls show for the server's policy: whether a film asks first, and whether and after how long a +// reshoot asks. `reshoot_seconds: null` is "never asks"; the seconds then stay at the last value. The exact +// seconds are kept, so a limit set elsewhere that is not a whole minute survives switching the control off and on. +export function confirmView(policy, seconds = DEFAULT_SECONDS) { + const limit = policy?.reshoot_seconds; + const long = typeof limit === 'number'; + return {start: policy?.start !== false, long, seconds: long ? limit : seconds}; +} + +export const minutesOf = seconds => Number((seconds / 60).toFixed(2)); + +// The one field that changed; the server keeps the other, so a policy set elsewhere is never overwritten. +export function confirmPatch(field, value) { + if (field === 'start') return {start: value === true}; + if (field === 'long') return {reshoot_seconds: value.on ? value.seconds : null}; + throw new Error('Unknown confirmation field'); +} + +// Minutes as typed: a number from 0 to 1440, decimals allowed, never an empty field. +export function validMinutes(value) { + if (value === null || (typeof value === 'string' && !value.trim())) return false; + const minutes = Number(value); + return Number.isFinite(minutes) && minutes >= 0 && minutes <= MAX_MINUTES; +} + +// Settings -> AI Agent: when an agent has to wait for the user. The section stays hidden unless the Agent interface +// is on and the policy was read, so a page without it shows nothing half-working. +export function agentConfirmSettings(t, api) { + const section = el('details'); section.hidden = true; + section.append(el('summary', null, t('AI Agent', 'AI Agent'))); + const startBox = el('input'); startBox.type = 'checkbox'; + const startLabel = el('label', 'fv-check'); startLabel.append(startBox, el('span', null, t('Ask before an agent starts a film', 'Agent 开拍整部片前先询问'))); + const longBox = el('input'); longBox.type = 'checkbox'; + const longLabel = el('label', 'fv-check'); longLabel.append(longBox, el('span', null, t('Ask before a long agent reshoot', 'Agent 重拍耗时较长时先询问'))); + const minutesField = el('input', 'fv-minutes'); minutesField.type = 'number'; minutesField.min = '0'; minutesField.max = String(MAX_MINUTES); minutesField.step = 'any'; + const minutesLabel = el('label', null, t('Long means at least', '达到')); minutesLabel.append(minutesField, el('span', null, t('minutes', '分钟'))); + const status = el('p', 'fv-muted'); status.setAttribute('role', 'status'); + section.append(startLabel, longLabel, minutesLabel, status); + + let shown = {start: true, long: true, seconds: DEFAULT_SECONDS}, busy = false; + const render = () => { + startBox.checked = shown.start; longBox.checked = shown.long; + minutesField.value = String(minutesOf(shown.seconds)); minutesField.disabled = busy || !shown.long; + startBox.disabled = longBox.disabled = busy; + }; + const read = async () => { + const response = await agentGet(api, PATH); + if (!response.ok) throw new Error('Not read'); + return confirmView(await response.json(), shown.seconds); + }; + async function save(patch) { + // The control keeps what the user just chose while the save runs; it then shows what the server holds. + busy = true; startBox.disabled = longBox.disabled = minutesField.disabled = true; status.textContent = ''; + try { + const response = await agentSend(api, 'PUT', PATH, patch); + if (!response.ok) throw new Error('Not saved'); + shown = confirmView(await response.json(), shown.seconds); + status.textContent = t('Saved', '已保存'); + } catch { + // A save whose answer was lost may still have taken effect, so the server is asked what it holds. + try { + shown = await read(); + status.textContent = t('Could not save. The setting shown is the one in effect.', '保存失败,下面显示的是当前生效的设置。'); + } catch { status.textContent = t('Could not confirm whether the change was saved.', '无法确认这次修改是否已保存。'); } + } + busy = false; render(); + } + startBox.onchange = () => save(confirmPatch('start', startBox.checked)); + longBox.onchange = () => save(confirmPatch('long', {on: longBox.checked, seconds: shown.seconds})); + minutesField.onchange = () => { + if (!validMinutes(minutesField.value)) { render(); status.textContent = t(`Enter a number from 0 to ${MAX_MINUTES}.`, `请输入 0 到 ${MAX_MINUTES} 之间的数。`); return; } + const seconds = Math.round(Number(minutesField.value) * 60); + return save(confirmPatch('long', {on: true, seconds})); + }; + + const ready = (async () => { + try { + if (await agentInterfaceState(api) !== 'on') return; + shown = await read(); render(); + section.hidden = false; + } catch { /* The section simply stays hidden. */ } + })(); + return {element: section, ready}; +} diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index 46861834..e6a5fbd1 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -1007,7 +1007,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () // A picture takes seconds: the same quiet line while it is made, then the picture. const card = VIDEOS.includes(job.kind || 'generate') || (['image', 'production', 'cast'].includes(job.kind) && !WORKING.includes(job.status)); const next = card ? jobMode(job) + (jobMode(job) === 'done' ? `:${job.play}:${job.thumbnail}:${job.work}` - : jobMode(job) === 'failed' && job.kind === 'image' ? `:${job.error?.code}:${job.error?.report_id}` : '') + : jobMode(job) === 'failed' && job.kind === 'image' ? `:${job.error?.code}:${job.error?.report_id}:${job.error?.memory || ''}` : '') : WORKING.includes(job.status) ? 'quiet' : 'none'; if (next !== mode) { mode = next; item.dataset.job = job.id; item.dataset.mode = next.split(':')[0]; item.hidden = next === 'none'; @@ -1024,7 +1024,8 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () else if (next.startsWith('failed') && job.kind === 'image') { // Picture failures name their step with the picture's own report card. const host = el('div'); - renderImageFailure(host, {error: job.error?.code || 'generation_failed', report_id: job.error?.report_id}, t, + renderImageFailure(host, {error: job.error?.code || 'generation_failed', report_id: job.error?.report_id, + memory: job.error?.memory}, t, {productVersion, fetchApi: (path, options) => api.fetchApi(path, options)}); item.replaceChildren(host); if (retry) item.append(press('fv-chat-retry', t('Retry', '重试'), retry)); diff --git a/web/browser_extension.js b/web/browser_extension.js index da8c7b9d..cd65e4cb 100644 --- a/web/browser_extension.js +++ b/web/browser_extension.js @@ -121,6 +121,10 @@ export function detectBrowserExtension(error, {kind = 'unknown', known = null, b return {evidence, extension: byId.get(id)?.name ?? null, script: frame ? userScriptName(frame) : null}; } +// A wrapped window.fetch alone does not say that the request was blocked: any script that +// wraps fetch makes it true, also while the engine is merely unreachable for a moment. +export const wrappedFetchOnly = result => result?.evidence?.length === 1 && result.evidence[0] === 'wrapped-fetch'; + export const EXTENSION_COPY = Object.freeze({ title: ['A browser extension is interfering with FreeVideo', '浏览器扩展干扰了 FreeVideo'], // English uses straight quotes: curly ones widen in the CJK font stack. diff --git a/web/cast_generate.js b/web/cast_generate.js index 396c201d..cc2543f4 100644 --- a/web/cast_generate.js +++ b/web/cast_generate.js @@ -15,7 +15,7 @@ const VIEW_TITLES = Object.freeze({ function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; } const failureOf = error => ({error: error?.code || 'generation_failed', report_id: error?.report_id || null, - stage: error?.stage || '', fitted: error?.fitted || null}); + stage: error?.stage || '', fitted: error?.fitted || null, memory: error?.memory || ''}); const randomSeed = () => crypto.getRandomValues(new Uint32Array(1))[0] >>> 1; // The five pictures of one description. No DOM: the page below draws its state. @@ -32,7 +32,7 @@ export function createCastGenerator({client, onChange = () => {}, schedule = set const view = plan[index], front = state.pictures[0].file; const row = await client.generate({prompt: view.prompt, width: view.width, height: view.height, seed, images: view.from_front ? [front] : [], ...(view.view === 'back' ? {cast_view: 'back'} : {})}); - if (!row.job) throw Object.assign(new Error(row.error || 'generation_failed'), {code: row.error, report_id: row.report_id}); + if (!row.job) throw Object.assign(new Error(row.error || 'generation_failed'), {code: row.error, report_id: row.report_id, memory: row.memory}); if (!alive()) { await cancelJob(row.job); return null; } job = row.job; for (;;) { @@ -42,7 +42,7 @@ export function createCastGenerator({client, onChange = () => {}, schedule = set if (!alive()) return null; if (status.state === 'error') { throw Object.assign(new Error(status.error), {code: status.error, report_id: status.report_id, - stage: status.stage, fitted: status.fitted}); + stage: status.stage, fitted: status.fitted, memory: status.memory}); } if (status.state === 'done') { if (!status.file) throw new Error('generation_failed'); @@ -177,7 +177,7 @@ export function mountCastGenerate({container, api, text, t = text, viewURL, onSt cell.fill.style.width = (active ? row.fraction * 100 : 0) + '%'; }); if (state.error) { - const key = `${state.error}:${state.report_id || ''}:${state.stage || ''}`; + const key = `${state.error}:${state.report_id || ''}:${state.stage || ''}:${state.memory || ''}`; if (failureKey !== key) { renderImageFailure(note, {...state, info}, tr, {fetchApi: fetcher}); failureKey = key; } } else { failureKey = null; diff --git a/web/cast_library.css b/web/cast_library.css index f41a9f9c..7028dfee 100644 --- a/web/cast_library.css +++ b/web/cast_library.css @@ -5,7 +5,7 @@ .fv-cast-avatars{display:flex;align-items:center;gap:7px;min-width:0;overflow:auto;padding:3px} .fv-cast-avatars:empty{display:none} .fv-cast-library .fv-cast-avatar,.fv-cast-library .fv-cast-add{display:grid;place-items:center;flex:0 0 34px;width:34px;height:34px;min-height:0;padding:0;border:1px solid var(--fv-border);border-radius:50%;background:var(--fv-raised);color:var(--fv-muted);cursor:pointer;overflow:hidden} -.fv-cast-avatar img{width:100%;height:100%;object-fit:cover;object-position:50% 25%} +.fv-cast-avatar img{width:100%;height:100%;object-fit:cover;object-position:50% 15%;object-view-box:inset(0 20% 40% 20%)} .fv-cast-library .fv-cast-add{font-size:22px;border-style:dashed} .fv-cast-library button:hover{border-color:var(--fv-accent);background:var(--fv-hover)} .fv-cast-dialog{box-sizing:border-box;width:min(680px,calc(100vw - 32px));max-width:calc(100vw - 32px);max-height:calc(100dvh - 32px);margin:auto;padding:20px;border:1px solid var(--fv-border);border-radius:var(--fv-r-lg,16px);background:var(--fv-card);color:var(--fv-ink);font:13px/1.5 var(--fv-font);box-shadow:0 24px 80px #0008;overflow:auto;color-scheme:dark} diff --git a/web/cast_mention.js b/web/cast_mention.js index c243c255..e6364f8c 100644 --- a/web/cast_mention.js +++ b/web/cast_mention.js @@ -125,11 +125,24 @@ export function expandCast(text, rows, {active = true, continued = false} = {}) const last = ranges[ranges.length - 1]; if (last && number === last[1] + 1) last[1] = number; else ranges.push([number, number]); } - return {name: person.name, description: person.description, pictures: ranges, audio: null}; + return {name: person.name, handle: person.handle ?? person.name, description: person.description, pictures: ranges, audio: null}; }); return {text: subjectDefinitions(subjects) + '\n\n' + result, subjects}; } +// The rewrite of an expanded prompt, written back with the names the box had: becomes "@handle" of the +// subject expandCast gave that number, and the subject definitions the rewrite opens with are left out, since +// expandCast writes them again from the cards (their picture numbers follow the media list as it is then). A rewrite +// with a number that stands for no one is kept as it came. +export function restoreCast(text, subjects) { + text = String(text ?? ''); + const handles = (Array.isArray(subjects) ? subjects : []).map(subject => subject.handle ?? subject.name); + const numbers = [...text.matchAll(//g)].map(match => Number(match[1])); + if (!numbers.length || numbers.some(number => !handles[number - 1])) return text; + const body = text.replace(/^\s*subject_definitions:(?:[\s\S]*?\n\s*\n(?=[a-z_]+:)|[\s\S]*?\n\s*\n|[\s\S]*$)/, '').trim(); + return (body || text).replace(//g, (_, number) => '@' + handles[number - 1]); +} + // The pictures of a card to put in the media list, as the Agent carries them (agent/drama.py person_pictures): the sheet of // every view (the card's own, else the one the library made), then the face close-up, else the front view. A card with no // sheet (one picture, or none could be made) carries its face alone. diff --git a/web/editor.js b/web/editor.js index b8eb519b..d1d3103d 100644 --- a/web/editor.js +++ b/web/editor.js @@ -1463,7 +1463,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given const progressComponents = given && Object.hasOwn(given, 'generationStandIn') && Object.keys(given).length === 1 ? null : given; const componentsReady = Promise.allSettled([import('./editor_tracks.js'), progressComponents || loadEditorComponents()]); await editorStyles; if (closing || disposed) return; - let record = null, key = null; + let record = null, key = null, lastMissing = false; try { await store.recoverOutbox(); if (closing || disposed) return; if (from?.work) { @@ -1483,7 +1483,12 @@ export function openEditor({api, app, from = null, panel = {}, components: given let id; try { id = optionalText(localStorage.getItem('fv-editor-last')); } catch {} let draft; try { draft = globalThis.sessionStorage?.getItem('fv-editor-draft'); } catch { draft = lastDraft; } if (draft?.startsWith('draft:')) key = draft; - else if (id) { record = await store.load(id); if (record) key = id; } + else if (id) { + record = await store.load(id); + // The last edit is gone (deleted or moved); say so once instead of opening a blank edit silently. + if (record) key = id; + else { lastMissing = true; try { localStorage.removeItem('fv-editor-last'); } catch {} } + } } } catch (value) { error(value, null, undefined, 'open-load'); } if (closing || disposed) return; @@ -1495,6 +1500,15 @@ export function openEditor({api, app, from = null, panel = {}, components: given initializing = false; savedContent = content(state.record); player.refresh(); loaded = true; settleGenbar(); updateTop(); updateTransport(); + if (lastMissing && !state.record.id) { + // Point at the name menu only when it lists another edit to open. + store.projects().catch(() => ({})).then(rows => { + if (closing || disposed) return; + const others = Array.isArray(rows) && rows.some(row => row.id !== state.record.id); + notify(t('The last edit could not be found, so a blank edit is open.', '找不到上次的剪辑,已打开空白剪辑。') + + (others ? t(' Click the edit name at the top to open another edit.', '点击顶部的剪⁠辑⁠名可打开其他剪辑。') : '')); + }); + } const [trackResult, componentResult] = await componentsReady; if (closing || disposed) return; // Optional progress components may fail without taking the tracks with them. if (componentResult.status === 'rejected') error(componentResult.reason, null, undefined, 'components'); diff --git a/web/error_panel.js b/web/error_panel.js index ae235863..7910e1fb 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -201,10 +201,12 @@ function reportRedactor(detail, context) { for (const node of list(context.graph?._nodes)) { remember(node.properties?.freevideo_prompt_versions?.original); remember(node.properties?.freevideo_prompt_versions?.rewritten); + remember(node.properties?.freevideo_prompt_versions?.model); for (const widget of list(node.widgets)) remember(widget.value); } remember(context.node?.properties?.freevideo_prompt_versions?.original); remember(context.node?.properties?.freevideo_prompt_versions?.rewritten); + remember(context.node?.properties?.freevideo_prompt_versions?.model); for (const widget of list(context.node?.widgets)) remember(widget.value); for (const node of Object.values(record(detail?.response?.node_errors ?? detail?.node_errors))) for (const reason of list(node?.errors)) remember(reason?.extra_info?.received_value); @@ -234,8 +236,12 @@ function reportRedactor(detail, context) { .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '
'); // Keep traceback source basenames and line numbers, never drive names, // home directories or private media filenames. Spaces in paths count. - text = text.replace(/(^|[^A-Za-z0-9_:/<>])(\.\/setup\.sh(?![\w\/\\-]|\.\w)|\.{1,2}\/[^\r\n"'<>;,\)\]]*|(?:[A-Za-z]:[\\/]|\\\\[^\\/\s]+[\\/]|\/(?![\/\s]))[^\r\n"'<>;,\)\]]+)/gm, (_, prefix, path) => { + // The engine may have redacted the text already: what follows its own + // marker stays when it is one name, and goes when it is the rest + // of a path (a user name with a space, a share). + text = text.replace(/(^|[^A-Za-z0-9_:/<])(\.\/setup\.sh(?![\w\/\\-]|\.\w)|\.{1,2}\/[^\r\n"'<>;,\)\]]*|(?:[A-Za-z]:[\\/]|\\\\[^\\/\s]+[\\/]|\/(?![\/\s]))[^\r\n"'<>;,\)\]]+)/gm, (_, prefix, path, offset, whole) => { if (path === './setup.sh') return prefix + path; + if (prefix === '>' && whole.slice(0, offset + 1).endsWith('') && !/[\\/]/.test(path.slice(1))) return prefix + path; const source = path.replace(/\\/g, '/').match(/\/([A-Za-z0-9_.-]+\.(?:py|js|ts|mjs))(?=[:\s]|$)/); return prefix + (source ? '/' + source[1] + path.slice(path.indexOf(source[1]) + source[1].length) : ''); }); @@ -318,7 +324,8 @@ export function createErrorReport(detail, context = {}) { // known: a report's known_error. Pass null when the report has none, so an // echoed sentence in its redacted text is not taken for the engine's message. export function failureAdvice(value, t, known = knownError(value)) { - const text = String(value).slice(0, 65536); + // What each download source returned never changes which advice applies. + const text = String(value).slice(0, 65536).replace(/\nSources tried(?: for [^\n:]{1,80})?:\n(?: - [^\n]*(?:\n|$))*/g, '\n'); const row = (kind, title, summary, action) => ({kind, title: t(...title), summary: t(...summary), action: t(...action)}); if (known?.kind === 'sampling-table') { // Names and plan matching follow sampling_effort.js; metadata carries no user text. diff --git a/web/freevideo.js b/web/freevideo.js index dd9fb93b..4b696b98 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -18,6 +18,7 @@ import { openFreeVideoWorkflow } from './launcher.js'; import { agentInterfaceState } from './agent_connect.js'; import { attachReferencePicker, referenceItems, syncReferencePrompt, referenceInputs, mediaRows, mediaNodeOf } from './prompt_references.js'; import { expandCast } from './cast_mention.js'; +import { requestText } from './prompt_sections.js'; import { outputDownloadURL } from './output_download.js'; import { shareButton } from './share.js'; import { reportFileName } from './report_issue.js'; @@ -821,12 +822,14 @@ app.registerExtension({ force.value = false; force.serializeValue = () => false; } // Characters mentioned with @ travel as plus their definitions; the draft keeps the typed names. + // A rewrite the box shows in short goes back in the model's sections. const prompt = this.widgets?.find(w => w.name === 'text'); if (prompt) { const owner = this; // Replace person asks for its prefix here, where every way of running the node reads the text. prompt.serializeValue = () => { - const body = expandCast(prompt.value, mediaRows(owner), {continued: Boolean(owner.freevideoContinues?.())}).text; + const body = expandCast(requestText(prompt.value, owner.properties?.freevideo_prompt_versions), + mediaRows(owner), {continued: Boolean(owner.freevideoContinues?.())}).text; return swapState(mediaNodeOf(owner)) ? withPrefix(body) : body; }; } diff --git a/web/health.js b/web/health.js index fc54131a..9fc040a4 100644 --- a/web/health.js +++ b/web/health.js @@ -13,12 +13,13 @@ const t = (en, zh) => cn ? zh : en; // launcher.js removes this flag during setup; read it before that. const launchVisit = query.get('freevideo') === 'launch'; // Every script this folder ships; a test keeps the list in step with web/*.js. -export const MODULES = ['audio_original.js', 'chat_data.js', 'phone_model.js', 'phone.js', 'lan_access.js', 'qrcodegen.js', 'agent_activity.js', 'agent_connect.js', 'agent_session.js', 'autopilot.js', 'autopilot_chat.js', 'chat_attachments.js', 'autopilot_conversations.js', 'browser_extension.js', 'caret_rect.js', 'chain_support.js', 'chain_rules.js', 'capability_view.js', 'cast_generate.js', 'cast_mention.js', 'cast_overlay.js', 'cast_source.js', 'compatibility.js', 'entry_showcase.js', 'error_panel.js', 'first_run_guide.js', 'fonts.js', 'freevideo.js', 'generation_progress.js', +export const MODULES = ['audio_original.js', 'chat_data.js', 'phone_model.js', 'phone.js', 'lan_access.js', 'qrcodegen.js', 'agent_activity.js', 'agent_confirm_settings.js', 'agent_connect.js', 'agent_session.js', 'autopilot.js', 'autopilot_chat.js', 'chat_attachments.js', 'autopilot_conversations.js', 'browser_extension.js', 'caret_rect.js', 'chain_support.js', 'chain_rules.js', 'capability_view.js', 'cast_generate.js', 'cast_mention.js', 'cast_overlay.js', 'cast_source.js', 'compatibility.js', 'entry_showcase.js', 'error_panel.js', 'first_run_guide.js', 'fonts.js', 'freevideo.js', 'generation_progress.js', 'editor.js', 'editor_actions.js', 'editor_lifecycle.js', 'editor_adjust.js', 'editor_adjust_picture.js', 'editor_picture_fields.js', 'editor_picture_session.js', 'editor_preview_picture.js', 'editor_entry.js', 'editor_expand.js', 'editor_export_dialog.js', 'editor_first_frame.js', 'editor_media_references.js', 'editor_references.js', 'editor_genbar.js', 'editor_geometry.js', 'editor_library.js', 'editor_menu.js', 'editor_model.js', 'editor_frame.js', 'editor_player.js', 'editor_room.js', 'editor_select.js', 'editor_state.js', 'editor_store.js', 'editor_tracks.js', 'editor_generation.js', 'editor_transport.js', 'editor_transport_model.js', 'editor_audio.js', 'editor_audio_model.js', 'editor_audio_notes.js', 'editor_audio_replace.js', 'editor_audio_rules.js', 'editor_speed.js', 'editor_speed_model.js', 'editor_failure.js', 'editor_header_fit.js', 'editor_loader.js', 'editor_text_coverage.js', 'editor_text.js', 'editor_toolbar_more.js', 'editor_text_model.js', 'editor_text_layout.js', 'editor_notes.js', 'editor_icons.js', 'dialog_keys.js', 'done_notify.js', 'failure_issue.js', 'feedback.js', 'frame_sizes.js', 'health.js', 'image_commands.js', 'image_editor.js', 'image_editor_engine.js', 'image_generation.js', 'launch_visit.js', 'launcher.js', 'length_plan.js', 'lora_triggers.js', 'lora_check.js', 'library.js', 'motion.js', 'native_errors.js', 'translate_guard.js', 'output_download.js', 'port_notice.js', 'preview_scene.js', - 'progress_connection.js', 'prompt_draft.js', 'prompt_enhance.js', 'prompt_references.js', 'prompt_terms.js', 'request_assets.js', 'repair_hint.js', 'report_issue.js', 'issue_button.js', 'studio_recent.js', + 'progress_connection.js', 'prompt_draft.js', 'prompt_enhance.js', 'prompt_references.js', 'prompt_sections.js', 'prompt_terms.js', 'request_assets.js', 'repair_hint.js', 'report_issue.js', 'issue_button.js', 'studio_recent.js', 'result_actions.js', 'result_reuse.js', 'sampling_effort.js', 'setup.js', 'share.js', 'share_layout.js', 'studio.js', 'studio_chain.js', 'studio_extend.js', 'studio_length.js', 'first_frame_row.js', 'replace_person.js', 'replace_state.js', 'optional_inputs.js', 'studio_queue.js', 'studio_size.js', - 'toolbar_position.js', 'updates.js', 'view_navigation.js', 'wordmark.js', 'workspace_restore.js', 'cast_library.js', 'media_upload.js', 'whitebox_encode.js', 'whitebox_mp4.js', 'whitebox_recording.js', 'whitebox_render.js', 'whitebox_scenes.js', 'whitebox_worker.js']; + 'toolbar_position.js', 'updates.js', 'view_navigation.js', 'wordmark.js', 'workspace_restore.js', 'cast_library.js', 'media_upload.js', 'whitebox_encode.js', 'whitebox_mp4.js', 'whitebox_recording.js', 'whitebox_render.js', 'whitebox_scenes.js', 'whitebox_worker.js', + 'empty_prompt_notice.js', 'feature_tour.js', 'feature_tour_content.js', 'howto.js', 'swap_lora.js', 'tour_try.js', 'tutorial_book.js', 'tutorial_book_content.js']; const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); let visibleAt = 0; async function waitForVisible() { @@ -527,8 +528,22 @@ async function reloadOnce(problems) { return true; } +// Curly single quotes from a Latin face where Segoe UI is missing, as theme.css does: the CJK faces after it draw +// them full width. Added from here because theme.css may be one of the files that failed. +let quoteFaces = false; +function addQuoteFaces() { + if (quoteFaces || !globalThis.FontFace || !document.fonts) return; + quoteFaces = true; + for (const [weight, names] of [['100 500', ['Noto Sans Regular', 'NotoSans-Regular', 'DejaVu Sans', 'Liberation Sans']], + ['600 900', ['Noto Sans Bold', 'NotoSans-Bold', 'DejaVu Sans Bold', 'Liberation Sans Bold']]]) { + try { document.fonts.add(new FontFace('FreeVideo Quotes', names.map(name => `local("${name}")`).join(), {weight, unicodeRange: 'U+2018-2019'})); } + catch { /* The card still shows in the fonts the browser has. */ } + } +} + function show(problems) { lastProblems = problems; + addQuoteFaces(); card?.remove(); card = document.createElement('section'); card.setAttribute('role', 'alert'); card.className = 'fv-health'; @@ -536,7 +551,7 @@ function show(problems) { width: 'min(600px, calc(100vw - 32px))', maxHeight: 'calc(100vh - 32px)', overflow: 'auto', boxSizing: 'border-box', padding: '18px 20px 16px', borderRadius: '12px', background: '#1a222e', color: '#e7edf7', border: '1px solid #5a3440', boxShadow: '0 18px 48px rgba(0,0,0,.45)', - font: '13px/1.55 "Segoe UI","Microsoft YaHei UI","Noto Sans CJK SC","Noto Sans SC",system-ui,sans-serif'}); + font: '13px/1.55 "Segoe UI","FreeVideo Quotes","Microsoft YaHei UI","Noto Sans CJK SC","Noto Sans SC",system-ui,sans-serif'}); const title = document.createElement('div'); const extension = problems.map(extensionProblem).find(Boolean); const extensionAdvice = extension && extensionUI.browserExtensionAdvice(extension, t, diff --git a/web/image_editor.css b/web/image_editor.css index e9b0e64f..ecb2b5bf 100644 --- a/web/image_editor.css +++ b/web/image_editor.css @@ -87,6 +87,7 @@ button.fv-ie-size:hover{background:var(--fv-hover);color:var(--fv-ink)} .fv-ie-gen-history .fv-ie-menu-list{top:calc(100% + 6px);right:0;bottom:auto;left:auto;width:min(320px,calc(100vw - 16px));max-height:min(420px,70vh)} .fv-ie-gen-history .fv-ie-menu-list.fv-ie-gen-history-up{top:auto;bottom:calc(100% + 6px)} .fv-ie-gen-history-confirm{opacity:1!important;width:auto!important;padding:0 6px;background:#c0392b!important;color:#fff!important;font-size:var(--fv-micro)} +.fv-ie-gen-history-path{overflow-wrap:anywhere;user-select:text} .fv-ie-gen-history-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(88px,1fr));gap:6px} .fv-ie-gen-history-tile{position:relative} .fv-ie-gen-history-pick{display:block;width:100%;padding:0;border:0;background:none;cursor:pointer} diff --git a/web/image_generation.js b/web/image_generation.js index 05ddb97f..74f174c3 100644 --- a/web/image_generation.js +++ b/web/image_generation.js @@ -49,7 +49,7 @@ export function blankCanvasSize(ratio, sizes = null, tier = 'standard') { } const failure = (code, info = {}) => Object.assign(new Error(code || 'generation_failed'), { code: code || 'generation_failed', report_id: info.report_id || null, stage: info.stage || '', - fitted: info.fitted || null, + fitted: info.fitted || null, memory: info.memory || '', }); export function createImageGenClient(fetchImpl = globalThis.fetch.bind(globalThis)) { @@ -176,6 +176,9 @@ export function imageGenError(code, info, t) { }; const say = (en, zh) => t(...memoryWords([en, zh], info?.unified)); if (code === 'pack_missing') return imagePackUnavailableMessage(info, t); + if (code === 'no_memory' && info?.memory === 'host') + return t('Not enough virtual memory to load the image model. Close other apps, or increase virtual memory in system settings, then try again.', + '虚拟内存不足,无法载入图像模型。请关闭其他程序,或在系统设置中调大虚拟内存,然后重试。'); if (code === 'no_memory') { // Where it ran out decides what helps: the reference pictures are read at a fixed size, // so a smaller canvas does not change that stage. @@ -211,7 +214,7 @@ export const REPORT_LABELS = Object.freeze({ // Shared by the editor and the other image entry points. Only the opaque report // ID goes in the request; raw service messages are never shown in the page. -export function renderImageFailure(host, {error, report_id, stage, fitted, info} = {}, t, +export function renderImageFailure(host, {error, report_id, stage, fitted, info, memory} = {}, t, {productVersion = () => null, fetchApi = globalThis.fetch.bind(globalThis)} = {}) { host.replaceChildren(); if (!error || error === 'cancelled') return null; @@ -222,7 +225,7 @@ export function renderImageFailure(host, {error, report_id, stage, fitted, info} } const card = doc.createElement('div'); card.className = 'fv-result-links'; const reason = doc.createElement('div'); reason.className = 'fv-image-failure-reason'; - reason.setAttribute('role', 'status'); reason.textContent = imageGenError(error, {stage, fitted, unified: info?.unified_memory}, t); + reason.setAttribute('role', 'status'); reason.textContent = imageGenError(error, {stage, fitted, memory, unified: info?.unified_memory}, t); card.append(reason); host.append(card); if (!report_id) return card; const fetchReport = async () => { @@ -407,9 +410,9 @@ export function createImageGenSession({client, prepare, apply, onChange = () => let state = {info: null, checking: false, running: false, job: null, fraction: 0, stage: '', error: '', report_id: null, waiting: '', fitted: null}; const emit = patch => { state = {...state, ...patch}; if (!disposed) onChange(state); }; - const failed = error => error.code === 'cancelled' ? {error: '', report_id: null, stage: ''} + const failed = error => error.code === 'cancelled' ? {error: '', report_id: null, stage: '', memory: ''} : {error: error.code || 'generation_failed', report_id: error.report_id || null, stage: error.stage || '', - ...(error.fitted ? {fitted: error.fitted} : {})}; + memory: error.memory || '', ...(error.fitted ? {fitted: error.fitted} : {})}; const cancelJob = job => Promise.resolve().then(() => client.cancel(job)).catch(() => {}); const forget = () => { try { store?.clear(); } catch { /* the job is only not resumed after a reload */ } }; function scheduleInfo() { @@ -466,7 +469,7 @@ export function createImageGenSession({client, prepare, apply, onChange = () => if (state.running || disposed || !request.prompt.trim() || !state.info?.installed) return; const current = ++epoch; // A picture that is edited sends its reference images first; there is no job to report on yet. - emit({running: true, job: null, fraction: 0, stage: request.mode === 'edit' ? 'upload' : '', error: '', report_id: null, waiting: '', fitted: null}); + emit({running: true, job: null, fraction: 0, stage: request.mode === 'edit' ? 'upload' : '', error: '', report_id: null, memory: '', waiting: '', fitted: null}); const alive = () => current === epoch && !disposed; try { const {context, images = [], mask} = await prepare(request, alive); @@ -489,7 +492,7 @@ export function createImageGenSession({client, prepare, apply, onChange = () => function resume({job, context, withImages = false}) { if (state.running || disposed || !job || !context) return false; const current = ++epoch; - emit({running: true, job, fraction: 0, stage: '', error: '', report_id: null, waiting: '', fitted: null}); + emit({running: true, job, fraction: 0, stage: '', error: '', report_id: null, memory: '', waiting: '', fitted: null}); track(current, job, context, withImages, true); return true; } @@ -497,16 +500,16 @@ export function createImageGenSession({client, prepare, apply, onChange = () => ++epoch; unschedule(timer); timer = null; const job = state.job; forget(); - emit({running: false, job: null, stage: '', fraction: 0, error: '', report_id: null, waiting: '', fitted: null}); + emit({running: false, job: null, stage: '', fraction: 0, error: '', report_id: null, memory: '', waiting: '', fitted: null}); return job ? cancelJob(job) : Promise.resolve(); } // Start or pause the download of the image models; the reply is the new state, then polling follows it. async function pack(action) { - try { const info = await (action === 'cancel' ? client.packCancel() : client.packInstall()); if (!disposed) emit({info, error: '', report_id: null}); } + try { const info = await (action === 'cancel' ? client.packCancel() : client.packInstall()); if (!disposed) emit({info, error: '', report_id: null, memory: ''}); } catch (error) { if (!disposed) emit(failed(error)); } finally { if (!disposed) scheduleInfo(); } } - return {check, start, resume, cancel, pack, clearError: () => emit({error: '', report_id: null}), state: () => state, + return {check, start, resume, cancel, pack, clearError: () => emit({error: '', report_id: null, memory: ''}), state: () => state, dispose() { disposed = true; unschedule(infoTimer); infoTimer = null; return cancel(); }}; } @@ -652,13 +655,17 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection const list = el('div', null, 'fv-ie-menu-list'); menu.append(summary, list); // Choosing several: the ticked files, the reason each one that stayed gives, and the two-step confirm of the delete button. - let selecting = false, armed = null, failed = false; + let selecting = false, armed = null, failed = false, unopened = null; const chosen = new Set(), stayed = new Map(); const reasons = {busy: t('In use', '被占用'), missing: t('Already gone', '已不存在'), invalid: t('Not deleted', '未删除')}; const disarm = () => { clearTimeout(armed); armed = null; }; const draw = () => { list.replaceChildren(); if (failed) list.append(el('div', t('Could not delete this image', '无法删除这张图片'), 'fv-ie-menu-label')); + if (unopened) { + list.append(el('div', t('The folder could not be opened automatically. Open it in a file manager.', '无法自动打开该文件夹,请在文件管理器中手动打开'), 'fv-ie-menu-label')); + if (unopened.folder) list.append(el('div', unopened.folder, 'fv-ie-menu-label fv-ie-gen-history-path')); + } if (!past.length) { list.append(el('div', t('No images generated yet', '还没有生成过图片'), 'fv-ie-menu-label')); return; } const grid = el('div', null, 'fv-ie-gen-history-grid'); for (const item of past) { @@ -732,14 +739,17 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection } else { foot.append(button(t('Select', '选择'), null, () => { selecting = true; failed = false; draw(); }, 'fv-ie-menu-item')); if (['localhost', '127.0.0.1', '[::1]'].includes(location.hostname)) - foot.append(button(t('Open folder', '打开文件夹'), null, () => { void client.historyOpen().catch(() => {}); }, 'fv-ie-menu-item')); + foot.append(button(t('Open folder', '打开文件夹'), null, async () => { + let answer; try { answer = await client.historyOpen(); } catch { answer = {opened: false}; } + unopened = answer.opened === false ? {folder: answer.folder || ''} : null; draw(); + }, 'fv-ie-menu-item')); } list.append(foot); }; menu.ontoggle = async () => { if (!menu.open) return; try { past = await client.history(); } catch { past = []; } - selecting = false; chosen.clear(); stayed.clear(); disarm(); failed = false; + selecting = false; chosen.clear(); stayed.clear(); disarm(); failed = false; unopened = null; draw(); // Open upward when the list does not fit below, and keep it inside the window. list.classList.remove('fv-ie-gen-history-up'); list.style.right = ''; @@ -780,7 +790,7 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection action.hidden = !!missing && !pack; const percent = Math.floor(state.fraction * 100) + '%'; if (state.error && !promptRequired) { - const failureKey = `${state.error}:${state.report_id || ''}:${state.stage || ''}`; + const failureKey = `${state.error}:${state.report_id || ''}:${state.stage || ''}:${state.memory || ''}`; if (mounted.failureKey !== failureKey) { renderImageFailure(note, state, t, {productVersion, fetchApi}); mounted.failureKey = failureKey; diff --git a/web/length_plan.js b/web/length_plan.js index d1b28ea5..d296b63e 100644 --- a/web/length_plan.js +++ b/web/length_plan.js @@ -33,7 +33,8 @@ export function readLengthPlan(reply) { export function classifyPlanError(reply) { if (reply?.code === 'device_limit') return 'continuation_unavailable'; if (reply?.code === 'too_long' || ['too_many_parts', 'too_many_short_parts'].includes(reply?.code)) return 'too_long'; - if (['block_too_long', 'block_too_short', 'too_short', 'invalid'].includes(reply?.code)) return 'other'; + if (reply?.code === 'block_too_short' || reply?.code === 'block_too_long') return reply.code; + if (['too_short', 'invalid'].includes(reply?.code)) return 'other'; const value = reply?.error; const message = reply?.code || (typeof value === 'string' ? value : value?.code || value?.message || ''); if (/single.*(?:limit|long|cannot|unavailable|impossible)|force.*single|强制单次/i.test(message)) return 'single_unavailable'; diff --git a/web/prompt_enhance.js b/web/prompt_enhance.js index 0bc3e400..9d74d6e8 100644 --- a/web/prompt_enhance.js +++ b/web/prompt_enhance.js @@ -1,3 +1,5 @@ +import {isNetworkTypeError, connectionLostText, detectBrowserExtension, browserExtensionAdvice} from './browser_extension.js'; + // Original and enhanced prompts are separate workflow drafts, never diagnostics. export class PromptVersions { constructor(saved, text) { @@ -12,16 +14,30 @@ export class PromptVersions { edit(text, restart = false) { this.value.edits = (this.value.edits || 0) + 1; if (restart && this.value.selected === 'rewritten') { - Object.assign(this.value, {original: text, rewritten: '', selected: 'original', context: '', useOriginal: false}); + Object.assign(this.value, {original: text, originalModel: '', rewritten: '', model: '', selected: 'original', context: '', useOriginal: false}); return; } this.value[this.value.selected] = text; - if (this.value.selected === 'original') this.value.context = ''; + if (this.value.selected === 'original') { this.value.context = ''; if (restart) this.value.originalModel = ''; } + } + // Text put in the box from elsewhere starts a new scene. `model` is the H3 text it shows in short, sent as written + // until the box is cleared or replaced. + adopt(text, model = '') { + // A version saved in sections before the box showed it in short (an older workflow) keeps its place. + if (model && model !== text && model === this.text()) { + this.value[this.value.selected] = text; + this.value[this.value.selected === 'rewritten' ? 'model' : 'originalModel'] = model; + return; + } + this.edit(text, true); + this.value.originalModel = model === text ? '' : model; } key(context) { return JSON.stringify({text: this.value.original, ...context}); } - accept(key, context, text) { + // `model` is the rewrite as the model wrote it, kept when the box shows it in a shorter form. + accept(key, context, text, model = text) { if (this.key(context) !== key || !this.value.enabled || this.value.useOriginal) return false; - this.value.rewritten = text; this.value.selected = 'rewritten'; this.value.context = key; + this.value.rewritten = text; this.value.model = model === text ? '' : model; + this.value.selected = 'rewritten'; this.value.context = key; return true; } fresh(context) { return Boolean(this.value.rewritten && this.value.context === this.key(context)); } @@ -64,8 +80,10 @@ export function promptPrestart(api, nodeId, enabled, alive = () => true) { // An empty box is never rewritten, the untouched starter prompt included: // enhancement waits for the user's own scene. -// `prepare` turns the user's text into what the model reads (character mentions written out with their pictures). -export function createPromptEnhancer({node, input, editor, api, context, setText, t, starter = '', prepare = text => text, onReport = () => {}, onChange = () => {}, issueButton = null}) { +// `prepare` turns the user's text into what the model reads (character mentions written out with their pictures); +// `restore` turns the rewrite back into the user's terms (the mentions written as the user wrote them), and `display` +// into what the box shows; the restored rewrite is kept for the request (see prompt_sections.js). +export function createPromptEnhancer({node, input, editor, api, context, setText, t, starter = '', prepare = text => text, restore = text => text, display = text => text, onReport = () => {}, onChange = () => {}, issueButton = null}) { const saved = node.properties?.freevideo_prompt_versions; // Drafts saved before the starter became a placeholder hold it as the original. const versions = new PromptVersions(starter && saved?.original === starter ? {...saved, original: ''} : saved, input.value); @@ -91,7 +109,7 @@ export function createPromptEnhancer({node, input, editor, api, context, setText const statusText = el('span', null, 'fv-enhance-status-text'); const meter = el('div', null, 'fv-enhance-meter'), fill = el('i'); meter.setAttribute('role', 'progressbar'); meter.setAttribute('aria-valuemin', '0'); meter.setAttribute('aria-valuemax', '100'); - // The shared "Report a problem" button, handed in by the page (this file loads without imports). + // The shared "Report a problem" button, handed in by the page. const report = issueButton ? issueButton(t, () => ({where: ['Prompt enhancement', '提示词增强'], message: statusText.textContent})) : document.createElement('span'); report.hidden = true; meter.append(fill); status.append(statusText, meter, report); @@ -115,6 +133,7 @@ export function createPromptEnhancer({node, input, editor, api, context, setText install.append(detail, actions); bar.append(label, tabs, redo, cancel); element.append(bar, editor || input, status, install); let disposed = false, token = '', job = '', running = null, consent = null, cancelling = false, waiting = false, replacing = false; + let retryWait = null; let downloadOnly = false, pausing = false, modelReading = null; const modelState = {info: null, text: '', busy: false}, modelListeners = new Set(); function publishModel(change) { @@ -144,7 +163,12 @@ export function createPromptEnhancer({node, input, editor, api, context, setText const gib = value => String(Math.round(value / 2 ** 30)); // Unknown codes keep the server's own reason, without paths, so the user can quote it. const reasonOf = error => String(error?.message || '').replace(/[A-Za-z]:[\\/][^\s'"]*|\/[^\s'"]+\/[^\s'"]*/g, '').replace(/\s+/g, ' ').trim().slice(0, 120); - const errorText = error => ({ + // A dropped request names a browser extension only when the error carries evidence of one. + const networkText = error => { + const found = detectBrowserExtension(error); + return found ? browserExtensionAdvice(found, t).summary : connectionLostText(t); + }; + const errorText = error => isNetworkTypeError(error) ? networkText(error) : ({ busy: t('Finish the current queue before enhancing this prompt.', '请等待当前队列完成后再改写。'), prompt_required: t('Type a prompt first; enhancement rewrites it before generating.', '请先输入提示词,生成前会自动改写。'), setup_required: t('Complete FreeVideo installation first.', '请先完成 FreeVideo 安装。'), @@ -168,7 +192,7 @@ export function createPromptEnhancer({node, input, editor, api, context, setText }[error?.message] || t(`Could not enhance the prompt${reasonOf(error) ? `: ${reasonOf(error)}` : ''}. Retry or switch enhancement off to use the original.`, `暂时无法改写${reasonOf(error) ? `。原因:${reasonOf(error)}。` : ','}请重试,或关闭增强使用原文。`)); // After falling back the original is already in use, so only the reason and the next useful step are shown. - const fallbackText = error => ({ + const fallbackText = error => isNetworkTypeError(error) ? networkText(error) : ({ busy: t('The prompt cannot be rewritten while the queue is running.', '队列未完成时不能改写。'), setup_required: t('Rewriting is available after FreeVideo installation is complete.', 'FreeVideo 安装完成后才能改写。'), model_missing: t('Rewriting needs the optional model. Download it to enable rewriting.', '改写需要可选模型,下载后可用。'), @@ -223,7 +247,7 @@ export function createPromptEnhancer({node, input, editor, api, context, setText return data; } async function cancelJob() { - cancelling = true; consent?.(false); consent = null; render(); + cancelling = true; retryWait?.(); consent?.(false); consent = null; render(); const request = running; try { if (job) await post('cancel', {job}); } catch (error) { if (running === request) { cancelling = false; render(); } throw error; } @@ -233,11 +257,24 @@ export function createPromptEnhancer({node, input, editor, api, context, setText if (disposed || cancelling) throw new Error('cancelled'); const started = await post(action, body); job = started.job; const since = performance.now(), elapsed = () => Math.floor((performance.now() - since) / 1000); + let failures = 0; if (disposed || cancelling) await post('cancel', {job}); try { for (;;) { if (disposed || cancelling) throw new Error('cancelled'); - const row = await post('status', {job}); + let row; + try { row = await post('status', {job}); failures = 0; } + catch (error) { + if (disposed || cancelling) throw new Error('cancelled'); + if (!isNetworkTypeError(error) || ++failures > 3) throw error; + // Retry status reads only; submitting again could start a second job. + await new Promise(resolve => { + const done = () => { clearTimeout(timer); retryWait = null; resolve(); }; + const timer = setTimeout(done, 1000); + retryWait = done; + }); + continue; + } if (disposed || cancelling) throw new Error('cancelled'); if (row.rewrite_report_id) { node.properties.freevideo_prompt_report = row.rewrite_report_id; @@ -333,11 +370,12 @@ export function createPromptEnhancer({node, input, editor, api, context, setText } const result = await poll('rewrite', {text: prepare(original), ...scene}); if (disposed || cancelling) throw new Error('cancelled'); - if (edits !== versions.value.edits || !versions.accept(key, context(), result.text)) throw new Error('stale'); + const restored = restore(result.text); + if (edits !== versions.value.edits || !versions.accept(key, context(), display(restored), restored)) throw new Error('stale'); apply(); show(''); }; running = Promise.resolve().then(execute).catch(error => { - if (!disposed) show(errorText(error), error?.message === 'cancelled' ? '' : 'error'); + if (!disposed) show(errorText(error), error?.message === 'cancelled' ? '' : error?.message === 'stale' ? 'note' : 'error'); throw error; }).finally(() => { running = null; cancelling = false; install.hidden = true; render(); }); render(); @@ -384,7 +422,12 @@ export function createPromptEnhancer({node, input, editor, api, context, setText if (error?.message === 'cancelled' || cancelling || disposed) { show(errorText({message: 'cancelled'})); return false; } useOriginal(error); } - }, sync() { if (input.value !== versions.text()) changed(); }, dispose() { + }, sync(model = '') { + // The box was filled from elsewhere (a reused prompt, the node on the canvas): a new scene, not an edit. + if (input.value === versions.text() && !model) return; + if (waiting) { waiting = false; show(''); } + versions.adopt(input.value, model); replacing = false; save(); render(); + }, dispose() { disposed = true; modelListeners.clear(); prestart.dispose(); cancelJob().catch(() => {}); input.removeEventListener('input', changed); input.removeEventListener('beforeinput', replace); input.removeEventListener('focus', onFocus); diff --git a/web/prompt_references.css b/web/prompt_references.css index faddfc1c..b92ca2cf 100644 --- a/web/prompt_references.css +++ b/web/prompt_references.css @@ -1,5 +1,5 @@ .fv-prompt-editor{position:relative;display:flex;flex-direction:column;height:170px;min-height:148px;max-height:340px;resize:vertical;overflow:hidden;background:var(--fv-card)} -.fv-studio .fv-prompt-editor textarea{flex:1;min-height:0;height:0;max-height:none;resize:none;padding-bottom:8px;outline:none} +.fv-studio .fv-prompt-editor textarea{flex:1;min-height:0;height:0;max-height:none;resize:none;padding-bottom:14px;outline:none} .fv-prompt-tools{display:flex;flex:none;align-items:center;justify-content:flex-end;height:36px;padding-right:9px} .fv-studio .fv-prompt-draft-note{min-height:27px;height:27px;margin:0 auto 0 9px;padding:0 9px;border:0;background:transparent;color:var(--fv-warning);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .fv-studio .fv-prompt-draft-note[hidden]{display:none} @@ -21,7 +21,7 @@ .fv-reference-group{padding:6px 8px 3px;font-size:11px;color:var(--fv-muted,#a9b3c4)} .fv-reference-person{min-height:36px;padding:5px 8px;gap:10px} -.fv-reference-face{width:24px;height:24px;flex:none;border-radius:50%;object-fit:cover;background:var(--fv-raised,#252e39)} +.fv-reference-face{width:24px;height:24px;flex:none;border-radius:50%;object-fit:cover;object-position:50% 15%;object-view-box:inset(0 20% 40% 20%);background:var(--fv-raised,#252e39)} .fv-reference-person strong{font-size:13px;font-weight:550;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .fv-reference-person[aria-disabled=true]{opacity:.5;cursor:not-allowed} .fv-mention-mirror{position:absolute;box-sizing:border-box;overflow:hidden;pointer-events:none;white-space:pre-wrap;overflow-wrap:break-word;word-break:normal;color:var(--fv-ink,#eee);z-index:1} diff --git a/web/prompt_sections.js b/web/prompt_sections.js new file mode 100644 index 00000000..6fd04074 --- /dev/null +++ b/web/prompt_sections.js @@ -0,0 +1,110 @@ +// A rewrite in H3's sections, as the prompt box shows it: the scene, then a "Sound:" paragraph (any music joins it). +// The section names, the summary and the retention analysis stay out of sight; `modelText` puts them back when the +// prompt is sent, so an untouched rewrite reaches the model exactly as the model wrote it. +const ORDERS = [ + ['subject_definitions', 'summary', 'retention_analysis', 'detailed_description', 'overall_soundscape', 'non_diegetic_music'], + ['integrated_multimodal_description', 'overall_soundscape', 'non_diegetic_music'], +]; +const SCENE = new Set(['detailed_description', 'integrated_multimodal_description']); +const LABELS = [['en', 'Sound: '], ['zh', '声音:']]; +const NO_MUSIC = /^n\/?a\.?$/i; +// "[Shot 2] At 00:03.000, ..." in the model's words; "Shot 2 (0:03): ..." or "镜头 2(0:03):..." in the box. +const AT = /^At (\d{1,2}):(\d{2}(?:\.\d+)?),[ \t]*/; +const SHOT_HEAD = /^(?:Shot|镜头) (\d+)(?: ?[((](\d{1,2}):(\d{2}(?:\.\d+)?)[))])?[::][ \t]*/; + +// [[name, body]] when the text is exactly H3's sections in order (subject definitions may be absent), else null. +function sections(text) { + const parts = String(text ?? '').trim().split(/\n\n+(?=[a-z_]+:)/); + const rows = parts.map(part => /^([a-z_]+):[ \t]*([\s\S]*)$/.exec(part)).map(match => match && [match[1], match[2].trim()]); + if (!rows.length || rows.some(row => !row)) return null; + const names = rows.map(([name]) => name).join(); + return [ORDERS[0], ORDERS[0].slice(1), ORDERS[1]].some(order => order.join() === names) ? rows : null; +} + +// The scene's shots in order as {at, minutes, seconds, text} (a scene without markers is one shot), or null when the +// markers are not [Shot 1], [Shot 2], ... from the start. +function shots(scene) { + const pieces = scene.split(/[ \t]*\[Shot (\d+)\][ \t]*/); + if (pieces.length === 1) return [{at: '', minutes: 0, seconds: '00', text: scene.trim()}]; + if (pieces[0].trim()) return null; + const list = []; + for (let i = 1; i < pieces.length; i += 2) { + if (Number(pieces[i]) !== list.length + 1) return null; + const at = AT.exec(pieces[i + 1]); + list.push({at: at ? at[0].trim() : '', minutes: at ? Number(at[1]) : 0, seconds: at ? at[2] : '00', text: pieces[i + 1].slice(at ? at[0].length : 0).trim()}); + } + return list; +} + +const clock = (minutes, seconds) => `${minutes}:${seconds.replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '')}`; + +// What the box shows for a rewrite, or null to show it as it is. A single shot shows without its marker; several shots +// each start a paragraph with their number and start time. +export function shownRewrite(text, language = 'en') { + const rows = sections(text); + if (!rows) return null; + const get = name => rows.find(([key]) => key === name)?.[1] ?? ''; + const list = shots(rows.find(([key]) => SCENE.has(key))[1]); + if (!list) return null; + const zh = language === 'zh'; + const scene = list.length === 1 ? list[0].text : list.map((shot, index) => { + const time = clock(shot.minutes, shot.seconds); + return (zh ? `镜头 ${index + 1}(${time}):` : `Shot ${index + 1} (${time}): `) + shot.text; + }).join('\n\n'); + const music = get('non_diegetic_music'); + const sound = [get('overall_soundscape'), NO_MUSIC.test(music) ? '' : music].filter(Boolean).join(' '); + return scene + '\n\n' + (zh ? LABELS[1][1] : LABELS[0][1]) + sound; +} + +// The scene section for the scene part of an edited box. Paragraphs that open with a shot number become that shot, +// keeping the model's own time when the shown one is unchanged; anything before the first is the first shot. +function sceneFromBox(box, original) { + const shot = []; + for (const paragraph of box.split(/\n\n+/)) { + const head = SHOT_HEAD.exec(paragraph); + if (head || !shot.length) shot.push({head, text: paragraph.slice(head ? head[0].length : 0).trim()}); + else shot[shot.length - 1].text += ' ' + paragraph.trim(); + } + if (shot.length === 1 && !shot[0].head) return '[Shot 1] ' + shot[0].text; + return shot.map(({head, text}, index) => { + const old = original?.[index]; + let at = ''; + if (head?.[2] !== undefined) { + const time = clock(Number(head[2]), head[3]); + at = old && clock(old.minutes, old.seconds) === time ? old.at + : time === '0:00' && index === 0 ? '' : `At ${head[2].padStart(2, '0')}:${head[3]},`; + } else if (old?.at) at = old.at; + return `[Shot ${index + 1}] ${at ? at + ' ' : ''}${text}`; + }).join(' '); +} + +// The text sent to the model for what the box holds, given the rewrite it was shown from. An edited box keeps the hidden +// definitions and retention analysis but drops the summary, which may no longer match the edited scene. +export function modelText(shown, rewrite) { + const rows = sections(rewrite); + if (!rows || LABELS.some(([code]) => shownRewrite(rewrite, code) === shown)) return rows ? rewrite : shown; + const get = name => rows.find(([key]) => key === name)?.[1] ?? ''; + const [sceneName, sceneText] = rows.find(([key]) => SCENE.has(key)); + const box = String(shown ?? '').trim(); + // The last paragraph that opens with "Sound:" or "声音:" (in either language) is the sound; the music goes on while + // its words still end it. Without one, the whole box is the scene and no sound is described, as the box shows. + let scene = box, soundscape = 'N/A', music = get('non_diegetic_music'); + const heads = [...box.matchAll(/(?:^|\n\n+)(?:Sound:|声音:)[ \t]*/g)]; + const head = heads[heads.length - 1]; + let sound = head ? box.slice(head.index + head[0].length).trim() : ''; + if (head) scene = box.slice(0, head.index).trim(); + if (!NO_MUSIC.test(music) && sound.endsWith(music)) sound = sound.slice(0, -music.length).trim(); + else music = 'N/A'; + soundscape = sound || soundscape; + const keep = rows.filter(([name]) => name === 'subject_definitions' || name === 'retention_analysis'); + return [...keep, [sceneName, sceneFromBox(scene, shots(sceneText))], ['overall_soundscape', soundscape], ['non_diegetic_music', music]] + .map(([name, body]) => `${name}: ${body}`).join('\n\n'); +} + +// The prompt widget's text for the request: text the box shows in short (a rewrite, or H3 text put in the box from +// elsewhere) goes back in the model's sections while the widget still holds what the box last recorded. +export function requestText(text, versions) { + const side = versions?.selected === 'rewritten' ? 'rewritten' : 'original'; + const model = side === 'rewritten' ? versions?.model : versions?.originalModel; + return model && versions[side] === text ? modelText(text, model) : text; +} diff --git a/web/setup.css b/web/setup.css index db8bed7a..f363cbe7 100644 --- a/web/setup.css +++ b/web/setup.css @@ -54,6 +54,7 @@ } .fv-setup :is(input,textarea,select):focus{border-color:var(--fv-accent)} .fv-setup input.fv-number{display:inline-block;width:96px;margin:0 6px} +.fv-setup input.fv-minutes:not([type=checkbox]):not([type=range]){display:inline-block;width:96px;margin:0 6px} .fv-setup textarea{resize:vertical;line-height:1.6} .fv-setup label.fv-check{display:flex;align-items:flex-start;gap:9px;color:var(--fv-ink)} .fv-setup input[type=checkbox]{flex:none;width:16px;height:16px;margin:3px 0 0;accent-color:var(--fv-accent)} diff --git a/web/setup.js b/web/setup.js index 22b0b8d9..9aba385a 100644 --- a/web/setup.js +++ b/web/setup.js @@ -4,6 +4,7 @@ import { openCompatibility } from './compatibility.js'; import { animateDetails, closeDialog } from './motion.js'; import { createGuideReplayEntry } from './first_run_guide.js'; import { issueButton } from './issue_button.js'; +import { agentConfirmSettings } from './agent_confirm_settings.js'; const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./setup.css', import.meta.url).href; @@ -165,7 +166,8 @@ export async function openSetup() { const {openCreativeWorkspace} = await import('./view_navigation.js'); return openCreativeWorkspace(); }}); - dialog.append(heading, installation, downloads, advanced, guideReplay); + const agentConfirm = agentConfirmSettings(t, api); + dialog.append(heading, installation, downloads, agentConfirm.element, advanced, guideReplay); const motions = [...dialog.querySelectorAll('details')].map(animateDetails); dialog.addEventListener('cancel', e => { e.preventDefault(); closeDialog(dialog); }); document.body.append(dialog); dialog.showModal(); @@ -252,7 +254,7 @@ export async function openSetup() { table.append(header); for (const [family, rows] of Object.entries(measured.sources)) for (const row of rows) { const tr = node('tr'); - const names = {'edge-models':t('Video model','视频模型'),'vdn-models':t('Decoder','解码器'),models:t('Prompt model','提示词模型'),pypi:t('Python packages','Python 依赖'),github:t('Tools','安装工具'),git:'Git',cuda:'CUDA'}; + const names = {'edge-models':t('Video model','视频模型'),'vdn-models':t('Decoder','解码器'),'encoder-models':t('Text encoder','文本编码器'),models:t('Prompt model','提示词模型'),pypi:t('Python packages','Python 依赖'),github:t('Tools','安装工具'),git:'Git',cuda:'CUDA'}; if (family.startsWith('torch-')) names[family] = t('GPU packages','GPU 依赖'); const rate = row.bytes_per_second / 2**20; const speed = !row.ok ? t('Unavailable','未连通') : row.speed_measured === false || !Number.isFinite(rate) || rate <= 0 ? diff --git a/web/studio.css b/web/studio.css index 9935c12a..ed8cf5ff 100644 --- a/web/studio.css +++ b/web/studio.css @@ -172,23 +172,36 @@ @media(min-width:681px){.fv-studio .fv-controls{display:flex;flex-direction:column}.fv-studio .fv-controls>.fv-runbar{margin-top:auto}} @media(max-width:900px){.fv-studio{width:calc(100vw - 24px);height:calc(100dvh - 24px)}.fv-studio .fv-body{grid-template-columns:360px minmax(0,1fr)}.fv-studio .fv-preview-column{padding:22px 18px}.fv-studio .fv-controls{padding:22px 18px 0}.fv-studio .fv-header{padding:0 18px}.fv-studio .fv-stats{grid-template-columns:1fr 1fr}.fv-studio .fv-title{display:none}.fv-studio .fv-header{grid-template-columns:1fr auto}} @media(max-width:680px){.fv-studio{width:calc(100vw - 16px);height:calc(100dvh - 16px);border-radius:var(--fv-r-md);overflow:auto}.fv-studio .fv-header{height:65px;position:sticky;top:0;background:var(--fv-bg);z-index:3}.fv-studio .fv-body{display:flex;flex-direction:column;height:auto}.fv-studio .fv-controls{overflow:visible;border-right:0;border-bottom:1px solid var(--fv-border)}.fv-studio .fv-preview-column{min-height:420px}.fv-studio .fv-preview-space{height:min(65dvh,600px);min-height:260px;flex:none}.fv-studio .fv-runbar{position:static}.fv-studio .fv-stats{grid-template-columns:repeat(4,minmax(0,1fr))}.fv-studio .fv-stat strong{font-size:var(--fv-body)}} -/* Recent videos only use space on wide windows; a zero third track restores the filled layout. */ -.fv-studio .fv-recent,.fv-studio .fv-recent-toggle{display:none} +/* Recent videos take a column on wide windows; a zero third track restores the filled layout. + Narrower windows open the same list as a panel over the preview, so the settings keep their width. */ +.fv-studio .fv-recent{display:none} +.fv-studio .fv-preview-tools .fv-recent-toggle{display:inline-flex;width:var(--fv-h-sm);padding:5px} +.fv-studio .fv-recent-toggle:is([aria-pressed=true],[aria-expanded=true]){color:var(--fv-accent);border-color:var(--fv-accent);background:var(--fv-selected)} +.fv-recent-head{min-height:32px;box-sizing:content-box;padding:26px 20px 12px;font-size:var(--fv-body);font-weight:var(--fv-medium);white-space:nowrap} +.fv-recent-list{flex:1;min-height:0;overflow:auto;padding:0 10px 16px;display:flex;flex-direction:column;gap:2px;scrollbar-width:thin;scrollbar-color:var(--fv-border) transparent;overscroll-behavior:contain} +.fv-studio .fv-recent-card{display:grid;grid-template-columns:112px minmax(0,1fr);grid-template-rows:auto auto;column-gap:12px;row-gap:2px;align-items:center;justify-content:stretch;flex:none;padding:6px;min-height:0;border:1px solid transparent;border-radius:var(--fv-r-md);background:transparent;text-align:left;font-weight:var(--fv-regular)} +.fv-recent-card .fv-library-picture{grid-row:1/3;width:112px;aspect-ratio:16/10} +.fv-recent-card>span{align-self:end;font-size:12px;color:var(--fv-ink)} +.fv-recent-card>small{align-self:start;font-size:11px;color:var(--fv-muted);font-variant-numeric:tabular-nums} @media(min-width:1680px){ .fv-studio:not(.fv-library) .fv-body{grid-template-columns:460px minmax(0,1fr) 0;transition:grid-template-columns 200ms ease} .fv-studio:not(.fv-library) .fv-body[data-recent=true]{grid-template-columns:440px minmax(0,1fr) 296px} .fv-studio .fv-recent{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden;border-left:1px solid var(--fv-border);background:color-mix(in srgb,var(--fv-bg) 70%,#000)} .fv-studio .fv-body[data-recent=false] .fv-recent{visibility:hidden;border-left-width:0} - .fv-studio .fv-preview-tools .fv-recent-toggle{display:inline-flex;width:var(--fv-h-sm);padding:5px} - .fv-studio .fv-recent-toggle[aria-pressed=true]{color:var(--fv-accent);border-color:var(--fv-accent);background:var(--fv-selected)} /* fitPreview follows the animated grid; a second size transition would delay the picture. */ .fv-studio .fv-body.fv-recent-resizing .fv-stage{transition:none} - .fv-recent-head{min-height:32px;box-sizing:content-box;padding:26px 20px 12px;font-size:var(--fv-body);font-weight:var(--fv-medium);white-space:nowrap} - .fv-recent-list{flex:1;min-height:0;overflow:auto;padding:0 10px 16px;display:flex;flex-direction:column;gap:2px;scrollbar-width:thin;scrollbar-color:var(--fv-border) transparent;overscroll-behavior:contain} - .fv-studio .fv-recent-card{display:grid;grid-template-columns:112px minmax(0,1fr);grid-template-rows:auto auto;column-gap:12px;row-gap:2px;align-items:center;justify-content:stretch;flex:none;padding:6px;min-height:0;border:1px solid transparent;border-radius:var(--fv-r-md);background:transparent;text-align:left;font-weight:var(--fv-regular)} - .fv-recent-card .fv-library-picture{grid-row:1/3;width:112px;aspect-ratio:16/10} - .fv-recent-card>span{align-self:end;font-size:12px;color:var(--fv-ink)} - .fv-recent-card>small{align-self:start;font-size:11px;color:var(--fv-muted);font-variant-numeric:tabular-nums} +} +@media(max-width:1679px){ + .fv-studio:not(.fv-library) .fv-body{position:relative} + .fv-studio .fv-body[data-recent-panel=true] .fv-recent{display:flex;flex-direction:column;position:absolute;z-index:5;top:var(--fv-recent-top,72px);right:var(--fv-recent-right,24px);bottom:16px;width:min(320px,calc(100% - 16px));overflow:hidden;border:1px solid var(--fv-border);border-radius:var(--fv-r-md);background:var(--fv-card);box-shadow:0 14px 45px #0006;animation:fv-preview-appear .16s ease} + .fv-studio .fv-recent:focus{outline:none} + .fv-studio .fv-recent .fv-recent-head{min-height:0;padding:14px 16px 10px} + .fv-studio .fv-recent .fv-recent-list{padding:0 8px 10px} + .fv-studio .fv-recent .fv-recent-card:not([aria-pressed=true]):hover{background:var(--fv-hover)} +} +@media(max-width:680px){ + /* Below the sticky header, which stays in place when the window scrolls. */ + .fv-studio .fv-body[data-recent-panel=true] .fv-recent{z-index:2;left:var(--fv-recent-right,12px);width:auto;bottom:auto;height:min(70dvh,560px)} } @media(min-width:2200px){.fv-studio:not(.fv-library) .fv-body[data-recent=true]{grid-template-columns:460px minmax(0,1fr) 320px}} @media(prefers-reduced-motion:reduce){.fv-studio *,.fv-studio *::before,.fv-studio *::after{transition:none!important;animation:none!important}} diff --git a/web/studio.js b/web/studio.js index 1ff04782..e30040a1 100644 --- a/web/studio.js +++ b/web/studio.js @@ -21,10 +21,10 @@ import { openLibrary } from './library.js'; import { createRecentVideos } from './studio_recent.js'; import { createAgentActivity } from './agent_activity.js'; import { createStudioQueue, randomSeed, turnNodeOn } from './studio_queue.js'; -import { isNetworkTypeError, connectionLostText } from './browser_extension.js'; +import { isNetworkTypeError, connectionLostText, wrappedFetchOnly } from './browser_extension.js'; import { attachReferencePicker, referenceItems, enhancementMedia, syncReferencePrompt, referenceInputs, mentionAt, handleNames, mediaRows } from './prompt_references.js'; import { attachTermSuggest } from './prompt_terms.js'; -import { castInRows, expandCast, mentionHandle, mentionState, mentionedIds, withCharacter, MAX_PICTURES_EACH } from './cast_mention.js'; +import { castInRows, expandCast, mentionHandle, mentionState, mentionedIds, restoreCast, withCharacter, MAX_PICTURES_EACH } from './cast_mention.js'; import { attachMentionOverlay } from './cast_overlay.js'; import { openCastLibrary } from './cast_source.js'; import { uploadMedia } from './media_upload.js'; @@ -34,6 +34,7 @@ import { continuationUpscale, createSamplingEffort, effortFor, effortsFrom, pass import { resultActions, previewBadge, icon, actionsUnavailable } from './result_actions.js'; import { framesOf } from './result_reuse.js'; import { createPromptEnhancer } from './prompt_enhance.js'; +import { requestText, shownRewrite } from './prompt_sections.js'; import { createRequestAssets, enabledLoras, referenceFiles } from './request_assets.js'; import { promptDraftTrouble, rememberPromptDraft, restorePromptDraft, savePromptDraft, trackPromptDraft, watchPromptDraftTrouble } from './prompt_draft.js'; import { canvasDimensions, imageSizeSelection } from './studio_size.js'; @@ -684,14 +685,15 @@ export function openStudio(node, {extendFrom = null} = {}) { const field = (name, input) => { const label = el('label', null, 'fv-field'); label.append(el('span', name), input); return label; }; const expand = label => { const d = el('details', null, 'fv-section'); d.append(el('summary', label, 'fv-section-title')); const content = el('div', null, 'fv-expand'); d.append(content); controls.append(d); cleanup.push(animateDetails(d)); return [d, content]; }; syncReferencePrompt(node, t); - // An untouched node shows its starter prompt as a placeholder and generates it. - // Once something was typed, an emptied box stays empty and asks for a scene. + // An untouched node shows its starter prompt as a placeholder, in the same readable form as a rewrite, and generates + // it as written. Once something was typed, an emptied box stays empty and asks for a scene. const starter = node.freevideoStarterPrompt || ''; + const starterShown = shownRewrite(starter, t('en', 'zh')) ?? starter; const untouched = () => Boolean(starter) && value(node, 'text') === starter; const shown = text => starter && text === starter ? '' : String(text || ''); const prompt = el('textarea'); prompt.value = shown(value(node, 'text')); prompt.setAttribute('aria-label', t('Prompt', '提示词')); prompt.dataset.fvGuide = 'describe'; - const placeholder = () => { prompt.placeholder = extension.source ? extendText(t, 'prompt') : untouched() ? starter : t('Describe the scene… Type @ to reference media', '描述画面… 输入 @ 引用素材'); }; + const placeholder = () => { prompt.placeholder = extension.source ? extendText(t, 'prompt') : untouched() ? starterShown : t('Describe the scene… Type @ to reference media', '描述画面… 输入 @ 引用素材'); }; placeholder(); prompt.disabled = linked(node, 'text'); prompt.oninput = () => { set(node, 'text', prompt.value); placeholder(); }; const promptEditor = el('div', null, 'fv-prompt-editor'); promptEditor.append(prompt); @@ -817,9 +819,11 @@ export function openStudio(node, {extendFrom = null} = {}) { cleanup.push(() => { castLibrary?.destroy?.(); castLibrary = null; window.removeEventListener('freevideo-media', castChanged); prompt.removeEventListener('input', announceCast); castOverlay.dispose(); delete node.freevideoContinues; }); openCastLibrary({api, text: t, viewURL: view, uploadOne: castUpload, onChange: () => { castOverlay.refresh(); references.refresh(); }}) .then(library => { if (disposed) library?.destroy?.(); else castLibrary = library; }); - let requestAssets = null; + let requestAssets = null, rewriteSubjects = []; const enhancer = createPromptEnhancer({node, input: prompt, editor: promptEditor, api, t, starter, issueButton, - prepare: text => expandCast(text, mediaRows(node), {continued: Boolean(extension.source)}).text, + // One rewrite runs at a time, so the subjects of the text it was given are the ones its numbers refer to. + prepare: text => { const out = expandCast(text, mediaRows(node), {continued: Boolean(extension.source)}); rewriteSubjects = out.subjects; return out.text; }, + restore: text => restoreCast(text, rewriteSubjects), display: text => shownRewrite(text, t('en', 'zh')) ?? text, // The rewrite has its own report; the video's report entry is not touched. onReport: row => { node.freevideoPromptReportId = row.rewrite_report_id || row.report_id; }, onChange: () => { rememberPromptDraft(api, node); requestAssets?.refresh(); }, @@ -831,7 +835,17 @@ export function openStudio(node, {extendFrom = null} = {}) { requestAssets = createRequestAssets({t, api, request: () => requestAssetsQuery?.()}); cleanup.push(() => requestAssets.dispose()); let requestAssetsQuery = null; - const promptChanged = e => { if (String(e.detail) === String(node.id)) { if (prompt.value !== shown(value(node, 'text'))) prompt.value = shown(value(node, 'text')); prompt.disabled = linked(node, 'text'); mention.disabled = prompt.disabled; placeholder(); castOverlay.refresh(); references.refresh(); announceCast(); enhancer.sync(); } }; + // H3 text put in the box from elsewhere (a reused prompt, an older workflow) shows in the same readable form as a + // rewrite; the enhancer keeps the model's words and sends them as written until the box is cleared or replaced. + const showPrompt = () => { + const text = value(node, 'text'); + const readable = untouched() || linked(node, 'text') ? null : shownRewrite(text, t('en', 'zh')); + if (readable !== null) set(node, 'text', readable); + if (prompt.value !== shown(value(node, 'text'))) prompt.value = shown(value(node, 'text')); + enhancer.sync(readable === null ? '' : text); + }; + showPrompt(); + const promptChanged = e => { if (String(e.detail) === String(node.id)) { prompt.disabled = linked(node, 'text'); showPrompt(); mention.disabled = prompt.disabled; placeholder(); castOverlay.refresh(); references.refresh(); announceCast(); } }; window.addEventListener('freevideo-reference-prompt', promptChanged); cleanup.push(() => window.removeEventListener('freevideo-reference-prompt', promptChanged)); const promptSection = section(t('Describe your scene', '描述画面')); @@ -1441,7 +1455,7 @@ export function openStudio(node, {extendFrom = null} = {}) { const reuse = { now: saved => { const sizeLocked = dimensionsLinked || Boolean(extension.source), lengthLocked = linked(node, 'seconds') || Boolean(extension.source); - return {text: prompt.disabled ? saved.text : prompt.value, width: sizeLocked ? saved.width : Number(value(node, 'width')), + return {text: prompt.disabled ? saved.text : requestText(prompt.value, node.properties?.freevideo_prompt_versions), width: sizeLocked ? saved.width : Number(value(node, 'width')), height: sizeLocked ? saved.height : Number(value(node, 'height')), frames: lengthLocked ? saved.frames : framesOf(seconds.value), seconds: Number(seconds.value)}; }, @@ -1692,7 +1706,7 @@ export function openStudio(node, {extendFrom = null} = {}) { lastQueueError = identity; const report = failure.show(error, error?.schema === 'freevideo.error-report', {stage: 'queue', node, graph: app.graph}); node.freevideoFailureReport = report; node.freevideoFailure = errorText(report); - connectionNotice = isNetworkTypeError(error) && !report.browser_extension; + connectionNotice = isNetworkTypeError(error) && (!report.browser_extension || wrappedFetchOnly(report.browser_extension)); } // The queue read works again: take the lost-connection notice down. A failure card for anything else stays. function clearConnectionNotice() { @@ -2122,7 +2136,8 @@ export function openStudio(node, {extendFrom = null} = {}) { cleanup.push(listenDialogKey(dialog, 'keydown', escape)); cleanup.push(isolateDialogKeys(dialog, event => routeDialogKey(event, dialog))); - dialog.addEventListener('cancel', e => { e.preventDefault(); closeDialog(dialog); }); + // Escape first closes the recent videos panel of a narrower window, then the studio. + dialog.addEventListener('cancel', e => { e.preventDefault(); if (!recent.dismiss()) closeDialog(dialog); }); dialog.onclose = () => { disposed = true; blockOriginal('extend', false); blockOriginal('preview', false); for (const f of cleanup) f(); stage.querySelector('video')?.pause(); dialog.remove(); if (current?.dialog === dialog) { current = null; viewChanged('nodes', node); } }; capabilityView = createCapabilityView({node, api, t, getState: () => ({ratio, custom: selected === 'custom', steps: Number(baseSteps.value), twoPass: twoPass.checked, @@ -2162,7 +2177,7 @@ export function openStudio(node, {extendFrom = null} = {}) { } twoPass.checked = Boolean(source) || value(node, 'two_pass') !== false; previewFirst.checked = !source && value(node, 'preview') === true; - prompt.value = shown(value(node, 'text')); placeholder(); enhancer.sync(); + showPrompt(); placeholder(); // The media panel and the @ list read whether a video is being continued. if (mediaNode) window.dispatchEvent(new CustomEvent('freevideo-media', {detail: mediaNode.id})); syncSamplingSteps(); syncCanvasSize(); seconds.dispatchEvent(new Event('input')); diff --git a/web/studio_chain.js b/web/studio_chain.js index c6440c0f..0f812787 100644 --- a/web/studio_chain.js +++ b/web/studio_chain.js @@ -1,5 +1,5 @@ import {chainRoute, createPoller} from './chain_support.js'; -import {buildChainRequest} from './length_plan.js?v=20261011-length'; +import {buildChainRequest} from './length_plan.js?v=20261027-length'; export const CHAIN_PROPERTY = 'freevideo_chain'; const activeStates = new Set(['queued', 'running', 'cancelling']); diff --git a/web/studio_length.js b/web/studio_length.js index 5c58d773..9e7f7816 100644 --- a/web/studio_length.js +++ b/web/studio_length.js @@ -1,6 +1,6 @@ import {capabilityPoints, interpolate} from './chain_support.js'; import {issueButton} from './issue_button.js'; -import {buildPlanRequest, readLengthPlan, classifyPlanError, singleLimitSeconds, defaultBlockSeconds} from './length_plan.js?v=20261011-length'; +import {buildPlanRequest, readLengthPlan, classifyPlanError, singleLimitSeconds, defaultBlockSeconds} from './length_plan.js?v=20261027-length'; export const LENGTH_PROPERTIES = Object.freeze({seconds: 'freevideo_seconds', single: 'freevideo_single', block: 'freevideo_block_seconds'}); const positive = value => typeof value === 'number' && Number.isFinite(value) && value > 0; @@ -133,6 +133,8 @@ export function createStudioLength({api, node, seconds, advanced, fields, t, fie continuation_unavailable: noParts ? NO_PARTS : t('Choose a smaller picture size to make a longer video.', '如需生成更长的视频,请选择更小的画面大小。'), too_long: TOO_LONG() + storyboardText(), + block_too_short: t('The part length is too short. Set a longer part length.', '每段时长过短,请加长。'), + block_too_long: t('The part length is longer than one run allows at this size. Set a shorter part length.', '每段时长超过当前画面大小的单次上限,请缩短。'), other: t('Could not plan this video. Try again.', '暂时无法安排这条视频的分段,请重试。')}[kind]; } function invalidate() { @@ -150,7 +152,11 @@ export function createStudioLength({api, node, seconds, advanced, fields, t, fie const reply = response.status === 404 ? null : await response.json(); if (disposed || controller.signal.aborted || version !== revision) return null; if (response.status === 404) { absent = true; plan = null; error = null; } - else if (response.status === 400) error = classifyPlanError(reply); + else if (response.status === 400) { + error = classifyPlanError(reply); + // A part length the user cannot edit is not the cause: the device limit is. + if (block.disabled && (error === 'block_too_short' || error === 'block_too_long')) error = 'continuation_unavailable'; + } else if (response.ok) { plan = readLengthPlan(reply); if (!plan) error = 'other'; } else error = 'other'; } catch { if (!controller.signal.aborted && version === revision) error = 'other'; } diff --git a/web/studio_queue.js b/web/studio_queue.js index 801af7dd..89c5c35e 100644 --- a/web/studio_queue.js +++ b/web/studio_queue.js @@ -1,6 +1,6 @@ // ComfyUI owns execution. This controller only submits frozen workflows and // replenishes a loop after its previous request has actually completed. -import {fetchQueueRead, detectBrowserExtension, isNetworkTypeError} from './browser_extension.js'; +import {fetchQueueRead, detectBrowserExtension, isNetworkTypeError, wrappedFetchOnly} from './browser_extension.js'; import {clonePrompt} from './optional_inputs.js'; import {isStaleProgress} from './progress_connection.js'; const clone = clonePrompt; @@ -131,7 +131,7 @@ export function createStudioQueue(api, nodeId, {random = randomSeed, pollMs = 20 nodeId = String(nodeId); let running = null, pending = [], submitting = false, loop = null, timer = null; let runningAll = [], pooled = false; - let refreshing = null, refreshAgain = false, lastError = null, disposed = false, outage = null; + let refreshing = null, refreshAgain = false, lastError = null, disposed = false, outage = null, submitError = null; const listeners = new Set(); const owned = new Set(), delivered = new Set(); function remember(id) { owned.add(id); if (owned.size > 128) { const old = owned.values().next().value; owned.delete(old); delivered.delete(old); } } @@ -224,6 +224,8 @@ export function createStudioQueue(api, nodeId, {random = randomSeed, pollMs = 20 } session.completed++; session.sending = true; publish(); try { await submit(session, true); refreshAgain = true; } + // The server may have accepted the request even though its reply was lost: submitting again could queue a second job. + catch (error) { submitError = error; throw error; } finally { session.sending = false; } } async function readQueue() { @@ -235,10 +237,12 @@ export function createStudioQueue(api, nodeId, {random = randomSeed, pollMs = 20 // Comfy returns its heap array; the queue number gives the real running order. pending = (queue.queue_pending || []).filter(matches).sort((a, b) => a[0] - b[0]).map(entry); pooled = [...runningAll, ...pending].some(row => row.executor) || runningAll.length > 1; + for (const row of [...runningAll, ...pending]) remember(row.id); + // A failing history read of a loop counts as part of the same outage: the count restarts only after the whole round worked. + await advanceLoop(); outage = null; if (isNetworkTypeError(lastError) && !lastError.loopStopped) lastError = null; - for (const row of [...runningAll, ...pending]) remember(row.id); - await advanceLoop(); publish(); + publish(); } function refresh() { if (disposed) return Promise.resolve(); @@ -246,7 +250,9 @@ export function createStudioQueue(api, nodeId, {random = randomSeed, pollMs = 20 refreshing = (async () => { do { refreshAgain = false; await readQueue(); } while (refreshAgain && !disposed); })().catch(error => { - if (!isNetworkTypeError(error) || detectBrowserExtension(error)) { fail(error); throw error; } + // Only a wrapped fetch is not proof of a blocking extension: it is counted as an outage like any other. + const extension = detectBrowserExtension(error); + if (!isNetworkTypeError(error) || error === submitError || (extension && !wrappedFetchOnly(extension))) { fail(error); throw error; } // A lost connection does not stop a loop at first: it only continues after a successful read. outage = {failures: (outage?.failures || 0) + 1, since: outage?.since ?? Date.now()}; if (outage.failures < OUTAGE_POLLS) return; diff --git a/web/studio_recent.js b/web/studio_recent.js index a9de9abd..f1ded0f5 100644 --- a/web/studio_recent.js +++ b/web/studio_recent.js @@ -18,24 +18,68 @@ export function createRecentVideos(t, {body, onSelect}) { element.append(heading, list); const toggle = el('button', null, 'fv-recent-toggle'); toggle.type = 'button'; toggle.append(icon('sidebar')); toggle.setAttribute('aria-controls', element.id); - let expanded = true, selected = null, disposed = false, request = null, resizeTimer = null; + element.tabIndex = -1; + // Wide windows give the list its own column, remembered across visits. Narrower windows show it + // as a panel over the preview; it starts closed each time, so a saved column never covers the picture. + const wide = window.matchMedia('(min-width:1680px)'); + let expanded = true, open = false, selected = null, disposed = false, request = null, resizeTimer = null; const cards = new Map(); try { expanded = localStorage.getItem(KEY) !== 'false'; } catch {} function visibility() { + const shown = wide.matches ? expanded : open; body.dataset.recent = String(expanded); - toggle.setAttribute('aria-pressed', String(expanded)); - const label = expanded ? t('Hide recent videos', '收起最近作品') : t('Show recent videos', '显示最近作品'); + body.dataset.recentPanel = String(!wide.matches && open); + // The column is a pressed state of the layout; the panel is a disclosure that opens and closes. + toggle.setAttribute(wide.matches ? 'aria-pressed' : 'aria-expanded', String(shown)); + toggle.removeAttribute(wide.matches ? 'aria-expanded' : 'aria-pressed'); + const label = shown ? t('Hide recent videos', '收起最近作品') : t('Show recent videos', '显示最近作品'); toggle.title = label; toggle.setAttribute('aria-label', label); - element.inert = !expanded; element.setAttribute('aria-hidden', String(!expanded)); + element.inert = !shown; element.setAttribute('aria-hidden', String(!shown)); } visibility(); + // The panel hangs below the preview's tools, its right edge in line with theirs. + function place() { + const tools = toggle.parentElement?.getBoundingClientRect(), frame = body.getBoundingClientRect(); + if (!tools) return; + body.style.setProperty('--fv-recent-top', `${Math.max(8, Math.round(tools.bottom - frame.top + 8))}px`); + body.style.setProperty('--fv-recent-right', `${Math.max(8, Math.round(frame.right - tools.right))}px`); + } + function showPanel() { + if (open || wide.matches) return; + place(); open = true; visibility(); + const card = cards.get(selected) || cards.values().next().value; + (card || element).focus({preventScroll: true}); + card?.scrollIntoView({block: 'nearest'}); + element.scrollIntoView({block: 'nearest'}); + } + // Returns whether a panel was open. Escape and a chosen video hand focus back to the button. + function hidePanel({restoreFocus = false} = {}) { + if (!open) return false; + open = false; visibility(); + if (restoreFocus) toggle.focus({preventScroll: true}); + return true; + } toggle.onclick = () => { + if (!wide.matches) { if (open) hidePanel(); else showPanel(); return; } // Let fitPreview follow the 200 ms grid change without a second size transition. clearTimeout(resizeTimer); body.classList.add('fv-recent-resizing'); resizeTimer = setTimeout(() => body.classList.remove('fv-recent-resizing'), 250); expanded = !expanded; visibility(); try { localStorage.setItem(KEY, String(expanded)); } catch {} }; + // A press anywhere else, or focus moving elsewhere, closes the panel without taking focus. + const outside = event => { + if (open && !element.contains(event.target) && !toggle.contains(event.target)) hidePanel(); + }; + element.addEventListener('focusout', event => { + const next = event.relatedTarget; + if (open && next && !element.contains(next) && next !== toggle) hidePanel(); + }); + document.addEventListener('pointerdown', outside, true); + const layout = () => { open = false; visibility(); }; + const resized = () => { if (open) place(); }; + wide.addEventListener('change', layout); + window.addEventListener('resize', resized); const thumbnails = new IntersectionObserver(entries => { for (const {target, isIntersecting} of entries) if (isIntersecting) { target.src = target.dataset.src; thumbnails.unobserve(target); @@ -59,7 +103,7 @@ export function createRecentVideos(t, {body, onSelect}) { const card = el('button', null, 'fv-recent-card'); card.type = 'button'; const date = videoTimestamp(row, t), dimensions = videoDimensions(row); card.setAttribute('aria-label', `${date} · ${dimensions}`); - card.onclick = () => onSelect(row); + card.onclick = () => { onSelect(row); hidePanel({restoreFocus: true}); }; const picture = el('div', null, 'fv-library-picture'); const image = el('img'); image.alt = ''; image.decoding = 'async'; image.dataset.src = videoThumbnailURL(row); @@ -91,8 +135,10 @@ export function createRecentVideos(t, {body, onSelect}) { window.addEventListener('freevideo-result', completed); window.addEventListener('freevideo-library-deleted', deleted); window.addEventListener('freevideo-library-restored', refresh); - return {element, toggle, refresh, setSelected, dispose() { + return {element, toggle, refresh, setSelected, dismiss: () => hidePanel({restoreFocus: true}), dispose() { disposed = true; request?.abort(); thumbnails.disconnect(); + document.removeEventListener('pointerdown', outside, true); + wide.removeEventListener('change', layout); window.removeEventListener('resize', resized); clearTimeout(resizeTimer); body.classList.remove('fv-recent-resizing'); window.removeEventListener('freevideo-result', completed); window.removeEventListener('freevideo-library-deleted', deleted); diff --git a/web/theme.css b/web/theme.css index 2eb43a3d..d8c27790 100644 --- a/web/theme.css +++ b/web/theme.css @@ -1,6 +1,10 @@ /* One token table for the web views, mirroring freevideo_engine/branding.py so a control looks the same in the launcher and in ComfyUI. Scope the tokens to our own views; never change ComfyUI's theme. */ +/* Curly single quotes from a Latin face where Segoe UI is missing: the CJK faces after it draw them full width. + Only Linux faces are named, so other systems keep their own quotes. */ +@font-face{font-family:"FreeVideo Quotes";font-weight:100 500;unicode-range:U+2018-2019;src:local("Noto Sans Regular"),local("NotoSans-Regular"),local("DejaVu Sans"),local("Liberation Sans")} +@font-face{font-family:"FreeVideo Quotes";font-weight:600 900;unicode-range:U+2018-2019;src:local("Noto Sans Bold"),local("NotoSans-Bold"),local("DejaVu Sans Bold"),local("Liberation Sans Bold")} :where(.fv-phone,.fv-lan,.fv-studio,.fv-setup,.fv-panel,.fv-generation-progress,.fv-view-navigation,.fv-prompt-guide,.fv-failure-dialog,.fv-reference-picker,.fv-release-notes,.fv-image-editor,.fv-cast-dialog,.fv-tutorial-book,.fv-feature-tour){ /* Surfaces, from the backdrop up to a hovered control; mirrors branding.py. */ --fv-bg:#111720;--fv-canvas:#161e29;--fv-card:#1a222e;--fv-raised:#212a37;--fv-hover:#2a3341; @@ -24,7 +28,7 @@ --fv-ease-out:cubic-bezier(.22,1,.36,1);--fv-ease:cubic-bezier(.4,0,.2,1); --fv-quick:.14s;--fv-base:.24s;--fv-slow:.42s; --fv-step:4px; - --fv-font:"Segoe UI","Microsoft YaHei UI","Noto Sans CJK SC","Noto Sans SC",system-ui,sans-serif; + --fv-font:"Segoe UI","FreeVideo Quotes","Microsoft YaHei UI","Noto Sans CJK SC","Noto Sans SC",system-ui,sans-serif; font-family:var(--fv-font); color-scheme:dark; }