Create standalone browser port of LTX Athena with authenticated native API transport

This commit is contained in:
Mikei386
2026-09-29 17:59:23 +02:00
commit 1333b0c36b
202 changed files with 63054 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.git
node_modules
dist
data
secrets
.env
+11
View File
@@ -0,0 +1,11 @@
# Copy to .env. These are configuration examples, not credentials.
LTX_BACKEND_URL=http://host.docker.internal:8120
WEB_BIND_IP=127.0.0.1
WEB_PORT=8118
PUBLIC_ORIGIN=http://127.0.0.1:8118
WEB_UID=1000
WEB_GID=1000
LTX_INPUT_HOST_DIR=/data/video/ltx-desktop/LTXDesktop/remote-inputs
LTX_OUTPUT_HOST_DIR=/data/video/ltx-desktop/LTXDesktop/outputs
BACKEND_INPUT_DIR=/data/LTXDesktop/remote-inputs
BACKEND_OUTPUT_DIR=/data/LTXDesktop/outputs
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
.env
secrets/
data/
*.tsbuildinfo
.DS_Store
+12
View File
@@ -0,0 +1,12 @@
# LTX DeskWEB
Independent browser frontend fork of LTX Athena / Lightricks LTX Desktop.
Do not add CUDA, PyTorch, model weights or process/container lifecycle control.
The existing LTX API is forwarded unchanged; no generic video adapter.
Read docs/ARCHITECTURE.md before modifying the platform boundary.
Keep attribution in LICENSE.txt, NOTICES.md and docs/PROVENANCE.md.
All LTX HTTP calls use frontend/lib/backend.ts; web-service calls use webRequest.
Credentials stay on the server. Never log prompts, request bodies, media or tokens.
Never modify the original LTX-Athena source or Athena Deck as part of this repository.
Tests: npm run typecheck; npm test; npm run build.
Use synthetic media and a test backend for integration tests. Do not start production
models or change the Athena GPU mode without user authorization for that operation.
+20
View File
@@ -0,0 +1,20 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts --no-audit --no-fund
COPY frontend ./frontend
COPY shared ./shared
COPY public ./public
COPY index.html vite.config.ts tsconfig.json tailwind.config.js postcss.config.js ./
RUN npm run typecheck && npm run build
FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY server ./server
COPY LICENSE.txt NOTICES.md ./
ENV HOST=0.0.0.0 PORT=8118
USER node
EXPOSE 8118
CMD ["node", "server/index.mjs"]
+199
View File
@@ -0,0 +1,199 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2024 Lightricks
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
---
Third-Party Software Notices
This application uses open-source software and AI models subject to
their own license terms. See NOTICES.md for the complete list
of third-party components, their copyright holders, and license texts.
+813
View File
@@ -0,0 +1,813 @@
# Third-Party Notices
This file contains the licenses and notices for third-party software and models
used by LTX Desktop.
---
## Models
- **LTX-2**
Copyright (c) Lightricks Ltd.
License: LTX-2 Community License
https://github.com/Lightricks/LTX-2/blob/main/LICENSE
- **Gemma 3**
Copyright (c) Google LLC.
License: Gemma Terms of Use
https://ai.google.dev/gemma/terms
- **Z-Image-Turbo**
Copyright (c) Alibaba Tongyi Lab.
License: Apache License 2.0
https://huggingface.co/Tongyi-MAI/Z-Image-Turbo
- **DPT-Hybrid (MiDaS 3.0)**
Copyright (c) Intel Corporation.
License: Apache License 2.0
https://huggingface.co/Intel/dpt-hybrid-midas
- **DW Pose TorchScript (BatchSize5)**
Copyright (c) DWPose contributors.
License: Apache License 2.0
https://huggingface.co/hr16/DWPose-TorchScript-BatchSize5
- **YOLOX Person Detector (TorchScript)**
Copyright (c) YOLOX contributors.
License: Apache License 2.0
https://huggingface.co/hr16/yolox-onnx
---
## Python Dependencies
- **pillow** — Copyright (c) Jeffrey A. Clark and Pillow contributors — HPND License
- **torch** — Copyright (c) Meta Platforms, Inc. — BSD 3-Clause License
- **huggingface-hub** — Copyright (c) Hugging Face — Apache License 2.0
- **tqdm** — Copyright (c) Casper da Costa-Luis — Mozilla Public License 2.0
- **pynvml** — Copyright (c) NVIDIA Corporation — BSD 3-Clause License
- **pydantic** — Copyright (c) Samuel Colvin and Pydantic Services Inc. — MIT License
- **ltx-core** — Copyright (c) Lightricks — Apache License 2.0
- **ltx-pipelines** — Copyright (c) Lightricks — Apache License 2.0
- **diffusers** — Copyright (c) Hugging Face — Apache License 2.0
- **ftfy** — Copyright (c) Robyn Speer and Luminoso Technologies — Apache License 2.0
- **imageio** — Copyright (c) imageio contributors — BSD 2-Clause License
- **imageio-ffmpeg** — Copyright (c) imageio contributors — BSD 2-Clause License
- **peft** — Copyright (c) Hugging Face — Apache License 2.0
- **protobuf** — Copyright (c) Google LLC — BSD 3-Clause License
- **transformers** — Copyright (c) Hugging Face — Apache License 2.0
- **sentencepiece** — Copyright (c) Google LLC — Apache License 2.0
- **sageattention** — Copyright (c) Jintao Zhang et al. — Apache License 2.0
- **natten** — Copyright (c) Ali Hassani, Steven Walton, et al. (SHI Labs) — Apache License 2.0
- **opencv-python-headless** — Copyright (c) OpenCV team — Apache License 2.0
- **fastapi** — Copyright (c) Sebastian Ramirez — MIT License
- **uvicorn** — Copyright (c) Encode OSS Ltd. — BSD 3-Clause License
- **python-multipart** — Copyright (c) Andrew Dunham — Apache License 2.0
- **triton-windows** — Copyright (c) OpenAI — MIT License (Windows only)
---
## Node.js Dependencies
- **react** — Copyright (c) Meta Platforms, Inc. — MIT License
- **react-dom** — Copyright (c) Meta Platforms, Inc. — MIT License
- **electron** — Copyright (c) GitHub Inc. and Electron contributors — MIT License
- **class-variance-authority** — Copyright (c) Joe Bell — Apache License 2.0
- **clsx** — Copyright (c) Luke Edwards — MIT License
- **electron-updater** — Copyright (c) electron-userland — MIT License
- **js-yaml** — Copyright (c) Vitaly Puzrin — MIT License
- **lucide-react** — Copyright (c) Lucide contributors — ISC License
- **react-dropzone** — Copyright (c) Param Aggarwal — MIT License
- **tailwind-merge** — Copyright (c) dcastil — MIT License
---
## License Texts
### Apache License 2.0
```
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. Please also get an in-depth
understanding of how to properly apply this license to your work
by reading the FAQ at http://www.apache.org/foundation/license-faq.html
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied. See the License for the specific language governing
permissions and limitations under the License.
```
---
### BSD 3-Clause License
```
BSD 3-Clause License
Copyright (c) <year> <owner>. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
---
### BSD 2-Clause License
```
BSD 2-Clause License
Copyright (c) <year> <owner>. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
---
### MIT License
```
MIT License
Copyright (c) <year> <owner>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
---
### Mozilla Public License 2.0
```
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under the name "LEGAL", and included in all Source Code Form
distributions of the Covered Software. Except to the extent prohibited
by statute or regulation, such description must be sufficiently detailed
for a recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
```
---
### ISC License
```
ISC License
Copyright (c) <year> <owner>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
```
---
### HPND License (Historical Permission Notice and Disclaimer)
```
Historical Permission Notice and Disclaimer (HPND)
Copyright (c) <year> <owner>
Permission to use, copy, modify, and distribute this software and its
documentation for any purpose and without fee is hereby granted, provided that
the above copyright notice appear in all copies and that both that copyright
notice and this permission notice appear in supporting documentation, and that
the name of the copyright holder not be used in advertising or publicity
pertaining to distribution of the software without specific, written prior
permission.
THE COPYRIGHT HOLDER DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE,
INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT
SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY SPECIAL, INDIRECT OR
CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE,
DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE
OF THIS SOFTWARE.
```
+70
View File
@@ -0,0 +1,70 @@
# LTX DeskWEB
A standalone browser port of **LTX Athena / LTX Desktop**, for a separately managed
LTX Desktop backend. Independent of the Athena Deck repository.
Includes the existing project overview, Gen Space, media gallery and timeline
editor; browser login, uploads with progress, native API forwarding, authenticated
video playback/download and CPU thumbnails. **No Python inference environment,
CUDA, model downloads on startup, GPU access or Docker socket.**
This is the first web preview. Real Athena generation still needs an integration
check; it is not yet a fully equivalent replacement for every desktop feature.
See [limitations and architecture](docs/ARCHITECTURE.md) and [provenance](docs/PROVENANCE.md).
## Debian / Docker deployment
Prerequisites: Docker Engine + Compose; a separately configured LTX Desktop backend;
shared input/output directories; a free web port. The web image includes Node and
ffmpeg only. It does not install or restart LTX, Deck, WireGuard or a GPU driver.
1. Clone `ssh://git@192.168.1.2:33/michael/LTX-DeskWEB.git`.
2. Copy `.env.example` to `.env` and set the backend address and shared directories.
For Deck, use its common API port and enable **Video mode in Deck** yourself.
The address must be reachable from the GUI container. `host.docker.internal`
reaches the Docker host, but not a service bound only to host loopback. Do not
change production bindings blindly; use an existing reachable Deck address.
3. Create `secrets/web-password` with your chosen password (at least 16 characters)
and `secrets/ltx-token` with the Deck API token (or direct LTX token). Do not commit
these files. Set file permissions so only the selected WEB_UID can read them.
For a backend without authentication, the token file can be empty.
4. Ensure WEB_UID/WEB_GID can write the input directory and read the output
directory. Do not recursively change ownership of existing LTX data.
5. Set PUBLIC_ORIGIN to the browser URL, e.g. `http://127.0.0.1:8118` for an SSH tunnel.
6. Start: `docker compose up -d --build`.
7. Stop: `docker compose down`. Shared media is retained; LTX keeps running.
Default UI port is 8118, bound only to server loopback. Example SSH tunnel from Mac:
```sh
ssh -i /Users/mike_i386/.ssh/athena_key -o BatchMode=yes -N \
-L 8118:127.0.0.1:8118 root@192.168.1.212
```
Then open http://127.0.0.1:8118. The Mac is only a browser/SSH client; deployment
runs on Debian. This command assumes the container has actually been deployed.
The Compose service has `io.athena-deck.managed=true` and
`io.athena-deck.role=application`, matching Deck's existing filtered inventory for
“Weitere Dienste”. Only this frontend is labeled; no other containers are adopted.
The GUI has no authority to start/stop other containers.
## Development and verification
Node 22+, npm, ffmpeg/ffprobe. `npm ci`, then:
```sh
npm run typecheck
npm test
npm run build
```
For the web service set WEB_PASSWORD_FILE, LTX_TOKEN_FILE, LTX_BACKEND_URL,
LOCAL_INPUT_DIR, BACKEND_INPUT_DIR, LOCAL_OUTPUT_DIR, BACKEND_OUTPUT_DIR and
PUBLIC_ORIGIN, then `npm start`. It serves the built application on 127.0.0.1:8118.
`npm run dev` serves Vite on 5188 and proxies HTTP to 8118; set PUBLIC_ORIGIN to
http://127.0.0.1:5188 for that development session.
Tests use temporary files and isolated HTTP servers, not production services.
They check authentication, logout, token replacement, native API forwarding,
validation errors, uploads, range playback, path/symlink restrictions and rate limits.
+43
View File
@@ -0,0 +1,43 @@
services:
ltx-deskweb:
build: .
image: ltx-deskweb:0.1.0
restart: unless-stopped
init: true
user: "${WEB_UID:-1000}:${WEB_GID:-1000}"
ports:
- "${WEB_BIND_IP:-127.0.0.1}:${WEB_PORT:-8118}:8118"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
LTX_BACKEND_URL: "${LTX_BACKEND_URL:-http://host.docker.internal:8120}"
WEB_PASSWORD_FILE: /run/secrets/web_password
LTX_TOKEN_FILE: /run/secrets/ltx_token
PUBLIC_ORIGIN: "${PUBLIC_ORIGIN:-http://127.0.0.1:8118}"
LOCAL_INPUT_DIR: /data/inputs
BACKEND_INPUT_DIR: "${BACKEND_INPUT_DIR:-/data/LTXDesktop/remote-inputs}"
LOCAL_OUTPUT_DIR: /data/outputs
BACKEND_OUTPUT_DIR: "${BACKEND_OUTPUT_DIR:-/data/LTXDesktop/outputs}"
volumes:
- "${LTX_INPUT_HOST_DIR:?Set shared LTX input directory}:/data/inputs"
- "${LTX_OUTPUT_HOST_DIR:?Set LTX output directory}:/data/outputs:ro"
secrets:
- web_password
- ltx_token
read_only: true
tmpfs:
- /tmp:size=128m,mode=1777
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
labels:
io.athena-deck.managed: "true"
io.athena-deck.role: application
org.ltx-deskweb.role: frontend
org.ltx-deskweb.backend: ltx-desktop
secrets:
web_password:
file: ./secrets/web-password
ltx_token:
file: ./secrets/ltx-token
+55
View File
@@ -0,0 +1,55 @@
# Architecture
Browser (React, inherited Gen Space and timeline)
→ Node web service (session authentication, file I/O, streaming HTTP forwarding)
→ Athena Deck shared API port in Video mode
→ original LTX Desktop backend.
The backend address may alternatively point directly to the native LTX API.
The web service forwards `/ltx/api/...` to `/api/...`, preserving method, body,
query, response status and body. No model-specific request translation. A fixed,
operator-configured upstream prevents browser-controlled proxy targets. Tokens
are loaded from files at startup and never sent to the browser. Native streaming
HTTP responses and polling are supported; WebSocket forwarding is not implemented.
`frontend/lib/web-platform.ts` implements the inherited typed `electronAPI` boundary
in the browser. It is an OS/file integration replacement, not a model API adapter.
The application shell no longer calls Python installers, model setup, first-run
license acceptance, Electron updates or backend process start/stop. Unsupported
functions throw explicit errors; timeline rendering is visibly disabled.
Media: browsers cannot send server paths without first uploading. File dialogs
upload immediately; drag-and-drop uses temporary blob URLs until a generation or
asset import resolves the file. Server stores UUID filenames in a shared input
folder; its backend-visible path is returned. The web service and LTX must see the
SAME files (Docker bind mounts can expose different absolute paths). Existing LTX
outputs are mounted read-only. Only configured input/output roots are readable;
traversal and symlinks escaping those roots are rejected. ffmpeg/ffprobe create
thumbnails, dimensions and extracted frames on CPU; they are not inference runtimes.
Network protocols are disabled for media inspection. HTTP Range supports seeking.
Projects: initial preview retains the upstream browser-local project storage.
Projects are specific to this browser/origin, not multiuser or cross-device synced.
JSON backup and restore are provided. Media is persistent in the mounted input directory.
Deleting a project removes its index/metadata, not shared LTX files.
Security: single shared login, >=16-character operator-provided password, in-memory
12-hour sessions, HttpOnly SameSite=Strict cookie, Secure for configured HTTPS,
login throttling, same-origin requests, fixed upstream, no Docker socket or GPU
access. PUBLIC_ORIGIN must match the user's actual browser address. For remote
access use HTTPS or an SSH tunnel; no unencrypted public password login.
The existing native LTX API has operator-level functionality; authenticated web
users are trusted operators, not isolated tenants. The input directory must be
writable only by trusted service accounts. This is a preview, not a public SaaS.
## Current limitations
- No native timeline video rendering; FCPXML exports metadata only and references
backend media paths. A portable XML/media package needs a later export stage.
- No server-side project library, cross-browser synchronization or multiuser roles.
- No desktop Hugging Face OAuth, server-folder chooser or Electron updater.
- Exact feature support depends on the selected LTX backend/model. Browser porting
does not add Retake/Extend support to a model that lacks it.
- Another container stack needs access to the same media storage; an API URL alone
does not transport backend filesystem files. No SSH/SCP credentials are embedded.
- First release validated with a synthetic backend, not a real GPU generation.
+18
View File
@@ -0,0 +1,18 @@
# Source provenance
Initial import: local LTX-Athena working tree at commit
`68cd86c15e5fd25f56229ea63c0dbcb0338f7812` (version 1.2.7), 2026-09-29.
Source directory: LTX-Athena, sibling of this repository.
Upstream: https://github.com/Lightricks/LTX-Desktop (Apache-2.0).
The import includes the user's current frontend changes in use-generation,
use-extend, use-retake, GenSpace, generation-input and the shared IPC schema.
Only frontend, shared schemas, public assets, build configuration and license
notices were copied. No backend, Electron main process, credentials, application
state, backups, downloaded weights, or generated user media were imported.
The source working tree was not changed.
Modifications in this fork: browser platform implementation, web application shell,
authenticated HTTP transport, shared-directory media operations, Docker packaging,
web-specific labels and explicit unsupported desktop functions. This is an
independent derivative, not an official Lightricks distribution.
+23
View File
@@ -0,0 +1,23 @@
# Validation — initial web preview
2026-09-29:
- `npm run typecheck`: passed.
- `npm run build`: passed. Vite reports a large inherited editor bundle; splitting
the editor into a lazy-loaded chunk is a future performance improvement.
- `npm test`: 8 tests passed (7 HTTP/media integrations + 1 error-presentation test).
- Browser smoke test against `scripts/ui-fixture.mjs`, which never contacts Athena:
sign-in; new project; reload and reopen saved project; Gen Space renders backend
model options; synthetic native API failure shows a recoverable error dialog;
timeline editor opens; synthetic 1-second clip upload/import returns dimensions
and thumbnail in the asset library.
- Source LTX-Athena working-tree changes left untouched.
Not verified yet: Docker image build/run on Debian, real LTX generation through
Athena Deck's selected Video endpoint, remote file permissions/mount paths,
long-running job recovery/cancellation, all editor interactions and all LTX modes.
No Athena service was started, stopped or reconfigured during this port.
The synthetic fixture uses a fixed public test password and isolated temporary
media storage on loopback port 18118. Never deploy that fixture as the real service.
Production start (`server/index.mjs`) requires an operator-provided password file.
+65
View File
@@ -0,0 +1,65 @@
import { WebProjectBackup } from './components/WebProjectBackup'
import { useEffect, useState } from 'react'
import { ProjectProvider } from './contexts/ProjectContext'
import { ViewProvider, useView } from './contexts/ViewContext'
import { KeyboardShortcutsProvider } from './contexts/KeyboardShortcutsContext'
import { AppSettingsProvider, useAppSettings } from './contexts/AppSettingsContext'
import { DevFlagsProvider } from './contexts/DevFlagsContext'
import { useGenerationRecoveryWatcher } from './hooks/use-generation-recovery-watcher'
import { Home } from './views/Home'
import { Project } from './views/Project'
import { webRequest } from './lib/web-platform'
import { useBackend } from './hooks/use-backend'
import { DevPanel } from './components/DevPanel'
import { KeyboardShortcutsModal } from './components/KeyboardShortcutsModal'
function Workspace() {
const { currentView, goHome } = useView()
const { connected, isLoading } = useBackend()
const { isLoaded, runtimePolicyLoaded } = useAppSettings()
useGenerationRecoveryWatcher()
const ready = connected && isLoaded && runtimePolicyLoaded
return <>
<div className={`px-4 py-2 text-sm ${ready ? 'bg-emerald-950 text-emerald-200' : 'bg-amber-950 text-amber-200'}`} role="status">
{ready ? 'LTX backend connected · generation runs on the remote server' : isLoading ? 'Checking LTX connection…' : 'LTX is not ready. Activate Video mode in Athena Deck. The connection is checked automatically.'}
</div>
{currentView === 'home' ? <Home /> : ready ? <Project /> : <div className="p-12"><p>Projects are retained. Generation becomes available when the LTX backend and its settings are reachable.</p><button className="mt-4 underline" onClick={goHome}>Back to projects</button></div>}
<KeyboardShortcutsModal /><DevPanel />
</>
}
export default function App() {
const [session, setSession] = useState<{ backendUrl: string } | null>(null)
const [checking, setChecking] = useState(true)
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [message, setMessage] = useState('')
const [help, setHelp] = useState(false)
useEffect(() => { void webRequest<{ backendUrl: string }>('/web/session').then(setSession).catch(() => {}).finally(() => setChecking(false)) }, [])
useEffect(() => { const listener = (event: Event) => setMessage((event as CustomEvent<string>).detail); window.addEventListener('web-status', listener); return () => window.removeEventListener('web-status', listener) }, [])
useEffect(() => { const listener = () => setHelp(true); window.addEventListener('open-settings', listener); return () => window.removeEventListener('open-settings', listener) }, [])
if (checking) return <div className="p-12">Loading LTX DeskWEB…</div>
if (!session) return <main className="min-h-screen flex items-center justify-center bg-zinc-950 text-white">
<form className="w-full max-w-md p-8 border border-zinc-700 rounded-xl" onSubmit={async event => {
event.preventDefault(); setError('')
try { await webRequest('/web/login', { password }); setPassword(''); setSession(await webRequest('/web/session')) } catch (e) { setError(e instanceof Error ? e.message : String(e)) }
}}>
<h1 className="text-2xl font-bold">LTX DeskWEB</h1><p className="my-4 text-zinc-400">Browser workspace for your LTX backend.</p>
<label htmlFor="password">Access password</label><input id="password" type="password" autoComplete="current-password" required value={password} onChange={e => setPassword(e.target.value)} className="mt-2 mb-4 w-full p-3 bg-zinc-800 rounded" />
<button className="bg-blue-600 rounded px-4 py-2" type="submit">Sign in</button><p role="alert" className="mt-4 text-red-300">{error}</p>
</form>
</main>
return <>
<header className="flex justify-between items-center px-4 py-2 bg-zinc-900 border-b border-zinc-700 text-sm">
<strong>LTX DeskWEB · Preview</strong><div className="flex gap-4"><button onClick={() => setHelp(!help)}>Connection & information</button><button onClick={async () => { await webRequest('/web/logout', {}); location.reload() }}>Sign out</button></div>
</header>
{help && <aside className="p-5 bg-zinc-900 border-b border-zinc-700 space-y-2">
<p>Native LTX API via: <code>{session.backendUrl}</code>. Change the address and token in the container configuration.</p>
<p>Video mode is managed in Athena Deck. This web app never starts or installs a model.</p>
<p>Preview: projects are saved in this browser. Use “Back up projects” before clearing browser data. Timeline rendering, desktop folder dialogs and Hugging Face desktop login are not yet ported.</p>
<WebProjectBackup />
<button className="ml-4 underline" onClick={() => setHelp(false)}>Close</button>
</aside>}
{message && <div className="p-3 bg-blue-950 flex justify-between" role="status">{message}<button onClick={() => setMessage('')}>Dismiss</button></div>}
<AppSettingsProvider><ProjectProvider><ViewProvider><KeyboardShortcutsProvider><DevFlagsProvider><Workspace /></DevFlagsProvider></KeyboardShortcutsProvider></ViewProvider></ProjectProvider></AppSettingsProvider>
</>
}
+221
View File
@@ -0,0 +1,221 @@
import { useEffect, useMemo, useState } from 'react'
import { KeyRound, X, Zap } from 'lucide-react'
import { ApiKeyHelperRow, LtxApiKeyInput } from './LtxApiKeyInput'
export type ApiKeyType = 'ltx' | 'fal'
export interface ApiGatewaySection {
keyType: ApiKeyType
title: string
description: string
required: boolean
isConfigured: boolean
inputLabel: string
placeholder?: string
onSave: (apiKey: string) => Promise<void> | void
onGetKey?: () => void
getKeyLabel?: string
}
export interface ApiGatewayModalProps {
isOpen: boolean
onClose: () => void
title: string
description: string
sections: ApiGatewaySection[]
blocking?: boolean
}
const KEY_TYPE_META: Record<ApiKeyType, { icon: typeof Zap; iconClass: string; chipClass: string }> = {
ltx: {
icon: Zap,
iconClass: 'text-blue-400',
chipClass: 'bg-amber-500/10 text-amber-300',
},
fal: {
icon: KeyRound,
iconClass: 'text-cyan-400',
chipClass: 'bg-zinc-800 text-zinc-400',
},
}
export function ApiGatewayModal({
isOpen,
onClose,
title,
description,
sections,
blocking = false,
}: ApiGatewayModalProps) {
const [values, setValues] = useState<Record<ApiKeyType, string>>({ ltx: '', fal: '' })
const [isSaving, setIsSaving] = useState<Record<ApiKeyType, boolean>>({ ltx: false, fal: false })
const [errors, setErrors] = useState<Record<ApiKeyType, string | null>>({ ltx: null, fal: null })
useEffect(() => {
if (!isOpen) return
setValues({ ltx: '', fal: '' })
setIsSaving({ ltx: false, fal: false })
setErrors({ ltx: null, fal: null })
}, [isOpen])
const allRequiredConfigured = useMemo(() => {
const requiredSections = sections.filter((section) => section.required)
if (requiredSections.length === 0) return true
return requiredSections.every((section) => section.isConfigured)
}, [sections])
useEffect(() => {
if (!isOpen) return
if (!allRequiredConfigured) return
if (sections.length <= 1) {
onClose()
}
}, [isOpen, onClose, sections.length, allRequiredConfigured])
useEffect(() => {
if (!isOpen) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !blocking) {
onClose()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [blocking, isOpen, onClose])
const handleSave = async (section: ApiGatewaySection) => {
const keyType = section.keyType
const trimmedKey = (values[keyType] ?? '').trim()
if (!trimmedKey) {
setErrors((prev) => ({ ...prev, [keyType]: `Please enter a valid ${section.inputLabel}.` }))
return
}
setIsSaving((prev) => ({ ...prev, [keyType]: true }))
setErrors((prev) => ({ ...prev, [keyType]: null }))
try {
await section.onSave(trimmedKey)
setValues((prev) => ({ ...prev, [keyType]: '' }))
} catch (err) {
if (err instanceof Error && err.message.trim()) {
setErrors((prev) => ({ ...prev, [keyType]: err.message }))
} else {
setErrors((prev) => ({ ...prev, [keyType]: 'Failed to save API key.' }))
}
} finally {
setIsSaving((prev) => ({ ...prev, [keyType]: false }))
}
}
const requiredMissing = useMemo(() => sections.some((section) => section.required && !section.isConfigured), [sections])
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4">
<div className="w-full max-w-[620px] rounded-xl border border-zinc-700 bg-zinc-900 shadow-2xl overflow-hidden">
<div className="flex items-center justify-between border-b border-zinc-800 px-5 py-4">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-500/15 text-blue-300">
<KeyRound className="h-4 w-4" />
</div>
<h2 className="text-base font-semibold text-zinc-100">{title}</h2>
</div>
{!blocking && (
<button
onClick={onClose}
className="rounded-lg p-1.5 text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-300"
aria-label="Close API gateway modal"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<div className="space-y-5 px-5 py-5">
<p className="text-sm leading-relaxed text-zinc-300">{description}</p>
<div className="space-y-4">
{sections.map((section) => {
const meta = KEY_TYPE_META[section.keyType]
const Icon = meta.icon
const configured = section.isConfigured
const saving = isSaving[section.keyType]
const value = values[section.keyType] ?? ''
const error = errors[section.keyType]
const canSubmit = value.trim().length > 0 && !saving
return (
<div key={section.keyType} className="rounded-lg border border-zinc-800 bg-zinc-900/60 p-4 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2">
<Icon className={`h-4 w-4 ${meta.iconClass}`} />
<h3 className="text-sm font-semibold text-white">{section.title}</h3>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${section.required ? meta.chipClass : 'bg-zinc-800 text-zinc-500'}`}>
{section.required ? 'Required' : 'Optional'}
</span>
</div>
<p className="text-xs text-zinc-500 mt-1">{section.description}</p>
</div>
<div className={`text-xs px-2 py-1 rounded inline-flex items-center gap-1.5 ${configured ? 'bg-green-500/10 text-green-400' : 'bg-zinc-800 text-zinc-500'}`}>
{configured ? 'Configured' : 'Not set'}
</div>
</div>
{!configured && (
<div className="space-y-2">
<label className="block text-xs text-zinc-300">{section.inputLabel}</label>
<div className="flex gap-2">
<LtxApiKeyInput
value={value}
onChange={(event) => setValues((prev) => ({ ...prev, [section.keyType]: event.target.value }))}
placeholder={section.placeholder ?? 'Paste your API key'}
className="flex-1"
/>
<button
onClick={() => handleSave(section)}
disabled={!canSubmit}
className="px-3 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-500 disabled:bg-zinc-700 disabled:text-zinc-500 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
>
{saving ? 'Saving...' : 'Save Key'}
</button>
</div>
<ApiKeyHelperRow
label={section.getKeyLabel ?? 'Get API key'}
onOpenKey={section.onGetKey}
/>
</div>
)}
{error && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{error}
</div>
)}
</div>
)
})}
</div>
{blocking && requiredMissing && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
Required API keys are missing. Add them to continue.
</div>
)}
{sections.length > 1 && allRequiredConfigured && (
<div className="flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-500 transition-colors"
>
Done
</button>
</div>
)}
</div>
</div>
</div>
)
}
+166
View File
@@ -0,0 +1,166 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Check, ChevronLeft, ChevronRight, Copy, X } from 'lucide-react'
import type { Asset } from '../types/project-model'
import { pathToFileUrl } from '../lib/file-url'
import { formatPipelineDisplayName } from '../lib/video-generation-model-specs'
const mediaClassName =
'mx-auto max-h-[calc(100dvh-18vh-7rem)] max-w-full rounded-xl object-contain'
// Duration can be a raw float (e.g. extend output: 12.041667s). Show a clean value:
// integers as-is, otherwise at most 2 decimals with trailing zeros trimmed.
function formatSeconds(seconds: number): string {
return Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(2).replace(/\.?0+$/, '')
}
function formatDurationLabel(asset: Asset): string | null {
if (asset.type === 'image') return 'Image'
// Requested Auto is persisted as generationParams.duration === null. Do not fall through
// to omitting the chip — that made Auto clips look like they had no duration at all.
if (asset.generationParams?.duration === null) return 'Auto duration'
if (asset.duration) return `${formatSeconds(asset.duration)}s`
return null
}
export interface AssetPreviewModalProps {
asset: Asset
/** 0-based index into the current filtered gallery. */
index: number
total: number
canGoPrev: boolean
canGoNext: boolean
onPrev: () => void
onNext: () => void
onClose: () => void
}
export function AssetPreviewModal({
asset,
index,
total,
canGoPrev,
canGoNext,
onPrev,
onNext,
onClose,
}: AssetPreviewModalProps) {
const [copiedPrompt, setCopiedPrompt] = useState(false)
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') { e.preventDefault(); onPrev() }
else if (e.key === 'ArrowRight') { e.preventDefault(); onNext() }
else if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handleKey)
return () => window.removeEventListener('keydown', handleKey)
}, [onPrev, onNext, onClose])
// Reset copy affordance when paging between assets.
useEffect(() => {
setCopiedPrompt(false)
}, [asset.id])
// Image assets also store a placeholder `model` (e.g. "fast" for Z-Image) that is not an
// LTX video pipeline — only label known video pipelines on video assets. Prefer the label
// captured at generation time: the local "fast" pipeline id is shared by every LTX version,
// so mapping it here would report the wrong version.
const modelLabel = asset.type === 'video'
? asset.generationParams?.modelLabel ?? formatPipelineDisplayName(asset.generationParams?.model)
: null
const metaParts = [
modelLabel,
asset.resolution || null,
formatDurationLabel(asset),
].filter(Boolean)
return createPortal(
<div
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/90"
onClick={onClose}
>
<button
onClick={(e) => { e.stopPropagation(); onPrev() }}
disabled={!canGoPrev}
className={`absolute left-4 top-1/2 -translate-y-1/2 z-10 p-3 rounded-full backdrop-blur-md transition-all ${
canGoPrev
? 'bg-white/10 text-white hover:bg-white/20 cursor-pointer'
: 'bg-white/5 text-zinc-600 cursor-default'
}`}
>
<ChevronLeft className="h-6 w-6" />
</button>
<button
onClick={(e) => { e.stopPropagation(); onNext() }}
disabled={!canGoNext}
className={`absolute right-4 top-1/2 -translate-y-1/2 z-10 p-3 rounded-full backdrop-blur-md transition-all ${
canGoNext
? 'bg-white/10 text-white hover:bg-white/20 cursor-pointer'
: 'bg-white/5 text-zinc-600 cursor-default'
}`}
>
<ChevronRight className="h-6 w-6" />
</button>
<div
className="relative flex max-h-full w-full max-w-5xl flex-col px-20 py-6"
onClick={e => e.stopPropagation()}
>
<div className="mb-3 flex shrink-0 items-center justify-between">
<span className="text-sm text-zinc-500 font-medium">
{index + 1} / {total}
</span>
<button
onClick={onClose}
className="p-2 rounded-md text-zinc-400 hover:text-white transition-colors"
>
<X className="h-6 w-6" />
</button>
</div>
{asset.type === 'video' ? (
<video
key={asset.id}
src={pathToFileUrl(asset.path)}
controls
autoPlay
className={mediaClassName}
/>
) : (
<img
key={asset.id}
src={pathToFileUrl(asset.path)}
alt=""
className={mediaClassName}
/>
)}
<div className="mt-3 shrink-0 text-center">
<div className="inline-flex max-w-full items-start gap-2">
<p className="max-h-[18vh] overflow-y-auto whitespace-pre-wrap break-words text-left text-zinc-300">{asset.prompt}</p>
{asset.prompt && (
<button
onClick={() => {
void navigator.clipboard.writeText(asset.prompt)
setCopiedPrompt(true)
setTimeout(() => setCopiedPrompt(false), 2000)
}}
className="shrink-0 p-1 rounded hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
title="Copy prompt"
>
{copiedPrompt ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
</button>
)}
</div>
{metaParts.length > 0 && (
<p className="mt-1 text-sm text-zinc-500">
{metaParts.join(' • ')}
</p>
)}
</div>
</div>
</div>,
document.body,
)
}
+386
View File
@@ -0,0 +1,386 @@
import { useRef, useEffect, useState, useCallback } from 'react'
import { Music } from 'lucide-react'
import { logger } from '../lib/logger'
interface AudioClipInfo {
url: string
name: string
startTime: number
duration: number
}
interface AudioWaveformProps {
audioClips: AudioClipInfo[]
currentTime: number
isPlaying: boolean
}
// Global waveform cache: URL → Float32Array of peak amplitudes (one per pixel-bucket)
export const waveformCache = new Map<string, Float32Array>()
const pendingDecodes = new Set<string>()
// Convert a base64 string to an ArrayBuffer
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binaryString = atob(base64)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return bytes.buffer
}
// Decode audio file and extract amplitude envelope
export async function computeWaveform(url: string, buckets: number = 800): Promise<Float32Array> {
if (waveformCache.has(url)) return waveformCache.get(url)!
if (pendingDecodes.has(url)) {
while (pendingDecodes.has(url)) {
await new Promise(r => setTimeout(r, 50))
}
if (waveformCache.has(url)) return waveformCache.get(url)!
}
pendingDecodes.add(url)
try {
let arrayBuffer: ArrayBuffer
if (url.startsWith('file://') && (window as any).electronAPI?.readLocalFile) {
const { data } = await (window as any).electronAPI.readLocalFile({ filePath: url })
arrayBuffer = base64ToArrayBuffer(data)
} else {
const response = await fetch(url)
arrayBuffer = await response.arrayBuffer()
}
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)()
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer)
audioCtx.close()
const channelData = audioBuffer.getChannelData(0)
const samplesPerBucket = Math.floor(channelData.length / buckets)
const peaks = new Float32Array(buckets)
for (let i = 0; i < buckets; i++) {
let max = 0
const start = i * samplesPerBucket
const end = Math.min(start + samplesPerBucket, channelData.length)
for (let j = start; j < end; j++) {
const abs = Math.abs(channelData[j])
if (abs > max) max = abs
}
peaks[i] = max
}
waveformCache.set(url, peaks)
return peaks
} finally {
pendingDecodes.delete(url)
}
}
export function AudioWaveform({ audioClips, currentTime, isPlaying }: AudioWaveformProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [waveforms, setWaveforms] = useState<Map<string, Float32Array>>(new Map())
const animRef = useRef<number>(0)
// Load waveform data for all clips
useEffect(() => {
let cancelled = false
const loadAll = async () => {
const newMap = new Map<string, Float32Array>()
for (const clip of audioClips) {
if (!clip.url) continue
try {
const peaks = await computeWaveform(clip.url)
if (cancelled) return
newMap.set(clip.url, peaks)
} catch (e) {
logger.warn(`Failed to decode audio waveform: ${clip.url} ${e}`)
}
}
if (!cancelled) setWaveforms(newMap)
}
loadAll()
return () => { cancelled = true }
}, [audioClips.map(c => c.url).join(',')])
// Draw waveform on canvas
const draw = useCallback(() => {
const canvas = canvasRef.current
const container = containerRef.current
if (!canvas || !container) return
const dpr = window.devicePixelRatio || 1
const rect = container.getBoundingClientRect()
const w = rect.width
const h = rect.height
canvas.width = w * dpr
canvas.height = h * dpr
canvas.style.width = `${w}px`
canvas.style.height = `${h}px`
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.scale(dpr, dpr)
// Background
ctx.fillStyle = '#0a0a0a'
ctx.fillRect(0, 0, w, h)
// Grid lines (subtle)
ctx.strokeStyle = 'rgba(255,255,255,0.04)'
ctx.lineWidth = 1
const centerY = h / 2
// Horizontal center line
ctx.beginPath()
ctx.moveTo(0, centerY)
ctx.lineTo(w, centerY)
ctx.stroke()
// Quarter lines
for (const frac of [0.25, 0.75]) {
ctx.beginPath()
ctx.moveTo(0, h * frac)
ctx.lineTo(w, h * frac)
ctx.stroke()
}
if (audioClips.length === 0) return
// For simplicity, render the first (or longest) audio clip's waveform
// filling the entire monitor width. If multiple clips, overlay them.
const maxAmplitude = h * 0.4 // 40% of height above and below center
for (let ci = 0; ci < audioClips.length; ci++) {
const clip = audioClips[ci]
const peaks = waveforms.get(clip.url)
if (!peaks || peaks.length === 0) continue
// Map clip's time range to screen
const clipProgress = (currentTime - clip.startTime) / clip.duration
// Color: emerald with some alpha for overlapping
const alpha = audioClips.length > 1 ? 0.6 : 0.9
const gradient = ctx.createLinearGradient(0, centerY - maxAmplitude, 0, centerY + maxAmplitude)
gradient.addColorStop(0, `rgba(52, 211, 153, ${alpha})`) // emerald-400
gradient.addColorStop(0.5, `rgba(16, 185, 129, ${alpha})`) // emerald-500
gradient.addColorStop(1, `rgba(52, 211, 153, ${alpha})`)
// Draw filled waveform (mirrored around center)
ctx.fillStyle = gradient
ctx.beginPath()
// Top half (positive)
for (let i = 0; i < w; i++) {
const peakIdx = Math.floor((i / w) * peaks.length)
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
const y = centerY - amp * maxAmplitude
if (i === 0) ctx.moveTo(i, y)
else ctx.lineTo(i, y)
}
// Bottom half (negative, traced backwards)
for (let i = w - 1; i >= 0; i--) {
const peakIdx = Math.floor((i / w) * peaks.length)
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
const y = centerY + amp * maxAmplitude
ctx.lineTo(i, y)
}
ctx.closePath()
ctx.fill()
// Played region: brighter overlay
if (clipProgress > 0 && clipProgress <= 1) {
const playedX = clipProgress * w
ctx.save()
ctx.beginPath()
ctx.rect(0, 0, playedX, h)
ctx.clip()
const brightGradient = ctx.createLinearGradient(0, centerY - maxAmplitude, 0, centerY + maxAmplitude)
brightGradient.addColorStop(0, 'rgba(52, 211, 153, 0.3)')
brightGradient.addColorStop(0.5, 'rgba(16, 185, 129, 0.3)')
brightGradient.addColorStop(1, 'rgba(52, 211, 153, 0.3)')
ctx.fillStyle = brightGradient
ctx.beginPath()
for (let i = 0; i < w; i++) {
const peakIdx = Math.floor((i / w) * peaks.length)
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
const y = centerY - amp * maxAmplitude
if (i === 0) ctx.moveTo(i, y)
else ctx.lineTo(i, y)
}
for (let i = w - 1; i >= 0; i--) {
const peakIdx = Math.floor((i / w) * peaks.length)
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
const y = centerY + amp * maxAmplitude
ctx.lineTo(i, y)
}
ctx.closePath()
ctx.fill()
ctx.restore()
}
// Playhead line
if (clipProgress >= 0 && clipProgress <= 1) {
const px = clipProgress * w
ctx.strokeStyle = '#ffffff'
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.moveTo(px, 4)
ctx.lineTo(px, h - 4)
ctx.stroke()
// Small triangle at top
ctx.fillStyle = '#ffffff'
ctx.beginPath()
ctx.moveTo(px, 2)
ctx.lineTo(px - 4, 8)
ctx.lineTo(px + 4, 8)
ctx.closePath()
ctx.fill()
}
}
// If no waveform data loaded yet, show loading indicator
if (waveforms.size === 0) {
ctx.fillStyle = 'rgba(255,255,255,0.3)'
ctx.font = '12px system-ui, sans-serif'
ctx.textAlign = 'center'
ctx.fillText('Loading waveform...', w / 2, centerY)
}
}, [audioClips, currentTime, waveforms])
// Animate during playback
useEffect(() => {
if (isPlaying) {
const animate = () => {
draw()
animRef.current = requestAnimationFrame(animate)
}
animRef.current = requestAnimationFrame(animate)
return () => cancelAnimationFrame(animRef.current)
} else {
draw()
}
}, [isPlaying, draw])
// Redraw on resize
useEffect(() => {
const observer = new ResizeObserver(() => draw())
if (containerRef.current) observer.observe(containerRef.current)
return () => observer.disconnect()
}, [draw])
return (
<div ref={containerRef} className="w-full h-full flex flex-col">
{/* Canvas fills available space */}
<div className="flex-1 relative min-h-0">
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full"
/>
{/* Small music icon badge */}
<div className="absolute top-3 left-3 flex items-center gap-2 px-2 py-1 rounded bg-black/60">
<Music className="h-3 w-3 text-emerald-400" />
<span className="text-[10px] text-emerald-400 font-medium">Audio</span>
</div>
</div>
{/* Clip names */}
{audioClips.length > 0 && (
<div className="flex-shrink-0 px-3 py-1.5 bg-zinc-950 border-t border-zinc-800">
{audioClips.map((clip, i) => (
<p key={i} className="text-[10px] text-zinc-500 truncate">
{clip.name}
</p>
))}
</div>
)}
</div>
)
}
// --- Compact inline waveform for timeline audio clips ---
interface ClipWaveformProps {
url: string
className?: string
color?: string
}
export function ClipWaveform({ url, className = '', color = 'rgba(52, 211, 153, 0.7)' }: ClipWaveformProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [peaks, setPeaks] = useState<Float32Array | null>(null)
useEffect(() => {
if (!url) return
let cancelled = false
computeWaveform(url, 200).then(p => {
if (!cancelled) setPeaks(p)
}).catch(() => {})
return () => { cancelled = true }
}, [url])
const draw = useCallback(() => {
const canvas = canvasRef.current
const container = containerRef.current
if (!canvas || !container || !peaks) return
const dpr = window.devicePixelRatio || 1
const rect = container.getBoundingClientRect()
const w = rect.width
const h = rect.height
if (w === 0 || h === 0) return
canvas.width = w * dpr
canvas.height = h * dpr
canvas.style.width = `${w}px`
canvas.style.height = `${h}px`
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, w, h)
const centerY = h / 2
const maxAmp = h * 0.45
ctx.fillStyle = color
ctx.beginPath()
for (let i = 0; i < w; i++) {
const peakIdx = Math.floor((i / w) * peaks.length)
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
const y = centerY - amp * maxAmp
if (i === 0) ctx.moveTo(i, y)
else ctx.lineTo(i, y)
}
for (let i = w - 1; i >= 0; i--) {
const peakIdx = Math.floor((i / w) * peaks.length)
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
const y = centerY + amp * maxAmp
ctx.lineTo(i, y)
}
ctx.closePath()
ctx.fill()
}, [peaks, color])
useEffect(() => {
draw()
}, [draw])
useEffect(() => {
const observer = new ResizeObserver(() => draw())
if (containerRef.current) observer.observe(containerRef.current)
return () => observer.disconnect()
}, [draw])
return (
<div ref={containerRef} className={`absolute inset-0 ${className}`}>
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
</div>
)
}
+127
View File
@@ -0,0 +1,127 @@
import { useState, useEffect } from 'react'
import { X, Copy, Check } from 'lucide-react'
import { useDevFlags, DEV_FLAGS, type DevFlagKey } from '../contexts/DevFlagsContext'
// Master-detail feature-flag panel (Ctrl/Cmd+Shift+D). List on the left
// (green = enabled, ring = selected), detail + toggle on the right.
export function DevPanel() {
const { flags, setFlag, isPanelOpen, setPanelOpen } = useDevFlags()
const [selectedKey, setSelectedKey] = useState<DevFlagKey>(DEV_FLAGS[0]?.key)
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!isPanelOpen) return
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setPanelOpen(false) }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [isPanelOpen, setPanelOpen])
if (!isPanelOpen) return null
const selected = DEV_FLAGS.find(f => f.key === selectedKey) ?? DEV_FLAGS[0]
const isOn = selected ? flags[selected.key] : false
const copyKey = () => {
if (!selected) return
void navigator.clipboard?.writeText(selected.key).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1200)
})
}
return (
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={() => setPanelOpen(false)}
>
<div
className="flex h-[440px] w-full max-w-3xl flex-col overflow-hidden rounded-xl border border-zinc-700 bg-zinc-900 shadow-2xl"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-zinc-800 px-5 py-3.5">
<div>
<h2 className="text-base font-semibold text-white">Dev Panel</h2>
<p className="text-[11px] text-zinc-500">Local-only feature toggles, saved on this machine.</p>
</div>
<button
onClick={() => setPanelOpen(false)}
className="rounded-md p-1.5 text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-white"
title="Close (Esc)"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body: list + detail */}
<div className="flex min-h-0 flex-1">
{/* List */}
<div className="w-64 shrink-0 overflow-y-auto border-r border-zinc-800 p-2">
{DEV_FLAGS.map(spec => {
const on = flags[spec.key]
const isSelected = spec.key === selectedKey
return (
<button
key={spec.key}
onClick={() => setSelectedKey(spec.key)}
className={`mb-1 w-full rounded-md px-3 py-2 text-left text-sm transition-colors ${
isSelected
? 'text-white ring-1 ring-blue-500/60 ' + (on ? 'bg-emerald-900/40' : 'bg-zinc-800')
: on
? 'bg-emerald-900/30 text-emerald-100 hover:bg-emerald-900/50'
: 'text-zinc-300 hover:bg-zinc-800'
}`}
>
{spec.label}
</button>
)
})}
</div>
{/* Detail */}
<div className="min-w-0 flex-1 overflow-y-auto p-5">
{selected && (
<>
<div className="flex items-start justify-between gap-3">
<h3 className="text-lg font-semibold text-white">{selected.label}</h3>
<button
onClick={copyKey}
className="flex shrink-0 items-center gap-1.5 font-mono text-[11px] text-zinc-500 transition-colors hover:text-zinc-300"
title="Copy flag key"
>
{selected.key}
{copied ? <Check className="h-3 w-3 text-green-400" /> : <Copy className="h-3 w-3" />}
</button>
</div>
<div className="mt-4 flex items-center gap-3">
<span className="text-xs font-medium uppercase tracking-wide text-zinc-400">Enabled</span>
<button
type="button"
role="switch"
aria-checked={isOn}
onClick={() => setFlag(selected.key, !isOn)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
isOn ? 'bg-blue-500' : 'bg-zinc-600'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
isOn ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
<div className="mt-6">
<p className="mb-1 text-xs font-medium text-zinc-400">Description</p>
<p className="text-sm leading-relaxed text-zinc-300">{selected.description}</p>
</div>
</>
)}
</div>
</div>
</div>
</div>
)
}
+671
View File
@@ -0,0 +1,671 @@
import { useState, useRef, useCallback, useEffect, useMemo } from 'react'
import { X, Download, FolderOpen, Film, Package, Loader2, Check, AlertCircle, ChevronDown } from 'lucide-react'
import { Button } from './ui/button'
import { DEFAULT_SUBTITLE_STYLE } from '../types/project-model'
import type { Track, TimelineClip } from '../types/project-model'
import {
selectActiveTimeline,
selectAssets,
selectClipPathFromAssets,
selectClips,
selectShowExportModal,
selectSubtitles,
selectTracks,
} from '../views/editor/editor-selectors'
import { useEditorActions, useEditorStore } from '../views/editor/editor-store'
interface ExportModalProps {
projectName: string
}
type ExportStatus = 'idle' | 'exporting' | 'done' | 'error'
type ExportCodec = 'h264' | 'prores' | 'vp9'
interface ExportSettings {
codec: ExportCodec
width: number
height: number
fps: number
quality: number // CRF for h264, profile for prores, bitrate(Mbps) for vp9
}
const CODEC_INFO: Record<ExportCodec, { label: string; ext: string; description: string; filterName: string }> = {
h264: { label: 'H.264 / MP4', ext: 'mp4', description: 'Most compatible format', filterName: 'MP4 Video' },
prores: { label: 'ProRes / MOV', ext: 'mov', description: 'Professional editing format', filterName: 'QuickTime Movie' },
vp9: { label: 'VP9 / WebM', ext: 'webm', description: 'Web-optimized format', filterName: 'WebM Video' },
}
const RESOLUTIONS = [
{ label: '4K (3840 x 2160)', width: 3840, height: 2160 },
{ label: '1080p (1920 x 1080)', width: 1920, height: 1080 },
{ label: '720p (1280 x 720)', width: 1280, height: 720 },
]
const FRAME_RATES = [24, 25, 30, 60]
const PRORES_PROFILES = [
{ value: 0, label: 'Proxy' },
{ value: 1, label: 'LT' },
{ value: 2, label: 'Standard' },
{ value: 3, label: 'HQ' },
]
const LETTERBOX_RATIO_MAP: Record<string, number> = {
'2.35:1': 2.35,
'2.39:1': 2.39,
'2.76:1': 2.76,
'1.85:1': 1.85,
'4:3': 4 / 3,
}
// Generate FCPXML for Premiere / DaVinci
function generateFCPXML(
clips: TimelineClip[],
tracks: Track[],
projectName: string,
timelineName: string,
fps: number = 24
): string {
const frameDuration = `${Math.round(100 * fps)}/${100 * fps}s`
const totalDuration = clips.reduce((max, c) => Math.max(max, c.startTime + c.duration), 0)
const totalFrames = Math.ceil(totalDuration * fps)
const assetEntries: string[] = []
const seenAssets = new Set<string>()
for (const clip of clips) {
const assetId = clip.assetId || clip.id
if (seenAssets.has(assetId)) continue
seenAssets.add(assetId)
const assetPath = clip.asset?.path || ''
const dur = clip.asset?.duration || clip.duration
const durFrames = Math.ceil(dur * fps)
const format = clip.type === 'audio' ? 'audio' : 'video'
assetEntries.push(
` <asset id="${escapeXml(assetId)}" name="${escapeXml(clip.asset?.prompt?.slice(0, 60) || clip.importedName || 'Clip')}" src="${escapeXml(assetPath)}" start="0s" duration="${durFrames}/${fps}s" hasVideo="${format === 'video' ? '1' : '0'}" hasAudio="1" format="r1" />`
)
}
const trackGroups: Map<number, TimelineClip[]> = new Map()
for (const clip of clips) {
if (!trackGroups.has(clip.trackIndex)) trackGroups.set(clip.trackIndex, [])
trackGroups.get(clip.trackIndex)!.push(clip)
}
const laneXml: string[] = []
const sortedTrackIndices = [...trackGroups.keys()].sort((a, b) => a - b)
for (const trackIdx of sortedTrackIndices) {
const trackClips = trackGroups.get(trackIdx)!.sort((a, b) => a.startTime - b.startTime)
const clipElements: string[] = []
for (const clip of trackClips) {
const assetId = clip.assetId || clip.id
const startFrame = Math.round(clip.startTime * fps)
const durFrames = Math.round(clip.duration * fps)
const trimStartFrame = Math.round(clip.trimStart * fps)
const name = clip.asset?.prompt?.slice(0, 60) || clip.importedName || 'Clip'
let clipXml = ` <asset-clip ref="${escapeXml(assetId)}" name="${escapeXml(name)}" offset="${startFrame}/${fps}s" duration="${durFrames}/${fps}s" start="${trimStartFrame}/${fps}s"`
if (clip.speed !== 1) {
clipXml += ` tcFormat="NDF"`
}
clipXml += ` />`
clipElements.push(clipXml)
}
const trackName = tracks[trackIdx]?.name || `Track ${trackIdx + 1}`
laneXml.push(
` <!-- ${escapeXml(trackName)} -->\n` +
clipElements.join('\n')
)
}
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE fcpxml>
<fcpxml version="1.10">
<resources>
<format id="r1" name="FFVideoFormat${fps === 24 ? '1080p2398' : '1080p' + fps}" frameDuration="${frameDuration}" width="1920" height="1080" />
${assetEntries.join('\n')}
</resources>
<library>
<event name="${escapeXml(projectName)}">
<project name="${escapeXml(timelineName)}">
<sequence format="r1" duration="${totalFrames}/${fps}s" tcStart="0s" tcFormat="NDF">
<spine>
${laneXml.join('\n')}
</spine>
</sequence>
</project>
</event>
</library>
</fcpxml>`
}
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
export function ExportModal({ projectName }: ExportModalProps) {
const { closeExportModal } = useEditorActions()
const isOpen = useEditorStore(selectShowExportModal)
const timeline = useEditorStore(selectActiveTimeline)
const assets = useEditorStore(selectAssets)
const clips = useEditorStore(selectClips)
const tracks = useEditorStore(selectTracks)
const subtitles = useEditorStore(selectSubtitles)
const exportClips = useMemo(() => (
clips
.filter(clip => clip.type === 'video' || clip.type === 'image' || clip.type === 'audio')
.filter(clip => tracks[clip.trackIndex]?.enabled !== false)
.map(clip => ({
path: selectClipPathFromAssets(assets, clip),
type: clip.type,
startTime: clip.startTime,
duration: clip.duration,
trimStart: clip.trimStart,
speed: clip.speed || 1,
reversed: clip.reversed || false,
flipH: clip.flipH || false,
flipV: clip.flipV || false,
opacity: clip.opacity ?? 100,
trackIndex: clip.trackIndex,
muted: clip.muted || false,
volume: clip.volume ?? 1,
}))
), [assets, clips, tracks])
const subtitleData = useMemo(() => (
subtitles.map(subtitle => {
const track = tracks[subtitle.trackIndex]
return {
text: subtitle.text,
startTime: subtitle.startTime,
endTime: subtitle.endTime,
style: {
...DEFAULT_SUBTITLE_STYLE,
...(track?.subtitleStyle || {}),
...(subtitle.style || {}),
},
}
})
), [subtitles, tracks])
const letterbox = useMemo(() => {
const adjustmentClips = clips.filter(
clip =>
clip.type === 'adjustment'
&& clip.letterbox?.enabled
&& tracks[clip.trackIndex]?.enabled !== false,
)
if (adjustmentClips.length === 0) return null
const best = adjustmentClips.reduce((currentBest, candidate) => (
candidate.duration > currentBest.duration ? candidate : currentBest
))
const config = best.letterbox!
return {
ratio: config.aspectRatio === 'custom'
? (config.customRatio || 2.35)
: (LETTERBOX_RATIO_MAP[config.aspectRatio] || 2.35),
color: config.color || '#000000',
opacity: (config.opacity ?? 100) / 100,
}
}, [clips, tracks])
const [exportStatus, setExportStatus] = useState<ExportStatus>('idle')
const [exportType, setExportType] = useState<'package' | 'video' | null>(null)
const [exportProgress, setExportProgress] = useState(0)
const [exportError, setExportError] = useState<string | null>(null)
const [exportPath, setExportPath] = useState<string | null>(null)
const [exportFrameInfo, setExportFrameInfo] = useState('')
const abortRef = useRef(false)
// Export settings
const [settings, setSettings] = useState<ExportSettings>({
codec: 'h264',
width: 1920,
height: 1080,
fps: 24,
quality: 18, // CRF 18 for h264
})
const [burnSubtitles, setBurnSubtitles] = useState(true)
const closeModal = useCallback(() => {
closeExportModal()
}, [closeExportModal])
const hasSubtitles = subtitleData.length > 0
useEffect(() => {
if (!isOpen) return
setExportStatus('idle')
setExportType(null)
setExportProgress(0)
setExportError(null)
setExportPath(null)
setExportFrameInfo('')
abortRef.current = false
}, [isOpen])
// Update quality default when codec changes
const handleCodecChange = useCallback((codec: ExportCodec) => {
let quality = 18
if (codec === 'prores') quality = 3 // HQ profile
if (codec === 'vp9') quality = 8 // 8 Mbps
setSettings(prev => ({ ...prev, codec, quality }))
}, [])
const handleExportPackage = useCallback(async () => {
if (!timeline) return
setExportType('package')
setExportStatus('exporting')
setExportProgress(0)
setExportError(null)
try {
const filePath = await window.electronAPI?.showSaveDialog({
title: 'Export FCPXML Package',
defaultPath: `${projectName}_${timeline.name}.fcpxml`,
filters: [
{ name: 'Final Cut Pro XML', extensions: ['fcpxml'] },
{ name: 'All Files', extensions: ['*'] },
],
})
if (!filePath) {
setExportStatus('idle')
return
}
setExportProgress(50)
const xml = generateFCPXML(clips, tracks, projectName, timeline.name)
const result = await window.electronAPI?.saveFile({ filePath, data: xml })
if (result?.success) {
setExportProgress(100)
setExportPath(filePath)
setExportStatus('done')
} else {
throw new Error(result && !result.success ? result.error : 'Failed to save file')
}
} catch (err) {
setExportError(String(err))
setExportStatus('error')
}
}, [clips, tracks, timeline, projectName])
const handleExportVideo = useCallback(async () => {
if (!timeline || clips.length === 0) return
setExportType('video')
setExportStatus('exporting')
setExportProgress(0)
setExportError(null)
setExportFrameInfo('Preparing...')
abortRef.current = false
try {
const codecInfo = CODEC_INFO[settings.codec]
const filePath = await window.electronAPI?.showSaveDialog({
title: `Export ${codecInfo.label}`,
defaultPath: `${projectName}_${timeline.name}.${codecInfo.ext}`,
filters: [
{ name: codecInfo.filterName, extensions: [codecInfo.ext] },
{ name: 'All Files', extensions: ['*'] },
],
})
if (!filePath) {
setExportStatus('idle')
return
}
// Build clip data for ffmpeg native export (video/image + audio clips)
setExportFrameInfo('Starting ffmpeg...')
const result = await window.electronAPI?.exportNative({
clips: exportClips,
outputPath: filePath,
codec: settings.codec,
width: settings.width,
height: settings.height,
fps: settings.fps,
quality: settings.quality,
letterbox: letterbox || undefined,
subtitles: burnSubtitles && subtitleData.length > 0 ? subtitleData : undefined,
})
if (result && !result.success) {
throw new Error(result.error)
}
setExportProgress(100)
setExportPath(filePath)
setExportFrameInfo('Export complete')
setExportStatus('done')
} catch (err) {
setExportError(String(err))
setExportStatus('error')
}
}, [burnSubtitles, exportClips, letterbox, projectName, settings, subtitleData, timeline])
const handleCancel = useCallback(async () => {
abortRef.current = true
window.electronAPI?.exportCancel({ sessionId: 'current' }).catch(() => {})
setExportStatus('idle')
}, [])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" onClick={closeModal}>
<div
className="bg-zinc-900 rounded-2xl border border-zinc-700/50 shadow-2xl w-full max-w-lg relative overflow-hidden max-h-[calc(100vh-2rem)] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800">
<h2 className="text-lg font-bold text-white">Export</h2>
<button
onClick={closeModal}
className="p-1.5 rounded-lg text-zinc-500 hover:text-white hover:bg-zinc-800 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="p-6 overflow-y-auto flex-1">
{/* Exporting state */}
{exportStatus === 'exporting' && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 text-blue-400 animate-spin" />
<span className="text-sm text-zinc-300">
{exportType === 'package' ? 'Generating FCPXML...' : 'Rendering video...'}
</span>
</div>
<div className="w-full bg-zinc-800 rounded-full h-2 overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all duration-300"
style={{ width: `${exportProgress}%` }}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-zinc-500">{exportProgress}% complete</p>
{exportFrameInfo && <p className="text-xs text-zinc-500">{exportFrameInfo}</p>}
</div>
{exportType === 'video' && (
<Button
variant="outline"
size="sm"
className="border-zinc-700 text-zinc-400"
onClick={handleCancel}
>
Cancel
</Button>
)}
</div>
)}
{/* Done state */}
{exportStatus === 'done' && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-green-500/20 flex items-center justify-center">
<Check className="h-5 w-5 text-green-400" />
</div>
<div>
<p className="text-sm text-white font-medium">Export complete</p>
<p className="text-xs text-zinc-500 truncate max-w-[340px]">{exportPath}</p>
{exportFrameInfo && <p className="text-xs text-zinc-500">{exportFrameInfo}</p>}
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="border-zinc-700 text-zinc-300"
onClick={() => {
if (exportPath) {
window.electronAPI?.openParentFolderOfFile({ filePath: exportPath })
}
}}
>
<FolderOpen className="h-4 w-4 mr-2" />
Show in Folder
</Button>
<Button
variant="outline"
size="sm"
className="border-zinc-700 text-zinc-300"
onClick={() => {
setExportStatus('idle')
setExportType(null)
}}
>
Export Another
</Button>
</div>
</div>
)}
{/* Error state */}
{exportStatus === 'error' && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center">
<AlertCircle className="h-5 w-5 text-red-400" />
</div>
<div>
<p className="text-sm text-white font-medium">Export failed</p>
<p className="text-xs text-red-400 max-w-[340px] break-words">{exportError}</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="border-zinc-700 text-zinc-300"
onClick={() => {
setExportStatus('idle')
setExportType(null)
}}
>
Try Again
</Button>
</div>
)}
{/* Idle state — settings */}
{exportStatus === 'idle' && (
<div className="space-y-5">
{/* Package export (compact) */}
<button
onClick={handleExportPackage}
className="w-full flex items-center gap-3 p-3 rounded-xl border border-zinc-700/50 bg-zinc-800/50 hover:bg-zinc-800 hover:border-zinc-600 transition-all group"
>
<div className="w-10 h-10 rounded-lg bg-zinc-700/50 flex items-center justify-center flex-shrink-0">
<Package className="h-5 w-5 text-zinc-300" />
</div>
<div className="flex-1 text-left">
<p className="text-sm font-semibold text-white">Package (FCPXML)</p>
<p className="text-[10px] text-zinc-500">For Premiere Pro &amp; DaVinci Resolve</p>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<div className="w-6 h-6 rounded bg-zinc-700 flex items-center justify-center" title="DaVinci Resolve">
<span className="text-[8px] font-bold text-orange-400">DR</span>
</div>
<div className="w-6 h-6 rounded bg-zinc-700 flex items-center justify-center" title="Premiere Pro">
<span className="text-[8px] font-bold text-blue-400">Pr</span>
</div>
<Download className="h-4 w-4 text-zinc-500 group-hover:text-zinc-300 transition-colors ml-1" />
</div>
</button>
{/* Divider */}
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-zinc-800" />
<span className="text-[10px] text-zinc-600 uppercase tracking-wider font-semibold">Video Export</span>
<div className="flex-1 h-px bg-zinc-800" />
</div>
{/* Format selector */}
<div>
<label className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold mb-2 block">Format</label>
<div className="grid grid-cols-3 gap-2">
{(Object.keys(CODEC_INFO) as ExportCodec[]).map(codec => (
<button
key={codec}
onClick={() => handleCodecChange(codec)}
className={`p-2.5 rounded-lg border text-center transition-all ${
settings.codec === codec
? 'border-blue-500 bg-blue-500/10 text-white'
: 'border-zinc-700 bg-zinc-800/50 text-zinc-400 hover:border-zinc-600 hover:text-zinc-300'
}`}
>
<p className="text-xs font-semibold">{CODEC_INFO[codec].label.split(' / ')[0]}</p>
<p className="text-[9px] text-zinc-500 mt-0.5">.{CODEC_INFO[codec].ext}</p>
</button>
))}
</div>
</div>
{/* Resolution & Frame rate row */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold mb-1.5 block">Resolution</label>
<div className="relative">
<select
value={`${settings.width}x${settings.height}`}
onChange={(e) => {
const [w, h] = e.target.value.split('x').map(Number)
setSettings(prev => ({ ...prev, width: w, height: h }))
}}
className="w-full appearance-none bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500 pr-8 cursor-pointer"
>
{RESOLUTIONS.map(r => (
<option key={`${r.width}x${r.height}`} value={`${r.width}x${r.height}`}>
{r.label}
</option>
))}
</select>
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-zinc-500 pointer-events-none" />
</div>
</div>
<div>
<label className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold mb-1.5 block">Frame Rate</label>
<div className="relative">
<select
value={settings.fps}
onChange={(e) => setSettings(prev => ({ ...prev, fps: parseInt(e.target.value) }))}
className="w-full appearance-none bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500 pr-8 cursor-pointer"
>
{FRAME_RATES.map(fps => (
<option key={fps} value={fps}>{fps} fps</option>
))}
</select>
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-zinc-500 pointer-events-none" />
</div>
</div>
</div>
{/* Quality */}
<div>
<label className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold mb-1.5 block">Quality</label>
{settings.codec === 'h264' && (
<div className="flex items-center gap-3">
<input
type="range"
min={15}
max={28}
step={1}
value={settings.quality}
onChange={(e) => setSettings(prev => ({ ...prev, quality: parseInt(e.target.value) }))}
className="flex-1 h-1.5 accent-blue-500 cursor-pointer"
// Note: lower CRF = higher quality (inverted display)
/>
<span className="text-xs text-zinc-400 w-16 text-right">
{settings.quality <= 18 ? 'High' : settings.quality <= 23 ? 'Medium' : 'Low'}
<span className="text-zinc-600 ml-1">({settings.quality})</span>
</span>
</div>
)}
{settings.codec === 'prores' && (
<div className="grid grid-cols-4 gap-1.5">
{PRORES_PROFILES.map(p => (
<button
key={p.value}
onClick={() => setSettings(prev => ({ ...prev, quality: p.value }))}
className={`py-1.5 px-2 rounded-md text-xs font-medium transition-all ${
settings.quality === p.value
? 'bg-blue-500/20 border border-blue-500 text-blue-300'
: 'bg-zinc-800 border border-zinc-700 text-zinc-400 hover:border-zinc-600'
}`}
>
{p.label}
</button>
))}
</div>
)}
{settings.codec === 'vp9' && (
<div className="flex items-center gap-3">
<input
type="range"
min={2}
max={20}
step={1}
value={settings.quality}
onChange={(e) => setSettings(prev => ({ ...prev, quality: parseInt(e.target.value) }))}
className="flex-1 h-1.5 accent-blue-500 cursor-pointer"
/>
<span className="text-xs text-zinc-400 w-20 text-right">
{settings.quality} Mbps
</span>
</div>
)}
</div>
{/* Options */}
{hasSubtitles && (
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-zinc-800" />
<span className="text-[10px] text-zinc-600 uppercase tracking-wider font-semibold">Options</span>
<div className="flex-1 h-px bg-zinc-800" />
</div>
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={burnSubtitles}
onChange={(e) => setBurnSubtitles(e.target.checked)}
className="w-4 h-4 rounded border-zinc-600 bg-zinc-800 accent-blue-500 cursor-pointer"
/>
<span className="text-xs text-zinc-300 group-hover:text-white transition-colors">Burn-in subtitles</span>
</label>
</div>
)}
{/* Export button */}
<button
onClick={handleExportVideo}
disabled={clips.length === 0 || window.electronAPI.platform === 'web'}
className="w-full py-3 rounded-xl bg-blue-600 hover:bg-blue-500 disabled:opacity-40 disabled:cursor-not-allowed text-white font-semibold text-sm flex items-center justify-center gap-2 transition-colors"
>
<Film className="h-4 w-4" />
Timeline rendering not yet available in web preview
</button>
{clips.length === 0 && (
<p className="text-xs text-zinc-500 text-center">Add clips to the timeline to export.</p>
)}
</div>
)}
</div>
</div>
</div>
)
}
+71
View File
@@ -0,0 +1,71 @@
import { useState, useEffect, useCallback } from 'react'
import { VideoPreviewPanel } from './VideoPreviewPanel'
import { validateVideoSource } from '../lib/video-constraints'
// Preview-only panel for extend. The direction + seconds-to-add controls live in the
// PromptBar (like Video mode), so this just owns the source video + preview.
interface ExtendPanelProps {
initialVideoPath?: string | null
initialDuration?: number
resetKey?: number
isProcessing?: boolean
processingStatus?: string
fillHeight?: boolean
// When the request will hit the cloud API, enforce its input constraints (4K / aspect /
// min duration). In local mode resolution is corrected, so these don't block.
enforceApiConstraints?: boolean
onChange?: (data: {
videoPath: string | null
videoDuration: number
width: number
height: number
ready: boolean
}) => void
}
export function ExtendPanel({
initialVideoPath,
initialDuration,
resetKey,
isProcessing = false,
processingStatus = '',
fillHeight = false,
enforceApiConstraints = true,
onChange,
}: ExtendPanelProps) {
const [videoPath, setVideoPath] = useState<string | null>(initialVideoPath || null)
const [videoDuration, setVideoDuration] = useState<number>(initialDuration || 0)
const [dimensions, setDimensions] = useState<{ width: number; height: number }>({ width: 0, height: 0 })
const handleSourceChange = useCallback((data: { videoPath: string | null; videoDuration: number; width: number; height: number }) => {
setVideoPath(data.videoPath)
setVideoDuration(data.videoDuration)
setDimensions({ width: data.width, height: data.height })
}, [])
const error = enforceApiConstraints && videoPath
? validateVideoSource({ width: dimensions.width, height: dimensions.height, duration: videoDuration })
: null
useEffect(() => {
onChange?.({ videoPath, videoDuration, width: dimensions.width, height: dimensions.height, ready: !!videoPath && !error })
}, [videoPath, videoDuration, dimensions, error, onChange])
return (
<VideoPreviewPanel
title="Extend"
initialVideoPath={initialVideoPath}
initialDuration={initialDuration}
resetKey={resetKey}
isProcessing={isProcessing}
processingStatus={processingStatus}
processingDefault="Processing extend..."
fillHeight={fillHeight}
emptyTitle="Drop a video to extend"
hint={{ title: 'Add new frames to your video', subtitle: 'Set direction and length in the bar below, then extend' }}
errorMessage={error ?? undefined}
onSourceChange={handleSourceChange}
/>
)
}
+28
View File
@@ -0,0 +1,28 @@
@import url('https://fonts.googleapis.com/css2?family=Miriam+Libre:wght@400;700&display=swap');
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes gradientBg {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.9; }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
import { useState, useEffect, useRef } from 'react'
import { X } from 'lucide-react'
const SHOW_DELAY_MS = 2500
// Module-level flag — resets on each app launch (page reload)
let dismissedThisSession = false
export function FreeApiKeyBubble({
forceApiGenerations,
hasLtxApiKey,
isGenerating,
}: {
forceApiGenerations: boolean
hasLtxApiKey: boolean
isGenerating: boolean
}) {
const [dismissed, setDismissed] = useState(() => dismissedThisSession)
const [visible, setVisible] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (isGenerating && !dismissed && !forceApiGenerations && !hasLtxApiKey) {
timerRef.current = setTimeout(() => setVisible(true), SHOW_DELAY_MS)
} else {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = null
setVisible(false)
}
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [isGenerating, dismissed, forceApiGenerations, hasLtxApiKey])
if (!visible) return null
const handleDismiss = () => {
dismissedThisSession = true
setDismissed(true)
}
const handleGoToSettings = () => {
window.dispatchEvent(
new CustomEvent('open-settings', { detail: { tab: 'apiKeys' } }),
)
}
return (
<div
className="mb-2 rounded-xl bg-zinc-900/90 backdrop-blur-sm border border-zinc-700/50 px-4 py-3 text-sm text-zinc-200"
style={{ animation: 'fadeInUp 0.3s ease-out' }}
>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<p>
Speed up inference and save memory with free cloud text encoding and
prompt enhancement.{' '}
<button
onClick={handleGoToSettings}
className="text-blue-400 hover:text-blue-300 underline underline-offset-2 transition-colors"
>
Get a free LTX API key
</button>{' '}
to enable it.
</p>
<p className="mt-1 text-xs text-zinc-400">
The free API key covers text encoding and prompt enhancement only.
Video generation via API requires a paid plan.
</p>
</div>
<button
onClick={handleDismiss}
className="shrink-0 p-0.5 rounded hover:bg-zinc-700/60 text-zinc-400 hover:text-zinc-200 transition-colors"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
)
}
@@ -0,0 +1,142 @@
import { useState } from 'react'
import { AlertCircle, ChevronDown, ChevronRight, X } from 'lucide-react'
import type { GenerationError } from '../lib/generation-errors'
interface GenerationErrorDialogProps {
error: GenerationError
onDismiss: () => void
}
function assertNever(value: never): never {
throw new Error(`Unexpected generation error variant: ${JSON.stringify(value)}`)
}
function getGenericHumanMessage(message: string): string {
const lower = message.toLowerCase()
if (lower.includes('409') || lower.includes('already')) {
return 'A generation is already in progress. Please wait for it to finish or cancel it.'
}
if (lower.includes('cuda') || lower.includes('out of memory') || lower.includes('oom')) {
return 'The GPU ran out of memory. Try a lower resolution or shorter duration.'
}
if ((lower.includes('model') && (lower.includes('not found') || lower.includes('load')))) {
return 'The AI model failed to load. Please check your setup and try again.'
}
if (lower.includes('fetch') || lower.includes('network') || lower.includes('econnrefused')) {
return 'Could not connect to the generation server. Make sure the backend is running.'
}
if (lower.includes('input image') || lower.includes('canvas') || lower.includes('cors')) {
return 'Failed to prepare the input image. The file may be corrupted or inaccessible.'
}
if (lower.includes('could not auto-generate') || lower.includes('send to genspace')) {
return message
}
return 'Something went wrong during generation. Please try again.'
}
function getDialogModel(error: GenerationError): {
humanMessage: string
technicalDetails: string
primaryAction?: {
label: string
onClick: () => void
}
} {
switch (error.status) {
case 402:
switch (error.error.code) {
case 'LTX_INSUFFICIENT_FUNDS':
return {
humanMessage: 'Your LTX API credits are insufficient for this generation. Buy more credits in LTX and try again.',
technicalDetails: JSON.stringify(error.error, null, 2),
primaryAction: {
label: 'Buy Credits',
onClick: () => {
void window.electronAPI.openLtxBillingPage()
},
},
}
}
return assertNever(error.error.code)
case '4XX':
case '5XX':
case 'default':
return {
humanMessage: getGenericHumanMessage(error.error.message),
technicalDetails: JSON.stringify(error.error, null, 2),
}
default:
return assertNever(error)
}
}
export function GenerationErrorDialog({ error, onDismiss }: GenerationErrorDialogProps) {
const [detailsExpanded, setDetailsExpanded] = useState(false)
const dialogModel = getDialogModel(error)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-[480px] flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800">
<div className="flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-red-400" />
<h2 className="text-base font-semibold text-zinc-100">Generation Failed</h2>
</div>
<button
onClick={onDismiss}
className="p-1.5 rounded-lg hover:bg-zinc-800 text-zinc-500 hover:text-zinc-300 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="px-6 py-5">
<p className="text-sm text-zinc-300 leading-relaxed">
{dialogModel.humanMessage}
</p>
<div className="mt-4">
<button
onClick={() => setDetailsExpanded(!detailsExpanded)}
className="flex items-center gap-2 text-xs font-semibold text-zinc-400 uppercase tracking-wider hover:text-zinc-300"
>
{detailsExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
Technical Details
</button>
{detailsExpanded && (
<pre className="mt-2 bg-zinc-800/50 rounded-lg p-3 text-[11px] text-zinc-400 whitespace-pre-wrap break-words max-h-40 overflow-auto">
{dialogModel.technicalDetails}
</pre>
)}
</div>
</div>
<div className={`px-6 py-4 border-t border-zinc-800 flex items-center gap-3 ${dialogModel.primaryAction ? 'justify-between' : 'justify-end'}`}>
{dialogModel.primaryAction ? (
<button
onClick={onDismiss}
className="px-4 py-2 bg-zinc-800 text-zinc-100 text-sm font-medium rounded-lg hover:bg-zinc-700 transition-colors"
>
Try Again
</button>
) : null}
{dialogModel.primaryAction ? (
<button
onClick={dialogModel.primaryAction.onClick}
className="px-4 py-2 bg-zinc-100 text-zinc-900 text-sm font-medium rounded-lg hover:bg-white transition-colors"
>
{dialogModel.primaryAction.label}
</button>
) : (
<button
onClick={onDismiss}
className="px-4 py-2 bg-zinc-100 text-zinc-900 text-sm font-medium rounded-lg hover:bg-white transition-colors"
>
Try Again
</button>
)}
</div>
</div>
</div>
)
}
+98
View File
@@ -0,0 +1,98 @@
import { AlertCircle } from 'lucide-react'
import type { ApiSuccessOf } from '../lib/api-client'
import { Button } from './ui/button'
type HfAuthStatus = ApiSuccessOf<'getHuggingFaceAuthStatus'>['status']
type ModelAccessMap = ApiSuccessOf<'checkModelAccess'>['access']
interface HfModelAccessGateProps {
accessMap: ModelAccessMap
allAuthorized: boolean
hfAuthStatus: HfAuthStatus
hfAuthPolling: boolean
startHuggingFaceLogin: () => void
/** When the access check itself failed (network/backend), distinct from unauthorized. */
checkError?: string | null
onRetryCheck?: () => void
className?: string
}
export function HfModelAccessGate({
accessMap,
allAuthorized,
hfAuthStatus,
hfAuthPolling,
startHuggingFaceLogin,
checkError = null,
onRetryCheck,
className,
}: HfModelAccessGateProps) {
if (allAuthorized) return null
if (checkError) {
return (
<div className={className ?? 'space-y-2'}>
<div className="flex items-start gap-2 text-xs text-amber-400">
<AlertCircle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<span>Couldn&apos;t verify Hugging Face access: {checkError}</span>
</div>
{onRetryCheck && (
<Button
size="sm"
variant="outline"
onClick={onRetryCheck}
className="text-xs"
>
Retry
</Button>
)}
</div>
)
}
const unauthorizedRepos = Object.entries(accessMap).filter(([, status]) => status === 'not_authorized')
if (unauthorizedRepos.length === 0) return null
if (hfAuthStatus !== 'authenticated') {
return (
<div className={className ?? 'space-y-2'}>
<div className="flex items-start gap-2 text-xs text-amber-400">
<AlertCircle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<span>
This model is gated on Hugging Face. Sign in, then accept the license to download.
</span>
</div>
<Button
size="sm"
onClick={startHuggingFaceLogin}
disabled={hfAuthPolling}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-xs"
>
{hfAuthPolling ? 'Waiting for sign in…' : 'Sign in with Hugging Face'}
</Button>
</div>
)
}
return (
<div className={className ?? 'space-y-1.5'}>
<p className="text-xs text-amber-400">
Accept the Hugging Face license for this model, then download.
</p>
{unauthorizedRepos.map(([repoId]) => (
<div key={repoId} className="flex items-center justify-between gap-2 bg-zinc-900 rounded px-2 py-1.5">
<span className="text-[10px] text-zinc-400 font-mono truncate">{repoId}</span>
<button
type="button"
onClick={() => {
void window.electronAPI.openHuggingFaceRepo({ repoId })
}}
className="text-[10px] text-indigo-400 hover:text-indigo-300 font-medium flex-shrink-0"
>
Request access
</button>
</div>
))}
</div>
)
}
+751
View File
@@ -0,0 +1,751 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import {
Upload, Loader2, Film, Sparkles, Image as ImageIcon,
RefreshCw, Download, AlertCircle, Trash2,
} from 'lucide-react'
import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../lib/api-client'
import { logger } from '../lib/logger'
import { pathToFileUrl } from '../lib/file-url'
import { OutpaintCanvasEditor, type OutpaintPads } from './OutpaintCanvasEditor'
export type ICLoraConditioningType = 'canny' | 'depth' | 'custom'
interface ICLoraPanelProps {
initialVideoPath?: string | null
resetKey?: number
fillHeight?: boolean
isProcessing?: boolean
processingStatus?: string
// IC-LoRA mode: 'image' accepts a still image and skips canny/depth preprocessing
// (the IC-LoRA builds the control video server-side). Default 'video' = today's behavior.
inputKind?: 'image' | 'video'
// IC-LoRA mode: when an IC-LoRA is selected (via the library modal) the panel switches to
// IC-LoRA mode and hides the canny/depth conditioning preview. The catalog/download UI
// lives in the modal, not here.
selectedIcLoraId?: string | null
// Catalog IC-LoRA opt-in: when true the middle column offers an optional reference-image
// picker (instead of the canny/depth conditioning preview, which catalog IC-LoRAs hide).
allowsReferenceImage?: boolean
// Outpainting (position_canvas control): when true the middle column shows the canvas editor.
showOutpaintCanvas?: boolean
outpaintPads?: OutpaintPads
onOutpaintPadsChange?: (pads: OutpaintPads) => void
// Library browsing (and catalog IC-LoRA generation in general) is local-only.
isLocalMode?: boolean
onBrowseLibrary?: () => void
conditioningType?: ICLoraConditioningType
onConditioningTypeChange?: (type: ICLoraConditioningType) => void
conditioningStrength?: number
onConditioningStrengthChange?: (strength: number) => void
outputVideoPath?: string | null
onChange?: (data: {
videoPath: string | null
conditioningType: ICLoraConditioningType
conditioningStrength: number
ready: boolean
referenceImagePath: string | null
// Source pixel dimensions (0 until media loads) — feeds the resolution control.
width: number
height: number
}) => void
}
export const CONDITIONING_TYPES: { value: ICLoraConditioningType; label: string; desc: string }[] = [
{ value: 'canny', label: 'Canny Edges', desc: 'Edge detection' },
{ value: 'depth', label: 'Depth Map', desc: 'Estimated depth' },
{ value: 'custom', label: 'Custom IC-LoRA', desc: 'Your own weights + control video' },
]
type StartModelDownloadBody = NonNullable<ApiRequestBodyOf<'startModelDownload'>>
type ModelCheckpointID = NonNullable<StartModelDownloadBody['cp_ids']>[number]
type DownloadProgress = ApiSuccessOf<'getModelDownloadProgress'>
export function ICLoraPanel({
initialVideoPath,
resetKey,
fillHeight = false,
isProcessing = false,
processingStatus = '',
inputKind = 'video',
selectedIcLoraId = null,
allowsReferenceImage = false,
showOutpaintCanvas = false,
outpaintPads,
onOutpaintPadsChange,
isLocalMode = false,
onBrowseLibrary,
conditioningType: conditioningTypeProp,
onConditioningTypeChange,
conditioningStrength: conditioningStrengthProp,
onConditioningStrengthChange,
outputVideoPath: _outputVideoPath,
onChange,
}: ICLoraPanelProps) {
const inputVideoRef = useRef<HTMLVideoElement>(null)
const [inputVideoPath, setInputVideoPath] = useState<string | null>(initialVideoPath || null)
const inputVideoUrl = inputVideoPath ? pathToFileUrl(inputVideoPath) : null
const [inputTime, setInputTime] = useState(0)
// Source pixel dimensions, read off the loaded media — drives the outpaint canvas editor.
const [sourceDims, setSourceDims] = useState<{ w: number; h: number } | null>(null)
// Optional reference image for catalog IC-LoRAs that allow it (frame 0, strength 1.0).
const [referenceImagePath, setReferenceImagePath] = useState<string | null>(null)
const referenceImageUrl = referenceImagePath ? pathToFileUrl(referenceImagePath) : null
const [isReferenceDragOver, setIsReferenceDragOver] = useState(false)
const [internalCondType, setInternalCondType] = useState<ICLoraConditioningType>('canny')
const [internalCondStrength, setInternalCondStrength] = useState(1.0)
const conditioningType = conditioningTypeProp ?? internalCondType
const conditioningStrength = conditioningStrengthProp ?? internalCondStrength
// Custom mode: the user supplies their own IC-LoRA + a pre-rendered control video,
// so there's no canny/depth preprocessing to preview.
const isCustom = conditioningType === 'custom'
// Image (IC-LoRA) mode: no canny/depth preprocessing, no bundled-cp download gate.
const isImage = inputKind === 'image'
// Catalog IC-LoRA mode (any input kind): the IC-LoRA builds its own control video server-side.
const isCatalogIcLora = selectedIcLoraId !== null
// The conditioning preview only applies to the built-in canny/depth flow.
const showConditioningPreview = !isCatalogIcLora && !isCustom
const [conditioningPreview, setConditioningPreview] = useState<string | null>(null)
const [isExtracting, setIsExtracting] = useState(false)
const [requiredIcLoraCpIds, setRequiredIcLoraCpIds] = useState<ModelCheckpointID[]>([])
const [icLoraSupported, setIcLoraSupported] = useState(true)
const [isCheckingIcLora, setIsCheckingIcLora] = useState(false)
const [isDownloadingIcLora, setIsDownloadingIcLora] = useState(false)
const [downloadProgress, setDownloadProgress] = useState<DownloadProgress | null>(null)
const [downloadError, setDownloadError] = useState<string | null>(null)
const [downloadSessionId, setDownloadSessionId] = useState<string | null>(null)
const [extractError, setExtractError] = useState<string | null>(null)
const [isDragOver, setIsDragOver] = useState(false)
const icLoraReady = icLoraSupported && requiredIcLoraCpIds.length === 0
// Switching to an entry with a different input.kind (image vs video) invalidates the loaded
// input — clear it so a stale video can't be submitted to an image-input entry (backend 400).
const prevInputKindRef = useRef(inputKind)
useEffect(() => {
if (prevInputKindRef.current === inputKind) return
prevInputKindRef.current = inputKind
setInputVideoPath(null)
setInputTime(0)
setSourceDims(null)
setConditioningPreview(null)
setExtractError(null)
}, [inputKind])
useEffect(() => {
if (resetKey === undefined) return
setInputVideoPath(initialVideoPath || null)
setInputTime(0)
setSourceDims(null)
setInternalCondType('canny')
setInternalCondStrength(1.0)
onConditioningTypeChange?.('canny')
onConditioningStrengthChange?.(1.0)
setConditioningPreview(null)
setExtractError(null)
setReferenceImagePath(null)
}, [resetKey, initialVideoPath]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
// Custom brings its own IC-LoRA + control video; recipes (any input kind) build the
// control video server-side. Neither needs the bundled canny/depth cps.
const ready = !!inputVideoPath && (isCustom || isImage || isCatalogIcLora || icLoraReady)
onChange?.({
videoPath: inputVideoPath,
conditioningType,
conditioningStrength,
ready,
referenceImagePath,
width: sourceDims?.w ?? 0,
height: sourceDims?.h ?? 0,
})
}, [inputVideoUrl, inputVideoPath, conditioningType, conditioningStrength, isCustom, isImage, isCatalogIcLora, icLoraReady, referenceImagePath, sourceDims, onChange])
const checkIcLoraAvailability = useCallback(async () => {
setIsCheckingIcLora(true)
const result = await ApiClient.getLtxIcLoraRecommendation()
if (!result.ok) {
logger.warn(`Failed to fetch IC-LoRA model status: ${result.error.message}`)
setDownloadError(result.error.message)
setIsCheckingIcLora(false)
return
}
const recommendationPayload = result.data
setRequiredIcLoraCpIds(recommendationPayload.cps_to_download)
setIcLoraSupported(recommendationPayload.supported)
const isReady = recommendationPayload.supported && recommendationPayload.cps_to_download.length === 0
if (isReady) {
setIsDownloadingIcLora(false)
setDownloadProgress(null)
setDownloadError(null)
setDownloadSessionId(null)
}
setIsCheckingIcLora(false)
}, [])
useEffect(() => {
void checkIcLoraAvailability()
}, [checkIcLoraAvailability])
useEffect(() => {
if (icLoraReady || !isDownloadingIcLora || !downloadSessionId) return
const pollProgress = async () => {
const result = await ApiClient.getModelDownloadProgress({ sessionId: downloadSessionId })
if (!result.ok) {
logger.warn(`Failed polling IC-LoRA download progress: ${result.error.message}`)
return
}
const progressPayload = result.data
setDownloadProgress(progressPayload)
if (progressPayload.status === 'error') {
setIsDownloadingIcLora(false)
setDownloadError(progressPayload.error || 'Model download failed')
return
}
if (progressPayload.status === 'complete') {
setIsDownloadingIcLora(false)
await checkIcLoraAvailability()
}
}
void pollProgress()
const interval = setInterval(() => { void pollProgress() }, 1000)
return () => clearInterval(interval)
}, [icLoraReady, isDownloadingIcLora, downloadSessionId, checkIcLoraAvailability])
const handleDownloadIcLora = useCallback(async () => {
if (isDownloadingIcLora) return
setDownloadError(null)
const result = await ApiClient.startModelDownload({
type: 'download',
cp_ids: [...requiredIcLoraCpIds],
})
if (!result.ok) {
logger.warn(`Failed to start IC-LoRA download: ${result.error.message}`)
setDownloadError(result.error.message)
return
}
const startedPayload = result.data
if (startedPayload.status === 'started') {
setDownloadSessionId(startedPayload.sessionId)
setIsDownloadingIcLora(true)
return
}
setDownloadError('Unexpected response while starting IC-LoRA download')
}, [isDownloadingIcLora, requiredIcLoraCpIds])
const isExtractingRef = useRef(false)
const extractConditioning = useCallback(async () => {
if (isCustom || isImage || isCatalogIcLora || !inputVideoPath || isExtractingRef.current || !icLoraReady) return
isExtractingRef.current = true
setIsExtracting(true)
setExtractError(null)
const result = await ApiClient.extractIcLoraConditioning({
video_path: inputVideoPath,
conditioning_type: conditioningType,
frame_time: inputTime,
})
if (!result.ok) {
logger.warn(`Failed to extract conditioning: ${result.error.message}`)
setExtractError(result.error.message)
isExtractingRef.current = false
setIsExtracting(false)
return
}
setConditioningPreview(result.data.conditioning)
isExtractingRef.current = false
setIsExtracting(false)
}, [inputVideoPath, conditioningType, inputTime, icLoraReady, isCustom, isImage, isCatalogIcLora])
const extractTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (isCustom || isImage || isCatalogIcLora || !inputVideoPath || !icLoraReady) return
if (extractTimerRef.current) clearTimeout(extractTimerRef.current)
extractTimerRef.current = setTimeout(() => {
void extractConditioning()
}, 300)
return () => {
if (extractTimerRef.current) clearTimeout(extractTimerRef.current)
}
}, [inputTime, conditioningType, inputVideoPath, icLoraReady, extractConditioning])
useEffect(() => {
const video = inputVideoRef.current
if (!video) return
const onTime = () => setInputTime(video.currentTime)
const onSeeked = () => setInputTime(video.currentTime)
video.addEventListener('timeupdate', onTime)
video.addEventListener('seeked', onSeeked)
return () => {
video.removeEventListener('timeupdate', onTime)
video.removeEventListener('seeked', onSeeked)
}
}, [inputVideoUrl, icLoraReady, isCheckingIcLora])
const handleBrowse = useCallback(async () => {
const paths = await window.electronAPI.showOpenFileDialog(
isImage
? { title: 'Select Input Image', filters: [{ name: 'Image', extensions: ['png', 'jpg', 'jpeg', 'webp'] }] }
: { title: 'Select Driving Video', filters: [{ name: 'Video', extensions: ['mp4', 'mov', 'avi', 'webm', 'mkv'] }] },
)
if (paths && paths.length > 0) {
const filePath = paths[0]
setInputVideoPath(filePath)
setConditioningPreview(null)
setExtractError(null)
}
}, [isImage])
const handleClear = useCallback(() => {
setInputVideoPath(null)
setInputTime(0)
setSourceDims(null)
setConditioningPreview(null)
setExtractError(null)
}, [])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(false)
const acceptedAssetType = isImage ? 'image' : 'video'
const assetData = e.dataTransfer.getData('asset')
if (assetData) {
try {
const asset = JSON.parse(assetData) as { type?: string; path?: string }
if (asset.type === acceptedAssetType && asset.path) {
setInputVideoPath(asset.path)
setConditioningPreview(null)
setExtractError(null)
return
}
} catch {
// fall through
}
}
const file = e.dataTransfer.files?.[0]
if (file) {
const filePath = window.electronAPI?.getPathForFile(file)
if (filePath) {
setInputVideoPath(filePath)
setConditioningPreview(null)
setExtractError(null)
}
}
}, [isImage])
const handleBrowseReference = useCallback(async () => {
const paths = await window.electronAPI.showOpenFileDialog({
title: 'Select Reference Image',
filters: [{ name: 'Image', extensions: ['png', 'jpg', 'jpeg', 'webp'] }],
})
if (paths && paths.length > 0) {
setReferenceImagePath(paths[0])
}
}, [])
const handleClearReference = useCallback(() => {
setReferenceImagePath(null)
}, [])
const handleDropReference = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsReferenceDragOver(false)
const assetData = e.dataTransfer.getData('asset')
if (assetData) {
try {
const asset = JSON.parse(assetData) as { type?: string; path?: string }
if (asset.type === 'image' && asset.path) {
setReferenceImagePath(asset.path)
return
}
} catch {
// fall through
}
}
const file = e.dataTransfer.files?.[0]
if (file) {
const filePath = window.electronAPI?.getPathForFile(file)
if (filePath) setReferenceImagePath(filePath)
}
}, [])
// Only the built-in canny/depth flow needs the bundled preprocessing cps. Recipes
// (any input kind) and custom build their own control video, so never gate them.
const showBuiltinGate =
!isCustom && !isImage && !isCatalogIcLora && (isCheckingIcLora || !icLoraReady)
const showUnsupportedGate = showBuiltinGate && !isCheckingIcLora && !icLoraSupported
const showDownloadGate = showBuiltinGate && !showUnsupportedGate
const runningDownloadProgress =
downloadProgress?.status === 'downloading' ? downloadProgress : null
const gateItemIds = [...new Set([...(requiredIcLoraCpIds ?? []), ...(runningDownloadProgress?.all_files ?? [])])]
const gateItems = gateItemIds.map((cpId) => {
const downloaded = !requiredIcLoraCpIds.includes(cpId)
const isCompleted = runningDownloadProgress?.completed_files?.includes(cpId) ?? false
const isCurrentDownload = isDownloadingIcLora && runningDownloadProgress?.current_downloading_file === cpId
const progress = downloaded ? 100 : (isCompleted ? 100 : (isCurrentDownload ? (runningDownloadProgress?.current_file_progress ?? 0) : 0))
const status = downloaded ? 'Ready' : (isCompleted ? 'Complete' : (isCurrentDownload ? 'Downloading' : 'Missing'))
return { id: cpId, label: cpId, downloaded, progress, status }
})
return (
<div className={`bg-zinc-900 border border-zinc-800 rounded-2xl overflow-hidden flex flex-col ${fillHeight ? 'h-full min-h-0' : ''}`}>
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800 flex-shrink-0">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-amber-400" />
<span className="text-sm font-semibold text-white">IC-LoRA / Style Transfer</span>
</div>
{inputVideoUrl && (
<div className="flex items-center gap-2">
<button
onClick={handleClear}
className="p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
title="Clear video"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<button
onClick={handleBrowse}
className="p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
title="Replace video"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
{isLocalMode && (
<div className="px-3 py-2 border-b border-zinc-800 flex-shrink-0">
<button
onClick={onBrowseLibrary}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-medium transition-colors"
>
<Sparkles className="h-3.5 w-3.5 text-amber-400" /> Browse IC-LoRAs
</button>
</div>
)}
{showUnsupportedGate ? (
<div className="flex-1 flex items-center justify-center p-6 min-h-0 overflow-y-auto">
<div className="w-full max-w-xl rounded-xl border border-zinc-700 bg-zinc-800/60 p-6">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-amber-600/20 flex items-center justify-center mt-0.5">
<AlertCircle className="h-4 w-4 text-amber-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-white">Built-in control needs LTX 2.3</h3>
<p className="text-xs text-zinc-400 mt-1">
Depth and canny Union Control are not available on the active LTX 2.5 model.
Switch to an LTX 2.3 local model in Settings, or use a custom / catalog IC-LoRA.
</p>
</div>
</div>
</div>
</div>
) : showDownloadGate ? (
<div className="flex-1 flex items-center justify-center p-6 min-h-0 overflow-y-auto">
<div className="w-full max-w-xl rounded-xl border border-zinc-700 bg-zinc-800/60 p-6">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-blue-600/20 flex items-center justify-center mt-0.5">
<Download className="h-4 w-4 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-white">Download Required: IC-LoRA Resources</h3>
<p className="text-xs text-zinc-400 mt-1">
Editing is locked until all IC-LoRA preprocessing models are available locally.
</p>
</div>
</div>
<div className="mt-5 space-y-3">
{isCheckingIcLora ? (
<div className="flex items-center gap-2 text-xs text-zinc-300">
<Loader2 className="h-4 w-4 animate-spin text-blue-400" />
Checking model availability...
</div>
) : (
<>
<div className="space-y-2">
{gateItems.map(item => (
<div key={item.id} className="rounded-lg border border-zinc-700 bg-zinc-900/60 px-3 py-2">
<div className="flex items-center justify-between text-[11px] mb-1.5">
<span className="text-zinc-300">{item.label}</span>
<span className={item.downloaded ? 'text-blue-400' : 'text-zinc-500'}>
{item.status}
</span>
</div>
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full transition-all duration-300 bg-blue-500"
style={{ width: `${item.progress}%` }}
/>
</div>
<div className="mt-1 text-[10px] text-zinc-500">{item.progress}%</div>
</div>
))}
</div>
{downloadError && (
<div className="text-[11px] text-red-400">{downloadError}</div>
)}
<div className="flex items-center gap-2 pt-1">
<button
onClick={handleDownloadIcLora}
disabled={isDownloadingIcLora}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDownloadingIcLora ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
Downloading...
</>
) : (
<>
<Download className="h-3 w-3" />
{downloadError ? 'Retry Download' : 'Download Models'}
</>
)}
</button>
<button
onClick={() => { void checkIcLoraAvailability() }}
disabled={isCheckingIcLora}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-600 text-zinc-300 hover:text-white hover:border-zinc-500 text-xs transition-colors disabled:opacity-50"
>
<RefreshCw className={`h-3 w-3 ${isCheckingIcLora ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
</>
)}
</div>
</div>
</div>
) : (
<div className="flex-1 flex min-h-0 overflow-hidden">
<div className="flex-1 flex flex-col border-r border-zinc-800 min-w-0">
<div className="px-3 py-2 border-b border-zinc-800 flex items-center justify-between gap-2">
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider shrink-0">Input</span>
{inputVideoPath && (
<span className="text-[10px] text-zinc-500 truncate min-w-0">
{inputVideoPath.split(/[\\/]/).pop()}
</span>
)}
<button
onClick={handleBrowse}
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors shrink-0"
>
<Upload className="h-3 w-3" />
Import
</button>
</div>
<div
className={`flex-1 min-h-0 bg-black flex items-center justify-center relative ${!inputVideoUrl ? 'border-2 border-dashed border-zinc-700 m-3 rounded-lg' : ''} ${isDragOver ? 'border-blue-500 bg-blue-500/10' : ''}`}
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true) }}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
>
{inputVideoUrl ? (
isImage ? (
<img
src={inputVideoUrl}
alt="Input"
className="w-full h-full object-contain"
onLoad={(e) => setSourceDims({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })}
/>
) : (
<video
ref={inputVideoRef}
src={inputVideoUrl}
className="w-full h-full object-contain"
controls
onLoadedMetadata={(e) => setSourceDims({ w: e.currentTarget.videoWidth, h: e.currentTarget.videoHeight })}
/>
)
) : (
<div className="text-center p-4">
<div className="w-12 h-12 rounded-full bg-zinc-800 flex items-center justify-center mx-auto mb-2">
<Film className="h-6 w-6 text-zinc-600" />
</div>
<p className="text-zinc-400 text-xs">{isImage ? 'Drop or import an image' : isCustom ? 'Drop or import a control video' : 'Drop or import a driving video'}</p>
<button
onClick={handleBrowse}
className="mt-2 px-3 py-1.5 text-[10px] text-blue-400 border border-blue-500/30 rounded-lg hover:bg-blue-600/10 transition-colors"
>
{isImage ? 'Import Image' : 'Import Video'}
</button>
</div>
)}
</div>
</div>
{showConditioningPreview && (
<div className="flex-1 flex flex-col min-w-0">
<div className="px-3 py-2 border-b border-zinc-800 flex items-center justify-between gap-2">
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">Conditioning</span>
<button
onClick={() => { void extractConditioning() }}
disabled={!inputVideoPath || isExtracting}
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors disabled:opacity-50"
>
<RefreshCw className={`h-3 w-3 ${isExtracting ? 'animate-spin' : ''}`} />
</button>
</div>
<div className="flex-1 bg-black flex items-center justify-center min-h-0 relative">
{isExtracting && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
<Loader2 className="h-5 w-5 text-blue-400 animate-spin" />
</div>
)}
{conditioningPreview ? (
<img src={conditioningPreview} alt="Conditioning preview" className="w-full h-full object-contain" />
) : (
<div className="text-center p-4">
<p className="text-zinc-600 text-xs">
{inputVideoUrl ? 'Scrub the input video to see conditioning preview' : 'Import a video to preview conditioning'}
</p>
</div>
)}
</div>
</div>
)}
{/* Reference image column: takes the conditioning slot for catalog IC-LoRAs that opt in. */}
{allowsReferenceImage && !showConditioningPreview && (
<div className="flex-1 flex flex-col min-w-0">
<div className="px-3 py-2 border-b border-zinc-800 flex items-center justify-between gap-2">
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider shrink-0">Reference</span>
{referenceImagePath && (
<span className="text-[10px] text-zinc-500 truncate min-w-0">
{referenceImagePath.split(/[\\/]/).pop()}
</span>
)}
{referenceImagePath ? (
<div className="flex items-center gap-1 shrink-0">
<button
onClick={handleClearReference}
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors"
title="Clear reference image"
>
<Trash2 className="h-3 w-3" />
</button>
<button
onClick={handleBrowseReference}
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors"
title="Replace reference image"
>
<RefreshCw className="h-3 w-3" />
</button>
</div>
) : (
<button
onClick={handleBrowseReference}
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors shrink-0"
>
<Upload className="h-3 w-3" />
Import
</button>
)}
</div>
<div
className={`flex-1 min-h-0 bg-black flex items-center justify-center relative ${!referenceImageUrl ? 'border-2 border-dashed border-zinc-700 m-3 rounded-lg' : ''} ${isReferenceDragOver ? 'border-blue-500 bg-blue-500/10' : ''}`}
onDragOver={(e) => { e.preventDefault(); setIsReferenceDragOver(true) }}
onDragLeave={() => setIsReferenceDragOver(false)}
onDrop={handleDropReference}
>
{referenceImageUrl ? (
<img src={referenceImageUrl} alt="Reference" className="w-full h-full object-contain" />
) : (
<div className="text-center p-4">
<div className="w-12 h-12 rounded-full bg-zinc-800 flex items-center justify-center mx-auto mb-2">
<ImageIcon className="h-6 w-6 text-zinc-600" />
</div>
<p className="text-zinc-400 text-xs">Drop or import a reference image</p>
<button
onClick={handleBrowseReference}
className="mt-2 px-3 py-1.5 text-[10px] text-blue-400 border border-blue-500/30 rounded-lg hover:bg-blue-600/10 transition-colors"
>
Import Image
</button>
</div>
)}
</div>
</div>
)}
{/* Canvas editor column: takes the middle slot for outpainting (position_canvas control). */}
{showOutpaintCanvas && !showConditioningPreview && (
<div className="flex-1 flex flex-col min-w-0">
<div className="px-3 py-2 border-b border-zinc-800 flex items-center">
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">Canvas</span>
</div>
{sourceDims && outpaintPads ? (
<OutpaintCanvasEditor
sourceWidth={sourceDims.w}
sourceHeight={sourceDims.h}
value={outpaintPads}
onChange={(p) => onOutpaintPadsChange?.(p)}
/>
) : (
<div className="flex-1 bg-black flex items-center justify-center">
<p className="text-zinc-600 text-xs">Import a video to set the outpaint canvas</p>
</div>
)}
</div>
)}
{/* Output column */}
<div className="flex-1 flex flex-col border-l border-zinc-800 min-w-0">
<div className="px-3 py-2 border-b border-zinc-800 flex items-center">
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">Output</span>
</div>
<div className="flex-1 bg-black flex items-center justify-center min-h-0 relative">
{_outputVideoPath ? (
<video
src={pathToFileUrl(_outputVideoPath)}
className="w-full h-full object-contain"
controls
/>
) : isProcessing ? (
<div className="text-center p-4">
<Loader2 className="h-6 w-6 text-blue-400 animate-spin mx-auto mb-2" />
<p className="text-zinc-400 text-xs">{processingStatus || 'Generating...'}</p>
</div>
) : (
<div className="text-center p-4">
<p className="text-zinc-600 text-xs">Output video will appear here</p>
</div>
)}
</div>
</div>
</div>
)}
{extractError && (
<div className="px-4 py-3 border-t border-zinc-800 flex-shrink-0">
<div className="flex items-center gap-2 text-xs text-red-400">
<AlertCircle className="h-3.5 w-3.5 flex-shrink-0" />
<span>{extractError}</span>
</div>
</div>
)}
</div>
)
}
+139
View File
@@ -0,0 +1,139 @@
import type { ReactNode } from 'react'
import { ChevronUp } from 'lucide-react'
import { SettingsDropdown } from './SettingsDropdown'
import type { IcLoraControlsProps } from './IcLoraSettingsControls'
import type { IcLoraAudioMode } from '../hooks/use-ic-lora'
// One labelled row: caption on the left, the dropdown on the right.
function Row({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] text-zinc-500 uppercase tracking-wide shrink-0">{label}</span>
{children}
</div>
)
}
const triggerValue = (text: string) => (
<>
<span className="text-zinc-300 font-medium">{text}</span>
<ChevronUp className="h-3 w-3 text-zinc-500" />
</>
)
// Advanced IC-LoRA controls, in a side panel beside the prompt bar (so they don't push the
// Generate button off the bottom row). Reuses the shared IcLoraControlsProps bag.
export function IcLoraAdvancedPanel({
icLoraSkipStage2,
onIcLoraSkipStage2Change,
icLoraUseLoraInStage2,
onIcLoraUseLoraInStage2Change,
icLoraResolutionOptions,
icLoraResolutionKey,
onIcLoraResolutionKeyChange,
icLoraResolutionFactor,
onIcLoraResolutionFactorChange,
icLoraAudioMode,
onIcLoraAudioModeChange,
icLoraFps,
onIcLoraFpsChange,
advancedIcLoraControls,
}: IcLoraControlsProps) {
// Advanced-only knobs. (LoRA strength is a regular control — it lives in the bottom bar.)
if (!advancedIcLoraControls) return null
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/90 backdrop-blur p-1.5 flex flex-col gap-0.5 w-40 text-xs">
<span className="text-[9px] font-semibold text-zinc-500 uppercase tracking-wider px-1">Advanced</span>
<Row label="Stage 2">
<SettingsDropdown
title="STAGE 2 REFINE"
value={icLoraSkipStage2 ? 'skip' : 'keep'}
onChange={(v) => onIcLoraSkipStage2Change?.(v === 'skip')}
options={[
{ value: 'keep', label: 'On (two-stage)' },
{ value: 'skip', label: 'Off (stage 1 only)' },
]}
trigger={triggerValue(icLoraSkipStage2 ? 'Off' : 'On')}
/>
</Row>
{/* Always shown. Only takes effect when Stage 2 runs (Stage 2 = On); it's a
no-op when Stage 2 is skipped, but kept visible for discoverability. */}
<Row label="LoRA in S2">
<SettingsDropdown
title="IC-LORA IN STAGE 2"
value={icLoraUseLoraInStage2 ? 'on' : 'off'}
onChange={(v) => onIcLoraUseLoraInStage2Change?.(v === 'on')}
options={[
{ value: 'off', label: 'Off (refine from prompt)' },
{ value: 'on', label: 'On (keep effect, source res)' },
]}
trigger={triggerValue(icLoraUseLoraInStage2 ? 'On' : 'Off')}
/>
</Row>
{!icLoraSkipStage2 && icLoraUseLoraInStage2 && (icLoraResolutionOptions?.length ?? 0) > 1 && (
<Row label="Res">
<SettingsDropdown
title="RESOLUTION"
value={icLoraResolutionKey ?? 'original'}
onChange={(v) => onIcLoraResolutionKeyChange?.(v)}
options={icLoraResolutionOptions!.map((o) => ({ value: o.key, label: o.label }))}
trigger={triggerValue(
(icLoraResolutionOptions!.find((o) => o.key === icLoraResolutionKey)?.label ?? 'Original').split(' ')[0],
)}
/>
</Row>
)}
{icLoraSkipStage2 && (
<Row label="Res">
<SettingsDropdown
title="RES FACTOR"
value={String(icLoraResolutionFactor ?? 2.0)}
onChange={(v) => onIcLoraResolutionFactorChange?.(parseFloat(v))}
options={[
// 0 is the "source dimensions" sentinel — output matches the input
// resolution and the multiplier is ignored (see ic_lora_handler).
{ value: '0', label: 'Source dimensions' },
{ value: '1', label: '1.00 (half)' },
{ value: '1.25', label: '1.25' },
{ value: '1.5', label: '1.50' },
{ value: '1.75', label: '1.75' },
{ value: '2', label: '2.00 (native)' },
]}
trigger={triggerValue(
icLoraResolutionFactor === 0 ? 'Source' : `×${(icLoraResolutionFactor ?? 2.0).toFixed(2)}`,
)}
/>
</Row>
)}
<Row label="Audio">
<SettingsDropdown
title="AUDIO"
value={icLoraAudioMode ?? 'generated'}
onChange={(v) => onIcLoraAudioModeChange?.(v as IcLoraAudioMode)}
options={[
{ value: 'generated', label: 'Generated' },
{ value: 'source', label: 'Source' },
{ value: 'off', label: 'Off' },
]}
trigger={triggerValue((icLoraAudioMode ?? 'generated').replace(/^\w/, (c: string) => c.toUpperCase()))}
/>
</Row>
<Row label="FPS">
<SettingsDropdown
title="FPS"
value={icLoraFps == null ? 'source' : String(icLoraFps)}
onChange={(v) => onIcLoraFpsChange?.(v === 'source' ? null : parseFloat(v))}
options={[
{ value: 'source', label: 'Source' },
{ value: '24', label: '24 fps' },
{ value: '16', label: '16 fps' },
{ value: '12', label: '12 fps' },
{ value: '8', label: '8 fps' },
]}
trigger={triggerValue(icLoraFps == null ? 'Src' : String(icLoraFps))}
/>
</Row>
</div>
)
}
@@ -0,0 +1,188 @@
import { Fragment } from 'react'
import { ChevronUp } from 'lucide-react'
import { SettingsDropdown } from './SettingsDropdown'
import { Tooltip } from './ui/tooltip'
import type { ApiSuccessOf } from '../lib/api-client'
import type { components } from '../generated/backend-openapi'
import type { ICLoraConditioningType } from './ICLoraPanel'
import type { IcLoraAudioMode } from '../hooks/use-ic-lora'
import type { ResolutionOption } from '../lib/video-resolution'
type CatalogControl = components['schemas']['IcLoraControl']
// Short hover/focus help per IC-LoRA control. The catalog-driven controls are keyed by control id.
const CONTROL_TOOLTIPS: Record<string, string> = {
condStrength: 'How strongly the conditioning (edges/depth) constrains the result. Higher follows the control signal more closely.',
loraStrength: 'How strongly the IC-LoRA weights are applied. Lower is subtler, higher is stronger.',
duration: 'Length of the generated clip.',
}
// A catalog control's display label for a given option: explicit value_labels win, else value + unit.
function controlOptionLabel(control: CatalogControl, option: number | string): string {
return control.value_labels?.[String(option)] ?? `${option}${control.unit ?? ''}`
}
// The IC-LoRA control row in the prompt bar: conditioning-type selector + per-mode knobs
// (strength, duration, LoRA strength, and the advanced stage-2 / resolution / audio / fps).
export interface IcLoraControlsProps {
icLoraCondType?: ICLoraConditioningType
icLoraSelectorValue?: string
icLoraSelectorOptions?: { value: string; label: string }[]
onIcLoraSelectorChange?: (value: string) => void
icLoraStrength?: number
onIcLoraStrengthChange?: (strength: number) => void
availableIcLoras?: ApiSuccessOf<'listModels'>['models']
icLoraCustomRef?: string | null
onIcLoraCustomRefChange?: (ref: string | null) => void
icLoraSkipStage2?: boolean
onIcLoraSkipStage2Change?: (skip: boolean) => void
icLoraUseLoraInStage2?: boolean
onIcLoraUseLoraInStage2Change?: (use: boolean) => void
// Resolution tiers for the use_lora_in_stage_2 path (empty until source media loads).
icLoraResolutionOptions?: ResolutionOption[]
icLoraResolutionKey?: string
onIcLoraResolutionKeyChange?: (key: string) => void
icLoraResolutionFactor?: number
onIcLoraResolutionFactorChange?: (factor: number) => void
icLoraAudioMode?: IcLoraAudioMode
onIcLoraAudioModeChange?: (mode: IcLoraAudioMode) => void
icLoraLoraStrength?: number
onIcLoraLoraStrengthChange?: (strength: number) => void
icLoraFps?: number | null
onIcLoraFpsChange?: (fps: number | null) => void
isCatalogIcLora?: boolean
advancedIcLoraControls?: boolean
// Catalog-declared controls for the selected IC-LoRA, rendered generically by id + kind.
controls?: CatalogControl[]
controlValues?: Record<string, number | string>
onControlChange?: (id: string, value: number | string) => void
}
export function IcLoraSettingsControls({
icLoraCondType,
icLoraSelectorValue,
icLoraSelectorOptions,
onIcLoraSelectorChange,
icLoraStrength,
onIcLoraStrengthChange,
availableIcLoras,
icLoraCustomRef,
onIcLoraCustomRefChange,
icLoraLoraStrength,
onIcLoraLoraStrengthChange,
isCatalogIcLora,
controls,
controlValues,
onControlChange,
}: IcLoraControlsProps) {
return (
<>
{/* Unified IC-LoRA selector: canny | depth | [catalog IC-LoRAs] | custom. No tooltip:
the options are self-explanatory and the copy was wrong for catalog recipes. */}
<SettingsDropdown
title="CONDITIONING TYPE"
value={icLoraSelectorValue ?? 'canny'}
onChange={(v) => onIcLoraSelectorChange?.(v)}
options={icLoraSelectorOptions ?? []}
trigger={
<>
<span className="text-zinc-300 font-medium">{(icLoraSelectorOptions ?? []).find(o => o.value === icLoraSelectorValue)?.label ?? 'Canny Edges'}</span>
<ChevronUp className="h-3 w-3 text-zinc-500" />
</>
}
/>
{!isCatalogIcLora && icLoraCondType === 'custom' && (
<>
<div className="w-px h-4 bg-zinc-700 mx-0.5" />
<SettingsDropdown
title="IC-LORA"
value={icLoraCustomRef ?? ''}
onChange={(v) => onIcLoraCustomRefChange?.(v || null)}
options={(availableIcLoras ?? []).map(m => ({ value: m.path, label: m.name }))}
trigger={
<>
<span className="text-zinc-300 font-medium truncate max-w-[160px]">
{availableIcLoras?.find(m => m.path === icLoraCustomRef)?.name
?? (availableIcLoras?.length ? 'Select IC-LoRA' : 'No IC-LoRAs found')}
</span>
<ChevronUp className="h-3 w-3 text-zinc-500" />
</>
}
/>
</>
)}
{!isCatalogIcLora && (
<>
<div className="w-px h-4 bg-zinc-700 mx-0.5" />
<SettingsDropdown
title="STRENGTH"
tooltip={CONTROL_TOOLTIPS.condStrength}
value={String(icLoraStrength ?? 1.0)}
onChange={(v) => onIcLoraStrengthChange?.(parseFloat(v))}
options={[
{ value: '0.5', label: '0.50' },
{ value: '0.75', label: '0.75' },
{ value: '1', label: '1.00' },
{ value: '1.25', label: '1.25' },
{ value: '1.5', label: '1.50' },
{ value: '2', label: '2.00' },
]}
trigger={
<>
<span className="text-zinc-500 text-[10px]">STR</span>
<span className="text-zinc-300 font-medium">{(icLoraStrength ?? 1.0).toFixed(2)}</span>
<ChevronUp className="h-3 w-3 text-zinc-500" />
</>
}
/>
</>
)}
{/* Catalog-declared option controls (duration, …) rendered generically as dropdowns.
Structured controls (position_canvas) carry no options and render elsewhere — skip them. */}
{isCatalogIcLora && (controls ?? []).filter((c) => c.options != null).map((c) => {
const current = controlValues?.[c.id] ?? c.default ?? ''
const title = c.label.toUpperCase()
return (
<Fragment key={c.id}>
<div className="w-px h-4 bg-zinc-700 mx-0.5" />
<SettingsDropdown
title={title}
tooltip={CONTROL_TOOLTIPS[c.id]}
value={String(current)}
onChange={(v) => onControlChange?.(c.id, c.kind === 'int' ? parseInt(v, 10) : v)}
options={(c.options as (number | string)[]).map((o) => ({ value: String(o), label: controlOptionLabel(c, o) }))}
trigger={
<>
<span className="text-zinc-500 text-[10px]">{title}</span>
<span className="text-zinc-300 font-medium">{controlOptionLabel(c, current)}</span>
<ChevronUp className="h-3 w-3 text-zinc-500" />
</>
}
/>
</Fragment>
)
})}
{/* LoRA strength: a regular control for every IC-LoRA mode (canny/depth/custom/catalog).
It scales the adapter merge weight, so it always has an effect. (The advanced knobs —
S2/Res/Audio/FPS — live in IcLoraAdvancedPanel beside the prompt bar.) A continuous
slider (like the plain-LoRA strength bar) rather than a dropdown, for finer control. */}
<div className="w-px h-4 bg-zinc-700 mx-0.5" />
<Tooltip content={CONTROL_TOOLTIPS.loraStrength}>
<div className="flex items-center gap-1.5 px-2 py-1.5">
<span className="text-zinc-500 text-[10px]">LORA</span>
<input
type="range"
min={0}
max={2}
step={0.05}
value={icLoraLoraStrength ?? 1.0}
onChange={(e) => onIcLoraLoraStrengthChange?.(parseFloat(e.target.value))}
className="w-16 h-1 accent-white cursor-pointer"
aria-label="LoRA strength"
/>
<span className="text-zinc-300 font-medium w-9 text-right">{(icLoraLoraStrength ?? 1.0).toFixed(2)}</span>
</div>
</Tooltip>
</>
)
}
+769
View File
@@ -0,0 +1,769 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { logger } from '../lib/logger'
import { addGenericAssetToProject, addVisualAssetToProject } from '../lib/asset-copy'
import {
X, FileVideo, FileAudio, Image, Check, AlertTriangle,
FolderOpen, RefreshCw, Loader2, FileText, Link2,
ChevronDown, ChevronRight, Upload
} from 'lucide-react'
import type { ParsedTimeline, ParsedMediaRef } from '../lib/timeline-import'
import { parseTimelineXml } from '../lib/timeline-import'
import { selectShowImportTimelineModal } from '../views/editor/editor-selectors'
import { useEditorActions, useEditorStore } from '../views/editor/editor-store'
interface ImportTimelineModalProps {
projectId: string | null
}
type ImportStep = 'select' | 'parsing' | 'relink' | 'error'
type MediaImportStatus = 'missing' | 'ready' | 'copying' | 'copied' | 'skipped' | 'error'
interface ImportProgressState {
total: number
completed: number
skipped: number
currentName: string
}
function getFilenameFromPath(filePath: string): string {
const parts = filePath.split(/[/\\]/)
return parts[parts.length - 1] || ''
}
export function ImportTimelineModal({ projectId }: ImportTimelineModalProps) {
const { closeImportTimelineModal, importParsedTimeline } = useEditorActions()
const isOpen = useEditorStore(selectShowImportTimelineModal)
const [step, setStep] = useState<ImportStep>('select')
const [parsedTimeline, setParsedTimeline] = useState<ParsedTimeline | null>(null)
const [mediaRefs, setMediaRefs] = useState<ParsedMediaRef[]>([])
const [mediaStatus, setMediaStatus] = useState<Record<string, MediaImportStatus>>({})
const [error, setError] = useState<string>('')
const [importError, setImportError] = useState<string>('')
const [expandedInfo, setExpandedInfo] = useState(true)
const [expandedMedia, setExpandedMedia] = useState(true)
const [isChecking, setIsChecking] = useState(false)
const [isSearching, setIsSearching] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const [importProgress, setImportProgress] = useState<ImportProgressState>({
total: 0,
completed: 0,
skipped: 0,
currentName: '',
})
const fileInputRef = useRef<HTMLInputElement>(null)
const closeModal = useCallback(() => {
closeImportTimelineModal()
}, [closeImportTimelineModal])
// Reset when modal opens
useEffect(() => {
if (!isOpen) return
setStep('select')
setParsedTimeline(null)
setMediaRefs([])
setMediaStatus({})
setError('')
setImportError('')
setIsChecking(false)
setIsSearching(false)
setIsImporting(false)
setImportProgress({ total: 0, completed: 0, skipped: 0, currentName: '' })
}, [isOpen])
const applyAvailabilityToStatus = useCallback((refs: ParsedMediaRef[], availabilityById: Record<string, boolean>) => {
const nextStatus: Record<string, MediaImportStatus> = {}
for (const ref of refs) {
nextStatus[ref.id] = availabilityById[ref.id] ? 'ready' : 'missing'
}
setMediaStatus(nextStatus)
}, [])
// Check file existence for all media refs
const checkMediaFiles = useCallback(async (refs: ParsedMediaRef[]): Promise<Record<string, boolean>> => {
const availabilityById: Record<string, boolean> = {}
if (!window.electronAPI?.checkFilesExist) {
refs.forEach(ref => {
availabilityById[ref.id] = false
})
return availabilityById
}
setIsChecking(true)
try {
const uniquePaths = Array.from(new Set(
refs
.map(r => r.path?.trim())
.filter((p): p is string => Boolean(p))
))
const results = await window.electronAPI.checkFilesExist({ filePaths: uniquePaths })
refs.forEach(ref => {
const path = ref.path?.trim()
availabilityById[ref.id] = path ? (results[path] || false) : false
})
return availabilityById
} catch (err) {
logger.error(`Error checking files: ${err}`)
refs.forEach(ref => {
availabilityById[ref.id] = false
})
return availabilityById
} finally {
setIsChecking(false)
}
}, [])
// Handle file selection
const handleFileSelected = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const filename = file.name
const ext = filename.split('.').pop()?.toLowerCase() || ''
// Check for AAF
if (ext === 'aaf') {
setError(
'AAF files cannot be imported directly.\n\n' +
'Please export your timeline as FCP 7 XML from your editing software:\n\n' +
' Premiere Pro: File → Export → Final Cut Pro XML\n' +
' DaVinci Resolve: File → Export Timeline → FCP 7 XML\n' +
' Avid Media Composer: File → Export → FCP 7 XML'
)
setStep('error')
return
}
setStep('parsing')
setImportError('')
try {
const content = await file.text()
const timeline = parseTimelineXml(content, filename)
if (!timeline) {
throw new Error('Could not parse timeline from file')
}
setParsedTimeline(timeline)
setMediaRefs(timeline.mediaRefs)
const availabilityById = await checkMediaFiles(timeline.mediaRefs)
applyAvailabilityToStatus(timeline.mediaRefs, availabilityById)
setStep('relink')
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setStep('error')
}
// Reset input
if (fileInputRef.current) fileInputRef.current.value = ''
}, [applyAvailabilityToStatus, checkMediaFiles])
// Relink a single media file
const handleRelinkFile = useCallback(async (mediaRefId: string) => {
if (isImporting || !window.electronAPI?.showOpenFileDialog) return
const filePaths = await window.electronAPI.showOpenFileDialog({
title: 'Relink Media File',
filters: [
{ name: 'Media Files', extensions: ['mp4', 'mov', 'avi', 'mkv', 'mxf', 'mp3', 'wav', 'aac', 'flac', 'jpg', 'jpeg', 'png', 'tiff', 'exr', 'dpx'] },
{ name: 'All Files', extensions: ['*'] },
],
})
if (!filePaths || filePaths.length === 0) return
const newPath = filePaths[0]
setImportError('')
setMediaRefs(prev => prev.map(r =>
r.id === mediaRefId
? { ...r, path: newPath }
: r
))
setMediaStatus(prev => ({ ...prev, [mediaRefId]: 'ready' }))
}, [isImporting])
// Search a directory for all missing files
const handleSearchDirectory = useCallback(async () => {
if (isImporting || !window.electronAPI?.showOpenDirectoryDialog) return
const dir = await window.electronAPI.showOpenDirectoryDialog({ title: 'Select folder to search for media files' })
if (!dir) return
setIsSearching(true)
setImportError('')
try {
const missingRefs = mediaRefs.filter(r => mediaStatus[r.id] === 'missing')
if (missingRefs.length === 0) return
// Build list of filenames to search for
const filenames: string[] = []
for (const ref of missingRefs) {
const filename = ref.name || getFilenameFromPath(ref.path)
if (filename) filenames.push(filename)
}
if (filenames.length === 0) return
// Use recursive directory search (searches subdirectories up to 10 levels deep)
if (window.electronAPI.searchDirectoryForFiles) {
const results = await window.electronAPI.searchDirectoryForFiles({ directory: dir, filenames })
// results is { "filename.mp4" (lowercase): "C:\\full\\path\\filename.mp4" }
setMediaRefs(prev => prev.map(ref => {
if (mediaStatus[ref.id] !== 'missing') return ref
const filename = ref.name || getFilenameFromPath(ref.path)
const foundPath = filename ? results[filename.toLowerCase()] : undefined
if (foundPath) {
return { ...ref, path: foundPath }
}
return ref
}))
setMediaStatus(prev => {
const next = { ...prev }
for (const ref of missingRefs) {
const filename = ref.name || getFilenameFromPath(ref.path)
const foundPath = filename ? results[filename.toLowerCase()] : undefined
if (foundPath) {
next[ref.id] = 'ready'
}
}
return next
})
} else {
// Fallback: check direct paths only (no recursive search)
const searchPaths: string[] = []
for (const filename of filenames) {
const separator = dir.includes('\\') ? '\\' : '/'
searchPaths.push(`${dir}${separator}${filename}`)
}
const results = await window.electronAPI.checkFilesExist({ filePaths: searchPaths })
setMediaRefs(prev => prev.map(ref => {
if (mediaStatus[ref.id] !== 'missing') return ref
const filename = ref.name || getFilenameFromPath(ref.path)
const separator = dir.includes('\\') ? '\\' : '/'
const testPath = filename ? `${dir}${separator}${filename}` : ''
if (testPath && results[testPath]) {
return { ...ref, path: testPath }
}
return ref
}))
setMediaStatus(prev => {
const next = { ...prev }
for (const ref of missingRefs) {
const filename = ref.name || getFilenameFromPath(ref.path)
const separator = dir.includes('\\') ? '\\' : '/'
const testPath = filename ? `${dir}${separator}${filename}` : ''
if (testPath && results[testPath]) {
next[ref.id] = 'ready'
}
}
return next
})
}
} catch (err) {
logger.error(`Error searching directory: ${err}`)
} finally {
setIsSearching(false)
}
}, [isImporting, mediaRefs, mediaStatus])
// Recheck all paths
const handleRecheckAll = useCallback(async () => {
if (isImporting) return
const availabilityById = await checkMediaFiles(mediaRefs)
applyAvailabilityToStatus(mediaRefs, availabilityById)
}, [applyAvailabilityToStatus, checkMediaFiles, isImporting, mediaRefs])
// Confirm import
const handleConfirmImport = useCallback(async () => {
if (!parsedTimeline || isImporting) return
if (!projectId) {
setImportError('Cannot import timeline: no active project.')
return
}
setImportError('')
setIsImporting(true)
const refsToImport = mediaRefs.map(ref => ({ ...ref }))
const copiedAssetBySource = new Map<string, {
path: string
bigThumbnailPath?: string
smallThumbnailPath?: string
width?: number
height?: number
}>()
const total = refsToImport.length
let completed = 0
let skipped = 0
setImportProgress({ total, completed: 0, skipped: 0, currentName: '' })
try {
for (const ref of refsToImport) {
const sourcePath = ref.path?.trim() || ''
const displayName = ref.name || getFilenameFromPath(sourcePath) || 'Unnamed media'
const currentStatus = mediaStatus[ref.id] || 'missing'
setImportProgress({
total,
completed,
skipped,
currentName: displayName,
})
if (!sourcePath || currentStatus === 'missing') {
skipped += 1
completed += 1
setMediaStatus(prev => ({ ...prev, [ref.id]: 'skipped' }))
setImportProgress({
total,
completed,
skipped,
currentName: displayName,
})
continue
}
const existingCopiedAsset = copiedAssetBySource.get(sourcePath)
if (existingCopiedAsset) {
ref.path = existingCopiedAsset.path
ref.bigThumbnailPath = existingCopiedAsset.bigThumbnailPath
ref.smallThumbnailPath = existingCopiedAsset.smallThumbnailPath
ref.width = existingCopiedAsset.width
ref.height = existingCopiedAsset.height
completed += 1
setMediaStatus(prev => ({ ...prev, [ref.id]: 'copied' }))
setMediaRefs(prev => prev.map(item => (
item.id === ref.id
? {
...item,
path: existingCopiedAsset.path,
bigThumbnailPath: existingCopiedAsset.bigThumbnailPath,
smallThumbnailPath: existingCopiedAsset.smallThumbnailPath,
width: existingCopiedAsset.width,
height: existingCopiedAsset.height,
}
: item
)))
setImportProgress({
total,
completed,
skipped,
currentName: displayName,
})
continue
}
setMediaStatus(prev => ({ ...prev, [ref.id]: 'copying' }))
let copiedAsset: {
path: string
bigThumbnailPath?: string
smallThumbnailPath?: string
width?: number
height?: number
} | null = null
if (ref.type === 'video' || ref.type === 'image') {
const copied = await addVisualAssetToProject(sourcePath, projectId, ref.type)
if (!copied) {
setMediaStatus(prev => ({ ...prev, [ref.id]: 'error' }))
throw new Error(`Failed to copy media into project assets: ${displayName}`)
}
copiedAsset = copied
} else {
const copied = await addGenericAssetToProject(sourcePath, projectId)
if (!copied?.path) {
setMediaStatus(prev => ({ ...prev, [ref.id]: 'error' }))
throw new Error(`Failed to copy media into project assets: ${displayName}`)
}
copiedAsset = { path: copied.path }
}
ref.path = copiedAsset.path
ref.bigThumbnailPath = copiedAsset.bigThumbnailPath
ref.smallThumbnailPath = copiedAsset.smallThumbnailPath
ref.width = copiedAsset.width
ref.height = copiedAsset.height
copiedAssetBySource.set(sourcePath, copiedAsset)
completed += 1
setMediaStatus(prev => ({ ...prev, [ref.id]: 'copied' }))
setMediaRefs(prev => prev.map(item => (
item.id === ref.id
? {
...item,
path: copiedAsset.path,
bigThumbnailPath: copiedAsset.bigThumbnailPath,
smallThumbnailPath: copiedAsset.smallThumbnailPath,
width: copiedAsset.width,
height: copiedAsset.height,
}
: item
)))
setImportProgress({
total,
completed,
skipped,
currentName: displayName,
})
}
// Update timeline refs with copied/updated paths
const updatedTimeline: ParsedTimeline = {
...parsedTimeline,
mediaRefs: refsToImport,
}
importParsedTimeline(updatedTimeline)
closeModal()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setImportError(message)
logger.error(`Import timeline copy failed: ${message}`)
} finally {
setIsImporting(false)
}
}, [closeModal, importParsedTimeline, isImporting, mediaRefs, mediaStatus, parsedTimeline, projectId])
const readyCount = mediaRefs.filter(r => (mediaStatus[r.id] || 'missing') !== 'missing').length
const totalCount = mediaRefs.length
const allFound = readyCount === totalCount && totalCount > 0
const interactionDisabled = isImporting
const importPercent = importProgress.total > 0 ? (importProgress.completed / importProgress.total) * 100 : 0
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-[680px] max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-blue-600/20 flex items-center justify-center">
<Upload className="h-4 w-4 text-blue-400" />
</div>
<div>
<h2 className="text-base font-semibold text-white">Import Timeline</h2>
<p className="text-[11px] text-zinc-500">
Premiere Pro XML, DaVinci Resolve XML, Final Cut Pro XML/FCPXML
</p>
</div>
</div>
<button
onClick={closeModal}
disabled={interactionDisabled}
className="p-1.5 rounded-lg hover:bg-zinc-800 text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className={`flex-1 overflow-auto p-6 ${interactionDisabled ? 'pointer-events-none opacity-60' : ''}`}>
{/* Step 1: File Selection */}
{step === 'select' && (
<div className="space-y-6">
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-zinc-700 hover:border-blue-500/50 rounded-xl p-10 text-center cursor-pointer transition-colors group"
>
<div className="w-14 h-14 rounded-full bg-zinc-800 group-hover:bg-blue-900/30 flex items-center justify-center mx-auto mb-4 transition-colors">
<FileText className="h-7 w-7 text-zinc-500 group-hover:text-blue-400 transition-colors" />
</div>
<p className="text-sm text-zinc-300 font-medium mb-1">Click to select timeline file</p>
<p className="text-xs text-zinc-600">Supports .xml (FCP 7 XML), .fcpxml</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".xml,.fcpxml,.aaf"
onChange={handleFileSelected}
className="hidden"
/>
<div className="bg-zinc-800/50 rounded-lg p-4 space-y-2">
<h4 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">How to export from your NLE:</h4>
<div className="space-y-1.5 text-[11px] text-zinc-500">
<p><span className="text-blue-400 font-medium">Premiere Pro:</span> File → Export → Final Cut Pro XML</p>
<p><span className="text-orange-400 font-medium">DaVinci Resolve:</span> File → Export Timeline → FCP 7 XML (.xml)</p>
<p><span className="text-blue-400 font-medium">Final Cut Pro:</span> File → Export XML</p>
<p className="text-zinc-600 pt-1 border-t border-zinc-700/50 mt-2">
AAF files are binary and cannot be imported directly. Please export as XML instead.
</p>
</div>
</div>
</div>
)}
{/* Parsing indicator */}
{step === 'parsing' && (
<div className="flex flex-col items-center justify-center py-16">
<Loader2 className="h-8 w-8 text-blue-400 animate-spin mb-4" />
<p className="text-sm text-zinc-400">Parsing timeline...</p>
</div>
)}
{/* Error */}
{step === 'error' && (
<div className="space-y-4">
<div className="bg-red-900/20 border border-red-800/30 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-red-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-300 mb-1">Import Error</p>
<p className="text-xs text-red-400/80 whitespace-pre-wrap">{error}</p>
</div>
</div>
</div>
<button
onClick={() => { setStep('select'); setError('') }}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm hover:bg-zinc-700 transition-colors"
>
Try another file
</button>
</div>
)}
{/* Step 2: Relink media */}
{step === 'relink' && parsedTimeline && (
<div className="space-y-4">
{/* Timeline info */}
<div>
<button
onClick={() => setExpandedInfo(!expandedInfo)}
className="flex items-center gap-2 text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-2 hover:text-zinc-300"
>
{expandedInfo ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
Timeline Info
</button>
{expandedInfo && (
<div className="bg-zinc-800/50 rounded-lg p-3 grid grid-cols-2 gap-x-6 gap-y-1.5 text-[11px]">
<div className="flex justify-between">
<span className="text-zinc-500">Name:</span>
<span className="text-white font-medium">{parsedTimeline.name}</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">Format:</span>
<span className="text-zinc-300">
{parsedTimeline.format === 'fcp7xml' ? 'FCP 7 XML' : parsedTimeline.format === 'fcpxml' ? 'FCPXML' : parsedTimeline.format}
</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">FPS:</span>
<span className="text-zinc-300">{parsedTimeline.fps.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">Duration:</span>
<span className="text-zinc-300">{parsedTimeline.duration.toFixed(1)}s</span>
</div>
{parsedTimeline.width && parsedTimeline.height && (
<div className="flex justify-between">
<span className="text-zinc-500">Resolution:</span>
<span className="text-zinc-300">{parsedTimeline.width}x{parsedTimeline.height}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-zinc-500">Clips:</span>
<span className="text-zinc-300">{parsedTimeline.clips.length}</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">Video Tracks:</span>
<span className="text-zinc-300">{parsedTimeline.videoTrackCount}</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">Audio Tracks:</span>
<span className="text-zinc-300">{parsedTimeline.audioTrackCount}</span>
</div>
</div>
)}
</div>
{/* Media files */}
<div>
<div className="flex items-center justify-between mb-2">
<button
onClick={() => setExpandedMedia(!expandedMedia)}
className="flex items-center gap-2 text-xs font-semibold text-zinc-400 uppercase tracking-wider hover:text-zinc-300"
>
{expandedMedia ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
Media Files ({readyCount}/{totalCount} linked)
</button>
<div className="flex items-center gap-1.5">
<button
onClick={handleSearchDirectory}
disabled={isSearching || allFound || interactionDisabled}
className="px-2.5 py-1 rounded-md bg-zinc-800 text-zinc-400 text-[10px] hover:bg-zinc-700 hover:text-zinc-300 flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
title="Search a folder for missing media"
>
{isSearching ? <Loader2 className="h-3 w-3 animate-spin" /> : <FolderOpen className="h-3 w-3" />}
Search Folder
</button>
<button
onClick={handleRecheckAll}
disabled={isChecking || interactionDisabled}
className="px-2.5 py-1 rounded-md bg-zinc-800 text-zinc-400 text-[10px] hover:bg-zinc-700 hover:text-zinc-300 flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
title="Recheck all file paths"
>
{isChecking ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
Recheck
</button>
</div>
</div>
{/* Availability bar */}
<div className="mb-2">
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ${allFound ? 'bg-green-500' : readyCount > 0 ? 'bg-amber-500' : 'bg-red-500'}`}
style={{ width: `${totalCount > 0 ? (readyCount / totalCount) * 100 : 0}%` }}
/>
</div>
</div>
{expandedMedia && (
<div className="space-y-1 max-h-[300px] overflow-auto rounded-lg border border-zinc-800">
{mediaRefs.map((ref, i) => {
const TypeIcon = ref.type === 'video' ? FileVideo : ref.type === 'audio' ? FileAudio : Image
const status = mediaStatus[ref.id] || 'missing'
const isPositive = status === 'ready' || status === 'copied'
const isMissing = status === 'missing'
const isCopying = status === 'copying'
const isSkipped = status === 'skipped'
const isErrored = status === 'error'
return (
<div
key={ref.id}
className={`flex items-center gap-2 px-3 py-2 text-[11px] ${i % 2 === 0 ? 'bg-zinc-800/30' : 'bg-zinc-900/30'}`}
>
<TypeIcon className={`h-3.5 w-3.5 flex-shrink-0 ${
ref.type === 'video' ? 'text-blue-400' : ref.type === 'audio' ? 'text-green-400' : 'text-blue-400'
}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
{isCopying ? (
<Loader2 className="h-3 w-3 text-blue-400 flex-shrink-0 animate-spin" />
) : isPositive ? (
<Check className="h-3 w-3 text-green-400 flex-shrink-0" />
) : isSkipped ? (
<AlertTriangle className="h-3 w-3 text-amber-400 flex-shrink-0" />
) : (
<AlertTriangle className={`h-3 w-3 flex-shrink-0 ${isErrored ? 'text-red-400' : 'text-red-400'}`} />
)}
<span className={`truncate font-medium ${
isPositive ? 'text-zinc-300' : isSkipped ? 'text-amber-300' : 'text-red-300'
}`}>
{ref.name}
</span>
</div>
<p className="text-[9px] text-zinc-600 truncate mt-0.5">
{ref.path || '(no path)'}
</p>
</div>
{isMissing && (
<button
onClick={() => handleRelinkFile(ref.id)}
disabled={interactionDisabled}
className="flex-shrink-0 px-2 py-1 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300 text-[10px] flex items-center gap-1 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title="Relink this file"
>
<Link2 className="h-3 w-3" />
Relink
</button>
)}
</div>
)
})}
{mediaRefs.length === 0 && (
<div className="p-4 text-center text-xs text-zinc-600">
No media files referenced in this timeline.
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
{/* Footer */}
{step === 'relink' && (
<>
<div className={`px-6 py-4 border-t border-zinc-800 flex items-center justify-between bg-zinc-900 ${interactionDisabled ? 'pointer-events-none opacity-60' : ''}`}>
<div className="text-[11px] text-zinc-500">
{importError && (
<span className="text-red-400">{importError}</span>
)}
{!importError && !allFound && totalCount > 0 && (
<span className="text-amber-400">
{totalCount - readyCount} missing file{totalCount - readyCount !== 1 ? 's' : ''} — clips with missing media will be placeholders
</span>
)}
{!importError && allFound && totalCount > 0 && (
<span className="text-green-400">All media files found</span>
)}
{!importError && totalCount === 0 && (
<span>No media references to link</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={closeModal}
disabled={interactionDisabled}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm hover:bg-zinc-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
onClick={handleConfirmImport}
disabled={interactionDisabled}
className="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-500 transition-colors font-medium flex items-center gap-2 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Upload className="h-3.5 w-3.5" />
Import Timeline
</button>
</div>
</div>
{isImporting && (
<div className="px-6 py-3 border-t border-zinc-800 bg-zinc-900/95">
<div className="flex items-center justify-between mb-1.5 text-[11px]">
<span className="text-blue-300 flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />
Copying media {importProgress.completed}/{importProgress.total}
</span>
<span className="text-zinc-500">
{importProgress.skipped} skipped
</span>
</div>
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full bg-blue-500 transition-all duration-200"
style={{ width: `${importPercent}%` }}
/>
</div>
{importProgress.currentName && (
<p className="text-[10px] text-zinc-500 truncate mt-1.5">
{importProgress.currentName}
</p>
)}
</div>
)}
</>
)}
</div>
</div>
)
}
@@ -0,0 +1,793 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { X, Search, Keyboard, RotateCcw, Save, AlertTriangle, ChevronDown, GripVertical, Trash2 } from 'lucide-react'
import { useKeyboardShortcuts } from '../contexts/KeyboardShortcutsContext'
import {
ACTION_REGISTRY,
ActionId,
KeyCombo,
formatKeyCombo,
findConflicts,
ActionDefinition,
} from '../lib/keyboard-shortcuts'
// ── Visual keyboard layout (US QWERTY) ──
// Each key: { id: lowercase key id matching KeyCombo.key, label: display text, w: width units (1 = standard key) }
interface KBKey { id: string; label: string; w: number }
const KB_ROWS: KBKey[][] = [
// Row 0: Number row
[
{ id: '`', label: '`', w: 1 },
{ id: '1', label: '1', w: 1 },
{ id: '2', label: '2', w: 1 },
{ id: '3', label: '3', w: 1 },
{ id: '4', label: '4', w: 1 },
{ id: '5', label: '5', w: 1 },
{ id: '6', label: '6', w: 1 },
{ id: '7', label: '7', w: 1 },
{ id: '8', label: '8', w: 1 },
{ id: '9', label: '9', w: 1 },
{ id: '0', label: '0', w: 1 },
{ id: '-', label: '-', w: 1 },
{ id: '=', label: '=', w: 1 },
{ id: 'backspace', label: 'Bksp', w: 2 },
],
// Row 1: QWERTY
[
{ id: 'tab', label: 'Tab', w: 1.5 },
{ id: 'q', label: 'Q', w: 1 },
{ id: 'w', label: 'W', w: 1 },
{ id: 'e', label: 'E', w: 1 },
{ id: 'r', label: 'R', w: 1 },
{ id: 't', label: 'T', w: 1 },
{ id: 'y', label: 'Y', w: 1 },
{ id: 'u', label: 'U', w: 1 },
{ id: 'i', label: 'I', w: 1 },
{ id: 'o', label: 'O', w: 1 },
{ id: 'p', label: 'P', w: 1 },
{ id: '[', label: '[', w: 1 },
{ id: ']', label: ']', w: 1 },
{ id: '\\', label: '\\', w: 1.5 },
],
// Row 2: Home row
[
{ id: 'capslock', label: 'Caps', w: 1.8 },
{ id: 'a', label: 'A', w: 1 },
{ id: 's', label: 'S', w: 1 },
{ id: 'd', label: 'D', w: 1 },
{ id: 'f', label: 'F', w: 1 },
{ id: 'g', label: 'G', w: 1 },
{ id: 'h', label: 'H', w: 1 },
{ id: 'j', label: 'J', w: 1 },
{ id: 'k', label: 'K', w: 1 },
{ id: 'l', label: 'L', w: 1 },
{ id: ';', label: ';', w: 1 },
{ id: "'", label: "'", w: 1 },
{ id: 'enter', label: 'Enter', w: 2.2 },
],
// Row 3: Shift row
[
{ id: 'shift-l', label: 'Shift', w: 2.5 },
{ id: 'z', label: 'Z', w: 1 },
{ id: 'x', label: 'X', w: 1 },
{ id: 'c', label: 'C', w: 1 },
{ id: 'v', label: 'V', w: 1 },
{ id: 'b', label: 'B', w: 1 },
{ id: 'n', label: 'N', w: 1 },
{ id: 'm', label: 'M', w: 1 },
{ id: ',', label: ',', w: 1 },
{ id: '.', label: '.', w: 1 },
{ id: '/', label: '/', w: 1 },
{ id: 'shift-r', label: 'Shift', w: 2.5 },
],
// Row 4: Bottom row
[
{ id: 'ctrl-l', label: 'Ctrl', w: 1.5 },
{ id: 'alt-l', label: 'Alt', w: 1.5 },
{ id: ' ', label: 'Space', w: 7 },
{ id: 'alt-r', label: 'Alt', w: 1.5 },
{ id: 'ctrl-r', label: 'Ctrl', w: 1.5 },
],
// Row 5: Navigation cluster
[
{ id: 'escape', label: 'Esc', w: 1 },
{ id: 'delete', label: 'Del', w: 1 },
{ id: 'home', label: 'Home', w: 1 },
{ id: 'end', label: 'End', w: 1 },
{ id: 'f9', label: 'F9', w: 1 },
{ id: 'f10', label: 'F10', w: 1 },
{ id: 'f11', label: 'F11', w: 1 },
{ id: 'arrowleft', label: '\u2190', w: 1 },
{ id: 'arrowup', label: '\u2191', w: 1 },
{ id: 'arrowdown', label: '\u2193', w: 1 },
{ id: 'arrowright', label: '\u2192', w: 1 },
],
]
// Category → color mapping
const CATEGORY_COLORS: Record<string, { bg: string; border: string; text: string; dot: string }> = {
Tools: { bg: 'bg-blue-600/30', border: 'border-blue-500/60', text: 'text-blue-300', dot: 'bg-blue-400' },
Transport: { bg: 'bg-emerald-600/30', border: 'border-emerald-500/60', text: 'text-emerald-300', dot: 'bg-emerald-400' },
Editing: { bg: 'bg-amber-600/30', border: 'border-amber-500/60', text: 'text-amber-300', dot: 'bg-amber-400' },
Marking: { bg: 'bg-rose-600/30', border: 'border-rose-500/60', text: 'text-rose-300', dot: 'bg-rose-400' },
Timeline: { bg: 'bg-blue-600/30', border: 'border-blue-500/60', text: 'text-blue-300', dot: 'bg-blue-400' },
}
// Keys that are modifier indicators (not assignable targets)
const MODIFIER_KEY_IDS = new Set(['shift-l', 'shift-r', 'ctrl-l', 'ctrl-r', 'alt-l', 'alt-r', 'capslock', 'tab'])
export function KeyboardShortcutsModal() {
const {
activeLayout,
activePresetId,
presets,
switchPreset,
updateBinding,
resetToPreset,
saveAsCustomPreset,
deleteCustomPreset,
isEditorOpen,
setEditorOpen,
} = useKeyboardShortcuts()
const [searchQuery, setSearchQuery] = useState('')
const [recordingAction, setRecordingAction] = useState<ActionId | null>(null)
const [showPresetDropdown, setShowPresetDropdown] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false)
const [savePresetName, setSavePresetName] = useState('')
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const recordRef = useRef<HTMLDivElement>(null)
// Visual keyboard state
const [kbModCtrl, setKbModCtrl] = useState(false)
const [kbModShift, setKbModShift] = useState(false)
const [kbModAlt, setKbModAlt] = useState(false)
const [hoveredActionId, setHoveredActionId] = useState<ActionId | null>(null)
const [draggedActionId, setDraggedActionId] = useState<ActionId | null>(null)
const [dropTargetKey, setDropTargetKey] = useState<string | null>(null)
const [showKeyboard, setShowKeyboard] = useState(true)
const conflicts = findConflicts(activeLayout)
const activePreset = presets.find(p => p.id === activePresetId)
// ── Reverse lookup: given current modifier state, map keyId → ActionId ──
const keyToAction = useMemo(() => {
const map = new Map<string, { actionId: ActionId; action: ActionDefinition }>()
for (const [actionId, combos] of Object.entries(activeLayout)) {
if (!combos) continue
for (const combo of combos) {
// Check if this combo matches the current modifier filter
const comboCtrl = !!combo.ctrl || !!combo.meta
const comboShift = !!combo.shift
const comboAlt = !!combo.alt
if (comboCtrl === kbModCtrl && comboShift === kbModShift && comboAlt === kbModAlt) {
const def = ACTION_REGISTRY.find(a => a.id === actionId)
if (def) {
map.set(combo.key, { actionId: actionId as ActionId, action: def })
}
}
}
}
return map
}, [activeLayout, kbModCtrl, kbModShift, kbModAlt])
// ── Keys assigned to the hovered action (for highlighting) ──
const hoveredKeys = useMemo(() => {
if (!hoveredActionId) return new Set<string>()
const combos = activeLayout[hoveredActionId] || []
return new Set(combos.map(c => c.key))
}, [hoveredActionId, activeLayout])
// Close on Escape (only when not recording)
useEffect(() => {
if (!isEditorOpen) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !recordingAction) {
setEditorOpen(false)
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isEditorOpen, recordingAction, setEditorOpen])
// Recording mode: capture the next keystroke as a new binding
const handleRecordKeyDown = useCallback((e: KeyboardEvent) => {
if (!recordingAction) return
e.preventDefault()
e.stopPropagation()
// Ignore modifier-only presses
if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return
const combo: KeyCombo = {
key: e.key.toLowerCase(),
...(e.ctrlKey || e.metaKey ? { ctrl: true } : {}),
...(e.shiftKey ? { shift: true } : {}),
...(e.altKey ? { alt: true } : {}),
}
// If Escape pressed alone, cancel recording
if (combo.key === 'escape' && !combo.ctrl && !combo.shift && !combo.alt) {
setRecordingAction(null)
return
}
// Set the new binding (replace existing)
updateBinding(recordingAction, [combo])
setRecordingAction(null)
}, [recordingAction, updateBinding])
useEffect(() => {
if (recordingAction) {
window.addEventListener('keydown', handleRecordKeyDown, true)
return () => window.removeEventListener('keydown', handleRecordKeyDown, true)
}
}, [recordingAction, handleRecordKeyDown])
// ── Drag and drop via React state (avoids HTML5 DnD issues in Electron) ──
// We track the dragged action in a ref so it's always current
const draggedActionRef = useRef<ActionId | null>(null)
// Mouse-based drag: user mousedowns on an action, moves to a key, mouseups to drop
const handleActionMouseDown = useCallback((e: React.MouseEvent, actionId: ActionId) => {
// Only left-click
if (e.button !== 0) return
e.preventDefault()
draggedActionRef.current = actionId
setDraggedActionId(actionId)
}, [])
// Global mouse handlers for drag (attached when dragging)
useEffect(() => {
if (!draggedActionId) return
const handleMouseMove = (e: MouseEvent) => {
// Find which keyboard key the mouse is over
const el = document.elementFromPoint(e.clientX, e.clientY)
const keyEl = el?.closest('[data-kb-key]') as HTMLElement | null
if (keyEl) {
const keyId = keyEl.getAttribute('data-kb-key')
if (keyId && !MODIFIER_KEY_IDS.has(keyId)) {
setDropTargetKey(keyId)
} else {
setDropTargetKey(null)
}
} else {
setDropTargetKey(null)
}
}
const handleMouseUp = (e: MouseEvent) => {
const actionId = draggedActionRef.current
const el = document.elementFromPoint(e.clientX, e.clientY)
const keyEl = el?.closest('[data-kb-key]') as HTMLElement | null
if (actionId && keyEl) {
const keyId = keyEl.getAttribute('data-kb-key')
if (keyId && !MODIFIER_KEY_IDS.has(keyId)) {
// Build combo from current modifier state + dropped key
const combo: KeyCombo = {
key: keyId,
...(kbModCtrl ? { ctrl: true } : {}),
...(kbModShift ? { shift: true } : {}),
...(kbModAlt ? { alt: true } : {}),
}
updateBinding(actionId, [combo])
}
}
draggedActionRef.current = null
setDraggedActionId(null)
setDropTargetKey(null)
}
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseup', handleMouseUp)
return () => {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
}
}, [draggedActionId, kbModCtrl, kbModShift, kbModAlt, updateBinding])
// Click on a key to select the action assigned to it
const handleKeyClick = useCallback((keyId: string) => {
if (MODIFIER_KEY_IDS.has(keyId)) return
// Don't treat mouseup from a drag as a click
if (draggedActionRef.current) return
const assigned = keyToAction.get(keyId)
if (assigned) {
setSelectedCategory(null)
setSearchQuery(assigned.action.label)
}
}, [keyToAction])
if (!isEditorOpen) return null
// Filter and group actions
const categories = ['Tools', 'Transport', 'Editing', 'Marking', 'Timeline'] as const
const filteredActions = ACTION_REGISTRY.filter(a => {
if (selectedCategory && a.category !== selectedCategory) return false
if (searchQuery) {
const q = searchQuery.toLowerCase()
const combos = activeLayout[a.id]
const comboStr = combos?.map(formatKeyCombo).join(' ') || ''
return a.label.toLowerCase().includes(q) || a.category.toLowerCase().includes(q) || comboStr.toLowerCase().includes(q)
}
return true
})
const groupedActions: Record<string, ActionDefinition[]> = {}
for (const a of filteredActions) {
if (!groupedActions[a.category]) groupedActions[a.category] = []
groupedActions[a.category].push(a)
}
// Unit size for keyboard keys
const KEY_UNIT = 42 // px per 1 unit of key width
const KEY_GAP = 2 // px gap between keys
const KEY_H = 36 // px key height
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget && !recordingAction) setEditorOpen(false) }}
>
<div className="w-[880px] max-h-[90vh] bg-zinc-900 rounded-xl border border-zinc-700/80 shadow-2xl flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 px-5 py-3 border-b border-zinc-800 bg-zinc-900/95">
<div className="w-8 h-8 rounded-lg bg-blue-600/20 flex items-center justify-center">
<Keyboard className="h-4 w-4 text-blue-400" />
</div>
<div className="flex-1">
<h2 className="text-sm font-semibold text-white">Keyboard Shortcuts</h2>
<p className="text-[11px] text-zinc-500 mt-0.5">Customize keybindings — drag actions onto keys or click Edit</p>
</div>
<button
onClick={() => setShowKeyboard(!showKeyboard)}
className={`px-2.5 py-1 text-[10px] font-medium rounded-md transition-colors ${
showKeyboard ? 'bg-blue-600/20 text-blue-300' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'
}`}
>
{showKeyboard ? 'Hide Keyboard' : 'Show Keyboard'}
</button>
<button onClick={() => setEditorOpen(false)} className="text-zinc-500 hover:text-white transition-colors p-1">
<X className="h-4 w-4" />
</button>
</div>
{/* Toolbar: Preset selector + Search + Actions */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-zinc-800/80 bg-zinc-950/50">
{/* Preset dropdown */}
<div className="relative">
<button
onClick={() => setShowPresetDropdown(!showPresetDropdown)}
className="flex items-center gap-2 px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 rounded-md border border-zinc-700/60 text-[11px] text-zinc-300 transition-colors"
>
<span className="max-w-[140px] truncate">{activePreset?.name || 'Custom'}</span>
<ChevronDown className="h-3 w-3 text-zinc-500" />
</button>
{showPresetDropdown && (
<div className="absolute top-full left-0 mt-1 w-64 bg-zinc-800 rounded-lg border border-zinc-700 shadow-xl z-50 overflow-hidden">
{presets.map(p => (
<div
key={p.id}
className={`flex items-center gap-1 hover:bg-zinc-700 transition-colors ${
p.id === activePresetId ? 'bg-blue-600/15' : ''
}`}
>
<button
onClick={() => {
switchPreset(p.id)
setShowPresetDropdown(false)
}}
className={`flex-1 text-left px-3 py-2 text-[11px] ${
p.id === activePresetId ? 'text-blue-300' : 'text-zinc-300'
}`}
>
<div className="font-medium">{p.name}</div>
<div className="text-[10px] text-zinc-500 mt-0.5">{p.description}</div>
</button>
{/* Delete button — only for user-created (non-built-in) presets */}
{!p.builtIn && (
<button
onClick={(e) => {
e.stopPropagation()
if (confirm(`Delete preset "${p.name}"?`)) {
deleteCustomPreset(p.id)
if (presets.filter(pr => pr.id !== p.id).length > 0) {
setShowPresetDropdown(true)
} else {
setShowPresetDropdown(false)
}
}
}}
className="p-1.5 mr-1.5 rounded text-zinc-600 hover:text-red-400 hover:bg-red-600/10 transition-colors"
title={`Delete "${p.name}"`}
>
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
)}
</div>
{/* Search */}
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3 w-3 text-zinc-600" />
<input
type="text"
placeholder="Search actions or keys..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 bg-zinc-800 rounded-md text-[11px] text-white placeholder-zinc-600 outline-none border border-zinc-700/40 focus:border-blue-500/50 transition-colors"
/>
</div>
{/* Reset button */}
<button
onClick={() => resetToPreset(activePresetId)}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-md transition-colors"
title="Reset all shortcuts to the selected preset"
>
<RotateCcw className="h-3 w-3" />
Reset
</button>
{/* Save as custom */}
<div className="relative">
<button
onClick={() => setShowSaveDialog(!showSaveDialog)}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-md transition-colors"
title="Save current layout as a custom preset"
>
<Save className="h-3 w-3" />
Save As
</button>
{showSaveDialog && (
<div className="absolute top-full right-0 mt-1 w-52 bg-zinc-800 rounded-lg border border-zinc-700 shadow-xl z-50 p-3">
<p className="text-[10px] text-zinc-400 mb-2">Save as custom preset:</p>
<input
type="text"
placeholder="Preset name..."
value={savePresetName}
onChange={(e) => setSavePresetName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && savePresetName.trim()) {
saveAsCustomPreset(savePresetName.trim())
setSavePresetName('')
setShowSaveDialog(false)
}
}}
className="w-full px-2 py-1.5 bg-zinc-900 rounded text-[11px] text-white placeholder-zinc-600 outline-none border border-zinc-700 focus:border-blue-500 mb-2"
autoFocus
/>
<button
onClick={() => {
if (savePresetName.trim()) {
saveAsCustomPreset(savePresetName.trim())
setSavePresetName('')
setShowSaveDialog(false)
}
}}
disabled={!savePresetName.trim()}
className="w-full py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-zinc-700 disabled:text-zinc-500 text-white text-[11px] font-medium rounded transition-colors"
>
Save Preset
</button>
</div>
)}
</div>
</div>
{/* Conflict warning */}
{conflicts.size > 0 && (
<div className="flex items-center gap-2 px-4 py-2 bg-amber-950/30 border-b border-amber-800/30">
<AlertTriangle className="h-3.5 w-3.5 text-amber-400 flex-shrink-0" />
<span className="text-[11px] text-amber-400/90">
{conflicts.size} shortcut conflict{conflicts.size > 1 ? 's' : ''} detected — some keys are assigned to multiple actions
</span>
</div>
)}
{/* ══════════════ Visual Keyboard ══════════════ */}
{showKeyboard && (
<div className="border-b border-zinc-800/80 bg-zinc-950/60 px-4 py-3">
{/* Modifier toggles */}
<div className="flex items-center gap-2 mb-2.5">
<span className="text-[10px] text-zinc-500 font-medium mr-1">Modifiers:</span>
{([
{ label: 'Ctrl', active: kbModCtrl, toggle: () => setKbModCtrl(v => !v) },
{ label: 'Shift', active: kbModShift, toggle: () => setKbModShift(v => !v) },
{ label: 'Alt', active: kbModAlt, toggle: () => setKbModAlt(v => !v) },
] as const).map(mod => (
<button
key={mod.label}
onClick={mod.toggle}
className={`px-3 py-1 rounded text-[10px] font-bold transition-all ${
mod.active
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/20'
: 'bg-zinc-800 text-zinc-500 hover:bg-zinc-700 hover:text-zinc-300 border border-zinc-700/50'
}`}
>
{mod.label}
</button>
))}
<div className="flex-1" />
{/* Category legend */}
<div className="flex items-center gap-3">
{categories.map(cat => {
const c = CATEGORY_COLORS[cat]
return (
<div key={cat} className="flex items-center gap-1">
<div className={`w-2 h-2 rounded-sm ${c.dot}`} />
<span className="text-[9px] text-zinc-500">{cat}</span>
</div>
)
})}
</div>
</div>
{/* Keyboard rows */}
<div className="flex flex-col items-center gap-[2px]">
{KB_ROWS.map((row, rowIdx) => (
<div key={rowIdx} className="flex gap-[2px]">
{row.map((kbKey) => {
const isModifier = MODIFIER_KEY_IDS.has(kbKey.id)
const assigned = keyToAction.get(kbKey.id)
const catColors = assigned ? CATEGORY_COLORS[assigned.action.category] : null
const isHighlighted = hoveredKeys.has(kbKey.id)
const isDropTarget = dropTargetKey === kbKey.id
const isDragging = !!draggedActionId
// Check if this key has a conflict
const comboStr = assigned ? formatKeyCombo({
key: kbKey.id,
...(kbModCtrl ? { ctrl: true } : {}),
...(kbModShift ? { shift: true } : {}),
...(kbModAlt ? { alt: true } : {}),
}) : null
const hasConflict = comboStr ? conflicts.has(comboStr) : false
return (
<div
key={kbKey.id}
className={`
relative flex flex-col items-center justify-center rounded-md
border text-center select-none transition-all duration-100
${isModifier
? 'bg-zinc-800/60 border-zinc-700/40 cursor-default'
: isDropTarget
? 'bg-blue-600/40 border-blue-400 ring-1 ring-blue-400 scale-105'
: isHighlighted
? 'bg-blue-600/30 border-blue-500 ring-1 ring-blue-400/50'
: hasConflict
? 'bg-amber-900/30 border-amber-600/60'
: assigned && catColors
? `${catColors.bg} ${catColors.border}`
: isDragging && !isModifier
? 'bg-zinc-800/80 border-zinc-600/80 border-dashed hover:border-blue-500/60 hover:bg-zinc-700/50'
: 'bg-zinc-850 border-zinc-700/50 hover:border-zinc-600'
}
${!isModifier ? 'cursor-pointer' : ''}
`}
style={{
width: kbKey.w * KEY_UNIT + (kbKey.w - 1) * KEY_GAP,
height: KEY_H,
}}
data-kb-key={kbKey.id}
onClick={() => handleKeyClick(kbKey.id)}
title={
assigned
? `${assigned.action.label} (${assigned.action.category})`
: isModifier
? kbKey.label
: 'Unassigned — drag an action here'
}
>
{/* Key label */}
<span className={`text-[9px] font-medium leading-none ${
isModifier ? 'text-zinc-600' :
assigned && catColors ? catColors.text :
isHighlighted ? 'text-blue-300' :
'text-zinc-500'
}`}>
{kbKey.label}
</span>
{/* Assigned action label (truncated) */}
{assigned && !isModifier && (
<span className={`text-[7px] leading-tight mt-0.5 max-w-full px-0.5 truncate ${
catColors ? catColors.text : 'text-zinc-400'
}`} style={{ opacity: 0.8 }}>
{assigned.action.label.replace(/ Tool$/, '').replace(/ \(.*\)$/, '')}
</span>
)}
{/* Conflict indicator */}
{hasConflict && (
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-amber-400" />
)}
</div>
)
})}
</div>
))}
</div>
{/* Drag hint */}
{draggedActionId && (
<div className="text-center mt-2 text-[10px] text-blue-400 animate-pulse">
Drop on a key to assign — current modifiers: {
[kbModCtrl && 'Ctrl', kbModShift && 'Shift', kbModAlt && 'Alt'].filter(Boolean).join('+') || 'None'
}
</div>
)}
</div>
)}
{/* Category tabs */}
<div className="flex items-center gap-1 px-4 py-2 border-b border-zinc-800/60 bg-zinc-950/30">
<button
onClick={() => setSelectedCategory(null)}
className={`px-2.5 py-1 rounded-md text-[10px] font-medium transition-colors ${
!selectedCategory ? 'bg-blue-600/20 text-blue-300' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'
}`}
>
All
</button>
{categories.map(cat => {
const c = CATEGORY_COLORS[cat]
return (
<button
key={cat}
onClick={() => setSelectedCategory(cat === selectedCategory ? null : cat)}
className={`px-2.5 py-1 rounded-md text-[10px] font-medium transition-colors flex items-center gap-1.5 ${
selectedCategory === cat ? `${c.bg} ${c.text}` : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'
}`}
>
<div className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
{cat}
</button>
)
})}
</div>
{/* Actions list */}
<div className="flex-1 overflow-y-auto min-h-0" ref={recordRef}>
{categories.map(cat => {
const actions = groupedActions[cat]
if (!actions || actions.length === 0) return null
const catColor = CATEGORY_COLORS[cat]
return (
<div key={cat}>
{/* Category header */}
<div className="sticky top-0 z-10 px-4 py-1.5 bg-zinc-950/95 border-b border-zinc-800/50 flex items-center gap-2">
<div className={`w-1.5 h-1.5 rounded-full ${catColor.dot}`} />
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">{cat}</span>
</div>
{/* Action rows */}
{actions.map(action => {
const combos = activeLayout[action.id] || []
const isRecording = recordingAction === action.id
const hasConflict = combos.some(c => {
const key = formatKeyCombo(c)
return conflicts.has(key)
})
return (
<div
key={action.id}
onMouseDown={(e) => { if (!isRecording) handleActionMouseDown(e, action.id) }}
onMouseEnter={() => setHoveredActionId(action.id)}
onMouseLeave={() => setHoveredActionId(null)}
className={`flex items-center gap-3 px-4 py-2 border-b border-zinc-800/30 transition-colors select-none ${
isRecording ? 'bg-blue-950/30' :
draggedActionId === action.id ? 'bg-blue-600/20 ring-1 ring-blue-500/50' :
hoveredActionId === action.id ? 'bg-zinc-800/40' :
'hover:bg-zinc-800/30'
} ${!isRecording ? 'cursor-grab active:cursor-grabbing' : ''}`}
>
{/* Drag handle */}
{!isRecording && (
<GripVertical className={`h-3 w-3 flex-shrink-0 ${
draggedActionId === action.id ? 'text-blue-400 cursor-grabbing' : 'text-zinc-700 cursor-grab'
}`} />
)}
{/* Category dot */}
<div className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${catColor.dot}`} />
{/* Action label */}
<div className="flex-1 min-w-0">
<span className={`text-[12px] ${hasConflict ? 'text-amber-300' : 'text-zinc-300'}`}>
{action.label}
</span>
{action.description && (
<span className="text-[10px] text-zinc-600 ml-2">{action.description}</span>
)}
{hasConflict && (
<AlertTriangle className="inline-block h-3 w-3 text-amber-400 ml-1.5 -mt-0.5" />
)}
</div>
{/* Current binding(s) */}
<div className="flex items-center gap-1.5">
{isRecording ? (
<div className="flex items-center gap-2 px-3 py-1 bg-blue-600/20 border border-blue-500/50 rounded-md animate-pulse">
<span className="text-[11px] text-blue-300">Press a key...</span>
<button
onClick={(e) => { e.stopPropagation(); setRecordingAction(null) }}
className="text-zinc-500 hover:text-white"
>
<X className="h-3 w-3" />
</button>
</div>
) : (
<>
{combos.length === 0 ? (
<span className="text-[11px] text-zinc-600 italic">Unassigned</span>
) : (
combos.map((combo, i) => {
const comboKey = formatKeyCombo(combo)
const conflicting = conflicts.has(comboKey)
return (
<span
key={i}
className={`inline-flex items-center px-2 py-0.5 rounded text-[11px] font-mono font-medium ${
conflicting
? 'bg-amber-900/30 text-amber-300 border border-amber-700/50'
: `${catColor.bg} ${catColor.text} border ${catColor.border}`
}`}
>
{comboKey}
</span>
)
})
)}
</>
)}
</div>
{/* Edit / Clear buttons */}
{!isRecording && (
<div className="flex items-center gap-1 ml-2">
<button
onClick={() => setRecordingAction(action.id)}
className="px-2 py-0.5 text-[10px] text-zinc-500 hover:text-blue-400 hover:bg-blue-600/10 rounded transition-colors"
>
Edit
</button>
{combos.length > 0 && (
<button
onClick={() => updateBinding(action.id, [])}
className="px-2 py-0.5 text-[10px] text-zinc-600 hover:text-red-400 hover:bg-red-600/10 rounded transition-colors"
>
Clear
</button>
)}
</div>
)}
</div>
)
})}
</div>
)
})}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2.5 border-t border-zinc-800 bg-zinc-950/80">
<span className="text-[10px] text-zinc-600">
{ACTION_REGISTRY.length} actions &middot; Drag actions onto keys or click "Edit" to record
</span>
<button
onClick={() => setEditorOpen(false)}
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-[11px] font-medium rounded-md transition-colors"
>
Done
</button>
</div>
</div>
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
import { pathToFileUrl } from '../lib/file-url'
import { imagePathsFromDataTransfer } from '../lib/keyframe-drop'
import type { KeyframeItem } from '../lib/multi-keyframe'
interface KeyframePreviewProps {
keyframe: KeyframeItem | null
aspectRatio: string
onDropImages: (paths: string[]) => void
}
export function KeyframePreview({
keyframe,
aspectRatio,
onDropImages,
}: KeyframePreviewProps) {
const cssAspect = aspectRatio.includes(':')
? aspectRatio.replace(':', ' / ')
: aspectRatio
return (
<div
className="flex min-h-0 flex-1 items-center justify-center"
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault()
const paths = imagePathsFromDataTransfer(event.dataTransfer)
if (paths.length > 0) onDropImages(paths)
}}
>
<div
className="h-full max-w-full overflow-hidden rounded-xl border border-zinc-800 bg-zinc-950"
style={{ aspectRatio: cssAspect }}
>
{keyframe ? (
<img
src={pathToFileUrl(keyframe.path)}
alt="Keyframe at the playhead"
draggable={false}
className="h-full w-full object-contain"
/>
) : (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-zinc-500">
Add keyframes to preview your sequence
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,85 @@
import { useRef } from 'react'
import {
formatKeyframeStrength,
nudgeKeyframeStrength,
strengthFromPointer,
} from '../lib/keyframe-strength'
interface KeyframeStrengthRailProps {
strength: number
label: string
onStrengthChange: (strength: number) => void
}
export function KeyframeStrengthRail({
strength,
label,
onStrengthChange,
}: KeyframeStrengthRailProps) {
const railRef = useRef<HTMLDivElement>(null)
const draggingRef = useRef(false)
const clamped = Math.min(1, Math.max(0, strength))
const percent = formatKeyframeStrength(strength)
const applyFromClientY = (clientY: number) => {
const rect = railRef.current?.getBoundingClientRect()
if (!rect) return
onStrengthChange(strengthFromPointer(clientY, rect))
}
return (
<div
ref={railRef}
role="slider"
aria-orientation="vertical"
tabIndex={0}
data-keyframe-strength
aria-label={`Strength for keyframe at ${label}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(clamped * 100)}
aria-valuetext={percent}
title={`Strength ${percent}`}
className="absolute left-0 top-0 z-20 h-11 w-3 cursor-ns-resize touch-none"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
draggingRef.current = true
event.currentTarget.setPointerCapture(event.pointerId)
applyFromClientY(event.clientY)
}}
onPointerMove={(event) => {
if (!draggingRef.current) return
event.stopPropagation()
applyFromClientY(event.clientY)
}}
onPointerUp={(event) => {
draggingRef.current = false
event.stopPropagation()
}}
onPointerCancel={() => {
draggingRef.current = false
}}
onLostPointerCapture={() => {
draggingRef.current = false
}}
onKeyDown={(event) => {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
event.preventDefault()
event.stopPropagation()
onStrengthChange(nudgeKeyframeStrength(strength, event.key === 'ArrowUp' ? 1 : -1))
}}
>
<div className="relative mx-auto h-full w-0.5 rounded-full bg-zinc-700">
<div
className="absolute bottom-0 w-full rounded-full bg-blue-400"
style={{ height: `${clamped * 100}%` }}
/>
<div
className="absolute left-1/2 h-1.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-blue-200 shadow"
style={{ top: `${(1 - clamped) * 100}%` }}
/>
</div>
</div>
)
}
+276
View File
@@ -0,0 +1,276 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { RefreshCw, Trash2 } from 'lucide-react'
import { applyTimecode, nudgeKeyframe } from '../lib/keyframe-controls'
import {
formatKeyframeStrength,
nudgeKeyframeStrength,
} from '../lib/keyframe-strength'
import {
findNearestFreeFrameIndex,
formatKeyframeTimecode,
frameFromPointer,
sameDraggedFrame,
withDraggedFrame,
type DraggedFrame,
} from '../lib/keyframe-timeline'
import type { KeyframeItem } from '../lib/multi-keyframe'
import { pathToFileUrl } from '../lib/file-url'
import { KeyframeStrengthRail } from './KeyframeStrengthRail'
interface KeyframeTimelineProps {
keyframes: readonly KeyframeItem[]
fps: number
lastFrame: number
playheadFrame: number
onPlayheadChange: (frameIndex: number) => void
onDragFrameChange?: (drag: DraggedFrame | null) => void
onFrameChange: (id: string, frameIndex: number) => void
onStrengthChange: (id: string, strength: number) => void
onReplaceRequest: (id: string) => void
onDelete: (id: string) => void
onImagesDrop: (dataTransfer: DataTransfer, replaceId: string | null) => void
}
interface DragState {
id: string
frameIndex: number
pointerId: number
}
export function KeyframeTimeline({
keyframes,
fps,
lastFrame,
playheadFrame,
onPlayheadChange,
onDragFrameChange,
onFrameChange,
onStrengthChange,
onReplaceRequest,
onDelete,
onImagesDrop,
}: KeyframeTimelineProps) {
const trackRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<DragState | null>(null)
const onDragFrameChangeRef = useRef(onDragFrameChange)
onDragFrameChangeRef.current = onDragFrameChange
const [drag, setDrag] = useState<DragState | null>(null)
const [timecodeDraft, setTimecodeDraft] = useState<{
id: string
value: string
} | null>(null)
useLayoutEffect(() => {
return () => onDragFrameChangeRef.current?.(null)
}, [])
const updateDrag = (next: DragState | null) => {
const prev = dragRef.current
dragRef.current = next
const overlay = next ? { id: next.id, frameIndex: next.frameIndex } : null
const prevOverlay = prev ? { id: prev.id, frameIndex: prev.frameIndex } : null
if (sameDraggedFrame(prevOverlay, overlay)) return
setDrag(next)
onDragFrameChangeRef.current?.(overlay)
}
const displayed = withDraggedFrame(
keyframes,
drag ? { id: drag.id, frameIndex: drag.frameIndex } : null,
)
const frameAtPointer = (clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect()
return rect ? frameFromPointer(clientX, rect, lastFrame) : 0
}
const handlePointerMove = (event: React.PointerEvent<HTMLElement>) => {
const activeDrag = dragRef.current
if (activeDrag && activeDrag.pointerId === event.pointerId) {
const frameIndex = frameAtPointer(event.clientX)
updateDrag({ ...activeDrag, frameIndex })
onPlayheadChange(frameIndex)
return
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
onPlayheadChange(frameAtPointer(event.clientX))
}
}
const finishDrag = (event: React.PointerEvent<HTMLElement>) => {
const activeDrag = dragRef.current
if (!activeDrag || activeDrag.pointerId !== event.pointerId) return
const otherKeyframes = keyframes.filter((keyframe) => keyframe.id !== activeDrag.id)
const frameIndex = findNearestFreeFrameIndex(otherKeyframes, frameAtPointer(event.clientX), lastFrame)
if (frameIndex !== null) onFrameChange(activeDrag.id, frameIndex)
updateDrag(null)
}
const positionPercent = (frameIndex: number) =>
lastFrame > 0 ? (frameIndex / lastFrame) * 100 : 0
const commitTimecode = (id: string, value: string) => {
const frameIndex = applyTimecode(keyframes, id, value, fps, lastFrame)
if (frameIndex !== null) onFrameChange(id, frameIndex)
setTimecodeDraft(null)
}
return (
<div>
<div
ref={trackRef}
className="relative h-16 rounded-lg border border-zinc-700 bg-zinc-950/70"
onPointerMove={handlePointerMove}
onPointerUp={finishDrag}
onPointerCancel={() => updateDrag(null)}
onPointerDown={(event) => {
if (event.target !== event.currentTarget) return
event.currentTarget.setPointerCapture(event.pointerId)
onPlayheadChange(frameAtPointer(event.clientX))
}}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault()
onImagesDrop(event.dataTransfer, null)
}}
>
<div className="pointer-events-none absolute left-2 right-2 top-1/2 h-px bg-zinc-700" />
<div
className="pointer-events-none absolute inset-y-1 w-px bg-blue-400"
style={{ left: `${positionPercent(playheadFrame)}%` }}
/>
{displayed.map((keyframe) => {
const markerFrame = keyframe.frameIndex
const source = keyframes.find((item) => item.id === keyframe.id) ?? keyframe
const timecode = formatKeyframeTimecode(markerFrame, fps)
return (
<div
key={keyframe.id}
className="group absolute top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 touch-none"
style={{ left: `${positionPercent(markerFrame)}%` }}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault()
event.stopPropagation()
onImagesDrop(event.dataTransfer, keyframe.id)
}}
>
<button
type="button"
title="Drag to move keyframe"
aria-label={`Keyframe at ${timecode}`}
className="relative block h-11 w-11 cursor-ew-resize overflow-hidden rounded-md border-2 border-blue-500 bg-zinc-900 shadow-lg"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
event.currentTarget.focus({ preventScroll: true })
event.currentTarget.setPointerCapture(event.pointerId)
updateDrag({
id: keyframe.id,
frameIndex: source.frameIndex,
pointerId: event.pointerId,
})
}}
onPointerMove={handlePointerMove}
onPointerUp={finishDrag}
onPointerCancel={() => updateDrag(null)}
onKeyDown={(event) => {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault()
event.stopPropagation()
onStrengthChange(
keyframe.id,
nudgeKeyframeStrength(source.strength, event.key === 'ArrowUp' ? 1 : -1),
)
return
}
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
event.preventDefault()
event.stopPropagation()
const frameIndex = nudgeKeyframe(
keyframes,
keyframe.id,
event.key === 'ArrowLeft' ? -1 : 1,
lastFrame,
)
if (frameIndex !== null) onFrameChange(keyframe.id, frameIndex)
}}
>
<img
src={pathToFileUrl(source.path)}
alt=""
draggable={false}
className="h-full w-full object-cover"
/>
<span className="pointer-events-none absolute inset-x-0 bottom-0 bg-zinc-950/70 py-px text-center text-[9px] font-medium text-blue-200">
{formatKeyframeStrength(source.strength)}
</span>
</button>
<KeyframeStrengthRail
strength={source.strength}
label={timecode}
onStrengthChange={(strength) => onStrengthChange(keyframe.id, strength)}
/>
<div className="absolute -right-3 -top-2 z-20 flex gap-0.5 opacity-0 shadow group-hover:opacity-100 group-focus-within:opacity-100">
<button
type="button"
data-keyframe-replace
title="Replace keyframe image"
aria-label={`Replace keyframe at ${timecode}`}
className="rounded-full bg-zinc-800 p-1 text-zinc-400 hover:text-blue-300"
onClick={(event) => {
event.stopPropagation()
onReplaceRequest(keyframe.id)
}}
>
<RefreshCw className="h-3 w-3" />
</button>
<button
type="button"
data-keyframe-delete
title="Delete keyframe"
aria-label={`Delete keyframe at ${timecode}`}
className="rounded-full bg-zinc-800 p-1 text-zinc-400 hover:text-red-300"
onClick={(event) => {
event.stopPropagation()
onDelete(keyframe.id)
}}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
<input
type="text"
aria-label={`Timecode for keyframe at frame ${markerFrame}`}
className="absolute left-1/2 top-full mt-0.5 h-4 w-[66px] -translate-x-1/2 rounded border border-transparent bg-zinc-950/90 px-1 text-center font-mono text-[9px] text-zinc-400 outline-none hover:border-zinc-700 focus:border-blue-500 focus:text-zinc-200"
value={timecodeDraft?.id === keyframe.id
? timecodeDraft.value
: timecode}
onFocus={(event) => {
setTimecodeDraft({ id: keyframe.id, value: event.currentTarget.value })
}}
onChange={(event) => {
setTimecodeDraft({ id: keyframe.id, value: event.currentTarget.value })
}}
onBlur={(event) => commitTimecode(keyframe.id, event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === 'Enter') event.currentTarget.blur()
if (event.key === 'Escape') {
event.currentTarget.value = timecode
event.currentTarget.blur()
}
}}
/>
</div>
)
})}
</div>
<div className="mt-1 flex justify-between font-mono text-[9px] text-zinc-600">
<span>0</span>
<span>{lastFrame}</span>
</div>
</div>
)
}
+283
View File
@@ -0,0 +1,283 @@
import { useState, useEffect, useRef } from 'react'
import { X, FolderOpen, RefreshCw, ChevronDown, ChevronUp, Download, Search } from 'lucide-react'
import { Button } from './ui/button'
import { logger } from '../lib/logger'
import { getBackendCredentials } from '../lib/backend'
interface LogViewerProps {
isOpen: boolean
onClose: () => void
embedded?: boolean
}
// Wraps every case-insensitive occurrence of `query` in the line with a <mark>,
// keeping all other text as-is -- marks matches instead of hiding non-matching
// lines, so surrounding context stays visible while searching.
function highlightMatches(line: string, query: string): React.ReactNode {
if (!query) return line
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const parts = line.split(new RegExp(`(${escaped})`, 'gi'))
if (parts.length === 1) return line
// String.split with a capturing group alternates [text, match, text, match, ...] --
// odd indices are always the captured matches.
return parts.map((part, i) =>
i % 2 === 1
? <mark key={i} className="bg-yellow-500/70 text-black rounded-sm px-0.5">{part}</mark>
: <span key={i}>{part}</span>,
)
}
export function LogViewer({ isOpen, onClose, embedded = false }: LogViewerProps) {
const [logs, setLogs] = useState<string[]>([])
const [logPath, setLogPath] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [autoScroll, setAutoScroll] = useState(true)
const [searchInput, setSearchInput] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const [tokenCopied, setTokenCopied] = useState(false)
const logContainerRef = useRef<HTMLDivElement>(null)
// Autoscroll doesn't make sense while browsing search matches -- remember
// the user's prior preference so clearing the search restores it exactly.
const savedAutoScrollRef = useRef<boolean | null>(null)
// Debounce the raw input so typing doesn't fire an IPC round-trip (a full
// untruncated log read while searching) on every keystroke.
useEffect(() => {
const timer = setTimeout(() => setSearchQuery(searchInput.trim()), 300)
return () => clearTimeout(timer)
}, [searchInput])
const fetchLogs = async () => {
if (!window.electronAPI?.getLogs) return
setIsLoading(true)
try {
const result = await window.electronAPI.getLogs({ query: searchQuery || undefined })
setLogs(result.lines || [])
setLogPath(result.logPath || '')
} catch (error) {
logger.error(`Failed to fetch logs: ${error}`)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
if (!isOpen) return
fetchLogs()
// Live-tail only when NOT searching. A search reads the whole session file and
// ships every line over IPC (then re-highlights each), so polling that every 2s is a
// large CPU/IPC cost for no gain -- results are a point-in-time view. Fetch once per
// query change instead (this effect re-runs on searchQuery), and skip the interval.
if (searchQuery) return
const interval = setInterval(fetchLogs, 2000)
return () => clearInterval(interval)
}, [isOpen, searchQuery])
const matchCount = searchQuery
? logs.filter(l => l.toLowerCase().includes(searchQuery.toLowerCase())).length
: 0
useEffect(() => {
const isSearching = Boolean(searchQuery)
if (isSearching && savedAutoScrollRef.current === null) {
savedAutoScrollRef.current = autoScroll
setAutoScroll(false)
} else if (!isSearching && savedAutoScrollRef.current !== null) {
setAutoScroll(savedAutoScrollRef.current)
savedAutoScrollRef.current = null
}
// autoScroll intentionally excluded -- this only reacts to searchQuery
// transitions, not to the manual toggle (which is disabled while searching anyway).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery])
useEffect(() => {
if (autoScroll && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
}
}, [logs, autoScroll])
const handleOpenFolder = async () => {
if (window.electronAPI?.openLogFolder) {
await window.electronAPI.openLogFolder()
}
}
// Copy the backend Bearer token so local QA/perf scripts can authenticate
// against the Electron-spawned backend (its token is random per launch).
// Surfaced via a semi-hidden control in the footer (see below).
const handleCopyToken = async () => {
try {
const { token } = await getBackendCredentials()
if (!token) {
logger.error('No backend token available to copy')
return
}
await navigator.clipboard.writeText(token)
setTokenCopied(true)
setTimeout(() => setTokenCopied(false), 1200)
} catch (error) {
logger.error(`Failed to copy backend token: ${error}`)
}
}
const handleDownload = () => {
const content = logs.join('\n')
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = logPath ? logPath.split(/[/\\]/).pop() || 'session.log' : 'session.log'
a.click()
URL.revokeObjectURL(url)
}
if (!isOpen) return null
const panel = (
<div className={`bg-zinc-900 rounded-lg border border-zinc-700 w-full ${embedded ? 'h-full' : 'max-w-5xl h-[80vh]'} flex flex-col`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-zinc-700">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-white">Logs</h2>
<span className="text-xs text-zinc-500 font-mono truncate max-w-[300px]" title={logPath}>
{logPath}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleDownload}
disabled={logs.length === 0}
className="text-zinc-400 hover:text-white"
title="Download logs"
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleOpenFolder}
className="text-zinc-400 hover:text-white"
title="Open log folder"
>
<FolderOpen className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={fetchLogs}
disabled={isLoading}
className="text-zinc-400 hover:text-white"
title="Refresh logs"
>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setAutoScroll(!autoScroll)}
disabled={Boolean(searchQuery)}
className={`${autoScroll ? 'text-blue-400' : 'text-zinc-400'} hover:text-white disabled:opacity-40 disabled:cursor-not-allowed`}
title={searchQuery ? 'Auto-scroll disabled while searching' : autoScroll ? 'Auto-scroll enabled' : 'Auto-scroll disabled'}
>
{autoScroll ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</Button>
{!embedded && (
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="text-zinc-400 hover:text-white"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
{/* Search */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-zinc-800">
<Search className="h-3.5 w-3.5 text-zinc-500 flex-shrink-0" />
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search logs (case-insensitive)..."
className="flex-1 bg-transparent text-xs text-zinc-200 placeholder:text-zinc-600 focus:outline-none"
/>
{searchQuery && (
<span className="text-xs text-zinc-500 flex-shrink-0">
{matchCount} matching line{matchCount === 1 ? '' : 's'}
</span>
)}
</div>
{/* Log content */}
<div
ref={logContainerRef}
className="flex-1 overflow-auto p-4 font-mono text-xs bg-black"
>
{logs.length === 0 ? (
<div className="text-zinc-500 text-center py-8">
No logs yet...
</div>
) : (
<div className="space-y-0.5">
{logs.map((line, index) => {
// Color code log levels
let lineClass = 'text-zinc-300'
if (line.includes(' - ERROR - ') || line.includes(' - CRITICAL - ')) {
lineClass = 'text-red-400'
} else if (line.includes(' - WARNING - ')) {
lineClass = 'text-yellow-400'
} else if (line.includes(' - INFO - ')) {
lineClass = 'text-blue-300'
} else if (line.includes(' - DEBUG - ')) {
lineClass = 'text-zinc-500'
}
return (
<div key={index} className={`${lineClass} whitespace-pre-wrap break-all`}>
{highlightMatches(line, searchQuery)}
</div>
)
})}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-3 border-t border-zinc-700 text-xs text-zinc-500">
<span>{logs.length} lines {searchQuery ? '(full session)' : '(last 200)'}</span>
<div className="flex items-center gap-3">
{/* Semi-hidden: copies the backend Bearer token for local QA/perf scripts
(the Electron-spawned backend's token is random per launch). Muted until
hover so it stays out of the way. */}
<button
type="button"
onClick={() => void handleCopyToken()}
className="font-mono tracking-widest text-zinc-700 hover:text-zinc-300 transition-colors"
title="Copy backend auth token"
aria-label="Copy backend auth token"
>
{tokenCopied ? 'copied!' : '•••••'}
</button>
<span>Auto-refreshing every 2s</span>
</div>
</div>
</div>
)
if (embedded) {
return panel
}
return (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
{panel}
</div>
)
}
+108
View File
@@ -0,0 +1,108 @@
import { useId, useRef } from 'react'
import { Info, ExternalLink } from 'lucide-react'
import type { components } from '../generated/backend-openapi'
type InstructionSection = NonNullable<components['schemas']['LoraCatalogItem']['instructions']>[number]
const COMMUNITY_DISCLAIMER =
'This LoRA was created by a community member, not LTX. LTX does not endorse or take responsibility for community LoRAs. LTX has no control over the LoRA and any claims of any kind should be directed to the creator of the LoRA.'
// Info icon → popover with a LoRA/IC-LoRA's instruction sections + a link to its HF page
// (derived from repoId, no stored URL). Native popover API: top-layer (escapes the
// modal's overflow clipping and stacks above the <dialog>), with ESC + click-outside
// dismiss for free. The popover/popovertarget attrs aren't in @types/react 18.3's JSX
// yet, so they're declared via cast objects below — but statically, so the element is
// never rendered without `popover` (setting it post-mount let it flash inline in the row).
export function LoraInfoPopover({
sections,
name,
repoId,
isCommunity = true,
maxHeight = '70vh',
}: {
sections: InstructionSection[]
name: string
repoId?: string
// Fail closed: missing affiliation → show disclaimer.
isCommunity?: boolean
// Caller-tunable cap so the popover scrolls instead of overflowing its container
// (e.g. the library modal needs a lower value than it does elsewhere).
maxHeight?: string
}) {
const id = useId()
const btnRef = useRef<HTMLButtonElement>(null)
const popRef = useRef<HTMLDivElement>(null)
const showCommunityDisclaimer = isCommunity
if (sections.length === 0 && !repoId && !showCommunityDisclaimer) return null
// Native Popover API attrs missing from @types/react 18.3's JSX; cast so they render.
const popoverAttrs = { popover: 'auto' } as unknown as React.HTMLAttributes<HTMLDivElement>
const triggerAttrs = { popovertarget: id } as unknown as React.ButtonHTMLAttributes<HTMLButtonElement>
// The popover is viewport-fixed in the top layer; anchor it above the trigger on open.
const anchor = () => {
const btn = btnRef.current, pop = popRef.current
if (!btn || !pop) return
const r = btn.getBoundingClientRect()
pop.style.margin = '0'
pop.style.right = 'auto'
pop.style.top = 'auto'
pop.style.left = `${Math.max(8, r.left)}px`
pop.style.bottom = `${window.innerHeight - r.top + 6}px`
}
return (
<>
<button
ref={btnRef}
type="button"
{...triggerAttrs}
onClick={anchor}
aria-label={`${name} instructions`}
className="rounded p-0.5 text-zinc-400 transition-colors hover:text-white"
>
<Info className="h-3.5 w-3.5" />
</button>
<div
ref={popRef}
id={id}
{...popoverAttrs}
aria-label={`${name} instructions`}
style={{ maxHeight }}
className="w-72 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900 p-3 text-left text-white shadow-xl"
>
<div className="mb-1.5 text-xs font-semibold text-white">{name}</div>
<div className="flex flex-col gap-2">
{sections.map((s, i) => (
<div key={i}>
<div className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">{s.title}</div>
{Array.isArray(s.body) ? (
<ul className="mt-0.5 list-disc pl-4 text-[11px] text-zinc-300">
{s.body.map((b, j) => <li key={j}>{b}</li>)}
</ul>
) : (
<p className="mt-0.5 text-[11px] text-zinc-300">{s.body}</p>
)}
</div>
))}
</div>
{showCommunityDisclaimer && (
<p className="mt-2.5 border-t border-zinc-800 pt-2 text-[10px] leading-relaxed text-zinc-500">
{COMMUNITY_DISCLAIMER}
</p>
)}
{repoId && (
<button
type="button"
onClick={() => { void window.electronAPI.openHuggingFaceRepo({ repoId }) }}
className="mt-2.5 inline-flex items-center gap-1 text-[11px] font-medium text-indigo-400 transition-colors hover:text-indigo-300"
>
<ExternalLink className="h-3 w-3" /> View on HuggingFace
</button>
)}
</div>
</>
)
}
+123
View File
@@ -0,0 +1,123 @@
import { LibraryModal } from './library/LibraryModal'
import { LibraryItemCard } from './library/LibraryItemCard'
import { LoraInfoPopover } from './LoraInfoPopover'
import { useHfAuth } from '../hooks/use-hf-auth'
import type { LibraryEntry } from '../lib/lora-library'
export type LibraryKind = 'lora' | 'ic-lora'
interface LoraLibraryModalProps {
open: boolean
onClose: () => void
kind: LibraryKind
items: LibraryEntry[]
selectedId: string | null
// Which variant of selectedId is actually active — lets the card badge the exact
// installed checkpoint as "Selected" rather than the whole catalog entry.
selectedVariantId?: string | null
downloadingKey: string | null
progress: number
downloadError: { key: string; message: string } | null
// Transient message (e.g. "still syncing installed files") shown when onSelect returns false.
syncError?: string | null
onDownload: (id: string, variantId?: string) => void
// Returns whether the entry was actually selected — the modal only closes on success.
onSelect: (entry: LibraryEntry, variantId?: string) => boolean
}
// Generic catalog library modal — works for both plain LoRAs and IC-LoRAs. The consumer
// maps its API/on-disk types into LibraryEntry[]; LibraryModal/LibraryItemCard stay
// domain-agnostic.
export function LoraLibraryModal({
open, onClose, kind, items, selectedId, selectedVariantId, downloadingKey, progress, downloadError,
syncError, onDownload, onSelect,
}: LoraLibraryModalProps) {
// A catalog entry with no download variants has nothing to show a card for.
const visibleItems = items.filter(e => (e.variants?.length ?? 0) > 0)
// HF "Connect" CTA: shown only when a gated item is visible and we're not signed in.
const anyGated = visibleItems.some(e => e.requiresHfLogin)
const { hfAuthStatus } = useHfAuth(open && anyGated)
const showConnectCta = anyGated && hfAuthStatus !== 'authenticated'
const handleConnect = () => {
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'apiKeys' } }))
onClose()
}
return (
<LibraryModal
open={open}
onClose={onClose}
title={kind === 'ic-lora' ? 'IC-LoRAs' : 'LoRAs'}
headerSlot={showConnectCta ? (
<button
onClick={handleConnect}
className="inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-indigo-500"
>
Connect HuggingFace
</button>
) : undefined}
>
{syncError && (
<div className="mb-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-300">
{syncError}
</div>
)}
{visibleItems.length === 0 ? (
<div className="px-2 py-8 text-center text-xs text-zinc-500">
{kind === 'ic-lora' ? 'No IC-LoRAs available.' : 'No LoRAs found. Downloaded and installed LoRAs will appear here.'}
</div>
) : (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3">
{visibleItems.map(e => {
const cardError = !downloadError ? null
: (e.requiresHfLogin && hfAuthStatus !== 'authenticated')
? { key: downloadError.key, message: 'Sign in to HuggingFace (top right), then retry.', gated: false }
: { ...downloadError, gated: Boolean(e.requiresHfLogin) }
return (
<LibraryItemCard
key={e.id}
selected={selectedId === e.id}
selectedVariantId={selectedId === e.id ? selectedVariantId : undefined}
downloadingKey={downloadingKey}
progress={progress}
downloadError={cardError}
item={{
id: e.id,
title: e.name,
description: e.description || undefined,
sizeBytes: e.sizeBytes,
downloaded: e.downloaded,
thumbnailUrl: e.thumbnailUrl,
demoVideoUrl: e.demoVideoUrl,
author: e.author,
license: e.license,
requiresHfLogin: e.requiresHfLogin,
variants: e.variants?.map(v => ({
id: v.id,
label: v.label,
sizeBytes: v.sizeBytes,
downloaded: e.downloadedVariantIds?.includes(v.id),
})),
defaultVariantId: e.defaultVariantId,
}}
infoSlot={(
<LoraInfoPopover
sections={e.instructions ?? []}
name={e.name}
repoId={e.repoId}
isCommunity={e.author?.affiliation !== 'ltx'}
maxHeight="45vh"
/>
)}
onDownload={onDownload}
onRetry={onDownload}
onRequestAccess={() => { if (e.repoId) void window.electronAPI.openHuggingFaceRepo({ repoId: e.repoId }) }}
onUse={(_id, variantId) => { if (onSelect(e, variantId)) onClose() }}
/>
)
})}
</div>
)}
</LibraryModal>
)
}
+72
View File
@@ -0,0 +1,72 @@
import React, { forwardRef } from 'react'
import { ExternalLink, KeyRound } from 'lucide-react'
interface LtxApiKeyInputProps {
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
placeholder?: string
id?: string
stopPropagation?: boolean
className?: string
}
export const LtxApiKeyInput = forwardRef<HTMLInputElement, LtxApiKeyInputProps>(
({ value, onChange, placeholder = 'Paste your API key', id, stopPropagation, className }, ref) => {
return (
<div className={`relative ${className ?? ''}`}>
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-500" />
<input
ref={ref}
id={id}
type="password"
value={value}
onChange={onChange}
onClick={stopPropagation ? (e) => e.stopPropagation() : undefined}
placeholder={placeholder}
autoComplete="off"
className="w-full rounded-lg border border-zinc-700 bg-zinc-900 py-2.5 pl-9 pr-3 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
)
},
)
LtxApiKeyInput.displayName = 'LtxApiKeyInput'
interface ApiKeyHelperRowProps {
stopPropagation?: boolean
label?: string
onOpenKey?: () => void
}
export function ApiKeyHelperRow({ stopPropagation, label = 'Get API key', onOpenKey }: ApiKeyHelperRowProps) {
return (
<div className="mt-2 flex items-center justify-between gap-3">
<span className="text-xs text-zinc-500">Your key stays in your local app settings.</span>
<button
type="button"
onClick={(e) => {
if (stopPropagation) e.stopPropagation()
onOpenKey?.()
}}
className="inline-flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300"
>
{label}
<ExternalLink className="h-3 w-3" />
</button>
</div>
)
}
interface LtxApiKeyHelperRowProps {
stopPropagation?: boolean
}
export function LtxApiKeyHelperRow({ stopPropagation }: LtxApiKeyHelperRowProps) {
return (
<ApiKeyHelperRow
stopPropagation={stopPropagation}
label="Get API key"
onOpenKey={() => window.electronAPI.openLtxApiKeyPage()}
/>
)
}
+18
View File
@@ -0,0 +1,18 @@
interface LtxLogoProps {
className?: string
}
export function LtxLogo({ className = "h-6" }: LtxLogoProps) {
return (
<svg
className={className}
viewBox="0 0 75 32"
xmlns="http://www.w3.org/2000/svg"
fill="none"
>
<path d="M0 30.0087V7.50057C0 7.09765 0.154254 6.69973 0.460162 6.43869C0.708822 6.22729 0.987356 6.12029 1.29316 6.12029H8.2339C8.63671 6.12029 9.03463 6.26205 9.31316 6.55308C9.53944 6.79174 9.65133 7.07777 9.65133 7.41345V23.198C9.65133 23.6108 9.98462 23.944 10.3974 23.944H21.4638C21.8666 23.944 22.267 24.0858 22.5431 24.3767C22.7668 24.6155 22.8812 24.9015 22.8812 25.2372V30.5856C22.8812 31.0457 22.6823 31.4982 22.3018 31.7569C22.078 31.9086 21.8244 31.9832 21.5383 31.9832L1.99199 31.9956C0.890348 31.9981 0 31.1078 0 30.0087Z" fill="currentColor"/>
<path d="M36.5888 31.9926C34.4062 31.9876 32.492 31.6543 30.8413 30.9878C29.1906 30.3214 27.9104 29.2346 26.9981 27.7227C26.0856 26.2132 25.6333 24.2137 25.6382 21.7269L25.6532 13.7194L21.7016 13.7119C21.3486 13.7119 21.0528 13.5876 20.8116 13.3365C20.5705 13.0853 20.4512 12.7819 20.4537 12.4164L20.4636 7.39299C20.4636 7.02744 20.5854 6.72154 20.8265 6.47288C21.0677 6.22422 21.3635 6.09983 21.7165 6.10233L25.6681 6.10983L25.6779 1.29066C25.6779 0.925114 25.7998 0.619208 26.041 0.370548C26.2821 0.121887 26.5779 0 26.9309 0L33.9065 0.0124913C34.2595 0.0124913 34.5554 0.136772 34.7965 0.387931C35.0376 0.639089 35.1569 0.944995 35.1545 1.30805L35.1445 6.12721L41.2078 6.13959C41.5608 6.13959 41.8566 6.26398 42.0977 6.51514C42.3389 6.76629 42.4582 7.0722 42.4557 7.43525L42.4458 12.4586C42.4458 12.8242 42.3239 13.1301 42.0829 13.3787C41.8417 13.6274 41.5434 13.7518 41.1928 13.7493L35.1296 13.7368L35.1171 20.8988C35.1171 21.8613 35.3061 22.6148 35.6914 23.1618C36.0767 23.7089 36.6833 23.985 37.5186 23.985L41.5608 23.9925C41.9138 23.9925 42.2096 24.1168 42.4507 24.368C42.6919 24.6192 42.8113 24.9251 42.8088 25.2881L42.7988 30.7093C42.7988 31.075 42.677 31.3808 42.4358 31.6294C42.1947 31.8782 41.8963 32.0025 41.5459 32L36.5913 31.9901L36.5888 31.9926Z" fill="currentColor"/>
<path d="M47.5486 31.9851C47.2282 31.9851 46.965 31.8682 46.7589 31.6369C46.5503 31.4056 46.4485 31.1395 46.4485 30.841C46.4485 30.7416 46.4634 30.6248 46.4957 30.4929C46.5279 30.3611 46.5926 30.2268 46.6869 30.0951L54.3506 18.9342C54.4648 18.7675 54.4673 18.5463 54.3556 18.3771L47.4543 8.01457C47.3896 7.91517 47.335 7.79827 47.2854 7.6664C47.2382 7.53463 47.2133 7.40036 47.2133 7.26859C47.2133 6.97017 47.3251 6.70403 47.5486 6.47275C47.7722 6.24147 48.0279 6.12458 48.316 6.12458H55.6444C56.0914 6.12458 56.4267 6.23158 56.6501 6.44787C56.8737 6.66426 57.0327 6.85328 57.1295 7.01993L60.3082 11.8169C60.5043 12.1128 60.939 12.1128 61.1352 11.8169L64.3139 7.01993C64.4405 6.85328 64.6094 6.66426 64.8156 6.44787C65.0216 6.23158 65.3494 6.12458 65.7964 6.12458H72.7896C73.0778 6.12458 73.331 6.24147 73.557 6.47275C73.7805 6.70403 73.8922 6.95268 73.8922 7.21883C73.8922 7.38547 73.8748 7.53463 73.8451 7.6664C73.8128 7.79827 73.7482 7.91517 73.6539 8.01457L66.6159 18.3747C66.4992 18.5463 66.5017 18.77 66.6209 18.9392L74.4212 30.0975C74.5181 30.2293 74.5801 30.3636 74.6124 30.4954C74.6448 30.6273 74.6596 30.744 74.6596 30.8435C74.6596 31.142 74.5479 31.4081 74.3244 31.6394C74.1008 31.8707 73.8451 31.9874 73.557 31.9874H65.8934C65.4786 31.9874 65.1756 31.888 64.9844 31.689C64.7932 31.4901 64.6317 31.3086 64.5051 31.142L60.9886 25.9544C60.7924 25.671 60.3753 25.6685 60.1766 25.9471L56.4118 31.1395C56.3149 31.3061 56.1634 31.4876 55.9573 31.6865C55.7488 31.8855 55.4383 31.9851 55.0236 31.9851H47.5486Z" fill="currentColor"/>
</svg>
)
}
+65
View File
@@ -0,0 +1,65 @@
.ltx-upgrade-backdrop {
animation: ltx-upgrade-backdrop-in 180ms ease-out;
}
.ltx-upgrade-card {
animation: ltx-upgrade-card-in 260ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ltx-upgrade-reveal {
animation: ltx-upgrade-reveal 260ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ltx-upgrade-indeterminate {
position: relative;
overflow: hidden;
}
.ltx-upgrade-indeterminate::after {
content: '';
position: absolute;
inset: 0;
width: 40%;
background: linear-gradient(90deg, transparent, rgba(191, 219, 254, 0.95), transparent);
animation: ltx-upgrade-indeterminate 1.15s ease-in-out infinite;
}
@keyframes ltx-upgrade-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes ltx-upgrade-card-in {
from {
opacity: 0;
transform: translateY(16px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes ltx-upgrade-reveal {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes ltx-upgrade-indeterminate {
from {
transform: translateX(-120%);
}
to {
transform: translateX(320%);
}
}
+383
View File
@@ -0,0 +1,383 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { AlertCircle, Download, Loader2, Sparkles, X } from 'lucide-react'
import { useHfAuth } from '../hooks/use-hf-auth'
import { useHfModelAccess } from '../hooks/use-hf-model-access'
import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../lib/api-client'
import { logger } from '../lib/logger'
import { HfModelAccessGate } from './HfModelAccessGate'
import { Button } from './ui/button'
import './LtxUpgradePrompt.css'
type UpgradeRecommendation = Extract<ApiSuccessOf<'getLtxRecommendation'>, { status: 'upgrade' }>
type ModelCheckpointID = NonNullable<
NonNullable<ApiRequestBodyOf<'checkModelAccess'>>['cp_ids']
>[number]
interface LtxUpgradePromptProps {
recommendation: UpgradeRecommendation
onClose: () => void
// Dismiss permanently for this model id (persisted), so the prompt doesn't return for it.
onDontShowAgain: () => void
onComplete: () => Promise<void> | void
}
type UpgradePhase = 'idle' | 'starting' | 'downloading' | 'finishing'
function formatCheckpointId(cpId: string): string {
return cpId.replace(/-/g, ' ')
}
export function LtxUpgradePrompt({
recommendation,
onClose,
onDontShowAgain,
onComplete,
}: LtxUpgradePromptProps) {
const [wantsUpgrade, setWantsUpgrade] = useState(false)
const [phase, setPhase] = useState<UpgradePhase>('idle')
const [downloadSessionId, setDownloadSessionId] = useState<string | null>(null)
const [downloadProgress, setDownloadProgress] = useState<ApiSuccessOf<'getModelDownloadProgress'> | null>(
null,
)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
// Default off when deleting the old bundle would also wipe built-in Union Control IC-LoRA
// (2.3 → 2.5). Otherwise default on to reclaim the tens of GB the old transformer uses.
const [deleteOld, setDeleteOld] = useState(!recommendation.loses_built_in_control)
const cpsToDownload = useMemo(
() => recommendation.cps_to_download as ModelCheckpointID[],
[recommendation.cps_to_download],
)
const { hfAuthStatus, hfAuthPolling, startHuggingFaceLogin } = useHfAuth(true)
const { accessMap, allAuthorized, checking: checkingAccess, checkError, recheckAccess } = useHfModelAccess(
cpsToDownload,
hfAuthStatus,
)
const hasOldToDelete = recommendation.cps_to_delete.length > 0
const canClose = phase === 'idle'
const canStartUpgrade = wantsUpgrade && phase === 'idle' && allAuthorized && !checkingAccess
const runningProgress = downloadProgress?.status === 'downloading' ? downloadProgress : null
const totalProgress = runningProgress?.total_progress ?? (phase === 'finishing' ? 100 : 0)
const completedCount = runningProgress?.completed_files.length ?? 0
const totalCount = runningProgress?.all_files.length ?? recommendation.cps_to_download.length
useEffect(() => {
if (!canClose) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [canClose, onClose])
useEffect(() => {
if (phase !== 'downloading' || !downloadSessionId) return
let cancelled = false
const pollProgress = async () => {
const progressResult = await ApiClient.getModelDownloadProgress({ sessionId: downloadSessionId })
if (!progressResult.ok) {
logger.warn(`Failed polling LTX upgrade progress: ${progressResult.error.message}`)
return
}
if (cancelled) return
const progress = progressResult.data
setDownloadProgress(progress)
if (progress.status === 'error') {
setPhase('idle')
setErrorMessage(progress.error || 'Upgrade download failed.')
return
}
if (progress.status === 'complete') {
setPhase('finishing')
if (deleteOld && recommendation.cps_to_delete.length > 0) {
const deleteResult = await ApiClient.deleteModels({ cp_ids: recommendation.cps_to_delete })
if (!deleteResult.ok) {
logger.error(`Failed finalizing LTX upgrade: ${deleteResult.error.message}`)
if (cancelled) return
setPhase('idle')
setErrorMessage(deleteResult.error.message)
return
}
}
try {
await onComplete()
if (cancelled) return
// Reset to idle rather than waiting for the parent's refresh to flip away from 'upgrade'
// — if the active model isn't flipped yet we'd otherwise be stuck on "Finishing up...".
setPhase('idle')
onClose()
} catch (e) {
logger.error(`Failed finalizing LTX upgrade: ${e}`)
if (cancelled) return
setPhase('idle')
setErrorMessage(e instanceof Error ? e.message : 'Upgrade downloaded, but cleanup failed.')
}
}
}
void pollProgress()
const interval = setInterval(() => {
void pollProgress()
}, 700)
return () => {
cancelled = true
clearInterval(interval)
}
}, [deleteOld, downloadSessionId, onClose, onComplete, phase, recommendation.cps_to_delete])
const handleStartUpgrade = useCallback(async () => {
if (!canStartUpgrade) return
setErrorMessage(null)
setDownloadProgress(null)
setPhase('starting')
const result = await ApiClient.startModelDownload({
type: 'upgrade',
cp_ids: recommendation.cps_to_download,
})
if (!result.ok) {
logger.warn(`Failed to start LTX upgrade download: ${result.error.message}`)
setPhase('idle')
setErrorMessage(result.error.message)
return
}
const response = result.data
if (response.status !== 'started') {
setPhase('idle')
setErrorMessage('Unexpected response while starting the upgrade.')
return
}
setDownloadSessionId(response.sessionId)
setPhase('downloading')
}, [canStartUpgrade, recommendation.cps_to_download])
return (
<div className="ltx-upgrade-backdrop fixed inset-0 z-[55] flex items-center justify-center bg-black/72 px-4 py-6 backdrop-blur-sm">
<div
className="absolute inset-0"
onClick={() => {
if (canClose) onClose()
}}
/>
<div className="ltx-upgrade-card relative w-full max-w-[640px] overflow-hidden rounded-[28px] border border-blue-500/20 bg-[#04070d] shadow-[0_24px_120px_rgba(0,0,0,0.72)]">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.16),_transparent_44%),linear-gradient(180deg,rgba(15,23,42,0.68),rgba(2,6,23,0.16))]" />
<div className="relative px-6 pb-6 pt-6 sm:px-8">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="mt-0.5 flex h-12 w-12 items-center justify-center rounded-2xl border border-blue-400/20 bg-blue-500/12">
<Sparkles className="h-5 w-5 text-blue-200" />
</div>
<div>
<div className="inline-flex items-center rounded-full border border-blue-400/20 bg-blue-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-blue-200/85">
Optional Upgrade
</div>
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-white sm:text-[30px]">
LTX Model Upgrade Detected!
</h2>
<p className="mt-2 text-sm text-blue-100/78">
Upgrade target: <span className="font-medium text-blue-50">{recommendation.ltx_model_id}</span>
</p>
</div>
</div>
{canClose && (
<div className="flex items-center gap-1">
<button
type="button"
onClick={onDontShowAgain}
className="rounded-full px-3 py-1.5 text-xs text-blue-100/55 transition-colors hover:bg-white/5 hover:text-white"
>
Don't show again
</button>
<button
type="button"
onClick={onClose}
className="rounded-full p-2 text-blue-100/55 transition-colors hover:bg-white/5 hover:text-white"
aria-label="Close LTX upgrade prompt"
>
<X className="h-4 w-4" />
</button>
</div>
)}
</div>
<div className="mt-6 rounded-[24px] border border-blue-400/16 bg-[linear-gradient(145deg,rgba(10,14,22,0.98),rgba(5,8,14,0.96))] p-5 sm:p-6">
{recommendation.upgrade_message ? (
<>
<p className="text-lg font-semibold leading-snug text-blue-50 sm:text-[22px]">What's new in this version</p>
<ul className="mt-3 space-y-1.5">
{recommendation.upgrade_message.split('\n').map((line) => line.trim()).filter(Boolean).map((line, i) => (
<li key={i} className="flex gap-2 text-sm leading-relaxed text-blue-100/82">
<span className="mt-0.5 text-blue-300/70">•</span>
<span>{line}</span>
</li>
))}
</ul>
</>
) : (
<p className="text-lg font-semibold leading-snug text-blue-50 sm:text-[22px]">
A better LTX checkpoint is ready for this install.
</p>
)}
{hasOldToDelete && (
<p className="mt-3 max-w-[44ch] text-sm leading-relaxed text-blue-100/72">
{deleteOld
? recommendation.loses_built_in_control
? 'Your previous checkpoint and its built-in depth/canny/pose control models will be removed from disk.'
: 'Your previous checkpoint will be removed from disk once the download completes.'
: 'Your previous checkpoint will be kept — switch between versions anytime in Settings → Models.'}
</p>
)}
<label className="mt-5 flex cursor-pointer items-center gap-3 rounded-2xl border border-blue-400/12 bg-black/35 px-4 py-3 text-sm text-blue-50/92 transition-colors hover:border-blue-300/22">
<input
type="checkbox"
checked={wantsUpgrade}
onChange={(event) => setWantsUpgrade(event.target.checked)}
disabled={!canClose}
className="h-4 w-4 rounded border-blue-300/40 bg-slate-950 text-blue-500 focus:ring-blue-400"
/>
<span className="font-medium">I want this!</span>
</label>
{wantsUpgrade && hasOldToDelete && (
<label className="mt-3 flex cursor-pointer items-center gap-3 rounded-2xl border border-blue-400/12 bg-black/35 px-4 py-3 text-sm text-blue-50/92 transition-colors hover:border-blue-300/22">
<input
type="checkbox"
checked={deleteOld}
onChange={(event) => setDeleteOld(event.target.checked)}
disabled={!canClose}
className="h-4 w-4 rounded border-blue-300/40 bg-slate-950 text-blue-500 focus:ring-blue-400"
/>
<span className="font-medium">
{recommendation.loses_built_in_control
? 'Delete the previous checkpoint (also removes built-in control models)'
: 'Delete the previous checkpoint to free up disk space'}
</span>
</label>
)}
</div>
{wantsUpgrade && (
<div className="mt-5 space-y-4">
{!allAuthorized && (
<div className="ltx-upgrade-reveal rounded-2xl border border-amber-400/16 bg-[#060b14] p-5">
<HfModelAccessGate
accessMap={accessMap}
allAuthorized={allAuthorized}
hfAuthStatus={hfAuthStatus}
hfAuthPolling={hfAuthPolling}
startHuggingFaceLogin={() => {
void startHuggingFaceLogin()
}}
checkError={checkError}
onRetryCheck={recheckAccess}
/>
</div>
)}
{canStartUpgrade && (
<div className="ltx-upgrade-reveal rounded-2xl border border-blue-400/12 bg-[#060b14] p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-semibold text-white">Ready when you are</p>
<p className="mt-1 text-sm text-blue-100/72">
This will download {recommendation.cps_to_download.length} checkpoint
{recommendation.cps_to_download.length === 1 ? '' : 's'}
{hasOldToDelete && deleteOld ? ' and remove the old one.' : '.'}
</p>
</div>
<Button
onClick={() => {
void handleStartUpgrade()
}}
className="bg-blue-600 text-white hover:bg-blue-500"
>
<Download className="mr-2 h-4 w-4" />
Upgrade now
</Button>
</div>
</div>
)}
</div>
)}
{phase !== 'idle' && (
<div className="mt-5 rounded-2xl border border-blue-400/12 bg-[#060b14] p-5">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-sm font-semibold text-white">
{phase === 'starting' ? 'Preparing your upgrade...' : phase === 'downloading' ? 'Downloading update...' : 'Finishing up...'}
</h3>
<p className="mt-1 text-sm text-blue-100/70">
{phase === 'finishing'
? (deleteOld && hasOldToDelete ? 'Cleaning up the previous checkpoint files.' : 'Finalizing the upgrade.')
: 'Keep this window open while the new checkpoint is downloaded.'}
</p>
</div>
<div className="inline-flex items-center gap-2 rounded-full border border-blue-400/14 bg-blue-500/10 px-3 py-1 text-xs text-blue-100/82">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Working
</div>
</div>
<div className="mt-5">
<div className="mb-2 flex items-center justify-between text-xs text-blue-100/72">
<span>
{phase === 'starting'
? 'Waiting for the download session to start'
: `Checkpoint progress ${Math.min(completedCount + (runningProgress ? 1 : 0), totalCount)} / ${totalCount}`}
</span>
<span>{Math.round(totalProgress)}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-950/60">
{phase === 'starting' ? (
<div className="ltx-upgrade-indeterminate h-full bg-blue-500/55" />
) : (
<div
className="h-full rounded-full bg-[linear-gradient(90deg,#60a5fa,#2563eb)] transition-all duration-300"
style={{ width: `${Math.max(totalProgress, 4)}%` }}
/>
)}
</div>
{runningProgress?.current_downloading_file && (
<div className="mt-3 space-y-1">
<div className="flex items-center justify-between text-xs text-blue-100/70">
<span className="truncate">Current file</span>
<span>{Math.round(runningProgress.current_file_progress)}%</span>
</div>
<div className="truncate text-sm text-blue-50/85">
{formatCheckpointId(runningProgress.current_downloading_file)}
</div>
</div>
)}
</div>
</div>
)}
{errorMessage && (
<div className="mt-5 flex items-start gap-3 rounded-2xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-100">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-300" />
<span>{errorMessage}</span>
</div>
)}
</div>
</div>
</div>
)
}
+251
View File
@@ -0,0 +1,251 @@
import React, { useState, useRef, useEffect, useMemo } from 'react'
import { Search } from 'lucide-react'
// --- Types ---
export interface MenuItem {
id: string
label: string
shortcut?: string
action?: () => void
disabled?: boolean
separator?: boolean // renders a divider line
submenu?: MenuItem[]
}
export interface MenuDefinition {
id: string
label: string
items: MenuItem[]
}
interface MenuBarProps {
menus: MenuDefinition[]
rightContent?: React.ReactNode
}
// --- Component ---
export function MenuBar({ menus, rightContent }: MenuBarProps) {
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
const [hoverMenuId, setHoverMenuId] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [highlightedResult, setHighlightedResult] = useState(0)
const menuBarRef = useRef<HTMLDivElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
// The active open menu (follow hover once a menu is open)
const activeMenuId = openMenuId ? (hoverMenuId || openMenuId) : null
// Close menus on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuBarRef.current && !menuBarRef.current.contains(e.target as Node)) {
setOpenMenuId(null)
setHoverMenuId(null)
}
}
if (openMenuId) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [openMenuId])
// Close on Escape
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setOpenMenuId(null)
setHoverMenuId(null)
setSearchQuery('')
}
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [])
const searchResults = useMemo(() => {
const query = searchQuery.trim().toLowerCase()
if (!query) return []
const results: { menuLabel: string; item: MenuItem }[] = []
for (const menu of menus) {
for (const item of menu.items) {
if (item.separator) continue
if (item.label.toLowerCase().includes(query)) {
results.push({ menuLabel: menu.label, item })
}
if (item.submenu) {
for (const sub of item.submenu) {
if (sub.separator) continue
if (sub.label.toLowerCase().includes(query)) {
results.push({ menuLabel: `${menu.label} > ${item.label}`, item: sub })
}
}
}
}
}
return results
}, [menus, searchQuery])
useEffect(() => {
setHighlightedResult(prev => {
if (searchResults.length === 0) return 0
return Math.min(prev, searchResults.length - 1)
})
}, [searchResults.length])
const handleItemClick = (item: MenuItem) => {
if (item.disabled || !item.action) return
item.action()
setOpenMenuId(null)
setHoverMenuId(null)
}
const handleSearchResultClick = (item: MenuItem) => {
if (item.disabled || !item.action) return
item.action()
setSearchQuery('')
setOpenMenuId(null)
setHoverMenuId(null)
}
// Handle search keyboard navigation
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setHighlightedResult(prev => Math.min(prev + 1, searchResults.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setHighlightedResult(prev => Math.max(prev - 1, 0))
} else if (e.key === 'Enter' && searchResults[highlightedResult]) {
e.preventDefault()
handleSearchResultClick(searchResults[highlightedResult].item)
}
}
const renderMenuItem = (item: MenuItem, index: number) => {
if (item.separator) {
return <div key={`sep-${index}`} className="h-px bg-zinc-700 my-1 mx-2" />
}
return (
<button
key={item.id}
onClick={() => handleItemClick(item)}
disabled={item.disabled}
className={`w-full flex items-center justify-between px-3 py-1.5 text-left text-[13px] transition-colors ${
item.disabled
? 'text-zinc-600 cursor-not-allowed'
: 'text-zinc-200 hover:bg-blue-600 hover:text-white'
}`}
>
<span>{item.label}</span>
{item.shortcut && (
<span className={`ml-8 text-[11px] ${item.disabled ? 'text-zinc-700' : 'text-zinc-500'}`}>
{item.shortcut}
</span>
)}
</button>
)
}
return (
<div ref={menuBarRef} className="flex items-center bg-zinc-900 border-b border-zinc-800 select-none relative z-[60]">
<div className="flex items-center flex-1">
{menus.map(menu => {
const isActive = activeMenuId === menu.id
const isHelpMenu = menu.id === 'help'
return (
<div key={menu.id} className="relative">
<button
onMouseDown={() => {
if (openMenuId === menu.id) {
setOpenMenuId(null)
setHoverMenuId(null)
} else {
setOpenMenuId(menu.id)
setHoverMenuId(null)
if (isHelpMenu) {
setTimeout(() => searchInputRef.current?.focus(), 50)
}
}
}}
onMouseEnter={() => {
if (openMenuId) setHoverMenuId(menu.id)
}}
className={`px-3 py-1.5 text-[13px] font-medium transition-colors ${
isActive
? 'bg-zinc-800 text-white'
: 'text-zinc-400 hover:text-zinc-200'
}`}
>
{menu.label}
</button>
{/* Dropdown */}
{isActive && (
<div className="absolute top-full left-0 min-w-[240px] bg-zinc-900 border border-zinc-700 rounded-b-lg shadow-xl shadow-black/50 py-1 z-[60]">
{/* Help menu has search */}
{isHelpMenu && (
<div className="px-2 py-1.5 border-b border-zinc-700">
<div className="flex items-center gap-2 bg-zinc-800 rounded px-2 py-1">
<Search className="h-3.5 w-3.5 text-zinc-500 flex-shrink-0" />
<input
ref={searchInputRef}
type="text"
placeholder="Search menus..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearchKeyDown}
className="flex-1 bg-transparent text-[13px] text-white placeholder-zinc-500 outline-none"
autoFocus
/>
</div>
{/* Search results */}
{searchQuery && (
<div className="mt-1 max-h-48 overflow-y-auto">
{searchResults.length === 0 ? (
<div className="text-[12px] text-zinc-500 px-2 py-2 text-center">No results</div>
) : (
searchResults.map((result, i) => (
<button
key={`${result.item.id}-${i}`}
onClick={() => handleSearchResultClick(result.item)}
className={`w-full flex items-center justify-between px-2 py-1.5 text-left text-[12px] rounded transition-colors ${
i === highlightedResult
? 'bg-blue-600 text-white'
: 'text-zinc-300 hover:bg-zinc-800'
}`}
>
<div>
<span>{result.item.label}</span>
<span className="text-[10px] text-zinc-500 ml-2">{result.menuLabel}</span>
</div>
{result.item.shortcut && (
<span className="text-[10px] text-zinc-500">{result.item.shortcut}</span>
)}
</button>
))
)}
</div>
)}
</div>
)}
{/* Regular menu items */}
{menu.items.map((item, i) => renderMenuItem(item, i))}
</div>
)}
</div>
)
})}
</div>
{rightContent && (
<div className="flex items-center mr-2">
{rightContent}
</div>
)}
</div>
)
}
+134
View File
@@ -0,0 +1,134 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { ImagePlus, Plus } from 'lucide-react'
import { imagePathsFromDataTransfer, imagePathsFromFiles } from '../lib/keyframe-drop'
import { lastFrameFromDuration, type DraggedFrame } from '../lib/keyframe-timeline'
import { applyKeyframeImagePaths, type KeyframeItem } from '../lib/multi-keyframe'
import { KeyframeTimeline } from './KeyframeTimeline'
interface MultiKeyframePanelProps {
keyframes: readonly KeyframeItem[]
duration: number | null
fps: number
maxCount: number
playheadFrame: number
onPlayheadChange: (frameIndex: number) => void
onChange: (keyframes: KeyframeItem[]) => void
onDragFrameChange?: (drag: DraggedFrame | null) => void
}
export function MultiKeyframePanel({
keyframes,
duration,
fps,
maxCount,
playheadFrame,
onPlayheadChange,
onChange,
onDragFrameChange,
}: MultiKeyframePanelProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const replaceIdRef = useRef<string | null>(null)
const [isDragOver, setIsDragOver] = useState(false)
const lastFrame = duration == null ? null : lastFrameFromDuration(duration, fps)
const addEnabled = lastFrame !== null && keyframes.length < maxCount
useLayoutEffect(() => {
if (lastFrame === null) onDragFrameChange?.(null)
}, [lastFrame, onDragFrameChange])
const applyPaths = (paths: string[], replaceId: string | null = null) => {
onChange(applyKeyframeImagePaths({
keyframes,
paths,
replaceId,
lastFrame,
preferredFrame: playheadFrame,
maxCount,
}))
}
const openFilePicker = (replaceId: string | null) => {
replaceIdRef.current = replaceId
fileInputRef.current?.click()
}
return (
<div className="border-b border-zinc-800/60 px-2 pb-2 pt-2">
<div className="mb-1.5 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-zinc-300">
<ImagePlus className="h-3.5 w-3.5 text-zinc-500" />
<span>Keyframes</span>
<span className="text-zinc-600">{keyframes.length}/{maxCount}</span>
</div>
<button
type="button"
disabled={!addEnabled}
title={keyframes.length >= maxCount
? `You can place up to ${maxCount} keyframes`
: lastFrame === null
? 'Choose a duration to place keyframes'
: 'Add keyframes at the playhead'}
className="flex h-6 items-center gap-1 rounded-md border border-dashed border-zinc-700 px-2 text-[10px] text-zinc-400 hover:border-zinc-500 hover:text-zinc-200 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => openFilePicker(null)}
onDragOver={(event) => {
event.preventDefault()
if (addEnabled) setIsDragOver(true)
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={(event) => {
event.preventDefault()
setIsDragOver(false)
applyPaths(imagePathsFromDataTransfer(event.dataTransfer))
}}
>
<Plus className="h-3 w-3" />
<span className={isDragOver ? 'text-blue-300' : undefined}>Add keyframe</span>
</button>
</div>
{lastFrame === null ? (
<div className="flex h-12 items-center justify-center rounded-lg border border-dashed border-zinc-700 text-[10px] text-zinc-500">
Choose a duration to place keyframes
</div>
) : (
<KeyframeTimeline
keyframes={keyframes}
fps={fps}
lastFrame={lastFrame}
playheadFrame={Math.min(playheadFrame, lastFrame)}
onPlayheadChange={onPlayheadChange}
onDragFrameChange={onDragFrameChange}
onFrameChange={(id, frameIndex) => {
onChange(keyframes.map((keyframe) => (
keyframe.id === id ? { ...keyframe, frameIndex } : keyframe
)))
onPlayheadChange(frameIndex)
}}
onStrengthChange={(id, strength) => {
onChange(keyframes.map((keyframe) => (
keyframe.id === id ? { ...keyframe, strength } : keyframe
)))
}}
onReplaceRequest={(id) => openFilePicker(id)}
onDelete={(id) => onChange(keyframes.filter((keyframe) => keyframe.id !== id))}
onImagesDrop={(dataTransfer, replaceId) => {
applyPaths(imagePathsFromDataTransfer(dataTransfer), replaceId)
}}
/>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => {
applyPaths(imagePathsFromFiles(event.target.files ?? []), replaceIdRef.current)
replaceIdRef.current = null
event.target.value = ''
}}
/>
</div>
)
}
@@ -0,0 +1,187 @@
import { useCallback } from 'react'
export interface OutpaintPads {
left: number
right: number
top: number
bottom: number
}
interface OutpaintCanvasEditorProps {
// Source video dimensions in pixels (the silhouette, fixed — never resized).
sourceWidth: number
sourceHeight: number
value: OutpaintPads
onChange: (pads: OutpaintPads) => void
}
// Square display box for the editor. The source sits centred; the output frame grows around it.
const STAGE = 260
// Aspect-ratio quick-snaps: expand the deficient axis to hit the ratio, centred. null = reset.
const PRESETS: { id: string; label: string; ratio: number | null }[] = [
{ id: 'reset', label: 'Reset', ratio: null },
{ id: '16:9', label: '16:9', ratio: 16 / 9 },
{ id: '1:1', label: '1:1', ratio: 1 },
{ id: '9:16', label: '9:16', ratio: 9 / 16 },
{ id: '21:9', label: '21:9', ratio: 21 / 9 },
]
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
const toEven = (v: number) => v + (v % 2)
// Handles: each names the edge(s) it moves ('l'/'r'/'t'/'b'). Corners move two.
const HANDLES = ['tl', 't', 'tr', 'l', 'r', 'bl', 'b', 'br'] as const
type Handle = (typeof HANDLES)[number]
export function OutpaintCanvasEditor({ sourceWidth, sourceHeight, value, onChange }: OutpaintCanvasEditorProps) {
// Cap = +100% per axis, so the max canvas is 3× the source on each side. Fit that into the stage.
const scale = Math.min(STAGE / (3 * sourceWidth), STAGE / (3 * sourceHeight))
const stageW = 3 * sourceWidth * scale
const stageH = 3 * sourceHeight * scale
const srcLeft = stageW / 2 - (sourceWidth * scale) / 2
const srcTop = stageH / 2 - (sourceHeight * scale) / 2
const frameLeft = srcLeft - value.left * scale
const frameTop = srcTop - value.top * scale
const frameW = (sourceWidth + value.left + value.right) * scale
const frameH = (sourceHeight + value.top + value.bottom) * scale
const outW = toEven(sourceWidth + value.left + value.right)
const outH = toEven(sourceHeight + value.top + value.bottom)
const startDrag = useCallback(
(e: React.PointerEvent, handle: Handle) => {
e.preventDefault()
e.stopPropagation()
const startX = e.clientX
const startY = e.clientY
const start = { ...value }
const move = (ev: PointerEvent) => {
const dx = (ev.clientX - startX) / scale
const dy = (ev.clientY - startY) / scale
const next = { ...start }
// Dragging the left/top edge outward (negative delta) grows that pad; clamp to [0, source axis].
if (handle.includes('l')) next.left = clamp(Math.round(start.left - dx), 0, sourceWidth)
if (handle.includes('r')) next.right = clamp(Math.round(start.right + dx), 0, sourceWidth)
if (handle.includes('t')) next.top = clamp(Math.round(start.top - dy), 0, sourceHeight)
if (handle.includes('b')) next.bottom = clamp(Math.round(start.bottom + dy), 0, sourceHeight)
onChange(next)
}
const up = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
},
[value, scale, sourceWidth, sourceHeight, onChange],
)
const applyPreset = useCallback(
(ratio: number | null) => {
if (ratio === null) {
onChange({ left: 0, right: 0, top: 0, bottom: 0 })
return
}
const current = sourceWidth / sourceHeight
if (current < ratio) {
// Too tall for the ratio → widen. Cap each side at +100% (total extra ≤ 2× width).
const extra = clamp(Math.round(sourceHeight * ratio) - sourceWidth, 0, 2 * sourceWidth)
const half = Math.round(extra / 2)
onChange({ left: half, right: extra - half, top: 0, bottom: 0 })
} else {
// Too wide → heighten.
const extra = clamp(Math.round(sourceWidth / ratio) - sourceHeight, 0, 2 * sourceHeight)
const half = Math.round(extra / 2)
onChange({ left: 0, right: 0, top: half, bottom: extra - half })
}
},
[sourceWidth, sourceHeight, onChange],
)
// A ratio is reachable only if the +100%-per-axis cap can grow the deficient axis far enough.
const isPresetReachable = (ratio: number | null): boolean => {
if (ratio === null) return true
const current = sourceWidth / sourceHeight
return current < ratio
? Math.round(sourceHeight * ratio) - sourceWidth <= 2 * sourceWidth
: Math.round(sourceWidth / ratio) - sourceHeight <= 2 * sourceHeight
}
// Handle display position (centre point) on the frame border, in stage coordinates.
const handlePos = (h: Handle): { x: number; y: number } => {
const midX = frameLeft + frameW / 2
const midY = frameTop + frameH / 2
const left = frameLeft
const right = frameLeft + frameW
const top = frameTop
const bottom = frameTop + frameH
switch (h) {
case 'tl': return { x: left, y: top }
case 't': return { x: midX, y: top }
case 'tr': return { x: right, y: top }
case 'l': return { x: left, y: midY }
case 'r': return { x: right, y: midY }
case 'bl': return { x: left, y: bottom }
case 'b': return { x: midX, y: bottom }
case 'br': return { x: right, y: bottom }
}
}
const handleCursor: Record<Handle, string> = {
tl: 'nwse-resize', t: 'ns-resize', tr: 'nesw-resize', l: 'ew-resize',
r: 'ew-resize', bl: 'nesw-resize', b: 'ns-resize', br: 'nwse-resize',
}
return (
<div className="flex-1 flex flex-col min-h-0 bg-black">
<div className="flex-1 flex items-center justify-center min-h-0 p-3">
<div className="relative" style={{ width: stageW, height: stageH }}>
{/* Output frame: the green-filled generate region around the source. */}
<div
className="absolute border border-emerald-500/60 bg-emerald-500/10"
style={{ left: frameLeft, top: frameTop, width: frameW, height: frameH }}
/>
{/* Source silhouette (kept region) — fixed size, centred. */}
<div
className="absolute bg-zinc-400/80 border border-zinc-300"
style={{ left: srcLeft, top: srcTop, width: sourceWidth * scale, height: sourceHeight * scale }}
/>
{HANDLES.map((h) => {
const { x, y } = handlePos(h)
return (
<div
key={h}
onPointerDown={(e) => startDrag(e, h)}
className="absolute w-2.5 h-2.5 -ml-1.5 -mt-1.5 rounded-sm bg-white border border-zinc-500 shadow"
style={{ left: x, top: y, cursor: handleCursor[h], touchAction: 'none' }}
/>
)
})}
</div>
</div>
<div className="px-3 py-2 border-t border-zinc-800 flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-1">
{PRESETS.map((p) => {
// Unreachable presets stay clickable but cap-to-closest (applyPreset clamps to the
// +100% extend cap) — marked with a · and a tooltip so it's not a silent wrong ratio.
const capped = !isPresetReachable(p.ratio)
return (
<button
key={p.id}
onClick={() => applyPreset(p.ratio)}
title={capped ? 'Limited by the +100% expand cap — lands at the closest ratio' : undefined}
className="px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 border border-zinc-700 transition-colors"
>
{p.label}
{capped && <span className="text-amber-500/80">·</span>}
</button>
)
})}
</div>
<span className="text-[10px] text-zinc-500 tabular-nums">
{sourceWidth}×{sourceHeight} → {outW}×{outH}
</span>
</div>
</div>
)
}
+300
View File
@@ -0,0 +1,300 @@
import { useState, useEffect, useRef } from 'react'
interface PythonSetupProps {
onReady: () => void
}
interface SetupProgress {
status: 'downloading' | 'extracting' | 'complete' | 'error'
percent: number
downloadedBytes: number
totalBytes: number
speed: number
}
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
const formatTimeRemaining = (seconds: number): string => {
if (!seconds || !isFinite(seconds) || seconds <= 0) return '--'
if (seconds < 60) return `${Math.round(seconds)}s`
if (seconds < 3600) return `${Math.round(seconds / 60)}m`
return `${Math.round(seconds / 3600)}h ${Math.round((seconds % 3600) / 60)}m`
}
export function PythonSetup({ onReady }: PythonSetupProps) {
const [progress, setProgress] = useState<SetupProgress | null>(null)
const [error, setError] = useState<string | null>(null)
const [videoPath, setVideoPath] = useState('/splash/splash.mp4')
const started = useRef(false)
const getTimeRemaining = (): string => {
if (!progress || progress.speed <= 0) return '--'
const remainingBytes = progress.totalBytes - progress.downloadedBytes
if (remainingBytes <= 0) return '--'
return formatTimeRemaining(remainingBytes / progress.speed)
}
// Resolve video path for production
useEffect(() => {
const init = async () => {
try {
const resourcePath = await window.electronAPI.getResourcePath?.()
if (resourcePath) {
setVideoPath(`file://${resourcePath}/app.asar.unpacked/dist/splash/splash.mp4`)
}
} catch {
// Dev mode: use relative path
}
}
init()
}, [])
// Listen for progress events
useEffect(() => {
window.electronAPI.onPythonSetupProgress((data: unknown) => {
const p = data as SetupProgress
setProgress(p)
if (p.status === 'complete') {
onReady()
} else if (p.status === 'error') {
setError('Download failed. Please check your internet connection and try again.')
}
})
return () => {
window.electronAPI.removePythonSetupProgress()
}
}, [onReady])
// Start download on mount
useEffect(() => {
if (started.current) return
started.current = true
startSetup()
}, [])
const startSetup = async () => {
setError(null)
try {
await window.electronAPI.startPythonSetup()
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to download Python environment.')
}
}
const statusLabel = progress?.status === 'extracting' ? 'Extracting...' : 'Downloading Python environment...'
const percent = progress?.percent ?? 0
return (
<div className="h-screen flex flex-col" style={{
background: '#000000',
fontFamily: 'Arial, Helvetica, sans-serif',
color: '#ffffff'
}}>
{/* Custom Title Bar */}
<div style={{
height: 32,
background: '#000000',
display: 'flex',
alignItems: 'center',
paddingLeft: 80,
borderBottom: '1px solid #1a1a1a',
// @ts-expect-error - Electron-specific CSS property
WebkitAppRegion: 'drag'
}}>
<span style={{ fontSize: 13, color: '#a0a0a0' }}>LTX Desktop</span>
</div>
{/* Main Container */}
<div style={{
display: 'flex',
flexDirection: 'column',
flex: 1,
// @ts-expect-error - Electron-specific CSS property
WebkitAppRegion: 'no-drag'
}}>
{/* Header */}
<div style={{
padding: '12px 32px',
borderBottom: '1px solid #1a1a1a'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<svg style={{ height: 24, width: 'auto' }} viewBox="0 0 75 32" xmlns="http://www.w3.org/2000/svg" fill="none">
<path d="M0 30.0087V7.50057C0 7.09765 0.154254 6.69973 0.460162 6.43869C0.708822 6.22729 0.987356 6.12029 1.29316 6.12029H8.2339C8.63671 6.12029 9.03463 6.26205 9.31316 6.55308C9.53944 6.79174 9.65133 7.07777 9.65133 7.41345V23.198C9.65133 23.6108 9.98462 23.944 10.3974 23.944H21.4638C21.8666 23.944 22.267 24.0858 22.5431 24.3767C22.7668 24.6155 22.8812 24.9015 22.8812 25.2372V30.5856C22.8812 31.0457 22.6823 31.4982 22.3018 31.7569C22.078 31.9086 21.8244 31.9832 21.5383 31.9832L1.99199 31.9956C0.890348 31.9981 0 31.1078 0 30.0087Z" fill="white"/>
<path d="M36.5888 31.9926C34.4062 31.9876 32.492 31.6543 30.8413 30.9878C29.1906 30.3214 27.9104 29.2346 26.9981 27.7227C26.0856 26.2132 25.6333 24.2137 25.6382 21.7269L25.6532 13.7194L21.7016 13.7119C21.3486 13.7119 21.0528 13.5876 20.8116 13.3365C20.5705 13.0853 20.4512 12.7819 20.4537 12.4164L20.4636 7.39299C20.4636 7.02744 20.5854 6.72154 20.8265 6.47288C21.0677 6.22422 21.3635 6.09983 21.7165 6.10233L25.6681 6.10983L25.6779 1.29066C25.6779 0.925114 25.7998 0.619208 26.041 0.370548C26.2821 0.121887 26.5779 0 26.9309 0L33.9065 0.0124913C34.2595 0.0124913 34.5554 0.136772 34.7965 0.387931C35.0376 0.639089 35.1569 0.944995 35.1545 1.30805L35.1445 6.12721L41.2078 6.13959C41.5608 6.13959 41.8566 6.26398 42.0977 6.51514C42.3389 6.76629 42.4582 7.0722 42.4557 7.43525L42.4458 12.4586C42.4458 12.8242 42.3239 13.1301 42.0829 13.3787C41.8417 13.6274 41.5434 13.7518 41.1928 13.7493L35.1296 13.7368L35.1171 20.8988C35.1171 21.8613 35.3061 22.6148 35.6914 23.1618C36.0767 23.7089 36.6833 23.985 37.5186 23.985L41.5608 23.9925C41.9138 23.9925 42.2096 24.1168 42.4507 24.368C42.6919 24.6192 42.8113 24.9251 42.8088 25.2881L42.7988 30.7093C42.7988 31.075 42.677 31.3808 42.4358 31.6294C42.1947 31.8782 41.8963 32.0025 41.5459 32L36.5913 31.9901L36.5888 31.9926Z" fill="white"/>
<path d="M47.5486 31.9851C47.2282 31.9851 46.965 31.8682 46.7589 31.6369C46.5503 31.4056 46.4485 31.1395 46.4485 30.841C46.4485 30.7416 46.4634 30.6248 46.4957 30.4929C46.5279 30.3611 46.5926 30.2268 46.6869 30.0951L54.3506 18.9342C54.4648 18.7675 54.4673 18.5463 54.3556 18.3771L47.4543 8.01457C47.3896 7.91517 47.335 7.79827 47.2854 7.6664C47.2382 7.53463 47.2133 7.40036 47.2133 7.26859C47.2133 6.97017 47.3251 6.70403 47.5486 6.47275C47.7722 6.24147 48.0279 6.12458 48.316 6.12458H55.6444C56.0914 6.12458 56.4267 6.23158 56.6501 6.44787C56.8737 6.66426 57.0327 6.85328 57.1295 7.01993L60.3082 11.8169C60.5043 12.1128 60.939 12.1128 61.1352 11.8169L64.3139 7.01993C64.4405 6.85328 64.6094 6.66426 64.8156 6.44787C65.0216 6.23158 65.3494 6.12458 65.7964 6.12458H72.7896C73.0778 6.12458 73.331 6.24147 73.557 6.47275C73.7805 6.70403 73.8922 6.95268 73.8922 7.21883C73.8922 7.38547 73.8748 7.53463 73.8451 7.6664C73.8128 7.79827 73.7482 7.91517 73.6539 8.01457L66.6159 18.3747C66.4992 18.5463 66.5017 18.77 66.6209 18.9392L74.4212 30.0975C74.5181 30.2293 74.5801 30.3636 74.6124 30.4954C74.6448 30.6273 74.6596 30.744 74.6596 30.8435C74.6596 31.142 74.5479 31.4081 74.3244 31.6394C74.1008 31.8707 73.8451 31.9874 73.557 31.9874H65.8934C65.4786 31.9874 65.1756 31.888 64.9844 31.689C64.7932 31.4901 64.6317 31.3086 64.5051 31.142L60.9886 25.9544C60.7924 25.671 60.3753 25.6685 60.1766 25.9471L56.4118 31.1395C56.3149 31.3061 56.1634 31.4876 55.9573 31.6865C55.7488 31.8855 55.4383 31.9851 55.0236 31.9851H47.5486Z" fill="white"/>
</svg>
</div>
</div>
{/* Content Area */}
<div style={{
flex: 1,
padding: 0,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
position: 'relative',
height: '100%',
}}>
{/* Video Section */}
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 140,
background: '#0a0a0a',
overflow: 'hidden'
}}>
<video
key={videoPath}
autoPlay
loop
muted
playsInline
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block'
}}
>
<source src={videoPath} type="video/mp4" />
</video>
</div>
{/* Progress Section */}
<div style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: 140,
background: '#0d0d0d',
padding: '16px 24px',
borderTop: '1px solid #2a2a2a'
}}>
{error ? (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
gap: 10,
}}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f87171" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span style={{ color: '#f87171', fontSize: 13, textAlign: 'center', maxWidth: 400 }}>{error}</span>
<button
onClick={() => { setError(null); started.current = false; startSetup() }}
style={{
padding: '6px 20px',
borderRadius: 9999,
fontSize: 13,
fontWeight: 600,
cursor: 'pointer',
background: 'linear-gradient(125deg, #A98BD9, #6D28D9)',
border: 'none',
color: '#ffffff',
}}
>
Retry
</button>
</div>
) : (
<>
{/* Header row */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8
}}>
<span style={{ fontSize: 13, fontWeight: 500 }}>
{statusLabel}
</span>
<span style={{ fontSize: 13, color: '#A98BD9', fontWeight: 600 }}>
{percent}%
</span>
</div>
{/* Progress Bar */}
<div style={{
height: 6,
background: '#1a1a1a',
borderRadius: 3,
overflow: 'hidden'
}}>
<div style={{
height: '100%',
background: 'linear-gradient(125deg, #A98BD9, #6D28D9, #194DF9)',
backgroundSize: '200% 200%',
animation: 'gradientShift 3s ease infinite',
borderRadius: 3,
width: `${percent}%`,
transition: 'width 0.3s ease'
}} />
</div>
{/* Stats row */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 10,
fontSize: 12,
color: '#a0a0a0'
}}>
<span style={{ flex: 1 }}>
{progress?.status === 'extracting'
? 'Setting up Python environment...'
: 'First-time setup — this only happens once'}
</span>
<div style={{ display: 'flex', gap: 16, marginLeft: 16, flexShrink: 0 }}>
{progress && progress.speed > 0 && (
<span style={{ color: '#6D28D9', fontWeight: 500 }}>
{(progress.speed / (1024 * 1024)).toFixed(1)} MB/s
</span>
)}
{progress && progress.downloadedBytes > 0 && (
<span>
{formatBytes(progress.downloadedBytes)}{progress.totalBytes > 0 ? ` / ${formatBytes(progress.totalBytes)}` : ''}
</span>
)}
{progress && progress.speed > 0 && progress.totalBytes > 0 && (
<span>
ETA: {getTimeRemaining()}
</span>
)}
</div>
</div>
</>
)}
</div>
</div>
{/* Footer */}
<div style={{
padding: '12px 24px',
borderTop: '1px solid #1a1a1a',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div style={{ fontSize: 11, color: '#666' }}>&copy; 2026 Lightricks</div>
</div>
</div>
</div>
)
}
+279
View File
@@ -0,0 +1,279 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'
import { VideoPreviewPanel, formatTimecode, type VideoPreviewContext } from './VideoPreviewPanel'
import { validateVideoSource } from '../lib/video-constraints'
interface RetakePanelProps {
initialVideoPath?: string | null
initialDuration?: number
resetKey?: number
isProcessing?: boolean
processingStatus?: string
fillHeight?: boolean
enforceApiConstraints?: boolean
onChange?: (data: {
videoPath: string | null
startTime: number
duration: number
videoDuration: number
width: number
height: number
ready: boolean
}) => void
}
const MIN_DURATION = 2
export function RetakePanel({
initialVideoPath,
initialDuration,
resetKey,
isProcessing = false,
processingStatus = '',
fillHeight = false,
enforceApiConstraints = true,
onChange,
}: RetakePanelProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const filmstripRef = useRef<HTMLDivElement>(null)
const [videoPath, setVideoPath] = useState<string | null>(initialVideoPath || null)
const [videoDuration, setVideoDuration] = useState<number>(initialDuration || 0)
const [dimensions, setDimensions] = useState<{ width: number; height: number }>({ width: 0, height: 0 })
const [selStart, setSelStart] = useState(0)
const [selEnd, setSelEnd] = useState(Math.min(initialDuration || 0, 5))
const [draggingHandle, setDraggingHandle] = useState<'start' | 'end' | 'range' | null>(null)
// Read synchronously by the filmstrip click guard. Stays true through the click the
// browser fires right after a drag-release mouseup (cleared on the next tick), so
// releasing a trim drag over the strip doesn't jump the playhead.
const draggingRef = useRef(false)
const dragStartRef = useRef<{ mouseX: number; selStart: number; selEnd: number } | null>(null)
const initialSelectionAppliedRef = useRef(false)
useEffect(() => {
if (resetKey === undefined) return
setSelStart(0)
setSelEnd(Math.min(initialDuration || 0, 5))
initialSelectionAppliedRef.current = false
}, [resetKey, initialDuration])
const handleSourceChange = useCallback((data: { videoPath: string | null; videoDuration: number; width: number; height: number }) => {
setVideoPath(data.videoPath)
setVideoDuration(data.videoDuration)
setDimensions({ width: data.width, height: data.height })
if (!data.videoPath) {
setSelStart(0)
setSelEnd(0)
initialSelectionAppliedRef.current = false
}
}, [])
const sourceError = enforceApiConstraints && videoPath
? validateVideoSource({ width: dimensions.width, height: dimensions.height, duration: videoDuration })
: null
// Apply the default selection once the source duration is known.
useEffect(() => {
if (!videoPath || videoDuration <= 0 || initialSelectionAppliedRef.current) return
setSelStart(0)
setSelEnd(Math.min(videoDuration, 5))
initialSelectionAppliedRef.current = true
}, [videoDuration, videoPath])
useEffect(() => {
const ready = !!videoPath && (selEnd - selStart) >= MIN_DURATION && !sourceError
onChange?.({
videoPath,
startTime: selStart,
duration: selEnd - selStart,
videoDuration,
width: dimensions.width,
height: dimensions.height,
ready,
})
}, [videoPath, selStart, selEnd, videoDuration, dimensions, sourceError, onChange])
// Retake-specific keyboard: set in/out points to the playhead.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
const key = e.key.toLowerCase()
const video = videoRef.current
if (!video) return
if (key === 'i') {
e.preventDefault()
e.stopPropagation()
const t = video.currentTime
if (t < selEnd - MIN_DURATION) setSelStart(t)
} else if (key === 'o') {
e.preventDefault()
e.stopPropagation()
const t = video.currentTime
if (t > selStart + MIN_DURATION) setSelEnd(t)
}
}
window.addEventListener('keydown', handler, true)
return () => window.removeEventListener('keydown', handler, true)
}, [selStart, selEnd])
const selStartRef = useRef(selStart)
selStartRef.current = selStart
const selEndRef = useRef(selEnd)
selEndRef.current = selEnd
const handleFilmstripMouseDown = useCallback((e: React.MouseEvent, handle: 'start' | 'end' | 'range') => {
e.preventDefault()
e.stopPropagation()
dragStartRef.current = { mouseX: e.clientX, selStart: selStartRef.current, selEnd: selEndRef.current }
draggingRef.current = true
setDraggingHandle(handle)
}, [])
useEffect(() => {
if (!draggingHandle) return
const handleMouseMove = (e: MouseEvent) => {
const strip = filmstripRef.current
const origin = dragStartRef.current
if (!strip || !origin) return
const rect = strip.getBoundingClientRect()
if (draggingHandle === 'range') {
const dx = e.clientX - origin.mouseX
const dtSeconds = (dx / rect.width) * videoDuration
const rangeDuration = origin.selEnd - origin.selStart
let newStart = origin.selStart + dtSeconds
let newEnd = origin.selEnd + dtSeconds
if (newStart < 0) { newStart = 0; newEnd = rangeDuration }
if (newEnd > videoDuration) { newEnd = videoDuration; newStart = videoDuration - rangeDuration }
setSelStart(Math.max(0, newStart))
setSelEnd(Math.min(videoDuration, newEnd))
} else {
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const time = fraction * videoDuration
if (draggingHandle === 'start') {
const maxStart = selEndRef.current - MIN_DURATION
setSelStart(Math.max(0, Math.min(maxStart, time)))
} else {
const minEnd = selStartRef.current + MIN_DURATION
setSelEnd(Math.min(videoDuration, Math.max(minEnd, time)))
}
}
}
const handleMouseUp = () => {
setDraggingHandle(null)
dragStartRef.current = null
// Clear on the next tick so the click synthesized from this mouseup still sees the
// drag in progress and is suppressed by the filmstrip seek guard.
setTimeout(() => { draggingRef.current = false }, 0)
}
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseup', handleMouseUp)
return () => {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
}
}, [draggingHandle, videoDuration])
const renderTrimOverlay = useCallback((ctx: VideoPreviewContext) => {
const dur = ctx.videoDuration
const selStartFrac = dur > 0 ? selStart / dur : 0
const selEndFrac = dur > 0 ? selEnd / dur : 1
return (
<>
<div
className="absolute top-0 bottom-0 left-0 bg-black/75 pointer-events-none"
style={{ width: `${selStartFrac * 100}%` }}
/>
<div
className="absolute top-0 bottom-0 right-0 bg-black/75 pointer-events-none"
style={{ width: `${(1 - selEndFrac) * 100}%` }}
/>
<div
className="absolute top-0 bottom-0 bg-white pointer-events-none"
style={{ left: `${selStartFrac * 100}%`, width: `${(selEndFrac - selStartFrac) * 100}%` }}
/>
<div
className={`absolute top-0 bottom-0 z-[12] ${draggingHandle === 'range' ? 'cursor-grabbing' : 'cursor-grab'}`}
style={{
left: `calc(${selStartFrac * 100}% + 14px)`,
width: `calc(${(selEndFrac - selStartFrac) * 100}% - 28px)`,
}}
onMouseDown={(e) => handleFilmstripMouseDown(e, 'range')}
/>
<div
className="absolute top-0 bottom-0 border-2 border-blue-500 pointer-events-none"
style={{ left: `${selStartFrac * 100}%`, width: `${(selEndFrac - selStartFrac) * 100}%` }}
/>
<div
className="absolute top-0 bottom-0 cursor-ew-resize z-20 group"
style={{ left: `calc(${selStartFrac * 100}% - 6px)`, width: '20px' }}
onMouseDown={(e) => handleFilmstripMouseDown(e, 'start')}
>
<div className="absolute top-0 bottom-0 bg-blue-500 group-hover:bg-blue-400 transition-colors"
style={{ left: '5px', width: '4px', borderRadius: '2px 0 0 2px' }}
/>
<div className="absolute top-0 bg-blue-500 group-hover:bg-blue-400 transition-colors" style={{ left: '5px', width: '10px', height: '3px', borderRadius: '2px 0 0 0' }} />
<div className="absolute bottom-0 bg-blue-500 group-hover:bg-blue-400 transition-colors" style={{ left: '5px', width: '10px', height: '3px', borderRadius: '0 0 0 2px' }} />
</div>
<div
className="absolute top-0 bottom-0 cursor-ew-resize z-20 group"
style={{ left: `calc(${selEndFrac * 100}% - 14px)`, width: '20px' }}
onMouseDown={(e) => handleFilmstripMouseDown(e, 'end')}
>
<div className="absolute top-0 bottom-0 bg-blue-500 group-hover:bg-blue-400 transition-colors"
style={{ right: '5px', width: '4px', borderRadius: '0 2px 2px 0' }}
/>
<div className="absolute top-0 bg-blue-500 group-hover:bg-blue-400 transition-colors" style={{ right: '5px', width: '10px', height: '3px', borderRadius: '0 2px 0 0' }} />
<div className="absolute bottom-0 bg-blue-500 group-hover:bg-blue-400 transition-colors" style={{ right: '5px', width: '10px', height: '3px', borderRadius: '0 0 2px 0' }} />
</div>
<div
className="absolute top-1/2 pointer-events-none z-10"
style={{ left: `${((selStartFrac + selEndFrac) / 2) * 100}%`, transform: 'translate(-50%, -50%)' }}
>
<span className="text-[11px] font-mono text-zinc-700 bg-white/90 rounded px-2 py-0.5 font-semibold shadow">
{formatTimecode(selEnd - selStart)}
</span>
</div>
</>
)
}, [selStart, selEnd, draggingHandle, handleFilmstripMouseDown])
const renderLabels = useCallback(() => (
<div className="flex justify-between mt-1.5">
<span className="text-[10px] font-mono text-blue-400">{formatTimecode(selStart)}</span>
<span className="text-[10px] font-mono text-zinc-500">Duration: {formatTimecode(selEnd - selStart)}</span>
<span className="text-[10px] font-mono text-blue-400">{formatTimecode(selEnd)}</span>
</div>
), [selStart, selEnd])
return (
<VideoPreviewPanel
title="Retake"
initialVideoPath={initialVideoPath}
initialDuration={initialDuration}
resetKey={resetKey}
isProcessing={isProcessing}
processingStatus={processingStatus}
processingDefault="Processing retake..."
fillHeight={fillHeight}
emptyTitle="Drop a video to retake"
hint={{ title: 'Select the video part to regenerate', subtitle: 'Use the prompt panel below to describe what should happen' }}
errorMessage={sourceError ?? undefined}
videoRef={videoRef}
filmstripRef={filmstripRef}
onSourceChange={handleSourceChange}
shouldSuppressSeek={() => draggingRef.current}
filmstripOverlay={renderTrimOverlay}
belowFilmstrip={renderLabels}
/>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { LoraInfoPopover } from './LoraInfoPopover'
import type { components } from '../generated/backend-openapi'
type Sections = NonNullable<components['schemas']['LoraCatalogItem']['instructions']>
export interface SelectedLoraItem {
name: string
sections?: Sections
repoId?: string
isCommunity?: boolean
}
// The "currently selected LoRA(s)" bar shown above the prompt bar — a name + info popover
// per item, inline. Used for both the single active IC-LoRA and the list of plain LoRAs.
export function SelectedLoraInfo({ items }: { items: SelectedLoraItem[] }) {
if (items.length === 0) return null
return (
<div className="mb-2 flex flex-wrap items-center gap-x-4 gap-y-1 rounded-lg border border-zinc-800 bg-zinc-900/80 px-3 py-2 text-[11px] text-zinc-400">
{items.map((it, i) => (
<span key={i} className="inline-flex items-center gap-1">
<span className="font-semibold text-zinc-300">{it.name}</span>
<LoraInfoPopover
sections={it.sections ?? []}
name={it.name}
repoId={it.repoId}
isCommunity={it.isCommunity}
/>
</span>
))}
</div>
)
}
+93
View File
@@ -0,0 +1,93 @@
import React from 'react'
import { createPortal } from 'react-dom'
import { useFixedMenu } from '../hooks/use-fixed-menu'
import { Tooltip } from './ui/tooltip'
// Generic settings dropdown used across the prompt bar's control rows.
// The menu is portaled to document.body so overflow-hidden ancestors
// (react-resizable-panels) cannot clip it.
export function SettingsDropdown({
trigger,
options,
value,
onChange,
title,
tooltip,
triggerClassName,
placement = 'above',
}: {
trigger: React.ReactNode
options: { value: string; label: string; disabled?: boolean; tooltip?: string; icon?: React.ReactNode }[]
value: string
onChange: (value: string) => void
title: string
tooltip?: string
// Extra classes on the trigger button — e.g. to visually attach it to an adjacent button
// as a split-button (rounded-l-none, no left padding, etc).
triggerClassName?: string
// Prompt-bar menus open upward; gallery toolbar menus open downward.
placement?: 'above' | 'below'
}) {
const { isOpen, setIsOpen, triggerRef, menuRef, style } = useFixedMenu(placement)
const triggerButton = (
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex shrink-0 items-center gap-1 whitespace-nowrap px-2 py-1.5 rounded-md transition-colors ${isOpen ? 'bg-zinc-700 hover:bg-zinc-700' : 'hover:bg-zinc-800'} ${triggerClassName ?? ''}`}
>
{trigger}
</button>
)
return (
<div ref={triggerRef} className="relative">
{tooltip && !isOpen ? <Tooltip content={tooltip}>{triggerButton}</Tooltip> : triggerButton}
{isOpen && createPortal(
<div
ref={menuRef}
style={style}
className="fixed w-max bg-zinc-800 border border-zinc-700 rounded-md p-2 min-w-[160px] shadow-xl"
>
<div className="text-[10px] text-zinc-500 uppercase tracking-wider mb-2">{title}</div>
{/* Cap height + scroll so a long option list (e.g. many catalog / custom IC-LoRAs)
doesn't clip off-screen — matches the LoRA picker's max-h-80. */}
<div className="space-y-1 max-h-80 overflow-y-auto">
{options.map(option => (
<div key={option.value} className="relative group/option">
<button
onClick={() => { if (!option.disabled) { onChange(option.value); setIsOpen(false) } }}
className={`w-full flex items-center justify-between px-2 py-2 rounded-md transition-colors text-left ${
option.disabled
? 'cursor-not-allowed'
: value === option.value ? 'bg-white/20 hover:bg-white/25' : 'hover:bg-zinc-700'
}`}
>
<span className={`flex items-center gap-2.5 text-sm ${
option.disabled
? 'text-zinc-600'
: value === option.value ? 'text-white' : 'text-zinc-400'
}`}>
{option.icon && <span className="flex-shrink-0">{option.icon}</span>}
{option.label}
</span>
{value === option.value && !option.disabled && (
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
{option.disabled && option.tooltip && (
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 px-2 py-1 bg-zinc-700 rounded text-xs text-zinc-300 whitespace-nowrap opacity-0 group-hover/option:opacity-100 pointer-events-none z-[10000] transition-opacity">
{option.tooltip}
</div>
)}
</div>
))}
</div>
</div>,
document.body,
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
+271
View File
@@ -0,0 +1,271 @@
import { useEffect, useMemo } from 'react'
import { Select } from './ui/select'
import { videoGenerationResolutionLabel } from '../lib/video-resolution'
import {
areVideoGenerationSettingsEquivalent,
resolveVideoGenerationOptions,
sanitizeVideoGenerationSettings,
type VideoGenerationModelSpecItem,
type VideoGenerationPipeline,
} from '../lib/video-generation-model-specs'
export type GenerationMode = 'text-to-video' | 'image-to-video' | 'text-to-image'
export interface LoraSelection {
ref: string
name: string
scale: number
// Catalog id (e.g. "cozy-felt-style"), when this LoRA was picked from the library —
// absent for a manually-added/custom LoRA not in the catalog. Lets the prompt enhancer
// look up trigger/instructions without re-matching on filename or display name.
catalogId?: string
}
export interface GenerationSettings {
model: VideoGenerationPipeline
duration: number | null
videoResolution: string
fps: number
audio: boolean
cameraMotion: string
aspectRatio?: string
loras?: LoraSelection[]
// Image-specific settings
imageResolution: string
imageAspectRatio: string
imageSteps: number
variations?: number // Number of image variations to generate
imageEditStrength?: number // Denoising strength when editing an existing image
}
interface SettingsPanelProps {
settings: GenerationSettings
onSettingsChange: (settings: GenerationSettings) => void
disabled?: boolean
mode?: GenerationMode
hasAudio?: boolean
videoModelSpecs?: VideoGenerationModelSpecItem[] | null
minimumDuration?: number
hideDuration?: boolean
videoSettingsMessage?: string | null
}
export function SettingsPanel({
settings,
onSettingsChange,
disabled,
mode = 'text-to-video',
hasAudio = false,
videoModelSpecs,
minimumDuration,
hideDuration = false,
videoSettingsMessage,
}: SettingsPanelProps) {
const isImageMode = mode === 'text-to-image'
const resolvedVideoOptions = useMemo(() => {
if (isImageMode || !videoModelSpecs || videoModelSpecs.length === 0) return null
return resolveVideoGenerationOptions({
settings,
modelSpecs: videoModelSpecs,
hasAudio,
minimumDuration,
durationSelection: hideDuration ? 'smallest_valid' : 'preserve',
})
}, [hasAudio, hideDuration, isImageMode, minimumDuration, settings, videoModelSpecs])
useEffect(() => {
if (isImageMode || !videoModelSpecs || videoModelSpecs.length === 0) return
const sanitized = sanitizeVideoGenerationSettings(settings, videoModelSpecs, {
hasAudio,
minimumDuration,
durationSelection: hideDuration ? 'smallest_valid' : 'preserve',
})
if (!sanitized) return
if (!areVideoGenerationSettingsEquivalent(settings, sanitized)) {
onSettingsChange(sanitized)
}
}, [hasAudio, hideDuration, isImageMode, minimumDuration, onSettingsChange, settings, videoModelSpecs])
const handleChange = (key: keyof GenerationSettings, value: string | number | boolean | null) => {
if (isImageMode) {
onSettingsChange({ ...settings, [key]: value } as GenerationSettings)
return
}
if (!videoModelSpecs || videoModelSpecs.length === 0) return
const nextSettings = { ...settings, [key]: value } as GenerationSettings
const sanitized = sanitizeVideoGenerationSettings(nextSettings, videoModelSpecs, {
hasAudio,
minimumDuration,
durationSelection: hideDuration ? 'smallest_valid' : 'preserve',
})
if (sanitized) {
onSettingsChange(sanitized)
}
}
// Image mode settings
if (isImageMode) {
return (
<div className="space-y-4">
{/* Aspect Ratio and Quality side by side */}
<div className="grid grid-cols-2 gap-3">
<Select
label="Aspect Ratio"
value={settings.imageAspectRatio || '16:9'}
onChange={(e) => handleChange('imageAspectRatio', e.target.value)}
disabled={disabled}
>
<option value="1:1">1:1 (Square)</option>
<option value="16:9">16:9 (Landscape)</option>
<option value="9:16">9:16 (Portrait)</option>
<option value="4:3">4:3 (Standard)</option>
<option value="3:4">3:4 (Portrait Standard)</option>
<option value="21:9">21:9 (Cinematic)</option>
</Select>
<Select
label="Quality"
value={settings.imageSteps || 4}
onChange={(e) => handleChange('imageSteps', parseInt(e.target.value))}
disabled={disabled}
>
<option value={4}>Fast</option>
<option value={8}>Balanced</option>
<option value={12}>High</option>
</Select>
</div>
</div>
)
}
if (!videoModelSpecs || videoModelSpecs.length === 0 || !resolvedVideoOptions || !resolvedVideoOptions.hasCompatibleOptions) {
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-950/60 px-3 py-2 text-xs text-zinc-500">
{videoSettingsMessage || 'Loading generation settings...'}
</div>
)
}
const showFps = resolvedVideoOptions.fpsOptions.length > 1
const videoControlsColumns = hideDuration
? (showFps ? 'grid-cols-2' : 'grid-cols-1')
: (showFps ? 'grid-cols-3' : 'grid-cols-2')
// Video mode settings
return (
<div className="space-y-4">
<Select
label="Model"
value={resolvedVideoOptions.selectedModel ?? settings.model}
onChange={(e) => handleChange('model', e.target.value)}
disabled={disabled}
>
{resolvedVideoOptions.modelOptions.map((item) => (
<option key={item.pipeline} value={item.pipeline}>
{item.spec.display_name}
</option>
))}
</Select>
{/* Duration, Resolution, FPS Row */}
<div className={`grid gap-3 ${videoControlsColumns}`}>
{!hideDuration && (
<Select
label="Duration"
value={
resolvedVideoOptions.autoDurationAvailable && resolvedVideoOptions.selectedDuration === null
? 'auto'
: String(resolvedVideoOptions.selectedDuration ?? settings.duration)
}
onChange={(e) => handleChange('duration', e.target.value === 'auto' ? null : parseInt(e.target.value))}
disabled={disabled}
>
{resolvedVideoOptions.autoDurationAvailable && (
<option value="auto">Auto</option>
)}
{resolvedVideoOptions.durationOptions.map((duration) => (
<option key={duration} value={duration}>
{duration} sec
</option>
))}
</Select>
)}
<Select
label="Resolution"
value={resolvedVideoOptions.selectedResolution ?? settings.videoResolution}
onChange={(e) => handleChange('videoResolution', e.target.value)}
disabled={disabled}
>
{resolvedVideoOptions.resolutionOptions.map((resolution) => (
<option key={resolution} value={resolution}>
{videoGenerationResolutionLabel(resolution)}
</option>
))}
</Select>
{showFps && (
<Select
label="FPS"
value={resolvedVideoOptions.selectedFps ?? settings.fps}
onChange={(e) => handleChange('fps', parseInt(e.target.value))}
disabled={disabled}
>
{resolvedVideoOptions.fpsOptions.map((fps) => (
<option key={fps} value={fps}>
{fps}
</option>
))}
</Select>
)}
</div>
{/* Aspect Ratio */}
<Select
label="Aspect Ratio"
value={settings.aspectRatio || '16:9'}
onChange={(e) => handleChange('aspectRatio', e.target.value)}
disabled={disabled}
>
<option value="16:9">16:9 Landscape</option>
<option value="9:16">9:16 Portrait</option>
</Select>
{/* Audio and Camera Motion Row */}
<div className="flex gap-3">
<div className="w-[140px] flex-shrink-0">
<Select
label="Audio"
badge="PREVIEW"
value={settings.audio ? 'on' : 'off'}
onChange={(e) => handleChange('audio', e.target.value === 'on')}
disabled={disabled}
>
<option value="on">On</option>
<option value="off">Off</option>
</Select>
</div>
<div className="flex-1">
<Select
label="Camera Motion"
value={settings.cameraMotion}
onChange={(e) => handleChange('cameraMotion', e.target.value)}
disabled={disabled}
>
<option value="none">None</option>
<option value="static">Static</option>
<option value="focus_shift">Focus Shift</option>
<option value="dolly_in">Dolly In</option>
<option value="dolly_out">Dolly Out</option>
<option value="dolly_left">Dolly Left</option>
<option value="dolly_right">Dolly Right</option>
<option value="jib_up">Jib Up</option>
<option value="jib_down">Jib Down</option>
</Select>
</div>
</div>
</div>
)
}
@@ -0,0 +1,165 @@
.update-modal-backdrop {
position: fixed;
inset: 0;
z-index: 70;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(4px);
animation: update-modal-backdrop-in 180ms ease-out;
}
.update-modal {
position: relative;
width: 100%;
max-width: 440px;
padding: 1.75rem 1.5rem 1.5rem;
border-radius: 16px;
border: 1px solid rgb(63 63 70);
background: rgb(24 24 27);
color: white;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
animation: update-modal-card-in 260ms cubic-bezier(0.16, 1, 0.3, 1);
}
.update-modal h2 {
margin: 0 1.5rem 0.25rem 0;
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.update-modal-close {
position: absolute;
top: 0.75rem;
right: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 0;
border-radius: 0.5rem;
background: transparent;
color: rgb(161 161 170);
cursor: pointer;
}
.update-modal-close:hover {
background: rgb(39 39 42);
color: white;
}
.update-modal-version {
margin: 0;
font-size: 0.875rem;
color: rgb(161 161 170);
}
.update-modal-notes {
margin-top: 0.75rem;
max-height: 10rem;
overflow-y: auto;
padding: 0.75rem;
border-radius: 0.5rem;
background: rgb(9 9 11 / 0.6);
border: 1px solid rgb(63 63 70 / 0.6);
color: rgb(212 212 216);
font-size: 0.8125rem;
line-height: 1.45;
white-space: pre-wrap;
}
.update-modal-error,
.update-modal-warning {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin: 0.75rem 0 0;
font-size: 0.8125rem;
line-height: 1.4;
}
.update-modal-error {
color: rgb(248 113 113);
}
.update-modal-warning {
color: rgb(251 191 36);
}
.update-modal-progress {
position: relative;
margin-top: 1.25rem;
padding-bottom: 1.5rem;
}
.update-modal-progress::before {
content: '';
display: block;
height: 8px;
border-radius: 999px;
background: rgb(39 39 42);
}
.update-modal-bar {
position: absolute;
top: 0;
left: 0;
height: 8px;
border-radius: 999px;
background: rgb(43 97 255);
transition: width 150ms ease;
}
.update-modal-progress span {
position: absolute;
right: 0;
top: 14px;
font-size: 0.75rem;
color: rgb(161 161 170);
}
.update-modal-skip {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
font-size: 0.875rem;
color: rgb(212 212 216);
cursor: pointer;
}
.update-modal-skip input {
width: 1rem;
height: 1rem;
}
.update-modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.25rem;
}
.update-modal-actions button {
gap: 0.5rem;
}
@keyframes update-modal-backdrop-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes update-modal-card-in {
from {
opacity: 0;
transform: translateY(16px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@@ -0,0 +1,133 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { AlertCircle, Download, RefreshCw, X } from 'lucide-react'
import { Button } from './ui/button'
import type { AppUpdate } from '../hooks/use-app-update'
import './UpdateAvailableModal.css'
interface Props {
update: AppUpdate
isGenerationActive: boolean
// onClose receives whether the user ticked "Skip this version".
onClose: (skipThisVersion: boolean) => void
}
function titleForStatus(status: AppUpdate['state']['status']): string {
if (status === 'downloaded') return 'Ready to install'
if (status === 'downloading') return 'Downloading update'
if (status === 'checking') return 'Checking for updates'
return 'Update available'
}
export function UpdateAvailableModal({ update, isGenerationActive, onClose }: Props) {
const { state, startDownload, installAndRestart } = update
const [skipChecked, setSkipChecked] = useState(false)
const [installError, setInstallError] = useState<string | null>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const downloading = state.status === 'downloading'
const downloaded = state.status === 'downloaded'
const canDownload = state.status === 'available'
const canDismiss = !downloaded
const handleClose = useCallback(() => {
if (!canDismiss) return
onClose(skipChecked && state.status === 'available')
}, [canDismiss, onClose, skipChecked, state.status])
useEffect(() => {
dialogRef.current?.focus()
}, [])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && canDismiss) handleClose()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [canDismiss, handleClose])
const handleInstall = async () => {
try {
const res = await installAndRestart()
if (!res.success) setInstallError(res.error ?? 'Could not install the update.')
} catch (e) {
setInstallError(e instanceof Error ? e.message : 'Could not install the update.')
}
}
return (
<div
className="update-modal-backdrop"
onClick={canDismiss ? handleClose : undefined}
>
<div
ref={dialogRef}
className="update-modal"
role="dialog"
aria-modal="true"
aria-labelledby="update-modal-title"
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
{canDismiss && (
<button className="update-modal-close" onClick={handleClose} aria-label="Close">
<X className="h-4 w-4" />
</button>
)}
<h2 id="update-modal-title">{titleForStatus(state.status)}</h2>
<p className="update-modal-version">
{state.currentVersion} → <strong>{state.version}</strong>
</p>
{state.releaseNotes && <div className="update-modal-notes">{state.releaseNotes}</div>}
{state.message && (
<p className="update-modal-error"><AlertCircle className="h-4 w-4" /> {state.message}</p>
)}
{installError && (
<p className="update-modal-error"><AlertCircle className="h-4 w-4" /> {installError}</p>
)}
{downloading && (
<div className="update-modal-progress">
<div className="update-modal-bar" style={{ width: `${state.percent ?? 0}%` }} />
<span>{state.percent ?? 0}%</span>
</div>
)}
{downloaded && isGenerationActive && (
<p className="update-modal-warning">
A generation is running. Installing will restart the app — it will be enabled when the
generation finishes.
</p>
)}
{state.status === 'available' && (
<label className="update-modal-skip">
<input type="checkbox" checked={skipChecked} onChange={(e) => setSkipChecked(e.target.checked)} />
Skip this version
</label>
)}
<div className="update-modal-actions">
{downloaded ? (
<Button onClick={handleInstall} disabled={isGenerationActive}>
<RefreshCw className="h-4 w-4" /> Restart to update
</Button>
) : downloading ? (
<Button variant="ghost" onClick={handleClose}>Hide</Button>
) : (
<>
<Button variant="ghost" onClick={handleClose}>Later</Button>
<Button onClick={startDownload} disabled={!canDownload}>
{state.message ? (<><Download className="h-4 w-4" /> Try again</>)
: (<><Download className="h-4 w-4" /> Update now</>)}
</Button>
</>
)}
</div>
</div>
</div>
)
}
+506
View File
@@ -0,0 +1,506 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'
import { Film, Play, Pause, Volume2, VolumeX, Loader2, Upload, Trash2, RefreshCw } from 'lucide-react'
import { logger } from '../lib/logger'
import { pathToFileUrl } from '../lib/file-url'
// Shared video source + preview surface used by RetakePanel and ExtendPanel. Owns the
// video file (drop/browse/clear), playback, mute, the timecode transport bar, and the
// filmstrip thumbnail strip with click-to-seek. Mode-specific UI (retake's trim handles,
// extend's direction/seconds controls) is injected via the render-prop slots.
export interface VideoPreviewContext {
videoPath: string | null
videoUrl: string | null
videoDuration: number
currentTime: number
isPlaying: boolean
thumbnails: string[]
videoRef: React.RefObject<HTMLVideoElement>
filmstripRef: React.RefObject<HTMLDivElement>
seek: (seconds: number) => void
}
interface VideoPreviewPanelProps {
title: string
initialVideoPath?: string | null
initialDuration?: number
resetKey?: number
isProcessing?: boolean
processingStatus?: string
processingDefault?: string
fillHeight?: boolean
emptyTitle?: string
hint?: { title: string; subtitle?: string }
// Validation error for the current source; when set, shown as a banner.
errorMessage?: string
// Owned by a parent when it needs them in its own effects (e.g. retake drag/keyboard).
videoRef?: React.RefObject<HTMLVideoElement>
filmstripRef?: React.RefObject<HTMLDivElement>
onSourceChange?: (data: { videoPath: string | null; videoDuration: number; width: number; height: number }) => void
// Returns true when the parent is mid-interaction (e.g. dragging a trim handle) and the
// filmstrip click-to-seek should be ignored — a drag release lands a click on the strip.
shouldSuppressSeek?: () => boolean
// Absolutely-positioned overlay rendered on top of the filmstrip thumbnails.
filmstripOverlay?: (ctx: VideoPreviewContext) => React.ReactNode
// Content rendered below the filmstrip (labels for retake, controls for extend).
belowFilmstrip?: (ctx: VideoPreviewContext) => React.ReactNode
}
export function formatTimecode(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${String(m).padStart(2, '0')}:${s.toFixed(2).padStart(5, '0')}`
}
export function VideoPreviewPanel({
title,
initialVideoPath,
initialDuration,
resetKey,
isProcessing = false,
processingStatus = '',
processingDefault = 'Processing...',
fillHeight = false,
emptyTitle = 'Drop a video',
hint,
errorMessage,
videoRef: videoRefProp,
filmstripRef: filmstripRefProp,
onSourceChange,
shouldSuppressSeek,
filmstripOverlay,
belowFilmstrip,
}: VideoPreviewPanelProps) {
const internalVideoRef = useRef<HTMLVideoElement>(null)
const internalFilmstripRef = useRef<HTMLDivElement>(null)
const videoRef = videoRefProp ?? internalVideoRef
const filmstripRef = filmstripRefProp ?? internalFilmstripRef
const [videoPath, setVideoPath] = useState<string | null>(initialVideoPath || null)
const videoUrl = videoPath ? pathToFileUrl(videoPath) : null
const [videoDuration, setVideoDuration] = useState<number>(initialDuration || 0)
const [isPlaying, setIsPlaying] = useState(false)
const [isMuted, setIsMuted] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [isDragOver, setIsDragOver] = useState(false)
const [dimensions, setDimensions] = useState<{ width: number; height: number }>({ width: 0, height: 0 })
const [thumbnails, setThumbnails] = useState<string[]>([])
const [thumbCount] = useState(20)
const extractingRef = useRef(false)
useEffect(() => {
if (resetKey === undefined) return
setVideoPath(initialVideoPath || null)
setVideoDuration(initialDuration || 0)
setIsPlaying(false)
setCurrentTime(0)
setThumbnails([])
setDimensions({ width: 0, height: 0 })
extractingRef.current = false
}, [resetKey, initialVideoPath, initialDuration])
useEffect(() => {
if (!videoUrl) {
setVideoDuration(0)
setIsPlaying(false)
setCurrentTime(0)
setThumbnails([])
setDimensions({ width: 0, height: 0 })
extractingRef.current = false
}
}, [videoUrl, initialDuration])
useEffect(() => {
onSourceChange?.({ videoPath, videoDuration, width: dimensions.width, height: dimensions.height })
}, [videoPath, videoDuration, dimensions, onSourceChange])
useEffect(() => {
if (!videoUrl || extractingRef.current || videoDuration <= 0) return
extractingRef.current = true
let cancelled = false
const extractThumbnails = async () => {
const video = document.createElement('video')
video.crossOrigin = 'anonymous'
video.preload = 'auto'
video.muted = true
video.src = videoUrl
await new Promise<void>((resolve, reject) => {
video.onloadeddata = () => resolve()
video.onerror = () => reject(new Error('Failed to load video for thumbnails'))
setTimeout(() => reject(new Error('Timeout loading video')), 10000)
})
if (cancelled) return
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
const thumbWidth = 80
const thumbHeight = Math.round(thumbWidth * (video.videoHeight / video.videoWidth))
canvas.width = thumbWidth
canvas.height = thumbHeight
const frames: string[] = []
const count = Math.min(thumbCount, Math.max(5, Math.floor(videoDuration / 0.25)))
for (let i = 0; i < count; i++) {
const seekTime = (i / count) * videoDuration
video.currentTime = seekTime
await new Promise<void>(resolve => {
video.onseeked = () => resolve()
setTimeout(resolve, 500)
})
if (cancelled) { video.src = ''; video.load(); return }
ctx.drawImage(video, 0, 0, thumbWidth, thumbHeight)
frames.push(canvas.toDataURL('image/jpeg', 0.6))
}
video.src = ''
video.load()
setThumbnails(frames)
}
extractThumbnails().catch(err => {
logger.warn(`Filmstrip extraction failed: ${err}`)
})
// Abort an in-flight extraction when the source changes or the component unmounts.
// Reset the re-entry guard too: videoDuration is refined by loadedmetadata after the
// asset's approximate duration kicks off the first run, and without this the cancelled
// run leaves the guard stuck true and the restart never happens (permanent spinner).
return () => {
cancelled = true
extractingRef.current = false
}
}, [videoUrl, videoDuration, thumbCount])
useEffect(() => {
const video = videoRef.current
if (!video) return
const handler = () => setCurrentTime(video.currentTime)
const onLoaded = () => {
// The real file duration is authoritative; initialDuration is only a pre-load hint
// (an asset's stored duration can be stale/approximate and mismatch the actual file).
if (video.duration && Number.isFinite(video.duration)) {
setVideoDuration(video.duration)
}
if (video.videoWidth && video.videoHeight) {
setDimensions({ width: video.videoWidth, height: video.videoHeight })
}
}
video.addEventListener('timeupdate', handler)
video.addEventListener('loadedmetadata', onLoaded)
return () => {
video.removeEventListener('timeupdate', handler)
video.removeEventListener('loadedmetadata', onLoaded)
}
}, [videoUrl])
const togglePlay = useCallback(() => {
const video = videoRef.current
if (!video) return
if (video.paused) {
video.play()
setIsPlaying(true)
} else {
video.pause()
setIsPlaying(false)
}
}, [])
const toggleMute = useCallback(() => {
const video = videoRef.current
if (!video) return
video.muted = !video.muted
setIsMuted(video.muted)
}, [])
// Shared transport keyboard: play/pause + frame/scrub. (Retake adds its own i/o keys.)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
const key = e.key.toLowerCase()
const video = videoRef.current
if (key === ' ') {
e.preventDefault()
e.stopPropagation()
togglePlay()
} else if (key === 'arrowleft') {
e.preventDefault()
e.stopPropagation()
if (video) {
video.pause()
setIsPlaying(false)
video.currentTime = Math.max(0, video.currentTime - 1 / 24)
}
} else if (key === 'arrowright') {
e.preventDefault()
e.stopPropagation()
if (video) {
video.pause()
setIsPlaying(false)
video.currentTime = Math.min(videoDuration, video.currentTime + 1 / 24)
}
} else if (key === 'k') {
e.preventDefault()
e.stopPropagation()
if (video) { video.pause(); setIsPlaying(false) }
} else if (key === 'l') {
e.preventDefault()
e.stopPropagation()
if (video) { video.play(); setIsPlaying(true) }
}
}
window.addEventListener('keydown', handler, true)
return () => window.removeEventListener('keydown', handler, true)
}, [togglePlay, videoDuration])
const seek = useCallback((seconds: number) => {
const video = videoRef.current
if (video) video.currentTime = seconds
}, [])
const handleFilmstripClick = useCallback((e: React.MouseEvent) => {
if (shouldSuppressSeek?.()) return
const strip = filmstripRef.current
const video = videoRef.current
if (!strip || !video) return
const rect = strip.getBoundingClientRect()
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
video.currentTime = fraction * videoDuration
}, [videoDuration, shouldSuppressSeek])
// Swap to a new source. Reset duration/dimensions too (not just thumbnails) so the next
// onSourceChange doesn't emit the new path with the previous file's metadata until
// loadedmetadata fires.
const selectSource = useCallback((path: string) => {
setVideoPath(path)
setVideoDuration(0)
setCurrentTime(0)
setDimensions({ width: 0, height: 0 })
setThumbnails([])
extractingRef.current = false
}, [])
const handleBrowse = useCallback(async () => {
const paths = await window.electronAPI.showOpenFileDialog({
title: 'Select Video',
filters: [{ name: 'Video', extensions: ['mp4', 'mov', 'avi', 'webm', 'mkv'] }],
})
if (paths && paths.length > 0) {
selectSource(paths[0])
}
}, [selectSource])
const handleClear = useCallback(() => {
setVideoPath(null)
setVideoDuration(0)
setIsPlaying(false)
setCurrentTime(0)
setThumbnails([])
extractingRef.current = false
}, [])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(false)
const assetData = e.dataTransfer.getData('asset')
if (assetData) {
try {
const asset = JSON.parse(assetData) as { type?: string; path?: string }
if (asset.type === 'video' && asset.path) {
selectSource(asset.path)
return
}
} catch {
// fall through to file handling
}
}
const file = e.dataTransfer.files?.[0]
if (file) {
const filePath = window.electronAPI?.getPathForFile(file)
if (filePath) {
selectSource(filePath)
}
}
}, [selectSource])
const ctx: VideoPreviewContext = {
videoPath,
videoUrl,
videoDuration,
currentTime,
isPlaying,
thumbnails,
videoRef,
filmstripRef,
seek,
}
const playheadFrac = videoDuration > 0 ? currentTime / videoDuration : 0
return (
<div className={`bg-zinc-900 border border-zinc-800 rounded-2xl overflow-hidden flex flex-col ${fillHeight ? 'h-full min-h-0' : ''}`}>
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800 flex-shrink-0">
<div className="flex items-center gap-2">
<Film className="h-4 w-4 text-blue-400" />
<span className="text-sm font-semibold text-white">{title}</span>
{videoPath && (
<span className="text-xs text-zinc-500 truncate max-w-[240px]">
{videoPath.split(/[/\\]/).pop()}
</span>
)}
</div>
{videoUrl && (
<div className="flex items-center gap-2">
<button
onClick={handleClear}
className="p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
title="Clear video"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<button
onClick={handleBrowse}
className="p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
title="Replace video"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
{!videoUrl ? (
<div
className={`p-8 flex flex-col items-center justify-center gap-3 border-2 border-dashed rounded-xl m-4 transition-colors ${
isDragOver ? 'border-blue-500 bg-blue-500/10' : 'border-zinc-700'
}`}
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true) }}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
>
<div className="p-3 rounded-full bg-zinc-800">
<Upload className="h-5 w-5 text-zinc-400" />
</div>
<div className="text-center">
<p className="text-sm text-white">{emptyTitle}</p>
<p className="text-xs text-zinc-500">mp4, mov, avi, webm, mkv</p>
</div>
<button
onClick={handleBrowse}
className="px-4 py-1.5 text-xs font-medium rounded-md bg-white text-black hover:bg-zinc-200 transition-colors"
>
Browse
</button>
</div>
) : (
<div className="flex-1 min-h-0 flex flex-col">
<div className="relative bg-black flex-1 min-h-0">
<video
ref={videoRef}
src={videoUrl}
className="w-full h-full object-contain"
onClick={togglePlay}
onEnded={() => setIsPlaying(false)}
/>
<div className="absolute bottom-2 left-2 flex items-center gap-1.5">
<button
onClick={toggleMute}
className="p-1.5 rounded bg-black/60 hover:bg-black/80 text-white/80 hover:text-white transition-colors"
>
{isMuted ? <VolumeX className="h-3.5 w-3.5" /> : <Volume2 className="h-3.5 w-3.5" />}
</button>
</div>
</div>
<div className="flex-shrink-0">
<div className="flex items-center justify-center gap-3 px-4 py-2 bg-zinc-900 border-b border-zinc-800">
<button
onClick={togglePlay}
className="p-1 rounded hover:bg-zinc-800 text-white transition-colors"
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
<span className="text-xs font-mono text-zinc-400">
{formatTimecode(currentTime)} / {formatTimecode(videoDuration)}
</span>
</div>
{hint && (
<div className="px-4 pt-3 pb-1">
<p className="text-xs font-semibold text-white">{hint.title}</p>
{hint.subtitle && <p className="text-[10px] text-zinc-500 mt-0.5">{hint.subtitle}</p>}
</div>
)}
<div className="px-4 pb-4">
<div className="relative h-3 mb-0">
<div
className="absolute pointer-events-none z-10"
style={{ left: `${playheadFrac * 100}%`, transform: 'translateX(-50%)' }}
>
<svg width="10" height="10" viewBox="0 0 10 10">
<polygon points="0,0 10,0 5,8" fill="#fff" />
</svg>
</div>
</div>
<div
ref={filmstripRef}
className="relative h-14 rounded-md overflow-hidden cursor-pointer select-none"
onClick={handleFilmstripClick}
>
<div className="absolute inset-0 flex">
{thumbnails.length > 0 ? (
thumbnails.map((thumb, i) => (
<img
key={i}
src={thumb}
alt=""
className="h-full flex-1 object-cover"
style={{ minWidth: 0 }}
draggable={false}
/>
))
) : (
<div className="w-full h-full bg-zinc-800 flex items-center justify-center">
<Loader2 className="h-4 w-4 text-zinc-600 animate-spin" />
</div>
)}
</div>
{filmstripOverlay?.(ctx)}
<div
className="absolute top-0 bottom-0 w-0.5 bg-zinc-800 pointer-events-none z-[15]"
style={{ left: `${playheadFrac * 100}%` }}
/>
</div>
{belowFilmstrip?.(ctx)}
</div>
{errorMessage ? (
<div className="px-4 pb-4">
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-600/10 border border-red-500/20">
<span className="text-xs text-red-300">{errorMessage}</span>
</div>
</div>
) : isProcessing ? (
<div className="px-4 pb-4">
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-blue-600/10 border border-blue-500/20">
<Loader2 className="h-3.5 w-3.5 text-blue-400 animate-spin flex-shrink-0" />
<span className="text-xs text-blue-300">{processingStatus || processingDefault}</span>
</div>
</div>
) : null}
</div>
</div>
)}
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { Component, type ReactNode } from 'react'
export class WebErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
state = { failed: false }
static getDerivedStateFromError() { return { failed: true } }
render() {
if (this.state.failed) return <main className="p-12 space-y-4"><h1 className="text-2xl">LTX DeskWEB could not render this view</h1><p>Your saved projects remain in this browser. Reload to return to the project list.</p><button className="px-4 py-2 bg-blue-600 rounded" onClick={() => location.reload()}>Reload workspace</button></main>
return this.props.children
}
}
+32
View File
@@ -0,0 +1,32 @@
import { useState } from 'react'
import { migrateProjectData } from '../types/project-model'
import { readProjectIds, writeProject, writeProjectIds, getProjectStorageKey } from '../lib/project-storage'
export function WebProjectBackup() {
const [error, setError] = useState('')
return <div className="flex gap-4 items-center flex-wrap">
<button className="underline" onClick={() => {
const data: Record<string, string> = { 'ltx-project-ids': JSON.stringify(readProjectIds()) }
for (const id of readProjectIds()) { const key = getProjectStorageKey(id), item = localStorage.getItem(key); if (item) data[key] = item }
void window.electronAPI.saveFile({ filePath: 'ltx-projects-backup.json', data: JSON.stringify(data, null, 2) })
}}>Back up projects (JSON)</button>
<label className="underline cursor-pointer">Restore project backup
<input type="file" accept=".json" className="hidden" onChange={async event => {
const file = event.target.files?.[0]; if (!file) return
setError('')
try {
if (file.size > 20 * 1024 * 1024) throw Error('Backup is too large (maximum 20 MiB)')
const data = JSON.parse(await file.text()) as Record<string, string>
const ids: unknown = JSON.parse(data['ltx-project-ids'])
if (!Array.isArray(ids) || ids.some(id => typeof id !== 'string' || !/^project-[a-zA-Z0-9-]+$/.test(id))) throw Error('Invalid project index')
const projects = ids.map(id => { const result = migrateProjectData(JSON.parse(data[getProjectStorageKey(id)])).project; if (result.id !== id) throw Error('Project ID mismatch'); return result })
if (!confirm(`Restore ${projects.length} project(s)? Projects with matching IDs in this browser will be replaced. Media files are not included in the backup.`)) return
for (const project of projects) writeProject(project.id, project)
writeProjectIds([...new Set([...ids as string[], ...readProjectIds()])]); location.reload()
} catch (e) { setError(e instanceof Error ? e.message : 'Cannot restore backup') }
event.target.value = ''
}} />
</label>
{error && <p role="alert" className="text-red-300">{error}</p>}
</div>
}
@@ -0,0 +1,253 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Download, ExternalLink, Check, Loader2, Sparkles } from 'lucide-react'
import { formatBytes } from '@/lib/format'
import { catalogVariantKey, preferredVariantId } from '@/lib/lora-library'
export type LibraryItemStatus =
| { kind: 'available' }
| { kind: 'downloading'; progress: number }
| { kind: 'downloaded' }
| { kind: 'error'; gated?: boolean; message: string }
export interface LibraryItemVariant {
id: string
label: string
sizeBytes: number
downloaded?: boolean
}
export interface LibraryItem {
id: string
title: string
description?: string
sizeBytes?: number
/** Item-level fallback when there are no variants (any file present). */
downloaded: boolean
thumbnailUrl?: string
demoVideoUrl?: string
author?: { name: string; url?: string | null }
license?: { name: string; url?: string | null }
variants?: LibraryItemVariant[]
defaultVariantId?: string
requiresHfLogin?: boolean
}
interface LibraryItemCardProps {
item: LibraryItem
selected?: boolean
// The variant actually active for this entry (when selected). Undefined/null means
// "don't check" (e.g. no variant picker) — badge follows `selected` alone.
selectedVariantId?: string | null
downloadingKey?: string | null
progress?: number
downloadError?: { key: string; message: string; gated?: boolean } | null
onDownload?: (id: string, variantId?: string) => void
onUse?: (id: string, variantId?: string) => void
onRequestAccess?: (id: string) => void
onRetry?: (id: string, variantId?: string) => void
infoSlot?: ReactNode
}
const openUrl = (url?: string | null) => { if (url) void window.electronAPI.openExternalUrl({ url }) }
// Generic media grid card for one downloadable asset. No domain knowledge — the consumer
// maps its type into a LibraryItem. Media is optional: a demo video shows its first frame
// (preload=metadata) and plays on hover; without media the card shows the thumbnail or a placeholder.
export function LibraryItemCard({
item, selected, selectedVariantId, downloadingKey, progress = 0, downloadError,
onDownload, onUse, onRequestAccess, onRetry, infoSlot,
}: LibraryItemCardProps) {
const hasVariants = (item.variants?.length ?? 0) > 1
const downloadedVariantIds = useMemo(
() => (item.variants ?? []).filter(v => v.downloaded).map(v => v.id),
[item.variants],
)
const downloadedKey = downloadedVariantIds.join(',')
const fallbackVariantId = item.defaultVariantId ?? item.variants?.[0]?.id
const initialVariantId = preferredVariantId(item.variants, item.defaultVariantId, downloadedVariantIds)
?? fallbackVariantId
const [variantId, setVariantId] = useState(initialVariantId)
// Keep selection on an installed checkpoint when install state arrives after first paint
// (e.g. listModels refresh) and the catalog default still isn't on disk.
useEffect(() => {
if (!hasVariants) return
const preferred = preferredVariantId(item.variants, item.defaultVariantId, downloadedVariantIds)
setVariantId(prev => {
if (prev && downloadedVariantIds.includes(prev)) return prev
if (prev && item.variants?.some(v => v.id === prev)) return prev
return preferred ?? fallbackVariantId
})
}, [hasVariants, item.defaultVariantId, item.variants, downloadedKey, fallbackVariantId]) // eslint-disable-line react-hooks/exhaustive-deps -- downloadedKey proxies ids
const selectedVariant = item.variants?.find(v => v.id === variantId)
const sizeBytes = selectedVariant?.sizeBytes ?? item.sizeBytes
const size = sizeBytes ? formatBytes(sizeBytes) : null
const activeKey = catalogVariantKey(item.id, hasVariants ? variantId : undefined)
const status: LibraryItemStatus = (() => {
if (downloadingKey === activeKey) return { kind: 'downloading', progress }
if (downloadError?.key === activeKey) {
return {
kind: 'error',
gated: downloadError.gated ?? Boolean(item.requiresHfLogin),
message: downloadError.message,
}
}
if (hasVariants) {
return selectedVariant?.downloaded ? { kind: 'downloaded' } : { kind: 'available' }
}
return item.downloaded ? { kind: 'downloaded' } : { kind: 'available' }
})()
const selectedVariantReady = status.kind === 'downloaded'
// With a variant picker, only badge "Selected" when the dropdown is on the variant
// that's actually active — not just this catalog entry (avoids marking the wrong checkpoint).
const isSelected = Boolean(selected) && (!hasVariants || selectedVariantId == null || selectedVariantId === variantId)
const [hovered, setHovered] = useState(false)
// Lazy media: don't fetch/decode the demo video until the card scrolls near the viewport.
const [near, setNear] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null)
const cardRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = cardRef.current
if (!el || near) return
const observer = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) setNear(true)
}, { rootMargin: '200px' })
observer.observe(el)
return () => observer.disconnect()
}, [near])
useEffect(() => {
const v = videoRef.current
if (!v) return
if (hovered) void v.play().catch(() => {})
else { v.pause(); v.currentTime = 0 }
}, [hovered])
// Keep the variant picker visible while another variant downloads.
const showVariantPicker = hasVariants && status.kind !== 'downloading'
return (
<div
ref={cardRef}
className={`flex flex-col overflow-hidden rounded-xl border transition-colors ${isSelected ? 'border-amber-500/60 bg-amber-500/10' : 'border-zinc-700 bg-zinc-800/50'}`}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Media */}
<div className="relative aspect-[4/3] w-full overflow-hidden bg-zinc-950">
{item.demoVideoUrl ? (
<video
ref={videoRef}
src={near || hovered ? item.demoVideoUrl : undefined}
poster={item.thumbnailUrl}
muted
loop
playsInline
preload="metadata"
className="h-full w-full object-cover"
/>
) : item.thumbnailUrl ? (
<img src={item.thumbnailUrl} alt={item.title} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-zinc-800 to-zinc-900">
<Sparkles className="h-6 w-6 text-zinc-700" />
</div>
)}
{size && (
<span className="absolute bottom-1.5 right-1.5 rounded bg-black/60 px-1.5 py-0.5 text-[10px] text-zinc-300">{size}</span>
)}
</div>
{/* Body */}
<div className="flex flex-1 flex-col gap-1.5 p-2.5">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium text-white" title={item.title}>{item.title}</span>
{infoSlot}
</div>
{item.description && <div className="line-clamp-2 text-[11px] text-zinc-500">{item.description}</div>}
{(item.author || item.license) && (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] text-zinc-500">
{item.author && (
item.author.url
? <button onClick={() => openUrl(item.author!.url)} className="text-zinc-400 hover:text-white transition-colors">by {item.author.name}</button>
: <span>by {item.author.name}</span>
)}
{item.license && (
item.license.url
? <button onClick={() => openUrl(item.license!.url)} className="rounded bg-zinc-700/60 px-1.5 py-0.5 text-zinc-300 hover:bg-zinc-700 transition-colors">{item.license.name}</button>
: <span className="rounded bg-zinc-700/60 px-1.5 py-0.5 text-zinc-300">{item.license.name}</span>
)}
</div>
)}
{/* Footer: status / action */}
<div className="mt-auto pt-1.5 flex flex-col gap-1.5">
{showVariantPicker && (
<select
value={variantId}
onChange={(e) => setVariantId(e.target.value)}
className="w-full rounded bg-zinc-900 border border-zinc-700 px-2 py-1 text-[11px] text-zinc-200"
aria-label={`${item.title} variant`}
>
{item.variants!.map(v => (
<option key={v.id} value={v.id}>
{v.downloaded ? `${v.label} ✓` : v.label}
</option>
))}
</select>
)}
{status.kind === 'downloading' && (
<div className="flex flex-col gap-1.5">
<span className="inline-flex items-center gap-1 text-xs text-zinc-400">
<Loader2 className="h-3 w-3 animate-spin" /> {Math.round(status.progress)}%
</span>
<div className="h-1.5 overflow-hidden rounded-full bg-zinc-800">
<div className="h-full bg-blue-500 transition-all duration-300" style={{ width: `${status.progress}%` }} />
</div>
</div>
)}
{status.kind === 'error' && (
<div className="flex flex-col gap-1.5">
<div className="text-[11px] text-red-400" title={status.message}>
{status.gated ? 'This model is gated — request access, then retry.' : status.message}
</div>
<div className="flex gap-2">
{status.gated && (
<button
onClick={() => onRequestAccess?.(item.id)}
className="inline-flex items-center gap-1 rounded bg-amber-600 px-2 py-1 text-[11px] font-medium text-white transition-colors hover:bg-amber-500"
>
<ExternalLink className="h-3 w-3" /> Request access
</button>
)}
<button
onClick={() => onRetry?.(item.id, hasVariants ? variantId : undefined)}
className="inline-flex items-center gap-1 rounded bg-zinc-700 px-2 py-1 text-[11px] font-medium text-zinc-200 transition-colors hover:bg-zinc-600"
>
<Download className="h-3 w-3" /> Retry
</button>
</div>
</div>
)}
{status.kind !== 'downloading' && status.kind !== 'error' && selectedVariantReady && (
<button
onClick={() => onUse?.(item.id, hasVariants ? variantId : undefined)}
className={`inline-flex w-full items-center justify-center gap-1 rounded px-3 py-1.5 text-xs font-medium transition-colors ${isSelected ? 'bg-amber-600 text-white' : 'bg-zinc-700 text-zinc-200 hover:bg-zinc-600'}`}
>
{isSelected ? <><Check className="h-3 w-3" /> Selected</> : 'Use'}
</button>
)}
{status.kind !== 'downloading' && status.kind !== 'error' && !selectedVariantReady && (
<button
onClick={() => onDownload?.(item.id, hasVariants ? variantId : undefined)}
className="inline-flex w-full items-center justify-center gap-1 rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500"
>
<Download className="h-3 w-3" /> Download
</button>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,51 @@
import { useEffect, useRef, type ReactNode } from 'react'
import { X } from 'lucide-react'
interface LibraryModalProps {
open: boolean
onClose: () => void
title: string
headerSlot?: ReactNode
children: ReactNode
}
// Generic library dialog shell — no asset/recipe knowledge. A future regular-LoRA
// library reuses it.
export function LibraryModal({ open, onClose, title, headerSlot, children }: LibraryModalProps) {
const ref = useRef<HTMLDialogElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
if (open && !el.open) el.showModal()
else if (!open && el.open) el.close()
}, [open])
return (
<dialog
ref={ref}
onClose={onClose}
// Clicking the backdrop targets the <dialog> element itself (content is the inner div).
onClick={(e) => { if (e.target === ref.current) onClose() }}
className="m-auto w-[min(960px,calc(100%-2rem))] max-h-[80vh] overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-900 p-0 text-white backdrop:bg-black/60"
>
{/* Flex column so only the body scrolls — no hardcoded header-height calc. */}
<div className="flex max-h-[80vh] flex-col">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-zinc-800 px-4 py-3">
<h2 className="text-sm font-semibold">{title}</h2>
<div className="flex items-center gap-2">
{headerSlot}
<button
onClick={onClose}
aria-label="Close"
className="rounded-md p-1.5 text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-white"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">{children}</div>
</div>
</dialog>
)
}
@@ -0,0 +1,340 @@
import { AlertCircle, Check, Download, Folder, HardDrive, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useAppSettings } from '../../contexts/AppSettingsContext'
import { useHfAuth } from '../../hooks/use-hf-auth'
import { useHfModelAccess } from '../../hooks/use-hf-model-access'
import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../../lib/api-client'
import { formatBytes } from '../../lib/format'
import { logger } from '../../lib/logger'
import { HfModelAccessGate } from '../HfModelAccessGate'
import { Button } from '../ui/button'
type LtxModelVersionItem = ApiSuccessOf<'getLtxVersions'>['versions'][number]
type ModelCheckpointID = NonNullable<
NonNullable<ApiRequestBodyOf<'checkModelAccess'>>['cp_ids']
>[number]
type HfAuthStatus = ApiSuccessOf<'getHuggingFaceAuthStatus'>['status']
const DOWNLOAD_POLL_INTERVAL_MS = 1000
function VersionRow({
version,
onChanged,
resumeSessionId,
hfAuthStatus,
hfAuthPolling,
startHuggingFaceLogin,
}: {
version: LtxModelVersionItem
onChanged: () => Promise<void>
resumeSessionId: string | null
hfAuthStatus: HfAuthStatus
hfAuthPolling: boolean
startHuggingFaceLogin: () => void
}) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [downloadSessionId, setDownloadSessionId] = useState<string | null>(null)
const [downloadPercent, setDownloadPercent] = useState(0)
const mountedRef = useRef(true)
useEffect(() => () => { mountedRef.current = false }, [])
const cpsToDownload = useMemo(
() => (version.installed ? [] : (version.cps_to_download as ModelCheckpointID[])),
[version.installed, version.cps_to_download],
)
const { accessMap, allAuthorized, checking: checkingAccess, checkError, recheckAccess } = useHfModelAccess(
cpsToDownload,
hfAuthStatus,
)
const canDownload = version.installed || (allAuthorized && !checkingAccess)
const adoptedRef = useRef<string | null>(null)
useEffect(() => {
if (resumeSessionId && resumeSessionId !== adoptedRef.current && !downloadSessionId) {
adoptedRef.current = resumeSessionId
setDownloadSessionId(resumeSessionId)
setBusy(true)
}
}, [resumeSessionId, downloadSessionId])
useEffect(() => {
if (!downloadSessionId) return
let cancelled = false
const poll = async () => {
const result = await ApiClient.getModelDownloadProgress({ sessionId: downloadSessionId })
if (cancelled) return
if (!result.ok) {
logger.error(`Progress poll error: ${result.error.message}`)
return
}
const progress = result.data
if (progress.status === 'downloading') {
setDownloadPercent(Math.round(progress.total_progress))
return
}
if (progress.status === 'error') {
setDownloadSessionId(null)
setBusy(false)
setError(progress.error || 'Download failed.')
return
}
if (progress.status === 'complete') {
setDownloadSessionId(null)
setDownloadPercent(100)
await onChanged()
if (!cancelled) setBusy(false)
}
}
void poll()
const interval = setInterval(() => void poll(), DOWNLOAD_POLL_INTERVAL_MS)
return () => {
cancelled = true
clearInterval(interval)
}
}, [downloadSessionId, onChanged])
const handleSetActive = useCallback(async () => {
setError(null)
setBusy(true)
const result = await ApiClient.setActiveLtxModel({ model_id: version.model_id })
if (!result.ok) {
setBusy(false)
setError(result.error.message || 'Failed to set active model.')
return
}
await onChanged()
if (mountedRef.current) setBusy(false)
}, [version.model_id, onChanged])
const handleDownload = useCallback(async () => {
setError(null)
setBusy(true)
setDownloadPercent(0)
const result = await ApiClient.startModelDownload({ type: 'download', cp_ids: version.cps_to_download })
if (!result.ok) {
setBusy(false)
setError(result.error.message || 'Failed to start download.')
return
}
if (result.data.status !== 'started') {
setBusy(false)
setError('Unexpected response while starting download.')
return
}
setDownloadSessionId(result.data.sessionId)
}, [version.cps_to_download])
const handleDelete = useCallback(async () => {
setError(null)
setBusy(true)
const result = await ApiClient.deleteModels({ cp_ids: [version.model_cp] })
if (!result.ok) {
setBusy(false)
setError(result.error.message || 'Failed to delete model.')
return
}
await onChanged()
if (mountedRef.current) setBusy(false)
}, [version.model_cp, onChanged])
const isDownloading = downloadSessionId !== null
return (
<div className="bg-zinc-800/50 rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<input
type="radio"
name="active-ltx-model"
checked={version.active}
disabled={!version.installed || version.active || busy}
onChange={() => void handleSetActive()}
className="h-4 w-4 accent-blue-500 flex-shrink-0 disabled:cursor-not-allowed"
/>
<span className="text-sm text-white truncate">{version.label}</span>
{version.active && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-400 inline-flex items-center gap-1 flex-shrink-0">
<Check className="h-3 w-3" />
Active
</span>
)}
{version.is_newest && !version.installed && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 flex-shrink-0">
New
</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{!version.installed && (
<Button
size="sm"
onClick={() => void handleDownload()}
disabled={busy || !canDownload}
className="bg-blue-600 hover:bg-blue-500 text-white text-xs"
>
<Download className="h-3.5 w-3.5" />
{isDownloading ? `Downloading… ${downloadPercent}%` : `Download ${formatBytes(version.size_bytes)}`}
</Button>
)}
{version.installed && !version.active && (
<Button
variant="outline"
size="sm"
onClick={() => void handleDelete()}
disabled={busy}
className="border-zinc-700 text-zinc-300 hover:text-red-400 text-xs"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
)}
</div>
</div>
{!version.installed && (
<HfModelAccessGate
accessMap={accessMap}
allAuthorized={allAuthorized}
hfAuthStatus={hfAuthStatus}
hfAuthPolling={hfAuthPolling}
startHuggingFaceLogin={startHuggingFaceLogin}
checkError={checkError}
onRetryCheck={recheckAccess}
/>
)}
{error && (
<div className="text-xs text-red-400 inline-flex items-center gap-1.5">
<AlertCircle className="h-3 w-3 flex-shrink-0" />
{error}
</div>
)}
</div>
)
}
export function BaseModelSection() {
const [versions, setVersions] = useState<LtxModelVersionItem[]>([])
const [modelsDir, setModelsDir] = useState('')
const [activeDownload, setActiveDownload] = useState<{ sessionId: string; cpIds: string[] } | null>(null)
const { hfAuthStatus, hfAuthPolling, startHuggingFaceLogin } = useHfAuth(true)
const { notifyModelsChanged } = useAppSettings()
const knownActiveRef = useRef<string | null>(null)
const refreshVersions = useCallback(async () => {
const [versionsResult, activeResult] = await Promise.all([
ApiClient.getLtxVersions(),
ApiClient.getActiveDownload(),
])
if (!versionsResult.ok) {
logger.error(`Failed to fetch LTX versions: ${versionsResult.error.message}`)
return
}
setVersions(versionsResult.data.versions)
// Signal only on a real change so mounting the panel doesn't refetch generation specs.
const nextActive = versionsResult.data.versions.find((item) => item.active)?.model_id ?? null
const nextKey = `${nextActive}|${versionsResult.data.versions.filter((item) => item.installed).map((item) => item.model_id).join(',')}`
if (knownActiveRef.current !== null && knownActiveRef.current !== nextKey) {
notifyModelsChanged()
}
knownActiveRef.current = nextKey
if (activeResult.ok) {
setActiveDownload(
activeResult.data.session_id
? { sessionId: activeResult.data.session_id, cpIds: activeResult.data.cp_ids ?? [] }
: null,
)
}
}, [notifyModelsChanged])
useEffect(() => {
void refreshVersions()
void (async () => {
const result = await ApiClient.getSettings()
if (!result.ok) {
logger.error(`Failed to fetch settings: ${result.error.message}`)
return
}
setModelsDir(result.data.modelsDir ?? '')
})()
}, [refreshVersions])
return (
<>
<div className="space-y-3">
<div className="flex items-center gap-2">
<Folder className="h-4 w-4 text-blue-400" />
<h3 className="text-sm font-semibold text-white">Models Folder</h3>
</div>
<p className="text-xs text-zinc-500 leading-relaxed">
Where model checkpoints are stored. Changing the location requires restarting the app.
</p>
<div className="flex gap-2">
<div className="flex-1 px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-zinc-300 text-sm truncate select-text">
{modelsDir || <span className="text-zinc-600">Not set</span>}
</div>
<Button
variant="outline"
className="border-zinc-700 flex-shrink-0"
onClick={async () => {
const result = await window.electronAPI.openModelsDirChangeDialog()
if (result.success) {
setModelsDir(result.path)
}
}}
>
Change…
</Button>
<Button
variant="outline"
className="border-zinc-700 flex-shrink-0"
disabled={!modelsDir}
onClick={() => {
void window.electronAPI.openModelsFolder()
}}
>
Open folder
</Button>
</div>
</div>
<div className="space-y-3 pt-4 border-t border-zinc-800">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-blue-400" />
<h3 className="text-sm font-semibold text-white">Base Model</h3>
</div>
<p className="text-xs text-zinc-500 leading-relaxed">
The active version is used for new generations. Download a version to make it available,
then set it active. Newer versions may require a Hugging Face sign-in.
</p>
<div className="space-y-2">
{versions.length === 0 ? (
<div className="text-xs text-zinc-600">No versions available.</div>
) : (
versions.map((version) => (
<VersionRow
key={version.model_id}
version={version}
onChanged={refreshVersions}
resumeSessionId={
activeDownload && activeDownload.cpIds.includes(version.model_cp)
? activeDownload.sessionId
: null
}
hfAuthStatus={hfAuthStatus}
hfAuthPolling={hfAuthPolling}
startHuggingFaceLogin={() => {
void startHuggingFaceLogin()
}}
/>
))
)}
</div>
</div>
</>
)
}
+48
View File
@@ -0,0 +1,48 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-red-500 text-white shadow-sm hover:bg-red-600',
outline: 'border border-border bg-transparent shadow-sm hover:bg-secondary',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-secondary hover:text-secondary-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }
+40
View File
@@ -0,0 +1,40 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
value?: number
max?: number
showLabel?: boolean
}
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value = 0, max = 100, showLabel = false, ...props }, ref) => {
const percentage = Math.min(100, Math.max(0, (value / max) * 100))
return (
<div className="w-full">
<div
ref={ref}
className={cn(
'relative h-2 w-full overflow-hidden rounded-full bg-secondary',
className
)}
{...props}
>
<div
className="h-full bg-primary transition-all duration-300 ease-in-out"
style={{ width: `${percentage}%` }}
/>
</div>
{showLabel && (
<span className="text-xs text-muted-foreground mt-1 block text-right">
{Math.round(percentage)}%
</span>
)}
</div>
)
}
)
Progress.displayName = 'Progress'
export { Progress }
+47
View File
@@ -0,0 +1,47 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import { ChevronDown } from 'lucide-react'
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
label?: string
badge?: string
}
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ className, label, badge, children, ...props }, ref) => {
return (
<div className="relative">
{label && (
<label className="flex items-center gap-2 text-[12px] font-semibold text-zinc-500 mb-2 uppercase leading-4 h-4">
{label}
{badge && (
<span className="px-1.5 py-0.5 text-[10px] bg-zinc-700 text-zinc-300 rounded border border-zinc-600 font-semibold leading-none">
{badge}
</span>
)}
</label>
)}
<div className="relative">
<select
className={cn(
'flex h-10 w-full appearance-none rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-white',
'focus:outline-none focus:ring-1 focus:ring-zinc-500 focus:border-zinc-500',
'disabled:cursor-not-allowed disabled:opacity-50',
'pr-8 cursor-pointer',
'[&>option]:bg-zinc-800 [&>option]:text-white [&>option:disabled]:text-zinc-500',
className
)}
ref={ref}
{...props}
>
{children}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400 pointer-events-none" />
</div>
</div>
)
}
)
Select.displayName = 'Select'
export { Select }
+48
View File
@@ -0,0 +1,48 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string
helperText?: string
charCount?: number
maxChars?: number
}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, label, helperText, charCount, maxChars, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label className="block text-[12px] font-semibold text-zinc-500 mb-2 uppercase leading-4">
{label}
</label>
)}
<textarea
className={cn(
'flex min-h-[120px] w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-3 text-sm text-white',
'placeholder:text-zinc-500',
'focus:outline-none focus:ring-1 focus:ring-zinc-500 focus:border-zinc-500',
'disabled:cursor-not-allowed disabled:opacity-50',
'resize-y',
className
)}
ref={ref}
{...props}
/>
<div className="flex justify-between mt-2">
{helperText && (
<span className="text-xs text-zinc-500">{helperText}</span>
)}
{maxChars !== undefined && (
<span className="text-xs text-zinc-500 ml-auto">
{charCount ?? 0}/{maxChars}
</span>
)}
</div>
</div>
)
}
)
Textarea.displayName = 'Textarea'
export { Textarea }
+75
View File
@@ -0,0 +1,75 @@
import React, { useState, useRef, useCallback, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { cn } from '@/lib/utils'
interface TooltipProps {
content: React.ReactNode
children: React.ReactNode
/** Which side of the trigger the tooltip appears on. Default: 'top' */
side?: 'top' | 'bottom' | 'left' | 'right'
className?: string
}
const DELAY_MS = 500
const GAP_PX = 6
/**
* Styled tooltip with 250ms show delay and instant hide.
* Renders via a portal into document.body so it is never clipped
* by overflow-hidden ancestors.
*/
export function Tooltip({ content, children, side = 'top', className }: TooltipProps) {
const [visible, setVisible] = useState(false)
const [style, setStyle] = useState<React.CSSProperties>({})
const wrapperRef = useRef<HTMLDivElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const computeStyle = useCallback(() => {
const rect = wrapperRef.current?.getBoundingClientRect()
if (!rect) return {}
switch (side) {
case 'top':
return { left: rect.left + rect.width / 2, top: rect.top - GAP_PX, transform: 'translate(-50%, -100%)' }
case 'bottom':
return { left: rect.left + rect.width / 2, top: rect.bottom + GAP_PX, transform: 'translate(-50%, 0)' }
case 'left':
return { left: rect.left - GAP_PX, top: rect.top + rect.height / 2, transform: 'translate(-100%, -50%)' }
case 'right':
return { left: rect.right + GAP_PX, top: rect.top + rect.height / 2, transform: 'translate(0, -50%)' }
}
}, [side])
const handleMouseEnter = useCallback(() => {
timerRef.current = setTimeout(() => {
setStyle(computeStyle() ?? {})
setVisible(true)
}, DELAY_MS)
}, [computeStyle])
const handleMouseLeave = useCallback(() => {
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
setVisible(false)
}, [])
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current) }, [])
return (
<div
ref={wrapperRef}
className={cn('inline-flex', className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children}
{visible && ReactDOM.createPortal(
<div
className="fixed z-[99999] px-2.5 py-1.5 bg-white text-zinc-800 text-xs font-medium rounded-md shadow-md whitespace-nowrap pointer-events-none select-none"
style={style}
>
{content}
</div>,
document.body,
)}
</div>
)
}
+329
View File
@@ -0,0 +1,329 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
import { resetBackendCredentials } from '../lib/backend'
import { ApiClient, type ApiSuccessOf } from '../lib/api-client'
export interface AppSettings {
useTorchCompile: boolean
diffusionStageCacheEnabled: boolean
hasLtxApiKey: boolean
userPrefersLtxApiVideoGenerations: boolean
hasFalApiKey: boolean
userPrefersFalApiImageGenerations: boolean
hasGeminiApiKey: boolean
geminiModel: string
useLocalTextEncoder: boolean
promptCacheSize: number
promptEnhancerEnabledT2V: boolean
promptEnhancerEnabledI2V: boolean
// The user's explicit prompt-enhancer provider choice, persisted so it survives restarts.
// null means no active choice yet — the enhancer defaults to whichever provider is available
// without writing that default back here; only an explicit pick (never an automatic fallback
// when the preferred provider is temporarily unavailable) sets this.
promptEnhancerProviderPreference: 'local' | 'api' | null
seedLocked: boolean
lockedSeed: number
modelsDir: string
useConvVae: boolean
}
export const DEFAULT_GEMINI_MODEL = 'gemini-3.5-flash-lite'
export const DEFAULT_APP_SETTINGS: AppSettings = {
useTorchCompile: false,
diffusionStageCacheEnabled: false,
hasLtxApiKey: false,
userPrefersLtxApiVideoGenerations: false,
hasFalApiKey: false,
userPrefersFalApiImageGenerations: false,
hasGeminiApiKey: false,
geminiModel: '',
useLocalTextEncoder: false,
promptCacheSize: 1,
promptEnhancerEnabledT2V: false,
promptEnhancerEnabledI2V: false,
promptEnhancerProviderPreference: null,
seedLocked: false,
lockedSeed: 42,
modelsDir: '',
useConvVae: false,
}
type BackendProcessStatus = 'alive' | 'restarting' | 'dead'
interface AppSettingsContextValue {
settings: AppSettings
isLoaded: boolean
runtimePolicyLoaded: boolean
updateSettings: (patch: Partial<AppSettings> | ((prev: AppSettings) => AppSettings)) => void
refreshSettings: () => Promise<void>
saveLtxApiKey: (value: string) => Promise<void>
saveFalApiKey: (value: string) => Promise<void>
saveGeminiApiKey: (value: string) => Promise<void>
forceApiGenerations: boolean
shouldVideoGenerateWithLtxApi: boolean
shouldImageGenerateWithFalApi: boolean
cudaAvailable: boolean
// Bumped whenever installed models change (download / delete / activate a version). Generation
// model specs are derived from the *active* local model, so anything reading them must refetch;
// without this they stay pinned to whatever was installed at app start.
modelsVersion: number
notifyModelsChanged: () => void
}
const AppSettingsContext = createContext<AppSettingsContextValue | null>(null)
function toBackendProcessStatus(value: unknown): BackendProcessStatus | null {
if (!value || typeof value !== 'object') {
return null
}
const record = value as { status?: unknown }
if (record.status === 'alive' || record.status === 'restarting' || record.status === 'dead') {
return record.status
}
return null
}
function normalizeAppSettings(data: Partial<AppSettings>): AppSettings {
return {
useTorchCompile: data.useTorchCompile ?? DEFAULT_APP_SETTINGS.useTorchCompile,
diffusionStageCacheEnabled: data.diffusionStageCacheEnabled ?? DEFAULT_APP_SETTINGS.diffusionStageCacheEnabled,
hasLtxApiKey: data.hasLtxApiKey ?? DEFAULT_APP_SETTINGS.hasLtxApiKey,
userPrefersLtxApiVideoGenerations: data.userPrefersLtxApiVideoGenerations ?? DEFAULT_APP_SETTINGS.userPrefersLtxApiVideoGenerations,
hasFalApiKey: data.hasFalApiKey ?? DEFAULT_APP_SETTINGS.hasFalApiKey,
userPrefersFalApiImageGenerations: data.userPrefersFalApiImageGenerations ?? DEFAULT_APP_SETTINGS.userPrefersFalApiImageGenerations,
hasGeminiApiKey: data.hasGeminiApiKey ?? DEFAULT_APP_SETTINGS.hasGeminiApiKey,
geminiModel: data.geminiModel ?? DEFAULT_APP_SETTINGS.geminiModel,
useLocalTextEncoder: data.useLocalTextEncoder ?? DEFAULT_APP_SETTINGS.useLocalTextEncoder,
promptCacheSize: data.promptCacheSize ?? DEFAULT_APP_SETTINGS.promptCacheSize,
promptEnhancerEnabledT2V: data.promptEnhancerEnabledT2V ?? DEFAULT_APP_SETTINGS.promptEnhancerEnabledT2V,
promptEnhancerEnabledI2V: data.promptEnhancerEnabledI2V ?? DEFAULT_APP_SETTINGS.promptEnhancerEnabledI2V,
promptEnhancerProviderPreference: data.promptEnhancerProviderPreference ?? DEFAULT_APP_SETTINGS.promptEnhancerProviderPreference,
seedLocked: data.seedLocked ?? DEFAULT_APP_SETTINGS.seedLocked,
lockedSeed: data.lockedSeed ?? DEFAULT_APP_SETTINGS.lockedSeed,
modelsDir: data.modelsDir ?? DEFAULT_APP_SETTINGS.modelsDir,
useConvVae: data.useConvVae ?? DEFAULT_APP_SETTINGS.useConvVae,
}
}
type RuntimePolicyPayload = ApiSuccessOf<'getRuntimePolicy'>
type GpuInfoPayload = ApiSuccessOf<'getGpuInfo'>
export function AppSettingsProvider({ children }: { children: ReactNode }) {
const [settings, setSettings] = useState<AppSettings>(DEFAULT_APP_SETTINGS)
const [isLoaded, setIsLoaded] = useState(false)
const [runtimePolicyLoaded, setRuntimePolicyLoaded] = useState(false)
const [forceApiGenerations, setForceApiGenerations] = useState(true)
const [cudaAvailable, setCudaAvailable] = useState(false)
const [backendProcessStatus, setBackendProcessStatus] = useState<BackendProcessStatus | null>(null)
const [modelsVersion, setModelsVersion] = useState(0)
const [settingsRevision, setSettingsRevision] = useState(0)
const notifyModelsChanged = useCallback(() => {
setModelsVersion((current) => current + 1)
}, [])
useEffect(() => {
if (backendProcessStatus !== 'alive') return
let cancelled = false
setRuntimePolicyLoaded(false)
const fetchRuntimePolicy = async () => {
const result = await ApiClient.getRuntimePolicy()
if (!result.ok) {
if (!cancelled) {
// Fail closed until policy can be read.
setForceApiGenerations(true)
setRuntimePolicyLoaded(true)
}
return
}
const payload = result.data as RuntimePolicyPayload
if (typeof payload.force_api_generations !== 'boolean') {
if (!cancelled) {
setForceApiGenerations(true)
}
} else if (!cancelled) {
setForceApiGenerations(payload.force_api_generations)
}
if (!cancelled) {
setRuntimePolicyLoaded(true)
}
}
void fetchRuntimePolicy()
return () => {
cancelled = true
}
}, [backendProcessStatus])
useEffect(() => {
if (backendProcessStatus !== 'alive') return
let cancelled = false
const fetchGpuInfo = async () => {
const result = await ApiClient.getGpuInfo()
if (!result.ok || cancelled) return
const payload = result.data as GpuInfoPayload
setCudaAvailable(Boolean(payload.cuda_available))
}
void fetchGpuInfo()
return () => {
cancelled = true
}
}, [backendProcessStatus, modelsVersion])
useEffect(() => {
let cancelled = false
const applyStatus = (value: unknown) => {
const nextStatus = toBackendProcessStatus(value)
if (!nextStatus || cancelled) {
return
}
if (nextStatus === 'alive') {
resetBackendCredentials()
}
setBackendProcessStatus(nextStatus)
}
const unsubscribe = window.electronAPI.onBackendHealthStatus((data) => {
applyStatus(data)
})
void window.electronAPI.getBackendHealthStatus()
.then((snapshot) => {
applyStatus(snapshot)
})
.catch(() => {
// Snapshot is optional at startup; subscription continues to listen for pushes.
})
return () => {
cancelled = true
unsubscribe()
}
}, [])
const refreshSettings = useCallback(async () => {
const result = await ApiClient.getSettings()
if (!result.ok) {
throw new Error(result.error.message)
}
setSettings(normalizeAppSettings(result.data))
setIsLoaded(true)
}, [])
useEffect(() => {
if (isLoaded || backendProcessStatus !== 'alive') return
let cancelled = false
let retryTimer: ReturnType<typeof setTimeout> | null = null
const fetchSettings = async () => {
try {
await refreshSettings()
if (cancelled) return
} catch {
if (!cancelled) {
retryTimer = setTimeout(fetchSettings, 1000)
}
}
}
fetchSettings()
return () => {
cancelled = true
if (retryTimer) clearTimeout(retryTimer)
}
}, [backendProcessStatus, isLoaded, refreshSettings])
useEffect(() => {
if (!isLoaded || backendProcessStatus !== 'alive' || settingsRevision === 0) return
const syncTimer = setTimeout(async () => {
const { hasLtxApiKey: _a, hasFalApiKey: _b, hasGeminiApiKey: _c, modelsDir: _d, ...syncPayload } = settings
const result = await ApiClient.updateSettings(syncPayload)
if (!result.ok) {
// Best-effort settings sync.
}
}, 150)
return () => clearTimeout(syncTimer)
}, [backendProcessStatus, isLoaded, settings, settingsRevision])
const updateSettings = useCallback((patch: Partial<AppSettings> | ((prev: AppSettings) => AppSettings)) => {
setSettingsRevision(value => value + 1)
if (typeof patch === 'function') {
setSettings((prev) => patch(prev))
return
}
setSettings((prev) => ({ ...prev, ...patch }))
}, [])
const saveLtxApiKey = useCallback(async (value: string) => {
const result = await ApiClient.updateSettings({ ltxApiKey: value })
if (!result.ok) {
throw new Error(result.error.message)
}
await refreshSettings()
}, [refreshSettings])
const saveGeminiApiKey = useCallback(async (value: string) => {
const result = await ApiClient.updateSettings({ geminiApiKey: value })
if (!result.ok) {
throw new Error(result.error.message)
}
await refreshSettings()
}, [refreshSettings])
const saveFalApiKey = useCallback(async (value: string) => {
const result = await ApiClient.updateSettings({ falApiKey: value })
if (!result.ok) {
throw new Error(result.error.message)
}
await refreshSettings()
}, [refreshSettings])
const shouldVideoGenerateWithLtxApi =
forceApiGenerations || (settings.userPrefersLtxApiVideoGenerations && settings.hasLtxApiKey)
const shouldImageGenerateWithFalApi =
forceApiGenerations || (settings.userPrefersFalApiImageGenerations && settings.hasFalApiKey)
const contextValue = useMemo<AppSettingsContextValue>(
() => ({
settings,
isLoaded,
runtimePolicyLoaded,
updateSettings,
refreshSettings,
saveLtxApiKey,
saveFalApiKey,
saveGeminiApiKey,
forceApiGenerations,
shouldVideoGenerateWithLtxApi,
shouldImageGenerateWithFalApi,
cudaAvailable,
modelsVersion,
notifyModelsChanged,
}),
[cudaAvailable, forceApiGenerations, isLoaded, modelsVersion, notifyModelsChanged, refreshSettings, runtimePolicyLoaded, saveFalApiKey, saveGeminiApiKey, saveLtxApiKey, settings, shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi, updateSettings],
)
return <AppSettingsContext.Provider value={contextValue}>{children}</AppSettingsContext.Provider>
}
export function useAppSettings() {
const context = useContext(AppSettingsContext)
if (!context) {
throw new Error('useAppSettings must be used within AppSettingsProvider')
}
return context
}
+92
View File
@@ -0,0 +1,92 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
// Dev / escape-hatch feature flags. Client-only, per-machine (localStorage) —
// not a user setting and not synced. Toggle via the Dev Panel (Ctrl/Cmd+Shift+D).
// Adding a feature flag = one entry in DEV_FLAGS; read it from a single gate where
// the feature is used (avoid sprinkling the flag across call sites).
export type DevFlagKey = 'customIcLora' | 'advancedIcLoraControls' | 'enableMultipleKeyframesVideos'
interface DevFlagSpec {
key: DevFlagKey
label: string
description: string
}
export const DEV_FLAGS: DevFlagSpec[] = [
{
key: 'customIcLora',
label: 'Custom IC-LoRA',
description: 'Show the "Custom IC-LoRA" conditioning option (user-supplied weights + control video). Off by default — local results are currently low quality. Built-in Canny/Depth IC-LoRA is unaffected.',
},
{
key: 'advancedIcLoraControls',
label: 'Advanced IC-LoRA controls',
description: 'Expose all IC-LoRA settings (skip stage 2, resolution factor, audio, strengths) for catalog and custom modes. Off by default — settings use the catalog IC-LoRA defaults.',
},
{
key: 'enableMultipleKeyframesVideos',
label: 'Multiple keyframe videos',
description: 'Show "Generate Multi Keyframes Videos" in the GenSpace mode dropdown (immediately after Generate Videos). Only available for local generations — hidden in API/cloud mode even when this flag is on. Off by default.',
},
]
type Flags = Record<DevFlagKey, boolean>
const DEFAULT_FLAGS: Flags = { customIcLora: false, advancedIcLoraControls: false, enableMultipleKeyframesVideos: false }
const STORAGE_KEY = 'ltx-dev-flags'
function loadFlags(): Flags {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) return { ...DEFAULT_FLAGS, ...(JSON.parse(raw) as Partial<Flags>) }
} catch { /* ignore */ }
return { ...DEFAULT_FLAGS }
}
interface DevFlagsState {
flags: Flags
setFlag: (key: DevFlagKey, on: boolean) => void
isPanelOpen: boolean
setPanelOpen: (open: boolean) => void
}
const DevFlagsContext = createContext<DevFlagsState | null>(null)
export function DevFlagsProvider({ children }: { children: React.ReactNode }) {
const [flags, setFlags] = useState<Flags>(() => loadFlags())
const [isPanelOpen, setPanelOpen] = useState(false)
const setFlag = useCallback((key: DevFlagKey, on: boolean) => {
setFlags(prev => {
const next = { ...prev, [key]: on }
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) } catch { /* ignore */ }
return next
})
}, [])
// Global toggle: Ctrl/Cmd + Shift + D. Capture phase so component-level keydown
// handlers don't swallow it; preventDefault avoids any Electron/Chromium default.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'd') {
e.preventDefault()
e.stopPropagation()
setPanelOpen(prev => !prev)
}
}
window.addEventListener('keydown', onKey, { capture: true })
return () => window.removeEventListener('keydown', onKey, { capture: true })
}, [])
return (
<DevFlagsContext.Provider value={{ flags, setFlag, isPanelOpen, setPanelOpen }}>
{children}
</DevFlagsContext.Provider>
)
}
export function useDevFlags(): DevFlagsState {
const ctx = useContext(DevFlagsContext)
if (!ctx) throw new Error('useDevFlags must be used within DevFlagsProvider')
return ctx
}
@@ -0,0 +1,142 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'
import {
KeyboardLayout,
KeyboardPreset,
BUILT_IN_PRESETS,
LTX_DEFAULT_LAYOUT,
cloneLayout,
ActionId,
} from '../lib/keyboard-shortcuts'
interface KeyboardShortcutsState {
// Current active layout
activeLayout: KeyboardLayout
activePresetId: string
// All presets (built-in + user)
presets: KeyboardPreset[]
// Actions
switchPreset: (presetId: string) => void
updateBinding: (actionId: ActionId, combos: KeyboardLayout[ActionId]) => void
resetToPreset: (presetId: string) => void
saveAsCustomPreset: (name: string) => void
deleteCustomPreset: (presetId: string) => void
// Whether the shortcuts editor modal is open
isEditorOpen: boolean
setEditorOpen: (open: boolean) => void
}
const KeyboardShortcutsContext = createContext<KeyboardShortcutsState | null>(null)
const STORAGE_KEY = 'ltx-keyboard-shortcuts'
interface PersistedState {
activePresetId: string
customLayout?: KeyboardLayout // Only stored when user has modified bindings
customPresets?: KeyboardPreset[]
}
function loadFromStorage(): PersistedState | null {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) return JSON.parse(raw)
} catch { /* ignore */ }
return null
}
function saveToStorage(state: PersistedState) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
} catch { /* ignore */ }
}
export function KeyboardShortcutsProvider({ children }: { children: React.ReactNode }) {
const stored = useRef(loadFromStorage())
const [activePresetId, setActivePresetId] = useState<string>(stored.current?.activePresetId || 'ltx-default')
const [customLayout, setCustomLayout] = useState<KeyboardLayout | null>(stored.current?.customLayout || null)
const [customPresets, setCustomPresets] = useState<KeyboardPreset[]>(stored.current?.customPresets || [])
const [isEditorOpen, setEditorOpen] = useState(false)
// Resolve active layout: if customLayout is set, use it; otherwise use the preset's layout
const activeLayout: KeyboardLayout = customLayout
|| [...BUILT_IN_PRESETS, ...customPresets].find(p => p.id === activePresetId)?.layout
|| LTX_DEFAULT_LAYOUT
// Keep a ref to the active layout so updateBinding always reads the latest
const activeLayoutRef = useRef(activeLayout)
activeLayoutRef.current = activeLayout
const allPresets = [...BUILT_IN_PRESETS, ...customPresets]
// Persist whenever state changes
useEffect(() => {
saveToStorage({
activePresetId,
customLayout: customLayout || undefined,
customPresets: customPresets.length > 0 ? customPresets : undefined,
})
}, [activePresetId, customLayout, customPresets])
const switchPreset = useCallback((presetId: string) => {
setActivePresetId(presetId)
setCustomLayout(null) // Clear any custom modifications — use the preset directly
}, [])
const updateBinding = useCallback((actionId: ActionId, combos: KeyboardLayout[ActionId]) => {
// Always read the LATEST layout from the ref to avoid stale closure issues
setCustomLayout(prev => {
const base = prev || cloneLayout(activeLayoutRef.current)
const updated = { ...base, [actionId]: combos }
return updated
})
}, []) // No deps needed — reads from ref
const resetToPreset = useCallback((presetId: string) => {
setActivePresetId(presetId)
setCustomLayout(null)
}, [])
const saveAsCustomPreset = useCallback((name: string) => {
const preset: KeyboardPreset = {
id: `custom-${Date.now()}`,
name,
description: 'Custom keyboard layout',
layout: cloneLayout(activeLayoutRef.current),
builtIn: false,
}
setCustomPresets(prev => [...prev, preset])
setActivePresetId(preset.id)
setCustomLayout(null)
}, [])
const deleteCustomPreset = useCallback((presetId: string) => {
setCustomPresets(prev => prev.filter(p => p.id !== presetId))
if (activePresetId === presetId) {
setActivePresetId('ltx-default')
setCustomLayout(null)
}
}, [activePresetId])
return (
<KeyboardShortcutsContext.Provider value={{
activeLayout,
activePresetId,
presets: allPresets,
switchPreset,
updateBinding,
resetToPreset,
saveAsCustomPreset,
deleteCustomPreset,
isEditorOpen,
setEditorOpen,
}}>
{children}
</KeyboardShortcutsContext.Provider>
)
}
export function useKeyboardShortcuts() {
const ctx = useContext(KeyboardShortcutsContext)
if (!ctx) throw new Error('useKeyboardShortcuts must be used inside KeyboardShortcutsProvider')
return ctx
}
+348
View File
@@ -0,0 +1,348 @@
import React, { createContext, useCallback, useContext, useState } from 'react'
import { hasLegacyProjectsEntry } from '../hooks/useProjectReferencesMigration'
import { createDefaultTimeline, normalizeProject, type Project, type Asset, type AssetTake, type ProjectTab } from '../types/project-model'
import {
deleteProjectEntry,
readProject,
readProjectIds,
writeProject,
writeProjectIds,
} from '../lib/project-storage'
interface ProjectContextType {
currentTab: ProjectTab
setCurrentTab: (tab: ProjectTab) => void
projectIds: string[]
activeProject: Project | null
getProject: (id: string) => Project | null
setProject: (id: string, project: Project) => void
createProject: (name: string) => Project
deleteProject: (id: string) => void
renameProject: (id: string, name: string) => void
activateProject: (id: string) => void
clearActiveProject: () => void
reloadProjectIds: () => void
addAsset: (projectId: string, asset: Omit<Asset, 'id' | 'createdAt'>) => Asset
deleteAsset: (projectId: string, assetId: string) => void
updateAsset: (projectId: string, assetId: string, updates: Partial<Asset>) => void
addTakeToAsset: (projectId: string, assetId: string, take: AssetTake) => void
deleteTakeFromAsset: (projectId: string, assetId: string, takeIndex: number) => void
setAssetActiveTake: (projectId: string, assetId: string, takeIndex: number) => void
toggleFavorite: (projectId: string, assetId: string) => void
genSpaceEditImagePath: string | null
setGenSpaceEditImagePath: (path: string | null) => void
genSpaceEditMode: 'image' | 'video' | null
setGenSpaceEditMode: (mode: 'image' | 'video' | null) => void
genSpaceAudioPath: string | null
setGenSpaceAudioPath: (path: string | null) => void
genSpaceRetakeSource: GenSpaceRetakeSource | null
setGenSpaceRetakeSource: (source: GenSpaceRetakeSource | null) => void
pendingRetakeUpdate: PendingRetakeUpdate | null
setPendingRetakeUpdate: (update: PendingRetakeUpdate | null) => void
genSpaceIcLoraSource: GenSpaceIcLoraSource | null
setGenSpaceIcLoraSource: (source: GenSpaceIcLoraSource | null) => void
pendingIcLoraUpdate: PendingIcLoraUpdate | null
setPendingIcLoraUpdate: (update: PendingIcLoraUpdate | null) => void
}
export interface GenSpaceRetakeSource {
videoPath: string
clipId?: string
assetId?: string
linkedClipIds?: string[]
duration?: number
}
export interface PendingRetakeUpdate {
assetId: string
clipIds: string[]
newTakeIndex: number
}
export interface GenSpaceIcLoraSource {
videoPath: string
clipId?: string
assetId?: string
linkedClipIds?: string[]
}
export interface PendingIcLoraUpdate {
assetId: string
clipIds: string[]
newTakeIndex: number
}
const ProjectContext = createContext<ProjectContextType | null>(null)
function loadInitialProjectIds(): string[] {
if (hasLegacyProjectsEntry()) return []
return readProjectIds()
}
export function ProjectProvider({ children }: { children: React.ReactNode }) {
const [currentTab, setCurrentTab] = useState<ProjectTab>('gen-space')
const [projectIds, setProjectIds] = useState<string[]>(() => loadInitialProjectIds())
const [activeProject, setActiveProject] = useState<Project | null>(null)
const [projectRevision, setProjectRevision] = useState(0)
const [genSpaceEditImagePath, setGenSpaceEditImagePath] = useState<string | null>(null)
const [genSpaceEditMode, setGenSpaceEditMode] = useState<'image' | 'video' | null>(null)
const [genSpaceAudioPath, setGenSpaceAudioPath] = useState<string | null>(null)
const [genSpaceRetakeSource, setGenSpaceRetakeSource] = useState<GenSpaceRetakeSource | null>(null)
const [pendingRetakeUpdate, setPendingRetakeUpdate] = useState<PendingRetakeUpdate | null>(null)
const [genSpaceIcLoraSource, setGenSpaceIcLoraSource] = useState<GenSpaceIcLoraSource | null>(null)
const [pendingIcLoraUpdate, setPendingIcLoraUpdate] = useState<PendingIcLoraUpdate | null>(null)
const bumpProjectRevision = useCallback(() => {
setProjectRevision(prev => prev + 1)
}, [])
const getProject = useCallback((id: string): Project | null => readProject(id), [projectRevision])
const reloadProjectIds = useCallback(() => {
const nextProjectIds = hasLegacyProjectsEntry() ? [] : readProjectIds()
setProjectIds(nextProjectIds)
setActiveProject(prev => (
prev && nextProjectIds.includes(prev.id) ? prev : null
))
bumpProjectRevision()
}, [bumpProjectRevision])
const activateProject = useCallback((id: string) => {
setActiveProject(readProject(id))
}, [])
const clearActiveProject = useCallback(() => {
setActiveProject(null)
}, [])
const persistProject = useCallback((projectId: string, project: Project): Project => {
const persistedProject = writeProject(projectId, normalizeProject({ ...project, id: projectId }))
setActiveProject(prev => (prev?.id === projectId ? persistedProject : prev))
bumpProjectRevision()
return persistedProject
}, [bumpProjectRevision])
const mutateProject = useCallback((projectId: string, updater: (project: Project) => Project): Project | null => {
const project = readProject(projectId)
if (!project) return null
return persistProject(projectId, updater(project))
}, [persistProject])
const setProject = useCallback((projectId: string, project: Project) => {
persistProject(projectId, project)
}, [persistProject])
const createProject = useCallback((name: string): Project => {
const defaultTimeline = createDefaultTimeline('Timeline 1')
const newProject = normalizeProject({
id: `project-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name,
createdAt: Date.now(),
updatedAt: Date.now(),
assets: [],
timelines: [defaultTimeline],
activeTimelineId: defaultTimeline.id,
})
const persistedProject = writeProject(newProject.id, newProject)
const nextProjectIds = [persistedProject.id, ...readProjectIds().filter(id => id !== persistedProject.id)]
writeProjectIds(nextProjectIds)
setProjectIds(nextProjectIds)
bumpProjectRevision()
return persistedProject
}, [bumpProjectRevision])
const deleteProject = useCallback((id: string) => {
const nextProjectIds = readProjectIds().filter(projectId => projectId !== id)
writeProjectIds(nextProjectIds)
setProjectIds(nextProjectIds)
deleteProjectEntry(id)
setActiveProject(prev => (prev?.id === id ? null : prev))
bumpProjectRevision()
}, [bumpProjectRevision])
const renameProject = useCallback((id: string, name: string) => {
mutateProject(id, project => ({
...project,
name,
updatedAt: Date.now(),
}))
}, [mutateProject])
const addAsset = useCallback((projectId: string, assetData: Omit<Asset, 'id' | 'createdAt'>): Asset => {
const newAsset: Asset = {
...assetData,
id: `asset-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
createdAt: Date.now(),
}
mutateProject(projectId, project => ({
...project,
assets: [newAsset, ...project.assets],
updatedAt: Date.now(),
}))
return newAsset
}, [mutateProject])
const deleteAsset = useCallback((projectId: string, assetId: string) => {
mutateProject(projectId, project => ({
...project,
assets: project.assets.filter(asset => asset.id !== assetId),
updatedAt: Date.now(),
}))
}, [mutateProject])
const updateAsset = useCallback((projectId: string, assetId: string, updates: Partial<Asset>) => {
mutateProject(projectId, project => ({
...project,
assets: project.assets.map(asset => (
asset.id === assetId ? { ...asset, ...updates } : asset
)),
updatedAt: Date.now(),
}))
}, [mutateProject])
const addTakeToAsset = useCallback((projectId: string, assetId: string, take: AssetTake) => {
mutateProject(projectId, project => ({
...project,
assets: project.assets.map(asset => {
if (asset.id !== assetId) return asset
const existingTakes: AssetTake[] = asset.takes || [{
path: asset.path,
bigThumbnailPath: asset.bigThumbnailPath,
smallThumbnailPath: asset.smallThumbnailPath,
width: asset.width,
height: asset.height,
createdAt: asset.createdAt,
}]
const newTakes = [...existingTakes, take]
const newIndex = newTakes.length - 1
return {
...asset,
takes: newTakes,
activeTakeIndex: newIndex,
path: take.path,
bigThumbnailPath: take.bigThumbnailPath,
smallThumbnailPath: take.smallThumbnailPath,
width: take.width,
height: take.height,
}
}),
updatedAt: Date.now(),
}))
}, [mutateProject])
const deleteTakeFromAsset = useCallback((projectId: string, assetId: string, takeIndex: number) => {
mutateProject(projectId, project => ({
...project,
assets: project.assets.map(asset => {
if (asset.id !== assetId || !asset.takes || asset.takes.length <= 1) return asset
const newTakes = asset.takes.filter((_, index) => index !== takeIndex)
let newActiveIdx = asset.activeTakeIndex ?? newTakes.length - 1
if (newActiveIdx >= newTakes.length) newActiveIdx = newTakes.length - 1
if (newActiveIdx < 0) newActiveIdx = 0
const activeTake = newTakes[newActiveIdx]
return {
...asset,
takes: newTakes,
activeTakeIndex: newActiveIdx,
path: activeTake.path,
bigThumbnailPath: activeTake.bigThumbnailPath,
smallThumbnailPath: activeTake.smallThumbnailPath,
width: activeTake.width,
height: activeTake.height,
}
}),
updatedAt: Date.now(),
}))
}, [mutateProject])
const setAssetActiveTake = useCallback((projectId: string, assetId: string, takeIndex: number) => {
mutateProject(projectId, project => ({
...project,
assets: project.assets.map(asset => {
if (asset.id !== assetId || !asset.takes) return asset
const nextIndex = Math.max(0, Math.min(takeIndex, asset.takes.length - 1))
const take = asset.takes[nextIndex]
return {
...asset,
activeTakeIndex: nextIndex,
path: take.path,
bigThumbnailPath: take.bigThumbnailPath,
smallThumbnailPath: take.smallThumbnailPath,
width: take.width,
height: take.height,
}
}),
updatedAt: Date.now(),
}))
}, [mutateProject])
const toggleFavorite = useCallback((projectId: string, assetId: string) => {
mutateProject(projectId, project => ({
...project,
assets: project.assets.map(asset => (
asset.id === assetId ? { ...asset, favorite: !asset.favorite } : asset
)),
updatedAt: Date.now(),
}))
}, [mutateProject])
return (
<ProjectContext.Provider value={{
currentTab,
setCurrentTab,
projectIds,
activeProject,
getProject,
setProject,
createProject,
deleteProject,
renameProject,
activateProject,
clearActiveProject,
reloadProjectIds,
addAsset,
deleteAsset,
updateAsset,
addTakeToAsset,
deleteTakeFromAsset,
setAssetActiveTake,
toggleFavorite,
genSpaceEditImagePath,
setGenSpaceEditImagePath,
genSpaceEditMode,
setGenSpaceEditMode,
genSpaceAudioPath,
setGenSpaceAudioPath,
genSpaceRetakeSource,
setGenSpaceRetakeSource,
pendingRetakeUpdate,
setPendingRetakeUpdate,
genSpaceIcLoraSource,
setGenSpaceIcLoraSource,
pendingIcLoraUpdate,
setPendingIcLoraUpdate,
}}>
{children}
</ProjectContext.Provider>
)
}
export function useProjects() {
const context = useContext(ProjectContext)
if (!context) {
throw new Error('useProjects must be used within a ProjectProvider')
}
return context
}
+59
View File
@@ -0,0 +1,59 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react'
import type { ViewType } from '../types/project-model'
import { useProjects } from './ProjectContext'
interface ViewContextType {
currentView: ViewType
setCurrentView: (view: ViewType) => void
openProject: (projectId: string) => void
goHome: () => void
}
const ViewContext = createContext<ViewContextType | null>(null)
export function ViewProvider({ children }: { children: React.ReactNode }) {
const {
activeProject,
activateProject,
clearActiveProject,
setCurrentTab,
} = useProjects()
const [currentView, setCurrentView] = useState<ViewType>('home')
const openProject = useCallback((projectId: string) => {
activateProject(projectId)
setCurrentTab('gen-space')
setCurrentView('project')
}, [activateProject, setCurrentTab])
const goHome = useCallback(() => {
clearActiveProject()
setCurrentView('home')
}, [clearActiveProject])
useEffect(() => {
if (currentView === 'project' && !activeProject) {
setCurrentView('home')
}
}, [activeProject, currentView])
return (
<ViewContext.Provider value={{ currentView, setCurrentView, openProject, goHome }}>
{children}
</ViewContext.Provider>
)
}
export function useView() {
const context = useContext(ViewContext)
if (!context) {
throw new Error('useView must be used within a ViewProvider')
}
return context
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
import { useCallback, useEffect, useState } from 'react'
import type { UpdateStatePayload } from '../../shared/electron-api-schema'
import { useIsGenerationActive } from '../lib/generation-active'
export type AppUpdate = {
state: UpdateStatePayload
checkForUpdates: () => Promise<void>
startDownload: () => Promise<void>
installAndRestart: () => Promise<{ success: true } | { success: false; error: string }>
skipVersion: (version: string) => Promise<void>
}
const INITIAL: UpdateStatePayload = { status: 'idle', currentVersion: '' }
const MODAL_STATUSES: ReadonlySet<UpdateStatePayload['status']> = new Set([
'available',
'downloading',
'downloaded',
])
export function useAppUpdate(): AppUpdate {
const [state, setState] = useState<UpdateStatePayload>(INITIAL)
useEffect(() => {
let alive = true
let fromEvent = false
// Subscribe first so a check that starts during getUpdateState cannot be missed,
// then ignore the snapshot if an event already applied a newer value.
const unsubscribe = window.electronAPI.onUpdateEvent((data) => {
fromEvent = true
if (alive) setState(data)
})
void window.electronAPI.getUpdateState()
.then((s) => { if (alive && !fromEvent) setState(s) })
.catch(() => {})
return () => { alive = false; unsubscribe() }
}, [])
const checkForUpdates = useCallback(async () => {
await window.electronAPI.checkForUpdatesNow()
}, [])
const startDownload = useCallback(async () => {
await window.electronAPI.startUpdateDownload()
}, [])
const installAndRestart = useCallback(async () => {
return window.electronAPI.installUpdateAndRestart()
}, [])
const skipVersion = useCallback(async (version: string) => {
await window.electronAPI.skipUpdateVersion({ version })
}, [])
return { state, checkForUpdates, startDownload, installAndRestart, skipVersion }
}
/** Session Later / skip / manual-check intent. App only mounts the modal. */
export function useAppUpdateModal() {
const update = useAppUpdate()
const isGenerationActive = useIsGenerationActive()
const [modalOpen, setModalOpen] = useState(false)
const [manualCheckPending, setManualCheckPending] = useState(false)
const [laterVersion, setLaterVersion] = useState<string | null>(null)
useEffect(() => {
// Mac has no modal: silent download + install-on-quit, gated only by the About toggle.
if (window.electronAPI.platform === 'darwin') return
const s = update.state
if (s.status === 'available') {
if (manualCheckPending || s.version !== laterVersion) {
setModalOpen(true)
}
if (manualCheckPending) setManualCheckPending(false)
} else if (s.status === 'downloaded') {
// Bits are on disk: the modal is the only install path and must stay up,
// including after Hide-during-download.
setModalOpen(true)
} else if (s.status === 'not-available') {
if (manualCheckPending) setManualCheckPending(false)
}
}, [update.state.status, update.state.version, manualCheckPending, laterVersion])
const requestCheck = update.checkForUpdates
const skipVersion = update.skipVersion
const version = update.state.version
const status = update.state.status
const checkForUpdates = useCallback(() => {
setManualCheckPending(true)
void requestCheck()
}, [requestCheck])
const closeModal = useCallback((skipThisVersion: boolean) => {
if (status === 'downloaded') return
if (skipThisVersion && version) void skipVersion(version)
// Hide during download is not Later — keep the session prompt so a failed
// download can reopen the modal. Later/skip only apply when dismissing the offer.
else if (version && status !== 'downloading') setLaterVersion(version)
setModalOpen(false)
}, [skipVersion, version, status])
const openModal = useCallback(() => setModalOpen(true), [])
return {
update,
isGenerationActive,
// Keep the modal mounted across a periodic re-check (`checking`) so it does not
// unmount/remount. Do not treat `available` as busy in main — that would hide a
// newer version after the user clicked Later.
isModalOpen:
window.electronAPI.platform !== 'darwin'
&& modalOpen
&& (MODAL_STATUSES.has(status) || status === 'checking'),
openModal,
closeModal,
checkForUpdates,
}
}
+94
View File
@@ -0,0 +1,94 @@
import { useState, useEffect, useCallback } from 'react'
import { resetBackendCredentials } from '../lib/backend'
import { logger } from '../lib/logger'
export type BackendProcessStatus = 'alive' | 'restarting' | 'dead'
interface BackendHealthStatusPayload {
status: BackendProcessStatus
exitCode?: number | null
}
interface UseBackendReturn {
processStatus: BackendProcessStatus | null
connected: boolean
isLoading: boolean
}
function toBackendHealthStatus(value: unknown): BackendHealthStatusPayload | null {
if (!value || typeof value !== 'object') {
return null
}
const record = value as { status?: unknown; exitCode?: unknown }
if (record.status !== 'alive' && record.status !== 'restarting' && record.status !== 'dead') {
return null
}
return {
status: record.status,
exitCode: typeof record.exitCode === 'number' || record.exitCode === null ? record.exitCode : undefined,
}
}
export function useBackend(): UseBackendReturn {
const [processStatus, setProcessStatus] = useState<BackendProcessStatus | null>(null)
const [isLoading, setIsLoading] = useState(true)
const handleBackendStatus = useCallback((payload: BackendHealthStatusPayload) => {
setProcessStatus(payload.status)
if (payload.status === 'alive') {
// Main has verified HTTP reachability before publishing 'alive' and may
// have spawned a fresh backend with a new port/token — drop cached creds
// so the next backendFetch picks up the current values.
resetBackendCredentials()
setIsLoading(false)
return
}
if (payload.status === 'restarting') {
return
}
setIsLoading(false)
}, [])
useEffect(() => {
let cancelled = false
const applyStatus = (value: unknown) => {
const payload = toBackendHealthStatus(value)
if (!payload || cancelled) {
return
}
handleBackendStatus(payload)
}
const unsubscribe = window.electronAPI.onBackendHealthStatus((data: BackendHealthStatusPayload) => {
applyStatus(data)
})
const init = async () => {
try {
const snapshot = await window.electronAPI.getBackendHealthStatus()
applyStatus(snapshot)
} catch (err) {
logger.error(`Failed to load backend health status snapshot: ${err}`)
}
}
void init()
return () => {
cancelled = true
unsubscribe()
}
}, [handleBackendStatus])
return {
processStatus,
connected: processStatus === 'alive',
isLoading,
}
}
+128
View File
@@ -0,0 +1,128 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { ApiClient } from '../lib/api-client'
import type { ApiSuccessOf } from '../lib/api-client'
import { catalogVariantKey } from '../lib/lora-library'
export type IcLoraListItem = ApiSuccessOf<'listIcLoras'>['ic_loras'][number]
export type LoraCatalogListItem = ApiSuccessOf<'listLoras'>['loras'][number]
type StartResult = { ok: true; data: { sessionId: string } } | { ok: false; error: { message: string } }
type ProgressResult =
| { ok: true; data: { progress: number; status: 'downloading' | 'complete' | 'error'; error?: string | null } }
| { ok: false; error: { message: string } }
// Shared download + poll state machine for a catalog item (LoRA or IC-LoRA). The two hooks
// below differ only in which endpoints they hit and how they list items; this owns the
// identical download/poll/error half. onComplete refreshes the caller's list.
// downloadingKey / downloadError.key are catalogVariantKey(id, variantId) so two checkpoints
// of the same item don't clobber each other's UI state.
function useCatalogDownload(
startDownload: (id: string, variantId?: string) => Promise<StartResult>,
getProgress: (sessionId: string) => Promise<ProgressResult>,
onComplete: () => void,
) {
const [downloadingKey, setDownloadingKey] = useState<string | null>(null)
const [progress, setProgress] = useState(0)
const [downloadError, setDownloadError] = useState<{ key: string; message: string } | null>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const download = useCallback(async (id: string, variantId?: string) => {
const key = catalogVariantKey(id, variantId)
setDownloadError(null)
const start = await startDownload(id, variantId)
if (!start.ok) {
setDownloadError({ key, message: start.error.message })
return
}
setDownloadingKey(key)
setProgress(0)
const sessionId = start.data.sessionId
// Never stack intervals: a re-entrant download() would otherwise leak the previous one.
if (pollRef.current) clearInterval(pollRef.current)
const stop = () => {
if (pollRef.current) clearInterval(pollRef.current)
pollRef.current = null
setDownloadingKey(null)
setProgress(0)
}
let inFlight = false
let failures = 0
pollRef.current = setInterval(async () => {
if (inFlight) return // don't overlap polls if a tick outruns the 1s interval
inFlight = true
try {
const p = await getProgress(sessionId)
if (!p.ok) {
// Tolerate a blip, but give up (and surface it) rather than poll forever.
if (++failures >= 3) { stop(); setDownloadError({ key, message: 'Lost contact with the download.' }) }
return
}
failures = 0
setProgress(p.data.progress)
if (p.data.status !== 'downloading') {
stop()
if (p.data.status === 'error') setDownloadError({ key, message: p.data.error ?? 'Download failed' })
onComplete()
}
} finally {
inFlight = false
}
}, 1000)
}, [startDownload, getProgress, onComplete])
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
return { downloadingKey, progress, downloadError, download }
}
export function useIcLoras(enabled: boolean) {
const [icLoras, setIcLoras] = useState<IcLoraListItem[]>([])
const refresh = useCallback(async () => {
if (!enabled) return
const r = await ApiClient.listIcLoras()
if (r.ok) setIcLoras(r.data.ic_loras)
}, [enabled])
useEffect(() => { void refresh() }, [refresh])
const { downloadingKey, progress, downloadError, download: downloadIcLora } = useCatalogDownload(
// Attach the in-app HF token when the user is signed in; the backend ignores it for public
// repos and requires it only for gated entries (optional auth).
(id, variantId) => ApiClient.startIcLoraDownload({
ic_lora_id: id,
variant_id: variantId,
use_hf_auth: true,
}),
(sessionId) => ApiClient.getIcLoraDownloadProgress({ sessionId }),
refresh,
)
return { icLoras, refresh, downloadIcLora, downloadingKey, progress, downloadError }
}
// Plain-LoRA catalog: uses /api/loras* (an isolated download session, so it never collides
// with an in-flight IC-LoRA download).
export function useLoraCatalog(enabled: boolean) {
const [loras, setLoras] = useState<LoraCatalogListItem[]>([])
const refresh = useCallback(async () => {
if (!enabled) return
const r = await ApiClient.listLoras()
if (r.ok) setLoras(r.data.loras)
}, [enabled])
useEffect(() => { void refresh() }, [refresh])
const { downloadingKey, progress, downloadError, download: downloadLora } = useCatalogDownload(
(id, variantId) => ApiClient.startLoraDownload({
lora_id: id,
variant_id: variantId,
use_hf_auth: true,
}),
(sessionId) => ApiClient.getLoraDownloadProgress({ sessionId }),
refresh,
)
return { loras, refresh, downloadLora, downloadingKey, progress, downloadError }
}
@@ -0,0 +1,9 @@
import { useDevFlags } from '../contexts/DevFlagsContext'
// Single source of truth for whether the "Custom IC-LoRA" UI is available.
// Off by default via the dev flag. Built-in Canny/Depth IC-LoRA is unaffected.
// The custom option only appears inside IC-LoRA mode, which is already gated by
// the existing API-only check — so this flag is all the extra gating it needs.
export function useCustomIcLoraEnabled(): boolean {
return useDevFlags().flags.customIcLora
}
+128
View File
@@ -0,0 +1,128 @@
import { useCallback, useState } from 'react'
import { ApiClient } from '../lib/api-client'
import { canCancelLocalJob, withGenerationActive } from '../lib/generation-active'
import { logger } from '../lib/logger'
import { prepareGenerationInput } from '../lib/generation-input'
import { useAppSettings } from '../contexts/AppSettingsContext'
import type { RetakeExtendModel } from './use-retake'
export type ExtendDirection = 'start' | 'end'
// Seconds-to-add presets (matches LTX Studio). API allows 2–20s.
export const EXTEND_SECONDS = [4, 6, 8, 10, 12] as const
export const DEFAULT_EXTEND_SECONDS = 4
export interface ExtendSubmitParams {
videoPath: string
duration: number
prompt: string
mode: ExtendDirection
resolution?: { width: number; height: number }
model: RetakeExtendModel
}
export interface ExtendResult {
videoPath: string
}
interface UseExtendState {
isExtending: boolean
canCancel: boolean
extendStatus: string
extendError: string | null
result: ExtendResult | null
}
export function useExtend() {
const { shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi } = useAppSettings()
const [state, setState] = useState<UseExtendState>({
isExtending: false,
canCancel: false,
extendStatus: '',
extendError: null,
result: null,
})
const submitExtend = useCallback(async (params: ExtendSubmitParams) => {
if (!params.videoPath) return
setState({
isExtending: true,
canCancel: canCancelLocalJob('video', shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi),
extendStatus: 'Generating',
extendError: null,
result: null,
})
await withGenerationActive(async () => {
let preparedVideoPath: string
try {
preparedVideoPath = (await prepareGenerationInput(params.videoPath)) ?? params.videoPath
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to prepare source video'
logger.error(`Extend input error: ${message}`)
setState({ isExtending: false, canCancel: false, extendStatus: '', extendError: message, result: null })
return
}
const result = await ApiClient.extend({
video_path: preparedVideoPath,
duration: params.duration,
prompt: params.prompt,
mode: params.mode,
resolution: params.resolution,
model: params.model,
})
if (!result.ok) {
logger.error(`Extend error: ${result.error.message}`)
setState({ isExtending: false, canCancel: false, extendStatus: '', extendError: result.error.message, result: null })
return
}
const payload = result.data
if (payload.status === 'cancelled') {
setState({ isExtending: false, canCancel: false, extendStatus: 'Cancelled', extendError: null, result: null })
return
}
if ('video_path' in payload) {
setState({
isExtending: false,
canCancel: false,
extendStatus: 'Extend complete!',
extendError: null,
result: { videoPath: payload.video_path },
})
return
}
// A 200 with a remote payload and no local file is a legitimate success (the backend
// completed the generation); there's just no local artifact to import. Don't surface
// it as an error.
logger.warn(`Extend completed with a remote payload and no local file: ${JSON.stringify(payload.result)}`)
setState({
isExtending: false,
canCancel: false,
extendStatus: 'Extend complete!',
extendError: null,
result: null,
})
})
}, [shouldImageGenerateWithFalApi, shouldVideoGenerateWithLtxApi])
const resetExtend = useCallback(() => {
setState({ isExtending: false, canCancel: false, extendStatus: '', extendError: null, result: null })
}, [])
return {
submitExtend,
resetExtend,
isExtending: state.isExtending,
canCancel: state.canCancel,
extendStatus: state.extendStatus,
extendError: state.extendError,
extendResult: state.result,
}
}
+58
View File
@@ -0,0 +1,58 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { fixedMenuPosition, type MenuPlacement } from '../lib/fixed-menu-position'
const HIDDEN_FIXED: CSSProperties = { position: 'fixed', zIndex: 9999, visibility: 'hidden' }
export function useFixedMenu(placement: MenuPlacement) {
const [isOpen, setIsOpen] = useState(false)
const triggerRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [style, setStyle] = useState<CSSProperties>(HIDDEN_FIXED)
const updatePosition = useCallback(() => {
const trigger = triggerRef.current?.getBoundingClientRect()
if (!trigger) return
const menuWidth = menuRef.current?.offsetWidth
const pos = fixedMenuPosition({
trigger,
placement,
viewport: { width: window.innerWidth, height: window.innerHeight },
menuWidth: menuWidth || undefined,
})
setStyle({
position: 'fixed',
zIndex: 9999,
visibility: 'visible',
left: pos.left,
...(pos.top != null ? { top: pos.top } : { bottom: pos.bottom }),
})
}, [placement])
useLayoutEffect(() => {
if (!isOpen) {
setStyle(HIDDEN_FIXED)
return
}
updatePosition()
window.addEventListener('resize', updatePosition)
window.addEventListener('scroll', updatePosition, true)
return () => {
window.removeEventListener('resize', updatePosition)
window.removeEventListener('scroll', updatePosition, true)
}
}, [isOpen, updatePosition])
useEffect(() => {
if (!isOpen) return
const onPointerDown = (event: MouseEvent) => {
const node = event.target as Node
if (triggerRef.current?.contains(node) || menuRef.current?.contains(node)) return
setIsOpen(false)
}
document.addEventListener('mousedown', onPointerDown)
return () => document.removeEventListener('mousedown', onPointerDown)
}, [isOpen])
return { isOpen, setIsOpen, triggerRef, menuRef, style }
}
@@ -0,0 +1,27 @@
import { useEffect, useRef } from 'react'
import { useProjects } from '../contexts/ProjectContext'
import { useAppSettings } from '../contexts/AppSettingsContext'
import { checkAndConsumeRecovery } from '../lib/generation-recovery'
import { subscribeWhileGenerationMayBeActive } from '../lib/generation-progress-poll'
// Always-mounted safety net for a generation that finishes while its own project's GenSpace
// isn't open (a different project, the Video Editor tab, or Home): periodically checks whether
// the pending recovery marker's generation has completed and, if so, persists it directly into
// that project's assets — independent of whichever view/project is currently on screen. Mount
// this once, near the app root (outside whatever view-switching renders GenSpace), so it survives
// navigation. GenSpace's own live polling/import effects are unchanged and take priority — see
// setActiveGenerationOwner in lib/generation-recovery.ts for how the two avoid double-importing.
export function useGenerationRecoveryWatcher(): void {
const { addAsset } = useProjects()
const { settings } = useAppSettings()
const modelsDirRef = useRef(settings.modelsDir)
modelsDirRef.current = settings.modelsDir
const isCheckingRef = useRef(false)
useEffect(() => subscribeWhileGenerationMayBeActive(progress => {
if (isCheckingRef.current) return
isCheckingRef.current = true
void checkAndConsumeRecovery(progress, { addAsset, modelsDir: modelsDirRef.current })
.finally(() => { isCheckingRef.current = false })
}), [addAsset])
}
+601
View File
@@ -0,0 +1,601 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import type { GenerationSettings } from '../components/SettingsPanel'
import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../lib/api-client'
import { createLocalGenerationError, type GenerationError } from '../lib/generation-errors'
import { canCancelLocalJob, withGenerationActive } from '../lib/generation-active'
import { useAppSettings } from '../contexts/AppSettingsContext'
import { buildGenerateVideoImageInputs } from '../lib/build-generate-video-body'
import type { GenSpaceMode } from '../lib/genspace-multi-keyframe'
import type { KeyframeItem, PersistedKeyframe } from '../lib/multi-keyframe'
import { prepareGenerationInput } from '../lib/generation-input'
const POLLING_INTERVAL_MS = 2000
export const GENERATION_RECOVERY_KEY = 'ltx-generation-recovery'
export interface GenerationRecoveryContext {
projectId: string
prompt: string
// Absent for ic-lora/retake: those recover as standalone video assets (Phase 1),
// so there are no video/image settings to restore.
settings?: GenerationSettings
// Retake/extend write this instead of a full `settings` blob — the recovery importer
// prefers it over `settings.model` (which defaults to 'fast' when absent).
model?: string
// Frozen at click. Local `fast` and API `fast` share an id; display_name must not be
// re-resolved from whichever offering is selected when the job later finishes.
modelLabel?: string
inputImageUrl?: string
inputLastImageUrl?: string
inputAudioUrl?: string
keyframes?: PersistedKeyframe[]
genType?: 'image' | 'enhance'
// Frozen at marker write (job start) — same rule as hook canCancel. Lets Stop survive a
// UI refresh before the first progress poll returns (local GPU can starve that poll).
// Absent on older markers: treat as not cancellable until the poll reports it.
canCancel?: boolean
// Whatever generation id the backend reported at the moment this marker was written — i.e.
// immediately BEFORE this generation started. The handler that starts a generation loads its
// pipeline (can take many seconds — worse for image models loading checkpoint shards) before
// it ever reports a new id, so a poll can otherwise be looking at a stale, unrelated id/result
// that predates this marker entirely. Once a later poll observes a DIFFERENT id, that's proof
// (single global generation slot) that this marker's own generation has started — see
// checkAndConsumeRecovery in lib/generation-recovery.ts.
baselineId: string | null
// Set once a poll observes an id different from baselineId — i.e. once this marker's own
// generation is confirmed to exist. Distinct from baselineId: a LATER id change past this point
// means a DIFFERENT generation superseded ours (not that ours just started), which must NOT be
// imported under this marker.
generationId?: string
}
export function readRecoveryMarkerCanCancel(): boolean {
const saved = localStorage.getItem(GENERATION_RECOVERY_KEY)
if (!saved) return false
try {
return (JSON.parse(saved) as GenerationRecoveryContext).canCancel === true
} catch {
return false
}
}
interface GenerationState {
isGenerating: boolean
isCancelling: boolean
/** Frozen at job start — Stop stays hidden if this POST was an LTX/FAL cloud job. */
canCancel: boolean
progress: number
statusMessage: string
videoPath: string | null
imagePath: string | null
imagePaths: string[]
error: GenerationError | null
}
type GenerateVideoRequest = ApiRequestBodyOf<'generateVideo'>
type GenerateImageRequest = ApiRequestBodyOf<'generateImage'>
interface UseGenerationReturn extends GenerationState {
generate: (
prompt: string,
imagePath: string | null,
settings: GenerationSettings,
audioPath?: string | null,
lastImagePath?: string | null,
imageInputs?: { mode: GenSpaceMode; keyframes: KeyframeItem[] },
) => Promise<void>
generateImage: (prompt: string, settings: GenerationSettings, editSource?: string | null) => Promise<void>
cancel: () => void
reset: () => void
resumeIfRunning: () => Promise<'running' | 'complete' | 'none'>
}
const IMAGE_SHORT_SIDE_BY_RESOLUTION: Record<string, number> = {
'1080p': 1080,
'1440p': 1440,
'2048p': 2048,
}
const IMAGE_ASPECT_RATIO_VALUE: Record<string, number> = {
'1:1': 1,
'16:9': 16 / 9,
'9:16': 9 / 16,
'4:3': 4 / 3,
'3:4': 3 / 4,
'21:9': 21 / 9,
}
function getImageDimensions(settings: GenerationSettings): { width: number; height: number } {
const shortSide = IMAGE_SHORT_SIDE_BY_RESOLUTION[settings.imageResolution]
if (!shortSide) {
throw new Error(`Unsupported image resolution mapping: ${settings.imageResolution}`)
}
const ratio = IMAGE_ASPECT_RATIO_VALUE[settings.imageAspectRatio]
if (!ratio) {
throw new Error(`Unsupported image aspect ratio mapping: ${settings.imageAspectRatio}`)
}
if (ratio >= 1) {
return { width: Math.round(shortSide * ratio), height: shortSide }
}
return { width: shortSide, height: Math.round(shortSide / ratio) }
}
// Map phase to user-friendly message
function getPhaseMessage(phase: string): string {
switch (phase) {
case 'validating_request':
return 'Validating request...'
case 'uploading_image':
return 'Uploading image...'
case 'uploading_audio':
return 'Uploading audio...'
case 'loading_model':
return 'Loading model...'
case 'encoding_text':
return 'Encoding prompt...'
case 'inference':
return 'Generating...'
case 'downloading_output':
return 'Downloading output...'
case 'decoding':
return 'Decoding video...'
case 'complete':
return 'Complete!'
case 'cancelled':
return 'Cancelling…'
default:
return 'Generating...'
}
}
export function useGeneration(): UseGenerationReturn {
const { settings: appSettings, shouldImageGenerateWithFalApi, shouldVideoGenerateWithLtxApi, refreshSettings } = useAppSettings()
const [state, setState] = useState<GenerationState>({
isGenerating: false,
isCancelling: false,
canCancel: false,
progress: 0,
statusMessage: '',
videoPath: null,
imagePath: null,
imagePaths: [],
error: null,
})
const recoveryIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const clearRecoveryPolling = () => {
if (recoveryIntervalRef.current) {
clearInterval(recoveryIntervalRef.current)
recoveryIntervalRef.current = null
}
}
useEffect(() => clearRecoveryPolling, [])
// Re-attach to a generation that was running OR finished while the frontend was
// unmounted. Polls the backend progress endpoint; localStorage recovery context
// (inputs, settings incl. loras) is owned by the caller (GenSpace). Returns the
// recovered status so the caller can restore context for 'running' AND 'complete'
// (a generation that finished during the unmount window still needs its metadata).
const resumeIfRunning = useCallback(async (): Promise<'running' | 'complete' | 'none'> => {
const apply = (data: ApiSuccessOf<'getGenerationProgress'>): 'running' | 'complete' | 'other' => {
if (data.status === 'complete' && data.result != null) {
const vp = typeof data.result === 'string' ? data.result : null
const ips = Array.isArray(data.result) ? data.result : []
setState({
isGenerating: false, isCancelling: false, canCancel: false, progress: 100, statusMessage: 'Complete!',
videoPath: vp, imagePath: ips[0] ?? null, imagePaths: ips, error: null,
})
return 'complete'
}
if (data.status === 'running') {
setState(prev => ({
...prev,
isGenerating: true,
isCancelling: data.phase === 'cancelled',
canCancel: data.cancellable,
progress: data.progress,
statusMessage: getPhaseMessage(data.phase),
}))
return 'running'
}
setState(prev => ({ ...prev, isGenerating: false, isCancelling: false, canCancel: false, statusMessage: '' }))
return 'other'
}
const initial = await ApiClient.getGenerationProgress()
if (!initial.ok) return 'none'
const status = apply(initial.data)
if (status === 'complete') return 'complete'
if (status !== 'running') return 'none'
clearRecoveryPolling()
recoveryIntervalRef.current = setInterval(async () => {
const r = await ApiClient.getGenerationProgress()
if (!r.ok) return
if (apply(r.data) !== 'running') clearRecoveryPolling()
}, POLLING_INTERVAL_MS)
return 'running'
}, [])
const generate = useCallback(async (
prompt: string,
imagePath: string | null,
settings: GenerationSettings,
audioPath?: string | null,
lastImagePath?: string | null,
imageInputs?: { mode: GenSpaceMode; keyframes: KeyframeItem[] },
) => {
const statusMsg = settings.model.startsWith('pro')
? 'Loading Pro model & generating...'
: 'Generating video...'
setState({
isGenerating: true,
isCancelling: false,
canCancel: canCancelLocalJob('video', shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi),
progress: 0,
statusMessage: statusMsg,
videoPath: null,
imagePath: null,
imagePaths: [],
error: null,
})
let progressInterval: ReturnType<typeof setInterval> | null = null
let shouldApplyPollingUpdates = true
await withGenerationActive(async () => {
try {
const [preparedImagePath, preparedLastImagePath, preparedAudioPath] = await Promise.all([
prepareGenerationInput(imagePath),
prepareGenerationInput(lastImagePath),
prepareGenerationInput(audioPath),
])
const preparedKeyframes = await Promise.all(
(imageInputs?.keyframes ?? []).map(async keyframe => ({
...keyframe,
path: (await prepareGenerationInput(keyframe.path)) ?? keyframe.path,
})),
)
// Prepare JSON body
const body: Record<string, unknown> = {
prompt,
model: settings.model,
duration: settings.duration,
resolution: settings.videoResolution,
fps: settings.fps,
audio: settings.audio,
cameraMotion: settings.cameraMotion,
negativePrompt: (settings as { negativePrompt?: string }).negativePrompt ?? '',
aspectRatio: settings.aspectRatio || '16:9',
...buildGenerateVideoImageInputs({
mode: imageInputs?.mode ?? 'video',
imagePath: preparedImagePath,
lastImagePath: preparedLastImagePath,
keyframes: preparedKeyframes,
}),
}
if (preparedAudioPath) {
body.audioPath = preparedAudioPath
}
if (settings.loras?.length) {
body.loras = settings.loras.map(l => ({ ref: l.ref, scale: l.scale }))
}
// Poll for real progress from backend with time-based interpolation
let lastPhase = ''
let inferenceStartTime = 0
// Estimated inference time in seconds based on model
const estimatedInferenceTime = settings.model.startsWith('pro') ? 120 : 45
const pollProgress = async () => {
if (!shouldApplyPollingUpdates) return
const result = await ApiClient.getGenerationProgress()
if (!result.ok || !shouldApplyPollingUpdates) return
const data = result.data
let displayProgress = data.progress
let statusMessage = getPhaseMessage(data.phase)
// Time-based interpolation during inference phase
if (data.phase === 'inference') {
if (lastPhase !== 'inference') {
inferenceStartTime = Date.now()
}
const elapsed = (Date.now() - inferenceStartTime) / 1000
// Interpolate from 15% to 95% based on estimated time
const inferenceProgress = Math.min(elapsed / estimatedInferenceTime, 0.95)
displayProgress = 15 + Math.floor(inferenceProgress * 80)
}
// Keep API/local completion as a terminal response state, not polling state.
// Polling complete means backend state is finalized, but request can still be in-flight.
if (data.phase === 'complete' || data.status === 'complete') {
displayProgress = 95
statusMessage = 'Finalizing...'
}
lastPhase = data.phase
setState(prev => {
if (prev.isCancelling) {
return { ...prev, statusMessage: 'Cancelling…' }
}
return {
...prev,
progress: displayProgress,
statusMessage,
}
})
}
progressInterval = setInterval(pollProgress, 500)
// Start generation (HTTP POST - synchronous, returns when done)
// Do not abort this POST: liveness suppression stays up until the
// backend returns {status: "cancelled"} and the GPU job unwinds.
const result = await ApiClient.generateVideo(body as unknown as GenerateVideoRequest)
shouldApplyPollingUpdates = false
if (!result.ok) {
setState(prev => ({
...prev,
isGenerating: false,
isCancelling: false,
canCancel: false,
error: result,
}))
return
}
const payload = result.data
if (payload.status === 'complete') {
setState({
isGenerating: false,
isCancelling: false,
canCancel: false,
progress: 100,
statusMessage: 'Complete!',
videoPath: payload.video_path,
imagePath: null,
imagePaths: [],
error: null,
})
} else if (payload.status === 'cancelled') {
setState(prev => ({
...prev,
isGenerating: false,
isCancelling: false,
canCancel: false,
statusMessage: 'Cancelled',
}))
} else {
throw new Error('Unexpected response from /api/generate')
}
} catch (error) {
setState(prev => ({
...prev,
isGenerating: false,
isCancelling: false,
canCancel: false,
error: createLocalGenerationError(error instanceof Error ? error.message : 'Unknown error'),
}))
} finally {
shouldApplyPollingUpdates = false
if (progressInterval) {
clearInterval(progressInterval)
}
}
})
}, [shouldImageGenerateWithFalApi, shouldVideoGenerateWithLtxApi])
const cancel = useCallback(() => {
let claimedCancelling = false
setState(prev => {
if (!prev.isGenerating || prev.isCancelling) return prev
claimedCancelling = true
return {
...prev,
isCancelling: true,
statusMessage: 'Cancelling…',
}
})
// Always POST — retake/extend/IC-LoRA Stop reuse this while this hook is idle.
void (async () => {
const result = await ApiClient.cancelGeneration()
const accepted = result.ok && result.data.status === 'cancelling'
if (accepted || !claimedCancelling) return
setState(prev => {
if (!prev.isCancelling) return prev
return { ...prev, isCancelling: false }
})
})()
}, [])
const generateImage = useCallback(async (
prompt: string,
settings: GenerationSettings,
editSource?: string | null,
) => {
const isEditing = !!editSource
const openFalConnectDialog = () => {
window.dispatchEvent(new CustomEvent('open-api-gateway', {
detail: {
requiredKeys: ['fal'],
title: 'Connect FAL AI',
description: `FAL AI is required for ${isEditing ? 'editing' : 'generating'} images with Z Image Turbo when API generations are enabled.`,
blocking: false,
},
}))
}
if (shouldImageGenerateWithFalApi) {
const settingsResult = await ApiClient.getSettings()
const hasFalApiKey = settingsResult.ok ? settingsResult.data.hasFalApiKey : appSettings.hasFalApiKey
if (!hasFalApiKey) {
if (settingsResult.ok) void refreshSettings()
openFalConnectDialog()
return
}
}
const numImages = settings.variations || 1
setState({
isGenerating: true,
isCancelling: false,
canCancel: canCancelLocalJob('image', shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi),
progress: 0,
statusMessage: isEditing
? 'Editing image...'
: numImages > 1 ? `Generating ${numImages} images...` : 'Generating image...',
videoPath: null,
imagePath: null,
imagePaths: [],
error: null,
})
await withGenerationActive(async () => {
let progressInterval: ReturnType<typeof setInterval> | null = null
try {
const preparedEditSource = await prepareGenerationInput(editSource)
// Skip prompt enhancement for T2I - use original prompt directly
const finalPrompt = prompt
// Edit runs at the source image's resolution; width/height are ignored server-side.
const dims = isEditing ? { width: 1024, height: 1024 } : getImageDimensions(settings)
const numSteps = settings.imageSteps || (isEditing ? 8 : 4)
// Poll for progress
const pollProgress = async () => {
const result = await ApiClient.getGenerationProgress()
if (!result.ok) return
const data = result.data
const currentImage = data.currentStep || 0
const totalImages = data.totalSteps || numImages
setState(prev => {
if (prev.isCancelling) {
return { ...prev, statusMessage: 'Cancelling…' }
}
return {
...prev,
progress: data.progress,
statusMessage: data.phase === 'loading_model'
? 'Loading Z-Image Turbo model...'
: data.phase === 'inference'
? isEditing
? 'Editing image...'
: numImages > 1
? `Generating image ${currentImage + 1}/${totalImages}...`
: 'Generating image...'
: data.phase === 'complete'
? 'Complete!'
: 'Generating...',
}
})
}
progressInterval = setInterval(pollProgress, 500)
const imageRequest: GenerateImageRequest = {
prompt: finalPrompt,
width: dims.width,
height: dims.height,
numSteps,
numImages,
// strength is ignored server-side unless imagePath is set, but the request type
// requires it — send the default rather than the edit-only setting when not editing.
strength: isEditing ? (settings.imageEditStrength ?? 0.6) : 0.6,
...(isEditing && preparedEditSource ? { imagePath: preparedEditSource } : {}),
}
const result = await ApiClient.generateImage(imageRequest)
if (!result.ok) {
setState(prev => ({
...prev,
isGenerating: false,
isCancelling: false,
canCancel: false,
error: result,
}))
return
}
const payload = result.data
if (payload.status === 'complete') {
const rawPaths = payload.image_paths
if (rawPaths.length === 0) {
throw new Error('Image generation completed without output images')
}
setState({
isGenerating: false,
isCancelling: false,
canCancel: false,
progress: 100,
statusMessage: 'Complete!',
videoPath: null,
imagePath: rawPaths[0],
imagePaths: rawPaths,
error: null,
})
} else if (payload.status === 'cancelled') {
setState(prev => ({
...prev,
isGenerating: false,
isCancelling: false,
canCancel: false,
statusMessage: 'Cancelled',
}))
} else {
throw new Error('Unexpected response from /api/generate-image')
}
} catch (error) {
setState(prev => ({
...prev,
isGenerating: false,
isCancelling: false,
canCancel: false,
error: createLocalGenerationError(error instanceof Error ? error.message : 'Unknown error'),
}))
} finally {
if (progressInterval) {
clearInterval(progressInterval)
}
}
})
}, [appSettings.hasFalApiKey, shouldImageGenerateWithFalApi, shouldVideoGenerateWithLtxApi, refreshSettings])
const reset = useCallback(() => {
clearRecoveryPolling()
localStorage.removeItem(GENERATION_RECOVERY_KEY)
setState({
isGenerating: false,
isCancelling: false,
canCancel: false,
progress: 0,
statusMessage: '',
videoPath: null,
imagePath: null,
imagePaths: [],
error: null,
})
}, [])
return {
...state,
generate,
generateImage,
cancel,
reset,
resumeIfRunning,
}
}
@@ -0,0 +1,62 @@
import { useEffect, useState } from 'react'
import { subscribeWhileGenerationMayBeActive } from '../lib/generation-progress-poll'
import { GENERATION_RECOVERY_KEY, readRecoveryMarkerCanCancel } from './use-generation'
export interface GlobalGenerationLock {
// Fail-closed Generate disable: a recovery marker, a running poll, or an unconfirmed
// poll error all mean the single global slot must be treated as busy.
isRunning: boolean
// Same poll as isRunning, gated on the backend's frozen local-GPU vs API slot.
// Never derived from live Settings — switching to local mid-API-job must not reveal Stop.
canCancel: boolean
// Slot still busy after Stop (`status=running`, `phase=cancelled`) until generate() unwinds.
isCancelling: boolean
}
// Only one generation can run at a time across the whole app (single global backend slot), but
// each project's GenSpace only tracks its OWN local isGenerating-style state — it has no idea a
// different project (or the same one, reconnected via a stale click) is already occupying that
// slot. Without this, Generate stays clickable in project B while project A is mid-generation;
// the request 409s, but only after writeRecoveryContext already overwrote A's in-flight recovery
// marker with B's (now-failed) one. Polling here lets Generate disable proactively instead.
// Stop uses the same sources: the recovery marker (immediate, frozen at job start) and
// GET /generation/progress.cancellable (authoritative once a poll lands). Hook-local canCancel
// dies on UI refresh; these do not. Live Settings must not flip Stop mid-job.
// No marker anywhere means nothing CAN be running (see subscribeWhileGenerationMayBeActive), so
// idle starts unlocked and costs no network call; once a marker exists and we're actually
// polling, an unconfirmed/failed poll is treated as locked rather than silently trusting "not
// running" — that unconfirmed-failure gap is exactly what previously let Generate stay clickable
// during another project's generation. The initial state has to check the marker too, not just
// hardcode false: a page refresh resets this hook's React state from scratch while another
// project's marker (and its still-running backend generation) survives in localStorage, and the
// first poll takes a network round trip to resolve — that gap is otherwise the same unconfirmed
// window all over again, just re-opened on every reload instead of only at first app launch.
export function useGlobalGenerationLock(): GlobalGenerationLock {
const [lock, setLock] = useState<GlobalGenerationLock>(() => ({
isRunning: localStorage.getItem(GENERATION_RECOVERY_KEY) != null,
canCancel: readRecoveryMarkerCanCancel(),
isCancelling: false,
}))
useEffect(() => subscribeWhileGenerationMayBeActive(result => {
if (!result.ok) {
// Same fail-closed Generate lock as before. Stop keeps the frozen marker bit rather
// than guessing — a starved poll during local GPU work must not hide Stop, and a
// failed poll during an API job must not reveal it.
setLock({
isRunning: true,
canCancel: readRecoveryMarkerCanCancel(),
isCancelling: false,
})
return
}
const isRunning = result.data.status === 'running'
setLock({
isRunning,
canCancel: isRunning && result.data.cancellable,
isCancelling: isRunning && result.data.phase === 'cancelled',
})
}), [])
return lock
}
+94
View File
@@ -0,0 +1,94 @@
import { useState, useEffect, useCallback } from 'react'
import { ApiClient, type ApiSuccessOf } from '../lib/api-client'
import { logger } from '../lib/logger'
type HfAuthStatus = ApiSuccessOf<'getHuggingFaceAuthStatus'>['status']
interface UseHfAuthResult {
hfAuthStatus: HfAuthStatus
hfAuthPolling: boolean
startHuggingFaceLogin: () => Promise<void>
handleHuggingFaceLogout: () => Promise<void>
}
const NOOP = async () => {}
export function useHfAuth(enabled: boolean): UseHfAuthResult {
// Used for gated downloads (LTX 2.5 base models, catalog LoRAs / IC-LoRAs).
const [hfAuthStatus, setHfAuthStatus] = useState<HfAuthStatus>('not_authenticated')
const [hfAuthPolling, setHfAuthPolling] = useState(false)
// One-time check when enabled becomes true
useEffect(() => {
if (!enabled) return
const checkAuth = async () => {
const result = await ApiClient.getHuggingFaceAuthStatus()
if (!result.ok) {
logger.error(`HF auth status check failed: ${result.error.message}`)
return
}
setHfAuthStatus(result.data.status)
}
void checkAuth()
}, [enabled])
// Poll while waiting for user to complete auth in browser
useEffect(() => {
if (!hfAuthPolling) return
const interval = setInterval(async () => {
const result = await ApiClient.getHuggingFaceAuthStatus()
if (!result.ok) {
logger.error(`HF auth status check failed: ${result.error.message}`)
return
}
const { status } = result.data
setHfAuthStatus(status)
if (status === 'authenticated') setHfAuthPolling(false)
}, 2000)
return () => clearInterval(interval)
}, [hfAuthPolling])
const startHuggingFaceLogin = useCallback(async () => {
const result = await ApiClient.startHuggingFaceLogin()
if (!result.ok) {
logger.error(`HF login failed: ${result.error.message}`)
return
}
const params = result.data
setHfAuthPolling(true)
await window.electronAPI.openHuggingFaceAuth({
clientId: params.client_id,
redirectUri: params.redirect_uri,
scope: params.scope,
state: params.state,
codeChallenge: params.code_challenge,
codeChallengeMethod: params.code_challenge_method,
})
}, [])
const handleHuggingFaceLogout = useCallback(async () => {
const result = await ApiClient.huggingFaceLogout()
if (!result.ok) {
logger.error(`HF logout failed: ${result.error.message}`)
return
}
setHfAuthStatus('not_authenticated')
}, [])
if (!enabled) {
return {
hfAuthStatus: 'authenticated',
hfAuthPolling: false,
startHuggingFaceLogin: NOOP,
handleHuggingFaceLogout: NOOP,
}
}
return {
hfAuthStatus,
hfAuthPolling,
startHuggingFaceLogin,
handleHuggingFaceLogout,
}
}
+76
View File
@@ -0,0 +1,76 @@
import { useState, useEffect, useCallback } from 'react'
import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../lib/api-client'
import { logger } from '../lib/logger'
type HfAuthStatus = ApiSuccessOf<'getHuggingFaceAuthStatus'>['status']
type ModelAccessMap = ApiSuccessOf<'checkModelAccess'>['access']
type CheckModelAccessBody = NonNullable<ApiRequestBodyOf<'checkModelAccess'>>
type ModelCheckpointID = NonNullable<CheckModelAccessBody['cp_ids']>[number]
interface UseHfModelAccessResult {
accessMap: ModelAccessMap
allAuthorized: boolean
checking: boolean
/** Set when the access check itself failed — distinct from "not authorized". */
checkError: string | null
recheckAccess: () => void
}
export function useHfModelAccess(modelTypes: readonly ModelCheckpointID[], hfAuthStatus: HfAuthStatus): UseHfModelAccessResult {
const [accessMap, setAccessMap] = useState<ModelAccessMap>({})
const [checking, setChecking] = useState(false)
const [polling, setPolling] = useState(false)
const [checkError, setCheckError] = useState<string | null>(null)
// Nothing to authorize, or every returned repo is authorized. Empty map with pending
// checkpoints is NOT authorized — that covers both "still checking" and a failed check
// (which leaves the map empty and sets checkError for the gate to show).
const allAuthorized = modelTypes.length === 0
|| (Object.keys(accessMap).length > 0
&& Object.values(accessMap).every((status) => status === 'authorized'))
const doCheck = useCallback(async () => {
if (modelTypes.length === 0) return
setChecking(true)
const result = await ApiClient.checkModelAccess({ cp_ids: [...modelTypes] })
if (!result.ok) {
logger.error(`Model access check failed: ${result.error.message}`)
setAccessMap({})
setCheckError(result.error.message)
setChecking(false)
return
}
const { access } = result.data
setAccessMap(access)
setCheckError(null)
const allOk = Object.values(access).every((s) => s === 'authorized')
if (allOk) setPolling(false)
setChecking(false)
}, [modelTypes])
// Signed out still needs a check: gated repos (LTX 2.5) can't be downloaded without a token.
useEffect(() => {
if (modelTypes.length === 0) {
setAccessMap((current) => (Object.keys(current).length === 0 ? current : {}))
setCheckError(null)
setPolling(false)
return
}
void doCheck()
setPolling(true)
}, [hfAuthStatus, modelTypes.length, doCheck])
// Poll while any model is not_authorized
useEffect(() => {
if (!polling || hfAuthStatus !== 'authenticated') return
const interval = setInterval(() => { void doCheck() }, 5000)
return () => clearInterval(interval)
}, [polling, hfAuthStatus, doCheck])
const recheckAccess = useCallback(() => {
void doCheck()
}, [doCheck])
return { accessMap, allAuthorized, checking, checkError, recheckAccess }
}
+78
View File
@@ -0,0 +1,78 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ApiSuccessOf } from '../lib/api-client'
import { useIcLoras, type IcLoraListItem } from './use-catalog'
import { catalogItemToEntry, preferredVariantId, resolveDownloadedVariantIds } from '../lib/lora-library'
// Owns the IC-LoRA library wiring: the catalog hook (fetch + download), the modal open state,
// the selected recipe id (+ optional variant), and the catalog→entry mapping for the modal.
// Selection *side effects* (seeding the generation controls/settings from the picked recipe)
// stay with the caller via onSelect — they touch GenSpace gen state.
export function useIcLoraLibrary(
enabled: boolean,
onSelect: (item: IcLoraListItem | null) => void,
installed: ApiSuccessOf<'listModels'>['models'] = [],
refreshInstalled: () => void = () => {},
) {
const { icLoras, downloadIcLora, downloadingKey, progress, downloadError } = useIcLoras(enabled)
const [selectedIcLoraId, setSelectedIcLoraId] = useState<string | null>(null)
const [selectedIcLoraVariantId, setSelectedIcLoraVariantId] = useState<string | null>(null)
const [modalOpen, setModalOpen] = useState(false)
const installedPaths = useMemo(() => installed.map(m => m.path), [installed])
const items = useMemo(
() => icLoras.map(r => {
const base = catalogItemToEntry(r.ic_lora)
const downloadedVariantIds = resolveDownloadedVariantIds(
base.variants,
r.downloaded_variant_ids,
installedPaths,
)
return {
...base,
downloaded: r.downloaded || downloadedVariantIds.length > 0,
downloadedVariantIds,
}
}),
[icLoras, installedPaths],
)
// Re-list on-disk IC-LoRAs once a download finishes so per-variant ✓ / Use stay accurate
// even if the catalog list payload lags or omits downloaded_variant_ids.
const prevDownloadingKey = useRef<string | null>(null)
useEffect(() => {
if (prevDownloadingKey.current && !downloadingKey) refreshInstalled()
prevDownloadingKey.current = downloadingKey
}, [downloadingKey, refreshInstalled])
const selectIcLora = useCallback(
(item: IcLoraListItem | null, variantId?: string | null) => {
setSelectedIcLoraId(item?.ic_lora.id ?? null)
if (!item) {
setSelectedIcLoraVariantId(null)
} else {
const entry = items.find(e => e.id === item.ic_lora.id)
const preferred = preferredVariantId(
entry?.variants,
entry?.defaultVariantId,
entry?.downloadedVariantIds,
)
// Explicit variant wins; otherwise only an *installed* preferred id (never undownloaded default).
setSelectedIcLoraVariantId(variantId ?? preferred ?? null)
}
onSelect(item)
},
[onSelect, items],
)
return {
icLoras,
items,
downloadIcLora,
downloadingKey,
progress,
downloadError,
modalOpen,
setModalOpen,
selectedIcLoraId,
selectedIcLoraVariantId,
selectIcLora,
}
}
+173
View File
@@ -0,0 +1,173 @@
import { useCallback, useState } from 'react'
import { ApiClient, type ApiRequestBodyOf } from '../lib/api-client'
import { withGenerationActive } from '../lib/generation-active'
import { logger } from '../lib/logger'
export type IcLoraConditioningType = 'canny' | 'depth' | 'custom'
export type IcLoraAudioMode = 'source' | 'generated' | 'off'
export interface IcLoraSubmitParams {
videoPath: string
conditioningType: IcLoraConditioningType
conditioningStrength: number
prompt: string
// "custom": the user's own IC-LoRA weights + a pre-rendered control video.
customLoraRef?: string
controlVideoPath?: string
// Skip Stage 2 refine — transformation IC-LoRAs need this; default off.
skipStage2?: boolean
// Keep the IC-LoRA active during Stage 2 refine (only when Stage 2 runs). Default off.
// On: the transformation survives refinement and the output lands at the chosen resolution.
useLoraInStage2?: boolean
// Target output resolution for the useLoraInStage2 path (undefined = source). Backend
// snaps it down to a valid size and never upscales above source.
resolution?: { width: number; height: number }
// Stage-1-only canvas multiplier: 2.0 = native, 1.0 = half. Only used when skipStage2.
resolutionFactor?: number
// Soundtrack: source (input clip) / generated (prompt) / off.
audioMode?: IcLoraAudioMode
// LoRA adapter merge weight (load-time). Default 1.0.
loraStrength?: number
// Override control-video fps (decimate to fewer frames). undefined = source fps.
fpsOverride?: number
// IC-LoRA mode: catalog IC-LoRA id + the user's input media path + output duration.
// When icLoraId is set, the backend resolves catalog weights and builds the
// control video via the IC-LoRA's preprocessing pipeline.
icLoraId?: string
// Catalog download.variants[].id when the entry has multiple checkpoints.
variantId?: string
inputPath?: string
// Values for the IC-LoRA's declared controls, keyed by control id (e.g. duration).
// Sent verbatim; the backend validates + maps them to behaviour.
controlValues?: Record<string, number | string>
// Outpainting (position_canvas control): per-edge pixels to add around the source.
outpaintPads?: { left: number; right: number; top: number; bottom: number }
// When true, an empty prompt is allowed (set from the IC-LoRA's allows_empty_prompt).
allowEmptyPrompt?: boolean
// Optional reference image for catalog IC-LoRAs that opt in (allows_reference_image).
// Sent as image conditioning at frame 0, strength 1.0.
referenceImagePath?: string
}
export interface IcLoraResult {
videoPath: string
}
interface UseIcLoraState {
isGenerating: boolean
canCancel: boolean
status: string
error: string | null
result: IcLoraResult | null
}
type GenerateIcLoraBody = ApiRequestBodyOf<'generateIcLora'>
export function useIcLora() {
const [state, setState] = useState<UseIcLoraState>({
isGenerating: false,
canCancel: false,
status: '',
error: null,
result: null,
})
const submitIcLora = useCallback(async (params: IcLoraSubmitParams) => {
if (!params.prompt.trim() && !params.allowEmptyPrompt) return
// IC-LoRA mode needs inputPath; canny/depth/custom need videoPath.
if (!params.icLoraId && !params.videoPath) return
if (params.icLoraId && !params.inputPath) return
setState({
isGenerating: true,
// IC-LoRA is always local GPU (the tab is hidden when forceApiGenerations).
// Do not gate on shouldVideoGenerateWithLtxApi — that flag is for t2v/i2v.
canCancel: true,
status: 'Generating',
error: null,
result: null,
})
await withGenerationActive(async () => {
const result = await ApiClient.generateIcLora({
video_path: params.videoPath,
conditioning_type: params.conditioningType,
conditioning_strength: params.conditioningStrength,
prompt: params.prompt,
custom_lora_ref: params.customLoraRef,
control_video_path: params.controlVideoPath,
skip_stage_2: params.skipStage2,
use_lora_in_stage_2: params.useLoraInStage2,
resolution: params.resolution,
resolution_factor: params.resolutionFactor,
audio_mode: params.audioMode,
lora_strength: params.loraStrength,
fps_override: params.fpsOverride,
ic_lora_id: params.icLoraId,
variant_id: params.variantId,
input_path: params.inputPath,
control_values: params.controlValues,
outpaint_pads: params.outpaintPads,
images: params.referenceImagePath
? [{ path: params.referenceImagePath, frame: 0, strength: 1.0 }]
: [],
} as GenerateIcLoraBody)
if (!result.ok) {
logger.error(`IC-LoRA error: ${result.error.message}`)
setState({
isGenerating: false,
canCancel: false,
status: '',
error: result.error.message,
result: null,
})
return
}
const payload = result.data
if (payload.status === 'cancelled') {
setState({
isGenerating: false,
canCancel: false,
status: 'Cancelled',
error: null,
result: null,
})
return
}
if (payload.status === 'complete') {
setState({
isGenerating: false,
canCancel: false,
status: 'Generation complete!',
error: null,
result: {
videoPath: payload.video_path,
},
})
return
}
})
}, [])
const reset = useCallback(() => {
setState({
isGenerating: false,
canCancel: false,
status: '',
error: null,
result: null,
})
}, [])
return {
submitIcLora,
resetIcLora: reset,
isIcLoraGenerating: state.isGenerating,
canCancel: state.canCancel,
icLoraStatus: state.status,
icLoraError: state.error,
icLoraResult: state.result,
}
}
+66
View File
@@ -0,0 +1,66 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ApiSuccessOf } from '../lib/api-client'
import type { LoraSelection } from '../components/SettingsPanel'
import { useLoraCatalog } from './use-catalog'
import {
mergeLoraLibrary,
resolveInstalledPath,
variantDisplayName,
type LibraryEntry,
} from '../lib/lora-library'
// Owns the plain-LoRA library wiring: the catalog hook, the catalog ∪ on-disk merge, the
// modal open state, "use this LoRA" → selectedLoras, and re-listing installed files when a
// download finishes. Kept out of GenSpace so the view just consumes the result.
export function useLoraLibrary(
enabled: boolean,
installed: ApiSuccessOf<'listModels'>['models'],
selected: LoraSelection[],
onSelectedChange: (loras: LoraSelection[]) => void,
refreshInstalled: () => void,
) {
const { loras, downloadLora, downloadingKey, progress, downloadError } = useLoraCatalog(enabled)
const [modalOpen, setModalOpen] = useState(false)
const [useError, setUseError] = useState<string | null>(null)
const items = useMemo(() => mergeLoraLibrary(loras, installed), [loras, installed])
// Re-list on-disk files once a download finishes so the new file appears + is usable.
const prevDownloadingKey = useRef<string | null>(null)
useEffect(() => {
if (prevDownloadingKey.current && !downloadingKey) refreshInstalled()
prevDownloadingKey.current = downloadingKey
}, [downloadingKey, refreshInstalled])
// Returns whether the LoRA was actually added, so the modal knows whether it's safe to
// close. resolveInstalledPath never substitutes a different variant's path — if the catalog
// says this variant is downloaded but its path hasn't landed yet (listModels refresh race),
// re-trigger the refresh and surface a retry message instead of silently using the wrong file.
const useEntry = useCallback((e: LibraryEntry, variantId?: string) => {
const path = resolveInstalledPath(e, variantId)
if (!path) {
if (variantId && e.downloadedVariantIds?.includes(variantId)) {
refreshInstalled()
setUseError('Still syncing installed files — try again in a moment.')
}
return false
}
setUseError(null)
if (selected.some(s => s.ref === path)) return true
const variantLabel = variantId ? e.variants?.find(v => v.id === variantId)?.label : undefined
onSelectedChange([
...selected,
{
ref: path,
name: variantDisplayName(e.name, variantLabel, e.variants?.length),
scale: e.recommendedStrength ?? 1.0,
catalogId: e.id,
},
])
return true
}, [selected, onSelectedChange, refreshInstalled])
return {
items, downloadLora, downloadingKey, progress, downloadError,
modalOpen, setModalOpen, useEntry, useError,
}
}
@@ -0,0 +1,82 @@
import { useEffect, useState } from 'react'
import { ApiClient } from '../lib/api-client'
import { useAppSettings } from '../contexts/AppSettingsContext'
export type EnhanceProvider = 'local' | 'api'
interface UsePromptEnhancerProviderResult {
// Local requires the Gemma text-encoder checkpoint to be downloaded AND local generation to
// actually be usable this run (e.g. not memory-constrained into API-only mode); API requires a
// stored Gemini key to actually run, but the option stays selectable without one so Enhance
// can send the user to Settings instead of hiding the choice.
hasLocalTextEncoder: boolean
hasGeminiApiKey: boolean
// The provider Enhance will actually use: the persisted preference when it's currently
// choosable. API remains choosable without a key (clicking Enhance then opens Settings).
// Local that's temporarily unavailable (e.g. memory-constrained run) falls back silently —
// it does NOT overwrite the persisted preference, which only an explicit setProviderPreference
// call changes.
provider: EnhanceProvider
// Shown when local Enhance is available, so the user can still pick API (Gemini) before
// they've added a key. Hidden when local isn't an option — the button is already API-only.
canToggleProvider: boolean
setProviderPreference: (provider: EnhanceProvider) => void
}
// Single source of truth for which prompt-enhancer provider (local Gemma text encoder vs.
// Gemini's hosted API) is available and which one Enhance should use. `enabled` gates the local
// checkpoint lookup so it only fires once the enhancer could plausibly be shown for the current
// mode.
export function usePromptEnhancerProvider(enabled: boolean): UsePromptEnhancerProviderResult {
const {
settings: { hasGeminiApiKey, promptEnhancerProviderPreference },
updateSettings,
forceApiGenerations,
modelsVersion,
} = useAppSettings()
const [isLocalEncoderUsable, setIsLocalEncoderUsable] = useState(false)
useEffect(() => {
if (!enabled) return
let cancelled = false
void ApiClient.getTextEncoderRecommendation().then((result) => {
// Deliberately not cp_to_download: the encoder that runs generations isn't always the one
// that can enhance (LTX 2.5's encodes only, and enhances from a separate checkpoint), so
// the backend reports enhancer availability on its own.
if (!cancelled) {
setIsLocalEncoderUsable(result.ok && result.data.local_enhancement_supported)
}
})
return () => { cancelled = true }
// modelsVersion: the enhancer is a download the user can make mid-session, and Enhance should
// become available without a restart.
}, [enabled, modelsVersion])
// Downloaded isn't enough on its own — forceApiGenerations is the pure "insufficient memory
// for local models this run" signal (deliberately NOT shouldVideoGenerateWithLtxApi, which
// also folds in the user's own preference to use the LTX API for VIDEO specifically — that's
// unrelated to whether the much smaller Gemma text encoder can run locally right now).
const hasLocalTextEncoder = isLocalEncoderUsable && !forceApiGenerations
const canToggleProvider = hasLocalTextEncoder
// Default to local when the user hasn't made an explicit choice, or when they asked for
// local and it's currently usable. API preference is honored even without a Gemini key so
// the Enhance (API) option isn't silently replaced by local.
const provider: EnhanceProvider =
promptEnhancerProviderPreference === 'api' ? 'api'
: promptEnhancerProviderPreference === 'local' && hasLocalTextEncoder ? 'local'
: hasLocalTextEncoder ? 'local'
: 'api'
const setProviderPreference = (next: EnhanceProvider) => {
updateSettings({ promptEnhancerProviderPreference: next })
}
return {
hasLocalTextEncoder,
hasGeminiApiKey,
provider,
canToggleProvider,
setProviderPreference,
}
}
+165
View File
@@ -0,0 +1,165 @@
import { useCallback, useState } from 'react'
import type { components } from '../generated/backend-openapi'
import { ApiClient } from '../lib/api-client'
import { canCancelLocalJob, withGenerationActive } from '../lib/generation-active'
import { logger } from '../lib/logger'
import { prepareGenerationInput } from '../lib/generation-input'
import { useAppSettings } from '../contexts/AppSettingsContext'
export type RetakeMode = 'replace_audio_and_video' | 'replace_video' | 'replace_audio'
// ltxv-api /v1/retake and /v2/extend accept ltx-2-pro / ltx-2-3-pro.
// Desktop maps those to pipeline "pro".
export type RetakeExtendModel = components['schemas']['RetakeRequest']['model']
// Runtime options for the retake/extend MODEL dropdown. Checked against the OpenAPI union
// so a schema change that adds/removes a value fails typecheck until this list is updated.
export const RETAKE_EXTEND_MODELS = ['pro'] as const satisfies ReadonlyArray<RetakeExtendModel>
/** Map a persisted video pipeline id onto the nearest retake/extend model. */
export function retakeExtendModelFromPipeline(
_model: string | undefined | null,
): RetakeExtendModel {
return 'pro'
}
export interface RetakeSubmitParams {
videoPath: string
startTime: number
duration: number
prompt: string
mode: RetakeMode
resolution?: { width: number; height: number }
model: RetakeExtendModel
}
export interface RetakeResult {
videoPath: string
}
interface UseRetakeState {
isRetaking: boolean
canCancel: boolean
retakeStatus: string
retakeError: string | null
result: RetakeResult | null
}
export function useRetake() {
const { shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi } = useAppSettings()
const [state, setState] = useState<UseRetakeState>({
isRetaking: false,
canCancel: false,
retakeStatus: '',
retakeError: null,
result: null,
})
const submitRetake = useCallback(async (params: RetakeSubmitParams) => {
if (!params.videoPath) return
setState({
isRetaking: true,
canCancel: canCancelLocalJob('video', shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi),
retakeStatus: 'Generating',
retakeError: null,
result: null,
})
await withGenerationActive(async () => {
let preparedVideoPath: string
try {
preparedVideoPath = (await prepareGenerationInput(params.videoPath)) ?? params.videoPath
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to prepare source video'
logger.error(`Retake input error: ${message}`)
setState({
isRetaking: false,
canCancel: false,
retakeStatus: '',
retakeError: message,
result: null,
})
return
}
const result = await ApiClient.retake({
video_path: preparedVideoPath,
start_time: params.startTime,
duration: params.duration,
prompt: params.prompt,
mode: params.mode,
resolution: params.resolution,
model: params.model,
})
if (!result.ok) {
logger.error(`Retake error: ${result.error.message}`)
setState({
isRetaking: false,
canCancel: false,
retakeStatus: '',
retakeError: result.error.message,
result: null,
})
return
}
const payload = result.data
if (payload.status === 'cancelled') {
setState({
isRetaking: false,
canCancel: false,
retakeStatus: 'Cancelled',
retakeError: null,
result: null,
})
return
}
if ('video_path' in payload) {
setState({
isRetaking: false,
canCancel: false,
retakeStatus: 'Retake complete!',
retakeError: null,
result: {
videoPath: payload.video_path,
},
})
return
}
logger.error(`Retake completed without local video payload: ${JSON.stringify(payload.result)}`)
const errorMsg = 'Retake completed but no local video file was returned'
setState({
isRetaking: false,
canCancel: false,
retakeStatus: '',
retakeError: errorMsg,
result: null,
})
})
}, [shouldImageGenerateWithFalApi, shouldVideoGenerateWithLtxApi])
const resetRetake = useCallback(() => {
setState({
isRetaking: false,
canCancel: false,
retakeStatus: '',
retakeError: null,
result: null,
})
}, [])
return {
submitRetake,
resetRetake,
isRetaking: state.isRetaking,
canCancel: state.canCancel,
retakeStatus: state.retakeStatus,
retakeError: state.retakeError,
retakeResult: state.result,
}
}
@@ -0,0 +1,55 @@
import { useEffect, useState } from 'react'
import { useAppSettings } from '../contexts/AppSettingsContext'
import { ApiClient } from '../lib/api-client'
import type { VideoGenerationModelSpecsResponse } from '../lib/video-generation-model-specs'
interface VideoGenerationModelSpecsState {
modelSpecs: VideoGenerationModelSpecsResponse | null
isLoading: boolean
errorMessage: string | null
}
export function useVideoGenerationModelSpecs(): VideoGenerationModelSpecsState {
// Local specs describe the *active* LTX version, so they go stale as soon as the user
// downloads, deletes, or activates a version — refetch on the models-changed signal.
const { modelsVersion } = useAppSettings()
const [state, setState] = useState<VideoGenerationModelSpecsState>({
modelSpecs: null,
isLoading: true,
errorMessage: null,
})
useEffect(() => {
const abortController = new AbortController()
let isActive = true
void (async () => {
const result = await ApiClient.getGenerateVideoModelSpecs(undefined, {
signal: abortController.signal,
})
if (!isActive) return
if (result.ok) {
setState({
modelSpecs: result.data,
isLoading: false,
errorMessage: null,
})
return
}
setState({
modelSpecs: null,
isLoading: false,
errorMessage: result.error.message,
})
})()
return () => {
isActive = false
abortController.abort()
}
}, [modelsVersion])
return state
}
@@ -0,0 +1,150 @@
import { useCallback, useRef, useState } from 'react'
import { useProjects } from '../contexts/ProjectContext'
import { projectReferenceSchema, projectSchema, type Project } from '../types/project-model'
import {
PROJECT_IDS_STORAGE_KEY,
PROJECT_STORAGE_KEY_PREFIX,
getProjectStorageKey,
readProject,
readProjectIds,
writeProjectIds,
} from '../lib/project-storage'
import { logger } from '../lib/logger'
export type ProjectReferencesMigrationStatus =
| { status: 'needed' }
| { status: 'inProgress'; ratio: number }
| { status: 'completed' }
const LEGACY_PROJECTS_STORAGE_KEY = 'ltx-projects'
interface LegacyProjectRecord {
projectData: unknown
projectId: string
}
function yieldToUi(): Promise<void> {
return new Promise(resolve => {
window.setTimeout(resolve, 0)
})
}
export function hasLegacyProjectsEntry(): boolean {
return localStorage.getItem(LEGACY_PROJECTS_STORAGE_KEY) !== null
}
export function deleteLegacyProjectsEntry(): void {
localStorage.removeItem(LEGACY_PROJECTS_STORAGE_KEY)
}
export function writeLegacyProjects(projects: readonly Project[]): Project[] {
const normalizedProjects = projects.map(project => projectSchema.parse(project))
localStorage.setItem(
LEGACY_PROJECTS_STORAGE_KEY,
JSON.stringify(normalizedProjects),
)
return normalizedProjects
}
export function readLegacyProjects(): LegacyProjectRecord[] {
const stored = localStorage.getItem(LEGACY_PROJECTS_STORAGE_KEY)
if (!stored) return []
const parsed = JSON.parse(stored)
if (!Array.isArray(parsed)) {
throw new Error('Legacy projects payload is not an array')
}
return parsed.map(projectData => ({
projectData,
projectId: projectReferenceSchema.parse(projectData).id,
}))
}
export function writeRawProject(projectId: string, projectData: unknown): void {
localStorage.setItem(
getProjectStorageKey(projectId),
JSON.stringify(projectData),
)
}
export function deleteProjectIdsEntry(): void {
localStorage.removeItem(PROJECT_IDS_STORAGE_KEY)
}
export function readProjectsFromReferences(): Project[] {
return readProjectIds().map(projectId => {
const project = readProject(projectId)
if (!project) {
throw new Error(`Missing project entry for id ${projectId}`)
}
return project
})
}
export function deleteAllProjectEntries(): void {
const keysToDelete: string[] = []
for (let i = 0; i < localStorage.length; i += 1) {
const storageKey = localStorage.key(i)
if (storageKey?.startsWith(PROJECT_STORAGE_KEY_PREFIX)) {
keysToDelete.push(storageKey)
}
}
for (const storageKey of keysToDelete) {
localStorage.removeItem(storageKey)
}
}
export function useProjectReferencesMigration() {
const { reloadProjectIds } = useProjects()
const [migrationStatus, setMigrationStatus] = useState<ProjectReferencesMigrationStatus>(() => (
hasLegacyProjectsEntry() ? { status: 'needed' } : { status: 'completed' }
))
const inFlightRef = useRef<Promise<void> | null>(null)
const migrateProjects = useCallback(async () => {
if (inFlightRef.current) return inFlightRef.current
const runMigration = async () => {
if (!hasLegacyProjectsEntry()) {
reloadProjectIds()
setMigrationStatus({ status: 'completed' })
return
}
setMigrationStatus({ status: 'inProgress', ratio: 0 })
await yieldToUi()
try {
const legacyProjects = readLegacyProjects()
const total = legacyProjects.length
for (const [index, project] of legacyProjects.entries()) {
writeRawProject(project.projectId, project.projectData)
setMigrationStatus({
status: 'inProgress',
ratio: total === 0 ? 1 : (index + 1) / total,
})
await yieldToUi()
}
writeProjectIds(legacyProjects.map(project => project.projectId))
deleteLegacyProjectsEntry()
reloadProjectIds()
setMigrationStatus({ status: 'completed' })
} catch (error) {
logger.error(`Failed to migrate project references: ${error}`)
setMigrationStatus({ status: 'needed' })
}
}
inFlightRef.current = runMigration().finally(() => {
inFlightRef.current = null
})
return inFlightRef.current
}, [reloadProjectIds])
return { migrationStatus, migrateProjects }
}
+66
View File
@@ -0,0 +1,66 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* ─────────────────────────────────────────
THEME — edit this block to retheme the app
Values are RGB channels: R G B
───────────────────────────────────────── */
:root {
/* Accent color (used for buttons, progress, highlights) */
--accent: 43 97 255; /* #2B61FF */
--accent-dark: 26 80 224; /* #1A50E0 — darker shade for hover states */
/* Surface colors */
--bg: 9 9 11; /* zinc-950 — main app background */
--surface: 24 24 27; /* zinc-900 — panels, cards */
--surface-raised: 39 39 42; /* zinc-800 — inputs, hover states */
--border-color: 63 63 70; /* zinc-700 — dividers, borders */
/* Text */
--text: 255 255 255;
--text-muted: 161 161 170; /* zinc-400 */
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1a1a1a;
}
::-webkit-scrollbar-thumb {
background: #444;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Drag and drop styling */
.dropzone-active {
border-color: rgb(var(--accent)) !important;
background-color: rgb(var(--accent) / 0.1) !important;
}
+446
View File
@@ -0,0 +1,446 @@
import { normalizeResponseError } from './response-error'
import { backendFetch } from './backend'
import type { components, paths } from '../generated/backend-openapi'
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'
type OperationFor<
TPath extends keyof paths,
TMethod extends HttpMethod,
> = NonNullable<paths[TPath][TMethod]>
type ResponsesFor<
TPath extends keyof paths,
TMethod extends HttpMethod,
> = OperationFor<TPath, TMethod>['responses']
type JsonBodyOf<TResponse> = TResponse extends {
content: infer TContent
}
? TContent extends { 'application/json': infer TJson }
? TJson
: never
: never
type JsonResponseFor<
TPath extends keyof paths,
TMethod extends HttpMethod,
> = OperationFor<TPath, TMethod> extends {
responses: { 200: infer TResponse }
}
? JsonBodyOf<TResponse>
: never
type JsonBodyFor<
TPath extends keyof paths,
TMethod extends HttpMethod,
> = OperationFor<TPath, TMethod> extends {
requestBody?: { content: { 'application/json': infer TBody } }
}
? TBody
: never
type QueryFor<
TPath extends keyof paths,
TMethod extends HttpMethod,
> = OperationFor<TPath, TMethod> extends {
parameters: { query?: infer TQuery }
}
? TQuery
: never
type HTTPErrorResponse = components["schemas"]["HTTPErrorResponse"]
type ExactErrorResponseFor<
TPath extends keyof paths,
TMethod extends HttpMethod,
TStatus extends number,
> = TStatus extends keyof ResponsesFor<TPath, TMethod>
? JsonBodyOf<ResponsesFor<TPath, TMethod>[TStatus]>
: never
type Fallback4xxErrorFor<
TPath extends keyof paths,
TMethod extends HttpMethod,
> = '4XX' extends keyof ResponsesFor<TPath, TMethod>
? JsonBodyOf<ResponsesFor<TPath, TMethod>['4XX']>
: HTTPErrorResponse
type Fallback5xxErrorFor<
TPath extends keyof paths,
TMethod extends HttpMethod,
> = '5XX' extends keyof ResponsesFor<TPath, TMethod>
? JsonBodyOf<ResponsesFor<TPath, TMethod>['5XX']>
: HTTPErrorResponse
type DefaultErrorFor<
TPath extends keyof paths,
TMethod extends HttpMethod,
> = 'default' extends keyof ResponsesFor<TPath, TMethod>
? JsonBodyOf<ResponsesFor<TPath, TMethod>['default']>
: HTTPErrorResponse
type ExactErrorMembers<
TPath extends keyof paths,
TMethod extends HttpMethod,
TExactStatuses extends readonly number[],
> = {
[TStatus in TExactStatuses[number]]: {
ok: false
status: TStatus
error: ExactErrorResponseFor<TPath, TMethod, TStatus>
}
}[TExactStatuses[number]]
type FallbackErrorMembers<
TPath extends keyof paths,
TMethod extends HttpMethod,
> =
| {
ok: false
status: '4XX'
error: Fallback4xxErrorFor<TPath, TMethod>
}
| {
ok: false
status: '5XX'
error: Fallback5xxErrorFor<TPath, TMethod>
}
| {
ok: false
status: 'default'
error: DefaultErrorFor<TPath, TMethod>
}
export type EndpointResult<
TPath extends keyof paths,
TMethod extends HttpMethod,
TExactStatuses extends readonly number[] = [],
> =
| {
ok: true
data: JsonResponseFor<TPath, TMethod>
}
| ExactErrorMembers<TPath, TMethod, TExactStatuses>
| FallbackErrorMembers<TPath, TMethod>
type SyntheticErrorStatus = '4XX' | '5XX' | 'default'
export type ApiSuccess<TValue> = TValue extends { ok: true; data: infer TData }
? TData
: never
export type ApiErrors<TValue> = TValue extends { ok: false; status: infer TStatus; error: infer TError }
? { status: TStatus; error: TError }
: never
function buildQueryString(query: Record<string, unknown> | undefined): string {
if (!query) return ''
const params = new URLSearchParams()
for (const [key, value] of Object.entries(query)) {
if (value === null || value === undefined) continue
params.set(key, String(value))
}
const serialized = params.toString()
return serialized ? `?${serialized}` : ''
}
function buildJsonRequestInit(body: unknown, init?: RequestInit): RequestInit {
const headers = new Headers(init?.headers)
headers.set('Content-Type', 'application/json')
return {
...init,
headers,
body: JSON.stringify(body),
}
}
function buildSyntheticError(code: string, message: string): HTTPErrorResponse {
return { code, message }
}
function resolveFallbackStatus(httpStatus: number): SyntheticErrorStatus {
if (httpStatus >= 400 && httpStatus < 500) return '4XX'
if (httpStatus >= 500 && httpStatus < 600) return '5XX'
return 'default'
}
function resolveErrorStatus<TExactStatuses extends readonly number[]>(
httpStatus: number,
exactErrorStatuses: TExactStatuses,
): TExactStatuses[number] | SyntheticErrorStatus {
if ((exactErrorStatuses as readonly number[]).includes(httpStatus)) {
return httpStatus as TExactStatuses[number]
}
return resolveFallbackStatus(httpStatus)
}
function buildParsedErrorResult<
TPath extends keyof paths,
TMethod extends HttpMethod,
TExactStatuses extends readonly number[],
>(
status: TExactStatuses[number] | SyntheticErrorStatus,
payload: unknown,
): EndpointResult<TPath, TMethod, TExactStatuses> {
const normalized = normalizeResponseError(payload)
return {
ok: false,
status: status === 402 && normalized.code !== 'LTX_INSUFFICIENT_FUNDS' ? '4XX' : status,
error: normalized as ExactErrorResponseFor<TPath, TMethod, TExactStatuses[number]>
| Fallback4xxErrorFor<TPath, TMethod>
| Fallback5xxErrorFor<TPath, TMethod>
| DefaultErrorFor<TPath, TMethod>,
} as EndpointResult<TPath, TMethod, TExactStatuses>
}
function buildSyntheticErrorResult<
TPath extends keyof paths,
TMethod extends HttpMethod,
TExactStatuses extends readonly number[],
>(
status: SyntheticErrorStatus,
code: string,
message: string,
): EndpointResult<TPath, TMethod, TExactStatuses> {
return {
ok: false,
status,
error: buildSyntheticError(code, message) as Fallback4xxErrorFor<TPath, TMethod>
| Fallback5xxErrorFor<TPath, TMethod>
| DefaultErrorFor<TPath, TMethod>,
} as EndpointResult<TPath, TMethod, TExactStatuses>
}
async function requestEndpointResult<
TPath extends keyof paths,
TMethod extends HttpMethod,
TExactStatuses extends readonly number[],
>(
endpoint: TPath,
method: TMethod,
exactErrorStatuses: TExactStatuses,
init?: RequestInit,
requestPath?: string,
): Promise<EndpointResult<TPath, TMethod, TExactStatuses>> {
const path = requestPath ?? String(endpoint)
let response: Response
try {
response = await backendFetch(path, {
method: method.toUpperCase(),
...init,
})
} catch (error) {
return buildSyntheticErrorResult<TPath, TMethod, TExactStatuses>(
'default',
'NETWORK_ERROR',
error instanceof Error ? error.message : 'Request failed before the server responded.',
)
}
let text = ''
try {
text = await response.text()
} catch (error) {
return buildSyntheticErrorResult<TPath, TMethod, TExactStatuses>(
resolveFallbackStatus(response.status),
'RESPONSE_READ_FAILED',
error instanceof Error ? error.message : 'Failed to read response body.',
)
}
if (response.ok) {
if (!text) {
return buildSyntheticErrorResult<TPath, TMethod, TExactStatuses>(
'default',
'EMPTY_SUCCESS_RESPONSE',
`${path} returned an empty response body.`,
)
}
try {
return {
ok: true,
data: JSON.parse(text) as JsonResponseFor<TPath, TMethod>,
}
} catch (error) {
return buildSyntheticErrorResult<TPath, TMethod, TExactStatuses>(
'default',
'INVALID_SUCCESS_RESPONSE',
error instanceof Error ? error.message : 'Server returned invalid JSON.',
)
}
}
if (!text) {
return buildSyntheticErrorResult<TPath, TMethod, TExactStatuses>(
resolveFallbackStatus(response.status),
`HTTP_${response.status}`,
`${response.status} ${response.statusText || 'Request failed'}`,
)
}
try {
const payload = JSON.parse(text) as unknown
return buildParsedErrorResult<TPath, TMethod, TExactStatuses>(
resolveErrorStatus(response.status, exactErrorStatuses),
payload,
)
} catch {
return buildSyntheticErrorResult<TPath, TMethod, TExactStatuses>(
resolveFallbackStatus(response.status),
`HTTP_${response.status}`,
text,
)
}
}
export function makeEndpointClient<
TPath extends keyof paths,
TMethod extends HttpMethod,
TExactStatuses extends readonly number[] = [],
>(
endpoint: TPath,
method: TMethod,
config?: {
exactErrorStatuses?: TExactStatuses
},
) {
const exactErrorStatuses = (config?.exactErrorStatuses ?? []) as TExactStatuses
return (
body?: JsonBodyFor<TPath, TMethod>,
init?: RequestInit,
requestPath?: string,
): Promise<EndpointResult<TPath, TMethod, TExactStatuses>> => {
const requestInit = body === undefined
? init
: buildJsonRequestInit(body, init)
return requestEndpointResult(endpoint, method, exactErrorStatuses, requestInit, requestPath)
}
}
export class ApiClient {
static getHealth = makeEndpointClient('/health', 'get')
static getModelDownloadProgress(
query: QueryFor<'/api/models/download/progress', 'get'>,
): Promise<EndpointResult<'/api/models/download/progress', 'get'>> {
const path = `/api/models/download/progress${buildQueryString(query as Record<string, unknown>)}`
return requestEndpointResult('/api/models/download/progress', 'get', [] as const, undefined, path)
}
static listModels(
query?: QueryFor<'/api/models', 'get'>,
): Promise<EndpointResult<'/api/models', 'get'>> {
const path = `/api/models${buildQueryString(query as Record<string, unknown>)}`
return requestEndpointResult('/api/models', 'get', [] as const, undefined, path)
}
static getLtxRecommendation = makeEndpointClient('/api/models/ltx-recommendation', 'get')
static getImgGenRecommendation = makeEndpointClient('/api/models/img-gen-recommendation', 'get')
static getLtxIcLoraRecommendation = makeEndpointClient('/api/models/ltx-ic-lora-recommendation', 'get')
static getTextEncoderRecommendation = makeEndpointClient('/api/models/text-encoder-recommendation', 'get')
static describeCheckpoints = makeEndpointClient('/api/models/describe', 'post')
static getActiveDownload = makeEndpointClient('/api/models/download/active', 'get')
static getLtxVersions = makeEndpointClient('/api/models/ltx-versions', 'get')
static setActiveLtxModel = makeEndpointClient('/api/models/active-ltx-model', 'post')
static startModelDownload = makeEndpointClient('/api/models/download', 'post')
static deleteModels = makeEndpointClient('/api/models/delete', 'delete')
static getRuntimePolicy = makeEndpointClient('/api/runtime-policy', 'get')
static getGpuInfo = makeEndpointClient('/api/gpu-info', 'get')
static getSettings = makeEndpointClient('/api/settings', 'get')
static listGeminiModels = makeEndpointClient('/api/settings/gemini-models', 'get')
static updateSettings = makeEndpointClient('/api/settings', 'post')
static suggestGapPrompt = makeEndpointClient('/api/suggest-gap-prompt', 'post', {
exactErrorStatuses: [401, 403] as const,
})
static generateVideo = makeEndpointClient('/api/generate', 'post', {
exactErrorStatuses: [402] as const,
})
static getGenerateVideoModelSpecs = makeEndpointClient('/api/generate/models-specs', 'get')
static cancelGeneration = makeEndpointClient('/api/generate/cancel', 'post')
static getGenerationProgress = makeEndpointClient('/api/generation/progress', 'get')
static generateImage = makeEndpointClient('/api/generate-image', 'post')
static enhancePrompt = makeEndpointClient('/api/enhance-prompt', 'post', {
exactErrorStatuses: [404, 409] as const,
})
static retake = makeEndpointClient('/api/retake', 'post')
static extend = makeEndpointClient('/api/extend', 'post')
static startHuggingFaceLogin = makeEndpointClient('/api/auth/huggingface/login', 'post')
static getHuggingFaceAuthStatus = makeEndpointClient('/api/auth/huggingface/status', 'get')
static huggingFaceLogout = makeEndpointClient('/api/auth/huggingface/logout', 'post')
static checkModelAccess = makeEndpointClient('/api/models/check-access', 'post')
static generateIcLora = makeEndpointClient('/api/ic-lora/generate', 'post')
static extractIcLoraConditioning = makeEndpointClient('/api/ic-lora/extract-conditioning', 'post')
static listIcLoras = makeEndpointClient('/api/ic-loras', 'get')
static startIcLoraDownload = makeEndpointClient('/api/ic-loras/download', 'post')
static getIcLoraDownloadProgress(
query: QueryFor<'/api/ic-loras/download/progress', 'get'>,
): Promise<EndpointResult<'/api/ic-loras/download/progress', 'get'>> {
const path = `/api/ic-loras/download/progress${buildQueryString(query as Record<string, unknown>)}`
return requestEndpointResult('/api/ic-loras/download/progress', 'get', [] as const, undefined, path)
}
static listLoras = makeEndpointClient('/api/loras', 'get')
static startLoraDownload = makeEndpointClient('/api/loras/download', 'post')
static getLoraDownloadProgress(
query: QueryFor<'/api/loras/download/progress', 'get'>,
): Promise<EndpointResult<'/api/loras/download/progress', 'get'>> {
const path = `/api/loras/download/progress${buildQueryString(query as Record<string, unknown>)}`
return requestEndpointResult('/api/loras/download/progress', 'get', [] as const, undefined, path)
}
}
type ApiClientMethodName = keyof typeof ApiClient
export type ApiRequestBodyOf<TMethod extends ApiClientMethodName> = (typeof ApiClient)[TMethod] extends (
body?: infer TBody,
...args: any[]
) => Promise<any>
? TBody
: never
export type ApiSuccessOf<TMethod extends ApiClientMethodName> = (typeof ApiClient)[TMethod] extends (...args: any[]) => Promise<any>
? ApiSuccess<Awaited<ReturnType<(typeof ApiClient)[TMethod]>>>
: never
export type ApiErrorsOf<TMethod extends ApiClientMethodName> = (typeof ApiClient)[TMethod] extends (...args: any[]) => Promise<any>
? ApiErrors<Awaited<ReturnType<(typeof ApiClient)[TMethod]>>>
: never
+56
View File
@@ -0,0 +1,56 @@
import { logger } from './logger'
export type ProjectAssetType = 'video' | 'image'
export interface ProjectAssetCopyResult {
path: string
bigThumbnailPath: string
smallThumbnailPath: string
width: number
height: number
}
/**
* Copy a video/image file to project storage and return precomputed thumbnail paths.
*/
export async function addVisualAssetToProject(
srcPath: string,
projectId: string,
type: ProjectAssetType,
): Promise<ProjectAssetCopyResult | null> {
try {
const result = await window.electronAPI.addVisualAssetToProject({ srcPath, projectId, type })
if (result.success) {
return {
path: result.path,
bigThumbnailPath: result.bigThumbnailPath,
smallThumbnailPath: result.smallThumbnailPath,
width: result.width,
height: result.height,
}
}
logger.warn(`Failed to add asset to project folder: ${result.error}`)
} catch (e) {
logger.warn(`Failed to add asset to project folder: ${e}`)
}
return null
}
/**
* Copy a file to project storage without thumbnail generation (audio path).
*/
export async function addGenericAssetToProject(
srcPath: string,
projectId: string,
): Promise<{ path: string } | null> {
try {
const result = await window.electronAPI.addGenericAssetToProject({ srcPath, projectId })
if (result.success) {
return { path: result.path }
}
logger.warn(`Failed to copy file to project folder: ${result.error}`)
} catch (e) {
logger.warn(`Failed to copy file to project folder: ${e}`)
}
return null
}
+24
View File
@@ -0,0 +1,24 @@
let cached: { url: string; token: string } | null = null
export async function getBackendCredentials(): Promise<{ url: string; token: string }> {
if (!cached) cached = await window.electronAPI.getBackend()
return cached
}
export function resetBackendCredentials(): void {
cached = null
}
export async function backendFetch(path: string, init?: RequestInit): Promise<Response> {
const { url, token } = await getBackendCredentials()
const headers = new Headers(init?.headers)
if (token) headers.set('Authorization', `Bearer ${token}`)
return fetch(`${url}${path}`, { ...init, headers })
}
export async function backendWsUrl(path: string): Promise<string> {
const { url, token } = await getBackendCredentials()
const ws = url.replace('http://', 'ws://')
const sep = path.includes('?') ? '&' : '?'
return `${ws}${path}${sep}token=${token}`
}
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { DEFAULT_KEYFRAME_STRENGTH, type KeyframeItem } from './multi-keyframe.ts'
import { buildGenerateVideoImageInputs } from './build-generate-video-body.ts'
const keyframes: KeyframeItem[] = [
{ id: 'opening', path: '/frames/opening.png', frameIndex: 0, strength: DEFAULT_KEYFRAME_STRENGTH },
{ id: 'ending', path: '/frames/ending.png', frameIndex: 121, strength: DEFAULT_KEYFRAME_STRENGTH },
]
describe('buildGenerateVideoImageInputs', () => {
it('maps multi-keyframes to the backend request shape', () => {
assert.deepEqual(
buildGenerateVideoImageInputs({
mode: 'multi-keyframe',
imagePath: null,
lastImagePath: null,
keyframes,
}),
{
keyframes: [
{ imagePath: '/frames/opening.png', frameIndex: 0, strength: DEFAULT_KEYFRAME_STRENGTH },
{ imagePath: '/frames/ending.png', frameIndex: 121, strength: DEFAULT_KEYFRAME_STRENGTH },
],
},
)
})
it('omits image inputs when multi-keyframes are present', () => {
const result = buildGenerateVideoImageInputs({
mode: 'multi-keyframe',
imagePath: '/frames/first.png',
lastImagePath: '/frames/last.png',
keyframes,
})
assert.equal('imagePath' in result, false)
assert.equal('lastImagePath' in result, false)
assert.equal('keyframes' in result, true)
})
it('forwards each still\'s owned strength on generate', () => {
assert.deepEqual(
buildGenerateVideoImageInputs({
mode: 'multi-keyframe',
imagePath: null,
lastImagePath: null,
keyframes: [
{ id: 'opening', path: '/frames/opening.png', frameIndex: 0, strength: 0.7 },
],
}),
{
keyframes: [{ imagePath: '/frames/opening.png', frameIndex: 0, strength: 0.7 }],
},
)
})
it('preserves image inputs outside multi-keyframe mode', () => {
assert.deepEqual(
buildGenerateVideoImageInputs({
mode: 'video',
imagePath: '/frames/first.png',
lastImagePath: '/frames/last.png',
keyframes,
}),
{
imagePath: '/frames/first.png',
lastImagePath: '/frames/last.png',
},
)
})
})
+33
View File
@@ -0,0 +1,33 @@
import type { GenSpaceMode } from './genspace-multi-keyframe'
import { toKeyframeInputs, type KeyframeItem } from './multi-keyframe.ts'
interface GenerateVideoImageInputs {
imagePath?: string
lastImagePath?: string
keyframes?: Array<{
imagePath: string
frameIndex: number
strength: number
}>
}
export function buildGenerateVideoImageInputs({
mode,
imagePath,
lastImagePath,
keyframes,
}: {
mode: GenSpaceMode
imagePath: string | null | undefined
lastImagePath: string | null | undefined
keyframes: KeyframeItem[]
}): GenerateVideoImageInputs {
if (mode === 'multi-keyframe' && keyframes.length > 0) {
return { keyframes: toKeyframeInputs(keyframes) }
}
return {
...(imagePath ? { imagePath } : {}),
...(lastImagePath ? { lastImagePath } : {}),
}
}
+72
View File
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
GEMINI_KEY_REQUIRED_SETTINGS_DETAIL,
isEnhanceBlockedByMissingGeminiKey,
} from './enhance-gemini-key.ts'
const blocked = {
enhanceAvailableForMode: true,
enhanceProvider: 'api' as const,
hasGeminiApiKey: false,
hasEnhanceInput: true,
isGenerationInProgressForEnhance: false,
isOtherGenerationRunning: false,
}
describe('isEnhanceBlockedByMissingGeminiKey', () => {
it('is true when Enhance (API) is selected and no Gemini key is configured', () => {
assert.equal(isEnhanceBlockedByMissingGeminiKey(blocked), true)
})
it('is false when enhance is not available for the current mode', () => {
assert.equal(
isEnhanceBlockedByMissingGeminiKey({ ...blocked, enhanceAvailableForMode: false }),
false,
)
})
it('is false when a Gemini key is already configured', () => {
assert.equal(
isEnhanceBlockedByMissingGeminiKey({ ...blocked, hasGeminiApiKey: true }),
false,
)
})
it('is false when Enhance is using the local provider, even without a Gemini key', () => {
assert.equal(
isEnhanceBlockedByMissingGeminiKey({ ...blocked, enhanceProvider: 'local' }),
false,
)
})
it('is false when there is no prompt or image to enhance', () => {
assert.equal(
isEnhanceBlockedByMissingGeminiKey({ ...blocked, hasEnhanceInput: false }),
false,
)
})
it('is false while this project is already generating', () => {
assert.equal(
isEnhanceBlockedByMissingGeminiKey({ ...blocked, isGenerationInProgressForEnhance: true }),
false,
)
})
it('is false while another project is generating', () => {
assert.equal(
isEnhanceBlockedByMissingGeminiKey({ ...blocked, isOtherGenerationRunning: true }),
false,
)
})
})
describe('GEMINI_KEY_REQUIRED_SETTINGS_DETAIL', () => {
it('opens Settings on the API Keys tab for the Gemini key banner', () => {
assert.deepEqual(GEMINI_KEY_REQUIRED_SETTINGS_DETAIL, {
tab: 'apiKeys',
reason: 'geminiKeyRequired',
})
})
})
+25
View File
@@ -0,0 +1,25 @@
export const GEMINI_KEY_REQUIRED_SETTINGS_DETAIL = {
tab: 'apiKeys' as const,
reason: 'geminiKeyRequired' as const,
}
export function isEnhanceBlockedByMissingGeminiKey(input: {
enhanceAvailableForMode: boolean
enhanceProvider: 'local' | 'api'
hasGeminiApiKey: boolean
hasEnhanceInput: boolean
isGenerationInProgressForEnhance: boolean
isOtherGenerationRunning: boolean
}): boolean {
// True when Enhance would run via Gemini but no key is configured — including when local
// Enhance is available and the user explicitly picked API. Clicking then opens Settings
// instead of hiding the API option.
return (
input.enhanceAvailableForMode
&& input.enhanceProvider === 'api'
&& !input.hasGeminiApiKey
&& input.hasEnhanceInput
&& !input.isGenerationInProgressForEnhance
&& !input.isOtherGenerationRunning
)
}
+2
View File
@@ -0,0 +1,2 @@
import { mediaUrl } from './web-platform'
export function pathToFileUrl(filePath: string): string { return mediaUrl(filePath) }
+66
View File
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { fixedMenuPosition } from './fixed-menu-position.ts'
const trigger = { left: 100, right: 180, top: 500, bottom: 532 }
const viewport = { width: 1000, height: 800 }
describe('fixedMenuPosition', () => {
it('anchors a menu above the trigger', () => {
const pos = fixedMenuPosition({
trigger,
placement: 'above',
viewport,
menuWidth: 160,
gap: 8,
})
assert.equal(pos.left, 100)
assert.equal(pos.bottom, 308)
assert.equal(pos.top, undefined)
})
it('anchors a menu below the trigger', () => {
const pos = fixedMenuPosition({
trigger,
placement: 'below',
viewport,
menuWidth: 160,
gap: 8,
})
assert.equal(pos.left, 100)
assert.equal(pos.top, 540)
assert.equal(pos.bottom, undefined)
})
it('shifts left so a wide menu stays on-screen', () => {
const pos = fixedMenuPosition({
trigger: { left: 900, right: 980, top: 500, bottom: 532 },
placement: 'above',
viewport,
menuWidth: 160,
gap: 8,
})
assert.equal(pos.left, 820)
})
it('does not use an assumed width when the menu has not been measured', () => {
const pos = fixedMenuPosition({
trigger: { left: 900, right: 980, top: 500, bottom: 532 },
placement: 'above',
viewport,
gap: 8,
})
assert.equal(pos.left, 900)
})
it('ignores a viewport-filling width from an unpositioned block menu', () => {
const pos = fixedMenuPosition({
trigger: { left: 400, right: 480, top: 500, bottom: 532 },
placement: 'above',
viewport,
menuWidth: 1000,
gap: 8,
})
assert.equal(pos.left, 400)
})
})
+33
View File
@@ -0,0 +1,33 @@
export type MenuPlacement = 'above' | 'below'
const EDGE_PX = 8
export function fixedMenuPosition({
trigger,
placement,
viewport,
menuWidth,
gap = 8,
}: {
trigger: { left: number; right: number; top: number; bottom: number }
placement: MenuPlacement
viewport: { width: number; height: number }
menuWidth?: number
gap?: number
}): { left: number; top?: number; bottom?: number } {
let left = trigger.left
const measuredWidth =
menuWidth != null && menuWidth < viewport.width - EDGE_PX * 2 ? menuWidth : undefined
if (measuredWidth != null) {
const maxLeft = viewport.width - measuredWidth - EDGE_PX
if (left > maxLeft) left = trigger.right - measuredWidth
left = Math.max(EDGE_PX, Math.min(left, maxLeft))
} else {
left = Math.max(EDGE_PX, left)
}
if (placement === 'below') {
return { left, top: trigger.bottom + gap }
}
return { left, bottom: viewport.height - trigger.top + gap }
}
+17
View File
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { formatBytes } from './format.ts'
describe('formatBytes', () => {
it('formats zero', () => {
assert.equal(formatBytes(0), '0 B')
})
it('formats megabytes with one decimal', () => {
assert.equal(formatBytes(996_000_000), '949.9 MB')
})
it('formats terabytes instead of overflowing the unit list', () => {
assert.equal(formatBytes(1.8 * 1024 ** 4), '1.8 TB')
})
})
+8
View File
@@ -0,0 +1,8 @@
// Human-readable byte size, e.g. 996_000_000 -> "949.9 MB". Binary units (1024).
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
+43
View File
@@ -0,0 +1,43 @@
import { useSyncExternalStore } from 'react'
// Local, live "is a generation running?" signal for the renderer UI.
// Ref-counted because generations can overlap.
let activeCount = 0
const listeners = new Set<() => void>()
function emit() { for (const l of listeners) l() }
function subscribe(cb: () => void): () => void {
listeners.add(cb)
return () => { listeners.delete(cb) }
}
function getSnapshot(): boolean {
return activeCount > 0
}
export function useIsGenerationActive(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}
// Local generation can starve the backend's event loop; withGenerationActive tells main so the
// liveness monitor doesn't kill a busy backend. We also keep a local count for the UI signal above.
export async function withGenerationActive<T>(fn: () => Promise<T>): Promise<T> {
activeCount += 1; emit()
void window.electronAPI.notifyGenerationActive({ active: true })
try {
return await fn()
} finally {
activeCount = Math.max(0, activeCount - 1); emit()
void window.electronAPI.notifyGenerationActive({ active: false })
}
}
/** Evaluate at job start and freeze — live Settings must not flip Stop mid-request. */
export function canCancelLocalJob(
kind: 'video' | 'image',
videoUsesLtxApi: boolean,
imageUsesFalApi: boolean,
): boolean {
if (kind === 'image') return !imageUsesFalApi
if (kind === 'video') return !videoUsesLtxApi
return false
}

Some files were not shown because too many files have changed in this diff Show More