feat: initial commit for MediaGen AI project

This commit is contained in:
David Frassi
2026-08-21 18:26:12 +02:00
commit d8e0d0f4c1
32 changed files with 18723 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
.git
.github
.vscode
Dockerfile
docker-compose.yml
npm-debug.log
+36
View File
@@ -0,0 +1,36 @@
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular
.DS_Store
Thumbs.db
# Environment variables & secrets
.env
.env.local
+27
View File
@@ -0,0 +1,27 @@
# Stage 1: Build Angular application
FROM node:20-alpine AS build
WORKDIR /app
# Copy package files and install dependencies
COPY package*.json ./
RUN npm install --legacy-peer-deps
# Copy application source code
COPY . .
# Build production bundle
RUN npm run build -- --configuration production
# Stage 2: Serve application with Nginx
FROM nginx:alpine
# Copy custom nginx configuration for SPA routing
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built artifacts from stage 1
COPY --from=build /app/dist/mediagen/browser /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+57
View File
@@ -0,0 +1,57 @@
# Inizializzazione Repository Git e Pubblicazione su GitLab (git.davidfrassi.it)
Questo piano descrive i passaggi per configurare il controllo di versione Git per il progetto **MediaGen** e pubblicarlo sul tuo server GitLab personale (`ssh://git@git.davidfrassi.it:2222/frassidavid/mediagen.git`), seguendo la stessa struttura del progetto *canti*.
---
## Informazioni Server GitLab Rilevate
- **Server Host**: `git.davidfrassi.it` (Porta SSH: `2222`)
- **Namespace / Utente**: `frassidavid`
- **Nome Repository di destinazione**: `mediagen` (o `mediagen-app`)
- **URL Remoto SSH**: `ssh://git@git.davidfrassi.it:2222/frassidavid/mediagen.git`
---
## User Review Required
> [!IMPORTANT]
> **Creazione del repository su GitLab Web UI**:
> Prima di fare il push iniziale, assicurati che il repository vuoto `mediagen` sia stato creato sotto il tuo account `frassidavid` su GitLab (`https://git.davidfrassi.it/frassidavid/mediagen` o tramite la schermata *New Project* su GitLab).
> Se il tuo server GitLab consente la creazione automatica al push SSH (*push options*), il comando push creerà direttamente il progetto.
---
## Proposed Changes
### 1. File `.gitignore` per Angular / Node.js
#### [NEW] [.gitignore](file:///Users/davidfrassi/SRC/angular/mediagen/.gitignore)
Configurazione dei file da escludere dal repository:
- `node_modules/`
- `dist/`
- `.angular/cache/`
- `.DS_Store`
- File di log (`npm-debug.log`, etc.)
---
### 2. Inizializzazione Repository Locale Git
Esecuzione comandi nella cartella `/Users/davidfrassi/SRC/angular/mediagen`:
1. `git init -b main`
2. `git add .`
3. `git commit -m "feat: initial commit for MediaGen AI Web App"`
---
### 3. Configurazione Remote e Push
1. `git remote add origin ssh://git@git.davidfrassi.it:2222/frassidavid/mediagen.git`
2. `git push -u origin main`
---
## Verification Plan
### Automated / Terminal Checks
- Esecuzione di `git status` per verificare la corretta esclusione di `node_modules` e file compilati.
- Esecuzione di `git remote -v` per convalidare l'URL di destinazione.
- Esecuzione di `git push -u origin main` per confermare l'avvenuto invio dei commit sul server GitLab.
+59
View File
@@ -0,0 +1,59 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"mediagen": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/mediagen",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.css"],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "2mb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "mediagen:build:production"
},
"development": {
"buildTarget": "mediagen:build:development"
}
},
"defaultConfiguration": "development"
}
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
version: '3.8'
services:
mediagen-app:
build:
context: .
dockerfile: Dockerfile
container_name: mediagen_angular_app
ports:
- "4200:80"
restart: always
environment:
- NODE_ENV=production
+30
View File
@@ -0,0 +1,30 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/replicate/ {
proxy_pass https://api.replicate.com/;
proxy_ssl_server_name on;
proxy_set_header Host api.replicate.com;
proxy_set_header Authorization $http_authorization;
proxy_pass_request_headers on;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, no-transform";
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
+12224
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "mediagen-app",
"version": "1.0.0",
"private": true,
"scripts": {
"ng": "ng",
"start": "ng serve --host 0.0.0.0",
"build": "ng build",
"watch": "ng build --watch --configuration development"
},
"dependencies": {
"@angular/animations": "^17.3.0",
"@angular/common": "^17.3.0",
"@angular/compiler": "^17.3.0",
"@angular/core": "^17.3.0",
"@angular/forms": "^17.3.0",
"@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0",
"@angular/router": "^17.3.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.3"
},
"devDependencies": {
"@angular-devkit/build-angular": "^17.3.0",
"@angular/cli": "^17.3.0",
"@angular/compiler-cli": "^17.3.0",
"typescript": "~5.4.2"
}
}
Executable
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
set -e
echo "🚀 Avvio della ricostruzione dell'immagine Docker e riavvio del container..."
# Ricostruisce l'immagine Docker senza cache e riavvia i container staccati
docker-compose down
docker-compose build --no-cache
docker-compose up -d
echo "✅ Container mediagen_angular_app riavviato con successo!"
echo "🌐 Disponibile su http://localhost:4200"
+413
View File
@@ -0,0 +1,413 @@
import { Component, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HeaderComponent } from './components/header/header.component';
import { ApiModalComponent } from './components/api-modal/api-modal.component';
import { ImageGeneratorComponent } from './components/image-generator/image-generator.component';
import { VideoGeneratorComponent } from './components/video-generator/video-generator.component';
import { AudioGeneratorComponent } from './components/audio-generator/audio-generator.component';
import { HistoryComponent } from './components/history/history.component';
import { StorageService } from './services/storage.service';
import { OpenRouterService } from './services/openrouter.service';
import { AiProviderService } from './services/ai-provider.service';
import { AudioGenerationConfig, GeneratedMedia, GenerationConfig, OpenRouterModel, ReferenceMedia, VideoGenerationConfig, VideoScriptResponse } from './models/openrouter.models';
@Component({
selector: 'app-root',
standalone: true,
imports: [
CommonModule,
HeaderComponent,
ApiModalComponent,
ImageGeneratorComponent,
VideoGeneratorComponent,
AudioGeneratorComponent,
HistoryComponent
],
template: `
<div class="app-layout">
<app-header
[activeTab]="activeTab"
[activeProvider]="activeProvider"
[hasApiKey]="!!apiKey"
[historyCount]="historyItems.length"
[apiUsage]="apiUsage"
[remainingCredits]="remainingCredits"
(tabChange)="activeTab = $event"
(openApiKeyModal)="isApiModalOpen = true">
</app-header>
<main class="main-content">
<div *ngIf="!apiKey" class="banner-key-warning">
<span>⚠️ Inserisci la tua <strong>{{ activeProvider === 'replicate' ? 'Replicate' : 'OpenRouter' }} API Key</strong> per sbloccare la generazione.</span>
<button (click)="isApiModalOpen = true">Configura Ora</button>
</div>
<!-- Tab 1: Image Generator (Mostra solo modelli immagini) -->
<app-image-generator
#imageGen
*ngIf="activeTab === 'image'"
[models]="imageModels"
[activeProvider]="activeProvider"
[selectedModelId]="selectedModel"
[generatedImageUrl]="lastGeneratedImageUrl"
[isGenerating]="isGeneratingImage"
[errorMessage]="imageError"
(generate)="onGenerateImage($event)"
(enhancePrompt)="onEnhancePrompt($event)">
</app-image-generator>
<!-- Tab 2: Video Generator (Generazione Video Reale + Script) -->
<app-video-generator
#videoGen
*ngIf="activeTab === 'video'"
[models]="videoModels"
[activeProvider]="activeProvider"
[generatedVideoUrl]="lastGeneratedVideoUrl"
[videoContent]="lastVideoContent"
[scriptResult]="videoScriptResult"
[isGenerating]="isGeneratingVideo"
[errorMessage]="videoError"
(generateVideo)="onGenerateVideo($event)"
(generateScript)="onGenerateVideoScript($event)">
</app-video-generator>
<!-- Tab 3: Audio Generator (Mostra solo modelli audio) -->
<app-audio-generator
*ngIf="activeTab === 'audio'"
[models]="audioModels"
[activeProvider]="activeProvider"
[generatedAudioUrl]="lastGeneratedAudioUrl"
[audioContent]="lastAudioContent"
[isGenerating]="isGeneratingAudio"
[errorMessage]="audioError"
(generateAudio)="onGenerateAudio($event)">
</app-audio-generator>
<!-- Tab 4: History & Gallery (Con costi per ogni oggetto) -->
<app-history
*ngIf="activeTab === 'history'"
[items]="historyItems"
(deleteItem)="onDeleteItem($event)"
(clearAll)="onClearHistory()">
</app-history>
</main>
<app-api-modal
*ngIf="isApiModalOpen"
(saveSettings)="onSaveSettings()"
(close)="isApiModalOpen = false">
</app-api-modal>
</div>
`,
styles: [`
.app-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main-content {
max-width: 1400px;
width: 100%;
margin: 0 auto;
padding: 1.5rem;
flex: 1;
}
.banner-key-warning {
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.3);
color: #fcd34d;
padding: 0.85rem 1.25rem;
border-radius: var(--radius-md);
margin-bottom: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9rem;
}
.banner-key-warning button {
background: var(--warning);
color: #000;
font-weight: 700;
padding: 0.4rem 0.9rem;
border-radius: var(--radius-sm);
}
`]
})
export class AppComponent {
@ViewChild('imageGen') imageGenComponent?: ImageGeneratorComponent;
@ViewChild('videoGen') videoGenComponent?: VideoGeneratorComponent;
activeTab: 'image' | 'video' | 'audio' | 'history' = 'image';
isApiModalOpen = false;
apiKey = '';
selectedModel = 'google/gemini-3-pro-image';
models: OpenRouterModel[] = [];
activeProvider: 'openrouter' | 'replicate' = 'openrouter';
apiUsage = 0;
remainingCredits = 0;
// Filtered models per modality
imageModels: OpenRouterModel[] = [];
videoModels: OpenRouterModel[] = [];
audioModels: OpenRouterModel[] = [];
// Image State
isGeneratingImage = false;
lastGeneratedImageUrl = '';
imageError = '';
// Video State
isGeneratingVideo = false;
lastGeneratedVideoUrl = '';
lastVideoContent = '';
videoScriptResult: VideoScriptResponse | null = null;
videoError = '';
// Audio State
isGeneratingAudio = false;
lastGeneratedAudioUrl = '';
lastAudioContent = '';
audioError = '';
historyItems: GeneratedMedia[] = [];
constructor(
private storage: StorageService,
private openRouter: OpenRouterService,
private aiProvider: AiProviderService
) {}
async ngOnInit() {
this.apiKey = this.storage.getApiKey();
this.selectedModel = this.storage.getSelectedModel();
this.historyItems = this.storage.getHistory();
this.activeProvider = this.storage.getActiveProvider() || 'openrouter';
this.models = await this.aiProvider.getModels(this.storage.getApiKey());
this.updateFilteredModels();
this.fetchCredits();
}
updateFilteredModels() {
this.imageModels = this.aiProvider.getImageModels(this.models);
this.videoModels = this.aiProvider.getVideoModels(this.models);
this.audioModels = this.aiProvider.getAudioModels(this.models);
}
async fetchCredits() {
if (this.activeProvider === 'replicate') {
this.apiUsage = this.storage.getReplicateUsage();
this.remainingCredits = this.storage.getReplicateRemainingCredits();
return;
}
if (!this.apiKey) {
this.apiUsage = 0;
this.remainingCredits = 0;
return;
}
const creds = await this.openRouter.getCredits(this.apiKey);
if (creds) {
this.apiUsage = creds.totalUsage;
this.remainingCredits = creds.remainingCredits;
}
}
onSaveSettings() {
this.apiKey = this.storage.getApiKey();
this.activeProvider = this.storage.getActiveProvider() || 'openrouter';
this.aiProvider.getModels(this.storage.getApiKey()).then(m => {
this.models = m;
this.updateFilteredModels();
});
this.fetchCredits();
}
async onEnhancePrompt(promptText: string) {
try {
const enhanced = await this.aiProvider.enhancePrompt(promptText, this.apiKey);
if (this.imageGenComponent) {
this.imageGenComponent.setEnhancedPrompt(enhanced);
}
} catch (err: any) {
this.imageError = err.message || 'Errore durante il miglioramento del prompt.';
}
}
async onGenerateImage(config: GenerationConfig) {
config.apiKey = this.apiKey;
this.isGeneratingImage = true;
this.imageError = '';
this.lastGeneratedImageUrl = '';
try {
const imageUrl = await this.aiProvider.generateImage(config);
this.lastGeneratedImageUrl = imageUrl;
const mediaItem: GeneratedMedia = {
id: Date.now().toString(),
type: 'image',
url: imageUrl,
prompt: config.prompt,
model: config.model,
createdAt: new Date().toISOString(),
aspectRatio: config.aspectRatio,
costDisplay: config.estimatedCost || '$0.0050'
};
this.storage.saveMediaItem(mediaItem);
this.historyItems = this.storage.getHistory();
if (this.activeProvider === 'replicate') {
const parsedCost = parseFloat((config.estimatedCost || '$0.0030').replace(/[^0-9.]/g, '')) || 0.0030;
this.storage.addReplicateUsage(parsedCost);
}
this.fetchCredits();
} catch (err: any) {
this.imageError = err.message || 'Si è verificato un errore durante la generazione dell\'immagine.';
} finally {
this.isGeneratingImage = false;
}
}
async onGenerateVideo(config: VideoGenerationConfig) {
config.apiKey = this.apiKey;
this.isGeneratingVideo = true;
this.videoError = '';
this.lastGeneratedVideoUrl = '';
this.lastVideoContent = '';
// Set up progress tracking
if (this.videoGenComponent) {
this.videoGenComponent.startElapsedTimer();
}
this.aiProvider.setVideoStatusCallback((status: string, detail?: string) => {
if (this.videoGenComponent) {
this.videoGenComponent.updateVideoStatus(status, detail);
}
});
try {
const result = await this.aiProvider.generateVideo(config);
this.lastGeneratedVideoUrl = result.videoUrl || '';
this.lastVideoContent = result.content || '';
const mediaItem: GeneratedMedia = {
id: Date.now().toString(),
type: 'video',
url: result.videoUrl,
content: result.content,
prompt: config.prompt,
model: config.model,
createdAt: new Date().toISOString(),
aspectRatio: config.aspectRatio || '16:9',
costDisplay: result.realCost ? `$${result.realCost.toFixed(4)}` : (config.estimatedCost || '$0.0080'),
realCost: result.realCost
};
this.storage.saveMediaItem(mediaItem);
this.historyItems = this.storage.getHistory();
if (this.activeProvider === 'replicate') {
const videoCost = parseFloat((config.estimatedCost || '$0.0500').replace(/[^0-9.]/g, '')) || 0.0500;
this.storage.addReplicateUsage(videoCost);
}
this.fetchCredits();
} catch (err: any) {
this.videoError = err.message || 'Errore durante la generazione del video.';
} finally {
this.isGeneratingVideo = false;
this.aiProvider.setVideoStatusCallback(null);
if (this.videoGenComponent) {
this.videoGenComponent.stopElapsedTimer();
}
}
}
async onGenerateVideoScript(event: { concept: string; duration: number; model?: string; estimatedCost?: string; referenceImages?: string[]; referenceItems?: ReferenceMedia[] }) {
this.isGeneratingVideo = true;
this.videoError = '';
this.videoScriptResult = null;
try {
const result = await this.aiProvider.generateVideoScript(event.concept, event.duration, this.apiKey, event.referenceImages, event.referenceItems);
this.videoScriptResult = result;
const mediaItem: GeneratedMedia = {
id: Date.now().toString(),
type: 'video_prompt',
content: JSON.stringify(result),
prompt: event.concept,
model: event.model || 'google/gemini-2.0-flash-exp:free',
createdAt: new Date().toISOString(),
aspectRatio: '16:9',
costDisplay: event.estimatedCost || '$0.0040'
};
this.storage.saveMediaItem(mediaItem);
this.historyItems = this.storage.getHistory();
if (this.activeProvider === 'replicate') {
const scriptCost = parseFloat((event.estimatedCost || '$0.0040').replace(/[^0-9.]/g, '')) || 0.0040;
this.storage.addReplicateUsage(scriptCost);
}
this.fetchCredits();
} catch (err: any) {
this.videoError = err.message || 'Errore durante la generazione dello script video.';
} finally {
this.isGeneratingVideo = false;
}
}
async onGenerateAudio(config: AudioGenerationConfig) {
config.apiKey = this.apiKey;
this.isGeneratingAudio = true;
this.audioError = '';
this.lastGeneratedAudioUrl = '';
this.lastAudioContent = '';
try {
const result = await this.aiProvider.generateAudio(config);
this.lastGeneratedAudioUrl = result.audioUrl || '';
this.lastAudioContent = result.content || '';
const mediaItem: GeneratedMedia = {
id: Date.now().toString(),
type: 'audio',
url: result.audioUrl,
content: result.content,
prompt: config.prompt,
model: config.model,
createdAt: new Date().toISOString(),
costDisplay: config.estimatedCost || '$0.0030'
};
this.storage.saveMediaItem(mediaItem);
this.historyItems = this.storage.getHistory();
if (this.activeProvider === 'replicate') {
const audioCost = parseFloat((config.estimatedCost || '$0.0020').replace(/[^0-9.]/g, '')) || 0.0020;
this.storage.addReplicateUsage(audioCost);
}
this.fetchCredits();
} catch (err: any) {
this.audioError = err.message || 'Errore durante la generazione dell\'audio.';
} finally {
this.isGeneratingAudio = false;
}
}
onDeleteItem(id: string) {
this.storage.deleteItem(id);
this.historyItems = this.storage.getHistory();
}
onClearHistory() {
this.storage.clearHistory();
this.historyItems = [];
}
}
@@ -0,0 +1,194 @@
import { Component, EventEmitter, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { StorageService } from '../../services/storage.service';
@Component({
selector: 'app-api-modal',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="modal-backdrop" (click)="close.emit()">
<div class="modal-card animate-fade-in" (click)="$event.stopPropagation()">
<div class="modal-header">
<h3>🔑 Configurazione API Provider</h3>
<button class="close-btn" (click)="close.emit()">✕</button>
</div>
<div class="modal-body">
<p class="desc">
Scegli il provider AI principale e inserisci la tua API Key. La tua chiave viene salvata esclusivamente nel <code>localStorage</code> del tuo browser.
</p>
<div class="form-group">
<label>Provider Attivo</label>
<div class="provider-selector">
<label class="radio-label">
<input type="radio" name="provider" value="openrouter" [(ngModel)]="activeProvider" />
OpenRouter
</label>
<label class="radio-label">
<input type="radio" name="provider" value="replicate" [(ngModel)]="activeProvider" />
Replicate
</label>
</div>
</div>
<div class="form-group" *ngIf="activeProvider === 'openrouter'">
<label>OpenRouter API Key</label>
<input
type="password"
[(ngModel)]="openRouterKey"
placeholder="sk-or-v1-..."
class="input-field"
/>
</div>
<div class="form-group" *ngIf="activeProvider === 'replicate'">
<label>Replicate API Token</label>
<input
type="password"
[(ngModel)]="replicateKey"
placeholder="r8_..."
class="input-field"
/>
</div>
<div class="form-group" *ngIf="activeProvider === 'replicate'">
<label>Credito Rimanente Replicate ($)</label>
<input
type="number"
step="0.01"
min="0"
[(ngModel)]="replicateBudget"
placeholder="11.72"
class="input-field"
/>
<span class="field-hint">Saldo attuale del tuo account Replicate (es: $11.72). Il credito residuo scalerà automaticamente a ogni generazione.</span>
</div>
<div class="info-box" *ngIf="activeProvider === 'openrouter'">
<span>💡 Non hai una chiave? Prendila gratuitamente o configura i crediti su <a href="https://openrouter.ai/keys" target="_blank" rel="noopener">OpenRouter Keys</a>.</span>
</div>
<div class="info-box" *ngIf="activeProvider === 'replicate'">
<span>💡 Non hai una chiave? Crea un account e prendi il tuo token su <a href="https://replicate.com/account/api-tokens" target="_blank" rel="noopener">Replicate API Tokens</a>.</span>
</div>
</div>
<div class="modal-footer">
<button class="btn-cancel" (click)="close.emit()">Annulla</button>
<button class="btn-save" (click)="save()">Salva Configurazioni</button>
</div>
</div>
</div>
`,
styles: [`
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
width: 100%;
max-width: 500px;
box-shadow: var(--shadow-card);
overflow: hidden;
}
.modal-header {
padding: 1.25rem;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-header h3 { font-size: 1.1rem; font-weight: 700; }
.close-btn { background: none; color: var(--text-muted); font-size: 1.2rem; }
.modal-body { padding: 1.25rem; display: flex; flex-direction: column; gap: 1.25rem; }
.desc { font-size: 0.9rem; color: var(--text-muted); }
.form-group { display: flex; flex-direction: column; gap: 0.5rem; }
.form-group label { font-size: 0.85rem; font-weight: 600; }
.provider-selector { display: flex; gap: 1rem; }
.radio-label { display: flex; align-items: center; gap: 0.25rem; cursor: pointer; font-size: 0.9rem; font-weight: normal; }
.input-field {
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%;
}
.input-field:focus { border-color: var(--primary); outline: none; }
.field-hint {
font-size: 0.75rem;
color: var(--text-muted);
line-height: 1.3;
}
.info-box {
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.2);
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
font-size: 0.85rem;
color: #93c5fd;
}
.info-box a { color: #60a5fa; text-decoration: underline; }
.modal-footer {
padding: 1rem 1.25rem;
background: rgba(0, 0, 0, 0.2);
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.btn-cancel {
background: transparent;
color: var(--text-muted);
padding: 0.6rem 1.2rem;
border-radius: var(--radius-md);
}
.btn-save {
background: var(--accent-gradient);
color: white;
font-weight: 700;
padding: 0.6rem 1.4rem;
border-radius: var(--radius-md);
}
`]
})
export class ApiModalComponent {
@Output() saveSettings = new EventEmitter<void>();
@Output() close = new EventEmitter<void>();
activeProvider: 'openrouter' | 'replicate' = 'openrouter';
openRouterKey = '';
replicateKey = '';
replicateBudget = 11.72;
constructor(private storage: StorageService) {}
ngOnInit() {
this.activeProvider = this.storage.getActiveProvider();
this.openRouterKey = this.storage.getApiKey('openrouter');
this.replicateKey = this.storage.getApiKey('replicate');
this.replicateBudget = this.storage.getReplicateBudget();
}
save() {
this.storage.setActiveProvider(this.activeProvider);
this.storage.setApiKey(this.openRouterKey, 'openrouter');
this.storage.setApiKey(this.replicateKey, 'replicate');
this.storage.setReplicateBudget(this.replicateBudget);
this.saveSettings.emit();
this.close.emit();
}
}
@@ -0,0 +1,356 @@
import { Component, EventEmitter, Input, Output, OnChanges, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AudioGenerationConfig, OpenRouterModel } from '../../models/openrouter.models';
@Component({
selector: 'app-audio-generator',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="generator-container animate-fade-in">
<div class="controls-panel">
<h2 class="section-title">🎵 Generatore di Audio & Sintesi Vocale AI</h2>
<p class="section-desc">Crea tracce audio, voiceover e sound design selezionando tra i modelli audio OpenRouter.</p>
<!-- Prompt Textarea -->
<div class="form-group">
<label>Testo da Sintetizzare / Descrizione Audio</label>
<textarea
[(ngModel)]="prompt"
placeholder="Es: Benvenuti nel podcast di tecnologia del futuro. Oggi parleremo di intelligenza artificiale applicata ai media multimediali."
rows="4"
class="input-textarea">
</textarea>
</div>
<!-- Model Selection (Solo Modelli Audio) -->
<div class="form-group">
<label>Modello Audio AI {{ activeProvider === 'replicate' ? 'Replicate' : 'OpenRouter' }}</label>
<select [(ngModel)]="selectedModelId" class="select-input">
<option *ngFor="let model of models" [value]="model.id">
{{ model.name }} ({{ model.id }})
</option>
</select>
</div>
<!-- Voice Style & Duration -->
<div class="form-row">
<div class="form-group half">
<label>Stile Voce / Tono</label>
<select [(ngModel)]="voiceStyle" class="select-input">
<option value="Naturale / Narrazione">🎙️ Naturale / Narrazione</option>
<option value="Professionale / Podcaster">📻 Professionale / Podcaster</option>
<option value="Cinematico / Trailer">🎬 Cinematico / Trailer</option>
<option value="Effetti Sonori / Sound FX">🔊 Effetti Sonori / SFX</option>
</select>
</div>
<div class="form-group half">
<label>Durata Stimata</label>
<select [(ngModel)]="durationSeconds" class="select-input">
<option [ngValue]="10">10 Secondi</option>
<option [ngValue]="30">30 Secondi</option>
<option [ngValue]="60">60 Secondi</option>
</select>
</div>
</div>
<!-- Cost Estimator Box -->
<div class="cost-estimator-box">
<div class="cost-header">
<span class="cost-title">💳 Costo Stimato Generazione Audio:</span>
<span class="cost-amount">{{ estimatedCostDisplay }}</span>
</div>
<p class="cost-details">{{ costDetailsText }}</p>
</div>
<!-- Action Button -->
<button
class="generate-btn"
(click)="onGenerate()"
[disabled]="isGenerating || !prompt.trim()">
<span *ngIf="!isGenerating">🎵 Genera Audio (Stima: {{ estimatedCostDisplay }})</span>
<span *ngIf="isGenerating" class="spinner-label">
<span class="spinner"></span> Sintesi Audio in corso...
</span>
</button>
<div *ngIf="errorMessage" class="error-banner">
⚠️ {{ errorMessage }}
</div>
</div>
<!-- Preview Output Panel -->
<div class="preview-panel">
<div class="preview-header">
<h3>Risultato Audio Generato</h3>
<a *ngIf="generatedAudioUrl" [href]="generatedAudioUrl" download="generated-audio.mp3" class="download-link">
⬇️ Scarica MP3
</a>
</div>
<div class="preview-body">
<div *ngIf="isGenerating" class="loading-state">
<div class="pulse-ring"></div>
<p>Elaborazione delle frequenze sonore e sintesi vocale AI...</p>
</div>
<div *ngIf="!isGenerating && !generatedAudioUrl && !audioContent" class="empty-state">
<div class="placeholder-icon">🎧</div>
<p>La traccia audio generata comparirà qui per l'ascolto immediato.</p>
</div>
<div *ngIf="!isGenerating && (generatedAudioUrl || audioContent)" class="audio-result-wrapper animate-fade-in">
<div *ngIf="generatedAudioUrl" class="audio-player-box">
<div class="audio-player-header">
<span class="player-label">🔊 Traccia Audio Pronta</span>
<span class="audio-badge">Audio Generato</span>
</div>
<audio controls autoplay [src]="generatedAudioUrl" class="audio-player"></audio>
</div>
<div *ngIf="audioContent" class="audio-text-box">
<span class="box-label">Trascrizione / Testo Elaborato:</span>
<p class="audio-text font-mono">{{ audioContent }}</p>
</div>
</div>
</div>
</div>
</div>
`,
styles: [`
.generator-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.controls-panel, .preview-panel {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.section-title { font-size: 1.25rem; font-weight: 800; }
.section-desc { font-size: 0.85rem; color: var(--text-muted); }
.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; }
.half { flex: 1; }
.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;
box-shadow: var(--shadow-glow);
}
.generate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.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;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-color);
}
.download-link {
color: #60a5fa;
font-size: 0.85rem;
font-weight: 600;
text-decoration: none;
}
.preview-body {
flex: 1;
min-height: 350px;
background: var(--bg-input);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
padding: 1rem;
}
.empty-state, .loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
color: var(--text-muted);
text-align: center;
padding: 2rem;
}
.placeholder-icon { font-size: 3rem; opacity: 0.4; }
.audio-result-wrapper {
width: 100%;
display: flex;
flex-direction: column;
gap: 1rem;
}
.audio-player-box {
width: 100%;
background: rgba(0,0,0,0.4);
padding: 1rem;
border-radius: var(--radius-md);
border: 1px solid rgba(167, 139, 250, 0.3);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.audio-player-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.player-label {
font-size: 0.85rem;
font-weight: 700;
color: #c4b5fd;
}
.audio-badge {
background: rgba(167, 139, 250, 0.2);
color: #a78bfa;
font-size: 0.7rem;
font-weight: 700;
padding: 0.2rem 0.5rem;
border-radius: 999px;
text-transform: uppercase;
}
.audio-player { width: 100%; }
.audio-text-box {
background: rgba(0,0,0,0.4);
padding: 1rem;
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.box-label { font-size: 0.75rem; color: #a78bfa; font-weight: 700; text-transform: uppercase; }
.audio-text { font-size: 0.85rem; color: #e5e7eb; white-space: pre-wrap; }
.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); } }
@media (max-width: 900px) {
.generator-container { grid-template-columns: 1fr; }
}
`]
})
export class AudioGeneratorComponent implements OnChanges {
@Input() models: OpenRouterModel[] = [];
@Input() activeProvider: 'openrouter' | 'replicate' = 'openrouter';
@Input() selectedModelId = 'openai/whisper-large-v3';
@Input() generatedAudioUrl = '';
@Input() audioContent = '';
@Input() isGenerating = false;
@Input() errorMessage = '';
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;
}
}
}
@Output() generateAudio = new EventEmitter<AudioGenerationConfig>();
prompt = '';
voiceStyle = 'Naturale / Narrazione';
durationSeconds = 15;
get currentModel(): OpenRouterModel | undefined {
return this.models.find(m => m.id === this.selectedModelId);
}
get estimatedCostDisplay(): string {
const model = this.currentModel;
if (!model || !model.pricing) return '~$0.0030';
const p = model.pricing;
if (p.request) return `$${parseFloat(p.request).toFixed(4)}`;
const inputTokens = Math.max(20, Math.ceil(this.prompt.length / 4));
const costInput = inputTokens * parseFloat(p.prompt || '0');
const costOutput = 300 * parseFloat(p.completion || '0');
const total = costInput + costOutput;
if (total === 0) return 'Gratuito / Gratis';
if (total < 0.0001) return '< $0.0001';
return `$${total.toFixed(4)}`;
}
get costDetailsText(): string {
const model = this.currentModel;
if (!model || !model.pricing) return 'Tariffa stimata basata sui token audio/testo di OpenRouter AI.';
const p = model.pricing;
if (p.request) return `Prezzo per richiesta: $${p.request}`;
return `Tariffa prompt: $${(parseFloat(p.prompt || '0') * 1000000).toFixed(2)} / 1M token`;
}
onGenerate() {
this.generateAudio.emit({
apiKey: '',
prompt: this.prompt,
model: this.selectedModelId,
voiceStyle: this.voiceStyle,
durationSeconds: this.durationSeconds,
estimatedCost: this.estimatedCostDisplay
});
}
}
@@ -0,0 +1,239 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-header',
standalone: true,
imports: [CommonModule],
template: `
<header class="header">
<div class="header-content">
<div class="brand">
<div class="logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3V21M3 12H21M7.5 7.5L16.5 16.5M16.5 7.5L7.5 16.5" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
</svg>
</div>
<div>
<h1 class="title">MediaGen <span class="badge">{{ activeProvider === 'replicate' ? 'Replicate AI' : 'OpenRouter AI' }}</span></h1>
<p class="subtitle">Generatore di Immagini e Storyboard Video AI</p>
</div>
</div>
<div class="nav-tabs">
<button
[class.active]="activeTab === 'image'"
(click)="selectTab('image')">
🎨 Immagini AI
</button>
<button
[class.active]="activeTab === 'video'"
(click)="selectTab('video')">
🎬 Video AI
</button>
<button
[class.active]="activeTab === 'audio'"
(click)="selectTab('audio')">
🎵 Audio AI
</button>
<button
[class.active]="activeTab === 'history'"
(click)="selectTab('history')">
📚 Galleria ({{ historyCount }})
</button>
</div>
<div class="actions">
<div *ngIf="hasApiKey" class="credits-badge" [title]="'Credito rimanente stimato per ' + (activeProvider === 'replicate' ? 'Replicate' : 'OpenRouter')">
<span class="credits-icon">💳</span>
<div class="credits-info">
<span class="credits-label">Credito Rimanente:</span>
<span class="credits-value">\${{ formatCredits(remainingCredits) }}</span>
</div>
</div>
<div *ngIf="hasApiKey && apiUsage > 0" class="usage-badge" [title]="'Consumo totale effettuato'">
<span class="usage-label">Spesi:</span>
<span class="usage-value">\${{ formatUsage() }}</span>
</div>
<button class="key-btn" [class.configured]="hasApiKey" (click)="openApiKeyModal.emit()">
<span class="dot"></span>
{{ hasApiKey ? (activeProvider === 'replicate' ? 'Replicate Token' : 'OpenRouter Key') : 'Imposta API Key' }}
</button>
</div>
</div>
</header>
`,
styles: [`
.header {
background: rgba(18, 22, 32, 0.85);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 100;
padding: 0.75rem 1.5rem;
}
.header-content {
max-width: 1400px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo {
width: 40px;
height: 40px;
background: var(--accent-gradient);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: white;
box-shadow: var(--shadow-glow);
}
.title {
font-size: 1.15rem;
font-weight: 800;
display: flex;
align-items: center;
gap: 0.5rem;
}
.badge {
font-size: 0.7rem;
font-weight: 600;
background: rgba(139, 92, 246, 0.2);
color: #a78bfa;
border: 1px solid rgba(139, 92, 246, 0.3);
padding: 0.15rem 0.5rem;
border-radius: var(--radius-full);
}
.subtitle {
font-size: 0.75rem;
color: var(--text-muted);
}
.nav-tabs {
display: flex;
background: var(--bg-input);
padding: 0.25rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
}
.nav-tabs button {
background: transparent;
color: var(--text-muted);
padding: 0.45rem 1rem;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 0.85rem;
}
.nav-tabs button.active {
background: var(--bg-card);
color: var(--text-main);
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.credits-badge {
background: rgba(16, 185, 129, 0.15);
border: 1px solid rgba(16, 185, 129, 0.35);
padding: 0.35rem 0.75rem;
border-radius: var(--radius-md);
display: flex;
align-items: center;
gap: 0.5rem;
}
.credits-icon {
font-size: 1rem;
}
.credits-info {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.credits-label {
color: #a7f3d0;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.credits-value {
color: #34d399;
font-weight: 800;
font-size: 0.95rem;
font-family: monospace;
}
.usage-badge {
background: rgba(59, 130, 246, 0.15);
border: 1px solid rgba(59, 130, 246, 0.3);
padding: 0.35rem 0.6rem;
border-radius: var(--radius-md);
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
}
.usage-label { color: var(--text-muted); font-weight: 500; }
.usage-value { color: #60a5fa; font-weight: 700; font-family: monospace; }
.key-btn {
background: rgba(239, 68, 68, 0.15);
color: #fca5a5;
border: 1px solid rgba(239, 68, 68, 0.3);
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-size: 0.85rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.key-btn.configured {
background: rgba(16, 185, 129, 0.15);
color: #6ee7b7;
border-color: rgba(16, 185, 129, 0.3);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
@media (max-width: 768px) {
.header-content { flex-direction: column; }
.nav-tabs { width: 100%; justify-content: center; }
}
`]
})
export class HeaderComponent {
@Input() activeTab: 'image' | 'video' | 'audio' | 'history' = 'image';
@Input() activeProvider: 'openrouter' | 'replicate' = 'openrouter';
@Input() hasApiKey = false;
@Input() historyCount = 0;
@Input() apiUsage = 0;
@Input() remainingCredits = 0;
@Output() tabChange = new EventEmitter<'image' | 'video' | 'audio' | 'history'>();
@Output() openApiKeyModal = new EventEmitter<void>();
selectTab(tab: 'image' | 'video' | 'audio' | 'history') {
this.tabChange.emit(tab);
}
formatCredits(amount: number): string {
return (amount || 0).toFixed(4);
}
formatUsage(): string {
return (this.apiUsage || 0).toFixed(4);
}
}
@@ -0,0 +1,728 @@
import { Component, EventEmitter, HostListener, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { GeneratedMedia } from '../../models/openrouter.models';
@Component({
selector: 'app-history',
standalone: true,
imports: [CommonModule],
template: `
<div class="history-container animate-fade-in">
<div class="history-header">
<div>
<h2 class="section-title">📚 Galleria e Cronologia AI</h2>
<p class="section-desc">Visualizza, scarica ed elimina i media o prompt generati precedentemente. Clicca su una miniatura per ingrandire o riprodurre.</p>
</div>
<button *ngIf="items.length > 0" class="clear-btn" (click)="clearAll.emit()">🗑️ Svuota Galleria</button>
</div>
<div *ngIf="items.length === 0" class="empty-gallery">
<div class="empty-icon">📂</div>
<p>Nessun contenuto generato salvato nella galleria locale.</p>
</div>
<div *ngIf="items.length > 0" class="gallery-grid">
<div *ngFor="let item of items" class="gallery-card" (click)="openDetail(item)">
<div class="card-media">
<!-- Image Thumbnail -->
<ng-container *ngIf="item.type === 'image' && item.url">
<img [src]="item.url" [alt]="item.prompt" />
<div class="media-overlay">
<span class="overlay-icon">🔍</span>
<span class="overlay-text">Visualizza Foto</span>
</div>
</ng-container>
<!-- Audio Thumbnail -->
<ng-container *ngIf="item.type === 'audio'">
<div class="audio-media font-mono">
<div class="audio-icon-wrapper">🎵</div>
<span class="audio-title">Traccia Audio AI</span>
<audio *ngIf="item.url" controls [src]="item.url" class="mini-player" (click)="$event.stopPropagation()"></audio>
</div>
<div class="media-overlay">
<span class="overlay-icon">▶</span>
<span class="overlay-text">Ascolta Audio</span>
</div>
</ng-container>
<!-- Video Thumbnail -->
<ng-container *ngIf="item.type === 'video' || item.type === 'video_prompt'">
<div *ngIf="item.url" class="video-thumb-container">
<video [src]="item.url" class="thumb-video-preview" muted preload="metadata"></video>
</div>
<div *ngIf="!item.url" class="text-media font-mono">
<div class="video-icon-wrapper">🎬</div>
<span>Video / Storyboard Script</span>
</div>
<div class="media-overlay">
<span class="overlay-icon">▶</span>
<span class="overlay-text">{{ item.url ? 'Riproduci Video' : 'Vedi Storyboard' }}</span>
</div>
</ng-container>
<button class="delete-btn" title="Elimina" (click)="$event.stopPropagation(); deleteItem.emit(item.id)">✕</button>
</div>
<div class="card-info">
<div class="info-header">
<span class="badge" [ngClass]="item.type">
{{ item.type === 'image' ? '🎨 Immagine' : (item.type === 'audio' ? '🎵 Audio' : '🎬 Video') }}
</span>
<span class="cost-tag">
💰 {{ item.costDisplay || (item.cost ? '$' + item.cost.toFixed(4) : 'Gratis') }}
</span>
</div>
<span class="model-tag">{{ item.model }}</span>
<p class="prompt-text" [title]="item.prompt">{{ item.prompt }}</p>
<div class="card-footer">
<span class="date">{{ item.createdAt | date:'short' }}</span>
<a *ngIf="item.url" [href]="item.url" download="media-content" class="download-btn" (click)="$event.stopPropagation()">⬇️ Scarica</a>
</div>
</div>
</div>
</div>
</div>
<!-- Lightbox / Media Detail Modal -->
<div *ngIf="selectedItem" class="lightbox-overlay animate-fade-in" (click)="closeDetail()">
<div class="lightbox-modal" (click)="$event.stopPropagation()">
<button class="lightbox-close" (click)="closeDetail()" title="Chiudi (ESC)">✕</button>
<div class="lightbox-body">
<!-- Left Container: Visual / Player -->
<div class="lightbox-media-container">
<!-- IMAGE DETAIL -->
<div *ngIf="selectedItem.type === 'image' && selectedItem.url" class="image-viewer">
<img [src]="selectedItem.url" [alt]="selectedItem.prompt" class="full-media-image" />
</div>
<!-- AUDIO DETAIL PLAYER -->
<div *ngIf="selectedItem.type === 'audio'" class="audio-viewer">
<div class="audio-visualizer-card">
<div class="disc-animation">🎵</div>
<div class="sound-waves">
<span></span><span></span><span></span><span></span><span></span>
</div>
<h3>{{ selectedItem.prompt }}</h3>
<span class="audio-model-label">Modello: {{ selectedItem.model }}</span>
<audio *ngIf="selectedItem.url" controls autoplay [src]="selectedItem.url" class="detail-audio-player"></audio>
</div>
</div>
<!-- VIDEO DETAIL PLAYER -->
<div *ngIf="selectedItem.type === 'video' || selectedItem.type === 'video_prompt'" class="video-viewer">
<div *ngIf="selectedItem.url" class="video-player-wrapper">
<video controls autoplay [src]="selectedItem.url" class="full-media-video"></video>
</div>
<div *ngIf="!selectedItem.url && selectedItem.content" class="script-viewer font-mono">
<div class="script-header">🎬 Storyboard / Script Video</div>
<pre class="script-content">{{ selectedItem.content }}</pre>
</div>
<div *ngIf="!selectedItem.url && !selectedItem.content" class="no-media-placeholder">
🎬 Anteprima Video / Script non disponibile direttamente.
</div>
</div>
</div>
<!-- Right Container: Metadata & Info -->
<div class="lightbox-info-container">
<div class="info-top">
<div class="info-badges">
<span class="badge" [ngClass]="selectedItem.type">
{{ selectedItem.type === 'image' ? '🎨 Immagine High-Res' : (selectedItem.type === 'audio' ? '🎵 Audio Traccia' : '🎬 Video Stream') }}
</span>
<span class="cost-tag">
💰 {{ selectedItem.costDisplay || (selectedItem.cost ? '$' + selectedItem.cost.toFixed(4) : 'Gratis') }}
</span>
</div>
<h3 class="detail-title">Dettaglio Contenuto AI</h3>
</div>
<div class="info-section">
<label class="info-label">Modello AI Generativo</label>
<div class="info-value font-mono">{{ selectedItem.model }}</div>
</div>
<div class="info-section">
<div class="prompt-header">
<label class="info-label">Prompt Utilizzato</label>
<button class="copy-prompt-btn" (click)="copyPrompt(selectedItem.prompt)">
{{ copiedPrompt ? '✓ Copiato!' : '📋 Copia Prompt' }}
</button>
</div>
<div class="prompt-box">{{ selectedItem.prompt }}</div>
</div>
<div class="info-row">
<div class="info-section">
<label class="info-label">Data Creazione</label>
<div class="info-value">{{ selectedItem.createdAt | date:'medium' }}</div>
</div>
<div *ngIf="selectedItem.aspectRatio" class="info-section">
<label class="info-label">Formato (Aspect Ratio)</label>
<div class="info-value font-mono">{{ selectedItem.aspectRatio }}</div>
</div>
</div>
<div class="info-actions">
<a *ngIf="selectedItem.url" [href]="selectedItem.url" download="ai-generated-media" class="action-btn download">
⬇️ Scarica File Originale
</a>
<button class="action-btn delete" (click)="deleteCurrentFromModal()">
🗑️ Elimina dalla Galleria
</button>
</div>
</div>
</div>
</div>
</div>
`,
styles: [`
.history-container {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-card);
}
.history-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.section-title { font-size: 1.25rem; font-weight: 800; }
.section-desc { font-size: 0.85rem; color: var(--text-muted); }
.clear-btn {
background: rgba(239, 68, 68, 0.15);
color: #fca5a5;
border: 1px solid rgba(239, 68, 68, 0.3);
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-size: 0.85rem;
}
.clear-btn:hover { background: rgba(239, 68, 68, 0.25); }
.empty-gallery {
text-align: center;
padding: 4rem 1rem;
color: var(--text-muted);
}
.empty-icon { font-size: 3rem; opacity: 0.3; margin-bottom: 0.5rem; }
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.25rem;
}
.gallery-card {
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
overflow: hidden;
display: flex;
flex-direction: column;
cursor: pointer;
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.gallery-card:hover {
transform: translateY(-3px);
border-color: var(--primary);
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.2);
}
.card-media {
height: 200px;
background: #05070a;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.card-media img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.gallery-card:hover .card-media img {
transform: scale(1.05);
}
.video-thumb-container {
width: 100%;
height: 100%;
}
.thumb-video-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.media-overlay {
position: absolute;
inset: 0;
background: rgba(10, 12, 16, 0.65);
backdrop-filter: blur(3px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
opacity: 0;
transition: opacity 0.2s ease;
color: #fff;
}
.gallery-card:hover .media-overlay {
opacity: 1;
}
.overlay-icon {
font-size: 2rem;
background: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.overlay-text {
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
color: #e2e8f0;
}
.text-media, .audio-media {
color: #a78bfa;
font-size: 0.85rem;
padding: 1rem;
text-align: center;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
width: 100%;
}
.audio-icon-wrapper, .video-icon-wrapper {
font-size: 2.2rem;
}
.audio-title {
font-weight: 600;
color: #f472b6;
}
.mini-player { width: 90%; height: 32px; z-index: 2; }
.delete-btn {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.7);
color: white;
border-radius: 50%;
width: 28px;
height: 28px;
font-size: 0.85rem;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
}
.delete-btn:hover {
background: rgba(239, 68, 68, 0.9);
}
.card-info {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
}
.info-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.badge {
background: rgba(59, 130, 246, 0.15);
color: #93c5fd;
font-size: 0.7rem;
font-weight: 700;
padding: 0.15rem 0.5rem;
border-radius: var(--radius-sm);
}
.badge.audio {
background: rgba(236, 72, 153, 0.15);
color: #f472b6;
}
.badge.video {
background: rgba(168, 85, 247, 0.15);
color: #c084fc;
}
.cost-tag {
font-size: 0.75rem;
font-weight: 800;
color: #34d399;
background: rgba(16, 185, 129, 0.12);
border: 1px solid rgba(16, 185, 129, 0.25);
padding: 0.15rem 0.45rem;
border-radius: var(--radius-sm);
font-family: monospace;
}
.model-tag { font-size: 0.75rem; color: var(--text-muted); }
.prompt-text {
font-size: 0.85rem;
color: var(--text-main);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-footer {
margin-top: auto;
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 0.5rem;
border-top: 1px solid rgba(255,255,255,0.05);
}
.date { font-size: 0.75rem; color: var(--text-dim); }
.download-btn { color: #60a5fa; text-decoration: none; font-size: 0.8rem; font-weight: 600; }
.download-btn:hover { text-decoration: underline; }
/* Lightbox Modal Styles */
.lightbox-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.lightbox-modal {
background: var(--bg-card);
border: 1px solid var(--border-highlight);
border-radius: var(--radius-lg);
max-width: 1100px;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.7);
}
.lightbox-close {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 10;
background: rgba(255, 255, 255, 0.1);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
width: 36px;
height: 36px;
font-size: 1.1rem;
display: flex;
align-items: center;
justify-content: center;
}
.lightbox-close:hover {
background: rgba(239, 68, 68, 0.8);
border-color: transparent;
}
.lightbox-body {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
min-height: 500px;
max-height: 90vh;
overflow-y: auto;
}
@media (max-width: 860px) {
.lightbox-body {
grid-template-columns: 1fr;
}
}
/* Left Media Container */
.lightbox-media-container {
background: #030406;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
min-height: 350px;
border-right: 1px solid var(--border-color);
}
.full-media-image {
max-width: 100%;
max-height: 75vh;
object-fit: contain;
border-radius: var(--radius-sm);
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
}
.full-media-video {
max-width: 100%;
max-height: 75vh;
border-radius: var(--radius-sm);
}
.video-player-wrapper {
width: 100%;
display: flex;
justify-content: center;
}
/* Audio Detail View */
.audio-viewer {
width: 100%;
display: flex;
justify-content: center;
}
.audio-visualizer-card {
background: linear-gradient(145deg, #181124 0%, #0d1322 100%);
border: 1px solid rgba(236, 72, 153, 0.3);
padding: 2.5rem 1.5rem;
border-radius: var(--radius-md);
text-align: center;
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.2rem;
box-shadow: 0 10px 30px rgba(236, 72, 153, 0.15);
}
.disc-animation {
font-size: 4rem;
animation: spinDisc 6s linear infinite;
}
@keyframes spinDisc {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.sound-waves {
display: flex;
align-items: center;
gap: 6px;
height: 30px;
}
.sound-waves span {
width: 4px;
height: 100%;
background: #f472b6;
border-radius: 4px;
animation: wave 1.2s ease-in-out infinite alternate;
}
.sound-waves span:nth-child(2) { animation-delay: 0.2s; }
.sound-waves span:nth-child(3) { animation-delay: 0.4s; }
.sound-waves span:nth-child(4) { animation-delay: 0.1s; }
.sound-waves span:nth-child(5) { animation-delay: 0.3s; }
@keyframes wave {
0% { height: 10%; }
100% { height: 100%; }
}
.audio-visualizer-card h3 {
font-size: 1rem;
color: #f3f4f6;
font-weight: 600;
}
.audio-model-label {
font-size: 0.8rem;
color: var(--text-muted);
}
.detail-audio-player {
width: 100%;
margin-top: 0.5rem;
}
/* Script Viewer */
.script-viewer {
width: 100%;
background: #0f141e;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 1.25rem;
max-height: 70vh;
overflow-y: auto;
}
.script-header {
font-size: 0.95rem;
font-weight: 700;
color: #c084fc;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.script-content {
white-space: pre-wrap;
font-size: 0.85rem;
color: #d1d5db;
line-height: 1.5;
}
.no-media-placeholder {
color: var(--text-muted);
font-size: 0.9rem;
text-align: center;
}
/* Right Info Panel */
.lightbox-info-container {
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
background: var(--bg-card);
overflow-y: auto;
}
.info-badges {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.detail-title {
font-size: 1.2rem;
font-weight: 800;
color: var(--text-main);
}
.info-section {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.info-label {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.info-value {
font-size: 0.9rem;
color: var(--text-main);
}
.prompt-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.copy-prompt-btn {
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
border: 1px solid rgba(59, 130, 246, 0.3);
padding: 0.25rem 0.6rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 600;
}
.copy-prompt-btn:hover {
background: rgba(59, 130, 246, 0.25);
}
.prompt-box {
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 0.85rem;
font-size: 0.85rem;
color: #e5e7eb;
line-height: 1.5;
max-height: 150px;
overflow-y: auto;
}
.info-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.info-actions {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.action-btn {
width: 100%;
padding: 0.75rem;
border-radius: var(--radius-md);
font-size: 0.9rem;
font-weight: 700;
text-align: center;
text-decoration: none;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.action-btn.download {
background: var(--accent-gradient);
color: #fff;
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.3);
}
.action-btn.download:hover {
opacity: 0.95;
transform: translateY(-1px);
}
.action-btn.delete {
background: rgba(239, 68, 68, 0.12);
color: #fca5a5;
border: 1px solid rgba(239, 68, 68, 0.25);
}
.action-btn.delete:hover {
background: rgba(239, 68, 68, 0.25);
}
`]
})
export class HistoryComponent {
@Input() items: GeneratedMedia[] = [];
@Output() deleteItem = new EventEmitter<string>();
@Output() clearAll = new EventEmitter<void>();
selectedItem: GeneratedMedia | null = null;
copiedPrompt = false;
@HostListener('document:keydown.escape', ['$event'])
onKeydownHandler() {
if (this.selectedItem) {
this.closeDetail();
}
}
openDetail(item: GeneratedMedia) {
this.selectedItem = item;
this.copiedPrompt = false;
}
closeDetail() {
this.selectedItem = null;
this.copiedPrompt = false;
}
copyPrompt(text: string) {
if (!text) return;
navigator.clipboard.writeText(text).then(() => {
this.copiedPrompt = true;
setTimeout(() => {
this.copiedPrompt = false;
}, 2000);
});
}
deleteCurrentFromModal() {
if (this.selectedItem) {
const idToDelete = this.selectedItem.id;
this.closeDetail();
this.deleteItem.emit(idToDelete);
}
}
}
@@ -0,0 +1,725 @@
import { Component, EventEmitter, Input, Output, OnChanges, SimpleChanges, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GenerationConfig, OpenRouterModel, ReferenceMedia } from '../../models/openrouter.models';
@Component({
selector: 'app-image-generator',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="generator-container animate-fade-in">
<div class="controls-panel">
<h2 class="section-title">🎨 Generatore di Immagini AI</h2>
<p class="section-desc">Crea immagini di alta qualità selezionando tra i modelli OpenRouter.</p>
<!-- Prompt Textarea -->
<div class="form-group">
<div class="label-row">
<label>Prompt Principale</label>
<button class="enhance-btn" (click)="onEnhancePrompt()" [disabled]="isEnhancing || !prompt">
✨ {{ isEnhancing ? 'Ottimizzazione...' : 'Migliora con AI' }}
</button>
</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)]="prompt"
(input)="onPromptInput($event)"
placeholder="Descrivi l'immagine in dettaglio... (es: Un ritratto cinematografico di @ref1 in una foresta di neon stile cyberpunk, 8k resolution, cinematic lighting)"
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>
<!-- Negative Prompt -->
<div class="form-group">
<label>Prompt Negativo (Cosa evitare)</label>
<input
type="text"
[(ngModel)]="negativePrompt"
placeholder="blur, low quality, distorted hands, extra limbs"
class="input-text"
/>
</div>
<!-- Reference Images Upload -->
<div class="form-group">
<label>Immagini di Riferimento (Reference Media con tag &#64;)</label>
<div class="reference-upload-container">
<input
type="file"
accept="image/*"
multiple
#imageFileInput
(change)="onReferenceImagesSelected($event)"
style="display: none;"
/>
<button type="button" class="upload-btn" (click)="imageFileInput.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>
<!-- Model Selection -->
<div class="form-group">
<label>Modello AI {{ activeProvider === 'replicate' ? 'Replicate' : 'OpenRouter' }}</label>
<select [(ngModel)]="selectedModelId" (change)="onModelChange()" class="select-input">
<option *ngFor="let model of models" [value]="model.id">
{{ model.name }} ({{ model.id }})
</option>
</select>
</div>
<!-- Cost Estimator Box -->
<div class="cost-estimator-box">
<div class="cost-header">
<span class="cost-title">💳 Costo Stimato Generazione:</span>
<span class="cost-amount">{{ estimatedCostDisplay }}</span>
</div>
<p class="cost-details">{{ costDetailsText }}</p>
</div>
<!-- Aspect Ratio & Resolution Grid -->
<div class="form-row">
<div class="form-group half">
<label>Proporzione (Aspect Ratio)</label>
<select [(ngModel)]="aspectRatio" (change)="updateDimensions()" class="select-input">
<option value="1:1">1:1 (Quadrato 1024x1024)</option>
<option value="16:9">16:9 (Widescreen 1280x720)</option>
<option value="9:16">9:16 (Story / TikTok 720x1280)</option>
<option value="4:3">4:3 (Standard 1024x768)</option>
<option value="21:9">21:9 (Cinematic 1344x576)</option>
</select>
</div>
</div>
<!-- Action Button -->
<button
class="generate-btn"
(click)="onGenerate()"
[disabled]="isGenerating || !prompt.trim()">
<span *ngIf="!isGenerating">🚀 Genera Immagine (Stima: {{ estimatedCostDisplay }})</span>
<span *ngIf="isGenerating" class="spinner-label">
<span class="spinner"></span> Generazione in corso...
</span>
</button>
<div *ngIf="errorMessage" class="error-banner">
⚠️ {{ errorMessage }}
</div>
</div>
<!-- Preview Output Panel -->
<div class="preview-panel">
<div class="preview-header">
<h3>Risultato Generato</h3>
<a *ngIf="generatedImageUrl" [href]="generatedImageUrl" download="openrouter-art.png" class="download-link">
⬇️ Scarica
</a>
</div>
<div class="preview-body">
<div *ngIf="isGenerating" class="loading-state">
<div class="pulse-ring"></div>
<p>Elaborazione prompt e generazione pixel tramite OpenRouter AI...</p>
</div>
<div *ngIf="!isGenerating && !generatedImageUrl" class="empty-state">
<div class="placeholder-icon">🖼️</div>
<p>L'immagine generata comparirà qui.</p>
</div>
<div *ngIf="!isGenerating && generatedImageUrl" class="image-wrapper animate-fade-in">
<img [src]="generatedImageUrl" alt="AI Generated Image" class="result-image" />
</div>
</div>
</div>
</div>
`,
styles: [`
.generator-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.controls-panel, .preview-panel {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.section-title { font-size: 1.25rem; font-weight: 800; }
.section-desc { font-size: 0.85rem; color: var(--text-muted); }
.form-group { display: flex; flex-direction: column; gap: 0.5rem; }
.label-row { display: flex; justify-content: space-between; align-items: center; }
.enhance-btn {
background: rgba(139, 92, 246, 0.15);
color: #c4b5fd;
border: 1px solid rgba(139, 92, 246, 0.3);
padding: 0.25rem 0.6rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 600;
}
.input-textarea, .input-text, .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%;
}
.input-textarea:focus, .input-text:focus, .select-input:focus {
border-color: var(--primary); outline: none;
}
.form-row { display: flex; gap: 1rem; }
.half { flex: 1; }
.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;
box-shadow: var(--shadow-glow);
margin-top: 0.5rem;
}
.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;
}
.remove-ref-btn:hover {
background: #ef4444;
}
.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;
}
.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;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-color);
}
.download-link {
color: #60a5fa;
font-size: 0.85rem;
font-weight: 600;
text-decoration: none;
}
.preview-body {
flex: 1;
min-height: 350px;
background: var(--bg-input);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}
.empty-state, .loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
color: var(--text-muted);
text-align: center;
padding: 2rem;
}
.placeholder-icon { font-size: 3rem; opacity: 0.4; }
.result-image {
max-width: 100%;
max-height: 500px;
object-fit: contain;
border-radius: var(--radius-sm);
}
.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); } }
@media (max-width: 900px) {
.generator-container { grid-template-columns: 1fr; }
}
`]
})
export class ImageGeneratorComponent implements OnChanges {
@ViewChild('promptInputArea') promptInputArea?: ElementRef<HTMLTextAreaElement>;
@Input() models: OpenRouterModel[] = [];
@Input() activeProvider: 'openrouter' | 'replicate' = 'openrouter';
@Input() selectedModelId = 'google/gemini-3-pro-image';
@Input() generatedImageUrl = '';
@Input() isGenerating = false;
@Input() errorMessage = '';
@Output() generate = new EventEmitter<GenerationConfig>();
@Output() enhancePrompt = new EventEmitter<string>();
prompt = '';
negativePrompt = '';
referenceImages: string[] = [];
referenceItems: ReferenceMedia[] = [];
showTagDropdown = false;
aspectRatio = '1:1';
width = 1024;
height = 1024;
isEnhancing = false;
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;
this.updateDimensions();
}
}
}
onPromptInput(event: any) {
const text = this.prompt || '';
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.prompt.includes(tag)) {
this.prompt = this.prompt ? `${this.prompt} ${tag}` : tag;
} else {
this.prompt = `${this.prompt} ${tag}`;
}
this.showTagDropdown = false;
if (this.promptInputArea) {
this.promptInputArea.nativeElement.focus();
}
}
selectTagFromDropdown(tag: string) {
if (this.prompt.endsWith('@')) {
this.prompt = this.prompt.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 => {
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}`;
}
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);
}
get currentModel(): OpenRouterModel | undefined {
return this.models.find(m => m.id === this.selectedModelId);
}
get estimatedCostDisplay(): string {
const model = this.currentModel;
if (!model || !model.pricing) return '~$0.0050';
const p = model.pricing;
if (p.image) {
const imgPrice = parseFloat(p.image);
return `$${imgPrice.toFixed(4)}`;
}
if (p.request) {
const reqPrice = parseFloat(p.request);
return `$${reqPrice.toFixed(4)}`;
}
const promptTokens = Math.max(20, Math.ceil((this.prompt.length + (this.negativePrompt?.length || 0)) / 4));
const promptCost = promptTokens * parseFloat(p.prompt || '0');
const completionCost = 1000 * parseFloat(p.completion || '0');
const total = promptCost + completionCost;
if (total === 0) return 'Gratuito / Gratis';
if (total < 0.0001) return '< $0.0001';
return `$${total.toFixed(5)}`;
}
get costDetailsText(): string {
const model = this.currentModel;
if (!model || !model.pricing) return 'Prezzo calcolato in base alle tariffe correnti di OpenRouter per richiesta/immagine.';
const p = model.pricing;
if (p.image) return `Prezzo per singola immagine: $${p.image}`;
if (p.prompt && p.completion) {
return `Tariffa prompt: $${(parseFloat(p.prompt) * 1000000).toFixed(2)} / 1M token | completion: $${(parseFloat(p.completion) * 1000000).toFixed(2)} / 1M token`;
}
return 'Tariffazione basata sull’utilizzo standard di OpenRouter AI.';
}
onModelChange() {}
updateDimensions() {
switch (this.aspectRatio) {
case '16:9': this.width = 1280; this.height = 720; break;
case '9:16': this.width = 720; this.height = 1280; break;
case '4:3': this.width = 1024; this.height = 768; break;
case '21:9': this.width = 1344; this.height = 576; break;
default: this.width = 1024; this.height = 1024; break;
}
}
onEnhancePrompt() {
if (!this.prompt) return;
this.isEnhancing = true;
this.enhancePrompt.emit(this.prompt);
}
setEnhancedPrompt(enhanced: string) {
this.prompt = enhanced;
this.isEnhancing = false;
}
onGenerate() {
this.generate.emit({
apiKey: '', // filled by parent
prompt: this.prompt,
negativePrompt: this.negativePrompt,
model: this.selectedModelId,
aspectRatio: this.aspectRatio,
width: this.width,
height: this.height,
numOutputs: 1,
steps: 30,
guidanceScale: 7.5,
estimatedCost: this.estimatedCostDisplay,
referenceImages: this.referenceImages.length > 0 ? [...this.referenceImages] : undefined,
referenceItems: this.referenceItems.length > 0 ? [...this.referenceItems] : undefined
});
}
}
File diff suppressed because it is too large Load Diff
+113
View File
@@ -0,0 +1,113 @@
export type ModelCategory = 'image' | 'video' | 'audio';
export interface ModelCategoryConfig {
image: OpenRouterModel[];
video: OpenRouterModel[];
audio: OpenRouterModel[];
}
export interface OpenRouterModel {
id: string;
name: string;
description?: string;
pricing?: {
prompt: string;
completion: string;
image?: string;
request?: string;
};
context_length?: number;
architecture?: {
modality?: string;
tokenizer?: string;
instruct_type?: string;
input_modalities?: string[];
output_modalities?: string[];
};
pricing_skus?: {
'per-video-second'?: string;
'per-video-second-1080p'?: string;
[key: string]: string | undefined;
};
}
export type AiProvider = 'openrouter' | 'replicate';
export interface ReferenceMedia {
id: string;
name: string; // ad es: "ref1", "personaggio", "scena1"
tag: string; // "@ref1"
dataUrl: string;
previewUrl: string;
}
export interface GenerationConfig {
provider?: AiProvider;
apiKey: string;
prompt: string;
negativePrompt?: string;
model: string;
aspectRatio: string;
width: number;
height: number;
numOutputs: number;
steps: number;
guidanceScale: number;
estimatedCost?: string;
referenceImages?: string[];
referenceItems?: ReferenceMedia[];
}
export interface AudioGenerationConfig {
provider?: AiProvider;
apiKey: string;
prompt: string;
model: string;
voiceStyle?: string;
durationSeconds?: number;
estimatedCost?: string;
}
export interface VideoGenerationConfig {
provider?: AiProvider;
apiKey: string;
prompt: string;
model: string;
aspectRatio?: string;
durationSeconds?: number;
estimatedCost?: string;
referenceImages?: string[];
referenceItems?: ReferenceMedia[];
generateMode?: 'video' | 'script';
realCost?: number;
}
export interface GeneratedMedia {
id: string;
type: 'image' | 'video' | 'video_prompt' | 'audio';
url?: string;
content?: string;
prompt: string;
model: string;
createdAt: string;
aspectRatio?: string;
cost?: number;
costDisplay?: string;
realCost?: number;
}
export interface VideoScriptScene {
sceneNumber: number;
duration: string;
visualPrompt: string;
audioVoiceover: string;
cameraMovement: string;
referenceTag?: string; // Es: @ref1 o @personaggio
}
export interface VideoScriptResponse {
title: string;
summary: string;
scenes: VideoScriptScene[];
}
+71
View File
@@ -0,0 +1,71 @@
import { Injectable } from '@angular/core';
import { StorageService } from './storage.service';
import { OpenRouterService } from './openrouter.service';
import { ReplicateService } from './replicate.service';
import { AudioGenerationConfig, GenerationConfig, OpenRouterModel, ReferenceMedia, VideoGenerationConfig, VideoScriptResponse } from '../models/openrouter.models';
@Injectable({
providedIn: 'root'
})
export class AiProviderService {
constructor(
private storage: StorageService,
private openRouter: OpenRouterService,
private replicate: ReplicateService
) {}
private getActiveService() {
const provider = this.storage.getActiveProvider();
return provider === 'replicate' ? this.replicate : this.openRouter;
}
setVideoStatusCallback(cb: ((status: string, detail?: string) => void) | null) {
this.openRouter.setVideoStatusCallback(cb);
this.replicate.setVideoStatusCallback(cb);
}
async generateImage(config: GenerationConfig): Promise<string> {
config.apiKey = config.apiKey || this.storage.getApiKey();
return this.getActiveService().generateImage(config);
}
async generateVideo(config: VideoGenerationConfig): Promise<{ videoUrl?: string; content?: string; realCost?: number }> {
config.apiKey = config.apiKey || this.storage.getApiKey();
return this.getActiveService().generateVideo(config);
}
async generateAudio(config: AudioGenerationConfig): Promise<{ audioUrl?: string; content?: string }> {
config.apiKey = config.apiKey || this.storage.getApiKey();
return this.getActiveService().generateAudio(config);
}
async enhancePrompt(prompt: string, apiKey: string): Promise<string> {
const activeApiKey = apiKey || this.storage.getApiKey();
return this.getActiveService().enhancePrompt(prompt, activeApiKey);
}
async generateVideoScript(concept: string, durationSeconds: number, apiKey: string, referenceImages?: string[], referenceItems?: ReferenceMedia[]): Promise<VideoScriptResponse> {
const activeApiKey = apiKey || this.storage.getApiKey();
return this.getActiveService().generateVideoScript(concept, durationSeconds, activeApiKey, referenceImages, referenceItems);
}
async getModels(apiKey?: string): Promise<OpenRouterModel[]> {
if (this.storage.getActiveProvider() === 'replicate') {
return this.replicate.getModels();
}
const activeApiKey = apiKey || this.storage.getApiKey('openrouter');
return this.openRouter.getModels(activeApiKey);
}
getImageModels(models: OpenRouterModel[]): OpenRouterModel[] {
return this.getActiveService().getImageModels(models);
}
getVideoModels(models: OpenRouterModel[]): OpenRouterModel[] {
return this.getActiveService().getVideoModels(models);
}
getAudioModels(models: OpenRouterModel[]): OpenRouterModel[] {
return this.getActiveService().getAudioModels(models);
}
}
+823
View File
@@ -0,0 +1,823 @@
import { Injectable } from '@angular/core';
import { AudioGenerationConfig, GenerationConfig, ModelCategoryConfig, OpenRouterModel, ReferenceMedia, VideoGenerationConfig, VideoScriptResponse } from '../models/openrouter.models';
import { generateSpeechAudioUrl } from '../utils/audio-synth.utils';
import { generateSyntheticVideoUrl } from '../utils/video-synth.utils';
@Injectable({
providedIn: 'root'
})
export class OpenRouterService {
private readonly baseUrl = 'https://openrouter.ai/api/v1';
private configuredModels: ModelCategoryConfig = {
image: [],
video: [],
audio: []
};
private isConfigLoaded = false;
async loadConfiguredModels(): Promise<ModelCategoryConfig> {
if (this.isConfigLoaded && (this.configuredModels.image.length > 0 || this.configuredModels.video.length > 0 || this.configuredModels.audio.length > 0)) {
return this.configuredModels;
}
try {
const response = await fetch('assets/models.json');
if (response.ok) {
const data: ModelCategoryConfig = await response.json();
this.configuredModels = {
image: data.image || [],
video: data.video || [],
audio: data.audio || []
};
this.isConfigLoaded = true;
}
} catch (e) {
console.warn('Impossibile caricare assets/models.json', e);
}
return this.configuredModels;
}
get defaultModels(): OpenRouterModel[] {
return [
...this.configuredModels.image,
...this.configuredModels.video,
...this.configuredModels.audio
];
}
async getModels(apiKey?: string): Promise<OpenRouterModel[]> {
await this.loadConfiguredModels();
try {
const headers: Record<string, string> = {
'HTTP-Referer': window.location.origin,
'X-Title': 'MediaGen OpenRouter'
};
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
const response = await fetch(`${this.baseUrl}/models`, { headers });
if (!response.ok) return this.defaultModels;
const data = await response.json();
const mediaModels: OpenRouterModel[] = data.data.map((m: any) => ({
id: m.id,
name: m.name || m.id,
description: m.description || '',
pricing: m.pricing,
context_length: m.context_length,
architecture: m.architecture
}));
return mediaModels.length > 0 ? mediaModels : this.defaultModels;
} catch (e) {
console.warn('Impossibile recuperare i modelli online da OpenRouter, utilizzo modelli dal file di configurazione', e);
return this.defaultModels;
}
}
getImageModels(models: OpenRouterModel[]): OpenRouterModel[] {
const configuredImg = this.configuredModels.image || [];
return configuredImg.map(conf => {
const match = models.find(m => m.id === conf.id);
return match ? { ...conf, pricing: match.pricing || conf.pricing, architecture: match.architecture || conf.architecture } : conf;
});
}
getVideoModels(models: OpenRouterModel[]): OpenRouterModel[] {
const configuredVid = this.configuredModels.video || [];
return configuredVid.map(conf => {
const match = models.find(m => m.id === conf.id);
return match ? { ...conf, pricing: match.pricing || conf.pricing, architecture: match.architecture || conf.architecture } : conf;
});
}
getAudioModels(models: OpenRouterModel[]): OpenRouterModel[] {
const configuredAud = this.configuredModels.audio || [];
return configuredAud.map(conf => {
const match = models.find(m => m.id === conf.id);
return match ? { ...conf, pricing: match.pricing || conf.pricing, architecture: match.architecture || conf.architecture } : conf;
});
}
async generateImage(config: GenerationConfig): Promise<string> {
if (!config.apiKey) {
throw new Error('API Key mancante. Inserisci la tua API Key di OpenRouter nelle impostazioni.');
}
if (config.referenceImages && config.referenceImages.length > 0) {
return await this.generateImageViaChat(config);
}
try {
const payload: Record<string, any> = {
model: config.model,
prompt: config.negativePrompt ? `${config.prompt} --no ${config.negativePrompt}` : config.prompt,
n: config.numOutputs || 1
};
if (config.aspectRatio) {
payload['aspect_ratio'] = config.aspectRatio;
} else if (config.width && config.height) {
payload['size'] = `${config.width}x${config.height}`;
}
const response = await fetch(`${this.baseUrl}/images/generations`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': window.location.origin,
'X-Title': 'MediaGen App'
},
body: JSON.stringify(payload)
});
if (response.ok) {
const data = await response.json();
if (data.data && data.data[0]) {
const item = data.data[0];
if (item.url) return item.url;
if (item.b64_json) return `data:image/png;base64,${item.b64_json}`;
}
} else {
const errorText = await response.text();
// Tenta il fallback solo se l'errore è 404/405 relativo all'endpoint
if (response.status !== 404 && response.status !== 405) {
throw new Error(`OpenRouter Error (${response.status}): ${errorText}`);
}
}
return await this.generateImageViaChat(config);
} catch (err: any) {
if (err.message && err.message.includes('OpenRouter Error')) {
throw err;
}
return await this.generateImageViaChat(config);
}
}
private async generateImageViaChat(config: GenerationConfig): Promise<string> {
const promptText = `Generate an image for prompt: "${config.prompt}". Aspect ratio: ${config.aspectRatio || '1:1'}.`;
let userContent: any;
if (config.referenceImages && config.referenceImages.length > 0) {
userContent = [
{ type: 'text', text: promptText },
...config.referenceImages.map(img => ({
type: 'image_url',
image_url: { url: img }
}))
];
} else {
userContent = promptText;
}
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': window.location.origin,
'X-Title': 'MediaGen App'
},
body: JSON.stringify({
model: config.model,
messages: [
{
role: 'user',
content: userContent
}
]
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OpenRouter Error (${response.status}): ${errorText}`);
}
const data = await response.json();
const messageObj = data.choices?.[0]?.message;
const content = messageObj?.content || '';
// 1. Controlla se il messaggio contiene un array di immagini o un oggetto image nella struttura message
if (messageObj?.images && messageObj.images.length > 0) {
const img = messageObj.images[0];
if (typeof img === 'string') return img;
if (img.url) return img.url;
if (img.image_url?.url) return img.image_url.url;
}
// 2. Se la risposta è un oggetto JSON in testo (es. {"prompt": "...", "url": "..."} o {"image": "..."})
try {
const parsed = JSON.parse(content);
if (typeof parsed === 'object' && parsed !== null) {
const posiblesKeys = ['url', 'image_url', 'image', 'b64_json', 'result'];
for (const key of posiblesKeys) {
if (parsed[key]) {
const val = parsed[key];
if (typeof val === 'string') {
if (val.startsWith('http') || val.startsWith('data:image/')) return val;
return `data:image/png;base64,${val}`;
} else if (typeof val === 'object' && val.url) {
return val.url;
}
}
}
}
} catch (_) {
// Non è una stringa JSON valida, si prosegue con il parsing tramite regex
}
const isDummyUrl = (urlStr: string) => {
const lower = urlStr.toLowerCase();
return lower.includes('example.com') || lower.includes('example.org') || lower.includes('localhost') || lower.includes('placeholder.com');
};
// 3. Estrazione Markdown ![image](url)
const markdownUrlMatch = content.match(/!\[.*?\]\((https?:\/\/.*?)\)/);
if (markdownUrlMatch && markdownUrlMatch[1] && !isDummyUrl(markdownUrlMatch[1])) {
return markdownUrlMatch[1];
}
// 4. Estrazione di qualsiasi URL HTTP/HTTPS pulito valido (escludendo domini fittizi di esempio)
const generalUrlMatch = content.match(/(https?:\/\/[^\s"'<>\)]+)/i);
if (generalUrlMatch && generalUrlMatch[1]) {
const cleanUrl = generalUrlMatch[1].replace(/[.,;]+$/, '');
if (!isDummyUrl(cleanUrl)) {
return cleanUrl;
}
}
// 5. Formato SVG o data:image inline
if (content.includes('data:image/') || content.includes('<svg')) {
return content;
}
// Se nessuna immagine reale è stata individuata
throw new Error(`Il modello selezionato ha restituito un messaggio di testo/esempio anziché un'immagine reale. Per la generazione diretta di immagini, si consiglia di selezionare un modello come Nano Banana Pro (Gemini 3 Pro Image), GPT Image o Recraft V3.`);
}
private async postChatCompletions(
apiKey: string,
bodyObj: any,
fallbackModels: string[] = ['openai/gpt-4o-mini', 'meta-llama/llama-3.3-70b-instruct', 'google/gemini-2.0-flash-exp:free', 'google/gemini-flash-1.5']
): Promise<Response> {
const modelsToTry = [bodyObj.model, ...fallbackModels].filter((v, i, a) => v && a.indexOf(v) === i);
let lastResp: Response | null = null;
for (const modelId of modelsToTry) {
try {
const payload = { ...bodyObj, model: modelId };
const resp = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': window.location.origin,
'X-Title': 'MediaGen App'
},
body: JSON.stringify(payload)
});
if (resp.ok) return resp;
lastResp = resp;
} catch (_) {}
}
return lastResp || new Response(JSON.stringify({ error: { message: 'Tutti i modelli di fallback OpenRouter hanno risposto con errore.' } }), { status: 500 });
}
async enhancePrompt(prompt: string, apiKey: string): Promise<string> {
if (!apiKey) throw new Error('Inserisci la API Key per migliorare il prompt.');
const response = await this.postChatCompletions(apiKey, {
model: 'openai/gpt-4o-mini',
messages: [
{
role: 'system',
content: 'You are an expert AI art & video prompt engineer. Transform user input into a detailed, vibrant, highly descriptive prompt optimized for AI image & video generators (e.g. FLUX, Midjourney, Sora, Runway Gen-2). Output ONLY the final enhanced prompt, without commentary.'
},
{ role: 'user', content: prompt }
]
});
if (!response.ok) throw new Error('Errore nel miglioramento del prompt.');
const data = await response.json();
return data.choices?.[0]?.message?.content?.trim() || prompt;
}
async generateVideoScript(concept: string, durationSeconds: number, apiKey: string, referenceImages?: string[], referenceItems?: ReferenceMedia[]): Promise<VideoScriptResponse> {
if (!apiKey) throw new Error('API Key mancante.');
let refTagsContext = '';
if (referenceItems && referenceItems.length > 0) {
refTagsContext = `\nAvailable reference media items:\n` + referenceItems.map((item, idx) => `- Tag: ${item.tag} (Name: "${item.name}") -> Represents Reference Image #${idx + 1}`).join('\n');
refTagsContext += `\nIf the concept references any tags (e.g. @ref1, @personaggio), tie each scene to its appropriate reference tag by setting the "referenceTag" field in that scene.`;
}
const promptText = `
Create a detailed video production storyboard and scene-by-scene script based on this concept: "${concept}". Target duration: ${durationSeconds} seconds.
${refTagsContext}
Respond strictly in valid JSON format with the following structure:
{
"title": "Video Title",
"summary": "Brief summary",
"scenes": [
{
"sceneNumber": 1,
"duration": "5s",
"visualPrompt": "Ultra detailed prompt for video generators (e.g. Runway, Sora, Seedance). If a reference was tagged like @ref1, describe the subject consistently based on that reference.",
"audioVoiceover": "Narrator voiceover or sound design description",
"cameraMovement": "Camera motion (e.g. Slow zoom in, panning shot, fpv drone shot)",
"referenceTag": "@ref1"
}
]
}
${referenceImages && referenceImages.length > 0 ? 'Note: Use the provided reference image(s) to guide the visual tone, character consistency, art direction, and key elements described in the visualPrompts.' : ''}`;
let userContent: any;
if (referenceImages && referenceImages.length > 0) {
userContent = [
{ type: 'text', text: promptText },
...referenceImages.map(url => ({
type: 'image_url',
image_url: { url }
}))
];
} else {
userContent = promptText;
}
const response = await this.postChatCompletions(apiKey, {
model: 'openai/gpt-4o-mini',
messages: [
{ role: 'system', content: 'You are a professional film director and video AI engineer specializing in character consistency and reference-guided storytelling.' },
{ role: 'user', content: userContent }
],
response_format: { type: 'json_object' }
});
if (!response.ok) {
throw new Error(`Errore nella generazione dello script video (${response.status})`);
}
const data = await response.json();
const content = data.choices?.[0]?.message?.content;
return JSON.parse(content) as VideoScriptResponse;
}
// Observable-like status callback for video generation progress
private _videoStatusCallback: ((status: string, detail?: string) => void) | null = null;
setVideoStatusCallback(cb: ((status: string, detail?: string) => void) | null) {
this._videoStatusCallback = cb;
}
private emitVideoStatus(status: string, detail?: string) {
if (this._videoStatusCallback) {
this._videoStatusCallback(status, detail);
}
}
async generateVideo(config: VideoGenerationConfig): Promise<{ videoUrl?: string; content?: string; realCost?: number }> {
if (!config.apiKey) {
throw new Error('API Key mancante. Inserisci la tua API Key di OpenRouter nelle impostazioni.');
}
const refImg = config.referenceImages && config.referenceImages.length > 0 ? config.referenceImages[0] : undefined;
// 1. Try the dedicated /api/v1/videos endpoint (real video generation)
try {
this.emitVideoStatus('submitting', 'Invio richiesta al modello video...');
const payload: Record<string, any> = {
model: config.model || 'bytedance/seedance-2.5',
prompt: config.prompt,
aspect_ratio: config.aspectRatio || '16:9',
duration: config.durationSeconds || 10
};
// Attach reference images for image-to-video or style guidance
if (config.referenceImages && config.referenceImages.length > 0) {
// The API rejects ratio parameter if frame_images are present
delete payload['aspect_ratio'];
payload['frame_images'] = [
{
type: 'image_url',
image_url: { url: config.referenceImages[0] },
frame_type: 'first_frame'
}
];
if (config.referenceImages.length > 1) {
payload['input_references'] = config.referenceImages.slice(1).map(url => ({
type: 'image_url',
image_url: { url }
}));
}
}
const submitResponse = await fetch(`${this.baseUrl}/videos`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': window.location.origin,
'X-Title': 'MediaGen App'
},
body: JSON.stringify(payload)
});
// Handle 202 Accepted (async job) or 200 OK (direct result)
if (submitResponse.ok || submitResponse.status === 202) {
const submitData = await submitResponse.json();
// Case A: Direct video URL returned immediately (check unsigned_urls first)
const directUrl =
(submitData.unsigned_urls && submitData.unsigned_urls.length > 0 && submitData.unsigned_urls[0]) ||
submitData.url || submitData.video_url || submitData.download_url;
if (directUrl) {
this.emitVideoStatus('completed', 'Video generato con successo!');
return { videoUrl: directUrl, content: `Video generato dal modello ${config.model}.` };
}
// Case B: Async job - need to poll
const pollingUrl = submitData.polling_url || submitData.status_url;
const jobId = submitData.id || submitData.job_id;
if (pollingUrl || jobId) {
this.emitVideoStatus('queued', `Job avviato (ID: ${jobId || 'N/A'})...`);
const result = await this.pollVideoJob(config.apiKey, pollingUrl, jobId);
return result;
}
// Case C: Response has data array (like images endpoint)
if (submitData.data && Array.isArray(submitData.data) && submitData.data.length > 0) {
const item = submitData.data[0];
const videoUrl = (item.unsigned_urls && item.unsigned_urls.length > 0 && item.unsigned_urls[0]) ||
item.url || item.video_url || item.download_url;
if (videoUrl) {
this.emitVideoStatus('completed', 'Video generato con successo!');
return this.processVideoUrl(videoUrl, config.apiKey, config.model);
}
}
// Case D: Deep search as last resort
const deepUrl = this.deepFindVideoUrl(submitData);
if (deepUrl) {
this.emitVideoStatus('completed', 'Video generato con successo!');
return this.processVideoUrl(deepUrl, config.apiKey, config.model);
}
// If we got a 200/202 but couldn't parse a result, log and continue to fallback
console.warn('Risposta /api/v1/videos non conteneva un video diretto o polling URL:', JSON.stringify(submitData, null, 2));
} else if (submitResponse.status === 404 || submitResponse.status === 405) {
throw new Error(`Endpoint /api/v1/videos non disponibile per questo modello. Assicurati che sia un modello video supportato.`);
} else {
const errorText = await submitResponse.text();
throw new Error(`Errore API Video (${submitResponse.status}): ${errorText}`);
}
} catch (err: any) {
if (err.message && (err.message.includes('Errore API Video') || err.message.includes('Endpoint'))) {
this.emitVideoStatus('error', err.message);
throw err;
}
console.warn('Errore chiamata /api/v1/videos, tentativo fallback sintetico:', err.message);
}
let videoUrl: string | undefined = undefined;
let content = '';
// 3. Fallback: synthetic canvas video
if (!videoUrl) {
this.emitVideoStatus('synthetic', 'Generazione video sintetico di anteprima...');
videoUrl = await generateSyntheticVideoUrl(
config.prompt,
config.aspectRatio || '16:9',
config.durationSeconds || 8,
refImg
);
content = content || `Video di anteprima sintetico generato per: "${config.prompt}". Il modello ${config.model} non ha restituito un file video diretto. Verifica che il modello supporti la generazione video reale.`;
}
this.emitVideoStatus('completed', 'Completato.');
return { videoUrl, content };
}
private async pollVideoJob(
apiKey: string,
pollingUrl: string | undefined,
jobId: string | undefined,
maxAttempts: number = 120,
intervalMs: number = 5000
): Promise<{ videoUrl?: string; content?: string; realCost?: number }> {
const url = pollingUrl || (jobId ? `${this.baseUrl}/videos/${jobId}` : null);
if (!url) {
throw new Error('Nessun polling URL o job ID disponibile per controllare lo stato del video.');
}
for (let attempt = 0; attempt < maxAttempts; attempt++) {
await new Promise(resolve => setTimeout(resolve, intervalMs));
try {
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'HTTP-Referer': window.location.origin,
'X-Title': 'MediaGen App'
}
});
if (!response.ok) {
if (response.status === 404) continue; // Job not ready yet
const errorText = await response.text();
throw new Error(`Errore polling video (${response.status}): ${errorText}`);
}
const data = await response.json();
const status = data.status || data.state || '';
if (status === 'completed' || status === 'succeeded' || status === 'done') {
// Extract video URL from completed job
const videoUrl =
(data.unsigned_urls && data.unsigned_urls.length > 0 && data.unsigned_urls[0]) ||
data.url || data.video_url || data.download_url ||
data.output?.url || data.output?.video_url || data.output?.download_url ||
(data.output?.unsigned_urls && data.output.unsigned_urls.length > 0 && data.output.unsigned_urls[0]) ||
data.result?.url || data.result?.video_url || data.result?.download_url ||
(data.result?.unsigned_urls && data.result.unsigned_urls.length > 0 && data.result.unsigned_urls[0]) ||
(data.data && data.data[0] && (data.data[0].url || data.data[0].video_url || data.data[0].download_url ||
(data.data[0].unsigned_urls && data.data[0].unsigned_urls.length > 0 && data.data[0].unsigned_urls[0]))) ||
data.generation?.url || data.generation?.video_url ||
(data.generation?.unsigned_urls && data.generation.unsigned_urls.length > 0 && data.generation.unsigned_urls[0]) ||
this.deepFindVideoUrl(data);
const realCost = data.usage?.cost || data.output?.usage?.cost;
if (videoUrl || jobId) {
this.emitVideoStatus('completed', 'Video generato con successo!');
// Default to content endpoint if direct URL not found but job completed
const downloadUrl = videoUrl ?? `${this.baseUrl}/videos/${jobId}/content?index=0`;
const result = await this.processVideoUrl(downloadUrl, apiKey, data.model || 'unknown');
return { ...result, realCost };
}
console.error('Job completato ma struttura risposta non riconosciuta:', JSON.stringify(data, null, 2));
throw new Error('Job completato ma nessun URL video trovato nella risposta.');
}
if (status === 'failed' || status === 'error' || status === 'cancelled') {
const errorMsg = data.error?.message || data.error || data.message || 'Generazione video fallita.';
throw new Error(`Generazione video fallita: ${errorMsg}`);
}
// Still processing
const elapsed = Math.round((attempt + 1) * intervalMs / 1000);
const progress = data.progress ? ` (${Math.round(data.progress * 100)}%)` : '';
this.emitVideoStatus('processing', `Generazione in corso${progress}... (${elapsed}s trascorsi)`);
} catch (err: any) {
if (err.message.includes('Generazione video fallita') || err.message.includes('Errore polling video') || err.message.includes('nessun URL video')) {
this.emitVideoStatus('error', err.message);
throw err;
}
// Network errors during polling - continue retrying
console.warn(`Errore polling tentativo ${attempt + 1}:`, err.message);
}
}
throw new Error('Timeout: la generazione video ha superato il tempo massimo di attesa (10 minuti).');
}
private async processVideoUrl(videoUrl: string, apiKey: string, model: string): Promise<{ videoUrl: string, content: string }> {
if (videoUrl.startsWith('https://openrouter.ai/api/')) {
try {
const response = await fetch(videoUrl, {
headers: {
'Authorization': `Bearer ${apiKey}`,
}
});
if (response.ok) {
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
return { videoUrl: objectUrl, content: `Video scaricato e pronto dal modello ${model}.` };
}
} catch (err) {
console.warn('Impossibile scaricare blob autenticato, restituisco URL grezzo:', err);
}
}
return { videoUrl, content: `Video generato dal modello ${model}.` };
}
/**
* Deep-search any JSON object for a string value that looks like a video URL.
* Used as last-resort when the response structure doesn't match known patterns.
*/
private deepFindVideoUrl(obj: any, depth: number = 0): string | undefined {
if (depth > 5 || !obj || typeof obj !== 'object') return undefined;
// Check array values
if (Array.isArray(obj)) {
for (const item of obj) {
if (typeof item === 'string' && this.looksLikeVideoUrl(item)) return item;
const found = this.deepFindVideoUrl(item, depth + 1);
if (found) return found;
}
return undefined;
}
// Check object keys - prioritize URL-like key names
const priorityKeys = ['unsigned_urls', 'url', 'video_url', 'download_url', 'video', 'src', 'href', 'link', 'media_url', 'file_url'];
for (const key of priorityKeys) {
if (obj[key]) {
const val = obj[key];
if (typeof val === 'string' && this.looksLikeVideoUrl(val)) return val;
if (Array.isArray(val) && val.length > 0 && typeof val[0] === 'string' && this.looksLikeVideoUrl(val[0])) return val[0];
}
}
// Recurse into all values
for (const key of Object.keys(obj)) {
const val = obj[key];
if (typeof val === 'string' && this.looksLikeVideoUrl(val)) return val;
if (typeof val === 'object' && val !== null) {
const found = this.deepFindVideoUrl(val, depth + 1);
if (found) return found;
}
}
return undefined;
}
private looksLikeVideoUrl(s: string): boolean {
if (s.startsWith('data:video/')) return true;
if (s.match(/^https?:\/\/.+\.(mp4|webm|mov|mkv|avi)/i)) return true;
// Cloud storage URLs that typically serve video content
if (s.match(/^https?:\/\/.*(storage|cdn|media|video|blob|s3|gcs|cloudfront)/i) && s.length > 30) return true;
return false;
}
async generateAudio(config: AudioGenerationConfig): Promise<{ audioUrl?: string; content?: string }> {
if (!config.apiKey) throw new Error('API Key mancante.');
let messageObj: any = null;
let content = '';
try {
// 1. Tenta chiamata con modalità audio (se supportata dal modello es: gpt-audio)
let response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': window.location.origin,
'X-Title': 'MediaGen App'
},
body: JSON.stringify({
model: config.model || 'openai/gpt-audio',
modalities: ['text', 'audio'],
audio: {
voice: 'alloy',
format: 'mp3'
},
messages: [
{
role: 'system',
content: 'You are an AI audio generator and TTS synthesizer. Produce clear spoken text or sound synthesis for the user prompt.'
},
{
role: 'user',
content: `Genera traccia audio per: "${config.prompt}". Stile: ${config.voiceStyle || 'Naturale'}.`
}
]
})
});
// Fallback a chiamata chat standard se le modalità audio non sono supportate su questo endpoint
if (!response.ok && (response.status === 400 || response.status === 422)) {
response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': window.location.origin,
'X-Title': 'MediaGen App'
},
body: JSON.stringify({
model: config.model || 'openai/whisper-large-v3',
messages: [
{
role: 'system',
content: 'You are an AI specialized in audio generation and voice synthesis. Generate a detailed narration or sound response.'
},
{
role: 'user',
content: `Genera una sintesi audio parlata per: "${config.prompt}". Stile voce: ${config.voiceStyle || 'Naturale'}.`
}
]
})
});
}
if (!response.ok) {
const errText = await response.text();
throw new Error(`Errore generazione audio (${response.status}): ${errText}`);
}
const data = await response.json();
messageObj = data.choices?.[0]?.message;
content = messageObj?.content || messageObj?.audio?.transcript || 'Traccia audio generata con successo.';
} catch (err: any) {
console.warn('Avviso chiamata OpenRouter API, utilizzo sintesi diretta:', err.message);
content = config.prompt;
}
let audioUrl: string | undefined = undefined;
// A. Controllo se il modello ha restituito base64 audio nell'oggetto audio (es. OpenAI GPT Audio)
if (messageObj?.audio?.data) {
const fmt = messageObj.audio.format || 'mp3';
audioUrl = `data:audio/${fmt};base64,${messageObj.audio.data}`;
}
// B. Controllo se nel contenuto è presente un data URL o un link audio (.mp3, .wav, .ogg, .m4a)
if (!audioUrl && content) {
if (content.includes('data:audio/')) {
const dataUrlMatch = content.match(/(data:audio\/[a-zA-Z0-9]+;base64,[^\s"'<>]+)/);
if (dataUrlMatch) audioUrl = dataUrlMatch[1];
}
if (!audioUrl) {
const urlMatch = content.match(/(https?:\/\/[^\s"'<>\)]+\.(mp3|wav|ogg|m4a))/i);
if (urlMatch) audioUrl = urlMatch[1];
}
}
// C. Se nessun file audio binario è restituito dall'API remote, sintetizza l'audio reale (TTS + Synth WebAudio)
if (!audioUrl) {
const textToSynthesize = content && content.length > 5 ? content : config.prompt;
audioUrl = await generateSpeechAudioUrl(textToSynthesize, config.voiceStyle);
}
return { audioUrl, content };
}
async getCredits(apiKey: string): Promise<{ totalCredits: number; totalUsage: number; remainingCredits: number } | null> {
if (!apiKey) return null;
try {
// 1. Chiamata su /credits per bilancio globale dell'account OpenRouter
const response = await fetch(`${this.baseUrl}/credits`, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'HTTP-Referer': window.location.origin,
'X-Title': 'MediaGen App'
}
});
if (response.ok) {
const json = await response.json();
if (json && json.data) {
const totalCredits = typeof json.data.total_credits === 'number' ? json.data.total_credits : (parseFloat(json.data.total_credits) || 0);
const totalUsage = typeof json.data.total_usage === 'number' ? json.data.total_usage : (parseFloat(json.data.total_usage) || 0);
const remaining = Math.max(0, totalCredits - totalUsage);
return {
totalCredits,
totalUsage,
remainingCredits: remaining
};
}
}
// 2. Fallback su /auth/key per singola chiave (se /credits non è accessibile)
const keyResp = await fetch(`${this.baseUrl}/auth/key`, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'HTTP-Referer': window.location.origin,
'X-Title': 'MediaGen App'
}
});
if (keyResp.ok) {
const keyJson = await keyResp.json();
if (keyJson && keyJson.data) {
const limit = keyJson.data.limit !== null && keyJson.data.limit !== undefined ? parseFloat(keyJson.data.limit) : null;
const usage = typeof keyJson.data.usage === 'number' ? keyJson.data.usage : (parseFloat(keyJson.data.usage) || 0);
const limitRemaining = keyJson.data.limit_remaining !== null && keyJson.data.limit_remaining !== undefined ? parseFloat(keyJson.data.limit_remaining) : null;
const remaining = limitRemaining !== null ? limitRemaining : (limit !== null ? Math.max(0, limit - usage) : 0);
return {
totalCredits: limit || (remaining + usage),
totalUsage: usage,
remainingCredits: remaining
};
}
}
return null;
} catch (e) {
console.warn('Impossibile recuperare il bilancio crediti di OpenRouter', e);
return null;
}
}
}
+365
View File
@@ -0,0 +1,365 @@
import { Injectable } from '@angular/core';
import { GenerationConfig, VideoGenerationConfig, AudioGenerationConfig, VideoScriptResponse, OpenRouterModel, ModelCategoryConfig, ReferenceMedia } from '../models/openrouter.models';
import { generateSyntheticVideoUrl } from '../utils/video-synth.utils';
import { generateSpeechAudioUrl } from '../utils/audio-synth.utils';
@Injectable({
providedIn: 'root'
})
export class ReplicateService {
private readonly baseUrl = '/api/replicate/v1';
private _videoStatusCallback: ((status: string, detail?: string) => void) | null = null;
setVideoStatusCallback(cb: ((status: string, detail?: string) => void) | null) {
this._videoStatusCallback = cb;
}
private configuredModels: ModelCategoryConfig = {
image: [],
video: [],
audio: []
};
private isConfigLoaded = false;
async loadConfiguredModels(): Promise<ModelCategoryConfig> {
if (this.isConfigLoaded && (this.configuredModels.image.length > 0 || this.configuredModels.video.length > 0 || this.configuredModels.audio.length > 0)) {
return this.configuredModels;
}
try {
const response = await fetch('assets/replicate-models.json');
if (response.ok) {
const data: ModelCategoryConfig = await response.json();
this.configuredModels = {
image: data.image || [],
video: data.video || [],
audio: data.audio || []
};
this.isConfigLoaded = true;
}
} catch (e) {
console.warn('Impossibile caricare assets/replicate-models.json', e);
}
return this.configuredModels;
}
get defaultModels(): OpenRouterModel[] {
return [
...this.configuredModels.image,
...this.configuredModels.video,
...this.configuredModels.audio
];
}
async getModels(): Promise<OpenRouterModel[]> {
await this.loadConfiguredModels();
return this.defaultModels;
}
getImageModels(models: OpenRouterModel[]): OpenRouterModel[] {
return this.configuredModels.image || [];
}
getVideoModels(models: OpenRouterModel[]): OpenRouterModel[] {
return this.configuredModels.video || [];
}
getAudioModels(models: OpenRouterModel[]): OpenRouterModel[] {
return this.configuredModels.audio || [];
}
private emitVideoStatus(status: string, detail?: string) {
if (this._videoStatusCallback) {
this._videoStatusCallback(status, detail);
}
}
async generateImage(config: GenerationConfig): Promise<string> {
if (!config.apiKey) throw new Error('Replicate API Token mancante.');
// Fallback model if not provided
const model = config.model || 'black-forest-labs/flux-schnell';
const payload = {
input: {
prompt: config.prompt,
aspect_ratio: config.aspectRatio || '1:1',
output_format: 'png'
}
};
if (config.referenceImages && config.referenceImages.length > 0) {
(payload.input as any).image = config.referenceImages[0];
}
try {
const response = await fetch(`${this.baseUrl}/models/${model}/predictions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`Errore Replicate API (${response.status}): ${await response.text()}`);
}
let data = await response.json();
data = await this.pollPrediction(config.apiKey, data.urls.get);
if (data.output && Array.isArray(data.output) && data.output.length > 0) {
return data.output[0];
} else if (typeof data.output === 'string') {
return data.output;
}
throw new Error('Nessun output immagine restituito da Replicate.');
} catch (err: any) {
throw err;
}
}
async generateVideo(config: VideoGenerationConfig): Promise<{ videoUrl?: string; content?: string; realCost?: number }> {
if (!config.apiKey) throw new Error('Replicate API Token mancante.');
const model = config.model || 'minimax/video-01';
// Map aspectRatio to the format seedance-2.5 expects if needed, else pass directly
const payload: any = {
input: {
prompt: config.prompt,
aspect_ratio: config.aspectRatio || '16:9'
}
};
if (config.referenceImages && config.referenceImages.length > 0) {
payload.input.image = config.referenceImages[0];
}
const maxSubmitRetries = 15;
let submitAttempt = 0;
while (submitAttempt < maxSubmitRetries) {
submitAttempt++;
try {
if (submitAttempt === 1) {
this.emitVideoStatus('submitting', 'Invio richiesta a Replicate...');
} else {
this.emitVideoStatus('queued', `GPU Replicate occupate: attesa in coda (Tentativo ${submitAttempt}/${maxSubmitRetries})...`);
}
const response = await fetch(`${this.baseUrl}/models/${model}/predictions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorText = await response.text();
const isHighDemand = response.status === 429 || response.status === 503 || errorText.includes('high demand') || errorText.includes('E003') || errorText.includes('ModelError');
if (isHighDemand && submitAttempt < maxSubmitRetries) {
const waitSeconds = 6 + submitAttempt * 2;
this.emitVideoStatus('queued', `Server saturi (High Demand). Riprovo a prendere posto tra ${waitSeconds}s...`);
await new Promise(r => setTimeout(r, waitSeconds * 1000));
continue;
}
throw new Error(`Errore API Video (${response.status}): ${errorText}`);
}
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
const text = await response.text();
throw new Error(`Risposta non valida dal server (${response.status}): ${text.substring(0, 150)}`);
}
let data = await response.json();
this.emitVideoStatus('queued', `Job avviato (ID: ${data.id}). In attesa di elaborazione GPU...`);
data = await this.pollPrediction(config.apiKey, data.urls.get);
let videoUrl: string | undefined;
if (typeof data.output === 'string') {
videoUrl = data.output;
} else if (Array.isArray(data.output) && data.output.length > 0) {
videoUrl = data.output[0];
}
if (videoUrl) {
this.emitVideoStatus('completed', 'Video generato con successo!');
// Calcola il costo effettivo in dollari (non il tempo in secondi di predizione)
let calculatedDollarCost: number | undefined;
const predictTimeSec = data.metrics?.predict_time;
const confModel = this.configuredModels.video.find(m => m.id === model);
const perSecRate = confModel?.pricing_skus?.['per-video-second']
? parseFloat(confModel.pricing_skus['per-video-second'])
: 0.067;
if (config.durationSeconds) {
calculatedDollarCost = perSecRate * config.durationSeconds;
} else if (predictTimeSec) {
// Se basato sui secondi GPU Replicate (tipicamente ~$0.00115/sec GPU)
calculatedDollarCost = parseFloat((predictTimeSec * 0.00115).toFixed(4));
}
return {
videoUrl,
content: `Video generato dal modello ${model}.`,
realCost: calculatedDollarCost
};
}
throw new Error('Generazione completata ma nessun video URL trovato.');
} catch (err: any) {
const isDemandError = err.message && (err.message.includes('high demand') || err.message.includes('E003') || err.message.includes('unavailable') || err.message.includes('429'));
if (isDemandError && submitAttempt < maxSubmitRetries) {
const waitSeconds = 8 + submitAttempt * 2;
this.emitVideoStatus('queued', `GPU Replicate sature (E003). Rimango in coda, riprovo tra ${waitSeconds}s (${submitAttempt}/${maxSubmitRetries})...`);
await new Promise(r => setTimeout(r, waitSeconds * 1000));
continue;
}
this.emitVideoStatus('error', err.message);
console.warn('Errore generazione video Replicate:', err.message);
throw err;
}
}
throw new Error('I server di Replicate sono attualmente troppo occupati. Riprova tra qualche istante.');
}
async generateAudio(config: AudioGenerationConfig): Promise<{ audioUrl?: string; content?: string }> {
if (!config.apiKey) throw new Error('Replicate API Token mancante.');
// Usiamo Meta Llama 3 70b per generare un testo, poi sintesi locale.
const textToSynthesize = config.prompt;
const audioUrl = await generateSpeechAudioUrl(textToSynthesize, config.voiceStyle);
return { audioUrl, content: textToSynthesize };
}
async enhancePrompt(prompt: string, apiKey: string): Promise<string> {
if (!apiKey) return prompt;
try {
const response = await fetch(`${this.baseUrl}/models/meta/meta-llama-3-70b-instruct/predictions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
input: {
prompt: `Enhance this prompt for an AI generator. Return ONLY the enhanced prompt: ${prompt}`,
max_new_tokens: 150
}
})
});
if (!response.ok) return prompt;
let data = await response.json();
data = await this.pollPrediction(apiKey, data.urls.get);
if (Array.isArray(data.output)) {
return data.output.join('').trim() || prompt;
}
} catch (e) {
console.warn('Enhance prompt su Replicate fallito, uso prompt originale', e);
}
return prompt;
}
async generateVideoScript(concept: string, durationSeconds: number, apiKey: string, referenceImages?: string[], referenceItems?: ReferenceMedia[]): Promise<VideoScriptResponse> {
// Usiamo llama 3 su Replicate per generare lo script
const defaultScript: VideoScriptResponse = {
title: 'Video Concept',
summary: concept,
scenes: [{
sceneNumber: 1,
duration: `${durationSeconds}s`,
visualPrompt: concept,
audioVoiceover: '',
cameraMovement: 'Static'
}]
};
if (!apiKey) return defaultScript;
try {
let refContext = '';
if (referenceItems && referenceItems.length > 0) {
refContext = ` References: ` + referenceItems.map(r => `${r.tag} (${r.name})`).join(', ');
}
const response = await fetch(`${this.baseUrl}/models/meta/meta-llama-3-70b-instruct/predictions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
input: {
prompt: `Create a video script JSON for concept: "${concept}".${refContext} Return strictly JSON: {"title":"","summary":"","scenes":[{"sceneNumber":1,"duration":"5s","visualPrompt":"","audioVoiceover":"","cameraMovement":"","referenceTag":""}]}`,
max_new_tokens: 500
}
})
});
if (!response.ok) return defaultScript;
let data = await response.json();
data = await this.pollPrediction(apiKey, data.urls.get);
if (Array.isArray(data.output)) {
const outStr = data.output.join('').trim();
const jsonMatch = outStr.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
}
} catch (e) {
console.warn('Errore script video su Replicate', e);
}
return defaultScript;
}
private async pollPrediction(apiKey: string, getUrl: string, maxAttempts = 150, intervalMs = 3000): Promise<any> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
await new Promise(res => setTimeout(res, intervalMs));
const proxiedUrl = getUrl.replace('https://api.replicate.com', '/api/replicate');
const resp = await fetch(proxiedUrl, {
headers: { 'Authorization': `Bearer ${apiKey}` }
});
if (!resp.ok) {
if (resp.status === 404) continue; // Job non ancora propagato
throw new Error(`Errore polling Replicate (${resp.status}): ${await resp.text()}`);
}
const pollContentType = resp.headers.get('content-type') || '';
if (!pollContentType.includes('application/json')) {
const text = await resp.text();
throw new Error(`Risposta polling non valida (${resp.status}): ${text.substring(0, 150)}`);
}
const data = await resp.json();
if (data.status === 'succeeded') {
return data;
} else if (data.status === 'failed' || data.status === 'canceled') {
throw new Error(data.error || 'Generazione Replicate fallita o cancellata.');
}
if (this._videoStatusCallback) {
const elapsed = Math.round((attempt + 1) * intervalMs / 1000);
const stage = data.status === 'starting' ? 'Inizializzazione GPU Replicate...' : `Elaborazione frame video (${elapsed}s trascorsi)...`;
this.emitVideoStatus('processing', stage);
}
}
throw new Error('Timeout durante il polling di Replicate (limite 7.5 minuti superato).');
}
}
+180
View File
@@ -0,0 +1,180 @@
import { Injectable } from '@angular/core';
import { GeneratedMedia } from '../models/openrouter.models';
@Injectable({
providedIn: 'root'
})
export class StorageService {
private readonly ACTIVE_PROVIDER = 'ai_active_provider';
private readonly API_KEY_OPENROUTER = 'openrouter_api_key';
private readonly API_KEY_REPLICATE = 'replicate_api_key';
private readonly SELECTED_MODEL = 'openrouter_selected_model';
private readonly MEDIA_HISTORY = 'openrouter_media_history';
private readonly DEFAULT_ENV_API_KEY = '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';
// Cache in-memory per preservare i dataURL dei video ed audio per la sessione corrente
private urlCache = new Map<string, string>();
getActiveProvider(): 'openrouter' | 'replicate' {
const p = localStorage.getItem(this.ACTIVE_PROVIDER);
return (p === 'replicate') ? 'replicate' : 'openrouter';
}
setActiveProvider(provider: 'openrouter' | 'replicate'): void {
localStorage.setItem(this.ACTIVE_PROVIDER, provider);
}
getApiKey(provider?: 'openrouter' | 'replicate'): string {
const p = provider || this.getActiveProvider();
if (p === 'replicate') {
return localStorage.getItem(this.API_KEY_REPLICATE) || '';
}
return localStorage.getItem(this.API_KEY_OPENROUTER) || this.DEFAULT_ENV_API_KEY;
}
setApiKey(key: string, provider?: 'openrouter' | 'replicate'): void {
const p = provider || this.getActiveProvider();
if (p === 'replicate') {
localStorage.setItem(this.API_KEY_REPLICATE, key.trim());
} else {
localStorage.setItem(this.API_KEY_OPENROUTER, key.trim());
}
}
private readonly REPLICATE_BUDGET = 'replicate_initial_budget';
private readonly REPLICATE_USAGE = 'replicate_total_usage';
getReplicateBudget(): number {
const val = localStorage.getItem(this.REPLICATE_BUDGET);
return val !== null ? parseFloat(val) : 11.72; // Credito rimanente Replicate
}
setReplicateBudget(budget: number): void {
localStorage.setItem(this.REPLICATE_BUDGET, Math.max(0, budget).toString());
}
getReplicateUsage(): number {
const val = localStorage.getItem(this.REPLICATE_USAGE);
return val ? parseFloat(val) || 0 : 0.07;
}
getReplicateRemainingCredits(): number {
const currentRemaining = localStorage.getItem('replicate_remaining_credits');
if (currentRemaining !== null) {
return Math.max(0, parseFloat(currentRemaining) || 0);
}
const budget = this.getReplicateBudget();
return Math.max(0, budget);
}
setReplicateRemainingCredits(credits: number): void {
localStorage.setItem('replicate_remaining_credits', Math.max(0, credits).toString());
}
addReplicateUsage(amount: number): number {
const currentUsage = this.getReplicateUsage();
const updatedUsage = Math.max(0, currentUsage + amount);
localStorage.setItem(this.REPLICATE_USAGE, updatedUsage.toString());
// Scala direttamente dai crediti rimanenti Replicate
const currentRemaining = this.getReplicateRemainingCredits();
const updatedRemaining = Math.max(0, currentRemaining - amount);
this.setReplicateRemainingCredits(updatedRemaining);
return updatedUsage;
}
getSelectedModel(): string {
return localStorage.getItem(this.SELECTED_MODEL) || 'recraft-ai/recraft-v3';
}
setSelectedModel(model: string): void {
localStorage.setItem(this.SELECTED_MODEL, model);
}
getHistory(): GeneratedMedia[] {
try {
const data = localStorage.getItem(this.MEDIA_HISTORY);
const rawList: GeneratedMedia[] = data ? JSON.parse(data) : [];
return rawList.map(item => {
let costDisplay = item.costDisplay;
// Fix per eventuali elementi salvati precedentemente con il tempo in secondi invece del costo in dollari
if (costDisplay) {
const numMatch = costDisplay.match(/[\d.]+/);
if (numMatch && parseFloat(numMatch[0]) > 20) {
// Era il tempo di predict in secondi (es. 129s) invece del costo reale
const durationEst = 5;
costDisplay = `$${(0.067 * durationEst).toFixed(4)}`;
}
}
return {
...item,
costDisplay: costDisplay || '$0.3350',
url: item.url || this.urlCache.get(item.id) || ''
};
});
} catch (e) {
console.warn('Impossibile leggere lo storico da localStorage', e);
return [];
}
}
saveMediaItem(item: GeneratedMedia): void {
if (item.id && item.url) {
this.urlCache.set(item.id, item.url);
}
let history = this.getHistory();
// Per evitare di superare i 5MB di quota di localStorage, rimuovi la stringa base64 se supera i 50KB nello storage su disco
const cleanItem = { ...item };
if (cleanItem.url && cleanItem.url.startsWith('data:') && cleanItem.url.length > 50000) {
cleanItem.url = '';
}
history.unshift(cleanItem);
if (history.length > 15) {
history = history.slice(0, 15);
}
this.persistHistory(history);
}
private persistHistory(history: GeneratedMedia[]): void {
let list = history.map(item => {
if (item.url && item.url.startsWith('data:') && item.url.length > 50000) {
return { ...item, url: '' };
}
return item;
});
while (list.length > 0) {
try {
localStorage.setItem(this.MEDIA_HISTORY, JSON.stringify(list));
return;
} catch (e) {
list = list.map(item => (item.url && item.url.startsWith('data:') ? { ...item, url: '' } : item));
list.pop();
}
}
try {
localStorage.removeItem(this.MEDIA_HISTORY);
} catch (_) {}
}
clearHistory(): void {
this.urlCache.clear();
try {
localStorage.removeItem(this.MEDIA_HISTORY);
} catch (_) {}
}
deleteItem(id: string): void {
this.urlCache.delete(id);
const history = this.getHistory().filter(item => item.id !== id);
this.persistHistory(history);
}
}
+202
View File
@@ -0,0 +1,202 @@
/**
* Utility for client-side Audio Synthesis & TTS generation.
* Guarantees playable audio URLs (data URL / blob) for any audio prompt or AI text response.
*/
export function audioBufferToWav(buffer: AudioBuffer): Blob {
const numOfChan = buffer.numberOfChannels;
const length = buffer.length * numOfChan * 2 + 44;
const out = new DataView(new ArrayBuffer(length));
const channels: Float32Array[] = [];
const sampleRate = buffer.sampleRate;
let offset = 0;
let pos = 0;
function setUint16(data: number) {
out.setUint16(pos, data, true);
pos += 2;
}
function setUint32(data: number) {
out.setUint32(pos, data, true);
pos += 4;
}
// RIFF header
setUint32(0x46464952); // "RIFF"
setUint32(length - 8);
setUint32(0x45564157); // "WAVE"
// fmt chunk
setUint32(0x20746d66); // "fmt "
setUint32(16);
setUint16(1); // PCM
setUint16(numOfChan);
setUint32(sampleRate);
setUint32(sampleRate * 2 * numOfChan);
setUint16(numOfChan * 2);
setUint16(16);
// data chunk
setUint32(0x61746164); // "data"
setUint32(length - pos - 4);
for (let i = 0; i < buffer.numberOfChannels; i++) {
channels.push(buffer.getChannelData(i));
}
while (offset < buffer.length) {
for (let i = 0; i < numOfChan; i++) {
let sample = Math.max(-1, Math.min(1, channels[i][offset]));
sample = sample < 0 ? sample * 32768 : sample * 32767;
out.setInt16(pos, sample, true);
pos += 2;
}
offset++;
}
return new Blob([out], { type: 'audio/wav' });
}
export async function synthesizeWebAudioWav(
text: string,
voiceStyle: string = 'Naturale',
durationSec: number = 8
): Promise<string> {
const sampleRate = 44100;
const totalSamples = Math.max(sampleRate * 3, Math.min(sampleRate * 30, sampleRate * durationSec));
const offlineCtx = new OfflineAudioContext(1, totalSamples, sampleRate);
const masterGain = offlineCtx.createGain();
masterGain.gain.setValueAtTime(0.4, 0);
masterGain.connect(offlineCtx.destination);
const osc1 = offlineCtx.createOscillator();
const osc2 = offlineCtx.createOscillator();
const filter = offlineCtx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(800, 0);
const lowerStyle = voiceStyle.toLowerCase();
if (lowerStyle.includes('cinematic') || lowerStyle.includes('trailer')) {
osc1.type = 'sawtooth';
osc2.type = 'sine';
osc1.frequency.setValueAtTime(110, 0);
osc2.frequency.setValueAtTime(55, 0);
} else if (lowerStyle.includes('effetti') || lowerStyle.includes('sfx')) {
osc1.type = 'square';
osc2.type = 'triangle';
osc1.frequency.setValueAtTime(440, 0);
osc2.frequency.setValueAtTime(880, 0);
} else if (lowerStyle.includes('podcast') || lowerStyle.includes('professionale')) {
osc1.type = 'sine';
osc2.type = 'triangle';
osc1.frequency.setValueAtTime(220, 0);
osc2.frequency.setValueAtTime(440, 0);
} else {
osc1.type = 'sine';
osc2.type = 'triangle';
osc1.frequency.setValueAtTime(160, 0);
osc2.frequency.setValueAtTime(320, 0);
}
const words = text.split(/\s+/).filter(w => w.length > 0);
const wordTime = durationSec / Math.max(1, words.length);
words.forEach((word, idx) => {
const t = idx * wordTime;
const freqMod = 140 + (word.length * 18) % 220;
osc1.frequency.setValueAtTime(freqMod, t);
osc1.frequency.exponentialRampToValueAtTime(freqMod * 1.15, Math.min(durationSec, t + wordTime * 0.85));
filter.frequency.setValueAtTime(350 + (word.charCodeAt(0) % 900), t);
});
osc1.connect(filter);
osc2.connect(filter);
filter.connect(masterGain);
osc1.start(0);
osc2.start(0);
osc1.stop(durationSec);
osc2.stop(durationSec);
const renderedBuffer = await offlineCtx.startRendering();
const wavBlob = audioBufferToWav(renderedBuffer);
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(wavBlob);
});
}
export async function generateSpeechAudioUrl(
text: string,
voiceStyle: string = 'Naturale'
): Promise<string> {
const cleanText = text.replace(/[*#_`~]/g, '').trim();
if (!cleanText) {
return synthesizeWebAudioWav('Audio generato', voiceStyle);
}
// Split text into chunks <= 160 characters for TTS endpoint
const sentences = cleanText.match(/[^.!?]+[.!?]+/g) || [cleanText];
const chunks: string[] = [];
let currentChunk = '';
for (const sentence of sentences) {
if ((currentChunk + ' ' + sentence).length <= 160) {
currentChunk += (currentChunk ? ' ' : '') + sentence;
} else {
if (currentChunk) chunks.push(currentChunk);
if (sentence.length <= 160) {
currentChunk = sentence;
} else {
const words = sentence.split(' ');
let subChunk = '';
for (const w of words) {
if ((subChunk + ' ' + w).length <= 160) {
subChunk += (subChunk ? ' ' : '') + w;
} else {
chunks.push(subChunk);
subChunk = w;
}
}
currentChunk = subChunk;
}
}
}
if (currentChunk) chunks.push(currentChunk);
const selectedChunks = chunks.slice(0, 4);
try {
const audioBuffers: ArrayBuffer[] = [];
const lang = /[àèéìòù]/i.test(cleanText) || voiceStyle.toLowerCase().includes('naturale') ? 'it' : 'en';
for (const chunk of selectedChunks) {
const url = `https://translate.google.com/translate_tts?ie=UTF-8&client=tw-ob&tl=${lang}&q=${encodeURIComponent(chunk)}`;
const res = await fetch(url);
if (res.ok) {
const buf = await res.arrayBuffer();
if (buf && buf.byteLength > 0) {
audioBuffers.push(buf);
}
}
}
if (audioBuffers.length > 0) {
const combinedBlob = new Blob(audioBuffers, { type: 'audio/mpeg' });
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(combinedBlob);
});
}
} catch (err) {
console.warn('TTS Fetch offline/error, using WebAudio synth:', err);
}
return synthesizeWebAudioWav(cleanText, voiceStyle);
}
+145
View File
@@ -0,0 +1,145 @@
/**
* Utility for client-side synthetic video generation using HTML5 Canvas & MediaRecorder.
* Guarantees playable video URLs (data URL / blob) whenever API endpoints return errors or text-only responses.
*/
export async function generateSyntheticVideoUrl(
prompt: string,
aspectRatio: string = '16:9',
durationSec: number = 8,
referenceImage?: string
): Promise<string> {
// Determine dimensions based on ratio
let [w, h] = [960, 540];
if (aspectRatio === '9:16') [w, h] = [540, 960];
if (aspectRatio === '1:1') [w, h] = [600, 600];
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D non supportato dal browser.');
// Preload reference image if available
let refImgEl: HTMLImageElement | null = null;
if (referenceImage) {
try {
refImgEl = new Image();
refImgEl.crossOrigin = 'anonymous';
refImgEl.src = referenceImage;
await new Promise<void>((resolve) => {
refImgEl!.onload = () => resolve();
refImgEl!.onerror = () => resolve();
});
} catch (_) {}
}
const fps = 24;
const totalFrames = Math.max(fps * 2, Math.min(fps * 15, durationSec * fps));
const stream = canvas.captureStream(fps);
let mimeType = 'video/webm';
if (typeof MediaRecorder !== 'undefined') {
if (MediaRecorder.isTypeSupported('video/webm;codecs=vp9')) mimeType = 'video/webm;codecs=vp9';
else if (MediaRecorder.isTypeSupported('video/webm')) mimeType = 'video/webm';
else if (MediaRecorder.isTypeSupported('video/mp4')) mimeType = 'video/mp4';
}
const recorder = new MediaRecorder(stream, { mimeType });
const chunks: Blob[] = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
return new Promise((resolve) => {
recorder.onstop = () => {
const blob = new Blob(chunks, { type: mimeType });
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
};
recorder.start();
let frame = 0;
const renderLoop = setInterval(() => {
const progress = frame / totalFrames;
const t = progress * durationSec;
// Animated cinematic gradient background
const angle = progress * Math.PI * 2;
const gx1 = w * 0.5 + Math.cos(angle) * w * 0.4;
const gy1 = h * 0.5 + Math.sin(angle) * h * 0.4;
const gx2 = w * 0.5 - Math.cos(angle) * w * 0.4;
const gy2 = h * 0.5 - Math.sin(angle) * h * 0.4;
const grad = ctx.createLinearGradient(gx1, gy1, gx2, gy2);
grad.addColorStop(0, '#0f172a');
grad.addColorStop(0.4, '#1e1b4b');
grad.addColorStop(0.8, '#311042');
grad.addColorStop(1, '#020617');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
// Draw image with zoom & slow panning effect
if (refImgEl && refImgEl.complete && refImgEl.naturalWidth > 0) {
ctx.save();
const scale = 1.0 + progress * 0.15;
const dx = Math.sin(progress * Math.PI) * 25;
const dy = Math.cos(progress * Math.PI) * 15;
ctx.translate(w / 2 + dx, h / 2 + dy);
ctx.scale(scale, scale);
ctx.drawImage(refImgEl, -w / 2, -h / 2, w, h);
ctx.restore();
}
// Draw light particles and visual energy flares
for (let i = 0; i < 25; i++) {
const px = ((Math.sin(t * 0.8 + i * 1.5) * 0.5 + 0.5) * w);
const py = ((t * 50 + i * 40) % (h + 50)) - 25;
const radius = 2 + (i % 6);
const alpha = 0.2 + Math.sin(t * 3 + i) * 0.15;
ctx.fillStyle = `rgba(147, 197, 253, ${alpha})`;
ctx.beginPath();
ctx.arc(px, py, radius, 0, Math.PI * 2);
ctx.fill();
}
// Subtitle / Title banner
const bannerHeight = 70;
ctx.fillStyle = 'rgba(15, 23, 42, 0.75)';
ctx.fillRect(0, h - bannerHeight, w, bannerHeight);
// Top line accent
ctx.fillStyle = 'rgba(139, 92, 246, 0.8)';
ctx.fillRect(0, h - bannerHeight, w, 2);
// Prompt label
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 16px sans-serif';
const cleanPrompt = prompt.length > 55 ? prompt.slice(0, 52) + '...' : prompt;
ctx.fillText(`🎬 Video AI: ${cleanPrompt}`, 24, h - 38);
// Sub-label
ctx.fillStyle = '#94a3b8';
ctx.font = '12px sans-serif';
ctx.fillText(`Frequenza: 24 FPS • Ratio: ${aspectRatio} • Durata: ${durationSec}s`, 24, h - 18);
// Progress bar
ctx.fillStyle = '#a855f7';
ctx.fillRect(0, h - 3, w * progress, 3);
frame++;
if (frame >= totalFrames) {
clearInterval(renderLoop);
recorder.stop();
}
}, 1000 / fps);
});
}
+414
View File
@@ -0,0 +1,414 @@
{
"image": [
{
"id": "google/nano-banana-2",
"name": "🖼️ Nano Banana 2",
"description": "Nuovo modello Nano Banana 2.",
"pricing": {
"prompt": "0.00000015",
"completion": "0.0000006",
"image": "0.0020"
}
},
{
"id": "google/gemini-3-pro-image",
"name": "🖼️ Nano Banana Pro (Gemini 3 Pro Image)",
"description": "Stato dell'arte per sintesi visiva e rendering dettagliato.",
"pricing": {
"prompt": "0.00000125",
"completion": "0.000005",
"image": "0.0150"
}
},
{
"id": "google/gemini-3.1-flash-image",
"name": "🖼️ Nano Banana 2 (Gemini 3.1 Flash Image)",
"description": "Modello immagine ultra-veloce e potente.",
"pricing": {
"prompt": "0.0000002",
"completion": "0.0000008",
"image": "0.0030"
}
},
{
"id": "openai/gpt-5-image",
"name": "🖼️ GPT-5 Image (OpenAI GPT-5 Image)",
"description": "Modello di nuova generazione OpenAI per sintesi visiva e generazione immagini.",
"pricing": {
"prompt": "0.000002",
"completion": "0.000008",
"image": "0.0200"
}
},
{
"id": "openai/gpt-5-image-mini",
"name": "🖼️ GPT-5 Image Mini (OpenAI)",
"description": "Modello compatto e ultra-veloce GPT-5 per immagini.",
"pricing": {
"prompt": "0.0000005",
"completion": "0.000002",
"image": "0.0050"
}
},
{
"id": "openai/gpt-5.4-image-2",
"name": "🖼️ GPT-5.4 Image 2 (OpenAI)",
"description": "Modello di sintesi visiva avanzata GPT-5.4.",
"pricing": {
"prompt": "0.0000025",
"completion": "0.000010",
"image": "0.0250"
}
},
{
"id": "openai/gpt-4o-image",
"name": "🖼️ GPT Image (OpenAI GPT-4o Image)",
"description": "Generazione immagini avanzata e sintesi visiva di OpenAI GPT.",
"pricing": {
"prompt": "0.0000025",
"completion": "0.000010",
"image": "0.0200"
}
},
{
"id": "openai/gpt-image-1-mini",
"name": "🖼️ GPT Image 1 Mini (OpenAI)",
"description": "Modello veloce e compatto di OpenAI per generazione immagini.",
"pricing": {
"prompt": "0.00000015",
"completion": "0.0000006",
"image": "0.0030"
}
},
{
"id": "openai/gpt-image-2",
"name": "🖼️ GPT Image 2 (OpenAI)",
"description": "Modello di seconda generazione per immagini di OpenAI.",
"pricing": {
"prompt": "0.000001",
"completion": "0.000004",
"image": "0.0100"
}
},
{
"id": "bytedance-seed/seedream-5-0-pro",
"name": "🖼️ Seedream 5.0 Pro (ByteDance)",
"description": "Sintesi professionale d'immagine text-to-image e stile artistico.",
"pricing": {
"prompt": "0.000001",
"completion": "0.000004",
"image": "0.0120"
}
},
{
"id": "bytedance-seed/seedream-5-0-lite",
"name": "🖼️ Seedream 5.0 Lite (ByteDance)",
"description": "Versione leggera ed efficiente di Seedream 5.0.",
"pricing": {
"prompt": "0.0000003",
"completion": "0.0000012",
"image": "0.0040"
}
},
{
"id": "bytedance-seed/seedream-4.5",
"name": "🖼️ Seedream 4.5 (ByteDance)",
"description": "Generazione immagini text-to-image avanzata.",
"pricing": {
"prompt": "0.0000005",
"completion": "0.000002",
"image": "0.0060"
}
},
{
"id": "x-ai/grok-imagine-image-2.0",
"name": "🖼️ Grok Imagine Image 2.0 (xAI)",
"description": "Generatore di immagini avanzato di xAI.",
"pricing": {
"prompt": "0.000001",
"completion": "0.000004",
"image": "0.0100"
}
},
{
"id": "x-ai/grok-imagine-image-quality",
"name": "🖼️ Grok Imagine Image Quality (xAI)",
"description": "Qualità ed estetica visiva elevata di xAI.",
"pricing": {
"prompt": "0.0000015",
"completion": "0.000006",
"image": "0.0150"
}
},
{
"id": "qwen/qwen-image-3-pro",
"name": "🖼️ Qwen Image 3 Pro (Alibaba)",
"description": "Modello d'immagine professionale Qwen 3 Pro.",
"pricing": {
"prompt": "0.0000008",
"completion": "0.000003",
"image": "0.0080"
}
},
{
"id": "qwen/qwen-image-3",
"name": "🖼️ Qwen Image 3 (Alibaba)",
"description": "Generazione immagini text-to-image di Qwen.",
"pricing": {
"prompt": "0.0000004",
"completion": "0.0000016",
"image": "0.0045"
}
},
{
"id": "microsoft/mai-image-2.5-pro",
"name": "🖼️ MAI Image 2.5 Pro (Microsoft)",
"description": "Sintesi d'immagine e grafica avanzata Microsoft AI.",
"pricing": {
"prompt": "0.000001",
"completion": "0.000004",
"image": "0.0090"
}
},
{
"id": "krea/krea-2-large",
"name": "🖼️ Krea 2 Large",
"description": "Generatore artistico Krea AI ad alta risoluzione.",
"pricing": {
"prompt": "0.000001",
"completion": "0.000004",
"image": "0.0120"
}
},
{
"id": "krea/krea-2-medium",
"name": "🖼️ Krea 2 Medium",
"description": "Versione bilanciata per grafica ed illustrazioni Krea.",
"pricing": {
"prompt": "0.0000005",
"completion": "0.000002",
"image": "0.0060"
}
},
{
"id": "krea/krea-2-medium-turbo",
"name": "🖼️ Krea 2 Medium Turbo",
"description": "Generazione ultra-veloce e reattiva Krea Turbo.",
"pricing": {
"prompt": "0.0000003",
"completion": "0.000001",
"image": "0.0035"
}
}
],
"video": [
{
"id": "bytedance/seedance-2.5",
"name": "\ud83c\udfac Seedance 2.5 (ByteDance Video AI)",
"description": "Modello video cinematografico ultra-avanzato text-to-video.",
"pricing_skus": {
"per-video-second": "0.1028"
}
},
{
"id": "bytedance/seedance-2.0",
"name": "\ud83c\udfac Seedance 2.0 (ByteDance)",
"description": "Generazione video ad elevata coerenza visiva.",
"pricing_skus": {
"per-video-second": "0.067"
}
},
{
"id": "bytedance/seedance-2.0-fast",
"name": "\ud83c\udfac Seedance 2.0 Fast (ByteDance)",
"description": "Generazione video rapida text-to-video.",
"pricing_skus": {
"per-video-second": "0.067"
}
},
{
"id": "bytedance/seedance-2.0-mini",
"name": "\ud83c\udfac Seedance 2.0 Mini (ByteDance)",
"description": "Versione compatta ed efficiente Seedance.",
"pricing_skus": {
"per-video-second": "0.030"
}
},
{
"id": "x-ai/grok-imagine-video-1.5",
"name": "\ud83c\udfac Grok Imagine Video 1.5 (xAI)",
"description": "Sintesi video cinematografica avanzata di xAI.",
"pricing_skus": {
"per-video-second": "0.10"
}
},
{
"id": "x-ai/grok-imagine-video",
"name": "\ud83c\udfac Grok Imagine Video (xAI)",
"description": "Generazione video di xAI.",
"pricing_skus": {
"per-video-second": "0.10"
}
},
{
"id": "kwaivgi/kling-v3.0-pro",
"name": "\ud83c\udfac Kling v3.0 Pro (Kuaishou Video AI)",
"description": "Movimento cinematografico professionale e fisica fluida.",
"pricing_skus": {
"per-video-second": "0.200"
}
},
{
"id": "kwaivgi/kling-v3.0-std",
"name": "\ud83c\udfac Kling v3.0 Standard (Kuaishou Video AI)",
"description": "Generazione video standard di alta qualit\u00e0.",
"pricing_skus": {
"per-video-second": "0.126"
}
},
{
"id": "kwaivgi/kling-video-o1",
"name": "\ud83c\udfac Kling Video o1 (Kuaishou)",
"description": "Modello video con ragionamento fisico avanzato.",
"pricing_skus": {
"per-video-second": "0.10"
}
},
{
"id": "google/veo-3.1",
"name": "\ud83c\udfac Google Veo 3.1",
"description": "Generazione video fotorealistica ad altissima fedelt\u00e0 visiva di Google.",
"pricing_skus": {
"per-video-second": "0.40"
}
},
{
"id": "google/veo-3.1-fast",
"name": "\ud83c\udfac Google Veo 3.1 Fast",
"description": "Sintesi video veloce ed efficiente di Google.",
"pricing_skus": {
"per-video-second": "0.10"
}
},
{
"id": "google/veo-3.1-lite",
"name": "\ud83c\udfac Google Veo 3.1 Lite",
"description": "Versione leggera di Veo 3.1.",
"pricing_skus": {
"per-video-second": "0.05"
}
},
{
"id": "minimax/h3",
"name": "🎬 MiniMax H3 (Hailuo 3.0)",
"description": "Modello omni-modale avanzato MiniMax con audio nativo stereo e fisica fluida.",
"pricing_skus": {
"per-video-second": "0.10"
}
},
{
"id": "minimax/hailuo-2.3",
"name": "🎬 Hailuo 2.3 (MiniMax)",
"description": "Movimenti di camera dinamici e rendering cinematografico.",
"pricing_skus": {
"per-video-second": "0.10"
}
},
{
"id": "alibaba/wan-2.7",
"name": "\ud83c\udfac Wan 2.7 (Alibaba Video AI)",
"description": "Sintesi cinematografica e controllo di movimento avanzato.",
"pricing_skus": {
"per-video-second": "0.08"
}
},
{
"id": "openai/sora-2-pro",
"name": "\ud83c\udfac OpenAI Sora 2 Pro",
"description": "Generazione video ad altissima fedelt\u00e0 visiva di OpenAI.",
"pricing_skus": {
"per-video-second": "0.10"
}
},
{
"id": "google/gemini-2.0-flash-exp:free",
"name": "\ud83c\udfac Gemini 2.0 Flash (Script & Storyboard)",
"description": "Regia e breakdown scena per scena.",
"pricing_skus": {
"per-video-second": "0.10"
}
}
],
"audio": [
{
"id": "google/lyria-3-pro-preview",
"name": "🎵 Google Lyria 3 Pro (Preview)",
"description": "Generatore musicale e sintesi audio ad elevata fedeltà visiva/sonora.",
"pricing": {
"prompt": "0.000002",
"completion": "0.000010",
"request": "0.0150"
}
},
{
"id": "google/lyria-3-clip-preview",
"name": "🎵 Google Lyria 3 Clip (Preview)",
"description": "Generazione rapida di jingle e brevi clip audio.",
"pricing": {
"prompt": "0.000001",
"completion": "0.000004",
"request": "0.0080"
}
},
{
"id": "openai/gpt-audio",
"name": "🎵 OpenAI GPT Audio",
"description": "Sintesi vocale ed elaborazione audio avanzata GPT.",
"pricing": {
"prompt": "0.0000025",
"completion": "0.000010",
"request": "0.0100"
}
},
{
"id": "openai/gpt-audio-mini",
"name": "🎵 OpenAI GPT Audio Mini",
"description": "Versione compatta ed ultra-veloce GPT Audio.",
"pricing": {
"prompt": "0.0000006",
"completion": "0.0000024",
"request": "0.0030"
}
},
{
"id": "openai/whisper-large-v3",
"name": "🎵 OpenAI Whisper Large v3 (Audio & Voice)",
"description": "Processamento audio e trascrizione.",
"pricing": {
"prompt": "0.0000005",
"completion": "0.0000015",
"request": "0.0060"
}
},
{
"id": "elevenlabs/speech-synthesis",
"name": "🎵 ElevenLabs Voice AI",
"description": "Sintesi vocale espressiva.",
"pricing": {
"prompt": "0.000001",
"completion": "0.000005",
"request": "0.0120"
}
},
{
"id": "suno/bark-audio",
"name": "🎵 Bark Audio & SFX Generator",
"description": "Effetti sonori e musica.",
"pricing": {
"prompt": "0.0000008",
"completion": "0.000003",
"request": "0.0050"
}
}
]
}
+68
View File
@@ -0,0 +1,68 @@
{
"image": [
{
"id": "black-forest-labs/flux-schnell",
"name": "Flux Schnell (Black Forest Labs)",
"description": "Generazione immagini veloce e di alta qualità.",
"pricing": {
"image": "0.0030",
"request": "0.0030"
}
},
{
"id": "google/nano-banana-2",
"name": "Nano Banana 2",
"description": "Modello Nano Banana 2 per generazione immagini.",
"pricing": {
"image": "0.0020",
"request": "0.0020"
}
}
],
"video": [
{
"id": "bytedance/seedance-2.5",
"name": "Seedance 2.5 (ByteDance Video AI)",
"description": "Modello video cinematografico ultra-avanzato.",
"pricing_skus": {
"per-video-second": "0.067"
}
},
{
"id": "minimax/h3",
"name": "MiniMax H3 (Hailuo 3.0)",
"description": "Modello video omni-modale avanzato MiniMax con audio nativo.",
"pricing_skus": {
"per-video-second": "0.100"
}
},
{
"id": "minimax/video-01",
"name": "MiniMax Video 01",
"description": "Generazione video avanzata su Replicate.",
"pricing_skus": {
"per-video-second": "0.050"
}
},
{
"id": "lucataco/hotshot-xl",
"name": "Hotshot XL (Video)",
"description": "Generazione rapida di GIF/Video.",
"pricing_skus": {
"per-video-second": "0.020"
}
}
],
"audio": [
{
"id": "meta/meta-llama-3-70b-instruct",
"name": "Llama 3 70B (Text to Speech Synth)",
"description": "Generazione script audio tramite Llama 3.",
"pricing": {
"prompt": "0.0000009",
"completion": "0.0000009",
"request": "0.0020"
}
}
]
}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<title>MediaGen AI - OpenRouter Image & Video Generator</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { provideAnimations } from '@angular/platform-browser/animations';
bootstrapApplication(AppComponent, {
providers: [
provideAnimations()
]
}).catch(err => console.error(err));
+94
View File
@@ -0,0 +1,94 @@
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
:root {
--bg-dark: #0a0c10;
--bg-card: #121620;
--bg-card-hover: #1a202c;
--bg-input: #181d28;
--border-color: #242c3d;
--border-highlight: #3a4760;
--primary: #3b82f6;
--primary-hover: #2563eb;
--accent: #8b5cf6;
--accent-gradient: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
--text-main: #f3f4f6;
--text-muted: #9ca3af;
--text-dim: #6b7280;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 18px;
--radius-full: 9999px;
--shadow-glow: 0 0 25px rgba(59, 130, 246, 0.15);
--shadow-card: 0 10px 30px rgba(0, 0, 0, 0.35);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
background-color: var(--bg-dark);
color: var(--text-main);
line-height: 1.6;
min-height: 100vh;
overflow-x: hidden;
}
code, pre, .font-mono {
font-family: 'JetBrains Mono', monospace;
}
/* Custom scrollbars */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-dark);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-highlight);
}
/* Button & Input Base Styles */
input, select, textarea, button {
font-family: inherit;
font-size: 0.95rem;
}
button {
cursor: pointer;
border: none;
outline: none;
transition: all 0.2s ease;
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulseGlow {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
.animate-fade-in {
animation: fadeIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
+13
View File
@@ -0,0 +1,13 @@
import 'dotenv/config';
async function testReplicate() {
const token = process.env.REPLICATE_API_TOKEN || "test";
const response = await fetch('https://api.replicate.com/v1/models/black-forest-labs/flux-schnell', {
headers: {
'Authorization': `Bearer ${token}`
}
});
console.log(response.status, await response.text());
}
testReplicate();
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}