Spaces:
Sleeping
Sleeping
Deploy Mplus Studio
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +11 -0
- .editorconfig +17 -0
- .env.example +22 -0
- .gitattributes +7 -0
- .gitignore +53 -0
- .prettierrc +12 -0
- .vscode/extensions.json +4 -0
- .vscode/launch.json +20 -0
- .vscode/mcp.json +9 -0
- .vscode/tasks.json +42 -0
- AGENTS.md +21 -0
- DEPLOYMENT.md +128 -0
- Dockerfile +30 -0
- README.md +206 -10
- angular.json +104 -0
- backend/package.json +30 -0
- backend/src/app.integration.spec.ts +59 -0
- backend/src/app.ts +129 -0
- backend/src/generation/generation.ts +97 -0
- backend/src/generation/generation.unit.spec.ts +37 -0
- backend/src/providers/kaggle.ts +329 -0
- backend/src/server.ts +26 -0
- backend/tsconfig.json +15 -0
- coding-guidelines.md +341 -0
- docker-compose.yml +54 -0
- docs/demo-menu-burger.jpg +3 -0
- docs/demo-menu-drink.jpg +3 -0
- docs/demo-menu-fries.jpg +3 -0
- docs/demo-menu-result-20260531022723.png +3 -0
- docs/multi-user-architecture.md +62 -0
- e2e/fixtures/menu-burger.jpg +3 -0
- e2e/fixtures/menu-drink.jpg +3 -0
- e2e/fixtures/menu-fries.jpg +3 -0
- e2e/studio-flow.spec.ts +263 -0
- kaggle-flux2-cache/kernel-metadata.example.json +14 -0
- kaggle-flux2-cache/kernel-metadata.json +14 -0
- kaggle-flux2-cache/kernel.py +71 -0
- kaggle-flux2-klein/kernel.py +246 -0
- package-lock.json +0 -0
- package.json +48 -0
- playwright.config.ts +40 -0
- public/favicon.ico +0 -0
- scripts/deploy-check.py +50 -0
- scripts/deploy-huggingface.py +72 -0
- src/app/app.component.spec.ts +20 -0
- src/app/app.config.ts +5 -0
- src/app/app.html +187 -0
- src/app/app.integration.spec.ts +198 -0
- src/app/app.scss +70 -0
- src/app/app.ts +399 -0
.dockerignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
backend/node_modules
|
| 3 |
+
dist
|
| 4 |
+
backend/dist
|
| 5 |
+
test-results
|
| 6 |
+
playwright-report
|
| 7 |
+
.angular
|
| 8 |
+
.kaggle-work
|
| 9 |
+
.env
|
| 10 |
+
comfyui-data
|
| 11 |
+
graphify-out
|
.editorconfig
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Editor configuration, see https://editorconfig.org
|
| 2 |
+
root = true
|
| 3 |
+
|
| 4 |
+
[*]
|
| 5 |
+
charset = utf-8
|
| 6 |
+
indent_style = space
|
| 7 |
+
indent_size = 2
|
| 8 |
+
insert_final_newline = true
|
| 9 |
+
trim_trailing_whitespace = true
|
| 10 |
+
|
| 11 |
+
[*.ts]
|
| 12 |
+
quote_type = single
|
| 13 |
+
ij_typescript_use_double_quotes = false
|
| 14 |
+
|
| 15 |
+
[*.md]
|
| 16 |
+
max_line_length = off
|
| 17 |
+
trim_trailing_whitespace = false
|
.env.example
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
PORT=3000
|
| 2 |
+
GENERATION_PROVIDER=kaggle
|
| 3 |
+
COMMERCIAL_MODE=1
|
| 4 |
+
FLUX_MODEL_ID=black-forest-labs/FLUX.2-klein-4B
|
| 5 |
+
KAGGLE_COMMAND=uv run --with kaggle kaggle
|
| 6 |
+
KAGGLE_CONFIG_DIR=C:\Users\Joel\.kaggle
|
| 7 |
+
KAGGLE_USERNAME=
|
| 8 |
+
KAGGLE_API_TOKEN=
|
| 9 |
+
KAGGLE_ACCELERATOR=NvidiaTeslaT4
|
| 10 |
+
KAGGLE_USE_SMALL_DECODER=0
|
| 11 |
+
KAGGLE_INFERENCE_STEPS=4
|
| 12 |
+
KAGGLE_DATASET_PREFIX=mplus-flux2-input
|
| 13 |
+
KAGGLE_KERNEL_PREFIX=mplus-flux2-generate
|
| 14 |
+
KAGGLE_CACHE_KERNEL_REF=jolvangeest/mplus-flux2-cache
|
| 15 |
+
KAGGLE_POLL_INTERVAL_MS=5000
|
| 16 |
+
KAGGLE_POLL_TIMEOUT_MS=7200000
|
| 17 |
+
DATABASE_URL=postgresql://mplus:mplus@postgres:5432/mplus_studio
|
| 18 |
+
S3_ENDPOINT=http://minio:9000
|
| 19 |
+
S3_BUCKET=mplus-studio
|
| 20 |
+
S3_ACCESS_KEY_ID=mplus
|
| 21 |
+
S3_SECRET_ACCESS_KEY=mplus-studio-secret
|
| 22 |
+
S3_FORCE_PATH_STYLE=1
|
.gitattributes
CHANGED
|
@@ -33,3 +33,10 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
docs/demo-menu-burger.jpg filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
docs/demo-menu-drink.jpg filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
docs/demo-menu-fries.jpg filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
docs/demo-menu-result-20260531022723.png filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
e2e/fixtures/menu-burger.jpg filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
e2e/fixtures/menu-drink.jpg filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
e2e/fixtures/menu-fries.jpg filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# Compiled output
|
| 4 |
+
/dist
|
| 5 |
+
/backend/dist
|
| 6 |
+
/tmp
|
| 7 |
+
/out-tsc
|
| 8 |
+
/bazel-out
|
| 9 |
+
|
| 10 |
+
# Node
|
| 11 |
+
/node_modules
|
| 12 |
+
/backend/node_modules
|
| 13 |
+
npm-debug.log
|
| 14 |
+
yarn-error.log
|
| 15 |
+
*.log
|
| 16 |
+
|
| 17 |
+
# IDEs and editors
|
| 18 |
+
.idea/
|
| 19 |
+
.project
|
| 20 |
+
.classpath
|
| 21 |
+
.c9/
|
| 22 |
+
*.launch
|
| 23 |
+
.settings/
|
| 24 |
+
*.sublime-workspace
|
| 25 |
+
|
| 26 |
+
# Visual Studio Code
|
| 27 |
+
.vscode/*
|
| 28 |
+
!.vscode/settings.json
|
| 29 |
+
!.vscode/tasks.json
|
| 30 |
+
!.vscode/launch.json
|
| 31 |
+
!.vscode/extensions.json
|
| 32 |
+
!.vscode/mcp.json
|
| 33 |
+
.history/*
|
| 34 |
+
|
| 35 |
+
# Miscellaneous
|
| 36 |
+
/.env
|
| 37 |
+
/.kaggle-work
|
| 38 |
+
/.angular/cache
|
| 39 |
+
/comfyui-data
|
| 40 |
+
/test-results
|
| 41 |
+
/playwright-report
|
| 42 |
+
/graphify-out
|
| 43 |
+
.sass-cache/
|
| 44 |
+
/connect.lock
|
| 45 |
+
/coverage
|
| 46 |
+
/libpeerconnection.log
|
| 47 |
+
testem.log
|
| 48 |
+
/typings
|
| 49 |
+
__screenshots__/
|
| 50 |
+
|
| 51 |
+
# System files
|
| 52 |
+
.DS_Store
|
| 53 |
+
Thumbs.db
|
.prettierrc
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"printWidth": 100,
|
| 3 |
+
"singleQuote": true,
|
| 4 |
+
"overrides": [
|
| 5 |
+
{
|
| 6 |
+
"files": "*.html",
|
| 7 |
+
"options": {
|
| 8 |
+
"parser": "angular"
|
| 9 |
+
}
|
| 10 |
+
}
|
| 11 |
+
]
|
| 12 |
+
}
|
.vscode/extensions.json
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
| 3 |
+
"recommendations": ["angular.ng-template"]
|
| 4 |
+
}
|
.vscode/launch.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
| 3 |
+
"version": "0.2.0",
|
| 4 |
+
"configurations": [
|
| 5 |
+
{
|
| 6 |
+
"name": "ng serve",
|
| 7 |
+
"type": "chrome",
|
| 8 |
+
"request": "launch",
|
| 9 |
+
"preLaunchTask": "npm: start",
|
| 10 |
+
"url": "http://localhost:4200/"
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"name": "ng test",
|
| 14 |
+
"type": "chrome",
|
| 15 |
+
"request": "launch",
|
| 16 |
+
"preLaunchTask": "npm: test",
|
| 17 |
+
"url": "http://localhost:9876/debug.html"
|
| 18 |
+
}
|
| 19 |
+
]
|
| 20 |
+
}
|
.vscode/mcp.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
// For more information, visit: https://angular.dev/ai/mcp
|
| 3 |
+
"servers": {
|
| 4 |
+
"angular-cli": {
|
| 5 |
+
"command": "npx",
|
| 6 |
+
"args": ["-y", "@angular/cli", "mcp"]
|
| 7 |
+
}
|
| 8 |
+
}
|
| 9 |
+
}
|
.vscode/tasks.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
| 3 |
+
"version": "2.0.0",
|
| 4 |
+
"tasks": [
|
| 5 |
+
{
|
| 6 |
+
"type": "npm",
|
| 7 |
+
"script": "start",
|
| 8 |
+
"isBackground": true,
|
| 9 |
+
"problemMatcher": {
|
| 10 |
+
"owner": "typescript",
|
| 11 |
+
"pattern": "$tsc",
|
| 12 |
+
"background": {
|
| 13 |
+
"activeOnStart": true,
|
| 14 |
+
"beginsPattern": {
|
| 15 |
+
"regexp": "Changes detected"
|
| 16 |
+
},
|
| 17 |
+
"endsPattern": {
|
| 18 |
+
"regexp": "bundle generation (complete|failed)"
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"type": "npm",
|
| 25 |
+
"script": "test",
|
| 26 |
+
"isBackground": true,
|
| 27 |
+
"problemMatcher": {
|
| 28 |
+
"owner": "typescript",
|
| 29 |
+
"pattern": "$tsc",
|
| 30 |
+
"background": {
|
| 31 |
+
"activeOnStart": true,
|
| 32 |
+
"beginsPattern": {
|
| 33 |
+
"regexp": "Changes detected"
|
| 34 |
+
},
|
| 35 |
+
"endsPattern": {
|
| 36 |
+
"regexp": "bundle generation (complete|failed)"
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
]
|
| 42 |
+
}
|
AGENTS.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Agent Instructions
|
| 2 |
+
|
| 3 |
+
Follow `coding-guidlines.md` for all code changes in this repository.
|
| 4 |
+
|
| 5 |
+
Important local rules:
|
| 6 |
+
|
| 7 |
+
- File suffixes are only used for `.spec` files. Do not create `.component.ts` or `.service.ts` files.
|
| 8 |
+
- Event handlers are prefixed with `on`.
|
| 9 |
+
- Do not use one-letter variable names.
|
| 10 |
+
- Use injected property names that describe the injected dependency.
|
| 11 |
+
- Keep class members ordered as `public`, then `protected`, then `private`. Keep methods in the same order after the constructor.
|
| 12 |
+
- Do not use `as` casts; use `<T>` casts.
|
| 13 |
+
- Use `undefined`, not `null`.
|
| 14 |
+
- Always use braces for `if` statements.
|
| 15 |
+
- Prefer `for..of` over `forEach`.
|
| 16 |
+
- Do not call methods from templates; use signals, computed values, or direct properties.
|
| 17 |
+
- Do not use `this` in templates.
|
| 18 |
+
- Do not root-scope services automatically. If a file has no injected dependencies, make it a helper instead of a service.
|
| 19 |
+
- HTML attributes go on separate lines for larger elements, ordered as class, style bindings, class bindings, events.
|
| 20 |
+
- Source code and comments use English.
|
| 21 |
+
- Remove unused imports, files, and properties in files you touch.
|
DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deployment
|
| 2 |
+
|
| 3 |
+
## Recommended Free Test Host
|
| 4 |
+
|
| 5 |
+
Use a Docker-capable free host with server-side secrets, for example Hugging Face Spaces with Docker.
|
| 6 |
+
|
| 7 |
+
The app must be hosted as one full-stack service because Kaggle credentials must stay on the server. Do not publish `KAGGLE_USERNAME`, `KAGGLE_KEY`, OAuth files, or `.env` in the frontend.
|
| 8 |
+
|
| 9 |
+
## Required Secrets
|
| 10 |
+
|
| 11 |
+
- `KAGGLE_USERNAME`
|
| 12 |
+
- `KAGGLE_KEY`
|
| 13 |
+
|
| 14 |
+
Optional overrides:
|
| 15 |
+
|
| 16 |
+
- `KAGGLE_CACHE_KERNEL_REF=jolvangeest/mplus-flux2-cache`
|
| 17 |
+
- `KAGGLE_POLL_INTERVAL_MS=5000`
|
| 18 |
+
- `KAGGLE_ACCELERATOR=NvidiaTeslaT4`
|
| 19 |
+
|
| 20 |
+
Do not mount `.env` into the Docker Compose app service. Compose should not blindly inject local development secrets into hosted containers. Use platform secrets for demo mode or per-user server-side auth for multi-user mode.
|
| 21 |
+
|
| 22 |
+
## Runtime
|
| 23 |
+
|
| 24 |
+
The Docker image builds the Angular frontend, builds the Node backend, serves the frontend from Express, and keeps `/api/generations` server-side.
|
| 25 |
+
|
| 26 |
+
## Multi-User Hosting
|
| 27 |
+
|
| 28 |
+
A single hosted container must not use one shared Kaggle CLI login for all users. Kaggle CLI stores auth state on disk, so a shared `KAGGLE_CONFIG_DIR` would make every browser session use the same Kaggle account.
|
| 29 |
+
|
| 30 |
+
Required model for multi-user hosting:
|
| 31 |
+
|
| 32 |
+
- Store users, sessions, jobs, and ownership in a database.
|
| 33 |
+
- Store uploaded images, generated images, and reports in S3-compatible object storage.
|
| 34 |
+
- Store Kaggle auth material server-side only, encrypted at rest.
|
| 35 |
+
- Give every user a separate Kaggle config directory or credential record.
|
| 36 |
+
- Run Kaggle CLI commands with credentials scoped to the current user.
|
| 37 |
+
- Never send Kaggle credentials or token files to the browser.
|
| 38 |
+
|
| 39 |
+
Recommended self-hosted stack:
|
| 40 |
+
|
| 41 |
+
- App container: Angular + Node backend.
|
| 42 |
+
- PostgreSQL: users, sessions, jobs, image metadata, audit events.
|
| 43 |
+
- MinIO: S3-compatible storage for input images, generated images, and reports.
|
| 44 |
+
- Persistent volume: optional temporary Kaggle CLI config dirs if the CLI flow is used.
|
| 45 |
+
|
| 46 |
+
Recommended hosted stack:
|
| 47 |
+
|
| 48 |
+
- App container on a Docker-capable host.
|
| 49 |
+
- Managed Postgres if available, otherwise a hosted Postgres service.
|
| 50 |
+
- Managed S3-compatible bucket, or MinIO if the platform supports persistent volumes.
|
| 51 |
+
|
| 52 |
+
Data model sketch:
|
| 53 |
+
|
| 54 |
+
```text
|
| 55 |
+
users
|
| 56 |
+
- id
|
| 57 |
+
- display_name
|
| 58 |
+
- created_at
|
| 59 |
+
|
| 60 |
+
sessions
|
| 61 |
+
- id
|
| 62 |
+
- user_id
|
| 63 |
+
- expires_at
|
| 64 |
+
|
| 65 |
+
kaggle_connections
|
| 66 |
+
- id
|
| 67 |
+
- user_id
|
| 68 |
+
- encrypted_auth_reference
|
| 69 |
+
- status
|
| 70 |
+
- updated_at
|
| 71 |
+
|
| 72 |
+
generation_jobs
|
| 73 |
+
- id
|
| 74 |
+
- user_id
|
| 75 |
+
- mode
|
| 76 |
+
- prompt
|
| 77 |
+
- status
|
| 78 |
+
- kaggle_kernel_ref
|
| 79 |
+
- inference_seconds
|
| 80 |
+
- total_seconds
|
| 81 |
+
- created_at
|
| 82 |
+
|
| 83 |
+
generation_assets
|
| 84 |
+
- id
|
| 85 |
+
- job_id
|
| 86 |
+
- kind: input | output | report
|
| 87 |
+
- object_key
|
| 88 |
+
- mime_type
|
| 89 |
+
- size
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
Object storage layout:
|
| 93 |
+
|
| 94 |
+
```text
|
| 95 |
+
users/<user-id>/jobs/<job-id>/inputs/<file-name>
|
| 96 |
+
users/<user-id>/jobs/<job-id>/outputs/generated.png
|
| 97 |
+
users/<user-id>/jobs/<job-id>/report/result.md
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
For the MVP, uploads can still be sent directly to the backend. The backend writes them to MinIO/S3, starts a Kaggle job, then stores generated output and metrics back in object storage.
|
| 101 |
+
|
| 102 |
+
Product language should avoid Kaggle in the main flow. Use:
|
| 103 |
+
|
| 104 |
+
- `Productafbeeldingen maken`
|
| 105 |
+
- `Inloggen en starten`
|
| 106 |
+
- `Je kunt nu productafbeeldingen maken`
|
| 107 |
+
|
| 108 |
+
Technical wording such as Kaggle, GPU, kernel, notebook, or provider belongs only in admin/help screens.
|
| 109 |
+
|
| 110 |
+
### Supported Modes
|
| 111 |
+
|
| 112 |
+
For the current MVP there are two practical modes:
|
| 113 |
+
|
| 114 |
+
1. `Single-user / demo mode`
|
| 115 |
+
|
| 116 |
+
One server-side Kaggle account is configured with secrets. This is simplest for a shared internal demo, but all usage goes through that one Kaggle account.
|
| 117 |
+
|
| 118 |
+
2. `Multi-user session mode`
|
| 119 |
+
|
| 120 |
+
Each browser session gets a separate Kaggle config directory and logs in separately. This is the direction needed when colleagues should use their own Kaggle quota from one hosted container.
|
| 121 |
+
|
| 122 |
+
The codebase is prepared to keep Kaggle credentials server-side, but the hosted multi-user login flow still needs dedicated session endpoints before it should be exposed broadly.
|
| 123 |
+
|
| 124 |
+
## Notes
|
| 125 |
+
|
| 126 |
+
- Free hosts can sleep or have request limits. Kaggle generation can take several minutes.
|
| 127 |
+
- If the host kills long HTTP requests, the next change should be an async job API: submit, poll status, then fetch result.
|
| 128 |
+
- Keep `KAGGLE_CACHE_KERNEL_REF` pointed at a completed cache notebook output to reduce dependency/model setup time.
|
Dockerfile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:20-bookworm-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
RUN apt-get update \
|
| 6 |
+
&& apt-get install -y --no-install-recommends python3 python3-pip \
|
| 7 |
+
&& python3 -m pip install --break-system-packages --no-cache-dir kaggle \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
COPY package*.json ./
|
| 11 |
+
COPY backend/package.json ./backend/package.json
|
| 12 |
+
RUN npm ci
|
| 13 |
+
|
| 14 |
+
COPY . .
|
| 15 |
+
RUN npm run build:all
|
| 16 |
+
|
| 17 |
+
ENV NODE_ENV=production
|
| 18 |
+
ENV PORT=7860
|
| 19 |
+
ENV GENERATION_PROVIDER=kaggle
|
| 20 |
+
ENV KAGGLE_COMMAND=kaggle
|
| 21 |
+
ENV KAGGLE_ACCELERATOR=NvidiaTeslaT4
|
| 22 |
+
ENV KAGGLE_USE_SMALL_DECODER=0
|
| 23 |
+
ENV KAGGLE_INFERENCE_STEPS=4
|
| 24 |
+
ENV KAGGLE_CACHE_KERNEL_REF=jolvangeest/mplus-flux2-cache
|
| 25 |
+
ENV KAGGLE_POLL_INTERVAL_MS=5000
|
| 26 |
+
ENV KAGGLE_POLL_TIMEOUT_MS=7200000
|
| 27 |
+
|
| 28 |
+
EXPOSE 7860
|
| 29 |
+
|
| 30 |
+
CMD ["npm", "run", "start:prod"]
|
README.md
CHANGED
|
@@ -1,10 +1,206 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MplusKassa Studio
|
| 2 |
+
|
| 3 |
+
MplusKassa Studio is een MVP voor ondernemers die snel betere productbeelden nodig hebben voor hun kassa, bestelzuil, webshop of menuscherm.
|
| 4 |
+
|
| 5 |
+
De gebruiker uploadt bestaande productfoto's, kiest het type beeld en laat de Studio een verkoopklare variant maken. Geen losse AI-tools, geen technische instellingen, maar een begeleide flow van foto naar bruikbaar resultaat.
|
| 6 |
+
|
| 7 |
+
## Demo
|
| 8 |
+
|
| 9 |
+
Drie losse productfoto's worden samengebracht tot een menu-deal compositie op een schone witte achtergrond.
|
| 10 |
+
|
| 11 |
+
| Hoofdproduct | Drank | Bijgerecht |
|
| 12 |
+
| --- | --- | --- |
|
| 13 |
+
|  |  |  |
|
| 14 |
+
|
| 15 |
+
**Gegenereerd resultaat**
|
| 16 |
+
|
| 17 |
+

|
| 18 |
+
|
| 19 |
+
Deze demo-output komt uit een echte end-to-end run via de backend en Kaggle GPU. De README gebruikt een vaste kopie in `docs/`, zodat nieuwe testruns het voorbeeld niet overschrijven.
|
| 20 |
+
|
| 21 |
+
## Waarom Dit Bestaat
|
| 22 |
+
|
| 23 |
+
Veel ondernemers hebben wel productfoto's, maar die zijn niet altijd direct geschikt voor verkoop:
|
| 24 |
+
|
| 25 |
+
- de achtergrond is rommelig;
|
| 26 |
+
- producten hebben verschillende lichtval of kwaliteit;
|
| 27 |
+
- losse beelden voelen niet als één aanbieding;
|
| 28 |
+
- iemand moet handmatig knippen, schalen en combineren;
|
| 29 |
+
- menu- en webshopbeelden moeten consistent zijn.
|
| 30 |
+
|
| 31 |
+
Studio maakt daar een eenvoudige workflow van:
|
| 32 |
+
|
| 33 |
+
```text
|
| 34 |
+
productfoto's uploaden -> type beeld kiezen -> beeld maken -> resultaat controleren
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
De app is bedoeld als assistent voor één concrete taak: betere productafbeeldingen maken.
|
| 38 |
+
|
| 39 |
+
## Wat De MVP Kan
|
| 40 |
+
|
| 41 |
+
- Losse productfoto's uploaden.
|
| 42 |
+
- Een map met meerdere beelden uploaden.
|
| 43 |
+
- Kiezen tussen `Los product` en `Menu deal`.
|
| 44 |
+
- Een klantvriendelijke prompt invullen, bijvoorbeeld: `Maak een professionele fastfood menu deal op een schone witte menu-achtergrond`.
|
| 45 |
+
- Echte beelden genereren via een server-side FLUX.2 model op Kaggle GPU.
|
| 46 |
+
- Origineel en resultaat naast elkaar controleren.
|
| 47 |
+
- Een e2e-bewijsrapport genereren met inputbeelden, outputbeeld en timing.
|
| 48 |
+
|
| 49 |
+
## Belangrijk Voor De Output
|
| 50 |
+
|
| 51 |
+
De huidige menu-deal prompt stuurt op drie dingen:
|
| 52 |
+
|
| 53 |
+
- exacte samenstelling: geen extra producten, dubbele drankjes of extra sauzen;
|
| 54 |
+
- herkenbare producten: vorm, materiaal, glas, ijs, kleur en textuur moeten dicht bij de input blijven;
|
| 55 |
+
- commerciële compositie: hoofdproduct links, drank centraal/achter, bijgerecht rechts, met witte achtergrond en realistische schaduw.
|
| 56 |
+
|
| 57 |
+
Tekst, prijzen en actielabels worden bewust niet door het image model gemaakt. Die horen later als aparte grafische laag bovenop het beeld.
|
| 58 |
+
|
| 59 |
+
## Laatste E2E Resultaat
|
| 60 |
+
|
| 61 |
+
Laatste echte Kaggle-run:
|
| 62 |
+
|
| 63 |
+
```text
|
| 64 |
+
Model inference only: 77.71s
|
| 65 |
+
Model load: 54.50s
|
| 66 |
+
Setup before model load: 31.13s
|
| 67 |
+
Total Kaggle kernel runtime: 163.40s
|
| 68 |
+
GPU: Tesla T4
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
Na een nieuwe e2e-run staan de actuele testbestanden hier:
|
| 72 |
+
|
| 73 |
+
```text
|
| 74 |
+
test-results/e2e/kaggle-demo/result.md
|
| 75 |
+
test-results/e2e/kaggle-demo/generated.png
|
| 76 |
+
test-results/e2e/kaggle-demo/generated-<run>.png
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
## Architectuur
|
| 80 |
+
|
| 81 |
+
De app bestaat uit:
|
| 82 |
+
|
| 83 |
+
- Angular 21 frontend voor de begeleide productflow;
|
| 84 |
+
- Node/Express backend voor uploads, validatie en provider-aansturing;
|
| 85 |
+
- async generation jobs: `POST /api/generations` maakt een taak aan, `GET /api/generations/:jobId` levert status en resultaat;
|
| 86 |
+
- Kaggle als remote GPU-runner voor echte generatie;
|
| 87 |
+
- Docker-voorbereiding voor app, Postgres en MinIO/S3;
|
| 88 |
+
- e2e-rapportage via Playwright.
|
| 89 |
+
|
| 90 |
+
Kaggle credentials blijven server-side. De frontend ziet alleen de app-flow en krijgt geen toegang tot secrets of providerinstellingen.
|
| 91 |
+
|
| 92 |
+
## Lokaal Starten
|
| 93 |
+
|
| 94 |
+
Maak eerst `.env` op basis van `.env.example` en vul je Kaggle configuratie in.
|
| 95 |
+
|
| 96 |
+
```powershell
|
| 97 |
+
npm install
|
| 98 |
+
npm run backend:dev
|
| 99 |
+
npm run start -- --host 127.0.0.1 --port 5000
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
Open daarna:
|
| 103 |
+
|
| 104 |
+
```text
|
| 105 |
+
http://127.0.0.1:5000
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
## Kaggle Configuratie
|
| 109 |
+
|
| 110 |
+
Voor lokale ontwikkeling wordt de Kaggle CLI via `uv` gebruikt.
|
| 111 |
+
|
| 112 |
+
```env
|
| 113 |
+
GENERATION_PROVIDER=kaggle
|
| 114 |
+
COMMERCIAL_MODE=1
|
| 115 |
+
FLUX_MODEL_ID=black-forest-labs/FLUX.2-klein-4B
|
| 116 |
+
KAGGLE_COMMAND=uv run --with kaggle kaggle
|
| 117 |
+
KAGGLE_USERNAME=jouw-kaggle-username
|
| 118 |
+
KAGGLE_ACCELERATOR=NvidiaTeslaT4
|
| 119 |
+
KAGGLE_USE_SMALL_DECODER=0
|
| 120 |
+
KAGGLE_INFERENCE_STEPS=4
|
| 121 |
+
KAGGLE_CACHE_KERNEL_REF=jolvangeest/mplus-flux2-cache
|
| 122 |
+
KAGGLE_POLL_INTERVAL_MS=5000
|
| 123 |
+
KAGGLE_POLL_TIMEOUT_MS=7200000
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
`FLUX_MODEL_ID` kiest de server-side modelroute. De MVP gebruikt nu de commercial-safe Kaggle-route voor FLUX.2 `[klein] 4B`. Laat `COMMERCIAL_MODE=1` aan in productie, zodat non-commercial modelroutes niet per ongeluk geselecteerd kunnen worden.
|
| 127 |
+
|
| 128 |
+
Kaggle OAuth login:
|
| 129 |
+
|
| 130 |
+
```powershell
|
| 131 |
+
uv run --with kaggle kaggle auth login --force
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
Cache-kernel opnieuw pushen:
|
| 135 |
+
|
| 136 |
+
```powershell
|
| 137 |
+
npm run kaggle:cache:push
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
## Testen
|
| 141 |
+
|
| 142 |
+
Snelle checks:
|
| 143 |
+
|
| 144 |
+
```powershell
|
| 145 |
+
npm run test:integration
|
| 146 |
+
npm run test:backend
|
| 147 |
+
npm run build:all
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
Echte Kaggle e2e:
|
| 151 |
+
|
| 152 |
+
```powershell
|
| 153 |
+
$env:E2E_PORT="5010"
|
| 154 |
+
npx playwright test e2e/studio-flow.spec.ts --project=chromium --workers=1
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
De echte e2e duurt meestal meerdere minuten door Kaggle scheduling, dependency setup en model load.
|
| 158 |
+
|
| 159 |
+
## Docker En Publiceren
|
| 160 |
+
|
| 161 |
+
Voor een private testversie:
|
| 162 |
+
|
| 163 |
+
1. Gebruik de private GitHub repo.
|
| 164 |
+
2. Deploy met Docker op een host die server-side secrets ondersteunt.
|
| 165 |
+
3. Zet Kaggle credentials als secrets op de host.
|
| 166 |
+
4. Deel alleen de app-URL met collega's.
|
| 167 |
+
|
| 168 |
+
Zie `DEPLOYMENT.md` voor de Docker-route en `docs/multi-user-architecture.md` voor de multi-user richting met Postgres, MinIO/S3 en per-user Kaggle isolation.
|
| 169 |
+
|
| 170 |
+
## Gratis Demo Host
|
| 171 |
+
|
| 172 |
+
Voor een gratis private demo is een Docker-based Hugging Face Space geschikt. Gebruik daar `PORT=7860` en zet secrets server-side:
|
| 173 |
+
|
| 174 |
+
- `GENERATION_PROVIDER=kaggle`
|
| 175 |
+
- `COMMERCIAL_MODE=1`
|
| 176 |
+
- `FLUX_MODEL_ID=black-forest-labs/FLUX.2-klein-4B`
|
| 177 |
+
- `KAGGLE_COMMAND=kaggle`
|
| 178 |
+
- `KAGGLE_USERNAME=<kaggle-user>`
|
| 179 |
+
- `KAGGLE_API_TOKEN=<kaggle-token>`
|
| 180 |
+
|
| 181 |
+
Deze route is bedoeld als demo-host. Jobs zijn nog in-memory en resultaten blijven niet persistent na restart/sleep.
|
| 182 |
+
|
| 183 |
+
Deploy via CLI:
|
| 184 |
+
|
| 185 |
+
```powershell
|
| 186 |
+
npm run deploy:check
|
| 187 |
+
$env:HF_TOKEN="hf_..."
|
| 188 |
+
$env:HF_SPACE_ID="jouw-hf-naam/mplus-studio"
|
| 189 |
+
$env:KAGGLE_USERNAME="jouw-kaggle-username"
|
| 190 |
+
$env:KAGGLE_API_TOKEN="jouw-kaggle-token"
|
| 191 |
+
npm run deploy:huggingface
|
| 192 |
+
```
|
| 193 |
+
|
| 194 |
+
De deploy-script maakt een private Docker Space aan, zet de Space secrets en uploadt de repo zonder lokale `.env`, `node_modules`, `dist`, testresultaten of graphify-output.
|
| 195 |
+
|
| 196 |
+
## Roadmap
|
| 197 |
+
|
| 198 |
+
De volgende stap is niet meer knoppen toevoegen, maar de workflow slimmer en betrouwbaarder maken:
|
| 199 |
+
|
| 200 |
+
- presets per branche, zoals snackbar, lunchroom, retail of bakkerij;
|
| 201 |
+
- review-stap met goedkeuren, afkeuren en opnieuw proberen;
|
| 202 |
+
- meerdere outputvarianten per run;
|
| 203 |
+
- automatische tekst en prijzen als aparte grafische laag;
|
| 204 |
+
- database en object storage voor multi-user gebruik;
|
| 205 |
+
- persistente job queue voor multi-instance hosting;
|
| 206 |
+
- providerlaag uitbreiden richting betaalde BFL API-modellen waar dat commercieel zinvol is.
|
angular.json
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
| 3 |
+
"version": 1,
|
| 4 |
+
"cli": {
|
| 5 |
+
"packageManager": "npm",
|
| 6 |
+
"analytics": false
|
| 7 |
+
},
|
| 8 |
+
"newProjectRoot": "projects",
|
| 9 |
+
"projects": {
|
| 10 |
+
"mplus-studio": {
|
| 11 |
+
"projectType": "application",
|
| 12 |
+
"schematics": {
|
| 13 |
+
"@schematics/angular:component": {
|
| 14 |
+
"style": "scss",
|
| 15 |
+
"skipTests": true
|
| 16 |
+
},
|
| 17 |
+
"@schematics/angular:class": {
|
| 18 |
+
"skipTests": true
|
| 19 |
+
},
|
| 20 |
+
"@schematics/angular:directive": {
|
| 21 |
+
"skipTests": true
|
| 22 |
+
},
|
| 23 |
+
"@schematics/angular:guard": {
|
| 24 |
+
"skipTests": true
|
| 25 |
+
},
|
| 26 |
+
"@schematics/angular:interceptor": {
|
| 27 |
+
"skipTests": true
|
| 28 |
+
},
|
| 29 |
+
"@schematics/angular:pipe": {
|
| 30 |
+
"skipTests": true
|
| 31 |
+
},
|
| 32 |
+
"@schematics/angular:resolver": {
|
| 33 |
+
"skipTests": true
|
| 34 |
+
},
|
| 35 |
+
"@schematics/angular:service": {
|
| 36 |
+
"skipTests": true
|
| 37 |
+
}
|
| 38 |
+
},
|
| 39 |
+
"root": "",
|
| 40 |
+
"sourceRoot": "src",
|
| 41 |
+
"prefix": "app",
|
| 42 |
+
"architect": {
|
| 43 |
+
"build": {
|
| 44 |
+
"builder": "@angular/build:application",
|
| 45 |
+
"options": {
|
| 46 |
+
"browser": "src/main.ts",
|
| 47 |
+
"tsConfig": "tsconfig.app.json",
|
| 48 |
+
"inlineStyleLanguage": "scss",
|
| 49 |
+
"assets": [
|
| 50 |
+
{
|
| 51 |
+
"glob": "**/*",
|
| 52 |
+
"input": "public"
|
| 53 |
+
}
|
| 54 |
+
],
|
| 55 |
+
"styles": ["src/styles.scss"]
|
| 56 |
+
},
|
| 57 |
+
"configurations": {
|
| 58 |
+
"production": {
|
| 59 |
+
"budgets": [
|
| 60 |
+
{
|
| 61 |
+
"type": "initial",
|
| 62 |
+
"maximumWarning": "500kB",
|
| 63 |
+
"maximumError": "1MB"
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"type": "anyComponentStyle",
|
| 67 |
+
"maximumWarning": "4kB",
|
| 68 |
+
"maximumError": "8kB"
|
| 69 |
+
}
|
| 70 |
+
],
|
| 71 |
+
"outputHashing": "all"
|
| 72 |
+
},
|
| 73 |
+
"development": {
|
| 74 |
+
"optimization": false,
|
| 75 |
+
"extractLicenses": false,
|
| 76 |
+
"sourceMap": true
|
| 77 |
+
}
|
| 78 |
+
},
|
| 79 |
+
"defaultConfiguration": "production"
|
| 80 |
+
},
|
| 81 |
+
"serve": {
|
| 82 |
+
"builder": "@angular/build:dev-server",
|
| 83 |
+
"configurations": {
|
| 84 |
+
"production": {
|
| 85 |
+
"buildTarget": "mplus-studio:build:production"
|
| 86 |
+
},
|
| 87 |
+
"development": {
|
| 88 |
+
"buildTarget": "mplus-studio:build:development"
|
| 89 |
+
}
|
| 90 |
+
},
|
| 91 |
+
"defaultConfiguration": "development"
|
| 92 |
+
},
|
| 93 |
+
"test": {
|
| 94 |
+
"builder": "@angular/build:unit-test",
|
| 95 |
+
"options": {
|
| 96 |
+
"buildTarget": "mplus-studio:build:development",
|
| 97 |
+
"tsConfig": "tsconfig.spec.json",
|
| 98 |
+
"runner": "vitest"
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
}
|
backend/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "mplus-studio-backend",
|
| 3 |
+
"version": "0.0.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "tsx watch src/server.ts",
|
| 8 |
+
"build": "tsc -p tsconfig.json",
|
| 9 |
+
"start": "node dist/server.js",
|
| 10 |
+
"test": "vitest run src"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"cors": "^2.8.5",
|
| 14 |
+
"dotenv": "^17.4.2",
|
| 15 |
+
"express": "^5.2.1",
|
| 16 |
+
"multer": "^2.0.2",
|
| 17 |
+
"zod": "^4.2.1"
|
| 18 |
+
},
|
| 19 |
+
"devDependencies": {
|
| 20 |
+
"@types/cors": "^2.8.19",
|
| 21 |
+
"@types/express": "^5.0.6",
|
| 22 |
+
"@types/multer": "^2.0.0",
|
| 23 |
+
"@types/node": "^20.19.37",
|
| 24 |
+
"@types/supertest": "^6.0.3",
|
| 25 |
+
"supertest": "^7.1.4",
|
| 26 |
+
"tsx": "^4.21.0",
|
| 27 |
+
"typescript": "~5.9.2",
|
| 28 |
+
"vitest": "^4.1.7"
|
| 29 |
+
}
|
| 30 |
+
}
|
backend/src/app.integration.spec.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import request from 'supertest';
|
| 2 |
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
| 3 |
+
import { createApp } from './app.js';
|
| 4 |
+
|
| 5 |
+
describe('generation api', () => {
|
| 6 |
+
afterEach(() => {
|
| 7 |
+
vi.unstubAllEnvs();
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
it('returns health status', async () => {
|
| 11 |
+
await request(createApp()).get('/health').expect(200, { status: 'ok' });
|
| 12 |
+
});
|
| 13 |
+
|
| 14 |
+
it('accepts uploaded images and returns a queued generation job', async () => {
|
| 15 |
+
const app = createApp();
|
| 16 |
+
const response = await request(app)
|
| 17 |
+
.post('/api/generations')
|
| 18 |
+
.field('prompt', 'Photorealistic product image')
|
| 19 |
+
.field('preset', 'studio')
|
| 20 |
+
.field('ratio', '16:9')
|
| 21 |
+
.field('mode', 'product')
|
| 22 |
+
.attach('images', Buffer.from('image'), { filename: 'product.png', contentType: 'image/png' })
|
| 23 |
+
.expect(202);
|
| 24 |
+
|
| 25 |
+
expect(response.body).toMatchObject({
|
| 26 |
+
provider: 'mock',
|
| 27 |
+
status: expect.stringMatching(/queued|running|completed/),
|
| 28 |
+
});
|
| 29 |
+
expect(response.body.jobId).toEqual(expect.any(String));
|
| 30 |
+
|
| 31 |
+
await vi.waitFor(async () => {
|
| 32 |
+
const jobResponse = await request(app).get(`/api/generations/${response.body.jobId}`).expect(200);
|
| 33 |
+
expect(jobResponse.body).toMatchObject({
|
| 34 |
+
status: 'completed',
|
| 35 |
+
result: {
|
| 36 |
+
provider: 'mock',
|
| 37 |
+
preset: 'studio',
|
| 38 |
+
ratio: '16:9',
|
| 39 |
+
images: [{ originalName: 'product.png' }],
|
| 40 |
+
},
|
| 41 |
+
});
|
| 42 |
+
expect(jobResponse.body.result.images[0].variants).toHaveLength(3);
|
| 43 |
+
});
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
it('returns 404 for unknown generation jobs', async () => {
|
| 47 |
+
await request(createApp()).get('/api/generations/missing').expect(404, { error: 'Generation job not found' });
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
it('rejects requests without images', async () => {
|
| 51 |
+
await request(createApp())
|
| 52 |
+
.post('/api/generations')
|
| 53 |
+
.field('prompt', 'Photorealistic product image')
|
| 54 |
+
.field('preset', 'studio')
|
| 55 |
+
.field('ratio', '16:9')
|
| 56 |
+
.field('mode', 'product')
|
| 57 |
+
.expect(400, { error: 'At least one image is required' });
|
| 58 |
+
});
|
| 59 |
+
});
|
backend/src/app.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import crypto from 'node:crypto';
|
| 2 |
+
import cors from 'cors';
|
| 3 |
+
import express, { Request, Response } from 'express';
|
| 4 |
+
import multer from 'multer';
|
| 5 |
+
import { buildGenerationResponse, GenerationProvider, GenerationResponse, generationRequestSchema, UploadedImage } from './generation/generation.js';
|
| 6 |
+
import { generateKaggleImages } from './providers/kaggle.js';
|
| 7 |
+
|
| 8 |
+
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024, files: 50 } });
|
| 9 |
+
|
| 10 |
+
type GenerationJob = {
|
| 11 |
+
id: string;
|
| 12 |
+
provider: GenerationProvider;
|
| 13 |
+
status: 'queued' | 'running' | 'completed' | 'failed';
|
| 14 |
+
createdAt: string;
|
| 15 |
+
updatedAt: string;
|
| 16 |
+
result?: GenerationResponse;
|
| 17 |
+
error?: string;
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
if (!globalThis.crypto) {
|
| 21 |
+
globalThis.crypto = <Crypto>crypto.webcrypto;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export function createApp(): express.Express {
|
| 25 |
+
const app = express();
|
| 26 |
+
const jobs = new Map<string, GenerationJob>();
|
| 27 |
+
|
| 28 |
+
app.use(cors({ origin: true }));
|
| 29 |
+
app.use(express.json());
|
| 30 |
+
|
| 31 |
+
app.get('/health', (_request: Request, response: Response) => {
|
| 32 |
+
response.json({ status: 'ok' });
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
app.post('/api/generations', upload.array('images'), async (request: Request, response: Response) => {
|
| 36 |
+
const parsedRequest = generationRequestSchema.safeParse(request.body);
|
| 37 |
+
if (!parsedRequest.success) {
|
| 38 |
+
response.status(400).json({ error: 'Invalid generation request' });
|
| 39 |
+
return;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const files = <Express.Multer.File[]>request.files ?? [];
|
| 43 |
+
const images = files
|
| 44 |
+
.filter((file) => file.mimetype.startsWith('image/'))
|
| 45 |
+
.map<UploadedImage>((file) => ({
|
| 46 |
+
originalName: file.originalname,
|
| 47 |
+
mimeType: file.mimetype,
|
| 48 |
+
size: file.size,
|
| 49 |
+
buffer: file.buffer,
|
| 50 |
+
}));
|
| 51 |
+
|
| 52 |
+
if (images.length === 0) {
|
| 53 |
+
response.status(400).json({ error: 'At least one image is required' });
|
| 54 |
+
return;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const provider = getGenerationProvider();
|
| 58 |
+
const now = new Date().toISOString();
|
| 59 |
+
const job: GenerationJob = {
|
| 60 |
+
id: crypto.randomUUID(),
|
| 61 |
+
provider,
|
| 62 |
+
status: 'queued',
|
| 63 |
+
createdAt: now,
|
| 64 |
+
updatedAt: now,
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
jobs.set(job.id, job);
|
| 68 |
+
runGenerationJob(job, parsedRequest.data, images);
|
| 69 |
+
response.status(202).json(toJobResponse(job));
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
app.get('/api/generations/:jobId', (request: Request, response: Response) => {
|
| 73 |
+
const jobId = request.params.jobId;
|
| 74 |
+
if (typeof jobId !== 'string') {
|
| 75 |
+
response.status(404).json({ error: 'Generation job not found' });
|
| 76 |
+
return;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
const job = jobs.get(jobId);
|
| 80 |
+
if (job === undefined) {
|
| 81 |
+
response.status(404).json({ error: 'Generation job not found' });
|
| 82 |
+
return;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
response.json(toJobResponse(job));
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
function runGenerationJob(job: GenerationJob, input: ReturnType<typeof generationRequestSchema.parse>, images: UploadedImage[]): void {
|
| 89 |
+
void (async () => {
|
| 90 |
+
updateJob(job, { status: 'running' });
|
| 91 |
+
|
| 92 |
+
try {
|
| 93 |
+
const result = job.provider === 'kaggle'
|
| 94 |
+
? await generateKaggleImages(input, images)
|
| 95 |
+
: buildGenerationResponse(input, images);
|
| 96 |
+
|
| 97 |
+
updateJob(job, { status: 'completed', result });
|
| 98 |
+
}
|
| 99 |
+
catch (error) {
|
| 100 |
+
updateJob(job, {
|
| 101 |
+
status: 'failed',
|
| 102 |
+
error: error instanceof Error ? error.message : 'Generation provider failed',
|
| 103 |
+
});
|
| 104 |
+
}
|
| 105 |
+
})();
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
function updateJob(job: GenerationJob, updates: Partial<GenerationJob>): void {
|
| 109 |
+
Object.assign(job, updates, { updatedAt: new Date().toISOString() });
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
return app;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
function getGenerationProvider(): GenerationProvider {
|
| 116 |
+
return process.env.GENERATION_PROVIDER === 'kaggle' ? 'kaggle' : 'mock';
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
function toJobResponse(job: GenerationJob): Record<string, unknown> {
|
| 120 |
+
return {
|
| 121 |
+
jobId: job.id,
|
| 122 |
+
provider: job.provider,
|
| 123 |
+
status: job.status,
|
| 124 |
+
createdAt: job.createdAt,
|
| 125 |
+
updatedAt: job.updatedAt,
|
| 126 |
+
result: job.result,
|
| 127 |
+
error: job.error,
|
| 128 |
+
};
|
| 129 |
+
}
|
backend/src/generation/generation.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { z } from 'zod';
|
| 2 |
+
|
| 3 |
+
export type UploadedImage = {
|
| 4 |
+
originalName: string;
|
| 5 |
+
mimeType: string;
|
| 6 |
+
size: number;
|
| 7 |
+
buffer?: Buffer;
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
export type GenerationVariant = {
|
| 11 |
+
id: string;
|
| 12 |
+
label: string;
|
| 13 |
+
status: 'queued' | 'completed';
|
| 14 |
+
prompt: string;
|
| 15 |
+
providerPromptId?: string;
|
| 16 |
+
imageUrl?: string;
|
| 17 |
+
imageBase64?: string;
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
export type GenerationImage = {
|
| 21 |
+
id: string;
|
| 22 |
+
originalName: string;
|
| 23 |
+
variants: GenerationVariant[];
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
export type GenerationProvider = 'mock' | 'kaggle';
|
| 27 |
+
|
| 28 |
+
export type GenerationResponse = {
|
| 29 |
+
jobId: string;
|
| 30 |
+
provider: GenerationProvider;
|
| 31 |
+
preset: string;
|
| 32 |
+
ratio: string;
|
| 33 |
+
images: GenerationImage[];
|
| 34 |
+
metrics?: GenerationMetrics;
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
export type GenerationMetrics = {
|
| 38 |
+
durationSeconds?: number;
|
| 39 |
+
inferenceSeconds?: number;
|
| 40 |
+
modelLoadSeconds?: number;
|
| 41 |
+
setupSeconds?: number;
|
| 42 |
+
height?: number;
|
| 43 |
+
width?: number;
|
| 44 |
+
steps?: number;
|
| 45 |
+
smallDecoder?: boolean;
|
| 46 |
+
cudaAvailable?: boolean;
|
| 47 |
+
gpuName?: string;
|
| 48 |
+
hfHome?: string;
|
| 49 |
+
mode?: GenerationMode;
|
| 50 |
+
modelId?: string;
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
export type GenerationMode = 'product' | 'menu';
|
| 54 |
+
|
| 55 |
+
export const generationRequestSchema = z.object({
|
| 56 |
+
prompt: z.string().trim().min(1),
|
| 57 |
+
preset: z.enum(['studio', 'food', 'retail', 'premium']),
|
| 58 |
+
ratio: z.enum(['16:9', '9:16', '1:1']),
|
| 59 |
+
mode: z.enum(['product', 'menu']).default('product'),
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
export function buildGenerationResponse(input: z.infer<typeof generationRequestSchema>, images: UploadedImage[], provider: GenerationProvider = 'mock'): GenerationResponse {
|
| 63 |
+
return {
|
| 64 |
+
jobId: crypto.randomUUID(),
|
| 65 |
+
provider,
|
| 66 |
+
preset: input.preset,
|
| 67 |
+
ratio: input.ratio,
|
| 68 |
+
images: images.map((image) => ({
|
| 69 |
+
id: crypto.randomUUID(),
|
| 70 |
+
originalName: image.originalName,
|
| 71 |
+
variants: buildVariants(input.prompt),
|
| 72 |
+
})),
|
| 73 |
+
};
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function buildVariants(prompt: string): GenerationVariant[] {
|
| 77 |
+
return [
|
| 78 |
+
{
|
| 79 |
+
id: crypto.randomUUID(),
|
| 80 |
+
label: 'Clean studio',
|
| 81 |
+
status: 'queued',
|
| 82 |
+
prompt: `${prompt}. Clean studio background, realistic product shadow.`,
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
id: crypto.randomUUID(),
|
| 86 |
+
label: 'Warme food sfeer',
|
| 87 |
+
status: 'queued',
|
| 88 |
+
prompt: `${prompt}. Warm hospitality setting, photorealistic lighting.`,
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
id: crypto.randomUUID(),
|
| 92 |
+
label: 'Premium donker',
|
| 93 |
+
status: 'queued',
|
| 94 |
+
prompt: `${prompt}. Dark premium campaign scene, controlled highlights.`,
|
| 95 |
+
},
|
| 96 |
+
];
|
| 97 |
+
}
|
backend/src/generation/generation.unit.spec.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
| 2 |
+
import { buildGenerationResponse } from './generation.js';
|
| 3 |
+
|
| 4 |
+
describe('buildGenerationResponse', () => {
|
| 5 |
+
beforeEach(() => {
|
| 6 |
+
vi.stubGlobal('crypto', { randomUUID: vi.fn(() => 'id') });
|
| 7 |
+
});
|
| 8 |
+
|
| 9 |
+
afterEach(() => {
|
| 10 |
+
vi.unstubAllGlobals();
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
it('creates queued variants for each image', () => {
|
| 14 |
+
const response = buildGenerationResponse(
|
| 15 |
+
{ prompt: 'Photorealistic bottle', preset: 'studio', ratio: '16:9', mode: 'product' },
|
| 16 |
+
[{ originalName: 'bottle.png', mimeType: 'image/png', size: 1000 }],
|
| 17 |
+
);
|
| 18 |
+
|
| 19 |
+
expect(response).toMatchObject({
|
| 20 |
+
jobId: 'id',
|
| 21 |
+
provider: 'mock',
|
| 22 |
+
preset: 'studio',
|
| 23 |
+
ratio: '16:9',
|
| 24 |
+
images: [
|
| 25 |
+
{
|
| 26 |
+
id: 'id',
|
| 27 |
+
originalName: 'bottle.png',
|
| 28 |
+
variants: [
|
| 29 |
+
{ id: 'id', label: 'Clean studio', status: 'queued' },
|
| 30 |
+
{ id: 'id', label: 'Warme food sfeer', status: 'queued' },
|
| 31 |
+
{ id: 'id', label: 'Premium donker', status: 'queued' },
|
| 32 |
+
],
|
| 33 |
+
},
|
| 34 |
+
],
|
| 35 |
+
});
|
| 36 |
+
});
|
| 37 |
+
});
|
backend/src/providers/kaggle.ts
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { spawn } from 'node:child_process';
|
| 2 |
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
| 3 |
+
import { basename, dirname, resolve } from 'node:path';
|
| 4 |
+
import { fileURLToPath } from 'node:url';
|
| 5 |
+
import { z } from 'zod';
|
| 6 |
+
import { GenerationImage, GenerationMetrics, GenerationResponse, generationRequestSchema, UploadedImage } from '../generation/generation.js';
|
| 7 |
+
|
| 8 |
+
type KagglePayload = {
|
| 9 |
+
prompt: string;
|
| 10 |
+
mode: 'product' | 'menu';
|
| 11 |
+
preset: string;
|
| 12 |
+
ratio: string;
|
| 13 |
+
modelId: string;
|
| 14 |
+
images: Array<{
|
| 15 |
+
name: string;
|
| 16 |
+
mimeType: string;
|
| 17 |
+
}>;
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
type FluxModelConfig = {
|
| 21 |
+
id: string;
|
| 22 |
+
label: string;
|
| 23 |
+
provider: 'kaggle';
|
| 24 |
+
commercialSafe: boolean;
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
const defaultFluxModelId = 'black-forest-labs/FLUX.2-klein-4B';
|
| 28 |
+
const fluxModels: FluxModelConfig[] = [
|
| 29 |
+
{
|
| 30 |
+
id: defaultFluxModelId,
|
| 31 |
+
label: 'FLUX.2 klein 4B',
|
| 32 |
+
provider: 'kaggle',
|
| 33 |
+
commercialSafe: true,
|
| 34 |
+
},
|
| 35 |
+
];
|
| 36 |
+
|
| 37 |
+
const backendSourceDirectory = dirname(fileURLToPath(import.meta.url));
|
| 38 |
+
const projectRootDirectory = resolve(backendSourceDirectory, '..', '..', '..');
|
| 39 |
+
const workspaceDirectory = resolve(projectRootDirectory, '.kaggle-work');
|
| 40 |
+
const kernelTemplatePath = resolve(projectRootDirectory, 'kaggle-flux2-klein', 'kernel.py');
|
| 41 |
+
|
| 42 |
+
export async function generateKaggleImages(input: z.infer<typeof generationRequestSchema>, images: UploadedImage[]): Promise<GenerationResponse> {
|
| 43 |
+
const kaggleUsername = requiredEnv('KAGGLE_USERNAME');
|
| 44 |
+
const modelId = getKaggleFluxModelId();
|
| 45 |
+
const runId = crypto.randomUUID().replaceAll('-', '').slice(0, 16);
|
| 46 |
+
const datasetSlug = `${process.env.KAGGLE_DATASET_PREFIX ?? 'mplus-flux2-input'}-${runId}`;
|
| 47 |
+
const kernelSlug = `${process.env.KAGGLE_KERNEL_PREFIX ?? 'mplus-flux2-generate'}-${runId}`;
|
| 48 |
+
const kernelRef = `${kaggleUsername}/${kernelSlug}`;
|
| 49 |
+
const runDirectory = resolve(workspaceDirectory, runId);
|
| 50 |
+
const kernelDirectory = resolve(runDirectory, 'kernel');
|
| 51 |
+
const outputDirectory = resolve(runDirectory, 'output');
|
| 52 |
+
|
| 53 |
+
await mkdir(kernelDirectory, { recursive: true });
|
| 54 |
+
await mkdir(outputDirectory, { recursive: true });
|
| 55 |
+
|
| 56 |
+
try {
|
| 57 |
+
await writeKernelInput(input, images, kernelDirectory, modelId);
|
| 58 |
+
await writeKernel(input, images, kernelDirectory, kernelSlug, datasetSlug, modelId);
|
| 59 |
+
await runKaggle(['kernels', 'push', '-p', kernelDirectory, '--accelerator', process.env.KAGGLE_ACCELERATOR ?? 'NvidiaTeslaT4', '-t', process.env.KAGGLE_KERNEL_TIMEOUT_SECONDS ?? '7200']);
|
| 60 |
+
await waitForKernel(kernelRef);
|
| 61 |
+
await runKaggle(['kernels', 'output', kernelRef, '-p', outputDirectory, '-o', '-q', '--file-pattern', 'results.json']);
|
| 62 |
+
await runKaggle(['kernels', 'output', kernelRef, '-p', outputDirectory, '-o', '-q', '--file-pattern', '.*png']);
|
| 63 |
+
|
| 64 |
+
const results = <Record<string, string | Record<string, unknown>>>JSON.parse(await readFile(resolve(outputDirectory, 'results.json'), 'utf8'));
|
| 65 |
+
if (results.__error !== undefined) {
|
| 66 |
+
throw new Error(`Kaggle kernel error: ${results.__error}\n${results.__traceback ?? ''}`);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
const responseImages = input.mode === 'menu' ? [images[0]] : images;
|
| 70 |
+
|
| 71 |
+
const generationImages: GenerationImage[] = [];
|
| 72 |
+
for (const image of responseImages) {
|
| 73 |
+
generationImages.push({
|
| 74 |
+
id: crypto.randomUUID(),
|
| 75 |
+
originalName: image.originalName,
|
| 76 |
+
variants: [
|
| 77 |
+
{
|
| 78 |
+
id: crypto.randomUUID(),
|
| 79 |
+
label: input.mode === 'menu' ? 'Menu deal' : getModelLabel(modelId),
|
| 80 |
+
status: 'completed',
|
| 81 |
+
prompt: buildPrompt(input.prompt, input.preset, input.ratio, input.mode, images.length),
|
| 82 |
+
providerPromptId: kernelRef,
|
| 83 |
+
imageBase64: await getGeneratedImage(results, image.originalName, outputDirectory),
|
| 84 |
+
},
|
| 85 |
+
],
|
| 86 |
+
});
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
return {
|
| 90 |
+
jobId: runId,
|
| 91 |
+
provider: 'kaggle',
|
| 92 |
+
preset: input.preset,
|
| 93 |
+
ratio: input.ratio,
|
| 94 |
+
metrics: getMetrics(results),
|
| 95 |
+
images: generationImages,
|
| 96 |
+
};
|
| 97 |
+
}
|
| 98 |
+
finally {
|
| 99 |
+
if (process.env.KAGGLE_KEEP_WORKDIR !== '1') {
|
| 100 |
+
await rm(runDirectory, { recursive: true, force: true });
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
function getMetrics(results: Record<string, string | Record<string, unknown>>): GenerationMetrics | undefined {
|
| 106 |
+
const metrics = results.__metrics;
|
| 107 |
+
if (metrics === undefined || typeof metrics === 'string') {
|
| 108 |
+
return undefined;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
return generationMetricsSchema.parse(metrics);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
const generationMetricsSchema = z.object({
|
| 115 |
+
durationSeconds: z.number().optional(),
|
| 116 |
+
inferenceSeconds: z.number().optional(),
|
| 117 |
+
modelLoadSeconds: z.number().optional(),
|
| 118 |
+
setupSeconds: z.number().optional(),
|
| 119 |
+
height: z.number().optional(),
|
| 120 |
+
width: z.number().optional(),
|
| 121 |
+
steps: z.number().optional(),
|
| 122 |
+
smallDecoder: z.boolean().optional(),
|
| 123 |
+
cudaAvailable: z.boolean().optional(),
|
| 124 |
+
gpuName: z.string().optional(),
|
| 125 |
+
hfHome: z.string().optional(),
|
| 126 |
+
mode: z.enum(['product', 'menu']).optional(),
|
| 127 |
+
modelId: z.string().optional(),
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
async function getGeneratedImage(results: Record<string, string | Record<string, unknown>>, imageName: string, outputDirectory: string): Promise<string | undefined> {
|
| 131 |
+
const imageResult = results[imageName];
|
| 132 |
+
if (typeof imageResult !== 'string') {
|
| 133 |
+
return undefined;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
if (imageResult.startsWith('data:image/')) {
|
| 137 |
+
return imageResult;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
const imageBuffer = await readFile(resolve(outputDirectory, sanitizeFileName(imageResult)));
|
| 141 |
+
return `data:image/png;base64,${imageBuffer.toString('base64')}`;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
async function writeKernelInput(input: z.infer<typeof generationRequestSchema>, images: UploadedImage[], kernelDirectory: string, modelId: string): Promise<void> {
|
| 145 |
+
const payload: KagglePayload = {
|
| 146 |
+
prompt: buildPrompt(input.prompt, input.preset, input.ratio, input.mode, images.length),
|
| 147 |
+
mode: input.mode,
|
| 148 |
+
preset: input.preset,
|
| 149 |
+
ratio: input.ratio,
|
| 150 |
+
modelId,
|
| 151 |
+
images: images.map((image) => ({ name: image.originalName, mimeType: image.mimeType })),
|
| 152 |
+
};
|
| 153 |
+
|
| 154 |
+
await writeFile(resolve(kernelDirectory, 'request.json'), JSON.stringify(payload, undefined, 2));
|
| 155 |
+
|
| 156 |
+
for (const image of images) {
|
| 157 |
+
if (image.buffer === undefined) {
|
| 158 |
+
throw new Error('Image buffer is required for Kaggle generation');
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
await writeFile(resolve(kernelDirectory, sanitizeFileName(image.originalName)), image.buffer);
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
async function writeKernel(input: z.infer<typeof generationRequestSchema>, images: UploadedImage[], kernelDirectory: string, kernelSlug: string, datasetSlug: string, modelId: string): Promise<void> {
|
| 166 |
+
const payload: KagglePayload = {
|
| 167 |
+
prompt: buildPrompt(input.prompt, input.preset, input.ratio, input.mode, images.length),
|
| 168 |
+
mode: input.mode,
|
| 169 |
+
preset: input.preset,
|
| 170 |
+
ratio: input.ratio,
|
| 171 |
+
modelId,
|
| 172 |
+
images: images.map((image) => ({ name: image.originalName, mimeType: image.mimeType })),
|
| 173 |
+
};
|
| 174 |
+
const embeddedImages: Record<string, string> = {};
|
| 175 |
+
|
| 176 |
+
for (const image of images) {
|
| 177 |
+
if (image.buffer === undefined) {
|
| 178 |
+
throw new Error('Image buffer is required for Kaggle generation');
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
embeddedImages[sanitizeFileName(image.originalName)] = image.buffer.toString('base64');
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
const cacheKernelRef = process.env.KAGGLE_CACHE_KERNEL_REF;
|
| 185 |
+
const kernelSources = cacheKernelRef === undefined || cacheKernelRef.trim() === '' ? [] : [cacheKernelRef];
|
| 186 |
+
const kernelSource = (await readFile(kernelTemplatePath, 'utf8'))
|
| 187 |
+
.replace('__MPLUS_REQUEST_JSON__', JSON.stringify(payload).replaceAll('\\', '\\\\').replaceAll('"', '\\"'))
|
| 188 |
+
.replace('__MPLUS_IMAGES_JSON__', JSON.stringify(embeddedImages).replaceAll('\\', '\\\\').replaceAll('"', '\\"'))
|
| 189 |
+
.replace('__MPLUS_MODEL_ID__', modelId)
|
| 190 |
+
.replace('__MPLUS_USE_SMALL_DECODER__', process.env.KAGGLE_USE_SMALL_DECODER ?? '1')
|
| 191 |
+
.replace('__MPLUS_INFERENCE_STEPS__', process.env.KAGGLE_INFERENCE_STEPS ?? '4');
|
| 192 |
+
|
| 193 |
+
await writeFile(resolve(kernelDirectory, 'kernel.py'), kernelSource);
|
| 194 |
+
await writeFile(resolve(kernelDirectory, 'kernel-metadata.json'), JSON.stringify({
|
| 195 |
+
id: `${requiredEnv('KAGGLE_USERNAME')}/${kernelSlug}`,
|
| 196 |
+
title: kernelSlug,
|
| 197 |
+
code_file: 'kernel.py',
|
| 198 |
+
language: 'python',
|
| 199 |
+
kernel_type: 'script',
|
| 200 |
+
is_private: true,
|
| 201 |
+
enable_gpu: true,
|
| 202 |
+
enable_internet: true,
|
| 203 |
+
dataset_sources: [],
|
| 204 |
+
competition_sources: [],
|
| 205 |
+
kernel_sources: kernelSources,
|
| 206 |
+
model_sources: [],
|
| 207 |
+
keywords: [datasetSlug],
|
| 208 |
+
}, undefined, 2));
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
async function waitForKernel(kernelRef: string): Promise<void> {
|
| 212 |
+
const timeoutMs = Number(process.env.KAGGLE_POLL_TIMEOUT_MS ?? 7_200_000);
|
| 213 |
+
const intervalMs = Number(process.env.KAGGLE_POLL_INTERVAL_MS ?? 30_000);
|
| 214 |
+
const startedAt = Date.now();
|
| 215 |
+
|
| 216 |
+
while (Date.now() - startedAt < timeoutMs) {
|
| 217 |
+
const status = await runKaggle(['kernels', 'status', kernelRef]);
|
| 218 |
+
|
| 219 |
+
if (/complete/i.test(status)) {
|
| 220 |
+
return;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
if (/error|failed|cancel/i.test(status)) {
|
| 224 |
+
throw new Error(`Kaggle kernel failed: ${status.trim()}`);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
await wait(intervalMs);
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
throw new Error(`Kaggle kernel timed out: ${kernelRef}`);
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
async function runKaggle(args: string[]): Promise<string> {
|
| 234 |
+
const command = process.env.KAGGLE_COMMAND ?? 'kaggle';
|
| 235 |
+
const commandParts = command.split(' ').filter((part) => part.trim() !== '');
|
| 236 |
+
const executable = commandParts[0];
|
| 237 |
+
const commandArgs = [...commandParts.slice(1), ...args];
|
| 238 |
+
const kaggleEnv: NodeJS.ProcessEnv = { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' };
|
| 239 |
+
|
| 240 |
+
if (process.env.KAGGLE_USE_CONFIG_DIR !== '1') {
|
| 241 |
+
delete kaggleEnv.KAGGLE_CONFIG_DIR;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
return await new Promise((resolvePromise, reject) => {
|
| 245 |
+
const childProcess = spawn(executable, commandArgs, {
|
| 246 |
+
env: kaggleEnv,
|
| 247 |
+
shell: process.platform === 'win32',
|
| 248 |
+
});
|
| 249 |
+
const output: string[] = [];
|
| 250 |
+
const errors: string[] = [];
|
| 251 |
+
|
| 252 |
+
childProcess.stdout.on('data', (chunk: Buffer) => output.push(chunk.toString('utf8')));
|
| 253 |
+
childProcess.stderr.on('data', (chunk: Buffer) => errors.push(chunk.toString('utf8')));
|
| 254 |
+
childProcess.on('error', reject);
|
| 255 |
+
childProcess.on('close', (code) => {
|
| 256 |
+
if (code !== 0) {
|
| 257 |
+
reject(new Error(errors.join('').trim() || output.join('').trim() || `${command} ${args.join(' ')} failed with code ${code}`));
|
| 258 |
+
return;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
resolvePromise(output.join(''));
|
| 262 |
+
});
|
| 263 |
+
});
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
function requiredEnv(name: string): string {
|
| 267 |
+
const value = process.env[name];
|
| 268 |
+
if (value === undefined || value.trim() === '') {
|
| 269 |
+
throw new Error(`${name} is required for Kaggle generation`);
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
return value;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
function sanitizeFileName(fileName: string): string {
|
| 276 |
+
return basename(fileName).replaceAll(/[^a-zA-Z0-9._-]/g, '_');
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
function wait(ms: number): Promise<void> {
|
| 280 |
+
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
function getKaggleFluxModelId(): string {
|
| 284 |
+
const modelId = process.env.FLUX_MODEL_ID ?? defaultFluxModelId;
|
| 285 |
+
const modelConfig = fluxModels.find((model) => model.id === modelId && model.provider === 'kaggle');
|
| 286 |
+
if (modelConfig === undefined) {
|
| 287 |
+
throw new Error(`Unsupported Kaggle FLUX model: ${modelId}`);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
if (process.env.COMMERCIAL_MODE === '1' && !modelConfig.commercialSafe) {
|
| 291 |
+
throw new Error(`FLUX model is not enabled for commercial mode: ${modelId}`);
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
return modelConfig.id;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
function getModelLabel(modelId: string): string {
|
| 298 |
+
return fluxModels.find((model) => model.id === modelId)?.label ?? modelId;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
function buildPrompt(prompt: string, preset: string, ratio: string, mode: 'product' | 'menu', imageCount = 1): string {
|
| 302 |
+
if (mode === 'menu') {
|
| 303 |
+
return `${prompt}. Create one premium commercial menu-deal product photo using ${buildReferenceImageList(imageCount)} as the only product references. The goal is a clean sales image for a menu board or webshop: the products should look appetizing, consistent, and commercially lit, while still clearly matching the supplied references. There are exactly ${imageCount} supplied product reference images, so the final image must contain exactly ${imageCount} product groups total. ${buildReferenceRoles(imageCount)} Preserve one visible instance of each referenced product. Keep the product identity, silhouette, container shape, glass shape, material, color, ice, texture, and main visual details as close as possible to each reference image. Improve lighting, contrast, background cleanliness, and scale consistency only; do not redesign the food or drink. For the drink, directly preserve the original glass or cup shape, rim, base, transparency, dark beverage color, and visible ice arrangement instead of replacing it with a different cup or generic soft-drink container. Compose the scene as a menu-deal hero layout: main product larger on the left foreground, drink upright near the center or back, side item on the right foreground, all close enough to feel like one offer, with natural overlap only where it improves depth. Do not duplicate products. Do not add extra products, sauces, cups, drinks, side dishes, desserts, labels, logos, text, prices, hands, packaging claims, brand marks, or clutter. Use a bright white product photography background, realistic scale, contact shadows, subtle depth, and professional commercial food lighting. Output aspect ratio ${ratio}.`;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
return `${prompt}. Create a photorealistic product image variation for ${preset}. Preserve the product identity, packaging details, proportions, readable labels, colors, and materials. Output aspect ratio ${ratio}. Do not add fake prices or fake promotional text.`;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
function buildReferenceImageList(imageCount: number): string {
|
| 310 |
+
const imageNames = [];
|
| 311 |
+
for (let imageIndex = 1; imageIndex <= imageCount; imageIndex += 1) {
|
| 312 |
+
imageNames.push(`image ${imageIndex}`);
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
return imageNames.join(', ');
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
function buildReferenceRoles(imageCount: number): string {
|
| 319 |
+
const roles = ['Use image 1 as the main product.'];
|
| 320 |
+
if (imageCount >= 2) {
|
| 321 |
+
roles.push('Use image 2 as the drink if it is a drink.');
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
if (imageCount >= 3) {
|
| 325 |
+
roles.push('Use image 3 as the side item if it is a side item.');
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
return roles.join(' ');
|
| 329 |
+
}
|
backend/src/server.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import dotenv from 'dotenv';
|
| 2 |
+
import express from 'express';
|
| 3 |
+
import { existsSync } from 'node:fs';
|
| 4 |
+
import { dirname, resolve } from 'node:path';
|
| 5 |
+
import { fileURLToPath } from 'node:url';
|
| 6 |
+
|
| 7 |
+
import { createApp } from './app.js';
|
| 8 |
+
|
| 9 |
+
const serverDirectory = dirname(fileURLToPath(import.meta.url));
|
| 10 |
+
dotenv.config();
|
| 11 |
+
dotenv.config({ path: resolve(serverDirectory, '..', '..', '.env') });
|
| 12 |
+
|
| 13 |
+
const port = Number(process.env.PORT ?? 3000);
|
| 14 |
+
const app = createApp();
|
| 15 |
+
const staticDirectory = resolve(serverDirectory, '..', '..', 'dist', 'mplus-studio', 'browser');
|
| 16 |
+
|
| 17 |
+
if (existsSync(staticDirectory)) {
|
| 18 |
+
app.use(express.static(staticDirectory));
|
| 19 |
+
app.get(/^(?!\/api\/).*/, (_request, response) => {
|
| 20 |
+
response.sendFile(resolve(staticDirectory, 'index.html'));
|
| 21 |
+
});
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
app.listen(port, () => {
|
| 25 |
+
console.log(`Mplus Studio backend listening on http://localhost:${port}`);
|
| 26 |
+
});
|
backend/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2022",
|
| 4 |
+
"module": "NodeNext",
|
| 5 |
+
"moduleResolution": "NodeNext",
|
| 6 |
+
"strict": true,
|
| 7 |
+
"esModuleInterop": true,
|
| 8 |
+
"forceConsistentCasingInFileNames": true,
|
| 9 |
+
"skipLibCheck": true,
|
| 10 |
+
"outDir": "dist",
|
| 11 |
+
"types": ["node", "vitest/globals"]
|
| 12 |
+
},
|
| 13 |
+
"include": ["src/**/*.ts"],
|
| 14 |
+
"exclude": ["dist"]
|
| 15 |
+
}
|
coding-guidelines.md
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Coding Guidelines
|
| 2 |
+
|
| 3 |
+
These guidelines are the default standard for this repository. Source code, comments, identifiers, documentation inside the codebase, and commit-facing technical language should be written in English.
|
| 4 |
+
|
| 5 |
+
The goal is boring, maintainable software: small files, clear boundaries, explicit names, predictable control flow, and changes that are easy to review.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Core Principles
|
| 10 |
+
|
| 11 |
+
- Prefer clarity over cleverness. A straightforward solution that everyone can maintain beats a compact abstraction.
|
| 12 |
+
- Keep changes small and goal-driven. Every changed line should map to the current task.
|
| 13 |
+
- Optimize for reading. Code is read more often than it is written.
|
| 14 |
+
- Apply KISS first, DRY second. Duplication is acceptable until the shared concept is real and stable.
|
| 15 |
+
- Follow SOLID pragmatically. Do not introduce interfaces or classes just to satisfy a pattern.
|
| 16 |
+
- Use early returns and guard clauses to keep the happy path shallow.
|
| 17 |
+
- Avoid speculative configuration, compatibility layers, or extension points unless there is a current consumer.
|
| 18 |
+
- Do not mix orchestration, IO, parsing, domain decisions, and UI rendering in the same function or component.
|
| 19 |
+
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
## Architecture Boundaries
|
| 23 |
+
|
| 24 |
+
### Electron Desktop
|
| 25 |
+
|
| 26 |
+
- Main process code owns OS, filesystem, child processes, IPC registration, native dialogs, and app lifecycle.
|
| 27 |
+
- Renderer code owns UI, local UI state, visual composition, and user interactions.
|
| 28 |
+
- Preload code is the only bridge. Keep exposed APIs narrow and typed.
|
| 29 |
+
- IPC handlers should be thin. Move command resolution, env parsing, process management, and report detection into focused modules.
|
| 30 |
+
- Avoid long `*.ipc.ts` files. Split by capability, for example `test-runner`, `local-apps`, `agent-session`, `reports`, `permissions`.
|
| 31 |
+
- Never let renderer components know command-line construction details. Renderer passes intent; main process resolves execution.
|
| 32 |
+
|
| 33 |
+
### Agent Runner
|
| 34 |
+
|
| 35 |
+
- Provider adapters should translate between Specwright contracts and provider APIs only.
|
| 36 |
+
- Shared generation flow belongs outside individual providers.
|
| 37 |
+
- Keep transport concerns separate from protocol concerns. HTTP, SSE, CLI, and ACP should not be interleaved in one long function.
|
| 38 |
+
- Provider selection and environment parsing belong in the registry/config layer, not inside generation logic.
|
| 39 |
+
|
| 40 |
+
### Plugin Runtime
|
| 41 |
+
|
| 42 |
+
- `stepHelpers.js` remains the single source of truth for `FIELD_TYPES`.
|
| 43 |
+
- `testDataGenerator.js` remains the single source of truth for generated test data behavior.
|
| 44 |
+
- Step files import `Given`, `When`, `Then`, `Before`, `After`, and `expect` from `fixtures.js` only.
|
| 45 |
+
- Generated tests should prefer stable user-observable behavior over implementation details.
|
| 46 |
+
- Fixture changes must preserve normal Playwright behavior first. Special modes, such as CDP, must be explicit opt-in.
|
| 47 |
+
|
| 48 |
+
---
|
| 49 |
+
|
| 50 |
+
## File Size And Module Shape
|
| 51 |
+
|
| 52 |
+
- Target files under 250 lines.
|
| 53 |
+
- Treat files over 400 lines as refactor candidates.
|
| 54 |
+
- Treat files over 800 lines as architectural debt that needs an extraction plan.
|
| 55 |
+
- A file should have one primary reason to change.
|
| 56 |
+
- Extract by responsibility, not by syntax. Do not create a `utils.ts` dumping ground.
|
| 57 |
+
- Prefer feature folders with local helpers near their consumer.
|
| 58 |
+
- Keep public APIs small. Export only what another module actually uses.
|
| 59 |
+
- Avoid barrels when they hide dependency direction or create circular imports.
|
| 60 |
+
|
| 61 |
+
Good extraction targets:
|
| 62 |
+
|
| 63 |
+
- Pure parsing and normalization functions.
|
| 64 |
+
- Process spawning and lifecycle management.
|
| 65 |
+
- External API clients.
|
| 66 |
+
- UI subcomponents with independent state or layout.
|
| 67 |
+
- State selectors and derived view models.
|
| 68 |
+
- Reusable test fixtures or environment adapters.
|
| 69 |
+
|
| 70 |
+
Bad extraction targets:
|
| 71 |
+
|
| 72 |
+
- Single-use wrappers with vague names.
|
| 73 |
+
- Abstract base classes without multiple stable implementations.
|
| 74 |
+
- Generic helpers named `common`, `misc`, `utils`, or `helpers` without a domain noun.
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
## Naming
|
| 79 |
+
|
| 80 |
+
- Use English names everywhere.
|
| 81 |
+
- Use descriptive names. Avoid one-letter variables except conventional local coordinates or indexes in tiny scopes.
|
| 82 |
+
- File names should describe the thing they contain.
|
| 83 |
+
- Avoid suffixes unless they add real information. Keep `.spec` for tests.
|
| 84 |
+
|
| 85 |
+
```text
|
| 86 |
+
zone-editor.ts not zone-editor.component.ts
|
| 87 |
+
fonts.ts not fonts.service.ts, if it is a helper rather than an injectable service
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
- Event handlers start with `on`.
|
| 91 |
+
|
| 92 |
+
```ts
|
| 93 |
+
// Bad
|
| 94 |
+
(click)="zoneSelect.emit(zone)"
|
| 95 |
+
|
| 96 |
+
// Good
|
| 97 |
+
(click)="onZoneClick(zone)"
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
- Injected service properties must describe the service.
|
| 101 |
+
|
| 102 |
+
```ts
|
| 103 |
+
// Bad
|
| 104 |
+
private readonly storage = inject(SettingsService);
|
| 105 |
+
|
| 106 |
+
// Good
|
| 107 |
+
private readonly settingsService = inject(SettingsService);
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
- Injection tokens are `UPPER_SNAKE_CASE`.
|
| 111 |
+
|
| 112 |
+
```ts
|
| 113 |
+
export const ZONE_COMPONENTS = new InjectionToken<ZoneComponent[]>('Zone components');
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
- Library components use the `mpo` prefix. CSS classes use dashed-case.
|
| 117 |
+
- Prefer domain names over technical names: `resolveRunCommand` is better than `processInput`.
|
| 118 |
+
- Boolean names should read as predicates: `isRunning`, `hasAuth`, `canRetry`, `shouldStream`.
|
| 119 |
+
|
| 120 |
+
---
|
| 121 |
+
|
| 122 |
+
## TypeScript
|
| 123 |
+
|
| 124 |
+
- Prefer explicit return types for exported functions, public methods, IPC handlers, and async functions.
|
| 125 |
+
- Avoid `any`. If the shape is unknown, use `unknown` and narrow it.
|
| 126 |
+
- Avoid type assertions. Prefer type guards, schema validation, or typed APIs.
|
| 127 |
+
- Use `undefined` instead of `null` unless an external API requires `null`.
|
| 128 |
+
- Always use braces for control flow.
|
| 129 |
+
- Put `else` on a new line when it is needed. Prefer early return over `else` after a guard.
|
| 130 |
+
- Prefer `for...of` over `forEach`, especially for async code and better stack traces.
|
| 131 |
+
- Store repeated expressions in a named `const`.
|
| 132 |
+
- Use shorthand object notation.
|
| 133 |
+
- Keep filter predicates inline when they are trivial. Extract only if the predicate has a domain name and is reused.
|
| 134 |
+
- Use discriminated unions for state machines instead of loosely related booleans.
|
| 135 |
+
- Use `Record<string, unknown>` carefully. Prefer named types for cross-module contracts.
|
| 136 |
+
|
| 137 |
+
```ts
|
| 138 |
+
// Bad
|
| 139 |
+
function run(input: any) {
|
| 140 |
+
if (input.enabled) {
|
| 141 |
+
doWork(input.value);
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
// Good
|
| 146 |
+
function run(input: RunInput): void {
|
| 147 |
+
if (!input.enabled) {
|
| 148 |
+
return;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
doWork(input.value);
|
| 152 |
+
}
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
---
|
| 156 |
+
|
| 157 |
+
## Functions
|
| 158 |
+
|
| 159 |
+
- A function should do one thing at one level of abstraction.
|
| 160 |
+
- Prefer functions under 40 lines. Longer functions need a reason.
|
| 161 |
+
- Use guard clauses for invalid, empty, or unsupported cases.
|
| 162 |
+
- Keep the main path left-aligned.
|
| 163 |
+
- Avoid boolean parameters when they change behavior significantly. Use an options object or split the function.
|
| 164 |
+
- Avoid hidden side effects in functions that look like queries.
|
| 165 |
+
- Do not catch errors only to rethrow the same error.
|
| 166 |
+
- When catching errors, add context or recover intentionally.
|
| 167 |
+
- Name functions after the outcome, not the implementation detail.
|
| 168 |
+
|
| 169 |
+
```ts
|
| 170 |
+
// Bad
|
| 171 |
+
function handle(data: RawIssue): Issue {
|
| 172 |
+
if (data.fields) {
|
| 173 |
+
if (data.fields.title) {
|
| 174 |
+
return mapIssue(data);
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
throw new Error('Invalid issue');
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
// Good
|
| 181 |
+
function parseIssue(data: RawIssue): Issue {
|
| 182 |
+
if (!data.fields?.title) {
|
| 183 |
+
throw new Error('Issue title is required');
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
return mapIssue(data);
|
| 187 |
+
}
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+
---
|
| 191 |
+
|
| 192 |
+
## Classes And Services
|
| 193 |
+
|
| 194 |
+
- Prefer functions and small modules until state or lifecycle makes a class useful.
|
| 195 |
+
- Classes should have one responsibility.
|
| 196 |
+
- Keep constructor work minimal. Start IO explicitly.
|
| 197 |
+
- Class members are ordered: `public` then `protected` then `private`.
|
| 198 |
+
- Keep inputs and outputs grouped.
|
| 199 |
+
- Methods follow the same order: public, protected, private.
|
| 200 |
+
|
| 201 |
+
```ts
|
| 202 |
+
export class MyComponent {
|
| 203 |
+
public readonly type = input<string>();
|
| 204 |
+
public readonly valueChanged = output<string>();
|
| 205 |
+
|
| 206 |
+
protected readonly zones = this.zoneService.zones;
|
| 207 |
+
|
| 208 |
+
private readonly settingsService = inject(SettingsService);
|
| 209 |
+
|
| 210 |
+
public save(): void {
|
| 211 |
+
// ...
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
protected onZoneClick(zone: Zone): void {
|
| 215 |
+
// ...
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
private buildPayload(): Payload {
|
| 219 |
+
// ...
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
```
|
| 223 |
+
|
| 224 |
+
---
|
| 225 |
+
|
| 226 |
+
## React And Renderer Code
|
| 227 |
+
|
| 228 |
+
- Components should primarily render. Move command resolution, parsing, persistence, and IO into hooks or services.
|
| 229 |
+
- Split components when separate parts have separate state, effects, or responsibilities.
|
| 230 |
+
- Avoid `useMemo` and `useCallback` by default. Use them only for expensive work, stable dependencies required by a child, or existing project patterns.
|
| 231 |
+
- Effects synchronize with external systems. Do not use effects to derive state that can be computed during render.
|
| 232 |
+
- Prefer custom hooks for reusable UI behavior, not for hiding unrelated complexity.
|
| 233 |
+
- Zustand stores should expose focused actions and selectors. Avoid one store becoming an application service locator.
|
| 234 |
+
- Keep renderer state serializable where possible.
|
| 235 |
+
- Do not construct shell commands in React components.
|
| 236 |
+
|
| 237 |
+
---
|
| 238 |
+
|
| 239 |
+
## Angular, Signals, And Resources
|
| 240 |
+
|
| 241 |
+
- Do not use `this` in templates.
|
| 242 |
+
- Do not call getters or methods from templates. Use signals or computed values.
|
| 243 |
+
- A signal that never changes is a constant.
|
| 244 |
+
- Do not wrap signals in `params` unnecessarily. Pass the signal reference when possible.
|
| 245 |
+
- Store signal output in a local variable when it is read more than once.
|
| 246 |
+
- Always check `hasValue()` before `value()` in reactive contexts.
|
| 247 |
+
- Use `equal: compareObjects` for object-valued signals where supported.
|
| 248 |
+
- Expose `FormControl` values as signals instead of duplicating writable state.
|
| 249 |
+
- Use `rxResource` with `timer` for periodic fetching.
|
| 250 |
+
- Use `OnDestroy` when lifecycle cleanup is required.
|
| 251 |
+
- Use `HostListener` when component inputs need to be checked.
|
| 252 |
+
- Services should be provided at the narrowest useful scope.
|
| 253 |
+
- If a file creates no injectable state and only transforms data, it is a helper, not a service.
|
| 254 |
+
|
| 255 |
+
---
|
| 256 |
+
|
| 257 |
+
## Templates
|
| 258 |
+
|
| 259 |
+
- Keep templates declarative.
|
| 260 |
+
- Move multi-statement template logic into an event handler.
|
| 261 |
+
- HTML attributes go on new lines when the element becomes hard to scan.
|
| 262 |
+
- Attribute order: `class`, `[style]`, `[class]`, bindings, events.
|
| 263 |
+
- Use two spaces for indentation. Do not use tabs.
|
| 264 |
+
- Put `@else` on a new line.
|
| 265 |
+
- Use self-closing tags with a space before the slash.
|
| 266 |
+
|
| 267 |
+
```html
|
| 268 |
+
<mpo-carousel />
|
| 269 |
+
```
|
| 270 |
+
|
| 271 |
+
---
|
| 272 |
+
|
| 273 |
+
## CSS And SCSS
|
| 274 |
+
|
| 275 |
+
- Prefer design tokens and theme variables over literal colors.
|
| 276 |
+
- Keep CSS close to the component or feature when possible.
|
| 277 |
+
- Large global stylesheets must be split by layer: tokens, base, layout, components, utilities.
|
| 278 |
+
- Avoid unused styles. Delete styles when deleting the UI that uses them.
|
| 279 |
+
- Avoid selector chains that depend on deep DOM structure.
|
| 280 |
+
- Do not use `::ng-deep`. Use `:host`, component APIs, or explicit CSS variables.
|
| 281 |
+
- CSS class names are dashed-case and domain-specific.
|
| 282 |
+
|
| 283 |
+
---
|
| 284 |
+
|
| 285 |
+
## Error Handling And Logging
|
| 286 |
+
|
| 287 |
+
- Error messages should say what failed and include the relevant context.
|
| 288 |
+
- Do not swallow errors silently unless the operation is truly optional.
|
| 289 |
+
- Logs should be actionable. Avoid noisy logs in hot paths.
|
| 290 |
+
- Main-process logs should include the subsystem prefix.
|
| 291 |
+
- Do not log secrets, tokens, full auth payloads, or private user content.
|
| 292 |
+
- Prefer typed result objects for expected failures and exceptions for unexpected failures.
|
| 293 |
+
|
| 294 |
+
---
|
| 295 |
+
|
| 296 |
+
## Testing
|
| 297 |
+
|
| 298 |
+
- Tests should prove behavior, not implementation details.
|
| 299 |
+
- Add or update tests when changing command resolution, process management, provider behavior, fixtures, or generated-output contracts.
|
| 300 |
+
- Playwright assertions should prefer user-visible results and stable accessibility selectors.
|
| 301 |
+
- Avoid fallback assertions that can pass without proving the scenario.
|
| 302 |
+
- When adding a bug fix, reproduce the bug first where feasible.
|
| 303 |
+
- Keep test helpers small and named after user intent.
|
| 304 |
+
|
| 305 |
+
---
|
| 306 |
+
|
| 307 |
+
## Comments And Documentation
|
| 308 |
+
|
| 309 |
+
- Comments must be in English.
|
| 310 |
+
- Explain why, not what the next line already says.
|
| 311 |
+
- Prefer deleting misleading comments over updating around them.
|
| 312 |
+
- Public architecture decisions should live in `docs/`, not as long comments in code.
|
| 313 |
+
- If code needs a long comment to be understandable, consider extracting names or functions first.
|
| 314 |
+
|
| 315 |
+
---
|
| 316 |
+
|
| 317 |
+
## Review Checklist
|
| 318 |
+
|
| 319 |
+
Before opening or merging a change, check:
|
| 320 |
+
|
| 321 |
+
- Is the main path easy to read without jumping across many files?
|
| 322 |
+
- Did the change stay inside the right architectural boundary?
|
| 323 |
+
- Did any file exceed 400 lines or become noticeably more mixed in responsibility?
|
| 324 |
+
- Are names specific enough to understand intent without comments?
|
| 325 |
+
- Are invalid states impossible or guarded early?
|
| 326 |
+
- Are errors actionable and free of secrets?
|
| 327 |
+
- Are tests or verification commands included for behavior changes?
|
| 328 |
+
- Did you remove unused imports, types, functions, files, and CSS?
|
| 329 |
+
- Did you avoid adding abstractions with only one speculative consumer?
|
| 330 |
+
|
| 331 |
+
---
|
| 332 |
+
|
| 333 |
+
## Repository-Specific Rules
|
| 334 |
+
|
| 335 |
+
- Node must satisfy the repository engine: `>=20.20.2`.
|
| 336 |
+
- Use `pnpm` from the workspace root for installs and workspace commands.
|
| 337 |
+
- Do not run package installs from nested application folders.
|
| 338 |
+
- Keep `.gitignore` in the project root unless a nested repository explicitly requires its own.
|
| 339 |
+
- Generated or scaffolded plugin behavior must preserve existing consumer projects unless a migration is explicit.
|
| 340 |
+
- Desktop builds require `@specwright/agent-runner` to be built when packaging or testing packaged flows.
|
| 341 |
+
- `agent-runner` is CommonJS output; MCP server is ESM/plain JS. Do not blur module-system assumptions.
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
app:
|
| 3 |
+
build: .
|
| 4 |
+
ports:
|
| 5 |
+
- "3000:3000"
|
| 6 |
+
environment:
|
| 7 |
+
PORT: "3000"
|
| 8 |
+
GENERATION_PROVIDER: kaggle
|
| 9 |
+
KAGGLE_COMMAND: kaggle
|
| 10 |
+
KAGGLE_ACCELERATOR: NvidiaTeslaT4
|
| 11 |
+
KAGGLE_USE_SMALL_DECODER: "0"
|
| 12 |
+
KAGGLE_INFERENCE_STEPS: "4"
|
| 13 |
+
KAGGLE_CACHE_KERNEL_REF: jolvangeest/mplus-flux2-cache
|
| 14 |
+
KAGGLE_POLL_INTERVAL_MS: "5000"
|
| 15 |
+
KAGGLE_POLL_TIMEOUT_MS: "7200000"
|
| 16 |
+
DATABASE_URL: postgresql://mplus:mplus@postgres:5432/mplus_studio
|
| 17 |
+
S3_ENDPOINT: http://minio:9000
|
| 18 |
+
S3_BUCKET: mplus-studio
|
| 19 |
+
S3_ACCESS_KEY_ID: mplus
|
| 20 |
+
S3_SECRET_ACCESS_KEY: mplus-studio-secret
|
| 21 |
+
S3_FORCE_PATH_STYLE: "1"
|
| 22 |
+
depends_on:
|
| 23 |
+
- postgres
|
| 24 |
+
- minio
|
| 25 |
+
volumes:
|
| 26 |
+
- kaggle-sessions:/app/data/kaggle-sessions
|
| 27 |
+
|
| 28 |
+
postgres:
|
| 29 |
+
image: postgres:16-alpine
|
| 30 |
+
environment:
|
| 31 |
+
POSTGRES_DB: mplus_studio
|
| 32 |
+
POSTGRES_USER: mplus
|
| 33 |
+
POSTGRES_PASSWORD: mplus
|
| 34 |
+
ports:
|
| 35 |
+
- "5432:5432"
|
| 36 |
+
volumes:
|
| 37 |
+
- postgres-data:/var/lib/postgresql/data
|
| 38 |
+
|
| 39 |
+
minio:
|
| 40 |
+
image: minio/minio:latest
|
| 41 |
+
command: server /data --console-address ":9001"
|
| 42 |
+
environment:
|
| 43 |
+
MINIO_ROOT_USER: mplus
|
| 44 |
+
MINIO_ROOT_PASSWORD: mplus-studio-secret
|
| 45 |
+
ports:
|
| 46 |
+
- "9000:9000"
|
| 47 |
+
- "9001:9001"
|
| 48 |
+
volumes:
|
| 49 |
+
- minio-data:/data
|
| 50 |
+
|
| 51 |
+
volumes:
|
| 52 |
+
postgres-data:
|
| 53 |
+
minio-data:
|
| 54 |
+
kaggle-sessions:
|
docs/demo-menu-burger.jpg
ADDED
|
Git LFS Details
|
docs/demo-menu-drink.jpg
ADDED
|
Git LFS Details
|
docs/demo-menu-fries.jpg
ADDED
|
Git LFS Details
|
docs/demo-menu-result-20260531022723.png
ADDED
|
Git LFS Details
|
docs/multi-user-architecture.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Multi-User Architecture
|
| 2 |
+
|
| 3 |
+
Mplus Studio can run in one hosted Docker environment for multiple users, but Kaggle auth must be isolated per user.
|
| 4 |
+
|
| 5 |
+
## Services
|
| 6 |
+
|
| 7 |
+
- App: Angular frontend served by the Node backend.
|
| 8 |
+
- PostgreSQL: users, sessions, jobs, ownership, metrics, and audit records.
|
| 9 |
+
- MinIO: S3-compatible object storage for uploaded images, generated outputs, and reports.
|
| 10 |
+
- Kaggle CLI: executed server-side only, scoped to the current user's auth context.
|
| 11 |
+
|
| 12 |
+
## User Flow
|
| 13 |
+
|
| 14 |
+
Product language should stay simple:
|
| 15 |
+
|
| 16 |
+
```text
|
| 17 |
+
Productafbeeldingen maken
|
| 18 |
+
Inloggen en starten
|
| 19 |
+
Je kunt nu productafbeeldingen maken
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
The regular user flow should not mention Kaggle, GPU, kernel, notebook, provider, S3, or database.
|
| 23 |
+
|
| 24 |
+
## Kaggle Isolation
|
| 25 |
+
|
| 26 |
+
Each user needs one isolated Kaggle auth context.
|
| 27 |
+
|
| 28 |
+
For CLI-based auth, use a per-user config directory:
|
| 29 |
+
|
| 30 |
+
```text
|
| 31 |
+
/app/data/kaggle-sessions/<user-id-or-session-id>/
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
Every Kaggle command for that user must run with:
|
| 35 |
+
|
| 36 |
+
```text
|
| 37 |
+
KAGGLE_CONFIG_DIR=/app/data/kaggle-sessions/<user-id-or-session-id>
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
Never expose Kaggle auth files to the browser.
|
| 41 |
+
|
| 42 |
+
## Storage Model
|
| 43 |
+
|
| 44 |
+
PostgreSQL stores metadata and ownership. MinIO stores binary assets.
|
| 45 |
+
|
| 46 |
+
Suggested object keys:
|
| 47 |
+
|
| 48 |
+
```text
|
| 49 |
+
users/<user-id>/jobs/<job-id>/inputs/<file-name>
|
| 50 |
+
users/<user-id>/jobs/<job-id>/outputs/generated.png
|
| 51 |
+
users/<user-id>/jobs/<job-id>/report/result.md
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
## Next Implementation Step
|
| 55 |
+
|
| 56 |
+
The current MVP still generates directly from the request payload. The next backend step is to add:
|
| 57 |
+
|
| 58 |
+
- session/user table;
|
| 59 |
+
- job table;
|
| 60 |
+
- S3 upload/download helpers;
|
| 61 |
+
- async job status endpoint;
|
| 62 |
+
- per-user Kaggle config resolution.
|
e2e/fixtures/menu-burger.jpg
ADDED
|
Git LFS Details
|
e2e/fixtures/menu-drink.jpg
ADDED
|
Git LFS Details
|
e2e/fixtures/menu-fries.jpg
ADDED
|
Git LFS Details
|
e2e/studio-flow.spec.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from '@playwright/test';
|
| 2 |
+
import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises';
|
| 3 |
+
import path from 'node:path';
|
| 4 |
+
|
| 5 |
+
const demoMode = process.env.E2E_DEMO_MODE === '1';
|
| 6 |
+
const demoDelayMs = 2000;
|
| 7 |
+
const demoImagePaths = [
|
| 8 |
+
path.join(process.cwd(), 'e2e', 'fixtures', 'menu-burger.jpg'),
|
| 9 |
+
path.join(process.cwd(), 'e2e', 'fixtures', 'menu-drink.jpg'),
|
| 10 |
+
path.join(process.cwd(), 'e2e', 'fixtures', 'menu-fries.jpg'),
|
| 11 |
+
];
|
| 12 |
+
const demoOutputDirectory = path.join(process.cwd(), 'test-results', 'e2e', 'kaggle-demo');
|
| 13 |
+
const demoRunId = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
|
| 14 |
+
const prompt = 'Maak een professionele fastfood menu deal met burger, drinken en friet op een schone witte menu-achtergrond';
|
| 15 |
+
|
| 16 |
+
type GenerationMetrics = {
|
| 17 |
+
durationSeconds?: number;
|
| 18 |
+
inferenceSeconds?: number;
|
| 19 |
+
modelLoadSeconds?: number;
|
| 20 |
+
setupSeconds?: number;
|
| 21 |
+
steps?: number;
|
| 22 |
+
gpuName?: string;
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
async function waitForDemo(): Promise<void> {
|
| 26 |
+
if (!demoMode) {
|
| 27 |
+
return;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, demoDelayMs));
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
test('generates a real menu deal image through Kaggle', async ({ page }) => {
|
| 34 |
+
test.setTimeout(7_500_000);
|
| 35 |
+
|
| 36 |
+
await page.goto('/');
|
| 37 |
+
await waitForDemo();
|
| 38 |
+
|
| 39 |
+
await expect(page.getByRole('heading', { name: 'Maak betere productbeelden.' })).toBeVisible();
|
| 40 |
+
await expect(page.getByRole('button', { name: 'Maak nieuwe beelden' })).toBeDisabled();
|
| 41 |
+
|
| 42 |
+
await page.locator('input[type="file"]:not([webkitdirectory])').setInputFiles(demoImagePaths);
|
| 43 |
+
await waitForDemo();
|
| 44 |
+
|
| 45 |
+
await expect(page.getByText('menu-burger.jpg')).toBeVisible();
|
| 46 |
+
await expect(page.getByText('menu-drink.jpg')).toBeVisible();
|
| 47 |
+
await expect(page.getByText('menu-fries.jpg')).toBeVisible();
|
| 48 |
+
|
| 49 |
+
await page.getByLabel('Type beeld').selectOption('menu');
|
| 50 |
+
await page.getByLabel('Preset').selectOption('studio');
|
| 51 |
+
await page.getByLabel('Formaat').selectOption('1:1');
|
| 52 |
+
await page.getByLabel('Gewenste uitstraling').fill(prompt);
|
| 53 |
+
await waitForDemo();
|
| 54 |
+
|
| 55 |
+
const generationResponsePromise = page.waitForResponse('http://127.0.0.1:3000/api/generations', { timeout: 7_200_000 });
|
| 56 |
+
|
| 57 |
+
await page.getByRole('button', { name: 'Maak nieuwe beelden' }).click();
|
| 58 |
+
await expect(page.getByRole('button', { name: 'Beelden maken...' })).toBeVisible();
|
| 59 |
+
await waitForDemo();
|
| 60 |
+
|
| 61 |
+
const generationResponse = await generationResponsePromise;
|
| 62 |
+
const generationBody = await generationResponse.text();
|
| 63 |
+
if (generationResponse.status() !== 202) {
|
| 64 |
+
await writeFailureReport(generationResponse.status(), generationBody);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
expect(generationResponse.status(), generationBody).toBe(202);
|
| 68 |
+
const jobId = parseGenerationJobId(generationBody);
|
| 69 |
+
const completedGenerationResponse = await page.waitForResponse(async (response) => {
|
| 70 |
+
if (response.url() !== `http://127.0.0.1:3000/api/generations/${jobId}` || response.status() !== 200) {
|
| 71 |
+
return false;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
const body = await response.text();
|
| 75 |
+
return parseGenerationJobStatus(body) === 'completed';
|
| 76 |
+
}, { timeout: 7_200_000 });
|
| 77 |
+
const completedGenerationBody = await completedGenerationResponse.text();
|
| 78 |
+
const generationMetrics = parseGenerationMetrics(completedGenerationBody);
|
| 79 |
+
|
| 80 |
+
await expect(page.getByRole('button', { name: 'Maak nieuwe beelden' })).toBeVisible();
|
| 81 |
+
await expect(page.getByText(/beeld is klaar voor controle\./).first()).toBeVisible({ timeout: 30_000 });
|
| 82 |
+
await waitForDemo();
|
| 83 |
+
|
| 84 |
+
await page.locator('.image-card').first().getByRole('button', { name: 'Clean studio' }).click();
|
| 85 |
+
await expect(page.locator('.image-card').first().getByText(/beeld is klaar voor controle\./)).toBeVisible();
|
| 86 |
+
await waitForDemo();
|
| 87 |
+
|
| 88 |
+
const generatedSource = await page.locator('.image-card').first().locator('.generated img').getAttribute('src');
|
| 89 |
+
expect(generatedSource?.startsWith('data:image/')).toBe(true);
|
| 90 |
+
|
| 91 |
+
if (generatedSource !== null) {
|
| 92 |
+
await writeDemoReport(generatedSource, generationMetrics);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
await page.locator('.image-card').first().getByRole('button', { name: 'Verwijder' }).click();
|
| 96 |
+
await waitForDemo();
|
| 97 |
+
await expect(page.getByText('menu-burger.jpg')).not.toBeVisible();
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
async function writeDemoReport(generatedSource: string, metrics: GenerationMetrics | undefined): Promise<void> {
|
| 101 |
+
const originalFileNames = ['input-menu-burger.jpg', 'input-menu-drink.jpg', 'input-menu-fries.jpg'];
|
| 102 |
+
const generatedFileName = `generated-${demoRunId}.png`;
|
| 103 |
+
const reportFileName = 'result.md';
|
| 104 |
+
|
| 105 |
+
await resetDemoOutputDirectory();
|
| 106 |
+
await mkdir(demoOutputDirectory, { recursive: true });
|
| 107 |
+
for (const [index, imagePath] of demoImagePaths.entries()) {
|
| 108 |
+
await copyFile(imagePath, path.join(demoOutputDirectory, originalFileNames[index]));
|
| 109 |
+
}
|
| 110 |
+
const generatedImage = decodeDataUrl(generatedSource);
|
| 111 |
+
await writeFile(path.join(demoOutputDirectory, generatedFileName), generatedImage);
|
| 112 |
+
await writeFile(path.join(demoOutputDirectory, 'generated.png'), generatedImage);
|
| 113 |
+
await writeFile(
|
| 114 |
+
path.join(demoOutputDirectory, reportFileName),
|
| 115 |
+
[
|
| 116 |
+
'# Kaggle FLUX.2 klein Production Menu E2E Result',
|
| 117 |
+
'',
|
| 118 |
+
'## Prompt',
|
| 119 |
+
'',
|
| 120 |
+
prompt,
|
| 121 |
+
'',
|
| 122 |
+
'## Input Images',
|
| 123 |
+
'',
|
| 124 |
+
...originalFileNames.map((fileName) => ``),
|
| 125 |
+
'',
|
| 126 |
+
'## Timing',
|
| 127 |
+
'',
|
| 128 |
+
...buildTimingLines(metrics),
|
| 129 |
+
'',
|
| 130 |
+
'## Visual Acceptance Criteria',
|
| 131 |
+
'',
|
| 132 |
+
'- One composed menu-deal image, not a copied collage.',
|
| 133 |
+
'- White menu-board/product photography background.',
|
| 134 |
+
'- Burger, exactly one drink, and exactly one side item must be visible and correctly scaled.',
|
| 135 |
+
'- No duplicate drinks, duplicate sides, or extra products.',
|
| 136 |
+
'- No added brand logos, prices, labels, or promotional text.',
|
| 137 |
+
'',
|
| 138 |
+
'## Generated Image',
|
| 139 |
+
'',
|
| 140 |
+
``,
|
| 141 |
+
'',
|
| 142 |
+
].join('\n'),
|
| 143 |
+
);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
function buildTimingLines(metrics: GenerationMetrics | undefined): string[] {
|
| 147 |
+
if (metrics === undefined) {
|
| 148 |
+
return ['- Model inference: not reported by provider.'];
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
return [
|
| 152 |
+
`- Model inference only: ${formatSeconds(metrics.inferenceSeconds)}`,
|
| 153 |
+
`- Model load: ${formatSeconds(metrics.modelLoadSeconds)}`,
|
| 154 |
+
`- Setup before model load: ${formatSeconds(metrics.setupSeconds)}`,
|
| 155 |
+
`- Total Kaggle kernel runtime: ${formatSeconds(metrics.durationSeconds)}`,
|
| 156 |
+
`- Steps: ${metrics.steps ?? 'unknown'}`,
|
| 157 |
+
`- GPU: ${metrics.gpuName ?? 'unknown'}`,
|
| 158 |
+
];
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
function formatSeconds(seconds: number | undefined): string {
|
| 162 |
+
if (seconds === undefined) {
|
| 163 |
+
return 'unknown';
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
return `${seconds.toFixed(2)}s`;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
function parseGenerationMetrics(responseBody: string): GenerationMetrics | undefined {
|
| 170 |
+
const response = JSON.parse(responseBody);
|
| 171 |
+
if (!hasMetrics(response)) {
|
| 172 |
+
return undefined;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
return response.result?.metrics ?? response.metrics;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
function parseGenerationJobId(responseBody: string): string {
|
| 179 |
+
const response = JSON.parse(responseBody);
|
| 180 |
+
if (!hasJobId(response)) {
|
| 181 |
+
throw new Error('Generation response did not include a job id');
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
return response.jobId;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
function parseGenerationJobStatus(responseBody: string): string | undefined {
|
| 188 |
+
const response = JSON.parse(responseBody);
|
| 189 |
+
if (typeof response !== 'object' || response === null || !('status' in response)) {
|
| 190 |
+
return undefined;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
const status = (<{ status?: unknown }>response).status;
|
| 194 |
+
return typeof status === 'string' ? status : undefined;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
function hasMetrics(value: unknown): value is { metrics: GenerationMetrics } {
|
| 198 |
+
if (typeof value !== 'object' || value === null) {
|
| 199 |
+
return false;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
const response = <{ metrics?: unknown; result?: { metrics?: unknown } }>value;
|
| 203 |
+
const metrics = response.result?.metrics ?? response.metrics;
|
| 204 |
+
return typeof metrics === 'object' && metrics !== null;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
function hasJobId(value: unknown): value is { jobId: string } {
|
| 208 |
+
if (typeof value !== 'object' || value === null || !('jobId' in value)) {
|
| 209 |
+
return false;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
return typeof (<{ jobId?: unknown }>value).jobId === 'string';
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
async function writeFailureReport(status: number, responseBody: string): Promise<void> {
|
| 216 |
+
const originalFileNames = ['input-menu-burger.jpg', 'input-menu-drink.jpg', 'input-menu-fries.jpg'];
|
| 217 |
+
const reportFileName = 'result.md';
|
| 218 |
+
|
| 219 |
+
await resetDemoOutputDirectory();
|
| 220 |
+
await mkdir(demoOutputDirectory, { recursive: true });
|
| 221 |
+
for (const [index, imagePath] of demoImagePaths.entries()) {
|
| 222 |
+
await copyFile(imagePath, path.join(demoOutputDirectory, originalFileNames[index]));
|
| 223 |
+
}
|
| 224 |
+
await writeFile(
|
| 225 |
+
path.join(demoOutputDirectory, reportFileName),
|
| 226 |
+
[
|
| 227 |
+
'# Kaggle FLUX.2 klein Production Menu E2E Result',
|
| 228 |
+
'',
|
| 229 |
+
'## Status',
|
| 230 |
+
'',
|
| 231 |
+
`Generation failed with HTTP ${status}.`,
|
| 232 |
+
'',
|
| 233 |
+
'## Prompt',
|
| 234 |
+
'',
|
| 235 |
+
prompt,
|
| 236 |
+
'',
|
| 237 |
+
'## Input Images',
|
| 238 |
+
'',
|
| 239 |
+
...originalFileNames.map((fileName) => ``),
|
| 240 |
+
'',
|
| 241 |
+
'## Error',
|
| 242 |
+
'',
|
| 243 |
+
'```json',
|
| 244 |
+
responseBody,
|
| 245 |
+
'```',
|
| 246 |
+
'',
|
| 247 |
+
].join('\n'),
|
| 248 |
+
);
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
async function resetDemoOutputDirectory(): Promise<void> {
|
| 252 |
+
await rm(demoOutputDirectory, { force: true, recursive: true });
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
function decodeDataUrl(dataUrl: string): Buffer {
|
| 256 |
+
const base64Marker = ';base64,';
|
| 257 |
+
const markerIndex = dataUrl.indexOf(base64Marker);
|
| 258 |
+
if (markerIndex === -1) {
|
| 259 |
+
throw new Error('Generated image was not a base64 data URL');
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
return Buffer.from(dataUrl.slice(markerIndex + base64Marker.length), 'base64');
|
| 263 |
+
}
|
kaggle-flux2-cache/kernel-metadata.example.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "jolvangeest/mplus-flux2-cache",
|
| 3 |
+
"title": "mplus-flux2-cache",
|
| 4 |
+
"code_file": "kernel.py",
|
| 5 |
+
"language": "python",
|
| 6 |
+
"kernel_type": "script",
|
| 7 |
+
"is_private": true,
|
| 8 |
+
"enable_gpu": true,
|
| 9 |
+
"enable_internet": true,
|
| 10 |
+
"dataset_sources": [],
|
| 11 |
+
"competition_sources": [],
|
| 12 |
+
"kernel_sources": [],
|
| 13 |
+
"model_sources": []
|
| 14 |
+
}
|
kaggle-flux2-cache/kernel-metadata.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "jolvangeest/mplus-flux2-cache",
|
| 3 |
+
"title": "mplus-flux2-cache",
|
| 4 |
+
"code_file": "kernel.py",
|
| 5 |
+
"language": "python",
|
| 6 |
+
"kernel_type": "script",
|
| 7 |
+
"is_private": true,
|
| 8 |
+
"enable_gpu": true,
|
| 9 |
+
"enable_internet": true,
|
| 10 |
+
"dataset_sources": [],
|
| 11 |
+
"competition_sources": [],
|
| 12 |
+
"kernel_sources": [],
|
| 13 |
+
"model_sources": []
|
| 14 |
+
}
|
kaggle-flux2-cache/kernel.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import subprocess
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
OUTPUT_ROOT = Path("/kaggle/working")
|
| 10 |
+
CACHE_ROOT = OUTPUT_ROOT / "hf-cache"
|
| 11 |
+
DEPENDENCY_ROOT = OUTPUT_ROOT / "python-deps"
|
| 12 |
+
MODEL_ID = "black-forest-labs/FLUX.2-klein-4B"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def main() -> None:
|
| 16 |
+
os.environ["HF_HOME"] = str(CACHE_ROOT)
|
| 17 |
+
install_dependencies()
|
| 18 |
+
add_dependency_path()
|
| 19 |
+
cache_model()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def cache_model() -> None:
|
| 23 |
+
import torch
|
| 24 |
+
from diffusers import Flux2KleinPipeline
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
Flux2KleinPipeline.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16)
|
| 28 |
+
(OUTPUT_ROOT / "cache-ready.txt").write_text(f"Cached {MODEL_ID} in {CACHE_ROOT}\n")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def install_dependencies() -> None:
|
| 32 |
+
if has_required_dependencies():
|
| 33 |
+
return
|
| 34 |
+
|
| 35 |
+
DEPENDENCY_ROOT.mkdir(parents=True, exist_ok=True)
|
| 36 |
+
subprocess.check_call([
|
| 37 |
+
sys.executable,
|
| 38 |
+
"-m",
|
| 39 |
+
"pip",
|
| 40 |
+
"install",
|
| 41 |
+
"--quiet",
|
| 42 |
+
"--upgrade",
|
| 43 |
+
"--prefer-binary",
|
| 44 |
+
"--target",
|
| 45 |
+
str(DEPENDENCY_ROOT),
|
| 46 |
+
"diffusers",
|
| 47 |
+
"accelerate",
|
| 48 |
+
"transformers",
|
| 49 |
+
"sentencepiece",
|
| 50 |
+
])
|
| 51 |
+
add_dependency_path()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def add_dependency_path() -> None:
|
| 55 |
+
if DEPENDENCY_ROOT.exists() and str(DEPENDENCY_ROOT) not in sys.path:
|
| 56 |
+
sys.path.insert(0, str(DEPENDENCY_ROOT))
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def has_required_dependencies() -> bool:
|
| 60 |
+
try:
|
| 61 |
+
import accelerate
|
| 62 |
+
import diffusers
|
| 63 |
+
import transformers
|
| 64 |
+
except ImportError:
|
| 65 |
+
return False
|
| 66 |
+
|
| 67 |
+
return True
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
if __name__ == "__main__":
|
| 71 |
+
main()
|
kaggle-flux2-klein/kernel.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import io
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import subprocess
|
| 8 |
+
import sys
|
| 9 |
+
import time
|
| 10 |
+
import traceback
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
INPUT_ROOT = Path("/kaggle/input")
|
| 16 |
+
OUTPUT_ROOT = Path("/kaggle/working")
|
| 17 |
+
SOURCE_ROOT = Path(__file__).parent
|
| 18 |
+
MODEL_ID = "__MPLUS_MODEL_ID__"
|
| 19 |
+
SMALL_DECODER_ID = "black-forest-labs/FLUX.2-small-decoder"
|
| 20 |
+
EMBEDDED_REQUEST_JSON = "__MPLUS_REQUEST_JSON__"
|
| 21 |
+
EMBEDDED_IMAGES_JSON = "__MPLUS_IMAGES_JSON__"
|
| 22 |
+
USE_SMALL_DECODER = "__MPLUS_USE_SMALL_DECODER__" == "1"
|
| 23 |
+
INFERENCE_STEPS = int("__MPLUS_INFERENCE_STEPS__")
|
| 24 |
+
SIZE_BY_RATIO = {
|
| 25 |
+
"16:9": (768, 432),
|
| 26 |
+
"9:16": (432, 768),
|
| 27 |
+
"1:1": (512, 512),
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def main() -> None:
|
| 32 |
+
try:
|
| 33 |
+
run_generation()
|
| 34 |
+
except Exception as error:
|
| 35 |
+
write_error_result(error)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def write_error_result(error: Exception) -> None:
|
| 39 |
+
(OUTPUT_ROOT / "results.json").write_text(json.dumps({
|
| 40 |
+
"__error": str(error),
|
| 41 |
+
"__traceback": traceback.format_exc(),
|
| 42 |
+
}))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def run_generation() -> None:
|
| 46 |
+
started_at = time.perf_counter()
|
| 47 |
+
configure_model_cache()
|
| 48 |
+
add_dependency_path()
|
| 49 |
+
install_dependencies()
|
| 50 |
+
|
| 51 |
+
import torch
|
| 52 |
+
from diffusers import AutoencoderKLFlux2, Flux2KleinPipeline
|
| 53 |
+
from PIL import Image
|
| 54 |
+
|
| 55 |
+
request, input_directory, embedded_images = load_request()
|
| 56 |
+
|
| 57 |
+
width, height = SIZE_BY_RATIO.get(request["ratio"], (512, 512))
|
| 58 |
+
|
| 59 |
+
torch.backends.cuda.matmul.allow_tf32 = True
|
| 60 |
+
model_load_started_at = time.perf_counter()
|
| 61 |
+
if USE_SMALL_DECODER:
|
| 62 |
+
vae = AutoencoderKLFlux2.from_pretrained(SMALL_DECODER_ID, torch_dtype=torch.bfloat16)
|
| 63 |
+
pipe = Flux2KleinPipeline.from_pretrained(MODEL_ID, vae=vae, torch_dtype=torch.bfloat16)
|
| 64 |
+
else:
|
| 65 |
+
pipe = Flux2KleinPipeline.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16)
|
| 66 |
+
pipe.enable_model_cpu_offload()
|
| 67 |
+
model_load_seconds = time.perf_counter() - model_load_started_at
|
| 68 |
+
|
| 69 |
+
results: dict[str, Any] = {}
|
| 70 |
+
inference_seconds = 0.0
|
| 71 |
+
if request.get("mode") == "menu":
|
| 72 |
+
inference_started_at = time.perf_counter()
|
| 73 |
+
generated_image = pipe(
|
| 74 |
+
image=build_menu_reference(request["images"], input_directory, embedded_images, Image),
|
| 75 |
+
prompt=request["prompt"],
|
| 76 |
+
height=height,
|
| 77 |
+
width=width,
|
| 78 |
+
guidance_scale=1.0,
|
| 79 |
+
num_inference_steps=INFERENCE_STEPS,
|
| 80 |
+
).images[0]
|
| 81 |
+
inference_seconds += time.perf_counter() - inference_started_at
|
| 82 |
+
|
| 83 |
+
output_file = OUTPUT_ROOT / "menu-deal-generated.png"
|
| 84 |
+
generated_image.save(output_file)
|
| 85 |
+
results[request["images"][0]["name"]] = output_file.name
|
| 86 |
+
else:
|
| 87 |
+
inference_seconds = generate_product_images(request, input_directory, embedded_images, Image, pipe, height, width, results)
|
| 88 |
+
|
| 89 |
+
results["__metrics"] = {
|
| 90 |
+
"durationSeconds": round(time.perf_counter() - started_at, 2),
|
| 91 |
+
"inferenceSeconds": round(inference_seconds, 2),
|
| 92 |
+
"modelLoadSeconds": round(model_load_seconds, 2),
|
| 93 |
+
"setupSeconds": round(model_load_started_at - started_at, 2),
|
| 94 |
+
"height": height,
|
| 95 |
+
"width": width,
|
| 96 |
+
"steps": INFERENCE_STEPS,
|
| 97 |
+
"smallDecoder": USE_SMALL_DECODER,
|
| 98 |
+
"cudaAvailable": torch.cuda.is_available(),
|
| 99 |
+
"gpuName": torch.cuda.get_device_name(0) if torch.cuda.is_available() else "none",
|
| 100 |
+
"hfHome": os.environ.get("HF_HOME", ""),
|
| 101 |
+
"mode": request.get("mode", "product"),
|
| 102 |
+
"modelId": request.get("modelId", MODEL_ID),
|
| 103 |
+
}
|
| 104 |
+
(OUTPUT_ROOT / "results.json").write_text(json.dumps(results))
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def generate_product_images(
|
| 108 |
+
request: dict[str, Any],
|
| 109 |
+
input_directory: Path | None,
|
| 110 |
+
embedded_images: dict[str, str],
|
| 111 |
+
image_module: Any,
|
| 112 |
+
pipe: Any,
|
| 113 |
+
height: int,
|
| 114 |
+
width: int,
|
| 115 |
+
results: dict[str, Any],
|
| 116 |
+
) -> float:
|
| 117 |
+
inference_seconds = 0.0
|
| 118 |
+
for image_request in request["images"]:
|
| 119 |
+
source_image = load_source_image(image_request["name"], input_directory, embedded_images, image_module)
|
| 120 |
+
inference_started_at = time.perf_counter()
|
| 121 |
+
generated_image = pipe(
|
| 122 |
+
image=source_image,
|
| 123 |
+
prompt=request["prompt"],
|
| 124 |
+
height=height,
|
| 125 |
+
width=width,
|
| 126 |
+
guidance_scale=1.0,
|
| 127 |
+
num_inference_steps=INFERENCE_STEPS,
|
| 128 |
+
).images[0]
|
| 129 |
+
inference_seconds += time.perf_counter() - inference_started_at
|
| 130 |
+
|
| 131 |
+
output_file = OUTPUT_ROOT / f"{Path(image_request['name']).stem}-generated.png"
|
| 132 |
+
generated_image.save(output_file)
|
| 133 |
+
results[image_request["name"]] = output_file.name
|
| 134 |
+
|
| 135 |
+
return inference_seconds
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def add_dependency_path() -> None:
|
| 139 |
+
for dependency_root in INPUT_ROOT.glob("*/python-deps"):
|
| 140 |
+
if dependency_root.exists() and str(dependency_root) not in sys.path:
|
| 141 |
+
sys.path.insert(0, str(dependency_root))
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def build_menu_reference(image_requests: list[dict[str, Any]], input_directory: Path | None, embedded_images: dict[str, str], image_module: Any) -> Any:
|
| 145 |
+
canvas = image_module.new("RGB", (768, 512), "white")
|
| 146 |
+
if len(image_requests) == 0:
|
| 147 |
+
raise ValueError("At least one image is required for menu generation")
|
| 148 |
+
|
| 149 |
+
slot_width = canvas.width // min(len(image_requests), 3)
|
| 150 |
+
for index, image_request in enumerate(image_requests[:3]):
|
| 151 |
+
source_image = load_source_image(image_request["name"], input_directory, embedded_images, image_module)
|
| 152 |
+
source_image.thumbnail((slot_width - 48, 360))
|
| 153 |
+
x_position = (slot_width * index) + ((slot_width - source_image.width) // 2)
|
| 154 |
+
y_position = 86 + ((360 - source_image.height) // 2)
|
| 155 |
+
canvas.paste(source_image, (x_position, y_position))
|
| 156 |
+
|
| 157 |
+
return canvas
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def safe_name(file_name: str) -> str:
|
| 161 |
+
return "".join(character if character.isalnum() or character in "._-" else "_" for character in Path(file_name).name)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def load_source_image(image_name: str, input_directory: Path | None, embedded_images: dict[str, str], image_module: Any) -> Any:
|
| 165 |
+
safe_image_name = safe_name(image_name)
|
| 166 |
+
if input_directory is not None:
|
| 167 |
+
return image_module.open(input_directory / safe_image_name).convert("RGB")
|
| 168 |
+
|
| 169 |
+
return image_module.open(io.BytesIO(base64.b64decode(embedded_images[safe_image_name]))).convert("RGB")
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def find_request_path() -> Path | None:
|
| 173 |
+
input_request_paths = list(INPUT_ROOT.glob("*/request.json"))
|
| 174 |
+
if input_request_paths:
|
| 175 |
+
return input_request_paths[0]
|
| 176 |
+
|
| 177 |
+
source_request_path = SOURCE_ROOT / "request.json"
|
| 178 |
+
if source_request_path.exists():
|
| 179 |
+
return source_request_path
|
| 180 |
+
|
| 181 |
+
return None
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def load_request() -> tuple[dict[str, Any], Path | None, dict[str, str]]:
|
| 185 |
+
request_path = find_request_path()
|
| 186 |
+
if request_path is not None:
|
| 187 |
+
request = json.loads(request_path.read_text())
|
| 188 |
+
return request, request_path.parent, {}
|
| 189 |
+
|
| 190 |
+
request = json.loads(EMBEDDED_REQUEST_JSON)
|
| 191 |
+
embedded_images = json.loads(EMBEDDED_IMAGES_JSON)
|
| 192 |
+
if not isinstance(embedded_images, dict):
|
| 193 |
+
raise ValueError("Embedded images payload must be a JSON object")
|
| 194 |
+
|
| 195 |
+
return request, None, embedded_images
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def install_dependencies() -> None:
|
| 199 |
+
if has_required_dependencies():
|
| 200 |
+
return
|
| 201 |
+
|
| 202 |
+
subprocess.check_call([
|
| 203 |
+
sys.executable,
|
| 204 |
+
"-m",
|
| 205 |
+
"pip",
|
| 206 |
+
"install",
|
| 207 |
+
"--quiet",
|
| 208 |
+
"--upgrade",
|
| 209 |
+
"--prefer-binary",
|
| 210 |
+
"diffusers",
|
| 211 |
+
"accelerate",
|
| 212 |
+
"transformers",
|
| 213 |
+
"sentencepiece",
|
| 214 |
+
])
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def has_required_dependencies() -> bool:
|
| 218 |
+
try:
|
| 219 |
+
import accelerate
|
| 220 |
+
import diffusers
|
| 221 |
+
import transformers
|
| 222 |
+
except ImportError:
|
| 223 |
+
return False
|
| 224 |
+
|
| 225 |
+
return True
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def configure_model_cache() -> None:
|
| 229 |
+
cache_root = find_cache_root()
|
| 230 |
+
if cache_root is None:
|
| 231 |
+
os.environ["HF_HOME"] = str(OUTPUT_ROOT / "hf-cache")
|
| 232 |
+
return
|
| 233 |
+
|
| 234 |
+
os.environ["HF_HOME"] = str(cache_root)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def find_cache_root() -> Path | None:
|
| 238 |
+
for cache_directory in INPUT_ROOT.glob("*/hf-cache"):
|
| 239 |
+
if cache_directory.exists():
|
| 240 |
+
return cache_directory
|
| 241 |
+
|
| 242 |
+
return None
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
if __name__ == "__main__":
|
| 246 |
+
main()
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "mplus-studio",
|
| 3 |
+
"version": "0.0.0",
|
| 4 |
+
"scripts": {
|
| 5 |
+
"ng": "ng",
|
| 6 |
+
"start": "ng serve",
|
| 7 |
+
"start:prod": "npm --workspace backend run start",
|
| 8 |
+
"build": "ng build",
|
| 9 |
+
"build:all": "npm run build && npm run backend:build",
|
| 10 |
+
"watch": "ng build --watch --configuration development",
|
| 11 |
+
"test": "npm run test:unit && npm run test:component && npm run test:integration",
|
| 12 |
+
"test:unit": "ng test --watch=false --include \"src/**/*.unit.spec.ts\"",
|
| 13 |
+
"test:component": "ng test --watch=false --include \"src/**/*.component.spec.ts\"",
|
| 14 |
+
"test:integration": "ng test --watch=false --include \"src/**/*.integration.spec.ts\"",
|
| 15 |
+
"test:e2e": "playwright test",
|
| 16 |
+
"test:e2e:kaggle": "playwright test e2e/studio-flow.spec.ts",
|
| 17 |
+
"deploy:check": "uv run python scripts/deploy-check.py",
|
| 18 |
+
"deploy:huggingface": "uv run --with huggingface_hub python scripts/deploy-huggingface.py",
|
| 19 |
+
"kaggle:cache:push": "uv run --with kaggle kaggle kernels push -p kaggle-flux2-cache --accelerator NvidiaTeslaT4 -t 7200",
|
| 20 |
+
"test:backend": "npm --workspace backend test",
|
| 21 |
+
"backend:dev": "npm --workspace backend run dev",
|
| 22 |
+
"backend:build": "npm --workspace backend run build",
|
| 23 |
+
"test:all": "npm run test && npm run test:backend && npm run test:e2e"
|
| 24 |
+
},
|
| 25 |
+
"private": true,
|
| 26 |
+
"workspaces": ["backend"],
|
| 27 |
+
"packageManager": "npm@10.8.2",
|
| 28 |
+
"dependencies": {
|
| 29 |
+
"@angular/common": "^21.2.0",
|
| 30 |
+
"@angular/compiler": "^21.2.0",
|
| 31 |
+
"@angular/core": "^21.2.0",
|
| 32 |
+
"@angular/forms": "^21.2.0",
|
| 33 |
+
"@angular/platform-browser": "^21.2.0",
|
| 34 |
+
"@angular/router": "^21.2.0",
|
| 35 |
+
"rxjs": "~7.8.0",
|
| 36 |
+
"tslib": "^2.3.0"
|
| 37 |
+
},
|
| 38 |
+
"devDependencies": {
|
| 39 |
+
"@angular/build": "^21.2.13",
|
| 40 |
+
"@angular/cli": "^21.2.13",
|
| 41 |
+
"@angular/compiler-cli": "^21.2.0",
|
| 42 |
+
"@playwright/test": "^1.60.0",
|
| 43 |
+
"jsdom": "^29.1.1",
|
| 44 |
+
"prettier": "^3.8.1",
|
| 45 |
+
"typescript": "~5.9.2",
|
| 46 |
+
"vitest": "^4.1.7"
|
| 47 |
+
}
|
| 48 |
+
}
|
playwright.config.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import 'dotenv/config';
|
| 2 |
+
import { defineConfig, devices } from '@playwright/test';
|
| 3 |
+
|
| 4 |
+
const frontendPort = process.env.E2E_PORT ?? '5000';
|
| 5 |
+
const frontendUrl = `http://127.0.0.1:${frontendPort}`;
|
| 6 |
+
|
| 7 |
+
export default defineConfig({
|
| 8 |
+
testDir: './e2e',
|
| 9 |
+
outputDir: './test-results/e2e/artifacts',
|
| 10 |
+
reporter: [
|
| 11 |
+
['list'],
|
| 12 |
+
['json', { outputFile: 'test-results/e2e/playwright-json/results.json' }],
|
| 13 |
+
['html', { outputFolder: 'test-results/e2e/html', open: 'never' }],
|
| 14 |
+
],
|
| 15 |
+
use: {
|
| 16 |
+
baseURL: frontendUrl,
|
| 17 |
+
trace: 'retain-on-failure',
|
| 18 |
+
screenshot: 'only-on-failure',
|
| 19 |
+
},
|
| 20 |
+
webServer: [
|
| 21 |
+
{
|
| 22 |
+
command: 'npm run backend:dev',
|
| 23 |
+
url: 'http://127.0.0.1:3000/health',
|
| 24 |
+
reuseExistingServer: true,
|
| 25 |
+
timeout: 120_000,
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
command: `npm run start -- --host 127.0.0.1 --port ${frontendPort}`,
|
| 29 |
+
url: frontendUrl,
|
| 30 |
+
reuseExistingServer: true,
|
| 31 |
+
timeout: 120_000,
|
| 32 |
+
},
|
| 33 |
+
],
|
| 34 |
+
projects: [
|
| 35 |
+
{
|
| 36 |
+
name: 'chromium',
|
| 37 |
+
use: { ...devices['Desktop Chrome'] },
|
| 38 |
+
},
|
| 39 |
+
],
|
| 40 |
+
});
|
public/favicon.ico
ADDED
|
|
scripts/deploy-check.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import webbrowser
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
TOKEN_URLS = {
|
| 8 |
+
"HF_TOKEN": "https://huggingface.co/settings/tokens",
|
| 9 |
+
"KAGGLE_API_TOKEN": "https://www.kaggle.com/settings/api",
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
REQUIRED_ENV = ["HF_TOKEN", "HF_SPACE_ID", "KAGGLE_USERNAME", "KAGGLE_API_TOKEN"]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def main() -> None:
|
| 16 |
+
missing = [name for name in REQUIRED_ENV if not os.environ.get(name)]
|
| 17 |
+
|
| 18 |
+
print("Mplus Studio deploy check")
|
| 19 |
+
print("")
|
| 20 |
+
for name in REQUIRED_ENV:
|
| 21 |
+
status = "set" if os.environ.get(name) else "missing"
|
| 22 |
+
print(f"{name}: {status}")
|
| 23 |
+
|
| 24 |
+
if not missing:
|
| 25 |
+
print("")
|
| 26 |
+
print("All required deploy variables are set. Run: npm run deploy:huggingface")
|
| 27 |
+
return
|
| 28 |
+
|
| 29 |
+
print("")
|
| 30 |
+
print("Missing variables:")
|
| 31 |
+
for name in missing:
|
| 32 |
+
print(f"- {name}")
|
| 33 |
+
|
| 34 |
+
for name in missing:
|
| 35 |
+
url = TOKEN_URLS.get(name)
|
| 36 |
+
if url is not None:
|
| 37 |
+
print(f"Opening {name} setup page: {url}")
|
| 38 |
+
webbrowser.open(url)
|
| 39 |
+
|
| 40 |
+
print("")
|
| 41 |
+
print("Set the variables in this PowerShell session, then run deploy again:")
|
| 42 |
+
print('$env:HF_TOKEN="hf_..."')
|
| 43 |
+
print('$env:HF_SPACE_ID="your-huggingface-name/mplus-studio"')
|
| 44 |
+
print('$env:KAGGLE_USERNAME="your-kaggle-username"')
|
| 45 |
+
print('$env:KAGGLE_API_TOKEN="your-kaggle-api-token"')
|
| 46 |
+
print("npm run deploy:huggingface")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
if __name__ == "__main__":
|
| 50 |
+
main()
|
scripts/deploy-huggingface.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from huggingface_hub import HfApi
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
REQUIRED_ENV = ["HF_TOKEN", "HF_SPACE_ID", "KAGGLE_USERNAME", "KAGGLE_API_TOKEN"]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def main() -> None:
|
| 13 |
+
missing = [name for name in REQUIRED_ENV if not os.environ.get(name)]
|
| 14 |
+
if missing:
|
| 15 |
+
raise SystemExit(f"Missing required environment variables: {', '.join(missing)}")
|
| 16 |
+
|
| 17 |
+
project_root = Path(__file__).resolve().parents[1]
|
| 18 |
+
repo_id = os.environ["HF_SPACE_ID"]
|
| 19 |
+
token = os.environ["HF_TOKEN"]
|
| 20 |
+
api = HfApi(token=token)
|
| 21 |
+
|
| 22 |
+
api.create_repo(
|
| 23 |
+
repo_id=repo_id,
|
| 24 |
+
repo_type="space",
|
| 25 |
+
space_sdk="docker",
|
| 26 |
+
private=True,
|
| 27 |
+
exist_ok=True,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
secrets = {
|
| 31 |
+
"PORT": "7860",
|
| 32 |
+
"GENERATION_PROVIDER": "kaggle",
|
| 33 |
+
"COMMERCIAL_MODE": "1",
|
| 34 |
+
"FLUX_MODEL_ID": os.environ.get("FLUX_MODEL_ID", "black-forest-labs/FLUX.2-klein-4B"),
|
| 35 |
+
"KAGGLE_COMMAND": "kaggle",
|
| 36 |
+
"KAGGLE_USERNAME": os.environ["KAGGLE_USERNAME"],
|
| 37 |
+
"KAGGLE_API_TOKEN": os.environ["KAGGLE_API_TOKEN"],
|
| 38 |
+
"KAGGLE_ACCELERATOR": os.environ.get("KAGGLE_ACCELERATOR", "NvidiaTeslaT4"),
|
| 39 |
+
"KAGGLE_USE_SMALL_DECODER": os.environ.get("KAGGLE_USE_SMALL_DECODER", "0"),
|
| 40 |
+
"KAGGLE_INFERENCE_STEPS": os.environ.get("KAGGLE_INFERENCE_STEPS", "4"),
|
| 41 |
+
"KAGGLE_CACHE_KERNEL_REF": os.environ.get("KAGGLE_CACHE_KERNEL_REF", "jolvangeest/mplus-flux2-cache"),
|
| 42 |
+
"KAGGLE_POLL_INTERVAL_MS": os.environ.get("KAGGLE_POLL_INTERVAL_MS", "5000"),
|
| 43 |
+
"KAGGLE_POLL_TIMEOUT_MS": os.environ.get("KAGGLE_POLL_TIMEOUT_MS", "7200000"),
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
for key, value in secrets.items():
|
| 47 |
+
api.add_space_secret(repo_id=repo_id, key=key, value=value)
|
| 48 |
+
|
| 49 |
+
api.upload_folder(
|
| 50 |
+
repo_id=repo_id,
|
| 51 |
+
repo_type="space",
|
| 52 |
+
folder_path=str(project_root),
|
| 53 |
+
commit_message="Deploy Mplus Studio",
|
| 54 |
+
ignore_patterns=[
|
| 55 |
+
".git/*",
|
| 56 |
+
".angular/*",
|
| 57 |
+
".kaggle-work/*",
|
| 58 |
+
".env",
|
| 59 |
+
"backend/dist/*",
|
| 60 |
+
"backend/node_modules/*",
|
| 61 |
+
"dist/*",
|
| 62 |
+
"graphify-out/*",
|
| 63 |
+
"node_modules/*",
|
| 64 |
+
"test-results/*",
|
| 65 |
+
],
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
print(f"Deployed to https://huggingface.co/spaces/{repo_id}")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
if __name__ == "__main__":
|
| 72 |
+
main()
|
src/app/app.component.spec.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
| 2 |
+
import { App } from './app';
|
| 3 |
+
|
| 4 |
+
describe('App component', () => {
|
| 5 |
+
let fixture: ComponentFixture<App>;
|
| 6 |
+
|
| 7 |
+
beforeEach(async () => {
|
| 8 |
+
await TestBed.configureTestingModule({ imports: [App] }).compileComponents();
|
| 9 |
+
fixture = TestBed.createComponent(App);
|
| 10 |
+
fixture.detectChanges();
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
it('renders the studio shell and disabled generate button', () => {
|
| 14 |
+
const text = fixture.nativeElement.textContent.replace(/\s+/g, ' ').trim();
|
| 15 |
+
|
| 16 |
+
expect(text).toContain('MplusKassa Studio MVP');
|
| 17 |
+
expect(text).toContain('Maak betere productbeelden.');
|
| 18 |
+
expect(fixture.nativeElement.querySelector('.primary').disabled).toBe(true);
|
| 19 |
+
});
|
| 20 |
+
});
|
src/app/app.config.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
| 2 |
+
|
| 3 |
+
export const appConfig: ApplicationConfig = {
|
| 4 |
+
providers: [provideBrowserGlobalErrorListeners()],
|
| 5 |
+
};
|
src/app/app.html
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<main class="studio-shell">
|
| 2 |
+
<section class="workflow-board" aria-label="Beeldworkflow">
|
| 3 |
+
<header class="board-header">
|
| 4 |
+
<div>
|
| 5 |
+
<p class="eyebrow">MplusKassa Studio MVP</p>
|
| 6 |
+
<h1>Maak betere productbeelden.</h1>
|
| 7 |
+
</div>
|
| 8 |
+
<p class="next-action">{{ nextAction() }}</p>
|
| 9 |
+
</header>
|
| 10 |
+
|
| 11 |
+
<div class="workflow-grid">
|
| 12 |
+
<aside class="step-rail" aria-label="Stappen">
|
| 13 |
+
<ol class="monitor-steps">
|
| 14 |
+
@for (step of monitorSteps(); track step.label) {
|
| 15 |
+
<li [class]="step.state">
|
| 16 |
+
<strong>{{ step.label }}</strong>
|
| 17 |
+
<span>{{ step.detail }}</span>
|
| 18 |
+
</li>
|
| 19 |
+
}
|
| 20 |
+
</ol>
|
| 21 |
+
</aside>
|
| 22 |
+
|
| 23 |
+
<section class="action-panel" aria-label="Volgende actie">
|
| 24 |
+
<div class="upload-card">
|
| 25 |
+
<label>
|
| 26 |
+
<input
|
| 27 |
+
type="file"
|
| 28 |
+
accept="image/*"
|
| 29 |
+
multiple
|
| 30 |
+
(change)="onFilesSelected($event)"
|
| 31 |
+
/>
|
| 32 |
+
<span>Foto’s kiezen</span>
|
| 33 |
+
<small>{{ selectedImageCountLabel() }}</small>
|
| 34 |
+
</label>
|
| 35 |
+
|
| 36 |
+
<label>
|
| 37 |
+
<input
|
| 38 |
+
type="file"
|
| 39 |
+
accept="image/*"
|
| 40 |
+
multiple
|
| 41 |
+
webkitdirectory
|
| 42 |
+
(change)="onFilesSelected($event)"
|
| 43 |
+
/>
|
| 44 |
+
<span>Map kiezen</span>
|
| 45 |
+
<small>Meerdere foto’s tegelijk</small>
|
| 46 |
+
</label>
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
<div class="controls" aria-label="Transformatie instellingen">
|
| 50 |
+
<label>
|
| 51 |
+
Type beeld
|
| 52 |
+
<select
|
| 53 |
+
[ngModel]="mode()"
|
| 54 |
+
(ngModelChange)="mode.set($event)"
|
| 55 |
+
>
|
| 56 |
+
<option value="product">Los product</option>
|
| 57 |
+
<option value="menu">Menu deal</option>
|
| 58 |
+
</select>
|
| 59 |
+
</label>
|
| 60 |
+
|
| 61 |
+
<label>
|
| 62 |
+
Preset
|
| 63 |
+
<select
|
| 64 |
+
[ngModel]="preset()"
|
| 65 |
+
(ngModelChange)="preset.set($event)"
|
| 66 |
+
>
|
| 67 |
+
<option value="studio">Witte menu achtergrond</option>
|
| 68 |
+
<option value="food">Warme horeca sfeer</option>
|
| 69 |
+
<option value="retail">Winkelpresentatie</option>
|
| 70 |
+
<option value="premium">Luxe campagnebeeld</option>
|
| 71 |
+
</select>
|
| 72 |
+
</label>
|
| 73 |
+
|
| 74 |
+
<label>
|
| 75 |
+
Formaat
|
| 76 |
+
<select
|
| 77 |
+
[ngModel]="ratio()"
|
| 78 |
+
(ngModelChange)="ratio.set($event)"
|
| 79 |
+
>
|
| 80 |
+
<option value="16:9">Breed scherm</option>
|
| 81 |
+
<option value="9:16">Staand scherm</option>
|
| 82 |
+
<option value="1:1">Vierkant beeld</option>
|
| 83 |
+
</select>
|
| 84 |
+
</label>
|
| 85 |
+
|
| 86 |
+
<label class="prompt-field">
|
| 87 |
+
Gewenste uitstraling
|
| 88 |
+
<textarea
|
| 89 |
+
[ngModel]="prompt()"
|
| 90 |
+
(ngModelChange)="prompt.set($event)"
|
| 91 |
+
rows="2"
|
| 92 |
+
></textarea>
|
| 93 |
+
</label>
|
| 94 |
+
|
| 95 |
+
<button
|
| 96 |
+
class="primary"
|
| 97 |
+
type="button"
|
| 98 |
+
[disabled]="!hasImages() || isGenerating()"
|
| 99 |
+
(click)="onGenerateBatch()"
|
| 100 |
+
>
|
| 101 |
+
{{ isGenerating() ? 'Beelden maken...' : 'Maak nieuwe beelden' }}
|
| 102 |
+
</button>
|
| 103 |
+
</div>
|
| 104 |
+
</section>
|
| 105 |
+
|
| 106 |
+
<aside class="proof-panel" aria-label="Status en bewijs">
|
| 107 |
+
<p class="eyebrow">Status</p>
|
| 108 |
+
<h2>{{ monitor().message }}</h2>
|
| 109 |
+
<dl>
|
| 110 |
+
<div>
|
| 111 |
+
<dt>Beelden</dt>
|
| 112 |
+
<dd>{{ selectedImageCountLabel() }}</dd>
|
| 113 |
+
</div>
|
| 114 |
+
<div>
|
| 115 |
+
<dt>Referentie</dt>
|
| 116 |
+
<dd>{{ monitor().jobId }}</dd>
|
| 117 |
+
</div>
|
| 118 |
+
<div>
|
| 119 |
+
<dt>Systeem</dt>
|
| 120 |
+
<dd>{{ monitor().provider }}</dd>
|
| 121 |
+
</div>
|
| 122 |
+
</dl>
|
| 123 |
+
</aside>
|
| 124 |
+
</div>
|
| 125 |
+
</section>
|
| 126 |
+
|
| 127 |
+
@if (generationError(); as error) {
|
| 128 |
+
<p class="status-message">{{ error }}</p>
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
@if (!hasImages()) {
|
| 132 |
+
<section class="empty-state">
|
| 133 |
+
<h2>Start met productfoto’s</h2>
|
| 134 |
+
<p>De workflow houdt de volgende stap zichtbaar: uploaden, maken, controleren.</p>
|
| 135 |
+
</section>
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
<section class="image-grid">
|
| 139 |
+
@for (image of viewImages(); track image.id) {
|
| 140 |
+
<article class="image-card">
|
| 141 |
+
<header>
|
| 142 |
+
<div>
|
| 143 |
+
<h2>{{ image.name }}</h2>
|
| 144 |
+
<p>{{ image.size }} · {{ ratio() }}</p>
|
| 145 |
+
</div>
|
| 146 |
+
<button
|
| 147 |
+
class="ghost"
|
| 148 |
+
type="button"
|
| 149 |
+
(click)="removeImage(image.id)"
|
| 150 |
+
>Verwijder</button>
|
| 151 |
+
</header>
|
| 152 |
+
|
| 153 |
+
<div class="preview-pair">
|
| 154 |
+
<figure>
|
| 155 |
+
<img
|
| 156 |
+
[src]="image.url"
|
| 157 |
+
[alt]="image.name + ' origineel'"
|
| 158 |
+
/>
|
| 159 |
+
<figcaption>Origineel</figcaption>
|
| 160 |
+
</figure>
|
| 161 |
+
<figure class="generated">
|
| 162 |
+
<img
|
| 163 |
+
[src]="image.previewUrl"
|
| 164 |
+
[style.filter]="image.previewFilter"
|
| 165 |
+
[alt]="image.name + ' variant'"
|
| 166 |
+
/>
|
| 167 |
+
<figcaption>{{ image.selectedVariantData.label }}</figcaption>
|
| 168 |
+
</figure>
|
| 169 |
+
</div>
|
| 170 |
+
|
| 171 |
+
<div class="variant-row">
|
| 172 |
+
@for (variant of variants; track variant.id) {
|
| 173 |
+
<button
|
| 174 |
+
type="button"
|
| 175 |
+
[class.active]="image.selectedVariant === variant.id"
|
| 176 |
+
(click)="selectVariant(image.id, variant.id)"
|
| 177 |
+
>
|
| 178 |
+
{{ variant.label }}
|
| 179 |
+
</button>
|
| 180 |
+
}
|
| 181 |
+
</div>
|
| 182 |
+
|
| 183 |
+
<p class="note">{{ image.note }}</p>
|
| 184 |
+
</article>
|
| 185 |
+
}
|
| 186 |
+
</section>
|
| 187 |
+
</main>
|
src/app/app.integration.spec.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
| 2 |
+
import { By } from '@angular/platform-browser';
|
| 3 |
+
import { App } from './app';
|
| 4 |
+
|
| 5 |
+
describe('App studio flow', () => {
|
| 6 |
+
let fixture: ComponentFixture<App>;
|
| 7 |
+
|
| 8 |
+
beforeEach(async () => {
|
| 9 |
+
vi.stubGlobal('crypto', { randomUUID: vi.fn(() => `image-${Math.random()}`) });
|
| 10 |
+
vi.stubGlobal('fetch', vi.fn());
|
| 11 |
+
vi.stubGlobal('URL', {
|
| 12 |
+
createObjectURL: vi.fn((file: File) => `blob:${file.name}`),
|
| 13 |
+
revokeObjectURL: vi.fn(),
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
await TestBed.configureTestingModule({
|
| 17 |
+
imports: [App],
|
| 18 |
+
}).compileComponents();
|
| 19 |
+
|
| 20 |
+
fixture = TestBed.createComponent(App);
|
| 21 |
+
fixture.detectChanges();
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
afterEach(() => {
|
| 25 |
+
vi.unstubAllGlobals();
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
it('shows the empty starting state', () => {
|
| 29 |
+
expect(text()).toContain('Start met productfoto’s');
|
| 30 |
+
expect(fixture.nativeElement.querySelector('.primary').disabled).toBe(true);
|
| 31 |
+
expect(fixture.nativeElement.querySelectorAll('.image-card')).toHaveLength(0);
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
it('loads multiple images from the picker and renders previews', () => {
|
| 35 |
+
selectFiles([
|
| 36 |
+
new File(['one'], 'broodje.png', { type: 'image/png' }),
|
| 37 |
+
new File(['two'], 'koffie.jpg', { type: 'image/jpeg' }),
|
| 38 |
+
new File(['skip'], 'artikelen.csv', { type: 'text/csv' }),
|
| 39 |
+
]);
|
| 40 |
+
|
| 41 |
+
expect(text()).toContain('broodje.png');
|
| 42 |
+
expect(text()).toContain('koffie.jpg');
|
| 43 |
+
expect(text()).not.toContain('artikelen.csv');
|
| 44 |
+
expect(fixture.nativeElement.querySelectorAll('.image-card')).toHaveLength(2);
|
| 45 |
+
expect(fixture.nativeElement.querySelector('.primary').disabled).toBe(false);
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
it('updates settings, generates variants and shows the generated result', async () => {
|
| 49 |
+
mockCompletedGenerationJob('job-1', {
|
| 50 |
+
images: [
|
| 51 |
+
{
|
| 52 |
+
originalName: 'cola.webp',
|
| 53 |
+
variants: [
|
| 54 |
+
{
|
| 55 |
+
label: 'FLUX.2 Flex',
|
| 56 |
+
imageBase64: 'data:image/png;base64,generated',
|
| 57 |
+
},
|
| 58 |
+
],
|
| 59 |
+
},
|
| 60 |
+
],
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
selectFiles([new File(['one'], 'cola.webp', { type: 'image/webp' })]);
|
| 64 |
+
|
| 65 |
+
const selects = fixture.nativeElement.querySelectorAll('select') as NodeListOf<HTMLSelectElement>;
|
| 66 |
+
selects[1].value = 'premium';
|
| 67 |
+
selects[1].dispatchEvent(new Event('change'));
|
| 68 |
+
selects[2].value = '9:16';
|
| 69 |
+
selects[2].dispatchEvent(new Event('change'));
|
| 70 |
+
|
| 71 |
+
const textarea = fixture.nativeElement.querySelector('textarea') as HTMLTextAreaElement;
|
| 72 |
+
textarea.value = 'Maak een realistische koeling achtergrond';
|
| 73 |
+
textarea.dispatchEvent(new Event('input'));
|
| 74 |
+
fixture.detectChanges();
|
| 75 |
+
|
| 76 |
+
click('.primary');
|
| 77 |
+
expect(text()).toContain('Beelden maken...');
|
| 78 |
+
|
| 79 |
+
await waitForGeneration();
|
| 80 |
+
fixture.detectChanges();
|
| 81 |
+
|
| 82 |
+
expect(text()).toContain('Maak nieuwe beelden');
|
| 83 |
+
expect(text()).toContain('cola.webp');
|
| 84 |
+
expect(text()).toContain('9:16');
|
| 85 |
+
expect(text()).toContain('FLUX.2 Flex beeld is klaar voor controle.');
|
| 86 |
+
expect(fixture.nativeElement.querySelector('.generated img').src).toBe('data:image/png;base64,generated');
|
| 87 |
+
|
| 88 |
+
expect(fetch).toHaveBeenCalledWith('/api/generations', expect.objectContaining({ method: 'POST' }));
|
| 89 |
+
expect(fetch).toHaveBeenCalledWith('/api/generations/job-1');
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
it('submits menu mode with multiple products', async () => {
|
| 93 |
+
mockCompletedGenerationJob('job-menu', {
|
| 94 |
+
images: [
|
| 95 |
+
{
|
| 96 |
+
originalName: 'burger.jpg',
|
| 97 |
+
variants: [{ label: 'Menu deal', imageBase64: 'data:image/png;base64,menu' }],
|
| 98 |
+
},
|
| 99 |
+
],
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
selectFiles([
|
| 103 |
+
new File(['burger'], 'burger.jpg', { type: 'image/jpeg' }),
|
| 104 |
+
new File(['cola'], 'cola-bottle.jpg', { type: 'image/jpeg' }),
|
| 105 |
+
new File(['fries'], 'fries.jpg', { type: 'image/jpeg' }),
|
| 106 |
+
]);
|
| 107 |
+
|
| 108 |
+
const selects = fixture.nativeElement.querySelectorAll('select') as NodeListOf<HTMLSelectElement>;
|
| 109 |
+
selects[0].value = 'menu';
|
| 110 |
+
selects[0].dispatchEvent(new Event('change'));
|
| 111 |
+
fixture.detectChanges();
|
| 112 |
+
|
| 113 |
+
click('.primary');
|
| 114 |
+
await waitForGeneration();
|
| 115 |
+
fixture.detectChanges();
|
| 116 |
+
|
| 117 |
+
const form = vi.mocked(fetch).mock.calls[0][1]?.body as FormData;
|
| 118 |
+
expect(form.get('mode')).toBe('menu');
|
| 119 |
+
expect(form.getAll('images')).toHaveLength(3);
|
| 120 |
+
expect(text()).toContain('Menu deal beeld is klaar voor controle.');
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
it('shows an error when backend generation fails', async () => {
|
| 124 |
+
vi.mocked(fetch).mockResolvedValue(new Response(undefined, { status: 502 }));
|
| 125 |
+
selectFiles([new File(['one'], 'cola.webp', { type: 'image/webp' })]);
|
| 126 |
+
|
| 127 |
+
click('.primary');
|
| 128 |
+
await waitForGeneration();
|
| 129 |
+
fixture.detectChanges();
|
| 130 |
+
|
| 131 |
+
expect(text()).toContain('De beeldgenerator kon het verzoek niet verwerken:');
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
it('shows an error when an async job fails', async () => {
|
| 135 |
+
vi.mocked(fetch).mockResolvedValueOnce(new Response(
|
| 136 |
+
JSON.stringify({ jobId: 'job-failed', provider: 'kaggle', status: 'queued' }),
|
| 137 |
+
{ status: 202, headers: { 'Content-Type': 'application/json' } },
|
| 138 |
+
));
|
| 139 |
+
vi.mocked(fetch).mockResolvedValueOnce(new Response(
|
| 140 |
+
JSON.stringify({ jobId: 'job-failed', provider: 'kaggle', status: 'failed', error: 'Kaggle failed' }),
|
| 141 |
+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
| 142 |
+
));
|
| 143 |
+
selectFiles([new File(['one'], 'cola.webp', { type: 'image/webp' })]);
|
| 144 |
+
|
| 145 |
+
click('.primary');
|
| 146 |
+
await waitForGeneration();
|
| 147 |
+
fixture.detectChanges();
|
| 148 |
+
|
| 149 |
+
expect(text()).toContain('De beeldgenerator kon het verzoek niet verwerken: Kaggle failed');
|
| 150 |
+
});
|
| 151 |
+
|
| 152 |
+
it('removes an image and releases the object url', () => {
|
| 153 |
+
selectFiles([new File(['one'], 'pizza.png', { type: 'image/png' })]);
|
| 154 |
+
|
| 155 |
+
click('.ghost');
|
| 156 |
+
|
| 157 |
+
expect(text()).not.toContain('pizza.png');
|
| 158 |
+
expect(fixture.nativeElement.querySelectorAll('.image-card')).toHaveLength(0);
|
| 159 |
+
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:pizza.png');
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
function mockCompletedGenerationJob(jobId: string, result: object): void {
|
| 163 |
+
vi.mocked(fetch).mockResolvedValueOnce(new Response(
|
| 164 |
+
JSON.stringify({ jobId, provider: 'kaggle', status: 'queued' }),
|
| 165 |
+
{ status: 202, headers: { 'Content-Type': 'application/json' } },
|
| 166 |
+
));
|
| 167 |
+
vi.mocked(fetch).mockResolvedValueOnce(new Response(
|
| 168 |
+
JSON.stringify({ jobId, provider: 'kaggle', status: 'completed', result }),
|
| 169 |
+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
| 170 |
+
));
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
function selectFiles(files: File[]): void {
|
| 174 |
+
const input = <HTMLInputElement>fixture.nativeElement.querySelector('input[type="file"]');
|
| 175 |
+
Object.defineProperty(input, 'files', {
|
| 176 |
+
configurable: true,
|
| 177 |
+
value: files,
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
input.dispatchEvent(new Event('change'));
|
| 181 |
+
fixture.detectChanges();
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
function click(selector: string): void {
|
| 185 |
+
fixture.debugElement.query(By.css(selector)).nativeElement.click();
|
| 186 |
+
fixture.detectChanges();
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
function text(): string {
|
| 190 |
+
return fixture.nativeElement.textContent.replace(/\s+/g, ' ').trim();
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
async function waitForGeneration(): Promise<void> {
|
| 194 |
+
await fixture.whenStable();
|
| 195 |
+
await new Promise((resolve) => window.setTimeout(resolve));
|
| 196 |
+
await fixture.whenStable();
|
| 197 |
+
}
|
| 198 |
+
});
|
src/app/app.scss
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:host { display: block; min-height: 100dvh; color: #242424; background: #fafafa; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
| 2 |
+
.studio-shell { width: min(1220px, calc(100% - 24px)); margin: 0 auto; padding: 12px 0 32px; }
|
| 3 |
+
.workflow-board, .empty-state, .image-card { border: 1px solid #e6e6e6; border-radius: 6px; background: white; }
|
| 4 |
+
.workflow-board { min-height: calc(100dvh - 24px); padding: 18px; display: grid; grid-template-rows: auto 1fr; gap: 14px; }
|
| 5 |
+
.board-header { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 360px); gap: 18px; align-items: end; padding-bottom: 14px; border-bottom: 1px solid #eaeaea; }
|
| 6 |
+
.eyebrow { margin: 0 0 8px; font-size: 0.68rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: #e94e1b; }
|
| 7 |
+
h1, h2, p { margin: 0; }
|
| 8 |
+
h1 { font-size: clamp(2rem, 4vw, 3.6rem); line-height: 0.94; letter-spacing: -0.055em; }
|
| 9 |
+
.next-action { padding: 12px 14px; border-left: 3px solid #e94e1b; background: #fdf1ed; font-weight: 800; line-height: 1.35; }
|
| 10 |
+
.workflow-grid { display: grid; grid-template-columns: 230px minmax(420px, 1fr) 280px; gap: 14px; min-height: 0; }
|
| 11 |
+
.step-rail, .action-panel, .proof-panel { min-height: 0; }
|
| 12 |
+
.monitor-steps { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
|
| 13 |
+
.monitor-steps li { display: grid; gap: 5px; padding: 12px; border: 1px solid #eaeaea; border-radius: 4px; background: #fafafa; color: #666; }
|
| 14 |
+
.monitor-steps li.active { border-color: #e94e1b; background: #fceae4; color: #af1200; }
|
| 15 |
+
.monitor-steps li.completed { border-color: #bde5c1; background: #edf8ee; color: #24702b; }
|
| 16 |
+
.monitor-steps li.failed { border-color: #e74c3c; background: #e74c3c; color: white; }
|
| 17 |
+
.monitor-steps span { font-size: 0.82rem; line-height: 1.35; }
|
| 18 |
+
.action-panel { display: grid; grid-template-rows: 150px auto; gap: 12px; }
|
| 19 |
+
.upload-card { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; overflow: hidden; border: 1px solid #eaeaea; border-radius: 4px; background: #eaeaea; text-align: center; }
|
| 20 |
+
.upload-card label { display: grid; place-content: center; gap: 6px; cursor: pointer; background: #fafafa; transition: background 160ms ease, transform 160ms ease; }
|
| 21 |
+
.upload-card label:hover { background: #fceae4; }
|
| 22 |
+
.upload-card label:active, button:active:not(:disabled) { transform: translateY(1px); }
|
| 23 |
+
.upload-card input { position: absolute; inline-size: 1px; block-size: 1px; opacity: 0; }
|
| 24 |
+
.upload-card span { font-size: 1rem; font-weight: 800; }
|
| 25 |
+
.upload-card small, .note, .image-card header p { color: #6f6f6f; }
|
| 26 |
+
.controls { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; align-content: start; }
|
| 27 |
+
.prompt-field, .primary { grid-column: 1 / -1; }
|
| 28 |
+
label { display: grid; gap: 6px; font-size: 0.68rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: #555; }
|
| 29 |
+
select, textarea, button { font: inherit; }
|
| 30 |
+
select, textarea { width: 100%; border: 1px solid #dfdfdf; border-radius: 4px; padding: 9px 10px; background: #fafafa; color: #242424; text-transform: none; letter-spacing: normal; outline: none; transition: border-color 160ms ease, box-shadow 160ms ease; }
|
| 31 |
+
select:focus, textarea:focus { border-color: #e94e1b; box-shadow: 0 0 0 2px rgba(233, 78, 27, 0.14); }
|
| 32 |
+
textarea { min-height: 72px; resize: vertical; }
|
| 33 |
+
button { border: 0; border-radius: 4px; padding: 11px 14px; font-weight: 800; cursor: pointer; transition: background 160ms ease, color 160ms ease, opacity 160ms ease, transform 160ms ease; }
|
| 34 |
+
button:disabled { cursor: not-allowed; opacity: 0.5; }
|
| 35 |
+
.primary { min-height: 44px; background: #e94e1b; color: white; }
|
| 36 |
+
.primary:hover:not(:disabled) { background: #d92508; }
|
| 37 |
+
.proof-panel { padding: 14px; border: 1px solid #eaeaea; border-radius: 4px; background: #fafafa; }
|
| 38 |
+
.proof-panel h2 { font-size: 1.15rem; line-height: 1.15; letter-spacing: -0.025em; }
|
| 39 |
+
.proof-panel dl { display: grid; gap: 10px; margin: 18px 0 0; }
|
| 40 |
+
.proof-panel div { padding-top: 10px; border-top: 1px solid #e2e2e2; }
|
| 41 |
+
dt { font-size: 0.66rem; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase; color: #777; }
|
| 42 |
+
dd { margin: 3px 0 0; overflow: hidden; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; }
|
| 43 |
+
.empty-state, .status-message { margin-top: 12px; padding: 16px; }
|
| 44 |
+
.status-message { border-radius: 4px; background: #e74c3c; color: white; font-weight: 800; }
|
| 45 |
+
.image-grid { display: grid; gap: 12px; margin-top: 12px; }
|
| 46 |
+
.image-card { padding: 14px; }
|
| 47 |
+
.image-card header { display: flex; justify-content: space-between; gap: 14px; align-items: flex-start; margin-bottom: 14px; }
|
| 48 |
+
.image-card h2 { overflow: hidden; max-width: 520px; font-size: 1rem; text-overflow: ellipsis; white-space: nowrap; }
|
| 49 |
+
.ghost, .variant-row button { background: #fafafa; color: #242424; border: 1px solid #eaeaea; }
|
| 50 |
+
.preview-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
| 51 |
+
figure { margin: 0; overflow: hidden; border: 1px solid #eaeaea; border-radius: 4px; background: #dedede; }
|
| 52 |
+
img { display: block; width: 100%; aspect-ratio: 16 / 10; object-fit: cover; }
|
| 53 |
+
figcaption { padding: 8px 10px; font-size: 0.78rem; font-weight: 800; color: #555; }
|
| 54 |
+
.generated { background: #303030; }
|
| 55 |
+
.variant-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
| 56 |
+
.variant-row button:hover, .ghost:hover { background: #fceae4; }
|
| 57 |
+
.variant-row button.active { background: #e94e1b; color: white; border-color: #e94e1b; }
|
| 58 |
+
.note { margin-top: 12px; font-size: 0.92rem; line-height: 1.45; }
|
| 59 |
+
|
| 60 |
+
@media (max-width: 980px) {
|
| 61 |
+
.workflow-board { min-height: auto; }
|
| 62 |
+
.board-header, .workflow-grid, .preview-pair { grid-template-columns: 1fr; }
|
| 63 |
+
.action-panel { grid-template-rows: auto auto; }
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
@media (max-width: 640px) {
|
| 67 |
+
.studio-shell { width: min(100% - 20px, 720px); padding-top: 10px; }
|
| 68 |
+
.workflow-board { padding: 14px; }
|
| 69 |
+
.upload-card, .controls { grid-template-columns: 1fr; }
|
| 70 |
+
}
|
src/app/app.ts
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { CommonModule } from '@angular/common';
|
| 2 |
+
import { Component, computed, signal } from '@angular/core';
|
| 3 |
+
import { FormsModule } from '@angular/forms';
|
| 4 |
+
import { createImages, releaseImage, StudioImage } from './image-file';
|
| 5 |
+
|
| 6 |
+
type Variant = {
|
| 7 |
+
id: string;
|
| 8 |
+
label: string;
|
| 9 |
+
filter: string;
|
| 10 |
+
note: string;
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
type ViewImage = StudioImage & {
|
| 14 |
+
selectedVariantData: Variant;
|
| 15 |
+
previewUrl: string;
|
| 16 |
+
previewFilter: string;
|
| 17 |
+
note: string;
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
type GenerationResponse = {
|
| 21 |
+
provider?: string;
|
| 22 |
+
images?: Array<{
|
| 23 |
+
originalName?: string;
|
| 24 |
+
variants?: Array<{
|
| 25 |
+
imageBase64?: string;
|
| 26 |
+
imageUrl?: string;
|
| 27 |
+
label?: string;
|
| 28 |
+
}>;
|
| 29 |
+
}>;
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
type GenerationJobResponse = {
|
| 33 |
+
jobId?: string;
|
| 34 |
+
provider?: string;
|
| 35 |
+
status?: 'queued' | 'running' | 'completed' | 'failed';
|
| 36 |
+
result?: GenerationResponse;
|
| 37 |
+
error?: string;
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
type GenerationErrorResponse = {
|
| 41 |
+
error?: string;
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
type MonitorStepState = 'pending' | 'active' | 'completed' | 'failed';
|
| 45 |
+
|
| 46 |
+
type MonitorStep = {
|
| 47 |
+
label: string;
|
| 48 |
+
detail: string;
|
| 49 |
+
state: MonitorStepState;
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
type MonitorState = {
|
| 53 |
+
provider: string;
|
| 54 |
+
jobId: string;
|
| 55 |
+
status: 'idle' | 'uploading' | 'running' | 'processing' | 'completed' | 'failed';
|
| 56 |
+
message: string;
|
| 57 |
+
events: string[];
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
const generationEndpoint = getGenerationEndpoint();
|
| 61 |
+
const generationPollIntervalMs = 3000;
|
| 62 |
+
|
| 63 |
+
function getGenerationEndpoint(): string {
|
| 64 |
+
if (globalThis.location?.hostname === '127.0.0.1' && globalThis.location.port !== '3000') {
|
| 65 |
+
return 'http://127.0.0.1:3000/api/generations';
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
return '/api/generations';
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
@Component({
|
| 72 |
+
selector: 'app-root',
|
| 73 |
+
imports: [CommonModule, FormsModule],
|
| 74 |
+
templateUrl: './app.html',
|
| 75 |
+
styleUrl: './app.scss',
|
| 76 |
+
})
|
| 77 |
+
export class App {
|
| 78 |
+
protected readonly images = signal<StudioImage[]>([]);
|
| 79 |
+
protected readonly prompt = signal(
|
| 80 |
+
'Maak hiervan een professionele productfoto met mooi licht, scherpe details en een realistische schaduw',
|
| 81 |
+
);
|
| 82 |
+
protected readonly preset = signal('studio');
|
| 83 |
+
protected readonly ratio = signal('16:9');
|
| 84 |
+
protected readonly mode = signal<'product' | 'menu'>('product');
|
| 85 |
+
protected readonly isGenerating = signal(false);
|
| 86 |
+
protected readonly generationError = signal<string | undefined>(undefined);
|
| 87 |
+
protected readonly monitor = signal<MonitorState>({
|
| 88 |
+
provider: 'Nog niet gestart',
|
| 89 |
+
jobId: '-',
|
| 90 |
+
status: 'idle',
|
| 91 |
+
message: 'Kies productfoto’s en maak nieuwe beelden.',
|
| 92 |
+
events: ['Klaar om te starten.'],
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
protected readonly variants: Variant[] = [
|
| 96 |
+
{
|
| 97 |
+
id: 'clean',
|
| 98 |
+
label: 'Clean studio',
|
| 99 |
+
filter: 'contrast(1.06) saturate(1.04) brightness(1.03)',
|
| 100 |
+
note: 'Witte/lichte achtergrond, product blijft centraal.',
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
id: 'warm',
|
| 104 |
+
label: 'Warme food sfeer',
|
| 105 |
+
filter: 'contrast(1.08) saturate(1.18) sepia(0.12) brightness(1.04)',
|
| 106 |
+
note: 'Geschikt voor horeca, lunch en dagmenu.',
|
| 107 |
+
},
|
| 108 |
+
{
|
| 109 |
+
id: 'premium',
|
| 110 |
+
label: 'Premium donker',
|
| 111 |
+
filter: 'contrast(1.18) saturate(0.96) brightness(0.9)',
|
| 112 |
+
note: 'Meer diepte, luxe uitstraling en sterke schaduw.',
|
| 113 |
+
},
|
| 114 |
+
];
|
| 115 |
+
|
| 116 |
+
protected readonly hasImages = computed(() => this.images().length > 0);
|
| 117 |
+
public readonly selectedImageCountLabel = computed(() => {
|
| 118 |
+
const count = this.images().length;
|
| 119 |
+
|
| 120 |
+
if (count === 0) {
|
| 121 |
+
return 'Nog geen foto’s';
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
if (count === 1) {
|
| 125 |
+
return '1 foto klaar';
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
return `${count} foto’s klaar`;
|
| 129 |
+
});
|
| 130 |
+
public readonly nextAction = computed(() => {
|
| 131 |
+
if (!this.hasImages()) {
|
| 132 |
+
return 'Upload losse productfoto’s om te starten.';
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
if (this.isGenerating()) {
|
| 136 |
+
return 'Wacht tot het nieuwe beeld klaar is.';
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
if (this.monitor().status === 'completed') {
|
| 140 |
+
return 'Controleer het resultaat en verwijder wat je niet gebruikt.';
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
return 'Controleer de instellingen en maak nieuwe beelden.';
|
| 144 |
+
});
|
| 145 |
+
protected readonly monitorSteps = computed<MonitorStep[]>(() => {
|
| 146 |
+
const status = this.monitor().status;
|
| 147 |
+
|
| 148 |
+
return [
|
| 149 |
+
{
|
| 150 |
+
label: 'Upload',
|
| 151 |
+
detail: 'Productfoto’s worden klaargezet.',
|
| 152 |
+
state: this.resolveStepState(status, ['uploading'], ['running', 'processing', 'completed'], 'failed'),
|
| 153 |
+
},
|
| 154 |
+
{
|
| 155 |
+
label: 'Beelden maken',
|
| 156 |
+
detail: 'Nieuwe productbeelden worden gemaakt. Dit kan even duren.',
|
| 157 |
+
state: this.resolveStepState(status, ['running'], ['processing', 'completed'], 'failed'),
|
| 158 |
+
},
|
| 159 |
+
{
|
| 160 |
+
label: 'Resultaten',
|
| 161 |
+
detail: 'De nieuwe beelden worden naast het origineel gezet.',
|
| 162 |
+
state: this.resolveStepState(status, ['processing'], ['completed'], 'failed'),
|
| 163 |
+
},
|
| 164 |
+
];
|
| 165 |
+
});
|
| 166 |
+
protected readonly viewImages = computed<ViewImage[]>(() =>
|
| 167 |
+
this.images().map((image) => {
|
| 168 |
+
const selectedVariantData = this.findVariant(image.selectedVariant);
|
| 169 |
+
|
| 170 |
+
return {
|
| 171 |
+
...image,
|
| 172 |
+
selectedVariantData,
|
| 173 |
+
previewUrl: image.generatedUrl ?? image.url,
|
| 174 |
+
previewFilter: image.generatedUrl === undefined ? selectedVariantData.filter : 'none',
|
| 175 |
+
note: image.generationNote ?? selectedVariantData.note,
|
| 176 |
+
};
|
| 177 |
+
}),
|
| 178 |
+
);
|
| 179 |
+
|
| 180 |
+
protected onFilesSelected(event: Event): void {
|
| 181 |
+
const input = <HTMLInputElement>event.target;
|
| 182 |
+
const nextImages = createImages(Array.from(input.files ?? []));
|
| 183 |
+
|
| 184 |
+
this.images.update((current) => [...current, ...nextImages]);
|
| 185 |
+
input.value = '';
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
protected async onGenerateBatch(): Promise<void> {
|
| 189 |
+
if (!this.hasImages()) {
|
| 190 |
+
return;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
this.isGenerating.set(true);
|
| 194 |
+
this.generationError.set(undefined);
|
| 195 |
+
this.setMonitor('uploading', 'Productfoto’s worden voorbereid.', 'Productfoto’s en instellingen zijn ontvangen.');
|
| 196 |
+
|
| 197 |
+
try {
|
| 198 |
+
const response = await fetch(generationEndpoint, {
|
| 199 |
+
method: 'POST',
|
| 200 |
+
body: this.buildGenerationForm(),
|
| 201 |
+
});
|
| 202 |
+
|
| 203 |
+
this.setMonitor('running', 'Nieuwe productbeelden worden gemaakt.', 'De beelden worden nu gemaakt. Dit kan enkele minuten duren.');
|
| 204 |
+
|
| 205 |
+
if (!response.ok) {
|
| 206 |
+
throw new Error(await this.getGenerationResponseError(response));
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
const jobResponse = <GenerationJobResponse>await response.json();
|
| 210 |
+
if (jobResponse.jobId === undefined) {
|
| 211 |
+
throw new Error('De beeldgenerator gaf geen taaknummer terug.');
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
this.monitor.update((current) => ({
|
| 215 |
+
...current,
|
| 216 |
+
provider: this.getFriendlyProviderName(jobResponse.provider ?? current.provider),
|
| 217 |
+
jobId: jobResponse.jobId ?? current.jobId,
|
| 218 |
+
}));
|
| 219 |
+
|
| 220 |
+
const completedJob = await this.pollGenerationJob(jobResponse.jobId);
|
| 221 |
+
const generationResponse = completedJob.result;
|
| 222 |
+
if (generationResponse === undefined) {
|
| 223 |
+
throw new Error('De beeldgenerator gaf geen resultaat terug.');
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
this.setMonitor('processing', 'Resultaten worden klaargezet.', 'Nieuwe beelden worden in het overzicht geplaatst.');
|
| 227 |
+
this.applyGenerationResponse(generationResponse);
|
| 228 |
+
this.monitor.update((current) => ({
|
| 229 |
+
...current,
|
| 230 |
+
provider: this.getFriendlyProviderName(generationResponse.provider ?? current.provider),
|
| 231 |
+
jobId: completedJob.jobId ?? current.jobId,
|
| 232 |
+
status: 'completed',
|
| 233 |
+
message: 'Nieuwe productbeelden zijn klaar.',
|
| 234 |
+
events: [...current.events, 'Klaar. Bekijk en vergelijk het resultaat.'],
|
| 235 |
+
}));
|
| 236 |
+
} catch (error) {
|
| 237 |
+
const errorMessage = this.getGenerationErrorMessage(error);
|
| 238 |
+
this.generationError.set(errorMessage);
|
| 239 |
+
this.monitor.update((current) => ({
|
| 240 |
+
...current,
|
| 241 |
+
status: 'failed',
|
| 242 |
+
message: errorMessage,
|
| 243 |
+
events: [...current.events, 'Er ging iets mis tijdens het maken van de beelden.'],
|
| 244 |
+
}));
|
| 245 |
+
} finally {
|
| 246 |
+
this.isGenerating.set(false);
|
| 247 |
+
}
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
protected selectVariant(imageId: string, variantId: string): void {
|
| 251 |
+
this.images.update((images) =>
|
| 252 |
+
images.map((image) => (image.id === imageId ? { ...image, selectedVariant: variantId } : image)),
|
| 253 |
+
);
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
protected removeImage(imageId: string): void {
|
| 257 |
+
const image = this.images().find((item) => item.id === imageId);
|
| 258 |
+
if (image) {
|
| 259 |
+
releaseImage(image);
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
this.images.update((images) => images.filter((item) => item.id !== imageId));
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
private findVariant(variantId: string): Variant {
|
| 266 |
+
return this.variants.find((variant) => variant.id === variantId) ?? this.variants[0];
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
private resolveStepState(status: MonitorState['status'], activeStates: MonitorState['status'][], completedStates: MonitorState['status'][], failedState: MonitorState['status']): MonitorStepState {
|
| 270 |
+
if (status === failedState) {
|
| 271 |
+
return 'failed';
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
if (completedStates.includes(status)) {
|
| 275 |
+
return 'completed';
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
if (activeStates.includes(status)) {
|
| 279 |
+
return 'active';
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
return 'pending';
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
private setMonitor(status: MonitorState['status'], message: string, event: string): void {
|
| 286 |
+
this.monitor.update((current) => ({
|
| 287 |
+
...current,
|
| 288 |
+
provider: current.provider === 'Nog niet gestart' ? 'Beeldgenerator' : current.provider,
|
| 289 |
+
status,
|
| 290 |
+
message,
|
| 291 |
+
events: [...current.events, event],
|
| 292 |
+
}));
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
private buildGenerationForm(): FormData {
|
| 296 |
+
const form = new FormData();
|
| 297 |
+
form.append('prompt', this.prompt());
|
| 298 |
+
form.append('preset', this.preset());
|
| 299 |
+
form.append('ratio', this.ratio());
|
| 300 |
+
form.append('mode', this.mode());
|
| 301 |
+
|
| 302 |
+
for (const image of this.images()) {
|
| 303 |
+
form.append('images', image.file, image.name);
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
return form;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
private applyGenerationResponse(response: GenerationResponse): void {
|
| 310 |
+
this.images.update((images) =>
|
| 311 |
+
images.map((image) => {
|
| 312 |
+
const generatedImage = response.images?.find((item) => item.originalName === image.name);
|
| 313 |
+
const variant = generatedImage?.variants?.[0];
|
| 314 |
+
const generatedUrl = variant?.imageBase64 ?? variant?.imageUrl;
|
| 315 |
+
|
| 316 |
+
if (generatedUrl === undefined) {
|
| 317 |
+
return image;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
return {
|
| 321 |
+
...image,
|
| 322 |
+
generatedUrl,
|
| 323 |
+
generationNote: `${variant?.label ?? 'AI'} beeld is klaar voor controle.`,
|
| 324 |
+
};
|
| 325 |
+
}),
|
| 326 |
+
);
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
private async pollGenerationJob(jobId: string): Promise<GenerationJobResponse> {
|
| 330 |
+
while (true) {
|
| 331 |
+
const response = await fetch(`${generationEndpoint}/${jobId}`);
|
| 332 |
+
if (!response.ok) {
|
| 333 |
+
throw new Error(await this.getGenerationResponseError(response));
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
const jobResponse = <GenerationJobResponse>await response.json();
|
| 337 |
+
if (jobResponse.status === 'failed') {
|
| 338 |
+
throw new Error(jobResponse.error ?? 'Het maken van nieuwe beelden is mislukt.');
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
if (jobResponse.status === 'completed') {
|
| 342 |
+
return jobResponse;
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
this.monitor.update((current) => ({
|
| 346 |
+
...current,
|
| 347 |
+
provider: this.getFriendlyProviderName(jobResponse.provider ?? current.provider),
|
| 348 |
+
jobId: jobResponse.jobId ?? current.jobId,
|
| 349 |
+
status: 'running',
|
| 350 |
+
message: 'Nieuwe productbeelden worden gemaakt.',
|
| 351 |
+
}));
|
| 352 |
+
|
| 353 |
+
await this.wait(generationPollIntervalMs);
|
| 354 |
+
}
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
private wait(ms: number): Promise<void> {
|
| 358 |
+
return new Promise((resolvePromise) => window.setTimeout(resolvePromise, ms));
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
private getFriendlyProviderName(provider: string): string {
|
| 362 |
+
if (provider === 'kaggle' || provider === 'flux2-klein' || provider === 'openrouter') {
|
| 363 |
+
return 'AI beeldgenerator';
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
if (provider === 'mock') {
|
| 367 |
+
return 'Voorbeeldmodus';
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
return provider;
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
private getGenerationErrorMessage(error: unknown): string {
|
| 374 |
+
if (error instanceof TypeError) {
|
| 375 |
+
return 'De beeldgenerator is niet bereikbaar. Start de lokale generator en probeer het daarna opnieuw.';
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
if (error instanceof Error && error.message.trim() !== '') {
|
| 379 |
+
return `De beeldgenerator kon het verzoek niet verwerken: ${error.message}`;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
return 'Het maken van nieuwe beelden is niet gelukt. Controleer de verbinding met de beeldgenerator en probeer het opnieuw.';
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
private async getGenerationResponseError(response: Response): Promise<string> {
|
| 386 |
+
try {
|
| 387 |
+
const errorResponse = <GenerationErrorResponse>await response.json();
|
| 388 |
+
if (errorResponse.error !== undefined && errorResponse.error.trim() !== '') {
|
| 389 |
+
return errorResponse.error;
|
| 390 |
+
}
|
| 391 |
+
}
|
| 392 |
+
catch {
|
| 393 |
+
return `HTTP ${response.status}`;
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
return `HTTP ${response.status}`;
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
}
|