Spaces:
Running on Zero
Running on Zero
File size: 35,049 Bytes
6ef0a95 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 | """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())
|