Bump version to 0.10.11 and update installation instructions; enhance progress reporting and estimated time features

This commit is contained in:
2026-08-09 10:23:14 +08:00
parent 9c896213a5
commit 97a720051f
12 changed files with 382 additions and 150 deletions

View File

@@ -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` 中按需下载,请保持网络畅通,首次安装需要一些时间。
## 快速上手

View File

@@ -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 镜像)、

View File

@@ -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"

View File

@@ -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"

View File

@@ -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():

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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"][

View File

@@ -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, {})

View File

@@ -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")

2
uv.lock generated
View File

@@ -65,7 +65,7 @@ wheels = [
[[package]]
name = "snapform"
version = "0.10.10"
version = "0.10.11"
source = { virtual = "." }
[package.dev-dependencies]