From 97a720051f90518cbf376b8d7e1e7c170807472f Mon Sep 17 00:00:00 2001 From: nan Date: Sun, 9 Aug 2026 10:23:14 +0800 Subject: [PATCH] Bump version to 0.10.11 and update installation instructions; enhance progress reporting and estimated time features --- README.md | 9 +- docs/technical.md | 22 +-- pyproject.toml | 2 +- src/snapform/blender_manifest.toml | 2 +- src/snapform/bootstrap_runtime.py | 243 ++++++++++++++++++----------- src/snapform/operators.py | 50 ++++-- src/snapform/status_bar.py | 2 +- src/snapform/worker.py | 32 +++- tests/test_addon.py | 29 +++- tests/test_bootstrap_runtime.py | 108 +++++++++++-- tests/test_download_progress.py | 31 +++- uv.lock | 2 +- 12 files changed, 382 insertions(+), 150 deletions(-) diff --git a/README.md b/README.md index 482b2f5..82a4514 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,7 @@ SnapForm 是一个 Blender 扩展,可以把一张照片、一段视频或一 ## 安装 -1. 下载对应系统的扩展压缩包: -- Windows:`SnapForm.v0.10.10.windows-x64.zip` -- macOS(Apple 芯片):`SnapForm.v0.10.10.macos-arm64.zip` -- macOS(Intel):`SnapForm.v0.10.10.macos-x64.zip` -- Linux:`SnapForm.v0.10.10.linux-x64.zip` +1. 下载对应系统的扩展压缩包 2. 打开 Blender,进入 `Preferences > Extensions` 3. 点击 `Install from Disk`,选择下载的压缩包 4. 在扩展偏好设置中确认 Storage Root 与 Device(保持默认即可);Device 默认取本机 @@ -23,7 +19,8 @@ SnapForm 是一个 Blender 扩展,可以把一张照片、一段视频或一 5. 打开 3D 视图右侧的 `SnapForm` 面板,点击 `Install Runtime` DA3、BEN2 源码包和 uv 已内置在扩展里,安装时直接从本地解包,不需要访问 GitHub。首次使用仍会自动 -下载运行环境依赖(PyTorch 等)和 AI 模型,请保持网络畅通,下载需要一些时间。 +下载运行环境依赖(PyTorch 等),随后自动下载 `DA3 Small` 和 `BEN2 Base`。其他 AI 模型可在 +`Host > Models` 中按需下载,请保持网络畅通,首次安装需要一些时间。 ## 快速上手 diff --git a/docs/technical.md b/docs/technical.md index 28612db..f7a0b3b 100644 --- a/docs/technical.md +++ b/docs/technical.md @@ -55,13 +55,14 @@ 本机支持的设备。 4. 在 `Host` 点击 `Install Runtime`。首次安装会下载 Python、PyTorch、FastAPI、Uvicorn; DA3、BEN2 源码包与 uv 已内置在扩展中,安装时从 `~/.snapform/sources/` 解包使用, - 并默认下载 `DA3 Small`、`DA3 Base` 和 `BEN2 Base` 模型。安装按 8 个阶段依次推进,每段进度 - 从 0 到 100%:解包内置 uv、创建 Python Runtime、下载 PyTorch、安装 xFormers(macOS 跳过, - 内置 DA3 源码包为官方原版并已移除 xformers 硬依赖,自动回退纯 PyTorch 实现)、安装 AI 依赖, - 然后是三个模型的下载。安装完成后进入 `验证 Runtime` 阶段,会导入 Torch 与全部 AI 依赖; - macOS 首次导入 Torch 会初始化 Metal 后端(编译 shader 缓存),该阶段可能持续较久,状态栏 - 会显示已用时间而不是卡住。进度文字统一为 `阶段/总数 阶段名: 状态`, - 模型下载阶段会实时显示下载速度,例如 `6/8 da3 small: downloading 14.2MB/s`。 + 并在 Runtime 就绪后自动下载 `DA3 Small` 和 `BEN2 Base` 推荐模型。Runtime 安装 + 固定显示 `Environment`、`PyTorch`、`AI components` 三个用户阶段;xFormers(macOS 跳过)、 + FastAPI 等内部安装步骤合并在 `AI components` 中。耗时最长的 PyTorch 与 AI 组件占主要进度 + 区间。安装完成后直接写入 Runtime 就绪信息,不再额外导入全部组件进行验证。推荐模型属于安装 + 后的自动步骤,不计入阶段编号;`DA3 Base` 等其他模型可稍后在 `Models` 面板按需下载。 + 状态文字使用 `阶段/总数 阶段名 | 当前操作 | 预计剩余时间`,依赖安装会结合预设耗时与实际 + 进度修正预期;模型下载根据字节数和速度计算剩余时间,例如 + `Downloading DA3 Small | 14.2 MB/s | About 2 min left`。重试或切换镜像时进度不会倒退。 Runtime 就绪后即使模型下载中断也不影响 Host 使用,可稍后在 `Models` 面板单独补下。 安装不做启动网络预检:pip 安装失败会自动依次重试清华、阿里镜像,模型下载由 worker 自行探测回退到 HF 镜像;需要手动指定镜像时可通过 `PIP_INDEX_URL` 环境变量覆盖。 @@ -138,8 +139,9 @@ Cyclic 沿用原 Empty 的设置。序列帧无法被 Blender 整体 Pack,因 `8×12` 个面。随后的 Simple Subdivision 修改器使用原生名称,Viewport Levels 固定为 `1`,Render Levels 固定为 `2`,不再随 Resolution Scale 或 Subdivision 动态变化。 -所有任务使用统一的状态栏进度显示。变化中的阶段说明显示在右侧的紧凑进度条内部,取消按钮 -位于进度条右侧。Image Depth 的阶段包括 Runtime 启动、输入准备、模型加载、深度估算、缓存 +所有任务使用统一的状态栏进度显示。变化中的阶段说明显示在右侧的进度条内部,取消按钮 +位于进度条右侧。状态字段支持阶段、当前操作与预计剩余秒数,界面统一组合为紧凑文案并保证 +进度单调递增。Image Depth 的阶段包括 Runtime 启动、输入准备、模型加载、深度估算、缓存 写出和面片构建。进度更新会强制刷新状态栏与窗口,无需移动鼠标即可实时显示。安装中断后残留的 半成品 Runtime 会在下次安装前自动清理,不会因为中断而无法再次安装。 @@ -281,7 +283,7 @@ GLB 任务会关闭其默认附带的深度预览,使四种 job 产物保持 Extension 本身不包含 Torch。`Install Runtime` 使用 `uv` 创建独立 Python 3.12 环境,并安装。机器上即使没有安装 `uv` 或 Python 也没关系:安装程序由 Blender -自带的 Python 启动,缺 `uv` 时会自动下载官方独立二进制(约 30MB),再用它下载 +自带的 Python 启动,缺 `uv` 时会解包扩展内置的对应平台二进制,再用它下载 Python 3.12 与全部依赖。`uv` 会缓存在扩展存储目录下,后续安装直接复用。 安装不做网络预检,直接开始;环境变量仍可切换镜像与代理:`PIP_INDEX_URL`(PyPI 镜像)、 `SNAPFORM_TORCH_INDEX`(PyTorch 索引)、`HF_ENDPOINT`(Hugging Face 镜像)、 diff --git a/pyproject.toml b/pyproject.toml index c06bce8..3484f15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "snapform" -version = "0.10.10" +version = "0.10.11" description = "Fast image-guided structure building tools for Blender" readme = "README.md" requires-python = ">=3.11" diff --git a/src/snapform/blender_manifest.toml b/src/snapform/blender_manifest.toml index 11e1de7..4db0bd5 100644 --- a/src/snapform/blender_manifest.toml +++ b/src/snapform/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "snapform" -version = "0.10.10" +version = "0.10.11" name = "SnapForm" tagline = "Build spatial structures from images, fast" maintainer = "OmooLab" diff --git a/src/snapform/bootstrap_runtime.py b/src/snapform/bootstrap_runtime.py index 4d27786..18a42c0 100644 --- a/src/snapform/bootstrap_runtime.py +++ b/src/snapform/bootstrap_runtime.py @@ -39,16 +39,24 @@ TORCH_INDEXES = { "cpu": "https://download.pytorch.org/whl/cpu", } _resolved_pypi_index = None -DEFAULT_MODEL_KEYS = ("SMALL", "BASE", BEN2_MODEL.key) -RUNTIME_SLICE_START = 0.07 -RUNTIME_SLICE_END = 0.88 -VERIFY_SLICE_START = 0.88 -VERIFY_SLICE_END = 0.90 -MODELS_SLICE_START = 0.90 +DEFAULT_MODEL_KEYS = ("SMALL", BEN2_MODEL.key) +RUNTIME_STAGE_COUNT = 3 +ENVIRONMENT_SLICE = (0.02, 0.06) +PYTORCH_SLICE = (0.06, 0.48) +XFORMERS_SLICE = (0.48, 0.62) +AI_COMPONENTS_SLICE = (0.62, 0.85) +MODELS_SLICE_START = 0.85 MODELS_SLICE_END = 1.0 -PLAIN_SLICE_SPAN = 0.02 RAMP_RATE = 0.02 CREEP_CAP = 0.60 +INSTALL_ESTIMATES = { + "PyTorch": 8 * 60, + "AI components": 8 * 60, +} +INSTALL_ESTIMATE_RANGES = { + "PyTorch": PYTORCH_SLICE, + "AI components": (XFORMERS_SLICE[0], AI_COMPONENTS_SLICE[1]), +} CHECK_CODE = ( "import json; import torch; " "from depth_anything_3.api import DepthAnything3; " @@ -70,6 +78,7 @@ def write_status( stage=None, stages=None, stage_label=None, + eta_seconds=None, ): payload = {"progress": progress, "message": message} if error: @@ -78,6 +87,8 @@ def write_status( payload["stage"] = stage payload["stages"] = stages payload["stage_label"] = stage_label + if eta_seconds is not None: + payload["eta_seconds"] = max(int(eta_seconds), 0) Path(path).write_text( json.dumps(payload, ensure_ascii=False), encoding="utf-8", @@ -120,6 +131,21 @@ def parse_package_count(line): return 0 +def estimated_remaining(label, progress, elapsed=0.0): + expected_seconds = INSTALL_ESTIMATES.get(label) + progress_range = INSTALL_ESTIMATE_RANGES.get(label) + if expected_seconds is None or progress_range is None: + return None + start, end = progress_range + if end <= start: + return 0.0 + remaining_fraction = 1.0 - min( + max((progress - start) / (end - start), 0.0), + 1.0, + ) + return max(expected_seconds * remaining_fraction - elapsed, 0.0) + + def effective_torch_index(backend): return os.environ.get("SNAPFORM_TORCH_INDEX") or TORCH_INDEXES.get(backend) @@ -166,16 +192,6 @@ def _run_install_once( threading.Thread(target=read_output, daemon=True).start() - def write(progress, message): - write_status( - status_path, - progress, - message, - stage=stage, - stages=stages, - stage_label=label, - ) - def eased(fraction): return start + min(max(fraction, 0.0), 1.0) * (end - start) @@ -186,6 +202,21 @@ def _run_install_once( started = clock() last_tick = started last_heartbeat = 0.0 + def detail(message): + write_status( + status_path, + displayed, + message, + stage=stage, + stages=stages, + stage_label=label, + eta_seconds=estimated_remaining( + label, + displayed, + clock() - started, + ), + ) + while True: try: raw_line = output_queue.get(timeout=0.2) @@ -206,7 +237,7 @@ def _run_install_once( if empty: if elapsed - last_heartbeat >= 5.0: last_heartbeat = elapsed - write(displayed, f"downloading {format_elapsed(elapsed)}") + detail("Downloading packages") continue line = raw_line.rstrip() print(line) @@ -215,17 +246,17 @@ def _run_install_once( resolved_progress = eased(0.02) displayed = max(displayed, resolved_progress) target = max(target, resolved_progress) - write(displayed, f"{total} packages") + detail(f"Resolving {total} packages") elif line.startswith("Prepared ") and "package" in line: done = parse_package_count(line) if total: target = max(target, eased(0.10 + 0.60 * done / total)) - write(displayed, f"{done}/{total} packages") + detail(f"Downloading packages {done}/{total}") elif line.startswith("Installed ") and "package" in line: done = parse_package_count(line) if total: target = max(target, eased(0.75 + 0.25 * done / total)) - write(displayed, f"{done}/{total} packages") + detail(f"Installing packages {done}/{total}") elif line.startswith("+"): entry = line[1:].strip() installed.append(entry) @@ -315,19 +346,19 @@ def install_stages(args, runtime_dir, python, uv): torch_command.extend(["--index-url", torch_index]) stages = [ ( - "uv", + "Environment", [uv, "venv", "--clear", "--python", "3.12", runtime_dir], None, "plain", ), ( - "pytorch deps", + "PyTorch", torch_command, None, "install", ), ( - "ai deps", + "AI components", [ uv, "pip", @@ -363,7 +394,7 @@ def install_stages(args, runtime_dir, python, uv): xformers_command.append("--no-deps") stages.insert( 2, - ("xformers", xformers_command, None, "install"), + ("AI components", xformers_command, None, "install"), ) models = {**MODELS, BEN2_MODEL.key: BEN2_MODEL} worker_script = Path(__file__).with_name("worker.py") @@ -380,12 +411,14 @@ def install_stages(args, runtime_dir, python, uv): models_dir / model.directory_name, "--status", args.status, + "--label", + model.label, ] for pattern in model.download_patterns: download_command.extend(["--allow-pattern", pattern]) stages.append( ( - model.label.lower(), + model.label, download_command, None, "download", @@ -405,6 +438,17 @@ def runtime_ready(runtime_dir): return payload.get("runtime_schema") == RUNTIME_SCHEMA +def installed_devices(args): + devices = [] + machine = platform.machine().lower() + if sys.platform == "darwin" and machine in {"arm64", "aarch64"}: + devices.append("mps") + elif args.backend.startswith("cu"): + devices.append("cuda") + devices.append("cpu") + return devices + + def verify_command(python): return [python, "-c", CHECK_CODE] @@ -412,9 +456,12 @@ def verify_command(python): def run_verify( python, status_path, - start=VERIFY_SLICE_START, - end=VERIFY_SLICE_END, + start=0.0, + end=1.0, clock=time.monotonic, + stage=None, + stages=None, + stage_label=None, ): """Run the runtime import check with heartbeat progress; return the device report.""" command = verify_command(python) @@ -457,7 +504,11 @@ def run_verify( write_status( status_path, progress, - f"verifying runtime {format_elapsed(elapsed)}", + "Checking Runtime", + stage=stage, + stages=stages, + stage_label=stage_label, + eta_seconds=max(60.0 - elapsed, 0.0), ) continue line = raw_line.rstrip() @@ -585,13 +636,20 @@ def run_stage( start=0.0, end=1.0, ): + if stage is None: + opening_message = f"Downloading {label}" + elif kind == "install": + opening_message = "Preparing packages" + else: + opening_message = "" write_status( status_path, start, - "", + opening_message, stage=stage, stages=stages, stage_label=label, + eta_seconds=estimated_remaining(label, start), ) installed = [] if kind == "install": @@ -606,28 +664,31 @@ def run_stage( end=end, ) elif kind == "download": - run( - [ - *command, - "--progress-start", - str(start), - "--progress-end", - str(end), - "--stage", - str(stage), - "--stages", - str(stages), - "--stage-label", - label, - ], - environment, - ) + progress_command = [ + *command, + "--progress-start", + str(start), + "--progress-end", + str(end), + ] + if stage is not None: + progress_command.extend( + [ + "--stage", + str(stage), + "--stages", + str(stages), + "--stage-label", + label, + ] + ) + run(progress_command, environment) else: run(command, environment) write_status( status_path, end, - "", + f"{label} ready" if stage is None else "", stage=stage, stages=stages, stage_label=label, @@ -662,44 +723,45 @@ def install(args): stages = install_stages(args, runtime_dir, python, uv) runtime_stages = [stage for stage in stages if stage[3] != "download"] model_stages = [stage for stage in stages if stage[3] == "download"] - plain_count = sum(1 for stage in runtime_stages if stage[3] != "install") - runtime_slice = RUNTIME_SLICE_END - RUNTIME_SLICE_START - install_count = len(runtime_stages) - plain_count - install_span = ( - (runtime_slice - PLAIN_SLICE_SPAN * plain_count) / install_count - if install_count - else 0.0 - ) model_span = ( (MODELS_SLICE_END - MODELS_SLICE_START) / len(model_stages) if model_stages else 0.0 ) installed_packages = [] - runtime_offset = RUNTIME_SLICE_START - for index, (label, command, environment, kind) in enumerate( - runtime_stages, - start=1, - ): - stage_start = runtime_offset - stage_end = stage_start + ( - install_span if kind == "install" else PLAIN_SLICE_SPAN - ) - if uv_missing and index == 1: + component_stages = runtime_stages[2:] + component_ranges = ( + [XFORMERS_SLICE, AI_COMPONENTS_SLICE] + if len(component_stages) == 2 + else [(XFORMERS_SLICE[0], AI_COMPONENTS_SLICE[1])] + ) + for index, (label, command, environment, kind) in enumerate(runtime_stages): + if index == 0: + stage = 1 + stage_start, stage_end = ENVIRONMENT_SLICE + elif index == 1: + stage = 2 + stage_start, stage_end = PYTORCH_SLICE + else: + stage = 3 + component_index = index - 2 + stage_start, stage_end = component_ranges[component_index] + if uv_missing and index == 0: + midpoint = stage_start + (stage_end - stage_start) * 0.5 install_uv( uv, args.status, - stage=index, - stages=len(stages), + stage=stage, + stages=RUNTIME_STAGE_COUNT, stage_label=label, start=stage_start, - end=stage_start + PLAIN_SLICE_SPAN * 0.5, + end=midpoint, ) - stage_start += PLAIN_SLICE_SPAN * 0.5 + stage_start = midpoint stage_installed = run_stage( args.status, - index, - len(stages), + stage, + RUNTIME_STAGE_COUNT, label, command, environment, @@ -709,17 +771,7 @@ def install(args): ) if isinstance(stage_installed, list): installed_packages.extend(stage_installed) - runtime_offset = stage_end - write_status(args.status, VERIFY_SLICE_START, "verifying runtime") - device_report = run_verify(python, args.status) - write_status(args.status, VERIFY_SLICE_END, "Runtime is ready") - - devices = [] - if device_report.get("cuda"): - devices.append("cuda") - if device_report.get("mps"): - devices.append("mps") - devices.append("cpu") + devices = installed_devices(args) runtime_dir.mkdir(parents=True, exist_ok=True) ready_path = runtime_dir / "snapform_ready.json" ready_path.write_text( @@ -741,19 +793,16 @@ def install(args): ), encoding="utf-8", ) - offset = len(runtime_stages) model_offset = MODELS_SLICE_START - for index, (label, command, environment, kind) in enumerate( - model_stages, - start=offset + 1, - ): + failed_models = [] + for label, command, environment, kind in model_stages: stage_start = model_offset stage_end = stage_start + model_span try: run_stage( args.status, - index, - len(stages), + None, + None, label, command, environment, @@ -763,14 +812,22 @@ def install(args): ) except Exception as error: print(f"Model download failed: {label}: {error}") + failed_models.append(label) write_status( args.status, - MODELS_SLICE_END, - f"Runtime is ready; {label} failed", + stage_end, + f"Skipping {label}; continuing", ) - return model_offset = stage_end - write_status(args.status, 1.0, "Runtime and models ready") + if failed_models: + labels = ", ".join(failed_models) + write_status( + args.status, + 1.0, + f"Runtime is ready; retry {labels} from Models", + ) + return + write_status(args.status, 1.0, "Runtime and recommended models ready") def parse_args(): diff --git a/src/snapform/operators.py b/src/snapform/operators.py index c6f0dce..f366146 100644 --- a/src/snapform/operators.py +++ b/src/snapform/operators.py @@ -475,23 +475,53 @@ class RunJob(bpy.types.Operator): if status is None: return settings = context.scene.snapform_settings - settings.progress = float(status.get("progress", settings.progress)) + reported_progress = float( + status.get("progress", settings.progress) + ) + settings.progress = max( + settings.progress, + min(max(reported_progress, 0.0), 1.0), + ) settings.status = self._format_status(status, settings.status) self._redraw_ui(context, force=True) def _format_status(self, status, fallback): - message = status.get("message", fallback) + message = status.get("message") stage = status.get("stage") stages = status.get("stages") stage_label = status.get("stage_label") - if not stage or not stages: - return message - text = ( - f"{stage}/{stages} {stage_label}" - if stage_label - else f"{stage}/{stages}" - ) - return f"{text}: {message}" if message else text + parts = [] + if stage and stages: + stage_text = f"{stage}/{stages}" + if stage_label: + stage_text = f"{stage_text} {stage_label}" + parts.append(stage_text) + elif stage_label: + parts.append(str(stage_label)) + if message: + normalized = str(message).strip() + if normalized.lower() != str(stage_label or "").lower(): + parts.append(normalized) + eta = self._format_eta(status.get("eta_seconds")) + if eta: + parts.append(eta) + return " | ".join(parts) if parts else (message or fallback) + + @staticmethod + def _format_eta(seconds): + if seconds is None: + return "" + seconds = max(float(seconds), 0.0) + if seconds <= 10: + return "A few seconds left" + if seconds < 60: + rounded = max(5, int((seconds + 2.5) // 5) * 5) + return f"About {rounded} sec left" + if seconds < 3600: + minutes = int((seconds + 59) // 60) + return f"About {minutes} min left" + hours = int((seconds + 3599) // 3600) + return f"About {hours} hr left" def _finish(self, context, job, return_code): settings = context.scene.snapform_settings diff --git a/src/snapform/status_bar.py b/src/snapform/status_bar.py index 5c35dc4..5b3206a 100644 --- a/src/snapform/status_bar.py +++ b/src/snapform/status_bar.py @@ -14,7 +14,7 @@ def draw_status_bar(self, context): progress = min(max(settings.progress, 0.0), 1.0) row = self.layout.row(align=True) progress_row = row.row(align=True) - progress_row.ui_units_x = 12.0 + progress_row.ui_units_x = 18.0 progress_row.progress( factor=progress, type="BAR", diff --git a/src/snapform/worker.py b/src/snapform/worker.py index f2fc3b6..44f52af 100644 --- a/src/snapform/worker.py +++ b/src/snapform/worker.py @@ -49,6 +49,7 @@ def write_status( stage=None, stages=None, stage_label=None, + eta_seconds=None, ): payload = {"progress": progress, "message": message} if error: @@ -57,6 +58,8 @@ def write_status( payload["stage"] = stage payload["stages"] = stages payload["stage_label"] = stage_label + if eta_seconds is not None: + payload["eta_seconds"] = max(int(eta_seconds), 0) if state is None: if error: state = "failed" @@ -229,6 +232,7 @@ class DownloadProgressReporter: self.label = label self.progress = start self.last_write_time = 0.0 + self.started_at = self.clock() def report(self, current, total, rate=None): if not total or current <= 0: @@ -244,14 +248,25 @@ class DownloadProgressReporter: return self.progress = progress self.last_write_time = now - speed = format_rate(rate) if rate else "" + effective_rate = rate + elapsed = now - self.started_at + if not effective_rate and elapsed > 0: + effective_rate = current / elapsed + speed = format_rate(effective_rate) if effective_rate else "" + eta_seconds = None + if effective_rate and effective_rate > 0: + eta_seconds = max(total - current, 0) / effective_rate if self.stage is not None: - message = f"downloading {speed}" if speed else "downloading" + message = ( + f"Downloading packages | {speed}" + if speed + else "Downloading packages" + ) else: prefix = self.label or self.repository - message = f"downloading {prefix}" + message = f"Downloading {prefix}" if speed: - message = f"{message} {speed}" + message = f"{message} | {speed}" write_status( self.status_path, progress, @@ -259,6 +274,7 @@ class DownloadProgressReporter: stage=self.stage, stages=self.stages, stage_label=self.stage_label, + eta_seconds=eta_seconds, ) @@ -356,7 +372,7 @@ def download_model(args): failures.append((endpoint, "endpoint unreachable")) continue os.environ["HF_ENDPOINT"] = endpoint - message = f"downloading {label}" + message = f"Downloading {label}" if endpoint != HF_ENDPOINT: message = f"{message} (mirror)" write_status(args.status, start, message, **stage_kwargs) @@ -375,7 +391,7 @@ def download_model(args): write_status( args.status, start + span, - "model ready", + f"{label} ready", **stage_kwargs, ) return @@ -384,7 +400,7 @@ def download_model(args): write_status( args.status, start, - f"downloading {label} (mirror: modelscope.cn)", + f"Downloading {label} | ModelScope mirror", **stage_kwargs, ) download_from_modelscope( @@ -402,7 +418,7 @@ def download_model(args): write_status( args.status, start + span, - "model ready", + f"{label} ready", **stage_kwargs, ) return diff --git a/tests/test_addon.py b/tests/test_addon.py index 87d082c..b7dda58 100644 --- a/tests/test_addon.py +++ b/tests/test_addon.py @@ -611,7 +611,7 @@ class AddonTest(unittest.TestCase): }, "fallback", ), - "3/8 pytorch: 2 downloaded", + "3/8 pytorch | 2 downloaded", ) self.assertEqual( operator._format_status( @@ -636,6 +636,33 @@ class AddonTest(unittest.TestCase): ), "Estimating depth", ) + self.assertEqual( + operator._format_status( + { + "progress": 0.25, + "message": "Downloading packages", + "stage": 2, + "stages": 3, + "stage_label": "PyTorch", + "eta_seconds": 125, + }, + "fallback", + ), + "2/3 PyTorch | Downloading packages | About 3 min left", + ) + self.assertEqual( + operator._format_status( + { + "progress": 0.5, + "message": "", + "stage": 2, + "stages": 3, + "stage_label": "PyTorch", + }, + "2/3 PyTorch | Downloading packages", + ), + "2/3 PyTorch", + ) def test_model_options_mark_only_models_that_are_not_downloaded(self): items_callback = self.addon.SnapFormSettings.__annotations__["model"][ diff --git a/tests/test_bootstrap_runtime.py b/tests/test_bootstrap_runtime.py index 7b7c4af..c8a6c91 100644 --- a/tests/test_bootstrap_runtime.py +++ b/tests/test_bootstrap_runtime.py @@ -153,7 +153,13 @@ class BootstrapRuntimeTest(unittest.TestCase): ) ) ] - self.assertEqual(len(default_downloads), 3) + self.assertEqual(len(default_downloads), 2) + self.assertFalse( + any( + "depth-anything/DA3-BASE" in map(str, command) + for command in default_downloads + ) + ) ben2_download = next( command for command in default_downloads @@ -165,9 +171,12 @@ class BootstrapRuntimeTest(unittest.TestCase): if value == "--allow-pattern" ] self.assertEqual(patterns, ["config.json", "model.safetensors"]) - self.assertIn("--stage", ben2_download) - self.assertIn("--stage-label", ben2_download) - self.assertEqual(ben2_download[-1], "ben2 base") + self.assertNotIn("--stage", ben2_download) + self.assertNotIn("--stage-label", ben2_download) + self.assertEqual( + ben2_download[ben2_download.index("--label") + 1], + "BEN2 Base", + ) def test_darwin_install_skips_cuda_index_and_xformers(self): bootstrap = load_bootstrap_runtime() @@ -400,7 +409,15 @@ class BootstrapRuntimeTest(unittest.TestCase): all(starts[i] < starts[i + 1] for i in range(len(starts) - 1)) ) self.assertEqual(ends[-1], 1.0) - self.assertEqual(starts[0], bootstrap.RUNTIME_SLICE_START) + self.assertEqual(starts[0], bootstrap.ENVIRONMENT_SLICE[0]) + runtime_ranges = stage_ranges[:4] + self.assertEqual( + [item[0] for item in runtime_ranges], + [1, 2, 3, 3], + ) + self.assertTrue( + all(item[0] is None for item in stage_ranges[4:]) + ) status = json.loads( (root / "status.json").read_text(encoding="utf-8") ) @@ -443,9 +460,9 @@ class BootstrapRuntimeTest(unittest.TestCase): self.assertEqual( messages, [ - "3 packages", - "3/3 packages", - "3/3 packages", + "Resolving 3 packages", + "Downloading packages 3/3", + "Installing packages 3/3", ], ) self.assertEqual(mocked_write.call_args_list[0].args[1], 0.02) @@ -508,8 +525,8 @@ class BootstrapRuntimeTest(unittest.TestCase): ) ) messages = [call.args[2] for call in mocked_write.call_args_list] - self.assertIn("3 packages", messages) - self.assertIn("3/3 packages", messages) + self.assertIn("Resolving 3 packages", messages) + self.assertIn("Downloading packages 3/3", messages) def test_run_install_maps_progress_into_stage_range(self): bootstrap = load_bootstrap_runtime() @@ -585,6 +602,22 @@ class BootstrapRuntimeTest(unittest.TestCase): self.assertEqual(bootstrap.format_elapsed(12), "12s") self.assertEqual(bootstrap.format_elapsed(83), "1m 23s") + def test_ai_component_estimate_continues_across_internal_steps(self): + bootstrap = load_bootstrap_runtime() + + first = bootstrap.estimated_remaining( + "AI components", + bootstrap.XFORMERS_SLICE[0], + ) + second = bootstrap.estimated_remaining( + "AI components", + bootstrap.AI_COMPONENTS_SLICE[0], + ) + + self.assertEqual(first, bootstrap.INSTALL_ESTIMATES["AI components"]) + self.assertGreater(second, 0) + self.assertLess(second, first) + def test_resolve_indexes_uses_configured_pypi_index(self): bootstrap = load_bootstrap_runtime() bootstrap.resolve_indexes( @@ -739,8 +772,10 @@ class BootstrapRuntimeTest(unittest.TestCase): def test_model_download_failure_keeps_runtime_ready(self): bootstrap = load_bootstrap_runtime() + commands = [] def fake_run(command, environment=None): + commands.append(command) if any("DA3-SMALL" in str(value) for value in command): raise subprocess.CalledProcessError(1, command) @@ -775,6 +810,12 @@ class BootstrapRuntimeTest(unittest.TestCase): ) self.assertEqual(status["progress"], 1.0) self.assertIn("Runtime is ready", status["message"]) + self.assertTrue( + any( + "PramaLLC/BEN2" in map(str, command) + for command in commands + ) + ) def test_install_fails_when_bundled_uv_missing(self): bootstrap = load_bootstrap_runtime() @@ -806,6 +847,10 @@ class BootstrapRuntimeTest(unittest.TestCase): ), patch.object( bootstrap.shutil, "rmtree", + ), patch.object( + bootstrap, + "bundled_uv_archive", + return_value=None, ): with self.assertRaisesRegex( RuntimeError, @@ -1011,7 +1056,7 @@ class BootstrapRuntimeTest(unittest.TestCase): self.assertIn(f"depth-anything-3 @ {da3_local}", ai_command) self.assertIn(f"ben2 @ {ben2_local}", ai_command) - def test_install_stages_download_models_into_prefixed_directories(self): + def test_install_stages_download_only_default_models(self): bootstrap = load_bootstrap_runtime() with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -1045,9 +1090,42 @@ class BootstrapRuntimeTest(unittest.TestCase): for label, command, _environment, kind in stages if kind == "download" } - self.assertIn("da3_small", " ".join(map(str, downloads["da3 small"]))) - self.assertIn("da3_base", " ".join(map(str, downloads["da3 base"]))) - self.assertIn("ben2_base", " ".join(map(str, downloads["ben2 base"]))) + self.assertIn("da3_small", " ".join(map(str, downloads["DA3 Small"]))) + self.assertIn("ben2_base", " ".join(map(str, downloads["BEN2 Base"]))) + self.assertNotIn("DA3 Base", downloads) + + def test_install_does_not_verify_runtime(self): + bootstrap = load_bootstrap_runtime() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + runtime_dir = root / "runtime" + runtime_dir.mkdir() + self.create_bundled_sources(root) + uv_path = root / "uv" + uv_path.write_bytes(b"uv") + args = SimpleNamespace( + uv=str(uv_path), + runtime_dir=str(runtime_dir), + models_dir=str(root / "models"), + backend="cu128", + da3_commit="test-commit", + ben2_commit="test-ben2-commit", + status=str(root / "status.json"), + ) + with patch.object( + bootstrap, + "resolve_indexes", + return_value=None, + ), patch.object(bootstrap, "run"), patch.object( + bootstrap, + "run_install", + ), patch.object( + bootstrap, + "run_verify", + ) as mocked_verify: + bootstrap.install(args) + + mocked_verify.assert_not_called() def test_run_verify_returns_detected_devices(self): bootstrap = load_bootstrap_runtime() @@ -1111,7 +1189,7 @@ class BootstrapRuntimeTest(unittest.TestCase): messages = [call.args[2] for call in mocked_write.call_args_list] self.assertTrue( - any("verifying runtime" in message for message in messages) + any("Checking Runtime" in message for message in messages) ) self.assertEqual(report, {}) diff --git a/tests/test_download_progress.py b/tests/test_download_progress.py index b5e8bc7..e96af51 100644 --- a/tests/test_download_progress.py +++ b/tests/test_download_progress.py @@ -58,7 +58,7 @@ class DownloadProgressTest(unittest.TestCase): status = json.loads(status_path.read_text(encoding="utf-8")) self.assertAlmostEqual(status["progress"], 0.275) - self.assertEqual(status["message"], "downloading owner/model") + self.assertEqual(status["message"], "Downloading owner/model") def test_reports_download_speed(self): class FakeTqdmWithRate(FakeTqdm): @@ -87,9 +87,31 @@ class DownloadProgressTest(unittest.TestCase): status = json.loads(status_path.read_text(encoding="utf-8")) self.assertEqual( status["message"], - "downloading da3 small 13.4 MB/s", + "Downloading da3 small | 13.4 MB/s", ) + def test_reports_estimated_time_remaining(self): + times = iter((0.0, 10.0)) + with tempfile.TemporaryDirectory() as directory: + status_path = Path(directory) / "status.json" + progress_class = self.worker.create_download_progress_class( + status_path, + "owner/model", + tqdm_type=FakeTqdm, + clock=lambda: next(times), + ) + progress = progress_class( + desc="Downloading bytes", + total=100, + initial=0, + unit="B", + ) + + progress.update(50) + + status = json.loads(status_path.read_text(encoding="utf-8")) + self.assertEqual(status["eta_seconds"], 10) + def test_staged_download_prefixes_speed_with_downloading(self): class FakeTqdmWithRate(FakeTqdm): @property @@ -118,7 +140,10 @@ class DownloadProgressTest(unittest.TestCase): progress.update(50) status = json.loads(status_path.read_text(encoding="utf-8")) - self.assertEqual(status["message"], "downloading 13.4 MB/s") + self.assertEqual( + status["message"], + "Downloading packages | 13.4 MB/s", + ) self.assertEqual(status["stage"], 6) self.assertEqual(status["stages"], 8) self.assertEqual(status["stage_label"], "da3 small") diff --git a/uv.lock b/uv.lock index 891ab1d..bea7574 100644 --- a/uv.lock +++ b/uv.lock @@ -65,7 +65,7 @@ wheels = [ [[package]] name = "snapform" -version = "0.10.10" +version = "0.10.11" source = { virtual = "." } [package.dev-dependencies]