commit 1333b0c36ba960acb378aa14dc3a4123510a30b0 Author: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Tue Sep 29 17:59:23 2026 +0200 Create standalone browser port of LTX Athena with authenticated native API transport diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a097e35 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +node_modules +dist +data +secrets +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..613d625 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8806996 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.env +secrets/ +data/ +*.tsbuildinfo +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7ff621c --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fadbf96 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..41771b9 --- /dev/null +++ b/LICENSE.txt @@ -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. diff --git a/NOTICES.md b/NOTICES.md new file mode 100644 index 0000000..62320b2 --- /dev/null +++ b/NOTICES.md @@ -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) . 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) . 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) + +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) + +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) + +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. +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..87481a0 --- /dev/null +++ b/README.md @@ -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. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..f9f4b88 --- /dev/null +++ b/compose.yaml @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..12bc983 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/PROVENANCE.md b/docs/PROVENANCE.md new file mode 100644 index 0000000..97de313 --- /dev/null +++ b/docs/PROVENANCE.md @@ -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. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 0000000..19a1331 --- /dev/null +++ b/docs/VALIDATION.md @@ -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. diff --git a/frontend/App.tsx b/frontend/App.tsx new file mode 100644 index 0000000..ee6c82c --- /dev/null +++ b/frontend/App.tsx @@ -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 <> +
+ {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.'} +
+ {currentView === 'home' ? : ready ? :

Projects are retained. Generation becomes available when the LTX backend and its settings are reachable.

} + + +} +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).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
Loading LTX DeskWEB…
+ if (!session) return
+
{ + 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)) } + }}> +

LTX DeskWEB

Browser workspace for your LTX backend.

+ setPassword(e.target.value)} className="mt-2 mb-4 w-full p-3 bg-zinc-800 rounded" /> +

{error}

+
+
+ return <> +
+ LTX DeskWEB · Preview
+
+ {help && } + {message &&
{message}
} + + +} diff --git a/frontend/components/ApiGatewayModal.tsx b/frontend/components/ApiGatewayModal.tsx new file mode 100644 index 0000000..4713c91 --- /dev/null +++ b/frontend/components/ApiGatewayModal.tsx @@ -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 + onGetKey?: () => void + getKeyLabel?: string +} + +export interface ApiGatewayModalProps { + isOpen: boolean + onClose: () => void + title: string + description: string + sections: ApiGatewaySection[] + blocking?: boolean +} + +const KEY_TYPE_META: Record = { + 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>({ ltx: '', fal: '' }) + const [isSaving, setIsSaving] = useState>({ ltx: false, fal: false }) + const [errors, setErrors] = useState>({ 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 ( +
+
+
+
+
+ +
+

{title}

+
+ {!blocking && ( + + )} +
+ +
+

{description}

+ +
+ {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 ( +
+
+
+
+ +

{section.title}

+ + {section.required ? 'Required' : 'Optional'} + +
+

{section.description}

+
+
+ {configured ? 'Configured' : 'Not set'} +
+
+ + {!configured && ( +
+ +
+ setValues((prev) => ({ ...prev, [section.keyType]: event.target.value }))} + placeholder={section.placeholder ?? 'Paste your API key'} + className="flex-1" + /> + +
+ +
+ )} + + {error && ( +
+ {error} +
+ )} +
+ ) + })} +
+ + {blocking && requiredMissing && ( +
+ Required API keys are missing. Add them to continue. +
+ )} + + {sections.length > 1 && allRequiredConfigured && ( +
+ +
+ )} +
+
+
+ ) +} diff --git a/frontend/components/AssetPreviewModal.tsx b/frontend/components/AssetPreviewModal.tsx new file mode 100644 index 0000000..c8ac5ea --- /dev/null +++ b/frontend/components/AssetPreviewModal.tsx @@ -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( +
+ + + + +
e.stopPropagation()} + > +
+ + {index + 1} / {total} + + +
+ + {asset.type === 'video' ? ( +
+
, + document.body, + ) +} diff --git a/frontend/components/AudioWaveform.tsx b/frontend/components/AudioWaveform.tsx new file mode 100644 index 0000000..a6d4320 --- /dev/null +++ b/frontend/components/AudioWaveform.tsx @@ -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() +const pendingDecodes = new Set() + +// 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 { + 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(null) + const containerRef = useRef(null) + const [waveforms, setWaveforms] = useState>(new Map()) + const animRef = useRef(0) + + // Load waveform data for all clips + useEffect(() => { + let cancelled = false + const loadAll = async () => { + const newMap = new Map() + 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 ( +
+ {/* Canvas fills available space */} +
+ + {/* Small music icon badge */} +
+ + Audio +
+
+ {/* Clip names */} + {audioClips.length > 0 && ( +
+ {audioClips.map((clip, i) => ( +

+ {clip.name} +

+ ))} +
+ )} +
+ ) +} + +// --- 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(null) + const containerRef = useRef(null) + const [peaks, setPeaks] = useState(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 ( +
+ +
+ ) +} diff --git a/frontend/components/DevPanel.tsx b/frontend/components/DevPanel.tsx new file mode 100644 index 0000000..2d2b7c5 --- /dev/null +++ b/frontend/components/DevPanel.tsx @@ -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(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 ( +
setPanelOpen(false)} + > +
e.stopPropagation()} + > + {/* Header */} +
+
+

Dev Panel

+

Local-only feature toggles, saved on this machine.

