diff --git a/README.zh-CN.md b/README.zh-CN.md index 43d10c0a..5d40f748 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -20,7 +20,7 @@ https://github.com/user-attachments/assets/ecda7d0d-7fbe-4e0c-8c29-8f3315bafc15 ## 更新动态 - **2026-10-13** · **[v0.4.0](https://github.com/FlashML-org/FreeVideo/releases/tag/v0.4.0):Agent 全自动生成、分钟级长视频和剪辑视图。** 连接 Claude Code、Codex 等 AI Agent,让它全自动生成视频;一条视频可以生成到一分钟以上;在时间线上剪辑。 -- **2026-10-07** · **[v0.3.2](https://github.com/FlashML-org/FreeVideo/releases/tag/v0.3.2):先出预览。** 先看半分辨率预览,满意的再继续二采。 +- **2026-10-07** · **[v0.3.2](https://github.com/FlashML-org/FreeVideo/releases/tag/v0.3.2):先出预览。** 先看半分辨率预览,满意的再继续生成。 - **2026-10-07** · **[v0.3.0](https://github.com/FlashML-org/FreeVideo/releases/tag/v0.3.0):更快的 int8 模型。** GeForce 和 RTX 30 系显卡改用 int8,RTX 3090 生成 5 秒视频快约 2.3 倍。 - **2026-10-06** · **[作品展示](https://freevideo-community.pages.dev/#gallery)上线。** 观看 FreeVideo 生成的 20 秒视频,以及四档质量的并排对比。 - **2026-10-06** · **[v0.2.3](https://github.com/FlashML-org/FreeVideo/releases/tag/v0.2.3):视频自带工作流。** 把 FreeVideo 生成的视频拖到 ComfyUI 画布上,即可还原提示词、种子和全部参数,之前的视频也能补上。 diff --git a/docs/Linux.md b/docs/Linux.md index d98840e9..426fee6e 100644 --- a/docs/Linux.md +++ b/docs/Linux.md @@ -48,7 +48,7 @@ To connect from computers on your local network without SSH: freevideo server --listen 0.0.0.0 ``` -This prints an access link with a token. Anyone with the link can use FreeVideo on the server; `freevideo server --listen 0.0.0.0 --new-token` replaces it, and `freevideo status` shows it again. ComfyUI itself still listens only on the server; a small proxy in front of it turns away every request without the token. The connection is not encrypted, so use it on a network you trust, or add `--tls-cert cert.pem --tls-key key.pem` for HTTPS. +This prints an access link with a token, and a QR code of it to scan with a phone camera. Anyone with the link can use FreeVideo on the server; `freevideo server --listen 0.0.0.0 --new-token` replaces it, and `freevideo status` shows it again. ComfyUI itself still listens only on the server; a small proxy in front of it turns away every request without the token. The connection is not encrypted, so use it on a network you trust, or add `--tls-cert cert.pem --tls-key key.pem` for HTTPS. To start FreeVideo with the server, add `--enable`; it becomes a systemd user service. To keep it running after you log out and start it at boot, also run `sudo loginctl enable-linger $USER` once. `freevideo server --disable` removes the service. diff --git a/docs/Linux.zh-CN.md b/docs/Linux.zh-CN.md index 6023d783..92cd89a7 100644 --- a/docs/Linux.zh-CN.md +++ b/docs/Linux.zh-CN.md @@ -48,7 +48,7 @@ ssh -N -L 8188:127.0.0.1:8188 用户名@服务器 freevideo server --listen 0.0.0.0 ``` -这会打印一个带访问令牌的链接,持有链接的人都可以使用这台服务器上的 FreeVideo;用 `freevideo server --listen 0.0.0.0 --new-token` 可以换一个新链接,`freevideo status` 可以再次查看。ComfyUI 本身仍然只监听服务器本机,前面的访问代理会拒绝所有不带令牌的请求。连接没有加密,请只在可信的网络中使用,或加上 `--tls-cert cert.pem --tls-key key.pem` 使用 HTTPS。 +这会打印一个带访问令牌的链接和对应的二维码(可用手机相机扫描),持有链接的人都可以使用这台服务器上的 FreeVideo;用 `freevideo server --listen 0.0.0.0 --new-token` 可以换一个新链接,`freevideo status` 可以再次查看。ComfyUI 本身仍然只监听服务器本机,前面的访问代理会拒绝所有不带令牌的请求。连接没有加密,请只在可信的网络中使用,或加上 `--tls-cert cert.pem --tls-key key.pem` 使用 HTTPS。 如需随服务器启动,加上 `--enable`,FreeVideo 会注册为 systemd 用户服务;若要在注销后保持运行、开机自动启动,还需运行一次 `sudo loginctl enable-linger $USER`。`freevideo server --disable` 会移除这个服务。 diff --git a/freevideo_engine/adaln.py b/freevideo_engine/adaln.py index 28c2e8ad..4fbbea8b 100644 --- a/freevideo_engine/adaln.py +++ b/freevideo_engine/adaln.py @@ -12,6 +12,20 @@ import torch +def all_finite(tensor): + """Whether every element is finite; reads half-precision bits instead of calling isfinite. + + A bfloat16 or float16 table is finite exactly when its exponent field is not + all ones, so one integer mask answers the question about ten times faster on + the CPU than isfinite() on those dtypes. + """ + mask = {torch.bfloat16: 0x7F80, torch.float16: 0x7C00}.get(tensor.dtype) + if mask is None: + return bool(torch.isfinite(tensor).all()) + bits = tensor.reshape(-1).view(torch.int16) + return not bool((bits & mask).eq(mask).any()) + + class ScheduleCursor: def __init__(self, timesteps): self.timesteps = timesteps @@ -174,7 +188,7 @@ def load(self, index, steps): # These are small immutable constants; their producer's GPU/runtime is # deliberately irrelevant. Validate before transferring to this device. state = load_file(path, device=self.device) - if any(not bool(torch.isfinite(tensor).all()) for tensor in state.values()): + if any(not all_finite(tensor) for tensor in state.values()): raise ValueError('Nonfinite AdaLN model asset') return [state[f'step_{i}'].chunk(6, dim=-1) for i in range(steps)] @@ -199,7 +213,7 @@ def save(self, index, module, *, producer_device=None): path = self.root / f'{index:02d}.safetensors' temporary = path.with_suffix('.partial') state = {name: tensor.cpu() for name, tensor in module.state_dict().items()} - if any(not bool(torch.isfinite(tensor).all()) for tensor in state.values()): + if any(not all_finite(tensor) for tensor in state.values()): raise ValueError('Cannot save nonfinite AdaLN constants') save_file(state, temporary) row = {'bytes': temporary.stat().st_size, 'sha256': assets.file_hash(temporary)} diff --git a/freevideo_engine/agent/guide.py b/freevideo_engine/agent/guide.py index 5c6d0a15..2cf848f5 100644 --- a/freevideo_engine/agent/guide.py +++ b/freevideo_engine/agent/guide.py @@ -70,6 +70,13 @@ 'lighting and surroundings as described above; do not copy its simplified, flat look." The result follows ' 'the draft and looks plainer than a direct generation, so keep the direct one when the user prefers its look.', ], + 'quality': [ + 'Steps per quality: "light" = 8 first-pass steps + 3 refine steps, two-pass (default); "medium" = 12 steps, ' + '"high" = 16 steps, "max" = 20 steps, each in one pass.', + 'Two-pass generation runs only with 8 first-pass steps. Any other base_steps (for example 20 with ' + 'two_pass=true) runs in one pass of that many steps; the job notice says so. A preview is always two-pass ' + 'with 8 steps.', + ], 'timing': [ 'Single-block frames are 24 fps on a 17n+5 grid (6 s -> 6.583 s, 10 s -> 10.125 s). ' 'Under 30 s defaults to one generation when the device supports this size and quality; unknown limits use 15 s. ' diff --git a/freevideo_engine/agent/jobs.py b/freevideo_engine/agent/jobs.py index 15451274..b56e89b1 100644 --- a/freevideo_engine/agent/jobs.py +++ b/freevideo_engine/agent/jobs.py @@ -131,8 +131,14 @@ def continuation_problems(spec, prefix='', *, editor=None): problems = [] if not spec['two_pass']: # The quality name is a contract enum, never caller text. - problem = schema.diagnostic(prefix + '/quality', '"light" (two-pass), or sampling.two_pass=true,' + why, None) - got = '"%s" (one pass)' % spec['quality'] + if spec.get('sampling_note'): + # Two-pass was asked for with a first-pass count other than 8: it runs one pass. + problem = schema.diagnostic(prefix + '/quality', + '"light" (two-pass), or sampling.two_pass=true with base_steps=8,' + why, None) + got = '%d first-pass steps (one pass)' % spec['base_steps'] + else: + problem = schema.diagnostic(prefix + '/quality', '"light" (two-pass), or sampling.two_pass=true,' + why, None) + got = '"%s" (one pass)' % spec['quality'] problems.append(dict(problem, got=got, message=problem['message'].replace('received None', 'received ' + got))) if spec['preview']: problems.append(schema.diagnostic(prefix + '/preview', 'false' + why, True)) @@ -546,6 +552,12 @@ def load(self): self.finish(row, 'failed', error=dict(code='interrupted', message='ComfyUI restarted while this job ran; submit it again'), save=False) row['_restored'] = True + if row['kind'] == 'generate' and row['status'] in ACTIVE: + # Waiting, queued or running when the process restarted: this process runs it + # under the one-pass rule, however it is started again. A finished job is left. + note = self.legacy_one_pass_note(row['spec']) + if note: + row['spec']['sampling_note'] = note if row['kind'] == 'generate' and not row['spec'].get('refine_from'): # Before device-aware Agent planning, every accepted job used the unknown limit. row['spec'].setdefault('single_max_frames', None) @@ -1079,6 +1091,15 @@ def view(self, row, *, detail=True): result['chain_id'] = row['chain_id'] if row['kind'] == 'generate': result.update(cancel_requested=row.get('cancel_requested', False), attempt=row.get('attempt', 1)) + note = spec.get('sampling_note') + if (note is None and row['status'] not in DONE and spec.get('two_pass') and not spec.get('preview') + and not spec.get('refine_from')): + # A job stored before the one-pass rule and not yet run: the engine applies it when it runs. + # A finished one keeps what it did, which a stored request cannot tell. + from ..two_pass import one_pass_note + note = one_pass_note(spec.get('base_steps', 8)) + if note: + result['notice'] = note if row['status'] == 'waiting': pending = [d for d in row.get('depends_on', ()) if self.jobs.get(d, {}).get('status') not in DONE] result['waiting_for'] = pending or row.get('waiting_for') or (['gpu'] if row['kind'] in GPU_KINDS + ('generate',) else []) @@ -1449,9 +1470,28 @@ def media_file(self, row, item, name, *, target=None): relative = edited return relative + @staticmethod + def legacy_one_pass_note(spec): + """The note for an unfinished two-pass job stored before the one-pass rule, else None.""" + base = spec.get('base_steps', 8) + if (spec.get('sampling_note') is None and spec.get('two_pass') is True and not spec.get('preview') + and not spec.get('refine_from') and type(base) is int and 1 <= base <= 32 and base != 8): + from ..two_pass import one_pass_note + return one_pass_note(base) + return None # a damaged stored value gets no note; the row is judged where it is used + async def start_generation(self, row): def prepare(): spec = row['spec'] + note = self.legacy_one_pass_note(spec) + if note: + # A job stored before the one-pass rule starts under it now, on a first start + # and on a resume alike: keep what it runs as, so the note outlasts the run and + # a reload (a stored request alone cannot tell, and a finished old job must + # not gain a note). + with self.lock: + spec['sampling_note'] = note + self.touch(row) if row.get('resume_pending'): self.start_chain(row, resume=True) return diff --git a/freevideo_engine/agent/routes.py b/freevideo_engine/agent/routes.py index 0dfc2474..040c525f 100644 --- a/freevideo_engine/agent/routes.py +++ b/freevideo_engine/agent/routes.py @@ -251,6 +251,8 @@ def plan(port, value, jobs=None): result['geometry'] = {k: canvas[k] for k in ('width', 'height', 'frames', 'seconds', 'fps', 'latent_frames')} result['sampling'] = dict(two_pass=passes['enabled'], total_steps=passes['total_steps'], first_pass=passes.get('first'), second_pass=passes.get('second') if passes['enabled'] else None) + if spec.get('sampling_note'): + result['warnings'].append(spec['sampling_note']) if spec['seconds'] > 15: result['warnings'].append('Longer than 15 s is experimental') if spec['task'] == 'ref2va': diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py index 303b8ee4..df65bb38 100644 --- a/freevideo_engine/agent/schema.py +++ b/freevideo_engine/agent/schema.py @@ -176,7 +176,22 @@ def generate(*, local=False): {'if': {'if': {'required': ['sampling'], 'properties': {'sampling': {'required': ['two_pass']}}}, 'then': {'properties': {'sampling': {'properties': {'two_pass': {'const': False}}}}}, 'else': {'required': ['quality'], 'properties': {'quality': {'not': {'const': 'light'}}}}}, - 'then': {'properties': {'preview': {'const': False}}}}], + 'then': {'properties': {'preview': {'const': False}}}}, + # A preview is two-pass, which runs only with 8 first-pass steps: refused when the effective + # first-pass count is another number, given in sampling or inherited from the quality. + {'if': {'properties': {'preview': {'const': True}}, 'required': ['preview']}, + # x-derived: issues() names this case at /sampling with the derived sampling checks. + 'then': {'x-derived': True, 'not': {'anyOf': [ + {'required': ['sampling'], 'properties': {'sampling': {'required': ['two_pass', 'base_steps'], + 'properties': {'two_pass': {'const': True}, 'base_steps': {'not': {'const': 8}}}}}}, + {'required': ['sampling'], 'properties': { + 'sampling': {'required': ['base_steps'], 'not': {'required': ['two_pass']}, + 'properties': {'base_steps': {'not': {'const': 8}}}}, + 'quality': {'const': 'light'}}}, + {'required': ['sampling', 'quality'], 'properties': { + 'sampling': {'required': ['two_pass'], 'not': {'required': ['base_steps']}, + 'properties': {'two_pass': {'const': True}}}, + 'quality': {'enum': ['medium', 'high', 'max']}}}]}}}], description='x-job-kind selects normalization and checks derived geometry and sampling step comparisons; these calculations are not JSON Schema constraints.', **{'x-job-kind': 'generate'}) @@ -976,7 +991,7 @@ def bad(allowed, got=value, suffix='', **kw): if 'if' in schema: rule = schema.get('then', True) if not issues(schema['if'], value) else schema.get('else', True) errors += issues(rule, value, path) - if 'not' in schema and not issues(schema['not'], value): + if 'not' in schema and not schema.get('x-derived') and not issues(schema['not'], value): errors.append(diagnostic(path, 'a permitted field combination', value)) if 'oneOf' in schema or 'anyOf' in schema: # Structural unions choose the most specific applicable branch, then @@ -1101,6 +1116,8 @@ def bad(field, allowed, got=None): validate_steps(base, refine, two) except ValueError: bad('sampling', 'two-pass refine_steps=3 or refine_steps below base_steps', custom) + if value.get('preview') and two and base != 8: + bad('sampling', 'base_steps=8 for a preview, the only first-pass count that runs two-pass', custom) if kind == 'vlm': rows = value['questions'] if isinstance(value['questions'], list) else [value['questions']] count = sum(len(r.get('images', [])) + (r.get('video', {}).get('frames', 6) if isinstance(r.get('video'), dict) diff --git a/freevideo_engine/agent/spec.py b/freevideo_engine/agent/spec.py index 39973501..1a932aee 100644 --- a/freevideo_engine/agent/spec.py +++ b/freevideo_engine/agent/spec.py @@ -130,9 +130,16 @@ def sampling(value): two, base, refine = QUALITY[quality] custom = value.get('sampling', {}) two, base, refine = custom.get('two_pass', two), custom.get('base_steps', base), custom.get('refine_steps', refine) + note = None + if two: + from ..two_pass import one_pass_note + # Only the 8-step first pass goes two-pass; any other count is one pass, said once in the answer. + note = one_pass_note(base) + two = two and note is None if custom: quality = next((k for k, v in QUALITY.items() if v == (two, base, refine)), 'custom') - return dict(quality=quality, two_pass=two, base_steps=base, refine_steps=refine) + return dict(quality=quality, two_pass=two, base_steps=base, refine_steps=refine, + **({'sampling_note': note} if note else {})) def generation_media(value): diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index 63c52701..784f6972 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -1399,7 +1399,7 @@ def command(self, label, args, env=None, cwd=None): key = str(index) + '-' + label title, detail = setup_task(label) if label == 'models' and self.plan.get('local_models'): - title, detail = 'Reuse models / download missing files', 'Import verified local models; source files stay in place' + title, detail = 'Reuse models / download missing files', 'Uses models already on this computer; original files stay in place' self.ui.begin(key, title, detail=str(log) if self.ui.verbose else detail) initial = ('Python downloading' if title == 'Install Python' else 'Environment creating' if title == 'Create Python environment' else '') @@ -1408,6 +1408,7 @@ def command(self, label, args, env=None, cwd=None): progress = LogProgress(log) parallel_tasks = set() models_step = label == 'models' + build_step = label == 'sage2-build' def update_progress(*, final=False): self.ui.update(key, **progress.read(final=final)) if progress.model_groups is not None: @@ -1415,6 +1416,9 @@ def update_progress(*, final=False): if models_step and getattr(self, 'model_bytes_callback', None): self.model_bytes_callback(progress.model_groups) progress.model_groups = None + fraction = progress.compile_fraction() + if build_step and fraction is not None and getattr(self, 'build_callback', None): + self.build_callback(min(fraction, .95)) for name, details in progress.take_tasks().items(): subkey = key + '-' + name label, state = details.pop('label'), details.pop('state') @@ -2022,11 +2026,12 @@ def execute(self): tasks, pythons, comfy = self.component_tasks(uv) total = len(tasks) + 4 # Bootstrap, preparation, checks and final readiness. phase = 'Install with space saver' if self.plan.get('disk_mode') == 'extreme' else 'Install components in parallel' - weighted = dict(finished=set(), fetched=0, shown=1.) + weighted = dict(finished=set(), fetched=0, built=0., shown=1.) + build_bytes = INSTALL_PHASE_BUILD_BYTES if self.system != 'Windows' and 'sage' in tasks else 0 def publish(): with self.state_lock: units = install_phase_units(len(tasks), weighted['finished'], self.plan.get('model_download_bytes') or 0, - weighted['fetched']) + weighted['fetched'], build_bytes, weighted['built']) # Never step back: a restarted file may report fewer bytes for a moment. weighted['shown'] = done = max(weighted['shown'], 1 + units) self.ui.phase(phase, done, total) @@ -2044,6 +2049,12 @@ def model_bytes(groups): for group in groups or ()) publish() self.model_bytes_callback = model_bytes + def built(fraction): + if fraction <= weighted['built']: + return # A repeated heartbeat adds nothing to show. + weighted['built'] = fraction + publish() + self.build_callback = built self.ui.phase(phase, 1, total) results = run(tasks, self.cancel, progress=scheduling, workers=1 if self.plan.get('disk_mode') == 'extreme' else 3) @@ -2080,19 +2091,32 @@ def model_bytes(groups): INSTALL_PHASE_OTHER_BYTES = 6 * 2**30 -def install_phase_units(tasks, finished, download_bytes, fetched_bytes): +# The GPU acceleration build is one task of the phase but takes minutes, so it +# weighs in the same way: as a share of the phase that advances with its counter. +INSTALL_PHASE_BUILD_BYTES = 3 * 2**30 + + +def install_phase_units(tasks, finished, download_bytes, fetched_bytes, build_bytes=0, built=0.): """Units of the parallel install phase completed, out of ``tasks``. Counting finished tasks left the overall bar still for the whole model download (one task, most of the time). That task now takes a share of the - phase in proportion to its bytes and advances with the bytes fetched. + phase in proportion to its bytes and advances with the bytes fetched. With + ``build_bytes`` the compile task does the same, advancing with ``built`` + (the fraction of its compile steps done). """ - if download_bytes <= 0: + if download_bytes <= 0 and build_bytes <= 0: return len(finished) - share = download_bytes / (download_bytes + INSTALL_PHASE_OTHER_BYTES) - others = len(finished - {'models'}) - models = 1. if 'models' in finished else min(1., max(0, fetched_bytes) / download_bytes) - return tasks * ((1 - share) * others / max(1, tasks - 1) + share * models) + # Only a task that has its own share leaves the ordinary count; one without + # (nothing to download, no build on this platform) counts like any other. + weighted = ({'models'} if download_bytes > 0 else set()) | ({'sage'} if build_bytes > 0 else set()) + others = len(finished - weighted) + count = max(1, tasks - len(weighted)) + models = 1. if 'models' in finished else min(1., max(0, fetched_bytes) / download_bytes) if download_bytes > 0 else 0. + build = 1. if 'sage' in finished else min(1., max(0., built)) + done = (INSTALL_PHASE_OTHER_BYTES * others / count + + max(0, download_bytes) * models + max(0, build_bytes) * build) + return tasks * done / (INSTALL_PHASE_OTHER_BYTES + max(0, download_bytes) + max(0, build_bytes)) class Prefetcher: diff --git a/freevideo_engine/comfy_bridge.py b/freevideo_engine/comfy_bridge.py index e820671e..6b5b750e 100644 --- a/freevideo_engine/comfy_bridge.py +++ b/freevideo_engine/comfy_bridge.py @@ -969,7 +969,7 @@ def progress_message(event): (first['width'], first['height'], second['width'], second['height'], event.get('base_steps', 8), event.get('refine_steps', 3)), detail='Audio is preserved from the first pass', sampling_plan=event) - return dict(label='Single-pass · %d steps' % event.get('base_steps', 8), detail=event.get('reason'), sampling_plan=event) + return dict(label='Single-pass · %d step%s' % (event.get('base_steps', 8), '' if event.get('base_steps', 8) == 1 else 's'), detail=event.get('reason'), sampling_plan=event) if name == 'latent_upscaler_prepare': return dict(label='Preparing two-pass upscaler', timing_phase='load') if name == 'first_pass_reused': @@ -1203,7 +1203,7 @@ def generate(prompt, width, height, seconds, seed, output_directory, *, base_steps = int(argument(arguments, '--base-steps', 8)) refine_steps = int(argument(arguments, '--refine-steps', 3)) from .preview import validate_flags - validate_flags(preview, upscale, two_pass) + validate_flags(preview, upscale, two_pass, base_steps) from .two_pass import validate_steps validate_steps(base_steps, refine_steps, two_pass) canvas = validate_request(prompt, width, height, seconds, seed, continue_from=continue_from, frames=frames) diff --git a/freevideo_engine/comfy_launcher.py b/freevideo_engine/comfy_launcher.py index d64c4224..345fd9ce 100644 --- a/freevideo_engine/comfy_launcher.py +++ b/freevideo_engine/comfy_launcher.py @@ -19,7 +19,7 @@ from .setup_progress import number, progress_text -FIREWALL_HINT = ('If Windows asks about the firewall the first time, choose Allow.', 'Windows 首次询问防火墙权限时,请选择“允许”。') +FIREWALL_HINT = ('Windows will ask for permission. Check "Private networks", then click "Allow access".', 'Windows 会弹出确认框,请勾选“专用网络”,再点“允许访问”。') BROWSER_STARTING = ('Still starting. Your browser will open automatically when ready.', '还在启动,好了会自动打开') diff --git a/freevideo_engine/comfy_launcher_runtime.py b/freevideo_engine/comfy_launcher_runtime.py index 94ea39b7..b33af27c 100644 --- a/freevideo_engine/comfy_launcher_runtime.py +++ b/freevideo_engine/comfy_launcher_runtime.py @@ -7,6 +7,7 @@ import errno import hashlib import ipaddress +import re import json import logging import os @@ -488,6 +489,57 @@ def node_target(root): '# Keeps an update of a surrounding ComfyUI from copying, moving or deleting them.\n*\n') + +def launcher_downloaded(root): + """Whether this ComfyUI folder is the copy the launcher itself downloaded (its source receipt).""" + try: + value = json.loads((io_path(root) / RECEIPT).read_text(encoding='utf-8')) + except (OSError, ValueError): + return False + return isinstance(value, dict) and bool(value.get('url')) and bool(value.get('commit')) + + +def _plugin_names(root): + try: + return sorted(p.name for p in (io_path(root) / 'custom_nodes').iterdir() if p.name != '__pycache__') + except OSError: + return [] + + +SCOPED_RECORD = 'launcher/scoped-nodes.json' + + +def remembered_scope(engine, root): + """The user's plugins stopped FreeVideo from loading with this very set of plugins before.""" + try: + value = json.loads((io_path(engine) / SCOPED_RECORD).read_text(encoding='utf-8')) + except (OSError, ValueError): + return False + return (isinstance(value, dict) and value.get('root') == plain(resolved(root)) + and value.get('nodes') == _plugin_names(root)) + + +def remember_scope(engine, root): + try: + save(Path(engine) / SCOPED_RECORD, dict(root=plain(resolved(root)), nodes=_plugin_names(root))) + except OSError: + pass + + +def freevideo_import_failed(path, name, offset): + """(failed, new offset): ComfyUI's own import failure line for exactly our node folder, read incrementally.""" + pattern = re.compile(r'[\\/]custom_nodes[\\/]' + re.escape(name) + r'(?![\w.\-])') + try: + with io_path(path).open('rb') as handle: + handle.seek(offset) + data = handle.read(1 << 20) + except OSError: + return False, offset + for line in data.decode('utf-8', 'replace').splitlines(): + if ('Cannot import' in line or 'IMPORT FAILED' in line) and pattern.search(line): + return True, offset + len(data) + return False, offset + len(data) + def shield_engine(engine): """Keep a surrounding git checkout's operations away from the engine. @@ -606,6 +658,7 @@ def __init__(self, source, *, child_environment=None): # it was asked to serve. self.move_when_taken = True self.port_move = None + self._scope_retry = False def retire_old_versions(self, selected): """Once ComfyUI runs this version, earlier FreeVideo copies are no longer read.""" @@ -853,6 +906,16 @@ def stage(self, name, *, done=0, total=1, label=''): return offset += count + def open_ceiling(self): + """How far through the last section the start-up wait may show: the whole bar stays under 97%.""" + total = sum(count for _, count in self.sections) + offset = 0 + for key, count in self.sections: + if key == 'open': + return max(0., min(.9, (.97 * total - offset) / count)) if count else 0. + offset += count + return 0. + @property def busy(self): return self.thread is not None and self.thread.is_alive() @@ -1147,7 +1210,9 @@ def _start_access_proxy(self): self.refresh_access_links() def _connect_comfy(self): - self.state = {key: value for key, value in self.state.items() if key != 'moved_from'} + retry, self._scope_retry = self._scope_retry, False + self.state = {key: value for key, value in self.state.items() + if key != 'moved_from' and (retry or key != 'plugin_scoped')} if not self.selection or not self.selection['ready']: raise ValueError('Choose and inspect ComfyUI first') selected = self.selection @@ -1268,13 +1333,21 @@ def _connect_comfy(self): command = [selected['python'], '-u', '-B', str(Path(selected['root']) / 'main.py'), '--listen', ','.join(listen_hosts(parsed.hostname)), '--port', str(parsed.port or 80), '--disable-auto-launch'] managed = managed_frontend(selected) - if managed: - # This environment has ComfyUI core dependencies, not the user's - # custom-node packages. Scope both prestartup scripts and imports; - # a plugin can otherwise run pip, replace Torch, or call sys.exit. + # Only the ComfyUI this launcher downloaded loads FreeVideo alone. The user's own ComfyUI keeps + # every plugin, unless they stopped FreeVideo from loading (retry below, or remembered). + ours = launcher_downloaded(selected['root']) + scoped = ours or retry or remembered_scope(selected['engine'], selected['root']) + if scoped and not ours: + self.state = dict(self.state, plugin_scoped=True) + if scoped: + # Scope both prestartup scripts and imports; a plugin can otherwise run pip, + # replace Torch, or call sys.exit. command += ['--disable-all-custom-nodes', '--whitelist-custom-nodes', node_target(selected['root']).name] + if managed and not scoped: + # The user's plugins run in our environment: they may not install into it or replace Torch. + env.update(PIP_NO_INDEX='1', UV_OFFLINE='1', PIP_DISABLE_PIP_VERSION_CHECK='1') context = dict(environment='freevideo-managed' if managed else 'existing-comfyui', - custom_nodes='FreeVideo' if managed else 'all') + custom_nodes='FreeVideo' if scoped else 'all') if selected.get('portable') and not selected['separate']: command.append('--windows-standalone-build') view = launch_view.for_engine(selected['engine']) @@ -1292,9 +1365,11 @@ def _connect_comfy(self): with self._server_lock, external_python(), io_path(self.server_log).open('wb') as log: if self._closed or self.cancelled.is_set(): raise RuntimeError('ComfyUI startup cancelled') - if managed: + if scoped: log.write(b'[FreeVideo] Separate environment: loading FreeVideo custom nodes only. ' - b'Use your original ComfyUI launcher for other plugins.\n') + b'Use your original ComfyUI launcher for other plugins.\n' if ours else + b'[FreeVideo] Your other plugins stopped FreeVideo from loading: ' + b'loading FreeVideo custom nodes only this time.\n') log.flush() # Keep durable embedded output, but own the whole tree: Windows # Job / Linux supervisor also reclaim independently grouped model @@ -1307,15 +1382,43 @@ def _connect_comfy(self): save(directory / 'launch.json', dict(command=command, pid=server.pid, root=selected['root'], url=url, **context)) self.state = dict(self.state, task=dict(progress=dict(label='Starting ComfyUI', detail='Loading nodes and the web interface'), log=plain(directory))) waiting_since = time.monotonic() + log_offset = 0 + plugin_name = node_target(selected['root']).name + + def fall_back(): + # Once: the user's other plugins stopped FreeVideo; start again with FreeVideo's nodes only. + with self._server_lock: + if self.server is server: + self.stop_owned_server(grace=3) + self._scope_retry = True + result = self._connect_comfy() + # Only a start that worked with FreeVideo alone says the plugins were the cause. + if self.state.get('status') == 'open': + remember_scope(selected['engine'], selected['root']) + return result extended = False + shown = 0. + # Start-up has no countable steps. The bar creeps through the last section and stays below the + # displayed 97%, so a long start never reads as nearly done and then waits there. + ceiling = self.open_ceiling() while True: now = time.monotonic() + fraction = round(ceiling * (1 - 1 / (1 + (now - waiting_since) / 30)), 2) + if self.section == 'open' and fraction >= shown + .02: + shown = fraction + self.stage('open', done=fraction, label=(self.state.get('overall') or {}).get('label', 'Open ComfyUI')) if self.cancelled.is_set(): with self._server_lock: if self.server is server: self.stop_owned_server(grace=3) raise RuntimeError('ComfyUI startup cancelled') + if not scoped: + failed, log_offset = freevideo_import_failed(self.server_log, plugin_name, log_offset) + if failed: + return fall_back() if server.poll() is not None: + if not scoped: + return fall_back() from .failure_details import startup_failure message = startup_failure('ComfyUI could not start.', directory / 'comfy.log', exit_code=server.returncode, context=context) @@ -1339,6 +1442,8 @@ def _connect_comfy(self): return if now - waiting_since >= START_WAIT_SECONDS: if now - waiting_since >= START_WAIT_LIMIT_SECONDS: + if not scoped: + return fall_back() break if not extended: extended = True diff --git a/freevideo_engine/comfy_library.py b/freevideo_engine/comfy_library.py index 6c529436..9549c1a9 100644 --- a/freevideo_engine/comfy_library.py +++ b/freevideo_engine/comfy_library.py @@ -429,24 +429,44 @@ def _queued_continuation_uses(path, output_directory, queue): return False -def delete_video(output_directory, identity, *, queue=None): - """Delete one saved creation: its whole run folder, only when FreeVideo wrote everything in it. +def _request_states(root, cache): + """The small request records of every run, re-read only when their file changed.""" + rows = [] + for state_path in root.glob('*/*/comfy-request.json'): + try: + info = state_path.stat() + key = (info.st_mtime_ns, info.st_size, getattr(info, 'st_ino', 0)) + known = cache.get(state_path) + if known is None or known[0] != key: + state = json.loads(state_path.read_text(encoding='utf-8')) if info.st_size <= 1024 * 1024 else {} + known = cache[state_path] = (key, state) + except (OSError, ValueError): + continue + rows.append(known[1]) + return rows - The folder is first renamed into FreeVideo/.deleted, so it leaves the - library at once, or, when Windows still has a file open, nothing changes. + +class RecordUnsaved(Exception): + """The record that names a folder before it moves could not be written, so nothing moved.""" + + +def _trash_creation(output_directory, identity, queue, cache, announce=None): + """Check that a creation may go, and rename its folder into FreeVideo/.deleted. + + Returns the trash folder and the content identifier of its video. Nothing is + deleted yet: the caller removes the folder when it is sure it is not wanted back. + ``announce(target, identifier)`` runs under the same locks just before the move, so a + caller can write down where the folder is going first; if it raises, nothing moves. """ - try: - output_directory = checked_real_path(output_directory) - except AliasUnavailable as error: - raise DeleteRefused('not-freevideo') from error path = _video(output_directory, identity) _report(path) root = Path(output_directory).resolve() / 'FreeVideo' folder = path.parent if folder.parent.parent != root or not _plain(folder.parent) or not _plain(folder): raise DeleteRefused('not-freevideo') - # Not while the startup release is deleting files in this folder. - with _RELEASING, (queue.mutex if queue is not None else nullcontext()): + # Not while the startup release is deleting files in this folder. _PENDING comes first, + # as in undo_deleted, so the move and its in-flight mark cannot interleave with a purge. + with _PENDING, _RELEASING, (queue.mutex if queue is not None else nullcontext()): from .comfy_editor import reference_users try: users = reference_users(output_directory, identity) @@ -460,11 +480,7 @@ def delete_video(output_directory, identity, *, queue=None): if _queued_continuation_uses(path, output_directory, queue): raise DeleteRefused('continuation-in-use') # A second pass of this preview may be reading its first pass right now. - for state_path in root.glob('*/*/comfy-request.json'): - try: - state = json.loads(state_path.read_text(encoding='utf-8')) if state_path.stat().st_size <= 1024 * 1024 else {} - except (OSError, ValueError): - continue + for state in _request_states(root, cache): if not isinstance(state, dict) or state.get('status') not in ('queued', 'starting', 'running'): continue source = state.get('upscaled_preview') @@ -479,7 +495,7 @@ def delete_video(output_directory, identity, *, queue=None): # cleanup may fail, including in lightweight CI without PyAV. identifier = None try: - from .continuation_store import video_id, delete_id + from .continuation_store import video_id identifier = video_id(path) except ModuleNotFoundError as error: if error.name not in ('av', __package__ + '.continuation_store'): @@ -491,30 +507,497 @@ def delete_video(output_directory, identity, *, queue=None): if not _plain(trash): raise DeleteRefused('not-freevideo') target = trash / ('%s-%d' % (identity.replace('/', '-'), time.time_ns())) + if announce is not None: + announce(target, identifier) try: folder.rename(target) except OSError as error: raise DeleteRefused('in-use') from error - if identifier is not None: - try: - delete_id(identifier) - except Exception as error: - logging.warning('[FreeVideo] Continuation cleanup failed (%s).', type(error).__name__) - freed, left = _remove(target) - # A result-cache row naming this creation can no longer be reused. + _INFLIGHT.add(target.name) + return target, identifier + + +def _forget(identifiers, root): + """Drop what only a deleted creation needed: its continuation data and reusable results.""" + for identifier in identifiers: + if identifier is None: + continue + try: + from .continuation_store import delete_id + delete_id(identifier) + except Exception as error: + logging.warning('[FreeVideo] Continuation cleanup failed (%s).', type(error).__name__) + + +def _forget_results(identities, root): + """A result-cache row naming a deleted creation can no longer be reused.""" for index in (root / '.result-cache').glob('*.json'): try: - if index.stat().st_size <= 1024 * 1024 and json.loads(index.read_text(encoding='utf-8')).get('id') == identity: + if index.stat().st_size <= 1024 * 1024 and json.loads(index.read_text(encoding='utf-8')).get('id') in identities: index.unlink() - except (OSError, ValueError, AttributeError): + except (OSError, ValueError, AttributeError, TypeError): pass + + +def delete_video(output_directory, identity, *, queue=None): + """Delete one saved creation: its whole run folder, only when FreeVideo wrote everything in it. + + The folder is first renamed into FreeVideo/.deleted, so it leaves the + library at once, or, when Windows still has a file open, nothing changes. + """ + try: + output_directory = checked_real_path(output_directory) + except AliasUnavailable as error: + raise DeleteRefused('not-freevideo') from error + root = Path(output_directory).resolve() / 'FreeVideo' + target, identifier = _trash_creation(output_directory, identity, queue, {}) + try: + _forget([identifier], root) + freed, left = _remove(target) + _forget_results({identity}, root) + finally: + with _PENDING: + _INFLIGHT.discard(target.name) # Finish earlier deletions that Windows had kept open. - for earlier in trash.iterdir(): - if earlier != target and _plain(earlier) and earlier.is_dir(): - freed += _remove(earlier)[0] + freed += _purge_trash(root / '.deleted', keep={target.name}) return dict(id=identity, freed_bytes=freed, left=len(left)) +# Several creations deleted together stay in FreeVideo/.deleted for a few seconds, so +# one click can bring them back. A manifest beside them names what to restore or remove. +UNDO_SECONDS = 10 +_UNDO_GRACE = 3 # the server waits a little longer than the page shows +_MANIFEST = '.pending-' +_BATCH_NAME = re.compile(r'[0-9]{1,20}-[0-9a-f]{6}') +# A folder in the trash is named by the id it had, with its slash a dash, and the time it moved. +_TRASH_NAME = re.compile(r'\d{4}-\d{2}-\d{2}-[0-9a-f]{32}-\d+') +_PENDING = threading.Lock() +MAX_BATCH = 500 +MAX_EXTEND = 200 +_SESSION = os.urandom(6).hex() +# Batch names taken in the last hour, and those whose deletion is still moving folders. +# A name is taken before the first folder moves, so a repeated or parallel request with it +# is refused instead of overwriting the other's record. Guarded by _PENDING. +_CLAIMED = {} +_ACTIVE = set() +_CLAIM_SECONDS = 3600 +_CLAIM_LIMIT = 4096 +# Folders already moved into FreeVideo/.deleted whose batch record is not written yet. +# A purge running at the same time must leave them alone. Guarded by _PENDING. +_INFLIGHT = set() + + +def _pending_manifests(trash): + rows = [] + try: + names = sorted(path for path in trash.iterdir() if path.name.startswith(_MANIFEST) and path.suffix == '.json') + except OSError: + return rows + for path in names: + try: + value = json.loads(path.read_text(encoding='utf-8')) + if isinstance(value, dict) and isinstance(value.get('items'), list): + rows.append((path, value)) + except (OSError, ValueError): + continue + return rows + + +def _claim(token, manifest): + with _PENDING: + now = time.monotonic() + for name in [name for name, taken in _CLAIMED.items() if now - taken > _CLAIM_SECONDS]: + del _CLAIMED[name] + while len(_CLAIMED) >= _CLAIM_LIMIT: + del _CLAIMED[next(iter(_CLAIMED))] + if token in _CLAIMED or manifest.exists(): + raise ValueError('Invalid selection') + _CLAIMED[token] = now + _ACTIVE.add(token) + + +def _hold(root, tokens, due): + """Set the end of the undo time of batches an earlier request of the same click already made. + + Returns the batches whose record could not be written; those keep the time they had. + """ + from .monitoring import save + failed = [] + with _PENDING: + for token in tokens: + path = root / '.deleted' / (_MANIFEST + token + '.json') + try: + value = json.loads(path.read_text(encoding='utf-8')) + current = float(value['due']) + if value.get('restore') or time.time() >= current: + continue + value['due'] = due + save(path, value) + except (ValueError, KeyError, TypeError, AttributeError): + continue + except OSError: + if path.exists(): + failed.append(token) + return failed + + +def _recover(root, path, value): + """Try again to put back what a failed delete could not; never removes anything. + + The caller holds _PENDING and _RELEASING.""" + from .monitoring import save + rows = [item for item in value['items'] if isinstance(item, dict) and isinstance(item.get('id'), str) + and _ID.fullmatch(item['id']) and isinstance(item.get('name'), str) + and _TRASH_NAME.fullmatch(item['name'])] + failed = set(_restore_moved(root, rows)) + left = [row for row in rows if row['id'] in failed] + try: + if left: + if len(left) != len(value['items']): + save(path, dict(value, items=left)) + else: + path.unlink() + except OSError: + logging.warning('FreeVideo could not update the record of creations it still has to put back.') + return + _INFLIGHT.difference_update(row['name'] for row in rows if row['id'] not in failed) + + +def _purge_trash(trash, keep=()): + """Remove folders that earlier deletions left behind, except those a batch may still restore. + + The folders to remove are chosen while no batch is moving one in, so a folder + that is on its way into a batch record is never taken for left-over rubbish. + """ + freed = 0 + try: + with _PENDING: + held = set(keep) | _INFLIGHT + for _, value in _pending_manifests(trash): + held.update(str(item.get('name')) for item in value['items'] if isinstance(item, dict)) + leftovers = [earlier for earlier in trash.iterdir() if earlier.name not in held] + for earlier in leftovers: + try: + if _plain(earlier) and earlier.is_dir(): + freed += _remove(earlier)[0] + except OSError: + continue + except OSError: + pass + return freed + + +def _restore_moved(root, rows): + """Put folders back where they were taken from; returns the ids that could not be restored.""" + trash, failed = root / '.deleted', [] + for row in rows: + try: + source, folder = trash / row['name'], root / row['id'] + # Named before it moved: a folder that never left, or is already back, needs nothing. + if not os.path.lexists(source): + continue + if folder.exists(): + raise OSError + folder.parent.mkdir(parents=True, exist_ok=True) + source.rename(folder) + except OSError: + failed.append(row['id']) + return failed + + +def delete_videos(output_directory, identities, *, queue=None, batch=None, extend=()): + """Delete several creations at once, each under the single delete's checks. + + A creation that is in use is skipped with its reason, never forced. The rest + leave the library at once and are removed for good ``UNDO_SECONDS`` later; + ``undo_deleted`` brings them back until then. ``batch`` lets the page name the batch + before asking, so a page that is reloaded while the request runs can still undo it. A name + is used once: a second request with it is refused. ``extend`` names the earlier batches of + the same click; their undo time is kept open while this request runs and ends with it. + """ + if batch is not None and not (isinstance(batch, str) and _BATCH_NAME.fullmatch(batch)): + raise ValueError('Invalid selection') + if (not isinstance(identities, list) or not identities or len(identities) > MAX_BATCH + or not all(isinstance(identity, str) for identity in identities)): + raise ValueError('Invalid selection') + if (not isinstance(extend, (list, tuple)) or len(extend) > MAX_EXTEND + or not all(isinstance(name, str) and _BATCH_NAME.fullmatch(name) for name in extend)): + raise ValueError('Invalid selection') + try: + output_directory = checked_real_path(output_directory) + except AliasUnavailable as error: + raise DeleteRefused('not-freevideo') from error + root = Path(output_directory).resolve() / 'FreeVideo' + finish_deleted(output_directory) + cache, moved, skipped = {}, [], [] + from .monitoring import save + token = batch or '%d-%s' % (time.time_ns(), os.urandom(3).hex()) + manifest = root / '.deleted' / (_MANIFEST + token + '.json') + _claim(token, manifest) + if extend: + _hold(root, extend, time.time() + 120) + + def announce(row): + # Written before the folder moves, as a record that only puts things back: if the move, the next + # step, a later write or this process fails, whatever sits in the trash is named and never removed. + # The caller holds _PENDING. + def write(target, identifier): + try: + save(manifest, dict(schema=1, due=0, restore=True, session=_SESSION, + items=moved + [dict(row, name=target.name, continuation=identifier)])) + except OSError as error: + raise RecordUnsaved from error + return write + + def publish(due): + # Every folder is in the trash and named; from now on the record removes them when the time is over. + with _PENDING: + save(manifest, dict(schema=1, due=due, born=time.time(), session=_SESSION, items=moved)) + _INFLIGHT.difference_update(row['name'] for row in moved) + + try: + for identity in dict.fromkeys(identities): + try: + target, identifier = _trash_creation(output_directory, identity, queue, cache, + announce=announce(dict(id=identity))) + except DeleteRefused as error: + skipped.append(dict(id=identity, reason=error.reason, kept=error.kept[:20])) + except RecordUnsaved: + skipped.append(dict(id=identity, reason='unsaved', kept=[])) + except (FileNotFoundError, NotADirectoryError, ValueError): + # The creation is already gone from disk (or was never complete): it only has to leave the page. + skipped.append(dict(id=identity, reason='unavailable', kept=[])) + except OSError: + # Still there but not readable or writable (permissions, read-only disk): it stays, and says so. + skipped.append(dict(id=identity, reason='failed', kept=[])) + else: + moved.append(dict(id=identity, name=target.name, continuation=identifier)) + if moved: + # The undo time starts when the last creation has left the library. + publish(time.time() + UNDO_SECONDS + _UNDO_GRACE) + else: + with _PENDING: + try: + manifest.unlink() + except OSError: + pass + except BaseException: + with _PENDING, _RELEASING: + failed = set(_restore_moved(root, moved)) + _INFLIGHT.difference_update(row['name'] for row in moved) + try: + if not failed: + manifest.unlink() + else: + logging.warning('FreeVideo could not put back %d creations after a failed delete.', len(failed)) + # What could not be put back stays named by a record that only restores it, until it can be. + save(manifest, dict(schema=1, due=0, restore=True, session=_SESSION, + items=[row for row in moved if row['id'] in failed])) + except OSError: + # The record already on disk was written before the moves and says the same. + pass + raise + finally: + # The earlier chunks get their normal time before this one is reported as finished. + if extend: + _hold(root, extend, time.time() + UNDO_SECONDS + _UNDO_GRACE) + with _PENDING: + _ACTIVE.discard(token) + if moved: + timer = threading.Timer(UNDO_SECONDS + _UNDO_GRACE + 1, _finish_quietly, (output_directory,)) + timer.daemon = True + timer.start() + return dict(batch=token if moved else None, undo_seconds=UNDO_SECONDS, + deleted=[row['id'] for row in moved], skipped=skipped) + + +def _finish_quietly(output_directory): + try: + finish_deleted(output_directory) + except Exception: + logging.warning('FreeVideo could not finish removing deleted creations.', exc_info=True) + + +def finish_deleted(output_directory, *, everything=False): + """Remove for good the batches whose undo time is over (all of them with ``everything``). + + Also what the continuation store and the result cache kept for them, and any + folder an earlier deletion could not remove. Returns the bytes freed. + """ + root = Path(checked_real_path(output_directory)).resolve() / 'FreeVideo' + trash = root / '.deleted' + if not trash.is_dir() or not _plain(trash): + return 0 + claimed, recover = [], [] + with _PENDING: + for path, value in _pending_manifests(trash): + try: + # A batch still moving folders is not touched. + if path.name[len(_MANIFEST):-len('.json')] in _ACTIVE or any( + isinstance(item, dict) and item.get('name') in _INFLIGHT for item in value['items']): + continue + if value.get('restore'): + recover.append((path, value)) + # Starting up, only what an earlier session left is finished; a batch of this one is still undoable. + elif (everything and value.get('session') != _SESSION) or time.time() >= float(value.get('due', 0)): + path.unlink() + claimed.append(value['items']) + except (OSError, ValueError, TypeError): + continue + if recover: + with _PENDING, _RELEASING: + for path, value in recover: + _recover(root, path, value) + freed = 0 + for items in claimed: + rows = [item for item in items if isinstance(item, dict) and isinstance(item.get('name'), str) + and _TRASH_NAME.fullmatch(item['name'])] + _forget([item.get('continuation') for item in rows], root) + for item in rows: + folder = trash / item['name'] + if folder.name == item['name'] and _plain(folder) and folder.is_dir(): + freed += _remove(folder)[0] + _forget_results({item.get('id') for item in rows}, root) + return freed + _purge_trash(trash) + + +class UndoBusy(ValueError): + """The batch is still being deleted; asking again once that is done works.""" + + +def undo_deleted(output_directory, token): + """Put a batch back while its undo time lasts; raises ValueError('expired') afterwards.""" + output_directory = checked_real_path(output_directory) + root = Path(output_directory).resolve() / 'FreeVideo' + trash = root / '.deleted' + if not isinstance(token, str) or not _BATCH_NAME.fullmatch(token): + raise ValueError('Invalid batch') + manifest = trash / (_MANIFEST + token + '.json') + restored, lost = [], [] + from .monitoring import save + with _PENDING, _RELEASING: + if token in _ACTIVE: + raise UndoBusy('busy') + try: + value = json.loads(manifest.read_text(encoding='utf-8')) + if value.get('restore') or time.time() >= float(value['due']): + raise ValueError('expired') + items = [item for item in value['items'] if isinstance(item, dict)] + except (OSError, KeyError, TypeError, AttributeError): + raise ValueError('expired') from None + rows = [item for item in items if isinstance(item.get('id'), str) and _ID.fullmatch(item['id']) + and isinstance(item.get('name'), str) and _TRASH_NAME.fullmatch(item['name'])] + # From here the record only puts things back, so whatever fails or stops half way is never removed. + try: + save(manifest, dict(value, restore=True, due=0, items=items)) + except OSError: + logging.warning('FreeVideo could not record an undo, so nothing was restored.') + return dict(restored=[], lost=[item.get('id') for item in items]) + for item in items: + if item not in rows: + lost.append(item.get('id')) + continue + try: + source, folder = trash / item['name'], root / item['id'] + if folder.exists() or not _plain(source): + raise OSError + folder.parent.mkdir(parents=True, exist_ok=True) + source.rename(folder) + restored.append(item['id']) + except OSError: + lost.append(item['id']) + # What could not come back stays named by the record, to be put back later and never removed. + kept = [item for item in items if item.get('id') in lost] + try: + if kept: + save(manifest, dict(value, restore=True, due=0, items=kept)) + else: + manifest.unlink() + except OSError: + pass + return dict(restored=restored, lost=lost) + + +def _checked_tokens(tokens): + if (not isinstance(tokens, list) or len(tokens) > MAX_EXTEND + or not all(isinstance(name, str) and _BATCH_NAME.fullmatch(name) for name in tokens)): + raise ValueError('Invalid selection') + return list(dict.fromkeys(tokens)) + + +def undo_state(output_directory, tokens): + """What the server says about each batch: ``busy`` (folders still moving), ``ready`` with the + seconds left to undo it, or ``gone`` (finished, restored, expired or never made).""" + tokens = _checked_tokens(tokens) + root = Path(checked_real_path(output_directory)).resolve() / 'FreeVideo' + states = {} + with _PENDING: + for token in tokens: + if token in _ACTIVE: + states[token] = dict(state='busy') + continue + try: + value = json.loads((root / '.deleted' / (_MANIFEST + token + '.json')).read_text(encoding='utf-8')) + left = float(value['due']) - time.time() - _UNDO_GRACE + if value.get('restore') or left <= 0: + raise ValueError + states[token] = dict(state='ready', seconds=left, count=len(value['items'])) + except (OSError, ValueError, KeyError, TypeError, AttributeError): + states[token] = dict(state='gone') + return states + + +def hold_deleted(output_directory, tokens, *, running): + """Keep earlier batches of one click undoable while its next request runs (``running``), or + give them the normal undo time again once it is over; returns ``undo_state``. + + Raises OSError when a batch's record could not be written: the caller must not go on as if it was held. + """ + tokens = _checked_tokens(tokens) + if not isinstance(running, bool): + raise ValueError('Invalid selection') + root = Path(checked_real_path(output_directory)).resolve() / 'FreeVideo' + seconds = 120 if running else UNDO_SECONDS + _UNDO_GRACE + failed = _hold(root, tokens, time.time() + seconds) + states = undo_state(output_directory, tokens) + if any(row['state'] == 'ready' for row in states.values()): + # The request that would have ended their time may never come: check once the time is over. + timer = threading.Timer(seconds + 1, _finish_quietly, (output_directory,)) + timer.daemon = True + timer.start() + if failed: + raise OSError('Could not keep the batches') + return states + + +def folder_sizes(output_directory, identities): + """Bytes each creation's whole run folder holds, links not followed; unknown ids are left out.""" + if not isinstance(identities, list) or len(identities) > MAX_BATCH: + raise ValueError('Invalid selection') + root = Path(checked_real_path(output_directory)).resolve() / 'FreeVideo' + sizes = {} + for identity in identities: + if not isinstance(identity, str) or not _ID.fullmatch(identity): + continue + folder = root / identity + total = 0 + try: + if not _plain(folder): + continue + for parent, folders, files in os.walk(folder): + folders[:] = [name for name in folders if _plain(Path(parent) / name)] + for name in files: + try: + info = os.lstat(Path(parent) / name) + total += info.st_size if stat.S_ISREG(info.st_mode) else 0 + except OSError: + pass + except OSError: + continue + sizes[identity] = total + return sizes + + def register(): from aiohttp import web import folder_paths @@ -533,6 +1016,11 @@ def release(): result['freed_bytes'] / 1e9, result['videos']) except Exception: logging.warning('FreeVideo could not free the decoded files of earlier videos.', exc_info=True) + try: + # A deletion the last session did not finish: its undo time is long over. + finish_deleted(folder_paths.get_output_directory(), everything=True) + except Exception: + logging.warning('FreeVideo could not finish removing deleted creations.', exc_info=True) threading.Thread(target=release, name='freevideo-release-decoded', daemon=True).start() @server.routes.get('/freevideo/sampling-estimate') @@ -623,6 +1111,62 @@ async def delete(request): raise web.HTTPNotFound(text='Saved video unavailable') from None return web.json_response(dict(result, deleted=True)) + @server.routes.post('/freevideo/library/delete-batch') + async def delete_batch(request): + try: + body = await request.json() + result = await asyncio.to_thread(delete_videos, folder_paths.get_output_directory(), + body.get('ids') if isinstance(body, dict) else None, + queue=getattr(server, 'prompt_queue', None), + batch=body.get('batch') if isinstance(body, dict) else None, + extend=body.get('extend') or () if isinstance(body, dict) else ()) + except (DeleteRefused, OSError, ValueError, TypeError): + raise web.HTTPBadRequest(text='Invalid selection') from None + return web.json_response(result) + + @server.routes.post('/freevideo/library/undo') + async def undo(request): + try: + body = await request.json() + result = await asyncio.to_thread(undo_deleted, folder_paths.get_output_directory(), + body.get('batch') if isinstance(body, dict) else None) + except UndoBusy: + return web.json_response(dict(restored=[], lost=[], busy=True), status=409) + except (OSError, ValueError, TypeError): + return web.json_response(dict(restored=[], lost=[], expired=True), status=410) + return web.json_response(result) + + @server.routes.post('/freevideo/library/undo-state') + async def undo_status(request): + try: + body = await request.json() + result = await asyncio.to_thread(undo_state, folder_paths.get_output_directory(), + body.get('batches') if isinstance(body, dict) else None) + except (OSError, ValueError, TypeError): + raise web.HTTPBadRequest(text='Invalid selection') from None + return web.json_response(dict(batches=result), headers={'Cache-Control': 'no-store'}) + + @server.routes.post('/freevideo/library/hold') + async def hold(request): + try: + body = await request.json() + result = await asyncio.to_thread(hold_deleted, folder_paths.get_output_directory(), + body.get('batches') if isinstance(body, dict) else None, + running=body.get('running', False) if isinstance(body, dict) else None) + except (OSError, ValueError, TypeError): + raise web.HTTPBadRequest(text='Invalid selection') from None + return web.json_response(dict(batches=result), headers={'Cache-Control': 'no-store'}) + + @server.routes.post('/freevideo/library/sizes') + async def sizes(request): + try: + body = await request.json() + result = await asyncio.to_thread(folder_sizes, folder_paths.get_output_directory(), + body.get('ids') if isinstance(body, dict) else None) + except (OSError, ValueError, TypeError): + raise web.HTTPBadRequest(text='Invalid selection') from None + return web.json_response(dict(sizes=result), headers={'Cache-Control': 'no-store'}) + @server.routes.get('/freevideo/library/download') async def download(request): try: diff --git a/freevideo_engine/comfy_nodes.py b/freevideo_engine/comfy_nodes.py index c918a344..8a5f8de6 100644 --- a/freevideo_engine/comfy_nodes.py +++ b/freevideo_engine/comfy_nodes.py @@ -45,7 +45,8 @@ def define_schema(cls): io.Boolean.Input('two_pass', display_name='Two-pass acceleration', default=True, optional=True, tooltip='Usually faster: generate at a lower resolution, then upscale and finish sampling at the target size.'), io.Int.Input('base_steps', display_name='First-pass steps', default=8, min=1, max=32, optional=True, - tooltip='Default: 8. Changing sampling steps may reduce generation quality.'), + tooltip='Default: 8. Two-pass acceleration runs only with 8 first-pass steps; any other count ' + 'generates in one pass of that many steps. Changing sampling steps may reduce generation quality.'), io.Int.Input('refine_steps', display_name='Second-pass steps', default=3, min=1, max=31, optional=True, tooltip='Default: 3. Three steps use the independent refinement schedule. Changing sampling steps may reduce generation quality.'), io.Boolean.Input('force_regenerate', display_name='Force regeneration', default=False, optional=True, @@ -173,6 +174,12 @@ def execute(cls, text, width, height, seconds, seed, first=None, last=None, if notice: logging.warning('[FreeVideo] %s', notice) PromptServer.instance.send_progress_text(notice, node_id) + if two_pass and not refine_from and not preview: + from .two_pass import one_pass_note + step_note = one_pass_note(base_steps) + if step_note: + logging.warning('[FreeVideo] %s', step_note) + notice = notice + '\n' + step_note if notice else step_note notice = notice + '\n' if notice else '' def said(): return notice + ''.join(line + '\n' for line in notes) diff --git a/freevideo_engine/comfy_progress.py b/freevideo_engine/comfy_progress.py index 2a016c34..51da6f5a 100644 --- a/freevideo_engine/comfy_progress.py +++ b/freevideo_engine/comfy_progress.py @@ -124,7 +124,7 @@ def publish(self, node, message): # pass from a first pass, without repeating the whole plan. plan = message.get('sampling_plan') if isinstance(plan, dict): - value['plan_context'] = {key: deepcopy(plan[key]) for key in ('enabled', 'preview', 'base_steps', 'refine_steps', 'second') + value['plan_context'] = {key: deepcopy(plan[key]) for key in ('enabled', 'requested', 'preview', 'base_steps', 'refine_steps', 'second') if key in plan} elif old and not fresh and old['message'].get('plan_context'): value.setdefault('plan_context', deepcopy(old['message']['plan_context'])) @@ -318,6 +318,12 @@ async def frame_preview(request): return web.Response(body=data, content_type='image/jpeg', headers={ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}) + @server.routes.get('/freevideo/feedback-info') + async def feedback_info(request): + from . import feedback + info = await asyncio.to_thread(feedback.collect, request.query.get('report_id')) + return web.json_response(info, headers={'Cache-Control': 'no-store'}) + @server.routes.get('/freevideo/progress') async def progress(request): return web.json_response({'progress': STATE.snapshot()}, diff --git a/freevideo_engine/comfy_service.py b/freevideo_engine/comfy_service.py index 81428416..3f4f54dd 100644 --- a/freevideo_engine/comfy_service.py +++ b/freevideo_engine/comfy_service.py @@ -544,14 +544,29 @@ def host_url(scheme, address, port): return '%s://%s:%d' % (scheme, '[%s]' % address if ':' in address else address, port) +def qr_lines(link): + """The link as a terminal QR code for a phone camera; nothing when it cannot be drawn.""" + if not link: + return [] + try: + from . import qr_code + return qr_code.terminal_lines(link) + except (ImportError, ValueError): + return [] + + def describe(root, state, *, browser_opened=False, out=say): url = state['url'] + '/?freevideo=launch' access = state.get('access') if access: secret = token(root) out('FreeVideo is running. Open it from another computer with:') - for address in addresses(state.get('listen', '')): - out(' %s/?freevideo=launch&token=%s' % (host_url(access['scheme'], address, access['port']), secret)) + links = ['%s/?freevideo=launch&token=%s' % (host_url(access['scheme'], address, access['port']), secret) + for address in addresses(state.get('listen', ''))] + for link in links: + out(' ' + link) + for line in qr_lines(links[0] if links else ''): + out(' ' + line) out('Anyone with this link can use FreeVideo on this computer; freevideo server --new-token replaces it.') if access['scheme'] == 'http': out('The connection is not encrypted. Use it on a network you trust, add --tls-cert/--tls-key, or use an SSH tunnel.') diff --git a/freevideo_engine/decode_resume.py b/freevideo_engine/decode_resume.py index 58fc2416..c02a964a 100644 --- a/freevideo_engine/decode_resume.py +++ b/freevideo_engine/decode_resume.py @@ -71,6 +71,48 @@ def _digest(path): return digest.hexdigest() +def _plan_without_staged(plan): + return {k: v for k, v in plan.items() if k != 'staged'} if isinstance(plan, dict) else plan + + +def _canvas_without_staged(canvas): + if isinstance(canvas, dict) and isinstance(canvas.get('sampling_plan'), dict): + return dict(canvas, sampling_plan=_plan_without_staged(canvas['sampling_plan'])) + return canvas + + +def _without_staged(value): + """A copy of `value` whose sampling plan lacks `staged` in its two stored places. + + The controller keeps the plan twice: as `sampling_plan` and inside the + canvas (`geometry`), and the canvas is also part of the saved identity. + Nothing else is normalised. + """ + if not isinstance(value, dict): + return value + result = dict(value) + if 'sampling_plan' in result: + result['sampling_plan'] = _plan_without_staged(result['sampling_plan']) + if 'geometry' in result: + result['geometry'] = _canvas_without_staged(result['geometry']) + return result + + +def same_identity(left, right): + """Equal sampling identities, ignoring the plan's `staged` marker. + + A plan made before the marker existed (a 20-step preview of a large canvas + kept in the previous version) differs from today's plan of the same request + only by that key; every field that decides the math is still compared. + """ + return _without_staged(left) == _without_staged(right) + + +def same_canvas(saved, canvas): + """The saved canvas equals the request's, ignoring the plan's `staged` marker.""" + return _canvas_without_staged(saved) == _canvas_without_staged(canvas) + + def sampling_provenance(request): """Bind the result to the exact conditioning, model manifest and math.""" from .paths import base_path @@ -127,14 +169,15 @@ def load_saved_sampling(request, torch, *, continuation_tail_callback=None): if not completed_sampling(previous): raise ValueError('Decoder retry requires all planned finite sampling steps') provenance = sampling_provenance(request) - if provenance != sampling_provenance(prior) or provenance != previous.get('sampling_provenance'): + if (not same_identity(provenance, sampling_provenance(prior)) + or not same_identity(provenance, previous.get('sampling_provenance'))): raise ValueError('Retained sampling does not match this request and its current inputs') canvas = request['geometry'] if any(previous.get('geometry', {}).get(key) != canvas[key] for key in ('width', 'height', 'frames', 'fps')): raise ValueError('Retained sampling geometry differs from the request') value = torch.load(str(paths['latents']), map_location='cpu', weights_only=True) if (not isinstance(value, dict) or type(value.get('seed')) is not int or value['seed'] != request['seed'] - or value.get('geometry') != canvas or value.get('sampling_provenance') != provenance): + or not same_canvas(value.get('geometry'), canvas) or not same_identity(value.get('sampling_provenance'), provenance)): raise ValueError('Retained latent provenance differs from the sampling receipt') width, height, frames, fps = (canvas[key] for key in ('width', 'height', 'frames', 'fps')) if (any(type(n) is not int or n <= 0 for n in (width, height, frames, fps)) @@ -248,12 +291,13 @@ def load_first_pass(request, torch): if not isinstance(prior, dict) or not first_pass_complete(previous): raise ValueError('Upscaling requires a finished preview') provenance = refine_provenance(request) - if provenance != refine_provenance(prior) or provenance != previous['refine_provenance']: + if (not same_identity(provenance, refine_provenance(prior)) + or not same_identity(provenance, previous['refine_provenance'])): raise ValueError('The preview does not match this request and its current inputs') canvas, first = request['geometry'], request['sampling_plan']['first'] value = torch.load(str(paths['input']), map_location='cpu', weights_only=True) if (not isinstance(value, dict) or type(value.get('seed')) is not int or value['seed'] != request['seed'] - or value.get('geometry') != canvas or value.get('refine_provenance') != provenance): + or not same_canvas(value.get('geometry'), canvas) or not same_identity(value.get('refine_provenance'), provenance)): raise ValueError('Preview first-pass provenance differs from its receipt') width, height, frames, fps = first['width'], first['height'], first['frames'], canvas['fps'] if (any(type(n) is not int or n <= 0 for n in (width, height, frames, fps)) @@ -309,12 +353,13 @@ def load_refine_input(request, torch, *, continuation_tail_callback=None): if not isinstance(prior, dict) or not refine_checkpoint_complete(previous): raise ValueError('First-pass retry requires a complete retained first pass') provenance = refine_provenance(request) - if provenance != refine_provenance(prior) or provenance != previous['refine_provenance']: + if (not same_identity(provenance, refine_provenance(prior)) + or not same_identity(provenance, previous['refine_provenance'])): raise ValueError('Retained first pass does not match this request and its current inputs') canvas = request['geometry'] value = torch.load(str(paths['input']), map_location='cpu', weights_only=True) if (not isinstance(value, dict) or type(value.get('seed')) is not int or value['seed'] != request['seed'] - or value.get('geometry') != canvas or value.get('refine_provenance') != provenance): + or not same_canvas(value.get('geometry'), canvas) or not same_identity(value.get('refine_provenance'), provenance)): raise ValueError('Retained first-pass provenance differs from its receipt') width, height, frames, fps = (canvas[key] for key in ('width', 'height', 'frames', 'fps')) if (any(type(n) is not int or n <= 0 for n in (width, height, frames, fps)) diff --git a/freevideo_engine/download_settings_ui.py b/freevideo_engine/download_settings_ui.py index 362814ec..bcdb94a4 100644 --- a/freevideo_engine/download_settings_ui.py +++ b/freevideo_engine/download_settings_ui.py @@ -128,7 +128,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', '解码器'), - 'models': self.t('Text encoder', '文本编码器'), 'pypi': self.t('Python packages', 'Python 依赖'), + '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(): if family.startswith('torch-'): diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index f57b9121..faa5b3f8 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -16,8 +16,8 @@ IMAGE_PACK_FAILURES = { 'disk_space': ('Not enough disk space to install the image model.', '磁盘空间不足,无法安装图像模型。'), 'offline': ('The network is unavailable; the image model could not be downloaded.', '网络不通,无法下载图像模型。'), - 'source_failed': ('The download source is unreachable or could not complete the image model download.', '下载源不可达或未能完成图像模型下载。'), - 'integrity_failed': ('The image model files failed verification; repair them in the launcher.', '图像模型文件校验失败,请在启动器里修复。'), + 'source_failed': ("The image model download didn't finish. Check your network and try again.", '图像模型没有下载完成,请检查网络后重试。'), + 'integrity_failed': ('The image model files are incomplete or damaged. Repair them in the launcher.', '图像模型文件不完整或已损坏,请在启动器里修复。'), 'write_failed': ('The image model files are in use or cannot be written or deleted.', '图像模型文件被占用,或无法写入/删除。'), 'invalid_catalog': ('The image model package version is incompatible; update FreeVideo and try again.', '图像模型包版本不匹配,请更新 FreeVideo 后重试。'), 'busy': ('Another task is using the image model package; try again when it finishes.', '图像模型包被其他任务占用,完成后再试。'), @@ -94,12 +94,12 @@ def sampling_table_failure(value, *, zh=False): # A custom step count is not a quality level (Iris): no "档" / "quality level" for it. if zh: if index is None: - title = '“自定义(%d 步)”的采样表还没有下载' % details['base_steps'] - what = '这个步数的采样表' + title = '“自定义(%d 步)”还没有下载' % details['base_steps'] + what = '这个步数需要的文件' else: - title = '“%s”档的采样表还没有下载' % QUALITY_LEVELS[index][1] - what = '这一档的采样表' - detail = ('这次生成需要先下载%s(约 %s),但这次没能下载。' + title = '“%s”还没有下载' % QUALITY_LEVELS[index][1] + what = '这一档需要的文件' + detail = ('这次生成要先下载%s(约 %s),但没能下载完。' '请确认网络连接后再生成一次,FreeVideo 会自动下载。') % (what, size) if available: detail += '也可以改用已经可以使用的%s档。' % available @@ -201,16 +201,16 @@ def environment_python_removed(args, pythons): PLACE_RULES = ( (r'The installation folder is on a FAT32 disk', 'disk-format', ('This disk cannot hold the models', '这块硬盘放不下模型'), - ('It is formatted as FAT32, which stores files up to 4 GB; the text encoder alone is 15.7 GB. ' + ('It is formatted as FAT32, which stores files up to 4 GB; the prompt model alone is 15.7 GB. ' 'Choose a folder on an APFS or Mac OS Extended disk.', - '它是 FAT32 格式,单个文件最大 4 GB,而文本编码器一个文件就有 15.7 GB。' - '请选择 APFS 或 Mac OS 扩展格式硬盘上的文件夹。')), + '它不能存放 4 GB 以上的文件。' + '请选择其他硬盘上的文件夹。')), (r'The installation folder is on an exFAT disk', 'disk-format', ('This disk cannot hold the FreeVideo environment', '这块硬盘装不了 FreeVideo 的运行环境'), ("It is formatted as exFAT, where FreeVideo's Python environment does not work. " 'Choose a folder on an APFS or Mac OS Extended disk.', - '它是 exFAT 格式,FreeVideo 的 Python 运行环境在这种格式上无法正常工作。' - '请选择 APFS 或 Mac OS 扩展格式硬盘上的文件夹。')), + '它不能安装 FreeVideo。' + '请选择其他硬盘上的文件夹。')), (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.', @@ -218,7 +218,7 @@ def environment_python_removed(args, pythons): (r'The installation folder is on a read-only disk', 'disk-read-only', ('This disk is read-only', '这块硬盘是只读的'), ('This Mac can read the disk but not write to it, as with NTFS disks. Choose a folder on another disk.', - '这台 Mac 只能读取这块硬盘,不能写入(例如 NTFS 格式的硬盘)。请选择其他硬盘上的文件夹。')), + '这块硬盘只能读取、不能写入,请选择其他硬盘上的文件夹。')), ) @@ -263,7 +263,7 @@ def _model_unchanged(tail): for kind, why in (('plan-changed-files', 'files'), ('plan-changed', 'other'))}, 'dependencies': dict( busy=('When the current task ends, a Repair button appears here; it checks this installation and restores what is missing.', - '当前任务结束后,这里会出现“修复”按钮,点击后 FreeVideo 将检查这份安装并补装缺失的组件。'), + '当前任务结束后,这里会出现“修复”按钮,点击后会补装缺失的组件。'), bundled=('The portable package is missing components it needs and cannot restore them in one step. ' + _EXTRACT[0], '整合包缺少运行所需的组件,不能一键补齐。' + _EXTRACT[1]), offline=('An imported offline environment is missing components it needs and cannot restore them in one step. ' + _IMPORT[0], @@ -271,7 +271,7 @@ def _model_unchanged(tail): none=WITHOUT_REPAIR_NONE), 'runtime-libraries': dict( busy=('When the current task ends, a Repair button appears here; it reinstalls a complete copy of PyTorch and keeps your models and videos.', - '当前任务结束后,这里会出现“修复”按钮,点击后 FreeVideo 将重新安装完整的 PyTorch,模型和视频都会保留。'), + '当前任务结束后,这里会出现“修复”按钮,点击后会重新安装运行组件,模型和视频都会保留。'), bundled=('The portable package cannot be repaired in one step. ' + _EXTRACT[0], '整合包版不能一键修复。' + _EXTRACT[1]), offline=('An imported offline environment cannot be repaired in one step. ' + _IMPORT[0], @@ -282,13 +282,13 @@ def _model_unchanged(tail): 'update-unfinished': dict( busy=('FreeVideo is back on the previous version and works as before. When the current task ends, a Repair button appears here; ' 'clicking it reinstalls the engine environment and updates again. The details give the reason.', - 'FreeVideo 已退回原来的版本,可以照常使用。当前任务结束后,这里会出现“修复”按钮;点击后会重新安装引擎环境并再次更新。原因写在详情里。'), + 'FreeVideo 已退回原来的版本,可以照常使用。当前任务结束后,点这里出现的“修复”可以再次更新。'), **dict.fromkeys(('bundled', 'offline'), ( 'FreeVideo is back on the previous version and works as before; the details give the reason.', 'FreeVideo 已退回原来的版本,可以照常使用;原因写在详情里。')), none=('FreeVideo is back on the previous version and works as before. To update again, open Installation in the sidebar, keep the same folder, ' 'click Continue and then Check & continue, and finally click Install & launch on the confirmation page; the details give the reason.', - 'FreeVideo 已退回原来的版本,可以照常使用。要再次更新,请在左栏的“安装”页保持同一个安装目录,依次点击“继续”和“检查并继续”,最后在确认页点击“安装并启动”;原因写在详情里。')), + 'FreeVideo 已退回原来的版本,可以照常使用。要再次更新,请在“安装”页保持同一个安装目录,依次点“继续”“检查并继续”和“安装并启动”。')), } @@ -411,8 +411,8 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep if can_repair and repairs_comfy and (_stopped_on_missing_module(summary) or ENVIRONMENT_FILES in summary): return dict(title=DEPENDENCIES[0][zh], detail='', action=DEPENDENCIES[1][zh], kind='dependencies') return dict(title='ComfyUI 启动失败' if zh else 'ComfyUI could not start', - detail='ComfyUI 进程在启动时退出。' if zh else 'The ComfyUI process exited during startup.', - action='展开详情查看退出码与插件错误;已有 ComfyUI 可使用原启动器检查。' if zh else + detail='ComfyUI 启动时退出了。' if zh else 'ComfyUI quit while starting.', + action='原因在详情里;使用已有的 ComfyUI 时,可以用原来的启动器检查。' if zh else 'Open details for the exit code and plugin errors. For an existing ComfyUI, also check its original launcher.', kind='comfy-startup') if 'Failed step: kernels (' in summary: @@ -420,9 +420,12 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep detail = detail.partition('\nRetained step log:')[0] if zh: detail = detail.replace('Failed step: kernels (exit code ', '失败步骤:GPU 检查(退出码 ').replace(')\n', ')\n', 1) - detail = detail.replace('Required linear kernel did not pass.', '必需的 linear 运算检查未通过。') - detail = detail.replace('No attention backend passed.', '没有可用的 attention 后端。') - detail = detail.replace('Required path missing', '缺少必需目录') + detail = detail.replace('Required linear kernel did not pass.', + '显卡必需的运算检查没有通过。' if zh else 'A required GPU check did not pass.') + detail = detail.replace('No attention backend passed.', + '显卡加速检查没有通过。' if zh else 'The GPU acceleration check did not pass.') + detail = detail.replace('Required path missing', + '缺少必需的文件夹' if zh else 'A required folder is missing') return dict(title='GPU 检查未通过' if zh else 'GPU validation failed', detail=detail, action='展开详情或导出报告,查看完整检查结果。' if zh else 'Open the details or export the report for the complete results.', kind='kernels') @@ -471,7 +474,7 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep (r'Editable install failed for|Editable verification failed for|The engine.s uv is missing', 'update-unfinished', ('The update did not finish', '这次更新没有完成'), ('FreeVideo is back on the previous version and works as before. Click Repair to reinstall the engine environment and update again; the details give the reason.', - 'FreeVideo 已退回原来的版本,可以照常使用。点击“修复”会重新安装引擎环境并再次更新,原因写在详情里。')), + 'FreeVideo 已退回原来的版本,可以照常使用。点“修复”可以再次更新,原因在详情里。')), (r'Git \d+\.\d+ is too old to download the pinned sources', 'git-version', ('Git is too old', 'Git 版本过旧'), ('Click Retry installation; FreeVideo then installs its own Git. On macOS, update the Command Line Tools.', @@ -485,15 +488,15 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep (r'ComfyUI is still starting or could not load FreeVideo', 'comfy-nodes', ('ComfyUI did not load FreeVideo', 'ComfyUI 没有加载 FreeVideo'), ('Click Retry. If it stops here again, the ComfyUI log in the details shows why the FreeVideo nodes did not load; export the report to send it.', - '请点击重试。如果仍停在这里,详情里的 ComfyUI 日志会显示 FreeVideo 节点没有加载的原因,可以导出报告反馈。')), + '请点“重试”。仍然停在这里时,可以导出报告。')), (r'ComfyUI is already running\. Restart it once', 'comfy-restart', - ('ComfyUI must restart to load FreeVideo', 'ComfyUI 需要重启才能加载 FreeVideo'), + ('Restart ComfyUI to load FreeVideo', '需要重启 ComfyUI 才能加载 FreeVideo'), ('Close the ComfyUI that is running now, then click Retry.', '请关闭正在运行的 ComfyUI,然后点击重试。')), # web/health.js lists the failed files and what to do on the page itself. (r'The browser page did not load FreeVideo completely|浏览器页面没有完整加载 FreeVideo', 'page', ('FreeVideo did not load completely in the browser', '浏览器里的 FreeVideo 没有完整加载'), ('The browser page names the files that failed and what to do. Follow it, then reload the page; if it happens again, export the report.', - '浏览器页面上列出了出错的文件和处理方法。按提示处理后刷新页面;仍然出现时请导出报告。')), + '请按浏览器页面上的提示处理后刷新;仍然出现时,可以导出报告。')), (r'The browser has not opened FreeVideo yet|浏览器还没有打开 FreeVideo', 'page-silent', ('The browser has not opened FreeVideo', '浏览器还没有打开 FreeVideo'), ('Click “Open FreeVideo”. If the page stays blank or keeps loading, open it in a current Chrome or Edge.', @@ -506,29 +509,29 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep (r'CUDA_ARCH_UNSUPPORTED', 'gpu-architecture', ('This GPU lacks the required compute support', '这张显卡不满足当前计算要求'), ('The detected GPU and compute capability are shown below. The CUDA runtime requires native BF16 computation (SM80 or newer). Use a compatible GPU; changing the driver does not change its hardware capability.', - '下方显示实际显卡和计算能力。当前 CUDA 引擎要求原生 BF16 计算(SM80 及以上);需要使用兼容显卡,更新驱动无法改变显卡的硬件能力。')), + '下方是这张显卡的信息。FreeVideo 需要较新的显卡,更新驱动无法解决。')), (r'CUDA_CAPABILITY_UNKNOWN', 'gpu-detection', - ('The GPU architecture could not be read', '未能读取显卡架构'), + ('GPU information could not be read', '未能读取显卡信息'), ('Retry hardware detection. If it still fails, export the report; it includes the GPU identity and the raw driver query.', - '请重试硬件检测。仍失败时导出报告,其中包含显卡信息和驱动查询的原始结果。')), + '请点“重新检查”。仍然失败时,可以导出报告。')), (r'CURL_REPAIR_(?:TIMEOUT|HTTP|TLS|DNS|FAILED|PROXY_UNSUPPORTED|PROXY_MISSING|REDIRECT|SIZE|INTEGRITY)', 'download-tool-network', ('The download tool could not be prepared', '下载工具自动补齐失败'), ('Check the connection mode in Downloads and retry. Export the report for the source, route and failure code.', - '请在“下载”中检查连接方式后重试。导出报告已包含来源、连接方式和失败码。')), + '请在“下载”里检查连接方式后重试。')), (r'CURL_START_DENIED|CURL_REPAIR_STORAGE', 'download-tool-access', ('Windows blocked the download tool', '下载工具启动或保存被拒绝'), ('Check folder permissions and Windows Security protection history, then retry. Export the report for the Windows error code.', - '请检查安装目录权限和 Windows 安全中心的保护历史记录后重试。导出报告包含 Windows 错误码。')), + '请检查安装目录的权限,并查看 Windows 安全中心的保护历史记录,然后重试。')), (r'CURL_[A-Z_]+|Windows curl\.exe was not found', 'download-tool', ('The download tool is unavailable', '下载工具不可用'), ('Retry installation to repair the tool automatically. If it still fails, export the report for the complete tool diagnostics.', - '请重试安装,启动器会自动修复下载工具。仍失败时导出报告,其中包含完整工具诊断。')), + '请点“重试安装”,启动器会自动修复下载工具。仍然失败时,可以导出报告。')), (r'ComfyUI is still running a job', 'busy', ('A video is still generating', '还有视频正在生成'), ('The update is installed. Click Connect again after the job finishes to restart ComfyUI with it.', '更新已装好。等当前任务完成后点“重新连接”,ComfyUI 会重启并使用新版。')), (r'Installation paused: Windows commit headroom', 'memory-commit', - ('Windows memory headroom is low', 'Windows 内存提交余量不足'), + ('Windows is low on available memory', 'Windows 可用内存不足'), ('Close memory-heavy applications, then retry. Downloaded files are retained.', '关闭占用内存较多的程序后重试,已下载文件会保留。')), (r'Installation paused: system RAM is nearly exhausted', 'memory-system', @@ -536,9 +539,9 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep ('Close memory-heavy applications, then retry. Downloaded files are retained.', '关闭占用内存较多的程序后重试,已下载文件会保留。')), (r'Installation RAM monitoring failed|Cannot read process-tree memory', 'memory-monitor', - ('Memory usage could not be read', '暂时无法读取进程内存'), + ('Memory usage could not be read', '暂时无法读取内存用量'), ('Download progress is saved. Retry installation; copy the details if it happens again.', - '下载进度已保留。点击“重试安装”;若再次出现,请复制详情反馈。')), + '下载进度已保留,请点“重试安装”。')), (r'No space left on device|WinError 112|disk (?:is )?full|Not enough disk space|Insufficient disk space', 'disk', ('Not enough disk space', '磁盘空间不足'), ('Free up space on the installation drive, then retry.', '清理安装盘空间后,点击重试。')), @@ -556,13 +559,13 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep (r'The installation plan changed\. Detect configuration again', 'plan-changed', CHANGED_TITLE, _joined(CHANGED_WHY['other'], CHANGED_REPAIR)), (ENVIRONMENT_PYTHON_REMOVED, 'environment-python-removed', - ('A Python file is missing', '一个 Python 文件不见了'), + ('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.', '可能被安全软件隔离了。恢复这个文件或允许 FreeVideo 所在的文件夹后,点“重试安装”。')), # Windows refuses PyTorch's unsigned DLLs under Smart App Control; reinstalling changes nothing. (r'WinError 4551|Application Control policy has blocked', 'app-control', - ('Windows blocked the PyTorch files', 'Windows 拦截了 PyTorch 的文件'), + ('Windows blocked some runtime files', 'Windows 拦截了运行组件'), ('Smart App Control in Windows blocked these files. Search Windows for "Smart App Control", or open Windows Security → App & browser control → Smart App Control settings.', 'Windows 的“智能应用控制”拦截了这些文件。在 Windows 中搜索“智能应用控制”,或打开“Windows 安全中心 → 应用和浏览器控制 → 智能应用控制设置”。')), (r'PermissionError|Access is denied|Permission denied|WinError 5\b', 'permission', @@ -570,9 +573,9 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep ('Choose an installation folder you can write to, then retry.', '选择有写入权限的安装目录后重试。')), # A worker's own check of the cuDNN files it loaded (runtime_libraries). (r"FreeVideo's PyTorch library files do not match", 'runtime-libraries', - ('PyTorch files have been changed', 'PyTorch 文件已被更改'), + ('Runtime files have been changed', '运行组件已被更改'), ('Click Repair to reinstall a complete copy of PyTorch. Models and videos are kept.', - '请点击“修复”,FreeVideo 将重新安装完整的 PyTorch,模型和视频都会保留。')), + '请点“修复”,FreeVideo 会重新安装运行组件,模型和视频都会保留。')), (r'ModuleNotFoundError|No module named|DLL load failed', 'dependencies', *DEPENDENCIES), (r'CUDA (?:error: )?out of memory|torch\.OutOfMemoryError|Insufficient currently available memory|crossed its RAM budget|paging file is too small|WinError 1455', 'memory', ('Memory is unavailable for this step', '这一步可用内存不足'), diff --git a/freevideo_engine/feedback.py b/freevideo_engine/feedback.py new file mode 100644 index 00000000..2c2f0c2c --- /dev/null +++ b/freevideo_engine/feedback.py @@ -0,0 +1,69 @@ +"""The diagnostic summary a user may add to a suggestion (it is put in the GitHub page). + +Only the redacted fields the report form already uses: version, hardware, +settings and timings. No prompt, reference or picture is read here. +""" +import json +import re + +# The summary travels in a GitHub page address, which has to stay short. +MAX_DIAGNOSTICS = 3000 +# Anything that looks like a file location or a long secret-like token is replaced. +# A location may contain spaces, so it is removed to the end of the line or quote. +PATH = re.compile(r'(?:[A-Za-z]:[\\/]|\\\\|~[\\/])[^\'"<>|\r\n]+|(?|\r\n]+)+') +TOKEN = re.compile(r'\b[A-Za-z0-9_\-]{32,}\b') +HARDWARE_KEYS = ('gpu_name', 'vram_total', 'ram_total', 'system', 'architecture', 'driver_version', + 'torch_version', 'cuda_version') + + +def scrub(value): + return TOKEN.sub('', PATH.sub('', str(value))) + + +def summary(report, machine=None): + """The diagnostic summary: the same redacted fields the report form uses, no prompt or picture. + + report is the parsed diagnostic report of the latest generation (or None); + machine is the installation's machine record (or None). + """ + out = {} + hardware = {} + if isinstance(machine, dict) and isinstance(machine.get('hardware'), dict): + hardware.update({k: v for k, v in machine['hardware'].items() if k in HARDWARE_KEYS}) + analysis = report.get('analysis') if isinstance(report, dict) else None + if isinstance(analysis, dict): + if isinstance(analysis.get('hardware'), dict): + hardware.update({k: v for k, v in analysis['hardware'].items() if k in HARDWARE_KEYS}) + for key in ('version', 'status', 'request_seconds', 'geometry', 'config', 'stages', 'budgets'): + if key in analysis: + out[key] = analysis[key] + errors = analysis.get('errors') + if isinstance(errors, list): + out['errors'] = [scrub(e)[:300] for e in errors[:3]] + if hardware: + out['hardware'] = hardware + # Bound the whole summary; drop the largest optional parts first. + for key in ('stages', 'config', 'errors', 'budgets', 'geometry'): + if len(json.dumps(out, separators=(',', ':'), ensure_ascii=False)) <= MAX_DIAGNOSTICS: + break + out.pop(key, None) + return out + + +def collect(report_id=None): + """The diagnostic summary for the latest generation, or for this machine alone before any.""" + report = machine = None + try: + from .comfy_progress import REPORTS + with REPORTS.lock: + token = report_id if report_id in REPORTS.runs else next(reversed(REPORTS.runs), None) + if token: + report = json.loads(REPORTS.snapshot(token)) + except (KeyError, OSError, RuntimeError, ValueError, TypeError): + report = None + try: + from .comfy_bridge import installation + machine = installation()[1] + except Exception: # Setup problems must not stop a suggestion from being sent. + machine = None + return summary(report, machine) diff --git a/freevideo_engine/generate.py b/freevideo_engine/generate.py index 6585e63d..3d03c7d3 100644 --- a/freevideo_engine/generate.py +++ b/freevideo_engine/generate.py @@ -425,7 +425,7 @@ def usable_continuation(value, *check): two_pass = getattr(args, 'two_pass', True) preview = getattr(args, 'preview', False) is True from .preview import validate_flags, source as preview_source - validate_flags(preview, getattr(args, 'refine_from', None), two_pass) + validate_flags(preview, getattr(args, 'refine_from', None), two_pass, requested_steps) refine_source = preview_source(args.refine_from) if getattr(args, 'refine_from', None) else None if requested_steps is not None: validate_steps(requested_steps, refine_steps, two_pass) @@ -567,6 +567,8 @@ def usable_continuation(value, *check): from .two_pass import plan as continuation_plan validate_route(task=profile['engine'].get('task', 't2va'), anchor=anchored) planned = continuation_plan(canvas, two_pass, base_steps=base_steps, refine_steps=refine_steps) + if two_pass and not planned['enabled'] and base_steps != 8: + raise ValueError(planned['reason'] + ' A continuation needs two-pass generation.') continuation_first = planned['first'] continuation = usable_continuation(continuation, canvas, None if active_loras else manifest, planned) if manifest.get('precision') not in ('fp8', 'int8', 'bf16'): # BF16: the unquantized reference @@ -1007,9 +1009,9 @@ def launch(selected, attempt): request.pop('resume_refine', None) request.pop('resume_first_pass', None) if resume_decode is None and resume_refine is None and preview_pass is not None: - from .decode_resume import refine_provenance + from .decode_resume import refine_provenance, same_identity receipt = json.loads(Path(preview_pass['metrics']).read_text(encoding='utf-8')) - if refine_provenance(request) == receipt.get('refine_provenance'): + if same_identity(refine_provenance(request), receipt.get('refine_provenance')): request['resume_first_pass'] = dict(preview_pass) report['upscaled_preview']['first_pass_reused'] = True else: diff --git a/freevideo_engine/launcher/FIcon.qml b/freevideo_engine/launcher/FIcon.qml index a110e540..85668ba3 100644 --- a/freevideo_engine/launcher/FIcon.qml +++ b/freevideo_engine/launcher/FIcon.qml @@ -19,6 +19,7 @@ Canvas { else if (kind === "text") { c.moveTo(5,5); c.lineTo(19,5); c.moveTo(12,5); c.lineTo(12,20); c.moveTo(8,20); c.lineTo(16,20) } else if (kind === "disk") { c.rect(3,7,18,10); c.moveTo(3,13); c.lineTo(21,13); c.moveTo(16.5,15.2); c.lineTo(17.5,15.2) } else if (kind === "decoder") { c.rect(3,5,18,14); c.moveTo(8,9); c.lineTo(5,12); c.lineTo(8,15); c.moveTo(16,9); c.lineTo(19,12); c.lineTo(16,15) } + else if (kind === "eye" || kind === "eyeoff") { c.moveTo(2,12); c.quadraticCurveTo(12,3,22,12); c.quadraticCurveTo(12,21,2,12); c.moveTo(15,12); c.arc(12,12,3,0,Math.PI*2); if (kind === "eyeoff") { c.moveTo(4,4); c.lineTo(20,20) } } else { c.rect(3,4,18,16); c.moveTo(9,8); c.lineTo(16,12); c.lineTo(9,16); c.closePath() } c.stroke() } diff --git a/freevideo_engine/launcher/FQrCode.qml b/freevideo_engine/launcher/FQrCode.qml new file mode 100644 index 00000000..4c36b541 --- /dev/null +++ b/freevideo_engine/launcher/FQrCode.qml @@ -0,0 +1,45 @@ +import QtQuick + +// A QR code drawn from rows of "0"/"1" (see qr_code.py): dark modules on a white tile. +// Plain rectangles rather than a Canvas: a Canvas paints nothing on software-rendered setups. +Rectangle { + id: tile + property var rows: [] + property int size: 132 + readonly property int count: rows.length + // The rows joined into one string, so a refresh that brings the same code changes nothing. + readonly property string signature: rows.join("") + // Whole pixels per module keep the edges sharp; the leftover space becomes the quiet zone. + readonly property int cell: count ? Math.max(1, Math.floor((size - 12) / count)) : 0 + // Horizontal runs of dark modules: {x, y, n}. + property var runs: [] + implicitWidth: size; implicitHeight: size + radius: 8; color: "#ffffff" + function rebuild() { + var found = [] + for (var y = 0; y < rows.length; y++) { + var row = rows[y], start = -1 + for (var x = 0; x <= row.length; x++) { + var dark = x < row.length && row.charAt(x) === "1" + if (dark && start < 0) start = x + else if (!dark && start >= 0) { found.push({x: start, y: y, n: x - start}); start = -1 } + } + } + runs = found + } + onSignatureChanged: rebuild() + Component.onCompleted: rebuild() + Item { + anchors.centerIn: parent + width: tile.cell * tile.count; height: width + Repeater { + model: tile.runs + delegate: Rectangle { + required property var modelData + x: modelData.x * tile.cell; y: modelData.y * tile.cell + width: modelData.n * tile.cell; height: tile.cell + color: "#000000"; antialiasing: false + } + } + } +} diff --git a/freevideo_engine/launcher/Main.qml b/freevideo_engine/launcher/Main.qml index d2f5b533..622cfec7 100644 --- a/freevideo_engine/launcher/Main.qml +++ b/freevideo_engine/launcher/Main.qml @@ -15,6 +15,9 @@ ApplicationWindow { Connections { target: backend; function onChanged() { win.s = backend.state } } property bool settingsOpen: false property bool terminalOpen: false + property bool lanPasswordEditing: false + function firstLanLink() { return s.access_links && s.access_links.length ? s.access_links[0] : "" } + property bool lanPasswordShown: false property bool confirmArchives: false // the in-place "delete the imported ZIPs?" step property bool archivesExpanded: false property string settingsTab: "downloads" @@ -81,10 +84,10 @@ ApplicationWindow { var d = disk if (!d) return "" var en = "\"" + d.name + "\"", zh = "“" + d.name + "”" - if (d.problem === "fat32") return t(en + " uses FAT32, which limits each file to 4 GB, too small for the model files.", zh + "是 FAT32 格式,单个文件最大 4 GB,放不下模型。") - if (d.problem === "exfat") return t(en + " uses exFAT, where the FreeVideo environment cannot run.", zh + "是 exFAT 格式,装不了 FreeVideo 的运行环境。") + if (d.problem === "fat32") return t(en + " can't store files over 4 GB. Choose another disk.", zh + " 不能存放 4 GB 以上的文件,请换一块硬盘。") + if (d.problem === "exfat") return t(en + " can't hold FreeVideo. Choose another disk.", zh + " 不能安装 FreeVideo,请换一块硬盘。") if (d.problem === "disconnected") return t(en + " is not connected. Connect it to continue.", zh + "没有连接,请接上这块硬盘。") - if (d.problem === "read-only") return t(en + " is read-only on this Mac.", zh + "在这台 Mac 上是只读的。") + if (d.problem === "read-only") return t(en + " is read-only. Choose another disk.", zh + " 不能写入,请换一块硬盘。") if (d.short) return t(en + " has " + d.free + " available; this installation needs about " + d.need + ".", zh + "可用 " + d.free + ",这次安装需要约 " + d.need + "。") return t(d.name + " · " + d.free + " available", d.name + " · 可用 " + d.free) @@ -105,7 +108,7 @@ ApplicationWindow { readonly property bool upgradeShown: !upgrade.dismissed && ["available", "low-disk", "downloading", "downloaded", "switching", "releasing", "releasable", "complete", "failed"].indexOf(upgrade.status) >= 0 function upgradeHeadline() { var u = upgrade - if (u.status === "available") return t("A faster model is available for your GPU", "你的显卡有更快的模型可用") + if (u.status === "available") return t("A faster model is available for your GPU", "有适合这张显卡的更快模型") if (u.status === "low-disk") return t("Not enough disk space for the faster model", "磁盘空间不够,暂时无法升级到更快的模型") if (u.status === "downloading") return t("Downloading the faster model", "正在下载更快的模型") if (u.status === "downloaded" && u.waiting === "comfy") return t("Switching after ComfyUI is closed", "关闭 ComfyUI 后切换") @@ -122,8 +125,8 @@ ApplicationWindow { } function upgradeBlocker(code) { if (code === "prepared-folder-redirected") return t("The model folder is linked to another location, and FreeVideo never deletes through a link.", "模型文件夹被链接到了别的位置,FreeVideo 不会通过链接删除文件。") - if (code === "unexpected-active-variant") return t("FreeVideo is not using the int8 model, so the old model stays.", "当前使用的不是 int8 模型,旧模型保留。") - if (code === "active-variant-incomplete") return t("Some int8 files are missing, so the old model stays.", "int8 模型文件不完整,旧模型保留。") + if (code === "unexpected-active-variant") return t("The faster model is not in use, so the old model is kept.", "没有在用更快的模型,旧模型保留。") + if (code === "active-variant-incomplete") return t("The faster model's files are incomplete, so the old model is kept.", "更快的模型文件不完整,旧模型保留。") return t("The model in use is not a standard FreeVideo model, so nothing was deleted.", "当前使用的模型不是标准安装的模型,为安全起见没有删除任何文件。") } function upgradeExplanation() { @@ -132,9 +135,9 @@ ApplicationWindow { var speed = u.architecture === "ampere" ? t("about twice as fast on this GPU", "在这张显卡上预计快约 2 倍") : t("about 10–30% faster on this GPU", "在这张显卡上预计快约 10–30%") if (u.status === "available") - return (u.resized ? t("Recounted with the other model files this installation still needs. ", "下载量已按实际重新计算,包含这份安装还缺的其他模型文件。") : "") - + t("The int8 model is ", "int8 模型") + speed + t(" and closer to the original quality. It downloads ", ",画质也更接近原版。需要下载约 ") + bytes(u.download_bytes) - + (lora ? t("; your old model stays for the fused LoRAs that int8 cannot merge yet.", ";你有需要合并的 LoRA,int8 暂不支持,旧模型会保留,不释放空间。") + return (u.resized ? t("Download size updated. ", "下载量已更新。") : "") + + t("The faster model is ", "更快的模型") + speed + t(" and closer to the original quality. It downloads ", ",画质也更接近原版。需要下载约 ") + bytes(u.download_bytes) + + (lora ? t("; the old model stays so your LoRAs keep working, and no space is freed.", ";为了继续使用已有的 LoRA,旧模型会保留,空间不会释放。") : t(", then removes the old model and frees about ", ",完成后删除旧模型,释放约 ") + bytes(u.release_bytes) + t(".", "。")) + t(" You can keep generating while it downloads.", "下载期间可以继续生成。") if (u.status === "low-disk") @@ -147,12 +150,12 @@ ApplicationWindow { return t("This ComfyUI was not opened by the launcher, so FreeVideo cannot restart it. Close it and the switch starts on its own; until then the current model keeps working.", "这个 ComfyUI 不是启动器打开的,FreeVideo 没法重启它。关闭它后会自动开始切换,在此之前当前模型照常可用。") if (u.status === "downloaded" || u.status === "switching") - return t("ComfyUI restarts once and generation pauses for about 2 minutes while FreeVideo switches; settings and videos are kept.", "切换约需 2 分钟,ComfyUI 会重启一次,期间暂停生成,设置和视频都会保留。") + return t("Generation pauses for about 2 minutes during the switch; settings and videos are kept.", "切换期间暂停生成约 2 分钟,设置和视频会保留。") if (u.status === "releasable" && u.in_use === "int8_convrot") - return t("FreeVideo now uses the int8 model. Removing the old model frees about ", "现在用的是 int8 模型,删除不再使用的旧模型可以释放约 ") + bytes(u.release_bytes) + return t("FreeVideo now uses the faster model. Removing the old model frees about ", "已在用更快的模型,删除旧模型可释放约 ") + bytes(u.release_bytes) + t("; only its own files are removed.", ",只删除旧模型自己的文件。") if (u.status === "releasable") - return t("These model files are not in use, for example an int8 download this GPU could not run. Removing them frees about ", "这些模型文件没有在使用,比如这张显卡没能运行的 int8 下载。删除可以释放约 ") + bytes(u.release_bytes) + return t("Removing unused model files frees about ", "删除未使用的模型文件可释放约 ") + bytes(u.release_bytes) + t("; the model in use is not touched.", ",正在使用的模型不受影响。") if (u.status === "releasing") return t("Only the old model's own files are removed.", "只删除旧模型自己的文件。") if (u.status === "complete") @@ -160,8 +163,8 @@ ApplicationWindow { + (lora ? t(" The old model stays for your fused LoRAs.", "旧模型为需要合并的 LoRA 保留。") : "") if (u.status === "failed") return (u.blockers && u.blockers.length ? upgradeBlocker(u.blockers[0]) + t(" ", "") : u.error ? u.error.split("\n")[0] + "\n" : "") - + (u.step === "release" ? t("The int8 model is in use; the old files stay on disk for now.", "int8 模型已在使用,旧模型文件暂时保留在磁盘上。") - : u.step === "switch" && !u.kept ? t("The previous model's files are all kept. Retry on the setup page restores it; you can upgrade again afterwards.", "原来的模型文件都还在。在安装页面点「重试」即可恢复,之后可以再次升级。") + + (u.step === "release" ? t("The faster model is in use; the old model files are kept for now.", "已在用更快的模型,旧模型文件暂时保留。") + : u.step === "switch" && !u.kept ? t("The previous model's files are all kept. Select \"Retry\" on the setup page to restore it.", "原来的模型文件都还在,在安装页点“重试”可恢复。") : t("Your previous model is unchanged and still works.", "原来的模型没有变化,可以继续使用。")) return "" } @@ -170,7 +173,7 @@ ApplicationWindow { if (phase === "downloading") return t("Downloading update ", "正在下载更新 ") + percent(s.update.progress) if (phase === "waiting") return t("Updating after the current video finishes", "当前视频生成完成后自动更新") if (phase === "restarting") return t("Restarting FreeVideo…", "正在重启 FreeVideo…") - if (phase === "engine") return t("Updating the engine…", "正在更新引擎…") + if (phase === "engine") return t("Updating…", "正在更新…") if (phase === "checking") return t("Checking for updates…", "正在检查更新…") if (s.update.status === "error") return t("Update incomplete", "更新未完成") if (s.update.candidate) return t("FreeVideo ", "FreeVideo ") + releaseVersion(s.update.candidate) + t(" is available", " 可以更新") @@ -254,7 +257,8 @@ ApplicationWindow { Rectangle { Layout.fillWidth: true; Layout.bottomMargin: 8; implicitHeight: busyBody.implicitHeight + 28 radius: theme.radiusMd; color: theme.surface; border.color: theme.border - opacity: s.busy ? 1 : 0; visible: opacity > 0 + // Waiting for the person to confirm the plan is not work in progress. + opacity: s.working ? 1 : 0; visible: opacity > 0 Behavior on opacity { NumberAnimation { duration: 240; easing.type: Easing.OutCubic } } ColumnLayout { id: busyBody; x: 14; y: 14; width: parent.width - 28; spacing: 8 @@ -413,12 +417,12 @@ ApplicationWindow { onClicked: backend.useDisk(bestDisk.path) } FButton { objectName: "chooseDisk"; visible: diskFailure && s.form.new_comfy && diskChoices.length > (bestDisk ? 1 : 0); enabled: !s.busy text: t("Other disks…", "其他硬盘…"); onClicked: diskDialogOpen = true } - FButton { objectName: "diskCleanupButton"; visible: s.failure.kind === "disk"; enabled: s.can_cleanup; text: t("Clean download cache", "清理下载缓存"); onClicked: { settingsTab = "general"; settingsOpen = true; backend.cleanupDownloads(false) } } + FButton { objectName: "diskCleanupButton"; visible: s.failure.kind === "disk"; enabled: s.can_cleanup; text: t("Clean download cache", "清理缓存"); onClicked: { settingsTab = "general"; settingsOpen = true; backend.cleanupDownloads(false) } } FButton { visible: s.failure.kind === "download"; text: t("Change source", "切换下载源"); onClicked: { settingsTab = "downloads"; settingsOpen = true } } FButton { objectName: "repairCardButton"; primary: true; visible: !!s.repair_offered; text: t("Repair", "修复"); onClicked: backend.action("repair", false) } FButton { objectName: "latestLauncherLink"; visible: s.failure.kind === "older-launcher"; flat: true text: t("Download the latest ↗", "下载最新版 ↗"); onClicked: backend.link("https://github.com/FlashML-org/FreeVideo/releases/latest") } - FButton { objectName: "copyError"; visible: !diskFailure; text: t("Copy full details", "复制完整详情"); onClicked: backend.copy(s.error) } + FButton { objectName: "copyError"; visible: !diskFailure; text: t("Copy details", "复制详情"); onClicked: backend.copy(s.error) } FButton { objectName: "showError"; text: errorDetailsOpen ? t("Hide details", "收起详情") : t("Show details", "查看详情"); flat: true; onClicked: errorDetailsOpen = !errorDetailsOpen } FButton { objectName: "exportError"; visible: !diskFailure; text: t("Export report", "导出报告"); enabled: s.report.status !== "running"; onClicked: backend.exportReport() } } @@ -435,7 +439,7 @@ ApplicationWindow { text: s.report.status === "running" ? t("Exporting full logs…", "正在导出完整日志…") : s.report.status === "error" ? t("Export failed: ", "导出失败:") + s.report.error : t("Report saved locally: ", "报告已保存到本机:") + s.report.path + - (s.report.collection_errors ? t("\nSome diagnostics were unavailable; see manifest.json in the ZIP.", "\n部分诊断信息未能收集,原因记录在 ZIP 内的 manifest.json。") : "") + (s.report.collection_errors ? t("\nReport saved with some diagnostic information missing.", "\n报告已保存,部分诊断信息未能收集。") : "") } ColumnLayout { @@ -489,7 +493,7 @@ ApplicationWindow { // The label's right edge lines up with Browse; the hover fill reaches into the card's padding. FButton { objectName: "changeDisk"; visible: s.form.new_comfy && diskChoices.length > 0; enabled: s.can_manage; flat: true; implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1 leftPadding: 10; rightPadding: 10; Layout.rightMargin: -10; Layout.alignment: Qt.AlignBaseline - text: t("Choose another disk", "换一块硬盘"); onClicked: diskDialogOpen = true } + text: t("Change disk", "更换硬盘"); onClicked: diskDialogOpen = true } } } FCard { @@ -667,7 +671,7 @@ ApplicationWindow { Layout.fillWidth: true; spacing: 16 ColumnLayout { Layout.fillWidth: true; spacing: 4 - FText { text: s.status === "review" ? t("Installation plan", "安装计划") : s.overall.label || s.progress.label || t("Checking your installation", "正在检查安装"); font.pixelSize: theme.strong + 1; font.weight: Font.DemiBold; Layout.fillWidth: true } + FText { text: s.status === "review" ? t("Installation details", "安装内容") : s.overall.label || s.progress.label || t("Checking your installation", "正在检查安装"); font.pixelSize: theme.strong + 1; font.weight: Font.DemiBold; Layout.fillWidth: true } // The plan's line (GPU, download, disk peak) belongs to the review; while installing, // the step line already gives the download, so the card does not repeat it. FText { objectName: "planSummary"; visible: !!s.summary && !s.busy; text: s.summary; color: theme.muted; font.pixelSize: theme.micro + 1; Layout.fillWidth: true } @@ -841,8 +845,9 @@ ApplicationWindow { Layout.fillWidth: true; spacing: 6 // A failed start stays failed while another task (an import, a cleanup) runs: the controller's own tasks clear it first. FText { objectName: "statusTitle"; text: s.status === "open" ? t("FreeVideo is ready", "FreeVideo 已就绪") : s.status === "failed" ? t("Startup interrupted", "启动未完成") : s.busy ? t("Starting ComfyUI", "正在启动 ComfyUI") : s.status === "restart-required" ? t("Restart ComfyUI", "请重启 ComfyUI") : t("Ready to launch", "准备就绪"); font.pixelSize: theme.section + 2; font.weight: Font.DemiBold; Layout.fillWidth: true } - FText { objectName: "statusDetail"; text: s.status === "open" ? s.url : s.status === "restart-required" ? t("Restart your running ComfyUI to load the updated nodes, then connect again.", "重启正在运行的 ComfyUI 以载入更新后的节点,再点击重新连接。") : s.status === "failed" ? t("ComfyUI did not start. See the message above.", "ComfyUI 未能启动,原因见上方。") : t("Your models and environment are connected.", "已连接你的模型与运行环境。"); color: theme.muted; Layout.fillWidth: true } + FText { objectName: "statusDetail"; text: s.status === "open" ? s.url : s.status === "restart-required" ? t("Restart ComfyUI after the update, then select \"Connect again\".", "更新后需重启 ComfyUI,再点“重新连接”。") : s.status === "failed" ? t("ComfyUI did not start. See the message above.", "ComfyUI 未能启动,原因见上方。") : t("Your models and environment are connected.", "已连接你的模型与运行环境。"); color: theme.muted; Layout.fillWidth: true } FText { objectName: "portMovedNote"; visible: s.status === "open" && s.port_from > 0; text: t("Port %1 is in use by another program, so FreeVideo now uses port %2 and will keep using this address.", "端口 %1 已被其他程序占用,FreeVideo 改用端口 %2,以后也使用这个地址。").arg(s.port_from).arg(s.port_to); font.pixelSize: 13; color: theme.muted; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -2 } + FText { objectName: "pluginScopedNote"; visible: s.status === "open" && s.plugin_scoped; text: t("Some installed plugins are not compatible with FreeVideo, so only FreeVideo was loaded this time.", "部分已装的插件与 FreeVideo 不兼容,这次只载入了 FreeVideo。"); font.pixelSize: 13; color: theme.muted; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -2 } } } FMeter { objectName: "statusMeter"; visible: s.busy && s.status !== "failed"; Layout.fillWidth: true; fraction: win.fraction(s.overall); active: visible } @@ -938,7 +943,7 @@ ApplicationWindow { text: s.setup_pending ? s.setup_wait_text : s.busy ? t("Working…", "正在处理…") : primaryText() enabled: !s.busy && !s.setup_pending && !(s.page === "launcher" && s.needs_consent && !accepted) && !(s.page === "progress" && s.status === "review" && !s.retry_kind && (!accepted || !!s.error)) - onClicked: { if (s.retry_kind) { backend.action("retry", accepted); return } if (needsRuntime) { backend.browseRuntimePackage(); return } if (needsPackages) { backend.browsePackages(); return } if (updateFirst) { backend.update(""); return } backend.action(s.page === "launcher" && s.status === "open" ? "browser" : "primary", accepted); if (s.busy && s.page === "launcher") terminalOpen = true } + onClicked: { if (s.retry_kind) { backend.action("retry", accepted); return } if (needsRuntime) { backend.browseRuntimePackage(); return } if (needsPackages) { backend.browsePackages(); return } if (updateFirst) { backend.update(""); return } backend.action(s.page === "launcher" && s.status === "open" ? "browser" : "primary", accepted) } } } } @@ -968,15 +973,15 @@ ApplicationWindow { } Flow { Layout.fillWidth: true; spacing: 6 - FButton { text: t("Copy full log", "复制完整日志"); flat: true; implicitHeight: 28; leftPadding: 8; rightPadding: 8; font.pixelSize: theme.micro; onClicked: backend.copyLog() } + FButton { text: t("Copy log", "复制日志"); flat: true; implicitHeight: 28; leftPadding: 8; rightPadding: 8; font.pixelSize: theme.micro; onClicked: backend.copyLog() } FButton { text: t("Export report", "导出报告"); flat: true; implicitHeight: 28; leftPadding: 8; rightPadding: 8; font.pixelSize: theme.micro; enabled: s.report.status !== "running"; onClicked: backend.exportReport() } FButton { text: t("Clear", "清空"); flat: true; implicitHeight: 28; implicitWidth: 64; leftPadding: 8; rightPadding: 8; font.pixelSize: theme.micro; onClicked: backend.clearTerminal() } } - FText { Layout.fillWidth: true; font.pixelSize: theme.micro; color: theme.muted; text: t("Recent output is shown here. Copy or export to get the full redacted log.", "这里显示最近的输出;复制或导出可获取完整脱敏日志。") } + FText { Layout.fillWidth: true; font.pixelSize: theme.micro; color: theme.muted; text: t("Copy or export the full log with private information hidden.", "可复制或导出完整日志,隐私信息已隐藏。") } ScrollView { id: terminalScroll; Layout.fillWidth: true; Layout.fillHeight: true; clip: true TextArea { - id: terminalText; objectName: "terminalText"; text: s.log || t("Process output will appear here.", "进程启动后,输出会显示在这里。"); textFormat: TextEdit.PlainText + id: terminalText; objectName: "terminalText"; text: s.log || t("Activity will appear here after startup.", "启动后将在这里显示运行记录。"); textFormat: TextEdit.PlainText readOnly: true; selectByMouse: true; wrapMode: TextEdit.Wrap; color: s.log ? theme.text : theme.disabled; font.family: theme.mono; font.pixelSize: 12; background: null selectionColor: theme.accentDim onTextChanged: if (!activeFocus) cursorPosition = length @@ -1043,7 +1048,7 @@ ApplicationWindow { Layout.fillWidth: true; current: s.source; onPicked: function(value) { backend.selectSource(value) } options: [{value: "auto", label: t("Automatic", "自动选择")}, {value: "official", label: "Hugging Face"}, {value: "hf-mirror", label: t("HF Mirror", "HF 镜像")}, {value: "modelscope", label: t("ModelScope", "魔搭")}] } - FText { text: t("Switching also moves the current download. Verified partial data is retained wherever resuming is supported.", "切换会应用于当前下载;支持续传时,继续使用已校验的片段。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } + FText { text: t("Downloads continue from the new source, keeping progress where supported.", "切换后继续下载,支持续传的文件会保留进度。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } FDivider {} RowLayout { Layout.fillWidth: true; spacing: 12 @@ -1105,10 +1110,35 @@ ApplicationWindow { Layout.fillWidth: true; title: t("Local network", "局域网") FSwitch { objectName: "lanAccessSwitch"; Layout.fillWidth: true; text: t("LAN access", "局域网访问"); checked: !!s.form.lan_access; enabled: !s.busy; onToggled: backend.edit("lan_access", checked) } FText { objectName: "lanAccessMessage"; text: s.lan_message || ""; visible: !!text; color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } - FText { objectName: "lanAccessHint"; text: t("When on, other devices on the same network can open FreeVideo with the link below. Anyone with the link can use it, so keep it private.", "开启后,同一网络中的其他设备可以用下面的链接打开 FreeVideo。拿到链接的人都能使用,请勿公开。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } - FText { objectName: "lanFirewallHint"; visible: Qt.platform.os === "windows"; text: t("If Windows asks about the firewall the first time, choose Allow.", "Windows 首次询问防火墙权限时,请选择“允许”。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } + FText { objectName: "lanAccessHint"; text: s.lan_password_set ? t("When on, other devices on the same network can open FreeVideo with the link below and enter the password the first time.", "开启后,同一网络中的其他设备可以用下面的链接打开 FreeVideo,第一次打开时要输入密码。") : t("When on, other devices on the same network can open FreeVideo with the link below. Anyone with the link can use it, so keep it private.", "开启后,同一网络中的其他设备可以用下面的链接打开 FreeVideo。拿到链接的人都能使用,请勿公开。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } + // The Chinese line breaks after the comma, keeping the second half whole. + Flow { + objectName: "lanFirewallHint" + visible: Qt.platform.os === "windows" && !!s.form.lan_access && !(s.lan_qr && s.lan_qr.length > 0) + Layout.fillWidth: true + FText { text: t("Windows will ask for permission. Check \"Private networks\", then click \"Allow access\".", "Windows 会弹出确认框,"); color: theme.muted; font.pixelSize: theme.micro; width: Math.min(implicitWidth, parent.width); wrapMode: Text.WordWrap } + FText { text: "请勾选“专用网络”,再点“允许访问”。"; visible: s.zh; color: theme.muted; font.pixelSize: theme.micro; width: Math.min(implicitWidth, parent.width); wrapMode: Text.WordWrap } + } + GridLayout { + id: lanQrRow; objectName: "lanQrRow" + visible: !!s.lan_qr && s.lan_qr.length > 0 + Layout.fillWidth: true; columnSpacing: 16; rowSpacing: 12 + // The minimum launcher width stacks the code above its text. + columns: width < 460 ? 1 : 2 + FQrCode { objectName: "lanQr"; rows: s.lan_qr || []; Layout.alignment: Qt.AlignTop } + ColumnLayout { + Layout.fillWidth: true; spacing: 10 + FText { objectName: "lanQrHint"; text: t("Connect your phone to the same Wi-Fi, then scan with its camera. You can also copy the link to another device.", "手机连同一个 Wi-Fi 后用相机扫码,也可以复制链接到其他设备打开。"); Layout.fillWidth: true } + RowLayout { + Layout.fillWidth: true; spacing: 8 + FField { objectName: "lanQrLink"; Layout.fillWidth: true; readOnly: true; text: firstLanLink(); onTextChanged: cursorPosition = 0; Component.onCompleted: cursorPosition = 0 } + FButton { objectName: "lanQrCopy"; text: t("Copy link", "复制链接"); onClicked: backend.copy(firstLanLink()) } + } + } + } Repeater { - model: s.access_links || [] + // More than one network address: the others stay available. + model: (s.access_links || []).slice(1) delegate: RowLayout { required property string modelData Layout.fillWidth: true; spacing: 8 @@ -1116,6 +1146,55 @@ ApplicationWindow { FButton { text: t("Copy link", "复制链接"); onClicked: backend.copy(modelData) } } } + FDivider { visible: lanPasswordRow.visible } + ColumnLayout { + id: lanPasswordRow; objectName: "lanPasswordRow" + onVisibleChanged: if (!visible) lanPasswordEditing = false + visible: !!s.form.lan_access && !!s.lan_password_available + Layout.fillWidth: true; spacing: 10 + GridLayout { + Layout.fillWidth: true; columnSpacing: 12; rowSpacing: 8 + columns: width < 460 ? 1 : 2 + ColumnLayout { + Layout.fillWidth: true; spacing: 2 + FText { text: t("Password", "密码"); Layout.fillWidth: true } + FText { text: t("Optional. Once set, other devices enter it the first time they open FreeVideo. Not needed on this computer.", "可选。设置后,其他设备第一次打开时要输入,本机不用。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } + } + RowLayout { + visible: !lanPasswordEditing + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter; spacing: 8 + FText { objectName: "lanPasswordStatus"; text: s.lan_password_set ? t("On", "已设置") : t("Not set", "未设置"); color: theme.muted } + FButton { objectName: "lanPasswordSet"; visible: !s.lan_password_set; text: t("Set password", "设置密码"); implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1; enabled: !lanPasswordEditing; onClicked: { lanPasswordEditing = true; lanPasswordShown = false; lanPasswordInput.text = ""; lanPasswordInput.forceActiveFocus() } } + FButton { objectName: "lanPasswordChange"; visible: s.lan_password_set; text: t("Change", "修改"); implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1; enabled: !lanPasswordEditing; onClicked: { lanPasswordEditing = true; lanPasswordShown = false; lanPasswordInput.text = ""; lanPasswordInput.forceActiveFocus() } } + FButton { objectName: "lanPasswordRemove"; visible: s.lan_password_set; text: t("Remove", "移除"); implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1; onClicked: { lanPasswordEditing = false; backend.removeLanPassword() } } + } + } + GridLayout { + objectName: "lanPasswordEditor" + visible: lanPasswordEditing; Layout.fillWidth: true; columnSpacing: 8; rowSpacing: 8 + columns: width < 460 ? 1 : 2 + FField { + id: lanPasswordInput; objectName: "lanPasswordField"; Layout.fillWidth: true + echoMode: lanPasswordShown ? TextInput.Normal : TextInput.Password + rightPadding: 40 + Accessible.name: t("Password", "密码") + onAccepted: if (text.length >= (s.lan_password_min || 6)) lanPasswordSave.clicked() + FButton { + objectName: "lanPasswordEye"; flat: true; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter + width: 36; height: parent.height - 8; leftPadding: 0; rightPadding: 0 + Accessible.name: lanPasswordShown ? t("Hide", "隐藏") : t("Show", "显示") + contentItem: FIcon { kind: lanPasswordShown ? "eyeoff" : "eye"; ink: theme.muted; implicitWidth: 16; implicitHeight: 16 } + onClicked: lanPasswordShown = !lanPasswordShown + } + } + RowLayout { + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter; spacing: 8 + FButton { objectName: "lanPasswordCancel"; text: t("Cancel", "取消"); onClicked: { lanPasswordEditing = false; lanPasswordInput.text = "" } } + FButton { id: lanPasswordSave; objectName: "lanPasswordSave"; primary: true; text: t("Save", "保存"); enabled: lanPasswordInput.text.length >= (s.lan_password_min || 6); onClicked: { backend.setLanPassword(lanPasswordInput.text); lanPasswordInput.text = ""; lanPasswordEditing = false } } + } + } + } + FText { objectName: "lanRemedyHint"; visible: Qt.platform.os === "windows" && !!s.form.lan_access; text: t("Phone can't open it? Set this computer's Wi-Fi to \"Private\", then in Windows Security allow Python on private networks under \"Allow an app through firewall\".", "手机打不开?请把电脑连的 Wi-Fi 设为“专用网络”,并在 Windows 安全中心的“允许应用通过防火墙”里勾选 Python 的“专用”。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } } FGroup { Layout.fillWidth: true; title: t("Storage", "存储") @@ -1180,7 +1259,7 @@ ApplicationWindow { FText { text: t("Video model", "视频模型"); Layout.fillWidth: true } FText { objectName: "modelStorageStatus"; Layout.fillWidth: true; font.pixelSize: theme.micro; color: theme.muted; wrapMode: Text.WordWrap - text: upgrade.status === "available" ? t("A faster int8 model is available: about ", "有更快的 int8 模型:下载约 ") + bytes(upgrade.download_bytes) + text: upgrade.status === "available" ? t("A faster model is available: about ", "有更快的模型:下载约 ") + bytes(upgrade.download_bytes) + (upgrade.release_bytes ? t(" to download, about ", ",完成后删除旧模型,释放约 ") + bytes(upgrade.release_bytes) + t(" freed afterwards.", "。") : t(" to download.", "。")) : upgrade.status === "releasable" ? (upgrade.in_use === "int8_convrot" ? t("The old model is no longer used; about ", "旧模型已不再使用,删除可释放约 ") : t("Model files not in use; about ", "有没在使用的模型文件,删除可释放约 ")) + bytes(upgrade.release_bytes) + t(" can be freed.", "。") : upgradeHeadline() @@ -1205,7 +1284,7 @@ ApplicationWindow { FText { objectName: "compatibilityLevel"; text: s.compatibility.available && s.compatibility.levels && s.compatibility.levels[s.compatibility.level || 0] ? t(s.compatibility.levels[s.compatibility.level || 0].en.split(" · ")[0], s.compatibility.levels[s.compatibility.level || 0].zh.split(" · ")[0]) : "—"; color: s.compatibility.available ? theme.accent : theme.disabled; font.weight: Font.DemiBold } } FSlider { objectName: "compatibilitySlider"; Layout.fillWidth: true; from: 0; to: 3; stepSize: 1; value: s.compatibility.level || 0; enabled: s.compatibility.available && !s.busy; snapMode: Slider.SnapAlways; onMoved: backend.compatibilityLevel(Math.round(value)) } - FText { objectName: "compatibilityNote"; text: s.compatibility.available ? t("Applies from the next generation. Higher levels have the GPU handle less at a time, so generation is slower and the picture differs slightly; resolution, duration and steps stay the same.", "从下一次生成起生效。档位越高,显卡每次处理的数据越少,生成越慢,画面也会有细微差别;分辨率、时长和步数不变。") : t("Available after hardware setup.", "完成硬件检查后可调整。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } + FText { objectName: "compatibilityNote"; text: s.compatibility.available ? t("Applies to the next generation; higher levels are slower and may slightly change the picture.", "从下次生成起生效,档位越高越慢,画面可能略有变化。") : t("Available after hardware setup.", "完成硬件检查后可调整。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } FDivider {} FSwitch { objectName: "compatibilityAutomatic"; Layout.fillWidth: true; text: t("Raise the level automatically after an unexpected interruption", "异常中断后自动提高兼容档位"); checked: !!s.compatibility.automatic; enabled: s.compatibility.available && !s.busy; onToggled: backend.compatibility(s.compatibility.level, checked) } } @@ -1216,8 +1295,8 @@ ApplicationWindow { FGroup { Layout.fillWidth: true; title: t("ComfyUI address", "ComfyUI 地址") FField { objectName: "listenAddress"; Layout.fillWidth: true; value: s.form.url; enabled: !s.form.lan_access; onEditingFinished: backend.edit("url", text) } - FText { objectName: "listenAddressHint"; text: t("With 0.0.0.0 or this computer's LAN IP, devices on the same network can open FreeVideo without the access link.", "填 0.0.0.0 或本机的局域网 IP 后,同一网络中的设备不需要访问链接就能打开 FreeVideo。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } - FText { visible: Qt.platform.os === "windows"; text: t("If Windows asks about the firewall the first time, choose Allow.", "Windows 首次询问防火墙权限时,请选择“允许”。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } + FText { objectName: "listenAddressHint"; text: s.lan_password_set ? t("With 0.0.0.0 or this computer's LAN IP, devices on the same network can open FreeVideo without the password.", "填 0.0.0.0 或本机的局域网 IP 后,同一网络中的设备不用密码就能打开 FreeVideo。") : t("With 0.0.0.0 or this computer's LAN IP, devices on the same network can open FreeVideo without the access link.", "填 0.0.0.0 或本机的局域网 IP 后,同一网络中的设备不需要访问链接就能打开 FreeVideo。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } + FText { visible: Qt.platform.os === "windows"; text: t("Windows will ask for permission. Check \"Private networks\", then click \"Allow access\".", "Windows 会弹出确认框,请勾选“专用网络”,再点“允许访问”。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } } FGroup { Layout.fillWidth: true; title: t("Folders · optional", "目录 · 可选"); enabled: !s.portable @@ -1258,7 +1337,7 @@ ApplicationWindow { } Flow { Layout.fillWidth: true; spacing: 8 - FButton { text: t("Copy full details", "复制完整详情"); implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1; onClicked: backend.copy(s.error) } + FButton { text: t("Copy details", "复制详情"); implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1; onClicked: backend.copy(s.error) } FButton { text: t("Export report", "导出报告"); implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1; enabled: s.report.status !== "running"; onClicked: backend.exportReport() } } FText { @@ -1299,8 +1378,8 @@ ApplicationWindow { id: diskRowText; Layout.fillWidth: true; spacing: 2 FText { text: modelData.name; font.weight: Font.DemiBold; Layout.fillWidth: true } FText { visible: !modelData.enough; Layout.fillWidth: true; color: theme.danger; font.pixelSize: theme.micro - text: modelData.problem === "exfat" ? t("exFAT not supported", "不支持 exFAT") - : modelData.problem === "fat32" ? t("FAT32 not supported", "不支持 FAT32") + text: modelData.problem === "exfat" ? t("Cannot install", "无法安装") + : modelData.problem === "fat32" ? t("Cannot install", "无法安装") : t("Not enough space", "空间不够") } } FText { text: t(modelData.free + " available", "可用 " + modelData.free); color: theme.muted; font.pixelSize: theme.micro + 1; font.features: { "tnum": 1 }; wrapMode: Text.NoWrap; Layout.alignment: Qt.AlignVCenter } @@ -1636,9 +1715,9 @@ ApplicationWindow { FDialog { objectName: "releaseConfirmation"; visible: releaseConfirm title: upgrade.in_use === "int8_convrot" ? t("Remove the old model?", "删除旧模型?") : t("Remove unused model files?", "删除没在使用的模型文件?") - text: (upgrade.in_use === "int8_convrot" ? t("The FP8 model files FreeVideo no longer uses are removed, about ", "将删除 FreeVideo 不再使用的 FP8 模型文件,释放约 ") + text: (upgrade.in_use === "int8_convrot" ? t("Removes the old model files FreeVideo no longer uses, about ", "将删除不再使用的旧模型文件,释放约 ") : t("The model files FreeVideo does not use are removed, about ", "将删除 FreeVideo 没在使用的模型文件,释放约 ")) + bytes(upgrade.release_bytes || 0) - + (upgrade.in_use === "int8_convrot" ? t(". The int8 model, your videos and settings are not touched.", "。int8 模型、视频和设置都不受影响。") + + (upgrade.in_use === "int8_convrot" ? t(". The model in use, your videos and settings are not touched.", "。现在用的模型、视频和设置都不受影响。") : t(". The model in use, your videos and settings are not touched.", "。正在使用的模型、视频和设置都不受影响。")) acceptText: t("Remove", "删除"); rejectText: t("Cancel", "取消"); destructive: true onAccepted: { releaseConfirm = false; backend.upgradeModel() } diff --git a/freevideo_engine/launcher_copy.py b/freevideo_engine/launcher_copy.py index d34e0761..d4eade84 100644 --- a/freevideo_engine/launcher_copy.py +++ b/freevideo_engine/launcher_copy.py @@ -19,8 +19,8 @@ '已记下,处理完当前任务后自动导入'), 'setup_queued': ('Models are being prepared in the background. Setup will continue when they are ready.', '后台正在准备模型,好了会接着继续'), - 'update_queued': ('Another task is running. Updating will start when it finishes.', - '正在做别的事,完成后会自动更新'), + 'update_queued': ('The update will start after the current task finishes.', + '当前任务完成后会自动更新'), } @@ -32,20 +32,20 @@ ZH = dict([ ('The network is slow. Downloading again; this may take a while.', '网络较慢,正在重试'), - ('Prepare Python', '准备 Python'), ('Install Python', '安装 Python'), + ('Prepare Python', '准备运行环境'), ('Install Python', '安装运行环境'), ('Prepare download tools', '准备下载工具'), ('Tsinghua mirror', '清华镜像'), ('Preparing installation', '准备安装'), ('Checking the environment', '正在检查运行环境'), ('Testing download sources', '正在测试下载源'), ('Preparing download tools', '正在准备下载工具'), ('GitHub mirror', 'GitHub 镜像'), ('attempt', '正在连接'), ('fallback', '正在切换下载源'), ('route-retry', '正在切换连接'), - ('verifying', '正在校验'), ('no-progress', '下载无进展'), + ('verifying', '正在检查'), ('no-progress', '下载无进展'), ('slow-transfer', '持续低速'), ('resume-refused', '当前下载源不支持续传'), - ('Download PyTorch + CUDA', '下载 GPU 运行环境'), - ('Use existing Python', '复用 Python'), ('Create Python environment', '创建运行环境'), - ('Install PyTorch + CUDA', '安装 GPU 运行环境'), + ('Download PyTorch + CUDA', '下载运行组件'), + ('Use existing Python', '使用已有环境'), ('Create Python environment', '创建运行环境'), + ('Install PyTorch + CUDA', '安装运行组件'), ('Install engine dependencies', '安装生成组件'), ('Install encoder support', '安装编码组件'), ('Install model support', '安装模型组件'), ('Download model code', '下载模型组件'), - ('Check text encoder', '检查文本编码器'), ('Download model weights', '下载所需模型'), + ('Check text encoder', '检查提示词模型'), ('Download model weights', '下载所需模型'), ('Reuse models / download missing files', '复用模型并补齐文件'), ('Verify / reuse local models', '检查并复用已有模型'), ('Verify existing models', '校验已有模型'), ('Check existing model folders', '查找已有模型'), @@ -56,8 +56,8 @@ ('Optimize model storage', '准备模型'), ('Finish model storage', '完成模型准备'), ('Test GPU acceleration', '检查 GPU 加速'), ('Check GPU runtime', '检查 GPU 运行环境'), ('Check installed packages', '检查运行环境'), ('Save installed versions', '保存安装信息'), - ('Check build tools', '检查编译工具'), ('Build SageAttention 2', '准备 GPU 加速'), - ('Install SageAttention 2', '安装 GPU 加速组件'), ('Prepare dependencies', '准备运行组件'), + ('Check build tools', '检查安装工具'), ('Build SageAttention 2', '准备显卡加速'), + ('Install SageAttention 2', '安装显卡加速组件'), ('Prepare dependencies', '准备运行组件'), # The parallel phase also downloads the models (internal names stay as they are). ('Install components in parallel', '安装组件和模型'), ('Install with space saver', '安装组件和模型(极限省空间)'), @@ -74,7 +74,7 @@ ('Prepare ComfyUI', '准备 ComfyUI'), ('Update ComfyUI packages', '更新 ComfyUI 组件'), ('Install FreeVideo workflow', '安装 FreeVideo 工作流'), ('Open ComfyUI', '打开 ComfyUI'), ('Starting ComfyUI', '启动 ComfyUI'), - ('Loading nodes and the web interface', '加载节点与界面'), + ('Loading nodes and the web interface', '正在加载界面'), ('Still loading. This may take a few more minutes.', '仍在加载,可能还需要几分钟'), ('Checking configuration', '检查配置'), ('Check installation', '检查安装'), ('Verify bundled files', '校验离线包'), ('Check local files', '检查本地文件'), @@ -84,38 +84,39 @@ ('Ready', '已就绪'), ('Verified and ready', '已就绪'), ('Stopped; files retained', '已暂停,下载进度已保留'), ('Local verification stopped; files retained', '校验已暂停,文件已保留'), - ('Download and prepare the private Python runtime', '准备 Python 运行环境'), - ('Check the Python already prepared by the launcher', '检查已有 Python'), + ('Download and prepare the private Python runtime', '准备运行环境'), + ('Check the Python already prepared by the launcher', '检查已有环境'), ('Prepare an isolated environment for FreeVideo', '创建 FreeVideo 运行环境'), ('Download and install the GPU runtime packages', '下载并安装 GPU 运行组件'), ('Download and install video and text encoding libraries', '下载并安装生成组件'), - ('Connect the encoder to FreeVideo', '准备编码器'), ('Install the verified model code', '安装模型组件'), + ('Connect the encoder to FreeVideo', '准备提示词模型'), ('Install the verified model code', '安装模型组件'), ('Prepare model source files while runtime packages download', '下载模型组件'), - ('Verify that the text encoding library loads', '检查文本编码器'), + ('Verify that the text encoding library loads', '检查提示词模型'), ('Download and verify the video model and text encoder', '下载并校验模型'), ('Prepare compact FP8 weights for your GPU', '准备适配显卡的模型'), ('Prepare the model weights for your GPU', '准备适配显卡的模型'), ('Run small checks on your GPU', '检查 GPU 加速是否可用'), - ('Verify prepared weights and apply your storage choice', '校验模型文件'), + ('Verify prepared weights and apply your storage choice', '检查模型文件'), ('Verify dependency compatibility', '检查运行组件'), ('Record versions for future diagnostics', '保存安装信息'), ('Verify the compiler before building GPU acceleration', '检查编译工具'), - ('Compile acceleration for your GPU', '编译 GPU 加速组件'), + ('Compile acceleration for your GPU', '准备显卡加速'), ('the first build takes longer', '首次准备需要一些时间'), ('Install the prepared GPU acceleration package', '安装 GPU 加速组件'), ('Install the verified Windows acceleration package', '安装 Windows 加速组件'), - ('Verify CUDA and the Windows runtime libraries', '检查 CUDA 与 Windows 运行组件'), + ('Verify CUDA and the Windows runtime libraries', '检查运行组件'), ('Prepare verified source files', '准备组件文件'), ('Download verified source code', '下载组件文件'), ('Prepare source files for installation', '准备安装文件'), ('Download and verify required files', '下载并校验所需文件'), - ('Dependencies resolved', '依赖检查完成'), ('preparing downloads', '准备下载'), + ('Dependencies resolved', '组件检查完成'), ('preparing downloads', '准备下载'), ('Downloads ready', '下载完成'), ('Downloading packages', '下载组件'), ('installing packages', '安装组件'), ('Packages installed', '组件已安装'), ('finishing checks', '完成检查'), ('Installing packages', '安装组件'), ('Compiling CUDA kernels', '编译 GPU 加速组件'), ('Load cached blocks and upload resident weights', '加载模型'), ('Finish model placement', '完成模型加载'), - ('Verify cached weights', '校验模型缓存'), ('Read and convert source weights', '转换模型格式'), + ('Verify cached weights', '校验模型缓存'), ('Read and convert source weights', '正在准备模型'), ('Storage ready', '模型文件已就绪'), ('Download', '下载'), ('Model', '模型'), - ('Video model', '视频模型'), ('Video decoder', '视频解码器'), ('text encoder', '文本编码器'), + ('Video model', '视频模型'), ('Video decoder', '视频解码组件'), ('text encoder', '提示词模型'), + ('Text encoder', '提示词模型'), ('ModelScope', '魔搭'), ('ModelScope parallel', '魔搭'), ('HF Mirror', 'HF 镜像'), ('HF Xet', 'Hugging Face'), ('HF HTTP', 'Hugging Face'), ('Parallel HTTP', '并行下载'), ('Custom source', '自定义源'), ('Model source', '模型下载源'), @@ -128,7 +129,7 @@ ('Resume unavailable; checking another source', '正在尝试其他下载源'), ('Paused; existing data kept', '已暂停,下载进度已保留'), ('Restart explicitly allowed', '重新下载此文件'), - ('Restarting this file; incompatible fragments retained on disk', '重新下载此文件,旧片段已保留'), + ('Restarting this file; incompatible fragments retained on disk', '正在重新下载文件'), ('Cannot resume this format; enable restart in Setup', '此文件无法续传,请允许重新下载'), ('Download response too large', '下载数据异常'), ('switching source', '正在切换下载源'), ('Download memory budget reached', '正在调整下载方式'), ('switching to streaming', '继续下载'), @@ -143,17 +144,20 @@ ('Copying ', '正在复制'), ('Model files', '模型文件'), ('Packages resolving', '正在检查组件'), ('Packages installing', '正在安装组件'), ('Packages ready', '组件已就绪'), ('Environment creating', '正在创建运行环境'), ('Base packages installing', '正在安装基础组件'), - ('Python ready', 'Python 已就绪'), ('Python downloading', '正在下载 Python'), + ('Python ready', '运行环境已就绪'), ('Python downloading', '正在下载运行环境'), ('GPU acceleration building', '正在准备显卡加速'), ('Runtime libraries verifying', '正在校验运行组件'), ('Runtime libraries importing', '正在载入运行组件'), - ('Encoder configuring', '正在准备文本编码器'), ('Encoder importing', '正在载入文本编码器'), - ('Encoder ready', '文本编码器已就绪'), + ('Encoder configuring', '正在准备提示词模型'), ('Encoder importing', '正在加载提示词模型'), + ('Encoder ready', '提示词模型已就绪'), ('GPU information reading', '正在读取显卡信息'), ('GPU tests running', '正在检查显卡'), ('GPU tests finished', '显卡检查已完成'), ('Packages preparing', '正在准备组件'), ('Packages removing', '正在移除旧组件'), ('Model files verifying', '正在校验模型文件'), ('Model files preparing', '正在准备模型文件'), ('Model files assembling', '正在合并模型文件'), - ('Package files', '离线包文件'), + ('Package files', '离线包文件'), ('Connecting to download server', '正在连接下载服务器'), + ('Runtime components', '运行组件'), ('video model', '视频模型'), ('video decoder', '视频解码组件'), + ('model files', '模型文件'), ('runtime components', '运行组件'), + ('Uses models already on this computer; original files stay in place', '使用电脑上已有的模型,不移动原文件'), ]) # Card lines for steps that only report counts (the terminal keeps the counts). ZH.update([('Downloading components', '正在下载组件'), ('Preparing components', '正在准备组件'), @@ -177,7 +181,7 @@ def source_name(value, zh=False): 'Enter localhost or an IP address of this computer, for example http://127.0.0.1:8188.': '请填写 localhost 或本机的 IP 地址,例如 http://127.0.0.1:8188。', 'This address does not belong to this computer. Enter one of its IP addresses or 0.0.0.0.': '这个地址不属于本机。请填写本机的 IP 地址或 0.0.0.0。', 'The ports for LAN access are in use. Choose another ComfyUI port.': '局域网访问要用的端口已被占用,请换一个 ComfyUI 端口。', - 'A generation is running. Change the address after it finishes.': '正在生成,暂时不能修改地址。请在生成完成后再改。', + 'A generation is running. Change the address after it finishes.': '正在生成,完成后可修改地址。', 'The ComfyUI already running does not use this address. After its jobs finish, restart it with this address and connect again.': '已在运行的 ComfyUI 没有使用这个地址。请在任务完成后用这个地址重新启动 ComfyUI,再重新连接。', 'The ComfyUI already running is open to the network directly. After its jobs finish, restart it on 127.0.0.1, then turn on LAN access.': '已在运行的 ComfyUI 正在直接对局域网开放。请在任务完成后改用 127.0.0.1 重新启动它,再开启局域网访问。', 'LAN access startup cancelled': '已取消开启局域网访问。', @@ -186,20 +190,124 @@ def source_name(value, zh=False): def lan_error(value, zh=False): value = str(value) - if zh: - for english, chinese in LAN_ERRORS.items(): - value = value.replace(english, chinese) + translations = LAN_ERRORS if zh else { + 'A generation is running. Change the address after it finishes.': 'Generation is running; the address can be changed when it finishes.', + } + for english, chinese in translations.items(): + value = value.replace(english, chinese) return value +MODEL_FILE = re.compile(r'[\w.+-]+\.(?:safetensors|gguf|bin|pth?|ckpt|onnx|json|txt|model)', re.I) +# A whole path to a model file, with a folder the user chose and any characters in its names. +MODEL_PATH = re.compile(r'(?:[A-Za-z]:)?[\\/][^·]*[\\/][^\\/·]+\.(?:safetensors|gguf|bin|pth?|ckpt|onnx)', re.I) +# One downloaded package or archive: MinGit-2.55.0.5.zip, a wheel with a local version, uv.zip. +PACKAGE_FILE = re.compile(r'[\w.+-]+\.(?:zip|whl|tar\.gz|tgz|tar\.xz|7z|exe|msi)', re.I) +SHARD_FILE = re.compile(r'diffusion_pytorch_model-\d+-of-(\d+)\.safetensors') +# Components are named, never their file or package names. +FILE_GROUPS = {14: 'video model', 3: 'video decoder'} + + +def file_group(name): + """The group a model file belongs to, in lower case for use inside a sentence.""" + name = re.sub(r' · shard \d+ / \d+$', '', name) + name = name.replace('\\', '/').rsplit('/', 1)[-1] + shard = SHARD_FILE.fullmatch(name) + if shard: + return FILE_GROUPS.get(int(shard[1]), 'model files') + if MODEL_FILE.fullmatch(name): + return 'model files' + if PACKAGE_FILE.fullmatch(name): + return 'runtime components' + return re.sub(r' · shard \d+ / \d+$', '', name) + + +def names_a_file(rest): + """Whether what follows a verb is one file or group name; a sentence or a count such as "3 / 2" is not.""" + rest = re.sub(r' · shard \d+ / \d+$', '', rest) + return bool(rest) and ' · ' not in rest and ' / ' not in rest and not rest.startswith('/') + + +def without_names(value): + """Lines the launcher shows, without file or package names; known sentences and states stay as they are.""" + if value in ZH or value in SOURCES: + return value + match = re.fullmatch(r'Connecting to [^·]+(?: · .*)?', value) + if match: + return 'Connecting to download server' + for prefix in ('Verified ', 'Verify ', 'Download ', 'Assemble downloaded parts · '): + if value.startswith(prefix) and names_a_file(value[len(prefix):]): + group = file_group(value[len(prefix):]) + if group[:1].isupper() and group.split(' ')[0].lower() in ('video', 'audio', 'text', 'model'): + group = group[:1].lower() + group[1:] + return prefix.split(' · ')[0].rstrip() + ('' if prefix.startswith('Assemble') else ' ' + group) + if ' · ' in value: + return ' · '.join('Model files' if MODEL_FILE.fullmatch(part) else + 'Runtime components' if PACKAGE_FILE.fullmatch(part) else part for part in value.split(' · ')) + if MODEL_FILE.fullmatch(value) or MODEL_PATH.fullmatch(value): + return 'Model files' + if PACKAGE_FILE.fullmatch(value): + return 'Runtime components' + if re.fullmatch(r'[a-z0-9][a-z0-9_.]*[-_.][a-z0-9_.-]*', value) and re.search(r'[a-z]', value): + return 'Runtime components' + return value + + +# Counters in these English progress parts are dropped; the line says what is happening. +EN_PLAIN = {r'\d+ files / folders scanned': 'Finding files', r'shard \d+ / \d+': 'Downloading', + r'retry \d+ in \d+s': 'Retrying download'} + + def display(value, zh=False): - value = clean(value) + value = without_names(clean(value)) if not zh: + if ' · ' in value: + return ' · '.join(display(part) for part in value.split(' · ')) + for prefix, translated in ( + ('Reuse verified FP8 ', 'Use existing model files '), + ('Prepare FP8 ', 'Prepare model files '), + ): + if value.startswith(prefix): + return translated + display(value[len(prefix):]) # English names for stages whose internal names describe how they run, not what they do. - return {'检查配套包': 'Checking packages', '下载模型': 'Downloading models', + value = {'检查配套包': 'Checking packages', '下载模型': 'Downloading models', + 'Prepare Python': 'Prepare environment', + 'Install Python': 'Install environment', + 'Download PyTorch + CUDA': 'Download runtime components', + 'Use existing Python': 'Use existing environment', + 'Install PyTorch + CUDA': 'Install runtime components', + 'Check text encoder': 'Check prompt model', + 'Check build tools': 'Check installation tools', + 'Build SageAttention 2': 'Prepare GPU acceleration', + 'Install SageAttention 2': 'Install GPU acceleration', + 'Loading nodes and the web interface': 'Loading the interface', + 'Download and prepare the private Python runtime': 'Prepare environment', + 'Check the Python already prepared by the launcher': 'Check existing environment', + 'Connect the encoder to FreeVideo': 'Prepare the prompt model', + 'Verify that the text encoding library loads': 'Check prompt model', + 'Download and verify the video model and text encoder': 'Download and check models', + 'Verify prepared weights and apply your storage choice': 'Check model files', + 'Compile acceleration for your GPU': 'Prepare GPU acceleration', + 'the first build takes longer': 'the first setup takes longer', + 'Verify CUDA and the Windows runtime libraries': 'Check runtime components', + 'Dependencies resolved': 'Component checks complete', + 'Read and convert source weights': 'Preparing the model', + 'text encoder': 'prompt model', + 'Text encoder': 'Prompt model', + 'Restarting this file; incompatible fragments retained on disk': 'Downloading the file again', + 'Python ready': 'Environment ready', + 'Python downloading': 'Downloading the environment', + 'Encoder configuring': 'Preparing the prompt model', + 'Encoder importing': 'Loading the prompt model', + 'Encoder ready': 'Prompt model ready', 'Install components in parallel': 'Install components and models', 'Install with space saver': 'Install components and models (space saver)', 'Optimize model storage': 'Prepare models'}.get(value, value) + for prefix in ('Download ', 'Prepare '): + if value.startswith(prefix): + return prefix + display(value[len(prefix):]) + return ' · '.join(EN_PLAIN.get(next((p for p in EN_PLAIN if re.fullmatch(p, part)), None), part) + for part in value.split(' · ')) if value in ZH: return ZH[value] if value in SOURCES: @@ -208,23 +316,23 @@ def display(value, zh=False): return ' · '.join(display(part, True) for part in value.split(' · ')) for prefix, translated in ( MODEL_REPAIR_PROGRESS, - ('Switched current download to ', '已切换至 '), ('Connecting to ', '正在连接 '), - ('Assemble downloaded parts', '合并已下载文件'), ('Reuse verified FP8 ', '复用模型缓存 '), - ('Prepare FP8 ', '准备模型缓存 '), ('Verified ', '已校验 '), ('Verify ', '正在校验 '), - ('Download ', '下载 '), ('Prepare ', '准备 '), ('Source: ', '下载源:'), ('Log: ', '日志:'), + ('Switched current download to ', '已切换至 '), ('Connecting to ', '正在连接'), + ('Assemble downloaded parts', '合并已下载文件'), ('Reuse verified FP8 ', '使用已有模型文件 '), + ('Prepare FP8 ', '准备模型文件 '), ('Verified ', '已校验'), ('Verify ', '正在校验'), + ('Download ', '正在下载'), ('Prepare ', '准备 '), ('Source: ', '下载源:'), ('Log: ', '日志:'), ): if value.startswith(prefix): return translated + display(value[len(prefix):], True) for pattern, translated in ( (r'Downloading packages · about (.+) across (\d+) files', r'下载组件 · 约 \1,共 \2 个文件'), (r'about (.+) across (\d+) files', r'约 \1,共 \2 个文件'), - (r'(\d+) files / folders scanned', r'已扫描 \1 个文件和目录'), + (r'(\d+) files / folders scanned', '正在查找文件'), (r'Testing download sources (\d+ / \d+)', r'正在测试下载源 \1'), - (r'(\d+ / \d+) files', r'\1 个文件'), (r'shard (\d+ / \d+)', r'分片 \1'), + (r'(\d+ / \d+) files', r'\1 个文件'), (r'shard (\d+ / \d+)', '正在下载'), (r'([\d.]+ MiB) retained', r'已保留 \1'), (r'([\d.]+ GiB) released', r'已释放 \1'), (r'([\d.]+ MiB) downloaded', r'已下载 \1'), (r'Resuming ([\d.]+ MiB) retained', r'继续下载,已保留 \1'), - (r'retry (\d+) in (\d+)s', r'\2 秒后第 \1 次重试'), + (r'retry (\d+) in (\d+)s', '正在重试下载'), (r'Receiving objects:.*', '正在下载组件'), (r'Resolving deltas:.*', '正在整理组件'), (r'Updating files:.*', '正在准备文件'), ): diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index 5f1ca3cc..b9be1a8d 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -1,5 +1,6 @@ """Widget-independent state for the Qt launcher; uses the existing installer.""" from copy import deepcopy +from functools import lru_cache import json import os from pathlib import Path @@ -42,6 +43,15 @@ } + +@lru_cache(maxsize=4) +def _qr_rows(text): + from .qr_code import encode + try: + return [''.join('1' if cell else '0' for cell in row) for row in encode(text)] + except ValueError: + return [] + class Session: # A launcher started from an offline package (portable_launcher.PortableSession). bundled = False @@ -62,6 +72,7 @@ class Session: release_details = None adaln_thread = adaln_result = adaln_found = None adaln_polled = 0. + lan_synced = 0. adaln_hidden = adaln_retry = False # The source whose earlier copies were removed once ComfyUI ran it. old_versions_retired = None @@ -728,7 +739,7 @@ def image_pack_view(self): repairable = value['state'] in ('ready', 'invalid') if active.get('state') == 'running': labels = {'install': ('Downloading', '正在下载'), 'repair': ('Repairing', '正在修复'), - 'remove': ('Removing', '正在删除'), 'verify': ('Checking', '正在校验')} + 'remove': ('Removing', '正在删除'), 'verify': ('Checking', '正在检查')} detail = self.t(*labels[active['action']]) elif active.get('state') == 'paused_for_generation': detail = self.t('Paused while generating', '生成时暂停') @@ -1541,9 +1552,25 @@ def adaln_dialog_view(self, error, update): self.t('Try again', '再试一次') if self.adaln_retry else self.t('Delete', '删除')), later_text=self.t('Not now', '暂不删除')) + def sync_lan_from_store(self): + """Follow a LAN switch changed from the page's phone panel (it writes the shared settings).""" + now = time.monotonic() + if now - self.lan_synced < RECEIPT_POLL_SECONDS or not self.selected: + return + if self.controller.busy or self.importer.busy or self.cleaner.busy or self.archives_busy: + return # edit() would refuse and tell the user the app is busy; follow the store once it is idle + self.lan_synced = now + saved = self.store.read().get('installation') or {} + if (saved.get('root') != self.selected.get('root') or saved.get('engine') != self.selected.get('engine') + or type(saved.get('lan_access')) is not bool): + return + if saved['lan_access'] != (self.form.get('lan_access') is True): + self.edit('lan_access', saved['lan_access']) + def tick(self): if isinstance(self.controller, Controller) and not self.controller.busy: self.controller.poll_access_proxy() + self.sync_lan_from_store() self._finish_archives() imported = self.importer.state if not self.importer.busy and imported is not self.imported_batch: @@ -1880,12 +1907,56 @@ def licenses(self, row): terms=self.t(*value['terms']) if value['terms'] else '', url=value['link'], file=is_file(value['link'])) for value in rows or [MODEL]] + def lan_qr_rows(self): + """The first access link as rows of '0'/'1' for the settings page to draw.""" + links = self.controller.access_links + if self.controller.state.get('status') != 'open' or not isinstance(links, list) or not links: + return [] + return _qr_rows(str(links[0])) + + def lan_password_state(self): + """(supported, set) for the optional LAN password; unsupported without the access module.""" + engine = (self.controller.selection or {}).get('engine') + try: + from . import lan_password + return True, bool(engine) and bool(lan_password.is_set(engine)) + except (ImportError, OSError): + return False, False + + def lan_password_min(self): + try: + from . import lan_password + return int(getattr(lan_password, 'MIN_LENGTH', 6)) + except (ImportError, OSError, TypeError, ValueError): + return 6 + + def change_lan_password(self, text=None): + """Set or change the password (text), or remove it (None). Other devices must sign in again.""" + engine = (self.controller.selection or {}).get('engine') + if not engine: + return + from . import lan_password + if text is None: + lan_password.remove(engine) + else: + text = str(text) + least = self.lan_password_min() + if len(text) < least: + raise ValueError(self.t('Use at least %d characters.' % least, '至少 %d 个字符。' % least)) + lan_password.set_password(engine, text) + # The links change with the password; do not wait for the next poll. + self.controller.access_polled = 0 + def snapshot(self): from .failure_details import REPAIR_KINDS, launcher_failure, redacted_launcher_error, display_launcher_error from .launcher_copy import display, progress_view, source_name zh = self.language.startswith('zh') row = self.controller.state busy = self.controller.busy or self.importer.busy or self.cleaner.busy + # The sidebar card shows work being done; a plan waiting for confirmation is not work, but an + # import or a cleanup started from that page is. + working = bool(self.importer.busy or self.cleaner.busy + or (self.controller.busy and row.get('status') != 'review')) task = row.get('task', {}) port_from = int(row['moved_from']) if row.get('status') == 'open' and 'moved_from' in row else 0 port_to = (urlsplit(row.get('selection', {}).get('url') or row['url']).port or 80) if port_from else 0 @@ -2003,7 +2074,7 @@ def snapshot(self): for entry in entries: names = {'edge-models': ('Video model', '视频模型'), 'vdn-models': ('Decoder', '解码器'), 'image-models': NAMES['image'], - 'models': ('Text encoder', '文本编码器'), 'pypi': ('Python packages', 'Python 依赖'), + 'models': NAMES['text'], 'pypi': ('Python packages', 'Python 依赖'), 'github': ('Tools', '安装工具'), 'git': ('Git', 'Git'), 'cuda': ('CUDA', 'CUDA')} if group.startswith('torch-'): names[group] = ('GPU packages', 'GPU 依赖') @@ -2060,7 +2131,7 @@ def snapshot(self): download_state.update(error=self.t(*DOWNLOAD_COPY['error_read']), needs_check=True) return plain_data(dict(download_list=download_state, sampling_cache_bytes=total_bytes(), version=__version__, image_pack=image_pack, disk=disk, decimal_sizes=sys.platform == 'darwin', zh=self.language.startswith('zh'), form=dict(self.form), - page=self.page, status=row.get('status', 'idle'), busy=busy, + page=self.page, status=row.get('status', 'idle'), busy=busy, working=working, action_notice=self.t(*ACTION_COPY[self.action_notice]) if self.action_notice else '', setup_pending=bool(self.pending_action), setup_wait_text=self.t(*ACTION_COPY['setup_queued']), can_manage=self.can_manage(), busy_reason=self.t(*ACTION_COPY['busy']), @@ -2089,11 +2160,14 @@ def snapshot(self): url=(self._browser_url(row.get('url', '')) if row.get('status') == 'open' else ''), shortcut=shortcut, access_links=(list(self.controller.access_links) if row.get('status') == 'open' and isinstance(self.controller.access_links, list) else []), + lan_qr=self.lan_qr_rows(), lan_password_set=self.lan_password_state()[1], + lan_password_available=self.lan_password_state()[0], lan_password_min=self.lan_password_min(), lan_message=(self.t('LAN access has stopped. Turn it on again.', '局域网访问已停止,请重新开启。') if row.get('lan_status') == 'stopped' else self.t('No LAN address found. Connect to Wi-Fi or a wired network first.', '未找到局域网地址,请先连接 Wi-Fi 或有线网络。') if row.get('lan_status') == 'no-address' else ''), port_from=port_from, port_to=port_to, + plugin_scoped=row.get('status') == 'open' and row.get('plugin_scoped') is True, old_versions=self.old_versions_text(), copied_models=self.copied_models_text(), archives=self.archives_text(), archives_confirm=self.archives_confirm_text(), archives_done=self.archives_done_text(), archives_failed=self.archives_failed_text(), diff --git a/freevideo_engine/launcher_view.py b/freevideo_engine/launcher_view.py index fc6712c1..2309c2ba 100644 --- a/freevideo_engine/launcher_view.py +++ b/freevideo_engine/launcher_view.py @@ -294,7 +294,7 @@ def wheel(event): component_row = ttk.Frame(app.model_components, style='Card.TFrame'); component_row.pack(fill='x') app.component_buttons = {} for component, en, zh in (('video', 'Video model ↗', '视频模型 ↗'), - ('encoder', 'Text encoder ↗', '文本编码器 ↗'), ('decoder', 'Decoders ↗', '解码器 ↗')): + ('encoder', 'Prompt model ↗', '提示词模型 ↗'), ('decoder', 'Decoders ↗', '解码器 ↗')): button = app.button(component_row, en, zh, lambda name=component: app.model_download_link(name)) button.configure(style='Card.TButton'); button.pack(side='left', padx=(0, 8)) app.component_buttons[component] = button diff --git a/freevideo_engine/macos_generate.py b/freevideo_engine/macos_generate.py index f5e6a7d2..38e47e32 100644 --- a/freevideo_engine/macos_generate.py +++ b/freevideo_engine/macos_generate.py @@ -127,7 +127,7 @@ def run(args): require_native() from .preview import validate_flags, source as preview_source preview = getattr(args, 'preview', False) is True - validate_flags(preview, getattr(args, 'refine_from', None), args.two_pass) + validate_flags(preview, getattr(args, 'refine_from', None), args.two_pass, getattr(args, 'base_steps', None)) refine_source = preview_source(args.refine_from, native=True) if getattr(args, 'refine_from', None) else None output = Path(args.out).expanduser().resolve() artifacts = output.with_suffix('.artifacts') diff --git a/freevideo_engine/model_guidance.py b/freevideo_engine/model_guidance.py index 1a9b536f..de39f4e3 100644 --- a/freevideo_engine/model_guidance.py +++ b/freevideo_engine/model_guidance.py @@ -316,19 +316,19 @@ def package_instructions(runtime_needed, zh=False): """ if sys.platform == 'darwin': return ( - 'Get the four model packs (video model, text encoder, video & audio decoder, sampling caches); they also work on Mac. ' + 'Get the four model packs: video model, prompt model (FreeVideo-文本编码器.zip), video & audio decoder, and sampling caches. They also work on Mac. ' 'The installer downloads the Mac environment automatically. Do not download the Windows Environment package.', - '下载「视频模型」「文本编码器」「视频与音频解码器」「采样缓存」四个模型包,Mac 同样适用。' + '下载「视频模型」「提示词模型(FreeVideo-文本编码器.zip)」「视频与音频解码器」「采样缓存」四个模型包,Mac 同样适用。' 'Mac 运行环境由安装器自动下载,无需下载 Windows「运行环境」包。')[bool(zh)] if sys.platform.startswith('linux'): return ( - 'Get the four model packs (video model, text encoder, video & audio decoder, sampling caches); they also work on Linux. ' + 'Get the four model packs: video model, prompt model (FreeVideo-文本编码器.zip), video & audio decoder, and sampling caches. They also work on Linux. ' 'The installer downloads the Linux environment automatically. Do not download the Windows Environment package.', - '下载「视频模型」「文本编码器」「视频与音频解码器」「采样缓存」四个模型包,Linux 同样适用。' + '下载「视频模型」「提示词模型(FreeVideo-文本编码器.zip)」「视频与音频解码器」「采样缓存」四个模型包,Linux 同样适用。' 'Linux 运行环境由安装器自动下载,无需下载 Windows「运行环境」包。')[bool(zh)] - text = ('Get the four model packs: video model, text encoder, video & audio decoder, and sampling caches. ' + text = ('Get the four model packs: video model, prompt model (FreeVideo-文本编码器.zip), video & audio decoder, and sampling caches. ' 'The reference audio pack is optional, for audio references.', - '下载「视频模型」「文本编码器」「视频与音频解码器」「采样缓存」四个模型包;' + '下载「视频模型」「提示词模型(FreeVideo-文本编码器.zip)」「视频与音频解码器」「采样缓存」四个模型包;' '「音频参考缓存」可选,用音频做参考时才需要。')[bool(zh)] if runtime_needed: text += '\n' + ('For a new installation, also get the Environment package.', @@ -373,13 +373,13 @@ def show_component(app, component): import webbrowser from . import branding as b from .launcher_copy import display - titles = {'video': ('Video model', '视频模型'), 'encoder': ('Text encoder', '文本编码器'), + titles = {'video': ('Video model', '视频模型'), 'encoder': ('Prompt model', '提示词模型'), 'decoder': ('Video & audio decoders', '视频与音频解码器')} instructions = { 'video': ('Choose your GPU variant on the model page and keep its folder structure.', '按模型页说明选择显卡版本,保留下载的目录结构。'), - 'encoder': ('Download the text encoder to your model folder.', - '下载文本编码器,放入模型目录。'), + 'encoder': ('Download the prompt model to your model folder.', + '下载提示词模型,放入模型目录。'), 'decoder': ('Download the vae and audio_vae folders.', '下载 vae 和 audio_vae 文件夹。'), } diff --git a/freevideo_engine/model_repair.py b/freevideo_engine/model_repair.py index 421dad95..a78b7ecb 100644 --- a/freevideo_engine/model_repair.py +++ b/freevideo_engine/model_repair.py @@ -41,8 +41,8 @@ 'FreeVideo renames and keeps this file, then downloads the tested version.', '当前任务结束后,这里会出现“修复”按钮;点击并确认后,FreeVideo 会把这个文件改名保留,再下载测试过的版本。') OFFLINE = ('This offline-package installation cannot switch back in one step yet. Move or rename the file below, ' - 'then import the Text encoder package again on the Installation page.', - '离线包安装暂时不能一键换回。请把下面这个文件移走或改名,再在“安装”页重新导入“文本编码器”包。') + 'then import the Prompt model package (FreeVideo-文本编码器.zip) again on the Installation page.', + '离线包安装暂时不能一键换回。请把下面这个文件移走或改名,再在“安装”页重新导入“提示词模型(FreeVideo-文本编码器.zip)”包。') FIRST_INSTALL = ( '{file} differs from the version FreeVideo tests and was left unchanged. Click Retry installation below: ' 'the confirmation page will list this file, and after you confirm, it is renamed and kept, ' diff --git a/freevideo_engine/model_status.py b/freevideo_engine/model_status.py index fe3d7487..70b75a98 100644 --- a/freevideo_engine/model_status.py +++ b/freevideo_engine/model_status.py @@ -3,7 +3,7 @@ import threading FAMILIES = ('video', 'text', 'decoder', 'sampling', 'image') -NAMES = {'video': ('Video model', '视频模型'), 'text': ('Text encoder', '文本编码器'), +NAMES = {'video': ('Video model', '视频模型'), 'text': ('Prompt model', '提示词模型'), 'decoder': ('Video & audio decoder', '视频与音频解码器'), 'sampling': ('Sampling caches', '采样缓存'), 'image': ('Image generation models', '图像生成模型')} diff --git a/freevideo_engine/modern_launcher.py b/freevideo_engine/modern_launcher.py index 8fad2d28..67fc54c5 100644 --- a/freevideo_engine/modern_launcher.py +++ b/freevideo_engine/modern_launcher.py @@ -243,6 +243,14 @@ def importPackages(self, urls): def removeFolder(self, index): self.invoke(session.remove_folder, index) + @Slot(str) + def setLanPassword(self, value): + self.invoke(session.change_lan_password, value) + + @Slot() + def removeLanPassword(self): + self.invoke(session.change_lan_password, None) + @Slot(str) def copy(self, value): app.clipboard().setText(value) diff --git a/freevideo_engine/offline_packages.py b/freevideo_engine/offline_packages.py index 62810c83..635bc40e 100644 --- a/freevideo_engine/offline_packages.py +++ b/freevideo_engine/offline_packages.py @@ -71,7 +71,7 @@ def pack_name(path): if 'sampling-cache' in parts: return '采样缓存' if path.startswith('models/encoder/') or 'latent_upscaler' in parts: - return '文本编码器' + return '提示词模型(FreeVideo-文本编码器.zip)' return '视频与音频解码器' if any(p in ('vae', 'audio_vae', 'vocoder') for p in parts) else '视频模型' diff --git a/freevideo_engine/preview.py b/freevideo_engine/preview.py index c1908a26..028405c2 100644 --- a/freevideo_engine/preview.py +++ b/freevideo_engine/preview.py @@ -14,11 +14,14 @@ MAC_FILES = ('first-pass.pt', 'first-pass.json', 'engine-request.json', 'conditioning.pt') -def validate_flags(preview, refine_from, two_pass): +def validate_flags(preview, refine_from, two_pass, base_steps=None): if preview and refine_from: raise ValueError('Choose either a preview or an upscale of one, not both') if (preview or refine_from) and not two_pass: raise ValueError('Previews and their upscale use two-pass generation') + if preview and base_steps not in (None, 8): + # Two-pass runs only with 8 first-pass steps (two_pass.TWO_PASS_BASE_STEPS). + raise ValueError('A preview uses two-pass generation, which runs only with 8 first-pass steps') def source(output, native=False): diff --git a/freevideo_engine/prompt_vlm/rules.py b/freevideo_engine/prompt_vlm/rules.py index e1bfa77a..e0efbf3a 100644 --- a/freevideo_engine/prompt_vlm/rules.py +++ b/freevideo_engine/prompt_vlm/rules.py @@ -422,7 +422,9 @@ def validate_answers(answers, value): # The small local model only sorts the events: spoken lines become placeholders it places, and the shared # description, the sound sections and the words themselves are put back by code, so they cannot drift. SPLIT_MAX_PARTS = 8 -CONTINUE = 'Continuing directly from the previous moment, with no pause: everything already in progress keeps going.' +# Asking for no pause, or for everything in progress to keep going, makes the model keep talking past the +# seam with invented words; the plain sentence keeps the picture going without that. +CONTINUE = 'Continuing from the previous moment.' SPEAKER = re.compile(r'(?:\(S\d+(?:,S\d+)*\)\s*)?(\[[A-Za-z]+\])?\s*([^<\n]{1,400}?)\s*') @@ -489,13 +491,15 @@ def split_instruction(value): previous one ended. Sort the user's description into what stays the same and what happens in each part: {chr(10).join(rows)} Shared: who is in the scene and how they look, the place, the lighting, the camera, the style and the -background sound. Copy these words from the description. Leave out every action, event, change and every [L] marker. +background sound. Copy these words from the description. Leave out every action, event, change, every [L] +marker and all talking: how someone speaks belongs next to their marker. Part N: only what happens during that part, in the order of the description, so each action happens once. Part 1 {opening}; each later part picks up where the previous part ended. When the story is -shorter than the video, the later parts continue the same activity calmly instead of repeating it. +shorter than the video, the later parts go on with small, calm, silent actions instead of repeating it. The markers [L1], [L2], ... stand for spoken lines; their speaking time is given. Put each marker in exactly one part, in order, where it is said, as "he says [L2]" or "她说 [L2]"; spread them so each -part's speech fits its seconds. Do not write any other speech. +part's speech fits its seconds. Do not write any other speech: nobody talks, keeps talking or goes +on speaking except at a marker. Example for a 2-part video. Description: A girl in a yellow raincoat stands on a wooden pier at dawn, filmed in a wide shot, gulls crying. She waves and shouts [L1], runs to the end of the pier and jumps into the sea. Speaking time: [L1] 2 s diff --git a/freevideo_engine/qr_code.py b/freevideo_engine/qr_code.py new file mode 100644 index 00000000..89150697 --- /dev/null +++ b/freevideo_engine/qr_code.py @@ -0,0 +1,264 @@ +"""Small QR Code encoder for the LAN access address (byte mode, versions 1-10, error correction level M). + +The launcher and `freevideo status` draw the result themselves, so there is no image library involved. +""" + +MAX_VERSION = 10 +# Per version (index 1..10): error correction codewords per block, and number of blocks, at level M. +_ECC_PER_BLOCK = (0, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26) +_BLOCKS = (0, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5) +_FORMAT_M = 0 # the two format bits for level M are 00 + + +def _alignment_positions(version): + if version == 1: + return [] + count = version // 7 + 2 + step = 26 if version == 32 else (version * 4 + count * 2 + 1) // (count * 2 - 2) * 2 + last = version * 4 + 10 + return [6] + [last - step * k for k in range(count - 2, -1, -1)] + + +def _raw_modules(version): + result = (16 * version + 128) * version + 64 + if version >= 2: + count = version // 7 + 2 + result -= (25 * count - 10) * count - 55 + if version >= 7: + result -= 36 + return result + + +def _data_codewords(version): + return _raw_modules(version) // 8 - _ECC_PER_BLOCK[version] * _BLOCKS[version] + + +def _gf_multiply(x, y): + result = 0 + for bit in range(7, -1, -1): + result = (result << 1) ^ ((result >> 7) * 0x11D) + result ^= ((y >> bit) & 1) * x + return result + + +def _generator(degree): + result = [0] * (degree - 1) + [1] + root = 1 + for _ in range(degree): + for index in range(degree): + result[index] = _gf_multiply(result[index], root) + if index + 1 < degree: + result[index] ^= result[index + 1] + root = _gf_multiply(root, 2) + return result + + +def _remainder(data, generator): + result = [0] * len(generator) + for byte in data: + factor = byte ^ result.pop(0) + result.append(0) + for index, coefficient in enumerate(generator): + result[index] ^= _gf_multiply(coefficient, factor) + return result + + +def _codewords(data, version): + blocks = _BLOCKS[version] + ecc_length = _ECC_PER_BLOCK[version] + raw = _raw_modules(version) // 8 + short_blocks = blocks - raw % blocks + short_length = raw // blocks + generator = _generator(ecc_length) + split, position = [], 0 + for index in range(blocks): + length = short_length - ecc_length + (0 if index < short_blocks else 1) + block = data[position:position + length] + position += length + split.append(block + _remainder(block, generator)) + if index < short_blocks: + split[-1].insert(length, None) # keeps columns aligned with the longer blocks + result = [] + for column in range(len(split[-1])): + for index, block in enumerate(split): + if column != short_length - ecc_length or index >= short_blocks: + result.append(block[column]) + return result + + +class _Matrix: + def __init__(self, version): + self.size = version * 4 + 17 + self.modules = [[False] * self.size for _ in range(self.size)] + self.function = [[False] * self.size for _ in range(self.size)] + + def set(self, x, y, dark, function=True): + self.modules[y][x] = bool(dark) + if function: + self.function[y][x] = True + + +def _draw_function_patterns(matrix, version): + size = matrix.size + for index in range(size): + matrix.set(6, index, index % 2 == 0) + matrix.set(index, 6, index % 2 == 0) + + def finder(cx, cy): + for dy in range(-4, 5): + for dx in range(-4, 5): + x, y = cx + dx, cy + dy + if 0 <= x < size and 0 <= y < size: + distance = max(abs(dx), abs(dy)) + matrix.set(x, y, distance not in (2, 4)) + + finder(3, 3); finder(size - 4, 3); finder(3, size - 4) + positions = _alignment_positions(version) + for i, cx in enumerate(positions): + for j, cy in enumerate(positions): + if (i == 0 and j == 0) or (i == 0 and j == len(positions) - 1) or (i == len(positions) - 1 and j == 0): + continue + for dy in range(-2, 3): + for dx in range(-2, 3): + matrix.set(cx + dx, cy + dy, max(abs(dx), abs(dy)) != 1) + _draw_format(matrix, 0) # reserves the format areas; the real bits are written after masking + if version >= 7: + remainder = version + for _ in range(12): + remainder = (remainder << 1) ^ ((remainder >> 11) * 0x1F25) + bits = version << 12 | remainder + for index in range(18): + dark = (bits >> index) & 1 + a, b = size - 11 + index % 3, index // 3 + matrix.set(a, b, dark); matrix.set(b, a, dark) + + +def _draw_format(matrix, mask): + size = matrix.size + data = _FORMAT_M << 3 | mask + remainder = data + for _ in range(10): + remainder = (remainder << 1) ^ ((remainder >> 9) * 0x537) + bits = (data << 10 | remainder) ^ 0x5412 + + def bit(index): + return (bits >> index) & 1 + + for index in range(0, 6): + matrix.set(8, index, bit(index)) + matrix.set(8, 7, bit(6)); matrix.set(8, 8, bit(7)); matrix.set(7, 8, bit(8)) + for index in range(9, 15): + matrix.set(14 - index, 8, bit(index)) + for index in range(0, 8): + matrix.set(size - 1 - index, 8, bit(index)) + for index in range(8, 15): + matrix.set(8, size - 15 + index, bit(index)) + matrix.set(8, size - 8, True) + + +def _draw_codewords(matrix, codewords): + size = matrix.size + bits = [(byte >> shift) & 1 for byte in codewords for shift in range(7, -1, -1)] + index = 0 + for right in range(size - 1, 0, -2): + if right <= 6: + right -= 1 + for vertical in range(size): + for offset in range(2): + x = right - offset + upward = ((right + 1) & 2) == 0 + y = size - 1 - vertical if upward else vertical + if not matrix.function[y][x] and index < len(bits): + matrix.modules[y][x] = bool(bits[index]); index += 1 + + +_MASKS = ( + lambda x, y: (x + y) % 2 == 0, + lambda x, y: y % 2 == 0, + lambda x, y: x % 3 == 0, + lambda x, y: (x + y) % 3 == 0, + lambda x, y: (x // 3 + y // 2) % 2 == 0, + lambda x, y: x * y % 2 + x * y % 3 == 0, + lambda x, y: (x * y % 2 + x * y % 3) % 2 == 0, + lambda x, y: ((x + y) % 2 + x * y % 3) % 2 == 0, +) + + +def _apply_mask(matrix, mask): + for y in range(matrix.size): + for x in range(matrix.size): + if not matrix.function[y][x] and _MASKS[mask](x, y): + matrix.modules[y][x] = not matrix.modules[y][x] + + +def _penalty(modules): + size = len(modules) + score = 0 + for lines in (modules, [list(column) for column in zip(*modules)]): + for line in lines: + run = 1 + for index in range(1, size): + if line[index] == line[index - 1]: + run += 1 + continue + score += run - 2 if run >= 5 else 0 + run = 1 + score += run - 2 if run >= 5 else 0 + text = ''.join('1' if value else '0' for value in line) + for pattern in ('10111010000', '00001011101'): + start = text.find(pattern) + while start != -1: + score += 40 + start = text.find(pattern, start + 1) + for y in range(size - 1): + for x in range(size - 1): + if modules[y][x] == modules[y][x + 1] == modules[y + 1][x] == modules[y + 1][x + 1]: + score += 3 + dark = sum(row.count(True) for row in modules) + total = size * size + score += ((abs(dark * 20 - total * 10) + total - 1) // total - 1) * 10 + return score + + +def encode(text): + """Return the QR code for `text` as rows of booleans (True is a dark module), without the quiet zone.""" + payload = text.encode('utf-8') + version = next((v for v in range(1, MAX_VERSION + 1) if len(payload) <= _data_codewords(v) - (2 if v < 10 else 3)), None) + if version is None: + raise ValueError('The text is too long for a QR code.') + count_bits = 8 if version < 10 else 16 + bits = '0100' + format(len(payload), '0%db' % count_bits) + ''.join(format(byte, '08b') for byte in payload) + capacity = _data_codewords(version) * 8 + bits += '0' * min(4, capacity - len(bits)) + bits += '0' * (-len(bits) % 8) + data = [int(bits[index:index + 8], 2) for index in range(0, len(bits), 8)] + pad = 0xEC + while len(data) < capacity // 8: + data.append(pad) + pad ^= 0xEC ^ 0x11 + codewords = _codewords(data, version) + best = None + for mask in range(8): + matrix = _Matrix(version) + _draw_function_patterns(matrix, version) + _draw_codewords(matrix, codewords) + _apply_mask(matrix, mask) + _draw_format(matrix, mask) + score = _penalty(matrix.modules) + if best is None or score < best[0]: + best = (score, matrix.modules) + return [list(row) for row in best[1]] + + +def terminal_lines(text, quiet=2): + """The code as lines of half-block characters, two module rows per line, dark modules drawn dark on light.""" + modules = encode(text) + size = len(modules) + quiet * 2 + grid = [[False] * size for _ in range(size)] + for y, row in enumerate(modules): + for x, value in enumerate(row): + grid[y + quiet][x + quiet] = value + if size % 2: + grid.append([False] * size) + glyph = {(False, False): '█', (True, True): ' ', (False, True): '▀', (True, False): '▄'} + return [''.join(glyph[(grid[y][x], grid[y + 1][x])] for x in range(size)) for y in range(0, len(grid), 2)] diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 52ecdb3f..362406ea 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,37 +1,68 @@ { "schema": 1, - "product_version": "0.4.6", + "product_version": "0.4.7", "en": { - "summary": "Long videos keep the same voices and people from part to part and no longer pause where parts join, and a new character library lets you create characters, generate their pictures and bring them into a prompt with @. A small preview appears while a video is generated, a waiting video can be moved to the front of the queue, and image generation, the editing view and the launcher get many fixes.", + "summary": "Your creations can now be selected and deleted several at a time with a short undo window, every view gets a Feedback button, and the launcher shows a QR code for opening FreeVideo from a phone. The description box in the editing view grows with its text, wording across the app is shorter and plainer, and long videos keep a steadier loudness from part to part.", "changes": [ - "Long videos: voices keep the same sound and loudness from part to part, the pause and the return to the same pose where parts with dialogue join are gone, and continued parts also receive the characters' pictures. A long video made in one request gives each part its own share of the description, and each part's description can be read and edited; if the description cannot be split, every part uses the whole description and the reason is shown. Agent can also write one description per part.", - "Characters: the material section has a character row where you add, edit and delete characters, each with a front picture and up to five more views. The AI tab of the character dialog generates the front, three-quarter, side, back and face pictures one by one, with progress, stop and retry. Type @ in the prompt to bring a character's pictures in as references. In Agent shots, continuing parts carry the characters' pictures too: two when one person is in the shot, one each when there are more.", - "Continue from video: first and last frame images are used as reference pictures, and settings that cannot continue a video are adjusted automatically with a short note instead of stopping with an error. In the editing view, a part that continues the one before keeps your reference pictures, so people stay the same; first and last frames apply only to a part that starts fresh.", - "Generation: from the third sampling step, a small picture of the frames being made appears in the progress block, in both passes. The creation panel and the bottom bar show the same estimated time, and once a run goes past it both say it is taking longer than expected. The main button stays on Add to queue while a video is running. Above Generate, the creation panel lists what the current settings will still download and which LoRA files cannot be found.", - "Queue: from the second waiting video on, each row has a Move to front button. The row is greyed until the server confirms; the running video and the parts of a continuous chain are never moved. The waiting list is shown in queue order and stays inside narrow windows.", - "Image generation: when graphics memory runs out, the picture is retried one size smaller until it fits, and the size made is shown. After the image pack is moved or copied it is checked again instead of being reported as missing and downloaded again. A page refresh resumes the running image task at its exact canvas size, and a request that arrives while the idle image worker is closing runs on a fresh worker instead of failing.", - "Editing view: a subtitle look can be saved as a style and applied to every subtitle in one press; a range drawn on the timeline starts exactly where it is pressed; a queued part that is cancelled, or left unstarted when a run fails, shows as stopped, and after a cancel is accepted the bar reads Stopping; a failed save before export names the reason, and choosing media without sound for Replace audio says so.", - "Install and launcher: Settings show right away what an installation check or install is doing, and the launcher shows counts and speed while it finds, checks and copies existing models. The one-time background kernel compile after an install or update starts about 5 seconds after ComfyUI opens instead of 30. Reopening the creation panel from the launcher keeps the previous project's media and connections. On macOS, the update reminder says to download the new version and replace the current one, and its button opens the download page.", - "AI autopilot: every button responds at once and retries before giving up, and a message sent while the agent is not listening goes out when it listens again. Failed tasks, pictures, short dramas and character cards name the step that failed and offer the same report download and submit dialog as video. The character library keeps every picture of cards without a front picture, tells characters that share a name apart, and stays hidden while the Agent interface is off.", - "Optional inputs left empty or written as \"None\" or \"null\" by older workflows or scripts are no longer treated as real values, so an ordinary generation is no longer sent down the second-pass route." + "Your creations: select several creations (click, Shift-click, a whole day, or all) and delete them in one step, with the size shown before you confirm and ten seconds to undo. Creations that are still in use are kept.", + "Feedback: every view has a Feedback button beside Settings. Write one sentence, optionally attach a screenshot of the page and a diagnostic summary (no prompts or images), and the GitHub suggestion page opens with the text filled in.", + "Launcher: local network access shows a QR code to open FreeVideo from a phone and offers an optional password for other devices; freevideo status prints the same QR code on Linux.", + "Your own ComfyUI: the FreeVideo toolbar appears only on FreeVideo workflows, no longer opens over your other workflows or adds nodes to them, and FreeVideo can be opened from the Extensions menu. Opening ComfyUI from the launcher keeps your other plugins; if a plugin stops FreeVideo from loading, the launcher starts again with FreeVideo only and says so.", + "Long videos: steadier loudness from part to part.", + "Editing view: the description box grows with its text and can be expanded into a larger window; errors and shortcut labels use plainer wording.", + "Studio: shorter hints. Generate no longer fails with \"missing node\" when the creation node was muted or bypassed in the workflow; the node is turned back on and a line says so. Two-pass sampling runs only with 8 first-pass steps; other step counts use a single pass, and the progress line, the ComfyUI node and Agent results say so.", + "Installation and launcher: install progress is steadier, with the total following the GPU acceleration build and the final start-up; sublines no longer show file names, and the terminal stays closed unless something goes wrong. Installation and update wording is plainer.", + "Progress and error messages: shorter, plainer wording." ] }, "zh": { - "summary": "长视频各段的人声和人物保持一致,段与段相接处不再停顿;新增角色库,可以新建角色、用 AI 生成角色图片,并在描述里用 @ 引用。生成视频时进度块里会显示正在生成的画面小预览,等待中的视频可以排到最前;图像生成、剪辑视图和启动器也修复了多处问题。", + "summary": "作品库可以多选、一次删除,并在短时间内撤销;每个视图新增“反馈”按钮;启动器显示二维码,手机扫码即可打开。剪辑视图的描述框随内容长高,各处说法更短、更直白,长视频各段的声音响度也更稳定。", "changes": [ - "长视频:人声在各段之间保持同样的音色和音量;有对话的段相接处不再停顿,人物也不再每段回到同一个姿势;接续的段也会带上人物参考图。一次请求生成的长视频,每段只用描述里属于自己的部分,每段的描述可以查看和修改;描述无法拆分时,各段都用完整描述,并说明原因。Agent 也可以为每段分别写描述。", - "角色:素材区新增“角色”一行,可以新建、编辑和删除角色,每个角色有一张正面图和最多五张其他角度图。角色对话框的“AI 生成”会逐张生成正面、四分之三、侧面、背面和脸部五张图,可看进度、停止和重试。在描述里输入 @ 可以引用角色,它的图片自动加入参考素材。Agent 拍片时,接续的镜头也带上人物图片:只有一个人时带两张,多人时每人一张。", - "视频接续:首帧和尾帧图会用作参考图;不能用于接续的设置会自动调整并附一句说明,不再直接报错停止。剪辑视图里接着上一段生成的段会保留参考图,人物保持一致;首尾帧只用于重新开始的段。", - "生成:从采样第 3 步起,进度块里会显示一张正在生成的画面小预览,两遍采样都有。创作面板和底栏显示同一个预计时间,超过预计后两处都改为“比预计久”。视频生成中主按钮保持“加入队列”。“生成”按钮上方会列出当前设置还需要下载的内容,以及找不到的 LoRA 文件。", - "队列:从第 2 个等待中的视频起,每行有“排到最前”按钮。点击后这一行置灰,服务端确认后按新顺序显示;正在生成的视频和连续生成的各段不会被移动。等待列表按队列顺序显示,在窄窗口里也不会被截掉。", - "图像生成:显存不足时自动缩小一档重试,直到能出图,并显示实际尺寸。图像包被移动或复制后会先重新校验,不再误报缺失、要求重新下载。刷新页面后会按原画布尺寸接回正在进行的图像任务;空闲的图像进程正在关闭时发来的请求会在新进程上运行,不再失败。", - "剪辑视图:字幕样式可以存为预设,一键套用到所有字幕;在时间线上画范围,起点就是按下的位置;取消排队中的段,或整条失败时还没开始的段,会显示为已停止,取消生效后底栏显示“正在停止”;导出前保存失败会说明原因;替换声音时选了没有声音的素材会提示。", - "安装和启动器:设置里检查安装、开始安装时马上显示正在做什么;启动器复用已有模型时,显示查找、校验和复制的项数与速度。安装或更新后的一次性后台内核编译在 ComfyUI 打开约 5 秒后开始(原来 30 秒)。从启动器重新打开创作面板时,上次工程的素材和连线会保留。Mac 上的更新提醒写明“下载新版后替换当前版本”,按钮会打开下载页。", - "AI 全自动:每个按钮点了都会马上响应,失败会先重试;Agent 没在接收时发出的消息,会在它恢复后发出。任务、图片、短剧和角色卡失败时写明是哪一步出的错,并和视频一样可以下载和提交诊断报告。角色库里没有正面图的角色卡会保留全部图片,同名角色可以区分,Agent 界面关闭时角色库也隐藏。", - "旧工作流或脚本把可选输入留空、写成 \"None\" 或 \"null\" 时,不再被当成真实值,普通生成也不会再被误当作第二遍处理。" + "作品库:可多选作品(点选、Shift 连选、选择某一天或全选),确认前显示大小,一次删除,10 秒内可撤销;仍在使用的作品会保留。", + "反馈:每个视图的“设置”左边新增“反馈”按钮。写一句话,可选附上这一页的截图和诊断信息(不含提示词和画面),点一下即打开已填好文字的 GitHub 反馈页。", + "启动器:局域网访问显示二维码,手机扫码即可打开 FreeVideo,并可为其他设备设置密码;Linux 的 freevideo status 也会显示二维码。", + "自己的 ComfyUI:工具条只在 FreeVideo 工作流上出现,不再盖住其他工作流,也不往里面加节点;Extensions 菜单里可以打开 FreeVideo。从启动器打开时保留已装的其他插件;如有插件让 FreeVideo 载入失败,会自动只载入 FreeVideo 再启动一次,并提示一句。", + "长视频:各段声音响度更稳定。", + "剪辑视图:描述框随内容长高,可放大成大窗口编辑;出错和快捷键的说法更直白。", + "创作面板:提示更简短。创作节点在工作流里被静音或绕过时,点生成不再报“节点缺失”,会自动重新打开并提示一句。只有首轮为 8 步时才会使用二次采样,其他步数只生成一轮,进度、ComfyUI 节点和 Agent 接口的结果会说明。", + "安装和启动器:安装进度更平稳,总进度会跟随显卡加速编译和最后的启动步骤,副行不再显示文件名,终端只在出错时需要打开;安装和更新的说法更直白。", + "进度与出错提示:说法更短、更直白。" ] }, "history": [ + { + "product_version": "0.4.6", + "en": { + "summary": "Long videos keep the same voices and people from part to part and no longer pause where parts join, and a new character library lets you create characters, generate their pictures and bring them into a prompt with @. A small preview appears while a video is generated, a waiting video can be moved to the front of the queue, and image generation, the editing view and the launcher get many fixes.", + "changes": [ + "Long videos: voices keep the same sound and loudness from part to part, the pause and the return to the same pose where parts with dialogue join are gone, and continued parts also receive the characters' pictures. A long video made in one request gives each part its own share of the description, and each part's description can be read and edited; if the description cannot be split, every part uses the whole description and the reason is shown. Agent can also write one description per part.", + "Characters: the material section has a character row where you add, edit and delete characters, each with a front picture and up to five more views. The AI tab of the character dialog generates the front, three-quarter, side, back and face pictures one by one, with progress, stop and retry. Type @ in the prompt to bring a character's pictures in as references. In Agent shots, continuing parts carry the characters' pictures too: two when one person is in the shot, one each when there are more.", + "Continue from video: first and last frame images are used as reference pictures, and settings that cannot continue a video are adjusted automatically with a short note instead of stopping with an error. In the editing view, a part that continues the one before keeps your reference pictures, so people stay the same; first and last frames apply only to a part that starts fresh.", + "Generation: from the third sampling step, a small picture of the frames being made appears in the progress block, in both passes. The creation panel and the bottom bar show the same estimated time, and once a run goes past it both say it is taking longer than expected. The main button stays on Add to queue while a video is running. Above Generate, the creation panel lists what the current settings will still download and which LoRA files cannot be found.", + "Queue: from the second waiting video on, each row has a Move to front button. The row is greyed until the server confirms; the running video and the parts of a continuous chain are never moved. The waiting list is shown in queue order and stays inside narrow windows.", + "Image generation: when graphics memory runs out, the picture is retried one size smaller until it fits, and the size made is shown. After the image pack is moved or copied it is checked again instead of being reported as missing and downloaded again. A page refresh resumes the running image task at its exact canvas size, and a request that arrives while the idle image worker is closing runs on a fresh worker instead of failing.", + "Editing view: a subtitle look can be saved as a style and applied to every subtitle in one press; a range drawn on the timeline starts exactly where it is pressed; a queued part that is cancelled, or left unstarted when a run fails, shows as stopped, and after a cancel is accepted the bar reads Stopping; a failed save before export names the reason, and choosing media without sound for Replace audio says so.", + "Install and launcher: Settings show right away what an installation check or install is doing, and the launcher shows counts and speed while it finds, checks and copies existing models. The one-time background kernel compile after an install or update starts about 5 seconds after ComfyUI opens instead of 30. Reopening the creation panel from the launcher keeps the previous project's media and connections. On macOS, the update reminder says to download the new version and replace the current one, and its button opens the download page.", + "AI autopilot: every button responds at once and retries before giving up, and a message sent while the agent is not listening goes out when it listens again. Failed tasks, pictures, short dramas and character cards name the step that failed and offer the same report download and submit dialog as video. The character library keeps every picture of cards without a front picture, tells characters that share a name apart, and stays hidden while the Agent interface is off.", + "Optional inputs left empty or written as \"None\" or \"null\" by older workflows or scripts are no longer treated as real values, so an ordinary generation is no longer sent down the second-pass route." + ] + }, + "zh": { + "summary": "长视频各段的人声和人物保持一致,段与段相接处不再停顿;新增角色库,可以新建角色、用 AI 生成角色图片,并在描述里用 @ 引用。生成视频时进度块里会显示正在生成的画面小预览,等待中的视频可以排到最前;图像生成、剪辑视图和启动器也修复了多处问题。", + "changes": [ + "长视频:人声在各段之间保持同样的音色和音量;有对话的段相接处不再停顿,人物也不再每段回到同一个姿势;接续的段也会带上人物参考图。一次请求生成的长视频,每段只用描述里属于自己的部分,每段的描述可以查看和修改;描述无法拆分时,各段都用完整描述,并说明原因。Agent 也可以为每段分别写描述。", + "角色:素材区新增“角色”一行,可以新建、编辑和删除角色,每个角色有一张正面图和最多五张其他角度图。角色对话框的“AI 生成”会逐张生成正面、四分之三、侧面、背面和脸部五张图,可看进度、停止和重试。在描述里输入 @ 可以引用角色,它的图片自动加入参考素材。Agent 拍片时,接续的镜头也带上人物图片:只有一个人时带两张,多人时每人一张。", + "视频接续:首帧和尾帧图会用作参考图;不能用于接续的设置会自动调整并附一句说明,不再直接报错停止。剪辑视图里接着上一段生成的段会保留参考图,人物保持一致;首尾帧只用于重新开始的段。", + "生成:从采样第 3 步起,进度块里会显示一张正在生成的画面小预览,两遍采样都有。创作面板和底栏显示同一个预计时间,超过预计后两处都改为“比预计久”。视频生成中主按钮保持“加入队列”。“生成”按钮上方会列出当前设置还需要下载的内容,以及找不到的 LoRA 文件。", + "队列:从第 2 个等待中的视频起,每行有“排到最前”按钮。点击后这一行置灰,服务端确认后按新顺序显示;正在生成的视频和连续生成的各段不会被移动。等待列表按队列顺序显示,在窄窗口里也不会被截掉。", + "图像生成:显存不足时自动缩小一档重试,直到能出图,并显示实际尺寸。图像包被移动或复制后会先重新校验,不再误报缺失、要求重新下载。刷新页面后会按原画布尺寸接回正在进行的图像任务;空闲的图像进程正在关闭时发来的请求会在新进程上运行,不再失败。", + "剪辑视图:字幕样式可以存为预设,一键套用到所有字幕;在时间线上画范围,起点就是按下的位置;取消排队中的段,或整条失败时还没开始的段,会显示为已停止,取消生效后底栏显示“正在停止”;导出前保存失败会说明原因;替换声音时选了没有声音的素材会提示。", + "安装和启动器:设置里检查安装、开始安装时马上显示正在做什么;启动器复用已有模型时,显示查找、校验和复制的项数与速度。安装或更新后的一次性后台内核编译在 ComfyUI 打开约 5 秒后开始(原来 30 秒)。从启动器重新打开创作面板时,上次工程的素材和连线会保留。Mac 上的更新提醒写明“下载新版后替换当前版本”,按钮会打开下载页。", + "AI 全自动:每个按钮点了都会马上响应,失败会先重试;Agent 没在接收时发出的消息,会在它恢复后发出。任务、图片、短剧和角色卡失败时写明是哪一步出的错,并和视频一样可以下载和提交诊断报告。角色库里没有正面图的角色卡会保留全部图片,同名角色可以区分,Agent 界面关闭时角色库也隐藏。", + "旧工作流或脚本把可选输入留空、写成 \"None\" 或 \"null\" 时,不再被当成真实值,普通生成也不会再被误当作第二遍处理。" + ] + } + }, { "product_version": "0.4.5", "en": { @@ -196,70 +227,6 @@ "生成期间其他程序占用了较多显卡,或显卡过热时,结果下方会出现一句简短的提示。" ] } - }, - { - "product_version": "0.4.0", - "en": { - "summary": "Adds an editing view, long videos generated one part after another, and AI agent connections, and makes generation faster.", - "changes": [ - "New editing view: arrange generated clips on a timeline, add text and subtitles, change speed or reverse a clip, and choose the size and quality when exporting.", - "Long videos can be generated one part after another: select a range on the timeline in the editing view and press Generate, and each part continues from the one before. A 30-second video is generated in two parts.", - "Clips can be extended in the editing view: drag a new part out of the end of a clip and press Generate, and it continues from the clip. A \"First-frame reference\" switch reduces drift when a clip is extended.", - "The editing view can create subtitles automatically: it recognizes Chinese, English, Japanese, Korean and Cantonese speech and gives each line its text and timing.", - "A step-by-step guide appears the first time FreeVideo opens, and can be viewed again from Settings.", - "Connect an AI agent and let it generate, edit and export videos.", - "A new \"LAN access\" switch in Settings lets phones and other computers on the same network open FreeVideo with a link.", - "After an offline package is imported, FreeVideo offers to delete the ZIP files that are no longer needed, freeing up disk space.", - "LoRAs can be added directly in the creation panel.", - "The FreeVideo · Media node accepts several reference images.", - "Faster generation: the wait before decoding is shorter; when generating several videos in a row, the models load faster for the later ones. On computers with less memory, background compiling after an update uses less memory.", - "Fixes: generating with several large reference images no longer runs out of memory; 16:9 videos can be saved as share videos; making the full video from a preview no longer stalls on a single step now and then; when setup cannot find a graphics driver, the error says so; setup shows the current step; when security software removes one of FreeVideo's files, the error says why and how to restore it; on a slow network, setup waits longer and tries once more when no download source responds; on English Windows, FreeVideo starts when the install folder or the user name contains Chinese characters." - ] - }, - "zh": { - "summary": "新增剪辑视图、一段接一段的长视频生成,并可以连接 AI Agent;生成也更快。", - "changes": [ - "新增剪辑视图:把生成的片段排在时间线上剪辑,可以加文字和字幕、变速和倒放;导出时可以选画面大小和画质。", - "长视频可以一段接一段生成:在剪辑视图的时间线上选好范围再点生成,每一段都接着前一段往下生成;30 秒的视频分两段生成。", - "剪辑视图里可以延长片段:从片段尾巴拖出一段再点生成,接在原片段后面往下生成;延长片段时可以打开“首帧参考”,减小画面漂移。", - "剪辑视图可以自动生成字幕:识别中文、英文、日文、韩文和粤语语音,得到每句的文字和时间。", - "第一次打开时有分步指引,设置里可以再看一遍。", - "可以连接 AI Agent,让它生成、剪辑和导出视频。", - "设置里新增“局域网访问”开关,打开后,同一网络里的手机和其他电脑可以用链接打开 FreeVideo。", - "导入离线包以后,可以删掉用不到的压缩包,释放磁盘空间。", - "创作面板里可以直接添加 LoRA。", - "FreeVideo · Media 节点可以接多张参考图。", - "生成更快:解码前的等待更短;连续生成时,后面的视频加载模型更快。内存较小的电脑上,更新后的后台编译占用的内存更少。", - "修复:用多张较大的参考图生成时不再内存不足;16:9 的视频可以保存分享视频;先出预览再生成正式视频时,不再偶尔在某一步停留很久;安装时没有找到显卡驱动,会直接说明;安装时会显示当前步骤;FreeVideo 的文件被安全软件移走时,会说明原因和恢复的办法;网络较慢、下载源都连不上时,安装会多等一会儿再试一轮;英文 Windows 上,安装文件夹或用户名带中文时也能正常启动。" - ] - } - }, - { - "product_version": "0.3.25", - "en": { - "summary": "Offline packages now work on Windows PCs where long-path support is off, and a generation interrupted once no longer slows down later ones.", - "changes": [ - "On Windows PCs where long-path support is off, which is the Windows default, offline packages now import, start and generate normally with no change to system settings, including in deeply nested folders with very long paths. A folder left half imported by the previous version needs no cleanup: import the package again after updating.", - "The Total pixels list in the creation panel no longer offers 1.00 MP, which gave the same size as 0.98 MP · Native. Workflows saved at 1.00 MP open at their original size.", - "On Windows PCs whose RAM cannot hold the whole video model, the part of the model that comes from the drive is now read ahead while the rest is computing, so a slow drive holds up each sampling step much less.", - "On Windows, the first video at the Max quality level, or at some custom step counts, no longer downloads about 26 GB of extra model files.", - "A generation interrupted once no longer slows down later generations. The compatibility level is raised automatically only after two interruptions in a row or a graphics card error, and goes back down after three generations complete normally.", - "Closing FreeVideo during a generation now cancels it instead of counting as an interruption. While the compatibility level is raised automatically, the progress says so and shows how many more completed generations are needed before it speeds up.", - "The creation panel has a new Available sizes and lengths row under Frame & duration. It opens a map of the picture sizes and lengths that can be generated; hovering shows the estimated time, and clicking fills in the size and length." - ] - }, - "zh": { - "summary": "离线包在没有开启长路径支持的 Windows 电脑上也能使用;生成被打断一次后,之后的生成不再变慢。", - "changes": [ - "在没有开启长路径支持的 Windows 电脑上(Windows 的默认设置),离线包现在可以正常导入、启动和生成,不需要更改系统设置;层级很深、路径很长的文件夹也可以。上一版没有导完的文件夹不需要清理,更新后可以直接重新导入。", - "创作面板“总像素”的选项里去掉了“1.00 MP”,它和“0.98 MP · 原生”是同一个尺寸;以前用 1.00 MP 保存的工作流打开后尺寸不变。", - "内存不足以容纳整个视频模型的 Windows 电脑上,需要从硬盘读取的那部分模型现在会在其余部分计算的同时提前读好,硬盘较慢时每个采样步等待的时间明显缩短。", - "在 Windows 上第一次用“极致”档(以及部分自定义步数)生成时,不再额外下载约 26 GB 的模型文件。", - "生成被意外中断一次后,之后的生成不再因此变慢:只有连续两次中断或显卡报错,才会自动提高兼容档位;之后连续三次正常完成,档位会自动调回。", - "生成过程中关掉 FreeVideo,现在会取消这次生成,不算作中断;兼容档位被自动提高时,生成进度上会说明这一点,并显示再完成几次后会加快。", - "创作面板“画幅与时长”下面新增“可生成范围”:展开是一张图,显示可以生成的画面大小和时长;鼠标移到图上会显示预计用时,点击图中的位置会填入对应的大小和时长。" - ] - } } ] } diff --git a/freevideo_engine/setup_progress.py b/freevideo_engine/setup_progress.py index fb3baa72..0ef9e5e7 100644 --- a/freevideo_engine/setup_progress.py +++ b/freevideo_engine/setup_progress.py @@ -11,6 +11,13 @@ def number(value): and math.isfinite(value) and value >= 0) +MODEL_ACTIVITIES = ('download', 'verify', 'assemble', 'copy', 'copy_bytes', 'check', 'resume') + + +VERB_ACTIVITIES = ('verify', 'assemble', 'copy_bytes', 'check', 'copy', 'download') +COMPONENT_NAMES = ('Video ', 'Text ', 'Sampling ', 'Image ', 'Model', 'Runtime components') + + class ProgressState: def __init__(self): self.phase = {} @@ -26,8 +33,11 @@ def update(self, event, now=None): self.model_groups = event.get('groups', []) return if event.get('kind') == 'phase': + # A parallel compiler re-sends the running phase with a larger count + # every moment; only a different phase ends a retry notice. + if event.get('label') != self.phase.get('label'): + self.download_notices.clear() self.phase = event - self.download_notices.clear() return key = str(event.get('key', 'current')) if event.get('kind') == 'progress' and key.startswith('download-patient-'): @@ -53,8 +63,12 @@ def update(self, event, now=None): def snapshot(self, now=None): now = time.monotonic() if now is None else now active = [self._view(row, now) for row in self.tasks.values() if row.get('state') == 'running'] - # Keep a download visible while a parallel compiler reports a heartbeat. - active.sort(key=lambda row: (number(row.get('total')) and row['total'] > 0, + # Keep model work visible while a parallel compiler reports a heartbeat, + # also in the moments a file is only being connected or verified (no + # byte total yet); otherwise the line would alternate between the two. + def models(row): + return (row.get('unit') == 'bytes' or row.get('activity') in MODEL_ACTIVITIES) and row['age_seconds'] <= 30 + active.sort(key=lambda row: (models(row), number(row.get('total')) and row['total'] > 0, row.get('unit') == 'bytes'), reverse=True) current = active[0] if active else self._view(self.last, now) if self.download_notices: @@ -98,9 +112,14 @@ def transfer_parts(done, total, rate, decimal_sizes=False): def progress_text(event, zh=False, *, decimal_sizes=False): - """One activity line for the launcher, including known transfer and scan counts.""" + """One activity line for the launcher, including known transfer counts.""" t = lambda en, cn: cn if zh else en activity, name = event.get('activity'), event.get('name') or '' + # A Chinese name follows its verb directly; a Latin one (Python) keeps a space. + gap = ' ' if name[:1].isascii() and name[:1].isalnum() else '' + if not zh and activity in VERB_ACTIVITIES and ( + name.startswith(COMPONENT_NAMES) or any(name == en for en, _ in NAMES.values())): + name = name[:1].lower() + name[1:] # In the middle of a sentence. if activity == 'count': done, total = event.get('done'), event.get('total') if number(done) and number(total) and total > 0 and done <= total: @@ -110,24 +129,25 @@ def progress_text(event, zh=False, *, decimal_sizes=False): verb = {'verify': ('Verifying ', '正在校验'), 'assemble': ('Assembling ', '正在合并'), 'copy_bytes': ('Copying ', '正在复制')}[activity] # MiB rather than tenths of GiB: even slow disks change the visible text. - parts = [t(*verb) + name] + parts = [t(verb[0], verb[1] + gap) + name] done, total = event.get('done'), event.get('total') - if event.get('unit') == 'bytes' and number(done) and number(total) and total > 0 and done <= total: + if event.get('unit') == 'bytes' and number(done) and number(total) and total >= 2**20 and done <= total: parts.append('%d / %d %s' % (done // 2**20, total // 2**20, 'MiB')) return ' · '.join(parts) if activity == 'resume': return t('The connection dropped; resuming the download and keeping what was already downloaded', '连接中断,正在继续下载,已下载的部分会保留') if activity in ('check', 'copy'): - # Mid-sentence, a model family name starts in lower case in English; Chinese has no space after the verb. - name = name[:1].lower() + name[1:] if any(name == en for en, _ in NAMES.values()) else name - parts = [t('Checking ', '正在校验') + name if activity == 'check' - else t('Copying ', '正在复制') + name] + parts = [t('Checking ', '正在校验' + gap) + name if activity == 'check' + else t('Copying ', '正在复制' + gap) + name] done, total = event.get('done'), event.get('total') - if event.get('unit') == 'bytes' and number(done) and number(total) and total > 0 and done <= total: + # Below 1 KiB the size would read "0 / 0 KiB". + if event.get('unit') == 'bytes' and number(done) and number(total) and total >= 1024 and done <= total: parts.extend(transfer_parts(done, total, event.get('rate'), decimal_sizes)) return ' · '.join(parts) if activity == 'note': + if name in ('Finding existing models', '正在查找已有模型'): + return t('Finding files', '正在查找文件') parts = [name] scanned = event.get('scanned') if number(scanned) and scanned > 0: @@ -135,6 +155,6 @@ def progress_text(event, zh=False, *, decimal_sizes=False): return ' · '.join(parts + transfer_parts(0, 0, event.get('rate'), decimal_sizes)) if activity == 'download': total = event.get('total') if event.get('unit') == 'bytes' else 0 - return ' · '.join([t('Downloading ', '正在下载 ') + name] + + return ' · '.join([t('Downloading ', '正在下载' + gap) + name] + transfer_parts(event.get('done'), total or 0, event.get('rate'), decimal_sizes)) return '' diff --git a/freevideo_engine/terminal_ui.py b/freevideo_engine/terminal_ui.py index 730e16f8..e6ef021d 100644 --- a/freevideo_engine/terminal_ui.py +++ b/freevideo_engine/terminal_ui.py @@ -328,6 +328,23 @@ def __init__(self, path): self.model_backend = '' self.parallel_tasks = {} self.model_groups = None + # Ninja runs several times during one build, each with its own count. + self.compile_before, self.compile_last, self.compile_runs = 0, (0, 0), 0 + + def compile_fraction(self): + """How far the compile has advanced, never reaching 1; None before any step. + + The number of runs is unknown until the build ends, so a finished run + cannot mean "done": each run advances the share of the range still left + by half, in proportion to its own counter. A first run of two steps + followed by one of a hundred keeps moving instead of stopping at the + end of the first. + """ + done, total = self.compile_last + if total <= 0: + return None + inside = min(1., max(0., done / total)) + return 1 - .5 ** self.compile_runs + .5 ** (self.compile_runs + 1) * inside def take_tasks(self): tasks, self.parallel_tasks = self.parallel_tasks, {} @@ -421,7 +438,18 @@ def update(**values): package_phase = ('remove', 'Packages removing') if 'Uninstalling' in text else ( ('prepare', 'Packages preparing') if 'Preparing packages' in text else ( ('install', 'Packages installing') if self.installing_packages else ('compile', 'GPU acceleration building'))) - update(done=int(match[1]), total=int(match[2]), + if package_phase[0] == 'compile': + done, total = int(match[1]), int(match[2]) + if total != self.compile_last[1] or done < self.compile_last[0]: + self.compile_before += self.compile_last[1] + self.compile_runs += self.compile_last[1] > 0 + self.compile_last = (done, total) + shown_done, shown_total = int(match[1]), int(match[2]) + if package_phase[0] == 'compile': + # One count across the runs, so it never restarts from 1. + shown_done += self.compile_before + shown_total += self.compile_before + update(done=shown_done, total=shown_total, activity='count', name=package_phase[1], detail='Installing packages' if self.installing_packages else 'Compiling CUDA kernels', scope='package-' + package_phase[0], unit='items') diff --git a/freevideo_engine/two_pass.py b/freevideo_engine/two_pass.py index e69ceba7..d32bb406 100644 --- a/freevideo_engine/two_pass.py +++ b/freevideo_engine/two_pass.py @@ -22,6 +22,19 @@ def validate_steps(base_steps=8, refine_steps=3, enabled=True): raise ValueError('Second-pass steps must be fewer than first-pass steps') +# The learned latent lift and the three refinement steps were built and checked +# for the 8-step first pass only. Any other first-pass count runs in one pass. +TWO_PASS_BASE_STEPS = 8 + + +def one_pass_note(base_steps): + """The sentence a request gets when its step count rules out two-pass, else None.""" + if base_steps == TWO_PASS_BASE_STEPS: + return None + return ('Two-pass generation runs only with %d first-pass steps; this request runs in one pass of %d step%s.' + % (TWO_PASS_BASE_STEPS, base_steps, '' if base_steps == 1 else 's')) + + # A first pass below this many pixels is too small for the learned lift: about # a third of the clips at 768x512 and every clip at 512x512 had coloured # bursts, and none did from 480x288 (960x544) upward. Those sizes run in one pass. @@ -107,6 +120,13 @@ def plan(canvas, enabled=True, task='t2va', *, base_steps=8, refine_steps=3, sta if not enabled: result['reason'] = 'Two-pass generation is disabled.' return result + if base_steps != TWO_PASS_BASE_STEPS: + if not staged: + single = plan(canvas, False, task, base_steps=base_steps) + return dict(single, requested=True, reason=one_pass_note(base_steps)) + # A preview and its upscale keep the first pass they were made with; the + # receipt says so, because a replay without the flag would take the one-pass rule. + result['staged'] = True if min(target['width'], target['height']) >= 512: first = dict(width=((target['width'] + 63) // 64) * 32, height=((target['height'] + 63) // 64) * 32, frames=target['frames']) diff --git a/scripts/test_freevideo_entry.mjs b/scripts/test_freevideo_entry.mjs index 23c5365b..5ef12828 100644 --- a/scripts/test_freevideo_entry.mjs +++ b/scripts/test_freevideo_entry.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import {readFile} from 'node:fs/promises'; import test from 'node:test'; import {outputDownloadURL} from '../web/output_download.js'; -import {regenerateResult, upscaleResult, createSecondPassQueue} from '../web/studio_queue.js'; +import {regenerateResult, upscaleResult, createSecondPassQueue, enableNode} from '../web/studio_queue.js'; import {referenceInputs, mediaRows} from '../web/prompt_references.js'; import {expandCast} from '../web/cast_mention.js'; import {cleanOptionalPrompt, cleanOptionalWidgets, optionalInputValue} from '../web/optional_inputs.js'; @@ -38,7 +38,7 @@ test('main browser entry registers both views and preserves node hooks', async t const document = {head: new Element('head'), createElement: tag => new Element(tag), createRange: () => ({createContextualFragment: () => new Element('span')})}; const dependencies = { - app, api, outputDownloadURL, regenerateResult, upscaleResult, createSecondPassQueue, referenceInputs, mediaRows, expandCast, cleanOptionalPrompt, cleanOptionalWidgets, optionalInputValue, + app, api, outputDownloadURL, regenerateResult, upscaleResult, createSecondPassQueue, enableNode, referenceInputs, mediaRows, expandCast, cleanOptionalPrompt, cleanOptionalWidgets, optionalInputValue, createCastLibrary: options => { const cast = {element: new Element('cast'), options, destroyed: false, destroy() { this.destroyed = true; }}; casts.push(cast); return cast; @@ -77,7 +77,7 @@ test('main browser entry registers both views and preserves node hooks', async t }; const previous = new Map(); for (const [name, value] of Object.entries({ - document, navigator: {language: 'en'}, window: events, + document, navigator: {language: 'en'}, window: events, addEventListener: (type, fn, options) => events.addEventListener(type, fn, options), getComputedStyle: element => ({marginTop: '8px', marginBottom: '8px', display: element.hidden ? 'none' : 'block', ...element.style}), CustomEvent: class extends Event { constructor(name, options) { super(name); this.detail = options?.detail; } }, requestAnimationFrame: callback => callback(), __freevideoEntryTest: dependencies, @@ -106,10 +106,12 @@ test('main browser entry registers both views and preserves node hooks', async t .replace("import { rememberPromptDraft, savePromptDraft, trackPromptDraft, watchPromptHistory, promptHistoryRestoring } from './prompt_draft.js';", 'const {rememberPromptDraft,savePromptDraft} = globalThis.__freevideoEntryTest; const trackPromptDraft = () => {}; const watchPromptHistory = () => {}; const promptHistoryRestoring = () => false;') .replace("import { cleanOptionalWidgets, cleanOptionalPrompt, optionalInputValue } from './optional_inputs.js';", 'const {cleanOptionalWidgets,cleanOptionalPrompt,optionalInputValue} = globalThis.__freevideoEntryTest;') .replace("import { regenerateResult, upscaleResult } from './studio_queue.js';", 'const {regenerateResult,upscaleResult} = globalThis.__freevideoEntryTest;') - .replace("import { regenerateResult, createSecondPassQueue } from './studio_queue.js';", 'const {regenerateResult,createSecondPassQueue} = globalThis.__freevideoEntryTest;') + .replace("import { regenerateResult, createSecondPassQueue, enableNode } from './studio_queue.js';", 'const {regenerateResult,createSecondPassQueue,enableNode} = globalThis.__freevideoEntryTest;') .replace("import { secondPassAction } from './result_actions.js';", 'const {secondPassAction} = globalThis.__freevideoEntryTest;') .replace("import { openImageEditor, icon } from './image_editor.js';", 'const {openImageEditor,icon} = globalThis.__freevideoEntryTest;') - .replace("import { installNavigation, refreshNavigation, preferredView } from './view_navigation.js';", 'const {installNavigation,refreshNavigation,preferredView} = globalThis.__freevideoEntryTest;') + .replace("import { installNavigation, refreshNavigation, preferredView, collapseWorkspaces } from './view_navigation.js';", 'const {installNavigation,refreshNavigation,preferredView} = globalThis.__freevideoEntryTest; const collapseWorkspaces = () => { globalThis.__freevideoEntryTest.collapsed = (globalThis.__freevideoEntryTest.collapsed || 0) + 1; };') + .replace("import { isLaunchVisit } from './launch_visit.js';", 'const isLaunchVisit = () => false;') + .replace("import { openFreeVideoWorkflow } from './launcher.js';", 'const openFreeVideoWorkflow = (...args) => globalThis.__freevideoEntryTest.openFreeVideoWorkflow?.(...args);') .replace("import { imageGenerationSupported } from './image_generation.js';", 'const imageGenerationSupported = () => true;') .replace("import { createCastLibrary } from './cast_library.js';", 'const {createCastLibrary} = globalThis.__freevideoEntryTest;') .replace("import { agentInterfaceEnabled } from './agent_connect.js';", 'const agentInterfaceEnabled = async () => false;'); @@ -140,6 +142,29 @@ test('main browser entry registers both views and preserves node hooks', async t arrange() { this.arranged = (this.arranged || 0) + 1; } computeSize() { return [this.size[0], this.dom.reduce((height, row) => height + (row.options?.getMinHeight?.() || 0), 0)]; } } + for (const [language, tooltip] of [ + ['en', 'Changing sampling steps may lower generation quality. Default: 8 first-pass and 3 second-pass steps. Two-pass acceleration runs only with 8 first-pass steps. Other step counts use a single pass. Other second-pass counts must be fewer than first-pass steps.'], + ['zh', '修改采样步数可能降低生成质量。默认一采 8 步、二采 3 步;只有一采为 8 步时才会使用二次采样加速,其他步数只采样一遍;其他二采步数需小于一采步数。'], + ]) { + await t.test(`${language} node step tooltips include the three-step exception for valid plans`, async () => { + navigator.language = language; + try { + await import('data:text/javascript;base64,' + Buffer.from(source + `\n// tooltip language: ${language}`).toString('base64')); + const localized = extensions.at(-1); + class LocalizedGenerateNode extends GenerateNode {} + await localized.beforeRegisterNodeDef(LocalizedGenerateNode, {name: 'FreeVideoGenerate'}); + for (const [base, refine] of [[1, 3], [2, 3], [3, 3], [8, 3], [8, 2]]) { + const node = new LocalizedGenerateNode(); + node.widgets[0].value = base; node.widgets[1].value = refine; + node.onNodeCreated(); + assert.equal(node.widgets[0].tooltip, tooltip, `${base} + ${refine}: first-pass tooltip`); + assert.equal(node.widgets[1].tooltip, tooltip, `${base} + ${refine}: refine tooltip`); + assert.deepEqual(node.widgets.slice(0, 2).map(widget => widget.value), [base, refine]); + node.onRemoved(); + } + } finally { navigator.language = 'en'; } + }); + } await extension.beforeRegisterNodeDef(GenerateNode, {name: 'FreeVideoGenerate'}); const node = new GenerateNode(); app.graph._nodes = [node]; assert.equal(node.onNodeCreated(), 'created'); @@ -157,8 +182,28 @@ test('main browser entry registers both views and preserves node hooks', async t node.widgets[0].value = false; node.onConfigure(); assert.equal(node.widgets[0].value, 8, 'Migrate the previous private force-checkbox slot'); node.buttons[0].callback(); + const quiet = opened.length; + extension.afterConfigureGraph(); + assert.equal(opened.length, quiet, 'a tab restored with the page does not open the workspace on its own'); + events.dispatchEvent(new Event('pointerdown')); extension.afterConfigureGraph(); assert.deepEqual(opened, [node, node]); + // A workflow without FreeVideo nodes is the user's own: every workspace makes way and none opens. + const nodes = app.graph._nodes, collapsed = dependencies.collapsed || 0; + app.graph._nodes = []; + extension.afterConfigureGraph(); + assert.equal(dependencies.collapsed, collapsed + 1); assert.deepEqual(opened, [node, node]); + // The menu entry opens the workspace of the workflow in front, or FreeVideo's own workflow when it has no node. + const entry = extension.commands.find(row => row.id === 'FreeVideo.Open'); + assert.equal(entry.label, 'FreeVideo'); assert.deepEqual(extension.menuCommands[0].commands, ['FreeVideo.Open']); + let workflows = 0; + dependencies.openFreeVideoWorkflow = async () => { workflows++; return node; }; + const before = opened.length; + await entry.function(); + assert.equal(workflows, 1); assert.equal(opened.length, before + 1); assert.equal(app.graph._nodes.length, 0); + app.graph._nodes = nodes; + await entry.function(); + assert.equal(workflows, 1); assert.equal(opened.length, before + 2); rememberedView = 'editor'; const restored = []; dependencies.openEditorView = (value, options) => restored.push({node:value, studio:options.studio}); diff --git a/web/autopilot.js b/web/autopilot.js index 9453dd5d..a0db9751 100644 --- a/web/autopilot.js +++ b/web/autopilot.js @@ -6,6 +6,7 @@ import { openEditorView } from './editor_loader.js'; import { closeDialog } from './motion.js'; import { productVersion } from './updates.js'; import { openSetup } from './setup.js'; +import { feedbackButton } from './feedback.js'; import { openExtendEditor, openStudio, showNodes } from './studio.js'; import { viewChanged, viewSwitch } from './view_navigation.js'; import { wordmark } from './wordmark.js'; @@ -33,7 +34,7 @@ export function openAutopilotView(node, {studio = null} = {}) { go.type = halt.type = 'button'; go.append(ring, name, more, percent, eta); now.append(go, halt); halt.title = t('Stop all', '全部停止'); halt.setAttribute('aria-label', halt.title); const line = el('i', 'fv-autopilot-line'); - tools.append(now, lamp, settings); + tools.append(now, lamp, feedbackButton(t), settings); // The view chosen next takes over once this page has closed; Escape goes back to what is underneath. let next = () => studio?.open ? viewChanged('studio', node) : viewChanged('nodes', node); const leave = action => { next = action; closeDialog(dialog); }; diff --git a/web/done_notify.js b/web/done_notify.js index c2bcec37..d5bf111e 100644 --- a/web/done_notify.js +++ b/web/done_notify.js @@ -1,12 +1,13 @@ // Tells the user a long run has finished while FreeVideo is in a background tab: // the tab title flashes and one short beep plays until the page is shown again. // A desktop notification is sent only when the user turned it on in the settings. +// Only a queue that ran a FreeVideo node counts: other workflows finish silently. export const DESKTOP_SETTING = 'FreeVideo.DoneNotify.Desktop'; const LONG_RUN_MS = 20000, FLASH_MS = 1000; -export function startDoneNotify({api, text, doc = document, win = window, now = () => Date.now(), desktop = () => false, +export function startDoneNotify({api, text, doc = document, win = window, now = () => Date.now(), desktop = () => false, isFreeVideoNode = () => false, beep = playBeep, notify = desktopNotice, minimum = LONG_RUN_MS, setTimer = setInterval, clearTimer = clearInterval} = {}) { - let busySince = null, failed = false, cancelled = false, flashing = null; + let busySince = null, failed = false, cancelled = false, flashing = null, involved = false; const away = () => doc.hidden || (typeof doc.hasFocus === 'function' && !doc.hasFocus()); function stopFlashing() { @@ -26,25 +27,35 @@ export function startDoneNotify({api, text, doc = document, win = window, now = const remaining = event?.detail?.exec_info?.queue_remaining; if (!Number.isFinite(remaining)) return; if (remaining > 0) { - if (busySince === null) { busySince = now(); failed = false; cancelled = false; } + if (busySince === null) { busySince = now(); failed = false; cancelled = false; involved = false; } return; } if (busySince === null) return; const elapsed = now() - busySince; busySince = null; - if (cancelled || elapsed < minimum || !away()) return; + if (!involved || cancelled || elapsed < minimum || !away()) return; remind(!failed); } - const error = () => { failed = true; }; + const error = event => { failed = true; if (/^FreeVideo/.test(String(event?.detail?.node_type || ''))) involved = true; }; + const freevideo = () => { involved = true; }; + const executing = event => { + const id = typeof event?.detail === 'object' ? event.detail?.node : event?.detail; + if (id != null && isFreeVideoNode(id)) involved = true; + }; const interrupted = () => { cancelled = true; }; const shown = () => { if (!away()) stopFlashing(); }; api.addEventListener('status', status); api.addEventListener('execution_error', error); api.addEventListener('execution_interrupted', interrupted); + api.addEventListener('freevideo_progress', freevideo); + api.addEventListener('freevideo_prewarm', freevideo); + api.addEventListener('executing', executing); doc.addEventListener('visibilitychange', shown); win.addEventListener?.('focus', shown); return {stop() { api.removeEventListener('status', status); api.removeEventListener('execution_error', error); api.removeEventListener('execution_interrupted', interrupted); + api.removeEventListener('freevideo_progress', freevideo); api.removeEventListener('freevideo_prewarm', freevideo); + api.removeEventListener('executing', executing); doc.removeEventListener('visibilitychange', shown); win.removeEventListener?.('focus', shown); stopFlashing(); }}; } diff --git a/web/editor.css b/web/editor.css index 694a5ff8..5d7837da 100644 --- a/web/editor.css +++ b/web/editor.css @@ -1,5 +1,5 @@ /* Share the studio shell and header geometry; only the editor rows differ. */ -.fv-studio.fv-editor{display:grid;grid-template-columns:264px minmax(0,1fr);grid-template-rows:auto minmax(272px,1fr) 40px var(--fv-ed-generation-row,56px) 40px minmax(var(--fv-ed-tracks-min,174px),clamp(174px,calc(100dvh - 704px),236px));animation:none} +.fv-studio.fv-editor{--fv-ed-generation-row:max-content;display:grid;grid-template-columns:264px minmax(0,1fr);grid-template-rows:auto minmax(max(96px,calc(272px - var(--fv-ed-description-growth,0px))),1fr) 40px var(--fv-ed-generation-row,56px) 40px minmax(var(--fv-ed-tracks-min,174px),clamp(174px,calc(100dvh - 704px),236px));animation:none} .fv-studio.fv-editor[data-generation-ready=false]{--fv-ed-generation-row:0px} @media(min-width:1280px){.fv-studio.fv-editor[data-adjust-available=true]{grid-template-columns:264px minmax(0,1fr) 264px}} .fv-editor *{box-sizing:border-box} @@ -60,7 +60,7 @@ .fv-ed-transport{grid-column:2;grid-row:3;display:flex;align-items:center;justify-content:center;gap:12px} .fv-editor .fv-ed-play{width:36px;height:36px;border-radius:50%;padding:0;background:transparent} .fv-ed-time{font-size:13px;font-variant-numeric:tabular-nums;font-family:ui-monospace,monospace;min-width:100px} -.fv-ed-genbar{grid-column:1/-1;grid-row:4;display:flex;align-items:center;gap:12px;padding:0 16px;border-top:1px solid var(--fv-border);border-bottom:1px solid var(--fv-border);background:var(--fv-card);min-width:0} +.fv-ed-genbar{grid-column:1/-1;grid-row:4;display:flex;align-items:center;gap:12px;min-height:56px;padding:9px 16px;border-top:1px solid var(--fv-border);border-bottom:1px solid var(--fv-border);background:var(--fv-card);min-width:0} .fv-ed-genbar .fv-ed-description{width:auto} .fv-ed-clip[data-state=running] .fv-block-progress.fv-ed-block-progress{display:grid} .fv-ed-gen-hint{display:flex;font-size:13px;color:var(--fv-muted);flex:1;min-width:0;overflow:hidden;white-space:pre} @@ -161,7 +161,7 @@ .fv-ed-rename{width:100%;margin-bottom:4px} .fv-ed-notice{position:absolute;bottom:340px;right:16px;max-width:min(560px,calc(100vw - 32px));border:1px solid var(--fv-border);background:var(--fv-card);padding:10px 14px;border-radius:8px;z-index:9;color:var(--fv-ink)} @media(max-width:680px){ - .fv-studio.fv-editor{grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(180px,1fr) 40px var(--fv-ed-generation-row,auto) 40px 236px;overflow:hidden} + .fv-studio.fv-editor{grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(max(0px,calc(180px - var(--fv-ed-description-growth,0px))),1fr) 40px var(--fv-ed-generation-row,auto) 40px 236px;overflow:hidden} .fv-editor .fv-header{overflow-x:auto} .fv-editor .fv-ed-name{font-size:13px;max-width:112px;flex:1;padding:0 2px} .fv-editor .fv-ed-icon{width:28px;flex:none;font-size:20px} @@ -201,7 +201,7 @@ /* Keep the editor's controls visible instead of clipping them at phone widths. */ @media(max-width:480px){ - .fv-studio.fv-editor{grid-template-rows:auto minmax(96px,1fr) 40px var(--fv-ed-generation-row,max-content) auto minmax(160px,236px)} + .fv-studio.fv-editor{grid-template-rows:auto minmax(max(0px,calc(96px - var(--fv-ed-description-growth,0px))),1fr) 40px var(--fv-ed-generation-row,max-content) auto minmax(160px,236px)} .fv-studio.fv-editor:has(.fv-ed-gen-failure:not([hidden])){grid-template-rows:auto minmax(0,1fr) 40px var(--fv-ed-generation-row,max-content) auto minmax(160px,236px)} .fv-studio.fv-editor .fv-header:has(.fv-view-switch){grid-template-columns:minmax(0,1fr) auto} /* One row: undo, split, delete and zoom; editor_toolbar_more.js moves the rest behind ⋯. */ diff --git a/web/editor.js b/web/editor.js index 6dc9f6f5..520c7d13 100644 --- a/web/editor.js +++ b/web/editor.js @@ -4,6 +4,7 @@ import {isText} from './editor_text_model.js'; import {viewSwitch, viewChanged} from './view_navigation.js'; import {wordmark} from './wordmark.js'; import {openSetup} from './setup.js'; +import {feedbackButton} from './feedback.js'; import * as model from './editor_model.js'; import {createEditorState} from './editor_state.js'; import {createEditorStore, sourceKey, mediaURL} from './editor_store.js'; @@ -27,7 +28,7 @@ import {createFirstRunGuide} from './first_run_guide.js'; import {createEditorFailures} from './editor_failure.js'; import {fitEditorHeader} from './editor_header_fit.js'; import {optionalText} from './optional_inputs.js'; -const editorStyles = Promise.all(['result_actions.css', 'editor.css', 'editor_tracks.css', 'editor_frame.css', 'editor_adjust.css', 'editor_adjust_picture.css', 'editor_preview_picture.css', 'editor_select.css', 'editor_menu.css', 'editor_text.css', 'editor_notes.css', 'editor_transport.css'].map(name => { +const editorStyles = Promise.all(['result_actions.css', 'editor.css', 'editor_expand.css', 'editor_tracks.css', 'editor_frame.css', 'editor_adjust.css', 'editor_adjust_picture.css', 'editor_preview_picture.css', 'editor_select.css', 'editor_menu.css', 'editor_text.css', 'editor_notes.css', 'editor_transport.css'].map(name => { const style = document.createElement('link'); style.rel = 'stylesheet'; style.href = new URL(name, import.meta.url).href; const ready = 'sheet' in style ? new Promise(resolve => { style.onload = style.onerror = resolve; }) : Promise.resolve(); document.head.append(style); return ready; @@ -44,27 +45,27 @@ export function editorErrorText(value, t = en => en) { const node = oneLine(detail?.node_type), step = `${value?.phase || ''} ${value?.stage || ''} ${node}`; const part = Number.isInteger(value?.index) ? value.index + 1 : null; const generating = part ? t(`Generating part ${part}`, `生成第 ${part} 段`) : t('Generation', '生成'); - const stage = /encod|编码/i.test(step) ? t('Encoding', '编码') : /decod|解码/i.test(step) ? t('Decoding', '解码') + const stage = /encod|编码/i.test(step) ? t('Preparing', '准备生成') : /decod|解码/i.test(step) ? t('Making the video', '合成视频') : /load|model|加载/i.test(step) ? t('Loading the model', '加载模型') : generating; if (code === 'gpu_memory' || /out of memory|cuda.*memory|显存不足|显存不够/i.test(raw)) - return t(`${stage} ran out of GPU memory`, `${stage}时显存不足`); + return t(`${stage}: not enough GPU memory.`, `${stage}时显存不足。`); const known = { - chain_part_too_short: t('Each continued part must be at least 10 seconds.', '续接的每一段至少要 10 秒。'), + chain_part_too_short: t('Each continued part needs at least 10 seconds.', '接续的每段至少 10 秒。'), chain_too_long: t('A video can have up to 5 parts.', '一条视频最多 5 段。'), too_long: t('This part is longer than the maximum length. Shorten it and try again.', '这一段超过了最长时长,请缩短后再试。'), missing_node: t('Generation is not ready. Restart FreeVideo.', '生成功能没有就绪,请重新启动 FreeVideo。'), - chain_missing: t('Generation could not be submitted. Choose "Generate" again.', '提交生成时出错,请再点一次“生成”。'), - result_mismatch: t('Reading the result failed. Choose "Generate" again.', '读取生成结果时出错,请再点一次“生成”。'), - no_material: t('The media this part uses is missing.', '找不到这一段用到的素材。'), + chain_missing: t('Generation could not start. Choose "Generate" again.', '生成没能开始,请再点“生成”。'), + result_mismatch: t('Could not read the result. Choose "Generate" again.', '没能读取结果,请再点“生成”。'), + no_material: t('The media this part uses is missing. Import it again from the library.', '找不到这一段用到的素材,请在素材库重新导入。'), }; if (known[code]) return known[code]; if (['model_missing', 'missing_model', 'model_not_found'].includes(code) || /(?:model|checkpoint|模型).*(?:not found|missing|不存在|缺失)/i.test(raw)) return t(`Loading the model failed: ${raw || code}`, `加载模型时出错:${raw || code}`); if (['timeout', 'timed_out', 'generation_timeout'].includes(code) || /timed?\s*out|timeout|超时/i.test(raw)) - return t(`${stage} timed out`, `${stage}时等待超时`); + return t(`${stage} timed out. Try again.`, `${stage}时等待超时,请再试一次。`); const received = raw || (/^[a-z_]+$/.test(code) ? '' : code); return received ? t(`${generating} failed: ${received}`, `${generating}时出错:${received}`) - : t(`${generating} failed`, `${generating}时出错`); + : t(`${generating} failed. Try again.`, `${generating}时出错,请再试一次。`); } function exportErrorText(value, t) { const message = [value?.response?.error?.message, value?.response?.error, value?.response?.message, value?.message] @@ -182,7 +183,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given }, 'fv-primary'); const exportCancel = button('', () => store.cancelExport(exportStatus.id).catch(error)); exportCancel.hidden = true; exportCancel.append(icon('m4 4 8 8M12 4l-8 8')); exportCancel.setAttribute('aria-label', t('Cancel', '取消')); exportBox.append(exportHint, exportButton, exportCancel); brand.append(wordmark(), title); - headerActions.append(exportBox, button(t('Settings', '设置'), openSetup, 'fv-quiet')); + headerActions.append(exportBox, feedbackButton(t), button(t('Settings', '设置'), openSetup, 'fv-quiet')); top.append(brand, navigation, headerActions); const headerFit = fitEditorHeader(top, brand, title, navigation, headerActions); const menu = el('div', 'fv-ed-name-menu'); menu.hidden = true; menu.setAttribute('role', 'menu'); @@ -674,7 +675,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given }); } const metadata = store.sources.get(sourceKey(value.source)); - try { state.apply(t('Insert source', '放入素材'), record => model.insertSource(record, { + try { state.apply(t('Insert clip', '放入素材'), record => model.insertSource(record, { ...value, width: metadata?.width ?? value.width, height: metadata?.height ?? value.height, text: value.text ?? metadata?.text, at: value.at ?? model.duration(record)}), {clearRange: state.selection !== initialSelection}); return {ok: true}; @@ -821,7 +822,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (actions.selectionHeld()) return {say: t('Queued', '排队中')}; if (busySelection(selection)) return {say: t('This part is generating.', '这一段正在生成。')}; const flush = flushSpot(selection); - if (flush) { selection = state.select(flush); said = t('Moved to right after the part before.', '已接在上一段后面。'); genbar?.say(said); } + if (flush) { selection = state.select(flush); said = t('Moved right after the previous part.', '已接在上一段后面。'); genbar?.say(said); } else if (!actions.supportedSelection(selection)) { const moved = emptySpot(selection); if (!moved) return {say: t('Choose an empty spot on the timeline.', '请在时间线上选一段空白。')}; @@ -839,7 +840,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (waiting) { hold(selection, text); return {text, say: [said, used, waiting === 'ready' ? queuedReady() - : t('Queued. It starts after the part before it.', '已排队,上一段完成后自动开始。')].filter(Boolean).join(' '), queued: true}; + : t('Queued. It starts after the previous part finishes.', '已排队,上一段完成后自动开始。')].filter(Boolean).join(' '), queued: true}; } const busyBefore = jobActive(); await actions.generate({text, selection}); @@ -1169,6 +1170,9 @@ export function openEditor({api, app, from = null, panel = {}, components: given }); const leaving = () => { player.pause(); rememberView(); if (queue.dirty) queue.flush(); }; window.addEventListener('pagehide', leaving); + // A workflow without FreeVideo nodes came to the front: the editing view makes way (unsaved edits stay open). + const collapse = () => { void handle.close(); }; + window.addEventListener('freevideo-collapse', collapse); const handle = {element, state, actions, store, adjust, audio, notify, async switchView(view) { if (closing || disposed || view === 'editor') return; @@ -1197,7 +1201,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given progressConnection?.stop(); progressObserver?.disconnect(); runProgress.clear(); failures.clear(); stopKeys(); resumeCanvasPointer(); - window.removeEventListener('pagehide', leaving); clearTimeout(noticeTimer); + window.removeEventListener('pagehide', leaving); window.removeEventListener('freevideo-collapse', collapse); clearTimeout(noticeTimer); for (const stop of jobWatchers.values()) stop(); jobWatchers.clear(); queue.dispose({discard: discardOnSwitch}); store.dispose(); if (element.open) element.close(); element.replaceChildren(); element.remove(); if (opened === handle) opened = null; if (returnFocus?.isConnected) returnFocus.focus({preventScroll: true}); diff --git a/web/editor_adjust_picture.js b/web/editor_adjust_picture.js index 08ca6d9d..64348ac3 100644 --- a/web/editor_adjust_picture.js +++ b/web/editor_adjust_picture.js @@ -19,7 +19,7 @@ export function createAdjustPicture({state, sources, t, player, notes = null}) { const submit = event => { if (control.input.dataset.editClip === clip?.id && (control.input.value === '' || !Number.isFinite(Number(control.input.value)) || Number(control.input.value) < min || Number(control.input.value) > max)) { - inputError.textContent = t(`${label}: enter a number from ${min} to ${max}${suffix}.`, `${label}:请输入 ${min} 到 ${max}${suffix} 之间的数值。`); + inputError.textContent = t(`${label}: enter a number from ${min} to ${max}${suffix}.`, `${label}:请输入 ${min} 至 ${max}${suffix}。`); inputError.hidden = false; invalidClip = clip?.id; control.input.setAttribute('aria-invalid', 'true'); return; } clearError(); diff --git a/web/editor_audio.js b/web/editor_audio.js index ec0e2326..b5b0fb3d 100644 --- a/web/editor_audio.js +++ b/web/editor_audio.js @@ -229,7 +229,7 @@ export function attachAudioTracks(element, {state, t = en => en, actions = {}}) controls.replaceChildren(); for (const name of track.kind === 'audio' ? ['muted', 'solo', 'locked'] : ['muted', 'locked']) { const active = !!track[name], button = el('button', 'fv-ed-audio-track-switch'); button.type = 'button'; button.dataset.flag = name; button.dataset.track = track.id; - const labels = {muted: active ? t('Unmute', '取消静音') : t('Mute this track', '静音这条轨道'), solo: active ? t('Unsolo', '恢复全部轨道') : t('Solo this track', '只听这条轨道'), locked: active ? t('Unlock', '解除锁定') : t('Lock this track', '锁定这条轨道')}; + const labels = {muted: active ? t('Unmute', '取消静音') : t('Mute this track', '静音这条轨道'), solo: active ? t('Hear all tracks', '恢复全部轨道') : t('Solo this track', '只听这条轨道'), locked: active ? t('Unlock', '解除锁定') : t('Lock this track', '锁定这条轨道')}; button.title = labels[name]; button.setAttribute('aria-label', button.title); button.setAttribute('aria-pressed', String(active)); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'), path = document.createElementNS(svg.namespaceURI, 'path'); svg.setAttribute('viewBox', '0 0 16 16'); svg.setAttribute('aria-hidden', 'true'); path.setAttribute('d', paths[name]); svg.append(path); button.append(svg); // Switches are view settings: they change every snapshot alike and never enter the undo history. diff --git a/web/editor_entry.js b/web/editor_entry.js index d01abfe0..99667746 100644 --- a/web/editor_entry.js +++ b/web/editor_entry.js @@ -6,6 +6,7 @@ import {openStudio, showNodes} from './studio.js'; import {cleanOptionalPrompt} from './optional_inputs.js'; import {openAutopilot, viewChanged} from './view_navigation.js'; import {mediaReferences} from './editor_media_references.js'; +import {turnNodeOn} from './studio_queue.js'; function editorPanel(node) { const values = Object.fromEntries((node.widgets || []).filter(widget => widget.name).map(widget => [widget.name, widget.value])); return {node: node.id, width: values.width, height: values.height, text: values.text || '', settings: Object.fromEntries(Object.entries({ @@ -14,7 +15,11 @@ function editorPanel(node) { references: mediaReferences(node), async snapshot() { if (node.freevideoEditorLooping?.()) throw new Error('loop_active'); - const value = cleanOptionalPrompt(structuredClone(await app.graphToPrompt())); + // A muted or bypassed node would be left out of the request; if no request comes out, it stays as it was. + const held = turnNodeOn(node); + let value; + try { value = cleanOptionalPrompt(structuredClone(await app.graphToPrompt())); } catch (error) { held?.restore(); throw error; } + held?.done(); return {node: String(node.id), prompt: value.output, extra_data: {extra_pnginfo: {workflow: value.workflow || {}}}}; }}; } diff --git a/web/editor_expand.css b/web/editor_expand.css new file mode 100644 index 00000000..d0de662b --- /dev/null +++ b/web/editor_expand.css @@ -0,0 +1,14 @@ +.fv-ed-description-box{position:relative;display:flex;flex:1;min-width:0} +.fv-ed-description-box .fv-ed-description{width:100%;min-height:36px;max-height:108px;line-height:18px;padding:8px 36px 8px 10px;border-radius:var(--fv-r-sm);resize:none;overflow-y:hidden} +.fv-ed-expand,.fv-editor .fv-ed-expand{position:absolute;right:4px;bottom:4px;width:26px;height:26px;padding:4px;min-height:0;border:0;background:var(--fv-bg);color:var(--fv-muted)} +.fv-ed-expand svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round} +.fv-ed-expand-dialog{box-sizing:border-box;position:fixed;inset:0;margin:auto;width:60vw;height:70dvh;max-width:calc(100vw - 32px);max-height:calc(100dvh - 32px);padding:20px;border:1px solid var(--fv-border);border-radius:var(--fv-r-lg);background:var(--fv-card);color:var(--fv-ink)} +.fv-ed-expand-dialog[open]{display:flex;flex-direction:column;gap:16px} +.fv-ed-expand-dialog::backdrop{background:color-mix(in srgb,var(--fv-bg) 75%,transparent)} +.fv-ed-expand-dialog .fv-ed-expand-text{box-sizing:border-box;flex:1;min-height:0;height:auto;max-height:none;width:100%;resize:none;padding:12px;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm);background:var(--fv-bg);color:var(--fv-ink);font:14px/1.6 var(--fv-font)} +.fv-ed-expand-buttons{display:flex;justify-content:flex-end;gap:8px;flex:none} +.fv-ed-expand-buttons button{height:36px;padding:0 16px;border-radius:var(--fv-r-sm);font:500 13px/1.4 var(--fv-font)} +@media(max-width:680px){.fv-ed-description-box{width:100%;flex:1 1 100%;order:-1}} +@media(max-width:400px){.fv-ed-expand-dialog{width:100vw;height:100dvh;max-width:100vw;max-height:100dvh;border-radius:0;padding:16px}} +.fv-ed-expand-note{flex:none;min-height:0;font:13px/1.4 var(--fv-font);color:var(--fv-muted)} +.fv-ed-expand-note:empty{display:none} diff --git a/web/editor_expand.js b/web/editor_expand.js new file mode 100644 index 00000000..2442a307 --- /dev/null +++ b/web/editor_expand.js @@ -0,0 +1,140 @@ +import {isolateDialogKeys, listenDialogKey, routeDialogKey} from './dialog_keys.js'; + +let style; +function loadStyle() { + if (style) return; + const href = new URL('editor_expand.css', import.meta.url).href; + style = [...document.querySelectorAll('link')].find(link => link.href === href); + if (style) return; + style = document.createElement('link'); + style.rel = 'stylesheet'; style.href = href; + document.head.append(style); +} +const el = (tag, cls, text) => { + const node = document.createElement(tag); node.className = cls; + if (text != null) node.textContent = text; + return node; +}; + +// The supplied textarea owns the text. Both views use its input handler; callers +// changing its value or generation policy programmatically send input or change. +export function createExpandButton({t = en => en, textarea, onGenerate, canGenerate, onDraft = () => {}}) { + loadStyle(); + const element = el('button', 'fv-ed-expand'); element.type = 'button'; + element.title = t('Expand', '放大'); element.setAttribute('aria-label', element.title); + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true'); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', 'M14 4h6v6M20 4l-7 7M10 20H4v-6M4 20l7-7'); svg.append(path); element.append(svg); + let reading = {text: null, top: 0, from: 0, to: 0}, dialog = null, large = null, generate = null, notice = null, seen = '', pending = false, disposed = false, cleanups = []; + const writable = () => !textarea.disabled && !textarea.readOnly; + const allowed = () => writable() && !pending && !!canGenerate(); + function sync() { + if (disposed) return; + element.disabled = !writable(); + if (!dialog) return; + // The small box changed from outside (another part selected, text restored): the dialog follows. + if (textarea.value !== seen) { seen = textarea.value; large.value = seen; } + large.disabled = !writable(); generate.disabled = !allowed(); + } + async function submit() { + if (!allowed()) return; + commit(); notice.textContent = ''; + pending = true; sync(); + let reason = ''; + try { reason = await onGenerate(); } + finally { + pending = false; + // The editor's own reason sits under this dialog, so it is said here too. + if (notice) { notice.textContent = typeof reason === 'string' ? reason : ''; if (notice.textContent && !large.value.trim()) large.focus(); } + sync(); + } + } + function close() { + if (!dialog) return; + commit(); reading = {text: large.value, top: large.scrollTop, from: textarea.selectionStart, to: textarea.selectionEnd}; + const current = dialog; dialog = large = generate = notice = null; + cleanups.splice(0).forEach(stop => stop()); + current.oncancel = current.onclose = null; + if (current.open) current.close(); current.remove(); + if (element.isConnected) element.focus({preventScroll: true}); + } + // The dialog's text reaches the small box as one native edit that leaves its earlier undo history alone; + // the small box is only written to when the dialog is submitted or closed. + function commit() { + if (!dialog || !textarea.isConnected) return; + if (large.value === textarea.value) { textarea.setSelectionRange?.(large.selectionStart, large.selectionEnd); return; } + if (!writable()) return; + const typed = large.value, shown = dialog.open, [from, to] = [large.selectionStart, large.selectionEnd]; + // A modal dialog makes the page behind it inert, so the small box is only editable while it is closed. + if (shown) dialog.close(); + textarea.focus({preventScroll: true}); textarea.select?.(); + const done = typed ? document.execCommand?.('insertText', false, typed) : document.execCommand?.('delete'); + if (!done || textarea.value !== typed) { textarea.value = typed; textarea.dispatchEvent(new Event('input', {bubbles: true})); } + textarea.setSelectionRange?.(from, to); + seen = textarea.value; + if (shown) { dialog.showModal(); large.focus(); large.setSelectionRange?.(from, to); } + } + function open() { + sync(); + if (disposed || element.disabled || dialog) return; + dialog = el('dialog', 'fv-ed-expand-dialog'); + dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true'); + dialog.setAttribute('aria-label', t('Expand', '放大')); + seen = textarea.value; + large = el('textarea', 'fv-ed-expand-text'); large.value = seen; + large.setAttribute('aria-label', textarea.getAttribute('aria-label') || t('Expand', '放大')); + notice = el('div', 'fv-ed-expand-note'); notice.setAttribute('role', 'status'); + large.oninput = () => { + notice.textContent = ''; + // The editor's draft follows every keystroke; the small box itself is written at close or generate. + if (writable()) onDraft(large.value); + sync(); + }; + const buttons = el('div', 'fv-ed-expand-buttons'); + generate = el('button', 'fv-primary', t('Generate', '生成')); generate.type = 'button'; generate.onclick = submit; + const dismiss = el('button', '', t('Close', '关闭')); dismiss.type = 'button'; dismiss.onclick = close; + buttons.append(generate, dismiss); dialog.append(large, notice, buttons); + cleanups.push(listenDialogKey(dialog, 'keydown', event => { + if (event.isComposing || event.keyCode === 229) return; + if (event.key === 'Escape') { event.preventDefault(); close(); } + else if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); if (!event.repeat) submit(); + } else if (event.key === 'Tab') { + event.preventDefault(); + const controls = [large, generate, dismiss].filter(node => !node.disabled); + const index = controls.indexOf(document.activeElement); + controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length]?.focus(); + } + })); + cleanups.push(isolateDialogKeys(dialog, event => routeDialogKey(event, dialog))); + dialog.oncancel = event => { event.preventDefault(); event.stopPropagation(); close(); }; + dialog.onclose = () => { if (dialog && !dialog.open) close(); }; + const parent = textarea.closest('dialog') || document.body; + parent.addEventListener('close', close); cleanups.push(() => parent.removeEventListener('close', close)); + // A click on the backdrop never takes focus from the text. + dialog.addEventListener('mousedown', event => { if (event.target === dialog) { event.preventDefault(); large.focus(); } }); + const stop = event => event.stopPropagation(); + for (const type of ['pointerdown', 'pointerup', 'pointermove', 'mousedown', 'mouseup', 'mousemove', 'click', 'dblclick', 'wheel', 'contextmenu']) dialog.addEventListener(type, stop); + parent.append(dialog); sync(); dialog.showModal(); + // Without a remembered reading position the caret line is centred; focusing alone would jump to the end. + const from = textarea.selectionStart ?? large.value.length, to = textarea.selectionEnd ?? large.value.length; + if (reading.text === large.value && reading.from === from && reading.to === to) { large.focus(); large.setSelectionRange?.(from, to); large.scrollTop = reading.top; } + else { + const full = large.value; large.value = full.slice(0, to); + large.scrollTop = 0; const above = large.scrollHeight, view = large.clientHeight; large.value = full; + const top = above <= view ? 0 : above - view / 2; + large.focus(); large.setSelectionRange?.(from, to); large.scrollTop = top; + } + } + element.onclick = open; + textarea.addEventListener('input', sync); textarea.addEventListener('change', sync); + const observer = typeof MutationObserver === 'function' ? new MutationObserver(sync) : null; + observer?.observe(textarea, {attributes: true, attributeFilter: ['disabled', 'readonly']}); + sync(); + return {element, open, close, commit, dispose() { + close(); disposed = true; observer?.disconnect(); + textarea.removeEventListener('input', sync); textarea.removeEventListener('change', sync); + element.onclick = null; element.remove(); + }}; +} diff --git a/web/editor_genbar.js b/web/editor_genbar.js index 5a5692b1..728a6a4c 100644 --- a/web/editor_genbar.js +++ b/web/editor_genbar.js @@ -1,6 +1,7 @@ // The editor owns its generation controls; progress can use the optional component. import {createFirstFrameSwitch} from './editor_first_frame.js'; import {createEditorReferences} from './editor_references.js'; +import {createExpandButton} from './editor_expand.js'; const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }; export const timeEstimate = (seconds, t) => seconds == null ? '' : seconds < 60 ? t(`about ${Math.max(1, Math.ceil(seconds))} s`, `约 ${Math.max(1, Math.ceil(seconds))} 秒`) @@ -11,13 +12,14 @@ const painted = () => typeof document === 'undefined' || document.visibilityStat export function createEditorGenbar(host, {state, t, actions, text = ''}) { const hint = el('span', 'fv-ed-gen-hint'), note = el('span', 'fv-ed-gen-note'); note.setAttribute('role', 'status'); note.hidden = true; - const input = el('input', 'fv-ed-description'); input.type = 'text'; input.value = text; + const input = el('textarea', 'fv-ed-description'); input.rows = 1; input.value = text; + const description = el('div', 'fv-ed-description-box'); description.append(input); const generate = el('button', 'fv-ed-generate'), cancel = el('button', null, t('Cancel', '取消')), resume = el('button', null, t('Continue', '继续')); input.dataset.fvGuide = 'editor-describe'; generate.dataset.fvGuide = 'editor-generate'; for (const button of [generate, cancel, resume]) button.type = 'button'; - host.append(hint, note, input, generate, cancel, resume); + host.append(hint, note, description, generate, cancel, resume); const firstFrame = createFirstFrameSwitch(host, {state, t, actions}); - input.after(firstFrame.element); + description.after(firstFrame.element); const failure = el('div', 'fv-ed-gen-failure fv-result-links'), failureBar = el('div', 'fv-ed-failure-actions fv-actions'); const failureReason = el('span', 'fv-ed-failure-reason'); failureReason.setAttribute('role', 'status'); const report = el('button', 'fv-ed-failure-report fv-action fv-action-secondary', t('Download diagnostic report', '下载诊断报告')); report.type = 'button'; @@ -25,7 +27,7 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { let failureId = null, downloading = false; // Reference pictures for a part that starts fresh: thumbnails and a small "+" before the description. const references = actions.references ? createEditorReferences({t, source: actions.references.source, size: actions.references.size, error: actions.error, notify: actions.references.notify}) : null; - if (references) input.before(references.element); + if (references) description.before(references.element); let status = null, serial = 0, disposed = false, pressing = new Set(), previewSelection = null, estimateCache = null, noteTimer = null, queuedAt = null; // Which parts are generating, or how the job ended: a "Queued" line goes once this moves on. const progressKey = () => ['done', 'failed', 'cancelled', 'interrupted'].includes(status?.state) ? status.state @@ -114,6 +116,8 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { generate.textContent = t('Generate', '生成'); // Never disabled for a reason the press itself can explain or fix. generate.disabled = busy || !!previewSelection; + resizeDescription(); + input.dispatchEvent(new Event('change')); generate.title = ''; if (selected && !busy) { const key = JSON.stringify(selection); @@ -125,26 +129,50 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { generate.textContent = t('Generate', '生成') + label; } } + function resizeDescription() { + // 18 px lines + 16 px padding + 2 px border: one line is still 36 px, five lines 108 px. + input.style.height = '0px'; + const height = Math.max(36, (input.scrollHeight || 34) + 2); + input.style.height = `${Math.min(108, height)}px`; + input.style.overflowY = height > 108 ? 'auto' : 'hidden'; + host.closest('.fv-editor')?.style.setProperty('--fv-ed-description-growth', `${Math.min(108, height) - 36}px`); + } + let width = null; + const resize = typeof ResizeObserver === 'function' ? new ResizeObserver(entries => { + const next = entries[0]?.contentRect.width; + if (next === width) return; + width = next; resizeDescription(); + }) : null; + resize?.observe(description); input.oninput = () => { state.setRangeText(input.value); render(); }; + input.onkeydown = event => { + // The former text input did not submit or insert a line on Enter. + if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && event.keyCode !== 229) event.preventDefault(); + }; generate.onclick = async () => { const key = JSON.stringify(state.selection ?? null); if (pressing.has(key)) return; pressing.add(key); say(''); render(); const typed = input.value; + let reason = ''; try { // The press shows before any planning, however long that takes. await painted(); const result = actions.press ? await actions.press(input.value) : await actions.generate({target: 'both', text: input.value}); // Never over what the user typed meanwhile, for another part. if (result?.text != null && result.text !== input.value && input.value === typed) input.value = result.text; - if (result?.say) say(result.say, !!result.queued); + if (result?.say) { say(result.say, !!result.queued); reason = result.say; } if (result?.focus) { pressing.delete(key); render(); input.disabled = false; input.focus(); } } // A specific reason is said once; the planning limits are already shown above. - catch (error) { const result = actions.error(error, 'generate'); if (result?.say && !['chain_too_long', 'chain_part_too_short'].includes(result.reason)) say(result.say); - else if (!result && !note.textContent) say(t('Could not start. Try again.', '没能开始,请重试。')); } + catch (error) { const result = actions.error(error, 'generate'); reason = result?.say || ''; + if (result?.say && !['chain_too_long', 'chain_part_too_short'].includes(result.reason)) say(result.say); + else if (!result && !note.textContent) { reason = t('Could not start. Try again.', '没能开始,请重试。'); say(reason); } } finally { pressing.delete(key); render(); } + return reason; }; + const expand = createExpandButton({t, textarea: input, onGenerate: () => generate.onclick(), canGenerate: () => !generate.disabled, onDraft: text => state.setRangeText(text)}); + description.append(expand.element); cancel.onclick = () => (!previewSelection && actions.selectionHeld?.() ? Promise.resolve(actions.unhold()).then(render) : actions.cancel()).catch(actions.error); resume.onclick = () => actions.resume().catch(value => actions.error(value, 'generate')); const unsubscribe = state.subscribe(event => { @@ -160,8 +188,8 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { if (event.type === 'selection' || event.type === 'record') render(); }); render(); - return {element: host, say, downloadFailure, getText() { return input.value; }, update(value) { status = value; render(); }, preview(selection) { previewSelection = selection; render(); }, setText(value) { input.value = value || ''; state.setRangeText(input.value); render(); }, - dispose() { disposed = true; ++serial; clearTimeout(noteTimer); unsubscribe(); references?.dispose(); }}; + return {element: host, say, downloadFailure, getText() { expand.commit(); return input.value; }, update(value) { status = value; render(); }, preview(selection) { previewSelection = selection; render(); }, setText(value) { input.value = value || ''; state.setRangeText(input.value); render(); }, + dispose() { disposed = true; ++serial; clearTimeout(noteTimer); unsubscribe(); resize?.disconnect(); expand.dispose(); references?.dispose(); }}; } // A missing optional module leaves the editor's existing progress in place. diff --git a/web/editor_library.js b/web/editor_library.js index 3d185409..0a35fdfc 100644 --- a/web/editor_library.js +++ b/web/editor_library.js @@ -20,7 +20,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { let listRead = Promise.resolve(), refreshTask = null, retryTimer = null, finishRetry = null, refreshAgain = false; const expectedOutputs = new Set(); const uploading = new Map(), recentImports = new Map(), pendingAdds = new Map(); - const preparing = () => t('Preparing this clip. It goes on the timeline when ready.', '素材正在准备,准备好后会自动放到时间线上。'); + const preparing = () => t('Preparing this clip. It goes on the timeline when ready.', '素材准备好后会自动放到时间线上。'); const cannotOpen = () => t('This file cannot be opened. Supported formats: ', '这个文件打不开。支持的格式:') + FORMATS; function metadata(row, kind) { const g = row.geometry || row, cached = store.sources.get(`${kind}:${row.id}`); diff --git a/web/editor_speed.js b/web/editor_speed.js index fed1e7e7..566d02d6 100644 --- a/web/editor_speed.js +++ b/web/editor_speed.js @@ -20,7 +20,7 @@ export function handleSpeedKey(event) { export function speedErrorText(error, t) { if (error?.code === 'speed_pending') return t('Speed can be adjusted after generation is complete.', '生成完成后可以调整速度。'); if (error?.code === 'speed_locked') return t('This track is locked', '这条轨道已锁定'); - if (error?.code === 'speed') return t('Enter a speed from 0.25 to 4.', '请输入 0.25 至 4 的速度。'); + if (error?.code === 'speed') return t('Enter a speed from 0.25× to 4×.', '请输入 0.25× 至 4× 的速度。'); return null; } export function previewSpeed(media, clip, playing, sound = clip) { diff --git a/web/editor_store.js b/web/editor_store.js index 16f5e522..86a804ee 100644 --- a/web/editor_store.js +++ b/web/editor_store.js @@ -307,7 +307,7 @@ export function createEditorStore(api, {t = en => en, notify = () => {}, onGener }, onConflict: (record, nameSuffix) => { state.adopt({id: null, revision: 0, nameSuffix}); }, onCopied: () => notice('conflict', 'This edit changed elsewhere. Your changes here were saved as a copy.', '这条剪辑在别处改过,这里的改动已存为副本'), - onMemory: () => notice('memory', 'Changes are kept in this page. Restart after updating to save them to disk.', '改动暂存在这个页面里,更新后重启即可保存到磁盘。'), + onMemory: () => notice('memory', 'Changes are kept in this page for now. Update and restart to save them.', '改动暂存在这个页面里,更新并重启后会保存。'), // Automatic retries stay quiet; switching away while unsaved warns in editor.js. onError: () => {}}); const reset = queue.reset; diff --git a/web/editor_text.js b/web/editor_text.js index e9baaa9b..b43dadd0 100644 --- a/web/editor_text.js +++ b/web/editor_text.js @@ -84,7 +84,7 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, const ability=done?await actions.speech.ability():null; if(run!==speech.run)return; if(ability){speech.available=ability.available;speech.need=ability.need;} - if(!done||speech.need){speechEnd(t('Could not download','没能下载'));return;} + if(!done||speech.need){speechEnd(t('Could not download. Try again.','没能下载,请再试一次。'));return;} if(!speech.available){speechEnd(speechFailed());return;} speech.loading=false; } @@ -94,7 +94,7 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, if(!job){speechEnd(speechFailed());return;} speech.job=job.id??null; if(speechResult(job))speech.stop=actions.speech.watch(job.id,value=>{if(run!==speech.run)return false;if(!value){speechEnd(speechFailed());return false;}return speechResult(value);}); - }catch{if(run===speech.run)speechEnd(speech.loading?t('Could not download','没能下载'):speechFailed());} + }catch{if(run===speech.run)speechEnd(speech.loading?t('Could not download. Try again.','没能下载,请再试一次。'):speechFailed());} } function commitDraft(){if(draft){const change=draft.change;apply(t('Text','文字'),change);const c=selectedText();if(c?.text_kind==='text')lastStyle={...styleOf(state.record,c)};}} // Editing a text ended. One left without words is taken away; one that never had any leaves no step behind. diff --git a/web/editor_text_layout.js b/web/editor_text_layout.js index 2d5a9608..5c9f2c90 100644 --- a/web/editor_text_layout.js +++ b/web/editor_text_layout.js @@ -66,4 +66,4 @@ export function drawText(ctx,entry,width,height) { ctx.restore(); return layout; } export const overTwoHint=t=>t('Over two lines','超过两行'); -export const missingHint=(chars,t)=>chars.length ? t(`This font is missing: ${chars.join(' ')}`,`这款字体缺少:${chars.join(' ')}`) : ''; +export const missingHint=(chars,t)=>chars.length ? t(`This font is missing ${chars.join(' ')}. Choose another font.`,`这款字体缺少:${chars.join(' ')},请换一种字体。`) : ''; diff --git a/web/editor_tracks.js b/web/editor_tracks.js index 46d5f61a..f42897a5 100644 --- a/web/editor_tracks.js +++ b/web/editor_tracks.js @@ -1080,7 +1080,7 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = // following clips. The insertion line is an indication, not an edit. const at = snap(frameAt(event), transportSnapPoints(state, allPoints([]), event), state.zoom).frame; if (actions.insertSource) Promise.resolve(actions.insertSource({...payload, at}, {loadMetadata: true})).catch(error => actions.editError?.(error)); - else attempt(t('Insert source', '放入素材'), record => model.insertSource(record, {...payload, at}), {clearRange: true}); + else attempt(t('Insert clip', '放入素材'), record => model.insertSource(record, {...payload, at}), {clearRange: true}); showInsertion(null); showGuide(null); dropPoint = null; dropPreviewKey = ''; draggedSource = null; } function zoom(value, anchor = scroll.clientWidth / 2) { diff --git a/web/editor_transport.js b/web/editor_transport.js index bd7da4c6..a3c5ee84 100644 --- a/web/editor_transport.js +++ b/web/editor_transport.js @@ -48,8 +48,8 @@ export function registerEditorShortcuts(audioRows = [], speedRows = []) { .map(([id, key, en, zh]) => row(id, `Ctrl / Cmd + ${key}`, en, zh, 'edit')), row('addText', 'T', 'Add text', '添加文字', 'text'), row('nextSubtitle', 'Enter', 'Next subtitle while editing', '编辑时添加下一条字幕', 'text'), - row('cancelSelection', 'Esc', 'Cancel selection / gesture / close popup', '取消选择、拖动或关闭弹层', 'edit'), - row('selectPart', 'Enter', 'Select the focused part', '选中聚焦的段', 'edit'), + row('cancelSelection', 'Esc', 'Cancel selection or dragging, or close a popup', '取消选择、拖动或关闭弹窗', 'edit'), + row('selectPart', 'Enter', 'Select the highlighted part', '选中高亮的段', 'edit'), row('toggleSelection', 'Ctrl / Cmd + click', 'Add or remove a part from selection', '添加或移出选择', 'edit'), row('rangeSelection', 'Shift + click', 'Select parts between two points', '选中两处之间的段', 'edit'), row('boxSelection', 'Shift + drag', 'Select parts in a box', '框选多段', 'edit'), diff --git a/web/error_panel.js b/web/error_panel.js index 3df01e3d..80c16d00 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -51,7 +51,7 @@ const PLAIN_ERRORS = [ title: ['This LoRA cannot be used with the int8 model yet', '这个 LoRA 暂时不能用于 int8 模型'], summary: ['This LoRA changes parts of the model that the int8 version cannot merge yet, so it cannot be used for now. LoRAs that change only linear layers work.', '这个 LoRA 修改了 int8 模型目前还不能合并的部分,暂时无法使用。只修改线性层的 LoRA 可以正常使用。']}, {key: 'fa4-update', message: 'The attention speed-up component on this computer needs an update. Run freevideo setup --fa4-guard in a terminal, then restart FreeVideo.', - title: ['The attention speed-up component needs an update', '需要更新注意力加速组件'], + title: ['Runtime components need updating', '运行组件需要更新'], summary: ['The attention speed-up component on this computer needs an update. Run freevideo setup --fa4-guard in a terminal, then restart FreeVideo.', '注意力加速组件需要更新。请在终端运行 freevideo setup --fa4-guard,然后重新启动 FreeVideo。']}, {key: 'duration-too-short', message: 'The video must be at least 1.625 s long.', title: ['The video is too short', '视频时长太短'], @@ -82,11 +82,11 @@ function knownError(text) { // When a diagnostic report exists the card hides Export report; the same advice // then names Download report in the preview or node panel. -const DOWNLOAD_ADVICE = ['If it still fails, click Download report in the preview or node panel and attach the report to a GitHub issue.', - '仍然失败时,请在预览区或节点面板中点击“下载报告”,并提交到 GitHub issue。']; +const DOWNLOAD_ADVICE = ['If it still fails, use Download report in the preview and submit it in a GitHub issue.', + '仍然失败时,可点预览区的“下载报告”,提交到 GitHub issue。']; const EXPORT_TO_DOWNLOAD = [ ['Export the report if it still fails.', DOWNLOAD_ADVICE[0]], - ['If it still fails, export the report and attach it to a GitHub issue.', DOWNLOAD_ADVICE[0]], + ['If it still fails, export the report and submit it in a GitHub issue.', DOWNLOAD_ADVICE[0]], ['仍失败时请导出报告。', DOWNLOAD_ADVICE[1]], ['仍然失败时,请导出报告并提交到 GitHub issue。', DOWNLOAD_ADVICE[1]], ['If it still fails, export the report.', DOWNLOAD_ADVICE[0]], @@ -294,8 +294,8 @@ export function failureAdvice(value, t, known = knownError(value)) { return {...row(key, title, summary, ['', '']), plain: true}; } if (/Prompt outputs failed validation|Required input is missing|Value not in list|Return type mismatch|Failed to validate prompt|value (?:\S+ )?(?:smaller|bigger) than/i.test(text)) - return row('validation', ['Check the workflow inputs', '请检查工作流输入'], - ['ComfyUI rejected the workflow before generation. The affected nodes and reasons are listed below.', 'ComfyUI 在生成开始前拒绝了工作流,相关节点和具体原因列在下方。'], + return row('validation', ['Check generation settings', '检查生成设置'], + ['The generation settings did not pass validation; see the error details below.', '生成设置未通过检查,可查看下方错误详情。'], ['Check the named inputs, connections and model choices. Copy the details or export the error report when asking for help.', '检查下方指出的输入、节点连接和模型选项;反馈时可复制详情或导出错误报告。']); if (/FreeVideo resource planning could not admit/i.test(text)) { // The planner names smaller requests it admitted on this GPU just now. @@ -304,46 +304,46 @@ export function failureAdvice(value, t, known = knownError(value)) { if (fits.length) { const en = fits.map(f => f.longest ? `${f.width}\u00a0×\u00a0${f.height} up to ${f.seconds.toFixed(1)}\u00a0s` : `${f.width}\u00a0×\u00a0${f.height} at ${f.seconds.toFixed(1)}\u00a0s`).join(', or '); const zh = fits.map(f => f.longest ? `${f.width}\u00a0×\u00a0${f.height} 最长 ${f.seconds.toFixed(1)}\u00a0秒` : `${f.width}\u00a0×\u00a0${f.height} 生成 ${f.seconds.toFixed(1)}\u00a0秒`).join(',或 '); - return row('resources', ['This video is too large for the free GPU memory', '这段视频超出了当前可用显存'], - ['Before generating, FreeVideo estimated the GPU memory this video needs, and it is more than the GPU has free right now.', 'FreeVideo 在开始生成前估算了所需显存,这段视频超过了显卡现在空闲的显存。'], - [`This computer can make ${en} right now. Change the duration or total pixels and generate again; closing other programs that use the GPU also frees memory.`, - `现在可以生成:${zh}。请调整时长或总像素后重新生成;关闭其他占用显卡的程序也可以腾出显存。`]); + return row('resources', ['Not enough GPU memory', '显存不够'], + ['Generation needs more GPU memory than is currently available.', '生成所需显存超过当前可用量。'], + [`This computer can make ${en} right now. Change the duration or resolution and generate again; closing other programs that use the GPU also frees memory.`, + `现在可以生成:${zh}。请调整时长或清晰度后重新生成;关闭其他占用显卡的程序也可以腾出显存。`]); } return row('resources', ['The request exceeds available memory', '当前任务的可用内存不足'], - ['Before generating, FreeVideo estimated the GPU memory and RAM this task needs, and it is more than is available right now.', 'FreeVideo 在开始生成前估算了所需的显存和内存,这次任务超过了现在可用的部分。'], - ['Close other memory-heavy programs, or lower the resolution or shorten the duration, and retry. The details show whether GPU memory or RAM ran short, and by how much.', '请关闭其他占用内存的程序,或调低分辨率、缩短时长后重试。详情里写明了是显存还是内存不足,以及还差多少。']); + ['Generation did not start because there is not enough available RAM or GPU memory.', '可用内存或显存不足,本次生成未开始。'], + ['Close memory-heavy applications, or lower the resolution or duration, then retry.', '可关闭占用内存较多的程序,或调低分辨率、缩短时长后重试。']); } if (/commit headroom exhausted|paging file is too small|WinError 1455/i.test(text)) - return row('commit', ['Windows memory allocation limit reached', 'Windows 内存提交额度不足'], - ['Windows cannot back another memory allocation, even if physical RAM is still available.', 'Windows 已没有足够的内存提交额度;即使物理内存还有空闲,也无法再分配内存。'], + return row('commit', ['Not enough Windows memory', 'Windows 内存不足'], + ['Windows cannot provide enough memory for generation right now.', 'Windows 暂时无法提供生成所需的内存。'], ['Close memory-heavy applications and retry. In Windows virtual memory settings, use a system-managed paging file on a drive with free space.', '关闭占用内存较多的程序后重试。在 Windows 虚拟内存设置中,使用系统管理的分页文件,并确保所在磁盘有空闲空间。']); // Refused before it starts: more than this Mac can allocate at all, so closing // other applications cannot help (macos_generate.request_admission). if (/needs at least [\d.]+ GiB of unified memory for .*this Mac can allocate at most/i.test(text)) return row('unified-memory-limit', ['Not enough unified memory', '统一内存不足'], - ['These settings need more unified memory than this Mac can allocate, even with other applications closed.', '这组设置需要的统一内存超过了这台 Mac 可分配的上限,关闭其他程序也无法满足。'], + ['These settings need more memory than the device can provide.', '这组设置需要的内存超过设备上限。'], ['Choose a lower resolution or a shorter duration and retry.', '请降低分辨率或缩短时长后重试。']); if (/MPS backend out of memory|MPS (?:generation|encoding|decoding) reached|No unified-memory allowance|Native generation needs at least .* unified memory/i.test(text)) return row('unified-memory', ['Not enough unified memory', '统一内存不足'], - ['On a Mac, the CPU and GPU share unified memory. This step needed more unified memory than was available at the time.', 'Mac 的 CPU 和 GPU 共用统一内存。这一步需要的统一内存超过了当时可用的量。'], - ['Close memory-heavy applications and retry, or choose a lower resolution or a shorter duration. If you set a memory limit or reserve in Settings, check it as well. If it still fails, export the report.', '请关闭占用内存较多的程序后重试,或降低分辨率、缩短时长。如果在设置中手动设置了内存上限或预留,请一并检查。仍然失败时,请导出报告。']); + ['Generation needed more memory than was available.', '生成所需内存超过当时可用量。'], + ['Close memory-heavy applications, or lower the resolution or duration, then retry. Export the report if it still fails.', '可关闭占用内存较多的程序,或调低分辨率、缩短时长后重试。仍然失败时,请导出报告。']); if (/CUDA out of memory|torch\.OutOfMemoryError|cudaErrorMemoryAllocation/i.test(text)) - return row('vram', ['GPU memory allocation failed', '显存分配失败'], - ['The GPU could not allocate the memory needed at this stage.', '这一阶段的显存申请没有成功。'], - ['Close other GPU workloads and retry so FreeVideo can replan using the newly available memory. Your resolution, duration and steps are kept.', '关闭其他占用显卡的任务后重试,FreeVideo 会根据新的可用显存重新规划,保留你的分辨率、时长和步数。']); + return row('vram', ['GPU memory unavailable', '显存暂不可用'], + ['', ''], + ['Close other programs using the GPU, then retry.', '关闭其他占用显卡的程序后重试。']); const ramReasons = /RAM guard:\s*([a-z_, ]+)\./i.exec(text)?.[1] || ''; if (ramReasons.includes('system_or_commit_pressure') && !ramReasons.includes('working_memory_budget')) return row('ram', ['System memory headroom is too low', '系统可用内存不足'], - ['Memory headroom fell below the safety threshold during execution, so generation stopped. Earlier planning values may be higher.', '运行时测得的系统内存余量低于保护线,生成已停止。此前资源规划时的数值可能更高。'], + ['Generation stopped because available memory was too low.', '可用内存不足,生成已停止。'], ['Close memory-heavy applications and duplicate FreeVideo/ComfyUI instances, then retry. Export the report if it still fails.', '关闭占用内存较多的程序及重复运行的 FreeVideo/ComfyUI 后重试。仍失败时请导出报告。']); if (ramReasons.includes('working_memory_budget') && !ramReasons.includes('system_or_commit_pressure')) - return row('ram', ['The configured RAM limit was reached', '已触及设置的 RAM 限额'], - ['The worker exceeded the enforced RAM budget from a manual limit or selected profile.', '生成进程超过了手动限额或所选配置规定的 RAM 预算。'], - ['Review the RAM limit or selected profile. Automatic mode replans from available memory. Export the report if it still fails.', '检查 RAM 限额或所选配置;自动模式会根据可用内存重新规划。仍失败时请导出报告。']); + return row('ram', ['Memory limit reached', '已达到内存上限'], + ['This generation exceeded the configured memory limit.', '本次生成超过了设置的内存上限。'], + ['Check the memory limit in Settings, or switch back to automatic. Export the report if it still fails.', '请在设置里检查内存上限,或改回自动。仍然失败时,请导出报告。']); if (/RAM guard:|crossed its RAM budget|Insufficient currently available memory/i.test(text)) - return row('ram', ['Available memory or an explicit RAM limit reached', '可用内存或手动 RAM 限额不足'], - ['Automatic RAM estimates can be exceeded while memory is available. This stop indicates system pressure or an explicitly enforced limit.', '自动模式允许在系统仍有余量时超出预估 RAM;停止意味着系统内存压力,或触及了手动设置的硬限制。'], - ['Close memory-heavy applications and retry. If you set a RAM limit for testing, remove or raise it for normal generation. Copy the details below if it still fails.', '关闭占用内存较多的程序后重试。如果设置过测试用 RAM 限制,正常生成时可取消或调高。仍失败时可复制下方详情。']); + return row('ram', ['Memory is low or its limit was reached', '内存不足或已达到上限'], + ['Generation stopped because memory was low or its configured limit was reached.', '内存不足或已达到设置的上限,生成已停止。'], + ['Close memory-heavy applications and retry. If you set a memory limit, remove or raise it. Copy the details below if it still fails.', '关闭占用内存较多的程序后重试;设置过内存上限的,请取消或调高。仍然失败时,可复制下方详情。']); if (/No space left on device|WinError 112|disk (?:is )?full/i.test(text)) return row('disk', ['Disk space is insufficient', '磁盘空间不足'], ['FreeVideo could not write a required file.', 'FreeVideo 无法写入所需文件。'], @@ -351,14 +351,14 @@ export function failureAdvice(value, t, known = knownError(value)) { if (/Model download paused|unexpected-transfer-size|ConnectionError|ConnectTimeout|ReadTimeout|Every download source failed|All Git sources failed|All package sources failed|curl-28/i.test(text)) return row('download', ['Download needs attention', '下载未完成'], ['A transfer failed or its received data did not match the expected file.', '传输中断,或收到的数据与预期文件不一致。'], - ['Check your connection or select another download source in Settings, then retry. Existing download fragments are retained.', '检查网络,或在设置中切换下载源后重试。已有下载片段会保留。']); + ['Choose another download source in Settings and retry; download progress is kept.', '可在设置中切换下载源后重试,下载进度会保留。']); if (/ModuleNotFoundError|No module named|DLL load failed/i.test(text)) - return row('dependencies', ['A runtime dependency could not load', '运行依赖无法加载'], - ['A component FreeVideo needs could not load. This usually means the environment is incomplete or an incompatible environment was selected.', 'FreeVideo 运行所需的组件没能加载,通常是运行环境不完整,或者选择了不兼容的环境。'], - ['In the FreeVideo launcher, open Settings › Environment and click Repair; it checks this installation and restores what is missing. If it still fails, export the report and attach it to a GitHub issue.', '请在 FreeVideo 启动器的“设置 → 环境”里点击“修复”,它会检查这份安装并补齐缺少的组件。仍然失败时,请导出报告并提交到 GitHub issue。']); + return row('dependencies', ['A runtime component could not load', '运行组件无法加载'], + ['A component needed to run FreeVideo could not load.', 'FreeVideo 所需的运行组件未能加载。'], + ['Select "Repair" in the launcher under Settings > Environment. If it still fails, export the report and submit it in a GitHub issue.', '可在启动器“设置 → 环境”里点“修复”。仍然失败时,请导出报告并提交到 GitHub issue。']); return row('unknown', ['Generation stopped', '生成已停止'], - ['FreeVideo hit an internal error, and this generation did not finish.', 'FreeVideo 内部出错,这次生成没有完成。'], - ['Click Export report and attach the report to a GitHub issue; we will look into it and fix the problem for you.', '请点击“导出报告”,并把报告提交到 GitHub issue,我们会专门排查,为您解决这个问题。']); + ['This generation did not finish.', '这次生成未完成。'], + ['Click Export report and submit it in a GitHub issue; we will look into it.', '请点“导出报告”,提交到 GitHub issue,我们会专门排查。']); } let activeDialog = null; @@ -462,7 +462,7 @@ export function createErrorPanel(t) { // disabled by the browser or the page is served over plain HTTP. disclosure.open = true; details.value = JSON.stringify(currentReport, null, 2); details.focus(); details.select(); - downloadStatus.textContent = t('Download unavailable. Use Copy error to copy the report below.', '浏览器未能下载,请点击“复制错误”复制下方完整报告。'); + downloadStatus.textContent = t('The download did not finish; select "Copy error" to copy the report below.', '下载未完成,可点“复制错误”复制下方报告。'); } finally { link?.remove(); if (url) setTimeout(() => URL.revokeObjectURL(url), 30000); @@ -477,15 +477,15 @@ export function createErrorPanel(t) { : failureAdvice(report.details, t, report.known_error ?? null); if (report.stage === 'submission' && advice.kind === 'unknown') { advice.title = t('The task could not be submitted', '任务提交失败'); - advice.summary = t('The task submission returned an error. Its details are shown below.', '提交任务时返回了错误,具体原因显示在下方。'); + advice.summary = t('The task could not be submitted; see the error details below.', '任务未提交成功,可查看下方错误详情。'); } if (report.stage === 'queue' && advice.kind === 'unknown') { advice.title = t('The queue operation did not complete', '队列操作未完成'); advice.summary = t('Check that ComfyUI is still running and connected, then retry.', '请确认 ComfyUI 仍在运行、连接正常后重试。'); } if (advice.kind === 'unknown' && ['submission', 'queue'].includes(report.stage)) - advice.action = t('Copy the error details when reporting the issue; they include the failed stage when available.', '反馈问题时请复制错误详情,其中会保留已记录的失败阶段。'); - title.textContent = advice.title; explanation.textContent = advice.summary; action.textContent = advice.action; + advice.action = t('Select "Copy error" to keep the error details.', '可点“复制错误”保留错误详情。'); + title.textContent = advice.title; explanation.textContent = advice.summary; explanation.hidden = !advice.summary; action.textContent = advice.action; element.querySelector('.fv-extension-steps')?.remove(); if (advice.kind === 'browser-extension') action.after(extensionSteps(advice)); element.freevideoKind = advice.kind; reload.hidden = advice.kind !== 'browser-extension'; @@ -497,7 +497,7 @@ export function createErrorPanel(t) { } action.hidden = !advice.action; const stage = report.stage === 'submission' ? t('Submitting task', '提交任务') - : report.stage === 'queue' ? t('Queue operation', '队列操作') : t('Executing workflow', '执行工作流'); + : report.stage === 'queue' ? t('Queue operation', '队列操作') : t('Generating', '正在生成'); location.textContent = stage + (report.http_status ? ` · HTTP ${report.http_status}` : '') + report.nodes.map(node => ` · ${node.type || t('Node', '节点')} #${node.id}`).join(''); const reasons = report.nodes.flatMap(node => node.errors.map(error => @@ -516,15 +516,15 @@ export function createErrorPanel(t) { downloadStatus.textContent = ''; download.hidden = diagnosticReports.has(report); if (internal && download.hidden) - action.textContent = t('Click Download report in the preview or node panel and attach the report to a GitHub issue; we will look into it and fix the problem for you.', - '请在预览区或节点面板中点击“下载报告”,并把报告提交到 GitHub issue,我们会专门排查,为您解决这个问题。'); + action.textContent = t('Use Download report in the preview and submit it in a GitHub issue; we will look into it.', + '请点预览区的“下载报告”,提交到 GitHub issue,我们会专门排查。'); else if (download.hidden) { // Advice that ends with "export the report" points at the report this card does offer. for (const [from, to] of EXPORT_TO_DOWNLOAD) if (action.textContent.includes(from)) action.textContent = action.textContent.replace(from, to); } privacy.textContent = download.hidden - ? t('Use Download report in the preview or node panel for the diagnostic report.', '完整诊断请使用预览区或节点面板中的“下载报告”。') + ? t('Select "Download report" in the preview to save the full diagnostic information.', '可在预览区点“下载报告”保存完整诊断信息。') : t('Paths and typed text are removed.', '已去掉路径和输入的文字。'); copy.textContent = advice.kind === 'sampling-table' ? t('Copy details', '复制详情') : t('Copy error', '复制错误'); if (popup) showFailureDialog(report, t); diff --git a/web/feedback.css b/web/feedback.css new file mode 100644 index 00000000..5cfe82fa --- /dev/null +++ b/web/feedback.css @@ -0,0 +1,15 @@ +.fv-feedback textarea{margin-top:16px;min-height:112px} +.fv-feedback label.fv-check{margin:14px 0 0} +.fv-feedback .fv-feedback-shot{position:relative;margin:10px 0 0 25px;width:max-content;max-width:calc(100% - 25px)} +.fv-feedback .fv-feedback-shot img{display:block;max-width:100%;max-height:96px;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm)} +.fv-feedback .fv-feedback-shot button{position:absolute;top:4px;right:4px;min-height:22px;width:22px;padding:0;line-height:1;border-radius:50%;background:var(--fv-bg);border-color:var(--fv-border);color:var(--fv-ink)} +.fv-feedback .fv-feedback-actions{display:flex;align-items:center;justify-content:flex-end;gap:14px;margin-top:20px} +.fv-feedback .fv-feedback-status{flex:1;margin:0;min-width:0;color:#a5b3c6;font-size:13px;line-height:1.45;text-decoration:none} +.fv-feedback .fv-feedback-actions button{flex:none;min-width:88px} +/* Header buttons never break into lines; where the header cannot hold the entry beside Settings, only Settings stays. */ +.fv-header-actions>button{white-space:nowrap} +@media(max-width:1040px){.fv-autopilot .fv-header-actions>.fv-feedback-entry{display:none}} +/* Below 801 px the editor's header has room for the name, the view switch and Export only; the entry is in the other views. */ +@media(max-width:800px){.fv-editor .fv-header-actions>.fv-feedback-entry{display:none}} +/* On a narrow screen the line gets the whole width above the button. */ +@media(max-width:480px){.fv-feedback .fv-feedback-actions{flex-wrap:wrap;row-gap:10px}.fv-feedback .fv-feedback-status{flex:1 0 100%}.fv-feedback .fv-feedback-status:empty{display:none}} diff --git a/web/feedback.js b/web/feedback.js new file mode 100644 index 00000000..6dd3510d --- /dev/null +++ b/web/feedback.js @@ -0,0 +1,223 @@ +// The "Feedback" entry: one sentence, optionally the page's picture and a +// diagnostic summary (both off until ticked). Sending opens the GitHub +// suggestion page with the text (and summary) filled in; the user submits it +// there. The picture cannot travel in a link, so it is put on the clipboard +// for pasting. The entry sits beside Settings in every view. +import { api } from '../../scripts/api.js'; +import { closeDialog } from './motion.js'; +import { isolateDialogKeys, routeDialogKey } from './dialog_keys.js'; +import { productVersion } from './updates.js'; + +const ISSUE_FORM = 'https://github.com/FlashML-org/FreeVideo/issues/new'; +const DRAFT_KEY = 'freevideo.feedback-draft'; +const MAX_TEXT = 1500; +const MAX_DIAGNOSTICS = 3000; +const MAX_ADDRESS = 7000; +const SHOT_WIDTH = 1600; + +for (const name of ['setup.css', 'feedback.css']) { + if (document.head.querySelector(`link[href$="${name}"]`)) continue; + const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = new URL('./' + name, import.meta.url).href; + document.head.append(link); +} + +// The GitHub page for a suggestion, with the text (and, when asked for, the +// summary) already in it. +export function feedbackIssueURL(text, version = null, diagnostics = '') { + return feedbackIssueParts(text, version, diagnostics).url; +} + +// Same, and whether the end of the text had to be left off to fit the address. +export function feedbackIssueParts(text, version = null, diagnostics = '') { + const build = (body, extra) => { + const params = new URLSearchParams({ template: 'feedback.yml', title: '[Feedback]', feedback: body }); + if (version) params.set('version', `v${version}`); + if (extra) params.set('diagnostics', extra); + return `${ISSUE_FORM}?${params}`; + }; + const whole = String(text || '').slice(0, MAX_TEXT); + let body = whole, extra = String(diagnostics || '').slice(0, MAX_DIAGNOSTICS); + // GitHub refuses very long addresses; the summary gives way first, then the end of the text. + while (build(body, extra).length > MAX_ADDRESS && extra) extra = extra.slice(0, Math.max(0, extra.length - 250)); + while (build(body, extra).length > MAX_ADDRESS && body.length > 1) body = body.slice(0, Math.floor(body.length * 0.9)); + return { url: build(body, extra), truncated: body.length < whole.length }; +} + +function readDraft() { try { return localStorage.getItem(DRAFT_KEY) || ''; } catch { return ''; } } +function writeDraft(text) { + try { text ? localStorage.setItem(DRAFT_KEY, text) : localStorage.removeItem(DRAFT_KEY); } catch { /* The text stays in the box. */ } +} + +// The current tab as one PNG: the browser asks the user to share it, and the +// dialog hides for the moment of capture so the page is what shows. +async function capturePage(hide) { + const media = navigator.mediaDevices; + if (!media?.getDisplayMedia) throw Object.assign(new Error('unsupported'), { name: 'NotSupportedError' }); + const stream = await media.getDisplayMedia({ video: { displaySurface: 'browser' }, audio: false, + preferCurrentTab: true, selfBrowserSurface: 'include', surfaceSwitching: 'exclude' }); + try { + const video = document.createElement('video'); video.muted = true; video.srcObject = stream; + // A tab that never produces a frame must not leave the box waiting. + await Promise.race([video.play(), new Promise((_, fail) => setTimeout(() => fail(new Error('no frame')), 5000))]); + const restore = hide(); + try { + await new Promise(done => setTimeout(done, 180)); + const width = video.videoWidth, height = video.videoHeight; + if (!width || !height) throw new Error('empty'); + const scale = Math.min(1, SHOT_WIDTH / width); + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(width * scale)); canvas.height = Math.max(1, Math.round(height * scale)); + canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height); + const blob = await new Promise(done => canvas.toBlob(done, 'image/png')); + if (!blob) throw new Error('empty'); + return blob; + } finally { restore(); } + } finally { for (const track of stream.getTracks()) track.stop(); } +} + +// The engine's redacted summary (hardware, settings, timings; no prompt or +// picture) as one compact line of text; '' when it cannot be read. +export async function readDiagnostics(fetchApi = p => api.fetchApi(p)) { + try { + const response = await fetchApi('/freevideo/feedback-info'); + if (!response.ok) return ''; + return JSON.stringify(await response.json()).slice(0, MAX_DIAGNOSTICS); + } catch { return ''; } +} + +// Puts the picture on the clipboard; resolves to whether it got there. +export async function copyPicture(blob, clipboard = navigator.clipboard) { + try { + if (!clipboard?.write || typeof ClipboardItem === 'undefined') return false; + await clipboard.write([new ClipboardItem({ [blob.type]: blob })]); + return true; + } catch { return false; } +} + +const el = (tag, cls, text) => { + const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; + return e; +}; + +// True when the browser opened the page; false when it blocked it. +function openPage(address) { + const page = window.open(address, '_blank'); + if (!page) return false; + try { page.opener = null; } catch { /* Same-origin only; the page is GitHub's. */ } + return true; +} + +async function copyText(text, clipboard = navigator.clipboard) { + try { await clipboard.writeText(text); return true; } catch { return false; } +} + +let current = null; + +export function openFeedback(t, { open = openPage, version = productVersion, + fetchApi = p => api.fetchApi(p) } = {}) { + if (current?.open) { current.querySelector('textarea')?.focus(); return current; } + const dialog = el('dialog', 'fv-setup fv-setup-small fv-feedback'); current = dialog; + dialog.setAttribute('aria-label', t('Feedback', '反馈')); + dialog.style.pointerEvents = 'auto'; + const head = el('header', 'fv-setup-head'), close = el('button', 'fv-close', '×'); + close.type = 'button'; close.setAttribute('aria-label', t('Close', '关闭')); close.onclick = () => closeDialog(dialog); + head.append(el('h2', null, t('Feedback', '反馈')), close); + + const field = el('textarea'); field.rows = 5; field.maxLength = MAX_TEXT; + field.placeholder = t('What could be better?', '哪里可以做得更好?'); + field.setAttribute('aria-label', t('Feedback', '反馈')); + field.value = readDraft(); + + const check = label => { + const row = el('label', 'fv-check'), box = el('input'); box.type = 'checkbox'; + row.append(box, el('span', null, label)); return { row, box }; + }; + const shotCheck = check(t('Attach a screenshot of this page', '附上这一页的截图')); + const diagCheck = check(t('Include diagnostic info (no prompts or images)', '附上诊断信息(不含提示词和画面)')); + + const preview = el('div', 'fv-feedback-shot'); preview.hidden = true; + const thumb = el('img'); thumb.alt = ''; + const remove = el('button', 'fv-quiet', '×'); remove.type = 'button'; remove.setAttribute('aria-label', t('Remove', '移除')); + preview.append(thumb, remove); + + const send = el('button', 'fv-primary', t('Submit on GitHub', '在 GitHub 提交')); send.type = 'button'; + const status = el('p', 'fv-feedback-status'); status.setAttribute('role', 'status'); + const actions = el('div', 'fv-feedback-actions'); actions.append(status, send); + dialog.append(head, field, shotCheck.row, preview, diagCheck.row, actions); + + let picture = null, previewURL = null, diagnostics = ''; + const setPicture = blob => { + if (previewURL) URL.revokeObjectURL(previewURL); + picture = blob; previewURL = blob ? URL.createObjectURL(blob) : null; + preview.hidden = !blob; thumb.src = previewURL || ''; + shotCheck.box.checked = Boolean(blob); + }; + const refresh = () => { send.disabled = !field.value.trim(); }; + field.addEventListener('input', () => { writeDraft(field.value); refresh(); }); + remove.onclick = () => setPicture(null); + shotCheck.box.addEventListener('change', async () => { + if (!shotCheck.box.checked) { setPicture(null); return; } + shotCheck.box.disabled = true; status.replaceChildren(); + try { + setPicture(await capturePage(() => { dialog.style.visibility = 'hidden'; return () => { dialog.style.visibility = ''; }; })); + } catch (error) { + setPicture(null); + console.warn('FreeVideo feedback screenshot:', error); + // Declining the browser's prompt is the user's own answer; anything else is said once. + if (error?.name !== 'NotAllowedError') status.textContent = t("Couldn't capture this page. You can submit without it.", '没能截取这一页,可以不带截图提交。'); + } finally { shotCheck.box.disabled = false; } + }); + // Read while the box is ticked, so the page can open in the same click as Send. + diagCheck.box.addEventListener('change', async () => { + diagnostics = ''; + if (!diagCheck.box.checked) return; + status.replaceChildren(); diagCheck.box.disabled = true; + diagnostics = await readDiagnostics(fetchApi); + diagCheck.box.disabled = false; + if (!diagnostics) { diagCheck.box.checked = false; status.textContent = t("Couldn't read the diagnostic info. You can submit without it.", '没能读取诊断信息,可以不带它提交。'); } + }); + send.onclick = async () => { + const text = field.value.trim(); + if (!text) return; + const { url: address, truncated } = feedbackIssueParts(text, version(), diagCheck.box.checked ? diagnostics : ''); + // Opening and copying both start inside the click, which is what lets the browser allow them. + const reached = open(address); + const hadPicture = Boolean(picture); + const copied = reached ? (picture ? copyPicture(picture) : truncated ? copyText(text) : Promise.resolve(false)) + : copyText(address); + // A text too long for the address stays in the box, so nothing is lost with the dialog. + if (reached && !truncated) writeDraft(''); + const done = await copied; + status.textContent = reached && truncated + ? (hadPicture || !done + ? t('GitHub is open, but the text was too long and was cut short there. Your full text stays here.', '已打开 GitHub,但文字过长,那边被截短了,完整文字仍留在这里。') + : t('GitHub is open, but the text was too long and was cut short there. The full text is copied; paste it in.', '已打开 GitHub,但文字过长,那边被截短了,完整文字已复制,请粘贴补全。')) + : !reached + ? (done ? t("Couldn't open GitHub. The link is copied; paste it into your browser.", '没能打开 GitHub,链接已复制,请粘贴到浏览器。') + : t("Couldn't open GitHub. Please try again later.", '没能打开 GitHub,请稍后再试。')) + : hadPicture && done + ? t('GitHub is open and the screenshot is copied. Paste it in, then submit.', '已打开 GitHub,截图已复制,粘贴后提交。') + : hadPicture + ? t("GitHub is open with your text filled in, but the screenshot couldn't be copied. Submit it there without it.", '已打开 GitHub,文字已填好,但截图没能复制,可不带截图提交。') + : t('GitHub is open with your text filled in. Submit it there.', '已打开 GitHub,文字已填好,确认后提交。'); + }; + + const stopKeys = isolateDialogKeys(dialog, event => routeDialogKey(event, dialog)); + // A press that starts and ends on the backdrop closes; selecting text and releasing outside does not. + let downOnBackdrop = false; + dialog.addEventListener('pointerdown', event => { downOnBackdrop = event.target === dialog; }); + dialog.addEventListener('click', event => { if (event.target === dialog && downOnBackdrop) closeDialog(dialog); }); + dialog.addEventListener('close', () => { + stopKeys(); setPicture(null); dialog.remove(); if (current === dialog) current = null; + }); + document.body.append(dialog); + dialog.showModal(); refresh(); field.focus(); + return dialog; +} + +// The header entry, in the style of the Settings button beside it. +export function feedbackButton(t, className = 'fv-quiet') { + const button = el('button', `${className} fv-feedback-entry`, t('Feedback', '反馈')); button.type = 'button'; + button.onclick = () => openFeedback(t); + return button; +} diff --git a/web/freevideo.js b/web/freevideo.js index e11c54be..22b12b48 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -8,7 +8,9 @@ import { createProgressConnection } from './progress_connection.js'; import { notifyCompatibility } from './compatibility.js'; import { startUpdateChecks } from './updates.js'; import { startDoneNotify, askDesktopPermission, DESKTOP_SETTING } from './done_notify.js'; -import { installNavigation, refreshNavigation, preferredView } from './view_navigation.js'; +import { installNavigation, refreshNavigation, preferredView, collapseWorkspaces } from './view_navigation.js'; +import { isLaunchVisit } from './launch_visit.js'; +import { openFreeVideoWorkflow } from './launcher.js'; import { agentInterfaceEnabled } from './agent_connect.js'; import { attachReferencePicker, referenceItems, syncReferencePrompt, referenceInputs, mediaRows } from './prompt_references.js'; import { expandCast } from './cast_mention.js'; @@ -17,7 +19,7 @@ import { shareButton } from './share.js'; import { reportFileName } from './report_issue.js'; import { rememberPromptDraft, savePromptDraft, trackPromptDraft, watchPromptHistory, promptHistoryRestoring } from './prompt_draft.js'; import { cleanOptionalWidgets, cleanOptionalPrompt, optionalInputValue } from './optional_inputs.js'; -import { regenerateResult, createSecondPassQueue } from './studio_queue.js'; +import { regenerateResult, createSecondPassQueue, enableNode } from './studio_queue.js'; import { secondPassAction } from './result_actions.js'; import { openImageEditor, icon } from './image_editor.js'; import { imageGenerationSupported } from './image_generation.js'; @@ -73,7 +75,7 @@ function mediaKind(name) { throw new Error(text("Choose an image, video or audio file", "请选择图片、视频或音频文件")); } -const referenceHelp = () => text('When one input receives several images, each counts as one reference.', '一个接口接入多张图片时,每张各算一个参考素材。'); +const referenceHelp = () => text('Each reference image counts as one media item.', '每张参考图片各占一个素材名额。'); // Input names stay English like every other node's; the panel's chips are translated. const referenceSocket = index => `Reference image ${index + 1}`; const referenceLabel = index => text(`Reference image ${index + 1}`, `参考图片 ${index + 1}`); @@ -184,7 +186,7 @@ function mediaPanel(node, mount = null) { try { outcome = await openImageEditor({source, name: source, target: videoSize(), edit: original.edit || agent?.edit || null, role: original.role, t: text, resolve: viewURL, upload: uploadOne, library, tool}); - } catch (error) { message(text('The image could not be opened for editing.', '这张图片无法打开编辑。'), true); return; } + } catch (error) { message(text('This image cannot be edited right now; choose an image again.', '图片暂时无法编辑,可重新选择图片。'), true); return; } if (!outcome) return; // The cards may have changed meanwhile; find this one again by its file. const rows = read(), at = rows.findIndex(row => row.file === file); @@ -202,8 +204,8 @@ function mediaPanel(node, mount = null) { if (index < 0) return; const hasOriginal = outcome.edit.layers.some(layer => layer.id === 'base'); latest[index] = {...latest[index], file: result.file, ...(hasOriginal ? {source} : {}), edit: outcome.edit}; update(latest); - message(hasOriginal ? text(`Saved the edited image, ${outcome.width}×${outcome.height}. The original is kept.`, - `已保存编辑后的图片(${outcome.width}×${outcome.height}),原图保留。`) : ''); + message(hasOriginal ? text('The edited image is saved, and the original is kept.', + '编辑后的图片已保存,原图已保留。') : ''); } catch (error) { message(error.message, true); } finally { node.isUploading = false; add.disabled = false; addAudio.disabled = false; } } @@ -215,7 +217,7 @@ function mediaPanel(node, mount = null) { try { outcome = await openImageEditor({target: videoSize(), role: role.value, t: text, resolve: viewURL, upload: uploadOne, library}); - } catch { message(text('The image could not be opened for editing.', '这张图片无法打开编辑。'), true); return; } + } catch { message(text('This image cannot be edited right now; choose an image again.', '图片暂时无法编辑,可重新选择图片。'), true); return; } if (!outcome?.blob || read().length >= 32) return; node.isUploading = true; add.disabled = addAudio.disabled = generateImage.disabled = true; try { @@ -252,8 +254,8 @@ function mediaPanel(node, mount = null) { const audioRefs = active.filter(row => row.role === 'reference' && mediaKind(row.file) === 'audio').length + Number(linked('reference_audio')); const refs = active.filter(row => row.role === "reference").length + referenceInputs.filter(linked).length + Number(linked('reference_audio')); audioHelp.hidden = !audioRefs; - audioHelp.textContent = text('Use