notaneimu commited on
Commit
6cc4b75
·
0 Parent(s):

Initial layered RMBG editor

Browse files
Files changed (13) hide show
  1. .gitattributes +1 -0
  2. .gitignore +5 -0
  3. README.md +41 -0
  4. bun.lock +263 -0
  5. index.html +17 -0
  6. package.json +24 -0
  7. public/favicon.svg +8 -0
  8. src/App.vue +2811 -0
  9. src/env.d.ts +1 -0
  10. src/main.ts +5 -0
  11. src/style.css +547 -0
  12. tsconfig.json +17 -0
  13. vite.config.ts +6 -0
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ dist/**/*.wasm filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ node_modules
2
+ .DS_Store
3
+ dist
4
+ .playwright-cli
5
+ output
README.md ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: RMBG Layers
3
+ emoji: 🪄
4
+ colorFrom: orange
5
+ colorTo: yellow
6
+ sdk: static
7
+ app_file: dist/index.html
8
+ pinned: false
9
+ ---
10
+
11
+ # RMBG Layers
12
+
13
+ Browser-based image and video compositor for simple hide-behind-object effects. It uses
14
+ `onnxruntime-web` on `WebGPU` with BRIA RMBG-1.4 loaded directly from Hugging Face.
15
+
16
+ ## Features
17
+
18
+ - Remove the background from a still image or a short video entirely in the browser.
19
+ - Add text layers with editable size, style, weight, family, and color.
20
+ - Insert extra image layers between the original source and the foreground cutout.
21
+ - Drag text and image layers directly on the preview.
22
+ - Export a final composite as `PNG` for images or `WebM` for videos.
23
+
24
+ ## Local development
25
+
26
+ ```bash
27
+ bun install
28
+ bun run dev
29
+ ```
30
+
31
+ Build for production:
32
+
33
+ ```bash
34
+ bun run build
35
+ ```
36
+
37
+ ## Model
38
+
39
+ The app uses `BRIA RMBG-1.4 FP16` from `briaai/RMBG-1.4`. It is WebGPU-only and does not include a
40
+ WASM fallback. Inference uses the reference `1024×1024` input size, then reapplies the predicted
41
+ mask at the original image or video frame size.
bun.lock ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "webrmbg",
7
+ "dependencies": {
8
+ "onnxruntime-web": "^1.22.0",
9
+ "vue": "^3.5.13",
10
+ "webm-muxer": "^5.1.4",
11
+ },
12
+ "devDependencies": {
13
+ "@vitejs/plugin-vue": "^5.2.1",
14
+ "typescript": "^5.8.2",
15
+ "vite": "^6.2.0",
16
+ "vue-tsc": "^2.2.8",
17
+ },
18
+ },
19
+ },
20
+ "packages": {
21
+ "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
22
+
23
+ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
24
+
25
+ "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
26
+
27
+ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
28
+
29
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
30
+
31
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
32
+
33
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
34
+
35
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
36
+
37
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
38
+
39
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
40
+
41
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
42
+
43
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
44
+
45
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
46
+
47
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
48
+
49
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
50
+
51
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
52
+
53
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
54
+
55
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
56
+
57
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
58
+
59
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
60
+
61
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
62
+
63
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
64
+
65
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
66
+
67
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
68
+
69
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
70
+
71
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
72
+
73
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
74
+
75
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
76
+
77
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
78
+
79
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
80
+
81
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
82
+
83
+ "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
84
+
85
+ "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
86
+
87
+ "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
88
+
89
+ "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
90
+
91
+ "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
92
+
93
+ "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
94
+
95
+ "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
96
+
97
+ "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
98
+
99
+ "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
100
+
101
+ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
102
+
103
+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
104
+
105
+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
106
+
107
+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
108
+
109
+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
110
+
111
+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
112
+
113
+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
114
+
115
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
116
+
117
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
118
+
119
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
120
+
121
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
122
+
123
+ "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
124
+
125
+ "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
126
+
127
+ "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
128
+
129
+ "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
130
+
131
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
132
+
133
+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
134
+
135
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
136
+
137
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
138
+
139
+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
140
+
141
+ "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
142
+
143
+ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
144
+
145
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
146
+
147
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
148
+
149
+ "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
150
+
151
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
152
+
153
+ "@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.18", "", {}, "sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg=="],
154
+
155
+ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
156
+
157
+ "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
158
+
159
+ "@types/wicg-file-system-access": ["@types/wicg-file-system-access@2020.9.8", "", {}, "sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A=="],
160
+
161
+ "@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
162
+
163
+ "@volar/language-core": ["@volar/language-core@2.4.15", "", { "dependencies": { "@volar/source-map": "2.4.15" } }, "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA=="],
164
+
165
+ "@volar/source-map": ["@volar/source-map@2.4.15", "", {}, "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg=="],
166
+
167
+ "@volar/typescript": ["@volar/typescript@2.4.15", "", { "dependencies": { "@volar/language-core": "2.4.15", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg=="],
168
+
169
+ "@vue/compiler-core": ["@vue/compiler-core@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.30", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw=="],
170
+
171
+ "@vue/compiler-dom": ["@vue/compiler-dom@3.5.30", "", { "dependencies": { "@vue/compiler-core": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g=="],
172
+
173
+ "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.30", "@vue/compiler-dom": "3.5.30", "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.8", "source-map-js": "^1.2.1" } }, "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A=="],
174
+
175
+ "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA=="],
176
+
177
+ "@vue/compiler-vue2": ["@vue/compiler-vue2@2.7.16", "", { "dependencies": { "de-indent": "^1.0.2", "he": "^1.2.0" } }, "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A=="],
178
+
179
+ "@vue/language-core": ["@vue/language-core@2.2.12", "", { "dependencies": { "@volar/language-core": "2.4.15", "@vue/compiler-dom": "^3.5.0", "@vue/compiler-vue2": "^2.7.16", "@vue/shared": "^3.5.0", "alien-signals": "^1.0.3", "minimatch": "^9.0.3", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA=="],
180
+
181
+ "@vue/reactivity": ["@vue/reactivity@3.5.30", "", { "dependencies": { "@vue/shared": "3.5.30" } }, "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q=="],
182
+
183
+ "@vue/runtime-core": ["@vue/runtime-core@3.5.30", "", { "dependencies": { "@vue/reactivity": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg=="],
184
+
185
+ "@vue/runtime-dom": ["@vue/runtime-dom@3.5.30", "", { "dependencies": { "@vue/reactivity": "3.5.30", "@vue/runtime-core": "3.5.30", "@vue/shared": "3.5.30", "csstype": "^3.2.3" } }, "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw=="],
186
+
187
+ "@vue/server-renderer": ["@vue/server-renderer@3.5.30", "", { "dependencies": { "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "vue": "3.5.30" } }, "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ=="],
188
+
189
+ "@vue/shared": ["@vue/shared@3.5.30", "", {}, "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ=="],
190
+
191
+ "alien-signals": ["alien-signals@1.0.13", "", {}, "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg=="],
192
+
193
+ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
194
+
195
+ "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
196
+
197
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
198
+
199
+ "de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
200
+
201
+ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
202
+
203
+ "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
204
+
205
+ "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
206
+
207
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
208
+
209
+ "flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="],
210
+
211
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
212
+
213
+ "guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="],
214
+
215
+ "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
216
+
217
+ "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
218
+
219
+ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
220
+
221
+ "minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
222
+
223
+ "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
224
+
225
+ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
226
+
227
+ "onnxruntime-common": ["onnxruntime-common@1.24.3", "", {}, "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA=="],
228
+
229
+ "onnxruntime-web": ["onnxruntime-web@1.24.3", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.24.3", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-41dDq7fxtTm0XzGE7N0d6m8FcOY8EWtUA65GkOixJPB/G7DGzBmiDAnVVXHznRw9bgUZpb+4/1lQK/PNxGpbrQ=="],
230
+
231
+ "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
232
+
233
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
234
+
235
+ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
236
+
237
+ "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
238
+
239
+ "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
240
+
241
+ "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
242
+
243
+ "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
244
+
245
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
246
+
247
+ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
248
+
249
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
250
+
251
+ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
252
+
253
+ "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
254
+
255
+ "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
256
+
257
+ "vue": ["vue@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/compiler-sfc": "3.5.30", "@vue/runtime-dom": "3.5.30", "@vue/server-renderer": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg=="],
258
+
259
+ "vue-tsc": ["vue-tsc@2.2.12", "", { "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw=="],
260
+
261
+ "webm-muxer": ["webm-muxer@5.1.4", "", { "dependencies": { "@types/dom-webcodecs": "^0.1.4", "@types/wicg-file-system-access": "^2020.9.5" } }, "sha512-ditzgFVFbfqPaugkIr4mGhAdob5K9HY6Rzlh7TRsA368yA1sp/m5O7nQCcMLdgFDeNGtFPg8B+MeXLtpzKWX6Q=="],
262
+ }
263
+ }
index.html ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta
7
+ name="description"
8
+ content="Static Hugging Face Space for browser-side background removal with ONNX Runtime Web and BRIA RMBG-1.4."
9
+ />
10
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
11
+ <title>RMBG Layers</title>
12
+ </head>
13
+ <body>
14
+ <div id="app"></div>
15
+ <script type="module" src="/src/main.ts"></script>
16
+ </body>
17
+ </html>
package.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "rmbglayers",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "packageManager": "bun@1.2.23",
7
+ "scripts": {
8
+ "dev": "vite",
9
+ "build": "vue-tsc --noEmit && vite build",
10
+ "preview": "vite preview",
11
+ "typecheck": "vue-tsc --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "onnxruntime-web": "^1.22.0",
15
+ "vue": "^3.5.13",
16
+ "webm-muxer": "^5.1.4"
17
+ },
18
+ "devDependencies": {
19
+ "@vitejs/plugin-vue": "^5.2.1",
20
+ "typescript": "^5.8.2",
21
+ "vite": "^6.2.0",
22
+ "vue-tsc": "^2.2.8"
23
+ }
24
+ }
public/favicon.svg ADDED
src/App.vue ADDED
@@ -0,0 +1,2811 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
3
+ import * as ort from "onnxruntime-web";
4
+ import { ArrayBufferTarget, Muxer } from "webm-muxer";
5
+ import ortWasmThreadedMjsUrl from "/node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.mjs?url";
6
+ import ortWasmThreadedWasmUrl from "/node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.wasm?url";
7
+ import ortWasmThreadedJsepMjsUrl from "/node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.jsep.mjs?url";
8
+ import ortWasmThreadedJsepWasmUrl from "/node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.jsep.wasm?url";
9
+
10
+ const MODEL_DIMENSION = 1024;
11
+ const VIDEO_CAPTURE_FPS = 30;
12
+ const ACCEPTED_FILE_TYPES = [
13
+ "image/png",
14
+ "image/jpeg",
15
+ "image/webp",
16
+ "video/mp4",
17
+ "video/webm",
18
+ "video/quicktime",
19
+ "video/x-m4v",
20
+ ].join(",");
21
+ const FONT_FAMILY_OPTIONS = [
22
+ "Space Grotesk",
23
+ "Georgia",
24
+ "Impact",
25
+ "Courier New",
26
+ "Trebuchet MS",
27
+ "Times New Roman",
28
+ "Verdana",
29
+ ];
30
+ const FONT_WEIGHT_OPTIONS = [300, 400, 500, 600, 700, 800];
31
+
32
+ type PreviewUrlKind = "source" | "result";
33
+ type MediaKind = "image" | "video";
34
+
35
+ interface ModelOption {
36
+ id: string;
37
+ label: string;
38
+ source: string;
39
+ url: string;
40
+ sizeLabel: string;
41
+ }
42
+
43
+ interface FileSummary {
44
+ kind: MediaKind;
45
+ name: string;
46
+ size: string;
47
+ dimensions: string;
48
+ duration?: string;
49
+ fps?: string;
50
+ }
51
+
52
+ interface ResultSummary {
53
+ duration: string;
54
+ backend: string;
55
+ output: string;
56
+ fps?: string;
57
+ frames?: string;
58
+ note?: string;
59
+ }
60
+
61
+ interface CachedModelData {
62
+ bytes: Uint8Array;
63
+ url: string;
64
+ }
65
+
66
+ interface FramePipeline {
67
+ inputCanvas: HTMLCanvasElement;
68
+ inputContext: CanvasRenderingContext2D;
69
+ rawMaskCanvas: HTMLCanvasElement;
70
+ rawMaskContext: CanvasRenderingContext2D;
71
+ scaledMaskCanvas: HTMLCanvasElement;
72
+ scaledMaskContext: CanvasRenderingContext2D;
73
+ outputCanvas: HTMLCanvasElement;
74
+ outputContext: CanvasRenderingContext2D;
75
+ }
76
+
77
+ interface StageSize {
78
+ width: number;
79
+ height: number;
80
+ }
81
+
82
+ interface ProcessedVideoMeta {
83
+ width: number;
84
+ height: number;
85
+ startFrame: number;
86
+ targetFrames: number;
87
+ }
88
+
89
+ interface LayerBase {
90
+ id: string;
91
+ type: "text" | "image";
92
+ name: string;
93
+ visible: boolean;
94
+ placement: "middle" | "front";
95
+ x: number;
96
+ y: number;
97
+ scale: number;
98
+ rotation: number;
99
+ }
100
+
101
+ interface TextLayer extends LayerBase {
102
+ type: "text";
103
+ text: string;
104
+ fontSize: number;
105
+ fontStyle: "normal" | "italic";
106
+ fontWeight: number;
107
+ fontFamily: string;
108
+ color: string;
109
+ }
110
+
111
+ interface ImageLayer extends LayerBase {
112
+ type: "image";
113
+ url: string;
114
+ width: number;
115
+ height: number;
116
+ naturalWidth: number;
117
+ naturalHeight: number;
118
+ }
119
+
120
+ type EditorLayer = TextLayer | ImageLayer;
121
+
122
+ interface DragState {
123
+ layerId: string;
124
+ pointerId: number;
125
+ mode: "move" | "scale" | "rotate";
126
+ moved: boolean;
127
+ wasSelected: boolean;
128
+ offsetX: number;
129
+ offsetY: number;
130
+ startScale: number;
131
+ startRotation: number;
132
+ startDistance: number;
133
+ startAngle: number;
134
+ startPointerX: number;
135
+ startPointerY: number;
136
+ }
137
+
138
+ const RMBG_MODEL: ModelOption = {
139
+ id: "bria-rmbg-1.4-fp16",
140
+ label: "BRIA RMBG-1.4 FP16",
141
+ source: "briaai/RMBG-1.4",
142
+ url: "https://huggingface.co/briaai/RMBG-1.4/resolve/main/onnx/model_fp16.onnx",
143
+ sizeLabel: "88 MB",
144
+ };
145
+
146
+ const fileInput = ref<HTMLInputElement | null>(null);
147
+ const layerImageInput = ref<HTMLInputElement | null>(null);
148
+ const editorViewportRef = ref<HTMLElement | null>(null);
149
+ const stageFrameRef = ref<HTMLElement | null>(null);
150
+ const sourcePreviewVideoRef = ref<HTMLVideoElement | null>(null);
151
+ const resultPreviewCanvasRef = ref<HTMLCanvasElement | null>(null);
152
+ const session = ref<ort.InferenceSession | null>(null);
153
+ const isBusy = ref(false);
154
+ const isExporting = ref(false);
155
+ const isModelLoading = ref(false);
156
+ const isDragging = ref(false);
157
+ const isPreviewPlaying = ref(false);
158
+ const statusText = ref("Drop an image or video, then load the model.");
159
+ const errorText = ref("");
160
+ const activeBackend = ref("pending");
161
+ const selectedFile = ref<File | null>(null);
162
+ const selectedMediaKind = ref<MediaKind | null>(null);
163
+ const loadedModelId = ref<string | null>(null);
164
+ const sourcePreviewUrl = ref("");
165
+ const resultPreviewUrl = ref("");
166
+ const sourceSummary = ref<FileSummary | null>(null);
167
+ const resultSummary = ref<ResultSummary | null>(null);
168
+ const sourceDurationSeconds = ref<number | null>(null);
169
+ const stageSize = ref<StageSize | null>(null);
170
+ const progressPercent = ref<number | null>(null);
171
+ const startFrameInput = ref("");
172
+ const videoFrameCapInput = ref("");
173
+ const processedVideoMeta = ref<ProcessedVideoMeta | null>(null);
174
+ const processedVideoFrames = ref<ImageBitmap[]>([]);
175
+ const editorViewportSize = ref<StageSize>({ width: 0, height: 0 });
176
+ const userLayers = ref<EditorLayer[]>([]);
177
+ const selectedLayerId = ref<string | null>(null);
178
+ const modelDataCache = new Map<string, CachedModelData>();
179
+ const pendingModelLoads = new Map<string, Promise<Uint8Array>>();
180
+ let resizeObserver: ResizeObserver | null = null;
181
+ let dragState: DragState | null = null;
182
+ let lastLayerInteraction: { layerId: string; wasSelected: boolean; moved: boolean; mode: DragState["mode"] } | null = null;
183
+ let layerSequence = 0;
184
+ let measurementCanvas: HTMLCanvasElement | null = null;
185
+ let previewForegroundRafId: number | null = null;
186
+
187
+ const loadButtonDisabled = computed(() => isModelLoading.value || isBusy.value || isExporting.value);
188
+ const isReady = computed(() => session.value !== null && loadedModelId.value === RMBG_MODEL.id);
189
+ const hasSource = computed(() => sourcePreviewUrl.value.length > 0 && stageSize.value !== null);
190
+ const hasForeground = computed(() => (
191
+ selectedMediaKind.value === "video"
192
+ ? processedVideoFrames.value.length > 0
193
+ : resultPreviewUrl.value.length > 0
194
+ ));
195
+ const hasVideoSource = computed(() => selectedMediaKind.value === "video");
196
+ const modelStateLabel = computed(() => {
197
+ if (isReady.value) {
198
+ return "Ready";
199
+ }
200
+
201
+ if (isModelLoading.value) {
202
+ return "Loading";
203
+ }
204
+
205
+ if (isBusy.value) {
206
+ return "Running";
207
+ }
208
+
209
+ return "Not loaded";
210
+ });
211
+ const progressLabel = computed(() => (
212
+ progressPercent.value === null ? "—" : `${Math.round(progressPercent.value)}%`
213
+ ));
214
+ const selectedMediaLabel = computed(() => {
215
+ if (selectedMediaKind.value === "video") {
216
+ return "video";
217
+ }
218
+
219
+ return "image";
220
+ });
221
+ const frameCapValue = computed<number | null>(() => {
222
+ const trimmed = videoFrameCapInput.value.trim();
223
+ if (!trimmed) {
224
+ return null;
225
+ }
226
+
227
+ if (!/^\d+$/.test(trimmed)) {
228
+ return Number.NaN;
229
+ }
230
+
231
+ const parsed = Number.parseInt(trimmed, 10);
232
+ return parsed > 0 ? parsed : Number.NaN;
233
+ });
234
+ const startFrameValue = computed<number>(() => {
235
+ const trimmed = startFrameInput.value.trim();
236
+ if (!trimmed) {
237
+ return 0;
238
+ }
239
+
240
+ if (!/^\d+$/.test(trimmed)) {
241
+ return Number.NaN;
242
+ }
243
+
244
+ return Number.parseInt(trimmed, 10);
245
+ });
246
+ const hasValidFrameCap = computed(() => frameCapValue.value === null || Number.isFinite(frameCapValue.value));
247
+ const hasValidStartFrame = computed(() => Number.isFinite(startFrameValue.value));
248
+ const canRun = computed(
249
+ () => selectedFile.value !== null
250
+ && isReady.value
251
+ && !isBusy.value
252
+ && !isModelLoading.value
253
+ && hasValidFrameCap.value
254
+ && hasValidStartFrame.value,
255
+ );
256
+ const canExport = computed(() => {
257
+ if (!hasSource.value || isBusy.value || isModelLoading.value || isExporting.value) {
258
+ return false;
259
+ }
260
+
261
+ if (selectedMediaKind.value === "video") {
262
+ return hasForeground.value && processedVideoMeta.value !== null;
263
+ }
264
+
265
+ return true;
266
+ });
267
+ const previewStartSeconds = computed<number>(() => {
268
+ if (selectedMediaKind.value !== "video" || !Number.isFinite(startFrameValue.value)) {
269
+ return 0;
270
+ }
271
+
272
+ const duration = sourceDurationSeconds.value;
273
+ const startSeconds = startFrameValue.value / VIDEO_CAPTURE_FPS;
274
+ if (!duration || !Number.isFinite(duration)) {
275
+ return startSeconds;
276
+ }
277
+
278
+ return Math.min(duration, startSeconds);
279
+ });
280
+ const previewCapDurationSeconds = computed<number | null>(() => {
281
+ if (selectedMediaKind.value !== "video") {
282
+ return null;
283
+ }
284
+
285
+ const cap = frameCapValue.value;
286
+ const duration = sourceDurationSeconds.value;
287
+ const startSeconds = previewStartSeconds.value;
288
+ if (!duration || !Number.isFinite(duration)) {
289
+ return cap === null || !Number.isFinite(cap) ? null : startSeconds + cap / VIDEO_CAPTURE_FPS;
290
+ }
291
+
292
+ if (cap === null || !Number.isFinite(cap)) {
293
+ return duration;
294
+ }
295
+
296
+ return Math.min(duration, startSeconds + cap / VIDEO_CAPTURE_FPS);
297
+ });
298
+ const stageScale = computed(() => {
299
+ if (!stageSize.value) {
300
+ return 1;
301
+ }
302
+
303
+ const widthRatio = editorViewportSize.value.width > 0
304
+ ? editorViewportSize.value.width / stageSize.value.width
305
+ : 1;
306
+ const heightRatio = editorViewportSize.value.height > 0
307
+ ? editorViewportSize.value.height / stageSize.value.height
308
+ : 1;
309
+
310
+ return Math.max(0.1, Math.min(widthRatio, heightRatio, 1));
311
+ });
312
+ const stageFrameStyle = computed(() => {
313
+ if (!stageSize.value) {
314
+ return {};
315
+ }
316
+
317
+ return {
318
+ width: `${stageSize.value.width * stageScale.value}px`,
319
+ height: `${stageSize.value.height * stageScale.value}px`,
320
+ };
321
+ });
322
+ const stageSurfaceStyle = computed(() => {
323
+ if (!stageSize.value) {
324
+ return {};
325
+ }
326
+
327
+ return {
328
+ width: `${stageSize.value.width}px`,
329
+ height: `${stageSize.value.height}px`,
330
+ transform: `scale(${stageScale.value})`,
331
+ };
332
+ });
333
+ const selectedLayer = computed(() => (
334
+ userLayers.value.find((layer) => layer.id === selectedLayerId.value) ?? null
335
+ ));
336
+ const selectedTextLayer = computed(() => (
337
+ selectedLayer.value?.type === "text" ? selectedLayer.value : null
338
+ ));
339
+ const selectedImageLayer = computed(() => (
340
+ selectedLayer.value?.type === "image" ? selectedLayer.value : null
341
+ ));
342
+ const middleLayers = computed(() => (
343
+ userLayers.value.filter((layer) => layer.visible && layer.placement === "middle")
344
+ ));
345
+ const frontLayers = computed(() => (
346
+ userLayers.value.filter((layer) => layer.visible && layer.placement === "front")
347
+ ));
348
+ const selectedLayerOutlineStyle = computed<Record<string, string> | null>(() => {
349
+ if (!selectedLayer.value) {
350
+ return null;
351
+ }
352
+
353
+ const bounds = measureLayerBase(selectedLayer.value);
354
+ return {
355
+ left: `${selectedLayer.value.x}px`,
356
+ top: `${selectedLayer.value.y}px`,
357
+ width: `${bounds.width}px`,
358
+ height: `${bounds.height}px`,
359
+ transform: `rotate(${selectedLayer.value.rotation}deg) scale(${selectedLayer.value.scale})`,
360
+ transformOrigin: "center center",
361
+ };
362
+ });
363
+ const stageDimensionsLabel = computed(() => (
364
+ stageSize.value ? `${stageSize.value.width}×${stageSize.value.height}` : "No source"
365
+ ));
366
+ const processLabel = computed(() => {
367
+ if (isBusy.value) {
368
+ return selectedMediaKind.value === "video" ? "Processing video…" : "Removing background…";
369
+ }
370
+
371
+ if (selectedMediaKind.value === "video") {
372
+ return hasForeground.value ? "Refresh foreground clip" : "Process video";
373
+ }
374
+
375
+ return hasForeground.value ? "Refresh cutout" : "Remove background";
376
+ });
377
+ const exportLabel = computed(() => "Export");
378
+
379
+ watch(userLayers, (nextLayers) => {
380
+ if (!nextLayers.some((layer) => layer.id === selectedLayerId.value)) {
381
+ selectedLayerId.value = nextLayers[0]?.id ?? null;
382
+ }
383
+ }, { deep: true });
384
+
385
+ watch(editorViewportRef, (nextElement, previousElement) => {
386
+ if (!resizeObserver) {
387
+ return;
388
+ }
389
+
390
+ if (previousElement) {
391
+ resizeObserver.unobserve(previousElement);
392
+ }
393
+
394
+ if (nextElement) {
395
+ resizeObserver.observe(nextElement);
396
+ syncViewportSize(nextElement);
397
+ }
398
+ });
399
+
400
+ watch(previewCapDurationSeconds, (nextCap) => {
401
+ const sourceVideo = sourcePreviewVideoRef.value;
402
+ if (!sourceVideo) {
403
+ return;
404
+ }
405
+
406
+ if (!nextCap) {
407
+ queueForegroundPreviewFrame();
408
+ return;
409
+ }
410
+
411
+ if (sourceVideo.currentTime >= nextCap) {
412
+ restartPreviewLoop(false);
413
+ return;
414
+ }
415
+
416
+ queueForegroundPreviewFrame();
417
+ });
418
+
419
+ watch(previewStartSeconds, (nextStart) => {
420
+ const sourceVideo = sourcePreviewVideoRef.value;
421
+ if (!sourceVideo) {
422
+ return;
423
+ }
424
+
425
+ const wasPlaying = !sourceVideo.paused;
426
+ sourceVideo.currentTime = nextStart;
427
+
428
+ if (wasPlaying) {
429
+ void sourceVideo.play().catch(() => {
430
+ // Ignore autoplay failures.
431
+ });
432
+ }
433
+
434
+ queueForegroundPreviewFrame();
435
+ });
436
+
437
+ watch([stageSize, () => processedVideoFrames.value.length, selectedMediaKind], () => {
438
+ if (selectedMediaKind.value !== "video" || processedVideoFrames.value.length === 0) {
439
+ clearResultPreviewCanvas();
440
+ return;
441
+ }
442
+
443
+ queueForegroundPreviewFrame();
444
+ }, { deep: true });
445
+
446
+ watch([startFrameInput, videoFrameCapInput], ([nextStart, nextCap], [previousStart, previousCap]) => {
447
+ if (selectedMediaKind.value !== "video" || !processedVideoMeta.value) {
448
+ return;
449
+ }
450
+
451
+ if (nextStart === previousStart && nextCap === previousCap) {
452
+ return;
453
+ }
454
+
455
+ clearProcessedOutput();
456
+ statusText.value = "Video range changed. Refresh the cutout to export the updated clip.";
457
+ });
458
+
459
+ onMounted(() => {
460
+ syncIdleStatus();
461
+
462
+ resizeObserver = new ResizeObserver((entries) => {
463
+ const entry = entries[0];
464
+ if (!entry) {
465
+ return;
466
+ }
467
+
468
+ editorViewportSize.value = {
469
+ width: entry.contentRect.width,
470
+ height: entry.contentRect.height,
471
+ };
472
+ });
473
+
474
+ if (editorViewportRef.value) {
475
+ resizeObserver.observe(editorViewportRef.value);
476
+ syncViewportSize(editorViewportRef.value);
477
+ }
478
+
479
+ window.addEventListener("pointermove", onWindowPointerMove);
480
+ window.addEventListener("pointerup", stopLayerDrag);
481
+ window.addEventListener("pointercancel", stopLayerDrag);
482
+ window.addEventListener("keydown", onWindowKeyDown);
483
+ });
484
+
485
+ onBeforeUnmount(() => {
486
+ resizeObserver?.disconnect();
487
+ resizeObserver = null;
488
+ stopForegroundPreviewLoop();
489
+ window.removeEventListener("pointermove", onWindowPointerMove);
490
+ window.removeEventListener("pointerup", stopLayerDrag);
491
+ window.removeEventListener("pointercancel", stopLayerDrag);
492
+ window.removeEventListener("keydown", onWindowKeyDown);
493
+ stopPreviewVideos();
494
+ void resetSession();
495
+ clearObjectUrl("source");
496
+ clearObjectUrl("result");
497
+ clearLayerImageUrls();
498
+ });
499
+
500
+ function syncViewportSize(element: HTMLElement): void {
501
+ const rect = element.getBoundingClientRect();
502
+ editorViewportSize.value = {
503
+ width: rect.width,
504
+ height: rect.height,
505
+ };
506
+ }
507
+
508
+ function formatFileSize(bytes: number): string {
509
+ if (bytes < 1024) {
510
+ return `${bytes}B`;
511
+ }
512
+
513
+ const kilobytes = bytes / 1024;
514
+ if (kilobytes < 1024) {
515
+ return `${kilobytes.toFixed(1)}KB`;
516
+ }
517
+
518
+ return `${(kilobytes / 1024).toFixed(1)}MB`;
519
+ }
520
+
521
+ function formatDuration(durationMs: number): string {
522
+ if (durationMs < 1000) {
523
+ return `${Math.round(durationMs)}ms`;
524
+ }
525
+
526
+ return `${(durationMs / 1000).toFixed(2)}s`;
527
+ }
528
+
529
+ function formatMediaDuration(durationSeconds: number): string {
530
+ if (!Number.isFinite(durationSeconds)) {
531
+ return "unknown";
532
+ }
533
+
534
+ if (durationSeconds < 60) {
535
+ return `${durationSeconds.toFixed(durationSeconds >= 10 ? 0 : 1)}s`;
536
+ }
537
+
538
+ const roundedSeconds = Math.round(durationSeconds);
539
+ const minutes = Math.floor(roundedSeconds / 60);
540
+ const seconds = roundedSeconds % 60;
541
+ return `${minutes}:${String(seconds).padStart(2, "0")}`;
542
+ }
543
+
544
+ function clampPercent(value: number): number {
545
+ return Math.min(100, Math.max(0, value));
546
+ }
547
+
548
+ function getCanvasContext(
549
+ canvas: HTMLCanvasElement,
550
+ options?: CanvasRenderingContext2DSettings,
551
+ ): CanvasRenderingContext2D {
552
+ const context = canvas.getContext("2d", options);
553
+ if (!context) {
554
+ throw new Error("Could not get a 2D canvas context.");
555
+ }
556
+
557
+ return context;
558
+ }
559
+
560
+ function getMeasurementContext(): CanvasRenderingContext2D {
561
+ measurementCanvas ??= document.createElement("canvas");
562
+ return getCanvasContext(measurementCanvas);
563
+ }
564
+
565
+ function createFramePipeline(width: number, height: number): FramePipeline {
566
+ const inputCanvas = document.createElement("canvas");
567
+ inputCanvas.width = MODEL_DIMENSION;
568
+ inputCanvas.height = MODEL_DIMENSION;
569
+
570
+ const rawMaskCanvas = document.createElement("canvas");
571
+ rawMaskCanvas.width = MODEL_DIMENSION;
572
+ rawMaskCanvas.height = MODEL_DIMENSION;
573
+
574
+ const scaledMaskCanvas = document.createElement("canvas");
575
+ scaledMaskCanvas.width = width;
576
+ scaledMaskCanvas.height = height;
577
+
578
+ const outputCanvas = document.createElement("canvas");
579
+ outputCanvas.width = width;
580
+ outputCanvas.height = height;
581
+
582
+ return {
583
+ inputCanvas,
584
+ inputContext: getCanvasContext(inputCanvas, { willReadFrequently: true }),
585
+ rawMaskCanvas,
586
+ rawMaskContext: getCanvasContext(rawMaskCanvas),
587
+ scaledMaskCanvas,
588
+ scaledMaskContext: getCanvasContext(scaledMaskCanvas, { willReadFrequently: true }),
589
+ outputCanvas,
590
+ outputContext: getCanvasContext(outputCanvas, { willReadFrequently: true }),
591
+ };
592
+ }
593
+
594
+ function ensureFramePipelineSize(pipeline: FramePipeline, width: number, height: number): void {
595
+ if (pipeline.scaledMaskCanvas.width !== width || pipeline.scaledMaskCanvas.height !== height) {
596
+ pipeline.scaledMaskCanvas.width = width;
597
+ pipeline.scaledMaskCanvas.height = height;
598
+ }
599
+
600
+ if (pipeline.outputCanvas.width !== width || pipeline.outputCanvas.height !== height) {
601
+ pipeline.outputCanvas.width = width;
602
+ pipeline.outputCanvas.height = height;
603
+ }
604
+ }
605
+
606
+ function clearObjectUrl(kind: PreviewUrlKind): void {
607
+ const current = kind === "source" ? sourcePreviewUrl.value : resultPreviewUrl.value;
608
+
609
+ if (current) {
610
+ URL.revokeObjectURL(current);
611
+ }
612
+
613
+ if (kind === "source") {
614
+ sourcePreviewUrl.value = "";
615
+ return;
616
+ }
617
+
618
+ resultPreviewUrl.value = "";
619
+ }
620
+
621
+ function setObjectUrl(kind: PreviewUrlKind, blob: Blob | File): void {
622
+ clearObjectUrl(kind);
623
+ const nextUrl = URL.createObjectURL(blob);
624
+
625
+ if (kind === "source") {
626
+ sourcePreviewUrl.value = nextUrl;
627
+ return;
628
+ }
629
+
630
+ resultPreviewUrl.value = nextUrl;
631
+ }
632
+
633
+ function clearLayerImageUrls(): void {
634
+ for (const layer of userLayers.value) {
635
+ if (layer.type === "image") {
636
+ URL.revokeObjectURL(layer.url);
637
+ }
638
+ }
639
+ }
640
+
641
+ function clearProcessedVideoFrames(): void {
642
+ for (const frame of processedVideoFrames.value) {
643
+ frame.close();
644
+ }
645
+
646
+ processedVideoFrames.value = [];
647
+ }
648
+
649
+ function clearEditorLayers(): void {
650
+ clearLayerImageUrls();
651
+ userLayers.value = [];
652
+ selectedLayerId.value = null;
653
+ }
654
+
655
+ function clearProcessedOutput(): void {
656
+ resultSummary.value = null;
657
+ progressPercent.value = null;
658
+ processedVideoMeta.value = null;
659
+ clearProcessedVideoFrames();
660
+ stopForegroundPreviewLoop();
661
+ clearResultPreviewCanvas();
662
+ clearObjectUrl("result");
663
+ }
664
+
665
+ function stopPreviewVideos(): void {
666
+ stopForegroundPreviewLoop();
667
+ sourcePreviewVideoRef.value?.pause();
668
+ clearResultPreviewCanvas();
669
+ isPreviewPlaying.value = false;
670
+ }
671
+
672
+ function syncIdleStatus(): void {
673
+ if (isModelLoading.value || isBusy.value || isExporting.value) {
674
+ return;
675
+ }
676
+
677
+ if (isReady.value) {
678
+ if (!selectedFile.value) {
679
+ statusText.value = "Ready on webgpu. Drop an image or video to start.";
680
+ return;
681
+ }
682
+
683
+ if (selectedMediaKind.value === "video") {
684
+ statusText.value = hasForeground.value
685
+ ? "Foreground clip ready. Adjust layers, preview, then export the final WebM."
686
+ : "Ready on webgpu. Process the video to build the foreground layer.";
687
+ return;
688
+ }
689
+
690
+ statusText.value = hasForeground.value
691
+ ? "Foreground cutout ready. Move layers and export the composite PNG."
692
+ : "Ready on webgpu. Remove the background when you want the front cutout.";
693
+ return;
694
+ }
695
+
696
+ statusText.value = selectedFile.value
697
+ ? `${selectedMediaLabel.value[0].toUpperCase()}${selectedMediaLabel.value.slice(1)} loaded. Click Load model.`
698
+ : "Drop an image or video, then load the model.";
699
+ }
700
+
701
+ function detectMediaKind(file: File): MediaKind {
702
+ if (file.type.startsWith("image/")) {
703
+ return "image";
704
+ }
705
+
706
+ if (file.type.startsWith("video/")) {
707
+ return "video";
708
+ }
709
+
710
+ const lowerName = file.name.toLowerCase();
711
+ if (/\.(png|jpe?g|webp)$/.test(lowerName)) {
712
+ return "image";
713
+ }
714
+
715
+ if (/\.(mp4|webm|mov|m4v)$/.test(lowerName)) {
716
+ return "video";
717
+ }
718
+
719
+ throw new Error("Unsupported file type. Choose PNG, JPG, WebP, MP4, WebM, MOV, or M4V.");
720
+ }
721
+
722
+ async function loadDetachedVideo(source: string): Promise<HTMLVideoElement> {
723
+ return new Promise((resolve, reject) => {
724
+ const video = document.createElement("video");
725
+ const onLoadedMetadata = () => {
726
+ cleanup();
727
+ resolve(video);
728
+ };
729
+ const onError = () => {
730
+ cleanup();
731
+ reject(new Error("The video could not be decoded."));
732
+ };
733
+ const cleanup = () => {
734
+ video.removeEventListener("loadedmetadata", onLoadedMetadata);
735
+ video.removeEventListener("error", onError);
736
+ };
737
+
738
+ video.preload = "auto";
739
+ video.muted = true;
740
+ video.playsInline = true;
741
+ video.src = source;
742
+ video.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
743
+ video.addEventListener("error", onError, { once: true });
744
+ video.load();
745
+ });
746
+ }
747
+
748
+ async function seekVideo(video: HTMLVideoElement, timeSeconds: number): Promise<void> {
749
+ const clampedTime = Math.max(0, Math.min(timeSeconds, Math.max(0, video.duration || timeSeconds)));
750
+ if (Math.abs(video.currentTime - clampedTime) < 0.001) {
751
+ return;
752
+ }
753
+
754
+ await new Promise<void>((resolve, reject) => {
755
+ const onSeeked = () => {
756
+ cleanup();
757
+ resolve();
758
+ };
759
+ const onError = () => {
760
+ cleanup();
761
+ reject(new Error("The video could not seek to the requested frame."));
762
+ };
763
+ const cleanup = () => {
764
+ video.removeEventListener("seeked", onSeeked);
765
+ video.removeEventListener("error", onError);
766
+ };
767
+
768
+ video.addEventListener("seeked", onSeeked, { once: true });
769
+ video.addEventListener("error", onError, { once: true });
770
+ video.currentTime = clampedTime;
771
+ });
772
+ }
773
+
774
+ async function loadImageElement(source: string): Promise<HTMLImageElement> {
775
+ return new Promise((resolve, reject) => {
776
+ const image = new Image();
777
+ image.decoding = "async";
778
+ image.onload = () => resolve(image);
779
+ image.onerror = () => reject(new Error("The image could not be decoded."));
780
+ image.src = source;
781
+ });
782
+ }
783
+
784
+ async function pickVideoEncoderConfig(
785
+ width: number,
786
+ height: number,
787
+ options?: { preserveAlpha?: boolean },
788
+ ): Promise<{ encoderConfig: VideoEncoderConfig; muxerCodec: "V_VP9" | "V_VP8" }> {
789
+ const preserveAlpha = options?.preserveAlpha ?? false;
790
+ const candidates: Array<{ encoderConfig: VideoEncoderConfig; muxerCodec: "V_VP9" | "V_VP8" }> = [
791
+ {
792
+ encoderConfig: {
793
+ codec: "vp09.00.10.08",
794
+ width,
795
+ height,
796
+ bitrate: 8_000_000,
797
+ framerate: VIDEO_CAPTURE_FPS,
798
+ ...(preserveAlpha ? { alpha: "keep" } : {}),
799
+ },
800
+ muxerCodec: "V_VP9",
801
+ },
802
+ {
803
+ encoderConfig: {
804
+ codec: "vp8",
805
+ width,
806
+ height,
807
+ bitrate: 8_000_000,
808
+ framerate: VIDEO_CAPTURE_FPS,
809
+ ...(preserveAlpha ? { alpha: "keep" } : {}),
810
+ },
811
+ muxerCodec: "V_VP8",
812
+ },
813
+ ];
814
+
815
+ if (typeof VideoEncoder.isConfigSupported !== "function") {
816
+ return candidates[0];
817
+ }
818
+
819
+ for (const candidate of candidates) {
820
+ const support = await VideoEncoder.isConfigSupported(candidate.encoderConfig);
821
+ if (support.supported) {
822
+ return candidate;
823
+ }
824
+ }
825
+
826
+ throw new Error("No supported WebCodecs encoder configuration was found for WebM export.");
827
+ }
828
+
829
+ function clearResultPreviewCanvas(): void {
830
+ const canvas = resultPreviewCanvasRef.value;
831
+ if (!canvas) {
832
+ return;
833
+ }
834
+
835
+ const context = getCanvasContext(canvas);
836
+ context.clearRect(0, 0, canvas.width, canvas.height);
837
+ }
838
+
839
+ function syncResultPreviewCanvasSize(): HTMLCanvasElement | null {
840
+ const canvas = resultPreviewCanvasRef.value;
841
+ const size = stageSize.value;
842
+ if (!canvas || !size) {
843
+ return canvas;
844
+ }
845
+
846
+ if (canvas.width !== size.width || canvas.height !== size.height) {
847
+ canvas.width = size.width;
848
+ canvas.height = size.height;
849
+ }
850
+
851
+ return canvas;
852
+ }
853
+
854
+ function renderForegroundPreviewFrame(): void {
855
+ const canvas = syncResultPreviewCanvasSize();
856
+ const sourceVideo = sourcePreviewVideoRef.value;
857
+ const meta = processedVideoMeta.value;
858
+ if (!canvas) {
859
+ return;
860
+ }
861
+
862
+ const context = getCanvasContext(canvas);
863
+ context.clearRect(0, 0, canvas.width, canvas.height);
864
+
865
+ if (!sourceVideo || !meta || processedVideoFrames.value.length === 0) {
866
+ return;
867
+ }
868
+
869
+ const relativeFrame = Math.round((sourceVideo.currentTime * VIDEO_CAPTURE_FPS) - meta.startFrame);
870
+ const frameIndex = Math.min(
871
+ processedVideoFrames.value.length - 1,
872
+ Math.max(0, relativeFrame),
873
+ );
874
+ const frame = processedVideoFrames.value[frameIndex];
875
+ if (!frame) {
876
+ return;
877
+ }
878
+
879
+ try {
880
+ context.drawImage(frame, 0, 0, canvas.width, canvas.height);
881
+ } catch {
882
+ // Ignore transient draw failures while the browser catches up after a seek.
883
+ }
884
+ }
885
+
886
+ function stopForegroundPreviewLoop(): void {
887
+ if (previewForegroundRafId !== null) {
888
+ window.cancelAnimationFrame(previewForegroundRafId);
889
+ previewForegroundRafId = null;
890
+ }
891
+ }
892
+
893
+ function queueForegroundPreviewFrame(): void {
894
+ if (previewForegroundRafId !== null) {
895
+ return;
896
+ }
897
+
898
+ previewForegroundRafId = window.requestAnimationFrame(() => {
899
+ previewForegroundRafId = null;
900
+ renderForegroundPreviewFrame();
901
+
902
+ const sourceVideo = sourcePreviewVideoRef.value;
903
+ const shouldContinue = Boolean(
904
+ sourceVideo
905
+ && processedVideoFrames.value.length > 0
906
+ && !sourceVideo.paused
907
+ && !sourceVideo.ended,
908
+ );
909
+
910
+ if (shouldContinue) {
911
+ queueForegroundPreviewFrame();
912
+ }
913
+ });
914
+ }
915
+
916
+ async function fetchModelBytes(): Promise<Uint8Array> {
917
+ const cacheKey = RMBG_MODEL.id;
918
+ const cached = modelDataCache.get(cacheKey);
919
+ if (cached && cached.url === RMBG_MODEL.url) {
920
+ return cached.bytes;
921
+ }
922
+
923
+ const existingLoad = pendingModelLoads.get(cacheKey);
924
+ if (existingLoad) {
925
+ return existingLoad;
926
+ }
927
+
928
+ const loadPromise = (async () => {
929
+ const response = await fetch(RMBG_MODEL.url);
930
+ if (!response.ok) {
931
+ throw new Error(`Failed to fetch model: HTTP ${response.status}`);
932
+ }
933
+
934
+ const bytes = new Uint8Array(await response.arrayBuffer());
935
+ modelDataCache.set(cacheKey, {
936
+ bytes,
937
+ url: RMBG_MODEL.url,
938
+ });
939
+ return bytes;
940
+ })();
941
+
942
+ pendingModelLoads.set(cacheKey, loadPromise);
943
+
944
+ try {
945
+ return await loadPromise;
946
+ } finally {
947
+ pendingModelLoads.delete(cacheKey);
948
+ }
949
+ }
950
+
951
+ function applyOrtWasmPaths(useJsepRuntime: boolean): void {
952
+ ort.env.wasm.wasmPaths = useJsepRuntime
953
+ ? {
954
+ mjs: ortWasmThreadedJsepMjsUrl,
955
+ wasm: ortWasmThreadedJsepWasmUrl,
956
+ }
957
+ : {
958
+ mjs: ortWasmThreadedMjsUrl,
959
+ wasm: ortWasmThreadedWasmUrl,
960
+ };
961
+ }
962
+
963
+ async function initializeSession(): Promise<void> {
964
+ if (session.value) {
965
+ return;
966
+ }
967
+
968
+ isModelLoading.value = true;
969
+ errorText.value = "";
970
+ statusText.value = `Loading ${RMBG_MODEL.label}…`;
971
+
972
+ ort.env.wasm.proxy = false;
973
+ ort.env.wasm.simd = true;
974
+ ort.env.wasm.numThreads = 1;
975
+ ort.env.logLevel = "error";
976
+
977
+ const hasWebGpu = typeof navigator !== "undefined" && "gpu" in navigator;
978
+ if (!hasWebGpu) {
979
+ throw new Error("WebGPU is required for this app. No WASM fallback is enabled.");
980
+ }
981
+
982
+ try {
983
+ const modelBytes = await fetchModelBytes();
984
+ applyOrtWasmPaths(true);
985
+ session.value = await ort.InferenceSession.create(modelBytes, {
986
+ executionProviders: ["webgpu"],
987
+ graphOptimizationLevel: "all",
988
+ });
989
+ loadedModelId.value = RMBG_MODEL.id;
990
+ activeBackend.value = "webgpu";
991
+ } finally {
992
+ isModelLoading.value = false;
993
+ syncIdleStatus();
994
+ }
995
+ }
996
+
997
+ async function resetSession(): Promise<void> {
998
+ const currentSession = session.value;
999
+ session.value = null;
1000
+ loadedModelId.value = null;
1001
+ activeBackend.value = "pending";
1002
+
1003
+ if (currentSession) {
1004
+ await currentSession.release();
1005
+ }
1006
+ }
1007
+
1008
+ async function loadSelectedModel(): Promise<void> {
1009
+ await resetSession();
1010
+ errorText.value = "";
1011
+
1012
+ try {
1013
+ await initializeSession();
1014
+ } catch (error) {
1015
+ const message = error instanceof Error ? error.message : String(error);
1016
+ errorText.value = message;
1017
+ statusText.value = "The model could not be loaded.";
1018
+ }
1019
+ }
1020
+
1021
+ function float32ToFloat16(value: number): number {
1022
+ const floatView = new Float32Array(1);
1023
+ const intView = new Int32Array(floatView.buffer);
1024
+
1025
+ floatView[0] = value;
1026
+ const x = intView[0];
1027
+
1028
+ let bits = (x >> 16) & 0x8000;
1029
+ let mantissa = (x >> 12) & 0x07ff;
1030
+ const exponent = (x >> 23) & 0xff;
1031
+
1032
+ if (exponent < 103) {
1033
+ return bits;
1034
+ }
1035
+
1036
+ if (exponent > 142) {
1037
+ bits |= 0x7c00;
1038
+ bits |= exponent === 255 && (x & 0x007fffff) !== 0 ? 1 : 0;
1039
+ return bits;
1040
+ }
1041
+
1042
+ if (exponent < 113) {
1043
+ mantissa |= 0x0800;
1044
+ bits |= (mantissa >> (114 - exponent)) + ((mantissa >> (113 - exponent)) & 1);
1045
+ return bits;
1046
+ }
1047
+
1048
+ bits |= ((exponent - 112) << 10) | (mantissa >> 1);
1049
+ bits += mantissa & 1;
1050
+ return bits;
1051
+ }
1052
+
1053
+ function float16ToFloat32(value: number): number {
1054
+ const sign = (value & 0x8000) >> 15;
1055
+ const exponent = (value & 0x7c00) >> 10;
1056
+ const fraction = value & 0x03ff;
1057
+
1058
+ if (exponent === 0) {
1059
+ if (fraction === 0) {
1060
+ return sign ? -0 : 0;
1061
+ }
1062
+
1063
+ return (sign ? -1 : 1) * 2 ** -14 * (fraction / 1024);
1064
+ }
1065
+
1066
+ if (exponent === 31) {
1067
+ return fraction === 0 ? (sign ? -Infinity : Infinity) : Number.NaN;
1068
+ }
1069
+
1070
+ return (sign ? -1 : 1) * 2 ** (exponent - 15) * (1 + fraction / 1024);
1071
+ }
1072
+
1073
+ function normalizeTensorData(tensor: ort.Tensor): Float32Array {
1074
+ if (tensor.type === "float16") {
1075
+ const source = tensor.data as Uint16Array;
1076
+ const output = new Float32Array(source.length);
1077
+
1078
+ for (let index = 0; index < source.length; index += 1) {
1079
+ output[index] = float16ToFloat32(source[index]);
1080
+ }
1081
+
1082
+ return output;
1083
+ }
1084
+
1085
+ if (tensor.data instanceof Float32Array) {
1086
+ return tensor.data;
1087
+ }
1088
+
1089
+ return Float32Array.from(tensor.data as ArrayLike<number>);
1090
+ }
1091
+
1092
+ function createInputTensor(
1093
+ imageData: ImageData,
1094
+ inputType: string | undefined,
1095
+ ): ort.Tensor {
1096
+ const planeSize = MODEL_DIMENSION * MODEL_DIMENSION;
1097
+ const normalized = new Float32Array(planeSize * 3);
1098
+
1099
+ for (let index = 0; index < planeSize; index += 1) {
1100
+ const pixelOffset = index * 4;
1101
+ normalized[index] = imageData.data[pixelOffset] / 255 - 0.5;
1102
+ normalized[planeSize + index] = imageData.data[pixelOffset + 1] / 255 - 0.5;
1103
+ normalized[planeSize * 2 + index] = imageData.data[pixelOffset + 2] / 255 - 0.5;
1104
+ }
1105
+
1106
+ if (inputType === "float16") {
1107
+ const fp16 = new Uint16Array(normalized.length);
1108
+ for (let index = 0; index < normalized.length; index += 1) {
1109
+ fp16[index] = float32ToFloat16(normalized[index]);
1110
+ }
1111
+
1112
+ return new ort.Tensor("float16", fp16, [1, 3, MODEL_DIMENSION, MODEL_DIMENSION]);
1113
+ }
1114
+
1115
+ return new ort.Tensor("float32", normalized, [1, 3, MODEL_DIMENSION, MODEL_DIMENSION]);
1116
+ }
1117
+
1118
+ async function blobFromCanvas(canvas: HTMLCanvasElement): Promise<Blob> {
1119
+ return new Promise((resolve, reject) => {
1120
+ canvas.toBlob((blob) => {
1121
+ if (!blob) {
1122
+ reject(new Error("Could not encode canvas as PNG."));
1123
+ return;
1124
+ }
1125
+
1126
+ resolve(blob);
1127
+ }, "image/png");
1128
+ });
1129
+ }
1130
+
1131
+ async function processSourceToCanvas(
1132
+ source: CanvasImageSource,
1133
+ width: number,
1134
+ height: number,
1135
+ pipeline: FramePipeline,
1136
+ ): Promise<HTMLCanvasElement> {
1137
+ if (!session.value) {
1138
+ throw new Error("Load the model on WebGPU before running inference.");
1139
+ }
1140
+
1141
+ ensureFramePipelineSize(pipeline, width, height);
1142
+
1143
+ pipeline.inputContext.clearRect(0, 0, MODEL_DIMENSION, MODEL_DIMENSION);
1144
+ pipeline.inputContext.drawImage(source, 0, 0, MODEL_DIMENSION, MODEL_DIMENSION);
1145
+ const inputImage = pipeline.inputContext.getImageData(0, 0, MODEL_DIMENSION, MODEL_DIMENSION);
1146
+
1147
+ const inputName = session.value.inputNames[0];
1148
+ const inputMetadata = (
1149
+ session.value.inputMetadata as unknown as Record<string, { isTensor?: boolean; type?: string }>
1150
+ )[inputName];
1151
+ const inputTensor = createInputTensor(
1152
+ inputImage,
1153
+ inputMetadata?.isTensor ? inputMetadata.type : undefined,
1154
+ );
1155
+
1156
+ const outputMap = await session.value.run({ [inputName]: inputTensor });
1157
+ const outputName = session.value.outputNames[0];
1158
+ const outputTensor = outputMap[outputName];
1159
+ if (!outputTensor) {
1160
+ throw new Error("The model did not return an output tensor.");
1161
+ }
1162
+
1163
+ const outputShape = outputTensor.dims.map(Number);
1164
+ const maskHeight = outputShape.at(-2) ?? MODEL_DIMENSION;
1165
+ const maskWidth = outputShape.at(-1) ?? MODEL_DIMENSION;
1166
+ const outputData = normalizeTensorData(outputTensor);
1167
+
1168
+ if (
1169
+ pipeline.rawMaskCanvas.width !== maskWidth
1170
+ || pipeline.rawMaskCanvas.height !== maskHeight
1171
+ ) {
1172
+ pipeline.rawMaskCanvas.width = maskWidth;
1173
+ pipeline.rawMaskCanvas.height = maskHeight;
1174
+ }
1175
+
1176
+ let min = Number.POSITIVE_INFINITY;
1177
+ let max = Number.NEGATIVE_INFINITY;
1178
+
1179
+ for (let index = 0; index < outputData.length; index += 1) {
1180
+ const value = outputData[index];
1181
+ if (value < min) {
1182
+ min = value;
1183
+ }
1184
+ if (value > max) {
1185
+ max = value;
1186
+ }
1187
+ }
1188
+
1189
+ const range = max - min || 1;
1190
+ const rawMaskImage = pipeline.rawMaskContext.createImageData(maskWidth, maskHeight);
1191
+
1192
+ for (let index = 0; index < outputData.length; index += 1) {
1193
+ const alpha = Math.max(0, Math.min(255, Math.round(((outputData[index] - min) / range) * 255)));
1194
+ const offset = index * 4;
1195
+ rawMaskImage.data[offset] = alpha;
1196
+ rawMaskImage.data[offset + 1] = alpha;
1197
+ rawMaskImage.data[offset + 2] = alpha;
1198
+ rawMaskImage.data[offset + 3] = 255;
1199
+ }
1200
+
1201
+ pipeline.rawMaskContext.putImageData(rawMaskImage, 0, 0);
1202
+
1203
+ pipeline.scaledMaskContext.clearRect(0, 0, width, height);
1204
+ pipeline.scaledMaskContext.imageSmoothingEnabled = true;
1205
+ pipeline.scaledMaskContext.drawImage(pipeline.rawMaskCanvas, 0, 0, width, height);
1206
+ const scaledMaskData = pipeline.scaledMaskContext.getImageData(0, 0, width, height);
1207
+
1208
+ pipeline.outputContext.clearRect(0, 0, width, height);
1209
+ pipeline.outputContext.drawImage(source, 0, 0, width, height);
1210
+ const resultImage = pipeline.outputContext.getImageData(0, 0, width, height);
1211
+
1212
+ for (let index = 0; index < width * height; index += 1) {
1213
+ resultImage.data[index * 4 + 3] = scaledMaskData.data[index * 4];
1214
+ }
1215
+
1216
+ pipeline.outputContext.putImageData(resultImage, 0, 0);
1217
+ return pipeline.outputCanvas;
1218
+ }
1219
+
1220
+ async function prepareFile(file: File): Promise<void> {
1221
+ let imageBitmap: ImageBitmap | null = null;
1222
+ let previewVideo: HTMLVideoElement | null = null;
1223
+
1224
+ try {
1225
+ const mediaKind = detectMediaKind(file);
1226
+
1227
+ stopPreviewVideos();
1228
+ errorText.value = "";
1229
+ selectedFile.value = file;
1230
+ selectedMediaKind.value = mediaKind;
1231
+ sourceSummary.value = null;
1232
+ resultSummary.value = null;
1233
+ sourceDurationSeconds.value = null;
1234
+ stageSize.value = null;
1235
+ progressPercent.value = null;
1236
+ clearEditorLayers();
1237
+ clearProcessedOutput();
1238
+ setObjectUrl("source", file);
1239
+
1240
+ if (mediaKind === "image") {
1241
+ imageBitmap = await createImageBitmap(file);
1242
+ stageSize.value = {
1243
+ width: imageBitmap.width,
1244
+ height: imageBitmap.height,
1245
+ };
1246
+ sourceSummary.value = {
1247
+ kind: mediaKind,
1248
+ name: file.name,
1249
+ size: formatFileSize(file.size),
1250
+ dimensions: `${imageBitmap.width}×${imageBitmap.height}`,
1251
+ };
1252
+ } else {
1253
+ previewVideo = await loadDetachedVideo(sourcePreviewUrl.value);
1254
+ stageSize.value = {
1255
+ width: previewVideo.videoWidth,
1256
+ height: previewVideo.videoHeight,
1257
+ };
1258
+ sourceSummary.value = {
1259
+ kind: mediaKind,
1260
+ name: file.name,
1261
+ size: formatFileSize(file.size),
1262
+ dimensions: `${previewVideo.videoWidth}×${previewVideo.videoHeight}`,
1263
+ duration: formatMediaDuration(previewVideo.duration),
1264
+ fps: `${VIDEO_CAPTURE_FPS} fps`,
1265
+ };
1266
+ sourceDurationSeconds.value = previewVideo.duration;
1267
+ }
1268
+
1269
+ syncIdleStatus();
1270
+ } catch (error) {
1271
+ const message = error instanceof Error ? error.message : String(error);
1272
+ errorText.value = message;
1273
+ statusText.value = "The file could not be prepared.";
1274
+ selectedFile.value = null;
1275
+ selectedMediaKind.value = null;
1276
+ sourceSummary.value = null;
1277
+ sourceDurationSeconds.value = null;
1278
+ stageSize.value = null;
1279
+ progressPercent.value = null;
1280
+ clearEditorLayers();
1281
+ clearProcessedOutput();
1282
+ clearObjectUrl("source");
1283
+ } finally {
1284
+ imageBitmap?.close();
1285
+ if (previewVideo) {
1286
+ previewVideo.pause();
1287
+ previewVideo.removeAttribute("src");
1288
+ previewVideo.load();
1289
+ }
1290
+ }
1291
+ }
1292
+
1293
+ async function processImageFile(file: File): Promise<void> {
1294
+ let bitmap: ImageBitmap | null = null;
1295
+
1296
+ try {
1297
+ if (!session.value) {
1298
+ throw new Error("Load the model on WebGPU before running inference.");
1299
+ }
1300
+
1301
+ statusText.value = "Decoding image…";
1302
+ bitmap = await createImageBitmap(file);
1303
+ const pipeline = createFramePipeline(bitmap.width, bitmap.height);
1304
+
1305
+ progressPercent.value = null;
1306
+ statusText.value = "Running background removal…";
1307
+ const startedAt = performance.now();
1308
+ const resultCanvas = await processSourceToCanvas(bitmap, bitmap.width, bitmap.height, pipeline);
1309
+ statusText.value = "Building the foreground cutout…";
1310
+ const resultBlob = await blobFromCanvas(resultCanvas);
1311
+
1312
+ setObjectUrl("result", resultBlob);
1313
+ resultSummary.value = {
1314
+ duration: formatDuration(performance.now() - startedAt),
1315
+ backend: activeBackend.value,
1316
+ output: "Foreground PNG",
1317
+ };
1318
+ statusText.value = "Foreground ready. Move layers and export the composite PNG.";
1319
+ } finally {
1320
+ bitmap?.close();
1321
+ }
1322
+ }
1323
+
1324
+ async function processVideoFile(file: File): Promise<void> {
1325
+ let processingVideo: HTMLVideoElement | null = null;
1326
+ let temporaryVideoUrl = "";
1327
+ const nextFrames: ImageBitmap[] = [];
1328
+ let committedFrames = false;
1329
+
1330
+ try {
1331
+ if (!session.value) {
1332
+ throw new Error("Load the model on WebGPU before running inference.");
1333
+ }
1334
+
1335
+ const resolvedFrameCap = frameCapValue.value;
1336
+ const resolvedStartFrame = startFrameValue.value;
1337
+ if (resolvedFrameCap !== null && !Number.isFinite(resolvedFrameCap)) {
1338
+ throw new Error("Frame cap must be a whole number.");
1339
+ }
1340
+ if (!Number.isFinite(resolvedStartFrame)) {
1341
+ throw new Error("Start frame must be a whole number.");
1342
+ }
1343
+
1344
+ statusText.value = "Decoding video…";
1345
+ temporaryVideoUrl = sourcePreviewUrl.value ? "" : URL.createObjectURL(file);
1346
+ processingVideo = await loadDetachedVideo(sourcePreviewUrl.value || temporaryVideoUrl);
1347
+ processingVideo.currentTime = 0;
1348
+
1349
+ const width = processingVideo.videoWidth;
1350
+ const height = processingVideo.videoHeight;
1351
+ const pipeline = createFramePipeline(width, height);
1352
+ const estimatedTotalFrames = Math.max(1, Math.round(processingVideo.duration * VIDEO_CAPTURE_FPS));
1353
+ const availableFrames = Math.max(0, estimatedTotalFrames - resolvedStartFrame);
1354
+ if (availableFrames <= 0) {
1355
+ throw new Error("Start frame is beyond the available video length.");
1356
+ }
1357
+ const targetFrames = resolvedFrameCap === null
1358
+ ? availableFrames
1359
+ : Math.max(1, Math.min(resolvedFrameCap, availableFrames));
1360
+ const usesFrameCap = resolvedFrameCap !== null;
1361
+ const frameDurationSeconds = 1 / VIDEO_CAPTURE_FPS;
1362
+ const lastFrameTime = Math.max(0, processingVideo.duration - frameDurationSeconds / 2);
1363
+
1364
+ progressPercent.value = 0;
1365
+
1366
+ const updateVideoProgress = (processedFrames: number): void => {
1367
+ progressPercent.value = clampPercent((processedFrames / targetFrames) * 100);
1368
+ statusText.value = `Processing video… ${Math.round(progressPercent.value)}%`;
1369
+ };
1370
+
1371
+ let processedFrames = 0;
1372
+ const startedAt = performance.now();
1373
+
1374
+ for (let frameIndex = 0; frameIndex < targetFrames; frameIndex += 1) {
1375
+ const targetTime = Math.min((resolvedStartFrame + frameIndex) * frameDurationSeconds, lastFrameTime);
1376
+ await seekVideo(processingVideo, targetTime);
1377
+ await processSourceToCanvas(processingVideo, width, height, pipeline);
1378
+ nextFrames.push(await createImageBitmap(pipeline.outputCanvas));
1379
+
1380
+ processedFrames = frameIndex + 1;
1381
+ updateVideoProgress(processedFrames);
1382
+ }
1383
+
1384
+ processedVideoFrames.value = nextFrames;
1385
+ committedFrames = true;
1386
+ processedVideoMeta.value = {
1387
+ width,
1388
+ height,
1389
+ startFrame: resolvedStartFrame,
1390
+ targetFrames,
1391
+ };
1392
+ progressPercent.value = 100;
1393
+ resultSummary.value = {
1394
+ duration: formatDuration(performance.now() - startedAt),
1395
+ backend: activeBackend.value,
1396
+ output: "Foreground frames",
1397
+ fps: `${VIDEO_CAPTURE_FPS} fps`,
1398
+ frames: usesFrameCap
1399
+ ? `${processedFrames}/${targetFrames} rendered`
1400
+ : `${processedFrames} rendered`,
1401
+ note: usesFrameCap
1402
+ ? `Started at frame ${resolvedStartFrame} and stopped after the ${targetFrames}-frame cap.`
1403
+ : `Processed from frame ${resolvedStartFrame} through the rest of the video and kept the RGBA frames in memory for preview/export.`,
1404
+ };
1405
+ statusText.value = "Foreground frames ready. Preview the layer stack and export the final WebM.";
1406
+ queueForegroundPreviewFrame();
1407
+ } finally {
1408
+ if (!committedFrames) {
1409
+ for (const frame of nextFrames) {
1410
+ frame.close();
1411
+ }
1412
+ }
1413
+
1414
+ processingVideo?.pause();
1415
+ if (processingVideo) {
1416
+ processingVideo.removeAttribute("src");
1417
+ processingVideo.load();
1418
+ }
1419
+
1420
+ if (temporaryVideoUrl) {
1421
+ URL.revokeObjectURL(temporaryVideoUrl);
1422
+ }
1423
+ }
1424
+ }
1425
+
1426
+ async function processFile(file: File): Promise<void> {
1427
+ isBusy.value = true;
1428
+ errorText.value = "";
1429
+
1430
+ try {
1431
+ if (selectedMediaKind.value === "video") {
1432
+ await processVideoFile(file);
1433
+ } else {
1434
+ await processImageFile(file);
1435
+ }
1436
+ } catch (error) {
1437
+ const message = error instanceof Error ? error.message : String(error);
1438
+ errorText.value = message;
1439
+ progressPercent.value = null;
1440
+ statusText.value = selectedMediaKind.value === "video"
1441
+ ? "The video could not be processed."
1442
+ : "The image could not be processed.";
1443
+ } finally {
1444
+ isBusy.value = false;
1445
+ if (!errorText.value) {
1446
+ syncIdleStatus();
1447
+ }
1448
+ }
1449
+ }
1450
+
1451
+ function runInference(): void {
1452
+ if (!selectedFile.value) {
1453
+ statusText.value = "Choose an image or video first.";
1454
+ return;
1455
+ }
1456
+
1457
+ if (!isReady.value || activeBackend.value !== "webgpu") {
1458
+ statusText.value = "Load the model on WebGPU before running inference.";
1459
+ return;
1460
+ }
1461
+
1462
+ clearProcessedOutput();
1463
+ void processFile(selectedFile.value);
1464
+ }
1465
+
1466
+ function openFilePicker(): void {
1467
+ fileInput.value?.click();
1468
+ }
1469
+
1470
+ function openLayerImagePicker(): void {
1471
+ if (!stageSize.value) {
1472
+ statusText.value = "Choose a base asset before adding overlay artwork.";
1473
+ return;
1474
+ }
1475
+
1476
+ layerImageInput.value?.click();
1477
+ }
1478
+
1479
+ function onFileInputChanged(event: Event): void {
1480
+ const target = event.target as HTMLInputElement;
1481
+ const file = target.files?.[0];
1482
+ target.value = "";
1483
+
1484
+ if (file) {
1485
+ void prepareFile(file);
1486
+ }
1487
+ }
1488
+
1489
+ function onLayerImageInputChanged(event: Event): void {
1490
+ const target = event.target as HTMLInputElement;
1491
+ const file = target.files?.[0];
1492
+ target.value = "";
1493
+
1494
+ if (file) {
1495
+ void addImageLayer(file).catch((error: unknown) => {
1496
+ errorText.value = error instanceof Error ? error.message : String(error);
1497
+ statusText.value = "The overlay image could not be added.";
1498
+ });
1499
+ }
1500
+ }
1501
+
1502
+ function onDragEnter(): void {
1503
+ isDragging.value = true;
1504
+ }
1505
+
1506
+ function onDragLeave(event: DragEvent): void {
1507
+ const nextTarget = event.relatedTarget as Node | null;
1508
+ if (!nextTarget || !event.currentTarget || !(event.currentTarget as Node).contains(nextTarget)) {
1509
+ isDragging.value = false;
1510
+ }
1511
+ }
1512
+
1513
+ function onDrop(event: DragEvent): void {
1514
+ isDragging.value = false;
1515
+ const file = event.dataTransfer?.files?.[0];
1516
+ if (file) {
1517
+ void prepareFile(file);
1518
+ }
1519
+ }
1520
+
1521
+ function nextLayerId(prefix: string): string {
1522
+ layerSequence += 1;
1523
+ return `${prefix}-${layerSequence}`;
1524
+ }
1525
+
1526
+ function fontDescriptor(layer: TextLayer): string {
1527
+ return `${layer.fontStyle} ${layer.fontWeight} ${layer.fontSize}px ${layer.fontFamily}`;
1528
+ }
1529
+
1530
+ function measureLayerBase(layer: EditorLayer): { width: number; height: number } {
1531
+ if (layer.type === "image") {
1532
+ return {
1533
+ width: layer.width,
1534
+ height: layer.height,
1535
+ };
1536
+ }
1537
+
1538
+ const context = getMeasurementContext();
1539
+ context.font = fontDescriptor(layer);
1540
+ const lines = layer.text.split(/\r?\n/);
1541
+ const width = Math.max(
1542
+ ...lines.map((line) => context.measureText(line || " ").width),
1543
+ layer.fontSize * 0.6,
1544
+ );
1545
+ const lineHeight = layer.fontSize * 1.08;
1546
+
1547
+ return {
1548
+ width,
1549
+ height: Math.max(layer.fontSize, lineHeight * lines.length),
1550
+ };
1551
+ }
1552
+
1553
+ function measureLayer(layer: EditorLayer): { width: number; height: number } {
1554
+ const bounds = measureLayerBase(layer);
1555
+ return {
1556
+ width: bounds.width * layer.scale,
1557
+ height: bounds.height * layer.scale,
1558
+ };
1559
+ }
1560
+
1561
+ function getLayerCenter(layer: EditorLayer): { x: number; y: number } {
1562
+ const bounds = measureLayerBase(layer);
1563
+ return {
1564
+ x: layer.x + bounds.width / 2,
1565
+ y: layer.y + bounds.height / 2,
1566
+ };
1567
+ }
1568
+
1569
+ function clampLayerPosition(layer: EditorLayer, x: number, y: number): { x: number; y: number } {
1570
+ if (!stageSize.value) {
1571
+ return { x, y };
1572
+ }
1573
+
1574
+ const bounds = measureLayer(layer);
1575
+ return {
1576
+ x: Math.min(stageSize.value.width - 18, Math.max(18 - bounds.width, x)),
1577
+ y: Math.min(stageSize.value.height - 18, Math.max(18 - bounds.height, y)),
1578
+ };
1579
+ }
1580
+
1581
+ function createDefaultTextLayer(): TextLayer {
1582
+ const width = stageSize.value?.width ?? 1200;
1583
+ const height = stageSize.value?.height ?? 800;
1584
+ const layer: TextLayer = {
1585
+ id: nextLayerId("text"),
1586
+ type: "text",
1587
+ name: `Text ${userLayers.value.filter((item) => item.type === "text").length + 1}`,
1588
+ visible: true,
1589
+ placement: "middle",
1590
+ x: width * 0.18,
1591
+ y: height * 0.62,
1592
+ scale: 1,
1593
+ rotation: 0,
1594
+ text: "Your text",
1595
+ fontSize: Math.max(42, Math.round(width * 0.07)),
1596
+ fontStyle: "normal",
1597
+ fontWeight: 700,
1598
+ fontFamily: "Space Grotesk",
1599
+ color: "#f97316",
1600
+ };
1601
+ const bounds = measureLayer(layer);
1602
+ layer.x = Math.max(20, Math.round((width - bounds.width) / 2));
1603
+ return layer;
1604
+ }
1605
+
1606
+ function addTextLayer(): void {
1607
+ if (!stageSize.value) {
1608
+ statusText.value = "Choose an image or video before adding text.";
1609
+ return;
1610
+ }
1611
+
1612
+ const layer = createDefaultTextLayer();
1613
+ userLayers.value.push(layer);
1614
+ selectedLayerId.value = layer.id;
1615
+ statusText.value = "Text layer added. Drag it directly on the preview.";
1616
+ }
1617
+
1618
+ async function addImageLayer(file: File): Promise<void> {
1619
+ if (!stageSize.value) {
1620
+ throw new Error("Choose a base asset before adding overlay artwork.");
1621
+ }
1622
+
1623
+ const layerKind = detectMediaKind(file);
1624
+ if (layerKind !== "image") {
1625
+ throw new Error("Overlay layers must be PNG, JPG, or WebP images.");
1626
+ }
1627
+
1628
+ let bitmap: ImageBitmap | null = null;
1629
+
1630
+ try {
1631
+ bitmap = await createImageBitmap(file);
1632
+ const maxWidth = stageSize.value.width * 0.45;
1633
+ const width = Math.max(80, Math.min(maxWidth, bitmap.width));
1634
+ const height = width * (bitmap.height / bitmap.width);
1635
+ const layer: ImageLayer = {
1636
+ id: nextLayerId("image"),
1637
+ type: "image",
1638
+ name: file.name.replace(/\.[^.]+$/, "") || `Image ${userLayers.value.filter((item) => item.type === "image").length + 1}`,
1639
+ visible: true,
1640
+ placement: "middle",
1641
+ x: Math.round((stageSize.value.width - width) / 2),
1642
+ y: Math.round((stageSize.value.height - height) / 2),
1643
+ scale: 1,
1644
+ rotation: 0,
1645
+ url: URL.createObjectURL(file),
1646
+ width,
1647
+ height,
1648
+ naturalWidth: bitmap.width,
1649
+ naturalHeight: bitmap.height,
1650
+ };
1651
+
1652
+ userLayers.value.push(layer);
1653
+ selectedLayerId.value = layer.id;
1654
+ statusText.value = `${layer.name} added between the source and the foreground layer.`;
1655
+ } finally {
1656
+ bitmap?.close();
1657
+ }
1658
+ }
1659
+
1660
+ function removeLayer(layerId: string): void {
1661
+ const target = userLayers.value.find((layer) => layer.id === layerId);
1662
+ if (target?.type === "image") {
1663
+ URL.revokeObjectURL(target.url);
1664
+ }
1665
+
1666
+ userLayers.value = userLayers.value.filter((layer) => layer.id !== layerId);
1667
+ }
1668
+
1669
+ function moveLayer(layerId: string, direction: "up" | "down"): void {
1670
+ const currentIndex = userLayers.value.findIndex((layer) => layer.id === layerId);
1671
+ if (currentIndex === -1) {
1672
+ return;
1673
+ }
1674
+
1675
+ const targetIndex = direction === "up" ? currentIndex + 1 : currentIndex - 1;
1676
+ if (targetIndex < 0 || targetIndex >= userLayers.value.length) {
1677
+ return;
1678
+ }
1679
+
1680
+ const nextLayers = [...userLayers.value];
1681
+ const [moved] = nextLayers.splice(currentIndex, 1);
1682
+ nextLayers.splice(targetIndex, 0, moved);
1683
+ userLayers.value = nextLayers;
1684
+ }
1685
+
1686
+ function duplicateSelectedTextLayer(): void {
1687
+ if (!selectedTextLayer.value) {
1688
+ return;
1689
+ }
1690
+
1691
+ const clone: TextLayer = {
1692
+ ...selectedTextLayer.value,
1693
+ id: nextLayerId("text"),
1694
+ name: `${selectedTextLayer.value.name} copy`,
1695
+ x: selectedTextLayer.value.x + 24,
1696
+ y: selectedTextLayer.value.y + 24,
1697
+ };
1698
+
1699
+ userLayers.value.push(clone);
1700
+ selectedLayerId.value = clone.id;
1701
+ }
1702
+
1703
+ function getNaturalPoint(clientX: number, clientY: number): { x: number; y: number } | null {
1704
+ const rect = stageFrameRef.value?.getBoundingClientRect() ?? editorViewportRef.value?.getBoundingClientRect();
1705
+ if (!rect || !stageSize.value) {
1706
+ return null;
1707
+ }
1708
+
1709
+ const x = (clientX - rect.left) / stageScale.value;
1710
+ const y = (clientY - rect.top) / stageScale.value;
1711
+ return {
1712
+ x: Math.min(stageSize.value.width, Math.max(0, x)),
1713
+ y: Math.min(stageSize.value.height, Math.max(0, y)),
1714
+ };
1715
+ }
1716
+
1717
+ function startLayerDrag(event: PointerEvent, layerId: string): void {
1718
+ const layer = userLayers.value.find((item) => item.id === layerId);
1719
+ if (!layer) {
1720
+ return;
1721
+ }
1722
+
1723
+ event.preventDefault();
1724
+ event.stopPropagation();
1725
+ const wasSelected = selectedLayerId.value === layerId;
1726
+ selectedLayerId.value = layerId;
1727
+
1728
+ const point = getNaturalPoint(event.clientX, event.clientY);
1729
+ if (!point) {
1730
+ return;
1731
+ }
1732
+
1733
+ dragState = {
1734
+ layerId,
1735
+ pointerId: event.pointerId,
1736
+ mode: "move",
1737
+ moved: false,
1738
+ wasSelected,
1739
+ offsetX: point.x - layer.x,
1740
+ offsetY: point.y - layer.y,
1741
+ startScale: layer.scale,
1742
+ startRotation: layer.rotation,
1743
+ startDistance: 0,
1744
+ startAngle: 0,
1745
+ startPointerX: event.clientX,
1746
+ startPointerY: event.clientY,
1747
+ };
1748
+ }
1749
+
1750
+ function startLayerTransform(
1751
+ event: PointerEvent,
1752
+ layerId: string,
1753
+ mode: "scale" | "rotate",
1754
+ ): void {
1755
+ const layer = userLayers.value.find((item) => item.id === layerId);
1756
+ if (!layer) {
1757
+ return;
1758
+ }
1759
+
1760
+ event.preventDefault();
1761
+ event.stopPropagation();
1762
+ const wasSelected = selectedLayerId.value === layerId;
1763
+ selectedLayerId.value = layerId;
1764
+
1765
+ const point = getNaturalPoint(event.clientX, event.clientY);
1766
+ if (!point) {
1767
+ return;
1768
+ }
1769
+ const center = getLayerCenter(layer);
1770
+
1771
+ dragState = {
1772
+ layerId,
1773
+ pointerId: event.pointerId,
1774
+ mode,
1775
+ moved: false,
1776
+ wasSelected,
1777
+ offsetX: 0,
1778
+ offsetY: 0,
1779
+ startScale: layer.scale,
1780
+ startRotation: layer.rotation,
1781
+ startDistance: Math.max(1, Math.hypot(point.x - center.x, point.y - center.y)),
1782
+ startAngle: Math.atan2(point.y - center.y, point.x - center.x),
1783
+ startPointerX: event.clientX,
1784
+ startPointerY: event.clientY,
1785
+ };
1786
+ }
1787
+
1788
+ function clampLayerScale(scale: number): number {
1789
+ return Math.min(8, Math.max(0.1, scale));
1790
+ }
1791
+
1792
+ function onWindowPointerMove(event: PointerEvent): void {
1793
+ if (!dragState || dragState.pointerId !== event.pointerId) {
1794
+ return;
1795
+ }
1796
+
1797
+ const layer = userLayers.value.find((item) => item.id === dragState?.layerId);
1798
+ if (!layer) {
1799
+ dragState = null;
1800
+ return;
1801
+ }
1802
+
1803
+ const point = getNaturalPoint(event.clientX, event.clientY);
1804
+ if (!point) {
1805
+ return;
1806
+ }
1807
+
1808
+ if (
1809
+ !dragState.moved
1810
+ && Math.hypot(event.clientX - dragState.startPointerX, event.clientY - dragState.startPointerY) > 3
1811
+ ) {
1812
+ dragState.moved = true;
1813
+ }
1814
+
1815
+ if (dragState.mode === "move") {
1816
+ const next = clampLayerPosition(
1817
+ layer,
1818
+ point.x - dragState.offsetX,
1819
+ point.y - dragState.offsetY,
1820
+ );
1821
+ layer.x = next.x;
1822
+ layer.y = next.y;
1823
+ return;
1824
+ }
1825
+
1826
+ if (dragState.mode === "scale") {
1827
+ const center = getLayerCenter(layer);
1828
+ const currentDistance = Math.max(1, Math.hypot(point.x - center.x, point.y - center.y));
1829
+ layer.scale = clampLayerScale(dragState.startScale * (currentDistance / dragState.startDistance));
1830
+ const next = clampLayerPosition(layer, layer.x, layer.y);
1831
+ layer.x = next.x;
1832
+ layer.y = next.y;
1833
+ return;
1834
+ }
1835
+
1836
+ const center = getLayerCenter(layer);
1837
+ const currentAngle = Math.atan2(point.y - center.y, point.x - center.x);
1838
+ const deltaDegrees = ((currentAngle - dragState.startAngle) * 180) / Math.PI;
1839
+ layer.rotation = dragState.startRotation + deltaDegrees;
1840
+ }
1841
+
1842
+ function stopLayerDrag(event?: PointerEvent): void {
1843
+ if (!dragState) {
1844
+ return;
1845
+ }
1846
+
1847
+ if (event && dragState.pointerId !== event.pointerId) {
1848
+ return;
1849
+ }
1850
+
1851
+ lastLayerInteraction = {
1852
+ layerId: dragState.layerId,
1853
+ wasSelected: dragState.wasSelected,
1854
+ moved: dragState.moved,
1855
+ mode: dragState.mode,
1856
+ };
1857
+ dragState = null;
1858
+ }
1859
+
1860
+ function onLayerClick(layerId: string): void {
1861
+ if (!lastLayerInteraction || lastLayerInteraction.layerId !== layerId) {
1862
+ selectedLayerId.value = selectedLayerId.value === layerId ? null : layerId;
1863
+ return;
1864
+ }
1865
+
1866
+ const shouldToggleOff = lastLayerInteraction.wasSelected
1867
+ && !lastLayerInteraction.moved
1868
+ && lastLayerInteraction.mode === "move";
1869
+ if (shouldToggleOff) {
1870
+ selectedLayerId.value = null;
1871
+ } else {
1872
+ selectedLayerId.value = layerId;
1873
+ }
1874
+
1875
+ lastLayerInteraction = null;
1876
+ }
1877
+
1878
+ function toggleLayerSelection(layerId: string): void {
1879
+ selectedLayerId.value = selectedLayerId.value === layerId ? null : layerId;
1880
+ lastLayerInteraction = null;
1881
+ }
1882
+
1883
+ function clearSelection(): void {
1884
+ selectedLayerId.value = null;
1885
+ }
1886
+
1887
+ function layerStyle(layer: EditorLayer): Record<string, string> {
1888
+ const bounds = measureLayerBase(layer);
1889
+ const baseStyle: Record<string, string> = {
1890
+ left: `${layer.x}px`,
1891
+ top: `${layer.y}px`,
1892
+ width: `${bounds.width}px`,
1893
+ height: `${bounds.height}px`,
1894
+ transform: `rotate(${layer.rotation}deg) scale(${layer.scale})`,
1895
+ transformOrigin: "center center",
1896
+ };
1897
+
1898
+ if (layer.type === "text") {
1899
+ return {
1900
+ ...baseStyle,
1901
+ fontSize: `${layer.fontSize}px`,
1902
+ fontStyle: layer.fontStyle,
1903
+ fontWeight: String(layer.fontWeight),
1904
+ fontFamily: layer.fontFamily,
1905
+ color: layer.color,
1906
+ };
1907
+ }
1908
+
1909
+ return {
1910
+ ...baseStyle,
1911
+ };
1912
+ }
1913
+
1914
+ function nudgeSelectedLayer(deltaX: number, deltaY: number): void {
1915
+ if (!selectedLayer.value) {
1916
+ return;
1917
+ }
1918
+
1919
+ const next = clampLayerPosition(
1920
+ selectedLayer.value,
1921
+ selectedLayer.value.x + deltaX,
1922
+ selectedLayer.value.y + deltaY,
1923
+ );
1924
+ selectedLayer.value.x = next.x;
1925
+ selectedLayer.value.y = next.y;
1926
+ }
1927
+
1928
+ function onStageKeyDown(event: KeyboardEvent): void {
1929
+ if (!selectedLayer.value) {
1930
+ return;
1931
+ }
1932
+
1933
+ const step = event.shiftKey ? 10 : 1;
1934
+ if (event.key === "ArrowLeft") {
1935
+ event.preventDefault();
1936
+ nudgeSelectedLayer(-step, 0);
1937
+ } else if (event.key === "ArrowRight") {
1938
+ event.preventDefault();
1939
+ nudgeSelectedLayer(step, 0);
1940
+ } else if (event.key === "ArrowUp") {
1941
+ event.preventDefault();
1942
+ nudgeSelectedLayer(0, -step);
1943
+ } else if (event.key === "ArrowDown") {
1944
+ event.preventDefault();
1945
+ nudgeSelectedLayer(0, step);
1946
+ }
1947
+ }
1948
+
1949
+ function isEditableTarget(target: EventTarget | null): boolean {
1950
+ if (!(target instanceof HTMLElement)) {
1951
+ return false;
1952
+ }
1953
+
1954
+ const tagName = target.tagName;
1955
+ return target.isContentEditable
1956
+ || tagName === "INPUT"
1957
+ || tagName === "TEXTAREA"
1958
+ || tagName === "SELECT";
1959
+ }
1960
+
1961
+ function onWindowKeyDown(event: KeyboardEvent): void {
1962
+ if (isEditableTarget(event.target)) {
1963
+ return;
1964
+ }
1965
+
1966
+ if (event.key === "Escape") {
1967
+ clearSelection();
1968
+ return;
1969
+ }
1970
+
1971
+ onStageKeyDown(event);
1972
+ }
1973
+
1974
+ function syncSelectedTextPosition(): void {
1975
+ if (!selectedTextLayer.value) {
1976
+ return;
1977
+ }
1978
+
1979
+ const next = clampLayerPosition(
1980
+ selectedTextLayer.value,
1981
+ selectedTextLayer.value.x,
1982
+ selectedTextLayer.value.y,
1983
+ );
1984
+ selectedTextLayer.value.x = next.x;
1985
+ selectedTextLayer.value.y = next.y;
1986
+ }
1987
+
1988
+ function syncSelectedLayerTransform(): void {
1989
+ if (!selectedLayer.value) {
1990
+ return;
1991
+ }
1992
+
1993
+ selectedLayer.value.scale = clampLayerScale(selectedLayer.value.scale);
1994
+ const next = clampLayerPosition(
1995
+ selectedLayer.value,
1996
+ selectedLayer.value.x,
1997
+ selectedLayer.value.y,
1998
+ );
1999
+ selectedLayer.value.x = next.x;
2000
+ selectedLayer.value.y = next.y;
2001
+ }
2002
+
2003
+ function syncSelectedImageSize(): void {
2004
+ if (!selectedImageLayer.value) {
2005
+ return;
2006
+ }
2007
+
2008
+ const safeWidth = Math.max(24, selectedImageLayer.value.width);
2009
+ selectedImageLayer.value.width = safeWidth;
2010
+ selectedImageLayer.value.height = safeWidth
2011
+ * (selectedImageLayer.value.naturalHeight / selectedImageLayer.value.naturalWidth);
2012
+
2013
+ const next = clampLayerPosition(
2014
+ selectedImageLayer.value,
2015
+ selectedImageLayer.value.x,
2016
+ selectedImageLayer.value.y,
2017
+ );
2018
+ selectedImageLayer.value.x = next.x;
2019
+ selectedImageLayer.value.y = next.y;
2020
+ }
2021
+
2022
+ function restartPreviewLoop(playAfterSeek: boolean): void {
2023
+ const sourceVideo = sourcePreviewVideoRef.value;
2024
+ const startSeconds = previewStartSeconds.value;
2025
+
2026
+ if (sourceVideo) {
2027
+ sourceVideo.currentTime = startSeconds;
2028
+ }
2029
+
2030
+ if (playAfterSeek && sourceVideo) {
2031
+ void sourceVideo.play().catch(() => {
2032
+ // Ignore autoplay failures.
2033
+ });
2034
+ }
2035
+
2036
+ queueForegroundPreviewFrame();
2037
+ }
2038
+
2039
+ function onSourcePreviewLoaded(): void {
2040
+ const sourceVideo = sourcePreviewVideoRef.value;
2041
+ if (sourceVideo) {
2042
+ sourceVideo.currentTime = previewStartSeconds.value;
2043
+ }
2044
+
2045
+ isPreviewPlaying.value = false;
2046
+ queueForegroundPreviewFrame();
2047
+ }
2048
+
2049
+ function onSourcePreviewPlay(): void {
2050
+ isPreviewPlaying.value = true;
2051
+ queueForegroundPreviewFrame();
2052
+ }
2053
+
2054
+ function onSourcePreviewPause(): void {
2055
+ isPreviewPlaying.value = false;
2056
+ queueForegroundPreviewFrame();
2057
+ }
2058
+
2059
+ function onSourcePreviewTimeUpdate(): void {
2060
+ const startSeconds = previewStartSeconds.value;
2061
+ const capDuration = previewCapDurationSeconds.value;
2062
+ const sourceVideo = sourcePreviewVideoRef.value;
2063
+ if (sourceVideo && sourceVideo.currentTime < startSeconds) {
2064
+ sourceVideo.currentTime = startSeconds;
2065
+ return;
2066
+ }
2067
+
2068
+ if (capDuration && sourceVideo && sourceVideo.currentTime >= capDuration) {
2069
+ restartPreviewLoop(true);
2070
+ return;
2071
+ }
2072
+
2073
+ queueForegroundPreviewFrame();
2074
+ }
2075
+
2076
+ function onSourcePreviewSeeking(): void {
2077
+ const startSeconds = previewStartSeconds.value;
2078
+ const capDuration = previewCapDurationSeconds.value;
2079
+ const sourceVideo = sourcePreviewVideoRef.value;
2080
+ if (sourceVideo && sourceVideo.currentTime < startSeconds) {
2081
+ sourceVideo.currentTime = startSeconds;
2082
+ return;
2083
+ }
2084
+
2085
+ if (capDuration && sourceVideo && sourceVideo.currentTime > capDuration) {
2086
+ sourceVideo.currentTime = Math.max(startSeconds, capDuration - 1 / VIDEO_CAPTURE_FPS);
2087
+ return;
2088
+ }
2089
+
2090
+ queueForegroundPreviewFrame();
2091
+ }
2092
+
2093
+ function onSourcePreviewRateChange(): void {
2094
+ queueForegroundPreviewFrame();
2095
+ }
2096
+
2097
+ function togglePreviewPlayback(): void {
2098
+ const sourceVideo = sourcePreviewVideoRef.value;
2099
+ if (!sourceVideo) {
2100
+ return;
2101
+ }
2102
+
2103
+ if (sourceVideo.paused) {
2104
+ void sourceVideo.play().catch(() => {
2105
+ statusText.value = "Video playback was blocked by the browser.";
2106
+ });
2107
+ return;
2108
+ }
2109
+
2110
+ sourceVideo.pause();
2111
+ }
2112
+
2113
+ function resetPreviewPlayback(): void {
2114
+ stopPreviewVideos();
2115
+ const sourceVideo = sourcePreviewVideoRef.value;
2116
+ if (sourceVideo) {
2117
+ sourceVideo.currentTime = previewStartSeconds.value;
2118
+ }
2119
+
2120
+ queueForegroundPreviewFrame();
2121
+ }
2122
+
2123
+ async function preloadLayerImages(): Promise<Map<string, HTMLImageElement>> {
2124
+ const cache = new Map<string, HTMLImageElement>();
2125
+
2126
+ for (const layer of userLayers.value) {
2127
+ if (layer.type === "image" && layer.visible) {
2128
+ cache.set(layer.id, await loadImageElement(layer.url));
2129
+ }
2130
+ }
2131
+
2132
+ return cache;
2133
+ }
2134
+
2135
+ function drawTextLayer(context: CanvasRenderingContext2D, layer: TextLayer): void {
2136
+ const bounds = measureLayerBase(layer);
2137
+ context.save();
2138
+ context.translate(layer.x + bounds.width / 2, layer.y + bounds.height / 2);
2139
+ context.rotate((layer.rotation * Math.PI) / 180);
2140
+ context.scale(layer.scale, layer.scale);
2141
+ context.font = fontDescriptor(layer);
2142
+ context.fillStyle = layer.color;
2143
+ context.textBaseline = "top";
2144
+
2145
+ const lines = layer.text.split(/\r?\n/);
2146
+ const lineHeight = layer.fontSize * 1.08;
2147
+ let y = -bounds.height / 2;
2148
+
2149
+ for (const line of lines) {
2150
+ context.fillText(line || " ", -bounds.width / 2, y);
2151
+ y += lineHeight;
2152
+ }
2153
+
2154
+ context.restore();
2155
+ }
2156
+
2157
+ async function drawVisibleLayers(
2158
+ context: CanvasRenderingContext2D,
2159
+ imageCache: Map<string, HTMLImageElement>,
2160
+ placement: "middle" | "front",
2161
+ ): Promise<void> {
2162
+ for (const layer of userLayers.value) {
2163
+ if (!layer.visible || layer.placement !== placement) {
2164
+ continue;
2165
+ }
2166
+
2167
+ if (layer.type === "image") {
2168
+ const image = imageCache.get(layer.id);
2169
+ if (!image) {
2170
+ continue;
2171
+ }
2172
+
2173
+ const bounds = measureLayerBase(layer);
2174
+ context.save();
2175
+ context.translate(layer.x + bounds.width / 2, layer.y + bounds.height / 2);
2176
+ context.rotate((layer.rotation * Math.PI) / 180);
2177
+ context.scale(layer.scale, layer.scale);
2178
+ context.drawImage(image, -bounds.width / 2, -bounds.height / 2, bounds.width, bounds.height);
2179
+ context.restore();
2180
+ continue;
2181
+ }
2182
+
2183
+ drawTextLayer(context, layer);
2184
+ }
2185
+ }
2186
+
2187
+ async function renderCompositeImageCanvas(): Promise<HTMLCanvasElement> {
2188
+ if (!stageSize.value || !sourcePreviewUrl.value) {
2189
+ throw new Error("Choose a base image before exporting.");
2190
+ }
2191
+
2192
+ const canvas = document.createElement("canvas");
2193
+ canvas.width = stageSize.value.width;
2194
+ canvas.height = stageSize.value.height;
2195
+
2196
+ const context = getCanvasContext(canvas);
2197
+ const sourceImage = await loadImageElement(sourcePreviewUrl.value);
2198
+ const imageCache = await preloadLayerImages();
2199
+
2200
+ context.drawImage(sourceImage, 0, 0, canvas.width, canvas.height);
2201
+ await drawVisibleLayers(context, imageCache, "middle");
2202
+
2203
+ if (resultPreviewUrl.value) {
2204
+ const foregroundImage = await loadImageElement(resultPreviewUrl.value);
2205
+ context.drawImage(foregroundImage, 0, 0, canvas.width, canvas.height);
2206
+ }
2207
+
2208
+ await drawVisibleLayers(context, imageCache, "front");
2209
+
2210
+ return canvas;
2211
+ }
2212
+
2213
+ async function exportCompositeImage(): Promise<void> {
2214
+ if (!sourceSummary.value) {
2215
+ throw new Error("Choose a base image before exporting.");
2216
+ }
2217
+
2218
+ const canvas = await renderCompositeImageCanvas();
2219
+ const blob = await blobFromCanvas(canvas);
2220
+ const link = document.createElement("a");
2221
+ const sourceStem = sourceSummary.value.name.replace(/\.[^.]+$/, "") || "layered-image";
2222
+ link.href = URL.createObjectURL(blob);
2223
+ link.download = resultPreviewUrl.value
2224
+ ? `${sourceStem}-layered.png`
2225
+ : `${sourceStem}-preview.png`;
2226
+ link.click();
2227
+ setTimeout(() => URL.revokeObjectURL(link.href), 0);
2228
+ }
2229
+
2230
+ async function exportCompositeVideo(): Promise<void> {
2231
+ if (typeof VideoEncoder === "undefined") {
2232
+ throw new Error("WebCodecs VideoEncoder is required to export composite videos in this browser.");
2233
+ }
2234
+
2235
+ if (!sourceSummary.value || !sourcePreviewUrl.value || !processedVideoMeta.value || processedVideoFrames.value.length === 0) {
2236
+ throw new Error("Process the video first so the foreground frames exist.");
2237
+ }
2238
+
2239
+ let sourceVideo: HTMLVideoElement | null = null;
2240
+
2241
+ try {
2242
+ const { width, height, startFrame, targetFrames } = processedVideoMeta.value;
2243
+ const frameDurationUs = Math.round(1_000_000 / VIDEO_CAPTURE_FPS);
2244
+ const frameDurationSeconds = 1 / VIDEO_CAPTURE_FPS;
2245
+ const compositeCanvas = document.createElement("canvas");
2246
+ compositeCanvas.width = width;
2247
+ compositeCanvas.height = height;
2248
+ const context = getCanvasContext(compositeCanvas);
2249
+ const imageCache = await preloadLayerImages();
2250
+
2251
+ sourceVideo = await loadDetachedVideo(sourcePreviewUrl.value);
2252
+
2253
+ const sourceLastFrameTime = Math.max(0, sourceVideo.duration - frameDurationSeconds / 2);
2254
+ const { encoderConfig, muxerCodec } = await pickVideoEncoderConfig(width, height);
2255
+ const target = new ArrayBufferTarget();
2256
+ const muxer = new Muxer({
2257
+ target,
2258
+ video: {
2259
+ codec: muxerCodec,
2260
+ width,
2261
+ height,
2262
+ frameRate: VIDEO_CAPTURE_FPS,
2263
+ },
2264
+ firstTimestampBehavior: "offset",
2265
+ });
2266
+
2267
+ let encoderError: Error | null = null;
2268
+ const encoder = new VideoEncoder({
2269
+ output: (chunk, meta) => {
2270
+ muxer.addVideoChunk(chunk, meta);
2271
+ },
2272
+ error: (error) => {
2273
+ encoderError = error instanceof Error ? error : new Error(String(error));
2274
+ },
2275
+ });
2276
+ encoder.configure(encoderConfig);
2277
+
2278
+ for (let frameIndex = 0; frameIndex < targetFrames; frameIndex += 1) {
2279
+ if (encoderError) {
2280
+ throw encoderError;
2281
+ }
2282
+
2283
+ const sourceTime = Math.min((startFrame + frameIndex) * frameDurationSeconds, sourceLastFrameTime);
2284
+
2285
+ await seekVideo(sourceVideo, sourceTime);
2286
+
2287
+ context.clearRect(0, 0, width, height);
2288
+ context.drawImage(sourceVideo, 0, 0, width, height);
2289
+ await drawVisibleLayers(context, imageCache, "middle");
2290
+ const foregroundFrame = processedVideoFrames.value[frameIndex];
2291
+ if (foregroundFrame) {
2292
+ context.drawImage(foregroundFrame, 0, 0, width, height);
2293
+ }
2294
+ await drawVisibleLayers(context, imageCache, "front");
2295
+
2296
+ const frame = new VideoFrame(compositeCanvas, {
2297
+ timestamp: frameIndex * frameDurationUs,
2298
+ duration: frameDurationUs,
2299
+ });
2300
+ encoder.encode(frame, { keyFrame: frameIndex % VIDEO_CAPTURE_FPS === 0 });
2301
+ frame.close();
2302
+
2303
+ progressPercent.value = clampPercent(((frameIndex + 1) / targetFrames) * 100);
2304
+ statusText.value = `Rendering composite video… ${Math.round(progressPercent.value)}%`;
2305
+ }
2306
+
2307
+ await encoder.flush();
2308
+ if (encoderError) {
2309
+ throw encoderError;
2310
+ }
2311
+
2312
+ encoder.close();
2313
+ muxer.finalize();
2314
+
2315
+ const resultBlob = new Blob([target.buffer], { type: "video/webm" });
2316
+ const link = document.createElement("a");
2317
+ const sourceStem = sourceSummary.value.name.replace(/\.[^.]+$/, "") || "layered-video";
2318
+ link.href = URL.createObjectURL(resultBlob);
2319
+ link.download = `${sourceStem}-layered.webm`;
2320
+ link.click();
2321
+ setTimeout(() => URL.revokeObjectURL(link.href), 0);
2322
+ } finally {
2323
+ sourceVideo?.pause();
2324
+ if (sourceVideo) {
2325
+ sourceVideo.removeAttribute("src");
2326
+ sourceVideo.load();
2327
+ }
2328
+ }
2329
+ }
2330
+
2331
+ async function exportComposite(): Promise<void> {
2332
+ if (!sourceSummary.value) {
2333
+ statusText.value = "Choose an image or video first.";
2334
+ return;
2335
+ }
2336
+
2337
+ isExporting.value = true;
2338
+ errorText.value = "";
2339
+ progressPercent.value = null;
2340
+ statusText.value = selectedMediaKind.value === "video"
2341
+ ? "Preparing composite WebM export…"
2342
+ : "Rendering composite PNG…";
2343
+
2344
+ try {
2345
+ if (selectedMediaKind.value === "video") {
2346
+ await exportCompositeVideo();
2347
+ statusText.value = "Composite WebM exported.";
2348
+ return;
2349
+ }
2350
+
2351
+ await exportCompositeImage();
2352
+ statusText.value = "Composite PNG exported.";
2353
+ } catch (error) {
2354
+ errorText.value = error instanceof Error ? error.message : String(error);
2355
+ statusText.value = "The composite could not be exported.";
2356
+ } finally {
2357
+ isExporting.value = false;
2358
+ if (!errorText.value) {
2359
+ syncIdleStatus();
2360
+ }
2361
+ }
2362
+ }
2363
+ </script>
2364
+
2365
+ <template>
2366
+ <div class="shell">
2367
+ <aside class="sidebar">
2368
+ <header class="hero hero-compact">
2369
+ <h1>RMBG Layers</h1>
2370
+ </header>
2371
+
2372
+ <section class="status-grid">
2373
+ <div class="status-item">
2374
+ <p class="label">Model</p>
2375
+ <p class="value">{{ modelStateLabel }}</p>
2376
+ </div>
2377
+ <div class="status-item">
2378
+ <p class="label">Backend</p>
2379
+ <p class="value">{{ activeBackend }}</p>
2380
+ </div>
2381
+ <div class="status-item">
2382
+ <p class="label">Canvas</p>
2383
+ <p class="value">{{ stageDimensionsLabel }}</p>
2384
+ </div>
2385
+ <div class="status-item">
2386
+ <p class="label">Progress</p>
2387
+ <p class="value">{{ progressLabel }}</p>
2388
+ </div>
2389
+ <div class="status-item status-wide">
2390
+ <p class="label">Status</p>
2391
+ <p class="status-text">{{ statusText }}</p>
2392
+ </div>
2393
+ </section>
2394
+
2395
+ <section class="section">
2396
+ <div class="section-row">
2397
+ <p class="section-title">Source</p>
2398
+ <button
2399
+ class="button button-secondary"
2400
+ type="button"
2401
+ :disabled="loadButtonDisabled"
2402
+ @click="loadSelectedModel"
2403
+ >
2404
+ {{ isModelLoading ? "Loading model…" : isReady ? "Reload model" : "Load model" }}
2405
+ </button>
2406
+ </div>
2407
+
2408
+ <div
2409
+ class="dropzone"
2410
+ :class="{ 'dropzone-active': isDragging }"
2411
+ @dragenter.prevent="onDragEnter"
2412
+ @dragover.prevent="isDragging = true"
2413
+ @dragleave="onDragLeave"
2414
+ @drop.prevent="onDrop"
2415
+ >
2416
+ <input
2417
+ ref="fileInput"
2418
+ hidden
2419
+ type="file"
2420
+ :accept="ACCEPTED_FILE_TYPES"
2421
+ @change="onFileInputChanged"
2422
+ />
2423
+ <p class="dropzone-title">
2424
+ {{ hasSource ? "Replace image or video" : "Drop an image or video here" }}
2425
+ </p>
2426
+ <p class="dropzone-copy">
2427
+ PNG, JPG, WebP, MP4, WebM, MOV, and M4V are supported.
2428
+ </p>
2429
+ <button class="button button-secondary" type="button" @click.stop="openFilePicker">
2430
+ Choose file
2431
+ </button>
2432
+ </div>
2433
+
2434
+ </section>
2435
+
2436
+ <section v-if="hasVideoSource" class="section">
2437
+ <p class="section-title">Video</p>
2438
+
2439
+ <div class="field-grid">
2440
+ <label class="field">
2441
+ <span class="label">Start frame</span>
2442
+ <input v-model="startFrameInput" class="field-input" type="text" inputmode="numeric" />
2443
+ </label>
2444
+ <label class="field">
2445
+ <span class="label">Frame cap</span>
2446
+ <input v-model="videoFrameCapInput" class="field-input" type="text" inputmode="numeric" placeholder="Full clip" />
2447
+ </label>
2448
+ </div>
2449
+
2450
+ <div class="button-row">
2451
+ <button class="button button-secondary" type="button" :disabled="!hasSource" @click="togglePreviewPlayback">
2452
+ {{ isPreviewPlaying ? "Pause preview" : "Play preview" }}
2453
+ </button>
2454
+ <button class="button button-secondary" type="button" :disabled="!hasSource" @click="resetPreviewPlayback">
2455
+ Reset preview
2456
+ </button>
2457
+ </div>
2458
+ </section>
2459
+
2460
+ <section class="section">
2461
+ <div class="section-row">
2462
+ <p class="section-title">Layers</p>
2463
+ <button class="button button-primary" type="button" :disabled="!hasSource" @click="addTextLayer">
2464
+ Add text
2465
+ </button>
2466
+ <button class="button button-secondary" type="button" :disabled="!hasSource" @click="openLayerImagePicker">
2467
+ Add image
2468
+ </button>
2469
+ </div>
2470
+
2471
+ <input
2472
+ ref="layerImageInput"
2473
+ hidden
2474
+ type="file"
2475
+ accept="image/png,image/jpeg,image/webp"
2476
+ @change="onLayerImageInputChanged"
2477
+ />
2478
+
2479
+ <ul v-if="userLayers.length" class="layer-list">
2480
+ <li
2481
+ v-for="(layer, index) in userLayers"
2482
+ :key="layer.id"
2483
+ class="layer-row"
2484
+ :class="{ 'layer-row-selected': selectedLayerId === layer.id }"
2485
+ @click="toggleLayerSelection(layer.id)"
2486
+ >
2487
+ <button class="layer-main" type="button" @click.stop="toggleLayerSelection(layer.id)">
2488
+ <span class="layer-name">{{ layer.name }}</span>
2489
+ <span class="layer-kind">{{ layer.type }} · {{ layer.placement === "front" ? "above" : "behind" }}</span>
2490
+ </button>
2491
+ <div class="layer-actions">
2492
+ <button class="icon-button" type="button" @click.stop="layer.visible = !layer.visible">
2493
+ {{ layer.visible ? "Hide" : "Show" }}
2494
+ </button>
2495
+ <button class="icon-button" type="button" :disabled="index === 0" @click.stop="moveLayer(layer.id, 'down')">
2496
+ Back
2497
+ </button>
2498
+ <button
2499
+ class="icon-button"
2500
+ type="button"
2501
+ :disabled="index === userLayers.length - 1"
2502
+ @click.stop="moveLayer(layer.id, 'up')"
2503
+ >
2504
+ Front
2505
+ </button>
2506
+ <button class="icon-button icon-danger" type="button" @click.stop="removeLayer(layer.id)">
2507
+ Delete
2508
+ </button>
2509
+ </div>
2510
+ </li>
2511
+ </ul>
2512
+
2513
+ <p v-else class="empty-note">
2514
+ No layers yet.
2515
+ </p>
2516
+ </section>
2517
+
2518
+ <section class="section">
2519
+ <p class="section-title">Selected layer</p>
2520
+
2521
+ <div v-if="selectedTextLayer" class="inspector">
2522
+ <label class="field">
2523
+ <span class="label">Text</span>
2524
+ <textarea
2525
+ v-model="selectedTextLayer.text"
2526
+ class="field-input field-textarea"
2527
+ rows="4"
2528
+ @input="syncSelectedTextPosition"
2529
+ />
2530
+ </label>
2531
+ <div class="field-grid">
2532
+ <label class="field">
2533
+ <span class="label">Size</span>
2534
+ <input
2535
+ v-model.number="selectedTextLayer.fontSize"
2536
+ class="field-input"
2537
+ type="number"
2538
+ min="12"
2539
+ max="640"
2540
+ @input="syncSelectedTextPosition"
2541
+ />
2542
+ </label>
2543
+ <label class="field">
2544
+ <span class="label">Weight</span>
2545
+ <select v-model.number="selectedTextLayer.fontWeight" class="field-input">
2546
+ <option v-for="weight in FONT_WEIGHT_OPTIONS" :key="weight" :value="weight">
2547
+ {{ weight }}
2548
+ </option>
2549
+ </select>
2550
+ </label>
2551
+ </div>
2552
+ <div class="field-grid">
2553
+ <label class="field">
2554
+ <span class="label">Style</span>
2555
+ <select v-model="selectedTextLayer.fontStyle" class="field-input">
2556
+ <option value="normal">Normal</option>
2557
+ <option value="italic">Italic</option>
2558
+ </select>
2559
+ </label>
2560
+ <label class="field">
2561
+ <span class="label">Color</span>
2562
+ <input v-model="selectedTextLayer.color" class="field-input field-color" type="color" />
2563
+ </label>
2564
+ </div>
2565
+ <div class="field-grid">
2566
+ <label class="field">
2567
+ <span class="label">Scale</span>
2568
+ <input
2569
+ v-model.number="selectedTextLayer.scale"
2570
+ class="field-input"
2571
+ type="number"
2572
+ min="0.1"
2573
+ max="8"
2574
+ step="0.05"
2575
+ @input="syncSelectedLayerTransform"
2576
+ />
2577
+ </label>
2578
+ <label class="field">
2579
+ <span class="label">Rotate</span>
2580
+ <input
2581
+ v-model.number="selectedTextLayer.rotation"
2582
+ class="field-input"
2583
+ type="number"
2584
+ step="1"
2585
+ />
2586
+ </label>
2587
+ </div>
2588
+ <label class="field">
2589
+ <span class="label">Family</span>
2590
+ <select v-model="selectedTextLayer.fontFamily" class="field-input">
2591
+ <option v-for="family in FONT_FAMILY_OPTIONS" :key="family" :value="family">
2592
+ {{ family }}
2593
+ </option>
2594
+ </select>
2595
+ </label>
2596
+ <label class="field">
2597
+ <span class="label">Placement</span>
2598
+ <select v-model="selectedTextLayer.placement" class="field-input">
2599
+ <option value="middle">Behind subject</option>
2600
+ <option value="front">Above subject</option>
2601
+ </select>
2602
+ </label>
2603
+ <div class="button-row">
2604
+ <button class="button button-secondary" type="button" @click="duplicateSelectedTextLayer">
2605
+ Duplicate text
2606
+ </button>
2607
+ </div>
2608
+ </div>
2609
+
2610
+ <div v-else-if="selectedImageLayer" class="inspector">
2611
+ <label class="field">
2612
+ <span class="label">Layer name</span>
2613
+ <input v-model="selectedImageLayer.name" class="field-input" type="text" />
2614
+ </label>
2615
+ <label class="field">
2616
+ <span class="label">Width</span>
2617
+ <input
2618
+ v-model.number="selectedImageLayer.width"
2619
+ class="field-input"
2620
+ type="number"
2621
+ min="24"
2622
+ @input="syncSelectedImageSize"
2623
+ />
2624
+ </label>
2625
+ <div class="field-grid">
2626
+ <label class="field">
2627
+ <span class="label">Scale</span>
2628
+ <input
2629
+ v-model.number="selectedImageLayer.scale"
2630
+ class="field-input"
2631
+ type="number"
2632
+ min="0.1"
2633
+ max="8"
2634
+ step="0.05"
2635
+ @input="syncSelectedLayerTransform"
2636
+ />
2637
+ </label>
2638
+ <label class="field">
2639
+ <span class="label">Rotate</span>
2640
+ <input
2641
+ v-model.number="selectedImageLayer.rotation"
2642
+ class="field-input"
2643
+ type="number"
2644
+ step="1"
2645
+ />
2646
+ </label>
2647
+ </div>
2648
+ <label class="field">
2649
+ <span class="label">Placement</span>
2650
+ <select v-model="selectedImageLayer.placement" class="field-input">
2651
+ <option value="middle">Behind subject</option>
2652
+ <option value="front">Above subject</option>
2653
+ </select>
2654
+ </label>
2655
+ </div>
2656
+
2657
+ <p v-else class="empty-note">
2658
+ Select a layer.
2659
+ </p>
2660
+ </section>
2661
+
2662
+ <section class="section">
2663
+ <div class="button-row">
2664
+ <button class="button button-primary" type="button" :disabled="!canRun" @click="runInference">
2665
+ {{ processLabel }}
2666
+ </button>
2667
+ <button class="button button-secondary" type="button" :disabled="!canExport" @click="exportComposite">
2668
+ {{ isExporting ? "Exporting…" : exportLabel }}
2669
+ </button>
2670
+ </div>
2671
+
2672
+ <div v-if="resultSummary" class="meta-card">
2673
+ <p><span>Output</span>{{ resultSummary.output }}</p>
2674
+ <p><span>Backend</span>{{ resultSummary.backend }}</p>
2675
+ <p><span>Time</span>{{ resultSummary.duration }}</p>
2676
+ <p v-if="resultSummary.fps"><span>Frame rate</span>{{ resultSummary.fps }}</p>
2677
+ <p v-if="resultSummary.frames"><span>Frames</span>{{ resultSummary.frames }}</p>
2678
+ <p v-if="resultSummary.note" class="meta-note">{{ resultSummary.note }}</p>
2679
+ </div>
2680
+
2681
+ <p v-if="errorText" class="error-text">{{ errorText }}</p>
2682
+ </section>
2683
+ </aside>
2684
+
2685
+ <main class="workspace">
2686
+ <section class="panel editor-panel">
2687
+ <div
2688
+ ref="editorViewportRef"
2689
+ class="editor-viewport"
2690
+ tabindex="0"
2691
+ @keydown="onStageKeyDown"
2692
+ >
2693
+ <div
2694
+ v-if="hasSource && stageSize"
2695
+ ref="stageFrameRef"
2696
+ class="editor-stage-frame"
2697
+ :style="stageFrameStyle"
2698
+ >
2699
+ <div class="editor-stage-surface" :style="stageSurfaceStyle" @pointerdown.self="clearSelection">
2700
+ <img
2701
+ v-if="selectedMediaKind === 'image'"
2702
+ class="stage-media stage-base"
2703
+ :src="sourcePreviewUrl"
2704
+ alt="Base image"
2705
+ draggable="false"
2706
+ />
2707
+ <video
2708
+ v-else
2709
+ ref="sourcePreviewVideoRef"
2710
+ class="stage-media stage-base"
2711
+ :src="sourcePreviewUrl"
2712
+ muted
2713
+ playsinline
2714
+ preload="metadata"
2715
+ @loadedmetadata="onSourcePreviewLoaded"
2716
+ @play="onSourcePreviewPlay"
2717
+ @pause="onSourcePreviewPause"
2718
+ @timeupdate="onSourcePreviewTimeUpdate"
2719
+ @seeking="onSourcePreviewSeeking"
2720
+ @ratechange="onSourcePreviewRateChange"
2721
+ />
2722
+
2723
+ <template v-for="layer in middleLayers" :key="layer.id">
2724
+ <div
2725
+ class="stage-layer stage-layer-shell"
2726
+ :class="{ 'stage-layer-selected': selectedLayerId === layer.id }"
2727
+ :style="layerStyle(layer)"
2728
+ @pointerdown="startLayerDrag($event, layer.id)"
2729
+ @click.stop="onLayerClick(layer.id)"
2730
+ >
2731
+ <img
2732
+ v-if="layer.type === 'image'"
2733
+ class="stage-layer-content stage-layer-image"
2734
+ :src="layer.url"
2735
+ :alt="layer.name"
2736
+ draggable="false"
2737
+ />
2738
+ <div v-else class="stage-layer-content stage-layer-text">
2739
+ {{ layer.text }}
2740
+ </div>
2741
+ </div>
2742
+ </template>
2743
+
2744
+ <img
2745
+ v-if="selectedMediaKind === 'image' && hasForeground"
2746
+ class="stage-media stage-foreground"
2747
+ :src="resultPreviewUrl"
2748
+ alt="Foreground cutout"
2749
+ draggable="false"
2750
+ />
2751
+ <canvas
2752
+ v-else-if="selectedMediaKind === 'video' && hasForeground"
2753
+ ref="resultPreviewCanvasRef"
2754
+ class="stage-media stage-foreground"
2755
+ />
2756
+
2757
+ <template v-for="layer in frontLayers" :key="`${layer.id}-front`">
2758
+ <div
2759
+ class="stage-layer stage-layer-shell"
2760
+ :class="{ 'stage-layer-selected': selectedLayerId === layer.id }"
2761
+ :style="layerStyle(layer)"
2762
+ @pointerdown="startLayerDrag($event, layer.id)"
2763
+ @click.stop="onLayerClick(layer.id)"
2764
+ >
2765
+ <img
2766
+ v-if="layer.type === 'image'"
2767
+ class="stage-layer-content stage-layer-image"
2768
+ :src="layer.url"
2769
+ :alt="layer.name"
2770
+ draggable="false"
2771
+ />
2772
+ <div v-else class="stage-layer-content stage-layer-text">
2773
+ {{ layer.text }}
2774
+ </div>
2775
+ </div>
2776
+ </template>
2777
+
2778
+ <div
2779
+ v-if="selectedLayerOutlineStyle"
2780
+ class="stage-selection-overlay"
2781
+ :style="selectedLayerOutlineStyle"
2782
+ >
2783
+ <div class="stage-selection-outline" />
2784
+ <button
2785
+ class="layer-handle layer-handle-rotate"
2786
+ type="button"
2787
+ @click.stop
2788
+ @pointerdown="selectedLayer && startLayerTransform($event, selectedLayer.id, 'rotate')"
2789
+ >
2790
+
2791
+ </button>
2792
+ <button
2793
+ class="layer-handle layer-handle-scale"
2794
+ type="button"
2795
+ @click.stop
2796
+ @pointerdown="selectedLayer && startLayerTransform($event, selectedLayer.id, 'scale')"
2797
+ >
2798
+
2799
+ </button>
2800
+ </div>
2801
+ </div>
2802
+ </div>
2803
+
2804
+ <div v-else class="editor-empty">
2805
+ <p>Drop a source to start.</p>
2806
+ </div>
2807
+ </div>
2808
+ </section>
2809
+ </main>
2810
+ </div>
2811
+ </template>
src/env.d.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ /// <reference types="vite/client" />
src/main.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import { createApp } from "vue";
2
+ import App from "./App.vue";
3
+ import "./style.css";
4
+
5
+ createApp(App).mount("#app");
src/style.css ADDED
@@ -0,0 +1,547 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: light;
3
+ font-family:
4
+ "Space Grotesk",
5
+ "Avenir Next",
6
+ "Segoe UI",
7
+ sans-serif;
8
+ line-height: 1.5;
9
+ font-weight: 400;
10
+ --paper: #f4f4f2;
11
+ --ink: #171717;
12
+ --muted: #666666;
13
+ --panel: rgba(250, 250, 248, 0.96);
14
+ --panel-strong: #ffffff;
15
+ --border: rgba(23, 23, 23, 0.14);
16
+ --accent: #2a2a2a;
17
+ --accent-strong: #111111;
18
+ --error: #8a1f1f;
19
+ background:
20
+ radial-gradient(circle at top, rgba(0, 0, 0, 0.04), transparent 42%),
21
+ linear-gradient(180deg, #fbfbfa 0%, #f0f0ed 100%);
22
+ color: var(--ink);
23
+ }
24
+
25
+ * {
26
+ box-sizing: border-box;
27
+ }
28
+
29
+ html,
30
+ body,
31
+ #app {
32
+ min-height: 100%;
33
+ }
34
+
35
+ body {
36
+ margin: 0;
37
+ }
38
+
39
+ button,
40
+ input,
41
+ select,
42
+ textarea {
43
+ font: inherit;
44
+ }
45
+
46
+ button {
47
+ border: 0;
48
+ background: none;
49
+ color: inherit;
50
+ }
51
+
52
+ img,
53
+ video {
54
+ display: block;
55
+ max-width: 100%;
56
+ }
57
+
58
+ .shell {
59
+ display: grid;
60
+ min-height: 100vh;
61
+ grid-template-columns: 360px minmax(0, 1fr);
62
+ gap: 14px;
63
+ padding: 14px;
64
+ align-items: start;
65
+ }
66
+
67
+ .panel {
68
+ background: var(--panel);
69
+ border: 1px solid var(--border);
70
+ border-radius: 28px;
71
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.06);
72
+ }
73
+
74
+ .sidebar {
75
+ display: flex;
76
+ flex-direction: column;
77
+ gap: 10px;
78
+ min-height: calc(100vh - 28px);
79
+ padding: 10px 4px 10px 6px;
80
+ }
81
+
82
+ .hero {
83
+ display: flex;
84
+ align-items: center;
85
+ justify-content: space-between;
86
+ padding: 4px 6px 2px;
87
+ }
88
+
89
+ .hero h1 {
90
+ margin: 0;
91
+ font-size: 0.95rem;
92
+ font-weight: 700;
93
+ letter-spacing: 0.08em;
94
+ text-transform: uppercase;
95
+ }
96
+
97
+ .hero-compact {
98
+ min-height: auto;
99
+ }
100
+
101
+ .dropzone-copy,
102
+ .empty-note,
103
+ .status-text,
104
+ .editor-empty span,
105
+ .meta-note {
106
+ margin: 0;
107
+ color: var(--muted);
108
+ }
109
+
110
+ .status-grid {
111
+ display: grid;
112
+ gap: 8px 14px;
113
+ grid-template-columns: repeat(2, minmax(0, 1fr));
114
+ padding: 8px 6px 10px;
115
+ border-block: 1px solid var(--border);
116
+ }
117
+
118
+ .status-wide {
119
+ grid-column: 1 / -1;
120
+ }
121
+
122
+ .label {
123
+ margin: 0 0 4px;
124
+ font-size: 0.76rem;
125
+ font-weight: 700;
126
+ letter-spacing: 0.1em;
127
+ text-transform: uppercase;
128
+ color: var(--muted);
129
+ }
130
+
131
+ .value,
132
+ .status-text {
133
+ margin: 0;
134
+ }
135
+
136
+ .value {
137
+ font-size: 0.95rem;
138
+ }
139
+
140
+ .section {
141
+ display: grid;
142
+ gap: 8px;
143
+ padding: 4px 6px 8px;
144
+ }
145
+
146
+ .section + .section {
147
+ border-top: 1px solid var(--border);
148
+ padding-top: 10px;
149
+ }
150
+
151
+ .section-row {
152
+ display: flex;
153
+ justify-content: space-between;
154
+ gap: 8px;
155
+ align-items: center;
156
+ flex-wrap: wrap;
157
+ }
158
+
159
+ .section-title {
160
+ margin: 0;
161
+ font-size: 0.82rem;
162
+ font-weight: 700;
163
+ letter-spacing: 0.08em;
164
+ text-transform: uppercase;
165
+ color: var(--muted);
166
+ }
167
+
168
+ .dropzone {
169
+ display: grid;
170
+ place-items: center;
171
+ gap: 8px;
172
+ min-height: 148px;
173
+ padding: 16px;
174
+ border: 1.5px dashed rgba(23, 23, 23, 0.22);
175
+ border-radius: 24px;
176
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.86), rgba(245, 245, 243, 0.96));
177
+ text-align: center;
178
+ cursor: pointer;
179
+ transition:
180
+ transform 180ms ease,
181
+ border-color 180ms ease,
182
+ box-shadow 180ms ease;
183
+ }
184
+
185
+ .dropzone-active {
186
+ border-color: rgba(23, 23, 23, 0.38);
187
+ box-shadow: 0 14px 28px rgba(0, 0, 0, 0.07);
188
+ }
189
+
190
+ .dropzone-title {
191
+ margin: 0;
192
+ font-size: 1.15rem;
193
+ font-weight: 700;
194
+ }
195
+
196
+ .button-row,
197
+ .layer-actions {
198
+ display: flex;
199
+ flex-wrap: wrap;
200
+ gap: 8px;
201
+ }
202
+
203
+ .button,
204
+ .icon-button {
205
+ border: 1px solid transparent;
206
+ border-radius: 999px;
207
+ padding: 10px 16px;
208
+ font-weight: 700;
209
+ cursor: pointer;
210
+ transition:
211
+ transform 180ms ease,
212
+ opacity 180ms ease,
213
+ background 180ms ease;
214
+ }
215
+
216
+ .icon-button {
217
+ padding: 7px 11px;
218
+ font-size: 0.83rem;
219
+ }
220
+
221
+ .button:disabled,
222
+ .icon-button:disabled {
223
+ cursor: not-allowed;
224
+ opacity: 0.5;
225
+ }
226
+
227
+ .button:not(:disabled):hover,
228
+ .icon-button:not(:disabled):hover {
229
+ transform: translateY(-1px);
230
+ }
231
+
232
+ .button-primary {
233
+ background: linear-gradient(180deg, #2a2a2a, #111111);
234
+ color: white;
235
+ border-color: rgba(0, 0, 0, 0.4);
236
+ }
237
+
238
+ .button-secondary,
239
+ .icon-button {
240
+ background: rgba(255, 255, 255, 0.92);
241
+ border: 1px solid rgba(23, 23, 23, 0.18);
242
+ color: var(--ink);
243
+ }
244
+
245
+ .icon-danger {
246
+ color: var(--error);
247
+ }
248
+
249
+ .field,
250
+ .inspector {
251
+ display: grid;
252
+ gap: 6px;
253
+ }
254
+
255
+ .field-grid {
256
+ display: grid;
257
+ gap: 6px;
258
+ grid-template-columns: repeat(2, minmax(0, 1fr));
259
+ }
260
+
261
+ .field-input {
262
+ width: 100%;
263
+ border: 1px solid rgba(23, 23, 23, 0.18);
264
+ border-radius: 16px;
265
+ padding: 10px 12px;
266
+ background: rgba(255, 255, 255, 0.92);
267
+ color: var(--ink);
268
+ }
269
+
270
+ .field-textarea {
271
+ resize: vertical;
272
+ min-height: 88px;
273
+ }
274
+
275
+ .field-color {
276
+ min-height: 50px;
277
+ padding: 8px;
278
+ }
279
+
280
+ .meta-card {
281
+ display: grid;
282
+ gap: 6px;
283
+ padding: 10px 12px;
284
+ border: 1px solid rgba(23, 23, 23, 0.12);
285
+ border-radius: 18px;
286
+ background: rgba(255, 255, 255, 0.72);
287
+ }
288
+
289
+ .meta-card p {
290
+ display: flex;
291
+ justify-content: space-between;
292
+ gap: 16px;
293
+ margin: 0;
294
+ }
295
+
296
+ .meta-card .meta-note {
297
+ display: block;
298
+ }
299
+
300
+ .meta-card span {
301
+ color: var(--muted);
302
+ }
303
+
304
+ .layer-list {
305
+ display: grid;
306
+ gap: 8px;
307
+ padding: 0;
308
+ margin: 0;
309
+ list-style: none;
310
+ }
311
+
312
+ .layer-row {
313
+ display: grid;
314
+ gap: 8px;
315
+ padding: 10px;
316
+ border: 1px solid rgba(23, 23, 23, 0.12);
317
+ border-radius: 18px;
318
+ background: rgba(255, 255, 255, 0.68);
319
+ }
320
+
321
+ .layer-row-selected {
322
+ border-color: rgba(23, 23, 23, 0.3);
323
+ box-shadow: inset 0 0 0 1px rgba(23, 23, 23, 0.08);
324
+ }
325
+
326
+ .layer-main {
327
+ display: flex;
328
+ justify-content: space-between;
329
+ gap: 10px;
330
+ width: 100%;
331
+ padding: 0;
332
+ text-align: left;
333
+ cursor: pointer;
334
+ }
335
+
336
+ .layer-name {
337
+ font-weight: 700;
338
+ }
339
+
340
+ .layer-kind {
341
+ color: var(--muted);
342
+ text-transform: capitalize;
343
+ }
344
+
345
+ .error-text {
346
+ margin: 0;
347
+ color: var(--error);
348
+ }
349
+
350
+ .workspace {
351
+ display: grid;
352
+ grid-template-columns: minmax(0, 1fr);
353
+ align-self: start;
354
+ position: sticky;
355
+ top: 14px;
356
+ }
357
+
358
+ .editor-panel {
359
+ display: grid;
360
+ height: calc(100vh - 28px);
361
+ padding: 8px;
362
+ }
363
+
364
+ .editor-viewport {
365
+ display: grid;
366
+ place-items: center;
367
+ position: relative;
368
+ overflow: hidden;
369
+ min-height: 0;
370
+ height: 100%;
371
+ padding: 10px;
372
+ border-radius: 20px;
373
+ background:
374
+ radial-gradient(circle at center, rgba(0, 0, 0, 0.035), transparent 56%),
375
+ rgba(255, 255, 255, 0.82);
376
+ outline: none;
377
+ }
378
+
379
+ .editor-stage-frame {
380
+ position: relative;
381
+ overflow: hidden;
382
+ border-radius: 20px;
383
+ box-shadow: 0 14px 28px rgba(0, 0, 0, 0.08);
384
+ }
385
+
386
+ .editor-stage-surface {
387
+ position: absolute;
388
+ inset: 0 auto auto 0;
389
+ overflow: hidden;
390
+ transform-origin: top left;
391
+ background-color: #f7f7f7;
392
+ background-image:
393
+ linear-gradient(45deg, #e6e6e6 25%, transparent 25%),
394
+ linear-gradient(-45deg, #e6e6e6 25%, transparent 25%),
395
+ linear-gradient(45deg, transparent 75%, #e6e6e6 75%),
396
+ linear-gradient(-45deg, transparent 75%, #e6e6e6 75%);
397
+ background-position:
398
+ 0 0,
399
+ 0 10px,
400
+ 10px -10px,
401
+ -10px 0;
402
+ background-size: 20px 20px;
403
+ }
404
+
405
+ .stage-media,
406
+ .stage-layer {
407
+ position: absolute;
408
+ }
409
+
410
+ .stage-media {
411
+ inset: 0;
412
+ width: 100%;
413
+ height: 100%;
414
+ object-fit: contain;
415
+ user-select: none;
416
+ pointer-events: none;
417
+ }
418
+
419
+ .stage-layer {
420
+ cursor: grab;
421
+ user-select: none;
422
+ touch-action: none;
423
+ overflow: visible;
424
+ }
425
+
426
+ .stage-layer:active {
427
+ cursor: grabbing;
428
+ }
429
+
430
+ .stage-layer-shell {
431
+ display: inline-block;
432
+ }
433
+
434
+ .stage-layer-content {
435
+ display: block;
436
+ width: 100%;
437
+ height: 100%;
438
+ }
439
+
440
+ .stage-layer-selected {
441
+ outline: 2px solid rgba(23, 23, 23, 0.7);
442
+ outline-offset: 2px;
443
+ }
444
+
445
+ .stage-selection-overlay {
446
+ position: absolute;
447
+ pointer-events: none;
448
+ z-index: 3;
449
+ overflow: visible;
450
+ }
451
+
452
+ .stage-selection-outline {
453
+ width: 100%;
454
+ height: 100%;
455
+ border: 2px solid rgba(23, 23, 23, 0.72);
456
+ }
457
+
458
+ .stage-layer-text {
459
+ display: inline-block;
460
+ min-width: 28px;
461
+ width: max-content;
462
+ max-width: none;
463
+ white-space: pre;
464
+ line-height: 1.08;
465
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.22);
466
+ }
467
+
468
+ .stage-layer-image {
469
+ width: 100%;
470
+ height: 100%;
471
+ object-fit: contain;
472
+ }
473
+
474
+ .layer-handle {
475
+ position: absolute;
476
+ width: 24px;
477
+ height: 24px;
478
+ display: grid;
479
+ place-items: center;
480
+ padding: 0;
481
+ border: 1px solid rgba(23, 23, 23, 0.18);
482
+ border-radius: 999px;
483
+ background: rgba(255, 255, 255, 0.94);
484
+ box-shadow: 0 6px 14px rgba(0, 0, 0, 0.12);
485
+ font-size: 0.9rem;
486
+ line-height: 1;
487
+ cursor: pointer;
488
+ z-index: 2;
489
+ pointer-events: auto;
490
+ }
491
+
492
+ .layer-handle-rotate {
493
+ top: -14px;
494
+ right: -14px;
495
+ cursor: crosshair;
496
+ }
497
+
498
+ .layer-handle-scale {
499
+ right: -14px;
500
+ bottom: -14px;
501
+ cursor: nwse-resize;
502
+ }
503
+
504
+ .editor-empty {
505
+ display: grid;
506
+ gap: 6px;
507
+ max-width: 14rem;
508
+ text-align: center;
509
+ }
510
+
511
+ .editor-empty p {
512
+ margin: 0;
513
+ font-weight: 700;
514
+ }
515
+
516
+ @media (max-width: 1180px) {
517
+ .shell {
518
+ grid-template-columns: 1fr;
519
+ }
520
+
521
+ .workspace {
522
+ position: static;
523
+ }
524
+
525
+ .sidebar,
526
+ .editor-panel {
527
+ min-height: auto;
528
+ height: auto;
529
+ }
530
+ }
531
+
532
+ @media (max-width: 720px) {
533
+ .shell {
534
+ padding: 10px;
535
+ gap: 10px;
536
+ }
537
+
538
+ .field-grid,
539
+ .status-grid {
540
+ grid-template-columns: 1fr;
541
+ }
542
+
543
+ .editor-viewport {
544
+ min-height: 360px;
545
+ padding: 8px;
546
+ }
547
+ }
tsconfig.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "useDefineForClassFields": true,
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler",
7
+ "strict": true,
8
+ "jsx": "preserve",
9
+ "resolveJsonModule": true,
10
+ "isolatedModules": true,
11
+ "esModuleInterop": true,
12
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
13
+ "types": ["vite/client"],
14
+ "noEmit": true
15
+ },
16
+ "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts"]
17
+ }
vite.config.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import vue from "@vitejs/plugin-vue";
2
+ import { defineConfig } from "vite";
3
+
4
+ export default defineConfig({
5
+ plugins: [vue()],
6
+ });