feat: initial commit for MediaGen AI project
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.vscode
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
npm-debug.log
|
||||||
+36
@@ -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
@@ -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
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+12224
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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"
|
||||||
@@ -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 @)</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>@ref1</strong>, <strong>@ref2</strong>...) agganciabile nel prompt</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="referenceItems.length > 0" class="reference-previews">
|
||||||
|
<div *ngFor="let item of referenceItems; let i = index" class="ref-card-wrapper animate-fade-in">
|
||||||
|
<div class="ref-thumb-box">
|
||||||
|
<img [src]="item.previewUrl" alt="Reference Image" class="ref-thumb" />
|
||||||
|
<button type="button" class="remove-ref-btn" (click)="removeReferenceItem(i)">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="ref-meta-box">
|
||||||
|
<span class="ref-badge-tag" (click)="insertTagIntoPrompt(item.tag)">{{ item.tag }}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="item.name"
|
||||||
|
(change)="updateTagName(item, i)"
|
||||||
|
placeholder="Nome soggetto"
|
||||||
|
class="ref-name-input"
|
||||||
|
title="Rinomina reference"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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
@@ -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[];
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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));
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src/main.ts"
|
||||||
|
],
|
||||||
|
"include": [
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user