File size: 30,543 Bytes
e488ce7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""The ComfyUI node surface of the timeline surface, plus the recorder pair.

Two doors onto one compiler. H3TimelineAnalyze/H3TimelineRender drive the
whole plan-to-graph path in one step; H3DrawnPlan/H3PlanSettings/
H3PlanEstimate hand a plan's compiled answers out as typed outputs, so a
graph author can wire the same numbers into stock H3 nodes and get the same
execution. Whichever door you use, the answers come from timeline/h3.

Two-phase execution, no mid-graph pause (spec): ANALYZE is cheap and writes
plan.json; COMPILE+RENDER is phase 2. The auto checkbox is phase 2's entry:
auto = accept the proposal verbatim and compile immediately (the zero-click
mode), off = the plan is written and you open an editor.

These nodes are THIN CLIENTS. Every rule lives in timeline/ (semantics) and
timeline/h3/ (the model's reality); this file only moves strings around and
draws a preview.
"""
import json
import os

import torch

from . import price, recorder, schema
from .h3 import compile as h3compile
from .h3 import propose
from .h3.compile import H3Backend
# through gridlaw's loader, not `from ..motion import ANY`: the same import
# then works whether the pack is loaded as a ComfyUI package or as a plain
# directory on sys.path (which is how the suites load it).
from .h3.gridlaw import motion as _motion

ANY = _motion.ANY

DEFAULT_PLAN_DIR = os.environ.get("H3_TIMELINE_PLAN_DIR",
    os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))), "output", "timeline"))
DEFAULT_GRAPH_DIR = os.environ.get("H3_TIMELINE_GRAPH_DIR", DEFAULT_PLAN_DIR)


def _strip(plan, compiled=None, height=160, width=None):
    """Preview strip: the drawn density envelope over the clip, with the
    compiled hold map underneath so the quantization is visible (spec: the
    UI draws the quantization honestly)."""
    frames = int(plan["clip"]["frames"])
    w = int(width or max(256, min(1024, frames * 4)))
    lanes = schema.density_lanes(plan)
    env = lanes[0]["envelope"] if lanes else [[0, 1.0]]
    ceiling = float(lanes[0].get("ceiling", 4.0)) if lanes else 4.0
    img = torch.zeros(height, w, 3)
    img[:, :, 2] = 0.06
    for gy in range(1, int(ceiling) + 1):          # density gridlines
        y = height - 1 - int((gy - 1) / max(ceiling - 1, 1e-9) * (height - 1))
        img[max(0, min(height - 1, y)), :, :] = 0.18
    for x in range(w):
        f = int(x / float(w) * frames)
        r = schema.sample_envelope(env, f)
        y = height - 1 - int((r - 1.0) / max(ceiling - 1.0, 1e-9) * (height - 1))
        y = max(0, min(height - 1, y))
        img[y:, x, 0] = 0.85                       # filled area, red
        img[y:, x, 1] = 0.35
        img[y, x, :] = torch.tensor([1.0, 0.85, 0.2])
    if compiled:
        art = compiled.get("artifact", compiled)
        w0 = art["window"]["start"]
        wl = art["window"]["len"]
        holds = art["hold_map"]["holds"]
        hmax = float(max(holds))
        band = max(6, height // 8)
        for x in range(w):
            f = int(x / float(w) * frames)
            if w0 <= f < w0 + wl:
                h = holds[f - w0]
                v = 0.25 + 0.75 * (h / hmax)
                img[height - band:, x, 0] = v
                img[height - band:, x, 1] = v * 0.6
                img[height - band:, x, 2] = 0.1
        for edge in (w0, w0 + wl - 1):             # window boundaries
            x = min(w - 1, int(edge / float(frames) * w))
            img[:, x, :] = torch.tensor([0.2, 0.9, 1.0])
    return img[None]


def _read_plan(plan_path, plan_json=""):
    """Text -> a migrated plan document, with errors a user can act on.

    A malformed plan is the normal accident here (a half-copied paste, a
    truncated file), so it reports WHERE it broke and stops, rather than
    letting a json exception surface as a stack trace.
    """
    text, src = (plan_json or "").strip(), "the pasted plan"
    if not text:
        p = (plan_path or "").strip()
        if not p:
            raise ValueError("H3 Drawn Plan: give a plan_path or paste a plan "
                             "into plan_json")
        if not os.path.isfile(p):
            raise ValueError(f"H3 Drawn Plan: no plan file at {p}")
        with open(p) as fh:
            text = fh.read()
        src = p
    try:
        plan = json.loads(text)
    except json.JSONDecodeError as e:
        raise ValueError(f"H3 Drawn Plan: {src} is not valid JSON "
                         f"(line {e.lineno}, column {e.colno}): {e.msg}")
    if not isinstance(plan, dict):
        raise ValueError(f"H3 Drawn Plan: {src} is valid JSON but not a plan "
                         f"document (it is a {type(plan).__name__})")
    return schema.migrate(plan)


class H3TimelineAnalyze:
    """Phase 1: oracle -> plan.json (+ auto-compile)."""

    DESCRIPTION = (
        "EXPERIMENTAL (alpha), new 2026-08-14. The timeline surface's front "
        "half. Wire an oracle's profile output (H3 Jerk Oracle and/or H3 "
        "Indecision Oracle) and this writes a PLAN DOCUMENT: a per-clip JSON "
        "that says, in dilation ratios over time, how the shot should be "
        "retimed. The plan is the source of truth; a graph is only ever "
        "compiled FROM it.\n\n"
        "Both oracles wired is the validated combination: jerk catches fast "
        "PROPS, indecision catches detail the model is still arguing with "
        "itself about, and they disagree in both directions, so the proposal "
        "takes the max of the two rank-normalized profiles.\n\n"
        "auto=True is the zero-click mode: the proposal is compiled straight "
        "into a minted graph and the report prices it. auto=False stops at "
        "the plan, for editing in the widget or the web editor.\n\n"
        "The density ceiling is yours to raise. 4x is the measured sweet spot, "
        "not a wall: if you want 8x across the whole clip and are cool "
        "spending the time, raise it and read the price meter.")

    @classmethod
    def INPUT_TYPES(cls):
        return {"required": {
            "clip_path": ("STRING", {"default": "",
                          "tooltip": "the source clip this plan is about (its file name is what the compiled graph's LoadVideo will use)"}),
            "frames": ("INT", {"default": 124, "min": 39, "max": 3600,
                       "tooltip": "world length of the clip, on the 17k+5 grid; wire H3 Video Fit's length"}),
            "plan_path": ("STRING", {"default": "",
                          "tooltip": "where to write plan.json; empty = output/timeline/<clip>.plan.json"}),
            "auto": ("BOOLEAN", {"default": True,
                     "tooltip": "True: accept the proposal and compile it immediately (zero click). False: write the plan and stop"}),
        }, "optional": {
            "jerk_profile": ("STRING", {"forceInput": True,
                             "tooltip": "H3 Jerk Oracle's profile output"}),
            "indecision_profile": ("STRING", {"forceInput": True,
                                    "tooltip": "H3 Indecision Oracle's profile output"}),
            "ceiling": ("FLOAT", {"default": 4.0, "min": 1.0, "max": 16.0, "step": 0.5,
                        "tooltip": "the lane's top end in generated frames per world frame; the output is NOT slower or longer, it is generated denser. The price meter is what makes a high ceiling safe"}),
            "threshold": ("FLOAT", {"default": 0.75, "min": 0.0, "max": 0.99, "step": 0.01,
                          "tooltip": "rank-scale saliency below which the clip stays at real time (the oracle's q, on a scale both signals share)"}),
            "prompt": ("STRING", {"default": "", "multiline": True}),
            "width": ("INT", {"default": 1152, "min": 64, "max": 4096, "step": 32}),
            "height": ("INT", {"default": 640, "min": 64, "max": 4096, "step": 32}),
            "regen_strength": ("FLOAT", {"default": 0.45, "min": 0.0, "max": 1.0, "step": 0.01,
                               "tooltip": "how much of the denoise schedule the regeneration gets (H3's inject)"}),
            "steps": ("INT", {"default": 25, "min": 1, "max": 100}),
            "seed": ("INT", {"default": 20260817, "min": 0, "max": 0xffffffffffffffff}),
            "output_prefix": ("STRING", {"default": "video/timeline"}),
            "graph_path": ("STRING", {"default": "",
                           "tooltip": "where auto-compile mints the .api.json; empty = workflows/<plan name>.api.json"}),
        }}

    RETURN_TYPES = ("STRING", "IMAGE", "STRING", "STRING")
    RETURN_NAMES = ("plan_path", "preview", "graph_path", "report")
    FUNCTION = "analyze"
    CATEGORY = "latent/minimax/timeline"

    def analyze(self, clip_path, frames, plan_path, auto,
                jerk_profile=None, indecision_profile=None, ceiling=4.0,
                threshold=0.75, prompt="", width=1152, height=640,
                regen_strength=0.45, steps=25, seed=20260817,
                output_prefix="video/timeline", graph_path=""):
        plan = schema.new_plan(
            clip_path, frames=frames, fps=24.0, width=width, height=height,
            proposed_by="H3TimelineAnalyze",
            settings={"prompt": prompt, "regen_strength": float(regen_strength),
                      "steps": int(steps), "seed": int(seed),
                      "output_prefix": output_prefix})
        lane = propose.propose_density_lane(
            frames, jerk=jerk_profile, indecision=indecision_profile,
            ceiling=float(ceiling), threshold=float(threshold))
        plan["lanes"].append(lane)
        schema.note_edit(plan, "H3TimelineAnalyze",
                         f"proposed temporal lane from {lane['proposer']}")
        schema.validate_or_raise(plan)

        base = os.path.splitext(os.path.basename(clip_path or "clip"))[0]
        plan_path = plan_path or os.path.join(DEFAULT_PLAN_DIR,
                                              f"{base}.plan.json")
        peak = max(r for _f, r in lane["envelope"])
        ch = propose.challenge_ceiling(peak)
        lines = [f"plan {plan['id'][:8]} rev {plan['revision']}: "
                 f"{len(lane['envelope'])} control points, peak {peak:.2f}x, "
                 f"ceiling {ceiling:g}, proposer {lane['proposer']}"]
        if ch:
            plan.setdefault("provenance", {}).setdefault(
                "flags", []).append(ch)
            lines.append(f"RECIPE CHALLENGE (flagged, not overridden): "
                         f"{ch['why']}")

        compiled, out_graph = None, ""
        if auto:
            out_graph = graph_path or os.path.join(
                DEFAULT_GRAPH_DIR, f"{base}_timeline.api.json")
            res = H3Backend().compile(plan, {"graph_path": out_graph})
            compiled = res.compiled
            lines.append(res.report)
        schema.save(plan, plan_path)
        lines.insert(1, f"plan written: {plan_path}")
        if out_graph:
            lines.insert(2, f"graph minted: {out_graph}")
        return (plan_path, _strip(plan, compiled), out_graph, "\n".join(lines))


class H3TimelineRender:
    """Phase 2: plan.json -> a minted graph, priced.

    SHIPPED IN v0: this node COMPILES and MINTS; it does not execute the
    compiled graph in-process. Execution stays with the launcher
    (queue_scene.py), which is the project's only sanctioned way to start a
    render and carries the collision and node_error guards. Wiring the
    compiled section into a live sampler inside one graph is T2 work.
    """

    DESCRIPTION = (
        "EXPERIMENTAL (alpha), new 2026-08-14. Phase 2 of the timeline "
        "surface: read a plan.json (yours, the oracle's, or one somebody "
        "posted), compile it to a legal H3 graph, and price it.\n\n"
        "v0 emits the minted graph PATH and the exact launch line rather "
        "than executing it in place: launching belongs to queue_scene.py, "
        "which guards output-prefix collisions and refuses partial graphs.\n\n"
        "If the plan was edited after it was last compiled, this recompiles "
        "it — the compiled block is a cache keyed to the plan's revision, "
        "never a second source of truth.")

    @classmethod
    def INPUT_TYPES(cls):
        return {"required": {
            "plan_path": ("STRING", {"default": "", "forceInput": False}),
        }, "optional": {
            "graph_path": ("STRING", {"default": "",
                           "tooltip": "empty = workflows/<plan name>.api.json"}),
            "port": ("INT", {"default": 8189, "min": 8188, "max": 8199,
                     "tooltip": "instance the launch line targets, and whose /system_stats the VRAM fit line reads"}),
            "recorder_path": ("STRING", {"default": recorder.DEFAULT_PATH,
                              "tooltip": "flight-recorder jsonl; layer (b) of the price meter calibrates from YOUR runs only"}),
        }}

    RETURN_TYPES = ("STRING", "STRING", "STRING")
    RETURN_NAMES = ("graph_path", "launch_command", "report")
    FUNCTION = "render"
    CATEGORY = "latent/minimax/timeline"

    def render(self, plan_path, graph_path="", port=8189,
               recorder_path=recorder.DEFAULT_PATH):
        plan = schema.load(plan_path)
        base = os.path.splitext(os.path.basename(plan_path))[0]
        base = base.replace(".plan", "")
        graph_path = graph_path or os.path.join(DEFAULT_GRAPH_DIR,
                                                f"{base}_timeline.api.json")
        stale = schema.compiled_is_stale(plan, H3Backend.backend_id)
        res = H3Backend().compile(plan, {"graph_path": graph_path,
                                         "port": int(port),
                                         "recorder_path": recorder_path})
        schema.save(plan, plan_path)
        launcher = os.environ.get("H3_TIMELINE_QUEUE_CMD", "")
        cmd = (f"{launcher} {graph_path} --tag timeline --port {int(port)}" if launcher
               else f"(set H3_TIMELINE_QUEUE_CMD to a launcher; graph at {graph_path})")
        report = "\n".join([
            f"plan {plan.get('id', '?')[:8]} rev {plan.get('revision', 0)} "
            f"({'recompiled after an edit' if stale else 'cache was current'})",
            res.report,
            f"minted: {graph_path}",
            "launch with:  " + cmd,
        ])
        return (graph_path, cmd, report)


def _compile_for_nodes(plan_path, plan_json="", ignore_uncompiled_lanes=False):
    """Read a plan and compile it. -> (plan, CompileResult, skipped lanes).

    THE ONE DOOR the plan-loading nodes go through, so H3 Drawn Plan, H3
    Plan Settings and H3 Plan Estimate cannot drift into three opinions
    about the same document. Everything they return afterwards is a field
    of the compiler's answer, never a recomputation of it.
    """
    plan = _read_plan(plan_path, plan_json)
    backend = H3Backend()
    skipped = []
    if ignore_uncompiled_lanes:
        # which lane types compile is the BACKEND's answer, asked for
        # rather than assumed, so this stays true as lanes land
        runs = set(backend.capabilities()["lanes_compiled"])
        for lane in plan.get("lanes", []):
            if schema.lane_type(lane) not in runs and lane.get("enabled", True):
                lane["enabled"] = False
                skipped.append(str(lane.get("type")))
    problems = backend.validate(plan)
    if problems:
        raise ValueError("this plan cannot be compiled: " + "; ".join(problems))
    return plan, backend.compile(plan), skipped


def _plan_inputs(extra=None):
    """The three loader nodes take the same document the same way."""
    opt = {"plan_json": ("STRING", {"default": "", "multiline": True,
                         "tooltip": "the plan document itself, pasted; wins over plan_path"}),
           "ignore_uncompiled_lanes": ("BOOLEAN", {"default": False,
                         "tooltip": "a plan may carry lanes this backend cannot compile yet (pins, regions). OFF: refuse, so nothing is silently dropped. ON: compile the density lane anyway and name every lane that was skipped"})}
    opt.update(extra or {})
    return {"required": {
        "plan_path": ("STRING", {"default": "",
                      "tooltip": "path to a plan.json written by the editor, the oracle node or the CLI"}),
    }, "optional": opt}


def _tail_lines(res, skipped, include_warnings=True):
    """Skipped lanes and the compiler's warnings, for nodes that do not
    already print the compile report (which carries the warnings itself)."""
    lines = []
    if skipped:
        lines.append("SKIPPED (ignore_uncompiled_lanes is ON): %s. Those lanes "
                     "are still in the plan; this pass did not run them"
                     % ", ".join(sorted(set(skipped))))
    if include_warnings:
        for w in res.warnings:
            lines.append("WARNING: " + w)
    return lines


class H3DrawnPlan:
    """A drawn plan document in, the motion nodes' hold-map ranges out.

    THIN LOADER. It parses the plan, hands it to the H3 backend, and reads
    the ranges off the compiled artifact. It contains no grid law: which
    frames are held, how long the window is and where it starts are the
    compiler's answers, and if this node ever needs to know about 17s or
    token indices to do its job, the answer has been forked.
    """

    DESCRIPTION = (
        "EXPERIMENTAL (alpha), new 2026-08-15. Load a plan document (the "
        "timeline surface's JSON: what the shot should do, in seconds and "
        "densities) and get back the hold-map ranges H3 Manual Hold Map "
        "takes, already on the world clock.\n\n"
        "Draw the envelope in the web editor, export the plan, point this "
        "node at the file: the drawing arrives in the graph without anybody "
        "retyping frame numbers.\n\n"
        "The ranges come from the COMPILER, not from this node. It is the "
        "same compilation H3 Timeline Render would mint, so the map is "
        "already shaped: feed the ranges to H3 Manual Hold Map with ramp "
        "OFF and bridge 0 to reproduce it exactly. Leaving ramp on re-shapes "
        "the shoulders a second time.\n\n"
        "plan_json wins over plan_path when both are filled, so a plan "
        "pasted from a chat works without a file.\n\n"
        "TWO DOORS, ONE ANSWER: hold_map is the compiler's map verbatim, "
        "for wiring straight into H3 Temporal Insert. ranges is the same "
        "map in H3 Manual Hold Map's language, for when you want to read "
        "or edit it by hand (that route re-shapes through the node's own "
        "token snapping, so wire ranges with ramp OFF and bridge 0). "
        "splice_map drives H3 Segment Splice at feather 0 to put the "
        "recovered window back into the untouched clip.")

    @classmethod
    def INPUT_TYPES(cls):
        return _plan_inputs()

    RETURN_TYPES = ("STRING", "INT", "INT", "INT", "INT", "STRING",
                    "STRING", "INT", "INT", "STRING")
    RETURN_NAMES = ("ranges", "length", "fps", "window_start", "window_len",
                    "report",
                    "hold_map", "dilated_frames", "guide_frame", "splice_map")
    FUNCTION = "load"
    CATEGORY = "latent/minimax/timeline"

    def load(self, plan_path, plan_json="", ignore_uncompiled_lanes=False):
        try:
            plan, res, skipped = _compile_for_nodes(
                plan_path, plan_json, ignore_uncompiled_lanes)
        except ValueError as e:
            raise ValueError("H3 Drawn Plan: %s" % e)
        art = res.compiled["artifact"]
        holds = art["hold_map"]["holds"]
        w0 = int(art["window"]["start"])
        wlen = int(art["window"]["len"])
        frames = int(plan["clip"]["frames"])
        fps = int(round(float(plan["clip"].get("fps") or 24)))
        ranges = h3compile.ranges_from_holds(holds, w0, fps)
        shown = ranges or "(none: the drawn envelope is flat at 1.0)"
        # Both strings are the COMPILER's, passed through: the hold map as
        # H3 Temporal Insert eats it, and the window's place in the clip as
        # H3 Segment Splice eats it.
        hold_map = json.dumps(art["hold_map"])
        splice_map = json.dumps({"start": w0, "end": w0 + wlen - 1,
                                 "world_len": frames,
                                 "handle_in": 0, "handle_out": 0})
        lines = [f"plan {plan.get('id', '?')[:8]} rev {plan.get('revision', 0)}"
                 f" -> {len([1 for h in holds if h > 1])} held world frames in "
                 f"{len(ranges.split(',')) if ranges else 0} ranges",
                 f"ranges (world clock, {fps} fps): {shown}",
                 "wire hold_map into H3 Temporal Insert for the exact map; "
                 "the ranges route needs ramp OFF and bridge 0",
                 res.report] + _tail_lines(res, skipped,
                                           include_warnings=False)
        return (ranges, frames, fps, w0, wlen, "\n".join(lines),
                hold_map, int(art["dilated_frames"]),
                int(art["guide_dilated_idx"]), splice_map)


class H3PlanSettings:
    """The plan's execution knobs, as typed outputs.

    THIN LOADER, like its sibling: every value here is read out of the plan
    document or the compiler's answer. No default is invented, no number is
    adjusted, and nothing about the model's grid is known here.
    """

    DESCRIPTION = (
        "EXPERIMENTAL (alpha), new 2026-08-15. The other half of H3 Drawn "
        "Plan: the settings a graph needs wired, straight off the plan "
        "document.\n\n"
        "inject and steps drive H3 Inject Schedule, seed drives Random "
        "Noise, width/height/prompt drive the conditioning node, "
        "output_prefix drives Save Video. Wire these instead of retyping "
        "them and the graph cannot drift from the plan it came from.\n\n"
        "expand_to_end reports what the plan ASKED for. The compiler has "
        "already applied it to the map, so H3 Temporal Insert must be left "
        "at False: wiring this output into that toggle would apply it "
        "twice.")

    @classmethod
    def INPUT_TYPES(cls):
        return _plan_inputs()

    RETURN_TYPES = ("FLOAT", "INT", "INT", "STRING", "INT", "INT", "STRING",
                    "BOOLEAN", "STRING")
    RETURN_NAMES = ("inject", "steps", "seed", "prompt", "width", "height",
                    "output_prefix", "expand_to_end", "report")
    FUNCTION = "load"
    CATEGORY = "latent/minimax/timeline"

    def load(self, plan_path, plan_json="", ignore_uncompiled_lanes=False):
        try:
            plan, res, skipped = _compile_for_nodes(
                plan_path, plan_json, ignore_uncompiled_lanes)
        except ValueError as e:
            raise ValueError("H3 Plan Settings: %s" % e)
        clip = plan["clip"]
        ov = dict(h3compile.DEFAULT_OVERRIDES)
        ov.update(schema.backend_settings(plan, H3Backend.backend_id))
        inject = float(schema.setting(plan, "regen_strength"))
        steps = int(schema.setting(plan, "steps"))
        seed = int(schema.setting(plan, "seed"))
        width = int(clip.get("width") or ov["width"])
        height = int(clip.get("height") or ov["height"])
        expand = bool(schema.setting(plan, "hold_expansion_to_clip_end"))
        lines = [f"plan {plan.get('id', '?')[:8]} rev {plan.get('revision', 0)}: "
                 f"inject {inject:g} over {steps} steps, seed {seed}, "
                 f"{width}x{height}",
                 f"delivery: {schema.setting(plan, 'output_prefix')} "
                 f"(sync policy {schema.setting(plan, 'sync_policy')})",
                 "expand_to_end is the plan's INTENT and is already baked "
                 "into the hold map; leave H3 Temporal Insert at False",
                 ] + _tail_lines(res, skipped)
        return (inject, steps, seed, schema.setting(plan, "prompt", "") or "",
                width, height, schema.setting(plan, "output_prefix"),
                expand, "\n".join(lines))


class H3PlanEstimate:
    """What the plan costs, before anything runs.

    The price meter's three layers, surfaced in the graph. Layer (b)
    ABSTAINS rather than quoting another machine's minutes: seconds comes
    back as -1 when there is no calibration for this box, and the report
    says why. Reading -1 as a number is the caller's mistake to avoid; the
    node will not invent one.
    """

    DESCRIPTION = (
        "EXPERIMENTAL (alpha), new 2026-08-15. Price a plan without running "
        "it: how many times a plain render of this clip it costs, how many "
        "work units it generates, and whether it fits your VRAM.\n\n"
        "(a) equivalent clip time is exact from the plan and needs no "
        "calibration. (b) seconds is REAL MINUTES from your own flight "
        "recorder, and comes back as -1 when you have no runs on record for "
        "this box: an uncalibrated guess from GPU specs is worse than no "
        "answer. (c) vram_band is a bracketing verdict from your recorded "
        "runs, not a peak-bytes prediction.\n\n"
        "recorder_path and port are the same two sources the timeline "
        "surface uses; leave them alone unless your setup moved.")

    @classmethod
    def INPUT_TYPES(cls):
        return _plan_inputs({
            "port": ("INT", {"default": 8188, "min": 8188, "max": 8199,
                     "tooltip": "instance whose /system_stats the VRAM reading comes from"}),
            "recorder_path": ("STRING", {"default": recorder.DEFAULT_PATH,
                              "tooltip": "flight-recorder jsonl; layer (b) calibrates from YOUR runs only"}),
        })

    RETURN_TYPES = ("FLOAT", "FLOAT", "INT", "STRING", "STRING")
    RETURN_NAMES = ("equivalent_clip_time", "seconds", "work_units",
                    "vram_band", "report")
    FUNCTION = "load"
    CATEGORY = "latent/minimax/timeline"

    def load(self, plan_path, plan_json="", ignore_uncompiled_lanes=False,
             port=8188, recorder_path=recorder.DEFAULT_PATH):
        try:
            plan, res, skipped = _compile_for_nodes(
                plan_path, plan_json, ignore_uncompiled_lanes)
        except ValueError as e:
            raise ValueError("H3 Plan Estimate: %s" % e)
        ctx = {"port": int(port)}
        if recorder_path and os.path.isfile(recorder_path):
            ctx["recorder_path"] = recorder_path
        est = H3Backend().estimate(plan, ctx)
        secs = -1.0 if est.seconds is None else float(est.seconds)
        lines = est.lines + _tail_lines(res, skipped)
        if est.seconds is None:
            lines.append("seconds = -1: layer (b) abstained, and this node "
                         "will not turn an abstention into a number")
        return (float(est.multiplier), secs,
                int(est.geometry.work_units_plan), str(est.fit),
                "\n".join(lines))


class H3RecordStart:
    """Flight recorder, front half: reset the torch peak counters."""

    DESCRIPTION = ("Alpha. Drop this in front of the expensive node and H3 "
                   "Record Stop after it. Together they append ONE local "
                   "JSON line per render (work units, resolution, model tag, "
                   "s/step, torch peak bytes) that the timeline's price meter "
                   "calibrates from. Local file, nothing phones home.")

    @classmethod
    def INPUT_TYPES(cls):
        return {"required": {"anything": (ANY, {}),
                             "key": ("STRING", {"default": "default"})},
                "optional": {"log_path": ("STRING", {"default": "",
                             "tooltip": "ComfyUI's log file, if you have one. "
                             "Its size is noted NOW so the streamed flag can "
                             "only be read from lines this run wrote"})}}

    RETURN_TYPES = (ANY,)
    RETURN_NAMES = ("anything",)
    FUNCTION = "go"
    CATEGORY = "latent/minimax/timeline"

    def go(self, anything, key="default", log_path=""):
        recorder.start(key, log_path=log_path or None)
        return (anything,)


class H3RecordStop:
    """Flight recorder, back half: read the peaks and append the line."""

    DESCRIPTION = H3RecordStart.DESCRIPTION

    @classmethod
    def INPUT_TYPES(cls):
        return {"required": {
            "anything": (ANY, {}),
            "key": ("STRING", {"default": "default"}),
            "work_units": ("INT", {"default": 0, "min": 0, "max": 100000,
                           "tooltip": "latent tokens this run generated (the compiled plan reports it)"}),
            "steps": ("INT", {"default": 25, "min": 1, "max": 1000}),
        }, "optional": {
            "pixels": ("INT", {"default": 0, "min": 0, "max": 1 << 30}),
            "frames": ("INT", {"default": 0, "min": 0, "max": 10000}),
            "model": ("STRING", {"default": "minimax-h3-int8-convrot"}),
            "path": ("STRING", {"default": recorder.DEFAULT_PATH}),
            "log_path": ("STRING", {"default": "",
                         "tooltip": "ComfyUI's log file, if you have one: the streamed flag is READ from its partial-load lines, never guessed"}),
        }}

    RETURN_TYPES = (ANY, "STRING")
    RETURN_NAMES = ("anything", "record")
    FUNCTION = "go"
    CATEGORY = "latent/minimax/timeline"

    def go(self, anything, key="default", work_units=0, steps=25, pixels=0,
           frames=0, model="minimax-h3-int8-convrot",
           path=recorder.DEFAULT_PATH, log_path=""):
        rec = recorder.stop(key, work_units=work_units, steps=steps,
                            pixels=pixels, frames=frames, model=model,
                            path=path, log_path=log_path or None)
        return (anything, json.dumps(rec))


NODE_CLASS_MAPPINGS = {
    "H3TimelineAnalyze": H3TimelineAnalyze,
    "H3TimelineRender": H3TimelineRender,
    "H3DrawnPlan": H3DrawnPlan,
    "H3PlanSettings": H3PlanSettings,
    "H3PlanEstimate": H3PlanEstimate,
    "H3RecordStart": H3RecordStart,
    "H3RecordStop": H3RecordStop,
}

NODE_DISPLAY_NAME_MAPPINGS = {
    "H3TimelineAnalyze": "H3 Timeline Analyze (alpha)",
    "H3TimelineRender": "H3 Timeline Render (alpha)",
    "H3DrawnPlan": "H3 Drawn Plan (alpha)",
    "H3PlanSettings": "H3 Plan Settings (alpha)",
    "H3PlanEstimate": "H3 Plan Estimate (alpha)",
    "H3RecordStart": "H3 Flight Recorder Start (alpha)",
    "H3RecordStop": "H3 Flight Recorder Stop (alpha)",
}

__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "price"]