+
+ +
+ + {/* Body: list + detail */} +
+ {/* List */} +
+ {DEV_FLAGS.map(spec => { + const on = flags[spec.key] + const isSelected = spec.key === selectedKey + return ( + + ) + })} +
+ + {/* Detail */} +
+ {selected && ( + <> +
+

{selected.label}

+ +
+ +
+ Enabled + +
+ +
+

Description

+

{selected.description}

+
+ + )} +
+
+
+
+ ) +} diff --git a/frontend/components/ExportModal.tsx b/frontend/components/ExportModal.tsx new file mode 100644 index 0000000..cfc9b3f --- /dev/null +++ b/frontend/components/ExportModal.tsx @@ -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 = { + 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 = { + '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() + 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( + ` ` + ) + } + + const trackGroups: Map = 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 = ` ` + clipElements.push(clipXml) + } + + const trackName = tracks[trackIdx]?.name || `Track ${trackIdx + 1}` + laneXml.push( + ` \n` + + clipElements.join('\n') + ) + } + + return ` + + + + +${assetEntries.join('\n')} + + + + + + +${laneXml.join('\n')} + + + + + +` +} + +function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +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('idle') + const [exportType, setExportType] = useState<'package' | 'video' | null>(null) + const [exportProgress, setExportProgress] = useState(0) + const [exportError, setExportError] = useState(null) + const [exportPath, setExportPath] = useState(null) + const [exportFrameInfo, setExportFrameInfo] = useState('') + const abortRef = useRef(false) + + // Export settings + const [settings, setSettings] = useState({ + 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 ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

Export

+ +
+ +
+ {/* Exporting state */} + {exportStatus === 'exporting' && ( +
+
+ + + {exportType === 'package' ? 'Generating FCPXML...' : 'Rendering video...'} + +
+
+
+
+
+

{exportProgress}% complete

+ {exportFrameInfo &&

{exportFrameInfo}

} +
+ {exportType === 'video' && ( + + )} +
+ )} + + {/* Done state */} + {exportStatus === 'done' && ( +
+
+
+ +
+
+

Export complete

+

{exportPath}

+ {exportFrameInfo &&

{exportFrameInfo}

} +
+
+
+ + +
+
+ )} + + {/* Error state */} + {exportStatus === 'error' && ( +
+
+
+ +
+
+

Export failed

+

{exportError}

+
+
+ +
+ )} + + {/* Idle state — settings */} + {exportStatus === 'idle' && ( +
+ {/* Package export (compact) */} + + + {/* Divider */} +
+
+ Video Export +
+
+ + {/* Format selector */} +
+ +
+ {(Object.keys(CODEC_INFO) as ExportCodec[]).map(codec => ( + + ))} +
+
+ + {/* Resolution & Frame rate row */} +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + {/* Quality */} +
+ + {settings.codec === 'h264' && ( +
+ 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) + /> + + {settings.quality <= 18 ? 'High' : settings.quality <= 23 ? 'Medium' : 'Low'} + ({settings.quality}) + +
+ )} + {settings.codec === 'prores' && ( +
+ {PRORES_PROFILES.map(p => ( + + ))} +
+ )} + {settings.codec === 'vp9' && ( +
+ setSettings(prev => ({ ...prev, quality: parseInt(e.target.value) }))} + className="flex-1 h-1.5 accent-blue-500 cursor-pointer" + /> + + {settings.quality} Mbps + +
+ )} +
+ + {/* Options */} + {hasSubtitles && ( +
+
+
+ Options +
+
+ +
+ )} + + {/* Export button */} + + + {clips.length === 0 && ( +

Add clips to the timeline to export.

+ )} +
+ )} +
+ +
+
+ ) +} diff --git a/frontend/components/ExtendPanel.tsx b/frontend/components/ExtendPanel.tsx new file mode 100644 index 0000000..5ff0f21 --- /dev/null +++ b/frontend/components/ExtendPanel.tsx @@ -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(initialVideoPath || null) + const [videoDuration, setVideoDuration] = useState(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 ( + + ) +} diff --git a/frontend/components/FirstRunSetup.css b/frontend/components/FirstRunSetup.css new file mode 100644 index 0000000..a0d3503 --- /dev/null +++ b/frontend/components/FirstRunSetup.css @@ -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); } +} diff --git a/frontend/components/FirstRunSetup.tsx b/frontend/components/FirstRunSetup.tsx new file mode 100644 index 0000000..a0c345d --- /dev/null +++ b/frontend/components/FirstRunSetup.tsx @@ -0,0 +1,1209 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react' +import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../lib/api-client' +import { formatBytes } from '../lib/format' +import { logger } from '../lib/logger' +import { useHfAuth } from '../hooks/use-hf-auth' +import { useHfModelAccess } from '../hooks/use-hf-model-access' +import { useAppSettings } from '../contexts/AppSettingsContext' +import { HfModelAccessGate } from './HfModelAccessGate' +import './FirstRunSetup.css' + +interface LaunchGateProps { + licenseOnly?: boolean + showLicenseStep?: boolean + onComplete: () => Promise + onAcceptLicense?: () => Promise +} + +type Step = 'license' | 'location' | 'installing' | 'complete' +type StartModelDownloadBody = NonNullable> +type ModelCheckpointID = NonNullable[number] +type LtxRecommendation = ApiSuccessOf<'getLtxRecommendation'> +type ImgGenRecommendation = ApiSuccessOf<'getImgGenRecommendation'> +type DownloadProgress = ApiSuccessOf<'getModelDownloadProgress'> +type CheckpointDescriptor = ApiSuccessOf<'describeCheckpoints'>['checkpoints'][number] +type DownloadStepSpec = { + type: StartModelDownloadBody['type'] + cpIds: ModelCheckpointID[] +} + +// Fun loading messages +const INSTALL_MESSAGES = [ + "Downloading model weights...", + "Teaching AI to dream in 4K...", + "Loading neural pathways...", + "Calibrating inference engine...", + "Almost there...", + "Unpacking the magic...", + "Configuring parameters...", + "Finalizing installation..." +] + +function uniqueCpIds(cpIds: readonly ModelCheckpointID[]): ModelCheckpointID[] { + return [...new Set(cpIds)] +} + +// User-facing explanation per checkpoint role (moved out of the backend so wording/i18n +// iterates without a backend deploy). Keyed by the stable `role` the backend ships. +const CP_INFO_BY_ROLE: Record = { + base: 'The core LTX video model that turns your prompt into video frames. This is the largest download.', + upscaler: + 'Doubles the resolution of generated video for sharper, more detailed output. This updated version also ' + + 'fixes glitches and stray text or overlay artifacts that could appear near the end of longer clips, and ' + + 'keeps detail consistent through the final frames — recommended for long videos.', + text_encoder: + 'Reads your text prompt so the model understands it. You can skip this large download by entering an LTX ' + + 'API key, which encodes prompts via the API instead.', + vae: + 'Decodes the model\'s latent frames into video (and audio, when present). Required for LTX versions that ' + + 'ship the transformer separately from their VAEs.', + image: 'Generates still images from text prompts (used for image-to-video and image tools).', + support: 'A supporting model used for guided generation (depth, edges, or pose control).', +} + +// One line in the first-run "What will be downloaded" list: checkpoint name, an +// info-tooltip icon, and the size. Items an API key covers get a checkbox instead of being +// silently dropped, so the download stays available to anyone who wants to run offline. +function DownloadItem({ + item, + optIn, +}: { + item: CheckpointDescriptor + optIn?: { checked: boolean; onToggle: () => void } +}) { + const skipped = optIn !== undefined && !optIn.checked + return ( +
+ + {optIn && ( + + )} + {item.name} + + i + + + + {item.downloaded ? 'Installed' : skipped ? 'Skipped (API key)' : formatBytes(item.size_bytes)} + +
+ ) +} + +function buildDownloadSteps( + ltxRecommendation: LtxRecommendation, + imgGenRecommendation: ImgGenRecommendation, + extraCpIds: readonly ModelCheckpointID[] = [], +): DownloadStepSpec[] { + const cpIds: ModelCheckpointID[] = [] + if (ltxRecommendation.status === 'download') { + cpIds.push(...ltxRecommendation.cps_to_download) + } + if (imgGenRecommendation.cp_to_download) { + cpIds.push(imgGenRecommendation.cp_to_download) + } + cpIds.push(...extraCpIds) + const unique = uniqueCpIds(cpIds) + return unique.length > 0 ? [{ type: 'download', cpIds: unique }] : [] +} + + +export function LaunchGate({ + licenseOnly, + showLicenseStep = true, + onComplete, + onAcceptLicense, +}: LaunchGateProps) { + const [currentStep, setCurrentStep] = useState(showLicenseStep ? 'license' : 'location') + const [installPath, setInstallPath] = useState('') + const [downloadProgress, setDownloadProgress] = useState(null) + const [downloadError, setDownloadError] = useState(null) + const [downloadSessionId, setDownloadSessionId] = useState(null) + const [installMessage, setInstallMessage] = useState(INSTALL_MESSAGES[0]) + const [availableSpace, setAvailableSpace] = useState('...') + const [downloadItems, setDownloadItems] = useState([]) + const [optionalItems, setOptionalItems] = useState([]) + const [optedInCpIds, setOptedInCpIds] = useState([]) + const [videoPath, setVideoPath] = useState('/splash/splash.mp4') + const [ltxApiKey, setLtxApiKey] = useState('') + const [hasSavedLtxApiKey, setHasSavedLtxApiKey] = useState(false) + const [licenseAccepted, setLicenseAccepted] = useState(false) + const [licenseText, setLicenseText] = useState(null) + const [licenseError, setLicenseError] = useState(null) + const [actionError, setActionError] = useState(null) + const [isActionPending, setIsActionPending] = useState(false) + const { hfAuthStatus, hfAuthPolling, startHuggingFaceLogin } = useHfAuth(currentStep === 'location') + // A key typed here isn't saved yet, so the backend still lists the text encoder as required; + // move it to the opt-in list so the choice reads the same either way. + const keyEnteredNow = ltxApiKey.trim().length > 0 + const requiredItems = useMemo( + () => downloadItems.filter((item) => !(item.role === 'text_encoder' && keyEnteredNow)), + [downloadItems, keyEnteredNow], + ) + const allOptionalItems = useMemo( + () => [ + ...(keyEnteredNow ? downloadItems.filter((item) => item.role === 'text_encoder') : []), + ...optionalItems, + ], + [downloadItems, keyEnteredNow, optionalItems], + ) + const isOptedIn = (item: CheckpointDescriptor) => optedInCpIds.includes(item.cp_id as ModelCheckpointID) + const toggleOptIn = (item: CheckpointDescriptor) => { + const cpId = item.cp_id as ModelCheckpointID + setOptedInCpIds((ids) => (ids.includes(cpId) ? ids.filter((id) => id !== cpId) : [...ids, cpId])) + } + const pendingDownloadItems = useMemo( + () => + [...requiredItems, ...allOptionalItems.filter((item) => optedInCpIds.includes(item.cp_id as ModelCheckpointID))] + .filter((item) => !item.downloaded), + [requiredItems, allOptionalItems, optedInCpIds], + ) + const installCpIds = useMemo( + () => uniqueCpIds(pendingDownloadItems.map((item) => item.cp_id as ModelCheckpointID)), + [pendingDownloadItems], + ) + const { accessMap, allAuthorized, checking: checkingAccess, checkError, recheckAccess } = useHfModelAccess( + currentStep === 'location' ? installCpIds : [], + hfAuthStatus, + ) + const { saveLtxApiKey } = useAppSettings() + const downloadQueueRef = useRef([]) + const runningDownloadProgress = downloadProgress?.status === 'downloading' ? downloadProgress : null + const totalProgress = runningDownloadProgress?.total_progress ?? (downloadProgress?.status === 'complete' ? 100 : 0) + + const totalDownloadBytes = pendingDownloadItems.reduce((sum, item) => sum + item.size_bytes, 0) + + // Format time remaining + 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` + } + + // Calculate ETA based on speed and remaining bytes + const getTimeRemaining = (): string => { + if (!runningDownloadProgress || runningDownloadProgress.speed_bytes_per_sec <= 0) return '--' + const remainingBytes = runningDownloadProgress.expected_total_bytes - runningDownloadProgress.total_downloaded_bytes + if (remainingBytes <= 0) return '--' + const secondsRemaining = remainingBytes / runningDownloadProgress.speed_bytes_per_sec + return formatTimeRemaining(secondsRemaining) + } + + // Fetch license text + const fetchLicense = async () => { + setLicenseError(null) + setLicenseText(null) + try { + const text = await window.electronAPI.fetchLicenseText() + setLicenseText(text) + } catch (e) { + setLicenseError(e instanceof Error ? e.message : 'Failed to fetch license text.') + } + } + + const refreshModelRecommendations = useCallback(async () => { + if (licenseOnly) return + + const [settingsResult, ltxResult, imgGenResult] = await Promise.all([ + ApiClient.getSettings(), + ApiClient.getLtxRecommendation(), + ApiClient.getImgGenRecommendation(), + ]) + if (!settingsResult.ok) { + logger.error(`Failed to fetch model recommendations: ${settingsResult.error.message}`) + return + } + if (!ltxResult.ok) { + logger.error(`Failed to fetch model recommendations: ${ltxResult.error.message}`) + return + } + if (!imgGenResult.ok) { + logger.error(`Failed to fetch model recommendations: ${imgGenResult.error.message}`) + return + } + + setInstallPath(settingsResult.data.modelsDir ?? '') + setHasSavedLtxApiKey(Boolean(settingsResult.data.hasLtxApiKey)) + + // Surface exactly what the install will download (base model, upscaler, text + // encoder, image model) with per-checkpoint sizes and info. Same cp set the + // installer actually downloads, so the preview can't drift from reality. + const cpIds = buildDownloadSteps(ltxResult.data, imgGenResult.data).flatMap((step) => step.cpIds) + const optionalCpIds = ltxResult.data.status === 'download' ? ltxResult.data.optional_cp_ids : [] + if (cpIds.length === 0 && optionalCpIds.length === 0) { + setDownloadItems([]) + setOptionalItems([]) + return + } + const describeResult = await ApiClient.describeCheckpoints({ cp_ids: [...cpIds, ...optionalCpIds] }) + if (!describeResult.ok) { + logger.error(`Failed to describe checkpoints: ${describeResult.error.message}`) + return + } + const described = describeResult.data.checkpoints + const isOptional = (item: CheckpointDescriptor) => optionalCpIds.includes(item.cp_id) + setDownloadItems(described.filter((item) => !isOptional(item))) + setOptionalItems(described.filter(isOptional)) + }, [licenseOnly]) + + const startDownloadStep = useCallback(async (step: DownloadStepSpec) => { + setDownloadProgress(null) + setDownloadError(null) + const result = await ApiClient.startModelDownload({ + type: step.type, + cp_ids: step.cpIds, + }) + if (!result.ok) { + throw new Error(result.error.message) + } + const downloadData = result.data + if (downloadData.status === 'started') { + setDownloadSessionId(downloadData.sessionId) + return + } + throw new Error('Unexpected response while starting model download.') + }, []) + + // Initialize + useEffect(() => { + const init = async () => { + try { + // Get video path for production (unpacked from asar) + 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 + setVideoPath('/splash/splash.mp4') + } + + await refreshModelRecommendations() + } catch (e) { + logger.error(`Init error: ${e}`) + } + } + init() + if (showLicenseStep) { + void fetchLicense() + } + }, [refreshModelRecommendations, showLicenseStep]) + + useEffect(() => { + if (!installPath) { + setAvailableSpace('...') + return + } + let cancelled = false + void (async () => { + try { + const result = await window.electronAPI.getFreeDiskSpace({ path: installPath }) + if (cancelled) return + setAvailableSpace(result.success ? formatBytes(result.bytes) : '—') + } catch { + if (!cancelled) setAvailableSpace('—') + } + })() + return () => { + cancelled = true + } + }, [installPath]) + + // Cycle install messages + useEffect(() => { + if (currentStep !== 'installing') return + let index = 0 + const interval = setInterval(() => { + index = (index + 1) % INSTALL_MESSAGES.length + setInstallMessage(INSTALL_MESSAGES[index]) + }, 4000) + return () => clearInterval(interval) + }, [currentStep]) + + // Poll download progress during installation + useEffect(() => { + if (currentStep !== 'installing' || !downloadSessionId) return + + const pollProgress = async () => { + const result = await ApiClient.getModelDownloadProgress({ sessionId: downloadSessionId }) + if (!result.ok) { + logger.error(`Progress poll error: ${result.error.message}`) + return + } + + const progress = result.data + setDownloadProgress(progress) + + if (progress.status === 'error') { + downloadQueueRef.current = [] + setDownloadError(progress.error || 'Download failed.') + } else if (progress.status === 'complete') { + const nextStep = downloadQueueRef.current.shift() ?? null + if (nextStep) { + await startDownloadStep(nextStep) + return + } + setTimeout(() => setCurrentStep('complete'), 600) + } + } + + pollProgress() + const interval = setInterval(pollProgress, 500) + return () => clearInterval(interval) + }, [currentStep, downloadSessionId, startDownloadStep]) + + // Start installation + const startInstallation = async () => { + setCurrentStep('installing') + try { + if (ltxApiKey.trim()) { + try { + await saveLtxApiKey(ltxApiKey.trim()) + } catch (e) { + logger.error(`Failed to save API key: ${e instanceof Error ? e.message : String(e)}`) + } + } + + const [ltxResult, imgGenResult] = await Promise.all([ + ApiClient.getLtxRecommendation(), + ApiClient.getImgGenRecommendation(), + ]) + if (!ltxResult.ok) { + throw new Error(ltxResult.error.message) + } + if (!imgGenResult.ok) { + throw new Error(imgGenResult.error.message) + } + const nextLtxRecommendation = ltxResult.data + const nextImgGenRecommendation = imgGenResult.data + + // Saving the key above drops the encoder from the recommendation, so an explicit opt-in + // has to be added back here rather than read off the fresh response. + const downloadSteps = buildDownloadSteps( + nextLtxRecommendation, + nextImgGenRecommendation, + optedInCpIds, + ) + if (downloadSteps.length === 0) { + setCurrentStep('complete') + return + } + + downloadQueueRef.current = downloadSteps.slice(1) + await startDownloadStep(downloadSteps[0]) + } catch (e) { + logger.error(`Download start error: ${e}`) + setDownloadError(e instanceof Error ? e.message : 'Failed to start model download.') + } + } + + const retryInstallation = () => { + setDownloadError(null) + downloadQueueRef.current = [] + startInstallation() + } + + // Handle next button + const handleNext = async () => { + setActionError(null) + if (currentStep === 'license') { + if (!licenseAccepted) return + setIsActionPending(true) + try { + if (onAcceptLicense) { + await onAcceptLicense() + } + if (licenseOnly) { + await onComplete() + return + } + setCurrentStep('location') + } catch (e) { + setActionError(e instanceof Error ? e.message : 'Failed to accept license.') + } finally { + setIsActionPending(false) + } + return + } + if (currentStep === 'location') { + startInstallation() + return + } + if (currentStep === 'complete') { + await handleFinish() + } + } + + const handleFinish = async () => { + setActionError(null) + setIsActionPending(true) + try { + await onComplete() + } catch (e) { + setActionError(e instanceof Error ? e.message : 'Failed to complete setup.') + } finally { + setIsActionPending(false) + } + } + + // Get button text + const getNextButtonText = () => { + if (currentStep === 'license') return licenseOnly ? 'Accept' : 'Next' + if (currentStep === 'location') return 'Install' + if (currentStep === 'complete') return 'Finish' + return 'Continue' + } + + // Check if next button should be disabled + const isNextDisabled = () => { + if (currentStep === 'license') return !licenseAccepted || isActionPending + if (currentStep === 'location') { + return installCpIds.length > 0 && (!allAuthorized || checkingAccess) + } + if (currentStep === 'complete') return isActionPending + return false + } + + return ( +
+ {/* Custom Title Bar */} +
+
+ + {/* Main Container */} +
+ {/* Header */} +
+
+ {/* LTX Logo */} + + + + + + Desktop +
+
+ + {/* Content Area */} +
+ {/* Step 1: Model License */} + {currentStep === 'license' && ( +
+

+ LTX-2 Model License +

+

+ The LTX-2 model is subject to the following license agreement. Please review and accept before downloading. +

+ +
+
+ {licenseError ? ( +
+ + + + + + {licenseError} + +
+ ) : licenseText === null ? ( +
+ + + + Loading license... +
+ ) : ( +
+
+                        {licenseText?.replace(/([^\n])\n([^\n])/g, '$1 $2')}
+                      
+
+ )} +
+ + +
+
+ )} + + {/* Step 2: Choose Location */} + {currentStep === 'location' && ( +
+

+ Choose Location +

+

+ Select where to install the model files. +

+ +
+
+ + +
+ +
+ Available: {availableSpace} +
+
+ + {/* What will be downloaded */} + {(requiredItems.length > 0 || allOptionalItems.length > 0) && ( +
+
+ + + Total: {formatBytes(totalDownloadBytes)} + +
+
+ {requiredItems.map((item) => ( + + ))} +
+ + {allOptionalItems.length > 0 && ( +
+ +

+ {allOptionalItems.every((item) => item.role === 'text_encoder') + ? "Your LTX API key covers this, so it isn't needed to generate. Download it to encode prompts on this computer instead — slower, but works offline and without a key." + : "Not required for the current setup. Tick any you want to keep on this computer."} +

+
+ {allOptionalItems.map((item) => ( + toggleOptIn(item) }} + /> + ))} +
+
+ )} +
+ )} + + {/* LTX API Key - Optional but saves ~25 GB download */} +
+
+ +
+ setLtxApiKey(e.target.value)} + placeholder="Enter API key to make the text encoder optional..." + style={{ + width: '100%', + background: '#1a1a1a', + border: '1px solid #333', + borderRadius: 8, + padding: '12px 14px', + color: '#ffffff', + fontSize: 13, + boxSizing: 'border-box' + }} + /> +

