Bernard Review - Developer IPC API Guide

Welcome to the official developer guide for the Inter-Process Communication (IPC) API of Bernard Review.

This document describes how to programmatically control the player, query its status, and receive real-time playback event callbacks. It is designed for pipeline engineers, technical directors (TDs), and technical artists who want to integrate Bernard Review into studio environments (such as Autodesk Maya, Foundry Nuke, SideFX Houdini, or production tracking systems like Autodesk Flow Production Tracking / ShotGrid, and Kitsu).


Table of Contents

  1. Understanding IPC & Protocol Specifications
  2. Command Reference (Control payloads)
  3. Event Reference (Callbacks)
  4. Status Query Details
  5. The Python Client API (bernard_api.py)
  6. Studio Integration Examples (Maya & Nuke)
  7. ShotGrid & Kitsu Web Sync Daemons
  8. Full Review Round-Trip: Playlist In, Notes Out (Bernard Engine / ShotGrid / ftrack)
  9. NOVA (.nvfx)
  10. GPU YUV upload for movies (beta)
  11. GPU video decode (beta)
  12. GPU ProRes decode (beta)

1. Understanding IPC & Protocol Specifications

What is Inter-Process Communication?

When you are working in a studio, you often want different software to communicate with each other. For example, when you change the current frame in Maya, you want Bernard Review to instantly jump to the same frame. To achieve this, Bernard Review runs a lightweight TCP Socket Server in the background on your local computer.

Protocol Details

Every request gets a reply

Every line you send gets exactly one reply line of its own, in the order sent -- usually within one frame of the player (a few milliseconds). There is no request-ID field: a client matches a reply to its request by the reply's "cmd", which is the name it sent. A reply is "status": "ok" or "status": "error"; an error always carries a "reason" in plain words, and never waits for your time-out.

You sendThe reply
a command the player does not know, {"cmd": "no-such-command"}{"status": "error", "cmd": "no-such-command", "reason": "unknown command: no-such-command"}
a line that is not JSON -- here Python's str() of a dict instead of json.dumps(): {'cmd': 'play'}{"status": "error", "reason": "invalid JSON at byte 2: a request is one JSON object per line, with a \"cmd\" string"}
JSON that is not an object (42, ["play"]){"status": "error", "reason": "a request must be a JSON object with a \"cmd\" string"}
an object with no cmd, {"frame": 3}{"status": "error", "reason": "missing \"cmd\": a request must be a JSON object with a \"cmd\" string"}
a cmd that is not a non-empty string, {"cmd": 7}{"status": "error", "reason": "\"cmd\" must be a non-empty string"}
a known command without a field it needs, {"cmd": "seek"}{"status": "error", "cmd": "seek", "reason": "seek: a required field is missing, nothing was done (see api/README.md)"}
a field of the wrong type that made the command fail, {"cmd": "set-fps", "value": "fast"}{"status": "error", "cmd": "set-fps", "reason": "set-fps failed: [json.exception.type_error.302] type must be number, but is string"}

The Console window: the same commands, inside the player

Window > Console (Ctrl+Shift+C) is a second client of this dispatcher that needs no socket: a line typed there -- status, seek {"frame": 1001}, or a request exactly as this page writes it, {"cmd": "seek", "frame": 1001} -- becomes one request, read by the same code as a socket line and answered by the same rules above; the reply is shown in the Console, indented, and never sent to the client connected to the port (that client's own requests and events are not shown there). Tab completes a name from the list of commands this page documents, taken from the dispatcher itself when the player is built. The Console can do nothing a socket client cannot, runs no shell command, and asks before quit, export, export-recipe, export-notes, save-playlist, screenshot, dump-thumbnails, cache-purge, tracker-publish and tracker-download-media unless the line starts with !. See the user guide, section 11.1.


2. Command Reference (Control Payloads)

To control the player, open a TCP socket, connect to port 5556, and send the corresponding JSON line.

Playback Controls

Audio Controls

Layout & Comparison Controls

Loading Media & Playlists

Playlist Management

Transport & Timeline Range

Viewport

Contact Sheet

What the Viewer Shows (View)

Annotation Tool

Notes Export (Production Tracking)

Production Trackers (Kitsu, ftrack, Flow)

The commands of File > Tracker... (user guide 6.6). Each replies at once, "queued": true when a call to the tracker was started, and the result comes afterwards as an event. Every call runs on the tracker thread; one at a time, in order. No secret travels through the IPC: a request that carries a password, api_key, key, token, secret or client_secret is refused. A site's secret comes from the credential store (the window's Remember) or from BERNARD_TRACKER_<ID>_SECRET. No reply, event or status field holds a secret, a token or a signed URL.

Frame & Movie Export

Production-Tracking Metadata (x_tracking)

