mplus-studio / src /app /app.ts
joelsefanja's picture
Deploy Mplus Studio
993eeba verified
Raw
History Blame Contribute Delete
15 kB
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<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}`;
}
}