diff --git a/MANIFEST.in b/MANIFEST.in index d3d40bf7..350e1442 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,6 +6,7 @@ recursive-include example_workflows *.json recursive-include web *.js *.css include web/vendor/three/LICENSE include web/assets/*.svg web/assets/*.png web/assets/*.webp +recursive-include web/assets/image *.webp recursive-include web/howto *.png recursive-include web/fonts *.woff2 recursive-include web/tutorial *.webp *.mp4 diff --git a/docs/community-results.md b/docs/community-results.md index 0d245d3d..1d58c096 100644 --- a/docs/community-results.md +++ b/docs/community-results.md @@ -139,7 +139,6 @@ Thanks to the community members who sent diagnostic reports. FreeVideo already r | 3070 Laptop | 8 GB | 64 GB | 960×544 | 5 s | Light | 0.4.33 | 260 s | 0.4.33 | Already optimal | | 3070 | 8 GB | 64 GB | 736×736 | 10 s | Light | 0.4.35 | 471 s | 0.4.35 | Already optimal | | 5060 | 8 GB | 64 GB | 1088×1920 | 5.9 s | Medium | 0.4.31 | 1259 s | 0.4.31 | Already optimal¹² | -| 5060 | 8 GB | 64 GB | 960×1664 | 11.5 s | Light⁵ | 0.4.38 | 1035 s | 0.4.38 | Already optimal | - Time: end to end, from submission until the video is saved. - After: the estimated time, derived from the report's stage times and from complete planned runs on our test machines (RTX 3090 and RTX 5060 Ti on Windows, RTX PRO 6000 on Linux) under the reporting machine's VRAM and RAM limits, not from reruns on that machine. diff --git a/docs/zh-CN/community-results.md b/docs/zh-CN/community-results.md index be0c59f6..d08802d3 100644 --- a/docs/zh-CN/community-results.md +++ b/docs/zh-CN/community-results.md @@ -139,7 +139,6 @@ | 3070 笔⁠记⁠本 | 8 GB | 64 GB | 960×544 | 5 s | 轻⁠量 | 0.4.33 | 260 s | 0.4.33 | 已⁠是⁠最⁠优 | | 3070 | 8 GB | 64 GB | 736×736 | 10 s | 轻⁠量 | 0.4.35 | 471 s | 0.4.35 | 已⁠是⁠最⁠优 | | 5060 | 8 GB | 64 GB | 1088×1920 | 5.9 s | 标⁠准 | 0.4.31 | 1259 s | 0.4.31 | 已⁠是⁠最⁠优¹² | -| 5060 | 8 GB | 64 GB | 960×1664 | 11.5 s | 轻⁠量⁵ | 0.4.38 | 1035 s | 0.4.38 | 已⁠是⁠最⁠优 | - 用时:从提交到视频保存完成的端到端用时。 - 优化后:预估用时,根据报告中的分段用时,结合测试机(Windows RTX 3090 和 RTX 5060 Ti、Linux RTX PRO 6000)在报告机器的显存和内存上限下运行完整规划的实测推算,不是在报告机器上重跑。 diff --git a/freevideo_engine/agent/errors.py b/freevideo_engine/agent/errors.py index 92ee884c..18b72b7b 100644 --- a/freevideo_engine/agent/errors.py +++ b/freevideo_engine/agent/errors.py @@ -7,6 +7,7 @@ import math import re import uuid +from ..image_gen.failure import STAGES as IMAGE_STAGES from ..image_gen.loras import ERRORS as LORA_ERRORS, MESSAGE_EN as LORA_MESSAGE_EN, MESSAGE_ZH as LORA_MESSAGE_ZH REQUEST_ID = ContextVar('agent_request_id', default=None) @@ -149,7 +150,7 @@ 'small_memory': ('This Mac has less than 16 GB of memory and cannot generate pictures.', '这台 Mac 的内存不足 16 GB,无法生成图片。'), 'no_memory': ('Not enough GPU memory, even at the smallest size. Close other apps using the GPU, then try again.', - '显存不够,最小尺寸也没能生成。关掉其他占用显卡的程序后再试。'), + '显存不足,最小尺寸也无法生成。请关闭其他占用显卡的程序后重试。'), 'busy_video': ('A video is generating. Try again when it finishes.', '视频正在生成,完成后再试。'), 'pack_missing': ('Download "Image generation models" in the launcher first.', '请先在启动器里下载“图像生成模型”。'), @@ -297,8 +298,8 @@ def safe_details(value): 'lan_unavailable': '局域网设置需要启动器安装记录', 'capabilities_computing': '正在计算这台电脑的生成能力,稍后再试', 'out_of_memory': '生成时显存或内存不足', - 'insufficient_memory': '可用显存或内存不够生成这段内容', - 'no_memory': '可用显存或内存不够生成这张图片', + 'insufficient_memory': '可用显存或内存不足,无法生成这段内容', + 'no_memory': '可用显存或内存不足,无法生成这张图片', 'ram_space': '加载模型时系统内存不足', 'model_missing': '需要的模型还没有安装好', 'pack_missing': '图片模型还没有安装好', @@ -466,7 +467,7 @@ def with_reason(error): def build(code, *, details=None, request=None, validation=False, message=None, next_action=None, action_required=True, retry_after_seconds=None, owner=None, kind=None, stage='request', resumable=True, - report_id=None, memory=None, other_usage=_UNREAD_USAGE): + report_id=None, memory=None, image_stage=None, other_usage=_UNREAD_USAGE): code = code if code in ERRORS else 'internal_error' _, retryable, default_message, _ = ERRORS[code] action = recovery(code, kind=kind, stage=stage, resumable=resumable) @@ -482,6 +483,8 @@ def build(code, *, details=None, request=None, validation=False, message=None, n result.update(image_failure(code, report_id)) if code == 'no_memory' and memory == 'host': result['memory'] = 'host' + if image_stage in IMAGE_STAGES: + result['stage'] = image_stage if other_usage is not _UNREAD_USAGE: result['other_usage'] = other_usage result = capture_other_usage(result) diff --git a/freevideo_engine/agent/jobs.py b/freevideo_engine/agent/jobs.py index 1e9574bc..134446b3 100644 --- a/freevideo_engine/agent/jobs.py +++ b/freevideo_engine/agent/jobs.py @@ -713,7 +713,7 @@ def finish(self, row, status, *, result=None, error=None, save=True): error = errors.build(code, details=error.get('details'), request=row.get('request_id'), action_required=code != 'cancelled', kind=row['kind'], stage='terminal', resumable=not row['spec'].get('refine_from'), - report_id=error.get('report_id'), memory=error.get('memory'), + report_id=error.get('report_id'), memory=error.get('memory'), image_stage=error.get('stage'), other_usage=error.get('other_usage')) if row['kind'] == 'image': reason_zh, reason_en = error['error_zh'], error['error_en'] @@ -1264,6 +1264,7 @@ def view(self, row, *, detail=True): result['error'] = errors.build(result['error']['code'], kind='image', stage='terminal', details=result['error'].get('details'), request=row.get('request_id'), action_required=result['error']['code'] != 'cancelled', report_id=row['error'].get('report_id'), + memory=row['error'].get('memory'), image_stage=row['error'].get('stage'), other_usage=row['error'].get('other_usage')) else: result['error'] = dict(result['error'], **errors.reasons(row['error'])) @@ -2168,7 +2169,7 @@ def poll_image(self, row): code = code or 'generation_failed' if not self.wait_image(row, code): self.finish(row, 'failed', error=dict(code=code, report_id=state.get('report_id'), memory=state.get('memory'), - other_usage=state.get('other_usage'))) + stage=state.get('stage'), other_usage=state.get('other_usage'))) # Local VLM ----------------------------------------------------------------------------------------- def start_vlm(self, row): diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index 36e71d89..a555f84f 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -1136,6 +1136,32 @@ def unpack(archive, destination): tar.extractall(io_path(destination)) +def prepare_uv(system, root, uv_spec, networking, env, fetch): + """Put the pinned uv under root/tools and return its path: from a verified local copy when one exists, else downloaded. + + The installer and the launcher's source update share it, so a missing uv is restored the same way in both. + """ + root = Path(root) + if system == 'Windows': + from .uv_bootstrap import prepare_windows + return prepare_windows(root, uv_spec, networking, env, fetch) + if system == 'Darwin': + from . import macos_bootstrap + return macos_bootstrap.prepare_uv(root, networking, env, fetch) + archive = root / 'downloads' / 'uv.tar.gz' + from .system import bootstrap_root + previous = bootstrap_root(root) / ('uv-' + uv_spec['version'] + '.tar.gz') + if not archive.exists() and previous.is_file() and digest(previous) == uv_spec['sha256']: + archive.parent.mkdir(parents=True, exist_ok=True) + try: + os.link(previous, archive) + except OSError: + shutil.copyfile(previous, archive) + fetch(uv_spec['url'], archive, uv_spec['sha256']) + unpack_folder(archive, root / 'tools', 'uv-x86_64-unknown-linux-gnu') + return root / 'tools' / 'uv-x86_64-unknown-linux-gnu' / 'uv' + + def unpack_folder(archive, destination, name): """Unpack the archive's top-level folder `name` to destination/name, replacing what is there. @@ -1265,9 +1291,9 @@ def initialize(self, value, ui): self.system = value['inventory'].get('hardware', {}).get('system', platform.system()) self.pythons = role_pythons(self.root, self.layout, self.system) self.root.mkdir(parents=True, exist_ok=True) - self.lock = self.locks.enter_context(runtime_lock(self.root / 'setup.lock', inherit=False)) + self.lock = self.locks.enter_context(runtime_lock(self.root / 'setup.lock', inherit=False, wait_seconds=3)) lock_path = os.environ.get('FREEVIDEO_LOCK_PATH', str(self.root / 'engine.lock')) - self.runtime_fd = self.locks.enter_context(runtime_lock(lock_path)) + self.runtime_fd = self.locks.enter_context(runtime_lock(lock_path, wait_seconds=3)) self.run_dir = self.root / 'setup-runs' / (time.strftime('%Y%m%dT%H%M%SZ', time.gmtime()) + '-' + str(os.getpid())) self.run_dir.mkdir(parents=True, exist_ok=False) save(self.run_dir / 'plan.json', value) @@ -2264,24 +2290,7 @@ def record_kernel_validation(self, results, kernel_report): save(sage_manifest, sage_record) def prepare_tools(self): - uv_spec = self.versions['uv'] - if self.system == 'Windows': - from .uv_bootstrap import prepare_windows - uv = prepare_windows(self.root, uv_spec, self.network, self.env, self.fetch) - else: - archive = self.root / 'downloads' / 'uv.tar.gz' - from .system import bootstrap_root - previous = bootstrap_root(self.root) / ('uv-' + uv_spec['version'] + '.tar.gz') - if not archive.exists() and previous.is_file() and digest(previous) == uv_spec['sha256']: - archive.parent.mkdir(parents=True, exist_ok=True) - try: - os.link(previous, archive) - except OSError: - shutil.copyfile(previous, archive) - self.fetch(uv_spec['url'], archive, uv_spec['sha256']) - unpack_folder(archive, self.root / 'tools', 'uv-x86_64-unknown-linux-gnu') - uv = self.root / 'tools' / 'uv-x86_64-unknown-linux-gnu' / 'uv' - return uv + return prepare_uv(self.system, self.root, self.versions['uv'], self.network, self.env, self.fetch) def execute(self): if self.int8_check_first: @@ -2618,10 +2627,12 @@ def curl_progress(message): except BlockingIOError: prepare_end(ui, False) print('Setup, testing or generation is using this installation/GPU lock. Retry after it finishes.', file=sys.stderr) - from .locking import lock_holders - owners = lock_holders([Path(value['root']) / 'setup.lock', os.environ.get('FREEVIDEO_LOCK_PATH', str(Path(value['root']) / 'engine.lock'))]) - if owners: - print('Processes with open lock handles: %s. Stop the old task before retrying; do not delete lock files.' % ', '.join(map(str, owners)), file=sys.stderr) + from .locking import describe_lock_holders + try: + for line in describe_lock_holders([Path(value['root']) / 'setup.lock', os.environ.get('FREEVIDEO_LOCK_PATH', str(Path(value['root']) / 'engine.lock'))]): + print(line, file=sys.stderr) + except Exception: + pass return 1 except (OSError, ValueError) as error: prepare_end(ui, False) diff --git a/freevideo_engine/chain_runner.py b/freevideo_engine/chain_runner.py index 50c74d70..b1c647fa 100644 --- a/freevideo_engine/chain_runner.py +++ b/freevideo_engine/chain_runner.py @@ -726,6 +726,10 @@ def _seams_kept(self, snapshot, reasons=None): kept += 1 else: reason = self._lead_failure(identity, row['index'], row['output'], neighbour['output'], True) + told = row['continuation'].get('record_not_kept') if isinstance(row['continuation'], dict) else None + if reason == 'no_record' and isinstance(told, str): + # The engine already said when the block finished that it could not keep this record. + reason = 'not_saved:' + told[:64] if reason is None: kept += 1 elif reasons is not None: diff --git a/freevideo_engine/cli.py b/freevideo_engine/cli.py index 5c9eec28..ff8d76b4 100644 --- a/freevideo_engine/cli.py +++ b/freevideo_engine/cli.py @@ -113,6 +113,9 @@ def main(): help='With --continue-from: also give the model the first frame of the whole film as a ' 'reference picture. Reduces drift over many parts; holds framing and motion close to ' 'the opening, so leave it off when later parts should move away from it') + generate.add_argument('--ten-bit', choices=('h264', 'hevc'), + help='Also write a 10-bit copy next to the MP4 (name ends -10bit), from the decoder\'s own ' + 'frames. The usual 8-bit file is unchanged. Direct generation only.') generate.add_argument('--hide-tail', action='store_true', help='Generate and discard 17 extra frames for dialogue segments.') generate.add_argument('--voice-reference', action='store_true', diff --git a/freevideo_engine/comfy_bridge.py b/freevideo_engine/comfy_bridge.py index d87b2541..7638797e 100644 --- a/freevideo_engine/comfy_bridge.py +++ b/freevideo_engine/comfy_bridge.py @@ -154,6 +154,9 @@ def _engine_version(): # Names emitted by both decoders; forecast keys use the same suffixes. +WORK_LABELS = {'encoding': 'Encoding text and images', 'load': 'Preparing offloaded weight storage', + 'decode': 'Decoding the video'} +WORK_UNITS = ('bytes', 'layers', 'files', 'frames', 'chunks') _DECODE_PARTS = {'Loading video VAE': 'vae_load', 'Decoding the video': 'video_decode', 'Loading and decoding audio': 'audio_load_decode', 'Saving MP4 and audio': 'encode'} @@ -1124,6 +1127,15 @@ def progress_message(event): return dict(label='Decoding the video', timing_phase='decode', phase='decode', stage='video', done=event.get('done'), total=event.get('total'), unit='frames', elapsed_seconds=event.get('elapsed_seconds')) + if name == 'work_progress': + # Real completed counts inside a long step that otherwise prints nothing. + group, stage = event.get('group'), event.get('stage') + done, total, unit = event.get('done'), event.get('total'), event.get('unit') + if (group not in WORK_LABELS or type(done) is not int or type(total) is not int + or not 0 <= done <= total or total < 1 or unit not in WORK_UNITS): + return None + return dict(label=WORK_LABELS[group], timing_phase=group, phase=group, stage=stage, + done=done, total=total, unit=unit, elapsed_seconds=event.get('elapsed_seconds')) if name == 'media_encode_phase': return {'label': str(event.get('phase', 'Encoding input media')), 'timing_phase': 'encoding'} if name == 'media_encode_progress': @@ -1419,6 +1431,9 @@ def check_ending(): command += ['--' + field.replace('_', '-'), str(reserve)] if preview: command.append('--preview') + elif continue_from is None: + from .output_settings import command_option + command += command_option(root) if hide_tail and '--hide-tail' not in command: command.append('--hide-tail') state = {'status': 'starting', 'geometry': canvas, 'seed': seed, 'installation': str(root), @@ -1718,6 +1733,13 @@ def asset_progress(message): # The ending was encoded by the engine, which is where the real counters come from. state['continuation'] = dict(receipt) state['continuation']['audio_lead_in_samples'] = receipt.get('audio_lead_in_samples', 0) + # When the engine could not keep this block's seam record, say why, so a join that + # later finds no record can name it. Only a short word, never a path or a text. + stored = engine.get('continuation_store') + if isinstance(stored, dict) and stored.get('saved') is False: + why = stored.get('reason') or stored.get('error') + if isinstance(why, str) and why.isascii() and why.isidentifier() and len(why) <= 64: + state['continuation']['record_not_kept'] = why if comfy_metadata: # Dropping the video on the canvas restores this graph, as with ComfyUI's own video nodes. # Written before the result cache records the file's size and hash. diff --git a/freevideo_engine/comfy_continuation.py b/freevideo_engine/comfy_continuation.py index 055bf119..4d7172eb 100644 --- a/freevideo_engine/comfy_continuation.py +++ b/freevideo_engine/comfy_continuation.py @@ -126,9 +126,18 @@ def audio_lead_in(video, previous=None): return audio_lead_in_checked(video, previous)[0] +def _record_file_exists(path, directory): + """Whether the record file of this video is on disk, however it reads. Only asked after a failed read.""" + try: + from .continuation_store import _path, video_id + return _path(video_id(path), directory).is_file() + except Exception: + return False + + def audio_lead_in_checked(video, previous=None): """(the seam audio, None) as audio_lead_in reads it, or (None, why it could not be read): no_video, - no_record, old_record, source_changed or read_failed:. Never a path or a text.""" + no_record, unreadable_record, old_record, source_changed or read_failed:. Never a path or a text.""" try: import numpy as np from av import FFmpegError @@ -161,7 +170,8 @@ def located(name): return None, 'no_video' value = load(path, directory=directory) if value is None: - return None, 'no_record' + # load() also answers None for a record file that is there but fails to read or check. + return None, 'unreadable_record' if _record_file_exists(path, directory) else 'no_record' if 'audio_lead_in' not in value: return None, 'old_record' if previous is not None: diff --git a/freevideo_engine/comfy_launcher_api.py b/freevideo_engine/comfy_launcher_api.py index 9d1a2ec7..952f5acc 100644 --- a/freevideo_engine/comfy_launcher_api.py +++ b/freevideo_engine/comfy_launcher_api.py @@ -72,15 +72,35 @@ def request_repair(text, server=None): def launcher_view(page, server, now=None): - """What the launcher reads: the page check and any repair a failed run asked for.""" + """What the launcher reads: the page check, any repair a failed run asked for, and the page's + request to add the video model.""" server._freevideo_launcher_seen = time.monotonic() if now is None else now value = dict(page) repair = getattr(server, '_freevideo_repair', None) if isinstance(repair, dict): value['repair'] = repair + add_video = getattr(server, '_freevideo_add_video', None) + if isinstance(add_video, dict): + value['add_video'] = add_video return value +def video_model_installed(engine_root): + """False only for an installation set up with MiniMax H3 Image alone (machine.json says so).""" + try: + machine = json.loads((Path(engine_root) / 'machine.json').read_text(encoding='utf-8')) + except (OSError, ValueError, TypeError): + return True + return not (isinstance(machine, dict) and machine.get('video_model') is False) + + +def request_add_video(server, now=None): + """The page asks the launcher reading this server to open its installation with the video model. + Returns whether a launcher is reading, so the page can say where to continue.""" + server._freevideo_add_video = dict(at=time.time() if now is None else now) + return launcher_watching(server) + + def page_notice(server): """Text for the generating node when no FreeVideo page has reported to this ComfyUI. @@ -181,3 +201,15 @@ async def page_check(request): @server.routes.get('/freevideo/launcher/ui') async def page_state(request): return web.json_response(launcher_view(page, server)) + + @server.routes.get('/freevideo/launcher/models') + async def installed_models(request): + return web.json_response(dict(video=video_model_installed(info['engine_root']), launcher=launcher_watching(server)), + headers={'Cache-Control': 'no-store'}) + + @server.routes.post('/freevideo/launcher/add-video') + async def add_video(request): + # A JSON body: a plain cross-site form cannot send one, so another site cannot open the installation. + if request.content_type != 'application/json': + return web.json_response(dict(error='Expected application/json'), status=415) + return web.json_response(dict(launcher=request_add_video(server))) diff --git a/freevideo_engine/comfy_launcher_runtime.py b/freevideo_engine/comfy_launcher_runtime.py index c4a13783..37b39e0c 100644 --- a/freevideo_engine/comfy_launcher_runtime.py +++ b/freevideo_engine/comfy_launcher_runtime.py @@ -32,7 +32,7 @@ from .desktop_runtime import materialize_source, source_release from .monitoring import save from .long_paths import io_path, resolved, plain, plain_text, plain_error, plain_errors -from . import engine_binding, processes, lan_password +from . import engine_binding, processes, lan_password, uv_restore PROTOCOL = 1 TEMPLATE = 'FreeVideo-All-in-One.json' @@ -1166,6 +1166,9 @@ def _inspect(self, values): from .sampling_assets import installed as sampling_installed ready = (not values.get('sampling_caches') or machine.get('video_model') is False or sampling_installed(machine)) + # An installation without the video model sets up again when the video model is asked for. + if machine.get('video_model') is False and values.get('only') != 'image': + ready = False except (OSError, ValueError, KeyError): pass self.selection = dict(descriptor, engine=str(engine), source=str(source), url=url, ready=ready, @@ -1186,7 +1189,7 @@ def _inspect(self, values): extra = [p for p in dict.fromkeys(extra) if p.strip() and io_path(Path(p).expanduser()).is_dir()] self.setup.inspect(dict(root=str(engine), extra_libraries=extra, copy=False, sampling_caches=bool(values.get('sampling_caches')), prepared_format=values.get('prepared_format'), - **({'download_list': True} if checked else {}), + **({'download_list': True} if checked else {}), **({'only': 'image'} if values.get('only') == 'image' else {}), frontend=dict(root=descriptor['root'], separate=descriptor['separate'], download=fresh, repair_python=host['python'] if repair_comfy else None))) row = self._wait_setup() @@ -1240,7 +1243,12 @@ def _install(self, accepted): raise RuntimeError(ENGINE_UPDATE_WAITS + '. Try again after it finishes; the job is left running.') self._stop_server_for_setup(selected['url']) self.stage('nodes', label='Connecting workflow nodes') - engine_binding.rebind(selected['engine'], machine, selected['source']) + def stopped(): + if self.cancelled.is_set(): + raise RuntimeError('Stopped; files retained') + # A uv that security software removed is downloaded again, as the installer does. + engine_binding.rebind(selected['engine'], machine, selected['source'], + restore=lambda root, system: uv_restore.restore(root, system, cancelled=stopped)) self.stage('nodes', label='Install FreeVideo workflow') deployed = deploy(selected['root'], selected['source'], selected['engine']) selected['ready'] = True diff --git a/freevideo_engine/comfy_progress.py b/freevideo_engine/comfy_progress.py index cf68c96a..ecbaa508 100644 --- a/freevideo_engine/comfy_progress.py +++ b/freevideo_engine/comfy_progress.py @@ -475,6 +475,16 @@ async def progress(request): async def preview_style_route(request): return web.json_response(preview_style(), headers={'Cache-Control': 'no-store'}) + @server.routes.get('/freevideo/output-settings') + async def output_settings_route(request): + try: + from .comfy_bridge import installation_root + from .output_settings import available, read + value = dict(read(installation_root()), available=await asyncio.to_thread(available)) + except Exception: + value = dict(ten_bit=False, available=False) + return web.json_response(value, headers={'Cache-Control': 'no-store'}) + @server.routes.post('/freevideo/node-view-opened') async def node_view_opened(request): from .comfy_host_stats import record_node_view diff --git a/freevideo_engine/comfy_service.py b/freevideo_engine/comfy_service.py index 44c57120..14dfab6c 100644 --- a/freevideo_engine/comfy_service.py +++ b/freevideo_engine/comfy_service.py @@ -27,7 +27,7 @@ from . import execution_paths from .monitoring import save -from . import engine_binding +from . import engine_binding, uv_restore DIAGNOSE = 'If it keeps failing, run ./freevideo diagnose and send the report.' @@ -160,7 +160,7 @@ def install(root, source, machine, port, out=say): else: add_node_packages(root, source, machine, comfy, out) if switching: - engine_binding.rebind(root, machine, source) + engine_binding.rebind(root, machine, source, restore=uv_restore.restore) deploy(comfy, source, root) record = dict(root=str(comfy), engine=str(Path(root)), source=str(source), python=python, url='http://127.0.0.1:%d' % port) diff --git a/freevideo_engine/comfy_setup.py b/freevideo_engine/comfy_setup.py index be82b031..505b6212 100644 --- a/freevideo_engine/comfy_setup.py +++ b/freevideo_engine/comfy_setup.py @@ -224,6 +224,13 @@ def preview_style(self, value=None): return read(root) return write(root, value.get('style')) + def output_settings(self, value=None): + from .output_settings import available, read, write + root = installation_root(self.source) + if value is None: + return dict(read(root), available=available()) + return dict(write(root, value.get('ten_bit')), available=available()) + def downloads(self, root=None): from .download_settings import read return dict(preferences=read(Path(root or installation_root(self.source))/'download-settings.json'), @@ -327,6 +334,8 @@ def inspect(self, value): arguments.append('--copy-existing-models') if value.get('download_list'): arguments.append('--download-list') + if value.get('only') == 'image': + arguments += ['--only', 'image'] self.selection = dict(root=str(root), arguments=arguments) self.plan = None self.state = dict(status='running', action='plan', libraries=libraries) @@ -512,6 +521,8 @@ async def action(request): result = setup.resource_settings(value) elif name == 'preview-style': result = setup.preview_style(value) + elif name == 'output-settings': + result = setup.output_settings(value) elif name == 'calibrate': running, pending = server.prompt_queue.get_current_queue() if running or pending: diff --git a/freevideo_engine/decode.py b/freevideo_engine/decode.py index e054f312..95045206 100644 --- a/freevideo_engine/decode.py +++ b/freevideo_engine/decode.py @@ -26,13 +26,21 @@ def decode_to_file(latents, audio_latents, out_path, *, base, offload=False, pre preload=False, pin_weights=False, artifacts_dir=None, phase_callback=None, stream_output=False, stream_weights=False, resident_blocks=0, model_cache=None, linear_compute_cache=False, continuation=None, audio_lead_in_callback=None, output_size=None, - hide_tail_frames=0, ram_budget_bytes=None, original_audio=None): + hide_tail_frames=0, ram_budget_bytes=None, original_audio=None, output_10bit=None): from .continuation import validate_hide_tail validate_hide_tail(hide_tail_frames) def phase(name): if phase_callback is not None: phase_callback(name) print(json.dumps({'event': 'decode_phase', 'phase': DECODE_LABELS[name]}), flush=True) + # The optional 10-bit file: output_10bit = dict(path=..., codec='h264'|'hevc'). The usual + # 8-bit file is written exactly as before; this one is made from the same float frames. + wide_path = wide = wide_map = None + if output_10bit is not None: + if original_audio is not None: + raise ValueError('10-bit output is not available with the original sound') + wide_path = Path(output_10bit['path']).with_name(Path(output_10bit['path']).stem + '.rgb16.partial.npy') + wide_path.parent.mkdir(parents=True, exist_ok=True) phase('vae_load') from diffusers import AutoencoderKLMiniMaxH3 from diffusers.utils.export_utils import encode_video @@ -153,19 +161,30 @@ def room(): # Autocast follows the original renderer. The opt-in Linear cache stores # its existing FP16 compute values once; all other weights stay FP32. with torch.autocast(device_type='cuda', dtype=torch.float16, cache_enabled=not offload): + def decoded(done, total, elapsed): + print(json.dumps(dict(event='decode_progress', done=done, total=total, + elapsed_seconds=elapsed)), flush=True) if stream_output: from .decode_stream import render_rgb directory = Path(artifacts_dir) if artifacts_dir else Path(out_path).with_suffix('.artifacts') - def decoded(done, total, elapsed): - print(json.dumps(dict(event='decode_progress', done=done, total=total, - elapsed_seconds=elapsed)), flush=True) frames, rgb_mapping, streamed_timings = render_rgb(vae, latents * std + mean, - PIXEL_MEAN, PIXEL_STD, directory/'rgb.npy', progress=decoded) + PIXEL_MEAN, PIXEL_STD, directory/'rgb.npy', progress=decoded, + **({'wide_path': wide_path} if wide_path is not None else {})) else: - video = vae.decode(latents * std + mean, return_dict=False)[0] + from .decode_stream import count_decoded_clips + uncount = count_decoded_clips(vae, latents.shape[2], decoded) + try: + video = vae.decode(latents * std + mean, return_dict=False)[0] + finally: + uncount() video = video.cpu() if offloader is not None: offload_stats = offloader.stats() + except BaseException: + if wide_path is not None: + from .video_10bit import discard_store + discard_store(None, wide_path) + raise finally: if offloader is not None: offloader.close() @@ -183,18 +202,30 @@ def decoded(done, total, elapsed): if not stream_output: phase('video_postprocess') frames = torch.empty((video.shape[2], video.shape[3], video.shape[4], 3), dtype=torch.uint8) + if wide_path is not None: + from .video_10bit import open_store, to_rgb16 + wide = wide_map = open_store(wide_path, *frames.shape[:3]) pixel_mean = torch.tensor(PIXEL_MEAN).view(1, -1, 1, 1, 1) pixel_std = torch.tensor(PIXEL_STD).view(1, -1, 1, 1, 1) for start in range(0, video.shape[2], 8): chunk = (video[:, :, start:start + 8].float() * pixel_std + pixel_mean).clamp_(0, 1) frames[start:start + chunk.shape[2]] = (chunk[0].permute(1, 2, 3, 0) * 255).round().to(torch.uint8) + if wide is not None: + wide[start:start + chunk.shape[2]] = to_rgb16(chunk[0].permute(1, 2, 3, 0)) del video + if wide_path is not None and wide is None: + import numpy as np + wide = wide_map = np.load(wide_path, mmap_mode='r', allow_pickle=False) if hide_tail_frames: kept_frames = len(frames) - hide_tail_frames frames = frames[:kept_frames] + if wide is not None: + wide = wide[:kept_frames] if continuation is not None: from .continuation import OVERLAP_FRAMES frames = frames[OVERLAP_FRAMES:] + if wide is not None: + wide = wide[OVERLAP_FRAMES:] if original_audio is not None and 'segment' not in original_audio: from .original_audio import verify verify(original_audio) @@ -307,7 +338,13 @@ def decoded(done, total, elapsed): encode_video(picture, fps=FPS, output_path=str(temporary), audio=audio, audio_sample_rate=rate) temporary.replace(destination) frame_count = len(frames) + if wide is not None: + tenbit = _write_10bit(wide, output_10bit, output_size, audio, rate, FPS, phase) finally: + if wide_path is not None: + from .video_10bit import discard_store + wide = picture = None + discard_store(wide_map, wide_path) if rgb_mapping is not None: del frames rgb_mapping._mmap.close() @@ -315,6 +352,7 @@ def decoded(done, total, elapsed): if (continuation is not None or hide_tail_frames or original_audio is not None) and (retained / 'rgb-new.npy').is_file(): (retained / 'rgb-new.npy').replace(retained / 'rgb.npy') return {**({'original_audio': original_receipt} if original_receipt is not None else {}), + **({'output_10bit': tenbit} if output_10bit is not None else {}), 'vae_load_seconds': load_seconds, 'video_decode_seconds': video_decode_seconds, 'vae_linear_compute_cache': linear_compute_cache, 'vae_compute_cache_preparation': compute_cache_metrics, 'resident_vae_cache_hit': vae_cache_hit, 'resident_audio_vae_cache_hit': audio_cache_hit, @@ -329,3 +367,27 @@ def decoded(done, total, elapsed): 'vae_pinned_weight_bytes': pinned_bytes, 'decode_save_seconds': time.perf_counter() - started, 'vae_offload': offload_stats, 'frames': frame_count, 'output': str(destination)} + + +def _write_10bit(wide, output_10bit, output_size, audio, rate, fps, phase): + """Encode the 16-bit frames as the 10-bit file. The 8-bit file is already saved, so a + failure here is reported in the result and does not fail the generation.""" + started = time.perf_counter() + destination = Path(output_10bit['path']) + temporary = destination.with_name(destination.stem + '.partial' + destination.suffix) + try: + from . import video_10bit + phase('mp4_save') + picture = video_10bit.fit_frames16(wide, *output_size) if output_size is not None else wide + video_10bit.write(picture, temporary, fps=fps, codec=output_10bit.get('codec', 'h264'), + audio=audio, audio_sample_rate=rate) + temporary.replace(destination) + return dict(saved=True, path=str(destination), codec=output_10bit.get('codec', 'h264'), + seconds=time.perf_counter() - started) + except Exception as error: + if isinstance(error, MemoryError) or type(error).__name__ in ( + 'OutOfMemoryError', 'CancelledError', 'InterruptProcessingException'): + raise + return dict(saved=False, error=type(error).__name__, seconds=time.perf_counter() - started) + finally: + temporary.unlink(missing_ok=True) diff --git a/freevideo_engine/decode_stream.py b/freevideo_engine/decode_stream.py index 34fef33e..edd145b7 100644 --- a/freevideo_engine/decode_stream.py +++ b/freevideo_engine/decode_stream.py @@ -77,8 +77,48 @@ def iter_decoded_chunks(vae, z): keep -= count -def render_rgb(vae, latents, pixel_mean, pixel_std, path, progress=None): - """Write exact uint8 output a temporal clip at a time; retain partial data.""" +def count_decoded_clips(vae, latent_frames, progress): + """Report each finished temporal clip of the original whole-video decode. + + Wraps only the instance's `_decode_clip`; the arithmetic and call order are + unchanged. `progress(done_frames, total_frames, elapsed)` gets the frames + the finished clips cover, which is exact except for trailing padding. + Returns a callable that removes the wrapper. + """ + import time + try: + tokens = vae.tokens_chunk_size + drop = vae.config.token_drop + padding = (-(latent_frames + drop)) % tokens + clips = (latent_frames + drop + padding) // tokens - int(drop > 0) + total = decoded_frames(vae, latent_frames) + per_clip = tokens * vae.temporal_compression_ratio - vae.frame_pre_padding + original = vae._decode_clip + except Exception: + return lambda: None # a decoder without these fields is decoded without counts, never refused + own = vae.__dict__.get('_decode_clip') + state = dict(done=0, started=time.perf_counter()) + def counted(*values, **kwargs): + result = original(*values, **kwargs) + state['done'] += 1 + done = total if state['done'] >= clips else min(total, state['done'] * per_clip) + progress(done, total, time.perf_counter() - state['started']) + return result + vae._decode_clip = counted + def restore(): + if own is not None: + vae._decode_clip = own + else: + vae.__dict__.pop('_decode_clip', None) + return restore + + +def render_rgb(vae, latents, pixel_mean, pixel_std, path, progress=None, wide_path=None): + """Write exact uint8 output a temporal clip at a time; retain partial data. + + With wide_path, the same frames are also written there as 16-bit RGB, from the + float values and not from the uint8 ones (the optional 10-bit file). + """ import time from pathlib import Path import numpy as np @@ -92,12 +132,17 @@ def render_rgb(vae, latents, pixel_mean, pixel_std, path, progress=None): shape = (count, latents.shape[-2] * vae.spatial_compression_ratio, latents.shape[-1] * vae.spatial_compression_ratio, vae.config.out_channels) mapping = np.lib.format.open_memmap(temporary, mode='w+', dtype=np.uint8, shape=shape) + wide = None + if wide_path is not None: + from .video_10bit import open_store, to_rgb16 + wide = open_store(wide_path, *shape[:3]) mean = torch.tensor(pixel_mean).view(1, -1, 1, 1, 1) std = torch.tensor(pixel_std).view(1, -1, 1, 1, 1) timings = dict(video_decode_seconds=0., video_postprocess_seconds=0., decoded_artifact_save_seconds=0.) offset = 0 iterator = iter_decoded_chunks(vae, latents) started = time.perf_counter() + complete = False try: while True: tick = time.perf_counter() @@ -113,6 +158,8 @@ def render_rgb(vae, latents, pixel_mean, pixel_std, path, progress=None): part = (video[:, :, start:start + 8].float() * std + mean).clamp_(0, 1) rgb = (part[0].permute(1, 2, 3, 0) * 255).round().to(torch.uint8) mapping[offset:offset + len(rgb)] = rgb.numpy() + if wide is not None: + wide[offset:offset + len(rgb)] = to_rgb16(part[0].permute(1, 2, 3, 0)) offset += len(rgb) del part, rgb del video @@ -126,10 +173,18 @@ def render_rgb(vae, latents, pixel_mean, pixel_std, path, progress=None): progress(offset, count, time.perf_counter() - started) if offset != count: raise ValueError('Streamed VAE output length differs from its original temporal plan') + complete = True finally: iterator.close() mapping.flush() mapping._mmap.close() + if wide is not None: + wide.flush() + wide._mmap.close() + if not complete: + # A failed or cancelled decode leaves no multi-gigabyte 16-bit store behind. + from .video_10bit import discard_store + discard_store(None, wide_path) temporary.replace(path) # Keep one mapped uint8 plane for the original encoder; no full float video # or second RGB copy is materialized. The caller owns this mapping's life. diff --git a/freevideo_engine/encode_worker.py b/freevideo_engine/encode_worker.py index 5e07be14..e298626f 100644 --- a/freevideo_engine/encode_worker.py +++ b/freevideo_engine/encode_worker.py @@ -127,7 +127,9 @@ def start_read_ahead(trace, path, request): return try: from .encoder_read_ahead import EncoderReadAhead - trace.read_ahead = EncoderReadAhead(path, ram_budget_bytes=request.get('ram_budget_bytes')).start() + from .encoder_progress import checkpoint_read_reporter + trace.read_ahead = EncoderReadAhead(path, ram_budget_bytes=request.get('ram_budget_bytes'), + progress=checkpoint_read_reporter()).start() except Exception as error: trace.data['encoder_read_ahead'] = dict(state='failed', reason=type(error).__name__ + ': ' + str(error)) @@ -511,7 +513,8 @@ def attempt(index, failure): from .encoder_memory import encode_with_recovery from .encoder_pinning import readonly_pinning import comfy.model_management as memory - with readonly_pinning(torch, memory): + from .encoder_progress import layer_progress + with readonly_pinning(torch, memory), layer_progress(clip.cond_stage_model): encoded, attempts = encode_with_recovery(clip, tokens, memory, torch, phase, max_attempts=4, attempt=attempt) loading['encoder_attempts'] = attempts @@ -524,7 +527,7 @@ def attempt(index, failure): tokens = clip.tokenize(prompt, **reference_options(normalized, media_kwargs, step)) size = input_size(tokens) current.clear() - with readonly_pinning(torch, memory): + with readonly_pinning(torch, memory), layer_progress(clip.cond_stage_model, 'encoder_compute_reference'): stage_encoded, stage_attempts = encode_with_recovery( clip, tokens, memory, torch, phase, max_attempts=4, attempt=attempt) stage_task = task_for(stage_media) diff --git a/freevideo_engine/encoder_progress.py b/freevideo_engine/encoder_progress.py new file mode 100644 index 00000000..e0572b2d --- /dev/null +++ b/freevideo_engine/encoder_progress.py @@ -0,0 +1,63 @@ +"""Real work counts for the text encoder's long silent spans; reporting only. + +Nothing here touches tensors, CUDA or memory planning: a callback is handed +integers that the reader or the forward pass already produced. A failure in +reporting switches reporting off and never reaches the encode. +""" +from contextlib import contextmanager + +from .work_progress import WorkProgress + + +def checkpoint_read_reporter(stage='encoder_device_load'): + """`progress(read_bytes, total_bytes)` for the checkpoint read-ahead, in bytes of the file read so far. + + The device copy itself has no count (the native loader reports none), so + the bytes the read-ahead has read of the same file are what is reported.""" + state = {} + + def progress(done, total): + if state.get('off'): + return + try: + work = state.get('work') + if work is None: + work = state['work'] = WorkProgress('encoding', stage, int(total), 'bytes') + work.update(done, source='checkpoint_read_ahead') + except Exception: + state['off'] = True + return progress + + +@contextmanager +def layer_progress(model, stage='encoder_compute'): + """Report finished language-model layers of each forward pass, as `layers` out of the layer count. + + A forward-hook per layer only counts; a retried pass starts again at layer 1 and the count never moves back.""" + handles = [] + try: + try: + from .encoder_lowmem import language_model + layers = list(language_model(model).layers) + work = WorkProgress('encoding', stage, len(layers), 'layers') + except Exception: + layers = [] + if layers: + state = dict(off=False) + + def hook(index): + def after(module, args, output): + if state['off']: + return + try: + work.update(index + 1) + except Exception: + state['off'] = True + return after + work.update(0) + for index, layer in enumerate(layers): + handles.append(layer.register_forward_hook(hook(index))) + yield + finally: + for handle in handles: + handle.remove() diff --git a/freevideo_engine/encoder_read_ahead.py b/freevideo_engine/encoder_read_ahead.py index c6b3a02a..5331d01a 100644 --- a/freevideo_engine/encoder_read_ahead.py +++ b/freevideo_engine/encoder_read_ahead.py @@ -31,8 +31,9 @@ class EncoderReadAhead: - def __init__(self, path, *, ram_budget_bytes=None): + def __init__(self, path, *, ram_budget_bytes=None, progress=None): self.path = Path(path) + self.progress = progress # progress(read_bytes, total_bytes) after each chunk; reporting only self.ram_budget_bytes = ram_budget_bytes self.cancelled = threading.Event() self.thread = None @@ -87,6 +88,8 @@ def _run(self): raise ValueError('Encoder checkpoint truncated during read-ahead') remaining -= count self.result['read_bytes'] += count + if self.progress is not None: + self.progress(self.result['read_bytes'], total) current = self.path.stat() if (current.st_size, current.st_mtime_ns) != (stamp.st_size, stamp.st_mtime_ns): raise ValueError('Encoder checkpoint changed during read-ahead') diff --git a/freevideo_engine/encoder_window.py b/freevideo_engine/encoder_window.py index c0345c2f..19f764a7 100644 --- a/freevideo_engine/encoder_window.py +++ b/freevideo_engine/encoder_window.py @@ -13,6 +13,12 @@ find the pages in the OS cache) and gives the pages of finished layers back, so the encoder never holds more than a few hundred MB beyond what it is using. It keeps no private copy, CUDA allocation or pinned memory. + +One handle reading a layer front to back leaves most of a fast drive idle, as +it does for the video model's streamed layers, so each layer is read in large +pieces on a few handles at once, with the same reader count and piece layout +(FREEVIDEO_READ_THREADS=1 reads on one handle). The bytes the encoder later +uses are unchanged; only the order the drive is asked for them in differs. """ import ctypes import json @@ -27,6 +33,7 @@ from .encoder_prewarm import CHUNK from .hardware import cgroup_memory from .system import system_memory +from .tensor_io import read_threads LEAD_LAYERS = 3 # The window holds a few layers and gives finished ones back, so it needs no @@ -105,6 +112,9 @@ def __init__(self, path, modules, *, lead=LEAD_LAYERS, release=True): self.path = Path(path) self.modules = modules self.lead = max(1, int(lead)) + self.threads = read_threads() + self.local = threading.local() + self.streams = [] # one read handle per pool thread, closed when the window ends self.release = release self.cond = threading.Condition() self.cancelled = False @@ -118,7 +128,7 @@ def __init__(self, path, modules, *, lead=LEAD_LAYERS, release=True): self.mapping = None self.address = None self.result = dict(state='not-started', layers=0, read_bytes=0, released_bytes=0, stalls=0, - sweeps=0, elapsed_seconds=0., lead_layers=self.lead, + sweeps=0, elapsed_seconds=0., lead_layers=self.lead, read_threads=self.threads, scope='Streamed encoder layers; reclaimable OS cache, no GPU residency') def start(self): @@ -155,10 +165,72 @@ def hook(module, args, kwargs=None): self.cond.notify_all() return hook + def _read_parallel(self, pool, spans): + """Read one layer's ranges in CHUNK pieces on the pool's handles; return the bytes read. + + Neighbouring pieces go to different handles, so together they still + move through the layer from front to back. Each pool thread keeps its + own handle and scratch buffer for the whole window: the bytes are only + brought into the OS cache. A failure on one handle stops the others + at their next piece.""" + pieces = [] + for begin, end in spans: + while begin < end: + length = min(CHUNK, end - begin) + pieces.append((begin, length)) + begin += length + threads = min(self.threads, len(pieces)) + failed = threading.Event() + + def read(group): + total = 0 + local = self.local + stream = getattr(local, 'stream', None) + if stream is None: + stream = local.stream = self.path.open('rb', buffering=0) + local.scratch = bytearray(CHUNK) + with self.cond: + self.streams.append(stream) + scratch = local.scratch + try: + for position, length in group: + if self.cancelled or failed.is_set(): + break + stream.seek(position) + done = 0 + while done < length: + count = stream.readinto(memoryview(scratch)[:length - done]) + if not count: + raise ValueError('Encoder checkpoint truncated during read window') + done += count + total += length + except BaseException: + failed.set() # the other handles stop before their next piece + raise + return total + + futures = [pool.submit(read, pieces[first::threads]) for first in range(threads)] + total, failure = 0, None + for future in futures: + try: + total += future.result() + except BaseException as error: + failed.set() + failure = failure or error + if failure is not None: + raise failure + return total + def _run(self): started = time.monotonic() - scratch = bytearray(CHUNK) + scratch = None + pool = None try: + if self.threads > 1: + from concurrent.futures import ThreadPoolExecutor + pool = ThreadPoolExecutor(self.threads, thread_name_prefix='freevideo-encoder-window') + else: + scratch = bytearray(CHUNK) with self.path.open('rb', buffering=0) as file: while True: with self.cond: @@ -175,17 +247,20 @@ def _run(self): if not self._room(size): self.result.update(state='stopped', reason='Live RAM stayed too low to read ahead') return - for begin, end in spans: - file.seek(begin) - remaining = end - begin - while remaining: - if self.cancelled: - break - count = file.readinto(memoryview(scratch)[:min(CHUNK, remaining)]) - if not count: - raise ValueError('Encoder checkpoint truncated during read window') - remaining -= count - self.result['read_bytes'] += count + if pool is None: + for begin, end in spans: + file.seek(begin) + remaining = end - begin + while remaining: + if self.cancelled: + break + count = file.readinto(memoryview(scratch)[:min(CHUNK, remaining)]) + if not count: + raise ValueError('Encoder checkpoint truncated during read window') + remaining -= count + self.result['read_bytes'] += count + else: + self.result['read_bytes'] += self._read_parallel(pool, spans) with self.cond: if self.next == position: self.next = position + 1 @@ -194,6 +269,15 @@ def _run(self): # Speculative I/O never replaces the loader's own reads and validation. self.result.update(state='failed', reason=type(error).__name__ + ': ' + str(error)) finally: + if pool is not None: + pool.shutdown(wait=True) + with self.cond: + streams, self.streams = self.streams, [] + for stream in streams: + try: + stream.close() + except OSError: + pass self.result['elapsed_seconds'] = time.monotonic() - started def _room(self, size): diff --git a/freevideo_engine/engine_binding.py b/freevideo_engine/engine_binding.py index 46fabc5b..e1ba5044 100644 --- a/freevideo_engine/engine_binding.py +++ b/freevideo_engine/engine_binding.py @@ -85,14 +85,23 @@ def source_version(source): @plain_errors -def _uv(engine_root, machine): +def _uv(engine_root, machine, restore=None): system = machine.get('system') or ('Windows' if os.name == 'nt' else 'Darwin' if sys.platform == 'darwin' else 'Linux') relative = {'Windows': 'uv.exe', 'Darwin': 'uv', 'Linux': 'uv-x86_64-unknown-linux-gnu/uv'}.get(system) tool = io_path(engine_root) / 'tools' / relative if relative else None if tool is None or not tool.is_file(): - raise RuntimeError('The engine\'s uv is missing from %s/tools (%s); run Install / repair. ' - 'Source rebinding does not download tools.' % (engine_root, system)) + where = 'The engine\'s uv is missing from %s/tools (%s)' % (engine_root, system) + if restore is None or tool is None: + raise RuntimeError(where + '; run Install / repair. Source rebinding does not download tools.') + # Security software can remove it after installation; download the pinned one again. A tool in place stays. + try: + restore(engine_root, system) + if not tool.is_file(): + raise RuntimeError('it was not found after the download') + except Exception as error: + reason = (str(error).strip().splitlines() or [type(error).__name__])[0] + raise RuntimeError('%s and could not be downloaded again (%s); run Install / repair.' % (where, reason)) from error return Path(plain(tool)) @@ -154,11 +163,13 @@ def pending(machine, source): @plain_errors -def rebind(engine_root, machine, source, *, uv=None, run=subprocess.run): +def rebind(engine_root, machine, source, *, uv=None, run=subprocess.run, restore=None): """Rebind E/T, verify fresh imports, then atomically record the new source. A failure restores every attempted environment whose previous source still exists, including a failed uv install that may have partially replaced it. + A missing uv is restored through `restore(engine_root, system)` when the caller passes one; a failed + restore stops the update before any environment is touched. """ engine_root = Path(plain(io_path(checked_real_path(engine_root)))) source = Path(plain(resolved(checked_real_path(source)))) @@ -166,7 +177,7 @@ def rebind(engine_root, machine, source, *, uv=None, run=subprocess.run): environments = pending(machine, source) if environments: try: - uv = Path(plain(uv)) if uv is not None else _uv(engine_root, machine) + uv = Path(plain(uv)) if uv is not None else _uv(engine_root, machine, restore) if not io_path(uv).is_file(): raise RuntimeError('The engine\'s uv is missing: ' + str(uv)) except RuntimeError as error: diff --git a/freevideo_engine/feedback.py b/freevideo_engine/feedback.py index 6acb6b68..66befc15 100644 --- a/freevideo_engine/feedback.py +++ b/freevideo_engine/feedback.py @@ -65,6 +65,24 @@ def summary(report, machine=None): return out +def setup_hardware(machine): + """The hardware the setup run recorded for this machine's device, or {}. + + machine.json carries only the device identity; the totals are in the plan of + the setup run it names, the same place the progress forecast reads them from. + """ + try: + from pathlib import Path + from .effort_forecast import same_device + device = machine.get('device_identity') if machine.get('device_backend') == 'mps' else machine.get('gpu_uuid') + with (Path(machine['setup_run']) / 'plan.json').open('rb') as stream: + raw = stream.read(1024 * 1024 + 1) + found = json.loads(raw).get('inventory', {}).get('hardware', {}) if len(raw) <= 1024 * 1024 else {} + return found if isinstance(found, dict) and same_device(found, device) else {} + except (OSError, ValueError, TypeError, AttributeError, KeyError): + return {} + + def collect(report_id=None): """The diagnostic summary for the latest generation, or for this machine alone before any.""" report = machine = None @@ -79,6 +97,8 @@ def collect(report_id=None): try: from .comfy_bridge import installation machine = installation()[1] + if not isinstance(machine.get('hardware'), dict): + machine = dict(machine, hardware=setup_hardware(machine)) except Exception: # Setup problems must not stop a suggestion from being sent. machine = None return summary(report, machine) diff --git a/freevideo_engine/generate.py b/freevideo_engine/generate.py index 55735ca4..1ba3e4ce 100644 --- a/freevideo_engine/generate.py +++ b/freevideo_engine/generate.py @@ -1089,6 +1089,14 @@ def validate(metrics, telemetry, selected): request['continuation'] = continuation if hide_tail_frames: request['hide_tail_frames'] = hide_tail_frames + if getattr(args, 'ten_bit', None): + skipped = ('preview' if preview else 'refine' if refine_source else 'continuation' + if continuation is not None else 'original_audio' if original_audio is not None else None) + if skipped is None: + request['output_10bit'] = dict( + path=str(destination.with_name(destination.stem + '-10bit' + destination.suffix)), codec=args.ten_bit) + else: + report['output_10bit'] = dict(requested=True, saved=False, reason=skipped) if original_audio is not None: request['original_audio'] = original_audio report['original_audio'] = original_audio diff --git a/freevideo_engine/image_gen/failure.py b/freevideo_engine/image_gen/failure.py index 35c23c2a..ca4957d2 100644 --- a/freevideo_engine/image_gen/failure.py +++ b/freevideo_engine/image_gen/failure.py @@ -25,7 +25,7 @@ KINDS = ('generate', 'edit', 'cast', 'compose', 'download', 'install') MESSAGE_ZH = { **LORA_MESSAGE_ZH, - 'no_memory': '显存不够,最小尺寸也没能生成。关掉其他占用显卡的程序后再试。', + 'no_memory': '显存不足,最小尺寸也无法生成。请关闭其他占用显卡的程序后重试。', 'small_memory': '这台 Mac 的内存不足 16 GB,无法生成图片。', 'busy_video': '视频正在生成,完成后再试。', 'busy': '已有一张图在生成,请等它完成。', @@ -57,7 +57,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': '内存不足,最小尺寸也无法生成。请关闭其他程序后重试。'} HOST_MESSAGE_ZH = '虚拟内存不足,无法载入图像模型。请关闭其他程序,或在系统设置中调大虚拟内存,然后重试。' @@ -209,6 +209,9 @@ def seconds(self): return max(0., self.clock() - self.started) +_GPU_READING = ('total_bytes', 'used_bytes', 'budget_bytes', 'owned_credit_bytes', 'native_credit_bytes') + + class Context: def __init__(self, action, value): self.redactor = Redactor() @@ -219,6 +222,8 @@ def __init__(self, action, value): self.plan = None self.pack = None self.start_memory = None + # The card as the admission check read it (memory_budget): numbers only. + self.gpu_reading = {} # ComfyUI's own models this request unloaded before its worker started (comfy_residency.release_native). self.native_release = None self.update_request(value) @@ -249,7 +254,7 @@ def set_machine(self, machine): seed_redactor(self.redactor, machine) self.machine = machine - def report(self, code, *, row=None, error=None, video_busy=False, worker_restarts=0): + def report(self, code, *, row=None, error=None, video_busy=False, worker_restarts=0, outcome='failed', forecast=None): row = row if isinstance(row, dict) else {} seed_redactor(self.redactor, row) stage = row.get('stage') if row.get('stage') in STAGES else self.timing.stage @@ -312,17 +317,26 @@ def report(self, code, *, row=None, error=None, video_busy=False, worker_restart # Macs only: which attention kernel ran and why it left MLX (mlx_attention.summary). attention = row.get('attention') extra = dict(attention=clean(attention, self.redactor)) if isinstance(attention, dict) else {} + if isinstance(forecast, dict): + extra['forecast'] = {key: value for key, value in forecast.items() if key in ('basis', 'typical_seconds', 'predicted') and clean(value, self.redactor) is not None} return dict(schema='freevideo.image-report', schema_version=1, **extra, created_at=datetime.now(timezone.utc).isoformat(), kind=self.kind, analysis=dict(version=__version__, hardware=hardware, geometry=geometry), summary=dict(request_seconds=self.timing.seconds()), request=dict(self.request), - plan=plan, stages=stages, stage_at_failure=stage, error=details, pack=clean(pack, self.redactor), + outcome=outcome, plan=plan, stages=stages, stage_at_failure=stage if outcome == 'failed' else None, + current_stage=stage if outcome == 'running' else None, error=details if outcome == 'failed' else None, pack=clean(pack, self.redactor), service=self.service(video_busy, worker_restarts, error)) def service(self, video_busy, worker_restarts, error): service = dict(video_busy=bool(video_busy), worker_restarts=worker_restarts) if self.start_memory: service['memory_at_request_start'] = clean_sample(self.start_memory) + gpu = {key: self.gpu_reading[key] for key in _GPU_READING if type(self.gpu_reading.get(key)) is int + and self.gpu_reading[key] >= 0} + if type(self.gpu_reading.get('owned_unreported')) is bool and gpu: + gpu['owned_unreported'] = self.gpu_reading['owned_unreported'] + if gpu: + service['gpu_at_request_start'] = gpu released = self.native_release if isinstance(released, dict): service['comfy_models_released'] = {key: released[key] for key in diff --git a/freevideo_engine/image_gen/h3_dit.py b/freevideo_engine/image_gen/h3_dit.py index eb6d7693..28b399c8 100644 --- a/freevideo_engine/image_gen/h3_dit.py +++ b/freevideo_engine/image_gen/h3_dit.py @@ -271,8 +271,11 @@ def unpatchify(rows, height, width, channels=24): @torch.no_grad() def sample(dit, features, tags, latent_h, latent_w, generator, device='cuda', dtype=torch.bfloat16, blocks=None, - noise_device=None, step_callback=None): - """Text-to-image: six velocity-Euler steps from noise; returns [24, H, W] float32 latents.""" + noise_device=None, step_callback=None, preview_callback=None): + """Text-to-image: six velocity-Euler steps from noise; returns [24, H, W] float32 latents. + + ``preview_callback(step, total, rows, velocity, sigma, latent_h, latent_w)`` runs after each step's velocity, before + the update; it only reads the two tensors (the clean prediction is ``rows + sigma * velocity``).""" text = dit.embed_text(features.to(device)) noise = torch.randn((1, 24, 1, latent_h, latent_w), generator=generator, device=noise_device or device, dtype=torch.float32).to(device) @@ -286,5 +289,7 @@ def sample(dit, features, tags, latent_h, latent_w, generator, device='cuda', dt step_callback(i, len(steps)) times = steps[i:i + 1].to(device) velocity = dit(text, rows, positions, segments, times, i, blocks=blocks) + if preview_callback is not None: + preview_callback(i + 1, len(steps), rows, velocity, float(sigmas[i]), latent_h, latent_w) rows = rows + (sigmas[i] - sigmas[i + 1]).to(device) * velocity return unpatchify(rows, latent_h, latent_w) diff --git a/freevideo_engine/image_gen/h3_pipeline.py b/freevideo_engine/image_gen/h3_pipeline.py index 88b8d47c..0d3b5ab4 100644 --- a/freevideo_engine/image_gen/h3_pipeline.py +++ b/freevideo_engine/image_gen/h3_pipeline.py @@ -51,7 +51,8 @@ def _encode(prompt, *, encoder, comfy_root, comfy_python, model_paths, vram_gib, def generate(prompt, width, height, seed, references=None, progress_cb=None, cancel=None, *, dit_path, vae_path, encoder='qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors', comfy_root=None, comfy_python=None, - model_paths=None, vram_gib=None, ram_gib=None, resident=None, cpu_noise=False, stats=None, mapped=True): + model_paths=None, vram_gib=None, ram_gib=None, resident=None, cpu_noise=False, stats=None, mapped=True, + preview=None, enter=None): """Return a PIL image for the prompt. ``progress_cb(stage, done, total)`` is called with stage in encode, denoise, decode; @@ -59,6 +60,9 @@ def generate(prompt, width, height, seed, references=None, progress_cb=None, can with less memory than the 21 GB of weights loads them and pins what live memory allows. ``resident`` overrides the planned layer count. ``stats`` (a dict) receives timings, the plan and the peak memory. + ``preview`` is the sampler's ``preview_callback`` (see ``step_preview``); it never changes the picture. + ``enter(stage)`` names the stage a failure belongs to without moving the progress: the DiT loads after the + text, so a load error would otherwise be filed under the text stage. """ if references: raise ValueError('H3 Image does not take reference images yet') @@ -89,6 +93,8 @@ def check(): if vram_gib: torch.cuda.set_per_process_memory_fraction(min(1.0, budget / total)) canvas_h, canvas_w = h3_dit.canvas_size(height, width) + if enter is not None: + enter('load') tick = time.perf_counter() model = load_h3_dit(str(dit_path), 'cpu', mapped=mapped) for name, child in model.named_children(): @@ -112,7 +118,8 @@ def step(i, n): tick = time.perf_counter() try: latents = h3_dit.sample(model, cond['prompt_embeds'], cond['text_token_tags'], canvas_h // 16, canvas_w // 16, - generator, noise_device='cpu' if cpu_noise else None, step_callback=step) + generator, noise_device='cpu' if cpu_noise else None, step_callback=step, + preview_callback=preview) torch.cuda.synchronize() stats['sample_s'] = round(time.perf_counter() - tick, 2) stats['denoise_peak_gib'] = round(torch.cuda.max_memory_allocated() / 2**30, 2) diff --git a/freevideo_engine/image_gen/h3_runner.py b/freevideo_engine/image_gen/h3_runner.py index 7e0791ce..bcbc55ca 100644 --- a/freevideo_engine/image_gen/h3_runner.py +++ b/freevideo_engine/image_gen/h3_runner.py @@ -24,7 +24,16 @@ def files(model_root): return found -def _stub(prompt, *, width, height, seed, progress, cancel): +def _previewer(send): + """The sampler's per-step preview callback for `send`, or None when previews are off or unavailable.""" + if send is None: + return None + from . import step_preview + previewer = step_preview.Previewer(send) + return previewer if previewer.active else None + + +def _stub(prompt, *, width, height, seed, progress, cancel, preview=None): """A flat picture of the asked size with the stage callbacks, for protocol tests and page checks only.""" from PIL import Image shade = (seed or 0) % 160 + 40 @@ -32,6 +41,13 @@ def _stub(prompt, *, width, height, seed, progress, cancel): if cancel is not None and cancel(): raise InterruptedError('cancelled') progress('denoise', (index + 1) / 6) + if preview is not None: + import io + scale = min(1., 512 / max(width, height)) + small = (max(1, round(width * scale)), max(1, round(height * scale))) + buffer = io.BytesIO() + Image.new('RGB', small, (shade * (index + 1) // 6, 90, 255 - shade)).save(buffer, 'JPEG', quality=80) + preview(step=index + 1, total=6, width=small[0], height=small[1], jpeg=buffer.getvalue()) return Image.new('RGB', (width, height), (shade, 90, 255 - shade)) @@ -49,15 +65,20 @@ def __init__(self, pack_dir, *, device='cuda', progress=None): progress('load', 1.) def generate(self, prompt, *, width, height, seed=None, reference_images=None, progress=None, cancel=None, - setup=None, **_): - """`setup` is what the service read from the installation: the shared encoder's runtime and the card budget.""" + setup=None, preview=None, stage=None, **_): + """`setup` is what the service read from the installation: the shared encoder's runtime and the card budget. + + `preview(step=, total=, width=, height=, jpeg=)` receives a small picture after each sampling step.""" progress = progress or (lambda stage, fraction: None) # A request without a seed draws a fresh one, as the other image model does; the one used is reported # with the plan so the picture can carry it. seed = secrets.randbits(32) if seed is None else seed self.last_plan = dict(seed=seed) + from ..frame_preview import disabled + if disabled(): + preview = None if os.environ.get(STUB_ENV) == '1': - return _stub(prompt, width=width, height=height, seed=seed, progress=progress, cancel=cancel) + return _stub(prompt, width=width, height=height, seed=seed, progress=progress, cancel=cancel, preview=preview) try: from . import h3_pipeline except ImportError: @@ -77,7 +98,7 @@ def report(stage, done, total): (lambda: bool(cancel and cancel())), dit_path=self.files['dit'], vae_path=self.files['vae'], encoder=setup.get('encoder'), comfy_root=setup.get('comfy_root'), comfy_python=setup.get('comfy_python'), model_paths=setup.get('model_paths'), vram_gib=setup.get('vram_gib'), ram_gib=setup.get('ram_gib'), - stats=stats) + stats=stats, preview=_previewer(preview), enter=stage) except h3_pipeline.Cancelled: raise InterruptedError('cancelled') from None finally: diff --git a/freevideo_engine/image_gen/progress_model.py b/freevideo_engine/image_gen/progress_model.py new file mode 100644 index 00000000..55a78e1c --- /dev/null +++ b/freevideo_engine/image_gen/progress_model.py @@ -0,0 +1,205 @@ +"""Where an image request stands as one percentage, and how long it still takes. + +The share of each stage comes from how long it really took on this machine for a request of this +size (the last runs are kept in a small file); before the first run, from rough per-size priors that +are corrected by the speed of the stages already finished. The remaining time is that prediction, never +the current percentage extrapolated. Between worker reports the percentage keeps moving at the pace +of the last step, but never past the next step, so a long load or a tiled decode does not stand still. +""" +import json +import math +import os +from pathlib import Path +import tempfile +import threading +import time + +ORDER = ('load', 'text', 'encode', 'denoise', 'decode') +# Per request, seconds for 1 megapixel (the part that grows with the size) and fixed seconds. +PRIOR = dict(load=(0., 12.), text=(0., 40.), encode=(2., 1.), denoise=(10., 3.), decode=(4., 1.)) +KEEP_RUNS, KEEP_KEYS = 6, 48 +MIN_RATE = .06 # points per second kept up while a stage reports nothing +MAX_RATE = 1.6 # percentage points per second the shown number may rise +CEILING = .99 # only the finished state shows 100 + + +def key_for(model, width, height, images): + return '%s|%.1f|%s' % (model, width * height / 1e6, 'edit' if images else 'new') + + +def _megapixels(key): + try: + return float(key.split('|')[1]) + except (IndexError, ValueError): + return 1. + + +def _number(value): + return type(value) in (int, float) and math.isfinite(value) and 0 <= value < 1e6 + + +class History: + """Seconds per stage of the finished runs, by model, size and kind of request.""" + + def __init__(self, file): + self.file = Path(file) if file else None + self.lock = threading.Lock() + + def _load(self): + try: + data = json.loads(self.file.read_text('utf-8')) + runs = data['runs'] + return {key: [r for r in rows if isinstance(r, dict) and all(_number(r.get(s, 0)) for s in ORDER)][-KEEP_RUNS:] + for key, rows in runs.items() if isinstance(rows, list)} + except (OSError, ValueError, KeyError, TypeError, AttributeError): + return {} + + def record(self, key, seconds): + """Keep one finished run; only runs that went through every stage of the key's kind count.""" + row = {stage: float(seconds[stage]) for stage in ORDER if _number(seconds.get(stage))} + if self.file is None or 'denoise' not in row or 'decode' not in row: + return + with self.lock: + runs = self._load() + runs.setdefault(key, []).append(row) + runs[key] = runs[key][-KEEP_RUNS:] + for old in list(runs)[:-KEEP_KEYS]: + del runs[old] + try: + self.file.parent.mkdir(parents=True, exist_ok=True) + descriptor, temp = tempfile.mkstemp(dir=self.file.parent, prefix='.image-stage-times') + with os.fdopen(descriptor, 'w', encoding='utf-8') as handle: + json.dump(dict(version=1, runs=runs), handle, allow_nan=False) + os.replace(temp, self.file) + except (OSError, ValueError): + pass + + def predict(self, key, with_edit): + """(seconds per stage, kind): 'history' from this size, 'scaled' from another size, 'prior'.""" + with self.lock: + runs = self._load() if self.file else {} + rows = runs.get(key) + kind = 'history' + if not rows: + same = [(abs(_megapixels(k) - _megapixels(key)), k) for k in runs + if k.split('|')[0] == key.split('|')[0] and k.split('|')[2:] == key.split('|')[2:] and runs[k]] + if same: + rows, kind = runs[min(same)[1]], 'scaled' + scale = _megapixels(key) / max(.05, _megapixels(min(same)[1])) + if not rows: + return self.prior(_megapixels(key), with_edit), 'prior' + out = {} + for stage in ORDER: + values = sorted(r[stage] for r in rows if stage in r) + if values: + out[stage] = values[len(values) // 2] + if kind == 'scaled' and stage in ('encode', 'denoise', 'decode'): + out[stage] *= scale + if not with_edit: + out.pop('encode', None) + for stage, value in self.prior(_megapixels(key), with_edit).items(): + out.setdefault(stage, value) + return out, kind + + @staticmethod + def prior(megapixels, with_edit): + return {stage: per * megapixels + fixed for stage, (per, fixed) in PRIOR.items() + if stage != 'encode' or with_edit} + + +class Forecast: + """One request's stage list, fed with the worker's rows, read as often as the page polls.""" + + def __init__(self, predicted, kind, clock=time.monotonic): + self.clock = clock + self.predicted, self.kind = dict(predicted), kind + self.stages = [s for s in ORDER if s in self.predicted] + self.started = clock() + self.stage, self.entered = None, self.started + self.reported, self.reported_at = 0., self.started + self.unit, self.step_seconds = .1, None + self.spent = {} # finished stage -> seconds it really took + self.shown, self.shown_at = 0., self.started + self.left, self.left_at = None, self.started + + def update(self, stage, fraction): + now = self.clock() + if stage not in self.stages: + return + if stage != self.stage: + if self.stage is not None: + self.spent[self.stage] = now - self.entered + self.stage, self.entered, self.reported, self.reported_at = stage, now, 0., now + self.unit, self.step_seconds = .1, None + # A stage that was skipped over counts as done in no time. + for earlier in self.stages[:self.stages.index(stage)]: + self.spent.setdefault(earlier, 0.) + if fraction > self.reported: + gap = now - self.reported_at + if self.reported > 0 or fraction < 1: + self.unit = max(.01, fraction - self.reported) if self.reported > 0 or fraction < .5 else self.unit + self.step_seconds = gap if gap > 0 else self.step_seconds + self.reported, self.reported_at = fraction, now + + def _ratio(self): + """How much slower or faster than predicted the finished stages ran (damped, 0.5 .. 3).""" + spent = dict(self.spent) + running = self.stage in self.predicted and self.clock() - self.entered + if running and running > 1.2 * self.predicted[self.stage]: + spent[self.stage] = running # already over its prediction: at least this slow + done = [s for s in spent if self.predicted.get(s, 0) > 1.] + want = sum(self.predicted[s] for s in done) + if not done or want <= 0: + return 1. + ratio = sum(spent[s] for s in done) / want + weight = .5 if self.kind == 'prior' else .3 + return min(3., max(.5, 1 + (ratio - 1) * weight)) + + def _inside(self, now): + """Fraction of the current stage, the reported one plus the pace of the last step up to the next step.""" + if self.stage is None: + return 0. + fraction = self.reported + if fraction >= 1: + return 1. + over = now - self.entered - self.predicted.get(self.stage, 10.) + if over > 0 and not self.step_seconds: + # Past its prediction without any step report: keep creeping towards the end, never stand still. + return .8 + .17 * (1 - math.exp(-over / max(10., self.predicted.get(self.stage, 10.)))) + pace = self.step_seconds or max(1., self.predicted.get(self.stage, 10.) * self.unit) + since = now - self.reported_at + return min(fraction + self.unit * .95, fraction + self.unit * since / max(.5, pace)) if self.reported > 0 or self.step_seconds else \ + min(.8, since / max(1., self.predicted.get(self.stage, 10.) * 1.3)) + + def view(self): + now = self.clock() + ratio = self._ratio() + index = self.stages.index(self.stage) if self.stage in self.stages else -1 + # Finished stages count with the time they really took, the others with the corrected prediction. + before = total = sum(self.spent.get(s, 0.) for s in self.stages[:max(index, 0)]) + current = self.predicted.get(self.stage, 0.) if index >= 0 else 0. + after = sum(self.predicted[s] for s in self.stages[index + 1:]) * ratio + inside = self._inside(now) + current_left = max(0., current * ratio * (1 - inside)) if index >= 0 else 0. + wanted = total + current * ratio + after + done = before + current * ratio * inside + target = min(CEILING, done / wanted) if wanted > 0 else 0. + # Monotone, and never faster than the number can be followed. + dt = max(0., now - self.shown_at) + if now - self.reported_at > 5. and self.stage is not None: + target = max(target, min(CEILING, self.shown + MIN_RATE / 100 * dt)) # still running: never stand still + self.shown = max(self.shown, min(target, self.shown + MAX_RATE / 100 * dt)) + self.shown_at = now + left = current_left + after + if self.left is None: + self.left = left + else: + # Counts down in real time and follows the new prediction slowly: no jump up and down. + counted = max(0., self.left - (now - self.left_at)) + dt = now - self.left_at + step = (left - counted) * min(1., dt / 20.) + # A longer prediction raises the time left gradually, never by more than about a tenth per 30 s. + self.left = counted + min(step, max(.1 * counted, 10.) * dt / 30.) + self.left_at = now + return dict(overall=round(self.shown, 4), remaining_seconds=round(self.left, 1), + typical_seconds=round(sum(self.predicted.values()), 1), basis=self.kind) diff --git a/freevideo_engine/image_gen/qwen_pipeline.py b/freevideo_engine/image_gen/qwen_pipeline.py index e724fe08..d31abad2 100644 --- a/freevideo_engine/image_gen/qwen_pipeline.py +++ b/freevideo_engine/image_gen/qwen_pipeline.py @@ -386,7 +386,8 @@ def report(stage, fraction): latents = self._unpack_latents(latents, height, width).to(dtype=_dtype(self.vae)) mean, std = self._stats(latents) latents = latents * std + mean - kwargs = {"return_dict": False} + # Tiles are most of a large decode; the blend and the copy to the host take the rest. + kwargs = {"return_dict": False, "progress": lambda done, total: report("decode", .9 * done / total)} if self.vae_tile: kwargs["tile"] = True if self.vae_tile_size: diff --git a/freevideo_engine/image_gen/qwen_vae.py b/freevideo_engine/image_gen/qwen_vae.py index 80748eae..191113e5 100644 --- a/freevideo_engine/image_gen/qwen_vae.py +++ b/freevideo_engine/image_gen/qwen_vae.py @@ -1193,6 +1193,8 @@ def __init__( # The minimal distance between two spatial tiles self.tile_sample_stride_height = 192 self.tile_sample_stride_width = 192 + # Called as (tiles done, tiles in all) while a tiled decode runs; set by the caller. + self.tile_progress = None # Precompute and cache conv counts for encoder and decoder for clear_cache speedup self._cached_conv_counts = { @@ -1358,9 +1360,10 @@ def _decode(self, z: torch.Tensor, return_dict: bool = True): # Copied from diffusers.models.autoencoders.autoencoder_kl_wan.AutoencoderKLWan.decode def decode(self, z: torch.Tensor, return_dict: bool = True, *, tile: bool | None = None, - tile_size: int | None = None, tile_stride: int | None = None) -> DecoderOutput | torch.Tensor: + tile_size: int | None = None, tile_stride: int | None = None, + progress=None) -> DecoderOutput | torch.Tensor: r""" - Decode a batch of images. + Decode a batch of images; ``progress(done, total)`` is called after each tile of a tiled decode. Args: z (`torch.Tensor`): Input batch of latent vectors. @@ -1375,6 +1378,7 @@ def decode(self, z: torch.Tensor, return_dict: bool = True, *, tile: bool | None previous_tiling = self.use_tiling previous_tiles = (self.tile_sample_min_height, self.tile_sample_min_width, self.tile_sample_stride_height, self.tile_sample_stride_width) + self.tile_progress = progress if tile is not None: self.use_tiling = tile if tile_size is not None: @@ -1389,6 +1393,7 @@ def decode(self, z: torch.Tensor, return_dict: bool = True, *, tile: bool | None else: decoded = self._decode(z).sample finally: + self.tile_progress = None self.use_tiling = previous_tiling (self.tile_sample_min_height, self.tile_sample_min_width, self.tile_sample_stride_height, self.tile_sample_stride_width) = previous_tiles @@ -1530,9 +1535,12 @@ def tiled_decode(self, z: torch.Tensor, return_dict: bool = True) -> DecoderOutp rows = [] # Stop once a tile already covers the edge: a redundant narrow last tile has little # context and leaves coloured seams. - for i in range(0, max(0, height - tile_latent_min_height) + tile_latent_stride_height, tile_latent_stride_height): + tile_rows = range(0, max(0, height - tile_latent_min_height) + tile_latent_stride_height, tile_latent_stride_height) + tile_columns = range(0, max(0, width - tile_latent_min_width) + tile_latent_stride_width, tile_latent_stride_width) + tile_count, tiles_done = len(tile_rows) * len(tile_columns), 0 + for i in tile_rows: row = [] - for j in range(0, max(0, width - tile_latent_min_width) + tile_latent_stride_width, tile_latent_stride_width): + for j in tile_columns: self.clear_cache() time = [] for k in range(num_frames): @@ -1545,6 +1553,9 @@ def tiled_decode(self, z: torch.Tensor, return_dict: bool = True) -> DecoderOutp ) time.append(decoded) row.append(torch.cat(time, dim=2)) + tiles_done += 1 + if self.tile_progress is not None: + self.tile_progress(tiles_done, tile_count) rows.append(row) self.clear_cache() diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index 18f44a71..0a873000 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -17,7 +17,7 @@ import time import uuid -from . import image_model, image_pack, keep +from . import image_model, image_pack, keep, progress_model from .loras import ERRORS as LORA_ERRORS, resolve_selection from ..download_settings import observed_speed from .failure import (Context, HOST_MESSAGE_ZH, host_memory_shortage, ERRORS, FailureError, MESSAGE_ZH, NO_REPORT, STAGES, UNIFIED_MESSAGE_ZH, @@ -134,6 +134,7 @@ def __init__(self, installation, input_directory, *, video_busy=None, idle_secon self.worker = None self.key = None self.state = {} + self.preview = None # {'serial', 'jpeg'}: the running job's newest step picture self._busy = False self.closed = False self.yielding = False @@ -141,6 +142,8 @@ def __init__(self, installation, input_directory, *, video_busy=None, idle_secon self.context = None self.last_machine = {} self.worker_restarts = 0 + # The last memory_budget reading of each calling thread, for the report. + self.budget_reading = threading.local() self.worker_started = False self.queued = None self.habit_root = None @@ -634,7 +637,7 @@ def prepare(self, action, value, context, *, waiting=False): comfy_python=machine.get('comfy_python'), model_paths=machine.get('model_paths')) context.update_request(value) # H3 Image plans its own residency in the worker; the Qwen plan below does not describe it. - context.plan = None if h3 else self.preflight(machine, value, fit=not waiting) + context.plan = None if h3 else self.preflight(machine, value, fit=not waiting, reading=context.gpu_reading) # Live free memory while another job owns the GPU is advisory. Keep # the original request for dispatch, when the owner has released it. if not waiting and context.plan is not None and not context.plan['feasible']: @@ -662,6 +665,7 @@ def begin(self, key, root, machine, value, context): self.key = key self.stop.clear() self.state = dict(phase='starting', stage='start') + self.preview = None if 'fitted' in value: self.state['fitted'] = deepcopy(value['fitted']) self.context = context @@ -745,6 +749,7 @@ def memory_budget(self, machine): from ..comfy_residency import native_reclaimable # ComfyUI's own idle models in this process: work() unloads them before the worker starts. native = native_reclaimable(comfy_server()) + seen = {} def read(): gpu = Gpu(machine.get('gpu_uuid')) @@ -755,6 +760,7 @@ def read(): used, total = memory if not total or not 0 <= used <= total: return None + seen.update(total_bytes=total, used_bytes=used, native_credit_bytes=native) # work() releases idle video/compile models; the image worker # reuses its own allocator. Never credit an unrelated process. owned = set() @@ -766,19 +772,29 @@ def read(): or any(rows.get(pid) is None for pid in owned if pid in rows)): # An unreported owned pool must not cause a false refusal. # The worker still checks live capacity before inference. + seen.update(owned_unreported=True, budget_bytes=total) return total credit = sum(size for pid, size in rows.items() if pid in owned) - return min(total, total - used + credit + native) + budget = min(total, total - used + credit + native) + seen.update(owned_credit_bytes=credit, budget_bytes=budget) + return budget finally: gpu.close() try: - return open_bounded(read, .5) + budget = open_bounded(read, .5) except (OSError, RuntimeError, AttributeError, ValueError): return None + # What the plan was made from, for the report: a refusal at 0 s otherwise says + # only that the budget was small, not how much of the card was in use. + self.budget_reading.value = dict(seen) if budget is not None else None + return budget - def preflight(self, machine, value, *, fit=True): + def preflight(self, machine, value, *, fit=True, reading=None): + self.budget_reading.value = None budget = self.memory_budget(machine) + if reading is not None: + reading.update(self.budget_reading.value or {}) if budget is None: return from PIL import Image @@ -843,8 +859,9 @@ def failed(self, code, context, *, row=None, error=None, video_busy=None): report = context.report(code, row=row, error=error, video_busy=video_busy, worker_restarts=self.worker_restarts) with self.mutex: - report_id = uuid.uuid4().hex + report_id = getattr(context, 'report_id', None) or uuid.uuid4().hex self.reports[report_id] = report + self.reports.move_to_end(report_id) while len(self.reports) > REPORT_LIMIT: self.reports.popitem(last=False) result['report_id'] = report_id @@ -856,16 +873,86 @@ def report(self, report_id): return None with self.mutex: report = self.reports.get(report_id) + context = self.context + if report is None and context is not None and getattr(context, 'report_id', None) == report_id and self._busy: + report = self.progress_report(context) # Callers cannot mutate a retained report through an in-process reference. return json.loads(json.dumps(report, allow_nan=False)) if report is not None else None + def forecast_facts(self, context): + forecast = getattr(context, 'forecast', None) + if forecast is None: + return None + return dict(basis=forecast.kind, typical_seconds=round(sum(forecast.predicted.values()), 1), + predicted={stage: round(seconds, 1) for stage, seconds in forecast.predicted.items()}) + + def progress_report(self, context): + """The report of a request that is still running: what has happened so far.""" + return context.report('running', outcome='running', worker_restarts=self.worker_restarts, + forecast=self.forecast_facts(context)) + + def start_forecast(self, context, value, habit): + """The stage-time forecast of this request: this machine's earlier runs of the same size, else priors.""" + try: + images = value.get('images') or [] + key = progress_model.key_for(value.get('model') or '', value['width'], value['height'], len(images)) + history = progress_model.History(Path(habit).with_name('image-stage-times.json')) + predicted, kind = history.predict(key, bool(images)) + context.forecast = progress_model.Forecast(predicted, kind) + context.forecast.update('load', 0.) + context.forecast_key, context.forecast_history = key, history + except (KeyError, TypeError, ValueError, OSError): + context.forecast = None + + def keep_forecast(self, context): + if getattr(context, 'forecast', None) is None: + return + seconds = {row['stage']: row['seconds'] for row in context.timing.snapshot()} + context.forecast_history.record(context.forecast_key, seconds) + + def keep_preview(self, row): + """Keep the worker's newest step picture; the status row says which one it is, and `preview_image` returns it.""" + from .step_preview import LIMIT + step, total, width, height, data = (row.get(name) for name in ('step', 'total', 'width', 'height', 'jpeg')) + if not (type(step) is int and type(total) is int and 1 <= step <= total <= 64 + and type(width) is int and type(height) is int and 1 <= width <= 1024 and 1 <= height <= 1024 + and isinstance(data, str) and len(data) <= LIMIT): + return + import base64 + try: + jpeg = base64.b64decode(data, validate=True) + except ValueError: + return + if not jpeg.startswith(b'\xff\xd8'): + return + with self.mutex: + if self.state.get('phase') not in ('starting', 'progress', 'cancelling'): + return + serial = (self.preview or {}).get('serial', 0) + 1 + self.preview = dict(serial=serial, jpeg=jpeg) + self.state['preview'] = dict(step=step, total=total, serial=serial, width=width, height=height) + + def preview_image(self, key): + """(serial, JPEG bytes) of the running job's newest step picture, or None.""" + with self.mutex: + if (isinstance(key, str) and self.key is not None and self.preview is not None + and secrets.compare_digest(key.encode('utf-8'), self.key.encode('utf-8'))): + return self.preview['serial'], self.preview['jpeg'] + return None + def status(self, key): with self.mutex: if isinstance(key, str): def matches(candidate): return candidate is not None and secrets.compare_digest(key.encode('utf-8'), candidate.encode('utf-8')) if matches(self.key): - return deepcopy(self.state) + row = deepcopy(self.state) + forecast = getattr(self.context, 'forecast', None) + if forecast is not None and row.get('phase') in ('progress', 'cancelling'): + row.update(forecast.view()) + if row.get('phase') in ('progress', 'cancelling') and getattr(self.context, 'report_id', None): + row['report_id'] = self.context.report_id + return row if self.queued is not None and matches(self.queued['key']): return deepcopy(self.queued['state']) for candidate, state in self.finished.items(): @@ -1081,6 +1168,7 @@ def respawn(): video_busy=failure == 'busy_video')) if fitted is not None: self.state['fitted'] = fitted + self.preview = None self._busy = False self.context = None self.queue_wake.set() @@ -1173,6 +1261,8 @@ def infer(self, session, value): keep.note('image', habit) # How long the worker may wait for the next picture; it applies that only with the whole model on the card. value['keep_seconds'] = keep.keep_seconds(habit) + context.report_id = uuid.uuid4().hex + self.start_forecast(context, value, habit) loading = True limit = H3_INFERENCE_TIMEOUT if value.get('model') == image_model.H3 else INFERENCE_TIMEOUT try: @@ -1210,8 +1300,12 @@ def infer(self, session, value): if self.state.get('stage') == stage and type(self.state.get('fraction')) in (int, float): fraction = max(fraction, self.state['fraction']) context.timing.enter(stage) + if getattr(context, 'forecast', None) is not None: + context.forecast.update(stage, float(fraction)) self.state.update(phase='cancelling' if self.stop.is_set() else 'progress', stage=stage, fraction=fraction) + elif phase == 'preview': + self.keep_preview(row) elif phase in ('result', 'error'): if self.stop.is_set() or row.get('code') == 'cancelled': result = dict(phase='cancelled') @@ -1235,14 +1329,25 @@ def infer(self, session, value): seconds = row.get('seconds') if type(seconds) not in (int, float) or not math.isfinite(seconds) or seconds < 0: raise RuntimeError('worker_exit') + self.keep_forecast(context) + report_id = getattr(context, 'report_id', None) + if report_id: + done = context.report('succeeded', row=row, outcome='succeeded', worker_restarts=self.worker_restarts, + forecast=self.forecast_facts(context)) + with self.mutex: + self.reports[report_id] = done + self.reports.move_to_end(report_id) + while len(self.reports) > REPORT_LIMIT: + self.reports.popitem(last=False) result = dict(phase='complete', file='freevideo-image/' + path.name, - seconds=seconds, plan=row.get('plan')) + seconds=seconds, plan=row.get('plan'), report_id=report_id) if isinstance(row.get('attention'), dict): result['attention'] = row['attention'] with self.mutex: if 'fitted' in self.state: result['fitted'] = self.state['fitted'] self.state = result + self.preview = None self._busy = False self.context = None self.queue_wake.set() @@ -1335,6 +1440,12 @@ async def action(request): result = page_views(value.get('description'), value.get('kind', 'character')) elif name == 'status': result = service.status(value.get('job')) + elif name == 'preview': + found = service.preview_image(value.get('job')) + if found is None: + return web.json_response(dict(error='no_preview'), status=404, headers={'Cache-Control': 'no-store'}) + return web.Response(body=found[1], content_type='image/jpeg', + headers={'Cache-Control': 'no-store', 'X-FreeVideo-Preview-Serial': str(found[0])}) elif name == 'cancel': result = service.cancel(value.get('job')) elif name in ('pack-install', 'pack-cancel'): diff --git a/freevideo_engine/image_gen/step_preview.py b/freevideo_engine/image_gen/step_preview.py new file mode 100644 index 00000000..0a00f171 --- /dev/null +++ b/freevideo_engine/image_gen/step_preview.py @@ -0,0 +1,87 @@ +"""A small picture of an H3 Image request after each sampling step. + +After each of the six steps the sampler holds the model's prediction of the +clean latents (``rows + sigma * velocity``). A fixed per-pixel affine map from +the 24 latent channels to RGB draws it at an eighth of the canvas without the +VAE. The observer only reads the sampler's tensors and never writes to them, +so the picture is identical with previews on or off. Any failure turns +previews off for the rest of the request; the picture itself is unaffected. +""" +import io +from pathlib import Path + +WEIGHTS = Path(__file__).with_name('step_preview_weights.json') +CHANNELS = 24 +ROW_WIDTH = CHANNELS * 4 # 24 channels x a 2 x 2 patch +MAX_SIDE = 512 +QUALITY = 80 +FIRST_STEP = 1 +# Base64 characters of one picture on the worker's pipe; the service drops longer lines (128 KiB). +# 16 MP pictures measured 15-30 KB as JPEG, so this is never reached in practice. +LIMIT = 96 * 1024 + + +def load_weights(path=None): + """(k, weight[24][3k^2], bias[3k^2]); ValueError when malformed. Same file format as the video preview's.""" + from ..frame_preview import load_weights as load + return load(WEIGHTS if path is None else path) + + +def render(rows, velocity, sigma, latent_h, latent_w, weights, tensors=None, max_side=MAX_SIDE): + """uint8 (height, width, 3) numpy picture of ``rows + sigma * velocity``; ``tensors`` caches the map on the device.""" + import torch + k, weight, bias = weights + lh, lw = latent_h // 2, latent_w // 2 + with torch.no_grad(): + x0 = rows.detach().float() + float(sigma) * velocity.detach().float() + # rows are raster-ordered 2 x 2 patches with features (C, ph, pw) -> (lh*2, lw*2, C) + x0 = x0.reshape(lh, lw, CHANNELS, 2, 2).permute(0, 3, 1, 4, 2).reshape(lh * 2 * lw * 2, CHANNELS) + if tensors is None or tensors[0].device != x0.device: + tensors = (torch.tensor(weight, dtype=torch.float32, device=x0.device), + torch.tensor(bias, dtype=torch.float32, device=x0.device)) + out = torch.addmm(tensors[1], x0, tensors[0]) + out = out.reshape(lh * 2, lw * 2, 3, k, k).permute(2, 0, 3, 1, 4).reshape(1, 3, lh * 2 * k, lw * 2 * k) + height, width = out.shape[-2:] + scale = max_side / max(height, width) + if scale < 1: + size = (max(1, round(height * scale)), max(1, round(width * scale))) + out = torch.nn.functional.interpolate(out, size=size, mode='area') + picture = out[0].clamp(0., 1.).mul(255.).round().to(torch.uint8).permute(1, 2, 0) + return picture.cpu().numpy(), tensors + + +def encode(picture, quality=QUALITY): + """JPEG bytes of a uint8 (height, width, 3) picture.""" + from PIL import Image + buffer = io.BytesIO() + Image.fromarray(picture).save(buffer, 'JPEG', quality=quality) + return buffer.getvalue() + + +class Previewer: + """The sampler's ``preview_callback``: ``send(step=, total=, width=, height=, jpeg=bytes)`` per step from FIRST_STEP. + + Never raises: the first failure (weights missing, a full card, a closed pipe) turns it off for the request. + """ + def __init__(self, send, weights=None): + self.send = send + self.tensors = None + try: + self.weights = load_weights() if weights is None else weights + except Exception: + self.weights = None + + @property + def active(self): + return self.weights is not None + + def __call__(self, step, total, rows, velocity, sigma, latent_h, latent_w): + if self.weights is None or step < FIRST_STEP: + return + try: + if rows.ndim != 2 or rows.shape[1] != ROW_WIDTH or rows.shape[0] != (latent_h // 2) * (latent_w // 2): + raise ValueError('unexpected rows') + picture, self.tensors = render(rows, velocity, sigma, latent_h, latent_w, self.weights, self.tensors) + self.send(step=step, total=total, width=picture.shape[1], height=picture.shape[0], jpeg=encode(picture)) + except Exception: + self.weights = self.tensors = None diff --git a/freevideo_engine/image_gen/step_preview_weights.json b/freevideo_engine/image_gen/step_preview_weights.json new file mode 100644 index 00000000..e66ac0f0 --- /dev/null +++ b/freevideo_engine/image_gen/step_preview_weights.json @@ -0,0 +1 @@ +{"version": 1, "k": 2, "channels": 24, "weight_layout": "weight[c][j]: 24 rows x (3*k*k) columns", "output_layout": "j=rgb*k*k+dy*k+dx; out[h*k+dy,w*k+dx,rgb]=sum_c v[c]*weight[c][j]+bias[j]", "input": "normalized H3 Image latents (the clean prediction rows + sigma * velocity), no extra mean/std transform", "output": "RGB [0,1], clip after affine projection", "fitted": "least squares on 18 H3 Image pictures at 2048x2048, target = picture area-downsampled to 1/8", "precision": "float32 values serialized as round-trippable JSON numbers", "weight": [[-0.020519856363534927, 0.03680306300520897, 0.004424100276082754, 0.08473287522792816, -0.11772982031106949, -0.05241509899497032, -0.0814192146062851, 0.0061472756788134575, -0.12439466267824173, -0.06690968573093414, -0.08421661704778671, -0.0016526939580217004], [-0.014227217994630337, -0.05449267104268074, -0.03428851068019867, -0.08782711625099182, 0.06520423293113708, 0.015524079091846943, 0.03848354145884514, -0.02475234866142273, 0.02874596416950226, -0.015106922946870327, -0.0028913861606270075, -0.06170009821653366], [0.162856787443161, 0.21195290982723236, 0.31141921877861023, 0.29218655824661255, 0.08155624568462372, 0.13570670783519745, 0.2271956503391266, 0.21411658823490143, 0.035433728247880936, 0.08353801816701889, 0.1736077219247818, 0.15921205282211304], [0.03540787100791931, 0.11448779702186584, 0.018911439925432205, 0.034090232104063034, 0.0005923872231505811, 0.0820380449295044, -0.04114539176225662, -0.023947881534695625, -0.10835101455450058, -0.023958221077919006, -0.16008360683918, -0.13906283676624298], [-0.026500066742300987, 0.0861055999994278, -0.03817032277584076, 0.024212367832660675, 0.03879007324576378, 0.15740473568439484, 0.00564296031370759, 0.07287145406007767, -0.04544799402356148, 0.072591133415699, -0.08782859146595001, -0.019728347659111023], [0.003891203785315156, 0.09320669621229172, -0.023419832810759544, 0.01026424951851368, 0.029272451996803284, 0.12498550117015839, -0.020216813310980797, 0.018500205129384995, -0.01092728041112423, 0.0857962965965271, -0.07004400342702866, -0.028323182836174965], [-0.013983755372464657, -0.030397163704037666, -0.00191461481153965, 0.00557293277233839, -0.0009411891805939376, -0.02330782637000084, 0.006740887183696032, 0.007482477929443121, 0.0036195991560816765, -0.016408327966928482, 0.00751525117084384, 0.007632329594343901], [0.023903852328658104, -0.03992248699069023, 0.0058837332762777805, -0.016850681975483894, 0.045147236436605453, -0.019195787608623505, 0.01950722560286522, -0.005736263934522867, 0.03328397870063782, -0.026689941063523293, 0.0028007053770124912, -0.020056219771504402], [-0.013813482597470284, -0.03988497331738472, 0.02388756535947323, 0.03551537171006203, -0.006625063717365265, -0.03201927989721298, 0.025370286777615547, 0.03717278316617012, 0.00023325135407503694, -0.02308058738708496, 0.026688164100050926, 0.03862011805176735], [0.17954066395759583, 0.12186099588871002, 0.02626551128923893, 0.072877436876297, 0.1514047533273697, 0.0914347842335701, 0.0027990469243377447, 0.04485737532377243, 0.1464180201292038, 0.0909373015165329, 0.0060033793561160564, 0.04790378361940384], [-0.020550845190882683, 0.05586417391896248, -0.011942313052713871, 0.029313640668988228, -0.02684812806546688, 0.05268141254782677, -0.014247381128370762, 0.031145518645644188, -0.012515469454228878, 0.06130204722285271, 0.002395160961896181, 0.045828767120838165], [-0.05431250110268593, -0.010709956288337708, -0.02172488160431385, 0.0065878164023160934, -0.05917636305093765, -0.011582829989492893, -0.019354036077857018, 0.013655416667461395, -0.03814913332462311, 0.005661668721586466, 0.004399243742227554, 0.035826072096824646], [-0.00603556027635932, 0.013663294725120068, 0.0004822535265702754, 0.01898360438644886, -0.00826322939246893, 0.005639747716486454, -0.0004524134274106473, 0.012531141750514507, 0.007092363201081753, 0.01959075592458248, 0.015274045057594776, 0.026763267815113068], [-0.004926110617816448, -0.014171281829476357, 0.002532107522711158, -0.015473954379558563, 0.011279687285423279, 0.0037224269472062588, 0.014464639127254486, -0.0018046577461063862, 0.0058720908127725124, 0.001314458786509931, 0.006644784472882748, -0.005703279282897711], [-0.026093456894159317, -0.004506231751292944, -0.034460775554180145, 0.00797981582581997, -0.0057359179481863976, 0.009806254878640175, -0.017637362703680992, 0.018369289115071297, 0.004375911783427, 0.019846854731440544, -0.010138836689293385, 0.024212555959820747], [0.024607183411717415, 0.009367811493575573, 0.02761935442686081, -0.0030472087673842907, 0.004614030476659536, -0.005269862245768309, 0.011440549977123737, -0.013029621914029121, -0.005496395751833916, -0.015686139464378357, 0.004156784620136023, -0.019529568031430244], [0.01315641961991787, 0.0033930593635886908, 0.006574562285095453, -0.003854434471577406, 0.008533786982297897, 0.0013751765945926309, 0.0049570780247449875, -0.0030741007067263126, 0.00435589998960495, -0.0012374965008348227, 0.003411707701161504, -0.0035858096089214087], [-0.006562435533851385, 0.0003267154097557068, -0.0036392102483659983, 0.005411320831626654, 0.004809104837477207, 0.007884343154728413, 0.0037595333997160196, 0.009283755905926228, 0.01492139883339405, 0.017285114154219627, 0.010781857185065746, 0.016648918390274048], [0.005621402524411678, 0.006449949461966753, -0.0054878187365829945, -0.009105413220822811, 0.006198833230882883, 0.007364932913333178, -0.006209773477166891, -0.009386732243001461, 0.0027236128225922585, 0.004144981503486633, -0.00949072279036045, -0.012355150654911995], [0.004669112619012594, 0.01257795188575983, 0.005608001258224249, 0.010781609453260899, -0.001603704527951777, 0.004678204655647278, 0.0008960458799265325, 0.004318838007748127, 0.004992329049855471, 0.01187984924763441, 0.007191287819296122, 0.010987618006765842], [0.0017026275163516402, -0.013969273306429386, -0.006758732255548239, -0.016799742355942726, 0.006843465380370617, 0.00017784544616006315, 0.0015148487873375416, 0.0007431263802573085, 0.00991683267056942, 0.006829846650362015, 0.006275269668549299, 0.008987435139715672], [-0.008934392593801022, -0.0034802090376615524, -0.0009123934432864189, 0.0036347955465316772, -0.010938027873635292, -0.008241395466029644, -0.004556691274046898, -0.0028113320004194975, -0.012604079209268093, -0.012243703007698059, -0.008746710605919361, -0.008331727236509323], [0.006368798669427633, -0.0028802950400859118, 0.0013602462131530046, -0.004128752276301384, 0.00865787174552679, 0.0006751946057192981, 0.0038771694526076317, 0.0003610570856835693, 0.0067396159283816814, -0.0004248875775374472, 0.003557046176865697, 0.0007409221725538373], [-0.007960989139974117, 0.0033710950519889593, -0.010085073299705982, 0.00017908995505422354, 0.0005519220721907914, 0.007029206957668066, -0.0034952196292579174, 0.0020514989737421274, 0.0028743247967213392, 0.006772557273507118, -0.0029004511889070272, 0.0006415639072656631]], "bias": [0.5767930746078491, 0.5776917338371277, 0.5720980167388916, 0.5950961112976074, 0.5413785576820374, 0.5361343026161194, 0.5406692624092102, 0.557608425617218, 0.5051232576370239, 0.49623924493789673, 0.507293701171875, 0.5186836123466492]} diff --git a/freevideo_engine/image_gen/worker.py b/freevideo_engine/image_gen/worker.py index a915347a..60d39455 100644 --- a/freevideo_engine/image_gen/worker.py +++ b/freevideo_engine/image_gen/worker.py @@ -1,5 +1,6 @@ """Persistent ImageRunner owner; stdin/stdout contain one JSON object per line.""" import argparse +import base64 from contextlib import ExitStack, redirect_stdout import json import os @@ -11,6 +12,7 @@ from . import keep from .failure import Context, classify_exception, exception_details, host_memory_shortage, safe_plan +from .step_preview import LIMIT as PREVIEW_LIMIT class Inbox: @@ -174,13 +176,20 @@ def progress(stage, fraction): region['edit_mask'].load() loras = value.get('loras') or None back_check = None + def preview(*, step, total, width, height, jpeg): + data = base64.b64encode(jpeg).decode('ascii') + if len(data) <= PREVIEW_LIMIT: + send(phase='preview', step=step, total=total, width=width, height=height, jpeg=data) + def generate(seed): return runner.generate( value['prompt'], width=value['width'], height=value['height'], seed=seed, reference_images=references, **region, **(dict(loras=loras) if loras else {}), progress=progress, cancel=inbox.cancel.is_set, - **(dict(setup=value['h3']) if model == 'minimax-h3-image' and value.get('h3') else {})) + **(dict(setup=value['h3']) if model == 'minimax-h3-image' and value.get('h3') else {}), + # Which stage a failure belongs to, without a progress row the page would show. + **(dict(preview=preview, stage=context.timing.enter) if model == 'minimax-h3-image' else {})) if value.get('cast_view') == 'back' and len(references) == 1 and not region: from .back_view import generate_back, load_detector diff --git a/freevideo_engine/launcher/Main.qml b/freevideo_engine/launcher/Main.qml index 309e003a..d8d5bcec 100644 --- a/freevideo_engine/launcher/Main.qml +++ b/freevideo_engine/launcher/Main.qml @@ -42,7 +42,7 @@ ApplicationWindow { readonly property bool usingRuntime: manualEnvironment && s.offline.runtime readonly property bool needsRuntime: s.page === "comfy" && manualEnvironment && !s.offline.runtime // An imported environment can take its models from packages or download them. - readonly property bool offlineSelected: s.form.model_method === "manual" + readonly property bool offlineSelected: s.form.model_method === "manual" && !s.image_only readonly property bool needsPackages: s.page === "models" && offlineSelected && s.offline.models === 0 && (usingRuntime || s.form.model_dirs.length === 0) readonly property int step: s.page === "comfy" ? 0 : s.page === "models" ? 1 : 2 property string previousPage: "" @@ -614,8 +614,28 @@ ApplicationWindow { ColumnLayout { objectName: "modelsPage" visible: s.page === "models"; Layout.fillWidth: true; spacing: 16 + // One model or the other, never both at once; the one not chosen can be added later in the app. + FText { objectName: "installModelHeading"; visible: !!s.model_choice; text: t("Which model to install?", "选择模型"); font.pixelSize: theme.strong; font.weight: Font.DemiBold; Layout.fillWidth: true } + RowLayout { + objectName: "installModelChoice"; visible: !!s.model_choice + Layout.fillWidth: true; spacing: 12 + FChoice { + objectName: "installVideoModel"; Layout.fillWidth: true; Layout.minimumWidth: 0; Layout.preferredWidth: 1; Layout.fillHeight: true + text: "MiniMax H3\u00a0Video" + detail: checked ? t("Makes videos.", "生成视频。") : t("Makes videos. Can be added later in the app.", "生成视频。以后可在应用内添加。") + checked: !s.image_only; enabled: !s.busy + onClicked: backend.edit("install_model", "video") + } + FChoice { + objectName: "installImageModel"; Layout.fillWidth: true; Layout.minimumWidth: 0; Layout.preferredWidth: 1; Layout.fillHeight: true + text: "MiniMax H3\u00a0Image" + detail: s.image_installed ? t("Installed.", "已安装。") : checked ? t("Makes pictures.", "生成图片。") : t("Makes pictures. Can be added later in the app.", "生成图片。以后可在应用内添加。") + checked: !!s.image_only; enabled: !s.busy + onClicked: backend.edit("install_model", "image") + } + } FCard { - objectName: "modelLibraries"; visible: !usingRuntime; Layout.fillWidth: true; padding: 20; spacing: 10 + objectName: "modelLibraries"; visible: !usingRuntime && !s.image_only; Layout.fillWidth: true; padding: 20; spacing: 10 RowLayout { Layout.fillWidth: true; spacing: 12 ColumnLayout { @@ -644,8 +664,10 @@ ApplicationWindow { 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("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 { visible: !s.image_only; 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 { + // MiniMax H3 Image downloads by itself; the offline packages hold the video model. + objectName: "modelMethodChoice"; visible: !s.image_only Layout.fillWidth: true; spacing: 12 FChoice { objectName: "automaticMethod"; Layout.fillWidth: true; Layout.minimumWidth: 0; Layout.preferredWidth: 1; Layout.fillHeight: true @@ -715,7 +737,7 @@ ApplicationWindow { } FCard { objectName: "aheadCard" - visible: !manualEnvironment && !offlineSelected; Layout.fillWidth: true; padding: 16; spacing: 12 + visible: !manualEnvironment && !offlineSelected && !s.image_only; Layout.fillWidth: true; padding: 16; spacing: 12 FText { text: t("Download ahead", "提前下载"); font.weight: Font.DemiBold; Layout.fillWidth: true } RowLayout { Layout.fillWidth: true; spacing: 14 @@ -995,6 +1017,20 @@ ApplicationWindow { FCheck { visible: s.needs_consent; text: s.consent; checked: accepted; enabled: !s.setup_job_wait; onToggled: accepted = checked; Layout.fillWidth: true } FButton { objectName: "bundleLicensesButton"; visible: s.needs_consent; text: t("View licenses", "查看许可证"); flat: true; implicitHeight: theme.heightSm - 2; leftPadding: 32; font.pixelSize: theme.micro + 1; onClicked: licensesOpen = true } } + FCard { + // Set up with MiniMax H3 Image alone: the video model is added through a reviewed installation. + objectName: "videoModelCard"; visible: !!s.video_missing + Layout.fillWidth: true; padding: 22; spacing: 10 + RowLayout { + Layout.fillWidth: true; spacing: 14 + ColumnLayout { + Layout.fillWidth: true; spacing: 4 + FText { objectName: "videoModelTitle"; text: "MiniMax H3\u00a0Video"; font.weight: Font.DemiBold; Layout.fillWidth: true } + FText { objectName: "videoModelStatus"; text: t("Not installed", "未安装"); color: theme.muted; Layout.fillWidth: true } + } + FButton { objectName: "videoModelAdd"; text: t("Add", "添加"); enabled: !!s.can_add_video; onClicked: backend.action("add-video", false) } + } + } FCard { objectName: "imagePackCard" visible: !!s.image_pack && s.image_pack.visible @@ -1008,7 +1044,7 @@ ApplicationWindow { FText { objectName: "imagePackTitle"; text: s.image_pack.title; font.weight: Font.DemiBold; Layout.fillWidth: true } FText { objectName: "imagePackStatus"; visible: s.image_pack.install.state !== "failed"; text: s.image_pack.detail; color: theme.muted; Layout.fillWidth: true } } - FText { objectName: "imagePackSize"; text: bytes(s.image_pack.total_bytes).replace(" ", "\u00a0"); color: theme.muted } + FText { objectName: "imagePackSize"; text: gb(s.image_pack.total_bytes); color: theme.muted } FButton { objectName: "imagePackInstall"; visible: !s.image_pack.ready && !s.image_pack.busy; text: s.image_pack.downloaded_bytes > 0 ? t("Resume", "继续") : t("Download", "下载"); enabled: s.image_pack.can_act; onClicked: backend.imagePackAction("install") } FButton { objectName: "imagePackCancel"; visible: s.image_pack.busy; text: t("Pause", "暂停"); flat: true; onClicked: backend.imagePackAction("cancel") } FButton { objectName: "imagePackRepair"; visible: s.image_pack.repairable && s.image_pack.downloaded_bytes > 0 && !s.image_pack.busy; text: t("Repair", "修复"); flat: true; enabled: s.image_pack.can_act; onClicked: backend.imagePackAction("repair") } diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index 32b38e84..1e2fcef4 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -120,6 +120,9 @@ class Session: upgrade = image_installer = h3_installer = prompt_installer = None model_switch = None language = 'en' + # Raised when the app asks for the launcher (adding the video model); the window comes to the front once per raise. + attention = 0 + add_video_seen = None def __init__(self, source=None, *, controller=None, store=None, updater=None, smoke=False): from .offline_packages import Importer @@ -140,7 +143,8 @@ def __init__(self, source=None, *, controller=None, store=None, updater=None, sm url='http://127.0.0.1:8188', models='', model_dirs=[], model_method='auto', environment_method='auto', separate=False, repair=False, new_comfy=True, offline_runtime='', offline_models=[], imported_archives=[], kept_archives=[], sampling_caches=not saved.get('installation'), - image_model=False, prompt_model=False, prompt_hint_dismissed=False) + image_model=False, prompt_model=False, prompt_hint_dismissed=False, + install_model='video') self.form['lan_access'] = False self.form.update({k: v for k, v in saved.items() if k in self.form}) if 'environment_method' not in saved and self.form['offline_runtime']: @@ -338,6 +342,8 @@ def edit(self, key, value): '%s 运行环境由安装器自动准备,请在下一步导入模型包。' % system)) if key == 'model_method' and value not in ('auto', 'manual', 'reuse'): raise ValueError('Unknown model download method') + if key == 'install_model' and value not in ('video', 'image'): + raise ValueError('Unknown model to install') if self.form[key] == value: return self.repair_request = None @@ -516,10 +522,56 @@ def prepare_offline(self): else: self.importer.prepare(self.form['offline_runtime'], self.form['offline_models'], self.source) + def installed_machine(self): + """machine.json of the installation this form points at; empty before one is ready.""" + try: + value = json.loads((self.engine_root() / 'machine.json').read_text(encoding='utf-8')) + except (OSError, ValueError, TypeError): + return {} + return value if isinstance(value, dict) and value.get('ready') else {} + + def video_missing(self): + """The selected installation was set up with MiniMax H3 Image alone (home shows "Add" for the video model).""" + engine = (self.selected or {}).get('engine') + if not isinstance(engine, str) or not engine: + return False + try: + value = json.loads((Path(engine) / 'machine.json').read_text(encoding='utf-8')) + except (OSError, ValueError, TypeError): + return False + return isinstance(value, dict) and value.get('ready') is True and value.get('video_model') is False + + def adding_video(self): + """The model step of an installation with MiniMax H3 Image alone, now with MiniMax H3 Video chosen: + "Check & continue" plans that installation even while its ComfyUI is open (setup stops it first).""" + return (self.page == 'models' and self.form.get('install_model') == 'video' + and self.installed_machine().get('video_model') is False) + + def model_choice(self, form=None): + """Whether this setup can install MiniMax H3 Image instead of the video model. + + Only a first installation (or one that already has no video model) offers it, on the + systems that carry MiniMax H3 Image, with the automatic environment. + """ + form = self.form if form is None else form + if sys.platform == 'darwin' or form.get('offline_runtime'): + return False + machine = self.installed_machine() + return not machine or machine.get('video_model') is False + + def image_only_choice(self, form=None): + form = self.form if form is None else form + return form.get('install_model') == 'image' and self.model_choice(form) + def setup_values(self, form=None, **extra): """Only the visible automatic-setup option can request every quality level.""" form = self.form if form is None else form values = dict(form, token=self.token, **extra) + if self.image_only_choice(form): + # MiniMax H3 Image alone: an automatic download of the engine and the image model, nothing of the video model. + values.update(only='image', model_method='auto', offline_models=[], sampling_caches=False, + image_model=False, prompt_model=False) + return values if (form['model_method'] == 'manual' or (runtime_packages_supported() and form['new_comfy'] and form['environment_method'] == 'manual')): @@ -606,6 +658,21 @@ def action(self, name, accepted=False, *, retry=None): self.model_groups = [] self.started = time.monotonic() return + if name == 'add-video': + # An installation with MiniMax H3 Image alone adds the video model: its model step opens with + # MiniMax H3 Video chosen. "Check & continue" plans it, and the review downloads only once accepted; + # files already there are kept. + if not self.can_repair(): + return + self.remember(self.selected, persist=False) + if self.installed_machine().get('video_model') is not False: + return + self.form['install_model'] = 'video' + self.repair_request = None + self.persist() + self.page = 'models' + self.attention += 1 + return if name == 'retry': kind = retry or self.retry_kind() if kind == 'import': @@ -702,7 +769,7 @@ def action(self, name, accepted=False, *, retry=None): if not accepted or self.controller.state.get('errors'): raise ValueError(self.t('Accept the reviewed installation plan to continue.', '请先同意本次安装计划及许可证。')) self.controller.run('install', True) - elif state in ('open', 'restart-required'): + elif state in ('open', 'restart-required') and not self.adding_video(): self.controller.run('connect') else: self.persist() @@ -895,6 +962,8 @@ def image_pack_report(self): def start_image_model_after_install(self): """The installer's "also install the image model": once ComfyUI opens, the download runs in the background.""" + if self.image_only_choice(): + return # A MiniMax H3 Image installation hides these ticks; an earlier tick must not start a download. if self.form['prompt_model'] and self.installation_engine() and self.controller.state.get('status') == 'open' \ and not self.controller.busy and not (self.prompt_installer and self.prompt_installer.busy): from .prompt_vlm import assets @@ -1988,6 +2057,16 @@ def _tick_page_check(self, row): address = row['url'].split('/?')[0] self.page_check.poll(address) self.page_error = self.page_check.text(self.t, self.browser_opened, address) + request = self.page_check.report.get('add_video') + at = request.get('at') if isinstance(request, dict) else None + if type(at) in (int, float) and at != self.add_video_seen: + # Only a click from the last minute: a launcher started later does not replay an old one. + if abs(time.time() - at) >= 60: + self.add_video_seen = at + # A busy launcher, or an installation review on screen, takes the click once it is free again. + elif self.can_repair() and self.page != 'progress': + self.add_video_seen = at + self.action('add-video', False) def engine_exit_text(self, row): """The sentence for a ComfyUI that stopped by itself while it was open; empty otherwise.""" @@ -2337,6 +2416,9 @@ def snapshot(self): rate='%.1f MiB/s' % (item['bytes_per_second']/2**20) if item.get('bytes_per_second') else '')) image_pack = self.image_pack_view() image_pack['enough_disk'] = self.image_disk_fits(image_pack['total_bytes']) + if self.video_missing(): + # Set up with MiniMax H3 Image alone: the older image models are not offered beside it. + image_pack['visible'] = False prompt_model = self.prompt_model_view() try: from .download_settings import observed_speed @@ -2489,6 +2571,9 @@ def snapshot(self): return plain_data(dict(download_list=download_state, sampling_cache_bytes=total_bytes(), version=__version__, image_pack=image_pack, prompt_model=prompt_model, bytes_per_second=speed, disk=disk, decimal_sizes=sys.platform == 'darwin', zh=self.language.startswith('zh'), form=dict(self.form), + model_choice=self.model_choice(), image_only=self.image_only_choice(), + image_installed=self.installed_machine().get('video_model') is False, + video_missing=self.page == 'launcher' and self.video_missing(), can_add_video=self.can_repair(), page=self.page, status=row.get('status', 'idle'), busy=busy, working=working, action_notice=self.t(*ACTION_COPY[self.action_notice]) if self.action_notice else '', setup_pending=bool(self.pending_action) or self.setup_job_wait, setup_job_wait=self.setup_job_wait, diff --git a/freevideo_engine/launcher_settings.py b/freevideo_engine/launcher_settings.py index c597f439..ed877269 100644 --- a/freevideo_engine/launcher_settings.py +++ b/freevideo_engine/launcher_settings.py @@ -47,6 +47,8 @@ def sanitize(value): result['model_method'] = value['model_method'] if value.get('environment_method') in ('auto', 'manual'): result['environment_method'] = value['environment_method'] + if value.get('install_model') in ('video', 'image'): + result['install_model'] = value['install_model'] folders = value.get('model_dirs') if isinstance(folders, list) and all(isinstance(p, str) for p in folders): result['model_dirs'] = list(dict.fromkeys(folders)) diff --git a/freevideo_engine/macos_doctor.py b/freevideo_engine/macos_doctor.py index 95466b18..9fb17cb6 100644 --- a/freevideo_engine/macos_doctor.py +++ b/freevideo_engine/macos_doctor.py @@ -107,8 +107,10 @@ def main(): from .monitoring import save save(args.out, result) print(json.dumps(result, indent=2)) - return int((args.probe and not result['ready']) or - (args.require_paths and missing_required_paths(result, video_models=not args.without_video_models))) + not_ready = bool(args.probe and not result['ready']) + missing = bool(args.require_paths and + missing_required_paths(result, video_models=not args.without_video_models)) + return int(not_ready or missing) if __name__ == '__main__': diff --git a/freevideo_engine/media_encoding.py b/freevideo_engine/media_encoding.py index 901f52b3..55c566bc 100644 --- a/freevideo_engine/media_encoding.py +++ b/freevideo_engine/media_encoding.py @@ -650,6 +650,12 @@ def encode_latents(normalized, value, base, canvas, *, resident=None, device='cu std = torch.tensor(vae.config.latents_std).view(1, 1, -1) if vae.config.sampling_rate != 32000: raise ValueError('Reference audio normalization requires the pinned 32 kHz H3 VAE') + from .work_progress import WorkProgress + pending_audio = sum(1 for i in audio_rows if i not in audio_latents) # cached files are not work + audio_work = WorkProgress('encoding', 'media_encode_audio', pending_audio, 'files') if pending_audio else None + if audio_work: + audio_work.update(0) + encoded_audio = 0 for index in audio_rows: if index not in audio_latents: audio = torch.from_numpy(np.load(normalized[index]['audio'], allow_pickle=False)).to(device) @@ -659,6 +665,9 @@ def encode_latents(normalized, value, base, canvas, *, resident=None, device='cu _remember_latent(audio_cache, audio_keys[index], audio_latents[index], Path(normalized[index]['audio']).parent) del audio, latent + encoded_audio += 1 + if audio_work: + audio_work.update(encoded_audio) del vae gc.collect() empty_cache() diff --git a/freevideo_engine/model_upgrade.py b/freevideo_engine/model_upgrade.py index e426aef7..9b5ef853 100644 --- a/freevideo_engine/model_upgrade.py +++ b/freevideo_engine/model_upgrade.py @@ -224,7 +224,8 @@ def work(): self.state = dict(base, phase='plan') try: planned = self._run(runner, events, 'plan', root, - ['setup', '--prepared-format', 'int8', '--plan', '--json'], 'plan') + ['setup', '--prepared-format', 'int8', '--plan', '--json', + '--allow-model-restart'], 'plan') if planned['status'] == 'cancelled': self.state = dict(offered) return @@ -236,6 +237,11 @@ def work(): # Nothing beyond what the user agreed to: the approval receipt # makes the run refuse a larger download or a changed model. A # larger plan is shown on the card and confirmed again first. + # A partial file that cannot resume is set aside, never deleted, + # and downloaded again: as with every other setup the launcher + # starts, so Try again never stops at the same file. The plan + # and the download carry the same setting, or the receipt + # would not match. planned_bytes = value['model_download_bytes'] if planned_bytes > offered['download_bytes'] + 64 * (1 << 20): free = disk_space.free_bytes(root) @@ -247,7 +253,8 @@ def work(): save(receipt, value) fetched = self._run(runner, events, 'setup', root, ['setup', '--prepared-format', 'int8', '--prefetch-only', '--yes', - '--accept-model-license', '--approved-plan', str(receipt)], 'download') + '--accept-model-license', '--approved-plan', str(receipt), + '--allow-model-restart'], 'download') if fetched['status'] == 'cancelled': self.state = dict(offered, status='available') return diff --git a/freevideo_engine/modern_launcher.py b/freevideo_engine/modern_launcher.py index b3ce4d9e..56aa0975 100644 --- a/freevideo_engine/modern_launcher.py +++ b/freevideo_engine/modern_launcher.py @@ -81,6 +81,7 @@ class Bridge(QObject): def __init__(self): super().__init__(engine) self.alerted = None + self.attended = session.attention self.value = session.snapshot() self.download_value = session.downloads_view() self.timer = QTimer(self) @@ -123,6 +124,11 @@ def refresh(self): window = engine.rootObjects()[0] if not window.isActive(): window.alert(0) + if session.attention != self.attended and engine.rootObjects(): + # The app asked for the launcher (adding the video model): bring it in front of the browser. + self.attended = session.attention + window = engine.rootObjects()[0] + window.showNormal(); window.raise_(); window.requestActivate() if session.closing: self.timer.stop() app.quit() diff --git a/freevideo_engine/network.py b/freevideo_engine/network.py index ce917f2e..91cf414e 100644 --- a/freevideo_engine/network.py +++ b/freevideo_engine/network.py @@ -407,7 +407,7 @@ def probe_score(row, family): def plan(spec, versions, layout='unified', *, mode='auto', timeout=5, offline=False, env=None, - model_only=False, measure_speed=True, progress=None, proxy_mode='auto', git_available=True): + model_only=False, measure_speed=True, progress=None, proxy_mode='auto', git_available=True, only=None): from .environments import ENVIRONMENTS, environment_names env = proxy_environment(env) started = time.monotonic() @@ -429,6 +429,8 @@ def plan(spec, versions, layout='unified', *, mode='auto', timeout=5, offline=Fa families.append(family) if model_only: families=[family for family in families if family in ('models','encoder-models','vdn-models','edge-models','image-models')] + if only is not None: + families = [family for family in families if family in only] selections, jobs = {}, [] for family in families: group = 'torch' if family.startswith('torch-') else 'github' if family == 'git' else family @@ -904,10 +906,16 @@ def retain_partial(path, reason): RETRY_DELAYS = (2, 5) +# Large files that have no model-style retry (GPU wheels) ask for patience: after +# the fast round has tried every source, each further round waits first and +# allows a longer connect time, so a slow or unstable link is not given up on +# within seconds. Round 0 is unchanged. +PATIENT_WAITS = (5, 15, 30) +PATIENT_CONNECT_SECONDS = (None, 15, 30, 60) -def retry_wait(network, attempt, **details): - delay = RETRY_DELAYS[min(attempt, len(RETRY_DELAYS)-1)] +def retry_wait(network, attempt, *, delays=RETRY_DELAYS, **details): + delay = delays[min(attempt, len(delays)-1)] event(network, action='retry', retry_number=attempt+1, wait_seconds=delay, **details) deadline = time.monotonic() + delay while time.monotonic() < deadline: @@ -1026,9 +1034,12 @@ def download(candidates, path, expected, *args, **options): 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): + validate_url=None, verify=None, fallbacks=None, patient=False): """Resume ranked sources; defaults retain rejected bytes. + patient adds waiting and longer connect times to the rounds after the + first, but only while the previous round failed for connection reasons. + validate_url checks each redirect before curl can follow it; verify runs after the digest and before publication. fallbacks maps a source name to an alternate (url, headers) within the same source/route retry round. @@ -1148,6 +1159,18 @@ def verification(done, total): for cycle in range(cycles): # Per round: a source refused the retained prefix / another was only slow, # stalled or interrupted (those may still finish from the same bytes). + if patient and cycle: + # From the third round on, only connection-type failures earn more waiting. + if cycle >= 2 and not retryable: + break + retry_wait(network, cycle - 1, delays=PATIENT_WAITS, category=category, + file=path.name, reason='connection-interrupted', + resume_bytes=partial.stat().st_size if partial.exists() else 0) + connect_timeout = timeout + if patient and cycle: + connect_timeout = max(timeout, PATIENT_CONNECT_SECONDS[min(cycle, len(PATIENT_CONNECT_SECONDS) - 1)] or 0) + # The stall clock starts with the request, so a longer connect wait must not be cut short by it. + stall_limit = max(stall_seconds, connect_timeout + 5) if patient and cycle else stall_seconds resume_refused = retryable = False # Once every route has been tried, keep the fastest observed route even # if the user's entire connection is slow. Do not oscillate forever. @@ -1191,7 +1214,7 @@ def remember_source(): remember_source() event(network, category=category, source=name, route=route, file=path.name, action='attempt', method='curl', cycle=cycle, resume_bytes=offset) attempt_env = route_environment(network, category, name, env, route) - command = curl_command(timeout, attempt_env) + ['--output', str(partial), '--write-out', '%{http_code}'] + command = curl_command(connect_timeout, attempt_env) + ['--output', str(partial), '--write-out', '%{http_code}'] if validate_url: command += ['--disable', '--max-redirs', '0', '--write-out', '%{http_code}\n%{redirect_url}'] # --disable must be the first option to suppress .curlrc. @@ -1237,7 +1260,7 @@ def remember_source(): previous_done, last_body = done, time.monotonic() remember_source() now = time.monotonic() - if now-last_body > stall_seconds: + if now-last_body > stall_limit: stalled = True break if size is not None and done > size: diff --git a/freevideo_engine/output_fit.py b/freevideo_engine/output_fit.py index 529a6e02..cfd4fe03 100644 --- a/freevideo_engine/output_fit.py +++ b/freevideo_engine/output_fit.py @@ -1,8 +1,8 @@ """Scale decoded frames to the requested size when the model rendered a larger one.""" -def fit_frames(frames, width, height, chunk=16): - """Return uint8 frames of exactly height x width; audio is not involved. +def fit_frames(frames, width, height, chunk=16, dtype='uint8'): + """Return uint8 (or uint16, for the 10-bit file) frames of exactly height x width; audio is not involved. The rendered frames are centre-cropped to the requested shape, then reduced with an area filter, so every output pixel averages the rendered pixels it @@ -22,10 +22,19 @@ def fit_frames(frames, width, height, chunk=16): else: crop_height = min(source_height, max(height, round(source_width * height / width))) left, top = (source_width - crop_width) // 2, (source_height - crop_height) // 2 - result = torch.empty((count, height, width, 3), dtype=torch.uint8) + wide = dtype == 'uint16' + maximum = 65535 if wide else 255 + result = np.empty((count, height, width, 3), dtype=np.uint16) if wide else torch.empty((count, height, width, 3), dtype=torch.uint8) for start in range(0, count, chunk): block = frames[start:start + chunk, top:top + crop_height, left:left + crop_width] - block = torch.from_numpy(np.ascontiguousarray(block)) if not torch.is_tensor(block) else block + if wide and not torch.is_tensor(block): + block = torch.from_numpy(np.ascontiguousarray(block).astype(np.float32)) + else: + block = torch.from_numpy(np.ascontiguousarray(block)) if not torch.is_tensor(block) else block scaled = functional.interpolate(block.permute(0, 3, 1, 2).float(), size=(height, width), mode='area') - result[start:start + block.shape[0]] = scaled.round_().clamp_(0, 255).to(torch.uint8).permute(0, 2, 3, 1) + scaled = scaled.round_().clamp_(0, maximum).permute(0, 2, 3, 1) + if wide: + result[start:start + block.shape[0]] = scaled.to(torch.int32).numpy().astype(np.uint16) + else: + result[start:start + block.shape[0]] = scaled.to(torch.uint8) return result diff --git a/freevideo_engine/output_settings.py b/freevideo_engine/output_settings.py new file mode 100644 index 00000000..5207da8a --- /dev/null +++ b/freevideo_engine/output_settings.py @@ -0,0 +1,43 @@ +"""Whether a finished video is also saved as a 10-bit copy (see video_10bit.py). Off unless the user turns it on.""" +import json +from pathlib import Path + +from .monitoring import save + +FILE = 'output-settings.json' +# One format for the first batch; chosen from playback tests on Windows (H.264 High 10 or HEVC Main 10). +CODEC = 'h264' + + +def available(): + """True when this installation's packaged encoder can write the 10-bit format.""" + try: + import av + from .video_10bit import CODECS + return 'yuv420p10le' in {f.name for f in av.codec.Codec(CODECS[CODEC], 'w').video_formats or ()} + except Exception: + return False + + +def read(root): + """{'ten_bit': bool}; anything unreadable is off.""" + try: + value = json.loads((Path(root) / FILE).read_text(encoding='utf-8')) + except (OSError, ValueError): + return dict(ten_bit=False) + return dict(ten_bit=isinstance(value, dict) and value.get('ten_bit') is True) + + +def write(root, ten_bit): + if not isinstance(ten_bit, bool): + raise ValueError('The 10-bit copy is switched on or off') + if ten_bit and not available(): + raise ValueError('This installation cannot write 10-bit video') + result = dict(ten_bit=ten_bit) + save(Path(root) / FILE, result) + return result + + +def command_option(root): + """['--ten-bit', codec] when the copy is on and can be written, else [].""" + return ['--ten-bit', CODEC] if read(root)['ten_bit'] and available() else [] diff --git a/freevideo_engine/policy.py b/freevideo_engine/policy.py index 70122261..72715719 100644 --- a/freevideo_engine/policy.py +++ b/freevideo_engine/policy.py @@ -592,7 +592,7 @@ class ResourceBudgetError(ValueError): """A rejected plan with the exact capacities, reserves and shortfalls.""" def __init__(self, hardware, gpu_total, ram_total, free_gpu, free_ram, reserve_gpu, reserve_ram, *, gpu_minimum_bytes=5*GiB, gpu_requirement=None, canvas=None, - ram_minimum_bytes=4*GiB, ram_requirement=None): + ram_minimum_bytes=4*GiB, ram_requirement=None, gpu_reserve_explicit=False): self.details = dict(gpu=hardware.gpu_name, system=hardware.system, gpu_total_bytes=hardware.vram_total, gpu_capacity_bytes=gpu_total, gpu_available_bytes=free_gpu, gpu_reserve_bytes=int(reserve_gpu * GiB), @@ -606,6 +606,15 @@ def __init__(self, hardware, gpu_total, ram_total, free_gpu, free_ram, reserve_g failed = [name for name in ('gpu', 'ram') if self.details[name+'_budget_bytes'] < self.details[name+'_minimum_bytes']] self.details['insufficient'] = failed + # A reserve the user set, and which alone keeps the request out: the + # automatic reserve would leave enough. Said in the refusal so the + # setting is named instead of looking like a shortage of memory. + automatic = int(automatic_gpu_reserve_gib(hardware.system == 'Windows', free_gpu) * GiB) + automatic_budget = free_gpu - automatic + self.details['gpu_reserve_explicit'] = bool(gpu_reserve_explicit) + if ('gpu' in failed and gpu_reserve_explicit and int(reserve_gpu * GiB) > automatic + and automatic_budget >= gpu_minimum_bytes): + self.details['gpu_budget_with_automatic_reserve_bytes'] = automatic_budget for name in ('gpu', 'ram'): self.details[name+'_shortfall_bytes'] = max( 0, self.details[name+'_minimum_bytes'] - self.details[name+'_budget_bytes']) @@ -644,6 +653,11 @@ def __init__(self, hardware, gpu_total, ram_total, free_gpu, free_ram, reserve_g self.details[name+'_total_bytes']/GiB)) if self.details[name+'_shortfall_bytes']: lines.append('%s shortfall: %.2f GiB.' % (label, self.details[name+'_shortfall_bytes']/GiB)) + if name == 'gpu' and 'gpu_budget_with_automatic_reserve_bytes' in self.details: + lines.append('Reserved VRAM is set to %.2f GiB; with the automatic reserve this request would have ' + '%.2f GiB usable. Set Reserved VRAM to automatic to use it.' % + (self.details['gpu_reserve_bytes']/GiB, + self.details['gpu_budget_with_automatic_reserve_bytes']/GiB)) if hardware.ram_commit_available is not None and hardware.ram_physical_available is not None: lines.append('Windows reports %.2f GiB of free physical RAM and %.2f GiB of commit headroom.' % (hardware.ram_physical_available / GiB, hardware.ram_commit_available / GiB)) @@ -800,6 +814,11 @@ def commit_gpu_limit(hardware: Hardware, request_budget): return int(limit) if limit < request_budget else None +def automatic_gpu_reserve_gib(desktop, free_gpu): + """The VRAM growth reserve when none is set: a fixed share on Windows, a fraction of the free card elsewhere.""" + return .25 if desktop else max(.5, min(1., free_gpu / GiB * .025)) + + def resource_budget(hardware: Hardware, *, vram_gib=None, ram_gib=None, gpu_reserve_gib=None, ram_reserve_gib=None): """Live capacity and growth reserves, without admitting a generation request.""" @@ -823,7 +842,7 @@ def resource_budget(hardware: Hardware, *, vram_gib=None, ram_gib=None, if hardware.cgroup_ram_limit is not None: free_ram = min(free_ram, hardware.cgroup_ram_limit) reserve_gpu = (gpu_reserve_gib if gpu_reserve_gib is not None else - .25 if desktop else max(.5, min(1., free_gpu / GiB * .025))) + automatic_gpu_reserve_gib(desktop, free_gpu)) reserve_ram = ram_reserve_gib if ram_reserve_gib is not None else max(2. if desktop else 1., min(4., free_ram / GiB * .05)) if not math.isfinite(reserve_ram) or reserve_ram < 0: raise ValueError('System RAM headroom must be finite and nonnegative.') @@ -1174,7 +1193,7 @@ def _choose(hardware: Hardware, *, vram_gib=None, ram_gib=None, attention='auto' if gpu_budget < gpu_minimum or ram_budget < ram_minimum: raise ResourceBudgetError(hardware, gpu_total, ram_total, free_gpu, free_ram, reserve_gpu, reserve_ram, gpu_minimum_bytes=gpu_minimum + placement_workspace, ram_minimum_bytes=ram_minimum, - canvas=canvas) + canvas=canvas, gpu_reserve_explicit=gpu_reserve_gib is not None) if ram_budget < ENCODER_HOST_BYTES: # Say so here rather than let a request spend a minute loading a # 14.6 GiB encoder and die before the first sampling step, which is @@ -1499,7 +1518,8 @@ def held(group): # release/remeasurement and loses the actual request shortfall. raise ResourceBudgetError(hardware, gpu_total, ram_total, free_gpu, free_ram, reserve_gpu, reserve_ram, gpu_minimum_bytes=residual_floor + placement_workspace, - gpu_requirement='estimated_working_set', canvas=canvas) + gpu_requirement='estimated_working_set', canvas=canvas, + gpu_reserve_explicit=gpu_reserve_gib is not None) head, reserve = selected # The host attention-output buffer was tied to this band with no # arithmetic behind it. It saves 0.04 to 0.23 GiB of peak and costs 19% @@ -1661,7 +1681,8 @@ def held(group): if gpu_budget < residual_floor and not capacity_trial: raise ResourceBudgetError(hardware, gpu_total, ram_total, free_gpu, free_ram, reserve_gpu, reserve_ram, gpu_minimum_bytes=residual_floor + placement_workspace, - gpu_requirement='estimated_working_set', canvas=canvas) + gpu_requirement='estimated_working_set', canvas=canvas, + gpu_reserve_explicit=gpu_reserve_gib is not None) head = 4 residual_offload = True # Staging the residual alone is the placement measured under diff --git a/freevideo_engine/progress_timeline.py b/freevideo_engine/progress_timeline.py index 4e911e23..a5dc2b9f 100644 --- a/freevideo_engine/progress_timeline.py +++ b/freevideo_engine/progress_timeline.py @@ -76,6 +76,8 @@ 'complete', 'encode', 'encoder_compute', + 'encoder_compute_reference', + 'encoder_device_load', 'encoder_load', 'encoder_save', 'encoder_tokenize', @@ -90,6 +92,7 @@ 'latent_upscale', 'latent_validation', 'layers', + 'media_encode_audio', 'media_vae', 'offload_release', 'other', @@ -102,6 +105,8 @@ 'transformer_release', 'verify', 'video', + 'weight_pin', + 'weight_read', )) TIMING_PHASES = frozenset(( diff --git a/freevideo_engine/prompt_vlm/rules.py b/freevideo_engine/prompt_vlm/rules.py index cd85c90b..55ec43a9 100644 --- a/freevideo_engine/prompt_vlm/rules.py +++ b/freevideo_engine/prompt_vlm/rules.py @@ -34,7 +34,8 @@ def request(value): for row in media: # kind and number say which /