Every playlist item can now carry an optional x_tracking block -- the extension described in Section 8 is implemented. Fields: shot_id, shot_name, version_id, shotgrid_version_id (int), ftrack_id, artist, task. Set it when building the .bpl you pass to load-playlist (see playlist.schema.json); it's saved back out with the playlist (.bpl save/autosave) and round-tripped into export-notes' output per shot (see notes_payload.schema.json) -- so a connector script can route a note back to the right ShotGrid Version / ftrack entity directly by ID, with no filename matching needed.

OCIO context variables (x_tracking.ocio_context, optional, OCIO step 3): an object of strings, the variables a studio's OCIO config reads for this clip ($SHOT, $SEQ, ... in a look's file name, for example a per-shot CDL through a look whose FileTransform is ${SEQ}/${SHOT}.cc), e.g. {"SEQ": "sq010", "SHOT": "sh010"}. SHOT is otherwise x_tracking.shot_name (else shot_id) and TASK is x_tracking.task; a variable of ocio_context wins over those two. A name is [A-Za-z_][A-Za-z0-9_]* (64 bytes at most); a value is one path component: not empty, 128 bytes at most, without /, \, :, $ or a control character, and not . or ..; 16 variables at most. A bad entry is skipped and logged. A variable the clip does not give takes the config's own default (its environment: section). The process environment is never read, and nothing is guessed from file names. The key is kept through the .bpl, the autosave and export.json like any other x_tracking key.

Bounds over the IPC (playlist-add, build-matrix, play-version, media-ready):

Past a bound, the command answers {"status": "error", "cmd": ..., "reason": ...}: the reason names the field and its size. Nothing of the command is applied. earlier-notes has bounds of its own, in its entry (Diagnostics).

