Initial release
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+732
@@ -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}`);
|
||||
@@ -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
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from "vue";
|
||||
|
||||
import App from "./App.vue";
|
||||
import "./styles.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user