Skip to content

Commit db8f200

Browse files
SlavaSextronclaude
andcommitted
One preview per write, and a Refresh on the sequence flipbook
A written sequence came back with TWO previews on the node: the flipbook of the real frames, and an H.264 proxy of the same frames beside it. Both branches ran, because the flipbook was added alongside the proxy instead of in front of it. They do not agree - the proxy is 8-bit and carries no colour conversion, so it reads darker than the frames it claims to preview - and nothing on the node said which one was the master. Reading colour off the wrong one is the failure this pack exists to prevent. The proxy is now the fallback it was meant to be: it ships only when the frame range cannot be described. A movie still gets it and no flipbook, because a container file has no range to scrub. A single frame still gets its own PNG thumb and nothing that pretends to move. The flipbook gains the persistent top-left Refresh square OCIO Player already carries, for when the files on disk change under a preview that is already drawn. It busts the browser cache, since every frame URL is otherwise byte-identical from one pass to the next and the button would do nothing visible. A frame that will not load now replaces the strip with the folder path instead of freezing on the last good frame. OV_BASE / OV_STALE move to module scope. They were local to the Player's setup function, and the flipbook's button - defined hundreds of lines above it - read them at runtime and would have thrown ReferenceError. node --check passes either way, because a name is only resolved when the code runs. tools/test_write_preview_single.py reads the preview counts off a real write of all three containers. Mutation-checked: forcing the proxy back onto the sequence branch turns it red on the right assertion, and green again when reverted. Gate 37 passed / 0 failed (baseline 36/0, +1 the new test). .gitignore picks up the renders the example graphs produce. The .json workflows there ship; what comes out of running them does not, and a stray blanket add would otherwise sweep them into a public repository. Deliberately not *.png: nyc_skyline.png is a tracked input those graphs load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent cb4b5b5 commit db8f200

6 files changed

Lines changed: 217 additions & 20 deletions

File tree