A key of the block that is none of those fields (nor a tracker's below) is a pipeline's own (Bernard Engine puts {"bernard": {"file_id": ..., "version": ...}} there): it is kept as it came, any JSON value, through the .bpl save, the autosave and export-notes' export.json, after the fields above (older versions dropped such a key when the playlist was loaded).

An item loaded from a tracker playlist also carries tracker_kind (kitsu, ftrack, flow), tracker_site (the site's host), task_id, playlist_id, media_ref (the media's URL path on the site, for Flow the key-free /api/v1.1/entity/versions/<id>/uploaded_movie/<attachment id>, or the studio path the tracker gave) and, once a note was published from it, published ({note_id, hash, at}: the tracker's note id, the fingerprint of what was sent, so that an unchanged note is not published twice, and when, in UTC). They are saved in the .bpl only when set, so a playlist from before them loads as it always did, and export-notes writes each of them when set. A row loaded from a tracker without a path (its media not on this machine) is kept by the .bpl with its ids, its name and version from shot_name and version_id. The preview's size is not saved: unknown once the .bpl is reopened.

A row of the site connected whose status was picked in the status menu, when it is not what one of the three review keys sends, also carries studio_status (the status's id on the site) and studio_status_of (the player's status it gave the row: another status given since makes it void). It is what a publication with status sends for that row; .bpl and autosave keep it; export.json does not.

Color & Grading

Playback Rate & Frame Numbering

Performance & Cache

These mirror the controls in the Cache & Performance tab of the Inspector.

Compression cache

The compression cache keeps compressed copies of the image sequences you open (NOVA q97 or q92, later lossless EXR HTJ2K) on this machine only, in your own profile (%LOCALAPPDATA%\BernardReview\CompressionCache on Windows, ~/Library/Caches/BernardReview/CompressionCache on macOS, $XDG_CACHE_HOME/bernard-review/compression-cache or ~/.cache/bernard-review/compression-cache on Linux), never in a shared folder: the copies are a double of the source images. A sequence is recognized by the content of its frames, not by its path: moved, renamed, renumbered or copied, it finds its copies again. The cache empties itself when it is opened and then every hour, in this order: sequences not read for 30 days; above its maximum size (20 % of the disk by default), least recently read first; while the disk has less than 10 % free, least recently read first. Pinned sequences and the one on screen are never removed automatically.

What exists today

The player does not compress anything yet: this version has the cache's store and these two commands only, so the cache stays empty. The store is opened, and its folder created, at the first cache-info or cache-purge of a session, which is also when its hourly clean-up starts.

Multi-Layer / AOV

Diagnostics

Dictated Notes (local AI)

The setting Dictated notes (local AI) (Preferences, General page, card Dictation (BETA); off by default) lets a reviewer speak notes while the shots play. The speech engine runs on this computer: the audio never leaves it and is never written to disk. Each phrase becomes a draft of the shot and frame on screen when the speaker began it; a draft is marked AI in the notes panels (Essential, Review & Notes) and waits for Validate, Edit or Discard: it is never a note on its own, and nothing writes it out (session, export, tracker, guests) until it is validated -- then only as the note's text. Drafts live in the player's memory, not in the .bpl. The engine needs its two model files in the player's models folder (%LOCALAPPDATA%\BernardReview\models under Windows); without them, state is failed, models.needed lists them and models.question is what the notes panel asks. The player downloads them only after a yes (the panel's Download, or download: true here), checks each file's size and SHA-256 against its catalogue, and refuses -- deletes -- any other file.

Closing the Player

The installers register the bernardreview:// scheme (docs/site/en/trackers.md, "Open in Bernard Review from Flow"): the browser starts BernardReview.exe "bernardreview://flow?...", or the launch hands the link to the player already open (the single-instance channel's open-link message), and leaves with 0. The link is untrusted input: only entity_type (Playlist or Version), ids, selected_ids, project_id and server_hostname are read; the ids must be whole numbers (500 at most), the link 16 KiB at most, the host a site of trackers.json, else it is refused and nothing is sent. Kitsu (its custom action through a relay page: ours, or tools/kitsu_relay.py hosted by the studio, which keeps nothing; docs/site/en/trackers.md): bernardreview://kitsu?entity_type=Task&selected_ids=<task ids>&project_id=<id>&server_hostname=<host>, Task only and every id a Kitsu UUID; each task opens as its latest preview (the highest revision), in the link's order, the first one on screen (or, without its preview, the first that has one); the host must be a Kitsu site of trackers.json. ftrack (its action answered by tools/ftrack_action.py on the user's machine, through ftrack Connect or alone; docs/site/en/trackers.md): bernardreview://ftrack?entity_type=Version&selected_ids=<version ids>&server_hostname=<host> (those versions, in the link's order, the first one on screen) or entity_type=Playlist with one review session or list id (the whole of it); every id an ftrack UUID; the host must be an ftrack site of trackers.json. A refusal, or the end of the opening, gives the open-link event and status.tracker.open_link. Nothing of a link is ever run.

Running two players on one machine

A clip opened by itself next to a player in use: BernardReview.exe --singleshot --ipc-port <n> <file>. The process started that way copies the settings of the profile (session_config.json, imgui.ini, keymap, naming rules, color presets, layouts, export presets and recipes) into a private folder of the single shots' own folder in the temp folder (%TEMP%\BernardReview-singleshot\<pid>-<start>; on Linux /tmp/BernardReview-singleshot-<uid>, the user's own, 0700), runs the player on it (APPDATA there on Windows, XDG_CONFIG_HOME on Linux: only the configuration; the cache -- the compression cache -- stays the user's, shared with the other player, whose upkeep it is: a single shot never evicts from it, and refuses cache-purge), waits for it, then removes the folder and exits with the player's code: the single shot's autosave, history, layout and session file never replace the other player's. The launcher and its player hold a lock file beside the folder while they run: a launcher killed or a player crashed leaves its folder, which the next single shot removes once nobody holds its lock -- never one still in use. Only that folder of the temp folder is ever read. A launcher that cannot start -- its folder unusable (a file at its name; on Linux, another user's folder), its lock or its player refused -- says why on its error output and exits with code 71. The single shot never asks to restore the last session and answers on the IPC only on the port it was given (none without --ipc-port). quit on that port closes it, and the launcher with it. On macOS the profile is shared; the two other rules hold.

A review driven by a pipeline adds --review-dir <dir>: at the close, whatever closed the player, the review notes are written as export-notes writes them, without the "save your review session?" question, then review-exported is sent (see the Event Reference); as soon as the window hides, review-exporting says how many pictures follow, and the pictures of several shots are written at once (one worker per shot, its own decoder, as many as the cores, 8 at most, fewer when memory is short). They are written beside <dir>, in <dir>.part, export.json first, then that folder is renamed onto <dir>: a new <dir> never holds half a review, whatever stopped the player (create <dir>'s parent and leave <dir> to the player; a root, a drive or a name without a folder above it is never written to). A <dir> already there, or a rename refused (a scanner holding a file), gets the files one by one instead -- the pictures, export.html, then export.json -- and keeps the files the player did not write; a file that cannot be moved stays in <dir>.part, and the others go on. A <dir>.part left by a player stopped mid-export is marked as the player's by a .bernard-review-part file (or is empty): the next export to <dir> empties it; anything else at that name is left alone, and nothing is exported. review-exported comes within five minutes of the close: past 280 seconds, what is written (export.json first of all) is put in <dir> as it is, nothing more is written, and the event says which frames are missing.

Exit codes of a review launch. A launch with --review-dir that would be handed to the player already open (the "open in the running player" setting, without --ipc-port or --singleshot) is refused: the running player would never write that review. The refusal is a line in the log and on the error output, and the process exits with code 75 without opening anything. Start such a review with --singleshot or --ipc-port <n>, which always run a player of its own. A --singleshot launch that cannot start (its private folder, its lock or its player refused) says why on the error output and exits with code 71; a single shot that started exits with its player's code. Code 0 is a normal close (or files handed to the running player without --review-dir); 3 (Windows) and 128 + a signal number (Linux, macOS) mean the player crashed, its report in crashes/.

Installing the player from a pipeline (Windows). BernardReview-<version>-win64.exe /S /NOASSOC /D=<folder> installs without a window. /NOASSOC writes no file association at all, whatever the components page would have chosen: the pipeline decides which files open in the player. /D= comes last, without quotes. A silent install asks nothing, so it also uninstalls the old player's first versions, from before the rename, without asking (the question an interactive install asks is answered yes). It exits with code 3 when a player runs from the installation folder, and then writes nothing. Python: client.on_review_changed(cb), client.on_playlist_changed(cb) and client.on_review_exported(cb) in bernard_api.py receive the three events above.

The player listens on port 5556 by default. Start a second instance with BernardReview.exe --ipc-port 5599 and connect with BernardClient(port=5599) (or python player-cli.py --port 5599 ...); otherwise both instances answer on the same port and a script cannot tell which one it is driving.

Audio diagnostics in status

status.audio carries, besides volume and muted: out_rms, the RMS of the last block the audio callback wrote (0 means it wrote silence -- the one number that answers "is there sound?" from a script); full_audio_ready and full_audio_seconds, whether the whole track of the clip on track A has been decoded and how long it is; decoded_seconds, how far the streaming ring buffer has been filled by the video decoder.

Synchronised Review Sessions

A session is one player hosting a WebSocket server on TCP 9000 and every other seat connecting to it. Everything below is also reachable from the UI; the commands exist so a two-machine review can be set up and checked from a script. The host enforces the session's id, the password and the pilot role, so a guest that sends transport or annotations while not driving, or a message for another session, is simply dropped (see dropped_by_server); so is a message a seat sends under another seat's sender_id, and a Host badge (is_host) only ever stays on the host's own messages (see server_guard). It refuses a web page's WebSocket (a handshake with an Origin header) with 403.

Introspection (debugging & pipeline health checks)


3. Event Reference (Callbacks)

When an artist interacts with Bernard Review (e.g. presses the Spacebar, drags the timeline, or loads a new playlist), the player automatically broadcasts JSON lines to all connected TCP clients. You can listen to these events to trigger actions in your script.


4. Status Query Details

By sending {"cmd": "status"}\n, you can query the complete state of the player. This is useful for initializing a UI or syncing client state upon connection.

Status Response Example

{
  "status": "ok",
  "version": "1.0.0NNN",
  "public_version": "1.0",
  "theme": "sombre",
  "theme_requested": "sombre",
  "theme_system": null,
  "theme_rebuilds": 0,
  "theme_font_builds": 0,
  "playback": {
    "state": "playing",
    "currentFrame": 23,
    "reverse": false,
    "startOffset": -1,
    "displayFrame": 1024,
    "firstFrameNumber": 1001,
    "defaultStartFrame": 1001,
    "timecode": "00:00:42:16",
    "dropFrame": false,
    "fps": 24.0,
    "ab_mode": false,
    "ab_view": "wipe",
    "loop_iterations_missed": 0,
    "dropped_frames": 0,
    "frames_due": 1440,
    "frames_on_time": 1440,
    "late_frames": 0,
    "lost_frames": 0,
    "longest_iteration_ms": 17.7,
    "presented_fps": 24.0,
    "late_tolerance_ms": 25.0,
    "display_hz": 60,
    "related_clips": {"count": 4, "fingerprint": "9c1e07a2", "computes": 3}
  },
  "decode": {
    "valid": true,
    "ms_per_frame": 117.4,
    "avg_decode_ms": 117.4,
    "samples": 12,
    "scale": 0.25,
    "newest_age_s": 0.04,
    "track_b": {"valid": false, "ms_per_frame": 0.0, "avg_decode_ms": 0.0, "samples": 0, "scale": 0.0, "newest_age_s": 0.0},
    "budget_ms": 41.67,
    "too_slow": true,
    "losing_frames": true,
    "warning": true,
    "slow_decode_tier": "auto",
    "rule_active": true,
    "filling": false,
    "base_scale": 0.25,
    "tier_cap": 1.0,
    "lowered": false,
    "last_change": null,
    "read_a": {"failing": false, "failures": 0, "frame": -1, "seconds": 0.0, "error": "", "unreadable": false, "last_event": null, "frames": 814},
    "read_b": {"failing": false, "failures": 0, "frame": -1, "seconds": 0.0, "error": "", "unreadable": false, "last_event": null, "frames": 0},
    "counts": {"sequence": 48, "sequence_served_from_ram": 0, "video_a": 814, "video_b": 0, "video_fill": 0, "video_b_held_skips": 0, "video_b_codec_frames": 0}
  },
  "color": {
    "exposure": 1.25,
    "gamma": 2.2,
    "grade": {"exposure": 1.25, "gamma": 2.2, "rgb": [1.0, 1.0, 1.0], "white_balance_temp": 0.35,
              "white_balance_tint": -0.2, "offset": [0.05, 0.0, 0.0], "saturation": 1.0, "mask": 0,
              "input_space": 1, "display_space": 1, "...": "the other keys of a .bpl item's grading"},
    "channel": "RGBA",
    "channel_b": "RGBA",
    "channels_linked": true
  },
  "audio": {
    "volume": 0.8,
    "muted": false
  },
  "viewport": {
    "decoded_width": 4096,
    "decoded_height": 2160,
    "native_width": 8192,
    "native_height": 4320,
    "source_width": 8192,
    "source_height": 4320,
    "retiering": false,
    "frame_a": 120,
    "frame_b": -1,
    "proxy_idx": 1,
    "proxy_scale": 0.5,
    "zoom": 1.0,
    "fit_mode": true,
    "fullscreen": false,
    "timeline_reveal": 1.0,
    "picture_rect": [744.2, 50.0, 1951.6, 1340.0],
    "ab_labels": []
  },
  "track_b": {
    "path": "D:/shots/sh010/plate/sh010_plate.1001.exr",
    "is_video": false,
    "frames": 96,
    "edit_target": 1,
    "wipe_position": 0.3,
    "wipe_angle": 90.0,
    "exposure": 1.5,
    "session_restore": "restored",
    "pinned": true,
    "last_switch": "kept",
    "from_playlist": {
      "row": 1, "how": "menu", "dragged_row": -1, "over_b": false, "menu_entry": null,
      "track_b_row": [12.0, 986.0, 1908.0, 1019.0],
      "cache": {"key": "seq_b_d:/shots/sh010/plate/sh010_plate.1001.exr",
                "shared_from": "d:/shots/sh010/plate/sh010_plate.1001.exr",
                "frames": 96, "own_frames": 0, "shared_frames": 96, "own_bytes": 0.0}
    },
    "version": "v002",
    "versions": [
      {"name": "v001", "path": "D:/shots/sh010/comp/sh010_comp_v001.1001.exr", "current": false},
      {"name": "v002", "path": "D:/shots/sh010/comp/sh010_comp_v002.1001.exr", "current": true},
      {"name": "v003", "path": "D:/shots/sh010/comp/sh010_comp_v003.1001.exr", "current": false}
    ],
    "version_prev": "v001",
    "version_next": "v003",
    "version_preload": {
      "enabled": true,
      "cap_bytes": 3972844748,
      "other_layers_bytes": 0,
      "held_bytes": 21233664,
      "versions": [
        {"version": "v001", "frame": 2, "resident": true, "bytes": 10616832},
        {"version": "v003", "frame": 2, "resident": true, "bytes": 10616832}
      ]
    },
    "last_version_step": {"from": "v001", "to": "v002", "direction": "next", "preloaded": true, "ms": 1.5}
  },
  "ram_fit": {
    "common_tier": 0,
    "common_scale": 1.0,
    "ceiling_px": 0,
    "fits": true,
    "status": "fits",
    "suggested_fixed_tier": -1,
    "unmeasured_count": 0,
    "estimated_bytes": 1048412160.0,
    "budget_bytes": 1073741824.0,
    "streamed_video_seconds": 120.0,
    "track_b": {
      "present": true,
      "path": "D:/shots/sh020/sh020.1001.exr",
      "in_playlist": false,
      "proxy_idx": 4,
      "effective_proxy_idx": 1,
      "effective_proxy_scale": 0.5,
      "estimated_bytes": 254803968.0,
      "streamed": false,
      "measured": true,
      "resident": 12,
      "shares_row": -1,
      "shares": false,
      "own_bytes": 0.0
    }
  },
  "playlist": [
    {
      "index": 0,
      "path": "D:/shots/sh010/sh010.1001.exr",
      "proxy_idx": 4,
      "effective_proxy_idx": 1,
      "effective_proxy_scale": 0.5,
      "estimated_bytes": 199065600.0,
      "streamed": false,
      "measured": true
    }
  ]
}

Response Properties


5. The Python Client API (bernard_api.py)

We provide a pre-built Python module bernard_api.py that wraps the socket protocol into simple object-oriented methods and an event-callback system.

Basic Client Usage

from bernard_api import BernardClient
import time

# Create a client instance pointing to localhost, port 5556
client = BernardClient(host="127.0.0.1", port=5556)

# Connect to the player. This starts a background thread to listen for events.
if client.connect():
    # Load a video file
    client.open_media("C:/videos/shot_01.mov")
    
    # Wait for 1 second, then play
    time.sleep(1)
    client.play()

Errors

request() (and every method that returns the player's reply) returns an error reply as it returns any other: at once, as a dict. It does not raise; None still means a time-out or a lost connection.

reply = client.request({"cmd": "no-such-command"})
if reply is None:
    print("no reply: timed out or disconnected")
elif reply.get("status") == "error":
    print("refused:", reply["reason"])   # "unknown command: no-such-command"

request() raises ValueError, before anything is sent, only for a request it cannot build: no cmd, a cmd that is not a string, or a value JSON cannot carry (NaN, infinity). A refusal with no cmd in it (a hand-built line that was not JSON, sent with send_command()) goes to the callbacks registered with client.on("error", ...).

The Steps view from a script

The Steps view's stack is built and driven with the steps_* methods, each a wrapper of steps (section 2, "What the Viewer Shows"); each returns the player's reply, the stack's state, or its refusal with a reason. Ask the player how many steps a stack holds (steps_max(), the state's max_steps) rather than assume a number.

import time

client = BernardClient(port=5556)
client.connect()
most = client.steps_max()                      # how many steps a stack holds in this player
reply = client.steps_build(
    [True,                                     # the playlist on screen: the clock
     "D:/review/anim.bpl",                     # a playlist file (.bpl, OTIO, CSV, EDL)
     ["D:/SQ010/SH010_lay_v003.mov", "D:/SQ010/SH020_lay_v002.mov"]],  # rows, one media per line
    names=["Comp", "Anim", "Layout"], current=1, display="layout")
if reply.get("status") == "error":
    print("refused:", reply["reason"])
# A playlist file is read off the render thread: the stack is there once `loading` is 0 (a file that could not be
# read is said then, in `refusal`).
while True:
    state = client.steps_state()
    if state.get("loading") == 0 and len(state.get("steps", [])) == 3:
        break
    time.sleep(0.1)
client.steps_set_current(2)                    # Layout on screen
client.steps_look("wipe")                      # the pair wiped
client.steps_display("stack")                  # one step at a time again
client.steps_clear()

6. Studio Integration Examples (Maya & Nuke)

Here are production-ready integration scripts. They include extensive comments explaining the code mechanics for technical artists and TDs.

A. Autodesk Maya Timeline Synchronization

This script runs inside Autodesk Maya. It connects to Bernard Review and ensures that when the artist scrubs the timeline in Maya, Bernard Review jumps to the same frame. Conversely, when the video plays in Bernard Review, Maya's timeline follows in real-time.

# ==============================================================================
# MAYA & BERNARD REVIEW TIMELINE SYNC SCRIPT
# ==============================================================================
# This script sets up a bi-directional frame synchronization.
# 1. Maya to Player: ScriptJob listens for Maya timeline changes and tells the player.
# 2. Player to Maya: Sockets listen for player events and update Maya's currentTime.
# ==============================================================================

import maya.cmds as cmds
from bernard_api import BernardClient

# We store the client in a global variable to prevent Python from garbage-collecting it
global_bernard_client = None
maya_sync_job_id = None
is_updating_timeline = False # Guard variable to prevent infinite loops of feedback

def on_player_frame_changed(event_data):
    """
    Callback function triggered whenever Bernard Review broadcasts a 'frame_changed' event.
    """
    global is_updating_timeline
    
    # If Maya itself triggered this update, ignore it to prevent loopback loops
    if is_updating_timeline:
        return
        
    # Get the frame number from the player's JSON payload
    player_frame = event_data.get("frame", 0)
    
    # Mark that we are programmatically updating Maya's timeline
    is_updating_timeline = True
    try:
        # Move Maya's playback head to the player's current frame
        # We run this on Maya's main thread idle queue to avoid crashes
        cmds.evalDeferred(f"cmds.currentTime({player_frame}, edit=True)")
    finally:
        # Release the guard once the update is enqueued
        is_updating_timeline = False

def on_maya_time_changed():
    """
    Callback function triggered whenever Maya's current time changes.
    """
    global is_updating_timeline, global_bernard_client
    
    # If the change was triggered by the player, ignore it
    if is_updating_timeline:
        return
        
    # Verify the client is connected
    if global_bernard_client and global_bernard_client.connected:
        # Get the current frame in Maya
        current_maya_frame = int(cmds.currentTime(query=True))
        
        # Mark that we are updating, and send the seek command to the player
        is_updating_timeline = True
        try:
            global_bernard_client.seek(current_maya_frame)
        finally:
            is_updating_timeline = False

def start_maya_sync():
    """
    Initializes the connection and sets up the synchronization callbacks.
    """
    global global_bernard_client, maya_sync_job_id
    
    # 1. Connect to the player
    global_bernard_client = BernardClient()
    if not global_bernard_client.connect():
        cmds.warning("Could not connect to Bernard Review. Make sure the player is open.")
        return
        
    # 2. Register callback: listen for player timeline changes
    global_bernard_client.on("frame_changed", on_player_frame_changed)
    
    # 3. Register Maya scriptJob: listen for Maya timeline changes
    # 'timeChanged' is a built-in Maya event triggered when the playhead moves
    maya_sync_job_id = cmds.scriptJob(event=["timeChanged", on_maya_time_changed], protected=True)
    print("Bernard Review synchronization successfully established in Maya!")

def stop_maya_sync():
    """
    Cleans up the callbacks and disconnects the client.
    """
    global global_bernard_client, maya_sync_job_id
    
    # Remove the Maya scriptJob to stop listening to Maya's timeline
    if maya_sync_job_id is not None:
        cmds.scriptJob(kill=maya_sync_job_id, force=True)
        maya_sync_job_id = None
        
    # Close the network socket
    if global_bernard_client:
        global_bernard_client.connected = False
        if global_bernard_client.sock:
            global_bernard_client.sock.close()
        global_bernard_client = None
    print("Bernard Review synchronization stopped.")

B. Foundry Nuke Node Render & Open Script

This script adds a custom button to Nuke Write nodes. Clicking the button renders the active frame range and immediately opens the resulting file in Bernard Review.

# ==============================================================================
# NUKE RENDER & OPEN IN BERNARD REVIEW
# ==============================================================================
# This script integrates into Nuke's menu/Write nodes.
# It renders the node's frame range and tells Bernard Review to load and play it.
# ==============================================================================

import nuke
import os
from bernard_api import BernardClient

def render_and_play_in_bernard(write_node):
    """
    Renders the specified Write node and loads the output file in Bernard Review.
    """
    # 1. Retrieve the file path from the Write node
    output_path = nuke.filename(write_node)
    if not output_path:
        nuke.message("No file path configured on this Write Node.")
        return
        
    # 2. Get the rendering range from Nuke's project settings
    first_frame = int(nuke.root().firstFrame())
    last_frame = int(nuke.root().lastFrame())
    
    # 3. Perform the render
    nuke.message(f"Starting render of frames {first_frame} to {last_frame}...")
    try:
        # Execute render in Nuke (blocks until finished)
        nuke.execute(write_node, first_frame, last_frame)
    except Exception as e:
        nuke.message(f"Render failed: {e}")
        return
        
    # 4. Connect to Bernard Review and load the rendered file
    client = BernardClient()
    if client.connect():
        # Load the rendered video or image sequence path in the player
        client.open_media(output_path)
        # Seek to the first frame of the sequence
        client.seek(first_frame)
        # Start playback automatically
        client.play()
    else:
        # If the player isn't open, print instructions
        nuke.message("Render completed! Launch Bernard Review to view the result.")

7. ShotGrid & Kitsu Web Sync Daemons

If you want to sync the player's timeline with a web-based production tracker (like ShotGrid's Web Player), you can write a lightweight Python daemon that runs in the background. It connects to the Bernard Review via bernard_api.py and relays the events to the web app using websockets or by updating fields via the SG/Kitsu REST API.

# ==============================================================================
# MOCKUP: SHOTGRID/KITSU WEB SYNC DAEMON
# ==============================================================================
# This daemon runs in the background. It listens to frame change events from
# Bernard Review and prints the REST API payloads to send to your studio website.
# ==============================================================================

from bernard_api import BernardClient
import sys
import time

def send_time_to_shotgrid(event_data):
    """
    Stub simulating sending the current playhead position to ShotGrid/Kitsu.
    """
    frame = event_data.get("frame", 0)
    # Inside a real pipeline, you would send an HTTP POST request to your website:
    # requests.post("https://studio.shotgrid.com/api/v1/sessions/sync", json={"frame": frame})
    print(f"[ShotGrid Sync] Broadcasting frame {frame} to Web Interface...")

def run_sync_daemon():
    client = BernardClient()
    print("Starting Studio Sync Daemon...")
    
    # Try connecting to the player
    while not client.connect():
        print("Waiting for Bernard Review to start... Retrying in 5s.")
        time.sleep(5)
        
    # Register the sync function to run whenever the frame changes in the player
    client.on("frame_changed", send_time_to_shotgrid)
    
    # Keep the main daemon thread alive
    try:
        while client.connected:
            time.sleep(1)
    except KeyboardInterrupt:
        print("Daemon stopped by user.")
        sys.exit(0)

if __name__ == "__main__":
    run_sync_daemon()

8. Full Review Round-Trip: Playlist In, Notes Out (Bernard Engine / ShotGrid / ftrack)

Section 7 above only pushes the playhead position out to a web viewer. This section covers the other two legs a production-tracking integration actually needs: loading a review session in (a playlist built by your pipeline tool) and pulling notes back out (what the supervisor typed/drew during the session) once review is done.

What works today

What a pipeline can count on: capabilities.json

A version number does not tell which of the features below a player holds: two lines of development can share numbers. capabilities.json, installed with this client (bin\api\ on Windows), lists them by name in its "bernard" array. A pipeline reads it before starting the player, and leaves aside a player that lacks a feature it needs. A name is only listed with its feature, and stays as long as the feature does.

NameWhat it means
singleshot--singleshot with --ipc-port: a player of its own, on a throwaway copy of the profile's settings (the cache stays the user's), answering on the IPC only on the given port
review-dir--review-dir <dir>: the review written there when the player closes, whatever closed it (beside it, then renamed onto it; the event within five minutes), then the review-exported event
x-tracking-bernardx_tracking keeps a pipeline's own keys (Bernard Engine's bernard block) through the .bpl, the autosave and export.json
playlist-add-x-trackingplaylist-add takes the row's x_tracking and answers the new row's index
versions-on-demandbuild-matrix takes a department's versions in one array; media-request, media-ready and play-version
earlier-notesearlier-notes-request and earlier-notes: a row's earlier notes, read-only, in Essential's inspector and in Review Notes
review-exportingreview-exporting: sent when the window of a --review-dir player hides at the close, with the number of pictures the export is to write; the annotated frames' pictures of several shots are written at once
earlier-notes-liveearlier-notes also takes total and unasked newest pages for an open row; the player sends earlier-notes-closed when a row is open nowhere any more

