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. 下载对应系统的扩展压缩包: 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`
2. 打开 Blender,进入 `Preferences > Extensions` 2. 打开 Blender,进入 `Preferences > Extensions`
3. 点击 `Install from Disk`,选择下载的压缩包 3. 点击 `Install from Disk`,选择下载的压缩包
4. 在扩展偏好设置中确认 Storage Root 与 Device(保持默认即可);Device 默认取本机 4. 在扩展偏好设置中确认 Storage Root 与 Device(保持默认即可);Device 默认取本机
@@ -23,7 +19,8 @@ SnapForm 是一个 Blender 扩展,可以把一张照片、一段视频或一
5. 打开 3D 视图右侧的 `SnapForm` 面板,点击 `Install Runtime` 5. 打开 3D 视图右侧的 `SnapForm` 面板,点击 `Install Runtime`
DA3、BEN2 源码包和 uv 已内置在扩展里,安装时直接从本地解包,不需要访问 GitHub。首次使用仍会自动 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; 4. 在 `Host` 点击 `Install Runtime`。首次安装会下载 Python、PyTorch、FastAPI、Uvicorn;
DA3、BEN2 源码包与 uv 已内置在扩展中,安装时从 `~/.snapform/sources/` 解包使用, DA3、BEN2 源码包与 uv 已内置在扩展中,安装时从 `~/.snapform/sources/` 解包使用,
并默认下载 `DA3 Small`、`DA3 Base` 和 `BEN2 Base` 模型。安装按 8 个阶段依次推进,每段进度 并在 Runtime 就绪后自动下载 `DA3 Small` 和 `BEN2 Base` 推荐模型。Runtime 安装
从 0 到 100%:解包内置 uv、创建 Python Runtime、下载 PyTorch、安装 xFormers(macOS 跳过, 固定显示 `Environment`、`PyTorch`、`AI components` 三个用户阶段;xFormers(macOS 跳过)、
内置 DA3 源码包为官方原版并已移除 xformers 硬依赖,自动回退纯 PyTorch 实现)、安装 AI 依赖, FastAPI 等内部安装步骤合并在 `AI components` 中。耗时最长的 PyTorch 与 AI 组件占主要进度
然后是三个模型的下载。安装完成后进入 `验证 Runtime` 阶段,会导入 Torch 与全部 AI 依赖; 区间。安装完成后直接写入 Runtime 就绪信息,不再额外导入全部组件进行验证。推荐模型属于安装
macOS 首次导入 Torch 会初始化 Metal 后端(编译 shader 缓存),该阶段可能持续较久,状态栏 后的自动步骤,不计入阶段编号;`DA3 Base` 等其他模型可稍后在 `Models` 面板按需下载。
会显示已用时间而不是卡住。进度文字统一为 `阶段/总数 阶段名: 状态`, 状态文字使用 `阶段/总数 阶段名 | 当前操作 | 预计剩余时间`,依赖安装会结合预设耗时与实际
模型下载阶段会实时显示下载速度,例如 `6/8 da3 small: downloading 14.2MB/s`。 进度修正预期;模型下载根据字节数和速度计算剩余时间,例如
`Downloading DA3 Small | 14.2 MB/s | About 2 min left`。重试或切换镜像时进度不会倒退。
Runtime 就绪后即使模型下载中断也不影响 Host 使用,可稍后在 `Models` 面板单独补下。 Runtime 就绪后即使模型下载中断也不影响 Host 使用,可稍后在 `Models` 面板单独补下。
安装不做启动网络预检:pip 安装失败会自动依次重试清华、阿里镜像,模型下载由 worker 安装不做启动网络预检:pip 安装失败会自动依次重试清华、阿里镜像,模型下载由 worker
自行探测回退到 HF 镜像;需要手动指定镜像时可通过 `PIP_INDEX_URL` 环境变量覆盖。 自行探测回退到 HF 镜像;需要手动指定镜像时可通过 `PIP_INDEX_URL` 环境变量覆盖。
@@ -138,8 +139,9 @@ Cyclic 沿用原 Empty 的设置。序列帧无法被 Blender 整体 Pack,因
`8×12` 个面。随后的 Simple Subdivision 修改器使用原生名称,Viewport Levels 固定为 `8×12` 个面。随后的 Simple Subdivision 修改器使用原生名称,Viewport Levels 固定为
`1`,Render Levels 固定为 `2`,不再随 Resolution Scale 或 Subdivision 动态变化。 `1`,Render Levels 固定为 `2`,不再随 Resolution Scale 或 Subdivision 动态变化。
所有任务使用统一的状态栏进度显示。变化中的阶段说明显示在右侧的紧凑进度条内部,取消按钮 所有任务使用统一的状态栏进度显示。变化中的阶段说明显示在右侧的进度条内部,取消按钮
位于进度条右侧。Image Depth 的阶段包括 Runtime 启动、输入准备、模型加载、深度估算、缓存 位于进度条右侧。状态字段支持阶段、当前操作与预计剩余秒数,界面统一组合为紧凑文案并保证
进度单调递增。Image Depth 的阶段包括 Runtime 启动、输入准备、模型加载、深度估算、缓存
写出和面片构建。进度更新会强制刷新状态栏与窗口,无需移动鼠标即可实时显示。安装中断后残留的 写出和面片构建。进度更新会强制刷新状态栏与窗口,无需移动鼠标即可实时显示。安装中断后残留的
半成品 Runtime 会在下次安装前自动清理,不会因为中断而无法再次安装。 半成品 Runtime 会在下次安装前自动清理,不会因为中断而无法再次安装。
@@ -281,7 +283,7 @@ GLB 任务会关闭其默认附带的深度预览,使四种 job 产物保持
Extension 本身不包含 Torch。`Install Runtime` 使用 `uv` 创建独立 Python 3.12 Extension 本身不包含 Torch。`Install Runtime` 使用 `uv` 创建独立 Python 3.12
环境,并安装。机器上即使没有安装 `uv` 或 Python 也没关系:安装程序由 Blender 环境,并安装。机器上即使没有安装 `uv` 或 Python 也没关系:安装程序由 Blender
自带的 Python 启动,缺 `uv` 时会自动下载官方独立二进制(约 30MB),再用它下载 自带的 Python 启动,缺 `uv` 时会解包扩展内置的对应平台二进制,再用它下载
Python 3.12 与全部依赖。`uv` 会缓存在扩展存储目录下,后续安装直接复用。 Python 3.12 与全部依赖。`uv` 会缓存在扩展存储目录下,后续安装直接复用。
安装不做网络预检,直接开始;环境变量仍可切换镜像与代理:`PIP_INDEX_URL`(PyPI 镜像)、 安装不做网络预检,直接开始;环境变量仍可切换镜像与代理:`PIP_INDEX_URL`(PyPI 镜像)、
`SNAPFORM_TORCH_INDEX`(PyTorch 索引)、`HF_ENDPOINT`(Hugging Face 镜像)、 `SNAPFORM_TORCH_INDEX`(PyTorch 索引)、`HF_ENDPOINT`(Hugging Face 镜像)、

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "snapform" name = "snapform"
version = "0.10.10" version = "0.10.11"
description = "Fast image-guided structure building tools for Blender" description = "Fast image-guided structure building tools for Blender"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"

View File

@@ -1,7 +1,7 @@
schema_version = "1.0.0" schema_version = "1.0.0"
id = "snapform" id = "snapform"
version = "0.10.10" version = "0.10.11"
name = "SnapForm" name = "SnapForm"
tagline = "Build spatial structures from images, fast" tagline = "Build spatial structures from images, fast"
maintainer = "OmooLab" maintainer = "OmooLab"

View File

@@ -39,16 +39,24 @@ TORCH_INDEXES = {
"cpu": "https://download.pytorch.org/whl/cpu", "cpu": "https://download.pytorch.org/whl/cpu",
} }
_resolved_pypi_index = None _resolved_pypi_index = None
DEFAULT_MODEL_KEYS = ("SMALL", "BASE", BEN2_MODEL.key) DEFAULT_MODEL_KEYS = ("SMALL", BEN2_MODEL.key)
RUNTIME_SLICE_START = 0.07 RUNTIME_STAGE_COUNT = 3
RUNTIME_SLICE_END = 0.88 ENVIRONMENT_SLICE = (0.02, 0.06)
VERIFY_SLICE_START = 0.88 PYTORCH_SLICE = (0.06, 0.48)
VERIFY_SLICE_END = 0.90 XFORMERS_SLICE = (0.48, 0.62)
MODELS_SLICE_START = 0.90 AI_COMPONENTS_SLICE = (0.62, 0.85)
MODELS_SLICE_START = 0.85
MODELS_SLICE_END = 1.0 MODELS_SLICE_END = 1.0
PLAIN_SLICE_SPAN = 0.02
RAMP_RATE = 0.02 RAMP_RATE = 0.02
CREEP_CAP = 0.60 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 = ( CHECK_CODE = (
"import json; import torch; " "import json; import torch; "
"from depth_anything_3.api import DepthAnything3; " "from depth_anything_3.api import DepthAnything3; "
@@ -70,6 +78,7 @@ def write_status(
stage=None, stage=None,
stages=None, stages=None,
stage_label=None, stage_label=None,
eta_seconds=None,
): ):
payload = {"progress": progress, "message": message} payload = {"progress": progress, "message": message}
if error: if error:
@@ -78,6 +87,8 @@ def write_status(
payload["stage"] = stage payload["stage"] = stage
payload["stages"] = stages payload["stages"] = stages
payload["stage_label"] = stage_label payload["stage_label"] = stage_label
if eta_seconds is not None:
payload["eta_seconds"] = max(int(eta_seconds), 0)
Path(path).write_text( Path(path).write_text(
json.dumps(payload, ensure_ascii=False), json.dumps(payload, ensure_ascii=False),
encoding="utf-8", encoding="utf-8",
@@ -120,6 +131,21 @@ def parse_package_count(line):
return 0 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): def effective_torch_index(backend):
return os.environ.get("SNAPFORM_TORCH_INDEX") or TORCH_INDEXES.get(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() 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): def eased(fraction):
return start + min(max(fraction, 0.0), 1.0) * (end - start) return start + min(max(fraction, 0.0), 1.0) * (end - start)
@@ -186,6 +202,21 @@ def _run_install_once(
started = clock() started = clock()
last_tick = started last_tick = started
last_heartbeat = 0.0 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: while True:
try: try:
raw_line = output_queue.get(timeout=0.2) raw_line = output_queue.get(timeout=0.2)
@@ -206,7 +237,7 @@ def _run_install_once(
if empty: if empty:
if elapsed - last_heartbeat >= 5.0: if elapsed - last_heartbeat >= 5.0:
last_heartbeat = elapsed last_heartbeat = elapsed
write(displayed, f"downloading {format_elapsed(elapsed)}") detail("Downloading packages")
continue continue
line = raw_line.rstrip() line = raw_line.rstrip()
print(line) print(line)
@@ -215,17 +246,17 @@ def _run_install_once(
resolved_progress = eased(0.02) resolved_progress = eased(0.02)
displayed = max(displayed, resolved_progress) displayed = max(displayed, resolved_progress)
target = max(target, 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: elif line.startswith("Prepared ") and "package" in line:
done = parse_package_count(line) done = parse_package_count(line)
if total: if total:
target = max(target, eased(0.10 + 0.60 * done / 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: elif line.startswith("Installed ") and "package" in line:
done = parse_package_count(line) done = parse_package_count(line)
if total: if total:
target = max(target, eased(0.75 + 0.25 * done / 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("+"): elif line.startswith("+"):
entry = line[1:].strip() entry = line[1:].strip()
installed.append(entry) installed.append(entry)
@@ -315,19 +346,19 @@ def install_stages(args, runtime_dir, python, uv):
torch_command.extend(["--index-url", torch_index]) torch_command.extend(["--index-url", torch_index])
stages = [ stages = [
( (
"uv", "Environment",
[uv, "venv", "--clear", "--python", "3.12", runtime_dir], [uv, "venv", "--clear", "--python", "3.12", runtime_dir],
None, None,
"plain", "plain",
), ),
( (
"pytorch deps", "PyTorch",
torch_command, torch_command,
None, None,
"install", "install",
), ),
( (
"ai deps", "AI components",
[ [
uv, uv,
"pip", "pip",
@@ -363,7 +394,7 @@ def install_stages(args, runtime_dir, python, uv):
xformers_command.append("--no-deps") xformers_command.append("--no-deps")
stages.insert( stages.insert(
2, 2,
("xformers", xformers_command, None, "install"), ("AI components", xformers_command, None, "install"),
) )
models = {**MODELS, BEN2_MODEL.key: BEN2_MODEL} models = {**MODELS, BEN2_MODEL.key: BEN2_MODEL}
worker_script = Path(__file__).with_name("worker.py") 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, models_dir / model.directory_name,
"--status", "--status",
args.status, args.status,
"--label",
model.label,
] ]
for pattern in model.download_patterns: for pattern in model.download_patterns:
download_command.extend(["--allow-pattern", pattern]) download_command.extend(["--allow-pattern", pattern])
stages.append( stages.append(
( (
model.label.lower(), model.label,
download_command, download_command,
None, None,
"download", "download",
@@ -405,6 +438,17 @@ def runtime_ready(runtime_dir):
return payload.get("runtime_schema") == RUNTIME_SCHEMA 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): def verify_command(python):
return [python, "-c", CHECK_CODE] return [python, "-c", CHECK_CODE]
@@ -412,9 +456,12 @@ def verify_command(python):
def run_verify( def run_verify(
python, python,
status_path, status_path,
start=VERIFY_SLICE_START, start=0.0,
end=VERIFY_SLICE_END, end=1.0,
clock=time.monotonic, clock=time.monotonic,
stage=None,
stages=None,
stage_label=None,
): ):
"""Run the runtime import check with heartbeat progress; return the device report.""" """Run the runtime import check with heartbeat progress; return the device report."""
command = verify_command(python) command = verify_command(python)
@@ -457,7 +504,11 @@ def run_verify(
write_status( write_status(
status_path, status_path,
progress, 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 continue
line = raw_line.rstrip() line = raw_line.rstrip()
@@ -585,13 +636,20 @@ def run_stage(
start=0.0, start=0.0,
end=1.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( write_status(
status_path, status_path,
start, start,
"", opening_message,
stage=stage, stage=stage,
stages=stages, stages=stages,
stage_label=label, stage_label=label,
eta_seconds=estimated_remaining(label, start),
) )
installed = [] installed = []
if kind == "install": if kind == "install":
@@ -606,28 +664,31 @@ def run_stage(
end=end, end=end,
) )
elif kind == "download": elif kind == "download":
run( progress_command = [
[ *command,
*command, "--progress-start",
"--progress-start", str(start),
str(start), "--progress-end",
"--progress-end", str(end),
str(end), ]
"--stage", if stage is not None:
str(stage), progress_command.extend(
"--stages", [
str(stages), "--stage",
"--stage-label", str(stage),
label, "--stages",
], str(stages),
environment, "--stage-label",
) label,
]
)
run(progress_command, environment)
else: else:
run(command, environment) run(command, environment)
write_status( write_status(
status_path, status_path,
end, end,
"", f"{label} ready" if stage is None else "",
stage=stage, stage=stage,
stages=stages, stages=stages,
stage_label=label, stage_label=label,
@@ -662,44 +723,45 @@ def install(args):
stages = install_stages(args, runtime_dir, python, uv) stages = install_stages(args, runtime_dir, python, uv)
runtime_stages = [stage for stage in stages if stage[3] != "download"] runtime_stages = [stage for stage in stages if stage[3] != "download"]
model_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 = ( model_span = (
(MODELS_SLICE_END - MODELS_SLICE_START) / len(model_stages) (MODELS_SLICE_END - MODELS_SLICE_START) / len(model_stages)
if model_stages if model_stages
else 0.0 else 0.0
) )
installed_packages = [] installed_packages = []
runtime_offset = RUNTIME_SLICE_START component_stages = runtime_stages[2:]
for index, (label, command, environment, kind) in enumerate( component_ranges = (
runtime_stages, [XFORMERS_SLICE, AI_COMPONENTS_SLICE]
start=1, if len(component_stages) == 2
): else [(XFORMERS_SLICE[0], AI_COMPONENTS_SLICE[1])]
stage_start = runtime_offset )
stage_end = stage_start + ( for index, (label, command, environment, kind) in enumerate(runtime_stages):
install_span if kind == "install" else PLAIN_SLICE_SPAN if index == 0:
) stage = 1
if uv_missing and index == 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( install_uv(
uv, uv,
args.status, args.status,
stage=index, stage=stage,
stages=len(stages), stages=RUNTIME_STAGE_COUNT,
stage_label=label, stage_label=label,
start=stage_start, 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( stage_installed = run_stage(
args.status, args.status,
index, stage,
len(stages), RUNTIME_STAGE_COUNT,
label, label,
command, command,
environment, environment,
@@ -709,17 +771,7 @@ def install(args):
) )
if isinstance(stage_installed, list): if isinstance(stage_installed, list):
installed_packages.extend(stage_installed) installed_packages.extend(stage_installed)
runtime_offset = stage_end devices = installed_devices(args)
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")
runtime_dir.mkdir(parents=True, exist_ok=True) runtime_dir.mkdir(parents=True, exist_ok=True)
ready_path = runtime_dir / "snapform_ready.json" ready_path = runtime_dir / "snapform_ready.json"
ready_path.write_text( ready_path.write_text(
@@ -741,19 +793,16 @@ def install(args):
), ),
encoding="utf-8", encoding="utf-8",
) )
offset = len(runtime_stages)
model_offset = MODELS_SLICE_START model_offset = MODELS_SLICE_START
for index, (label, command, environment, kind) in enumerate( failed_models = []
model_stages, for label, command, environment, kind in model_stages:
start=offset + 1,
):
stage_start = model_offset stage_start = model_offset
stage_end = stage_start + model_span stage_end = stage_start + model_span
try: try:
run_stage( run_stage(
args.status, args.status,
index, None,
len(stages), None,
label, label,
command, command,
environment, environment,
@@ -763,14 +812,22 @@ def install(args):
) )
except Exception as error: except Exception as error:
print(f"Model download failed: {label}: {error}") print(f"Model download failed: {label}: {error}")
failed_models.append(label)
write_status( write_status(
args.status, args.status,
MODELS_SLICE_END, stage_end,
f"Runtime is ready; {label} failed", f"Skipping {label}; continuing",
) )
return
model_offset = stage_end 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(): def parse_args():

View File

@@ -475,23 +475,53 @@ class RunJob(bpy.types.Operator):
if status is None: if status is None:
return return
settings = context.scene.snapform_settings 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) settings.status = self._format_status(status, settings.status)
self._redraw_ui(context, force=True) self._redraw_ui(context, force=True)
def _format_status(self, status, fallback): def _format_status(self, status, fallback):
message = status.get("message", fallback) message = status.get("message")
stage = status.get("stage") stage = status.get("stage")
stages = status.get("stages") stages = status.get("stages")
stage_label = status.get("stage_label") stage_label = status.get("stage_label")
if not stage or not stages: parts = []
return message if stage and stages:
text = ( stage_text = f"{stage}/{stages}"
f"{stage}/{stages} {stage_label}" if stage_label:
if stage_label stage_text = f"{stage_text} {stage_label}"
else f"{stage}/{stages}" parts.append(stage_text)
) elif stage_label:
return f"{text}: {message}" if message else text 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): def _finish(self, context, job, return_code):
settings = context.scene.snapform_settings 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) progress = min(max(settings.progress, 0.0), 1.0)
row = self.layout.row(align=True) row = self.layout.row(align=True)
progress_row = row.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( progress_row.progress(
factor=progress, factor=progress,
type="BAR", type="BAR",

View File

@@ -49,6 +49,7 @@ def write_status(
stage=None, stage=None,
stages=None, stages=None,
stage_label=None, stage_label=None,
eta_seconds=None,
): ):
payload = {"progress": progress, "message": message} payload = {"progress": progress, "message": message}
if error: if error:
@@ -57,6 +58,8 @@ def write_status(
payload["stage"] = stage payload["stage"] = stage
payload["stages"] = stages payload["stages"] = stages
payload["stage_label"] = stage_label payload["stage_label"] = stage_label
if eta_seconds is not None:
payload["eta_seconds"] = max(int(eta_seconds), 0)
if state is None: if state is None:
if error: if error:
state = "failed" state = "failed"
@@ -229,6 +232,7 @@ class DownloadProgressReporter:
self.label = label self.label = label
self.progress = start self.progress = start
self.last_write_time = 0.0 self.last_write_time = 0.0
self.started_at = self.clock()
def report(self, current, total, rate=None): def report(self, current, total, rate=None):
if not total or current <= 0: if not total or current <= 0:
@@ -244,14 +248,25 @@ class DownloadProgressReporter:
return return
self.progress = progress self.progress = progress
self.last_write_time = now 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: 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: else:
prefix = self.label or self.repository prefix = self.label or self.repository
message = f"downloading {prefix}" message = f"Downloading {prefix}"
if speed: if speed:
message = f"{message} {speed}" message = f"{message} | {speed}"
write_status( write_status(
self.status_path, self.status_path,
progress, progress,
@@ -259,6 +274,7 @@ class DownloadProgressReporter:
stage=self.stage, stage=self.stage,
stages=self.stages, stages=self.stages,
stage_label=self.stage_label, stage_label=self.stage_label,
eta_seconds=eta_seconds,
) )
@@ -356,7 +372,7 @@ def download_model(args):
failures.append((endpoint, "endpoint unreachable")) failures.append((endpoint, "endpoint unreachable"))
continue continue
os.environ["HF_ENDPOINT"] = endpoint os.environ["HF_ENDPOINT"] = endpoint
message = f"downloading {label}" message = f"Downloading {label}"
if endpoint != HF_ENDPOINT: if endpoint != HF_ENDPOINT:
message = f"{message} (mirror)" message = f"{message} (mirror)"
write_status(args.status, start, message, **stage_kwargs) write_status(args.status, start, message, **stage_kwargs)
@@ -375,7 +391,7 @@ def download_model(args):
write_status( write_status(
args.status, args.status,
start + span, start + span,
"model ready", f"{label} ready",
**stage_kwargs, **stage_kwargs,
) )
return return
@@ -384,7 +400,7 @@ def download_model(args):
write_status( write_status(
args.status, args.status,
start, start,
f"downloading {label} (mirror: modelscope.cn)", f"Downloading {label} | ModelScope mirror",
**stage_kwargs, **stage_kwargs,
) )
download_from_modelscope( download_from_modelscope(
@@ -402,7 +418,7 @@ def download_model(args):
write_status( write_status(
args.status, args.status,
start + span, start + span,
"model ready", f"{label} ready",
**stage_kwargs, **stage_kwargs,
) )
return return

View File

@@ -611,7 +611,7 @@ class AddonTest(unittest.TestCase):
}, },
"fallback", "fallback",
), ),
"3/8 pytorch: 2 downloaded", "3/8 pytorch | 2 downloaded",
) )
self.assertEqual( self.assertEqual(
operator._format_status( operator._format_status(
@@ -636,6 +636,33 @@ class AddonTest(unittest.TestCase):
), ),
"Estimating depth", "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): def test_model_options_mark_only_models_that_are_not_downloaded(self):
items_callback = self.addon.SnapFormSettings.__annotations__["model"][ 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( ben2_download = next(
command command
for command in default_downloads for command in default_downloads
@@ -165,9 +171,12 @@ class BootstrapRuntimeTest(unittest.TestCase):
if value == "--allow-pattern" if value == "--allow-pattern"
] ]
self.assertEqual(patterns, ["config.json", "model.safetensors"]) self.assertEqual(patterns, ["config.json", "model.safetensors"])
self.assertIn("--stage", ben2_download) self.assertNotIn("--stage", ben2_download)
self.assertIn("--stage-label", ben2_download) self.assertNotIn("--stage-label", ben2_download)
self.assertEqual(ben2_download[-1], "ben2 base") self.assertEqual(
ben2_download[ben2_download.index("--label") + 1],
"BEN2 Base",
)
def test_darwin_install_skips_cuda_index_and_xformers(self): def test_darwin_install_skips_cuda_index_and_xformers(self):
bootstrap = load_bootstrap_runtime() 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)) all(starts[i] < starts[i + 1] for i in range(len(starts) - 1))
) )
self.assertEqual(ends[-1], 1.0) 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( status = json.loads(
(root / "status.json").read_text(encoding="utf-8") (root / "status.json").read_text(encoding="utf-8")
) )
@@ -443,9 +460,9 @@ class BootstrapRuntimeTest(unittest.TestCase):
self.assertEqual( self.assertEqual(
messages, messages,
[ [
"3 packages", "Resolving 3 packages",
"3/3 packages", "Downloading packages 3/3",
"3/3 packages", "Installing packages 3/3",
], ],
) )
self.assertEqual(mocked_write.call_args_list[0].args[1], 0.02) 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] messages = [call.args[2] for call in mocked_write.call_args_list]
self.assertIn("3 packages", messages) self.assertIn("Resolving 3 packages", messages)
self.assertIn("3/3 packages", messages) self.assertIn("Downloading packages 3/3", messages)
def test_run_install_maps_progress_into_stage_range(self): def test_run_install_maps_progress_into_stage_range(self):
bootstrap = load_bootstrap_runtime() bootstrap = load_bootstrap_runtime()
@@ -585,6 +602,22 @@ class BootstrapRuntimeTest(unittest.TestCase):
self.assertEqual(bootstrap.format_elapsed(12), "12s") self.assertEqual(bootstrap.format_elapsed(12), "12s")
self.assertEqual(bootstrap.format_elapsed(83), "1m 23s") 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): def test_resolve_indexes_uses_configured_pypi_index(self):
bootstrap = load_bootstrap_runtime() bootstrap = load_bootstrap_runtime()
bootstrap.resolve_indexes( bootstrap.resolve_indexes(
@@ -739,8 +772,10 @@ class BootstrapRuntimeTest(unittest.TestCase):
def test_model_download_failure_keeps_runtime_ready(self): def test_model_download_failure_keeps_runtime_ready(self):
bootstrap = load_bootstrap_runtime() bootstrap = load_bootstrap_runtime()
commands = []
def fake_run(command, environment=None): def fake_run(command, environment=None):
commands.append(command)
if any("DA3-SMALL" in str(value) for value in command): if any("DA3-SMALL" in str(value) for value in command):
raise subprocess.CalledProcessError(1, command) raise subprocess.CalledProcessError(1, command)
@@ -775,6 +810,12 @@ class BootstrapRuntimeTest(unittest.TestCase):
) )
self.assertEqual(status["progress"], 1.0) self.assertEqual(status["progress"], 1.0)
self.assertIn("Runtime is ready", status["message"]) 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): def test_install_fails_when_bundled_uv_missing(self):
bootstrap = load_bootstrap_runtime() bootstrap = load_bootstrap_runtime()
@@ -806,6 +847,10 @@ class BootstrapRuntimeTest(unittest.TestCase):
), patch.object( ), patch.object(
bootstrap.shutil, bootstrap.shutil,
"rmtree", "rmtree",
), patch.object(
bootstrap,
"bundled_uv_archive",
return_value=None,
): ):
with self.assertRaisesRegex( with self.assertRaisesRegex(
RuntimeError, RuntimeError,
@@ -1011,7 +1056,7 @@ class BootstrapRuntimeTest(unittest.TestCase):
self.assertIn(f"depth-anything-3 @ {da3_local}", ai_command) self.assertIn(f"depth-anything-3 @ {da3_local}", ai_command)
self.assertIn(f"ben2 @ {ben2_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() bootstrap = load_bootstrap_runtime()
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
@@ -1045,9 +1090,42 @@ class BootstrapRuntimeTest(unittest.TestCase):
for label, command, _environment, kind in stages for label, command, _environment, kind in stages
if kind == "download" if kind == "download"
} }
self.assertIn("da3_small", " ".join(map(str, downloads["da3 small"]))) 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("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): def test_run_verify_returns_detected_devices(self):
bootstrap = load_bootstrap_runtime() bootstrap = load_bootstrap_runtime()
@@ -1111,7 +1189,7 @@ class BootstrapRuntimeTest(unittest.TestCase):
messages = [call.args[2] for call in mocked_write.call_args_list] messages = [call.args[2] for call in mocked_write.call_args_list]
self.assertTrue( self.assertTrue(
any("verifying runtime" in message for message in messages) any("Checking Runtime" in message for message in messages)
) )
self.assertEqual(report, {}) self.assertEqual(report, {})

View File

@@ -58,7 +58,7 @@ class DownloadProgressTest(unittest.TestCase):
status = json.loads(status_path.read_text(encoding="utf-8")) status = json.loads(status_path.read_text(encoding="utf-8"))
self.assertAlmostEqual(status["progress"], 0.275) 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): def test_reports_download_speed(self):
class FakeTqdmWithRate(FakeTqdm): class FakeTqdmWithRate(FakeTqdm):
@@ -87,9 +87,31 @@ class DownloadProgressTest(unittest.TestCase):
status = json.loads(status_path.read_text(encoding="utf-8")) status = json.loads(status_path.read_text(encoding="utf-8"))
self.assertEqual( self.assertEqual(
status["message"], 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): def test_staged_download_prefixes_speed_with_downloading(self):
class FakeTqdmWithRate(FakeTqdm): class FakeTqdmWithRate(FakeTqdm):
@property @property
@@ -118,7 +140,10 @@ class DownloadProgressTest(unittest.TestCase):
progress.update(50) progress.update(50)
status = json.loads(status_path.read_text(encoding="utf-8")) 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["stage"], 6)
self.assertEqual(status["stages"], 8) self.assertEqual(status["stages"], 8)
self.assertEqual(status["stage_label"], "da3 small") self.assertEqual(status["stage_label"], "da3 small")

2
uv.lock generated
View File

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