+ {ltxApiKey || hasSavedLtxApiKey ? ( + + ✓ Text encoder download is optional (using API instead). Tick it under Optional if you want it offline. + + ) : ( + 'If you have an LTX API key, entering it here makes the text encoder optional. ' + + 'The API encodes prompts faster than running the local encoder.' + )} +

+
+ + {/* HuggingFace Authentication */} +
+
+ +
+ {allAuthorized ? ( + hfAuthStatus === 'authenticated' ? ( +

+ ✓ Authenticated — gated models will download with your account. +

+ ) : ( + <> +

+ Optional for this install. Sign in if you later download gated models from Settings. +

+ + + ) + ) : ( + { + void startHuggingFaceLogin() + }} + checkError={checkError} + onRetryCheck={recheckAccess} + /> + )} +
+ +
+ )} + + {/* Step 3: Installing */} + {currentStep === 'installing' && ( +
+ {/* Video Section - fills container but leaves room for progress */} +
+ {/* Splash Video */} + + + {/* Video Credit */} +
+ Generated by PongFlongo +
+
+ + {/* Progress Section - fixed at bottom */} +
+ {downloadError ? ( +
+ + + + + + {downloadError} +
+ + +
+
+ ) : ( + <> + {/* Header row with status and percentage */} +
+ + {totalProgress > 85 ? 'Installing...' : 'Downloading...'} + + + {Math.round(totalProgress)}% + +
+ + {/* Progress Bar */} +
+
+
+ + {/* Download stats row */} +
+ {/* Current file */} + + {runningDownloadProgress?.current_downloading_file || installMessage} + + + {/* Speed and ETA */} +
+ {runningDownloadProgress && runningDownloadProgress.speed_bytes_per_sec > 0 && ( + + {(runningDownloadProgress.speed_bytes_per_sec / (1024 * 1024)).toFixed(1)} MB/s + + )} + {runningDownloadProgress && runningDownloadProgress.expected_total_bytes > 0 && ( + + {formatBytes(runningDownloadProgress.total_downloaded_bytes)} / {formatBytes(runningDownloadProgress.expected_total_bytes)} + + )} + {runningDownloadProgress && runningDownloadProgress.speed_bytes_per_sec > 0 && ( + + ETA: {getTimeRemaining()} + + )} +
+
+ + {/* Files progress */} + {runningDownloadProgress && runningDownloadProgress.all_files.length > 0 && ( +
+ File {runningDownloadProgress.completed_files.length + 1} of {runningDownloadProgress.all_files.length} +
+ )} + + )} +
+
+ )} + + {/* Step 4: Complete */} + {currentStep === 'complete' && ( +
+ {/* Success Icon */} +
+ + + +
+ +

+ Ready to Create +

+

+ LTX Video is installed. Start generating. +

+ + {/* Install Summary */} +
+
+ Location + + {installPath.split('\\').pop() || installPath} + +
+
+
+ )} +
+ + {/* Footer */} +
+
© 2026 Lightricks
+ +
+ {/* Next/Install/Finish Button */} + {currentStep !== 'installing' && ( + + )} +
+
+ {actionError && ( +
+ {actionError} +
+ )} +
+ +
+ ) +} diff --git a/frontend/components/FreeApiKeyBubble.tsx b/frontend/components/FreeApiKeyBubble.tsx new file mode 100644 index 0000000..7c1b361 --- /dev/null +++ b/frontend/components/FreeApiKeyBubble.tsx @@ -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 | 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 ( +
+
+
+

+ Speed up inference and save memory with free cloud text encoding and + prompt enhancement.{' '} + {' '} + to enable it. +

+

+ The free API key covers text encoding and prompt enhancement only. + Video generation via API requires a paid plan. +

+
+ +
+
+ ) +} diff --git a/frontend/components/GenerationErrorDialog.tsx b/frontend/components/GenerationErrorDialog.tsx new file mode 100644 index 0000000..81a052f --- /dev/null +++ b/frontend/components/GenerationErrorDialog.tsx @@ -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 ( +
+
+
+
+ +

Generation Failed

+
+ +
+ +
+

+ {dialogModel.humanMessage} +

+ +
+ + {detailsExpanded && ( +
+                {dialogModel.technicalDetails}
+              
+ )} +
+
+ +
+ {dialogModel.primaryAction ? ( + + ) : null} + {dialogModel.primaryAction ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/frontend/components/HfModelAccessGate.tsx b/frontend/components/HfModelAccessGate.tsx new file mode 100644 index 0000000..5cee9ec --- /dev/null +++ b/frontend/components/HfModelAccessGate.tsx @@ -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 ( +
+
+ + Couldn't verify Hugging Face access: {checkError} +
+ {onRetryCheck && ( + + )} +
+ ) + } + + const unauthorizedRepos = Object.entries(accessMap).filter(([, status]) => status === 'not_authorized') + if (unauthorizedRepos.length === 0) return null + + if (hfAuthStatus !== 'authenticated') { + return ( +
+
+ + + This model is gated on Hugging Face. Sign in, then accept the license to download. + +
+ +
+ ) + } + + return ( +
+

+ Accept the Hugging Face license for this model, then download. +

+ {unauthorizedRepos.map(([repoId]) => ( +
+ {repoId} + +
+ ))} +
+ ) +} diff --git a/frontend/components/ICLoraPanel.tsx b/frontend/components/ICLoraPanel.tsx new file mode 100644 index 0000000..69cabc3 --- /dev/null +++ b/frontend/components/ICLoraPanel.tsx @@ -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> +type ModelCheckpointID = NonNullable[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(null) + const [inputVideoPath, setInputVideoPath] = useState(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(null) + const referenceImageUrl = referenceImagePath ? pathToFileUrl(referenceImagePath) : null + const [isReferenceDragOver, setIsReferenceDragOver] = useState(false) + + const [internalCondType, setInternalCondType] = useState('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(null) + const [isExtracting, setIsExtracting] = useState(false) + + const [requiredIcLoraCpIds, setRequiredIcLoraCpIds] = useState([]) + const [icLoraSupported, setIcLoraSupported] = useState(true) + const [isCheckingIcLora, setIsCheckingIcLora] = useState(false) + const [isDownloadingIcLora, setIsDownloadingIcLora] = useState(false) + const [downloadProgress, setDownloadProgress] = useState(null) + const [downloadError, setDownloadError] = useState(null) + const [downloadSessionId, setDownloadSessionId] = useState(null) + const [extractError, setExtractError] = useState(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 | 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 ( +
+
+
+ + IC-LoRA / Style Transfer +
+ {inputVideoUrl && ( +
+ + +
+ )} +
+ + {isLocalMode && ( +
+ +
+ )} + + {showUnsupportedGate ? ( +
+
+
+
+ +
+
+

Built-in control needs LTX 2.3

+

+ 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. +

+
+
+
+
+ ) : showDownloadGate ? ( +
+
+
+
+ +
+
+

Download Required: IC-LoRA Resources

+

+ Editing is locked until all IC-LoRA preprocessing models are available locally. +

+
+
+ +
+ {isCheckingIcLora ? ( +
+ + Checking model availability... +
+ ) : ( + <> +
+ {gateItems.map(item => ( +
+
+ {item.label} + + {item.status} + +
+
+
+
+
{item.progress}%
+
+ ))} +
+ {downloadError && ( +
{downloadError}
+ )} +
+ + +
+ + )} +
+
+
+ ) : ( +
+
+
+ Input + {inputVideoPath && ( + + {inputVideoPath.split(/[\\/]/).pop()} + + )} + +
+
{ e.preventDefault(); setIsDragOver(true) }} + onDragLeave={() => setIsDragOver(false)} + onDrop={handleDrop} + > + {inputVideoUrl ? ( + isImage ? ( + Input setSourceDims({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })} + /> + ) : ( +
+
+ + {showConditioningPreview && ( +
+
+ Conditioning + +
+
+ {isExtracting && ( +
+ +
+ )} + {conditioningPreview ? ( + Conditioning preview + ) : ( +
+

+ {inputVideoUrl ? 'Scrub the input video to see conditioning preview' : 'Import a video to preview conditioning'} +

+
+ )} +
+
+ )} + + {/* Reference image column: takes the conditioning slot for catalog IC-LoRAs that opt in. */} + {allowsReferenceImage && !showConditioningPreview && ( +
+
+ Reference + {referenceImagePath && ( + + {referenceImagePath.split(/[\\/]/).pop()} + + )} + {referenceImagePath ? ( +
+ + +
+ ) : ( + + )} +
+
{ e.preventDefault(); setIsReferenceDragOver(true) }} + onDragLeave={() => setIsReferenceDragOver(false)} + onDrop={handleDropReference} + > + {referenceImageUrl ? ( + Reference + ) : ( +
+
+ +
+

Drop or import a reference image

+ +
+ )} +
+
+ )} + + {/* Canvas editor column: takes the middle slot for outpainting (position_canvas control). */} + {showOutpaintCanvas && !showConditioningPreview && ( +
+
+ Canvas +
+ {sourceDims && outpaintPads ? ( + onOutpaintPadsChange?.(p)} + /> + ) : ( +
+

Import a video to set the outpaint canvas

+
+ )} +
+ )} + + {/* Output column */} +
+
+ Output +
+
+ {_outputVideoPath ? ( +
+
+
+ )} + + {extractError && ( +
+
+ + {extractError} +
+
+ )} +
+ ) +} diff --git a/frontend/components/IcLoraAdvancedPanel.tsx b/frontend/components/IcLoraAdvancedPanel.tsx new file mode 100644 index 0000000..7967e52 --- /dev/null +++ b/frontend/components/IcLoraAdvancedPanel.tsx @@ -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 ( +
+ {label} + {children} +
+ ) +} + +const triggerValue = (text: string) => ( + <> + {text} + + +) + +// 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 ( +
+ Advanced + + + onIcLoraSkipStage2Change?.(v === 'skip')} + options={[ + { value: 'keep', label: 'On (two-stage)' }, + { value: 'skip', label: 'Off (stage 1 only)' }, + ]} + trigger={triggerValue(icLoraSkipStage2 ? 'Off' : 'On')} + /> + + {/* 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. */} + + onIcLoraUseLoraInStage2Change?.(v === 'on')} + options={[ + { value: 'off', label: 'Off (refine from prompt)' }, + { value: 'on', label: 'On (keep effect, source res)' }, + ]} + trigger={triggerValue(icLoraUseLoraInStage2 ? 'On' : 'Off')} + /> + + {!icLoraSkipStage2 && icLoraUseLoraInStage2 && (icLoraResolutionOptions?.length ?? 0) > 1 && ( + + onIcLoraResolutionKeyChange?.(v)} + options={icLoraResolutionOptions!.map((o) => ({ value: o.key, label: o.label }))} + trigger={triggerValue( + (icLoraResolutionOptions!.find((o) => o.key === icLoraResolutionKey)?.label ?? 'Original').split(' ')[0], + )} + /> + + )} + {icLoraSkipStage2 && ( + + 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)}`, + )} + /> + + )} + + 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()))} + /> + + + 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))} + /> + +
+ ) +} diff --git a/frontend/components/IcLoraSettingsControls.tsx b/frontend/components/IcLoraSettingsControls.tsx new file mode 100644 index 0000000..0540cec --- /dev/null +++ b/frontend/components/IcLoraSettingsControls.tsx @@ -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 = { + 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 + 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. */} + onIcLoraSelectorChange?.(v)} + options={icLoraSelectorOptions ?? []} + trigger={ + <> + {(icLoraSelectorOptions ?? []).find(o => o.value === icLoraSelectorValue)?.label ?? 'Canny Edges'} + + + } + /> + {!isCatalogIcLora && icLoraCondType === 'custom' && ( + <> +
+ onIcLoraCustomRefChange?.(v || null)} + options={(availableIcLoras ?? []).map(m => ({ value: m.path, label: m.name }))} + trigger={ + <> + + {availableIcLoras?.find(m => m.path === icLoraCustomRef)?.name + ?? (availableIcLoras?.length ? 'Select IC-LoRA' : 'No IC-LoRAs found')} + + + + } + /> + + )} + {!isCatalogIcLora && ( + <> +
+ 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={ + <> + STR + {(icLoraStrength ?? 1.0).toFixed(2)} + + + } + /> + + )} + {/* 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 ( + +
+ 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={ + <> + {title} + {controlOptionLabel(c, current)} + + + } + /> + + ) + })} + {/* 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. */} +
+ +
+ LORA + onIcLoraLoraStrengthChange?.(parseFloat(e.target.value))} + className="w-16 h-1 accent-white cursor-pointer" + aria-label="LoRA strength" + /> + {(icLoraLoraStrength ?? 1.0).toFixed(2)} +
+
+ + ) +} diff --git a/frontend/components/ImportTimelineModal.tsx b/frontend/components/ImportTimelineModal.tsx new file mode 100644 index 0000000..77af86a --- /dev/null +++ b/frontend/components/ImportTimelineModal.tsx @@ -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('select') + const [parsedTimeline, setParsedTimeline] = useState(null) + const [mediaRefs, setMediaRefs] = useState([]) + const [mediaStatus, setMediaStatus] = useState>({}) + const [error, setError] = useState('') + const [importError, setImportError] = useState('') + 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({ + total: 0, + completed: 0, + skipped: 0, + currentName: '', + }) + const fileInputRef = useRef(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) => { + const nextStatus: Record = {} + 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> => { + const availabilityById: Record = {} + 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) => { + 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() + 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 ( +
+
+ {/* Header */} +
+
+
+ +
+
+

Import Timeline

+

+ Premiere Pro XML, DaVinci Resolve XML, Final Cut Pro XML/FCPXML +

+
+
+ +
+ + {/* Body */} +
+ {/* Step 1: File Selection */} + {step === 'select' && ( +
+
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" + > +
+ +
+

Click to select timeline file

+

Supports .xml (FCP 7 XML), .fcpxml

+
+ + + +
+

How to export from your NLE:

+
+

Premiere Pro: File → Export → Final Cut Pro XML

+

DaVinci Resolve: File → Export Timeline → FCP 7 XML (.xml)

+

Final Cut Pro: File → Export XML

+

+ AAF files are binary and cannot be imported directly. Please export as XML instead. +

+
+
+
+ )} + + {/* Parsing indicator */} + {step === 'parsing' && ( +
+ +

Parsing timeline...

+
+ )} + + {/* Error */} + {step === 'error' && ( +
+
+
+ +
+

Import Error

+

{error}

+
+
+
+ +
+ )} + + {/* Step 2: Relink media */} + {step === 'relink' && parsedTimeline && ( +
+ {/* Timeline info */} +
+ + {expandedInfo && ( +
+
+ Name: + {parsedTimeline.name} +
+
+ Format: + + {parsedTimeline.format === 'fcp7xml' ? 'FCP 7 XML' : parsedTimeline.format === 'fcpxml' ? 'FCPXML' : parsedTimeline.format} + +
+
+ FPS: + {parsedTimeline.fps.toFixed(2)} +
+
+ Duration: + {parsedTimeline.duration.toFixed(1)}s +
+ {parsedTimeline.width && parsedTimeline.height && ( +
+ Resolution: + {parsedTimeline.width}x{parsedTimeline.height} +
+ )} +
+ Clips: + {parsedTimeline.clips.length} +
+
+ Video Tracks: + {parsedTimeline.videoTrackCount} +
+
+ Audio Tracks: + {parsedTimeline.audioTrackCount} +
+
+ )} +
+ + {/* Media files */} +
+
+ +
+ + +
+
+ + {/* Availability bar */} +
+
+
0 ? 'bg-amber-500' : 'bg-red-500'}`} + style={{ width: `${totalCount > 0 ? (readyCount / totalCount) * 100 : 0}%` }} + /> +
+
+ + {expandedMedia && ( +
+ {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 ( +
+ + +
+
+ {isCopying ? ( + + ) : isPositive ? ( + + ) : isSkipped ? ( + + ) : ( + + )} + + {ref.name} + +
+

+ {ref.path || '(no path)'} +

+
+ + {isMissing && ( + + )} +
+ ) + })} + + {mediaRefs.length === 0 && ( +
+ No media files referenced in this timeline. +
+ )} +
+ )} +
+
+ )} +
+ + {/* Footer */} + {step === 'relink' && ( + <> +
+
+ {importError && ( + {importError} + )} + {!importError && !allFound && totalCount > 0 && ( + + {totalCount - readyCount} missing file{totalCount - readyCount !== 1 ? 's' : ''} — clips with missing media will be placeholders + + )} + {!importError && allFound && totalCount > 0 && ( + All media files found + )} + {!importError && totalCount === 0 && ( + No media references to link + )} +
+
+ + +
+
+ + {isImporting && ( +
+
+ + + Copying media {importProgress.completed}/{importProgress.total} + + + {importProgress.skipped} skipped + +
+
+
+
+ {importProgress.currentName && ( +

+ {importProgress.currentName} +

+ )} +
+ )} + + )} +
+
+ ) +} diff --git a/frontend/components/KeyboardShortcutsModal.tsx b/frontend/components/KeyboardShortcutsModal.tsx new file mode 100644 index 0000000..ed2a6a9 --- /dev/null +++ b/frontend/components/KeyboardShortcutsModal.tsx @@ -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 = { + 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(null) + const [showPresetDropdown, setShowPresetDropdown] = useState(false) + const [showSaveDialog, setShowSaveDialog] = useState(false) + const [savePresetName, setSavePresetName] = useState('') + const [selectedCategory, setSelectedCategory] = useState(null) + const recordRef = useRef(null) + + // Visual keyboard state + const [kbModCtrl, setKbModCtrl] = useState(false) + const [kbModShift, setKbModShift] = useState(false) + const [kbModAlt, setKbModAlt] = useState(false) + const [hoveredActionId, setHoveredActionId] = useState(null) + const [draggedActionId, setDraggedActionId] = useState(null) + const [dropTargetKey, setDropTargetKey] = useState(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() + 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() + 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(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 = {} + 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 ( +
{ if (e.target === e.currentTarget && !recordingAction) setEditorOpen(false) }} + > +
+ {/* Header */} +
+
+ +
+
+

