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: `
Scena {{ scene.sceneNumber }}
โฑ๏ธ {{ scene.duration }}
๐น {{ scene.cameraMovement }}
๐ {{ scene.referenceTag }}
Soggetto Reference: {{ getSceneReferenceItem(scene)?.tag }}
{{ getSceneReferenceItem(scene)?.name }}
Visual AI Prompt:
{{ scene.visualPrompt }}
Audio / Voiceover:
๐๏ธ {{ scene.audioVoiceover }}
`,
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;
@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();
@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 = {
'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) => {
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);
}
}