devatlas-ai-gpu / scripts /fetch_i2v_model.py
YashKotak's picture
Upload Python project
6ef0a95
Raw History Blame Contribute Delete
35 kB
"""Fetch the LOCAL image-to-video model snapshot used by the AI service.
Run this ONCE on a machine that has the disk and bandwidth:
python scripts/fetch_i2v_model.py # download / repair
python scripts/fetch_i2v_model.py --verify # check only, download nothing
python scripts/fetch_i2v_model.py --force # re-fetch anything suspect
It downloads `Lightricks/LTX-Video` (an open-weights, image-to-video diffusion
model) into `LOCAL_DIFFUSION_MODEL_DIR` (default `models/ltx-video`). After
this, video generation with `VIDEO_PROVIDER=local_diffusion` runs completely
offline: the service loads the snapshot with `local_files_only=True` and never
touches the network.
This is the ONLY place in the project that downloads model weights. There is
no third-party AI API anywhere in the generation path.
WHY THIS SCRIPT IS NOT A `snapshot_download` CALL
------------------------------------------------
`huggingface_hub.snapshot_download()` fetches each file over a SINGLE HTTP
stream. This host's link delivers ~0.3-1.9 MB/s per stream and drops
connections mid-transfer (observed: `IncompleteRead(7285660 bytes read, 517027676
more expected)`). The T5 text encoder is four ~4.8 GB shards, so a single
stream needs over an hour per shard, and the one that dies at 90% forces the
whole shard to restart. The retry budget then expires and the snapshot stays
permanently short - the exact "folder exists but is incomplete" failure, which
surfaced as:
local I2V model snapshot ... is incomplete; missing:
text_encoder/model-00003-of-00004.safetensors, ...
Measured on this link: 1 stream = 1.9 MB/s, 8 streams = 3.0 MB/s. So the fix is
to keep `snapshot_download` out of the transfer path and drive plain HTTP range
requests ourselves:
* the authoritative file list + byte sizes come from the Hub API, not from
guessing globs, so "complete" is defined by the publisher, not by us;
* each file is fetched as N concurrent byte ranges into a `.part` file, with
a `.parts.json` ledger recording which ranges already landed, so an
interrupted run resumes at byte granularity instead of restarting;
* every file is validated against the published size AND the published
sha256 (the blob ETag) before it is moved into place;
* a file that is already present and correct is never touched.
Size: roughly 27 GB on disk (fp32 weights). Peak RAM during inference is much
lower than that because the pipeline loads in bfloat16 and decodes the VAE in
chunks, but the host still needs ~14 GB free RAM. See README for guidance on
smaller local models.
"""
from __future__ import annotations
import hashlib
import json
import os
import shutil
import subprocess
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
# LTX-Video ships its weights in fp32; the pipeline casts to bfloat16/fp16 on
# load, so these are the files the snapshot must contain.
#
# ALLOW_PATTERNS is the DOWNLOAD filter.
#
# The sharded `*.safetensors.index.json` manifests must be listed EXPLICITLY.
# The obvious-looking glob `text_encoder/model-*.safetensors` does not match
# them: the manifest is `model.safetensors.index.json` - a `.` (not a `-`)
# after "model", and a `.json` (not `.safetensors`) suffix. Omitting the
# manifests downloads every multi-GB shard and still leaves the snapshot
# UNLOADABLE, because transformers cannot map weights onto shard files without
# the manifest. That is exactly the "folder exists but is incomplete" failure.
ALLOW_PATTERNS = [
"model_index.json",
"scheduler/scheduler_config.json",
"tokenizer/*",
"text_encoder/config.json",
"text_encoder/model*.safetensors",
"text_encoder/model.safetensors.index.json",
"transformer/config.json",
"transformer/diffusion_pytorch_model*.safetensors",
"transformer/diffusion_pytorch_model.safetensors.index.json",
"vae/config.json",
"vae/diffusion_pytorch_model.safetensors",
]
# The repo also publishes ~150 GB of single-file checkpoints at its root
# (ltxv-13b-*, ltx-video-2b-*, spatial/temporal upscalers) plus demo media.
# None of them are part of the diffusers pipeline. `allow_patterns` already
# excludes them; listing them in `ignore_patterns` as well keeps them excluded
# even if the allow filter above is ever loosened.
IGNORE_PATTERNS = [
"media/*",
"*.md",
"*.txt",
"*.gif",
"*.png",
"*.mp4",
"ltxv-*.safetensors",
"ltx-video-*.safetensors",
"*.pth",
"*.ckpt",
]
# Exact files that must exist AND be non-empty for the pipeline to load.
# (Globs drive the download; verification needs exact names.)
REQUIRED_FILES = [
"model_index.json",
"scheduler/scheduler_config.json",
"tokenizer/tokenizer_config.json",
"tokenizer/spiece.model",
"text_encoder/config.json",
"text_encoder/model.safetensors.index.json",
"transformer/config.json",
"transformer/diffusion_pytorch_model.safetensors.index.json",
"vae/config.json",
"vae/diffusion_pytorch_model.safetensors",
]
# Manifests that enumerate the shards of a sharded checkpoint. Every shard a
# manifest names must be present and non-empty.
SHARD_MANIFESTS = [
"text_encoder/model.safetensors.index.json",
"transformer/diffusion_pytorch_model.safetensors.index.json",
]
# Target size of one range request, and how many run at once.
#
# Chunks are deliberately small: the resume ledger records progress per chunk,
# so a 32 MB chunk means an interrupted run loses at most 32 MB of work instead
# of restarting a multi-GB shard. 8 concurrent chunks measured ~3.0 MB/s on
# this link versus ~1.9 MB/s for a single stream, and each is retried
# independently, so one dropped connection costs a chunk rather than a shard.
RANGE_CHUNK_BYTES = int(os.environ.get("I2V_RANGE_CHUNK_BYTES", str(32 * 1024 * 1024)))
RANGES_IN_FLIGHT = int(os.environ.get("I2V_RANGES_IN_FLIGHT", "8"))
# Read timeout for a single range request. Kept short so a wedged socket is
# retried quickly instead of blocking a worker slot until the OS gives up.
RANGE_TIMEOUT = float(os.environ.get("I2V_RANGE_TIMEOUT", "30"))
# Per-chunk retry budget. A retry re-requests only the bytes still missing, so
# a long flaky transfer still converges.
RANGE_ATTEMPTS = int(os.environ.get("I2V_RANGE_ATTEMPTS", "40"))
# Live progress, in bytes, shared with the stall watchdog.
# The watchdog MUST use this rather than the size of the files on disk: the
# `.part` scratch file is pre-allocated to the full published size with
# `truncate()`, so its on-disk length is constant from the first second and a
# directory-size watchdog would declare a perfectly healthy transfer wedged.
_PROGRESS = [0]
def _wanted(
rel: str,
allow: list | None = None,
ignore: list | None = None,
) -> bool:
"""True when `rel` is part of the diffusers pipeline snapshot.
The allow/ignore lists are parameters (defaulting to the video model's) so
`scripts/fetch_image_model.py` can reuse this exact downloader for a
different repository instead of forking a second, flakier copy.
"""
from fnmatch import fnmatch
ignore = IGNORE_PATTERNS if ignore is None else ignore
allow = ALLOW_PATTERNS if allow is None else allow
if any(fnmatch(rel, pat) for pat in ignore):
return False
return any(fnmatch(rel, pat) for pat in allow)
def fetch_manifest(
repo: str,
allow: list | None = None,
ignore: list | None = None,
) -> dict:
"""Ask the Hub for the authoritative file list, sizes, sha256s and commit sha.
Returns `{"repo", "revision", "files": {rel: size}, "oids": {rel: sha256}}`.
The revision is returned because the whole snapshot must come from ONE
commit: mixing files from two revisions of a diffusers repo produces a
folder that passes every structural check and still fails to load.
The sha256 of every LFS blob is captured HERE, at the pinned revision, and
carried in the manifest as the single source of truth for integrity. It
must not be re-read from the CDN's `ETag` at download time: the redirect
that serves the bytes lands on an edge node whose `ETag` is NOT the
content hash, so a correct download is rejected and thrown away. That bug
discarded ~2.6 GB of perfectly good weights and made this snapshot look
permanently unfetchable.
"""
from huggingface_hub import HfApi
info = HfApi().model_info(repo, files_metadata=True)
files = {}
oids = {}
for sibling in info.siblings or []:
rel = sibling.rfilename
if not _wanted(rel, allow, ignore):
continue
size = getattr(sibling, "size", None)
if size is None:
raise RuntimeError(f"Hub did not report a size for {repo}:{rel}")
files[rel] = int(size)
# `lfs.oid` (or the sibling's own sha for a non-LFS blob) is the
# content-addressed sha256 the Hub published for this exact revision.
blob_sha = getattr(sibling, "lfs", None)
oid = getattr(blob_sha, "sha256", None) if blob_sha is not None else None
if oid is None:
oid = getattr(sibling, "blob_id", None)
if isinstance(oid, str) and len(oid) == 64:
oids[rel] = oid
if not files:
raise RuntimeError(
f"{repo} returned no files matching the snapshot allow-list"
)
return {"repo": repo, "revision": info.sha, "files": files, "oids": oids}
def resolve_target() -> Path:
"""Absolute snapshot directory, from settings, resolved against this file."""
from app.config.settings import get_settings
settings = get_settings()
target = Path(settings.local_diffusion_model_dir)
if not target.is_absolute():
target = Path.cwd() / target
return target
def resolve_repo() -> str:
from app.config.settings import get_settings
return get_settings().local_diffusion_model_id or "Lightricks/LTX-Video"
def verify_snapshot(
root: Path,
manifest: dict | None = None,
required: list | None = None,
shard_manifests: list | None = None,
) -> list:
"""Return every problem that would make this snapshot unloadable.
A completed transfer is NOT proof of a usable snapshot, and neither is a
full-looking directory: a truncated shard is still a file, and
`from_pretrained` then fails deep inside safetensors with a confusing
error. This is the honest check that runs before the script claims success.
Checks, in increasing cost:
1. the small config/manifest files are present and non-empty,
2. every shard a manifest promises is present and non-empty,
3. every file the Hub published is present, and when `manifest` is
supplied its on-disk size matches the published size exactly.
Level 3 is what catches a half-written shard that level 2 waves through.
"""
problems: list = []
required = REQUIRED_FILES if required is None else required
shard_manifests = SHARD_MANIFESTS if shard_manifests is None else shard_manifests
for rel in required:
path = root / rel
if not path.is_file():
problems.append(f"missing: {rel}")
elif path.stat().st_size == 0:
problems.append(f"empty (0 bytes): {rel}")
for manifest_rel in shard_manifests:
manifest_path = root / manifest_rel
if not manifest_path.is_file():
continue # already reported by the REQUIRED_FILES pass
try:
manifest_doc = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
problems.append(f"unreadable manifest: {manifest_rel} ({exc})")
continue
shards = sorted(set((manifest_doc.get("weight_map") or {}).values()))
if not shards:
problems.append(f"manifest lists no shards: {manifest_rel}")
folder = manifest_rel.split("/")[0]
for shard in shards:
shard_path = manifest_path.parent / shard
if not shard_path.is_file():
problems.append(f"missing shard: {folder}/{shard}")
elif shard_path.stat().st_size == 0:
problems.append(f"empty shard (0 bytes): {folder}/{shard}")
# Authoritative size check against the Hub's published file list.
for rel, expected in sorted((manifest or {}).get("files", {}).items()):
path = root / rel
if not path.is_file():
problems.append(f"missing: {rel}")
continue
actual = path.stat().st_size
if actual != expected:
problems.append(
f"wrong size: {rel} ({actual} bytes on disk, "
f"{expected} expected)"
)
return problems
# ---------------------------------------------------------------------------
# Resumable parallel-range transfer
# ---------------------------------------------------------------------------
#
# State on disk, per file being fetched:
#
# <name>.part pre-allocated to the published size; ranges are written
# into it at their true offsets
# <name>.parts.json {"repo", "revision", "size", "done": [[start, end], ..]}
# the ranges that already landed, merged
#
# A range is only recorded as done after its bytes are written, so the ledger
# can under-report progress after a crash but never over-report it: re-fetching
# a range is harmless, skipping one that was never written would not be.
def _file_url(repo: str, revision: str, rel: str) -> str:
from urllib.parse import quote
return f"https://huggingface.co/{repo}/resolve/{revision}/{quote(rel)}"
def _merge_ranges(ranges: list, size: int) -> list:
"""Merge, sort and clamp `[start, end)` ranges."""
out: list[list[int]] = []
for start, end in sorted((int(a), int(b)) for a, b in ranges if b > a):
start = max(0, min(start, size))
end = max(0, min(end, size))
if end <= start:
continue
if out and start <= out[-1][1]:
out[-1][1] = max(out[-1][1], end)
else:
out.append([start, end])
return out
def _gaps(done: list, size: int) -> list:
"""Byte ranges of `size` not covered by `done`."""
gaps: list[list[int]] = []
cursor = 0
for start, end in done:
if start > cursor:
gaps.append([cursor, start])
cursor = max(cursor, end)
if cursor < size:
gaps.append([cursor, size])
return gaps
def _split(gap: list, parts: int) -> list:
"""Split one gap into at most `parts` equal ranges (used by tests)."""
start, end = gap
span = end - start
if parts <= 1 or span <= 1:
return [[start, end]]
parts = min(parts, span)
step = span // parts
out: list[list[int]] = []
cursor = start
for _ in range(parts):
stop = min(cursor + step, end)
out.append([cursor, stop])
cursor = stop
if cursor < end:
out.append([cursor, end])
return out
def _covered(done: list) -> int:
return sum(int(b) - int(a) for a, b in done)
def _load_ledger(path: Path, repo: str, revision: str, size: int) -> list:
"""Ranges already fetched for this file, or [] to start over.
A ledger written for a different repo, revision or expected size describes
a different file, so it is discarded rather than trusted.
"""
try:
state = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
if (
state.get("repo") != repo
or state.get("revision") != revision
or int(state.get("size", -1)) != size
):
return []
try:
return _merge_ranges([tuple(r) for r in state.get("done", [])], size)
except (TypeError, ValueError):
return []
def _save_ledger(path: Path, repo: str, revision: str, size: int, done: list) -> None:
"""Write the ledger atomically so a crash cannot leave it corrupt."""
payload = {
"repo": repo,
"revision": revision,
"size": size,
"done": [[int(a), int(b)] for a, b in done],
}
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(json.dumps(payload), encoding="utf-8")
os.replace(tmp, path)
def _expected_sha256(session, url: str) -> str | None:
"""DEPRECATED fallback only - do not use this to verify a download.
The Hub's CDN redirect does not end on a node whose `ETag` is the content
hash: for these blobs the header carries an unrelated digest, so a byte-
perfect download is reported as corrupt and DISCARDED. `fetch_manifest`
captures the authoritative `lfs.oid` at the pinned revision instead, and
that is what the integrity gate compares against.
Kept only so the video script's own callers keep importing successfully.
"""
try:
resp = session.head(url, allow_redirects=True, timeout=RANGE_TIMEOUT)
etag = (resp.headers.get("ETag") or "").strip().strip('"')
resp.close()
except Exception:
return None
if len(etag) == 64 and all(c in "0123456789abcdef" for c in etag):
return etag
return None
def _sha256_file(path: Path, cancel: threading.Event) -> str | None:
"""sha256 of a file, or None if the run was cancelled mid-hash."""
digest = hashlib.sha256()
with path.open("rb") as handle:
while True:
if cancel.is_set():
return None
block = handle.read(8 * 1024 * 1024)
if not block:
break
digest.update(block)
return digest.hexdigest()
def _fetch_range(session, url, handle, start, end, total, cancel) -> int:
"""Stream `[start, end)` into `handle` at its true offset.
Returns the number of bytes written. A short read is NOT an error: the
socket simply died mid-stream, and the caller resumes this chunk from
where it stopped instead of restarting the whole multi-GB shard.
"""
handle.seek(start)
written = 0
resp = session.get(
url,
headers={"Range": f"bytes={start}-{end - 1}"},
stream=True,
timeout=(RANGE_TIMEOUT, RANGE_TIMEOUT),
)
try:
if resp.status_code == 200 and (start != 0 or end != total):
# Server ignored the Range header and streamed the whole blob.
raise RuntimeError("server ignored the Range header")
if resp.status_code not in (200, 206):
raise RuntimeError(f"HTTP {resp.status_code}")
for block in resp.iter_content(1024 * 1024):
if cancel.is_set():
raise RuntimeError("cancelled")
if not block:
continue
handle.write(block)
written += len(block)
_PROGRESS[0] += len(block)
finally:
resp.close()
return written
def _chunk_plan(done: list, size: int) -> list:
"""Missing bytes as fixed-size chunks, in order.
Fixed-size chunks (rather than "split the file into N ranges") are what make
the ledger meaningful: progress is recorded per chunk, so an interrupted
run resumes from the last completed chunk boundary instead of redoing the
whole file.
"""
plan: list[list[int]] = []
for gap_start, gap_end in _gaps(done, size):
cursor = gap_start
while cursor < gap_end:
stop = min(cursor + RANGE_CHUNK_BYTES, gap_end)
plan.append([cursor, stop])
cursor = stop
return plan
def _download_file(repo, revision, rel, dest: Path, size: int, force=False, expect_sha256: str | None = None) -> str:
"""Fetch one file, resuming whatever a previous run left behind.
Returns "cached", "downloaded", "repaired" or "cancelled". Raises
RuntimeError when the finished bytes do not match the published
size/sha256, so a corrupt file is never moved into place and later loaded
as if it were real.
`expect_sha256` is the Hub's content-addressed hash for this exact
revision (from the manifest), NOT the CDN `ETag`: the two disagree on
these blobs, and trusting the `ETag` rejects - and deletes - transfers
that are in fact byte-perfect.
"""
import requests
if dest.is_file() and dest.stat().st_size == size and not force:
return "cached"
part = dest.with_name(dest.name + ".part")
ledger = dest.with_name(dest.name + ".parts.json")
url = _file_url(repo, revision, rel)
dest.parent.mkdir(parents=True, exist_ok=True)
# A final file of the WRONG size is a truncated transfer, not part of the
# snapshot. Move it aside rather than deleting it, so a failed repair never
# leaves the user with less than they started with.
had_dest = dest.is_file()
if had_dest and dest.stat().st_size != size:
dest.replace(dest.with_name(dest.name + ".bad"))
done = _load_ledger(ledger, repo, revision, size)
if not part.exists() or part.stat().st_size != size:
# A ledger without a correctly sized scratch file cannot be trusted.
done = []
with part.open("wb") as handle:
handle.truncate(size)
print(
f" {rel}: {size / 1e9:.2f} GB "
f"({_covered(done) / 1e9:.2f} GB already fetched)",
flush=True,
)
cancel = threading.Event()
lock = threading.Lock()
before = _PROGRESS[0]
session = requests.Session()
session.headers["User-Agent"] = "devatlas-fetch-i2v/1.0"
plan = _chunk_plan(done, size)
if plan:
def _one(chunk):
start, end = chunk
for attempt in range(1, RANGE_ATTEMPTS + 1):
if cancel.is_set():
return
try:
with part.open("r+b") as handle:
wrote = _fetch_range(
session, url, handle, start, end, size, cancel
)
if wrote == end - start:
with lock:
done[:] = _merge_ranges(done + [[start, end]], size)
_save_ledger(ledger, repo, revision, size, list(done))
return
start += wrote # resume this chunk, never restart the file
except Exception as exc:
if attempt >= RANGE_ATTEMPTS:
cancel.set()
print(
f" chunk {start}-{end} of {rel} failed after "
f"{RANGE_ATTEMPTS} attempts: {exc}",
flush=True,
)
return
time.sleep(min(2 ** (attempt // 4), 15))
# A bounded pool keeps every socket busy without spawning one thread
# per 32 MB chunk (a 4.8 GB shard would otherwise need ~150 threads).
with ThreadPoolExecutor(max_workers=RANGES_IN_FLIGHT) as pool:
for fut in [pool.submit(_one, c) for c in plan]:
fut.result()
gained = _PROGRESS[0] - before
if gained:
print(f" +{gained / 1e9:.2f} GB this run", flush=True)
if cancel.is_set() or _covered(done) != size:
return "cancelled"
# Integrity gate. A correct size alone cannot catch a corrupted file, so
# verify against the sha256 the Hub published for this exact revision.
#
# The expected value comes from the manifest (`fetch_manifest`), NOT from a
# fresh `ETag` lookup: the CDN edge node that actually serves these bytes
# reports an unrelated digest in `ETag`, so the old check rejected and
# DELETED every correct download. That is why this snapshot could never be
# completed, which in turn is why image generation silently fell back to
# re-showing the companion's avatar instead of generating a new picture.
want = expect_sha256
if want:
got = _sha256_file(part, cancel)
if got is None:
return "cancelled"
if got != want:
# Discard the bad scratch file so the next run starts this file
# clean rather than trusting the corrupt ranges forever.
for victim in (part, ledger):
try:
victim.unlink()
except OSError:
pass
raise RuntimeError(
f"{rel}: sha256 mismatch (expected {want}, got {got}); "
"corrupt data discarded - re-run to retry this file"
)
os.replace(part, dest)
try:
ledger.unlink()
except OSError:
pass
return "repaired" if had_dest else "downloaded"
def _clean_partial_downloads(root: Path, keep_parts: bool = False) -> int:
"""Delete partial download leftovers, returning bytes reclaimed.
Older runs used `huggingface_hub`, which leaves partial chunks behind as
`.<repo filename>.<chunk index>` (e.g.
`.p_text_encoder_model-00002-of-00004.safetensors.0`) and resumed transfers
as `<filename>.incomplete`. Those are not part of the snapshot and can be
tens of GB, so a folder that "looks complete" on disk can still be missing
real shards.
`keep_parts=True` preserves this script's OWN `<name>.part` /
`<name>.parts.json` resume state, which is the whole point of the range
ledger: deleting it would throw away gigabytes of already-fetched bytes and
make a long flaky transfer impossible to finish.
"""
reclaimed = 0
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
name = path.name
is_chunk = name.startswith(".") and ".safetensors." in name
is_incomplete = name.endswith(".incomplete")
is_xet_chunk = name.endswith(".incomplete") and "=" in name
if keep_parts and (name.endswith(".part") or name.endswith(".parts.json")):
continue
if not (is_chunk or is_incomplete or is_xet_chunk):
continue
try:
reclaimed += path.stat().st_size
path.unlink()
except OSError:
continue
return reclaimed
def _free_bytes(path: Path) -> int:
"""Free space on the volume holding `path`, 0 when it cannot be read."""
try:
usage = shutil.disk_usage(str(path))
except OSError:
return 0
return int(usage.free)
def _check_disk_space(target: Path, manifest: dict) -> list:
"""Report files that will not fit, before starting a multi-GB transfer.
A download that dies at 99% because the volume filled up is exactly the
kind of failure that leaves a permanently incomplete snapshot, so this is
checked up front rather than discovered at the last shard.
"""
needed = 0
for rel, size in manifest["files"].items():
dest = target / rel
part = dest.with_name(dest.name + ".part")
for candidate in (dest, part):
if candidate.is_file():
needed -= candidate.stat().st_size
needed += size
free = _free_bytes(target)
if needed > free:
return [
f"not enough free disk space: need {needed / 1e9:.1f} GB, "
f"have {free / 1e9:.1f} GB on the volume holding {target}"
]
return []
def download_snapshot(manifest: dict, target: Path, force=False) -> list:
"""Fetch every file in `manifest` into `target`. Returns error strings.
Files already present at the published size are skipped, so this repairs
an incomplete snapshot without re-downloading the ~20 GB that is already
correct. Larger files go first: they dominate the wall clock, so starting
them early means a cancelled run still banks the most progress.
"""
repo, revision = manifest["repo"], manifest["revision"]
files = manifest["files"]
# Authoritative per-file sha256 captured by `fetch_manifest` at this exact
# revision. Passed to every download so the integrity gate never has to
# guess a hash from a CDN response header.
oids = manifest.get("oids") or {}
errors: list = []
order = sorted(files.items(), key=lambda kv: kv[1], reverse=True)
for index, (rel, size) in enumerate(order, start=1):
dest = target / rel
label = rel if len(rel) < 58 else "..." + rel[-55:]
print(f"[{index}/{len(order)}] {label}", flush=True)
try:
status = _download_file(
repo, revision, rel, dest, size,
force=force, expect_sha256=oids.get(rel),
)
except Exception as exc:
errors.append(f"{rel}: {exc}")
print(f" FAILED: {exc}", flush=True)
continue
if status == "cancelled":
errors.append(f"{rel}: transfer did not complete")
elif status != "cached":
print(f" {status}", flush=True)
return errors
def _worker(repo: str, target: str, stall_seconds: int) -> int:
"""Run one download attempt in a CHILD process, with a stall watchdog.
Invoked as `python scripts/fetch_i2v_model.py --_worker <repo> <target>
<stall-seconds>`. Each attempt runs out-of-process so the parent can kill a
wedged transfer outright: a dead socket raises nothing, it just stops making
progress while holding its connection open, so the only reliable detector is
watching the target directory stop growing from a separate THREAD.
"""
root = Path(target)
root.mkdir(parents=True, exist_ok=True)
stall = {"last": time.time(), "bytes": -1}
def watch() -> None:
# Progress is measured from the transfer's own byte counter, NOT from
# the size of the files in `root`. The `.part` scratch file is
# pre-allocated to the full published size, so its length is constant
# from the first second - a directory-size watchdog would kill a
# perfectly healthy download and restart it forever.
while True:
time.sleep(10)
done = _PROGRESS[0]
if done != stall["bytes"]:
stall["bytes"] = done
stall["last"] = time.time()
print(f" {done / 1e9:.2f} GB transferred", flush=True)
elif time.time() - stall["last"] > stall_seconds:
print(
f" no transfer progress for {stall_seconds}s - wedged",
flush=True,
)
os._exit(3)
threading.Thread(target=watch, daemon=True).start()
try:
manifest = fetch_manifest(repo)
except Exception as exc:
print(f" could not read the Hub manifest: {exc}", flush=True)
return 2
# A previous huggingface_hub run may have left unusable partials. This
# script's own `.part` / `.parts.json` resume state is preserved.
reclaimed = _clean_partial_downloads(root, keep_parts=True)
if reclaimed:
print(f" reclaimed {reclaimed / 1e9:.2f} GB of stale partials", flush=True)
errors = download_snapshot(manifest, root)
if errors:
print(f" {len(errors)} file(s) did not complete", flush=True)
return 2
return 0
def main() -> int:
if len(sys.argv) > 1 and sys.argv[1] == "--_worker":
# The child indexes four values out of argv, so a truncated invocation
# must fail with a readable message here. Bare indexing raises
# IndexError, and the parent - which can only read a child exit code -
# counts that as "attempt failed" and burns every attempt without a
# single byte being downloaded.
if len(sys.argv) != 5:
print(
"usage: fetch_i2v_model.py --_worker <repo> <target> <stall-seconds>",
file=sys.stderr,
)
return 64
return _worker(sys.argv[2], sys.argv[3], int(sys.argv[4]))
verify_only = "--verify" in sys.argv
force = "--force" in sys.argv
repo = resolve_repo()
target = resolve_target()
target.mkdir(parents=True, exist_ok=True)
print(f"Fetching {repo} -> {target}")
try:
manifest = fetch_manifest(repo)
except Exception as exc:
print(f"\nCould not reach the Hugging Face Hub: {exc}")
print("Check the network/proxy, then re-run.")
return 1
print(
f"Revision {manifest['revision']} - {len(manifest['files'])} files, "
f"{sum(manifest['files'].values()) / 1e9:.1f} GB total."
)
print("Re-running is safe: complete files are skipped, partial ones resume.\n")
space_problems = _check_disk_space(target, manifest)
if space_problems:
for problem in space_problems:
print(f" - {problem}")
return 1
if verify_only:
problems = verify_snapshot(target, manifest)
if problems:
print("Snapshot is INCOMPLETE:")
for problem in problems:
print(f" - {problem}")
return 1
print(f"Snapshot is complete and verified at: {target}")
return 0
# Each attempt is a child process so a wedged transfer can be killed
# outright. Retried because a ~27 GB transfer on a slow link is the single
# most likely thing here to drop; every attempt resumes from the byte ranges
# already on disk, so no progress is ever lost.
attempts = 6
stall_seconds = 300
script = os.path.abspath(__file__)
failed = False
for attempt in range(1, attempts + 1):
print(f"\nDownload attempt {attempt}/{attempts}...", flush=True)
proc = subprocess.Popen(
[sys.executable, script, "--_worker", repo, str(target),
str(stall_seconds)],
cwd=str(target.parents[1]) if len(target.parents) > 1 else None,
)
code = proc.wait()
if code == 0:
break
if code == 3:
print("\nAttempt: transfer stalled (no disk progress).", flush=True)
else:
print(f"\nAttempt failed (exit {code}).", flush=True)
if attempt == attempts:
print(f"\nDownload failed after {attempts} attempts.")
print("Re-run this script to resume. Nothing already fetched is lost.")
failed = True
break
print("Retrying in 5s...", flush=True)
time.sleep(5)
if failed:
return 1
# A completed transfer is not the same as a loadable snapshot. Verify
# against the published file list and the shard manifests, and fail loudly
# rather than leaving a half-usable snapshot behind.
problems = verify_snapshot(target, manifest)
if problems:
print("\nSnapshot is STILL INCOMPLETE:")
for problem in problems:
print(f" - {problem}")
print("\nRe-run this script to finish the download.")
return 1
print(f"\nDone. Model snapshot is complete and verified at: {target}")
print("Set VIDEO_PROVIDER=local_diffusion in python-ai/.env to use it.")
return 0
if __name__ == "__main__":
raise SystemExit(main())