Keyboard Shortcuts

+

Customize keybindings — drag actions onto keys or click Edit

+
+ + +
+ + {/* Toolbar: Preset selector + Search + Actions */} +
+ {/* Preset dropdown */} +
+ + {showPresetDropdown && ( +
+ {presets.map(p => ( +
+ + {/* Delete button — only for user-created (non-built-in) presets */} + {!p.builtIn && ( + + )} +
+ ))} +
+ )} +
+ + {/* Search */} +
+ + 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" + /> +
+ + {/* Reset button */} + + + {/* Save as custom */} +
+ + {showSaveDialog && ( +
+

Save as custom preset:

+ 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 + /> + +
+ )} +
+
+ + {/* Conflict warning */} + {conflicts.size > 0 && ( +
+ + + {conflicts.size} shortcut conflict{conflicts.size > 1 ? 's' : ''} detected — some keys are assigned to multiple actions + +
+ )} + + {/* ══════════════ Visual Keyboard ══════════════ */} + {showKeyboard && ( +
+ {/* Modifier toggles */} +
+ Modifiers: + {([ + { 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 => ( + + ))} +
+ {/* Category legend */} +
+ {categories.map(cat => { + const c = CATEGORY_COLORS[cat] + return ( +
+
+ {cat} +
+ ) + })} +
+
+ + {/* Keyboard rows */} +
+ {KB_ROWS.map((row, rowIdx) => ( +
+ {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 ( +
handleKeyClick(kbKey.id)} + title={ + assigned + ? `${assigned.action.label} (${assigned.action.category})` + : isModifier + ? kbKey.label + : 'Unassigned — drag an action here' + } + > + {/* Key label */} + + {kbKey.label} + + {/* Assigned action label (truncated) */} + {assigned && !isModifier && ( + + {assigned.action.label.replace(/ Tool$/, '').replace(/ \(.*\)$/, '')} + + )} + {/* Conflict indicator */} + {hasConflict && ( +
+ )} +
+ ) + })} +
+ ))} +
+ + {/* Drag hint */} + {draggedActionId && ( +
+ Drop on a key to assign — current modifiers: { + [kbModCtrl && 'Ctrl', kbModShift && 'Shift', kbModAlt && 'Alt'].filter(Boolean).join('+') || 'None' + } +
+ )} +
+ )} + + {/* Category tabs */} +
+ + {categories.map(cat => { + const c = CATEGORY_COLORS[cat] + return ( + + ) + })} +
+ + {/* Actions list */} +
+ {categories.map(cat => { + const actions = groupedActions[cat] + if (!actions || actions.length === 0) return null + const catColor = CATEGORY_COLORS[cat] + return ( +
+ {/* Category header */} +
+
+ {cat} +
+ {/* 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 ( +
{ 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 && ( + + )} + + {/* Category dot */} +
+ + {/* Action label */} +
+ + {action.label} + + {action.description && ( + {action.description} + )} + {hasConflict && ( + + )} +
+ + {/* Current binding(s) */} +
+ {isRecording ? ( +
+ Press a key... + +
+ ) : ( + <> + {combos.length === 0 ? ( + Unassigned + ) : ( + combos.map((combo, i) => { + const comboKey = formatKeyCombo(combo) + const conflicting = conflicts.has(comboKey) + return ( + + {comboKey} + + ) + }) + )} + + )} +
+ + {/* Edit / Clear buttons */} + {!isRecording && ( +
+ + {combos.length > 0 && ( + + )} +
+ )} +
+ ) + })} +
+ ) + })} +
+ + {/* Footer */} +
+ + {ACTION_REGISTRY.length} actions · Drag actions onto keys or click "Edit" to record + + +
+
+
+ ) +} diff --git a/frontend/components/KeyframePreview.tsx b/frontend/components/KeyframePreview.tsx new file mode 100644 index 0000000..ec81e55 --- /dev/null +++ b/frontend/components/KeyframePreview.tsx @@ -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 ( +
event.preventDefault()} + onDrop={(event) => { + event.preventDefault() + const paths = imagePathsFromDataTransfer(event.dataTransfer) + if (paths.length > 0) onDropImages(paths) + }} + > +
+ {keyframe ? ( + Keyframe at the playhead + ) : ( +
+ Add keyframes to preview your sequence +
+ )} +
+
+ ) +} diff --git a/frontend/components/KeyframeStrengthRail.tsx b/frontend/components/KeyframeStrengthRail.tsx new file mode 100644 index 0000000..cd03578 --- /dev/null +++ b/frontend/components/KeyframeStrengthRail.tsx @@ -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(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 ( +
{ + 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)) + }} + > +
+
+
+
+
+ ) +} diff --git a/frontend/components/KeyframeTimeline.tsx b/frontend/components/KeyframeTimeline.tsx new file mode 100644 index 0000000..eddb388 --- /dev/null +++ b/frontend/components/KeyframeTimeline.tsx @@ -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(null) + const dragRef = useRef(null) + const onDragFrameChangeRef = useRef(onDragFrameChange) + onDragFrameChangeRef.current = onDragFrameChange + const [drag, setDrag] = useState(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) => { + 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) => { + 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 ( +
+
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) + }} + > +
+
+ + {displayed.map((keyframe) => { + const markerFrame = keyframe.frameIndex + const source = keyframes.find((item) => item.id === keyframe.id) ?? keyframe + const timecode = formatKeyframeTimecode(markerFrame, fps) + return ( +
event.preventDefault()} + onDrop={(event) => { + event.preventDefault() + event.stopPropagation() + onImagesDrop(event.dataTransfer, keyframe.id) + }} + > + + onStrengthChange(keyframe.id, strength)} + /> +
+ + +
+ { + 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() + } + }} + /> +
+ ) + })} +
+
+ 0 + {lastFrame} +
+
+ ) +} diff --git a/frontend/components/LogViewer.tsx b/frontend/components/LogViewer.tsx new file mode 100644 index 0000000..bc85d00 --- /dev/null +++ b/frontend/components/LogViewer.tsx @@ -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 , +// 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 + ? {part} + : {part}, + ) +} + +export function LogViewer({ isOpen, onClose, embedded = false }: LogViewerProps) { + const [logs, setLogs] = useState([]) + 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(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(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 = ( +
+ {/* Header */} +
+
+

Logs

