import { CommonModule } from '@angular/common'; import { Component, computed, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { createImages, releaseImage, StudioImage } from './image-file'; type ExampleImage = { name: string; label: string; url: string; }; type Variant = { id: string; label: string; filter: string; note: string; }; type ViewImage = StudioImage & { selectedVariantData: Variant; previewUrl: string; previewFilter: string; note: string; }; type GenerationResponse = { provider?: string; images?: Array<{ originalName?: string; variants?: Array<{ imageBase64?: string; imageUrl?: string; label?: string; }>; }>; }; type GenerationJobResponse = { jobId?: string; provider?: string; status?: 'queued' | 'running' | 'completed' | 'failed'; result?: GenerationResponse; error?: string; progress?: number; message?: string; events?: string[]; }; type GenerationErrorResponse = { error?: string; }; type MonitorStepState = 'pending' | 'active' | 'completed' | 'failed'; type MonitorStep = { label: string; detail: string; state: MonitorStepState; }; type MonitorState = { provider: string; jobId: string; status: 'idle' | 'uploading' | 'running' | 'processing' | 'completed' | 'failed'; message: string; events: string[]; progress: number; }; const generationEndpoint = getGenerationEndpoint(); const generationPollIntervalMs = 3000; function getGenerationEndpoint(): string { if (globalThis.location?.hostname === '127.0.0.1' && globalThis.location.port !== '3000') { return 'http://127.0.0.1:3000/api/generations'; } return '/api/generations'; } @Component({ selector: 'app-root', imports: [CommonModule, FormsModule], templateUrl: './app.html', styleUrl: './app.scss', }) export class App { protected readonly images = signal([]); protected readonly prompt = signal( 'Maak een professionele fastfood menu deal met burger, drinken en friet op een schone witte menu-achtergrond', ); protected readonly preset = signal('studio'); protected readonly ratio = signal('1:1'); protected readonly mode = signal<'product' | 'menu'>('menu'); protected readonly isGenerating = signal(false); protected readonly generationError = signal(undefined); protected readonly monitor = signal({ provider: 'Nog niet gestart', jobId: '-', status: 'idle', message: 'Kies productfoto’s en maak nieuwe beelden.', events: ['Klaar om te starten.'], progress: 0, }); protected readonly variants: Variant[] = [ { id: 'clean', label: 'Clean studio', filter: 'contrast(1.06) saturate(1.04) brightness(1.03)', note: 'Witte/lichte achtergrond, product blijft centraal.', }, { id: 'warm', label: 'Warme food sfeer', filter: 'contrast(1.08) saturate(1.18) sepia(0.12) brightness(1.04)', note: 'Geschikt voor horeca, lunch en dagmenu.', }, { id: 'premium', label: 'Premium donker', filter: 'contrast(1.18) saturate(0.96) brightness(0.9)', note: 'Meer diepte, luxe uitstraling en sterke schaduw.', }, ]; protected readonly exampleImages: ExampleImage[] = [ { name: 'demo-menu-burger.jpg', label: 'Burger', url: '/demo-menu-burger.jpg' }, { name: 'demo-menu-drink.jpg', label: 'Drank', url: '/demo-menu-drink.jpg' }, { name: 'demo-menu-fries.jpg', label: 'Friet', url: '/demo-menu-fries.jpg' }, ]; protected readonly hasImages = computed(() => this.images().length > 0); public readonly selectedImageCountLabel = computed(() => { const count = this.images().length; if (count === 0) { return 'Nog geen foto’s'; } if (count === 1) { return '1 foto klaar'; } return `${count} foto’s klaar`; }); public readonly nextAction = computed(() => { if (!this.hasImages()) { return 'Upload losse productfoto’s om te starten.'; } if (this.isGenerating()) { return 'Wacht tot het nieuwe beeld klaar is.'; } if (this.monitor().status === 'completed') { return 'Controleer het resultaat en verwijder wat je niet gebruikt.'; } return 'Controleer de instellingen en maak nieuwe beelden.'; }); protected readonly progressLabel = computed(() => `${this.monitor().progress}%`); protected readonly hasGeneratedResult = computed(() => this.images().some((image) => image.generatedUrl !== undefined)); protected readonly resultImages = computed(() => this.viewImages().filter((image) => image.generatedUrl !== undefined)); protected readonly estimateLabel = computed(() => { const progress = this.monitor().progress; if (!this.isGenerating() || progress <= 0 || progress >= 100) { return ''; } const estimatedTotalSeconds = 240; const remainingSeconds = Math.max(15, Math.round(estimatedTotalSeconds * ((100 - progress) / 100))); return `ongeveer ${Math.ceil(remainingSeconds / 60)} min. resterend`; }); protected readonly monitorSteps = computed(() => { const status = this.monitor().status; return [ { label: 'Upload', detail: 'Productfoto’s worden klaargezet.', state: this.resolveStepState(status, ['uploading'], ['running', 'processing', 'completed'], 'failed'), }, { label: 'Beelden maken', detail: 'Nieuwe productbeelden worden gemaakt. Dit kan even duren.', state: this.resolveStepState(status, ['running'], ['processing', 'completed'], 'failed'), }, { label: 'Resultaten', detail: 'De nieuwe beelden worden naast het origineel gezet.', state: this.resolveStepState(status, ['processing'], ['completed'], 'failed'), }, ]; }); protected readonly viewImages = computed(() => this.images().map((image) => { const selectedVariantData = this.findVariant(image.selectedVariant); return { ...image, selectedVariantData, previewUrl: image.generatedUrl ?? image.url, previewFilter: image.generatedUrl === undefined ? selectedVariantData.filter : 'none', note: image.generationNote ?? selectedVariantData.note, }; }), ); protected onFilesSelected(event: Event): void { const input = event.target; const nextImages = createImages(Array.from(input.files ?? [])); this.images.update((current) => [...current, ...nextImages]); input.value = ''; } protected async onUseExampleMenu(): Promise { const files: File[] = []; for (const exampleImage of this.exampleImages) { const response = await fetch(exampleImage.url); const imageBlob = await response.blob(); files.push(new File([imageBlob], exampleImage.name, { type: imageBlob.type })); } this.releaseCurrentImages(); this.mode.set('menu'); this.preset.set('studio'); this.ratio.set('1:1'); this.prompt.set('Maak een professionele fastfood menu deal met burger, drinken en friet op een schone witte menu-achtergrond'); this.images.set(createImages(files)); this.monitor.update((current) => ({ ...current, status: 'idle', message: 'Voorbeeldmenu is geladen. Maak nu een nieuw beeld.', events: [...current.events, 'Voorbeeldbeelden geladen.'], progress: 0, })); } protected async onGenerateBatch(): Promise { if (!this.hasImages()) { return; } this.isGenerating.set(true); this.generationError.set(undefined); this.setMonitor('uploading', 'Productfoto’s worden voorbereid.', 'Productfoto’s en instellingen zijn ontvangen.'); try { const response = await fetch(generationEndpoint, { method: 'POST', body: this.buildGenerationForm(), }); this.setMonitor('running', 'Nieuwe productbeelden worden gemaakt.', 'De beelden worden nu gemaakt. Dit kan enkele minuten duren.'); if (!response.ok) { throw new Error(await this.getGenerationResponseError(response)); } const jobResponse = await response.json(); if (jobResponse.jobId === undefined) { throw new Error('De beeldgenerator gaf geen taaknummer terug.'); } this.monitor.update((current) => ({ ...current, provider: this.getFriendlyProviderName(jobResponse.provider ?? current.provider), jobId: jobResponse.jobId ?? current.jobId, progress: jobResponse.progress ?? current.progress, message: jobResponse.message ?? current.message, events: jobResponse.events ?? current.events, })); const completedJob = await this.pollGenerationJob(jobResponse.jobId); const generationResponse = completedJob.result; if (generationResponse === undefined) { throw new Error('De beeldgenerator gaf geen resultaat terug.'); } this.setMonitor('processing', 'Resultaten worden klaargezet.', 'Nieuwe beelden worden in het overzicht geplaatst.'); this.applyGenerationResponse(generationResponse); this.monitor.update((current) => ({ ...current, provider: this.getFriendlyProviderName(generationResponse.provider ?? current.provider), jobId: completedJob.jobId ?? current.jobId, status: 'completed', message: 'Nieuwe productbeelden zijn klaar.', progress: 100, events: [...current.events, 'Klaar. Bekijk en vergelijk het resultaat.'], })); } catch (error) { const errorMessage = this.getGenerationErrorMessage(error); this.generationError.set(errorMessage); this.monitor.update((current) => ({ ...current, status: 'failed', message: errorMessage, progress: 100, events: [...current.events, 'Er ging iets mis tijdens het maken van de beelden.'], })); } finally { this.isGenerating.set(false); } } protected selectVariant(imageId: string, variantId: string): void { this.images.update((images) => images.map((image) => (image.id === imageId ? { ...image, selectedVariant: variantId } : image)), ); } protected removeImage(imageId: string): void { const image = this.images().find((item) => item.id === imageId); if (image) { releaseImage(image); } this.images.update((images) => images.filter((item) => item.id !== imageId)); } private releaseCurrentImages(): void { for (const image of this.images()) { releaseImage(image); } } private findVariant(variantId: string): Variant { return this.variants.find((variant) => variant.id === variantId) ?? this.variants[0]; } private resolveStepState(status: MonitorState['status'], activeStates: MonitorState['status'][], completedStates: MonitorState['status'][], failedState: MonitorState['status']): MonitorStepState { if (status === failedState) { return 'failed'; } if (completedStates.includes(status)) { return 'completed'; } if (activeStates.includes(status)) { return 'active'; } return 'pending'; } private setMonitor(status: MonitorState['status'], message: string, event: string): void { this.monitor.update((current) => ({ ...current, provider: current.provider === 'Nog niet gestart' ? 'Beeldgenerator' : current.provider, status, message, events: [...current.events, event], progress: status === 'uploading' ? Math.max(current.progress, 5) : current.progress, })); } private buildGenerationForm(): FormData { const form = new FormData(); form.append('prompt', this.prompt()); form.append('preset', this.preset()); form.append('ratio', this.ratio()); form.append('mode', this.mode()); for (const image of this.images()) { form.append('images', image.file, image.name); } return form; } private applyGenerationResponse(response: GenerationResponse): void { this.images.update((images) => images.map((image) => { const generatedImage = response.images?.find((item) => item.originalName === image.name); const variant = generatedImage?.variants?.[0]; const generatedUrl = variant?.imageBase64 ?? variant?.imageUrl; if (generatedUrl === undefined) { return image; } return { ...image, generatedUrl, generationNote: `${variant?.label ?? 'AI'} beeld is klaar voor controle.`, }; }), ); } private async pollGenerationJob(jobId: string): Promise { while (true) { const response = await fetch(`${generationEndpoint}/${jobId}`); if (!response.ok) { throw new Error(await this.getGenerationResponseError(response)); } const jobResponse = await response.json(); if (jobResponse.status === 'failed') { throw new Error(jobResponse.error ?? 'Het maken van nieuwe beelden is mislukt.'); } if (jobResponse.status === 'completed') { return jobResponse; } this.monitor.update((current) => ({ ...current, provider: this.getFriendlyProviderName(jobResponse.provider ?? current.provider), jobId: jobResponse.jobId ?? current.jobId, status: 'running', message: jobResponse.message ?? 'Nieuwe productbeelden worden gemaakt.', progress: jobResponse.progress ?? current.progress, events: jobResponse.events ?? current.events, })); await this.wait(generationPollIntervalMs); } } private wait(ms: number): Promise { return new Promise((resolvePromise) => window.setTimeout(resolvePromise, ms)); } private getFriendlyProviderName(provider: string): string { if (provider === 'kaggle' || provider === 'flux2-klein' || provider === 'openrouter') { return 'AI beeldgenerator'; } if (provider === 'mock') { return 'Voorbeeldmodus'; } return provider; } private getGenerationErrorMessage(error: unknown): string { if (error instanceof TypeError) { return 'De beeldgenerator is niet bereikbaar. Start de lokale generator en probeer het daarna opnieuw.'; } if (error instanceof Error && error.message.trim() !== '') { return `De beeldgenerator kon het verzoek niet verwerken: ${error.message}`; } return 'Het maken van nieuwe beelden is niet gelukt. Controleer de verbinding met de beeldgenerator en probeer het opnieuw.'; } private async getGenerationResponseError(response: Response): Promise { try { const errorResponse = await response.json(); if (errorResponse.error !== undefined && errorResponse.error.trim() !== '') { return errorResponse.error; } } catch { return `HTTP ${response.status}`; } return `HTTP ${response.status}`; } }