Initial release

This commit is contained in:
civ
2026-08-16 18:27:57 +07:00
commit 8ff9ca0fc0
3800 changed files with 848933 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules
.DS_Store
dist
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 leejet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+126
View File
@@ -0,0 +1,126 @@
# sdcpp-webui
A lightweight Vue + Vite web UI for `stable-diffusion.cpp` servers that expose the native `sdcpp` API.
It is designed for two use cases:
- run as a standalone frontend during local development
- build into a single HTML file that can be embedded into `sd-server`
## What It Does
`sdcpp-webui` talks directly to the native server endpoints:
- `GET /sdcpp/v1/capabilities`
- `POST /sdcpp/v1/img_gen`
- `POST /sdcpp/v1/vid_gen`
- `GET /sdcpp/v1/jobs/:id`
- `POST /sdcpp/v1/jobs/:id/cancel`
The current UI supports:
- image generation and video generation mode switching
- prompt and negative prompt editing
- width, height, seed, batch count, video frames, and fps
- sampler and scheduler selection
- guidance controls, including the video high-noise stage
- conditioning controls such as `clip_skip`, `strength`, `control_strength`, `moe_boundary`, and `vace_strength`
- LoRA selection from server capabilities
- init image, end image, mask image, control image, control frames, and reference images
- VAE tiling controls
- cache controls
- job polling, cancellation, image preview, and video or animated WebP preview
## Requirements
- Node.js `>= 20`
- `pnpm` `>= 10`
- a running `stable-diffusion.cpp` server with the `sdcpp` API enabled
## Development
Install dependencies:
```bash
pnpm install
```
Start the dev server:
```bash
pnpm dev
```
Then open the Vite URL shown in the terminal.
The UI lets you set the backend base URL in the Settings tab.
If left empty, requests go to the current origin.
## Production Build
Build a production bundle:
```bash
pnpm build
```
This project uses `vite-plugin-singlefile`, so the output is emitted as a self-contained `dist/index.html`.
Preview the production build locally:
```bash
pnpm preview
```
## Embedding Into `sd-server`
If you want to ship the UI inside `stable-diffusion.cpp`, first build the frontend:
```bash
pnpm build
```
Then generate the C header:
```bash
pnpm build:header
```
That produces:
```text
dist/gen_index_html.h
```
The generated header contains the built HTML as a byte array, which can be compiled into the server binary.
## Type Checking
Run:
```bash
pnpm type-check
```
## Project Layout
```text
src/
components/ reusable UI pieces
lib/ API, form mapping, image helpers, settings helpers
App.vue main application shell
main.ts app entry
styles.css global styles
scripts/
build_gen_index_html.js
```
## Notes
- This UI is intentionally thin. Most selectable options come from the server's `capabilities` response.
- It assumes the backend handles CORS correctly if the frontend is served from a different origin.
- It is scoped to the native `sdcpp` API, not the OpenAI-compatible routes and not the A1111-compatible `sdapi` routes.
## License
MIT License
+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect x="4" y="4" width="56" height="56" rx="16" fill="#111827"/>
<text
x="32"
y="39"
fill="#F9FAFB"
font-family="Segoe UI, Arial, sans-serif"
font-size="28"
font-weight="700"
letter-spacing="-1"
text-anchor="middle"
>SD</text>
</svg>

After

Width:  |  Height:  |  Size: 346 B

