Files
talemate/talemate_frontend/src/components/VisualLibraryScene.vue
2026-07-04 23:39:34 +03:00

546 lines
18 KiB
Vue

<template>
<v-row>
<v-col cols="3">
<VisualLibraryUpload :scene="scene" />
<v-card elevation="2">
<v-card-title>Assets</v-card-title>
<v-divider></v-divider>
<v-toolbar density="compact" color="transparent">
<v-text-field
class="mx-3 my-1"
v-model="assetSearchInput"
placeholder="Filter"
prepend-inner-icon="mdi-magnify"
:append-inner-icon="assetSearchInput ? 'mdi-close' : undefined"
@click:append-inner="assetSearchInput = ''"
variant="underlined"
hide-details="auto"
></v-text-field>
</v-toolbar>
<v-card-text style="max-height: 720px; overflow-y: auto;">
<VisualAssetsTree
:assets-map="filteredAssetsMap"
mode="leaf"
color="primary"
density="compact"
:opened="openNodes"
:activated="activeNodes"
@update:opened="onOpenNodesChanged"
@update:activated="onActiveChange"
/>
</v-card-text>
</v-card>
</v-col>
<v-col cols="9">
<div v-if="selectedAsset">
<div class="d-flex mb-2 flex-wrap">
<v-btn color="delete" variant="text" @click="confirmDelete" prepend-icon="mdi-close-box-outline">Delete</v-btn>
<v-spacer></v-spacer>
<v-btn
v-if="selectedId"
:disabled="!analysisAvailable || analyzing"
variant="text"
@click="handleAnalyzeClick"
prepend-icon="mdi-image-search"
color="primary"
:loading="analyzing"
>
<v-tooltip activator="parent" location="top">
Analyze the image using AI. ({{ primaryModifierLabel }}: set instructions)
</v-tooltip>
Analyze
</v-btn>
<v-btn
v-if="analyzing"
variant="text"
@click="cancelAnalysis"
prepend-icon="mdi-cancel"
color="error"
>
<v-tooltip activator="parent" location="top">
Cancel the current analysis
</v-tooltip>
Cancel Analysis
</v-btn>
<v-btn
variant="text"
@click="onSetSceneCoverImage({ assetId: selectedId })"
prepend-icon="mdi-image-frame"
color="primary"
>
<v-tooltip activator="parent" location="top">
Set this image as the scene cover image
</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>
<v-btn v-if="canSaveValue" color="success" @click="saveMeta" variant="text" prepend-icon="mdi-content-save">Save</v-btn>
</div>
<VisualImageView
ref="visualImageView"
:base64="selectedBase64"
:meta="selectedMeta"
:media-type="selectedAsset?.media_type || 'image/png'"
:backend-name="null"
:request-id="null"
:editable="true"
:asset-id="selectedId"
:available-assets-map="assetsMap"
:analysis-available="analysisAvailable"
:busy="appBusy"
:scene="scene"
:initial-tab="initialTab"
@save-meta="onSaveMeta"
@set-scene-cover-image="onSetSceneCoverImage"
/>
</div>
<div v-else class="text-medium-emphasis text-caption">Select an asset to view</div>
</v-col>
</v-row>
<ConfirmActionPrompt
ref="deleteConfirm"
action-label="Delete asset?"
description="This will permanently remove the asset from the scene."
icon="mdi-alert-circle-outline"
color="warning"
:max-width="420"
@confirm="onDeleteConfirmed"
/>
<v-dialog v-model="showAnalyzeDialog" max-width="600">
<v-card>
<v-card-title>
<v-icon class="mr-2" size="small" color="primary">mdi-image-search</v-icon>
Analyze Image
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-textarea
v-model="analyzePrompt"
label="Analysis Prompt"
hint="Describe what you want to analyze about this image"
rows="4"
auto-grow
:disabled="analyzing"
placeholder="e.g., Describe this image in detail, What characters are in this image?, What is the mood and atmosphere?"
></v-textarea>
</v-card-text>
<v-card-actions>
<v-btn
v-if="analyzing"
variant="text"
@click="cancelAnalysis"
prepend-icon="mdi-cancel"
color="error"
>
Cancel Analysis
</v-btn>
<v-spacer></v-spacer>
<v-btn variant="text" @click="closeAnalyzeDialog">Close</v-btn>
<v-btn
color="primary"
@click="analyzeAsset"
:disabled="!analyzePrompt.trim() || analyzing"
:loading="analyzing"
prepend-icon="mdi-image-search"
>
Analyze
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import VisualImageView from './VisualImageView.vue';
import ConfirmActionPrompt from './ConfirmActionPrompt.vue';
import VisualLibraryUpload from './VisualLibraryUpload.vue';
import VisualAssetsTree from './VisualAssetsTree.vue';
import { debounce } from 'lodash';
import { isPrimaryModifier, primaryModifierLabel } from '@/utils/keyboardModifiers';
import { VIS_TYPE, FORMAT_TYPE } from '@/constants/visual';
export default {
name: 'VisualLibraryScene',
emits: ['open-generate', 'open-iterate', 'update:open-nodes', 'update:active-nodes', 'update:selected-id'],
components: { VisualImageView, ConfirmActionPrompt, VisualLibraryUpload, VisualAssetsTree },
inject: ['getWebsocket', 'registerMessageHandler', 'unregisterMessageHandler', 'requestSceneAssets'],
props: {
scene: { type: Object, required: false, default: () => ({}) },
analysisAvailable: { type: Boolean, default: false },
appBusy: { type: Boolean, default: false },
openNodes: { type: Array, default: () => [] },
activeNodes: { type: Array, default: () => [] },
selectedId: { type: String, default: null },
initialTab: { type: String, default: 'info' },
},
data() {
return {
primaryModifierLabel,
base64ById: {},
assetSearchInput: '',
assetSearch: '',
showAnalyzeDialog: false,
analyzePrompt: 'Describe this image in detail. (3 paragraphs max.)',
canSaveValue: false,
analyzing: false,
};
},
computed: {
assetsMap() {
return (this.scene && this.scene.data && this.scene.data.assets && this.scene.data.assets.assets) || {};
},
filteredAssetsMap() {
if (!this.assetSearch || this.assetSearch.length < 1) {
return this.assetsMap;
}
const searchTerm = this.assetSearch.toLowerCase();
const filtered = {};
for (const [id, asset] of Object.entries(this.assetsMap)) {
const meta = (asset && asset.meta) || {};
const name = (meta.name || '').toLowerCase();
const characterName = (meta.character_name || '').toLowerCase();
const tags = (meta.tags || []).map(tag => tag.toLowerCase());
// character_name: startsWith matching (case insensitive)
const matchesCharacterName = characterName && characterName.startsWith(searchTerm);
// name: partial matching (case insensitive)
const matchesName = name && name.includes(searchTerm);
// tags: exact matching on word boundary (case insensitive)
// Using word boundary regex to match whole words only
const tagRegex = new RegExp(`\\b${this.escapeRegex(searchTerm)}\\b`, 'i');
const matchesTag = tags.some(tag => tagRegex.test(tag));
if (matchesCharacterName || matchesName || matchesTag) {
filtered[id] = asset;
}
}
return filtered;
},
selectedAsset() {
if (!this.selectedId) return null;
return this.assetsMap[this.selectedId] || null;
},
selectedMeta() {
return this.selectedAsset ? this.selectedAsset.meta : null;
},
selectedBase64() {
return this.base64ById[this.selectedId] || null;
},
foldersToOpen() {
// If no search, return empty array (let user control what's open)
if (!this.assetSearch || this.assetSearch.length < 1) {
return [];
}
const filteredIds = Object.keys(this.filteredAssetsMap);
if (filteredIds.length === 0) {
return [];
}
const foldersToOpen = new Set();
// Build the same structure as VisualAssetsTree to determine folder IDs
for (const id of filteredIds) {
const asset = this.assetsMap[id];
if (!asset) continue;
const meta = asset.meta || {};
const vis = meta.vis_type || VIS_TYPE.UNSPECIFIED;
const groupKey = (vis && typeof vis === 'string') ? vis : VIS_TYPE.UNSPECIFIED;
// Always open the top-level group folder
foldersToOpen.add(groupKey);
// For CHARACTER_* groups, also open the character name subfolder
if (groupKey.startsWith('CHARACTER_')) {
const charName = meta.character_name || 'Unknown';
foldersToOpen.add(`${groupKey}::${charName}`);
}
}
return Array.from(foldersToOpen);
},
},
methods: {
escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
},
onOpenNodesChanged(nodes) {
this.$emit('update:open-nodes', nodes);
},
onActiveChange(active) {
// active is an array; take last activated id
const id = (active && active.length > 0) ? active[active.length - 1] : null;
// Filter out category node ids (contain '::' or match group keys)
if (id && this.assetsMap[id]) {
this.$emit('update:selected-id', id);
this.$emit('update:active-nodes', active);
if (!this.base64ById[id]) {
this.requestSceneAssets([id]);
}
} else {
this.$emit('update:active-nodes', active);
}
},
handleMessage(message) {
if (message.type === 'scene_asset') {
const { asset_id, asset } = message;
if (asset_id) {
this.base64ById = { ...this.base64ById, [asset_id]: asset };
}
}
// Handle analysis completion messages
if (message.type === 'image_analyzed' || message.type === 'image_analysis_failed') {
this.analyzing = false;
}
},
confirmDelete() {
if (!this.selectedId) return;
this.$refs.deleteConfirm.initiateAction({ id: this.selectedId });
},
onOpenGenerate() {
if (!this.selectedId) return;
const refIds = [this.selectedId];
const meta = this.selectedMeta || {};
const initialRequest = {
prompt: '',
negative_prompt: '',
vis_type: meta.vis_type || VIS_TYPE.UNSPECIFIED,
format: meta.format || FORMAT_TYPE.LANDSCAPE,
character_name: meta.character_name || '',
reference_assets: refIds,
};
this.$emit('open-generate', { referenceAssets: refIds, initialRequest });
},
onOpenIterate() {
if (!this.selectedId || !this.selectedBase64) return;
const meta = this.selectedMeta || {};
const initialRequest = {
prompt: '',
negative_prompt: meta.negative_prompt || '',
vis_type: meta.vis_type || VIS_TYPE.UNSPECIFIED,
format: meta.format || FORMAT_TYPE.LANDSCAPE,
character_name: meta.character_name || '',
reference_assets: [],
};
this.$emit('open-iterate', { base64: this.selectedBase64, initialRequest });
},
onDeleteConfirmed(params) {
const id = params && params.id ? params.id : this.selectedId;
if (!id) return;
this.getWebsocket().send(JSON.stringify({ type: 'scene_assets', action: 'delete', asset_id: id }));
// Optimistically clear selection; scene_status will update thereafter
this.$emit('update:selected-id', null);
},
onSaveMeta(payload) {
if (!payload || !payload.asset_id) return;
this.getWebsocket().send(JSON.stringify({
type: 'scene_assets',
action: 'edit_meta',
asset_id: payload.asset_id,
name: payload.name,
vis_type: payload.vis_type,
prompt: payload.prompt,
negative_prompt: payload.negative_prompt,
character_name: payload.character_name,
tags: payload.tags,
reference: payload.reference,
reference_assets: payload.reference_assets,
analysis: payload.analysis,
cover_bbox: payload.cover_bbox,
}));
},
onSetSceneCoverImage(payload) {
if (!payload || !payload.assetId) return;
this.getWebsocket().send(JSON.stringify({
type: 'scene_assets',
action: 'set_scene_cover_image',
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) {
console.warn('VisualImageView ref not available');
return;
}
// Call the child's saveMeta method which will emit the event
// The child's saveMeta will check canSave internally
ref.saveMeta();
// Update our canSaveValue after save
this.$nextTick(() => {
this.updateCanSave();
});
},
resetForm() {
const ref = this.$refs.visualImageView;
if (!ref) {
console.warn('VisualImageView ref not available');
return;
}
// Call the child's resetForm method
ref.resetForm();
// Update our canSaveValue after reset
this.$nextTick(() => {
this.updateCanSave();
});
},
handleAnalyzeClick(event) {
const ref = this.$refs.visualImageView;
if (!ref) {
console.warn('VisualImageView ref not available');
return;
}
// If Ctrl/Cmd is pressed, open dialog; otherwise quick analyze
if (isPrimaryModifier(event)) {
event.preventDefault();
event.stopPropagation();
this.showAnalyzeDialog = true;
// Sync the prompt from the child component
if (ref.analyzePrompt) {
this.analyzePrompt = ref.analyzePrompt;
}
} else {
// Set analyzing to true when starting
this.analyzing = true;
ref.handleAnalyzeClick(event);
}
},
cancelAnalysis() {
const ref = this.$refs.visualImageView;
if (!ref) {
console.warn('VisualImageView ref not available');
return;
}
ref.cancelAnalysis();
// Set analyzing to false when cancelled
this.analyzing = false;
},
analyzeAsset() {
if (!this.selectedId || !this.analyzePrompt.trim()) return;
const ref = this.$refs.visualImageView;
if (!ref) {
console.warn('VisualImageView ref not available');
return;
}
// Update the child's prompt
ref.analyzePrompt = this.analyzePrompt;
// Set analyzing to true when starting
this.analyzing = true;
ref.analyzeAsset();
this.showAnalyzeDialog = false;
},
closeAnalyzeDialog() {
this.showAnalyzeDialog = false;
},
getCanSave() {
const ref = this.$refs.visualImageView;
if (!ref) return false;
return ref.canSave || false;
},
updateCanSave() {
this.canSaveValue = this.getCanSave();
},
updateAnalyzing() {
const ref = this.$refs.visualImageView;
if (ref) {
this.analyzing = ref.analyzing || false;
}
},
hasUnsavedChanges() {
return this.canSaveValue;
},
},
watch: {
assetSearchInput(newValue) {
this.updateSearchDebounced(newValue);
},
assetSearch(newValue) {
// When filtering is active, open folders containing matches
if (newValue && newValue.length > 0) {
const foldersToOpen = this.foldersToOpen;
if (foldersToOpen && foldersToOpen.length > 0) {
// Merge computed folders with current openNodes to preserve manually opened folders
const merged = new Set([...this.openNodes, ...foldersToOpen]);
this.$emit('update:open-nodes', Array.from(merged));
}
}
},
selectedId(newId) {
if (newId && this.assetsMap[newId] && !this.base64ById[newId]) {
this.requestSceneAssets([newId]);
}
this.$nextTick(() => {
this.updateCanSave();
});
},
selectedMeta: {
deep: true,
handler() {
this.$nextTick(() => {
this.updateCanSave();
});
},
},
},
created() {
this.updateSearchDebounced = debounce((value) => {
this.assetSearch = value;
}, 200);
},
mounted() {
this.registerMessageHandler(this.handleMessage);
// Load image for selected asset if it exists but image isn't loaded yet
if (this.selectedId && this.assetsMap[this.selectedId] && !this.base64ById[this.selectedId]) {
this.requestSceneAssets([this.selectedId]);
}
// Set up interval to check canSave status and analyzing state
this.canSaveInterval = setInterval(() => {
this.updateCanSave();
this.updateAnalyzing();
}, 100);
},
unmounted() {
this.unregisterMessageHandler(this.handleMessage);
if (this.canSaveInterval) {
clearInterval(this.canSaveInterval);
}
},
};
</script>
<style scoped>
</style>