Skip to content

Commit cb06bfb

Browse files
Merge branch 'mainline' into feature/ci-tests
2 parents 51e41d5 + c9d04eb commit cb06bfb

4 files changed

Lines changed: 402 additions & 25 deletions

File tree

src/deadline/cinema4d_adaptor/Cinema4DClient/cinema4d_handler.py

Lines changed: 68 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,19 @@
22
from __future__ import annotations
33

44
import os
5+
import time
56
import traceback
67
from typing import Any, Callable, Dict
78

89
# The Cinema4D Adaptor adds the `deadline` namespace directory to PYTHONPATH,
910
# so that importing just the cinema4d_adaptor should work.
1011
try:
11-
from cinema4d_adaptor.Cinema4DClient import tile_rendering # type: ignore[import]
12+
from cinema4d_adaptor.Cinema4DClient import ocio_bake, tile_rendering # type: ignore[import]
1213
except (ImportError, ModuleNotFoundError):
13-
from deadline.cinema4d_adaptor.Cinema4DClient import tile_rendering # type: ignore[import]
14+
from deadline.cinema4d_adaptor.Cinema4DClient import ( # type: ignore[import]
15+
ocio_bake,
16+
tile_rendering,
17+
)
1418

1519
try:
1620
import c4d # type: ignore
@@ -265,6 +269,14 @@ def _parse_frame_range(frame_value) -> tuple[int, int]:
265269
frame = int(frame_str)
266270
return frame, frame
267271

272+
def _raise_on_render_error(self, result: int) -> None:
273+
"""Raise if a RenderDocument result is an error (or unrecognized)."""
274+
result_description = _RENDERRESULT.get(result)
275+
if result_description is None:
276+
raise RuntimeError("Error: unhandled render result: %s" % result)
277+
if result != c4d.RENDERRESULT_OK:
278+
raise RuntimeError("Error: render result: %s" % result_description)
279+
268280
def start_render(self, data: dict) -> None:
269281
if self.cached_text_was_used_in_previous_frame:
270282
# Close and then reload document since we collapsed some text in the previous frame
@@ -303,33 +315,66 @@ def start_render(self, data: dict) -> None:
303315

304316
width = int(self.render_data[c4d.RDATA_XRES])
305317
height = int(self.render_data[c4d.RDATA_YRES])
306-
if is_tile_render:
307-
bm = tile_rendering.create_tile_bitmap(width, height)
308-
else:
309-
bm = bitmaps.MultipassBitmap(width, height, c4d.COLORMODE_RGB)
310318
rd = self.render_data.GetDataInstance()
311319

312320
self.cached_text_was_used_in_previous_frame = self._cache_text_if_needed(
313321
c4d.BaseTime(start_frame, fps)
314322
)
315323

