cybermedia's picture
Upload folder using huggingface_hub
702a754 verified
<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) !important;
background: rgba(255, 255, 255, 0.01) !important;
transition: all 0.3s ease;
}
.scene-card:focus-within {
border-color: var(--accent-red) !important;
background: rgba(255, 255, 255, 0.02) !important;
}
.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) !important;
border: 1px solid var(--border-color) !important;
border-radius: 8px;
font-size: 0.8rem;
line-height: 1.6;
color: var(--text-main) !important;
}
.w-full { width: 100%; }
</style>