Files
mediagen/src/app/components/video-generator/video-generator.component.ts
T
2026-08-21 18:26:12 +02:00

1025 lines
33 KiB
TypeScript

import { Component, EventEmitter, Input, Output, ViewChild, ElementRef, OnInit, OnChanges, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { OpenRouterModel, ReferenceMedia, VideoGenerationConfig, VideoScriptResponse } from '../../models/openrouter.models';
@Component({
selector: 'app-video-generator',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="video-container animate-fade-in">
<div class="input-card">
<div class="card-title-row">
<div>
<h2 class="section-title">🎬 Generatore Video AI Multi-Modello</h2>
<p class="section-desc">Genera video cinematografici ad alta fedeltà visiva (Seedance 2.5, Kling v3.0, Veo 3.1, Hailuo, Wan, Sora).</p>
</div>
<!-- Mode Selector Switcher -->
<div class="mode-switcher">
<button
type="button"
class="mode-btn"
[class.active]="generateMode === 'video'"
(click)="generateMode = 'video'">
📹 Video MP4 (Reale)
</button>
<button
type="button"
class="mode-btn"
[class.active]="generateMode === 'script'"
(click)="generateMode = 'script'">
📝 Storyboard Script
</button>
</div>
</div>
<div class="form-group">
<div class="label-row">
<label>Prompt / Descrizione del Video</label>
<span class="prompt-hint-tag" *ngIf="referenceItems.length > 0">💡 Usa <strong>&#64;</strong> per menzionare una reference (es: <code>&#64;ref1</code>, <code>&#64;ref2</code>)</span>
</div>
<!-- Tag Insertion Toolbar -->
<div *ngIf="referenceItems.length > 0" class="tag-toolbar animate-fade-in">
<span class="toolbar-label">Collega nel Prompt:</span>
<button
*ngFor="let item of referenceItems"
type="button"
class="ref-tag-pill"
(click)="insertTagIntoPrompt(item.tag)"
[title]="'Clicca per inserire ' + item.tag + ' nel prompt'">
<img [src]="item.previewUrl" class="mini-tag-thumb" />
<strong>{{ item.tag }}</strong>
<span class="tag-subname">{{ item.name }}</span>
</button>
</div>
<textarea
#promptInputArea
[(ngModel)]="concept"
(input)="onPromptInput($event)"
placeholder="Es: Inizia con @ref1 che cammina in una piazza deserta di notte. Poi la camera ruota su @ref2 in primo piano con illuminazione cinematografica e fumo realistico."
rows="4"
class="input-textarea">
</textarea>
<!-- Dropdown autocomplete when typing @ -->
<div *ngIf="showTagDropdown && referenceItems.length > 0" class="tag-autocomplete-dropdown animate-fade-in">
<div class="dropdown-header">Seleziona Reference da agganciare:</div>
<div
*ngFor="let item of referenceItems"
class="dropdown-item"
(click)="selectTagFromDropdown(item.tag)">
<img [src]="item.previewUrl" class="dropdown-thumb" />
<div class="dropdown-meta">
<span class="dropdown-tag font-bold text-accent">{{ item.tag }}</span>
<span class="dropdown-name">{{ item.name }}</span>
</div>
</div>
</div>
</div>
<!-- Reference Images Upload for Video -->
<div class="form-group">
<label>Immagini di Riferimento Visivo (Reference Media con tag &#64;)</label>
<div class="reference-upload-container">
<input
type="file"
accept="image/*"
multiple
#videoFileInput
(change)="onReferenceImagesSelected($event)"
style="display: none;"
/>
<button type="button" class="upload-btn" (click)="videoFileInput.click()">
📁 Carica Reference (Immagini / Soggetti)
</button>
<span class="upload-hint">Ogni file riceverà un tag automatico (<strong>&#64;ref1</strong>, <strong>&#64;ref2</strong>...) agganciabile nel prompt</span>
</div>
<div *ngIf="referenceItems.length > 0" class="reference-previews">
<div *ngFor="let item of referenceItems; let i = index" class="ref-card-wrapper animate-fade-in">
<div class="ref-thumb-box">
<img [src]="item.previewUrl" alt="Reference Image" class="ref-thumb" />
<button type="button" class="remove-ref-btn" (click)="removeReferenceItem(i)">✕</button>
</div>
<div class="ref-meta-box">
<span class="ref-badge-tag" (click)="insertTagIntoPrompt(item.tag)">{{ item.tag }}</span>
<input
type="text"
[(ngModel)]="item.name"
(change)="updateTagName(item, i)"
placeholder="Nome soggetto"
class="ref-name-input"
title="Rinomina reference"
/>
</div>
</div>
</div>
</div>
<!-- Options Row -->
<div class="form-row">
<div class="form-group half">
<label>Modello Video AI {{ activeProvider === 'replicate' ? 'Replicate' : 'OpenRouter' }}</label>
<select [(ngModel)]="selectedModelId" class="select-input">
<option *ngFor="let model of models" [value]="model.id">
{{ model.name }}
</option>
</select>
</div>
<div class="form-group quarter">
<label>Formato (Aspect Ratio)</label>
<select [(ngModel)]="aspectRatio" class="select-input">
<option value="16:9">🖥️ 16:9 (Cinematic Widescreen)</option>
<option value="9:16">📱 9:16 (Vertical Reels / TikTok)</option>
<option value="1:1">🔳 1:1 (Quadrato Feed)</option>
</select>
</div>
<div class="form-group quarter">
<label>Durata Video</label>
<select [(ngModel)]="duration" class="select-input">
<option [ngValue]="5">5 Secondi (Clip Rapida)</option>
<option [ngValue]="10">10 Secondi (Standard)</option>
<option [ngValue]="15">15 Secondi (Extended)</option>
<option [ngValue]="30">30 Secondi (Commercial Spot)</option>
</select>
</div>
</div>
<!-- Cost Estimator Box -->
<div class="cost-estimator-box">
<div class="cost-header">
<span class="cost-title">💳 Preventivo Costo Video:</span>
<span class="cost-amount">{{ estimatedCostDisplay }}</span>
</div>
<p class="cost-details" *ngIf="generateMode === 'video'">
Tariffa: <strong>\${{ costPerSecond.toFixed(3) }}/sec</strong> &times; <strong>{{ duration }}s</strong> selezionati (Modello: {{ currentModel?.name || selectedModelId }})
</p>
<p class="cost-details" *ngIf="generateMode === 'script'">
Stima token per generazione Storyboard ({{ duration }}s estensione)
</p>
</div>
<button
class="generate-btn"
(click)="onGenerate()"
[disabled]="isGenerating || !concept.trim()">
<span *ngIf="!isGenerating">
🎬 {{ generateMode === 'video' ? 'Genera Video Reale MP4' : 'Genera Storyboard Script' }} (Stima: {{ estimatedCostDisplay }})
</span>
<span *ngIf="isGenerating" class="spinner-label">
<span class="spinner"></span> Generazione Video AI in corso...
</span>
</button>
<div *ngIf="isGenerating && videoStatusDetail" class="progress-status-bar animate-fade-in">
<div class="progress-icon-wrapper">
<span class="progress-pulse"></span>
</div>
<div class="progress-info">
<span class="progress-stage">{{ videoStatusStageLabel }}</span>
<span class="progress-detail">{{ videoStatusDetail }}</span>
</div>
<span class="progress-elapsed">⏱️ {{ elapsedTimeDisplay }}</span>
</div>
<div *ngIf="errorMessage" class="error-banner">
⚠️ {{ errorMessage }}
</div>
</div>
<!-- Video Output Result (quando un video è generato) -->
<div *ngIf="generatedVideoUrl" class="output-card animate-fade-in">
<div class="output-header">
<h3 class="output-title">🎬 Video Generato con Successo</h3>
<a [href]="generatedVideoUrl" download="video-ai.mp4" class="download-video-btn">⬇️ Scarica MP4</a>
</div>
<div class="video-player-container">
<video controls autoplay loop [src]="generatedVideoUrl" class="generated-video-player"></video>
</div>
</div>
<div *ngIf="!generatedVideoUrl && videoContent" class="output-card animate-fade-in">
<div class="output-header">
<h3 class="output-title">📹 Esito Generazione Video</h3>
</div>
<div class="video-content-text font-mono">
<pre>{{ videoContent }}</pre>
</div>
</div>
<!-- Storyboard Script Output (se in modalità script) -->
<div *ngIf="scriptResult && generateMode === 'script'" class="script-card animate-fade-in">
<div class="script-header">
<div>
<h3 class="script-title">🎥 {{ scriptResult.title }}</h3>
<p class="script-summary">{{ scriptResult.summary }}</p>
</div>
<button class="copy-all-btn" (click)="copyFullScript()">📋 Copia Tutto</button>
</div>
<div class="scenes-grid">
<div *ngFor="let scene of scriptResult.scenes" class="scene-box">
<div class="scene-top">
<span class="scene-num">Scena {{ scene.sceneNumber }}</span>
<span class="scene-dur">⏱️ {{ scene.duration }}</span>
<span class="scene-cam">📹 {{ scene.cameraMovement }}</span>
<span *ngIf="scene.referenceTag" class="scene-ref-pill">🔗 {{ scene.referenceTag }}</span>
</div>
<!-- Attached Reference Media Preview for Scene -->
<div *ngIf="getSceneReferenceItem(scene)" class="scene-attached-ref animate-fade-in">
<img [src]="getSceneReferenceItem(scene)?.previewUrl" class="scene-ref-img" />
<div class="scene-ref-meta">
<span class="scene-ref-title">Soggetto Reference: {{ getSceneReferenceItem(scene)?.tag }}</span>
<span class="scene-ref-sub">{{ getSceneReferenceItem(scene)?.name }}</span>
</div>
</div>
<div class="prompt-section">
<div class="prompt-title-row">
<span class="label">Visual AI Prompt:</span>
<button class="copy-btn" (click)="copyToClipboard(scene.visualPrompt)">Copia Prompt</button>
</div>
<p class="prompt-content font-mono">{{ scene.visualPrompt }}</p>
</div>
<div class="voiceover-section">
<span class="label">Audio / Voiceover:</span>
<p class="voiceover-content">🎙️ {{ scene.audioVoiceover }}</p>
</div>
</div>
</div>
</div>
</div>
`,
styles: [`
.video-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.input-card, .output-card, .script-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-card);
}
.input-card { display: flex; flex-direction: column; gap: 1.25rem; }
.card-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
}
.section-title { font-size: 1.25rem; font-weight: 800; }
.section-desc { font-size: 0.85rem; color: var(--text-muted); }
.mode-switcher {
display: flex;
background: var(--bg-input);
border: 1px solid var(--border-color);
padding: 3px;
border-radius: var(--radius-md);
gap: 4px;
}
.mode-btn {
background: transparent;
color: var(--text-muted);
border: none;
padding: 0.4rem 0.85rem;
border-radius: var(--radius-sm);
font-size: 0.8rem;
font-weight: 700;
transition: all 0.2s ease;
}
.mode-btn.active {
background: var(--accent-gradient);
color: white;
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.3);
}
.form-group { display: flex; flex-direction: column; gap: 0.5rem; }
.input-textarea, .select-input {
background: var(--bg-input);
border: 1px solid var(--border-color);
color: var(--text-main);
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
width: 100%;
}
.form-row { display: flex; gap: 1rem; flex-wrap: wrap; }
.half { flex: 2; min-width: 260px; }
.quarter { flex: 1; min-width: 160px; }
.cost-estimator-box {
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.3);
padding: 0.85rem 1rem;
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.cost-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.cost-title {
font-size: 0.85rem;
font-weight: 700;
color: #6ee7b7;
}
.cost-amount {
font-size: 1rem;
font-weight: 800;
color: #34d399;
font-family: monospace;
}
.cost-details {
font-size: 0.75rem;
color: var(--text-muted);
margin: 0;
}
.generate-btn {
background: var(--accent-gradient);
color: white;
font-weight: 700;
padding: 0.9rem 1.5rem;
border-radius: var(--radius-md);
font-size: 1rem;
}
.generate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.reference-upload-container {
display: flex;
align-items: center;
gap: 0.75rem;
}
.upload-btn {
background: var(--bg-input);
border: 1px dashed var(--primary);
color: var(--text-main);
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s ease;
}
.upload-btn:hover {
background: rgba(139, 92, 246, 0.15);
}
.upload-hint {
font-size: 0.75rem;
color: var(--text-muted);
}
.reference-previews {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.ref-thumb-wrapper {
position: relative;
width: 64px;
height: 64px;
border-radius: var(--radius-sm);
overflow: hidden;
border: 1px solid var(--border-color);
}
.ref-thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.remove-ref-btn {
position: absolute;
top: 2px;
right: 2px;
background: rgba(0, 0, 0, 0.7);
color: white;
border: none;
border-radius: 50%;
width: 18px;
height: 18px;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.prompt-hint-tag {
font-size: 0.75rem;
color: #c084fc;
background: rgba(192, 132, 252, 0.1);
padding: 2px 8px;
border-radius: 9999px;
border: 1px solid rgba(192, 132, 252, 0.2);
}
.tag-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.5rem;
padding: 0.4rem 0.6rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
}
.toolbar-label {
font-size: 0.75rem;
color: var(--text-muted);
font-weight: 600;
}
.ref-tag-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: rgba(168, 85, 247, 0.15);
border: 1px solid rgba(168, 85, 247, 0.35);
color: #e9d5ff;
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
border-radius: 9999px;
cursor: pointer;
transition: all 0.2s ease;
}
.ref-tag-pill:hover {
background: rgba(168, 85, 247, 0.35);
border-color: #c084fc;
transform: translateY(-1px);
}
.mini-tag-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
object-fit: cover;
}
.tag-subname {
color: var(--text-muted);
font-size: 0.7rem;
max-width: 70px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tag-autocomplete-dropdown {
position: absolute;
z-index: 100;
background: #1e1b4b;
border: 1px solid #7c3aed;
border-radius: var(--radius-md);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
max-height: 200px;
overflow-y: auto;
width: 280px;
margin-top: 4px;
}
.dropdown-header {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #a855f7;
padding: 0.4rem 0.75rem;
background: rgba(0,0,0,0.3);
font-weight: 800;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
transition: background 0.15s ease;
}
.dropdown-item:hover {
background: rgba(168, 85, 247, 0.2);
}
.dropdown-thumb {
width: 28px;
height: 28px;
border-radius: 4px;
object-fit: cover;
}
.dropdown-meta {
display: flex;
flex-direction: column;
}
.dropdown-tag {
font-size: 0.8rem;
color: #c084fc;
}
.dropdown-name {
font-size: 0.7rem;
color: var(--text-muted);
}
.ref-card-wrapper {
display: flex;
flex-direction: column;
gap: 0.25rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 0.4rem;
width: 100px;
}
.ref-thumb-box {
position: relative;
width: 100%;
height: 70px;
border-radius: 4px;
overflow: hidden;
}
.ref-meta-box {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.ref-badge-tag {
font-size: 0.7rem;
font-weight: 800;
color: #c084fc;
cursor: pointer;
text-align: center;
background: rgba(168, 85, 247, 0.15);
border-radius: 3px;
padding: 1px 3px;
}
.ref-name-input {
font-size: 0.65rem;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-main);
padding: 2px 4px;
border-radius: 3px;
width: 100%;
text-align: center;
}
.scene-ref-pill {
background: rgba(168, 85, 247, 0.2);
border: 1px solid #a855f7;
color: #f3e8ff;
font-size: 0.75rem;
font-weight: 700;
padding: 2px 6px;
border-radius: 9999px;
}
.scene-attached-ref {
display: flex;
align-items: center;
gap: 0.6rem;
background: rgba(168, 85, 247, 0.08);
border: 1px solid rgba(168, 85, 247, 0.25);
border-radius: var(--radius-sm);
padding: 0.4rem 0.6rem;
margin: 0.4rem 0;
}
.scene-ref-img {
width: 36px;
height: 36px;
border-radius: 4px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.scene-ref-meta {
display: flex;
flex-direction: column;
}
.scene-ref-title {
font-size: 0.75rem;
font-weight: 700;
color: #e9d5ff;
}
.scene-ref-sub {
font-size: 0.7rem;
color: var(--text-muted);
}
.output-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-color);
}
.output-title { font-size: 1.1rem; font-weight: 800; color: #c084fc; }
.download-video-btn {
background: var(--accent-gradient);
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-size: 0.85rem;
font-weight: 700;
}
.video-player-container {
width: 100%;
background: #000;
border-radius: var(--radius-md);
overflow: hidden;
display: flex;
justify-content: center;
}
.generated-video-player {
width: 100%;
max-height: 550px;
outline: none;
}
.error-banner {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #fca5a5;
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
font-size: 0.85rem;
}
.script-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding-bottom: 1rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.script-title { font-size: 1.3rem; font-weight: 800; color: #a78bfa; }
.script-summary { font-size: 0.9rem; color: var(--text-muted); margin-top: 0.25rem; }
.copy-all-btn {
background: var(--bg-input);
border: 1px solid var(--border-color);
color: var(--text-main);
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-size: 0.85rem;
font-weight: 600;
}
.scenes-grid { display: flex; flex-direction: column; gap: 1.25rem; }
.scene-box {
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.scene-top { display: flex; gap: 1rem; align-items: center; }
.scene-num {
background: var(--primary);
color: white;
font-weight: 700;
padding: 0.2rem 0.6rem;
border-radius: var(--radius-sm);
font-size: 0.8rem;
}
.scene-dur, .scene-cam { font-size: 0.85rem; color: var(--text-muted); }
.prompt-section { display: flex; flex-direction: column; gap: 0.35rem; }
.prompt-title-row { display: flex; justify-content: space-between; align-items: center; }
.label { font-size: 0.8rem; font-weight: 700; color: #9ca3af; text-transform: uppercase; }
.copy-btn {
background: rgba(59, 130, 246, 0.15);
color: #93c5fd;
border: 1px solid rgba(59, 130, 246, 0.3);
padding: 0.15rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
}
.prompt-content {
background: rgba(0, 0, 0, 0.4);
padding: 0.75rem;
border-radius: var(--radius-sm);
font-size: 0.85rem;
color: #e5e7eb;
border: 1px solid rgba(255,255,255,0.05);
}
.voiceover-content { font-size: 0.9rem; color: var(--text-muted); }
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
display: inline-block;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.progress-status-bar {
display: flex;
align-items: center;
gap: 0.75rem;
background: rgba(139, 92, 246, 0.08);
border: 1px solid rgba(139, 92, 246, 0.25);
border-radius: var(--radius-md);
padding: 0.85rem 1rem;
}
.progress-icon-wrapper {
position: relative;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.progress-pulse {
display: block;
width: 18px;
height: 18px;
border-radius: 50%;
background: #a855f7;
animation: pulse-glow 1.5s ease-in-out infinite;
}
@keyframes pulse-glow {
0%, 100% { transform: scale(0.85); opacity: 0.6; box-shadow: 0 0 0 0 rgba(168, 85, 247, 0.5); }
50% { transform: scale(1.1); opacity: 1; box-shadow: 0 0 12px 4px rgba(168, 85, 247, 0.3); }
}
.progress-info {
display: flex;
flex-direction: column;
gap: 0.15rem;
flex: 1;
}
.progress-stage {
font-size: 0.8rem;
font-weight: 800;
color: #c084fc;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.progress-detail {
font-size: 0.85rem;
color: var(--text-muted);
}
.progress-elapsed {
font-size: 0.8rem;
color: var(--text-muted);
font-family: monospace;
white-space: nowrap;
}
`]
})
export class VideoGeneratorComponent implements OnChanges {
@ViewChild('promptInputArea') promptInputArea?: ElementRef<HTMLTextAreaElement>;
@Input() models: OpenRouterModel[] = [];
@Input() activeProvider: 'openrouter' | 'replicate' = 'openrouter';
@Input() selectedModelId = 'bytedance/seedance-2.5';
@Input() generatedVideoUrl = '';
@Input() videoContent = '';
@Input() scriptResult: VideoScriptResponse | null = null;
@Input() isGenerating = false;
@Input() errorMessage = '';
@Output() generateVideo = new EventEmitter<VideoGenerationConfig>();
@Output() generateScript = new EventEmitter<{ concept: string; duration: number; model: string; estimatedCost: string; referenceImages?: string[]; referenceItems?: ReferenceMedia[] }>();
concept = '';
duration = 15;
aspectRatio = '16:9';
generateMode: 'video' | 'script' = 'video';
referenceImages: string[] = [];
referenceItems: ReferenceMedia[] = [];
showTagDropdown = false;
// Video generation progress tracking
videoStatusStage = '';
videoStatusDetail = '';
private _elapsedSeconds = 0;
private _elapsedTimer: any = null;
ngOnChanges(changes: SimpleChanges) {
if (changes['models'] && this.models.length > 0) {
const modelExists = this.models.find(m => m.id === this.selectedModelId);
if (!modelExists) {
this.selectedModelId = this.models[0].id;
}
}
}
get elapsedTimeDisplay(): string {
const m = Math.floor(this._elapsedSeconds / 60);
const s = this._elapsedSeconds % 60;
return m > 0 ? `${m}m ${s}s` : `${s}s`;
}
get videoStatusStageLabel(): string {
const labels: Record<string, string> = {
'submitting': '📤 Invio',
'queued': '📋 In Coda',
'processing': '⚙️ Elaborazione',
'fallback': '🔄 Fallback',
'synthetic': '🎨 Anteprima',
'completed': '✅ Completato',
'error': '❌ Errore'
};
return labels[this.videoStatusStage] || '⏳ In Corso';
}
updateVideoStatus(status: string, detail?: string) {
this.videoStatusStage = status;
this.videoStatusDetail = detail || '';
}
startElapsedTimer() {
this._elapsedSeconds = 0;
this.videoStatusStage = '';
this.videoStatusDetail = '';
this.stopElapsedTimer();
this._elapsedTimer = setInterval(() => {
this._elapsedSeconds++;
}, 1000);
}
stopElapsedTimer() {
if (this._elapsedTimer) {
clearInterval(this._elapsedTimer);
this._elapsedTimer = null;
}
}
onPromptInput(event: any) {
const text = this.concept || '';
const lastChar = text.slice(-1);
if (lastChar === '@') {
this.showTagDropdown = true;
} else if (text.endsWith(' ') || text.length === 0) {
this.showTagDropdown = false;
}
}
insertTagIntoPrompt(tag: string) {
if (!this.concept.includes(tag)) {
this.concept = this.concept ? `${this.concept} ${tag}` : tag;
} else {
this.concept = `${this.concept} ${tag}`;
}
this.showTagDropdown = false;
if (this.promptInputArea) {
this.promptInputArea.nativeElement.focus();
}
}
selectTagFromDropdown(tag: string) {
if (this.concept.endsWith('@')) {
this.concept = this.concept.slice(0, -1) + tag + ' ';
} else {
this.insertTagIntoPrompt(tag);
}
this.showTagDropdown = false;
}
onReferenceImagesSelected(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
Array.from(input.files).forEach((file, index) => {
const reader = new FileReader();
reader.onload = (e: ProgressEvent<FileReader>) => {
if (e.target?.result) {
const dataUrl = e.target.result as string;
const currentCount = this.referenceItems.length + 1;
const defaultTag = `@ref${currentCount}`;
const cleanName = file.name ? file.name.replace(/\.[^/.]+$/, '') : `Soggetto ${currentCount}`;
const newItem: ReferenceMedia = {
id: Date.now().toString() + '_' + currentCount,
name: cleanName,
tag: defaultTag,
dataUrl: dataUrl,
previewUrl: dataUrl
};
this.referenceItems.push(newItem);
this.referenceImages.push(dataUrl);
}
};
reader.readAsDataURL(file);
});
input.value = '';
}
updateTagName(item: ReferenceMedia, index: number) {
if (!item.name.trim()) {
item.name = `ref${index + 1}`;
}
// Clean tag formatting
const tagFormatted = item.name.trim().toLowerCase().replace(/[^a-z0-9_]/g, '');
if (tagFormatted) {
item.tag = `@${tagFormatted}`;
}
}
removeReferenceItem(index: number) {
this.referenceItems.splice(index, 1);
this.referenceImages.splice(index, 1);
}
getSceneReferenceItem(scene: any): ReferenceMedia | undefined {
if (!scene) return undefined;
if (scene.referenceTag) {
const match = this.referenceItems.find(r => r.tag.toLowerCase() === scene.referenceTag.toLowerCase() || r.name.toLowerCase() === scene.referenceTag.replace('@', '').toLowerCase());
if (match) return match;
}
// Fallback: match by index if tagged in prompt
for (const item of this.referenceItems) {
if (scene.visualPrompt && scene.visualPrompt.includes(item.tag)) {
return item;
}
}
return undefined;
}
get currentModel(): OpenRouterModel | undefined {
return this.models.find(m => m.id === this.selectedModelId);
}
get costPerSecond(): number {
const model = this.currentModel;
if (model?.pricing_skus && model.pricing_skus['per-video-second']) {
return parseFloat(model.pricing_skus['per-video-second']) || 0;
}
// Fallback in base all'ID o default
if (this.selectedModelId.includes('seedance-2.5') || this.selectedModelId.includes('seedance-2.0')) {
return 0.067;
}
if (this.selectedModelId.includes('minimax')) {
return 0.050;
}
if (this.selectedModelId.includes('kling-v3.0-pro')) {
return 0.200;
}
if (this.selectedModelId.includes('grok-imagine-video')) {
return 0.100;
}
if (this.selectedModelId.includes('hotshot')) {
return 0.020;
}
return 0.050;
}
get estimatedCostDisplay(): string {
if (this.generateMode === 'video') {
const perSec = this.costPerSecond;
const total = perSec * this.duration;
return `$${total.toFixed(4)}`;
}
const promptLen = this.concept?.length || 0;
const inputTokens = Math.max(50, Math.ceil(promptLen / 4));
const expectedOutputTokens = (this.duration / 15) * 800;
const costInput = (inputTokens / 1_000_000) * 0.10;
const costOutput = (expectedOutputTokens / 1_000_000) * 0.50;
const total = costInput + costOutput;
if (total < 0.0001) return '< $0.0001';
return `$${total.toFixed(4)}`;
}
onGenerate() {
if (this.generateMode === 'video') {
this.generateVideo.emit({
apiKey: '',
prompt: this.concept,
model: this.selectedModelId,
aspectRatio: this.aspectRatio,
durationSeconds: this.duration,
estimatedCost: this.estimatedCostDisplay,
referenceImages: this.referenceImages.length > 0 ? [...this.referenceImages] : undefined,
referenceItems: this.referenceItems.length > 0 ? [...this.referenceItems] : undefined,
generateMode: 'video'
});
} else {
this.generateScript.emit({
concept: this.concept,
duration: this.duration,
model: this.selectedModelId,
estimatedCost: this.estimatedCostDisplay,
referenceImages: this.referenceImages.length > 0 ? [...this.referenceImages] : undefined,
referenceItems: this.referenceItems.length > 0 ? [...this.referenceItems] : undefined
});
}
}
copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
copyFullScript() {
if (!this.scriptResult) return;
const full = `${this.scriptResult.title}\n${this.scriptResult.summary}\n\n` +
this.scriptResult.scenes.map(s => `[Scena ${s.sceneNumber} - ${s.duration} - ${s.cameraMovement} ${s.referenceTag ? '(' + s.referenceTag + ')' : ''}]\nVisual: ${s.visualPrompt}\nAudio: ${s.audioVoiceover}\n`).join('\n');
navigator.clipboard.writeText(full);
}
}