File size: 14,614 Bytes
bc50ea0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Read-only catalogue of saved processing takes, separate from generation lineage.

Listing reads JSON and file sizes, never model tensors or multi-GB checksums.
Execution remains responsible for artifact integrity and latent validation.
"""

import json
from pathlib import Path
import re


STAGES = ("derope", "latent_upscale", "pixel_upscale", "other")


def processing_lineage(segments):
    """Small immutable addresses, not copies of tensors or source manifests."""
    return [{"scene": int(item["index"]), "revision": item["revision"],
             "metadata_path": item["revision_metadata"],
             "checkpoint_sha256": item["checkpoint_sha256"]}
            for item in segments]


def validate_processing_lineage(value):
    if not isinstance(value, list) or not value:
        raise ValueError("Saved processing branch has no lineage.")
    for item in value:
        if (not isinstance(item, dict) or type(item.get("scene")) is not int or
                item["scene"] < 1 or
                not re.fullmatch(r"[0-9a-f]{32}", str(item.get("revision") or "")) or
                not re.fullmatch(r"[0-9a-f]{64}", str(item.get("checkpoint_sha256") or "")) or
                not isinstance(item.get("metadata_path"), str) or not item["metadata_path"]):
            raise ValueError("Saved processing branch has an invalid revision address.")
    first = value[0]["scene"]
    if [item["scene"] for item in value] != list(range(first, first + len(value))):
        raise ValueError("Saved processing branch is not contiguous.")
    return value


def processing_stage(config):
    config = config if isinstance(config, dict) else {}
    recipe = config.get("recipe") or {}
    recipe = recipe if isinstance(recipe, dict) else {}
    explicit = recipe.get("stage")
    if explicit in STAGES:
        return explicit
    # The released combined LBH/DeRoPE example records this recipe field.
    # Never infer a processing stage from a user-chosen folder/profile name.
    derope = recipe.get("derope")
    if derope is True or (isinstance(derope, str) and derope.strip().lower()
                          not in ("", "false", "off", "none", "disabled")):
        return "derope"
    return {"h3_latent": "latent_upscale", "pixel": "pixel_upscale"}.get(
        config.get("backend"), "other")


def saved_checkpoint_variants(output_root, run_name, originals):
    root = Path(output_root).resolve()
    run = (root / "h3_chains" / run_name).resolve()
    if run.parent != root / "h3_chains":
        raise ValueError("Invalid checkpoint variant run directory.")
    records, warnings, seen, branches = [], [], set(), []
    legacy_profiles = set()

    def inside(path, parent):
        resolved = path.resolve()
        if not resolved.is_relative_to(parent):
            raise ValueError("Saved processing artifact escapes its profile directory.")
        return resolved

    def read(path):
        with path.open(encoding="utf-8") as handle:
            value = json.load(handle)
        if not isinstance(value, dict):
            raise ValueError("Saved processing metadata is not an object.")
        return value

    def media(path):
        rel = path.relative_to(root)
        return {"filename": rel.name, "subfolder": str(rel.parent), "type": "output"}

    def legacy_branches(profile):
        # Only legacy DeRoPE takes need these larger, embedded-source files.
        # New saves use compact immutable lineage snapshots in their metadata.
        if profile in legacy_profiles:
            return
        legacy_profiles.add(profile)
        manifests = [profile / "upscale_manifest.json"]
        partial = inside(profile / "partial", profile)
        if partial.is_dir():
            manifests.extend(partial.glob("through_clip_*.manifest.json"))
        for path in manifests:
            if not path.is_file():
                continue
            try:
                path = inside(path, profile)
                saved = read(path)
                if (saved.get("format") not in (
                        "h3_chain_upscale_manifest_v1", "h3_chain_upscale_partial_manifest_v1") or
                        saved.get("run_name") != run_name or saved.get("profile") != profile.name):
                    raise ValueError("Invalid processing branch manifest.")
                branches.append({"path": str(path.relative_to(root)), "kind": "manifest",
                                 "lineage": validate_processing_lineage(processing_lineage(saved["segments"]))})
            except (OSError, ValueError, TypeError, KeyError) as exc:
                warnings.append("%s: %s" % (path.name, exc))

    parents = [run / "upscaled"]
    chapters = run / "chapters"
    if chapters.is_dir():
        parents.extend(path / "upscaled" for path in chapters.iterdir() if path.is_dir())
    for parent in parents:
        try:
            inside(parent, run)
            if not parent.is_dir():
                continue
            profiles = sorted(parent.iterdir())
        except (OSError, ValueError) as exc:
            warnings.append(str(exc))
            continue
        for profile in profiles:
            try:
                profile = inside(profile, run)
                folder = inside(profile / "checkpoints", profile)
                if not folder.is_dir():
                    continue
                files = sorted(folder.iterdir())
            except (OSError, ValueError) as exc:
                warnings.append(str(exc))
                continue
            for path in files:
                match = re.fullmatch(r"clip_(\d{4})(?:\.([0-9a-f]{32}))?\.json", path.name)
                if not match:
                    continue
                try:
                    path = inside(path, profile)
                    metadata = read(path)
                    if metadata.get("format") != "h3_chain_upscale_segment_v1":
                        continue
                    if metadata.get("run_name") != run_name or metadata.get("profile") != profile.name:
                        raise ValueError("Saved processing metadata belongs to another run/profile.")
                    segment = metadata["segment"]
                    scene = int(segment["index"])
                    revision = str(segment["revision"])
                    if (scene != int(match[1]) or not re.fullmatch(r"[0-9a-f]{32}", revision)
                            or (match[2] and match[2] != revision)):
                        raise ValueError("Saved processing scene/revision does not match its file.")
                    canonical = inside(root / segment["revision_metadata"], profile)
                    if canonical != folder / ("clip_%04d.%s.json" % (scene, revision)):
                        raise ValueError("Saved processing revision address is inconsistent.")
                    # Ignore mutable pointer copies; their immutable revision
                    # file is the authority and is scanned separately.
                    if path != canonical:
                        continue
                    identity = str(path.relative_to(root))
                    if identity in seen:
                        continue
                    seen.add(identity)
                    missing, size, outputs = [], 0, {}
                    for field in ("segment", "checkpoint", "generated_audio"):
                        address = segment.get(field)
                        if not address and field == "generated_audio":
                            continue
                        if not isinstance(address, str) or not address:
                            missing.append(field)
                            continue
                        artifact = inside(root / address, profile)
                        if not artifact.is_file():
                            missing.append(field)
                            continue
                        size += artifact.stat().st_size
                        if field != "checkpoint":
                            outputs["video" if field == "segment" else "audio"] = media(artifact)
                    config = metadata.get("profile_config") or {}
                    stage = metadata.get("processing_stage")
                    if stage not in STAGES:
                        stage = processing_stage(config)
                    record = {
                        "key": identity, "metadata_path": identity,
                        "scene": scene, "scene_id": str(segment.get("id") or ""),
                        "revision": revision, "stage": stage, "profile": profile.name,
                        "profile_path": str(profile.relative_to(root)),
                        "source_manifest_hash": str(metadata.get("source_manifest_hash") or ""),
                        "source_revision": str(segment.get("source_revision") or ""),
                        "source_checkpoint_sha256": str(segment.get("source_checkpoint_sha256") or ""),
                        "checkpoint_sha256": str(segment.get("checkpoint_sha256") or ""),
                        "created_at": str(segment.get("created_at") or ""),
                        "width": segment.get("width"), "height": segment.get("height"),
                        "raw_frames": segment.get("raw_frames"),
                        "delivered_frames": segment.get("delivered_frames"),
                        "latent_saved": segment.get("latent_saved") is True,
                        "latent_layout": str(segment.get("latent_layout") or "omitted"),
                        "context_steps": segment.get("context_steps", 0),
                        "audio_route": str(segment.get("audio_route") or "unknown"),
                        "prompt": str(segment.get("prompt") or ""),
                        "ready": not missing, "missing_files": missing, "size_bytes": size,
                        **outputs,
                    }
                    records.append(record)
                    if metadata.get("processing_lineage"):
                        branches.append({"path": identity, "kind": "metadata",
                                         "lineage": validate_processing_lineage(metadata["processing_lineage"])})
                    elif record["stage"] == "derope":
                        legacy_branches(profile)
                except (OSError, ValueError, TypeError, KeyError) as exc:
                    warnings.append("%s: %s" % (path.name, exc))

    # Match the exact source revision AND saved checkpoint identity. A rerun
    # with the same scene number is not the same take. Follow derived sources
    # transitively so a future DeRoPE -> upscale take stays beside its original.
    sources = {}
    for original in originals:
        if original.get("take_kind") == "editorial_alternate":
            continue
        key = (int(original["scene"]), str(original["revision"]),
               str(original.get("checkpoint_sha256") or ""))
        if key[2]:
            sources.setdefault(key, []).append(original)
        alias = str(original.get("adopted_from_revision") or "")
        if alias and key[2]:
            sources.setdefault((key[0], alias, key[2]), []).append(original)
    # ALT is not a generation branch, but its processed pictures still belong
    # beside the immutable base scene. Match the ALT's own checkpoint hash;
    # never attach an arbitrary same-scene processed take to the active base.
    bases = {(int(item["scene"]), str(item["revision"])): item
             for item in originals if item.get("take_kind") != "editorial_alternate"}
    for alternate in originals:
        if alternate.get("take_kind") != "editorial_alternate":
            continue
        base = bases.get((int(alternate["scene"]), str(alternate.get("alternate_of_revision") or "")))
        digest = str(alternate.get("checkpoint_sha256") or "")
        if base is not None and digest:
            sources.setdefault((int(alternate["scene"]), str(alternate["revision"]), digest), []).append(base)
    derived = {}
    for record in records:
        key = (record["scene"], record["revision"], record["checkpoint_sha256"])
        if key[2]:
            derived.setdefault(key, []).append(record)

    def origins(record, visited):
        key = (record["scene"], record["source_revision"], record["source_checkpoint_sha256"])
        if not key[1] or not key[2] or key in visited:
            return []
        if key in sources:
            return sources[key]
        return [origin for parent in derived.get(key, [])
                for origin in origins(parent, visited | {key})]

    for record in records:
        matches = {(int(item["scene"]), str(item["revision"]))
                   for item in origins(record, set())}
        record["originals"] = [{"scene": scene, "revision": revision}
                               for scene, revision in sorted(matches)]
        record["source_status"] = "linked" if matches else "original unavailable or source mismatch"
    records.sort(key=lambda item: (item["scene"], item["created_at"], item["key"]), reverse=True)
    # Independent pixel cleanup can leave a hole in an immutable historical
    # lineage. Keep every surviving take visible, but don't offer that history
    # as an executable complete branch or silently substitute a newer take.
    available = {record["key"]: (record["scene"], record["revision"], record["checkpoint_sha256"])
                 for record in records if record["ready"]}
    branches = [branch for branch in branches if all(
        available.get(item["metadata_path"]) == (item["scene"], item["revision"], item["checkpoint_sha256"])
        for item in branch["lineage"])]
    for record in records:
        matches = [branch for branch in branches if any(
            item.get("metadata_path") == record["key"] and
            item.get("checkpoint_sha256") == record["checkpoint_sha256"]
            for item in branch["lineage"] if isinstance(item, dict))]
        # Remove strict prefixes and duplicate snapshots. Two distinct leaves
        # are ambiguous: the user must click a take unique to the desired leaf.
        leaves = {}
        for branch in matches:
            lineage = branch["lineage"]
            if not any(len(other["lineage"]) > len(lineage) and
                       other["lineage"][:len(lineage)] == lineage for other in matches):
                key = json.dumps(lineage, sort_keys=True)
                if key not in leaves or branch["kind"] == "metadata":
                    leaves[key] = branch
        record["processing_branch"] = next(iter(leaves.values())) if len(leaves) == 1 else None
    return {"variants": records, "warnings": warnings}