scene owns the backdrop asset selection

This commit is contained in:
vegu-ai-tools
2026-07-04 23:39:34 +03:00
parent 7e6e746808
commit 91b39ebc74
16 changed files with 379 additions and 170 deletions

View File

@@ -1,6 +1,6 @@
0.39.0.dev:
features:
- "Scene Illustration Background Mode: Scene illustrations gained a new 'Background' display size that renders the most recent illustration as a backdrop filling the whole scene view instead of showing it inline with messages. Message text sits on translucent blurred panels with a drop shadow, and the message input, control chips and buttons turn opaque so everything stays legible against the image; panel opacity and the text shadow are configurable. Scene Backgrounds ('Visualize Scene (Background)') and Scene Illustrations ('Visualize Moment') are individually configurable, so environmental backgrounds can fill the backdrop while moment illustrations keep rendering inline — when both use Background, the most recent image is the active backdrop. An 'Immersive' quick-toggle chip in the scene tools flips the mode whenever the scene has a suitable background image, a small marker icon shows which message set the current backdrop (click it for the image menu), and an Illustration chip on the message hover toolbar keeps the image menu (view, regenerate, edit, select) reachable. Configure under Settings → Appearance → Message Visuals."
- "Scene Backdrop: Any scene illustration can now be set as the scene backdrop — an image that fills the whole scene view behind the messages instead of rendering inline. The backdrop belongs to the scene and is saved with it, so it survives reloads and history edits. Set it via 'Set as scene backdrop' on any illustration's image menu, the 'Set backdrop' button in the Visual Library, or enable 'Auto Backdrop' per visual type (Settings → Appearance → Message Visuals) to have newly generated Scene Backgrounds ('Visualize Scene (Background)') and/or Scene Illustrations ('Visualize Moment') promoted automatically. An 'Immersive' quick-toggle chip in the scene tools turns the backdrop on and off without forgetting the chosen image. Message text sits on translucent panels with a drop shadow for legibility — panel opacity and the text shadow are configurable — and a small marker icon shows which message's image is the current backdrop (click it for the image menu, which stays reachable via an Illustration chip on the message hover toolbar)."
- "Visual Prompt Finalization: The Visualizer agent gained a Prompt Finalization settings tab defining post-processing actions (exact, fuzzy or regex match and replace, or an AI instruction) that rewrite image prompts right before they are sent to the image generation backend. Actions can target positive and/or negative prompts, be restricted to specific visual types, and be overridden per scene. Characters can define their own actions under World Editor → Characters → Visuals → Prompt Finalization, which run after the agent's. Reusable action sets are managed as a new 'Visual prompt finalizer' template type, including a shipped Ideogram JSON preset that converts the positive prompt into an Ideogram 4.0 structured JSON prompt. Prompt-only generation output is finalized as well, and a new FinalizePrompt node exposes the step to custom node graphs."
improvements:
- "Uniform Settings Framework: Agent settings and client settings now share one field-definition schema on the backend and one field renderer on the frontend. Client-specific settings gain the full widget set previously exclusive to agents (sliders, autocompletes, selects with rich choices, per-value notes) plus conditional visibility, and choice lists are delivered to the frontend in a single normalized shape."
@@ -9,7 +9,7 @@
- "OpenRouter Client: A failed model/provider list fetch at startup no longer sticks until the server is restarted — later config saves and client status refreshes now retry it. Setting the API key for the first time during initial setup also correctly triggers the provider fetch."
- "Generation Error Dialog: When several generations failed at the same time (e.g. an API throttle hitting both a summarization and a background task), only the most recent error could be answered — the earlier generation was never resumed and its result (such as a scene summary) was silently lost. Error dialogs are now queued and answered one after another, and pending dialogs are cancelled cleanly when the scene is unloaded or the frontend disconnects."
0.38.0.dev:
0.38.0:
features:
- "Event Module Auto-Register: Event Modules gained an `auto_register` toggle so a module subscribes to its event as soon as it's registered with the scene, without having to be placed in the scene loop graph."
- "Per-Scene Agent Overrides: Agent configuration can now be overridden per scene without changing the global config. The Agent Modal gains a Global / Scene mode switch for toggling and editing overrides; choose, swap, or opt out of the override file under World Editor → Scene → Settings."

View File

@@ -595,15 +595,18 @@ class MarkupMessageStyle(HistoryMessageStyle):
class MessageAssetCadenceConfig(pydantic.BaseModel):
cadence: Literal["always", "never", "on_change"] = "always"
# "background" is only meaningful for scene_illustration and
# scene_background assets
size: Literal["small", "medium", "big", "background"] = "medium"
# opacity of the text panels rendered over the backdrop ("background"
# size mode only)
background_panel_opacity: float = pydantic.Field(default=0.8, ge=0.0, le=1.0)
# drop shadow on message text over the backdrop ("background" size mode
# only)
background_text_shadow: bool = True
size: Literal["small", "medium", "big"] = "medium"
# automatically promote newly generated assets of this kind to the scene
# backdrop (only meaningful for scene_illustration and scene_background)
auto_backdrop: bool = False
def default_message_asset_config(kind: str) -> MessageAssetCadenceConfig:
# scene backgrounds are environmental full-frame images — full width
# by default
if kind == "scene_background":
return MessageAssetCadenceConfig(size="big")
return MessageAssetCadenceConfig()
class SceneAppearance(pydantic.BaseModel):
@@ -625,12 +628,17 @@ class SceneAppearance(pydantic.BaseModel):
# individually configurable
message_assets: Dict[str, MessageAssetCadenceConfig] = pydantic.Field(
default_factory=lambda: {
kind: MessageAssetCadenceConfig() for kind in MESSAGE_ASSET_KINDS
kind: default_message_asset_config(kind) for kind in MESSAGE_ASSET_KINDS
}
)
auto_attach_assets: bool = True
# opacity of the message text panels rendered over the scene backdrop
backdrop_panel_opacity: float = pydantic.Field(default=0.8, ge=0.0, le=1.0)
# drop shadow on message text over the scene backdrop
backdrop_text_shadow: bool = True
@pydantic.field_validator("message_assets", mode="after")
@classmethod
def ensure_default_message_asset_entries(cls, value):
@@ -638,7 +646,7 @@ class SceneAppearance(pydantic.BaseModel):
# in without it — fill the gaps so consumers can rely on all keys
for key in MESSAGE_ASSET_KINDS:
if key not in value:
value[key] = MessageAssetCadenceConfig()
value[key] = default_message_asset_config(key)
return value