+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' fill='none'%3E%3Crect x='4' y='4' width='56' height='56' rx='16' fill='%23111827'/%3E%3Ctext x='32' y='39' fill='%23F9FAFB' font-family='Segoe UI, Arial, sans-serif' font-size='28' font-weight='700' letter-spacing='-1' text-anchor='middle'%3ESD%3C/text%3E%3C/svg%3E" />
<title>stable-diffusion.cpp WebUI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "sdcpp-webui",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.15.1",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --port 4174",
"build:header": "node scripts/build_gen_index_html.js",
"type-check": "vue-tsc --noEmit"
},
"engines": {
"node": ">=20.0.0"
},
"dependencies": {
"vue": "^3.2.38"
},
"devDependencies": {
"@vitejs/plugin-vue": "^3.0.3",
"typescript": "^5.8.3",
"vite": "^3.2.10",
"vite-plugin-singlefile": "^1.0.0",
"vue-tsc": "^2.2.8"
}
}
+732
View File
@@ -0,0 +1,732 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
vue:
specifier: ^3.2.38
version: 3.5.32(typescript@5.9.3)
devDependencies:
'@vitejs/plugin-vue':
specifier: ^3.0.3
version: 3.2.0(vite@3.2.11)(vue@3.5.32(typescript@5.9.3))
typescript:
specifier: ^5.8.3
version: 5.9.3
vite:
specifier: ^3.2.10
version: 3.2.11
vite-plugin-singlefile:
specifier: ^1.0.0
version: 1.0.0(rollup@2.80.0)(vite@3.2.11)
vue-tsc:
specifier: ^2.2.8
version: 2.2.12(typescript@5.9.3)
packages:
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.2':
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.29.0':
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@esbuild/android-arm@0.15.18':
resolution: {integrity: sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==}
engines: {node: '>=12'}
cpu: [arm]
os: [android]
'@esbuild/linux-loong64@0.15.18':
resolution: {integrity: sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==}
engines: {node: '>=12'}
cpu: [loong64]
os: [linux]
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@vitejs/plugin-vue@3.2.0':
resolution: {integrity: sha512-E0tnaL4fr+qkdCNxJ+Xd0yM31UwMkQje76fsDVBBUCoGOUPexu2VDUYHL8P4CwV+zMvWw6nlRw19OnRKmYAJpw==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
vite: ^3.0.0
vue: ^3.2.25
'@volar/language-core@2.4.15':
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
'@volar/source-map@2.4.15':
resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==}
'@volar/typescript@2.4.15':
resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==}
'@vue/compiler-core@3.5.32':
resolution: {integrity: sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==}
'@vue/compiler-dom@3.5.32':
resolution: {integrity: sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==}
'@vue/compiler-sfc@3.5.32':
resolution: {integrity: sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==}
'@vue/compiler-ssr@3.5.32':
resolution: {integrity: sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==}
'@vue/compiler-vue2@2.7.16':
resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
'@vue/language-core@2.2.12':
resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
'@vue/reactivity@3.5.32':
resolution: {integrity: sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==}
'@vue/runtime-core@3.5.32':
resolution: {integrity: sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==}
'@vue/runtime-dom@3.5.32':
resolution: {integrity: sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==}
'@vue/server-renderer@3.5.32':
resolution: {integrity: sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==}
peerDependencies:
vue: 3.5.32
'@vue/shared@3.5.32':
resolution: {integrity: sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==}
alien-signals@1.0.13:
resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
brace-expansion@2.0.3:
resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
de-indent@1.0.2:
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
esbuild-android-64@0.15.18:
resolution: {integrity: sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==}
engines: {node: '>=12'}
cpu: [x64]
os: [android]
esbuild-android-arm64@0.15.18:
resolution: {integrity: sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==}
engines: {node: '>=12'}
cpu: [arm64]
os: [android]
esbuild-darwin-64@0.15.18:
resolution: {integrity: sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==}
engines: {node: '>=12'}
cpu: [x64]
os: [darwin]
esbuild-darwin-arm64@0.15.18:
resolution: {integrity: sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==}
engines: {node: '>=12'}
cpu: [arm64]
os: [darwin]
esbuild-freebsd-64@0.15.18:
resolution: {integrity: sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==}
engines: {node: '>=12'}
cpu: [x64]
os: [freebsd]
esbuild-freebsd-arm64@0.15.18:
resolution: {integrity: sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==}
engines: {node: '>=12'}
cpu: [arm64]
os: [freebsd]
esbuild-linux-32@0.15.18:
resolution: {integrity: sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==}
engines: {node: '>=12'}
cpu: [ia32]
os: [linux]
esbuild-linux-64@0.15.18:
resolution: {integrity: sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==}
engines: {node: '>=12'}
cpu: [x64]
os: [linux]
esbuild-linux-arm64@0.15.18:
resolution: {integrity: sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==}
engines: {node: '>=12'}
cpu: [arm64]
os: [linux]
esbuild-linux-arm@0.15.18:
resolution: {integrity: sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==}
engines: {node: '>=12'}
cpu: [arm]
os: [linux]
esbuild-linux-mips64le@0.15.18:
resolution: {integrity: sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==}
engines: {node: '>=12'}
cpu: [mips64el]
os: [linux]
esbuild-linux-ppc64le@0.15.18:
resolution: {integrity: sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [linux]
esbuild-linux-riscv64@0.15.18:
resolution: {integrity: sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==}
engines: {node: '>=12'}
cpu: [riscv64]
os: [linux]
esbuild-linux-s390x@0.15.18:
resolution: {integrity: sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==}
engines: {node: '>=12'}
cpu: [s390x]
os: [linux]
esbuild-netbsd-64@0.15.18:
resolution: {integrity: sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==}
engines: {node: '>=12'}
cpu: [x64]
os: [netbsd]
esbuild-openbsd-64@0.15.18:
resolution: {integrity: sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [openbsd]
esbuild-sunos-64@0.15.18:
resolution: {integrity: sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==}
engines: {node: '>=12'}
cpu: [x64]
os: [sunos]
esbuild-windows-32@0.15.18:
resolution: {integrity: sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==}
engines: {node: '>=12'}
cpu: [ia32]
os: [win32]
esbuild-windows-64@0.15.18:
resolution: {integrity: sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==}
engines: {node: '>=12'}
cpu: [x64]
os: [win32]
esbuild-windows-arm64@0.15.18:
resolution: {integrity: sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==}
engines: {node: '>=12'}
cpu: [arm64]
os: [win32]
esbuild@0.15.18:
resolution: {integrity: sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==}
engines: {node: '>=12'}
hasBin: true
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
is-core-module@2.16.1:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'}
is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@2.3.2:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
postcss@8.5.8:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
resolve@1.22.11:
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
engines: {node: '>= 0.4'}
hasBin: true
rollup@2.80.0:
resolution: {integrity: sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==}
engines: {node: '>=10.0.0'}
hasBin: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
supports-preserve-symlinks-flag@1.0.0:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
vite-plugin-singlefile@1.0.0:
resolution: {integrity: sha512-iVAxl0t7gndQrznI4kI14hqAGxhVL7FQndeTdvTIH5/CbwpR2asgD3XSTsd7iWC83YTCmwAmsHIQvKdIdnHu7w==}
engines: {node: '>18.0.0'}
peerDependencies:
rollup: ^4.9.5
vite: ^5.0.11
vite@3.2.11:
resolution: {integrity: sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==}
engines: {node: ^14.18.0 || >=16.0.0}
hasBin: true
peerDependencies:
'@types/node': '>= 14'
less: '*'
sass: '*'
stylus: '*'
sugarss: '*'
terser: ^5.4.0
peerDependenciesMeta:
'@types/node':
optional: true
less:
optional: true
sass:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
vue-tsc@2.2.12:
resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==}
hasBin: true
peerDependencies:
typescript: '>=5.0.0'
vue@3.5.32:
resolution: {integrity: sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
snapshots:
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {}
'@babel/parser@7.29.2':
dependencies:
'@babel/types': 7.29.0
'@babel/types@7.29.0':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@esbuild/android-arm@0.15.18':
optional: true
'@esbuild/linux-loong64@0.15.18':
optional: true
'@jridgewell/sourcemap-codec@1.5.5': {}
'@vitejs/plugin-vue@3.2.0(vite@3.2.11)(vue@3.5.32(typescript@5.9.3))':
dependencies:
vite: 3.2.11
vue: 3.5.32(typescript@5.9.3)
'@volar/language-core@2.4.15':
dependencies:
'@volar/source-map': 2.4.15
'@volar/source-map@2.4.15': {}
'@volar/typescript@2.4.15':
dependencies:
'@volar/language-core': 2.4.15
path-browserify: 1.0.1
vscode-uri: 3.1.0
'@vue/compiler-core@3.5.32':
dependencies:
'@babel/parser': 7.29.2
'@vue/shared': 3.5.32
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/compiler-dom@3.5.32':
dependencies:
'@vue/compiler-core': 3.5.32
'@vue/shared': 3.5.32
'@vue/compiler-sfc@3.5.32':
dependencies:
'@babel/parser': 7.29.2
'@vue/compiler-core': 3.5.32
'@vue/compiler-dom': 3.5.32
'@vue/compiler-ssr': 3.5.32
'@vue/shared': 3.5.32
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.8
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.32':
dependencies:
'@vue/compiler-dom': 3.5.32
'@vue/shared': 3.5.32
'@vue/compiler-vue2@2.7.16':
dependencies:
de-indent: 1.0.2
he: 1.2.0
'@vue/language-core@2.2.12(typescript@5.9.3)':
dependencies:
'@volar/language-core': 2.4.15
'@vue/compiler-dom': 3.5.32
'@vue/compiler-vue2': 2.7.16
'@vue/shared': 3.5.32
alien-signals: 1.0.13
minimatch: 9.0.9
muggle-string: 0.4.1
path-browserify: 1.0.1
optionalDependencies:
typescript: 5.9.3
'@vue/reactivity@3.5.32':
dependencies:
'@vue/shared': 3.5.32
'@vue/runtime-core@3.5.32':
dependencies:
'@vue/reactivity': 3.5.32
'@vue/shared': 3.5.32
'@vue/runtime-dom@3.5.32':
dependencies:
'@vue/reactivity': 3.5.32
'@vue/runtime-core': 3.5.32
'@vue/shared': 3.5.32
csstype: 3.2.3
'@vue/server-renderer@3.5.32(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@vue/compiler-ssr': 3.5.32
'@vue/shared': 3.5.32
vue: 3.5.32(typescript@5.9.3)
'@vue/shared@3.5.32': {}
alien-signals@1.0.13: {}
balanced-match@1.0.2: {}
brace-expansion@2.0.3:
dependencies:
balanced-match: 1.0.2
braces@3.0.3:
dependencies:
fill-range: 7.1.1
csstype@3.2.3: {}
de-indent@1.0.2: {}
entities@7.0.1: {}
esbuild-android-64@0.15.18:
optional: true
esbuild-android-arm64@0.15.18:
optional: true
esbuild-darwin-64@0.15.18:
optional: true
esbuild-darwin-arm64@0.15.18:
optional: true
esbuild-freebsd-64@0.15.18:
optional: true
esbuild-freebsd-arm64@0.15.18:
optional: true
esbuild-linux-32@0.15.18:
optional: true
esbuild-linux-64@0.15.18:
optional: true
esbuild-linux-arm64@0.15.18:
optional: true
esbuild-linux-arm@0.15.18:
optional: true
esbuild-linux-mips64le@0.15.18:
optional: true
esbuild-linux-ppc64le@0.15.18:
optional: true
esbuild-linux-riscv64@0.15.18:
optional: true
esbuild-linux-s390x@0.15.18:
optional: true
esbuild-netbsd-64@0.15.18:
optional: true
esbuild-openbsd-64@0.15.18:
optional: true
esbuild-sunos-64@0.15.18:
optional: true
esbuild-windows-32@0.15.18:
optional: true
esbuild-windows-64@0.15.18:
optional: true
esbuild-windows-arm64@0.15.18:
optional: true
esbuild@0.15.18:
optionalDependencies:
'@esbuild/android-arm': 0.15.18
'@esbuild/linux-loong64': 0.15.18
esbuild-android-64: 0.15.18
esbuild-android-arm64: 0.15.18
esbuild-darwin-64: 0.15.18
esbuild-darwin-arm64: 0.15.18
esbuild-freebsd-64: 0.15.18
esbuild-freebsd-arm64: 0.15.18
esbuild-linux-32: 0.15.18
esbuild-linux-64: 0.15.18
esbuild-linux-arm: 0.15.18
esbuild-linux-arm64: 0.15.18
esbuild-linux-mips64le: 0.15.18
esbuild-linux-ppc64le: 0.15.18
esbuild-linux-riscv64: 0.15.18
esbuild-linux-s390x: 0.15.18
esbuild-netbsd-64: 0.15.18
esbuild-openbsd-64: 0.15.18
esbuild-sunos-64: 0.15.18
esbuild-windows-32: 0.15.18
esbuild-windows-64: 0.15.18
esbuild-windows-arm64: 0.15.18
estree-walker@2.0.2: {}
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
fsevents@2.3.3:
optional: true
function-bind@1.1.2: {}
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
he@1.2.0: {}
is-core-module@2.16.1:
dependencies:
hasown: 2.0.2
is-number@7.0.0: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
micromatch@4.0.8:
dependencies:
braces: 3.0.3
picomatch: 2.3.2
minimatch@9.0.9:
dependencies:
brace-expansion: 2.0.3
muggle-string@0.4.1: {}
nanoid@3.3.11: {}
path-browserify@1.0.1: {}
path-parse@1.0.7: {}
picocolors@1.1.1: {}
picomatch@2.3.2: {}
postcss@8.5.8:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
resolve@1.22.11:
dependencies:
is-core-module: 2.16.1
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
rollup@2.80.0:
optionalDependencies:
fsevents: 2.3.3
source-map-js@1.2.1: {}
supports-preserve-symlinks-flag@1.0.0: {}
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
typescript@5.9.3: {}
vite-plugin-singlefile@1.0.0(rollup@2.80.0)(vite@3.2.11):
dependencies:
micromatch: 4.0.8
rollup: 2.80.0
vite: 3.2.11
vite@3.2.11:
dependencies:
esbuild: 0.15.18
postcss: 8.5.8
resolve: 1.22.11
rollup: 2.80.0
optionalDependencies:
fsevents: 2.3.3
vscode-uri@3.1.0: {}
vue-tsc@2.2.12(typescript@5.9.3):
dependencies:
'@volar/typescript': 2.4.15
'@vue/language-core': 2.2.12(typescript@5.9.3)
typescript: 5.9.3
vue@3.5.32(typescript@5.9.3):
dependencies:
'@vue/compiler-dom': 3.5.32
'@vue/compiler-sfc': 3.5.32
'@vue/runtime-dom': 3.5.32
'@vue/server-renderer': 3.5.32(vue@3.5.32(typescript@5.9.3))
'@vue/shared': 3.5.32
optionalDependencies:
typescript: 5.9.3
@@ -0,0 +1,24 @@
const fs = require("fs");
const path = require("path");
const distHtml = path.join(__dirname, "../dist/index.html");
const outHeader = path.join(__dirname, "../dist/gen_index_html.h");
if (!fs.existsSync(distHtml)) {
console.error("index.html not found. Please run pnpm build first.");
process.exit(1);
}
const html = fs.readFileSync(distHtml);
const bytes = Array.from(html).map((b) => `0x${b.toString(16).padStart(2, "0")},`);
const lines = [];
for (let i = 0; i < bytes.length; i += 12) {
lines.push(" " + bytes.slice(i, i + 12).join(" "));
}
const headerContent =
`static const unsigned char index_html_bytes[] = {\n${lines.join("\n")}\n};\n` +
"static const size_t index_html_size = sizeof(index_html_bytes);\n";
fs.writeFileSync(outHeader, headerContent);
console.log(`Generated ${outHeader}`);
+923
View File
@@ -0,0 +1,923 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import CollapsibleSection from "./components/CollapsibleSection.vue";
import ImageDropzone from "./components/ImageDropzone.vue";
import StatusChip from "./components/StatusChip.vue";
import { cancelJob, getCapabilities, getJob, submitImageJob, submitVideoJob } from "./lib/api";
import { buildRequestBodyForMode, CACHE_MODES, createBlankForm, formFromCapabilities } from "./lib/form";
import { IMAGE_INPUTS, VIDEO_IMAGE_INPUTS } from "./lib/image-inputs";
import { assignImageEntries, clearImageEntries, filesToImageEntries, removeImageEntry } from "./lib/images";
import { createStoredRef, normalizePollIntervalMs } from "./lib/settings";
import type {
Capabilities,
GenerationForm,
GenerationMode,
ImageEntry,
ImageTarget,
Job,
SampleParams,
} from "./lib/types";
const baseUrl = createStoredRef<string>("sdcpp-webui-base-url", "", (value: unknown) => String(value || ""));
const pollIntervalMs = createStoredRef<number>("sdcpp-webui-poll-interval-ms", 100, normalizePollIntervalMs);
const activeTab = ref<"image" | "video" | "settings">("image");
const selectedGenerationTab = ref<"image" | "video">("image");
const generationMode = computed<GenerationMode>(() => selectedGenerationTab.value === "video" ? "video" : "image");
const lightboxOpen = ref(false);
const lightboxImageSrc = ref("");
const lightboxImageAlt = ref("");
const sectionState = reactive<Record<string, boolean>>({
sample: true,
sampleAdvanced: false,
guidance: true,
guidanceAdvanced: false,
highNoise: false,
highNoiseSample: true,
highNoiseGuidance: true,
conditioning: false,
auxiliaryImages: false,
lora: false,
vaeTiling: false,
cache: false,
});
const loadingCapabilities = ref(false);
const capabilitiesError = ref("");
const serviceOnline = ref(false);
const capabilities = ref<Capabilities | null>(null);
const currentJob = ref<Job | null>(null);
const selectedOutputIndex = ref(0);
const statusMessage = ref("");
const statusTone = ref("");
const form = reactive<GenerationForm>(createBlankForm());
let pollTimer = 0;
let elapsedTimer = 0;
const nowSeconds = ref(Date.now() / 1000);
const modelName = computed(() => {
const model = capabilities.value?.model;
return model?.stem || model?.name || "No model info";
});
const supportedModes = computed(() => capabilities.value?.supported_modes || []);
const supportsImageMode = computed(() => {
return !supportedModes.value.length || supportedModes.value.includes("img_gen");
});
const supportsVideoMode = computed(() => {
return !supportedModes.value.length || supportedModes.value.includes("vid_gen");
});
const selectedModeKey = computed<"img_gen" | "vid_gen">(() => generationMode.value === "video" ? "vid_gen" : "img_gen");
const currentJobModeKey = computed<"img_gen" | "vid_gen">(() => currentJob.value?.kind || selectedModeKey.value);
const selectedModeFeatures = computed(() => {
return capabilities.value?.features_by_mode?.[selectedModeKey.value] || {};
});
const currentJobFeatures = computed(() => {
return capabilities.value?.features_by_mode?.[currentJobModeKey.value] || selectedModeFeatures.value;
});
const imageOutputFormats = computed(() => {
return capabilities.value?.output_formats_by_mode?.img_gen || ["png", "jpeg"];
});
const videoOutputFormats = computed(() => {
return capabilities.value?.output_formats_by_mode?.vid_gen || ["webm", "avi"];
});
const outputFormats = computed(() => generationMode.value === "video" ? videoOutputFormats.value : imageOutputFormats.value);
const samplers = computed(() => capabilities.value?.samplers || ["default"]);
const schedulers = computed(() => capabilities.value?.schedulers || ["default"]);
const availableLoras = computed(() => capabilities.value?.loras || []);
const currentImageInputs = computed(() => generationMode.value === "video" ? VIDEO_IMAGE_INPUTS : IMAGE_INPUTS);
const gridImageInputs = computed(() => currentImageInputs.value.filter((input) => input.layout === "grid"));
const fullImageInputs = computed(() => currentImageInputs.value.filter((input) => input.layout === "full"));
const queueLimit = computed(() => capabilities.value?.limits?.max_queue_size ?? "unknown");
const canCancelQueued = computed(() => Boolean(currentJobFeatures.value.cancel_queued));
const canCancelGenerating = computed(() => Boolean(currentJobFeatures.value.cancel_generating));
const currentStatus = computed(() => currentJob.value?.status || "idle");
const currentJobKind = computed(() => currentJob.value?.kind || null);
const currentImages = computed(() => currentJobKind.value === "img_gen" ? currentJob.value?.result?.images || [] : []);
const selectedImage = computed(() => {
if (!currentImages.value.length) {
return null;
}
const index = Math.min(selectedOutputIndex.value, currentImages.value.length - 1);
const image = currentImages.value[index];
const format = currentJob.value?.result?.output_format || form.output_format || "png";
return `data:image/${format};base64,${image.b64_json}`;
});
const videoMimeType = computed(() => currentJobKind.value === "vid_gen" ? currentJob.value?.result?.mime_type || "" : "");
const videoFrameCount = computed(() => currentJobKind.value === "vid_gen" ? currentJob.value?.result?.frame_count || 0 : 0);
const videoFps = computed(() => currentJobKind.value === "vid_gen" ? currentJob.value?.result?.fps || 0 : 0);
const videoPreviewSrc = computed(() => {
if (currentJobKind.value !== "vid_gen" || !currentJob.value?.result?.b64_json) {
return null;
}
if (currentJob.value?.result?.output_format === "avi") {
return null;
}
if (!videoMimeType.value.startsWith("video/")) {
return null;
}
return `data:${videoMimeType.value};base64,${currentJob.value.result.b64_json}`;
});
const animatedVideoImageSrc = computed(() => {
if (currentJobKind.value !== "vid_gen" || !currentJob.value?.result?.b64_json) {
return null;
}
if (videoMimeType.value !== "image/webp") {
return null;
}
return `data:image/webp;base64,${currentJob.value.result.b64_json}`;
});
const previewImageSrc = computed(() => animatedVideoImageSrc.value || selectedImage.value);
const downloadableSrc = computed(() => {
const result = currentJob.value?.result;
if (currentJobKind.value === "vid_gen" && result?.b64_json && videoMimeType.value) {
return `data:${videoMimeType.value};base64,${result.b64_json}`;
}
return selectedImage.value;
});
const canCancelCurrentJob = computed(() => {
if (!currentJob.value) {
return false;
}
if (currentStatus.value === "queued") {
return canCancelQueued.value;
}
if (currentStatus.value === "generating") {
return canCancelGenerating.value;
}
return false;
});
const isJobRunning = computed(() => {
return currentStatus.value === "queued" || currentStatus.value === "generating";
});
function buildSampleSummary(sample: SampleParams): string {
const scheduler = sample.scheduler || "default";
const method = sample.sample_method || "default";
const steps = sample.sample_steps || 0;
const flowShift = sample.flow_shift === "" || sample.flow_shift == null
? "flow auto"
: `flow ${sample.flow_shift}`;
return `${scheduler} · ${flowShift} · ${method} · ${steps} steps`;
}
function buildGuidanceSummary(sample: SampleParams): string {
const cfg = sample.guidance.txt_cfg;
const distilled = sample.guidance.distilled_guidance;
return `cfg ${cfg} · distilled ${distilled}`;
}
const sampleSummary = computed(() => buildSampleSummary(form.sample_params));
const guidanceSummary = computed(() => buildGuidanceSummary(form.sample_params));
const highNoiseSummary = computed(() => {
return `moe ${formatSummaryNumber(form.moe_boundary)} · ${buildSampleSummary(form.high_noise_sample_params)} · ${buildGuidanceSummary(form.high_noise_sample_params)}`;
});
const loraSummary = computed(() => {
if (!form.lora.length) {
return "No LoRA";
}
return `${form.lora.length} configured`;
});
const imageInputsSummary = computed(() => {
const parts: string[] = [];
if (form.init_image) parts.push(generationMode.value === "video" ? "start" : "init");
if (generationMode.value === "image") {
if (form.mask_image) parts.push("mask");
if (form.control_image) parts.push("control");
if (form.ref_images.length) parts.push(`${form.ref_images.length} refs`);
} else {
if (form.end_image) parts.push("end");
if (form.control_frames.length) parts.push(`${form.control_frames.length} frames`);
}
return parts.length ? parts.join(" · ") : "No images";
});
const vaeTilingSummary = computed(() => {
if (!form.vae_tiling_params.enabled) {
return "Disabled";
}
return `${form.vae_tiling_params.tile_size_x}×${form.vae_tiling_params.tile_size_y} · overlap ${form.vae_tiling_params.target_overlap}`;
});
const cacheSummary = computed(() => {
const mode = form.cache.mode || "disabled";
if (mode === "disabled") {
return "Disabled";
}
const option = String(form.cache.option || "").trim();
return option ? `${mode} · ${option}` : mode;
});
const conditioningSummary = computed(() => {
const clipSkip = formatSummaryNumber(form.clip_skip, 0);
const strength = formatSummaryNumber(form.strength);
if (generationMode.value === "video") {
return `clip_skip ${clipSkip} · strength ${strength} · vace ${formatSummaryNumber(form.vace_strength)}`;
}
const controlStrength = formatSummaryNumber(form.control_strength);
return `clip_skip ${clipSkip} · img ${strength} · control ${controlStrength}`;
});
function defaultOutputFormatForMode(mode: GenerationMode): string {
if (mode === "video") {
return videoOutputFormats.value[0] || "webm";
}
return imageOutputFormats.value[0] || "png";
}
function ensureOutputFormatForMode(mode = generationMode.value): void {
const validFormats = mode === "video" ? videoOutputFormats.value : imageOutputFormats.value;
if (!validFormats.length) {
return;
}
if (!validFormats.includes(form.output_format)) {
form.output_format = defaultOutputFormatForMode(mode);
}
}
function setMessage(message: string, tone = ""): void {
statusMessage.value = message;
statusTone.value = tone;
}
function clearMessage(): void {
setMessage("", "");
}
function deepAssign(target: Record<string, any>, ...sources: Record<string, any>[]): Record<string, any> {
for (const source of sources) {
if (!source) continue;
for (const key of Object.keys(source)) {
const sv = source[key];
if (sv !== null && typeof sv === "object" && !Array.isArray(sv) &&
target[key] !== null && typeof target[key] === "object" && !Array.isArray(target[key])) {
deepAssign(target[key], sv);
} else {
target[key] = sv;
}
}
}
return target;
}
function applyForm(nextForm: GenerationForm): void {
deepAssign(form, createBlankForm(), nextForm);
}
async function refreshCapabilities(): Promise<void> {
loadingCapabilities.value = true;
capabilitiesError.value = "";
try {
const response = await getCapabilities(baseUrl.value);
capabilities.value = response;
serviceOnline.value = true;
applyForm(formFromCapabilities(response));
if (selectedGenerationTab.value === "image" && !supportsImageMode.value && supportsVideoMode.value) {
selectedGenerationTab.value = "video";
} else if (selectedGenerationTab.value === "video" && !supportsVideoMode.value && supportsImageMode.value) {
selectedGenerationTab.value = "image";
}
if (activeTab.value === "image" && !supportsImageMode.value && supportsVideoMode.value) {
activeTab.value = "video";
} else if (activeTab.value === "video" && !supportsVideoMode.value && supportsImageMode.value) {
activeTab.value = "image";
}
ensureOutputFormatForMode();
clearMessage();
} catch (error) {
capabilitiesError.value = error instanceof Error ? error.message : String(error);
serviceOnline.value = false;
setMessage(capabilitiesError.value, "error");
} finally {
loadingCapabilities.value = false;
}
}
function toggleSection(section: string): void {
sectionState[section] = !sectionState[section];
}
function selectGenerationMode(mode: GenerationMode): void {
if (mode === "image" && !supportsImageMode.value) {
setMessage("Current model only supports video generation.", "error");
return;
}
if (mode === "video" && !supportsVideoMode.value) {
setMessage("Current model only supports image generation.", "error");
return;
}
selectedGenerationTab.value = mode;
activeTab.value = mode;
ensureOutputFormatForMode(mode);
}
function stopPolling(): void {
if (pollTimer) {
window.clearTimeout(pollTimer);
pollTimer = 0;
}
}
function stopElapsedTimer(): void {
if (elapsedTimer) {
window.clearInterval(elapsedTimer);
elapsedTimer = 0;
}
}
function startElapsedTimer(): void {
stopElapsedTimer();
nowSeconds.value = Date.now() / 1000;
elapsedTimer = window.setInterval(() => {
nowSeconds.value = Date.now() / 1000;
}, 100);
}
async function pollJob(id: string): Promise<void> {
stopPolling();
try {
currentJob.value = await getJob(baseUrl.value, id);
serviceOnline.value = true;
if (currentStatus.value === "queued" || currentStatus.value === "generating") {
pollTimer = window.setTimeout(() => pollJob(id), normalizePollIntervalMs(pollIntervalMs.value));
clearMessage();
return;
}
stopElapsedTimer();
if (currentStatus.value === "completed") {
setMessage(currentJob.value?.kind === "vid_gen" ? "Video generation completed." : "Image generation completed.", "success");
return;
}
if (currentStatus.value === "cancelled") {
setMessage("Job cancelled.", "error");
return;
}
if (currentStatus.value === "failed") {
setMessage(currentJob.value?.error?.message || "Generation failed.", "error");
}
} catch (error) {
stopElapsedTimer();
serviceOnline.value = false;
setMessage(error instanceof Error ? error.message : String(error), "error");
}
}
async function generate(): Promise<void> {
try {
const request = buildRequestBodyForMode(generationMode.value, form);
clearMessage();
selectedOutputIndex.value = 0;
startElapsedTimer();
currentJob.value = generationMode.value === "video"
? await submitVideoJob(baseUrl.value, request)
: await submitImageJob(baseUrl.value, request);
await pollJob(currentJob.value.id);
} catch (error) {
stopElapsedTimer();
setMessage(error instanceof Error ? error.message : String(error), "error");
}
}
async function cancelCurrentJob(): Promise<void> {
if (!currentJob.value?.id) {
return;
}
try {
currentJob.value = await cancelJob(baseUrl.value, currentJob.value.id);
stopPolling();
stopElapsedTimer();
setMessage("Job cancelled.", "error");
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error), "error");
}
}
function addLora(): void {
form.lora.push({
path: availableLoras.value[0]?.path || "",
multiplier: 1,
is_high_noise: false,
});
}
function removeLora(index: number): void {
form.lora.splice(index, 1);
}
async function assignImages(target: ImageTarget, files: FileList): Promise<void> {
const images = await filesToImageEntries(files);
if (!images.length) {
return;
}
assignImageEntries(form, target, images);
}
function clearImage(target: ImageTarget): void {
clearImageEntries(form, target);
}
function getFormImage(target: ImageTarget): ImageEntry | null {
if (target === "ref_images" || target === "control_frames") return null;
return form[target];
}
function openImageEntry(image: ImageEntry | null): void {
openLightbox(image?.dataUrl, image?.name);
}
function removeCollectionImage(target: "ref_images" | "control_frames", index: number): void {
removeImageEntry(form, target, index);
}
function selectOutput(index: number): void {
selectedOutputIndex.value = index;
}
function formatUnixTime(seconds: number | undefined): string {
if (!seconds) {
return "No job";
}
return new Date(seconds * 1000).toLocaleString();
}
function formatElapsed(started: number | undefined, completed: number | undefined): string {
if (!started) {
return "Idle";
}
const end = completed || nowSeconds.value;
const total = Math.max(0, end - started);
if (total < 60) {
return `${total.toFixed(1)}s`;
}
const minutes = Math.floor(total / 60);
const seconds = total - minutes * 60;
return `${minutes}m ${seconds.toFixed(1)}s`;
}
function formatSummaryNumber(value: number, digits = 3): string {
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric)) {
return "0";
}
return Number(numeric.toFixed(digits)).toString();
}
function downloadSelected(): void {
if (!downloadableSrc.value) {
return;
}
const link = document.createElement("a");
link.href = downloadableSrc.value;
link.download = `${currentJob.value?.id || "output"}.${currentJob.value?.result?.output_format || form.output_format}`;
document.body.appendChild(link);
link.click();
link.remove();
}
function openLightbox(src: string | null | undefined = previewImageSrc.value, alt = "Expanded image"): void {
if (!src) {
return;
}
lightboxImageSrc.value = src;
lightboxImageAlt.value = alt;
lightboxOpen.value = true;
}
function closeLightbox(): void {
lightboxOpen.value = false;
lightboxImageSrc.value = "";
lightboxImageAlt.value = "";
}
async function onPaste(event: ClipboardEvent): Promise<void> {
if (!event.clipboardData?.files?.length) {
return;
}
await assignImages("init_image", event.clipboardData.files);
setMessage("Pasted image into init_image.", "success");
}
watch(generationMode, (mode) => {
ensureOutputFormatForMode(mode);
});
watch(imageOutputFormats, () => {
ensureOutputFormatForMode();
});
watch(videoOutputFormats, () => {
ensureOutputFormatForMode();
});
onMounted(() => {
window.addEventListener("paste", onPaste);
refreshCapabilities();
});
onBeforeUnmount(() => {
stopPolling();
stopElapsedTimer();
window.removeEventListener("paste", onPaste);
});
</script>
<template>
<div class="shell">
<header class="page-header panel">
<div class="page-header__top">
<div class="page-header__copy">
<div class="breadcrumb">
<span class="breadcrumb__org">stable-diffusion.cpp</span>
<span class="breadcrumb__slash">/</span>
<span>{{ modelName }}</span>
</div>
<h1 class="page-title">{{ modelName }}</h1>
<p class="page-description">
Native async image and video generation interface for the local `stable-diffusion.cpp` server.
</p>
</div>
<div class="page-header__meta">
<StatusChip :status="serviceOnline ? 'online' : 'offline'" :label="serviceOnline ? 'service online' : 'service unavailable'" />
<StatusChip :label="`queue ${queueLimit}`" />
<StatusChip :status="currentStatus" :label="currentStatus" />
</div>
</div>
<div class="page-tabs">
<div class="page-tabs__list">
<button class="page-tab" :class="{ 'page-tab--active': activeTab === 'image' }" type="button" @click="selectGenerationMode('image')" :disabled="!supportsImageMode">Image Generation</button>
<button class="page-tab" :class="{ 'page-tab--active': activeTab === 'video' }" type="button" @click="selectGenerationMode('video')" :disabled="!supportsVideoMode">Video Generation</button>
<button class="page-tab" :class="{ 'page-tab--active': activeTab === 'settings' }" type="button" @click="activeTab = 'settings'">Settings</button>
</div>
<div class="page-tabs__actions">
<button class="btn-secondary" type="button" @click="refreshCapabilities" :disabled="loadingCapabilities">{{ loadingCapabilities ? "Refreshing..." : "Refresh Server Info" }}</button>
</div>
</div>
<div v-if="activeTab === 'settings'" class="settings">
<div class="settings__grid">
<div class="field">
<label>Base URL</label>
<input v-model="baseUrl" placeholder="Leave blank to use same origin" />
</div>
<div class="field">
<label>Queue Limit</label>
<input :value="queueLimit" readonly />
</div>
<div class="field">
<label>Output Formats ({{ generationMode === "video" ? "video" : "image" }})</label>
<input :value="outputFormats.join(', ')" readonly />
</div>
<div class="field">
<label>Output Format</label>
<select v-model="form.output_format">
<option v-for="format in outputFormats" :key="format" :value="format">{{ format }}</option>
</select>
</div>
<div class="field">
<label>Output Compression</label>
<input v-model.number="form.output_compression" type="number" min="0" max="100" />
</div>
<div class="field">
<label>Job Poll Interval (ms)</label>
<input v-model.number="pollIntervalMs" type="number" min="1" step="1" />
</div>
</div>
<div v-if="capabilitiesError" class="status-message status-message--error">{{ capabilitiesError }}</div>
</div>
</header>
<div v-if="activeTab !== 'settings'" class="layout">
<section class="panel control-panel">
<div class="panel-header">
<div>
<h2 class="panel-title">Input</h2>
</div>
</div>
<div class="prompt-card">
<div class="field--full">
<label>Prompt</label>
<textarea v-model="form.prompt" :placeholder="generationMode === 'video' ? 'Describe the motion, scene, and framing you want to generate' : 'Describe the image you want to generate'" />
</div>
<div class="field--full stack-top">
<label>Negative Prompt</label>
<textarea v-model="form.negative_prompt" placeholder="What should be excluded?" />
</div>
</div>
<div class="fields stack-top">
<div class="field"><label>Width</label><input v-model.number="form.width" type="number" min="64" /></div>
<div class="field"><label>Height</label><input v-model.number="form.height" type="number" min="64" /></div>
</div>
<div v-if="generationMode === 'image'" class="fields stack-top">
<div class="field"><label>Batch Count</label><input v-model.number="form.batch_count" type="number" min="1" /></div>
<div class="field"><label>Seed</label><input v-model.number="form.seed" type="number" /></div>
</div>
<div v-else class="fields stack-top">
<div class="field"><label>Video Frames</label><input v-model.number="form.video_frames" type="number" min="1" /></div>
<div class="field"><label>FPS</label><input v-model.number="form.fps" type="number" min="1" /></div>
</div>
<div v-if="generationMode === 'video'" class="field stack-top">
<label>Seed</label>
<input v-model.number="form.seed" type="number" />
</div>
<CollapsibleSection class="stack-top" eyebrow="Sample" :summary="sampleSummary" :open="sectionState.sample" variant="module" @toggle="toggleSection('sample')">
<div class="fields">
<div class="field">
<label>Scheduler</label>
<select v-model="form.sample_params.scheduler">
<option value="default">default</option>
<option v-for="scheduler in schedulers" :key="scheduler" :value="scheduler">{{ scheduler }}</option>
</select>
</div>
<div class="field"><label>Flow Shift</label><input v-model="form.sample_params.flow_shift" type="number" step="0.01" placeholder="blank = default" /></div>
<div class="field">
<label>Method</label>
<select v-model="form.sample_params.sample_method">
<option value="default">default</option>
<option v-for="sampler in samplers" :key="sampler" :value="sampler">{{ sampler }}</option>
</select>
</div>
<div class="field"><label>Steps</label><input v-model.number="form.sample_params.sample_steps" type="number" /></div>
</div>
<div class="sample-panel__extras">
<button class="module-card__link" type="button" @click="toggleSection('sampleAdvanced')">
{{ sectionState.sampleAdvanced ? "Hide extras" : "Show extras" }}
</button>
</div>
<div v-if="sectionState.sampleAdvanced" class="fields">
<div class="field"><label>Eta</label><input v-model="form.sample_params.eta" type="number" step="0.01" placeholder="blank = default" /></div>
<div class="field"><label>Shifted Timestep</label><input v-model.number="form.sample_params.shifted_timestep" type="number" /></div>
</div>
</CollapsibleSection>
<CollapsibleSection class="stack-top" eyebrow="Guidance" :summary="guidanceSummary" :open="sectionState.guidance" variant="module" @toggle="toggleSection('guidance')">
<div class="fields">
<div class="field"><label>CFG Scale</label><input v-model.number="form.sample_params.guidance.txt_cfg" type="number" step="0.1" /></div>
<div class="field"><label>Distilled Guidance</label><input v-model.number="form.sample_params.guidance.distilled_guidance" type="number" step="0.1" /></div>
</div>
<div class="sample-panel__extras">
<button class="module-card__link" type="button" @click="toggleSection('guidanceAdvanced')">
{{ sectionState.guidanceAdvanced ? "Hide extras" : "Show extras" }}
</button>
</div>
<div v-if="sectionState.guidanceAdvanced" class="fields">
<div class="field"><label>Image CFG</label><input v-model="form.sample_params.guidance.img_cfg" type="number" step="0.1" placeholder="blank = follow text cfg" /></div>
<div class="field"><label>SLG Layers</label><input v-model="form.sample_params.guidance.slg_layers" placeholder="7,8,9" /></div>
<div class="field"><label>SLG Layer Start</label><input v-model.number="form.sample_params.guidance.layer_start" type="number" step="0.01" /></div>
<div class="field"><label>SLG Layer End</label><input v-model.number="form.sample_params.guidance.layer_end" type="number" step="0.01" /></div>
<div class="field"><label>SLG Scale</label><input v-model.number="form.sample_params.guidance.scale" type="number" step="0.01" /></div>
</div>
</CollapsibleSection>
<CollapsibleSection v-if="generationMode === 'video'" class="stack-top" eyebrow="High Noise Pass" :summary="highNoiseSummary" :open="sectionState.highNoise" variant="module" @toggle="toggleSection('highNoise')">
<div class="field">
<label>MoE Boundary</label>
<input v-model.number="form.moe_boundary" type="number" step="0.001" />
</div>
<CollapsibleSection eyebrow="Sample" :summary="buildSampleSummary(form.high_noise_sample_params)" :open="sectionState.highNoiseSample" variant="plain" @toggle="toggleSection('highNoiseSample')">
<div class="fields">
<div class="field">
<label>Scheduler</label>
<select v-model="form.high_noise_sample_params.scheduler">
<option value="default">default</option>
<option v-for="scheduler in schedulers" :key="`high-noise-${scheduler}`" :value="scheduler">{{ scheduler }}</option>
</select>
</div>
<div class="field"><label>Flow Shift</label><input v-model="form.high_noise_sample_params.flow_shift" type="number" step="0.01" placeholder="blank = default" /></div>
<div class="field">
<label>Method</label>
<select v-model="form.high_noise_sample_params.sample_method">
<option value="default">default</option>
<option v-for="sampler in samplers" :key="`high-noise-${sampler}`" :value="sampler">{{ sampler }}</option>
</select>
</div>
<div class="field"><label>Steps</label><input v-model.number="form.high_noise_sample_params.sample_steps" type="number" /></div>
<div class="field"><label>Eta</label><input v-model="form.high_noise_sample_params.eta" type="number" step="0.01" placeholder="blank = auto" /></div>
<div class="field"><label>Shifted Timestep</label><input v-model.number="form.high_noise_sample_params.shifted_timestep" type="number" /></div>
</div>
</CollapsibleSection>
<CollapsibleSection class="stack-top" eyebrow="Guidance" :summary="buildGuidanceSummary(form.high_noise_sample_params)" :open="sectionState.highNoiseGuidance" variant="plain" @toggle="toggleSection('highNoiseGuidance')">
<div class="fields">
<div class="field"><label>CFG Scale</label><input v-model.number="form.high_noise_sample_params.guidance.txt_cfg" type="number" step="0.1" /></div>
<div class="field"><label>Distilled Guidance</label><input v-model.number="form.high_noise_sample_params.guidance.distilled_guidance" type="number" step="0.1" /></div>
<div class="field"><label>Image CFG</label><input v-model="form.high_noise_sample_params.guidance.img_cfg" type="number" step="0.1" placeholder="blank = follow text cfg" /></div>
<div class="field"><label>SLG Layers</label><input v-model="form.high_noise_sample_params.guidance.slg_layers" placeholder="7,8,9" /></div>
<div class="field"><label>SLG Layer Start</label><input v-model.number="form.high_noise_sample_params.guidance.layer_start" type="number" step="0.01" /></div>
<div class="field"><label>SLG Layer End</label><input v-model.number="form.high_noise_sample_params.guidance.layer_end" type="number" step="0.01" /></div>
<div class="field"><label>SLG Scale</label><input v-model.number="form.high_noise_sample_params.guidance.scale" type="number" step="0.01" /></div>
</div>
</CollapsibleSection>
</CollapsibleSection>
<CollapsibleSection class="stack-top" eyebrow="Conditioning" :summary="conditioningSummary" :open="sectionState.conditioning" @toggle="toggleSection('conditioning')">
<div class="fields">
<div class="field"><label>CLIP Skip</label><input v-model.number="form.clip_skip" type="number" /></div>
<div class="field"><label>Strength</label><input v-model.number="form.strength" type="number" step="0.01" /></div>
<div v-if="generationMode === 'image'" class="field"><label>Control Strength</label><input v-model.number="form.control_strength" type="number" step="0.01" /></div>
<div v-if="generationMode === 'video'" class="field"><label>VACE Strength</label><input v-model.number="form.vace_strength" type="number" step="0.01" /></div>
</div>
</CollapsibleSection>
<CollapsibleSection class="stack-top" eyebrow="LoRA" :summary="loraSummary" :open="sectionState.lora" @toggle="toggleSection('lora')">
<div v-if="!availableLoras.length" class="hint">No LoRA entries were returned by capabilities.</div>
<div v-else-if="!form.lora.length" class="hint">No LoRA overrides configured.</div>
<div v-else class="list-editor">
<div class="list-row list-row--header">
<div>LoRA</div>
<div>Multiplier</div>
<div>High Noise</div>
<div></div>
</div>
<div v-for="(item, index) in form.lora" :key="index" class="list-row">
<select v-model="item.path">
<option v-for="lora in availableLoras" :key="lora.path" :value="lora.path">
{{ lora.name }} ({{ lora.path }})
</option>
</select>
<input v-model.number="item.multiplier" type="number" step="0.1" />
<label class="checkbox list-row__checkbox">
<input v-model="item.is_high_noise" type="checkbox" />
<span>{{ item.is_high_noise ? "On" : "Off" }}</span>
</label>
<button class="btn-ghost" type="button" @click="removeLora(index)">Remove</button>
</div>
</div>
<div><button class="btn-ghost" type="button" @click="addLora" :disabled="!availableLoras.length">Add LoRA</button></div>
</CollapsibleSection>
<CollapsibleSection class="stack-top" eyebrow="Image Inputs" :summary="imageInputsSummary" :open="sectionState.auxiliaryImages" @toggle="toggleSection('auxiliaryImages')">
<div class="upload-grid">
<ImageDropzone
v-for="input in gridImageInputs"
:key="input.target"
:label="input.label"
:description="input.description"
:preview="getFormImage(input.target)"
@select="assignImages(input.target, $event)"
@clear="clearImage(input.target)"
@preview="openImageEntry($event)"
/>
</div>
<div v-for="input in fullImageInputs" :key="input.target">
<ImageDropzone
:label="input.label"
:description="input.description"
:preview="getFormImage(input.target)"
@select="assignImages(input.target, $event)"
@clear="clearImage(input.target)"
@preview="openImageEntry($event)"
/>
</div>
<div v-if="generationMode === 'image'" class="group">
<label>Reference Images</label>
<ImageDropzone
label="Reference Images"
description="Multiple reference images supported."
:items="form.ref_images"
multiple
@select="assignImages('ref_images', $event)"
@clear="clearImage('ref_images')"
/>
<div v-if="!form.ref_images.length" class="hint">No files selected.</div>
<div v-else class="file-list">
<div v-for="(item, index) in form.ref_images" :key="item.name + index" class="file-chip file-chip--preview">
<button class="file-chip__thumb-button" type="button" @click="openLightbox(item.dataUrl, item.name)">
<img class="file-chip__thumb" :src="item.dataUrl" :alt="item.name" />
</button>
<span class="file-chip__name">{{ item.name }}</span>
<button class="icon-button" type="button" @click="removeCollectionImage('ref_images', index)">Remove</button>
</div>
</div>
</div>
<div v-else class="group">
<label>Control Frames</label>
<ImageDropzone
label="Control Frames"
description="Upload ordered conditioning frames. The server preserves the array order."
:items="form.control_frames"
multiple
@select="assignImages('control_frames', $event)"
@clear="clearImage('control_frames')"
/>
<div v-if="!form.control_frames.length" class="hint">No files selected.</div>
<div v-else class="file-list">
<div v-for="(item, index) in form.control_frames" :key="item.name + index" class="file-chip file-chip--preview">
<button class="file-chip__thumb-button" type="button" @click="openLightbox(item.dataUrl, item.name)">
<img class="file-chip__thumb" :src="item.dataUrl" :alt="item.name" />
</button>
<span class="file-chip__name">{{ item.name }}</span>
<button class="icon-button" type="button" @click="removeCollectionImage('control_frames', index)">Remove</button>
</div>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection class="stack-top" eyebrow="VAE Tiling" :summary="vaeTilingSummary" :open="sectionState.vaeTiling" @toggle="toggleSection('vaeTiling')">
<label class="checkbox"><input v-model="form.vae_tiling_params.enabled" type="checkbox" /><span>Enabled</span></label>
<div class="fields">
<div class="field"><label>Tile Size X</label><input v-model.number="form.vae_tiling_params.tile_size_x" type="number" /></div>
<div class="field"><label>Tile Size Y</label><input v-model.number="form.vae_tiling_params.tile_size_y" type="number" /></div>
</div>
<div class="field"><label>Target Overlap</label><input v-model.number="form.vae_tiling_params.target_overlap" type="number" step="0.01" /></div>
<div class="fields">
<div class="field"><label>Relative Size X</label><input v-model.number="form.vae_tiling_params.rel_size_x" type="number" step="0.01" /></div>
<div class="field"><label>Relative Size Y</label><input v-model.number="form.vae_tiling_params.rel_size_y" type="number" step="0.01" /></div>
</div>
</CollapsibleSection>
<CollapsibleSection class="stack-top" eyebrow="Cache" :summary="cacheSummary" :open="sectionState.cache" @toggle="toggleSection('cache')">
<div class="field">
<label>Mode</label>
<select v-model="form.cache.mode">
<option v-for="mode in CACHE_MODES" :key="mode" :value="mode">{{ mode }}</option>
</select>
</div>
<div class="field field--full"><label>Cache Option</label><input v-model="form.cache.option" placeholder="threshold=0.25,start=0.15,end=0.95" /></div>
<div class="field"><label>SCM Mask</label><input v-model="form.cache.scm_mask" /></div>
<label class="checkbox"><input v-model="form.cache.scm_policy_dynamic" type="checkbox" /><span>Dynamic SCM Policy</span></label>
</CollapsibleSection>
</section>
<section class="panel output-panel">
<div class="panel-header">
<div>
<h2 class="panel-title">Output</h2>
</div>
</div>
<div v-if="videoPreviewSrc" class="hero-frame hero-frame--media">
<video class="hero-frame__video" :src="videoPreviewSrc" controls autoplay loop muted playsinline />
</div>
<button v-else class="hero-frame hero-frame--button" type="button" :disabled="!previewImageSrc" @click="openLightbox(previewImageSrc, 'Generated output')">
<img v-if="previewImageSrc" :src="previewImageSrc" alt="Generated output" />
<div v-else class="hero-placeholder">
<h2>{{ generationMode === "video" ? "Generate Video" : "Generate Images" }}</h2>
<p>
{{ generationMode === "video"
? "Generated video or animated WebP output will appear here once the current job finishes."
: "Generated images will appear here once the current job finishes." }}
</p>
</div>
</button>
<div v-if="currentJobKind === 'vid_gen' && currentJob?.result?.output_format === 'avi'" class="hint stack-top">
Browser playback for AVI depends on codec support. Download the file if the preview cannot play.
</div>
<div class="metrics output-metrics">
<div class="metric">
<div class="metric__label">Status</div>
<div class="metric__value">{{ currentStatus }}</div>
</div>
<div class="metric">
<div class="metric__label">Queue</div>
<div class="metric__value">{{ currentJob?.queue_position ?? 0 }}</div>
</div>
<div class="metric">
<div class="metric__label">Created</div>
<div class="metric__value mono">{{ formatUnixTime(currentJob?.created) }}</div>
</div>
<div class="metric">
<div class="metric__label">Elapsed</div>
<div class="metric__value">{{ formatElapsed(currentJob?.started, currentJob?.completed) }}</div>
</div>
<div v-if="currentJobKind === 'vid_gen'" class="metric">
<div class="metric__label">FPS</div>
<div class="metric__value">{{ videoFps || "-" }}</div>
</div>
<div v-if="currentJobKind === 'vid_gen'" class="metric">
<div class="metric__label">Frames</div>
<div class="metric__value">{{ videoFrameCount || "-" }}</div>
</div>
</div>
<div v-if="statusMessage" class="status-message" :class="statusTone === 'error' ? 'status-message--error' : 'status-message--success'">{{ statusMessage }}</div>
<div v-else-if="currentJob?.error?.message" class="status-message status-message--error">{{ currentJob.error.message }}</div>
<div class="output-controls">
<button class="btn output-controls__primary" type="button" :disabled="isJobRunning" @click="generate">
{{ generationMode === "video" ? "Generate Video" : "Generate Image" }}
</button>
<div class="actions output-controls__secondary">
<button class="btn-secondary" type="button" :disabled="!downloadableSrc" @click="downloadSelected">Download</button>
<button class="btn-danger" type="button" :disabled="!canCancelCurrentJob" @click="cancelCurrentJob">Cancel</button>
</div>
</div>
<div v-if="currentImages.length > 1" class="thumb-row">
<button v-for="(image, index) in currentImages" :key="image.index" class="thumb" :class="{ 'thumb--active': index === selectedOutputIndex }" type="button" @click="selectOutput(index)">
<img :src="`data:image/${currentJob?.result?.output_format || 'png'};base64,${image.b64_json}`" :alt="`Output ${index + 1}`" />
</button>
</div>
</section>
</div>
<div v-if="lightboxOpen && lightboxImageSrc" class="lightbox" @click.self="closeLightbox">
<button class="lightbox__close" type="button" @click="closeLightbox">Close</button>
<img class="lightbox__image" :src="lightboxImageSrc" :alt="lightboxImageAlt" />
</div>
</div>
</template>
@@ -0,0 +1,62 @@
<script setup lang="ts">
type Variant = "module" | "section" | "plain";
const props = withDefaults(defineProps<{
eyebrow?: string;
title?: string;
summary?: string;
open: boolean;
variant?: Variant;
}>(), {
eyebrow: "",
title: "",
summary: "",
variant: "section",
});
const emit = defineEmits<{
(e: "toggle"): void;
}>();
const rootClass: Record<Variant, string> = {
module: "advanced-panel module-card",
section: "advanced-group module-card",
plain: "advanced-group",
};
const buttonClass: Record<Variant, string> = {
module: "advanced-group__toggle",
section: "advanced-group__toggle",
plain: "advanced-group__toggle",
};
const bodyClass: Record<Variant, string> = {
module: "advanced-group__content",
section: "advanced-group__content",
plain: "advanced-group__content",
};
function onToggle(): void {
emit("toggle");
}
</script>
<template>
<div :class="rootClass[variant] || rootClass.section">
<button :class="buttonClass[variant] || buttonClass.section" type="button" @click="onToggle">
<span v-if="$slots.header" class="module-card__copy">
<slot name="header" />
</span>
<span v-else class="module-card__copy">
<span v-if="eyebrow" class="module-card__eyebrow">{{ eyebrow }}</span>
<span v-if="title && variant === 'plain'" class="advanced-group__title">{{ title }}</span>
<span v-else-if="title" class="module-card__summary">{{ title }}</span>
<span v-if="summary && !open" class="module-card__summary">{{ summary }}</span>
</span>
<span class="module-card__action">{{ open ? "Hide" : "Show" }}</span>
</button>
<div v-if="open" :class="bodyClass[variant] || bodyClass.section">
<slot />
</div>
</div>
</template>
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { ImageEntry } from "../lib/types";
const props = withDefaults(defineProps<{
label: string;
description?: string;
preview?: ImageEntry | null;
items?: ImageEntry[];
multiple?: boolean;
}>(), {
description: "",
preview: null,
items: () => [],
multiple: false,
});
const emit = defineEmits<{
(e: "select", files: FileList): void;
(e: "clear"): void;
(e: "preview", entry: ImageEntry): void;
}>();
const inputRef = ref<HTMLInputElement | null>(null);
const dragging = ref(false);
const hasSelection = computed(() => Boolean(props.preview) || props.items.length > 0);
const summary = computed(() => {
return props.multiple
? (props.items.length ? `${props.items.length} file(s)` : "No files selected")
: (props.preview?.name || "No file selected");
});
function emitSelection(files: FileList | undefined | null): void {
if (!files || !files.length) {
return;
}
emit("select", files);
}
function onPick(event: Event): void {
const target = event.target as HTMLInputElement;
emitSelection(target.files);
target.value = "";
}
function onDrop(event: DragEvent): void {
dragging.value = false;
emitSelection(event.dataTransfer?.files);
}
</script>
<template>
<div class="upload-card" :class="{ 'upload-card--active': dragging }" @dragover.prevent="dragging = true" @dragleave.prevent="dragging = false" @drop.prevent="onDrop">
<button
v-if="preview"
class="upload-card__preview-button"
type="button"
@click="$emit('preview', preview)"
>
<img
class="upload-card__preview"
:src="preview.dataUrl"
:alt="preview.name"
/>
</button>
<div v-else class="upload-card__drop">
<div class="upload-card__label">{{ label }}</div>
<div>{{ description }}</div>
<div class="hint">{{ summary }}</div>
</div>
<div class="upload-card__actions">
<button class="btn-ghost" type="button" @click="inputRef?.click()">Select</button>
<button
v-if="hasSelection"
class="btn-ghost"
type="button"
@click="$emit('clear')"
>
Clear
</button>
<input
ref="inputRef"
type="file"
accept="image/*"
:multiple="multiple"
@change="onPick"
/>
</div>
</div>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { computed } from "vue";
const STATUS_TONES: Record<string, string> = {
online: "online",
completed: "online",
queued: "queued",
generating: "generating",
failed: "failed",
cancelled: "cancelled",
offline: "offline",
};
const props = withDefaults(defineProps<{
status?: string;
label?: string;
}>(), {
status: "",
label: "",
});
const tone = computed(() => STATUS_TONES[props.status] || "");
const classes = computed(() => ["chip", tone.value && `chip--${tone.value}`].filter(Boolean));
</script>
<template>
<span :class="classes">{{ label || status || "idle" }}</span>
</template>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
+56
View File
@@ -0,0 +1,56 @@
import type { Capabilities, Job } from "./types";
function withBase(baseUrl: string, path: string): string {
const base = String(baseUrl || window.location.pathname).trim().replace(/\/+$/, "");
return `${base}${path}`;
}
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init);
let payload: any = null;
try {
payload = await response.json();
} catch {
payload = null;
}
if (!response.ok) {
throw new Error(
(payload && (payload.error || payload.message)) || `HTTP ${response.status}`
);
}
return payload as T;
}
export function getCapabilities(baseUrl: string): Promise<Capabilities> {
return fetchJson<Capabilities>(withBase(baseUrl, "/sdcpp/v1/capabilities"));
}
export function submitImageJob(baseUrl: string, body: unknown): Promise<Job> {
return fetchJson<Job>(withBase(baseUrl, "/sdcpp/v1/img_gen"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
}
export function submitVideoJob(baseUrl: string, body: unknown): Promise<Job> {
return fetchJson<Job>(withBase(baseUrl, "/sdcpp/v1/vid_gen"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
}
export function getJob(baseUrl: string, id: string): Promise<Job> {
return fetchJson<Job>(withBase(baseUrl, `/sdcpp/v1/jobs/${id}`));
}
export function cancelJob(baseUrl: string, id: string): Promise<Job> {
return fetchJson<Job>(withBase(baseUrl, `/sdcpp/v1/jobs/${id}/cancel`), {
method: "POST",
});
}
@@ -0,0 +1,76 @@
import { createBlankForm } from "./form-defaults";
import type { Capabilities, GenerationForm, SampleParams } from "./types";
function finiteOrFallback(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function assignSampleParams(
target: SampleParams,
sample: Record<string, any>,
fallbackSteps: number,
): void {
const guidance = sample.guidance || {};
const slg = guidance.slg || {};
target.scheduler = sample.scheduler || "default";
target.sample_method = sample.sample_method || "default";
target.sample_steps = finiteOrFallback(sample.sample_steps, fallbackSteps);
target.eta = sample.eta == null ? "" : sample.eta;
target.shifted_timestep = finiteOrFallback(sample.shifted_timestep, 0);
target.flow_shift = sample.flow_shift == null ? "" : sample.flow_shift;
target.guidance.txt_cfg = finiteOrFallback(guidance.txt_cfg, 7);
target.guidance.img_cfg = guidance.img_cfg == null ? "" : guidance.img_cfg;
target.guidance.distilled_guidance = finiteOrFallback(guidance.distilled_guidance, 3.5);
target.guidance.slg_layers = Array.isArray(slg.layers) ? slg.layers.join(",") : "7,8,9";
target.guidance.layer_start = finiteOrFallback(slg.layer_start, 0.01);
target.guidance.layer_end = finiteOrFallback(slg.layer_end, 0.2);
target.guidance.scale = finiteOrFallback(slg.scale, 0);
}
export function formFromCapabilities(capabilities: Capabilities): GenerationForm {
const currentMode = capabilities?.current_mode;
const defaultsByMode = capabilities?.defaults_by_mode || {};
const defaults: Record<string, any> = (currentMode && defaultsByMode[currentMode]) || {};
const sample = defaults.sample_params || {};
const highNoiseSample = defaults.high_noise_sample_params || {};
const tiling = defaults.vae_tiling_params || {};
const form = createBlankForm();
form.prompt = defaults.prompt || "";
form.negative_prompt = defaults.negative_prompt || "";
form.width = finiteOrFallback(defaults.width, 512);
form.height = finiteOrFallback(defaults.height, 512);
form.batch_count = finiteOrFallback(defaults.batch_count, 1);
form.video_frames = finiteOrFallback(defaults.video_frames, 33);
form.fps = finiteOrFallback(defaults.fps, 16);
form.seed = typeof defaults.seed === "number" ? defaults.seed : -1;
form.clip_skip = typeof defaults.clip_skip === "number" ? defaults.clip_skip : -1;
form.strength = finiteOrFallback(defaults.strength, 0.75);
form.control_strength = finiteOrFallback(defaults.control_strength, 0.9);
form.moe_boundary = finiteOrFallback(defaults.moe_boundary, 0.875);
form.vace_strength = finiteOrFallback(defaults.vace_strength, 1.0);
form.output_format = defaults.output_format || "png";
form.output_compression = finiteOrFallback(defaults.output_compression, 100);
assignSampleParams(form.sample_params, sample, 20);
assignSampleParams(form.high_noise_sample_params, highNoiseSample, -1);
form.vae_tiling_params.enabled = Boolean(tiling.enabled);
form.vae_tiling_params.tile_size_x = finiteOrFallback(tiling.tile_size_x, 0);
form.vae_tiling_params.tile_size_y = finiteOrFallback(tiling.tile_size_y, 0);
form.vae_tiling_params.target_overlap = finiteOrFallback(tiling.target_overlap, 0.5);
form.vae_tiling_params.rel_size_x = finiteOrFallback(tiling.rel_size_x, 0);
form.vae_tiling_params.rel_size_y = finiteOrFallback(tiling.rel_size_y, 0);
form.cache.mode = defaults.cache_mode || "disabled";
form.cache.option = defaults.cache_option || "";
form.cache.scm_mask = defaults.scm_mask || "";
form.cache.scm_policy_dynamic =
typeof defaults.scm_policy_dynamic === "boolean"
? defaults.scm_policy_dynamic
: true;
return form;
}
@@ -0,0 +1,86 @@
import type { GenerationForm } from "./types";
export const CACHE_MODES = [
"disabled",
"easycache",
"ucache",
"dbcache",
"taylorseer",
"cache-dit",
"spectrum",
] as const;
export function createBlankForm(): GenerationForm {
return {
prompt: "",
negative_prompt: "",
width: 512,
height: 512,
batch_count: 1,
video_frames: 33,
fps: 16,
seed: -1,
clip_skip: -1,
strength: 0.75,
control_strength: 0.9,
moe_boundary: 0.875,
vace_strength: 1.0,
output_format: "png",
output_compression: 100,
sample_params: {
scheduler: "default",
sample_method: "default",
sample_steps: 20,
eta: "",
shifted_timestep: 0,
flow_shift: "",
guidance: {
txt_cfg: 7,
img_cfg: "",
distilled_guidance: 3.5,
slg_layers: "7,8,9",
layer_start: 0.01,
layer_end: 0.2,
scale: 0,
},
},
high_noise_sample_params: {
scheduler: "default",
sample_method: "default",
sample_steps: -1,
eta: "",
shifted_timestep: 0,
flow_shift: "",
guidance: {
txt_cfg: 7,
img_cfg: "",
distilled_guidance: 3.5,
slg_layers: "7,8,9",
layer_start: 0.01,
layer_end: 0.2,
scale: 0,
},
},
init_image: null,
end_image: null,
ref_images: [],
control_frames: [],
mask_image: null,
control_image: null,
lora: [],
vae_tiling_params: {
enabled: false,
tile_size_x: 0,
tile_size_y: 0,
target_overlap: 0.5,
rel_size_x: 0,
rel_size_y: 0,
},
cache: {
mode: "disabled",
option: "",
scm_mask: "",
scm_policy_dynamic: true,
},
};
}
@@ -0,0 +1,167 @@
import type { GenerationForm, GenerationMode, SampleParams } from "./types";
function parseNumber(value: unknown, fallback: number): number {
if (value === "" || value == null) {
return fallback;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function nullable(value: unknown): unknown {
return value === "" || value == null ? null : value;
}
interface SampleParamsRequest {
sample_steps: number;
shifted_timestep: number;
custom_sigmas: number[];
guidance: {
txt_cfg: number;
distilled_guidance: number;
slg: {
layers: number[];
layer_start: number;
layer_end: number;
scale: number;
};
img_cfg?: number;
};
eta?: number;
flow_shift?: number;
scheduler?: string;
sample_method?: string;
}
function buildSampleParams(sample: SampleParams, defaultSteps: number): SampleParamsRequest {
const eta = nullable(sample.eta);
const flowShift = nullable(sample.flow_shift);
const imgCfg = nullable(sample.guidance.img_cfg);
const sampleParams: SampleParamsRequest = {
sample_steps: parseNumber(sample.sample_steps, defaultSteps),
shifted_timestep: parseNumber(sample.shifted_timestep, 0),
custom_sigmas: [],
guidance: {
txt_cfg: parseNumber(sample.guidance.txt_cfg, 7),
distilled_guidance: parseNumber(sample.guidance.distilled_guidance, 3.5),
slg: {
layers: String(sample.guidance.slg_layers || "")
.split(",")
.map((item) => Number(item.trim()))
.filter((value) => Number.isInteger(value)),
layer_start: parseNumber(sample.guidance.layer_start, 0.01),
layer_end: parseNumber(sample.guidance.layer_end, 0.2),
scale: parseNumber(sample.guidance.scale, 0),
},
},
};
if (eta != null) {
sampleParams.eta = Number(eta);
}
if (flowShift != null) {
sampleParams.flow_shift = Number(flowShift);
}
if (imgCfg != null) {
sampleParams.guidance.img_cfg = Number(imgCfg);
}
const scheduler =
sample.scheduler && sample.scheduler !== "default"
? sample.scheduler
: undefined;
const sampleMethod =
sample.sample_method && sample.sample_method !== "default"
? sample.sample_method
: undefined;
if (scheduler) {
sampleParams.scheduler = scheduler;
}
if (sampleMethod) {
sampleParams.sample_method = sampleMethod;
}
return sampleParams;
}
function buildLoraRequest(form: GenerationForm) {
return form.lora
.filter((item) => String(item.path || "").trim())
.map((item) => ({
path: String(item.path).trim(),
multiplier: parseNumber(item.multiplier, 1.0),
is_high_noise: Boolean(item.is_high_noise),
}));
}
function buildTilingRequest(form: GenerationForm) {
return {
enabled: Boolean(form.vae_tiling_params.enabled),
tile_size_x: parseNumber(form.vae_tiling_params.tile_size_x, 0),
tile_size_y: parseNumber(form.vae_tiling_params.tile_size_y, 0),
target_overlap: parseNumber(form.vae_tiling_params.target_overlap, 0.5),
rel_size_x: parseNumber(form.vae_tiling_params.rel_size_x, 0),
rel_size_y: parseNumber(form.vae_tiling_params.rel_size_y, 0),
};
}
export function buildRequestBody(form: GenerationForm) {
const request = {
prompt: String(form.prompt || "").trim(),
negative_prompt: form.negative_prompt,
clip_skip: parseNumber(form.clip_skip, -1),
width: parseNumber(form.width, 512),
height: parseNumber(form.height, 512),
strength: parseNumber(form.strength, 0.75),
seed: parseNumber(form.seed, -1),
sample_params: buildSampleParams(form.sample_params, 20),
lora: buildLoraRequest(form),
vae_tiling_params: buildTilingRequest(form),
cache_mode: form.cache.mode || "disabled",
cache_option: String(form.cache.option || ""),
scm_mask: String(form.cache.scm_mask || ""),
scm_policy_dynamic: Boolean(form.cache.scm_policy_dynamic),
output_format: form.output_format,
output_compression: parseNumber(form.output_compression, 100),
};
if (!request.prompt) {
throw new Error("prompt is required");
}
return request;
}
export function buildImageRequestBody(form: GenerationForm) {
return {
...buildRequestBody(form),
batch_count: parseNumber(form.batch_count, 1),
auto_resize_ref_image: true,
increase_ref_index: false,
control_strength: parseNumber(form.control_strength, 0.9),
init_image: form.init_image ? form.init_image.dataUrl : null,
ref_images: form.ref_images.map((item) => item.dataUrl),
mask_image: form.mask_image ? form.mask_image.dataUrl : null,
control_image: form.control_image ? form.control_image.dataUrl : null,
};
}
export function buildVideoRequestBody(form: GenerationForm) {
return {
...buildRequestBody(form),
video_frames: parseNumber(form.video_frames, 33),
fps: parseNumber(form.fps, 16),
moe_boundary: parseNumber(form.moe_boundary, 0.875),
vace_strength: parseNumber(form.vace_strength, 1.0),
init_image: form.init_image ? form.init_image.dataUrl : null,
end_image: form.end_image ? form.end_image.dataUrl : null,
control_frames: form.control_frames.map((item) => item.dataUrl),
high_noise_sample_params: buildSampleParams(form.high_noise_sample_params, -1),
};
}
export function buildRequestBodyForMode(mode: GenerationMode, form: GenerationForm) {
return mode === "video" ? buildVideoRequestBody(form) : buildImageRequestBody(form);
}
+3
View File
@@ -0,0 +1,3 @@
export { CACHE_MODES, createBlankForm } from "./form-defaults";
export { formFromCapabilities } from "./form-capabilities";
export { buildImageRequestBody, buildRequestBody, buildRequestBodyForMode, buildVideoRequestBody } from "./form-request";
@@ -0,0 +1,37 @@
import type { ImageInputConfig } from "./types";
export const IMAGE_INPUTS: readonly ImageInputConfig[] = [
{
target: "init_image",
label: "Init Image",
description: "Drop, paste, or browse an image to seed generation.",
layout: "grid",
},
{
target: "mask_image",
label: "Mask Image",
description: "One-channel mask image.",
layout: "grid",
},
{
target: "control_image",
label: "Control Image",
description: "ControlNet-style guidance image.",
layout: "full",
},
];
export const VIDEO_IMAGE_INPUTS: readonly ImageInputConfig[] = [
{
target: "init_image",
label: "Start Frame",
description: "Optional first frame or seed image for the sequence.",
layout: "grid",
},
{
target: "end_image",
label: "End Frame",
description: "Optional end frame for interpolation or FLF2V-style runs.",
layout: "grid",
},
];
@@ -0,0 +1,45 @@
import type { GenerationForm, ImageEntry, ImageTarget } from "./types";
export function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error(`failed to read ${file.name}`));
reader.readAsDataURL(file);
});
}
export async function filesToImageEntries(fileList: FileList | File[]): Promise<ImageEntry[]> {
const files = Array.from(fileList || []);
return Promise.all(files.map(async (file) => ({
name: file.name,
type: file.type || "image/png",
dataUrl: await readFileAsDataUrl(file),
})));
}
export function assignImageEntries(form: GenerationForm, target: ImageTarget, images: ImageEntry[]): void {
if (target === "init_image" || target === "mask_image" || target === "control_image" || target === "end_image") {
form[target] = images[0] || null;
return;
}
if (target === "ref_images" || target === "control_frames") {
form[target].push(...images);
}
}
export function clearImageEntries(form: GenerationForm, target: ImageTarget): void {
if (target === "init_image" || target === "mask_image" || target === "control_image" || target === "end_image") {
form[target] = null;
return;
}
if (target === "ref_images" || target === "control_frames") {
form[target].splice(0);
}
}
export function removeImageEntry(form: GenerationForm, target: ImageTarget, index: number): void {
if (target === "ref_images" || target === "control_frames") {
form[target].splice(index, 1);
}
}
@@ -0,0 +1,36 @@
import { type Ref, ref, watch } from "vue";
export function normalizePollIntervalMs(value: unknown): number {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return 100;
}
return Math.max(1, Math.round(numeric));
}
export function createStoredRef<T>(key: string, fallbackValue: T, normalize: (value: any) => T = (value) => value): Ref<T> {
const state = ref(readStoredValue(key, fallbackValue, normalize)) as Ref<T>;
watch(state, (value) => {
const normalized = normalize(value);
if (normalized !== value) {
state.value = normalized;
return;
}
window.localStorage.setItem(key, JSON.stringify(normalized));
});
return state;
}
function readStoredValue<T>(key: string, fallbackValue: T, normalize: (value: any) => T): T {
try {
const storedValue = window.localStorage.getItem(key);
if (storedValue == null) {
return normalize(fallbackValue);
}
return normalize(JSON.parse(storedValue));
} catch {
return normalize(fallbackValue);
}
}
+181
View File
@@ -0,0 +1,181 @@
import type { CACHE_MODES } from "./form-defaults";
// ---------------------------------------------------------------------------
// Image helpers
// ---------------------------------------------------------------------------
export interface ImageEntry {
name: string;
type: string;
dataUrl: string;
}
export type ImageTarget =
| "init_image"
| "mask_image"
| "control_image"
| "end_image"
| "ref_images"
| "control_frames";
export type GenerationMode = "image" | "video";
export interface ImageInputConfig {
target: ImageTarget;
label: string;
description: string;
layout: "grid" | "full";
}
// ---------------------------------------------------------------------------
// Form model
// ---------------------------------------------------------------------------
export interface GuidanceParams {
txt_cfg: number;
img_cfg: string | number;
distilled_guidance: number;
slg_layers: string;
layer_start: number;
layer_end: number;
scale: number;
}
export interface SampleParams {
scheduler: string;
sample_method: string;
sample_steps: number;
eta: string | number;
shifted_timestep: number;
flow_shift: string | number;
guidance: GuidanceParams;
}
export interface VaeTilingParams {
enabled: boolean;
tile_size_x: number;
tile_size_y: number;
target_overlap: number;
rel_size_x: number;
rel_size_y: number;
}
export interface CacheParams {
mode: string;
option: string;
scm_mask: string;
scm_policy_dynamic: boolean;
}
export interface FormLoraEntry {
path: string;
multiplier: number;
is_high_noise: boolean;
}
export interface GenerationForm {
prompt: string;
negative_prompt: string;
width: number;
height: number;
batch_count: number;
video_frames: number;
fps: number;
seed: number;
clip_skip: number;
strength: number;
control_strength: number;
moe_boundary: number;
vace_strength: number;
output_format: string;
output_compression: number;
sample_params: SampleParams;
high_noise_sample_params: SampleParams;
init_image: ImageEntry | null;
end_image: ImageEntry | null;
ref_images: ImageEntry[];
control_frames: ImageEntry[];
mask_image: ImageEntry | null;
control_image: ImageEntry | null;
lora: FormLoraEntry[];
vae_tiling_params: VaeTilingParams;
cache: CacheParams;
}
// ---------------------------------------------------------------------------
// LoRA
// ---------------------------------------------------------------------------
export interface AvailableLora {
name: string;
path: string;
}
// ---------------------------------------------------------------------------
// Capabilities (API response)
// ---------------------------------------------------------------------------
export interface Capabilities {
model?: {
stem?: string;
name?: string;
};
current_mode?: "img_gen" | "vid_gen";
supported_modes?: Array<"img_gen" | "vid_gen">;
output_formats?: string[];
output_formats_by_mode?: Partial<Record<"img_gen" | "vid_gen", string[]>>;
samplers?: string[];
schedulers?: string[];
loras?: AvailableLora[];
limits?: {
max_queue_size?: number;
};
features?: {
cancel_queued?: boolean;
cancel_generating?: boolean;
};
features_by_mode?: Partial<Record<"img_gen" | "vid_gen", Record<string, any>>>;
defaults?: Record<string, any>;
defaults_by_mode?: Partial<Record<"img_gen" | "vid_gen", Record<string, any>>>;
}
// ---------------------------------------------------------------------------
// Job (API response)
// ---------------------------------------------------------------------------
export interface ImageOutput {
index: number;
b64_json: string;
}
export type JobKind = "img_gen" | "vid_gen";
export interface JobResult {
images?: ImageOutput[];
output_format?: string;
b64_json?: string;
mime_type?: string;
fps?: number;
frame_count?: number;
}
export interface Job {
id: string;
kind?: JobKind;
status: string;
queue_position?: number;
created?: number;
started?: number;
completed?: number;
result?: JobResult | null;
error?: {
code?: string;
message?: string;
} | null;
}
// ---------------------------------------------------------------------------
// Cache mode literal union
// ---------------------------------------------------------------------------
export type CacheMode = (typeof CACHE_MODES)[number];
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./styles.css";
createApp(App).mount("#app");
+927
View File
@@ -0,0 +1,927 @@
:root {
--bg: #f6f7f8;
--panel: #ffffff;
--panel-subtle: #fafafa;
--text: #111827;
--muted: #6b7280;
--border: #e5e7eb;
--border-strong: #d1d5db;
--shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
--chip-bg: #f3f4f6;
--primary: #111827;
--primary-soft: #f3f4f6;
--success: #0f766e;
--warning: #9a6700;
--danger: #b42318;
--radius-lg: 16px;
--radius-md: 12px;
--radius-sm: 10px;
--font-ui: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--checker-bg:
linear-gradient(45deg, #f3f4f6 25%, transparent 25%),
linear-gradient(-45deg, #f3f4f6 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #f3f4f6 75%),
linear-gradient(-45deg, transparent 75%, #f3f4f6 75%),
#ffffff;
--checker-size-sm: 12px 12px;
--checker-position-sm:
0 0,
0 6px,
6px -6px,
-6px 0;
--checker-size-md: 16px 16px;
--checker-position-md:
0 0,
0 8px,
8px -8px,
-8px 0;
--checker-size-lg: 20px 20px;
--checker-position-lg:
0 0,
0 10px,
10px -10px,
-10px 0;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
background: var(--bg);
color: var(--text);
font-family: var(--font-ui);
}
body {
min-width: 320px;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
#app {
width: min(1440px, calc(100vw - 32px));
margin: 0 auto;
padding: 24px 0 40px;
}
.shell {
display: grid;
gap: 20px;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.page-header {
padding: 20px 24px 0;
}
.page-header__top {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: flex-start;
}
.page-header__copy {
min-width: 0;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--muted);
margin-bottom: 8px;
}
.breadcrumb__org {
font-weight: 600;
color: var(--text);
}
.breadcrumb__slash {
color: #9ca3af;
}
.page-title {
margin: 0;
font-size: 32px;
line-height: 1.1;
font-weight: 650;
letter-spacing: -0.02em;
}
.page-description {
margin: 10px 0 0;
max-width: 680px;
color: var(--muted);
line-height: 1.5;
}
.page-header__meta,
.actions,
.page-tabs,
.page-tabs__list,
.page-tabs__actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.page-header__meta {
justify-content: flex-end;
}
.chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: 999px;
background: var(--chip-bg);
border: 1px solid var(--border);
color: var(--muted);
font-size: 12px;
font-weight: 600;
}
.chip::before {
content: "";
width: 7px;
height: 7px;
border-radius: 999px;
background: currentColor;
}
.chip--online,
.chip--completed {
color: var(--success);
}
.chip--queued,
.chip--generating {
color: var(--warning);
}
.chip--offline,
.chip--failed,
.chip--cancelled {
color: var(--danger);
}
.btn,
.btn-secondary,
.btn-ghost,
.btn-danger,
.page-tab {
border: 1px solid var(--border);
border-radius: 999px;
padding: 10px 14px;
background: #fff;
color: var(--text);
transition: background-color 120ms ease, border-color 120ms ease, opacity 120ms ease;
}
.btn:hover,
.btn-secondary:hover,
.btn-ghost:hover,
.btn-danger:hover,
.page-tab:hover {
background: var(--panel-subtle);
}
.btn:disabled,
.btn-secondary:disabled,
.btn-ghost:disabled,
.btn-danger:disabled,
.page-tab:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn {
background: var(--primary);
border-color: var(--primary);
color: #fff;
font-weight: 600;
}
.btn:hover {
background: #1f2937;
}
.btn-secondary {
background: var(--primary-soft);
font-weight: 600;
}
.btn-danger {
background: #fff;
border-color: #f0c7c3;
color: var(--danger);
font-weight: 600;
}
.page-tabs {
margin-top: 18px;
padding: 0 0 16px;
border-top: 1px solid transparent;
justify-content: space-between;
}
.page-tabs__list,
.page-tabs__actions {
min-width: 0;
}
.mode-switch {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.page-tab {
background: transparent;
color: var(--muted);
font-size: 13px;
padding: 8px 12px;
}
.page-tab--active {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.page-tab--active:hover,
.page-tab--active:focus {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.settings {
padding: 0 0 20px;
}
.settings__grid,
.metrics,
.fields,
.upload-grid {
display: grid;
gap: 12px;
}
.settings__grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(420px, 520px);
gap: 20px;
align-items: start;
}
.control-panel,
.output-panel {
padding: 20px;
}
.control-panel {
order: 1;
min-width: 0;
}
.output-panel {
order: 2;
position: sticky;
top: 24px;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.panel-title {
margin: 0;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.prompt-card,
.advanced-panel,
.metric,
.upload-card,
.status-message {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: #fff;
}
.prompt-card {
padding: 16px;
}
.prompt-card textarea {
border: 0;
min-height: 140px;
resize: vertical;
padding: 0;
background: transparent;
font-size: 15px;
line-height: 1.6;
}
.fields {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.field,
.field--full,
.group {
display: flex;
flex-direction: column;
gap: 8px;
}
.field--full {
grid-column: 1 / -1;
}
:is(.field, .field--full, .group) label,
.upload-card__label {
font-size: 13px;
font-weight: 600;
}
:is(.field, .field--full, .group) :is(input, select, textarea),
.settings input {
width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
background: #fff;
color: var(--text);
outline: none;
}
:is(.field, .field--full, .group) textarea {
min-height: 110px;
resize: vertical;
}
:is(.field, .field--full, .group) :is(input, select, textarea):focus,
.settings input:focus {
border-color: #9ca3af;
box-shadow: 0 0 0 3px rgba(17, 24, 39, 0.06);
}
.stack-top {
margin-top: 16px;
}
.hint {
font-size: 13px;
color: var(--muted);
line-height: 1.5;
}
.sample-panel__extras {
display: flex;
justify-content: flex-end;
}
.module-card {
overflow: hidden;
box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
}
.module-card__copy {
display: grid;
gap: 4px;
text-align: left;
}
.module-card__eyebrow {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.module-card__summary {
font-size: 13px;
color: var(--muted);
}
.module-card__action {
font-size: 12px;
font-weight: 600;
color: var(--muted);
padding-top: 2px;
}
.module-card__link {
border: 0;
background: transparent;
padding: 0;
font: inherit;
font-size: 12px;
font-weight: 600;
color: #4b5563;
}
.module-card__link:hover {
color: var(--text);
}
.advanced-group {
display: grid;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--panel-subtle);
overflow: hidden;
}
.advanced-group.module-card {
background: #fff;
}
.advanced-group__title {
margin: 0;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.advanced-group__toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px;
border: 0;
background: transparent;
border-bottom: 1px solid var(--border);
}
.advanced-group__content {
display: grid;
gap: 12px;
padding: 14px;
min-width: 0;
}
.module-card > .advanced-group__toggle {
padding: 16px;
align-items: flex-start;
background: linear-gradient(180deg, rgba(249, 250, 251, 0.9), rgba(255, 255, 255, 1));
}
.module-card > .advanced-group__content {
gap: 16px;
padding: 16px;
background: #fff;
}
.upload-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.upload-card {
overflow: hidden;
}
.upload-card__drop {
min-height: 140px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
padding: 16px;
text-align: center;
color: var(--muted);
background: var(--panel-subtle);
}
.upload-card__preview-button {
width: 100%;
min-height: 180px;
max-height: 220px;
padding: 14px;
border: 0;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
}
.upload-card__preview {
width: auto;
max-width: 100%;
max-height: 180px;
object-fit: contain;
display: block;
}
.upload-card__actions {
display: flex;
gap: 8px;
padding: 12px;
border-top: 1px solid var(--border);
}
.upload-card__actions input {
display: none;
}
.upload-card--active {
border-color: #9ca3af;
}
.upload-card__preview-button,
.thumb {
background: var(--checker-bg);
background-size: var(--checker-size-md);
background-position: var(--checker-position-md);
}
.hero-frame {
border: 1px solid var(--border);
border-radius: var(--radius-md);
min-height: 360px;
max-height: 520px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.hero-frame,
.lightbox__image {
background: var(--checker-bg);
background-size: var(--checker-size-lg);
background-position: var(--checker-position-lg);
}
.hero-frame--button {
width: 100%;
padding: 0;
}
.hero-frame--media {
background: #0f172a;
}
.hero-frame img {
width: 100%;
height: 100%;
max-height: 520px;
object-fit: contain;
display: block;
}
.hero-frame__video {
width: 100%;
height: 100%;
max-height: 520px;
display: block;
background: #000;
}
.hero-placeholder {
padding: 32px;
text-align: center;
max-width: 420px;
}
.hero-placeholder h2 {
margin: 0 0 10px;
font-size: 24px;
letter-spacing: -0.02em;
}
.hero-placeholder p {
margin: 0;
color: var(--muted);
line-height: 1.6;
}
.output-metrics {
margin-top: 14px;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
}
.metric {
padding: 14px;
}
.metric__label {
font-size: 12px;
color: var(--muted);
margin-bottom: 8px;
}
.metric__value {
font-size: 14px;
font-weight: 600;
line-height: 1.5;
}
.thumb-row {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
gap: 10px;
}
.thumb {
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
min-height: 88px;
padding: 0;
}
.thumb img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.thumb--active {
border-color: #111827;
}
.status-message {
margin-top: 14px;
padding: 12px 14px;
color: var(--muted);
}
.status-message--error {
color: var(--danger);
border-color: #f0c7c3;
background: #fff7f6;
}
.status-message--success {
color: var(--success);
border-color: #b8e0db;
background: #f4fbfa;
}
.output-controls {
margin-top: 14px;
display: grid;
gap: 10px;
width: 100%;
}
.output-controls__primary {
width: 100%;
justify-content: center;
}
.output-controls__secondary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
width: 100%;
}
.output-controls__secondary > * {
width: 100%;
justify-content: center;
}
.file-list {
display: grid;
gap: 8px;
}
.file-chip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: #fff;
font-size: 13px;
}
.file-chip--preview {
justify-content: flex-start;
}
.file-chip__thumb {
width: 48px;
height: 48px;
border-radius: 10px;
object-fit: cover;
border: 1px solid var(--border);
background: var(--checker-bg);
background-size: var(--checker-size-sm);
background-position: var(--checker-position-sm);
flex: 0 0 auto;
}
.file-chip__thumb-button {
border: 0;
background: transparent;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.file-chip__name {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-button {
border: 0;
background: transparent;
color: var(--danger);
padding: 0;
}
.list-editor {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(96px, 0.8fr) auto auto;
gap: 10px;
min-width: 0;
}
.list-row {
display: grid;
grid-template-columns: subgrid;
grid-column: 1 / -1;
gap: 10px;
align-items: center;
min-width: 0;
}
.list-row--header {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.list-row > * {
min-width: 0;
}
.list-row__checkbox {
justify-self: start;
}
.checkbox {
display: inline-flex;
align-items: center;
gap: 8px;
}
.mono {
font-family: var(--font-mono);
font-size: 12px;
}
.lightbox {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(17, 24, 39, 0.82);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.lightbox__image {
max-width: min(92vw, 1600px);
max-height: 88vh;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
}
.lightbox__close {
position: absolute;
top: 18px;
right: 18px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(17, 24, 39, 0.56);
color: #fff;
border-radius: 999px;
padding: 10px 14px;
}
@media (max-width: 1180px) {
.list-row {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
}
.output-panel {
position: static;
top: auto;
order: 1;
}
.control-panel {
order: 2;
}
.settings__grid,
.fields,
.output-metrics,
.list-row,
.output-controls__secondary {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
#app {
width: calc(100vw - 16px);
padding-top: 12px;
}
.page-header,
.control-panel,
.output-panel {
padding: 16px;
}
.page-header__top,
.panel-header {
flex-direction: column;
}
.page-header__meta {
justify-content: flex-start;
}
.page-tabs {
flex-direction: column;
align-items: flex-start;
}
.page-title {
font-size: 28px;
}
.hero-frame {
min-height: 280px;
max-height: 420px;
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"isolatedModules": true,
"skipLibCheck": true,
"esModuleInterop": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue", "src/env.d.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
const { defineConfig } = require("vite");
const vue = require("@vitejs/plugin-vue");
const { viteSingleFile } = require("vite-plugin-singlefile");
module.exports = defineConfig({
plugins: [
vue(),
viteSingleFile(),
],
});