+ + {logPath} + +
+
+ + + + + {!embedded && ( + + )} +
+
+ + {/* Search */} +
+ + 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 && ( + + {matchCount} matching line{matchCount === 1 ? '' : 's'} + + )} +
+ + {/* Log content */} +
+ {logs.length === 0 ? ( +
+ No logs yet... +
+ ) : ( +
+ {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 ( +
+ {highlightMatches(line, searchQuery)} +
+ ) + })} +
+ )} +
+ + {/* Footer */} +
+ {logs.length} lines {searchQuery ? '(full session)' : '(last 200)'} +
+ {/* 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. */} + + Auto-refreshing every 2s +
+
+
+ ) + + if (embedded) { + return panel + } + + return ( +
+ {panel} +
+ ) +} diff --git a/frontend/components/LoraInfoPopover.tsx b/frontend/components/LoraInfoPopover.tsx new file mode 100644 index 0000000..fb063cb --- /dev/null +++ b/frontend/components/LoraInfoPopover.tsx @@ -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[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 ), 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(null) + const popRef = useRef(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 + const triggerAttrs = { popovertarget: id } as unknown as React.ButtonHTMLAttributes + + + // 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 ( + <> + +
+
{name}
+
+ {sections.map((s, i) => ( +
+
{s.title}
+ {Array.isArray(s.body) ? ( +
    + {s.body.map((b, j) =>
  • {b}
  • )} +
+ ) : ( +

{s.body}

+ )} +
+ ))} +
+ {showCommunityDisclaimer && ( +

+ {COMMUNITY_DISCLAIMER} +

+ )} + {repoId && ( + + )} +
+ + ) +} diff --git a/frontend/components/LoraLibraryModal.tsx b/frontend/components/LoraLibraryModal.tsx new file mode 100644 index 0000000..a145f8d --- /dev/null +++ b/frontend/components/LoraLibraryModal.tsx @@ -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 ( + + Connect HuggingFace + + ) : undefined} + > + {syncError && ( +
+ {syncError} +
+ )} + {visibleItems.length === 0 ? ( +
+ {kind === 'ic-lora' ? 'No IC-LoRAs available.' : 'No LoRAs found. Downloaded and installed LoRAs will appear here.'} +
+ ) : ( +
+ {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 ( + ({ + id: v.id, + label: v.label, + sizeBytes: v.sizeBytes, + downloaded: e.downloadedVariantIds?.includes(v.id), + })), + defaultVariantId: e.defaultVariantId, + }} + infoSlot={( + + )} + onDownload={onDownload} + onRetry={onDownload} + onRequestAccess={() => { if (e.repoId) void window.electronAPI.openHuggingFaceRepo({ repoId: e.repoId }) }} + onUse={(_id, variantId) => { if (onSelect(e, variantId)) onClose() }} + /> + ) + })} +
+ )} +
+ ) +} diff --git a/frontend/components/LtxApiKeyInput.tsx b/frontend/components/LtxApiKeyInput.tsx new file mode 100644 index 0000000..662b428 --- /dev/null +++ b/frontend/components/LtxApiKeyInput.tsx @@ -0,0 +1,72 @@ +import React, { forwardRef } from 'react' +import { ExternalLink, KeyRound } from 'lucide-react' + +interface LtxApiKeyInputProps { + value: string + onChange: (e: React.ChangeEvent) => void + placeholder?: string + id?: string + stopPropagation?: boolean + className?: string +} + +export const LtxApiKeyInput = forwardRef( + ({ value, onChange, placeholder = 'Paste your API key', id, stopPropagation, className }, ref) => { + return ( +
+ + 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" + /> +
+ ) + }, +) +LtxApiKeyInput.displayName = 'LtxApiKeyInput' + +interface ApiKeyHelperRowProps { + stopPropagation?: boolean + label?: string + onOpenKey?: () => void +} + +export function ApiKeyHelperRow({ stopPropagation, label = 'Get API key', onOpenKey }: ApiKeyHelperRowProps) { + return ( +
+ Your key stays in your local app settings. + +
+ ) +} + +interface LtxApiKeyHelperRowProps { + stopPropagation?: boolean +} + +export function LtxApiKeyHelperRow({ stopPropagation }: LtxApiKeyHelperRowProps) { + return ( + window.electronAPI.openLtxApiKeyPage()} + /> + ) +} diff --git a/frontend/components/LtxLogo.tsx b/frontend/components/LtxLogo.tsx new file mode 100644 index 0000000..9041641 --- /dev/null +++ b/frontend/components/LtxLogo.tsx @@ -0,0 +1,18 @@ +interface LtxLogoProps { + className?: string +} + +export function LtxLogo({ className = "h-6" }: LtxLogoProps) { + return ( + + + + + + ) +} diff --git a/frontend/components/LtxUpgradePrompt.css b/frontend/components/LtxUpgradePrompt.css new file mode 100644 index 0000000..652fe70 --- /dev/null +++ b/frontend/components/LtxUpgradePrompt.css @@ -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%); + } +} diff --git a/frontend/components/LtxUpgradePrompt.tsx b/frontend/components/LtxUpgradePrompt.tsx new file mode 100644 index 0000000..6336651 --- /dev/null +++ b/frontend/components/LtxUpgradePrompt.tsx @@ -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, { status: 'upgrade' }> +type ModelCheckpointID = NonNullable< + NonNullable>['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 +} + +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('idle') + const [downloadSessionId, setDownloadSessionId] = useState(null) + const [downloadProgress, setDownloadProgress] = useState | null>( + null, + ) + const [errorMessage, setErrorMessage] = useState(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 ( +
+
{ + if (canClose) onClose() + }} + /> + +
+
+
+
+
+
+ +
+
+
+ Optional Upgrade +
+

+ LTX Model Upgrade Detected! +

+

+ Upgrade target: {recommendation.ltx_model_id} +