316-
result = c4d.documents.RenderDocument(
317-
self.doc,
318-
rd,
319-
bm,
320-
c4d.RENDERFLAGS_EXTERNAL | c4d.RENDERFLAGS_SHOWERRORS,
321-
prog=progress_callback,
324+
render_flags = c4d.RENDERFLAGS_EXTERNAL | c4d.RENDERFLAGS_SHOWERRORS
325+
326+
# Non-tile OCIO workaround: RenderDocument's internal save does not bake the
327+
# OCIO View Transform (a Cinema 4D SDK bug), so ordinary renders are written
328+
# un-tone-mapped (dark/"Raw"). We disable the render-time bake and render ONE
329+
# frame per RenderDocument call, so each frame's render-space bitmap can be
330+
# OCIO-baked into its beauty file afterwards. (A single RenderDocument over a
331+
# frame range leaves only the last frame in the bitmap, so the earlier frames
332+
# could not be baked.) See tile_rendering.bake_full_frame_beauty.
333+
# Only 8-bit display output needs the view transform baked (float/EXR stays
334+
# scene-linear), so restrict the per-frame path to that case -- other outputs
335+
# keep the original single range render.
336+
bake_ocio = (
337+
not is_tile_render
338+
and hasattr(c4d, "RDATA_BAKE_OCIO_VIEW_TRANSFORM_RENDER")
339+
and self.render_data[c4d.RDATA_FORMATDEPTH] == c4d.RDATA_FORMATDEPTH_8
322340
)
323-
324-
result_description = _RENDERRESULT.get(result)
325-
if result_description is None:
326-
raise RuntimeError("Error: unhandled render result: %s" % result)
327-
if result != c4d.RENDERRESULT_OK:
328-
raise RuntimeError("Error: render result: %s" % result_description)
329-
330-
# Post-render tile processing: OCIO bake, crop, save tile, restore paths
331-
if is_tile_render and tile_ctx is not None:
332-
tile_rendering.finalize_tile_render(bm, rd, tile_ctx, self.render_data, start_frame)
341+
if bake_ocio:
342+
# Disable the render-time bake, restoring it afterwards (the document is
343+
# reused across renders in a session) -- mirrors the tile path, which saves
344+
# and restores this flag in finalize_tile_render.
345+
orig_bake_flag = self.render_data[c4d.RDATA_BAKE_OCIO_VIEW_TRANSFORM_RENDER]
346+
self.render_data[c4d.RDATA_BAKE_OCIO_VIEW_TRANSFORM_RENDER] = False
347+
try:
348+
for frame in range(start_frame, end_frame + 1):
349+
self.render_data[c4d.RDATA_FRAMEFROM] = c4d.BaseTime(frame, fps)
350+
self.render_data[c4d.RDATA_FRAMETO] = c4d.BaseTime(frame, fps)
351+
frame_rd = self.render_data.GetDataInstance()
352+
# Render into a float bitmap so the OCIO view transform is baked from
353+
# full-precision render-space data (baking 8-bit data would band the
354+
# gradients) -- same rationale as the tile path's create_tile_bitmap.
355+
frame_bm = bitmaps.MultipassBitmap(width, height, c4d.COLORMODE_RGBf)
356+
render_start = time.time()
357+
result = c4d.documents.RenderDocument(
358+
self.doc, frame_rd, frame_bm, render_flags, prog=progress_callback
359+
)
360+
self._raise_on_render_error(result)
361+
ocio_bake.bake_full_frame_beauty(
362+
frame_bm, frame_rd, self.render_data, self.doc, frame, render_start
363+
)
364+
finally:
365+
self.render_data[c4d.RDATA_BAKE_OCIO_VIEW_TRANSFORM_RENDER] = orig_bake_flag
366+
else:
367+
if is_tile_render:
368+
bm = tile_rendering.create_tile_bitmap(width, height)
369+
else:
370+
bm = bitmaps.MultipassBitmap(width, height, c4d.COLORMODE_RGB)
371+
result = c4d.documents.RenderDocument(
372+
self.doc, rd, bm, render_flags, prog=progress_callback
373+
)
374+
self._raise_on_render_error(result)
375+
# Post-render tile processing: OCIO bake, crop, save tile, restore paths
376+
if is_tile_render and tile_ctx is not None:
377+
tile_rendering.finalize_tile_render(bm, rd, tile_ctx, self.render_data, start_frame)
333378

334379
print("Finished Rendering")
335380

@@ -375,7 +420,7 @@ def get_child_takes(take):
375420
all_takes.extend(get_child_takes(child_take))
376421
return all_takes
377422

378-
main_take = take_data.GetCurrentTake()
423+
main_take = take_data.GetMainTake()
379424
all_takes = [main_take] + get_child_takes(main_take)
380425

381426
matched_take = None
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
"""OCIO view-transform baking for non-tile renders.
3+
4+
Works around a Cinema 4D SDK bug: ``RenderDocument``'s internal save does not apply
5+
the OCIO View Transform, so ordinary (non-tile) renders to 8-bit display formats are
6+
written un-tone-mapped (dark/"Raw"). This bakes the view transform into the beauty
7+
image after the render -- the tile path (tile_rendering.finalize_tile_render) already
8+
does the equivalent for tiles.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import os
14+
from typing import Any
15+
16+
try:
17+
import c4d # type: ignore
18+
except ImportError: # pragma: no cover
19+
raise OSError("Could not find the Cinema4D module. Are you running this inside of Cinema4D?")
20+
21+
try:
22+
from cinema4d_adaptor.Cinema4DClient.tile_rendering import ( # type: ignore[import]
23+
C4D_VERSION_2025_2,
24+
get_format_info,
25+
)
26+
except ImportError:
27+
from deadline.cinema4d_adaptor.Cinema4DClient.tile_rendering import ( # type: ignore[import]
28+
C4D_VERSION_2025_2,
29+
get_format_info,
30+
)
31+
32+
33+
def _resolve_render_path(doc: Any, render_data: Any, render_bc: Any, frame: int, path: str) -> str:
34+
"""Resolve a C4D render-path's tokens ($take, $frame, $res, ...) using C4D's own
35+
token system -- the same resolver C4D uses at save time. Returns ``path``
36+
unchanged if the token system is unavailable or resolution fails.
37+
38+
Note: this expands tokens only; C4D still appends the frame number + extension to
39+
the result at save time (per RDATA_NAMEFORMAT), so the return value is the output
40+
BASE (a filename prefix), not the full final path.
41+
"""
42+
tokensystem = getattr(c4d.modules, "tokensystem", None)
43+
if tokensystem is None or not hasattr(tokensystem, "FilenameConvertTokens"):
44+
return path
45+
take_data = doc.GetTakeData()
46+
rp_data = {
47+
"_doc": doc,
48+
"_rData": render_data,
49+
"_rBc": render_bc,
50+
"_frame": frame,
51+
"_take": take_data.GetCurrentTake() if take_data else None,
52+
}
53+
try:
54+
return tokensystem.FilenameConvertTokens(path, rp_data)
55+
except Exception:
56+
return path
57+
58+
59+
def bake_full_frame_beauty(
60+
bm: Any,
61+
rd: Any,
62+
render_data: Any,
63+
doc: Any,
64+
frame: int,
65+
render_start_time: float,
66+
) -> None:
67+
"""Bake the OCIO view transform into a single non-tiled beauty frame.
68+
69+
Works around a Cinema 4D SDK bug: ``RenderDocument``'s internal save does not
70+
apply the OCIO View Transform, so the beauty image is written un-tone-mapped
71+
(dark/"Raw"). Requires the render to have run with
72+
``RDATA_BAKE_OCIO_VIEW_TRANSFORM_RENDER`` disabled so ``bm`` holds render-space
73+
data. No-op on pre-2025.2 Cinema 4D or non-8-bit output (float EXR etc. must stay
74+
scene-linear).
75+
76+
Call once per rendered frame (``bm`` holds one frame). The target file is found by
77+
resolving the render output path's tokens with C4D's own token system and matching
78+
only files under that exact base name -- so we never touch an unrelated file in the
79+
folder. C4D's ``A_`` alpha file is excluded for free (it does not start with the
80+
beauty base); multi-pass files are excluded by their own resolved base. Among
81+
matches, the file written by this render (newest mtime at/after
82+
``render_start_time``) is baked, which is retry-safe.
83+
84+
Args:
85+
bm: The rendered MultipassBitmap for one frame (render-space).
86+
rd: The live render data instance (from GetDataInstance).
87+
render_data: The render data object (output path / format / tokens).
88+
doc: The active document (for token resolution).
89+
frame: The frame number this render produced (for $frame resolution).
90+
render_start_time: ``time.time()`` captured just before this frame's render.
91+
"""
92+
if not (
93+
hasattr(c4d, "RDATA_BAKE_OCIO_VIEW_TRANSFORM_RENDER")
94+
and hasattr(c4d.documents, "BakeOcioViewToBitmap")
95+
):
96+
return
97+
if rd[c4d.RDATA_FORMATDEPTH] != c4d.RDATA_FORMATDEPTH_8:
98+
return # only display-referred 8-bit output needs the view transform baked in
99+
100+
beauty_path = render_data[c4d.RDATA_PATH] or ""
101+
if not beauty_path:
102+
return
103+
# Resolve tokens ($take, $frame, ...) with C4D's own resolver, then anchor on the
104+
# exact base name C4D derives -- C4D appends the frame number + extension to it.
105+
resolved_base = _resolve_render_path(doc, render_data, rd, frame, beauty_path)
106+
beauty_dir = os.path.dirname(resolved_base) or "."
107+
beauty_prefix = os.path.basename(resolved_base)
108+
if not beauty_prefix or not os.path.isdir(beauty_dir):
109+
return
110+
ext, save_filter = get_format_info(render_data[c4d.RDATA_FORMAT])
111+
112+
# Multi-pass files can share the beauty prefix (e.g. "beauty" vs "beauty_mp"), so
113+
# exclude them by their own resolved base -- but only when that base is a MORE
114+
# specific (longer) match than the beauty base (see the loop below). The "A_" alpha
115+
# file needs no explicit exclusion -- it does not start with the beauty base.
116+
mp_prefix = ""
117+
if render_data[c4d.RDATA_MULTIPASS_SAVEIMAGE] and render_data[c4d.RDATA_MULTIPASS_FILENAME]:
118+
mp_resolved = _resolve_render_path(
119+
doc, render_data, rd, frame, render_data[c4d.RDATA_MULTIPASS_FILENAME]
120+
)
121+
mp_prefix = os.path.basename(mp_resolved)
122+
123+
# The beauty file this render wrote: derived from C4D's resolved output base,
124+
# correct extension, not a multi-pass file, and modified at/after this render's
125+
# start (never re-bakes a stale file; a retry that overwrites in place is caught).
126+
target = None
127+
target_mtime = render_start_time
128+
for fn in os.listdir(beauty_dir):
129+
if not fn.startswith(beauty_prefix):
130+
continue
131+
if not fn.lower().endswith(ext.lower()):
132+
continue
133+
# A file is multi-pass only when the multi-pass base is a longer (more
134+
# specific) prefix than the beauty base. Guarding on length -- not just
135+
# inequality -- keeps a beauty file whose base merely starts with a shorter
136+
# multi-pass base (e.g. beauty "render_beauty", mp "render") from being
137+
# wrongly skipped, and still excludes real multi-pass files whose base
138+
# extends the beauty base (e.g. beauty "render", mp "render_mp").
139+
if mp_prefix and len(mp_prefix) > len(beauty_prefix) and fn.startswith(mp_prefix):
140+
continue # multi-pass file -- leave as-is
141+
full = os.path.join(beauty_dir, fn)
142+
try:
143+
mtime = os.path.getmtime(full)
144+
except OSError:
145+
continue
146+
if mtime >= target_mtime:
147+
target = full
148+
target_mtime = mtime
149+
150+
if target is None:
151+
print("OCIO view transform NOT baked: no beauty file found from this render")
152+
return
153+
154+
baked = c4d.documents.BakeOcioViewToBitmap(bm, rd, c4d.SAVEBIT_NONE)
155+
bm = baked or bm
156+
if c4d.GetC4DVersion() >= C4D_VERSION_2025_2:
157+
bm.SetColorProfile(c4d.bitmaps.ColorProfile(), c4d.COLORPROFILE_INDEX_DISPLAYSPACE)
158+
bm.SetColorProfile(c4d.bitmaps.ColorProfile(), c4d.COLORPROFILE_INDEX_VIEW_TRANSFORM)
159+
bm.Save(target, save_filter)
160+
print(f"OCIO view transform baked into beauty output: {os.path.basename(target)}")

test/unit/deadline_adaptor_for_cinema4d/Cinema4DClient/test_cinema4d_handler.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def test_set_take_not_found_raises_error(self, mock_get_doc: Mock):
8282
mock_take_a.GetChildren.return_value = []
8383

8484
mock_take_data = Mock()
85-
mock_take_data.GetCurrentTake.return_value = mock_main_take
85+
mock_take_data.GetMainTake.return_value = mock_main_take
8686
mock_main_take.GetChildren.return_value = [mock_take_a]
8787

8888
mock_doc = Mock()
@@ -109,7 +109,7 @@ def test_set_take_found_sets_take(self, mock_get_doc: Mock):
109109
mock_take_a.GetChildren.return_value = []
110110

111111
mock_take_data = Mock()
112-
mock_take_data.GetCurrentTake.return_value = mock_main_take
112+
mock_take_data.GetMainTake.return_value = mock_main_take
113113
mock_main_take.GetChildren.return_value = [mock_take_a]
114114

115115
mock_doc = Mock()
@@ -119,6 +119,50 @@ def test_set_take_found_sets_take(self, mock_get_doc: Mock):
119119
handler.set_take({"take": "A"})
120120
mock_take_data.SetCurrentTake.assert_called_once_with(mock_take_a)
121121

122+
@patch(
123+
"deadline.cinema4d_adaptor.Cinema4DClient.cinema4d_handler.c4d.documents.GetActiveDocument"
124+
)
125+
def test_set_take_from_non_parent_current_take(self, mock_get_doc: Mock):
126+
"""Verify that set_take finds and sets a take even if the active take is not an ancestor of the target take."""
127+
handler = Cinema4DHandler(mock_map_path)
128+
129+
# Hierarchy:
130+
# Main
131+
# / \
132+
# Take A Take B
133+
# |
134+
# Take A1 (current active take)
135+
mock_main_take = Mock()
136+
mock_main_take.GetName.return_value = "Main"
137+
138+
mock_take_a = Mock()
139+
mock_take_a.GetName.return_value = "Take A"
140+
141+
mock_take_a1 = Mock()
142+
mock_take_a1.GetName.return_value = "Take A1"
143+
mock_take_a1.GetChildren.return_value = []
144+
145+
mock_take_b = Mock()
146+
mock_take_b.GetName.return_value = "Take B"
147+
mock_take_b.GetChildren.return_value = []
148+
149+
mock_main_take.GetChildren.return_value = [mock_take_a, mock_take_b]
150+
mock_take_a.GetChildren.return_value = [mock_take_a1]
151+
152+
mock_take_data = Mock()
153+
# Current active take is Take A1 (which has no children)
154+
mock_take_data.GetCurrentTake.return_value = mock_take_a1
155+
# GetMainTake returns Main take, which roots the entire hierarchy
156+
mock_take_data.GetMainTake.return_value = mock_main_take
157+
158+
mock_doc = Mock()
159+
mock_doc.GetTakeData.return_value = mock_take_data
160+
mock_get_doc.return_value = mock_doc
161+
162+
# Setting take to "Take B" should succeed because search starts from GetMainTake()
163+
handler.set_take({"take": "Take B"})
164+
mock_take_data.SetCurrentTake.assert_called_once_with(mock_take_b)
165+
122166

123167
class TestShouldCacheText:
124168
"""Tests for the use_cached_text method"""

0 commit comments

Comments
 (0)