Fix the scene backdrop never rendering (issue #313) (#314)

* fix: render the scene backdrop through an object URL (issue #313)

* docs: state the disabled timeline actions as disabled in this version, no return promise

* fix: refuse unsafe and colliding save names when forking from a message

* fix: give fork_scene a real default save name instead of a missing method

* fix: slug the generated fork name so the guard cannot refuse it; pin the refusal messages
This commit is contained in:
veguAI
2026-08-17 15:04:36 +03:00
committed by GitHub
parent cd22176529
commit 98b64b512b
11 changed files with 257 additions and 17 deletions

View File

@@ -14,7 +14,7 @@ Chats can be made scene-aware, and each question carries a small snapshot of wha
A new timeline dialog gives scene history a single home, replacing the old Restore from Backup dialog. Drag a slider across the scene's automatic version history to preview the message history at any revision, then fork that revision into a new save — the scene you are playing is never modified, and browsing the history changes nothing on disk. A new timeline dialog gives scene history a single home, replacing the old Restore from Backup dialog. Drag a slider across the scene's automatic version history to preview the message history at any revision, then fork that revision into a new save — the scene you are playing is never modified, and browsing the history changes nothing on disk.
Forking is the only action the timeline applies in this version. Rolling a scene back in place and opening a revision directly are disabled while they are being reworked, and will return in a future version. Forking is the only action the timeline applies. Rolling a scene back in place and opening a revision directly are disabled in this version.
### Pi Bridge Client ### Pi Bridge Client

View File

@@ -33,11 +33,12 @@
- "Scene Browser Landing Page: The Quick Load cards now pop out slightly on mouse hover, without shifting the cards around them." - "Scene Browser Landing Page: The Quick Load cards now pop out slightly on mouse hover, without shifting the cards around them."
- "Prompt Finalization: UX polish for the post-processing actions editor — action fields lay out on a single balanced row with per-mode visibility (flags only show for exact and regex modes), each action card gained a compact header showing its mode and target plus tooltips on all controls, disabled actions dim their fields, an empty action list shows a hint instead of blank space." - "Prompt Finalization: UX polish for the post-processing actions editor — action fields lay out on a single balanced row with per-mode visibility (flags only show for exact and regex modes), each action card gained a compact header showing its mode and target plus tooltips on all controls, disabled actions dim their fields, an empty action list shows a hint instead of blank space."
changes: changes:
- "Timeline: Forking a revision to a new save is the only action the timeline applies in this release. Rolling a scene back in place and opening a revision directly are disabled while they are being reworked and will return in a future version — their websocket routes are gone, so no timeline action can write over an existing scene file. Browsing and previewing revisions are unaffected, and forking now refuses a save name that already exists instead of writing over it." - "Timeline: Forking a revision to a new save is the only action the timeline applies in this release. Rolling a scene back in place and opening a revision directly are disabled in this version — their websocket routes are gone, so no timeline action can write over an existing scene file. Browsing and previewing revisions are unaffected, and forking now refuses a save name that already exists instead of writing over it."
- "Dependencies: Refreshed the dependency lock across the board, including a setuptools bump to 83.0.0 which carries a fix for a source-distribution file-exclusion vulnerability (GHSA-h35f-9h28-mq5c). No dependency required code migrations." - "Dependencies: Refreshed the dependency lock across the board, including a setuptools bump to 83.0.0 which carries a fix for a source-distribution file-exclusion vulnerability (GHSA-h35f-9h28-mq5c). No dependency required code migrations."
- "OpenRouter Client: The default model for newly created OpenRouter clients is now google/gemini-3.6-flash." - "OpenRouter Client: The default model for newly created OpenRouter clients is now google/gemini-3.6-flash."
- "OpenRouter Client: New OpenRouter clients now have reasoning enabled by default, with a budget of 2048 reasoning tokens, so the default model works out of the box. With reasoning off Talemate pre-fills the start of the response to steer it, and some providers — Google and Anthropic among them — reject requests that do that." - "OpenRouter Client: New OpenRouter clients now have reasoning enabled by default, with a budget of 2048 reasoning tokens, so the default model works out of the box. With reasoning off Talemate pre-fills the start of the response to steer it, and some providers — Google and Anthropic among them — reject requests that do that."
fixes: fixes:
- "Scene Forking: Forking from a message now refuses a save name that is not a valid filename or that an existing save already uses, instead of writing over that save or outside the scene directory. The timeline fork already refused both."
- "Prompt Finalization: The fuzzy match threshold slider's always-visible value bubble no longer overlaps the note above it." - "Prompt Finalization: The fuzzy match threshold slider's always-visible value bubble no longer overlaps the note above it."
- "Frontend: A backend websocket URL configured with the host 0.0.0.0 now connects — the URL was used literally, which most browsers refuse, so the app stayed on 'backend not connected'. The host is resolved to the hostname the UI itself was loaded from." - "Frontend: A backend websocket URL configured with the host 0.0.0.0 now connects — the URL was used literally, which most browsers refuse, so the app stayed on 'backend not connected'. The host is resolved to the hostname the UI itself was loaded from."
- "Help Chat: The warning shown while the Help agent has no configured client no longer stretches to fill the entire help panel — it renders as a compact notice above the chat." - "Help Chat: The warning shown while the Help agent has no configured client no longer stretches to fill the entire help panel — it renders as a compact notice above the chat."

View File

@@ -6,7 +6,7 @@ Talemate provides two ways to return a scene to a previous state:
- **Restore from Restore Point** — reset to a specific save file you've designated as a baseline in the [scene settings](/talemate/user-guide/world-editor/scene/settings#restoration-settings) - **Restore from Restore Point** — reset to a specific save file you've designated as a baseline in the [scene settings](/talemate/user-guide/world-editor/scene/settings#restoration-settings)
!!! warning "The timeline only forks in this version" !!! warning "The timeline only forks in this version"
Rolling a scene back in place and opening a revision directly are both disabled while they are being reworked, and will return in a future version. Forking a revision into a new save is the one action the timeline applies — it writes a new save alongside the scene and leaves every existing save untouched. Rolling a scene back in place and opening a revision directly are both disabled in this version. Forking a revision into a new save is the one action the timeline applies — it writes a new save alongside the scene and leaves every existing save untouched.
## The timeline ## The timeline

View File

@@ -1,5 +1,6 @@
import json import json
import re import re
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Tuple from typing import TYPE_CHECKING, Tuple
import traceback import traceback
import uuid import uuid
@@ -12,6 +13,7 @@ from talemate.emit import emit
from talemate.instance import get_agent from talemate.instance import get_agent
from talemate.prompts import Prompt from talemate.prompts import Prompt
from talemate.prompts.base import StripMode from talemate.prompts.base import StripMode
from talemate.util.path import is_safe_relative_filename
from talemate.util.response import extract_list from talemate.util.response import extract_list
from talemate.scene_message import CharacterMessage from talemate.scene_message import CharacterMessage
from talemate.world_state.templates import ( from talemate.world_state.templates import (
@@ -808,12 +810,44 @@ class AssistantMixin:
This properly creates a new scene file without modifying the current scene, This properly creates a new scene file without modifying the current scene,
then signals the frontend to load the new scene. then signals the frontend to load the new scene.
The fork always writes a save of its own: a name that is unsafe as a
filename, or that would land on an existing file, is refused rather
than escaping the save directory or overwriting a save. Without a
name it writes a timestamped one beside the scene, slugged so the
generated name cannot be the thing that gets refused.
""" """
try: try:
emit("status", "Preparing to fork scene...", status="busy") emit("status", "Preparing to fork scene...", status="busy")
if not save_name: if not save_name:
save_name = self.scene.generate_name() # new scenes before their first save and restored scenes have
# no filename
base = (
os.path.splitext(self.scene.filename)[0]
if self.scene.filename
else self.scene.project_name
)
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
save_name = f"{util.slugify(base) or 'scene'}_fork_{stamp}"
fork_filename = f"{save_name}.json"
if not is_safe_relative_filename(fork_filename, suffix=".json"):
emit(
"status",
f"'{save_name}' is not a valid save name",
status="error",
)
return
if os.path.exists(os.path.join(self.scene.save_dir, fork_filename)):
emit(
"status",
f"A save named '{save_name}' already exists — pick a different name",
status="error",
)
return
# Find the message to fork from # Find the message to fork from
message = self.scene.get_message(message_id) message = self.scene.get_message(message_id)
@@ -889,7 +923,7 @@ class AssistantMixin:
scene_data["shared_context"] = "" scene_data["shared_context"] = ""
# Write the fork file # Write the fork file
fork_file_path = os.path.join(self.scene.save_dir, f"{save_name}.json") fork_file_path = os.path.join(self.scene.save_dir, fork_filename)
with open(fork_file_path, "w") as f: with open(fork_file_path, "w") as f:
json.dump(scene_data, f, indent=2, cls=SceneEncoder) json.dump(scene_data, f, indent=2, cls=SceneEncoder)

View File

@@ -998,8 +998,7 @@
history to preview any revision, then fork that revision into a new save; browsing and previewing write nothing to disk, history to preview any revision, then fork that revision into a new save; browsing and previewing write nothing to disk,
and a save name an existing save already uses is refused) and restore from a designated restore point save file. Opened and a save name an existing save already uses is refused) and restore from a designated restore point save file. Opened
from the scene tools Save menu, a message's Fork button, or the scene card's three-dot menu. Forking is the timeline's from the scene tools Save menu, a message's Fork button, or the scene card's three-dot menu. Forking is the timeline's
only action in this version — rolling a scene back in place and opening a revision directly are both disabled while they only action in this version — rolling a scene back in place and opening a revision directly are both disabled.
are being reworked.
- path: user-guide/saving.md - path: user-guide/saving.md
title: Saving title: Saving
summary: How scene saving works, .json save files in the project directory, automatic changelog version history, restore summary: How scene saving works, .json save files in the project directory, automatic changelog version history, restore

View File

@@ -410,6 +410,7 @@ import { isVisualAgentReady, VIS_TYPE } from '@/constants/visual';
import { isKnownSceneCharacter } from '@/utils/entityActions'; import { isKnownSceneCharacter } from '@/utils/entityActions';
import { parseCharacterMessage } from '@/utils/characterMessage.js'; import { parseCharacterMessage } from '@/utils/characterMessage.js';
import { primaryModifierLabel } from '@/utils/keyboardModifiers'; import { primaryModifierLabel } from '@/utils/keyboardModifiers';
import { base64ToObjectUrl } from '@/utils/objectUrl.js';
import { import {
getMessageColor as resolveMessageColor, getMessageColor as resolveMessageColor,
getMessageStyle as resolveMessageStyle, getMessageStyle as resolveMessageStyle,
@@ -616,6 +617,7 @@ export default {
// cache (the asset may still be in flight when the dialog opens) // cache (the asset may still be in flight when the dialog opens)
assetViewShow: false, assetViewShow: false,
assetViewAssetId: null, assetViewAssetId: null,
sceneBackdropObjectUrl: null,
} }
}, },
computed: { computed: {
@@ -651,8 +653,9 @@ export default {
sceneBackdropAssetId() { sceneBackdropAssetId() {
return this.sceneBackdrop?.assetId || null; return this.sceneBackdrop?.assetId || null;
}, },
sceneBackdropSrc() { sceneBackdropAsset() {
return this.assetDataUrl(this.sceneBackdropAssetId); const assetId = this.sceneBackdropAssetId;
return assetId ? this.assetCache[assetId] || null : null;
}, },
// Most recent message-attached scene background the scene-tools // Most recent message-attached scene background the scene-tools
// "Immersive" chip could promote to a backdrop when none is set yet // "Immersive" chip could promote to a backdrop when none is set yet
@@ -1336,6 +1339,26 @@ export default {
return cached ? `data:${cached.mediaType};base64,${cached.base64}` : null; return cached ? `data:${cached.mediaType};base64,${cached.base64}` : null;
}, },
requestSceneBackdropAsset() {
const assetId = this.sceneBackdropAssetId;
if (assetId && this.requestSceneAssets && !this.assetCache[assetId]) {
this.requestSceneAssets([assetId]);
}
},
/**
* Swap in the backdrop's object URL, revoking the one it replaces.
* The backdrop reaches the DOM as a CSS custom property, and chromium
* silently drops custom property values over 2 MiB — which a scene
* image as a base64 data URL almost always exceeds.
*/
setSceneBackdropObjectUrl(url) {
if (this.sceneBackdropObjectUrl) {
URL.revokeObjectURL(this.sceneBackdropObjectUrl);
}
this.sceneBackdropObjectUrl = url;
},
showAssetMenu(event, context) { showAssetMenu(event, context) {
// Store the context (asset_id, asset_type, character, etc.) // Store the context (asset_id, asset_type, character, etc.)
this.assetMenu.context = { ...context }; this.assetMenu.context = { ...context };
@@ -2170,14 +2193,29 @@ export default {
}, },
deep: true, deep: true,
}, },
sceneBackdropAssetId(assetId) { sceneBackdropAssetId: {
if (assetId && this.requestSceneAssets && !this.assetCache[assetId]) { immediate: true,
this.requestSceneAssets([assetId]); handler() {
} this.requestSceneBackdropAsset();
},
},
// Scene load empties the asset cache without changing the backdrop id,
// so the id watcher alone would leave a reloaded scene without its
// backdrop image.
sceneBackdropAsset: {
immediate: true,
handler(asset) {
this.setSceneBackdropObjectUrl(
asset ? base64ToObjectUrl(asset.base64, asset.mediaType) : null
);
if (!asset) {
this.requestSceneBackdropAsset();
}
},
}, },
// The backdrop is painted by TalemateApp behind the whole scene // The backdrop is painted by TalemateApp behind the whole scene
// column, so hand the resolved image up // column, so hand the resolved image up
sceneBackdropSrc: { sceneBackdropObjectUrl: {
immediate: true, immediate: true,
handler(src) { handler(src) {
this.$emit('scene-backdrop', src); this.$emit('scene-backdrop', src);
@@ -2201,6 +2239,7 @@ export default {
clearTimeout(this._reapplyDebounceTimer); clearTimeout(this._reapplyDebounceTimer);
this._reapplyDebounceTimer = null; this._reapplyDebounceTimer = null;
} }
this.setSceneBackdropObjectUrl(null);
}, },
} }

View File

@@ -85,7 +85,7 @@
</v-alert> </v-alert>
<v-alert color="muted" icon="mdi-history" density="compact" variant="tonal" class="text-caption mt-3"> <v-alert color="muted" icon="mdi-history" density="compact" variant="tonal" class="text-caption mt-3">
Rolling a scene back in place and opening a revision directly are disabled in this version while they are being reworked, and will return in a future version. Fork the revision into a new save instead — it is written alongside the scene and leaves every existing save untouched. Rolling a scene back in place and opening a revision directly are disabled in this version. Fork the revision into a new save instead — it is written alongside the scene and leaves every existing save untouched.
</v-alert> </v-alert>
</template> </template>
</v-card-text> </v-card-text>

View File

@@ -531,7 +531,7 @@ export default {
data() { data() {
return { return {
appearancePreview: null, // Preview config while editing settings (null = use saved config) appearancePreview: null, // Preview config while editing settings (null = use saved config)
// data-url of the scene illustration acting as the scene backdrop // object URL of the scene illustration acting as the scene backdrop
// (scene.assets.backdrop), reported up by SceneMessages which owns // (scene.assets.backdrop), reported up by SceneMessages which owns
// the asset cache // the asset cache
sceneBackdropSrc: null, sceneBackdropSrc: null,

View File

@@ -106,7 +106,7 @@ export default {
}, },
{ {
"title": "Timeline", "title": "Timeline",
"description": "A new timeline dialog gives scene history a single home, replacing the old Restore from Backup dialog. Drag a slider across the scene's automatic version history to preview the message history at any revision, then fork that revision into a new save — the scene you are playing is never modified, and browsing the history changes nothing on disk.\n\nForking is the only action the timeline applies in this version. Rolling a scene back in place and opening a revision directly are disabled while they are being reworked, and will return in a future version." "description": "A new timeline dialog gives scene history a single home, replacing the old Restore from Backup dialog. Drag a slider across the scene's automatic version history to preview the message history at any revision, then fork that revision into a new save — the scene you are playing is never modified, and browsing the history changes nothing on disk.\n\nForking is the only action the timeline applies. Rolling a scene back in place and opening a revision directly are disabled in this version."
}, },
{ {
"title": "Pi Bridge Client", "title": "Pi Bridge Client",

View File

@@ -0,0 +1,14 @@
/**
* Turn base64 asset data into an object URL. Callers own the returned URL and
* must revoke it. Asset data is fine as a data URL on an <img>, but not in
* CSS: chromium silently drops custom property values over 2 MiB, which scene
* images exceed as base64.
*/
export function base64ToObjectUrl(base64, mediaType = 'image/png') {
const binary = window.atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return URL.createObjectURL(new Blob([bytes], { type: mediaType }));
}

View File

@@ -0,0 +1,153 @@
"""
Tests for the save-name guard on ``creator.fork_scene``.
Forking from a message reaches this path with a user-typed name. Like the
timeline fork, it must refuse a name that is unsafe as a filename or that
would land on an existing save, rather than escaping the save directory or
overwriting a save with forked data.
"""
import os
import re
import pytest
import talemate.instance as instance
from talemate.scene_message import NarratorMessage
from conftest import MockClient, MockScene, bootstrap_engine
class _ForkScene(MockScene):
"""Real ``Scene`` with ``save_dir`` pointed at a tmp dir.
The parent exposes ``save_dir`` as a ``@property`` that joins the scenes
directory with the project name; shadowing it with a class-level plain
attribute lets the instance own it.
"""
save_dir = None # type: ignore[assignment]
def __init__(self, save_dir: str, **kwargs):
super().__init__(**kwargs)
self.save_dir = save_dir
@pytest.fixture
def creator(tmp_path):
bootstrap_engine()
scene = _ForkScene(save_dir=str(tmp_path))
# distinct from the name, so a test can tell which one a generated save
# name was built from
scene.filename = "test_scene.json"
scene.name = "Project Scene"
scene.history.append(NarratorMessage("the scene begins"))
agent = instance.get_agent("creator")
agent.client = MockClient("test_client")
agent.scene = scene
return agent
@pytest.fixture
def emitted(monkeypatch):
"""Capture the agent's status emissions as ``(typ, message, kwargs)``."""
calls = []
monkeypatch.setattr(
"talemate.agents.creator.assistant.emit",
lambda typ, message=None, **kwargs: calls.append((typ, message, kwargs)),
)
return calls
def assert_refused(emitted, message):
assert ("status", message, {"status": "error"}) in emitted
@pytest.fixture
def message_id(creator):
return creator.scene.history[0].id
async def test_fork_writes_a_new_save(creator, message_id, tmp_path):
fork_path = await creator.fork_scene(message_id, save_name="forked")
assert fork_path == os.path.join(str(tmp_path), "forked.json")
assert os.path.exists(fork_path)
@pytest.mark.parametrize("save_name", [None, ""])
async def test_fork_without_a_name_writes_a_timestamped_save(
creator, message_id, tmp_path, save_name
):
fork_path = await creator.fork_scene(message_id, save_name=save_name)
assert fork_path is not None
assert os.path.exists(fork_path)
assert re.fullmatch(
r"test-scene_fork_\d{8}T\d{6}Z\.json", os.path.basename(fork_path)
)
async def test_fork_without_a_name_falls_back_to_the_project_name(
creator, message_id, tmp_path
):
"""New scenes before their first save, and restored scenes, have no
filename."""
creator.scene.filename = ""
fork_path = await creator.fork_scene(message_id)
assert fork_path is not None
assert os.path.exists(fork_path)
assert os.path.basename(fork_path).startswith("project-scene_fork_")
@pytest.mark.parametrize(
"scene_name,expected_base",
[
("Fate/Stay Night", "fate-stay-night"),
("..", "scene"),
],
)
async def test_fork_without_a_name_slugs_the_base(
creator, message_id, tmp_path, scene_name, expected_base
):
"""A scene name the guard would refuse must not make the no-name fork
impossible — the caller supplied nothing, so there is nothing to correct."""
creator.scene.filename = ""
creator.scene.name = scene_name
fork_path = await creator.fork_scene(message_id)
assert fork_path is not None
assert os.path.exists(fork_path)
assert re.fullmatch(
rf"{expected_base}_fork_\d{{8}}T\d{{6}}Z\.json", os.path.basename(fork_path)
)
async def test_fork_refuses_a_name_that_escapes_the_save_dir(
creator, message_id, tmp_path, emitted
):
outside = tmp_path.parent / "escaped.json"
assert await creator.fork_scene(message_id, save_name="../escaped") is None
assert not outside.exists()
assert_refused(emitted, "'../escaped' is not a valid save name")
@pytest.mark.parametrize("save_name", ["nested/fork", "..", "with\x00nul"])
async def test_fork_refuses_unsafe_names(creator, message_id, tmp_path, save_name):
assert await creator.fork_scene(message_id, save_name=save_name) is None
assert list(tmp_path.iterdir()) == []
async def test_fork_refuses_an_existing_save(creator, message_id, tmp_path, emitted):
existing = tmp_path / "taken.json"
existing.write_text("original")
assert await creator.fork_scene(message_id, save_name="taken") is None
assert existing.read_text() == "original"
assert_refused(
emitted, "A save named 'taken' already exists — pick a different name"
)