+
+
+ + {canClose && ( +
+ + +
+ )} +
+ +
+ {recommendation.upgrade_message ? ( + <> +

What's new in this version

+
    + {recommendation.upgrade_message.split('\n').map((line) => line.trim()).filter(Boolean).map((line, i) => ( +
  • + • + {line} +
  • + ))} +
+ + ) : ( +

+ A better LTX checkpoint is ready for this install. +

+ )} + {hasOldToDelete && ( +

+ {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.'} +

+ )} + + + + {wantsUpgrade && hasOldToDelete && ( + + )} +
+ + {wantsUpgrade && ( +
+ {!allAuthorized && ( +
+ { + void startHuggingFaceLogin() + }} + checkError={checkError} + onRetryCheck={recheckAccess} + /> +
+ )} + {canStartUpgrade && ( +
+
+
+

Ready when you are

+

+ This will download {recommendation.cps_to_download.length} checkpoint + {recommendation.cps_to_download.length === 1 ? '' : 's'} + {hasOldToDelete && deleteOld ? ' and remove the old one.' : '.'} +

+
+ +
+
+ )} +
+ )} + + {phase !== 'idle' && ( +
+
+
+

+ {phase === 'starting' ? 'Preparing your upgrade...' : phase === 'downloading' ? 'Downloading update...' : 'Finishing up...'} +

+

+ {phase === 'finishing' + ? (deleteOld && hasOldToDelete ? 'Cleaning up the previous checkpoint files.' : 'Finalizing the upgrade.') + : 'Keep this window open while the new checkpoint is downloaded.'} +

+
+
+ + Working +
+
+ +
+
+ + {phase === 'starting' + ? 'Waiting for the download session to start' + : `Checkpoint progress ${Math.min(completedCount + (runningProgress ? 1 : 0), totalCount)} / ${totalCount}`} + + {Math.round(totalProgress)}% +
+ +
+ {phase === 'starting' ? ( +
+ ) : ( +
+ )} +
+ + {runningProgress?.current_downloading_file && ( +
+
+ Current file + {Math.round(runningProgress.current_file_progress)}% +
+
+ {formatCheckpointId(runningProgress.current_downloading_file)} +
+
+ )} +
+
+ )} + + {errorMessage && ( +
+ + {errorMessage} +
+ )} +
+
+
+ ) +} diff --git a/frontend/components/MenuBar.tsx b/frontend/components/MenuBar.tsx new file mode 100644 index 0000000..943d8a4 --- /dev/null +++ b/frontend/components/MenuBar.tsx @@ -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(null) + const [hoverMenuId, setHoverMenuId] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [highlightedResult, setHighlightedResult] = useState(0) + const menuBarRef = useRef(null) + const searchInputRef = useRef(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
+ } + + return ( + + ) + } + + return ( +
+
+ {menus.map(menu => { + const isActive = activeMenuId === menu.id + const isHelpMenu = menu.id === 'help' + + return ( +
+ + + {/* Dropdown */} + {isActive && ( +
+ {/* Help menu has search */} + {isHelpMenu && ( +
+
+ + setSearchQuery(e.target.value)} + onKeyDown={handleSearchKeyDown} + className="flex-1 bg-transparent text-[13px] text-white placeholder-zinc-500 outline-none" + autoFocus + /> +
+ {/* Search results */} + {searchQuery && ( +
+ {searchResults.length === 0 ? ( +
No results
+ ) : ( + searchResults.map((result, i) => ( + + )) + )} +
+ )} +
+ )} + + {/* Regular menu items */} + {menu.items.map((item, i) => renderMenuItem(item, i))} +
+ )} +
+ ) + })} +
+ {rightContent && ( +
+ {rightContent} +
+ )} +
+ ) +} diff --git a/frontend/components/MultiKeyframePanel.tsx b/frontend/components/MultiKeyframePanel.tsx new file mode 100644 index 0000000..29e6ee5 --- /dev/null +++ b/frontend/components/MultiKeyframePanel.tsx @@ -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(null) + const replaceIdRef = useRef(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 ( +
+
+
+ + Keyframes + {keyframes.length}/{maxCount} +
+ +
+ + {lastFrame === null ? ( +
+ Choose a duration to place keyframes +
+ ) : ( + { + 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) + }} + /> + )} + + { + applyPaths(imagePathsFromFiles(event.target.files ?? []), replaceIdRef.current) + replaceIdRef.current = null + event.target.value = '' + }} + /> +
+ ) +} diff --git a/frontend/components/OutpaintCanvasEditor.tsx b/frontend/components/OutpaintCanvasEditor.tsx new file mode 100644 index 0000000..8eb035c --- /dev/null +++ b/frontend/components/OutpaintCanvasEditor.tsx @@ -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 = { + 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 ( +
+
+
+ {/* Output frame: the green-filled generate region around the source. */} +
+ {/* Source silhouette (kept region) — fixed size, centred. */} +
+ {HANDLES.map((h) => { + const { x, y } = handlePos(h) + return ( +
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' }} + /> + ) + })} +
+
+
+
+ {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 ( + + ) + })} +
+ + {sourceWidth}×{sourceHeight} → {outW}×{outH} + +
+
+ ) +} diff --git a/frontend/components/PythonSetup.tsx b/frontend/components/PythonSetup.tsx new file mode 100644 index 0000000..3b1746f --- /dev/null +++ b/frontend/components/PythonSetup.tsx @@ -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(null) + const [error, setError] = useState(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 ( +
+ {/* Custom Title Bar */} +
+ LTX Desktop +
+ + {/* Main Container */} +
+ {/* Header */} +
+
+ + + + + +
+
+ + {/* Content Area */} +
+ {/* Video Section */} +
+ +
+ + {/* Progress Section */} +
+ {error ? ( +
+ + + + + + {error} + +
+ ) : ( + <> + {/* Header row */} +
+ + {statusLabel} + + + {percent}% + +
+ + {/* Progress Bar */} +
+
+
+ + {/* Stats row */} +
+ + {progress?.status === 'extracting' + ? 'Setting up Python environment...' + : 'First-time setup — this only happens once'} + + +
+ {progress && progress.speed > 0 && ( + + {(progress.speed / (1024 * 1024)).toFixed(1)} MB/s + + )} + {progress && progress.downloadedBytes > 0 && ( + + {formatBytes(progress.downloadedBytes)}{progress.totalBytes > 0 ? ` / ${formatBytes(progress.totalBytes)}` : ''} + + )} + {progress && progress.speed > 0 && progress.totalBytes > 0 && ( + + ETA: {getTimeRemaining()} + + )} +
+
+ + )} +
+
+ + {/* Footer */} +
+
© 2026 Lightricks
+
+
+
+ ) +} diff --git a/frontend/components/RetakePanel.tsx b/frontend/components/RetakePanel.tsx new file mode 100644 index 0000000..337943c --- /dev/null +++ b/frontend/components/RetakePanel.tsx @@ -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(null) + const filmstripRef = useRef(null) + + const [videoPath, setVideoPath] = useState(initialVideoPath || null) + const [videoDuration, setVideoDuration] = useState(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 ( + <> +
+
+ +
+ +
handleFilmstripMouseDown(e, 'range')} + /> + +
+ +
handleFilmstripMouseDown(e, 'start')} + > +
+
+
+
+ +
handleFilmstripMouseDown(e, 'end')} + > +
+
+
+
+ +
+ + {formatTimecode(selEnd - selStart)} + +
+ + ) + }, [selStart, selEnd, draggingHandle, handleFilmstripMouseDown]) + + const renderLabels = useCallback(() => ( +
+ {formatTimecode(selStart)} + Duration: {formatTimecode(selEnd - selStart)} + {formatTimecode(selEnd)} +
+ ), [selStart, selEnd]) + + return ( + draggingRef.current} + filmstripOverlay={renderTrimOverlay} + belowFilmstrip={renderLabels} + /> + ) +} diff --git a/frontend/components/SelectedLoraInfo.tsx b/frontend/components/SelectedLoraInfo.tsx new file mode 100644 index 0000000..e490d37 --- /dev/null +++ b/frontend/components/SelectedLoraInfo.tsx @@ -0,0 +1,32 @@ +import { LoraInfoPopover } from './LoraInfoPopover' +import type { components } from '../generated/backend-openapi' + +type Sections = NonNullable + +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 ( +
+ {items.map((it, i) => ( + + {it.name} + + + ))} +
+ ) +} diff --git a/frontend/components/SettingsDropdown.tsx b/frontend/components/SettingsDropdown.tsx new file mode 100644 index 0000000..0495db1 --- /dev/null +++ b/frontend/components/SettingsDropdown.tsx @@ -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 = ( + + ) + + return ( +
+ {tooltip && !isOpen ? {triggerButton} : triggerButton} + + {isOpen && createPortal( +
+
{title}
+ {/* 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. */} +
+ {options.map(option => ( +
+ + {option.disabled && option.tooltip && ( +
+ {option.tooltip} +
+ )} +
+ ))} +
+
, + document.body, + )} +
+ ) +} diff --git a/frontend/components/SettingsModal.tsx b/frontend/components/SettingsModal.tsx new file mode 100644 index 0000000..191254d --- /dev/null +++ b/frontend/components/SettingsModal.tsx @@ -0,0 +1,1655 @@ +import { AlertCircle, Check, Download, Film, Folder, HardDrive, Info, KeyRound, Settings, Sparkles, X, Zap } from 'lucide-react' +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { Button } from './ui/button' +import { BaseModelSection } from './settings/BaseModelSection' +import { useAppSettings, type AppSettings, DEFAULT_GEMINI_MODEL } from '../contexts/AppSettingsContext' +import { ApiClient, type ApiSuccessOf } from '../lib/api-client' +import { logger } from '../lib/logger' +import { ApiKeyHelperRow, LtxApiKeyInput, LtxApiKeyHelperRow } from './LtxApiKeyInput' +import { HfModelAccessGate } from './HfModelAccessGate' +import { useHfAuth } from '../hooks/use-hf-auth' +import { useHfModelAccess } from '../hooks/use-hf-model-access' +import type { AppUpdate } from '../hooks/use-app-update' +import type { UpdateStatePayload } from '../../shared/electron-api-schema' + +export type SettingsInitialReason = 'geminiKeyRequired' + +interface SettingsModalProps { + isOpen: boolean + onClose: () => void + initialTab?: TabId + initialReason?: SettingsInitialReason + update: AppUpdate + onOpenUpdate: () => void + onCheckForUpdates: () => void +} + +type TabId = 'general' | 'models' | 'apiKeys' | 'promptEnhancer' | 'about' + +/** A checkpoint this modal can download: the text encoder or the optional prompt enhancer. */ +type TextEncodingCp = NonNullable['cp_to_download']> +type GeminiModelOption = ApiSuccessOf<'listGeminiModels'>['models'][number] + +/** Focuses an API Keys tab input once the modal has switched to that tab. + * Shared by the LTX and FAL key inputs — each call gets its own ref/pending state. + * Pass `sectionRef` + `containerRef` to scroll a whole section (e.g. Gemini heading + banner) + * into the tab body; focus then uses preventScroll so the input doesn't yank the banner back + * off-screen. */ +function useApiKeyFocus( + isOpen: boolean, + activeTab: TabId, + setActiveTab: (tab: TabId) => void, + sectionRef?: React.RefObject, + containerRef?: React.RefObject, +) { + const inputRef = useRef(null) + const [pending, setPending] = useState(false) + + useEffect(() => { + if (!pending) return + // Closing the modal or leaving API Keys cancels the timeout below; drop `pending` so a + // later open can't replay scroll/focus that nobody requested this time. + if (!isOpen || activeTab !== 'apiKeys') { + setPending(false) + return + } + + // Wait for the API Keys tab to paint before scrolling. Clearing `pending` in this + // effect body used to cancel the scheduled work on the very next render. + const timeoutId = window.setTimeout(() => { + const section = sectionRef?.current + const container = containerRef?.current + if (section && container) { + const top = container.scrollTop + section.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }) + } + inputRef.current?.focus({ preventScroll: sectionRef != null }) + setPending(false) + }, sectionRef ? 100 : 0) + + return () => { + window.clearTimeout(timeoutId) + } + }, [activeTab, pending, isOpen, sectionRef, containerRef]) + + const openAndFocus = () => { + setActiveTab('apiKeys') + setPending(true) + } + + return { inputRef, openAndFocus } +} + +/** A labelled on/off setting row: bolt icon, title, description, switch, and a status pill. + * Shared by the CUDA-only Torch Compile + Diffusion Stage Cache toggles. */ +function SettingToggle({ title, description, enabled, onToggle, statusOn, statusOff }: { + title: string + description: React.ReactNode + enabled: boolean + onToggle: () => void + statusOn: string + statusOff: string +}) { + return ( +
+
+
+
+ + + + +
+

{description}

+
+ + +
+ +
+
+ {enabled ? statusOn : statusOff} +
+
+ ) +} + +function GeminiModelSelect({ + disabled, + models, + value, + onChange, +}: { + disabled: boolean + models: GeminiModelOption[] + value: string + onChange: (id: string) => void +}) { + const options = models.some((model) => model.id === value) + ? models + : [...models, { id: value, displayName: value, description: '' }] + const selectedDescription = options.find((model) => model.id === value)?.description?.trim() ?? '' + return ( +
+ +

+ Gemini chat models only — used for Enhance (API) and timeline gap + suggestions. Local Enhance still uses Gemma on-device. +

+ {selectedDescription ? ( +

{selectedDescription}

+ ) : null} +
+ ) +} + +const ABOUT_ACTION_CLASS = 'w-full bg-zinc-700 hover:bg-zinc-600 text-white text-xs' + +function aboutUpdateAction( + state: UpdateStatePayload, + onOpenUpdate: () => void, + onCheckForUpdates: () => void, + isMac: boolean, + autoCheckOn: boolean, +): { label: string; onClick?: () => void; disabled?: boolean } { + if (isMac) { + // No modal: Check only forces a lookup. Download/install still follow the toggle. + switch (state.status) { + case 'checking': + return { label: 'Checking…', disabled: true } + case 'downloading': + return { label: `Downloading… ${state.percent ?? 0}%`, disabled: true } + case 'downloaded': + return { + label: autoCheckOn ? 'Will install when you quit' : 'Will still install when you quit', + disabled: true, + } + default: + return { + label: 'Check for updates', + onClick: autoCheckOn ? onCheckForUpdates : undefined, + disabled: !autoCheckOn, + } + } + } + switch (state.status) { + case 'available': + return { label: `Update available — v${state.version}`, onClick: onOpenUpdate } + case 'downloaded': + return { label: 'Restart to update', onClick: onOpenUpdate } + case 'checking': + return { label: 'Checking…', disabled: true } + case 'downloading': + return { label: `Downloading… ${state.percent ?? 0}%`, disabled: true } + default: + return { label: 'Check for updates', onClick: onCheckForUpdates } + } +} + +export function SettingsModal({ isOpen, onClose, initialTab, initialReason, update, onOpenUpdate, onCheckForUpdates }: SettingsModalProps) { + const { settings, updateSettings, saveLtxApiKey, saveFalApiKey, saveGeminiApiKey, refreshSettings, forceApiGenerations, cudaAvailable, notifyModelsChanged } = useAppSettings() + const onSettingsChange = (next: AppSettings) => updateSettings(next) + const [activeTab, setActiveTab] = useState('general') + const tabBodyRef = useRef(null) + const geminiSectionRef = useRef(null) + const ltxApiKey = useApiKeyFocus(isOpen, activeTab, setActiveTab) + const falApiKey = useApiKeyFocus(isOpen, activeTab, setActiveTab) + const geminiApiKey = useApiKeyFocus(isOpen, activeTab, setActiveTab, geminiSectionRef, tabBodyRef) + const [ltxApiKeyInput, setLtxApiKeyInput] = useState('') + const [falApiKeyInput, setFalApiKeyInput] = useState('') + const [geminiApiKeyInput, setGeminiApiKeyInput] = useState('') + const showGeminiKeyBanner = initialReason === 'geminiKeyRequired' + const [geminiModelOptions, setGeminiModelOptions] = useState([]) + const [resolvedGeminiModel, setResolvedGeminiModel] = useState(DEFAULT_GEMINI_MODEL) + const geminiModelSaveSeq = useRef(0) + const [textEncoderRecommendation, setTextEncoderRecommendation] = useState | null>(null) + // Which checkpoint is downloading, not just whether one is — the encoder and the optional + // prompt enhancer each have their own card and must show progress only on their own. + const [downloadingCp, setDownloadingCp] = useState(null) + const [downloadError, setDownloadError] = useState(null) + const [downloadSessionId, setDownloadSessionId] = useState(null) + const [downloadProgress, setDownloadProgress] = useState | null>(null) + const { hfAuthStatus, hfAuthPolling, startHuggingFaceLogin, handleHuggingFaceLogout } = useHfAuth(isOpen) + const textEncoderModelTypes = useMemo( + () => (forceApiGenerations || !textEncoderRecommendation?.cp_to_download + ? [] + : [textEncoderRecommendation.cp_to_download]), + [forceApiGenerations, textEncoderRecommendation?.cp_to_download], + ) + const { accessMap: teAccessMap, allAuthorized: teAllAuthorized, checkError: teCheckError, recheckAccess: recheckTeAccess } = useHfModelAccess(textEncoderModelTypes, hfAuthStatus) + const preferredEnhancerDownloaded = textEncoderRecommendation !== null + && textEncoderRecommendation.local_enhancer_cp !== null + && textEncoderRecommendation.active_local_enhancer_cp === textEncoderRecommendation.local_enhancer_cp + const enhancerCpToDownload = textEncoderRecommendation !== null + && textEncoderRecommendation.local_enhancer_cp !== null + && !preferredEnhancerDownloaded + ? textEncoderRecommendation.local_enhancer_cp + : null + const enhancerModelTypes = useMemo( + () => (enhancerCpToDownload === null ? [] : [enhancerCpToDownload]), + [enhancerCpToDownload], + ) + const { accessMap: enhancerAccessMap, allAuthorized: enhancerAllAuthorized, checkError: enhancerCheckError, recheckAccess: recheckEnhancerAccess } = useHfModelAccess(enhancerModelTypes, hfAuthStatus) + const apiEncodingSupported = textEncoderRecommendation?.api_encoding_supported ?? true + const localEncoderSelected = settings.useLocalTextEncoder || !apiEncodingSupported + const [appVersion, setAppVersion] = useState('') + const [noticesText, setNoticesText] = useState(null) + const [noticesLoading, setNoticesLoading] = useState(false) + const [showNotices, setShowNotices] = useState(false) + const [modelLicenseText, setModelLicenseText] = useState(null) + const [modelLicenseLoading, setModelLicenseLoading] = useState(false) + const [showModelLicense, setShowModelLicense] = useState(false) + const [analyticsEnabled, setAnalyticsEnabled] = useState(false) + const [autoCheckUpdates, setAutoCheckUpdatesState] = useState(true) + const [projectAssetsPath, setProjectAssetsPath] = useState('') + const isMac = window.electronAPI.platform === 'darwin' + const updateAction = aboutUpdateAction(update.state, onOpenUpdate, onCheckForUpdates, isMac, autoCheckUpdates) + + // Sync active tab with initialTab prop when modal opens + useEffect(() => { + if (isOpen && initialTab) { + setActiveTab(initialTab) + } + }, [isOpen, initialTab]) + + useEffect(() => { + if (isOpen && initialReason === 'geminiKeyRequired') { + geminiApiKey.openAndFocus() + } + }, [isOpen, initialReason]) + + // The Models tab is hidden in force-API mode; don't let the selection get stuck there + // (e.g. via initialTab or a stale value). + useEffect(() => { + if (forceApiGenerations && activeTab === 'models') { + setActiveTab('general') + } + }, [forceApiGenerations, activeTab]) + + // Fetch app version when About tab is shown + useEffect(() => { + if (activeTab !== 'about' || appVersion) return + window.electronAPI.getAppInfo().then(info => setAppVersion(info.version)).catch(() => {}) + }, [activeTab, appVersion]) + + // Fetch analytics state when modal opens + useEffect(() => { + if (!isOpen) return + window.electronAPI.getAnalyticsState() + .then((state: { analyticsEnabled: boolean }) => setAnalyticsEnabled(state.analyticsEnabled)) + .catch(() => {}) + window.electronAPI.getProjectAssetsPath() + .then((p: string) => setProjectAssetsPath(p)) + .catch(() => {}) + window.electronAPI.getAutoCheckUpdates() + .then((s: { enabled: boolean }) => setAutoCheckUpdatesState(s.enabled)) + .catch(() => {}) + }, [isOpen]) + + useEffect(() => { + const fallbackId = settings.geminiModel.trim() || DEFAULT_GEMINI_MODEL + if (!isOpen) return + if (!settings.hasGeminiApiKey) { + setGeminiModelOptions([{ id: fallbackId, displayName: fallbackId, description: '' }]) + setResolvedGeminiModel(fallbackId) + return + } + + let cancelled = false + const loadGeminiModels = async () => { + const result = await ApiClient.listGeminiModels() + if (cancelled) return + if (!result.ok) { + setGeminiModelOptions([{ id: fallbackId, displayName: fallbackId, description: '' }]) + setResolvedGeminiModel(fallbackId) + return + } + setGeminiModelOptions(result.data.models) + setResolvedGeminiModel(result.data.resolvedModel) + } + void loadGeminiModels() + return () => { + cancelled = true + } + // Selecting a model calls refreshSettings(), which updates settings.geminiModel. Relisting + // on that change would hit the backend on every pick and wipe the dropdown if the GET failed. + // eslint-disable-next-line react-hooks/exhaustive-deps -- fallbackId is only used when the key is missing or the list fails + }, [isOpen, settings.hasGeminiApiKey]) + + // Fetch text encoder recommendation when modal opens + useEffect(() => { + if (!isOpen || forceApiGenerations) return + + const fetchRecommendation = async () => { + const result = await ApiClient.getTextEncoderRecommendation() + if (!result.ok) { + logger.error(`Failed to fetch text encoder recommendation: ${result.error.message}`) + return + } + + const data = result.data + setTextEncoderRecommendation(data) + // A download that finished elsewhere (another surface, or before this modal opened) would + // otherwise leave its card stuck showing progress. + const stillPending = [ + data.cp_to_download, + data.local_enhancer_cp !== null + && data.active_local_enhancer_cp !== data.local_enhancer_cp + ? data.local_enhancer_cp + : null, + ] + setDownloadingCp((cp) => (cp !== null && !stillPending.includes(cp) ? null : cp)) + } + + void fetchRecommendation() + }, [forceApiGenerations, isOpen]) + + // Poll download progress via session ID + useEffect(() => { + if (downloadingCp === null || !downloadSessionId) return + + const poll = async () => { + const result = await ApiClient.getModelDownloadProgress({ sessionId: downloadSessionId }) + if (!result.ok) return + setDownloadProgress(result.data) + if (result.data.status === 'complete') { + setDownloadingCp(null) + setDownloadSessionId(null) + const rec = await ApiClient.getTextEncoderRecommendation() + if (rec.ok) setTextEncoderRecommendation(rec.data) + // Enhance reads local availability outside this modal, so it has to be told the set of + // installed checkpoints changed. + notifyModelsChanged() + } else if (result.data.status === 'error') { + setDownloadError(result.data.error ?? 'Download failed') + setDownloadingCp(null) + setDownloadSessionId(null) + } + } + + void poll() + const interval = setInterval(() => { void poll() }, 1000) + return () => clearInterval(interval) + }, [downloadingCp, downloadSessionId, notifyModelsChanged]) + + const handleDownloadCheckpoint = async (cpId: TextEncodingCp) => { + setDownloadingCp(cpId) + setDownloadError(null) + setDownloadProgress(null) + const result = await ApiClient.startModelDownload({ type: 'download', cp_ids: [cpId] }) + if (!result.ok) { + setDownloadError(result.error.message) + setDownloadingCp(null) + return + } + if (result.data.status === 'started') { + setDownloadSessionId(result.data.sessionId) + } + } + + if (!isOpen) return null + + const handleToggleTorchCompile = () => { + onSettingsChange({ + ...settings, + useTorchCompile: !settings.useTorchCompile, + }) + } + + const handleToggleDiffusionStageCache = () => { + onSettingsChange({ + ...settings, + diffusionStageCacheEnabled: !settings.diffusionStageCacheEnabled, + }) + } + + const handleToggleFastDecode = () => { + onSettingsChange({ + ...settings, + useConvVae: !settings.useConvVae, + }) + } + + const handleToggleLocalEncoder = () => { + onSettingsChange({ + ...settings, + useLocalTextEncoder: !settings.useLocalTextEncoder, + }) + } + + const handlePromptCacheSizeChange = (e: React.ChangeEvent) => { + const size = Math.max(0, Math.min(1000, parseInt(e.target.value) || 100)) + onSettingsChange({ + ...settings, + promptCacheSize: size, + }) + } + + // Prompt Enhancer handlers + const handleTogglePromptEnhancer = (mode: 't2v' | 'i2v') => { + if (mode === 't2v') { + onSettingsChange({ ...settings, promptEnhancerEnabledT2V: !settings.promptEnhancerEnabledT2V }) + } else { + onSettingsChange({ ...settings, promptEnhancerEnabledI2V: !settings.promptEnhancerEnabledI2V }) + } + } + // Analytics handler + const handleToggleAnalytics = () => { + const next = !analyticsEnabled + setAnalyticsEnabled(next) + window.electronAPI.setAnalyticsEnabled({ enabled: next }).catch(() => {}) + } + + const handleToggleAutoCheck = () => { + const next = !autoCheckUpdates + setAutoCheckUpdatesState(next) + window.electronAPI.setAutoCheckUpdates({ enabled: next }).catch(() => {}) + } + + // Seed handlers + const handleToggleSeedLock = () => { + onSettingsChange({ + ...settings, + seedLocked: !settings.seedLocked, + }) + } + + const handleLockedSeedChange = (e: React.ChangeEvent) => { + const value = parseInt(e.target.value) || 0 + onSettingsChange({ + ...settings, + lockedSeed: Math.max(0, Math.min(2147483647, value)), + }) + } + + const handleRandomizeSeed = () => { + onSettingsChange({ + ...settings, + lockedSeed: Math.floor(Math.random() * 2147483647), + }) + } + + const handleLoadModelLicense = async () => { + setModelLicenseLoading(true) + try { + const text = await window.electronAPI.fetchLicenseText() + setModelLicenseText(text) + setShowModelLicense(true) + } catch (e) { + logger.error(`Failed to load model license: ${e}`) + } finally { + setModelLicenseLoading(false) + } + } + + const handleLoadNotices = async () => { + setNoticesLoading(true) + try { + const text = await window.electronAPI.getNoticesText() + setNoticesText(text) + setShowNotices(true) + } catch (e) { + logger.error(`Failed to load notices: ${e}`) + } finally { + setNoticesLoading(false) + } + } + + const tabs = [ + { id: 'general' as TabId, label: 'General', icon: Settings }, + // The Models tab is local-model management — irrelevant (and non-functional) when all + // generation is forced through the API, so hide it in that mode. + ...(!forceApiGenerations ? [{ id: 'models' as TabId, label: 'Models', icon: HardDrive }] : []), + { id: 'apiKeys' as TabId, label: 'API Keys', icon: KeyRound }, + { id: 'promptEnhancer' as TabId, label: 'Prompt Enhancer', icon: Sparkles }, + { id: 'about' as TabId, label: 'About', icon: Info }, + ] + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+ +

Settings

+
+ +
+ + {/* Tabs */} +
+ {tabs.map((tab) => { + const Icon = tab.icon + return ( + + ) + })} +
+ + {/* Content */} +
+ {activeTab === 'general' && ( + <> + {/* Project Assets Path */} +
+
+ +

Project Assets Path

+
+

+ Where generated video and image assets are saved. Each project gets a subfolder. +

+
+
+ {projectAssetsPath || Not set} +
+ +
+
+ + {!forceApiGenerations && ( +
+
+ +

Videos Generation

+
+ +
{ + if (!settings.hasLtxApiKey) { + ltxApiKey.openAndFocus() + return + } + onSettingsChange({ + ...settings, + userPrefersLtxApiVideoGenerations: !settings.userPrefersLtxApiVideoGenerations, + }) + }} + > +
+
+
+ + Generate With API +
+

+ Use LTX API for video generation when an LTX API key is configured. +

+
+
+ {settings.userPrefersLtxApiVideoGenerations && } +
+
+ + {!settings.hasLtxApiKey && ( +
+ + API key required — configure it in the API Keys tab. +
+ )} +
+
+ )} + + {!forceApiGenerations && ( +
+
+ +

Images Generation

+
+ +
{ + if (!settings.hasFalApiKey) { + falApiKey.openAndFocus() + return + } + onSettingsChange({ + ...settings, + userPrefersFalApiImageGenerations: !settings.userPrefersFalApiImageGenerations, + }) + }} + > +
+
+
+ + Generate With API +
+

+ Use the FAL API for image generation and editing when a FAL API key is configured. +

+
+
+ {settings.userPrefersFalApiImageGenerations && } +
+
+ + {!settings.hasFalApiKey && ( +
+ + API key required — configure it in the API Keys tab. +
+ )} +
+
+ )} + + {/* Text Encoding Section */} + {!forceApiGenerations && ( +
+
+ + + + +

Text Encoding

+
+ +

+ Text encoding converts your prompt into data the AI understands. Choose how to do this. +

+ + {/* LTX API Option (Default) */} +
{ + if (!apiEncodingSupported) return + if (!settings.useLocalTextEncoder) return + if (!settings.hasLtxApiKey) { + ltxApiKey.openAndFocus() + return + } + handleToggleLocalEncoder() + }} + > +
+
+
+ + LTX API + {apiEncodingSupported ? ( + Recommended + ) : ( + Unavailable + )} +
+

+ Fast cloud-based text encoding (~1 second). Requires an LTX API key configured in the API Keys tab. +

+
+
+ {!localEncoderSelected && } +
+
+ + {!apiEncodingSupported && ( +
+ + + Not available for LTX {textEncoderRecommendation?.ltx_version_label ?? ''} — prompts for this + version can only be encoded by the local encoder. + +
+ )} + + {/* Warning when selected but no key */} + {apiEncodingSupported && !settings.useLocalTextEncoder && !settings.hasLtxApiKey && ( +
+ + API key required — configure it in the API Keys tab. +
+ )} + + {/* Prompt Cache Size — only relevant for API text encoding */} + {!localEncoderSelected && settings.hasLtxApiKey && ( +
+
+ +

Skip repeat encoding calls

+
+ e.stopPropagation()} + className="w-16 px-2 py-1 bg-zinc-700 border border-zinc-600 rounded text-xs text-white text-center focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ )} +
+ + {/* Local Encoder Option */} +
!settings.useLocalTextEncoder && handleToggleLocalEncoder()} + > +
+
+
+ + + + + Local Encoder + {!apiEncodingSupported && ( + Required + )} +
+

+ Run on your computer (slower than the API). Requires{' '} + {textEncoderRecommendation?.expected_size_gb ?? '~25'} GB download. +

+
+
+ {localEncoderSelected && } +
+
+ + {/* Download Status - show when this option is selected */} + {localEncoderSelected && ( +
+ {textEncoderRecommendation?.cp_to_download === null ? ( +
+ + Downloaded ({textEncoderRecommendation?.expected_size_gb ?? 0} GB) +
+ ) : downloadingCp === textEncoderRecommendation?.cp_to_download ? ( +
+
+ Downloading text encoder... + {downloadProgress?.status === 'downloading' ? Math.round(downloadProgress.current_file_progress) : 0}% +
+
+
+
+
+ ) : ( +
+
+ + Not downloaded ({textEncoderRecommendation?.expected_size_gb || 0} GB required) +
+ { + void startHuggingFaceLogin() + }} + checkError={teCheckError} + onRetryCheck={recheckTeAccess} + className="space-y-1.5 mb-2" + /> + + {downloadError && ( +

{downloadError}

+ )} +
+ )} +
+ )} +
+ + {/* Optional local prompt enhancer — only for models whose encoder can't generate */} + {textEncoderRecommendation?.local_enhancer_cp && ( +
+
+ + Local Prompt Enhancer + Optional +
+

+ LTX {textEncoderRecommendation.ltx_version_label}'s text encoder can only encode + prompts, so enhancing them on your computer needs a separate instruct model. + Gemma 3 already downloaded for 2.3 works; Gemma 4 E2B is the smaller optional + upgrade. Without either, the Enhance button can still use Gemini if you have a + key, and Generate uses the prompt as typed. +

+ +
+ {preferredEnhancerDownloaded ? ( +
+ + Downloaded ({textEncoderRecommendation.local_enhancer_expected_size_gb ?? 0} GB) +
+ ) : downloadingCp === textEncoderRecommendation.local_enhancer_cp ? ( +
+
+ Downloading prompt enhancer... + {downloadProgress?.status === 'downloading' ? Math.round(downloadProgress.current_file_progress) : 0}% +
+
+
+
+
+ ) : ( +
+ {textEncoderRecommendation.local_enhancement_supported && ( +
+ + Using Gemma 3 already on disk +
+ )} + { + void startHuggingFaceLogin() + }} + checkError={enhancerCheckError} + onRetryCheck={recheckEnhancerAccess} + className="space-y-1.5 mb-2" + /> + + {downloadError && ( +

{downloadError}

+ )} +
+ )} +
+
+ )} +
+ )} + + {/* Fast decode — all platforms. Swaps the 2.5 video VAE; takes effect on next load. */} + + + {/* Torch Compile + Diffusion Stage Cache -- CUDA only, no-op on MPS/CPU */} + {cudaAvailable && ( + Compiles the model for optimized inference. Experimental: First + generation can take 5-10+ minutes for compilation. Subsequent generations may be + 20-40% faster. Requires app restart to take effect.} + enabled={settings.useTorchCompile} + onToggle={handleToggleTorchCompile} + statusOn="Optimized inference (recommended)" + statusOff="Standard inference" + /> + )} + + {cudaAvailable && ( + Reuses an already-built transformer across stage 1/stage 2 within one generation + instead of reloading it from disk twice. Experimental: only + applies on high-VRAM cards (32GB+); no effect otherwise.} + enabled={settings.diffusionStageCacheEnabled} + onToggle={handleToggleDiffusionStageCache} + statusOn="Skipping redundant transformer reloads" + statusOff="Standard behavior" + /> + )} + + {/* Seed Lock Setting */} +
+
+
+
+ + + + + +
+

