Spaces:
Sleeping
Sleeping
| <template> | |
| <div class="step-visuals [ stack ]" data-composition="step-container"> | |
| <header class="[ stack ]" data-stack-gap="medium"> | |
| <h2 class="[ text-step-title ] [ text-primary ]">Étape 2 : Configuration Visuelle</h2> | |
| <p class="[ text-secondary ]">Définissez l'esthétique et le format de votre histoire horrifique.</p> | |
| </header> | |
| <div v-if="!currentStory" class="[ card-placeholder ] [ stack items-center justify-center p-12 ]"> | |
| <p class="text-muted">Veuillez d'abord sélectionner une histoire.</p> | |
| <Button label="RETOUR" icon="pi pi-arrow-left" @click="uiStore.setStep(1)" severity="secondary" class="mt-4" /> | |
| </div> | |
| <div v-else class="[ stack ]" style="gap: 3rem"> | |
| <div class="[ grid-main ]"> | |
| <!-- Left Column: Settings --> | |
| <div class="[ stack ]" style="gap: 2rem"> | |
| <section class="[ p-card ] [ stack ]"> | |
| <h3 class="property-label">Moteur de Rendu</h3> | |
| <Select v-model="form.model" :options="modelOptions" optionLabel="label" optionValue="value" class="w-full" /> | |
| <p class="text-tiny text-muted mt-2">Le modèle FLUX.1 offre la meilleure fidélité pour le style Dark Anime.</p> | |
| </section> | |
| <section class="[ p-card ] [ stack ]"> | |
| <h3 class="property-label">Format de Sortie</h3> | |
| <div class="[ flex gap-3 ]"> | |
| <div | |
| v-for="ratio in ratios" | |
| :key="ratio.value" | |
| class="ratio-card [ stack items-center justify-center cursor-pointer ]" | |
| :class="{ active: form.aspectRatio === ratio.value }" | |
| @click="form.aspectRatio = ratio.value" | |
| > | |
| <div class="ratio-preview" :class="'ratio-' + ratio.value.replace(':', '-')"></div> | |
| <span class="text-tiny mt-2 font-bold">{{ ratio.label }}</span> | |
| </div> | |
| </div> | |
| </section> | |
| </div> | |
| <!-- Right Column: Styles --> | |
| <section class="[ p-card ] [ stack ]"> | |
| <h3 class="property-label">Style Artistique</h3> | |
| <div class="style-grid"> | |
| <div | |
| v-for="style in styles" | |
| :key="style.value" | |
| class="style-item [ flex items-center gap-3 p-3 cursor-pointer ]" | |
| :class="{ active: form.style === style.value }" | |
| @click="form.style = style.value" | |
| > | |
| <div class="style-indicator"></div> | |
| <span class="text-small font-bold uppercase">{{ style.label }}</span> | |
| </div> | |
| </div> | |
| </section> | |
| </div> | |
| <!-- Scenes Content Editor --> | |
| <section class="[ stack ]" data-stack-gap="medium"> | |
| <div class="[ flex justify-between items-center px-2 ]"> | |
| <h3 class="property-label m-0">Prompts Visuels par Scène</h3> | |
| <div class="flex items-center gap-4"> | |
| <span class="text-tiny uppercase tracking-tighter text-red">Total : {{ currentStory.scenes.length }} scènes</span> | |
| <Button | |
| label="AMÉLIORER AVEC IA" | |
| icon="pi pi-sparkles" | |
| severity="help" | |
| size="small" | |
| @click="triggerImproveVisuals" | |
| :loading="storiesStore.isLoading" | |
| text | |
| /> | |
| <Button | |
| label="GÉNÉRER TOUTES LES IMAGES" | |
| icon="pi pi-bolt" | |
| severity="danger" | |
| size="small" | |
| @click="triggerImageGeneration" | |
| :loading="uiStore.isGenerating" | |
| /> | |
| </div> | |
| </div> | |
| <div class="[ scenes-grid ]"> | |
| <div v-for="(scene, index) in currentStory.scenes" :key="scene.id" class="[ p-card scene-card ]"> | |
| <div class="flex items-center justify-between mb-4"> | |
| <span class="scene-number">#{{ index + 1 }}</span> | |
| </div> | |
| <!-- Image Preview --> | |
| <div v-if="getSceneImage(scene.id)" class="scene-image-preview mb-4"> | |
| <img :src="getSceneImage(scene.id)" class="w-full rounded border border-red-dark" style="aspect-ratio: 16/10; object-fit: cover;" /> | |
| </div> | |
| <Textarea | |
| v-model="scenePrompts[scene.id]" | |
| autoResize | |
| rows="3" | |
| class="w-full p-inputtext-sm visual-textarea" | |
| placeholder="Prompt visuel de la scène..." | |
| /> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Footer --> | |
| <div class="[ flex justify-between items-center mt-8 ]"> | |
| <Button label="PRÉCÉDENT" icon="pi pi-chevron-left" @click="uiStore.setStep(1)" severity="secondary" text /> | |
| <div class="flex items-center gap-4"> | |
| <span v-if="hasChanges" class="text-tiny italic text-red">Modifications non enregistrées</span> | |
| <Button label="ENREGISTRER & CONTINUER" icon="pi pi-arrow-right" iconPos="right" @click="handleSave" :loading="isSubmitting" severity="primary" /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </template> | |
| <script setup lang="ts"> | |
| import { ref, computed, watch, reactive } from 'vue'; | |
| import { useStoriesStore, useUIStore } from '@presentation/stores'; | |
| import Select from 'primevue/select'; | |
| import Textarea from 'primevue/textarea'; | |
| import Button from 'primevue/button'; | |
| const storiesStore = useStoriesStore(); | |
| const uiStore = useUIStore(); | |
| const currentStory = computed(() => storiesStore.currentStory); | |
| const isSubmitting = ref(false); | |
| const modelOptions = [ | |
| { label: 'Black Forest FLUX.1 (Premium)', value: 'flux' }, | |
| { label: 'SSD-1B (Optimisé)', value: 'ssd-1b' }, | |
| { label: 'Stable Diffusion XL', value: 'sdxl' }, | |
| { label: 'API Gateway', value: 'api' } | |
| ]; | |
| const ratios = [ | |
| { label: 'TikTok (9:16)', value: '9:16' }, | |
| { label: 'Square (1:1)', value: '1:1' }, | |
| { label: 'YouTube (16:9)', value: '16:9' } | |
| ]; | |
| const styles = [ | |
| { label: 'Dark Anime', value: 'anime' }, | |
| { label: 'Gothic Horror', value: 'gothic' }, | |
| { label: 'CCTV Footage', value: 'cctv' }, | |
| { label: 'VHS Vintage', value: 'vhs' }, | |
| { label: 'Glitch Digital', value: 'glitch' } | |
| ]; | |
| const form = ref({ | |
| model: 'flux', | |
| aspectRatio: '9:16', | |
| style: 'anime' | |
| }); | |
| const scenePrompts = reactive<Record<string, string>>({}); | |
| const getSceneImage = (sceneId: string) => { | |
| const asset = storiesStore.currentAssets.find(a => a.sceneId === sceneId && a.type === 'image'); | |
| return asset?.url || null; | |
| }; | |
| watch(currentStory, (story) => { | |
| if (story) { | |
| if (story.visualConfig) { | |
| form.value = { | |
| model: story.visualConfig.model.value, | |
| aspectRatio: story.visualConfig.aspectRatio.value, | |
| style: story.visualConfig.style.value | |
| }; | |
| } | |
| story.scenes.forEach(scene => { | |
| scenePrompts[scene.id] = scene.visualPrompt; | |
| }); | |
| } | |
| }, { immediate: true }); | |
| const hasChanges = computed(() => { | |
| if (!currentStory.value) return false; | |
| const config = currentStory.value.visualConfig; | |
| const configChanged = form.value.model !== config.model.value || | |
| form.value.aspectRatio !== config.aspectRatio.value || | |
| form.value.style !== config.style.value; | |
| let promptChanged = false; | |
| currentStory.value.scenes.forEach(scene => { | |
| if (scenePrompts[scene.id] !== scene.visualPrompt) promptChanged = true; | |
| }); | |
| return configChanged || promptChanged; | |
| }); | |
| const triggerImageGeneration = async () => { | |
| if (!currentStory.value) return; | |
| try { | |
| // 1. First save the current prompts | |
| const sceneUpdates = Object.entries(scenePrompts).map(([id, visualPrompt]) => ({ id, visualPrompt })); | |
| await storiesStore.updateVisualConfig(form.value.model, form.value.aspectRatio, form.value.style, sceneUpdates); | |
| // 2. Trigger the backend generation | |
| await storiesStore.launchImageGeneration(); | |
| } catch (err) { | |
| console.error('Image generation failed', err); | |
| } | |
| }; | |
| const triggerImproveVisuals = async () => { | |
| if (!currentStory.value) return; | |
| try { | |
| await storiesStore.improveVisuals(form.value.style); | |
| uiStore.addNotification('Prompts améliorés avec succès', 'success'); | |
| } catch (err) { | |
| uiStore.addNotification('Échec de l\'amélioration des prompts', 'error'); | |
| } | |
| }; | |
| const handleSave = async () => { | |
| isSubmitting.value = true; | |
| try { | |
| const sceneUpdates = Object.entries(scenePrompts).map(([id, visualPrompt]) => ({ id, visualPrompt })); | |
| await storiesStore.updateVisualConfig(form.value.model, form.value.aspectRatio, form.value.style, sceneUpdates); | |
| uiStore.setStep(3); | |
| } catch (err) { | |
| console.error(err); | |
| } finally { | |
| isSubmitting.value = false; | |
| } | |
| }; | |
| </script> | |
| <style scoped> | |
| .step-visuals { | |
| padding: 1.5rem 2.5rem; | |
| flex: 1; | |
| width: 100%; | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .grid-main { | |
| display: grid; | |
| grid-template-columns: 1.2fr 1fr; | |
| gap: 1.5rem; | |
| align-items: start; | |
| } | |
| .ratio-card { | |
| flex: 1; | |
| background: rgba(255, 255, 255, 0.02); | |
| border: 1px solid var(--border-color); | |
| padding: 1.5rem; | |
| border-radius: 12px; | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| .ratio-card:hover { | |
| border-color: var(--text-muted); | |
| background: rgba(255, 255, 255, 0.04); | |
| } | |
| .ratio-card.active { | |
| border-color: var(--accent-red); | |
| background: rgba(204, 0, 0, 0.1); | |
| box-shadow: 0 0 20px rgba(204, 0, 0, 0.1); | |
| transform: translateY(-2px); | |
| } | |
| .ratio-preview { | |
| background: #222; | |
| border-radius: 4px; | |
| transition: all 0.3s ease; | |
| } | |
| .ratio-preview.ratio-9-16 { width: 18px; height: 32px; } | |
| .ratio-preview.ratio-1-1 { width: 28px; height: 28px; } | |
| .ratio-preview.ratio-16-9 { width: 38px; height: 22px; } | |
| .active .ratio-preview { | |
| background: var(--accent-red-bright); | |
| box-shadow: 0 0 10px var(--accent-red-bright); | |
| } | |
| .style-grid { | |
| display: grid; | |
| grid-template-columns: 1fr; | |
| gap: 0.75rem; | |
| } | |
| .style-item { | |
| border-radius: 8px; | |
| border: 1px solid transparent; | |
| background: rgba(255, 255, 255, 0.01); | |
| transition: all 0.2s ease; | |
| } | |
| .style-item:hover { | |
| background: rgba(255, 255, 255, 0.03); | |
| transform: translateX(4px); | |
| } | |
| .style-item.active { | |
| background: rgba(204, 0, 0, 0.08); | |
| border-color: rgba(204, 0, 0, 0.3); | |
| } | |
| .style-indicator { | |
| width: 6px; | |
| height: 6px; | |
| border-radius: 50%; | |
| background: #333; | |
| transition: all 0.3s ease; | |
| } | |
| .active .style-indicator { | |
| background: var(--accent-red-bright); | |
| box-shadow: 0 0 8px var(--accent-red-bright); | |
| } | |
| .scenes-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); | |
| gap: 1.5rem; | |
| } | |
| .scene-card { | |
| border: 1px solid var(--border-color) ; | |
| background: rgba(255, 255, 255, 0.01) ; | |
| transition: all 0.3s ease; | |
| } | |
| .scene-card:focus-within { | |
| border-color: var(--accent-red) ; | |
| background: rgba(255, 255, 255, 0.02) ; | |
| } | |
| .scene-number { | |
| font-size: 0.7rem; | |
| font-weight: 800; | |
| color: var(--text-muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.1rem; | |
| } | |
| .visual-textarea { | |
| background: rgba(0, 0, 0, 0.3) ; | |
| border: 1px solid var(--border-color) ; | |
| border-radius: 8px; | |
| font-size: 0.8rem; | |
| line-height: 1.6; | |
| color: var(--text-main) ; | |
| } | |
| .w-full { width: 100%; } | |
| </style> | |