Known limitation

x_tracking and screenshot are both track-A / playlist-only for now: track B (set-track-b) isn't backed by a PlaylistItem at all, so there's nowhere to attach tracking IDs or read grading/flip state from for a track B shot.


9. NOVA (.nvfx)

NOVA (.nvfx) is the player's own review format, and it is in the default build. A .nvfx sequence is opened like any image sequence -- open, load, playlist-add, set-track-b, drop -- and exported with export and "format": "nvfx" (alias nova), or an output whose extension is .nvfx. The built-in presets nvfx_review (HTJ2K, quality 92) and nvfx_lossless appear in export-presets, and export takes these parameters for the format:

{"cmd": "export", "source": "/renders/sh020/beauty.1001.exr", "output": "/review/sh020_nvfx/", "format": "nvfx", "preset": "nvfx_review"}

bernard_api.py's export() passes these through untouched, as it does every other option. docs/nova_integration.md has what the player does with the format and its known limits.

GPU decode (beta)

The player can decode a .nvfx color pass on the graphics card (OpenGL 4.3 compute) instead of the processor. It is a beta and off by default: set-gpu-decode turns it on or off (Preferences > Cache & Performance), and status.gpu_decode reports its state and counters. The picture is bit for bit the CPU decode's. The processor still decodes track B, data passes, passes not coded in HTJ2K, P-frames, the first frame of each clip, and every frame when the cache precision is not half. Any GPU failure on the frame on screen decodes that frame on the CPU at once; repeated failures turn the path off until the next start. A card without OpenGL 4.3 compute keeps the CPU decode.

