Bump version to 0.10.11 and update installation instructions; enhance progress reporting and estimated time features
This commit is contained in:
@@ -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` 中按需下载,请保持网络畅通,首次安装需要一些时间。
|
||||
|
||||
## 快速上手
|
||||
|
||||
|
||||
@@ -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 镜像)、
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"][
|
||||
|
||||
@@ -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, {})
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user