View File

@@ -373,8 +373,11 @@ async def load_scene_from_data(
scene.layered_history = []
scene.intent_state.reset()
scene.assets.cover_image = scene_data.get("assets", {}).get("cover_image", None)
scene.assets.load_assets(scene_data.get("assets", {}).get("assets", {}))
asset_data = scene_data.get("assets", {})
scene.assets.cover_image = asset_data.get("cover_image", None)
scene.assets.backdrop = asset_data.get("backdrop", None)
scene.assets.backdrop_enabled = asset_data.get("backdrop_enabled", True)
scene.assets.load_assets(asset_data.get("assets", {}))
# Clean up cover images and message avatars that reference non-existent assets
scene.assets.cleanup_cover_images()

View File

@@ -63,6 +63,13 @@ VIS_TYPE_TO_ASSET_TYPE = {
VIS_TYPE.UNSPECIFIED: None,
}
# vis_types eligible for the scene backdrop, mapped to their
# appearance.scene.message_assets config entry
VIS_TYPE_TO_MESSAGE_ASSET_KIND = {
VIS_TYPE.SCENE_BACKGROUND: "scene_background",
VIS_TYPE.SCENE_ILLUSTRATION: "scene_illustration",
}
def validate_image_data_url(image_data: str) -> None:
"""
@@ -373,6 +380,16 @@ async def _handle_asset_saved(payload: AssetSavedPayload):
message_ids=asset_attachment_context.message_ids,
)
# scene backdrop auto-promotion — a newly generated scene illustration /
# background becomes the backdrop when its kind's appearance config opts
# in; backdrop_enabled is left untouched so an explicit "Immersive" off
# isn't overridden
if payload.new_asset:
kind = VIS_TYPE_TO_MESSAGE_ASSET_KIND.get(asset.meta.vis_type)
if kind and config.appearance.scene.message_assets[kind].auto_backdrop:
await scene.assets.set_scene_backdrop(asset_id=asset.id)
# cover image (scene and character)
if asset_attachment_context.scene_cover:
@@ -434,6 +451,10 @@ class SceneAssets:
self.scene = scene
self._assets_cache = None
self.cover_image = None
# scene backdrop: which asset renders behind the scene text, and
# whether it currently renders at all
self.backdrop: str | None = None
self.backdrop_enabled: bool = True
def _signal_asset_saved(
self,
@@ -586,12 +607,16 @@ class SceneAssets:
def dict(self, *args, **kwargs):
return {
"cover_image": self.cover_image,
"backdrop": self.backdrop,
"backdrop_enabled": self.backdrop_enabled,
"assets": {asset.id: asset.model_dump() for asset in self.assets.values()},
}
def scene_info(self) -> dict:
return {
"cover_image": self.cover_image,
"backdrop": self.backdrop,
"backdrop_enabled": self.backdrop_enabled,
}
def load_assets(self, assets_dict: dict):
@@ -942,13 +967,22 @@ class SceneAssets:
def cleanup_cover_images(self) -> bool:
"""
Checks character cover images and the scene cover image and if they
no longer exist as assets, unsets them.
Checks character cover images, the scene cover image and the scene
backdrop and if they no longer exist as assets, unsets them.
Returns True if any cover images were cleaned up, False otherwise.
Returns True if anything was cleaned up, False otherwise.
"""
cleaned = False
# Check scene backdrop
if self.backdrop and not self.validate_asset_id(self.backdrop):
log.debug(
"Cleaning up scene backdrop",
asset_id=self.backdrop,
)
self.backdrop = None
cleaned = True
# Check scene cover image
if self.cover_image and not self.validate_asset_id(self.cover_image):
log.debug(
@@ -1632,6 +1666,31 @@ class SceneAssets:
return asset_id
async def set_scene_backdrop(
self, asset_id: str | None = None, enabled: bool | None = None
) -> str | None:
"""
Updates the scene backdrop.
Either argument may be omitted to leave that aspect untouched:
asset_id selects which asset renders behind the scene text,
enabled toggles whether it renders at all.
"""
log.debug("set_scene_backdrop", asset_id=asset_id, enabled=enabled)
if asset_id is not None:
if not self.validate_asset_id(asset_id):
log.error("Invalid asset id", asset_id=asset_id)
return None
self.backdrop = asset_id
if enabled is not None:
self.backdrop_enabled = enabled
# scene status carries the backdrop state to the frontend
if self.scene.active:
self.scene.emit_status()
return self.backdrop
async def set_character_cover_image_from_bytes(
self, character: "Character", bytes: bytes, override: bool = False
) -> str:

View File

@@ -46,6 +46,11 @@ class SetSceneCoverImagePayload(pydantic.BaseModel):
asset_id: str
class SetSceneBackdropPayload(pydantic.BaseModel):
asset_id: str | None = None
enabled: bool | None = None
class SetCharacterCoverImagePayload(pydantic.BaseModel):
asset_id: str
character_name: str
@@ -219,6 +224,30 @@ class SceneAssetsPlugin(Plugin):
log.error("set_scene_cover_image_failed", error=e)
await self.signal_operation_failed(f"Failed to set scene cover image: {e}")
async def handle_set_scene_backdrop(self, data: dict):
payload = SetSceneBackdropPayload(**data)
try:
if payload.asset_id is not None and not self.scene.assets.validate_asset_id(
payload.asset_id
):
await self.signal_operation_failed("Invalid asset_id")
return
await self.scene.assets.set_scene_backdrop(
asset_id=payload.asset_id, enabled=payload.enabled
)
# Request the asset for frontend
if payload.asset_id:
self.websocket_handler.request_scene_assets([payload.asset_id])
await self.scene.attempt_auto_save()
await self.signal_operation_done()
except Exception as e:
log.error("set_scene_backdrop_failed", error=e)
await self.signal_operation_failed(f"Failed to set scene backdrop: {e}")
async def handle_set_character_cover_image(self, data: dict):
payload = SetCharacterCoverImagePayload(**data)
asset_id = payload.asset_id

View File

@@ -67,6 +67,13 @@ export default {
}
config.scene.auto_attach_assets = this.$refs.assets.get_auto_attach_assets();
}
// Include shared backdrop legibility settings from Assets component
if(this.$refs.assets && this.$refs.assets.get_backdrop_settings) {
if(!config.scene) {
config.scene = {};
}
Object.assign(config.scene, this.$refs.assets.get_backdrop_settings());
}
return config;
},
onChildChanged() {

View File

@@ -28,6 +28,7 @@
<th class="text-left" style="padding: 8px 12px;">Visual Type</th>
<th class="text-left" style="padding: 8px 12px;">Render Cadence</th>
<th class="text-left" style="padding: 8px 12px;">Display Size</th>
<th class="text-left" style="padding: 8px 12px;">Auto Backdrop</th>
</tr>
</thead>
<tbody>
@@ -58,6 +59,7 @@
style="max-width: 200px;"
></v-select>
</td>
<td></td>
</tr>
<tr>
<td style="padding: 4px 12px;">
@@ -86,6 +88,7 @@
style="max-width: 200px;"
></v-select>
</td>
<td></td>
</tr>
<tr>
<td style="padding: 4px 12px;">
@@ -107,13 +110,21 @@
<td style="padding: 4px 12px;">
<v-select
v-model="config.scene_illustration.size"
:items="sceneIllustrationSizeOptions"
:items="sizeOptions"
density="compact"
variant="outlined"
hide-details
style="max-width: 200px;"
></v-select>
</td>
<td style="padding: 4px 12px;">
<v-checkbox
v-model="config.scene_illustration.auto_backdrop"
color="primary"
density="compact"
hide-details
></v-checkbox>
</td>
</tr>
<tr>
<td style="padding: 4px 12px;">
@@ -135,22 +146,30 @@
<td style="padding: 4px 12px;">
<v-select
v-model="config.scene_background.size"
:items="sceneIllustrationSizeOptions"
:items="sizeOptions"
density="compact"
variant="outlined"
hide-details
style="max-width: 200px;"
></v-select>
</td>
<td style="padding: 4px 12px;">
<v-checkbox
v-model="config.scene_background.auto_backdrop"
color="primary"
density="compact"
hide-details
></v-checkbox>
</td>
</tr>
</tbody>
</v-table>
<v-row v-for="kind in backgroundConfiguredKinds" :key="kind" class="mt-3">
<v-row class="mt-3">
<v-col cols="12" md="6">
<v-slider
v-model="config[kind].background_panel_opacity"
:label="`Message panel opacity (${kindLabels[kind]})`"
v-model="backdropPanelOpacity"
label="Backdrop message panel opacity"
color="primary"
:min="0"
:max="1"
@@ -162,8 +181,8 @@
</v-col>
<v-col cols="12" md="6">
<v-checkbox
v-model="config[kind].background_text_shadow"
:label="`Message text shadow (${kindLabels[kind]})`"
v-model="backdropTextShadow"
label="Backdrop message text shadow"
color="primary"
density="compact"
hide-details
@@ -178,7 +197,8 @@
<strong>Never:</strong> Never show visual inline with messages<br>
<strong>On change:</strong> Only show when visual changes (portraits: tracked per character)<br><br>
<strong>Scene Illustration</strong> covers images of the current moment ("Visualize Moment"), <strong>Scene Background</strong> covers purely environmental images ("Visualize Scene (Background)").<br>
<strong>Sizes:</strong> Big = full width above message, Small/Medium = inline with text, Background = fills behind the scene text. When both types use Background, the most recent image is the active backdrop.
<strong>Sizes:</strong> Big = full width above message, Small/Medium = inline with text.<br>
<strong>Auto Backdrop:</strong> Newly generated images of this type automatically become the scene backdrop (rendered behind the scene text). Any illustration can also be set as the backdrop manually via its image menu, and the scene-tools Immersive chip toggles the backdrop on and off.
</div>
</v-card-text>
</v-card>
@@ -186,8 +206,6 @@
</template>
<script>
import { BACKDROP_ASSET_KINDS } from '@/constants/visual';
function defaultAssetConfig() {
return {
avatar: {
@@ -201,14 +219,12 @@ function defaultAssetConfig() {
scene_illustration: {
cadence: 'always',
size: 'medium',
background_panel_opacity: 0.8,
background_text_shadow: true,
auto_backdrop: false,
},
scene_background: {
cadence: 'always',
size: 'medium',
background_panel_opacity: 0.8,
background_text_shadow: true,
size: 'big',
auto_backdrop: false,
},
};
}
@@ -223,11 +239,9 @@ export default {
data() {
return {
autoAttachAssets: true,
backdropPanelOpacity: 0.8,
backdropTextShadow: true,
config: defaultAssetConfig(),
kindLabels: {
scene_illustration: 'Scene Illustration',
scene_background: 'Scene Background',
},
cadenceOptions: [
{ title: 'Always', value: 'always' },
{ title: 'Never', value: 'never' },
@@ -245,19 +259,6 @@ export default {
isHydrating: false, // Flag to suppress changed events during initialization
}
},
computed: {
// "background" only makes sense for scene illustrations/backgrounds
sceneIllustrationSizeOptions() {
return [...this.sizeOptions, { title: 'Background', value: 'background' }];
},
// kinds currently set to the Background display size — each gets its
// own panel-opacity / text-shadow controls
backgroundConfiguredKinds() {
return BACKDROP_ASSET_KINDS.filter(
kind => this.config[kind].size === 'background'
);
},
},
watch: {
immutableConfig: {
handler: function(newVal) {
@@ -275,6 +276,10 @@ export default {
// Load auto_attach_assets setting
this.autoAttachAssets = sceneConfig.auto_attach_assets !== undefined ? sceneConfig.auto_attach_assets : true;
// Backdrop legibility settings
this.backdropPanelOpacity = sceneConfig.backdrop_panel_opacity ?? 0.8;
this.backdropTextShadow = sceneConfig.backdrop_text_shadow ?? true;
// Overlay stored values onto the defaults (?? so 0 / false
// survive)
@@ -312,6 +317,20 @@ export default {
}
},
},
backdropPanelOpacity: {
handler: function(newVal, oldVal) {
if (oldVal !== undefined && !this.isHydrating) {
this.$emit('changed');
}
},
},
backdropTextShadow: {
handler: function(newVal, oldVal) {
if (oldVal !== undefined && !this.isHydrating) {
this.$emit('changed');
}
},
},
},
methods: {
// Expose config for parent component
@@ -322,6 +341,13 @@ export default {
get_auto_attach_assets() {
return this.autoAttachAssets;
},
// Expose shared backdrop legibility settings for parent component
get_backdrop_settings() {
return {
backdrop_panel_opacity: this.backdropPanelOpacity,
backdrop_text_shadow: this.backdropTextShadow,
};
},
},
}
</script>

View File

@@ -19,6 +19,8 @@ export default {
// Provided by SceneMessages — splits scene_illustration assets into the
// scene_background / scene_illustration config entries by vis_type
resolveMessageAssetConfigKey: { default: null },
// Provided by SceneMessages — asset id of the active scene backdrop
getSceneBackdropAssetId: { default: null },
},
computed: {
messageAssetDisplaySize() {
@@ -35,15 +37,17 @@ export default {
isSceneIllustrationAbove() {
return this.assetType === 'scene_illustration' && this.messageAssetDisplaySize === 'big';
},
// "background" mode: the illustration is rendered as a backdrop behind the
// scene text (handled by SceneMessages), not inline with the message
// The illustration is the active scene backdrop, rendered behind the
// scene text (painted by TalemateApp), not inline with the message
isSceneIllustrationBackground() {
return this.assetType === 'scene_illustration' && this.messageAssetDisplaySize === 'background';
return this.assetType === 'scene_illustration' &&
!!this.assetId &&
this.getSceneBackdropAssetId?.() === this.assetId;
},
// In background mode there is no inline image to click, so the message
// toolbar offers a chip to reach the asset menu instead
// The backdrop has no inline image to click, so the message toolbar
// offers a chip to reach the asset menu instead
illustrationMenuAvailable() {
return this.isSceneIllustrationBackground && !!this.assetId;
return this.isSceneIllustrationBackground;
},
},
methods: {

View File

@@ -155,8 +155,8 @@ export default {
type: Boolean,
default: false,
},
// offer the illustration asset menu (used in "background" display mode,
// where no inline image is rendered to click on)
// offer the illustration asset menu (used when the message's image is
// the active scene backdrop, where no inline image is rendered to click on)
showIllustrationMenu: {
type: Boolean,
default: false,

View File

@@ -115,6 +115,16 @@
Choose from existing scene illustrations
</v-list-item-subtitle>
</v-list-item>
<v-list-item
v-if="assetMenu.context.asset_type === 'scene_illustration'"
prepend-icon="mdi-image-area"
@click="handleSetSceneBackdrop"
>
<v-list-item-title>Set as scene backdrop</v-list-item-title>
<v-list-item-subtitle class="text-wrap">
Render this image behind the scene text
</v-list-item-subtitle>
</v-list-item>
<v-divider v-if="assetMenu.context.asset_type === 'card'"></v-divider>
<v-list-item
v-if="assetMenu.context.asset_type === 'card'"
@@ -599,7 +609,7 @@ export default {
// cleared on the editor `operation_done` envelope, which has no id)
revisionPendingId: null,
// fallback image viewer for asset-menu "View Image" when the menu
// wasn't opened from a MessageAssetImage (background display mode);
// wasn't opened from a MessageAssetImage (backdrop indicator);
// tracks the asset id so the src resolves reactively from the
// cache (the asset may still be in flight when the dialog opens)
assetViewShow: false,
@@ -611,31 +621,30 @@ export default {
return this.appearanceConfig?.scene?.message_assets || null;
},
sceneBackdropPanelOpacity() {
const kind = this.sceneBackdrop?.kind || 'scene_illustration';
return this.messageAssetsConfig?.[kind]?.background_panel_opacity ?? 0.8;
return this.appearanceConfig?.scene?.backdrop_panel_opacity ?? 0.8;
},
sceneBackdropTextShadow() {
const kind = this.sceneBackdrop?.kind || 'scene_illustration';
return this.messageAssetsConfig?.[kind]?.background_text_shadow ?? true;
return this.appearanceConfig?.scene?.backdrop_text_shadow ?? true;
},
assetViewSrc() {
return this.assetDataUrl(this.assetViewAssetId);
},
sceneBackdrop() {
// Most recent illustration whose config entry (scene_background
// or scene_illustration, resolved by vis_type) is in "background"
// display mode becomes the backdrop
for (let i = this.messages.length - 1; i >= 0; i--) {
const msg = this.messages[i];
if (msg.asset_type !== 'scene_illustration' || !msg.asset_id) {
continue;
}
const kind = this.messageAssetConfigKey(msg.asset_id, msg.asset_type);
if (this.messageAssetsConfig?.[kind]?.size === 'background') {
return { assetId: msg.asset_id, messageId: msg.id, kind };
}
// The scene object is the source of truth: assets.backdrop names
// the asset, assets.backdrop_enabled gates rendering. messageId
// (when the asset is message-attached) drives the indicator icon
const assets = this.scene?.data?.assets;
if (!assets?.backdrop || !assets.backdrop_enabled) {
return null;
}
return null;
const assetId = assets.backdrop;
const message = this.messages.find(
(msg) => msg.asset_id === assetId && msg.asset_type === 'scene_illustration'
);
return {
assetId,
messageId: message?.id ?? null,
};
},
sceneBackdropAssetId() {
return this.sceneBackdrop?.assetId || null;
@@ -643,14 +652,17 @@ export default {
sceneBackdropSrc() {
return this.assetDataUrl(this.sceneBackdropAssetId);
},
// Whether the scene has any message-attached asset the scene-tools
// "Immersive" chip could promote to a backdrop (independent of the
// current display mode)
sceneBackdropCandidateAvailable() {
return this.messages.some(
(msg) => msg.asset_id && msg.asset_type === 'scene_illustration' &&
this.messageAssetConfigKey(msg.asset_id, msg.asset_type) === 'scene_background'
);
// Most recent message-attached scene background the scene-tools
// "Immersive" chip could promote to a backdrop when none is set yet
sceneBackdropCandidateAssetId() {
for (let i = this.messages.length - 1; i >= 0; i--) {
const msg = this.messages[i];
if (msg.asset_id && msg.asset_type === 'scene_illustration' &&
this.messageAssetConfigKey(msg.asset_id, msg.asset_type) === 'scene_background') {
return msg.asset_id;
}
}
return null;
},
editorRevisionsEnabled() {
return this.agentStatus && this.agentStatus.editor && this.agentStatus.editor.actions && this.agentStatus.editor.actions["revision"] && this.agentStatus.editor.actions["revision"].enabled;
@@ -724,6 +736,9 @@ export default {
visualizeMessage: this.visualizeMessage,
isMessageVisualizing: this.isMessageVisualizing,
resolveMessageAssetConfigKey: this.messageAssetConfigKey,
// Active backdrop asset id — message components hide the inline
// image for the asset currently rendered as the backdrop
getSceneBackdropAssetId: () => this.sceneBackdropAssetId,
}
},
methods: {
@@ -1347,9 +1362,9 @@ export default {
// Menu opened from an inline MessageAssetImage: its callback
// opens the component's own AssetView. Otherwise (e.g. the
// toolbar chip in "background" display mode) use the shared
// fallback viewer; assetViewSrc resolves from the cache so it
// fills in once the asset arrives
// toolbar chip when the image is the active backdrop) use the
// shared fallback viewer; assetViewSrc resolves from the cache
// so it fills in once the asset arrives
if (this.assetMenu.context.onViewImage) {
this.assetMenu.context.onViewImage();
} else if (this.assetMenu.context.asset_id) {
@@ -1374,9 +1389,27 @@ export default {
return visType === VIS_TYPE.SCENE_BACKGROUND ? 'scene_background' : 'scene_illustration';
},
/**
* Handle "Set as scene backdrop" menu option — makes this asset the
* scene's backdrop (scene-persisted) and enables rendering
*/
handleSetSceneBackdrop() {
this.assetMenu.show = false;
const assetId = this.assetMenu.context.asset_id;
if (!assetId) {
return;
}
this.getWebsocket().send(JSON.stringify({
type: 'scene_assets',
action: 'set_scene_backdrop',
asset_id: assetId,
enabled: true,
}));
},
/**
* Open the shared asset menu from the backdrop indicator icon —
* in background mode there is no inline image to click on
* the active backdrop has no inline image to click on
*/
openBackdropIndicatorMenu(event, message) {
this.showAssetMenu(event, {
@@ -2143,11 +2176,12 @@ export default {
this.$emit('scene-backdrop', src);
},
},
// Drives visibility of the scene-tools "Immersive" toggle chip
sceneBackdropCandidateAvailable: {
// Gives the scene-tools "Immersive" toggle chip an asset to promote
// when the scene has no backdrop set yet
sceneBackdropCandidateAssetId: {
immediate: true,
handler(available) {
this.$emit('scene-backdrop-candidate', available);
handler(assetId) {
this.$emit('scene-backdrop-candidate', assetId);
},
},
},
@@ -2170,10 +2204,9 @@ export default {
overflow-y: auto;
}
/* Scene illustration "background" display mode: the backdrop itself is
painted by TalemateApp on the scene column (.scene-backdrop-active);
here each message gets a translucent panel so the text stays legible
against an unknown image. */
/* Scene backdrop: the backdrop itself is painted by TalemateApp on the
scene column (.scene-backdrop-active); here each message gets a
translucent panel so the text stays legible against an unknown image. */
.scene-backdrop-active .message {
background-color: rgba(var(--v-theme-surface), var(--scene-backdrop-panel-opacity, 0.8));
backdrop-filter: blur(4px);

View File

@@ -228,7 +228,6 @@ import SceneToolsSave from './SceneToolsSave.vue';
import SceneToolsTime from './SceneToolsTime.vue';
import RequestInput from './RequestInput.vue';
import { isPrimaryModifier, primaryModifierLabel } from '@/utils/keyboardModifiers';
import { BACKDROP_ASSET_KINDS } from '@/constants/visual';
export default {
@@ -260,12 +259,9 @@ export default {
agentStatus: Object,
scene: Object,
visualAgentReady: Boolean,
// scene has a message-attached background-type asset the
// "Immersive" chip could promote to a backdrop
sceneBackdropCandidate: Boolean,
// a backdrop is currently rendering (any kind) — keeps the chip
// reachable so the mode can be toggled off
sceneBackdropActive: Boolean,
// most recent message-attached background asset the "Immersive"
// chip promotes when the scene has no backdrop set yet
sceneBackdropCandidate: String,
audioPlayedForMessageId: [Number, String],
},
computed: {
@@ -301,9 +297,12 @@ export default {
return ttsAgent && ttsAgent.available;
},
sceneBackdropAssetId() {
return this.scene?.data?.assets?.backdrop || null;
},
immersiveActive() {
const messageAssets = this.appConfig()?.appearance?.scene?.message_assets;
return BACKDROP_ASSET_KINDS.some(kind => messageAssets?.[kind]?.size === 'background');
return !!(this.sceneBackdropAssetId && this.scene?.data?.assets?.backdrop_enabled);
},
visibleQuickSettings() {
@@ -333,12 +332,8 @@ export default {
quickSettings: [
{"value": "toggleAutoSave", "title": "Auto Save", "icon": "mdi-content-save", "description": "Automatically save after each game-loop", "status": () => { return this.canAutoSave ? this.autoSave : "Manually save scene for auto-save to be available"; }},
{"value": "toggleAutoProgress", "title": "Auto Progress", "icon": "mdi-robot", "description": "AI automatically progresses after player turn.", "status": () => { return this.autoProgress }},
{"value": "toggleImmersive", "title": "Immersive", "icon": "mdi-image-area", "description": "Render the most recent scene background image as a backdrop behind the scene", "condition": () => { return this.sceneBackdropCandidate || this.sceneBackdropActive }, "status": () => { return this.immersiveActive }},
{"value": "toggleImmersive", "title": "Immersive", "icon": "mdi-image-area", "description": "Render the scene backdrop image behind the scene", "condition": () => { return !!(this.sceneBackdropAssetId || this.sceneBackdropCandidate) }, "status": () => { return this.immersiveActive }},
],
// per-kind inline sizes to restore when Immersive is toggled off
immersiveInlineSizes: {},
// kinds to flip back to background when toggled on again
immersiveKinds: ['scene_background'],
}
},
inject: [
@@ -348,7 +343,6 @@ export default {
'isWaitingForInput',
'creativeEditor',
'appConfig',
'setMessageAssetDisplaySizes',
'getTrackedCharacterState',
'getTrackedWorldState',
'getPlayerCharacterName',
@@ -386,27 +380,18 @@ export default {
},
toggleImmersive() {
const messageAssets = this.appConfig()?.appearance?.scene?.message_assets || {};
const sizes = {};
if (this.immersiveActive) {
// restore every kind currently in background mode to its
// remembered inline size
for (const kind of BACKDROP_ASSET_KINDS) {
if (messageAssets[kind]?.size === 'background') {
sizes[kind] = this.immersiveInlineSizes[kind] || 'medium';
}
}
this.immersiveKinds = Object.keys(sizes);
// the scene owns the backdrop: toggle rendering when one is set,
// otherwise promote the most recent scene background image
const message = { type: 'scene_assets', action: 'set_scene_backdrop' };
if (this.sceneBackdropAssetId) {
message.enabled = !this.immersiveActive;
} else if (this.sceneBackdropCandidate) {
message.asset_id = this.sceneBackdropCandidate;
message.enabled = true;
} else {
for (const kind of this.immersiveKinds) {
const currentSize = messageAssets[kind]?.size;
if (currentSize && currentSize !== 'background') {
this.immersiveInlineSizes[kind] = currentSize;
}
sizes[kind] = 'background';
}
return;
}
this.setMessageAssetDisplaySizes(sizes);
this.getWebsocket().send(JSON.stringify(message));
},
openWorldStateManager(tab, sub1, sub2, sub3) {

View File

@@ -287,7 +287,6 @@
:activeCharacters="activeCharacters"
:visual-agent-ready="visualAgentReady"
:scene-backdrop-candidate="sceneBackdropCandidate"
:scene-backdrop-active="!!sceneBackdropSrc"
:audioPlayedForMessageId="audioPlayedForMessageId" />
<SceneMessageInput
ref="sceneMessageInput"
@@ -459,11 +458,12 @@ export default {
return {
appearancePreview: null, // Preview config while editing settings (null = use saved config)
// data-url of the scene illustration acting as the scene backdrop
// ("background" display mode), reported up by SceneMessages
// (scene.assets.backdrop), reported up by SceneMessages which owns
// the asset cache
sceneBackdropSrc: null,
// scene has a message-attached asset eligible for backdrop promotion
// (drives the scene-tools "Immersive" toggle chip)
sceneBackdropCandidate: false,
// asset id of the most recent message-attached background image the
// scene-tools "Immersive" chip promotes when no backdrop is set
sceneBackdropCandidate: null,
tab: 'home',
tabs: [
{
@@ -834,7 +834,6 @@ export default {
appConfig: () => this.appConfig,
openAppConfig: this.openAppConfig,
openAgentActionOverrides: () => this.$refs.agentActionOverrides?.open(),
setMessageAssetDisplaySizes: this.setMessageAssetDisplaySizes,
configurationRequired: () => this.configurationRequired(),
getTrackedCharacterState: (name, question) => this.$refs.worldState.trackedCharacterState(name, question),
getTrackedCharacterStates: (name) => this.$refs.worldState.trackedCharacterStates(name),
@@ -1355,24 +1354,6 @@ export default {
}
this.websocket.send(JSON.stringify({ type: 'configure_clients', clients: saveData }));
},
setMessageAssetDisplaySizes(sizes) {
// Shortcut used by the scene-tools "Immersive" chip to flip
// message-asset display modes ({kind: size}, applied in one save)
// without opening the appearance settings.
// IMPORTANT: the save handler validates the payload as the FULL config
// model — a partial payload gets its missing sections replaced with
// defaults. Always send the complete config (same as AppConfig.vue).
if (!this.appConfig) {
return;
}
// appConfig is a full backend model dump — the nested structure
// always exists
const config = JSON.parse(JSON.stringify(this.appConfig));
for (const [assetKind, size] of Object.entries(sizes)) {
config.appearance.scene.message_assets[assetKind].size = size;
}
this.websocket.send(JSON.stringify({ type: 'config', action: 'save', config }));
},
saveAgents(agents) {
const saveData = {}
@@ -1773,9 +1754,9 @@ export default {
overflow-x: auto;
}
/* Scene illustration "background" display mode: the most recent scene
illustration fills the whole scene column (messages, tools and input);
SceneMessages gives each message a translucent panel for legibility. */
/* Scene backdrop: the scene's backdrop image (scene.assets.backdrop) fills
the whole scene column (messages, tools and input); SceneMessages gives
each message a translucent panel for legibility. */
.scene-backdrop-active {
background-image: var(--scene-backdrop-image);
background-size: cover;

View File

@@ -62,10 +62,10 @@
</v-tooltip>
Cancel Analysis
</v-btn>
<v-btn
variant="text"
<v-btn
variant="text"
@click="onSetSceneCoverImage({ assetId: selectedId })"
prepend-icon="mdi-image-frame"
prepend-icon="mdi-image-frame"
color="primary"
>
<v-tooltip activator="parent" location="top">
@@ -73,6 +73,17 @@
</v-tooltip>
Set cover
</v-btn>
<v-btn
variant="text"
@click="onSetSceneBackdrop({ assetId: selectedId })"
prepend-icon="mdi-image-area"
color="primary"
>
<v-tooltip activator="parent" location="top">
Set this image as the scene backdrop and render it behind the scene text
</v-tooltip>
Set backdrop
</v-btn>
<v-btn color="primary" variant="text" :disabled="!selectedId" @click="onOpenGenerate" prepend-icon="mdi-play">Use as reference</v-btn>
<v-btn color="primary" variant="text" :disabled="!selectedId" @click="onOpenIterate" prepend-icon="mdi-repeat">Iterate</v-btn>
<v-btn v-if="canSaveValue" variant="text" @click="resetForm" prepend-icon="mdi-cancel" color="cancel">Reset</v-btn>
@@ -368,6 +379,15 @@ export default {
asset_id: payload.assetId,
}));
},
onSetSceneBackdrop(payload) {
if (!payload || !payload.assetId) return;
this.getWebsocket().send(JSON.stringify({
type: 'scene_assets',
action: 'set_scene_backdrop',
asset_id: payload.assetId,
enabled: true,
}));
},
saveMeta() {
const ref = this.$refs.visualImageView;
if (!ref) {

View File

@@ -22,11 +22,6 @@ export const VIS_TYPE_OPTIONS = [
VIS_TYPE.UNSPECIFIED,
];
// message_assets appearance-config kinds whose "background" display size
// renders the asset as the scene backdrop (keys in
// config.appearance.scene.message_assets, see config/schema.py)
export const BACKDROP_ASSET_KINDS = ['scene_illustration', 'scene_background'];
// Must match FORMAT_TYPE in src/talemate/agents/visual/schema.py
export const FORMAT_TYPE = Object.freeze({
LANDSCAPE: 'LANDSCAPE',

View File

@@ -488,6 +488,9 @@ class TestSceneAppearanceMessageAssets:
def test_defaults_include_all_entries(self):
appearance = SceneAppearance()
assert set(appearance.message_assets.keys()) == self.DEFAULT_KEYS
# scene backgrounds default to full-width display
assert appearance.message_assets["scene_background"].size == "big"
assert appearance.message_assets["scene_illustration"].size == "medium"
def test_missing_entries_are_filled(self):
# configs saved before scene_background existed omit it — the
@@ -496,11 +499,11 @@ class TestSceneAppearanceMessageAssets:
message_assets={
"avatar": {"cadence": "never", "size": "small"},
"card": {},
"scene_illustration": {"size": "background"},
"scene_illustration": {"size": "big"},
}
)
assert set(appearance.message_assets.keys()) == self.DEFAULT_KEYS
assert appearance.message_assets["avatar"].cadence == "never"
assert appearance.message_assets["avatar"].size == "small"
assert appearance.message_assets["scene_illustration"].size == "background"
assert appearance.message_assets["scene_background"].size == "medium"
assert appearance.message_assets["scene_illustration"].size == "big"
assert appearance.message_assets["scene_background"].size == "big"

View File

@@ -441,10 +441,14 @@ class TestSceneAssetsDictAndSceneInfo:
assert result["cover_image"] == "cover-id"
assert "a1" in result["assets"]
def test_scene_info_only_returns_cover_image(self, scene):
def test_scene_info_returns_cover_image_and_backdrop(self, scene):
scene.assets.cover_image = "cv"
info = scene.assets.scene_info()
assert info == {"cover_image": "cv"}
assert info == {
"cover_image": "cv",
"backdrop": None,
"backdrop_enabled": True,
}
def test_load_assets_is_a_noop(self, scene):
# Legacy method -- must not raise and must not modify state.
@@ -806,6 +810,45 @@ class TestSceneCoverImage:
assert scene.assets.cover_image == b.id
class TestSceneBackdrop:
async def test_set_backdrop_with_valid_id(self, scene):
a = await scene.assets.add_asset(b"x", "png", "image/png")
result = await scene.assets.set_scene_backdrop(asset_id=a.id)
assert result == a.id
assert scene.assets.backdrop == a.id
# enabled defaults to True and is untouched by asset-only updates
assert scene.assets.backdrop_enabled is True
async def test_set_backdrop_invalid_returns_none(self, scene):
result = await scene.assets.set_scene_backdrop(asset_id="nope")
assert result is None
assert scene.assets.backdrop is None
async def test_toggle_enabled_keeps_asset(self, scene):
a = await scene.assets.add_asset(b"x", "png", "image/png")
await scene.assets.set_scene_backdrop(asset_id=a.id)
result = await scene.assets.set_scene_backdrop(enabled=False)
assert result == a.id
assert scene.assets.backdrop == a.id
assert scene.assets.backdrop_enabled is False
async def test_set_asset_does_not_reenable(self, scene):
# a new backdrop image must not override an explicit "off"
a = await scene.assets.add_asset(b"a", "png", "image/png")
b = await scene.assets.add_asset(b"b", "png", "image/png")
await scene.assets.set_scene_backdrop(asset_id=a.id, enabled=False)
await scene.assets.set_scene_backdrop(asset_id=b.id)
assert scene.assets.backdrop == b.id
assert scene.assets.backdrop_enabled is False
async def test_backdrop_in_dict_and_scene_info(self, scene):
a = await scene.assets.add_asset(b"x", "png", "image/png")
await scene.assets.set_scene_backdrop(asset_id=a.id, enabled=True)
for data in (scene.assets.dict(), scene.assets.scene_info()):
assert data["backdrop"] == a.id
assert data["backdrop_enabled"] is True
class TestSceneCoverImageFromSources:
async def test_from_bytes(self, scene):
rid = await scene.assets.set_scene_cover_image_from_bytes(_png_bytes())
@@ -995,6 +1038,19 @@ class TestCleanupCoverImages:
assert cleaned is True
assert char.cover_image is None
def test_cleans_dangling_backdrop(self, scene):
scene.assets.backdrop = "ghost"
cleaned = scene.assets.cleanup_cover_images()
assert cleaned is True
assert scene.assets.backdrop is None
async def test_keeps_valid_backdrop(self, scene):
a = await scene.assets.add_asset(b"x", "png", "image/png")
scene.assets.backdrop = a.id
cleaned = scene.assets.cleanup_cover_images()
assert cleaned is False
assert scene.assets.backdrop == a.id
class TestCleanupCharacterAvatars:
def test_cleans_dangling_default_and_current_avatar(self, scene):