+ Use the same seed for reproducible generations. When unlocked, a random seed is used each time. +

+
+ + {/* Toggle Switch */} + +
+ + {/* Seed input - only show when locked */} + {settings.seedLocked && ( +
+ + +
+ )} + + {/* Status indicator */} +
+
+ {settings.seedLocked ? `Seed locked: ${settings.lockedSeed ?? 42}` : 'Random seed each generation'} +
+
+ + {/* Anonymous Analytics Setting */} +
+
+
+
+ + + + + + +
+

+ Share anonymous usage data to help improve LTX Desktop. + Only basic technical information is collected — never personal data or generated content. +

+
+ + {/* Toggle Switch */} + +
+ +
+ + )} + + {activeTab === 'models' && !forceApiGenerations && } + + {activeTab === 'apiKeys' && ( + <> + {/* LTX API Key Section */} +
+
+ +

LTX API

+
+ +

+ Your LTX API key is used for cloud text encoding, prompt enhancement, and API video generation. + Add your key below to unlock these features. +

+ +
+
+ setLtxApiKeyInput(e.target.value)} + placeholder={settings.hasLtxApiKey ? 'Enter new key to replace...' : 'Enter your LTX API key...'} + stopPropagation + className="flex-1" + /> + +
+ +
+
+ {settings.hasLtxApiKey ? ( + <> + + Key configured + + ) : ( + <> + + API key required + + )} +
+
+
+
+ + {/* FAL API Key Section */} +
+
+ +

FAL AI

+ Optional +
+ +

+ Your FAL AI key is used for generating or editing images with Z Image Turbo when API generations are enabled. +

+ +
+
+ setFalApiKeyInput(e.target.value)} + placeholder={settings.hasFalApiKey ? 'Enter new key to replace...' : 'Enter your FAL AI API key...'} + stopPropagation + className="flex-1" + /> + +
+ window.electronAPI.openFalApiKeyPage()} + /> +
+
+ {settings.hasFalApiKey ? ( + <> + + Key configured + + ) : ( + <> + + Optional + + )} +
+
+
+
+ + {/* Gemini API Key Section */} +
+
+ +

Gemini API

+
+ + {showGeminiKeyBanner && ( +
+ + Add a Gemini API key to use Enhance (API). +
+ )} + +

+ Your Gemini API key is used for AI-powered prompt suggestions when filling timeline gaps, and for the Enhance (API) prompt enhancer. +

