Scene viewer
Paste or upload raw Excalidraw scene JSON — a trace, a .excalidraw file, a model's reply — and render it on a live canvas.
Overview
Trace an image hands its drawing back as scene JSON. This page is the other half of that loop: paste scene JSON in, press Render, and the canvas draws it. Edit the drawing, press Canvas → JSON, and the box holds the edited scene, ready to copy out again.
No model is called, so nothing here spends anything, and the page works on this site even though the AI pages cannot run here. A trace made on a local checkout renders here the same way.
What it accepts
| Paste this | Where it comes from |
|---|---|
{"type": "excalidraw", "elements": [...], "appState": {...}, "files": {...}} | /trace-image's Scene JSON panel, serializedData, a .excalidraw file from excalidraw.com |
{"elements": [...], "appState": {...}} | Anything shaped like initialData or welcomeScene |
[{...}, {...}] | A bare element array |
| A model's raw reply | AI agent output: a markdown code fence, a sentence before the object and trailing commas are all tolerated |
"{\"elements\": ...}" | serializedData copied out of a dcc.Store or a log, which is a JSON string and arrives quoted once more than it should |
Upload file takes a .json or .excalidraw file and does the same thing. A .excalidrawlib library file is refused with a pointer to Library: it holds library items, not a scene.
What Excalidraw would drop without saying so
Excalidraw's loader is forgiving, and the price is that it throws things away without saying anything. An element of an unknown type, a selection element, a zero-sized shape and a line with fewer than two points all vanish on load. The drawing just comes out shorter. The page does the same checks in Python first and says what it found, so a 30-element scene that draws 26 comes with the reason for the missing four:
- Skipped types. The list of types that survive was read from the pinned
Excalidraw 0.18 bundle's restoreElement, not remembered.
- Invisibly small elements. These mirror
isInvisiblySmallElement. - Deleted elements.
isDeleted: trueelements stay in the scene but are
not drawn.
- Duplicate ids. Excalidraw gives each repeat a fresh random id, so an
arrow bound to that id can end up attached to the other copy.
- Images with no bytes. A trace's JSON comes from
externalizedSerializedData, which strips every inline data: URI to null. An image with no bytes draws as an empty placeholder. Serve the file and point the scene at a URL; File uploads is that pattern.
Only viewBackgroundColor, gridSize and gridStep are applied from a pasted appState. Theme, view mode, zen mode and grid mode are this component's props, and a scene that set them would fight the props. Scroll and zoom are worked out again when the page fits the scene to the canvas.
How a render works: a command queue
initialData is mount-only. It gives the canvas its first scene (the sample, here), and changing it afterwards does nothing. Every render after that goes through command dispatch, and one render takes four commands:
resetSceneclears the elements, the undo history and the previous scene's
background.
updateSceneloads the scene withcaptureUpdate: "NEVER", so the first
Ctrl+Z does not undo the whole load and leave an empty canvas.
replaceFilessends the image bytes, if there are any. It is not
addFiles, because addFiles does nothing when the canvas already holds that id, and the second render of the same scene would keep the old image.
scrollToContentwithfitToContentfits the drawing to the view. It
zooms out to fit and never zooms in past 100%.
The canvas holds one command at a time. It runs the command and then sets command back to None. You cannot return all four from one callback, because each would overwrite the one before it ran. So the render callback sends the first command and parks the rest in a dcc.Store. A second callback, fired by command going back to None, sends the next one:
@callback(Output("canvas", "command"),
Output("queue", "data", allow_duplicate=True),
Input("canvas", "command"),
State("queue", "data"),
prevent_initial_call=True)
def next_command(current, queue):
if current is not None or not queue: # busy, or nothing left
return no_update, no_update
head, *rest = queue
return head, rest
You can reuse this for any sequence of commands whose order matters.
Live demo
Source
# File: docs/scene-viewer/scene_viewer.py
"""Scene viewer: paste or upload raw scene JSON and render it on a live canvas.
The other half of /trace-image. That page hands a trace back as scene JSON;
this one takes scene JSON — a trace, a `.excalidraw` file, a model's reply, a
bare element array — and draws it. No model is called, so nothing here spends
anything and the page works on a deployment with no provider keys.
The parsing and the "what will Excalidraw silently drop" checks live in
lib/scene_json.py so they can be tested without a browser. What is left here
is the one thing a Dash page has to get right: the canvas takes ONE command at
a time, and a render is four of them.
"""
from __future__ import annotations
import base64
import binascii
import json
import uuid
import dash
import dash_mantine_components as dmc
from dash import Input, Output, State, callback, clientside_callback, dcc, no_update
from dash_excalidraw import DashExcalidraw
from docs._shared import canvas_frame, sync_canvas_theme
from lib.scene_json import MAX_SCENE_BYTES, SceneError, build_render, parse_scene_text
sync_canvas_theme("scene-viewer-canvas")
# ---------------------------------------------------------------------------
# The sample — hand-written, in the shape /trace-image hands back
# ---------------------------------------------------------------------------
#
# NOT a real trace, and labelled as such on the page: it is a small scene
# written to the same envelope `externalizedSerializedData` produces, so the
# textarea opens on something a reader can edit and re-render straight away.
def _base(el_id, kind, x, y, width, height, stroke="#1e1e1e", bg="transparent",
seed=1, **extra):
return {
"id": el_id, "type": kind, "x": x, "y": y,
"width": width, "height": height, "angle": 0,
"strokeColor": stroke, "backgroundColor": bg, "fillStyle": "solid",
"strokeWidth": 2, "strokeStyle": "solid", "roughness": 1,
"opacity": 100, "groupIds": [], "frameId": None,
"roundness": {"type": 3} if kind in ("rectangle", "diamond") else (
{"type": 2} if kind == "ellipse" else None),
"seed": seed, "version": 1, "versionNonce": seed, "isDeleted": False,
"boundElements": [], "updated": 1, "link": None, "locked": False,
**extra,
}
def _text(el_id, text, cx, cy, size=16, color="#1e1e1e", container=None, seed=1):
lines = text.split("\n")
width = round(max(len(line) for line in lines) * size * 0.6)
height = round(size * 1.25 * len(lines))
return _base(
el_id, "text", round(cx - width / 2), round(cy - height / 2), width, height,
stroke=color, seed=seed, text=text, originalText=text, fontSize=size,
fontFamily=2, textAlign="center",
verticalAlign="middle" if container else "top",
containerId=container, lineHeight=1.25, autoResize=True,
)
def _box(el_id, kind, label, x, y, stroke, bg, seed):
"""A shape with its label bound inside it, both halves of the binding set."""
w, h = 200, 80
shape = _base(el_id, kind, x, y, w, h, stroke=stroke, bg=bg, seed=seed,
boundElements=[{"id": f"{el_id}-label", "type": "text"}])
label_el = _text(f"{el_id}-label", label, x + w / 2, y + h / 2,
color=stroke, container=el_id, seed=seed + 100)
return shape, label_el
def _arrow(el_id, start, end, x, y, dx, dy, seed):
return _base(
el_id, "arrow", x, y, abs(dx), abs(dy), seed=seed,
points=[[0, 0], [dx, dy]], lastCommittedPoint=None,
startBinding={"elementId": start, "focus": 0, "gap": 4},
endBinding={"elementId": end, "focus": 0, "gap": 4},
startArrowhead=None, endArrowhead="arrow", elbowed=False,
)
def _sample_scene() -> dict:
ref, ref_label = _box("ref", "rectangle", "Reference image",
60, 140, "#4263eb", "#dbe4ff", 11)
model, model_label = _box("model", "ellipse", "Vision model",
380, 140, "#0ca678", "#c3fae8", 21)
scene_json, scene_label = _box("json", "rectangle", "Scene JSON",
700, 140, "#e67700", "#ffe8cc", 31)
viewer, viewer_label = _box("viewer", "rectangle", "This canvas",
700, 320, "#ae3ec9", "#eebefa", 41)
arrows = [
_arrow("a1", "ref", "model", 264, 180, 112, 0, 51),
_arrow("a2", "model", "json", 584, 180, 112, 0, 52),
_arrow("a3", "json", "viewer", 800, 224, 0, 92, 53),
]
# Each arrow is listed on both shapes it binds, or dragging a shape
# leaves the arrow behind.
for arrow in arrows:
for shape in (ref, model, scene_json, viewer):
if shape["id"] in (arrow["startBinding"]["elementId"],
arrow["endBinding"]["elementId"]):
shape["boundElements"].append({"id": arrow["id"], "type": "arrow"})
title = _text("title", "Trace → scene JSON → canvas", 480, 60, size=28,
color="#1e3a8a", seed=61)
caption = _text(
"caption",
"A hand-written sample in the shape /trace-image hands back.\n"
"Edit the JSON, or replace it with your own, and press Render.",
300, 380, size=14, color="#6b7280", seed=62,
)
caption["textAlign"] = "left"
return {
"type": "excalidraw",
"version": 2,
"source": "https://excalidraw.2plot.dev/scene-viewer",
"elements": [title, ref, ref_label, model, model_label, scene_json,
scene_label, viewer, viewer_label, *arrows, caption],
"appState": {"viewBackgroundColor": "#ffffff", "gridSize": 20},
"files": {},
}
SAMPLE_SCENE = _sample_scene()
SAMPLE_TEXT = json.dumps(SAMPLE_SCENE, indent=2)
# ---------------------------------------------------------------------------
# Layout
# ---------------------------------------------------------------------------
component = dmc.Stack(
gap="sm",
children=[
# STACKED, not side by side. The canvas IS the output, and in this
# site's content column (sidebar left, table of contents right) a
# 7/12 column left it about 300px wide — too narrow to read a trace.
dmc.Textarea(
id="scene-viewer-input",
label="Scene JSON",
description=(
"A trace from /trace-image, a .excalidraw file, a model's reply, "
"or a bare element array"
),
value=SAMPLE_TEXT,
autosize=False,
# DMC 2.8 rejects `spellcheck=` as a kwarg; Mantine's `attributes`
# reaches the <textarea>. Without it every key in the JSON is
# underlined red.
attributes={"input": {"spellCheck": "false"}},
styles={
"input": {
"fontFamily": "var(--mantine-font-family-monospace)",
"fontSize": 12,
"height": 260,
}
},
**{"aria-label": "Scene JSON to render"},
),
dmc.Group(
gap="xs",
children=[
dmc.Button("Render", id="scene-viewer-render", color="indigo"),
dcc.Upload(
id="scene-viewer-upload",
multiple=False,
accept=".json,.excalidraw,application/json",
max_size=MAX_SCENE_BYTES,
children=dmc.Button("Upload file", variant="light", color="indigo"),
),
dmc.Button(
"Canvas → JSON", id="scene-viewer-pull", variant="light", color="gray"
),
dmc.Button("Sample", id="scene-viewer-sample", variant="subtle", color="gray"),
dmc.Button("Clear", id="scene-viewer-clear", variant="subtle", color="gray"),
],
),
dmc.Alert(
id="scene-viewer-status",
color="gray",
variant="light",
children=(
"Showing the sample. Paste a scene over it, or upload a .json / "
".excalidraw file."
),
),
canvas_frame(
DashExcalidraw(
id="scene-viewer-canvas",
height="560px",
# The FIRST scene only. initialData is mount-only; every render
# after this one is the command queue below.
initialData=SAMPLE_SCENE,
),
min_height=560,
),
# The commands still to send, in order. See `_next_command`.
dcc.Store(id="scene-viewer-queue", data=[]),
# Whether the opening scene has been fitted yet. See `_fit_once`.
dcc.Store(id="scene-viewer-fitted", data=False),
],
)
# ---------------------------------------------------------------------------
# Callbacks
# ---------------------------------------------------------------------------
def _decode_upload(contents: str, filename: str | None) -> str:
"""A dcc.Upload data URL -> the file's text."""
if not contents or "," not in contents:
raise SceneError("The upload arrived empty.")
try:
raw = base64.b64decode(contents.split(",", 1)[1], validate=True)
except (binascii.Error, ValueError):
raise SceneError(f"{filename or 'That file'} could not be read.") from None
try:
return raw.decode("utf-8-sig")
except UnicodeDecodeError:
raise SceneError(
f"{filename or 'That file'} is not text. A scene is a .json or "
f".excalidraw file; a PNG with an embedded scene is not supported here."
) from None
def _rich(text: str) -> list:
"""`backticked` spans as inline code. The notes are written for a reader,
in lib/scene_json.py, with backticks round the names they quote."""
parts = str(text).split("`")
return [dmc.Code(part) if i % 2 else part for i, part in enumerate(parts) if part]
def _summary(render: dict) -> str:
drawn = render["drawn"]
parts = [f"{drawn} element{'' if drawn == 1 else 's'} drawn"]
if render["images"]:
parts.append(f"{render['images']} image{'' if render['images'] == 1 else 's'}")
background = render["app_state"].get("viewBackgroundColor")
if background:
parts.append(f"background {background}")
return " · ".join(parts)
@callback(
Output("scene-viewer-canvas", "command", allow_duplicate=True),
Output("scene-viewer-queue", "data"),
Output("scene-viewer-input", "value"),
Output("scene-viewer-status", "children"),
Output("scene-viewer-status", "color"),
Output("scene-viewer-status", "title"),
Input("scene-viewer-render", "n_clicks"),
Input("scene-viewer-upload", "contents"),
Input("scene-viewer-sample", "n_clicks"),
Input("scene-viewer-clear", "n_clicks"),
State("scene-viewer-upload", "filename"),
State("scene-viewer-input", "value"),
prevent_initial_call=True,
)
def _render(_render, upload, _sample, _clear, filename, text):
"""Parse whatever the reader supplied and start the command queue.
Sends the FIRST command and parks the rest in the Store; `_next_command`
feeds them to the canvas one at a time.
"""
trigger = dash.ctx.triggered_id
if trigger == "scene-viewer-clear":
return (
{"id": f"scene-clear-{uuid.uuid4().hex[:8]}",
"type": "resetScene", "payload": {}},
[], "", "Canvas cleared.", "gray", None,
)
new_text = no_update
source = "the pasted JSON"
try:
if trigger == "scene-viewer-upload":
text = new_text = _decode_upload(upload, filename)
source = filename or "the uploaded file"
elif trigger == "scene-viewer-sample":
text = new_text = SAMPLE_TEXT
source = "the sample"
render = build_render(parse_scene_text(text))
except SceneError as exc:
return (no_update, no_update, new_text, dmc.Text(_rich(exc), size="sm"),
"red", f"Could not render {source}")
first, *rest = render["commands"]
notes = render["notes"]
body = dmc.Stack(gap=4, children=[dmc.Text(_summary(render), size="sm")] + [
dmc.Text(["• ", *_rich(note)], size="sm") for note in notes
])
return (
first, rest, new_text, body,
"yellow" if notes else "green",
f"Rendered {source}" + (" — with notes" if notes else ""),
)
@callback(
Output("scene-viewer-canvas", "command"),
Output("scene-viewer-queue", "data", allow_duplicate=True),
Input("scene-viewer-canvas", "command"),
State("scene-viewer-queue", "data"),
prevent_initial_call=True,
)
def _next_command(current, queue):
"""Feed the canvas the next queued command once it has finished the last.
THE CANVAS HOLDS ONE COMMAND. It runs it, then sets `command` back to None
— and that write is this callback's trigger. Returning four commands from
one callback is not an option (each would overwrite the last before it
ran), so a render is a queue: `_render` sends the head, and every time the
canvas reports itself idle this sends the next. When the queue is empty
it does nothing, which is how the chain ends.
`current` is not None when the trigger was a callback SETTING a command
rather than the canvas clearing one; that is not our turn.
"""
if current is not None or not queue:
return no_update, no_update
head, *rest = queue
return head, rest
@callback(
Output("scene-viewer-input", "value", allow_duplicate=True),
Output("scene-viewer-status", "children", allow_duplicate=True),
Output("scene-viewer-status", "color", allow_duplicate=True),
Output("scene-viewer-status", "title", allow_duplicate=True),
Input("scene-viewer-pull", "n_clicks"),
State("scene-viewer-canvas", "serializedData"),
prevent_initial_call=True,
)
def _pull(_clicks, serialized):
"""The canvas as it is now — tidied, edited, whatever — back into the box.
`serializedData`, not `externalizedSerializedData`: this is a round trip,
and the externalized form nulls every inline image, so pulling a scene
with pictures in it and rendering it again would lose them.
"""
if not serialized:
return no_update, "The canvas has not reported a scene yet — draw something first.", "gray", None
try:
scene = json.loads(serialized)
except (TypeError, ValueError):
return no_update, "The canvas produced no readable scene.", "red", None
count = len([e for e in scene.get("elements") or [] if not e.get("isDeleted")])
return (
json.dumps(scene, indent=2),
f"Copied the canvas into the box — {count} element{'' if count == 1 else 's'}.",
"gray",
None,
)
# THE OPENING SCENE, FITTED ONCE. `initialData.scrollToContent` only centres
# the scene, it does not zoom, and a fit command cannot go at page load: the
# API the command waits for exists before Excalidraw has finished loading
# initialData, so it would frame an empty scene. The first `elements` write
# with content IS the "scene loaded" signal.
#
# Even that can be too early on its own. The app shell applies the table of
# contents' offset AFTER first paint — a callback sets the aside, and main's
# padding-right then transitions 32px -> 312px over 200ms — so the canvas
# narrows under a fit already sent. MEASURED on a 1728px viewport: 1316px wide
# before the offset, 1036px after, with the fit going out at 1036 once this
# wait was in. Excalidraw keeps its scroll through a resize, so a fit framed
# for the wider canvas leaves the scene off-centre with its right edge cut
# off. The fit waits until the canvas has held one width for 300ms (capped at
# 2s).
# Clientside, because it is one command, and deciding "already done" on the
# server would cost a round trip per canvas change.
clientside_callback(
"""
async function(elements, fitted) {
const skip = window.dash_clientside.no_update;
if (fitted || !elements || !elements.length) { return [skip, skip]; }
const box = document.getElementById("scene-viewer-canvas");
const began = performance.now();
let width = -1, steadySince = began;
while (box && performance.now() - began < 2000) {
const now = box.getBoundingClientRect().width;
if (now !== width) { width = now; steadySince = performance.now(); }
else if (performance.now() - steadySince > 300) { break; }
await new Promise((done) => setTimeout(done, 50));
}
return [
{id: "scene-opening-fit", type: "scrollToContent",
payload: {opts: {fitToContent: true}}},
true,
];
}
""",
Output("scene-viewer-canvas", "command", allow_duplicate=True),
Output("scene-viewer-fitted", "data"),
Input("scene-viewer-canvas", "elements"),
State("scene-viewer-fitted", "data"),
prevent_initial_call=True,
)
:defaultExpanded: false :withExpandedButton: true
# File: lib/scene_json.py
"""Raw scene JSON -> the commands that put it on a live canvas (/scene-viewer).
The page takes whatever a reader is holding — the scene JSON /trace-image
hands back, a `.excalidraw` file saved from excalidraw.com, a model's raw reply
on /ai-agent, a bare element array — and renders it. Everything that can be
decided without a browser is decided here, in pure Python, so the tests can
reach it without one:
* whether the text is a scene at all, and if not, a message saying why;
* what Excalidraw would throw away WITHOUT A WORD, said out loud first;
* the ordered commands that replace the canvas with the scene.
Why commands and not `initialData`: initialData is mount-only, which is
Excalidraw's rule before it is this component's. A second paste is a
post-mount change like any other, so it goes through the command dispatch.
Nothing here is part of the published dash-excalidraw package; it belongs to
the documentation site.
"""
from __future__ import annotations
import json
import uuid
from collections import Counter
from lib.scene_ai import _cleanup_json, _extract_json_block
# A `.excalidraw` file with a few inline photos is a few MB of base64; ten
# times that is a paste nobody meant to make, and it would travel through
# every command below.
MAX_SCENE_BYTES = 8 * 1024 * 1024
# The element types Excalidraw 0.18's `restoreElements` keeps. READ FROM THE
# PINNED BUNDLE, not recalled: its `restoreElement` switch handles exactly
# these, returns null for anything else, and `restoreElements` skips
# `selection` before the switch. "draw" is the legacy name it migrates to
# `line`. An element of any other type vanishes with nothing logged.
KNOWN_TYPES = frozenset({
"rectangle", "ellipse", "diamond", "text", "arrow", "line", "draw",
"freedraw", "image", "frame", "magicframe", "embeddable", "iframe",
})
LINEAR_TYPES = frozenset({"arrow", "line", "draw", "freedraw"})
# The appState a pasted scene may set. Everything else in a saved appState is
# either this component's to control (theme, viewModeEnabled, zen and grid
# mode are props, and a scene that set them would fight the props) or view
# state that `scrollToContent` recomputes anyway (scroll, zoom).
APPSTATE_KEYS = ("viewBackgroundColor", "gridSize", "gridStep")
# What an image file's `dataURL` may be for the canvas to load it: inline
# image bytes, or a URL (what `replaceFiles` leaves behind on /file-uploads).
# Not `data:text/html`, not `javascript:` — this string becomes an <img src>.
_LOADABLE_PREFIXES = ("data:image/", "https://", "http://", "/")
class SceneError(ValueError):
"""The text is not a scene this page can render. The message is for the
reader, so it names the problem in their terms, not the parser's."""
def _strip_fences(text: str) -> str:
"""```json ... ``` -> the inside. Arrays as well as objects, which is why
this is not `_extract_json_block` — that one looks for the first `{` and
would return the first ELEMENT of a fenced array as if it were the scene."""
t = text.strip()
if t.startswith("```"):
newline = t.find("\n")
t = t[newline + 1:] if newline > 0 else ""
t = t.rstrip()
if t.endswith("```"):
t = t[:-3]
return t.strip()
def _loads(text: str):
"""json.loads, then the forgiving readings a model's reply needs."""
try:
return json.loads(text)
except ValueError as exc:
first = exc
fenced = _strip_fences(text)
for candidate in (
fenced,
_cleanup_json(fenced),
# Prose around the object ("Here is your diagram: {...}").
_cleanup_json(_extract_json_block(text)),
):
try:
return json.loads(candidate)
except ValueError:
continue
if isinstance(first, json.JSONDecodeError):
raise SceneError(
f"Not valid JSON: {first.msg} at line {first.lineno}, "
f"column {first.colno}."
) from None
raise SceneError("Not valid JSON.") from None
def parse_scene_text(text) -> dict:
"""Decode pasted or uploaded text into a scene dict with an `elements` list.
Raises SceneError with a reader-facing message for anything else.
"""
if text is None or not str(text).strip():
raise SceneError("Nothing to render — paste scene JSON or upload a file.")
text = str(text)
size = len(text.encode("utf-8"))
if size > MAX_SCENE_BYTES:
raise SceneError(
f"That is {size / 1024 / 1024:.1f} MB of JSON; the limit here is "
f"{MAX_SCENE_BYTES // 1024 // 1024} MB. A scene that size is almost "
f"always inline images — externalize them first (see File uploads)."
)
data = _loads(text)
# `serializedData` is a JSON STRING, so a scene copied out of a dcc.Store,
# a log line or a JSON dump arrives quoted once more than it should be.
if isinstance(data, str):
data = _loads(data)
if isinstance(data, list):
data = {"elements": data}
if not isinstance(data, dict):
raise SceneError(
f"Expected a scene object or an array of elements; this JSON is "
f"a {type(data).__name__}."
)
if data.get("type") == "excalidrawlib" or (
"libraryItems" in data and "elements" not in data
):
raise SceneError(
"That is a library file (.excalidrawlib), not a scene. Libraries "
"load through `updateLibrary` — see the Library page."
)
if "elements" not in data:
raise SceneError(
"This is JSON, but not an Excalidraw scene: there is no "
"`elements` key."
)
if not isinstance(data["elements"], list):
raise SceneError("`elements` has to be a list of element objects.")
return data
def _invisibly_small(el: dict) -> bool:
"""Mirror of Excalidraw's `isInvisiblySmallElement`, which
`restoreElements` uses to drop an element before it is ever drawn."""
if el.get("type") in LINEAR_TYPES:
points = el.get("points")
return not isinstance(points, list) or len(points) < 2
return el.get("width") == 0 and el.get("height") == 0
def _plural(n: int, word: str) -> str:
return f"{n} {word}{'' if n == 1 else 's'}"
def build_render(scene: dict, token: str | None = None) -> dict:
"""The commands that replace a canvas with `scene`, and what to tell the
reader about it.
Returns ``{"commands": [...], "drawn": int, "images": int, "notes": [...]}``.
`commands` are in dispatch order and must go ONE AT A TIME — the canvas
holds a single `command` and clears it when done, so a caller queues the
tail and sends the next when it sees `command` go back to None.
"""
token = token or uuid.uuid4().hex[:8]
notes: list[str] = []
kept: list[dict] = []
not_objects = 0
unknown: Counter = Counter()
too_small = 0
deleted = 0
for el in scene.get("elements") or []:
if not isinstance(el, dict):
not_objects += 1
continue
kind = el.get("type")
if kind not in KNOWN_TYPES:
unknown[str(kind)] += 1
continue
if _invisibly_small(el):
too_small += 1
continue
if el.get("isDeleted"):
deleted += 1
kept.append(el)
ids = Counter(el.get("id") for el in kept if el.get("id"))
duplicated = sum(n - 1 for n in ids.values() if n > 1)
drawn = len(kept) - deleted
# ---- files: which images can actually be shown -----------------------
files = scene.get("files")
if files is not None and not isinstance(files, dict):
notes.append("`files` is not an object keyed by file id, so it was ignored.")
files = None
loadable: dict[str, dict] = {}
for file_id, entry in (files or {}).items():
if not isinstance(entry, dict):
continue
url = entry.get("dataURL")
if isinstance(url, str) and url.startswith(_LOADABLE_PREFIXES):
mime = entry.get("mimeType")
if not mime and url.startswith("data:"):
mime = url[5:].split(";", 1)[0].split(",", 1)[0]
loadable[str(file_id)] = {"dataURL": url, "mimeType": mime or "image/png"}
images = [el for el in kept if el.get("type") == "image" and not el.get("isDeleted")]
no_bytes = sum(1 for el in images if el.get("fileId") not in loadable)
# ---- what to say -----------------------------------------------------
if not_objects:
notes.append(f"{_plural(not_objects, 'entry')} in `elements` "
f"{'is' if not_objects == 1 else 'are'} not an object — skipped.")
for kind, n in sorted(unknown.items()):
label = "`selection`" if kind == "selection" else f"type `{kind}`"
notes.append(f"{_plural(n, 'element')} of {label} skipped — Excalidraw "
f"drops that type on load without saying so.")
if too_small:
notes.append(f"{_plural(too_small, 'element')} skipped as invisibly small "
f"(zero width and height, or a line with fewer than two "
f"points) — Excalidraw would drop them too.")
if deleted:
notes.append(f"{_plural(deleted, 'element')} marked `isDeleted` "
f"{'is' if deleted == 1 else 'are'} kept in the scene but not drawn.")
if duplicated:
notes.append(f"{_plural(duplicated, 'duplicate id')} — Excalidraw gives "
f"each repeat a fresh random id, so an arrow bound to that "
f"id may attach to the other copy.")
if no_bytes:
notes.append(f"{_plural(no_bytes, 'image')} {'has' if no_bytes == 1 else 'have'} "
f"no loadable bytes (a `null` dataURL is what "
f"`externalizedSerializedData` leaves), so "
f"{'it draws' if no_bytes == 1 else 'they draw'} as an "
f"empty placeholder. Serve the file and point the scene at it — "
f"see File uploads.")
if not drawn:
notes.append("Nothing visible to draw — the canvas is cleared.")
# ---- the commands, in order -----------------------------------------
app_state = {}
raw_state = scene.get("appState")
if isinstance(raw_state, dict):
app_state = {k: raw_state[k] for k in APPSTATE_KEYS if k in raw_state}
commands = [
# 1. A clean slate: elements, history and the previous scene's
# background. The component re-asserts its own mode props after.
{"id": f"scene-{token}-reset", "type": "resetScene", "payload": {}},
# 2. The scene. NEVER, so the first Ctrl+Z after loading does not
# unwind the whole load back to an empty canvas.
{
"id": f"scene-{token}-load",
"type": "updateScene",
"payload": {
"elements": kept,
"appState": app_state,
"captureUpdate": "NEVER",
},
},
]
if loadable:
# 3. The image bytes. `replaceFiles`, not `addFiles`, on purpose:
# `addFiles` with an id the canvas already holds is a silent no-op
# (measured — see the component's replaceFiles comment), so the
# SECOND render of a scene, or a scene reusing an id with new bytes,
# would keep the old image. replaceFiles stores under a fresh id and
# repoints the image elements, which works every time.
commands.append({
"id": f"scene-{token}-files",
"type": "replaceFiles",
"payload": loadable,
})
if drawn:
# 4. Frame it. fitToContent zooms OUT to fit and never in past 100%,
# so a tiny scene is not blown up to fill the canvas.
commands.append({
"id": f"scene-{token}-fit",
"type": "scrollToContent",
"payload": {"opts": {"fitToContent": True}},
})
return {
"commands": commands,
"drawn": drawn,
"images": len(images),
"notes": notes,
"app_state": app_state,
}
:defaultExpanded: false :withExpandedButton: true
Source: /scene-viewer
Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs:
- /scene-viewer/llms.txt — LLM-friendly documentation
- /sitemap.xml
- /robots.txt