The decoder's own video memory (status.gpu_decode.vram_bytes) is bounded: two coefficient buffers of at most 256 MB each, two half-float pictures of the frame (the frame before its copy into the picture's texture, and the full frame a proxy tier is reduced from: 2 x 253 MB at 8K) and three upload sets of the packed frame with an eighth of headroom -- about 1 GB plus 3.4 times the packed frame at 8K. It is given back when the beta is turned off or turns itself off. The VRAM ring makes room for it: before the decoder grows for a frame larger than any it decoded before, the ring frees that growth first (the other clips' textures, then those farthest from the frame on screen). The ring's budget is what the card can still give the player less everything the player already uses there, measured again on the frame after the decoder's memory grows (the regular 5 s measure remains for a driver that commits its memory late). Where the card's memory cannot be read (the ring's fixed 1.5 GB) and under the ring's share of the RAM, the decoder's memory is taken out of the ring's budget. On Windows that memory is also part of the process's private bytes, which the RAM limit counts. A frame neither the card nor the processor could decode is not shown: the picture on screen stays and the frame is tried again (three such frames in a row turn the beta off until the next start).

A player built without NOVA

The format is a build option, BERNARD_ENABLE_NOVA, on by default. A player configured with -DBERNARD_ENABLE_NOVA=OFF behaves as if the format did not exist: status never names it, no built-in preset produces it, no dialog filter offers it, and the parameters above do not apply. It answers a request for the format rather than ignoring it:

The client has nothing of its own to hide: the same export() call is either honoured or refused by the player depending on how it was built.

10. GPU YUV upload for movies (beta)

The player can keep a movie's frames in RAM as the YUV planes the decoder wrote and let the graphics card convert them to RGB (OpenGL 4.3 compute) when they go up. It is a beta and off by default: set-gpu-yuv turns it on or off (Preferences > Cache & Performance), and status.gpu_yuv reports its state, counters and the route of each track's film.

11. GPU video decode (beta)

The player can let the graphics card's own video decoder decode a movie. It is a beta and off by default: set-gpu-video-decode turns it on or off (Preferences > Cache & Performance, and a checkbox of the same name in the Classique tabs), and status.gpu_video_decode reports its state, counters and the route of each track's film. Off, nothing of it runs: no thread, no device, no decoder armed.


12. GPU ProRes decode (beta)

The player can keep a ProRes film's frames in RAM still compressed and let the graphics card decode each one when it is shown (FFmpeg's Vulkan ProRes decoder). It is a beta and off by default: set-gpu-prores turns it on or off (Preferences > Cache & Performance), and status.gpu_prores reports its state, counters and the route of each track's film.