.gitignore

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,15 @@ data/
1313
# local working dirs - never publish (logs and artifacts can carry paths and names from other work)
1414
scratchpad/
1515
.agents/
16+
17+
# Renders made while testing the example graphs. The .json workflows in example_workflows/ DO ship; what
18+
# comes out of running them does not - they are large, anyone who runs the graph regenerates them, and a
19+
# stray `git add -A` would otherwise sweep them into a public repository.
20+
#
21+
# NOT *.png: example_workflows/nyc_skyline.png is a tracked input the example graphs load, and a blanket rule
22+
# there would read as "we do not ship pictures from this folder", which is false.
23+
example_workflows/output/
24+
example_workflows/*.mp4
25+
example_workflows/*.mov
26+
example_workflows/*.exr
27+
probe.mp4

CHANGELOG.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,23 @@ even when it fits: 912 s against 60 s over 121 frames at float32. Saved graphs a
7979
their own value.
8080

8181
A `sequence` write showed one still frame, because every format that branch writes is one a browser cannot
82-
animate. It now shows a playing clip, and hands the front end the written frames' path and range so a future
83-
version can flip through the real files rather than an H.264 proxy.
82+
animate. It now plays back **the written frames themselves**: the node gets their path and range, and the
83+
front end flips through them the way OCIO Read already does, one server-rendered frame at a time through
84+
`/ocio/thumb`. `thumb_frame` reads them with `_read_still`, the same reader OCIO Read uses, so every format
85+
this branch can write is one the flipbook can serve back.
86+
87+
There is a persistent `` in the strip's top-left corner, the same square OCIO Player carries, for when the
88+
frames on disk change under a preview that is already drawn - a re-render into the same folder, a retake from
89+
another graph. It re-reads them from disk rather than from the browser's cache. If a frame will not load, the
90+
strip is replaced by the folder path instead of freezing on the last good frame.
91+
92+
**One preview, not two.** The first cut of this shipped the flipbook *and* the H.264 proxy, so a written
93+
sequence came back with two previews on one node - the real frames, and a darker 8-bit copy of them
94+
disagreeing about colour, with nothing to say which was the master. An artist reading colour off the wrong one
95+
is the whole failure this pack exists to prevent. The proxy is now the fallback it was meant to be: it ships
96+
only when the flipbook cannot be described. A movie still gets the proxy and no flipbook, because a container
97+
file has no frame range to scrub; a single frame still gets its own PNG thumb and nothing that pretends to
98+
move. `tools/test_write_preview_single.py` reads the counts off a real write of each of the three.
8499

85100
## A tool that checks the installed copy against the repository
86101

docs/NODES_IO.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,18 @@ writes - EXR, DPX, TIFF, PNG - is one a browser either cannot decode or cannot a
441441
to come back as a single still. It now flips through the written files, each rendered server-side through
442442
OCIO by the same `/ocio/thumb` route `OCIO Read`'s viewer uses. That matters for a pack whose argument is
443443
that it does not throw information away: an 8-bit re-encode is a poor way to show frames just written at 16
444-
or 32 bits. The H.264 proxy stays alongside as the fallback.
444+
or 32 bits.
445+
446+
The H.264 proxy is the **fallback**, not a companion: it ships only when the frame range cannot be described,
447+
so exactly one preview appears on the node. Both at once meant two pictures of the same write disagreeing
448+
about colour, with nothing on the node to say which was the master.
449+
450+
The `` in the strip's top-left re-reads the frames from disk, bypassing the browser's cache. Use it when
451+
something else has written into that folder since - another graph, a retake, a re-render - and you want to be
452+
sure you are looking at what is there now. The colorspace the strip renders through is the one that was on
453+
the node when the write ran, so a later widget edit cannot make the picture disagree with the files. If a
454+
frame cannot be read back, the strip is replaced by the folder path rather than freezing on the last good
455+
frame.
445456

446457
No audio on the sequence path even when a track is wired, because a frame sequence carries none, and a
447458
preview that played sound the files do not have would misrepresent what was produced.

io_nodes.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4038,28 +4038,36 @@ def tc_text(offset):
40384038
# player. The video branch already solved this for its own case, and the same helper works here: a
40394039
# small H.264 copy in the temp dir, played on the node. The master on disk is untouched.
40404040
#
4041-
# No audio on this path even when a track is wired: a frame sequence carries none, and a preview that
4042-
# plays sound the written files do not have would misrepresent what was produced.
4043-
ui["images"] = self._video_preview(written, fps, saved)
4044-
ui["animated"] = (True,)
4045-
# AND THE FILES THEMSELVES, so the front end can flip through the real frames instead of an H.264
4046-
# proxy. OCIO Read already does this: /ocio/thumb renders ONE frame server-side through OCIO and
4047-
# the browser runs a Nuke-style flipbook over a blob cache, which is why a colorspace change there
4048-
# is exact rather than approximate. The proxy above stays as the fallback - it needs no round trip
4049-
# and works before any of this is wired - but a pack about colour should be able to show the
4050-
# frames it actually wrote, at their real depth, not a 8-bit copy of them.
4041+
# THE FRAMES THEMSELVES, so the front end flips through what was written instead of an H.264 copy of
4042+
# it. OCIO Read already does this: /ocio/thumb renders ONE frame server-side through OCIO and the
4043+
# browser runs a Nuke-style flipbook over it, which is why a colorspace change there is exact rather
4044+
# than approximate. A pack about colour should show the frames it actually wrote, at their real
4045+
# depth. thumb_frame reads them with _read_still, the same reader OCIO Read uses, so every still
4046+
# format this branch can write is one the flipbook can serve back.
40514047
#
40524048
# The path is the written sequence's own directory pattern, and the range is the frame numbers on
40534049
# disk, so the viewer and the master cannot disagree about which frames exist.
4050+
flip = False
40544051
try:
40554052
first_written = paths[0] if paths else None
40564053
if first_written:
40574054
ui["seq_src"] = [os.path.dirname(first_written)]
40584055
ui["seq_first"] = [int(start_number)]
40594056
ui["seq_last"] = [int(start_number) + int(written.shape[0]) - 1]
40604057
ui["seq_fps"] = [float(fps) if fps and fps > 0 else 24.0]
4058+
flip = True
40614059
except Exception as e: # a preview must never take the write down with it
40624060
logging.warning("OCIO Write: could not describe the sequence for the flipbook: %s", e)
4061+
if not flip:
4062+
# Only when the flipbook could NOT be described. Shipping both put TWO previews on one node
4063+
# (2026-08-15) - the real frames above a darker 8-bit copy of them, disagreeing about colour,
4064+
# with nothing to say which was the master. The proxy stays as the fallback because it needs no
4065+
# round trip, but it is a fallback, not a companion.
4066+
#
4067+
# No audio on this path even when a track is wired: a frame sequence carries none, and a preview
4068+
# that plays sound the written files do not have would misrepresent what was produced.
4069+
ui["images"] = self._video_preview(written, fps, saved)
4070+
ui["animated"] = (True,)
40634071
else:
40644072
ui["images"] = self._preview(preview)
40654073
return {"ui": ui, "result": (saved,)}

tools/test_write_preview_single.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Regression: OCIO Write shows exactly ONE preview per write (run: python tools/test_write_preview_single.py).
2+
3+
A written sequence got TWO previews on the node (2026-08-15): the flipbook of the real frames, and above/below
4+
it an H.264 proxy of the same frames. They do not agree - the proxy is 8-bit and carries no colour conversion,
5+
so it reads darker than the frames it claims to preview - and nothing on the node says which one is the master.
6+
An artist reading colour off the wrong one is the whole failure this pack exists to prevent.
7+
8+
Neither preview was wrong on its own; shipping both was. So the rule under test is a COUNT, not a value:
9+
10+
1. A SEQUENCE ships the flipbook (seq_src + range + fps) and NO proxy. thumb_frame reads the written frames
11+
with _read_still, the same reader OCIO Read uses, so every still format this branch writes is one the
12+
flipbook can serve back - the proxy has nothing left to add.
13+
14+
2. A VIDEO still ships the proxy, and no flipbook. A movie is one file, not a numbered range; /ocio/thumb can
15+
only hand back its FIRST frame, so a flipbook there would be a still pretending to be a clip.
16+
17+
3. A SINGLE-FRAME sequence ships neither. One frame is a still: the node's own thumb already shows it, and an
18+
H.264 clip of one frame is a video that cannot move.
19+
20+
The counts are read off the real return of OCIOWrite.write(), not from the source text - a preview key that is
21+
merely PRESENT in the file proves nothing about which branch sets it.
22+
"""
23+
import importlib.util
24+
import os
25+
import sys
26+
import tempfile
27+
import types
28+
29+
os.environ.setdefault("OPENCV_IO_ENABLE_OPENEXR", "1")
30+
31+
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
32+
33+
34+
def _load_io_nodes(tmp):
35+
fp = types.ModuleType("folder_paths")
36+
fp.get_output_directory = fp.get_temp_directory = fp.get_input_directory = lambda: tmp
37+
fp.get_filename_list = lambda *a, **k: []
38+
sys.modules.setdefault("folder_paths", fp)
39+
pkg = types.ModuleType("ocio_pkg")
40+
pkg.__path__ = [_ROOT]
41+
sys.modules["ocio_pkg"] = pkg
42+
for name in ("nodes", "io_nodes"):
43+
spec = importlib.util.spec_from_file_location(f"ocio_pkg.{name}", os.path.join(_ROOT, f"{name}.py"))
44+
mod = importlib.util.module_from_spec(spec)
45+
sys.modules[f"ocio_pkg.{name}"] = mod
46+
spec.loader.exec_module(mod)
47+
return sys.modules["ocio_pkg.io_nodes"]
48+
49+
50+
def _write(io, frames, **kw):
51+
import torch
52+
imgs = torch.zeros((frames, 6, 8, 3))
53+
imgs[..., 0], imgs[..., 1], imgs[..., 2] = 0.40, 0.60, 0.10
54+
args = dict(profile="none", from_colorspace="sRGB - Display", output_colorspace="ACEScg",
55+
container="sequence", still_format="exr", video_codec="prores_4444", bit_depth="16f",
56+
auto_range=False, first_frame=1, last_frame=0, start_number=1001, source_start=1,
57+
raw_data=False, filename="prev", fps=23.976, images=imgs)
58+
args.update(kw)
59+
res = io.OCIOWrite().write(**args)
60+
return (res or {}).get("ui", {}) or {}
61+
62+
63+
def check_sequence_is_flipbook_only(io):
64+
"""A multi-frame sequence: the real frames, and nothing standing beside them."""
65+
ui = _write(io, 3, output_folder="$OUTPUT/seq3")
66+
assert "seq_src" in ui, f"a 3-frame sequence lost its flipbook; ui keys were {sorted(ui)}"
67+
assert "images" not in ui and "animated" not in ui, (
68+
f"TWO previews on one node: the flipbook AND the H.264 proxy; ui keys were {sorted(ui)}")
69+
# The range has to describe the files that exist, or the flipbook asks /ocio/thumb for frames it will 404 on.
70+
first, last = int(ui["seq_first"][0]), int(ui["seq_last"][0])
71+
assert (first, last) == (1001, 1003), f"flipbook range {first}..{last} does not match the 3 frames written"
72+
folder = ui["seq_src"][0]
73+
on_disk = sorted(f for f in os.listdir(folder) if f.endswith(".exr"))
74+
assert len(on_disk) == 3, f"flipbook points at {folder}, which holds {on_disk}"
75+
assert float(ui["seq_fps"][0]) > 0, "a flipbook with no rate plays at whatever the browser feels like"
76+
77+
78+
def check_video_is_proxy_only(io):
79+
"""A movie: the proxy, and no flipbook - /ocio/thumb cannot scrub a single container file."""
80+
ui = _write(io, 3, container="video", video_codec="h264", output_folder="$OUTPUT/mov")
81+
assert "images" in ui and "animated" in ui, f"a movie lost its playable preview; ui keys were {sorted(ui)}"
82+
assert "seq_src" not in ui, (
83+
f"a movie was handed a frame-range flipbook it cannot serve; ui keys were {sorted(ui)}")
84+
85+
86+
def check_single_frame_has_no_clip(io):
87+
"""One frame is a still: its own PNG thumb, and nothing that pretends to move.
88+
89+
`images` IS expected here and is not the proxy - it is _preview(), a static PNG of the first written frame.
90+
What must not appear is `animated` (an H.264 clip of one frame) or `seq_src` (a range of one).
91+
"""
92+
ui = _write(io, 1, output_folder="$OUTPUT/one")
93+
assert "seq_src" not in ui, f"a single frame was described as a flipbook; ui keys were {sorted(ui)}"
94+
assert "animated" not in ui, f"a single frame was given a moving preview; ui keys were {sorted(ui)}"
95+
assert "images" in ui, f"a single frame lost its still thumb; ui keys were {sorted(ui)}"
96+
97+
98+
def main():
99+
tmp = tempfile.mkdtemp(prefix="ocio_prev_")
100+
io = _load_io_nodes(tmp)
101+
failures = []
102+
for fn in (check_sequence_is_flipbook_only, check_video_is_proxy_only, check_single_frame_has_no_clip):
103+
try:
104+
fn(io)
105+
print(f" ok {fn.__name__}")
106+
except AssertionError as e:
107+
failures.append(f"{fn.__name__}: {e}")
108+
print(f" FAIL {fn.__name__}: {e}")
109+
if failures:
110+
print(f"\n{len(failures)} failure(s)")
111+
return 1
112+
print("\nOCIO Write shows exactly one preview per container: OK")
113+
return 0
114+
115+
116+
if __name__ == "__main__":
117+
sys.exit(main())

web/ocio_io.js

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ function setWSilent(node, name, value) {
3232
w.value = value;
3333
node.setDirtyCanvas(true, true);
3434
}
35+
// Overlay chrome for every persistent Refresh square in this file (OCIO Player's viewport, OCIO Write's
36+
// flipbook). MODULE level on purpose: these were local to the Player's setup function, and the flipbook's
37+
// button - defined hundreds of lines above it - read them at runtime and would have thrown ReferenceError.
38+
// node --check passes either way, because a name is only resolved when the code runs.
39+
const OV_BASE = "rgba(40,40,64,0.85)", OV_STALE = "rgba(150,95,20,0.92)";
40+
3541
function extOf(name) { return (String(name || "").toLowerCase().split(".").pop() || ""); }
3642
function isExr(name) { const e = extOf(name); return e === "exr" || e === "hdr"; }
3743
function shorten(cs) { return String(cs || "").replace(" - Display", "").replace(" - Texture", ""); }
@@ -198,13 +204,18 @@ function crossingOf(enc, fromCs, outCs) {
198204
// Deliberately simpler than Read's player in two ways. It prefetches nothing: a write has already finished,
199205
// so the frames are on a local disk and warm, where Read is often scrubbing a 4K plate it has never touched.
200206
// And it has no scrub bar, because there is nothing to choose - a write shows what it wrote, start to end.
201-
function writeThumbUrl(seq, frameNo) {
202-
return "/ocio/thumb?" + new URLSearchParams({
207+
function writeThumbUrl(seq, frameNo, bust) {
208+
const q = {
203209
src: seq.src, frame: String(frameNo),
204210
in_cs: seq.cs, // what the FILE holds
205211
out_cs: CS_SRGB, // what a browser can show
206212
raw: "0", full: "0",
207-
}).toString();
213+
};
214+
// Refresh has to reach DISK, and every frame URL here is byte-identical from one pass to the next, so the
215+
// browser would serve the whole strip from its own cache and the button would do nothing visible. The
216+
// counter changes the URL without changing what is asked for.
217+
if (bust) q._ = String(bust);
218+
return "/ocio/thumb?" + new URLSearchParams(q).toString();
208219
}
209220

210221
function startWriteFlipbook(node) {
@@ -213,17 +224,41 @@ function startWriteFlipbook(node) {
213224
let w = (node.widgets || []).find(x => x.name === "__ocio_flip");
214225
if (!w) {
215226
const el = document.createElement("div");
216-
el.style.cssText = "width:100%;display:flex;align-items:center;justify-content:center;min-height:80px";
227+
el.style.cssText = "position:relative;width:100%;display:flex;align-items:center;justify-content:center;min-height:80px";
217228
const img = document.createElement("img");
218229
img.style.cssText = "max-width:100%;max-height:220px;image-rendering:auto";
219230
el.appendChild(img);
231+
// Same persistent top-left square as OCIO Player's viewport, and for the same reason: the frames on
232+
// disk can change under a preview that is already drawn (a re-render into the same folder, a retake
233+
// from another graph), and the only honest way back is to re-read them.
234+
const rf = document.createElement("button");
235+
rf.textContent = "↻";
236+
rf.title = "Re-read the written frames from disk";
237+
rf.style.cssText = "position:absolute;top:6px;left:6px;z-index:5;width:26px;height:26px;padding:0;border:0;border-radius:4px;background:" + OV_BASE + ";color:#cde;cursor:pointer;font:16px/1 sans-serif;box-shadow:0 1px 4px rgba(0,0,0,0.5);";
238+
rf.onmouseenter = () => rf.style.background = "rgba(57,57,90,0.95)";
239+
rf.onmouseleave = () => rf.style.background = OV_BASE;
240+
rf.onclick = () => { node._ocioFlipBust = Date.now(); startWriteFlipbook(node); };
241+
el.appendChild(rf);
242+
// A note that replaces the strip when a frame will not load, rather than leaving the last good frame up
243+
// and the timer running - a preview that silently freezes on a stale frame is worse than no preview.
244+
const err = document.createElement("div");
245+
err.style.cssText = "display:none;padding:10px;color:#e6a;font:12px sans-serif;text-align:center";
246+
el.appendChild(err);
220247
w = node.addDOMWidget("__ocio_flip", "div", el, { serialize: false });
221-
w._img = img;
248+
w._img = img; w._err = err;
222249
}
223250
if (node._ocioFlipTimer) clearInterval(node._ocioFlipTimer);
251+
w._err.style.display = "none"; w._img.style.display = "";
252+
w._img.onerror = () => {
253+
if (node._ocioFlipTimer) clearInterval(node._ocioFlipTimer);
254+
node._ocioFlipTimer = null;
255+
w._img.style.display = "none";
256+
w._err.textContent = "cannot read the written frames back for preview - they are on disk at " + seq.src;
257+
w._err.style.display = "";
258+
};
224259
let i = seq.first;
225260
const show = () => {
226-
w._img.src = writeThumbUrl(seq, i);
261+
w._img.src = writeThumbUrl(seq, i, node._ocioFlipBust);
227262
i = (i >= seq.last) ? seq.first : i + 1;
228263
};
229264
show();
@@ -2327,7 +2362,6 @@ function ensurePlayer(node) {
23272362
// so the viewport can be re-pulled anywhere - not just in video mode. Normally slate;
23282363
// turns amber (._stale) when a node was inserted / rewired upstream so the cached frames are stale, until the next
23292364
// render clears it. onClick: video upstream -> re-read the file; else Queue (OCIOPlayer is an OUTPUT_NODE viewer).
2330-
const OV_BASE = "rgba(40,40,64,0.85)", OV_STALE = "rgba(150,95,20,0.92)";
23312365
const refreshOverlay = document.createElement("button");
23322366
refreshOverlay.textContent = "↻";
23332367
refreshOverlay.title = "Refresh this viewport";

0 commit comments

Comments
 (0)