File size: 9,352 Bytes
b2f2f57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6b4a4b8
 
 
 
 
 
b2f2f57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2115e2f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b2f2f57
 
 
 
 
 
 
 
 
2115e2f
 
 
 
b2f2f57
2115e2f
 
b2f2f57
 
 
 
 
 
 
 
 
 
 
2115e2f
b2f2f57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2115e2f
b2f2f57
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""Build, check, and optionally publish the browser demo as a static HF Space."""

from __future__ import annotations

import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile


REPO = Path(__file__).resolve().parents[1]
GRAPH_NAMES = ("encoder", "cross_cache", "decoder_step")
# ORT chooses JSEP or Asyncify from browser support. Keep both module/binary
# pairs; Vite's hashed binary asset alone does not cover wasmPaths=/ort/.
ORT_FILES = tuple(
    f"ort-wasm-simd-threaded.{variant}.{suffix}"
    for variant in ("jsep", "asyncify") for suffix in ("mjs", "wasm")
)


def sha256(path: Path) -> str:
    with path.open("rb") as stream:
        return hashlib.file_digest(stream, "sha256").hexdigest()


def copy_file(source: Path, target: Path) -> None:
    target.parent.mkdir(parents=True, exist_ok=True)
    shutil.copyfile(source, target)


def package_space(web_dir: Path, graph_dir: Path, ort_dir: Path, output: Path) -> dict:
    """Copy a small allowlist; never upload the working checkout or audio cache."""
    manifest = json.loads((graph_dir / "manifest.json").read_text())
    if manifest["precision"] != "fp32":
        raise ValueError("The public Space requires the checked FP32 export")
    for name in GRAPH_NAMES:
        graph = graph_dir / f"{name}.onnx"
        record = manifest["graphs"][name]
        if graph.stat().st_size != record["bytes"] or sha256(graph) != record["sha256"]:
            raise ValueError(f"Graph does not match the export manifest: {graph}")

    if output.exists() and any(output.iterdir()):
        raise ValueError(f"Output must be empty: {output}")
    output.mkdir(parents=True, exist_ok=True)
    copy_file(web_dir / "local.html", output / "index.html")
    copy_file(web_dir / "local.html", output / "local.html")
    copy_file(web_dir / "favicon.png", output / "favicon.png")

    # The Vite manifest follows the local entry's imports and runtime assets.
    # This excludes compare/server pages and unreferenced public audio.
    build_manifest = json.loads((web_dir / ".vite/manifest.json").read_text())
    visited: set[str] = set()

    def copy_entry(key: str) -> None:
        if key in visited:
            return
        visited.add(key)
        entry = build_manifest[key]
        for field in ("imports", "dynamicImports"):
            for dependency in entry.get(field, []):
                copy_entry(dependency)
        files = [entry["file"], *entry.get("css", []), *entry.get("assets", [])]
        for relative in files:
            source = (web_dir / relative).resolve()
            if not source.is_relative_to(web_dir.resolve()):
                raise ValueError(f"Invalid Vite asset path: {relative}")
            copy_file(source, output / relative)

    copy_entry("local.html")
    manifest["checkpoint"]["path"] = Path(manifest["checkpoint"]["path"]).name
    (output / "models/fp32").mkdir(parents=True)
    (output / "models/fp32/manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
    for name in GRAPH_NAMES:
        copy_file(graph_dir / f"{name}.onnx", output / f"models/fp32/{name}.onnx")
    for filename in ORT_FILES:
        copy_file(ort_dir / filename, output / "ort" / filename)

    # Only publish the licensed catalog's exact audio/notes, with graph and
    # file hashes checked. Never copy the private local examples directory.
    catalog = json.loads((web_dir / "examples/catalog.json").read_text())
    public_files = ["examples/catalog.json", "examples/CREDITS.md", "soundfonts/GeneralUser_GS.sf2", "soundfonts/LICENSE.txt"]
    for example in catalog["examples"]:
        for field, digest_field in (("audio_url", "audio_sha256"), ("notes_url", "notes_sha256")):
            relative = example[field].lstrip("/")
            source = (web_dir / relative).resolve()
            if not source.is_relative_to((web_dir / "examples").resolve()):
                raise ValueError(f"Invalid example asset: {relative}")
            if sha256(source) != example[digest_field]:
                raise ValueError(f"Example asset hash mismatch: {relative}")
            public_files.append(relative)
        prediction = json.loads((web_dir / example["notes_url"].lstrip("/")).read_text())
        if prediction["checkpoint_sha256"] != manifest["checkpoint"]["sha256"]:
            raise ValueError(f"Example prediction uses another model: {example['id']}")
    for relative in public_files:
        copy_file(web_dir / relative, output / relative)

    copy_file(REPO / "deploy/huggingface/README.md", output / "README.md")
    # Keep the exact app source next to the build for review and reproduction.
    shutil.copytree(REPO / "demo/web/src", output / "source/demo/web/src")
    for filename in ("package.json", "pnpm-lock.yaml", "vite.config.ts", "tsconfig.json", "local.html", "index.html", "compare.html"):
        copy_file(REPO / "demo/web" / filename, output / "source/demo/web" / filename)
    copy_file(REPO / "demo/web/public/favicon.png", output / "source/demo/web/public/favicon.png")
    copy_file(Path(__file__), output / "source/scripts/deploy_huggingface_space.py")
    copy_file(REPO / "scripts/check_huggingface_space.mjs", output / "source/scripts/check_huggingface_space.mjs")
    copy_file(REPO / "scripts/prepare_public_demo.py", output / "source/scripts/prepare_public_demo.py")
    copy_file(REPO / "scripts/decode_demo_tokens.mjs", output / "source/scripts/decode_demo_tokens.mjs")
    copy_file(REPO / "configs/demo/public_examples.json", output / "source/configs/demo/public_examples.json")
    copy_file(REPO / "deploy/huggingface/GeneralUser-LICENSE.txt", output / "source/deploy/huggingface/GeneralUser-LICENSE.txt")
    copy_file(REPO / "deploy/huggingface/README.md", output / "source/deploy/huggingface/README.md")
    for relative in public_files:
        copy_file(web_dir / relative, output / "source/demo/web/public" / relative)

    def git(*args: str) -> str:
        return subprocess.check_output(["git", *args], cwd=REPO, text=True).strip()

    receipt = {
        "schema_version": 1,
        "source_commit": git("rev-parse", "HEAD"),
        "source_branch": git("branch", "--show-current"),
        "source_dirty": bool(git("status", "--porcelain")),
        "build_command": "VITE_HENDRIX_SPACE=1 npx --yes pnpm@10.20.0 --dir demo/web build --manifest",
        "checkpoint_sha256": manifest["checkpoint"]["sha256"],
        "example_catalog": {"count": len(catalog["examples"]), "sha256": sha256(web_dir / "examples/catalog.json")},
        "files": {
            path.relative_to(output).as_posix(): {"bytes": path.stat().st_size, "sha256": sha256(path)}
            for path in sorted(output.rglob("*")) if path.is_file()
        },
    }
    (output / "deployment.json").write_text(json.dumps(receipt, indent=2) + "\n")
    return receipt


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--repo-id", default="jackboyla/hendrix")
    parser.add_argument("--graph-dir", type=Path, default=REPO / "progress/evaluations/yptf_web_fp32_d26")
    parser.add_argument("--output-dir", type=Path, help="An empty staging directory; default: a new directory under progress/evaluations")
    parser.add_argument("--upload", action="store_true", help="Create/update a public static Space with the current HF login")
    args = parser.parse_args()

    api = None
    if args.upload:
        from huggingface_hub import HfApi, get_token

        if not get_token():
            raise SystemExit("Log in first with: hf auth login")
        api = HfApi()
        api.whoami()  # Fail before building if credentials are invalid.

    subprocess.run(
        ["npx", "--yes", "pnpm@10.20.0", "--dir", "demo/web", "build", "--manifest"],
        cwd=REPO, env={**os.environ, "VITE_HENDRIX_SPACE": "1"}, check=True,
    )
    evaluations = REPO / "progress/evaluations"
    evaluations.mkdir(parents=True, exist_ok=True)
    output = args.output_dir.resolve() if args.output_dir else Path(tempfile.mkdtemp(prefix="hf_space_", dir=evaluations))
    receipt = package_space(
        REPO / "demo/web/dist", args.graph_dir.resolve(),
        REPO / "demo/web/node_modules/onnxruntime-web/dist", output,
    )
    total = sum(record["bytes"] for record in receipt["files"].values())
    print(f"Checked package: {output} ({total / 1_000_000:.1f} MB)", flush=True)

    if api is not None:
        from huggingface_hub.errors import RepositoryNotFoundError

        try:
            existing = api.space_info(args.repo_id)
        except RepositoryNotFoundError:
            api.create_repo(args.repo_id, repo_type="space", space_sdk="static", private=False)
        else:
            if existing.private or existing.sdk != "static":
                raise SystemExit("Existing Space must already be public and static; no settings were changed")
        # No deletion patterns: preserve unrelated files in an existing Space.
        commit = api.upload_folder(
            repo_id=args.repo_id, repo_type="space", folder_path=output,
            commit_message="Update Hendrix music transcription demo",
        )
        print(f"Space: https://huggingface.co/spaces/{args.repo_id}")
        print(f"Commit: {commit.commit_url}")


if __name__ == "__main__":
    main()