Spaces:
Sleeping
Sleeping
| 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'; | |
| } | |
| ({ | |
| selector: 'app-root', | |
| imports: [CommonModule, FormsModule], | |
| templateUrl: './app.html', | |
| styleUrl: './app.scss', | |
| }) | |
| export class App { | |
| protected readonly images = signal<StudioImage[]>([]); | |
| 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<string | undefined>(undefined); | |
| protected readonly monitor = signal<MonitorState>({ | |
| 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<MonitorStep[]>(() => { | |
| 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<ViewImage[]>(() => | |
| 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 = <HTMLInputElement>event.target; | |
| const nextImages = createImages(Array.from(input.files ?? [])); | |
| this.images.update((current) => [...current, ...nextImages]); | |
| input.value = ''; | |
| } | |
| protected async onUseExampleMenu(): Promise<void> { | |
| 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<void> { | |
| 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 = <GenerationJobResponse>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<GenerationJobResponse> { | |
| while (true) { | |
| const response = await fetch(`${generationEndpoint}/${jobId}`); | |
| if (!response.ok) { | |
| throw new Error(await this.getGenerationResponseError(response)); | |
| } | |
| const jobResponse = <GenerationJobResponse>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<void> { | |
| 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<string> { | |
| try { | |
| const errorResponse = <GenerationErrorResponse>await response.json(); | |
| if (errorResponse.error !== undefined && errorResponse.error.trim() !== '') { | |
| return errorResponse.error; | |
| } | |
| } | |
| catch { | |
| return `HTTP ${response.status}`; | |
| } | |
| return `HTTP ${response.status}`; | |
| } | |
| } | |