1025 lines
33 KiB
TypeScript
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>@</strong> per menzionare una reference (es: <code>@ref1</code>, <code>@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 @)</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>@ref1</strong>, <strong>@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> × <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);
|
|
}
|
|
}
|
|
|