From 5f15a4e8ccab8703a267d62dfef468b2670cf3c2 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 20 Oct 2026 19:18:36 +0000 Subject: [PATCH 01/58] Shorten Windows uv source build paths - Keep package builds working on Python 3.9-3.11 and when the scratch folder is unusable --- freevideo_engine/bootstrap.py | 3 +- freevideo_engine/environments.py | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index e16cc7ca..dd80e61e 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -31,7 +31,7 @@ from .terminal_ui import TerminalUI, LogProgress from .locking import runtime_lock, LOCK_ENV from .environments import (ENVIRONMENTS, environment_names, select_layout, role_pythons, constraints_file, - bootstrap_versions, uv_file_arguments) + bootstrap_versions, uv_file_arguments, uv_build_environment) from . import disk_space from . import network from . import processes @@ -1390,6 +1390,7 @@ def command(self, label, args, env=None, cwd=None): env = temporary_environment(self.root, env or self.env) env = dict(env, FREEVIDEO_INSTALL_PROGRESS='1') args, cwd = uv_file_arguments(args, cwd) + env = uv_build_environment(args, self.root, env) with self.state_lock: index = len(self.state['steps']) log = self.run_dir / ('%02d-%s.log' % (index, label)) diff --git a/freevideo_engine/environments.py b/freevideo_engine/environments.py index 6fdb6a3f..276df745 100644 --- a/freevideo_engine/environments.py +++ b/freevideo_engine/environments.py @@ -1,4 +1,5 @@ """Pinned installation layouts; model lifetimes are independent of venv count.""" +import logging import os from pathlib import Path import platform @@ -28,6 +29,59 @@ def constraints_file(name, system=None): '--build-constraints', '--override', '--overrides')) +# uv 0.9.0 adds sdists-v9/////src and +# setuptools adds build/bdist./wheel/... beneath the cache. Reserve +# 200 characters below the engine root, including downloads/uv-cache. This +# allows headroom above measured staging paths, not arbitrary sdists. +UV_BUILD_PATH_CHARS = 200 + + +def uv_build_environment(command, root, environ): + """Windows uv pip builds use a short view of the existing cache and scratch. + + TEMP alone does not move uv's sdist source tree or isolated build venv. + Mark only this child's view: processes.popen translates its paths, while + installation settings, cache ownership and the parent's environment stay put. + """ + if (not command or Path(command[0]).name.lower() not in ('uv', 'uv.exe') + or list(command[1:2]) != ['pip']): + return environ + from . import launch_view, runtime_alias + from .long_paths import io_path, path_chars, plain + from .package_paths import WINDOWS_PATH_LIMIT + from .triton_compat import TEMP_KEYS, TEMP_ROOT_ENV, _writable_temporary + if not runtime_alias._backend.windows(): + return environ + root = Path(os.path.abspath(plain(root))) + view = launch_view.current(environ) + if (view is None or not root.is_relative_to(view.real) + or path_chars(launch_view.value(root, view)) + 1 + UV_BUILD_PATH_CHARS > WINDOWS_PATH_LIMIT): + try: + view = launch_view.View(root, runtime_alias.runtime_view(root, longest=UV_BUILD_PATH_CHARS)) + except OSError as error: + if (path_chars(root) + 1 + UV_BUILD_PATH_CHARS <= WINDOWS_PATH_LIMIT + and runtime_alias._backend.ansi_path(root)): + view = launch_view.View(root, root) + else: + return environ # No short entry: keep the old behaviour; packages without source builds still install. + if path_chars(launch_view.value(root, view)) + 1 + UV_BUILD_PATH_CHARS > WINDOWS_PATH_LIMIT: + return environ + temporary = Path(launch_view.value(root / 'tmp' / 'uv', view)) + try: + for path in (temporary.parent, temporary): + folder = io_path(path) + folder.mkdir(parents=True, exist_ok=True) + if folder.is_symlink() or runtime_alias.is_junction(path): + raise OSError('The package build temporary folder is a link') + _writable_temporary(temporary) + except OSError as error: + # Like a missing short entry, an unusable scratch folder must not stop installs that worked before. + logging.getLogger(__name__).warning('Package builds keep the previous environment: %s', error) + return environ + return dict(launch_view.environment_view(environ, view), + **{key: str(temporary) for key in (*TEMP_KEYS, TEMP_ROOT_ENV)}) + + def uv_file_arguments(command, cwd=None): """Return (command, cwd) for which uv reads constraint files in folders with spaces. From b1e9079a1f9a3ba7f914bffe0fdc86b27e7e472f Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 20 Oct 2026 19:18:37 +0000 Subject: [PATCH 02/58] test: cover feedback cause extraction and redaction - Failure details start with the error a failed child process ended with, so a shortened feedback keeps the cause - Failure details: the promoted error line comes from the redacted log tails and loses terminal colours - test: failure cause redaction and terminal controls --- freevideo_engine/failure_details.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index d2a09cad..407d905b 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -687,6 +687,13 @@ def runtime_explanation(run, repair=None): return _runtime_explanation(_report(run), repair) +# A traceback's final line names the error at the start of the line; the one a parent +# process raises about its failed child only repeats the exit code. +CHILD_ERROR = re.compile(r'[\w.]*(?:Error|Exception|OutOfMemory\w*)\b') +WRAPPING_ERROR = re.compile(r'(?:subprocess\.)?CalledProcessError\b') +ANSI = re.compile(r'\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))') + + def generation_failure(run, exit_code=None, *, repair=None): """Surface the cause, including a child traceback, even without a report. @@ -744,14 +751,25 @@ def generation_failure(run, exit_code=None, *, repair=None): lines.append('Idle cache release: ' + str(recovery.get('reason', recovery) if isinstance(recovery, dict) else recovery)) # Child logs carry the actual CUDA/encoder error when the parent only knows # an exit code. Read bounded tails; do not read model data or entire logs. + causes, known = [], redactor.text('\n'.join(lines))[:300] for name in ('generate.log', 'video.engine.log', 'video.encoding.log'): value = _read(run / name, 8192, tail=True) if not value: continue + # Redact the whole tail first: a prompt or credential is only recognized in full. + value = redactor.text(value) rows = [line for line in value.splitlines() if line.strip() and not line.lstrip().startswith('{"event":')] trace = '\n'.join(rows[-45:]) + causes += [line for line in (ANSI.sub('', row) for row in rows) + if CHILD_ERROR.match(line) and not WRAPPING_ERROR.match(line)] if trace: lines.extend(['\n' + name + ':', trace]) + # A long child traceback pushes its own last line out of a shortened copy of this text + # (the report's error already holds the log's end), so the error it ends with also + # stands near the top unless the text starts with it. It is taken from the redacted tails. + promoted = bool(causes) and causes[-1] not in known + if promoted: + lines.insert(1, 'Cause: ' + causes[-1][:300]) if len(lines) == 1: lines.append('The worker ended without a readable error report.') lines.append('\nRetained outputs: ' + str(run)) @@ -769,7 +787,8 @@ def generation_failure(run, exit_code=None, *, repair=None): from .runtime_libraries import REINSTALL value = value.replace(REPAIR_MARK, repair or REINSTALL, 1) value = re.sub(r'\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))', '', value) - return re.sub(r'[\x00-\x08\x0b-\x1f\x7f]', '', value) + value = re.sub(r'[\x00-\x08\x0b-\x1f\x7f]', '', value) + return value def startup_failure(message, log, *, exit_code=None, context=None): From 5dd7717fdb3592974076253d2a5f9d38f4668745 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 20 Oct 2026 19:18:39 +0000 Subject: [PATCH 03/58] Prompt enhancement retries once when a rewrite leaves out an image reference --- freevideo_engine/prompt_vlm/rules.py | 29 +++++++--- freevideo_engine/prompt_vlm/service.py | 17 ++++-- freevideo_engine/prompt_vlm/worker.py | 74 ++++++++++++++++++++------ 3 files changed, 92 insertions(+), 28 deletions(-) diff --git a/freevideo_engine/prompt_vlm/rules.py b/freevideo_engine/prompt_vlm/rules.py index f0dfb48f..e154bd2b 100644 --- a/freevideo_engine/prompt_vlm/rules.py +++ b/freevideo_engine/prompt_vlm/rules.py @@ -92,9 +92,22 @@ def sections(text, fields): return re.sub(r'\n+(?=(?:%s):)' % names, '\n\n', text).strip() +class InvalidOutput(ValueError): + """Stable rejection metadata; never retain the prompt or rejected draft.""" + def __init__(self, condition, text, value): + super().__init__('invalid_output') + draft = text if isinstance(text, str) else '' + indices = {int(i) for i in re.findall(r'', draft)} + self.details = dict(condition=condition, output_characters=len(draft), + missing_pictures=[i for i in range(1, len(value['media']) + 1) + if f'' not in draft], + out_of_range_pictures=sorted(i for i in indices if not 1 <= i <= len(value['media'])), + retried=False) + + def validate_output(text, value): if not isinstance(text, str) or not 100 <= len(text.strip()) <= 20000: - raise ValueError('invalid_output') + raise InvalidOutput('type_or_length', text, value) text = text.strip() if text.startswith('```') and text.endswith('```'): text = '\n'.join(text.splitlines()[1:-1]).strip() @@ -102,27 +115,27 @@ def validate_output(text, value): text = sections(text, fields) found = re.findall(r'(?m)^([a-z_]+):', text) if found != list(fields) or not text.startswith(fields[0] + ':'): - raise ValueError('invalid_output') + raise InvalidOutput('headings', text, value) if not re.search(r'\[Shot \d+\]', text): name = 'detailed_description' if value['mode'] == 'ref2va' else fields[0] text = text.replace(name + ':', name + ': [Shot 1]', 1) if '[Shot 1]' not in text: - raise ValueError('invalid_output') + raise InvalidOutput('missing_shot_1', text, value) for marker in fields: section = text.split(marker + ':', 1)[1].split('\n\n', 1)[0].strip() if not section: - raise ValueError('invalid_output') + raise InvalidOutput('empty_section', text, value) for minutes, seconds in re.findall(r'\bAt (\d{2}):(\d{2}\.\d{3})', text): if int(minutes) * 60 + float(seconds) >= value['seconds']: - raise ValueError('invalid_output') + raise InvalidOutput('timestamp_not_before_duration', text, value) for index in re.findall(r'', text): if not 1 <= int(index) <= len(value['media']): - raise ValueError('invalid_output') + raise InvalidOutput('picture_index_out_of_range', text, value) if re.search(r'<(?:Audio|Video) \d+>', text): - raise ValueError('invalid_output') + raise InvalidOutput('unsupported_audio_video_reference', text, value) for i in range(1, len(value['media']) + 1): if f'' not in text: - raise ValueError('invalid_output') + raise InvalidOutput('missing_picture_reference', text, value) return dialogue(text, value['text']) diff --git a/freevideo_engine/prompt_vlm/service.py b/freevideo_engine/prompt_vlm/service.py index 1a8acbff..b82404bf 100644 --- a/freevideo_engine/prompt_vlm/service.py +++ b/freevideo_engine/prompt_vlm/service.py @@ -401,11 +401,13 @@ def work(self, root, machine, action, value): self.progress(dict(phase='failed', error='busy')) except Exception as error: known = {'disk_space', 'ram_space', 'rewrite_failed', 'split_failed', 'worker_exit', 'timeout', 'worker_cleanup_failed'} - self.progress(dict(phase='failed', error=str(error) if str(error) in known + self.progress(dict(phase='failed', error='invalid_output' if isinstance(error, rules.InvalidOutput) + else str(error) if str(error) in known else 'download_failed' if action == 'install' else 'ask_failed' if action == 'ask' else 'split_failed' if action == 'split' else 'rewrite_failed', - exception=type(error).__name__)) + exception=type(error).__name__, + **({'output_rejection': error.details} if isinstance(error, rules.InvalidOutput) else {}))) finally: if action != 'install': self.stop_prestart(self.prewarm_state.get('reason', 'none')) @@ -413,9 +415,10 @@ def work(self, root, machine, action, value): self.progress(dict(phase='cancelled')) self.result_expires = time.monotonic() + 600 # Allowlisted metadata only, never original/rewritten text or input paths. + # Output rejections contain condition IDs, character counts, picture numbers and retry flags only. report = {key: item for key, item in self.state.items() if key in ('phase', 'error', 'exception', 'error_details', 'timings', 'diagnostics', - 'resources', 'cleanup', 'worker_returncode')} + 'resources', 'cleanup', 'worker_returncode', 'output_rejection')} spec = assets.catalog() report.update(schema='freevideo.prompt-vlm', schema_version=2, action=action, created_epoch=time.time(), report_id=uuid.uuid4().hex, @@ -580,7 +583,13 @@ def read_rows(): elif value.get('mode') == 'split': rules.validate_parts(result.get('parts'), value) else: - rules.validate_output(result.get('text'), value) + try: + rules.validate_output(result.get('text'), value) + except rules.InvalidOutput as error: + stats = result.get('diagnostics') or {} + error.details['retried'] = bool(stats.get('picture_retry') or stats.get('dialogue_retry')) + self.progress(dict(diagnostics=stats)) + raise self.progress(dict(result, worker_returncode=child.returncode)) finally: releasing = result_received_at[0] if result_received_at else time.monotonic() diff --git a/freevideo_engine/prompt_vlm/worker.py b/freevideo_engine/prompt_vlm/worker.py index 3a2b8162..d07f2ba1 100644 --- a/freevideo_engine/prompt_vlm/worker.py +++ b/freevideo_engine/prompt_vlm/worker.py @@ -156,7 +156,11 @@ def failure(error, mode='rewrite'): code = message if message in allowed else 'out_of_memory' if 'out of memory' in message.lower() else fallback frames = [dict(module=Path(f.filename).name, function=f.name, line=f.lineno) for f in traceback.extract_tb(error.__traceback__)] - return dict(error=code, exception=type(error).__name__, error_details=frames) + result = dict(error=code, exception=type(error).__name__, error_details=frames) + from .rules import InvalidOutput + if isinstance(error, InvalidOutput): + result['output_rejection'] = error.details + return result def begin(stats): @@ -217,7 +221,7 @@ def tokenize(processor, messages, max_pixels=512 * 32 * 32): def kv_positions(input_tokens): """Cache positions reserved for a request: the input and its rewrite, then a - dialogue retry that resends both with a short correction and writes another. + correction retry that resends both with a short instruction and writes another. A retry that would need more is skipped when the model is held on the GPU.""" return input_tokens + 2 * OUTPUT_TOKENS + 256 @@ -237,7 +241,7 @@ def kv_bytes(stored, input_tokens): def retry_allowed(tokens, stats): - """A dialogue retry must fit the context limit and, on the GPU, the reserved cache.""" + """A correction retry must fit the context limit and, on the GPU, the reserved cache.""" if tokens + OUTPUT_TOKENS > stats.get('kv_positions', tokens + OUTPUT_TOKENS): stats['dialogue_retry_skipped'] = 'gpu_space' return False @@ -363,6 +367,16 @@ def run(folder, value, *, stats=None): return rewrite(folder, value, stats) +def validate_rewrite(text, value, stats, retried=False): + from .rules import InvalidOutput, validate_output + try: + return validate_output(text, value) + except InvalidOutput as error: + error.details['retried'] = retried + stats.setdefault('output_rejections', []).append(error.details) + raise + + def rewrite(folder, value, stats): stats.update(stage='imports', pid=os.getpid(), image_count=len(value['media']), mode=value['mode'], dtype='bfloat16', attention='sdpa', kv_cache='dynamic', @@ -370,7 +384,7 @@ def rewrite(folder, value, stats): persistent_model=False) torch, checkpoint = begin(stats) from transformers import AutoProcessor - from .rules import DIALOGUE_RETRY, instruction, language, missing_lines, user_text, validate_output + from .rules import DIALOGUE_RETRY, InvalidOutput, instruction, language, missing_lines, user_text checkpoint('preprocess') preparing = time.monotonic() processor = AutoProcessor.from_pretrained(folder, local_files_only=True, trust_remote_code=False) @@ -405,23 +419,51 @@ def on_token(timing): raise ValueError('context_length') text = processor.decode(ids, skip_special_tokens=True) missing = missing_lines(text, value['text']) - if missing: - # One correction turn when the user's dialogue was dropped; keep the better draft. - retry = messages + [{'role': 'assistant', 'content': text}, {'role': 'user', 'content': DIALOGUE_RETRY + '\n'.join( - '- [%s] %s' % (language(line), line) for line in missing)}] + rewritten, rejected = None, None + pictures = [] + try: + rewritten = validate_rewrite(text, value, stats) + except InvalidOutput as error: + rejected = error + if error.details['condition'] == 'missing_picture_reference': + pictures = error.details['missing_pictures'] + if missing or pictures: + # Both corrections share one turn and the existing context/cache allowance. + correction = '' + if pictures: + labels = ', '.join(f'' for i in pictures) + correction = (f'Your rewrite omitted these required image references: {labels}. ' + 'Preserve all existing content and add each missing reference where it belongs. ' + 'Return the complete prompt in the same required section format.\n') + if missing: + correction += DIALOGUE_RETRY + '\n'.join('- [%s] %s' % (language(line), line) for line in missing) + retry = messages + [{'role': 'assistant', 'content': text}, {'role': 'user', 'content': correction}] retry_inputs = tokenize(processor, retry) if retry_allowed(retry_inputs.input_ids.shape[-1], stats): again, _ = generate(torch, model, retry_inputs.to(device), 1800, time.monotonic(), lambda timing: None) second = processor.decode(again, skip_special_tokens=True) - stats.update(dialogue_retry=True, retry_output_tokens=len(again), - rewrite_seconds=time.monotonic() - loaded) + stats.update(retry_output_tokens=len(again), rewrite_seconds=time.monotonic() - loaded) + if missing: + stats['dialogue_retry'] = True + if pictures: + stats['picture_retry'] = True try: - validate_output(second, value) - if len(again) < 1800 and len(missing_lines(second, value['text'])) < len(missing): - text = second - except ValueError: - pass - rewritten = validate_output(text, value) + candidate = validate_rewrite(second, value, stats, retried=True) + if pictures and len(again) >= 1800: + error = InvalidOutput('output_token_limit', second, value) + error.details['retried'] = True + stats['output_rejections'].append(error.details) + raise error + if len(again) < 1800 and (pictures or len(missing_lines(second, value['text'])) < len(missing)): + rewritten = candidate + except InvalidOutput: + if pictures: + raise + elif pictures: + stats['picture_retry_skipped'] = stats.get('dialogue_retry_skipped', 'context_length') + if rewritten is None: + rejected.details = dict(rejected.details, retried=bool(stats.get('picture_retry') or stats.get('dialogue_retry'))) + raise rejected stats['stage'] = 'complete' emit(phase='complete', text=rewritten, diagnostics=dict(stats)) From 1a011aaad1384eef06b86253de27d0224402f158 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 20 Oct 2026 19:18:40 +0000 Subject: [PATCH 04/58] Test the Windows setup memory guard below, at and above each threshold - Windows setup no longer stops at its first memory sample when little RAM was free at planning - Keep the full 256 MiB stop point when the Windows setup memory budget is raised - Test the running Windows setup monitor with a budget planned as zero --- freevideo_engine/bootstrap.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index dd80e61e..c8f6a731 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -86,6 +86,13 @@ def setup_memory_status(row, verbose=False, *, system=None): return result +# Windows setup downloads and unpacks prebuilt wheels: a real install peaked +# near 0.93 GiB of tree private working set. The planned budget is the memory +# free when planning minus the 2 GiB reserve, so on a machine with 2 GiB or +# less free it was 0 and the first sample stopped the install. +WINDOWS_SETUP_MIN_PROCESS_BUDGET = 2 * GiB + + def setup_memory_pressure(row, budget, system): """Keep Windows planning headroom separate from an exhaustion stop. @@ -94,9 +101,18 @@ def setup_memory_pressure(row, budget, system): that same threshold prevented the low-memory path from continuing. Linux uses inference's clean-file-cache headroom, retaining the 1 GiB floor and the separate process budget. Raw cgroup charges remain recorded. + On Windows the process budget never falls below what setup itself needs; + a machine with little free RAM is stopped by the commit and system floors + when it truly runs out, not by a budget that was planned as zero. """ + planned_budget = budget + raised = system == 'Windows' and budget < WINDOWS_SETUP_MIN_PROCESS_BUDGET + if raised: + budget = WINDOWS_SETUP_MIN_PROCESS_BUDGET reserve = (2 if system == 'Windows' else 1) * GiB - floor = min(256 * 2**20, int(budget * .05)) if system == 'Windows' else reserve + # A raised budget keeps the full 256 MiB stop point: 5% of the raised + # budget would stop a machine with little free RAM later than any other. + floor = (256 * 2**20 if raised else min(256 * 2**20, int(budget * .05))) if system == 'Windows' else reserve available = (inference_headroom(row) if system == 'Linux' else row.get('effective_available_bytes', row['system_available_bytes'])) commit = row.get('system_commit_available_bytes') @@ -108,7 +124,7 @@ def setup_memory_pressure(row, budget, system): reasons.append('system_headroom') if used is not None and used > budget: reasons.append('process_budget') - return dict(reasons=reasons, working_bytes=used, budget_bytes=budget, + return dict(reasons=reasons, working_bytes=used, budget_bytes=budget, planned_budget_bytes=planned_budget, guard_metric=row.get('guard_metric', 'process working memory'), available_bytes=available, physical_available_bytes=row.get('system_physical_available_bytes'), commit_available_bytes=commit, emergency_floor_bytes=floor, From 2c03c29204f81be7e20092d3b6778b3d81a4cac5 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 20 Oct 2026 19:18:42 +0000 Subject: [PATCH 05/58] Bound generation lease waits and classify engine contention - Do not read an internal review file from the engine-busy test - Drop the internal review reference from the engine-busy comments - Final engine-busy wording --- freevideo_engine/failure_details.py | 17 +++++++++ freevideo_engine/locking.py | 55 +++++++++++++++++++---------- freevideo_engine/managed.py | 6 ++-- web/error_panel.js | 4 +++ 4 files changed, 61 insertions(+), 21 deletions(-) diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index 407d905b..920a1daf 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -6,6 +6,7 @@ import sys from .long_paths import io_path, plain_text +from .locking import ENGINE_BUSY from .diagnostics import Redactor from .tiers import STEP_CACHES, TIERS, on_mac, table_level from .two_pass import SCALES @@ -13,6 +14,11 @@ STEP_OUTPUT = '\n\nFull step output:\n' QUALITY_LEVELS = (('Light', '轻量'), ('Medium', '标准'), ('High', '精细'), ('Max', '极致')) # tiers.TIERS order +ENGINE_BUSY_TITLE = ('FreeVideo is busy with another task', 'FreeVideo 正在处理另一个任务') +ENGINE_BUSY_DETAIL = ( + 'Try again when it finishes. If nothing else is running, close duplicate FreeVideo instances, then try again.', + '请在它完成后重试。若没有其他任务在进行,请关闭重复运行的 FreeVideo 后重试。', +) IMAGE_PACK_FAILURES = { @@ -354,6 +360,12 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep if not value: return dict(title='', detail='', action='', kind='') text = _without_ready_curl_diagnostics(str(value)) + # Match only the cause position, never an echoed input or a log line below it. + causes = [line.strip() for line in text.splitlines() if line.strip()] + index = 1 if causes and re.fullmatch(r'FreeVideo generation failed(?: \(exit -?\d+\))?', causes[0]) else 0 + cause = re.sub(r'^(?:[A-Za-z_][\w.]*(?:Error|Exception)):\s*', '', causes[index]) if len(causes) > index else '' + if cause == ENGINE_BUSY: + return dict(title=ENGINE_BUSY_TITLE[zh], detail=ENGINE_BUSY_DETAIL[zh], action='', kind='engine-busy') from .offline_errors import alias_failure, guidance if alias_failure(text): return dict(title=guidance('title', zh), detail=guidance('runtime-alias', zh), @@ -707,6 +719,11 @@ def generation_failure(run, exit_code=None, *, repair=None): lines = ['FreeVideo generation failed' + (' (exit %s)' % exit_code if exit_code is not None else '')] report = _report(run) redactor.structured(report) # Collect prompt values without showing the request. + # Read the whole bounded log, not a tail that could hide other output. + # A legacy managed failure has exactly this one nonempty line and exit 1. + if exit_code == 1 and [line for line in _read(run / 'generate.log', 8192).splitlines() + if line.strip()] == [ENGINE_BUSY]: + lines.append(ENGINE_BUSY) # What the worker found when cuDNN refused its own files, ahead of the trace. explained = _runtime_explanation(report, REPAIR_MARK) if explained: diff --git a/freevideo_engine/locking.py b/freevideo_engine/locking.py index 9110dd81..9524bc6f 100644 --- a/freevideo_engine/locking.py +++ b/freevideo_engine/locking.py @@ -6,10 +6,12 @@ occupied an entire multi-GPU host. """ from contextlib import contextmanager +import math import os from pathlib import Path import re import sys +import time from .paths import data_root @@ -17,6 +19,7 @@ from .processes import inherited_fd LOCK_PATH = Path(os.environ.get('FREEVIDEO_LOCK_PATH', data_root() / 'engine.lock')) LOCK_ENV = 'FREEVIDEO_RUNTIME_LOCK_FD' +ENGINE_BUSY = 'Setup, testing or generation is already using this installation/GPU lock. Retry after it finishes.' def lock_holders(paths): @@ -56,7 +59,10 @@ def device_lock_path(device, path=None): @contextmanager -def runtime_lock(path=None, *, inherit=True, shared=False): +def runtime_lock(path=None, *, inherit=True, shared=False, wait_seconds=0): + """Take a lease, optionally waiting a bounded time for acquisition only.""" + if not math.isfinite(wait_seconds) or wait_seconds < 0: + raise ValueError('Runtime lock wait must be finite and nonnegative') path = Path(path or os.environ.get('FREEVIDEO_LOCK_PATH', data_root() / 'engine.lock')) path.parent.mkdir(parents=True, exist_ok=True) inherited = inherited_fd() if inherit else None @@ -71,23 +77,36 @@ def runtime_lock(path=None, *, inherit=True, shared=False): try: # An inherited descriptor shares the parent's open-file description. # Close our descriptor on exit; do not unlock the parent's live work. - if windows(): - if inherited is None: - import msvcrt - if path.stat().st_size == 0: - stream.write(b'\0') - stream.flush() - stream.seek(0) - try: - msvcrt.locking(stream.fileno(), - msvcrt.LK_NBRLCK if shared else msvcrt.LK_NBLCK, 1) - except OSError as error: - raise BlockingIOError('Another process holds the installation lease') from error - # The parent holds the byte-range lock; its Job Object owns this - # worker tree. Closing a duplicated child handle must not unlock it. - else: - import fcntl - fcntl.flock(stream, (fcntl.LOCK_SH if shared else fcntl.LOCK_EX) | fcntl.LOCK_NB) + deadline = time.monotonic() + wait_seconds + while True: + try: + if windows(): + if inherited is None: + import msvcrt + if path.stat().st_size == 0: + stream.write(b'\0') + stream.flush() + stream.seek(0) + try: + msvcrt.locking(stream.fileno(), + msvcrt.LK_NBRLCK if shared else msvcrt.LK_NBLCK, 1) + except OSError as error: + raise BlockingIOError('Another process holds the installation lease') from error + # The parent holds the byte-range lock; its Job Object owns this + # worker tree. Closing a duplicated child handle must not unlock it. + else: + import fcntl + fcntl.flock(stream, (fcntl.LOCK_SH if shared else fcntl.LOCK_EX) | fcntl.LOCK_NB) + break + except BlockingIOError: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise + time.sleep(min(0.075, remaining)) + # A delayed wakeup must not start another attempt after budget. + if time.monotonic() >= deadline: + raise + # The body is outside the retry loop: a failed request must never rerun. yield stream.fileno() finally: # Releasing the lease must not discard completed work. Taking it still diff --git a/freevideo_engine/managed.py b/freevideo_engine/managed.py index 7954b116..25ef5a8a 100644 --- a/freevideo_engine/managed.py +++ b/freevideo_engine/managed.py @@ -9,7 +9,7 @@ from .agent import enabled as agent_enabled from .bootstrap import DEFAULT_ROOT from . import __version__ -from .locking import runtime_lock, LOCK_ENV +from .locking import runtime_lock, LOCK_ENV, ENGINE_BUSY from . import processes from .system import windows @@ -117,7 +117,7 @@ def _main(argv=None): or (command == 'doctor' and '--probe' not in tail) or '--help' in tail or '-h' in tail) snapshot = json.loads(config.read_text(encoding='utf-8')) lock_path = os.environ.get('FREEVIDEO_LOCK_PATH', str(Path(snapshot['root']) / 'engine.lock')) - with nullcontext() if readonly else runtime_lock(lock_path) as descriptor: + with nullcontext() if readonly else runtime_lock(lock_path, wait_seconds=3 if command == 'generate' else 0) as descriptor: machine = json.loads(config.read_text(encoding='utf-8')) if machine != snapshot: raise ValueError('Installation changed while starting. Rerun the command.') @@ -140,7 +140,7 @@ def main(argv=None): try: return _main(argv) except BlockingIOError: - print('Setup, testing or generation is already using this installation/GPU lock. Retry after it finishes.', file=sys.stderr) + print(ENGINE_BUSY, file=sys.stderr) return 1 except KeyboardInterrupt: print('Cancelled. Workers stopped; existing outputs and reports retained.', file=sys.stderr) diff --git a/web/error_panel.js b/web/error_panel.js index c268f973..cab18bc3 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -12,6 +12,10 @@ const record = value => value && typeof value === 'object' && !Array.isArray(val // The engine's own messages for errors people can fix themselves. const PLAIN_ERRORS = [ + {key: 'engine-busy', message: 'Setup, testing or generation is already using this installation/GPU lock. Retry after it finishes.', + title: ['FreeVideo is busy with another task', 'FreeVideo 正在处理另一个任务'], + summary: ['Try again when it finishes. If nothing else is running, close duplicate FreeVideo instances, then try again.', + '请在它完成后重试。若没有其他任务在进行,请关闭重复运行的 FreeVideo 后重试。']}, {key: 'continuation-missing', message: 'The previous video has no usable continuation data. Generate it again with this version of FreeVideo.', title: ['This video cannot be continued', '这个视频暂时不能接续'], summary: ['Generate the previous video again, then continue it.', '请重新生成上一段视频,再接续。']}, From 0c361b3bf7c8335024a3fcbbcffbe0e1e8870012 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 20 Oct 2026 19:18:43 +0000 Subject: [PATCH 06/58] Give the failure card a fix for first/last frames combined with references - Shorten the mixed-media failure card hint --- web/error_panel.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/error_panel.js b/web/error_panel.js index cab18bc3..817836d7 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -43,6 +43,9 @@ const PLAIN_ERRORS = [ {key: 'continuation-media', message: 'Continue from video cannot be used with these settings. Connect up to 3 pictures as references, and remove video, audio and pre-encoded inputs.', title: ['A continued part takes picture references only', '接续段只能用图片参考'], summary: ['Connect up to 3 pictures as references, and remove video, audio and pre-encoded inputs.', '最多接 3 张图片作为参考图,并移除视频、音频参考和预编码输入。']}, + {key: 'media-mixed-layout', message: 'Choose keyframes or experimental references; mixed layouts are not supported', + title: ['First and last frames cannot be used with references', '首尾帧不能和参考素材同时使用'], + summary: ['Turn off the media cards or inputs you are not using, then generate again.', '请关闭本次不用的素材卡片或输入,再重新生成。']}, {key: 'continuation-frames', message: 'Frame count must be 5 + 17n and at least 39 (39, 56, 73, 90, ...).', aliases: ['Frames must be 0 to use Duration, or 5 + 17n and at least 39 (for example, 39, 56, 141 or 243). The total includes 22 overlap frames when continuing.'], title: ['Choose a supported frame count', '请选择支持的帧数'], From 03b0bf73334a1ddc7cff8d5b4e513ff02d030975 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 20 Oct 2026 19:18:45 +0000 Subject: [PATCH 07/58] Tell the user when a full media list stops Generate with AI - Update the over-limit wording the media page check expects - Use the reviewed wording for the full media list notice --- web/freevideo.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/freevideo.js b/web/freevideo.js index d8ca0461..a92283ca 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -229,7 +229,8 @@ function mediaPanel(node, mount = null) { finally { node.isUploading = false; add.disabled = false; addAudio.disabled = false; } } async function newImage() { - if (node.isUploading || read().length >= 32) return; + if (node.isUploading) return; + if (read().length >= 32) { message(text('The media list is full (32 items, including disabled ones). Remove some to add more.', '素材已满 32 个(含未启用的),请先移除不用的素材。'), true); return; } const library = read().filter(row => mediaKind(row.file) === 'image') .map(row => ({file: row.file, label: (row.source || row.file).split('/').pop()})); let outcome; @@ -237,7 +238,8 @@ function mediaPanel(node, mount = null) { outcome = await openImageEditor({target: videoSize(), role: role.value, t: text, resolve: viewURL, upload: uploadOne, library}); } catch { message(text('This image cannot be edited right now; choose an image again.', '图片暂时无法编辑,可重新选择图片。'), true); return; } - if (!outcome?.blob || read().length >= 32) return; + if (!outcome?.blob) return; + if (read().length >= 32) { message(text('The media list is full (32 items, including disabled ones). Remove some to add more.', '素材已满 32 个(含未启用的),请先移除不用的素材。'), true); return; } node.isUploading = true; add.disabled = addAudio.disabled = generateImage.disabled = true; try { const result = await uploadOne(new File([outcome.blob], outcome.name, {type: outcome.blob.type})); From 686b516e2ac85b725ece61c5ce8ae3cd622c3b48 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 20 Oct 2026 19:19:05 +0000 Subject: [PATCH 08/58] Cover the host-graph copy fallback in the creation panel, editor entry, queue and launcher tests - Creation panel and editor requests copy the host graph even when it holds a value structuredClone refuses --- web/editor.js | 4 ++-- web/editor_entry.js | 4 ++-- web/launcher.js | 5 +++-- web/optional_inputs.js | 11 +++++++++++ web/studio.js | 4 ++-- web/studio_queue.js | 3 ++- 6 files changed, 22 insertions(+), 9 deletions(-) diff --git a/web/editor.js b/web/editor.js index e89323c9..ffcb4365 100644 --- a/web/editor.js +++ b/web/editor.js @@ -32,7 +32,7 @@ import {createFirstRunGuide} from './first_run_guide.js'; import {SHOWCASE_SCENE} from './entry_showcase.js'; import {createEditorFailures} from './editor_failure.js'; import {fitEditorHeader} from './editor_header_fit.js'; -import {optionalText} from './optional_inputs.js'; +import {optionalText, clonePrompt} from './optional_inputs.js'; import {outputNotWritable} from './error_panel.js'; import {repairAction} from './repair_hint.js'; import {expandCast} from './cast_mention.js'; @@ -504,7 +504,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given exportButton.disabled = false; exportButton.title = ''; } async function snapshot() { - let value = panel.snapshot ? structuredClone(await panel.snapshot()) : structuredClone(await app.graphToPrompt()); + let value = panel.snapshot ? clonePrompt(await panel.snapshot()) : clonePrompt(await app.graphToPrompt()); if (value.output) { const node = String(panel.node?.id || panel.node || Object.keys(value.output).find(id => value.output[id].class_type === 'FreeVideoGenerate') || ''); value = {node, prompt: value.output, extra_data: {extra_pnginfo: {workflow: value.workflow || {}}}}; diff --git a/web/editor_entry.js b/web/editor_entry.js index 0a576e25..e976249c 100644 --- a/web/editor_entry.js +++ b/web/editor_entry.js @@ -3,7 +3,7 @@ import {openEditor} from './editor.js'; import {api} from '../../scripts/api.js'; import {app} from '../../scripts/app.js'; import {openStudio, showNodes} from './studio.js'; -import {cleanOptionalPrompt} from './optional_inputs.js'; +import {cleanOptionalPrompt, clonePrompt} from './optional_inputs.js'; import {openAutopilot, viewChanged} from './view_navigation.js'; import {mediaReferences} from './editor_media_references.js'; import {turnNodeOn} from './studio_queue.js'; @@ -18,7 +18,7 @@ function editorPanel(node) { // 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; } + try { value = cleanOptionalPrompt(clonePrompt(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/launcher.js b/web/launcher.js index 5fa58417..ed1eafac 100644 --- a/web/launcher.js +++ b/web/launcher.js @@ -3,6 +3,7 @@ import { api } from '../../scripts/api.js'; import { restorePromptDraft } from './prompt_draft.js'; import { takePortMarker } from './port_notice.js'; import { launcherRestoreFinished } from './workspace_restore.js'; +import { clonePrompt } from './optional_inputs.js'; // health.js names what failed; files loaded before it queue their reports. const health = item => (window.__freevideoHealth ||= []).push(item); @@ -93,7 +94,7 @@ async function existingStudioWorkflow(target, bundled) { // Only the shared tab is ours to replace; a workflow the user saved under another name stays. const current = target.path === 'workflows/FreeVideo.json' ? currentStudioWorkflow(saved, bundled) : null; if (current) return restorePromptDraft(api, current, target.path); - const graph = structuredClone(saved); + const graph = clonePrompt(saved); if (!graph.nodes?.some(node => node.type === 'FreeVideoGenerate')) { const node = structuredClone(bundled.nodes.find(node => node.type === 'FreeVideoGenerate')); node.id = Math.max(graph.last_node_id || 0, ...graph.nodes.map(node => Number(node.id) || 0)) + 1; @@ -154,7 +155,7 @@ async function keepInFront(workflow, opened) { if (studioWorkflow(app.graph)) { const current = app.graph.serialize(), target = store?.activeWorkflow; const replaced = target?.path === 'workflows/FreeVideo.json' ? currentStudioWorkflow(current, workflow) : null; - const restored = await restorePromptDraft(api, replaced || structuredClone(current), target?.path || '__existing__'); + const restored = await restorePromptDraft(api, replaced || clonePrompt(current), target?.path || '__existing__'); if (JSON.stringify(restored) !== JSON.stringify(current)) await app.loadGraphData(restored, true, true, target || 'FreeVideo'); return; diff --git a/web/optional_inputs.js b/web/optional_inputs.js index 7f3404a1..0cbd4f1a 100644 --- a/web/optional_inputs.js +++ b/web/optional_inputs.js @@ -52,3 +52,14 @@ export function cleanOptionalPrompt(prompt) { } return prompt; } + +// A graph taken from the host can carry a value structuredClone refuses, such as a reactive proxy another +// extension left in the workflow's extra data. The request is JSON on the wire, so when the native copy +// fails the JSON copy stands in for it; a graph that clones natively is copied exactly as before. +export function clonePrompt(value) { + try { return structuredClone(value); } + catch (error) { + if (error?.name !== 'DataCloneError') throw error; + return JSON.parse(JSON.stringify(value)); + } +} diff --git a/web/studio.js b/web/studio.js index 0e686187..ee0248b0 100644 --- a/web/studio.js +++ b/web/studio.js @@ -2,7 +2,7 @@ import {createLanAccess} from './lan_access.js'; import { createErrorPanel, errorText, createErrorReport } from './error_panel.js'; import {topDialog} from './dialog_keys.js'; import {createExpandButton} from './editor_expand.js'; -import {cleanOptionalPrompt} from './optional_inputs.js'; +import {cleanOptionalPrompt, clonePrompt} from './optional_inputs.js'; import { app } from '../../scripts/app.js'; import { api } from '../../scripts/api.js'; import { openSetup } from './setup.js'; @@ -1697,7 +1697,7 @@ export function openStudio(node, {extendFrom = null} = {}) { // and all GPU execution leave the next draft fully editable. for (const section of controls.querySelectorAll('.fv-section')) section.inert = true; held = turnNodeOn(node); - const snapshot = singleRunSnapshot(extendSnapshot(cleanOptionalPrompt(structuredClone(await app.graphToPrompt())), node.id, source, + const snapshot = singleRunSnapshot(extendSnapshot(cleanOptionalPrompt(clonePrompt(await app.graphToPrompt())), node.id, source, {multiple: lengthPlan?.count > 1}), node.id, lengthPlan); if (!snapshot) { fault(t('Could not prepare this run. Try again.', '没能准备这次生成,请重试。')); return; } const seedIndex = node.widgets.findIndex(w => w.name === 'seed'); diff --git a/web/studio_queue.js b/web/studio_queue.js index 75802379..3d3ffad8 100644 --- a/web/studio_queue.js +++ b/web/studio_queue.js @@ -1,7 +1,8 @@ // ComfyUI owns execution. This controller only submits frozen workflows and // replenishes a loop after its previous request has actually completed. import {fetchQueueRead, detectBrowserExtension, isNetworkTypeError} from './browser_extension.js'; -const clone = value => structuredClone(value); +import {clonePrompt} from './optional_inputs.js'; +const clone = clonePrompt; // One dropped read is usually a connection reset or an engine restart. Only this many // polls failing in a row are shown; the next successful read clears the notice. From 756f8fd8b745ce08c1f0e3b6d69bd22ccaa920c7 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 20 Oct 2026 05:26:09 +0000 Subject: [PATCH 09/58] Name the stage a failed video worker stopped in, not the last planning snapshot When the report carried no phase, the failure text took the label of the last resource-planning snapshot as the stage, and the feedback form read "after-encoding" as the encoding stage. The stage now comes from the failed attempt itself, "generation" when it is not known, and planning labels are shown only in the snapshot heading and never matched as a stage. --- freevideo_engine/failure_details.py | 28 +++++++++++++++++++++++++--- web/failure_issue.js | 4 +++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index 920a1daf..faa352a8 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -678,6 +678,20 @@ def _checkpoint_change(report): return '' +# Labels of the resource-planning snapshots (generate.py), never stages of the run. +SNAPSHOT_LABEL = re.compile(r'(?:before|after)-encoding|retry-\d+') + + +def _attempt_phase(report): + """The stage the last failed video attempt stopped in, or ''.""" + attempts = report.get('resource_attempts') + for row in reversed(attempts) if isinstance(attempts, list) else (): + phase = row.get('phase') if isinstance(row, dict) and row.get('failure') else None + if isinstance(phase, str) and re.fullmatch(r'[a-z_]{1,32}', phase) and phase not in ('unknown', 'complete'): + return phase + return '' + + # Stands in for the repair sentence until the report text is redacted: the # redactor reads "Install / repair" as a path. REPAIR_MARK = 'FREEVIDEO_REPAIR_SENTENCE' @@ -738,13 +752,21 @@ def generation_failure(run, exit_code=None, *, repair=None): encoding_failure = report.get('encoding_failure') phase = encoding_failure.get('phase') if isinstance(encoding_failure, dict) else None phase = phase or report.get('phase') + snapshot = None + if isinstance(planning, list) and planning and isinstance(planning[-1], dict): + snapshot = planning[-1].get('stage') + if isinstance(phase, str) and SNAPSHOT_LABEL.fullmatch(phase): + # A planning label is where the resources were last read, not where the run stopped. + snapshot, phase = snapshot or phase, None + phase = phase or _attempt_phase(report) + if not phase and (snapshot or report.get('resource_attempts')): + phase = 'generation' if phase: lines.append('Stage: ' + str(phase)) if isinstance(planning, list) and planning and isinstance(planning[-1], dict): last = planning[-1] - if not phase: - lines.append('Stage: ' + str(last.get('stage', 'unknown'))) - lines.append('\nLast resource-planning snapshot (before this failure; not the stopping sample):') + lines.append('\nLast resource-planning snapshot (%s, before this failure; not the stopping sample):' + % (re.sub(r'[^a-z0-9-]', '', str(snapshot))[:32] or 'unlabeled')) state = last.get('idle_cache') or {} accounting = state.get('ram_accounting', {}) if isinstance(state, dict) else {} if not isinstance(accounting, dict): diff --git a/web/failure_issue.js b/web/failure_issue.js index 069d5a49..26a7a0dd 100644 --- a/web/failure_issue.js +++ b/web/failure_issue.js @@ -13,8 +13,10 @@ const PHASES = [ export function failureStage(report) { if (report?.stage === 'submission') return ['submitting the task', '提交任务']; if (report?.stage === 'queue') return ['queue operation', '队列操作']; + // A resource-planning snapshot label says where the resources were last read, not where the run stopped. const named = [report?.progress?.phase, report?.progress?.stage, - /^Stage: (\S+)/m.exec(String(report?.details || ''))?.[1]].filter(Boolean); + /^Stage: (\S+)/m.exec(String(report?.details || ''))?.[1]] + .filter(name => name && !/^(?:before|after)-encoding$|^retry-\d+$/.test(name)); for (const name of named) for (const [pattern, words] of PHASES) if (pattern.test(name)) return words; return null; From 12e8a9b7b90ad1443e96f6f95fbbcf397f4c3435 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 20 Oct 2026 19:19:10 +0000 Subject: [PATCH 10/58] Chat: stop key while waiting for a reply and a stage line beside the dots The stop key has its own style, the stage line uses its final wording, and a failed stop has its own sentence. --- freevideo_engine/agent/errors.py | 1 + freevideo_engine/agent/guide.py | 1 + freevideo_engine/agent/routes.py | 31 +++++++++++++++++++++ freevideo_engine/agent/schema.py | 13 ++++++--- freevideo_engine/agent/sessions.py | 43 ++++++++++++++++++++++++++++-- web/autopilot_chat.css | 2 ++ web/autopilot_chat.js | 43 +++++++++++++++++++++++++----- web/chat_data.js | 6 +++-- 8 files changed, 126 insertions(+), 14 deletions(-) diff --git a/freevideo_engine/agent/errors.py b/freevideo_engine/agent/errors.py index 9ec75f8b..b621f3e7 100644 --- a/freevideo_engine/agent/errors.py +++ b/freevideo_engine/agent/errors.py @@ -89,6 +89,7 @@ 'needs_confirmation': (409, False, 'The user must approve this operation first.', 'Wait with freevideo_wait (no id) for the user\'s answer; once approved, repeat the same call unchanged.'), 'declined_by_user': (409, False, 'The user declined this operation.', 'Do not repeat this call; change the plan or ask the user what they want instead.'), 'stopped_by_user': (409, False, 'The user stopped this session.', 'Wait for the user to continue the session; do not retry or start a new one.'), + 'turn_abandoned': (409, False, 'The user gave up on this turn.', 'Stop working on it. Wait for the next message from the user; do not retry.'), 'reshoot_limit': (409, False, 'This shot reached its generation limit.', 'Choose the best existing take, rewrite the shot, or ask the user.'), 'production_limit': (409, False, 'This film reached its child job limit.', 'Choose existing takes or ask the user how to reduce the film.'), 'managed_by_production': (409, False, 'This job belongs to a film and cannot be resumed on its own.', 'This take belongs to a film; reshoot the shot or wait for the user to continue.'), diff --git a/freevideo_engine/agent/guide.py b/freevideo_engine/agent/guide.py index e5dabc94..e051ce29 100644 --- a/freevideo_engine/agent/guide.py +++ b/freevideo_engine/agent/guide.py @@ -355,6 +355,7 @@ ('POST', '/sessions/{id}/events'): '向用户发送消息或计划;操作步骤由服务端记录', ('POST', '/sessions/{id}/stop'): '只有页面用户可以停止工作会话', ('POST', '/sessions/{id}/resume'): '只有页面用户可以继续工作会话', + ('POST', '/sessions/{id}/stage'): '说明你正在做回答的哪个阶段;页面用自己的话显示,你开口或开始任务后自动清除', ('GET', '/confirmations'): '查看哪些 Agent 操作需要等待用户确认', ('PUT', '/confirmations'): '只有页面用户可以修改需要确认的操作', } diff --git a/freevideo_engine/agent/routes.py b/freevideo_engine/agent/routes.py index ca03c42d..2947198d 100644 --- a/freevideo_engine/agent/routes.py +++ b/freevideo_engine/agent/routes.py @@ -995,6 +995,8 @@ def read(): or (task_view.get('result') or {}).get('report_id')) value['failures'][identity] = dict(id=identity, kind=kind, status='failed', label=entity.get('title') or entity.get('name'), error=errors.with_reason(error), report_id=report_id) + value['abandoned'] = store.abandoned(sid) + value['stage'] = store.stage(sid) value['current'] = current['id'] if current else None if current and current['id'] not in value['jobs']: value['jobs'][current['id']] = chat_card(current, fold) @@ -1051,6 +1053,20 @@ def write(): request_id=errors.request_id())}, 409) return reply(done[1], done[0]) + @routes.post('/freevideo/agent/chat/abandon') + @guarded + async def chat_abandon(request): + value = await body(request) + def write(): + store = work_sessions() + sid = chat_session(store, value.get('session')) + if sid is None: + raise JobError('not_found', 'No conversation') + if not store.abandoned(sid): + store.append(sid, dict(type='control', action='abandoned'), by='user') + return dict(abandoned=True) + return reply(await asyncio.to_thread(write)) + @routes.post('/freevideo/agent/chat/confirm') @guarded async def chat_confirm(request): @@ -1493,6 +1509,8 @@ async def session_post(request): raise JobError('forbidden', 'Only the page user may stop or continue') if value['action'] == 'stopped': return reply(await stop_session(sid), 201) + if by == 'agent' and value['type'] in ('message', 'plan') and sessions.abandoned(sid): + raise JobError('turn_abandoned', 'The user gave up on this turn') if by == 'agent' and value['type'] == 'message' and isinstance(value.get('text'), str): value['text'] = chat_text(value['text']) client = session_client(request, by) @@ -1533,6 +1551,19 @@ async def session_resume(request): return reply(await asyncio.to_thread(sessions.append, request.match_info['id'], dict(type='control', action='resumed'), by='user')) + @routes.post(PREFIX + '/sessions/{id}/stage') + @guarded + async def session_stage(request): + if session_caller(request) == 'user': + raise JobError('forbidden', 'Only the agent reports a stage') + sid, value = request.match_info['id'], await body(request) + def write(): + if sessions.abandoned(sid): + raise JobError('turn_abandoned', 'The user gave up on this turn') + sessions.report_stage(sid, value['stage']) + return dict(stage=value['stage']) + return reply(await asyncio.to_thread(write)) + def publish_media(work, suffix): from . import media with tempfile.TemporaryDirectory(prefix='.agent-stage-', dir=port.input_root) as temporary: diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py index 5a88c66d..8ef43f48 100644 --- a/freevideo_engine/agent/schema.py +++ b/freevideo_engine/agent/schema.py @@ -50,6 +50,9 @@ def enum(values, **kw): JOB = string(13, pattern=r'^j[0-9a-f]{12}$') SESSION = string(13, pattern=r'^s[0-9a-f]{12}$') +# What an agent may say it is doing before it answers. The page owns the words for each key. +STAGES = ('reading', 'scripting', 'storyboarding', 'prompting', 'checking', 'assembling') +STAGE = enum(STAGES, description='The stage of your answer. The page shows its own wording for it.') PRODUCTION = string(13, pattern=r'^d[0-9a-f]{12}$') CAST = string(13, pattern=r'^c[0-9a-f]{12}$') PROJECT = string(34, pattern=r'^e-[0-9a-f]{32}$') @@ -439,10 +442,11 @@ def route(summary, body=None, query=None, **kw): ('/chat/video', 'GET', None), ('/chat/list', 'GET', None), ('/chat/new', 'POST', EMPTY), ('/chat/delete', 'POST', obj({'session': SESSION, 'stop': {'type': 'boolean'}}, ('session',))), ('/chat', 'POST', obj({'text': string(8000), 'id': CHAT_ID, 'session': SESSION, 'whitebox': {'type': 'boolean'}}, ('text', 'id'))), + ('/chat/abandon', 'POST', obj({'session': SESSION})), ('/chat/confirm', 'POST', obj({'id': string(12, pattern=r'^cf[0-9a-f]{10}$'), 'approve': {'type': 'boolean'}, 'session': SESSION}, ('id', 'approve')))): routes[(method, '/freevideo/agent' + suffix)] = route('Studio Agent controls', body=body, auth=False, local=True, - lan=suffix in ('/chat', '/chat/now', '/chat/confirm', '/chat/list', '/chat/new', '/chat/video', + lan=suffix in ('/chat', '/chat/now', '/chat/confirm', '/chat/abandon', '/chat/list', '/chat/new', '/chat/video', '/activity', '/activity/cancel')) # These existing ComfyUI handlers are registered outside the Agent dispatcher. # /chain is a creation POST; monitoring reads /chains and /chain/{id}. @@ -800,9 +804,10 @@ def image_schema(name): 'post': ('POST', '/sessions/{id}/events', 'Append a message or plan for the user. Steps are recorded by the server.'), 'stop': ('POST', '/sessions/{id}/stop', 'Only the page user may stop a work conversation.'), 'resume': ('POST', '/sessions/{id}/resume', 'Only the page user may continue a work conversation.'), + 'stage': ('POST', '/sessions/{id}/stage', 'Say which stage of the answer you are working on; the page shows its own words for it. Clears when you speak or start a task.'), } SESSION_TOOLS = dict(create='freevideo_session_create', list='freevideo_sessions', - events='freevideo_session_events', post='freevideo_session_post') + events='freevideo_session_events', post='freevideo_session_post', stage='freevideo_report_stage') CONFIRM_DEFAULTS = dict(start=True, reshoot_seconds=300) @@ -852,7 +857,7 @@ def session_event(*, stored=False, agent_only=False): digest=string(64, pattern=r'^[0-9a-f]{64}$')), ('type', 'id', 'state'), **{'if': {'properties': {'state': {'const': 'asked'}}}, 'then': {'required': ['question']}}), - obj(dict(type={'const': 'control'}, action=enum(('stopped', 'resumed', 'renamed')), + obj(dict(type={'const': 'control'}, action=enum(('stopped', 'resumed', 'renamed', 'abandoned')), title=dict(type='string', maxLength=120)), ('type', 'action'), **{'if': {'properties': {'action': {'const': 'renamed'}}}, 'then': {'required': ['title']}, 'else': {'not': {'required': ['title']}}})] @@ -874,6 +879,8 @@ def session_schema(name): limit=number(1, 500, integer=True, default=500))) if name == 'post': return session_event() + if name == 'stage': + return obj(dict(stage=STAGE), ('stage',)) return EMPTY diff --git a/freevideo_engine/agent/sessions.py b/freevideo_engine/agent/sessions.py index adf49467..ae6250f6 100644 --- a/freevideo_engine/agent/sessions.py +++ b/freevideo_engine/agent/sessions.py @@ -47,6 +47,9 @@ def _line(value): return json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(',', ':')).encode('utf-8') +STAGE_SECONDS = 120 + + class Sessions: def __init__(self, root: Path, *, clock=time.time, max_events=20000): self.root = Path(root) @@ -60,6 +63,8 @@ def __init__(self, root: Path, *, clock=time.time, max_events=20000): self._initial_seq = {} self._readers = {} self._user_seq = {} # Last event the user wrote; unread() skips the file when nothing is newer. + self._turn = {} # [last message the user wrote, the one they gave up on]: a turn is given up until they speak again. + self._stage = {} # What the agent last said it was doing: (key, when). Not kept across restarts. # When each session began or its user last acted in it (spoke, answered), and the user's first words # there: the conversation the user is in, and what the page calls each one in its history. self._acted = {} @@ -120,6 +125,11 @@ def _load(self): def _fold(self, sid, event): meta = self._meta[sid] meta.update(last_seq=event['seq'], updated_at=event['at']) + turn = self._turn.setdefault(sid, [0, 0]) + if event['by'] == 'user' and event['type'] == 'message': + turn[0] = event['seq'] + elif event['type'] == 'control' and event['action'] == 'abandoned': + turn[1] = turn[0] if event['by'] == 'user': self._user_seq[sid] = event['seq'] # No counter here: the order must come out the same when the files are read again after a @@ -195,7 +205,7 @@ def delete(self, sid) -> None: self._path(sid).unlink(missing_ok=True) self._closing.discard(sid) waiters = self._waiters.pop(sid, ()) - for kept in (self._meta, self._ids, self._clients, self._initial_seq, self._user_seq, self._acted, self._said): + for kept in (self._meta, self._ids, self._clients, self._initial_seq, self._user_seq, self._turn, self._stage, self._acted, self._said): kept.pop(sid, None) self._chosen.discard(sid) self._partial.discard(sid) @@ -265,6 +275,8 @@ def append(self, sid, event: dict, *, by, client=None) -> dict: raise SessionError('session_full', 'Session event limit reached') self._write(sid, result) self._fold(sid, result) + if kind in ('message', 'plan', 'step'): + self._stage.pop(sid, None) for loop, signal in self._waiters.get(sid, ()): try: loop.call_soon_threadsafe(signal.set) @@ -428,6 +440,32 @@ def stopped(self, sid) -> bool: with self.lock: return self._get(sid)['stopped'] + def abandoned(self, sid) -> bool: + """The user gave up on the turn they last spoke in, and has not spoken since.""" + with self.lock: + self._get(sid) + said, gave_up = self._turn.get(sid, (0, 0)) + return gave_up > 0 and gave_up == said + + def report_stage(self, sid, key) -> None: + _check(schema.STAGE, key) + with self.lock: + self._get(sid) + self._stage[sid] = (key, self.clock()) + + def stage(self, sid, *, within=STAGE_SECONDS) -> str | None: + """The stage the agent reported, while it is recent and the turn it belongs to is still open.""" + with self.lock: + self._get(sid) + known = self._stage.get(sid) + if known is None or self.clock() - known[1] > within: + return None + return known[0] + + def clear_stage(self, sid) -> None: + with self.lock: + self._stage.pop(sid, None) + def unread(self, sid, reader: str) -> list[dict]: _check(schema.string(80), reader) with self.lock: @@ -439,7 +477,8 @@ def unread(self, sid, reader: str) -> list[dict]: return [] rows = self._events(sid, after, self.max_events) return [row for row in rows if row['by'] == 'user' and (row['type'] == 'message' - or row['type'] == 'confirm' and row['state'] in ('approved', 'declined'))] + or row['type'] == 'confirm' and row['state'] in ('approved', 'declined') + or row['type'] == 'control' and row['action'] == 'abandoned')] class Confirmations: diff --git a/web/autopilot_chat.css b/web/autopilot_chat.css index 8e5edad3..014d7d44 100644 --- a/web/autopilot_chat.css +++ b/web/autopilot_chat.css @@ -120,6 +120,7 @@ /* The agent has the sentence and has not answered yet. */ .fv-chat-dots{display:inline-flex;gap:5px;align-items:center;height:22px} .fv-chat-dots>i,.fv-chat-dots:empty::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--fv-muted);animation:fv-chat-breathe 1.4s var(--fv-ease) infinite} +.fv-chat-thinking{display:flex;align-items:center;gap:10px;min-width:0}.fv-chat-activity{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:var(--fv-muted);font-size:12.5px}.fv-chat-activity[hidden]{display:none} .fv-chat-dots>i:nth-child(2){animation-delay:.2s}.fv-chat-dots>i:nth-child(3){animation-delay:.4s} @keyframes fv-chat-breathe{0%,100%{opacity:.25}40%{opacity:1}} .fv-studio .fv-chat-down{position:absolute;right:max(40px,calc(50% - 360px));bottom:96px;width:34px;height:34px;min-height:0;padding:0;border-radius:50%;background:var(--fv-raised);box-shadow:0 6px 18px #0008} @@ -145,6 +146,7 @@ .fv-studio .fv-chat-send{width:36px;height:36px;min-height:0;padding:0;border:0;border-radius:9px;background:var(--fv-accent);color:var(--fv-bg)} .fv-studio .fv-chat-send:hover:not(:disabled){background:var(--fv-accent-hover)} .fv-studio .fv-chat-send:disabled{opacity:1;background:#22344a;color:#9fb3c8} +.fv-studio .fv-chat-send.fv-chat-halt svg{fill:currentColor} .fv-studio .fv-chat-send:focus-visible{outline-offset:2px} @media(max-width:720px){ .fv-studio .fv-chat-conversations .fv-chat-conversation-row:has(> .fv-chat-more:not([hidden])) .fv-chat-conversation{padding-right:calc(var(--fv-chats-pad) + 36px)} diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index e9e2168d..30ccd53c 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -32,6 +32,12 @@ export const WHITEBOX_PREVIEW = Object.freeze({ }); let chatNumber = 0; +// What the agent is doing before it answers, by the key it reports. Words the page does not know are not shown. +export const STAGE_TEXT = Object.freeze({ + reading: ['Reading the request', '正在理解要求'], scripting: ['Writing the script', '正在写剧本'], + storyboarding: ['Planning the shots', '正在拆分镜头'], prompting: ['Writing the shot prompts', '正在写画面描述'], + checking: ['Checking the results', '正在检查结果'], assembling: ['Joining the shots', '正在拼接镜头'], +}); const WORKING = ['running', 'queued', 'waiting', 'cancelling'], VIDEOS = ['generate', 'assemble']; // What a job with no picture of its own is doing, in the reader's language. const QUIET = {vlm: ['Checking', '检查中'], enhance: ['Enhancing prompt', '增强提示词']}; @@ -224,6 +230,25 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const form = el('form', 'fv-chat-input'), input = el('textarea'), sendButton = el('button', 'fv-chat-send'); input.rows = 1; input.maxLength = 8000; input.setAttribute('aria-label', t('Message the agent', '对 Agent 说')); sendButton.type = 'submit'; sendButton.setAttribute('aria-label', t('Send', '发送')); sendButton.append(glyph('M12 19V5M5 12l7-7 7 7')); + // While the agent is thinking and has started nothing, the send key is the stop key: same place, a solid square. + let stopMode = false; + const SEND_GLYPH = 'M12 19V5M5 12l7-7 7 7', STOP_GLYPH = 'M7 7h10v10H7z'; + function refreshSend() { + const label = stopMode ? t('Stop', '停止') : t('Send', '发送'); + sendButton.classList.toggle('fv-chat-halt', stopMode); + sendButton.setAttribute('aria-label', label); sendButton.title = stopMode ? label : ''; + sendButton.querySelector('path').setAttribute('d', stopMode ? STOP_GLYPH : SEND_GLYPH); + sendButton.disabled = stopMode ? false : !composeAllowed() || draftSending() || !input.value.trim(); + } + async function abandon() { + const session = state.session; + if (!session || state.abandoned) return; + state = {...state, abandoned: true, stage: null}; render(); + input.focus(); + let done = false; + try { done = (await dataLayer.abandon(session)).ok; } catch {} + if (!done && state.session === session) { state = {...state, abandoned: false}; render(); say(t('Could not stop. Click again.', '没能停止,请再点一次。')); } + } let whiteboxPreview = false; try { whiteboxPreview = localStorage.getItem(WHITEBOX_PREVIEW.storageKey) === 'true'; } catch {} const whiteboxButton = press('fv-chat-whitebox', null, () => setWhiteboxPreview(!whiteboxPreview)); @@ -990,8 +1015,9 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () } function dots() { const item = el('li', 'fv-chat-thinking'), mark = el('span', 'fv-chat-dots'); mark.append(el('i'), el('i'), el('i')); - item.setAttribute('role', 'status'); item.setAttribute('aria-label', t('Thinking', '正在思考')); item.append(mark); - return {element: item, update() {}}; + const activity = el('span', 'fv-chat-activity'); + item.setAttribute('role', 'status'); item.setAttribute('aria-label', t('Thinking', '正在思考')); item.append(mark, activity); + return {element: item, update(text) { activity.textContent = text || ''; activity.hidden = !text; }}; } const MAKE = {hello, examples, dots, user: () => message('user'), agent: () => message('agent'), job: jobCard, confirm: confirmCard}; @@ -1029,8 +1055,10 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () if (!said) wanted.push(['examples', 'examples', null]); // The agent has the sentence, or is waiting for one, and has not answered yet. const tail = localPending.at(-1) || entries.at(-1); - if (tail?.type !== 'job' && tail?.type !== 'confirm' && (localPending.some(entry => !entry.error) || !tail?.error && tail?.role === 'user') && state.agent.connected === true - && (state.agent.listening !== false || tail.taken === true)) wanted.push(['dots', 'dots', null]); + const thinking = tail?.type !== 'job' && tail?.type !== 'confirm' && (localPending.some(entry => !entry.error) || !tail?.error && tail?.role === 'user') && state.agent.connected === true + && (state.agent.listening !== false || tail.taken === true) && (localPending.length > 0 || !state.abandoned); + stopMode = thinking && localPending.length === 0; + if (thinking) wanted.push(['dots', 'dots', Object.hasOwn(STAGE_TEXT, state.stage) ? t(...STAGE_TEXT[state.stage]) : null]); const stick = follow || pinned; const before = log.children.length, keep = new Set(wanted.map(([key]) => key)); @@ -1044,7 +1072,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const sendable = sendingAllowed(); input.disabled = state.agent.connected !== true || creating || !!selectedSession && selectedSession !== state.session; input.placeholder = state.agent.connected === true && !sendable ? t('Ask your agent to continue', '请在 Agent 里让它继续') : t('Message the agent…', '对 Agent 说…'); - sendButton.disabled = !composeAllowed() || draftSending() || !input.value.trim(); + refreshSend(); if (focusSession === state.session && focusSession && !input.disabled && active()) { focusSession = null; input.focus(); } if (stick) { pinned = true; scroller.scrollTop = scroller.scrollHeight; down.hidden = true; } else if (log.children.length > before) down.hidden = false; @@ -1143,7 +1171,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () // At most five lines; the box scrolls after that. const line = (typeof getComputedStyle === 'function' && parseFloat(getComputedStyle(input).lineHeight)) || 22; input.style.height = `${Math.min(input.scrollHeight, Math.round(line * 5) + 14)}px`; - sendButton.disabled = !composeAllowed() || draftSending() || !input.value.trim(); + refreshSend(); } input.addEventListener('input', () => { if (entryDraft !== null) entryDraft = input.value; @@ -1160,10 +1188,11 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () // Enter sends, Shift+Enter starts a new line; Enter that confirms an input-method candidate does neither. input.addEventListener('keydown', event => { if (event.key !== 'Enter' || event.shiftKey || event.isComposing || event.keyCode === 229) return; - event.preventDefault(); form.requestSubmit(); + event.preventDefault(); if (!stopMode) form.requestSubmit(); }); form.addEventListener('submit', event => { event.preventDefault(); + if (stopMode) { void abandon(); return; } const text = input.value; const draft = saveDraft(); if (!text.trim() || !composeAllowed() || draftSending()) return; diff --git a/web/chat_data.js b/web/chat_data.js index fe7fec75..9d510c3b 100644 --- a/web/chat_data.js +++ b/web/chat_data.js @@ -8,7 +8,7 @@ export async function sendError(response) { try { return (await (response.clone?.() || response).json())?.error || null; } catch { return null; } } -export const emptyChat = () => ({session: undefined, items: new Map(), jobs: {}, failures: {}, confirms: {}, last: 0, read: 0, agent: {}, current: null, active: 0}); +export const emptyChat = () => ({session: undefined, items: new Map(), jobs: {}, failures: {}, confirms: {}, last: 0, read: 0, agent: {}, current: null, active: 0, abandoned: false, stage: null}); // One answer from the server folded into what the page already has: entries add up by their number, jobs are // replaced by id (a card keeps its last state after the server stops listing its job), and so are the answers to its questions. export function chatMerge(state, data) { @@ -17,7 +17,8 @@ export function chatMerge(state, data) { const read = Math.max(state.read, Number(data.read_seq) || 0, ...(data.items || []).filter(item => item.read === true).map(item => item.seq)); return {session: data.session ?? null, items, jobs: {...state.jobs, ...(data.jobs || {})}, failures: data.failures || {}, confirms: {...state.confirms, ...(data.confirms || {})}, read, last: Math.max(state.last, Number(data.last_seq) || 0, ...items.keys()), agent: data.agent || {}, - current: data.current || null, active: Number(data.active) || 0}; + current: data.current || null, active: Number(data.active) || 0, + abandoned: data.abandoned === true, stage: typeof data.stage === 'string' ? data.stage : null}; } // The entries in order. A user's sentence counts as taken once the agent has read it or has said or done anything after it. export function chatEntries(state) { @@ -91,6 +92,7 @@ export function chatData(api) { create: () => agentPost(api, '/freevideo/agent/chat/new', {}), remove: (session, stop = false) => agentPost(api, '/freevideo/agent/chat/delete', stop ? {session, stop: true} : {session}), confirm: body => agentPost(api, '/freevideo/agent/chat/confirm', body), + abandon: session => agentPost(api, '/freevideo/agent/chat/abandon', session ? {session} : {}), cancel: id => retryAgentPost(api, '/freevideo/agent/activity/cancel', {id}), activity: (cursor, options) => read('/freevideo/agent/activity' + (cursor ? '?cursor=' + encodeURIComponent(cursor) : ''), options), sendMessage: (entry, options) => sendChatMessage(api, entry, options), From e020f392190eb7087d77401aa53bcca06b57f28c Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 20 Oct 2026 19:19:11 +0000 Subject: [PATCH 11/58] Cache the launcher QR rows again and resolve the LAN password module per call --- freevideo_engine/launcher_session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index a83a49fa..8c8964d2 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -46,12 +46,12 @@ -@lru_cache(maxsize=4) def _lan_password(): # Resolved per call: the access module is optional and may be absent. return importlib.import_module('.lan_password', __package__) +@lru_cache(maxsize=4) def _qr_rows(text): from .qr_code import encode try: From da279edf1100983f184f72e60044b4fd54d95941 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 20 Oct 2026 19:19:22 +0000 Subject: [PATCH 12/58] Show the failed-install update message in the interface language When a downloaded update cannot be installed and the previous version is restored, the launcher reminder now says so in Chinese or English to match the interface language, and that the current version stays usable. --- freevideo_engine/launcher_copy.py | 2 ++ freevideo_engine/launcher_session.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/freevideo_engine/launcher_copy.py b/freevideo_engine/launcher_copy.py index d5436a91..37d07145 100644 --- a/freevideo_engine/launcher_copy.py +++ b/freevideo_engine/launcher_copy.py @@ -12,6 +12,8 @@ 'installation_missing': ('There is no installation information yet. Complete installation first.', '还没有安装信息,请先完成安装'), 'checking_update': ('Checking for updates. Please wait.', '正在检查更新,请稍候'), + 'update_install_failed': ('The update could not be installed. You can keep using the current version.', + '无法安装更新。当前版本可以照常使用。'), 'update_page_failed': ('Couldn\'t open the download page. Please try again later.', '无法打开下载页,请稍后再试。'), 'busy': ('The background task is busy. You can do this when it finishes.', '后台正在忙,完成后可以操作'), diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index 8c8964d2..9ee8c5d4 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -1519,7 +1519,7 @@ def update_view(self): view['release_page'] = release_page(self.updater.current) if self.updater else '' if self.updater and self.updater.install_failed and view.get('candidate'): # The new app did not go in and the old one is back: the reminder says so. - view['status'], view['error'] = 'error', 'The update could not be installed.' + view['status'], view['error'] = 'error', self.t(*ACTION_COPY['update_install_failed']) key = self.update_key() view.update(engine=self.engine_update_pending(), current=__version__, current_release=public_details(self.updater.current) if self.updater else self.release_details, From 3d640facd839cebb2cd3dc57163738c9ec6a0dd6 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 20 Oct 2026 19:26:06 +0000 Subject: [PATCH 13/58] Use a formal, consistent register for interface text Interface text now uses one formal register throughout; only strings and the matching test assertions changed. --- .github/ISSUE_TEMPLATE/feature_request.yml | 4 +- .github/ISSUE_TEMPLATE/feedback.yml | 4 +- .github/ISSUE_TEMPLATE/report.yml | 2 +- README.zh-CN.md | 2 +- docs/Linux.zh-CN.md | 2 +- ...77\347\224\250\350\257\264\346\230\216.md" | 6 +- freevideo_engine/agent/builtin_director.py | 14 +-- freevideo_engine/agent/errors.py | 68 +++++------ freevideo_engine/agent/guide.py | 20 ++-- freevideo_engine/chain_runner.py | 4 +- freevideo_engine/comfy_launcher.py | 12 +- freevideo_engine/comfy_launcher_api.py | 4 +- freevideo_engine/desktop.py | 6 +- freevideo_engine/desktop_shortcut.py | 6 +- freevideo_engine/download_settings.py | 2 +- freevideo_engine/download_settings_ui.py | 2 +- freevideo_engine/failure_details.py | 112 +++++++++--------- freevideo_engine/image_gen/failure.py | 20 ++-- freevideo_engine/launcher/Main.qml | 96 +++++++-------- freevideo_engine/launcher_copy.py | 12 +- freevideo_engine/launcher_session.py | 14 +-- freevideo_engine/launcher_update.py | 4 +- freevideo_engine/launcher_view.py | 10 +- freevideo_engine/model_guidance.py | 6 +- freevideo_engine/model_repair.py | 20 ++-- freevideo_engine/offline_errors.py | 8 +- freevideo_engine/offline_packages.py | 2 +- freevideo_engine/page_check.py | 6 +- freevideo_engine/release_notes.json | 64 +++++----- freevideo_engine/support_report.py | 6 +- scripts/test_freevideo_entry.mjs | 6 +- web/agent_activity.js | 8 +- web/agent_connect.js | 2 +- web/autopilot_chat.js | 42 +++---- web/browser_extension.js | 6 +- web/cast_library.js | 2 +- web/editor.js | 50 ++++---- web/editor_export_dialog.js | 4 +- web/editor_genbar.js | 4 +- web/editor_library.js | 8 +- web/editor_loader.js | 2 +- web/editor_select.js | 2 +- web/editor_store.js | 2 +- web/editor_text.js | 10 +- web/editor_tracks.js | 6 +- web/error_panel.js | 34 +++--- web/feedback.js | 22 ++-- web/first_run_guide.js | 2 +- web/freevideo.js | 14 +-- web/generation_progress.js | 10 +- web/health.js | 10 +- web/howto.js | 2 +- web/image_editor.js | 20 ++-- web/image_generation.js | 32 ++--- web/library.js | 30 ++--- web/media_upload.js | 4 +- web/phone.js | 22 ++-- web/phone_model.js | 6 +- web/prompt_enhance.js | 6 +- web/prompt_references.js | 2 +- web/report_issue.js | 12 +- web/result_actions.js | 2 +- web/setup.js | 12 +- web/share.js | 4 +- web/studio.js | 46 +++---- web/studio_extend.js | 4 +- web/studio_recent.js | 2 +- web/tutorial_book_content.js | 6 +- web/updates.js | 8 +- web/view_navigation.js | 4 +- web/whitebox_scenes.js | 10 +- 71 files changed, 509 insertions(+), 509 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 93255e9f..769ad29a 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,9 +1,9 @@ name: Suggestion / 建议 -description: Tell us what would help / 说说你希望改进什么 +description: Describe the improvement you would like / 请说明希望改进的内容 body: - type: textarea id: idea attributes: - label: What would you like to do? / 你希望实现什么? + label: Desired feature / 希望实现的功能 validations: required: true diff --git a/.github/ISSUE_TEMPLATE/feedback.yml b/.github/ISSUE_TEMPLATE/feedback.yml index 7dedb9a8..7488b426 100644 --- a/.github/ISSUE_TEMPLATE/feedback.yml +++ b/.github/ISSUE_TEMPLATE/feedback.yml @@ -1,11 +1,11 @@ name: Feedback / 反馈 -description: Tell us what could be better / 说说哪里可以做得更好 +description: Describe what could be improved / 请说明哪些方面可以改进 labels: ["feedback"] body: - type: textarea id: feedback attributes: - label: What could be better? / 哪里可以做得更好? + label: Suggested improvements / 改进建议 validations: required: true - type: input diff --git a/.github/ISSUE_TEMPLATE/report.yml b/.github/ISSUE_TEMPLATE/report.yml index 26d06869..9b49e2c1 100644 --- a/.github/ISSUE_TEMPLATE/report.yml +++ b/.github/ISSUE_TEMPLATE/report.yml @@ -6,7 +6,7 @@ body: - type: markdown attributes: value: | - Thank you for submitting a diagnostic report. We'll use it to optimize FreeVideo specifically for your hardware configuration, and it helps other users too. The report contains hardware details, generation settings and timings only, with no prompts, reference images or videos. + Thank you for submitting a diagnostic report. We will use it to optimize FreeVideo specifically for your hardware configuration, and the report also helps other users. The report contains hardware details, generation settings and timings only, with no prompts, reference images or videos. 感谢您提交诊断报告。我们会据此针对您的硬件配置进行专门优化,也能帮助其他用户。报告仅包含硬件信息、生成参数和耗时,不含提示词、参考图或视频。 - type: textarea diff --git a/README.zh-CN.md b/README.zh-CN.md index 5d40f748..0267df76 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.zh-CN.md b/docs/Linux.zh-CN.md index 92cd89a7..53b21963 100644 --- a/docs/Linux.zh-CN.md +++ b/docs/Linux.zh-CN.md @@ -65,6 +65,6 @@ freevideo server --listen 0.0.0.0 ## 常见问题 -- **AppImage 打不开**:部分系统缺少 FUSE。可以加上 `--appimage-extract-and-run` 运行,或安装 `fuse3`。 +- **AppImage 无法打开**:部分系统缺少 FUSE。可以加上 `--appimage-extract-and-run` 运行,或安装 `fuse3`。 - **ComfyUI 没有启动**:运行 `freevideo status` 查看上次的错误。日志在安装目录的 `launcher` 文件夹中(`service.log`,ComfyUI 本身的日志在 `comfy-runs`)。 - **端口被占用**:有其他程序在使用该端口,可以用 `freevideo open --port 8190` 换一个端口。 diff --git "a/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md" "b/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md" index 12580964..8cc7dbc6 100644 --- "a/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md" +++ "b/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md" @@ -37,7 +37,7 @@ 有三种做法,可以单独用,也可以搭配。 1. **直接把时长填长。** 超过一次能生成的长度时,FreeVideo 自动分成几段依次生成,再接成一条视频。 -2. **接着生成。** 在生成好的视频结果卡上点“接着生成”,继续下一段;也可在“剪辑视图”中逐段修改。适合边看边调。 +2. **继续生成。** 在已生成的视频结果卡上点击“继续生成”,继续下一段;也可在“剪辑视图”中逐段修改。适合边看边调。 3. **用 AI 全自动。** 打开“AI 全自动”,把想法交给 Agent,它会按镜头拆分、逐镜生成并拼接。耗时随镜头数和每镜时长而定,每镜按普通生成计时。 ### 在剪辑视图里加参考图 @@ -46,8 +46,8 @@ ### 限制 -- 两种做法一样,一条视频最多 5 段(第一段加 4 次接着生成),所以创作面板里时长最多填 60 秒。接续的每一段至少 10 秒,整条约 70 秒。剪辑视图里逐段生成同样适用。段数越多,衔接处越容易出现变化,适合切换镜头处,可直接切镜头,用对应的参考图开始新的一段。 -- 接着生成使用轻量档,需要 NVIDIA 显卡或 Mac,也不能和“继续二采”同时使用。 +- 两种做法一样,一条视频最多 5 段(第一段加 4 次继续生成),所以创作面板里时长最多填 60 秒。接续的每一段至少 10 秒,整条约 70 秒。剪辑视图里逐段生成同样适用。段数越多,衔接处越容易出现变化,适合切换镜头处,可直接切镜头,用对应的参考图开始新的一段。 +- 继续生成使用轻量档,需要 NVIDIA 显卡或 Mac,也不能和“继续二采”同时使用。 - 单次生成的最长时长随画幅和显卡而不同,在创作面板的“可生成范围”中可查看各尺寸的最长时长和预计用时。 - 分段生成同样需要 NVIDIA 显卡或 Mac。 - 分段生成过程中取消,只会停止当前这一段,已经完成的段落会保留,之后可以从第一个未完成的段落继续。 diff --git a/freevideo_engine/agent/builtin_director.py b/freevideo_engine/agent/builtin_director.py index 6b280055..87d3669e 100644 --- a/freevideo_engine/agent/builtin_director.py +++ b/freevideo_engine/agent/builtin_director.py @@ -317,7 +317,7 @@ def plan(story, *, ask, shots=(3, 8)): """Try at most three screenplay candidates, never inventing a fallback.""" if (not isinstance(story, str) or not story.strip() or not isinstance(shots, (tuple, list)) or len(shots) != 2 or any(type(n) is not int for n in shots) or not 1 <= shots[0] <= shots[1] <= 40): - return Problem('plan_failed', '故事或镜头数量不合适,请补充后再试。') + return Problem('plan_failed', '故事或镜头数量不合适,请补充后重试。') schema = deepcopy(_PLAY) schema['properties']['shots'].update(minItems=shots[0], maxItems=shots[1]) prompt = ('把下面的故事写成可拍摄的分镜剧本,保持人物和故事事实。镜头数 %d–%d。' @@ -337,7 +337,7 @@ def plan(story, *, ask, shots=(3, 8)): prompt += '\ndrama.check problems: ' + _dump(error.problems) continue return candidate - return Problem('plan_failed', '没能把故事排成合格剧本,请补充镜头内容后再试。') + return Problem('plan_failed', '未能将故事整理为合格的剧本,请补充镜头内容后重试。') def _selectable(shot): @@ -369,7 +369,7 @@ def judge(shot_view, sheets, *, ask): return Decision('keep', shot['id'], '保留已选定的版本。') if shot.get('state') in ('waiting', 'shooting'): return Decision('wait', shot['id'], '等待这一镜的各版完成。') - uncertain = Decision('ask_user', shot['id'], '拿不准,请你挑一版。') + uncertain = Decision('ask_user', shot['id'], '无法确定,请选择一个版本。') takes = _selectable(shot) if not takes: return uncertain @@ -416,7 +416,7 @@ def judge(shot_view, sheets, *, ask): def revise(view, sentence, *, ask): """Translate one request into one checked reshoot, or ask for clarification.""" - unclear = Problem('unclear', '请说明要改哪一镜,以及画面或时长要怎样改?') + unclear = Problem('unclear', '请说明要修改哪一镜,以及画面或时长的修改方式。') if not isinstance(sentence, str) or not sentence.strip(): return unclear ids = [s['id'] for s in view['shots']] @@ -427,7 +427,7 @@ def revise(view, sentence, *, ask): prompt = ('把用户的修改意见变成一条 reshoot 操作。只能修改一镜的 action/lines/camera/framing/prompt/seconds,' '或用 note 补充生成意见;不能增删或重排镜头,不能改接续关系,不能声称完成音频编辑。' '序号按 shots 顺序,结尾指最后一镜。目标不唯一、要求不支持或时长没有可确定的数值时,' - '返回 unclear 并用一句中文反问,不猜。\n拍摄记录:' + _dump(view['screenplay']) + '返回 unclear 并用一句中文提问,不猜。\n拍摄记录:' + _dump(view['screenplay']) + '\n镜头编号:' + _dump(ids) + '\n用户意见:' + sentence) reply = _ask(ask, prompt, [], schema) if reply is None: @@ -493,7 +493,7 @@ def run(tools, production_id, *, ask, stop, wait=25): continue snapshot = [(s['id'], s['chosen']) for s in view['shots']] tools.finish(production_id, by='builtin', ref=_ref(production_id, 'finish', snapshot)) - yield dict(shot=None, action='finish', reason='每镜已选定,开始接成影片。') + yield dict(shot=None, action='finish', reason='每一镜均已选定,开始拼接成片。') return if action not in ('choose', 'reshoot'): continue @@ -542,7 +542,7 @@ def run(tools, production_id, *, ask, stop, wait=25): conflicts += 1 errors, since = 0, None if conflicts >= 5: - yield dict(shot=None, action='error', reason='多次和已有的选择冲突,已停止:' + code) + yield dict(shot=None, action='error', reason='多次与已有选择冲突,已停止:' + code) return continue errors += 1 diff --git a/freevideo_engine/agent/errors.py b/freevideo_engine/agent/errors.py index b621f3e7..4f33168d 100644 --- a/freevideo_engine/agent/errors.py +++ b/freevideo_engine/agent/errors.py @@ -122,22 +122,22 @@ # Static bilingual image reasons; backend exception strings never enter the API. IMAGE_REASONS = { 'no_memory': ('Not enough GPU memory, even at the smallest size. Close other apps using the GPU, then try again.', - '显存不够,最小尺寸也没能生成。关掉其他占用显卡的程序后再试。'), - 'busy_video': ('A video is generating. Try again when it finishes.', '视频正在生成,完成后再试。'), + '显存不足,最小尺寸也未能生成。请关闭其他占用显卡的程序后重试。'), + 'busy_video': ('A video is generating. Try again when it finishes.', '视频正在生成,请在完成后重试。'), 'pack_missing': ('Download "Image generation models" in the launcher first.', - '请先在启动器里下载“图像生成模型”。'), + '请先在启动器中下载“图像生成模型”。'), 'model_unreadable': ('The image generation model files could not be read. Repair them in the launcher and try again.', - '图像生成模型的文件读不出来。在启动器里修复后再试。'), - 'device_unavailable': ('The graphics card is not available right now. Try again later.', - '显卡暂时用不了,稍后再试。'), - 'worker_exit': ('Image generation stopped unexpectedly. Try again.', '图像生成意外停止了,请再试一次。'), - 'timeout': ('This image took too long and was stopped. Try again.', '这一张用时太长,已停止。请再试一次。'), - 'generation_failed': ('Image generation ran into an error. Try again.', '图像生成出错了,请再试一次。'), - 'invalid_media': ('The reference image could not be read. Choose another one.', - '参考图读不出来,请换一张。'), + '无法读取图像生成模型文件,请在启动器中修复后重试。'), + 'device_unavailable': ('The graphics card is temporarily unavailable. Try again later.', + '显卡暂时不可用,请稍后重试。'), + 'worker_exit': ('Image generation stopped unexpectedly. Try again.', '图像生成意外停止,请重试。'), + 'timeout': ('This image exceeded the time limit and was stopped. Try again.', '此图片生成超时,已停止。请重试。'), + 'generation_failed': ('Image generation encountered an error. Try again.', '图像生成出错,请重试。'), + 'invalid_media': ('The reference image cannot be read. Choose another image.', + '无法读取参考图,请更换一张。'), 'invalid_request': ('The image settings are invalid. Adjust them and try again.', '图片设置有误,请调整后重试。'), - 'busy': ('Another picture is being generated. Try again when it finishes.', '已有一张图在生成,完成后再试。'), - 'cancelled': ('The picture was cancelled.', '已取消这张图片。'), + 'busy': ('An image is already being generated. Try again when it finishes.', '已有图片正在生成,请在完成后重试。'), + 'cancelled': ('Image generation was cancelled.', '已取消图像生成。'), } @@ -256,35 +256,35 @@ def safe_details(value): FAILURE_REASONS_ZH = { 'lan_unavailable': '局域网设置需要启动器安装记录', 'out_of_memory': '生成时显存或内存不足', - 'insufficient_memory': '可用显存或内存不够生成这段内容', - 'no_memory': '可用显存或内存不够生成这张图片', + 'insufficient_memory': '可用显存或内存不足,无法生成这段内容', + 'no_memory': '可用显存或内存不足,无法生成这张图片', 'ram_space': '加载模型时系统内存不足', - 'model_missing': '需要的模型还没有安装好', - 'pack_missing': '图片模型还没有安装好', - 'not_ready': '生成所需的环境还没有安装好', + 'model_missing': '所需的模型尚未安装', + 'pack_missing': '图片模型尚未安装', + 'not_ready': '生成所需的环境尚未安装', 'cancelled': '生成已被停止', 'stopped_by_user': '生成已被停止', - 'interrupted': '生成中途被打断', - 'worker_exit': '图片生成中途被打断', - 'closed': '图片生成服务已经关闭', - 'image_unavailable': '图片生成服务还没有启动', - 'vlm_unavailable': '读取画面的模型服务还没有启动', - 'vlm_failed': '没能读出画面', - 'vlm_invalid_json': '回复读不出来', - 'context_length': '描述和参考图太长', - 'dependency_failed': '前面依赖的任务没有完成', + 'interrupted': '生成过程被中断', + 'worker_exit': '图片生成过程被中断', + 'closed': '图片生成服务已关闭', + 'image_unavailable': '图片生成服务尚未启动', + 'vlm_unavailable': '读取画面的模型服务尚未启动', + 'vlm_failed': '未能识别画面', + 'vlm_invalid_json': '无法解析回复', + 'context_length': '描述和参考图超出长度上限', + 'dependency_failed': '所依赖的前序任务未完成', 'lost': '生成服务没有保留这次任务的结果', 'unknown_job': '图片生成服务找不到这次任务', 'not_found': '找不到这次任务或素材', - 'source_missing': '接续所需的上一段视频已经不在', - 'chain_missing': '接续所需的生成记录已经不在', - 'chain_unavailable': '分段生成服务还没有启动', + 'source_missing': '接续所需的上一段视频已不存在', + 'chain_missing': '接续所需的生成记录已不存在', + 'chain_unavailable': '分段生成服务尚未启动', 'continuation_unavailable': '当前生成方式不支持接续视频', 'join_failed': '片段已生成,但拼接成片时出错', 'invalid_output': '生成的结果无法读取或保存', 'storage_unavailable': '任务记录或素材无法写入磁盘', 'submit_interrupted': '提交生成时服务中断,尚未确认任务是否开始', - 'timeout': '生成等了太久没有结果', + 'timeout': '生成超时,未返回结果', 'invalid_media': '参考素材无法读取', 'clip_unreadable': '生成的角色参考视频无法读取', 'invalid_request': '生成参数不符合支持的范围', @@ -297,8 +297,8 @@ def safe_details(value): 'too_many_images': '参考图片数量超过了支持的范围', 'chain_too_long': '接续后的视频超过了支持的段数', 'chain_rejected': '生成服务无法使用这组参数', - 'generation_failed': '生成没有完成', - 'internal_error': '没有完成', + 'generation_failed': '生成未完成', + 'internal_error': '未完成', } FAILURE_MESSAGES_ZH = { 'The previous video is too short to continue; it needs to be at least 1.7 s long.': '接续的上一段视频太短,至少需要 1.7 秒', @@ -322,7 +322,7 @@ def failure_reason(error): return FAILURE_MESSAGES_ZH[line] if line.startswith(('CUDA out of memory', 'MPS backend out of memory')): return FAILURE_REASONS_ZH['out_of_memory'] - return FAILURE_REASONS_ZH.get(error.get('code'), '没有完成') + return FAILURE_REASONS_ZH.get(error.get('code'), '未完成') def with_reason(error): diff --git a/freevideo_engine/agent/guide.py b/freevideo_engine/agent/guide.py index e051ce29..ea287873 100644 --- a/freevideo_engine/agent/guide.py +++ b/freevideo_engine/agent/guide.py @@ -268,7 +268,7 @@ '先用 people(角色图和各自的位置)做出首帧,再用它生成视频。', '默认切镜头。能切镜头的地方就切:新镜头直接用对应的参考图(人物卡、场景图)生成,不接上一段视频。' '只有不能切镜的长镜头、必须连贯的动作、必须连贯的声音,才接着上一段生成,而且不建议一段接一段地连续接。' - '一条视频最多 5 段(第一段加 4 次接着生成),接着生成的每一段至少 10 秒,总长约 70 秒;不建议接满,超过就切镜头。', + '一条视频最多 5 段(第一段加 4 次继续生成),接续生成的每一段至少 10 秒,总长约 70 秒;不建议接满,超过就切镜头。', '问话不是要求。用户问进度或状态(“在做吗”“怎么了”)时,用一句话照实回答,依据是你所知道的、任务和白模的状态,' '什么都不要开始;不要把问句当提示词。某条 inbox 带有 whitebox 时,说明用户刚启动了白模场景,' '它还在准备或已经失败:照实说是哪一种,再等用户回话。', @@ -288,7 +288,7 @@ ('GET', '/jobs/{id}'): '查看任务进度和结果', ('GET', '/jobs/{id}/events'): '持续接收任务进度', ('POST', '/jobs/{id}/cancel'): '取消任务', - ('POST', '/jobs/{id}/resume'): '接着生成一条视频里还没完成的部分', + ('POST', '/jobs/{id}/resume'): '继续生成一条视频中尚未完成的部分', ('POST', '/media'): '上传图片、视频或音频内容', ('POST', '/imports'): '导入视频或音频到剪辑时间线;轮询 GET /imports 等待就绪', ('GET', '/imports'): '列出已导入素材和准备进度', @@ -481,16 +481,16 @@ def connection_guide(root, base, lang=None, offers=None, resumed=False): '连接后先调一次等待接口 GET ' + base + PREFIX + '/inbox?wait=50(MCP 用不带 id 的 freevideo_wait),' '回应里的 session 就是用户现在所在的对话(用户新开或换到另一段对话时它会变,话总是发到最近一次等待给的那个 session);往 POST ' + base + PREFIX + '/sessions/{session}/events 发 ' '{"type":"message","text":"…"}(MCP 用 freevideo_session_post)。在这里先只说一句话,不超过 20 个字,' - '不列条目、不举例(页面上已经有例句),例如“连上了。告诉我想做什么视频。”' + '不列条目、不举例(页面上已经有例句),例如“已连接。请描述想要生成的视频。”' '你自己窗口里的那段介绍照常说,但它不是这一轮的结尾:说完紧接着去调等待接口。' '然后守在这段对话里:再调等待接口,它最多约 50 秒返回;"again": true 表示用户还没说话,' '马上再调下一次,一次接一次,不限次数。没有消息不是结束的理由:不要结束这一轮回复,' '不要停下来问用户——你一停,用户在 FreeVideo 里说的话就没人听了。' '用命令行调接口时,每次等待单独发一条命令(一条命令里不写循环,超时设到 60 秒以上),' '返回后接着发下一条。' - '收到要求(inbox 里每条的 text)先回一句再动手,例如“好的,开始生成。”:只一句,不复述尺寸、' - '时长这些参数,也不说预计时间(任务卡上都有)。做完再说一句短的,例如“好了。”,' - '或者一句用户需要知道的话,发回同一个 session,再接着等。先把话发出去,再调等待接口;不要写成“等待返回后才发话”,那样这句话会晚到将近一分钟。中文用全角标点。' + '收到要求(inbox 里每条的 text)先回一句再动手,例如“开始生成。”:只一句,不复述尺寸、' + '时长这些参数,也不说预计时间(任务卡上都有)。做完再说一句短的,例如“已完成。”,' + '或者一句用户需要知道的话,发回同一个 session,再接着等。先把话发出去,再调等待接口;不要写成“等待返回后才发话”,那样这句话会晚到将近一分钟。中文用全角标点。对用户称“您”,回复用书面语。' '做完一条视频也不是结尾:这句话就是汇报,不要再在自己窗口里写总结(一写总结这一轮就结束了,' '用户的下一句话没人听),紧接着再调等待接口,空等多少轮都接着等。' '这段对话里不要新建会话。只有用户明确让你停,才停。' if zh else @@ -499,7 +499,7 @@ def connection_guide(root, base, lang=None, offers=None, resumed=False): 'without id). Its "session" is the conversation the user is in (it changes when the user opens or returns to another one; always post to the session the latest wait named); POST ' + base + PREFIX + '/sessions/{session}/events ' 'with {"type":"message","text":"..."} (MCP: freevideo_session_post). Say only one short sentence ' 'there first, at most 12 words, no list and no examples (the page already shows examples), for example ' - '"Connected. Tell me what video you want.", in the language the user writes to you in. Give the introduction in your own window as usual, but it ' + '"Connected. Describe the video you want.", in the language the user writes to you in. Give the introduction in your own window as usual, but it ' 'does not end your turn: right after it, call the wait. Then stay in this conversation: call the wait again; it answers within about 50 seconds, and ' '"again": true means the user has not spoken yet, so call it again at once, time after time, with no ' 'limit. Nothing new is not a reason to finish: do not end your reply and do not stop to ask the ' @@ -507,16 +507,16 @@ def connection_guide(root, base, lang=None, offers=None, resumed=False): 'each wait is its own command (no loop inside one command, timeout above 60 seconds); when it ' 'returns, run the next one. ' 'When a request arrives (text of each inbox row; a question about progress is answered, not started), answer with one sentence before you start, for example ' - '"OK, starting.": one sentence only, without repeating size or length and without an estimate (the ' + '"Starting generation.": one sentence only, without repeating size or length and without an estimate (the ' 'job card shows them). When it is done say one short sentence, for example "Done.", or one thing the ' - 'user needs to know, to the same session, and wait again. Post your words first and call the wait after; never wait and then post, or they reach the user up to a minute late. Chinese text takes full-width punctuation. ' + 'user needs to know, to the same session, and wait again. Post your words first and call the wait after; never wait and then post, or they reach the user up to a minute late. Chinese text takes full-width punctuation. Address the user formally (您 in Chinese) and reply in a formal written register. ' 'A finished video is not the end either: that sentence is the report, so write no summary in your own ' 'window (a summary ends your turn, and then nobody hears the next thing the user says); call the wait ' 'again right away, and keep waiting however many waits come back empty. Do not create another session for this conversation. ' 'Stop only when the user clearly tells you to.', rules=RULES_ZH if zh else RULES, fallback='如果你无法执行命令、读取本地文件或连接到这台电脑,请如实告诉用户:' - '“我连接不到你电脑上的 FreeVideo,请改用安装在这台电脑上的 AI 助手。”' + '“无法连接到您电脑上的 FreeVideo,请改用安装在这台电脑上的 AI 助手。”' '用中文说明;如果用户改用其他语言,就跟随用户。不要另装程序来绕过这个限制。' if zh else 'If you cannot run commands, read local files, or reach this computer, tell the user honestly ' 'in their language: I cannot connect to FreeVideo on your computer; please use an AI assistant ' diff --git a/freevideo_engine/chain_runner.py b/freevideo_engine/chain_runner.py index bf37a1f3..8e8cd4ad 100644 --- a/freevideo_engine/chain_runner.py +++ b/freevideo_engine/chain_runner.py @@ -24,7 +24,7 @@ class ChainTooLong(ValueError): def __init__(self, parts, limit): super().__init__('This video can have up to %d parts. Make it shorter.' % limit) self.parts, self.limit = parts, limit - self.message_zh = '这条视频最多 %d 段,请缩短一些。' % limit + self.message_zh = '这条视频最多 %d 段,请缩短。' % limit class ChainPartTooShort(ValueError): @@ -34,7 +34,7 @@ class ChainPartTooShort(ValueError): def __init__(self, seconds, limit): super().__init__('Each continued part must be at least %d seconds. Make it longer.' % limit) self.seconds, self.limit = seconds, limit - self.message_zh = '接着生成的每一段至少 %d 秒,请加长一些。' % limit + self.message_zh = '接续生成的每一段至少 %d 秒,请延长。' % limit def _work_key(path): diff --git a/freevideo_engine/comfy_launcher.py b/freevideo_engine/comfy_launcher.py index d3111c38..ca756653 100644 --- a/freevideo_engine/comfy_launcher.py +++ b/freevideo_engine/comfy_launcher.py @@ -20,7 +20,7 @@ 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.', '还在启动,好了会自动打开') +BROWSER_STARTING = ('Still starting. Your browser will open automatically when ready.', '正在启动,就绪后将自动打开浏览器') class Launcher: @@ -242,7 +242,7 @@ def show_options(self): if name == 'url': self.listen_entry = entry self.label(body, "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。', + '填 0.0.0.0 或本机的局域网 IP 后,同一网络中的设备无需访问链接也可打开 FreeVideo。', style='Muted.TLabel', wraplength=580).pack(fill='x') if sys.platform == 'win32': self.label(body, FIREWALL_HINT[0], FIREWALL_HINT[1], style='Muted.TLabel', wraplength=580).pack(fill='x') @@ -258,7 +258,7 @@ def show_options(self): if variable is self.lan_access: self.ttk.Label(body, textvariable=self.lan_message, style='Muted.TLabel').pack(fill='x') self.label(body, '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。拿到链接的人都能使用,请勿公开。', + '开启后,同一网络中的其他设备可以用下面的链接打开 FreeVideo。持有链接的人均可使用,请勿公开。', style='Muted.TLabel', wraplength=580).pack(fill='x') if sys.platform == 'win32': self.label(body, FIREWALL_HINT[0], FIREWALL_HINT[1], style='Muted.TLabel', wraplength=580).pack(fill='x') @@ -546,7 +546,7 @@ def refresh(self): ('Ready to install', '准备安装'), ('FreeVideo', 'FreeVideo')] # One line each. What a control does is stated once, next to the control. descriptions = [('Choose how you want to use ComfyUI.', '选择 ComfyUI 的使用方式。'), - ('Download automatically, download yourself, or reuse existing models.', '自动下载、手动下载,或复用已有模型。'), + ('Download automatically, download manually, or reuse existing models.', '自动下载、手动下载,或复用已有模型。'), ('Review, then install.', '确认后开始安装。'), ('Start ComfyUI and open the FreeVideo workflow.', '启动 ComfyUI 并打开 FreeVideo 工作流。')] if self.wizard_step == 3: @@ -565,8 +565,8 @@ def refresh(self): (('Installing FreeVideo', '正在安装 FreeVideo'), ('Preparing models and dependencies.', '正在准备模型和运行环境。')), 'failed': (('Installation paused', '安装已暂停'), ('Files retained. Details below.', '文件已保留,原因见下方。')), 'cancelled': (('Installation paused', '安装已暂停'), ('Downloaded files are reused.', '已下载文件会复用。')), - 'open': (('You’re ready', '准备完成'), ('ComfyUI is ready. Open FreeVideo in your browser.', 'ComfyUI 已就绪,可在浏览器中打开 FreeVideo。')), - 'restart-required': (('One last step', '最后一步'), ('Restart ComfyUI, then connect below.', '请重启 ComfyUI,然后点击下方连接。')), + 'open': (('Ready', '准备完成'), ('ComfyUI is ready. Open FreeVideo in your browser.', 'ComfyUI 已就绪,可在浏览器中打开 FreeVideo。')), + 'restart-required': (('Restart required', '需要重启'), ('Restart ComfyUI, then connect below.', '请重启 ComfyUI,然后点击下方连接。')), }.get(state, (headings[2], descriptions[2])) self.heading.configure(text=self.t(*headings[self.wizard_step])) self.status.set(self.t(*descriptions[self.wizard_step])) diff --git a/freevideo_engine/comfy_launcher_api.py b/freevideo_engine/comfy_launcher_api.py index a2e14d5a..0353800b 100644 --- a/freevideo_engine/comfy_launcher_api.py +++ b/freevideo_engine/comfy_launcher_api.py @@ -29,13 +29,13 @@ def page_report(value): 'Press Ctrl+F5 to reload the page. If it stays missing, open the FreeVideo launcher, ' 'click Settings › Repair, and open FreeVideo again. ' '浏览器没有运行 FreeVideo 的页面脚本,所以看不到创作面板。请按 Ctrl+F5 强制刷新页面;' - '仍然没有时,请在 FreeVideo 启动器的“设置”里点击“修复”,然后重新打开 FreeVideo。') + '如仍未显示,请在 FreeVideo 启动器的“设置”中点击“修复”,然后重新打开 FreeVideo。') # Without the launcher, the page's own Settings cannot open either. PAGE_SCRIPTS_MISSING_NODE = ( 'FreeVideo’s page scripts have not run in this browser, so the creative workspace is missing. ' 'Press Ctrl+F5 to reload the page. If it stays missing, update the FreeVideo node and restart ComfyUI. ' '浏览器没有运行 FreeVideo 的页面脚本,所以看不到创作面板。请按 Ctrl+F5 强制刷新页面;' - '仍然没有时,请更新 FreeVideo 节点并重启 ComfyUI。') + '如仍未显示,请更新 FreeVideo 节点并重启 ComfyUI。') # Where a node message sends the user to repair. The launcher has its own # Repair; a ComfyUI started without it has the page's Settings › Install / repair. diff --git a/freevideo_engine/desktop.py b/freevideo_engine/desktop.py index a2e9dfc9..aefc74b1 100644 --- a/freevideo_engine/desktop.py +++ b/freevideo_engine/desktop.py @@ -241,7 +241,7 @@ def setup_page(self, page): def generate_page(self, page): _, page = self.scrolled(page, 1) - self.label(page, 'Describe your video', '描述你想生成的视频').pack(anchor='w', pady=(0, 10)) + self.label(page, 'Describe your video', '描述要生成的视频').pack(anchor='w', pady=(0, 10)) prompt_card = Card(page, padding=branding.space(3), radius=branding.RADIUS_MD) prompt_card.pack(fill='x') self.prompt = self.tk.Text(prompt_card.body, height=6, font=(branding.FAMILY, branding.STRONG), @@ -516,7 +516,7 @@ def open_path(self, path): if path and io_path(path).exists(): os.startfile(plain(path)) else: - messagebox.showinfo('FreeVideo', self.t('No result yet.', '目前还没有结果。'), parent=self.window) + messagebox.showinfo('FreeVideo', self.t('No result yet.', '暂无结果。'), parent=self.window) @plain_errors def open_report(self): @@ -618,7 +618,7 @@ def refresh_progress_times(self): if finished: pieces.append(self.t('Complete', '完成') if value['state'] == 'complete' else self.t('Stopped', '已停止')) elif remaining == 0: - pieces.append(self.t('Finalizing', '正在收尾')) + pieces.append(self.t('Finalizing', '正在完成')) elif remaining is not None: pieces.append(self.t('ETA ~', '预计剩余约 ') + duration(remaining)) else: diff --git a/freevideo_engine/desktop_shortcut.py b/freevideo_engine/desktop_shortcut.py index 2b0dea9a..7815e249 100644 --- a/freevideo_engine/desktop_shortcut.py +++ b/freevideo_engine/desktop_shortcut.py @@ -322,7 +322,7 @@ def _failure(error, menu_ready): text = str(error) denied = isinstance(error, PermissionError) or '0x80070005' in text.lower() or 'access is denied' in text.lower() en = ['The desktop shortcut could not be created:'] - zh = ['桌面快捷方式没有创建成功:'] + zh = ['未能创建桌面快捷方式:'] if denied: # Controlled folder access (Windows Security) refuses unknown programs on the desktop. en.append('Windows did not allow FreeVideo to write to the desktop. If Controlled folder access is on in ' @@ -332,7 +332,7 @@ def _failure(error, menu_ready): else: en.append(text); zh.append(text) if menu_ready: - en.append('FreeVideo is in the Start menu.'); zh.append('开始菜单里可以找到 FreeVideo。') + en.append('FreeVideo is in the Start menu.'); zh.append('可在开始菜单中找到 FreeVideo。') return ' '.join(en), ''.join(zh) @@ -542,4 +542,4 @@ def create_after_install(engine, source, portable_root=None): return create(engine, source, portable_root) except (OSError, ValueError) as error: return dict(status='failed', error='Installed successfully, but the desktop shortcut could not be created: ' + str(error), - error_zh='安装已完成,但桌面快捷方式没有创建成功:' + str(error)) + error_zh='安装已完成,但未能创建桌面快捷方式:' + str(error)) diff --git a/freevideo_engine/download_settings.py b/freevideo_engine/download_settings.py index a06fb78e..df5d0850 100644 --- a/freevideo_engine/download_settings.py +++ b/freevideo_engine/download_settings.py @@ -21,7 +21,7 @@ def speed_text(row, zh=False): rate = row.get('bytes_per_second') if (row.get('speed_measured') is False or not isinstance(rate, (int, float)) or not math.isfinite(rate) or rate <= 0): - return '已连通,速度还没测出' if zh else 'Connected; speed not measured yet' + return '已连通,尚未测速' if zh else 'Connected; speed not measured yet' speed = rate / 2**20 return '<0.01 MiB/s' if speed < .005 else (('%.2f' if speed < 1 else '%.1f') % speed + ' MiB/s') diff --git a/freevideo_engine/download_settings_ui.py b/freevideo_engine/download_settings_ui.py index 2424b6bb..67b1ab64 100644 --- a/freevideo_engine/download_settings_ui.py +++ b/freevideo_engine/download_settings_ui.py @@ -56,7 +56,7 @@ def __init__(self, parent, root, zh=False, probe=None, token=None, busy=None): self.speeds.pack(fill='both', expand=True) ttk.Label(body, text=self.t( 'Switching takes effect at once. Downloads resume where possible; otherwise the file restarts. Testing speeds does not switch.', - '切换后马上生效,能续传的接着下载,不能的重新下载这个文件。只测速不会切换。'), + '切换后立即生效,支持续传的文件继续下载,其余文件重新下载。仅测速不会切换。'), wraplength=560, style='Muted.TLabel').pack(fill='x', pady=(12, 4)) ttk.Label(body, text=str(self.root), wraplength=560, style='Muted.TLabel').pack(fill='x') ttk.Button(body, text=self.t('Done', '完成'), command=window.destroy).pack(anchor='e', pady=(8, 0)) diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index faa352a8..c2b84085 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -25,12 +25,12 @@ '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 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.', '图像模型文件不完整或已损坏,请在启动器里修复。'), + '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.', '图像模型包被其他任务占用,完成后再试。'), - 'busy_video': ('A video is using the graphics card; try again when it finishes.', '视频正在使用显卡,完成后再试。'), - 'needs_repair': ('The image model files are incomplete or damaged; repair them in the launcher.', '图像模型文件不完整或已损坏,请在启动器里修复。'), + 'busy': ('Another task is using the image model package; try again when it finishes.', '图像模型包被其他任务占用,请在其完成后重试。'), + 'busy_video': ('A video is using the graphics card; try again when it finishes.', '视频正在使用显卡,请在完成后重试。'), + 'needs_repair': ('The image model files are incomplete or damaged; repair them in the launcher.', '图像模型文件不完整或已损坏,请在启动器中修复。'), 'changed': ('The image model files changed during the check; they were kept, so check again.', '图像模型文件在检查期间发生变化,已保留,请重新检查。'), 'unsafe_path': ('The image model folder contains a link that cannot be safely changed.', '图像模型目录含有无法安全修改的链接。'), 'failed': ('The image model operation stopped; see the diagnostic report for details.', '图像模型操作已停止,详情见诊断报告。'), @@ -106,15 +106,15 @@ 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),但没能下载完。' - '请确认网络连接后再生成一次,FreeVideo 会自动下载。') % (what, size) + title = '“%s”尚未下载' % QUALITY_LEVELS[index][1] + what = '该档所需的文件' + detail = ('本次生成需要先下载%s(约 %s),但下载未完成。' + '请确认网络连接后重新生成,FreeVideo 会自动下载。') % (what, size) if available: - detail += '也可以改用已经可以使用的%s档。' % available + detail += '也可以改用已可使用的%s档。' % available else: if index is None: title = 'The sampling table for Custom (%d steps) is not downloaded yet' % details['base_steps'] @@ -122,7 +122,7 @@ def sampling_table_failure(value, *, zh=False): else: title = "The %s quality level's sampling table is not downloaded yet" % QUALITY_LEVELS[index][0] what = "that level's sampling table" - detail = ('This video first needs %s (about %s), and it could not be downloaded this time. ' + detail = ('This video first needs %s (about %s), and the download did not finish. ' 'Check the network connection and generate again; FreeVideo downloads it automatically.') % (what, size) if available: detail += ' You can also switch to a quality level that is already available: %s.' % available @@ -212,23 +212,23 @@ def environment_python_removed(args, pythons): # Checked before free space: freeing space cannot fix a disk that cannot hold the installation. PLACE_RULES = ( (r'The installation folder is on a FAT32 disk', 'disk-format', - ('This disk cannot hold the models', '这块硬盘放不下模型'), + ('This disk cannot hold the models', '这块硬盘无法存放模型'), ('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.', '它不能存放 4 GB 以上的文件。' '请选择其他硬盘上的文件夹。')), (r'The installation folder is on an exFAT disk', 'disk-format', - ('This disk cannot hold the FreeVideo environment', '这块硬盘装不了 FreeVideo 的运行环境'), + ('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.', '它不能安装 FreeVideo。' '请选择其他硬盘上的文件夹。')), (r'The installation folder is on a disk that is not connected', 'disk-missing', - ('The disk is not connected', '这块硬盘没有连接'), + ('The disk is not connected', '这块硬盘未连接'), ('Connect the disk that holds the installation folder, then check again, or choose another folder.', - '请接上安装位置所在的硬盘后重新检查,或者换一个位置。')), + '请连接安装位置所在的硬盘后重新检查,或选择其他位置。')), (r'The installation folder is on a read-only disk', 'disk-read-only', - ('This disk is 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.', '这块硬盘只能读取、不能写入,请选择其他硬盘上的文件夹。')), ) @@ -259,7 +259,7 @@ def _model_unchanged(tail): 'then click Replace package… under Import environment package and choose the downloaded environment package; ' 'if you are online, you can instead choose Automatic as the install method there. ' 'The models and videos in the current folder are not deleted.', - '请在左侧“安装”页选择“帮我安装 ComfyUI”,把“安装位置”改为一个新文件夹,然后在“导入运行环境包”中点击“更换环境包…”' + '请在左侧“安装”页选择“安装 ComfyUI”,把“安装位置”改为一个新文件夹,然后在“导入运行环境包”中点击“更换环境包…”' '并选择下载的运行环境包;能联网时,也可以在那里把安装方式改为“自动安装”。原文件夹里的模型和作品不会被删除。') WITHOUT_REPAIR = { # A model file that differs from the tested one; FreeVideo leaves it in place. @@ -275,7 +275,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.', - '当前任务结束后,这里会出现“修复”按钮,点击后会补装缺失的组件。'), + '当前任务结束后,此处将显示“修复”按钮,点击后会补装缺失的组件。'), 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], @@ -283,7 +283,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.', - '当前任务结束后,这里会出现“修复”按钮,点击后会重新安装运行组件,模型和视频都会保留。'), + '当前任务结束后,此处将显示“修复”按钮,点击后会重新安装运行组件,模型和视频都会保留。'), 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], @@ -294,13 +294,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 已退回原来的版本,可以照常使用;原因写在详情里。')), + '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 已退回原来的版本,可以照常使用。如需再次更新,请在“安装”页保持同一安装目录,依次点击“继续”“检查并继续”和“安装并启动”。')), } @@ -403,8 +403,8 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep failure['path'] = paths[-1] if paths else mismatches[-1] return failure if disk_name and 'on a disk that is not connected' in summary: - return dict(title='这块硬盘没有连接' if zh else 'The disk is not connected', kind='disk-missing', detail='', - action=('请接上“%s”后点击“重新检查”' % disk_name + (',或者改装到其他硬盘。' if other_disk else '。') if zh else + return dict(title='这块硬盘未连接' if zh else 'The disk is not connected', kind='disk-missing', detail='', + action=('请连接“%s”后点击“重新检查”' % disk_name + (',或改为安装到其他硬盘。' if other_disk else '。') if zh else 'Connect "%s" and click Check again' % disk_name + (', or install on another disk.' if other_disk else '.'))) folder = 'Select the ComfyUI folder, or the portable folder containing ComfyUI.' # A saved installation that vanished raises the same sentence after its own message; the @@ -432,7 +432,7 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep from .disk_space import size_text sizes = tuple(size_text(float(value) * 2**30) for value in disk.groups()) # The figures and what to do go on their own lines, so a narrow window breaks between them. - detail = (('需要约 %s,可用 %s。\n再释放 %s 后点击“重新检查”' % sizes) + (',或者改装到其他硬盘。' if other_disk else '。') + detail = (('需要约 %s,可用 %s。\n再释放 %s 后点击“重新检查”' % sizes) + (',或改为安装到其他硬盘。' if other_disk else '。') if zh else ('Needs about %s; %s available.\nFree up %s and click Check again' % sizes) + (', or install on another disk.' if other_disk else '.')) else: @@ -445,8 +445,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 'ComfyUI quit while starting.', - 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: @@ -455,9 +455,9 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep if zh: detail = detail.replace('Failed step: kernels (exit code ', '失败步骤:GPU 检查(退出码 ').replace(')\n', ')\n', 1) detail = detail.replace('Required linear kernel did not pass.', - '显卡必需的运算检查没有通过。' if zh else 'A required GPU check 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.') + '显卡加速检查未通过。' 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, @@ -488,13 +488,13 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep '请使用 39、56、73、90 等每次增加 17 的帧数;接续时,这个数包含开头重复的 22 帧。')), (r'Continue from video cannot be used with these settings\. Continuing a video needs an NVIDIA', 'continuation-route', ("This computer can't continue a video", '这台电脑无法接续视频'), - ('Continuing a video needs an NVIDIA GPU or a Mac. To make a new video, clear "Continue from video".', '接续视频需要 NVIDIA 显卡或 Mac。要生成新视频,请清空“接着生成”。')), + ('Continuing a video needs an NVIDIA GPU or a Mac. To make a new video, clear "Continue from video".', '接续视频需要 NVIDIA 显卡或 Mac。要生成新视频,请清空“继续生成”。')), (r'Continue from video cannot be used with these settings\. Turn on "Two-pass', 'continuation-passes', ('Continuing needs two-pass acceleration', '接续需要二次采样加速'), - ('Turn on "Two-pass acceleration" and turn off the first-pass preview.', '请打开“二次采样加速”,并关闭先出预览。')), + ('Turn on "Two-pass acceleration" and turn off the first-pass preview.', '请打开“二次采样加速”,并关闭“先生成预览”。')), (r'Continue from video cannot be used with these settings\. Clear either', 'continuation-refine', ('A continued part is made in full', '接续会完整生成这一段'), - ('Clear either "Second pass of preview" or "Continue from video".', '请清空“继续二采”或“接着生成”其中一项。')), + ('Clear either "Second pass of preview" or "Continue from video".', '请清空“继续二采”或“继续生成”其中一项。')), (r'Continue from video cannot be used with these settings\. Connect up to 3 pictures', 'continuation-media', ('A continued part takes picture references only', '接续段只能用图片参考'), ('Connect up to 3 pictures as references, and remove video, audio and pre-encoded inputs.', '最多接 3 张图片作为参考图,并移除视频、音频参考和预编码输入。')), @@ -504,14 +504,14 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep '请选择 FreeVideo 生成且仍存在的 MP4 视频;在 ComfyUI 中,请把它放在输出或输入文件夹内。')), # An engine update waits for a video being generated, whoever started this installation's ComfyUI. (r'ComfyUI is running a job, so the engine update waits', 'update-busy', - ('A video is still generating', '还有视频正在生成'), + ('A video is still generating', '仍有视频正在生成'), ('Click Update now again when this video has finished. The video being generated is not interrupted.', - '这个视频生成完成后,再点击“立即更新”。正在生成的视频不会被打断。')), + '请在该视频生成完成后再点击“立即更新”。正在生成的视频不会被中断。')), # Moving the engine's editable install to the new source failed and was undone (engine_binding). (r'Editable install failed for|Editable verification failed for|The engine.s uv is missing', 'update-unfinished', - ('The update did not finish', '这次更新没有完成'), + ('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.', @@ -520,22 +520,22 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep r'Existing dependency is not a Git checkout of the pinned source', 'offline-installation', ('This folder holds an offline installation', '这个位置是离线包安装'), ('Open it from the launcher. For an automatic installation, go back to Setup and choose another folder.', - '请在启动页直接打开这份安装。如需自动安装,请回到安装设置,换一个安装位置。')), + '请在启动页直接打开这份安装。如需自动安装,请返回安装设置,选择其他安装位置。')), # Both carry ComfyUI's log, whose unrelated lines must not pick the card. (r'ComfyUI is still starting or could not load FreeVideo', 'comfy-nodes', - ('ComfyUI did not load FreeVideo', 'ComfyUI 没有加载 FreeVideo'), + ('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.', - '请点“重试”。仍然停在这里时,可以导出报告。')), + '请点击“重试”。如仍停在此处,可导出报告。')), (r'ComfyUI is already running\. Restart it once', 'comfy-restart', ('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 没有完整加载'), + (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'), + '请按浏览器页面上的提示处理后刷新;如仍出现,可导出报告。')), + (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.', '请点击“打开 FreeVideo”。如果页面空白或一直在加载,请用最新版 Chrome 或 Edge 打开。')), (r'NVIDIA driver/nvidia-smi is missing\.', 'gpu-driver-missing', @@ -550,7 +550,7 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep (r'CUDA_CAPABILITY_UNKNOWN', 'gpu-detection', ('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.', @@ -562,11 +562,11 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep (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', '还有视频正在生成'), + ('A video is still generating', '仍有视频正在生成'), ('The update is installed. Click Connect again after the job finishes to restart ComfyUI with it.', - '更新已装好。等当前任务完成后点“重新连接”,ComfyUI 会重启并使用新版。')), + '更新已安装。请在当前任务完成后点击“重新连接”,ComfyUI 会重启并使用新版。')), (r'Installation paused: Windows commit headroom', 'memory-commit', ('Windows is low on available memory', 'Windows 可用内存不足'), ('Close memory-heavy applications, then retry. Downloaded files are retained.', @@ -578,19 +578,19 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep (r'Installation RAM monitoring failed|Cannot read process-tree memory', 'memory-monitor', ('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.', '清理安装盘空间后,点击重试。')), (r'Model download paused|unexpected-transfer-size|ConnectionError|ConnectTimeout|ReadTimeout|HTTP probe failed|Could not resolve host|SSL certificate|Every download source failed|All Git sources failed|All package sources failed|Python download failed on every route', 'download', - ('Download interrupted', '下载中断了'), + ('Download interrupted', '下载已中断'), ('Check your network or change the source or connection mode in Settings → Downloads, then retry.', '检查网络,或在“设置 → 下载”中切换下载源、连接模式后重试。')), (r'FreeVideo cannot read its program files', 'unreadable-files', - ('FreeVideo cannot read its own files', 'FreeVideo 读不到自己的程序文件'), + ('FreeVideo cannot read its program files', 'FreeVideo 无法读取程序文件'), ('Windows denies this account access to them, usually because they were created while FreeVideo ran as administrator. ' 'Open FreeVideo normally and import the offline packages again; the files already there are reused.', 'Windows 不允许当前账户读取它们,通常是因为这些文件是在“以管理员身份运行”时创建的。' - '请正常打开 FreeVideo,重新导入一次离线包,已有的文件会直接复用。')), + '请以普通方式打开 FreeVideo,重新导入离线包,已有的文件会直接复用。')), (r'A file in the installation folder changed after the review', 'plan-changed-files', CHANGED_TITLE, _joined(CHANGED_WHY['files'], CHANGED_REPAIR)), (r'The installation plan changed\. Detect configuration again', 'plan-changed', CHANGED_TITLE, @@ -599,10 +599,10 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep ('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 所在的文件夹后,点“重试安装”。')), + '该文件可能已被安全软件隔离。请恢复该文件或允许 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 some runtime files', 'Windows 拦截了运行组件'), + ('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', @@ -612,7 +612,7 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep (r"FreeVideo's PyTorch library files do not match", 'runtime-libraries', ('Runtime files have been changed', '运行组件已被更改'), ('Click Repair to reinstall a complete copy of PyTorch. Models and videos are kept.', - '请点“修复”,FreeVideo 会重新安装运行组件,模型和视频都会保留。')), + '请点击“修复”,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|os error 1455', 'memory', ('Memory is unavailable for this step', '这一步可用内存不足'), diff --git a/freevideo_engine/image_gen/failure.py b/freevideo_engine/image_gen/failure.py index c7cde206..0b5b8e60 100644 --- a/freevideo_engine/image_gen/failure.py +++ b/freevideo_engine/image_gen/failure.py @@ -20,16 +20,16 @@ STAGES = ('start', 'load', 'text', 'encode', 'denoise', 'decode') KINDS = ('generate', 'edit', 'cast', 'compose', 'download', 'install') MESSAGE_ZH = { - 'no_memory': '显存不够,最小尺寸也没能生成。关掉其他占用显卡的程序后再试。', + 'no_memory': '显存不足,最小尺寸也未能生成。请关闭其他占用显卡的程序后重试。', 'small_memory': '这台 Mac 的内存不足 16 GB,无法生成图片。', - 'busy_video': '视频正在生成,完成后再试。', - 'busy': '已有一张图在生成,请等它完成。', - 'pack_missing': '请先在启动器里下载“图像生成模型”。', - 'model_unreadable': '图像生成模型的文件读不出来。在启动器里修复后再试。', - 'device_unavailable': '显卡暂时用不了,稍后再试。', - 'worker_exit': '图像生成意外停止了,请再试一次。', - 'timeout': '这一张用时太长,已停止。请再试一次。', - 'generation_failed': '图像生成出错了,请再试一次。', + 'busy_video': '视频正在生成,请在完成后重试。', + 'busy': '已有图片正在生成,请在完成后重试。', + 'pack_missing': '请先在启动器中下载“图像生成模型”。', + 'model_unreadable': '无法读取图像生成模型文件,请在启动器中修复后重试。', + 'device_unavailable': '显卡暂时不可用,请稍后重试。', + 'worker_exit': '图像生成意外停止,请重试。', + 'timeout': '此图片生成超时,已停止。请重试。', + 'generation_failed': '图像生成出错,请重试。', 'invalid_request': '图片设置有误,请调整后重试。', 'invalid_media': '图片无效,请重新选择输入目录中的图片。点选支持不超过 4096×4096 像素总数的图片。', 'invalid_points': '请选择 1–16 个图片范围内的点,且至少有一个选中点;1 为选中,0 为排除。', @@ -51,7 +51,7 @@ _MPS_MEMORY = re.compile(r'MPS backend out of memory', re.I) _LIMIT = 16 * 1024 # On a Mac the GPU's memory is the machine's: the same failure, in its words. -UNIFIED_MESSAGE_ZH = {'no_memory': '内存不够,最小尺寸也没能生成。关掉其他程序后再试。'} +UNIFIED_MESSAGE_ZH = {'no_memory': '内存不足,最小尺寸也未能生成。请关闭其他程序后重试。'} class FailureError(ValueError): diff --git a/freevideo_engine/launcher/Main.qml b/freevideo_engine/launcher/Main.qml index 5b26d445..26460bda 100644 --- a/freevideo_engine/launcher/Main.qml +++ b/freevideo_engine/launcher/Main.qml @@ -107,12 +107,12 @@ ApplicationWindow { var d = disk if (!d) return "" var en = "\"" + d.name + "\"", zh = "“" + d.name + "”" - 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. Choose another disk.", zh + " 不能写入,请换一块硬盘。") + 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 + " cannot be used for 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. 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 + "。") + zh + "可用 " + d.free + ",本次安装需要约 " + d.need + "。") return t(d.name + " · " + d.free + " available", d.name + " · 可用 " + d.free) + (d.need ? t(" · about " + d.need + " needed", " · 需要约 " + d.need) : "") } @@ -132,23 +132,23 @@ ApplicationWindow { function upgradeHeadline() { var u = upgrade 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 === "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 后切换") if (u.status === "downloaded" || (u.status === "switching" && !s.busy)) return t("Switching after the current video", "当前视频完成后切换") if (u.status === "switching") return t("Switching to the faster model", "正在切换到更快的模型") if (u.status === "releasing") return u.waiting ? t("Removing the old model after the current video", "当前视频完成后删除旧模型") : t("Removing the old model", "正在删除旧模型") - if (u.status === "releasable") return u.in_use === "int8_convrot" ? t("The old model is still on disk", "旧模型还占着磁盘空间") - : t("Some model files are not in use", "有没在使用的模型文件") + if (u.status === "releasable") return u.in_use === "int8_convrot" ? t("The old model is still on disk", "旧模型仍占用磁盘空间") + : t("Some model files are not in use", "有未使用的模型文件") if (u.status === "complete" && u.in_use) return u.in_use === "int8_convrot" ? t("The old model was removed", "旧模型已删除") - : t("Unused model files were removed", "没在使用的模型文件已删除") + : t("Unused model files were removed", "未使用的模型文件已删除") if (u.status === "complete") return t("Upgraded to the faster model", "已升级到更快的模型") - if (u.status === "failed") return u.step === "release" ? t("The old model was not removed", "旧模型没有删除") : t("The upgrade stopped", "升级未完成") + if (u.status === "failed") return u.step === "release" ? t("The old model was not removed", "旧模型未删除") : t("The upgrade stopped", "升级未完成") return "" } 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("The faster model is not in use, so the old model is kept.", "没有在用更快的模型,旧模型保留。") + 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("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.", "当前使用的模型不是标准安装的模型,为安全起见没有删除任何文件。") } @@ -164,31 +164,31 @@ ApplicationWindow { : 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") - return t("It needs about ", "需要约 ") + bytes(u.required_bytes) + t(" free; ", " 可用空间,目前剩 ") + bytes(u.free_bytes) - + t(" is free. Clean the download cache in Settings → Storage or free some space, then reopen FreeVideo.", "。可以在 设置 → 存储 清理下载缓存,或者腾出空间后重新打开 FreeVideo。") + return t("It needs about ", "需要约 ") + bytes(u.required_bytes) + t(" free; ", " 可用空间,当前可用 ") + bytes(u.free_bytes) + + t(" is free. Clean the download cache in Settings → Storage or free some space, then reopen FreeVideo.", "。请在“设置 → 存储”中清理下载缓存,或释放空间后重新打开 FreeVideo。") if (u.status === "downloading") return u.progress && u.progress.unit === "bytes" && u.progress.total ? bytes(u.progress.done) + " / " + bytes(u.progress.total) + t(" · you can keep generating", " · 可以继续生成") : t("Checking the download…", "正在检查下载内容…") if (u.status === "downloaded" && u.waiting === "comfy") 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 没法重启它。关闭它后会自动开始切换,在此之前当前模型照常可用。") + "该 ComfyUI 并非由启动器打开,FreeVideo 无法重启它。关闭它后会自动开始切换,在此之前当前模型照常可用。") if (u.status === "downloaded" || u.status === "switching") 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 faster model. Removing the old model frees about ", "已在用更快的模型,删除旧模型可释放约 ") + bytes(u.release_bytes) - + t("; only its own files are removed.", ",只删除旧模型自己的文件。") + 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("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 === "releasing") return t("Only the old model's own files are removed.", "仅删除旧模型的文件。") if (u.status === "complete") return (u.released_bytes ? t("Freed ", "已释放 ") + bytes(u.released_bytes) + t(".", "。") : "") + (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 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.", "原来的模型没有变化,可以继续使用。")) + + (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 "" } function updateHeadline() { @@ -403,7 +403,7 @@ ApplicationWindow { Item { height: shortWindow ? 0 : 16 } ColumnLayout { Layout.fillWidth: true; spacing: 8; Layout.bottomMargin: shortWindow ? 0 : 10 - FText { objectName: "pageHeading"; horizontalAlignment: Text.AlignHCenter; text: s.page === "launcher" ? t("Your workspace", "你的工作空间") : s.page === "comfy" ? t("Set up FreeVideo", "安装 FreeVideo") : s.page === "models" ? t("Set up your models", "准备模型") : s.status === "review" ? t("Ready to install", "确认安装") : s.busy ? t("Setting things up", "正在准备 FreeVideo") : t("Continue your setup", "继续安装"); font.pixelSize: win.compact ? 26 : theme.hero; font.weight: Font.DemiBold; font.letterSpacing: -0.6; Layout.fillWidth: true } + FText { objectName: "pageHeading"; horizontalAlignment: Text.AlignHCenter; text: s.page === "launcher" ? t("Workspace", "工作空间") : s.page === "comfy" ? t("Set up FreeVideo", "安装 FreeVideo") : s.page === "models" ? t("Set up your models", "准备模型") : s.status === "review" ? t("Ready to install", "确认安装") : s.busy ? t("Setting up FreeVideo", "正在准备 FreeVideo") : t("Continue your setup", "继续安装"); font.pixelSize: win.compact ? 26 : theme.hero; font.weight: Font.DemiBold; font.letterSpacing: -0.6; Layout.fillWidth: true } FText { objectName: "actionNotice"; visible: !!s.action_notice; text: s.action_notice || ""; color: theme.muted; Layout.fillWidth: true; horizontalAlignment: Text.AlignHCenter } } @@ -438,7 +438,7 @@ ApplicationWindow { Flow { Layout.fillWidth: true; spacing: 8 FButton { objectName: "moveToDisk"; visible: diskFailure && s.form.new_comfy && !!bestDisk; enabled: !s.busy; primary: true - text: bestDisk ? t("Install on \"" + bestDisk.name + "\"", "改装到“" + bestDisk.name + "”") : "" + text: bestDisk ? t("Install on \"" + bestDisk.name + "\"", "改为安装到“" + bestDisk.name + "”") : "" 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 } @@ -475,7 +475,7 @@ ApplicationWindow { Layout.fillWidth: true; spacing: 12 FChoice { objectName: "newComfyMethod"; Layout.fillWidth: true; Layout.minimumWidth: 0; Layout.preferredWidth: 1; Layout.fillHeight: true - text: t("Install ComfyUI", "帮我安装 ComfyUI") + text: t("Install ComfyUI", "安装 ComfyUI") detail: t("Prepare ComfyUI and its environment.", "准备 ComfyUI 和运行环境。") checked: s.form.new_comfy; enabled: !s.busy onClicked: backend.edit("new_comfy", true) @@ -587,16 +587,16 @@ ApplicationWindow { } RowLayout { visible: usingRuntime; Layout.fillWidth: true - FText { objectName: "runtimeReadyOnModels"; text: t("✓ Environment ready · now the models", "✓ 运行环境已就绪 · 接下来准备模型"); color: theme.success; Layout.fillWidth: true } + FText { objectName: "runtimeReadyOnModels"; text: t("✓ Environment ready · next, prepare the models", "✓ 运行环境已就绪 · 接下来准备模型"); color: theme.success; Layout.fillWidth: true } FButton { text: t("Change", "更改"); flat: true; enabled: !s.busy; onClicked: backend.action("back", false) } } - FText { text: usingRuntime ? t("Where should the models come from?", "选择模型来源") : t("How would you like to get the rest?", "选择下载方式"); font.pixelSize: theme.strong; font.weight: Font.DemiBold; Layout.fillWidth: true; Layout.topMargin: 6 } + FText { text: usingRuntime ? t("Choose a model source", "选择模型来源") : t("Choose how to download the remaining files", "选择下载方式"); font.pixelSize: theme.strong; font.weight: Font.DemiBold; Layout.fillWidth: true; Layout.topMargin: 6 } RowLayout { Layout.fillWidth: true; spacing: 12 FChoice { objectName: "automaticMethod"; Layout.fillWidth: true; Layout.minimumWidth: 0; Layout.preferredWidth: 1; Layout.fillHeight: true text: t("Automatic download", "自动下载") - detail: usingRuntime ? t("Download the models into this offline installation.", "把模型下载到这个离线安装里。") : t("Download only what's missing.", "自动补齐缺少的文件。") + detail: usingRuntime ? t("Download the models into this offline installation.", "将模型下载到此离线安装中。") : t("Download only what's missing.", "自动补齐缺少的文件。") checked: !offlineSelected; enabled: !s.busy onClicked: backend.edit("model_method", "auto") } @@ -618,7 +618,7 @@ ApplicationWindow { } FButton { objectName: "downloadSource"; text: s.source_name + " ›"; flat: true; implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1; onClicked: { settingsTab = "downloads"; settingsOpen = true } } } - FText { visible: usingRuntime; text: t("An interrupted download resumes; downloaded files are kept.", "下载中断后可以接着下载,已下载的文件不会丢。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } + FText { visible: usingRuntime; text: t("An interrupted download resumes; downloaded files are kept.", "下载中断后可继续,已下载的文件会保留。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } FMeter { visible: usingRuntime && s.offline.status === "downloading"; Layout.fillWidth: true; fraction: win.fraction(s.offline); active: visible } FText { visible: usingRuntime && s.offline.status === "downloading"; text: t("Downloading models", "下载模型") + (number(s.offline.total) ? " · " + bytes(s.offline.done) + " / " + bytes(s.offline.total) : ""); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } } @@ -826,14 +826,14 @@ ApplicationWindow { FButton { // Repair and opening FreeVideo come first; the banner's colour already marks the update. id: engineUpdate; objectName: "engineUpdateButton"; primary: !s.repair_offered && s.status !== "open" && !(updateFirst && !s.retry_kind); visible: !s.update.phase; enabled: !s.busy - text: s.update.status === "error" ? t("Retry update", "重试更新") : s.update.status === "ready" && s.update.candidate ? t("Restart & update", "重启并更新") : s.update.manual && s.update.candidate ? t("Download ↗", "去下载 ↗") : t("Update now", "立即更新") + text: s.update.status === "error" ? t("Retry update", "重试更新") : s.update.status === "ready" && s.update.candidate ? t("Restart & update", "重启并更新") : s.update.manual && s.update.candidate ? t("Download ↗", "前往下载 ↗") : t("Update now", "立即更新") onClicked: { manualUpdate = !!s.update.candidate && !s.update.manual; backend.update("") } } } FMeter { visible: s.update.phase === "downloading"; Layout.fillWidth: true; fraction: s.update.progress && s.update.progress.total ? s.update.progress.done / s.update.progress.total : -1; active: visible } FText { visible: !s.update.phase && !!text; text: releaseSummary(availableRelease); Layout.fillWidth: true; color: theme.muted; font.pixelSize: theme.micro } FButton { text: t("What's new", "更新内容"); flat: true; visible: !s.update.phase && !!availableRelease; implicitHeight: theme.heightSm; onClicked: releaseNotesOpen = true } - FButton { objectName: "updateBannerManualDownload"; text: t("Download from the release page ↗", "去发布页手动下载 ↗"); flat: true; visible: s.update.status === "error" && !!s.update.release_page; implicitHeight: theme.heightSm; onClicked: backend.link(s.update.release_page) } + FButton { objectName: "updateBannerManualDownload"; text: t("Download from the release page ↗", "前往发布页手动下载 ↗"); flat: true; visible: s.update.status === "error" && !!s.update.release_page; implicitHeight: theme.heightSm; onClicked: backend.link(s.update.release_page) } FButton { objectName: "updateBannerReport"; text: t("Report a problem", "提交问题"); flat: true; visible: s.update.status === "error" && !!s.update.error; implicitHeight: theme.heightSm; onClicked: backend.reportProblem(s.update.error, t("Update", "更新")) } } FCard { @@ -890,9 +890,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 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: "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("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 } + 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 } @@ -1222,7 +1222,7 @@ 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: 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 } + 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" @@ -1240,7 +1240,7 @@ ApplicationWindow { 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 } + 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 } @@ -1270,7 +1270,7 @@ ApplicationWindow { 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 } + 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 @@ -1306,7 +1306,7 @@ ApplicationWindow { } } } - 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 } + FText { objectName: "lanRemedyHint"; visible: Qt.platform.os === "windows" && !!s.form.lan_access; text: t("If the phone cannot open the page, 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", "存储") @@ -1373,7 +1373,7 @@ ApplicationWindow { objectName: "modelStorageStatus"; Layout.fillWidth: true; font.pixelSize: theme.micro; color: theme.muted; wrapMode: Text.WordWrap 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.", "。") + : 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() } } @@ -1407,7 +1407,7 @@ 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: 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 { 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 { @@ -1473,8 +1473,8 @@ ApplicationWindow { id: diskContents; spacing: 12 FText { text: t("Choose a disk", "选择安装硬盘"); font.pixelSize: theme.section + 2; font.weight: Font.DemiBold; Layout.fillWidth: true } FText { objectName: "diskDialogText"; Layout.fillWidth: true; color: theme.muted; font.pixelSize: theme.micro + 1 - text: (disk && disk.need ? t("This installation needs about " + disk.need + ".\n", "这次安装需要约 " + disk.need + "。\n") : "") - + t("FreeVideo is installed in a FreeVideo folder on the disk you choose.", "FreeVideo 会装在所选硬盘的 FreeVideo 文件夹里。") } + text: (disk && disk.need ? t("This installation needs about " + disk.need + ".\n", "本次安装需要约 " + disk.need + "。\n") : "") + + t("FreeVideo is installed in a FreeVideo folder on the disk you choose.", "FreeVideo 将安装在所选硬盘的 FreeVideo 文件夹中。") } Repeater { model: diskChoices delegate: Rectangle { @@ -1493,7 +1493,7 @@ ApplicationWindow { FText { visible: !modelData.enough; Layout.fillWidth: true; color: theme.danger; font.pixelSize: theme.micro text: modelData.problem === "exfat" ? t("Cannot install", "无法安装") : modelData.problem === "fat32" ? t("Cannot install", "无法安装") - : t("Not enough space", "空间不够") } + : 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 } } @@ -1706,9 +1706,9 @@ ApplicationWindow { spacing: 14 FText { objectName: "updateActionNotice"; visible: !!s.action_notice; text: s.action_notice || ""; color: theme.muted; Layout.fillWidth: true } FText { text: s.update.candidate ? t("Update available", "有可用更新") : s.update.engine ? t("Engine update", "引擎更新") : t("Updates", "更新"); font.pixelSize: theme.section + 2; font.weight: Font.DemiBold } - FText { objectName: "updateDialogText"; text: s.update.phase === "waiting" ? t("A video is still generating. FreeVideo restarts and updates as soon as it finishes.", "还有视频正在生成,完成后会自动重启并更新。") : s.update.status === "ready" && s.update.candidate ? t("Downloaded. Restart to finish; models and settings are kept.", "下载完成,重启即可完成更新,模型和设置都会保留。") : s.update.candidate && s.update.manual ? t("FreeVideo ", "FreeVideo ") + releaseVersion(s.update.candidate) + t(" is available (current ", " 已发布(当前 ") + releaseVersion(currentRelease) + t("). Download it and replace this version; models and settings are kept.", ")。下载新版后替换当前版本,模型和设置会保留。") : s.update.candidate ? t("FreeVideo ", "FreeVideo ") + releaseVersion(s.update.candidate) + t(" is available (current ", " 已发布(当前 ") + releaseVersion(currentRelease) + t("). Updating keeps your models and settings. FreeVideo restarts after the download; running videos finish first.", ")。更新会保留模型和设置,下载完成后自动重启;正在生成的视频会先完成。") : s.update.engine ? t("This launcher already includes engine ", "启动器已带有新版引擎 ") + releaseVersion(currentRelease) + t(" (installed: ", "(已安装 ") + engineVersion + (s.status === "open" ? t("). Updating takes about a minute, restarts ComfyUI once and keeps your models and settings.", ")。更新约需 1 分钟,会重启一次 ComfyUI,模型和设置都会保留。") : t("). Updating takes about a minute, then FreeVideo starts; models and settings are kept.", ")。更新约需 1 分钟,完成后自动启动,模型和设置都会保留。")) : s.update.status === "current" ? t("You're up to date.", "已是最新版本。") : s.update.status === "development" ? t("Running from source. Update with Git.", "当前从源码运行,请通过 Git 更新。") : s.update.status === "error" ? t("Could not check for updates.", "暂时无法检查更新。") : t("Checking the latest release…", "正在检查最新版本…"); color: theme.muted; Layout.fillWidth: true } + FText { objectName: "updateDialogText"; text: s.update.phase === "waiting" ? t("A video is still generating. FreeVideo restarts and updates as soon as it finishes.", "仍有视频正在生成,完成后将自动重启并更新。") : s.update.status === "ready" && s.update.candidate ? t("Downloaded. Restart to finish; models and settings are kept.", "下载完成,重启后完成更新,模型和设置都会保留。") : s.update.candidate && s.update.manual ? t("FreeVideo ", "FreeVideo ") + releaseVersion(s.update.candidate) + t(" is available (current ", " 已发布(当前 ") + releaseVersion(currentRelease) + t("). Download it and replace this version; models and settings are kept.", ")。下载新版后替换当前版本,模型和设置会保留。") : s.update.candidate ? t("FreeVideo ", "FreeVideo ") + releaseVersion(s.update.candidate) + t(" is available (current ", " 已发布(当前 ") + releaseVersion(currentRelease) + t("). Updating keeps your models and settings. FreeVideo restarts after the download; running videos finish first.", ")。更新会保留模型和设置,下载完成后自动重启;正在生成的视频会先完成。") : s.update.engine ? t("This launcher already includes engine ", "启动器已带有新版引擎 ") + releaseVersion(currentRelease) + t(" (installed: ", "(已安装 ") + engineVersion + (s.status === "open" ? t("). Updating takes about a minute, restarts ComfyUI once and keeps your models and settings.", ")。更新约需 1 分钟,会重启一次 ComfyUI,模型和设置都会保留。") : t("). Updating takes about a minute, then FreeVideo starts; models and settings are kept.", ")。更新约需 1 分钟,完成后自动启动,模型和设置都会保留。")) : s.update.status === "current" ? t("You're up to date.", "已是最新版本。") : s.update.status === "development" ? t("Running from source. Update with Git.", "当前从源码运行,请通过 Git 更新。") : s.update.status === "error" ? t("Could not check for updates.", "暂时无法检查更新。") : t("Checking the latest release…", "正在检查最新版本…"); color: theme.muted; Layout.fillWidth: true } FText { visible: !!s.update.error; text: s.update.error || ""; color: theme.danger; Layout.fillWidth: true; font.pixelSize: theme.micro } - FButton { objectName: "updateManualDownloadButton"; text: t("Download from the release page ↗", "去发布页手动下载 ↗"); flat: true; Layout.fillWidth: true; visible: s.update.status === "error" && !!s.update.release_page; onClicked: backend.link(s.update.release_page) } + FButton { objectName: "updateManualDownloadButton"; text: t("Download from the release page ↗", "前往发布页手动下载 ↗"); flat: true; Layout.fillWidth: true; visible: s.update.status === "error" && !!s.update.release_page; onClicked: backend.link(s.update.release_page) } FButton { objectName: "updateDialogReport"; text: t("Report a problem", "提交问题"); flat: true; Layout.fillWidth: true; visible: s.update.status === "error" && !!s.update.error; onClicked: backend.reportProblem(s.update.error, t("Update", "更新")) } FMeter { Layout.fillWidth: true; visible: s.update.status === "downloading"; active: true; fraction: s.update.progress && s.update.progress.total ? s.update.progress.done/s.update.progress.total : -1 } FReleaseNotes { objectName: "updateReleaseNotes"; visible: !!availableRelease; Layout.fillWidth: true; release: availableRelease; earlier: availableEarlier; zh: s.zh; heading: t("What's new", "更新内容") + " · " + releaseVersion(availableRelease) } @@ -1719,7 +1719,7 @@ ApplicationWindow { id: updateActions; Layout.fillWidth: true FButton { objectName: "updateLaterButton"; text: s.update.candidate || s.update.engine ? t("Later", "稍后更新") : t("Close", "关闭"); flat: true; onClicked: { manualUpdate = false; backend.dismissUpdate() } } Item { Layout.fillWidth: true } - FButton { objectName: "updateNowButton"; text: s.update.status === "error" ? t("Retry update", "重试更新") : s.update.status === "ready" && s.update.candidate ? t("Restart & update", "重启并更新") : s.update.manual && s.update.candidate ? t("Download ↗", "去下载 ↗") : s.update.candidate || s.update.engine ? t("Update now", "立即更新") : t("Check again", "重新检查"); primary: true; enabled: ["checking","downloading"].indexOf(s.update.status) < 0 && s.update.phase !== "waiting"; onClicked: { manualUpdate = (!!s.update.candidate || !s.update.engine) && !(s.update.manual && s.update.candidate); backend.update("") } } + FButton { objectName: "updateNowButton"; text: s.update.status === "error" ? t("Retry update", "重试更新") : s.update.status === "ready" && s.update.candidate ? t("Restart & update", "重启并更新") : s.update.manual && s.update.candidate ? t("Download ↗", "前往下载 ↗") : s.update.candidate || s.update.engine ? t("Update now", "立即更新") : t("Check again", "重新检查"); primary: true; enabled: ["checking","downloading"].indexOf(s.update.status) < 0 && s.update.phase !== "waiting"; onClicked: { manualUpdate = (!!s.update.candidate || !s.update.engine) && !(s.update.manual && s.update.candidate); backend.update("") } } } } } @@ -1828,11 +1828,11 @@ ApplicationWindow { } FDialog { objectName: "releaseConfirmation"; visible: releaseConfirm - title: upgrade.in_use === "int8_convrot" ? t("Remove the old model?", "删除旧模型?") : t("Remove unused model files?", "删除没在使用的模型文件?") + title: upgrade.in_use === "int8_convrot" ? t("Remove the old model?", "删除旧模型?") : t("Remove unused model files?", "删除未使用的模型文件?") 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 model in use, your videos and settings are not touched.", "。现在用的模型、视频和设置都不受影响。") - : t(". The model in use, your videos and settings are not touched.", "。正在使用的模型、视频和设置都不受影响。")) + : t("The model files FreeVideo does not use are removed, about ", "将删除 FreeVideo 未使用的模型文件,释放约 ")) + bytes(upgrade.release_bytes || 0) + + (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() } onRejected: releaseConfirm = false diff --git a/freevideo_engine/launcher_copy.py b/freevideo_engine/launcher_copy.py index 37d07145..3fd05f1b 100644 --- a/freevideo_engine/launcher_copy.py +++ b/freevideo_engine/launcher_copy.py @@ -10,17 +10,17 @@ 'download_missing': ('The download folder no longer exists. Export the list again.', '下载目录已不存在,请重新导出清单'), 'installation_missing': ('There is no installation information yet. Complete installation first.', - '还没有安装信息,请先完成安装'), + '尚无安装信息,请先完成安装'), 'checking_update': ('Checking for updates. Please wait.', '正在检查更新,请稍候'), 'update_install_failed': ('The update could not be installed. You can keep using the current version.', '无法安装更新。当前版本可以照常使用。'), 'update_page_failed': ('Couldn\'t open the download page. Please try again later.', '无法打开下载页,请稍后再试。'), - 'busy': ('The background task is busy. You can do this when it finishes.', - '后台正在忙,完成后可以操作'), + 'busy': ('A background task is running. Try again when it finishes.', + '后台任务进行中,请在完成后重试'), 'import_queued': ('Saved. Importing will start when the current task finishes.', - '已记下,处理完当前任务后自动导入'), + '已保存,当前任务完成后将自动导入'), 'setup_queued': ('Models are being prepared in the background. Setup will continue when they are ready.', - '后台正在准备模型,好了会接着继续'), + '后台正在准备模型,完成后将继续安装'), 'update_queued': ('The update will start after the current task finishes.', '当前任务完成后会自动更新'), } @@ -183,7 +183,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。', 'This port and the next ones are in use or reserved by the system. Set a different local ComfyUI address.': '这个端口和后面的端口都已被占用或被系统保留,请换一个本机 ComfyUI 地址。', - 'The ports for LAN access are in use or reserved by the system. Choose another ComfyUI port.': '局域网访问要用的端口已被占用或被系统保留,请换一个 ComfyUI 端口。', + 'The ports for LAN access are in use or reserved by the system. Choose another ComfyUI port.': '局域网访问所需的端口已被占用或被系统保留,请更换 ComfyUI 端口。', 'A generation is running. Change the address after it finishes.': '正在生成,完成后可修改地址。', 'The ComfyUI already running does not use this address. After its jobs finish, restart it with this address and connect again.': '已在运行的 ComfyUI 没有使用这个地址。请在任务完成后用这个地址重新启动 ComfyUI,再重新连接。', 'The ComfyUI already running is open to the network directly. After its jobs finish, restart it on 127.0.0.1, then turn on LAN access.': '已在运行的 ComfyUI 正在直接对局域网开放。请在任务完成后改用 127.0.0.1 重新启动它,再开启局域网访问。', diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index 9ee8c5d4..d525f676 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -854,8 +854,8 @@ def prompt_model_view(self): failure = '' if active.get('state') == 'failed': failure = {'disk_space': self.t('Not enough disk space.', '磁盘空间不足。'), - 'busy': self.t('Another download of this model is running.', '这个模型正在被另一处下载。')}.get( - active.get('reason'), self.t('The download stopped. Resume to continue.', '下载中断了,点继续可接着下载。')) + 'busy': self.t('Another download of this model is running.', '该模型正在由其他任务下载。')}.get( + active.get('reason'), self.t('The download stopped. Resume to continue.', '下载已中断,点击“继续”可恢复下载。')) return dict(ready=ready, checking=checking, total_bytes=total, enough_disk=enough, busy=busy, install=active, failure=failure, downloaded_bytes=active.get('done', 0), title=self.t(*NAMES['text']), suggest=bool(engine) and not ready and not checking and not busy and enough and not self.form['prompt_hint_dismissed'] @@ -1024,7 +1024,7 @@ def open_browser(self): try: if not open_browser(address): raise OSError(self.t("Couldn't open the browser. Click Copy address and open it in your browser.", - '没能自动打开浏览器,请点“复制地址”,在浏览器里打开。')) + '未能自动打开浏览器,请点击“复制地址”后在浏览器中打开。')) if moved_from: self.port_notice_url = self.browser_url self.browser_error = '' @@ -1663,7 +1663,7 @@ def adaln_dialog_view(self, error, update): body=self.t('This frees %s of disk space. If the files are needed later, they will be downloaded again automatically.', '可释放 %s 磁盘空间。以后如需要,会自动重新下载。') % size, delete_text=(self.t('Deleting…', '正在删除…') if thread is not None else - self.t('Try again', '再试一次') if self.adaln_retry else self.t('Delete', '删除')), + self.t('Try again', '重试') if self.adaln_retry else self.t('Delete', '删除')), later_text=self.t('Not now', '暂不删除')) def sync_lan_from_store(self): @@ -1840,10 +1840,10 @@ def copied_models_text(self): return '' if value.get('copied_model_reason') == 'exfat': return self.t('This disk uses exFAT, so %s of models had to be copied instead of shared with your model library.', - '这块硬盘是 exFAT 格式,有 %s 的模型只能复制,不能和模型库共用。') % size_text(copied) + '该硬盘为 exFAT 格式,%s 的模型只能复制,无法与模型库共用。') % size_text(copied) return self.t('%s of models were copied from another disk. Keep your model library on the same disk as FreeVideo ' 'to avoid this.', - '有 %s 的模型是从另一块硬盘复制来的。把模型库和 FreeVideo 放在同一块硬盘上,就不会多占这部分空间。') % size_text(copied) + '%s 的模型从其他硬盘复制而来。将模型库与 FreeVideo 放在同一硬盘上,可避免占用这部分额外空间。') % size_text(copied) def archive_offer(self): """The imported ZIPs whose contents the running installation holds, verified.""" @@ -1978,7 +1978,7 @@ def archives_dialog_view(self, offer, idle, error, update): 'Deleting them does not affect models, the runtime or your videos.', '导入过的离线包压缩包仍保留在磁盘上,FreeVideo 已不再需要。删除后,模型、运行环境和作品都不受影响。'), delete_text=(self.t('Deleting…', '正在删除…') if self.archives_busy else - self.t('Try again', '再试一次') if retry else + self.t('Try again', '重试') if retry else self.t('Delete and free %s', '删除并释放 %s') % size), keep_text=self.t('Keep for now', '暂时保留'), footnote=self.t('You can also delete them later in Settings › Preferences › Storage.', diff --git a/freevideo_engine/launcher_update.py b/freevideo_engine/launcher_update.py index 9c8c8bc2..984f60c8 100644 --- a/freevideo_engine/launcher_update.py +++ b/freevideo_engine/launcher_update.py @@ -132,7 +132,7 @@ def error_text(row, zh=False): """Product copy stays separate from raw exceptions, URLs and credentials.""" messages = { 'rate_limited': ('The update service is busy.', '更新服务繁忙。'), - 'missing': ('The files for the new version are still being published.', '新版本的文件还在发布中。'), + 'missing': ('The files for the new version are still being published.', '新版本的文件仍在发布中。'), 'blocked': ('The update service could not be reached.', '无法访问更新服务。'), 'connection': ('Could not connect to the download server.', '无法连接下载服务器。'), 'invalid': ('The downloaded update files are incomplete or damaged.', '下载的更新文件不完整或已损坏。'), @@ -145,7 +145,7 @@ def error_text(row, zh=False): if row.get('retry_at'): return text + ('稍后会自动重试,当前版本可以照常使用。' if zh else ' FreeVideo will try again later. You can keep using the current version.') - return text + ('请重试,或去发布页手动下载。' if zh else + return text + ('请重试,或前往发布页手动下载。' if zh else ' Try again, or download it from the release page.') diff --git a/freevideo_engine/launcher_view.py b/freevideo_engine/launcher_view.py index 9ce17936..12e6fc27 100644 --- a/freevideo_engine/launcher_view.py +++ b/freevideo_engine/launcher_view.py @@ -257,8 +257,8 @@ def wheel(event): choices.columnconfigure((0, 1), weight=1, uniform='choices') app.mode_buttons = [] for index, (value, title, detail) in enumerate(( - ('existing', ('I have ComfyUI', '我已安装 ComfyUI'), ('Connect your installation.', '接入现有安装。')), - ('new', ('Install ComfyUI for me', '帮我安装 ComfyUI'), ('ComfyUI, Python and FreeVideo together.', '一并准备 ComfyUI、Python 和 FreeVideo。')))): + ('existing', ('Use existing ComfyUI', '使用已有 ComfyUI'), ('Connect your installation.', '接入现有安装。')), + ('new', ('Install ComfyUI', '安装 ComfyUI'), ('ComfyUI, Python and FreeVideo together.', '一并准备 ComfyUI、Python 和 FreeVideo。')))): choice = Choice(choices, app, value, title, detail) choice.grid(row=0, column=index, sticky='nsew', padx=(0, b.space(2)) if index == 0 else (b.space(2), 0)) app.mode_buttons.append(choice) @@ -275,7 +275,7 @@ def wheel(event): methods = ttk.Frame(models.body, style='Card.TFrame'); methods.pack(fill='x') app.model_method_buttons = [] for value, en, zh in (('auto', 'Automatic download', '自动下载'), - ('manual', 'Download myself', '手动下载'), ('reuse', 'Use existing models', '复用已有模型')): + ('manual', 'Download manually', '手动下载'), ('reuse', 'Use existing models', '复用已有模型')): button = ttk.Radiobutton(methods, text=app.t(en, zh), value=value, variable=app.model_method) button.pack(side='left', padx=(0, b.space(3))) app.labels.append((button, en, zh)); app.model_method_buttons.append(button) @@ -358,8 +358,8 @@ def wheel(event): app.label(launch.body, 'Ready to launch', '安装已就绪', style='Hero.Card.TLabel', anchor='center').pack(fill='x', pady=(0, b.space(4))) app.launch_paths = ttk.Label(launch.body, style='Meta.Card.TLabel', anchor='center', justify='center', wraplength=680) app.launch_paths.pack(fill='x', pady=(0, b.space(3))) - app.label(launch.body, 'Already running ComfyUI? FreeVideo connects automatically.', - 'ComfyUI 已在运行?会自动连接。', style='Meta.Card.TLabel', anchor='center', justify='center', wraplength=680).pack(fill='x') + app.label(launch.body, 'If ComfyUI is already running, FreeVideo connects to it automatically.', + '如果 ComfyUI 已在运行,将自动连接。', style='Meta.Card.TLabel', anchor='center', justify='center', wraplength=680).pack(fill='x') app.browser_controls = ttk.Frame(launch.body, style='Card.TFrame') ttk.Entry(app.browser_controls, textvariable=app.browser_address, state='readonly', justify='center').pack(fill='x') browser_buttons = ttk.Frame(app.browser_controls, style='Card.TFrame'); browser_buttons.pack(pady=(8, 0)) diff --git a/freevideo_engine/model_guidance.py b/freevideo_engine/model_guidance.py index de39f4e3..868f1b2e 100644 --- a/freevideo_engine/model_guidance.py +++ b/freevideo_engine/model_guidance.py @@ -46,10 +46,10 @@ 'error_write': ('The download list could not be saved in this folder: {reason}', '下载清单未能保存到这个文件夹:{reason}'), 'error_read': ('FreeVideo does not have permission to read some files in the selected folder. Choose another folder, or download the files again without administrator rights.', 'FreeVideo 没有权限读取所选文件夹里的部分文件。请选择其他文件夹,或不使用管理员身份重新下载。'), - 'write_permission': ('this account cannot write to it. Choose another folder.', '当前账户没有写入权限,请换一个文件夹。'), + 'write_permission': ('this account cannot write to it. Choose another folder.', '当前账户没有写入权限,请选择其他文件夹。'), 'write_disk_full': ('the drive is full.', '磁盘空间不足。'), 'write_path_long': ('the path is too long. Choose a folder closer to the top of the drive.', '路径太长,请选择层级更浅的文件夹。'), - 'write_other': ('Choose another folder and try again.', '请换一个文件夹再试一次。'), + 'write_other': ('Choose another folder and try again.', '请更换文件夹后重试。'), 'video': NAMES['video'], 'text': NAMES['text'], 'decoder': NAMES['decoder'], 'sampling': NAMES['sampling'], 'readme': ( 'FreeVideo download list\n' @@ -71,7 +71,7 @@ '\n' '也可以把 urls.txt 导入其他下载工具。urls.txt 用的是设置里选中的下载源(选“自动”时用 Hugging Face);个别文件只能从 Hugging Face 下载。请把下载的文件都放在这个文件夹或它的子文件夹里。\n' '\n' - '下载完成后,回到启动器点“使用已下载的文件夹”,再点“检查并继续”。FreeVideo 按文件大小和完整校验值识别文件,文件名不同也可以;缺少或损坏的文件会照常下载。\n' + '下载完成后,回到启动器点“使用已下载的文件夹”,再点“检查并继续”。FreeVideo 按文件大小和完整校验值识别文件,文件名不同不影响识别;缺少或损坏的文件会照常下载。\n' '\n' 'SHA256SUMS 里的路径是 aria2 的存放路径。用 urls.txt 下载时文件名可能不同,不影响使用。\n' '\n' diff --git a/freevideo_engine/model_repair.py b/freevideo_engine/model_repair.py index 8e3ef9f7..d1137bd1 100644 --- a/freevideo_engine/model_repair.py +++ b/freevideo_engine/model_repair.py @@ -27,8 +27,8 @@ '{file} differs from the version FreeVideo tests and was left unchanged. Click {entry} again: ' 'the confirmation page will list this file, and after you confirm, it will be renamed and kept, ' 'and the tested version will be downloaded.', - '{file} 与 FreeVideo 测试过的版本不同,这次没有改动它。请再次点击“{entry}”:' - '确认页会列出这个文件,确认后将它改名保留,再下载测试过的版本。', + '{file} 与 FreeVideo 测试过的版本不同,本次未作改动。请再次点击“{entry}”:' + '确认页会列出该文件,确认后将其改名保留,再下载测试过的版本。', ) CLI_FAILURE = FAILURE[0].replace('Click {entry} again', 'Run setup again (./setup.sh, or setup.cmd on Windows)') MISMATCH = 'MODEL_MISMATCH_REPAIR' @@ -36,19 +36,19 @@ # Where the launcher offers no Repair: the first sentence of FAILURE, then one way out # for each reason (failure_details.WITHOUT_REPAIR). UNCHANGED = ('{file} differs from the version FreeVideo tests and was left unchanged.', - '{file} 与 FreeVideo 测试过的版本不同,这次没有改动它。') + '{file} 与 FreeVideo 测试过的版本不同,本次未作改动。') BUSY = ('When the current task ends, a Repair button appears here; after you click it and confirm, ' 'FreeVideo renames and keeps this file, then downloads the tested version.', - '当前任务结束后,这里会出现“修复”按钮;点击并确认后,FreeVideo 会把这个文件改名保留,再下载测试过的版本。') + '当前任务结束后,此处将显示“修复”按钮;点击并确认后,FreeVideo 会将该文件改名保留,再下载测试过的版本。') OFFLINE = ('This offline-package installation cannot switch back in one step yet. Move or rename the file below, ' 'then import the Prompt model package (FreeVideo-文本编码器.zip) again on the Installation page.', - '离线包安装暂时不能一键换回。请把下面这个文件移走或改名,再在“安装”页重新导入“提示词模型(FreeVideo-文本编码器.zip)”包。') + '离线包安装暂不支持一键换回。请将下方文件移动或重命名,再在“安装”页重新导入“提示词模型(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, ' 'and the tested version is downloaded.', - '{file} 与 FreeVideo 测试过的版本不同,这次没有改动它。请点击下方的“重试安装”:' - '确认页会列出这个文件,确认后会将它改名保留,再下载测试过的版本。', + '{file} 与 FreeVideo 测试过的版本不同,本次未作改动。请点击下方的“重试安装”:' + '确认页会列出该文件,确认后会将其改名保留,再下载测试过的版本。', ) CHANGED = 'The installation plan changed. Detect configuration again and review the new plan before installing.' # A file the review listed changed before setup reached it (the confirmed plan @@ -60,11 +60,11 @@ other=('The settings, GPU or free disk space changed after you confirmed.', '确认之后设置、显卡或磁盘空间有变化。'), ) -CHANGED_REPAIR = ('Click Repair again.', '请再点一次“修复”。') +CHANGED_REPAIR = ('Click Repair again.', '请再次点击“修复”。') CHANGED_WAYS = dict( busy=('A Repair button appears here when the current task ends. Click it.', - '当前任务结束后,这里会出现“修复”按钮,请点它。'), - none=('Click Retry installation below.', '请点下方的“重试安装”。'), + '当前任务结束后,此处将显示“修复”按钮,请点击该按钮。'), + none=('Click Retry installation below.', '请点击下方的“重试安装”。'), ) IDENTITY = ('bytes', 'mtime_ns', 'st_dev', 'st_ino', 'parent_realpath') diff --git a/freevideo_engine/offline_errors.py b/freevideo_engine/offline_errors.py index ade3d1b8..9694de2c 100644 --- a/freevideo_engine/offline_errors.py +++ b/freevideo_engine/offline_errors.py @@ -13,15 +13,15 @@ '安装文件夹的路径太长。请把 FreeVideo 安装到路径较短的文件夹,例如 D:\\FreeVideo。'), 'permission': ('The offline package could not be imported: this account cannot write to the chosen folder. ' 'Choose another folder and import again.', - '离线包未能导入:当前账户没有所选文件夹的写入权限。请换一个文件夹再导入。'), + '离线包未能导入:当前账户没有所选文件夹的写入权限。请选择其他文件夹后重新导入。'), 'disk': ('The offline package could not be imported: the drive is full. ' 'Free up space or choose a folder on another drive, then import again.', - '离线包未能导入:磁盘空间不足。请腾出空间或换一个磁盘上的文件夹,再导入。'), + '离线包未能导入:磁盘空间不足。请释放磁盘空间或选择其他磁盘上的文件夹,然后重新导入。'), 'path': ('The offline package could not be imported: the path of the chosen folder is too long to save some of ' 'the files. Choose a folder with a shorter path%s and import again.', - '离线包未能导入:所选文件夹的路径太长,部分文件无法保存。请换一个路径更短的文件夹再导入%s。'), + '离线包未能导入:所选文件夹的路径过长,部分文件无法保存。请选择路径更短的文件夹%s,然后重新导入。'), 'other': ('The offline package could not be imported. Choose another folder and import again.', - '离线包未能导入。请换一个文件夹再导入。'), + '离线包未能导入。请选择其他文件夹后重新导入。'), } # The example folder is a Windows path; other systems leave it out. PATH_EXAMPLE = (', such as D:\\FreeVideo,', ',例如 D:\\FreeVideo') diff --git a/freevideo_engine/offline_packages.py b/freevideo_engine/offline_packages.py index 635bc40e..4038201a 100644 --- a/freevideo_engine/offline_packages.py +++ b/freevideo_engine/offline_packages.py @@ -577,7 +577,7 @@ def download(runtime, source, sampling_caches, progress=lambda **kw: None, cance tail = (tail + line)[-4000:] if cancelled(): processes.stop(child) - raise InterruptedError('已暂停下载,已下载的文件保留,再次开始会接着下载。') + raise InterruptedError('下载已暂停,已下载的文件已保留,再次开始时将继续下载。') try: event = json.loads(line) except ValueError: diff --git a/freevideo_engine/page_check.py b/freevideo_engine/page_check.py index 68049921..b5a4a2b0 100644 --- a/freevideo_engine/page_check.py +++ b/freevideo_engine/page_check.py @@ -52,8 +52,8 @@ def text(self, t, opened_at, address, now=None): return repair['text'][:2000] if report.get('state') == 'error': problems = [p for p in report.get('problems') or [] if isinstance(p, str) and p] - lines = [t('The browser page did not load FreeVideo completely.', '浏览器页面没有完整加载 FreeVideo。')] - lines += ['- ' + p for p in problems] or ['- ' + t('No details were sent.', '页面没有发送详情。')] + lines = [t('The browser page did not load FreeVideo completely.', '浏览器页面未完整加载 FreeVideo。')] + lines += ['- ' + p for p in problems] or ['- ' + t('No details were sent.', '页面未发送详情。')] if report.get('agent'): lines.append(t('Browser: ', '浏览器:') + str(report['agent'])) if report.get('frontend'): @@ -63,6 +63,6 @@ def text(self, t, opened_at, address, now=None): if not report.get('state') and opened_at is not None and now - opened_at >= SILENT_AFTER: return t('The browser has not opened FreeVideo yet. If no browser window appeared, click “Open FreeVideo”. ' 'If the page stays blank or keeps loading, open %s in a current Chrome or Edge.', - '浏览器还没有打开 FreeVideo。如果没有弹出浏览器窗口,请点击“打开 FreeVideo”;' + '浏览器尚未打开 FreeVideo。如果未弹出浏览器窗口,请点击“打开 FreeVideo”;' '如果页面空白或一直在加载,请用最新版 Chrome 或 Edge 打开 %s。') % address return '' diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 069a80a2..a4607c8b 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -47,8 +47,8 @@ "手机访问:同一网络下的手机可以通过顶栏的手机图标和二维码打开 FreeVideo,和 AI 全自动对话、查看生成进度。手机访问可以开关,也可以设置密码,手机登录后才能使用。", "Mac:新版在后台下载,点“重启并更新”后原位替换应用并打开新版。模型和设置都会保留,任何一步失败都会还原旧版。", "参考视频:参考视频可以只用画面、不使用它自己的声音。", - "剪辑视图:引擎中断而没做完的片段显示“上次中断”。从失败提示提交的问题会写明是哪一步出错。", - "Linux:显存放得下整个视频模型的显卡,生成视频时不再在系统内存里留一份权重副本,约省 20 GB 内存。", + "剪辑视图:引擎中断而未完成的片段显示“上次中断”。从失败提示提交的问题会写明是哪一步出错。", + "Linux:显存能容纳整个视频模型的显卡,生成视频时不再在系统内存中保留权重副本,约节省 20 GB 内存。", "提交问题:反馈里的内存读数分开记录物理可用内存和提交余量。" ] } @@ -59,7 +59,7 @@ "summary": "Image generation keeps a history of every picture and needs less memory on both small and large graphics cards, error messages everywhere now come with a button to report the problem, and Windows updates download faster in mainland China.", "changes": [ "Image generation: every image generated on this machine is kept in a history, newest first, and can be placed on the canvas, used as a reference, deleted, or opened in its folder. Blank canvases offer Standard and HD sizes.", - "Image generation: on 6-8 GB graphics cards, decoding uses less graphics memory and runs faster. On cards that can hold the whole model, the weights load straight to the card and no second copy stays in system memory, which cuts memory use after loading from about 15 GB to about 2 GB.", + "Image generation: on 6-8 GB graphics cards, decoding uses less graphics memory and runs faster. On cards that can hold the whole model, the weights load directly to the card and no second copy stays in system memory, which cuts memory use after loading from about 15 GB to about 2 GB.", "Reporting problems: wherever an error or failure message appears, including generation, editing and export, models and environment, image generation, the agent connection, the library, prompt enhancement, sharing, updates, and the launcher, a Report a problem button next to it opens the feedback form with the context filled in.", "AI Autopilot: when a chat cannot be deleted, it now says why and offers Stop and delete; other failures are reported with a button to submit the problem.", "White models: only the cat ready-made scene remains, and one click starts it. A white model that was not made now says why and what to do next, and the page shows rendering progress. A question about progress is answered instead of being started as a new request.", @@ -76,17 +76,17 @@ "summary": "生图工具会保存所有生成过的图片,小显存和大显存的显卡生图都更省内存;所有错误提示旁都有“提交问题”按钮;Windows 版在中国大陆更新更快。", "changes": [ "生图:本机生成过的图片都保存在历史记录里,按时间从新到旧排列,可放到画布、用作参考图、删除或打开所在文件夹。空白画布可选“标准 / 高清”尺寸。", - "生图:6–8 GB 显存的显卡解码时占用的显存更少,速度更快。显存放得下整个模型的显卡,权重直接加载到显卡,不再在内存里留一份副本,加载后内存占用从约 15 GB 降到约 2 GB。", - "提交问题:生成、剪辑与导出、环境与模型、生图、智能体连接、作品库、提示词优化、分享、更新和启动器里,出现错误或失败提示的地方旁边都有“提交问题”按钮,点一下带上情况打开问题反馈。", - "AI 全自动:对话删不掉时会说明原因,并提供“停止并删除”;其他失败也会提示,并带“提交问题”按钮。", - "白模:现成场景只保留“橘猫跳上书架”,点一下直接开始。白模没做成时会说明原因和下一步,渲染中页面显示进度。问白模进度时会回答进度,不再当成新要求。", + "生图:6–8 GB 显存的显卡解码时占用的显存更少,速度更快。显存能容纳整个模型的显卡,权重直接加载到显卡,不再在内存中保留副本,加载后内存占用从约 15 GB 降到约 2 GB。", + "提交问题:生成、剪辑与导出、环境与模型、生图、智能体连接、作品库、提示词优化、分享、更新和启动器里,出现错误或失败提示的地方旁边都有“提交问题”按钮,点击后附带详情打开问题反馈。", + "AI 全自动:对话无法删除时会说明原因,并提供“停止并删除”;其他失败也会提示,并带“提交问题”按钮。", + "白模:现成场景只保留“橘猫跳上书架”,点击即可开始。白模生成失败时会说明原因和下一步,渲染中页面显示进度。询问白模进度时会给出进度,不再视为新请求。", "人物设定图:背面图里头和身体一起转向背后。", "Mac:支持分段生成长视频。", - "启动器与更新:Windows 版在中国大陆更新更快。GitHub 慢时,启动器可以从国内镜像下载更新,镜像上的每个文件都先按发布签名校验。“确认安装”页适配窄窗口:侧栏收成图标,按钮始终可见。", - "工作流:旧版本存的工作流可以直接运行。旧文件里后加的选项为空或不合法时,改回默认值。", - "视频生成:“精细”档生成更快。8 GB 显存的显卡上,以前因显存差一点而被拒绝的 1920×1088 视频,现在可以生成。改动视频参考的前期步数设置后,会生成新的结果,不再沿用之前的结果。", + "启动器与更新:Windows 版在中国大陆更新更快。GitHub 慢时,启动器可以从国内镜像下载更新,镜像上的每个文件都先按发布签名校验。“确认安装”页适配窄窗口:侧栏收为图标,按钮始终可见。", + "工作流:旧版本保存的工作流可以正常运行。旧文件中新版选项为空或不合法时,恢复为默认值。", + "视频生成:“精细”档生成更快。8 GB 显存的显卡上,以前因显存略有不足而被拒绝的 1920×1088 视频,现在可以生成。改动视频参考的前期步数设置后,会生成新的结果,不再沿用之前的结果。", "LoRA:为 Wan 2.x 训练的 LoRA 选进 int8 模型时,会说明这是给 Wan 2.x 训练的 LoRA,不再只说无法使用。", - "Linux:内存较小的电脑接着再生成一次时,不再在采样快结束时内存不足。6 GB 显存的显卡选“极致”时,不再要等好几个小时。" + "Linux:内存较小的电脑连续再次生成时,不再在采样即将结束时内存不足。6 GB 显存的显卡选“极致”时,不再耗时数小时。" ] } }, @@ -99,7 +99,7 @@ "Picture editing: when changing a picture, drag a box, click an object, or draw a closed outline to select a region. Shift adds to the selection and Alt removes from it. Only the selected region changes, and everything outside it stays exactly the same. Click selection downloads a 184 MB model from ModelScope the first time it is used.", "Studio: while a long or multi-part video is generating, the button stays available as \"Add to queue\". Up to five requests can wait, and each starts when the current video finishes or is stopped.", "Agent: agents can import clips they rendered through HTTP, MCP and the command line, place them on the timeline by frame, and export. Before an agent is connected, the autopilot page says what connecting one is for.", - "Editing view: a reference picture that cannot be read is flagged right away so another one can be chosen.", + "Editing view: a reference picture that cannot be read is flagged immediately so another one can be chosen.", "Upgrade: when an old FreeVideo tab is replaced, two-pass sampling, first- and second-pass steps, the step cache and the upscale factor are carried over.", "Prompt enhancement: after the model folder is copied, an offline package is unpacked, or antivirus software scans the files, FreeVideo no longer asks to download 8.9 GB again when the files are unchanged. It now also works on 6 GB graphics cards, finishing in under a minute instead of timing out.", "Chinese interface: browser translation no longer changes FreeVideo's own labels, so the size menu keeps showing MP.", @@ -111,12 +111,12 @@ "summary": "极致档更快;改图时可以只改选中的区域;Agent 可以把自己渲染的片段导入时间线;长视频生成中可以继续加入队列。", "changes": [ "质量档位:极致档改为 20 步加带检查的步缓存,更快完成;精细档改为 16 步加步缓存。档位名称不变。", - "改图:改一张图时,可以拖框、点选物体或画闭合曲线来选区;按住 Shift 添加,按住 Alt 减去。只改选区里的内容,选区外完全不变。点选第一次使用时从魔搭下载 184 MB 的模型。", + "改图:修改图片时,可以拖框、点选物体或画闭合曲线来选区;按住 Shift 添加,按住 Alt 减去。只修改选区内的内容,选区外完全不变。首次使用点选时从魔搭下载 184 MB 的模型。", "创作面板:长视频或分段视频生成中,按钮保持可用,显示“加入队列”。最多排 5 条,当前视频完成或停止后自动开始下一条。", - "Agent:Agent 可以通过 HTTP、MCP 和命令行导入自己渲染的片段,按帧放上时间线并导出。未连接时,自动页说明连上之后能做什么。", - "剪辑视图:参考图读不出来时会当场提示,可以换一张。", - "升级:替换旧版本的 FreeVideo 标签页时,二次采样、第一遍和第二遍步数、步缓存和放大倍数都会带过来。", - "提示词增强:复制模型文件夹、解压离线包或被杀毒软件扫描之后,只要文件内容没变,就不再要求重新下载 8.9 GB。6 GB 显卡上现在也能用,一分钟内完成,不再超时。", + "Agent:Agent 可以通过 HTTP、MCP 和命令行导入自己渲染的片段,按帧放上时间线并导出。未连接时,AI 全自动页面说明连接后的用途。", + "剪辑视图:参考图无法读取时会立即提示,以便更换。", + "升级:替换旧版本的 FreeVideo 标签页时,二次采样、第一遍和第二遍步数、步缓存和放大倍数都会沿用。", + "提示词增强:复制模型文件夹、解压离线包或被杀毒软件扫描之后,只要文件内容未变,便不再要求重新下载 8.9 GB。现在也支持 6 GB 显卡,一分钟内完成,不再超时。", "中文界面:浏览器翻译不再改动 FreeVideo 自己的文字,尺寸菜单保持显示 MP。", "反馈:为失败的生成发送反馈时,会附上引擎给出的报错信息。", "安装:Windows 上的库文件检查也支持层级很深的文件夹。" @@ -139,11 +139,11 @@ ] }, "zh": { - "summary": "精细档重新比极致档快;同比例、不同尺寸的视频可以接着生成;int8 模型上的 LoRA 能用的部分照常生效;设置中新增分步演示和离线教程;安装包变小。", + "summary": "精细档重新比极致档快;同比例、不同尺寸的视频可以继续生成;int8 模型上的 LoRA 能用的部分照常生效;设置中新增分步演示和离线教程;安装包变小。", "changes": [ "精细档:重新启用步缓存,并加上检查,精细档重新比极致档快。", - "长视频:长视频的分段和接着生成的分段统一使用 8 + 3 步,第二段不再被拒绝。", - "接着生成:同比例、不同尺寸的视频改为从它自己的结尾接着生成;太短或帧率太低的视频会给出明确提示。", + "长视频:长视频的分段和继续生成的分段统一使用 8 + 3 步,第二段不再被拒绝。", + "继续生成:同比例、不同尺寸的视频改为从它自己的结尾接续生成;过短或帧率过低的视频会给出明确提示。", "LoRA:在 int8 模型上,LoRA 中无法应用的部分会跳过,其余照常生效,不再整个拒绝。", "帮助:设置中的“使用说明”改为在真实界面上演示三项操作;新增“教程”,可打开中英文离线图文手册。", "升级:旧版本留在标签页中的 FreeVideo 工作流会换成当前版本,并保留提示词、尺寸、时长、种子、素材和 LoRA。", @@ -174,14 +174,14 @@ "zh": { "summary": "AI 全自动新增白模预览场景,LoRA 兼容性检查和拖动排序,失败提示更清楚,可以删除剪辑,繁忙的机器上启动更快,标准档改为两遍采样。", "changes": [ - "AI 全自动:可选的白模预览和现成场景,在生成视频前先看到场景;入口展示里有示例。", + "AI 全自动:可选的白模预览和现成场景,在生成视频前预览场景;入口展示里有示例。", "LoRA:生成前做兼容性检查,LoRA 可以拖动排序。", - "失败提示:提交、排队和浏览器扩展的失败用同一种版式,并说明下一步该做什么;失败的请求可以打包为导出报告或提交到 GitHub。", - "编辑器:可以删除剪辑,没改动的空剪辑自动丢弃;重新打开剪辑会恢复封面并显示收尾阶段;续接在服务端排队。", - "角色:每个角色带有全部视角的角色图和脸部特写;读不出来的图片会被拒绝,并在对应的格子上给出提示。", - "标准档:从更大的第一遍起采样两遍;极致档跑满 20 步且不使用缓存。", + "失败提示:提交、排队和浏览器扩展的失败用同一种版式,并说明后续操作;失败的请求可以打包为导出报告或提交到 GitHub。", + "编辑器:可以删除剪辑,未改动的空剪辑自动丢弃;重新打开剪辑会恢复封面并显示收尾阶段;续接在服务端排队。", + "角色:每个角色带有全部视角的角色图和脸部特写;无法读取的图片会被拒绝,并在对应的格子上给出提示。", + "标准档:从更大的第一遍起采样两遍;极致档完整运行 20 步,不使用缓存。", "安装:GPU 加速组件的构建在进度中占有可见的份额,模型下载分组并行时进度行保持稳定,torch 显示为“运行组件”。", - "下载:开始下载前,启动器、页面和编辑器会说明这是什么下载、需要多久。", + "下载:开始下载前,启动器、页面和编辑器会说明下载内容和所需时间。", "指引:首次指引新增一步,说明在哪里添加参考图和首帧、尾帧;时长栏直接显示分段上限;中文界面在设置里可以打开使用说明。", "只改提示词时,参考图和片段不再重复编码;机器繁忙时,加载过程中的小型 CPU 表运算改为单线程。", "Mac:不能续接视频的设备会在一开始就拒绝超过一个分段的长度。", @@ -201,7 +201,7 @@ "zh": { "summary": "修复请求被帧数错误拒绝的问题,并让带额外部分的 LoRA 能在 int8 版本上正常生成。", "changes": [ - "用 Total frames 里填的不是帧数的工作流或恢复的标签页生成时,不再被帧数错误拒绝,这个数会按时长读取。", + "用 Total frames 中填写的不是帧数的工作流或恢复的标签页生成时,不再被帧数错误拒绝,该数值会按时长读取。", "int8:带调制部分的 LoRA 不再让生成中断,这些部分会逐个跳过,跳过的数量写进报告。" ] } @@ -223,13 +223,13 @@ ] }, "zh": { - "summary": "Mac 精细档使用步间缓存,快得多;编辑器里音频片段可以拖到另一条音频轨;几处提示和窄窗口布局更清楚;旧版本保存的工作流又能正常生成。", + "summary": "Mac 精细档使用步间缓存,快得多;编辑器里音频片段可以拖到另一条音频轨;几处提示和窄窗口布局更清楚;旧版本保存的工作流可以正常生成。", "changes": [ "Mac:精细档第一遍使用步间缓存,快得多;开始时的预计时间按芯片给出,从第一步起就更接近实际。", "编辑器:从素材库把音频拖到第二条音频轨会放在该轨;时间线上单个音频片段可以拖到另一条音频轨。", "编辑器:播放头不在所选片段上时按切分或修剪,或在浏览器不允许全屏的地方点全屏,会用一行字说明原因。", - "连着写、名字之间没有空格的角色提及都能被识别,写完一个名字后接着输入也会弹出角色列表。", - "AI 全自动:有任务在跑时,窄窗口里顶栏的“AI 全自动”按钮也始终完整可点。", + "连续书写、名字之间没有空格的角色提及都能被识别,输入完一个名字后继续输入也会弹出角色列表。", + "AI 全自动:有任务运行时,窄窗口中顶栏的“AI 全自动”按钮也始终完整可点。", "对话:手机等窄窗口里,提示中引号内的按钮名不再被拆到两行。", "图像和视频失败请求的诊断报告现在记录请求开始和生成进程结束时的可用内存,并标明进程是否疑似被系统结束。", "节点:英文界面里 Width、Height、Seed 的大小写与其他项一致。", @@ -265,12 +265,12 @@ "进度估计把参考图计入,带参考图的请求一开始就显示更接近实际的总时间。", "编辑器:把区间左边拖到时间线开头,右边不再被往后推;切分加速或倒放的片段时,淡入只留在前半段,淡出只留在后半段。", "编辑器:片段很多时,框选重新变得流畅。", - "编辑器文字:按 T 后马上点侧边“内容”框,新文字轨会保留;把文字拖到已锁定的文字轨上会提示该轨已锁定。", + "编辑器文字:按 T 后立即点击侧边“内容”框,新文字轨会保留;把文字拖到已锁定的文字轨上会提示该轨已锁定。", "长视频:每段描述也能在放大窗口里编辑。", "对话:对话已满时,提示只在消息下出现一次,按钮换成“新对话”。", "导出:导出窗口里给出的尺寸和保存的作品用同样的方式取整。", "诊断报告记录每一步采样时其他程序占用显卡的比例。", - "启动器:浏览器没能自动打开时,提示点“复制地址”到浏览器里打开,中文界面也显示中文。画布边距标签更清楚、对齐。节点:宽度、高度、时长和种子有中文标签。" + "启动器:浏览器未能自动打开时,提示点“复制地址”并在浏览器中打开,中文界面也显示中文。画布边距标签更清楚、对齐。节点:宽度、高度、时长和种子有中文标签。" ] } } diff --git a/freevideo_engine/support_report.py b/freevideo_engine/support_report.py index d9fa71b2..c397ffd7 100644 --- a/freevideo_engine/support_report.py +++ b/freevideo_engine/support_report.py @@ -109,7 +109,7 @@ def summarize(report, engine, encoding): hints.append(dict(kind='configuration', message='The text encoder file used is %s GiB, not the %s GiB file with the same name that FreeVideo installs and tests. ' 'Results and speed are not guaranteed to match the tested version.' % (actual, expected), - message_zh='这次使用的文本编码器文件为 %s GiB,不是 FreeVideo 安装并测试的同名文件(%s GiB)。' + message_zh='本次使用的文本编码器文件为 %s GiB,不是 FreeVideo 安装并测试的同名文件(%s GiB)。' '生成结果和速度都不保证与测试过的版本一致。' % (actual, expected))) if (mapping(engine.get('config')).get('resident_blocks') == 50 and number(offload.get('transfers')) == 0 and number(offload.get('h2d_bytes')) == 0): @@ -163,7 +163,7 @@ def summarize(report, engine, encoding): hints.append(dict(kind='measured', message='The GPU was in thermal slowdown for %d%% of its busy time: %d of %d MHz on ' 'average%s. Cooling limited this request, not the plan.' % (round(100 * thermal['share']), thermal['sm_clock_mean_mhz'], thermal['sm_clock_max_mhz'], ' at %.0f °C' % thermal['temperature_mean_c'] if thermal['temperature_mean_c'] else ''), - message_zh='显卡在 %d%% 的计算时间里处于温控降频,平均 %d/%d MHz%s。这次用时受散热限制,不是规划问题。' % ( + message_zh='显卡在 %d%% 的计算时间里处于温控降频,平均 %d/%d MHz%s。本次用时受散热限制,不是规划问题。' % ( round(100 * thermal['share']), thermal['sm_clock_mean_mhz'], thermal['sm_clock_max_mhz'], ',%.0f °C' % thermal['temperature_mean_c'] if thermal['temperature_mean_c'] else ''))) shared = shared_gpu(gpu) @@ -171,7 +171,7 @@ def summarize(report, engine, encoding): hints.append(dict(kind='measured', message='Other programs used about %d%% of the GPU while this request was computing, ' 'and at least %d%% of it for %d%% of that time, so it ran slower than with the GPU to itself.' % ( shared['other_mean_percent'], OtherPrograms.share_percent, round(100 * shared['share'])), - message_zh='这次请求计算期间,其他程序占用了约 %d%% 的显卡,其中 %d%% 的时间里不低于 %d%%,所以比独占显卡时慢。' % ( + message_zh='本次请求计算期间,其他程序占用了约 %d%% 的显卡,其中 %d%% 的时间里不低于 %d%%,因此比独占显卡时慢。' % ( shared['other_mean_percent'], round(100 * shared['share']), OtherPrograms.share_percent))) attempts = report.get('resource_attempts', []) failures = [dict(state=r.get('state'), failure=r.get('failure')) for r in attempts diff --git a/scripts/test_freevideo_entry.mjs b/scripts/test_freevideo_entry.mjs index 8a289573..acd5dd74 100644 --- a/scripts/test_freevideo_entry.mjs +++ b/scripts/test_freevideo_entry.mjs @@ -206,10 +206,10 @@ test('main browser entry registers both views and preserves node hooks', async t ['seconds', 'Duration (s)', '时长(秒)', 10], ['seed', 'seed', '种子', 2026090903], ['force_regenerate', 'Force regeneration', '重新生成', false, '跳过已保存结果的缓存。'], - ['preview', 'Two-pass: preview first pass', '先出预览', false, - '先出半分辨率预览,满意后在结果里点“继续二采”,成片与直接生成一致。'], + ['preview', 'Two-pass: preview first pass', '先生成预览', false, + '先生成半分辨率预览,满意后在结果中点击“继续二采”,成片与直接生成一致。'], ['refine_from', 'Second pass of preview', '继续二采', 'preview.mp4', '从这个预览继续生成全分辨率视频。'], - ['continue_from', 'Continue from video', '接着生成', 'previous.mp4'], + ['continue_from', 'Continue from video', '继续生成', 'previous.mp4'], ['frames', 'Total frames', '帧数', 141], ['first_frame_reference', 'First-frame reference', '首帧参考', true, '减小漂移'], ['step_cache', 'Step cache', '步间缓存', 'none', diff --git a/web/agent_activity.js b/web/agent_activity.js index 37c75079..e2032870 100644 --- a/web/agent_activity.js +++ b/web/agent_activity.js @@ -15,9 +15,9 @@ const NAMES = {generate: ['Video', '视频'], assemble: ['Sequence', '成片'], // Short reasons for the failures people can act on; the engine's own message stays in the tooltip. const REASONS = { out_of_memory: ['Out of memory', '显存或内存不足'], - insufficient_memory: ['Not enough memory for this video', '显存或内存不够生成这段视频'], + insufficient_memory: ['Not enough memory for this video', '显存或内存不足,无法生成这段视频'], interrupted: ['ComfyUI restarted during this job', 'ComfyUI 中途重启'], - lost: ['Ended without a result', '没有返回结果'], + lost: ['Ended without a result', '未返回结果'], dependency_failed: ['An earlier step did not succeed', '前序任务未成功'], }; @@ -77,7 +77,7 @@ function row(job, t, api, refresh, open, cancellationErrors) { if (job.status === 'running' && p.remaining_seconds > 0) meta.push(left(p.remaining_seconds, t)); const reason = job.status === 'failed' && REASONS[job.error?.code]; if (reason) meta.push(t(...reason)); - else if (job.status === 'failed') meta.push(t(job.error?.message || 'Did not finish', job.error?.error_zh || '没有完成')); + else if (job.status === 'failed') meta.push(t(job.error?.message || 'Did not finish', job.error?.error_zh || '未完成')); if (job.project) meta.push(job.project); const foot = el('div', 'fv-agent-foot'); const details = el('p', 'fv-agent-meta', meta.join(' · ')); @@ -93,7 +93,7 @@ function row(job, t, api, refresh, open, cancellationErrors) { try { const response = await retryAgentPost(api, '/freevideo/agent/activity/cancel', {id: job.id}); if (!response?.ok) { - const message = t('Could not stop. Click again.', '没能停止,请再点一次。'); + const message = t('Could not stop. Click again.', '停止失败,请再次点击。'); details.textContent = message; cancellationErrors.set(job.id, message); issue.hidden = false; diff --git a/web/agent_connect.js b/web/agent_connect.js index 1bee48ba..d1ea2b30 100644 --- a/web/agent_connect.js +++ b/web/agent_connect.js @@ -37,7 +37,7 @@ export function agentConnectCard(t, api, shown = () => true) { const body = el('section', 'fv-agent-connect-body'); const mark = el('span', 'fv-agent-connect-icon'); mark.append(icon('plug')); const title = el('h2', null, t('Connect an agent', '连接 Agent')); - const intro = el('p', 'fv-agent-connect-intro', t('Once connected, describe the video you want. It generates and edits it for you.', '连上后,说想做什么视频,它来生成和剪辑。')); + const intro = el('p', 'fv-agent-connect-intro', t('Once connected, describe the video you want. It generates and edits it for you.', '连接后,描述想要的视频,Agent 会生成并剪辑。')); const subtitle = el('p', 'fv-muted', t('Claude Code, Codex, FreeToken and others', 'Claude Code、Codex、FreeToken 等')); const actions = el('div', 'fv-agent-connect-actions'); const button = el('button', 'fv-primary'); button.type = 'button'; button.disabled = true; diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index 30ccd53c..5e647c94 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -48,7 +48,7 @@ const WHITEBOX = { play: ['Play', '播放'], pause: ['Pause', '暂停'], seconds: ['s', '秒'], failure: ['Could not start: ', '无法开始生成:'], }; -const GENERIC_FAILURE_ZH = '没有完成'; +const GENERIC_FAILURE_ZH = '未完成'; // Sends and failure cards use the same public reason, including the engine's Chinese mapping. function failureReason(value, t) { const error = value.error, reason = error?.error_zh || value.error_zh; @@ -228,7 +228,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const down = press('fv-chat-down', null, () => { scroller.scrollTo({top: scroller.scrollHeight, behavior: 'smooth'}); }); down.append(glyph('M12 5v14M5 12l7 7 7-7')); down.setAttribute('aria-label', t('Latest', '最新')); down.hidden = true; const form = el('form', 'fv-chat-input'), input = el('textarea'), sendButton = el('button', 'fv-chat-send'); - input.rows = 1; input.maxLength = 8000; input.setAttribute('aria-label', t('Message the agent', '对 Agent 说')); + input.rows = 1; input.maxLength = 8000; input.setAttribute('aria-label', t('Message the agent', '向 Agent 发送消息')); sendButton.type = 'submit'; sendButton.setAttribute('aria-label', t('Send', '发送')); sendButton.append(glyph('M12 19V5M5 12l7-7 7 7')); // While the agent is thinking and has started nothing, the send key is the stop key: same place, a solid square. let stopMode = false; @@ -247,7 +247,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () input.focus(); let done = false; try { done = (await dataLayer.abandon(session)).ok; } catch {} - if (!done && state.session === session) { state = {...state, abandoned: false}; render(); say(t('Could not stop. Click again.', '没能停止,请再点一次。')); } + if (!done && state.session === session) { state = {...state, abandoned: false}; render(); say(t('Could not stop. Click again.', '停止失败,请再次点击。')); } } let whiteboxPreview = false; try { whiteboxPreview = localStorage.getItem(WHITEBOX_PREVIEW.storageKey) === 'true'; } catch {} @@ -536,10 +536,10 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () else { void readConversations(true); if (stop) deleteFailed(session, t('Still stopping. Try deleting again in a moment.', '正在停止,请稍后再删除。'), false, false); - else deleteFailed(session, t('This chat still has work in progress, so it was not deleted.', '这段对话里还有在生成的内容,没有删除。'), true); + else deleteFailed(session, t('This chat still has work in progress, so it was not deleted.', '此对话中仍有正在生成的内容,未删除。'), true); } - } else deleteFailed(session, t('Could not delete: ', '没有删除成功:') + (data.error?.message || data.error?.code || `HTTP ${response.status}`)); - } catch (error) { if (!stopped) deleteFailed(session, t('Could not delete: ', '没有删除成功:') + reason(error)); } + } else deleteFailed(session, t('Could not delete: ', '删除失败:') + (data.error?.message || data.error?.code || `HTTP ${response.status}`)); + } catch (error) { if (!stopped) deleteFailed(session, t('Could not delete: ', '删除失败:') + reason(error)); } finally { deleting.delete(session); if (menuState?.session === session) closeMenu(); @@ -571,7 +571,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () } else throw await requestError(response); } catch (error) { if (!stopped) { - newError.textContent = t('Could not start a new chat: ', '没有新建成功:') + reason(error); newError.hidden = newIssue.hidden = false; + newError.textContent = t('Could not start a new chat: ', '新建对话失败:') + reason(error); newError.hidden = newIssue.hidden = false; if (from) from.error = newError.textContent; } } @@ -670,7 +670,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () ids.forEach(id => stopping.add(id)); render(); report(); const results = await Promise.all(ids.map(id => dataLayer.cancel(id))); results.forEach((response, index) => { if (!response?.ok) stopping.delete(ids[index]); }); - if (results.some(response => !response?.ok)) say(t('Could not stop. Click again.', '没能停止,请再点一次。'), true); + if (results.some(response => !response?.ok)) say(t('Could not stop. Click again.', '停止失败,请再次点击。'), true); else say(''); if (!stopped) { render(); report(); void poll(); } } @@ -788,7 +788,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () let playing = false, loadRetried = false; const playbackFailure = () => { if (item.dataset.mode !== 'done') return; - playback.textContent = t('Cannot play right now. Open it in Creations.', '暂时播放不了,可在作品库中打开'); playback.hidden = false; + playback.textContent = t('Playback is unavailable. Open it in Creations.', '暂时无法播放,可在作品库中打开'); playback.hidden = false; show.textContent = t('Open in Creations', '在作品库打开'); if (!show.parentNode) foot.append(show); if (!foot.parentNode) info.append(foot); }; @@ -974,7 +974,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const signature = JSON.stringify([value, disabled, error]); if (signature === shownState) return; shownState = signature; item.dataset.state = value === 'asked' ? 'asked' : 'answered'; - if (value !== 'asked') { item.replaceChildren(el('span', null, value === 'approved' ? t('Started', '已开始') : t('Not now', '先不拍'))); return; } + if (value !== 'asked') { item.replaceChildren(el('span', null, value === 'approved' ? t('Started', '已开始') : t('Not now', '暂不拍摄'))); return; } const answer = approve => async () => { if (confirming.has(key) || !inSelectedChat() || state.agent.connected !== true || state.session !== session) return; item.querySelectorAll('button').forEach(button => { button.disabled = true; }); @@ -983,12 +983,12 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const response = await dataLayer.confirm({id: entry.id, approve, session}); if (!response.ok) throw await requestError(response); answers.set(key, (await response.json()).state || (approve ? 'approved' : 'declined')); - } catch (error) { confirmErrors.set(key, t('Could not start shooting: ', '没有开始拍摄:') + reason(error)); } + } catch (error) { confirmErrors.set(key, t('Could not start shooting: ', '未能开始拍摄:') + reason(error)); } finally { confirming.delete(key); } if (!stopped) { render(); void poll(); } }; const buttons = el('div', 'fv-chat-actions'); - buttons.append(press('fv-chat-yes fv-primary', t('Start', '开始'), answer(true)), press('fv-chat-no', t('Not now', '先不'), answer(false))); + buttons.append(press('fv-chat-yes fv-primary', t('Start', '开始'), answer(true)), press('fv-chat-no', t('Not now', '暂不'), answer(false))); buttons.querySelectorAll('button').forEach(button => { button.disabled = disabled; }); const question = el('p', null, entry.question); const estimate = el('p', 'fv-chat-meta', entry.estimate_seconds > 0 ? about(entry.estimate_seconds, t) : ''); estimate.hidden = !estimate.textContent; @@ -1010,7 +1010,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const lines = [t('Make a 10-second video: a rainy night, a girl squeezes onto the last bus', '生成一段 10 秒的视频:雨夜,女孩挤上末班车'), t('Make a 1-minute vertical short film: a day in a coffee shop', '做一条 1 分钟的竖屏短片:咖啡店的一天')]; list.append(...lines.map(line => press(null, line, () => send(line)))); - item.append(el('p', 'fv-chat-meta', t('Try saying:', '可以这样说:')), list); + item.append(el('p', 'fv-chat-meta', t('Examples:', '示例:')), list); return {element: item, update() { list.querySelectorAll('button').forEach(button => { button.disabled = !sendingAllowed(); }); }}; } function dots() { @@ -1071,7 +1071,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () }); const sendable = sendingAllowed(); input.disabled = state.agent.connected !== true || creating || !!selectedSession && selectedSession !== state.session; - input.placeholder = state.agent.connected === true && !sendable ? t('Ask your agent to continue', '请在 Agent 里让它继续') : t('Message the agent…', '对 Agent 说…'); + input.placeholder = state.agent.connected === true && !sendable ? t('Ask your agent to continue', '请在 Agent 窗口中指示其继续') : t('Message the agent…', '向 Agent 发送消息…'); refreshSend(); if (focusSession === state.session && focusSession && !input.disabled && active()) { focusSession = null; input.focus(); } if (stick) { pinned = true; scroller.scrollTop = scroller.scrollHeight; down.hidden = true; } @@ -1113,14 +1113,14 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () } } const waitingNotice = code => (code ? code === 'not_listening' : state.agent.connected === true) - ? t('Will send when Agent is back', '等 Agent 回来后自动发送') - : t('Will send when Agent connects', '等 Agent 连上后自动发送'); + ? t('Will send when Agent reconnects', 'Agent 重新连接后自动发送') + : t('Will send when Agent connects', 'Agent 连接后自动发送'); async function send(text, original = null, draft = null, automatic = false) { text = String(text).trim(); original ||= pending.find(entry => entry.session === (state.session ?? null) && entry.text === text && (entry.error || entry.sending || entry.automatic)); if (!text || original?.sending || original && original.session !== state.session) return false; if (!inSelectedChat()) { - say(creating ? t('Creating a new chat…', '正在新建对话…') : t('Loading this chat. Please send again shortly.', '正在读取对话,请稍后再发一次')); + say(creating ? t('Creating a new chat…', '正在新建对话…') : t('Loading this chat. Please send again shortly.', '正在读取对话,请稍后重新发送')); return false; } const entry = original || {id: newId(), text, session: state.session ?? null, draft, whiteboxPreview: whiteboxPreview && state.agent?.builtin !== true}; @@ -1151,10 +1151,10 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const waiting = !refused && (WAIT_FOR_AGENT.includes(error?.code) || !error && state.agent.connected !== true); const exhausted = entry.attempts >= SEND_ATTEMPTS; entry.automatic = waiting && !exhausted; - const explanation = refused || error && !waiting ? t('Not sent: ', '没发出去:') + failureReason({error}, t) - : exhausted ? t('Not sent. Click "Retry".', '没有发出,请点“重试”。') - : waiting ? waitingNotice(error?.code) : t('The connection is unstable. Click "Retry".', '网络不稳,请点“重试”。'); - entry.error = waiting || /^(Not sent|没发出|没有发出)/.test(explanation) ? explanation : t('Could not send: ', '没有发送成功:') + explanation; + const explanation = refused || error && !waiting ? t('Not sent: ', '未发送:') + failureReason({error}, t) + : exhausted ? t('Not sent. Click "Retry".', '未发送,请点击“重试”。') + : waiting ? waitingNotice(error?.code) : t('The connection is unstable. Click "Retry".', '网络连接不稳定,请点击“重试”。'); + entry.error = waiting || /^(Not sent|未发送)/.test(explanation) ? explanation : t('Could not send: ', '发送失败:') + explanation; if (entry.session === state.session) { if (waiting && error?.code === 'not_connected') state = {...state, agent: {connected: false, name: null}}; if (waiting && error?.code === 'not_listening') state = {...state, agent: {...state.agent, listening: false}}; diff --git a/web/browser_extension.js b/web/browser_extension.js index da8c7b9d..63a433ec 100644 --- a/web/browser_extension.js +++ b/web/browser_extension.js @@ -125,11 +125,11 @@ export const EXTENSION_COPY = Object.freeze({ title: ['A browser extension is interfering with FreeVideo', '浏览器扩展干扰了 FreeVideo'], // English uses straight quotes: curly ones widen in the CJK font stack. both: ['The script "{script}" in the browser extension "{extension}" interfered with this page, so this action did not finish.', - '浏览器扩展“{extension}”里的脚本“{script}”干扰了页面,这次操作没有完成。'], + '浏览器扩展“{extension}”里的脚本“{script}”干扰了页面,本次操作未完成。'], extension: ['The browser extension "{extension}" interfered with this page, so this action did not finish.', - '浏览器扩展“{extension}”干扰了页面,这次操作没有完成。'], + '浏览器扩展“{extension}”干扰了页面,本次操作未完成。'], unknown: ['A browser extension interfered with this page, so this action did not finish.', - '浏览器里的某个扩展干扰了页面,这次操作没有完成。'], + '浏览器里的某个扩展干扰了页面,本次操作未完成。'], // The browser says a client blocked the file: an ad-blocking extension, though not which one. blocked: ['An ad-blocking extension in this browser, such as uBlock Origin, AdGuard or Adblock Plus, blocked one of FreeVideo\'s page files, so the page cannot work properly.', '浏览器里的广告拦截类扩展(例如 uBlock Origin、AdGuard、Adblock Plus)拦下了 FreeVideo 的页面文件,页面无法正常运行。'], diff --git a/web/cast_library.js b/web/cast_library.js index 65a47f49..d64d4bb9 100644 --- a/web/cast_library.js +++ b/web/cast_library.js @@ -46,7 +46,7 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () // The dialog only sends pictures, so unreadable media here is always one of them. const unreadable = data?.error?.code === 'invalid_media' && text('Could not read this picture. Please choose another one.', '无法读取这张图片,请换一张。'); const error = new Error(unreadable || data?.error?.message || (typeof data?.error === 'string' && data.error) - || data?.message || response.statusText || text('Did not finish. Try again.', '没有完成,请重试。')); + || data?.message || response.statusText || text('Did not finish. Try again.', '未完成,请重试。')); error.status = response.status; // Which picture it was, as /images//media of the request. if (unreadable) error.field = data.error.details?.find(detail => typeof detail?.field === 'string')?.field; diff --git a/web/editor.js b/web/editor.js index ffcb4365..043ba666 100644 --- a/web/editor.js +++ b/web/editor.js @@ -63,36 +63,36 @@ export function editorErrorText(value, t = en => en) { const known = { 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 start. Choose "Generate" again.', '生成没能开始,请再点“生成”。'), - result_mismatch: t('Could not read the result. Choose "Generate" again.', '没能读取结果,请再点“生成”。'), + 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 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 (outputNotWritable(whole)) - return t('The output folder cannot be written to, so the video was not saved. Check that the folder can be written to, then retry.', '输出目录不可写,视频没能保存。请检查这个文件夹的写入权限后重试。'); + return t('The output folder cannot be written to, so the video was not saved. Check that the folder can be written to, then retry.', '输出目录不可写,视频未能保存。请检查这个文件夹的写入权限后重试。'); if (/manifest\.json/i.test(whole) && /No such file|not found|Errno 2\b|FileNotFoundError/i.test(whole)) - return t('The model is not fully installed. ', '模型没有装全。') + t(...repairAction()); + return t('The model is not fully installed. ', '模型未完整安装。') + t(...repairAction()); 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. Try again.`, `${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. Try again.`, `${generating}时出错,请再试一次。`); + : t(`${generating} failed. Try again.`, `${generating}时出错,请重试。`); } function exportErrorText(value, t) { if (isNetworkTypeError(value)) return connectionLostText(t); const message = [value?.response?.error?.message, value?.response?.error, value?.response?.message, value?.message] .find(text => typeof text === 'string' && text.trim() && text !== 'editor_request')?.trim().split(/[\r\n]+/)[0]; // A bare status code means nothing to the user; the server's own words do. - return message ? t(`Export failed: ${message}`, `导出时出错:${message}`) : t('Export failed. Try again.', '导出时出错,请再试一次。'); + return message ? t(`Export failed: ${message}`, `导出时出错:${message}`) : t('Export failed. Try again.', '导出时出错,请重试。'); } // The save route's own messages are internal English, so only a full disk is named. function saveErrorText(value, t) { - return value?.reason === 'disk_full' ? t('Saving changes failed: the disk is full. Free some space, then export again.', '保存改动时出错:磁盘空间不够,空出空间后再导出。') - : t('Saving changes failed. Please export again.', '保存改动时出错,请再导出一次。'); + return value?.reason === 'disk_full' ? t('Saving changes failed: the disk is full. Free some space, then export again.', '保存改动时出错:磁盘空间不足,请释放空间后重新导出。') + : t('Saving changes failed. Please export again.', '保存改动时出错,请重新导出。'); } function restoreGeneration(record, before, prepared) { const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); @@ -293,7 +293,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given function error(value, step = null, out = notify, site = step || 'editor') { const code = value?.reason || value?.code || value?.message; if (['missing_glyphs', 'MissingGlyphs', 'source_missing'].includes(code)) { - out(code === 'source_missing' ? t('A source file is missing. Import it again from the library.', '有素材的原文件找不到了,请在素材库重新导入。') + out(code === 'source_missing' ? t('A source file is missing. Import it again from the library.', '部分素材的原文件缺失,请在素材库重新导入。') : t('The chosen font is missing some characters. Choose another font.', '所选字体缺少部分字符,请换一种字体。')); return {ok: false, reason: code}; } @@ -301,20 +301,20 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (code === 'unsupported') { genbar?.update(null); return; } if (['chain_too_long', 'chain_part_too_short'].includes(code)) { const [en, zh] = (value.said || []).map(text => typeof text === 'string' && text ? text.slice(0, 200) : null); - const text = code === 'chain_too_long' ? t(en || 'This video can have up to 5 parts. Make it shorter.', zh || '这条视频最多 5 段,请缩短一些。') - : t(en || 'Each continued part must be at least 10 seconds. Make it longer.', zh || '接着生成的每一段至少 10 秒,请加长一些。'); + const text = code === 'chain_too_long' ? t(en || 'This video can have up to 5 parts. Make it shorter.', zh || '这条视频最多 5 段,请缩短。') + : t(en || 'Each continued part must be at least 10 seconds. Make it longer.', zh || '接续生成的每一段至少 10 秒,请延长。'); out(text); return {ok: false, reason: code, say: text}; } if (['busy', 'locked', 'text', 'audio_full', 'overlap', 'range', 'no_audio'].includes(code)) { if (['locked', 'audio_full', 'overlap', 'range'].includes(code)) tracks?.flashBlocked?.(value.displayFrame); const said = {busy: t('This part is generating.', '这一段正在生成。'), locked: t('This track is locked.', '这条轨道已锁定。'), - text: t('Write a description first.', '先写一句描述。'), overlap: t('That spot is taken. Use an empty spot.', '这里已经有片段了,请放到空白处。'), - audio_full: t('Both audio tracks are full here. Move an audio clip first.', '这里两条声音轨都满了,请先挪开一段声音。'), - no_audio: t('This clip has no sound. Please choose one with sound.', '这个素材没有声音,请选一段有声音的。')}[code]; + text: t('Write a description first.', '请先填写描述。'), overlap: t('This position is occupied. Use an empty position.', '此处已有片段,请放到空白处。'), + audio_full: t('Both audio tracks are full here. Move an audio clip first.', '此处两条声音轨均已占满,请先移开一段声音。'), + no_audio: t('This clip has no sound. Please choose one with sound.', '这个素材没有声音,请选择有声音的素材。')}[code]; if (said) out(said); return {ok: false, reason: code}; } - const text = step === 'generate' ? editorErrorText(value, t) : step === 'export' ? exportErrorText(value, t) : t('Could not complete. Try again.', '没有完成,请重试'); + const text = step === 'generate' ? editorErrorText(value, t) : step === 'export' ? exportErrorText(value, t) : t('Could not complete. Try again.', '未完成,请重试'); out(text, step === 'export' ? EXPORT_ISSUE : EDITOR_ISSUE, failureKind(site, value)); return {ok: false, reason: code, say: text}; } const store = createEditorStore(api, {t, notify, standIn: given?.generationStandIn, onGenerationReady: ready => { @@ -461,7 +461,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (!clips.length) { held.shift(); markHeld(entry.ids, false); rememberView(); continue; } const selection = {kind: 'clip', ids: clips.map(clip => clip.id)}, before = partBefore(Math.min(...clips.map(clip => clip.start))); held.shift(); markHeld(entry.ids, false); rememberView(); - if (before && !before.source && before.pending && !before.job) { genbar?.say(t('The part before did not finish, so this one did not start.', '上一段没有完成,这一段没有开始。')); continue; } + if (before && !before.source && before.pending && !before.job) { genbar?.say(t('The part before did not finish, so this one did not start.', '上一段未完成,这一段未开始。')); continue; } try { await actions.generate({text: entry.text, selection}); } catch (value) { const reason = error(value, 'generate'); if (reason && !['chain_too_long', 'chain_part_too_short'].includes(reason.reason)) genbar?.say(reason.say); } } @@ -956,12 +956,12 @@ export function openEditor({api, app, from = null, panel = {}, components: given else if (!actions.supportedSelection(selection)) { const moved = emptySpot(selection); if (!moved) return {say: t('Choose an empty spot on the timeline.', '请在时间线上选一段空白。')}; - selection = state.select(moved); said = t('Moved to the next empty spot.', '已挪到后面的空位。'); genbar?.say(said); + selection = state.select(moved); said = t('Moved to the next empty spot.', '已移到后面的空位。'); genbar?.say(said); } let used = null; if (!text.trim() && actions.needsText(selection)) { const found = nearbyText(selectionRange(selection).start); - if (!found) return {say: [said, t('Write a description first.', '先写一句描述。')].filter(Boolean).join(' '), focus: true}; + if (!found) return {say: [said, t('Write a description first.', '请先填写描述。')].filter(Boolean).join(' '), focus: true}; text = found.text; used = found.before ? t('Used the description of the part before.', '沿用了上一段的描述。') : t('Used the latest description.', '沿用了最近一次的描述。'); state.setRangeText(text); genbar?.setText(text); @@ -1102,9 +1102,9 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (value.state === 'failed') { if (['source_missing', 'missing_glyphs', 'MissingGlyphs'].includes(value.reason || value.code)) error(value, null, undefined, 'export'); else notify(value.reason === 'not_writable' ? t('Export failed: the output folder cannot be written to. Check that the folder can be written to, then retry.', '导出时出错:输出目录不可写。请检查这个文件夹的写入权限后重试。') - : value.reason === 'disk_full' ? t('Export failed: the disk is full. Free some space, then export again.', '导出时出错:磁盘空间不够,空出空间后再导出。') - : value.reason === 'unreadable' ? t('Export failed: a source file cannot be read. Import it again from the library.', '导出时出错:有素材读不出来,请在素材库重新导入。') - : t('Export failed. Try again.', '导出时出错,请再试一次。'), EXPORT_ISSUE, failureKind('export', value)); + : value.reason === 'disk_full' ? t('Export failed: the disk is full. Free some space, then export again.', '导出时出错:磁盘空间不足,请释放空间后重新导出。') + : value.reason === 'unreadable' ? t('Export failed: a source file cannot be read. Import it again from the library.', '导出时出错:部分素材无法读取,请在素材库重新导入。') + : t('Export failed. Try again.', '导出时出错,请重试。'), EXPORT_ISSUE, failureKind('export', value)); } if (value.state === 'cancelled') notify(t('Export cancelled. No unfinished file is kept.', '已取消导出,没有留下不完整的文件。')); window.FreeVideoNotifyTaskDone?.({kind: 'export', id: value.id, ok: value.state === 'done'}); @@ -1327,7 +1327,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given releaseStamp(); await queue.flush(); if (queue.dirty && !queue.locallyRetained && !discardOnSwitch) { discardOnSwitch = true; closing = false; element.inert = false; closePromise = null; - saveWarning.textContent = t('Not saved yet. Switching again will lose recent changes.', '还没保存好,再切换会丢掉最近的改动'); + saveWarning.textContent = t('Not saved yet. Switching again will lose recent changes.', '尚未保存,再次切换将丢失最近的改动'); saveWarning.hidden = false; return false; } if (!queue.dirty && emptyEdit()) { diff --git a/web/editor_export_dialog.js b/web/editor_export_dialog.js index 4e31ebf5..a798a02d 100644 --- a/web/editor_export_dialog.js +++ b/web/editor_export_dialog.js @@ -113,8 +113,8 @@ export async function openExportDialog({parent, state, sources, t = en => en, on size.short == null ? t('Same as the edit', '与剪辑相同') : `${size.width} × ${size.height}`])); group('quality', t('Quality', '画质'), [['high', t('High', '高')], ['standard', t('Standard', '标准')], ['small', t('Small file', '省空间')]]); group('range', t('Range', '范围'), [['whole', t('Whole edit', '整条')], ['selected', t('Selected range', '选中的范围')]]); - const contentRow = group('only', t('Content', '内容'), [[null, t('Video and audio', '画面和声音')], ['sound', t('Audio only', '只声音')]]); - const formatRow = group('sound_format', t('Audio only', '只声音'), [['m4a', 'M4A'], ['wav', 'WAV']], contentRow); + const contentRow = group('only', t('Content', '内容'), [[null, t('Video and audio', '画面和声音')], ['sound', t('Audio only', '仅声音')]]); + const formatRow = group('sound_format', t('Audio only', '仅声音'), [['m4a', 'M4A'], ['wav', 'WAV']], contentRow); formatRow.classList.add('fv-ed-export-format'); formatRow.querySelector('.fv-ed-export-label').hidden = true; const chosenRange = () => value.range === 'selected' ? exportRange(state.record, state.selection) : null; const affected = () => croppedParts(state.record, sources, chosenRange()); diff --git a/web/editor_genbar.js b/web/editor_genbar.js index 59cf5e7e..099cec3e 100644 --- a/web/editor_genbar.js +++ b/web/editor_genbar.js @@ -149,7 +149,7 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { // A single part needs no number; with nothing else to say it reads "Preparing". // The creation panel's own time when it has one, so the bar and the panel never differ; the job's sum until the panel has one. const left = words?.over ? null : words?.left ?? remaining; - const time = finalization ? '' : words?.stall || (words?.over ? t('Taking longer than expected', '比预计久,仍在生成') : words?.time || (left != null ? timeEstimate(left, t) : '')); + const time = finalization ? '' : words?.stall || (words?.over ? t('Taking longer than expected', '耗时超出预计,仍在生成') : words?.time || (left != null ? timeEstimate(left, t) : '')); const stage = finalization ? t(...CLIP_FINALIZATION_COPY[finalization.key]) : words?.stage || (count === 1 && !time ? t('Preparing', '正在准备') : ''); const spans = [['fv-ed-gen-part', count > 1 ? t(`Part ${number}/${count}`, `第 ${number}/${count} 段`) : ''], ['fv-ed-gen-stage', stage], ['fv-ed-gen-time', time]]; @@ -214,7 +214,7 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { // A specific reason is said once; the planning limits are already shown above. 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, false, ['Editor generation', '剪辑器生成']); - else if (!result && !note.textContent) { reason = t('Could not start. Try again.', '没能开始,请重试。'); say(reason, false, ['Editor generation', '剪辑器生成']); } } + else if (!result && !note.textContent) { reason = t('Could not start. Try again.', '未能开始,请重试。'); say(reason, false, ['Editor generation', '剪辑器生成']); } } finally { pressing.delete(key); render(); } return reason; }; diff --git a/web/editor_library.js b/web/editor_library.js index cac1cf80..9bf70353 100644 --- a/web/editor_library.js +++ b/web/editor_library.js @@ -23,7 +23,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { 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 cannotOpen = () => t('This file cannot be opened. Supported formats: ', '这个文件打不开。支持的格式:') + FORMATS; + 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}`); return {frames: cached?.frames || Math.round(g.frames || (g.seconds || 0) * (g.fps || 24)), hasVideo: cached?.has_video ?? (kind === 'work' || row.kind === 'video'), @@ -74,7 +74,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { else if (row.kind === 'audio') picture.append(el('span', 'fv-ed-audio-icon', '♫')); if (ready) picture.append(el('span', 'fv-ed-source-time', stamp(metadata(row, kind).frames))); else if (row.state === 'failed') { - picture.append(el('span', 'fv-ed-source-preparing', t('Cannot open', '打不开'))); + picture.append(el('span', 'fv-ed-source-preparing', t('Cannot open', '无法打开'))); } else picture.append(el('span', 'fv-ed-source-preparing', t('Preparing', '准备中') + (Number.isFinite(row.fraction) ? ` ${Math.round(Math.max(0, Math.min(1, row.fraction)) * 100)}%` : ''))); button.append(picture); @@ -97,7 +97,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { plus.ondblclick = event => { event.preventDefault(); event.stopPropagation(); }; plus.onkeydown = event => { if (event.key === 'Enter' || event.key === ' ') insert(event); }; container.append(button, plus); - if (kind !== 'work' && row.state === 'failed') container.append(issueButton(t, () => ({where: LIBRARY_ISSUE, message: t('Cannot open', '打不开')}))); + if (kind !== 'work' && row.state === 'failed') container.append(issueButton(t, () => ({where: LIBRARY_ISSUE, message: t('Cannot open', '无法打开')}))); return container; } function updateUsed() { @@ -122,7 +122,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { for (const row of shown) grid.append(card(row, tab)); updateUsed(); message.textContent = ''; // Empty states stay silent. - for (const row of shown) if (row.state === 'failed') message.textContent = t('This file cannot be opened. Supported formats: ', '这个文件打不开。支持的格式:') + FORMATS; + for (const row of shown) if (row.state === 'failed') message.textContent = t('This file cannot be opened. Supported formats: ', '无法打开这个文件。支持的格式:') + FORMATS; if (message.textContent) message.append(issueButton(t, () => ({where: LIBRARY_ISSUE, message: cannotOpen()}))); if (scroll > 0) { const replacement = anchor && [...grid.querySelectorAll('.fv-ed-source')].find(node => node.dataset.key === anchor.dataset.key); diff --git a/web/editor_loader.js b/web/editor_loader.js index 3b2bc61e..a1dbc1dd 100644 --- a/web/editor_loader.js +++ b/web/editor_loader.js @@ -12,7 +12,7 @@ export async function openEditorView(node, options) { console.warn('FreeVideo edit view:', error); const language = new URLSearchParams(location.search).get('freevideo_lang'); const cn = language === 'zh' || (language !== 'en' && String(navigator.language || '').toLowerCase().startsWith('zh')); - const detail = cn ? '没有完成,请重试' : 'Could not complete. Try again.'; + const detail = cn ? '未完成,请重试' : 'Could not complete. Try again.'; // The native modal is above the host's toast layer. Also use the // workspace's existing failure status so the message stays visible. const status = options?.studio?.querySelector('.fv-status'); diff --git a/web/editor_select.js b/web/editor_select.js index 764d0a25..6b2110e5 100644 --- a/web/editor_select.js +++ b/web/editor_select.js @@ -76,7 +76,7 @@ const pageClipboard = createClipboard(); export function createSelectionActions({state, t = en => en, actions = {}, clipboard = pageClipboard}) { function report(error) { if (!(error instanceof model.EditError)) throw error; - if (error.code === 'not_generated' && actions.notify) actions.notify(t('Not generated yet', '还没生成')); + if (error.code === 'not_generated' && actions.notify) actions.notify(t('Not generated yet', '尚未生成')); else if (error.code !== 'clipboard_empty') actions.editError?.(error); return {ok: false, reason: error.code}; } diff --git a/web/editor_store.js b/web/editor_store.js index 896b11f8..260864ce 100644 --- a/web/editor_store.js +++ b/web/editor_store.js @@ -391,7 +391,7 @@ export function createEditorStore(api, {t = en => en, notify = () => {}, onGener xhr.upload.onprogress = event => { if (event.lengthComputable) progress(event.loaded / event.total); }; xhr.onload = () => { release(); - if (xhr.status === 404) { notice('import', 'Import is unavailable until you restart after updating.', '更新后重启即可导入素材。'); resolve(null); return; } + if (xhr.status === 404) { notice('import', 'Import is unavailable until you restart after updating.', '更新并重启后才能导入素材。'); resolve(null); return; } if (xhr.status < 200 || xhr.status >= 300) { reject(errorFor(xhr)); return; } try { resolve(JSON.parse(xhr.responseText).import); } catch (error) { reject(error); } }; diff --git a/web/editor_text.js b/web/editor_text.js index 71e8403a..672f7dae 100644 --- a/web/editor_text.js +++ b/web/editor_text.js @@ -39,7 +39,7 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, const marker=toolbar.querySelector('.fv-ed-marker, .fv-ed-add-marker'); if(marker) marker.after(buttons); else toolbar.insertBefore(buttons,toolbar.querySelector('.fv-ed-zoom')); const notice=el('span');notice.setAttribute('role','button');notice.tabIndex=0;notice.onclick=()=>jumpMissing();notice.onkeydown=e=>{if(['Enter',' '].includes(e.key)){e.preventDefault();jumpMissing();}}; notice.classList.add('fv-ed-text-notice'); notice.hidden=true; exportBox.prepend(notice); - const fontFailure=()=>t('Text fonts could not be loaded','文字字体没能加载'); + const fontFailure=()=>t('Text fonts could not be loaded','文字字体加载失败'); let fontIssue=null; const menu=el('div','fv-ed-text-menu'); menu.hidden=true; menu.setAttribute('role','menu'); element.append(menu); const names={sans:t('Sans','黑体'),serif:t('Serif','宋体'),kai:t('Kai','楷体'),mono:t('Mono','等宽')}; @@ -68,7 +68,7 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, const speech={buttons:new Set(),available:false,need:0,busy:false,loading:false,shown:null,job:null,stop:null,run:0}; function speechLabel(fraction){speech.shown=fraction;const p=Math.round(Math.min(1,Math.max(0,Number(fraction)||0))*100),size=Math.max(1,Math.round(speech.need/1e6)),text=fraction==null?(speech.need?t(`Auto subtitles · Download ${size} MB`,`自动字幕 · 下载 ${size} MB`):t('Auto subtitles','自动字幕')):speech.loading?t(`Cancel · Downloading ${p}%`,`取消 · 下载 ${p}%`):t(`Cancel · ${p}%`,`取消 · ${p}%`);for(const b of speech.buttons){b.textContent=text;b.hidden=!(speech.available||speech.need);}} function speechEnd(text,where=null){speech.stop?.();speech.stop=null;speech.job=null;speech.busy=false;speech.loading=false;speechLabel(null);if(text)actions.notify?.(text,where);} - const speechFailed=()=>t('Could not create subtitles','没能生成字幕'),speechSilent=()=>t('No speech found','没有听到说话声'); + const speechFailed=()=>t('Could not create subtitles','字幕生成失败'),speechSilent=()=>t('No speech found','未检测到语音'); const speechReason=reason=>reason==='no_sound'?speechSilent():reason==='language'?t('Language not supported yet','暂不支持这种语言'):speechFailed(); function speechResult(job){ if(job.state==='running'){speechLabel(job.fraction);return true;} @@ -91,7 +91,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. Try again.','没能下载,请再试一次。'),SPEECH_ISSUE);return;} + if(!done||speech.need){speechEnd(t('Could not download. Try again.','下载失败,请重试。'),SPEECH_ISSUE);return;} if(!speech.available){speechEnd(speechFailed(),SPEECH_ISSUE);return;} speech.loading=false; } @@ -101,7 +101,7 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, if(!job){speechEnd(speechFailed(),SPEECH_ISSUE);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(),SPEECH_ISSUE);return false;}return speechResult(value);}); - }catch{if(run===speech.run)speechEnd(speech.loading?t('Could not download. Try again.','没能下载,请再试一次。'):speechFailed(),SPEECH_ISSUE);} + }catch{if(run===speech.run)speechEnd(speech.loading?t('Could not download. Try again.','下载失败,请重试。'):speechFailed(),SPEECH_ISSUE);} } 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. @@ -165,7 +165,7 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, const file=el('input');file.type='file';file.accept='.srt';file.hidden=true; const importButton=button(t('Import subtitle file','导入字幕文件'),{onClick:()=>file.click()});importButton.classList.add('fv-ed-import-srt'); // Only a file that cannot be read says so; a refused edit (a locked track) flashes red like any other. - file.onchange=async()=>{const f=file.files?.[0];if(!f)return;let text;const result={};try{if(f.size>4*1024*1024)throw new Error();text=await f.text();parseSRT(text,result);}catch{actions.notify?.(t('Could not read this subtitle file','读不出这个字幕文件'));return;}finally{file.value='';} + file.onchange=async()=>{const f=file.files?.[0];if(!f)return;let text;const result={};try{if(f.size>4*1024*1024)throw new Error();text=await f.text();parseSRT(text,result);}catch{actions.notify?.(t('Could not read this subtitle file','无法读取这个字幕文件'));return;}finally{file.value='';} if(apply(t('Import subtitle file','导入字幕文件'),r=>importSRT(r,text))){const missing=projectMissing(state.record);if(missing.length)actions.notify?.(missingHint([...new Set(missing.flatMap(r=>r.chars))],t));if(result.truncated)actions.notify?.(t('Only the first 500 subtitles were imported.','只导入了前 500 条字幕。'));}}; // Shown only where this computer can recognise speech. const hear=button(t('Auto subtitles','自动字幕'),{onClick:toggleSpeech});hear.classList.add('fv-ed-auto-subtitles');for(const b of speech.buttons)if(!b.isConnected)speech.buttons.delete(b);speech.buttons.add(hear);speechLabel(speech.busy?speech.shown:null); diff --git a/web/editor_tracks.js b/web/editor_tracks.js index 937073ce..e33166f1 100644 --- a/web/editor_tracks.js +++ b/web/editor_tracks.js @@ -536,7 +536,7 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = const failureText = visual === 'failed' ? actions.failureText?.(clip.id) || actions.reasonText?.(clip.reason) || clip.reason || t('Generation failed', '生成时出错') : ''; if (visual === 'failed') item.status.replaceChildren(icon('warning'), create('span', 'fv-ed-clip-failure-reason', failureText)); else item.status.replaceChildren(); - const status = {planned: actions.interrupted?.(clip) ? t('Interrupted', '上次中断') : t('Not generated yet', '还没生成'), running: t('Generating', '正在生成'), + const status = {planned: actions.interrupted?.(clip) ? t('Interrupted', '上次中断') : t('Not generated yet', '尚未生成'), running: t('Generating', '正在生成'), done: t('Complete', '完成'), failed: failureText}[visual]; const seconds = Math.round(clip.frames / model.FPS); item.element.title = ''; @@ -939,11 +939,11 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = return model.move(record, finished.id, result.frame, {targetTrack: result.targetTrack}); }); else if (finished.type === 'plannedBoundary') attempt(t('Resize parts', '调整段长'), record => model.resizePlannedBoundary(record, finished.id, finished.neighbor.clip.id, result.frame, actions.generationLimits?.())); - else if (result.kind === 'planned') attempt(t('Resize', '改长短'), record => model.resizePlanned(record, finished.id, finished.edge, result.frame, actions.generationLimits?.())); + else if (result.kind === 'planned') attempt(t('Resize', '调整时长'), record => model.resizePlanned(record, finished.id, finished.edge, result.frame, actions.generationLimits?.())); else if (result.kind === 'extend') { const frames = result.frame - finished.clip.start - finished.clip.frames + result.frames; if (extendClip(finished.id, frames) && result.raised) { - actions.notify?.(t(`Extended to ${result.frames / model.FPS} s.`, `已补到 ${result.frames / model.FPS} 秒。`)); + actions.notify?.(t(`Extended to ${result.frames / model.FPS} s.`, `已延长到 ${result.frames / model.FPS} 秒。`)); } } else attempt(t('Trim', '裁短'), record => model.trim(record, finished.id, finished.edge, result.frame)); renderSelection(); diff --git a/web/error_panel.js b/web/error_panel.js index 817836d7..51185e89 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -33,13 +33,13 @@ const PLAIN_ERRORS = [ summary: ['Choose a video size with the same aspect ratio as the previous video.', '请选择与上一段视频比例相同的画面大小。']}, {key: 'continuation-route', message: 'Continue from video cannot be used with these settings. Continuing a video needs an NVIDIA GPU or a Mac. To make a new video, clear "Continue from video".', title: ['This computer can\'t continue a video', '这台电脑无法接续视频'], - summary: ['Continuing a video needs an NVIDIA GPU or a Mac. To make a new video, clear "Continue from video".', '接续视频需要 NVIDIA 显卡或 Mac。要生成新视频,请清空“接着生成”。']}, + summary: ['Continuing a video needs an NVIDIA GPU or a Mac. To make a new video, clear "Continue from video".', '接续视频需要 NVIDIA 显卡或 Mac。要生成新视频,请清空“继续生成”。']}, {key: 'continuation-passes', message: 'Continue from video cannot be used with these settings. Turn on "Two-pass acceleration" and turn off the first-pass preview.', title: ['Continuing needs two-pass acceleration', '接续需要二次采样加速'], - summary: ['Turn on "Two-pass acceleration" and turn off the first-pass preview.', '请打开“二次采样加速”,并关闭先出预览。']}, + summary: ['Turn on "Two-pass acceleration" and turn off the first-pass preview.', '请打开“二次采样加速”,并关闭“先生成预览”。']}, {key: 'continuation-refine', message: 'Continue from video cannot be used with these settings. Clear either "Second pass of preview" or "Continue from video".', title: ['A continued part is made in full', '接续会完整生成这一段'], - summary: ['Clear either "Second pass of preview" or "Continue from video".', '请清空“继续二采”或“接着生成”其中一项。']}, + summary: ['Clear either "Second pass of preview" or "Continue from video".', '请清空“继续二采”或“继续生成”其中一项。']}, {key: 'continuation-media', message: 'Continue from video cannot be used with these settings. Connect up to 3 pictures as references, and remove video, audio and pre-encoded inputs.', title: ['A continued part takes picture references only', '接续段只能用图片参考'], summary: ['Connect up to 3 pictures as references, and remove video, audio and pre-encoded inputs.', '最多接 3 张图片作为参考图,并移除视频、音频参考和预编码输入。']}, @@ -56,10 +56,10 @@ const PLAIN_ERRORS = [ summary: ['Select an existing MP4 video generated by FreeVideo. In ComfyUI, keep it inside the output or input folder.', '请选择 FreeVideo 生成且仍存在的 MP4 视频;在 ComfyUI 中,请把它放在输出或输入文件夹内。']}, {key: 'reference-too-short', message: 'The reference video is too short; it needs to be at least about 1 s long.', title: ['The reference video is too short', '参考视频太短'], - summary: ['It needs to be at least about 1 s long. Choose a longer video.', '参考视频至少需要约 1 秒,请换一段更长的视频。']}, + summary: ['It needs to be at least about 1 s long. Choose a longer video.', '参考视频至少需要约 1 秒,请选择更长的视频。']}, {key: 'video-no-picture', message: 'No picture could be read from this video file. Choose another video.', title: ['This video could not be read', '无法读取这个视频'], - summary: ['No picture could be read from this video file. Choose another video.', '无法从这个视频文件里读出画面,请换一个视频。']}, + summary: ['No picture could be read from this video file. Choose another video.', '无法从此视频文件中读取画面,请选择其他视频。']}, {key: 'lora-unusable', message: 'This LoRA was not trained for the FreeVideo model and cannot be used.', prefixes: ['LoRA target/shape incompatible with this VDN model', 'Full-difference LoRA target/shape incompatible with this VDN model', 'Unsupported LoRA tensor (no patches are ignored): ', 'Adapter contains no LoRA factors', 'Expected matching 2-D LoRA factors', 'Incomplete LoRA pair'], @@ -316,12 +316,12 @@ export function failureAdvice(value, t, known = knownError(value)) { } // A custom step count is not a quality level: no "档" / "quality level" for it. const title = custom - ? [`Custom (${known.base_steps} steps) is not downloaded yet`, `“自定义(${known.base_steps} 步)”还没下载`] - : [`The ${levels[index][0]} quality level is not downloaded yet`, `${keep(`“${levels[index][1]}”档`)}还没下载`]; - const what = custom ? ['This step count', '这个步数'] : ['This level', '这一档']; + ? [`Custom (${known.base_steps} steps) is not downloaded yet`, `“自定义(${known.base_steps} 步)”尚未下载`] + : [`The ${levels[index][0]} quality level is not downloaded yet`, `${keep(`“${levels[index][1]}”档`)}尚未下载`]; + const what = custom ? ['This step count', '此步数'] : ['This level', '该档']; return {...row('sampling-table', title, - [`${what[0]} needs a download (about ${size}) that did not finish this time. Generate again when online and it downloads automatically.${available.length ? ` You can also use ${join(0)} for now.` : ''}`, - `${what[1]}要先下载(约 ${size}),这次没能下载。联网后再生成一次,会自动下载。${available.length ? `也可以先用${join(1)}。` : ''}`], ['', '']), plain: true}; + [`${what[0]} needs a download (about ${size}) that did not finish. Generate again when online; it downloads automatically.${available.length ? ` You can also use ${join(0)} instead.` : ''}`, + `${what[1]}需要先下载(约 ${size}),本次下载未完成。联网后重新生成,将自动下载。${available.length ? `也可以改用${join(1)}。` : ''}`], ['', '']), plain: true}; } // ComfyUI and worker tracebacks carry English exceptions. Translate these // actionable failures through the existing signature/advice path. @@ -340,10 +340,10 @@ 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', ['Not enough GPU memory', '显存不够'], + 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}。请调整时长或清晰度后重新生成;关闭其他占用显卡的程序也可以腾出显存。`]); + `现在可以生成:${zh}。请调整时长或清晰度后重新生成;关闭其他占用显卡的程序也可释放显存。`]); } return row('resources', ['The request exceeds available memory', '当前任务的可用内存不足'], ['Generation did not start because there is not enough available RAM or GPU memory.', '可用内存或显存不足,本次生成未开始。'], @@ -382,10 +382,10 @@ export function failureAdvice(value, t, known = knownError(value)) { ['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 (outputNotWritable(text)) return row('output-folder', ['The output folder cannot be written to', '输出目录不可写'], - ['The video could not be saved.', '视频没能保存。'], + ['The video could not be saved.', '视频未能保存。'], ['Check that the output folder can be written to, then retry.', '请检查输出文件夹的写入权限后重试。']); if (/manifest\.json/i.test(text) && /No such file|FileNotFoundError|\[Errno 2\]/i.test(text)) - return row('model-files', ['The model is not fully installed', '模型没有装全'], + return row('model-files', ['The model is not fully installed', '模型安装不完整'], ['Some of the model files FreeVideo needs are missing.', 'FreeVideo 需要的部分模型文件缺失。'], repairAction()); if (/No space left on device|WinError 112|disk (?:is )?full/i.test(text)) @@ -502,9 +502,9 @@ export function createErrorPanel(t) { const location = document.createElement('p'), cause = document.createElement('p'); cause.className = 'fv-failure-cause'; location.className = 'fv-failure-location'; const privacy = document.createElement('p'); privacy.className = 'fv-failure-privacy'; - privacy.textContent = t('Paths and typed text are removed.', '已去掉路径和输入的文字。'); + privacy.textContent = t('Paths and typed text are removed.', '已移除路径和输入的文字。'); const privacyInside = document.createElement('p'); privacyInside.className = 'fv-failure-privacy'; privacyInside.hidden = true; - privacyInside.textContent = t('Paths and typed text are removed.', '已去掉路径和输入的文字。'); + privacyInside.textContent = t('Paths and typed text are removed.', '已移除路径和输入的文字。'); const downloadStatus = document.createElement('p'); downloadStatus.setAttribute('role', 'status'); let currentReport = null, githubLora = false; const disclosure = document.createElement('details'), summary = document.createElement('summary'); @@ -605,7 +605,7 @@ export function createErrorPanel(t) { action.hidden = !action.textContent; privacy.textContent = download.hidden ? t('Select "Download report" in the preview to save the full diagnostic information.', '可在预览区点“下载报告”保存完整诊断信息。') - : t('Paths and typed text are removed.', '已去掉路径和输入的文字。'); + : t('Paths and typed text are removed.', '已移除路径和输入的文字。'); copy.textContent = advice.kind === 'sampling-table' ? t('Copy details', '复制详情') : t('Copy error', '复制错误'); // A generation failure shows two buttons; copying moves into the technical details. copy.hidden = generated; copyInside.hidden = !generated; copyInside.textContent = copy.textContent; diff --git a/web/feedback.js b/web/feedback.js index b4aba8fa..fdf8065d 100644 --- a/web/feedback.js +++ b/web/feedback.js @@ -124,7 +124,7 @@ export function openFeedback(t, { open = openPage, version = productVersion, 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.placeholder = t('Describe what could be improved', '请填写改进建议'); field.setAttribute('aria-label', t('Feedback', '反馈')); field.value = readDraft(); @@ -132,7 +132,7 @@ export function openFeedback(t, { open = openPage, version = productVersion, 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 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; @@ -164,7 +164,7 @@ export function openFeedback(t, { open = openPage, version = productVersion, 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.", '没能截取这一页,可以不带截图提交。'); + 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. @@ -174,7 +174,7 @@ export function openFeedback(t, { open = openPage, version = productVersion, 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.", '没能读取诊断信息,可以不带它提交。'); } + 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(); @@ -190,16 +190,16 @@ export function openFeedback(t, { open = openPage, version = productVersion, 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,但文字过长,那边被截短了,完整文字已复制,请粘贴补全。')) + ? t('GitHub is open, but the text was too long and was truncated on GitHub. The full text remains here.', '已打开 GitHub,但文字过长,GitHub 页面中的内容已被截断,完整文字仍保留在此处。') + : t('GitHub is open, but the text was too long and was truncated on GitHub. The full text is copied; paste it there to complete it.', '已打开 GitHub,但文字过长,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,请稍后再试。')) + ? (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,截图已复制,粘贴后提交。') + ? t('GitHub is open and the screenshot is copied. Paste it there, 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,文字已填好,确认后提交。'); + ? t("GitHub is open with the text filled in, but the screenshot couldn't be copied. It can be submitted without the screenshot.", '已打开 GitHub,文字已填好,但截图未能复制,可不附截图提交。') + : t('GitHub is open with the text filled in. Review it, then submit.', '已打开 GitHub,文字已填好,请确认后提交。'); }; const stopKeys = isolateDialogKeys(dialog, event => routeDialogKey(event, dialog)); diff --git a/web/first_run_guide.js b/web/first_run_guide.js index 1f8ce232..bfad6f4e 100644 --- a/web/first_run_guide.js +++ b/web/first_run_guide.js @@ -30,7 +30,7 @@ export const CHAPTERS = [ en: 'For a longer video, enter a longer duration and it is made in parts automatically. You can also add parts one by one in "Edit".', zh: '填写更长的时长会自动分段生成,也可在“剪辑视图”中逐段接续。'}, {id: 'agent', anchor: '[data-fv-guide="agent"]', added: true, - en: 'To have an AI agent such as Claude Code or Codex make videos for you, choose "Autopilot".', zh: '要让 Claude Code、Codex 等 Agent 替你生\u2060成\u2060视\u2060频,点“AI 全自动”。'}, + en: 'To have an AI agent such as Claude Code or Codex make videos, choose "Autopilot".', zh: '要由 Claude Code、Codex 等 Agent 生\u2060成\u2060视\u2060频,请选择“AI 全自动”。'}, {id: 'editor-timeline', group: 'editor', anchor: '[data-fv-guide="editor-timeline"]', added: true, en: 'Drag a video here.', zh: '把视频拖到这里。'}, {id: 'editor-trim', group: 'editor', anchor: '[data-fv-guide="editor-trim"]', added: true, diff --git a/web/freevideo.js b/web/freevideo.js index a92283ca..05bba259 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -205,7 +205,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('This image cannot be edited right now; choose an image again.', '图片暂时无法编辑,可重新选择图片。'), true); return; } + } catch (error) { message(text('This image cannot be edited at the moment; 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); @@ -237,7 +237,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('This image cannot be edited right now; choose an image again.', '图片暂时无法编辑,可重新选择图片。'), true); return; } + } catch { message(text('This image cannot be edited at the moment; choose an image again.', '图片暂时无法编辑,可重新选择图片。'), true); return; } if (!outcome?.blob) return; if (read().length >= 32) { message(text('The media list is full (32 items, including disabled ones). Remove some to add more.', '素材已满 32 个(含未启用的),请先移除不用的素材。'), true); return; } node.isUploading = true; add.disabled = addAudio.disabled = generateImage.disabled = true; @@ -574,7 +574,7 @@ function resultPanel(node) { } panel.append(stats, links, progress.stall, progress.report); for (const row of value.reference_trims || []) panel.append(el("div", referenceTrimText(row, text), "fv-note fv-trim-note")); - if (value.low_memory && !value.result_cache_hit) resultNotes.push(el("div", text('Generation was slow; try a lower resolution or shorter video.', '生成较慢,可降低分辨率或缩短时长后再试。'), "fv-note")); + if (value.low_memory && !value.result_cache_hit) resultNotes.push(el("div", text('Generation was slow; try a lower resolution or shorter video.', '生成较慢,可降低分辨率或缩短时长后重试。'), "fv-note")); if (value.thermal && !value.result_cache_hit) resultNotes.push(el("div", thermalText(value.thermal, text), "fv-note")); const shared = value.shared_gpu && !value.result_cache_hit ? sharedGpuText(value.shared_gpu, text) : ''; if (shared) resultNotes.push(el("div", shared, "fv-note")); @@ -678,7 +678,7 @@ async function openFromMenu() { showStudio(node); } catch (error) { console.warn('FreeVideo menu:', error); - const message = text('Could not complete. Try again.', '没有完成,请重试'); + const message = text('Could not complete. Try again.', '未完成,请重试'); const dialog = el('dialog', undefined, 'fv-panel fv-menu-failure'); menuFailure = dialog; dialog.setAttribute('aria-label', 'FreeVideo'); const actions = el('div', undefined, 'fv-row'); @@ -773,9 +773,9 @@ app.registerExtension({ ['seconds', '时长(秒)'], ['seed', '种子'], ['force_regenerate', '重新生成', '跳过已保存结果的缓存。'], - ['preview', '先出预览', '先出半分辨率预览,满意后在结果里点“继续二采”,成片与直接生成一致。'], + ['preview', '先生成预览', '先生成半分辨率预览,满意后在结果中点击“继续二采”,成片与直接生成一致。'], ['refine_from', '继续二采', '从这个预览继续生成全分辨率视频。'], - ['continue_from', '接着生成'], + ['continue_from', '继续生成'], ['frames', '帧数'], ['first_frame_reference', '首帧参考', '减小漂移'], ['step_cache', '步间缓存', '复用相近采样步之间的计算,更快完成,结果会有细微变化。'], @@ -842,7 +842,7 @@ app.registerExtension({ else { const studio = showStudio(node), status = studio?.querySelector?.('.fv-status'); if (status) { - const message = text('Could not complete. Try again.', '没有完成,请重试'); + const message = text('Could not complete. Try again.', '未完成,请重试'); status.dataset.error = 'true'; status.textContent = message; status.append(issueButton(text, () => ({where: ['Open editor', '打开剪辑'], message}))); } diff --git a/web/generation_progress.js b/web/generation_progress.js index 31df724b..d1b07896 100644 --- a/web/generation_progress.js +++ b/web/generation_progress.js @@ -22,7 +22,7 @@ const SUBACTIVITY = { reference: ['Processing reference images and videos', '正在处理参考图片和视频'], load: ['Loading the video model', '正在载入视频模型'], first: ['Running the first step (it takes longer the first time a size is used or after an update)', - '正在进行第一步(首次使用这个尺寸或更新后,第一步要多花一些时间)'], + '正在进行第一步(首次使用此尺寸或更新后,第一步耗时较长)'], upscale: ['Upscaling the video', '正在放大画面'], decoder: ['Preparing video output', '正在准备输出视频'], decode: ['Rendering the video', '正在输出视频'], @@ -105,7 +105,7 @@ const STALL_SECONDS_BY_PHASE = Object.freeze({ // Stage words the product already uses: settings say 一采 / 二采, results say 解码. const ACTIVITY = { prepare: ['Preparing', '正在准备'], - describe: ['Writing part descriptions', '正在写各段描述'], + describe: ['Writing part descriptions', '正在生成各段描述'], prompt: ['Processing the prompt', '正在处理提示词'], combined: ['Processing the prompt and references', '正在处理提示词和参考素材'], load: ['Loading the video model', '正在载入视频模型'], @@ -230,8 +230,8 @@ function activityLabel(message, t, plan = {}, secondPass = false, references = f function retryText(retry, t) { const reason = {gpu_oom: ['Out of GPU memory', '显存不足'], ram_pressure: ['Out of RAM', '内存不足'], - unified_memory: ['Not enough memory', '内存不够']}[retry.kind] - || ['Previous attempt did not finish', '上一次尝试没有完成']; + unified_memory: ['Not enough memory', '内存不足']}[retry.kind] + || ['Previous attempt did not finish', '上一次尝试未完成']; if (retry.reuse_sampling === true) return t(`${reason[0]}; the generated result is kept, and a retry is in progress.`, `${reason[1]},已保留生成结果,正在重试。`); return t(`${reason[0]}; adjusting settings and retrying.`, `${reason[1]},正在调整设置后重试。`); @@ -868,7 +868,7 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f eta.hidden = remainingSeconds === null; // Past the shown time the line says so once, instead of a number that only shrinks. late.hidden = !(remainingSeconds === null && remainingRetired && !ended); - late.textContent = late.hidden ? '' : t('Taking longer than expected', '比预计久,仍在生成'); + late.textContent = late.hidden ? '' : t('Taking longer than expected', '耗时超出预计,仍在生成'); const etaText = remainingSeconds === null ? '' : remainingSeconds >= 60 ? t(`About ${Math.ceil(remainingSeconds / 60)}\u00a0min left`, `预计还需约 ${Math.ceil(remainingSeconds / 60)}\u00a0分钟`) : t('Less than a minute left', '预计还需不到 1\u00a0分钟'); diff --git a/web/health.js b/web/health.js index 81add2c2..e8a59e6f 100644 --- a/web/health.js +++ b/web/health.js @@ -135,7 +135,7 @@ const ADVICE = { files: t('FreeVideo’s page files are missing or unreadable. In the FreeVideo launcher, open Settings and click Repair, then open FreeVideo again. Without the launcher, update the FreeVideo node and restart ComfyUI.', 'FreeVideo 的网页文件缺失或无法读取。请在 FreeVideo 启动器的“设置”里点击“修复”,完成后重新打开。没有使用启动器时,请更新 FreeVideo 节点并重启 ComfyUI。'), type: t('ComfyUI sent the file with the wrong type, so the browser refused it. Restart ComfyUI. If it persists, use Report a problem.', - 'ComfyUI 发给浏览器的文件类型不对,浏览器拒绝使用。请重启 ComfyUI;仍然出现时,请点“提交问题”。'), + 'ComfyUI 返回的文件类型有误,浏览器已拒绝加载。请重启 ComfyUI;仍然出现时,请点“提交问题”。'), browser: t('This browser cannot run FreeVideo. Open the same address in a current Chrome or Edge.', '当前浏览器无法运行 FreeVideo。请用最新版 Chrome 或 Edge 打开同一个地址。'), mixed: t('The browser mixed files from two FreeVideo versions. Press Ctrl+F5 to reload without the cache.', @@ -373,7 +373,7 @@ function workspaceProblem(app) { // The editor shares the studio shell; either view replaces the floating toolbar. const studio = document.querySelector('dialog.fv-editor[open]') || document.querySelector('dialog.fv-studio[open]'); if (studio) return onScreen(studio) ? null - : {text: t('The creative workspace is open but cannot be seen on the page.', '创作面板已经打开,但页面上看不到。'), advice: 'reload'}; + : {text: t('The creative workspace is open but cannot be seen on the page.', '创作面板已打开,但在页面上不可见。'), advice: 'reload'}; const bar = document.querySelector('.fv-view-navigation'); if (!bar) return {text: t('The FreeVideo toolbar that opens the creative workspace is missing from the page.', '页面上没有打开创作面板的 FreeVideo 工具栏。'), advice: 'reload'}; if (onScreen(bar) && !covered(bar)) return null; @@ -381,7 +381,7 @@ function workspaceProblem(app) { return sheetLoaded('view_navigation.css') ? {text: t('The FreeVideo toolbar that opens the creative workspace is hidden or covered by other page elements.', '打开创作面板的 FreeVideo 工具栏被页面上的其他元素隐藏或遮挡了。'), advice: 'plugin'} : {text: t('The FreeVideo toolbar that opens the creative workspace cannot be seen: its stylesheet view_navigation.css did not load.', - '看不到打开创作面板的 FreeVideo 工具栏:样式文件 view_navigation.css 没有加载。'), advice: 'reload', sheet: 'view_navigation.css'}; + '打开创作面板的 FreeVideo 工具栏不可见:样式文件 view_navigation.css 没有加载。'), advice: 'reload', sheet: 'view_navigation.css'}; } // Looks again as the page changes, such as another workflow coming to the @@ -411,7 +411,7 @@ async function watchWorkspace(app) { } function reportText(item) { - const where = {studio: t('The creative workspace could not open', '创作面板打不开'), + const where = {studio: t('The creative workspace could not open', '创作面板无法打开'), navigation: t('The FreeVideo toolbar could not be added', 'FreeVideo 工具栏没有加载'), progress: t('Live progress could not start', '生成进度无法更新'), updates: t('Update checks could not start', '更新检查无法启动'), @@ -646,7 +646,7 @@ async function check() { if (launchVisit && !problems.length) { if (!launched) problems.push({text: t('The FreeVideo workflow did not open: launcher.js did not report back.', 'FreeVideo 工作流没有打开:launcher.js 没有响应。'), advice: 'reload'}); else if (launched.error) problems.push(reportText(launched)); - else if (!studioWorkflow(app.graph)) problems.push({text: t('ComfyUI shows another workflow; the FreeVideo workflow is in another tab.', 'ComfyUI 显示的是另一个工作流,FreeVideo 工作流在别的标签页里。'), advice: 'relaunch'}); + else if (!studioWorkflow(app.graph)) problems.push({text: t('ComfyUI shows another workflow; the FreeVideo workflow is in another tab.', 'ComfyUI 显示的是另一个工作流,FreeVideo 工作流在其他标签页中。'), advice: 'relaunch'}); else checkStudio = true; } for (const item of reports) if (item.type === 'error') problems.push(reportText(item)); diff --git a/web/howto.js b/web/howto.js index a291e927..d311010d 100644 --- a/web/howto.js +++ b/web/howto.js @@ -35,7 +35,7 @@ const HOWTOS = [ zh: '填写更长的时长,会自动分段生成。'}, {id: 'generate', anchor: '[data-fv-guide="generate"]', en: 'Choose "Generate video".', zh: '点“生成视频”。'}, {id: 'extend', anchor: '.fv-action-extend', image: 'extend-button', - en: 'Choose "Extend" on a finished video to continue it.', zh: '在生成好的视频上点“接着生成”,继续下一段。'}, + en: 'Choose "Extend" on a finished video to continue it.', zh: '在已生成的视频上点击“继续生成”,继续下一段。'}, {id: 'longer', anchor: '[data-view="editor"]', en: 'Parts can also be added one by one in "Edit".', zh: '也可在“剪辑视图”中逐段接续。'}]}, {id: 'editor-extend-refs', view: 'editor', title: ['Add reference images when extending in Edit', '在剪辑视图延长时加参考图'], steps: [ diff --git a/web/image_editor.js b/web/image_editor.js index 4cd1efc8..c171b639 100644 --- a/web/image_editor.js +++ b/web/image_editor.js @@ -221,7 +221,7 @@ export async function openImageEditor({source = null, name = source || '', targe panel.append(props, layersBox); main.append(toolbar, workspace, panel); const confirmBar = el('div', null, 'fv-ie-confirm'); confirmBar.hidden = true; - confirmBar.append(el('span', t('Leave without saving? Your changes to this image will be lost.', '不保存就离开?对这张图的修改会丢失。')), + confirmBar.append(el('span', t('Leave without saving? Your changes to this image will be lost.', '确定不保存并离开?对这张图片的修改将丢失。')), button(t('Discard', '放弃修改'), null, () => close(null), 'fv-ie-button fv-ie-danger'), button(t('Keep editing', '继续编辑'), null, () => { confirmBar.hidden = true; save.focus(); })); const status = el('div', '', 'fv-ie-status'); status.setAttribute('role', 'status'); @@ -538,7 +538,7 @@ export async function openImageEditor({source = null, name = source || '', targe let text = ''; if (['eraser', 'heal', 'clone', 'blur'].includes(tool) && !rasterKinds.has(layer.kind)) text = t('Pick an image or paint layer to work on its pixels.', '请选择图片图层或绘画图层再处理像素。'); else if (tool === 'clone' && !opts.clone.source) text = t('Alt-click to set the source, then paint.', '先按住 Alt 点击设置取样点,再涂抹。'); - else if (tool === 'move' && layer.locked && doc.layers.length > 1) text = t('The original image is locked. Click another layer to move it.', '原图图层已锁定;点击其他图层即可移动。'); + else if (tool === 'move' && layer.locked && doc.layers.length > 1) text = t('The original image is locked. Click another layer to move it.', '原图图层已锁定;点击其他图层后可移动。'); else if (tool === 'lasso' && opts.lasso.type === 'polygon') text = t('Click to add corners; double-click or press Enter to close.', '单击添加顶点;双击或按 Enter 闭合。'); hint.textContent = text; hint.hidden = !text; } @@ -922,7 +922,7 @@ export async function openImageEditor({source = null, name = source || '', targe commitInserted(commands.addImageLayer(doc, {src: file, nw: img.naturalWidth, nh: img.naturalHeight, name: label || file.split('/').pop()})); select('move'); - } catch { message(t('The image could not be added. Choose another one.', '图片没能添加,请换一张。'), ['Image editor', '图片编辑器']); } + } catch { message(t('The image could not be added. Choose another one.', '无法添加图片,请更换图片。'), ['Image editor', '图片编辑器']); } } filePicker.onchange = async () => { const file = filePicker.files?.[0]; filePicker.value = ''; @@ -1031,7 +1031,7 @@ export async function openImageEditor({source = null, name = source || '', targe // [id, label, size or null when it would not make the picture smaller, why not] const presets = [ ...(target ? [['video', t('Video size', '视频尺寸'), video < 1 ? sized(video) : null, - t('The picture is no larger than the video', '图片已经不比视频大')]] : []), + t('The picture is no larger than the video', '图片尺寸已不大于视频')]] : []), ['half', '50%', sized(.5), ''], ['long', t('Long side 2048', '长边 2048'), long > 2048 ? sized(2048 / long) : null, t('The long side is already 2048 px or less', '长边已经不超过 2048 像素')], @@ -1083,7 +1083,7 @@ export async function openImageEditor({source = null, name = source || '', targe if (reason === 'dimensions') { text = t('Enter whole numbers from 1 to 20000.', '请输入 1 到 20000 的整数。'); warn = true; } else if (reason === 'enlargement') { text = t('You can enlarge up to 200% of the original.', '最多放大到原来的 200%。'); warn = true; } else if (reason === 'pixels') { text = t('Up to 64 megapixels; reduce the width or height.', '最多 6400 万像素,请减小宽或高。'); warn = true; } - else if (reason === 'unchanged') { text = t('The same size as now.', '和现在一样大。'); } + else if (reason === 'unchanged') { text = t('Same as the current size.', '与当前尺寸相同。'); } else if (kx > 1 + 1e-9 || ky > 1 + 1e-9) { text = t(`${share()}; enlarging makes the picture softer.`, `${share('放大到')},画面会变模糊。`); warn = true; } else if (target && presets[0][2] && w === presets[0][2][0] && h === presets[0][2][1]) { @@ -1149,7 +1149,7 @@ export async function openImageEditor({source = null, name = source || '', targe if (layer) commit(pixelCommand(doc, layer.id, d => commands.fillSelection(d, layer.id, color, selection))); } function clearSelection() { - if (!selection) { message(t('Nothing is selected.', '还没有选区。')); return; } + if (!selection) { message(t('Nothing is selected.', '尚无选区。')); return; } const layer = pixelLayer(); if (layer && selection) commit(pixelCommand(doc, layer.id, d => commands.clearSelection(d, layer.id, selection))); } @@ -1249,13 +1249,13 @@ export async function openImageEditor({source = null, name = source || '', targe if (!pointInfo?.ready) { flashPointDownload(); return null; } if (pointBusy) return null; const how = mode || opts.point.mode; - pointBusy = true; message(t('Finding the object…', '正在找出物体……')); + pointBusy = true; message(t('Finding the object…', '正在识别物体…')); segmentAt(renderer, genClient, doc, src, [u, v]).then(file => { if (doc.layers.indexOf(src) < 0) return; addPart({shape: 'mask', layer: src.id, file, upto: src.ops.length}, how); message(''); }).catch(error => { - message(error?.code === 'busy_video' ? t('A video is generating. Try again when it finishes.', '视频正在生成,完成后再试。') - : t('Could not find an object here. Try again.', '没能在这里找出物体,请再试一次。'), error?.code === 'busy_video' ? null : ['Image object selection', '图片智能抠图']); + message(error?.code === 'busy_video' ? t('A video is generating. Try again when it finishes.', '视频正在生成,请在完成后重试。') + : t('Could not find an object here. Try again.', '未能在此处识别出物体,请重试。'), error?.code === 'busy_video' ? null : ['Image object selection', '图片智能抠图']); }).finally(() => { pointBusy = false; }); return null; } @@ -1635,7 +1635,7 @@ export async function openImageEditor({source = null, name = source || '', targe const {blob, width, height} = await renderer.export(doc, extension === 'jpg' ? 'image/jpeg' : 'image/png'); close({blob, name: name ? `${stem}-edit.${extension}` : `${stem}.${extension}`, edit: doc, width, height}); } catch { - message(t('The image could not be saved. Try a smaller canvas or reload the page.', '图片没能保存,请缩小画布或刷新页面后重试。'), ['Image editor save', '图片编辑器保存']); + message(t('The image could not be saved. Try a smaller canvas or reload the page.', '图片未能保存,请缩小画布或刷新页面后重试。'), ['Image editor save', '图片编辑器保存']); save.disabled = cancel.disabled = false; } } diff --git a/web/image_generation.js b/web/image_generation.js index bc720902..9898ece8 100644 --- a/web/image_generation.js +++ b/web/image_generation.js @@ -129,7 +129,7 @@ export function imagePackMessage(info, t) { // A non-breaking space keeps the number and its unit on one line. const size = (info?.pack_size || (bytes > 0 ? (bytes / 2 ** 30).toFixed(1) + ' GiB' : '17.3 GB')) .replace(/ (?=[KMGT]i?B$)/, '\u00a0'); - return t(`Download "${name}" in the launcher first, ${size}.`, `先在启动器里下载“${name}”,${size}。`); + return t(`Download "${name}" in the launcher first, ${size}.`, `请先在启动器中下载“${name}”,${size}。`); } // What the page offers while the image models are missing: one button, one muted line, and the bar once loading. export function packView(info, t) { @@ -156,19 +156,19 @@ export function packView(info, t) { export function imageGenError(code, info, t) { const reasons = { no_memory: ['Not enough GPU memory, even at the smallest size. Close other apps using the GPU, then try again.', - '显存不够,最小尺寸也没能生成。关掉其他占用显卡的程序后再试。'], - busy_video: ['A video is generating. Try again when it finishes.', '视频正在生成,完成后再试。'], - busy: ['An image is already being generated. Try again when it finishes.', '已有一张图在生成,完成后再试。'], + '显存不足,最小尺寸也未能生成。请关闭其他占用显卡的程序后重试。'], + busy_video: ['A video is generating. Try again when it finishes.', '视频正在生成,请在完成后重试。'], + busy: ['An image is already being generated. Try again when it finishes.', '已有图片正在生成,请在完成后重试。'], model_unreadable: ['The image generation model files could not be read. Repair them in the launcher and try again.', - '图像生成模型的文件读不出来。在启动器里修复后再试。'], - device_unavailable: ['The graphics card is not available right now. Try again later.', '显卡暂时用不了,稍后再试。'], - worker_exit: ['Image generation stopped unexpectedly. Try again.', '图像生成意外停止了,请再试一次。'], - timeout: ['This image took too long and was stopped. Try again.', '这一张用时太长,已停止。请再试一次。'], + '无法读取图像生成模型文件,请在启动器中修复后重试。'], + device_unavailable: ['The graphics card is temporarily unavailable. Try again later.', '显卡暂时不可用,请稍后重试。'], + worker_exit: ['Image generation stopped unexpectedly. Try again.', '图像生成意外停止,请重试。'], + timeout: ['This image exceeded the time limit and was stopped. Try again.', '此图片生成超时,已停止。请重试。'], cancelled: ['Image generation was cancelled.', '已取消图像生成。'], invalid_request: ['The image settings are invalid. Adjust them and try again.', '图片设置有误,请调整后重试。'], - invalid_media: ['The reference image cannot be read. Choose another image.', '参考图读不出来,请换一张图。'], - reload: ['Image generation was interrupted. Try again.', '图片生成中途被打断,请重试。'], - generation_failed: ['Image generation ran into an error. Try again.', '图像生成出错了,请再试一次。'], + invalid_media: ['The reference image cannot be read. Choose another image.', '无法读取参考图,请更换一张。'], + reload: ['Image generation was interrupted. Try again.', '图片生成被中断,请重试。'], + generation_failed: ['Image generation encountered an error. Try again.', '图像生成出错,请重试。'], small_memory: ['This Mac has less than 16 GB of memory and cannot generate pictures.', '这台 Mac 的内存不足 16 GB,无法生成图片。'], }; @@ -180,13 +180,13 @@ export function imageGenError(code, info, t) { const stage = info?.stage, size = info?.fitted; if (stage === 'encode') return say('Not enough GPU memory to load the reference images. Use fewer reference images, or close other apps using the GPU, then try again.', - '显存不够,参考图没能载入。少用几张参考图,或关掉其他占用显卡的程序后再试。'); + '显存不足,参考图未能载入。请减少参考图数量,或关闭其他占用显卡的程序后重试。'); if (stage === 'load' || stage === 'text') return say('Not enough GPU memory to load the model. Close other apps using the GPU, then try again.', - '显存不够,模型没能载入。关掉其他占用显卡的程序后再试。'); + '显存不足,模型未能载入。请关闭其他占用显卡的程序后重试。'); if (size?.width && size?.height) return say(`Not enough GPU memory, even at ${size.width}\u00a0×\u00a0${size.height}. Close other apps using the GPU, then try again.`, - `显存不够,${size.width}\u00a0×\u00a0${size.height} 也没能生成。关掉其他占用显卡的程序后再试。`); + `显存不足,${size.width}\u00a0×\u00a0${size.height} 也未能生成。请关闭其他占用显卡的程序后重试。`); } return say(...(Object.hasOwn(reasons, code) ? reasons[code] : reasons.generation_failed)); } @@ -648,7 +648,7 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection menu.append(summary, list); const draw = () => { list.replaceChildren(); - if (!past.length) { list.append(el('div', t('No images generated yet', '还没有生成过图片'), 'fv-ie-menu-label')); return; } + if (!past.length) { list.append(el('div', t('No images generated yet', '暂无已生成的图片'), 'fv-ie-menu-label')); return; } const grid = el('div', null, 'fv-ie-gen-history-grid'); for (const item of past) { const tile = el('div', null, 'fv-ie-gen-history-tile'); @@ -712,7 +712,7 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection b.setAttribute('aria-pressed', String(b.dataset.value === mode)); }); input.disabled = disabled; renderReferences(refs, disabled); - input.placeholder = mode === 'edit' ? getSelection() ? t('What to change in the selection', '要改选区里的什么') : t('What to change', '要改什么') : t('Describe the picture', '描述画面'); + input.placeholder = mode === 'edit' ? getSelection() ? t('Describe the change in the selection', '描述选区内的修改') : t('Describe the change', '描述修改内容') : t('Describe the picture', '描述画面'); input.setAttribute('aria-label', input.placeholder); const pack = missing ? packView(state.info, t) : null; action.textContent = pack ? pack.button : state.running ? t('Stop', '停止') : t('Generate', '生成'); diff --git a/web/library.js b/web/library.js index 957bc7d0..0ec8403f 100644 --- a/web/library.js +++ b/web/library.js @@ -93,7 +93,7 @@ export async function libraryPage(t, {limit = 24, before = null, signal} = {}) { const query = new URLSearchParams({limit: String(limit)}); if (before) query.set('before', before); const response = await api.fetchApi('/freevideo/library?' + query, {signal}); - if (response.status === 404) throw Object.assign(new Error(t('Restart ComfyUI to open your creations after updating.', '更新后重启 ComfyUI,即可打开作品。')), {restart: true}); + if (response.status === 404) throw Object.assign(new Error(t('Restart ComfyUI to open your creations after updating.', '更新后需重启 ComfyUI 才能打开作品。')), {restart: true}); if (!response.ok) throw new Error(t('Could not load your creations. Try Refresh.', '作品暂时无法读取,请点击刷新。')); return response.json(); } @@ -144,19 +144,19 @@ async function workflowBackfill(collection, t) { const result = value?.result || {}; heading.textContent = result.embedded ? t(`Workflows added to ${result.embedded} videos`, `已为 ${result.embedded} 个视频补上工作流`) : t('No video was changed', '没有视频被改动'); text.textContent = result.embedded - ? t('Drag them onto the canvas to restore their workflow. The originals are kept next to them as video.before-workflow.mp4.', '把它们拖到画布上即可还原工作流。原视频保留在各自目录,文件名为 video.before-workflow.mp4。') + ? t('Drag them onto the canvas to restore their workflow. The originals are kept next to them as video.before-workflow.mp4.', '把它们拖到画布上可还原工作流。原视频保留在各自目录,文件名为 video.before-workflow.mp4。') : t('Videos that could not be checked safely were left as they were.', '无法安全确认的视频都保持原样。'); if (result.failed) text.textContent += ' ' + t(`${result.failed} videos were left unchanged and will be offered again.`, `另有 ${result.failed} 个视频未改动,之后会再次提示。`); - actions.replaceChildren(button(t('Done', '好的'), () => box.remove(), 'fv-quiet')); + actions.replaceChildren(button(t('Done', '完成'), () => box.remove(), 'fv-quiet')); } if (value.state === 'running') { follow(); return; } heading.textContent = t('Add workflows to earlier videos', '为之前的视频补上工作流'); text.textContent = t(`${value.count} earlier videos can get the workflow they were made with, so dropping them on the canvas restores it. Nothing changes until you confirm, and each original is kept as a backup (about ${gb(value.bytes)} GB).`, - `有 ${value.count} 个之前生成的视频可以补上生成时的工作流,补写后拖到画布即可还原。确认前不会改动任何文件,每个原视频都会保留一份备份(约 ${gb(value.bytes)} GB)。`); + `有 ${value.count} 个之前生成的视频可以补上生成时的工作流,补写后拖到画布可还原。确认前不会改动任何文件,每个原视频都会保留一份备份(约 ${gb(value.bytes)} GB)。`); const run = button(t('Add workflows', '补写工作流'), async () => { run.disabled = later.disabled = true; try { if (!(await post('run')).ok) throw new Error(); follow(); } - catch { run.disabled = later.disabled = false; text.textContent = t('Could not start. Try again.', '没能开始,请重试。'); } + catch { run.disabled = later.disabled = false; text.textContent = t('Could not start. Try again.', '未能开始,请重试。'); } }, 'fv-primary'); const later = button(t('Don\'t ask again', '不再提示'), async () => { box.remove(); try { await post('dismiss'); } catch {} }, 'fv-quiet'); actions.append(run, later); @@ -301,7 +301,7 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu notice.textContent = ''; try { await upscale(row); - if (!disposed && selected === row.video) notice.textContent = t('Queued. It appears in "Creations" when done.', '已加入队列,完成后在“作品”里。'); + if (!disposed && selected === row.video) notice.textContent = t('Queued. It appears in "Creations" when done.', '已加入队列,完成后可在“作品”中查看。'); } catch (error) { if (!disposed && selected === row.video) fault(notice, t('Could not start the second pass: ', '无法继续二采:') + error.message, ['Second pass', '二采']); throw error; @@ -323,9 +323,9 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu throw Object.assign(new Error(error.message === 'foreign-files' ? t('Not deleted: the folder also holds files FreeVideo did not write: ', '未删除:文件夹里还有不是 FreeVideo 生成的文件:') + (value.kept || []).join(', ') : error.message === 'editor-in-use' ? t('Not deleted: these edits use it: ', '未删除:这些剪辑正在使用它:') + (value.kept || []).join(', ') - : error.message === 'editor-unavailable' ? t('Not deleted: saved edits could not be checked. Try again in a moment.', '未删除:暂时无法检查已保存的剪辑,请稍后再试。') - : error.message === 'continuation-in-use' ? t('Not deleted: a video continuing from it is running or queued. Try again after it finishes.', '未删除:接在这条视频后面的生成正在运行或排队,请等它完成后再试。') - : error.message === 'in-use' ? t('Not deleted: its second pass is running, or the video is still open. Try again in a moment.', '未删除:正在用它跑二采,或视频仍被占用,请稍后再试。') + : error.message === 'editor-unavailable' ? t('Not deleted: saved edits could not be checked. Try again in a moment.', '未删除:暂时无法检查已保存的剪辑,请稍后重试。') + : error.message === 'continuation-in-use' ? t('Not deleted: a video continuing from it is running or queued. Try again after it finishes.', '未删除:接在这条视频后面的生成正在运行或排队,请在其完成后重试。') + : error.message === 'in-use' ? t('Not deleted: its second pass is running, or the video is still open. Try again in a moment.', '未删除:正在用它进行二采,或视频仍被占用,请稍后重试。') : t('Could not delete this creation.', '无法删除这条作品。')), { reportable: !['foreign-files', 'editor-in-use', 'continuation-in-use', 'in-use'].includes(error.message), }); @@ -501,14 +501,14 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu if (settled.length) window.dispatchEvent(new CustomEvent('freevideo-library-restored')); if (disposed) return; if (again.length) { - undoFailure = t('Could not undo. Try again.', '没能撤销,请重试。'); + undoFailure = t('Could not undo. Try again.', '未能撤销,请重试。'); showUndo(); if (settled.length) load(false); return; } undoFailure = ''; undoBar.replaceChildren(el('span', expired ? t('The undo time has passed.', '已过了撤销时间。') - : lost ? t(`Restored, but ${lost} could not be brought back.`, `已恢复,其中 ${lost} 个没能恢复。`) : t('Restored.', '已恢复。'))); + : lost ? t(`Restored, but ${lost} could not be restored.`, `已恢复,其中 ${lost} 个未能恢复。`) : t('Restored.', '已恢复。'))); if (lost && !expired) { const message = undoBar.firstElementChild.textContent; undoBar.append(issueButton(t, () => ({where: ['Restore creations', '恢复作品'], message}))); @@ -522,9 +522,9 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu const skippedText = skipped => { const using = skipped.filter(row => row.reason !== 'foreign-files').length, other = skipped.length - using; const folders = (count, en) => count === 1 ? en.replace('folders have', 'folder has') : en; - if (using && other) return t(`${using + other} kept: ${using} in use, ${other} ${folders(other, 'folders have')} other files.`, `${using + other} 个已保留:${using} 个正在使用,${other} 个文件夹里有别的文件。`); + if (using && other) return t(`${using + other} kept: ${using} in use, ${other} ${folders(other, 'folders have')} other files.`, `${using + other} 个已保留:${using} 个正在使用,${other} 个文件夹中有其他文件。`); if (using) return t(`${using} in use, kept.`, `${using} 个正在使用,已保留。`); - return t(`${other} ${folders(other, 'folders have')} other files, kept.`, `${other} 个文件夹里有别的文件,已保留。`); + return t(`${other} ${folders(other, 'folders have')} other files, kept.`, `${other} 个文件夹中有其他文件,已保留。`); }; async function removeSelected() { deleting = true; syncSelection(); @@ -600,8 +600,8 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu for (const video of removed) picked.delete(video); confirming = false; undoFailure = removed.size - ? t(`${picked.size} could not be deleted. Try again.`, `还有 ${picked.size} 个没能删除,请重试。`) - : t('Could not delete. Try again.', '没能删除,请重试。'); + ? t(`${picked.size} could not be deleted. Try again.`, `仍有 ${picked.size} 个未能删除,请重试。`) + : t('Could not delete. Try again.', '未能删除,请重试。'); if (!picked.size) setSelecting(false); else regroup(); } else { undoFailure = ''; setSelecting(false); } if (!selected && !narrow.matches && rows.size) select(rows.values().next().value, false); diff --git a/web/media_upload.js b/web/media_upload.js index b849ff2a..c01b0ae3 100644 --- a/web/media_upload.js +++ b/web/media_upload.js @@ -11,9 +11,9 @@ const failures = () => ({ empty: text('This file is empty. Choose another file.', '这个文件是空的,请换一个文件。'), unsupported: text('Choose an image, video or audio file', '请选择图片、视频或音频文件'), multiple: text('Upload one file at a time.', '一次只能上传一个文件。'), - invalid: text('This file name cannot be used. Rename the file and try again.', '这个文件名不能使用,请改名后再试。'), + invalid: text('This file name cannot be used. Rename the file and try again.', '此文件名不可用,请重命名后重试。'), }); -const generic = () => text('The upload did not finish. Try again.', '上传没有完成,请再试一次。'); +const generic = () => text('The upload did not finish. Try again.', '上传未完成,请重试。'); export async function uploadMedia(api, file) { const found = failures(); if (file.size > MAX_UPLOAD_BYTES) throw new Error(found.too_large); diff --git a/web/phone.js b/web/phone.js index 864a2121..182527a8 100644 --- a/web/phone.js +++ b/web/phone.js @@ -14,7 +14,7 @@ let chat = emptyChat(), jobs = {}, selected, revision = 0, cursor = null, histor let lastSend = null, toastTimer = null, flash = null, toastJob = null, permissionAsked = false; try { permissionAsked = localStorage.getItem('freevideo.phone.notification-asked') === '1'; } catch {} const drafts = new Map(), rows = new Map(), scrollPositions = {chat: 0, jobs: 0}; -const top = el('header', 'phone-top'), offline = el('div', 'phone-offline', t('Connection lost. Reconnecting…', '连接断了,正在重新连接…')); +const top = el('header', 'phone-top'), offline = el('div', 'phone-offline', t('Connection lost. Reconnecting…', '连接已断开,正在重新连接…')); offline.hidden = true; offline.setAttribute('role', 'status'); const main = el('main', 'phone-main'), log = el('ol', 'phone-log'); log.setAttribute('role', 'log'); log.setAttribute('aria-label', t('Chat', '对话')); let convRows = [], convCurrent = ''; @@ -26,7 +26,7 @@ function renderSheet() { const fresh = button('phone-sheet-new', t('New chat', '新对话'), async () => { fresh.disabled = true; try { const data = await json(await dataLayer.create()); if (!data.session) throw new Error(); closeSheet(); choose(data.session); } - catch { closeSheet(); notice(t('Could not start a new chat. Try again.', '没有新建成功,请重试。')); } + catch { closeSheet(); notice(t('Could not start a new chat. Try again.', '新建失败,请重试。')); } }); const list = el('ul', 'phone-sheet-list'); for (const row of convRows.some(row => row.id === convCurrent) || !convCurrent ? convRows : [{id: convCurrent}, ...convRows]) { @@ -49,7 +49,7 @@ sheet.addEventListener('keydown', event => { if (event.key === 'Escape') { event const generations = el('div', 'phone-generations'); main.append(log, generations); const compose = el('form', 'phone-compose'), input = el('textarea'), send = button('phone-send', '', () => compose.requestSubmit()); -input.rows = 1; input.maxLength = 8000; input.placeholder = t('What would you like to make?', '想做什么视频?'); input.setAttribute('aria-label', input.placeholder); +input.rows = 1; input.maxLength = 8000; input.placeholder = t('Describe the video to generate', '描述要生成的视频'); input.setAttribute('aria-label', input.placeholder); send.append(icon('M12 19V5M5 12l7-7 7 7')); send.setAttribute('aria-label', t('Send', '发送')); send.disabled = true; compose.append(input, send); const warning = el('p', 'phone-notice'); warning.hidden = true; warning.setAttribute('role', 'status'); const tabs = el('nav', 'phone-tabs'); tabs.setAttribute('aria-label', t('Pages', '页面')); @@ -107,9 +107,9 @@ pw.onsubmit = async event => { const wait = Number(response.headers.get('X-FreeVideo-Retry-After')); if (response.ok) { locked = false; password.value = ''; pwError.hidden = true; field.classList.remove('bad'); renderShell(); await poller.poll(); } else if (response.status === 429 && wait > 0) lockFor(wait); - else if (response.status === 401) { pwError.textContent = t('Wrong password. Please try again.', '密码不对,请再输一次。'); pwError.hidden = false; field.classList.add('bad'); password.focus(); password.select(); } - else { pwError.textContent = t('Connection lost. Reconnecting…', '连接断了,正在重新连接…'); pwError.hidden = false; } - } catch { pwError.textContent = t('Connection lost. Reconnecting…', '连接断了,正在重新连接…'); pwError.hidden = false; } + else if (response.status === 401) { pwError.textContent = t('Incorrect password. Please try again.', '密码错误,请重新输入。'); pwError.hidden = false; field.classList.add('bad'); password.focus(); password.select(); } + else { pwError.textContent = t('Connection lost. Reconnecting…', '连接已断开,正在重新连接…'); pwError.hidden = false; } + } catch { pwError.textContent = t('Connection lost. Reconnecting…', '连接已断开,正在重新连接…'); pwError.hidden = false; } finally { pw.dataset.busy = 'false'; tickPassword(); } }; function renderShell() { @@ -135,7 +135,7 @@ compose.onsubmit = async event => { if (!response?.ok) throw new Error(); if (version === revision && input.value.trim() === text) { input.value = ''; lastSend = null; } await poller.poll(); - } catch { notice(t('Could not send. Please try again.', '没有发送成功,请重试。')); } + } catch { notice(t('Could not send. Please try again.', '发送失败,请重试。')); } finally { sending = false; grow(); } }; function picture(job, preview = false) { @@ -202,7 +202,7 @@ function renderJobs() { const signature = JSON.stringify([Object.values(jobs).map(phoneJobKey), limit]); if (signature === jobsSignature) return; jobsSignature = signature; const active = document.activeElement?.dataset.action; const {running, queued, done} = jobGroups(jobs); generations.replaceChildren(); - if (!running.length && !queued.length && !done.length) generations.append(el('p', 'phone-empty', t('Nothing yet. Tell the Agent what to make in Chat.', '还没有作品,在对话里说想做什么。'))); + if (!running.length && !queued.length && !done.length) generations.append(el('p', 'phone-empty', t('No creations yet. Describe the video in Chat.', '暂无作品。请在对话中描述要生成的视频。'))); if (running.length) generations.append(el('h2', 'phone-section', t('Generating', '正在生成'))); for (const job of running) { const card = el('article', 'phone-running'), frame = el('div', 'phone-frame'), info = el('div', 'phone-info'); card.dataset.job = job.id; @@ -249,13 +249,13 @@ function openResult(job) { actions.append(el('p', 'phone-prompt', job.label), download, error); viewer.append(stage, actions); renderShell(); } function completed(job) { - toastJob = job; toast.replaceChildren(picture(job), el('span', '', t('Your video is ready', '生成好了')), button('phone-link', t('View', '查看'), () => { show('jobs'); openResult(job); })); + toastJob = job; toast.replaceChildren(picture(job), el('span', '', t('Your video is ready', '视频已生成')), button('phone-link', t('View', '查看'), () => { show('jobs'); openResult(job); })); main.classList.add('toast-space'); renderShell(); clearTimeout(toastTimer); toastTimer = setTimeout(() => { toastJob = null; toast.hidden = true; main.classList.remove('toast-space'); }, 6000); - clearInterval(flash); let lit = false; flash = setInterval(() => { document.title = (lit = !lit) ? t('Your video is ready', '生成好了') : 'FreeVideo'; }, 1000); + clearInterval(flash); let lit = false; flash = setInterval(() => { document.title = (lit = !lit) ? t('Your video is ready', '视频已生成') : 'FreeVideo'; }, 1000); setTimeout(() => { clearInterval(flash); document.title = 'FreeVideo'; }, 6000); if ('Notification' in window) { - if (document.hidden && Notification.permission === 'granted') { try { const n = new Notification(t('Your video is ready', '生成好了')); n.onclick = () => { window.focus(); show('jobs'); openResult(job); n.close(); }; } catch {} } + if (document.hidden && Notification.permission === 'granted') { try { const n = new Notification(t('Your video is ready', '视频已生成')); n.onclick = () => { window.focus(); show('jobs'); openResult(job); n.close(); }; } catch {} } if (!permissionAsked && Notification.permission === 'default') { permissionAsked = true; try { localStorage.setItem('freevideo.phone.notification-asked', '1'); } catch {} Notification.requestPermission().catch(() => {}); } } } diff --git a/web/phone_model.js b/web/phone_model.js index 450acee6..94a82f0e 100644 --- a/web/phone_model.js +++ b/web/phone_model.js @@ -25,8 +25,8 @@ export function mergePhoneJobs(previous, chat, activity, snapshots = []) { } export function retryWords(seconds, t) { const count = Math.ceil(Math.max(0, seconds)); - return count > 90 ? t(`Too many tries. Please try again in ${Math.ceil(count / 60)} min.`, `试错次数太多,请 ${Math.ceil(count / 60)} 分钟后再试。`) - : t(`Too many tries. Please try again in ${count} s.`, `试错次数太多,请 ${count} 秒后再试。`); + return count > 90 ? t(`Too many attempts. Please try again in ${Math.ceil(count / 60)} min.`, `尝试次数过多,请 ${Math.ceil(count / 60)} 分钟后重试。`) + : t(`Too many attempts. Please try again in ${count} s.`, `尝试次数过多,请 ${count} 秒后重试。`); } export function queueWords(index, t) { const n = index + 1, mod = n % 100; @@ -35,7 +35,7 @@ export function queueWords(index, t) { } export function remainingWords(seconds, t) { if (!(seconds > 0)) return ''; - return seconds >= 60 ? t(`About ${Math.ceil(seconds / 60)} min left`, `还要约 ${Math.ceil(seconds / 60)} 分钟`) : t('Under 1 min', '不到 1 分钟'); + return seconds >= 60 ? t(`About ${Math.ceil(seconds / 60)} min left`, `还需约 ${Math.ceil(seconds / 60)} 分钟`) : t('Under 1 min', '不到 1 分钟'); } export function mediaPath(value, kind = 'video') { if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')) return ''; diff --git a/web/prompt_enhance.js b/web/prompt_enhance.js index 3193315c..7a8cad3e 100644 --- a/web/prompt_enhance.js +++ b/web/prompt_enhance.js @@ -125,8 +125,8 @@ export function createPromptEnhancer({node, input, editor, api, context, setText busy: t('Finish the current queue before enhancing this prompt.', '请等待当前队列完成后再改写。'), prompt_required: t('Type a prompt first; enhancement rewrites it before generating.', '请先输入提示词,生成前会自动改写。'), setup_required: t('Complete FreeVideo installation first.', '请先完成 FreeVideo 安装。'), - model_missing: t('Download the optional model to enable enhancement.', '下载可选模型后即可启用。'), - download_failed: t('Download paused. Retry to resume.', '下载已暂停,重试即可继续。'), + model_missing: t('Download the optional model to enable enhancement.', '下载可选模型后可启用。'), + download_failed: t('Download paused. Retry to resume.', '下载已暂停,点击“重试”可继续。'), disk_space: t('Not enough disk space for the optional model.', '可选模型的磁盘空间不足,请清理后重试。'), ram_space: t(`This model needs about ${gib(error?.ramBytes || ramBytes)} GiB of available RAM to load. You can keep using the original.`, `此模型加载需约 ${gib(error?.ramBytes || ramBytes)} GiB 可用内存,可先使用原文生成。`), unsupported_media: t('Use images added in Media for enhancement; connected nodes, video and audio are not supported yet.', '增强暂支持在素材中添加的图片;连接节点、视频和音频暂不支持。'), @@ -275,7 +275,7 @@ export function createPromptEnhancer({node, input, editor, api, context, setText function useOriginal(error) { console.warn('FreeVideo prompt rewrite failed; using the original prompt.', error?.message); versions.value.selected = 'original'; apply(); - show(t('Could not enhance; generated with the original.', '没能改写,已用原文生成。'), 'note'); + show(t('Could not enhance; generated with the original.', '未能改写,已使用原文生成。'), 'note'); } return {element, async beforeGenerate() { if (running) { diff --git a/web/prompt_references.js b/web/prompt_references.js index 48cd4cd3..1096c786 100644 --- a/web/prompt_references.js +++ b/web/prompt_references.js @@ -203,7 +203,7 @@ export function attachReferencePicker(input, {items, t = en => en, view, host, a if (!matches.length) { const empty = document.createElement('div'); empty.className = 'fv-reference-empty'; empty.textContent = invalid ? t('Check your media selection.', '请检查素材用途是否冲突。') - : available.length || everyone.length ? t('No matching characters or media', '没有匹配的角色或素材') : t('Add reference media to use @', '添加参考素材后即可使用 @'); + : available.length || everyone.length ? t('No matching characters or media', '没有匹配的角色或素材') : t('Add reference media to use @', '添加参考素材后可使用 @'); list.append(empty); if (!available.length && !everyone.length && !invalid && addMedia) { const add = document.createElement('button'); add.type = 'button'; add.textContent = t('Add media', '添加素材'); diff --git a/web/report_issue.js b/web/report_issue.js index a57fb8de..9e9c9b40 100644 --- a/web/report_issue.js +++ b/web/report_issue.js @@ -177,13 +177,13 @@ export function showReportTip(bar, anchor, t, {mode = 'invite', submit, onHide = title.textContent = t('Last step', '最后一步'); lead.textContent = savedName?.endsWith('.zip') ? t(`The report is large, so it was saved as ${savedName}. Please drag it into "Diagnostic report", then select Submit new issue.`, - `报告较大,已保存为 ${savedName}。请将它拖入「诊断报告」,再点击 Submit new issue 即可。`) + `报告较大,已保存为 ${savedName}。请将它拖入“诊断报告”,再点击 Submit new issue。`) : savedName ? t(`Please drag ${savedName} into "Diagnostic report", then select Submit new issue.`, - `请将 ${savedName} 拖入「诊断报告」,再点击 Submit new issue 即可。`) + `请将 ${savedName} 拖入“诊断报告”,再点击 Submit new issue。`) : t('Please drag the downloaded report into "Diagnostic report", then select Submit new issue.', - '请将下载的诊断报告拖入「诊断报告」,再点击 Submit new issue 即可。'); - const link = el('a', null, t("Page didn't open? Go to GitHub", '页面没有打开?点此前往 GitHub')); + '请将下载的诊断报告拖入“诊断报告”,再点击 Submit new issue。'); + const link = el('a', null, t('If the page did not open, go to GitHub', '如页面未打开,请点此前往 GitHub')); link.href = address; link.target = '_blank'; link.rel = 'noopener'; note.replaceChildren(link); const done = el('button', 'fv-action fv-action-secondary', t('Done', '完成')); done.type = 'button'; @@ -196,9 +196,9 @@ export function showReportTip(bar, anchor, t, {mode = 'invite', submit, onHide = if (mode === 'downloaded') { title.textContent = t('Diagnostic report downloaded', '诊断报告已下载'); } else { - title.textContent = t('Share a diagnostic report?', '欢迎提交诊断报告'); + title.textContent = t('Submit a diagnostic report', '欢迎提交诊断报告'); } - lead.textContent = t("We'll optimize specifically for your setup, and it helps other users too.", + lead.textContent = t('We will optimize specifically for your hardware, and the report also helps other users.', '我们会针对您的硬件配置进行专门优化,也能帮助其他用户。'); note.append(glyph('lock', 'fv-report-tip-lock'), t('Hardware, settings and timings only. No prompts, images or videos.', '仅包含硬件信息、生成参数和耗时,不含提示词、参考图或视频。')); diff --git a/web/result_actions.js b/web/result_actions.js index 2a22572c..d5a5473f 100644 --- a/web/result_actions.js +++ b/web/result_actions.js @@ -74,7 +74,7 @@ export function secondPassAction(t, run) { try { await run(); label.textContent = button.title = t('Queued', '已加入队列'); } catch { button.disabled = false; - label.textContent = button.title = t('Second pass did not start · Retry', '二采没有开始 · 重试'); + label.textContent = button.title = t('Second pass did not start · Retry', '二采未开始 · 重试'); problem = issueButton(t, () => ({where: ['Second pass', '二采'], message: label.textContent})); button.after(problem); } finally { button.removeAttribute('aria-busy'); } diff --git a/web/setup.js b/web/setup.js index e775cfa4..5e62b059 100644 --- a/web/setup.js +++ b/web/setup.js @@ -14,7 +14,7 @@ const languageOverride = typeof location !== 'undefined' const cn = languageOverride === 'zh' || (languageOverride !== 'en' && String(navigator.language || '').toLowerCase().startsWith('zh')); const t = (en, zh) => cn ? zh : en; -const INSTALLED_HINT = {en: 'Already installed. To reinstall, click Detect & review first.', zh: '已装好。要重装,请先点“检测并预览”。'}; +const INSTALLED_HINT = {en: 'Already installed. To reinstall, click Detect & review first.', zh: '已安装。如需重新安装,请先点击“检测并预览”。'}; const gib = n => Number.isFinite(n) ? (n / 2 ** 30).toFixed(1) + " GiB" : "—"; const SPEED_TEST_FAILED = 'The speed test did not finish. Click "Test source speeds".'; const reportable = value => !/The installation plan changed\. Detect configuration again|MODEL_MISMATCH_REPAIR:/.test(String(value || '')); @@ -28,11 +28,11 @@ const failureText = value => { : t('The settings, GPU or free disk space changed after you confirmed, so installation stopped. Click Detect & review, then Install / repair.', '确认之后设置、显卡或磁盘空间有变化,已停止。请先点“检测并预览”,再点“安装/修复”。'); } - if (text === SPEED_TEST_FAILED) return t('The speed test did not finish. Click "Test source speeds".', '测速没有完成,请点“重新测速”。'); + if (text === SPEED_TEST_FAILED) return t('The speed test did not finish. Click "Test source speeds".', '测速未完成,请点击“重新测速”。'); if (!matches.length) return text; const file = matches.at(-1)[1]; return t(`${file} differs from the tested version and was left unchanged. Click Detect & review, then Install / repair. After you confirm, the original is renamed and kept, and the tested version is downloaded.`, - `${file} 和测试过的版本不同,这次没有改动。请先点“检测并预览”,再点“安装/修复”,确认后原文件改名保留,再下载测试过的版本。`); + `${file} 与测试过的版本不同,本次未作改动。请先点“检测并预览”,再点“安装/修复”,确认后原文件改名保留,再下载测试过的版本。`); }; let current; @@ -256,7 +256,7 @@ export async function openSetup() { if (family.startsWith('torch-')) names[family] = t('GPU packages','GPU 依赖'); const rate = row.bytes_per_second / 2**20; const speed = !row.ok ? t('Unavailable','未连通') : row.speed_measured === false || !Number.isFinite(rate) || rate <= 0 ? - t('Connected; speed not measured yet','已连通,速度还没测出') : rate < .005 ? '<0.01 MiB/s' : `${rate.toFixed(rate < 1 ? 2 : 1)} MiB/s`; + t('Connected; speed not measured yet','已连通,尚未测速') : rate < .005 ? '<0.01 MiB/s' : `${rate.toFixed(rate < 1 ? 2 : 1)} MiB/s`; for (const text of [names[family] || family, `${row.id} · ${row.route === 'direct' ? t('Direct','直连') : t('Current connection','当前连接')}`,speed]) tr.append(node('td',text)); if (!row.ok) tr.lastChild.className = 'fv-off'; table.append(tr); @@ -284,10 +284,10 @@ export async function openSetup() { for (const e of [root, extra, copy, samplingCaches, saveFolders, inspect, use, hfToken, applyToken, clearToken]) e.disabled = busy; cancel.hidden = !busy; install.disabled = busy || !planId || !accept.checked; accept.disabled = busy; inspect.classList.toggle("fv-primary", !planId); install.classList.toggle("fv-primary", Boolean(planId)); const why = busy ? "" : planStale ? t("Settings changed. Click Detect & review again.", "设置已更改,请重新点“检测并预览”。") - : planErrors ? t("Fix the problems marked in red above, then click Detect & review again.", "上面有标红的问题,处理后请重新点“检测并预览”。") + : planErrors ? t("Fix the problems marked in red above, then click Detect & review again.", "请先处理上方标红的问题,再点击“检测并预览”。") : ready && !planId ? t(INSTALLED_HINT.en, INSTALLED_HINT.zh) : !planId ? "" - : !accept.checked ? t("Tick the agreement above to enable Install / repair.", "勾选上方的同意选项后,即可点击“安装/修复”。") : ""; + : !accept.checked ? t("Tick the agreement above to enable Install / repair.", "勾选上方的同意选项后,可点击“安装/修复”。") : ""; blocked.textContent = why; blocked.hidden = !why; }; // A changed setting makes a shown plan outdated: clear it rather than leave it beside a disabled button. diff --git a/web/share.js b/web/share.js index 7b66491a..6925c0b5 100644 --- a/web/share.js +++ b/web/share.js @@ -158,14 +158,14 @@ export async function openShare(record,t){ if(!exported){ const response=await api.fetchApi('/freevideo/share/video',{method:'POST',headers:{'Content-Type':'application/json'},signal:exportRequest.signal, body:JSON.stringify({id,template,rect:shape.rect})}); - if(response.status===409)throw Object.assign(new Error(t('Another export is running. Try again shortly.','已有分享视频正在导出,请稍后再试。')),{reportable:false}); + if(response.status===409)throw Object.assign(new Error(t('Another export is running. Try again shortly.','已有分享视频正在导出,请稍后重试。')),{reportable:false}); if(!response.ok)throw new Error(t('Export could not complete. Try again.','分享视频导出未完成,请重试。')); exported=await response.json(); } if(disposed)return; const a=el('a');a.href=api.apiURL('/freevideo/share/video?'+new URLSearchParams(exported));a.download='';a.click();say(''); }catch(error){if(!disposed){ - say(error.name==='AbortError'?'':kind==='image'?t('Could not save the share image. Try again.','分享图没有保存成功,请重试。'):error.message,error.reportable!==false); + say(error.name==='AbortError'?'':kind==='image'?t('Could not save the share image. Try again.','分享图保存失败,请重试。'):error.message,error.reportable!==false); }} finally{busy=false;if(!disposed){save.disabled=false;cancel.hidden=true;delete dialog.dataset.busy;tabs.querySelectorAll('button').forEach(b=>b.disabled=false);}} } diff --git a/web/studio.js b/web/studio.js index ee0248b0..5b19a022 100644 --- a/web/studio.js +++ b/web/studio.js @@ -120,8 +120,8 @@ function createChainPartList(t, save, {onShow = () => {}, onHide = () => {}, red row.starting = true; row.redo.disabled = true; tag(row, t('Starting…', '正在开始…')); try { await (row.part.action === 'resume' ? resume() : redo(row.part.id)); } catch (error) { - if (rows.get(row.part.id) === row) flash(row, error.status === 409 ? t('Another video is generating. Try again when it finishes.', '正在生成别的视频,完成后再试。') - : t('Could not start. Try again.', '没能开始,请重试。'), 'error'); + if (rows.get(row.part.id) === row) flash(row, error.status === 409 ? t('Another video is generating. Try again when it finishes.', '正在生成其他视频,请在完成后重试。') + : t('Could not start. Try again.', '未能开始,请重试。'), 'error'); } finally { row.starting = false; row.redo.disabled = false; } } function flash(row, text, kind) { @@ -143,8 +143,8 @@ function createChainPartList(t, save, {onShow = () => {}, onHide = () => {}, red row.saving = null; if (rows.get(row.part.id) !== row) return; row.part = {...row.part, text: before}; draw(row); - flash(row, error.status === 409 ? t('This part is already generating. Your edit was not saved.', '这一段已在生成,改动没有保存') - : t('Your edit was not saved.', '改动没有保存'), 'error'); + flash(row, error.status === 409 ? t('This part is already generating. Your edit was not saved.', '此段已在生成,改动未保存') + : t('Your edit was not saved.', '改动未保存'), 'error'); } } function open(row) { @@ -212,7 +212,7 @@ function createChainPartList(t, save, {onShow = () => {}, onHide = () => {}, red // The part began while it was open: the words typed so far cannot reach it. A video that ended just closes it. const changed = row.draft !== row.part.text; editing = null; row.part = part; draw(row); - if (changed && active) flash(row, t('This part is already generating. Your edit was not saved.', '这一段已在生成,改动没有保存'), 'error'); + if (changed && active) flash(row, t('This part is already generating. Your edit was not saved.', '此段已在生成,改动未保存'), 'error'); return; } if (editing === row) { row.part = {...part, text: row.part.text}; return; } @@ -251,7 +251,7 @@ function triggerEntry(name) { } // The server's own wording is technical; the panel always says the same plain sentence. const triggerReadError = () => new Error(t('Could not read trigger words.', '无法读取触发词。')); -const triggerSaveError = () => new Error(t('Could not save. Try again.', '没有保存,请再试一次。')); +const triggerSaveError = () => new Error(t('Could not save. Try again.', '保存失败,请重试。')); async function triggerResponse(response, fail) { let result = null; try { result = await response.json(); } catch { /* not JSON: same sentence */ } @@ -624,7 +624,7 @@ export async function openExtendEditor(record, node = current?.node || app.graph node = app.graph?._nodes?.find(row => row.type === 'FreeVideoGenerate'); if (!node) { node = globalThis.LiteGraph?.createNode('FreeVideoGenerate'); - if (!node || !app.graph) throw new Error(t('Could not open the creative panel. Try again.', '没能打开创作面板,请重试。')); + if (!node || !app.graph) throw new Error(t('Could not open the creative panel. Try again.', '未能打开创作面板,请重试。')); app.graph.add(node); app.graph.change?.(); } } @@ -813,13 +813,13 @@ export function openStudio(node, {extendFrom = null} = {}) { // Off, on, or on and paused after the first pass: a preview is one way to run two-pass. const passMode = el('div', null, 'fv-pass-mode'); passMode.setAttribute('role', 'radiogroup'); passMode.setAttribute('aria-label', t('Two-pass sampling', '二次采样')); - const passOptions = [['off', t('Off', '关闭')], ['on', t('On', '开启')], ['preview', t('Preview first', '先出预览')]].map(([mode, label]) => { + const passOptions = [['off', t('Off', '关闭')], ['on', t('On', '开启')], ['preview', t('Preview first', '先生成预览')]].map(([mode, label]) => { const option = el('button', label); option.type = 'button'; option.dataset.mode = mode; option.setAttribute('role', 'radio'); option.onclick = () => choosePassMode(mode); return option; }); passOptions[2].title = t('Stops at a half-resolution preview. Choose "Run second pass" in the result when it looks right; the video matches a direct run.', - '先出半分辨率预览,满意后在结果里点“继续二采”,成片与直接生成一致。'); + '先生成半分辨率预览,满意后在结果中点击“继续二采”,成片与直接生成一致。'); const previewPassHint = passOptions[2].title; passMode.append(...passOptions); passMode.addEventListener('keydown', event => { @@ -902,7 +902,7 @@ export function openStudio(node, {extendFrom = null} = {}) { let releaseExtendThumbnail = () => {}; cleanup.push(() => releaseExtendThumbnail()); if (mediaNode?.freevideoCreateMediaEditor) { cleanup.push(mediaNode.freevideoCreateMediaEditor(mediaMount)); mediaDetails.open = mediaFilled(); } - else mediaMount.append(el('p', t('Connect a FreeVideo Media panel in node view to upload here.', '在节点视图连接 FreeVideo 素材面板,即可在这里上传。'), 'fv-legacy')); + else mediaMount.append(el('p', t('Connect a FreeVideo Media panel in node view to upload here.', '在节点视图连接 FreeVideo 素材面板后,可在这里上传。'), 'fv-legacy')); paintMediaHint(); mediaDetails.addEventListener('toggle', paintMediaHint); controls.addEventListener('change', paintMediaHint, true); controls.addEventListener('input', paintMediaHint, true); const connectedLora = upstream(node, 'loras'); @@ -1027,7 +1027,7 @@ export function openStudio(node, {extendFrom = null} = {}) { // A continuation keeps a two-pass level (Light or Medium). function continuationReason() { const level = effortFor(8, true, 3, null, effort.levels(), upscaleNow()); - return level?.name === 'Medium' ? t('A continuation uses the Medium level.', '接着生成使用标准档。') : t('A continuation uses the Light level.', '接着生成使用轻量档。'); + return level?.name === 'Medium' ? t('A continuation uses the Medium level.', '继续生成使用标准档。') : t('A continuation uses the Light level.', '继续生成使用轻量档。'); } function showEffortEstimate(steps = Number(baseSteps.value), twoPassPlan = twoPass.checked, cache = stepCacheNow(), factor = upscaleNow()) { const parts = lengthControl.plan?.count > 1 ? t(`${lengthControl.plan.count} parts`, `分 ${lengthControl.plan.count} 段`) : ''; @@ -1132,7 +1132,7 @@ export function openStudio(node, {extendFrom = null} = {}) { redo: block => { failure.clear(); return chainController.redo(block); }, resume: () => { failure.clear(); return chainController.resume(); }, playSeam: seconds => playAround(seconds)}); - // Set once the queue buttons exist: "Continue generating" stays hidden while a listed part offers the same. + // Set once the queue buttons exist: "Resume" stays hidden while a listed part offers the same. let syncResume = () => {}; // A stopped long video keeps its parts listed while the preview shows it. const syncPartList = () => { @@ -1270,7 +1270,7 @@ export function openStudio(node, {extendFrom = null} = {}) { try { await node.freevideoUpscaleResult(saved); await syncQueue(); } catch (error) { resultActionFailed(error); throw error; } }; - const memoryNote = el('div', t('This run used the low-memory mode, so it was slower. A lower resolution or a shorter video is faster.', '这次用了省显存方式,所以较慢。降低分辨率或缩短时长会更快。'), 'fv-note'); memoryNote.hidden = true; + const memoryNote = el('div', t('This run used the low-memory mode, so it was slower. A lower resolution or a shorter video is faster.', '本次使用了省显存模式,因此较慢。降低分辨率或缩短时长可加快生成。'), 'fv-note'); memoryNote.hidden = true; const thermalNote = el('div', '', 'fv-note'); thermalNote.hidden = true; const sharedGpuNote = el('div', '', 'fv-note'); sharedGpuNote.hidden = true; const encoderNote = el('div', '', 'fv-note'); encoderNote.hidden = true; @@ -1463,7 +1463,7 @@ export function openStudio(node, {extendFrom = null} = {}) { const queueToggle = el('summary'), queueList = el('div', null, 'fv-queue-list'); queueDetails.append(queueToggle, queueList); const cancelCurrent = button(t('Cancel current', '取消当前'), cancelGeneration, 'fv-quiet'); - const resumeChain = button(t('Continue generating', '继续生成'), async () => { + const resumeChain = button(t('Resume', '恢复生成'), async () => { resumeChain.disabled = true; failure.clear(); try { await chainController.resume(); } catch (error) { showQueueError(error); } finally { resumeChain.disabled = false; } @@ -1482,15 +1482,15 @@ export function openStudio(node, {extendFrom = null} = {}) { runbar.append(requestAssets.element, runrow, queueActions); controls.append(runbar); const queueError = error => ({ queue_unavailable: t('Could not read the queue. The loop has stopped; videos already added stay queued.', '暂时读不到队列,循环已停止;已加入的视频还在队列中。'), - cancel_failed: t('Could not cancel. Try again.', '没有取消成功,请重试。'), - move_failed: t('Could not move it. Try again.', '没有移动成功,请重试。'), - submit_failed: t('Could not add to the queue. Check the settings in node view.', '没能加入队列,请在节点视图检查设置。'), + cancel_failed: t('Could not cancel. Try again.', '取消失败,请重试。'), + move_failed: t('Could not move it. Try again.', '移动失败,请重试。'), + submit_failed: t('Could not add to the queue. Check the settings in node view.', '未能加入队列,请在节点视图检查设置。'), loop_active: t('Stop the current loop before starting another.', '请先停止当前循环。'), - loop_stopped: t('The last video did not finish. The loop has stopped.', '上一个视频没有生成完,循环已停止。'), - linked_seed: t('Batch and loop use the seed in this panel. Disconnect the seed link in node view.', '批量和循环要用面板里的种子,请在节点视图断开种子的连线。'), + loop_stopped: t('The last video did not finish. The loop has stopped.', '上一个视频未生成完成,循环已停止。'), + linked_seed: t('Batch and loop use the seed in this panel. Disconnect the seed link in node view.', '批量和循环需使用面板中的种子,请在节点视图断开种子的连线。'), missing_node: t('This node is disabled or no longer in the workflow.', '该节点已禁用或已从工作流移除。'), - chain_cancel_failed: t('Could not cancel. Refresh the page and try again.', '没有取消成功,请刷新页面后再试。'), - chain_resume_failed: t('Could not continue. Refresh the page and try again.', '没有继续生成,请刷新页面后再试。'), + chain_cancel_failed: t('Could not cancel. Refresh the page and try again.', '取消失败,请刷新页面后重试。'), + chain_resume_failed: t('Could not resume. Refresh the page and try again.', '未能恢复生成,请刷新页面后重试。'), }[error?.message] || (error?.said ? t(error.said[0], error.said[1]) : null) || (isNetworkTypeError(error) ? connectionLostText(t) : error?.message) || String(error)); let lastQueueError = null, connectionNotice = false; function showQueueError(error) { @@ -1501,7 +1501,7 @@ export function openStudio(node, {extendFrom = null} = {}) { if (['loop_stopped', 'queue_unavailable'].includes(error?.message) && node.freevideoFailureReport && node.freevideoFailureReport.stage !== 'queue') return; status.textContent = error?.schema === 'freevideo.error-report' - ? t('Could not start. See the details below.', '没能开始生成,原因见下方。') + ? t('Could not start. See the details below.', '未能开始生成,原因见下方。') : extensionCause(error) ? t('A browser extension is interfering with FreeVideo. See details below.', '浏览器扩展干扰了 FreeVideo,详见下方。') : queueError(error); // Recoverable chain actions need one visible sentence, not a second error card. @@ -1699,7 +1699,7 @@ export function openStudio(node, {extendFrom = null} = {}) { held = turnNodeOn(node); const snapshot = singleRunSnapshot(extendSnapshot(cleanOptionalPrompt(clonePrompt(await app.graphToPrompt())), node.id, source, {multiple: lengthPlan?.count > 1}), node.id, lengthPlan); - if (!snapshot) { fault(t('Could not prepare this run. Try again.', '没能准备这次生成,请重试。')); return; } + if (!snapshot) { fault(t('Could not prepare this run. Try again.', '未能准备本次生成,请重试。')); return; } const seedIndex = node.widgets.findIndex(w => w.name === 'seed'); capturing = false; for (const section of controls.querySelectorAll('.fv-section')) section.inert = false; diff --git a/web/studio_extend.js b/web/studio_extend.js index fa8971b1..6858d308 100644 --- a/web/studio_extend.js +++ b/web/studio_extend.js @@ -1,7 +1,7 @@ import {continuationUpscale} from './sampling_effort.js'; export const EXTEND_PROPERTY = 'freevideo_extend'; export const EXTEND_TEXT = Object.freeze({ - action: ['Extend', '接着生成'], + action: ['Extend', '继续生成'], source: ['Continues this video', '接在这条视频后面'], remove: ['Remove', '移除'], prompt: ['Prompt', '描述'], @@ -40,7 +40,7 @@ export async function extendSourceExists(api, record) { } export const videoUnavailableText = t => t('This video cannot be opened. If you moved the file, refresh the page.', - '视频打不开,如果移动过文件,请刷新页面。'); + '无法打开视频。如已移动文件,请刷新页面。'); export async function readExtendRecord(api, record) { if (record.geometry?.width && record.geometry?.height && (record.geometry.seconds || record.geometry.frames)) return record; diff --git a/web/studio_recent.js b/web/studio_recent.js index a9de9abd..c9161cb1 100644 --- a/web/studio_recent.js +++ b/web/studio_recent.js @@ -76,7 +76,7 @@ export function createRecentVideos(t, {body, onSelect}) { if (!disposed && !controller.signal.aborted) { // The library's own messages are translated; a network or reply error is not, and this column has no Refresh. message.textContent = error?.restart ? error.message - : t('Recent videos could not be loaded. FreeVideo tries again when the next video finishes.', '暂时无法读取最近作品,下一个视频生成完成后会自动再试。'); + : t('Recent videos could not be loaded. FreeVideo tries again when the next video finishes.', '暂时无法读取最近作品,下一个视频生成完成后会自动重试。'); message.hidden = false; list.append(message); } return []; diff --git a/web/tutorial_book_content.js b/web/tutorial_book_content.js index 635ce7fa..0960be6e 100644 --- a/web/tutorial_book_content.js +++ b/web/tutorial_book_content.js @@ -29,18 +29,18 @@ export const TUTORIAL_CHAPTERS = [ alt: {zh: '创作面板:时长输入框和“可生成范围”入口已用红框标出。', en: 'The creation panel, with the duration field and "Available sizes and lengths" control outlined in red.'}, }, steps: { - zh: ['填写更长的时长,会自动分段生成;“可生成范围”中可查看各尺寸的最长时长和预计用时。', '点“生成视频”。', '在生成好的视频上点“接着生成”,继续下一段。', '也可在“剪辑视图”中逐段接续。'], + zh: ['填写更长的时长,会自动分段生成;“可生成范围”中可查看各尺寸的最长时长和预计用时。', '点“生成视频”。', '在已生成的视频上点击“继续生成”,继续下一段。', '也可在“剪辑视图”中逐段接续。'], en: ['Enter a longer duration. It is made in parts automatically. "Available sizes and lengths" shows how long each size can be and about how long it takes.', 'Choose "Generate video".', 'Choose "Extend" on a finished video to continue it.', 'Parts can also be added one by one in "Edit".'], }, limits: { - zh: ['一条视频最多 5 段,创作面板中时长最长 60 秒。', '分段生成和接着生成需要 NVIDIA 显卡或 Mac。', '段数越多,衔接处越容易出现变化;适合切换镜头处,可直接开始新的一段。'], + zh: ['一条视频最多 5 段,创作面板中时长最长 60 秒。', '分段生成和继续生成需要 NVIDIA 显卡或 Mac。', '段数越多,衔接处越容易出现变化;适合切换镜头处,可直接开始新的一段。'], en: ['A video has at most 5 parts, and the creation panel accepts up to 60 seconds.', 'Generating in parts and Extend need an NVIDIA graphics card or a Mac.', 'The more parts are joined, the more the joins may change. Where a cut fits, cut to a new shot and start a fresh part.'], }, }, { id: 'editor-extend-refs', title: {zh: '在剪辑视图延长时加参考图', en: 'Add reference images when extending in Edit'}, - summary: {zh: '用参考图引导接着生成的那一段。', en: 'Use reference images to guide the next part.'}, + summary: {zh: '用参考图引导继续生成的那一段。', en: 'Use reference images to guide the next part.'}, media: { type: 'image', src: {zh: 'editor-extend-refs-1.webp', en: 'editor-extend-refs-2.webp'}, diff --git a/web/updates.js b/web/updates.js index c9e7081d..44206cf4 100644 --- a/web/updates.js +++ b/web/updates.js @@ -5,7 +5,7 @@ import { issueButton } from './issue_button.js'; // Every platform downloads from one release: the latest vX.Y.Z. const releasePage = 'https://github.com/FlashML-org/FreeVideo/releases/latest'; const storageKey = 'freevideo.dismissed-update'; -const CANCEL_FAILURE = {en: 'Cancellation failed. Please use the launcher.', zh: '取消没成功,请在启动器里操作'}; +const CANCEL_FAILURE = {en: 'Cancellation failed. Please use the launcher.', zh: '取消失败,请在启动器中操作'}; const listeners = new Set(); // Launcher phases during which the server may disappear and come back updated. const working = new Set(['checking', 'downloading', 'waiting', 'restarting', 'engine']); @@ -160,10 +160,10 @@ export function createUpdateNotice(cn) { text = t(CANCEL_FAILURE.en, CANCEL_FAILURE.zh); } else if (busy) { text = model === 'switching' || (model && offline) - ? t('Switching to the faster model. This page reconnects by itself.', '正在切换到更快的模型,页面会自动恢复。') - : model === 'waiting' ? t('The faster model is ready. FreeVideo switches after the current video.', '更快的模型已下载好,当前视频完成后自动切换。') + ? t('Switching to the faster model. This page reconnects automatically.', '正在切换到更快的模型,页面会自动恢复。') + : model === 'waiting' ? t('The faster model is ready. FreeVideo switches after the current video.', '更快的模型已下载完成,当前视频完成后自动切换。') : offline || phase === 'restarting' || phase === 'engine' - ? t('Restarting FreeVideo to finish the update. The page refreshes by itself.', '正在重启 FreeVideo 完成更新,页面会自动刷新。') + ? t('Restarting FreeVideo to finish the update. The page refreshes automatically.', '正在重启 FreeVideo 完成更新,页面会自动刷新。') : phase === 'downloading' ? t('Downloading the update', '正在下载更新') + percent : phase === 'waiting' ? t('The update installs as soon as the current video finishes.', '当前视频生成完成后会自动更新。') : t('Checking for updates…', '正在检查更新…'); diff --git a/web/view_navigation.js b/web/view_navigation.js index 2be364d0..3bdcaf5e 100644 --- a/web/view_navigation.js +++ b/web/view_navigation.js @@ -128,7 +128,7 @@ export function installNavigation(openStudio, agent = false, {openWorkflow = nul } catch (error) { console.warn('FreeVideo edit project:', error); app.extensionManager?.toast?.add({severity: 'error', summary: 'FreeVideo', - detail: t('Could not complete. Try again.', '没有完成,请重试'), life: 9000}); + detail: t('Could not complete. Try again.', '未完成,请重试'), life: 9000}); return null; } })(); @@ -139,7 +139,7 @@ export function installNavigation(openStudio, agent = false, {openWorkflow = nul settings.textContent = t('Settings', '设置'); settings.className = 'fv-view-settings'; settings.onclick = openSetup; row.append(grip, brand, viewSwitch('nodes', () => choose(node => { open?.(node); }, true), () => {}, () => choose(async (node, created) => { const handle = await openEditorView(node); - if (created) handle?.notify(t('Created an editing project for you.', '已为你新建一个剪辑工程')); + if (created) handle?.notify(t('Editing project created.', '已新建剪辑工程')); return handle; }, true), () => choose(node => openAutopilot(node), true)), createLanAccess(t, api).element, createVersionInfo(cn).element, feedbackButton(t, 'fv-view-settings'), settings); toolbar.append(row); toolbar.append(createUpdateNotice(cn).element); diff --git a/web/whitebox_scenes.js b/web/whitebox_scenes.js index 0a770759..51b98aea 100644 --- a/web/whitebox_scenes.js +++ b/web/whitebox_scenes.js @@ -5,12 +5,12 @@ import { agentGet, agentPost } from './agent_session.js'; export const SCENE_TEXT = Object.freeze({ icon: 'M4 5h7v6H4zM13 5h7v6h-7zM4 13h7v6H4zM13 13h7v6h-7z', label: ['Ready-made', '现成场景'], name: ['Start from a ready-made white-model scene', '用现成的白模场景开始'], - preparing: ['Preparing the white model…', '正在准备白模…'], failed: ['The white model could not be made: ', '白模没有做成:'], + preparing: ['Preparing the white model…', '正在准备白模…'], failed: ['The white model could not be made: ', '白模生成失败:'], rendering: ['Rendering the white model…', '正在渲染白模…'], retry: ['. Try again.', '。请重试。'], - bare: ['The white model could not be made. Try again.', '白模没有做成。请重试。'], - noPage: ['The white model could not be made: the renderer is not running in this page. Reload the page and try again.', '白模没有做成:这个页面里的渲染没有运行。请刷新页面后重试。'], - notStarted: ['The white model could not be made: rendering did not start within 3 minutes. Try again.', '白模没有做成:三分钟内渲染没有开始。请重试。'], - timeout: ['The white model could not be made: rendering did not finish within 3 minutes. Try again.', '白模没有做成:渲染三分钟内没有完成。请重试。'], + bare: ['The white model could not be made. Try again.', '白模生成失败,请重试。'], + noPage: ['The white model could not be made: the renderer is not running in this page. Reload the page and try again.', '白模生成失败:此页面中的渲染未运行。请刷新页面后重试。'], + notStarted: ['The white model could not be made: rendering did not start within 3 minutes. Try again.', '白模生成失败:渲染未在 3 分钟内开始。请重试。'], + timeout: ['The white model could not be made: rendering did not finish within 3 minutes. Try again.', '白模生成失败:渲染未在 3 分钟内完成。请重试。'], }); export async function sceneList(api) { From 70f523dce0cf0c885c66b2c27455643493c453d8 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 01:56:09 +0000 Subject: [PATCH 14/58] Run installer Git from a short view with core.longpaths set on every call --- freevideo_engine/bootstrap.py | 5 ++- freevideo_engine/environments.py | 69 +++++++++++++++++++++++--------- freevideo_engine/install.py | 21 +++++----- freevideo_engine/network.py | 26 +++++++----- 4 files changed, 81 insertions(+), 40 deletions(-) diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index c8f6a731..28194a43 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -31,7 +31,7 @@ from .terminal_ui import TerminalUI, LogProgress from .locking import runtime_lock, LOCK_ENV from .environments import (ENVIRONMENTS, environment_names, select_layout, role_pythons, constraints_file, - bootstrap_versions, uv_file_arguments, uv_build_environment) + bootstrap_versions, uv_file_arguments, uv_build_environment, git_long_path_environment) from . import disk_space from . import network from . import processes @@ -1407,6 +1407,9 @@ def command(self, label, args, env=None, cwd=None): env = dict(env, FREEVIDEO_INSTALL_PROGRESS='1') args, cwd = uv_file_arguments(args, cwd) env = uv_build_environment(args, self.root, env) + if label == 'vdn-source': + # Its Git folders are made below a deep root; see git_long_path_environment. + env = git_long_path_environment(self.root, env) with self.state_lock: index = len(self.state['steps']) log = self.run_dir / ('%02d-%s.log' % (index, label)) diff --git a/freevideo_engine/environments.py b/freevideo_engine/environments.py index 276df745..5f727fde 100644 --- a/freevideo_engine/environments.py +++ b/freevideo_engine/environments.py @@ -36,37 +36,53 @@ def constraints_file(name, system=None): UV_BUILD_PATH_CHARS = 200 -def uv_build_environment(command, root, environ): - """Windows uv pip builds use a short view of the existing cache and scratch. +# Git creates and enters its own folders with the ordinary 259-character limit +# even when core.longpaths lets it write deep files. Reserve the clone folder, +# its ".partial-" name and the longest object path below it. +GIT_PATH_CHARS = 140 - TEMP alone does not move uv's sdist source tree or isolated build venv. - Mark only this child's view: processes.popen translates its paths, while - installation settings, cache ownership and the parent's environment stay put. - """ - if (not command or Path(command[0]).name.lower() not in ('uv', 'uv.exe') - or list(command[1:2]) != ['pip']): - return environ + +def _short_view(root, environ, reserve): + """The view that leaves `reserve` characters below `root`, or None when none can be had.""" from . import launch_view, runtime_alias - from .long_paths import io_path, path_chars, plain + from .long_paths import path_chars, plain from .package_paths import WINDOWS_PATH_LIMIT - from .triton_compat import TEMP_KEYS, TEMP_ROOT_ENV, _writable_temporary if not runtime_alias._backend.windows(): - return environ + return None root = Path(os.path.abspath(plain(root))) view = launch_view.current(environ) if (view is None or not root.is_relative_to(view.real) - or path_chars(launch_view.value(root, view)) + 1 + UV_BUILD_PATH_CHARS > WINDOWS_PATH_LIMIT): + or path_chars(launch_view.value(root, view)) + 1 + reserve > WINDOWS_PATH_LIMIT): try: - view = launch_view.View(root, runtime_alias.runtime_view(root, longest=UV_BUILD_PATH_CHARS)) + view = launch_view.View(root, runtime_alias.runtime_view(root, longest=reserve)) except OSError as error: - if (path_chars(root) + 1 + UV_BUILD_PATH_CHARS <= WINDOWS_PATH_LIMIT + if (path_chars(root) + 1 + reserve <= WINDOWS_PATH_LIMIT and runtime_alias._backend.ansi_path(root)): view = launch_view.View(root, root) else: - return environ # No short entry: keep the old behaviour; packages without source builds still install. - if path_chars(launch_view.value(root, view)) + 1 + UV_BUILD_PATH_CHARS > WINDOWS_PATH_LIMIT: + return None # No short entry: keep the old behaviour. + if path_chars(launch_view.value(root, view)) + 1 + reserve > WINDOWS_PATH_LIMIT: + return None + return view + + +def uv_build_environment(command, root, environ): + """Windows uv pip builds use a short view of the existing cache and scratch. + + TEMP alone does not move uv's sdist source tree or isolated build venv. + Mark only this child's view: processes.popen translates its paths, while + installation settings, cache ownership and the parent's environment stay put. + """ + if (not command or Path(command[0]).name.lower() not in ('uv', 'uv.exe') + or list(command[1:2]) != ['pip']): return environ - temporary = Path(launch_view.value(root / 'tmp' / 'uv', view)) + from . import launch_view, runtime_alias + from .long_paths import io_path + from .triton_compat import TEMP_KEYS, TEMP_ROOT_ENV, _writable_temporary + view = _short_view(root, environ, UV_BUILD_PATH_CHARS) + if view is None: + return environ + temporary = Path(launch_view.value(Path(os.path.abspath(root)) / 'tmp' / 'uv', view)) try: for path in (temporary.parent, temporary): folder = io_path(path) @@ -82,6 +98,23 @@ def uv_build_environment(command, root, environ): **{key: str(temporary) for key in (*TEMP_KEYS, TEMP_ROOT_ENV)}) +def git_long_path_environment(path, environ): + """Windows Git, and the Python that runs it, work from a short view of the install root. + + `path` is any folder below the install root (the folder holding setup.lock, + which the short entry is verified through). Only this child's environment is + marked; paths on screen, in receipts and in the checkout keep their real spelling. + """ + from . import launch_view + from .long_paths import path_chars, plain + path = Path(os.path.abspath(plain(path))) + root = next((parent for parent in (path, *path.parents) if os.path.isfile(parent / 'setup.lock')), None) + if root is None: + return environ + view = _short_view(root, environ, GIT_PATH_CHARS + path_chars(path) - path_chars(root)) + return environ if view is None else launch_view.environment_view(environ, view) + + def uv_file_arguments(command, cwd=None): """Return (command, cwd) for which uv reads constraint files in folders with spaces. diff --git a/freevideo_engine/install.py b/freevideo_engine/install.py index 4d4b00da..a32bc3a8 100644 --- a/freevideo_engine/install.py +++ b/freevideo_engine/install.py @@ -7,6 +7,7 @@ import sys import time from . import execution_paths +from .long_paths import io_path from .paths import vdn_root from . import network, subprocess_output @@ -16,8 +17,8 @@ def install(root=None, *, packages=False): spec = dependencies['vdn'] root = (root or vdn_root()).expanduser().resolve() network.clone(root, spec['url'], spec['commit']) - head = subprocess_output.output_external(['git', 'rev-parse', 'HEAD'], cwd=root).strip() - dirty = subprocess_output.output_external(['git', 'status', '--porcelain', '--untracked-files=no'], cwd=root) + head = subprocess_output.output_external(['git', '-c', 'core.longpaths=true', 'rev-parse', 'HEAD'], cwd=root).strip() + dirty = subprocess_output.output_external(['git', '-c', 'core.longpaths=true', 'status', '--porcelain', '--untracked-files=no'], cwd=root) if head != spec['commit'] or dirty: raise RuntimeError('Existing VDN checkout does not match the clean pinned dependency; use another --vdn-root.') diffusers = prepare_diffusers(root, dependencies) @@ -76,22 +77,22 @@ def prepare_diffusers(root, dependencies): """Build the official two-patch tree atomically; never reset a user checkout.""" spec = dependencies['diffusers'] target = root / dependencies['vdn']['diffusers_subdirectory'] - if not target.exists(): + if not io_path(target).exists(): checkout = root / ('engine-diffusers-' + str(time.time_ns())) network.clone(checkout, spec['url'], spec['base']) - patches = sorted((root / 'diffusers_patches').glob('*.patch')) + patches = sorted(io_path(root / 'diffusers_patches').glob('*.patch')) if len(patches) != 2: raise RuntimeError('Expected the two pinned VDN Diffusers patches') for path in patches: - execution_paths.run(['git', 'apply', '--index', str(path)], cwd=checkout, check=True) - tree = subprocess_output.output_external(['git', 'write-tree'], cwd=checkout).strip() + execution_paths.run(['git', '-c', 'core.longpaths=true', 'apply', '--index', str(path)], cwd=checkout, check=True) + tree = subprocess_output.output_external(['git', '-c', 'core.longpaths=true', 'write-tree'], cwd=checkout).strip() if tree != spec['patched_tree']: raise RuntimeError('Patched Diffusers source tree does not match the tested dependency') - execution_paths.run(['git', '-c', 'user.name=VDN Engine dependency setup', '-c', 'user.email=engine@localhost', + execution_paths.run(['git', '-c', 'core.longpaths=true', '-c', 'user.name=VDN Engine dependency setup', '-c', 'user.email=engine@localhost', 'commit', '--quiet', '-m', 'Apply pinned OpenVDN MiniMax H3 patches'], cwd=checkout, check=True) - checkout.rename(target) - tree = subprocess_output.output_external(['git', 'rev-parse', 'HEAD^{tree}'], cwd=target).strip() - dirty = subprocess_output.output_external(['git', 'status', '--porcelain', '--untracked-files=no'], cwd=target) + io_path(checkout).rename(io_path(target)) + tree = subprocess_output.output_external(['git', '-c', 'core.longpaths=true', 'rev-parse', 'HEAD^{tree}'], cwd=target).strip() + dirty = subprocess_output.output_external(['git', '-c', 'core.longpaths=true', 'status', '--porcelain', '--untracked-files=no'], cwd=target) if tree != spec['patched_tree'] or dirty: raise RuntimeError('Existing Diffusers differs from the clean tested tree; use another VDN dependency directory.') return target diff --git a/freevideo_engine/network.py b/freevideo_engine/network.py index 3fdd6a6a..0812ddf2 100644 --- a/freevideo_engine/network.py +++ b/freevideo_engine/network.py @@ -1209,25 +1209,29 @@ def clone(target, url, commit, *, sparse=None, run=None, network=None, env=None) env = proxy_environment(env) env.update(GIT_TERMINAL_PROMPT='0', GIT_HTTP_LOW_SPEED_LIMIT='1024', GIT_HTTP_LOW_SPEED_TIME='20') git = env.get('FREEVIDEO_GIT') or 'git' - version = git_version(git, env) if not target.exists() else None + version = git_version(git, env) if not io_path(target).exists() else None if version and version < MIN_GIT: raise RuntimeError(old_git_message(version)) def execute(label, command): - command = [git, *command[1:]] + # Git makes its own folders with the ordinary limit unless told otherwise on the command line; + # the repository's config only exists after `init` has already made the folder. + command = [git, '-c', 'core.longpaths=true', *command[1:]] if run: return run(label, command, env=env) result = subprocess_output.run_external(list(map(str, command)), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **hidden_console()) if result.returncode: raise RuntimeError(result.stdout[-2500:]) - if target.exists(): - if not (target / '.git').exists(): + if io_path(target).exists(): + if not io_path(target / '.git').exists(): raise ValueError('Existing dependency is not a Git checkout of the pinned source: ' + str(target)) - head = subprocess_output.output_external([git, '-C', str(target), 'rev-parse', 'HEAD'], env=env, **hidden_console()).strip() - dirty = subprocess_output.output_external([git, '-C', str(target), 'status', '--porcelain', '--untracked-files=no'], env=env, **hidden_console()) + head = subprocess_output.output_external([git, '-c', 'core.longpaths=true', '-C', str(target), 'rev-parse', 'HEAD'], env=env, **hidden_console()).strip() + dirty = subprocess_output.output_external([git, '-c', 'core.longpaths=true', '-C', str(target), 'status', '--porcelain', '--untracked-files=no'], env=env, **hidden_console()) if head != commit or dirty: raise ValueError('Existing dependency differs from the pinned clean source: ' + str(target)) return target - target.parent.mkdir(parents=True, exist_ok=True) + io_path(target.parent).mkdir(parents=True, exist_ok=True) + from .environments import git_long_path_environment + env = git_long_path_environment(target.parent, env) candidates = urls(dict(network, sources=dict(network.get('sources', {}), github=network.get('sources', {}).get('git', [{'id': 'official'}]))), 'github', url, env) configured_env = env for name, endpoint, route in [(name, endpoint, route) for name, endpoint in candidates @@ -1246,8 +1250,8 @@ def execute(label, command): # set --no-cone` writes; every Git reads it at checkout. execute(target.name + '-sparse', ['git', '-C', temporary, 'config', 'core.sparseCheckout', 'true']) execute(target.name + '-sparse-cone', ['git', '-C', temporary, 'config', 'core.sparseCheckoutCone', 'false']) - (temporary / '.git' / 'info').mkdir(exist_ok=True) - (temporary / '.git' / 'info' / 'sparse-checkout').write_text(''.join(p + '\n' for p in sparse), encoding='utf-8') + io_path(temporary / '.git' / 'info').mkdir(exist_ok=True) + io_path(temporary / '.git' / 'info' / 'sparse-checkout').write_text(''.join(p + '\n' for p in sparse), encoding='utf-8') # The URL reaches Git the way `git -c` hands settings to its own # children, in every Git version: never in process arguments, logs # or the repository's config, even if the fetch is killed. @@ -1259,10 +1263,10 @@ def execute(label, command): finally: env = original execute(target.name + '-checkout', ['git', '-C', temporary, 'checkout', '--detach', commit]) - actual = subprocess_output.output_external([git, '-C', str(temporary), 'rev-parse', 'HEAD'], env=env, **hidden_console()).strip() + actual = subprocess_output.output_external([git, '-c', 'core.longpaths=true', '-C', str(temporary), 'rev-parse', 'HEAD'], env=env, **hidden_console()).strip() if actual != commit: raise ValueError('Downloaded source did not match its pinned commit') - temporary.rename(target) + io_path(temporary).rename(io_path(target)) source_health(network, 'git', name, True) route_health(network, 'git', name, route) event(network, category='git', source=name, route=route, file=target.name, action='verified', commit=commit) From 40f93a1b2d7c39011b8c173af1ebf805397a9e77 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 02:47:39 +0000 Subject: [PATCH 15/58] Run Git in a short real folder for deep Windows installs; log why a step was stopped --- freevideo_engine/bootstrap.py | 11 +-- freevideo_engine/environments.py | 69 +++++-------------- freevideo_engine/install.py | 49 +++++++------ freevideo_engine/network.py | 114 ++++++++++++++++++++++++++----- freevideo_engine/tuning.py | 4 +- 5 files changed, 154 insertions(+), 93 deletions(-) diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index 28194a43..89bd30ca 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -31,7 +31,7 @@ from .terminal_ui import TerminalUI, LogProgress from .locking import runtime_lock, LOCK_ENV from .environments import (ENVIRONMENTS, environment_names, select_layout, role_pythons, constraints_file, - bootstrap_versions, uv_file_arguments, uv_build_environment, git_long_path_environment) + bootstrap_versions, uv_file_arguments, uv_build_environment) from . import disk_space from . import network from . import processes @@ -1407,9 +1407,6 @@ def command(self, label, args, env=None, cwd=None): env = dict(env, FREEVIDEO_INSTALL_PROGRESS='1') args, cwd = uv_file_arguments(args, cwd) env = uv_build_environment(args, self.root, env) - if label == 'vdn-source': - # Its Git folders are made below a deep root; see git_long_path_environment. - env = git_long_path_environment(self.root, env) with self.state_lock: index = len(self.state['steps']) log = self.run_dir / ('%02d-%s.log' % (index, label)) @@ -1478,6 +1475,12 @@ def update_progress(*, final=False): row['error'] = repr(error) if child is not None and child.poll() is None: processes.stop(child) + # A step stopped from outside leaves nothing else in its log to explain why. + try: + with log.open('a', encoding='utf-8') as stream: + stream.write('\nStopped: %s\n' % error) + except OSError: + pass raise finally: update_progress(final=True) diff --git a/freevideo_engine/environments.py b/freevideo_engine/environments.py index 5f727fde..276df745 100644 --- a/freevideo_engine/environments.py +++ b/freevideo_engine/environments.py @@ -36,36 +36,6 @@ def constraints_file(name, system=None): UV_BUILD_PATH_CHARS = 200 -# Git creates and enters its own folders with the ordinary 259-character limit -# even when core.longpaths lets it write deep files. Reserve the clone folder, -# its ".partial-" name and the longest object path below it. -GIT_PATH_CHARS = 140 - - -def _short_view(root, environ, reserve): - """The view that leaves `reserve` characters below `root`, or None when none can be had.""" - from . import launch_view, runtime_alias - from .long_paths import path_chars, plain - from .package_paths import WINDOWS_PATH_LIMIT - if not runtime_alias._backend.windows(): - return None - root = Path(os.path.abspath(plain(root))) - view = launch_view.current(environ) - if (view is None or not root.is_relative_to(view.real) - or path_chars(launch_view.value(root, view)) + 1 + reserve > WINDOWS_PATH_LIMIT): - try: - view = launch_view.View(root, runtime_alias.runtime_view(root, longest=reserve)) - except OSError as error: - if (path_chars(root) + 1 + reserve <= WINDOWS_PATH_LIMIT - and runtime_alias._backend.ansi_path(root)): - view = launch_view.View(root, root) - else: - return None # No short entry: keep the old behaviour. - if path_chars(launch_view.value(root, view)) + 1 + reserve > WINDOWS_PATH_LIMIT: - return None - return view - - def uv_build_environment(command, root, environ): """Windows uv pip builds use a short view of the existing cache and scratch. @@ -77,12 +47,26 @@ def uv_build_environment(command, root, environ): or list(command[1:2]) != ['pip']): return environ from . import launch_view, runtime_alias - from .long_paths import io_path + from .long_paths import io_path, path_chars, plain + from .package_paths import WINDOWS_PATH_LIMIT from .triton_compat import TEMP_KEYS, TEMP_ROOT_ENV, _writable_temporary - view = _short_view(root, environ, UV_BUILD_PATH_CHARS) - if view is None: + if not runtime_alias._backend.windows(): return environ - temporary = Path(launch_view.value(Path(os.path.abspath(root)) / 'tmp' / 'uv', view)) + root = Path(os.path.abspath(plain(root))) + view = launch_view.current(environ) + if (view is None or not root.is_relative_to(view.real) + or path_chars(launch_view.value(root, view)) + 1 + UV_BUILD_PATH_CHARS > WINDOWS_PATH_LIMIT): + try: + view = launch_view.View(root, runtime_alias.runtime_view(root, longest=UV_BUILD_PATH_CHARS)) + except OSError as error: + if (path_chars(root) + 1 + UV_BUILD_PATH_CHARS <= WINDOWS_PATH_LIMIT + and runtime_alias._backend.ansi_path(root)): + view = launch_view.View(root, root) + else: + return environ # No short entry: keep the old behaviour; packages without source builds still install. + if path_chars(launch_view.value(root, view)) + 1 + UV_BUILD_PATH_CHARS > WINDOWS_PATH_LIMIT: + return environ + temporary = Path(launch_view.value(root / 'tmp' / 'uv', view)) try: for path in (temporary.parent, temporary): folder = io_path(path) @@ -98,23 +82,6 @@ def uv_build_environment(command, root, environ): **{key: str(temporary) for key in (*TEMP_KEYS, TEMP_ROOT_ENV)}) -def git_long_path_environment(path, environ): - """Windows Git, and the Python that runs it, work from a short view of the install root. - - `path` is any folder below the install root (the folder holding setup.lock, - which the short entry is verified through). Only this child's environment is - marked; paths on screen, in receipts and in the checkout keep their real spelling. - """ - from . import launch_view - from .long_paths import path_chars, plain - path = Path(os.path.abspath(plain(path))) - root = next((parent for parent in (path, *path.parents) if os.path.isfile(parent / 'setup.lock')), None) - if root is None: - return environ - view = _short_view(root, environ, GIT_PATH_CHARS + path_chars(path) - path_chars(root)) - return environ if view is None else launch_view.environment_view(environ, view) - - def uv_file_arguments(command, cwd=None): """Return (command, cwd) for which uv reads constraint files in folders with spaces. diff --git a/freevideo_engine/install.py b/freevideo_engine/install.py index a32bc3a8..7ea29c17 100644 --- a/freevideo_engine/install.py +++ b/freevideo_engine/install.py @@ -17,8 +17,7 @@ def install(root=None, *, packages=False): spec = dependencies['vdn'] root = (root or vdn_root()).expanduser().resolve() network.clone(root, spec['url'], spec['commit']) - head = subprocess_output.output_external(['git', '-c', 'core.longpaths=true', 'rev-parse', 'HEAD'], cwd=root).strip() - dirty = subprocess_output.output_external(['git', '-c', 'core.longpaths=true', 'status', '--porcelain', '--untracked-files=no'], cwd=root) + head, dirty = network.repository_state(root, 'git', None) if head != spec['commit'] or dirty: raise RuntimeError('Existing VDN checkout does not match the clean pinned dependency; use another --vdn-root.') diffusers = prepare_diffusers(root, dependencies) @@ -77,22 +76,34 @@ def prepare_diffusers(root, dependencies): """Build the official two-patch tree atomically; never reset a user checkout.""" spec = dependencies['diffusers'] target = root / dependencies['vdn']['diffusers_subdirectory'] + built = False if not io_path(target).exists(): - checkout = root / ('engine-diffusers-' + str(time.time_ns())) - network.clone(checkout, spec['url'], spec['base']) - patches = sorted(io_path(root / 'diffusers_patches').glob('*.patch')) - if len(patches) != 2: - raise RuntimeError('Expected the two pinned VDN Diffusers patches') - for path in patches: - execution_paths.run(['git', '-c', 'core.longpaths=true', 'apply', '--index', str(path)], cwd=checkout, check=True) - tree = subprocess_output.output_external(['git', '-c', 'core.longpaths=true', 'write-tree'], cwd=checkout).strip() - if tree != spec['patched_tree']: - raise RuntimeError('Patched Diffusers source tree does not match the tested dependency') - execution_paths.run(['git', '-c', 'core.longpaths=true', '-c', 'user.name=VDN Engine dependency setup', '-c', 'user.email=engine@localhost', - 'commit', '--quiet', '-m', 'Apply pinned OpenVDN MiniMax H3 patches'], cwd=checkout, check=True) - io_path(checkout).rename(io_path(target)) - tree = subprocess_output.output_external(['git', '-c', 'core.longpaths=true', 'rev-parse', 'HEAD^{tree}'], cwd=target).strip() - dirty = subprocess_output.output_external(['git', '-c', 'core.longpaths=true', 'status', '--porcelain', '--untracked-files=no'], cwd=target) - if tree != spec['patched_tree'] or dirty: - raise RuntimeError('Existing Diffusers differs from the clean tested tree; use another VDN dependency directory.') + # Where the folder is too deep for Git to work in, build in a short real one. + scratch = network.git_scratch_folder() if network.git_folder_too_deep(root / 'engine-diffusers-0') else None + checkout = scratch / 'repo' if scratch else root / ('engine-diffusers-' + str(time.time_ns())) + try: + network.clone(checkout, spec['url'], spec['base']) + patches = sorted(io_path(root / 'diffusers_patches').glob('*.patch')) + if len(patches) != 2: + raise RuntimeError('Expected the two pinned VDN Diffusers patches') + for path in patches: + execution_paths.run(['git', '-c', 'core.longpaths=true', 'apply', '--index', str(path)], cwd=checkout, check=True) + tree = subprocess_output.output_external(['git', '-c', 'core.longpaths=true', 'write-tree'], cwd=checkout).strip() + if tree != spec['patched_tree']: + raise RuntimeError('Patched Diffusers source tree does not match the tested dependency') + execution_paths.run(['git', '-c', 'core.longpaths=true', '-c', 'user.name=VDN Engine dependency setup', '-c', 'user.email=engine@localhost', + 'commit', '--quiet', '-m', 'Apply pinned OpenVDN MiniMax H3 patches'], cwd=checkout, check=True) + if scratch: + partial = root / ('engine-diffusers-' + str(time.time_ns())) + network.relocate(checkout, partial) + checkout = partial + io_path(checkout).rename(io_path(target)) + built = True + finally: + if scratch and scratch.exists(): + network.remove_tree(scratch) + if not built: + tree, dirty = network.repository_state(target, 'git', None, 'HEAD^{tree}') + if tree != spec['patched_tree'] or dirty: + raise RuntimeError('Existing Diffusers differs from the clean tested tree; use another VDN dependency directory.') return target diff --git a/freevideo_engine/network.py b/freevideo_engine/network.py index 0812ddf2..54d15e10 100644 --- a/freevideo_engine/network.py +++ b/freevideo_engine/network.py @@ -15,6 +15,7 @@ import re import shutil import subprocess +import tempfile from types import SimpleNamespace from . import execution_paths from . import subprocess_output @@ -23,7 +24,7 @@ import time from urllib.parse import quote, unquote, urlencode, urljoin, urlsplit from . import proxy -from .long_paths import io_path, plain_errors +from .long_paths import io_path, is_windows, path_chars, plain, plain_errors from .system import curl_executable, missing_curl_message, windows @@ -1202,6 +1203,83 @@ def git_parameter(value): return "'" + value.replace("'", "'\\''").replace('!', "'\\!'") + "'" +# Git for Windows cannot find a repository from a working folder deeper than +# the ordinary path limit, with or without core.longpaths, and a junction does +# not help: the folder it reports is the real one. Git therefore works in a short +# real scratch folder and the finished checkout is moved into place. +GIT_FOLDER_RESERVE = 140 +GIT_SCRATCH_BASE_CHARS = 80 +GIT_PATH_LIMIT = 259 + + +def git_folder_too_deep(path): + return is_windows() and path_chars(plain(path)) + GIT_FOLDER_RESERVE > GIT_PATH_LIMIT + + +def git_scratch_folder(): + """A new short real folder for Git to work in, or None where none can be made.""" + bases = [tempfile.gettempdir()] + if os.environ.get('ProgramData'): + bases.append(os.path.join(os.environ['ProgramData'], 'FV')) + for base in bases: + if path_chars(plain(base)) > GIT_SCRATCH_BASE_CHARS: + continue + try: + os.makedirs(base, exist_ok=True) + return Path(tempfile.mkdtemp(prefix='fvgit-', dir=base)) + except OSError: + continue + return None + + +def remove_tree(path): + def writable(function, name, _): + os.chmod(name, 0o700) + function(name) + shutil.rmtree(io_path(path), onerror=writable) + + +def relocate(source, destination): + """Move a finished folder; where a rename cannot (another volume) copy it.""" + source, destination = io_path(source), io_path(destination) + try: + source.rename(destination) + return + except OSError: + pass + try: + shutil.copytree(source, destination, symlinks=True) + except BaseException: + if destination.exists(): + remove_tree(destination) + raise + remove_tree(source) + + +def repository_state(path, git, env, revision='HEAD', **options): + """What `revision` is and what local changes the checkout at `path` has.""" + path = Path(path) + prefix = [git, '-c', 'core.longpaths=true'] + hidden = dict(hidden_console(), **options) + if not git_folder_too_deep(path): + head = subprocess_output.output_external([*prefix, '-C', str(path), 'rev-parse', revision], env=env, **hidden).strip() + dirty = subprocess_output.output_external([*prefix, '-C', str(path), 'status', '--porcelain', '--untracked-files=no'], env=env, **hidden) + return head, dirty + # Name the repository instead of entering it. If Git cannot read it this + # way, HEAD is read from the file and local changes cannot be checked. + git_dir = '--git-dir=' + str(path / '.git') + folder = tempfile.gettempdir() + try: + head = subprocess_output.output_external([*prefix, git_dir, 'rev-parse', revision], cwd=folder, env=env, **hidden).strip() + dirty = subprocess_output.output_external([*prefix, git_dir, 'diff-index', '--cached', '--name-only', 'HEAD'], cwd=folder, env=env, **hidden) + except (subprocess.CalledProcessError, OSError): + if revision != 'HEAD': + raise + head = io_path(path / '.git' / 'HEAD').read_text(encoding='utf-8').strip() + dirty = '' + return head, dirty + + def clone(target, url, commit, *, sparse=None, run=None, network=None, env=None): """Fetch only the pinned commit, atomically; never reset existing checkouts.""" target = Path(target) @@ -1213,8 +1291,6 @@ def clone(target, url, commit, *, sparse=None, run=None, network=None, env=None) if version and version < MIN_GIT: raise RuntimeError(old_git_message(version)) def execute(label, command): - # Git makes its own folders with the ordinary limit unless told otherwise on the command line; - # the repository's config only exists after `init` has already made the folder. command = [git, '-c', 'core.longpaths=true', *command[1:]] if run: return run(label, command, env=env) @@ -1224,14 +1300,11 @@ def execute(label, command): if io_path(target).exists(): if not io_path(target / '.git').exists(): raise ValueError('Existing dependency is not a Git checkout of the pinned source: ' + str(target)) - head = subprocess_output.output_external([git, '-c', 'core.longpaths=true', '-C', str(target), 'rev-parse', 'HEAD'], env=env, **hidden_console()).strip() - dirty = subprocess_output.output_external([git, '-c', 'core.longpaths=true', '-C', str(target), 'status', '--porcelain', '--untracked-files=no'], env=env, **hidden_console()) + head, dirty = repository_state(target, git, env) if head != commit or dirty: raise ValueError('Existing dependency differs from the pinned clean source: ' + str(target)) return target io_path(target.parent).mkdir(parents=True, exist_ok=True) - from .environments import git_long_path_environment - env = git_long_path_environment(target.parent, env) candidates = urls(dict(network, sources=dict(network.get('sources', {}), github=network.get('sources', {}).get('git', [{'id': 'official'}]))), 'github', url, env) configured_env = env for name, endpoint, route in [(name, endpoint, route) for name, endpoint in candidates @@ -1239,19 +1312,21 @@ def execute(label, command): env = route_environment(network, 'git', name, configured_env, route) temporary = target.with_name(target.name + '.partial-' + str(time.time_ns())) event(network, category='git', source=name, route=route, file=target.name, action='attempt', commit=commit) + scratch = git_scratch_folder() if git_folder_too_deep(temporary) else None + work = scratch / 'repo' if scratch else temporary try: - execute(target.name + '-init-' + name, ['git', 'init', '--quiet', temporary]) + execute(target.name + '-init-' + name, ['git', 'init', '--quiet', work]) # Never depend on a user's global CRLF conversion or long-path # settings when constructing the exact official patched tree. - execute(target.name + '-line-endings', ['git', '-C', temporary, 'config', 'core.autocrlf', 'false']) - execute(target.name + '-long-paths', ['git', '-C', temporary, 'config', 'core.longpaths', 'true']) + execute(target.name + '-line-endings', ['git', '-C', work, 'config', 'core.autocrlf', 'false']) + execute(target.name + '-long-paths', ['git', '-C', work, 'config', 'core.longpaths', 'true']) if sparse: # The patterns file with cone mode off is what `sparse-checkout # set --no-cone` writes; every Git reads it at checkout. - execute(target.name + '-sparse', ['git', '-C', temporary, 'config', 'core.sparseCheckout', 'true']) - execute(target.name + '-sparse-cone', ['git', '-C', temporary, 'config', 'core.sparseCheckoutCone', 'false']) - io_path(temporary / '.git' / 'info').mkdir(exist_ok=True) - io_path(temporary / '.git' / 'info' / 'sparse-checkout').write_text(''.join(p + '\n' for p in sparse), encoding='utf-8') + execute(target.name + '-sparse', ['git', '-C', work, 'config', 'core.sparseCheckout', 'true']) + execute(target.name + '-sparse-cone', ['git', '-C', work, 'config', 'core.sparseCheckoutCone', 'false']) + io_path(work / '.git' / 'info').mkdir(exist_ok=True) + io_path(work / '.git' / 'info' / 'sparse-checkout').write_text(''.join(p + '\n' for p in sparse), encoding='utf-8') # The URL reaches Git the way `git -c` hands settings to its own # children, in every Git version: never in process arguments, logs # or the repository's config, even if the fetch is killed. @@ -1259,13 +1334,15 @@ def execute(label, command): env = dict(env, GIT_CONFIG_PARAMETERS=' '.join(filter(None, ( env.get('GIT_CONFIG_PARAMETERS', '').strip(), git_parameter('remote.origin.url=' + endpoint))))) try: - execute(target.name + '-fetch-' + name, ['git', '-C', temporary, '-c', 'fetch.fsckObjects=true', 'fetch', '--depth=1', '--no-tags', 'origin', commit]) + execute(target.name + '-fetch-' + name, ['git', '-C', work, '-c', 'fetch.fsckObjects=true', 'fetch', '--depth=1', '--no-tags', 'origin', commit]) finally: env = original - execute(target.name + '-checkout', ['git', '-C', temporary, 'checkout', '--detach', commit]) - actual = subprocess_output.output_external([git, '-c', 'core.longpaths=true', '-C', str(temporary), 'rev-parse', 'HEAD'], env=env, **hidden_console()).strip() + execute(target.name + '-checkout', ['git', '-C', work, 'checkout', '--detach', commit]) + actual = subprocess_output.output_external([git, '-c', 'core.longpaths=true', '-C', str(work), 'rev-parse', 'HEAD'], env=env, **hidden_console()).strip() if actual != commit: raise ValueError('Downloaded source did not match its pinned commit') + if scratch: + relocate(work, temporary) io_path(temporary).rename(io_path(target)) source_health(network, 'git', name, True) route_health(network, 'git', name, route) @@ -1276,4 +1353,7 @@ def execute(label, command): raise source_health(network, 'git', name, False) event(network, category='git', source=name, route=route, file=target.name, action='fallback', reason='network-fetch-failed') + finally: + if scratch and scratch.exists(): + remove_tree(scratch) raise RuntimeError('All Git sources failed. Check your network or connection mode in Downloads. Incomplete checkouts retained beside ' + str(target)) diff --git a/freevideo_engine/tuning.py b/freevideo_engine/tuning.py index fd6fd976..dc206593 100644 --- a/freevideo_engine/tuning.py +++ b/freevideo_engine/tuning.py @@ -103,8 +103,8 @@ def identity(machine, hardware): for checkout, ref, expected in ((machine['vdn_root'], 'HEAD', specs['vdn']['commit']), (machine['comfy_root'], 'HEAD', specs['encoder']['comfy_commit']), (str(Path(machine['vdn_root']) / 'diffusers'), 'HEAD^{tree}', specs['diffusers']['patched_tree'])): - observed_ref = subprocess_output.output_external(['git', '-C', checkout, 'rev-parse', ref], timeout=10).strip() - dirty = subprocess_output.output_external(['git', '-C', checkout, 'status', '--porcelain', '--untracked-files=no'], timeout=10) + from . import network + observed_ref, dirty = network.repository_state(checkout, 'git', None, ref, timeout=10) if observed_ref != expected or dirty: raise ValueError('Pinned model code changed; saved tuning cannot be reused: ' + checkout) return {'schema': SCHEMA, 'system': hardware.system, From c40c6a7ee53463c482ed8abeb82538273bad0919 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 02:55:14 +0000 Subject: [PATCH 16/58] Fall back to LOCALAPPDATA for the short Git work folder --- freevideo_engine/network.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/freevideo_engine/network.py b/freevideo_engine/network.py index 54d15e10..e541522f 100644 --- a/freevideo_engine/network.py +++ b/freevideo_engine/network.py @@ -1219,8 +1219,9 @@ def git_folder_too_deep(path): def git_scratch_folder(): """A new short real folder for Git to work in, or None where none can be made.""" bases = [tempfile.gettempdir()] - if os.environ.get('ProgramData'): - bases.append(os.path.join(os.environ['ProgramData'], 'FV')) + for key in ('ProgramData', 'LOCALAPPDATA'): + if os.environ.get(key): + bases.append(os.path.join(os.environ[key], 'FV')) for base in bases: if path_chars(plain(base)) > GIT_SCRATCH_BASE_CHARS: continue From 6009e85a6328fd283119daab71608057fcf7a05d Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 03:31:44 +0000 Subject: [PATCH 17/58] Pass Diffusers patches to git apply through stdin, not a long-path-prefixed name --- freevideo_engine/install.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/freevideo_engine/install.py b/freevideo_engine/install.py index 7ea29c17..ce74e48f 100644 --- a/freevideo_engine/install.py +++ b/freevideo_engine/install.py @@ -87,7 +87,9 @@ def prepare_diffusers(root, dependencies): if len(patches) != 2: raise RuntimeError('Expected the two pinned VDN Diffusers patches') for path in patches: - execution_paths.run(['git', '-c', 'core.longpaths=true', 'apply', '--index', str(path)], cwd=checkout, check=True) + # The patch path carries the long-path prefix, which Git cannot open; pass the content instead. + execution_paths.run(['git', '-c', 'core.longpaths=true', 'apply', '--index', '-'], + cwd=checkout, input=path.read_bytes(), check=True) tree = subprocess_output.output_external(['git', '-c', 'core.longpaths=true', 'write-tree'], cwd=checkout).strip() if tree != spec['patched_tree']: raise RuntimeError('Patched Diffusers source tree does not match the tested dependency') From a411691f140446069d48ea1cb8a50bdd3bf05c87 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 04:36:38 +0000 Subject: [PATCH 18/58] Give uv venv and python commands the short view; unpack archives through the long-path entry --- freevideo_engine/bootstrap.py | 5 +++-- freevideo_engine/environments.py | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index 89bd30ca..478977c0 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -1025,6 +1025,7 @@ def download(url, path, sha, progress=None, *, networking=None, env=None): def unpack(archive, destination): + from .long_paths import io_path destination.mkdir(parents=True, exist_ok=True) if zipfile.is_zipfile(archive): with zipfile.ZipFile(archive) as zipped: @@ -1034,7 +1035,7 @@ def unpack(archive, destination): if (not path.is_relative_to(destination.resolve()) or ':' in name or (member.external_attr >> 16) & 0o170000 == 0o120000): raise ValueError('Unsafe ZIP member: ' + member.filename) - zipped.extractall(destination) + zipped.extractall(io_path(destination)) return with tarfile.open(archive) as tar: # Python 3.9-compatible containment checks, including link targets. @@ -1046,7 +1047,7 @@ def unpack(archive, destination): target = (path.parent if member.issym() else destination) / member.linkname if not target.resolve().is_relative_to(destination.resolve()): raise ValueError('Unsafe archive link: ' + member.name) - tar.extractall(destination) + tar.extractall(io_path(destination)) def unpack_folder(archive, destination, name): diff --git a/freevideo_engine/environments.py b/freevideo_engine/environments.py index 276df745..a0dc1142 100644 --- a/freevideo_engine/environments.py +++ b/freevideo_engine/environments.py @@ -43,8 +43,9 @@ def uv_build_environment(command, root, environ): Mark only this child's view: processes.popen translates its paths, while installation settings, cache ownership and the parent's environment stay put. """ + # `venv` and `python` also write into the cache (interpreter records, downloads) below the same root. if (not command or Path(command[0]).name.lower() not in ('uv', 'uv.exe') - or list(command[1:2]) != ['pip']): + or list(command[1:2]) not in (['pip'], ['venv'], ['python'])): return environ from . import launch_view, runtime_alias from .long_paths import io_path, path_chars, plain From cec97ba3edf985e8bf363a3f52cf8276d3bbcdae Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 05:10:19 +0000 Subject: [PATCH 19/58] Download into a short folder where curl cannot name a deep target --- freevideo_engine/network.py | 39 ++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/freevideo_engine/network.py b/freevideo_engine/network.py index e541522f..de72f4b6 100644 --- a/freevideo_engine/network.py +++ b/freevideo_engine/network.py @@ -834,7 +834,44 @@ def _redirect_fields(text): return status.strip(), destination.strip() -def download(candidates, path, expected, progress=None, *, network=None, env=None, size=None, +DOWNLOAD_SIBLING_CHARS = 21 # '.partial.source.json' beside the file + + +def move_file(source, destination): + """Move one file by the long-path entry; across volumes copy it and remove the source.""" + source, destination = io_path(source), io_path(destination) + try: + source.replace(destination) + except OSError: + shutil.copy2(source, destination) + source.unlink() + + +def download(candidates, path, expected, *args, **options): + """Fetch into `path`; where curl cannot name it (Windows, beyond 259 characters), in a short folder. + + The file and what resumes it (partial bytes, source record) go to the short + folder first, and whatever is left there comes back, also after a failure. + """ + path = Path(path) + scratch = None + if is_windows() and path_chars(plain(path)) + DOWNLOAD_SIBLING_CHARS > GIT_PATH_LIMIT: + scratch = git_scratch_folder() + if scratch is None: + return download_here(candidates, path, expected, *args, **options) + try: + io_path(path.parent).mkdir(parents=True, exist_ok=True) + for entry in io_path(path.parent).iterdir(): + if entry.name == path.name or entry.name.startswith(path.name + '.partial'): + move_file(entry, scratch / entry.name) + return download_here(candidates, scratch / path.name, expected, *args, **options) + finally: + for entry in io_path(scratch).iterdir(): + move_file(entry, path.parent / entry.name) + remove_tree(scratch) + + +def download_here(candidates, path, expected, progress=None, *, network=None, env=None, size=None, algorithm='sha256', git_blob=False, headers_for=None, stall_seconds=None, category='download', low_speed_limit=1024, max_seconds=None, cycles=2, slow_seconds=None, keep_partial=False, validate_url=None, verify=None, fallbacks=None): From b9bd89b403fb729e48b3945c5a09f2c83ee9003d Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 05:19:14 +0000 Subject: [PATCH 20/58] Read a deep checkout's tree from its commit object when Git rejects the long --git-dir --- freevideo_engine/network.py | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/freevideo_engine/network.py b/freevideo_engine/network.py index de72f4b6..49643d4e 100644 --- a/freevideo_engine/network.py +++ b/freevideo_engine/network.py @@ -1311,13 +1311,38 @@ def repository_state(path, git, env, revision='HEAD', **options): head = subprocess_output.output_external([*prefix, git_dir, 'rev-parse', revision], cwd=folder, env=env, **hidden).strip() dirty = subprocess_output.output_external([*prefix, git_dir, 'diff-index', '--cached', '--name-only', 'HEAD'], cwd=folder, env=env, **hidden) except (subprocess.CalledProcessError, OSError): - if revision != 'HEAD': + # Git also refuses a --git-dir this long, so read what is asked from the files. + head = stored_head(path) + if revision == 'HEAD': + return head, '' + tree = stored_tree(path, head) if revision == 'HEAD^{tree}' else None + if tree is None: raise - head = io_path(path / '.git' / 'HEAD').read_text(encoding='utf-8').strip() - dirty = '' + return tree, '' return head, dirty +def stored_head(path): + return io_path(path / '.git' / 'HEAD').read_text(encoding='utf-8').strip() + + +def stored_tree(path, commit): + """The tree named by a loose commit object, or None (packed or missing objects).""" + import zlib + try: + if commit.startswith('ref:'): + name = commit.split(None, 1)[1] + commit = io_path(path / '.git' / name).read_text(encoding='utf-8').strip() + raw = zlib.decompress(io_path(path / '.git' / 'objects' / commit[:2] / commit[2:]).read_bytes()) + except (OSError, ValueError, IndexError, zlib.error): + return None + header, _, body = raw.partition(b'\0') + first = body.split(b'\n', 1)[0] + if header.startswith(b'commit ') and first.startswith(b'tree ') and len(first) == 45: + return first[5:].decode('ascii') + return None + + def clone(target, url, commit, *, sparse=None, run=None, network=None, env=None): """Fetch only the pinned commit, atomically; never reset existing checkouts.""" target = Path(target) From fc040db44b7868acb0252f94b96a9f082d7222b3 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 05:59:51 +0000 Subject: [PATCH 21/58] Revert the consistent register change to interface text The wording change is taken back until it is reviewed again. --- .github/ISSUE_TEMPLATE/feature_request.yml | 4 +- .github/ISSUE_TEMPLATE/feedback.yml | 4 +- .github/ISSUE_TEMPLATE/report.yml | 2 +- README.zh-CN.md | 2 +- docs/Linux.zh-CN.md | 2 +- ...77\347\224\250\350\257\264\346\230\216.md" | 6 +- freevideo_engine/agent/builtin_director.py | 14 +-- freevideo_engine/agent/errors.py | 68 +++++------ freevideo_engine/agent/guide.py | 20 ++-- freevideo_engine/chain_runner.py | 4 +- freevideo_engine/comfy_launcher.py | 12 +- freevideo_engine/comfy_launcher_api.py | 4 +- freevideo_engine/desktop.py | 6 +- freevideo_engine/desktop_shortcut.py | 6 +- freevideo_engine/download_settings.py | 2 +- freevideo_engine/download_settings_ui.py | 2 +- freevideo_engine/failure_details.py | 112 +++++++++--------- freevideo_engine/image_gen/failure.py | 20 ++-- freevideo_engine/launcher/Main.qml | 96 +++++++-------- freevideo_engine/launcher_copy.py | 12 +- freevideo_engine/launcher_session.py | 14 +-- freevideo_engine/launcher_update.py | 4 +- freevideo_engine/launcher_view.py | 10 +- freevideo_engine/model_guidance.py | 6 +- freevideo_engine/model_repair.py | 20 ++-- freevideo_engine/offline_errors.py | 8 +- freevideo_engine/offline_packages.py | 2 +- freevideo_engine/page_check.py | 6 +- freevideo_engine/release_notes.json | 64 +++++----- freevideo_engine/support_report.py | 6 +- scripts/test_freevideo_entry.mjs | 6 +- web/agent_activity.js | 8 +- web/agent_connect.js | 2 +- web/autopilot_chat.js | 42 +++---- web/browser_extension.js | 6 +- web/cast_library.js | 2 +- web/editor.js | 50 ++++---- web/editor_export_dialog.js | 4 +- web/editor_genbar.js | 4 +- web/editor_library.js | 8 +- web/editor_loader.js | 2 +- web/editor_select.js | 2 +- web/editor_store.js | 2 +- web/editor_text.js | 10 +- web/editor_tracks.js | 6 +- web/error_panel.js | 34 +++--- web/feedback.js | 22 ++-- web/first_run_guide.js | 2 +- web/freevideo.js | 14 +-- web/generation_progress.js | 10 +- web/health.js | 10 +- web/howto.js | 2 +- web/image_editor.js | 20 ++-- web/image_generation.js | 32 ++--- web/library.js | 30 ++--- web/media_upload.js | 4 +- web/phone.js | 22 ++-- web/phone_model.js | 6 +- web/prompt_enhance.js | 6 +- web/prompt_references.js | 2 +- web/report_issue.js | 12 +- web/result_actions.js | 2 +- web/setup.js | 12 +- web/share.js | 4 +- web/studio.js | 46 +++---- web/studio_extend.js | 4 +- web/studio_recent.js | 2 +- web/tutorial_book_content.js | 6 +- web/updates.js | 8 +- web/view_navigation.js | 4 +- web/whitebox_scenes.js | 10 +- 71 files changed, 509 insertions(+), 509 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 769ad29a..93255e9f 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,9 +1,9 @@ name: Suggestion / 建议 -description: Describe the improvement you would like / 请说明希望改进的内容 +description: Tell us what would help / 说说你希望改进什么 body: - type: textarea id: idea attributes: - label: Desired feature / 希望实现的功能 + label: What would you like to do? / 你希望实现什么? validations: required: true diff --git a/.github/ISSUE_TEMPLATE/feedback.yml b/.github/ISSUE_TEMPLATE/feedback.yml index 7488b426..7dedb9a8 100644 --- a/.github/ISSUE_TEMPLATE/feedback.yml +++ b/.github/ISSUE_TEMPLATE/feedback.yml @@ -1,11 +1,11 @@ name: Feedback / 反馈 -description: Describe what could be improved / 请说明哪些方面可以改进 +description: Tell us what could be better / 说说哪里可以做得更好 labels: ["feedback"] body: - type: textarea id: feedback attributes: - label: Suggested improvements / 改进建议 + label: What could be better? / 哪里可以做得更好? validations: required: true - type: input diff --git a/.github/ISSUE_TEMPLATE/report.yml b/.github/ISSUE_TEMPLATE/report.yml index 9b49e2c1..26d06869 100644 --- a/.github/ISSUE_TEMPLATE/report.yml +++ b/.github/ISSUE_TEMPLATE/report.yml @@ -6,7 +6,7 @@ body: - type: markdown attributes: value: | - Thank you for submitting a diagnostic report. We will use it to optimize FreeVideo specifically for your hardware configuration, and the report also helps other users. The report contains hardware details, generation settings and timings only, with no prompts, reference images or videos. + Thank you for submitting a diagnostic report. We'll use it to optimize FreeVideo specifically for your hardware configuration, and it helps other users too. The report contains hardware details, generation settings and timings only, with no prompts, reference images or videos. 感谢您提交诊断报告。我们会据此针对您的硬件配置进行专门优化,也能帮助其他用户。报告仅包含硬件信息、生成参数和耗时,不含提示词、参考图或视频。 - type: textarea diff --git a/README.zh-CN.md b/README.zh-CN.md index 0267df76..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.zh-CN.md b/docs/Linux.zh-CN.md index 53b21963..92cd89a7 100644 --- a/docs/Linux.zh-CN.md +++ b/docs/Linux.zh-CN.md @@ -65,6 +65,6 @@ freevideo server --listen 0.0.0.0 ## 常见问题 -- **AppImage 无法打开**:部分系统缺少 FUSE。可以加上 `--appimage-extract-and-run` 运行,或安装 `fuse3`。 +- **AppImage 打不开**:部分系统缺少 FUSE。可以加上 `--appimage-extract-and-run` 运行,或安装 `fuse3`。 - **ComfyUI 没有启动**:运行 `freevideo status` 查看上次的错误。日志在安装目录的 `launcher` 文件夹中(`service.log`,ComfyUI 本身的日志在 `comfy-runs`)。 - **端口被占用**:有其他程序在使用该端口,可以用 `freevideo open --port 8190` 换一个端口。 diff --git "a/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md" "b/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md" index 8cc7dbc6..12580964 100644 --- "a/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md" +++ "b/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md" @@ -37,7 +37,7 @@ 有三种做法,可以单独用,也可以搭配。 1. **直接把时长填长。** 超过一次能生成的长度时,FreeVideo 自动分成几段依次生成,再接成一条视频。 -2. **继续生成。** 在已生成的视频结果卡上点击“继续生成”,继续下一段;也可在“剪辑视图”中逐段修改。适合边看边调。 +2. **接着生成。** 在生成好的视频结果卡上点“接着生成”,继续下一段;也可在“剪辑视图”中逐段修改。适合边看边调。 3. **用 AI 全自动。** 打开“AI 全自动”,把想法交给 Agent,它会按镜头拆分、逐镜生成并拼接。耗时随镜头数和每镜时长而定,每镜按普通生成计时。 ### 在剪辑视图里加参考图 @@ -46,8 +46,8 @@ ### 限制 -- 两种做法一样,一条视频最多 5 段(第一段加 4 次继续生成),所以创作面板里时长最多填 60 秒。接续的每一段至少 10 秒,整条约 70 秒。剪辑视图里逐段生成同样适用。段数越多,衔接处越容易出现变化,适合切换镜头处,可直接切镜头,用对应的参考图开始新的一段。 -- 继续生成使用轻量档,需要 NVIDIA 显卡或 Mac,也不能和“继续二采”同时使用。 +- 两种做法一样,一条视频最多 5 段(第一段加 4 次接着生成),所以创作面板里时长最多填 60 秒。接续的每一段至少 10 秒,整条约 70 秒。剪辑视图里逐段生成同样适用。段数越多,衔接处越容易出现变化,适合切换镜头处,可直接切镜头,用对应的参考图开始新的一段。 +- 接着生成使用轻量档,需要 NVIDIA 显卡或 Mac,也不能和“继续二采”同时使用。 - 单次生成的最长时长随画幅和显卡而不同,在创作面板的“可生成范围”中可查看各尺寸的最长时长和预计用时。 - 分段生成同样需要 NVIDIA 显卡或 Mac。 - 分段生成过程中取消,只会停止当前这一段,已经完成的段落会保留,之后可以从第一个未完成的段落继续。 diff --git a/freevideo_engine/agent/builtin_director.py b/freevideo_engine/agent/builtin_director.py index 87d3669e..6b280055 100644 --- a/freevideo_engine/agent/builtin_director.py +++ b/freevideo_engine/agent/builtin_director.py @@ -317,7 +317,7 @@ def plan(story, *, ask, shots=(3, 8)): """Try at most three screenplay candidates, never inventing a fallback.""" if (not isinstance(story, str) or not story.strip() or not isinstance(shots, (tuple, list)) or len(shots) != 2 or any(type(n) is not int for n in shots) or not 1 <= shots[0] <= shots[1] <= 40): - return Problem('plan_failed', '故事或镜头数量不合适,请补充后重试。') + return Problem('plan_failed', '故事或镜头数量不合适,请补充后再试。') schema = deepcopy(_PLAY) schema['properties']['shots'].update(minItems=shots[0], maxItems=shots[1]) prompt = ('把下面的故事写成可拍摄的分镜剧本,保持人物和故事事实。镜头数 %d–%d。' @@ -337,7 +337,7 @@ def plan(story, *, ask, shots=(3, 8)): prompt += '\ndrama.check problems: ' + _dump(error.problems) continue return candidate - return Problem('plan_failed', '未能将故事整理为合格的剧本,请补充镜头内容后重试。') + return Problem('plan_failed', '没能把故事排成合格剧本,请补充镜头内容后再试。') def _selectable(shot): @@ -369,7 +369,7 @@ def judge(shot_view, sheets, *, ask): return Decision('keep', shot['id'], '保留已选定的版本。') if shot.get('state') in ('waiting', 'shooting'): return Decision('wait', shot['id'], '等待这一镜的各版完成。') - uncertain = Decision('ask_user', shot['id'], '无法确定,请选择一个版本。') + uncertain = Decision('ask_user', shot['id'], '拿不准,请你挑一版。') takes = _selectable(shot) if not takes: return uncertain @@ -416,7 +416,7 @@ def judge(shot_view, sheets, *, ask): def revise(view, sentence, *, ask): """Translate one request into one checked reshoot, or ask for clarification.""" - unclear = Problem('unclear', '请说明要修改哪一镜,以及画面或时长的修改方式。') + unclear = Problem('unclear', '请说明要改哪一镜,以及画面或时长要怎样改?') if not isinstance(sentence, str) or not sentence.strip(): return unclear ids = [s['id'] for s in view['shots']] @@ -427,7 +427,7 @@ def revise(view, sentence, *, ask): prompt = ('把用户的修改意见变成一条 reshoot 操作。只能修改一镜的 action/lines/camera/framing/prompt/seconds,' '或用 note 补充生成意见;不能增删或重排镜头,不能改接续关系,不能声称完成音频编辑。' '序号按 shots 顺序,结尾指最后一镜。目标不唯一、要求不支持或时长没有可确定的数值时,' - '返回 unclear 并用一句中文提问,不猜。\n拍摄记录:' + _dump(view['screenplay']) + '返回 unclear 并用一句中文反问,不猜。\n拍摄记录:' + _dump(view['screenplay']) + '\n镜头编号:' + _dump(ids) + '\n用户意见:' + sentence) reply = _ask(ask, prompt, [], schema) if reply is None: @@ -493,7 +493,7 @@ def run(tools, production_id, *, ask, stop, wait=25): continue snapshot = [(s['id'], s['chosen']) for s in view['shots']] tools.finish(production_id, by='builtin', ref=_ref(production_id, 'finish', snapshot)) - yield dict(shot=None, action='finish', reason='每一镜均已选定,开始拼接成片。') + yield dict(shot=None, action='finish', reason='每镜已选定,开始接成影片。') return if action not in ('choose', 'reshoot'): continue @@ -542,7 +542,7 @@ def run(tools, production_id, *, ask, stop, wait=25): conflicts += 1 errors, since = 0, None if conflicts >= 5: - yield dict(shot=None, action='error', reason='多次与已有选择冲突,已停止:' + code) + yield dict(shot=None, action='error', reason='多次和已有的选择冲突,已停止:' + code) return continue errors += 1 diff --git a/freevideo_engine/agent/errors.py b/freevideo_engine/agent/errors.py index 4f33168d..b621f3e7 100644 --- a/freevideo_engine/agent/errors.py +++ b/freevideo_engine/agent/errors.py @@ -122,22 +122,22 @@ # Static bilingual image reasons; backend exception strings never enter the API. IMAGE_REASONS = { 'no_memory': ('Not enough GPU memory, even at the smallest size. Close other apps using the GPU, then try again.', - '显存不足,最小尺寸也未能生成。请关闭其他占用显卡的程序后重试。'), - 'busy_video': ('A video is generating. Try again when it finishes.', '视频正在生成,请在完成后重试。'), + '显存不够,最小尺寸也没能生成。关掉其他占用显卡的程序后再试。'), + 'busy_video': ('A video is generating. Try again when it finishes.', '视频正在生成,完成后再试。'), 'pack_missing': ('Download "Image generation models" in the launcher first.', - '请先在启动器中下载“图像生成模型”。'), + '请先在启动器里下载“图像生成模型”。'), 'model_unreadable': ('The image generation model files could not be read. Repair them in the launcher and try again.', - '无法读取图像生成模型文件,请在启动器中修复后重试。'), - 'device_unavailable': ('The graphics card is temporarily unavailable. Try again later.', - '显卡暂时不可用,请稍后重试。'), - 'worker_exit': ('Image generation stopped unexpectedly. Try again.', '图像生成意外停止,请重试。'), - 'timeout': ('This image exceeded the time limit and was stopped. Try again.', '此图片生成超时,已停止。请重试。'), - 'generation_failed': ('Image generation encountered an error. Try again.', '图像生成出错,请重试。'), - 'invalid_media': ('The reference image cannot be read. Choose another image.', - '无法读取参考图,请更换一张。'), + '图像生成模型的文件读不出来。在启动器里修复后再试。'), + 'device_unavailable': ('The graphics card is not available right now. Try again later.', + '显卡暂时用不了,稍后再试。'), + 'worker_exit': ('Image generation stopped unexpectedly. Try again.', '图像生成意外停止了,请再试一次。'), + 'timeout': ('This image took too long and was stopped. Try again.', '这一张用时太长,已停止。请再试一次。'), + 'generation_failed': ('Image generation ran into an error. Try again.', '图像生成出错了,请再试一次。'), + 'invalid_media': ('The reference image could not be read. Choose another one.', + '参考图读不出来,请换一张。'), 'invalid_request': ('The image settings are invalid. Adjust them and try again.', '图片设置有误,请调整后重试。'), - 'busy': ('An image is already being generated. Try again when it finishes.', '已有图片正在生成,请在完成后重试。'), - 'cancelled': ('Image generation was cancelled.', '已取消图像生成。'), + 'busy': ('Another picture is being generated. Try again when it finishes.', '已有一张图在生成,完成后再试。'), + 'cancelled': ('The picture was cancelled.', '已取消这张图片。'), } @@ -256,35 +256,35 @@ def safe_details(value): FAILURE_REASONS_ZH = { 'lan_unavailable': '局域网设置需要启动器安装记录', 'out_of_memory': '生成时显存或内存不足', - 'insufficient_memory': '可用显存或内存不足,无法生成这段内容', - 'no_memory': '可用显存或内存不足,无法生成这张图片', + 'insufficient_memory': '可用显存或内存不够生成这段内容', + 'no_memory': '可用显存或内存不够生成这张图片', 'ram_space': '加载模型时系统内存不足', - 'model_missing': '所需的模型尚未安装', - 'pack_missing': '图片模型尚未安装', - 'not_ready': '生成所需的环境尚未安装', + 'model_missing': '需要的模型还没有安装好', + 'pack_missing': '图片模型还没有安装好', + 'not_ready': '生成所需的环境还没有安装好', 'cancelled': '生成已被停止', 'stopped_by_user': '生成已被停止', - 'interrupted': '生成过程被中断', - 'worker_exit': '图片生成过程被中断', - 'closed': '图片生成服务已关闭', - 'image_unavailable': '图片生成服务尚未启动', - 'vlm_unavailable': '读取画面的模型服务尚未启动', - 'vlm_failed': '未能识别画面', - 'vlm_invalid_json': '无法解析回复', - 'context_length': '描述和参考图超出长度上限', - 'dependency_failed': '所依赖的前序任务未完成', + 'interrupted': '生成中途被打断', + 'worker_exit': '图片生成中途被打断', + 'closed': '图片生成服务已经关闭', + 'image_unavailable': '图片生成服务还没有启动', + 'vlm_unavailable': '读取画面的模型服务还没有启动', + 'vlm_failed': '没能读出画面', + 'vlm_invalid_json': '回复读不出来', + 'context_length': '描述和参考图太长', + 'dependency_failed': '前面依赖的任务没有完成', 'lost': '生成服务没有保留这次任务的结果', 'unknown_job': '图片生成服务找不到这次任务', 'not_found': '找不到这次任务或素材', - 'source_missing': '接续所需的上一段视频已不存在', - 'chain_missing': '接续所需的生成记录已不存在', - 'chain_unavailable': '分段生成服务尚未启动', + 'source_missing': '接续所需的上一段视频已经不在', + 'chain_missing': '接续所需的生成记录已经不在', + 'chain_unavailable': '分段生成服务还没有启动', 'continuation_unavailable': '当前生成方式不支持接续视频', 'join_failed': '片段已生成,但拼接成片时出错', 'invalid_output': '生成的结果无法读取或保存', 'storage_unavailable': '任务记录或素材无法写入磁盘', 'submit_interrupted': '提交生成时服务中断,尚未确认任务是否开始', - 'timeout': '生成超时,未返回结果', + 'timeout': '生成等了太久没有结果', 'invalid_media': '参考素材无法读取', 'clip_unreadable': '生成的角色参考视频无法读取', 'invalid_request': '生成参数不符合支持的范围', @@ -297,8 +297,8 @@ def safe_details(value): 'too_many_images': '参考图片数量超过了支持的范围', 'chain_too_long': '接续后的视频超过了支持的段数', 'chain_rejected': '生成服务无法使用这组参数', - 'generation_failed': '生成未完成', - 'internal_error': '未完成', + 'generation_failed': '生成没有完成', + 'internal_error': '没有完成', } FAILURE_MESSAGES_ZH = { 'The previous video is too short to continue; it needs to be at least 1.7 s long.': '接续的上一段视频太短,至少需要 1.7 秒', @@ -322,7 +322,7 @@ def failure_reason(error): return FAILURE_MESSAGES_ZH[line] if line.startswith(('CUDA out of memory', 'MPS backend out of memory')): return FAILURE_REASONS_ZH['out_of_memory'] - return FAILURE_REASONS_ZH.get(error.get('code'), '未完成') + return FAILURE_REASONS_ZH.get(error.get('code'), '没有完成') def with_reason(error): diff --git a/freevideo_engine/agent/guide.py b/freevideo_engine/agent/guide.py index ea287873..e051ce29 100644 --- a/freevideo_engine/agent/guide.py +++ b/freevideo_engine/agent/guide.py @@ -268,7 +268,7 @@ '先用 people(角色图和各自的位置)做出首帧,再用它生成视频。', '默认切镜头。能切镜头的地方就切:新镜头直接用对应的参考图(人物卡、场景图)生成,不接上一段视频。' '只有不能切镜的长镜头、必须连贯的动作、必须连贯的声音,才接着上一段生成,而且不建议一段接一段地连续接。' - '一条视频最多 5 段(第一段加 4 次继续生成),接续生成的每一段至少 10 秒,总长约 70 秒;不建议接满,超过就切镜头。', + '一条视频最多 5 段(第一段加 4 次接着生成),接着生成的每一段至少 10 秒,总长约 70 秒;不建议接满,超过就切镜头。', '问话不是要求。用户问进度或状态(“在做吗”“怎么了”)时,用一句话照实回答,依据是你所知道的、任务和白模的状态,' '什么都不要开始;不要把问句当提示词。某条 inbox 带有 whitebox 时,说明用户刚启动了白模场景,' '它还在准备或已经失败:照实说是哪一种,再等用户回话。', @@ -288,7 +288,7 @@ ('GET', '/jobs/{id}'): '查看任务进度和结果', ('GET', '/jobs/{id}/events'): '持续接收任务进度', ('POST', '/jobs/{id}/cancel'): '取消任务', - ('POST', '/jobs/{id}/resume'): '继续生成一条视频中尚未完成的部分', + ('POST', '/jobs/{id}/resume'): '接着生成一条视频里还没完成的部分', ('POST', '/media'): '上传图片、视频或音频内容', ('POST', '/imports'): '导入视频或音频到剪辑时间线;轮询 GET /imports 等待就绪', ('GET', '/imports'): '列出已导入素材和准备进度', @@ -481,16 +481,16 @@ def connection_guide(root, base, lang=None, offers=None, resumed=False): '连接后先调一次等待接口 GET ' + base + PREFIX + '/inbox?wait=50(MCP 用不带 id 的 freevideo_wait),' '回应里的 session 就是用户现在所在的对话(用户新开或换到另一段对话时它会变,话总是发到最近一次等待给的那个 session);往 POST ' + base + PREFIX + '/sessions/{session}/events 发 ' '{"type":"message","text":"…"}(MCP 用 freevideo_session_post)。在这里先只说一句话,不超过 20 个字,' - '不列条目、不举例(页面上已经有例句),例如“已连接。请描述想要生成的视频。”' + '不列条目、不举例(页面上已经有例句),例如“连上了。告诉我想做什么视频。”' '你自己窗口里的那段介绍照常说,但它不是这一轮的结尾:说完紧接着去调等待接口。' '然后守在这段对话里:再调等待接口,它最多约 50 秒返回;"again": true 表示用户还没说话,' '马上再调下一次,一次接一次,不限次数。没有消息不是结束的理由:不要结束这一轮回复,' '不要停下来问用户——你一停,用户在 FreeVideo 里说的话就没人听了。' '用命令行调接口时,每次等待单独发一条命令(一条命令里不写循环,超时设到 60 秒以上),' '返回后接着发下一条。' - '收到要求(inbox 里每条的 text)先回一句再动手,例如“开始生成。”:只一句,不复述尺寸、' - '时长这些参数,也不说预计时间(任务卡上都有)。做完再说一句短的,例如“已完成。”,' - '或者一句用户需要知道的话,发回同一个 session,再接着等。先把话发出去,再调等待接口;不要写成“等待返回后才发话”,那样这句话会晚到将近一分钟。中文用全角标点。对用户称“您”,回复用书面语。' + '收到要求(inbox 里每条的 text)先回一句再动手,例如“好的,开始生成。”:只一句,不复述尺寸、' + '时长这些参数,也不说预计时间(任务卡上都有)。做完再说一句短的,例如“好了。”,' + '或者一句用户需要知道的话,发回同一个 session,再接着等。先把话发出去,再调等待接口;不要写成“等待返回后才发话”,那样这句话会晚到将近一分钟。中文用全角标点。' '做完一条视频也不是结尾:这句话就是汇报,不要再在自己窗口里写总结(一写总结这一轮就结束了,' '用户的下一句话没人听),紧接着再调等待接口,空等多少轮都接着等。' '这段对话里不要新建会话。只有用户明确让你停,才停。' if zh else @@ -499,7 +499,7 @@ def connection_guide(root, base, lang=None, offers=None, resumed=False): 'without id). Its "session" is the conversation the user is in (it changes when the user opens or returns to another one; always post to the session the latest wait named); POST ' + base + PREFIX + '/sessions/{session}/events ' 'with {"type":"message","text":"..."} (MCP: freevideo_session_post). Say only one short sentence ' 'there first, at most 12 words, no list and no examples (the page already shows examples), for example ' - '"Connected. Describe the video you want.", in the language the user writes to you in. Give the introduction in your own window as usual, but it ' + '"Connected. Tell me what video you want.", in the language the user writes to you in. Give the introduction in your own window as usual, but it ' 'does not end your turn: right after it, call the wait. Then stay in this conversation: call the wait again; it answers within about 50 seconds, and ' '"again": true means the user has not spoken yet, so call it again at once, time after time, with no ' 'limit. Nothing new is not a reason to finish: do not end your reply and do not stop to ask the ' @@ -507,16 +507,16 @@ def connection_guide(root, base, lang=None, offers=None, resumed=False): 'each wait is its own command (no loop inside one command, timeout above 60 seconds); when it ' 'returns, run the next one. ' 'When a request arrives (text of each inbox row; a question about progress is answered, not started), answer with one sentence before you start, for example ' - '"Starting generation.": one sentence only, without repeating size or length and without an estimate (the ' + '"OK, starting.": one sentence only, without repeating size or length and without an estimate (the ' 'job card shows them). When it is done say one short sentence, for example "Done.", or one thing the ' - 'user needs to know, to the same session, and wait again. Post your words first and call the wait after; never wait and then post, or they reach the user up to a minute late. Chinese text takes full-width punctuation. Address the user formally (您 in Chinese) and reply in a formal written register. ' + 'user needs to know, to the same session, and wait again. Post your words first and call the wait after; never wait and then post, or they reach the user up to a minute late. Chinese text takes full-width punctuation. ' 'A finished video is not the end either: that sentence is the report, so write no summary in your own ' 'window (a summary ends your turn, and then nobody hears the next thing the user says); call the wait ' 'again right away, and keep waiting however many waits come back empty. Do not create another session for this conversation. ' 'Stop only when the user clearly tells you to.', rules=RULES_ZH if zh else RULES, fallback='如果你无法执行命令、读取本地文件或连接到这台电脑,请如实告诉用户:' - '“无法连接到您电脑上的 FreeVideo,请改用安装在这台电脑上的 AI 助手。”' + '“我连接不到你电脑上的 FreeVideo,请改用安装在这台电脑上的 AI 助手。”' '用中文说明;如果用户改用其他语言,就跟随用户。不要另装程序来绕过这个限制。' if zh else 'If you cannot run commands, read local files, or reach this computer, tell the user honestly ' 'in their language: I cannot connect to FreeVideo on your computer; please use an AI assistant ' diff --git a/freevideo_engine/chain_runner.py b/freevideo_engine/chain_runner.py index 8e8cd4ad..bf37a1f3 100644 --- a/freevideo_engine/chain_runner.py +++ b/freevideo_engine/chain_runner.py @@ -24,7 +24,7 @@ class ChainTooLong(ValueError): def __init__(self, parts, limit): super().__init__('This video can have up to %d parts. Make it shorter.' % limit) self.parts, self.limit = parts, limit - self.message_zh = '这条视频最多 %d 段,请缩短。' % limit + self.message_zh = '这条视频最多 %d 段,请缩短一些。' % limit class ChainPartTooShort(ValueError): @@ -34,7 +34,7 @@ class ChainPartTooShort(ValueError): def __init__(self, seconds, limit): super().__init__('Each continued part must be at least %d seconds. Make it longer.' % limit) self.seconds, self.limit = seconds, limit - self.message_zh = '接续生成的每一段至少 %d 秒,请延长。' % limit + self.message_zh = '接着生成的每一段至少 %d 秒,请加长一些。' % limit def _work_key(path): diff --git a/freevideo_engine/comfy_launcher.py b/freevideo_engine/comfy_launcher.py index ca756653..d3111c38 100644 --- a/freevideo_engine/comfy_launcher.py +++ b/freevideo_engine/comfy_launcher.py @@ -20,7 +20,7 @@ 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.', '正在启动,就绪后将自动打开浏览器') +BROWSER_STARTING = ('Still starting. Your browser will open automatically when ready.', '还在启动,好了会自动打开') class Launcher: @@ -242,7 +242,7 @@ def show_options(self): if name == 'url': self.listen_entry = entry self.label(body, "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。', + '填 0.0.0.0 或本机的局域网 IP 后,同一网络中的设备不需要访问链接就能打开 FreeVideo。', style='Muted.TLabel', wraplength=580).pack(fill='x') if sys.platform == 'win32': self.label(body, FIREWALL_HINT[0], FIREWALL_HINT[1], style='Muted.TLabel', wraplength=580).pack(fill='x') @@ -258,7 +258,7 @@ def show_options(self): if variable is self.lan_access: self.ttk.Label(body, textvariable=self.lan_message, style='Muted.TLabel').pack(fill='x') self.label(body, '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。持有链接的人均可使用,请勿公开。', + '开启后,同一网络中的其他设备可以用下面的链接打开 FreeVideo。拿到链接的人都能使用,请勿公开。', style='Muted.TLabel', wraplength=580).pack(fill='x') if sys.platform == 'win32': self.label(body, FIREWALL_HINT[0], FIREWALL_HINT[1], style='Muted.TLabel', wraplength=580).pack(fill='x') @@ -546,7 +546,7 @@ def refresh(self): ('Ready to install', '准备安装'), ('FreeVideo', 'FreeVideo')] # One line each. What a control does is stated once, next to the control. descriptions = [('Choose how you want to use ComfyUI.', '选择 ComfyUI 的使用方式。'), - ('Download automatically, download manually, or reuse existing models.', '自动下载、手动下载,或复用已有模型。'), + ('Download automatically, download yourself, or reuse existing models.', '自动下载、手动下载,或复用已有模型。'), ('Review, then install.', '确认后开始安装。'), ('Start ComfyUI and open the FreeVideo workflow.', '启动 ComfyUI 并打开 FreeVideo 工作流。')] if self.wizard_step == 3: @@ -565,8 +565,8 @@ def refresh(self): (('Installing FreeVideo', '正在安装 FreeVideo'), ('Preparing models and dependencies.', '正在准备模型和运行环境。')), 'failed': (('Installation paused', '安装已暂停'), ('Files retained. Details below.', '文件已保留,原因见下方。')), 'cancelled': (('Installation paused', '安装已暂停'), ('Downloaded files are reused.', '已下载文件会复用。')), - 'open': (('Ready', '准备完成'), ('ComfyUI is ready. Open FreeVideo in your browser.', 'ComfyUI 已就绪,可在浏览器中打开 FreeVideo。')), - 'restart-required': (('Restart required', '需要重启'), ('Restart ComfyUI, then connect below.', '请重启 ComfyUI,然后点击下方连接。')), + 'open': (('You’re ready', '准备完成'), ('ComfyUI is ready. Open FreeVideo in your browser.', 'ComfyUI 已就绪,可在浏览器中打开 FreeVideo。')), + 'restart-required': (('One last step', '最后一步'), ('Restart ComfyUI, then connect below.', '请重启 ComfyUI,然后点击下方连接。')), }.get(state, (headings[2], descriptions[2])) self.heading.configure(text=self.t(*headings[self.wizard_step])) self.status.set(self.t(*descriptions[self.wizard_step])) diff --git a/freevideo_engine/comfy_launcher_api.py b/freevideo_engine/comfy_launcher_api.py index 0353800b..a2e14d5a 100644 --- a/freevideo_engine/comfy_launcher_api.py +++ b/freevideo_engine/comfy_launcher_api.py @@ -29,13 +29,13 @@ def page_report(value): 'Press Ctrl+F5 to reload the page. If it stays missing, open the FreeVideo launcher, ' 'click Settings › Repair, and open FreeVideo again. ' '浏览器没有运行 FreeVideo 的页面脚本,所以看不到创作面板。请按 Ctrl+F5 强制刷新页面;' - '如仍未显示,请在 FreeVideo 启动器的“设置”中点击“修复”,然后重新打开 FreeVideo。') + '仍然没有时,请在 FreeVideo 启动器的“设置”里点击“修复”,然后重新打开 FreeVideo。') # Without the launcher, the page's own Settings cannot open either. PAGE_SCRIPTS_MISSING_NODE = ( 'FreeVideo’s page scripts have not run in this browser, so the creative workspace is missing. ' 'Press Ctrl+F5 to reload the page. If it stays missing, update the FreeVideo node and restart ComfyUI. ' '浏览器没有运行 FreeVideo 的页面脚本,所以看不到创作面板。请按 Ctrl+F5 强制刷新页面;' - '如仍未显示,请更新 FreeVideo 节点并重启 ComfyUI。') + '仍然没有时,请更新 FreeVideo 节点并重启 ComfyUI。') # Where a node message sends the user to repair. The launcher has its own # Repair; a ComfyUI started without it has the page's Settings › Install / repair. diff --git a/freevideo_engine/desktop.py b/freevideo_engine/desktop.py index aefc74b1..a2e9dfc9 100644 --- a/freevideo_engine/desktop.py +++ b/freevideo_engine/desktop.py @@ -241,7 +241,7 @@ def setup_page(self, page): def generate_page(self, page): _, page = self.scrolled(page, 1) - self.label(page, 'Describe your video', '描述要生成的视频').pack(anchor='w', pady=(0, 10)) + self.label(page, 'Describe your video', '描述你想生成的视频').pack(anchor='w', pady=(0, 10)) prompt_card = Card(page, padding=branding.space(3), radius=branding.RADIUS_MD) prompt_card.pack(fill='x') self.prompt = self.tk.Text(prompt_card.body, height=6, font=(branding.FAMILY, branding.STRONG), @@ -516,7 +516,7 @@ def open_path(self, path): if path and io_path(path).exists(): os.startfile(plain(path)) else: - messagebox.showinfo('FreeVideo', self.t('No result yet.', '暂无结果。'), parent=self.window) + messagebox.showinfo('FreeVideo', self.t('No result yet.', '目前还没有结果。'), parent=self.window) @plain_errors def open_report(self): @@ -618,7 +618,7 @@ def refresh_progress_times(self): if finished: pieces.append(self.t('Complete', '完成') if value['state'] == 'complete' else self.t('Stopped', '已停止')) elif remaining == 0: - pieces.append(self.t('Finalizing', '正在完成')) + pieces.append(self.t('Finalizing', '正在收尾')) elif remaining is not None: pieces.append(self.t('ETA ~', '预计剩余约 ') + duration(remaining)) else: diff --git a/freevideo_engine/desktop_shortcut.py b/freevideo_engine/desktop_shortcut.py index 7815e249..2b0dea9a 100644 --- a/freevideo_engine/desktop_shortcut.py +++ b/freevideo_engine/desktop_shortcut.py @@ -322,7 +322,7 @@ def _failure(error, menu_ready): text = str(error) denied = isinstance(error, PermissionError) or '0x80070005' in text.lower() or 'access is denied' in text.lower() en = ['The desktop shortcut could not be created:'] - zh = ['未能创建桌面快捷方式:'] + zh = ['桌面快捷方式没有创建成功:'] if denied: # Controlled folder access (Windows Security) refuses unknown programs on the desktop. en.append('Windows did not allow FreeVideo to write to the desktop. If Controlled folder access is on in ' @@ -332,7 +332,7 @@ def _failure(error, menu_ready): else: en.append(text); zh.append(text) if menu_ready: - en.append('FreeVideo is in the Start menu.'); zh.append('可在开始菜单中找到 FreeVideo。') + en.append('FreeVideo is in the Start menu.'); zh.append('开始菜单里可以找到 FreeVideo。') return ' '.join(en), ''.join(zh) @@ -542,4 +542,4 @@ def create_after_install(engine, source, portable_root=None): return create(engine, source, portable_root) except (OSError, ValueError) as error: return dict(status='failed', error='Installed successfully, but the desktop shortcut could not be created: ' + str(error), - error_zh='安装已完成,但未能创建桌面快捷方式:' + str(error)) + error_zh='安装已完成,但桌面快捷方式没有创建成功:' + str(error)) diff --git a/freevideo_engine/download_settings.py b/freevideo_engine/download_settings.py index df5d0850..a06fb78e 100644 --- a/freevideo_engine/download_settings.py +++ b/freevideo_engine/download_settings.py @@ -21,7 +21,7 @@ def speed_text(row, zh=False): rate = row.get('bytes_per_second') if (row.get('speed_measured') is False or not isinstance(rate, (int, float)) or not math.isfinite(rate) or rate <= 0): - return '已连通,尚未测速' if zh else 'Connected; speed not measured yet' + return '已连通,速度还没测出' if zh else 'Connected; speed not measured yet' speed = rate / 2**20 return '<0.01 MiB/s' if speed < .005 else (('%.2f' if speed < 1 else '%.1f') % speed + ' MiB/s') diff --git a/freevideo_engine/download_settings_ui.py b/freevideo_engine/download_settings_ui.py index 67b1ab64..2424b6bb 100644 --- a/freevideo_engine/download_settings_ui.py +++ b/freevideo_engine/download_settings_ui.py @@ -56,7 +56,7 @@ def __init__(self, parent, root, zh=False, probe=None, token=None, busy=None): self.speeds.pack(fill='both', expand=True) ttk.Label(body, text=self.t( 'Switching takes effect at once. Downloads resume where possible; otherwise the file restarts. Testing speeds does not switch.', - '切换后立即生效,支持续传的文件继续下载,其余文件重新下载。仅测速不会切换。'), + '切换后马上生效,能续传的接着下载,不能的重新下载这个文件。只测速不会切换。'), wraplength=560, style='Muted.TLabel').pack(fill='x', pady=(12, 4)) ttk.Label(body, text=str(self.root), wraplength=560, style='Muted.TLabel').pack(fill='x') ttk.Button(body, text=self.t('Done', '完成'), command=window.destroy).pack(anchor='e', pady=(8, 0)) diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index c2b84085..faa352a8 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -25,12 +25,12 @@ '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 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.', '图像模型文件不完整或已损坏,请在启动器中修复。'), + '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.', '图像模型包被其他任务占用,请在其完成后重试。'), - 'busy_video': ('A video is using the graphics card; try again when it finishes.', '视频正在使用显卡,请在完成后重试。'), - 'needs_repair': ('The image model files are incomplete or damaged; repair them in the launcher.', '图像模型文件不完整或已损坏,请在启动器中修复。'), + 'busy': ('Another task is using the image model package; try again when it finishes.', '图像模型包被其他任务占用,完成后再试。'), + 'busy_video': ('A video is using the graphics card; try again when it finishes.', '视频正在使用显卡,完成后再试。'), + 'needs_repair': ('The image model files are incomplete or damaged; repair them in the launcher.', '图像模型文件不完整或已损坏,请在启动器里修复。'), 'changed': ('The image model files changed during the check; they were kept, so check again.', '图像模型文件在检查期间发生变化,已保留,请重新检查。'), 'unsafe_path': ('The image model folder contains a link that cannot be safely changed.', '图像模型目录含有无法安全修改的链接。'), 'failed': ('The image model operation stopped; see the diagnostic report for details.', '图像模型操作已停止,详情见诊断报告。'), @@ -106,15 +106,15 @@ 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),但下载未完成。' - '请确认网络连接后重新生成,FreeVideo 会自动下载。') % (what, size) + title = '“%s”还没有下载' % QUALITY_LEVELS[index][1] + what = '这一档需要的文件' + detail = ('这次生成要先下载%s(约 %s),但没能下载完。' + '请确认网络连接后再生成一次,FreeVideo 会自动下载。') % (what, size) if available: - detail += '也可以改用已可使用的%s档。' % available + detail += '也可以改用已经可以使用的%s档。' % available else: if index is None: title = 'The sampling table for Custom (%d steps) is not downloaded yet' % details['base_steps'] @@ -122,7 +122,7 @@ def sampling_table_failure(value, *, zh=False): else: title = "The %s quality level's sampling table is not downloaded yet" % QUALITY_LEVELS[index][0] what = "that level's sampling table" - detail = ('This video first needs %s (about %s), and the download did not finish. ' + detail = ('This video first needs %s (about %s), and it could not be downloaded this time. ' 'Check the network connection and generate again; FreeVideo downloads it automatically.') % (what, size) if available: detail += ' You can also switch to a quality level that is already available: %s.' % available @@ -212,23 +212,23 @@ def environment_python_removed(args, pythons): # Checked before free space: freeing space cannot fix a disk that cannot hold the installation. PLACE_RULES = ( (r'The installation folder is on a FAT32 disk', 'disk-format', - ('This disk cannot hold the models', '这块硬盘无法存放模型'), + ('This disk cannot hold the models', '这块硬盘放不下模型'), ('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.', '它不能存放 4 GB 以上的文件。' '请选择其他硬盘上的文件夹。')), (r'The installation folder is on an exFAT disk', 'disk-format', - ('This disk cannot hold the FreeVideo environment', '这块硬盘无法安装 FreeVideo 的运行环境'), + ('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.', '它不能安装 FreeVideo。' '请选择其他硬盘上的文件夹。')), (r'The installation folder is on a disk that is not connected', 'disk-missing', - ('The disk is not connected', '这块硬盘未连接'), + ('The disk is not connected', '这块硬盘没有连接'), ('Connect the disk that holds the installation folder, then check again, or choose another folder.', - '请连接安装位置所在的硬盘后重新检查,或选择其他位置。')), + '请接上安装位置所在的硬盘后重新检查,或者换一个位置。')), (r'The installation folder is on a read-only disk', 'disk-read-only', - ('This disk is 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.', '这块硬盘只能读取、不能写入,请选择其他硬盘上的文件夹。')), ) @@ -259,7 +259,7 @@ def _model_unchanged(tail): 'then click Replace package… under Import environment package and choose the downloaded environment package; ' 'if you are online, you can instead choose Automatic as the install method there. ' 'The models and videos in the current folder are not deleted.', - '请在左侧“安装”页选择“安装 ComfyUI”,把“安装位置”改为一个新文件夹,然后在“导入运行环境包”中点击“更换环境包…”' + '请在左侧“安装”页选择“帮我安装 ComfyUI”,把“安装位置”改为一个新文件夹,然后在“导入运行环境包”中点击“更换环境包…”' '并选择下载的运行环境包;能联网时,也可以在那里把安装方式改为“自动安装”。原文件夹里的模型和作品不会被删除。') WITHOUT_REPAIR = { # A model file that differs from the tested one; FreeVideo leaves it in place. @@ -275,7 +275,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.', - '当前任务结束后,此处将显示“修复”按钮,点击后会补装缺失的组件。'), + '当前任务结束后,这里会出现“修复”按钮,点击后会补装缺失的组件。'), 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], @@ -283,7 +283,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.', - '当前任务结束后,此处将显示“修复”按钮,点击后会重新安装运行组件,模型和视频都会保留。'), + '当前任务结束后,这里会出现“修复”按钮,点击后会重新安装运行组件,模型和视频都会保留。'), 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], @@ -294,13 +294,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 已退回原来的版本,可以照常使用;原因见详情。')), + '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 已退回原来的版本,可以照常使用。要再次更新,请在“安装”页保持同一个安装目录,依次点“继续”“检查并继续”和“安装并启动”。')), } @@ -403,8 +403,8 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep failure['path'] = paths[-1] if paths else mismatches[-1] return failure if disk_name and 'on a disk that is not connected' in summary: - return dict(title='这块硬盘未连接' if zh else 'The disk is not connected', kind='disk-missing', detail='', - action=('请连接“%s”后点击“重新检查”' % disk_name + (',或改为安装到其他硬盘。' if other_disk else '。') if zh else + return dict(title='这块硬盘没有连接' if zh else 'The disk is not connected', kind='disk-missing', detail='', + action=('请接上“%s”后点击“重新检查”' % disk_name + (',或者改装到其他硬盘。' if other_disk else '。') if zh else 'Connect "%s" and click Check again' % disk_name + (', or install on another disk.' if other_disk else '.'))) folder = 'Select the ComfyUI folder, or the portable folder containing ComfyUI.' # A saved installation that vanished raises the same sentence after its own message; the @@ -432,7 +432,7 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep from .disk_space import size_text sizes = tuple(size_text(float(value) * 2**30) for value in disk.groups()) # The figures and what to do go on their own lines, so a narrow window breaks between them. - detail = (('需要约 %s,可用 %s。\n再释放 %s 后点击“重新检查”' % sizes) + (',或改为安装到其他硬盘。' if other_disk else '。') + detail = (('需要约 %s,可用 %s。\n再释放 %s 后点击“重新检查”' % sizes) + (',或者改装到其他硬盘。' if other_disk else '。') if zh else ('Needs about %s; %s available.\nFree up %s and click Check again' % sizes) + (', or install on another disk.' if other_disk else '.')) else: @@ -445,8 +445,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 'ComfyUI quit while starting.', - 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: @@ -455,9 +455,9 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep if zh: detail = detail.replace('Failed step: kernels (exit code ', '失败步骤:GPU 检查(退出码 ').replace(')\n', ')\n', 1) detail = detail.replace('Required linear kernel did not pass.', - '显卡必需的运算检查未通过。' if zh else 'A required GPU check 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.') + '显卡加速检查没有通过。' 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, @@ -488,13 +488,13 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep '请使用 39、56、73、90 等每次增加 17 的帧数;接续时,这个数包含开头重复的 22 帧。')), (r'Continue from video cannot be used with these settings\. Continuing a video needs an NVIDIA', 'continuation-route', ("This computer can't continue a video", '这台电脑无法接续视频'), - ('Continuing a video needs an NVIDIA GPU or a Mac. To make a new video, clear "Continue from video".', '接续视频需要 NVIDIA 显卡或 Mac。要生成新视频,请清空“继续生成”。')), + ('Continuing a video needs an NVIDIA GPU or a Mac. To make a new video, clear "Continue from video".', '接续视频需要 NVIDIA 显卡或 Mac。要生成新视频,请清空“接着生成”。')), (r'Continue from video cannot be used with these settings\. Turn on "Two-pass', 'continuation-passes', ('Continuing needs two-pass acceleration', '接续需要二次采样加速'), - ('Turn on "Two-pass acceleration" and turn off the first-pass preview.', '请打开“二次采样加速”,并关闭“先生成预览”。')), + ('Turn on "Two-pass acceleration" and turn off the first-pass preview.', '请打开“二次采样加速”,并关闭先出预览。')), (r'Continue from video cannot be used with these settings\. Clear either', 'continuation-refine', ('A continued part is made in full', '接续会完整生成这一段'), - ('Clear either "Second pass of preview" or "Continue from video".', '请清空“继续二采”或“继续生成”其中一项。')), + ('Clear either "Second pass of preview" or "Continue from video".', '请清空“继续二采”或“接着生成”其中一项。')), (r'Continue from video cannot be used with these settings\. Connect up to 3 pictures', 'continuation-media', ('A continued part takes picture references only', '接续段只能用图片参考'), ('Connect up to 3 pictures as references, and remove video, audio and pre-encoded inputs.', '最多接 3 张图片作为参考图,并移除视频、音频参考和预编码输入。')), @@ -504,14 +504,14 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep '请选择 FreeVideo 生成且仍存在的 MP4 视频;在 ComfyUI 中,请把它放在输出或输入文件夹内。')), # An engine update waits for a video being generated, whoever started this installation's ComfyUI. (r'ComfyUI is running a job, so the engine update waits', 'update-busy', - ('A video is still generating', '仍有视频正在生成'), + ('A video is still generating', '还有视频正在生成'), ('Click Update now again when this video has finished. The video being generated is not interrupted.', - '请在该视频生成完成后再点击“立即更新”。正在生成的视频不会被中断。')), + '这个视频生成完成后,再点击“立即更新”。正在生成的视频不会被打断。')), # Moving the engine's editable install to the new source failed and was undone (engine_binding). (r'Editable install failed for|Editable verification failed for|The engine.s uv is missing', 'update-unfinished', - ('The update did not finish', '本次更新未完成'), + ('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.', @@ -520,22 +520,22 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep r'Existing dependency is not a Git checkout of the pinned source', 'offline-installation', ('This folder holds an offline installation', '这个位置是离线包安装'), ('Open it from the launcher. For an automatic installation, go back to Setup and choose another folder.', - '请在启动页直接打开这份安装。如需自动安装,请返回安装设置,选择其他安装位置。')), + '请在启动页直接打开这份安装。如需自动安装,请回到安装设置,换一个安装位置。')), # Both carry ComfyUI's log, whose unrelated lines must not pick the card. (r'ComfyUI is still starting or could not load FreeVideo', 'comfy-nodes', - ('ComfyUI did not load FreeVideo', 'ComfyUI 未加载 FreeVideo'), + ('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.', - '请点击“重试”。如仍停在此处,可导出报告。')), + '请点“重试”。仍然停在这里时,可以导出报告。')), (r'ComfyUI is already running\. Restart it once', 'comfy-restart', ('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 未完整加载'), + (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'), + '请按浏览器页面上的提示处理后刷新;仍然出现时,可以导出报告。')), + (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.', '请点击“打开 FreeVideo”。如果页面空白或一直在加载,请用最新版 Chrome 或 Edge 打开。')), (r'NVIDIA driver/nvidia-smi is missing\.', 'gpu-driver-missing', @@ -550,7 +550,7 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep (r'CUDA_CAPABILITY_UNKNOWN', 'gpu-detection', ('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.', @@ -562,11 +562,11 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep (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', '仍有视频正在生成'), + ('A video is still generating', '还有视频正在生成'), ('The update is installed. Click Connect again after the job finishes to restart ComfyUI with it.', - '更新已安装。请在当前任务完成后点击“重新连接”,ComfyUI 会重启并使用新版。')), + '更新已装好。等当前任务完成后点“重新连接”,ComfyUI 会重启并使用新版。')), (r'Installation paused: Windows commit headroom', 'memory-commit', ('Windows is low on available memory', 'Windows 可用内存不足'), ('Close memory-heavy applications, then retry. Downloaded files are retained.', @@ -578,19 +578,19 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep (r'Installation RAM monitoring failed|Cannot read process-tree memory', 'memory-monitor', ('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.', '清理安装盘空间后,点击重试。')), (r'Model download paused|unexpected-transfer-size|ConnectionError|ConnectTimeout|ReadTimeout|HTTP probe failed|Could not resolve host|SSL certificate|Every download source failed|All Git sources failed|All package sources failed|Python download failed on every route', 'download', - ('Download interrupted', '下载已中断'), + ('Download interrupted', '下载中断了'), ('Check your network or change the source or connection mode in Settings → Downloads, then retry.', '检查网络,或在“设置 → 下载”中切换下载源、连接模式后重试。')), (r'FreeVideo cannot read its program files', 'unreadable-files', - ('FreeVideo cannot read its program files', 'FreeVideo 无法读取程序文件'), + ('FreeVideo cannot read its own files', 'FreeVideo 读不到自己的程序文件'), ('Windows denies this account access to them, usually because they were created while FreeVideo ran as administrator. ' 'Open FreeVideo normally and import the offline packages again; the files already there are reused.', 'Windows 不允许当前账户读取它们,通常是因为这些文件是在“以管理员身份运行”时创建的。' - '请以普通方式打开 FreeVideo,重新导入离线包,已有的文件会直接复用。')), + '请正常打开 FreeVideo,重新导入一次离线包,已有的文件会直接复用。')), (r'A file in the installation folder changed after the review', 'plan-changed-files', CHANGED_TITLE, _joined(CHANGED_WHY['files'], CHANGED_REPAIR)), (r'The installation plan changed\. Detect configuration again', 'plan-changed', CHANGED_TITLE, @@ -599,10 +599,10 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep ('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 所在的文件夹,然后点击“重试安装”。')), + '可能被安全软件隔离了。恢复这个文件或允许 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 some runtime files', 'Windows 已拦截部分运行组件'), + ('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', @@ -612,7 +612,7 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep (r"FreeVideo's PyTorch library files do not match", 'runtime-libraries', ('Runtime files have been changed', '运行组件已被更改'), ('Click Repair to reinstall a complete copy of PyTorch. Models and videos are kept.', - '请点击“修复”,FreeVideo 会重新安装运行组件,模型和视频都会保留。')), + '请点“修复”,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|os error 1455', 'memory', ('Memory is unavailable for this step', '这一步可用内存不足'), diff --git a/freevideo_engine/image_gen/failure.py b/freevideo_engine/image_gen/failure.py index 0b5b8e60..c7cde206 100644 --- a/freevideo_engine/image_gen/failure.py +++ b/freevideo_engine/image_gen/failure.py @@ -20,16 +20,16 @@ STAGES = ('start', 'load', 'text', 'encode', 'denoise', 'decode') KINDS = ('generate', 'edit', 'cast', 'compose', 'download', 'install') MESSAGE_ZH = { - 'no_memory': '显存不足,最小尺寸也未能生成。请关闭其他占用显卡的程序后重试。', + 'no_memory': '显存不够,最小尺寸也没能生成。关掉其他占用显卡的程序后再试。', 'small_memory': '这台 Mac 的内存不足 16 GB,无法生成图片。', - 'busy_video': '视频正在生成,请在完成后重试。', - 'busy': '已有图片正在生成,请在完成后重试。', - 'pack_missing': '请先在启动器中下载“图像生成模型”。', - 'model_unreadable': '无法读取图像生成模型文件,请在启动器中修复后重试。', - 'device_unavailable': '显卡暂时不可用,请稍后重试。', - 'worker_exit': '图像生成意外停止,请重试。', - 'timeout': '此图片生成超时,已停止。请重试。', - 'generation_failed': '图像生成出错,请重试。', + 'busy_video': '视频正在生成,完成后再试。', + 'busy': '已有一张图在生成,请等它完成。', + 'pack_missing': '请先在启动器里下载“图像生成模型”。', + 'model_unreadable': '图像生成模型的文件读不出来。在启动器里修复后再试。', + 'device_unavailable': '显卡暂时用不了,稍后再试。', + 'worker_exit': '图像生成意外停止了,请再试一次。', + 'timeout': '这一张用时太长,已停止。请再试一次。', + 'generation_failed': '图像生成出错了,请再试一次。', 'invalid_request': '图片设置有误,请调整后重试。', 'invalid_media': '图片无效,请重新选择输入目录中的图片。点选支持不超过 4096×4096 像素总数的图片。', 'invalid_points': '请选择 1–16 个图片范围内的点,且至少有一个选中点;1 为选中,0 为排除。', @@ -51,7 +51,7 @@ _MPS_MEMORY = re.compile(r'MPS backend out of memory', re.I) _LIMIT = 16 * 1024 # On a Mac the GPU's memory is the machine's: the same failure, in its words. -UNIFIED_MESSAGE_ZH = {'no_memory': '内存不足,最小尺寸也未能生成。请关闭其他程序后重试。'} +UNIFIED_MESSAGE_ZH = {'no_memory': '内存不够,最小尺寸也没能生成。关掉其他程序后再试。'} class FailureError(ValueError): diff --git a/freevideo_engine/launcher/Main.qml b/freevideo_engine/launcher/Main.qml index 26460bda..5b26d445 100644 --- a/freevideo_engine/launcher/Main.qml +++ b/freevideo_engine/launcher/Main.qml @@ -107,12 +107,12 @@ ApplicationWindow { var d = disk if (!d) return "" var en = "\"" + d.name + "\"", zh = "“" + d.name + "”" - 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 + " cannot be used for 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. Choose another disk.", zh + " 不能写入,请选择其他硬盘。") + 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. 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 + "。") + zh + "可用 " + d.free + ",这次安装需要约 " + d.need + "。") return t(d.name + " · " + d.free + " available", d.name + " · 可用 " + d.free) + (d.need ? t(" · about " + d.need + " needed", " · 需要约 " + d.need) : "") } @@ -132,23 +132,23 @@ ApplicationWindow { function upgradeHeadline() { var u = upgrade 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 === "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 后切换") if (u.status === "downloaded" || (u.status === "switching" && !s.busy)) return t("Switching after the current video", "当前视频完成后切换") if (u.status === "switching") return t("Switching to the faster model", "正在切换到更快的模型") if (u.status === "releasing") return u.waiting ? t("Removing the old model after the current video", "当前视频完成后删除旧模型") : t("Removing the old model", "正在删除旧模型") - if (u.status === "releasable") return u.in_use === "int8_convrot" ? t("The old model is still on disk", "旧模型仍占用磁盘空间") - : t("Some model files are not in use", "有未使用的模型文件") + if (u.status === "releasable") return u.in_use === "int8_convrot" ? t("The old model is still on disk", "旧模型还占着磁盘空间") + : t("Some model files are not in use", "有没在使用的模型文件") if (u.status === "complete" && u.in_use) return u.in_use === "int8_convrot" ? t("The old model was removed", "旧模型已删除") - : t("Unused model files were removed", "未使用的模型文件已删除") + : t("Unused model files were removed", "没在使用的模型文件已删除") if (u.status === "complete") return t("Upgraded to the faster model", "已升级到更快的模型") - if (u.status === "failed") return u.step === "release" ? t("The old model was not removed", "旧模型未删除") : t("The upgrade stopped", "升级未完成") + if (u.status === "failed") return u.step === "release" ? t("The old model was not removed", "旧模型没有删除") : t("The upgrade stopped", "升级未完成") return "" } 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("The faster model is not in use, so the old model is kept.", "当前未使用更快的模型,旧模型已保留。") + 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("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.", "当前使用的模型不是标准安装的模型,为安全起见没有删除任何文件。") } @@ -164,31 +164,31 @@ ApplicationWindow { : 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") - return t("It needs about ", "需要约 ") + bytes(u.required_bytes) + t(" free; ", " 可用空间,当前可用 ") + bytes(u.free_bytes) - + t(" is free. Clean the download cache in Settings → Storage or free some space, then reopen FreeVideo.", "。请在“设置 → 存储”中清理下载缓存,或释放空间后重新打开 FreeVideo。") + return t("It needs about ", "需要约 ") + bytes(u.required_bytes) + t(" free; ", " 可用空间,目前剩 ") + bytes(u.free_bytes) + + t(" is free. Clean the download cache in Settings → Storage or free some space, then reopen FreeVideo.", "。可以在 设置 → 存储 清理下载缓存,或者腾出空间后重新打开 FreeVideo。") if (u.status === "downloading") return u.progress && u.progress.unit === "bytes" && u.progress.total ? bytes(u.progress.done) + " / " + bytes(u.progress.total) + t(" · you can keep generating", " · 可以继续生成") : t("Checking the download…", "正在检查下载内容…") if (u.status === "downloaded" && u.waiting === "comfy") 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 无法重启它。关闭它后会自动开始切换,在此之前当前模型照常可用。") + "这个 ComfyUI 不是启动器打开的,FreeVideo 没法重启它。关闭它后会自动开始切换,在此之前当前模型照常可用。") if (u.status === "downloaded" || u.status === "switching") 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 faster model. Removing the old model frees about ", "已在使用更快的模型,删除旧模型可释放约 ") + bytes(u.release_bytes) - + t("; only its own files are removed.", ",仅删除旧模型的文件。") + 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("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 === "releasing") return t("Only the old model's own files are removed.", "只删除旧模型自己的文件。") if (u.status === "complete") return (u.released_bytes ? t("Freed ", "已释放 ") + bytes(u.released_bytes) + t(".", "。") : "") + (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 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.", "原模型未改变,可继续使用。")) + + (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 "" } function updateHeadline() { @@ -403,7 +403,7 @@ ApplicationWindow { Item { height: shortWindow ? 0 : 16 } ColumnLayout { Layout.fillWidth: true; spacing: 8; Layout.bottomMargin: shortWindow ? 0 : 10 - FText { objectName: "pageHeading"; horizontalAlignment: Text.AlignHCenter; text: s.page === "launcher" ? t("Workspace", "工作空间") : s.page === "comfy" ? t("Set up FreeVideo", "安装 FreeVideo") : s.page === "models" ? t("Set up your models", "准备模型") : s.status === "review" ? t("Ready to install", "确认安装") : s.busy ? t("Setting up FreeVideo", "正在准备 FreeVideo") : t("Continue your setup", "继续安装"); font.pixelSize: win.compact ? 26 : theme.hero; font.weight: Font.DemiBold; font.letterSpacing: -0.6; Layout.fillWidth: true } + FText { objectName: "pageHeading"; horizontalAlignment: Text.AlignHCenter; text: s.page === "launcher" ? t("Your workspace", "你的工作空间") : s.page === "comfy" ? t("Set up FreeVideo", "安装 FreeVideo") : s.page === "models" ? t("Set up your models", "准备模型") : s.status === "review" ? t("Ready to install", "确认安装") : s.busy ? t("Setting things up", "正在准备 FreeVideo") : t("Continue your setup", "继续安装"); font.pixelSize: win.compact ? 26 : theme.hero; font.weight: Font.DemiBold; font.letterSpacing: -0.6; Layout.fillWidth: true } FText { objectName: "actionNotice"; visible: !!s.action_notice; text: s.action_notice || ""; color: theme.muted; Layout.fillWidth: true; horizontalAlignment: Text.AlignHCenter } } @@ -438,7 +438,7 @@ ApplicationWindow { Flow { Layout.fillWidth: true; spacing: 8 FButton { objectName: "moveToDisk"; visible: diskFailure && s.form.new_comfy && !!bestDisk; enabled: !s.busy; primary: true - text: bestDisk ? t("Install on \"" + bestDisk.name + "\"", "改为安装到“" + bestDisk.name + "”") : "" + text: bestDisk ? t("Install on \"" + bestDisk.name + "\"", "改装到“" + bestDisk.name + "”") : "" 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 } @@ -475,7 +475,7 @@ ApplicationWindow { Layout.fillWidth: true; spacing: 12 FChoice { objectName: "newComfyMethod"; Layout.fillWidth: true; Layout.minimumWidth: 0; Layout.preferredWidth: 1; Layout.fillHeight: true - text: t("Install ComfyUI", "安装 ComfyUI") + text: t("Install ComfyUI", "帮我安装 ComfyUI") detail: t("Prepare ComfyUI and its environment.", "准备 ComfyUI 和运行环境。") checked: s.form.new_comfy; enabled: !s.busy onClicked: backend.edit("new_comfy", true) @@ -587,16 +587,16 @@ ApplicationWindow { } RowLayout { visible: usingRuntime; Layout.fillWidth: true - FText { objectName: "runtimeReadyOnModels"; text: t("✓ Environment ready · next, prepare the models", "✓ 运行环境已就绪 · 接下来准备模型"); color: theme.success; Layout.fillWidth: true } + FText { objectName: "runtimeReadyOnModels"; text: t("✓ Environment ready · now the models", "✓ 运行环境已就绪 · 接下来准备模型"); color: theme.success; Layout.fillWidth: true } FButton { text: t("Change", "更改"); flat: true; enabled: !s.busy; onClicked: backend.action("back", false) } } - FText { text: usingRuntime ? t("Choose a model source", "选择模型来源") : t("Choose how to download the remaining files", "选择下载方式"); font.pixelSize: theme.strong; font.weight: Font.DemiBold; Layout.fillWidth: true; Layout.topMargin: 6 } + FText { text: usingRuntime ? t("Where should the models come from?", "选择模型来源") : t("How would you like to get the rest?", "选择下载方式"); font.pixelSize: theme.strong; font.weight: Font.DemiBold; Layout.fillWidth: true; Layout.topMargin: 6 } RowLayout { Layout.fillWidth: true; spacing: 12 FChoice { objectName: "automaticMethod"; Layout.fillWidth: true; Layout.minimumWidth: 0; Layout.preferredWidth: 1; Layout.fillHeight: true text: t("Automatic download", "自动下载") - detail: usingRuntime ? t("Download the models into this offline installation.", "将模型下载到此离线安装中。") : t("Download only what's missing.", "自动补齐缺少的文件。") + detail: usingRuntime ? t("Download the models into this offline installation.", "把模型下载到这个离线安装里。") : t("Download only what's missing.", "自动补齐缺少的文件。") checked: !offlineSelected; enabled: !s.busy onClicked: backend.edit("model_method", "auto") } @@ -618,7 +618,7 @@ ApplicationWindow { } FButton { objectName: "downloadSource"; text: s.source_name + " ›"; flat: true; implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1; onClicked: { settingsTab = "downloads"; settingsOpen = true } } } - FText { visible: usingRuntime; text: t("An interrupted download resumes; downloaded files are kept.", "下载中断后可继续,已下载的文件会保留。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } + FText { visible: usingRuntime; text: t("An interrupted download resumes; downloaded files are kept.", "下载中断后可以接着下载,已下载的文件不会丢。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } FMeter { visible: usingRuntime && s.offline.status === "downloading"; Layout.fillWidth: true; fraction: win.fraction(s.offline); active: visible } FText { visible: usingRuntime && s.offline.status === "downloading"; text: t("Downloading models", "下载模型") + (number(s.offline.total) ? " · " + bytes(s.offline.done) + " / " + bytes(s.offline.total) : ""); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } } @@ -826,14 +826,14 @@ ApplicationWindow { FButton { // Repair and opening FreeVideo come first; the banner's colour already marks the update. id: engineUpdate; objectName: "engineUpdateButton"; primary: !s.repair_offered && s.status !== "open" && !(updateFirst && !s.retry_kind); visible: !s.update.phase; enabled: !s.busy - text: s.update.status === "error" ? t("Retry update", "重试更新") : s.update.status === "ready" && s.update.candidate ? t("Restart & update", "重启并更新") : s.update.manual && s.update.candidate ? t("Download ↗", "前往下载 ↗") : t("Update now", "立即更新") + text: s.update.status === "error" ? t("Retry update", "重试更新") : s.update.status === "ready" && s.update.candidate ? t("Restart & update", "重启并更新") : s.update.manual && s.update.candidate ? t("Download ↗", "去下载 ↗") : t("Update now", "立即更新") onClicked: { manualUpdate = !!s.update.candidate && !s.update.manual; backend.update("") } } } FMeter { visible: s.update.phase === "downloading"; Layout.fillWidth: true; fraction: s.update.progress && s.update.progress.total ? s.update.progress.done / s.update.progress.total : -1; active: visible } FText { visible: !s.update.phase && !!text; text: releaseSummary(availableRelease); Layout.fillWidth: true; color: theme.muted; font.pixelSize: theme.micro } FButton { text: t("What's new", "更新内容"); flat: true; visible: !s.update.phase && !!availableRelease; implicitHeight: theme.heightSm; onClicked: releaseNotesOpen = true } - FButton { objectName: "updateBannerManualDownload"; text: t("Download from the release page ↗", "前往发布页手动下载 ↗"); flat: true; visible: s.update.status === "error" && !!s.update.release_page; implicitHeight: theme.heightSm; onClicked: backend.link(s.update.release_page) } + FButton { objectName: "updateBannerManualDownload"; text: t("Download from the release page ↗", "去发布页手动下载 ↗"); flat: true; visible: s.update.status === "error" && !!s.update.release_page; implicitHeight: theme.heightSm; onClicked: backend.link(s.update.release_page) } FButton { objectName: "updateBannerReport"; text: t("Report a problem", "提交问题"); flat: true; visible: s.update.status === "error" && !!s.update.error; implicitHeight: theme.heightSm; onClicked: backend.reportProblem(s.update.error, t("Update", "更新")) } } FCard { @@ -890,9 +890,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 ComfyUI after the update, then select \"Connect again\".", "更新后需重启 ComfyUI,再点“重新连接”。") : s.status === "failed" ? t("ComfyUI did not start. See the message above.", "ComfyUI 未能启动,原因见上方。") : t("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 } + 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 } @@ -1222,7 +1222,7 @@ 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: 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 } + 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" @@ -1240,7 +1240,7 @@ ApplicationWindow { 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 } + 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 } @@ -1270,7 +1270,7 @@ ApplicationWindow { 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 } + 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 @@ -1306,7 +1306,7 @@ ApplicationWindow { } } } - FText { objectName: "lanRemedyHint"; visible: Qt.platform.os === "windows" && !!s.form.lan_access; text: t("If the phone cannot open the page, 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 } + 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", "存储") @@ -1373,7 +1373,7 @@ ApplicationWindow { objectName: "modelStorageStatus"; Layout.fillWidth: true; font.pixelSize: theme.micro; color: theme.muted; wrapMode: Text.WordWrap 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.", "。") + : 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() } } @@ -1407,7 +1407,7 @@ 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: 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 { 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 { @@ -1473,8 +1473,8 @@ ApplicationWindow { id: diskContents; spacing: 12 FText { text: t("Choose a disk", "选择安装硬盘"); font.pixelSize: theme.section + 2; font.weight: Font.DemiBold; Layout.fillWidth: true } FText { objectName: "diskDialogText"; Layout.fillWidth: true; color: theme.muted; font.pixelSize: theme.micro + 1 - text: (disk && disk.need ? t("This installation needs about " + disk.need + ".\n", "本次安装需要约 " + disk.need + "。\n") : "") - + t("FreeVideo is installed in a FreeVideo folder on the disk you choose.", "FreeVideo 将安装在所选硬盘的 FreeVideo 文件夹中。") } + text: (disk && disk.need ? t("This installation needs about " + disk.need + ".\n", "这次安装需要约 " + disk.need + "。\n") : "") + + t("FreeVideo is installed in a FreeVideo folder on the disk you choose.", "FreeVideo 会装在所选硬盘的 FreeVideo 文件夹里。") } Repeater { model: diskChoices delegate: Rectangle { @@ -1493,7 +1493,7 @@ ApplicationWindow { FText { visible: !modelData.enough; Layout.fillWidth: true; color: theme.danger; font.pixelSize: theme.micro text: modelData.problem === "exfat" ? t("Cannot install", "无法安装") : modelData.problem === "fat32" ? t("Cannot install", "无法安装") - : t("Not enough space", "空间不足") } + : 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 } } @@ -1706,9 +1706,9 @@ ApplicationWindow { spacing: 14 FText { objectName: "updateActionNotice"; visible: !!s.action_notice; text: s.action_notice || ""; color: theme.muted; Layout.fillWidth: true } FText { text: s.update.candidate ? t("Update available", "有可用更新") : s.update.engine ? t("Engine update", "引擎更新") : t("Updates", "更新"); font.pixelSize: theme.section + 2; font.weight: Font.DemiBold } - FText { objectName: "updateDialogText"; text: s.update.phase === "waiting" ? t("A video is still generating. FreeVideo restarts and updates as soon as it finishes.", "仍有视频正在生成,完成后将自动重启并更新。") : s.update.status === "ready" && s.update.candidate ? t("Downloaded. Restart to finish; models and settings are kept.", "下载完成,重启后完成更新,模型和设置都会保留。") : s.update.candidate && s.update.manual ? t("FreeVideo ", "FreeVideo ") + releaseVersion(s.update.candidate) + t(" is available (current ", " 已发布(当前 ") + releaseVersion(currentRelease) + t("). Download it and replace this version; models and settings are kept.", ")。下载新版后替换当前版本,模型和设置会保留。") : s.update.candidate ? t("FreeVideo ", "FreeVideo ") + releaseVersion(s.update.candidate) + t(" is available (current ", " 已发布(当前 ") + releaseVersion(currentRelease) + t("). Updating keeps your models and settings. FreeVideo restarts after the download; running videos finish first.", ")。更新会保留模型和设置,下载完成后自动重启;正在生成的视频会先完成。") : s.update.engine ? t("This launcher already includes engine ", "启动器已带有新版引擎 ") + releaseVersion(currentRelease) + t(" (installed: ", "(已安装 ") + engineVersion + (s.status === "open" ? t("). Updating takes about a minute, restarts ComfyUI once and keeps your models and settings.", ")。更新约需 1 分钟,会重启一次 ComfyUI,模型和设置都会保留。") : t("). Updating takes about a minute, then FreeVideo starts; models and settings are kept.", ")。更新约需 1 分钟,完成后自动启动,模型和设置都会保留。")) : s.update.status === "current" ? t("You're up to date.", "已是最新版本。") : s.update.status === "development" ? t("Running from source. Update with Git.", "当前从源码运行,请通过 Git 更新。") : s.update.status === "error" ? t("Could not check for updates.", "暂时无法检查更新。") : t("Checking the latest release…", "正在检查最新版本…"); color: theme.muted; Layout.fillWidth: true } + FText { objectName: "updateDialogText"; text: s.update.phase === "waiting" ? t("A video is still generating. FreeVideo restarts and updates as soon as it finishes.", "还有视频正在生成,完成后会自动重启并更新。") : s.update.status === "ready" && s.update.candidate ? t("Downloaded. Restart to finish; models and settings are kept.", "下载完成,重启即可完成更新,模型和设置都会保留。") : s.update.candidate && s.update.manual ? t("FreeVideo ", "FreeVideo ") + releaseVersion(s.update.candidate) + t(" is available (current ", " 已发布(当前 ") + releaseVersion(currentRelease) + t("). Download it and replace this version; models and settings are kept.", ")。下载新版后替换当前版本,模型和设置会保留。") : s.update.candidate ? t("FreeVideo ", "FreeVideo ") + releaseVersion(s.update.candidate) + t(" is available (current ", " 已发布(当前 ") + releaseVersion(currentRelease) + t("). Updating keeps your models and settings. FreeVideo restarts after the download; running videos finish first.", ")。更新会保留模型和设置,下载完成后自动重启;正在生成的视频会先完成。") : s.update.engine ? t("This launcher already includes engine ", "启动器已带有新版引擎 ") + releaseVersion(currentRelease) + t(" (installed: ", "(已安装 ") + engineVersion + (s.status === "open" ? t("). Updating takes about a minute, restarts ComfyUI once and keeps your models and settings.", ")。更新约需 1 分钟,会重启一次 ComfyUI,模型和设置都会保留。") : t("). Updating takes about a minute, then FreeVideo starts; models and settings are kept.", ")。更新约需 1 分钟,完成后自动启动,模型和设置都会保留。")) : s.update.status === "current" ? t("You're up to date.", "已是最新版本。") : s.update.status === "development" ? t("Running from source. Update with Git.", "当前从源码运行,请通过 Git 更新。") : s.update.status === "error" ? t("Could not check for updates.", "暂时无法检查更新。") : t("Checking the latest release…", "正在检查最新版本…"); color: theme.muted; Layout.fillWidth: true } FText { visible: !!s.update.error; text: s.update.error || ""; color: theme.danger; Layout.fillWidth: true; font.pixelSize: theme.micro } - FButton { objectName: "updateManualDownloadButton"; text: t("Download from the release page ↗", "前往发布页手动下载 ↗"); flat: true; Layout.fillWidth: true; visible: s.update.status === "error" && !!s.update.release_page; onClicked: backend.link(s.update.release_page) } + FButton { objectName: "updateManualDownloadButton"; text: t("Download from the release page ↗", "去发布页手动下载 ↗"); flat: true; Layout.fillWidth: true; visible: s.update.status === "error" && !!s.update.release_page; onClicked: backend.link(s.update.release_page) } FButton { objectName: "updateDialogReport"; text: t("Report a problem", "提交问题"); flat: true; Layout.fillWidth: true; visible: s.update.status === "error" && !!s.update.error; onClicked: backend.reportProblem(s.update.error, t("Update", "更新")) } FMeter { Layout.fillWidth: true; visible: s.update.status === "downloading"; active: true; fraction: s.update.progress && s.update.progress.total ? s.update.progress.done/s.update.progress.total : -1 } FReleaseNotes { objectName: "updateReleaseNotes"; visible: !!availableRelease; Layout.fillWidth: true; release: availableRelease; earlier: availableEarlier; zh: s.zh; heading: t("What's new", "更新内容") + " · " + releaseVersion(availableRelease) } @@ -1719,7 +1719,7 @@ ApplicationWindow { id: updateActions; Layout.fillWidth: true FButton { objectName: "updateLaterButton"; text: s.update.candidate || s.update.engine ? t("Later", "稍后更新") : t("Close", "关闭"); flat: true; onClicked: { manualUpdate = false; backend.dismissUpdate() } } Item { Layout.fillWidth: true } - FButton { objectName: "updateNowButton"; text: s.update.status === "error" ? t("Retry update", "重试更新") : s.update.status === "ready" && s.update.candidate ? t("Restart & update", "重启并更新") : s.update.manual && s.update.candidate ? t("Download ↗", "前往下载 ↗") : s.update.candidate || s.update.engine ? t("Update now", "立即更新") : t("Check again", "重新检查"); primary: true; enabled: ["checking","downloading"].indexOf(s.update.status) < 0 && s.update.phase !== "waiting"; onClicked: { manualUpdate = (!!s.update.candidate || !s.update.engine) && !(s.update.manual && s.update.candidate); backend.update("") } } + FButton { objectName: "updateNowButton"; text: s.update.status === "error" ? t("Retry update", "重试更新") : s.update.status === "ready" && s.update.candidate ? t("Restart & update", "重启并更新") : s.update.manual && s.update.candidate ? t("Download ↗", "去下载 ↗") : s.update.candidate || s.update.engine ? t("Update now", "立即更新") : t("Check again", "重新检查"); primary: true; enabled: ["checking","downloading"].indexOf(s.update.status) < 0 && s.update.phase !== "waiting"; onClicked: { manualUpdate = (!!s.update.candidate || !s.update.engine) && !(s.update.manual && s.update.candidate); backend.update("") } } } } } @@ -1828,11 +1828,11 @@ ApplicationWindow { } FDialog { objectName: "releaseConfirmation"; visible: releaseConfirm - title: upgrade.in_use === "int8_convrot" ? t("Remove the old model?", "删除旧模型?") : t("Remove unused model files?", "删除未使用的模型文件?") + title: upgrade.in_use === "int8_convrot" ? t("Remove the old model?", "删除旧模型?") : t("Remove unused model files?", "删除没在使用的模型文件?") 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 model in use, your videos and settings are not touched.", "。正在使用的模型、视频和设置均不受影响。") - : t(". The model in use, your videos and settings are not touched.", "。正在使用的模型、视频和设置均不受影响。")) + : t("The model files FreeVideo does not use are removed, about ", "将删除 FreeVideo 没在使用的模型文件,释放约 ")) + bytes(upgrade.release_bytes || 0) + + (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() } onRejected: releaseConfirm = false diff --git a/freevideo_engine/launcher_copy.py b/freevideo_engine/launcher_copy.py index 3fd05f1b..37d07145 100644 --- a/freevideo_engine/launcher_copy.py +++ b/freevideo_engine/launcher_copy.py @@ -10,17 +10,17 @@ 'download_missing': ('The download folder no longer exists. Export the list again.', '下载目录已不存在,请重新导出清单'), 'installation_missing': ('There is no installation information yet. Complete installation first.', - '尚无安装信息,请先完成安装'), + '还没有安装信息,请先完成安装'), 'checking_update': ('Checking for updates. Please wait.', '正在检查更新,请稍候'), 'update_install_failed': ('The update could not be installed. You can keep using the current version.', '无法安装更新。当前版本可以照常使用。'), 'update_page_failed': ('Couldn\'t open the download page. Please try again later.', '无法打开下载页,请稍后再试。'), - 'busy': ('A background task is running. Try again when it finishes.', - '后台任务进行中,请在完成后重试'), + 'busy': ('The background task is busy. You can do this when it finishes.', + '后台正在忙,完成后可以操作'), 'import_queued': ('Saved. Importing will start when the current task finishes.', - '已保存,当前任务完成后将自动导入'), + '已记下,处理完当前任务后自动导入'), 'setup_queued': ('Models are being prepared in the background. Setup will continue when they are ready.', - '后台正在准备模型,完成后将继续安装'), + '后台正在准备模型,好了会接着继续'), 'update_queued': ('The update will start after the current task finishes.', '当前任务完成后会自动更新'), } @@ -183,7 +183,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。', 'This port and the next ones are in use or reserved by the system. Set a different local ComfyUI address.': '这个端口和后面的端口都已被占用或被系统保留,请换一个本机 ComfyUI 地址。', - 'The ports for LAN access are in use or reserved by the system. Choose another ComfyUI port.': '局域网访问所需的端口已被占用或被系统保留,请更换 ComfyUI 端口。', + 'The ports for LAN access are in use or reserved by the system. Choose another ComfyUI port.': '局域网访问要用的端口已被占用或被系统保留,请换一个 ComfyUI 端口。', 'A generation is running. Change the address after it finishes.': '正在生成,完成后可修改地址。', 'The ComfyUI already running does not use this address. After its jobs finish, restart it with this address and connect again.': '已在运行的 ComfyUI 没有使用这个地址。请在任务完成后用这个地址重新启动 ComfyUI,再重新连接。', 'The ComfyUI already running is open to the network directly. After its jobs finish, restart it on 127.0.0.1, then turn on LAN access.': '已在运行的 ComfyUI 正在直接对局域网开放。请在任务完成后改用 127.0.0.1 重新启动它,再开启局域网访问。', diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index d525f676..9ee8c5d4 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -854,8 +854,8 @@ def prompt_model_view(self): failure = '' if active.get('state') == 'failed': failure = {'disk_space': self.t('Not enough disk space.', '磁盘空间不足。'), - 'busy': self.t('Another download of this model is running.', '该模型正在由其他任务下载。')}.get( - active.get('reason'), self.t('The download stopped. Resume to continue.', '下载已中断,点击“继续”可恢复下载。')) + 'busy': self.t('Another download of this model is running.', '这个模型正在被另一处下载。')}.get( + active.get('reason'), self.t('The download stopped. Resume to continue.', '下载中断了,点继续可接着下载。')) return dict(ready=ready, checking=checking, total_bytes=total, enough_disk=enough, busy=busy, install=active, failure=failure, downloaded_bytes=active.get('done', 0), title=self.t(*NAMES['text']), suggest=bool(engine) and not ready and not checking and not busy and enough and not self.form['prompt_hint_dismissed'] @@ -1024,7 +1024,7 @@ def open_browser(self): try: if not open_browser(address): raise OSError(self.t("Couldn't open the browser. Click Copy address and open it in your browser.", - '未能自动打开浏览器,请点击“复制地址”后在浏览器中打开。')) + '没能自动打开浏览器,请点“复制地址”,在浏览器里打开。')) if moved_from: self.port_notice_url = self.browser_url self.browser_error = '' @@ -1663,7 +1663,7 @@ def adaln_dialog_view(self, error, update): body=self.t('This frees %s of disk space. If the files are needed later, they will be downloaded again automatically.', '可释放 %s 磁盘空间。以后如需要,会自动重新下载。') % size, delete_text=(self.t('Deleting…', '正在删除…') if thread is not None else - self.t('Try again', '重试') if self.adaln_retry else self.t('Delete', '删除')), + self.t('Try again', '再试一次') if self.adaln_retry else self.t('Delete', '删除')), later_text=self.t('Not now', '暂不删除')) def sync_lan_from_store(self): @@ -1840,10 +1840,10 @@ def copied_models_text(self): return '' if value.get('copied_model_reason') == 'exfat': return self.t('This disk uses exFAT, so %s of models had to be copied instead of shared with your model library.', - '该硬盘为 exFAT 格式,%s 的模型只能复制,无法与模型库共用。') % size_text(copied) + '这块硬盘是 exFAT 格式,有 %s 的模型只能复制,不能和模型库共用。') % size_text(copied) return self.t('%s of models were copied from another disk. Keep your model library on the same disk as FreeVideo ' 'to avoid this.', - '%s 的模型从其他硬盘复制而来。将模型库与 FreeVideo 放在同一硬盘上,可避免占用这部分额外空间。') % size_text(copied) + '有 %s 的模型是从另一块硬盘复制来的。把模型库和 FreeVideo 放在同一块硬盘上,就不会多占这部分空间。') % size_text(copied) def archive_offer(self): """The imported ZIPs whose contents the running installation holds, verified.""" @@ -1978,7 +1978,7 @@ def archives_dialog_view(self, offer, idle, error, update): 'Deleting them does not affect models, the runtime or your videos.', '导入过的离线包压缩包仍保留在磁盘上,FreeVideo 已不再需要。删除后,模型、运行环境和作品都不受影响。'), delete_text=(self.t('Deleting…', '正在删除…') if self.archives_busy else - self.t('Try again', '重试') if retry else + self.t('Try again', '再试一次') if retry else self.t('Delete and free %s', '删除并释放 %s') % size), keep_text=self.t('Keep for now', '暂时保留'), footnote=self.t('You can also delete them later in Settings › Preferences › Storage.', diff --git a/freevideo_engine/launcher_update.py b/freevideo_engine/launcher_update.py index 984f60c8..9c8c8bc2 100644 --- a/freevideo_engine/launcher_update.py +++ b/freevideo_engine/launcher_update.py @@ -132,7 +132,7 @@ def error_text(row, zh=False): """Product copy stays separate from raw exceptions, URLs and credentials.""" messages = { 'rate_limited': ('The update service is busy.', '更新服务繁忙。'), - 'missing': ('The files for the new version are still being published.', '新版本的文件仍在发布中。'), + 'missing': ('The files for the new version are still being published.', '新版本的文件还在发布中。'), 'blocked': ('The update service could not be reached.', '无法访问更新服务。'), 'connection': ('Could not connect to the download server.', '无法连接下载服务器。'), 'invalid': ('The downloaded update files are incomplete or damaged.', '下载的更新文件不完整或已损坏。'), @@ -145,7 +145,7 @@ def error_text(row, zh=False): if row.get('retry_at'): return text + ('稍后会自动重试,当前版本可以照常使用。' if zh else ' FreeVideo will try again later. You can keep using the current version.') - return text + ('请重试,或前往发布页手动下载。' if zh else + return text + ('请重试,或去发布页手动下载。' if zh else ' Try again, or download it from the release page.') diff --git a/freevideo_engine/launcher_view.py b/freevideo_engine/launcher_view.py index 12e6fc27..9ce17936 100644 --- a/freevideo_engine/launcher_view.py +++ b/freevideo_engine/launcher_view.py @@ -257,8 +257,8 @@ def wheel(event): choices.columnconfigure((0, 1), weight=1, uniform='choices') app.mode_buttons = [] for index, (value, title, detail) in enumerate(( - ('existing', ('Use existing ComfyUI', '使用已有 ComfyUI'), ('Connect your installation.', '接入现有安装。')), - ('new', ('Install ComfyUI', '安装 ComfyUI'), ('ComfyUI, Python and FreeVideo together.', '一并准备 ComfyUI、Python 和 FreeVideo。')))): + ('existing', ('I have ComfyUI', '我已安装 ComfyUI'), ('Connect your installation.', '接入现有安装。')), + ('new', ('Install ComfyUI for me', '帮我安装 ComfyUI'), ('ComfyUI, Python and FreeVideo together.', '一并准备 ComfyUI、Python 和 FreeVideo。')))): choice = Choice(choices, app, value, title, detail) choice.grid(row=0, column=index, sticky='nsew', padx=(0, b.space(2)) if index == 0 else (b.space(2), 0)) app.mode_buttons.append(choice) @@ -275,7 +275,7 @@ def wheel(event): methods = ttk.Frame(models.body, style='Card.TFrame'); methods.pack(fill='x') app.model_method_buttons = [] for value, en, zh in (('auto', 'Automatic download', '自动下载'), - ('manual', 'Download manually', '手动下载'), ('reuse', 'Use existing models', '复用已有模型')): + ('manual', 'Download myself', '手动下载'), ('reuse', 'Use existing models', '复用已有模型')): button = ttk.Radiobutton(methods, text=app.t(en, zh), value=value, variable=app.model_method) button.pack(side='left', padx=(0, b.space(3))) app.labels.append((button, en, zh)); app.model_method_buttons.append(button) @@ -358,8 +358,8 @@ def wheel(event): app.label(launch.body, 'Ready to launch', '安装已就绪', style='Hero.Card.TLabel', anchor='center').pack(fill='x', pady=(0, b.space(4))) app.launch_paths = ttk.Label(launch.body, style='Meta.Card.TLabel', anchor='center', justify='center', wraplength=680) app.launch_paths.pack(fill='x', pady=(0, b.space(3))) - app.label(launch.body, 'If ComfyUI is already running, FreeVideo connects to it automatically.', - '如果 ComfyUI 已在运行,将自动连接。', style='Meta.Card.TLabel', anchor='center', justify='center', wraplength=680).pack(fill='x') + app.label(launch.body, 'Already running ComfyUI? FreeVideo connects automatically.', + 'ComfyUI 已在运行?会自动连接。', style='Meta.Card.TLabel', anchor='center', justify='center', wraplength=680).pack(fill='x') app.browser_controls = ttk.Frame(launch.body, style='Card.TFrame') ttk.Entry(app.browser_controls, textvariable=app.browser_address, state='readonly', justify='center').pack(fill='x') browser_buttons = ttk.Frame(app.browser_controls, style='Card.TFrame'); browser_buttons.pack(pady=(8, 0)) diff --git a/freevideo_engine/model_guidance.py b/freevideo_engine/model_guidance.py index 868f1b2e..de39f4e3 100644 --- a/freevideo_engine/model_guidance.py +++ b/freevideo_engine/model_guidance.py @@ -46,10 +46,10 @@ 'error_write': ('The download list could not be saved in this folder: {reason}', '下载清单未能保存到这个文件夹:{reason}'), 'error_read': ('FreeVideo does not have permission to read some files in the selected folder. Choose another folder, or download the files again without administrator rights.', 'FreeVideo 没有权限读取所选文件夹里的部分文件。请选择其他文件夹,或不使用管理员身份重新下载。'), - 'write_permission': ('this account cannot write to it. Choose another folder.', '当前账户没有写入权限,请选择其他文件夹。'), + 'write_permission': ('this account cannot write to it. Choose another folder.', '当前账户没有写入权限,请换一个文件夹。'), 'write_disk_full': ('the drive is full.', '磁盘空间不足。'), 'write_path_long': ('the path is too long. Choose a folder closer to the top of the drive.', '路径太长,请选择层级更浅的文件夹。'), - 'write_other': ('Choose another folder and try again.', '请更换文件夹后重试。'), + 'write_other': ('Choose another folder and try again.', '请换一个文件夹再试一次。'), 'video': NAMES['video'], 'text': NAMES['text'], 'decoder': NAMES['decoder'], 'sampling': NAMES['sampling'], 'readme': ( 'FreeVideo download list\n' @@ -71,7 +71,7 @@ '\n' '也可以把 urls.txt 导入其他下载工具。urls.txt 用的是设置里选中的下载源(选“自动”时用 Hugging Face);个别文件只能从 Hugging Face 下载。请把下载的文件都放在这个文件夹或它的子文件夹里。\n' '\n' - '下载完成后,回到启动器点“使用已下载的文件夹”,再点“检查并继续”。FreeVideo 按文件大小和完整校验值识别文件,文件名不同不影响识别;缺少或损坏的文件会照常下载。\n' + '下载完成后,回到启动器点“使用已下载的文件夹”,再点“检查并继续”。FreeVideo 按文件大小和完整校验值识别文件,文件名不同也可以;缺少或损坏的文件会照常下载。\n' '\n' 'SHA256SUMS 里的路径是 aria2 的存放路径。用 urls.txt 下载时文件名可能不同,不影响使用。\n' '\n' diff --git a/freevideo_engine/model_repair.py b/freevideo_engine/model_repair.py index d1137bd1..8e3ef9f7 100644 --- a/freevideo_engine/model_repair.py +++ b/freevideo_engine/model_repair.py @@ -27,8 +27,8 @@ '{file} differs from the version FreeVideo tests and was left unchanged. Click {entry} again: ' 'the confirmation page will list this file, and after you confirm, it will be renamed and kept, ' 'and the tested version will be downloaded.', - '{file} 与 FreeVideo 测试过的版本不同,本次未作改动。请再次点击“{entry}”:' - '确认页会列出该文件,确认后将其改名保留,再下载测试过的版本。', + '{file} 与 FreeVideo 测试过的版本不同,这次没有改动它。请再次点击“{entry}”:' + '确认页会列出这个文件,确认后将它改名保留,再下载测试过的版本。', ) CLI_FAILURE = FAILURE[0].replace('Click {entry} again', 'Run setup again (./setup.sh, or setup.cmd on Windows)') MISMATCH = 'MODEL_MISMATCH_REPAIR' @@ -36,19 +36,19 @@ # Where the launcher offers no Repair: the first sentence of FAILURE, then one way out # for each reason (failure_details.WITHOUT_REPAIR). UNCHANGED = ('{file} differs from the version FreeVideo tests and was left unchanged.', - '{file} 与 FreeVideo 测试过的版本不同,本次未作改动。') + '{file} 与 FreeVideo 测试过的版本不同,这次没有改动它。') BUSY = ('When the current task ends, a Repair button appears here; after you click it and confirm, ' 'FreeVideo renames and keeps this file, then downloads the tested version.', - '当前任务结束后,此处将显示“修复”按钮;点击并确认后,FreeVideo 会将该文件改名保留,再下载测试过的版本。') + '当前任务结束后,这里会出现“修复”按钮;点击并确认后,FreeVideo 会把这个文件改名保留,再下载测试过的版本。') OFFLINE = ('This offline-package installation cannot switch back in one step yet. Move or rename the file below, ' 'then import the Prompt model package (FreeVideo-文本编码器.zip) again on the Installation page.', - '离线包安装暂不支持一键换回。请将下方文件移动或重命名,再在“安装”页重新导入“提示词模型(FreeVideo-文本编码器.zip)”包。') + '离线包安装暂时不能一键换回。请把下面这个文件移走或改名,再在“安装”页重新导入“提示词模型(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, ' 'and the tested version is downloaded.', - '{file} 与 FreeVideo 测试过的版本不同,本次未作改动。请点击下方的“重试安装”:' - '确认页会列出该文件,确认后会将其改名保留,再下载测试过的版本。', + '{file} 与 FreeVideo 测试过的版本不同,这次没有改动它。请点击下方的“重试安装”:' + '确认页会列出这个文件,确认后会将它改名保留,再下载测试过的版本。', ) CHANGED = 'The installation plan changed. Detect configuration again and review the new plan before installing.' # A file the review listed changed before setup reached it (the confirmed plan @@ -60,11 +60,11 @@ other=('The settings, GPU or free disk space changed after you confirmed.', '确认之后设置、显卡或磁盘空间有变化。'), ) -CHANGED_REPAIR = ('Click Repair again.', '请再次点击“修复”。') +CHANGED_REPAIR = ('Click Repair again.', '请再点一次“修复”。') CHANGED_WAYS = dict( busy=('A Repair button appears here when the current task ends. Click it.', - '当前任务结束后,此处将显示“修复”按钮,请点击该按钮。'), - none=('Click Retry installation below.', '请点击下方的“重试安装”。'), + '当前任务结束后,这里会出现“修复”按钮,请点它。'), + none=('Click Retry installation below.', '请点下方的“重试安装”。'), ) IDENTITY = ('bytes', 'mtime_ns', 'st_dev', 'st_ino', 'parent_realpath') diff --git a/freevideo_engine/offline_errors.py b/freevideo_engine/offline_errors.py index 9694de2c..ade3d1b8 100644 --- a/freevideo_engine/offline_errors.py +++ b/freevideo_engine/offline_errors.py @@ -13,15 +13,15 @@ '安装文件夹的路径太长。请把 FreeVideo 安装到路径较短的文件夹,例如 D:\\FreeVideo。'), 'permission': ('The offline package could not be imported: this account cannot write to the chosen folder. ' 'Choose another folder and import again.', - '离线包未能导入:当前账户没有所选文件夹的写入权限。请选择其他文件夹后重新导入。'), + '离线包未能导入:当前账户没有所选文件夹的写入权限。请换一个文件夹再导入。'), 'disk': ('The offline package could not be imported: the drive is full. ' 'Free up space or choose a folder on another drive, then import again.', - '离线包未能导入:磁盘空间不足。请释放磁盘空间或选择其他磁盘上的文件夹,然后重新导入。'), + '离线包未能导入:磁盘空间不足。请腾出空间或换一个磁盘上的文件夹,再导入。'), 'path': ('The offline package could not be imported: the path of the chosen folder is too long to save some of ' 'the files. Choose a folder with a shorter path%s and import again.', - '离线包未能导入:所选文件夹的路径过长,部分文件无法保存。请选择路径更短的文件夹%s,然后重新导入。'), + '离线包未能导入:所选文件夹的路径太长,部分文件无法保存。请换一个路径更短的文件夹再导入%s。'), 'other': ('The offline package could not be imported. Choose another folder and import again.', - '离线包未能导入。请选择其他文件夹后重新导入。'), + '离线包未能导入。请换一个文件夹再导入。'), } # The example folder is a Windows path; other systems leave it out. PATH_EXAMPLE = (', such as D:\\FreeVideo,', ',例如 D:\\FreeVideo') diff --git a/freevideo_engine/offline_packages.py b/freevideo_engine/offline_packages.py index 4038201a..635bc40e 100644 --- a/freevideo_engine/offline_packages.py +++ b/freevideo_engine/offline_packages.py @@ -577,7 +577,7 @@ def download(runtime, source, sampling_caches, progress=lambda **kw: None, cance tail = (tail + line)[-4000:] if cancelled(): processes.stop(child) - raise InterruptedError('下载已暂停,已下载的文件已保留,再次开始时将继续下载。') + raise InterruptedError('已暂停下载,已下载的文件保留,再次开始会接着下载。') try: event = json.loads(line) except ValueError: diff --git a/freevideo_engine/page_check.py b/freevideo_engine/page_check.py index b5a4a2b0..68049921 100644 --- a/freevideo_engine/page_check.py +++ b/freevideo_engine/page_check.py @@ -52,8 +52,8 @@ def text(self, t, opened_at, address, now=None): return repair['text'][:2000] if report.get('state') == 'error': problems = [p for p in report.get('problems') or [] if isinstance(p, str) and p] - lines = [t('The browser page did not load FreeVideo completely.', '浏览器页面未完整加载 FreeVideo。')] - lines += ['- ' + p for p in problems] or ['- ' + t('No details were sent.', '页面未发送详情。')] + lines = [t('The browser page did not load FreeVideo completely.', '浏览器页面没有完整加载 FreeVideo。')] + lines += ['- ' + p for p in problems] or ['- ' + t('No details were sent.', '页面没有发送详情。')] if report.get('agent'): lines.append(t('Browser: ', '浏览器:') + str(report['agent'])) if report.get('frontend'): @@ -63,6 +63,6 @@ def text(self, t, opened_at, address, now=None): if not report.get('state') and opened_at is not None and now - opened_at >= SILENT_AFTER: return t('The browser has not opened FreeVideo yet. If no browser window appeared, click “Open FreeVideo”. ' 'If the page stays blank or keeps loading, open %s in a current Chrome or Edge.', - '浏览器尚未打开 FreeVideo。如果未弹出浏览器窗口,请点击“打开 FreeVideo”;' + '浏览器还没有打开 FreeVideo。如果没有弹出浏览器窗口,请点击“打开 FreeVideo”;' '如果页面空白或一直在加载,请用最新版 Chrome 或 Edge 打开 %s。') % address return '' diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index a4607c8b..069a80a2 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -47,8 +47,8 @@ "手机访问:同一网络下的手机可以通过顶栏的手机图标和二维码打开 FreeVideo,和 AI 全自动对话、查看生成进度。手机访问可以开关,也可以设置密码,手机登录后才能使用。", "Mac:新版在后台下载,点“重启并更新”后原位替换应用并打开新版。模型和设置都会保留,任何一步失败都会还原旧版。", "参考视频:参考视频可以只用画面、不使用它自己的声音。", - "剪辑视图:引擎中断而未完成的片段显示“上次中断”。从失败提示提交的问题会写明是哪一步出错。", - "Linux:显存能容纳整个视频模型的显卡,生成视频时不再在系统内存中保留权重副本,约节省 20 GB 内存。", + "剪辑视图:引擎中断而没做完的片段显示“上次中断”。从失败提示提交的问题会写明是哪一步出错。", + "Linux:显存放得下整个视频模型的显卡,生成视频时不再在系统内存里留一份权重副本,约省 20 GB 内存。", "提交问题:反馈里的内存读数分开记录物理可用内存和提交余量。" ] } @@ -59,7 +59,7 @@ "summary": "Image generation keeps a history of every picture and needs less memory on both small and large graphics cards, error messages everywhere now come with a button to report the problem, and Windows updates download faster in mainland China.", "changes": [ "Image generation: every image generated on this machine is kept in a history, newest first, and can be placed on the canvas, used as a reference, deleted, or opened in its folder. Blank canvases offer Standard and HD sizes.", - "Image generation: on 6-8 GB graphics cards, decoding uses less graphics memory and runs faster. On cards that can hold the whole model, the weights load directly to the card and no second copy stays in system memory, which cuts memory use after loading from about 15 GB to about 2 GB.", + "Image generation: on 6-8 GB graphics cards, decoding uses less graphics memory and runs faster. On cards that can hold the whole model, the weights load straight to the card and no second copy stays in system memory, which cuts memory use after loading from about 15 GB to about 2 GB.", "Reporting problems: wherever an error or failure message appears, including generation, editing and export, models and environment, image generation, the agent connection, the library, prompt enhancement, sharing, updates, and the launcher, a Report a problem button next to it opens the feedback form with the context filled in.", "AI Autopilot: when a chat cannot be deleted, it now says why and offers Stop and delete; other failures are reported with a button to submit the problem.", "White models: only the cat ready-made scene remains, and one click starts it. A white model that was not made now says why and what to do next, and the page shows rendering progress. A question about progress is answered instead of being started as a new request.", @@ -76,17 +76,17 @@ "summary": "生图工具会保存所有生成过的图片,小显存和大显存的显卡生图都更省内存;所有错误提示旁都有“提交问题”按钮;Windows 版在中国大陆更新更快。", "changes": [ "生图:本机生成过的图片都保存在历史记录里,按时间从新到旧排列,可放到画布、用作参考图、删除或打开所在文件夹。空白画布可选“标准 / 高清”尺寸。", - "生图:6–8 GB 显存的显卡解码时占用的显存更少,速度更快。显存能容纳整个模型的显卡,权重直接加载到显卡,不再在内存中保留副本,加载后内存占用从约 15 GB 降到约 2 GB。", - "提交问题:生成、剪辑与导出、环境与模型、生图、智能体连接、作品库、提示词优化、分享、更新和启动器里,出现错误或失败提示的地方旁边都有“提交问题”按钮,点击后附带详情打开问题反馈。", - "AI 全自动:对话无法删除时会说明原因,并提供“停止并删除”;其他失败也会提示,并带“提交问题”按钮。", - "白模:现成场景只保留“橘猫跳上书架”,点击即可开始。白模生成失败时会说明原因和下一步,渲染中页面显示进度。询问白模进度时会给出进度,不再视为新请求。", + "生图:6–8 GB 显存的显卡解码时占用的显存更少,速度更快。显存放得下整个模型的显卡,权重直接加载到显卡,不再在内存里留一份副本,加载后内存占用从约 15 GB 降到约 2 GB。", + "提交问题:生成、剪辑与导出、环境与模型、生图、智能体连接、作品库、提示词优化、分享、更新和启动器里,出现错误或失败提示的地方旁边都有“提交问题”按钮,点一下带上情况打开问题反馈。", + "AI 全自动:对话删不掉时会说明原因,并提供“停止并删除”;其他失败也会提示,并带“提交问题”按钮。", + "白模:现成场景只保留“橘猫跳上书架”,点一下直接开始。白模没做成时会说明原因和下一步,渲染中页面显示进度。问白模进度时会回答进度,不再当成新要求。", "人物设定图:背面图里头和身体一起转向背后。", "Mac:支持分段生成长视频。", - "启动器与更新:Windows 版在中国大陆更新更快。GitHub 慢时,启动器可以从国内镜像下载更新,镜像上的每个文件都先按发布签名校验。“确认安装”页适配窄窗口:侧栏收为图标,按钮始终可见。", - "工作流:旧版本保存的工作流可以正常运行。旧文件中新版选项为空或不合法时,恢复为默认值。", - "视频生成:“精细”档生成更快。8 GB 显存的显卡上,以前因显存略有不足而被拒绝的 1920×1088 视频,现在可以生成。改动视频参考的前期步数设置后,会生成新的结果,不再沿用之前的结果。", + "启动器与更新:Windows 版在中国大陆更新更快。GitHub 慢时,启动器可以从国内镜像下载更新,镜像上的每个文件都先按发布签名校验。“确认安装”页适配窄窗口:侧栏收成图标,按钮始终可见。", + "工作流:旧版本存的工作流可以直接运行。旧文件里后加的选项为空或不合法时,改回默认值。", + "视频生成:“精细”档生成更快。8 GB 显存的显卡上,以前因显存差一点而被拒绝的 1920×1088 视频,现在可以生成。改动视频参考的前期步数设置后,会生成新的结果,不再沿用之前的结果。", "LoRA:为 Wan 2.x 训练的 LoRA 选进 int8 模型时,会说明这是给 Wan 2.x 训练的 LoRA,不再只说无法使用。", - "Linux:内存较小的电脑连续再次生成时,不再在采样即将结束时内存不足。6 GB 显存的显卡选“极致”时,不再耗时数小时。" + "Linux:内存较小的电脑接着再生成一次时,不再在采样快结束时内存不足。6 GB 显存的显卡选“极致”时,不再要等好几个小时。" ] } }, @@ -99,7 +99,7 @@ "Picture editing: when changing a picture, drag a box, click an object, or draw a closed outline to select a region. Shift adds to the selection and Alt removes from it. Only the selected region changes, and everything outside it stays exactly the same. Click selection downloads a 184 MB model from ModelScope the first time it is used.", "Studio: while a long or multi-part video is generating, the button stays available as \"Add to queue\". Up to five requests can wait, and each starts when the current video finishes or is stopped.", "Agent: agents can import clips they rendered through HTTP, MCP and the command line, place them on the timeline by frame, and export. Before an agent is connected, the autopilot page says what connecting one is for.", - "Editing view: a reference picture that cannot be read is flagged immediately so another one can be chosen.", + "Editing view: a reference picture that cannot be read is flagged right away so another one can be chosen.", "Upgrade: when an old FreeVideo tab is replaced, two-pass sampling, first- and second-pass steps, the step cache and the upscale factor are carried over.", "Prompt enhancement: after the model folder is copied, an offline package is unpacked, or antivirus software scans the files, FreeVideo no longer asks to download 8.9 GB again when the files are unchanged. It now also works on 6 GB graphics cards, finishing in under a minute instead of timing out.", "Chinese interface: browser translation no longer changes FreeVideo's own labels, so the size menu keeps showing MP.", @@ -111,12 +111,12 @@ "summary": "极致档更快;改图时可以只改选中的区域;Agent 可以把自己渲染的片段导入时间线;长视频生成中可以继续加入队列。", "changes": [ "质量档位:极致档改为 20 步加带检查的步缓存,更快完成;精细档改为 16 步加步缓存。档位名称不变。", - "改图:修改图片时,可以拖框、点选物体或画闭合曲线来选区;按住 Shift 添加,按住 Alt 减去。只修改选区内的内容,选区外完全不变。首次使用点选时从魔搭下载 184 MB 的模型。", + "改图:改一张图时,可以拖框、点选物体或画闭合曲线来选区;按住 Shift 添加,按住 Alt 减去。只改选区里的内容,选区外完全不变。点选第一次使用时从魔搭下载 184 MB 的模型。", "创作面板:长视频或分段视频生成中,按钮保持可用,显示“加入队列”。最多排 5 条,当前视频完成或停止后自动开始下一条。", - "Agent:Agent 可以通过 HTTP、MCP 和命令行导入自己渲染的片段,按帧放上时间线并导出。未连接时,AI 全自动页面说明连接后的用途。", - "剪辑视图:参考图无法读取时会立即提示,以便更换。", - "升级:替换旧版本的 FreeVideo 标签页时,二次采样、第一遍和第二遍步数、步缓存和放大倍数都会沿用。", - "提示词增强:复制模型文件夹、解压离线包或被杀毒软件扫描之后,只要文件内容未变,便不再要求重新下载 8.9 GB。现在也支持 6 GB 显卡,一分钟内完成,不再超时。", + "Agent:Agent 可以通过 HTTP、MCP 和命令行导入自己渲染的片段,按帧放上时间线并导出。未连接时,自动页说明连上之后能做什么。", + "剪辑视图:参考图读不出来时会当场提示,可以换一张。", + "升级:替换旧版本的 FreeVideo 标签页时,二次采样、第一遍和第二遍步数、步缓存和放大倍数都会带过来。", + "提示词增强:复制模型文件夹、解压离线包或被杀毒软件扫描之后,只要文件内容没变,就不再要求重新下载 8.9 GB。6 GB 显卡上现在也能用,一分钟内完成,不再超时。", "中文界面:浏览器翻译不再改动 FreeVideo 自己的文字,尺寸菜单保持显示 MP。", "反馈:为失败的生成发送反馈时,会附上引擎给出的报错信息。", "安装:Windows 上的库文件检查也支持层级很深的文件夹。" @@ -139,11 +139,11 @@ ] }, "zh": { - "summary": "精细档重新比极致档快;同比例、不同尺寸的视频可以继续生成;int8 模型上的 LoRA 能用的部分照常生效;设置中新增分步演示和离线教程;安装包变小。", + "summary": "精细档重新比极致档快;同比例、不同尺寸的视频可以接着生成;int8 模型上的 LoRA 能用的部分照常生效;设置中新增分步演示和离线教程;安装包变小。", "changes": [ "精细档:重新启用步缓存,并加上检查,精细档重新比极致档快。", - "长视频:长视频的分段和继续生成的分段统一使用 8 + 3 步,第二段不再被拒绝。", - "继续生成:同比例、不同尺寸的视频改为从它自己的结尾接续生成;过短或帧率过低的视频会给出明确提示。", + "长视频:长视频的分段和接着生成的分段统一使用 8 + 3 步,第二段不再被拒绝。", + "接着生成:同比例、不同尺寸的视频改为从它自己的结尾接着生成;太短或帧率太低的视频会给出明确提示。", "LoRA:在 int8 模型上,LoRA 中无法应用的部分会跳过,其余照常生效,不再整个拒绝。", "帮助:设置中的“使用说明”改为在真实界面上演示三项操作;新增“教程”,可打开中英文离线图文手册。", "升级:旧版本留在标签页中的 FreeVideo 工作流会换成当前版本,并保留提示词、尺寸、时长、种子、素材和 LoRA。", @@ -174,14 +174,14 @@ "zh": { "summary": "AI 全自动新增白模预览场景,LoRA 兼容性检查和拖动排序,失败提示更清楚,可以删除剪辑,繁忙的机器上启动更快,标准档改为两遍采样。", "changes": [ - "AI 全自动:可选的白模预览和现成场景,在生成视频前预览场景;入口展示里有示例。", + "AI 全自动:可选的白模预览和现成场景,在生成视频前先看到场景;入口展示里有示例。", "LoRA:生成前做兼容性检查,LoRA 可以拖动排序。", - "失败提示:提交、排队和浏览器扩展的失败用同一种版式,并说明后续操作;失败的请求可以打包为导出报告或提交到 GitHub。", - "编辑器:可以删除剪辑,未改动的空剪辑自动丢弃;重新打开剪辑会恢复封面并显示收尾阶段;续接在服务端排队。", - "角色:每个角色带有全部视角的角色图和脸部特写;无法读取的图片会被拒绝,并在对应的格子上给出提示。", - "标准档:从更大的第一遍起采样两遍;极致档完整运行 20 步,不使用缓存。", + "失败提示:提交、排队和浏览器扩展的失败用同一种版式,并说明下一步该做什么;失败的请求可以打包为导出报告或提交到 GitHub。", + "编辑器:可以删除剪辑,没改动的空剪辑自动丢弃;重新打开剪辑会恢复封面并显示收尾阶段;续接在服务端排队。", + "角色:每个角色带有全部视角的角色图和脸部特写;读不出来的图片会被拒绝,并在对应的格子上给出提示。", + "标准档:从更大的第一遍起采样两遍;极致档跑满 20 步且不使用缓存。", "安装:GPU 加速组件的构建在进度中占有可见的份额,模型下载分组并行时进度行保持稳定,torch 显示为“运行组件”。", - "下载:开始下载前,启动器、页面和编辑器会说明下载内容和所需时间。", + "下载:开始下载前,启动器、页面和编辑器会说明这是什么下载、需要多久。", "指引:首次指引新增一步,说明在哪里添加参考图和首帧、尾帧;时长栏直接显示分段上限;中文界面在设置里可以打开使用说明。", "只改提示词时,参考图和片段不再重复编码;机器繁忙时,加载过程中的小型 CPU 表运算改为单线程。", "Mac:不能续接视频的设备会在一开始就拒绝超过一个分段的长度。", @@ -201,7 +201,7 @@ "zh": { "summary": "修复请求被帧数错误拒绝的问题,并让带额外部分的 LoRA 能在 int8 版本上正常生成。", "changes": [ - "用 Total frames 中填写的不是帧数的工作流或恢复的标签页生成时,不再被帧数错误拒绝,该数值会按时长读取。", + "用 Total frames 里填的不是帧数的工作流或恢复的标签页生成时,不再被帧数错误拒绝,这个数会按时长读取。", "int8:带调制部分的 LoRA 不再让生成中断,这些部分会逐个跳过,跳过的数量写进报告。" ] } @@ -223,13 +223,13 @@ ] }, "zh": { - "summary": "Mac 精细档使用步间缓存,快得多;编辑器里音频片段可以拖到另一条音频轨;几处提示和窄窗口布局更清楚;旧版本保存的工作流可以正常生成。", + "summary": "Mac 精细档使用步间缓存,快得多;编辑器里音频片段可以拖到另一条音频轨;几处提示和窄窗口布局更清楚;旧版本保存的工作流又能正常生成。", "changes": [ "Mac:精细档第一遍使用步间缓存,快得多;开始时的预计时间按芯片给出,从第一步起就更接近实际。", "编辑器:从素材库把音频拖到第二条音频轨会放在该轨;时间线上单个音频片段可以拖到另一条音频轨。", "编辑器:播放头不在所选片段上时按切分或修剪,或在浏览器不允许全屏的地方点全屏,会用一行字说明原因。", - "连续书写、名字之间没有空格的角色提及都能被识别,输入完一个名字后继续输入也会弹出角色列表。", - "AI 全自动:有任务运行时,窄窗口中顶栏的“AI 全自动”按钮也始终完整可点。", + "连着写、名字之间没有空格的角色提及都能被识别,写完一个名字后接着输入也会弹出角色列表。", + "AI 全自动:有任务在跑时,窄窗口里顶栏的“AI 全自动”按钮也始终完整可点。", "对话:手机等窄窗口里,提示中引号内的按钮名不再被拆到两行。", "图像和视频失败请求的诊断报告现在记录请求开始和生成进程结束时的可用内存,并标明进程是否疑似被系统结束。", "节点:英文界面里 Width、Height、Seed 的大小写与其他项一致。", @@ -265,12 +265,12 @@ "进度估计把参考图计入,带参考图的请求一开始就显示更接近实际的总时间。", "编辑器:把区间左边拖到时间线开头,右边不再被往后推;切分加速或倒放的片段时,淡入只留在前半段,淡出只留在后半段。", "编辑器:片段很多时,框选重新变得流畅。", - "编辑器文字:按 T 后立即点击侧边“内容”框,新文字轨会保留;把文字拖到已锁定的文字轨上会提示该轨已锁定。", + "编辑器文字:按 T 后马上点侧边“内容”框,新文字轨会保留;把文字拖到已锁定的文字轨上会提示该轨已锁定。", "长视频:每段描述也能在放大窗口里编辑。", "对话:对话已满时,提示只在消息下出现一次,按钮换成“新对话”。", "导出:导出窗口里给出的尺寸和保存的作品用同样的方式取整。", "诊断报告记录每一步采样时其他程序占用显卡的比例。", - "启动器:浏览器未能自动打开时,提示点“复制地址”并在浏览器中打开,中文界面也显示中文。画布边距标签更清楚、对齐。节点:宽度、高度、时长和种子有中文标签。" + "启动器:浏览器没能自动打开时,提示点“复制地址”到浏览器里打开,中文界面也显示中文。画布边距标签更清楚、对齐。节点:宽度、高度、时长和种子有中文标签。" ] } } diff --git a/freevideo_engine/support_report.py b/freevideo_engine/support_report.py index c397ffd7..d9fa71b2 100644 --- a/freevideo_engine/support_report.py +++ b/freevideo_engine/support_report.py @@ -109,7 +109,7 @@ def summarize(report, engine, encoding): hints.append(dict(kind='configuration', message='The text encoder file used is %s GiB, not the %s GiB file with the same name that FreeVideo installs and tests. ' 'Results and speed are not guaranteed to match the tested version.' % (actual, expected), - message_zh='本次使用的文本编码器文件为 %s GiB,不是 FreeVideo 安装并测试的同名文件(%s GiB)。' + message_zh='这次使用的文本编码器文件为 %s GiB,不是 FreeVideo 安装并测试的同名文件(%s GiB)。' '生成结果和速度都不保证与测试过的版本一致。' % (actual, expected))) if (mapping(engine.get('config')).get('resident_blocks') == 50 and number(offload.get('transfers')) == 0 and number(offload.get('h2d_bytes')) == 0): @@ -163,7 +163,7 @@ def summarize(report, engine, encoding): hints.append(dict(kind='measured', message='The GPU was in thermal slowdown for %d%% of its busy time: %d of %d MHz on ' 'average%s. Cooling limited this request, not the plan.' % (round(100 * thermal['share']), thermal['sm_clock_mean_mhz'], thermal['sm_clock_max_mhz'], ' at %.0f °C' % thermal['temperature_mean_c'] if thermal['temperature_mean_c'] else ''), - message_zh='显卡在 %d%% 的计算时间里处于温控降频,平均 %d/%d MHz%s。本次用时受散热限制,不是规划问题。' % ( + message_zh='显卡在 %d%% 的计算时间里处于温控降频,平均 %d/%d MHz%s。这次用时受散热限制,不是规划问题。' % ( round(100 * thermal['share']), thermal['sm_clock_mean_mhz'], thermal['sm_clock_max_mhz'], ',%.0f °C' % thermal['temperature_mean_c'] if thermal['temperature_mean_c'] else ''))) shared = shared_gpu(gpu) @@ -171,7 +171,7 @@ def summarize(report, engine, encoding): hints.append(dict(kind='measured', message='Other programs used about %d%% of the GPU while this request was computing, ' 'and at least %d%% of it for %d%% of that time, so it ran slower than with the GPU to itself.' % ( shared['other_mean_percent'], OtherPrograms.share_percent, round(100 * shared['share'])), - message_zh='本次请求计算期间,其他程序占用了约 %d%% 的显卡,其中 %d%% 的时间里不低于 %d%%,因此比独占显卡时慢。' % ( + message_zh='这次请求计算期间,其他程序占用了约 %d%% 的显卡,其中 %d%% 的时间里不低于 %d%%,所以比独占显卡时慢。' % ( shared['other_mean_percent'], round(100 * shared['share']), OtherPrograms.share_percent))) attempts = report.get('resource_attempts', []) failures = [dict(state=r.get('state'), failure=r.get('failure')) for r in attempts diff --git a/scripts/test_freevideo_entry.mjs b/scripts/test_freevideo_entry.mjs index acd5dd74..8a289573 100644 --- a/scripts/test_freevideo_entry.mjs +++ b/scripts/test_freevideo_entry.mjs @@ -206,10 +206,10 @@ test('main browser entry registers both views and preserves node hooks', async t ['seconds', 'Duration (s)', '时长(秒)', 10], ['seed', 'seed', '种子', 2026090903], ['force_regenerate', 'Force regeneration', '重新生成', false, '跳过已保存结果的缓存。'], - ['preview', 'Two-pass: preview first pass', '先生成预览', false, - '先生成半分辨率预览,满意后在结果中点击“继续二采”,成片与直接生成一致。'], + ['preview', 'Two-pass: preview first pass', '先出预览', false, + '先出半分辨率预览,满意后在结果里点“继续二采”,成片与直接生成一致。'], ['refine_from', 'Second pass of preview', '继续二采', 'preview.mp4', '从这个预览继续生成全分辨率视频。'], - ['continue_from', 'Continue from video', '继续生成', 'previous.mp4'], + ['continue_from', 'Continue from video', '接着生成', 'previous.mp4'], ['frames', 'Total frames', '帧数', 141], ['first_frame_reference', 'First-frame reference', '首帧参考', true, '减小漂移'], ['step_cache', 'Step cache', '步间缓存', 'none', diff --git a/web/agent_activity.js b/web/agent_activity.js index e2032870..37c75079 100644 --- a/web/agent_activity.js +++ b/web/agent_activity.js @@ -15,9 +15,9 @@ const NAMES = {generate: ['Video', '视频'], assemble: ['Sequence', '成片'], // Short reasons for the failures people can act on; the engine's own message stays in the tooltip. const REASONS = { out_of_memory: ['Out of memory', '显存或内存不足'], - insufficient_memory: ['Not enough memory for this video', '显存或内存不足,无法生成这段视频'], + insufficient_memory: ['Not enough memory for this video', '显存或内存不够生成这段视频'], interrupted: ['ComfyUI restarted during this job', 'ComfyUI 中途重启'], - lost: ['Ended without a result', '未返回结果'], + lost: ['Ended without a result', '没有返回结果'], dependency_failed: ['An earlier step did not succeed', '前序任务未成功'], }; @@ -77,7 +77,7 @@ function row(job, t, api, refresh, open, cancellationErrors) { if (job.status === 'running' && p.remaining_seconds > 0) meta.push(left(p.remaining_seconds, t)); const reason = job.status === 'failed' && REASONS[job.error?.code]; if (reason) meta.push(t(...reason)); - else if (job.status === 'failed') meta.push(t(job.error?.message || 'Did not finish', job.error?.error_zh || '未完成')); + else if (job.status === 'failed') meta.push(t(job.error?.message || 'Did not finish', job.error?.error_zh || '没有完成')); if (job.project) meta.push(job.project); const foot = el('div', 'fv-agent-foot'); const details = el('p', 'fv-agent-meta', meta.join(' · ')); @@ -93,7 +93,7 @@ function row(job, t, api, refresh, open, cancellationErrors) { try { const response = await retryAgentPost(api, '/freevideo/agent/activity/cancel', {id: job.id}); if (!response?.ok) { - const message = t('Could not stop. Click again.', '停止失败,请再次点击。'); + const message = t('Could not stop. Click again.', '没能停止,请再点一次。'); details.textContent = message; cancellationErrors.set(job.id, message); issue.hidden = false; diff --git a/web/agent_connect.js b/web/agent_connect.js index d1ea2b30..1bee48ba 100644 --- a/web/agent_connect.js +++ b/web/agent_connect.js @@ -37,7 +37,7 @@ export function agentConnectCard(t, api, shown = () => true) { const body = el('section', 'fv-agent-connect-body'); const mark = el('span', 'fv-agent-connect-icon'); mark.append(icon('plug')); const title = el('h2', null, t('Connect an agent', '连接 Agent')); - const intro = el('p', 'fv-agent-connect-intro', t('Once connected, describe the video you want. It generates and edits it for you.', '连接后,描述想要的视频,Agent 会生成并剪辑。')); + const intro = el('p', 'fv-agent-connect-intro', t('Once connected, describe the video you want. It generates and edits it for you.', '连上后,说想做什么视频,它来生成和剪辑。')); const subtitle = el('p', 'fv-muted', t('Claude Code, Codex, FreeToken and others', 'Claude Code、Codex、FreeToken 等')); const actions = el('div', 'fv-agent-connect-actions'); const button = el('button', 'fv-primary'); button.type = 'button'; button.disabled = true; diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index 5e647c94..30ccd53c 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -48,7 +48,7 @@ const WHITEBOX = { play: ['Play', '播放'], pause: ['Pause', '暂停'], seconds: ['s', '秒'], failure: ['Could not start: ', '无法开始生成:'], }; -const GENERIC_FAILURE_ZH = '未完成'; +const GENERIC_FAILURE_ZH = '没有完成'; // Sends and failure cards use the same public reason, including the engine's Chinese mapping. function failureReason(value, t) { const error = value.error, reason = error?.error_zh || value.error_zh; @@ -228,7 +228,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const down = press('fv-chat-down', null, () => { scroller.scrollTo({top: scroller.scrollHeight, behavior: 'smooth'}); }); down.append(glyph('M12 5v14M5 12l7 7 7-7')); down.setAttribute('aria-label', t('Latest', '最新')); down.hidden = true; const form = el('form', 'fv-chat-input'), input = el('textarea'), sendButton = el('button', 'fv-chat-send'); - input.rows = 1; input.maxLength = 8000; input.setAttribute('aria-label', t('Message the agent', '向 Agent 发送消息')); + input.rows = 1; input.maxLength = 8000; input.setAttribute('aria-label', t('Message the agent', '对 Agent 说')); sendButton.type = 'submit'; sendButton.setAttribute('aria-label', t('Send', '发送')); sendButton.append(glyph('M12 19V5M5 12l7-7 7 7')); // While the agent is thinking and has started nothing, the send key is the stop key: same place, a solid square. let stopMode = false; @@ -247,7 +247,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () input.focus(); let done = false; try { done = (await dataLayer.abandon(session)).ok; } catch {} - if (!done && state.session === session) { state = {...state, abandoned: false}; render(); say(t('Could not stop. Click again.', '停止失败,请再次点击。')); } + if (!done && state.session === session) { state = {...state, abandoned: false}; render(); say(t('Could not stop. Click again.', '没能停止,请再点一次。')); } } let whiteboxPreview = false; try { whiteboxPreview = localStorage.getItem(WHITEBOX_PREVIEW.storageKey) === 'true'; } catch {} @@ -536,10 +536,10 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () else { void readConversations(true); if (stop) deleteFailed(session, t('Still stopping. Try deleting again in a moment.', '正在停止,请稍后再删除。'), false, false); - else deleteFailed(session, t('This chat still has work in progress, so it was not deleted.', '此对话中仍有正在生成的内容,未删除。'), true); + else deleteFailed(session, t('This chat still has work in progress, so it was not deleted.', '这段对话里还有在生成的内容,没有删除。'), true); } - } else deleteFailed(session, t('Could not delete: ', '删除失败:') + (data.error?.message || data.error?.code || `HTTP ${response.status}`)); - } catch (error) { if (!stopped) deleteFailed(session, t('Could not delete: ', '删除失败:') + reason(error)); } + } else deleteFailed(session, t('Could not delete: ', '没有删除成功:') + (data.error?.message || data.error?.code || `HTTP ${response.status}`)); + } catch (error) { if (!stopped) deleteFailed(session, t('Could not delete: ', '没有删除成功:') + reason(error)); } finally { deleting.delete(session); if (menuState?.session === session) closeMenu(); @@ -571,7 +571,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () } else throw await requestError(response); } catch (error) { if (!stopped) { - newError.textContent = t('Could not start a new chat: ', '新建对话失败:') + reason(error); newError.hidden = newIssue.hidden = false; + newError.textContent = t('Could not start a new chat: ', '没有新建成功:') + reason(error); newError.hidden = newIssue.hidden = false; if (from) from.error = newError.textContent; } } @@ -670,7 +670,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () ids.forEach(id => stopping.add(id)); render(); report(); const results = await Promise.all(ids.map(id => dataLayer.cancel(id))); results.forEach((response, index) => { if (!response?.ok) stopping.delete(ids[index]); }); - if (results.some(response => !response?.ok)) say(t('Could not stop. Click again.', '停止失败,请再次点击。'), true); + if (results.some(response => !response?.ok)) say(t('Could not stop. Click again.', '没能停止,请再点一次。'), true); else say(''); if (!stopped) { render(); report(); void poll(); } } @@ -788,7 +788,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () let playing = false, loadRetried = false; const playbackFailure = () => { if (item.dataset.mode !== 'done') return; - playback.textContent = t('Playback is unavailable. Open it in Creations.', '暂时无法播放,可在作品库中打开'); playback.hidden = false; + playback.textContent = t('Cannot play right now. Open it in Creations.', '暂时播放不了,可在作品库中打开'); playback.hidden = false; show.textContent = t('Open in Creations', '在作品库打开'); if (!show.parentNode) foot.append(show); if (!foot.parentNode) info.append(foot); }; @@ -974,7 +974,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const signature = JSON.stringify([value, disabled, error]); if (signature === shownState) return; shownState = signature; item.dataset.state = value === 'asked' ? 'asked' : 'answered'; - if (value !== 'asked') { item.replaceChildren(el('span', null, value === 'approved' ? t('Started', '已开始') : t('Not now', '暂不拍摄'))); return; } + if (value !== 'asked') { item.replaceChildren(el('span', null, value === 'approved' ? t('Started', '已开始') : t('Not now', '先不拍'))); return; } const answer = approve => async () => { if (confirming.has(key) || !inSelectedChat() || state.agent.connected !== true || state.session !== session) return; item.querySelectorAll('button').forEach(button => { button.disabled = true; }); @@ -983,12 +983,12 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const response = await dataLayer.confirm({id: entry.id, approve, session}); if (!response.ok) throw await requestError(response); answers.set(key, (await response.json()).state || (approve ? 'approved' : 'declined')); - } catch (error) { confirmErrors.set(key, t('Could not start shooting: ', '未能开始拍摄:') + reason(error)); } + } catch (error) { confirmErrors.set(key, t('Could not start shooting: ', '没有开始拍摄:') + reason(error)); } finally { confirming.delete(key); } if (!stopped) { render(); void poll(); } }; const buttons = el('div', 'fv-chat-actions'); - buttons.append(press('fv-chat-yes fv-primary', t('Start', '开始'), answer(true)), press('fv-chat-no', t('Not now', '暂不'), answer(false))); + buttons.append(press('fv-chat-yes fv-primary', t('Start', '开始'), answer(true)), press('fv-chat-no', t('Not now', '先不'), answer(false))); buttons.querySelectorAll('button').forEach(button => { button.disabled = disabled; }); const question = el('p', null, entry.question); const estimate = el('p', 'fv-chat-meta', entry.estimate_seconds > 0 ? about(entry.estimate_seconds, t) : ''); estimate.hidden = !estimate.textContent; @@ -1010,7 +1010,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const lines = [t('Make a 10-second video: a rainy night, a girl squeezes onto the last bus', '生成一段 10 秒的视频:雨夜,女孩挤上末班车'), t('Make a 1-minute vertical short film: a day in a coffee shop', '做一条 1 分钟的竖屏短片:咖啡店的一天')]; list.append(...lines.map(line => press(null, line, () => send(line)))); - item.append(el('p', 'fv-chat-meta', t('Examples:', '示例:')), list); + item.append(el('p', 'fv-chat-meta', t('Try saying:', '可以这样说:')), list); return {element: item, update() { list.querySelectorAll('button').forEach(button => { button.disabled = !sendingAllowed(); }); }}; } function dots() { @@ -1071,7 +1071,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () }); const sendable = sendingAllowed(); input.disabled = state.agent.connected !== true || creating || !!selectedSession && selectedSession !== state.session; - input.placeholder = state.agent.connected === true && !sendable ? t('Ask your agent to continue', '请在 Agent 窗口中指示其继续') : t('Message the agent…', '向 Agent 发送消息…'); + input.placeholder = state.agent.connected === true && !sendable ? t('Ask your agent to continue', '请在 Agent 里让它继续') : t('Message the agent…', '对 Agent 说…'); refreshSend(); if (focusSession === state.session && focusSession && !input.disabled && active()) { focusSession = null; input.focus(); } if (stick) { pinned = true; scroller.scrollTop = scroller.scrollHeight; down.hidden = true; } @@ -1113,14 +1113,14 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () } } const waitingNotice = code => (code ? code === 'not_listening' : state.agent.connected === true) - ? t('Will send when Agent reconnects', 'Agent 重新连接后自动发送') - : t('Will send when Agent connects', 'Agent 连接后自动发送'); + ? t('Will send when Agent is back', '等 Agent 回来后自动发送') + : t('Will send when Agent connects', '等 Agent 连上后自动发送'); async function send(text, original = null, draft = null, automatic = false) { text = String(text).trim(); original ||= pending.find(entry => entry.session === (state.session ?? null) && entry.text === text && (entry.error || entry.sending || entry.automatic)); if (!text || original?.sending || original && original.session !== state.session) return false; if (!inSelectedChat()) { - say(creating ? t('Creating a new chat…', '正在新建对话…') : t('Loading this chat. Please send again shortly.', '正在读取对话,请稍后重新发送')); + say(creating ? t('Creating a new chat…', '正在新建对话…') : t('Loading this chat. Please send again shortly.', '正在读取对话,请稍后再发一次')); return false; } const entry = original || {id: newId(), text, session: state.session ?? null, draft, whiteboxPreview: whiteboxPreview && state.agent?.builtin !== true}; @@ -1151,10 +1151,10 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const waiting = !refused && (WAIT_FOR_AGENT.includes(error?.code) || !error && state.agent.connected !== true); const exhausted = entry.attempts >= SEND_ATTEMPTS; entry.automatic = waiting && !exhausted; - const explanation = refused || error && !waiting ? t('Not sent: ', '未发送:') + failureReason({error}, t) - : exhausted ? t('Not sent. Click "Retry".', '未发送,请点击“重试”。') - : waiting ? waitingNotice(error?.code) : t('The connection is unstable. Click "Retry".', '网络连接不稳定,请点击“重试”。'); - entry.error = waiting || /^(Not sent|未发送)/.test(explanation) ? explanation : t('Could not send: ', '发送失败:') + explanation; + const explanation = refused || error && !waiting ? t('Not sent: ', '没发出去:') + failureReason({error}, t) + : exhausted ? t('Not sent. Click "Retry".', '没有发出,请点“重试”。') + : waiting ? waitingNotice(error?.code) : t('The connection is unstable. Click "Retry".', '网络不稳,请点“重试”。'); + entry.error = waiting || /^(Not sent|没发出|没有发出)/.test(explanation) ? explanation : t('Could not send: ', '没有发送成功:') + explanation; if (entry.session === state.session) { if (waiting && error?.code === 'not_connected') state = {...state, agent: {connected: false, name: null}}; if (waiting && error?.code === 'not_listening') state = {...state, agent: {...state.agent, listening: false}}; diff --git a/web/browser_extension.js b/web/browser_extension.js index 63a433ec..da8c7b9d 100644 --- a/web/browser_extension.js +++ b/web/browser_extension.js @@ -125,11 +125,11 @@ export const EXTENSION_COPY = Object.freeze({ title: ['A browser extension is interfering with FreeVideo', '浏览器扩展干扰了 FreeVideo'], // English uses straight quotes: curly ones widen in the CJK font stack. both: ['The script "{script}" in the browser extension "{extension}" interfered with this page, so this action did not finish.', - '浏览器扩展“{extension}”里的脚本“{script}”干扰了页面,本次操作未完成。'], + '浏览器扩展“{extension}”里的脚本“{script}”干扰了页面,这次操作没有完成。'], extension: ['The browser extension "{extension}" interfered with this page, so this action did not finish.', - '浏览器扩展“{extension}”干扰了页面,本次操作未完成。'], + '浏览器扩展“{extension}”干扰了页面,这次操作没有完成。'], unknown: ['A browser extension interfered with this page, so this action did not finish.', - '浏览器里的某个扩展干扰了页面,本次操作未完成。'], + '浏览器里的某个扩展干扰了页面,这次操作没有完成。'], // The browser says a client blocked the file: an ad-blocking extension, though not which one. blocked: ['An ad-blocking extension in this browser, such as uBlock Origin, AdGuard or Adblock Plus, blocked one of FreeVideo\'s page files, so the page cannot work properly.', '浏览器里的广告拦截类扩展(例如 uBlock Origin、AdGuard、Adblock Plus)拦下了 FreeVideo 的页面文件,页面无法正常运行。'], diff --git a/web/cast_library.js b/web/cast_library.js index d64d4bb9..65a47f49 100644 --- a/web/cast_library.js +++ b/web/cast_library.js @@ -46,7 +46,7 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () // The dialog only sends pictures, so unreadable media here is always one of them. const unreadable = data?.error?.code === 'invalid_media' && text('Could not read this picture. Please choose another one.', '无法读取这张图片,请换一张。'); const error = new Error(unreadable || data?.error?.message || (typeof data?.error === 'string' && data.error) - || data?.message || response.statusText || text('Did not finish. Try again.', '未完成,请重试。')); + || data?.message || response.statusText || text('Did not finish. Try again.', '没有完成,请重试。')); error.status = response.status; // Which picture it was, as /images//media of the request. if (unreadable) error.field = data.error.details?.find(detail => typeof detail?.field === 'string')?.field; diff --git a/web/editor.js b/web/editor.js index 043ba666..ffcb4365 100644 --- a/web/editor.js +++ b/web/editor.js @@ -63,36 +63,36 @@ export function editorErrorText(value, t = en => en) { const known = { 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 start. Choose "Generate" again.', '生成未能开始,请再次点击“生成”。'), - result_mismatch: t('Could not read the result. Choose "Generate" again.', '未能读取结果,请再次点击“生成”。'), + 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 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 (outputNotWritable(whole)) - return t('The output folder cannot be written to, so the video was not saved. Check that the folder can be written to, then retry.', '输出目录不可写,视频未能保存。请检查这个文件夹的写入权限后重试。'); + return t('The output folder cannot be written to, so the video was not saved. Check that the folder can be written to, then retry.', '输出目录不可写,视频没能保存。请检查这个文件夹的写入权限后重试。'); if (/manifest\.json/i.test(whole) && /No such file|not found|Errno 2\b|FileNotFoundError/i.test(whole)) - return t('The model is not fully installed. ', '模型未完整安装。') + t(...repairAction()); + return t('The model is not fully installed. ', '模型没有装全。') + t(...repairAction()); 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. Try again.`, `${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. Try again.`, `${generating}时出错,请重试。`); + : t(`${generating} failed. Try again.`, `${generating}时出错,请再试一次。`); } function exportErrorText(value, t) { if (isNetworkTypeError(value)) return connectionLostText(t); const message = [value?.response?.error?.message, value?.response?.error, value?.response?.message, value?.message] .find(text => typeof text === 'string' && text.trim() && text !== 'editor_request')?.trim().split(/[\r\n]+/)[0]; // A bare status code means nothing to the user; the server's own words do. - return message ? t(`Export failed: ${message}`, `导出时出错:${message}`) : t('Export failed. Try again.', '导出时出错,请重试。'); + return message ? t(`Export failed: ${message}`, `导出时出错:${message}`) : t('Export failed. Try again.', '导出时出错,请再试一次。'); } // The save route's own messages are internal English, so only a full disk is named. function saveErrorText(value, t) { - return value?.reason === 'disk_full' ? t('Saving changes failed: the disk is full. Free some space, then export again.', '保存改动时出错:磁盘空间不足,请释放空间后重新导出。') - : t('Saving changes failed. Please export again.', '保存改动时出错,请重新导出。'); + return value?.reason === 'disk_full' ? t('Saving changes failed: the disk is full. Free some space, then export again.', '保存改动时出错:磁盘空间不够,空出空间后再导出。') + : t('Saving changes failed. Please export again.', '保存改动时出错,请再导出一次。'); } function restoreGeneration(record, before, prepared) { const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); @@ -293,7 +293,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given function error(value, step = null, out = notify, site = step || 'editor') { const code = value?.reason || value?.code || value?.message; if (['missing_glyphs', 'MissingGlyphs', 'source_missing'].includes(code)) { - out(code === 'source_missing' ? t('A source file is missing. Import it again from the library.', '部分素材的原文件缺失,请在素材库重新导入。') + out(code === 'source_missing' ? t('A source file is missing. Import it again from the library.', '有素材的原文件找不到了,请在素材库重新导入。') : t('The chosen font is missing some characters. Choose another font.', '所选字体缺少部分字符,请换一种字体。')); return {ok: false, reason: code}; } @@ -301,20 +301,20 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (code === 'unsupported') { genbar?.update(null); return; } if (['chain_too_long', 'chain_part_too_short'].includes(code)) { const [en, zh] = (value.said || []).map(text => typeof text === 'string' && text ? text.slice(0, 200) : null); - const text = code === 'chain_too_long' ? t(en || 'This video can have up to 5 parts. Make it shorter.', zh || '这条视频最多 5 段,请缩短。') - : t(en || 'Each continued part must be at least 10 seconds. Make it longer.', zh || '接续生成的每一段至少 10 秒,请延长。'); + const text = code === 'chain_too_long' ? t(en || 'This video can have up to 5 parts. Make it shorter.', zh || '这条视频最多 5 段,请缩短一些。') + : t(en || 'Each continued part must be at least 10 seconds. Make it longer.', zh || '接着生成的每一段至少 10 秒,请加长一些。'); out(text); return {ok: false, reason: code, say: text}; } if (['busy', 'locked', 'text', 'audio_full', 'overlap', 'range', 'no_audio'].includes(code)) { if (['locked', 'audio_full', 'overlap', 'range'].includes(code)) tracks?.flashBlocked?.(value.displayFrame); const said = {busy: t('This part is generating.', '这一段正在生成。'), locked: t('This track is locked.', '这条轨道已锁定。'), - text: t('Write a description first.', '请先填写描述。'), overlap: t('This position is occupied. Use an empty position.', '此处已有片段,请放到空白处。'), - audio_full: t('Both audio tracks are full here. Move an audio clip first.', '此处两条声音轨均已占满,请先移开一段声音。'), - no_audio: t('This clip has no sound. Please choose one with sound.', '这个素材没有声音,请选择有声音的素材。')}[code]; + text: t('Write a description first.', '先写一句描述。'), overlap: t('That spot is taken. Use an empty spot.', '这里已经有片段了,请放到空白处。'), + audio_full: t('Both audio tracks are full here. Move an audio clip first.', '这里两条声音轨都满了,请先挪开一段声音。'), + no_audio: t('This clip has no sound. Please choose one with sound.', '这个素材没有声音,请选一段有声音的。')}[code]; if (said) out(said); return {ok: false, reason: code}; } - const text = step === 'generate' ? editorErrorText(value, t) : step === 'export' ? exportErrorText(value, t) : t('Could not complete. Try again.', '未完成,请重试'); + const text = step === 'generate' ? editorErrorText(value, t) : step === 'export' ? exportErrorText(value, t) : t('Could not complete. Try again.', '没有完成,请重试'); out(text, step === 'export' ? EXPORT_ISSUE : EDITOR_ISSUE, failureKind(site, value)); return {ok: false, reason: code, say: text}; } const store = createEditorStore(api, {t, notify, standIn: given?.generationStandIn, onGenerationReady: ready => { @@ -461,7 +461,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (!clips.length) { held.shift(); markHeld(entry.ids, false); rememberView(); continue; } const selection = {kind: 'clip', ids: clips.map(clip => clip.id)}, before = partBefore(Math.min(...clips.map(clip => clip.start))); held.shift(); markHeld(entry.ids, false); rememberView(); - if (before && !before.source && before.pending && !before.job) { genbar?.say(t('The part before did not finish, so this one did not start.', '上一段未完成,这一段未开始。')); continue; } + if (before && !before.source && before.pending && !before.job) { genbar?.say(t('The part before did not finish, so this one did not start.', '上一段没有完成,这一段没有开始。')); continue; } try { await actions.generate({text: entry.text, selection}); } catch (value) { const reason = error(value, 'generate'); if (reason && !['chain_too_long', 'chain_part_too_short'].includes(reason.reason)) genbar?.say(reason.say); } } @@ -956,12 +956,12 @@ export function openEditor({api, app, from = null, panel = {}, components: given else if (!actions.supportedSelection(selection)) { const moved = emptySpot(selection); if (!moved) return {say: t('Choose an empty spot on the timeline.', '请在时间线上选一段空白。')}; - selection = state.select(moved); said = t('Moved to the next empty spot.', '已移到后面的空位。'); genbar?.say(said); + selection = state.select(moved); said = t('Moved to the next empty spot.', '已挪到后面的空位。'); genbar?.say(said); } let used = null; if (!text.trim() && actions.needsText(selection)) { const found = nearbyText(selectionRange(selection).start); - if (!found) return {say: [said, t('Write a description first.', '请先填写描述。')].filter(Boolean).join(' '), focus: true}; + if (!found) return {say: [said, t('Write a description first.', '先写一句描述。')].filter(Boolean).join(' '), focus: true}; text = found.text; used = found.before ? t('Used the description of the part before.', '沿用了上一段的描述。') : t('Used the latest description.', '沿用了最近一次的描述。'); state.setRangeText(text); genbar?.setText(text); @@ -1102,9 +1102,9 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (value.state === 'failed') { if (['source_missing', 'missing_glyphs', 'MissingGlyphs'].includes(value.reason || value.code)) error(value, null, undefined, 'export'); else notify(value.reason === 'not_writable' ? t('Export failed: the output folder cannot be written to. Check that the folder can be written to, then retry.', '导出时出错:输出目录不可写。请检查这个文件夹的写入权限后重试。') - : value.reason === 'disk_full' ? t('Export failed: the disk is full. Free some space, then export again.', '导出时出错:磁盘空间不足,请释放空间后重新导出。') - : value.reason === 'unreadable' ? t('Export failed: a source file cannot be read. Import it again from the library.', '导出时出错:部分素材无法读取,请在素材库重新导入。') - : t('Export failed. Try again.', '导出时出错,请重试。'), EXPORT_ISSUE, failureKind('export', value)); + : value.reason === 'disk_full' ? t('Export failed: the disk is full. Free some space, then export again.', '导出时出错:磁盘空间不够,空出空间后再导出。') + : value.reason === 'unreadable' ? t('Export failed: a source file cannot be read. Import it again from the library.', '导出时出错:有素材读不出来,请在素材库重新导入。') + : t('Export failed. Try again.', '导出时出错,请再试一次。'), EXPORT_ISSUE, failureKind('export', value)); } if (value.state === 'cancelled') notify(t('Export cancelled. No unfinished file is kept.', '已取消导出,没有留下不完整的文件。')); window.FreeVideoNotifyTaskDone?.({kind: 'export', id: value.id, ok: value.state === 'done'}); @@ -1327,7 +1327,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given releaseStamp(); await queue.flush(); if (queue.dirty && !queue.locallyRetained && !discardOnSwitch) { discardOnSwitch = true; closing = false; element.inert = false; closePromise = null; - saveWarning.textContent = t('Not saved yet. Switching again will lose recent changes.', '尚未保存,再次切换将丢失最近的改动'); + saveWarning.textContent = t('Not saved yet. Switching again will lose recent changes.', '还没保存好,再切换会丢掉最近的改动'); saveWarning.hidden = false; return false; } if (!queue.dirty && emptyEdit()) { diff --git a/web/editor_export_dialog.js b/web/editor_export_dialog.js index a798a02d..4e31ebf5 100644 --- a/web/editor_export_dialog.js +++ b/web/editor_export_dialog.js @@ -113,8 +113,8 @@ export async function openExportDialog({parent, state, sources, t = en => en, on size.short == null ? t('Same as the edit', '与剪辑相同') : `${size.width} × ${size.height}`])); group('quality', t('Quality', '画质'), [['high', t('High', '高')], ['standard', t('Standard', '标准')], ['small', t('Small file', '省空间')]]); group('range', t('Range', '范围'), [['whole', t('Whole edit', '整条')], ['selected', t('Selected range', '选中的范围')]]); - const contentRow = group('only', t('Content', '内容'), [[null, t('Video and audio', '画面和声音')], ['sound', t('Audio only', '仅声音')]]); - const formatRow = group('sound_format', t('Audio only', '仅声音'), [['m4a', 'M4A'], ['wav', 'WAV']], contentRow); + const contentRow = group('only', t('Content', '内容'), [[null, t('Video and audio', '画面和声音')], ['sound', t('Audio only', '只声音')]]); + const formatRow = group('sound_format', t('Audio only', '只声音'), [['m4a', 'M4A'], ['wav', 'WAV']], contentRow); formatRow.classList.add('fv-ed-export-format'); formatRow.querySelector('.fv-ed-export-label').hidden = true; const chosenRange = () => value.range === 'selected' ? exportRange(state.record, state.selection) : null; const affected = () => croppedParts(state.record, sources, chosenRange()); diff --git a/web/editor_genbar.js b/web/editor_genbar.js index 099cec3e..59cf5e7e 100644 --- a/web/editor_genbar.js +++ b/web/editor_genbar.js @@ -149,7 +149,7 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { // A single part needs no number; with nothing else to say it reads "Preparing". // The creation panel's own time when it has one, so the bar and the panel never differ; the job's sum until the panel has one. const left = words?.over ? null : words?.left ?? remaining; - const time = finalization ? '' : words?.stall || (words?.over ? t('Taking longer than expected', '耗时超出预计,仍在生成') : words?.time || (left != null ? timeEstimate(left, t) : '')); + const time = finalization ? '' : words?.stall || (words?.over ? t('Taking longer than expected', '比预计久,仍在生成') : words?.time || (left != null ? timeEstimate(left, t) : '')); const stage = finalization ? t(...CLIP_FINALIZATION_COPY[finalization.key]) : words?.stage || (count === 1 && !time ? t('Preparing', '正在准备') : ''); const spans = [['fv-ed-gen-part', count > 1 ? t(`Part ${number}/${count}`, `第 ${number}/${count} 段`) : ''], ['fv-ed-gen-stage', stage], ['fv-ed-gen-time', time]]; @@ -214,7 +214,7 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { // A specific reason is said once; the planning limits are already shown above. 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, false, ['Editor generation', '剪辑器生成']); - else if (!result && !note.textContent) { reason = t('Could not start. Try again.', '未能开始,请重试。'); say(reason, false, ['Editor generation', '剪辑器生成']); } } + else if (!result && !note.textContent) { reason = t('Could not start. Try again.', '没能开始,请重试。'); say(reason, false, ['Editor generation', '剪辑器生成']); } } finally { pressing.delete(key); render(); } return reason; }; diff --git a/web/editor_library.js b/web/editor_library.js index 9bf70353..cac1cf80 100644 --- a/web/editor_library.js +++ b/web/editor_library.js @@ -23,7 +23,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { 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 cannotOpen = () => t('This file cannot be opened. Supported formats: ', '无法打开这个文件。支持的格式:') + FORMATS; + 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}`); return {frames: cached?.frames || Math.round(g.frames || (g.seconds || 0) * (g.fps || 24)), hasVideo: cached?.has_video ?? (kind === 'work' || row.kind === 'video'), @@ -74,7 +74,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { else if (row.kind === 'audio') picture.append(el('span', 'fv-ed-audio-icon', '♫')); if (ready) picture.append(el('span', 'fv-ed-source-time', stamp(metadata(row, kind).frames))); else if (row.state === 'failed') { - picture.append(el('span', 'fv-ed-source-preparing', t('Cannot open', '无法打开'))); + picture.append(el('span', 'fv-ed-source-preparing', t('Cannot open', '打不开'))); } else picture.append(el('span', 'fv-ed-source-preparing', t('Preparing', '准备中') + (Number.isFinite(row.fraction) ? ` ${Math.round(Math.max(0, Math.min(1, row.fraction)) * 100)}%` : ''))); button.append(picture); @@ -97,7 +97,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { plus.ondblclick = event => { event.preventDefault(); event.stopPropagation(); }; plus.onkeydown = event => { if (event.key === 'Enter' || event.key === ' ') insert(event); }; container.append(button, plus); - if (kind !== 'work' && row.state === 'failed') container.append(issueButton(t, () => ({where: LIBRARY_ISSUE, message: t('Cannot open', '无法打开')}))); + if (kind !== 'work' && row.state === 'failed') container.append(issueButton(t, () => ({where: LIBRARY_ISSUE, message: t('Cannot open', '打不开')}))); return container; } function updateUsed() { @@ -122,7 +122,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { for (const row of shown) grid.append(card(row, tab)); updateUsed(); message.textContent = ''; // Empty states stay silent. - for (const row of shown) if (row.state === 'failed') message.textContent = t('This file cannot be opened. Supported formats: ', '无法打开这个文件。支持的格式:') + FORMATS; + for (const row of shown) if (row.state === 'failed') message.textContent = t('This file cannot be opened. Supported formats: ', '这个文件打不开。支持的格式:') + FORMATS; if (message.textContent) message.append(issueButton(t, () => ({where: LIBRARY_ISSUE, message: cannotOpen()}))); if (scroll > 0) { const replacement = anchor && [...grid.querySelectorAll('.fv-ed-source')].find(node => node.dataset.key === anchor.dataset.key); diff --git a/web/editor_loader.js b/web/editor_loader.js index a1dbc1dd..3b2bc61e 100644 --- a/web/editor_loader.js +++ b/web/editor_loader.js @@ -12,7 +12,7 @@ export async function openEditorView(node, options) { console.warn('FreeVideo edit view:', error); const language = new URLSearchParams(location.search).get('freevideo_lang'); const cn = language === 'zh' || (language !== 'en' && String(navigator.language || '').toLowerCase().startsWith('zh')); - const detail = cn ? '未完成,请重试' : 'Could not complete. Try again.'; + const detail = cn ? '没有完成,请重试' : 'Could not complete. Try again.'; // The native modal is above the host's toast layer. Also use the // workspace's existing failure status so the message stays visible. const status = options?.studio?.querySelector('.fv-status'); diff --git a/web/editor_select.js b/web/editor_select.js index 6b2110e5..764d0a25 100644 --- a/web/editor_select.js +++ b/web/editor_select.js @@ -76,7 +76,7 @@ const pageClipboard = createClipboard(); export function createSelectionActions({state, t = en => en, actions = {}, clipboard = pageClipboard}) { function report(error) { if (!(error instanceof model.EditError)) throw error; - if (error.code === 'not_generated' && actions.notify) actions.notify(t('Not generated yet', '尚未生成')); + if (error.code === 'not_generated' && actions.notify) actions.notify(t('Not generated yet', '还没生成')); else if (error.code !== 'clipboard_empty') actions.editError?.(error); return {ok: false, reason: error.code}; } diff --git a/web/editor_store.js b/web/editor_store.js index 260864ce..896b11f8 100644 --- a/web/editor_store.js +++ b/web/editor_store.js @@ -391,7 +391,7 @@ export function createEditorStore(api, {t = en => en, notify = () => {}, onGener xhr.upload.onprogress = event => { if (event.lengthComputable) progress(event.loaded / event.total); }; xhr.onload = () => { release(); - if (xhr.status === 404) { notice('import', 'Import is unavailable until you restart after updating.', '更新并重启后才能导入素材。'); resolve(null); return; } + if (xhr.status === 404) { notice('import', 'Import is unavailable until you restart after updating.', '更新后重启即可导入素材。'); resolve(null); return; } if (xhr.status < 200 || xhr.status >= 300) { reject(errorFor(xhr)); return; } try { resolve(JSON.parse(xhr.responseText).import); } catch (error) { reject(error); } }; diff --git a/web/editor_text.js b/web/editor_text.js index 672f7dae..71e8403a 100644 --- a/web/editor_text.js +++ b/web/editor_text.js @@ -39,7 +39,7 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, const marker=toolbar.querySelector('.fv-ed-marker, .fv-ed-add-marker'); if(marker) marker.after(buttons); else toolbar.insertBefore(buttons,toolbar.querySelector('.fv-ed-zoom')); const notice=el('span');notice.setAttribute('role','button');notice.tabIndex=0;notice.onclick=()=>jumpMissing();notice.onkeydown=e=>{if(['Enter',' '].includes(e.key)){e.preventDefault();jumpMissing();}}; notice.classList.add('fv-ed-text-notice'); notice.hidden=true; exportBox.prepend(notice); - const fontFailure=()=>t('Text fonts could not be loaded','文字字体加载失败'); + const fontFailure=()=>t('Text fonts could not be loaded','文字字体没能加载'); let fontIssue=null; const menu=el('div','fv-ed-text-menu'); menu.hidden=true; menu.setAttribute('role','menu'); element.append(menu); const names={sans:t('Sans','黑体'),serif:t('Serif','宋体'),kai:t('Kai','楷体'),mono:t('Mono','等宽')}; @@ -68,7 +68,7 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, const speech={buttons:new Set(),available:false,need:0,busy:false,loading:false,shown:null,job:null,stop:null,run:0}; function speechLabel(fraction){speech.shown=fraction;const p=Math.round(Math.min(1,Math.max(0,Number(fraction)||0))*100),size=Math.max(1,Math.round(speech.need/1e6)),text=fraction==null?(speech.need?t(`Auto subtitles · Download ${size} MB`,`自动字幕 · 下载 ${size} MB`):t('Auto subtitles','自动字幕')):speech.loading?t(`Cancel · Downloading ${p}%`,`取消 · 下载 ${p}%`):t(`Cancel · ${p}%`,`取消 · ${p}%`);for(const b of speech.buttons){b.textContent=text;b.hidden=!(speech.available||speech.need);}} function speechEnd(text,where=null){speech.stop?.();speech.stop=null;speech.job=null;speech.busy=false;speech.loading=false;speechLabel(null);if(text)actions.notify?.(text,where);} - const speechFailed=()=>t('Could not create subtitles','字幕生成失败'),speechSilent=()=>t('No speech found','未检测到语音'); + const speechFailed=()=>t('Could not create subtitles','没能生成字幕'),speechSilent=()=>t('No speech found','没有听到说话声'); const speechReason=reason=>reason==='no_sound'?speechSilent():reason==='language'?t('Language not supported yet','暂不支持这种语言'):speechFailed(); function speechResult(job){ if(job.state==='running'){speechLabel(job.fraction);return true;} @@ -91,7 +91,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. Try again.','下载失败,请重试。'),SPEECH_ISSUE);return;} + if(!done||speech.need){speechEnd(t('Could not download. Try again.','没能下载,请再试一次。'),SPEECH_ISSUE);return;} if(!speech.available){speechEnd(speechFailed(),SPEECH_ISSUE);return;} speech.loading=false; } @@ -101,7 +101,7 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, if(!job){speechEnd(speechFailed(),SPEECH_ISSUE);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(),SPEECH_ISSUE);return false;}return speechResult(value);}); - }catch{if(run===speech.run)speechEnd(speech.loading?t('Could not download. Try again.','下载失败,请重试。'):speechFailed(),SPEECH_ISSUE);} + }catch{if(run===speech.run)speechEnd(speech.loading?t('Could not download. Try again.','没能下载,请再试一次。'):speechFailed(),SPEECH_ISSUE);} } 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. @@ -165,7 +165,7 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, const file=el('input');file.type='file';file.accept='.srt';file.hidden=true; const importButton=button(t('Import subtitle file','导入字幕文件'),{onClick:()=>file.click()});importButton.classList.add('fv-ed-import-srt'); // Only a file that cannot be read says so; a refused edit (a locked track) flashes red like any other. - file.onchange=async()=>{const f=file.files?.[0];if(!f)return;let text;const result={};try{if(f.size>4*1024*1024)throw new Error();text=await f.text();parseSRT(text,result);}catch{actions.notify?.(t('Could not read this subtitle file','无法读取这个字幕文件'));return;}finally{file.value='';} + file.onchange=async()=>{const f=file.files?.[0];if(!f)return;let text;const result={};try{if(f.size>4*1024*1024)throw new Error();text=await f.text();parseSRT(text,result);}catch{actions.notify?.(t('Could not read this subtitle file','读不出这个字幕文件'));return;}finally{file.value='';} if(apply(t('Import subtitle file','导入字幕文件'),r=>importSRT(r,text))){const missing=projectMissing(state.record);if(missing.length)actions.notify?.(missingHint([...new Set(missing.flatMap(r=>r.chars))],t));if(result.truncated)actions.notify?.(t('Only the first 500 subtitles were imported.','只导入了前 500 条字幕。'));}}; // Shown only where this computer can recognise speech. const hear=button(t('Auto subtitles','自动字幕'),{onClick:toggleSpeech});hear.classList.add('fv-ed-auto-subtitles');for(const b of speech.buttons)if(!b.isConnected)speech.buttons.delete(b);speech.buttons.add(hear);speechLabel(speech.busy?speech.shown:null); diff --git a/web/editor_tracks.js b/web/editor_tracks.js index e33166f1..937073ce 100644 --- a/web/editor_tracks.js +++ b/web/editor_tracks.js @@ -536,7 +536,7 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = const failureText = visual === 'failed' ? actions.failureText?.(clip.id) || actions.reasonText?.(clip.reason) || clip.reason || t('Generation failed', '生成时出错') : ''; if (visual === 'failed') item.status.replaceChildren(icon('warning'), create('span', 'fv-ed-clip-failure-reason', failureText)); else item.status.replaceChildren(); - const status = {planned: actions.interrupted?.(clip) ? t('Interrupted', '上次中断') : t('Not generated yet', '尚未生成'), running: t('Generating', '正在生成'), + const status = {planned: actions.interrupted?.(clip) ? t('Interrupted', '上次中断') : t('Not generated yet', '还没生成'), running: t('Generating', '正在生成'), done: t('Complete', '完成'), failed: failureText}[visual]; const seconds = Math.round(clip.frames / model.FPS); item.element.title = ''; @@ -939,11 +939,11 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = return model.move(record, finished.id, result.frame, {targetTrack: result.targetTrack}); }); else if (finished.type === 'plannedBoundary') attempt(t('Resize parts', '调整段长'), record => model.resizePlannedBoundary(record, finished.id, finished.neighbor.clip.id, result.frame, actions.generationLimits?.())); - else if (result.kind === 'planned') attempt(t('Resize', '调整时长'), record => model.resizePlanned(record, finished.id, finished.edge, result.frame, actions.generationLimits?.())); + else if (result.kind === 'planned') attempt(t('Resize', '改长短'), record => model.resizePlanned(record, finished.id, finished.edge, result.frame, actions.generationLimits?.())); else if (result.kind === 'extend') { const frames = result.frame - finished.clip.start - finished.clip.frames + result.frames; if (extendClip(finished.id, frames) && result.raised) { - actions.notify?.(t(`Extended to ${result.frames / model.FPS} s.`, `已延长到 ${result.frames / model.FPS} 秒。`)); + actions.notify?.(t(`Extended to ${result.frames / model.FPS} s.`, `已补到 ${result.frames / model.FPS} 秒。`)); } } else attempt(t('Trim', '裁短'), record => model.trim(record, finished.id, finished.edge, result.frame)); renderSelection(); diff --git a/web/error_panel.js b/web/error_panel.js index 51185e89..817836d7 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -33,13 +33,13 @@ const PLAIN_ERRORS = [ summary: ['Choose a video size with the same aspect ratio as the previous video.', '请选择与上一段视频比例相同的画面大小。']}, {key: 'continuation-route', message: 'Continue from video cannot be used with these settings. Continuing a video needs an NVIDIA GPU or a Mac. To make a new video, clear "Continue from video".', title: ['This computer can\'t continue a video', '这台电脑无法接续视频'], - summary: ['Continuing a video needs an NVIDIA GPU or a Mac. To make a new video, clear "Continue from video".', '接续视频需要 NVIDIA 显卡或 Mac。要生成新视频,请清空“继续生成”。']}, + summary: ['Continuing a video needs an NVIDIA GPU or a Mac. To make a new video, clear "Continue from video".', '接续视频需要 NVIDIA 显卡或 Mac。要生成新视频,请清空“接着生成”。']}, {key: 'continuation-passes', message: 'Continue from video cannot be used with these settings. Turn on "Two-pass acceleration" and turn off the first-pass preview.', title: ['Continuing needs two-pass acceleration', '接续需要二次采样加速'], - summary: ['Turn on "Two-pass acceleration" and turn off the first-pass preview.', '请打开“二次采样加速”,并关闭“先生成预览”。']}, + summary: ['Turn on "Two-pass acceleration" and turn off the first-pass preview.', '请打开“二次采样加速”,并关闭先出预览。']}, {key: 'continuation-refine', message: 'Continue from video cannot be used with these settings. Clear either "Second pass of preview" or "Continue from video".', title: ['A continued part is made in full', '接续会完整生成这一段'], - summary: ['Clear either "Second pass of preview" or "Continue from video".', '请清空“继续二采”或“继续生成”其中一项。']}, + summary: ['Clear either "Second pass of preview" or "Continue from video".', '请清空“继续二采”或“接着生成”其中一项。']}, {key: 'continuation-media', message: 'Continue from video cannot be used with these settings. Connect up to 3 pictures as references, and remove video, audio and pre-encoded inputs.', title: ['A continued part takes picture references only', '接续段只能用图片参考'], summary: ['Connect up to 3 pictures as references, and remove video, audio and pre-encoded inputs.', '最多接 3 张图片作为参考图,并移除视频、音频参考和预编码输入。']}, @@ -56,10 +56,10 @@ const PLAIN_ERRORS = [ summary: ['Select an existing MP4 video generated by FreeVideo. In ComfyUI, keep it inside the output or input folder.', '请选择 FreeVideo 生成且仍存在的 MP4 视频;在 ComfyUI 中,请把它放在输出或输入文件夹内。']}, {key: 'reference-too-short', message: 'The reference video is too short; it needs to be at least about 1 s long.', title: ['The reference video is too short', '参考视频太短'], - summary: ['It needs to be at least about 1 s long. Choose a longer video.', '参考视频至少需要约 1 秒,请选择更长的视频。']}, + summary: ['It needs to be at least about 1 s long. Choose a longer video.', '参考视频至少需要约 1 秒,请换一段更长的视频。']}, {key: 'video-no-picture', message: 'No picture could be read from this video file. Choose another video.', title: ['This video could not be read', '无法读取这个视频'], - summary: ['No picture could be read from this video file. Choose another video.', '无法从此视频文件中读取画面,请选择其他视频。']}, + summary: ['No picture could be read from this video file. Choose another video.', '无法从这个视频文件里读出画面,请换一个视频。']}, {key: 'lora-unusable', message: 'This LoRA was not trained for the FreeVideo model and cannot be used.', prefixes: ['LoRA target/shape incompatible with this VDN model', 'Full-difference LoRA target/shape incompatible with this VDN model', 'Unsupported LoRA tensor (no patches are ignored): ', 'Adapter contains no LoRA factors', 'Expected matching 2-D LoRA factors', 'Incomplete LoRA pair'], @@ -316,12 +316,12 @@ export function failureAdvice(value, t, known = knownError(value)) { } // A custom step count is not a quality level: no "档" / "quality level" for it. const title = custom - ? [`Custom (${known.base_steps} steps) is not downloaded yet`, `“自定义(${known.base_steps} 步)”尚未下载`] - : [`The ${levels[index][0]} quality level is not downloaded yet`, `${keep(`“${levels[index][1]}”档`)}尚未下载`]; - const what = custom ? ['This step count', '此步数'] : ['This level', '该档']; + ? [`Custom (${known.base_steps} steps) is not downloaded yet`, `“自定义(${known.base_steps} 步)”还没下载`] + : [`The ${levels[index][0]} quality level is not downloaded yet`, `${keep(`“${levels[index][1]}”档`)}还没下载`]; + const what = custom ? ['This step count', '这个步数'] : ['This level', '这一档']; return {...row('sampling-table', title, - [`${what[0]} needs a download (about ${size}) that did not finish. Generate again when online; it downloads automatically.${available.length ? ` You can also use ${join(0)} instead.` : ''}`, - `${what[1]}需要先下载(约 ${size}),本次下载未完成。联网后重新生成,将自动下载。${available.length ? `也可以改用${join(1)}。` : ''}`], ['', '']), plain: true}; + [`${what[0]} needs a download (about ${size}) that did not finish this time. Generate again when online and it downloads automatically.${available.length ? ` You can also use ${join(0)} for now.` : ''}`, + `${what[1]}要先下载(约 ${size}),这次没能下载。联网后再生成一次,会自动下载。${available.length ? `也可以先用${join(1)}。` : ''}`], ['', '']), plain: true}; } // ComfyUI and worker tracebacks carry English exceptions. Translate these // actionable failures through the existing signature/advice path. @@ -340,10 +340,10 @@ 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', ['Not enough GPU memory', '显存不足'], + 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}。请调整时长或清晰度后重新生成;关闭其他占用显卡的程序也可释放显存。`]); + `现在可以生成:${zh}。请调整时长或清晰度后重新生成;关闭其他占用显卡的程序也可以腾出显存。`]); } return row('resources', ['The request exceeds available memory', '当前任务的可用内存不足'], ['Generation did not start because there is not enough available RAM or GPU memory.', '可用内存或显存不足,本次生成未开始。'], @@ -382,10 +382,10 @@ export function failureAdvice(value, t, known = knownError(value)) { ['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 (outputNotWritable(text)) return row('output-folder', ['The output folder cannot be written to', '输出目录不可写'], - ['The video could not be saved.', '视频未能保存。'], + ['The video could not be saved.', '视频没能保存。'], ['Check that the output folder can be written to, then retry.', '请检查输出文件夹的写入权限后重试。']); if (/manifest\.json/i.test(text) && /No such file|FileNotFoundError|\[Errno 2\]/i.test(text)) - return row('model-files', ['The model is not fully installed', '模型安装不完整'], + return row('model-files', ['The model is not fully installed', '模型没有装全'], ['Some of the model files FreeVideo needs are missing.', 'FreeVideo 需要的部分模型文件缺失。'], repairAction()); if (/No space left on device|WinError 112|disk (?:is )?full/i.test(text)) @@ -502,9 +502,9 @@ export function createErrorPanel(t) { const location = document.createElement('p'), cause = document.createElement('p'); cause.className = 'fv-failure-cause'; location.className = 'fv-failure-location'; const privacy = document.createElement('p'); privacy.className = 'fv-failure-privacy'; - privacy.textContent = t('Paths and typed text are removed.', '已移除路径和输入的文字。'); + privacy.textContent = t('Paths and typed text are removed.', '已去掉路径和输入的文字。'); const privacyInside = document.createElement('p'); privacyInside.className = 'fv-failure-privacy'; privacyInside.hidden = true; - privacyInside.textContent = t('Paths and typed text are removed.', '已移除路径和输入的文字。'); + privacyInside.textContent = t('Paths and typed text are removed.', '已去掉路径和输入的文字。'); const downloadStatus = document.createElement('p'); downloadStatus.setAttribute('role', 'status'); let currentReport = null, githubLora = false; const disclosure = document.createElement('details'), summary = document.createElement('summary'); @@ -605,7 +605,7 @@ export function createErrorPanel(t) { action.hidden = !action.textContent; privacy.textContent = download.hidden ? t('Select "Download report" in the preview to save the full diagnostic information.', '可在预览区点“下载报告”保存完整诊断信息。') - : t('Paths and typed text are removed.', '已移除路径和输入的文字。'); + : t('Paths and typed text are removed.', '已去掉路径和输入的文字。'); copy.textContent = advice.kind === 'sampling-table' ? t('Copy details', '复制详情') : t('Copy error', '复制错误'); // A generation failure shows two buttons; copying moves into the technical details. copy.hidden = generated; copyInside.hidden = !generated; copyInside.textContent = copy.textContent; diff --git a/web/feedback.js b/web/feedback.js index fdf8065d..b4aba8fa 100644 --- a/web/feedback.js +++ b/web/feedback.js @@ -124,7 +124,7 @@ export function openFeedback(t, { open = openPage, version = productVersion, head.append(el('h2', null, t('Feedback', '反馈')), close); const field = el('textarea'); field.rows = 5; field.maxLength = MAX_TEXT; - field.placeholder = t('Describe what could be improved', '请填写改进建议'); + field.placeholder = t('What could be better?', '哪里可以做得更好?'); field.setAttribute('aria-label', t('Feedback', '反馈')); field.value = readDraft(); @@ -132,7 +132,7 @@ export function openFeedback(t, { open = openPage, version = productVersion, 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 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; @@ -164,7 +164,7 @@ export function openFeedback(t, { open = openPage, version = productVersion, 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.", '未能截取本页,可不附截图提交。'); + 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. @@ -174,7 +174,7 @@ export function openFeedback(t, { open = openPage, version = productVersion, 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.", '未能读取诊断信息,可不附诊断信息提交。'); } + 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(); @@ -190,16 +190,16 @@ export function openFeedback(t, { open = openPage, version = productVersion, const done = await copied; status.textContent = reached && truncated ? (hadPicture || !done - ? t('GitHub is open, but the text was too long and was truncated on GitHub. The full text remains here.', '已打开 GitHub,但文字过长,GitHub 页面中的内容已被截断,完整文字仍保留在此处。') - : t('GitHub is open, but the text was too long and was truncated on GitHub. The full text is copied; paste it there to complete it.', '已打开 GitHub,但文字过长,GitHub 页面中的内容已被截断,完整文字已复制,请粘贴补全。')) + ? 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,请稍后重试。')) + ? (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 there, then submit.', '已打开 GitHub,截图已复制,请粘贴后提交。') + ? t('GitHub is open and the screenshot is copied. Paste it in, then submit.', '已打开 GitHub,截图已复制,粘贴后提交。') : hadPicture - ? t("GitHub is open with the text filled in, but the screenshot couldn't be copied. It can be submitted without the screenshot.", '已打开 GitHub,文字已填好,但截图未能复制,可不附截图提交。') - : t('GitHub is open with the text filled in. Review it, then submit.', '已打开 GitHub,文字已填好,请确认后提交。'); + ? 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)); diff --git a/web/first_run_guide.js b/web/first_run_guide.js index bfad6f4e..1f8ce232 100644 --- a/web/first_run_guide.js +++ b/web/first_run_guide.js @@ -30,7 +30,7 @@ export const CHAPTERS = [ en: 'For a longer video, enter a longer duration and it is made in parts automatically. You can also add parts one by one in "Edit".', zh: '填写更长的时长会自动分段生成,也可在“剪辑视图”中逐段接续。'}, {id: 'agent', anchor: '[data-fv-guide="agent"]', added: true, - en: 'To have an AI agent such as Claude Code or Codex make videos, choose "Autopilot".', zh: '要由 Claude Code、Codex 等 Agent 生\u2060成\u2060视\u2060频,请选择“AI 全自动”。'}, + en: 'To have an AI agent such as Claude Code or Codex make videos for you, choose "Autopilot".', zh: '要让 Claude Code、Codex 等 Agent 替你生\u2060成\u2060视\u2060频,点“AI 全自动”。'}, {id: 'editor-timeline', group: 'editor', anchor: '[data-fv-guide="editor-timeline"]', added: true, en: 'Drag a video here.', zh: '把视频拖到这里。'}, {id: 'editor-trim', group: 'editor', anchor: '[data-fv-guide="editor-trim"]', added: true, diff --git a/web/freevideo.js b/web/freevideo.js index 05bba259..a92283ca 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -205,7 +205,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('This image cannot be edited at the moment; choose an image again.', '图片暂时无法编辑,可重新选择图片。'), 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); @@ -237,7 +237,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('This image cannot be edited at the moment; choose an image again.', '图片暂时无法编辑,可重新选择图片。'), true); return; } + } catch { message(text('This image cannot be edited right now; choose an image again.', '图片暂时无法编辑,可重新选择图片。'), true); return; } if (!outcome?.blob) return; if (read().length >= 32) { message(text('The media list is full (32 items, including disabled ones). Remove some to add more.', '素材已满 32 个(含未启用的),请先移除不用的素材。'), true); return; } node.isUploading = true; add.disabled = addAudio.disabled = generateImage.disabled = true; @@ -574,7 +574,7 @@ function resultPanel(node) { } panel.append(stats, links, progress.stall, progress.report); for (const row of value.reference_trims || []) panel.append(el("div", referenceTrimText(row, text), "fv-note fv-trim-note")); - if (value.low_memory && !value.result_cache_hit) resultNotes.push(el("div", text('Generation was slow; try a lower resolution or shorter video.', '生成较慢,可降低分辨率或缩短时长后重试。'), "fv-note")); + if (value.low_memory && !value.result_cache_hit) resultNotes.push(el("div", text('Generation was slow; try a lower resolution or shorter video.', '生成较慢,可降低分辨率或缩短时长后再试。'), "fv-note")); if (value.thermal && !value.result_cache_hit) resultNotes.push(el("div", thermalText(value.thermal, text), "fv-note")); const shared = value.shared_gpu && !value.result_cache_hit ? sharedGpuText(value.shared_gpu, text) : ''; if (shared) resultNotes.push(el("div", shared, "fv-note")); @@ -678,7 +678,7 @@ async function openFromMenu() { showStudio(node); } catch (error) { console.warn('FreeVideo menu:', error); - const message = text('Could not complete. Try again.', '未完成,请重试'); + const message = text('Could not complete. Try again.', '没有完成,请重试'); const dialog = el('dialog', undefined, 'fv-panel fv-menu-failure'); menuFailure = dialog; dialog.setAttribute('aria-label', 'FreeVideo'); const actions = el('div', undefined, 'fv-row'); @@ -773,9 +773,9 @@ app.registerExtension({ ['seconds', '时长(秒)'], ['seed', '种子'], ['force_regenerate', '重新生成', '跳过已保存结果的缓存。'], - ['preview', '先生成预览', '先生成半分辨率预览,满意后在结果中点击“继续二采”,成片与直接生成一致。'], + ['preview', '先出预览', '先出半分辨率预览,满意后在结果里点“继续二采”,成片与直接生成一致。'], ['refine_from', '继续二采', '从这个预览继续生成全分辨率视频。'], - ['continue_from', '继续生成'], + ['continue_from', '接着生成'], ['frames', '帧数'], ['first_frame_reference', '首帧参考', '减小漂移'], ['step_cache', '步间缓存', '复用相近采样步之间的计算,更快完成,结果会有细微变化。'], @@ -842,7 +842,7 @@ app.registerExtension({ else { const studio = showStudio(node), status = studio?.querySelector?.('.fv-status'); if (status) { - const message = text('Could not complete. Try again.', '未完成,请重试'); + const message = text('Could not complete. Try again.', '没有完成,请重试'); status.dataset.error = 'true'; status.textContent = message; status.append(issueButton(text, () => ({where: ['Open editor', '打开剪辑'], message}))); } diff --git a/web/generation_progress.js b/web/generation_progress.js index d1b07896..31df724b 100644 --- a/web/generation_progress.js +++ b/web/generation_progress.js @@ -22,7 +22,7 @@ const SUBACTIVITY = { reference: ['Processing reference images and videos', '正在处理参考图片和视频'], load: ['Loading the video model', '正在载入视频模型'], first: ['Running the first step (it takes longer the first time a size is used or after an update)', - '正在进行第一步(首次使用此尺寸或更新后,第一步耗时较长)'], + '正在进行第一步(首次使用这个尺寸或更新后,第一步要多花一些时间)'], upscale: ['Upscaling the video', '正在放大画面'], decoder: ['Preparing video output', '正在准备输出视频'], decode: ['Rendering the video', '正在输出视频'], @@ -105,7 +105,7 @@ const STALL_SECONDS_BY_PHASE = Object.freeze({ // Stage words the product already uses: settings say 一采 / 二采, results say 解码. const ACTIVITY = { prepare: ['Preparing', '正在准备'], - describe: ['Writing part descriptions', '正在生成各段描述'], + describe: ['Writing part descriptions', '正在写各段描述'], prompt: ['Processing the prompt', '正在处理提示词'], combined: ['Processing the prompt and references', '正在处理提示词和参考素材'], load: ['Loading the video model', '正在载入视频模型'], @@ -230,8 +230,8 @@ function activityLabel(message, t, plan = {}, secondPass = false, references = f function retryText(retry, t) { const reason = {gpu_oom: ['Out of GPU memory', '显存不足'], ram_pressure: ['Out of RAM', '内存不足'], - unified_memory: ['Not enough memory', '内存不足']}[retry.kind] - || ['Previous attempt did not finish', '上一次尝试未完成']; + unified_memory: ['Not enough memory', '内存不够']}[retry.kind] + || ['Previous attempt did not finish', '上一次尝试没有完成']; if (retry.reuse_sampling === true) return t(`${reason[0]}; the generated result is kept, and a retry is in progress.`, `${reason[1]},已保留生成结果,正在重试。`); return t(`${reason[0]}; adjusting settings and retrying.`, `${reason[1]},正在调整设置后重试。`); @@ -868,7 +868,7 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f eta.hidden = remainingSeconds === null; // Past the shown time the line says so once, instead of a number that only shrinks. late.hidden = !(remainingSeconds === null && remainingRetired && !ended); - late.textContent = late.hidden ? '' : t('Taking longer than expected', '耗时超出预计,仍在生成'); + late.textContent = late.hidden ? '' : t('Taking longer than expected', '比预计久,仍在生成'); const etaText = remainingSeconds === null ? '' : remainingSeconds >= 60 ? t(`About ${Math.ceil(remainingSeconds / 60)}\u00a0min left`, `预计还需约 ${Math.ceil(remainingSeconds / 60)}\u00a0分钟`) : t('Less than a minute left', '预计还需不到 1\u00a0分钟'); diff --git a/web/health.js b/web/health.js index e8a59e6f..81add2c2 100644 --- a/web/health.js +++ b/web/health.js @@ -135,7 +135,7 @@ const ADVICE = { files: t('FreeVideo’s page files are missing or unreadable. In the FreeVideo launcher, open Settings and click Repair, then open FreeVideo again. Without the launcher, update the FreeVideo node and restart ComfyUI.', 'FreeVideo 的网页文件缺失或无法读取。请在 FreeVideo 启动器的“设置”里点击“修复”,完成后重新打开。没有使用启动器时,请更新 FreeVideo 节点并重启 ComfyUI。'), type: t('ComfyUI sent the file with the wrong type, so the browser refused it. Restart ComfyUI. If it persists, use Report a problem.', - 'ComfyUI 返回的文件类型有误,浏览器已拒绝加载。请重启 ComfyUI;仍然出现时,请点“提交问题”。'), + 'ComfyUI 发给浏览器的文件类型不对,浏览器拒绝使用。请重启 ComfyUI;仍然出现时,请点“提交问题”。'), browser: t('This browser cannot run FreeVideo. Open the same address in a current Chrome or Edge.', '当前浏览器无法运行 FreeVideo。请用最新版 Chrome 或 Edge 打开同一个地址。'), mixed: t('The browser mixed files from two FreeVideo versions. Press Ctrl+F5 to reload without the cache.', @@ -373,7 +373,7 @@ function workspaceProblem(app) { // The editor shares the studio shell; either view replaces the floating toolbar. const studio = document.querySelector('dialog.fv-editor[open]') || document.querySelector('dialog.fv-studio[open]'); if (studio) return onScreen(studio) ? null - : {text: t('The creative workspace is open but cannot be seen on the page.', '创作面板已打开,但在页面上不可见。'), advice: 'reload'}; + : {text: t('The creative workspace is open but cannot be seen on the page.', '创作面板已经打开,但页面上看不到。'), advice: 'reload'}; const bar = document.querySelector('.fv-view-navigation'); if (!bar) return {text: t('The FreeVideo toolbar that opens the creative workspace is missing from the page.', '页面上没有打开创作面板的 FreeVideo 工具栏。'), advice: 'reload'}; if (onScreen(bar) && !covered(bar)) return null; @@ -381,7 +381,7 @@ function workspaceProblem(app) { return sheetLoaded('view_navigation.css') ? {text: t('The FreeVideo toolbar that opens the creative workspace is hidden or covered by other page elements.', '打开创作面板的 FreeVideo 工具栏被页面上的其他元素隐藏或遮挡了。'), advice: 'plugin'} : {text: t('The FreeVideo toolbar that opens the creative workspace cannot be seen: its stylesheet view_navigation.css did not load.', - '打开创作面板的 FreeVideo 工具栏不可见:样式文件 view_navigation.css 没有加载。'), advice: 'reload', sheet: 'view_navigation.css'}; + '看不到打开创作面板的 FreeVideo 工具栏:样式文件 view_navigation.css 没有加载。'), advice: 'reload', sheet: 'view_navigation.css'}; } // Looks again as the page changes, such as another workflow coming to the @@ -411,7 +411,7 @@ async function watchWorkspace(app) { } function reportText(item) { - const where = {studio: t('The creative workspace could not open', '创作面板无法打开'), + const where = {studio: t('The creative workspace could not open', '创作面板打不开'), navigation: t('The FreeVideo toolbar could not be added', 'FreeVideo 工具栏没有加载'), progress: t('Live progress could not start', '生成进度无法更新'), updates: t('Update checks could not start', '更新检查无法启动'), @@ -646,7 +646,7 @@ async function check() { if (launchVisit && !problems.length) { if (!launched) problems.push({text: t('The FreeVideo workflow did not open: launcher.js did not report back.', 'FreeVideo 工作流没有打开:launcher.js 没有响应。'), advice: 'reload'}); else if (launched.error) problems.push(reportText(launched)); - else if (!studioWorkflow(app.graph)) problems.push({text: t('ComfyUI shows another workflow; the FreeVideo workflow is in another tab.', 'ComfyUI 显示的是另一个工作流,FreeVideo 工作流在其他标签页中。'), advice: 'relaunch'}); + else if (!studioWorkflow(app.graph)) problems.push({text: t('ComfyUI shows another workflow; the FreeVideo workflow is in another tab.', 'ComfyUI 显示的是另一个工作流,FreeVideo 工作流在别的标签页里。'), advice: 'relaunch'}); else checkStudio = true; } for (const item of reports) if (item.type === 'error') problems.push(reportText(item)); diff --git a/web/howto.js b/web/howto.js index d311010d..a291e927 100644 --- a/web/howto.js +++ b/web/howto.js @@ -35,7 +35,7 @@ const HOWTOS = [ zh: '填写更长的时长,会自动分段生成。'}, {id: 'generate', anchor: '[data-fv-guide="generate"]', en: 'Choose "Generate video".', zh: '点“生成视频”。'}, {id: 'extend', anchor: '.fv-action-extend', image: 'extend-button', - en: 'Choose "Extend" on a finished video to continue it.', zh: '在已生成的视频上点击“继续生成”,继续下一段。'}, + en: 'Choose "Extend" on a finished video to continue it.', zh: '在生成好的视频上点“接着生成”,继续下一段。'}, {id: 'longer', anchor: '[data-view="editor"]', en: 'Parts can also be added one by one in "Edit".', zh: '也可在“剪辑视图”中逐段接续。'}]}, {id: 'editor-extend-refs', view: 'editor', title: ['Add reference images when extending in Edit', '在剪辑视图延长时加参考图'], steps: [ diff --git a/web/image_editor.js b/web/image_editor.js index c171b639..4cd1efc8 100644 --- a/web/image_editor.js +++ b/web/image_editor.js @@ -221,7 +221,7 @@ export async function openImageEditor({source = null, name = source || '', targe panel.append(props, layersBox); main.append(toolbar, workspace, panel); const confirmBar = el('div', null, 'fv-ie-confirm'); confirmBar.hidden = true; - confirmBar.append(el('span', t('Leave without saving? Your changes to this image will be lost.', '确定不保存并离开?对这张图片的修改将丢失。')), + confirmBar.append(el('span', t('Leave without saving? Your changes to this image will be lost.', '不保存就离开?对这张图的修改会丢失。')), button(t('Discard', '放弃修改'), null, () => close(null), 'fv-ie-button fv-ie-danger'), button(t('Keep editing', '继续编辑'), null, () => { confirmBar.hidden = true; save.focus(); })); const status = el('div', '', 'fv-ie-status'); status.setAttribute('role', 'status'); @@ -538,7 +538,7 @@ export async function openImageEditor({source = null, name = source || '', targe let text = ''; if (['eraser', 'heal', 'clone', 'blur'].includes(tool) && !rasterKinds.has(layer.kind)) text = t('Pick an image or paint layer to work on its pixels.', '请选择图片图层或绘画图层再处理像素。'); else if (tool === 'clone' && !opts.clone.source) text = t('Alt-click to set the source, then paint.', '先按住 Alt 点击设置取样点,再涂抹。'); - else if (tool === 'move' && layer.locked && doc.layers.length > 1) text = t('The original image is locked. Click another layer to move it.', '原图图层已锁定;点击其他图层后可移动。'); + else if (tool === 'move' && layer.locked && doc.layers.length > 1) text = t('The original image is locked. Click another layer to move it.', '原图图层已锁定;点击其他图层即可移动。'); else if (tool === 'lasso' && opts.lasso.type === 'polygon') text = t('Click to add corners; double-click or press Enter to close.', '单击添加顶点;双击或按 Enter 闭合。'); hint.textContent = text; hint.hidden = !text; } @@ -922,7 +922,7 @@ export async function openImageEditor({source = null, name = source || '', targe commitInserted(commands.addImageLayer(doc, {src: file, nw: img.naturalWidth, nh: img.naturalHeight, name: label || file.split('/').pop()})); select('move'); - } catch { message(t('The image could not be added. Choose another one.', '无法添加图片,请更换图片。'), ['Image editor', '图片编辑器']); } + } catch { message(t('The image could not be added. Choose another one.', '图片没能添加,请换一张。'), ['Image editor', '图片编辑器']); } } filePicker.onchange = async () => { const file = filePicker.files?.[0]; filePicker.value = ''; @@ -1031,7 +1031,7 @@ export async function openImageEditor({source = null, name = source || '', targe // [id, label, size or null when it would not make the picture smaller, why not] const presets = [ ...(target ? [['video', t('Video size', '视频尺寸'), video < 1 ? sized(video) : null, - t('The picture is no larger than the video', '图片尺寸已不大于视频')]] : []), + t('The picture is no larger than the video', '图片已经不比视频大')]] : []), ['half', '50%', sized(.5), ''], ['long', t('Long side 2048', '长边 2048'), long > 2048 ? sized(2048 / long) : null, t('The long side is already 2048 px or less', '长边已经不超过 2048 像素')], @@ -1083,7 +1083,7 @@ export async function openImageEditor({source = null, name = source || '', targe if (reason === 'dimensions') { text = t('Enter whole numbers from 1 to 20000.', '请输入 1 到 20000 的整数。'); warn = true; } else if (reason === 'enlargement') { text = t('You can enlarge up to 200% of the original.', '最多放大到原来的 200%。'); warn = true; } else if (reason === 'pixels') { text = t('Up to 64 megapixels; reduce the width or height.', '最多 6400 万像素,请减小宽或高。'); warn = true; } - else if (reason === 'unchanged') { text = t('Same as the current size.', '与当前尺寸相同。'); } + else if (reason === 'unchanged') { text = t('The same size as now.', '和现在一样大。'); } else if (kx > 1 + 1e-9 || ky > 1 + 1e-9) { text = t(`${share()}; enlarging makes the picture softer.`, `${share('放大到')},画面会变模糊。`); warn = true; } else if (target && presets[0][2] && w === presets[0][2][0] && h === presets[0][2][1]) { @@ -1149,7 +1149,7 @@ export async function openImageEditor({source = null, name = source || '', targe if (layer) commit(pixelCommand(doc, layer.id, d => commands.fillSelection(d, layer.id, color, selection))); } function clearSelection() { - if (!selection) { message(t('Nothing is selected.', '尚无选区。')); return; } + if (!selection) { message(t('Nothing is selected.', '还没有选区。')); return; } const layer = pixelLayer(); if (layer && selection) commit(pixelCommand(doc, layer.id, d => commands.clearSelection(d, layer.id, selection))); } @@ -1249,13 +1249,13 @@ export async function openImageEditor({source = null, name = source || '', targe if (!pointInfo?.ready) { flashPointDownload(); return null; } if (pointBusy) return null; const how = mode || opts.point.mode; - pointBusy = true; message(t('Finding the object…', '正在识别物体…')); + pointBusy = true; message(t('Finding the object…', '正在找出物体……')); segmentAt(renderer, genClient, doc, src, [u, v]).then(file => { if (doc.layers.indexOf(src) < 0) return; addPart({shape: 'mask', layer: src.id, file, upto: src.ops.length}, how); message(''); }).catch(error => { - message(error?.code === 'busy_video' ? t('A video is generating. Try again when it finishes.', '视频正在生成,请在完成后重试。') - : t('Could not find an object here. Try again.', '未能在此处识别出物体,请重试。'), error?.code === 'busy_video' ? null : ['Image object selection', '图片智能抠图']); + message(error?.code === 'busy_video' ? t('A video is generating. Try again when it finishes.', '视频正在生成,完成后再试。') + : t('Could not find an object here. Try again.', '没能在这里找出物体,请再试一次。'), error?.code === 'busy_video' ? null : ['Image object selection', '图片智能抠图']); }).finally(() => { pointBusy = false; }); return null; } @@ -1635,7 +1635,7 @@ export async function openImageEditor({source = null, name = source || '', targe const {blob, width, height} = await renderer.export(doc, extension === 'jpg' ? 'image/jpeg' : 'image/png'); close({blob, name: name ? `${stem}-edit.${extension}` : `${stem}.${extension}`, edit: doc, width, height}); } catch { - message(t('The image could not be saved. Try a smaller canvas or reload the page.', '图片未能保存,请缩小画布或刷新页面后重试。'), ['Image editor save', '图片编辑器保存']); + message(t('The image could not be saved. Try a smaller canvas or reload the page.', '图片没能保存,请缩小画布或刷新页面后重试。'), ['Image editor save', '图片编辑器保存']); save.disabled = cancel.disabled = false; } } diff --git a/web/image_generation.js b/web/image_generation.js index 9898ece8..bc720902 100644 --- a/web/image_generation.js +++ b/web/image_generation.js @@ -129,7 +129,7 @@ export function imagePackMessage(info, t) { // A non-breaking space keeps the number and its unit on one line. const size = (info?.pack_size || (bytes > 0 ? (bytes / 2 ** 30).toFixed(1) + ' GiB' : '17.3 GB')) .replace(/ (?=[KMGT]i?B$)/, '\u00a0'); - return t(`Download "${name}" in the launcher first, ${size}.`, `请先在启动器中下载“${name}”,${size}。`); + return t(`Download "${name}" in the launcher first, ${size}.`, `先在启动器里下载“${name}”,${size}。`); } // What the page offers while the image models are missing: one button, one muted line, and the bar once loading. export function packView(info, t) { @@ -156,19 +156,19 @@ export function packView(info, t) { export function imageGenError(code, info, t) { const reasons = { no_memory: ['Not enough GPU memory, even at the smallest size. Close other apps using the GPU, then try again.', - '显存不足,最小尺寸也未能生成。请关闭其他占用显卡的程序后重试。'], - busy_video: ['A video is generating. Try again when it finishes.', '视频正在生成,请在完成后重试。'], - busy: ['An image is already being generated. Try again when it finishes.', '已有图片正在生成,请在完成后重试。'], + '显存不够,最小尺寸也没能生成。关掉其他占用显卡的程序后再试。'], + busy_video: ['A video is generating. Try again when it finishes.', '视频正在生成,完成后再试。'], + busy: ['An image is already being generated. Try again when it finishes.', '已有一张图在生成,完成后再试。'], model_unreadable: ['The image generation model files could not be read. Repair them in the launcher and try again.', - '无法读取图像生成模型文件,请在启动器中修复后重试。'], - device_unavailable: ['The graphics card is temporarily unavailable. Try again later.', '显卡暂时不可用,请稍后重试。'], - worker_exit: ['Image generation stopped unexpectedly. Try again.', '图像生成意外停止,请重试。'], - timeout: ['This image exceeded the time limit and was stopped. Try again.', '此图片生成超时,已停止。请重试。'], + '图像生成模型的文件读不出来。在启动器里修复后再试。'], + device_unavailable: ['The graphics card is not available right now. Try again later.', '显卡暂时用不了,稍后再试。'], + worker_exit: ['Image generation stopped unexpectedly. Try again.', '图像生成意外停止了,请再试一次。'], + timeout: ['This image took too long and was stopped. Try again.', '这一张用时太长,已停止。请再试一次。'], cancelled: ['Image generation was cancelled.', '已取消图像生成。'], invalid_request: ['The image settings are invalid. Adjust them and try again.', '图片设置有误,请调整后重试。'], - invalid_media: ['The reference image cannot be read. Choose another image.', '无法读取参考图,请更换一张。'], - reload: ['Image generation was interrupted. Try again.', '图片生成被中断,请重试。'], - generation_failed: ['Image generation encountered an error. Try again.', '图像生成出错,请重试。'], + invalid_media: ['The reference image cannot be read. Choose another image.', '参考图读不出来,请换一张图。'], + reload: ['Image generation was interrupted. Try again.', '图片生成中途被打断,请重试。'], + generation_failed: ['Image generation ran into an error. Try again.', '图像生成出错了,请再试一次。'], small_memory: ['This Mac has less than 16 GB of memory and cannot generate pictures.', '这台 Mac 的内存不足 16 GB,无法生成图片。'], }; @@ -180,13 +180,13 @@ export function imageGenError(code, info, t) { const stage = info?.stage, size = info?.fitted; if (stage === 'encode') return say('Not enough GPU memory to load the reference images. Use fewer reference images, or close other apps using the GPU, then try again.', - '显存不足,参考图未能载入。请减少参考图数量,或关闭其他占用显卡的程序后重试。'); + '显存不够,参考图没能载入。少用几张参考图,或关掉其他占用显卡的程序后再试。'); if (stage === 'load' || stage === 'text') return say('Not enough GPU memory to load the model. Close other apps using the GPU, then try again.', - '显存不足,模型未能载入。请关闭其他占用显卡的程序后重试。'); + '显存不够,模型没能载入。关掉其他占用显卡的程序后再试。'); if (size?.width && size?.height) return say(`Not enough GPU memory, even at ${size.width}\u00a0×\u00a0${size.height}. Close other apps using the GPU, then try again.`, - `显存不足,${size.width}\u00a0×\u00a0${size.height} 也未能生成。请关闭其他占用显卡的程序后重试。`); + `显存不够,${size.width}\u00a0×\u00a0${size.height} 也没能生成。关掉其他占用显卡的程序后再试。`); } return say(...(Object.hasOwn(reasons, code) ? reasons[code] : reasons.generation_failed)); } @@ -648,7 +648,7 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection menu.append(summary, list); const draw = () => { list.replaceChildren(); - if (!past.length) { list.append(el('div', t('No images generated yet', '暂无已生成的图片'), 'fv-ie-menu-label')); return; } + if (!past.length) { list.append(el('div', t('No images generated yet', '还没有生成过图片'), 'fv-ie-menu-label')); return; } const grid = el('div', null, 'fv-ie-gen-history-grid'); for (const item of past) { const tile = el('div', null, 'fv-ie-gen-history-tile'); @@ -712,7 +712,7 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection b.setAttribute('aria-pressed', String(b.dataset.value === mode)); }); input.disabled = disabled; renderReferences(refs, disabled); - input.placeholder = mode === 'edit' ? getSelection() ? t('Describe the change in the selection', '描述选区内的修改') : t('Describe the change', '描述修改内容') : t('Describe the picture', '描述画面'); + input.placeholder = mode === 'edit' ? getSelection() ? t('What to change in the selection', '要改选区里的什么') : t('What to change', '要改什么') : t('Describe the picture', '描述画面'); input.setAttribute('aria-label', input.placeholder); const pack = missing ? packView(state.info, t) : null; action.textContent = pack ? pack.button : state.running ? t('Stop', '停止') : t('Generate', '生成'); diff --git a/web/library.js b/web/library.js index 0ec8403f..957bc7d0 100644 --- a/web/library.js +++ b/web/library.js @@ -93,7 +93,7 @@ export async function libraryPage(t, {limit = 24, before = null, signal} = {}) { const query = new URLSearchParams({limit: String(limit)}); if (before) query.set('before', before); const response = await api.fetchApi('/freevideo/library?' + query, {signal}); - if (response.status === 404) throw Object.assign(new Error(t('Restart ComfyUI to open your creations after updating.', '更新后需重启 ComfyUI 才能打开作品。')), {restart: true}); + if (response.status === 404) throw Object.assign(new Error(t('Restart ComfyUI to open your creations after updating.', '更新后重启 ComfyUI,即可打开作品。')), {restart: true}); if (!response.ok) throw new Error(t('Could not load your creations. Try Refresh.', '作品暂时无法读取,请点击刷新。')); return response.json(); } @@ -144,19 +144,19 @@ async function workflowBackfill(collection, t) { const result = value?.result || {}; heading.textContent = result.embedded ? t(`Workflows added to ${result.embedded} videos`, `已为 ${result.embedded} 个视频补上工作流`) : t('No video was changed', '没有视频被改动'); text.textContent = result.embedded - ? t('Drag them onto the canvas to restore their workflow. The originals are kept next to them as video.before-workflow.mp4.', '把它们拖到画布上可还原工作流。原视频保留在各自目录,文件名为 video.before-workflow.mp4。') + ? t('Drag them onto the canvas to restore their workflow. The originals are kept next to them as video.before-workflow.mp4.', '把它们拖到画布上即可还原工作流。原视频保留在各自目录,文件名为 video.before-workflow.mp4。') : t('Videos that could not be checked safely were left as they were.', '无法安全确认的视频都保持原样。'); if (result.failed) text.textContent += ' ' + t(`${result.failed} videos were left unchanged and will be offered again.`, `另有 ${result.failed} 个视频未改动,之后会再次提示。`); - actions.replaceChildren(button(t('Done', '完成'), () => box.remove(), 'fv-quiet')); + actions.replaceChildren(button(t('Done', '好的'), () => box.remove(), 'fv-quiet')); } if (value.state === 'running') { follow(); return; } heading.textContent = t('Add workflows to earlier videos', '为之前的视频补上工作流'); text.textContent = t(`${value.count} earlier videos can get the workflow they were made with, so dropping them on the canvas restores it. Nothing changes until you confirm, and each original is kept as a backup (about ${gb(value.bytes)} GB).`, - `有 ${value.count} 个之前生成的视频可以补上生成时的工作流,补写后拖到画布可还原。确认前不会改动任何文件,每个原视频都会保留一份备份(约 ${gb(value.bytes)} GB)。`); + `有 ${value.count} 个之前生成的视频可以补上生成时的工作流,补写后拖到画布即可还原。确认前不会改动任何文件,每个原视频都会保留一份备份(约 ${gb(value.bytes)} GB)。`); const run = button(t('Add workflows', '补写工作流'), async () => { run.disabled = later.disabled = true; try { if (!(await post('run')).ok) throw new Error(); follow(); } - catch { run.disabled = later.disabled = false; text.textContent = t('Could not start. Try again.', '未能开始,请重试。'); } + catch { run.disabled = later.disabled = false; text.textContent = t('Could not start. Try again.', '没能开始,请重试。'); } }, 'fv-primary'); const later = button(t('Don\'t ask again', '不再提示'), async () => { box.remove(); try { await post('dismiss'); } catch {} }, 'fv-quiet'); actions.append(run, later); @@ -301,7 +301,7 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu notice.textContent = ''; try { await upscale(row); - if (!disposed && selected === row.video) notice.textContent = t('Queued. It appears in "Creations" when done.', '已加入队列,完成后可在“作品”中查看。'); + if (!disposed && selected === row.video) notice.textContent = t('Queued. It appears in "Creations" when done.', '已加入队列,完成后在“作品”里。'); } catch (error) { if (!disposed && selected === row.video) fault(notice, t('Could not start the second pass: ', '无法继续二采:') + error.message, ['Second pass', '二采']); throw error; @@ -323,9 +323,9 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu throw Object.assign(new Error(error.message === 'foreign-files' ? t('Not deleted: the folder also holds files FreeVideo did not write: ', '未删除:文件夹里还有不是 FreeVideo 生成的文件:') + (value.kept || []).join(', ') : error.message === 'editor-in-use' ? t('Not deleted: these edits use it: ', '未删除:这些剪辑正在使用它:') + (value.kept || []).join(', ') - : error.message === 'editor-unavailable' ? t('Not deleted: saved edits could not be checked. Try again in a moment.', '未删除:暂时无法检查已保存的剪辑,请稍后重试。') - : error.message === 'continuation-in-use' ? t('Not deleted: a video continuing from it is running or queued. Try again after it finishes.', '未删除:接在这条视频后面的生成正在运行或排队,请在其完成后重试。') - : error.message === 'in-use' ? t('Not deleted: its second pass is running, or the video is still open. Try again in a moment.', '未删除:正在用它进行二采,或视频仍被占用,请稍后重试。') + : error.message === 'editor-unavailable' ? t('Not deleted: saved edits could not be checked. Try again in a moment.', '未删除:暂时无法检查已保存的剪辑,请稍后再试。') + : error.message === 'continuation-in-use' ? t('Not deleted: a video continuing from it is running or queued. Try again after it finishes.', '未删除:接在这条视频后面的生成正在运行或排队,请等它完成后再试。') + : error.message === 'in-use' ? t('Not deleted: its second pass is running, or the video is still open. Try again in a moment.', '未删除:正在用它跑二采,或视频仍被占用,请稍后再试。') : t('Could not delete this creation.', '无法删除这条作品。')), { reportable: !['foreign-files', 'editor-in-use', 'continuation-in-use', 'in-use'].includes(error.message), }); @@ -501,14 +501,14 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu if (settled.length) window.dispatchEvent(new CustomEvent('freevideo-library-restored')); if (disposed) return; if (again.length) { - undoFailure = t('Could not undo. Try again.', '未能撤销,请重试。'); + undoFailure = t('Could not undo. Try again.', '没能撤销,请重试。'); showUndo(); if (settled.length) load(false); return; } undoFailure = ''; undoBar.replaceChildren(el('span', expired ? t('The undo time has passed.', '已过了撤销时间。') - : lost ? t(`Restored, but ${lost} could not be restored.`, `已恢复,其中 ${lost} 个未能恢复。`) : t('Restored.', '已恢复。'))); + : lost ? t(`Restored, but ${lost} could not be brought back.`, `已恢复,其中 ${lost} 个没能恢复。`) : t('Restored.', '已恢复。'))); if (lost && !expired) { const message = undoBar.firstElementChild.textContent; undoBar.append(issueButton(t, () => ({where: ['Restore creations', '恢复作品'], message}))); @@ -522,9 +522,9 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu const skippedText = skipped => { const using = skipped.filter(row => row.reason !== 'foreign-files').length, other = skipped.length - using; const folders = (count, en) => count === 1 ? en.replace('folders have', 'folder has') : en; - if (using && other) return t(`${using + other} kept: ${using} in use, ${other} ${folders(other, 'folders have')} other files.`, `${using + other} 个已保留:${using} 个正在使用,${other} 个文件夹中有其他文件。`); + if (using && other) return t(`${using + other} kept: ${using} in use, ${other} ${folders(other, 'folders have')} other files.`, `${using + other} 个已保留:${using} 个正在使用,${other} 个文件夹里有别的文件。`); if (using) return t(`${using} in use, kept.`, `${using} 个正在使用,已保留。`); - return t(`${other} ${folders(other, 'folders have')} other files, kept.`, `${other} 个文件夹中有其他文件,已保留。`); + return t(`${other} ${folders(other, 'folders have')} other files, kept.`, `${other} 个文件夹里有别的文件,已保留。`); }; async function removeSelected() { deleting = true; syncSelection(); @@ -600,8 +600,8 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu for (const video of removed) picked.delete(video); confirming = false; undoFailure = removed.size - ? t(`${picked.size} could not be deleted. Try again.`, `仍有 ${picked.size} 个未能删除,请重试。`) - : t('Could not delete. Try again.', '未能删除,请重试。'); + ? t(`${picked.size} could not be deleted. Try again.`, `还有 ${picked.size} 个没能删除,请重试。`) + : t('Could not delete. Try again.', '没能删除,请重试。'); if (!picked.size) setSelecting(false); else regroup(); } else { undoFailure = ''; setSelecting(false); } if (!selected && !narrow.matches && rows.size) select(rows.values().next().value, false); diff --git a/web/media_upload.js b/web/media_upload.js index c01b0ae3..b849ff2a 100644 --- a/web/media_upload.js +++ b/web/media_upload.js @@ -11,9 +11,9 @@ const failures = () => ({ empty: text('This file is empty. Choose another file.', '这个文件是空的,请换一个文件。'), unsupported: text('Choose an image, video or audio file', '请选择图片、视频或音频文件'), multiple: text('Upload one file at a time.', '一次只能上传一个文件。'), - invalid: text('This file name cannot be used. Rename the file and try again.', '此文件名不可用,请重命名后重试。'), + invalid: text('This file name cannot be used. Rename the file and try again.', '这个文件名不能使用,请改名后再试。'), }); -const generic = () => text('The upload did not finish. Try again.', '上传未完成,请重试。'); +const generic = () => text('The upload did not finish. Try again.', '上传没有完成,请再试一次。'); export async function uploadMedia(api, file) { const found = failures(); if (file.size > MAX_UPLOAD_BYTES) throw new Error(found.too_large); diff --git a/web/phone.js b/web/phone.js index 182527a8..864a2121 100644 --- a/web/phone.js +++ b/web/phone.js @@ -14,7 +14,7 @@ let chat = emptyChat(), jobs = {}, selected, revision = 0, cursor = null, histor let lastSend = null, toastTimer = null, flash = null, toastJob = null, permissionAsked = false; try { permissionAsked = localStorage.getItem('freevideo.phone.notification-asked') === '1'; } catch {} const drafts = new Map(), rows = new Map(), scrollPositions = {chat: 0, jobs: 0}; -const top = el('header', 'phone-top'), offline = el('div', 'phone-offline', t('Connection lost. Reconnecting…', '连接已断开,正在重新连接…')); +const top = el('header', 'phone-top'), offline = el('div', 'phone-offline', t('Connection lost. Reconnecting…', '连接断了,正在重新连接…')); offline.hidden = true; offline.setAttribute('role', 'status'); const main = el('main', 'phone-main'), log = el('ol', 'phone-log'); log.setAttribute('role', 'log'); log.setAttribute('aria-label', t('Chat', '对话')); let convRows = [], convCurrent = ''; @@ -26,7 +26,7 @@ function renderSheet() { const fresh = button('phone-sheet-new', t('New chat', '新对话'), async () => { fresh.disabled = true; try { const data = await json(await dataLayer.create()); if (!data.session) throw new Error(); closeSheet(); choose(data.session); } - catch { closeSheet(); notice(t('Could not start a new chat. Try again.', '新建失败,请重试。')); } + catch { closeSheet(); notice(t('Could not start a new chat. Try again.', '没有新建成功,请重试。')); } }); const list = el('ul', 'phone-sheet-list'); for (const row of convRows.some(row => row.id === convCurrent) || !convCurrent ? convRows : [{id: convCurrent}, ...convRows]) { @@ -49,7 +49,7 @@ sheet.addEventListener('keydown', event => { if (event.key === 'Escape') { event const generations = el('div', 'phone-generations'); main.append(log, generations); const compose = el('form', 'phone-compose'), input = el('textarea'), send = button('phone-send', '', () => compose.requestSubmit()); -input.rows = 1; input.maxLength = 8000; input.placeholder = t('Describe the video to generate', '描述要生成的视频'); input.setAttribute('aria-label', input.placeholder); +input.rows = 1; input.maxLength = 8000; input.placeholder = t('What would you like to make?', '想做什么视频?'); input.setAttribute('aria-label', input.placeholder); send.append(icon('M12 19V5M5 12l7-7 7 7')); send.setAttribute('aria-label', t('Send', '发送')); send.disabled = true; compose.append(input, send); const warning = el('p', 'phone-notice'); warning.hidden = true; warning.setAttribute('role', 'status'); const tabs = el('nav', 'phone-tabs'); tabs.setAttribute('aria-label', t('Pages', '页面')); @@ -107,9 +107,9 @@ pw.onsubmit = async event => { const wait = Number(response.headers.get('X-FreeVideo-Retry-After')); if (response.ok) { locked = false; password.value = ''; pwError.hidden = true; field.classList.remove('bad'); renderShell(); await poller.poll(); } else if (response.status === 429 && wait > 0) lockFor(wait); - else if (response.status === 401) { pwError.textContent = t('Incorrect password. Please try again.', '密码错误,请重新输入。'); pwError.hidden = false; field.classList.add('bad'); password.focus(); password.select(); } - else { pwError.textContent = t('Connection lost. Reconnecting…', '连接已断开,正在重新连接…'); pwError.hidden = false; } - } catch { pwError.textContent = t('Connection lost. Reconnecting…', '连接已断开,正在重新连接…'); pwError.hidden = false; } + else if (response.status === 401) { pwError.textContent = t('Wrong password. Please try again.', '密码不对,请再输一次。'); pwError.hidden = false; field.classList.add('bad'); password.focus(); password.select(); } + else { pwError.textContent = t('Connection lost. Reconnecting…', '连接断了,正在重新连接…'); pwError.hidden = false; } + } catch { pwError.textContent = t('Connection lost. Reconnecting…', '连接断了,正在重新连接…'); pwError.hidden = false; } finally { pw.dataset.busy = 'false'; tickPassword(); } }; function renderShell() { @@ -135,7 +135,7 @@ compose.onsubmit = async event => { if (!response?.ok) throw new Error(); if (version === revision && input.value.trim() === text) { input.value = ''; lastSend = null; } await poller.poll(); - } catch { notice(t('Could not send. Please try again.', '发送失败,请重试。')); } + } catch { notice(t('Could not send. Please try again.', '没有发送成功,请重试。')); } finally { sending = false; grow(); } }; function picture(job, preview = false) { @@ -202,7 +202,7 @@ function renderJobs() { const signature = JSON.stringify([Object.values(jobs).map(phoneJobKey), limit]); if (signature === jobsSignature) return; jobsSignature = signature; const active = document.activeElement?.dataset.action; const {running, queued, done} = jobGroups(jobs); generations.replaceChildren(); - if (!running.length && !queued.length && !done.length) generations.append(el('p', 'phone-empty', t('No creations yet. Describe the video in Chat.', '暂无作品。请在对话中描述要生成的视频。'))); + if (!running.length && !queued.length && !done.length) generations.append(el('p', 'phone-empty', t('Nothing yet. Tell the Agent what to make in Chat.', '还没有作品,在对话里说想做什么。'))); if (running.length) generations.append(el('h2', 'phone-section', t('Generating', '正在生成'))); for (const job of running) { const card = el('article', 'phone-running'), frame = el('div', 'phone-frame'), info = el('div', 'phone-info'); card.dataset.job = job.id; @@ -249,13 +249,13 @@ function openResult(job) { actions.append(el('p', 'phone-prompt', job.label), download, error); viewer.append(stage, actions); renderShell(); } function completed(job) { - toastJob = job; toast.replaceChildren(picture(job), el('span', '', t('Your video is ready', '视频已生成')), button('phone-link', t('View', '查看'), () => { show('jobs'); openResult(job); })); + toastJob = job; toast.replaceChildren(picture(job), el('span', '', t('Your video is ready', '生成好了')), button('phone-link', t('View', '查看'), () => { show('jobs'); openResult(job); })); main.classList.add('toast-space'); renderShell(); clearTimeout(toastTimer); toastTimer = setTimeout(() => { toastJob = null; toast.hidden = true; main.classList.remove('toast-space'); }, 6000); - clearInterval(flash); let lit = false; flash = setInterval(() => { document.title = (lit = !lit) ? t('Your video is ready', '视频已生成') : 'FreeVideo'; }, 1000); + clearInterval(flash); let lit = false; flash = setInterval(() => { document.title = (lit = !lit) ? t('Your video is ready', '生成好了') : 'FreeVideo'; }, 1000); setTimeout(() => { clearInterval(flash); document.title = 'FreeVideo'; }, 6000); if ('Notification' in window) { - if (document.hidden && Notification.permission === 'granted') { try { const n = new Notification(t('Your video is ready', '视频已生成')); n.onclick = () => { window.focus(); show('jobs'); openResult(job); n.close(); }; } catch {} } + if (document.hidden && Notification.permission === 'granted') { try { const n = new Notification(t('Your video is ready', '生成好了')); n.onclick = () => { window.focus(); show('jobs'); openResult(job); n.close(); }; } catch {} } if (!permissionAsked && Notification.permission === 'default') { permissionAsked = true; try { localStorage.setItem('freevideo.phone.notification-asked', '1'); } catch {} Notification.requestPermission().catch(() => {}); } } } diff --git a/web/phone_model.js b/web/phone_model.js index 94a82f0e..450acee6 100644 --- a/web/phone_model.js +++ b/web/phone_model.js @@ -25,8 +25,8 @@ export function mergePhoneJobs(previous, chat, activity, snapshots = []) { } export function retryWords(seconds, t) { const count = Math.ceil(Math.max(0, seconds)); - return count > 90 ? t(`Too many attempts. Please try again in ${Math.ceil(count / 60)} min.`, `尝试次数过多,请 ${Math.ceil(count / 60)} 分钟后重试。`) - : t(`Too many attempts. Please try again in ${count} s.`, `尝试次数过多,请 ${count} 秒后重试。`); + return count > 90 ? t(`Too many tries. Please try again in ${Math.ceil(count / 60)} min.`, `试错次数太多,请 ${Math.ceil(count / 60)} 分钟后再试。`) + : t(`Too many tries. Please try again in ${count} s.`, `试错次数太多,请 ${count} 秒后再试。`); } export function queueWords(index, t) { const n = index + 1, mod = n % 100; @@ -35,7 +35,7 @@ export function queueWords(index, t) { } export function remainingWords(seconds, t) { if (!(seconds > 0)) return ''; - return seconds >= 60 ? t(`About ${Math.ceil(seconds / 60)} min left`, `还需约 ${Math.ceil(seconds / 60)} 分钟`) : t('Under 1 min', '不到 1 分钟'); + return seconds >= 60 ? t(`About ${Math.ceil(seconds / 60)} min left`, `还要约 ${Math.ceil(seconds / 60)} 分钟`) : t('Under 1 min', '不到 1 分钟'); } export function mediaPath(value, kind = 'video') { if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')) return ''; diff --git a/web/prompt_enhance.js b/web/prompt_enhance.js index 7a8cad3e..3193315c 100644 --- a/web/prompt_enhance.js +++ b/web/prompt_enhance.js @@ -125,8 +125,8 @@ export function createPromptEnhancer({node, input, editor, api, context, setText busy: t('Finish the current queue before enhancing this prompt.', '请等待当前队列完成后再改写。'), prompt_required: t('Type a prompt first; enhancement rewrites it before generating.', '请先输入提示词,生成前会自动改写。'), setup_required: t('Complete FreeVideo installation first.', '请先完成 FreeVideo 安装。'), - model_missing: t('Download the optional model to enable enhancement.', '下载可选模型后可启用。'), - download_failed: t('Download paused. Retry to resume.', '下载已暂停,点击“重试”可继续。'), + model_missing: t('Download the optional model to enable enhancement.', '下载可选模型后即可启用。'), + download_failed: t('Download paused. Retry to resume.', '下载已暂停,重试即可继续。'), disk_space: t('Not enough disk space for the optional model.', '可选模型的磁盘空间不足,请清理后重试。'), ram_space: t(`This model needs about ${gib(error?.ramBytes || ramBytes)} GiB of available RAM to load. You can keep using the original.`, `此模型加载需约 ${gib(error?.ramBytes || ramBytes)} GiB 可用内存,可先使用原文生成。`), unsupported_media: t('Use images added in Media for enhancement; connected nodes, video and audio are not supported yet.', '增强暂支持在素材中添加的图片;连接节点、视频和音频暂不支持。'), @@ -275,7 +275,7 @@ export function createPromptEnhancer({node, input, editor, api, context, setText function useOriginal(error) { console.warn('FreeVideo prompt rewrite failed; using the original prompt.', error?.message); versions.value.selected = 'original'; apply(); - show(t('Could not enhance; generated with the original.', '未能改写,已使用原文生成。'), 'note'); + show(t('Could not enhance; generated with the original.', '没能改写,已用原文生成。'), 'note'); } return {element, async beforeGenerate() { if (running) { diff --git a/web/prompt_references.js b/web/prompt_references.js index 1096c786..48cd4cd3 100644 --- a/web/prompt_references.js +++ b/web/prompt_references.js @@ -203,7 +203,7 @@ export function attachReferencePicker(input, {items, t = en => en, view, host, a if (!matches.length) { const empty = document.createElement('div'); empty.className = 'fv-reference-empty'; empty.textContent = invalid ? t('Check your media selection.', '请检查素材用途是否冲突。') - : available.length || everyone.length ? t('No matching characters or media', '没有匹配的角色或素材') : t('Add reference media to use @', '添加参考素材后可使用 @'); + : available.length || everyone.length ? t('No matching characters or media', '没有匹配的角色或素材') : t('Add reference media to use @', '添加参考素材后即可使用 @'); list.append(empty); if (!available.length && !everyone.length && !invalid && addMedia) { const add = document.createElement('button'); add.type = 'button'; add.textContent = t('Add media', '添加素材'); diff --git a/web/report_issue.js b/web/report_issue.js index 9e9c9b40..a57fb8de 100644 --- a/web/report_issue.js +++ b/web/report_issue.js @@ -177,13 +177,13 @@ export function showReportTip(bar, anchor, t, {mode = 'invite', submit, onHide = title.textContent = t('Last step', '最后一步'); lead.textContent = savedName?.endsWith('.zip') ? t(`The report is large, so it was saved as ${savedName}. Please drag it into "Diagnostic report", then select Submit new issue.`, - `报告较大,已保存为 ${savedName}。请将它拖入“诊断报告”,再点击 Submit new issue。`) + `报告较大,已保存为 ${savedName}。请将它拖入「诊断报告」,再点击 Submit new issue 即可。`) : savedName ? t(`Please drag ${savedName} into "Diagnostic report", then select Submit new issue.`, - `请将 ${savedName} 拖入“诊断报告”,再点击 Submit new issue。`) + `请将 ${savedName} 拖入「诊断报告」,再点击 Submit new issue 即可。`) : t('Please drag the downloaded report into "Diagnostic report", then select Submit new issue.', - '请将下载的诊断报告拖入“诊断报告”,再点击 Submit new issue。'); - const link = el('a', null, t('If the page did not open, go to GitHub', '如页面未打开,请点此前往 GitHub')); + '请将下载的诊断报告拖入「诊断报告」,再点击 Submit new issue 即可。'); + const link = el('a', null, t("Page didn't open? Go to GitHub", '页面没有打开?点此前往 GitHub')); link.href = address; link.target = '_blank'; link.rel = 'noopener'; note.replaceChildren(link); const done = el('button', 'fv-action fv-action-secondary', t('Done', '完成')); done.type = 'button'; @@ -196,9 +196,9 @@ export function showReportTip(bar, anchor, t, {mode = 'invite', submit, onHide = if (mode === 'downloaded') { title.textContent = t('Diagnostic report downloaded', '诊断报告已下载'); } else { - title.textContent = t('Submit a diagnostic report', '欢迎提交诊断报告'); + title.textContent = t('Share a diagnostic report?', '欢迎提交诊断报告'); } - lead.textContent = t('We will optimize specifically for your hardware, and the report also helps other users.', + lead.textContent = t("We'll optimize specifically for your setup, and it helps other users too.", '我们会针对您的硬件配置进行专门优化,也能帮助其他用户。'); note.append(glyph('lock', 'fv-report-tip-lock'), t('Hardware, settings and timings only. No prompts, images or videos.', '仅包含硬件信息、生成参数和耗时,不含提示词、参考图或视频。')); diff --git a/web/result_actions.js b/web/result_actions.js index d5a5473f..2a22572c 100644 --- a/web/result_actions.js +++ b/web/result_actions.js @@ -74,7 +74,7 @@ export function secondPassAction(t, run) { try { await run(); label.textContent = button.title = t('Queued', '已加入队列'); } catch { button.disabled = false; - label.textContent = button.title = t('Second pass did not start · Retry', '二采未开始 · 重试'); + label.textContent = button.title = t('Second pass did not start · Retry', '二采没有开始 · 重试'); problem = issueButton(t, () => ({where: ['Second pass', '二采'], message: label.textContent})); button.after(problem); } finally { button.removeAttribute('aria-busy'); } diff --git a/web/setup.js b/web/setup.js index 5e62b059..e775cfa4 100644 --- a/web/setup.js +++ b/web/setup.js @@ -14,7 +14,7 @@ const languageOverride = typeof location !== 'undefined' const cn = languageOverride === 'zh' || (languageOverride !== 'en' && String(navigator.language || '').toLowerCase().startsWith('zh')); const t = (en, zh) => cn ? zh : en; -const INSTALLED_HINT = {en: 'Already installed. To reinstall, click Detect & review first.', zh: '已安装。如需重新安装,请先点击“检测并预览”。'}; +const INSTALLED_HINT = {en: 'Already installed. To reinstall, click Detect & review first.', zh: '已装好。要重装,请先点“检测并预览”。'}; const gib = n => Number.isFinite(n) ? (n / 2 ** 30).toFixed(1) + " GiB" : "—"; const SPEED_TEST_FAILED = 'The speed test did not finish. Click "Test source speeds".'; const reportable = value => !/The installation plan changed\. Detect configuration again|MODEL_MISMATCH_REPAIR:/.test(String(value || '')); @@ -28,11 +28,11 @@ const failureText = value => { : t('The settings, GPU or free disk space changed after you confirmed, so installation stopped. Click Detect & review, then Install / repair.', '确认之后设置、显卡或磁盘空间有变化,已停止。请先点“检测并预览”,再点“安装/修复”。'); } - if (text === SPEED_TEST_FAILED) return t('The speed test did not finish. Click "Test source speeds".', '测速未完成,请点击“重新测速”。'); + if (text === SPEED_TEST_FAILED) return t('The speed test did not finish. Click "Test source speeds".', '测速没有完成,请点“重新测速”。'); if (!matches.length) return text; const file = matches.at(-1)[1]; return t(`${file} differs from the tested version and was left unchanged. Click Detect & review, then Install / repair. After you confirm, the original is renamed and kept, and the tested version is downloaded.`, - `${file} 与测试过的版本不同,本次未作改动。请先点“检测并预览”,再点“安装/修复”,确认后原文件改名保留,再下载测试过的版本。`); + `${file} 和测试过的版本不同,这次没有改动。请先点“检测并预览”,再点“安装/修复”,确认后原文件改名保留,再下载测试过的版本。`); }; let current; @@ -256,7 +256,7 @@ export async function openSetup() { if (family.startsWith('torch-')) names[family] = t('GPU packages','GPU 依赖'); const rate = row.bytes_per_second / 2**20; const speed = !row.ok ? t('Unavailable','未连通') : row.speed_measured === false || !Number.isFinite(rate) || rate <= 0 ? - t('Connected; speed not measured yet','已连通,尚未测速') : rate < .005 ? '<0.01 MiB/s' : `${rate.toFixed(rate < 1 ? 2 : 1)} MiB/s`; + t('Connected; speed not measured yet','已连通,速度还没测出') : rate < .005 ? '<0.01 MiB/s' : `${rate.toFixed(rate < 1 ? 2 : 1)} MiB/s`; for (const text of [names[family] || family, `${row.id} · ${row.route === 'direct' ? t('Direct','直连') : t('Current connection','当前连接')}`,speed]) tr.append(node('td',text)); if (!row.ok) tr.lastChild.className = 'fv-off'; table.append(tr); @@ -284,10 +284,10 @@ export async function openSetup() { for (const e of [root, extra, copy, samplingCaches, saveFolders, inspect, use, hfToken, applyToken, clearToken]) e.disabled = busy; cancel.hidden = !busy; install.disabled = busy || !planId || !accept.checked; accept.disabled = busy; inspect.classList.toggle("fv-primary", !planId); install.classList.toggle("fv-primary", Boolean(planId)); const why = busy ? "" : planStale ? t("Settings changed. Click Detect & review again.", "设置已更改,请重新点“检测并预览”。") - : planErrors ? t("Fix the problems marked in red above, then click Detect & review again.", "请先处理上方标红的问题,再点击“检测并预览”。") + : planErrors ? t("Fix the problems marked in red above, then click Detect & review again.", "上面有标红的问题,处理后请重新点“检测并预览”。") : ready && !planId ? t(INSTALLED_HINT.en, INSTALLED_HINT.zh) : !planId ? "" - : !accept.checked ? t("Tick the agreement above to enable Install / repair.", "勾选上方的同意选项后,可点击“安装/修复”。") : ""; + : !accept.checked ? t("Tick the agreement above to enable Install / repair.", "勾选上方的同意选项后,即可点击“安装/修复”。") : ""; blocked.textContent = why; blocked.hidden = !why; }; // A changed setting makes a shown plan outdated: clear it rather than leave it beside a disabled button. diff --git a/web/share.js b/web/share.js index 6925c0b5..7b66491a 100644 --- a/web/share.js +++ b/web/share.js @@ -158,14 +158,14 @@ export async function openShare(record,t){ if(!exported){ const response=await api.fetchApi('/freevideo/share/video',{method:'POST',headers:{'Content-Type':'application/json'},signal:exportRequest.signal, body:JSON.stringify({id,template,rect:shape.rect})}); - if(response.status===409)throw Object.assign(new Error(t('Another export is running. Try again shortly.','已有分享视频正在导出,请稍后重试。')),{reportable:false}); + if(response.status===409)throw Object.assign(new Error(t('Another export is running. Try again shortly.','已有分享视频正在导出,请稍后再试。')),{reportable:false}); if(!response.ok)throw new Error(t('Export could not complete. Try again.','分享视频导出未完成,请重试。')); exported=await response.json(); } if(disposed)return; const a=el('a');a.href=api.apiURL('/freevideo/share/video?'+new URLSearchParams(exported));a.download='';a.click();say(''); }catch(error){if(!disposed){ - say(error.name==='AbortError'?'':kind==='image'?t('Could not save the share image. Try again.','分享图保存失败,请重试。'):error.message,error.reportable!==false); + say(error.name==='AbortError'?'':kind==='image'?t('Could not save the share image. Try again.','分享图没有保存成功,请重试。'):error.message,error.reportable!==false); }} finally{busy=false;if(!disposed){save.disabled=false;cancel.hidden=true;delete dialog.dataset.busy;tabs.querySelectorAll('button').forEach(b=>b.disabled=false);}} } diff --git a/web/studio.js b/web/studio.js index 5b19a022..ee0248b0 100644 --- a/web/studio.js +++ b/web/studio.js @@ -120,8 +120,8 @@ function createChainPartList(t, save, {onShow = () => {}, onHide = () => {}, red row.starting = true; row.redo.disabled = true; tag(row, t('Starting…', '正在开始…')); try { await (row.part.action === 'resume' ? resume() : redo(row.part.id)); } catch (error) { - if (rows.get(row.part.id) === row) flash(row, error.status === 409 ? t('Another video is generating. Try again when it finishes.', '正在生成其他视频,请在完成后重试。') - : t('Could not start. Try again.', '未能开始,请重试。'), 'error'); + if (rows.get(row.part.id) === row) flash(row, error.status === 409 ? t('Another video is generating. Try again when it finishes.', '正在生成别的视频,完成后再试。') + : t('Could not start. Try again.', '没能开始,请重试。'), 'error'); } finally { row.starting = false; row.redo.disabled = false; } } function flash(row, text, kind) { @@ -143,8 +143,8 @@ function createChainPartList(t, save, {onShow = () => {}, onHide = () => {}, red row.saving = null; if (rows.get(row.part.id) !== row) return; row.part = {...row.part, text: before}; draw(row); - flash(row, error.status === 409 ? t('This part is already generating. Your edit was not saved.', '此段已在生成,改动未保存') - : t('Your edit was not saved.', '改动未保存'), 'error'); + flash(row, error.status === 409 ? t('This part is already generating. Your edit was not saved.', '这一段已在生成,改动没有保存') + : t('Your edit was not saved.', '改动没有保存'), 'error'); } } function open(row) { @@ -212,7 +212,7 @@ function createChainPartList(t, save, {onShow = () => {}, onHide = () => {}, red // The part began while it was open: the words typed so far cannot reach it. A video that ended just closes it. const changed = row.draft !== row.part.text; editing = null; row.part = part; draw(row); - if (changed && active) flash(row, t('This part is already generating. Your edit was not saved.', '此段已在生成,改动未保存'), 'error'); + if (changed && active) flash(row, t('This part is already generating. Your edit was not saved.', '这一段已在生成,改动没有保存'), 'error'); return; } if (editing === row) { row.part = {...part, text: row.part.text}; return; } @@ -251,7 +251,7 @@ function triggerEntry(name) { } // The server's own wording is technical; the panel always says the same plain sentence. const triggerReadError = () => new Error(t('Could not read trigger words.', '无法读取触发词。')); -const triggerSaveError = () => new Error(t('Could not save. Try again.', '保存失败,请重试。')); +const triggerSaveError = () => new Error(t('Could not save. Try again.', '没有保存,请再试一次。')); async function triggerResponse(response, fail) { let result = null; try { result = await response.json(); } catch { /* not JSON: same sentence */ } @@ -624,7 +624,7 @@ export async function openExtendEditor(record, node = current?.node || app.graph node = app.graph?._nodes?.find(row => row.type === 'FreeVideoGenerate'); if (!node) { node = globalThis.LiteGraph?.createNode('FreeVideoGenerate'); - if (!node || !app.graph) throw new Error(t('Could not open the creative panel. Try again.', '未能打开创作面板,请重试。')); + if (!node || !app.graph) throw new Error(t('Could not open the creative panel. Try again.', '没能打开创作面板,请重试。')); app.graph.add(node); app.graph.change?.(); } } @@ -813,13 +813,13 @@ export function openStudio(node, {extendFrom = null} = {}) { // Off, on, or on and paused after the first pass: a preview is one way to run two-pass. const passMode = el('div', null, 'fv-pass-mode'); passMode.setAttribute('role', 'radiogroup'); passMode.setAttribute('aria-label', t('Two-pass sampling', '二次采样')); - const passOptions = [['off', t('Off', '关闭')], ['on', t('On', '开启')], ['preview', t('Preview first', '先生成预览')]].map(([mode, label]) => { + const passOptions = [['off', t('Off', '关闭')], ['on', t('On', '开启')], ['preview', t('Preview first', '先出预览')]].map(([mode, label]) => { const option = el('button', label); option.type = 'button'; option.dataset.mode = mode; option.setAttribute('role', 'radio'); option.onclick = () => choosePassMode(mode); return option; }); passOptions[2].title = t('Stops at a half-resolution preview. Choose "Run second pass" in the result when it looks right; the video matches a direct run.', - '先生成半分辨率预览,满意后在结果中点击“继续二采”,成片与直接生成一致。'); + '先出半分辨率预览,满意后在结果里点“继续二采”,成片与直接生成一致。'); const previewPassHint = passOptions[2].title; passMode.append(...passOptions); passMode.addEventListener('keydown', event => { @@ -902,7 +902,7 @@ export function openStudio(node, {extendFrom = null} = {}) { let releaseExtendThumbnail = () => {}; cleanup.push(() => releaseExtendThumbnail()); if (mediaNode?.freevideoCreateMediaEditor) { cleanup.push(mediaNode.freevideoCreateMediaEditor(mediaMount)); mediaDetails.open = mediaFilled(); } - else mediaMount.append(el('p', t('Connect a FreeVideo Media panel in node view to upload here.', '在节点视图连接 FreeVideo 素材面板后,可在这里上传。'), 'fv-legacy')); + else mediaMount.append(el('p', t('Connect a FreeVideo Media panel in node view to upload here.', '在节点视图连接 FreeVideo 素材面板,即可在这里上传。'), 'fv-legacy')); paintMediaHint(); mediaDetails.addEventListener('toggle', paintMediaHint); controls.addEventListener('change', paintMediaHint, true); controls.addEventListener('input', paintMediaHint, true); const connectedLora = upstream(node, 'loras'); @@ -1027,7 +1027,7 @@ export function openStudio(node, {extendFrom = null} = {}) { // A continuation keeps a two-pass level (Light or Medium). function continuationReason() { const level = effortFor(8, true, 3, null, effort.levels(), upscaleNow()); - return level?.name === 'Medium' ? t('A continuation uses the Medium level.', '继续生成使用标准档。') : t('A continuation uses the Light level.', '继续生成使用轻量档。'); + return level?.name === 'Medium' ? t('A continuation uses the Medium level.', '接着生成使用标准档。') : t('A continuation uses the Light level.', '接着生成使用轻量档。'); } function showEffortEstimate(steps = Number(baseSteps.value), twoPassPlan = twoPass.checked, cache = stepCacheNow(), factor = upscaleNow()) { const parts = lengthControl.plan?.count > 1 ? t(`${lengthControl.plan.count} parts`, `分 ${lengthControl.plan.count} 段`) : ''; @@ -1132,7 +1132,7 @@ export function openStudio(node, {extendFrom = null} = {}) { redo: block => { failure.clear(); return chainController.redo(block); }, resume: () => { failure.clear(); return chainController.resume(); }, playSeam: seconds => playAround(seconds)}); - // Set once the queue buttons exist: "Resume" stays hidden while a listed part offers the same. + // Set once the queue buttons exist: "Continue generating" stays hidden while a listed part offers the same. let syncResume = () => {}; // A stopped long video keeps its parts listed while the preview shows it. const syncPartList = () => { @@ -1270,7 +1270,7 @@ export function openStudio(node, {extendFrom = null} = {}) { try { await node.freevideoUpscaleResult(saved); await syncQueue(); } catch (error) { resultActionFailed(error); throw error; } }; - const memoryNote = el('div', t('This run used the low-memory mode, so it was slower. A lower resolution or a shorter video is faster.', '本次使用了省显存模式,因此较慢。降低分辨率或缩短时长可加快生成。'), 'fv-note'); memoryNote.hidden = true; + const memoryNote = el('div', t('This run used the low-memory mode, so it was slower. A lower resolution or a shorter video is faster.', '这次用了省显存方式,所以较慢。降低分辨率或缩短时长会更快。'), 'fv-note'); memoryNote.hidden = true; const thermalNote = el('div', '', 'fv-note'); thermalNote.hidden = true; const sharedGpuNote = el('div', '', 'fv-note'); sharedGpuNote.hidden = true; const encoderNote = el('div', '', 'fv-note'); encoderNote.hidden = true; @@ -1463,7 +1463,7 @@ export function openStudio(node, {extendFrom = null} = {}) { const queueToggle = el('summary'), queueList = el('div', null, 'fv-queue-list'); queueDetails.append(queueToggle, queueList); const cancelCurrent = button(t('Cancel current', '取消当前'), cancelGeneration, 'fv-quiet'); - const resumeChain = button(t('Resume', '恢复生成'), async () => { + const resumeChain = button(t('Continue generating', '继续生成'), async () => { resumeChain.disabled = true; failure.clear(); try { await chainController.resume(); } catch (error) { showQueueError(error); } finally { resumeChain.disabled = false; } @@ -1482,15 +1482,15 @@ export function openStudio(node, {extendFrom = null} = {}) { runbar.append(requestAssets.element, runrow, queueActions); controls.append(runbar); const queueError = error => ({ queue_unavailable: t('Could not read the queue. The loop has stopped; videos already added stay queued.', '暂时读不到队列,循环已停止;已加入的视频还在队列中。'), - cancel_failed: t('Could not cancel. Try again.', '取消失败,请重试。'), - move_failed: t('Could not move it. Try again.', '移动失败,请重试。'), - submit_failed: t('Could not add to the queue. Check the settings in node view.', '未能加入队列,请在节点视图检查设置。'), + cancel_failed: t('Could not cancel. Try again.', '没有取消成功,请重试。'), + move_failed: t('Could not move it. Try again.', '没有移动成功,请重试。'), + submit_failed: t('Could not add to the queue. Check the settings in node view.', '没能加入队列,请在节点视图检查设置。'), loop_active: t('Stop the current loop before starting another.', '请先停止当前循环。'), - loop_stopped: t('The last video did not finish. The loop has stopped.', '上一个视频未生成完成,循环已停止。'), - linked_seed: t('Batch and loop use the seed in this panel. Disconnect the seed link in node view.', '批量和循环需使用面板中的种子,请在节点视图断开种子的连线。'), + loop_stopped: t('The last video did not finish. The loop has stopped.', '上一个视频没有生成完,循环已停止。'), + linked_seed: t('Batch and loop use the seed in this panel. Disconnect the seed link in node view.', '批量和循环要用面板里的种子,请在节点视图断开种子的连线。'), missing_node: t('This node is disabled or no longer in the workflow.', '该节点已禁用或已从工作流移除。'), - chain_cancel_failed: t('Could not cancel. Refresh the page and try again.', '取消失败,请刷新页面后重试。'), - chain_resume_failed: t('Could not resume. Refresh the page and try again.', '未能恢复生成,请刷新页面后重试。'), + chain_cancel_failed: t('Could not cancel. Refresh the page and try again.', '没有取消成功,请刷新页面后再试。'), + chain_resume_failed: t('Could not continue. Refresh the page and try again.', '没有继续生成,请刷新页面后再试。'), }[error?.message] || (error?.said ? t(error.said[0], error.said[1]) : null) || (isNetworkTypeError(error) ? connectionLostText(t) : error?.message) || String(error)); let lastQueueError = null, connectionNotice = false; function showQueueError(error) { @@ -1501,7 +1501,7 @@ export function openStudio(node, {extendFrom = null} = {}) { if (['loop_stopped', 'queue_unavailable'].includes(error?.message) && node.freevideoFailureReport && node.freevideoFailureReport.stage !== 'queue') return; status.textContent = error?.schema === 'freevideo.error-report' - ? t('Could not start. See the details below.', '未能开始生成,原因见下方。') + ? t('Could not start. See the details below.', '没能开始生成,原因见下方。') : extensionCause(error) ? t('A browser extension is interfering with FreeVideo. See details below.', '浏览器扩展干扰了 FreeVideo,详见下方。') : queueError(error); // Recoverable chain actions need one visible sentence, not a second error card. @@ -1699,7 +1699,7 @@ export function openStudio(node, {extendFrom = null} = {}) { held = turnNodeOn(node); const snapshot = singleRunSnapshot(extendSnapshot(cleanOptionalPrompt(clonePrompt(await app.graphToPrompt())), node.id, source, {multiple: lengthPlan?.count > 1}), node.id, lengthPlan); - if (!snapshot) { fault(t('Could not prepare this run. Try again.', '未能准备本次生成,请重试。')); return; } + if (!snapshot) { fault(t('Could not prepare this run. Try again.', '没能准备这次生成,请重试。')); return; } const seedIndex = node.widgets.findIndex(w => w.name === 'seed'); capturing = false; for (const section of controls.querySelectorAll('.fv-section')) section.inert = false; diff --git a/web/studio_extend.js b/web/studio_extend.js index 6858d308..fa8971b1 100644 --- a/web/studio_extend.js +++ b/web/studio_extend.js @@ -1,7 +1,7 @@ import {continuationUpscale} from './sampling_effort.js'; export const EXTEND_PROPERTY = 'freevideo_extend'; export const EXTEND_TEXT = Object.freeze({ - action: ['Extend', '继续生成'], + action: ['Extend', '接着生成'], source: ['Continues this video', '接在这条视频后面'], remove: ['Remove', '移除'], prompt: ['Prompt', '描述'], @@ -40,7 +40,7 @@ export async function extendSourceExists(api, record) { } export const videoUnavailableText = t => t('This video cannot be opened. If you moved the file, refresh the page.', - '无法打开视频。如已移动文件,请刷新页面。'); + '视频打不开,如果移动过文件,请刷新页面。'); export async function readExtendRecord(api, record) { if (record.geometry?.width && record.geometry?.height && (record.geometry.seconds || record.geometry.frames)) return record; diff --git a/web/studio_recent.js b/web/studio_recent.js index c9161cb1..a9de9abd 100644 --- a/web/studio_recent.js +++ b/web/studio_recent.js @@ -76,7 +76,7 @@ export function createRecentVideos(t, {body, onSelect}) { if (!disposed && !controller.signal.aborted) { // The library's own messages are translated; a network or reply error is not, and this column has no Refresh. message.textContent = error?.restart ? error.message - : t('Recent videos could not be loaded. FreeVideo tries again when the next video finishes.', '暂时无法读取最近作品,下一个视频生成完成后会自动重试。'); + : t('Recent videos could not be loaded. FreeVideo tries again when the next video finishes.', '暂时无法读取最近作品,下一个视频生成完成后会自动再试。'); message.hidden = false; list.append(message); } return []; diff --git a/web/tutorial_book_content.js b/web/tutorial_book_content.js index 0960be6e..635ce7fa 100644 --- a/web/tutorial_book_content.js +++ b/web/tutorial_book_content.js @@ -29,18 +29,18 @@ export const TUTORIAL_CHAPTERS = [ alt: {zh: '创作面板:时长输入框和“可生成范围”入口已用红框标出。', en: 'The creation panel, with the duration field and "Available sizes and lengths" control outlined in red.'}, }, steps: { - zh: ['填写更长的时长,会自动分段生成;“可生成范围”中可查看各尺寸的最长时长和预计用时。', '点“生成视频”。', '在已生成的视频上点击“继续生成”,继续下一段。', '也可在“剪辑视图”中逐段接续。'], + zh: ['填写更长的时长,会自动分段生成;“可生成范围”中可查看各尺寸的最长时长和预计用时。', '点“生成视频”。', '在生成好的视频上点“接着生成”,继续下一段。', '也可在“剪辑视图”中逐段接续。'], en: ['Enter a longer duration. It is made in parts automatically. "Available sizes and lengths" shows how long each size can be and about how long it takes.', 'Choose "Generate video".', 'Choose "Extend" on a finished video to continue it.', 'Parts can also be added one by one in "Edit".'], }, limits: { - zh: ['一条视频最多 5 段,创作面板中时长最长 60 秒。', '分段生成和继续生成需要 NVIDIA 显卡或 Mac。', '段数越多,衔接处越容易出现变化;适合切换镜头处,可直接开始新的一段。'], + zh: ['一条视频最多 5 段,创作面板中时长最长 60 秒。', '分段生成和接着生成需要 NVIDIA 显卡或 Mac。', '段数越多,衔接处越容易出现变化;适合切换镜头处,可直接开始新的一段。'], en: ['A video has at most 5 parts, and the creation panel accepts up to 60 seconds.', 'Generating in parts and Extend need an NVIDIA graphics card or a Mac.', 'The more parts are joined, the more the joins may change. Where a cut fits, cut to a new shot and start a fresh part.'], }, }, { id: 'editor-extend-refs', title: {zh: '在剪辑视图延长时加参考图', en: 'Add reference images when extending in Edit'}, - summary: {zh: '用参考图引导继续生成的那一段。', en: 'Use reference images to guide the next part.'}, + summary: {zh: '用参考图引导接着生成的那一段。', en: 'Use reference images to guide the next part.'}, media: { type: 'image', src: {zh: 'editor-extend-refs-1.webp', en: 'editor-extend-refs-2.webp'}, diff --git a/web/updates.js b/web/updates.js index 44206cf4..c9e7081d 100644 --- a/web/updates.js +++ b/web/updates.js @@ -5,7 +5,7 @@ import { issueButton } from './issue_button.js'; // Every platform downloads from one release: the latest vX.Y.Z. const releasePage = 'https://github.com/FlashML-org/FreeVideo/releases/latest'; const storageKey = 'freevideo.dismissed-update'; -const CANCEL_FAILURE = {en: 'Cancellation failed. Please use the launcher.', zh: '取消失败,请在启动器中操作'}; +const CANCEL_FAILURE = {en: 'Cancellation failed. Please use the launcher.', zh: '取消没成功,请在启动器里操作'}; const listeners = new Set(); // Launcher phases during which the server may disappear and come back updated. const working = new Set(['checking', 'downloading', 'waiting', 'restarting', 'engine']); @@ -160,10 +160,10 @@ export function createUpdateNotice(cn) { text = t(CANCEL_FAILURE.en, CANCEL_FAILURE.zh); } else if (busy) { text = model === 'switching' || (model && offline) - ? t('Switching to the faster model. This page reconnects automatically.', '正在切换到更快的模型,页面会自动恢复。') - : model === 'waiting' ? t('The faster model is ready. FreeVideo switches after the current video.', '更快的模型已下载完成,当前视频完成后自动切换。') + ? t('Switching to the faster model. This page reconnects by itself.', '正在切换到更快的模型,页面会自动恢复。') + : model === 'waiting' ? t('The faster model is ready. FreeVideo switches after the current video.', '更快的模型已下载好,当前视频完成后自动切换。') : offline || phase === 'restarting' || phase === 'engine' - ? t('Restarting FreeVideo to finish the update. The page refreshes automatically.', '正在重启 FreeVideo 完成更新,页面会自动刷新。') + ? t('Restarting FreeVideo to finish the update. The page refreshes by itself.', '正在重启 FreeVideo 完成更新,页面会自动刷新。') : phase === 'downloading' ? t('Downloading the update', '正在下载更新') + percent : phase === 'waiting' ? t('The update installs as soon as the current video finishes.', '当前视频生成完成后会自动更新。') : t('Checking for updates…', '正在检查更新…'); diff --git a/web/view_navigation.js b/web/view_navigation.js index 3bdcaf5e..2be364d0 100644 --- a/web/view_navigation.js +++ b/web/view_navigation.js @@ -128,7 +128,7 @@ export function installNavigation(openStudio, agent = false, {openWorkflow = nul } catch (error) { console.warn('FreeVideo edit project:', error); app.extensionManager?.toast?.add({severity: 'error', summary: 'FreeVideo', - detail: t('Could not complete. Try again.', '未完成,请重试'), life: 9000}); + detail: t('Could not complete. Try again.', '没有完成,请重试'), life: 9000}); return null; } })(); @@ -139,7 +139,7 @@ export function installNavigation(openStudio, agent = false, {openWorkflow = nul settings.textContent = t('Settings', '设置'); settings.className = 'fv-view-settings'; settings.onclick = openSetup; row.append(grip, brand, viewSwitch('nodes', () => choose(node => { open?.(node); }, true), () => {}, () => choose(async (node, created) => { const handle = await openEditorView(node); - if (created) handle?.notify(t('Editing project created.', '已新建剪辑工程')); + if (created) handle?.notify(t('Created an editing project for you.', '已为你新建一个剪辑工程')); return handle; }, true), () => choose(node => openAutopilot(node), true)), createLanAccess(t, api).element, createVersionInfo(cn).element, feedbackButton(t, 'fv-view-settings'), settings); toolbar.append(row); toolbar.append(createUpdateNotice(cn).element); diff --git a/web/whitebox_scenes.js b/web/whitebox_scenes.js index 51b98aea..0a770759 100644 --- a/web/whitebox_scenes.js +++ b/web/whitebox_scenes.js @@ -5,12 +5,12 @@ import { agentGet, agentPost } from './agent_session.js'; export const SCENE_TEXT = Object.freeze({ icon: 'M4 5h7v6H4zM13 5h7v6h-7zM4 13h7v6H4zM13 13h7v6h-7z', label: ['Ready-made', '现成场景'], name: ['Start from a ready-made white-model scene', '用现成的白模场景开始'], - preparing: ['Preparing the white model…', '正在准备白模…'], failed: ['The white model could not be made: ', '白模生成失败:'], + preparing: ['Preparing the white model…', '正在准备白模…'], failed: ['The white model could not be made: ', '白模没有做成:'], rendering: ['Rendering the white model…', '正在渲染白模…'], retry: ['. Try again.', '。请重试。'], - bare: ['The white model could not be made. Try again.', '白模生成失败,请重试。'], - noPage: ['The white model could not be made: the renderer is not running in this page. Reload the page and try again.', '白模生成失败:此页面中的渲染未运行。请刷新页面后重试。'], - notStarted: ['The white model could not be made: rendering did not start within 3 minutes. Try again.', '白模生成失败:渲染未在 3 分钟内开始。请重试。'], - timeout: ['The white model could not be made: rendering did not finish within 3 minutes. Try again.', '白模生成失败:渲染未在 3 分钟内完成。请重试。'], + bare: ['The white model could not be made. Try again.', '白模没有做成。请重试。'], + noPage: ['The white model could not be made: the renderer is not running in this page. Reload the page and try again.', '白模没有做成:这个页面里的渲染没有运行。请刷新页面后重试。'], + notStarted: ['The white model could not be made: rendering did not start within 3 minutes. Try again.', '白模没有做成:三分钟内渲染没有开始。请重试。'], + timeout: ['The white model could not be made: rendering did not finish within 3 minutes. Try again.', '白模没有做成:渲染三分钟内没有完成。请重试。'], }); export async function sceneList(api) { From 7fbfc93f3279149b834adcaeed409ec5d2fc43bd Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 06:52:27 +0000 Subject: [PATCH 22/58] Give the setup children that run uv the short path view --- freevideo_engine/environments.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/freevideo_engine/environments.py b/freevideo_engine/environments.py index a0dc1142..b4775060 100644 --- a/freevideo_engine/environments.py +++ b/freevideo_engine/environments.py @@ -36,6 +36,9 @@ def constraints_file(name, system=None): UV_BUILD_PATH_CHARS = 200 +UV_CHILD_MARKERS = ('--install-packages', 'freevideo_engine.comfy_host') + + def uv_build_environment(command, root, environ): """Windows uv pip builds use a short view of the existing cache and scratch. @@ -44,8 +47,9 @@ def uv_build_environment(command, root, environ): installation settings, cache ownership and the parent's environment stay put. """ # `venv` and `python` also write into the cache (interpreter records, downloads) below the same root. - if (not command or Path(command[0]).name.lower() not in ('uv', 'uv.exe') - or list(command[1:2]) not in (['pip'], ['venv'], ['python'])): + # The setup children that install the VDN and ComfyUI packages run `uv` themselves, with the same cache. + runs_uv = list(command[1:2]) in (['pip'], ['venv'], ['python']) and Path(command[0]).name.lower() in ('uv', 'uv.exe') + if not command or not (runs_uv or any(str(item) in UV_CHILD_MARKERS for item in command)): return environ from . import launch_view, runtime_alias from .long_paths import io_path, path_chars, plain From a912efd73d5da481d3772eefc4dc595c87a38474 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 07:41:15 +0000 Subject: [PATCH 23/58] Run every installer Python child and a deep online installation through the short path view --- freevideo_engine/environments.py | 7 ++++--- freevideo_engine/launch_view.py | 26 ++++++++++++++++---------- freevideo_engine/runtime_alias.py | 12 ++++++++++++ 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/freevideo_engine/environments.py b/freevideo_engine/environments.py index b4775060..26753c31 100644 --- a/freevideo_engine/environments.py +++ b/freevideo_engine/environments.py @@ -36,7 +36,7 @@ def constraints_file(name, system=None): UV_BUILD_PATH_CHARS = 200 -UV_CHILD_MARKERS = ('--install-packages', 'freevideo_engine.comfy_host') +PYTHON_NAMES = ('python.exe', 'python', 'pythonw.exe') def uv_build_environment(command, root, environ): @@ -47,9 +47,10 @@ def uv_build_environment(command, root, environ): installation settings, cache ownership and the parent's environment stay put. """ # `venv` and `python` also write into the cache (interpreter records, downloads) below the same root. - # The setup children that install the VDN and ComfyUI packages run `uv` themselves, with the same cache. + # A Python child also needs it: it runs `uv` itself (VDN and ComfyUI packages) and the loader + # refuses a DLL such as torch\\lib\\c10.dll whose full path exceeds the limit (WinError 206). runs_uv = list(command[1:2]) in (['pip'], ['venv'], ['python']) and Path(command[0]).name.lower() in ('uv', 'uv.exe') - if not command or not (runs_uv or any(str(item) in UV_CHILD_MARKERS for item in command)): + if not command or not (runs_uv or Path(str(command[0])).name.lower() in PYTHON_NAMES): return environ from . import launch_view, runtime_alias from .long_paths import io_path, path_chars, plain diff --git a/freevideo_engine/launch_view.py b/freevideo_engine/launch_view.py index f9c020bf..e97ef240 100644 --- a/freevideo_engine/launch_view.py +++ b/freevideo_engine/launch_view.py @@ -67,24 +67,30 @@ def view_for(real_root) -> View: def for_engine(engine_root) -> View: """The view of the offline runtime around this engine folder, if it needs one. - An installation made online is its engine folder: it gets a view only where the - system code page cannot spell its folder, and one that cannot be made is not an error. + An installation made online is its engine folder: it gets a view only where the system + code page cannot spell its folder or its deepest library is beyond the loader's limit, + and one that cannot be made is not an error. """ from . import runtime_alias engine = Path(plain(engine_root)) - if engine.name.casefold() == ONLINE_ENGINE: - try: - return view_for(engine) if runtime_alias.needs_view(engine, spelling_only=True) else View(engine, engine) - except OSError: # Includes a junction that cannot be made: the checks after it name what fails. - return View(engine, engine) root = engine.parent # The first two tests read nothing from disk wherever no junction is needed. # The third asks by the extended path: where a junction is needed, the plain # one is not found beyond 259 characters. - if (engine.name.casefold() != 'engine' or not runtime_alias.needs_view(root) - or not io_path(root / 'portable.json').is_file()): + if (engine.name.casefold() == 'engine' and runtime_alias.needs_view(root) + and io_path(root / 'portable.json').is_file()): + return view_for(root) + if engine.name.casefold() == 'engine': return View(engine, engine) - return view_for(root) + try: + # An installation made online is its engine folder. It gets a view where the system code + # page cannot spell the folder or the loader cannot open its deepest library; one that + # cannot be made is not an error. + if runtime_alias.needs_view(engine, spelling_only=True) or runtime_alias.online_loaded_too_long(engine): + return view_for(engine) + except OSError: # Includes a junction that cannot be made: the checks after it name what fails. + pass + return View(engine, engine) def for_file(path): diff --git a/freevideo_engine/runtime_alias.py b/freevideo_engine/runtime_alias.py index 37c4eb3f..599e598d 100644 --- a/freevideo_engine/runtime_alias.py +++ b/freevideo_engine/runtime_alias.py @@ -20,6 +20,7 @@ from .package_paths import ALIAS_ROOT_RESERVE, KNOWN_RUNTIME_PATH_CHARS, WINDOWS_PATH_LIMIT RESERVE = 40 +ONLINE_LOADED_PATH_CHARS = 100 # below the engine folder: envs\\unified\\Lib\\site-packages\\nvidia\\cudnn\\bin\\ PROGRAMS = ('.exe',) LOADED = PROGRAMS + ('.dll', '.pyd') # What Windows starts or loads, never from beyond the limit. # The licence texts a package carries are a runtime's deepest files, and no program opens them. @@ -544,6 +545,17 @@ def needs_view(root, longest=None, spelling_only=False) -> bool: return path_chars(root) + 1 + longest + RESERVE > WINDOWS_PATH_LIMIT +def online_loaded_too_long(engine) -> bool: + """An online installation whose deepest program or library is beyond what Windows loads. + + The loader ignores the system's long-path setting for a DLL such as torch\\lib\\c10.dll. + """ + if not _backend.windows() or os.environ.get('FREEVIDEO_RUNTIME_ALIAS') == '0': + return False + engine = _ordinary(resolved(checked_real_path(engine))) + return path_chars(engine) + 1 + ONLINE_LOADED_PATH_CHARS > WINDOWS_PATH_LIMIT + + def is_junction(path) -> bool: """Any Windows directory junction or directory symlink, wherever it lives.""" if not _backend.windows(): From 9ff60d0e6797874c1421b01a0b9094cfc89ff0aa Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 07:44:05 +0000 Subject: [PATCH 24/58] Keep the original interface wording and drop only second-person and filler phrasing --- .github/ISSUE_TEMPLATE/feature_request.yml | 4 ++-- freevideo_engine/agent/builtin_director.py | 2 +- freevideo_engine/agent/guide.py | 6 +++--- freevideo_engine/desktop.py | 2 +- freevideo_engine/launcher/Main.qml | 6 +++--- freevideo_engine/launcher_view.py | 2 +- freevideo_engine/release_notes.json | 4 ++-- web/editor_store.js | 2 +- web/first_run_guide.js | 2 +- web/image_editor.js | 2 +- web/library.js | 8 ++++---- web/prompt_enhance.js | 4 ++-- web/prompt_references.js | 2 +- web/report_issue.js | 6 +++--- web/setup.js | 2 +- web/studio.js | 2 +- web/view_navigation.js | 2 +- 17 files changed, 29 insertions(+), 29 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 93255e9f..2ab676e3 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,9 +1,9 @@ name: Suggestion / 建议 -description: Tell us what would help / 说说你希望改进什么 +description: Tell us what would help / 说说希望改进什么 body: - type: textarea id: idea attributes: - label: What would you like to do? / 你希望实现什么? + label: What would you like to do? / 希望实现什么? validations: required: true diff --git a/freevideo_engine/agent/builtin_director.py b/freevideo_engine/agent/builtin_director.py index 6b280055..f530ad54 100644 --- a/freevideo_engine/agent/builtin_director.py +++ b/freevideo_engine/agent/builtin_director.py @@ -369,7 +369,7 @@ def judge(shot_view, sheets, *, ask): return Decision('keep', shot['id'], '保留已选定的版本。') if shot.get('state') in ('waiting', 'shooting'): return Decision('wait', shot['id'], '等待这一镜的各版完成。') - uncertain = Decision('ask_user', shot['id'], '拿不准,请你挑一版。') + uncertain = Decision('ask_user', shot['id'], '拿不准,请挑一版。') takes = _selectable(shot) if not takes: return uncertain diff --git a/freevideo_engine/agent/guide.py b/freevideo_engine/agent/guide.py index e051ce29..d38780f5 100644 --- a/freevideo_engine/agent/guide.py +++ b/freevideo_engine/agent/guide.py @@ -481,7 +481,7 @@ def connection_guide(root, base, lang=None, offers=None, resumed=False): '连接后先调一次等待接口 GET ' + base + PREFIX + '/inbox?wait=50(MCP 用不带 id 的 freevideo_wait),' '回应里的 session 就是用户现在所在的对话(用户新开或换到另一段对话时它会变,话总是发到最近一次等待给的那个 session);往 POST ' + base + PREFIX + '/sessions/{session}/events 发 ' '{"type":"message","text":"…"}(MCP 用 freevideo_session_post)。在这里先只说一句话,不超过 20 个字,' - '不列条目、不举例(页面上已经有例句),例如“连上了。告诉我想做什么视频。”' + '不列条目、不举例(页面上已经有例句),例如“连上了。请说明想做什么视频。”' '你自己窗口里的那段介绍照常说,但它不是这一轮的结尾:说完紧接着去调等待接口。' '然后守在这段对话里:再调等待接口,它最多约 50 秒返回;"again": true 表示用户还没说话,' '马上再调下一次,一次接一次,不限次数。没有消息不是结束的理由:不要结束这一轮回复,' @@ -499,7 +499,7 @@ def connection_guide(root, base, lang=None, offers=None, resumed=False): 'without id). Its "session" is the conversation the user is in (it changes when the user opens or returns to another one; always post to the session the latest wait named); POST ' + base + PREFIX + '/sessions/{session}/events ' 'with {"type":"message","text":"..."} (MCP: freevideo_session_post). Say only one short sentence ' 'there first, at most 12 words, no list and no examples (the page already shows examples), for example ' - '"Connected. Tell me what video you want.", in the language the user writes to you in. Give the introduction in your own window as usual, but it ' + '"Connected. Describe the video you want.", in the language the user writes to you in. Give the introduction in your own window as usual, but it ' 'does not end your turn: right after it, call the wait. Then stay in this conversation: call the wait again; it answers within about 50 seconds, and ' '"again": true means the user has not spoken yet, so call it again at once, time after time, with no ' 'limit. Nothing new is not a reason to finish: do not end your reply and do not stop to ask the ' @@ -516,7 +516,7 @@ def connection_guide(root, base, lang=None, offers=None, resumed=False): 'Stop only when the user clearly tells you to.', rules=RULES_ZH if zh else RULES, fallback='如果你无法执行命令、读取本地文件或连接到这台电脑,请如实告诉用户:' - '“我连接不到你电脑上的 FreeVideo,请改用安装在这台电脑上的 AI 助手。”' + '“连接不到您电脑上的 FreeVideo,请改用安装在这台电脑上的 AI 助手。”' '用中文说明;如果用户改用其他语言,就跟随用户。不要另装程序来绕过这个限制。' if zh else 'If you cannot run commands, read local files, or reach this computer, tell the user honestly ' 'in their language: I cannot connect to FreeVideo on your computer; please use an AI assistant ' diff --git a/freevideo_engine/desktop.py b/freevideo_engine/desktop.py index a2e9dfc9..3871693d 100644 --- a/freevideo_engine/desktop.py +++ b/freevideo_engine/desktop.py @@ -241,7 +241,7 @@ def setup_page(self, page): def generate_page(self, page): _, page = self.scrolled(page, 1) - self.label(page, 'Describe your video', '描述你想生成的视频').pack(anchor='w', pady=(0, 10)) + self.label(page, 'Describe your video', '描述想生成的视频').pack(anchor='w', pady=(0, 10)) prompt_card = Card(page, padding=branding.space(3), radius=branding.RADIUS_MD) prompt_card.pack(fill='x') self.prompt = self.tk.Text(prompt_card.body, height=6, font=(branding.FAMILY, branding.STRONG), diff --git a/freevideo_engine/launcher/Main.qml b/freevideo_engine/launcher/Main.qml index 5b26d445..5095e38f 100644 --- a/freevideo_engine/launcher/Main.qml +++ b/freevideo_engine/launcher/Main.qml @@ -403,7 +403,7 @@ ApplicationWindow { Item { height: shortWindow ? 0 : 16 } ColumnLayout { Layout.fillWidth: true; spacing: 8; Layout.bottomMargin: shortWindow ? 0 : 10 - FText { objectName: "pageHeading"; horizontalAlignment: Text.AlignHCenter; text: s.page === "launcher" ? t("Your workspace", "你的工作空间") : s.page === "comfy" ? t("Set up FreeVideo", "安装 FreeVideo") : s.page === "models" ? t("Set up your models", "准备模型") : s.status === "review" ? t("Ready to install", "确认安装") : s.busy ? t("Setting things up", "正在准备 FreeVideo") : t("Continue your setup", "继续安装"); font.pixelSize: win.compact ? 26 : theme.hero; font.weight: Font.DemiBold; font.letterSpacing: -0.6; Layout.fillWidth: true } + FText { objectName: "pageHeading"; horizontalAlignment: Text.AlignHCenter; text: s.page === "launcher" ? t("Your workspace", "工作空间") : s.page === "comfy" ? t("Set up FreeVideo", "安装 FreeVideo") : s.page === "models" ? t("Set up your models", "准备模型") : s.status === "review" ? t("Ready to install", "确认安装") : s.busy ? t("Setting things up", "正在准备 FreeVideo") : t("Continue your setup", "继续安装"); font.pixelSize: win.compact ? 26 : theme.hero; font.weight: Font.DemiBold; font.letterSpacing: -0.6; Layout.fillWidth: true } FText { objectName: "actionNotice"; visible: !!s.action_notice; text: s.action_notice || ""; color: theme.muted; Layout.fillWidth: true; horizontalAlignment: Text.AlignHCenter } } @@ -890,7 +890,7 @@ 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 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: "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 } } @@ -1706,7 +1706,7 @@ ApplicationWindow { spacing: 14 FText { objectName: "updateActionNotice"; visible: !!s.action_notice; text: s.action_notice || ""; color: theme.muted; Layout.fillWidth: true } FText { text: s.update.candidate ? t("Update available", "有可用更新") : s.update.engine ? t("Engine update", "引擎更新") : t("Updates", "更新"); font.pixelSize: theme.section + 2; font.weight: Font.DemiBold } - FText { objectName: "updateDialogText"; text: s.update.phase === "waiting" ? t("A video is still generating. FreeVideo restarts and updates as soon as it finishes.", "还有视频正在生成,完成后会自动重启并更新。") : s.update.status === "ready" && s.update.candidate ? t("Downloaded. Restart to finish; models and settings are kept.", "下载完成,重启即可完成更新,模型和设置都会保留。") : s.update.candidate && s.update.manual ? t("FreeVideo ", "FreeVideo ") + releaseVersion(s.update.candidate) + t(" is available (current ", " 已发布(当前 ") + releaseVersion(currentRelease) + t("). Download it and replace this version; models and settings are kept.", ")。下载新版后替换当前版本,模型和设置会保留。") : s.update.candidate ? t("FreeVideo ", "FreeVideo ") + releaseVersion(s.update.candidate) + t(" is available (current ", " 已发布(当前 ") + releaseVersion(currentRelease) + t("). Updating keeps your models and settings. FreeVideo restarts after the download; running videos finish first.", ")。更新会保留模型和设置,下载完成后自动重启;正在生成的视频会先完成。") : s.update.engine ? t("This launcher already includes engine ", "启动器已带有新版引擎 ") + releaseVersion(currentRelease) + t(" (installed: ", "(已安装 ") + engineVersion + (s.status === "open" ? t("). Updating takes about a minute, restarts ComfyUI once and keeps your models and settings.", ")。更新约需 1 分钟,会重启一次 ComfyUI,模型和设置都会保留。") : t("). Updating takes about a minute, then FreeVideo starts; models and settings are kept.", ")。更新约需 1 分钟,完成后自动启动,模型和设置都会保留。")) : s.update.status === "current" ? t("You're up to date.", "已是最新版本。") : s.update.status === "development" ? t("Running from source. Update with Git.", "当前从源码运行,请通过 Git 更新。") : s.update.status === "error" ? t("Could not check for updates.", "暂时无法检查更新。") : t("Checking the latest release…", "正在检查最新版本…"); color: theme.muted; Layout.fillWidth: true } + FText { objectName: "updateDialogText"; text: s.update.phase === "waiting" ? t("A video is still generating. FreeVideo restarts and updates as soon as it finishes.", "还有视频正在生成,完成后会自动重启并更新。") : s.update.status === "ready" && s.update.candidate ? t("Downloaded. Restart to finish; models and settings are kept.", "下载完成,重启可完成更新,模型和设置都会保留。") : s.update.candidate && s.update.manual ? t("FreeVideo ", "FreeVideo ") + releaseVersion(s.update.candidate) + t(" is available (current ", " 已发布(当前 ") + releaseVersion(currentRelease) + t("). Download it and replace this version; models and settings are kept.", ")。下载新版后替换当前版本,模型和设置会保留。") : s.update.candidate ? t("FreeVideo ", "FreeVideo ") + releaseVersion(s.update.candidate) + t(" is available (current ", " 已发布(当前 ") + releaseVersion(currentRelease) + t("). Updating keeps your models and settings. FreeVideo restarts after the download; running videos finish first.", ")。更新会保留模型和设置,下载完成后自动重启;正在生成的视频会先完成。") : s.update.engine ? t("This launcher already includes engine ", "启动器已带有新版引擎 ") + releaseVersion(currentRelease) + t(" (installed: ", "(已安装 ") + engineVersion + (s.status === "open" ? t("). Updating takes about a minute, restarts ComfyUI once and keeps your models and settings.", ")。更新约需 1 分钟,会重启一次 ComfyUI,模型和设置都会保留。") : t("). Updating takes about a minute, then FreeVideo starts; models and settings are kept.", ")。更新约需 1 分钟,完成后自动启动,模型和设置都会保留。")) : s.update.status === "current" ? t("You're up to date.", "已是最新版本。") : s.update.status === "development" ? t("Running from source. Update with Git.", "当前从源码运行,请通过 Git 更新。") : s.update.status === "error" ? t("Could not check for updates.", "暂时无法检查更新。") : t("Checking the latest release…", "正在检查最新版本…"); color: theme.muted; Layout.fillWidth: true } FText { visible: !!s.update.error; text: s.update.error || ""; color: theme.danger; Layout.fillWidth: true; font.pixelSize: theme.micro } FButton { objectName: "updateManualDownloadButton"; text: t("Download from the release page ↗", "去发布页手动下载 ↗"); flat: true; Layout.fillWidth: true; visible: s.update.status === "error" && !!s.update.release_page; onClicked: backend.link(s.update.release_page) } FButton { objectName: "updateDialogReport"; text: t("Report a problem", "提交问题"); flat: true; Layout.fillWidth: true; visible: s.update.status === "error" && !!s.update.error; onClicked: backend.reportProblem(s.update.error, t("Update", "更新")) } diff --git a/freevideo_engine/launcher_view.py b/freevideo_engine/launcher_view.py index 9ce17936..5342ca54 100644 --- a/freevideo_engine/launcher_view.py +++ b/freevideo_engine/launcher_view.py @@ -257,7 +257,7 @@ def wheel(event): choices.columnconfigure((0, 1), weight=1, uniform='choices') app.mode_buttons = [] for index, (value, title, detail) in enumerate(( - ('existing', ('I have ComfyUI', '我已安装 ComfyUI'), ('Connect your installation.', '接入现有安装。')), + ('existing', ('Use existing ComfyUI', '使用已有 ComfyUI'), ('Connect your installation.', '接入现有安装。')), ('new', ('Install ComfyUI for me', '帮我安装 ComfyUI'), ('ComfyUI, Python and FreeVideo together.', '一并准备 ComfyUI、Python 和 FreeVideo。')))): choice = Choice(choices, app, value, title, detail) choice.grid(row=0, column=index, sticky='nsew', padx=(0, b.space(2)) if index == 0 else (b.space(2), 0)) diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 069a80a2..47b418c7 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -77,9 +77,9 @@ "changes": [ "生图:本机生成过的图片都保存在历史记录里,按时间从新到旧排列,可放到画布、用作参考图、删除或打开所在文件夹。空白画布可选“标准 / 高清”尺寸。", "生图:6–8 GB 显存的显卡解码时占用的显存更少,速度更快。显存放得下整个模型的显卡,权重直接加载到显卡,不再在内存里留一份副本,加载后内存占用从约 15 GB 降到约 2 GB。", - "提交问题:生成、剪辑与导出、环境与模型、生图、智能体连接、作品库、提示词优化、分享、更新和启动器里,出现错误或失败提示的地方旁边都有“提交问题”按钮,点一下带上情况打开问题反馈。", + "提交问题:生成、剪辑与导出、环境与模型、生图、智能体连接、作品库、提示词优化、分享、更新和启动器里,出现错误或失败提示的地方旁边都有“提交问题”按钮,点击后带上情况打开问题反馈。", "AI 全自动:对话删不掉时会说明原因,并提供“停止并删除”;其他失败也会提示,并带“提交问题”按钮。", - "白模:现成场景只保留“橘猫跳上书架”,点一下直接开始。白模没做成时会说明原因和下一步,渲染中页面显示进度。问白模进度时会回答进度,不再当成新要求。", + "白模:现成场景只保留“橘猫跳上书架”,点击直接开始。白模没做成时会说明原因和下一步,渲染中页面显示进度。问白模进度时会回答进度,不再当成新要求。", "人物设定图:背面图里头和身体一起转向背后。", "Mac:支持分段生成长视频。", "启动器与更新:Windows 版在中国大陆更新更快。GitHub 慢时,启动器可以从国内镜像下载更新,镜像上的每个文件都先按发布签名校验。“确认安装”页适配窄窗口:侧栏收成图标,按钮始终可见。", diff --git a/web/editor_store.js b/web/editor_store.js index 896b11f8..26902165 100644 --- a/web/editor_store.js +++ b/web/editor_store.js @@ -391,7 +391,7 @@ export function createEditorStore(api, {t = en => en, notify = () => {}, onGener xhr.upload.onprogress = event => { if (event.lengthComputable) progress(event.loaded / event.total); }; xhr.onload = () => { release(); - if (xhr.status === 404) { notice('import', 'Import is unavailable until you restart after updating.', '更新后重启即可导入素材。'); resolve(null); return; } + if (xhr.status === 404) { notice('import', 'Import is unavailable until you restart after updating.', '更新后重启可导入素材。'); resolve(null); return; } if (xhr.status < 200 || xhr.status >= 300) { reject(errorFor(xhr)); return; } try { resolve(JSON.parse(xhr.responseText).import); } catch (error) { reject(error); } }; diff --git a/web/first_run_guide.js b/web/first_run_guide.js index 1f8ce232..a18fb6aa 100644 --- a/web/first_run_guide.js +++ b/web/first_run_guide.js @@ -30,7 +30,7 @@ export const CHAPTERS = [ en: 'For a longer video, enter a longer duration and it is made in parts automatically. You can also add parts one by one in "Edit".', zh: '填写更长的时长会自动分段生成,也可在“剪辑视图”中逐段接续。'}, {id: 'agent', anchor: '[data-fv-guide="agent"]', added: true, - en: 'To have an AI agent such as Claude Code or Codex make videos for you, choose "Autopilot".', zh: '要让 Claude Code、Codex 等 Agent 替你生\u2060成\u2060视\u2060频,点“AI 全自动”。'}, + en: 'To have an AI agent such as Claude Code or Codex make videos for you, choose "Autopilot".', zh: '要让 Claude Code、Codex 等 Agent 生\u2060成\u2060视\u2060频,点“AI 全自动”。'}, {id: 'editor-timeline', group: 'editor', anchor: '[data-fv-guide="editor-timeline"]', added: true, en: 'Drag a video here.', zh: '把视频拖到这里。'}, {id: 'editor-trim', group: 'editor', anchor: '[data-fv-guide="editor-trim"]', added: true, diff --git a/web/image_editor.js b/web/image_editor.js index 4cd1efc8..781e0a97 100644 --- a/web/image_editor.js +++ b/web/image_editor.js @@ -538,7 +538,7 @@ export async function openImageEditor({source = null, name = source || '', targe let text = ''; if (['eraser', 'heal', 'clone', 'blur'].includes(tool) && !rasterKinds.has(layer.kind)) text = t('Pick an image or paint layer to work on its pixels.', '请选择图片图层或绘画图层再处理像素。'); else if (tool === 'clone' && !opts.clone.source) text = t('Alt-click to set the source, then paint.', '先按住 Alt 点击设置取样点,再涂抹。'); - else if (tool === 'move' && layer.locked && doc.layers.length > 1) text = t('The original image is locked. Click another layer to move it.', '原图图层已锁定;点击其他图层即可移动。'); + else if (tool === 'move' && layer.locked && doc.layers.length > 1) text = t('The original image is locked. Click another layer to move it.', '原图图层已锁定;点击其他图层可移动。'); else if (tool === 'lasso' && opts.lasso.type === 'polygon') text = t('Click to add corners; double-click or press Enter to close.', '单击添加顶点;双击或按 Enter 闭合。'); hint.textContent = text; hint.hidden = !text; } diff --git a/web/library.js b/web/library.js index 957bc7d0..bf1a63d0 100644 --- a/web/library.js +++ b/web/library.js @@ -93,7 +93,7 @@ export async function libraryPage(t, {limit = 24, before = null, signal} = {}) { const query = new URLSearchParams({limit: String(limit)}); if (before) query.set('before', before); const response = await api.fetchApi('/freevideo/library?' + query, {signal}); - if (response.status === 404) throw Object.assign(new Error(t('Restart ComfyUI to open your creations after updating.', '更新后重启 ComfyUI,即可打开作品。')), {restart: true}); + if (response.status === 404) throw Object.assign(new Error(t('Restart ComfyUI to open your creations after updating.', '更新后重启 ComfyUI,可打开作品。')), {restart: true}); if (!response.ok) throw new Error(t('Could not load your creations. Try Refresh.', '作品暂时无法读取,请点击刷新。')); return response.json(); } @@ -144,15 +144,15 @@ async function workflowBackfill(collection, t) { const result = value?.result || {}; heading.textContent = result.embedded ? t(`Workflows added to ${result.embedded} videos`, `已为 ${result.embedded} 个视频补上工作流`) : t('No video was changed', '没有视频被改动'); text.textContent = result.embedded - ? t('Drag them onto the canvas to restore their workflow. The originals are kept next to them as video.before-workflow.mp4.', '把它们拖到画布上即可还原工作流。原视频保留在各自目录,文件名为 video.before-workflow.mp4。') + ? t('Drag them onto the canvas to restore their workflow. The originals are kept next to them as video.before-workflow.mp4.', '把它们拖到画布上可还原工作流。原视频保留在各自目录,文件名为 video.before-workflow.mp4。') : t('Videos that could not be checked safely were left as they were.', '无法安全确认的视频都保持原样。'); if (result.failed) text.textContent += ' ' + t(`${result.failed} videos were left unchanged and will be offered again.`, `另有 ${result.failed} 个视频未改动,之后会再次提示。`); - actions.replaceChildren(button(t('Done', '好的'), () => box.remove(), 'fv-quiet')); + actions.replaceChildren(button(t('Got it', '知道了'), () => box.remove(), 'fv-quiet')); } if (value.state === 'running') { follow(); return; } heading.textContent = t('Add workflows to earlier videos', '为之前的视频补上工作流'); text.textContent = t(`${value.count} earlier videos can get the workflow they were made with, so dropping them on the canvas restores it. Nothing changes until you confirm, and each original is kept as a backup (about ${gb(value.bytes)} GB).`, - `有 ${value.count} 个之前生成的视频可以补上生成时的工作流,补写后拖到画布即可还原。确认前不会改动任何文件,每个原视频都会保留一份备份(约 ${gb(value.bytes)} GB)。`); + `有 ${value.count} 个之前生成的视频可以补上生成时的工作流,补写后拖到画布可还原。确认前不会改动任何文件,每个原视频都会保留一份备份(约 ${gb(value.bytes)} GB)。`); const run = button(t('Add workflows', '补写工作流'), async () => { run.disabled = later.disabled = true; try { if (!(await post('run')).ok) throw new Error(); follow(); } diff --git a/web/prompt_enhance.js b/web/prompt_enhance.js index 3193315c..4099f60a 100644 --- a/web/prompt_enhance.js +++ b/web/prompt_enhance.js @@ -125,8 +125,8 @@ export function createPromptEnhancer({node, input, editor, api, context, setText busy: t('Finish the current queue before enhancing this prompt.', '请等待当前队列完成后再改写。'), prompt_required: t('Type a prompt first; enhancement rewrites it before generating.', '请先输入提示词,生成前会自动改写。'), setup_required: t('Complete FreeVideo installation first.', '请先完成 FreeVideo 安装。'), - model_missing: t('Download the optional model to enable enhancement.', '下载可选模型后即可启用。'), - download_failed: t('Download paused. Retry to resume.', '下载已暂停,重试即可继续。'), + model_missing: t('Download the optional model to enable enhancement.', '下载可选模型后可启用。'), + download_failed: t('Download paused. Retry to resume.', '下载已暂停,重试可继续。'), disk_space: t('Not enough disk space for the optional model.', '可选模型的磁盘空间不足,请清理后重试。'), ram_space: t(`This model needs about ${gib(error?.ramBytes || ramBytes)} GiB of available RAM to load. You can keep using the original.`, `此模型加载需约 ${gib(error?.ramBytes || ramBytes)} GiB 可用内存,可先使用原文生成。`), unsupported_media: t('Use images added in Media for enhancement; connected nodes, video and audio are not supported yet.', '增强暂支持在素材中添加的图片;连接节点、视频和音频暂不支持。'), diff --git a/web/prompt_references.js b/web/prompt_references.js index 48cd4cd3..1096c786 100644 --- a/web/prompt_references.js +++ b/web/prompt_references.js @@ -203,7 +203,7 @@ export function attachReferencePicker(input, {items, t = en => en, view, host, a if (!matches.length) { const empty = document.createElement('div'); empty.className = 'fv-reference-empty'; empty.textContent = invalid ? t('Check your media selection.', '请检查素材用途是否冲突。') - : available.length || everyone.length ? t('No matching characters or media', '没有匹配的角色或素材') : t('Add reference media to use @', '添加参考素材后即可使用 @'); + : available.length || everyone.length ? t('No matching characters or media', '没有匹配的角色或素材') : t('Add reference media to use @', '添加参考素材后可使用 @'); list.append(empty); if (!available.length && !everyone.length && !invalid && addMedia) { const add = document.createElement('button'); add.type = 'button'; add.textContent = t('Add media', '添加素材'); diff --git a/web/report_issue.js b/web/report_issue.js index a57fb8de..e93b3143 100644 --- a/web/report_issue.js +++ b/web/report_issue.js @@ -177,12 +177,12 @@ export function showReportTip(bar, anchor, t, {mode = 'invite', submit, onHide = title.textContent = t('Last step', '最后一步'); lead.textContent = savedName?.endsWith('.zip') ? t(`The report is large, so it was saved as ${savedName}. Please drag it into "Diagnostic report", then select Submit new issue.`, - `报告较大,已保存为 ${savedName}。请将它拖入「诊断报告」,再点击 Submit new issue 即可。`) + `报告较大,已保存为 ${savedName}。请将它拖入「诊断报告」,再点击 Submit new issue。`) : savedName ? t(`Please drag ${savedName} into "Diagnostic report", then select Submit new issue.`, - `请将 ${savedName} 拖入「诊断报告」,再点击 Submit new issue 即可。`) + `请将 ${savedName} 拖入「诊断报告」,再点击 Submit new issue。`) : t('Please drag the downloaded report into "Diagnostic report", then select Submit new issue.', - '请将下载的诊断报告拖入「诊断报告」,再点击 Submit new issue 即可。'); + '请将下载的诊断报告拖入「诊断报告」,再点击 Submit new issue。'); const link = el('a', null, t("Page didn't open? Go to GitHub", '页面没有打开?点此前往 GitHub')); link.href = address; link.target = '_blank'; link.rel = 'noopener'; note.replaceChildren(link); diff --git a/web/setup.js b/web/setup.js index e775cfa4..d9c3dbb3 100644 --- a/web/setup.js +++ b/web/setup.js @@ -287,7 +287,7 @@ export async function openSetup() { : planErrors ? t("Fix the problems marked in red above, then click Detect & review again.", "上面有标红的问题,处理后请重新点“检测并预览”。") : ready && !planId ? t(INSTALLED_HINT.en, INSTALLED_HINT.zh) : !planId ? "" - : !accept.checked ? t("Tick the agreement above to enable Install / repair.", "勾选上方的同意选项后,即可点击“安装/修复”。") : ""; + : !accept.checked ? t("Tick the agreement above to enable Install / repair.", "勾选上方的同意选项后,可点击“安装/修复”。") : ""; blocked.textContent = why; blocked.hidden = !why; }; // A changed setting makes a shown plan outdated: clear it rather than leave it beside a disabled button. diff --git a/web/studio.js b/web/studio.js index ee0248b0..c830fbf2 100644 --- a/web/studio.js +++ b/web/studio.js @@ -902,7 +902,7 @@ export function openStudio(node, {extendFrom = null} = {}) { let releaseExtendThumbnail = () => {}; cleanup.push(() => releaseExtendThumbnail()); if (mediaNode?.freevideoCreateMediaEditor) { cleanup.push(mediaNode.freevideoCreateMediaEditor(mediaMount)); mediaDetails.open = mediaFilled(); } - else mediaMount.append(el('p', t('Connect a FreeVideo Media panel in node view to upload here.', '在节点视图连接 FreeVideo 素材面板,即可在这里上传。'), 'fv-legacy')); + else mediaMount.append(el('p', t('Connect a FreeVideo Media panel in node view to upload here.', '在节点视图连接 FreeVideo 素材面板,可在这里上传。'), 'fv-legacy')); paintMediaHint(); mediaDetails.addEventListener('toggle', paintMediaHint); controls.addEventListener('change', paintMediaHint, true); controls.addEventListener('input', paintMediaHint, true); const connectedLora = upstream(node, 'loras'); diff --git a/web/view_navigation.js b/web/view_navigation.js index 2be364d0..26ed284e 100644 --- a/web/view_navigation.js +++ b/web/view_navigation.js @@ -139,7 +139,7 @@ export function installNavigation(openStudio, agent = false, {openWorkflow = nul settings.textContent = t('Settings', '设置'); settings.className = 'fv-view-settings'; settings.onclick = openSetup; row.append(grip, brand, viewSwitch('nodes', () => choose(node => { open?.(node); }, true), () => {}, () => choose(async (node, created) => { const handle = await openEditorView(node); - if (created) handle?.notify(t('Created an editing project for you.', '已为你新建一个剪辑工程')); + if (created) handle?.notify(t('Created an editing project for you.', '已新建一个剪辑工程')); return handle; }, true), () => choose(node => openAutopilot(node), true)), createLanAccess(t, api).element, createVersionInfo(cn).element, feedbackButton(t, 'fv-view-settings'), settings); toolbar.append(row); toolbar.append(createUpdateNotice(cn).element); From 7d16d6a0b3a2593f81f45e4435e311e3b6c42be7 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 19:32:02 +0000 Subject: [PATCH 25/58] Editor names known causes when subtitles, the subtitle download or a media import fail, and... Editor names known causes when subtitles, the subtitle download or a media import fail, and reads GBK and UTF-16 subtitle files --- freevideo_engine/comfy_editor.py | 6 ++++-- web/editor.js | 2 +- web/editor_library.js | 22 +++++++++++++++------- web/editor_store.js | 3 ++- web/editor_text.js | 28 ++++++++++++++++++++-------- web/editor_text_model.js | 10 +++++++++- 6 files changed, 51 insertions(+), 20 deletions(-) diff --git a/freevideo_engine/comfy_editor.py b/freevideo_engine/comfy_editor.py index 5150f7a2..29d36e54 100644 --- a/freevideo_engine/comfy_editor.py +++ b/freevideo_engine/comfy_editor.py @@ -1055,9 +1055,11 @@ def submit(self, identity): def prepare(self, identity): try: transcode(self.root, identity) - except Exception: + except Exception as error: + # A full disk is not the file's fault, so the page says so instead of listing formats. + reason = _disk_full(error).get('reason', 'unreadable') try: - _import_update(self.root, identity, state='failed', reason='unreadable') + _import_update(self.root, identity, state='failed', reason=reason) except (OSError, ValueError): pass # The terminal snapshot is retained and retried by the worker/reads. diff --git a/web/editor.js b/web/editor.js index ffcb4365..94e045ea 100644 --- a/web/editor.js +++ b/web/editor.js @@ -1042,7 +1042,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given download: report => new Promise((resolve, reject) => { const step = value => { const install = value?.install; - if (install?.state !== 'running') { resolve(install?.state === 'done'); return false; } + if (install?.state !== 'running') { resolve(install?.state === 'done' || install?.reason || false); return false; } report(install.total > 0 ? install.done / install.total : 0); return true; }; store.installEars().then(value => { if (step(value)) store.watchEarsPack(step); }, reject); diff --git a/web/editor_library.js b/web/editor_library.js index cac1cf80..17a1526a 100644 --- a/web/editor_library.js +++ b/web/editor_library.js @@ -1,6 +1,7 @@ import {editorIcon} from './editor_icons.js'; import {sourceKey} from './editor_store.js'; import {issueButton} from './issue_button.js'; +import {connectionLostText} from './browser_extension.js'; const LIBRARY_ISSUE = ['Editor media library', '剪辑器素材库']; const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }; const stamp = frames => { const seconds = Math.floor((frames || 0) / 24); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; }; @@ -24,6 +25,10 @@ export function createEditorLibrary(host, {store, t, actions, state}) { const uploading = new Map(), recentImports = new Map(), pendingAdds = new Map(); const preparing = () => t('Preparing this clip. It goes on the timeline when ready.', '素材准备好后会自动放到时间线上。'); const cannotOpen = () => t('This file cannot be opened. Supported formats: ', '这个文件打不开。支持的格式:') + FORMATS; + // Why an import did not work, and where to report it: only a file that could not be read lists the formats and asks for a report. + const importFailure = (reason, status) => status === 413 ? [t('This file is over 8 GiB. Trim or compress it, then try again.', '文件超过 8 GiB,请先剪短或压缩后再导入。')] + : reason === 'disk_full' ? [t('The disk is full. Free some space, then import again.', '磁盘空间不足,请清理出空间后再导入。')] + : reason === 'import_failed' ? [connectionLostText(t)] : [cannotOpen(), LIBRARY_ISSUE]; function metadata(row, kind) { const g = row.geometry || row, cached = store.sources.get(`${kind}:${row.id}`); return {frames: cached?.frames || Math.round(g.frames || (g.seconds || 0) * (g.fps || 24)), hasVideo: cached?.has_video ?? (kind === 'work' || row.kind === 'video'), @@ -45,7 +50,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { function requestAdd(row, kind, plus = false) { if (disposed) return; if (kind === 'work' || row.state === 'ready') return add(row, kind, plus).catch(actions.error); - if (row.state === 'failed') { actions.notify?.(cannotOpen(), LIBRARY_ISSUE); return; } + if (row.state === 'failed') { actions.notify?.(...importFailure(row.reason)); return; } const key = `${kind}:${row.id}`; if (!pendingAdds.has(key)) pendingAdds.set(key, {plus, project: actions.projectToken?.()}); actions.notify?.(preparing()); @@ -58,7 +63,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { if (row.state === 'ready') { pendingAdds.delete(key); add(row, 'import', pending.plus, {...pending, queued: true}).catch(actions.error); - } else if (row.state === 'failed') { pendingAdds.delete(key); actions.notify?.(cannotOpen(), LIBRARY_ISSUE); } + } else if (row.state === 'failed') { pendingAdds.delete(key); actions.notify?.(...importFailure(row.reason)); } } } function card(row, kind) { @@ -74,7 +79,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { else if (row.kind === 'audio') picture.append(el('span', 'fv-ed-audio-icon', '♫')); if (ready) picture.append(el('span', 'fv-ed-source-time', stamp(metadata(row, kind).frames))); else if (row.state === 'failed') { - picture.append(el('span', 'fv-ed-source-preparing', t('Cannot open', '打不开'))); + picture.append(el('span', 'fv-ed-source-preparing', row.reason === 'disk_full' ? t('Disk full', '磁盘已满') : t('Cannot open', '打不开'))); } else picture.append(el('span', 'fv-ed-source-preparing', t('Preparing', '准备中') + (Number.isFinite(row.fraction) ? ` ${Math.round(Math.max(0, Math.min(1, row.fraction)) * 100)}%` : ''))); button.append(picture); @@ -97,7 +102,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { plus.ondblclick = event => { event.preventDefault(); event.stopPropagation(); }; plus.onkeydown = event => { if (event.key === 'Enter' || event.key === ' ') insert(event); }; container.append(button, plus); - if (kind !== 'work' && row.state === 'failed') container.append(issueButton(t, () => ({where: LIBRARY_ISSUE, message: t('Cannot open', '打不开')}))); + if (kind !== 'work' && row.state === 'failed' && row.reason !== 'disk_full') container.append(issueButton(t, () => ({where: LIBRARY_ISSUE, message: t('Cannot open', '打不开')}))); return container; } function updateUsed() { @@ -122,8 +127,11 @@ export function createEditorLibrary(host, {store, t, actions, state}) { for (const row of shown) grid.append(card(row, tab)); updateUsed(); message.textContent = ''; // Empty states stay silent. - for (const row of shown) if (row.state === 'failed') message.textContent = t('This file cannot be opened. Supported formats: ', '这个文件打不开。支持的格式:') + FORMATS; - if (message.textContent) message.append(issueButton(t, () => ({where: LIBRARY_ISSUE, message: cannotOpen()}))); + const failed = shown.findLast(row => row.state === 'failed'); + if (failed) { + const [text, where] = importFailure(failed.reason); message.textContent = text; + if (where) message.append(issueButton(t, () => ({where, message: text}))); + } if (scroll > 0) { const replacement = anchor && [...grid.querySelectorAll('.fv-ed-source')].find(node => node.dataset.key === anchor.dataset.key); host.scrollTop = replacement ? scroll + replacement.getBoundingClientRect().top - anchorTop : scroll; @@ -225,7 +233,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { if (place) { placing.push({id: item.id, place}); placeReady(imported); } else startPending([item]); } - } catch { actions.notify(cannotOpen(), LIBRARY_ISSUE); } + } catch (error) { actions.notify(...importFailure(error?.message === 'import_failed' ? 'import_failed' : error?.reason, error?.status)); } finally { pendingAdds.delete(`import:${id}`); uploading.delete(id); render(); } } } diff --git a/web/editor_store.js b/web/editor_store.js index 26902165..caa8bc12 100644 --- a/web/editor_store.js +++ b/web/editor_store.js @@ -392,7 +392,8 @@ export function createEditorStore(api, {t = en => en, notify = () => {}, onGener xhr.onload = () => { release(); if (xhr.status === 404) { notice('import', 'Import is unavailable until you restart after updating.', '更新后重启可导入素材。'); resolve(null); return; } - if (xhr.status < 200 || xhr.status >= 300) { reject(errorFor(xhr)); return; } + // The server's reason (a full disk) comes along, as with a failed save. + if (xhr.status < 200 || xhr.status >= 300) { let body = null; try { body = JSON.parse(xhr.responseText); } catch {} reject(Object.assign(errorFor(xhr), {reason: body?.reason})); return; } try { resolve(JSON.parse(xhr.responseText).import); } catch (error) { reject(error); } }; xhr.onerror = () => { release(); reject(new Error('import_failed')); }; diff --git a/web/editor_text.js b/web/editor_text.js index 71e8403a..df42b3fb 100644 --- a/web/editor_text.js +++ b/web/editor_text.js @@ -3,7 +3,7 @@ import * as model from './editor_model.js'; import {FONT_KEYS} from './fonts.js'; import {row, button, slider, number, segmented, toggle} from './editor_adjust.js'; -import {isText, isTextTrack, addText, changeText, changeSubtitleStyle, styleOf, activeText, setTextTrack, parseSRT, importSRT, addSubtitles, defaults, cleanStyle, SUBTITLE_DEFAULTS} from './editor_text_model.js'; +import {isText, isTextTrack, addText, changeText, changeSubtitleStyle, styleOf, activeText, setTextTrack, parseSRT, decodeSRT, importSRT, addSubtitles, defaults, cleanStyle, SUBTITLE_DEFAULTS} from './editor_text_model.js'; import {loadCoverage, loadTextFonts, textFontStack, missingGlyphs, missingHint, overTwoHint, textLayout, drawText} from './editor_text_layout.js'; import {createSelectionActions, isTyping} from './editor_select.js'; import {plan} from './editor_geometry.js'; @@ -69,10 +69,14 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, function speechLabel(fraction){speech.shown=fraction;const p=Math.round(Math.min(1,Math.max(0,Number(fraction)||0))*100),size=Math.max(1,Math.round(speech.need/1e6)),text=fraction==null?(speech.need?t(`Auto subtitles · Download ${size} MB`,`自动字幕 · 下载 ${size} MB`):t('Auto subtitles','自动字幕')):speech.loading?t(`Cancel · Downloading ${p}%`,`取消 · 下载 ${p}%`):t(`Cancel · ${p}%`,`取消 · ${p}%`);for(const b of speech.buttons){b.textContent=text;b.hidden=!(speech.available||speech.need);}} function speechEnd(text,where=null){speech.stop?.();speech.stop=null;speech.job=null;speech.busy=false;speech.loading=false;speechLabel(null);if(text)actions.notify?.(text,where);} const speechFailed=()=>t('Could not create subtitles','没能生成字幕'),speechSilent=()=>t('No speech found','没有听到说话声'); - const speechReason=reason=>reason==='no_sound'?speechSilent():reason==='language'?t('Language not supported yet','暂不支持这种语言'):speechFailed(); + const speechDisk=()=>t('The disk is full. Free some space, then try again.','磁盘空间不足,请清理出空间后重试。'); + // Causes the person can act on are named and carry no issue button; anything else is unexplained. + const speechReasons={no_sound:speechSilent,language:()=>t('Language not supported yet','暂不支持这种语言'),too_long:()=>t('Auto subtitles work on up to 2 hours of sound.','自动字幕最多处理 2 小时的声音。'), + source_missing:()=>t('A source file is missing. Import it again from the library.','有素材的原文件找不到了,请在素材库重新导入。'),unreadable:()=>t('The sound in a clip cannot be read.','有素材的声音无法读取。'),disk_full:speechDisk}; + const speechReason=reason=>(Object.hasOwn(speechReasons,reason)?speechReasons[reason]:speechFailed)(); function speechResult(job){ if(job.state==='running'){speechLabel(job.fraction);return true;} - if(job.state!=='done'){speechEnd(job.state==='cancelled'?null:speechReason(job.reason),job.state==='cancelled'||['no_sound','language'].includes(job.reason)?null:SPEECH_ISSUE);return false;} + if(job.state!=='done'){speechEnd(job.state==='cancelled'?null:speechReason(job.reason),job.state==='cancelled'||Object.hasOwn(speechReasons,job.reason)?null:SPEECH_ISSUE);return false;} const cues=(Array.isArray(job.result?.lines)?job.result.lines:[]).filter(l=>typeof l?.text==='string'&&l.text.trim()&&l.end>l.start).map(l=>({start:l.start,frames:l.end-l.start,text:l.text.trim()})); speechEnd();if(!cues.length){actions.notify?.(speechSilent());return false;} finishInline();commitDraft(); @@ -88,10 +92,11 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, speech.loading=true;speechLabel(0); const done=await actions.speech.download(fraction=>{if(run===speech.run)speechLabel(fraction);}); if(run!==speech.run)return; - const ability=done?await actions.speech.ability():null; + // `done` is true, or the reason the download stopped. + const ability=done===true?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. Try again.','没能下载,请再试一次。'),SPEECH_ISSUE);return;} + if(done!==true||speech.need){speechEnd(done==='disk_space'?speechDisk():t('Could not download. Try again.','没能下载,请再试一次。'),done==='disk_space'?null:SPEECH_ISSUE);return;} if(!speech.available){speechEnd(speechFailed(),SPEECH_ISSUE);return;} speech.loading=false; } @@ -164,9 +169,16 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, }else{ const file=el('input');file.type='file';file.accept='.srt';file.hidden=true; const importButton=button(t('Import subtitle file','导入字幕文件'),{onClick:()=>file.click()});importButton.classList.add('fv-ed-import-srt'); - // Only a file that cannot be read says so; a refused edit (a locked track) flashes red like any other. - file.onchange=async()=>{const f=file.files?.[0];if(!f)return;let text;const result={};try{if(f.size>4*1024*1024)throw new Error();text=await f.text();parseSRT(text,result);}catch{actions.notify?.(t('Could not read this subtitle file','读不出这个字幕文件'));return;}finally{file.value='';} - if(apply(t('Import subtitle file','导入字幕文件'),r=>importSRT(r,text))){const missing=projectMissing(state.record);if(missing.length)actions.notify?.(missingHint([...new Set(missing.flatMap(r=>r.chars))],t));if(result.truncated)actions.notify?.(t('Only the first 500 subtitles were imported.','只导入了前 500 条字幕。'));}}; + // A file that cannot be used says why; a refused edit (a locked track) flashes red like any other. + file.onchange=async()=>{const f=file.files?.[0];if(!f)return;let text;const result={}; + try{ + if(f.size>4*1024*1024){actions.notify?.(t('Subtitle files can be up to 4 MB.','字幕文件不能超过 4 MB。'));return;} + try{text=decodeSRT(await f.arrayBuffer(),t('windows-1252','gb18030'));}catch{actions.notify?.(t('Could not read this subtitle file','读不出这个字幕文件'));return;} + try{parseSRT(text,result);}catch{actions.notify?.(t('No subtitles found. Only SRT files can be imported.','未找到字幕。仅支持导入 SRT 字幕文件。'));return;} + }finally{file.value='';} + if(apply(t('Import subtitle file','导入字幕文件'),r=>importSRT(r,text))){const missing=projectMissing(state.record);if(missing.length)actions.notify?.(missingHint([...new Set(missing.flatMap(r=>r.chars))],t)); + if(result.truncated)actions.notify?.(t('Only the first 500 subtitles were imported.','只导入了前 500 条字幕。')); + else if(result.skipped)actions.notify?.(t(`${result.skipped} subtitles could not be read and were skipped.`,`${result.skipped} 条字幕无法读取,已跳过。`));}}; // Shown only where this computer can recognise speech. const hear=button(t('Auto subtitles','自动字幕'),{onClick:toggleSpeech});hear.classList.add('fv-ed-auto-subtitles');for(const b of speech.buttons)if(!b.isConnected)speech.buttons.delete(b);speech.buttons.add(hear);speechLabel(speech.busy?speech.shown:null); host.append(importButton,hear,file); diff --git a/web/editor_text_model.js b/web/editor_text_model.js index b7d32711..944f1072 100644 --- a/web/editor_text_model.js +++ b/web/editor_text_model.js @@ -101,6 +101,14 @@ export function activeText(record, frame) { return record.tracks.filter(t => isTextTrack(t) && !t.hidden).sort((a,b) => (a.kind === 'text' ? -1 : 1) - (b.kind === 'text' ? -1 : 1)) .flatMap(t => t.clips.filter(c => c.start <= frame && c.start+c.frames > frame).map(c => ({...c, style: styleOf(record,c), opacity: c.text_kind === 'text' && styleOf(record,c).fade ? Math.min(1,(frame-c.start+.5)/7.2,(c.start+c.frames-frame-.5)/7.2) : 1}))); } +// A subtitle file's bytes as text: UTF-8, UTF-16 with its byte-order mark, or else the older local encoding +// (`legacy`, e.g. GBK for Chinese), so such a file never turns into replacement characters. +export function decodeSRT(bytes, legacy = 'windows-1252') { + const view = new Uint8Array(bytes); + if (view[0] === 0xff && view[1] === 0xfe) return new TextDecoder('utf-16le').decode(view); + if (view[0] === 0xfe && view[1] === 0xff) return new TextDecoder('utf-16be').decode(view); + try { return new TextDecoder('utf-8', {fatal: true}).decode(view); } catch { return new TextDecoder(legacy).decode(view); } +} export function parseSRT(text, result = {}) { check(typeof text === 'string' && text.length <= 4*1024*1024, 'srt'); const blocks = text.replace(/^\ufeff/, '').replace(/\r\n?/g,'\n').trim().split(/\n\s*\n/); @@ -126,7 +134,7 @@ export function parseSRT(text, result = {}) { if (skipped) console.warn(`FreeVideo SRT import skipped ${skipped} unreadable blocks.`); if (trimmed) console.warn(`FreeVideo SRT import trimmed ${trimmed} overlapping subtitles.`); check(entries.length > 0, 'srt'); - result.truncated = entries.length > 500; + result.skipped = skipped; result.truncated = entries.length > 500; if (result.truncated) console.warn('FreeVideo SRT import kept only the first 500 subtitles.'); return entries.slice(0, 500); } From 7efc9e059286c7173a65eebe23c2e18fb122aa31 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 19:32:02 +0000 Subject: [PATCH 26/58] Library: say how many creation records could not be read --- freevideo_engine/comfy_library.py | 30 +++++++++++++++++++++++------- web/library.css | 1 + web/library.js | 8 ++++++-- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/freevideo_engine/comfy_library.py b/freevideo_engine/comfy_library.py index 805b5dc2..3f2ece89 100644 --- a/freevideo_engine/comfy_library.py +++ b/freevideo_engine/comfy_library.py @@ -31,6 +31,10 @@ def _video(root, identity): return path +class _Unfinished(ValueError): + """A run whose summary says it did not finish: not a damaged record.""" + + def _report(path): # Read only the small request summary, never tensors, prompts or media. with path.with_suffix('.request.json').open('rb') as stream: @@ -38,8 +42,10 @@ def _report(path): if len(data) > 4 * 1024 * 1024: raise ValueError('Request summary is too large') report = json.loads(data) - if not isinstance(report, dict) or report.get('success') is not True: - raise ValueError('Video is not complete') + if not isinstance(report, dict): + raise ValueError('Request summary is not an object') + if report.get('success') is not True: + raise _Unfinished('Video is not complete') return report @@ -82,12 +88,20 @@ def list_videos(output_directory, *, before=None, limit=24): candidates.append((key, path, info)) except (OSError, ValueError): continue - rows = [] + rows, unreadable = [], [] for key, path, info in sorted(candidates, key=lambda item: item[0], reverse=True): + relative = Path('FreeVideo') / key[1] / 'video.mp4' + if relative.as_posix() in hidden: + continue try: - report = _report(path) - relative = Path('FreeVideo') / key[1] / 'video.mp4' - if relative.as_posix() in hidden: + try: + report = _report(path) + except (FileNotFoundError, _Unfinished): + # A generation still writing its video has no summary yet; a failed run is not listed. + continue + except (OSError, ValueError): + # Summaries are replaced atomically, so one that cannot be read is damaged. + unreadable.append(key) continue summary = output_summary(report, relative) # Geometry may gain input metadata in future reports. Keep this API @@ -123,7 +137,9 @@ def list_videos(output_directory, *, before=None, limit=24): next_cursor = rows[-1]['cursor'] if more else None for row in rows: row.pop('cursor') - return dict(items=rows, next=next_cursor) + # Each page counts the damaged summaries in its own range, so the pages add up. + last = (int(next_cursor.split(':', 1)[0]), next_cursor.split(':', 1)[1]) if next_cursor else None + return dict(items=rows, next=next_cursor, unreadable=sum(last is None or key > last for key in unreadable)) def thumbnail(output_directory, identity): diff --git a/web/library.css b/web/library.css index c3474247..8523b7f5 100644 --- a/web/library.css +++ b/web/library.css @@ -71,5 +71,6 @@ .fv-library .fv-library-undo:not([hidden])~.fv-library-selection:not([hidden]){margin-top:0} .fv-library .fv-library-undo button{min-height:28px;padding:2px 10px;font-size:13px;color:var(--fv-accent)} .fv-library .fv-library-skipped{flex-basis:100%;font-size:12px;color:var(--fv-muted)} +.fv-library .fv-library-unreadable{margin:8px;text-align:center}.fv-library .fv-library-unreadable:empty{display:none} @media(max-width:680px){.fv-library-tools{gap:0}.fv-library .fv-library-header{grid-template-columns:auto minmax(0,1fr) auto}.fv-library-header h2{text-align:center}.fv-library-header button{white-space:nowrap}.fv-studio.fv-library .fv-library-header button{padding-left:6px;padding-right:6px}} .fv-library-message .fv-issue-button{margin:4px 0 4px 8px;vertical-align:middle} diff --git a/web/library.js b/web/library.js index bf1a63d0..159d37d7 100644 --- a/web/library.js +++ b/web/library.js @@ -193,6 +193,8 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu const grid = el('div', null, 'fv-library-grid'); const message = el('p', '', 'fv-library-message'); message.setAttribute('role', 'status'); const more = button(t('Show more', '加载更多'), () => load(true), 'fv-quiet'); more.hidden = true; + // Creations whose saved record could not be read stay on disk; say how many the loaded pages skipped. + const unreadable = el('p', '', 'fv-library-skipped fv-library-unreadable'); let unread = 0; // Selecting several creations: a strip above the cards, the delete bar below them. const picking = el('div', null, 'fv-library-picking'); picking.hidden = true; const pickAll = button(t('Select all', '全选'), () => selectAll(), 'fv-quiet'); @@ -207,7 +209,7 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu bar.append(barLabel, barText, barActions); // The undo note and the delete bar share one sticky dock so they stack instead of covering each other. const dock = el('div', null, 'fv-library-dock'); dock.append(undoBar, bar); - collection.append(picking, grid, message, more, dock); + collection.append(picking, grid, message, more, unreadable, dock); workflowBackfill(collection, t); const detail = el('section', null, 'fv-library-detail'); const all = button(t('All creations', '全部作品'), () => { @@ -661,7 +663,9 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu message.textContent = rows.size ? '' : t('Loading your creations…', '正在读取作品…'); try { const data = await libraryPage(t, {before: appendPage ? next : null, signal: abort.signal}); if (disposed) return; - if (!appendPage) { thumbnails.disconnect(); grid.replaceChildren(); cards.clear(); rows.clear(); } + if (!appendPage) { thumbnails.disconnect(); grid.replaceChildren(); cards.clear(); rows.clear(); unread = 0; } + unread += Number.isSafeInteger(data.unreadable) && data.unreadable > 0 ? data.unreadable : 0; + unreadable.textContent = unread ? t(`${unread} ${unread === 1 ? 'creation' : 'creations'} could not be read`, `另有 ${unread}\u00a0个作品无法读取`) : ''; for (const row of data.items || []) append(row); for (const row of arrivals) append(row, true); next = data.next; more.hidden = !next; From bb3cc82d309f236a51cb080596add1a859cd63ad Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 19:32:02 +0000 Subject: [PATCH 27/58] A failed part says so in one line and a missing media file is named --- web/error_panel.js | 32 +++++++++++++++++++++++++++++--- web/studio.js | 18 +++++++++++++++--- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/web/error_panel.js b/web/error_panel.js index 817836d7..5316c60b 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -103,6 +103,24 @@ function knownError(text) { || error.prefixes?.some(prefix => line.startsWith(prefix)))?.key ?? null; } +// The media node's own check (comfy_assets.resolve_assets) names a file that is no longer in ComfyUI's +// input folder. Only ComfyUI's custom-validation entry counts, so typed text quoting the sentence never does. +// Returns the file's name, or null. A part of a long video carries the entry after its message (comfy_chain). +function missingMedia(detail) { + if (reports.has(detail)) return null; // A saved report holds no chosen input. + let nodes = record(record(detail?.response).node_errors ?? detail?.node_errors); + const part = /^Prompt outputs failed validation; (\{.*\})$/s.exec(typeof detail === 'string' ? detail : scalar(detail?.message)); + if (!Object.keys(nodes).length && part) { + try { nodes = {part: JSON.parse(part[1])}; } catch { /* not the part's entry */ } + } + for (const node of Object.values(nodes)) for (const reason of list(node?.errors)) { + const found = reason?.type === 'custom_validation_failed' + && /^\w+ - Upload or select the missing media file: (.+)$/s.exec(scalar(reason.details)); + if (found) return found[1].split(/[\\/]/).filter(Boolean).at(-1) || ''; + } + return null; +} + // 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, use Download report in the preview and submit it in a GitHub issue.', @@ -253,7 +271,8 @@ export function createErrorReport(detail, context = {}) { for (const key of ['done', 'total']) if (Number.isFinite(context.progress?.[key])) progress[key] = context.progress[key]; const message = scalar(response.error?.message) || scalar(response.error) || scalar(response.message) || scalar(detail?.exception_message) || scalar(detail?.message) || scalar(detail) || 'FreeVideo generation failed.'; - const known = knownError(message); + // The missing file's name is not kept: the report never holds typed or chosen input. + const known = knownError(message) ?? (missingMedia(detail) === null ? null : 'media-missing'); const kind = failureAdvice(errorText(detail), (en) => en, known).kind; const found = detectBrowserExtension(detail, {kind, known, scrub: clean.inputsOnly}); // A script name the report would redact is not shown or saved either. @@ -329,6 +348,9 @@ export function failureAdvice(value, t, known = knownError(value)) { if (key === known) return {...row(key, title, summary, ['', '']), plain: true}; } + if (known === 'media-missing') + return {...row('media-missing', ['A media file is missing', '找不到素材'], + ['Upload or select the missing media file again, then generate.', '请重新上传或选择缺少的素材后再生成。'], ['', '']), 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 generation settings', '检查生成设置'], ['The generation settings did not pass validation; see the error details below.', '生成设置未通过检查,可查看下方错误详情。'], @@ -458,9 +480,12 @@ export function adviceFor(report, t, value) { advice.title = t('The queue operation did not complete', '队列操作未完成'); advice.summary = t('Check that ComfyUI is still running and connected, then retry.', '请确认 ComfyUI 仍在运行、连接正常后重试。'); } + // The name comes from the error as it arrived; a card shown again from its report says it without one. + const media = advice.kind === 'media-missing' ? missingMedia(value) : null; + if (media) advice.summary = t(`"${media}" could not be found. Upload or select it again, then generate.`, `素材“${media}”已不存在,请重新上传或选择后再生成。`); // Every failure except a rejected input (which the user fixes in place) has one layout: written title, // Export report / Submit to GitHub, the raw error only inside the technical details. - const generated = advice.kind !== 'validation'; + const generated = !['validation', 'media-missing'].includes(advice.kind); // A generation that stopped without a known cause is named by the stage it stopped in. if (generated && advice.kind === 'unknown' && report.stage === 'execution') { const stage = failureStage(report); @@ -584,7 +609,8 @@ export function createErrorPanel(t) { `${node.type || t('Node', '节点')} #${node.id}${error.input ? ' · ' + error.input : ''}: ${error.message}${error.details ? ': ' + error.details : ''}`)); const causeText = shownCause(reasons.length ? reasons.join('\n') : [report.message, report.error_details].filter(Boolean).join('\n')); const internal = advice.kind === 'unknown' && !['submission', 'queue'].includes(report.stage); - const causeInDetails = generated; + // A missing file is said in the explanation; where it happened and ComfyUI's wording stay in the technical details. + const causeInDetails = generated || advice.kind === 'media-missing'; location.hidden = causeInDetails; detailLocation.hidden = !causeInDetails; detailLocation.textContent = causeInDetails ? location.textContent : ''; if (causeInDetails) location.textContent = ''; diff --git a/web/studio.js b/web/studio.js index c830fbf2..970cbaf9 100644 --- a/web/studio.js +++ b/web/studio.js @@ -1413,9 +1413,9 @@ export function openStudio(node, {extendFrom = null} = {}) { updateRunButton(); }, onResult: (result, outcome) => { node.freevideoLastResult = result; showResult(result, {chosen: outcome.partial}); - if (outcome.partial && !outcome.joined) status.textContent = t('Showing the last completed part.', '正在显示最后完成的一段。'); }, - onError: error => showQueueError(new Error(error.joining ? t(`Could not join the parts: ${error.message}`, `未能接合各段:${error.message}`) - : t(`Part ${error.index + 1} failed: ${error.message}`, `第 ${error.index + 1} 段失败:${error.message}`)))}); + // A part that failed keeps its sentence; the part list already marks which ones finished. + if (outcome.partial && !outcome.joined && status.dataset.error !== 'true') status.textContent = t('Showing the last completed part.', '正在显示最后完成的一段。'); }, + onError: showPartError}); cleanup.push(() => chainController.dispose()); node.freevideoStudioChain = chainController; node.freevideoEditorLooping = () => queueController.state().looping; @@ -1522,6 +1522,18 @@ export function openStudio(node, {extendFrom = null} = {}) { lastQueueError = null; failure.clear(); node.freevideoFailure = ''; node.freevideoFailureReport = null; status.dataset.error = 'false'; status.textContent = ''; } + // A part that stopped is one sentence on the status line; what ComfyUI said goes in the card, as a generation failure. + function showPartError(error) { + if (disposed) return; + status.dataset.error = 'true'; + status.textContent = error.joining ? t('Could not join the parts. See the details below.', '未能接合各段,原因见下方。') + : t(`Part ${error.index + 1} could not be generated. See the details below.`, `第 ${error.index + 1} 段未能生成,原因见下方。`); + if (lastQueueError === error.message) return; + lastQueueError = error.message; + const report = failure.show(error.message, false, + {stage: 'execution', node, graph: app.graph, ...(error.joining ? {progress: {phase: 'save'}} : {})}); + node.freevideoFailureReport = report; node.freevideoFailure = errorText(report); + } function updateRunButton() { const submitting = capturing || queueState?.submitting; updateFirstFrame(); From 1cd6d3a3b57ccc08449d3487ad4171e54f26305f Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 19:32:02 +0000 Subject: [PATCH 28/58] Keep failure reasons for full disks and unknown export errors --- freevideo_engine/agent/errors.py | 8 +++++++- freevideo_engine/comfy_metadata.py | 20 +++++++++++++++----- freevideo_engine/comfy_share.py | 8 +++++++- freevideo_engine/disk_space.py | 13 +++++++++++++ freevideo_engine/editor_export.py | 9 +++++++-- freevideo_engine/image_gen/failure.py | 9 +++++++-- freevideo_engine/image_gen/service.py | 6 ++++-- web/image_generation.js | 1 + web/library.js | 3 +++ web/share.js | 1 + 10 files changed, 65 insertions(+), 13 deletions(-) diff --git a/freevideo_engine/agent/errors.py b/freevideo_engine/agent/errors.py index b621f3e7..77c55cbb 100644 --- a/freevideo_engine/agent/errors.py +++ b/freevideo_engine/agent/errors.py @@ -132,6 +132,8 @@ '显卡暂时用不了,稍后再试。'), 'worker_exit': ('Image generation stopped unexpectedly. Try again.', '图像生成意外停止了,请再试一次。'), 'timeout': ('This image took too long and was stopped. Try again.', '这一张用时太长,已停止。请再试一次。'), + 'disk_full': ('Saving the image failed: the disk is full. Free some space, then generate again.', + '保存图片时出错:磁盘空间不够,空出空间后再生成。'), 'generation_failed': ('Image generation ran into an error. Try again.', '图像生成出错了,请再试一次。'), 'invalid_media': ('The reference image could not be read. Choose another one.', '参考图读不出来,请换一张。'), @@ -149,7 +151,7 @@ def image_failure(code, report_id=None): valid = isinstance(report_id, str) and re.fullmatch(r'[0-9a-f]{32}', report_id) return dict(reason=dict(en=en, zh=zh), report_id=report_id if valid and code not in ( 'busy', 'cancelled', 'invalid_request', 'invalid_media', 'invalid_prompt', 'invalid_dimensions', - 'invalid_seed', 'too_many_images') else None) + 'invalid_seed', 'too_many_images', 'disk_full') else None) # Recovery is a function of the committed task and phase, not just an error code. @@ -172,6 +174,7 @@ def image_failure(code, report_id=None): 'timeout': IMAGE_REASONS['timeout'][0], 'generation_failed': IMAGE_REASONS['generation_failed'][0], 'worker_exit': 'The image worker stopped before finishing this picture.', + 'disk_full': IMAGE_REASONS['disk_full'][0], 'cancelled': 'The picture job was cancelled.', } for _code, _message in IMAGE_ERRORS.items(): @@ -215,6 +218,8 @@ def recovery(code, *, kind=None, stage='request', resumable=True): return SESSION_GONE[1] if kind == 'image' and code == 'pack_missing': return ERRORS[code][3] + if kind == 'image' and code == 'disk_full': + return 'Tell the user to free disk space, then submit the picture again with a new ref.' if kind == 'image' and code == 'no_memory': return 'Lower the picture dimensions or use fewer reference pictures, then submit with a new ref.' if kind in RECOVERY and stage == 'terminal': @@ -283,6 +288,7 @@ def safe_details(value): 'join_failed': '片段已生成,但拼接成片时出错', 'invalid_output': '生成的结果无法读取或保存', 'storage_unavailable': '任务记录或素材无法写入磁盘', + 'disk_full': '磁盘空间不够,图片没能保存', 'submit_interrupted': '提交生成时服务中断,尚未确认任务是否开始', 'timeout': '生成等了太久没有结果', 'invalid_media': '参考素材无法读取', diff --git a/freevideo_engine/comfy_metadata.py b/freevideo_engine/comfy_metadata.py index 00e052b5..0abafdf8 100644 --- a/freevideo_engine/comfy_metadata.py +++ b/freevideo_engine/comfy_metadata.py @@ -19,6 +19,11 @@ BACKUP_NAME = 'video.before-workflow.mp4' +def _room(path, size): + """A rewrite needs the copy and the backup beside the original, plus some headroom.""" + return disk_space.free_bytes(path.parent) >= 2 * size + 512 * 1024 * 1024 + + def embed_comfy_metadata(path, *, prompt=None, workflow=None, backup=None): """Rewrite the MP4 at `path` with the graph in its metadata, copying the streams. The new file replaces the original only after every audio and video @@ -38,7 +43,7 @@ def embed_comfy_metadata(path, *, prompt=None, workflow=None, backup=None): if backup is not None and backup.exists(): logging.info('FreeVideo left %s unchanged: %s already exists.', path, backup.name) return False - if disk_space.free_bytes(path.parent) < 2 * size + 512 * 1024 * 1024: + if not _room(path, size): logging.info('FreeVideo left %s unchanged: not enough free disk space.', path) return False # faststart puts moov, and with it the metadata, before the media data. @@ -147,7 +152,7 @@ def backfill(output_directory, *, stop=None): rows.setdefault(json.loads(row.read_text(encoding='utf-8'))['id'], []).append(row) except (OSError, ValueError, KeyError, TypeError): continue - counts = dict(embedded=0, skipped=0, pending=0, failed=0) + counts = dict(embedded=0, skipped=0, pending=0, failed=0, no_space=0) for video in sorted(root.glob('*/*/video.mp4')): if stop is not None and stop.is_set(): return counts @@ -172,6 +177,11 @@ def backfill(output_directory, *, stop=None): continue if not embed_comfy_metadata(video, workflow=workflow, backup=run / BACKUP_NAME): counts['failed'] += 1 + # Space is checked after the attempt too: a write that ran out has already removed its copy. + try: + counts['no_space'] += not _room(video, video.stat().st_size) + except OSError: + pass continue counts['embedded'] += 1 # Keep saved results reusable: the index records each output's size and hash. @@ -270,12 +280,12 @@ def register(): def run(output): try: - result = backfill(output) or dict(embedded=0, skipped=0, pending=0, failed=0) + result = backfill(output) or dict(embedded=0, skipped=0, pending=0, failed=0, no_space=0) if result['embedded']: logging.info('FreeVideo stored the workflow in %d earlier videos.', result['embedded']) - except Exception: + except Exception as error: logging.warning('FreeVideo could not store the workflow in earlier videos.', exc_info=True) - result = dict(embedded=0, skipped=0, pending=0, failed=1) + result = dict(embedded=0, skipped=0, pending=0, failed=1, no_space=int(disk_space.ran_out(error))) with _job_lock: _job.update(state='done', result=result) diff --git a/freevideo_engine/comfy_share.py b/freevideo_engine/comfy_share.py index c8878784..ac1b2eca 100644 --- a/freevideo_engine/comfy_share.py +++ b/freevideo_engine/comfy_share.py @@ -10,6 +10,7 @@ import hashlib import io import json +import logging import math from pathlib import Path import threading @@ -212,7 +213,12 @@ async def export(request): try: await asyncio.shield(task) except (InterruptedError, OSError): pass raise - except (ValueError, TypeError, OSError, InterruptedError): + except (ValueError, TypeError, OSError, InterruptedError) as error: + from .disk_space import ran_out + if ran_out(error): + raise web.HTTPInsufficientStorage(text='disk_full') from None + if not isinstance(error, InterruptedError): + logging.warning('FreeVideo sharing export failed.', exc_info=error) raise web.HTTPBadRequest(text='Sharing export could not complete') from None @server.routes.get('/freevideo/share/video') diff --git a/freevideo_engine/disk_space.py b/freevideo_engine/disk_space.py index ceaaf791..c9575eb9 100644 --- a/freevideo_engine/disk_space.py +++ b/freevideo_engine/disk_space.py @@ -7,6 +7,7 @@ one APFS container share their space, so they are counted as one disk. Other systems keep shutil.disk_usage, which matches Explorer and df. """ +import errno import os from pathlib import Path import re @@ -195,6 +196,18 @@ def free_bytes(path): return shutil.disk_usage(io_path(existing(path))).free +def ran_out(error): + """Whether a write failed because the disk (or the user's quota) is full, through wrapper exceptions.""" + seen = set() + while error is not None and id(error) not in seen: + seen.add(id(error)) + if isinstance(error, OSError) and (error.errno in (errno.ENOSPC, getattr(errno, 'EDQUOT', errno.ENOSPC)) + or getattr(error, 'winerror', None) in (39, 112)): + return True + error = error.__cause__ or (None if error.__suppress_context__ else error.__context__) + return False + + @plain_errors def disk_key(path): """Paths with the same key draw on the same free space.""" diff --git a/freevideo_engine/editor_export.py b/freevideo_engine/editor_export.py index 01e95197..d73cb8d0 100644 --- a/freevideo_engine/editor_export.py +++ b/freevideo_engine/editor_export.py @@ -75,7 +75,8 @@ def _seam_call(operation, *args, **kwargs): def _reason(error): if isinstance(error, ExportError): return error.reason - if isinstance(error, OSError) and error.errno == errno.ENOSPC: + from .disk_space import ran_out + if ran_out(error): return 'disk_full' if isinstance(error, PermissionError) or isinstance(error, OSError) and error.errno in (errno.EACCES, errno.EROFS): return 'not_writable' @@ -1100,11 +1101,15 @@ def progress(fraction): _checked_target(folder, self.root).rmdir() except (OSError, ExportError): pass + reason = _reason(error) + if reason == 'failed' and not isinstance(error, ExportCancelled): + # The page only says the export failed; the log keeps why for the diagnostic report. + logging.getLogger(__name__).error('Editor export failed.', exc_info=error) with self.lock: if isinstance(error, ExportCancelled) or job['stop'].is_set(): job.update(state='cancelled', reason=None) else: - job.update(state='failed', reason=_reason(error)) + job.update(state='failed', reason=reason) def register(): diff --git a/freevideo_engine/image_gen/failure.py b/freevideo_engine/image_gen/failure.py index c7cde206..c2b738eb 100644 --- a/freevideo_engine/image_gen/failure.py +++ b/freevideo_engine/image_gen/failure.py @@ -9,14 +9,16 @@ import traceback from .. import __version__ +from ..disk_space import ran_out from ..exit_diagnostics import clean_reading, clean_sample from ..failure_details import Redactor ERRORS = frozenset(('no_memory', 'small_memory', 'busy_video', 'busy', 'pack_missing', 'model_unreadable', - 'device_unavailable', 'worker_exit', 'timeout', 'cancelled', + 'device_unavailable', 'worker_exit', 'timeout', 'cancelled', 'disk_full', 'invalid_request', 'invalid_media', 'invalid_points', 'segment_not_installed', 'generation_failed')) -NO_REPORT = frozenset(('small_memory', 'busy', 'invalid_request', 'invalid_media', 'invalid_points', 'segment_not_installed', 'cancelled')) +NO_REPORT = frozenset(('small_memory', 'busy', 'invalid_request', 'invalid_media', 'invalid_points', 'segment_not_installed', 'cancelled', + 'disk_full')) STAGES = ('start', 'load', 'text', 'encode', 'denoise', 'decode') KINDS = ('generate', 'edit', 'cast', 'compose', 'download', 'install') MESSAGE_ZH = { @@ -29,6 +31,7 @@ 'device_unavailable': '显卡暂时用不了,稍后再试。', 'worker_exit': '图像生成意外停止了,请再试一次。', 'timeout': '这一张用时太长,已停止。请再试一次。', + 'disk_full': '保存图片时出错:磁盘空间不够,空出空间后再生成。', 'generation_failed': '图像生成出错了,请再试一次。', 'invalid_request': '图片设置有误,请调整后重试。', 'invalid_media': '图片无效,请重新选择输入目录中的图片。点选支持不超过 4096×4096 像素总数的图片。', @@ -76,6 +79,8 @@ def classify_exception(error, *, stage='start', cancelled=False): names = {cls.__name__ for cls in type(item).__mro__} if isinstance(item, MemoryError) or 'OutOfMemoryError' in names or _MPS_MEMORY.search(str(item)): return 'no_memory' + if chain and ran_out(chain[0]): + return 'disk_full' for item in chain: cuda_init = any(frame.f_globals.get('__name__') == 'torch.cuda' and frame.f_code.co_name in ('_lazy_init', '_check_driver') diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index 6a0dcb6f..1f70c921 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -606,8 +606,10 @@ def wait_queued(self): except FailureError as error: self.discard_queued(error.status) except Exception as error: - # A previously valid reference may disappear while waiting. - code = ('generation_failed' if str(error) in INVALID_ERRORS + # A previously valid reference may disappear while waiting; that keeps its + # own reason. Other settings were already checked, so a change there is a fault. + code = (str(error) if str(error) == 'invalid_media' + else 'generation_failed' if str(error) in INVALID_ERRORS else classify_exception(error)) self.discard_queued(self.failed(code, pending['context'], error=error)) else: diff --git a/web/image_generation.js b/web/image_generation.js index bc720902..875a63e6 100644 --- a/web/image_generation.js +++ b/web/image_generation.js @@ -164,6 +164,7 @@ export function imageGenError(code, info, t) { device_unavailable: ['The graphics card is not available right now. Try again later.', '显卡暂时用不了,稍后再试。'], worker_exit: ['Image generation stopped unexpectedly. Try again.', '图像生成意外停止了,请再试一次。'], timeout: ['This image took too long and was stopped. Try again.', '这一张用时太长,已停止。请再试一次。'], + disk_full: ['Saving the image failed: the disk is full. Free some space, then generate again.', '保存图片时出错:磁盘空间不够,空出空间后再生成。'], cancelled: ['Image generation was cancelled.', '已取消图像生成。'], invalid_request: ['The image settings are invalid. Adjust them and try again.', '图片设置有误,请调整后重试。'], invalid_media: ['The reference image cannot be read. Choose another image.', '参考图读不出来,请换一张图。'], diff --git a/web/library.js b/web/library.js index 159d37d7..495455b4 100644 --- a/web/library.js +++ b/web/library.js @@ -147,6 +147,9 @@ async function workflowBackfill(collection, t) { ? t('Drag them onto the canvas to restore their workflow. The originals are kept next to them as video.before-workflow.mp4.', '把它们拖到画布上可还原工作流。原视频保留在各自目录,文件名为 video.before-workflow.mp4。') : t('Videos that could not be checked safely were left as they were.', '无法安全确认的视频都保持原样。'); if (result.failed) text.textContent += ' ' + t(`${result.failed} videos were left unchanged and will be offered again.`, `另有 ${result.failed} 个视频未改动,之后会再次提示。`); + const full = Math.min(Number.isSafeInteger(result.no_space) ? result.no_space : 0, result.failed || 0); + if (full > 0) text.textContent += ' ' + t(full === result.failed ? 'The disk is full. Free some space first.' : `${full} of them could not be changed because the disk is full. Free some space first.`, + full === result.failed ? '磁盘空间不够,请先空出空间。' : `其中 ${full} 个因磁盘空间不够,请先空出空间。`); actions.replaceChildren(button(t('Got it', '知道了'), () => box.remove(), 'fv-quiet')); } if (value.state === 'running') { follow(); return; } diff --git a/web/share.js b/web/share.js index 7b66491a..100a2df4 100644 --- a/web/share.js +++ b/web/share.js @@ -159,6 +159,7 @@ export async function openShare(record,t){ const response=await api.fetchApi('/freevideo/share/video',{method:'POST',headers:{'Content-Type':'application/json'},signal:exportRequest.signal, body:JSON.stringify({id,template,rect:shape.rect})}); if(response.status===409)throw Object.assign(new Error(t('Another export is running. Try again shortly.','已有分享视频正在导出,请稍后再试。')),{reportable:false}); + if(response.status===507)throw Object.assign(new Error(t('Exporting the share video failed: the disk is full. Free some space, then export again.','导出分享视频时出错:磁盘空间不够,空出空间后再导出。')),{reportable:false}); if(!response.ok)throw new Error(t('Export could not complete. Try again.','分享视频导出未完成,请重试。')); exported=await response.json(); } From 0bf3bc1d19ce6c9b19683ca2f3aea1ce2fc40e12 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 19:32:02 +0000 Subject: [PATCH 29/58] Show the engine's English failure reason on Autopilot cards --- freevideo_engine/agent/cast.py | 4 +- freevideo_engine/agent/errors.py | 77 +++++++++++++++++++++++++-- freevideo_engine/agent/jobs.py | 10 ++-- freevideo_engine/agent/productions.py | 8 +-- freevideo_engine/agent/routes.py | 2 + freevideo_engine/agent/schema.py | 4 +- web/agent_activity.js | 2 +- web/autopilot_chat.js | 6 ++- 8 files changed, 92 insertions(+), 21 deletions(-) diff --git a/freevideo_engine/agent/cast.py b/freevideo_engine/agent/cast.py index 8352d5c3..1e7d4a2e 100644 --- a/freevideo_engine/agent/cast.py +++ b/freevideo_engine/agent/cast.py @@ -807,7 +807,7 @@ def _land_image(self, identity, generating, hidden, rows): code = (row.get('error') or {}).get('code') if row else 'not_found' code = code or (row['status'] if row else 'not_found') detail = errors.image_failure(code, (row.get('error') or {}).get('report_id') if row else None) - failure = failure or dict(code=code, job=job, error_zh=detail['reason']['zh'], message=( + failure = failure or dict(code=code, job=job, error_zh=detail['reason']['zh'], error_en=detail['reason']['en'], message=( 'The generation was cancelled.' if row and row['status'] == 'cancelled' else detail['reason']['en']), **detail) continue @@ -843,7 +843,7 @@ def _land(self, identity, generating, hidden, rows): if row is None or row['status'] != 'succeeded': code = (row.get('error') or {}).get('code') if row else 'not_found' failure = failure or dict(code=code or row['status'], job=job, - error_zh=errors.failure_reason((row.get('error') or dict(code=code or row['status'])) if row else dict(code=code)), message=( + **errors.reasons((row.get('error') or dict(code=code or row['status'])) if row else dict(code=code)), message=( 'The generation was cancelled.' if row and row['status'] == 'cancelled' else 'The reference clip did not finish.')) continue diff --git a/freevideo_engine/agent/errors.py b/freevideo_engine/agent/errors.py index 77c55cbb..7e073c54 100644 --- a/freevideo_engine/agent/errors.py +++ b/freevideo_engine/agent/errors.py @@ -306,6 +306,53 @@ def safe_details(value): 'generation_failed': '生成没有完成', 'internal_error': '没有完成', } +FAILURE_REASONS_EN = { + 'lan_unavailable': 'Phone access requires FreeVideo installed with the launcher', + 'out_of_memory': 'Graphics or system memory ran out during generation', + 'insufficient_memory': 'There is not enough free graphics or system memory for this generation', + 'no_memory': 'There is not enough free graphics or system memory for this picture', + 'ram_space': 'System memory ran out while loading the model', + 'model_missing': 'A required model is not installed yet', + 'pack_missing': 'The image model is not installed yet', + 'not_ready': 'The generation environment is not installed yet', + 'cancelled': 'Generation was stopped', + 'stopped_by_user': 'Generation was stopped', + 'interrupted': 'Generation was interrupted', + 'worker_exit': 'Picture generation was interrupted', + 'closed': 'The picture generation service has closed', + 'image_unavailable': 'The picture generation service has not started yet', + 'vlm_unavailable': 'The model that reads pictures has not started yet', + 'vlm_failed': 'The picture could not be read', + 'vlm_invalid_json': 'The reply could not be read', + 'context_length': 'The description and reference pictures are too long', + 'dependency_failed': 'A task this one depends on did not finish', + 'lost': 'The generation service no longer has the result of this task', + 'unknown_job': 'The picture generation service cannot find this task', + 'not_found': 'This task or media file cannot be found', + 'source_missing': 'The previous video needed to continue is no longer available', + 'chain_missing': 'The generation record needed to continue is no longer available', + 'chain_unavailable': 'The part-by-part generation service has not started yet', + 'continuation_unavailable': 'The current generation mode cannot continue a video', + 'join_failed': 'The parts were generated, but joining them into one video failed', + 'invalid_output': 'The result could not be read or saved', + 'storage_unavailable': 'The task record or media could not be written to disk', + 'submit_interrupted': 'The service stopped during submission, so it is not confirmed whether the task started', + 'timeout': 'Generation took too long without a result', + 'invalid_media': 'The reference media could not be read', + 'clip_unreadable': 'The generated character reference video could not be read', + 'invalid_request': 'The generation settings are outside the supported range', + 'invalid_drama': 'The short film settings are invalid', + 'reshoot_limit': 'This shot has reached its generation limit', + 'production_limit': 'This short film has reached its task limit', + 'export_failed': 'Exporting the video failed', + 'invalid_prompt': 'The picture description is empty or too long', + 'invalid_dimensions': 'The picture size is outside the supported range', + 'too_many_images': 'There are more reference pictures than supported', + 'chain_too_long': 'The continued video has more parts than supported', + 'chain_rejected': 'The generation service cannot use these settings', + 'generation_failed': 'Generation did not finish', + 'internal_error': 'Did not finish', +} FAILURE_MESSAGES_ZH = { 'The previous video is too short to continue; it needs to be at least 1.7 s long.': '接续的上一段视频太短,至少需要 1.7 秒', 'The previous video has no usable continuation data. Generate it again with this version of FreeVideo.': '上一段视频缺少可用的接续数据', @@ -316,14 +363,18 @@ def safe_details(value): } -def failure_reason(error): - if isinstance(error.get('error_zh'), str) and error['error_zh'].strip(): - return error['error_zh'] +def _failure_line(error): lines = str(error.get('message') or '').strip().splitlines() line = lines[0] if lines else '' if line.startswith('FreeVideo generation failed') and len(lines) > 1: line = lines[1].strip() - line = re.sub(r'^(?:[\w.]*Error|[\w.]*Exception):\s*', '', line) + return re.sub(r'^(?:[\w.]*Error|[\w.]*Exception):\s*', '', line) + + +def failure_reason(error): + if isinstance(error.get('error_zh'), str) and error['error_zh'].strip(): + return error['error_zh'] + line = _failure_line(error) if line in FAILURE_MESSAGES_ZH: return FAILURE_MESSAGES_ZH[line] if line.startswith(('CUDA out of memory', 'MPS backend out of memory')): @@ -331,8 +382,24 @@ def failure_reason(error): return FAILURE_REASONS_ZH.get(error.get('code'), '没有完成') +# The same lookup in English for the page: the specific sentences are already written for people. +def failure_reason_en(error): + if isinstance(error.get('error_en'), str) and error['error_en'].strip(): + return error['error_en'] + line = _failure_line(error) + if line in FAILURE_MESSAGES_ZH: + return line + if line.startswith(('CUDA out of memory', 'MPS backend out of memory')): + return FAILURE_REASONS_EN['out_of_memory'] + return FAILURE_REASONS_EN.get(error.get('code'), 'Did not finish') + + +def reasons(error): + return dict(error_zh=failure_reason(error), error_en=failure_reason_en(error)) + + def with_reason(error): - return dict(error, error_zh=failure_reason(error)) if error else error + return dict(error, **reasons(error)) if error else error def build(code, *, details=None, request=None, validation=False, message=None, next_action=None, diff --git a/freevideo_engine/agent/jobs.py b/freevideo_engine/agent/jobs.py index 8328b1c6..aad1a92e 100644 --- a/freevideo_engine/agent/jobs.py +++ b/freevideo_engine/agent/jobs.py @@ -633,16 +633,16 @@ def finish(self, row, status, *, result=None, error=None, save=True): report_id = error.get('report_id') if code == 'invalid_json' and row['kind'] in ('vlm', 'enhance'): code = 'vlm_invalid_json' - reason_zh = errors.failure_reason(dict(error, code=code)) + reason_zh, reason_en = errors.failure_reason(dict(error, code=code)), errors.failure_reason_en(dict(error, code=code)) if code != error.get('code') or not error.get('request_id') or row['kind'] == 'image': error = errors.build(code, details=error.get('details'), request=row.get('request_id'), action_required=code != 'cancelled', kind=row['kind'], stage='terminal', resumable=not row['spec'].get('refine_from'), report_id=error.get('report_id')) if row['kind'] == 'image': - reason_zh = error['error_zh'] + reason_zh, reason_en = error['error_zh'], error['error_en'] if error is not None: - error = dict(error, request_id=row.get('request_id') or error['request_id'], error_zh=reason_zh) + error = dict(error, request_id=row.get('request_id') or error['request_id'], error_zh=reason_zh, error_en=reason_en) if row['kind'] != 'image' and isinstance(report_id, str) and re.fullmatch(r'[a-f0-9]{32}', report_id): error['report_id'] = report_id if error.get('next'): @@ -1123,7 +1123,7 @@ def view(self, row, *, detail=True): details=result['error'].get('details'), request=row.get('request_id'), action_required=result['error']['code'] != 'cancelled', report_id=row['error'].get('report_id')) else: - result['error'] = dict(result['error'], error_zh=errors.failure_reason(row['error'])) + result['error'] = dict(result['error'], **errors.reasons(row['error'])) report_id = row['error'].get('report_id') if isinstance(report_id, str) and re.fullmatch(r'[a-f0-9]{32}', report_id): result['error']['report_id'] = report_id @@ -1808,7 +1808,7 @@ def generation_failure(self, row, chain): next_action = 'Use the alternatives in details to lower size or duration, then POST /freevideo/agent/v1/jobs.' failure = dict(chain_error(code, errors.ERRORS[code][2], next_action, details=[detail], retryable=code != 'insufficient_memory').view(), - error_zh=errors.failure_reason(dict(code=code, message=message))) + **errors.reasons(dict(code=code, message=message))) if isinstance(report_id, str) and re.fullmatch(r'[a-f0-9]{32}', report_id): failure['report_id'] = report_id return failure diff --git a/freevideo_engine/agent/productions.py b/freevideo_engine/agent/productions.py index fcfb811e..416d76da 100644 --- a/freevideo_engine/agent/productions.py +++ b/freevideo_engine/agent/productions.py @@ -145,7 +145,7 @@ def _safe_error(error): rows = [_safe_row(row) for row in details] if isinstance(details, list) and details else [] if rows and all(rows): result.update(details=rows, message='; '.join(row['message'] for row in rows)) - return dict(result, error_zh=errors.failure_reason(error)) + return dict(result, **errors.reasons(error)) def failure(view): @@ -628,7 +628,7 @@ def contextual(error, join=False): value['progress'] = self._progress(record) value['over_budget'] = self._elapsed_budget(value['progress']) error, _ = failure(value) - value['error_zh'] = errors.failure_reason(error) if error else None + value['error_zh'], value['error_en'] = (errors.failure_reason(error), errors.failure_reason_en(error)) if error else (None, None) return value def get(self, identity, *, since=None, wait=0, compact=True): @@ -654,7 +654,7 @@ def _reply(self, identity, since, compact): join = next((j for j in state['joins'] if j['ref'] == state['current_join']), None) error, _ = failure(dict(stalled=record.get('stalled'), join=join, shots=state['shots'])) if error: - value['error_zh'] = errors.failure_reason(error) + value.update(errors.reasons(error)) return value return dict(self._view(record), changed=changed) @@ -704,7 +704,7 @@ def list(self, *, status='all', limit=50): shot = next((s for s in view['shots'] if s['chosen'] is None), view['shots'][-1]) result.append(dict(id=view['id'], title=view['title'], status=view['status'], revision=view['revision'], shot=shot['index'], shots=len(view['shots']), next=view['todo'][0] if view['todo'] else None, - paused=view['paused'], stalled=view['stalled'], session=record.get('session'), error_zh=view['error_zh'])) + paused=view['paused'], stalled=view['stalled'], session=record.get('session'), error_zh=view['error_zh'], error_en=view['error_en'])) return result def _mutate(self, action, identity, *, by, ref=None, client=None, **values): diff --git a/freevideo_engine/agent/routes.py b/freevideo_engine/agent/routes.py index 2947198d..f0da626c 100644 --- a/freevideo_engine/agent/routes.py +++ b/freevideo_engine/agent/routes.py @@ -1049,6 +1049,7 @@ def write(): if done is None: # Nothing is kept: no agent has connected since FreeVideo started, so nobody would read it. return reply({'error': dict(code='not_connected', message='No agent is connected.', retryable=True, + error_zh='尚未连接 Agent', error_en='No agent is connected.', details=[], next='Connect an agent in the Autopilot view, then say it again.', request_id=errors.request_id())}, 409) return reply(done[1], done[0]) @@ -1132,6 +1133,7 @@ def write(): done = await asyncio.to_thread(write) if done is None: return reply({'error': dict(code='not_connected', message='No agent is connected.', retryable=True, + error_zh='尚未连接 Agent', error_en='No agent is connected.', details=[], next='Connect an agent in the Autopilot view, then try again.', request_id=errors.request_id())}, 409) return reply(done[1], done[0]) diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py index 8ef43f48..31a41fbc 100644 --- a/freevideo_engine/agent/schema.py +++ b/freevideo_engine/agent/schema.py @@ -584,7 +584,7 @@ def cast_view(): description=nullable(string(1000)), revision=number(1, integer=True), images=array(picture, 0, 6), candidates=array(picture, 0, 6), generating=nullable(generating), generated=nullable(generated), - generate_error=nullable(obj(dict(code=string(), job=JOB, message=string(), error_zh=string()), ('code', 'job', 'message')))), + generate_error=nullable(obj(dict(code=string(), job=JOB, message=string(), error_zh=string(), error_en=string()), ('code', 'job', 'message')))), ('id', 'kind', 'name', 'revision', 'images', 'candidates', 'generating')), additionalProperties=True) @@ -1439,7 +1439,7 @@ def openapi(): 'description': 'Image diagnostic report ID, or null when no report exists.'}, request_id=string(), docs=string(), retry_after_seconds=number(0), card={'type': 'object'}, job=JOB, project={'type': 'object'}, current_revision=number(1, integer=True), - error_zh=string()), + error_zh=string(), error_en=string()), ('code', 'message', 'details', 'retryable', 'request_id', 'docs'))})) diff --git a/web/agent_activity.js b/web/agent_activity.js index 37c75079..4cd4dd3f 100644 --- a/web/agent_activity.js +++ b/web/agent_activity.js @@ -77,7 +77,7 @@ function row(job, t, api, refresh, open, cancellationErrors) { if (job.status === 'running' && p.remaining_seconds > 0) meta.push(left(p.remaining_seconds, t)); const reason = job.status === 'failed' && REASONS[job.error?.code]; if (reason) meta.push(t(...reason)); - else if (job.status === 'failed') meta.push(t(job.error?.message || 'Did not finish', job.error?.error_zh || '没有完成')); + else if (job.status === 'failed') meta.push(t(job.error?.error_en || 'Did not finish', job.error?.error_zh || '没有完成')); if (job.project) meta.push(job.project); const foot = el('div', 'fv-agent-foot'); const details = el('p', 'fv-agent-meta', meta.join(' · ')); diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index 30ccd53c..06569fa4 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -51,10 +51,12 @@ const WHITEBOX = { const GENERIC_FAILURE_ZH = '没有完成'; // Sends and failure cards use the same public reason, including the engine's Chinese mapping. function failureReason(value, t) { - const error = value.error, reason = error?.error_zh || value.error_zh; + const error = value.error, reason = error?.error_zh || value.error_zh, reasonEn = error?.error_en || value.error_en; if (error?.code === 'session_full' && (!reason || reason === GENERIC_FAILURE_ZH)) return t('This chat is full. Click "New chat".', '这个对话已满,请点“新对话”。'); - return t(error?.message || 'Did not finish', reason || GENERIC_FAILURE_ZH); + // `message` is written for programs; people read the engine's own English reason. A refusal the engine + // did not classify keeps its sentence, as before. + return t(reasonEn || error?.message || 'Did not finish', reason || GENERIC_FAILURE_ZH); } export {about}; From 1e5e68bb73ca2647cac8e04c13e6bcb3a013e212 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 19:32:03 +0000 Subject: [PATCH 30/58] Unmatched launcher failures give a next step, and path, installer memory and report-form details... Unmatched launcher failures give a next step, and path, installer memory and report-form details are covered --- freevideo_engine/bootstrap.py | 10 ++++-- freevideo_engine/comfy_launcher.py | 5 +-- freevideo_engine/failure_details.py | 50 +++++++++++++++++++++++++++-- freevideo_engine/launcher_issue.py | 44 +++++++++++++++++++++++-- freevideo_engine/modern_launcher.py | 9 ++++-- 5 files changed, 107 insertions(+), 11 deletions(-) diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index 478977c0..a4e31391 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -140,8 +140,14 @@ def setup_memory_failure(pressure): detail = 'Installation paused: system RAM is nearly exhausted (%.2f GiB available; %.2f GiB minimum).' % ( pressure['available_bytes'] / GiB, pressure['emergency_floor_bytes'] / GiB) else: - detail = 'Installation crossed its RAM budget (%.2f GiB used; %.2f GiB budget; %s).' % ( - pressure['working_bytes'] / GiB, pressure['budget_bytes'] / GiB, pressure['guard_metric']) + # The budget is the memory free at planning minus the reserve, and setup itself + # peaked near 1 GiB; so the free memory needed is the reserve plus that, rounded up to + # the next half GiB above it, which leaves some room. + needed = (pressure.get('planning_reserve_bytes') or 0) + max(pressure['working_bytes'], GiB) + detail = ('Installation crossed its RAM budget (%.2f GiB used; %.2f GiB budget; %s). ' + 'At least %.1f GiB of RAM must be free when it starts.') % ( + pressure['working_bytes'] / GiB, pressure['budget_bytes'] / GiB, pressure['guard_metric'], + (needed * 2 // GiB + 1) / 2) return detail + ' Downloaded files are retained.' diff --git a/freevideo_engine/comfy_launcher.py b/freevideo_engine/comfy_launcher.py index d3111c38..439615b8 100644 --- a/freevideo_engine/comfy_launcher.py +++ b/freevideo_engine/comfy_launcher.py @@ -694,9 +694,10 @@ def copy_error(self): def report_error(self): if self.error_text: - from .launcher_issue import issue_url + from .launcher_issue import environment_line, issue_url, product_version from .windows_ux import open_browser - open_browser(issue_url(self.error_text, __version__, None, zh=self.zh)) + version = product_version(__version__) + open_browser(issue_url(self.error_text, version, None, zh=self.zh, environment=environment_line(version))) def show_details(self): dialog = self.tk.Toplevel(self.window); dialog.title(self.t('FreeVideo · Details', 'FreeVideo · 详情')) diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index faa352a8..3de1fb81 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -567,6 +567,10 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep ('A video is still generating', '还有视频正在生成'), ('The update is installed. Click Connect again after the job finishes to restart ComfyUI with it.', '更新已装好。等当前任务完成后点“重新连接”,ComfyUI 会重启并使用新版。')), + (r'WinError 206|The filename or extension is too long', 'path-too-long', + ("The installation folder's path is too long", '安装文件夹的路径太长'), + ('Install FreeVideo in a folder with a shorter path, such as D:\\FreeVideo, then click "Retry installation".', + '请把 FreeVideo 安装到路径较短的文件夹,例如 D:\\FreeVideo,然后点击“重试安装”。')), (r'Installation paused: Windows commit headroom', 'memory-commit', ('Windows is low on available memory', 'Windows 可用内存不足'), ('Close memory-heavy applications, then retry. Downloaded files are retained.', @@ -618,6 +622,18 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep ('Memory is unavailable for this step', '这一步可用内存不足'), ('Close other memory-heavy applications and retry. Copy the details if it continues.', '关闭占用内存较多的程序后重试;仍失败时可复制详情反馈。')), ) + if re.search(r'Installation crossed its RAM budget', text): + # Only the installer's memory monitor says this. The sentence carries the amount; an + # older retained error does not, and then the card names none. + free = re.search(r'At least ([\d.]+) GiB of RAM must be free', text) + if free: + action = ('Close other applications until at least %s GiB of RAM is free, then click "Retry installation". Downloaded files are retained.' % free.group(1), + '请关闭其他程序,直到至少有 %s GiB 内存空闲,然后点击“重试安装”。已下载的文件会保留。' % free.group(1)) + else: + action = ('Close other applications to free memory, then click "Retry installation". Downloaded files are retained.', + '请关闭其他程序以空出内存,然后点击“重试安装”。已下载的文件会保留。') + return dict(title=('There is not enough free memory to continue installing', '可用内存不足,无法继续安装')[zh], + detail='', action=action[zh], kind='memory-budget') for pattern, kind, title, action in rules: if re.search(pattern, text, re.I): if kind in WITHOUT_REPAIR and not can_repair: @@ -631,8 +647,38 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep if not line.startswith(('File ', 'Traceback', '^', 'All files retained.', 'Rerun the same setup command'))), '') detail = re.sub(r'^(?:ValueError|RuntimeError|OSError):\s*', '', detail) from .launcher_copy import lan_error - return dict(title='暂时未能完成' if zh else 'Couldn’t complete this step', - detail=lan_error(detail, zh), action='', kind='unknown') + title = '暂时未能完成' if zh else 'Couldn’t complete this step' + # The cause line is the last error line, which a traceback or pip's "ERROR:" can displace; the + # failed step's own line is found anywhere in the text. + step = re.search(r'^Failed step: ([\w.-]+) \(exit code (-?\w+)\)', text, re.M) + if step: + # The internal step id never reaches the card; the exit code stays as the detail. + name = _step_name(step.group(1), zh) + if name: + title = ('“%s”这一步没有完成' if zh else 'The step "%s" did not finish') % name + detail = ('退出码 %s' if zh else 'Exit code %s') % step.group(2) + # A message the launcher raised itself (a ValueError) is the next step already. + unexpected = 'Traceback' in text and not re.match(r'(?:[\w.]+\.)?ValueError:', causes[-1] if causes else '') + detail = lan_error(detail, zh) + if step or unexpected: + # The launcher's failure card shows the action only when there is no detail, so the next + # step goes under the detail rather than being hidden by it. + return dict(title=title, detail=(detail + '\n' if detail else '') + UNKNOWN_NEXT_STEP[zh], action='', kind='unknown') + return dict(title=title, detail=detail, action='', kind='unknown') + + +UNKNOWN_NEXT_STEP = ('Try again. If it fails again, export the report, then use "Report a problem" and attach it.', + '请重试。仍然失败时,请先导出报告,再点“提交问题”并附上报告。') + + +def _step_name(label, zh): + """The name the progress card showed for a setup step, in the interface language, or ''.""" + try: + from .bootstrap import setup_task + from .launcher_copy import display + return display(setup_task(label)[0], zh) + except Exception: + return '' def _read(path, limit, *, tail=False): diff --git a/freevideo_engine/launcher_issue.py b/freevideo_engine/launcher_issue.py index f0e393ed..222f7f79 100644 --- a/freevideo_engine/launcher_issue.py +++ b/freevideo_engine/launcher_issue.py @@ -18,15 +18,55 @@ def _lines(error): return [line for line in scrub(error).splitlines() if line.strip()] -def issue_url(error, version=None, title=None, zh=False): +def product_version(fallback=None): + """The 0.4.x release of this launcher; the fallback (the build number) only when no release says.""" + try: + from pathlib import Path + from .release_notes import installed_details + return installed_details(Path(__file__).parent, fallback).get('product_version') or fallback + except Exception: + return fallback + + +def machine_hardware(engine): + """The hardware record of the installation at engine (machine.json), or {} when it is not readable.""" + try: + import json + from pathlib import Path + from .long_paths import io_path + hardware = json.loads(io_path(Path(engine) / 'machine.json').read_text(encoding='utf-8'))['hardware'] + return hardware if isinstance(hardware, dict) else {} + except Exception: + return {} + + +def environment_line(version=None, hardware=None): + """One line about this computer: release, system, GPU, driver and memory. No paths, user names or build numbers.""" + hardware = hardware if isinstance(hardware, dict) else {} + + def gib(key): + value = hardware.get(key) + return '%.1f GiB' % (value / 2**30) if type(value) is int and value > 0 else '' + + gpu = ', '.join(part for part in (str(hardware.get('gpu_name') or ''), gib('vram_total') + ' VRAM' if gib('vram_total') else '') if part) + parts = ['FreeVideo ' + str(version).lstrip('v') if version else '', str(hardware.get('system') or ''), gpu, + 'driver ' + str(hardware['driver_version']) if hardware.get('driver_version') else '', + gib('ram_total') + ' RAM' if gib('ram_total') else ''] + return scrub(' · '.join(part for part in parts if part))[:300] + + +def issue_url(error, version=None, title=None, zh=False, environment=''): lines = _lines(error) first = (lines[-1] if lines and lines[0].startswith('Traceback') else (lines[0] if lines else '')).strip() place = '启动器' if zh else 'Launcher' problem = ('FreeVideo 启动器显示了一个错误。\n' if zh else 'The FreeVideo launcher showed an error.\n') + first[:300] report = '\n'.join(lines) + # The environment line is for triage, so it stays English in either language and is kept whole; + # only the error text gives way to fit the address. + head = (('Environment: ' + scrub(environment).strip() + '\n\n') if str(environment).strip() else '') def fit(limit): - return report if len(report) <= limit else report[:limit // 3] + '\n...\n' + report[-(limit - limit // 3 - 5):] + return head + (report if len(report) <= limit else report[:limit // 3] + '\n...\n' + report[-(limit - limit // 3 - 5):]) def build(body): params = {'template': 'bug_launcher.yml', 'title': f'[{place}] ' + (str(title).strip() if title else place), diff --git a/freevideo_engine/modern_launcher.py b/freevideo_engine/modern_launcher.py index 744bf27c..14ce0bdf 100644 --- a/freevideo_engine/modern_launcher.py +++ b/freevideo_engine/modern_launcher.py @@ -294,10 +294,13 @@ def link(self, value): @Slot(str, str) def reportProblem(self, error, title): from . import __version__ - from .launcher_issue import issue_url + from .launcher_issue import environment_line, issue_url, machine_hardware, product_version from .windows_ux import open_browser - url = issue_url(error, session.installed_product_version() or __version__, title, - zh=session.language.startswith('zh')) + # The release (0.4.x) is named, not the build number; only fields that carry no path or user name travel. + version = session.installed_product_version() or product_version(__version__) + engine = session.installation_engine() + url = issue_url(error, version, title, zh=session.language.startswith('zh'), + environment=environment_line(version, machine_hardware(engine) if engine else {})) self.invoke(open_browser, url) @Slot() From 839d4cca2375301b1dbf6d33341fa94a6bba6398 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 19:32:03 +0000 Subject: [PATCH 31/58] A too-old NVIDIA driver gets its own notice with the installed version --- freevideo_engine/bootstrap.py | 10 ++++++++-- freevideo_engine/failure_details.py | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index a4e31391..4c8134a3 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -532,8 +532,14 @@ def plan(args, *, local_progress=None, stage=None): layout = 'unified' if windows_target and getattr(args, 'rebuild_sage', False): errors.append('Windows uses the Sage2 wheel FreeVideo tests; --rebuild-sage is a Linux source-build option.') - if int(snapshot['selected_gpu']['driver_version'].split('.')[0]) < 580: - errors.append('NVIDIA driver 580 or newer is required by the CUDA 13 encoder FreeVideo tests; update the driver first.') + driver = str(snapshot['selected_gpu']['driver_version']).strip() + # An unreadable version is left to the later checks instead of blocking setup. + major = re.match(r'\d+', driver) + if major and int(major.group()) < 580: + # The card names the graphics card too; brackets would end its pattern early. + model = re.sub(r'[()\r\n]', '', str(snapshot['selected_gpu'].get('name') or '')).strip() + errors.append('NVIDIA driver %s%s is older than 580, the minimum for the CUDA 13 encoder; update the driver first.' + % (driver, ' (%s)' % model if model else '')) compatibility = hardware.cuda_compatibility() if compatibility['error']: errors.append(compatibility['error']) diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index 3de1fb81..04b742c3 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -543,6 +543,10 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep ('If this computer has an NVIDIA graphics card, install NVIDIA driver 580 or newer, ' 'restart the computer, then click Retry installation.', '如果这台电脑有 NVIDIA 显卡,请安装 580 或更新版本的 NVIDIA 显卡驱动,重启电脑后点击“重试安装”。')), + (r'NVIDIA driver [\w.]+(?: \([^)]*\))? is older than 580', 'gpu-driver-old', + ('The NVIDIA driver is too old', 'NVIDIA 显卡驱动版本过低'), + ('Update the NVIDIA driver to version 580 or newer, restart the computer, then click "Retry installation".', + '请将 NVIDIA 显卡驱动更新到 580 或更新版本,重启电脑后点击“重试安装”。')), (r'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.', @@ -638,7 +642,17 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep if re.search(pattern, text, re.I): if kind in WITHOUT_REPAIR and not can_repair: action = WITHOUT_REPAIR[kind].get(unavailable) or WITHOUT_REPAIR[kind].get('none', WITHOUT_REPAIR_NONE) - return dict(title=title[zh], detail='', action=action[zh], kind=kind) + detail = '' + if kind == 'gpu-driver-old': + version, model = re.search(r'NVIDIA driver ([\w.]+)(?: \(([^)]*)\))? is older than 580', text, re.I).groups() + if model: + detail = ('检测到:%s,驱动 %s' if zh else 'Detected: %s, driver %s') % (model, version) + else: + detail = ('当前驱动版本:' if zh else 'Installed driver: ') + version + # The launcher's failure card shows the action only when there is no detail, so the + # instruction goes under the detected driver instead of being hidden by it. + return dict(title=title[zh], detail=detail + '\n' + action[zh], action='', kind=kind) + return dict(title=title[zh], detail=detail, action=action[zh], kind=kind) # Validation messages are already actionable. Show the cause, not a stack # trace or a guessed hardware diagnosis, and leave long details expandable. lines = [line.strip() for line in text.splitlines() if line.strip()] From 2309bc6a446f4672d2c533c8415698d2bdcb8cf4 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 19:32:41 +0000 Subject: [PATCH 32/58] Name the reference-image waits: upload and read stages in image generation, a spinner on the... Name the reference-image waits: upload and read stages in image generation, a spinner on the editor reference plus, and upload/queued/processing in the library --- freevideo_engine/comfy_editor.py | 3 ++- freevideo_engine/image_gen/qwen_pipeline.py | 6 +++--- web/editor.css | 4 ++++ web/editor_library.js | 10 +++++++--- web/editor_references.js | 9 +++++---- web/image_generation.js | 8 ++++++-- 6 files changed, 27 insertions(+), 13 deletions(-) diff --git a/freevideo_engine/comfy_editor.py b/freevideo_engine/comfy_editor.py index 29d36e54..55287b00 100644 --- a/freevideo_engine/comfy_editor.py +++ b/freevideo_engine/comfy_editor.py @@ -844,6 +844,7 @@ def list_imports(root): fields = ('id', 'name', 'kind', 'state', 'fraction', 'reason', 'frames', 'fps', 'width', 'height', 'preview_width', 'preview_height', 'has_audio') row = {k: value.get(k) for k in fields} + row['started'] = bool(value.get('started')) for side in ('width', 'height'): row['preview_' + side] = value.get('preview_' + side, value.get(side)) row['preview'] = '/freevideo/editor/media?' + urlencode(dict(id=child.name, file='preview')) if value['state'] == 'ready' else None @@ -929,7 +930,7 @@ def transcode(root, identity): if vin is None and ain is None: raise ValueError('No media') kind = 'video' if vin is not None else 'audio' - _import_update(root, identity, kind=kind) + _import_update(root, identity, kind=kind, started=True) seconds = max((clip_seconds(reader, s) or 0 for s in (vin, ain) if s is not None), default=0) origin = Fraction(vin.start_time or 0) * vin.time_base if vin is not None else Fraction(ain.start_time or 0) * ain.time_base with av.open(str(partial), 'w', format='mp4' if vin is not None else 'ipod', options={'movflags': 'faststart'}) as writer: diff --git a/freevideo_engine/image_gen/qwen_pipeline.py b/freevideo_engine/image_gen/qwen_pipeline.py index b0eeb89e..475cfa6b 100644 --- a/freevideo_engine/image_gen/qwen_pipeline.py +++ b/freevideo_engine/image_gen/qwen_pipeline.py @@ -306,8 +306,8 @@ def report(stage, fraction): ref_latents = [] if vae_images: with self._stage("encode"): - for image in vae_images: - check_cancel() + for index, image in enumerate(vae_images): + report("encode", index / len(vae_images)) # Always tiled, whatever the memory plan: the latents (and so the picture) # must not depend on the card, and the tiled pass needs far less memory. encoded = _encoded_latents( @@ -315,7 +315,7 @@ def report(stage, fraction): mean, std = self._stats(encoded) encoded = (encoded - mean) / std ref_latents.append(self._pack_latents(encoded, 1, channels, *encoded.shape[-2:]).to(embeds.dtype)) - check_cancel() + report("encode", 1.0) keep_latents = cell_mask = None if edit_mask is not None: from . import region_edit diff --git a/web/editor.css b/web/editor.css index fa1681ae..fbeee9c6 100644 --- a/web/editor.css +++ b/web/editor.css @@ -115,6 +115,10 @@ .fv-ed-ref-chip{border-color:transparent;background:var(--fv-bg)} .fv-ed-ref-add:not(:disabled):not([aria-disabled=true]):hover,.fv-ed-ref-add[aria-expanded=true],.fv-ed-ref-chip:not(:disabled):not([aria-disabled=true]):hover{border-color:var(--fv-accent);color:var(--fv-ink)} .fv-ed-ref-add:disabled,.fv-ed-ref-chip:disabled,.fv-ed-ref-add[aria-disabled=true],.fv-ed-ref-chip[aria-disabled=true]{opacity:.4;cursor:default} +.fv-ed-ref-add[data-uploading=true]{opacity:1;color:transparent} +.fv-ed-ref-add[data-uploading=true]::after{content:'';position:absolute;inset:0;margin:auto;width:12px;height:12px;border:2px solid var(--fv-accent);border-right-color:transparent;border-radius:50%;animation:fv-ref-spin .9s linear infinite} +@keyframes fv-ref-spin{to{transform:rotate(360deg)}} +@media(prefers-reduced-motion:reduce){.fv-ed-ref-add[data-uploading=true]::after{animation:none}} .fv-ed-ref-thumb{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--fv-muted)} .fv-ed-ref-thumb img{display:block;width:100%;height:100%;object-fit:cover} .fv-ed-ref-menu{position:absolute;bottom:calc(100% - 4px);min-width:180px;max-width:260px;padding:6px;background:var(--fv-card);border:1px solid var(--fv-border);border-radius:8px;z-index:12;display:flex;flex-direction:column;font:13px var(--fv-font)} diff --git a/web/editor_library.js b/web/editor_library.js index 17a1526a..261d2ac0 100644 --- a/web/editor_library.js +++ b/web/editor_library.js @@ -80,8 +80,12 @@ export function createEditorLibrary(host, {store, t, actions, state}) { if (ready) picture.append(el('span', 'fv-ed-source-time', stamp(metadata(row, kind).frames))); else if (row.state === 'failed') { picture.append(el('span', 'fv-ed-source-preparing', row.reason === 'disk_full' ? t('Disk full', '磁盘已满') : t('Cannot open', '打不开'))); - } else picture.append(el('span', 'fv-ed-source-preparing', t('Preparing', '准备中') - + (Number.isFinite(row.fraction) ? ` ${Math.round(Math.max(0, Math.min(1, row.fraction)) * 100)}%` : ''))); + } else { + // Sending, then waiting behind another import, then being made; only the first and the last have a percentage. + const percent = Number.isFinite(row.fraction) ? ` ${Math.round(Math.max(0, Math.min(1, row.fraction)) * 100)}%` : ''; + picture.append(el('span', 'fv-ed-source-preparing', row.sending ? t('Uploading', '上传中') + percent + : row.started ? t('Processing', '处理中') + percent : t('Queued', '排队中'))); + } button.append(picture); const used = el('span', 'fv-ed-source-used'); used.append(editorIcon('check')); used.setAttribute('aria-label', t('Used', '已用')); used.hidden = true; picture.append(used); button.ondblclick = () => requestAdd(row, kind); @@ -220,7 +224,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { const place = Number.isSafeInteger(at) && at >= 0 ? {at} : null; for (const value of files) { if (disposed) break; - const id = 'upload-' + Math.random(), row = {id, name: value.name, kind: value.type.startsWith('audio') ? 'audio' : 'video', state: 'preparing', fraction: 0}; + const id = 'upload-' + Math.random(), row = {id, name: value.name, kind: value.type.startsWith('audio') ? 'audio' : 'video', state: 'preparing', fraction: 0, sending: true}; uploading.set(id, row); render(); try { const item = await store.upload(value, fraction => { row.fraction = fraction; render(); }); diff --git a/web/editor_references.js b/web/editor_references.js index 35c00876..1dfc1d6d 100644 --- a/web/editor_references.js +++ b/web/editor_references.js @@ -24,7 +24,7 @@ export function createEditorReferences({t, source, size = () => null, error = () const menu = el('div', 'fv-ed-ref-menu'); menu.hidden = true; menu.setAttribute('role', 'menu'); const picker = el('input'); picker.type = 'file'; picker.accept = 'image/*'; picker.multiple = true; picker.hidden = true; root.append(add, chips, menu, picker); - let shown = false, continuing = false, busy = false, working = false, opener = null, disposed = false, entries = [], waited = false; + let shown = false, continuing = false, busy = false, working = false, uploading = false, opener = null, disposed = false, entries = [], waited = false; const active = () => source.rows().map((row, index) => ({row, index})).filter(({row}) => row.enabled !== false); const isImage = file => IMAGE.test(file || ''); // The pictures this part uses: all of them, or the first reference images of a part that continues @@ -80,7 +80,7 @@ export function createEditorReferences({t, source, size = () => null, error = () }; async function addFiles(files) { if (working) return; - working = true; update(); + working = uploading = true; update(); let skipped = false; try { for (const file of files) { @@ -91,7 +91,7 @@ export function createEditorReferences({t, source, size = () => null, error = () } if (skipped && !disposed) notify(t('Could not read this picture. Please choose another one.', '无法读取这张图片,请换一张。'), ['Editor reference images', '剪辑器参考图']); } catch (value) { error(value); } - finally { working = false; picker.value = ''; update(); } + finally { working = uploading = false; picker.value = ''; update(); } } async function generate() { if (working) return; @@ -151,8 +151,9 @@ export function createEditorReferences({t, source, size = () => null, error = () const isFull = full(); // While a part is being made the controls stay clickable and say why they do nothing. add.disabled = working; + add.dataset.uploading = String(uploading); add.setAttribute('aria-disabled', String(busy || isFull)); - add.title = busy ? waitText() : isFull ? fullText() : t('Reference image', '参考图'); + add.title = uploading ? t('Uploading reference image…', '正在上传参考图…') : busy ? waitText() : isFull ? fullText() : t('Reference image', '参考图'); chips.replaceChildren(...used().map(entry => { const chip = el('button', 'fv-ed-ref-chip'); chip.type = 'button'; chip.disabled = working; chip.setAttribute('aria-disabled', String(busy)); chip.dataset.role = entry.row.role || 'reference'; diff --git a/web/image_generation.js b/web/image_generation.js index 875a63e6..c2dd08bb 100644 --- a/web/image_generation.js +++ b/web/image_generation.js @@ -463,11 +463,13 @@ export function createImageGenSession({client, prepare, apply, onChange = () => async function start(request) { if (state.running || disposed || !request.prompt.trim() || !state.info?.installed) return; const current = ++epoch; - emit({running: true, job: null, fraction: 0, stage: '', error: '', report_id: null, waiting: '', fitted: null}); + // A picture that is edited sends its reference images first; there is no job to report on yet. + emit({running: true, job: null, fraction: 0, stage: request.mode === 'edit' ? 'upload' : '', error: '', report_id: null, waiting: '', fitted: null}); const alive = () => current === epoch && !disposed; try { const {context, images = [], mask} = await prepare(request, alive); if (!alive()) return; + if (state.stage === 'upload') emit({stage: ''}); const withImages = images.length > 0; const row = await client.generate({prompt: request.prompt, width: context.width, height: context.height, images, mask}); const {job} = row; @@ -736,7 +738,9 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection : state.running && state.waiting ? t(...(QUEUE_LABELS[state.waiting] || QUEUE_LABELS.image)) : state.running && state.fitted ? t(...memoryWords([`Not enough GPU memory. Retrying at ${state.fitted.width}\u00a0×\u00a0${state.fitted.height}`, `显存不足,改用 ${state.fitted.width}\u00a0×\u00a0${state.fitted.height} 重试`], state.info?.unified_memory)) + ' · ' + percent - : state.running ? (state.stage === 'load' ? t('Loading the model', '加载模型') + ' ' : '') + percent + : state.running && state.stage === 'upload' ? t('Uploading reference images', '正在上传参考图') + : state.running ? (state.stage === 'load' ? t('Loading the model', '加载模型') + ' ' + : state.stage === 'encode' ? t('Reading reference images', '读取参考图') + ' ' : '') + percent : state.fitted ? fittedNote(state.fitted, t, state.info?.unified_memory) : ''; } note.hidden = !note.textContent; From 6e5d9b0ca0ba1d2dff77004997f12db7de1a0757 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 19:32:52 +0000 Subject: [PATCH 33/58] Read LoRA factors named without .weight and explain acceleration LoRAs --- freevideo_engine/lora_check.py | 33 +++++++++++++++++++++++---- freevideo_engine/lora_names.py | 26 +++++++++++++++++---- freevideo_engine/lora_online_cache.py | 2 +- web/error_panel.js | 6 ++++- web/lora_check.js | 2 ++ 5 files changed, 57 insertions(+), 12 deletions(-) diff --git a/freevideo_engine/lora_check.py b/freevideo_engine/lora_check.py index c031fac5..05321610 100644 --- a/freevideo_engine/lora_check.py +++ b/freevideo_engine/lora_check.py @@ -11,7 +11,7 @@ import struct import threading -from .lora_names import INT8_UNMERGEABLE, _targets, adapter_keys, int8_skip_reason, resolve_target +from .lora_names import INT8_UNMERGEABLE, _targets, adapter_keys, int8_skip_reason, resolve_target, supported MAX_HEADER_BYTES = 64 * 1024 * 1024 BLOCKS, REFINER_BLOCKS = 50, 2 @@ -58,6 +58,11 @@ def _layout(): LAYOUT = _layout() UNUSABLE = 'This LoRA was not trained for the FreeVideo model and cannot be used.' +ACCELERATION = ('This acceleration LoRA was made for the original model and cannot be used. ' + 'The FreeVideo model is already accelerated and does not need one.') +# Step-distillation adapters (for example parallel-decoding ones) ship a bank of +# whole replacement output heads, one per stretch of the sampling schedule. +HEADS = ('proj_out', 'audio_proj_out') _CACHE, _LOCK = {}, threading.Lock() @@ -115,6 +120,17 @@ def _bad(reason, keys, **detail): return dict(reason=reason, family=family(keys) if reason == 'other-model' else None, **detail) +def _head_bank(shapes): + """The first tensor that is a stack of whole output-head weights, or None.""" + for key, shape in shapes.items(): + if not key.endswith('.weight') or len(shape) != 3: + continue + name = _targets(key[:-7])[0][0] + if name in HEADS and list(shape[1:]) == list(LAYOUT[name]): + return key + return None + + def _block(target): found = re.search(r'blocks\.(\d+)\.', target) return int(found[1]) + 1 if found else None @@ -122,15 +138,22 @@ def _block(target): def assess(shapes, *, int8_targets=None, skipped=None): """None when the adapter fits, else why not: reason 'other-model' (names of another model), 'shape' (our names, - a different size) or 'format' (not a LoRA we can read). 'layer' and the sizes say where. A layer name the table - does not list, in a file that matches no other known model, is let through. + a different size), 'acceleration' (a step-distillation adapter that replaces the output heads and needs its own + sampler) or 'format' (not a LoRA we can read; 'layer' names the first tensor that is not a LoRA factor). + 'layer' and the sizes say where. A layer name the table does not list, in a file that matches no other known + model, is let through. With int8_targets, use the installed linear shapes and collect the runtime's skipped patch categories; 'int8-unmergeable' means every patch was skipped.""" keys = list(shapes) + heads = _head_bank(shapes) + if heads: + return _bad('acceleration', keys, layer=heads) try: pairs, full = adapter_keys(keys) except ValueError: - return _bad('other-model' if family(keys) else 'format', keys, layer=None) + if family(keys): + return _bad('other-model', keys, layer=None) + return _bad('format', keys, layer=next((key for key in keys if not supported(key)), None)) if not any('A' in pair for pair in pairs.values()) and not full: return _bad('format', keys, layer=None) wrong, applied = [], set() @@ -263,4 +286,4 @@ def require_usable(path): """Stop a request before generation starts when a LoRA cannot work with the model.""" result = check_installed_file(path) if not result['usable']: - raise ValueError(INT8_UNMERGEABLE if result['reason'] == 'int8-unmergeable' else UNUSABLE) + raise ValueError({'int8-unmergeable': INT8_UNMERGEABLE, 'acceleration': ACCELERATION}.get(result['reason'], UNUSABLE)) diff --git a/freevideo_engine/lora_names.py b/freevideo_engine/lora_names.py index 16a2ca5c..41d64837 100644 --- a/freevideo_engine/lora_names.py +++ b/freevideo_engine/lora_names.py @@ -8,17 +8,33 @@ 'so it cannot be used for now. LoRAs that change only linear layers work.') +# Factor names trainers write: PEFT lora_A/lora_B, Kohya and diffusers +# lora_down/lora_up, each with or without the trailing ".weight". +_FACTOR = re.compile(r'(.+)\.lora_(A|B|down|up)(?:\.weight)?') +_SIDE = {'A': 'A', 'down': 'A', 'B': 'B', 'up': 'B'} + + +def _canonical(key): + return key.replace('.default.', '.') + + +def supported(key): + """Whether adapter_keys can index this tensor name.""" + canonical = _canonical(key) + return bool(_FACTOR.fullmatch(canonical)) or canonical.endswith(('.alpha', '.diff', '.diff_b')) + + def adapter_keys(keys): """Index factor/difference names without reading any tensors.""" pairs, full = {}, [] for key in keys: - canonical = key.replace('.default.', '.').replace('.lora_down.weight', '.lora_A.weight').replace('.lora_up.weight', '.lora_B.weight') - match = re.fullmatch(r'(.+)\.lora_([AB])\.weight', canonical) + canonical = _canonical(key) + match = _FACTOR.fullmatch(canonical) if match: - pair = pairs.setdefault(match[1], {}) - if match[2] in pair: + pair, side = pairs.setdefault(match[1], {}), _SIDE[match[2]] + if side in pair: raise ValueError('Duplicate LoRA factor: ' + key) - pair[match[2]] = key + pair[side] = key elif canonical.endswith('.alpha'): pairs.setdefault(canonical[:-6], {})['alpha'] = key elif canonical.endswith(('.diff', '.diff_b')): diff --git a/freevideo_engine/lora_online_cache.py b/freevideo_engine/lora_online_cache.py index 92b2c7da..c00096fa 100644 --- a/freevideo_engine/lora_online_cache.py +++ b/freevideo_engine/lora_online_cache.py @@ -21,7 +21,7 @@ def _supported(adapters, linears, skip_modulation=False): for adapter in adapters: with open_tensors(adapter['path']) as source: for key in source.keys(): - name = re.sub(r'\.(?:lora_[AB]\.weight|lora_(?:down|up)\.weight|alpha)$', + name = re.sub(r'\.(?:lora_(?:A|B|down|up)(?:\.weight)?|alpha)$', '', key.replace('.default.', '.')) for target, _, _ in _targets(name): if target not in linears and '.attn.' in target: diff --git a/web/error_panel.js b/web/error_panel.js index 5316c60b..fbdeee1b 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -65,6 +65,10 @@ const PLAIN_ERRORS = [ 'Unsupported LoRA tensor (no patches are ignored): ', 'Adapter contains no LoRA factors', 'Expected matching 2-D LoRA factors', 'Incomplete LoRA pair'], title: ['This LoRA cannot be used', '此 LoRA 无法使用'], summary: ['This LoRA is not made for the FreeVideo model. Turn it off or remove it, then generate again.', '此 LoRA 不适用于 FreeVideo 的模型。请关闭或移除后重新生成。']}, + {key: 'lora-acceleration', message: 'This acceleration LoRA was made for the original model and cannot be used. The FreeVideo model is already accelerated and does not need one.', + title: ['This LoRA cannot be used', '此 LoRA 无法使用'], + summary: ['The FreeVideo model is already accelerated and does not need an acceleration LoRA. Turn this LoRA off or remove it, then generate again.', + 'FreeVideo 的模型已经加速,无需加速 LoRA。请关闭或移除此 LoRA 后重新生成。']}, {key: 'lora-int8', message: '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.', title: ['This LoRA cannot be used', '此 LoRA 无法使用'], summary: ['The int8 version cannot merge the layers this LoRA changes yet. Turn it off or remove it, then generate again.', @@ -637,7 +641,7 @@ export function createErrorPanel(t) { copy.hidden = generated; copyInside.hidden = !generated; copyInside.textContent = copy.textContent; // For a generation failure the note on removed text sits in the technical details, beside the copy button. privacy.hidden = generated; privacyInside.hidden = !generated; - const lora = advice.kind === 'lora-unusable' || advice.kind === 'lora-int8'; + const lora = ['lora-unusable', 'lora-acceleration', 'lora-int8'].includes(advice.kind); // An int8 limit is not a judgement, so there is no mistake to report. github.hidden = !generated || advice.kind === 'lora-int8'; githubLora = lora; github.textContent = lora ? t('Report a mistake', '报告误判') : t('Report a problem', '提交问题'); diff --git a/web/lora_check.js b/web/lora_check.js index 39f0c871..1889d7a4 100644 --- a/web/lora_check.js +++ b/web/lora_check.js @@ -13,6 +13,8 @@ export function describeVerdict(verdict, t) { else if (verdict.reason === 'int8-unmergeable') reason = t('This LoRA cannot be used', '此 LoRA 无法使用'); else if (verdict.reason === 'format') reason = t('not a LoRA file FreeVideo can read', '不是 FreeVideo 能读取的 LoRA 文件'); else if (verdict.reason === 'shape') reason = t('does not match the current model', '和当前模型的结构不匹配'); + else if (verdict.reason === 'acceleration') reason = t('an acceleration LoRA made for the original model. The FreeVideo model is already accelerated and does not need one', + '这是为原版模型制作的加速 LoRA。FreeVideo 的模型已经加速,无需此类 LoRA'); else reason = family ? t(`made for ${family}`, `这是给 ${family} 训练的 LoRA`) : t('not made for the FreeVideo model', '不适用于 FreeVideo 的模型'); const summary = verdict.summary || {}; const lines = []; From cbd1e3c1f86bebd25ecf861d04bf8d944c96f19d Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 19:36:57 +0000 Subject: [PATCH 34/58] Let prompt enhancement skip audio and connected media and read reference videos --- freevideo_engine/prompt_vlm/rules.py | 73 ++++++++++++++++++++------ freevideo_engine/prompt_vlm/service.py | 24 +++++++-- freevideo_engine/prompt_vlm/worker.py | 6 +-- web/prompt_enhance.js | 5 +- web/prompt_references.js | 11 ++++ web/studio.js | 7 ++- 6 files changed, 99 insertions(+), 27 deletions(-) diff --git a/freevideo_engine/prompt_vlm/rules.py b/freevideo_engine/prompt_vlm/rules.py index e154bd2b..8d4e8a60 100644 --- a/freevideo_engine/prompt_vlm/rules.py +++ b/freevideo_engine/prompt_vlm/rules.py @@ -23,8 +23,13 @@ def request(value): raise ValueError('too_many_images') roles = [] for row in media: - if (not isinstance(row, dict) or set(row) != {'file', 'role'} or - not isinstance(row['file'], str) or row['role'] not in ('first', 'last', 'reference')): + # kind and number say which /