diff --git a/.github/ISSUE_TEMPLATE/bug_engine.yml b/.github/ISSUE_TEMPLATE/bug_engine.yml index dde2515..2161816 100644 --- a/.github/ISSUE_TEMPLATE/bug_engine.yml +++ b/.github/ISSUE_TEMPLATE/bug_engine.yml @@ -15,5 +15,10 @@ body: - type: textarea id: report attributes: - label: Error or local report (optional) / 错误详情或本地报告(选填) - description: Attach the diagnostic report (FreeVideo-report-….json, or video.debug.json in the output folder) if available; it includes hardware and timing information. / 如有诊断报告(FreeVideo-report-….json,或输出文件夹里的 video.debug.json),可拖入此处;其中已包含硬件和耗时信息。 + label: Diagnostic report / 诊断报告 + description: | + In FreeVideo, select Diagnostic report under a finished video. If the generation failed, select Report a problem on the error message. The report (FreeVideo-report-….json or .zip) is saved in your browser's download folder. Drag it here. For FreeVideo before 0.3.10, use video.debug.json in the output folder. + 在 FreeVideo 中,视频生成完成后点击视频下方的「诊断报告」;生成失败时点击错误提示上的「提交问题」。报告(FreeVideo-report-….json 或 .zip)保存在浏览器的下载文件夹,请将它拖到这里。0.3.10 之前的版本使用输出文件夹里的 video.debug.json。 + placeholder: Drop FreeVideo-report-….json or .zip here / 将 FreeVideo-report-….json 或 .zip 拖到这里 + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 7da7eb6..88a3335 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,4 +1,4 @@ -blank_issues_enabled: true +blank_issues_enabled: false contact_links: - name: Discord url: https://discord.gg/MsA277cJzZ diff --git a/freevideo_engine/adaptive.py b/freevideo_engine/adaptive.py index 102a6ad..234acb4 100644 --- a/freevideo_engine/adaptive.py +++ b/freevideo_engine/adaptive.py @@ -624,6 +624,7 @@ def execute_attempts(history, identity, profile, canvas, launch, *, row['seconds'] = time.perf_counter() - started if isinstance(metrics, dict): row['metrics'] = attempt_metrics(metrics) + details['completed_steps'] = row['metrics']['completed_steps'] history.finish(attempt, failure['outcome'], details=details) row.update(state=failure['outcome'], failure=failure) persist() diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index bf04997..b2637fe 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -301,16 +301,46 @@ def digest(path, algorithm='sha256', git_blob=False): def inventory(gpu=None): - smi = nvidia_smi() query = 'index,uuid,name,compute_cap,memory.total,memory.free,driver_version,pci.bus_id' - result = subprocess_output.run_external([smi, '--query-gpu=' + query, '--format=csv,noheader,nounits'], - check=True, capture_output=True, timeout=20) - rows = [dict(zip(query.split(','), map(str.strip, row))) for row in csv.reader(result.stdout.splitlines())] selector = gpu if gpu is not None else os.environ.get('CUDA_VISIBLE_DEVICES', '0').split(',')[0] - matches = [r for r in rows if r['index'] == selector or r['uuid'] == selector] - if len(matches) != 1: - raise ValueError('Select one physical GPU by nvidia-smi index or full UUID with --gpu. MIG is not supported.') - selected = matches[0] + detection = {} + try: + smi = nvidia_smi() + result = subprocess_output.run_external([smi, '--query-gpu=' + query, '--format=csv,noheader,nounits'], + check=True, capture_output=True, timeout=20) + rows = [dict(zip(query.split(','), map(str.strip, row))) for row in csv.reader(result.stdout.splitlines())] + if not rows or any(set(row) != set(query.split(',')) for row in rows): + raise ValueError('nvidia-smi returned an incomplete GPU inventory') + # Unsupported numeric fields are also a failed observation, not proof + # that the user has no GPU. Only the selected row has to be readable; + # another GPU reporting N/A must not block an installation that worked. + matches = [r for r in rows if r['index'] == selector or r['uuid'] == selector] + if len(matches) == 1: + row = matches[0] + float(row['memory.total']), float(row['memory.free']) + int(row['driver_version'].split('.')[0]) + capability = tuple(map(int, row['compute_cap'].split('.'))) + if len(capability) != 2 or capability[0] <= 0 or not 0 <= capability[1] <= 9: + raise ValueError('nvidia-smi returned an unknown compute capability: ' + row['compute_cap']) + except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as error: + from . import cuda_inventory + try: + fallback = cuda_inventory.probe(selector) + except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as fallback_error: + raise RuntimeError('GPU detection failed. nvidia-smi: %s; CUDA: %s. ' + 'Check device access and the NVIDIA driver, then retry setup.' % + (error, fallback_error)) from fallback_error + selected, rows = fallback['selected_gpu'], fallback['gpus'] + detection = dict(cuda_driver_api_version=fallback['cuda_driver_api_version'], + warnings=[cuda_inventory.WARNING], + gpu_detection=dict(source='cuda-driver', nvidia_smi_error=str(error), + nvidia_smi_returncode=getattr(error, 'returncode', None), + nvidia_smi_stderr=getattr(error, 'stderr', None))) + logging.getLogger(__name__).info('%s nvidia-smi: %s', cuda_inventory.WARNING, error) + else: + if len(matches) != 1: + raise ValueError('Select one physical GPU by nvidia-smi index or full UUID with --gpu. MIG is not supported.') + selected = matches[0] try: capability = tuple(map(int, selected['compute_cap'].split('.'))) if len(capability) != 2: @@ -332,7 +362,7 @@ def inventory(gpu=None): 'swap_free_bytes': ram.get('swap_free_bytes'), 'system_memory': ram, 'compiler': shutil.which('cl' if platform.system() == 'Windows' else 'g++'), 'git': usable_git(), **({} if platform.system() == 'Windows' else {'make': shutil.which('make')}), - 'git_found': shutil.which('git')} + 'git_found': shutil.which('git'), **detection} # Windows reuses a detected Git from 2.35, the oldest tested there, and @@ -522,6 +552,7 @@ def plan(args, *, local_progress=None, stage=None): if not args.hardware_json and (platform.system() not in ('Linux', 'Windows') or platform.machine().lower() not in ('x86_64', 'amd64')): raise RuntimeError('One-click setup supports Linux x86_64 and native Windows x64.') snapshot = json.loads(args.hardware_json.read_text(encoding='utf-8')) if args.hardware_json else inventory(args.gpu) + warnings.extend(snapshot.get('warnings', [])) hardware = Hardware.from_dict(snapshot['hardware']) errors = [] if snapshot.get('machine', '').lower() not in ('x86_64', 'amd64') or hardware.system not in ('Linux', 'Windows'): @@ -535,7 +566,8 @@ def plan(args, *, local_progress=None, stage=None): 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: + cuda_api = snapshot.get('cuda_driver_api_version') + if cuda_api < 13000 if cuda_api is not None else (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.' @@ -628,6 +660,15 @@ def plan(args, *, local_progress=None, stage=None): approved = getattr(args, 'approved_plan', None) if approved: previous_reuse = json.loads(Path(approved).read_text(encoding='utf-8')).get('local_models') + scan_cache = root / 'local-model-scan.json' + if not previous_reuse and scan_cache.is_file(): + # What an earlier check hashed; scan() still hashes any file whose size, times or inode changed. + try: + previous_reuse = json.loads(scan_cache.read_text(encoding='utf-8')) + except (OSError, ValueError): + previous_reuse = None + if not (isinstance(previous_reuse, dict) and isinstance(previous_reuse.get('matches'), dict)): + previous_reuse = None needed = [row for row in files if not model_target(row, model_dir, encoder_dir, prepared_dir).exists()] if local_manifest: if Path(local_manifest).stat().st_size > 512 * 1024: @@ -655,6 +696,11 @@ def plan(args, *, local_progress=None, stage=None): if error.__cause__ is not None: original += '\n' + str(error.__cause__) raise DownloadListError('read', original_error=original) from error + if local_reuse['matches'] and root.is_dir(): + try: + save(scan_cache, dict(root=local_reuse['root'], matches=local_reuse['matches'])) + except OSError: + pass # The next check hashes again; nothing depends on this file. local_reuse['copy_mode'] = bool(getattr(args, 'copy_existing_models', False)) local_reuse['copy_bytes'] = local_reuse['linked_bytes'] = 0 for row in needed: @@ -673,6 +719,16 @@ def plan(args, *, local_progress=None, stage=None): check_download_list = bool(getattr(args, 'download_list', False)) stamps_path = root / 'verified-models.json' stamps = json.loads(stamps_path.read_text(encoding='utf-8')) if check_download_list and stamps_path.is_file() else {} + # The check leaves the installer's ledger alone and keeps what it hashed in a file of its own. + checked_path = root / 'download-list-checks.json' + try: + checked = json.loads(checked_path.read_text(encoding='utf-8')) if check_download_list and checked_path.is_file() else {} + except (OSError, ValueError): + checked = {} + ledger_stamps = dict(stamps) + # A record this check made is newer than the ledger's for the same file whenever the two differ. + stamps = dict(ledger_stamps, **(checked if isinstance(checked, dict) else {})) + checked_paths = set() for row in files: path = model_target(row, model_dir, encoder_dir, prepared_dir) replacement = model_repair.replacement(path, row, mismatches, reserved) @@ -685,6 +741,7 @@ def plan(args, *, local_progress=None, stage=None): from .provision import verified hf_directory = Path(prepared_dir) if row.get('prepared') else model_dir if row['repo'].startswith('OpenVDN/') else encoder_dir size_matches = verified(path, row, stamps, mode=getattr(args, 'verify', 'auto'), hf_directory=hf_directory) + checked_paths.add(str(path)) present += row['bytes'] if size_matches else 0 device = existing_parent(path.parent) entry = groups.setdefault(str(device), {'path': str(device), 'needed_bytes': 0, @@ -697,6 +754,12 @@ def plan(args, *, local_progress=None, stage=None): # Only the streaming strategy can reuse this contiguous prefix. # Its completed content must still pass the pinned hash check. entry['resume_bytes'] = entry.get('resume_bytes', 0) + partial.stat().st_size + remembered = {name: value for name, value in stamps.items() if name in checked_paths and ledger_stamps.get(name) != value} + if check_download_list and root.is_dir() and remembered != checked: + try: + save(checked_path, remembered) # The next check does not hash these files again unless they change. + except OSError: + pass # Stream directly into final FP8 groups. The pinned model occupies ~45.3 # GiB; allow 52 GiB including the largest group in progress. Never credit # future source deletion toward the space needed to complete conversion. diff --git a/freevideo_engine/cli.py b/freevideo_engine/cli.py index 399a450..2767d47 100644 --- a/freevideo_engine/cli.py +++ b/freevideo_engine/cli.py @@ -30,6 +30,13 @@ def main(): if len(sys.argv) > 1 and sys.argv[1] in ('projects', 'sessions'): from .agent.cli import main as agent return agent(sys.argv[1:]) + if not getattr(sys, 'frozen', False) and not {'-h', '--help'} & set(sys.argv[1:]): + from .launch_view import restart_arguments + restart = restart_arguments(sys.argv[1:]) + if restart is not None: + # Deep installation folder: Windows cannot load the libraries by their full path. + from . import processes + raise SystemExit(processes.run(restart[0], env=restart[1], start_new_session=False).returncode) if len(sys.argv) > 1 and sys.argv[1] == 'predict': from .prediction_cli import main as predict raise SystemExit(predict(sys.argv[2:])) diff --git a/freevideo_engine/comfy_environment.py b/freevideo_engine/comfy_environment.py index 4b0ea9e..f3d1625 100644 --- a/freevideo_engine/comfy_environment.py +++ b/freevideo_engine/comfy_environment.py @@ -35,4 +35,7 @@ def isolated_environment(root, source, environ=None): env.update(FREEVIDEO_HOME=str(root), FREEVIDEO_PROXY_MODE=proxy_mode, PYTHONPATH=str(source), PYTHONNOUSERSITE='1', PYTHONUTF8='1', PYTHONIOENCODING='utf-8', NO_COLOR='1', FREEVIDEO_UI_EVENTS='1') from .triton_compat import temporary_environment - return temporary_environment(root, env) + # Pruning sys.prefix above also prunes the frozen launcher's static curl. + # Restore its fallback for managed Python after isolating Python packages. + from .linux_bundle import child_environment + return temporary_environment(root, child_environment(env)) diff --git a/freevideo_engine/compatibility.py b/freevideo_engine/compatibility.py index c5066fc..14ad4d7 100644 --- a/freevideo_engine/compatibility.py +++ b/freevideo_engine/compatibility.py @@ -78,6 +78,50 @@ def current_settings(value): return value +def failure_evidence(details): + # Bookkeeping must never turn a settled failure into another error. + try: + return _failure_evidence(details) + except Exception: + return None + + +def _failure_evidence(details): + """What a discarded GPU failure leaves for diagnosis: fixed fields and numbers only. + + The attempt row is deleted when it is discarded, so a report taken after a + later successful retry would otherwise show no more than the reason. + """ + if not isinstance(details, dict): + return None + evidence = {} + for key in ('failed_phase', 'completed_steps'): + value = details.get(key) + if isinstance(value, str) and len(value) <= 64 or type(value) is int: + evidence[key] = value + chain = [] + for item in (details.get('exception') or [])[:3]: + if not isinstance(item, dict): + continue + entry = {key: item[key] for key in ('exception_type', 'category') if isinstance(item.get(key), str) + and len(item[key]) <= 64} + if type(item.get('error_code')) is int: + entry['error_code'] = item['error_code'] + entry['frames'] = [{key: (str(frame[key]).replace('\\', '/').rsplit('/', 1)[-1] if key == 'file' else frame[key]) + for key in ('file', 'function', 'line') + if isinstance(frame.get(key), (str, int)) and len(str(frame[key])) <= 96} + for frame in (item.get('frames') or [])[-4:] if isinstance(frame, dict)] + chain.append(entry) + if chain: + evidence['exception'] = chain + memory = details.get('memory') + if isinstance(memory, dict): + numbers = {key: value for key, value in memory.items() if type(value) is int} + if numbers: + evidence['memory'] = dict(list(numbers.items())[:16]) + return evidence or None + + def device_key(identity): """Missing optional metadata disables compatibility, never invents a device.""" if not isinstance(identity, dict): @@ -174,7 +218,7 @@ def acknowledge(self, identity, notice_id): self.write_row(db, key, value) return dict(value, levels=LEVELS, available=True) - def incident(self, row, reason, batch=None): + def incident(self, row, reason, batch=None, evidence=None): # Only a launched video worker can be a load-related interrupted attempt. if row.get('purpose') != 'generation' or not row.get('worker'): return @@ -191,6 +235,9 @@ def incident(self, row, reason, batch=None): config=row['config'], geometry=row['geometry'], diagnosis='unknown', time=time.time(), policy_revision=revision, applicable=revision == policy_revision(), counted=False, level_changed=False) + evidence = failure_evidence(evidence) + if evidence: + incident['evidence'] = evidence escalate = False if value['automatic'] and incident['applicable']: if reason == 'cuda_error': @@ -225,6 +272,8 @@ def incident(self, row, reason, batch=None): value['notice'] = dict(id=row['id'], level=value['level'], reason=reason, phase=row.get('phase'), diagnosis='unknown', policy_revision=revision) value['trigger'] = dict(value['notice']) + if evidence: + value['trigger']['evidence'] = evidence db.execute('INSERT INTO incidents VALUES (?,?,?,?)', (row['id'], key, incident['time'], json.dumps(incident))) self.write_row(db, key, value) db.execute('DELETE FROM incidents WHERE device=? AND id NOT IN ' diff --git a/freevideo_engine/cuda_inventory.py b/freevideo_engine/cuda_inventory.py new file mode 100644 index 0000000..193de98 --- /dev/null +++ b/freevideo_engine/cuda_inventory.py @@ -0,0 +1,97 @@ +"""Stdlib-only setup fallback when the NVML command cannot report GPU details. + +Run in a child: its temporary CUDA context must exit before installation starts. +CUDA ordinals are not nvidia-smi indices; only UUIDs identify multi-GPU selections. +""" +import ctypes as C +import json +import os +import sys +import uuid + +from . import processes, subprocess_output + + +WARNING = ('NVIDIA GPU information could not be read with nvidia-smi. CUDA detected the GPU; ' + 'you can continue. Setup will test it before marking it ready.') + + +def probe(selector=None): + env = dict(os.environ) + # Enumerate physical devices, then select by stable UUID in the worker too. + env.pop('CUDA_VISIBLE_DEVICES', None) + result = subprocess_output.run_python(processes.module_command(__name__, '0' if selector is None else selector), + env=env, capture_output=True, timeout=30) + if result.returncode: + raise RuntimeError(result.stderr.strip() or 'CUDA GPU detection exited with code %d' % result.returncode) + return json.loads(result.stdout) + + +def local_probe(selector=None): + driver = C.WinDLL('nvcuda.dll') if sys.platform == 'win32' else C.CDLL('libcuda.so.1') + + def call(name, types, *args): + function = getattr(driver, name) + function.argtypes, function.restype = types, C.c_int + status = function(*args) + if status: + raise RuntimeError('%s failed (CUDA error %d)' % (name, status)) + + integer = C.POINTER(C.c_int) + call('cuInit', [C.c_uint], 0) + version, count = C.c_int(), C.c_int() + call('cuDriverGetVersion', [integer], C.byref(version)) + call('cuDeviceGetCount', [integer], C.byref(count)) + rows = [] + for ordinal in range(count.value): + device, major, minor = C.c_int(), C.c_int(), C.c_int() + call('cuDeviceGet', [integer, C.c_int], C.byref(device), ordinal) + name, pci = C.create_string_buffer(256), C.create_string_buffer(32) + identity = C.create_string_buffer(16) + call('cuDeviceGetName', [C.c_void_p, C.c_int, C.c_int], name, len(name), device) + call('cuDeviceGetPCIBusId', [C.c_void_p, C.c_int, C.c_int], pci, len(pci), device) + call('cuDeviceGetUuid', [C.c_void_p, C.c_int], identity, device) + if hasattr(driver, 'cuDeviceGetUuid_v2'): + instance = C.create_string_buffer(16) + call('cuDeviceGetUuid_v2', [C.c_void_p, C.c_int], instance, device) + if instance.raw != identity.raw: + raise RuntimeError('MIG is not supported by setup. Select a physical GPU.') + call('cuDeviceGetAttribute', [integer, C.c_int, C.c_int], C.byref(major), 75, device) + call('cuDeviceGetAttribute', [integer, C.c_int, C.c_int], C.byref(minor), 76, device) + rows.append({'index': '', 'cuda_ordinal': ordinal, 'uuid': 'GPU-' + str(uuid.UUID(bytes=identity.raw)), + 'name': name.value.decode('utf-8', errors='replace'), 'pci.bus_id': pci.value.decode('ascii'), + 'compute_cap': '%d.%d' % (major.value, minor.value), 'driver_version': ''}) + if not rows: + raise RuntimeError('CUDA found no accessible GPUs. Check the NVIDIA driver and device access, then retry setup.') + if selector and selector.startswith('MIG-'): + raise ValueError('MIG is not supported by setup. Select a physical GPU.') + if selector and selector.startswith('GPU-'): + matches = [row for row in rows if row['uuid'] == selector] + elif len(rows) == 1 and selector in (None, '0'): + matches = rows + else: + raise ValueError('nvidia-smi indices are unavailable. Select a full GPU UUID with --gpu: ' + + ', '.join(row['uuid'] + ' (' + row['name'] + ')' for row in rows)) + if len(matches) != 1: + raise ValueError('The selected GPU UUID was not found by CUDA: ' + str(selector)) + selected = matches[0] + device = C.c_int() + call('cuDeviceGet', [integer, C.c_int], C.byref(device), selected['cuda_ordinal']) + context = C.c_void_p() + call('cuDevicePrimaryCtxRetain', [C.POINTER(C.c_void_p), C.c_int], C.byref(context), device) + try: + call('cuCtxSetCurrent', [C.c_void_p], context) + free, total = C.c_size_t(), C.c_size_t() + call('cuMemGetInfo_v2', [C.POINTER(C.c_size_t), C.POINTER(C.c_size_t)], C.byref(free), C.byref(total)) + selected.update({'memory.total': str(total.value / 2**20), 'memory.free': str(free.value / 2**20)}) + finally: + call('cuDevicePrimaryCtxRelease_v2', [C.c_int], device) + return dict(selected_gpu=selected, gpus=rows, cuda_driver_api_version=version.value) + + +if __name__ == '__main__': + try: + print(json.dumps(local_probe(sys.argv[1] if len(sys.argv) > 1 else None))) + except (OSError, RuntimeError, ValueError, AttributeError) as error: + print(str(error), file=sys.stderr) + sys.exit(1) diff --git a/freevideo_engine/diagnostics.py b/freevideo_engine/diagnostics.py index 9f15bc4..29e08a0 100644 --- a/freevideo_engine/diagnostics.py +++ b/freevideo_engine/diagnostics.py @@ -42,7 +42,11 @@ # The preceding character guard prevents URL slashes from being treated # as local paths while still matching a drive path after prose such as # ``at C:\\Users\\...`` or a quoted traceback location. - r'''(?]+[\\/])|/(?!/))[^"'<>;\r\n,\)\]}]*''') + # Consume explicit relative paths too, spaces included, so an interior + # slash (after e.g. a hyphen) cannot subsequently be mistaken for an + # absolute path. Only our own ``./setup.sh`` command stays as written. + r'''(?\./setup\.sh(?![\w/\\-]|\.\w))|\.{1,2}/[^"'<>;\r\n,\)\]}]*|''' + r'''(?:(?:[A-Za-z]:[\\/])|(?:\\\\[^\\/\s"'<>]+[\\/])|/(?!/))[^"'<>;\r\n,\)\]}]*)''') class Redactor: @@ -97,6 +101,8 @@ def text(self, value): @classmethod def _path_match(cls, match): value = match.group(0) + if match.group('command') is not None: + return value # Text is often redacted again (a worker's log inside a report, the # launcher terminal). The ``/name`` after an earlier ```` is that # pass's own output, unless it still has a directory in it. diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index 5ebbfda..7a5a3c2 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -148,7 +148,17 @@ def display_launcher_error(value, *, zh=False, replacements=()): from .offline_errors import alias_failure, guidance if alias_failure(value): return guidance('runtime-alias', zh) - return redacted_launcher_error(value, replacements) + text = redacted_launcher_error(value, replacements) + if zh: + text = re.sub(r'Missing curl, needed for downloads and proxy connections\. ' + r'(In a terminal, run: ([^\r\n]+?), then check again\.|' + r'Ask your administrator to install curl, then check again\.)' + r'( You can also run \./setup\.sh in the source folder\.)?', + lambda match: '缺少 curl,下载和代理连接需要它。' + + ('请在终端运行:' + match.group(2) + ',然后重新检查。' if match.group(2) else + '请管理员安装 curl,然后重新检查。') + + ('也可以在源码目录运行 ./setup.sh。' if match.group(3) else ''), text) + return text def setup_command_failure(label, log, exit_code): diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index 1b4d5cd..a7b9e68 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -288,16 +288,43 @@ def history(self): names = sorted(named, reverse=True) + [name for _, name in sorted(older, reverse=True)] return dict(items=[dict(file='freevideo-image/' + name, name=name) for name in names[:HISTORY_LIMIT]]) - def history_delete(self, name): + def history_path(self, name): + """The picture a history name stands for; ValueError when it is none of ours, missing when it is already gone.""" leaf = name.rpartition('/')[2] if isinstance(name, str) else '' if not (HISTORY_NAME.fullmatch(leaf) or LEGACY_NAME.fullmatch(leaf)) or name != 'freevideo-image/' + leaf: raise ValueError('invalid_media') path = self.output_directory() / leaf - if path.is_symlink() or not path.is_file() or (LEGACY_NAME.fullmatch(leaf) and not self.legacy_picture(path)): + if path.is_symlink() or (path.exists() and not path.is_file()) or ( + LEGACY_NAME.fullmatch(leaf) and path.is_file() and not self.legacy_picture(path)): + raise ValueError('invalid_media') + return path + + def history_delete(self, name): + path = self.history_path(name) + if not path.is_file(): raise ValueError('invalid_media') path.unlink() return self.history() + def history_delete_many(self, names): + """Delete each picture on its own; the ones that stay are listed with why (busy, missing, invalid).""" + if not isinstance(names, list) or len(names) > HISTORY_LIMIT: + raise ValueError('invalid_request') + failed = [] + for name in dict.fromkeys(names): + try: + path = self.history_path(name) + except ValueError: + failed.append(dict(file=name if isinstance(name, str) else '', reason='invalid')) + continue + try: + path.unlink() + except FileNotFoundError: + failed.append(dict(file=name, reason='missing')) + except OSError: + failed.append(dict(file=name, reason='busy')) + return dict(self.history(), failed=failed) + def history_open(self): """Show the folder in the file manager of this machine.""" folder = str(self.output_directory()) @@ -1199,6 +1226,8 @@ async def action(request): result = service.history() elif name == 'history-delete': result = service.history_delete(value.get('file')) + elif name == 'history-delete-many': + result = service.history_delete_many(value.get('files')) elif name == 'history-open': if request.remote not in ('127.0.0.1', '::1'): raise ValueError('invalid_request') diff --git a/freevideo_engine/launch_view.py b/freevideo_engine/launch_view.py index e97ef24..c679583 100644 --- a/freevideo_engine/launch_view.py +++ b/freevideo_engine/launch_view.py @@ -11,6 +11,7 @@ import os from pathlib import Path import re +import sys from .long_paths import io_path, plain @@ -93,6 +94,30 @@ def for_engine(engine_root) -> View: return View(engine, engine) +def restart_arguments(arguments, prefix=None, environ=None): + """The command line and environment that run this command again from the engine's short folder. + + None where the process is not an engine's own Python on Windows, already runs from a + short folder, or its folder needs none. A command-line run reaches the libraries + (torch\\lib\\c10.dll) by the interpreter's own path, so only a restart can shorten it. + """ + from . import runtime_alias + environ = os.environ if environ is None else environ + if not runtime_alias._backend.windows() or current(environ) is not None: + return None + prefix = Path(plain(sys.prefix if prefix is None else prefix)) + if prefix.parent.name.casefold() != 'envs': + return None + try: + view = for_engine(prefix.parent.parent) + except OSError: + return None + if not view.used: + return None + command, child, _ = execution([sys.executable, '-m', 'freevideo_engine', *arguments], environ, None, view) + return command, child + + def for_file(path): """The view of the offline runtime that holds this file, or None. diff --git a/freevideo_engine/linux_bundle.py b/freevideo_engine/linux_bundle.py index bca5a1c..10fb1ea 100644 --- a/freevideo_engine/linux_bundle.py +++ b/freevideo_engine/linux_bundle.py @@ -61,6 +61,21 @@ def child_environment(environ=None): env.pop(name) elif name in QT_VARIABLES and inside_bundle(env[name]): env.pop(name) + # Setup also runs through bash and a managed, non-frozen Python. Carry the + # selected fallback on PATH so those workers use system.curl_executable() + # just like the GUI. Append it: existing system tools keep priority. + from .system import curl_executable, curl_ca_arguments + bundled = bundle_directory() / 'curl/curl' + curl = curl_executable(env) if bundled.is_file() else None + if curl is not None and Path(curl) == bundled: + folder = str(Path(curl).parent) + path = env.get('PATH', os.defpath) + if folder not in path.split(os.pathsep): + env['PATH'] = path + os.pathsep + folder if path else folder + certificates = curl_ca_arguments(curl, env) + if certificates: + # curl reads no variable for proxy roots; curl_command and setup pass this one on. + env['CURL_CA_BUNDLE'] = env['FREEVIDEO_CURL_PROXY_CA'] = certificates[1] return env diff --git a/freevideo_engine/modern_launcher.py b/freevideo_engine/modern_launcher.py index fac5388..60a0512 100644 --- a/freevideo_engine/modern_launcher.py +++ b/freevideo_engine/modern_launcher.py @@ -426,6 +426,16 @@ def smoke_test(app, engine, window, bridge, destination): try: payload = check_launcher_payload(session.source, destination) payload['reopen'] = check_launcher_reopen(destination) + from .linux_bundle import frozen_linux, child_environment + if frozen_linux(): + import subprocess + from .network import curl_command + command = curl_command(5) + version = subprocess.run([command[0], '--version'], env=child_environment(), + capture_output=True, text=True, timeout=10, check=True).stdout + if 'https' not in version: + raise RuntimeError('Packaged curl lacks HTTPS support') + payload['curl'] = dict(executable=command[0], version=version.splitlines()[0]) from PySide6.QtQuick import QQuickItem for page in ('comfy', 'models', 'progress', 'launcher'): session.page = page diff --git a/freevideo_engine/network.py b/freevideo_engine/network.py index 30c379a..ea3000a 100644 --- a/freevideo_engine/network.py +++ b/freevideo_engine/network.py @@ -25,7 +25,7 @@ from urllib.parse import quote, unquote, urlencode, urljoin, urlsplit from . import proxy from .long_paths import io_path, is_windows, path_chars, plain, plain_errors -from .system import curl_executable, missing_curl_message, windows +from .system import curl_executable, curl_ca_arguments, missing_curl_message, windows SOURCES = { @@ -156,6 +156,9 @@ def curl_command(timeout, env=None): command = [executable, '--config', '-', '--silent', '--show-error', '--no-location-trusted', '--location', '--max-redirs', '5', '--fail', '--proto', '=https,http', '--proto-redir', '=https,http', '--connect-timeout', str(timeout), '--user-agent', 'FreeVideo-Setup/1'] + command += curl_ca_arguments(executable, env) + if '--proxy-cacert' not in command and (env or {}).get('FREEVIDEO_CURL_PROXY_CA'): + command += ['--proxy-cacert', env['FREEVIDEO_CURL_PROXY_CA']] if (env or {}).get('FREEVIDEO_PROXY_ROUTE') == 'direct': command += ['--proxy', '', '--noproxy', '*'] return command @@ -579,8 +582,11 @@ def _package_failure(error): value = re.sub(r'(?i)(\b[\w-]{0,64}(?:token|password|secret|api[_-]?key)\s*[=:]\s*)[^\s,;&]+', r'\1', value) # Keep a stable path marker without exposing usernames, drives or mounts. - value = re.sub(r'(?;,)\]}]*)', - lambda match: '/' + re.split(r'[\\/]', match.group(1).rstrip('\\/'))[-1], value) + value = re.sub(r'(?\.{1,2}/[^\s"\'<>;,)\]}]*)|' + r'(?:[A-Za-z]:[\\/]|/)([^\s"\'<>;,)\]}]*))', + lambda match: match.group('relative') if match.group('relative') is not None + and not re.search(r'[\\/]', match.group('relative').split('/', 1)[1]) else + '/' + re.split(r'[\\/]', match.group(0).rstrip('\\/'))[-1], value) lines = [line.strip() for line in value.splitlines() if line.strip()] detail = '\n'.join(lines[-12:])[-4096:] if lines else 'no package-manager output' summary = next((line for line in lines if line), detail).strip()[:320] diff --git a/freevideo_engine/prompt_vlm/rules.py b/freevideo_engine/prompt_vlm/rules.py index 8d4e8a6..1f36d93 100644 --- a/freevideo_engine/prompt_vlm/rules.py +++ b/freevideo_engine/prompt_vlm/rules.py @@ -143,6 +143,7 @@ def __init__(self, condition, text, value): indices = {int(i) for i in re.findall(r'', draft)} self.details = dict(condition=condition, output_characters=len(draft), missing_pictures=sorted(i for i in pictures if f'' not in draft), + missing_references=[each for each in required if each not in draft], out_of_range_pictures=sorted(i for i in indices if i not in pictures), retried=False) diff --git a/freevideo_engine/prompt_vlm/service.py b/freevideo_engine/prompt_vlm/service.py index 4e8fc64..b8f6bee 100644 --- a/freevideo_engine/prompt_vlm/service.py +++ b/freevideo_engine/prompt_vlm/service.py @@ -434,7 +434,7 @@ 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. + # Output rejections contain condition IDs, counts, typed media labels 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', 'output_rejection')} @@ -609,7 +609,8 @@ def read_rows(): 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')) + error.details['retried'] = bool(stats.get('media_retry') or stats.get('picture_retry') + or stats.get('dialogue_retry')) self.progress(dict(diagnostics=stats)) raise self.progress(dict(result, worker_returncode=child.returncode)) diff --git a/freevideo_engine/prompt_vlm/worker.py b/freevideo_engine/prompt_vlm/worker.py index 0b6f97e..3d8bfbe 100644 --- a/freevideo_engine/prompt_vlm/worker.py +++ b/freevideo_engine/prompt_vlm/worker.py @@ -420,19 +420,19 @@ def on_token(timing): text = processor.decode(ids, skip_special_tokens=True) missing = missing_lines(text, value['text']) rewritten, rejected = None, None - pictures = [] + references = [] 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: + references = error.details['missing_references'] + if missing or references: # 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}. ' + if references: + labels = ', '.join(references) + correction = (f'Your rewrite omitted these required media references: {labels}. ' 'Preserve all existing content and add each missing reference where it belongs. ' 'Return the complete prompt in the same required section format.\n') if missing: @@ -445,24 +445,28 @@ def on_token(timing): stats.update(retry_output_tokens=len(again), rewrite_seconds=time.monotonic() - loaded) if missing: stats['dialogue_retry'] = True - if pictures: - stats['picture_retry'] = True + if references: + stats['media_retry'] = True + if any(each.startswith('= 1800: + if references 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)): + if len(again) < 1800 and (references or len(missing_lines(second, value['text'])) < len(missing)): rewritten = candidate except InvalidOutput: - if pictures: + if references: raise - elif pictures: - stats['picture_retry_skipped'] = stats.get('dialogue_retry_skipped', 'context_length') + elif references: + stats['media_retry_skipped'] = stats.get('dialogue_retry_skipped', 'context_length') + if any(each.startswith('>"$FREEVIDEO_BOOTSTRAP_ROOT/bootstrap.log" 2>&1; then if printf '%s %s\n' "$FV_UV_SHA256" "$part" | sha256sum --check --status; then mv -- "$part" "$archive" diff --git a/scripts/build_linux.py b/scripts/build_linux.py index 9712256..ac0573c 100644 --- a/scripts/build_linux.py +++ b/scripts/build_linux.py @@ -18,6 +18,7 @@ import subprocess import sys import sysconfig +import tarfile import urllib.request ROOT = Path(__file__).resolve().parents[1] @@ -33,6 +34,14 @@ APP_ID = 'org.flashml.FreeVideo' NAME = RELEASE_ASSETS[LINUX_CHANNEL][0] TOOLS = { + # stunnel/static-curl 8.22.0, x86_64 musl: fully static, HTTPS + HTTP/SOCKS + # proxies, with embedded Mozilla roots. The dev archive supplies the original + # licenses for curl AND its linked components; headers/libraries are not shipped. + 'curl': dict(version='8.22.0', + url='https://github.com/stunnel/static-curl/releases/download/8.22.0/curl-linux-x86_64-dev-8.22.0.tar.xz', + sha256='93a928c9bab36604ff646e7de31c9d4b789145acf8c7cda75e7da2e1221c5cc7', + binary_sha256='b7443db66d622a27ef2152bb2032d6ada9fb29a8917f22b9521a993f8e61cd7f', + ca_sha256='f66dff1bdf8f96060b8177976f8b7d9254bc89bc4db933d769f7384d28480bc9'), 'appimagetool': dict(url='https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage', sha256='ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0'), 'runtime': dict(url='https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-x86_64', @@ -90,11 +99,43 @@ def tool(cache, name): stage.replace(path) if hashlib.sha256(path.read_bytes()).hexdigest() != spec['sha256']: raise SystemExit('Pinned %s does not match its checksum: %s' % (name, path)) - if not name.startswith('font'): + if name in ('appimagetool', 'runtime'): path.chmod(0o755) return path +def bundle_curl(cache, destination, notices): + """Stage only the verified static executable and its original license texts.""" + spec = TOOLS['curl'] + destination.mkdir(parents=True, exist_ok=True) + licenses = notices / 'curl' + licenses.mkdir(parents=True, exist_ok=True) + with tarfile.open(tool(cache, 'curl'), 'r:xz') as archive: + # Read exact members, never extract archive paths or follow archive links. + member = archive.getmember('curl-x86_64/bin/curl') + if not member.isfile(): + raise ValueError('Bundled curl must be a regular file') + data = archive.extractfile(member).read() + if hashlib.sha256(data).hexdigest() != spec['binary_sha256']: + raise ValueError('Bundled curl binary does not match its checksum') + executable = destination / 'curl' + executable.write_bytes(data) + executable.chmod(0o755) + copied = [] + for member in archive.getmembers(): + path = Path(member.name) + if path.parent == Path('curl-x86_64/licenses') and member.isfile(): + (licenses / (path.name + '.txt')).write_bytes(archive.extractfile(member).read()) + copied.append(path.name) + if not {'curl', 'ca-bundle', 'openssl'} <= set(copied): + raise ValueError('Bundled curl licenses are missing') + (licenses / 'SOURCE.txt').write_text( + 'Static curl %s (Linux x86_64 musl)\n%s\nArchive SHA256: %s\nBinary SHA256: %s\n' + 'Build sources: https://github.com/stunnel/static-curl/tree/%s\n' % + (spec['version'], spec['url'], spec['sha256'], spec['binary_sha256'], spec['version']), encoding='utf-8') + return executable + + def launcher_characters(root): text = '' for path in [*(root / 'freevideo_engine').rglob('*.py'), *(root / 'freevideo_engine').rglob('*.qml'), @@ -206,6 +247,7 @@ def main(): stamp = stamp_source(staged, build) notices = staged / 'freevideo_engine/launcher/licenses/bundled' collect_notices(ROOT, notices) + curl = bundle_curl(tools, out / 'curl', notices) fonts = out / 'fonts' glyphs = make_fonts(ROOT, tools, fonts) shutil.copyfile(fonts / 'OFL.txt', notices / 'Noto-Sans-CJK-SC-OFL.txt') @@ -227,6 +269,7 @@ def main(): from scripts.source_build import run_pyinstaller # These files are added after PyInstaller; they belong to the same base key. extra_runtime = {'appimage-runtime': str(runtime), 'appimagetool': str(appimagetool)} + extra_runtime['curl'] = str(curl) for name in SYSTEM_LIBRARIES: path = system_library(name, args.libraries.resolve() if args.libraries else None) if path is not None: @@ -235,6 +278,9 @@ def main(): dist = out / 'dist' / 'FreeVideo' write_prune_report(out, prune_qt_bundle(dist)) bundled = bundle_libraries(dist / '_internal', args.libraries.resolve() if args.libraries else None) + # Copy after PyInstaller: a static ELF must not be rewritten or analyzed as + # a shared library. The base manifest above still binds its exact bytes. + shutil.copytree(curl.parent, dist / '_internal/curl') folder = appdir(out, dist, ROOT / 'freevideo_engine/assets/icon.png') # copytree expands hardlinks: deduplicate the final AppDir, after copying. write_data_report(out, deduplicate_bundle_data(folder)) diff --git a/web/autopilot_chat.css b/web/autopilot_chat.css index 014d7d4..91d2792 100644 --- a/web/autopilot_chat.css +++ b/web/autopilot_chat.css @@ -172,6 +172,7 @@ .fv-chat-delete-actions{display:flex;flex-wrap:wrap;gap:4px} .fv-chat-me>.fv-chat-actions{margin-top:6px;flex-wrap:wrap;justify-content:flex-end} .fv-chat-me .fv-chat-error{flex:1;min-width:0} +.fv-chat-error.fv-chat-calm{color:var(--fv-muted)} .fv-chat-keep{white-space:nowrap} .fv-chat-scene-menu{box-sizing:border-box;justify-self:center;width:100%;max-width:720px;display:grid;gap:2px;padding:6px;margin-bottom:6px;border:1px solid var(--fv-border);border-radius:12px;background:var(--fv-raised)} .fv-chat-scene-menu[hidden]{display:none} diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index 3526d82..b66970a 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -719,8 +719,10 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () status.append(error, retry, report); item.append(status); } quoted(error, entry.error || ''); error.hidden = !entry.error; - // Waiting for the agent to come back is not a fault. - report.hidden = !entry.error || !!entry.automatic; + // Waiting for the agent to come back, or the user editing a queued message, is not a fault. + const calm = !!entry.automatic || entry.errorCode === 'edited'; + report.hidden = !entry.error || calm; + error.classList.toggle('fv-chat-calm', calm); error.setAttribute('role', calm ? 'status' : 'alert'); const full = entry.errorCode === 'session_full'; retry.textContent = full ? t('New chat', '新对话') : t('Retry', '重试'); retry.disabled = entry.sending || (full ? creating : !sendingAllowed()); @@ -1212,7 +1214,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () saveDraft(); // Editing a queued draft cancels its automatic delivery. Its original message remains available for manual retry. for (const entry of pending) if (entry.automatic && entry.session === draftSession && entry.draft !== drafts.get(draftSession)) { - entry.automatic = false; + entry.automatic = false; entry.errorCode = 'edited'; entry.error = t('The message changed. Click "Retry" to send the original.', '消息已修改,点“重试”发送原消息。'); say(''); } diff --git a/web/editor.js b/web/editor.js index 5055fab..607eb0c 100644 --- a/web/editor.js +++ b/web/editor.js @@ -823,9 +823,10 @@ export function openEditor({api, app, from = null, panel = {}, components: given }, needsText(selection = state.selection) { const range = selectionRange(selection); if (!range) return true; - // Only a part pulled out of a finished one needs no description: it continues that one. + // Only a part pulled out of a finished one needs no description: it continues that one with the + // description it took over. A finished video without one (an exported edit) still needs it. const head = state.record.tracks[0].clips.find(clip => clip.start === range.start); - const before = !!head && model.continuesBefore(state.record, head.id) && state.record.tracks[0].clips.some(clip => + const before = !!head && !!head.text?.trim() && model.continuesBefore(state.record, head.id) && state.record.tracks[0].clips.some(clip => clip.start + clip.frames === range.start && clip.source && clip.extend && clip.in + clip.frames === clip.length); return !before; }, @@ -966,6 +967,10 @@ export function openEditor({api, app, from = null, panel = {}, components: given 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); + } else if (!text.trim()) { + // The box was emptied; the part keeps the description it took over, as an empty one cannot be generated. + text = state.record.tracks[0].clips.find(clip => clip.start === selectionRange(selection).start).text; + state.setRangeText(text); genbar?.setText(text); } const waiting = waitingFor(selection); if (waiting) { diff --git a/web/editor_failure.js b/web/editor_failure.js index 02df4df..f78e879 100644 --- a/web/editor_failure.js +++ b/web/editor_failure.js @@ -1,6 +1,6 @@ import {attemptClip, find} from './editor_model.js'; import {createErrorReport} from './error_panel.js'; -import {saveReport, showReportTip, reportIssue} from './report_issue.js'; +import {saveReport, showReportTip, focusReportTip, reportIssue} from './report_issue.js'; import {productVersion} from './updates.js'; // Receipts belong to an attempt, not to saved projects or undo history. @@ -68,6 +68,7 @@ export function createEditorFailures({api, state, jobStatuses, reasonText}) { report, async download(id, {bar, anchor, t, current = () => true}) { const found = receipt(id); if (!found) return; + if (focusReportTip(bar)) return; let blob; if (found.reportId) { const response = await api.fetchApi(`/freevideo/report/${found.reportId}`); diff --git a/web/error_panel.js b/web/error_panel.js index fbdeee1..e1d8a33 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -3,6 +3,7 @@ import {repairAction} from './repair_hint.js'; import {detectBrowserExtension, browserExtensionAdvice, extensionSteps, isNetworkTypeError, connectionLostText} from './browser_extension.js'; import {SAMPLING_EFFORTS, STEP_CACHES, UPSCALE_FACTORS, tableLevel} from './sampling_effort.js'; import {failureIssue, failureStage} from './failure_issue.js'; +import {focusReportTip, reportIssue, showReportTip} from './report_issue.js'; const reports = new WeakSet(); const captured = new WeakMap(); const diagnosticReports = new WeakSet(); @@ -228,7 +229,8 @@ function reportRedactor(detail, context) { .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '
'); // Keep traceback source basenames and line numbers, never drive names, // home directories or private media filenames. Spaces in paths count. - text = text.replace(/(^|[^A-Za-z0-9_:/<>])((?:[A-Za-z]:[\\/]|\\\\[^\\/\s]+[\\/]|\/(?![\/\s]))[^\r\n"'<>;,\)\]]+)/gm, (_, prefix, path) => { + text = text.replace(/(^|[^A-Za-z0-9_:/<>])(\.\/setup\.sh(?![\w\/\\-]|\.\w)|\.{1,2}\/[^\r\n"'<>;,\)\]]*|(?:[A-Za-z]:[\\/]|\\\\[^\\/\s]+[\\/]|\/(?![\/\s]))[^\r\n"'<>;,\)\]]+)/gm, (_, prefix, path) => { + if (path === './setup.sh') return prefix + path; const source = path.replace(/\\/g, '/').match(/\/([A-Za-z0-9_.-]+\.(?:py|js|ts|mjs))(?=[:\s]|$)/); return prefix + (source ? '/' + source[1] + path.slice(path.indexOf(source[1]) + source[1].length) : ''); }); @@ -441,6 +443,8 @@ function showFailureDialog(report, t) { dialog.freevideoError = identity; dialog.setAttribute('aria-label', t('FreeVideo error', 'FreeVideo 错误')); const panel = createErrorPanel(t); panel.show(report, false); + // The dialog has no run's report to download; submitting stays on the failure card. + if (diagnosticReports.has(report)) panel.element.querySelector('.fv-failure-github:not([data-lora])')?.remove(); const close = document.createElement('button'); close.type = 'button'; close.textContent = t('Got it', '知道了'); close.autofocus = true; close.onclick = () => dialog.close(); @@ -498,7 +502,9 @@ export function adviceFor(report, t, value) { return {advice, generated}; } -export function createErrorPanel(t) { +// route: where a generation's diagnostic report comes from ({download, fetch, version, has}); without it the +// button opens the feedback form. +export function createErrorPanel(t, route = null) { if (!document.getElementById('freevideo-error-style')) { const style = document.createElement('style'); style.id = 'freevideo-error-style'; style.textContent = `.fv-failure{border:1px solid var(--fv-danger,#dc7c7c);border-radius:var(--fv-r-md,12px);padding:12px 14px;margin:10px 0;text-align:left;min-width:0} @@ -562,6 +568,13 @@ export function createErrorPanel(t) { const loadIssueModules = () => import('./feedback.js').then(feedback => import('./updates.js').then(updates => { issueModules = {feedback, updates}; })).catch(() => {}); github.onclick = async () => { if (!currentReport) return; + // A generation with a diagnostic report goes the report route: save it, then the tip opens the form. + if (route?.has() && diagnosticReports.has(currentReport) && !githubLora) { + if (focusReportTip(head)) return; + try { await route.download(); } catch { return; } + showReportTip(head, github, t, {mode: 'downloaded', submit: () => reportIssue(route.fetch, route.version(), {save: false})}); + return; + } if (!issueModules) await loadIssueModules(); if (!issueModules) return; const draft = failureIssue(currentReport, element.freevideoKind, {lora: githubLora, t}); @@ -645,6 +658,7 @@ export function createErrorPanel(t) { // 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', '提交问题'); + github.toggleAttribute('data-lora', lora); if (generated && typeof window !== 'undefined') loadIssueModules(); if (popup) showFailureDialog(report, t); return report; diff --git a/web/generation_progress.js b/web/generation_progress.js index 31df724..1880718 100644 --- a/web/generation_progress.js +++ b/web/generation_progress.js @@ -1,5 +1,5 @@ // One whole-video estimate and the same five rows in Studio and the node view. -import { saveReport, reportIssue, showReportTip } from './report_issue.js'; +import { saveReport, reportIssue, showReportTip, focusReportTip } from './report_issue.js'; import { issueButton } from './issue_button.js'; const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./generation_progress.css', import.meta.url).href; @@ -403,6 +403,7 @@ export function createDiagnosticReportControl(t, {api = null, fallback = null, t async function downloadReport() { await saveReport(await fetchReport()); } report.onclick = async () => { if (report.disabled || report.hidden) return; + if (tip && report.isConnected && focusReportTip(report.parentElement)) return; problem?.remove(); const current = revision; report.disabled = true; report.textContent = t('Preparing report…', '正在整理报告…'); diff --git a/web/image_editor.css b/web/image_editor.css index a9369f1..e9b0e64 100644 --- a/web/image_editor.css +++ b/web/image_editor.css @@ -94,6 +94,16 @@ button.fv-ie-size:hover{background:var(--fv-hover);color:var(--fv-ink)} .fv-ie-gen-history-use{position:absolute;top:4px;left:4px;width:20px;height:20px;padding:0;border:0;border-radius:10px;background:rgba(0,0,0,.7);color:#fff;font-size:16px;line-height:18px;opacity:0;cursor:pointer} .fv-ie-gen-history-tile:hover>.fv-ie-gen-history-use,.fv-ie-gen-history-tile:hover>.fv-ie-gen-history-delete,.fv-ie-gen-history-use:focus-visible,.fv-ie-gen-history-delete:focus-visible{opacity:1} .fv-ie-gen-history-delete{opacity:0;top:4px;right:4px;width:20px;height:20px;border-radius:10px;font-size:16px;line-height:18px;background:rgba(0,0,0,.7);color:#fff} +.fv-ie-gen-history-foot{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:4px;margin-top:6px} +.fv-ie-gen-history-foot-end{display:flex;gap:4px} +.fv-ie-gen-history-foot .fv-ie-button{min-height:32px;padding:5px 9px} +.fv-ie-gen-history-delete-all{min-width:88px} +.fv-ie-gen-history-foot .fv-ie-gen-history-confirm{background:var(--fv-danger);border-color:var(--fv-danger);color:#fff} +.fv-ie-gen-history-idle{opacity:.5;pointer-events:none} +.fv-ie-gen-history-ticked img{outline:2px solid var(--fv-accent,#3b82f6);outline-offset:-2px} +.fv-ie-gen-history-tick{position:absolute;top:4px;left:4px;width:20px;height:20px;border:2px solid #fff;border-radius:10px;background:rgba(0,0,0,.45);color:#fff;font-size:13px;line-height:16px;text-align:center;pointer-events:none} +.fv-ie-gen-history-on{background:var(--fv-accent,#3b82f6)} +.fv-ie-gen-history-why{position:absolute;left:0;right:0;bottom:0;padding:2px 4px;border-radius:0 0 6px 6px;background:rgba(192,57,43,.9);color:#fff;font-size:var(--fv-micro);text-align:center;pointer-events:none} .fv-image-editor .fv-ie-gen-prompt{position:absolute;inset:0 0 auto;width:100%;height:32px;min-height:32px;max-height:80px;resize:none;padding:3px 9px;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm);background:var(--fv-raised);line-height:24px;overflow-y:auto} .fv-ie-gen-prompt:disabled{opacity:.5} .fv-ie-gen-note{align-self:center;min-width:0;overflow-wrap:anywhere;font-variant-numeric:tabular-nums} diff --git a/web/image_generation.js b/web/image_generation.js index c2dd08b..05ddb97 100644 --- a/web/image_generation.js +++ b/web/image_generation.js @@ -2,7 +2,7 @@ // owns its document/history; this module inserts a result through its commands. import * as commands from './image_commands.js'; import {orientMatrix, rasterKinds, scaleSelection} from './image_editor_engine.js'; -import {saveReport, reportIssue, showReportTip} from './report_issue.js'; +import {saveReport, reportIssue, showReportTip, focusReportTip} from './report_issue.js'; import {issueButton} from './issue_button.js'; import {about} from './request_assets.js'; @@ -106,6 +106,7 @@ export function createImageGenClient(fetchImpl = globalThis.fetch.bind(globalThi // Every picture generated on this machine, newest first; the engine keeps them in a folder. async history() { return (await post('history', {})).items || []; }, async historyDelete(file) { return (await post('history-delete', {file})).items || []; }, + async historyDeleteMany(files) { const answer = await post('history-delete-many', {files}); return {items: answer.items || [], failed: answer.failed || []}; }, historyOpen: () => post('history-open', {}), // The prompts and sizes of a new character's five pictures (the Agent's cards use the same ones). castViews: async description => (await post('cast-views', {description})).views, @@ -238,6 +239,7 @@ export function renderImageFailure(host, {error, report_id, stage, fitted, info} issue.hidden = true; anchor.onclick = async () => { if (anchor.disabled) return; + if (focusReportTip(bar)) return; const restoreFocus = doc.activeElement === anchor; issue.hidden = true; anchor.disabled = true; anchor.textContent = t(...REPORT_LABELS.preparing); @@ -649,42 +651,95 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection summary.title = t('History', '历史'); summary.setAttribute('aria-label', summary.title); const list = el('div', null, 'fv-ie-menu-list'); menu.append(summary, list); + // Choosing several: the ticked files, the reason each one that stayed gives, and the two-step confirm of the delete button. + let selecting = false, armed = null, failed = false; + const chosen = new Set(), stayed = new Map(); + const reasons = {busy: t('In use', '被占用'), missing: t('Already gone', '已不存在'), invalid: t('Not deleted', '未删除')}; + const disarm = () => { clearTimeout(armed); armed = null; }; const draw = () => { list.replaceChildren(); + if (failed) list.append(el('div', t('Could not delete this image', '无法删除这张图片'), 'fv-ie-menu-label')); if (!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'); - const place = button('', item.name, () => { menu.open = false; onPlace?.(item.file, item.name); }, 'fv-ie-gen-history-pick'); + const ticked = chosen.has(item.file); + const place = button('', item.name, () => { + if (!selecting) { menu.open = false; onPlace?.(item.file, item.name); return; } + if (chosen.has(item.file)) chosen.delete(item.file); else chosen.add(item.file); + stayed.delete(item.file); disarm(); draw(); + }, 'fv-ie-gen-history-pick' + (ticked ? ' fv-ie-gen-history-ticked' : '')); + if (selecting) place.setAttribute('aria-pressed', String(ticked)); const picture = el('img'); picture.src = resolve(item.file); picture.alt = item.name; picture.loading = 'lazy'; place.append(picture); tile.append(place); + if (selecting) { + tile.append(el('span', ticked ? '✓' : '', 'fv-ie-gen-history-tick' + (ticked ? ' fv-ie-gen-history-on' : ''))); + if (stayed.has(item.file)) tile.append(el('span', stayed.get(item.file), 'fv-ie-gen-history-why')); + grid.append(tile); + continue; + } if (mode === 'edit' && getDoc().layers.length && limit() > 1) { const use = button('+', t('Use as reference', '用作参考图'), () => { references = addReference(references, {file: item.file, label: item.name}, limit()); menu.open = false; update(); }, 'fv-ie-gen-history-use'); use.setAttribute('aria-label', use.title); tile.append(use); } - let armed = null; + let single = null; const remove = button('×', t('Delete', '删除'), async () => { - if (!armed) { + if (!single) { remove.textContent = t('Delete', '删除'); remove.classList.add('fv-ie-gen-history-confirm'); - armed = setTimeout(() => { armed = null; remove.textContent = '×'; remove.classList.remove('fv-ie-gen-history-confirm'); }, 3000); + single = setTimeout(() => { single = null; remove.textContent = '×'; remove.classList.remove('fv-ie-gen-history-confirm'); }, 3000); return; } - clearTimeout(armed); - try { past = await client.historyDelete(item.file); } catch { return; } + clearTimeout(single); + try { past = await client.historyDelete(item.file); failed = false; } catch { + // Read the folder again: a picture that is gone just leaves the list, one that is still there is named as kept. + failed = true; + try { past = await client.history(); failed = past.some(row => row.file === item.file); } catch { /* keep the list shown */ } + } draw(); }, 'fv-ie-gen-ref-remove fv-ie-gen-history-delete'); remove.setAttribute('aria-label', remove.title); tile.append(remove); grid.append(tile); } list.append(grid); - if (['localhost', '127.0.0.1', '[::1]'].includes(location.hostname)) - list.append(button(t('Open folder', '打开文件夹'), null, () => { void client.historyOpen().catch(() => {}); }, 'fv-ie-menu-item')); + const foot = el('div', null, 'fv-ie-gen-history-foot'); + if (selecting) { + const all = button(chosen.size === past.length ? t('Deselect all', '全不选') : t('Select all', '全选'), null, () => { + if (chosen.size === past.length) chosen.clear(); else for (const row of past) chosen.add(row.file); + stayed.clear(); disarm(); draw(); + }, 'fv-ie-button'); + const del = button(armed ? t('Confirm delete', '确认删除') : t('Delete ' + chosen.size, '删除 ' + chosen.size + ' 张'), null, async () => { + if (!chosen.size) return; + if (!armed) { armed = setTimeout(() => { armed = null; draw(); }, 3000); draw(); return; } + disarm(); + let answer; + try { answer = await client.historyDeleteMany([...chosen]); } catch { answer = null; } + stayed.clear(); failed = false; + if (!answer) { + try { past = await client.history(); } catch { /* keep the list shown */ } + for (const file of chosen) if (past.some(row => row.file === file)) stayed.set(file, reasons.invalid); + } else { + past = answer.items; + for (const row of answer.failed) if (row.reason !== 'missing') stayed.set(row.file, reasons[row.reason] || reasons.invalid); + } + for (const file of [...chosen]) if (!stayed.has(file)) chosen.delete(file); + draw(); + }, 'fv-ie-button fv-ie-danger fv-ie-gen-history-delete-all' + (armed ? ' fv-ie-gen-history-confirm' : '') + (chosen.size ? '' : ' fv-ie-gen-history-idle')); + const cancel = button(t('Cancel', '取消'), null, () => { selecting = false; chosen.clear(); stayed.clear(); disarm(); draw(); }, 'fv-ie-button'); + foot.append(all, el('span', null, 'fv-ie-gen-history-foot-end')); + foot.lastChild.append(cancel, del); + } else { + foot.append(button(t('Select', '选择'), null, () => { selecting = true; failed = false; draw(); }, 'fv-ie-menu-item')); + if (['localhost', '127.0.0.1', '[::1]'].includes(location.hostname)) + foot.append(button(t('Open folder', '打开文件夹'), null, () => { void client.historyOpen().catch(() => {}); }, 'fv-ie-menu-item')); + } + list.append(foot); }; menu.ontoggle = async () => { if (!menu.open) return; try { past = await client.history(); } catch { past = []; } + selecting = false; chosen.clear(); stayed.clear(); disarm(); failed = false; draw(); // Open upward when the list does not fit below, and keep it inside the window. list.classList.remove('fv-ie-gen-history-up'); list.style.right = ''; diff --git a/web/issue_button.js b/web/issue_button.js index 0eeaf64..e43af07 100644 --- a/web/issue_button.js +++ b/web/issue_button.js @@ -2,6 +2,7 @@ // shows one. A click opens the GitHub feedback form with the error sentence already in it; the report // and typed text never travel (the message goes through the same redaction as the failure card's report). import {createErrorReport} from './error_panel.js'; +import {focusReportTip, reportIssue, showReportTip} from './report_issue.js'; const MESSAGE = 300, TAIL = 1500; const ISSUE_FORM = 'https://github.com/FlashML-org/FreeVideo/issues/new'; @@ -47,8 +48,12 @@ function style() { document.head.append(sheet); } -/** The button. `source` is {where, message, kind} or a function returning it when clicked (null: nothing to send). */ -export function issueButton(t, source, {className = '', open = (url, features) => globalThis.open?.(url, '_blank', features)} = {}) { +/** + * The button. `source` is {where, message, kind} or a function returning it when clicked (null: nothing to send). + * `route` ({has, download, fetch, version}) is a run's diagnostic report: while it exists the click saves it and + * the report tip opens the form, instead of the feedback form. + */ +export function issueButton(t, source, {className = '', route = null, open = (url, features) => globalThis.open?.(url, '_blank', features)} = {}) { style(); const button = document.createElement('button'); button.type = 'button'; button.className = `fv-issue-button ${className}`.trim(); @@ -59,6 +64,12 @@ export function issueButton(t, source, {className = '', open = (url, features) = button.onclick = async () => { const value = typeof source === 'function' ? source() : source; if (!value) return; + if (route?.has()) { + if (focusReportTip(button.parentElement)) return; + try { await route.download(); } catch { return; } + showReportTip(button.parentElement, button, t, {mode: 'downloaded', submit: () => reportIssue(route.fetch, route.version(), {save: false})}); + return; + } const ready = await load(); const draft = issueDraft(value, t); // Modules that did not load still leave the blank form reachable. diff --git a/web/prompt_enhance.js b/web/prompt_enhance.js index a12259a..2c46e33 100644 --- a/web/prompt_enhance.js +++ b/web/prompt_enhance.js @@ -121,6 +121,8 @@ export function createPromptEnhancer({node, input, editor, api, context, setText // Available RAM the model needs; the server knows the usual case for this card. let ramBytes = 11 * 2 ** 30; const gib = value => String(Math.round(value / 2 ** 30)); + // Unknown codes keep the server's own reason, without paths, so the user can quote it. + const reasonOf = error => String(error?.message || '').replace(/[A-Za-z]:[\\/][^\s'"]*|\/[^\s'"]+\/[^\s'"]*/g, '').replace(/\s+/g, ' ').trim().slice(0, 120); const errorText = error => ({ busy: t('Finish the current queue before enhancing this prompt.', '请等待当前队列完成后再改写。'), prompt_required: t('Type a prompt first; enhancement rewrites it before generating.', '请先输入提示词,生成前会自动改写。'), @@ -142,7 +144,25 @@ export function createPromptEnhancer({node, input, editor, api, context, setText stale: t('The prompt or references changed. Rewrite again for the new scene.', '原文或素材已改变,请按新内容重新改写。'), cancelled: t('Cancelled. Your original prompt is retained.', '已取消,原文已保留。'), worker_cleanup_failed: t('The rewrite process could not close. Restart FreeVideo before generating.', '改写进程未能退出,请重启 FreeVideo 后再生成。'), - }[error?.message] || t('Could not enhance the prompt. Retry or switch enhancement off to use the original.', '暂时无法改写,请重试,或关闭增强使用原文。')); + }[error?.message] || t(`Could not enhance the prompt${reasonOf(error) ? `: ${reasonOf(error)}` : ''}. Retry or switch enhancement off to use the original.`, + `暂时无法改写${reasonOf(error) ? `。原因:${reasonOf(error)}。` : ','}请重试,或关闭增强使用原文。`)); + // After falling back the original is already in use, so only the reason and the next useful step are shown. + const fallbackText = error => ({ + busy: t('The prompt cannot be rewritten while the queue is running.', '队列未完成时不能改写。'), + setup_required: t('Rewriting is available after FreeVideo installation is complete.', 'FreeVideo 安装完成后才能改写。'), + model_missing: t('Rewriting needs the optional model. Download it to enable rewriting.', '改写需要可选模型,下载后可用。'), + download_failed: t('The optional model download paused. Retry to resume.', '可选模型下载已暂停,重试可继续下载。'), + disk_space: t('Not enough disk space for the optional model.', '磁盘空间不足,无法安装可选模型。'), + ram_space: t(`The rewrite model needs about ${gib(error?.ramBytes || ramBytes)} GiB of available RAM to load.`, `改写模型加载需约 ${gib(error?.ramBytes || ramBytes)} GiB 可用内存。`), + invalid_prompt: t('Rewriting supports prompts of up to 12,000 characters.', '改写只支持不超过 12,000 字符的提示词。'), + invalid_output: t('The rewrite was incomplete.', '改写结果不完整。'), + context_length: t('The scene is too long for the rewrite model.', '内容超出改写模型容量。'), + out_of_memory: t('Not enough memory to rewrite. Close other GPU apps before rewriting again.', '改写时内存不足,关闭其他显卡程序后可再改写。'), + timeout: t('Rewriting took too long.', '改写超时。'), + unsupported_media: errorText(error), too_many_images: errorText(error), stale: errorText(error), + connected_media: errorText(error), audio_media: errorText(error), unreadable_video: errorText(error), + worker_cleanup_failed: errorText(error), + }[error?.message] || (reasonOf(error) ? t(`The rewrite failed: ${reasonOf(error)}.`, `改写失败,原因:${reasonOf(error)}。`) : t('The rewrite failed.', '改写失败。'))); function save(changed = true) { node.properties ||= {}; node.properties.freevideo_prompt_versions = {...versions.value}; @@ -278,7 +298,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(`Generated with the original prompt. ${fallbackText(error)}`, `已用原文生成。${fallbackText(error)}`), 'note'); } return {element, async beforeGenerate() { if (running) { diff --git a/web/report_issue.js b/web/report_issue.js index e93b314..6e7a5f7 100644 --- a/web/report_issue.js +++ b/web/report_issue.js @@ -148,6 +148,18 @@ const reducedMotion = () => typeof matchMedia === 'function' && matchMedia('(pre // saved it: Submit only opens the form). `submit` resolves to the form's // address; `onHide` runs when the user closes the card, not on a redraw; // `animate: false` redraws a card that was already showing. +// The 'downloaded' card already showing under `bar`, if any. A second press on +// Report moves focus to its button instead of saving another copy of the report. +export function focusReportTip(bar) { + const tip = bar?.parentElement?.querySelector(':scope > .fv-report-tip-downloaded:not(.fv-report-tip-leaving)'); + if (!tip) return false; + tip.querySelector('.fv-action-primary, .fv-action')?.focus({preventScroll: true}); + return true; +} +export function hideReportTip(bar) { + bar?.parentElement?.querySelector(':scope > .fv-report-tip:not(.fv-report-tip-leaving)')?.remove(); +} + export function showReportTip(bar, anchor, t, {mode = 'invite', submit, onHide = null, animate = true} = {}) { bar.parentElement.querySelector(':scope > .fv-report-tip:not(.fv-report-tip-leaving)')?.remove(); const tip = el('div', 'fv-report-tip'); tip.setAttribute('role', 'region'); @@ -170,7 +182,9 @@ export function showReportTip(bar, anchor, t, {mode = 'invite', submit, onHide = {duration: 240, easing: 'cubic-bezier(.4,0,.2,1)', fill: 'forwards'}).finished.then(() => tip.remove(), () => tip.remove()); }; const dismiss = () => { closeForGood(); hide(); }; - close.onclick = dismiss; + // Leaving by hand returns focus to the Report button, as Esc does. + const leave = () => { dismiss(); anchor.focus(); }; + close.onclick = leave; function sent(address) { tip.classList.add('fv-report-tip-sent'); anchor.classList.remove('fv-report-attention'); mark.replaceChildren(glyph('done')); @@ -187,13 +201,14 @@ export function showReportTip(bar, anchor, t, {mode = 'invite', submit, onHide = 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'; - done.onclick = dismiss; + done.onclick = leave; row.replaceChildren(done); close.remove(); done.focus({preventScroll: true}); } mark.append(glyph('report')); if (mode === 'downloaded') { + tip.classList.add('fv-report-tip-downloaded'); title.textContent = t('Diagnostic report downloaded', '诊断报告已下载'); } else { title.textContent = t('Share a diagnostic report?', '欢迎提交诊断报告'); @@ -218,7 +233,7 @@ export function showReportTip(bar, anchor, t, {mode = 'invite', submit, onHide = tip.append(card); tip.addEventListener('keydown', event => { if (event.key !== 'Escape') return; - event.preventDefault(); event.stopPropagation(); dismiss(); anchor.focus(); + event.preventDefault(); event.stopPropagation(); leave(); }); if (mode === 'invite') anchor.classList.add('fv-report-attention'); bar.after(tip); diff --git a/web/result_actions.css b/web/result_actions.css index 42313b4..2f0c101 100644 --- a/web/result_actions.css +++ b/web/result_actions.css @@ -31,36 +31,36 @@ .fv-actions .fv-action-danger:hover{border-color:color-mix(in srgb,var(--fv-danger,#f09aa2) 45%,transparent);background:var(--fv-danger-subtle,#30212a);color:var(--fv-danger,#f09aa2)} /* The report card: its own row under the action bar, never over the video. Script opens and closes the row by height, so the picture above eases instead of jumping. */ -.fv-result-links>.fv-report-tip{flex:1 0 100%;box-sizing:border-box;padding-top:6px;overflow:hidden;container-type:inline-size} -.fv-result-links>.fv-report-tip .fv-report-tip-card{position:relative;display:flex;align-items:center;gap:14px;box-sizing:border-box;padding:12px 12px 12px 14px;border:1px solid color-mix(in srgb,var(--fv-accent,#6db8fa) 28%,var(--fv-border,#2a3443));border-radius:14px;background:color-mix(in srgb,var(--fv-accent,#6db8fa) 8%,var(--fv-card,#1a222e));box-shadow:inset 0 1px 0 #ffffff0a;color:var(--fv-ink,#eee);font:13px/1.55 var(--fv-font,system-ui);text-align:left} -.fv-result-links>.fv-report-tip .fv-report-tip-card::before{content:'';position:absolute;top:-6px;left:calc(var(--fv-tip-notch,50%) - 5px);width:10px;height:10px;box-sizing:border-box;border:solid color-mix(in srgb,var(--fv-accent,#6db8fa) 28%,var(--fv-border,#2a3443));border-width:1px 0 0 1px;border-radius:2px 0 0 0;background:color-mix(in srgb,var(--fv-accent,#6db8fa) 8%,var(--fv-card,#1a222e));transform:rotate(45deg)} -.fv-result-links>.fv-report-tip .fv-report-tip-glyph,.fv-result-links>.fv-report-tip .fv-report-tip-lock{display:inline-flex;flex:none} -.fv-result-links>.fv-report-tip .fv-report-tip-glyph svg,.fv-result-links>.fv-report-tip .fv-report-tip-lock svg{width:100%;height:100%} -.fv-result-links>.fv-report-tip .fv-report-tip-mark{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:11px;background:linear-gradient(150deg,color-mix(in srgb,var(--fv-accent,#6db8fa) 30%,transparent),color-mix(in srgb,var(--fv-accent,#6db8fa) 10%,transparent));color:var(--fv-accent,#6db8fa);transition:background .2s,color .2s} -.fv-result-links>.fv-report-tip .fv-report-tip-mark .fv-report-tip-glyph{width:18px;height:18px} -.fv-result-links>.fv-report-tip.fv-report-tip-sent .fv-report-tip-mark{background:linear-gradient(150deg,color-mix(in srgb,var(--fv-success,#76d9b3) 30%,transparent),color-mix(in srgb,var(--fv-success,#76d9b3) 10%,transparent));color:var(--fv-success,#76d9b3)} -.fv-result-links>.fv-report-tip .fv-report-tip-text{flex:1;min-width:0;display:grid;gap:2px} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip{flex:1 0 100%;box-sizing:border-box;padding-top:6px;overflow:hidden;container-type:inline-size} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-card{position:relative;display:flex;align-items:center;gap:14px;box-sizing:border-box;padding:12px 12px 12px 14px;border:1px solid color-mix(in srgb,var(--fv-accent,#6db8fa) 28%,var(--fv-border,#2a3443));border-radius:14px;background:color-mix(in srgb,var(--fv-accent,#6db8fa) 8%,var(--fv-card,#1a222e));box-shadow:inset 0 1px 0 #ffffff0a;color:var(--fv-ink,#eee);font:13px/1.55 var(--fv-font,system-ui);text-align:left} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-card::before{content:'';position:absolute;top:-6px;left:calc(var(--fv-tip-notch,50%) - 5px);width:10px;height:10px;box-sizing:border-box;border:solid color-mix(in srgb,var(--fv-accent,#6db8fa) 28%,var(--fv-border,#2a3443));border-width:1px 0 0 1px;border-radius:2px 0 0 0;background:color-mix(in srgb,var(--fv-accent,#6db8fa) 8%,var(--fv-card,#1a222e));transform:rotate(45deg)} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-glyph,:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-lock{display:inline-flex;flex:none} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-glyph svg,:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-lock svg{width:100%;height:100%} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-mark{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:11px;background:linear-gradient(150deg,color-mix(in srgb,var(--fv-accent,#6db8fa) 30%,transparent),color-mix(in srgb,var(--fv-accent,#6db8fa) 10%,transparent));color:var(--fv-accent,#6db8fa);transition:background .2s,color .2s} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-mark .fv-report-tip-glyph{width:18px;height:18px} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip.fv-report-tip-sent .fv-report-tip-mark{background:linear-gradient(150deg,color-mix(in srgb,var(--fv-success,#76d9b3) 30%,transparent),color-mix(in srgb,var(--fv-success,#76d9b3) 10%,transparent));color:var(--fv-success,#76d9b3)} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-text{flex:1;min-width:0;display:grid;gap:2px} /* Chinese breaks at its punctuation when it can; two lines come out even. */ -.fv-result-links>.fv-report-tip .fv-report-tip-title,.fv-result-links>.fv-report-tip .fv-report-tip-lead,.fv-result-links>.fv-report-tip .fv-report-tip-note{text-wrap:balance;word-break:keep-all;overflow-wrap:anywhere} -.fv-result-links>.fv-report-tip .fv-report-tip-title{font-size:13.5px;font-weight:600;line-height:1.45;color:var(--fv-ink,#eee)} -.fv-result-links>.fv-report-tip .fv-report-tip-lead{margin:0;font-size:13px;line-height:1.55;color:color-mix(in srgb,var(--fv-ink,#eee) 86%,var(--fv-muted,#a5b3c6))} -.fv-result-links>.fv-report-tip .fv-report-tip-note{display:flex;align-items:flex-start;gap:5px;margin-top:2px;font-size:12px;line-height:1.45;color:var(--fv-muted,#a5b3c6)} -.fv-result-links>.fv-report-tip .fv-report-tip-lock{width:12px;height:12px;margin-top:2.5px;opacity:.85} -.fv-result-links>.fv-report-tip .fv-report-tip-note a{display:inline;height:auto;min-height:0;margin:0;padding:0;border:0;background:none;box-shadow:none;color:var(--fv-accent,#6db8fa);font:inherit;text-decoration:none} -.fv-result-links>.fv-report-tip .fv-report-tip-note a:hover{text-decoration:underline} -.fv-result-links>.fv-report-tip .fv-report-tip-actions{flex:none;gap:6px} -.fv-result-links>.fv-report-tip .fv-report-tip-actions .fv-action{height:34px;min-height:34px;padding:0 14px;gap:6px} -.fv-result-links>.fv-report-tip .fv-report-tip-actions .fv-action .fv-report-tip-glyph{width:13px;height:13px;opacity:.85} -.fv-result-links>.fv-report-tip .fv-report-tip-close{flex:none;display:grid;place-items:center;width:30px;height:30px;margin:0;padding:0;border:0;border-radius:9px;background:none;color:var(--fv-muted,#a5b3c6);cursor:pointer;box-shadow:none} -.fv-result-links>.fv-report-tip .fv-report-tip-close:hover{background:color-mix(in srgb,var(--fv-ink,#eee) 8%,transparent);color:var(--fv-ink,#eee)} -.fv-result-links>.fv-report-tip .fv-report-tip-close:focus-visible{outline:2px solid color-mix(in srgb,var(--fv-accent,#6db8fa) 70%,transparent);outline-offset:1px} -.fv-result-links>.fv-report-tip .fv-report-tip-close .fv-report-tip-glyph{width:14px;height:14px} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-title,:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-lead,:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-note{text-wrap:balance;word-break:keep-all;overflow-wrap:anywhere} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-title{font-size:13.5px;font-weight:600;line-height:1.45;color:var(--fv-ink,#eee)} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-lead{margin:0;font-size:13px;line-height:1.55;color:color-mix(in srgb,var(--fv-ink,#eee) 86%,var(--fv-muted,#a5b3c6))} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-note{display:flex;align-items:flex-start;gap:5px;margin-top:2px;font-size:12px;line-height:1.45;color:var(--fv-muted,#a5b3c6)} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-lock{width:12px;height:12px;margin-top:2.5px;opacity:.85} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-note a{display:inline;height:auto;min-height:0;margin:0;padding:0;border:0;background:none;box-shadow:none;color:var(--fv-accent,#6db8fa);font:inherit;text-decoration:none} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-note a:hover{text-decoration:underline} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-actions{flex:none;gap:6px} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-actions .fv-action{height:34px;min-height:34px;padding:0 14px;gap:6px} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-actions .fv-action .fv-report-tip-glyph{width:13px;height:13px;opacity:.85} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-close{flex:none;display:grid;place-items:center;width:30px;height:30px;margin:0;padding:0;border:0;border-radius:9px;background:none;color:var(--fv-muted,#a5b3c6);cursor:pointer;box-shadow:none} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-close:hover{background:color-mix(in srgb,var(--fv-ink,#eee) 8%,transparent);color:var(--fv-ink,#eee)} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-close:focus-visible{outline:2px solid color-mix(in srgb,var(--fv-accent,#6db8fa) 70%,transparent);outline-offset:1px} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-close .fv-report-tip-glyph{width:14px;height:14px} /* While the invitation shows, a dot on the Diagnostic report button ties the two together. */ .fv-actions .fv-action-report{position:relative} .fv-actions .fv-action-report.fv-report-attention::after{content:'';position:absolute;top:-3px;right:-3px;width:9px;height:9px;border-radius:50%;background:var(--fv-accent,#6db8fa);box-shadow:0 0 0 2px var(--fv-bg,#111720)} /* A narrow card keeps the text on full-width lines and moves the button below it. */ -@container (max-width:640px){.fv-result-links>.fv-report-tip .fv-report-tip-card{flex-wrap:wrap;align-items:flex-start;padding-right:40px}.fv-result-links>.fv-report-tip .fv-report-tip-text{flex:1 1 calc(100% - 50px)}.fv-result-links>.fv-report-tip .fv-report-tip-actions{margin-left:50px}.fv-result-links>.fv-report-tip .fv-report-tip-close{position:absolute;top:8px;right:8px}} -@media(prefers-reduced-motion:reduce){.fv-result-links>.fv-report-tip .fv-report-tip-mark{transition:none}} +@container (max-width:640px){:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-card{flex-wrap:wrap;align-items:flex-start;padding-right:40px}:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-text{flex:1 1 calc(100% - 50px)}:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-actions{margin-left:50px}:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-close{position:absolute;top:8px;right:8px}} +@media(prefers-reduced-motion:reduce){:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-mark{transition:none}} @keyframes fv-menu-enter{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}} @media(prefers-reduced-motion:reduce){.fv-actions *{transition:none!important;animation:none!important}} .fv-actions[data-unavailable=true] :is(a.fv-action[download],.fv-action-extend,.fv-share-trigger){opacity:.45;cursor:default;pointer-events:none} diff --git a/web/studio.js b/web/studio.js index 2f33f86..fde4fb0 100644 --- a/web/studio.js +++ b/web/studio.js @@ -12,7 +12,7 @@ import { viewSwitch, viewChanged, agentTabReady, openAutopilot } from './view_na import { agentInterfaceState } from './agent_connect.js'; import { createUpdateNotice, createVersionInfo, productVersion } from './updates.js'; import { createPortNotice } from './port_notice.js'; -import { reportIssue, reportTipClosed, showReportTip } from './report_issue.js'; +import { focusReportTip, hideReportTip, reportIssue, reportTipClosed, showReportTip } from './report_issue.js'; import { issueButton } from './issue_button.js'; import { createPreviewScene } from './preview_scene.js?v=20260929-swell'; import { animateDetails, closeDialog } from './motion.js'; @@ -1211,7 +1211,7 @@ export function openStudio(node, {extendFrom = null} = {}) { showReportTip(bar, anchor, t, {mode, animate, onHide: () => { reportTipFor = null; }, submit: () => reportIssue(progress.fetchReport, productVersion(), {save: mode === 'invite'})}); } - async function downloadReport() { await progress.downloadReport(); reportCard('downloaded'); } + async function downloadReport() { if (focusReportTip(links.querySelector('.fv-actions'))) return; await progress.downloadReport(); reportCard('downloaded'); } function placeReportTip(delay) { clearTimeout(reportTipTimer); reportTipTimer = setTimeout(() => { @@ -1268,9 +1268,15 @@ export function openStudio(node, {extendFrom = null} = {}) { } const status = el('div', '', 'fv-status'); status.setAttribute('role', 'status'); // A fault in the status line carries the report button; the next text written to the line removes it. - const statusIssue = issueButton(t, () => ({where: ['Generate', '生成'], message: status.firstChild?.textContent}), {className: 'fv-status-issue'}); + // The run's diagnostic report: errors beside it go the report route instead of the feedback form. + const reportRoute = {has: () => progress.hasReport(), download: () => progress.downloadReport(), fetch: progress.fetchReport, version: productVersion}; + const statusIssue = issueButton(t, () => ({where: ['Generate', '生成'], message: status.firstChild?.textContent}), {className: 'fv-status-issue', route: reportRoute}); function fault(text) { status.dataset.error = 'true'; status.textContent = text; status.append(statusIssue); } const stats = el('div', null, 'fv-stats'), budget = el('div', '', 'fv-budget'), links = el('div', null, 'fv-result-links'); + // New text in the status line replaces the report card under the result. + const statusWatch = new MutationObserver(() => { if (!status.textContent.trim()) return; hideReportTip(links.querySelector('.fv-actions')); hideReportTip(status); }); + statusWatch.observe(status, {childList: true, characterData: true, subtree: true}); + cleanup.push(() => statusWatch.disconnect()); stats.hidden = true; const metrics = [], metricLabels = []; for (const label of [t('Generation', '生成用时'), t('Total time', '总用时'), t('Peak VRAM', '最高显存'), t('Peak RAM', '最高内存')]) { const box = el('div', null, 'fv-stat'), n = el('strong', '—'), caption = el('span', label); box.append(n, caption); stats.append(box); metrics.push(n); metricLabels.push(caption); } @@ -1303,7 +1309,7 @@ export function openStudio(node, {extendFrom = null} = {}) { const sharedGpuNote = el('div', '', 'fv-note'); sharedGpuNote.hidden = true; const encoderNote = el('div', '', 'fv-note'); encoderNote.hidden = true; output.append(status, reuseRow, trimNote, memoryNote, thermalNote, sharedGpuNote, encoderNote, links, stats, budget, progress.stall, progress.report, prewarm); - const failure = createErrorPanel(t); output.append(failure.element); + const failure = createErrorPanel(t, reportRoute); output.append(failure.element); // With the picture, the result's actions and numbers form one block, centred below the header. output.append(el('div', null, 'fv-preview-end')); if (node.freevideoFailure) failure.show(node.freevideoFailureReport || node.freevideoFailure, false);