+ +
+
+ setGeminiApiKeyInput(e.target.value)} + placeholder={settings.hasGeminiApiKey ? 'Enter new key to replace...' : 'Enter your Gemini API key...'} + onKeyDown={(e) => e.stopPropagation()} + className="flex-1 px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-sm text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + +
+
+
+ {settings.hasGeminiApiKey ? ( + <> + + Key configured + + ) : ( + <> + + API key required + + )} +
+
+ { + const previous = resolvedGeminiModel + const requestId = ++geminiModelSaveSeq.current + setResolvedGeminiModel(geminiModel) + void (async () => { + const result = await ApiClient.updateSettings({ geminiModel }) + if (requestId !== geminiModelSaveSeq.current) return + if (!result.ok) { + setResolvedGeminiModel(previous) + return + } + await refreshSettings() + })() + }} + /> + +
+
+ + {/* HuggingFace Account */} +
+
+ +

HuggingFace

+
+ +

+ Sign in to download gated models (such as LTX 2.5) and accept Hugging Face licenses. +

+ +
+
+ {hfAuthStatus === 'authenticated' ? ( + <> + + Signed in + + ) : ( + <> + + Not signed in + + )} +
+ + {hfAuthStatus === 'authenticated' ? ( + + ) : ( + + )} +
+
+ + )} + + {activeTab === 'promptEnhancer' && ( + <> +
+
+ +

Prompt Enhancer

+
+ +

+ When enabled, Generate rewrites your prompt with visual detail, sound, and camera + motion before the model sees it. Local generations use the on-device enhancer; + LTX API text encoding enhances on the server. The Enhance button in Gen Space is + separate — it rewrites the prompt box so you can edit it first. Control + independently for each generation type. +

+ + {!settings.hasLtxApiKey && ( +

+ An LTX API key is only needed when text encoding goes through the LTX API. + Local generations use the local enhancer instead (download it under Models + if this version ships one separately). +

+ )} + + {/* T2V Toggle */} +
handleTogglePromptEnhancer('t2v')} + > +
+ T2V +
+ Text-to-Video +

+ {settings.promptEnhancerEnabledT2V ? 'Prompts will be enhanced before T2V generation' : 'T2V prompts used as-is'} +

+
+
+
+
+
+
+ + {/* I2V Toggle */} +
handleTogglePromptEnhancer('i2v')} + > +
+ I2V +
+ Image-to-Video +

+ {settings.promptEnhancerEnabledI2V ? 'Prompts will be enhanced before I2V generation' : 'I2V prompts used as-is'} +

+
+
+
+
+
+
+
+ + )} + + {activeTab === 'about' && ( + <> + {showModelLicense ? ( +
+
+

LTX-2 Model License

+ +
+
+                    {modelLicenseText}
+                  
+
+ ) : showNotices ? ( +
+
+

Third-Party Notices

+ +
+
+                    {noticesText}
+                  
+
+ ) : ( +
+ {/* App Identity */} +
+

LTX Desktop

+

Version {appVersion || '...'}

+

AI-Powered Video Editor

+
+ + {/* Updates */} +
+
+ + Updates +
+

+ {isMac ? ( + <> + {update.state.status === 'downloading' && `Downloading… ${update.state.percent ?? 0}%`} + {update.state.status === 'downloaded' && ( + autoCheckUpdates + ? 'An update will install when you quit.' + : 'This update is already queued and will still install when you quit.' + )} + {update.state.status === 'checking' && 'Checking for updates…'} + {update.state.status === 'not-available' && "You're up to date."} + {(update.state.status === 'idle' || update.state.status === 'available') && ( + autoCheckUpdates + ? 'New versions download in the background and install when you quit.' + : 'Automatic updates are off. Turn this on to install new versions when you quit.' + )} + + ) : ( + <> + {update.state.status === 'available' && `Version ${update.state.version} is available.`} + {update.state.status === 'downloading' && `Downloading… ${update.state.percent ?? 0}%`} + {update.state.status === 'downloaded' && 'Download complete. Restart to apply the update.'} + {update.state.status === 'checking' && 'Checking for updates…'} + {update.state.status === 'not-available' && "You're up to date."} + {update.state.status === 'idle' && 'Check for a newer version, or let the app check automatically.'} + + )} +

+ {update.state.message && ( +

{update.state.message}

+ )} + +
+
+ +

+ {isMac + ? 'When on, new versions download in the background and install when you quit. Check above to look now.' + : 'Periodically check for new versions. You can always check manually above.'} +

+
+ +
+
+ + {/* License */} +
+
+ + License +
+

+ Licensed under the Apache License, Version 2.0 +

+
+ + {/* LTX-2 Model License */} +
+
+ + + + LTX-2 Model License +
+

+ The LTX-2 model is subject to the LTX-2 Community License Agreement, accepted during first-run setup. +

+ +
+ + {/* Third-Party Notices */} +
+
+ + + + + + + Third-Party Notices +
+

+ This application uses open-source software and AI models subject to their own license terms. +

+ +
+ + {/* Copyright */} +

+ Copyright © 2026 Lightricks +

+
+ )} + + )} +
+ + {/* Footer */} +
+ +
+
+
+ ) +} + +export type { AppSettings, TabId as SettingsTabId } diff --git a/frontend/components/SettingsPanel.tsx b/frontend/components/SettingsPanel.tsx new file mode 100644 index 0000000..4986a8f --- /dev/null +++ b/frontend/components/SettingsPanel.tsx @@ -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 ( +
+ {/* Aspect Ratio and Quality side by side */} +
+ + + +
+
+ ) + } + + if (!videoModelSpecs || videoModelSpecs.length === 0 || !resolvedVideoOptions || !resolvedVideoOptions.hasCompatibleOptions) { + return ( +
+ {videoSettingsMessage || 'Loading generation settings...'} +
+ ) + } + + 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 ( +
+ + + {/* Duration, Resolution, FPS Row */} +
+ {!hideDuration && ( + + )} + + + + {showFps && ( + + )} +
+ + {/* Aspect Ratio */} + + + {/* Audio and Camera Motion Row */} +
+
+ +
+ +
+ +
+
+
+ ) +} diff --git a/frontend/components/UpdateAvailableModal.css b/frontend/components/UpdateAvailableModal.css new file mode 100644 index 0000000..78b524a --- /dev/null +++ b/frontend/components/UpdateAvailableModal.css @@ -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); + } +} diff --git a/frontend/components/UpdateAvailableModal.tsx b/frontend/components/UpdateAvailableModal.tsx new file mode 100644 index 0000000..74b3b49 --- /dev/null +++ b/frontend/components/UpdateAvailableModal.tsx @@ -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(null) + const dialogRef = useRef(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 ( +
+
e.stopPropagation()} + > + {canDismiss && ( + + )} + +

{titleForStatus(state.status)}

+

+ {state.currentVersion} → {state.version} +

+ + {state.releaseNotes &&
{state.releaseNotes}
} + + {state.message && ( +

{state.message}

+ )} + {installError && ( +

{installError}

+ )} + + {downloading && ( +
+
+ {state.percent ?? 0}% +
+ )} + + {downloaded && isGenerationActive && ( +

+ A generation is running. Installing will restart the app — it will be enabled when the + generation finishes. +

+ )} + + {state.status === 'available' && ( + + )} + +
+ {downloaded ? ( + + ) : downloading ? ( + + ) : ( + <> + + + + )} +
+
+
+ ) +} diff --git a/frontend/components/VideoPreviewPanel.tsx b/frontend/components/VideoPreviewPanel.tsx new file mode 100644 index 0000000..6122ab6 --- /dev/null +++ b/frontend/components/VideoPreviewPanel.tsx @@ -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 + filmstripRef: React.RefObject + 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 + filmstripRef?: React.RefObject + 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(null) + const internalFilmstripRef = useRef(null) + const videoRef = videoRefProp ?? internalVideoRef + const filmstripRef = filmstripRefProp ?? internalFilmstripRef + + const [videoPath, setVideoPath] = useState(initialVideoPath || null) + const videoUrl = videoPath ? pathToFileUrl(videoPath) : null + const [videoDuration, setVideoDuration] = useState(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([]) + 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((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(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 ( +
+
+
+ + {title} + {videoPath && ( + + {videoPath.split(/[/\\]/).pop()} + + )} +
+ {videoUrl && ( +
+ + +
+ )} +
+ + {!videoUrl ? ( +
{ e.preventDefault(); setIsDragOver(true) }} + onDragLeave={() => setIsDragOver(false)} + onDrop={handleDrop} + > +
+ +
+
+

{emptyTitle}

+

mp4, mov, avi, webm, mkv

+
+ +
+ ) : ( +
+
+
+ +
+
+ + + {formatTimecode(currentTime)} / {formatTimecode(videoDuration)} + +
+ + {hint && ( +
+

{hint.title}

+ {hint.subtitle &&

{hint.subtitle}

} +
+ )} + +
+
+
+ + + +
+
+
+
+ {thumbnails.length > 0 ? ( + thumbnails.map((thumb, i) => ( + + )) + ) : ( +
+ +
+ )} +
+ + {filmstripOverlay?.(ctx)} + +
+
+ + {belowFilmstrip?.(ctx)} +
+ + {errorMessage ? ( +
+
+ {errorMessage} +
+
+ ) : isProcessing ? ( +
+
+ + {processingStatus || processingDefault} +
+
+ ) : null} +
+
+ )} +
+ ) +} diff --git a/frontend/components/WebErrorBoundary.tsx b/frontend/components/WebErrorBoundary.tsx new file mode 100644 index 0000000..0e5a1d5 --- /dev/null +++ b/frontend/components/WebErrorBoundary.tsx @@ -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

LTX DeskWEB could not render this view

Your saved projects remain in this browser. Reload to return to the project list.

+ return this.props.children + } +} diff --git a/frontend/components/WebProjectBackup.tsx b/frontend/components/WebProjectBackup.tsx new file mode 100644 index 0000000..feb58cc --- /dev/null +++ b/frontend/components/WebProjectBackup.tsx @@ -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
+ + + {error &&

{error}

} +
+} diff --git a/frontend/components/library/LibraryItemCard.tsx b/frontend/components/library/LibraryItemCard.tsx new file mode 100644 index 0000000..1665853 --- /dev/null +++ b/frontend/components/library/LibraryItemCard.tsx @@ -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(null) + const cardRef = useRef(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 ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {/* Media */} +
+ {item.demoVideoUrl ? ( +
+ + {/* Body */} +
+
+ {item.title} + {infoSlot} +
+ {item.description &&
{item.description}
} + {(item.author || item.license) && ( +
+ {item.author && ( + item.author.url + ? + : by {item.author.name} + )} + {item.license && ( + item.license.url + ? + : {item.license.name} + )} +
+ )} + + {/* Footer: status / action */} +
+ {showVariantPicker && ( + + )} + {status.kind === 'downloading' && ( +
+ + {Math.round(status.progress)}% + +
+
+
+
+ )} + {status.kind === 'error' && ( +
+
+ {status.gated ? 'This model is gated — request access, then retry.' : status.message} +
+
+ {status.gated && ( + + )} + +
+
+ )} + {status.kind !== 'downloading' && status.kind !== 'error' && selectedVariantReady && ( + + )} + {status.kind !== 'downloading' && status.kind !== 'error' && !selectedVariantReady && ( + + )} +
+
+
+ ) +} diff --git a/frontend/components/library/LibraryModal.tsx b/frontend/components/library/LibraryModal.tsx new file mode 100644 index 0000000..91d6511 --- /dev/null +++ b/frontend/components/library/LibraryModal.tsx @@ -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(null) + + useEffect(() => { + const el = ref.current + if (!el) return + if (open && !el.open) el.showModal() + else if (!open && el.open) el.close() + }, [open]) + + return ( + 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. */} +
+
+

{title}

+
+ {headerSlot} + +
+
+
{children}
+
+
+ ) +} diff --git a/frontend/components/settings/BaseModelSection.tsx b/frontend/components/settings/BaseModelSection.tsx new file mode 100644 index 0000000..a0e3c9f --- /dev/null +++ b/frontend/components/settings/BaseModelSection.tsx @@ -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>['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 + resumeSessionId: string | null + hfAuthStatus: HfAuthStatus + hfAuthPolling: boolean + startHuggingFaceLogin: () => void +}) { + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [downloadSessionId, setDownloadSessionId] = useState(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(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 ( +
+
+
+ void handleSetActive()} + className="h-4 w-4 accent-blue-500 flex-shrink-0 disabled:cursor-not-allowed" + /> + {version.label} + {version.active && ( + + + Active + + )} + {version.is_newest && !version.installed && ( + + New + + )} +
+ +
+ {!version.installed && ( + + )} + {version.installed && !version.active && ( + + )} +
+
+ + {!version.installed && ( + + )} + + {error && ( +
+ + {error} +
+ )} +
+ ) +} + +export function BaseModelSection() { + const [versions, setVersions] = useState([]) + 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(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 ( + <> +
+
+ +

Models Folder

+
+

+ Where model checkpoints are stored. Changing the location requires restarting the app. +

+
+
+ {modelsDir || Not set} +
+ + +
+
+ +
+
+ +

Base Model

+
+

+ 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. +

+
+ {versions.length === 0 ? ( +
No versions available.
+ ) : ( + versions.map((version) => ( + { + void startHuggingFaceLogin() + }} + /> + )) + )} +
+
+ + ) +} diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx new file mode 100644 index 0000000..6a2bf96 --- /dev/null +++ b/frontend/components/ui/button.tsx @@ -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, + VariantProps {} + +const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => { + return ( +