Create standalone browser port of LTX Athena with authenticated native API transport
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
node_modules
|
||||
dist
|
||||
data
|
||||
secrets
|
||||
.env
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
secrets/
|
||||
data/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
@@ -0,0 +1,12 @@
|
||||
# LTX DeskWEB
|
||||
Independent browser frontend fork of LTX Athena / Lightricks LTX Desktop.
|
||||
Do not add CUDA, PyTorch, model weights or process/container lifecycle control.
|
||||
The existing LTX API is forwarded unchanged; no generic video adapter.
|
||||
Read docs/ARCHITECTURE.md before modifying the platform boundary.
|
||||
Keep attribution in LICENSE.txt, NOTICES.md and docs/PROVENANCE.md.
|
||||
All LTX HTTP calls use frontend/lib/backend.ts; web-service calls use webRequest.
|
||||
Credentials stay on the server. Never log prompts, request bodies, media or tokens.
|
||||
Never modify the original LTX-Athena source or Athena Deck as part of this repository.
|
||||
Tests: npm run typecheck; npm test; npm run build.
|
||||
Use synthetic media and a test backend for integration tests. Do not start production
|
||||
models or change the Athena GPU mode without user authorization for that operation.
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
FROM node:22-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --ignore-scripts --no-audit --no-fund
|
||||
COPY frontend ./frontend
|
||||
COPY shared ./shared
|
||||
COPY public ./public
|
||||
COPY index.html vite.config.ts tsconfig.json tailwind.config.js postcss.config.js ./
|
||||
RUN npm run typecheck && npm run build
|
||||
|
||||
FROM node:22-bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY server ./server
|
||||
COPY LICENSE.txt NOTICES.md ./
|
||||
ENV HOST=0.0.0.0 PORT=8118
|
||||
USER node
|
||||
EXPOSE 8118
|
||||
CMD ["node", "server/index.mjs"]
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2024 Lightricks
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
---
|
||||
|
||||
Third-Party Software Notices
|
||||
|
||||
This application uses open-source software and AI models subject to
|
||||
their own license terms. See NOTICES.md for the complete list
|
||||
of third-party components, their copyright holders, and license texts.
|
||||
+813
@@ -0,0 +1,813 @@
|
||||
# Third-Party Notices
|
||||
|
||||
This file contains the licenses and notices for third-party software and models
|
||||
used by LTX Desktop.
|
||||
|
||||
---
|
||||
|
||||
## Models
|
||||
|
||||
- **LTX-2**
|
||||
Copyright (c) Lightricks Ltd.
|
||||
License: LTX-2 Community License
|
||||
https://github.com/Lightricks/LTX-2/blob/main/LICENSE
|
||||
|
||||
- **Gemma 3**
|
||||
Copyright (c) Google LLC.
|
||||
License: Gemma Terms of Use
|
||||
https://ai.google.dev/gemma/terms
|
||||
|
||||
- **Z-Image-Turbo**
|
||||
Copyright (c) Alibaba Tongyi Lab.
|
||||
License: Apache License 2.0
|
||||
https://huggingface.co/Tongyi-MAI/Z-Image-Turbo
|
||||
|
||||
- **DPT-Hybrid (MiDaS 3.0)**
|
||||
Copyright (c) Intel Corporation.
|
||||
License: Apache License 2.0
|
||||
https://huggingface.co/Intel/dpt-hybrid-midas
|
||||
|
||||
- **DW Pose TorchScript (BatchSize5)**
|
||||
Copyright (c) DWPose contributors.
|
||||
License: Apache License 2.0
|
||||
https://huggingface.co/hr16/DWPose-TorchScript-BatchSize5
|
||||
|
||||
- **YOLOX Person Detector (TorchScript)**
|
||||
Copyright (c) YOLOX contributors.
|
||||
License: Apache License 2.0
|
||||
https://huggingface.co/hr16/yolox-onnx
|
||||
|
||||
---
|
||||
|
||||
## Python Dependencies
|
||||
|
||||
- **pillow** — Copyright (c) Jeffrey A. Clark and Pillow contributors — HPND License
|
||||
- **torch** — Copyright (c) Meta Platforms, Inc. — BSD 3-Clause License
|
||||
- **huggingface-hub** — Copyright (c) Hugging Face — Apache License 2.0
|
||||
- **tqdm** — Copyright (c) Casper da Costa-Luis — Mozilla Public License 2.0
|
||||
- **pynvml** — Copyright (c) NVIDIA Corporation — BSD 3-Clause License
|
||||
- **pydantic** — Copyright (c) Samuel Colvin and Pydantic Services Inc. — MIT License
|
||||
- **ltx-core** — Copyright (c) Lightricks — Apache License 2.0
|
||||
- **ltx-pipelines** — Copyright (c) Lightricks — Apache License 2.0
|
||||
- **diffusers** — Copyright (c) Hugging Face — Apache License 2.0
|
||||
- **ftfy** — Copyright (c) Robyn Speer and Luminoso Technologies — Apache License 2.0
|
||||
- **imageio** — Copyright (c) imageio contributors — BSD 2-Clause License
|
||||
- **imageio-ffmpeg** — Copyright (c) imageio contributors — BSD 2-Clause License
|
||||
- **peft** — Copyright (c) Hugging Face — Apache License 2.0
|
||||
- **protobuf** — Copyright (c) Google LLC — BSD 3-Clause License
|
||||
- **transformers** — Copyright (c) Hugging Face — Apache License 2.0
|
||||
- **sentencepiece** — Copyright (c) Google LLC — Apache License 2.0
|
||||
- **sageattention** — Copyright (c) Jintao Zhang et al. — Apache License 2.0
|
||||
- **natten** — Copyright (c) Ali Hassani, Steven Walton, et al. (SHI Labs) — Apache License 2.0
|
||||
- **opencv-python-headless** — Copyright (c) OpenCV team — Apache License 2.0
|
||||
- **fastapi** — Copyright (c) Sebastian Ramirez — MIT License
|
||||
- **uvicorn** — Copyright (c) Encode OSS Ltd. — BSD 3-Clause License
|
||||
- **python-multipart** — Copyright (c) Andrew Dunham — Apache License 2.0
|
||||
- **triton-windows** — Copyright (c) OpenAI — MIT License (Windows only)
|
||||
|
||||
---
|
||||
|
||||
## Node.js Dependencies
|
||||
|
||||
- **react** — Copyright (c) Meta Platforms, Inc. — MIT License
|
||||
- **react-dom** — Copyright (c) Meta Platforms, Inc. — MIT License
|
||||
- **electron** — Copyright (c) GitHub Inc. and Electron contributors — MIT License
|
||||
- **class-variance-authority** — Copyright (c) Joe Bell — Apache License 2.0
|
||||
- **clsx** — Copyright (c) Luke Edwards — MIT License
|
||||
- **electron-updater** — Copyright (c) electron-userland — MIT License
|
||||
- **js-yaml** — Copyright (c) Vitaly Puzrin — MIT License
|
||||
- **lucide-react** — Copyright (c) Lucide contributors — ISC License
|
||||
- **react-dropzone** — Copyright (c) Param Aggarwal — MIT License
|
||||
- **tailwind-merge** — Copyright (c) dcastil — MIT License
|
||||
|
||||
---
|
||||
|
||||
## License Texts
|
||||
|
||||
### Apache License 2.0
|
||||
|
||||
```
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. Please also get an in-depth
|
||||
understanding of how to properly apply this license to your work
|
||||
by reading the FAQ at http://www.apache.org/foundation/license-faq.html
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied. See the License for the specific language governing
|
||||
permissions and limitations under the License.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### BSD 3-Clause License
|
||||
|
||||
```
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) <year> <owner>. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### BSD 2-Clause License
|
||||
|
||||
```
|
||||
BSD 2-Clause License
|
||||
|
||||
Copyright (c) <year> <owner>. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MIT License
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) <year> <owner>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Mozilla Public License 2.0
|
||||
|
||||
```
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under the name "LEGAL", and included in all Source Code Form
|
||||
distributions of the Covered Software. Except to the extent prohibited
|
||||
by statute or regulation, such description must be sufficiently detailed
|
||||
for a recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ISC License
|
||||
|
||||
```
|
||||
ISC License
|
||||
|
||||
Copyright (c) <year> <owner>
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
PERFORMANCE OF THIS SOFTWARE.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### HPND License (Historical Permission Notice and Disclaimer)
|
||||
|
||||
```
|
||||
Historical Permission Notice and Disclaimer (HPND)
|
||||
|
||||
Copyright (c) <year> <owner>
|
||||
|
||||
Permission to use, copy, modify, and distribute this software and its
|
||||
documentation for any purpose and without fee is hereby granted, provided that
|
||||
the above copyright notice appear in all copies and that both that copyright
|
||||
notice and this permission notice appear in supporting documentation, and that
|
||||
the name of the copyright holder not be used in advertising or publicity
|
||||
pertaining to distribution of the software without specific, written prior
|
||||
permission.
|
||||
|
||||
THE COPYRIGHT HOLDER DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE,
|
||||
INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT
|
||||
SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY SPECIAL, INDIRECT OR
|
||||
CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE,
|
||||
DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
|
||||
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE
|
||||
OF THIS SOFTWARE.
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,65 @@
|
||||
import { WebProjectBackup } from './components/WebProjectBackup'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ProjectProvider } from './contexts/ProjectContext'
|
||||
import { ViewProvider, useView } from './contexts/ViewContext'
|
||||
import { KeyboardShortcutsProvider } from './contexts/KeyboardShortcutsContext'
|
||||
import { AppSettingsProvider, useAppSettings } from './contexts/AppSettingsContext'
|
||||
import { DevFlagsProvider } from './contexts/DevFlagsContext'
|
||||
import { useGenerationRecoveryWatcher } from './hooks/use-generation-recovery-watcher'
|
||||
import { Home } from './views/Home'
|
||||
import { Project } from './views/Project'
|
||||
import { webRequest } from './lib/web-platform'
|
||||
import { useBackend } from './hooks/use-backend'
|
||||
import { DevPanel } from './components/DevPanel'
|
||||
import { KeyboardShortcutsModal } from './components/KeyboardShortcutsModal'
|
||||
|
||||
function Workspace() {
|
||||
const { currentView, goHome } = useView()
|
||||
const { connected, isLoading } = useBackend()
|
||||
const { isLoaded, runtimePolicyLoaded } = useAppSettings()
|
||||
useGenerationRecoveryWatcher()
|
||||
const ready = connected && isLoaded && runtimePolicyLoaded
|
||||
return <>
|
||||
<div className={`px-4 py-2 text-sm ${ready ? 'bg-emerald-950 text-emerald-200' : 'bg-amber-950 text-amber-200'}`} role="status">
|
||||
{ready ? 'LTX backend connected · generation runs on the remote server' : isLoading ? 'Checking LTX connection…' : 'LTX is not ready. Activate Video mode in Athena Deck. The connection is checked automatically.'}
|
||||
</div>
|
||||
{currentView === 'home' ? <Home /> : ready ? <Project /> : <div className="p-12"><p>Projects are retained. Generation becomes available when the LTX backend and its settings are reachable.</p><button className="mt-4 underline" onClick={goHome}>Back to projects</button></div>}
|
||||
<KeyboardShortcutsModal /><DevPanel />
|
||||
</>
|
||||
}
|
||||
export default function App() {
|
||||
const [session, setSession] = useState<{ backendUrl: string } | null>(null)
|
||||
const [checking, setChecking] = useState(true)
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [help, setHelp] = useState(false)
|
||||
useEffect(() => { void webRequest<{ backendUrl: string }>('/web/session').then(setSession).catch(() => {}).finally(() => setChecking(false)) }, [])
|
||||
useEffect(() => { const listener = (event: Event) => setMessage((event as CustomEvent<string>).detail); window.addEventListener('web-status', listener); return () => window.removeEventListener('web-status', listener) }, [])
|
||||
useEffect(() => { const listener = () => setHelp(true); window.addEventListener('open-settings', listener); return () => window.removeEventListener('open-settings', listener) }, [])
|
||||
if (checking) return <div className="p-12">Loading LTX DeskWEB…</div>
|
||||
if (!session) return <main className="min-h-screen flex items-center justify-center bg-zinc-950 text-white">
|
||||
<form className="w-full max-w-md p-8 border border-zinc-700 rounded-xl" onSubmit={async event => {
|
||||
event.preventDefault(); setError('')
|
||||
try { await webRequest('/web/login', { password }); setPassword(''); setSession(await webRequest('/web/session')) } catch (e) { setError(e instanceof Error ? e.message : String(e)) }
|
||||
}}>
|
||||
<h1 className="text-2xl font-bold">LTX DeskWEB</h1><p className="my-4 text-zinc-400">Browser workspace for your LTX backend.</p>
|
||||
<label htmlFor="password">Access password</label><input id="password" type="password" autoComplete="current-password" required value={password} onChange={e => setPassword(e.target.value)} className="mt-2 mb-4 w-full p-3 bg-zinc-800 rounded" />
|
||||
<button className="bg-blue-600 rounded px-4 py-2" type="submit">Sign in</button><p role="alert" className="mt-4 text-red-300">{error}</p>
|
||||
</form>
|
||||
</main>
|
||||
return <>
|
||||
<header className="flex justify-between items-center px-4 py-2 bg-zinc-900 border-b border-zinc-700 text-sm">
|
||||
<strong>LTX DeskWEB · Preview</strong><div className="flex gap-4"><button onClick={() => setHelp(!help)}>Connection & information</button><button onClick={async () => { await webRequest('/web/logout', {}); location.reload() }}>Sign out</button></div>
|
||||
</header>
|
||||
{help && <aside className="p-5 bg-zinc-900 border-b border-zinc-700 space-y-2">
|
||||
<p>Native LTX API via: <code>{session.backendUrl}</code>. Change the address and token in the container configuration.</p>
|
||||
<p>Video mode is managed in Athena Deck. This web app never starts or installs a model.</p>
|
||||
<p>Preview: projects are saved in this browser. Use “Back up projects” before clearing browser data. Timeline rendering, desktop folder dialogs and Hugging Face desktop login are not yet ported.</p>
|
||||
<WebProjectBackup />
|
||||
<button className="ml-4 underline" onClick={() => setHelp(false)}>Close</button>
|
||||
</aside>}
|
||||
{message && <div className="p-3 bg-blue-950 flex justify-between" role="status">{message}<button onClick={() => setMessage('')}>Dismiss</button></div>}
|
||||
<AppSettingsProvider><ProjectProvider><ViewProvider><KeyboardShortcutsProvider><DevFlagsProvider><Workspace /></DevFlagsProvider></KeyboardShortcutsProvider></ViewProvider></ProjectProvider></AppSettingsProvider>
|
||||
</>
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { KeyRound, X, Zap } from 'lucide-react'
|
||||
import { ApiKeyHelperRow, LtxApiKeyInput } from './LtxApiKeyInput'
|
||||
|
||||
export type ApiKeyType = 'ltx' | 'fal'
|
||||
|
||||
export interface ApiGatewaySection {
|
||||
keyType: ApiKeyType
|
||||
title: string
|
||||
description: string
|
||||
required: boolean
|
||||
isConfigured: boolean
|
||||
inputLabel: string
|
||||
placeholder?: string
|
||||
onSave: (apiKey: string) => Promise<void> | void
|
||||
onGetKey?: () => void
|
||||
getKeyLabel?: string
|
||||
}
|
||||
|
||||
export interface ApiGatewayModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
description: string
|
||||
sections: ApiGatewaySection[]
|
||||
blocking?: boolean
|
||||
}
|
||||
|
||||
const KEY_TYPE_META: Record<ApiKeyType, { icon: typeof Zap; iconClass: string; chipClass: string }> = {
|
||||
ltx: {
|
||||
icon: Zap,
|
||||
iconClass: 'text-blue-400',
|
||||
chipClass: 'bg-amber-500/10 text-amber-300',
|
||||
},
|
||||
fal: {
|
||||
icon: KeyRound,
|
||||
iconClass: 'text-cyan-400',
|
||||
chipClass: 'bg-zinc-800 text-zinc-400',
|
||||
},
|
||||
}
|
||||
|
||||
export function ApiGatewayModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
sections,
|
||||
blocking = false,
|
||||
}: ApiGatewayModalProps) {
|
||||
const [values, setValues] = useState<Record<ApiKeyType, string>>({ ltx: '', fal: '' })
|
||||
const [isSaving, setIsSaving] = useState<Record<ApiKeyType, boolean>>({ ltx: false, fal: false })
|
||||
const [errors, setErrors] = useState<Record<ApiKeyType, string | null>>({ ltx: null, fal: null })
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
setValues({ ltx: '', fal: '' })
|
||||
setIsSaving({ ltx: false, fal: false })
|
||||
setErrors({ ltx: null, fal: null })
|
||||
}, [isOpen])
|
||||
|
||||
const allRequiredConfigured = useMemo(() => {
|
||||
const requiredSections = sections.filter((section) => section.required)
|
||||
if (requiredSections.length === 0) return true
|
||||
return requiredSections.every((section) => section.isConfigured)
|
||||
}, [sections])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (!allRequiredConfigured) return
|
||||
if (sections.length <= 1) {
|
||||
onClose()
|
||||
}
|
||||
}, [isOpen, onClose, sections.length, allRequiredConfigured])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !blocking) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [blocking, isOpen, onClose])
|
||||
|
||||
const handleSave = async (section: ApiGatewaySection) => {
|
||||
const keyType = section.keyType
|
||||
const trimmedKey = (values[keyType] ?? '').trim()
|
||||
if (!trimmedKey) {
|
||||
setErrors((prev) => ({ ...prev, [keyType]: `Please enter a valid ${section.inputLabel}.` }))
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving((prev) => ({ ...prev, [keyType]: true }))
|
||||
setErrors((prev) => ({ ...prev, [keyType]: null }))
|
||||
try {
|
||||
await section.onSave(trimmedKey)
|
||||
setValues((prev) => ({ ...prev, [keyType]: '' }))
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.trim()) {
|
||||
setErrors((prev) => ({ ...prev, [keyType]: err.message }))
|
||||
} else {
|
||||
setErrors((prev) => ({ ...prev, [keyType]: 'Failed to save API key.' }))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving((prev) => ({ ...prev, [keyType]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const requiredMissing = useMemo(() => sections.some((section) => section.required && !section.isConfigured), [sections])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-[620px] rounded-xl border border-zinc-700 bg-zinc-900 shadow-2xl overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-zinc-800 px-5 py-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-500/15 text-blue-300">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-zinc-100">{title}</h2>
|
||||
</div>
|
||||
{!blocking && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-300"
|
||||
aria-label="Close API gateway modal"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 px-5 py-5">
|
||||
<p className="text-sm leading-relaxed text-zinc-300">{description}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{sections.map((section) => {
|
||||
const meta = KEY_TYPE_META[section.keyType]
|
||||
const Icon = meta.icon
|
||||
const configured = section.isConfigured
|
||||
const saving = isSaving[section.keyType]
|
||||
const value = values[section.keyType] ?? ''
|
||||
const error = errors[section.keyType]
|
||||
const canSubmit = value.trim().length > 0 && !saving
|
||||
|
||||
return (
|
||||
<div key={section.keyType} className="rounded-lg border border-zinc-800 bg-zinc-900/60 p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`h-4 w-4 ${meta.iconClass}`} />
|
||||
<h3 className="text-sm font-semibold text-white">{section.title}</h3>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${section.required ? meta.chipClass : 'bg-zinc-800 text-zinc-500'}`}>
|
||||
{section.required ? 'Required' : 'Optional'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 mt-1">{section.description}</p>
|
||||
</div>
|
||||
<div className={`text-xs px-2 py-1 rounded inline-flex items-center gap-1.5 ${configured ? 'bg-green-500/10 text-green-400' : 'bg-zinc-800 text-zinc-500'}`}>
|
||||
{configured ? 'Configured' : 'Not set'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!configured && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs text-zinc-300">{section.inputLabel}</label>
|
||||
<div className="flex gap-2">
|
||||
<LtxApiKeyInput
|
||||
value={value}
|
||||
onChange={(event) => setValues((prev) => ({ ...prev, [section.keyType]: event.target.value }))}
|
||||
placeholder={section.placeholder ?? 'Paste your API key'}
|
||||
className="flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSave(section)}
|
||||
disabled={!canSubmit}
|
||||
className="px-3 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-500 disabled:bg-zinc-700 disabled:text-zinc-500 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Key'}
|
||||
</button>
|
||||
</div>
|
||||
<ApiKeyHelperRow
|
||||
label={section.getKeyLabel ?? 'Get API key'}
|
||||
onOpenKey={section.onGetKey}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{blocking && requiredMissing && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
||||
Required API keys are missing. Add them to continue.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sections.length > 1 && allRequiredConfigured && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Check, ChevronLeft, ChevronRight, Copy, X } from 'lucide-react'
|
||||
import type { Asset } from '../types/project-model'
|
||||
import { pathToFileUrl } from '../lib/file-url'
|
||||
import { formatPipelineDisplayName } from '../lib/video-generation-model-specs'
|
||||
|
||||
const mediaClassName =
|
||||
'mx-auto max-h-[calc(100dvh-18vh-7rem)] max-w-full rounded-xl object-contain'
|
||||
|
||||
// Duration can be a raw float (e.g. extend output: 12.041667s). Show a clean value:
|
||||
// integers as-is, otherwise at most 2 decimals with trailing zeros trimmed.
|
||||
function formatSeconds(seconds: number): string {
|
||||
return Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(2).replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
function formatDurationLabel(asset: Asset): string | null {
|
||||
if (asset.type === 'image') return 'Image'
|
||||
// Requested Auto is persisted as generationParams.duration === null. Do not fall through
|
||||
// to omitting the chip — that made Auto clips look like they had no duration at all.
|
||||
if (asset.generationParams?.duration === null) return 'Auto duration'
|
||||
if (asset.duration) return `${formatSeconds(asset.duration)}s`
|
||||
return null
|
||||
}
|
||||
|
||||
export interface AssetPreviewModalProps {
|
||||
asset: Asset
|
||||
/** 0-based index into the current filtered gallery. */
|
||||
index: number
|
||||
total: number
|
||||
canGoPrev: boolean
|
||||
canGoNext: boolean
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function AssetPreviewModal({
|
||||
asset,
|
||||
index,
|
||||
total,
|
||||
canGoPrev,
|
||||
canGoNext,
|
||||
onPrev,
|
||||
onNext,
|
||||
onClose,
|
||||
}: AssetPreviewModalProps) {
|
||||
const [copiedPrompt, setCopiedPrompt] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); onPrev() }
|
||||
else if (e.key === 'ArrowRight') { e.preventDefault(); onNext() }
|
||||
else if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', handleKey)
|
||||
return () => window.removeEventListener('keydown', handleKey)
|
||||
}, [onPrev, onNext, onClose])
|
||||
|
||||
// Reset copy affordance when paging between assets.
|
||||
useEffect(() => {
|
||||
setCopiedPrompt(false)
|
||||
}, [asset.id])
|
||||
|
||||
// Image assets also store a placeholder `model` (e.g. "fast" for Z-Image) that is not an
|
||||
// LTX video pipeline — only label known video pipelines on video assets. Prefer the label
|
||||
// captured at generation time: the local "fast" pipeline id is shared by every LTX version,
|
||||
// so mapping it here would report the wrong version.
|
||||
const modelLabel = asset.type === 'video'
|
||||
? asset.generationParams?.modelLabel ?? formatPipelineDisplayName(asset.generationParams?.model)
|
||||
: null
|
||||
const metaParts = [
|
||||
modelLabel,
|
||||
asset.resolution || null,
|
||||
formatDurationLabel(asset),
|
||||
].filter(Boolean)
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/90"
|
||||
onClick={onClose}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||||
disabled={!canGoPrev}
|
||||
className={`absolute left-4 top-1/2 -translate-y-1/2 z-10 p-3 rounded-full backdrop-blur-md transition-all ${
|
||||
canGoPrev
|
||||
? 'bg-white/10 text-white hover:bg-white/20 cursor-pointer'
|
||||
: 'bg-white/5 text-zinc-600 cursor-default'
|
||||
}`}
|
||||
>
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onNext() }}
|
||||
disabled={!canGoNext}
|
||||
className={`absolute right-4 top-1/2 -translate-y-1/2 z-10 p-3 rounded-full backdrop-blur-md transition-all ${
|
||||
canGoNext
|
||||
? 'bg-white/10 text-white hover:bg-white/20 cursor-pointer'
|
||||
: 'bg-white/5 text-zinc-600 cursor-default'
|
||||
}`}
|
||||
>
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className="relative flex max-h-full w-full max-w-5xl flex-col px-20 py-6"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between">
|
||||
<span className="text-sm text-zinc-500 font-medium">
|
||||
{index + 1} / {total}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-md text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{asset.type === 'video' ? (
|
||||
<video
|
||||
key={asset.id}
|
||||
src={pathToFileUrl(asset.path)}
|
||||
controls
|
||||
autoPlay
|
||||
className={mediaClassName}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={asset.id}
|
||||
src={pathToFileUrl(asset.path)}
|
||||
alt=""
|
||||
className={mediaClassName}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-3 shrink-0 text-center">
|
||||
<div className="inline-flex max-w-full items-start gap-2">
|
||||
<p className="max-h-[18vh] overflow-y-auto whitespace-pre-wrap break-words text-left text-zinc-300">{asset.prompt}</p>
|
||||
{asset.prompt && (
|
||||
<button
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(asset.prompt)
|
||||
setCopiedPrompt(true)
|
||||
setTimeout(() => setCopiedPrompt(false), 2000)
|
||||
}}
|
||||
className="shrink-0 p-1 rounded hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
title="Copy prompt"
|
||||
>
|
||||
{copiedPrompt ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{metaParts.length > 0 && (
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{metaParts.join(' • ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||
import { Music } from 'lucide-react'
|
||||
import { logger } from '../lib/logger'
|
||||
|
||||
interface AudioClipInfo {
|
||||
url: string
|
||||
name: string
|
||||
startTime: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
interface AudioWaveformProps {
|
||||
audioClips: AudioClipInfo[]
|
||||
currentTime: number
|
||||
isPlaying: boolean
|
||||
}
|
||||
|
||||
// Global waveform cache: URL → Float32Array of peak amplitudes (one per pixel-bucket)
|
||||
export const waveformCache = new Map<string, Float32Array>()
|
||||
const pendingDecodes = new Set<string>()
|
||||
|
||||
// Convert a base64 string to an ArrayBuffer
|
||||
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binaryString = atob(base64)
|
||||
const bytes = new Uint8Array(binaryString.length)
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
// Decode audio file and extract amplitude envelope
|
||||
export async function computeWaveform(url: string, buckets: number = 800): Promise<Float32Array> {
|
||||
if (waveformCache.has(url)) return waveformCache.get(url)!
|
||||
|
||||
if (pendingDecodes.has(url)) {
|
||||
while (pendingDecodes.has(url)) {
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
}
|
||||
if (waveformCache.has(url)) return waveformCache.get(url)!
|
||||
}
|
||||
|
||||
pendingDecodes.add(url)
|
||||
try {
|
||||
let arrayBuffer: ArrayBuffer
|
||||
|
||||
if (url.startsWith('file://') && (window as any).electronAPI?.readLocalFile) {
|
||||
const { data } = await (window as any).electronAPI.readLocalFile({ filePath: url })
|
||||
arrayBuffer = base64ToArrayBuffer(data)
|
||||
} else {
|
||||
const response = await fetch(url)
|
||||
arrayBuffer = await response.arrayBuffer()
|
||||
}
|
||||
|
||||
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer)
|
||||
audioCtx.close()
|
||||
|
||||
const channelData = audioBuffer.getChannelData(0)
|
||||
const samplesPerBucket = Math.floor(channelData.length / buckets)
|
||||
const peaks = new Float32Array(buckets)
|
||||
|
||||
for (let i = 0; i < buckets; i++) {
|
||||
let max = 0
|
||||
const start = i * samplesPerBucket
|
||||
const end = Math.min(start + samplesPerBucket, channelData.length)
|
||||
for (let j = start; j < end; j++) {
|
||||
const abs = Math.abs(channelData[j])
|
||||
if (abs > max) max = abs
|
||||
}
|
||||
peaks[i] = max
|
||||
}
|
||||
|
||||
waveformCache.set(url, peaks)
|
||||
return peaks
|
||||
} finally {
|
||||
pendingDecodes.delete(url)
|
||||
}
|
||||
}
|
||||
|
||||
export function AudioWaveform({ audioClips, currentTime, isPlaying }: AudioWaveformProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [waveforms, setWaveforms] = useState<Map<string, Float32Array>>(new Map())
|
||||
const animRef = useRef<number>(0)
|
||||
|
||||
// Load waveform data for all clips
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const loadAll = async () => {
|
||||
const newMap = new Map<string, Float32Array>()
|
||||
for (const clip of audioClips) {
|
||||
if (!clip.url) continue
|
||||
try {
|
||||
const peaks = await computeWaveform(clip.url)
|
||||
if (cancelled) return
|
||||
newMap.set(clip.url, peaks)
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to decode audio waveform: ${clip.url} ${e}`)
|
||||
}
|
||||
}
|
||||
if (!cancelled) setWaveforms(newMap)
|
||||
}
|
||||
loadAll()
|
||||
return () => { cancelled = true }
|
||||
}, [audioClips.map(c => c.url).join(',')])
|
||||
|
||||
// Draw waveform on canvas
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
const container = containerRef.current
|
||||
if (!canvas || !container) return
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const rect = container.getBoundingClientRect()
|
||||
const w = rect.width
|
||||
const h = rect.height
|
||||
|
||||
canvas.width = w * dpr
|
||||
canvas.height = h * dpr
|
||||
canvas.style.width = `${w}px`
|
||||
canvas.style.height = `${h}px`
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = '#0a0a0a'
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
|
||||
// Grid lines (subtle)
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.04)'
|
||||
ctx.lineWidth = 1
|
||||
const centerY = h / 2
|
||||
// Horizontal center line
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, centerY)
|
||||
ctx.lineTo(w, centerY)
|
||||
ctx.stroke()
|
||||
// Quarter lines
|
||||
for (const frac of [0.25, 0.75]) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, h * frac)
|
||||
ctx.lineTo(w, h * frac)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
if (audioClips.length === 0) return
|
||||
|
||||
// For simplicity, render the first (or longest) audio clip's waveform
|
||||
// filling the entire monitor width. If multiple clips, overlay them.
|
||||
const maxAmplitude = h * 0.4 // 40% of height above and below center
|
||||
|
||||
for (let ci = 0; ci < audioClips.length; ci++) {
|
||||
const clip = audioClips[ci]
|
||||
const peaks = waveforms.get(clip.url)
|
||||
if (!peaks || peaks.length === 0) continue
|
||||
|
||||
// Map clip's time range to screen
|
||||
const clipProgress = (currentTime - clip.startTime) / clip.duration
|
||||
|
||||
// Color: emerald with some alpha for overlapping
|
||||
const alpha = audioClips.length > 1 ? 0.6 : 0.9
|
||||
const gradient = ctx.createLinearGradient(0, centerY - maxAmplitude, 0, centerY + maxAmplitude)
|
||||
gradient.addColorStop(0, `rgba(52, 211, 153, ${alpha})`) // emerald-400
|
||||
gradient.addColorStop(0.5, `rgba(16, 185, 129, ${alpha})`) // emerald-500
|
||||
gradient.addColorStop(1, `rgba(52, 211, 153, ${alpha})`)
|
||||
|
||||
// Draw filled waveform (mirrored around center)
|
||||
ctx.fillStyle = gradient
|
||||
ctx.beginPath()
|
||||
|
||||
// Top half (positive)
|
||||
for (let i = 0; i < w; i++) {
|
||||
const peakIdx = Math.floor((i / w) * peaks.length)
|
||||
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
|
||||
const y = centerY - amp * maxAmplitude
|
||||
if (i === 0) ctx.moveTo(i, y)
|
||||
else ctx.lineTo(i, y)
|
||||
}
|
||||
|
||||
// Bottom half (negative, traced backwards)
|
||||
for (let i = w - 1; i >= 0; i--) {
|
||||
const peakIdx = Math.floor((i / w) * peaks.length)
|
||||
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
|
||||
const y = centerY + amp * maxAmplitude
|
||||
ctx.lineTo(i, y)
|
||||
}
|
||||
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
// Played region: brighter overlay
|
||||
if (clipProgress > 0 && clipProgress <= 1) {
|
||||
const playedX = clipProgress * w
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.rect(0, 0, playedX, h)
|
||||
ctx.clip()
|
||||
|
||||
const brightGradient = ctx.createLinearGradient(0, centerY - maxAmplitude, 0, centerY + maxAmplitude)
|
||||
brightGradient.addColorStop(0, 'rgba(52, 211, 153, 0.3)')
|
||||
brightGradient.addColorStop(0.5, 'rgba(16, 185, 129, 0.3)')
|
||||
brightGradient.addColorStop(1, 'rgba(52, 211, 153, 0.3)')
|
||||
|
||||
ctx.fillStyle = brightGradient
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i < w; i++) {
|
||||
const peakIdx = Math.floor((i / w) * peaks.length)
|
||||
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
|
||||
const y = centerY - amp * maxAmplitude
|
||||
if (i === 0) ctx.moveTo(i, y)
|
||||
else ctx.lineTo(i, y)
|
||||
}
|
||||
for (let i = w - 1; i >= 0; i--) {
|
||||
const peakIdx = Math.floor((i / w) * peaks.length)
|
||||
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
|
||||
const y = centerY + amp * maxAmplitude
|
||||
ctx.lineTo(i, y)
|
||||
}
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// Playhead line
|
||||
if (clipProgress >= 0 && clipProgress <= 1) {
|
||||
const px = clipProgress * w
|
||||
ctx.strokeStyle = '#ffffff'
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(px, 4)
|
||||
ctx.lineTo(px, h - 4)
|
||||
ctx.stroke()
|
||||
|
||||
// Small triangle at top
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(px, 2)
|
||||
ctx.lineTo(px - 4, 8)
|
||||
ctx.lineTo(px + 4, 8)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
// If no waveform data loaded yet, show loading indicator
|
||||
if (waveforms.size === 0) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.3)'
|
||||
ctx.font = '12px system-ui, sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('Loading waveform...', w / 2, centerY)
|
||||
}
|
||||
}, [audioClips, currentTime, waveforms])
|
||||
|
||||
// Animate during playback
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
const animate = () => {
|
||||
draw()
|
||||
animRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
animRef.current = requestAnimationFrame(animate)
|
||||
return () => cancelAnimationFrame(animRef.current)
|
||||
} else {
|
||||
draw()
|
||||
}
|
||||
}, [isPlaying, draw])
|
||||
|
||||
// Redraw on resize
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(() => draw())
|
||||
if (containerRef.current) observer.observe(containerRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [draw])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full flex flex-col">
|
||||
{/* Canvas fills available space */}
|
||||
<div className="flex-1 relative min-h-0">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
{/* Small music icon badge */}
|
||||
<div className="absolute top-3 left-3 flex items-center gap-2 px-2 py-1 rounded bg-black/60">
|
||||
<Music className="h-3 w-3 text-emerald-400" />
|
||||
<span className="text-[10px] text-emerald-400 font-medium">Audio</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Clip names */}
|
||||
{audioClips.length > 0 && (
|
||||
<div className="flex-shrink-0 px-3 py-1.5 bg-zinc-950 border-t border-zinc-800">
|
||||
{audioClips.map((clip, i) => (
|
||||
<p key={i} className="text-[10px] text-zinc-500 truncate">
|
||||
{clip.name}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Compact inline waveform for timeline audio clips ---
|
||||
|
||||
interface ClipWaveformProps {
|
||||
url: string
|
||||
className?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export function ClipWaveform({ url, className = '', color = 'rgba(52, 211, 153, 0.7)' }: ClipWaveformProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [peaks, setPeaks] = useState<Float32Array | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return
|
||||
let cancelled = false
|
||||
computeWaveform(url, 200).then(p => {
|
||||
if (!cancelled) setPeaks(p)
|
||||
}).catch(() => {})
|
||||
return () => { cancelled = true }
|
||||
}, [url])
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
const container = containerRef.current
|
||||
if (!canvas || !container || !peaks) return
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const rect = container.getBoundingClientRect()
|
||||
const w = rect.width
|
||||
const h = rect.height
|
||||
if (w === 0 || h === 0) return
|
||||
|
||||
canvas.width = w * dpr
|
||||
canvas.height = h * dpr
|
||||
canvas.style.width = `${w}px`
|
||||
canvas.style.height = `${h}px`
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.scale(dpr, dpr)
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
const centerY = h / 2
|
||||
const maxAmp = h * 0.45
|
||||
|
||||
ctx.fillStyle = color
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i < w; i++) {
|
||||
const peakIdx = Math.floor((i / w) * peaks.length)
|
||||
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
|
||||
const y = centerY - amp * maxAmp
|
||||
if (i === 0) ctx.moveTo(i, y)
|
||||
else ctx.lineTo(i, y)
|
||||
}
|
||||
for (let i = w - 1; i >= 0; i--) {
|
||||
const peakIdx = Math.floor((i / w) * peaks.length)
|
||||
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
|
||||
const y = centerY + amp * maxAmp
|
||||
ctx.lineTo(i, y)
|
||||
}
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}, [peaks, color])
|
||||
|
||||
useEffect(() => {
|
||||
draw()
|
||||
}, [draw])
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(() => draw())
|
||||
if (containerRef.current) observer.observe(containerRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [draw])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`absolute inset-0 ${className}`}>
|
||||
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Copy, Check } from 'lucide-react'
|
||||
import { useDevFlags, DEV_FLAGS, type DevFlagKey } from '../contexts/DevFlagsContext'
|
||||
|
||||
// Master-detail feature-flag panel (Ctrl/Cmd+Shift+D). List on the left
|
||||
// (green = enabled, ring = selected), detail + toggle on the right.
|
||||
export function DevPanel() {
|
||||
const { flags, setFlag, isPanelOpen, setPanelOpen } = useDevFlags()
|
||||
const [selectedKey, setSelectedKey] = useState<DevFlagKey>(DEV_FLAGS[0]?.key)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPanelOpen) return
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setPanelOpen(false) }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [isPanelOpen, setPanelOpen])
|
||||
|
||||
if (!isPanelOpen) return null
|
||||
|
||||
const selected = DEV_FLAGS.find(f => f.key === selectedKey) ?? DEV_FLAGS[0]
|
||||
const isOn = selected ? flags[selected.key] : false
|
||||
|
||||
const copyKey = () => {
|
||||
if (!selected) return
|
||||
void navigator.clipboard?.writeText(selected.key).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1200)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setPanelOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="flex h-[440px] w-full max-w-3xl flex-col overflow-hidden rounded-xl border border-zinc-700 bg-zinc-900 shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-zinc-800 px-5 py-3.5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">Dev Panel</h2>
|
||||
<p className="text-[11px] text-zinc-500">Local-only feature toggles, saved on this machine.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPanelOpen(false)}
|
||||
className="rounded-md p-1.5 text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-white"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body: list + detail */}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* List */}
|
||||
<div className="w-64 shrink-0 overflow-y-auto border-r border-zinc-800 p-2">
|
||||
{DEV_FLAGS.map(spec => {
|
||||
const on = flags[spec.key]
|
||||
const isSelected = spec.key === selectedKey
|
||||
return (
|
||||
<button
|
||||
key={spec.key}
|
||||
onClick={() => setSelectedKey(spec.key)}
|
||||
className={`mb-1 w-full rounded-md px-3 py-2 text-left text-sm transition-colors ${
|
||||
isSelected
|
||||
? 'text-white ring-1 ring-blue-500/60 ' + (on ? 'bg-emerald-900/40' : 'bg-zinc-800')
|
||||
: on
|
||||
? 'bg-emerald-900/30 text-emerald-100 hover:bg-emerald-900/50'
|
||||
: 'text-zinc-300 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{spec.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Detail */}
|
||||
<div className="min-w-0 flex-1 overflow-y-auto p-5">
|
||||
{selected && (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold text-white">{selected.label}</h3>
|
||||
<button
|
||||
onClick={copyKey}
|
||||
className="flex shrink-0 items-center gap-1.5 font-mono text-[11px] text-zinc-500 transition-colors hover:text-zinc-300"
|
||||
title="Copy flag key"
|
||||
>
|
||||
{selected.key}
|
||||
{copied ? <Check className="h-3 w-3 text-green-400" /> : <Copy className="h-3 w-3" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-zinc-400">Enabled</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isOn}
|
||||
onClick={() => setFlag(selected.key, !isOn)}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||
isOn ? 'bg-blue-500' : 'bg-zinc-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
isOn ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<p className="mb-1 text-xs font-medium text-zinc-400">Description</p>
|
||||
<p className="text-sm leading-relaxed text-zinc-300">{selected.description}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from 'react'
|
||||
import { X, Download, FolderOpen, Film, Package, Loader2, Check, AlertCircle, ChevronDown } from 'lucide-react'
|
||||
import { Button } from './ui/button'
|
||||
import { DEFAULT_SUBTITLE_STYLE } from '../types/project-model'
|
||||
import type { Track, TimelineClip } from '../types/project-model'
|
||||
import {
|
||||
selectActiveTimeline,
|
||||
selectAssets,
|
||||
selectClipPathFromAssets,
|
||||
selectClips,
|
||||
selectShowExportModal,
|
||||
selectSubtitles,
|
||||
selectTracks,
|
||||
} from '../views/editor/editor-selectors'
|
||||
import { useEditorActions, useEditorStore } from '../views/editor/editor-store'
|
||||
|
||||
interface ExportModalProps {
|
||||
projectName: string
|
||||
}
|
||||
|
||||
type ExportStatus = 'idle' | 'exporting' | 'done' | 'error'
|
||||
type ExportCodec = 'h264' | 'prores' | 'vp9'
|
||||
|
||||
interface ExportSettings {
|
||||
codec: ExportCodec
|
||||
width: number
|
||||
height: number
|
||||
fps: number
|
||||
quality: number // CRF for h264, profile for prores, bitrate(Mbps) for vp9
|
||||
}
|
||||
|
||||
const CODEC_INFO: Record<ExportCodec, { label: string; ext: string; description: string; filterName: string }> = {
|
||||
h264: { label: 'H.264 / MP4', ext: 'mp4', description: 'Most compatible format', filterName: 'MP4 Video' },
|
||||
prores: { label: 'ProRes / MOV', ext: 'mov', description: 'Professional editing format', filterName: 'QuickTime Movie' },
|
||||
vp9: { label: 'VP9 / WebM', ext: 'webm', description: 'Web-optimized format', filterName: 'WebM Video' },
|
||||
}
|
||||
|
||||
const RESOLUTIONS = [
|
||||
{ label: '4K (3840 x 2160)', width: 3840, height: 2160 },
|
||||
{ label: '1080p (1920 x 1080)', width: 1920, height: 1080 },
|
||||
{ label: '720p (1280 x 720)', width: 1280, height: 720 },
|
||||
]
|
||||
|
||||
const FRAME_RATES = [24, 25, 30, 60]
|
||||
|
||||
const PRORES_PROFILES = [
|
||||
{ value: 0, label: 'Proxy' },
|
||||
{ value: 1, label: 'LT' },
|
||||
{ value: 2, label: 'Standard' },
|
||||
{ value: 3, label: 'HQ' },
|
||||
]
|
||||
|
||||
const LETTERBOX_RATIO_MAP: Record<string, number> = {
|
||||
'2.35:1': 2.35,
|
||||
'2.39:1': 2.39,
|
||||
'2.76:1': 2.76,
|
||||
'1.85:1': 1.85,
|
||||
'4:3': 4 / 3,
|
||||
}
|
||||
|
||||
// Generate FCPXML for Premiere / DaVinci
|
||||
function generateFCPXML(
|
||||
clips: TimelineClip[],
|
||||
tracks: Track[],
|
||||
projectName: string,
|
||||
timelineName: string,
|
||||
fps: number = 24
|
||||
): string {
|
||||
const frameDuration = `${Math.round(100 * fps)}/${100 * fps}s`
|
||||
const totalDuration = clips.reduce((max, c) => Math.max(max, c.startTime + c.duration), 0)
|
||||
const totalFrames = Math.ceil(totalDuration * fps)
|
||||
|
||||
const assetEntries: string[] = []
|
||||
const seenAssets = new Set<string>()
|
||||
for (const clip of clips) {
|
||||
const assetId = clip.assetId || clip.id
|
||||
if (seenAssets.has(assetId)) continue
|
||||
seenAssets.add(assetId)
|
||||
|
||||
const assetPath = clip.asset?.path || ''
|
||||
const dur = clip.asset?.duration || clip.duration
|
||||
const durFrames = Math.ceil(dur * fps)
|
||||
const format = clip.type === 'audio' ? 'audio' : 'video'
|
||||
|
||||
assetEntries.push(
|
||||
` <asset id="${escapeXml(assetId)}" name="${escapeXml(clip.asset?.prompt?.slice(0, 60) || clip.importedName || 'Clip')}" src="${escapeXml(assetPath)}" start="0s" duration="${durFrames}/${fps}s" hasVideo="${format === 'video' ? '1' : '0'}" hasAudio="1" format="r1" />`
|
||||
)
|
||||
}
|
||||
|
||||
const trackGroups: Map<number, TimelineClip[]> = new Map()
|
||||
for (const clip of clips) {
|
||||
if (!trackGroups.has(clip.trackIndex)) trackGroups.set(clip.trackIndex, [])
|
||||
trackGroups.get(clip.trackIndex)!.push(clip)
|
||||
}
|
||||
|
||||
const laneXml: string[] = []
|
||||
const sortedTrackIndices = [...trackGroups.keys()].sort((a, b) => a - b)
|
||||
|
||||
for (const trackIdx of sortedTrackIndices) {
|
||||
const trackClips = trackGroups.get(trackIdx)!.sort((a, b) => a.startTime - b.startTime)
|
||||
const clipElements: string[] = []
|
||||
|
||||
for (const clip of trackClips) {
|
||||
const assetId = clip.assetId || clip.id
|
||||
const startFrame = Math.round(clip.startTime * fps)
|
||||
const durFrames = Math.round(clip.duration * fps)
|
||||
const trimStartFrame = Math.round(clip.trimStart * fps)
|
||||
const name = clip.asset?.prompt?.slice(0, 60) || clip.importedName || 'Clip'
|
||||
|
||||
let clipXml = ` <asset-clip ref="${escapeXml(assetId)}" name="${escapeXml(name)}" offset="${startFrame}/${fps}s" duration="${durFrames}/${fps}s" start="${trimStartFrame}/${fps}s"`
|
||||
if (clip.speed !== 1) {
|
||||
clipXml += ` tcFormat="NDF"`
|
||||
}
|
||||
clipXml += ` />`
|
||||
clipElements.push(clipXml)
|
||||
}
|
||||
|
||||
const trackName = tracks[trackIdx]?.name || `Track ${trackIdx + 1}`
|
||||
laneXml.push(
|
||||
` <!-- ${escapeXml(trackName)} -->\n` +
|
||||
clipElements.join('\n')
|
||||
)
|
||||
}
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE fcpxml>
|
||||
<fcpxml version="1.10">
|
||||
<resources>
|
||||
<format id="r1" name="FFVideoFormat${fps === 24 ? '1080p2398' : '1080p' + fps}" frameDuration="${frameDuration}" width="1920" height="1080" />
|
||||
${assetEntries.join('\n')}
|
||||
</resources>
|
||||
<library>
|
||||
<event name="${escapeXml(projectName)}">
|
||||
<project name="${escapeXml(timelineName)}">
|
||||
<sequence format="r1" duration="${totalFrames}/${fps}s" tcStart="0s" tcFormat="NDF">
|
||||
<spine>
|
||||
${laneXml.join('\n')}
|
||||
</spine>
|
||||
</sequence>
|
||||
</project>
|
||||
</event>
|
||||
</library>
|
||||
</fcpxml>`
|
||||
}
|
||||
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.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<ExportStatus>('idle')
|
||||
const [exportType, setExportType] = useState<'package' | 'video' | null>(null)
|
||||
const [exportProgress, setExportProgress] = useState(0)
|
||||
const [exportError, setExportError] = useState<string | null>(null)
|
||||
const [exportPath, setExportPath] = useState<string | null>(null)
|
||||
const [exportFrameInfo, setExportFrameInfo] = useState('')
|
||||
const abortRef = useRef(false)
|
||||
|
||||
// Export settings
|
||||
const [settings, setSettings] = useState<ExportSettings>({
|
||||
codec: 'h264',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 24,
|
||||
quality: 18, // CRF 18 for h264
|
||||
})
|
||||
const [burnSubtitles, setBurnSubtitles] = useState(true)
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
closeExportModal()
|
||||
}, [closeExportModal])
|
||||
|
||||
const hasSubtitles = subtitleData.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
setExportStatus('idle')
|
||||
setExportType(null)
|
||||
setExportProgress(0)
|
||||
setExportError(null)
|
||||
setExportPath(null)
|
||||
setExportFrameInfo('')
|
||||
abortRef.current = false
|
||||
}, [isOpen])
|
||||
|
||||
// Update quality default when codec changes
|
||||
const handleCodecChange = useCallback((codec: ExportCodec) => {
|
||||
let quality = 18
|
||||
if (codec === 'prores') quality = 3 // HQ profile
|
||||
if (codec === 'vp9') quality = 8 // 8 Mbps
|
||||
setSettings(prev => ({ ...prev, codec, quality }))
|
||||
}, [])
|
||||
|
||||
const handleExportPackage = useCallback(async () => {
|
||||
if (!timeline) return
|
||||
setExportType('package')
|
||||
setExportStatus('exporting')
|
||||
setExportProgress(0)
|
||||
setExportError(null)
|
||||
|
||||
try {
|
||||
const filePath = await window.electronAPI?.showSaveDialog({
|
||||
title: 'Export FCPXML Package',
|
||||
defaultPath: `${projectName}_${timeline.name}.fcpxml`,
|
||||
filters: [
|
||||
{ name: 'Final Cut Pro XML', extensions: ['fcpxml'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
})
|
||||
|
||||
if (!filePath) {
|
||||
setExportStatus('idle')
|
||||
return
|
||||
}
|
||||
|
||||
setExportProgress(50)
|
||||
const xml = generateFCPXML(clips, tracks, projectName, timeline.name)
|
||||
const result = await window.electronAPI?.saveFile({ filePath, data: xml })
|
||||
if (result?.success) {
|
||||
setExportProgress(100)
|
||||
setExportPath(filePath)
|
||||
setExportStatus('done')
|
||||
} else {
|
||||
throw new Error(result && !result.success ? result.error : 'Failed to save file')
|
||||
}
|
||||
} catch (err) {
|
||||
setExportError(String(err))
|
||||
setExportStatus('error')
|
||||
}
|
||||
}, [clips, tracks, timeline, projectName])
|
||||
|
||||
const handleExportVideo = useCallback(async () => {
|
||||
if (!timeline || clips.length === 0) return
|
||||
setExportType('video')
|
||||
setExportStatus('exporting')
|
||||
setExportProgress(0)
|
||||
setExportError(null)
|
||||
setExportFrameInfo('Preparing...')
|
||||
abortRef.current = false
|
||||
|
||||
try {
|
||||
const codecInfo = CODEC_INFO[settings.codec]
|
||||
|
||||
const filePath = await window.electronAPI?.showSaveDialog({
|
||||
title: `Export ${codecInfo.label}`,
|
||||
defaultPath: `${projectName}_${timeline.name}.${codecInfo.ext}`,
|
||||
filters: [
|
||||
{ name: codecInfo.filterName, extensions: [codecInfo.ext] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
})
|
||||
|
||||
if (!filePath) {
|
||||
setExportStatus('idle')
|
||||
return
|
||||
}
|
||||
|
||||
// Build clip data for ffmpeg native export (video/image + audio clips)
|
||||
setExportFrameInfo('Starting ffmpeg...')
|
||||
|
||||
const result = await window.electronAPI?.exportNative({
|
||||
clips: exportClips,
|
||||
outputPath: filePath,
|
||||
codec: settings.codec,
|
||||
width: settings.width,
|
||||
height: settings.height,
|
||||
fps: settings.fps,
|
||||
quality: settings.quality,
|
||||
letterbox: letterbox || undefined,
|
||||
subtitles: burnSubtitles && subtitleData.length > 0 ? subtitleData : undefined,
|
||||
})
|
||||
|
||||
if (result && !result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
setExportProgress(100)
|
||||
setExportPath(filePath)
|
||||
setExportFrameInfo('Export complete')
|
||||
setExportStatus('done')
|
||||
} catch (err) {
|
||||
setExportError(String(err))
|
||||
setExportStatus('error')
|
||||
}
|
||||
}, [burnSubtitles, exportClips, letterbox, projectName, settings, subtitleData, timeline])
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
abortRef.current = true
|
||||
window.electronAPI?.exportCancel({ sessionId: 'current' }).catch(() => {})
|
||||
setExportStatus('idle')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" onClick={closeModal}>
|
||||
<div
|
||||
className="bg-zinc-900 rounded-2xl border border-zinc-700/50 shadow-2xl w-full max-w-lg relative overflow-hidden max-h-[calc(100vh-2rem)] flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800">
|
||||
<h2 className="text-lg font-bold text-white">Export</h2>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="p-1.5 rounded-lg text-zinc-500 hover:text-white hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
{/* Exporting state */}
|
||||
{exportStatus === 'exporting' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="h-5 w-5 text-blue-400 animate-spin" />
|
||||
<span className="text-sm text-zinc-300">
|
||||
{exportType === 'package' ? 'Generating FCPXML...' : 'Rendering video...'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-zinc-800 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${exportProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-zinc-500">{exportProgress}% complete</p>
|
||||
{exportFrameInfo && <p className="text-xs text-zinc-500">{exportFrameInfo}</p>}
|
||||
</div>
|
||||
{exportType === 'video' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-zinc-700 text-zinc-400"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Done state */}
|
||||
{exportStatus === 'done' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<Check className="h-5 w-5 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white font-medium">Export complete</p>
|
||||
<p className="text-xs text-zinc-500 truncate max-w-[340px]">{exportPath}</p>
|
||||
{exportFrameInfo && <p className="text-xs text-zinc-500">{exportFrameInfo}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-zinc-700 text-zinc-300"
|
||||
onClick={() => {
|
||||
if (exportPath) {
|
||||
window.electronAPI?.openParentFolderOfFile({ filePath: exportPath })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 mr-2" />
|
||||
Show in Folder
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-zinc-700 text-zinc-300"
|
||||
onClick={() => {
|
||||
setExportStatus('idle')
|
||||
setExportType(null)
|
||||
}}
|
||||
>
|
||||
Export Another
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{exportStatus === 'error' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||
<AlertCircle className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white font-medium">Export failed</p>
|
||||
<p className="text-xs text-red-400 max-w-[340px] break-words">{exportError}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-zinc-700 text-zinc-300"
|
||||
onClick={() => {
|
||||
setExportStatus('idle')
|
||||
setExportType(null)
|
||||
}}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Idle state — settings */}
|
||||
{exportStatus === 'idle' && (
|
||||
<div className="space-y-5">
|
||||
{/* Package export (compact) */}
|
||||
<button
|
||||
onClick={handleExportPackage}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl border border-zinc-700/50 bg-zinc-800/50 hover:bg-zinc-800 hover:border-zinc-600 transition-all group"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-zinc-700/50 flex items-center justify-center flex-shrink-0">
|
||||
<Package className="h-5 w-5 text-zinc-300" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<p className="text-sm font-semibold text-white">Package (FCPXML)</p>
|
||||
<p className="text-[10px] text-zinc-500">For Premiere Pro & DaVinci Resolve</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<div className="w-6 h-6 rounded bg-zinc-700 flex items-center justify-center" title="DaVinci Resolve">
|
||||
<span className="text-[8px] font-bold text-orange-400">DR</span>
|
||||
</div>
|
||||
<div className="w-6 h-6 rounded bg-zinc-700 flex items-center justify-center" title="Premiere Pro">
|
||||
<span className="text-[8px] font-bold text-blue-400">Pr</span>
|
||||
</div>
|
||||
<Download className="h-4 w-4 text-zinc-500 group-hover:text-zinc-300 transition-colors ml-1" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-px bg-zinc-800" />
|
||||
<span className="text-[10px] text-zinc-600 uppercase tracking-wider font-semibold">Video Export</span>
|
||||
<div className="flex-1 h-px bg-zinc-800" />
|
||||
</div>
|
||||
|
||||
{/* Format selector */}
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold mb-2 block">Format</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(Object.keys(CODEC_INFO) as ExportCodec[]).map(codec => (
|
||||
<button
|
||||
key={codec}
|
||||
onClick={() => handleCodecChange(codec)}
|
||||
className={`p-2.5 rounded-lg border text-center transition-all ${
|
||||
settings.codec === codec
|
||||
? 'border-blue-500 bg-blue-500/10 text-white'
|
||||
: 'border-zinc-700 bg-zinc-800/50 text-zinc-400 hover:border-zinc-600 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs font-semibold">{CODEC_INFO[codec].label.split(' / ')[0]}</p>
|
||||
<p className="text-[9px] text-zinc-500 mt-0.5">.{CODEC_INFO[codec].ext}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resolution & Frame rate row */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold mb-1.5 block">Resolution</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={`${settings.width}x${settings.height}`}
|
||||
onChange={(e) => {
|
||||
const [w, h] = e.target.value.split('x').map(Number)
|
||||
setSettings(prev => ({ ...prev, width: w, height: h }))
|
||||
}}
|
||||
className="w-full appearance-none bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500 pr-8 cursor-pointer"
|
||||
>
|
||||
{RESOLUTIONS.map(r => (
|
||||
<option key={`${r.width}x${r.height}`} value={`${r.width}x${r.height}`}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-zinc-500 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold mb-1.5 block">Frame Rate</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={settings.fps}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, fps: parseInt(e.target.value) }))}
|
||||
className="w-full appearance-none bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500 pr-8 cursor-pointer"
|
||||
>
|
||||
{FRAME_RATES.map(fps => (
|
||||
<option key={fps} value={fps}>{fps} fps</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-zinc-500 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality */}
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold mb-1.5 block">Quality</label>
|
||||
{settings.codec === 'h264' && (
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={15}
|
||||
max={28}
|
||||
step={1}
|
||||
value={settings.quality}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, quality: parseInt(e.target.value) }))}
|
||||
className="flex-1 h-1.5 accent-blue-500 cursor-pointer"
|
||||
// Note: lower CRF = higher quality (inverted display)
|
||||
/>
|
||||
<span className="text-xs text-zinc-400 w-16 text-right">
|
||||
{settings.quality <= 18 ? 'High' : settings.quality <= 23 ? 'Medium' : 'Low'}
|
||||
<span className="text-zinc-600 ml-1">({settings.quality})</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{settings.codec === 'prores' && (
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{PRORES_PROFILES.map(p => (
|
||||
<button
|
||||
key={p.value}
|
||||
onClick={() => setSettings(prev => ({ ...prev, quality: p.value }))}
|
||||
className={`py-1.5 px-2 rounded-md text-xs font-medium transition-all ${
|
||||
settings.quality === p.value
|
||||
? 'bg-blue-500/20 border border-blue-500 text-blue-300'
|
||||
: 'bg-zinc-800 border border-zinc-700 text-zinc-400 hover:border-zinc-600'
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{settings.codec === 'vp9' && (
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={2}
|
||||
max={20}
|
||||
step={1}
|
||||
value={settings.quality}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, quality: parseInt(e.target.value) }))}
|
||||
className="flex-1 h-1.5 accent-blue-500 cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs text-zinc-400 w-20 text-right">
|
||||
{settings.quality} Mbps
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
{hasSubtitles && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-px bg-zinc-800" />
|
||||
<span className="text-[10px] text-zinc-600 uppercase tracking-wider font-semibold">Options</span>
|
||||
<div className="flex-1 h-px bg-zinc-800" />
|
||||
</div>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={burnSubtitles}
|
||||
onChange={(e) => setBurnSubtitles(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-zinc-600 bg-zinc-800 accent-blue-500 cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs text-zinc-300 group-hover:text-white transition-colors">Burn-in subtitles</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Export button */}
|
||||
<button
|
||||
onClick={handleExportVideo}
|
||||
disabled={clips.length === 0 || window.electronAPI.platform === 'web'}
|
||||
className="w-full py-3 rounded-xl bg-blue-600 hover:bg-blue-500 disabled:opacity-40 disabled:cursor-not-allowed text-white font-semibold text-sm flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<Film className="h-4 w-4" />
|
||||
Timeline rendering not yet available in web preview
|
||||
</button>
|
||||
|
||||
{clips.length === 0 && (
|
||||
<p className="text-xs text-zinc-500 text-center">Add clips to the timeline to export.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { VideoPreviewPanel } from './VideoPreviewPanel'
|
||||
import { validateVideoSource } from '../lib/video-constraints'
|
||||
|
||||
// Preview-only panel for extend. The direction + seconds-to-add controls live in the
|
||||
// PromptBar (like Video mode), so this just owns the source video + preview.
|
||||
|
||||
interface ExtendPanelProps {
|
||||
initialVideoPath?: string | null
|
||||
initialDuration?: number
|
||||
resetKey?: number
|
||||
isProcessing?: boolean
|
||||
processingStatus?: string
|
||||
fillHeight?: boolean
|
||||
// When the request will hit the cloud API, enforce its input constraints (4K / aspect /
|
||||
// min duration). In local mode resolution is corrected, so these don't block.
|
||||
enforceApiConstraints?: boolean
|
||||
onChange?: (data: {
|
||||
videoPath: string | null
|
||||
videoDuration: number
|
||||
width: number
|
||||
height: number
|
||||
ready: boolean
|
||||
}) => void
|
||||
}
|
||||
|
||||
export function ExtendPanel({
|
||||
initialVideoPath,
|
||||
initialDuration,
|
||||
resetKey,
|
||||
isProcessing = false,
|
||||
processingStatus = '',
|
||||
fillHeight = false,
|
||||
enforceApiConstraints = true,
|
||||
onChange,
|
||||
}: ExtendPanelProps) {
|
||||
const [videoPath, setVideoPath] = useState<string | null>(initialVideoPath || null)
|
||||
const [videoDuration, setVideoDuration] = useState<number>(initialDuration || 0)
|
||||
const [dimensions, setDimensions] = useState<{ width: number; height: number }>({ width: 0, height: 0 })
|
||||
|
||||
const handleSourceChange = useCallback((data: { videoPath: string | null; videoDuration: number; width: number; height: number }) => {
|
||||
setVideoPath(data.videoPath)
|
||||
setVideoDuration(data.videoDuration)
|
||||
setDimensions({ width: data.width, height: data.height })
|
||||
}, [])
|
||||
|
||||
const error = enforceApiConstraints && videoPath
|
||||
? validateVideoSource({ width: dimensions.width, height: dimensions.height, duration: videoDuration })
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
onChange?.({ videoPath, videoDuration, width: dimensions.width, height: dimensions.height, ready: !!videoPath && !error })
|
||||
}, [videoPath, videoDuration, dimensions, error, onChange])
|
||||
|
||||
return (
|
||||
<VideoPreviewPanel
|
||||
title="Extend"
|
||||
initialVideoPath={initialVideoPath}
|
||||
initialDuration={initialDuration}
|
||||
resetKey={resetKey}
|
||||
isProcessing={isProcessing}
|
||||
processingStatus={processingStatus}
|
||||
processingDefault="Processing extend..."
|
||||
fillHeight={fillHeight}
|
||||
emptyTitle="Drop a video to extend"
|
||||
hint={{ title: 'Add new frames to your video', subtitle: 'Set direction and length in the bar below, then extend' }}
|
||||
errorMessage={error ?? undefined}
|
||||
onSourceChange={handleSourceChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Miriam+Libre:wght@400;700&display=swap');
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
@keyframes gradientBg {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.05); opacity: 0.9; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
const SHOW_DELAY_MS = 2500
|
||||
|
||||
// Module-level flag — resets on each app launch (page reload)
|
||||
let dismissedThisSession = false
|
||||
|
||||
export function FreeApiKeyBubble({
|
||||
forceApiGenerations,
|
||||
hasLtxApiKey,
|
||||
isGenerating,
|
||||
}: {
|
||||
forceApiGenerations: boolean
|
||||
hasLtxApiKey: boolean
|
||||
isGenerating: boolean
|
||||
}) {
|
||||
const [dismissed, setDismissed] = useState(() => dismissedThisSession)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isGenerating && !dismissed && !forceApiGenerations && !hasLtxApiKey) {
|
||||
timerRef.current = setTimeout(() => setVisible(true), SHOW_DELAY_MS)
|
||||
} else {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
setVisible(false)
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [isGenerating, dismissed, forceApiGenerations, hasLtxApiKey])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
const handleDismiss = () => {
|
||||
dismissedThisSession = true
|
||||
setDismissed(true)
|
||||
}
|
||||
|
||||
const handleGoToSettings = () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('open-settings', { detail: { tab: 'apiKeys' } }),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mb-2 rounded-xl bg-zinc-900/90 backdrop-blur-sm border border-zinc-700/50 px-4 py-3 text-sm text-zinc-200"
|
||||
style={{ animation: 'fadeInUp 0.3s ease-out' }}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p>
|
||||
Speed up inference and save memory with free cloud text encoding and
|
||||
prompt enhancement.{' '}
|
||||
<button
|
||||
onClick={handleGoToSettings}
|
||||
className="text-blue-400 hover:text-blue-300 underline underline-offset-2 transition-colors"
|
||||
>
|
||||
Get a free LTX API key
|
||||
</button>{' '}
|
||||
to enable it.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-zinc-400">
|
||||
The free API key covers text encoding and prompt enhancement only.
|
||||
Video generation via API requires a paid plan.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-zinc-700/60 text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertCircle, ChevronDown, ChevronRight, X } from 'lucide-react'
|
||||
import type { GenerationError } from '../lib/generation-errors'
|
||||
|
||||
interface GenerationErrorDialogProps {
|
||||
error: GenerationError
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unexpected generation error variant: ${JSON.stringify(value)}`)
|
||||
}
|
||||
|
||||
function getGenericHumanMessage(message: string): string {
|
||||
const lower = message.toLowerCase()
|
||||
if (lower.includes('409') || lower.includes('already')) {
|
||||
return 'A generation is already in progress. Please wait for it to finish or cancel it.'
|
||||
}
|
||||
if (lower.includes('cuda') || lower.includes('out of memory') || lower.includes('oom')) {
|
||||
return 'The GPU ran out of memory. Try a lower resolution or shorter duration.'
|
||||
}
|
||||
if ((lower.includes('model') && (lower.includes('not found') || lower.includes('load')))) {
|
||||
return 'The AI model failed to load. Please check your setup and try again.'
|
||||
}
|
||||
if (lower.includes('fetch') || lower.includes('network') || lower.includes('econnrefused')) {
|
||||
return 'Could not connect to the generation server. Make sure the backend is running.'
|
||||
}
|
||||
if (lower.includes('input image') || lower.includes('canvas') || lower.includes('cors')) {
|
||||
return 'Failed to prepare the input image. The file may be corrupted or inaccessible.'
|
||||
}
|
||||
if (lower.includes('could not auto-generate') || lower.includes('send to genspace')) {
|
||||
return message
|
||||
}
|
||||
return 'Something went wrong during generation. Please try again.'
|
||||
}
|
||||
|
||||
function getDialogModel(error: GenerationError): {
|
||||
humanMessage: string
|
||||
technicalDetails: string
|
||||
primaryAction?: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
} {
|
||||
switch (error.status) {
|
||||
case 402:
|
||||
switch (error.error.code) {
|
||||
case 'LTX_INSUFFICIENT_FUNDS':
|
||||
return {
|
||||
humanMessage: 'Your LTX API credits are insufficient for this generation. Buy more credits in LTX and try again.',
|
||||
technicalDetails: JSON.stringify(error.error, null, 2),
|
||||
primaryAction: {
|
||||
label: 'Buy Credits',
|
||||
onClick: () => {
|
||||
void window.electronAPI.openLtxBillingPage()
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return assertNever(error.error.code)
|
||||
case '4XX':
|
||||
case '5XX':
|
||||
case 'default':
|
||||
return {
|
||||
humanMessage: getGenericHumanMessage(error.error.message),
|
||||
technicalDetails: JSON.stringify(error.error, null, 2),
|
||||
}
|
||||
default:
|
||||
return assertNever(error)
|
||||
}
|
||||
}
|
||||
|
||||
export function GenerationErrorDialog({ error, onDismiss }: GenerationErrorDialogProps) {
|
||||
const [detailsExpanded, setDetailsExpanded] = useState(false)
|
||||
const dialogModel = getDialogModel(error)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-[480px] flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-red-400" />
|
||||
<h2 className="text-base font-semibold text-zinc-100">Generation Failed</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="p-1.5 rounded-lg hover:bg-zinc-800 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5">
|
||||
<p className="text-sm text-zinc-300 leading-relaxed">
|
||||
{dialogModel.humanMessage}
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => setDetailsExpanded(!detailsExpanded)}
|
||||
className="flex items-center gap-2 text-xs font-semibold text-zinc-400 uppercase tracking-wider hover:text-zinc-300"
|
||||
>
|
||||
{detailsExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
Technical Details
|
||||
</button>
|
||||
{detailsExpanded && (
|
||||
<pre className="mt-2 bg-zinc-800/50 rounded-lg p-3 text-[11px] text-zinc-400 whitespace-pre-wrap break-words max-h-40 overflow-auto">
|
||||
{dialogModel.technicalDetails}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`px-6 py-4 border-t border-zinc-800 flex items-center gap-3 ${dialogModel.primaryAction ? 'justify-between' : 'justify-end'}`}>
|
||||
{dialogModel.primaryAction ? (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="px-4 py-2 bg-zinc-800 text-zinc-100 text-sm font-medium rounded-lg hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
) : null}
|
||||
{dialogModel.primaryAction ? (
|
||||
<button
|
||||
onClick={dialogModel.primaryAction.onClick}
|
||||
className="px-4 py-2 bg-zinc-100 text-zinc-900 text-sm font-medium rounded-lg hover:bg-white transition-colors"
|
||||
>
|
||||
{dialogModel.primaryAction.label}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="px-4 py-2 bg-zinc-100 text-zinc-900 text-sm font-medium rounded-lg hover:bg-white transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import type { ApiSuccessOf } from '../lib/api-client'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
type HfAuthStatus = ApiSuccessOf<'getHuggingFaceAuthStatus'>['status']
|
||||
type ModelAccessMap = ApiSuccessOf<'checkModelAccess'>['access']
|
||||
|
||||
interface HfModelAccessGateProps {
|
||||
accessMap: ModelAccessMap
|
||||
allAuthorized: boolean
|
||||
hfAuthStatus: HfAuthStatus
|
||||
hfAuthPolling: boolean
|
||||
startHuggingFaceLogin: () => void
|
||||
/** When the access check itself failed (network/backend), distinct from unauthorized. */
|
||||
checkError?: string | null
|
||||
onRetryCheck?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function HfModelAccessGate({
|
||||
accessMap,
|
||||
allAuthorized,
|
||||
hfAuthStatus,
|
||||
hfAuthPolling,
|
||||
startHuggingFaceLogin,
|
||||
checkError = null,
|
||||
onRetryCheck,
|
||||
className,
|
||||
}: HfModelAccessGateProps) {
|
||||
if (allAuthorized) return null
|
||||
|
||||
if (checkError) {
|
||||
return (
|
||||
<div className={className ?? 'space-y-2'}>
|
||||
<div className="flex items-start gap-2 text-xs text-amber-400">
|
||||
<AlertCircle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>Couldn't verify Hugging Face access: {checkError}</span>
|
||||
</div>
|
||||
{onRetryCheck && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRetryCheck}
|
||||
className="text-xs"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const unauthorizedRepos = Object.entries(accessMap).filter(([, status]) => status === 'not_authorized')
|
||||
if (unauthorizedRepos.length === 0) return null
|
||||
|
||||
if (hfAuthStatus !== 'authenticated') {
|
||||
return (
|
||||
<div className={className ?? 'space-y-2'}>
|
||||
<div className="flex items-start gap-2 text-xs text-amber-400">
|
||||
<AlertCircle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>
|
||||
This model is gated on Hugging Face. Sign in, then accept the license to download.
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={startHuggingFaceLogin}
|
||||
disabled={hfAuthPolling}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 text-white text-xs"
|
||||
>
|
||||
{hfAuthPolling ? 'Waiting for sign in…' : 'Sign in with Hugging Face'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className ?? 'space-y-1.5'}>
|
||||
<p className="text-xs text-amber-400">
|
||||
Accept the Hugging Face license for this model, then download.
|
||||
</p>
|
||||
{unauthorizedRepos.map(([repoId]) => (
|
||||
<div key={repoId} className="flex items-center justify-between gap-2 bg-zinc-900 rounded px-2 py-1.5">
|
||||
<span className="text-[10px] text-zinc-400 font-mono truncate">{repoId}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void window.electronAPI.openHuggingFaceRepo({ repoId })
|
||||
}}
|
||||
className="text-[10px] text-indigo-400 hover:text-indigo-300 font-medium flex-shrink-0"
|
||||
>
|
||||
Request access
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Upload, Loader2, Film, Sparkles, Image as ImageIcon,
|
||||
RefreshCw, Download, AlertCircle, Trash2,
|
||||
} from 'lucide-react'
|
||||
import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../lib/api-client'
|
||||
import { logger } from '../lib/logger'
|
||||
import { pathToFileUrl } from '../lib/file-url'
|
||||
import { OutpaintCanvasEditor, type OutpaintPads } from './OutpaintCanvasEditor'
|
||||
|
||||
export type ICLoraConditioningType = 'canny' | 'depth' | 'custom'
|
||||
|
||||
interface ICLoraPanelProps {
|
||||
initialVideoPath?: string | null
|
||||
resetKey?: number
|
||||
fillHeight?: boolean
|
||||
isProcessing?: boolean
|
||||
processingStatus?: string
|
||||
// IC-LoRA mode: 'image' accepts a still image and skips canny/depth preprocessing
|
||||
// (the IC-LoRA builds the control video server-side). Default 'video' = today's behavior.
|
||||
inputKind?: 'image' | 'video'
|
||||
// IC-LoRA mode: when an IC-LoRA is selected (via the library modal) the panel switches to
|
||||
// IC-LoRA mode and hides the canny/depth conditioning preview. The catalog/download UI
|
||||
// lives in the modal, not here.
|
||||
selectedIcLoraId?: string | null
|
||||
// Catalog IC-LoRA opt-in: when true the middle column offers an optional reference-image
|
||||
// picker (instead of the canny/depth conditioning preview, which catalog IC-LoRAs hide).
|
||||
allowsReferenceImage?: boolean
|
||||
// Outpainting (position_canvas control): when true the middle column shows the canvas editor.
|
||||
showOutpaintCanvas?: boolean
|
||||
outpaintPads?: OutpaintPads
|
||||
onOutpaintPadsChange?: (pads: OutpaintPads) => void
|
||||
// Library browsing (and catalog IC-LoRA generation in general) is local-only.
|
||||
isLocalMode?: boolean
|
||||
onBrowseLibrary?: () => void
|
||||
conditioningType?: ICLoraConditioningType
|
||||
onConditioningTypeChange?: (type: ICLoraConditioningType) => void
|
||||
conditioningStrength?: number
|
||||
onConditioningStrengthChange?: (strength: number) => void
|
||||
outputVideoPath?: string | null
|
||||
onChange?: (data: {
|
||||
videoPath: string | null
|
||||
conditioningType: ICLoraConditioningType
|
||||
conditioningStrength: number
|
||||
ready: boolean
|
||||
referenceImagePath: string | null
|
||||
// Source pixel dimensions (0 until media loads) — feeds the resolution control.
|
||||
width: number
|
||||
height: number
|
||||
}) => void
|
||||
}
|
||||
|
||||
export const CONDITIONING_TYPES: { value: ICLoraConditioningType; label: string; desc: string }[] = [
|
||||
{ value: 'canny', label: 'Canny Edges', desc: 'Edge detection' },
|
||||
{ value: 'depth', label: 'Depth Map', desc: 'Estimated depth' },
|
||||
{ value: 'custom', label: 'Custom IC-LoRA', desc: 'Your own weights + control video' },
|
||||
]
|
||||
|
||||
type StartModelDownloadBody = NonNullable<ApiRequestBodyOf<'startModelDownload'>>
|
||||
type ModelCheckpointID = NonNullable<StartModelDownloadBody['cp_ids']>[number]
|
||||
type DownloadProgress = ApiSuccessOf<'getModelDownloadProgress'>
|
||||
|
||||
|
||||
export function ICLoraPanel({
|
||||
initialVideoPath,
|
||||
resetKey,
|
||||
fillHeight = false,
|
||||
isProcessing = false,
|
||||
processingStatus = '',
|
||||
inputKind = 'video',
|
||||
selectedIcLoraId = null,
|
||||
allowsReferenceImage = false,
|
||||
showOutpaintCanvas = false,
|
||||
outpaintPads,
|
||||
onOutpaintPadsChange,
|
||||
isLocalMode = false,
|
||||
onBrowseLibrary,
|
||||
conditioningType: conditioningTypeProp,
|
||||
onConditioningTypeChange,
|
||||
conditioningStrength: conditioningStrengthProp,
|
||||
onConditioningStrengthChange,
|
||||
outputVideoPath: _outputVideoPath,
|
||||
onChange,
|
||||
}: ICLoraPanelProps) {
|
||||
const inputVideoRef = useRef<HTMLVideoElement>(null)
|
||||
const [inputVideoPath, setInputVideoPath] = useState<string | null>(initialVideoPath || null)
|
||||
const inputVideoUrl = inputVideoPath ? pathToFileUrl(inputVideoPath) : null
|
||||
const [inputTime, setInputTime] = useState(0)
|
||||
// Source pixel dimensions, read off the loaded media — drives the outpaint canvas editor.
|
||||
const [sourceDims, setSourceDims] = useState<{ w: number; h: number } | null>(null)
|
||||
|
||||
// Optional reference image for catalog IC-LoRAs that allow it (frame 0, strength 1.0).
|
||||
const [referenceImagePath, setReferenceImagePath] = useState<string | null>(null)
|
||||
const referenceImageUrl = referenceImagePath ? pathToFileUrl(referenceImagePath) : null
|
||||
const [isReferenceDragOver, setIsReferenceDragOver] = useState(false)
|
||||
|
||||
const [internalCondType, setInternalCondType] = useState<ICLoraConditioningType>('canny')
|
||||
const [internalCondStrength, setInternalCondStrength] = useState(1.0)
|
||||
const conditioningType = conditioningTypeProp ?? internalCondType
|
||||
const conditioningStrength = conditioningStrengthProp ?? internalCondStrength
|
||||
// Custom mode: the user supplies their own IC-LoRA + a pre-rendered control video,
|
||||
// so there's no canny/depth preprocessing to preview.
|
||||
const isCustom = conditioningType === 'custom'
|
||||
// Image (IC-LoRA) mode: no canny/depth preprocessing, no bundled-cp download gate.
|
||||
const isImage = inputKind === 'image'
|
||||
// Catalog IC-LoRA mode (any input kind): the IC-LoRA builds its own control video server-side.
|
||||
const isCatalogIcLora = selectedIcLoraId !== null
|
||||
// The conditioning preview only applies to the built-in canny/depth flow.
|
||||
const showConditioningPreview = !isCatalogIcLora && !isCustom
|
||||
const [conditioningPreview, setConditioningPreview] = useState<string | null>(null)
|
||||
const [isExtracting, setIsExtracting] = useState(false)
|
||||
|
||||
const [requiredIcLoraCpIds, setRequiredIcLoraCpIds] = useState<ModelCheckpointID[]>([])
|
||||
const [icLoraSupported, setIcLoraSupported] = useState(true)
|
||||
const [isCheckingIcLora, setIsCheckingIcLora] = useState(false)
|
||||
const [isDownloadingIcLora, setIsDownloadingIcLora] = useState(false)
|
||||
const [downloadProgress, setDownloadProgress] = useState<DownloadProgress | null>(null)
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null)
|
||||
const [downloadSessionId, setDownloadSessionId] = useState<string | null>(null)
|
||||
const [extractError, setExtractError] = useState<string | null>(null)
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const icLoraReady = icLoraSupported && requiredIcLoraCpIds.length === 0
|
||||
|
||||
// Switching to an entry with a different input.kind (image vs video) invalidates the loaded
|
||||
// input — clear it so a stale video can't be submitted to an image-input entry (backend 400).
|
||||
const prevInputKindRef = useRef(inputKind)
|
||||
useEffect(() => {
|
||||
if (prevInputKindRef.current === inputKind) return
|
||||
prevInputKindRef.current = inputKind
|
||||
setInputVideoPath(null)
|
||||
setInputTime(0)
|
||||
setSourceDims(null)
|
||||
setConditioningPreview(null)
|
||||
setExtractError(null)
|
||||
}, [inputKind])
|
||||
|
||||
useEffect(() => {
|
||||
if (resetKey === undefined) return
|
||||
setInputVideoPath(initialVideoPath || null)
|
||||
setInputTime(0)
|
||||
setSourceDims(null)
|
||||
setInternalCondType('canny')
|
||||
setInternalCondStrength(1.0)
|
||||
onConditioningTypeChange?.('canny')
|
||||
onConditioningStrengthChange?.(1.0)
|
||||
setConditioningPreview(null)
|
||||
setExtractError(null)
|
||||
setReferenceImagePath(null)
|
||||
}, [resetKey, initialVideoPath]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
// Custom brings its own IC-LoRA + control video; recipes (any input kind) build the
|
||||
// control video server-side. Neither needs the bundled canny/depth cps.
|
||||
const ready = !!inputVideoPath && (isCustom || isImage || isCatalogIcLora || icLoraReady)
|
||||
onChange?.({
|
||||
videoPath: inputVideoPath,
|
||||
conditioningType,
|
||||
conditioningStrength,
|
||||
ready,
|
||||
referenceImagePath,
|
||||
width: sourceDims?.w ?? 0,
|
||||
height: sourceDims?.h ?? 0,
|
||||
})
|
||||
}, [inputVideoUrl, inputVideoPath, conditioningType, conditioningStrength, isCustom, isImage, isCatalogIcLora, icLoraReady, referenceImagePath, sourceDims, onChange])
|
||||
|
||||
const checkIcLoraAvailability = useCallback(async () => {
|
||||
setIsCheckingIcLora(true)
|
||||
const result = await ApiClient.getLtxIcLoraRecommendation()
|
||||
if (!result.ok) {
|
||||
logger.warn(`Failed to fetch IC-LoRA model status: ${result.error.message}`)
|
||||
setDownloadError(result.error.message)
|
||||
setIsCheckingIcLora(false)
|
||||
return
|
||||
}
|
||||
|
||||
const recommendationPayload = result.data
|
||||
setRequiredIcLoraCpIds(recommendationPayload.cps_to_download)
|
||||
setIcLoraSupported(recommendationPayload.supported)
|
||||
const isReady = recommendationPayload.supported && recommendationPayload.cps_to_download.length === 0
|
||||
|
||||
if (isReady) {
|
||||
setIsDownloadingIcLora(false)
|
||||
setDownloadProgress(null)
|
||||
setDownloadError(null)
|
||||
setDownloadSessionId(null)
|
||||
}
|
||||
setIsCheckingIcLora(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void checkIcLoraAvailability()
|
||||
}, [checkIcLoraAvailability])
|
||||
|
||||
useEffect(() => {
|
||||
if (icLoraReady || !isDownloadingIcLora || !downloadSessionId) return
|
||||
|
||||
const pollProgress = async () => {
|
||||
const result = await ApiClient.getModelDownloadProgress({ sessionId: downloadSessionId })
|
||||
if (!result.ok) {
|
||||
logger.warn(`Failed polling IC-LoRA download progress: ${result.error.message}`)
|
||||
return
|
||||
}
|
||||
|
||||
const progressPayload = result.data
|
||||
setDownloadProgress(progressPayload)
|
||||
|
||||
if (progressPayload.status === 'error') {
|
||||
setIsDownloadingIcLora(false)
|
||||
setDownloadError(progressPayload.error || 'Model download failed')
|
||||
return
|
||||
}
|
||||
|
||||
if (progressPayload.status === 'complete') {
|
||||
setIsDownloadingIcLora(false)
|
||||
await checkIcLoraAvailability()
|
||||
}
|
||||
}
|
||||
|
||||
void pollProgress()
|
||||
const interval = setInterval(() => { void pollProgress() }, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [icLoraReady, isDownloadingIcLora, downloadSessionId, checkIcLoraAvailability])
|
||||
|
||||
const handleDownloadIcLora = useCallback(async () => {
|
||||
if (isDownloadingIcLora) return
|
||||
setDownloadError(null)
|
||||
|
||||
const result = await ApiClient.startModelDownload({
|
||||
type: 'download',
|
||||
cp_ids: [...requiredIcLoraCpIds],
|
||||
})
|
||||
if (!result.ok) {
|
||||
logger.warn(`Failed to start IC-LoRA download: ${result.error.message}`)
|
||||
setDownloadError(result.error.message)
|
||||
return
|
||||
}
|
||||
|
||||
const startedPayload = result.data
|
||||
if (startedPayload.status === 'started') {
|
||||
setDownloadSessionId(startedPayload.sessionId)
|
||||
setIsDownloadingIcLora(true)
|
||||
return
|
||||
}
|
||||
|
||||
setDownloadError('Unexpected response while starting IC-LoRA download')
|
||||
}, [isDownloadingIcLora, requiredIcLoraCpIds])
|
||||
|
||||
const isExtractingRef = useRef(false)
|
||||
const extractConditioning = useCallback(async () => {
|
||||
if (isCustom || isImage || isCatalogIcLora || !inputVideoPath || isExtractingRef.current || !icLoraReady) return
|
||||
isExtractingRef.current = true
|
||||
setIsExtracting(true)
|
||||
setExtractError(null)
|
||||
const result = await ApiClient.extractIcLoraConditioning({
|
||||
video_path: inputVideoPath,
|
||||
conditioning_type: conditioningType,
|
||||
frame_time: inputTime,
|
||||
})
|
||||
if (!result.ok) {
|
||||
logger.warn(`Failed to extract conditioning: ${result.error.message}`)
|
||||
setExtractError(result.error.message)
|
||||
isExtractingRef.current = false
|
||||
setIsExtracting(false)
|
||||
return
|
||||
}
|
||||
|
||||
setConditioningPreview(result.data.conditioning)
|
||||
isExtractingRef.current = false
|
||||
setIsExtracting(false)
|
||||
}, [inputVideoPath, conditioningType, inputTime, icLoraReady, isCustom, isImage, isCatalogIcLora])
|
||||
|
||||
const extractTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
useEffect(() => {
|
||||
if (isCustom || isImage || isCatalogIcLora || !inputVideoPath || !icLoraReady) return
|
||||
if (extractTimerRef.current) clearTimeout(extractTimerRef.current)
|
||||
extractTimerRef.current = setTimeout(() => {
|
||||
void extractConditioning()
|
||||
}, 300)
|
||||
return () => {
|
||||
if (extractTimerRef.current) clearTimeout(extractTimerRef.current)
|
||||
}
|
||||
}, [inputTime, conditioningType, inputVideoPath, icLoraReady, extractConditioning])
|
||||
|
||||
useEffect(() => {
|
||||
const video = inputVideoRef.current
|
||||
if (!video) return
|
||||
const onTime = () => setInputTime(video.currentTime)
|
||||
const onSeeked = () => setInputTime(video.currentTime)
|
||||
video.addEventListener('timeupdate', onTime)
|
||||
video.addEventListener('seeked', onSeeked)
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', onTime)
|
||||
video.removeEventListener('seeked', onSeeked)
|
||||
}
|
||||
}, [inputVideoUrl, icLoraReady, isCheckingIcLora])
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
const paths = await window.electronAPI.showOpenFileDialog(
|
||||
isImage
|
||||
? { title: 'Select Input Image', filters: [{ name: 'Image', extensions: ['png', 'jpg', 'jpeg', 'webp'] }] }
|
||||
: { title: 'Select Driving Video', filters: [{ name: 'Video', extensions: ['mp4', 'mov', 'avi', 'webm', 'mkv'] }] },
|
||||
)
|
||||
if (paths && paths.length > 0) {
|
||||
const filePath = paths[0]
|
||||
setInputVideoPath(filePath)
|
||||
|
||||
setConditioningPreview(null)
|
||||
setExtractError(null)
|
||||
}
|
||||
}, [isImage])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setInputVideoPath(null)
|
||||
|
||||
setInputTime(0)
|
||||
setSourceDims(null)
|
||||
setConditioningPreview(null)
|
||||
setExtractError(null)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(false)
|
||||
|
||||
const acceptedAssetType = isImage ? 'image' : 'video'
|
||||
const assetData = e.dataTransfer.getData('asset')
|
||||
if (assetData) {
|
||||
try {
|
||||
const asset = JSON.parse(assetData) as { type?: string; path?: string }
|
||||
if (asset.type === acceptedAssetType && asset.path) {
|
||||
setInputVideoPath(asset.path)
|
||||
setConditioningPreview(null)
|
||||
setExtractError(null)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) {
|
||||
const filePath = window.electronAPI?.getPathForFile(file)
|
||||
if (filePath) {
|
||||
setInputVideoPath(filePath)
|
||||
|
||||
setConditioningPreview(null)
|
||||
setExtractError(null)
|
||||
}
|
||||
}
|
||||
}, [isImage])
|
||||
|
||||
const handleBrowseReference = useCallback(async () => {
|
||||
const paths = await window.electronAPI.showOpenFileDialog({
|
||||
title: 'Select Reference Image',
|
||||
filters: [{ name: 'Image', extensions: ['png', 'jpg', 'jpeg', 'webp'] }],
|
||||
})
|
||||
if (paths && paths.length > 0) {
|
||||
setReferenceImagePath(paths[0])
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleClearReference = useCallback(() => {
|
||||
setReferenceImagePath(null)
|
||||
}, [])
|
||||
|
||||
const handleDropReference = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsReferenceDragOver(false)
|
||||
|
||||
const assetData = e.dataTransfer.getData('asset')
|
||||
if (assetData) {
|
||||
try {
|
||||
const asset = JSON.parse(assetData) as { type?: string; path?: string }
|
||||
if (asset.type === 'image' && asset.path) {
|
||||
setReferenceImagePath(asset.path)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) {
|
||||
const filePath = window.electronAPI?.getPathForFile(file)
|
||||
if (filePath) setReferenceImagePath(filePath)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Only the built-in canny/depth flow needs the bundled preprocessing cps. Recipes
|
||||
// (any input kind) and custom build their own control video, so never gate them.
|
||||
const showBuiltinGate =
|
||||
!isCustom && !isImage && !isCatalogIcLora && (isCheckingIcLora || !icLoraReady)
|
||||
const showUnsupportedGate = showBuiltinGate && !isCheckingIcLora && !icLoraSupported
|
||||
const showDownloadGate = showBuiltinGate && !showUnsupportedGate
|
||||
const runningDownloadProgress =
|
||||
downloadProgress?.status === 'downloading' ? downloadProgress : null
|
||||
const gateItemIds = [...new Set([...(requiredIcLoraCpIds ?? []), ...(runningDownloadProgress?.all_files ?? [])])]
|
||||
const gateItems = gateItemIds.map((cpId) => {
|
||||
const downloaded = !requiredIcLoraCpIds.includes(cpId)
|
||||
const isCompleted = runningDownloadProgress?.completed_files?.includes(cpId) ?? false
|
||||
const isCurrentDownload = isDownloadingIcLora && runningDownloadProgress?.current_downloading_file === cpId
|
||||
const progress = downloaded ? 100 : (isCompleted ? 100 : (isCurrentDownload ? (runningDownloadProgress?.current_file_progress ?? 0) : 0))
|
||||
const status = downloaded ? 'Ready' : (isCompleted ? 'Complete' : (isCurrentDownload ? 'Downloading' : 'Missing'))
|
||||
return { id: cpId, label: cpId, downloaded, progress, status }
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={`bg-zinc-900 border border-zinc-800 rounded-2xl overflow-hidden flex flex-col ${fillHeight ? 'h-full min-h-0' : ''}`}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-amber-400" />
|
||||
<span className="text-sm font-semibold text-white">IC-LoRA / Style Transfer</span>
|
||||
</div>
|
||||
{inputVideoUrl && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
|
||||
title="Clear video"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
className="p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
|
||||
title="Replace video"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLocalMode && (
|
||||
<div className="px-3 py-2 border-b border-zinc-800 flex-shrink-0">
|
||||
<button
|
||||
onClick={onBrowseLibrary}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-medium transition-colors"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5 text-amber-400" /> Browse IC-LoRAs
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showUnsupportedGate ? (
|
||||
<div className="flex-1 flex items-center justify-center p-6 min-h-0 overflow-y-auto">
|
||||
<div className="w-full max-w-xl rounded-xl border border-zinc-700 bg-zinc-800/60 p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-amber-600/20 flex items-center justify-center mt-0.5">
|
||||
<AlertCircle className="h-4 w-4 text-amber-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-white">Built-in control needs LTX 2.3</h3>
|
||||
<p className="text-xs text-zinc-400 mt-1">
|
||||
Depth and canny Union Control are not available on the active LTX 2.5 model.
|
||||
Switch to an LTX 2.3 local model in Settings, or use a custom / catalog IC-LoRA.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : showDownloadGate ? (
|
||||
<div className="flex-1 flex items-center justify-center p-6 min-h-0 overflow-y-auto">
|
||||
<div className="w-full max-w-xl rounded-xl border border-zinc-700 bg-zinc-800/60 p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-blue-600/20 flex items-center justify-center mt-0.5">
|
||||
<Download className="h-4 w-4 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-white">Download Required: IC-LoRA Resources</h3>
|
||||
<p className="text-xs text-zinc-400 mt-1">
|
||||
Editing is locked until all IC-LoRA preprocessing models are available locally.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{isCheckingIcLora ? (
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-300">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-400" />
|
||||
Checking model availability...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{gateItems.map(item => (
|
||||
<div key={item.id} className="rounded-lg border border-zinc-700 bg-zinc-900/60 px-3 py-2">
|
||||
<div className="flex items-center justify-between text-[11px] mb-1.5">
|
||||
<span className="text-zinc-300">{item.label}</span>
|
||||
<span className={item.downloaded ? 'text-blue-400' : 'text-zinc-500'}>
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full transition-all duration-300 bg-blue-500"
|
||||
style={{ width: `${item.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-zinc-500">{item.progress}%</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{downloadError && (
|
||||
<div className="text-[11px] text-red-400">{downloadError}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleDownloadIcLora}
|
||||
disabled={isDownloadingIcLora}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isDownloadingIcLora ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Downloading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-3 w-3" />
|
||||
{downloadError ? 'Retry Download' : 'Download Models'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { void checkIcLoraAvailability() }}
|
||||
disabled={isCheckingIcLora}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-600 text-zinc-300 hover:text-white hover:border-zinc-500 text-xs transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${isCheckingIcLora ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||
<div className="flex-1 flex flex-col border-r border-zinc-800 min-w-0">
|
||||
<div className="px-3 py-2 border-b border-zinc-800 flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider shrink-0">Input</span>
|
||||
{inputVideoPath && (
|
||||
<span className="text-[10px] text-zinc-500 truncate min-w-0">
|
||||
{inputVideoPath.split(/[\\/]/).pop()}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors shrink-0"
|
||||
>
|
||||
<Upload className="h-3 w-3" />
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 min-h-0 bg-black flex items-center justify-center relative ${!inputVideoUrl ? 'border-2 border-dashed border-zinc-700 m-3 rounded-lg' : ''} ${isDragOver ? 'border-blue-500 bg-blue-500/10' : ''}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true) }}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{inputVideoUrl ? (
|
||||
isImage ? (
|
||||
<img
|
||||
src={inputVideoUrl}
|
||||
alt="Input"
|
||||
className="w-full h-full object-contain"
|
||||
onLoad={(e) => setSourceDims({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
ref={inputVideoRef}
|
||||
src={inputVideoUrl}
|
||||
className="w-full h-full object-contain"
|
||||
controls
|
||||
onLoadedMetadata={(e) => setSourceDims({ w: e.currentTarget.videoWidth, h: e.currentTarget.videoHeight })}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="text-center p-4">
|
||||
<div className="w-12 h-12 rounded-full bg-zinc-800 flex items-center justify-center mx-auto mb-2">
|
||||
<Film className="h-6 w-6 text-zinc-600" />
|
||||
</div>
|
||||
<p className="text-zinc-400 text-xs">{isImage ? 'Drop or import an image' : isCustom ? 'Drop or import a control video' : 'Drop or import a driving video'}</p>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
className="mt-2 px-3 py-1.5 text-[10px] text-blue-400 border border-blue-500/30 rounded-lg hover:bg-blue-600/10 transition-colors"
|
||||
>
|
||||
{isImage ? 'Import Image' : 'Import Video'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showConditioningPreview && (
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="px-3 py-2 border-b border-zinc-800 flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">Conditioning</span>
|
||||
<button
|
||||
onClick={() => { void extractConditioning() }}
|
||||
disabled={!inputVideoPath || isExtracting}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${isExtracting ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 bg-black flex items-center justify-center min-h-0 relative">
|
||||
{isExtracting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
|
||||
<Loader2 className="h-5 w-5 text-blue-400 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{conditioningPreview ? (
|
||||
<img src={conditioningPreview} alt="Conditioning preview" className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<div className="text-center p-4">
|
||||
<p className="text-zinc-600 text-xs">
|
||||
{inputVideoUrl ? 'Scrub the input video to see conditioning preview' : 'Import a video to preview conditioning'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reference image column: takes the conditioning slot for catalog IC-LoRAs that opt in. */}
|
||||
{allowsReferenceImage && !showConditioningPreview && (
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="px-3 py-2 border-b border-zinc-800 flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider shrink-0">Reference</span>
|
||||
{referenceImagePath && (
|
||||
<span className="text-[10px] text-zinc-500 truncate min-w-0">
|
||||
{referenceImagePath.split(/[\\/]/).pop()}
|
||||
</span>
|
||||
)}
|
||||
{referenceImagePath ? (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={handleClearReference}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors"
|
||||
title="Clear reference image"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBrowseReference}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors"
|
||||
title="Replace reference image"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleBrowseReference}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors shrink-0"
|
||||
>
|
||||
<Upload className="h-3 w-3" />
|
||||
Import
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 min-h-0 bg-black flex items-center justify-center relative ${!referenceImageUrl ? 'border-2 border-dashed border-zinc-700 m-3 rounded-lg' : ''} ${isReferenceDragOver ? 'border-blue-500 bg-blue-500/10' : ''}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsReferenceDragOver(true) }}
|
||||
onDragLeave={() => setIsReferenceDragOver(false)}
|
||||
onDrop={handleDropReference}
|
||||
>
|
||||
{referenceImageUrl ? (
|
||||
<img src={referenceImageUrl} alt="Reference" className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<div className="text-center p-4">
|
||||
<div className="w-12 h-12 rounded-full bg-zinc-800 flex items-center justify-center mx-auto mb-2">
|
||||
<ImageIcon className="h-6 w-6 text-zinc-600" />
|
||||
</div>
|
||||
<p className="text-zinc-400 text-xs">Drop or import a reference image</p>
|
||||
<button
|
||||
onClick={handleBrowseReference}
|
||||
className="mt-2 px-3 py-1.5 text-[10px] text-blue-400 border border-blue-500/30 rounded-lg hover:bg-blue-600/10 transition-colors"
|
||||
>
|
||||
Import Image
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Canvas editor column: takes the middle slot for outpainting (position_canvas control). */}
|
||||
{showOutpaintCanvas && !showConditioningPreview && (
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="px-3 py-2 border-b border-zinc-800 flex items-center">
|
||||
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">Canvas</span>
|
||||
</div>
|
||||
{sourceDims && outpaintPads ? (
|
||||
<OutpaintCanvasEditor
|
||||
sourceWidth={sourceDims.w}
|
||||
sourceHeight={sourceDims.h}
|
||||
value={outpaintPads}
|
||||
onChange={(p) => onOutpaintPadsChange?.(p)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 bg-black flex items-center justify-center">
|
||||
<p className="text-zinc-600 text-xs">Import a video to set the outpaint canvas</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output column */}
|
||||
<div className="flex-1 flex flex-col border-l border-zinc-800 min-w-0">
|
||||
<div className="px-3 py-2 border-b border-zinc-800 flex items-center">
|
||||
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">Output</span>
|
||||
</div>
|
||||
<div className="flex-1 bg-black flex items-center justify-center min-h-0 relative">
|
||||
{_outputVideoPath ? (
|
||||
<video
|
||||
src={pathToFileUrl(_outputVideoPath)}
|
||||
className="w-full h-full object-contain"
|
||||
controls
|
||||
/>
|
||||
) : isProcessing ? (
|
||||
<div className="text-center p-4">
|
||||
<Loader2 className="h-6 w-6 text-blue-400 animate-spin mx-auto mb-2" />
|
||||
<p className="text-zinc-400 text-xs">{processingStatus || 'Generating...'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-4">
|
||||
<p className="text-zinc-600 text-xs">Output video will appear here</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{extractError && (
|
||||
<div className="px-4 py-3 border-t border-zinc-800 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 text-xs text-red-400">
|
||||
<AlertCircle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>{extractError}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
import { SettingsDropdown } from './SettingsDropdown'
|
||||
import type { IcLoraControlsProps } from './IcLoraSettingsControls'
|
||||
import type { IcLoraAudioMode } from '../hooks/use-ic-lora'
|
||||
|
||||
// One labelled row: caption on the left, the dropdown on the right.
|
||||
function Row({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] text-zinc-500 uppercase tracking-wide shrink-0">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const triggerValue = (text: string) => (
|
||||
<>
|
||||
<span className="text-zinc-300 font-medium">{text}</span>
|
||||
<ChevronUp className="h-3 w-3 text-zinc-500" />
|
||||
</>
|
||||
)
|
||||
|
||||
// Advanced IC-LoRA controls, in a side panel beside the prompt bar (so they don't push the
|
||||
// Generate button off the bottom row). Reuses the shared IcLoraControlsProps bag.
|
||||
export function IcLoraAdvancedPanel({
|
||||
icLoraSkipStage2,
|
||||
onIcLoraSkipStage2Change,
|
||||
icLoraUseLoraInStage2,
|
||||
onIcLoraUseLoraInStage2Change,
|
||||
icLoraResolutionOptions,
|
||||
icLoraResolutionKey,
|
||||
onIcLoraResolutionKeyChange,
|
||||
icLoraResolutionFactor,
|
||||
onIcLoraResolutionFactorChange,
|
||||
icLoraAudioMode,
|
||||
onIcLoraAudioModeChange,
|
||||
icLoraFps,
|
||||
onIcLoraFpsChange,
|
||||
advancedIcLoraControls,
|
||||
}: IcLoraControlsProps) {
|
||||
// Advanced-only knobs. (LoRA strength is a regular control — it lives in the bottom bar.)
|
||||
if (!advancedIcLoraControls) return null
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/90 backdrop-blur p-1.5 flex flex-col gap-0.5 w-40 text-xs">
|
||||
<span className="text-[9px] font-semibold text-zinc-500 uppercase tracking-wider px-1">Advanced</span>
|
||||
|
||||
<Row label="Stage 2">
|
||||
<SettingsDropdown
|
||||
title="STAGE 2 REFINE"
|
||||
value={icLoraSkipStage2 ? 'skip' : 'keep'}
|
||||
onChange={(v) => onIcLoraSkipStage2Change?.(v === 'skip')}
|
||||
options={[
|
||||
{ value: 'keep', label: 'On (two-stage)' },
|
||||
{ value: 'skip', label: 'Off (stage 1 only)' },
|
||||
]}
|
||||
trigger={triggerValue(icLoraSkipStage2 ? 'Off' : 'On')}
|
||||
/>
|
||||
</Row>
|
||||
{/* Always shown. Only takes effect when Stage 2 runs (Stage 2 = On); it's a
|
||||
no-op when Stage 2 is skipped, but kept visible for discoverability. */}
|
||||
<Row label="LoRA in S2">
|
||||
<SettingsDropdown
|
||||
title="IC-LORA IN STAGE 2"
|
||||
value={icLoraUseLoraInStage2 ? 'on' : 'off'}
|
||||
onChange={(v) => onIcLoraUseLoraInStage2Change?.(v === 'on')}
|
||||
options={[
|
||||
{ value: 'off', label: 'Off (refine from prompt)' },
|
||||
{ value: 'on', label: 'On (keep effect, source res)' },
|
||||
]}
|
||||
trigger={triggerValue(icLoraUseLoraInStage2 ? 'On' : 'Off')}
|
||||
/>
|
||||
</Row>
|
||||
{!icLoraSkipStage2 && icLoraUseLoraInStage2 && (icLoraResolutionOptions?.length ?? 0) > 1 && (
|
||||
<Row label="Res">
|
||||
<SettingsDropdown
|
||||
title="RESOLUTION"
|
||||
value={icLoraResolutionKey ?? 'original'}
|
||||
onChange={(v) => onIcLoraResolutionKeyChange?.(v)}
|
||||
options={icLoraResolutionOptions!.map((o) => ({ value: o.key, label: o.label }))}
|
||||
trigger={triggerValue(
|
||||
(icLoraResolutionOptions!.find((o) => o.key === icLoraResolutionKey)?.label ?? 'Original').split(' ')[0],
|
||||
)}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
{icLoraSkipStage2 && (
|
||||
<Row label="Res">
|
||||
<SettingsDropdown
|
||||
title="RES FACTOR"
|
||||
value={String(icLoraResolutionFactor ?? 2.0)}
|
||||
onChange={(v) => onIcLoraResolutionFactorChange?.(parseFloat(v))}
|
||||
options={[
|
||||
// 0 is the "source dimensions" sentinel — output matches the input
|
||||
// resolution and the multiplier is ignored (see ic_lora_handler).
|
||||
{ value: '0', label: 'Source dimensions' },
|
||||
{ value: '1', label: '1.00 (half)' },
|
||||
{ value: '1.25', label: '1.25' },
|
||||
{ value: '1.5', label: '1.50' },
|
||||
{ value: '1.75', label: '1.75' },
|
||||
{ value: '2', label: '2.00 (native)' },
|
||||
]}
|
||||
trigger={triggerValue(
|
||||
icLoraResolutionFactor === 0 ? 'Source' : `×${(icLoraResolutionFactor ?? 2.0).toFixed(2)}`,
|
||||
)}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
<Row label="Audio">
|
||||
<SettingsDropdown
|
||||
title="AUDIO"
|
||||
value={icLoraAudioMode ?? 'generated'}
|
||||
onChange={(v) => onIcLoraAudioModeChange?.(v as IcLoraAudioMode)}
|
||||
options={[
|
||||
{ value: 'generated', label: 'Generated' },
|
||||
{ value: 'source', label: 'Source' },
|
||||
{ value: 'off', label: 'Off' },
|
||||
]}
|
||||
trigger={triggerValue((icLoraAudioMode ?? 'generated').replace(/^\w/, (c: string) => c.toUpperCase()))}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="FPS">
|
||||
<SettingsDropdown
|
||||
title="FPS"
|
||||
value={icLoraFps == null ? 'source' : String(icLoraFps)}
|
||||
onChange={(v) => onIcLoraFpsChange?.(v === 'source' ? null : parseFloat(v))}
|
||||
options={[
|
||||
{ value: 'source', label: 'Source' },
|
||||
{ value: '24', label: '24 fps' },
|
||||
{ value: '16', label: '16 fps' },
|
||||
{ value: '12', label: '12 fps' },
|
||||
{ value: '8', label: '8 fps' },
|
||||
]}
|
||||
trigger={triggerValue(icLoraFps == null ? 'Src' : String(icLoraFps))}
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Fragment } from 'react'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
import { SettingsDropdown } from './SettingsDropdown'
|
||||
import { Tooltip } from './ui/tooltip'
|
||||
import type { ApiSuccessOf } from '../lib/api-client'
|
||||
import type { components } from '../generated/backend-openapi'
|
||||
import type { ICLoraConditioningType } from './ICLoraPanel'
|
||||
import type { IcLoraAudioMode } from '../hooks/use-ic-lora'
|
||||
import type { ResolutionOption } from '../lib/video-resolution'
|
||||
|
||||
type CatalogControl = components['schemas']['IcLoraControl']
|
||||
|
||||
// Short hover/focus help per IC-LoRA control. The catalog-driven controls are keyed by control id.
|
||||
const CONTROL_TOOLTIPS: Record<string, string> = {
|
||||
condStrength: 'How strongly the conditioning (edges/depth) constrains the result. Higher follows the control signal more closely.',
|
||||
loraStrength: 'How strongly the IC-LoRA weights are applied. Lower is subtler, higher is stronger.',
|
||||
duration: 'Length of the generated clip.',
|
||||
}
|
||||
|
||||
// A catalog control's display label for a given option: explicit value_labels win, else value + unit.
|
||||
function controlOptionLabel(control: CatalogControl, option: number | string): string {
|
||||
return control.value_labels?.[String(option)] ?? `${option}${control.unit ?? ''}`
|
||||
}
|
||||
|
||||
// The IC-LoRA control row in the prompt bar: conditioning-type selector + per-mode knobs
|
||||
// (strength, duration, LoRA strength, and the advanced stage-2 / resolution / audio / fps).
|
||||
export interface IcLoraControlsProps {
|
||||
icLoraCondType?: ICLoraConditioningType
|
||||
icLoraSelectorValue?: string
|
||||
icLoraSelectorOptions?: { value: string; label: string }[]
|
||||
onIcLoraSelectorChange?: (value: string) => void
|
||||
icLoraStrength?: number
|
||||
onIcLoraStrengthChange?: (strength: number) => void
|
||||
availableIcLoras?: ApiSuccessOf<'listModels'>['models']
|
||||
icLoraCustomRef?: string | null
|
||||
onIcLoraCustomRefChange?: (ref: string | null) => void
|
||||
icLoraSkipStage2?: boolean
|
||||
onIcLoraSkipStage2Change?: (skip: boolean) => void
|
||||
icLoraUseLoraInStage2?: boolean
|
||||
onIcLoraUseLoraInStage2Change?: (use: boolean) => void
|
||||
// Resolution tiers for the use_lora_in_stage_2 path (empty until source media loads).
|
||||
icLoraResolutionOptions?: ResolutionOption[]
|
||||
icLoraResolutionKey?: string
|
||||
onIcLoraResolutionKeyChange?: (key: string) => void
|
||||
icLoraResolutionFactor?: number
|
||||
onIcLoraResolutionFactorChange?: (factor: number) => void
|
||||
icLoraAudioMode?: IcLoraAudioMode
|
||||
onIcLoraAudioModeChange?: (mode: IcLoraAudioMode) => void
|
||||
icLoraLoraStrength?: number
|
||||
onIcLoraLoraStrengthChange?: (strength: number) => void
|
||||
icLoraFps?: number | null
|
||||
onIcLoraFpsChange?: (fps: number | null) => void
|
||||
isCatalogIcLora?: boolean
|
||||
advancedIcLoraControls?: boolean
|
||||
// Catalog-declared controls for the selected IC-LoRA, rendered generically by id + kind.
|
||||
controls?: CatalogControl[]
|
||||
controlValues?: Record<string, number | string>
|
||||
onControlChange?: (id: string, value: number | string) => void
|
||||
}
|
||||
|
||||
export function IcLoraSettingsControls({
|
||||
icLoraCondType,
|
||||
icLoraSelectorValue,
|
||||
icLoraSelectorOptions,
|
||||
onIcLoraSelectorChange,
|
||||
icLoraStrength,
|
||||
onIcLoraStrengthChange,
|
||||
availableIcLoras,
|
||||
icLoraCustomRef,
|
||||
onIcLoraCustomRefChange,
|
||||
icLoraLoraStrength,
|
||||
onIcLoraLoraStrengthChange,
|
||||
isCatalogIcLora,
|
||||
controls,
|
||||
controlValues,
|
||||
onControlChange,
|
||||
}: IcLoraControlsProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Unified IC-LoRA selector: canny | depth | [catalog IC-LoRAs] | custom. No tooltip:
|
||||
the options are self-explanatory and the copy was wrong for catalog recipes. */}
|
||||
<SettingsDropdown
|
||||
title="CONDITIONING TYPE"
|
||||
value={icLoraSelectorValue ?? 'canny'}
|
||||
onChange={(v) => onIcLoraSelectorChange?.(v)}
|
||||
options={icLoraSelectorOptions ?? []}
|
||||
trigger={
|
||||
<>
|
||||
<span className="text-zinc-300 font-medium">{(icLoraSelectorOptions ?? []).find(o => o.value === icLoraSelectorValue)?.label ?? 'Canny Edges'}</span>
|
||||
<ChevronUp className="h-3 w-3 text-zinc-500" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{!isCatalogIcLora && icLoraCondType === 'custom' && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-zinc-700 mx-0.5" />
|
||||
<SettingsDropdown
|
||||
title="IC-LORA"
|
||||
value={icLoraCustomRef ?? ''}
|
||||
onChange={(v) => onIcLoraCustomRefChange?.(v || null)}
|
||||
options={(availableIcLoras ?? []).map(m => ({ value: m.path, label: m.name }))}
|
||||
trigger={
|
||||
<>
|
||||
<span className="text-zinc-300 font-medium truncate max-w-[160px]">
|
||||
{availableIcLoras?.find(m => m.path === icLoraCustomRef)?.name
|
||||
?? (availableIcLoras?.length ? 'Select IC-LoRA' : 'No IC-LoRAs found')}
|
||||
</span>
|
||||
<ChevronUp className="h-3 w-3 text-zinc-500" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!isCatalogIcLora && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-zinc-700 mx-0.5" />
|
||||
<SettingsDropdown
|
||||
title="STRENGTH"
|
||||
tooltip={CONTROL_TOOLTIPS.condStrength}
|
||||
value={String(icLoraStrength ?? 1.0)}
|
||||
onChange={(v) => onIcLoraStrengthChange?.(parseFloat(v))}
|
||||
options={[
|
||||
{ value: '0.5', label: '0.50' },
|
||||
{ value: '0.75', label: '0.75' },
|
||||
{ value: '1', label: '1.00' },
|
||||
{ value: '1.25', label: '1.25' },
|
||||
{ value: '1.5', label: '1.50' },
|
||||
{ value: '2', label: '2.00' },
|
||||
]}
|
||||
trigger={
|
||||
<>
|
||||
<span className="text-zinc-500 text-[10px]">STR</span>
|
||||
<span className="text-zinc-300 font-medium">{(icLoraStrength ?? 1.0).toFixed(2)}</span>
|
||||
<ChevronUp className="h-3 w-3 text-zinc-500" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* Catalog-declared option controls (duration, …) rendered generically as dropdowns.
|
||||
Structured controls (position_canvas) carry no options and render elsewhere — skip them. */}
|
||||
{isCatalogIcLora && (controls ?? []).filter((c) => c.options != null).map((c) => {
|
||||
const current = controlValues?.[c.id] ?? c.default ?? ''
|
||||
const title = c.label.toUpperCase()
|
||||
return (
|
||||
<Fragment key={c.id}>
|
||||
<div className="w-px h-4 bg-zinc-700 mx-0.5" />
|
||||
<SettingsDropdown
|
||||
title={title}
|
||||
tooltip={CONTROL_TOOLTIPS[c.id]}
|
||||
value={String(current)}
|
||||
onChange={(v) => onControlChange?.(c.id, c.kind === 'int' ? parseInt(v, 10) : v)}
|
||||
options={(c.options as (number | string)[]).map((o) => ({ value: String(o), label: controlOptionLabel(c, o) }))}
|
||||
trigger={
|
||||
<>
|
||||
<span className="text-zinc-500 text-[10px]">{title}</span>
|
||||
<span className="text-zinc-300 font-medium">{controlOptionLabel(c, current)}</span>
|
||||
<ChevronUp className="h-3 w-3 text-zinc-500" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{/* LoRA strength: a regular control for every IC-LoRA mode (canny/depth/custom/catalog).
|
||||
It scales the adapter merge weight, so it always has an effect. (The advanced knobs —
|
||||
S2/Res/Audio/FPS — live in IcLoraAdvancedPanel beside the prompt bar.) A continuous
|
||||
slider (like the plain-LoRA strength bar) rather than a dropdown, for finer control. */}
|
||||
<div className="w-px h-4 bg-zinc-700 mx-0.5" />
|
||||
<Tooltip content={CONTROL_TOOLTIPS.loraStrength}>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||
<span className="text-zinc-500 text-[10px]">LORA</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.05}
|
||||
value={icLoraLoraStrength ?? 1.0}
|
||||
onChange={(e) => onIcLoraLoraStrengthChange?.(parseFloat(e.target.value))}
|
||||
className="w-16 h-1 accent-white cursor-pointer"
|
||||
aria-label="LoRA strength"
|
||||
/>
|
||||
<span className="text-zinc-300 font-medium w-9 text-right">{(icLoraLoraStrength ?? 1.0).toFixed(2)}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { logger } from '../lib/logger'
|
||||
import { addGenericAssetToProject, addVisualAssetToProject } from '../lib/asset-copy'
|
||||
import {
|
||||
X, FileVideo, FileAudio, Image, Check, AlertTriangle,
|
||||
FolderOpen, RefreshCw, Loader2, FileText, Link2,
|
||||
ChevronDown, ChevronRight, Upload
|
||||
} from 'lucide-react'
|
||||
import type { ParsedTimeline, ParsedMediaRef } from '../lib/timeline-import'
|
||||
import { parseTimelineXml } from '../lib/timeline-import'
|
||||
import { selectShowImportTimelineModal } from '../views/editor/editor-selectors'
|
||||
import { useEditorActions, useEditorStore } from '../views/editor/editor-store'
|
||||
|
||||
interface ImportTimelineModalProps {
|
||||
projectId: string | null
|
||||
}
|
||||
|
||||
type ImportStep = 'select' | 'parsing' | 'relink' | 'error'
|
||||
type MediaImportStatus = 'missing' | 'ready' | 'copying' | 'copied' | 'skipped' | 'error'
|
||||
|
||||
interface ImportProgressState {
|
||||
total: number
|
||||
completed: number
|
||||
skipped: number
|
||||
currentName: string
|
||||
}
|
||||
|
||||
function getFilenameFromPath(filePath: string): string {
|
||||
const parts = filePath.split(/[/\\]/)
|
||||
return parts[parts.length - 1] || ''
|
||||
}
|
||||
|
||||
export function ImportTimelineModal({ projectId }: ImportTimelineModalProps) {
|
||||
const { closeImportTimelineModal, importParsedTimeline } = useEditorActions()
|
||||
const isOpen = useEditorStore(selectShowImportTimelineModal)
|
||||
const [step, setStep] = useState<ImportStep>('select')
|
||||
const [parsedTimeline, setParsedTimeline] = useState<ParsedTimeline | null>(null)
|
||||
const [mediaRefs, setMediaRefs] = useState<ParsedMediaRef[]>([])
|
||||
const [mediaStatus, setMediaStatus] = useState<Record<string, MediaImportStatus>>({})
|
||||
const [error, setError] = useState<string>('')
|
||||
const [importError, setImportError] = useState<string>('')
|
||||
const [expandedInfo, setExpandedInfo] = useState(true)
|
||||
const [expandedMedia, setExpandedMedia] = useState(true)
|
||||
const [isChecking, setIsChecking] = useState(false)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const [importProgress, setImportProgress] = useState<ImportProgressState>({
|
||||
total: 0,
|
||||
completed: 0,
|
||||
skipped: 0,
|
||||
currentName: '',
|
||||
})
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
closeImportTimelineModal()
|
||||
}, [closeImportTimelineModal])
|
||||
|
||||
// Reset when modal opens
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
setStep('select')
|
||||
setParsedTimeline(null)
|
||||
setMediaRefs([])
|
||||
setMediaStatus({})
|
||||
setError('')
|
||||
setImportError('')
|
||||
setIsChecking(false)
|
||||
setIsSearching(false)
|
||||
setIsImporting(false)
|
||||
setImportProgress({ total: 0, completed: 0, skipped: 0, currentName: '' })
|
||||
}, [isOpen])
|
||||
|
||||
const applyAvailabilityToStatus = useCallback((refs: ParsedMediaRef[], availabilityById: Record<string, boolean>) => {
|
||||
const nextStatus: Record<string, MediaImportStatus> = {}
|
||||
for (const ref of refs) {
|
||||
nextStatus[ref.id] = availabilityById[ref.id] ? 'ready' : 'missing'
|
||||
}
|
||||
setMediaStatus(nextStatus)
|
||||
}, [])
|
||||
|
||||
// Check file existence for all media refs
|
||||
const checkMediaFiles = useCallback(async (refs: ParsedMediaRef[]): Promise<Record<string, boolean>> => {
|
||||
const availabilityById: Record<string, boolean> = {}
|
||||
if (!window.electronAPI?.checkFilesExist) {
|
||||
refs.forEach(ref => {
|
||||
availabilityById[ref.id] = false
|
||||
})
|
||||
return availabilityById
|
||||
}
|
||||
|
||||
setIsChecking(true)
|
||||
try {
|
||||
const uniquePaths = Array.from(new Set(
|
||||
refs
|
||||
.map(r => r.path?.trim())
|
||||
.filter((p): p is string => Boolean(p))
|
||||
))
|
||||
|
||||
const results = await window.electronAPI.checkFilesExist({ filePaths: uniquePaths })
|
||||
refs.forEach(ref => {
|
||||
const path = ref.path?.trim()
|
||||
availabilityById[ref.id] = path ? (results[path] || false) : false
|
||||
})
|
||||
return availabilityById
|
||||
} catch (err) {
|
||||
logger.error(`Error checking files: ${err}`)
|
||||
refs.forEach(ref => {
|
||||
availabilityById[ref.id] = false
|
||||
})
|
||||
return availabilityById
|
||||
} finally {
|
||||
setIsChecking(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelected = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const filename = file.name
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
|
||||
// Check for AAF
|
||||
if (ext === 'aaf') {
|
||||
setError(
|
||||
'AAF files cannot be imported directly.\n\n' +
|
||||
'Please export your timeline as FCP 7 XML from your editing software:\n\n' +
|
||||
' Premiere Pro: File → Export → Final Cut Pro XML\n' +
|
||||
' DaVinci Resolve: File → Export Timeline → FCP 7 XML\n' +
|
||||
' Avid Media Composer: File → Export → FCP 7 XML'
|
||||
)
|
||||
setStep('error')
|
||||
return
|
||||
}
|
||||
|
||||
setStep('parsing')
|
||||
setImportError('')
|
||||
|
||||
try {
|
||||
const content = await file.text()
|
||||
const timeline = parseTimelineXml(content, filename)
|
||||
|
||||
if (!timeline) {
|
||||
throw new Error('Could not parse timeline from file')
|
||||
}
|
||||
|
||||
setParsedTimeline(timeline)
|
||||
setMediaRefs(timeline.mediaRefs)
|
||||
|
||||
const availabilityById = await checkMediaFiles(timeline.mediaRefs)
|
||||
applyAvailabilityToStatus(timeline.mediaRefs, availabilityById)
|
||||
setStep('relink')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setStep('error')
|
||||
}
|
||||
|
||||
// Reset input
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}, [applyAvailabilityToStatus, checkMediaFiles])
|
||||
|
||||
// Relink a single media file
|
||||
const handleRelinkFile = useCallback(async (mediaRefId: string) => {
|
||||
if (isImporting || !window.electronAPI?.showOpenFileDialog) return
|
||||
|
||||
const filePaths = await window.electronAPI.showOpenFileDialog({
|
||||
title: 'Relink Media File',
|
||||
filters: [
|
||||
{ name: 'Media Files', extensions: ['mp4', 'mov', 'avi', 'mkv', 'mxf', 'mp3', 'wav', 'aac', 'flac', 'jpg', 'jpeg', 'png', 'tiff', 'exr', 'dpx'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
})
|
||||
|
||||
if (!filePaths || filePaths.length === 0) return
|
||||
|
||||
const newPath = filePaths[0]
|
||||
setImportError('')
|
||||
setMediaRefs(prev => prev.map(r =>
|
||||
r.id === mediaRefId
|
||||
? { ...r, path: newPath }
|
||||
: r
|
||||
))
|
||||
setMediaStatus(prev => ({ ...prev, [mediaRefId]: 'ready' }))
|
||||
}, [isImporting])
|
||||
|
||||
// Search a directory for all missing files
|
||||
const handleSearchDirectory = useCallback(async () => {
|
||||
if (isImporting || !window.electronAPI?.showOpenDirectoryDialog) return
|
||||
|
||||
const dir = await window.electronAPI.showOpenDirectoryDialog({ title: 'Select folder to search for media files' })
|
||||
if (!dir) return
|
||||
|
||||
setIsSearching(true)
|
||||
setImportError('')
|
||||
|
||||
try {
|
||||
const missingRefs = mediaRefs.filter(r => mediaStatus[r.id] === 'missing')
|
||||
if (missingRefs.length === 0) return
|
||||
|
||||
// Build list of filenames to search for
|
||||
const filenames: string[] = []
|
||||
for (const ref of missingRefs) {
|
||||
const filename = ref.name || getFilenameFromPath(ref.path)
|
||||
if (filename) filenames.push(filename)
|
||||
}
|
||||
|
||||
if (filenames.length === 0) return
|
||||
|
||||
// Use recursive directory search (searches subdirectories up to 10 levels deep)
|
||||
if (window.electronAPI.searchDirectoryForFiles) {
|
||||
const results = await window.electronAPI.searchDirectoryForFiles({ directory: dir, filenames })
|
||||
// results is { "filename.mp4" (lowercase): "C:\\full\\path\\filename.mp4" }
|
||||
|
||||
setMediaRefs(prev => prev.map(ref => {
|
||||
if (mediaStatus[ref.id] !== 'missing') return ref
|
||||
const filename = ref.name || getFilenameFromPath(ref.path)
|
||||
const foundPath = filename ? results[filename.toLowerCase()] : undefined
|
||||
|
||||
if (foundPath) {
|
||||
return { ...ref, path: foundPath }
|
||||
}
|
||||
return ref
|
||||
}))
|
||||
|
||||
setMediaStatus(prev => {
|
||||
const next = { ...prev }
|
||||
for (const ref of missingRefs) {
|
||||
const filename = ref.name || getFilenameFromPath(ref.path)
|
||||
const foundPath = filename ? results[filename.toLowerCase()] : undefined
|
||||
if (foundPath) {
|
||||
next[ref.id] = 'ready'
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
// Fallback: check direct paths only (no recursive search)
|
||||
const searchPaths: string[] = []
|
||||
for (const filename of filenames) {
|
||||
const separator = dir.includes('\\') ? '\\' : '/'
|
||||
searchPaths.push(`${dir}${separator}${filename}`)
|
||||
}
|
||||
|
||||
const results = await window.electronAPI.checkFilesExist({ filePaths: searchPaths })
|
||||
|
||||
setMediaRefs(prev => prev.map(ref => {
|
||||
if (mediaStatus[ref.id] !== 'missing') return ref
|
||||
const filename = ref.name || getFilenameFromPath(ref.path)
|
||||
const separator = dir.includes('\\') ? '\\' : '/'
|
||||
const testPath = filename ? `${dir}${separator}${filename}` : ''
|
||||
|
||||
if (testPath && results[testPath]) {
|
||||
return { ...ref, path: testPath }
|
||||
}
|
||||
return ref
|
||||
}))
|
||||
|
||||
setMediaStatus(prev => {
|
||||
const next = { ...prev }
|
||||
for (const ref of missingRefs) {
|
||||
const filename = ref.name || getFilenameFromPath(ref.path)
|
||||
const separator = dir.includes('\\') ? '\\' : '/'
|
||||
const testPath = filename ? `${dir}${separator}${filename}` : ''
|
||||
if (testPath && results[testPath]) {
|
||||
next[ref.id] = 'ready'
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error searching directory: ${err}`)
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}, [isImporting, mediaRefs, mediaStatus])
|
||||
|
||||
// Recheck all paths
|
||||
const handleRecheckAll = useCallback(async () => {
|
||||
if (isImporting) return
|
||||
const availabilityById = await checkMediaFiles(mediaRefs)
|
||||
applyAvailabilityToStatus(mediaRefs, availabilityById)
|
||||
}, [applyAvailabilityToStatus, checkMediaFiles, isImporting, mediaRefs])
|
||||
|
||||
// Confirm import
|
||||
const handleConfirmImport = useCallback(async () => {
|
||||
if (!parsedTimeline || isImporting) return
|
||||
if (!projectId) {
|
||||
setImportError('Cannot import timeline: no active project.')
|
||||
return
|
||||
}
|
||||
|
||||
setImportError('')
|
||||
setIsImporting(true)
|
||||
|
||||
const refsToImport = mediaRefs.map(ref => ({ ...ref }))
|
||||
const copiedAssetBySource = new Map<string, {
|
||||
path: string
|
||||
bigThumbnailPath?: string
|
||||
smallThumbnailPath?: string
|
||||
width?: number
|
||||
height?: number
|
||||
}>()
|
||||
const total = refsToImport.length
|
||||
let completed = 0
|
||||
let skipped = 0
|
||||
|
||||
setImportProgress({ total, completed: 0, skipped: 0, currentName: '' })
|
||||
|
||||
try {
|
||||
for (const ref of refsToImport) {
|
||||
const sourcePath = ref.path?.trim() || ''
|
||||
const displayName = ref.name || getFilenameFromPath(sourcePath) || 'Unnamed media'
|
||||
const currentStatus = mediaStatus[ref.id] || 'missing'
|
||||
|
||||
setImportProgress({
|
||||
total,
|
||||
completed,
|
||||
skipped,
|
||||
currentName: displayName,
|
||||
})
|
||||
|
||||
if (!sourcePath || currentStatus === 'missing') {
|
||||
skipped += 1
|
||||
completed += 1
|
||||
setMediaStatus(prev => ({ ...prev, [ref.id]: 'skipped' }))
|
||||
setImportProgress({
|
||||
total,
|
||||
completed,
|
||||
skipped,
|
||||
currentName: displayName,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const existingCopiedAsset = copiedAssetBySource.get(sourcePath)
|
||||
if (existingCopiedAsset) {
|
||||
ref.path = existingCopiedAsset.path
|
||||
ref.bigThumbnailPath = existingCopiedAsset.bigThumbnailPath
|
||||
ref.smallThumbnailPath = existingCopiedAsset.smallThumbnailPath
|
||||
ref.width = existingCopiedAsset.width
|
||||
ref.height = existingCopiedAsset.height
|
||||
completed += 1
|
||||
setMediaStatus(prev => ({ ...prev, [ref.id]: 'copied' }))
|
||||
setMediaRefs(prev => prev.map(item => (
|
||||
item.id === ref.id
|
||||
? {
|
||||
...item,
|
||||
path: existingCopiedAsset.path,
|
||||
bigThumbnailPath: existingCopiedAsset.bigThumbnailPath,
|
||||
smallThumbnailPath: existingCopiedAsset.smallThumbnailPath,
|
||||
width: existingCopiedAsset.width,
|
||||
height: existingCopiedAsset.height,
|
||||
}
|
||||
: item
|
||||
)))
|
||||
setImportProgress({
|
||||
total,
|
||||
completed,
|
||||
skipped,
|
||||
currentName: displayName,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
setMediaStatus(prev => ({ ...prev, [ref.id]: 'copying' }))
|
||||
let copiedAsset: {
|
||||
path: string
|
||||
bigThumbnailPath?: string
|
||||
smallThumbnailPath?: string
|
||||
width?: number
|
||||
height?: number
|
||||
} | null = null
|
||||
if (ref.type === 'video' || ref.type === 'image') {
|
||||
const copied = await addVisualAssetToProject(sourcePath, projectId, ref.type)
|
||||
if (!copied) {
|
||||
setMediaStatus(prev => ({ ...prev, [ref.id]: 'error' }))
|
||||
throw new Error(`Failed to copy media into project assets: ${displayName}`)
|
||||
}
|
||||
copiedAsset = copied
|
||||
} else {
|
||||
const copied = await addGenericAssetToProject(sourcePath, projectId)
|
||||
if (!copied?.path) {
|
||||
setMediaStatus(prev => ({ ...prev, [ref.id]: 'error' }))
|
||||
throw new Error(`Failed to copy media into project assets: ${displayName}`)
|
||||
}
|
||||
copiedAsset = { path: copied.path }
|
||||
}
|
||||
|
||||
ref.path = copiedAsset.path
|
||||
ref.bigThumbnailPath = copiedAsset.bigThumbnailPath
|
||||
ref.smallThumbnailPath = copiedAsset.smallThumbnailPath
|
||||
ref.width = copiedAsset.width
|
||||
ref.height = copiedAsset.height
|
||||
copiedAssetBySource.set(sourcePath, copiedAsset)
|
||||
completed += 1
|
||||
|
||||
setMediaStatus(prev => ({ ...prev, [ref.id]: 'copied' }))
|
||||
setMediaRefs(prev => prev.map(item => (
|
||||
item.id === ref.id
|
||||
? {
|
||||
...item,
|
||||
path: copiedAsset.path,
|
||||
bigThumbnailPath: copiedAsset.bigThumbnailPath,
|
||||
smallThumbnailPath: copiedAsset.smallThumbnailPath,
|
||||
width: copiedAsset.width,
|
||||
height: copiedAsset.height,
|
||||
}
|
||||
: item
|
||||
)))
|
||||
setImportProgress({
|
||||
total,
|
||||
completed,
|
||||
skipped,
|
||||
currentName: displayName,
|
||||
})
|
||||
}
|
||||
|
||||
// Update timeline refs with copied/updated paths
|
||||
const updatedTimeline: ParsedTimeline = {
|
||||
...parsedTimeline,
|
||||
mediaRefs: refsToImport,
|
||||
}
|
||||
|
||||
importParsedTimeline(updatedTimeline)
|
||||
closeModal()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setImportError(message)
|
||||
logger.error(`Import timeline copy failed: ${message}`)
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
}
|
||||
}, [closeModal, importParsedTimeline, isImporting, mediaRefs, mediaStatus, parsedTimeline, projectId])
|
||||
|
||||
const readyCount = mediaRefs.filter(r => (mediaStatus[r.id] || 'missing') !== 'missing').length
|
||||
const totalCount = mediaRefs.length
|
||||
const allFound = readyCount === totalCount && totalCount > 0
|
||||
const interactionDisabled = isImporting
|
||||
const importPercent = importProgress.total > 0 ? (importProgress.completed / importProgress.total) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-[680px] max-h-[85vh] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-600/20 flex items-center justify-center">
|
||||
<Upload className="h-4 w-4 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">Import Timeline</h2>
|
||||
<p className="text-[11px] text-zinc-500">
|
||||
Premiere Pro XML, DaVinci Resolve XML, Final Cut Pro XML/FCPXML
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
disabled={interactionDisabled}
|
||||
className="p-1.5 rounded-lg hover:bg-zinc-800 text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className={`flex-1 overflow-auto p-6 ${interactionDisabled ? 'pointer-events-none opacity-60' : ''}`}>
|
||||
{/* Step 1: File Selection */}
|
||||
{step === 'select' && (
|
||||
<div className="space-y-6">
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-zinc-700 hover:border-blue-500/50 rounded-xl p-10 text-center cursor-pointer transition-colors group"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full bg-zinc-800 group-hover:bg-blue-900/30 flex items-center justify-center mx-auto mb-4 transition-colors">
|
||||
<FileText className="h-7 w-7 text-zinc-500 group-hover:text-blue-400 transition-colors" />
|
||||
</div>
|
||||
<p className="text-sm text-zinc-300 font-medium mb-1">Click to select timeline file</p>
|
||||
<p className="text-xs text-zinc-600">Supports .xml (FCP 7 XML), .fcpxml</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xml,.fcpxml,.aaf"
|
||||
onChange={handleFileSelected}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="bg-zinc-800/50 rounded-lg p-4 space-y-2">
|
||||
<h4 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">How to export from your NLE:</h4>
|
||||
<div className="space-y-1.5 text-[11px] text-zinc-500">
|
||||
<p><span className="text-blue-400 font-medium">Premiere Pro:</span> File → Export → Final Cut Pro XML</p>
|
||||
<p><span className="text-orange-400 font-medium">DaVinci Resolve:</span> File → Export Timeline → FCP 7 XML (.xml)</p>
|
||||
<p><span className="text-blue-400 font-medium">Final Cut Pro:</span> File → Export XML</p>
|
||||
<p className="text-zinc-600 pt-1 border-t border-zinc-700/50 mt-2">
|
||||
AAF files are binary and cannot be imported directly. Please export as XML instead.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parsing indicator */}
|
||||
{step === 'parsing' && (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 text-blue-400 animate-spin mb-4" />
|
||||
<p className="text-sm text-zinc-400">Parsing timeline...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{step === 'error' && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-red-900/20 border border-red-800/30 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-300 mb-1">Import Error</p>
|
||||
<p className="text-xs text-red-400/80 whitespace-pre-wrap">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setStep('select'); setError('') }}
|
||||
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
Try another file
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Relink media */}
|
||||
{step === 'relink' && parsedTimeline && (
|
||||
<div className="space-y-4">
|
||||
{/* Timeline info */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setExpandedInfo(!expandedInfo)}
|
||||
className="flex items-center gap-2 text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-2 hover:text-zinc-300"
|
||||
>
|
||||
{expandedInfo ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
Timeline Info
|
||||
</button>
|
||||
{expandedInfo && (
|
||||
<div className="bg-zinc-800/50 rounded-lg p-3 grid grid-cols-2 gap-x-6 gap-y-1.5 text-[11px]">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">Name:</span>
|
||||
<span className="text-white font-medium">{parsedTimeline.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">Format:</span>
|
||||
<span className="text-zinc-300">
|
||||
{parsedTimeline.format === 'fcp7xml' ? 'FCP 7 XML' : parsedTimeline.format === 'fcpxml' ? 'FCPXML' : parsedTimeline.format}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">FPS:</span>
|
||||
<span className="text-zinc-300">{parsedTimeline.fps.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">Duration:</span>
|
||||
<span className="text-zinc-300">{parsedTimeline.duration.toFixed(1)}s</span>
|
||||
</div>
|
||||
{parsedTimeline.width && parsedTimeline.height && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">Resolution:</span>
|
||||
<span className="text-zinc-300">{parsedTimeline.width}x{parsedTimeline.height}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">Clips:</span>
|
||||
<span className="text-zinc-300">{parsedTimeline.clips.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">Video Tracks:</span>
|
||||
<span className="text-zinc-300">{parsedTimeline.videoTrackCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">Audio Tracks:</span>
|
||||
<span className="text-zinc-300">{parsedTimeline.audioTrackCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Media files */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button
|
||||
onClick={() => setExpandedMedia(!expandedMedia)}
|
||||
className="flex items-center gap-2 text-xs font-semibold text-zinc-400 uppercase tracking-wider hover:text-zinc-300"
|
||||
>
|
||||
{expandedMedia ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
Media Files ({readyCount}/{totalCount} linked)
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={handleSearchDirectory}
|
||||
disabled={isSearching || allFound || interactionDisabled}
|
||||
className="px-2.5 py-1 rounded-md bg-zinc-800 text-zinc-400 text-[10px] hover:bg-zinc-700 hover:text-zinc-300 flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
title="Search a folder for missing media"
|
||||
>
|
||||
{isSearching ? <Loader2 className="h-3 w-3 animate-spin" /> : <FolderOpen className="h-3 w-3" />}
|
||||
Search Folder
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRecheckAll}
|
||||
disabled={isChecking || interactionDisabled}
|
||||
className="px-2.5 py-1 rounded-md bg-zinc-800 text-zinc-400 text-[10px] hover:bg-zinc-700 hover:text-zinc-300 flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
title="Recheck all file paths"
|
||||
>
|
||||
{isChecking ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||||
Recheck
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Availability bar */}
|
||||
<div className="mb-2">
|
||||
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${allFound ? 'bg-green-500' : readyCount > 0 ? 'bg-amber-500' : 'bg-red-500'}`}
|
||||
style={{ width: `${totalCount > 0 ? (readyCount / totalCount) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedMedia && (
|
||||
<div className="space-y-1 max-h-[300px] overflow-auto rounded-lg border border-zinc-800">
|
||||
{mediaRefs.map((ref, i) => {
|
||||
const TypeIcon = ref.type === 'video' ? FileVideo : ref.type === 'audio' ? FileAudio : Image
|
||||
const status = mediaStatus[ref.id] || 'missing'
|
||||
const isPositive = status === 'ready' || status === 'copied'
|
||||
const isMissing = status === 'missing'
|
||||
const isCopying = status === 'copying'
|
||||
const isSkipped = status === 'skipped'
|
||||
const isErrored = status === 'error'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ref.id}
|
||||
className={`flex items-center gap-2 px-3 py-2 text-[11px] ${i % 2 === 0 ? 'bg-zinc-800/30' : 'bg-zinc-900/30'}`}
|
||||
>
|
||||
<TypeIcon className={`h-3.5 w-3.5 flex-shrink-0 ${
|
||||
ref.type === 'video' ? 'text-blue-400' : ref.type === 'audio' ? 'text-green-400' : 'text-blue-400'
|
||||
}`} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isCopying ? (
|
||||
<Loader2 className="h-3 w-3 text-blue-400 flex-shrink-0 animate-spin" />
|
||||
) : isPositive ? (
|
||||
<Check className="h-3 w-3 text-green-400 flex-shrink-0" />
|
||||
) : isSkipped ? (
|
||||
<AlertTriangle className="h-3 w-3 text-amber-400 flex-shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className={`h-3 w-3 flex-shrink-0 ${isErrored ? 'text-red-400' : 'text-red-400'}`} />
|
||||
)}
|
||||
<span className={`truncate font-medium ${
|
||||
isPositive ? 'text-zinc-300' : isSkipped ? 'text-amber-300' : 'text-red-300'
|
||||
}`}>
|
||||
{ref.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[9px] text-zinc-600 truncate mt-0.5">
|
||||
{ref.path || '(no path)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isMissing && (
|
||||
<button
|
||||
onClick={() => handleRelinkFile(ref.id)}
|
||||
disabled={interactionDisabled}
|
||||
className="flex-shrink-0 px-2 py-1 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300 text-[10px] flex items-center gap-1 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Relink this file"
|
||||
>
|
||||
<Link2 className="h-3 w-3" />
|
||||
Relink
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{mediaRefs.length === 0 && (
|
||||
<div className="p-4 text-center text-xs text-zinc-600">
|
||||
No media files referenced in this timeline.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{step === 'relink' && (
|
||||
<>
|
||||
<div className={`px-6 py-4 border-t border-zinc-800 flex items-center justify-between bg-zinc-900 ${interactionDisabled ? 'pointer-events-none opacity-60' : ''}`}>
|
||||
<div className="text-[11px] text-zinc-500">
|
||||
{importError && (
|
||||
<span className="text-red-400">{importError}</span>
|
||||
)}
|
||||
{!importError && !allFound && totalCount > 0 && (
|
||||
<span className="text-amber-400">
|
||||
{totalCount - readyCount} missing file{totalCount - readyCount !== 1 ? 's' : ''} — clips with missing media will be placeholders
|
||||
</span>
|
||||
)}
|
||||
{!importError && allFound && totalCount > 0 && (
|
||||
<span className="text-green-400">All media files found</span>
|
||||
)}
|
||||
{!importError && totalCount === 0 && (
|
||||
<span>No media references to link</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={closeModal}
|
||||
disabled={interactionDisabled}
|
||||
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm hover:bg-zinc-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmImport}
|
||||
disabled={interactionDisabled}
|
||||
className="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-500 transition-colors font-medium flex items-center gap-2 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
Import Timeline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isImporting && (
|
||||
<div className="px-6 py-3 border-t border-zinc-800 bg-zinc-900/95">
|
||||
<div className="flex items-center justify-between mb-1.5 text-[11px]">
|
||||
<span className="text-blue-300 flex items-center gap-1.5">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Copying media {importProgress.completed}/{importProgress.total}
|
||||
</span>
|
||||
<span className="text-zinc-500">
|
||||
{importProgress.skipped} skipped
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500 transition-all duration-200"
|
||||
style={{ width: `${importPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{importProgress.currentName && (
|
||||
<p className="text-[10px] text-zinc-500 truncate mt-1.5">
|
||||
{importProgress.currentName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,793 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { X, Search, Keyboard, RotateCcw, Save, AlertTriangle, ChevronDown, GripVertical, Trash2 } from 'lucide-react'
|
||||
import { useKeyboardShortcuts } from '../contexts/KeyboardShortcutsContext'
|
||||
import {
|
||||
ACTION_REGISTRY,
|
||||
ActionId,
|
||||
KeyCombo,
|
||||
formatKeyCombo,
|
||||
findConflicts,
|
||||
ActionDefinition,
|
||||
} from '../lib/keyboard-shortcuts'
|
||||
|
||||
// ── Visual keyboard layout (US QWERTY) ──
|
||||
// Each key: { id: lowercase key id matching KeyCombo.key, label: display text, w: width units (1 = standard key) }
|
||||
interface KBKey { id: string; label: string; w: number }
|
||||
|
||||
const KB_ROWS: KBKey[][] = [
|
||||
// Row 0: Number row
|
||||
[
|
||||
{ id: '`', label: '`', w: 1 },
|
||||
{ id: '1', label: '1', w: 1 },
|
||||
{ id: '2', label: '2', w: 1 },
|
||||
{ id: '3', label: '3', w: 1 },
|
||||
{ id: '4', label: '4', w: 1 },
|
||||
{ id: '5', label: '5', w: 1 },
|
||||
{ id: '6', label: '6', w: 1 },
|
||||
{ id: '7', label: '7', w: 1 },
|
||||
{ id: '8', label: '8', w: 1 },
|
||||
{ id: '9', label: '9', w: 1 },
|
||||
{ id: '0', label: '0', w: 1 },
|
||||
{ id: '-', label: '-', w: 1 },
|
||||
{ id: '=', label: '=', w: 1 },
|
||||
{ id: 'backspace', label: 'Bksp', w: 2 },
|
||||
],
|
||||
// Row 1: QWERTY
|
||||
[
|
||||
{ id: 'tab', label: 'Tab', w: 1.5 },
|
||||
{ id: 'q', label: 'Q', w: 1 },
|
||||
{ id: 'w', label: 'W', w: 1 },
|
||||
{ id: 'e', label: 'E', w: 1 },
|
||||
{ id: 'r', label: 'R', w: 1 },
|
||||
{ id: 't', label: 'T', w: 1 },
|
||||
{ id: 'y', label: 'Y', w: 1 },
|
||||
{ id: 'u', label: 'U', w: 1 },
|
||||
{ id: 'i', label: 'I', w: 1 },
|
||||
{ id: 'o', label: 'O', w: 1 },
|
||||
{ id: 'p', label: 'P', w: 1 },
|
||||
{ id: '[', label: '[', w: 1 },
|
||||
{ id: ']', label: ']', w: 1 },
|
||||
{ id: '\\', label: '\\', w: 1.5 },
|
||||
],
|
||||
// Row 2: Home row
|
||||
[
|
||||
{ id: 'capslock', label: 'Caps', w: 1.8 },
|
||||
{ id: 'a', label: 'A', w: 1 },
|
||||
{ id: 's', label: 'S', w: 1 },
|
||||
{ id: 'd', label: 'D', w: 1 },
|
||||
{ id: 'f', label: 'F', w: 1 },
|
||||
{ id: 'g', label: 'G', w: 1 },
|
||||
{ id: 'h', label: 'H', w: 1 },
|
||||
{ id: 'j', label: 'J', w: 1 },
|
||||
{ id: 'k', label: 'K', w: 1 },
|
||||
{ id: 'l', label: 'L', w: 1 },
|
||||
{ id: ';', label: ';', w: 1 },
|
||||
{ id: "'", label: "'", w: 1 },
|
||||
{ id: 'enter', label: 'Enter', w: 2.2 },
|
||||
],
|
||||
// Row 3: Shift row
|
||||
[
|
||||
{ id: 'shift-l', label: 'Shift', w: 2.5 },
|
||||
{ id: 'z', label: 'Z', w: 1 },
|
||||
{ id: 'x', label: 'X', w: 1 },
|
||||
{ id: 'c', label: 'C', w: 1 },
|
||||
{ id: 'v', label: 'V', w: 1 },
|
||||
{ id: 'b', label: 'B', w: 1 },
|
||||
{ id: 'n', label: 'N', w: 1 },
|
||||
{ id: 'm', label: 'M', w: 1 },
|
||||
{ id: ',', label: ',', w: 1 },
|
||||
{ id: '.', label: '.', w: 1 },
|
||||
{ id: '/', label: '/', w: 1 },
|
||||
{ id: 'shift-r', label: 'Shift', w: 2.5 },
|
||||
],
|
||||
// Row 4: Bottom row
|
||||
[
|
||||
{ id: 'ctrl-l', label: 'Ctrl', w: 1.5 },
|
||||
{ id: 'alt-l', label: 'Alt', w: 1.5 },
|
||||
{ id: ' ', label: 'Space', w: 7 },
|
||||
{ id: 'alt-r', label: 'Alt', w: 1.5 },
|
||||
{ id: 'ctrl-r', label: 'Ctrl', w: 1.5 },
|
||||
],
|
||||
// Row 5: Navigation cluster
|
||||
[
|
||||
{ id: 'escape', label: 'Esc', w: 1 },
|
||||
{ id: 'delete', label: 'Del', w: 1 },
|
||||
{ id: 'home', label: 'Home', w: 1 },
|
||||
{ id: 'end', label: 'End', w: 1 },
|
||||
{ id: 'f9', label: 'F9', w: 1 },
|
||||
{ id: 'f10', label: 'F10', w: 1 },
|
||||
{ id: 'f11', label: 'F11', w: 1 },
|
||||
{ id: 'arrowleft', label: '\u2190', w: 1 },
|
||||
{ id: 'arrowup', label: '\u2191', w: 1 },
|
||||
{ id: 'arrowdown', label: '\u2193', w: 1 },
|
||||
{ id: 'arrowright', label: '\u2192', w: 1 },
|
||||
],
|
||||
]
|
||||
|
||||
// Category → color mapping
|
||||
const CATEGORY_COLORS: Record<string, { bg: string; border: string; text: string; dot: string }> = {
|
||||
Tools: { bg: 'bg-blue-600/30', border: 'border-blue-500/60', text: 'text-blue-300', dot: 'bg-blue-400' },
|
||||
Transport: { bg: 'bg-emerald-600/30', border: 'border-emerald-500/60', text: 'text-emerald-300', dot: 'bg-emerald-400' },
|
||||
Editing: { bg: 'bg-amber-600/30', border: 'border-amber-500/60', text: 'text-amber-300', dot: 'bg-amber-400' },
|
||||
Marking: { bg: 'bg-rose-600/30', border: 'border-rose-500/60', text: 'text-rose-300', dot: 'bg-rose-400' },
|
||||
Timeline: { bg: 'bg-blue-600/30', border: 'border-blue-500/60', text: 'text-blue-300', dot: 'bg-blue-400' },
|
||||
}
|
||||
|
||||
// Keys that are modifier indicators (not assignable targets)
|
||||
const MODIFIER_KEY_IDS = new Set(['shift-l', 'shift-r', 'ctrl-l', 'ctrl-r', 'alt-l', 'alt-r', 'capslock', 'tab'])
|
||||
|
||||
export function KeyboardShortcutsModal() {
|
||||
const {
|
||||
activeLayout,
|
||||
activePresetId,
|
||||
presets,
|
||||
switchPreset,
|
||||
updateBinding,
|
||||
resetToPreset,
|
||||
saveAsCustomPreset,
|
||||
deleteCustomPreset,
|
||||
isEditorOpen,
|
||||
setEditorOpen,
|
||||
} = useKeyboardShortcuts()
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [recordingAction, setRecordingAction] = useState<ActionId | null>(null)
|
||||
const [showPresetDropdown, setShowPresetDropdown] = useState(false)
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||
const [savePresetName, setSavePresetName] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
|
||||
const recordRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Visual keyboard state
|
||||
const [kbModCtrl, setKbModCtrl] = useState(false)
|
||||
const [kbModShift, setKbModShift] = useState(false)
|
||||
const [kbModAlt, setKbModAlt] = useState(false)
|
||||
const [hoveredActionId, setHoveredActionId] = useState<ActionId | null>(null)
|
||||
const [draggedActionId, setDraggedActionId] = useState<ActionId | null>(null)
|
||||
const [dropTargetKey, setDropTargetKey] = useState<string | null>(null)
|
||||
const [showKeyboard, setShowKeyboard] = useState(true)
|
||||
|
||||
const conflicts = findConflicts(activeLayout)
|
||||
const activePreset = presets.find(p => p.id === activePresetId)
|
||||
|
||||
// ── Reverse lookup: given current modifier state, map keyId → ActionId ──
|
||||
const keyToAction = useMemo(() => {
|
||||
const map = new Map<string, { actionId: ActionId; action: ActionDefinition }>()
|
||||
for (const [actionId, combos] of Object.entries(activeLayout)) {
|
||||
if (!combos) continue
|
||||
for (const combo of combos) {
|
||||
// Check if this combo matches the current modifier filter
|
||||
const comboCtrl = !!combo.ctrl || !!combo.meta
|
||||
const comboShift = !!combo.shift
|
||||
const comboAlt = !!combo.alt
|
||||
if (comboCtrl === kbModCtrl && comboShift === kbModShift && comboAlt === kbModAlt) {
|
||||
const def = ACTION_REGISTRY.find(a => a.id === actionId)
|
||||
if (def) {
|
||||
map.set(combo.key, { actionId: actionId as ActionId, action: def })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [activeLayout, kbModCtrl, kbModShift, kbModAlt])
|
||||
|
||||
// ── Keys assigned to the hovered action (for highlighting) ──
|
||||
const hoveredKeys = useMemo(() => {
|
||||
if (!hoveredActionId) return new Set<string>()
|
||||
const combos = activeLayout[hoveredActionId] || []
|
||||
return new Set(combos.map(c => c.key))
|
||||
}, [hoveredActionId, activeLayout])
|
||||
|
||||
// Close on Escape (only when not recording)
|
||||
useEffect(() => {
|
||||
if (!isEditorOpen) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !recordingAction) {
|
||||
setEditorOpen(false)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [isEditorOpen, recordingAction, setEditorOpen])
|
||||
|
||||
// Recording mode: capture the next keystroke as a new binding
|
||||
const handleRecordKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (!recordingAction) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Ignore modifier-only presses
|
||||
if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return
|
||||
|
||||
const combo: KeyCombo = {
|
||||
key: e.key.toLowerCase(),
|
||||
...(e.ctrlKey || e.metaKey ? { ctrl: true } : {}),
|
||||
...(e.shiftKey ? { shift: true } : {}),
|
||||
...(e.altKey ? { alt: true } : {}),
|
||||
}
|
||||
|
||||
// If Escape pressed alone, cancel recording
|
||||
if (combo.key === 'escape' && !combo.ctrl && !combo.shift && !combo.alt) {
|
||||
setRecordingAction(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Set the new binding (replace existing)
|
||||
updateBinding(recordingAction, [combo])
|
||||
setRecordingAction(null)
|
||||
}, [recordingAction, updateBinding])
|
||||
|
||||
useEffect(() => {
|
||||
if (recordingAction) {
|
||||
window.addEventListener('keydown', handleRecordKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', handleRecordKeyDown, true)
|
||||
}
|
||||
}, [recordingAction, handleRecordKeyDown])
|
||||
|
||||
// ── Drag and drop via React state (avoids HTML5 DnD issues in Electron) ──
|
||||
// We track the dragged action in a ref so it's always current
|
||||
const draggedActionRef = useRef<ActionId | null>(null)
|
||||
|
||||
// Mouse-based drag: user mousedowns on an action, moves to a key, mouseups to drop
|
||||
const handleActionMouseDown = useCallback((e: React.MouseEvent, actionId: ActionId) => {
|
||||
// Only left-click
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
draggedActionRef.current = actionId
|
||||
setDraggedActionId(actionId)
|
||||
}, [])
|
||||
|
||||
// Global mouse handlers for drag (attached when dragging)
|
||||
useEffect(() => {
|
||||
if (!draggedActionId) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
// Find which keyboard key the mouse is over
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY)
|
||||
const keyEl = el?.closest('[data-kb-key]') as HTMLElement | null
|
||||
if (keyEl) {
|
||||
const keyId = keyEl.getAttribute('data-kb-key')
|
||||
if (keyId && !MODIFIER_KEY_IDS.has(keyId)) {
|
||||
setDropTargetKey(keyId)
|
||||
} else {
|
||||
setDropTargetKey(null)
|
||||
}
|
||||
} else {
|
||||
setDropTargetKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
const actionId = draggedActionRef.current
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY)
|
||||
const keyEl = el?.closest('[data-kb-key]') as HTMLElement | null
|
||||
|
||||
if (actionId && keyEl) {
|
||||
const keyId = keyEl.getAttribute('data-kb-key')
|
||||
if (keyId && !MODIFIER_KEY_IDS.has(keyId)) {
|
||||
// Build combo from current modifier state + dropped key
|
||||
const combo: KeyCombo = {
|
||||
key: keyId,
|
||||
...(kbModCtrl ? { ctrl: true } : {}),
|
||||
...(kbModShift ? { shift: true } : {}),
|
||||
...(kbModAlt ? { alt: true } : {}),
|
||||
}
|
||||
updateBinding(actionId, [combo])
|
||||
}
|
||||
}
|
||||
|
||||
draggedActionRef.current = null
|
||||
setDraggedActionId(null)
|
||||
setDropTargetKey(null)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
window.addEventListener('mouseup', handleMouseUp)
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
window.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [draggedActionId, kbModCtrl, kbModShift, kbModAlt, updateBinding])
|
||||
|
||||
// Click on a key to select the action assigned to it
|
||||
const handleKeyClick = useCallback((keyId: string) => {
|
||||
if (MODIFIER_KEY_IDS.has(keyId)) return
|
||||
// Don't treat mouseup from a drag as a click
|
||||
if (draggedActionRef.current) return
|
||||
const assigned = keyToAction.get(keyId)
|
||||
if (assigned) {
|
||||
setSelectedCategory(null)
|
||||
setSearchQuery(assigned.action.label)
|
||||
}
|
||||
}, [keyToAction])
|
||||
|
||||
if (!isEditorOpen) return null
|
||||
|
||||
// Filter and group actions
|
||||
const categories = ['Tools', 'Transport', 'Editing', 'Marking', 'Timeline'] as const
|
||||
const filteredActions = ACTION_REGISTRY.filter(a => {
|
||||
if (selectedCategory && a.category !== selectedCategory) return false
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase()
|
||||
const combos = activeLayout[a.id]
|
||||
const comboStr = combos?.map(formatKeyCombo).join(' ') || ''
|
||||
return a.label.toLowerCase().includes(q) || a.category.toLowerCase().includes(q) || comboStr.toLowerCase().includes(q)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const groupedActions: Record<string, ActionDefinition[]> = {}
|
||||
for (const a of filteredActions) {
|
||||
if (!groupedActions[a.category]) groupedActions[a.category] = []
|
||||
groupedActions[a.category].push(a)
|
||||
}
|
||||
|
||||
// Unit size for keyboard keys
|
||||
const KEY_UNIT = 42 // px per 1 unit of key width
|
||||
const KEY_GAP = 2 // px gap between keys
|
||||
const KEY_H = 36 // px key height
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget && !recordingAction) setEditorOpen(false) }}
|
||||
>
|
||||
<div className="w-[880px] max-h-[90vh] bg-zinc-900 rounded-xl border border-zinc-700/80 shadow-2xl flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-b border-zinc-800 bg-zinc-900/95">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-600/20 flex items-center justify-center">
|
||||
<Keyboard className="h-4 w-4 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-sm font-semibold text-white">Keyboard Shortcuts</h2>
|
||||
<p className="text-[11px] text-zinc-500 mt-0.5">Customize keybindings — drag actions onto keys or click Edit</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowKeyboard(!showKeyboard)}
|
||||
className={`px-2.5 py-1 text-[10px] font-medium rounded-md transition-colors ${
|
||||
showKeyboard ? 'bg-blue-600/20 text-blue-300' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{showKeyboard ? 'Hide Keyboard' : 'Show Keyboard'}
|
||||
</button>
|
||||
<button onClick={() => setEditorOpen(false)} className="text-zinc-500 hover:text-white transition-colors p-1">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar: Preset selector + Search + Actions */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-zinc-800/80 bg-zinc-950/50">
|
||||
{/* Preset dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowPresetDropdown(!showPresetDropdown)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 rounded-md border border-zinc-700/60 text-[11px] text-zinc-300 transition-colors"
|
||||
>
|
||||
<span className="max-w-[140px] truncate">{activePreset?.name || 'Custom'}</span>
|
||||
<ChevronDown className="h-3 w-3 text-zinc-500" />
|
||||
</button>
|
||||
{showPresetDropdown && (
|
||||
<div className="absolute top-full left-0 mt-1 w-64 bg-zinc-800 rounded-lg border border-zinc-700 shadow-xl z-50 overflow-hidden">
|
||||
{presets.map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`flex items-center gap-1 hover:bg-zinc-700 transition-colors ${
|
||||
p.id === activePresetId ? 'bg-blue-600/15' : ''
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
switchPreset(p.id)
|
||||
setShowPresetDropdown(false)
|
||||
}}
|
||||
className={`flex-1 text-left px-3 py-2 text-[11px] ${
|
||||
p.id === activePresetId ? 'text-blue-300' : 'text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{p.name}</div>
|
||||
<div className="text-[10px] text-zinc-500 mt-0.5">{p.description}</div>
|
||||
</button>
|
||||
{/* Delete button — only for user-created (non-built-in) presets */}
|
||||
{!p.builtIn && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(`Delete preset "${p.name}"?`)) {
|
||||
deleteCustomPreset(p.id)
|
||||
if (presets.filter(pr => pr.id !== p.id).length > 0) {
|
||||
setShowPresetDropdown(true)
|
||||
} else {
|
||||
setShowPresetDropdown(false)
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="p-1.5 mr-1.5 rounded text-zinc-600 hover:text-red-400 hover:bg-red-600/10 transition-colors"
|
||||
title={`Delete "${p.name}"`}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3 w-3 text-zinc-600" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search actions or keys..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 bg-zinc-800 rounded-md text-[11px] text-white placeholder-zinc-600 outline-none border border-zinc-700/40 focus:border-blue-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reset button */}
|
||||
<button
|
||||
onClick={() => resetToPreset(activePresetId)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-md transition-colors"
|
||||
title="Reset all shortcuts to the selected preset"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reset
|
||||
</button>
|
||||
|
||||
{/* Save as custom */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowSaveDialog(!showSaveDialog)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-md transition-colors"
|
||||
title="Save current layout as a custom preset"
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
Save As
|
||||
</button>
|
||||
{showSaveDialog && (
|
||||
<div className="absolute top-full right-0 mt-1 w-52 bg-zinc-800 rounded-lg border border-zinc-700 shadow-xl z-50 p-3">
|
||||
<p className="text-[10px] text-zinc-400 mb-2">Save as custom preset:</p>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Preset name..."
|
||||
value={savePresetName}
|
||||
onChange={(e) => setSavePresetName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && savePresetName.trim()) {
|
||||
saveAsCustomPreset(savePresetName.trim())
|
||||
setSavePresetName('')
|
||||
setShowSaveDialog(false)
|
||||
}
|
||||
}}
|
||||
className="w-full px-2 py-1.5 bg-zinc-900 rounded text-[11px] text-white placeholder-zinc-600 outline-none border border-zinc-700 focus:border-blue-500 mb-2"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (savePresetName.trim()) {
|
||||
saveAsCustomPreset(savePresetName.trim())
|
||||
setSavePresetName('')
|
||||
setShowSaveDialog(false)
|
||||
}
|
||||
}}
|
||||
disabled={!savePresetName.trim()}
|
||||
className="w-full py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-zinc-700 disabled:text-zinc-500 text-white text-[11px] font-medium rounded transition-colors"
|
||||
>
|
||||
Save Preset
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conflict warning */}
|
||||
{conflicts.size > 0 && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-amber-950/30 border-b border-amber-800/30">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-amber-400 flex-shrink-0" />
|
||||
<span className="text-[11px] text-amber-400/90">
|
||||
{conflicts.size} shortcut conflict{conflicts.size > 1 ? 's' : ''} detected — some keys are assigned to multiple actions
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══════════════ Visual Keyboard ══════════════ */}
|
||||
{showKeyboard && (
|
||||
<div className="border-b border-zinc-800/80 bg-zinc-950/60 px-4 py-3">
|
||||
{/* Modifier toggles */}
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<span className="text-[10px] text-zinc-500 font-medium mr-1">Modifiers:</span>
|
||||
{([
|
||||
{ label: 'Ctrl', active: kbModCtrl, toggle: () => setKbModCtrl(v => !v) },
|
||||
{ label: 'Shift', active: kbModShift, toggle: () => setKbModShift(v => !v) },
|
||||
{ label: 'Alt', active: kbModAlt, toggle: () => setKbModAlt(v => !v) },
|
||||
] as const).map(mod => (
|
||||
<button
|
||||
key={mod.label}
|
||||
onClick={mod.toggle}
|
||||
className={`px-3 py-1 rounded text-[10px] font-bold transition-all ${
|
||||
mod.active
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/20'
|
||||
: 'bg-zinc-800 text-zinc-500 hover:bg-zinc-700 hover:text-zinc-300 border border-zinc-700/50'
|
||||
}`}
|
||||
>
|
||||
{mod.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
{/* Category legend */}
|
||||
<div className="flex items-center gap-3">
|
||||
{categories.map(cat => {
|
||||
const c = CATEGORY_COLORS[cat]
|
||||
return (
|
||||
<div key={cat} className="flex items-center gap-1">
|
||||
<div className={`w-2 h-2 rounded-sm ${c.dot}`} />
|
||||
<span className="text-[9px] text-zinc-500">{cat}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard rows */}
|
||||
<div className="flex flex-col items-center gap-[2px]">
|
||||
{KB_ROWS.map((row, rowIdx) => (
|
||||
<div key={rowIdx} className="flex gap-[2px]">
|
||||
{row.map((kbKey) => {
|
||||
const isModifier = MODIFIER_KEY_IDS.has(kbKey.id)
|
||||
const assigned = keyToAction.get(kbKey.id)
|
||||
const catColors = assigned ? CATEGORY_COLORS[assigned.action.category] : null
|
||||
const isHighlighted = hoveredKeys.has(kbKey.id)
|
||||
const isDropTarget = dropTargetKey === kbKey.id
|
||||
const isDragging = !!draggedActionId
|
||||
|
||||
// Check if this key has a conflict
|
||||
const comboStr = assigned ? formatKeyCombo({
|
||||
key: kbKey.id,
|
||||
...(kbModCtrl ? { ctrl: true } : {}),
|
||||
...(kbModShift ? { shift: true } : {}),
|
||||
...(kbModAlt ? { alt: true } : {}),
|
||||
}) : null
|
||||
const hasConflict = comboStr ? conflicts.has(comboStr) : false
|
||||
|
||||
return (
|
||||
<div
|
||||
key={kbKey.id}
|
||||
className={`
|
||||
relative flex flex-col items-center justify-center rounded-md
|
||||
border text-center select-none transition-all duration-100
|
||||
${isModifier
|
||||
? 'bg-zinc-800/60 border-zinc-700/40 cursor-default'
|
||||
: isDropTarget
|
||||
? 'bg-blue-600/40 border-blue-400 ring-1 ring-blue-400 scale-105'
|
||||
: isHighlighted
|
||||
? 'bg-blue-600/30 border-blue-500 ring-1 ring-blue-400/50'
|
||||
: hasConflict
|
||||
? 'bg-amber-900/30 border-amber-600/60'
|
||||
: assigned && catColors
|
||||
? `${catColors.bg} ${catColors.border}`
|
||||
: isDragging && !isModifier
|
||||
? 'bg-zinc-800/80 border-zinc-600/80 border-dashed hover:border-blue-500/60 hover:bg-zinc-700/50'
|
||||
: 'bg-zinc-850 border-zinc-700/50 hover:border-zinc-600'
|
||||
}
|
||||
${!isModifier ? 'cursor-pointer' : ''}
|
||||
`}
|
||||
style={{
|
||||
width: kbKey.w * KEY_UNIT + (kbKey.w - 1) * KEY_GAP,
|
||||
height: KEY_H,
|
||||
}}
|
||||
data-kb-key={kbKey.id}
|
||||
onClick={() => handleKeyClick(kbKey.id)}
|
||||
title={
|
||||
assigned
|
||||
? `${assigned.action.label} (${assigned.action.category})`
|
||||
: isModifier
|
||||
? kbKey.label
|
||||
: 'Unassigned — drag an action here'
|
||||
}
|
||||
>
|
||||
{/* Key label */}
|
||||
<span className={`text-[9px] font-medium leading-none ${
|
||||
isModifier ? 'text-zinc-600' :
|
||||
assigned && catColors ? catColors.text :
|
||||
isHighlighted ? 'text-blue-300' :
|
||||
'text-zinc-500'
|
||||
}`}>
|
||||
{kbKey.label}
|
||||
</span>
|
||||
{/* Assigned action label (truncated) */}
|
||||
{assigned && !isModifier && (
|
||||
<span className={`text-[7px] leading-tight mt-0.5 max-w-full px-0.5 truncate ${
|
||||
catColors ? catColors.text : 'text-zinc-400'
|
||||
}`} style={{ opacity: 0.8 }}>
|
||||
{assigned.action.label.replace(/ Tool$/, '').replace(/ \(.*\)$/, '')}
|
||||
</span>
|
||||
)}
|
||||
{/* Conflict indicator */}
|
||||
{hasConflict && (
|
||||
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-amber-400" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Drag hint */}
|
||||
{draggedActionId && (
|
||||
<div className="text-center mt-2 text-[10px] text-blue-400 animate-pulse">
|
||||
Drop on a key to assign — current modifiers: {
|
||||
[kbModCtrl && 'Ctrl', kbModShift && 'Shift', kbModAlt && 'Alt'].filter(Boolean).join('+') || 'None'
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category tabs */}
|
||||
<div className="flex items-center gap-1 px-4 py-2 border-b border-zinc-800/60 bg-zinc-950/30">
|
||||
<button
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
className={`px-2.5 py-1 rounded-md text-[10px] font-medium transition-colors ${
|
||||
!selectedCategory ? 'bg-blue-600/20 text-blue-300' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categories.map(cat => {
|
||||
const c = CATEGORY_COLORS[cat]
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat === selectedCategory ? null : cat)}
|
||||
className={`px-2.5 py-1 rounded-md text-[10px] font-medium transition-colors flex items-center gap-1.5 ${
|
||||
selectedCategory === cat ? `${c.bg} ${c.text}` : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
|
||||
{cat}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Actions list */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0" ref={recordRef}>
|
||||
{categories.map(cat => {
|
||||
const actions = groupedActions[cat]
|
||||
if (!actions || actions.length === 0) return null
|
||||
const catColor = CATEGORY_COLORS[cat]
|
||||
return (
|
||||
<div key={cat}>
|
||||
{/* Category header */}
|
||||
<div className="sticky top-0 z-10 px-4 py-1.5 bg-zinc-950/95 border-b border-zinc-800/50 flex items-center gap-2">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${catColor.dot}`} />
|
||||
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">{cat}</span>
|
||||
</div>
|
||||
{/* Action rows */}
|
||||
{actions.map(action => {
|
||||
const combos = activeLayout[action.id] || []
|
||||
const isRecording = recordingAction === action.id
|
||||
const hasConflict = combos.some(c => {
|
||||
const key = formatKeyCombo(c)
|
||||
return conflicts.has(key)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
onMouseDown={(e) => { if (!isRecording) handleActionMouseDown(e, action.id) }}
|
||||
onMouseEnter={() => setHoveredActionId(action.id)}
|
||||
onMouseLeave={() => setHoveredActionId(null)}
|
||||
className={`flex items-center gap-3 px-4 py-2 border-b border-zinc-800/30 transition-colors select-none ${
|
||||
isRecording ? 'bg-blue-950/30' :
|
||||
draggedActionId === action.id ? 'bg-blue-600/20 ring-1 ring-blue-500/50' :
|
||||
hoveredActionId === action.id ? 'bg-zinc-800/40' :
|
||||
'hover:bg-zinc-800/30'
|
||||
} ${!isRecording ? 'cursor-grab active:cursor-grabbing' : ''}`}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
{!isRecording && (
|
||||
<GripVertical className={`h-3 w-3 flex-shrink-0 ${
|
||||
draggedActionId === action.id ? 'text-blue-400 cursor-grabbing' : 'text-zinc-700 cursor-grab'
|
||||
}`} />
|
||||
)}
|
||||
|
||||
{/* Category dot */}
|
||||
<div className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${catColor.dot}`} />
|
||||
|
||||
{/* Action label */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={`text-[12px] ${hasConflict ? 'text-amber-300' : 'text-zinc-300'}`}>
|
||||
{action.label}
|
||||
</span>
|
||||
{action.description && (
|
||||
<span className="text-[10px] text-zinc-600 ml-2">{action.description}</span>
|
||||
)}
|
||||
{hasConflict && (
|
||||
<AlertTriangle className="inline-block h-3 w-3 text-amber-400 ml-1.5 -mt-0.5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current binding(s) */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isRecording ? (
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-blue-600/20 border border-blue-500/50 rounded-md animate-pulse">
|
||||
<span className="text-[11px] text-blue-300">Press a key...</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setRecordingAction(null) }}
|
||||
className="text-zinc-500 hover:text-white"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{combos.length === 0 ? (
|
||||
<span className="text-[11px] text-zinc-600 italic">Unassigned</span>
|
||||
) : (
|
||||
combos.map((combo, i) => {
|
||||
const comboKey = formatKeyCombo(combo)
|
||||
const conflicting = conflicts.has(comboKey)
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-[11px] font-mono font-medium ${
|
||||
conflicting
|
||||
? 'bg-amber-900/30 text-amber-300 border border-amber-700/50'
|
||||
: `${catColor.bg} ${catColor.text} border ${catColor.border}`
|
||||
}`}
|
||||
>
|
||||
{comboKey}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit / Clear buttons */}
|
||||
{!isRecording && (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<button
|
||||
onClick={() => setRecordingAction(action.id)}
|
||||
className="px-2 py-0.5 text-[10px] text-zinc-500 hover:text-blue-400 hover:bg-blue-600/10 rounded transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{combos.length > 0 && (
|
||||
<button
|
||||
onClick={() => updateBinding(action.id, [])}
|
||||
className="px-2 py-0.5 text-[10px] text-zinc-600 hover:text-red-400 hover:bg-red-600/10 rounded transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-zinc-800 bg-zinc-950/80">
|
||||
<span className="text-[10px] text-zinc-600">
|
||||
{ACTION_REGISTRY.length} actions · Drag actions onto keys or click "Edit" to record
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setEditorOpen(false)}
|
||||
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-[11px] font-medium rounded-md transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { pathToFileUrl } from '../lib/file-url'
|
||||
import { imagePathsFromDataTransfer } from '../lib/keyframe-drop'
|
||||
import type { KeyframeItem } from '../lib/multi-keyframe'
|
||||
|
||||
interface KeyframePreviewProps {
|
||||
keyframe: KeyframeItem | null
|
||||
aspectRatio: string
|
||||
onDropImages: (paths: string[]) => void
|
||||
}
|
||||
|
||||
export function KeyframePreview({
|
||||
keyframe,
|
||||
aspectRatio,
|
||||
onDropImages,
|
||||
}: KeyframePreviewProps) {
|
||||
const cssAspect = aspectRatio.includes(':')
|
||||
? aspectRatio.replace(':', ' / ')
|
||||
: aspectRatio
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-1 items-center justify-center"
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
const paths = imagePathsFromDataTransfer(event.dataTransfer)
|
||||
if (paths.length > 0) onDropImages(paths)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full max-w-full overflow-hidden rounded-xl border border-zinc-800 bg-zinc-950"
|
||||
style={{ aspectRatio: cssAspect }}
|
||||
>
|
||||
{keyframe ? (
|
||||
<img
|
||||
src={pathToFileUrl(keyframe.path)}
|
||||
alt="Keyframe at the playhead"
|
||||
draggable={false}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-zinc-500">
|
||||
Add keyframes to preview your sequence
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useRef } from 'react'
|
||||
import {
|
||||
formatKeyframeStrength,
|
||||
nudgeKeyframeStrength,
|
||||
strengthFromPointer,
|
||||
} from '../lib/keyframe-strength'
|
||||
|
||||
interface KeyframeStrengthRailProps {
|
||||
strength: number
|
||||
label: string
|
||||
onStrengthChange: (strength: number) => void
|
||||
}
|
||||
|
||||
export function KeyframeStrengthRail({
|
||||
strength,
|
||||
label,
|
||||
onStrengthChange,
|
||||
}: KeyframeStrengthRailProps) {
|
||||
const railRef = useRef<HTMLDivElement>(null)
|
||||
const draggingRef = useRef(false)
|
||||
const clamped = Math.min(1, Math.max(0, strength))
|
||||
const percent = formatKeyframeStrength(strength)
|
||||
|
||||
const applyFromClientY = (clientY: number) => {
|
||||
const rect = railRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
onStrengthChange(strengthFromPointer(clientY, rect))
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={railRef}
|
||||
role="slider"
|
||||
aria-orientation="vertical"
|
||||
tabIndex={0}
|
||||
data-keyframe-strength
|
||||
aria-label={`Strength for keyframe at ${label}`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(clamped * 100)}
|
||||
aria-valuetext={percent}
|
||||
title={`Strength ${percent}`}
|
||||
className="absolute left-0 top-0 z-20 h-11 w-3 cursor-ns-resize touch-none"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
draggingRef.current = true
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
applyFromClientY(event.clientY)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!draggingRef.current) return
|
||||
event.stopPropagation()
|
||||
applyFromClientY(event.clientY)
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
draggingRef.current = false
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
draggingRef.current = false
|
||||
}}
|
||||
onLostPointerCapture={() => {
|
||||
draggingRef.current = false
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onStrengthChange(nudgeKeyframeStrength(strength, event.key === 'ArrowUp' ? 1 : -1))
|
||||
}}
|
||||
>
|
||||
<div className="relative mx-auto h-full w-0.5 rounded-full bg-zinc-700">
|
||||
<div
|
||||
className="absolute bottom-0 w-full rounded-full bg-blue-400"
|
||||
style={{ height: `${clamped * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute left-1/2 h-1.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-blue-200 shadow"
|
||||
style={{ top: `${(1 - clamped) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { applyTimecode, nudgeKeyframe } from '../lib/keyframe-controls'
|
||||
import {
|
||||
formatKeyframeStrength,
|
||||
nudgeKeyframeStrength,
|
||||
} from '../lib/keyframe-strength'
|
||||
import {
|
||||
findNearestFreeFrameIndex,
|
||||
formatKeyframeTimecode,
|
||||
frameFromPointer,
|
||||
sameDraggedFrame,
|
||||
withDraggedFrame,
|
||||
type DraggedFrame,
|
||||
} from '../lib/keyframe-timeline'
|
||||
import type { KeyframeItem } from '../lib/multi-keyframe'
|
||||
import { pathToFileUrl } from '../lib/file-url'
|
||||
import { KeyframeStrengthRail } from './KeyframeStrengthRail'
|
||||
|
||||
interface KeyframeTimelineProps {
|
||||
keyframes: readonly KeyframeItem[]
|
||||
fps: number
|
||||
lastFrame: number
|
||||
playheadFrame: number
|
||||
onPlayheadChange: (frameIndex: number) => void
|
||||
onDragFrameChange?: (drag: DraggedFrame | null) => void
|
||||
onFrameChange: (id: string, frameIndex: number) => void
|
||||
onStrengthChange: (id: string, strength: number) => void
|
||||
onReplaceRequest: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
onImagesDrop: (dataTransfer: DataTransfer, replaceId: string | null) => void
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
id: string
|
||||
frameIndex: number
|
||||
pointerId: number
|
||||
}
|
||||
|
||||
export function KeyframeTimeline({
|
||||
keyframes,
|
||||
fps,
|
||||
lastFrame,
|
||||
playheadFrame,
|
||||
onPlayheadChange,
|
||||
onDragFrameChange,
|
||||
onFrameChange,
|
||||
onStrengthChange,
|
||||
onReplaceRequest,
|
||||
onDelete,
|
||||
onImagesDrop,
|
||||
}: KeyframeTimelineProps) {
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const dragRef = useRef<DragState | null>(null)
|
||||
const onDragFrameChangeRef = useRef(onDragFrameChange)
|
||||
onDragFrameChangeRef.current = onDragFrameChange
|
||||
const [drag, setDrag] = useState<DragState | null>(null)
|
||||
const [timecodeDraft, setTimecodeDraft] = useState<{
|
||||
id: string
|
||||
value: string
|
||||
} | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return () => onDragFrameChangeRef.current?.(null)
|
||||
}, [])
|
||||
|
||||
const updateDrag = (next: DragState | null) => {
|
||||
const prev = dragRef.current
|
||||
dragRef.current = next
|
||||
const overlay = next ? { id: next.id, frameIndex: next.frameIndex } : null
|
||||
const prevOverlay = prev ? { id: prev.id, frameIndex: prev.frameIndex } : null
|
||||
if (sameDraggedFrame(prevOverlay, overlay)) return
|
||||
setDrag(next)
|
||||
onDragFrameChangeRef.current?.(overlay)
|
||||
}
|
||||
|
||||
const displayed = withDraggedFrame(
|
||||
keyframes,
|
||||
drag ? { id: drag.id, frameIndex: drag.frameIndex } : null,
|
||||
)
|
||||
|
||||
const frameAtPointer = (clientX: number) => {
|
||||
const rect = trackRef.current?.getBoundingClientRect()
|
||||
return rect ? frameFromPointer(clientX, rect, lastFrame) : 0
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: React.PointerEvent<HTMLElement>) => {
|
||||
const activeDrag = dragRef.current
|
||||
if (activeDrag && activeDrag.pointerId === event.pointerId) {
|
||||
const frameIndex = frameAtPointer(event.clientX)
|
||||
updateDrag({ ...activeDrag, frameIndex })
|
||||
onPlayheadChange(frameIndex)
|
||||
return
|
||||
}
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
onPlayheadChange(frameAtPointer(event.clientX))
|
||||
}
|
||||
}
|
||||
|
||||
const finishDrag = (event: React.PointerEvent<HTMLElement>) => {
|
||||
const activeDrag = dragRef.current
|
||||
if (!activeDrag || activeDrag.pointerId !== event.pointerId) return
|
||||
const otherKeyframes = keyframes.filter((keyframe) => keyframe.id !== activeDrag.id)
|
||||
const frameIndex = findNearestFreeFrameIndex(otherKeyframes, frameAtPointer(event.clientX), lastFrame)
|
||||
if (frameIndex !== null) onFrameChange(activeDrag.id, frameIndex)
|
||||
updateDrag(null)
|
||||
}
|
||||
|
||||
const positionPercent = (frameIndex: number) =>
|
||||
lastFrame > 0 ? (frameIndex / lastFrame) * 100 : 0
|
||||
|
||||
const commitTimecode = (id: string, value: string) => {
|
||||
const frameIndex = applyTimecode(keyframes, id, value, fps, lastFrame)
|
||||
if (frameIndex !== null) onFrameChange(id, frameIndex)
|
||||
setTimecodeDraft(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="relative h-16 rounded-lg border border-zinc-700 bg-zinc-950/70"
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={finishDrag}
|
||||
onPointerCancel={() => updateDrag(null)}
|
||||
onPointerDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
onPlayheadChange(frameAtPointer(event.clientX))
|
||||
}}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
onImagesDrop(event.dataTransfer, null)
|
||||
}}
|
||||
>
|
||||
<div className="pointer-events-none absolute left-2 right-2 top-1/2 h-px bg-zinc-700" />
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-1 w-px bg-blue-400"
|
||||
style={{ left: `${positionPercent(playheadFrame)}%` }}
|
||||
/>
|
||||
|
||||
{displayed.map((keyframe) => {
|
||||
const markerFrame = keyframe.frameIndex
|
||||
const source = keyframes.find((item) => item.id === keyframe.id) ?? keyframe
|
||||
const timecode = formatKeyframeTimecode(markerFrame, fps)
|
||||
return (
|
||||
<div
|
||||
key={keyframe.id}
|
||||
className="group absolute top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 touch-none"
|
||||
style={{ left: `${positionPercent(markerFrame)}%` }}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onImagesDrop(event.dataTransfer, keyframe.id)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title="Drag to move keyframe"
|
||||
aria-label={`Keyframe at ${timecode}`}
|
||||
className="relative block h-11 w-11 cursor-ew-resize overflow-hidden rounded-md border-2 border-blue-500 bg-zinc-900 shadow-lg"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.currentTarget.focus({ preventScroll: true })
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
updateDrag({
|
||||
id: keyframe.id,
|
||||
frameIndex: source.frameIndex,
|
||||
pointerId: event.pointerId,
|
||||
})
|
||||
}}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={finishDrag}
|
||||
onPointerCancel={() => updateDrag(null)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onStrengthChange(
|
||||
keyframe.id,
|
||||
nudgeKeyframeStrength(source.strength, event.key === 'ArrowUp' ? 1 : -1),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const frameIndex = nudgeKeyframe(
|
||||
keyframes,
|
||||
keyframe.id,
|
||||
event.key === 'ArrowLeft' ? -1 : 1,
|
||||
lastFrame,
|
||||
)
|
||||
if (frameIndex !== null) onFrameChange(keyframe.id, frameIndex)
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={pathToFileUrl(source.path)}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-x-0 bottom-0 bg-zinc-950/70 py-px text-center text-[9px] font-medium text-blue-200">
|
||||
{formatKeyframeStrength(source.strength)}
|
||||
</span>
|
||||
</button>
|
||||
<KeyframeStrengthRail
|
||||
strength={source.strength}
|
||||
label={timecode}
|
||||
onStrengthChange={(strength) => onStrengthChange(keyframe.id, strength)}
|
||||
/>
|
||||
<div className="absolute -right-3 -top-2 z-20 flex gap-0.5 opacity-0 shadow group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
data-keyframe-replace
|
||||
title="Replace keyframe image"
|
||||
aria-label={`Replace keyframe at ${timecode}`}
|
||||
className="rounded-full bg-zinc-800 p-1 text-zinc-400 hover:text-blue-300"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onReplaceRequest(keyframe.id)
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-keyframe-delete
|
||||
title="Delete keyframe"
|
||||
aria-label={`Delete keyframe at ${timecode}`}
|
||||
className="rounded-full bg-zinc-800 p-1 text-zinc-400 hover:text-red-300"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onDelete(keyframe.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Timecode for keyframe at frame ${markerFrame}`}
|
||||
className="absolute left-1/2 top-full mt-0.5 h-4 w-[66px] -translate-x-1/2 rounded border border-transparent bg-zinc-950/90 px-1 text-center font-mono text-[9px] text-zinc-400 outline-none hover:border-zinc-700 focus:border-blue-500 focus:text-zinc-200"
|
||||
value={timecodeDraft?.id === keyframe.id
|
||||
? timecodeDraft.value
|
||||
: timecode}
|
||||
onFocus={(event) => {
|
||||
setTimecodeDraft({ id: keyframe.id, value: event.currentTarget.value })
|
||||
}}
|
||||
onChange={(event) => {
|
||||
setTimecodeDraft({ id: keyframe.id, value: event.currentTarget.value })
|
||||
}}
|
||||
onBlur={(event) => commitTimecode(keyframe.id, event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
if (event.key === 'Escape') {
|
||||
event.currentTarget.value = timecode
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between font-mono text-[9px] text-zinc-600">
|
||||
<span>0</span>
|
||||
<span>{lastFrame}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { X, FolderOpen, RefreshCw, ChevronDown, ChevronUp, Download, Search } from 'lucide-react'
|
||||
import { Button } from './ui/button'
|
||||
import { logger } from '../lib/logger'
|
||||
import { getBackendCredentials } from '../lib/backend'
|
||||
|
||||
interface LogViewerProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
// Wraps every case-insensitive occurrence of `query` in the line with a <mark>,
|
||||
// keeping all other text as-is -- marks matches instead of hiding non-matching
|
||||
// lines, so surrounding context stays visible while searching.
|
||||
function highlightMatches(line: string, query: string): React.ReactNode {
|
||||
if (!query) return line
|
||||
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const parts = line.split(new RegExp(`(${escaped})`, 'gi'))
|
||||
if (parts.length === 1) return line
|
||||
// String.split with a capturing group alternates [text, match, text, match, ...] --
|
||||
// odd indices are always the captured matches.
|
||||
return parts.map((part, i) =>
|
||||
i % 2 === 1
|
||||
? <mark key={i} className="bg-yellow-500/70 text-black rounded-sm px-0.5">{part}</mark>
|
||||
: <span key={i}>{part}</span>,
|
||||
)
|
||||
}
|
||||
|
||||
export function LogViewer({ isOpen, onClose, embedded = false }: LogViewerProps) {
|
||||
const [logs, setLogs] = useState<string[]>([])
|
||||
const [logPath, setLogPath] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [autoScroll, setAutoScroll] = useState(true)
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [tokenCopied, setTokenCopied] = useState(false)
|
||||
const logContainerRef = useRef<HTMLDivElement>(null)
|
||||
// Autoscroll doesn't make sense while browsing search matches -- remember
|
||||
// the user's prior preference so clearing the search restores it exactly.
|
||||
const savedAutoScrollRef = useRef<boolean | null>(null)
|
||||
|
||||
// Debounce the raw input so typing doesn't fire an IPC round-trip (a full
|
||||
// untruncated log read while searching) on every keystroke.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearchQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchInput])
|
||||
|
||||
const fetchLogs = async () => {
|
||||
if (!window.electronAPI?.getLogs) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await window.electronAPI.getLogs({ query: searchQuery || undefined })
|
||||
setLogs(result.lines || [])
|
||||
setLogPath(result.logPath || '')
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch logs: ${error}`)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
fetchLogs()
|
||||
// Live-tail only when NOT searching. A search reads the whole session file and
|
||||
// ships every line over IPC (then re-highlights each), so polling that every 2s is a
|
||||
// large CPU/IPC cost for no gain -- results are a point-in-time view. Fetch once per
|
||||
// query change instead (this effect re-runs on searchQuery), and skip the interval.
|
||||
if (searchQuery) return
|
||||
const interval = setInterval(fetchLogs, 2000)
|
||||
return () => clearInterval(interval)
|
||||
}, [isOpen, searchQuery])
|
||||
|
||||
const matchCount = searchQuery
|
||||
? logs.filter(l => l.toLowerCase().includes(searchQuery.toLowerCase())).length
|
||||
: 0
|
||||
|
||||
useEffect(() => {
|
||||
const isSearching = Boolean(searchQuery)
|
||||
if (isSearching && savedAutoScrollRef.current === null) {
|
||||
savedAutoScrollRef.current = autoScroll
|
||||
setAutoScroll(false)
|
||||
} else if (!isSearching && savedAutoScrollRef.current !== null) {
|
||||
setAutoScroll(savedAutoScrollRef.current)
|
||||
savedAutoScrollRef.current = null
|
||||
}
|
||||
// autoScroll intentionally excluded -- this only reacts to searchQuery
|
||||
// transitions, not to the manual toggle (which is disabled while searching anyway).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
|
||||
}
|
||||
}, [logs, autoScroll])
|
||||
|
||||
const handleOpenFolder = async () => {
|
||||
if (window.electronAPI?.openLogFolder) {
|
||||
await window.electronAPI.openLogFolder()
|
||||
}
|
||||
}
|
||||
|
||||
// Copy the backend Bearer token so local QA/perf scripts can authenticate
|
||||
// against the Electron-spawned backend (its token is random per launch).
|
||||
// Surfaced via a semi-hidden control in the footer (see below).
|
||||
const handleCopyToken = async () => {
|
||||
try {
|
||||
const { token } = await getBackendCredentials()
|
||||
if (!token) {
|
||||
logger.error('No backend token available to copy')
|
||||
return
|
||||
}
|
||||
await navigator.clipboard.writeText(token)
|
||||
setTokenCopied(true)
|
||||
setTimeout(() => setTokenCopied(false), 1200)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to copy backend token: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
const content = logs.join('\n')
|
||||
const blob = new Blob([content], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = logPath ? logPath.split(/[/\\]/).pop() || 'session.log' : 'session.log'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const panel = (
|
||||
<div className={`bg-zinc-900 rounded-lg border border-zinc-700 w-full ${embedded ? 'h-full' : 'max-w-5xl h-[80vh]'} flex flex-col`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-zinc-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-white">Logs</h2>
|
||||
<span className="text-xs text-zinc-500 font-mono truncate max-w-[300px]" title={logPath}>
|
||||
{logPath}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={logs.length === 0}
|
||||
className="text-zinc-400 hover:text-white"
|
||||
title="Download logs"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleOpenFolder}
|
||||
className="text-zinc-400 hover:text-white"
|
||||
title="Open log folder"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fetchLogs}
|
||||
disabled={isLoading}
|
||||
className="text-zinc-400 hover:text-white"
|
||||
title="Refresh logs"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setAutoScroll(!autoScroll)}
|
||||
disabled={Boolean(searchQuery)}
|
||||
className={`${autoScroll ? 'text-blue-400' : 'text-zinc-400'} hover:text-white disabled:opacity-40 disabled:cursor-not-allowed`}
|
||||
title={searchQuery ? 'Auto-scroll disabled while searching' : autoScroll ? 'Auto-scroll enabled' : 'Auto-scroll disabled'}
|
||||
>
|
||||
{autoScroll ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||
</Button>
|
||||
{!embedded && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="text-zinc-400 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-zinc-800">
|
||||
<Search className="h-3.5 w-3.5 text-zinc-500 flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search logs (case-insensitive)..."
|
||||
className="flex-1 bg-transparent text-xs text-zinc-200 placeholder:text-zinc-600 focus:outline-none"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<span className="text-xs text-zinc-500 flex-shrink-0">
|
||||
{matchCount} matching line{matchCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Log content */}
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="flex-1 overflow-auto p-4 font-mono text-xs bg-black"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-zinc-500 text-center py-8">
|
||||
No logs yet...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{logs.map((line, index) => {
|
||||
// Color code log levels
|
||||
let lineClass = 'text-zinc-300'
|
||||
if (line.includes(' - ERROR - ') || line.includes(' - CRITICAL - ')) {
|
||||
lineClass = 'text-red-400'
|
||||
} else if (line.includes(' - WARNING - ')) {
|
||||
lineClass = 'text-yellow-400'
|
||||
} else if (line.includes(' - INFO - ')) {
|
||||
lineClass = 'text-blue-300'
|
||||
} else if (line.includes(' - DEBUG - ')) {
|
||||
lineClass = 'text-zinc-500'
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index} className={`${lineClass} whitespace-pre-wrap break-all`}>
|
||||
{highlightMatches(line, searchQuery)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-3 border-t border-zinc-700 text-xs text-zinc-500">
|
||||
<span>{logs.length} lines {searchQuery ? '(full session)' : '(last 200)'}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Semi-hidden: copies the backend Bearer token for local QA/perf scripts
|
||||
(the Electron-spawned backend's token is random per launch). Muted until
|
||||
hover so it stays out of the way. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopyToken()}
|
||||
className="font-mono tracking-widest text-zinc-700 hover:text-zinc-300 transition-colors"
|
||||
title="Copy backend auth token"
|
||||
aria-label="Copy backend auth token"
|
||||
>
|
||||
{tokenCopied ? 'copied!' : '•••••'}
|
||||
</button>
|
||||
<span>Auto-refreshing every 2s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (embedded) {
|
||||
return panel
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
|
||||
{panel}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useId, useRef } from 'react'
|
||||
import { Info, ExternalLink } from 'lucide-react'
|
||||
import type { components } from '../generated/backend-openapi'
|
||||
|
||||
type InstructionSection = NonNullable<components['schemas']['LoraCatalogItem']['instructions']>[number]
|
||||
|
||||
const COMMUNITY_DISCLAIMER =
|
||||
'This LoRA was created by a community member, not LTX. LTX does not endorse or take responsibility for community LoRAs. LTX has no control over the LoRA and any claims of any kind should be directed to the creator of the LoRA.'
|
||||
|
||||
// Info icon → popover with a LoRA/IC-LoRA's instruction sections + a link to its HF page
|
||||
// (derived from repoId, no stored URL). Native popover API: top-layer (escapes the
|
||||
// modal's overflow clipping and stacks above the <dialog>), with ESC + click-outside
|
||||
// dismiss for free. The popover/popovertarget attrs aren't in @types/react 18.3's JSX
|
||||
// yet, so they're declared via cast objects below — but statically, so the element is
|
||||
// never rendered without `popover` (setting it post-mount let it flash inline in the row).
|
||||
export function LoraInfoPopover({
|
||||
sections,
|
||||
name,
|
||||
repoId,
|
||||
isCommunity = true,
|
||||
maxHeight = '70vh',
|
||||
}: {
|
||||
sections: InstructionSection[]
|
||||
name: string
|
||||
repoId?: string
|
||||
// Fail closed: missing affiliation → show disclaimer.
|
||||
isCommunity?: boolean
|
||||
// Caller-tunable cap so the popover scrolls instead of overflowing its container
|
||||
// (e.g. the library modal needs a lower value than it does elsewhere).
|
||||
maxHeight?: string
|
||||
}) {
|
||||
const id = useId()
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
const popRef = useRef<HTMLDivElement>(null)
|
||||
const showCommunityDisclaimer = isCommunity
|
||||
|
||||
if (sections.length === 0 && !repoId && !showCommunityDisclaimer) return null
|
||||
|
||||
// Native Popover API attrs missing from @types/react 18.3's JSX; cast so they render.
|
||||
const popoverAttrs = { popover: 'auto' } as unknown as React.HTMLAttributes<HTMLDivElement>
|
||||
const triggerAttrs = { popovertarget: id } as unknown as React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
|
||||
|
||||
// The popover is viewport-fixed in the top layer; anchor it above the trigger on open.
|
||||
const anchor = () => {
|
||||
const btn = btnRef.current, pop = popRef.current
|
||||
if (!btn || !pop) return
|
||||
const r = btn.getBoundingClientRect()
|
||||
pop.style.margin = '0'
|
||||
pop.style.right = 'auto'
|
||||
pop.style.top = 'auto'
|
||||
pop.style.left = `${Math.max(8, r.left)}px`
|
||||
pop.style.bottom = `${window.innerHeight - r.top + 6}px`
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
{...triggerAttrs}
|
||||
onClick={anchor}
|
||||
aria-label={`${name} instructions`}
|
||||
className="rounded p-0.5 text-zinc-400 transition-colors hover:text-white"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div
|
||||
ref={popRef}
|
||||
id={id}
|
||||
{...popoverAttrs}
|
||||
aria-label={`${name} instructions`}
|
||||
style={{ maxHeight }}
|
||||
className="w-72 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900 p-3 text-left text-white shadow-xl"
|
||||
>
|
||||
<div className="mb-1.5 text-xs font-semibold text-white">{name}</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{sections.map((s, i) => (
|
||||
<div key={i}>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">{s.title}</div>
|
||||
{Array.isArray(s.body) ? (
|
||||
<ul className="mt-0.5 list-disc pl-4 text-[11px] text-zinc-300">
|
||||
{s.body.map((b, j) => <li key={j}>{b}</li>)}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-0.5 text-[11px] text-zinc-300">{s.body}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showCommunityDisclaimer && (
|
||||
<p className="mt-2.5 border-t border-zinc-800 pt-2 text-[10px] leading-relaxed text-zinc-500">
|
||||
{COMMUNITY_DISCLAIMER}
|
||||
</p>
|
||||
)}
|
||||
{repoId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void window.electronAPI.openHuggingFaceRepo({ repoId }) }}
|
||||
className="mt-2.5 inline-flex items-center gap-1 text-[11px] font-medium text-indigo-400 transition-colors hover:text-indigo-300"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" /> View on HuggingFace
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { LibraryModal } from './library/LibraryModal'
|
||||
import { LibraryItemCard } from './library/LibraryItemCard'
|
||||
import { LoraInfoPopover } from './LoraInfoPopover'
|
||||
import { useHfAuth } from '../hooks/use-hf-auth'
|
||||
import type { LibraryEntry } from '../lib/lora-library'
|
||||
|
||||
export type LibraryKind = 'lora' | 'ic-lora'
|
||||
|
||||
interface LoraLibraryModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
kind: LibraryKind
|
||||
items: LibraryEntry[]
|
||||
selectedId: string | null
|
||||
// Which variant of selectedId is actually active — lets the card badge the exact
|
||||
// installed checkpoint as "Selected" rather than the whole catalog entry.
|
||||
selectedVariantId?: string | null
|
||||
downloadingKey: string | null
|
||||
progress: number
|
||||
downloadError: { key: string; message: string } | null
|
||||
// Transient message (e.g. "still syncing installed files") shown when onSelect returns false.
|
||||
syncError?: string | null
|
||||
onDownload: (id: string, variantId?: string) => void
|
||||
// Returns whether the entry was actually selected — the modal only closes on success.
|
||||
onSelect: (entry: LibraryEntry, variantId?: string) => boolean
|
||||
}
|
||||
|
||||
// Generic catalog library modal — works for both plain LoRAs and IC-LoRAs. The consumer
|
||||
// maps its API/on-disk types into LibraryEntry[]; LibraryModal/LibraryItemCard stay
|
||||
// domain-agnostic.
|
||||
export function LoraLibraryModal({
|
||||
open, onClose, kind, items, selectedId, selectedVariantId, downloadingKey, progress, downloadError,
|
||||
syncError, onDownload, onSelect,
|
||||
}: LoraLibraryModalProps) {
|
||||
// A catalog entry with no download variants has nothing to show a card for.
|
||||
const visibleItems = items.filter(e => (e.variants?.length ?? 0) > 0)
|
||||
// HF "Connect" CTA: shown only when a gated item is visible and we're not signed in.
|
||||
const anyGated = visibleItems.some(e => e.requiresHfLogin)
|
||||
const { hfAuthStatus } = useHfAuth(open && anyGated)
|
||||
const showConnectCta = anyGated && hfAuthStatus !== 'authenticated'
|
||||
const handleConnect = () => {
|
||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'apiKeys' } }))
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<LibraryModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={kind === 'ic-lora' ? 'IC-LoRAs' : 'LoRAs'}
|
||||
headerSlot={showConnectCta ? (
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-indigo-500"
|
||||
>
|
||||
Connect HuggingFace
|
||||
</button>
|
||||
) : undefined}
|
||||
>
|
||||
{syncError && (
|
||||
<div className="mb-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-300">
|
||||
{syncError}
|
||||
</div>
|
||||
)}
|
||||
{visibleItems.length === 0 ? (
|
||||
<div className="px-2 py-8 text-center text-xs text-zinc-500">
|
||||
{kind === 'ic-lora' ? 'No IC-LoRAs available.' : 'No LoRAs found. Downloaded and installed LoRAs will appear here.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3">
|
||||
{visibleItems.map(e => {
|
||||
const cardError = !downloadError ? null
|
||||
: (e.requiresHfLogin && hfAuthStatus !== 'authenticated')
|
||||
? { key: downloadError.key, message: 'Sign in to HuggingFace (top right), then retry.', gated: false }
|
||||
: { ...downloadError, gated: Boolean(e.requiresHfLogin) }
|
||||
return (
|
||||
<LibraryItemCard
|
||||
key={e.id}
|
||||
selected={selectedId === e.id}
|
||||
selectedVariantId={selectedId === e.id ? selectedVariantId : undefined}
|
||||
downloadingKey={downloadingKey}
|
||||
progress={progress}
|
||||
downloadError={cardError}
|
||||
item={{
|
||||
id: e.id,
|
||||
title: e.name,
|
||||
description: e.description || undefined,
|
||||
sizeBytes: e.sizeBytes,
|
||||
downloaded: e.downloaded,
|
||||
thumbnailUrl: e.thumbnailUrl,
|
||||
demoVideoUrl: e.demoVideoUrl,
|
||||
author: e.author,
|
||||
license: e.license,
|
||||
requiresHfLogin: e.requiresHfLogin,
|
||||
variants: e.variants?.map(v => ({
|
||||
id: v.id,
|
||||
label: v.label,
|
||||
sizeBytes: v.sizeBytes,
|
||||
downloaded: e.downloadedVariantIds?.includes(v.id),
|
||||
})),
|
||||
defaultVariantId: e.defaultVariantId,
|
||||
}}
|
||||
infoSlot={(
|
||||
<LoraInfoPopover
|
||||
sections={e.instructions ?? []}
|
||||
name={e.name}
|
||||
repoId={e.repoId}
|
||||
isCommunity={e.author?.affiliation !== 'ltx'}
|
||||
maxHeight="45vh"
|
||||
/>
|
||||
)}
|
||||
onDownload={onDownload}
|
||||
onRetry={onDownload}
|
||||
onRequestAccess={() => { if (e.repoId) void window.electronAPI.openHuggingFaceRepo({ repoId: e.repoId }) }}
|
||||
onUse={(_id, variantId) => { if (onSelect(e, variantId)) onClose() }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</LibraryModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { forwardRef } from 'react'
|
||||
import { ExternalLink, KeyRound } from 'lucide-react'
|
||||
|
||||
interface LtxApiKeyInputProps {
|
||||
value: string
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
placeholder?: string
|
||||
id?: string
|
||||
stopPropagation?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const LtxApiKeyInput = forwardRef<HTMLInputElement, LtxApiKeyInputProps>(
|
||||
({ value, onChange, placeholder = 'Paste your API key', id, stopPropagation, className }, ref) => {
|
||||
return (
|
||||
<div className={`relative ${className ?? ''}`}>
|
||||
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-500" />
|
||||
<input
|
||||
ref={ref}
|
||||
id={id}
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onClick={stopPropagation ? (e) => e.stopPropagation() : undefined}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
className="w-full rounded-lg border border-zinc-700 bg-zinc-900 py-2.5 pl-9 pr-3 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
LtxApiKeyInput.displayName = 'LtxApiKeyInput'
|
||||
|
||||
interface ApiKeyHelperRowProps {
|
||||
stopPropagation?: boolean
|
||||
label?: string
|
||||
onOpenKey?: () => void
|
||||
}
|
||||
|
||||
export function ApiKeyHelperRow({ stopPropagation, label = 'Get API key', onOpenKey }: ApiKeyHelperRowProps) {
|
||||
return (
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-zinc-500">Your key stays in your local app settings.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
if (stopPropagation) e.stopPropagation()
|
||||
onOpenKey?.()
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{label}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LtxApiKeyHelperRowProps {
|
||||
stopPropagation?: boolean
|
||||
}
|
||||
|
||||
export function LtxApiKeyHelperRow({ stopPropagation }: LtxApiKeyHelperRowProps) {
|
||||
return (
|
||||
<ApiKeyHelperRow
|
||||
stopPropagation={stopPropagation}
|
||||
label="Get API key"
|
||||
onOpenKey={() => window.electronAPI.openLtxApiKeyPage()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
interface LtxLogoProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LtxLogo({ className = "h-6" }: LtxLogoProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 75 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
>
|
||||
<path d="M0 30.0087V7.50057C0 7.09765 0.154254 6.69973 0.460162 6.43869C0.708822 6.22729 0.987356 6.12029 1.29316 6.12029H8.2339C8.63671 6.12029 9.03463 6.26205 9.31316 6.55308C9.53944 6.79174 9.65133 7.07777 9.65133 7.41345V23.198C9.65133 23.6108 9.98462 23.944 10.3974 23.944H21.4638C21.8666 23.944 22.267 24.0858 22.5431 24.3767C22.7668 24.6155 22.8812 24.9015 22.8812 25.2372V30.5856C22.8812 31.0457 22.6823 31.4982 22.3018 31.7569C22.078 31.9086 21.8244 31.9832 21.5383 31.9832L1.99199 31.9956C0.890348 31.9981 0 31.1078 0 30.0087Z" fill="currentColor"/>
|
||||
<path d="M36.5888 31.9926C34.4062 31.9876 32.492 31.6543 30.8413 30.9878C29.1906 30.3214 27.9104 29.2346 26.9981 27.7227C26.0856 26.2132 25.6333 24.2137 25.6382 21.7269L25.6532 13.7194L21.7016 13.7119C21.3486 13.7119 21.0528 13.5876 20.8116 13.3365C20.5705 13.0853 20.4512 12.7819 20.4537 12.4164L20.4636 7.39299C20.4636 7.02744 20.5854 6.72154 20.8265 6.47288C21.0677 6.22422 21.3635 6.09983 21.7165 6.10233L25.6681 6.10983L25.6779 1.29066C25.6779 0.925114 25.7998 0.619208 26.041 0.370548C26.2821 0.121887 26.5779 0 26.9309 0L33.9065 0.0124913C34.2595 0.0124913 34.5554 0.136772 34.7965 0.387931C35.0376 0.639089 35.1569 0.944995 35.1545 1.30805L35.1445 6.12721L41.2078 6.13959C41.5608 6.13959 41.8566 6.26398 42.0977 6.51514C42.3389 6.76629 42.4582 7.0722 42.4557 7.43525L42.4458 12.4586C42.4458 12.8242 42.3239 13.1301 42.0829 13.3787C41.8417 13.6274 41.5434 13.7518 41.1928 13.7493L35.1296 13.7368L35.1171 20.8988C35.1171 21.8613 35.3061 22.6148 35.6914 23.1618C36.0767 23.7089 36.6833 23.985 37.5186 23.985L41.5608 23.9925C41.9138 23.9925 42.2096 24.1168 42.4507 24.368C42.6919 24.6192 42.8113 24.9251 42.8088 25.2881L42.7988 30.7093C42.7988 31.075 42.677 31.3808 42.4358 31.6294C42.1947 31.8782 41.8963 32.0025 41.5459 32L36.5913 31.9901L36.5888 31.9926Z" fill="currentColor"/>
|
||||
<path d="M47.5486 31.9851C47.2282 31.9851 46.965 31.8682 46.7589 31.6369C46.5503 31.4056 46.4485 31.1395 46.4485 30.841C46.4485 30.7416 46.4634 30.6248 46.4957 30.4929C46.5279 30.3611 46.5926 30.2268 46.6869 30.0951L54.3506 18.9342C54.4648 18.7675 54.4673 18.5463 54.3556 18.3771L47.4543 8.01457C47.3896 7.91517 47.335 7.79827 47.2854 7.6664C47.2382 7.53463 47.2133 7.40036 47.2133 7.26859C47.2133 6.97017 47.3251 6.70403 47.5486 6.47275C47.7722 6.24147 48.0279 6.12458 48.316 6.12458H55.6444C56.0914 6.12458 56.4267 6.23158 56.6501 6.44787C56.8737 6.66426 57.0327 6.85328 57.1295 7.01993L60.3082 11.8169C60.5043 12.1128 60.939 12.1128 61.1352 11.8169L64.3139 7.01993C64.4405 6.85328 64.6094 6.66426 64.8156 6.44787C65.0216 6.23158 65.3494 6.12458 65.7964 6.12458H72.7896C73.0778 6.12458 73.331 6.24147 73.557 6.47275C73.7805 6.70403 73.8922 6.95268 73.8922 7.21883C73.8922 7.38547 73.8748 7.53463 73.8451 7.6664C73.8128 7.79827 73.7482 7.91517 73.6539 8.01457L66.6159 18.3747C66.4992 18.5463 66.5017 18.77 66.6209 18.9392L74.4212 30.0975C74.5181 30.2293 74.5801 30.3636 74.6124 30.4954C74.6448 30.6273 74.6596 30.744 74.6596 30.8435C74.6596 31.142 74.5479 31.4081 74.3244 31.6394C74.1008 31.8707 73.8451 31.9874 73.557 31.9874H65.8934C65.4786 31.9874 65.1756 31.888 64.9844 31.689C64.7932 31.4901 64.6317 31.3086 64.5051 31.142L60.9886 25.9544C60.7924 25.671 60.3753 25.6685 60.1766 25.9471L56.4118 31.1395C56.3149 31.3061 56.1634 31.4876 55.9573 31.6865C55.7488 31.8855 55.4383 31.9851 55.0236 31.9851H47.5486Z" fill="currentColor"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { AlertCircle, Download, Loader2, Sparkles, X } from 'lucide-react'
|
||||
import { useHfAuth } from '../hooks/use-hf-auth'
|
||||
import { useHfModelAccess } from '../hooks/use-hf-model-access'
|
||||
import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../lib/api-client'
|
||||
import { logger } from '../lib/logger'
|
||||
import { HfModelAccessGate } from './HfModelAccessGate'
|
||||
import { Button } from './ui/button'
|
||||
import './LtxUpgradePrompt.css'
|
||||
|
||||
type UpgradeRecommendation = Extract<ApiSuccessOf<'getLtxRecommendation'>, { status: 'upgrade' }>
|
||||
type ModelCheckpointID = NonNullable<
|
||||
NonNullable<ApiRequestBodyOf<'checkModelAccess'>>['cp_ids']
|
||||
>[number]
|
||||
|
||||
interface LtxUpgradePromptProps {
|
||||
recommendation: UpgradeRecommendation
|
||||
onClose: () => void
|
||||
// Dismiss permanently for this model id (persisted), so the prompt doesn't return for it.
|
||||
onDontShowAgain: () => void
|
||||
onComplete: () => Promise<void> | void
|
||||
}
|
||||
|
||||
type UpgradePhase = 'idle' | 'starting' | 'downloading' | 'finishing'
|
||||
|
||||
function formatCheckpointId(cpId: string): string {
|
||||
return cpId.replace(/-/g, ' ')
|
||||
}
|
||||
|
||||
export function LtxUpgradePrompt({
|
||||
recommendation,
|
||||
onClose,
|
||||
onDontShowAgain,
|
||||
onComplete,
|
||||
}: LtxUpgradePromptProps) {
|
||||
const [wantsUpgrade, setWantsUpgrade] = useState(false)
|
||||
const [phase, setPhase] = useState<UpgradePhase>('idle')
|
||||
const [downloadSessionId, setDownloadSessionId] = useState<string | null>(null)
|
||||
const [downloadProgress, setDownloadProgress] = useState<ApiSuccessOf<'getModelDownloadProgress'> | null>(
|
||||
null,
|
||||
)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
// Default off when deleting the old bundle would also wipe built-in Union Control IC-LoRA
|
||||
// (2.3 → 2.5). Otherwise default on to reclaim the tens of GB the old transformer uses.
|
||||
const [deleteOld, setDeleteOld] = useState(!recommendation.loses_built_in_control)
|
||||
|
||||
const cpsToDownload = useMemo(
|
||||
() => recommendation.cps_to_download as ModelCheckpointID[],
|
||||
[recommendation.cps_to_download],
|
||||
)
|
||||
const { hfAuthStatus, hfAuthPolling, startHuggingFaceLogin } = useHfAuth(true)
|
||||
const { accessMap, allAuthorized, checking: checkingAccess, checkError, recheckAccess } = useHfModelAccess(
|
||||
cpsToDownload,
|
||||
hfAuthStatus,
|
||||
)
|
||||
|
||||
const hasOldToDelete = recommendation.cps_to_delete.length > 0
|
||||
const canClose = phase === 'idle'
|
||||
const canStartUpgrade = wantsUpgrade && phase === 'idle' && allAuthorized && !checkingAccess
|
||||
|
||||
const runningProgress = downloadProgress?.status === 'downloading' ? downloadProgress : null
|
||||
const totalProgress = runningProgress?.total_progress ?? (phase === 'finishing' ? 100 : 0)
|
||||
const completedCount = runningProgress?.completed_files.length ?? 0
|
||||
const totalCount = runningProgress?.all_files.length ?? recommendation.cps_to_download.length
|
||||
|
||||
useEffect(() => {
|
||||
if (!canClose) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [canClose, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== 'downloading' || !downloadSessionId) return
|
||||
|
||||
let cancelled = false
|
||||
const pollProgress = async () => {
|
||||
const progressResult = await ApiClient.getModelDownloadProgress({ sessionId: downloadSessionId })
|
||||
if (!progressResult.ok) {
|
||||
logger.warn(`Failed polling LTX upgrade progress: ${progressResult.error.message}`)
|
||||
return
|
||||
}
|
||||
if (cancelled) return
|
||||
|
||||
const progress = progressResult.data
|
||||
setDownloadProgress(progress)
|
||||
|
||||
if (progress.status === 'error') {
|
||||
setPhase('idle')
|
||||
setErrorMessage(progress.error || 'Upgrade download failed.')
|
||||
return
|
||||
}
|
||||
|
||||
if (progress.status === 'complete') {
|
||||
setPhase('finishing')
|
||||
if (deleteOld && recommendation.cps_to_delete.length > 0) {
|
||||
const deleteResult = await ApiClient.deleteModels({ cp_ids: recommendation.cps_to_delete })
|
||||
if (!deleteResult.ok) {
|
||||
logger.error(`Failed finalizing LTX upgrade: ${deleteResult.error.message}`)
|
||||
if (cancelled) return
|
||||
setPhase('idle')
|
||||
setErrorMessage(deleteResult.error.message)
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
await onComplete()
|
||||
if (cancelled) return
|
||||
// Reset to idle rather than waiting for the parent's refresh to flip away from 'upgrade'
|
||||
// — if the active model isn't flipped yet we'd otherwise be stuck on "Finishing up...".
|
||||
setPhase('idle')
|
||||
onClose()
|
||||
} catch (e) {
|
||||
logger.error(`Failed finalizing LTX upgrade: ${e}`)
|
||||
if (cancelled) return
|
||||
setPhase('idle')
|
||||
setErrorMessage(e instanceof Error ? e.message : 'Upgrade downloaded, but cleanup failed.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void pollProgress()
|
||||
const interval = setInterval(() => {
|
||||
void pollProgress()
|
||||
}, 700)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [deleteOld, downloadSessionId, onClose, onComplete, phase, recommendation.cps_to_delete])
|
||||
|
||||
const handleStartUpgrade = useCallback(async () => {
|
||||
if (!canStartUpgrade) return
|
||||
|
||||
setErrorMessage(null)
|
||||
setDownloadProgress(null)
|
||||
setPhase('starting')
|
||||
|
||||
const result = await ApiClient.startModelDownload({
|
||||
type: 'upgrade',
|
||||
cp_ids: recommendation.cps_to_download,
|
||||
})
|
||||
if (!result.ok) {
|
||||
logger.warn(`Failed to start LTX upgrade download: ${result.error.message}`)
|
||||
setPhase('idle')
|
||||
setErrorMessage(result.error.message)
|
||||
return
|
||||
}
|
||||
|
||||
const response = result.data
|
||||
if (response.status !== 'started') {
|
||||
setPhase('idle')
|
||||
setErrorMessage('Unexpected response while starting the upgrade.')
|
||||
return
|
||||
}
|
||||
setDownloadSessionId(response.sessionId)
|
||||
setPhase('downloading')
|
||||
}, [canStartUpgrade, recommendation.cps_to_download])
|
||||
|
||||
return (
|
||||
<div className="ltx-upgrade-backdrop fixed inset-0 z-[55] flex items-center justify-center bg-black/72 px-4 py-6 backdrop-blur-sm">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
onClick={() => {
|
||||
if (canClose) onClose()
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="ltx-upgrade-card relative w-full max-w-[640px] overflow-hidden rounded-[28px] border border-blue-500/20 bg-[#04070d] shadow-[0_24px_120px_rgba(0,0,0,0.72)]">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.16),_transparent_44%),linear-gradient(180deg,rgba(15,23,42,0.68),rgba(2,6,23,0.16))]" />
|
||||
<div className="relative px-6 pb-6 pt-6 sm:px-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="mt-0.5 flex h-12 w-12 items-center justify-center rounded-2xl border border-blue-400/20 bg-blue-500/12">
|
||||
<Sparkles className="h-5 w-5 text-blue-200" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="inline-flex items-center rounded-full border border-blue-400/20 bg-blue-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-blue-200/85">
|
||||
Optional Upgrade
|
||||
</div>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-white sm:text-[30px]">
|
||||
LTX Model Upgrade Detected!
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-blue-100/78">
|
||||
Upgrade target: <span className="font-medium text-blue-50">{recommendation.ltx_model_id}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canClose && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDontShowAgain}
|
||||
className="rounded-full px-3 py-1.5 text-xs text-blue-100/55 transition-colors hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
Don't show again
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full p-2 text-blue-100/55 transition-colors hover:bg-white/5 hover:text-white"
|
||||
aria-label="Close LTX upgrade prompt"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-[24px] border border-blue-400/16 bg-[linear-gradient(145deg,rgba(10,14,22,0.98),rgba(5,8,14,0.96))] p-5 sm:p-6">
|
||||
{recommendation.upgrade_message ? (
|
||||
<>
|
||||
<p className="text-lg font-semibold leading-snug text-blue-50 sm:text-[22px]">What's new in this version</p>
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{recommendation.upgrade_message.split('\n').map((line) => line.trim()).filter(Boolean).map((line, i) => (
|
||||
<li key={i} className="flex gap-2 text-sm leading-relaxed text-blue-100/82">
|
||||
<span className="mt-0.5 text-blue-300/70">•</span>
|
||||
<span>{line}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-lg font-semibold leading-snug text-blue-50 sm:text-[22px]">
|
||||
A better LTX checkpoint is ready for this install.
|
||||
</p>
|
||||
)}
|
||||
{hasOldToDelete && (
|
||||
<p className="mt-3 max-w-[44ch] text-sm leading-relaxed text-blue-100/72">
|
||||
{deleteOld
|
||||
? recommendation.loses_built_in_control
|
||||
? 'Your previous checkpoint and its built-in depth/canny/pose control models will be removed from disk.'
|
||||
: 'Your previous checkpoint will be removed from disk once the download completes.'
|
||||
: 'Your previous checkpoint will be kept — switch between versions anytime in Settings → Models.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<label className="mt-5 flex cursor-pointer items-center gap-3 rounded-2xl border border-blue-400/12 bg-black/35 px-4 py-3 text-sm text-blue-50/92 transition-colors hover:border-blue-300/22">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={wantsUpgrade}
|
||||
onChange={(event) => setWantsUpgrade(event.target.checked)}
|
||||
disabled={!canClose}
|
||||
className="h-4 w-4 rounded border-blue-300/40 bg-slate-950 text-blue-500 focus:ring-blue-400"
|
||||
/>
|
||||
<span className="font-medium">I want this!</span>
|
||||
</label>
|
||||
|
||||
{wantsUpgrade && hasOldToDelete && (
|
||||
<label className="mt-3 flex cursor-pointer items-center gap-3 rounded-2xl border border-blue-400/12 bg-black/35 px-4 py-3 text-sm text-blue-50/92 transition-colors hover:border-blue-300/22">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteOld}
|
||||
onChange={(event) => setDeleteOld(event.target.checked)}
|
||||
disabled={!canClose}
|
||||
className="h-4 w-4 rounded border-blue-300/40 bg-slate-950 text-blue-500 focus:ring-blue-400"
|
||||
/>
|
||||
<span className="font-medium">
|
||||
{recommendation.loses_built_in_control
|
||||
? 'Delete the previous checkpoint (also removes built-in control models)'
|
||||
: 'Delete the previous checkpoint to free up disk space'}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{wantsUpgrade && (
|
||||
<div className="mt-5 space-y-4">
|
||||
{!allAuthorized && (
|
||||
<div className="ltx-upgrade-reveal rounded-2xl border border-amber-400/16 bg-[#060b14] p-5">
|
||||
<HfModelAccessGate
|
||||
accessMap={accessMap}
|
||||
allAuthorized={allAuthorized}
|
||||
hfAuthStatus={hfAuthStatus}
|
||||
hfAuthPolling={hfAuthPolling}
|
||||
startHuggingFaceLogin={() => {
|
||||
void startHuggingFaceLogin()
|
||||
}}
|
||||
checkError={checkError}
|
||||
onRetryCheck={recheckAccess}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{canStartUpgrade && (
|
||||
<div className="ltx-upgrade-reveal rounded-2xl border border-blue-400/12 bg-[#060b14] p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">Ready when you are</p>
|
||||
<p className="mt-1 text-sm text-blue-100/72">
|
||||
This will download {recommendation.cps_to_download.length} checkpoint
|
||||
{recommendation.cps_to_download.length === 1 ? '' : 's'}
|
||||
{hasOldToDelete && deleteOld ? ' and remove the old one.' : '.'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void handleStartUpgrade()
|
||||
}}
|
||||
className="bg-blue-600 text-white hover:bg-blue-500"
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Upgrade now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase !== 'idle' && (
|
||||
<div className="mt-5 rounded-2xl border border-blue-400/12 bg-[#060b14] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
{phase === 'starting' ? 'Preparing your upgrade...' : phase === 'downloading' ? 'Downloading update...' : 'Finishing up...'}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-blue-100/70">
|
||||
{phase === 'finishing'
|
||||
? (deleteOld && hasOldToDelete ? 'Cleaning up the previous checkpoint files.' : 'Finalizing the upgrade.')
|
||||
: 'Keep this window open while the new checkpoint is downloaded.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-blue-400/14 bg-blue-500/10 px-3 py-1 text-xs text-blue-100/82">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Working
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-blue-100/72">
|
||||
<span>
|
||||
{phase === 'starting'
|
||||
? 'Waiting for the download session to start'
|
||||
: `Checkpoint progress ${Math.min(completedCount + (runningProgress ? 1 : 0), totalCount)} / ${totalCount}`}
|
||||
</span>
|
||||
<span>{Math.round(totalProgress)}%</span>
|
||||
</div>
|
||||
|
||||
<div className="h-2 overflow-hidden rounded-full bg-slate-950/60">
|
||||
{phase === 'starting' ? (
|
||||
<div className="ltx-upgrade-indeterminate h-full bg-blue-500/55" />
|
||||
) : (
|
||||
<div
|
||||
className="h-full rounded-full bg-[linear-gradient(90deg,#60a5fa,#2563eb)] transition-all duration-300"
|
||||
style={{ width: `${Math.max(totalProgress, 4)}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{runningProgress?.current_downloading_file && (
|
||||
<div className="mt-3 space-y-1">
|
||||
<div className="flex items-center justify-between text-xs text-blue-100/70">
|
||||
<span className="truncate">Current file</span>
|
||||
<span>{Math.round(runningProgress.current_file_progress)}%</span>
|
||||
</div>
|
||||
<div className="truncate text-sm text-blue-50/85">
|
||||
{formatCheckpointId(runningProgress.current_downloading_file)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<div className="mt-5 flex items-start gap-3 rounded-2xl border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-100">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-300" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface MenuItem {
|
||||
id: string
|
||||
label: string
|
||||
shortcut?: string
|
||||
action?: () => void
|
||||
disabled?: boolean
|
||||
separator?: boolean // renders a divider line
|
||||
submenu?: MenuItem[]
|
||||
}
|
||||
|
||||
export interface MenuDefinition {
|
||||
id: string
|
||||
label: string
|
||||
items: MenuItem[]
|
||||
}
|
||||
|
||||
interface MenuBarProps {
|
||||
menus: MenuDefinition[]
|
||||
rightContent?: React.ReactNode
|
||||
}
|
||||
|
||||
// --- Component ---
|
||||
|
||||
export function MenuBar({ menus, rightContent }: MenuBarProps) {
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
|
||||
const [hoverMenuId, setHoverMenuId] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [highlightedResult, setHighlightedResult] = useState(0)
|
||||
const menuBarRef = useRef<HTMLDivElement>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// The active open menu (follow hover once a menu is open)
|
||||
const activeMenuId = openMenuId ? (hoverMenuId || openMenuId) : null
|
||||
|
||||
// Close menus on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuBarRef.current && !menuBarRef.current.contains(e.target as Node)) {
|
||||
setOpenMenuId(null)
|
||||
setHoverMenuId(null)
|
||||
}
|
||||
}
|
||||
if (openMenuId) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [openMenuId])
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpenMenuId(null)
|
||||
setHoverMenuId(null)
|
||||
setSearchQuery('')
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => document.removeEventListener('keydown', handleKey)
|
||||
}, [])
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase()
|
||||
if (!query) return []
|
||||
const results: { menuLabel: string; item: MenuItem }[] = []
|
||||
for (const menu of menus) {
|
||||
for (const item of menu.items) {
|
||||
if (item.separator) continue
|
||||
if (item.label.toLowerCase().includes(query)) {
|
||||
results.push({ menuLabel: menu.label, item })
|
||||
}
|
||||
if (item.submenu) {
|
||||
for (const sub of item.submenu) {
|
||||
if (sub.separator) continue
|
||||
if (sub.label.toLowerCase().includes(query)) {
|
||||
results.push({ menuLabel: `${menu.label} > ${item.label}`, item: sub })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}, [menus, searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightedResult(prev => {
|
||||
if (searchResults.length === 0) return 0
|
||||
return Math.min(prev, searchResults.length - 1)
|
||||
})
|
||||
}, [searchResults.length])
|
||||
|
||||
const handleItemClick = (item: MenuItem) => {
|
||||
if (item.disabled || !item.action) return
|
||||
item.action()
|
||||
setOpenMenuId(null)
|
||||
setHoverMenuId(null)
|
||||
}
|
||||
|
||||
const handleSearchResultClick = (item: MenuItem) => {
|
||||
if (item.disabled || !item.action) return
|
||||
item.action()
|
||||
setSearchQuery('')
|
||||
setOpenMenuId(null)
|
||||
setHoverMenuId(null)
|
||||
}
|
||||
|
||||
// Handle search keyboard navigation
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setHighlightedResult(prev => Math.min(prev + 1, searchResults.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setHighlightedResult(prev => Math.max(prev - 1, 0))
|
||||
} else if (e.key === 'Enter' && searchResults[highlightedResult]) {
|
||||
e.preventDefault()
|
||||
handleSearchResultClick(searchResults[highlightedResult].item)
|
||||
}
|
||||
}
|
||||
|
||||
const renderMenuItem = (item: MenuItem, index: number) => {
|
||||
if (item.separator) {
|
||||
return <div key={`sep-${index}`} className="h-px bg-zinc-700 my-1 mx-2" />
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleItemClick(item)}
|
||||
disabled={item.disabled}
|
||||
className={`w-full flex items-center justify-between px-3 py-1.5 text-left text-[13px] transition-colors ${
|
||||
item.disabled
|
||||
? 'text-zinc-600 cursor-not-allowed'
|
||||
: 'text-zinc-200 hover:bg-blue-600 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.shortcut && (
|
||||
<span className={`ml-8 text-[11px] ${item.disabled ? 'text-zinc-700' : 'text-zinc-500'}`}>
|
||||
{item.shortcut}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={menuBarRef} className="flex items-center bg-zinc-900 border-b border-zinc-800 select-none relative z-[60]">
|
||||
<div className="flex items-center flex-1">
|
||||
{menus.map(menu => {
|
||||
const isActive = activeMenuId === menu.id
|
||||
const isHelpMenu = menu.id === 'help'
|
||||
|
||||
return (
|
||||
<div key={menu.id} className="relative">
|
||||
<button
|
||||
onMouseDown={() => {
|
||||
if (openMenuId === menu.id) {
|
||||
setOpenMenuId(null)
|
||||
setHoverMenuId(null)
|
||||
} else {
|
||||
setOpenMenuId(menu.id)
|
||||
setHoverMenuId(null)
|
||||
if (isHelpMenu) {
|
||||
setTimeout(() => searchInputRef.current?.focus(), 50)
|
||||
}
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (openMenuId) setHoverMenuId(menu.id)
|
||||
}}
|
||||
className={`px-3 py-1.5 text-[13px] font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-zinc-800 text-white'
|
||||
: 'text-zinc-400 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{menu.label}
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isActive && (
|
||||
<div className="absolute top-full left-0 min-w-[240px] bg-zinc-900 border border-zinc-700 rounded-b-lg shadow-xl shadow-black/50 py-1 z-[60]">
|
||||
{/* Help menu has search */}
|
||||
{isHelpMenu && (
|
||||
<div className="px-2 py-1.5 border-b border-zinc-700">
|
||||
<div className="flex items-center gap-2 bg-zinc-800 rounded px-2 py-1">
|
||||
<Search className="h-3.5 w-3.5 text-zinc-500 flex-shrink-0" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search menus..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="flex-1 bg-transparent text-[13px] text-white placeholder-zinc-500 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{/* Search results */}
|
||||
{searchQuery && (
|
||||
<div className="mt-1 max-h-48 overflow-y-auto">
|
||||
{searchResults.length === 0 ? (
|
||||
<div className="text-[12px] text-zinc-500 px-2 py-2 text-center">No results</div>
|
||||
) : (
|
||||
searchResults.map((result, i) => (
|
||||
<button
|
||||
key={`${result.item.id}-${i}`}
|
||||
onClick={() => handleSearchResultClick(result.item)}
|
||||
className={`w-full flex items-center justify-between px-2 py-1.5 text-left text-[12px] rounded transition-colors ${
|
||||
i === highlightedResult
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-zinc-300 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<span>{result.item.label}</span>
|
||||
<span className="text-[10px] text-zinc-500 ml-2">{result.menuLabel}</span>
|
||||
</div>
|
||||
{result.item.shortcut && (
|
||||
<span className="text-[10px] text-zinc-500">{result.item.shortcut}</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Regular menu items */}
|
||||
{menu.items.map((item, i) => renderMenuItem(item, i))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{rightContent && (
|
||||
<div className="flex items-center mr-2">
|
||||
{rightContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { ImagePlus, Plus } from 'lucide-react'
|
||||
import { imagePathsFromDataTransfer, imagePathsFromFiles } from '../lib/keyframe-drop'
|
||||
import { lastFrameFromDuration, type DraggedFrame } from '../lib/keyframe-timeline'
|
||||
import { applyKeyframeImagePaths, type KeyframeItem } from '../lib/multi-keyframe'
|
||||
import { KeyframeTimeline } from './KeyframeTimeline'
|
||||
|
||||
interface MultiKeyframePanelProps {
|
||||
keyframes: readonly KeyframeItem[]
|
||||
duration: number | null
|
||||
fps: number
|
||||
maxCount: number
|
||||
playheadFrame: number
|
||||
onPlayheadChange: (frameIndex: number) => void
|
||||
onChange: (keyframes: KeyframeItem[]) => void
|
||||
onDragFrameChange?: (drag: DraggedFrame | null) => void
|
||||
}
|
||||
|
||||
export function MultiKeyframePanel({
|
||||
keyframes,
|
||||
duration,
|
||||
fps,
|
||||
maxCount,
|
||||
playheadFrame,
|
||||
onPlayheadChange,
|
||||
onChange,
|
||||
onDragFrameChange,
|
||||
}: MultiKeyframePanelProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const replaceIdRef = useRef<string | null>(null)
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const lastFrame = duration == null ? null : lastFrameFromDuration(duration, fps)
|
||||
const addEnabled = lastFrame !== null && keyframes.length < maxCount
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (lastFrame === null) onDragFrameChange?.(null)
|
||||
}, [lastFrame, onDragFrameChange])
|
||||
|
||||
const applyPaths = (paths: string[], replaceId: string | null = null) => {
|
||||
onChange(applyKeyframeImagePaths({
|
||||
keyframes,
|
||||
paths,
|
||||
replaceId,
|
||||
lastFrame,
|
||||
preferredFrame: playheadFrame,
|
||||
maxCount,
|
||||
}))
|
||||
}
|
||||
|
||||
const openFilePicker = (replaceId: string | null) => {
|
||||
replaceIdRef.current = replaceId
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-zinc-800/60 px-2 pb-2 pt-2">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium text-zinc-300">
|
||||
<ImagePlus className="h-3.5 w-3.5 text-zinc-500" />
|
||||
<span>Keyframes</span>
|
||||
<span className="text-zinc-600">{keyframes.length}/{maxCount}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!addEnabled}
|
||||
title={keyframes.length >= maxCount
|
||||
? `You can place up to ${maxCount} keyframes`
|
||||
: lastFrame === null
|
||||
? 'Choose a duration to place keyframes'
|
||||
: 'Add keyframes at the playhead'}
|
||||
className="flex h-6 items-center gap-1 rounded-md border border-dashed border-zinc-700 px-2 text-[10px] text-zinc-400 hover:border-zinc-500 hover:text-zinc-200 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={() => openFilePicker(null)}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault()
|
||||
if (addEnabled) setIsDragOver(true)
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
setIsDragOver(false)
|
||||
applyPaths(imagePathsFromDataTransfer(event.dataTransfer))
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
<span className={isDragOver ? 'text-blue-300' : undefined}>Add keyframe</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{lastFrame === null ? (
|
||||
<div className="flex h-12 items-center justify-center rounded-lg border border-dashed border-zinc-700 text-[10px] text-zinc-500">
|
||||
Choose a duration to place keyframes
|
||||
</div>
|
||||
) : (
|
||||
<KeyframeTimeline
|
||||
keyframes={keyframes}
|
||||
fps={fps}
|
||||
lastFrame={lastFrame}
|
||||
playheadFrame={Math.min(playheadFrame, lastFrame)}
|
||||
onPlayheadChange={onPlayheadChange}
|
||||
onDragFrameChange={onDragFrameChange}
|
||||
onFrameChange={(id, frameIndex) => {
|
||||
onChange(keyframes.map((keyframe) => (
|
||||
keyframe.id === id ? { ...keyframe, frameIndex } : keyframe
|
||||
)))
|
||||
onPlayheadChange(frameIndex)
|
||||
}}
|
||||
onStrengthChange={(id, strength) => {
|
||||
onChange(keyframes.map((keyframe) => (
|
||||
keyframe.id === id ? { ...keyframe, strength } : keyframe
|
||||
)))
|
||||
}}
|
||||
onReplaceRequest={(id) => openFilePicker(id)}
|
||||
onDelete={(id) => onChange(keyframes.filter((keyframe) => keyframe.id !== id))}
|
||||
onImagesDrop={(dataTransfer, replaceId) => {
|
||||
applyPaths(imagePathsFromDataTransfer(dataTransfer), replaceId)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
applyPaths(imagePathsFromFiles(event.target.files ?? []), replaceIdRef.current)
|
||||
replaceIdRef.current = null
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
export interface OutpaintPads {
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
bottom: number
|
||||
}
|
||||
|
||||
interface OutpaintCanvasEditorProps {
|
||||
// Source video dimensions in pixels (the silhouette, fixed — never resized).
|
||||
sourceWidth: number
|
||||
sourceHeight: number
|
||||
value: OutpaintPads
|
||||
onChange: (pads: OutpaintPads) => void
|
||||
}
|
||||
|
||||
// Square display box for the editor. The source sits centred; the output frame grows around it.
|
||||
const STAGE = 260
|
||||
// Aspect-ratio quick-snaps: expand the deficient axis to hit the ratio, centred. null = reset.
|
||||
const PRESETS: { id: string; label: string; ratio: number | null }[] = [
|
||||
{ id: 'reset', label: 'Reset', ratio: null },
|
||||
{ id: '16:9', label: '16:9', ratio: 16 / 9 },
|
||||
{ id: '1:1', label: '1:1', ratio: 1 },
|
||||
{ id: '9:16', label: '9:16', ratio: 9 / 16 },
|
||||
{ id: '21:9', label: '21:9', ratio: 21 / 9 },
|
||||
]
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
|
||||
const toEven = (v: number) => v + (v % 2)
|
||||
|
||||
// Handles: each names the edge(s) it moves ('l'/'r'/'t'/'b'). Corners move two.
|
||||
const HANDLES = ['tl', 't', 'tr', 'l', 'r', 'bl', 'b', 'br'] as const
|
||||
type Handle = (typeof HANDLES)[number]
|
||||
|
||||
export function OutpaintCanvasEditor({ sourceWidth, sourceHeight, value, onChange }: OutpaintCanvasEditorProps) {
|
||||
// Cap = +100% per axis, so the max canvas is 3× the source on each side. Fit that into the stage.
|
||||
const scale = Math.min(STAGE / (3 * sourceWidth), STAGE / (3 * sourceHeight))
|
||||
const stageW = 3 * sourceWidth * scale
|
||||
const stageH = 3 * sourceHeight * scale
|
||||
|
||||
const srcLeft = stageW / 2 - (sourceWidth * scale) / 2
|
||||
const srcTop = stageH / 2 - (sourceHeight * scale) / 2
|
||||
const frameLeft = srcLeft - value.left * scale
|
||||
const frameTop = srcTop - value.top * scale
|
||||
const frameW = (sourceWidth + value.left + value.right) * scale
|
||||
const frameH = (sourceHeight + value.top + value.bottom) * scale
|
||||
|
||||
const outW = toEven(sourceWidth + value.left + value.right)
|
||||
const outH = toEven(sourceHeight + value.top + value.bottom)
|
||||
|
||||
const startDrag = useCallback(
|
||||
(e: React.PointerEvent, handle: Handle) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const startX = e.clientX
|
||||
const startY = e.clientY
|
||||
const start = { ...value }
|
||||
const move = (ev: PointerEvent) => {
|
||||
const dx = (ev.clientX - startX) / scale
|
||||
const dy = (ev.clientY - startY) / scale
|
||||
const next = { ...start }
|
||||
// Dragging the left/top edge outward (negative delta) grows that pad; clamp to [0, source axis].
|
||||
if (handle.includes('l')) next.left = clamp(Math.round(start.left - dx), 0, sourceWidth)
|
||||
if (handle.includes('r')) next.right = clamp(Math.round(start.right + dx), 0, sourceWidth)
|
||||
if (handle.includes('t')) next.top = clamp(Math.round(start.top - dy), 0, sourceHeight)
|
||||
if (handle.includes('b')) next.bottom = clamp(Math.round(start.bottom + dy), 0, sourceHeight)
|
||||
onChange(next)
|
||||
}
|
||||
const up = () => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
},
|
||||
[value, scale, sourceWidth, sourceHeight, onChange],
|
||||
)
|
||||
|
||||
const applyPreset = useCallback(
|
||||
(ratio: number | null) => {
|
||||
if (ratio === null) {
|
||||
onChange({ left: 0, right: 0, top: 0, bottom: 0 })
|
||||
return
|
||||
}
|
||||
const current = sourceWidth / sourceHeight
|
||||
if (current < ratio) {
|
||||
// Too tall for the ratio → widen. Cap each side at +100% (total extra ≤ 2× width).
|
||||
const extra = clamp(Math.round(sourceHeight * ratio) - sourceWidth, 0, 2 * sourceWidth)
|
||||
const half = Math.round(extra / 2)
|
||||
onChange({ left: half, right: extra - half, top: 0, bottom: 0 })
|
||||
} else {
|
||||
// Too wide → heighten.
|
||||
const extra = clamp(Math.round(sourceWidth / ratio) - sourceHeight, 0, 2 * sourceHeight)
|
||||
const half = Math.round(extra / 2)
|
||||
onChange({ left: 0, right: 0, top: half, bottom: extra - half })
|
||||
}
|
||||
},
|
||||
[sourceWidth, sourceHeight, onChange],
|
||||
)
|
||||
|
||||
// A ratio is reachable only if the +100%-per-axis cap can grow the deficient axis far enough.
|
||||
const isPresetReachable = (ratio: number | null): boolean => {
|
||||
if (ratio === null) return true
|
||||
const current = sourceWidth / sourceHeight
|
||||
return current < ratio
|
||||
? Math.round(sourceHeight * ratio) - sourceWidth <= 2 * sourceWidth
|
||||
: Math.round(sourceWidth / ratio) - sourceHeight <= 2 * sourceHeight
|
||||
}
|
||||
|
||||
// Handle display position (centre point) on the frame border, in stage coordinates.
|
||||
const handlePos = (h: Handle): { x: number; y: number } => {
|
||||
const midX = frameLeft + frameW / 2
|
||||
const midY = frameTop + frameH / 2
|
||||
const left = frameLeft
|
||||
const right = frameLeft + frameW
|
||||
const top = frameTop
|
||||
const bottom = frameTop + frameH
|
||||
switch (h) {
|
||||
case 'tl': return { x: left, y: top }
|
||||
case 't': return { x: midX, y: top }
|
||||
case 'tr': return { x: right, y: top }
|
||||
case 'l': return { x: left, y: midY }
|
||||
case 'r': return { x: right, y: midY }
|
||||
case 'bl': return { x: left, y: bottom }
|
||||
case 'b': return { x: midX, y: bottom }
|
||||
case 'br': return { x: right, y: bottom }
|
||||
}
|
||||
}
|
||||
const handleCursor: Record<Handle, string> = {
|
||||
tl: 'nwse-resize', t: 'ns-resize', tr: 'nesw-resize', l: 'ew-resize',
|
||||
r: 'ew-resize', bl: 'nesw-resize', b: 'ns-resize', br: 'nwse-resize',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 bg-black">
|
||||
<div className="flex-1 flex items-center justify-center min-h-0 p-3">
|
||||
<div className="relative" style={{ width: stageW, height: stageH }}>
|
||||
{/* Output frame: the green-filled generate region around the source. */}
|
||||
<div
|
||||
className="absolute border border-emerald-500/60 bg-emerald-500/10"
|
||||
style={{ left: frameLeft, top: frameTop, width: frameW, height: frameH }}
|
||||
/>
|
||||
{/* Source silhouette (kept region) — fixed size, centred. */}
|
||||
<div
|
||||
className="absolute bg-zinc-400/80 border border-zinc-300"
|
||||
style={{ left: srcLeft, top: srcTop, width: sourceWidth * scale, height: sourceHeight * scale }}
|
||||
/>
|
||||
{HANDLES.map((h) => {
|
||||
const { x, y } = handlePos(h)
|
||||
return (
|
||||
<div
|
||||
key={h}
|
||||
onPointerDown={(e) => startDrag(e, h)}
|
||||
className="absolute w-2.5 h-2.5 -ml-1.5 -mt-1.5 rounded-sm bg-white border border-zinc-500 shadow"
|
||||
style={{ left: x, top: y, cursor: handleCursor[h], touchAction: 'none' }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-2 border-t border-zinc-800 flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-1">
|
||||
{PRESETS.map((p) => {
|
||||
// Unreachable presets stay clickable but cap-to-closest (applyPreset clamps to the
|
||||
// +100% extend cap) — marked with a · and a tooltip so it's not a silent wrong ratio.
|
||||
const capped = !isPresetReachable(p.ratio)
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => applyPreset(p.ratio)}
|
||||
title={capped ? 'Limited by the +100% expand cap — lands at the closest ratio' : undefined}
|
||||
className="px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 border border-zinc-700 transition-colors"
|
||||
>
|
||||
{p.label}
|
||||
{capped && <span className="text-amber-500/80">·</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<span className="text-[10px] text-zinc-500 tabular-nums">
|
||||
{sourceWidth}×{sourceHeight} → {outW}×{outH}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
interface PythonSetupProps {
|
||||
onReady: () => void
|
||||
}
|
||||
|
||||
interface SetupProgress {
|
||||
status: 'downloading' | 'extracting' | 'complete' | 'error'
|
||||
percent: number
|
||||
downloadedBytes: number
|
||||
totalBytes: number
|
||||
speed: number
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
const formatTimeRemaining = (seconds: number): string => {
|
||||
if (!seconds || !isFinite(seconds) || seconds <= 0) return '--'
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`
|
||||
if (seconds < 3600) return `${Math.round(seconds / 60)}m`
|
||||
return `${Math.round(seconds / 3600)}h ${Math.round((seconds % 3600) / 60)}m`
|
||||
}
|
||||
|
||||
export function PythonSetup({ onReady }: PythonSetupProps) {
|
||||
const [progress, setProgress] = useState<SetupProgress | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [videoPath, setVideoPath] = useState('/splash/splash.mp4')
|
||||
const started = useRef(false)
|
||||
|
||||
const getTimeRemaining = (): string => {
|
||||
if (!progress || progress.speed <= 0) return '--'
|
||||
const remainingBytes = progress.totalBytes - progress.downloadedBytes
|
||||
if (remainingBytes <= 0) return '--'
|
||||
return formatTimeRemaining(remainingBytes / progress.speed)
|
||||
}
|
||||
|
||||
// Resolve video path for production
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
const resourcePath = await window.electronAPI.getResourcePath?.()
|
||||
if (resourcePath) {
|
||||
setVideoPath(`file://${resourcePath}/app.asar.unpacked/dist/splash/splash.mp4`)
|
||||
}
|
||||
} catch {
|
||||
// Dev mode: use relative path
|
||||
}
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
// Listen for progress events
|
||||
useEffect(() => {
|
||||
window.electronAPI.onPythonSetupProgress((data: unknown) => {
|
||||
const p = data as SetupProgress
|
||||
setProgress(p)
|
||||
if (p.status === 'complete') {
|
||||
onReady()
|
||||
} else if (p.status === 'error') {
|
||||
setError('Download failed. Please check your internet connection and try again.')
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
window.electronAPI.removePythonSetupProgress()
|
||||
}
|
||||
}, [onReady])
|
||||
|
||||
// Start download on mount
|
||||
useEffect(() => {
|
||||
if (started.current) return
|
||||
started.current = true
|
||||
startSetup()
|
||||
}, [])
|
||||
|
||||
const startSetup = async () => {
|
||||
setError(null)
|
||||
try {
|
||||
await window.electronAPI.startPythonSetup()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to download Python environment.')
|
||||
}
|
||||
}
|
||||
|
||||
const statusLabel = progress?.status === 'extracting' ? 'Extracting...' : 'Downloading Python environment...'
|
||||
const percent = progress?.percent ?? 0
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col" style={{
|
||||
background: '#000000',
|
||||
fontFamily: 'Arial, Helvetica, sans-serif',
|
||||
color: '#ffffff'
|
||||
}}>
|
||||
{/* Custom Title Bar */}
|
||||
<div style={{
|
||||
height: 32,
|
||||
background: '#000000',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 80,
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
// @ts-expect-error - Electron-specific CSS property
|
||||
WebkitAppRegion: 'drag'
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: '#a0a0a0' }}>LTX Desktop</span>
|
||||
</div>
|
||||
|
||||
{/* Main Container */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
// @ts-expect-error - Electron-specific CSS property
|
||||
WebkitAppRegion: 'no-drag'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '12px 32px',
|
||||
borderBottom: '1px solid #1a1a1a'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<svg style={{ height: 24, width: 'auto' }} viewBox="0 0 75 32" xmlns="http://www.w3.org/2000/svg" fill="none">
|
||||
<path d="M0 30.0087V7.50057C0 7.09765 0.154254 6.69973 0.460162 6.43869C0.708822 6.22729 0.987356 6.12029 1.29316 6.12029H8.2339C8.63671 6.12029 9.03463 6.26205 9.31316 6.55308C9.53944 6.79174 9.65133 7.07777 9.65133 7.41345V23.198C9.65133 23.6108 9.98462 23.944 10.3974 23.944H21.4638C21.8666 23.944 22.267 24.0858 22.5431 24.3767C22.7668 24.6155 22.8812 24.9015 22.8812 25.2372V30.5856C22.8812 31.0457 22.6823 31.4982 22.3018 31.7569C22.078 31.9086 21.8244 31.9832 21.5383 31.9832L1.99199 31.9956C0.890348 31.9981 0 31.1078 0 30.0087Z" fill="white"/>
|
||||
<path d="M36.5888 31.9926C34.4062 31.9876 32.492 31.6543 30.8413 30.9878C29.1906 30.3214 27.9104 29.2346 26.9981 27.7227C26.0856 26.2132 25.6333 24.2137 25.6382 21.7269L25.6532 13.7194L21.7016 13.7119C21.3486 13.7119 21.0528 13.5876 20.8116 13.3365C20.5705 13.0853 20.4512 12.7819 20.4537 12.4164L20.4636 7.39299C20.4636 7.02744 20.5854 6.72154 20.8265 6.47288C21.0677 6.22422 21.3635 6.09983 21.7165 6.10233L25.6681 6.10983L25.6779 1.29066C25.6779 0.925114 25.7998 0.619208 26.041 0.370548C26.2821 0.121887 26.5779 0 26.9309 0L33.9065 0.0124913C34.2595 0.0124913 34.5554 0.136772 34.7965 0.387931C35.0376 0.639089 35.1569 0.944995 35.1545 1.30805L35.1445 6.12721L41.2078 6.13959C41.5608 6.13959 41.8566 6.26398 42.0977 6.51514C42.3389 6.76629 42.4582 7.0722 42.4557 7.43525L42.4458 12.4586C42.4458 12.8242 42.3239 13.1301 42.0829 13.3787C41.8417 13.6274 41.5434 13.7518 41.1928 13.7493L35.1296 13.7368L35.1171 20.8988C35.1171 21.8613 35.3061 22.6148 35.6914 23.1618C36.0767 23.7089 36.6833 23.985 37.5186 23.985L41.5608 23.9925C41.9138 23.9925 42.2096 24.1168 42.4507 24.368C42.6919 24.6192 42.8113 24.9251 42.8088 25.2881L42.7988 30.7093C42.7988 31.075 42.677 31.3808 42.4358 31.6294C42.1947 31.8782 41.8963 32.0025 41.5459 32L36.5913 31.9901L36.5888 31.9926Z" fill="white"/>
|
||||
<path d="M47.5486 31.9851C47.2282 31.9851 46.965 31.8682 46.7589 31.6369C46.5503 31.4056 46.4485 31.1395 46.4485 30.841C46.4485 30.7416 46.4634 30.6248 46.4957 30.4929C46.5279 30.3611 46.5926 30.2268 46.6869 30.0951L54.3506 18.9342C54.4648 18.7675 54.4673 18.5463 54.3556 18.3771L47.4543 8.01457C47.3896 7.91517 47.335 7.79827 47.2854 7.6664C47.2382 7.53463 47.2133 7.40036 47.2133 7.26859C47.2133 6.97017 47.3251 6.70403 47.5486 6.47275C47.7722 6.24147 48.0279 6.12458 48.316 6.12458H55.6444C56.0914 6.12458 56.4267 6.23158 56.6501 6.44787C56.8737 6.66426 57.0327 6.85328 57.1295 7.01993L60.3082 11.8169C60.5043 12.1128 60.939 12.1128 61.1352 11.8169L64.3139 7.01993C64.4405 6.85328 64.6094 6.66426 64.8156 6.44787C65.0216 6.23158 65.3494 6.12458 65.7964 6.12458H72.7896C73.0778 6.12458 73.331 6.24147 73.557 6.47275C73.7805 6.70403 73.8922 6.95268 73.8922 7.21883C73.8922 7.38547 73.8748 7.53463 73.8451 7.6664C73.8128 7.79827 73.7482 7.91517 73.6539 8.01457L66.6159 18.3747C66.4992 18.5463 66.5017 18.77 66.6209 18.9392L74.4212 30.0975C74.5181 30.2293 74.5801 30.3636 74.6124 30.4954C74.6448 30.6273 74.6596 30.744 74.6596 30.8435C74.6596 31.142 74.5479 31.4081 74.3244 31.6394C74.1008 31.8707 73.8451 31.9874 73.557 31.9874H65.8934C65.4786 31.9874 65.1756 31.888 64.9844 31.689C64.7932 31.4901 64.6317 31.3086 64.5051 31.142L60.9886 25.9544C60.7924 25.671 60.3753 25.6685 60.1766 25.9471L56.4118 31.1395C56.3149 31.3061 56.1634 31.4876 55.9573 31.6865C55.7488 31.8855 55.4383 31.9851 55.0236 31.9851H47.5486Z" fill="white"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
}}>
|
||||
{/* Video Section */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 140,
|
||||
background: '#0a0a0a',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<video
|
||||
key={videoPath}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block'
|
||||
}}
|
||||
>
|
||||
<source src={videoPath} type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
|
||||
{/* Progress Section */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 140,
|
||||
background: '#0d0d0d',
|
||||
padding: '16px 24px',
|
||||
borderTop: '1px solid #2a2a2a'
|
||||
}}>
|
||||
{error ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
gap: 10,
|
||||
}}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f87171" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<span style={{ color: '#f87171', fontSize: 13, textAlign: 'center', maxWidth: 400 }}>{error}</span>
|
||||
<button
|
||||
onClick={() => { setError(null); started.current = false; startSetup() }}
|
||||
style={{
|
||||
padding: '6px 20px',
|
||||
borderRadius: 9999,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
background: 'linear-gradient(125deg, #A98BD9, #6D28D9)',
|
||||
border: 'none',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Header row */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8
|
||||
}}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<span style={{ fontSize: 13, color: '#A98BD9', fontWeight: 600 }}>
|
||||
{percent}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div style={{
|
||||
height: 6,
|
||||
background: '#1a1a1a',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
background: 'linear-gradient(125deg, #A98BD9, #6D28D9, #194DF9)',
|
||||
backgroundSize: '200% 200%',
|
||||
animation: 'gradientShift 3s ease infinite',
|
||||
borderRadius: 3,
|
||||
width: `${percent}%`,
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: 10,
|
||||
fontSize: 12,
|
||||
color: '#a0a0a0'
|
||||
}}>
|
||||
<span style={{ flex: 1 }}>
|
||||
{progress?.status === 'extracting'
|
||||
? 'Setting up Python environment...'
|
||||
: 'First-time setup — this only happens once'}
|
||||
</span>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, marginLeft: 16, flexShrink: 0 }}>
|
||||
{progress && progress.speed > 0 && (
|
||||
<span style={{ color: '#6D28D9', fontWeight: 500 }}>
|
||||
{(progress.speed / (1024 * 1024)).toFixed(1)} MB/s
|
||||
</span>
|
||||
)}
|
||||
{progress && progress.downloadedBytes > 0 && (
|
||||
<span>
|
||||
{formatBytes(progress.downloadedBytes)}{progress.totalBytes > 0 ? ` / ${formatBytes(progress.totalBytes)}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{progress && progress.speed > 0 && progress.totalBytes > 0 && (
|
||||
<span>
|
||||
ETA: {getTimeRemaining()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: '12px 24px',
|
||||
borderTop: '1px solid #1a1a1a',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: 11, color: '#666' }}>© 2026 Lightricks</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { VideoPreviewPanel, formatTimecode, type VideoPreviewContext } from './VideoPreviewPanel'
|
||||
import { validateVideoSource } from '../lib/video-constraints'
|
||||
|
||||
interface RetakePanelProps {
|
||||
initialVideoPath?: string | null
|
||||
initialDuration?: number
|
||||
resetKey?: number
|
||||
isProcessing?: boolean
|
||||
processingStatus?: string
|
||||
fillHeight?: boolean
|
||||
enforceApiConstraints?: boolean
|
||||
onChange?: (data: {
|
||||
videoPath: string | null
|
||||
startTime: number
|
||||
duration: number
|
||||
videoDuration: number
|
||||
width: number
|
||||
height: number
|
||||
ready: boolean
|
||||
}) => void
|
||||
}
|
||||
|
||||
const MIN_DURATION = 2
|
||||
|
||||
export function RetakePanel({
|
||||
initialVideoPath,
|
||||
initialDuration,
|
||||
resetKey,
|
||||
isProcessing = false,
|
||||
processingStatus = '',
|
||||
fillHeight = false,
|
||||
enforceApiConstraints = true,
|
||||
onChange,
|
||||
}: RetakePanelProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const filmstripRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [videoPath, setVideoPath] = useState<string | null>(initialVideoPath || null)
|
||||
const [videoDuration, setVideoDuration] = useState<number>(initialDuration || 0)
|
||||
const [dimensions, setDimensions] = useState<{ width: number; height: number }>({ width: 0, height: 0 })
|
||||
|
||||
const [selStart, setSelStart] = useState(0)
|
||||
const [selEnd, setSelEnd] = useState(Math.min(initialDuration || 0, 5))
|
||||
const [draggingHandle, setDraggingHandle] = useState<'start' | 'end' | 'range' | null>(null)
|
||||
// Read synchronously by the filmstrip click guard. Stays true through the click the
|
||||
// browser fires right after a drag-release mouseup (cleared on the next tick), so
|
||||
// releasing a trim drag over the strip doesn't jump the playhead.
|
||||
const draggingRef = useRef(false)
|
||||
const dragStartRef = useRef<{ mouseX: number; selStart: number; selEnd: number } | null>(null)
|
||||
const initialSelectionAppliedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (resetKey === undefined) return
|
||||
setSelStart(0)
|
||||
setSelEnd(Math.min(initialDuration || 0, 5))
|
||||
initialSelectionAppliedRef.current = false
|
||||
}, [resetKey, initialDuration])
|
||||
|
||||
const handleSourceChange = useCallback((data: { videoPath: string | null; videoDuration: number; width: number; height: number }) => {
|
||||
setVideoPath(data.videoPath)
|
||||
setVideoDuration(data.videoDuration)
|
||||
setDimensions({ width: data.width, height: data.height })
|
||||
if (!data.videoPath) {
|
||||
setSelStart(0)
|
||||
setSelEnd(0)
|
||||
initialSelectionAppliedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const sourceError = enforceApiConstraints && videoPath
|
||||
? validateVideoSource({ width: dimensions.width, height: dimensions.height, duration: videoDuration })
|
||||
: null
|
||||
|
||||
// Apply the default selection once the source duration is known.
|
||||
useEffect(() => {
|
||||
if (!videoPath || videoDuration <= 0 || initialSelectionAppliedRef.current) return
|
||||
setSelStart(0)
|
||||
setSelEnd(Math.min(videoDuration, 5))
|
||||
initialSelectionAppliedRef.current = true
|
||||
}, [videoDuration, videoPath])
|
||||
|
||||
useEffect(() => {
|
||||
const ready = !!videoPath && (selEnd - selStart) >= MIN_DURATION && !sourceError
|
||||
onChange?.({
|
||||
videoPath,
|
||||
startTime: selStart,
|
||||
duration: selEnd - selStart,
|
||||
videoDuration,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
ready,
|
||||
})
|
||||
}, [videoPath, selStart, selEnd, videoDuration, dimensions, sourceError, onChange])
|
||||
|
||||
// Retake-specific keyboard: set in/out points to the playhead.
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||||
const key = e.key.toLowerCase()
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (key === 'i') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const t = video.currentTime
|
||||
if (t < selEnd - MIN_DURATION) setSelStart(t)
|
||||
} else if (key === 'o') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const t = video.currentTime
|
||||
if (t > selStart + MIN_DURATION) setSelEnd(t)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler, true)
|
||||
return () => window.removeEventListener('keydown', handler, true)
|
||||
}, [selStart, selEnd])
|
||||
|
||||
const selStartRef = useRef(selStart)
|
||||
selStartRef.current = selStart
|
||||
const selEndRef = useRef(selEnd)
|
||||
selEndRef.current = selEnd
|
||||
|
||||
const handleFilmstripMouseDown = useCallback((e: React.MouseEvent, handle: 'start' | 'end' | 'range') => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragStartRef.current = { mouseX: e.clientX, selStart: selStartRef.current, selEnd: selEndRef.current }
|
||||
draggingRef.current = true
|
||||
setDraggingHandle(handle)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggingHandle) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const strip = filmstripRef.current
|
||||
const origin = dragStartRef.current
|
||||
if (!strip || !origin) return
|
||||
const rect = strip.getBoundingClientRect()
|
||||
|
||||
if (draggingHandle === 'range') {
|
||||
const dx = e.clientX - origin.mouseX
|
||||
const dtSeconds = (dx / rect.width) * videoDuration
|
||||
const rangeDuration = origin.selEnd - origin.selStart
|
||||
let newStart = origin.selStart + dtSeconds
|
||||
let newEnd = origin.selEnd + dtSeconds
|
||||
if (newStart < 0) { newStart = 0; newEnd = rangeDuration }
|
||||
if (newEnd > videoDuration) { newEnd = videoDuration; newStart = videoDuration - rangeDuration }
|
||||
setSelStart(Math.max(0, newStart))
|
||||
setSelEnd(Math.min(videoDuration, newEnd))
|
||||
} else {
|
||||
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
const time = fraction * videoDuration
|
||||
if (draggingHandle === 'start') {
|
||||
const maxStart = selEndRef.current - MIN_DURATION
|
||||
setSelStart(Math.max(0, Math.min(maxStart, time)))
|
||||
} else {
|
||||
const minEnd = selStartRef.current + MIN_DURATION
|
||||
setSelEnd(Math.min(videoDuration, Math.max(minEnd, time)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setDraggingHandle(null)
|
||||
dragStartRef.current = null
|
||||
// Clear on the next tick so the click synthesized from this mouseup still sees the
|
||||
// drag in progress and is suppressed by the filmstrip seek guard.
|
||||
setTimeout(() => { draggingRef.current = false }, 0)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
window.addEventListener('mouseup', handleMouseUp)
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
window.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [draggingHandle, videoDuration])
|
||||
|
||||
const renderTrimOverlay = useCallback((ctx: VideoPreviewContext) => {
|
||||
const dur = ctx.videoDuration
|
||||
const selStartFrac = dur > 0 ? selStart / dur : 0
|
||||
const selEndFrac = dur > 0 ? selEnd / dur : 1
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 left-0 bg-black/75 pointer-events-none"
|
||||
style={{ width: `${selStartFrac * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 right-0 bg-black/75 pointer-events-none"
|
||||
style={{ width: `${(1 - selEndFrac) * 100}%` }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute top-0 bottom-0 bg-white pointer-events-none"
|
||||
style={{ left: `${selStartFrac * 100}%`, width: `${(selEndFrac - selStartFrac) * 100}%` }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`absolute top-0 bottom-0 z-[12] ${draggingHandle === 'range' ? 'cursor-grabbing' : 'cursor-grab'}`}
|
||||
style={{
|
||||
left: `calc(${selStartFrac * 100}% + 14px)`,
|
||||
width: `calc(${(selEndFrac - selStartFrac) * 100}% - 28px)`,
|
||||
}}
|
||||
onMouseDown={(e) => handleFilmstripMouseDown(e, 'range')}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute top-0 bottom-0 border-2 border-blue-500 pointer-events-none"
|
||||
style={{ left: `${selStartFrac * 100}%`, width: `${(selEndFrac - selStartFrac) * 100}%` }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute top-0 bottom-0 cursor-ew-resize z-20 group"
|
||||
style={{ left: `calc(${selStartFrac * 100}% - 6px)`, width: '20px' }}
|
||||
onMouseDown={(e) => handleFilmstripMouseDown(e, 'start')}
|
||||
>
|
||||
<div className="absolute top-0 bottom-0 bg-blue-500 group-hover:bg-blue-400 transition-colors"
|
||||
style={{ left: '5px', width: '4px', borderRadius: '2px 0 0 2px' }}
|
||||
/>
|
||||
<div className="absolute top-0 bg-blue-500 group-hover:bg-blue-400 transition-colors" style={{ left: '5px', width: '10px', height: '3px', borderRadius: '2px 0 0 0' }} />
|
||||
<div className="absolute bottom-0 bg-blue-500 group-hover:bg-blue-400 transition-colors" style={{ left: '5px', width: '10px', height: '3px', borderRadius: '0 0 0 2px' }} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute top-0 bottom-0 cursor-ew-resize z-20 group"
|
||||
style={{ left: `calc(${selEndFrac * 100}% - 14px)`, width: '20px' }}
|
||||
onMouseDown={(e) => handleFilmstripMouseDown(e, 'end')}
|
||||
>
|
||||
<div className="absolute top-0 bottom-0 bg-blue-500 group-hover:bg-blue-400 transition-colors"
|
||||
style={{ right: '5px', width: '4px', borderRadius: '0 2px 2px 0' }}
|
||||
/>
|
||||
<div className="absolute top-0 bg-blue-500 group-hover:bg-blue-400 transition-colors" style={{ right: '5px', width: '10px', height: '3px', borderRadius: '0 2px 0 0' }} />
|
||||
<div className="absolute bottom-0 bg-blue-500 group-hover:bg-blue-400 transition-colors" style={{ right: '5px', width: '10px', height: '3px', borderRadius: '0 0 2px 0' }} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute top-1/2 pointer-events-none z-10"
|
||||
style={{ left: `${((selStartFrac + selEndFrac) / 2) * 100}%`, transform: 'translate(-50%, -50%)' }}
|
||||
>
|
||||
<span className="text-[11px] font-mono text-zinc-700 bg-white/90 rounded px-2 py-0.5 font-semibold shadow">
|
||||
{formatTimecode(selEnd - selStart)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}, [selStart, selEnd, draggingHandle, handleFilmstripMouseDown])
|
||||
|
||||
const renderLabels = useCallback(() => (
|
||||
<div className="flex justify-between mt-1.5">
|
||||
<span className="text-[10px] font-mono text-blue-400">{formatTimecode(selStart)}</span>
|
||||
<span className="text-[10px] font-mono text-zinc-500">Duration: {formatTimecode(selEnd - selStart)}</span>
|
||||
<span className="text-[10px] font-mono text-blue-400">{formatTimecode(selEnd)}</span>
|
||||
</div>
|
||||
), [selStart, selEnd])
|
||||
|
||||
return (
|
||||
<VideoPreviewPanel
|
||||
title="Retake"
|
||||
initialVideoPath={initialVideoPath}
|
||||
initialDuration={initialDuration}
|
||||
resetKey={resetKey}
|
||||
isProcessing={isProcessing}
|
||||
processingStatus={processingStatus}
|
||||
processingDefault="Processing retake..."
|
||||
fillHeight={fillHeight}
|
||||
emptyTitle="Drop a video to retake"
|
||||
hint={{ title: 'Select the video part to regenerate', subtitle: 'Use the prompt panel below to describe what should happen' }}
|
||||
errorMessage={sourceError ?? undefined}
|
||||
videoRef={videoRef}
|
||||
filmstripRef={filmstripRef}
|
||||
onSourceChange={handleSourceChange}
|
||||
shouldSuppressSeek={() => draggingRef.current}
|
||||
filmstripOverlay={renderTrimOverlay}
|
||||
belowFilmstrip={renderLabels}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { LoraInfoPopover } from './LoraInfoPopover'
|
||||
import type { components } from '../generated/backend-openapi'
|
||||
|
||||
type Sections = NonNullable<components['schemas']['LoraCatalogItem']['instructions']>
|
||||
|
||||
export interface SelectedLoraItem {
|
||||
name: string
|
||||
sections?: Sections
|
||||
repoId?: string
|
||||
isCommunity?: boolean
|
||||
}
|
||||
|
||||
// The "currently selected LoRA(s)" bar shown above the prompt bar — a name + info popover
|
||||
// per item, inline. Used for both the single active IC-LoRA and the list of plain LoRAs.
|
||||
export function SelectedLoraInfo({ items }: { items: SelectedLoraItem[] }) {
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-x-4 gap-y-1 rounded-lg border border-zinc-800 bg-zinc-900/80 px-3 py-2 text-[11px] text-zinc-400">
|
||||
{items.map((it, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1">
|
||||
<span className="font-semibold text-zinc-300">{it.name}</span>
|
||||
<LoraInfoPopover
|
||||
sections={it.sections ?? []}
|
||||
name={it.name}
|
||||
repoId={it.repoId}
|
||||
isCommunity={it.isCommunity}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useFixedMenu } from '../hooks/use-fixed-menu'
|
||||
import { Tooltip } from './ui/tooltip'
|
||||
|
||||
// Generic settings dropdown used across the prompt bar's control rows.
|
||||
// The menu is portaled to document.body so overflow-hidden ancestors
|
||||
// (react-resizable-panels) cannot clip it.
|
||||
export function SettingsDropdown({
|
||||
trigger,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
title,
|
||||
tooltip,
|
||||
triggerClassName,
|
||||
placement = 'above',
|
||||
}: {
|
||||
trigger: React.ReactNode
|
||||
options: { value: string; label: string; disabled?: boolean; tooltip?: string; icon?: React.ReactNode }[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
title: string
|
||||
tooltip?: string
|
||||
// Extra classes on the trigger button — e.g. to visually attach it to an adjacent button
|
||||
// as a split-button (rounded-l-none, no left padding, etc).
|
||||
triggerClassName?: string
|
||||
// Prompt-bar menus open upward; gallery toolbar menus open downward.
|
||||
placement?: 'above' | 'below'
|
||||
}) {
|
||||
const { isOpen, setIsOpen, triggerRef, menuRef, style } = useFixedMenu(placement)
|
||||
|
||||
const triggerButton = (
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`flex shrink-0 items-center gap-1 whitespace-nowrap px-2 py-1.5 rounded-md transition-colors ${isOpen ? 'bg-zinc-700 hover:bg-zinc-700' : 'hover:bg-zinc-800'} ${triggerClassName ?? ''}`}
|
||||
>
|
||||
{trigger}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={triggerRef} className="relative">
|
||||
{tooltip && !isOpen ? <Tooltip content={tooltip}>{triggerButton}</Tooltip> : triggerButton}
|
||||
|
||||
{isOpen && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={style}
|
||||
className="fixed w-max bg-zinc-800 border border-zinc-700 rounded-md p-2 min-w-[160px] shadow-xl"
|
||||
>
|
||||
<div className="text-[10px] text-zinc-500 uppercase tracking-wider mb-2">{title}</div>
|
||||
{/* Cap height + scroll so a long option list (e.g. many catalog / custom IC-LoRAs)
|
||||
doesn't clip off-screen — matches the LoRA picker's max-h-80. */}
|
||||
<div className="space-y-1 max-h-80 overflow-y-auto">
|
||||
{options.map(option => (
|
||||
<div key={option.value} className="relative group/option">
|
||||
<button
|
||||
onClick={() => { if (!option.disabled) { onChange(option.value); setIsOpen(false) } }}
|
||||
className={`w-full flex items-center justify-between px-2 py-2 rounded-md transition-colors text-left ${
|
||||
option.disabled
|
||||
? 'cursor-not-allowed'
|
||||
: value === option.value ? 'bg-white/20 hover:bg-white/25' : 'hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<span className={`flex items-center gap-2.5 text-sm ${
|
||||
option.disabled
|
||||
? 'text-zinc-600'
|
||||
: value === option.value ? 'text-white' : 'text-zinc-400'
|
||||
}`}>
|
||||
{option.icon && <span className="flex-shrink-0">{option.icon}</span>}
|
||||
{option.label}
|
||||
</span>
|
||||
{value === option.value && !option.disabled && (
|
||||
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{option.disabled && option.tooltip && (
|
||||
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 px-2 py-1 bg-zinc-700 rounded text-xs text-zinc-300 whitespace-nowrap opacity-0 group-hover/option:opacity-100 pointer-events-none z-[10000] transition-opacity">
|
||||
{option.tooltip}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,271 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { Select } from './ui/select'
|
||||
import { videoGenerationResolutionLabel } from '../lib/video-resolution'
|
||||
import {
|
||||
areVideoGenerationSettingsEquivalent,
|
||||
resolveVideoGenerationOptions,
|
||||
sanitizeVideoGenerationSettings,
|
||||
type VideoGenerationModelSpecItem,
|
||||
type VideoGenerationPipeline,
|
||||
} from '../lib/video-generation-model-specs'
|
||||
|
||||
export type GenerationMode = 'text-to-video' | 'image-to-video' | 'text-to-image'
|
||||
|
||||
export interface LoraSelection {
|
||||
ref: string
|
||||
name: string
|
||||
scale: number
|
||||
// Catalog id (e.g. "cozy-felt-style"), when this LoRA was picked from the library —
|
||||
// absent for a manually-added/custom LoRA not in the catalog. Lets the prompt enhancer
|
||||
// look up trigger/instructions without re-matching on filename or display name.
|
||||
catalogId?: string
|
||||
}
|
||||
|
||||
export interface GenerationSettings {
|
||||
model: VideoGenerationPipeline
|
||||
duration: number | null
|
||||
videoResolution: string
|
||||
fps: number
|
||||
audio: boolean
|
||||
cameraMotion: string
|
||||
aspectRatio?: string
|
||||
loras?: LoraSelection[]
|
||||
// Image-specific settings
|
||||
imageResolution: string
|
||||
imageAspectRatio: string
|
||||
imageSteps: number
|
||||
variations?: number // Number of image variations to generate
|
||||
imageEditStrength?: number // Denoising strength when editing an existing image
|
||||
}
|
||||
|
||||
interface SettingsPanelProps {
|
||||
settings: GenerationSettings
|
||||
onSettingsChange: (settings: GenerationSettings) => void
|
||||
disabled?: boolean
|
||||
mode?: GenerationMode
|
||||
hasAudio?: boolean
|
||||
videoModelSpecs?: VideoGenerationModelSpecItem[] | null
|
||||
minimumDuration?: number
|
||||
hideDuration?: boolean
|
||||
videoSettingsMessage?: string | null
|
||||
}
|
||||
|
||||
export function SettingsPanel({
|
||||
settings,
|
||||
onSettingsChange,
|
||||
disabled,
|
||||
mode = 'text-to-video',
|
||||
hasAudio = false,
|
||||
videoModelSpecs,
|
||||
minimumDuration,
|
||||
hideDuration = false,
|
||||
videoSettingsMessage,
|
||||
}: SettingsPanelProps) {
|
||||
const isImageMode = mode === 'text-to-image'
|
||||
const resolvedVideoOptions = useMemo(() => {
|
||||
if (isImageMode || !videoModelSpecs || videoModelSpecs.length === 0) return null
|
||||
return resolveVideoGenerationOptions({
|
||||
settings,
|
||||
modelSpecs: videoModelSpecs,
|
||||
hasAudio,
|
||||
minimumDuration,
|
||||
durationSelection: hideDuration ? 'smallest_valid' : 'preserve',
|
||||
})
|
||||
}, [hasAudio, hideDuration, isImageMode, minimumDuration, settings, videoModelSpecs])
|
||||
|
||||
useEffect(() => {
|
||||
if (isImageMode || !videoModelSpecs || videoModelSpecs.length === 0) return
|
||||
const sanitized = sanitizeVideoGenerationSettings(settings, videoModelSpecs, {
|
||||
hasAudio,
|
||||
minimumDuration,
|
||||
durationSelection: hideDuration ? 'smallest_valid' : 'preserve',
|
||||
})
|
||||
if (!sanitized) return
|
||||
if (!areVideoGenerationSettingsEquivalent(settings, sanitized)) {
|
||||
onSettingsChange(sanitized)
|
||||
}
|
||||
}, [hasAudio, hideDuration, isImageMode, minimumDuration, onSettingsChange, settings, videoModelSpecs])
|
||||
|
||||
const handleChange = (key: keyof GenerationSettings, value: string | number | boolean | null) => {
|
||||
if (isImageMode) {
|
||||
onSettingsChange({ ...settings, [key]: value } as GenerationSettings)
|
||||
return
|
||||
}
|
||||
if (!videoModelSpecs || videoModelSpecs.length === 0) return
|
||||
|
||||
const nextSettings = { ...settings, [key]: value } as GenerationSettings
|
||||
const sanitized = sanitizeVideoGenerationSettings(nextSettings, videoModelSpecs, {
|
||||
hasAudio,
|
||||
minimumDuration,
|
||||
durationSelection: hideDuration ? 'smallest_valid' : 'preserve',
|
||||
})
|
||||
if (sanitized) {
|
||||
onSettingsChange(sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
// Image mode settings
|
||||
if (isImageMode) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Aspect Ratio and Quality side by side */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Select
|
||||
label="Aspect Ratio"
|
||||
value={settings.imageAspectRatio || '16:9'}
|
||||
onChange={(e) => handleChange('imageAspectRatio', e.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="1:1">1:1 (Square)</option>
|
||||
<option value="16:9">16:9 (Landscape)</option>
|
||||
<option value="9:16">9:16 (Portrait)</option>
|
||||
<option value="4:3">4:3 (Standard)</option>
|
||||
<option value="3:4">3:4 (Portrait Standard)</option>
|
||||
<option value="21:9">21:9 (Cinematic)</option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
label="Quality"
|
||||
value={settings.imageSteps || 4}
|
||||
onChange={(e) => handleChange('imageSteps', parseInt(e.target.value))}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value={4}>Fast</option>
|
||||
<option value={8}>Balanced</option>
|
||||
<option value={12}>High</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!videoModelSpecs || videoModelSpecs.length === 0 || !resolvedVideoOptions || !resolvedVideoOptions.hasCompatibleOptions) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-950/60 px-3 py-2 text-xs text-zinc-500">
|
||||
{videoSettingsMessage || 'Loading generation settings...'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const showFps = resolvedVideoOptions.fpsOptions.length > 1
|
||||
const videoControlsColumns = hideDuration
|
||||
? (showFps ? 'grid-cols-2' : 'grid-cols-1')
|
||||
: (showFps ? 'grid-cols-3' : 'grid-cols-2')
|
||||
|
||||
// Video mode settings
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Select
|
||||
label="Model"
|
||||
value={resolvedVideoOptions.selectedModel ?? settings.model}
|
||||
onChange={(e) => handleChange('model', e.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{resolvedVideoOptions.modelOptions.map((item) => (
|
||||
<option key={item.pipeline} value={item.pipeline}>
|
||||
{item.spec.display_name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* Duration, Resolution, FPS Row */}
|
||||
<div className={`grid gap-3 ${videoControlsColumns}`}>
|
||||
{!hideDuration && (
|
||||
<Select
|
||||
label="Duration"
|
||||
value={
|
||||
resolvedVideoOptions.autoDurationAvailable && resolvedVideoOptions.selectedDuration === null
|
||||
? 'auto'
|
||||
: String(resolvedVideoOptions.selectedDuration ?? settings.duration)
|
||||
}
|
||||
onChange={(e) => handleChange('duration', e.target.value === 'auto' ? null : parseInt(e.target.value))}
|
||||
disabled={disabled}
|
||||
>
|
||||
{resolvedVideoOptions.autoDurationAvailable && (
|
||||
<option value="auto">Auto</option>
|
||||
)}
|
||||
{resolvedVideoOptions.durationOptions.map((duration) => (
|
||||
<option key={duration} value={duration}>
|
||||
{duration} sec
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label="Resolution"
|
||||
value={resolvedVideoOptions.selectedResolution ?? settings.videoResolution}
|
||||
onChange={(e) => handleChange('videoResolution', e.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{resolvedVideoOptions.resolutionOptions.map((resolution) => (
|
||||
<option key={resolution} value={resolution}>
|
||||
{videoGenerationResolutionLabel(resolution)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{showFps && (
|
||||
<Select
|
||||
label="FPS"
|
||||
value={resolvedVideoOptions.selectedFps ?? settings.fps}
|
||||
onChange={(e) => handleChange('fps', parseInt(e.target.value))}
|
||||
disabled={disabled}
|
||||
>
|
||||
{resolvedVideoOptions.fpsOptions.map((fps) => (
|
||||
<option key={fps} value={fps}>
|
||||
{fps}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Aspect Ratio */}
|
||||
<Select
|
||||
label="Aspect Ratio"
|
||||
value={settings.aspectRatio || '16:9'}
|
||||
onChange={(e) => handleChange('aspectRatio', e.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="16:9">16:9 Landscape</option>
|
||||
<option value="9:16">9:16 Portrait</option>
|
||||
</Select>
|
||||
|
||||
{/* Audio and Camera Motion Row */}
|
||||
<div className="flex gap-3">
|
||||
<div className="w-[140px] flex-shrink-0">
|
||||
<Select
|
||||
label="Audio"
|
||||
badge="PREVIEW"
|
||||
value={settings.audio ? 'on' : 'off'}
|
||||
onChange={(e) => handleChange('audio', e.target.value === 'on')}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="on">On</option>
|
||||
<option value="off">Off</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
label="Camera Motion"
|
||||
value={settings.cameraMotion}
|
||||
onChange={(e) => handleChange('cameraMotion', e.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="static">Static</option>
|
||||
<option value="focus_shift">Focus Shift</option>
|
||||
<option value="dolly_in">Dolly In</option>
|
||||
<option value="dolly_out">Dolly Out</option>
|
||||
<option value="dolly_left">Dolly Left</option>
|
||||
<option value="dolly_right">Dolly Right</option>
|
||||
<option value="jib_up">Jib Up</option>
|
||||
<option value="jib_down">Jib Down</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
.update-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 70;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
backdrop-filter: blur(4px);
|
||||
animation: update-modal-backdrop-in 180ms ease-out;
|
||||
}
|
||||
|
||||
.update-modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
padding: 1.75rem 1.5rem 1.5rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgb(63 63 70);
|
||||
background: rgb(24 24 27);
|
||||
color: white;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
|
||||
animation: update-modal-card-in 260ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.update-modal h2 {
|
||||
margin: 0 1.5rem 0.25rem 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.update-modal-close {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
color: rgb(161 161 170);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.update-modal-close:hover {
|
||||
background: rgb(39 39 42);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.update-modal-version {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: rgb(161 161 170);
|
||||
}
|
||||
|
||||
.update-modal-notes {
|
||||
margin-top: 0.75rem;
|
||||
max-height: 10rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: rgb(9 9 11 / 0.6);
|
||||
border: 1px solid rgb(63 63 70 / 0.6);
|
||||
color: rgb(212 212 216);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.update-modal-error,
|
||||
.update-modal-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.update-modal-error {
|
||||
color: rgb(248 113 113);
|
||||
}
|
||||
|
||||
.update-modal-warning {
|
||||
color: rgb(251 191 36);
|
||||
}
|
||||
|
||||
.update-modal-progress {
|
||||
position: relative;
|
||||
margin-top: 1.25rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.update-modal-progress::before {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgb(39 39 42);
|
||||
}
|
||||
|
||||
.update-modal-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgb(43 97 255);
|
||||
transition: width 150ms ease;
|
||||
}
|
||||
|
||||
.update-modal-progress span {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 14px;
|
||||
font-size: 0.75rem;
|
||||
color: rgb(161 161 170);
|
||||
}
|
||||
|
||||
.update-modal-skip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: rgb(212 212 216);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.update-modal-skip input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.update-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.update-modal-actions button {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@keyframes update-modal-backdrop-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes update-modal-card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { AlertCircle, Download, RefreshCw, X } from 'lucide-react'
|
||||
import { Button } from './ui/button'
|
||||
import type { AppUpdate } from '../hooks/use-app-update'
|
||||
import './UpdateAvailableModal.css'
|
||||
|
||||
interface Props {
|
||||
update: AppUpdate
|
||||
isGenerationActive: boolean
|
||||
// onClose receives whether the user ticked "Skip this version".
|
||||
onClose: (skipThisVersion: boolean) => void
|
||||
}
|
||||
|
||||
function titleForStatus(status: AppUpdate['state']['status']): string {
|
||||
if (status === 'downloaded') return 'Ready to install'
|
||||
if (status === 'downloading') return 'Downloading update'
|
||||
if (status === 'checking') return 'Checking for updates'
|
||||
return 'Update available'
|
||||
}
|
||||
|
||||
export function UpdateAvailableModal({ update, isGenerationActive, onClose }: Props) {
|
||||
const { state, startDownload, installAndRestart } = update
|
||||
const [skipChecked, setSkipChecked] = useState(false)
|
||||
const [installError, setInstallError] = useState<string | null>(null)
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const downloading = state.status === 'downloading'
|
||||
const downloaded = state.status === 'downloaded'
|
||||
const canDownload = state.status === 'available'
|
||||
const canDismiss = !downloaded
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (!canDismiss) return
|
||||
onClose(skipChecked && state.status === 'available')
|
||||
}, [canDismiss, onClose, skipChecked, state.status])
|
||||
|
||||
useEffect(() => {
|
||||
dialogRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && canDismiss) handleClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [canDismiss, handleClose])
|
||||
|
||||
const handleInstall = async () => {
|
||||
try {
|
||||
const res = await installAndRestart()
|
||||
if (!res.success) setInstallError(res.error ?? 'Could not install the update.')
|
||||
} catch (e) {
|
||||
setInstallError(e instanceof Error ? e.message : 'Could not install the update.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="update-modal-backdrop"
|
||||
onClick={canDismiss ? handleClose : undefined}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="update-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="update-modal-title"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{canDismiss && (
|
||||
<button className="update-modal-close" onClick={handleClose} aria-label="Close">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<h2 id="update-modal-title">{titleForStatus(state.status)}</h2>
|
||||
<p className="update-modal-version">
|
||||
{state.currentVersion} → <strong>{state.version}</strong>
|
||||
</p>
|
||||
|
||||
{state.releaseNotes && <div className="update-modal-notes">{state.releaseNotes}</div>}
|
||||
|
||||
{state.message && (
|
||||
<p className="update-modal-error"><AlertCircle className="h-4 w-4" /> {state.message}</p>
|
||||
)}
|
||||
{installError && (
|
||||
<p className="update-modal-error"><AlertCircle className="h-4 w-4" /> {installError}</p>
|
||||
)}
|
||||
|
||||
{downloading && (
|
||||
<div className="update-modal-progress">
|
||||
<div className="update-modal-bar" style={{ width: `${state.percent ?? 0}%` }} />
|
||||
<span>{state.percent ?? 0}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{downloaded && isGenerationActive && (
|
||||
<p className="update-modal-warning">
|
||||
A generation is running. Installing will restart the app — it will be enabled when the
|
||||
generation finishes.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state.status === 'available' && (
|
||||
<label className="update-modal-skip">
|
||||
<input type="checkbox" checked={skipChecked} onChange={(e) => setSkipChecked(e.target.checked)} />
|
||||
Skip this version
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="update-modal-actions">
|
||||
{downloaded ? (
|
||||
<Button onClick={handleInstall} disabled={isGenerationActive}>
|
||||
<RefreshCw className="h-4 w-4" /> Restart to update
|
||||
</Button>
|
||||
) : downloading ? (
|
||||
<Button variant="ghost" onClick={handleClose}>Hide</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" onClick={handleClose}>Later</Button>
|
||||
<Button onClick={startDownload} disabled={!canDownload}>
|
||||
{state.message ? (<><Download className="h-4 w-4" /> Try again</>)
|
||||
: (<><Download className="h-4 w-4" /> Update now</>)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Film, Play, Pause, Volume2, VolumeX, Loader2, Upload, Trash2, RefreshCw } from 'lucide-react'
|
||||
import { logger } from '../lib/logger'
|
||||
import { pathToFileUrl } from '../lib/file-url'
|
||||
|
||||
// Shared video source + preview surface used by RetakePanel and ExtendPanel. Owns the
|
||||
// video file (drop/browse/clear), playback, mute, the timecode transport bar, and the
|
||||
// filmstrip thumbnail strip with click-to-seek. Mode-specific UI (retake's trim handles,
|
||||
// extend's direction/seconds controls) is injected via the render-prop slots.
|
||||
|
||||
export interface VideoPreviewContext {
|
||||
videoPath: string | null
|
||||
videoUrl: string | null
|
||||
videoDuration: number
|
||||
currentTime: number
|
||||
isPlaying: boolean
|
||||
thumbnails: string[]
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
filmstripRef: React.RefObject<HTMLDivElement>
|
||||
seek: (seconds: number) => void
|
||||
}
|
||||
|
||||
interface VideoPreviewPanelProps {
|
||||
title: string
|
||||
initialVideoPath?: string | null
|
||||
initialDuration?: number
|
||||
resetKey?: number
|
||||
isProcessing?: boolean
|
||||
processingStatus?: string
|
||||
processingDefault?: string
|
||||
fillHeight?: boolean
|
||||
emptyTitle?: string
|
||||
hint?: { title: string; subtitle?: string }
|
||||
// Validation error for the current source; when set, shown as a banner.
|
||||
errorMessage?: string
|
||||
// Owned by a parent when it needs them in its own effects (e.g. retake drag/keyboard).
|
||||
videoRef?: React.RefObject<HTMLVideoElement>
|
||||
filmstripRef?: React.RefObject<HTMLDivElement>
|
||||
onSourceChange?: (data: { videoPath: string | null; videoDuration: number; width: number; height: number }) => void
|
||||
// Returns true when the parent is mid-interaction (e.g. dragging a trim handle) and the
|
||||
// filmstrip click-to-seek should be ignored — a drag release lands a click on the strip.
|
||||
shouldSuppressSeek?: () => boolean
|
||||
// Absolutely-positioned overlay rendered on top of the filmstrip thumbnails.
|
||||
filmstripOverlay?: (ctx: VideoPreviewContext) => React.ReactNode
|
||||
// Content rendered below the filmstrip (labels for retake, controls for extend).
|
||||
belowFilmstrip?: (ctx: VideoPreviewContext) => React.ReactNode
|
||||
}
|
||||
|
||||
export function formatTimecode(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${String(m).padStart(2, '0')}:${s.toFixed(2).padStart(5, '0')}`
|
||||
}
|
||||
|
||||
export function VideoPreviewPanel({
|
||||
title,
|
||||
initialVideoPath,
|
||||
initialDuration,
|
||||
resetKey,
|
||||
isProcessing = false,
|
||||
processingStatus = '',
|
||||
processingDefault = 'Processing...',
|
||||
fillHeight = false,
|
||||
emptyTitle = 'Drop a video',
|
||||
hint,
|
||||
errorMessage,
|
||||
videoRef: videoRefProp,
|
||||
filmstripRef: filmstripRefProp,
|
||||
onSourceChange,
|
||||
shouldSuppressSeek,
|
||||
filmstripOverlay,
|
||||
belowFilmstrip,
|
||||
}: VideoPreviewPanelProps) {
|
||||
const internalVideoRef = useRef<HTMLVideoElement>(null)
|
||||
const internalFilmstripRef = useRef<HTMLDivElement>(null)
|
||||
const videoRef = videoRefProp ?? internalVideoRef
|
||||
const filmstripRef = filmstripRefProp ?? internalFilmstripRef
|
||||
|
||||
const [videoPath, setVideoPath] = useState<string | null>(initialVideoPath || null)
|
||||
const videoUrl = videoPath ? pathToFileUrl(videoPath) : null
|
||||
const [videoDuration, setVideoDuration] = useState<number>(initialDuration || 0)
|
||||
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [dimensions, setDimensions] = useState<{ width: number; height: number }>({ width: 0, height: 0 })
|
||||
|
||||
const [thumbnails, setThumbnails] = useState<string[]>([])
|
||||
const [thumbCount] = useState(20)
|
||||
const extractingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (resetKey === undefined) return
|
||||
setVideoPath(initialVideoPath || null)
|
||||
setVideoDuration(initialDuration || 0)
|
||||
setIsPlaying(false)
|
||||
setCurrentTime(0)
|
||||
setThumbnails([])
|
||||
setDimensions({ width: 0, height: 0 })
|
||||
extractingRef.current = false
|
||||
}, [resetKey, initialVideoPath, initialDuration])
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoUrl) {
|
||||
setVideoDuration(0)
|
||||
setIsPlaying(false)
|
||||
setCurrentTime(0)
|
||||
setThumbnails([])
|
||||
setDimensions({ width: 0, height: 0 })
|
||||
extractingRef.current = false
|
||||
}
|
||||
}, [videoUrl, initialDuration])
|
||||
|
||||
useEffect(() => {
|
||||
onSourceChange?.({ videoPath, videoDuration, width: dimensions.width, height: dimensions.height })
|
||||
}, [videoPath, videoDuration, dimensions, onSourceChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoUrl || extractingRef.current || videoDuration <= 0) return
|
||||
extractingRef.current = true
|
||||
let cancelled = false
|
||||
|
||||
const extractThumbnails = async () => {
|
||||
const video = document.createElement('video')
|
||||
video.crossOrigin = 'anonymous'
|
||||
video.preload = 'auto'
|
||||
video.muted = true
|
||||
video.src = videoUrl
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
video.onloadeddata = () => resolve()
|
||||
video.onerror = () => reject(new Error('Failed to load video for thumbnails'))
|
||||
setTimeout(() => reject(new Error('Timeout loading video')), 10000)
|
||||
})
|
||||
if (cancelled) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')!
|
||||
const thumbWidth = 80
|
||||
const thumbHeight = Math.round(thumbWidth * (video.videoHeight / video.videoWidth))
|
||||
canvas.width = thumbWidth
|
||||
canvas.height = thumbHeight
|
||||
|
||||
const frames: string[] = []
|
||||
const count = Math.min(thumbCount, Math.max(5, Math.floor(videoDuration / 0.25)))
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const seekTime = (i / count) * videoDuration
|
||||
video.currentTime = seekTime
|
||||
await new Promise<void>(resolve => {
|
||||
video.onseeked = () => resolve()
|
||||
setTimeout(resolve, 500)
|
||||
})
|
||||
if (cancelled) { video.src = ''; video.load(); return }
|
||||
ctx.drawImage(video, 0, 0, thumbWidth, thumbHeight)
|
||||
frames.push(canvas.toDataURL('image/jpeg', 0.6))
|
||||
}
|
||||
|
||||
video.src = ''
|
||||
video.load()
|
||||
setThumbnails(frames)
|
||||
}
|
||||
|
||||
extractThumbnails().catch(err => {
|
||||
logger.warn(`Filmstrip extraction failed: ${err}`)
|
||||
})
|
||||
|
||||
// Abort an in-flight extraction when the source changes or the component unmounts.
|
||||
// Reset the re-entry guard too: videoDuration is refined by loadedmetadata after the
|
||||
// asset's approximate duration kicks off the first run, and without this the cancelled
|
||||
// run leaves the guard stuck true and the restart never happens (permanent spinner).
|
||||
return () => {
|
||||
cancelled = true
|
||||
extractingRef.current = false
|
||||
}
|
||||
}, [videoUrl, videoDuration, thumbCount])
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const handler = () => setCurrentTime(video.currentTime)
|
||||
const onLoaded = () => {
|
||||
// The real file duration is authoritative; initialDuration is only a pre-load hint
|
||||
// (an asset's stored duration can be stale/approximate and mismatch the actual file).
|
||||
if (video.duration && Number.isFinite(video.duration)) {
|
||||
setVideoDuration(video.duration)
|
||||
}
|
||||
if (video.videoWidth && video.videoHeight) {
|
||||
setDimensions({ width: video.videoWidth, height: video.videoHeight })
|
||||
}
|
||||
}
|
||||
video.addEventListener('timeupdate', handler)
|
||||
video.addEventListener('loadedmetadata', onLoaded)
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', handler)
|
||||
video.removeEventListener('loadedmetadata', onLoaded)
|
||||
}
|
||||
}, [videoUrl])
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (video.paused) {
|
||||
video.play()
|
||||
setIsPlaying(true)
|
||||
} else {
|
||||
video.pause()
|
||||
setIsPlaying(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
video.muted = !video.muted
|
||||
setIsMuted(video.muted)
|
||||
}, [])
|
||||
|
||||
// Shared transport keyboard: play/pause + frame/scrub. (Retake adds its own i/o keys.)
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||||
const key = e.key.toLowerCase()
|
||||
const video = videoRef.current
|
||||
if (key === ' ') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
togglePlay()
|
||||
} else if (key === 'arrowleft') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (video) {
|
||||
video.pause()
|
||||
setIsPlaying(false)
|
||||
video.currentTime = Math.max(0, video.currentTime - 1 / 24)
|
||||
}
|
||||
} else if (key === 'arrowright') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (video) {
|
||||
video.pause()
|
||||
setIsPlaying(false)
|
||||
video.currentTime = Math.min(videoDuration, video.currentTime + 1 / 24)
|
||||
}
|
||||
} else if (key === 'k') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (video) { video.pause(); setIsPlaying(false) }
|
||||
} else if (key === 'l') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (video) { video.play(); setIsPlaying(true) }
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler, true)
|
||||
return () => window.removeEventListener('keydown', handler, true)
|
||||
}, [togglePlay, videoDuration])
|
||||
|
||||
const seek = useCallback((seconds: number) => {
|
||||
const video = videoRef.current
|
||||
if (video) video.currentTime = seconds
|
||||
}, [])
|
||||
|
||||
const handleFilmstripClick = useCallback((e: React.MouseEvent) => {
|
||||
if (shouldSuppressSeek?.()) return
|
||||
const strip = filmstripRef.current
|
||||
const video = videoRef.current
|
||||
if (!strip || !video) return
|
||||
const rect = strip.getBoundingClientRect()
|
||||
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
video.currentTime = fraction * videoDuration
|
||||
}, [videoDuration, shouldSuppressSeek])
|
||||
|
||||
// Swap to a new source. Reset duration/dimensions too (not just thumbnails) so the next
|
||||
// onSourceChange doesn't emit the new path with the previous file's metadata until
|
||||
// loadedmetadata fires.
|
||||
const selectSource = useCallback((path: string) => {
|
||||
setVideoPath(path)
|
||||
setVideoDuration(0)
|
||||
setCurrentTime(0)
|
||||
setDimensions({ width: 0, height: 0 })
|
||||
setThumbnails([])
|
||||
extractingRef.current = false
|
||||
}, [])
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
const paths = await window.electronAPI.showOpenFileDialog({
|
||||
title: 'Select Video',
|
||||
filters: [{ name: 'Video', extensions: ['mp4', 'mov', 'avi', 'webm', 'mkv'] }],
|
||||
})
|
||||
if (paths && paths.length > 0) {
|
||||
selectSource(paths[0])
|
||||
}
|
||||
}, [selectSource])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setVideoPath(null)
|
||||
setVideoDuration(0)
|
||||
setIsPlaying(false)
|
||||
setCurrentTime(0)
|
||||
setThumbnails([])
|
||||
extractingRef.current = false
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(false)
|
||||
|
||||
const assetData = e.dataTransfer.getData('asset')
|
||||
if (assetData) {
|
||||
try {
|
||||
const asset = JSON.parse(assetData) as { type?: string; path?: string }
|
||||
if (asset.type === 'video' && asset.path) {
|
||||
selectSource(asset.path)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// fall through to file handling
|
||||
}
|
||||
}
|
||||
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) {
|
||||
const filePath = window.electronAPI?.getPathForFile(file)
|
||||
if (filePath) {
|
||||
selectSource(filePath)
|
||||
}
|
||||
}
|
||||
}, [selectSource])
|
||||
|
||||
const ctx: VideoPreviewContext = {
|
||||
videoPath,
|
||||
videoUrl,
|
||||
videoDuration,
|
||||
currentTime,
|
||||
isPlaying,
|
||||
thumbnails,
|
||||
videoRef,
|
||||
filmstripRef,
|
||||
seek,
|
||||
}
|
||||
|
||||
const playheadFrac = videoDuration > 0 ? currentTime / videoDuration : 0
|
||||
|
||||
return (
|
||||
<div className={`bg-zinc-900 border border-zinc-800 rounded-2xl overflow-hidden flex flex-col ${fillHeight ? 'h-full min-h-0' : ''}`}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Film className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-semibold text-white">{title}</span>
|
||||
{videoPath && (
|
||||
<span className="text-xs text-zinc-500 truncate max-w-[240px]">
|
||||
{videoPath.split(/[/\\]/).pop()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{videoUrl && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
|
||||
title="Clear video"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
className="p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
|
||||
title="Replace video"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!videoUrl ? (
|
||||
<div
|
||||
className={`p-8 flex flex-col items-center justify-center gap-3 border-2 border-dashed rounded-xl m-4 transition-colors ${
|
||||
isDragOver ? 'border-blue-500 bg-blue-500/10' : 'border-zinc-700'
|
||||
}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true) }}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="p-3 rounded-full bg-zinc-800">
|
||||
<Upload className="h-5 w-5 text-zinc-400" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-white">{emptyTitle}</p>
|
||||
<p className="text-xs text-zinc-500">mp4, mov, avi, webm, mkv</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
className="px-4 py-1.5 text-xs font-medium rounded-md bg-white text-black hover:bg-zinc-200 transition-colors"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="relative bg-black flex-1 min-h-0">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
className="w-full h-full object-contain"
|
||||
onClick={togglePlay}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
/>
|
||||
<div className="absolute bottom-2 left-2 flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="p-1.5 rounded bg-black/60 hover:bg-black/80 text-white/80 hover:text-white transition-colors"
|
||||
>
|
||||
{isMuted ? <VolumeX className="h-3.5 w-3.5" /> : <Volume2 className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center justify-center gap-3 px-4 py-2 bg-zinc-900 border-b border-zinc-800">
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="p-1 rounded hover:bg-zinc-800 text-white transition-colors"
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</button>
|
||||
<span className="text-xs font-mono text-zinc-400">
|
||||
{formatTimecode(currentTime)} / {formatTimecode(videoDuration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hint && (
|
||||
<div className="px-4 pt-3 pb-1">
|
||||
<p className="text-xs font-semibold text-white">{hint.title}</p>
|
||||
{hint.subtitle && <p className="text-[10px] text-zinc-500 mt-0.5">{hint.subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-4 pb-4">
|
||||
<div className="relative h-3 mb-0">
|
||||
<div
|
||||
className="absolute pointer-events-none z-10"
|
||||
style={{ left: `${playheadFrac * 100}%`, transform: 'translateX(-50%)' }}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<polygon points="0,0 10,0 5,8" fill="#fff" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={filmstripRef}
|
||||
className="relative h-14 rounded-md overflow-hidden cursor-pointer select-none"
|
||||
onClick={handleFilmstripClick}
|
||||
>
|
||||
<div className="absolute inset-0 flex">
|
||||
{thumbnails.length > 0 ? (
|
||||
thumbnails.map((thumb, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={thumb}
|
||||
alt=""
|
||||
className="h-full flex-1 object-cover"
|
||||
style={{ minWidth: 0 }}
|
||||
draggable={false}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="w-full h-full bg-zinc-800 flex items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 text-zinc-600 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filmstripOverlay?.(ctx)}
|
||||
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-zinc-800 pointer-events-none z-[15]"
|
||||
style={{ left: `${playheadFrac * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{belowFilmstrip?.(ctx)}
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-600/10 border border-red-500/20">
|
||||
<span className="text-xs text-red-300">{errorMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : isProcessing ? (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-blue-600/10 border border-blue-500/20">
|
||||
<Loader2 className="h-3.5 w-3.5 text-blue-400 animate-spin flex-shrink-0" />
|
||||
<span className="text-xs text-blue-300">{processingStatus || processingDefault}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Component, type ReactNode } from 'react'
|
||||
export class WebErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
|
||||
state = { failed: false }
|
||||
static getDerivedStateFromError() { return { failed: true } }
|
||||
render() {
|
||||
if (this.state.failed) return <main className="p-12 space-y-4"><h1 className="text-2xl">LTX DeskWEB could not render this view</h1><p>Your saved projects remain in this browser. Reload to return to the project list.</p><button className="px-4 py-2 bg-blue-600 rounded" onClick={() => location.reload()}>Reload workspace</button></main>
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useState } from 'react'
|
||||
import { migrateProjectData } from '../types/project-model'
|
||||
import { readProjectIds, writeProject, writeProjectIds, getProjectStorageKey } from '../lib/project-storage'
|
||||
|
||||
export function WebProjectBackup() {
|
||||
const [error, setError] = useState('')
|
||||
return <div className="flex gap-4 items-center flex-wrap">
|
||||
<button className="underline" onClick={() => {
|
||||
const data: Record<string, string> = { 'ltx-project-ids': JSON.stringify(readProjectIds()) }
|
||||
for (const id of readProjectIds()) { const key = getProjectStorageKey(id), item = localStorage.getItem(key); if (item) data[key] = item }
|
||||
void window.electronAPI.saveFile({ filePath: 'ltx-projects-backup.json', data: JSON.stringify(data, null, 2) })
|
||||
}}>Back up projects (JSON)</button>
|
||||
<label className="underline cursor-pointer">Restore project backup
|
||||
<input type="file" accept=".json" className="hidden" onChange={async event => {
|
||||
const file = event.target.files?.[0]; if (!file) return
|
||||
setError('')
|
||||
try {
|
||||
if (file.size > 20 * 1024 * 1024) throw Error('Backup is too large (maximum 20 MiB)')
|
||||
const data = JSON.parse(await file.text()) as Record<string, string>
|
||||
const ids: unknown = JSON.parse(data['ltx-project-ids'])
|
||||
if (!Array.isArray(ids) || ids.some(id => typeof id !== 'string' || !/^project-[a-zA-Z0-9-]+$/.test(id))) throw Error('Invalid project index')
|
||||
const projects = ids.map(id => { const result = migrateProjectData(JSON.parse(data[getProjectStorageKey(id)])).project; if (result.id !== id) throw Error('Project ID mismatch'); return result })
|
||||
if (!confirm(`Restore ${projects.length} project(s)? Projects with matching IDs in this browser will be replaced. Media files are not included in the backup.`)) return
|
||||
for (const project of projects) writeProject(project.id, project)
|
||||
writeProjectIds([...new Set([...ids as string[], ...readProjectIds()])]); location.reload()
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'Cannot restore backup') }
|
||||
event.target.value = ''
|
||||
}} />
|
||||
</label>
|
||||
{error && <p role="alert" className="text-red-300">{error}</p>}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Download, ExternalLink, Check, Loader2, Sparkles } from 'lucide-react'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
import { catalogVariantKey, preferredVariantId } from '@/lib/lora-library'
|
||||
|
||||
export type LibraryItemStatus =
|
||||
| { kind: 'available' }
|
||||
| { kind: 'downloading'; progress: number }
|
||||
| { kind: 'downloaded' }
|
||||
| { kind: 'error'; gated?: boolean; message: string }
|
||||
|
||||
export interface LibraryItemVariant {
|
||||
id: string
|
||||
label: string
|
||||
sizeBytes: number
|
||||
downloaded?: boolean
|
||||
}
|
||||
|
||||
export interface LibraryItem {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
sizeBytes?: number
|
||||
/** Item-level fallback when there are no variants (any file present). */
|
||||
downloaded: boolean
|
||||
thumbnailUrl?: string
|
||||
demoVideoUrl?: string
|
||||
author?: { name: string; url?: string | null }
|
||||
license?: { name: string; url?: string | null }
|
||||
variants?: LibraryItemVariant[]
|
||||
defaultVariantId?: string
|
||||
requiresHfLogin?: boolean
|
||||
}
|
||||
|
||||
interface LibraryItemCardProps {
|
||||
item: LibraryItem
|
||||
selected?: boolean
|
||||
// The variant actually active for this entry (when selected). Undefined/null means
|
||||
// "don't check" (e.g. no variant picker) — badge follows `selected` alone.
|
||||
selectedVariantId?: string | null
|
||||
downloadingKey?: string | null
|
||||
progress?: number
|
||||
downloadError?: { key: string; message: string; gated?: boolean } | null
|
||||
onDownload?: (id: string, variantId?: string) => void
|
||||
onUse?: (id: string, variantId?: string) => void
|
||||
onRequestAccess?: (id: string) => void
|
||||
onRetry?: (id: string, variantId?: string) => void
|
||||
infoSlot?: ReactNode
|
||||
}
|
||||
|
||||
const openUrl = (url?: string | null) => { if (url) void window.electronAPI.openExternalUrl({ url }) }
|
||||
|
||||
// Generic media grid card for one downloadable asset. No domain knowledge — the consumer
|
||||
// maps its type into a LibraryItem. Media is optional: a demo video shows its first frame
|
||||
// (preload=metadata) and plays on hover; without media the card shows the thumbnail or a placeholder.
|
||||
export function LibraryItemCard({
|
||||
item, selected, selectedVariantId, downloadingKey, progress = 0, downloadError,
|
||||
onDownload, onUse, onRequestAccess, onRetry, infoSlot,
|
||||
}: LibraryItemCardProps) {
|
||||
const hasVariants = (item.variants?.length ?? 0) > 1
|
||||
const downloadedVariantIds = useMemo(
|
||||
() => (item.variants ?? []).filter(v => v.downloaded).map(v => v.id),
|
||||
[item.variants],
|
||||
)
|
||||
const downloadedKey = downloadedVariantIds.join(',')
|
||||
const fallbackVariantId = item.defaultVariantId ?? item.variants?.[0]?.id
|
||||
const initialVariantId = preferredVariantId(item.variants, item.defaultVariantId, downloadedVariantIds)
|
||||
?? fallbackVariantId
|
||||
const [variantId, setVariantId] = useState(initialVariantId)
|
||||
// Keep selection on an installed checkpoint when install state arrives after first paint
|
||||
// (e.g. listModels refresh) and the catalog default still isn't on disk.
|
||||
useEffect(() => {
|
||||
if (!hasVariants) return
|
||||
const preferred = preferredVariantId(item.variants, item.defaultVariantId, downloadedVariantIds)
|
||||
setVariantId(prev => {
|
||||
if (prev && downloadedVariantIds.includes(prev)) return prev
|
||||
if (prev && item.variants?.some(v => v.id === prev)) return prev
|
||||
return preferred ?? fallbackVariantId
|
||||
})
|
||||
}, [hasVariants, item.defaultVariantId, item.variants, downloadedKey, fallbackVariantId]) // eslint-disable-line react-hooks/exhaustive-deps -- downloadedKey proxies ids
|
||||
|
||||
const selectedVariant = item.variants?.find(v => v.id === variantId)
|
||||
const sizeBytes = selectedVariant?.sizeBytes ?? item.sizeBytes
|
||||
const size = sizeBytes ? formatBytes(sizeBytes) : null
|
||||
const activeKey = catalogVariantKey(item.id, hasVariants ? variantId : undefined)
|
||||
const status: LibraryItemStatus = (() => {
|
||||
if (downloadingKey === activeKey) return { kind: 'downloading', progress }
|
||||
if (downloadError?.key === activeKey) {
|
||||
return {
|
||||
kind: 'error',
|
||||
gated: downloadError.gated ?? Boolean(item.requiresHfLogin),
|
||||
message: downloadError.message,
|
||||
}
|
||||
}
|
||||
if (hasVariants) {
|
||||
return selectedVariant?.downloaded ? { kind: 'downloaded' } : { kind: 'available' }
|
||||
}
|
||||
return item.downloaded ? { kind: 'downloaded' } : { kind: 'available' }
|
||||
})()
|
||||
const selectedVariantReady = status.kind === 'downloaded'
|
||||
// With a variant picker, only badge "Selected" when the dropdown is on the variant
|
||||
// that's actually active — not just this catalog entry (avoids marking the wrong checkpoint).
|
||||
const isSelected = Boolean(selected) && (!hasVariants || selectedVariantId == null || selectedVariantId === variantId)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
// Lazy media: don't fetch/decode the demo video until the card scrolls near the viewport.
|
||||
const [near, setNear] = useState(false)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = cardRef.current
|
||||
if (!el || near) return
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) setNear(true)
|
||||
}, { rootMargin: '200px' })
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [near])
|
||||
|
||||
useEffect(() => {
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
if (hovered) void v.play().catch(() => {})
|
||||
else { v.pause(); v.currentTime = 0 }
|
||||
}, [hovered])
|
||||
|
||||
// Keep the variant picker visible while another variant downloads.
|
||||
const showVariantPicker = hasVariants && status.kind !== 'downloading'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`flex flex-col overflow-hidden rounded-xl border transition-colors ${isSelected ? 'border-amber-500/60 bg-amber-500/10' : 'border-zinc-700 bg-zinc-800/50'}`}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{/* Media */}
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden bg-zinc-950">
|
||||
{item.demoVideoUrl ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={near || hovered ? item.demoVideoUrl : undefined}
|
||||
poster={item.thumbnailUrl}
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : item.thumbnailUrl ? (
|
||||
<img src={item.thumbnailUrl} alt={item.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-zinc-800 to-zinc-900">
|
||||
<Sparkles className="h-6 w-6 text-zinc-700" />
|
||||
</div>
|
||||
)}
|
||||
{size && (
|
||||
<span className="absolute bottom-1.5 right-1.5 rounded bg-black/60 px-1.5 py-0.5 text-[10px] text-zinc-300">{size}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-1 flex-col gap-1.5 p-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium text-white" title={item.title}>{item.title}</span>
|
||||
{infoSlot}
|
||||
</div>
|
||||
{item.description && <div className="line-clamp-2 text-[11px] text-zinc-500">{item.description}</div>}
|
||||
{(item.author || item.license) && (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] text-zinc-500">
|
||||
{item.author && (
|
||||
item.author.url
|
||||
? <button onClick={() => openUrl(item.author!.url)} className="text-zinc-400 hover:text-white transition-colors">by {item.author.name}</button>
|
||||
: <span>by {item.author.name}</span>
|
||||
)}
|
||||
{item.license && (
|
||||
item.license.url
|
||||
? <button onClick={() => openUrl(item.license!.url)} className="rounded bg-zinc-700/60 px-1.5 py-0.5 text-zinc-300 hover:bg-zinc-700 transition-colors">{item.license.name}</button>
|
||||
: <span className="rounded bg-zinc-700/60 px-1.5 py-0.5 text-zinc-300">{item.license.name}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer: status / action */}
|
||||
<div className="mt-auto pt-1.5 flex flex-col gap-1.5">
|
||||
{showVariantPicker && (
|
||||
<select
|
||||
value={variantId}
|
||||
onChange={(e) => setVariantId(e.target.value)}
|
||||
className="w-full rounded bg-zinc-900 border border-zinc-700 px-2 py-1 text-[11px] text-zinc-200"
|
||||
aria-label={`${item.title} variant`}
|
||||
>
|
||||
{item.variants!.map(v => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.downloaded ? `${v.label} ✓` : v.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{status.kind === 'downloading' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="inline-flex items-center gap-1 text-xs text-zinc-400">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> {Math.round(status.progress)}%
|
||||
</span>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-zinc-800">
|
||||
<div className="h-full bg-blue-500 transition-all duration-300" style={{ width: `${status.progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status.kind === 'error' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-[11px] text-red-400" title={status.message}>
|
||||
{status.gated ? 'This model is gated — request access, then retry.' : status.message}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{status.gated && (
|
||||
<button
|
||||
onClick={() => onRequestAccess?.(item.id)}
|
||||
className="inline-flex items-center gap-1 rounded bg-amber-600 px-2 py-1 text-[11px] font-medium text-white transition-colors hover:bg-amber-500"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" /> Request access
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onRetry?.(item.id, hasVariants ? variantId : undefined)}
|
||||
className="inline-flex items-center gap-1 rounded bg-zinc-700 px-2 py-1 text-[11px] font-medium text-zinc-200 transition-colors hover:bg-zinc-600"
|
||||
>
|
||||
<Download className="h-3 w-3" /> Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status.kind !== 'downloading' && status.kind !== 'error' && selectedVariantReady && (
|
||||
<button
|
||||
onClick={() => onUse?.(item.id, hasVariants ? variantId : undefined)}
|
||||
className={`inline-flex w-full items-center justify-center gap-1 rounded px-3 py-1.5 text-xs font-medium transition-colors ${isSelected ? 'bg-amber-600 text-white' : 'bg-zinc-700 text-zinc-200 hover:bg-zinc-600'}`}
|
||||
>
|
||||
{isSelected ? <><Check className="h-3 w-3" /> Selected</> : 'Use'}
|
||||
</button>
|
||||
)}
|
||||
{status.kind !== 'downloading' && status.kind !== 'error' && !selectedVariantReady && (
|
||||
<button
|
||||
onClick={() => onDownload?.(item.id, hasVariants ? variantId : undefined)}
|
||||
className="inline-flex w-full items-center justify-center gap-1 rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500"
|
||||
>
|
||||
<Download className="h-3 w-3" /> Download
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
interface LibraryModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
headerSlot?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
// Generic library dialog shell — no asset/recipe knowledge. A future regular-LoRA
|
||||
// library reuses it.
|
||||
export function LibraryModal({ open, onClose, title, headerSlot, children }: LibraryModalProps) {
|
||||
const ref = useRef<HTMLDialogElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
if (open && !el.open) el.showModal()
|
||||
else if (!open && el.open) el.close()
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
onClose={onClose}
|
||||
// Clicking the backdrop targets the <dialog> element itself (content is the inner div).
|
||||
onClick={(e) => { if (e.target === ref.current) onClose() }}
|
||||
className="m-auto w-[min(960px,calc(100%-2rem))] max-h-[80vh] overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-900 p-0 text-white backdrop:bg-black/60"
|
||||
>
|
||||
{/* Flex column so only the body scrolls — no hardcoded header-height calc. */}
|
||||
<div className="flex max-h-[80vh] flex-col">
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-zinc-800 px-4 py-3">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{headerSlot}
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="rounded-md p-1.5 text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">{children}</div>
|
||||
</div>
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { AlertCircle, Check, Download, Folder, HardDrive, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useAppSettings } from '../../contexts/AppSettingsContext'
|
||||
import { useHfAuth } from '../../hooks/use-hf-auth'
|
||||
import { useHfModelAccess } from '../../hooks/use-hf-model-access'
|
||||
import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../../lib/api-client'
|
||||
import { formatBytes } from '../../lib/format'
|
||||
import { logger } from '../../lib/logger'
|
||||
import { HfModelAccessGate } from '../HfModelAccessGate'
|
||||
import { Button } from '../ui/button'
|
||||
|
||||
type LtxModelVersionItem = ApiSuccessOf<'getLtxVersions'>['versions'][number]
|
||||
type ModelCheckpointID = NonNullable<
|
||||
NonNullable<ApiRequestBodyOf<'checkModelAccess'>>['cp_ids']
|
||||
>[number]
|
||||
type HfAuthStatus = ApiSuccessOf<'getHuggingFaceAuthStatus'>['status']
|
||||
|
||||
const DOWNLOAD_POLL_INTERVAL_MS = 1000
|
||||
|
||||
function VersionRow({
|
||||
version,
|
||||
onChanged,
|
||||
resumeSessionId,
|
||||
hfAuthStatus,
|
||||
hfAuthPolling,
|
||||
startHuggingFaceLogin,
|
||||
}: {
|
||||
version: LtxModelVersionItem
|
||||
onChanged: () => Promise<void>
|
||||
resumeSessionId: string | null
|
||||
hfAuthStatus: HfAuthStatus
|
||||
hfAuthPolling: boolean
|
||||
startHuggingFaceLogin: () => void
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [downloadSessionId, setDownloadSessionId] = useState<string | null>(null)
|
||||
const [downloadPercent, setDownloadPercent] = useState(0)
|
||||
const mountedRef = useRef(true)
|
||||
useEffect(() => () => { mountedRef.current = false }, [])
|
||||
|
||||
const cpsToDownload = useMemo(
|
||||
() => (version.installed ? [] : (version.cps_to_download as ModelCheckpointID[])),
|
||||
[version.installed, version.cps_to_download],
|
||||
)
|
||||
const { accessMap, allAuthorized, checking: checkingAccess, checkError, recheckAccess } = useHfModelAccess(
|
||||
cpsToDownload,
|
||||
hfAuthStatus,
|
||||
)
|
||||
const canDownload = version.installed || (allAuthorized && !checkingAccess)
|
||||
|
||||
const adoptedRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (resumeSessionId && resumeSessionId !== adoptedRef.current && !downloadSessionId) {
|
||||
adoptedRef.current = resumeSessionId
|
||||
setDownloadSessionId(resumeSessionId)
|
||||
setBusy(true)
|
||||
}
|
||||
}, [resumeSessionId, downloadSessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!downloadSessionId) return
|
||||
let cancelled = false
|
||||
|
||||
const poll = async () => {
|
||||
const result = await ApiClient.getModelDownloadProgress({ sessionId: downloadSessionId })
|
||||
if (cancelled) return
|
||||
if (!result.ok) {
|
||||
logger.error(`Progress poll error: ${result.error.message}`)
|
||||
return
|
||||
}
|
||||
const progress = result.data
|
||||
if (progress.status === 'downloading') {
|
||||
setDownloadPercent(Math.round(progress.total_progress))
|
||||
return
|
||||
}
|
||||
if (progress.status === 'error') {
|
||||
setDownloadSessionId(null)
|
||||
setBusy(false)
|
||||
setError(progress.error || 'Download failed.')
|
||||
return
|
||||
}
|
||||
if (progress.status === 'complete') {
|
||||
setDownloadSessionId(null)
|
||||
setDownloadPercent(100)
|
||||
await onChanged()
|
||||
if (!cancelled) setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
void poll()
|
||||
const interval = setInterval(() => void poll(), DOWNLOAD_POLL_INTERVAL_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [downloadSessionId, onChanged])
|
||||
|
||||
const handleSetActive = useCallback(async () => {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
const result = await ApiClient.setActiveLtxModel({ model_id: version.model_id })
|
||||
if (!result.ok) {
|
||||
setBusy(false)
|
||||
setError(result.error.message || 'Failed to set active model.')
|
||||
return
|
||||
}
|
||||
await onChanged()
|
||||
if (mountedRef.current) setBusy(false)
|
||||
}, [version.model_id, onChanged])
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
setDownloadPercent(0)
|
||||
const result = await ApiClient.startModelDownload({ type: 'download', cp_ids: version.cps_to_download })
|
||||
if (!result.ok) {
|
||||
setBusy(false)
|
||||
setError(result.error.message || 'Failed to start download.')
|
||||
return
|
||||
}
|
||||
if (result.data.status !== 'started') {
|
||||
setBusy(false)
|
||||
setError('Unexpected response while starting download.')
|
||||
return
|
||||
}
|
||||
setDownloadSessionId(result.data.sessionId)
|
||||
}, [version.cps_to_download])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
const result = await ApiClient.deleteModels({ cp_ids: [version.model_cp] })
|
||||
if (!result.ok) {
|
||||
setBusy(false)
|
||||
setError(result.error.message || 'Failed to delete model.')
|
||||
return
|
||||
}
|
||||
await onChanged()
|
||||
if (mountedRef.current) setBusy(false)
|
||||
}, [version.model_cp, onChanged])
|
||||
|
||||
const isDownloading = downloadSessionId !== null
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-800/50 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<input
|
||||
type="radio"
|
||||
name="active-ltx-model"
|
||||
checked={version.active}
|
||||
disabled={!version.installed || version.active || busy}
|
||||
onChange={() => void handleSetActive()}
|
||||
className="h-4 w-4 accent-blue-500 flex-shrink-0 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="text-sm text-white truncate">{version.label}</span>
|
||||
{version.active && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-400 inline-flex items-center gap-1 flex-shrink-0">
|
||||
<Check className="h-3 w-3" />
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
{version.is_newest && !version.installed && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 flex-shrink-0">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{!version.installed && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleDownload()}
|
||||
disabled={busy || !canDownload}
|
||||
className="bg-blue-600 hover:bg-blue-500 text-white text-xs"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{isDownloading ? `Downloading… ${downloadPercent}%` : `Download ${formatBytes(version.size_bytes)}`}
|
||||
</Button>
|
||||
)}
|
||||
{version.installed && !version.active && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={busy}
|
||||
className="border-zinc-700 text-zinc-300 hover:text-red-400 text-xs"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!version.installed && (
|
||||
<HfModelAccessGate
|
||||
accessMap={accessMap}
|
||||
allAuthorized={allAuthorized}
|
||||
hfAuthStatus={hfAuthStatus}
|
||||
hfAuthPolling={hfAuthPolling}
|
||||
startHuggingFaceLogin={startHuggingFaceLogin}
|
||||
checkError={checkError}
|
||||
onRetryCheck={recheckAccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 inline-flex items-center gap-1.5">
|
||||
<AlertCircle className="h-3 w-3 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BaseModelSection() {
|
||||
const [versions, setVersions] = useState<LtxModelVersionItem[]>([])
|
||||
const [modelsDir, setModelsDir] = useState('')
|
||||
const [activeDownload, setActiveDownload] = useState<{ sessionId: string; cpIds: string[] } | null>(null)
|
||||
const { hfAuthStatus, hfAuthPolling, startHuggingFaceLogin } = useHfAuth(true)
|
||||
const { notifyModelsChanged } = useAppSettings()
|
||||
const knownActiveRef = useRef<string | null>(null)
|
||||
|
||||
const refreshVersions = useCallback(async () => {
|
||||
const [versionsResult, activeResult] = await Promise.all([
|
||||
ApiClient.getLtxVersions(),
|
||||
ApiClient.getActiveDownload(),
|
||||
])
|
||||
if (!versionsResult.ok) {
|
||||
logger.error(`Failed to fetch LTX versions: ${versionsResult.error.message}`)
|
||||
return
|
||||
}
|
||||
setVersions(versionsResult.data.versions)
|
||||
// Signal only on a real change so mounting the panel doesn't refetch generation specs.
|
||||
const nextActive = versionsResult.data.versions.find((item) => item.active)?.model_id ?? null
|
||||
const nextKey = `${nextActive}|${versionsResult.data.versions.filter((item) => item.installed).map((item) => item.model_id).join(',')}`
|
||||
if (knownActiveRef.current !== null && knownActiveRef.current !== nextKey) {
|
||||
notifyModelsChanged()
|
||||
}
|
||||
knownActiveRef.current = nextKey
|
||||
if (activeResult.ok) {
|
||||
setActiveDownload(
|
||||
activeResult.data.session_id
|
||||
? { sessionId: activeResult.data.session_id, cpIds: activeResult.data.cp_ids ?? [] }
|
||||
: null,
|
||||
)
|
||||
}
|
||||
}, [notifyModelsChanged])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshVersions()
|
||||
void (async () => {
|
||||
const result = await ApiClient.getSettings()
|
||||
if (!result.ok) {
|
||||
logger.error(`Failed to fetch settings: ${result.error.message}`)
|
||||
return
|
||||
}
|
||||
setModelsDir(result.data.modelsDir ?? '')
|
||||
})()
|
||||
}, [refreshVersions])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="h-4 w-4 text-blue-400" />
|
||||
<h3 className="text-sm font-semibold text-white">Models Folder</h3>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 leading-relaxed">
|
||||
Where model checkpoints are stored. Changing the location requires restarting the app.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-zinc-300 text-sm truncate select-text">
|
||||
{modelsDir || <span className="text-zinc-600">Not set</span>}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-zinc-700 flex-shrink-0"
|
||||
onClick={async () => {
|
||||
const result = await window.electronAPI.openModelsDirChangeDialog()
|
||||
if (result.success) {
|
||||
setModelsDir(result.path)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Change…
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-zinc-700 flex-shrink-0"
|
||||
disabled={!modelsDir}
|
||||
onClick={() => {
|
||||
void window.electronAPI.openModelsFolder()
|
||||
}}
|
||||
>
|
||||
Open folder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-4 border-t border-zinc-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4 text-blue-400" />
|
||||
<h3 className="text-sm font-semibold text-white">Base Model</h3>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 leading-relaxed">
|
||||
The active version is used for new generations. Download a version to make it available,
|
||||
then set it active. Newer versions may require a Hugging Face sign-in.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{versions.length === 0 ? (
|
||||
<div className="text-xs text-zinc-600">No versions available.</div>
|
||||
) : (
|
||||
versions.map((version) => (
|
||||
<VersionRow
|
||||
key={version.model_id}
|
||||
version={version}
|
||||
onChanged={refreshVersions}
|
||||
resumeSessionId={
|
||||
activeDownload && activeDownload.cpIds.includes(version.model_cp)
|
||||
? activeDownload.sessionId
|
||||
: null
|
||||
}
|
||||
hfAuthStatus={hfAuthStatus}
|
||||
hfAuthPolling={hfAuthPolling}
|
||||
startHuggingFaceLogin={() => {
|
||||
void startHuggingFaceLogin()
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive: 'bg-red-500 text-white shadow-sm hover:bg-red-600',
|
||||
outline: 'border border-border bg-transparent shadow-sm hover:bg-secondary',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-secondary hover:text-secondary-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value?: number
|
||||
max?: number
|
||||
showLabel?: boolean
|
||||
}
|
||||
|
||||
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
||||
({ className, value = 0, max = 100, showLabel = false, ...props }, ref) => {
|
||||
const percentage = Math.min(100, Math.max(0, (value / max) * 100))
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative h-2 w-full overflow-hidden rounded-full bg-secondary',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300 ease-in-out"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
{showLabel && (
|
||||
<span className="text-xs text-muted-foreground mt-1 block text-right">
|
||||
{Math.round(percentage)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Progress.displayName = 'Progress'
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
|
||||
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string
|
||||
badge?: string
|
||||
}
|
||||
|
||||
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, label, badge, children, ...props }, ref) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
{label && (
|
||||
<label className="flex items-center gap-2 text-[12px] font-semibold text-zinc-500 mb-2 uppercase leading-4 h-4">
|
||||
{label}
|
||||
{badge && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] bg-zinc-700 text-zinc-300 rounded border border-zinc-600 font-semibold leading-none">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<select
|
||||
className={cn(
|
||||
'flex h-10 w-full appearance-none rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-white',
|
||||
'focus:outline-none focus:ring-1 focus:ring-zinc-500 focus:border-zinc-500',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'pr-8 cursor-pointer',
|
||||
'[&>option]:bg-zinc-800 [&>option]:text-white [&>option:disabled]:text-zinc-500',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Select.displayName = 'Select'
|
||||
|
||||
export { Select }
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string
|
||||
helperText?: string
|
||||
charCount?: number
|
||||
maxChars?: number
|
||||
}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, label, helperText, charCount, maxChars, ...props }, ref) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-[12px] font-semibold text-zinc-500 mb-2 uppercase leading-4">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[120px] w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-3 text-sm text-white',
|
||||
'placeholder:text-zinc-500',
|
||||
'focus:outline-none focus:ring-1 focus:ring-zinc-500 focus:border-zinc-500',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'resize-y',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
<div className="flex justify-between mt-2">
|
||||
{helperText && (
|
||||
<span className="text-xs text-zinc-500">{helperText}</span>
|
||||
)}
|
||||
{maxChars !== undefined && (
|
||||
<span className="text-xs text-zinc-500 ml-auto">
|
||||
{charCount ?? 0}/{maxChars}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,75 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TooltipProps {
|
||||
content: React.ReactNode
|
||||
children: React.ReactNode
|
||||
/** Which side of the trigger the tooltip appears on. Default: 'top' */
|
||||
side?: 'top' | 'bottom' | 'left' | 'right'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DELAY_MS = 500
|
||||
const GAP_PX = 6
|
||||
|
||||
/**
|
||||
* Styled tooltip with 250ms show delay and instant hide.
|
||||
* Renders via a portal into document.body so it is never clipped
|
||||
* by overflow-hidden ancestors.
|
||||
*/
|
||||
export function Tooltip({ content, children, side = 'top', className }: TooltipProps) {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [style, setStyle] = useState<React.CSSProperties>({})
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const computeStyle = useCallback(() => {
|
||||
const rect = wrapperRef.current?.getBoundingClientRect()
|
||||
if (!rect) return {}
|
||||
switch (side) {
|
||||
case 'top':
|
||||
return { left: rect.left + rect.width / 2, top: rect.top - GAP_PX, transform: 'translate(-50%, -100%)' }
|
||||
case 'bottom':
|
||||
return { left: rect.left + rect.width / 2, top: rect.bottom + GAP_PX, transform: 'translate(-50%, 0)' }
|
||||
case 'left':
|
||||
return { left: rect.left - GAP_PX, top: rect.top + rect.height / 2, transform: 'translate(-100%, -50%)' }
|
||||
case 'right':
|
||||
return { left: rect.right + GAP_PX, top: rect.top + rect.height / 2, transform: 'translate(0, -50%)' }
|
||||
}
|
||||
}, [side])
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
timerRef.current = setTimeout(() => {
|
||||
setStyle(computeStyle() ?? {})
|
||||
setVisible(true)
|
||||
}, DELAY_MS)
|
||||
}, [computeStyle])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
|
||||
setVisible(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current) }, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={cn('inline-flex', className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
{visible && ReactDOM.createPortal(
|
||||
<div
|
||||
className="fixed z-[99999] px-2.5 py-1.5 bg-white text-zinc-800 text-xs font-medium rounded-md shadow-md whitespace-nowrap pointer-events-none select-none"
|
||||
style={style}
|
||||
>
|
||||
{content}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { resetBackendCredentials } from '../lib/backend'
|
||||
import { ApiClient, type ApiSuccessOf } from '../lib/api-client'
|
||||
|
||||
export interface AppSettings {
|
||||
useTorchCompile: boolean
|
||||
diffusionStageCacheEnabled: boolean
|
||||
hasLtxApiKey: boolean
|
||||
userPrefersLtxApiVideoGenerations: boolean
|
||||
hasFalApiKey: boolean
|
||||
userPrefersFalApiImageGenerations: boolean
|
||||
hasGeminiApiKey: boolean
|
||||
geminiModel: string
|
||||
useLocalTextEncoder: boolean
|
||||
promptCacheSize: number
|
||||
promptEnhancerEnabledT2V: boolean
|
||||
promptEnhancerEnabledI2V: boolean
|
||||
// The user's explicit prompt-enhancer provider choice, persisted so it survives restarts.
|
||||
// null means no active choice yet — the enhancer defaults to whichever provider is available
|
||||
// without writing that default back here; only an explicit pick (never an automatic fallback
|
||||
// when the preferred provider is temporarily unavailable) sets this.
|
||||
promptEnhancerProviderPreference: 'local' | 'api' | null
|
||||
seedLocked: boolean
|
||||
lockedSeed: number
|
||||
modelsDir: string
|
||||
useConvVae: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_GEMINI_MODEL = 'gemini-3.5-flash-lite'
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
useTorchCompile: false,
|
||||
diffusionStageCacheEnabled: false,
|
||||
hasLtxApiKey: false,
|
||||
userPrefersLtxApiVideoGenerations: false,
|
||||
hasFalApiKey: false,
|
||||
userPrefersFalApiImageGenerations: false,
|
||||
hasGeminiApiKey: false,
|
||||
geminiModel: '',
|
||||
useLocalTextEncoder: false,
|
||||
promptCacheSize: 1,
|
||||
promptEnhancerEnabledT2V: false,
|
||||
promptEnhancerEnabledI2V: false,
|
||||
promptEnhancerProviderPreference: null,
|
||||
seedLocked: false,
|
||||
lockedSeed: 42,
|
||||
modelsDir: '',
|
||||
useConvVae: false,
|
||||
}
|
||||
|
||||
type BackendProcessStatus = 'alive' | 'restarting' | 'dead'
|
||||
|
||||
interface AppSettingsContextValue {
|
||||
settings: AppSettings
|
||||
isLoaded: boolean
|
||||
runtimePolicyLoaded: boolean
|
||||
updateSettings: (patch: Partial<AppSettings> | ((prev: AppSettings) => AppSettings)) => void
|
||||
refreshSettings: () => Promise<void>
|
||||
saveLtxApiKey: (value: string) => Promise<void>
|
||||
saveFalApiKey: (value: string) => Promise<void>
|
||||
saveGeminiApiKey: (value: string) => Promise<void>
|
||||
forceApiGenerations: boolean
|
||||
shouldVideoGenerateWithLtxApi: boolean
|
||||
shouldImageGenerateWithFalApi: boolean
|
||||
cudaAvailable: boolean
|
||||
// Bumped whenever installed models change (download / delete / activate a version). Generation
|
||||
// model specs are derived from the *active* local model, so anything reading them must refetch;
|
||||
// without this they stay pinned to whatever was installed at app start.
|
||||
modelsVersion: number
|
||||
notifyModelsChanged: () => void
|
||||
}
|
||||
|
||||
const AppSettingsContext = createContext<AppSettingsContextValue | null>(null)
|
||||
|
||||
function toBackendProcessStatus(value: unknown): BackendProcessStatus | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const record = value as { status?: unknown }
|
||||
if (record.status === 'alive' || record.status === 'restarting' || record.status === 'dead') {
|
||||
return record.status
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeAppSettings(data: Partial<AppSettings>): AppSettings {
|
||||
return {
|
||||
useTorchCompile: data.useTorchCompile ?? DEFAULT_APP_SETTINGS.useTorchCompile,
|
||||
diffusionStageCacheEnabled: data.diffusionStageCacheEnabled ?? DEFAULT_APP_SETTINGS.diffusionStageCacheEnabled,
|
||||
hasLtxApiKey: data.hasLtxApiKey ?? DEFAULT_APP_SETTINGS.hasLtxApiKey,
|
||||
userPrefersLtxApiVideoGenerations: data.userPrefersLtxApiVideoGenerations ?? DEFAULT_APP_SETTINGS.userPrefersLtxApiVideoGenerations,
|
||||
hasFalApiKey: data.hasFalApiKey ?? DEFAULT_APP_SETTINGS.hasFalApiKey,
|
||||
userPrefersFalApiImageGenerations: data.userPrefersFalApiImageGenerations ?? DEFAULT_APP_SETTINGS.userPrefersFalApiImageGenerations,
|
||||
hasGeminiApiKey: data.hasGeminiApiKey ?? DEFAULT_APP_SETTINGS.hasGeminiApiKey,
|
||||
geminiModel: data.geminiModel ?? DEFAULT_APP_SETTINGS.geminiModel,
|
||||
useLocalTextEncoder: data.useLocalTextEncoder ?? DEFAULT_APP_SETTINGS.useLocalTextEncoder,
|
||||
promptCacheSize: data.promptCacheSize ?? DEFAULT_APP_SETTINGS.promptCacheSize,
|
||||
promptEnhancerEnabledT2V: data.promptEnhancerEnabledT2V ?? DEFAULT_APP_SETTINGS.promptEnhancerEnabledT2V,
|
||||
promptEnhancerEnabledI2V: data.promptEnhancerEnabledI2V ?? DEFAULT_APP_SETTINGS.promptEnhancerEnabledI2V,
|
||||
promptEnhancerProviderPreference: data.promptEnhancerProviderPreference ?? DEFAULT_APP_SETTINGS.promptEnhancerProviderPreference,
|
||||
seedLocked: data.seedLocked ?? DEFAULT_APP_SETTINGS.seedLocked,
|
||||
lockedSeed: data.lockedSeed ?? DEFAULT_APP_SETTINGS.lockedSeed,
|
||||
modelsDir: data.modelsDir ?? DEFAULT_APP_SETTINGS.modelsDir,
|
||||
useConvVae: data.useConvVae ?? DEFAULT_APP_SETTINGS.useConvVae,
|
||||
}
|
||||
}
|
||||
|
||||
type RuntimePolicyPayload = ApiSuccessOf<'getRuntimePolicy'>
|
||||
type GpuInfoPayload = ApiSuccessOf<'getGpuInfo'>
|
||||
|
||||
export function AppSettingsProvider({ children }: { children: ReactNode }) {
|
||||
const [settings, setSettings] = useState<AppSettings>(DEFAULT_APP_SETTINGS)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const [runtimePolicyLoaded, setRuntimePolicyLoaded] = useState(false)
|
||||
const [forceApiGenerations, setForceApiGenerations] = useState(true)
|
||||
const [cudaAvailable, setCudaAvailable] = useState(false)
|
||||
const [backendProcessStatus, setBackendProcessStatus] = useState<BackendProcessStatus | null>(null)
|
||||
const [modelsVersion, setModelsVersion] = useState(0)
|
||||
const [settingsRevision, setSettingsRevision] = useState(0)
|
||||
|
||||
const notifyModelsChanged = useCallback(() => {
|
||||
setModelsVersion((current) => current + 1)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (backendProcessStatus !== 'alive') return
|
||||
|
||||
let cancelled = false
|
||||
setRuntimePolicyLoaded(false)
|
||||
|
||||
const fetchRuntimePolicy = async () => {
|
||||
const result = await ApiClient.getRuntimePolicy()
|
||||
if (!result.ok) {
|
||||
if (!cancelled) {
|
||||
// Fail closed until policy can be read.
|
||||
setForceApiGenerations(true)
|
||||
setRuntimePolicyLoaded(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const payload = result.data as RuntimePolicyPayload
|
||||
if (typeof payload.force_api_generations !== 'boolean') {
|
||||
if (!cancelled) {
|
||||
setForceApiGenerations(true)
|
||||
}
|
||||
} else if (!cancelled) {
|
||||
setForceApiGenerations(payload.force_api_generations)
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setRuntimePolicyLoaded(true)
|
||||
}
|
||||
}
|
||||
|
||||
void fetchRuntimePolicy()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [backendProcessStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (backendProcessStatus !== 'alive') return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const fetchGpuInfo = async () => {
|
||||
const result = await ApiClient.getGpuInfo()
|
||||
if (!result.ok || cancelled) return
|
||||
|
||||
const payload = result.data as GpuInfoPayload
|
||||
setCudaAvailable(Boolean(payload.cuda_available))
|
||||
}
|
||||
|
||||
void fetchGpuInfo()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [backendProcessStatus, modelsVersion])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const applyStatus = (value: unknown) => {
|
||||
const nextStatus = toBackendProcessStatus(value)
|
||||
if (!nextStatus || cancelled) {
|
||||
return
|
||||
}
|
||||
if (nextStatus === 'alive') {
|
||||
resetBackendCredentials()
|
||||
}
|
||||
setBackendProcessStatus(nextStatus)
|
||||
}
|
||||
|
||||
const unsubscribe = window.electronAPI.onBackendHealthStatus((data) => {
|
||||
applyStatus(data)
|
||||
})
|
||||
|
||||
void window.electronAPI.getBackendHealthStatus()
|
||||
.then((snapshot) => {
|
||||
applyStatus(snapshot)
|
||||
})
|
||||
.catch(() => {
|
||||
// Snapshot is optional at startup; subscription continues to listen for pushes.
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshSettings = useCallback(async () => {
|
||||
const result = await ApiClient.getSettings()
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error.message)
|
||||
}
|
||||
setSettings(normalizeAppSettings(result.data))
|
||||
setIsLoaded(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoaded || backendProcessStatus !== 'alive') return
|
||||
|
||||
let cancelled = false
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
await refreshSettings()
|
||||
if (cancelled) return
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
retryTimer = setTimeout(fetchSettings, 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchSettings()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
}
|
||||
}, [backendProcessStatus, isLoaded, refreshSettings])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoaded || backendProcessStatus !== 'alive' || settingsRevision === 0) return
|
||||
const syncTimer = setTimeout(async () => {
|
||||
const { hasLtxApiKey: _a, hasFalApiKey: _b, hasGeminiApiKey: _c, modelsDir: _d, ...syncPayload } = settings
|
||||
const result = await ApiClient.updateSettings(syncPayload)
|
||||
if (!result.ok) {
|
||||
// Best-effort settings sync.
|
||||
}
|
||||
}, 150)
|
||||
return () => clearTimeout(syncTimer)
|
||||
}, [backendProcessStatus, isLoaded, settings, settingsRevision])
|
||||
|
||||
const updateSettings = useCallback((patch: Partial<AppSettings> | ((prev: AppSettings) => AppSettings)) => {
|
||||
setSettingsRevision(value => value + 1)
|
||||
if (typeof patch === 'function') {
|
||||
setSettings((prev) => patch(prev))
|
||||
return
|
||||
}
|
||||
setSettings((prev) => ({ ...prev, ...patch }))
|
||||
}, [])
|
||||
|
||||
const saveLtxApiKey = useCallback(async (value: string) => {
|
||||
const result = await ApiClient.updateSettings({ ltxApiKey: value })
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error.message)
|
||||
}
|
||||
await refreshSettings()
|
||||
}, [refreshSettings])
|
||||
|
||||
const saveGeminiApiKey = useCallback(async (value: string) => {
|
||||
const result = await ApiClient.updateSettings({ geminiApiKey: value })
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error.message)
|
||||
}
|
||||
await refreshSettings()
|
||||
}, [refreshSettings])
|
||||
|
||||
const saveFalApiKey = useCallback(async (value: string) => {
|
||||
const result = await ApiClient.updateSettings({ falApiKey: value })
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error.message)
|
||||
}
|
||||
await refreshSettings()
|
||||
}, [refreshSettings])
|
||||
|
||||
const shouldVideoGenerateWithLtxApi =
|
||||
forceApiGenerations || (settings.userPrefersLtxApiVideoGenerations && settings.hasLtxApiKey)
|
||||
const shouldImageGenerateWithFalApi =
|
||||
forceApiGenerations || (settings.userPrefersFalApiImageGenerations && settings.hasFalApiKey)
|
||||
|
||||
const contextValue = useMemo<AppSettingsContextValue>(
|
||||
() => ({
|
||||
settings,
|
||||
isLoaded,
|
||||
runtimePolicyLoaded,
|
||||
updateSettings,
|
||||
refreshSettings,
|
||||
saveLtxApiKey,
|
||||
saveFalApiKey,
|
||||
saveGeminiApiKey,
|
||||
forceApiGenerations,
|
||||
shouldVideoGenerateWithLtxApi,
|
||||
shouldImageGenerateWithFalApi,
|
||||
cudaAvailable,
|
||||
modelsVersion,
|
||||
notifyModelsChanged,
|
||||
}),
|
||||
[cudaAvailable, forceApiGenerations, isLoaded, modelsVersion, notifyModelsChanged, refreshSettings, runtimePolicyLoaded, saveFalApiKey, saveGeminiApiKey, saveLtxApiKey, settings, shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi, updateSettings],
|
||||
)
|
||||
|
||||
return <AppSettingsContext.Provider value={contextValue}>{children}</AppSettingsContext.Provider>
|
||||
}
|
||||
|
||||
export function useAppSettings() {
|
||||
const context = useContext(AppSettingsContext)
|
||||
if (!context) {
|
||||
throw new Error('useAppSettings must be used within AppSettingsProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||
|
||||
// Dev / escape-hatch feature flags. Client-only, per-machine (localStorage) —
|
||||
// not a user setting and not synced. Toggle via the Dev Panel (Ctrl/Cmd+Shift+D).
|
||||
// Adding a feature flag = one entry in DEV_FLAGS; read it from a single gate where
|
||||
// the feature is used (avoid sprinkling the flag across call sites).
|
||||
export type DevFlagKey = 'customIcLora' | 'advancedIcLoraControls' | 'enableMultipleKeyframesVideos'
|
||||
|
||||
interface DevFlagSpec {
|
||||
key: DevFlagKey
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const DEV_FLAGS: DevFlagSpec[] = [
|
||||
{
|
||||
key: 'customIcLora',
|
||||
label: 'Custom IC-LoRA',
|
||||
description: 'Show the "Custom IC-LoRA" conditioning option (user-supplied weights + control video). Off by default — local results are currently low quality. Built-in Canny/Depth IC-LoRA is unaffected.',
|
||||
},
|
||||
{
|
||||
key: 'advancedIcLoraControls',
|
||||
label: 'Advanced IC-LoRA controls',
|
||||
description: 'Expose all IC-LoRA settings (skip stage 2, resolution factor, audio, strengths) for catalog and custom modes. Off by default — settings use the catalog IC-LoRA defaults.',
|
||||
},
|
||||
{
|
||||
key: 'enableMultipleKeyframesVideos',
|
||||
label: 'Multiple keyframe videos',
|
||||
description: 'Show "Generate Multi Keyframes Videos" in the GenSpace mode dropdown (immediately after Generate Videos). Only available for local generations — hidden in API/cloud mode even when this flag is on. Off by default.',
|
||||
},
|
||||
]
|
||||
|
||||
type Flags = Record<DevFlagKey, boolean>
|
||||
|
||||
const DEFAULT_FLAGS: Flags = { customIcLora: false, advancedIcLoraControls: false, enableMultipleKeyframesVideos: false }
|
||||
const STORAGE_KEY = 'ltx-dev-flags'
|
||||
|
||||
function loadFlags(): Flags {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) return { ...DEFAULT_FLAGS, ...(JSON.parse(raw) as Partial<Flags>) }
|
||||
} catch { /* ignore */ }
|
||||
return { ...DEFAULT_FLAGS }
|
||||
}
|
||||
|
||||
interface DevFlagsState {
|
||||
flags: Flags
|
||||
setFlag: (key: DevFlagKey, on: boolean) => void
|
||||
isPanelOpen: boolean
|
||||
setPanelOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
const DevFlagsContext = createContext<DevFlagsState | null>(null)
|
||||
|
||||
export function DevFlagsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [flags, setFlags] = useState<Flags>(() => loadFlags())
|
||||
const [isPanelOpen, setPanelOpen] = useState(false)
|
||||
|
||||
const setFlag = useCallback((key: DevFlagKey, on: boolean) => {
|
||||
setFlags(prev => {
|
||||
const next = { ...prev, [key]: on }
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) } catch { /* ignore */ }
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Global toggle: Ctrl/Cmd + Shift + D. Capture phase so component-level keydown
|
||||
// handlers don't swallow it; preventDefault avoids any Electron/Chromium default.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'd') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setPanelOpen(prev => !prev)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey, { capture: true })
|
||||
return () => window.removeEventListener('keydown', onKey, { capture: true })
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DevFlagsContext.Provider value={{ flags, setFlag, isPanelOpen, setPanelOpen }}>
|
||||
{children}
|
||||
</DevFlagsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDevFlags(): DevFlagsState {
|
||||
const ctx = useContext(DevFlagsContext)
|
||||
if (!ctx) throw new Error('useDevFlags must be used within DevFlagsProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'
|
||||
import {
|
||||
KeyboardLayout,
|
||||
KeyboardPreset,
|
||||
BUILT_IN_PRESETS,
|
||||
LTX_DEFAULT_LAYOUT,
|
||||
cloneLayout,
|
||||
ActionId,
|
||||
} from '../lib/keyboard-shortcuts'
|
||||
|
||||
interface KeyboardShortcutsState {
|
||||
// Current active layout
|
||||
activeLayout: KeyboardLayout
|
||||
activePresetId: string
|
||||
// All presets (built-in + user)
|
||||
presets: KeyboardPreset[]
|
||||
// Actions
|
||||
switchPreset: (presetId: string) => void
|
||||
updateBinding: (actionId: ActionId, combos: KeyboardLayout[ActionId]) => void
|
||||
resetToPreset: (presetId: string) => void
|
||||
saveAsCustomPreset: (name: string) => void
|
||||
deleteCustomPreset: (presetId: string) => void
|
||||
// Whether the shortcuts editor modal is open
|
||||
isEditorOpen: boolean
|
||||
setEditorOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
const KeyboardShortcutsContext = createContext<KeyboardShortcutsState | null>(null)
|
||||
|
||||
const STORAGE_KEY = 'ltx-keyboard-shortcuts'
|
||||
|
||||
interface PersistedState {
|
||||
activePresetId: string
|
||||
customLayout?: KeyboardLayout // Only stored when user has modified bindings
|
||||
customPresets?: KeyboardPreset[]
|
||||
}
|
||||
|
||||
function loadFromStorage(): PersistedState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) return JSON.parse(raw)
|
||||
} catch { /* ignore */ }
|
||||
return null
|
||||
}
|
||||
|
||||
function saveToStorage(state: PersistedState) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsProvider({ children }: { children: React.ReactNode }) {
|
||||
const stored = useRef(loadFromStorage())
|
||||
|
||||
const [activePresetId, setActivePresetId] = useState<string>(stored.current?.activePresetId || 'ltx-default')
|
||||
const [customLayout, setCustomLayout] = useState<KeyboardLayout | null>(stored.current?.customLayout || null)
|
||||
const [customPresets, setCustomPresets] = useState<KeyboardPreset[]>(stored.current?.customPresets || [])
|
||||
const [isEditorOpen, setEditorOpen] = useState(false)
|
||||
|
||||
// Resolve active layout: if customLayout is set, use it; otherwise use the preset's layout
|
||||
const activeLayout: KeyboardLayout = customLayout
|
||||
|| [...BUILT_IN_PRESETS, ...customPresets].find(p => p.id === activePresetId)?.layout
|
||||
|| LTX_DEFAULT_LAYOUT
|
||||
|
||||
// Keep a ref to the active layout so updateBinding always reads the latest
|
||||
const activeLayoutRef = useRef(activeLayout)
|
||||
activeLayoutRef.current = activeLayout
|
||||
|
||||
const allPresets = [...BUILT_IN_PRESETS, ...customPresets]
|
||||
|
||||
// Persist whenever state changes
|
||||
useEffect(() => {
|
||||
saveToStorage({
|
||||
activePresetId,
|
||||
customLayout: customLayout || undefined,
|
||||
customPresets: customPresets.length > 0 ? customPresets : undefined,
|
||||
})
|
||||
}, [activePresetId, customLayout, customPresets])
|
||||
|
||||
const switchPreset = useCallback((presetId: string) => {
|
||||
setActivePresetId(presetId)
|
||||
setCustomLayout(null) // Clear any custom modifications — use the preset directly
|
||||
}, [])
|
||||
|
||||
const updateBinding = useCallback((actionId: ActionId, combos: KeyboardLayout[ActionId]) => {
|
||||
// Always read the LATEST layout from the ref to avoid stale closure issues
|
||||
setCustomLayout(prev => {
|
||||
const base = prev || cloneLayout(activeLayoutRef.current)
|
||||
const updated = { ...base, [actionId]: combos }
|
||||
return updated
|
||||
})
|
||||
}, []) // No deps needed — reads from ref
|
||||
|
||||
const resetToPreset = useCallback((presetId: string) => {
|
||||
setActivePresetId(presetId)
|
||||
setCustomLayout(null)
|
||||
}, [])
|
||||
|
||||
const saveAsCustomPreset = useCallback((name: string) => {
|
||||
const preset: KeyboardPreset = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name,
|
||||
description: 'Custom keyboard layout',
|
||||
layout: cloneLayout(activeLayoutRef.current),
|
||||
builtIn: false,
|
||||
}
|
||||
setCustomPresets(prev => [...prev, preset])
|
||||
setActivePresetId(preset.id)
|
||||
setCustomLayout(null)
|
||||
}, [])
|
||||
|
||||
const deleteCustomPreset = useCallback((presetId: string) => {
|
||||
setCustomPresets(prev => prev.filter(p => p.id !== presetId))
|
||||
if (activePresetId === presetId) {
|
||||
setActivePresetId('ltx-default')
|
||||
setCustomLayout(null)
|
||||
}
|
||||
}, [activePresetId])
|
||||
|
||||
return (
|
||||
<KeyboardShortcutsContext.Provider value={{
|
||||
activeLayout,
|
||||
activePresetId,
|
||||
presets: allPresets,
|
||||
switchPreset,
|
||||
updateBinding,
|
||||
resetToPreset,
|
||||
saveAsCustomPreset,
|
||||
deleteCustomPreset,
|
||||
isEditorOpen,
|
||||
setEditorOpen,
|
||||
}}>
|
||||
{children}
|
||||
</KeyboardShortcutsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
const ctx = useContext(KeyboardShortcutsContext)
|
||||
if (!ctx) throw new Error('useKeyboardShortcuts must be used inside KeyboardShortcutsProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import React, { createContext, useCallback, useContext, useState } from 'react'
|
||||
import { hasLegacyProjectsEntry } from '../hooks/useProjectReferencesMigration'
|
||||
import { createDefaultTimeline, normalizeProject, type Project, type Asset, type AssetTake, type ProjectTab } from '../types/project-model'
|
||||
import {
|
||||
deleteProjectEntry,
|
||||
readProject,
|
||||
readProjectIds,
|
||||
writeProject,
|
||||
writeProjectIds,
|
||||
} from '../lib/project-storage'
|
||||
|
||||
interface ProjectContextType {
|
||||
currentTab: ProjectTab
|
||||
setCurrentTab: (tab: ProjectTab) => void
|
||||
|
||||
projectIds: string[]
|
||||
activeProject: Project | null
|
||||
getProject: (id: string) => Project | null
|
||||
setProject: (id: string, project: Project) => void
|
||||
createProject: (name: string) => Project
|
||||
deleteProject: (id: string) => void
|
||||
renameProject: (id: string, name: string) => void
|
||||
activateProject: (id: string) => void
|
||||
clearActiveProject: () => void
|
||||
reloadProjectIds: () => void
|
||||
|
||||
addAsset: (projectId: string, asset: Omit<Asset, 'id' | 'createdAt'>) => Asset
|
||||
deleteAsset: (projectId: string, assetId: string) => void
|
||||
updateAsset: (projectId: string, assetId: string, updates: Partial<Asset>) => void
|
||||
addTakeToAsset: (projectId: string, assetId: string, take: AssetTake) => void
|
||||
deleteTakeFromAsset: (projectId: string, assetId: string, takeIndex: number) => void
|
||||
setAssetActiveTake: (projectId: string, assetId: string, takeIndex: number) => void
|
||||
toggleFavorite: (projectId: string, assetId: string) => void
|
||||
|
||||
genSpaceEditImagePath: string | null
|
||||
setGenSpaceEditImagePath: (path: string | null) => void
|
||||
genSpaceEditMode: 'image' | 'video' | null
|
||||
setGenSpaceEditMode: (mode: 'image' | 'video' | null) => void
|
||||
genSpaceAudioPath: string | null
|
||||
setGenSpaceAudioPath: (path: string | null) => void
|
||||
genSpaceRetakeSource: GenSpaceRetakeSource | null
|
||||
setGenSpaceRetakeSource: (source: GenSpaceRetakeSource | null) => void
|
||||
pendingRetakeUpdate: PendingRetakeUpdate | null
|
||||
setPendingRetakeUpdate: (update: PendingRetakeUpdate | null) => void
|
||||
genSpaceIcLoraSource: GenSpaceIcLoraSource | null
|
||||
setGenSpaceIcLoraSource: (source: GenSpaceIcLoraSource | null) => void
|
||||
pendingIcLoraUpdate: PendingIcLoraUpdate | null
|
||||
setPendingIcLoraUpdate: (update: PendingIcLoraUpdate | null) => void
|
||||
}
|
||||
|
||||
export interface GenSpaceRetakeSource {
|
||||
videoPath: string
|
||||
clipId?: string
|
||||
assetId?: string
|
||||
linkedClipIds?: string[]
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export interface PendingRetakeUpdate {
|
||||
assetId: string
|
||||
clipIds: string[]
|
||||
newTakeIndex: number
|
||||
}
|
||||
|
||||
export interface GenSpaceIcLoraSource {
|
||||
videoPath: string
|
||||
clipId?: string
|
||||
assetId?: string
|
||||
linkedClipIds?: string[]
|
||||
}
|
||||
|
||||
export interface PendingIcLoraUpdate {
|
||||
assetId: string
|
||||
clipIds: string[]
|
||||
newTakeIndex: number
|
||||
}
|
||||
|
||||
const ProjectContext = createContext<ProjectContextType | null>(null)
|
||||
|
||||
function loadInitialProjectIds(): string[] {
|
||||
if (hasLegacyProjectsEntry()) return []
|
||||
return readProjectIds()
|
||||
}
|
||||
|
||||
export function ProjectProvider({ children }: { children: React.ReactNode }) {
|
||||
const [currentTab, setCurrentTab] = useState<ProjectTab>('gen-space')
|
||||
const [projectIds, setProjectIds] = useState<string[]>(() => loadInitialProjectIds())
|
||||
const [activeProject, setActiveProject] = useState<Project | null>(null)
|
||||
const [projectRevision, setProjectRevision] = useState(0)
|
||||
const [genSpaceEditImagePath, setGenSpaceEditImagePath] = useState<string | null>(null)
|
||||
const [genSpaceEditMode, setGenSpaceEditMode] = useState<'image' | 'video' | null>(null)
|
||||
const [genSpaceAudioPath, setGenSpaceAudioPath] = useState<string | null>(null)
|
||||
const [genSpaceRetakeSource, setGenSpaceRetakeSource] = useState<GenSpaceRetakeSource | null>(null)
|
||||
const [pendingRetakeUpdate, setPendingRetakeUpdate] = useState<PendingRetakeUpdate | null>(null)
|
||||
const [genSpaceIcLoraSource, setGenSpaceIcLoraSource] = useState<GenSpaceIcLoraSource | null>(null)
|
||||
const [pendingIcLoraUpdate, setPendingIcLoraUpdate] = useState<PendingIcLoraUpdate | null>(null)
|
||||
|
||||
const bumpProjectRevision = useCallback(() => {
|
||||
setProjectRevision(prev => prev + 1)
|
||||
}, [])
|
||||
|
||||
const getProject = useCallback((id: string): Project | null => readProject(id), [projectRevision])
|
||||
|
||||
const reloadProjectIds = useCallback(() => {
|
||||
const nextProjectIds = hasLegacyProjectsEntry() ? [] : readProjectIds()
|
||||
setProjectIds(nextProjectIds)
|
||||
setActiveProject(prev => (
|
||||
prev && nextProjectIds.includes(prev.id) ? prev : null
|
||||
))
|
||||
bumpProjectRevision()
|
||||
}, [bumpProjectRevision])
|
||||
|
||||
const activateProject = useCallback((id: string) => {
|
||||
setActiveProject(readProject(id))
|
||||
}, [])
|
||||
|
||||
const clearActiveProject = useCallback(() => {
|
||||
setActiveProject(null)
|
||||
}, [])
|
||||
|
||||
const persistProject = useCallback((projectId: string, project: Project): Project => {
|
||||
const persistedProject = writeProject(projectId, normalizeProject({ ...project, id: projectId }))
|
||||
setActiveProject(prev => (prev?.id === projectId ? persistedProject : prev))
|
||||
bumpProjectRevision()
|
||||
return persistedProject
|
||||
}, [bumpProjectRevision])
|
||||
|
||||
const mutateProject = useCallback((projectId: string, updater: (project: Project) => Project): Project | null => {
|
||||
const project = readProject(projectId)
|
||||
if (!project) return null
|
||||
return persistProject(projectId, updater(project))
|
||||
}, [persistProject])
|
||||
|
||||
const setProject = useCallback((projectId: string, project: Project) => {
|
||||
persistProject(projectId, project)
|
||||
}, [persistProject])
|
||||
|
||||
const createProject = useCallback((name: string): Project => {
|
||||
const defaultTimeline = createDefaultTimeline('Timeline 1')
|
||||
const newProject = normalizeProject({
|
||||
id: `project-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
assets: [],
|
||||
timelines: [defaultTimeline],
|
||||
activeTimelineId: defaultTimeline.id,
|
||||
})
|
||||
|
||||
const persistedProject = writeProject(newProject.id, newProject)
|
||||
const nextProjectIds = [persistedProject.id, ...readProjectIds().filter(id => id !== persistedProject.id)]
|
||||
writeProjectIds(nextProjectIds)
|
||||
setProjectIds(nextProjectIds)
|
||||
bumpProjectRevision()
|
||||
return persistedProject
|
||||
}, [bumpProjectRevision])
|
||||
|
||||
const deleteProject = useCallback((id: string) => {
|
||||
const nextProjectIds = readProjectIds().filter(projectId => projectId !== id)
|
||||
writeProjectIds(nextProjectIds)
|
||||
setProjectIds(nextProjectIds)
|
||||
deleteProjectEntry(id)
|
||||
setActiveProject(prev => (prev?.id === id ? null : prev))
|
||||
bumpProjectRevision()
|
||||
}, [bumpProjectRevision])
|
||||
|
||||
const renameProject = useCallback((id: string, name: string) => {
|
||||
mutateProject(id, project => ({
|
||||
...project,
|
||||
name,
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
}, [mutateProject])
|
||||
|
||||
const addAsset = useCallback((projectId: string, assetData: Omit<Asset, 'id' | 'createdAt'>): Asset => {
|
||||
const newAsset: Asset = {
|
||||
...assetData,
|
||||
id: `asset-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
|
||||
mutateProject(projectId, project => ({
|
||||
...project,
|
||||
assets: [newAsset, ...project.assets],
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
|
||||
return newAsset
|
||||
}, [mutateProject])
|
||||
|
||||
const deleteAsset = useCallback((projectId: string, assetId: string) => {
|
||||
mutateProject(projectId, project => ({
|
||||
...project,
|
||||
assets: project.assets.filter(asset => asset.id !== assetId),
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
}, [mutateProject])
|
||||
|
||||
const updateAsset = useCallback((projectId: string, assetId: string, updates: Partial<Asset>) => {
|
||||
mutateProject(projectId, project => ({
|
||||
...project,
|
||||
assets: project.assets.map(asset => (
|
||||
asset.id === assetId ? { ...asset, ...updates } : asset
|
||||
)),
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
}, [mutateProject])
|
||||
|
||||
const addTakeToAsset = useCallback((projectId: string, assetId: string, take: AssetTake) => {
|
||||
mutateProject(projectId, project => ({
|
||||
...project,
|
||||
assets: project.assets.map(asset => {
|
||||
if (asset.id !== assetId) return asset
|
||||
|
||||
const existingTakes: AssetTake[] = asset.takes || [{
|
||||
path: asset.path,
|
||||
bigThumbnailPath: asset.bigThumbnailPath,
|
||||
smallThumbnailPath: asset.smallThumbnailPath,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
createdAt: asset.createdAt,
|
||||
}]
|
||||
const newTakes = [...existingTakes, take]
|
||||
const newIndex = newTakes.length - 1
|
||||
|
||||
return {
|
||||
...asset,
|
||||
takes: newTakes,
|
||||
activeTakeIndex: newIndex,
|
||||
path: take.path,
|
||||
bigThumbnailPath: take.bigThumbnailPath,
|
||||
smallThumbnailPath: take.smallThumbnailPath,
|
||||
width: take.width,
|
||||
height: take.height,
|
||||
}
|
||||
}),
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
}, [mutateProject])
|
||||
|
||||
const deleteTakeFromAsset = useCallback((projectId: string, assetId: string, takeIndex: number) => {
|
||||
mutateProject(projectId, project => ({
|
||||
...project,
|
||||
assets: project.assets.map(asset => {
|
||||
if (asset.id !== assetId || !asset.takes || asset.takes.length <= 1) return asset
|
||||
|
||||
const newTakes = asset.takes.filter((_, index) => index !== takeIndex)
|
||||
let newActiveIdx = asset.activeTakeIndex ?? newTakes.length - 1
|
||||
if (newActiveIdx >= newTakes.length) newActiveIdx = newTakes.length - 1
|
||||
if (newActiveIdx < 0) newActiveIdx = 0
|
||||
const activeTake = newTakes[newActiveIdx]
|
||||
|
||||
return {
|
||||
...asset,
|
||||
takes: newTakes,
|
||||
activeTakeIndex: newActiveIdx,
|
||||
path: activeTake.path,
|
||||
bigThumbnailPath: activeTake.bigThumbnailPath,
|
||||
smallThumbnailPath: activeTake.smallThumbnailPath,
|
||||
width: activeTake.width,
|
||||
height: activeTake.height,
|
||||
}
|
||||
}),
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
}, [mutateProject])
|
||||
|
||||
const setAssetActiveTake = useCallback((projectId: string, assetId: string, takeIndex: number) => {
|
||||
mutateProject(projectId, project => ({
|
||||
...project,
|
||||
assets: project.assets.map(asset => {
|
||||
if (asset.id !== assetId || !asset.takes) return asset
|
||||
|
||||
const nextIndex = Math.max(0, Math.min(takeIndex, asset.takes.length - 1))
|
||||
const take = asset.takes[nextIndex]
|
||||
|
||||
return {
|
||||
...asset,
|
||||
activeTakeIndex: nextIndex,
|
||||
path: take.path,
|
||||
bigThumbnailPath: take.bigThumbnailPath,
|
||||
smallThumbnailPath: take.smallThumbnailPath,
|
||||
width: take.width,
|
||||
height: take.height,
|
||||
}
|
||||
}),
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
}, [mutateProject])
|
||||
|
||||
const toggleFavorite = useCallback((projectId: string, assetId: string) => {
|
||||
mutateProject(projectId, project => ({
|
||||
...project,
|
||||
assets: project.assets.map(asset => (
|
||||
asset.id === assetId ? { ...asset, favorite: !asset.favorite } : asset
|
||||
)),
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
}, [mutateProject])
|
||||
|
||||
return (
|
||||
<ProjectContext.Provider value={{
|
||||
currentTab,
|
||||
setCurrentTab,
|
||||
projectIds,
|
||||
activeProject,
|
||||
getProject,
|
||||
setProject,
|
||||
createProject,
|
||||
deleteProject,
|
||||
renameProject,
|
||||
activateProject,
|
||||
clearActiveProject,
|
||||
reloadProjectIds,
|
||||
addAsset,
|
||||
deleteAsset,
|
||||
updateAsset,
|
||||
addTakeToAsset,
|
||||
deleteTakeFromAsset,
|
||||
setAssetActiveTake,
|
||||
toggleFavorite,
|
||||
genSpaceEditImagePath,
|
||||
setGenSpaceEditImagePath,
|
||||
genSpaceEditMode,
|
||||
setGenSpaceEditMode,
|
||||
genSpaceAudioPath,
|
||||
setGenSpaceAudioPath,
|
||||
genSpaceRetakeSource,
|
||||
setGenSpaceRetakeSource,
|
||||
pendingRetakeUpdate,
|
||||
setPendingRetakeUpdate,
|
||||
genSpaceIcLoraSource,
|
||||
setGenSpaceIcLoraSource,
|
||||
pendingIcLoraUpdate,
|
||||
setPendingIcLoraUpdate,
|
||||
}}>
|
||||
{children}
|
||||
</ProjectContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useProjects() {
|
||||
const context = useContext(ProjectContext)
|
||||
if (!context) {
|
||||
throw new Error('useProjects must be used within a ProjectProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { ViewType } from '../types/project-model'
|
||||
import { useProjects } from './ProjectContext'
|
||||
|
||||
interface ViewContextType {
|
||||
currentView: ViewType
|
||||
setCurrentView: (view: ViewType) => void
|
||||
openProject: (projectId: string) => void
|
||||
goHome: () => void
|
||||
}
|
||||
|
||||
const ViewContext = createContext<ViewContextType | null>(null)
|
||||
|
||||
export function ViewProvider({ children }: { children: React.ReactNode }) {
|
||||
const {
|
||||
activeProject,
|
||||
activateProject,
|
||||
clearActiveProject,
|
||||
setCurrentTab,
|
||||
} = useProjects()
|
||||
const [currentView, setCurrentView] = useState<ViewType>('home')
|
||||
|
||||
const openProject = useCallback((projectId: string) => {
|
||||
activateProject(projectId)
|
||||
setCurrentTab('gen-space')
|
||||
setCurrentView('project')
|
||||
}, [activateProject, setCurrentTab])
|
||||
|
||||
const goHome = useCallback(() => {
|
||||
clearActiveProject()
|
||||
setCurrentView('home')
|
||||
}, [clearActiveProject])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView === 'project' && !activeProject) {
|
||||
setCurrentView('home')
|
||||
}
|
||||
}, [activeProject, currentView])
|
||||
|
||||
return (
|
||||
<ViewContext.Provider value={{ currentView, setCurrentView, openProject, goHome }}>
|
||||
{children}
|
||||
</ViewContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useView() {
|
||||
const context = useContext(ViewContext)
|
||||
if (!context) {
|
||||
throw new Error('useView must be used within a ViewProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { UpdateStatePayload } from '../../shared/electron-api-schema'
|
||||
import { useIsGenerationActive } from '../lib/generation-active'
|
||||
|
||||
export type AppUpdate = {
|
||||
state: UpdateStatePayload
|
||||
checkForUpdates: () => Promise<void>
|
||||
startDownload: () => Promise<void>
|
||||
installAndRestart: () => Promise<{ success: true } | { success: false; error: string }>
|
||||
skipVersion: (version: string) => Promise<void>
|
||||
}
|
||||
|
||||
const INITIAL: UpdateStatePayload = { status: 'idle', currentVersion: '' }
|
||||
|
||||
const MODAL_STATUSES: ReadonlySet<UpdateStatePayload['status']> = new Set([
|
||||
'available',
|
||||
'downloading',
|
||||
'downloaded',
|
||||
])
|
||||
|
||||
export function useAppUpdate(): AppUpdate {
|
||||
const [state, setState] = useState<UpdateStatePayload>(INITIAL)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
let fromEvent = false
|
||||
// Subscribe first so a check that starts during getUpdateState cannot be missed,
|
||||
// then ignore the snapshot if an event already applied a newer value.
|
||||
const unsubscribe = window.electronAPI.onUpdateEvent((data) => {
|
||||
fromEvent = true
|
||||
if (alive) setState(data)
|
||||
})
|
||||
void window.electronAPI.getUpdateState()
|
||||
.then((s) => { if (alive && !fromEvent) setState(s) })
|
||||
.catch(() => {})
|
||||
return () => { alive = false; unsubscribe() }
|
||||
}, [])
|
||||
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
await window.electronAPI.checkForUpdatesNow()
|
||||
}, [])
|
||||
const startDownload = useCallback(async () => {
|
||||
await window.electronAPI.startUpdateDownload()
|
||||
}, [])
|
||||
const installAndRestart = useCallback(async () => {
|
||||
return window.electronAPI.installUpdateAndRestart()
|
||||
}, [])
|
||||
const skipVersion = useCallback(async (version: string) => {
|
||||
await window.electronAPI.skipUpdateVersion({ version })
|
||||
}, [])
|
||||
|
||||
return { state, checkForUpdates, startDownload, installAndRestart, skipVersion }
|
||||
}
|
||||
|
||||
/** Session Later / skip / manual-check intent. App only mounts the modal. */
|
||||
export function useAppUpdateModal() {
|
||||
const update = useAppUpdate()
|
||||
const isGenerationActive = useIsGenerationActive()
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [manualCheckPending, setManualCheckPending] = useState(false)
|
||||
const [laterVersion, setLaterVersion] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Mac has no modal: silent download + install-on-quit, gated only by the About toggle.
|
||||
if (window.electronAPI.platform === 'darwin') return
|
||||
const s = update.state
|
||||
if (s.status === 'available') {
|
||||
if (manualCheckPending || s.version !== laterVersion) {
|
||||
setModalOpen(true)
|
||||
}
|
||||
if (manualCheckPending) setManualCheckPending(false)
|
||||
} else if (s.status === 'downloaded') {
|
||||
// Bits are on disk: the modal is the only install path and must stay up,
|
||||
// including after Hide-during-download.
|
||||
setModalOpen(true)
|
||||
} else if (s.status === 'not-available') {
|
||||
if (manualCheckPending) setManualCheckPending(false)
|
||||
}
|
||||
}, [update.state.status, update.state.version, manualCheckPending, laterVersion])
|
||||
|
||||
const requestCheck = update.checkForUpdates
|
||||
const skipVersion = update.skipVersion
|
||||
const version = update.state.version
|
||||
const status = update.state.status
|
||||
|
||||
const checkForUpdates = useCallback(() => {
|
||||
setManualCheckPending(true)
|
||||
void requestCheck()
|
||||
}, [requestCheck])
|
||||
|
||||
const closeModal = useCallback((skipThisVersion: boolean) => {
|
||||
if (status === 'downloaded') return
|
||||
if (skipThisVersion && version) void skipVersion(version)
|
||||
// Hide during download is not Later — keep the session prompt so a failed
|
||||
// download can reopen the modal. Later/skip only apply when dismissing the offer.
|
||||
else if (version && status !== 'downloading') setLaterVersion(version)
|
||||
setModalOpen(false)
|
||||
}, [skipVersion, version, status])
|
||||
|
||||
const openModal = useCallback(() => setModalOpen(true), [])
|
||||
|
||||
return {
|
||||
update,
|
||||
isGenerationActive,
|
||||
// Keep the modal mounted across a periodic re-check (`checking`) so it does not
|
||||
// unmount/remount. Do not treat `available` as busy in main — that would hide a
|
||||
// newer version after the user clicked Later.
|
||||
isModalOpen:
|
||||
window.electronAPI.platform !== 'darwin'
|
||||
&& modalOpen
|
||||
&& (MODAL_STATUSES.has(status) || status === 'checking'),
|
||||
openModal,
|
||||
closeModal,
|
||||
checkForUpdates,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { resetBackendCredentials } from '../lib/backend'
|
||||
import { logger } from '../lib/logger'
|
||||
|
||||
export type BackendProcessStatus = 'alive' | 'restarting' | 'dead'
|
||||
|
||||
interface BackendHealthStatusPayload {
|
||||
status: BackendProcessStatus
|
||||
exitCode?: number | null
|
||||
}
|
||||
|
||||
interface UseBackendReturn {
|
||||
processStatus: BackendProcessStatus | null
|
||||
connected: boolean
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
function toBackendHealthStatus(value: unknown): BackendHealthStatusPayload | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const record = value as { status?: unknown; exitCode?: unknown }
|
||||
if (record.status !== 'alive' && record.status !== 'restarting' && record.status !== 'dead') {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
status: record.status,
|
||||
exitCode: typeof record.exitCode === 'number' || record.exitCode === null ? record.exitCode : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function useBackend(): UseBackendReturn {
|
||||
const [processStatus, setProcessStatus] = useState<BackendProcessStatus | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const handleBackendStatus = useCallback((payload: BackendHealthStatusPayload) => {
|
||||
setProcessStatus(payload.status)
|
||||
|
||||
if (payload.status === 'alive') {
|
||||
// Main has verified HTTP reachability before publishing 'alive' and may
|
||||
// have spawned a fresh backend with a new port/token — drop cached creds
|
||||
// so the next backendFetch picks up the current values.
|
||||
resetBackendCredentials()
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.status === 'restarting') {
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const applyStatus = (value: unknown) => {
|
||||
const payload = toBackendHealthStatus(value)
|
||||
if (!payload || cancelled) {
|
||||
return
|
||||
}
|
||||
handleBackendStatus(payload)
|
||||
}
|
||||
|
||||
const unsubscribe = window.electronAPI.onBackendHealthStatus((data: BackendHealthStatusPayload) => {
|
||||
applyStatus(data)
|
||||
})
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
const snapshot = await window.electronAPI.getBackendHealthStatus()
|
||||
applyStatus(snapshot)
|
||||
} catch (err) {
|
||||
logger.error(`Failed to load backend health status snapshot: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
void init()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsubscribe()
|
||||
}
|
||||
}, [handleBackendStatus])
|
||||
|
||||
return {
|
||||
processStatus,
|
||||
connected: processStatus === 'alive',
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ApiClient } from '../lib/api-client'
|
||||
import type { ApiSuccessOf } from '../lib/api-client'
|
||||
import { catalogVariantKey } from '../lib/lora-library'
|
||||
|
||||
export type IcLoraListItem = ApiSuccessOf<'listIcLoras'>['ic_loras'][number]
|
||||
export type LoraCatalogListItem = ApiSuccessOf<'listLoras'>['loras'][number]
|
||||
|
||||
type StartResult = { ok: true; data: { sessionId: string } } | { ok: false; error: { message: string } }
|
||||
type ProgressResult =
|
||||
| { ok: true; data: { progress: number; status: 'downloading' | 'complete' | 'error'; error?: string | null } }
|
||||
| { ok: false; error: { message: string } }
|
||||
|
||||
// Shared download + poll state machine for a catalog item (LoRA or IC-LoRA). The two hooks
|
||||
// below differ only in which endpoints they hit and how they list items; this owns the
|
||||
// identical download/poll/error half. onComplete refreshes the caller's list.
|
||||
// downloadingKey / downloadError.key are catalogVariantKey(id, variantId) so two checkpoints
|
||||
// of the same item don't clobber each other's UI state.
|
||||
function useCatalogDownload(
|
||||
startDownload: (id: string, variantId?: string) => Promise<StartResult>,
|
||||
getProgress: (sessionId: string) => Promise<ProgressResult>,
|
||||
onComplete: () => void,
|
||||
) {
|
||||
const [downloadingKey, setDownloadingKey] = useState<string | null>(null)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [downloadError, setDownloadError] = useState<{ key: string; message: string } | null>(null)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const download = useCallback(async (id: string, variantId?: string) => {
|
||||
const key = catalogVariantKey(id, variantId)
|
||||
setDownloadError(null)
|
||||
const start = await startDownload(id, variantId)
|
||||
if (!start.ok) {
|
||||
setDownloadError({ key, message: start.error.message })
|
||||
return
|
||||
}
|
||||
setDownloadingKey(key)
|
||||
setProgress(0)
|
||||
const sessionId = start.data.sessionId
|
||||
// Never stack intervals: a re-entrant download() would otherwise leak the previous one.
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
const stop = () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
setDownloadingKey(null)
|
||||
setProgress(0)
|
||||
}
|
||||
let inFlight = false
|
||||
let failures = 0
|
||||
pollRef.current = setInterval(async () => {
|
||||
if (inFlight) return // don't overlap polls if a tick outruns the 1s interval
|
||||
inFlight = true
|
||||
try {
|
||||
const p = await getProgress(sessionId)
|
||||
if (!p.ok) {
|
||||
// Tolerate a blip, but give up (and surface it) rather than poll forever.
|
||||
if (++failures >= 3) { stop(); setDownloadError({ key, message: 'Lost contact with the download.' }) }
|
||||
return
|
||||
}
|
||||
failures = 0
|
||||
setProgress(p.data.progress)
|
||||
if (p.data.status !== 'downloading') {
|
||||
stop()
|
||||
if (p.data.status === 'error') setDownloadError({ key, message: p.data.error ?? 'Download failed' })
|
||||
onComplete()
|
||||
}
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}, 1000)
|
||||
}, [startDownload, getProgress, onComplete])
|
||||
|
||||
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
|
||||
|
||||
return { downloadingKey, progress, downloadError, download }
|
||||
}
|
||||
|
||||
export function useIcLoras(enabled: boolean) {
|
||||
const [icLoras, setIcLoras] = useState<IcLoraListItem[]>([])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!enabled) return
|
||||
const r = await ApiClient.listIcLoras()
|
||||
if (r.ok) setIcLoras(r.data.ic_loras)
|
||||
}, [enabled])
|
||||
|
||||
useEffect(() => { void refresh() }, [refresh])
|
||||
|
||||
const { downloadingKey, progress, downloadError, download: downloadIcLora } = useCatalogDownload(
|
||||
// Attach the in-app HF token when the user is signed in; the backend ignores it for public
|
||||
// repos and requires it only for gated entries (optional auth).
|
||||
(id, variantId) => ApiClient.startIcLoraDownload({
|
||||
ic_lora_id: id,
|
||||
variant_id: variantId,
|
||||
use_hf_auth: true,
|
||||
}),
|
||||
(sessionId) => ApiClient.getIcLoraDownloadProgress({ sessionId }),
|
||||
refresh,
|
||||
)
|
||||
|
||||
return { icLoras, refresh, downloadIcLora, downloadingKey, progress, downloadError }
|
||||
}
|
||||
|
||||
// Plain-LoRA catalog: uses /api/loras* (an isolated download session, so it never collides
|
||||
// with an in-flight IC-LoRA download).
|
||||
export function useLoraCatalog(enabled: boolean) {
|
||||
const [loras, setLoras] = useState<LoraCatalogListItem[]>([])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!enabled) return
|
||||
const r = await ApiClient.listLoras()
|
||||
if (r.ok) setLoras(r.data.loras)
|
||||
}, [enabled])
|
||||
|
||||
useEffect(() => { void refresh() }, [refresh])
|
||||
|
||||
const { downloadingKey, progress, downloadError, download: downloadLora } = useCatalogDownload(
|
||||
(id, variantId) => ApiClient.startLoraDownload({
|
||||
lora_id: id,
|
||||
variant_id: variantId,
|
||||
use_hf_auth: true,
|
||||
}),
|
||||
(sessionId) => ApiClient.getLoraDownloadProgress({ sessionId }),
|
||||
refresh,
|
||||
)
|
||||
|
||||
return { loras, refresh, downloadLora, downloadingKey, progress, downloadError }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useDevFlags } from '../contexts/DevFlagsContext'
|
||||
|
||||
// Single source of truth for whether the "Custom IC-LoRA" UI is available.
|
||||
// Off by default via the dev flag. Built-in Canny/Depth IC-LoRA is unaffected.
|
||||
// The custom option only appears inside IC-LoRA mode, which is already gated by
|
||||
// the existing API-only check — so this flag is all the extra gating it needs.
|
||||
export function useCustomIcLoraEnabled(): boolean {
|
||||
return useDevFlags().flags.customIcLora
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { ApiClient } from '../lib/api-client'
|
||||
import { canCancelLocalJob, withGenerationActive } from '../lib/generation-active'
|
||||
import { logger } from '../lib/logger'
|
||||
import { prepareGenerationInput } from '../lib/generation-input'
|
||||
import { useAppSettings } from '../contexts/AppSettingsContext'
|
||||
import type { RetakeExtendModel } from './use-retake'
|
||||
|
||||
export type ExtendDirection = 'start' | 'end'
|
||||
|
||||
// Seconds-to-add presets (matches LTX Studio). API allows 2–20s.
|
||||
export const EXTEND_SECONDS = [4, 6, 8, 10, 12] as const
|
||||
export const DEFAULT_EXTEND_SECONDS = 4
|
||||
|
||||
export interface ExtendSubmitParams {
|
||||
videoPath: string
|
||||
duration: number
|
||||
prompt: string
|
||||
mode: ExtendDirection
|
||||
resolution?: { width: number; height: number }
|
||||
model: RetakeExtendModel
|
||||
}
|
||||
|
||||
export interface ExtendResult {
|
||||
videoPath: string
|
||||
}
|
||||
|
||||
interface UseExtendState {
|
||||
isExtending: boolean
|
||||
canCancel: boolean
|
||||
extendStatus: string
|
||||
extendError: string | null
|
||||
result: ExtendResult | null
|
||||
}
|
||||
|
||||
export function useExtend() {
|
||||
const { shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi } = useAppSettings()
|
||||
const [state, setState] = useState<UseExtendState>({
|
||||
isExtending: false,
|
||||
canCancel: false,
|
||||
extendStatus: '',
|
||||
extendError: null,
|
||||
result: null,
|
||||
})
|
||||
|
||||
const submitExtend = useCallback(async (params: ExtendSubmitParams) => {
|
||||
if (!params.videoPath) return
|
||||
|
||||
setState({
|
||||
isExtending: true,
|
||||
canCancel: canCancelLocalJob('video', shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi),
|
||||
extendStatus: 'Generating',
|
||||
extendError: null,
|
||||
result: null,
|
||||
})
|
||||
|
||||
await withGenerationActive(async () => {
|
||||
let preparedVideoPath: string
|
||||
try {
|
||||
preparedVideoPath = (await prepareGenerationInput(params.videoPath)) ?? params.videoPath
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to prepare source video'
|
||||
logger.error(`Extend input error: ${message}`)
|
||||
setState({ isExtending: false, canCancel: false, extendStatus: '', extendError: message, result: null })
|
||||
return
|
||||
}
|
||||
|
||||
const result = await ApiClient.extend({
|
||||
video_path: preparedVideoPath,
|
||||
duration: params.duration,
|
||||
prompt: params.prompt,
|
||||
mode: params.mode,
|
||||
resolution: params.resolution,
|
||||
model: params.model,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
logger.error(`Extend error: ${result.error.message}`)
|
||||
setState({ isExtending: false, canCancel: false, extendStatus: '', extendError: result.error.message, result: null })
|
||||
return
|
||||
}
|
||||
|
||||
const payload = result.data
|
||||
|
||||
if (payload.status === 'cancelled') {
|
||||
setState({ isExtending: false, canCancel: false, extendStatus: 'Cancelled', extendError: null, result: null })
|
||||
return
|
||||
}
|
||||
|
||||
if ('video_path' in payload) {
|
||||
setState({
|
||||
isExtending: false,
|
||||
canCancel: false,
|
||||
extendStatus: 'Extend complete!',
|
||||
extendError: null,
|
||||
result: { videoPath: payload.video_path },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// A 200 with a remote payload and no local file is a legitimate success (the backend
|
||||
// completed the generation); there's just no local artifact to import. Don't surface
|
||||
// it as an error.
|
||||
logger.warn(`Extend completed with a remote payload and no local file: ${JSON.stringify(payload.result)}`)
|
||||
setState({
|
||||
isExtending: false,
|
||||
canCancel: false,
|
||||
extendStatus: 'Extend complete!',
|
||||
extendError: null,
|
||||
result: null,
|
||||
})
|
||||
})
|
||||
}, [shouldImageGenerateWithFalApi, shouldVideoGenerateWithLtxApi])
|
||||
|
||||
const resetExtend = useCallback(() => {
|
||||
setState({ isExtending: false, canCancel: false, extendStatus: '', extendError: null, result: null })
|
||||
}, [])
|
||||
|
||||
return {
|
||||
submitExtend,
|
||||
resetExtend,
|
||||
isExtending: state.isExtending,
|
||||
canCancel: state.canCancel,
|
||||
extendStatus: state.extendStatus,
|
||||
extendError: state.extendError,
|
||||
extendResult: state.result,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { fixedMenuPosition, type MenuPlacement } from '../lib/fixed-menu-position'
|
||||
|
||||
const HIDDEN_FIXED: CSSProperties = { position: 'fixed', zIndex: 9999, visibility: 'hidden' }
|
||||
|
||||
export function useFixedMenu(placement: MenuPlacement) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [style, setStyle] = useState<CSSProperties>(HIDDEN_FIXED)
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
const trigger = triggerRef.current?.getBoundingClientRect()
|
||||
if (!trigger) return
|
||||
const menuWidth = menuRef.current?.offsetWidth
|
||||
const pos = fixedMenuPosition({
|
||||
trigger,
|
||||
placement,
|
||||
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||
menuWidth: menuWidth || undefined,
|
||||
})
|
||||
setStyle({
|
||||
position: 'fixed',
|
||||
zIndex: 9999,
|
||||
visibility: 'visible',
|
||||
left: pos.left,
|
||||
...(pos.top != null ? { top: pos.top } : { bottom: pos.bottom }),
|
||||
})
|
||||
}, [placement])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) {
|
||||
setStyle(HIDDEN_FIXED)
|
||||
return
|
||||
}
|
||||
updatePosition()
|
||||
window.addEventListener('resize', updatePosition)
|
||||
window.addEventListener('scroll', updatePosition, true)
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition)
|
||||
window.removeEventListener('scroll', updatePosition, true)
|
||||
}
|
||||
}, [isOpen, updatePosition])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
const node = event.target as Node
|
||||
if (triggerRef.current?.contains(node) || menuRef.current?.contains(node)) return
|
||||
setIsOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onPointerDown)
|
||||
return () => document.removeEventListener('mousedown', onPointerDown)
|
||||
}, [isOpen])
|
||||
|
||||
return { isOpen, setIsOpen, triggerRef, menuRef, style }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useProjects } from '../contexts/ProjectContext'
|
||||
import { useAppSettings } from '../contexts/AppSettingsContext'
|
||||
import { checkAndConsumeRecovery } from '../lib/generation-recovery'
|
||||
import { subscribeWhileGenerationMayBeActive } from '../lib/generation-progress-poll'
|
||||
|
||||
// Always-mounted safety net for a generation that finishes while its own project's GenSpace
|
||||
// isn't open (a different project, the Video Editor tab, or Home): periodically checks whether
|
||||
// the pending recovery marker's generation has completed and, if so, persists it directly into
|
||||
// that project's assets — independent of whichever view/project is currently on screen. Mount
|
||||
// this once, near the app root (outside whatever view-switching renders GenSpace), so it survives
|
||||
// navigation. GenSpace's own live polling/import effects are unchanged and take priority — see
|
||||
// setActiveGenerationOwner in lib/generation-recovery.ts for how the two avoid double-importing.
|
||||
export function useGenerationRecoveryWatcher(): void {
|
||||
const { addAsset } = useProjects()
|
||||
const { settings } = useAppSettings()
|
||||
const modelsDirRef = useRef(settings.modelsDir)
|
||||
modelsDirRef.current = settings.modelsDir
|
||||
const isCheckingRef = useRef(false)
|
||||
|
||||
useEffect(() => subscribeWhileGenerationMayBeActive(progress => {
|
||||
if (isCheckingRef.current) return
|
||||
isCheckingRef.current = true
|
||||
void checkAndConsumeRecovery(progress, { addAsset, modelsDir: modelsDirRef.current })
|
||||
.finally(() => { isCheckingRef.current = false })
|
||||
}), [addAsset])
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import type { GenerationSettings } from '../components/SettingsPanel'
|
||||
import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../lib/api-client'
|
||||
import { createLocalGenerationError, type GenerationError } from '../lib/generation-errors'
|
||||
import { canCancelLocalJob, withGenerationActive } from '../lib/generation-active'
|
||||
import { useAppSettings } from '../contexts/AppSettingsContext'
|
||||
import { buildGenerateVideoImageInputs } from '../lib/build-generate-video-body'
|
||||
import type { GenSpaceMode } from '../lib/genspace-multi-keyframe'
|
||||
import type { KeyframeItem, PersistedKeyframe } from '../lib/multi-keyframe'
|
||||
import { prepareGenerationInput } from '../lib/generation-input'
|
||||
|
||||
const POLLING_INTERVAL_MS = 2000
|
||||
|
||||
export const GENERATION_RECOVERY_KEY = 'ltx-generation-recovery'
|
||||
|
||||
export interface GenerationRecoveryContext {
|
||||
projectId: string
|
||||
prompt: string
|
||||
// Absent for ic-lora/retake: those recover as standalone video assets (Phase 1),
|
||||
// so there are no video/image settings to restore.
|
||||
settings?: GenerationSettings
|
||||
// Retake/extend write this instead of a full `settings` blob — the recovery importer
|
||||
// prefers it over `settings.model` (which defaults to 'fast' when absent).
|
||||
model?: string
|
||||
// Frozen at click. Local `fast` and API `fast` share an id; display_name must not be
|
||||
// re-resolved from whichever offering is selected when the job later finishes.
|
||||
modelLabel?: string
|
||||
inputImageUrl?: string
|
||||
inputLastImageUrl?: string
|
||||
inputAudioUrl?: string
|
||||
keyframes?: PersistedKeyframe[]
|
||||
genType?: 'image' | 'enhance'
|
||||
// Frozen at marker write (job start) — same rule as hook canCancel. Lets Stop survive a
|
||||
// UI refresh before the first progress poll returns (local GPU can starve that poll).
|
||||
// Absent on older markers: treat as not cancellable until the poll reports it.
|
||||
canCancel?: boolean
|
||||
// Whatever generation id the backend reported at the moment this marker was written — i.e.
|
||||
// immediately BEFORE this generation started. The handler that starts a generation loads its
|
||||
// pipeline (can take many seconds — worse for image models loading checkpoint shards) before
|
||||
// it ever reports a new id, so a poll can otherwise be looking at a stale, unrelated id/result
|
||||
// that predates this marker entirely. Once a later poll observes a DIFFERENT id, that's proof
|
||||
// (single global generation slot) that this marker's own generation has started — see
|
||||
// checkAndConsumeRecovery in lib/generation-recovery.ts.
|
||||
baselineId: string | null
|
||||
// Set once a poll observes an id different from baselineId — i.e. once this marker's own
|
||||
// generation is confirmed to exist. Distinct from baselineId: a LATER id change past this point
|
||||
// means a DIFFERENT generation superseded ours (not that ours just started), which must NOT be
|
||||
// imported under this marker.
|
||||
generationId?: string
|
||||
}
|
||||
|
||||
export function readRecoveryMarkerCanCancel(): boolean {
|
||||
const saved = localStorage.getItem(GENERATION_RECOVERY_KEY)
|
||||
if (!saved) return false
|
||||
try {
|
||||
return (JSON.parse(saved) as GenerationRecoveryContext).canCancel === true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
interface GenerationState {
|
||||
isGenerating: boolean
|
||||
isCancelling: boolean
|
||||
/** Frozen at job start — Stop stays hidden if this POST was an LTX/FAL cloud job. */
|
||||
canCancel: boolean
|
||||
progress: number
|
||||
statusMessage: string
|
||||
videoPath: string | null
|
||||
imagePath: string | null
|
||||
imagePaths: string[]
|
||||
error: GenerationError | null
|
||||
}
|
||||
|
||||
type GenerateVideoRequest = ApiRequestBodyOf<'generateVideo'>
|
||||
type GenerateImageRequest = ApiRequestBodyOf<'generateImage'>
|
||||
|
||||
interface UseGenerationReturn extends GenerationState {
|
||||
generate: (
|
||||
prompt: string,
|
||||
imagePath: string | null,
|
||||
settings: GenerationSettings,
|
||||
audioPath?: string | null,
|
||||
lastImagePath?: string | null,
|
||||
imageInputs?: { mode: GenSpaceMode; keyframes: KeyframeItem[] },
|
||||
) => Promise<void>
|
||||
generateImage: (prompt: string, settings: GenerationSettings, editSource?: string | null) => Promise<void>
|
||||
cancel: () => void
|
||||
reset: () => void
|
||||
resumeIfRunning: () => Promise<'running' | 'complete' | 'none'>
|
||||
}
|
||||
|
||||
const IMAGE_SHORT_SIDE_BY_RESOLUTION: Record<string, number> = {
|
||||
'1080p': 1080,
|
||||
'1440p': 1440,
|
||||
'2048p': 2048,
|
||||
}
|
||||
|
||||
const IMAGE_ASPECT_RATIO_VALUE: Record<string, number> = {
|
||||
'1:1': 1,
|
||||
'16:9': 16 / 9,
|
||||
'9:16': 9 / 16,
|
||||
'4:3': 4 / 3,
|
||||
'3:4': 3 / 4,
|
||||
'21:9': 21 / 9,
|
||||
}
|
||||
|
||||
function getImageDimensions(settings: GenerationSettings): { width: number; height: number } {
|
||||
const shortSide = IMAGE_SHORT_SIDE_BY_RESOLUTION[settings.imageResolution]
|
||||
if (!shortSide) {
|
||||
throw new Error(`Unsupported image resolution mapping: ${settings.imageResolution}`)
|
||||
}
|
||||
|
||||
const ratio = IMAGE_ASPECT_RATIO_VALUE[settings.imageAspectRatio]
|
||||
if (!ratio) {
|
||||
throw new Error(`Unsupported image aspect ratio mapping: ${settings.imageAspectRatio}`)
|
||||
}
|
||||
|
||||
if (ratio >= 1) {
|
||||
return { width: Math.round(shortSide * ratio), height: shortSide }
|
||||
}
|
||||
return { width: shortSide, height: Math.round(shortSide / ratio) }
|
||||
}
|
||||
|
||||
// Map phase to user-friendly message
|
||||
function getPhaseMessage(phase: string): string {
|
||||
switch (phase) {
|
||||
case 'validating_request':
|
||||
return 'Validating request...'
|
||||
case 'uploading_image':
|
||||
return 'Uploading image...'
|
||||
case 'uploading_audio':
|
||||
return 'Uploading audio...'
|
||||
case 'loading_model':
|
||||
return 'Loading model...'
|
||||
case 'encoding_text':
|
||||
return 'Encoding prompt...'
|
||||
case 'inference':
|
||||
return 'Generating...'
|
||||
case 'downloading_output':
|
||||
return 'Downloading output...'
|
||||
case 'decoding':
|
||||
return 'Decoding video...'
|
||||
case 'complete':
|
||||
return 'Complete!'
|
||||
case 'cancelled':
|
||||
return 'Cancelling…'
|
||||
default:
|
||||
return 'Generating...'
|
||||
}
|
||||
}
|
||||
|
||||
export function useGeneration(): UseGenerationReturn {
|
||||
const { settings: appSettings, shouldImageGenerateWithFalApi, shouldVideoGenerateWithLtxApi, refreshSettings } = useAppSettings()
|
||||
const [state, setState] = useState<GenerationState>({
|
||||
isGenerating: false,
|
||||
isCancelling: false,
|
||||
canCancel: false,
|
||||
progress: 0,
|
||||
statusMessage: '',
|
||||
videoPath: null,
|
||||
imagePath: null,
|
||||
imagePaths: [],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const recoveryIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const clearRecoveryPolling = () => {
|
||||
if (recoveryIntervalRef.current) {
|
||||
clearInterval(recoveryIntervalRef.current)
|
||||
recoveryIntervalRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => clearRecoveryPolling, [])
|
||||
|
||||
// Re-attach to a generation that was running OR finished while the frontend was
|
||||
// unmounted. Polls the backend progress endpoint; localStorage recovery context
|
||||
// (inputs, settings incl. loras) is owned by the caller (GenSpace). Returns the
|
||||
// recovered status so the caller can restore context for 'running' AND 'complete'
|
||||
// (a generation that finished during the unmount window still needs its metadata).
|
||||
const resumeIfRunning = useCallback(async (): Promise<'running' | 'complete' | 'none'> => {
|
||||
const apply = (data: ApiSuccessOf<'getGenerationProgress'>): 'running' | 'complete' | 'other' => {
|
||||
if (data.status === 'complete' && data.result != null) {
|
||||
const vp = typeof data.result === 'string' ? data.result : null
|
||||
const ips = Array.isArray(data.result) ? data.result : []
|
||||
setState({
|
||||
isGenerating: false, isCancelling: false, canCancel: false, progress: 100, statusMessage: 'Complete!',
|
||||
videoPath: vp, imagePath: ips[0] ?? null, imagePaths: ips, error: null,
|
||||
})
|
||||
return 'complete'
|
||||
}
|
||||
if (data.status === 'running') {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isGenerating: true,
|
||||
isCancelling: data.phase === 'cancelled',
|
||||
canCancel: data.cancellable,
|
||||
progress: data.progress,
|
||||
statusMessage: getPhaseMessage(data.phase),
|
||||
}))
|
||||
return 'running'
|
||||
}
|
||||
setState(prev => ({ ...prev, isGenerating: false, isCancelling: false, canCancel: false, statusMessage: '' }))
|
||||
return 'other'
|
||||
}
|
||||
|
||||
const initial = await ApiClient.getGenerationProgress()
|
||||
if (!initial.ok) return 'none'
|
||||
const status = apply(initial.data)
|
||||
if (status === 'complete') return 'complete'
|
||||
if (status !== 'running') return 'none'
|
||||
|
||||
clearRecoveryPolling()
|
||||
recoveryIntervalRef.current = setInterval(async () => {
|
||||
const r = await ApiClient.getGenerationProgress()
|
||||
if (!r.ok) return
|
||||
if (apply(r.data) !== 'running') clearRecoveryPolling()
|
||||
}, POLLING_INTERVAL_MS)
|
||||
return 'running'
|
||||
}, [])
|
||||
|
||||
const generate = useCallback(async (
|
||||
prompt: string,
|
||||
imagePath: string | null,
|
||||
settings: GenerationSettings,
|
||||
audioPath?: string | null,
|
||||
lastImagePath?: string | null,
|
||||
imageInputs?: { mode: GenSpaceMode; keyframes: KeyframeItem[] },
|
||||
) => {
|
||||
const statusMsg = settings.model.startsWith('pro')
|
||||
? 'Loading Pro model & generating...'
|
||||
: 'Generating video...'
|
||||
|
||||
setState({
|
||||
isGenerating: true,
|
||||
isCancelling: false,
|
||||
canCancel: canCancelLocalJob('video', shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi),
|
||||
progress: 0,
|
||||
statusMessage: statusMsg,
|
||||
videoPath: null,
|
||||
imagePath: null,
|
||||
imagePaths: [],
|
||||
error: null,
|
||||
})
|
||||
|
||||
let progressInterval: ReturnType<typeof setInterval> | null = null
|
||||
let shouldApplyPollingUpdates = true
|
||||
|
||||
await withGenerationActive(async () => {
|
||||
try {
|
||||
const [preparedImagePath, preparedLastImagePath, preparedAudioPath] = await Promise.all([
|
||||
prepareGenerationInput(imagePath),
|
||||
prepareGenerationInput(lastImagePath),
|
||||
prepareGenerationInput(audioPath),
|
||||
])
|
||||
const preparedKeyframes = await Promise.all(
|
||||
(imageInputs?.keyframes ?? []).map(async keyframe => ({
|
||||
...keyframe,
|
||||
path: (await prepareGenerationInput(keyframe.path)) ?? keyframe.path,
|
||||
})),
|
||||
)
|
||||
|
||||
// Prepare JSON body
|
||||
const body: Record<string, unknown> = {
|
||||
prompt,
|
||||
model: settings.model,
|
||||
duration: settings.duration,
|
||||
resolution: settings.videoResolution,
|
||||
fps: settings.fps,
|
||||
audio: settings.audio,
|
||||
cameraMotion: settings.cameraMotion,
|
||||
negativePrompt: (settings as { negativePrompt?: string }).negativePrompt ?? '',
|
||||
aspectRatio: settings.aspectRatio || '16:9',
|
||||
...buildGenerateVideoImageInputs({
|
||||
mode: imageInputs?.mode ?? 'video',
|
||||
imagePath: preparedImagePath,
|
||||
lastImagePath: preparedLastImagePath,
|
||||
keyframes: preparedKeyframes,
|
||||
}),
|
||||
}
|
||||
if (preparedAudioPath) {
|
||||
body.audioPath = preparedAudioPath
|
||||
}
|
||||
if (settings.loras?.length) {
|
||||
body.loras = settings.loras.map(l => ({ ref: l.ref, scale: l.scale }))
|
||||
}
|
||||
|
||||
// Poll for real progress from backend with time-based interpolation
|
||||
let lastPhase = ''
|
||||
let inferenceStartTime = 0
|
||||
// Estimated inference time in seconds based on model
|
||||
const estimatedInferenceTime = settings.model.startsWith('pro') ? 120 : 45
|
||||
|
||||
const pollProgress = async () => {
|
||||
if (!shouldApplyPollingUpdates) return
|
||||
const result = await ApiClient.getGenerationProgress()
|
||||
if (!result.ok || !shouldApplyPollingUpdates) return
|
||||
|
||||
const data = result.data
|
||||
let displayProgress = data.progress
|
||||
let statusMessage = getPhaseMessage(data.phase)
|
||||
|
||||
// Time-based interpolation during inference phase
|
||||
if (data.phase === 'inference') {
|
||||
if (lastPhase !== 'inference') {
|
||||
inferenceStartTime = Date.now()
|
||||
}
|
||||
const elapsed = (Date.now() - inferenceStartTime) / 1000
|
||||
// Interpolate from 15% to 95% based on estimated time
|
||||
const inferenceProgress = Math.min(elapsed / estimatedInferenceTime, 0.95)
|
||||
displayProgress = 15 + Math.floor(inferenceProgress * 80)
|
||||
}
|
||||
|
||||
// Keep API/local completion as a terminal response state, not polling state.
|
||||
// Polling complete means backend state is finalized, but request can still be in-flight.
|
||||
if (data.phase === 'complete' || data.status === 'complete') {
|
||||
displayProgress = 95
|
||||
statusMessage = 'Finalizing...'
|
||||
}
|
||||
|
||||
lastPhase = data.phase
|
||||
|
||||
setState(prev => {
|
||||
if (prev.isCancelling) {
|
||||
return { ...prev, statusMessage: 'Cancelling…' }
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
progress: displayProgress,
|
||||
statusMessage,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
progressInterval = setInterval(pollProgress, 500)
|
||||
|
||||
// Start generation (HTTP POST - synchronous, returns when done)
|
||||
// Do not abort this POST: liveness suppression stays up until the
|
||||
// backend returns {status: "cancelled"} and the GPU job unwinds.
|
||||
const result = await ApiClient.generateVideo(body as unknown as GenerateVideoRequest)
|
||||
shouldApplyPollingUpdates = false
|
||||
if (!result.ok) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isGenerating: false,
|
||||
isCancelling: false,
|
||||
canCancel: false,
|
||||
error: result,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
const payload = result.data
|
||||
if (payload.status === 'complete') {
|
||||
setState({
|
||||
isGenerating: false,
|
||||
isCancelling: false,
|
||||
canCancel: false,
|
||||
progress: 100,
|
||||
statusMessage: 'Complete!',
|
||||
videoPath: payload.video_path,
|
||||
imagePath: null,
|
||||
imagePaths: [],
|
||||
error: null,
|
||||
})
|
||||
} else if (payload.status === 'cancelled') {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isGenerating: false,
|
||||
isCancelling: false,
|
||||
canCancel: false,
|
||||
statusMessage: 'Cancelled',
|
||||
}))
|
||||
} else {
|
||||
throw new Error('Unexpected response from /api/generate')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isGenerating: false,
|
||||
isCancelling: false,
|
||||
canCancel: false,
|
||||
error: createLocalGenerationError(error instanceof Error ? error.message : 'Unknown error'),
|
||||
}))
|
||||
} finally {
|
||||
shouldApplyPollingUpdates = false
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [shouldImageGenerateWithFalApi, shouldVideoGenerateWithLtxApi])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
let claimedCancelling = false
|
||||
setState(prev => {
|
||||
if (!prev.isGenerating || prev.isCancelling) return prev
|
||||
claimedCancelling = true
|
||||
return {
|
||||
...prev,
|
||||
isCancelling: true,
|
||||
statusMessage: 'Cancelling…',
|
||||
}
|
||||
})
|
||||
// Always POST — retake/extend/IC-LoRA Stop reuse this while this hook is idle.
|
||||
void (async () => {
|
||||
const result = await ApiClient.cancelGeneration()
|
||||
const accepted = result.ok && result.data.status === 'cancelling'
|
||||
if (accepted || !claimedCancelling) return
|
||||
setState(prev => {
|
||||
if (!prev.isCancelling) return prev
|
||||
return { ...prev, isCancelling: false }
|
||||
})
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const generateImage = useCallback(async (
|
||||
prompt: string,
|
||||
settings: GenerationSettings,
|
||||
editSource?: string | null,
|
||||
) => {
|
||||
const isEditing = !!editSource
|
||||
|
||||
const openFalConnectDialog = () => {
|
||||
window.dispatchEvent(new CustomEvent('open-api-gateway', {
|
||||
detail: {
|
||||
requiredKeys: ['fal'],
|
||||
title: 'Connect FAL AI',
|
||||
description: `FAL AI is required for ${isEditing ? 'editing' : 'generating'} images with Z Image Turbo when API generations are enabled.`,
|
||||
blocking: false,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
if (shouldImageGenerateWithFalApi) {
|
||||
const settingsResult = await ApiClient.getSettings()
|
||||
const hasFalApiKey = settingsResult.ok ? settingsResult.data.hasFalApiKey : appSettings.hasFalApiKey
|
||||
if (!hasFalApiKey) {
|
||||
if (settingsResult.ok) void refreshSettings()
|
||||
openFalConnectDialog()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const numImages = settings.variations || 1
|
||||
|
||||
setState({
|
||||
isGenerating: true,
|
||||
isCancelling: false,
|
||||
canCancel: canCancelLocalJob('image', shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi),
|
||||
progress: 0,
|
||||
statusMessage: isEditing
|
||||
? 'Editing image...'
|
||||
: numImages > 1 ? `Generating ${numImages} images...` : 'Generating image...',
|
||||
videoPath: null,
|
||||
imagePath: null,
|
||||
imagePaths: [],
|
||||
error: null,
|
||||
})
|
||||
|
||||
await withGenerationActive(async () => {
|
||||
let progressInterval: ReturnType<typeof setInterval> | null = null
|
||||
try {
|
||||
const preparedEditSource = await prepareGenerationInput(editSource)
|
||||
// Skip prompt enhancement for T2I - use original prompt directly
|
||||
const finalPrompt = prompt
|
||||
|
||||
// Edit runs at the source image's resolution; width/height are ignored server-side.
|
||||
const dims = isEditing ? { width: 1024, height: 1024 } : getImageDimensions(settings)
|
||||
const numSteps = settings.imageSteps || (isEditing ? 8 : 4)
|
||||
|
||||
// Poll for progress
|
||||
const pollProgress = async () => {
|
||||
const result = await ApiClient.getGenerationProgress()
|
||||
if (!result.ok) return
|
||||
|
||||
const data = result.data
|
||||
const currentImage = data.currentStep || 0
|
||||
const totalImages = data.totalSteps || numImages
|
||||
setState(prev => {
|
||||
if (prev.isCancelling) {
|
||||
return { ...prev, statusMessage: 'Cancelling…' }
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
progress: data.progress,
|
||||
statusMessage: data.phase === 'loading_model'
|
||||
? 'Loading Z-Image Turbo model...'
|
||||
: data.phase === 'inference'
|
||||
? isEditing
|
||||
? 'Editing image...'
|
||||
: numImages > 1
|
||||
? `Generating image ${currentImage + 1}/${totalImages}...`
|
||||
: 'Generating image...'
|
||||
: data.phase === 'complete'
|
||||
? 'Complete!'
|
||||
: 'Generating...',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
progressInterval = setInterval(pollProgress, 500)
|
||||
|
||||
const imageRequest: GenerateImageRequest = {
|
||||
prompt: finalPrompt,
|
||||
width: dims.width,
|
||||
height: dims.height,
|
||||
numSteps,
|
||||
numImages,
|
||||
// strength is ignored server-side unless imagePath is set, but the request type
|
||||
// requires it — send the default rather than the edit-only setting when not editing.
|
||||
strength: isEditing ? (settings.imageEditStrength ?? 0.6) : 0.6,
|
||||
...(isEditing && preparedEditSource ? { imagePath: preparedEditSource } : {}),
|
||||
}
|
||||
const result = await ApiClient.generateImage(imageRequest)
|
||||
|
||||
if (!result.ok) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isGenerating: false,
|
||||
isCancelling: false,
|
||||
canCancel: false,
|
||||
error: result,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
const payload = result.data
|
||||
if (payload.status === 'complete') {
|
||||
const rawPaths = payload.image_paths
|
||||
if (rawPaths.length === 0) {
|
||||
throw new Error('Image generation completed without output images')
|
||||
}
|
||||
|
||||
setState({
|
||||
isGenerating: false,
|
||||
isCancelling: false,
|
||||
canCancel: false,
|
||||
progress: 100,
|
||||
statusMessage: 'Complete!',
|
||||
videoPath: null,
|
||||
imagePath: rawPaths[0],
|
||||
imagePaths: rawPaths,
|
||||
error: null,
|
||||
})
|
||||
} else if (payload.status === 'cancelled') {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isGenerating: false,
|
||||
isCancelling: false,
|
||||
canCancel: false,
|
||||
statusMessage: 'Cancelled',
|
||||
}))
|
||||
} else {
|
||||
throw new Error('Unexpected response from /api/generate-image')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isGenerating: false,
|
||||
isCancelling: false,
|
||||
canCancel: false,
|
||||
error: createLocalGenerationError(error instanceof Error ? error.message : 'Unknown error'),
|
||||
}))
|
||||
} finally {
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [appSettings.hasFalApiKey, shouldImageGenerateWithFalApi, shouldVideoGenerateWithLtxApi, refreshSettings])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
clearRecoveryPolling()
|
||||
localStorage.removeItem(GENERATION_RECOVERY_KEY)
|
||||
setState({
|
||||
isGenerating: false,
|
||||
isCancelling: false,
|
||||
canCancel: false,
|
||||
progress: 0,
|
||||
statusMessage: '',
|
||||
videoPath: null,
|
||||
imagePath: null,
|
||||
imagePaths: [],
|
||||
error: null,
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
...state,
|
||||
generate,
|
||||
generateImage,
|
||||
cancel,
|
||||
reset,
|
||||
resumeIfRunning,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { subscribeWhileGenerationMayBeActive } from '../lib/generation-progress-poll'
|
||||
import { GENERATION_RECOVERY_KEY, readRecoveryMarkerCanCancel } from './use-generation'
|
||||
|
||||
export interface GlobalGenerationLock {
|
||||
// Fail-closed Generate disable: a recovery marker, a running poll, or an unconfirmed
|
||||
// poll error all mean the single global slot must be treated as busy.
|
||||
isRunning: boolean
|
||||
// Same poll as isRunning, gated on the backend's frozen local-GPU vs API slot.
|
||||
// Never derived from live Settings — switching to local mid-API-job must not reveal Stop.
|
||||
canCancel: boolean
|
||||
// Slot still busy after Stop (`status=running`, `phase=cancelled`) until generate() unwinds.
|
||||
isCancelling: boolean
|
||||
}
|
||||
|
||||
// Only one generation can run at a time across the whole app (single global backend slot), but
|
||||
// each project's GenSpace only tracks its OWN local isGenerating-style state — it has no idea a
|
||||
// different project (or the same one, reconnected via a stale click) is already occupying that
|
||||
// slot. Without this, Generate stays clickable in project B while project A is mid-generation;
|
||||
// the request 409s, but only after writeRecoveryContext already overwrote A's in-flight recovery
|
||||
// marker with B's (now-failed) one. Polling here lets Generate disable proactively instead.
|
||||
// Stop uses the same sources: the recovery marker (immediate, frozen at job start) and
|
||||
// GET /generation/progress.cancellable (authoritative once a poll lands). Hook-local canCancel
|
||||
// dies on UI refresh; these do not. Live Settings must not flip Stop mid-job.
|
||||
// No marker anywhere means nothing CAN be running (see subscribeWhileGenerationMayBeActive), so
|
||||
// idle starts unlocked and costs no network call; once a marker exists and we're actually
|
||||
// polling, an unconfirmed/failed poll is treated as locked rather than silently trusting "not
|
||||
// running" — that unconfirmed-failure gap is exactly what previously let Generate stay clickable
|
||||
// during another project's generation. The initial state has to check the marker too, not just
|
||||
// hardcode false: a page refresh resets this hook's React state from scratch while another
|
||||
// project's marker (and its still-running backend generation) survives in localStorage, and the
|
||||
// first poll takes a network round trip to resolve — that gap is otherwise the same unconfirmed
|
||||
// window all over again, just re-opened on every reload instead of only at first app launch.
|
||||
export function useGlobalGenerationLock(): GlobalGenerationLock {
|
||||
const [lock, setLock] = useState<GlobalGenerationLock>(() => ({
|
||||
isRunning: localStorage.getItem(GENERATION_RECOVERY_KEY) != null,
|
||||
canCancel: readRecoveryMarkerCanCancel(),
|
||||
isCancelling: false,
|
||||
}))
|
||||
|
||||
useEffect(() => subscribeWhileGenerationMayBeActive(result => {
|
||||
if (!result.ok) {
|
||||
// Same fail-closed Generate lock as before. Stop keeps the frozen marker bit rather
|
||||
// than guessing — a starved poll during local GPU work must not hide Stop, and a
|
||||
// failed poll during an API job must not reveal it.
|
||||
setLock({
|
||||
isRunning: true,
|
||||
canCancel: readRecoveryMarkerCanCancel(),
|
||||
isCancelling: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
const isRunning = result.data.status === 'running'
|
||||
setLock({
|
||||
isRunning,
|
||||
canCancel: isRunning && result.data.cancellable,
|
||||
isCancelling: isRunning && result.data.phase === 'cancelled',
|
||||
})
|
||||
}), [])
|
||||
|
||||
return lock
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { ApiClient, type ApiSuccessOf } from '../lib/api-client'
|
||||
import { logger } from '../lib/logger'
|
||||
|
||||
type HfAuthStatus = ApiSuccessOf<'getHuggingFaceAuthStatus'>['status']
|
||||
|
||||
interface UseHfAuthResult {
|
||||
hfAuthStatus: HfAuthStatus
|
||||
hfAuthPolling: boolean
|
||||
startHuggingFaceLogin: () => Promise<void>
|
||||
handleHuggingFaceLogout: () => Promise<void>
|
||||
}
|
||||
|
||||
const NOOP = async () => {}
|
||||
|
||||
export function useHfAuth(enabled: boolean): UseHfAuthResult {
|
||||
// Used for gated downloads (LTX 2.5 base models, catalog LoRAs / IC-LoRAs).
|
||||
const [hfAuthStatus, setHfAuthStatus] = useState<HfAuthStatus>('not_authenticated')
|
||||
const [hfAuthPolling, setHfAuthPolling] = useState(false)
|
||||
|
||||
// One-time check when enabled becomes true
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
const checkAuth = async () => {
|
||||
const result = await ApiClient.getHuggingFaceAuthStatus()
|
||||
if (!result.ok) {
|
||||
logger.error(`HF auth status check failed: ${result.error.message}`)
|
||||
return
|
||||
}
|
||||
setHfAuthStatus(result.data.status)
|
||||
}
|
||||
void checkAuth()
|
||||
}, [enabled])
|
||||
|
||||
// Poll while waiting for user to complete auth in browser
|
||||
useEffect(() => {
|
||||
if (!hfAuthPolling) return
|
||||
const interval = setInterval(async () => {
|
||||
const result = await ApiClient.getHuggingFaceAuthStatus()
|
||||
if (!result.ok) {
|
||||
logger.error(`HF auth status check failed: ${result.error.message}`)
|
||||
return
|
||||
}
|
||||
const { status } = result.data
|
||||
setHfAuthStatus(status)
|
||||
if (status === 'authenticated') setHfAuthPolling(false)
|
||||
}, 2000)
|
||||
return () => clearInterval(interval)
|
||||
}, [hfAuthPolling])
|
||||
|
||||
const startHuggingFaceLogin = useCallback(async () => {
|
||||
const result = await ApiClient.startHuggingFaceLogin()
|
||||
if (!result.ok) {
|
||||
logger.error(`HF login failed: ${result.error.message}`)
|
||||
return
|
||||
}
|
||||
|
||||
const params = result.data
|
||||
setHfAuthPolling(true)
|
||||
await window.electronAPI.openHuggingFaceAuth({
|
||||
clientId: params.client_id,
|
||||
redirectUri: params.redirect_uri,
|
||||
scope: params.scope,
|
||||
state: params.state,
|
||||
codeChallenge: params.code_challenge,
|
||||
codeChallengeMethod: params.code_challenge_method,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleHuggingFaceLogout = useCallback(async () => {
|
||||
const result = await ApiClient.huggingFaceLogout()
|
||||
if (!result.ok) {
|
||||
logger.error(`HF logout failed: ${result.error.message}`)
|
||||
return
|
||||
}
|
||||
setHfAuthStatus('not_authenticated')
|
||||
}, [])
|
||||
|
||||
if (!enabled) {
|
||||
return {
|
||||
hfAuthStatus: 'authenticated',
|
||||
hfAuthPolling: false,
|
||||
startHuggingFaceLogin: NOOP,
|
||||
handleHuggingFaceLogout: NOOP,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hfAuthStatus,
|
||||
hfAuthPolling,
|
||||
startHuggingFaceLogin,
|
||||
handleHuggingFaceLogout,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../lib/api-client'
|
||||
import { logger } from '../lib/logger'
|
||||
|
||||
type HfAuthStatus = ApiSuccessOf<'getHuggingFaceAuthStatus'>['status']
|
||||
type ModelAccessMap = ApiSuccessOf<'checkModelAccess'>['access']
|
||||
type CheckModelAccessBody = NonNullable<ApiRequestBodyOf<'checkModelAccess'>>
|
||||
type ModelCheckpointID = NonNullable<CheckModelAccessBody['cp_ids']>[number]
|
||||
|
||||
interface UseHfModelAccessResult {
|
||||
accessMap: ModelAccessMap
|
||||
allAuthorized: boolean
|
||||
checking: boolean
|
||||
/** Set when the access check itself failed — distinct from "not authorized". */
|
||||
checkError: string | null
|
||||
recheckAccess: () => void
|
||||
}
|
||||
|
||||
export function useHfModelAccess(modelTypes: readonly ModelCheckpointID[], hfAuthStatus: HfAuthStatus): UseHfModelAccessResult {
|
||||
const [accessMap, setAccessMap] = useState<ModelAccessMap>({})
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [polling, setPolling] = useState(false)
|
||||
const [checkError, setCheckError] = useState<string | null>(null)
|
||||
|
||||
// Nothing to authorize, or every returned repo is authorized. Empty map with pending
|
||||
// checkpoints is NOT authorized — that covers both "still checking" and a failed check
|
||||
// (which leaves the map empty and sets checkError for the gate to show).
|
||||
const allAuthorized = modelTypes.length === 0
|
||||
|| (Object.keys(accessMap).length > 0
|
||||
&& Object.values(accessMap).every((status) => status === 'authorized'))
|
||||
|
||||
const doCheck = useCallback(async () => {
|
||||
if (modelTypes.length === 0) return
|
||||
setChecking(true)
|
||||
const result = await ApiClient.checkModelAccess({ cp_ids: [...modelTypes] })
|
||||
if (!result.ok) {
|
||||
logger.error(`Model access check failed: ${result.error.message}`)
|
||||
setAccessMap({})
|
||||
setCheckError(result.error.message)
|
||||
setChecking(false)
|
||||
return
|
||||
}
|
||||
|
||||
const { access } = result.data
|
||||
setAccessMap(access)
|
||||
setCheckError(null)
|
||||
const allOk = Object.values(access).every((s) => s === 'authorized')
|
||||
if (allOk) setPolling(false)
|
||||
setChecking(false)
|
||||
}, [modelTypes])
|
||||
|
||||
// Signed out still needs a check: gated repos (LTX 2.5) can't be downloaded without a token.
|
||||
useEffect(() => {
|
||||
if (modelTypes.length === 0) {
|
||||
setAccessMap((current) => (Object.keys(current).length === 0 ? current : {}))
|
||||
setCheckError(null)
|
||||
setPolling(false)
|
||||
return
|
||||
}
|
||||
void doCheck()
|
||||
setPolling(true)
|
||||
}, [hfAuthStatus, modelTypes.length, doCheck])
|
||||
|
||||
// Poll while any model is not_authorized
|
||||
useEffect(() => {
|
||||
if (!polling || hfAuthStatus !== 'authenticated') return
|
||||
const interval = setInterval(() => { void doCheck() }, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [polling, hfAuthStatus, doCheck])
|
||||
|
||||
const recheckAccess = useCallback(() => {
|
||||
void doCheck()
|
||||
}, [doCheck])
|
||||
|
||||
return { accessMap, allAuthorized, checking, checkError, recheckAccess }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ApiSuccessOf } from '../lib/api-client'
|
||||
import { useIcLoras, type IcLoraListItem } from './use-catalog'
|
||||
import { catalogItemToEntry, preferredVariantId, resolveDownloadedVariantIds } from '../lib/lora-library'
|
||||
|
||||
// Owns the IC-LoRA library wiring: the catalog hook (fetch + download), the modal open state,
|
||||
// the selected recipe id (+ optional variant), and the catalog→entry mapping for the modal.
|
||||
// Selection *side effects* (seeding the generation controls/settings from the picked recipe)
|
||||
// stay with the caller via onSelect — they touch GenSpace gen state.
|
||||
export function useIcLoraLibrary(
|
||||
enabled: boolean,
|
||||
onSelect: (item: IcLoraListItem | null) => void,
|
||||
installed: ApiSuccessOf<'listModels'>['models'] = [],
|
||||
refreshInstalled: () => void = () => {},
|
||||
) {
|
||||
const { icLoras, downloadIcLora, downloadingKey, progress, downloadError } = useIcLoras(enabled)
|
||||
const [selectedIcLoraId, setSelectedIcLoraId] = useState<string | null>(null)
|
||||
const [selectedIcLoraVariantId, setSelectedIcLoraVariantId] = useState<string | null>(null)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const installedPaths = useMemo(() => installed.map(m => m.path), [installed])
|
||||
const items = useMemo(
|
||||
() => icLoras.map(r => {
|
||||
const base = catalogItemToEntry(r.ic_lora)
|
||||
const downloadedVariantIds = resolveDownloadedVariantIds(
|
||||
base.variants,
|
||||
r.downloaded_variant_ids,
|
||||
installedPaths,
|
||||
)
|
||||
return {
|
||||
...base,
|
||||
downloaded: r.downloaded || downloadedVariantIds.length > 0,
|
||||
downloadedVariantIds,
|
||||
}
|
||||
}),
|
||||
[icLoras, installedPaths],
|
||||
)
|
||||
|
||||
// Re-list on-disk IC-LoRAs once a download finishes so per-variant ✓ / Use stay accurate
|
||||
// even if the catalog list payload lags or omits downloaded_variant_ids.
|
||||
const prevDownloadingKey = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (prevDownloadingKey.current && !downloadingKey) refreshInstalled()
|
||||
prevDownloadingKey.current = downloadingKey
|
||||
}, [downloadingKey, refreshInstalled])
|
||||
|
||||
const selectIcLora = useCallback(
|
||||
(item: IcLoraListItem | null, variantId?: string | null) => {
|
||||
setSelectedIcLoraId(item?.ic_lora.id ?? null)
|
||||
if (!item) {
|
||||
setSelectedIcLoraVariantId(null)
|
||||
} else {
|
||||
const entry = items.find(e => e.id === item.ic_lora.id)
|
||||
const preferred = preferredVariantId(
|
||||
entry?.variants,
|
||||
entry?.defaultVariantId,
|
||||
entry?.downloadedVariantIds,
|
||||
)
|
||||
// Explicit variant wins; otherwise only an *installed* preferred id (never undownloaded default).
|
||||
setSelectedIcLoraVariantId(variantId ?? preferred ?? null)
|
||||
}
|
||||
onSelect(item)
|
||||
},
|
||||
[onSelect, items],
|
||||
)
|
||||
return {
|
||||
icLoras,
|
||||
items,
|
||||
downloadIcLora,
|
||||
downloadingKey,
|
||||
progress,
|
||||
downloadError,
|
||||
modalOpen,
|
||||
setModalOpen,
|
||||
selectedIcLoraId,
|
||||
selectedIcLoraVariantId,
|
||||
selectIcLora,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { ApiClient, type ApiRequestBodyOf } from '../lib/api-client'
|
||||
import { withGenerationActive } from '../lib/generation-active'
|
||||
import { logger } from '../lib/logger'
|
||||
|
||||
export type IcLoraConditioningType = 'canny' | 'depth' | 'custom'
|
||||
export type IcLoraAudioMode = 'source' | 'generated' | 'off'
|
||||
|
||||
export interface IcLoraSubmitParams {
|
||||
videoPath: string
|
||||
conditioningType: IcLoraConditioningType
|
||||
conditioningStrength: number
|
||||
prompt: string
|
||||
// "custom": the user's own IC-LoRA weights + a pre-rendered control video.
|
||||
customLoraRef?: string
|
||||
controlVideoPath?: string
|
||||
// Skip Stage 2 refine — transformation IC-LoRAs need this; default off.
|
||||
skipStage2?: boolean
|
||||
// Keep the IC-LoRA active during Stage 2 refine (only when Stage 2 runs). Default off.
|
||||
// On: the transformation survives refinement and the output lands at the chosen resolution.
|
||||
useLoraInStage2?: boolean
|
||||
// Target output resolution for the useLoraInStage2 path (undefined = source). Backend
|
||||
// snaps it down to a valid size and never upscales above source.
|
||||
resolution?: { width: number; height: number }
|
||||
// Stage-1-only canvas multiplier: 2.0 = native, 1.0 = half. Only used when skipStage2.
|
||||
resolutionFactor?: number
|
||||
// Soundtrack: source (input clip) / generated (prompt) / off.
|
||||
audioMode?: IcLoraAudioMode
|
||||
// LoRA adapter merge weight (load-time). Default 1.0.
|
||||
loraStrength?: number
|
||||
// Override control-video fps (decimate to fewer frames). undefined = source fps.
|
||||
fpsOverride?: number
|
||||
// IC-LoRA mode: catalog IC-LoRA id + the user's input media path + output duration.
|
||||
// When icLoraId is set, the backend resolves catalog weights and builds the
|
||||
// control video via the IC-LoRA's preprocessing pipeline.
|
||||
icLoraId?: string
|
||||
// Catalog download.variants[].id when the entry has multiple checkpoints.
|
||||
variantId?: string
|
||||
inputPath?: string
|
||||
// Values for the IC-LoRA's declared controls, keyed by control id (e.g. duration).
|
||||
// Sent verbatim; the backend validates + maps them to behaviour.
|
||||
controlValues?: Record<string, number | string>
|
||||
// Outpainting (position_canvas control): per-edge pixels to add around the source.
|
||||
outpaintPads?: { left: number; right: number; top: number; bottom: number }
|
||||
// When true, an empty prompt is allowed (set from the IC-LoRA's allows_empty_prompt).
|
||||
allowEmptyPrompt?: boolean
|
||||
// Optional reference image for catalog IC-LoRAs that opt in (allows_reference_image).
|
||||
// Sent as image conditioning at frame 0, strength 1.0.
|
||||
referenceImagePath?: string
|
||||
}
|
||||
|
||||
export interface IcLoraResult {
|
||||
videoPath: string
|
||||
}
|
||||
|
||||
interface UseIcLoraState {
|
||||
isGenerating: boolean
|
||||
canCancel: boolean
|
||||
status: string
|
||||
error: string | null
|
||||
result: IcLoraResult | null
|
||||
}
|
||||
|
||||
type GenerateIcLoraBody = ApiRequestBodyOf<'generateIcLora'>
|
||||
|
||||
export function useIcLora() {
|
||||
const [state, setState] = useState<UseIcLoraState>({
|
||||
isGenerating: false,
|
||||
canCancel: false,
|
||||
status: '',
|
||||
error: null,
|
||||
result: null,
|
||||
})
|
||||
|
||||
const submitIcLora = useCallback(async (params: IcLoraSubmitParams) => {
|
||||
if (!params.prompt.trim() && !params.allowEmptyPrompt) return
|
||||
// IC-LoRA mode needs inputPath; canny/depth/custom need videoPath.
|
||||
if (!params.icLoraId && !params.videoPath) return
|
||||
if (params.icLoraId && !params.inputPath) return
|
||||
|
||||
setState({
|
||||
isGenerating: true,
|
||||
// IC-LoRA is always local GPU (the tab is hidden when forceApiGenerations).
|
||||
// Do not gate on shouldVideoGenerateWithLtxApi — that flag is for t2v/i2v.
|
||||
canCancel: true,
|
||||
status: 'Generating',
|
||||
error: null,
|
||||
result: null,
|
||||
})
|
||||
|
||||
await withGenerationActive(async () => {
|
||||
const result = await ApiClient.generateIcLora({
|
||||
video_path: params.videoPath,
|
||||
conditioning_type: params.conditioningType,
|
||||
conditioning_strength: params.conditioningStrength,
|
||||
prompt: params.prompt,
|
||||
custom_lora_ref: params.customLoraRef,
|
||||
control_video_path: params.controlVideoPath,
|
||||
skip_stage_2: params.skipStage2,
|
||||
use_lora_in_stage_2: params.useLoraInStage2,
|
||||
resolution: params.resolution,
|
||||
resolution_factor: params.resolutionFactor,
|
||||
audio_mode: params.audioMode,
|
||||
lora_strength: params.loraStrength,
|
||||
fps_override: params.fpsOverride,
|
||||
ic_lora_id: params.icLoraId,
|
||||
variant_id: params.variantId,
|
||||
input_path: params.inputPath,
|
||||
control_values: params.controlValues,
|
||||
outpaint_pads: params.outpaintPads,
|
||||
images: params.referenceImagePath
|
||||
? [{ path: params.referenceImagePath, frame: 0, strength: 1.0 }]
|
||||
: [],
|
||||
} as GenerateIcLoraBody)
|
||||
if (!result.ok) {
|
||||
logger.error(`IC-LoRA error: ${result.error.message}`)
|
||||
setState({
|
||||
isGenerating: false,
|
||||
canCancel: false,
|
||||
status: '',
|
||||
error: result.error.message,
|
||||
result: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const payload = result.data
|
||||
if (payload.status === 'cancelled') {
|
||||
setState({
|
||||
isGenerating: false,
|
||||
canCancel: false,
|
||||
status: 'Cancelled',
|
||||
error: null,
|
||||
result: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.status === 'complete') {
|
||||
setState({
|
||||
isGenerating: false,
|
||||
canCancel: false,
|
||||
status: 'Generation complete!',
|
||||
error: null,
|
||||
result: {
|
||||
videoPath: payload.video_path,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({
|
||||
isGenerating: false,
|
||||
canCancel: false,
|
||||
status: '',
|
||||
error: null,
|
||||
result: null,
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
submitIcLora,
|
||||
resetIcLora: reset,
|
||||
isIcLoraGenerating: state.isGenerating,
|
||||
canCancel: state.canCancel,
|
||||
icLoraStatus: state.status,
|
||||
icLoraError: state.error,
|
||||
icLoraResult: state.result,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ApiSuccessOf } from '../lib/api-client'
|
||||
import type { LoraSelection } from '../components/SettingsPanel'
|
||||
import { useLoraCatalog } from './use-catalog'
|
||||
import {
|
||||
mergeLoraLibrary,
|
||||
resolveInstalledPath,
|
||||
variantDisplayName,
|
||||
type LibraryEntry,
|
||||
} from '../lib/lora-library'
|
||||
|
||||
// Owns the plain-LoRA library wiring: the catalog hook, the catalog ∪ on-disk merge, the
|
||||
// modal open state, "use this LoRA" → selectedLoras, and re-listing installed files when a
|
||||
// download finishes. Kept out of GenSpace so the view just consumes the result.
|
||||
export function useLoraLibrary(
|
||||
enabled: boolean,
|
||||
installed: ApiSuccessOf<'listModels'>['models'],
|
||||
selected: LoraSelection[],
|
||||
onSelectedChange: (loras: LoraSelection[]) => void,
|
||||
refreshInstalled: () => void,
|
||||
) {
|
||||
const { loras, downloadLora, downloadingKey, progress, downloadError } = useLoraCatalog(enabled)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [useError, setUseError] = useState<string | null>(null)
|
||||
const items = useMemo(() => mergeLoraLibrary(loras, installed), [loras, installed])
|
||||
|
||||
// Re-list on-disk files once a download finishes so the new file appears + is usable.
|
||||
const prevDownloadingKey = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (prevDownloadingKey.current && !downloadingKey) refreshInstalled()
|
||||
prevDownloadingKey.current = downloadingKey
|
||||
}, [downloadingKey, refreshInstalled])
|
||||
|
||||
// Returns whether the LoRA was actually added, so the modal knows whether it's safe to
|
||||
// close. resolveInstalledPath never substitutes a different variant's path — if the catalog
|
||||
// says this variant is downloaded but its path hasn't landed yet (listModels refresh race),
|
||||
// re-trigger the refresh and surface a retry message instead of silently using the wrong file.
|
||||
const useEntry = useCallback((e: LibraryEntry, variantId?: string) => {
|
||||
const path = resolveInstalledPath(e, variantId)
|
||||
if (!path) {
|
||||
if (variantId && e.downloadedVariantIds?.includes(variantId)) {
|
||||
refreshInstalled()
|
||||
setUseError('Still syncing installed files — try again in a moment.')
|
||||
}
|
||||
return false
|
||||
}
|
||||
setUseError(null)
|
||||
if (selected.some(s => s.ref === path)) return true
|
||||
const variantLabel = variantId ? e.variants?.find(v => v.id === variantId)?.label : undefined
|
||||
onSelectedChange([
|
||||
...selected,
|
||||
{
|
||||
ref: path,
|
||||
name: variantDisplayName(e.name, variantLabel, e.variants?.length),
|
||||
scale: e.recommendedStrength ?? 1.0,
|
||||
catalogId: e.id,
|
||||
},
|
||||
])
|
||||
return true
|
||||
}, [selected, onSelectedChange, refreshInstalled])
|
||||
|
||||
return {
|
||||
items, downloadLora, downloadingKey, progress, downloadError,
|
||||
modalOpen, setModalOpen, useEntry, useError,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ApiClient } from '../lib/api-client'
|
||||
import { useAppSettings } from '../contexts/AppSettingsContext'
|
||||
|
||||
export type EnhanceProvider = 'local' | 'api'
|
||||
|
||||
interface UsePromptEnhancerProviderResult {
|
||||
// Local requires the Gemma text-encoder checkpoint to be downloaded AND local generation to
|
||||
// actually be usable this run (e.g. not memory-constrained into API-only mode); API requires a
|
||||
// stored Gemini key to actually run, but the option stays selectable without one so Enhance
|
||||
// can send the user to Settings instead of hiding the choice.
|
||||
hasLocalTextEncoder: boolean
|
||||
hasGeminiApiKey: boolean
|
||||
// The provider Enhance will actually use: the persisted preference when it's currently
|
||||
// choosable. API remains choosable without a key (clicking Enhance then opens Settings).
|
||||
// Local that's temporarily unavailable (e.g. memory-constrained run) falls back silently —
|
||||
// it does NOT overwrite the persisted preference, which only an explicit setProviderPreference
|
||||
// call changes.
|
||||
provider: EnhanceProvider
|
||||
// Shown when local Enhance is available, so the user can still pick API (Gemini) before
|
||||
// they've added a key. Hidden when local isn't an option — the button is already API-only.
|
||||
canToggleProvider: boolean
|
||||
setProviderPreference: (provider: EnhanceProvider) => void
|
||||
}
|
||||
|
||||
// Single source of truth for which prompt-enhancer provider (local Gemma text encoder vs.
|
||||
// Gemini's hosted API) is available and which one Enhance should use. `enabled` gates the local
|
||||
// checkpoint lookup so it only fires once the enhancer could plausibly be shown for the current
|
||||
// mode.
|
||||
export function usePromptEnhancerProvider(enabled: boolean): UsePromptEnhancerProviderResult {
|
||||
const {
|
||||
settings: { hasGeminiApiKey, promptEnhancerProviderPreference },
|
||||
updateSettings,
|
||||
forceApiGenerations,
|
||||
modelsVersion,
|
||||
} = useAppSettings()
|
||||
|
||||
const [isLocalEncoderUsable, setIsLocalEncoderUsable] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
let cancelled = false
|
||||
void ApiClient.getTextEncoderRecommendation().then((result) => {
|
||||
// Deliberately not cp_to_download: the encoder that runs generations isn't always the one
|
||||
// that can enhance (LTX 2.5's encodes only, and enhances from a separate checkpoint), so
|
||||
// the backend reports enhancer availability on its own.
|
||||
if (!cancelled) {
|
||||
setIsLocalEncoderUsable(result.ok && result.data.local_enhancement_supported)
|
||||
}
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
// modelsVersion: the enhancer is a download the user can make mid-session, and Enhance should
|
||||
// become available without a restart.
|
||||
}, [enabled, modelsVersion])
|
||||
|
||||
// Downloaded isn't enough on its own — forceApiGenerations is the pure "insufficient memory
|
||||
// for local models this run" signal (deliberately NOT shouldVideoGenerateWithLtxApi, which
|
||||
// also folds in the user's own preference to use the LTX API for VIDEO specifically — that's
|
||||
// unrelated to whether the much smaller Gemma text encoder can run locally right now).
|
||||
const hasLocalTextEncoder = isLocalEncoderUsable && !forceApiGenerations
|
||||
const canToggleProvider = hasLocalTextEncoder
|
||||
|
||||
// Default to local when the user hasn't made an explicit choice, or when they asked for
|
||||
// local and it's currently usable. API preference is honored even without a Gemini key so
|
||||
// the Enhance (API) option isn't silently replaced by local.
|
||||
const provider: EnhanceProvider =
|
||||
promptEnhancerProviderPreference === 'api' ? 'api'
|
||||
: promptEnhancerProviderPreference === 'local' && hasLocalTextEncoder ? 'local'
|
||||
: hasLocalTextEncoder ? 'local'
|
||||
: 'api'
|
||||
|
||||
const setProviderPreference = (next: EnhanceProvider) => {
|
||||
updateSettings({ promptEnhancerProviderPreference: next })
|
||||
}
|
||||
|
||||
return {
|
||||
hasLocalTextEncoder,
|
||||
hasGeminiApiKey,
|
||||
provider,
|
||||
canToggleProvider,
|
||||
setProviderPreference,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { components } from '../generated/backend-openapi'
|
||||
import { ApiClient } from '../lib/api-client'
|
||||
import { canCancelLocalJob, withGenerationActive } from '../lib/generation-active'
|
||||
import { logger } from '../lib/logger'
|
||||
import { prepareGenerationInput } from '../lib/generation-input'
|
||||
import { useAppSettings } from '../contexts/AppSettingsContext'
|
||||
|
||||
export type RetakeMode = 'replace_audio_and_video' | 'replace_video' | 'replace_audio'
|
||||
|
||||
// ltxv-api /v1/retake and /v2/extend accept ltx-2-pro / ltx-2-3-pro.
|
||||
// Desktop maps those to pipeline "pro".
|
||||
export type RetakeExtendModel = components['schemas']['RetakeRequest']['model']
|
||||
|
||||
// Runtime options for the retake/extend MODEL dropdown. Checked against the OpenAPI union
|
||||
// so a schema change that adds/removes a value fails typecheck until this list is updated.
|
||||
export const RETAKE_EXTEND_MODELS = ['pro'] as const satisfies ReadonlyArray<RetakeExtendModel>
|
||||
|
||||
/** Map a persisted video pipeline id onto the nearest retake/extend model. */
|
||||
export function retakeExtendModelFromPipeline(
|
||||
_model: string | undefined | null,
|
||||
): RetakeExtendModel {
|
||||
return 'pro'
|
||||
}
|
||||
|
||||
export interface RetakeSubmitParams {
|
||||
videoPath: string
|
||||
startTime: number
|
||||
duration: number
|
||||
prompt: string
|
||||
mode: RetakeMode
|
||||
resolution?: { width: number; height: number }
|
||||
model: RetakeExtendModel
|
||||
}
|
||||
|
||||
export interface RetakeResult {
|
||||
videoPath: string
|
||||
}
|
||||
|
||||
interface UseRetakeState {
|
||||
isRetaking: boolean
|
||||
canCancel: boolean
|
||||
retakeStatus: string
|
||||
retakeError: string | null
|
||||
result: RetakeResult | null
|
||||
}
|
||||
|
||||
export function useRetake() {
|
||||
const { shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi } = useAppSettings()
|
||||
const [state, setState] = useState<UseRetakeState>({
|
||||
isRetaking: false,
|
||||
canCancel: false,
|
||||
retakeStatus: '',
|
||||
retakeError: null,
|
||||
result: null,
|
||||
})
|
||||
|
||||
const submitRetake = useCallback(async (params: RetakeSubmitParams) => {
|
||||
if (!params.videoPath) return
|
||||
|
||||
setState({
|
||||
isRetaking: true,
|
||||
canCancel: canCancelLocalJob('video', shouldVideoGenerateWithLtxApi, shouldImageGenerateWithFalApi),
|
||||
retakeStatus: 'Generating',
|
||||
retakeError: null,
|
||||
result: null,
|
||||
})
|
||||
|
||||
await withGenerationActive(async () => {
|
||||
let preparedVideoPath: string
|
||||
try {
|
||||
preparedVideoPath = (await prepareGenerationInput(params.videoPath)) ?? params.videoPath
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to prepare source video'
|
||||
logger.error(`Retake input error: ${message}`)
|
||||
setState({
|
||||
isRetaking: false,
|
||||
canCancel: false,
|
||||
retakeStatus: '',
|
||||
retakeError: message,
|
||||
result: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const result = await ApiClient.retake({
|
||||
video_path: preparedVideoPath,
|
||||
start_time: params.startTime,
|
||||
duration: params.duration,
|
||||
prompt: params.prompt,
|
||||
mode: params.mode,
|
||||
resolution: params.resolution,
|
||||
model: params.model,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
logger.error(`Retake error: ${result.error.message}`)
|
||||
setState({
|
||||
isRetaking: false,
|
||||
canCancel: false,
|
||||
retakeStatus: '',
|
||||
retakeError: result.error.message,
|
||||
result: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const payload = result.data
|
||||
|
||||
if (payload.status === 'cancelled') {
|
||||
setState({
|
||||
isRetaking: false,
|
||||
canCancel: false,
|
||||
retakeStatus: 'Cancelled',
|
||||
retakeError: null,
|
||||
result: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if ('video_path' in payload) {
|
||||
setState({
|
||||
isRetaking: false,
|
||||
canCancel: false,
|
||||
retakeStatus: 'Retake complete!',
|
||||
retakeError: null,
|
||||
result: {
|
||||
videoPath: payload.video_path,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger.error(`Retake completed without local video payload: ${JSON.stringify(payload.result)}`)
|
||||
const errorMsg = 'Retake completed but no local video file was returned'
|
||||
setState({
|
||||
isRetaking: false,
|
||||
canCancel: false,
|
||||
retakeStatus: '',
|
||||
retakeError: errorMsg,
|
||||
result: null,
|
||||
})
|
||||
})
|
||||
}, [shouldImageGenerateWithFalApi, shouldVideoGenerateWithLtxApi])
|
||||
|
||||
const resetRetake = useCallback(() => {
|
||||
setState({
|
||||
isRetaking: false,
|
||||
canCancel: false,
|
||||
retakeStatus: '',
|
||||
retakeError: null,
|
||||
result: null,
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
submitRetake,
|
||||
resetRetake,
|
||||
isRetaking: state.isRetaking,
|
||||
canCancel: state.canCancel,
|
||||
retakeStatus: state.retakeStatus,
|
||||
retakeError: state.retakeError,
|
||||
retakeResult: state.result,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAppSettings } from '../contexts/AppSettingsContext'
|
||||
import { ApiClient } from '../lib/api-client'
|
||||
import type { VideoGenerationModelSpecsResponse } from '../lib/video-generation-model-specs'
|
||||
|
||||
interface VideoGenerationModelSpecsState {
|
||||
modelSpecs: VideoGenerationModelSpecsResponse | null
|
||||
isLoading: boolean
|
||||
errorMessage: string | null
|
||||
}
|
||||
|
||||
export function useVideoGenerationModelSpecs(): VideoGenerationModelSpecsState {
|
||||
// Local specs describe the *active* LTX version, so they go stale as soon as the user
|
||||
// downloads, deletes, or activates a version — refetch on the models-changed signal.
|
||||
const { modelsVersion } = useAppSettings()
|
||||
const [state, setState] = useState<VideoGenerationModelSpecsState>({
|
||||
modelSpecs: null,
|
||||
isLoading: true,
|
||||
errorMessage: null,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController()
|
||||
let isActive = true
|
||||
|
||||
void (async () => {
|
||||
const result = await ApiClient.getGenerateVideoModelSpecs(undefined, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
if (!isActive) return
|
||||
|
||||
if (result.ok) {
|
||||
setState({
|
||||
modelSpecs: result.data,
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setState({
|
||||
modelSpecs: null,
|
||||
isLoading: false,
|
||||
errorMessage: result.error.message,
|
||||
})
|
||||
})()
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
abortController.abort()
|
||||
}
|
||||
}, [modelsVersion])
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useProjects } from '../contexts/ProjectContext'
|
||||
import { projectReferenceSchema, projectSchema, type Project } from '../types/project-model'
|
||||
import {
|
||||
PROJECT_IDS_STORAGE_KEY,
|
||||
PROJECT_STORAGE_KEY_PREFIX,
|
||||
getProjectStorageKey,
|
||||
readProject,
|
||||
readProjectIds,
|
||||
writeProjectIds,
|
||||
} from '../lib/project-storage'
|
||||
import { logger } from '../lib/logger'
|
||||
|
||||
export type ProjectReferencesMigrationStatus =
|
||||
| { status: 'needed' }
|
||||
| { status: 'inProgress'; ratio: number }
|
||||
| { status: 'completed' }
|
||||
|
||||
const LEGACY_PROJECTS_STORAGE_KEY = 'ltx-projects'
|
||||
|
||||
interface LegacyProjectRecord {
|
||||
projectData: unknown
|
||||
projectId: string
|
||||
}
|
||||
|
||||
function yieldToUi(): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
window.setTimeout(resolve, 0)
|
||||
})
|
||||
}
|
||||
|
||||
export function hasLegacyProjectsEntry(): boolean {
|
||||
return localStorage.getItem(LEGACY_PROJECTS_STORAGE_KEY) !== null
|
||||
}
|
||||
|
||||
export function deleteLegacyProjectsEntry(): void {
|
||||
localStorage.removeItem(LEGACY_PROJECTS_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export function writeLegacyProjects(projects: readonly Project[]): Project[] {
|
||||
const normalizedProjects = projects.map(project => projectSchema.parse(project))
|
||||
localStorage.setItem(
|
||||
LEGACY_PROJECTS_STORAGE_KEY,
|
||||
JSON.stringify(normalizedProjects),
|
||||
)
|
||||
return normalizedProjects
|
||||
}
|
||||
|
||||
export function readLegacyProjects(): LegacyProjectRecord[] {
|
||||
const stored = localStorage.getItem(LEGACY_PROJECTS_STORAGE_KEY)
|
||||
if (!stored) return []
|
||||
|
||||
const parsed = JSON.parse(stored)
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('Legacy projects payload is not an array')
|
||||
}
|
||||
|
||||
return parsed.map(projectData => ({
|
||||
projectData,
|
||||
projectId: projectReferenceSchema.parse(projectData).id,
|
||||
}))
|
||||
}
|
||||
|
||||
export function writeRawProject(projectId: string, projectData: unknown): void {
|
||||
localStorage.setItem(
|
||||
getProjectStorageKey(projectId),
|
||||
JSON.stringify(projectData),
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteProjectIdsEntry(): void {
|
||||
localStorage.removeItem(PROJECT_IDS_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export function readProjectsFromReferences(): Project[] {
|
||||
return readProjectIds().map(projectId => {
|
||||
const project = readProject(projectId)
|
||||
if (!project) {
|
||||
throw new Error(`Missing project entry for id ${projectId}`)
|
||||
}
|
||||
return project
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteAllProjectEntries(): void {
|
||||
const keysToDelete: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i += 1) {
|
||||
const storageKey = localStorage.key(i)
|
||||
if (storageKey?.startsWith(PROJECT_STORAGE_KEY_PREFIX)) {
|
||||
keysToDelete.push(storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
for (const storageKey of keysToDelete) {
|
||||
localStorage.removeItem(storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function useProjectReferencesMigration() {
|
||||
const { reloadProjectIds } = useProjects()
|
||||
const [migrationStatus, setMigrationStatus] = useState<ProjectReferencesMigrationStatus>(() => (
|
||||
hasLegacyProjectsEntry() ? { status: 'needed' } : { status: 'completed' }
|
||||
))
|
||||
const inFlightRef = useRef<Promise<void> | null>(null)
|
||||
|
||||
const migrateProjects = useCallback(async () => {
|
||||
if (inFlightRef.current) return inFlightRef.current
|
||||
|
||||
const runMigration = async () => {
|
||||
if (!hasLegacyProjectsEntry()) {
|
||||
reloadProjectIds()
|
||||
setMigrationStatus({ status: 'completed' })
|
||||
return
|
||||
}
|
||||
|
||||
setMigrationStatus({ status: 'inProgress', ratio: 0 })
|
||||
await yieldToUi()
|
||||
|
||||
try {
|
||||
const legacyProjects = readLegacyProjects()
|
||||
const total = legacyProjects.length
|
||||
|
||||
for (const [index, project] of legacyProjects.entries()) {
|
||||
writeRawProject(project.projectId, project.projectData)
|
||||
setMigrationStatus({
|
||||
status: 'inProgress',
|
||||
ratio: total === 0 ? 1 : (index + 1) / total,
|
||||
})
|
||||
await yieldToUi()
|
||||
}
|
||||
|
||||
writeProjectIds(legacyProjects.map(project => project.projectId))
|
||||
deleteLegacyProjectsEntry()
|
||||
reloadProjectIds()
|
||||
setMigrationStatus({ status: 'completed' })
|
||||
} catch (error) {
|
||||
logger.error(`Failed to migrate project references: ${error}`)
|
||||
setMigrationStatus({ status: 'needed' })
|
||||
}
|
||||
}
|
||||
|
||||
inFlightRef.current = runMigration().finally(() => {
|
||||
inFlightRef.current = null
|
||||
})
|
||||
|
||||
return inFlightRef.current
|
||||
}, [reloadProjectIds])
|
||||
|
||||
return { migrationStatus, migrateProjects }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
THEME — edit this block to retheme the app
|
||||
Values are RGB channels: R G B
|
||||
───────────────────────────────────────── */
|
||||
:root {
|
||||
/* Accent color (used for buttons, progress, highlights) */
|
||||
--accent: 43 97 255; /* #2B61FF */
|
||||
--accent-dark: 26 80 224; /* #1A50E0 — darker shade for hover states */
|
||||
|
||||
/* Surface colors */
|
||||
--bg: 9 9 11; /* zinc-950 — main app background */
|
||||
--surface: 24 24 27; /* zinc-900 — panels, cards */
|
||||
--surface-raised: 39 39 42; /* zinc-800 — inputs, hover states */
|
||||
--border-color: 63 63 70; /* zinc-700 — dividers, borders */
|
||||
|
||||
/* Text */
|
||||
--text: 255 255 255;
|
||||
--text-muted: 161 161 170; /* zinc-400 */
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #444;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* Drag and drop styling */
|
||||
.dropzone-active {
|
||||
border-color: rgb(var(--accent)) !important;
|
||||
background-color: rgb(var(--accent) / 0.1) !important;
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
import { normalizeResponseError } from './response-error'
|
||||
import { backendFetch } from './backend'
|
||||
import type { components, paths } from '../generated/backend-openapi'
|
||||
|
||||
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'
|
||||
|
||||
type OperationFor<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
> = NonNullable<paths[TPath][TMethod]>
|
||||
|
||||
type ResponsesFor<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
> = OperationFor<TPath, TMethod>['responses']
|
||||
|
||||
type JsonBodyOf<TResponse> = TResponse extends {
|
||||
content: infer TContent
|
||||
}
|
||||
? TContent extends { 'application/json': infer TJson }
|
||||
? TJson
|
||||
: never
|
||||
: never
|
||||
|
||||
type JsonResponseFor<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
> = OperationFor<TPath, TMethod> extends {
|
||||
responses: { 200: infer TResponse }
|
||||
}
|
||||
? JsonBodyOf<TResponse>
|
||||
: never
|
||||
|
||||
type JsonBodyFor<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
> = OperationFor<TPath, TMethod> extends {
|
||||
requestBody?: { content: { 'application/json': infer TBody } }
|
||||
}
|
||||
? TBody
|
||||
: never
|
||||
|
||||
type QueryFor<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
> = OperationFor<TPath, TMethod> extends {
|
||||
parameters: { query?: infer TQuery }
|
||||
}
|
||||
? TQuery
|
||||
: never
|
||||
|
||||
type HTTPErrorResponse = components["schemas"]["HTTPErrorResponse"]
|
||||
|
||||
type ExactErrorResponseFor<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
TStatus extends number,
|
||||
> = TStatus extends keyof ResponsesFor<TPath, TMethod>
|
||||
? JsonBodyOf<ResponsesFor<TPath, TMethod>[TStatus]>
|
||||
: never
|
||||
|
||||
type Fallback4xxErrorFor<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
> = '4XX' extends keyof ResponsesFor<TPath, TMethod>
|
||||
? JsonBodyOf<ResponsesFor<TPath, TMethod>['4XX']>
|
||||
: HTTPErrorResponse
|
||||
|
||||
type Fallback5xxErrorFor<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
> = '5XX' extends keyof ResponsesFor<TPath, TMethod>
|
||||
? JsonBodyOf<ResponsesFor<TPath, TMethod>['5XX']>
|
||||
: HTTPErrorResponse
|
||||
|
||||
type DefaultErrorFor<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
> = 'default' extends keyof ResponsesFor<TPath, TMethod>
|
||||
? JsonBodyOf<ResponsesFor<TPath, TMethod>['default']>
|
||||
: HTTPErrorResponse
|
||||
|
||||
type ExactErrorMembers<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
TExactStatuses extends readonly number[],
|
||||
> = {
|
||||
[TStatus in TExactStatuses[number]]: {
|
||||
ok: false
|
||||
status: TStatus
|
||||
error: ExactErrorResponseFor<TPath, TMethod, TStatus>
|
||||
}
|
||||
}[TExactStatuses[number]]
|
||||
|
||||
type FallbackErrorMembers<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
> =
|
||||
| {
|
||||
ok: false
|
||||
status: '4XX'
|
||||
error: Fallback4xxErrorFor<TPath, TMethod>
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
status: '5XX'
|
||||
error: Fallback5xxErrorFor<TPath, TMethod>
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
status: 'default'
|
||||
error: DefaultErrorFor<TPath, TMethod>
|
||||
}
|
||||
|
||||
export type EndpointResult<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
TExactStatuses extends readonly number[] = [],
|
||||
> =
|
||||
| {
|
||||
ok: true
|
||||
data: JsonResponseFor<TPath, TMethod>
|
||||
}
|
||||
| ExactErrorMembers<TPath, TMethod, TExactStatuses>
|
||||
| FallbackErrorMembers<TPath, TMethod>
|
||||
|
||||
type SyntheticErrorStatus = '4XX' | '5XX' | 'default'
|
||||
|
||||
export type ApiSuccess<TValue> = TValue extends { ok: true; data: infer TData }
|
||||
? TData
|
||||
: never
|
||||
|
||||
export type ApiErrors<TValue> = TValue extends { ok: false; status: infer TStatus; error: infer TError }
|
||||
? { status: TStatus; error: TError }
|
||||
: never
|
||||
|
||||
function buildQueryString(query: Record<string, unknown> | undefined): string {
|
||||
if (!query) return ''
|
||||
const params = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === null || value === undefined) continue
|
||||
params.set(key, String(value))
|
||||
}
|
||||
const serialized = params.toString()
|
||||
return serialized ? `?${serialized}` : ''
|
||||
}
|
||||
|
||||
function buildJsonRequestInit(body: unknown, init?: RequestInit): RequestInit {
|
||||
const headers = new Headers(init?.headers)
|
||||
headers.set('Content-Type', 'application/json')
|
||||
return {
|
||||
...init,
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
}
|
||||
|
||||
function buildSyntheticError(code: string, message: string): HTTPErrorResponse {
|
||||
return { code, message }
|
||||
}
|
||||
|
||||
function resolveFallbackStatus(httpStatus: number): SyntheticErrorStatus {
|
||||
if (httpStatus >= 400 && httpStatus < 500) return '4XX'
|
||||
if (httpStatus >= 500 && httpStatus < 600) return '5XX'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function resolveErrorStatus<TExactStatuses extends readonly number[]>(
|
||||
httpStatus: number,
|
||||
exactErrorStatuses: TExactStatuses,
|
||||
): TExactStatuses[number] | SyntheticErrorStatus {
|
||||
if ((exactErrorStatuses as readonly number[]).includes(httpStatus)) {
|
||||
return httpStatus as TExactStatuses[number]
|
||||
}
|
||||
return resolveFallbackStatus(httpStatus)
|
||||
}
|
||||
|
||||
function buildParsedErrorResult<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
TExactStatuses extends readonly number[],
|
||||
>(
|
||||
status: TExactStatuses[number] | SyntheticErrorStatus,
|
||||
payload: unknown,
|
||||
): EndpointResult<TPath, TMethod, TExactStatuses> {
|
||||
const normalized = normalizeResponseError(payload)
|
||||
return {
|
||||
ok: false,
|
||||
status: status === 402 && normalized.code !== 'LTX_INSUFFICIENT_FUNDS' ? '4XX' : status,
|
||||
error: normalized as ExactErrorResponseFor<TPath, TMethod, TExactStatuses[number]>
|
||||
| Fallback4xxErrorFor<TPath, TMethod>
|
||||
| Fallback5xxErrorFor<TPath, TMethod>
|
||||
| DefaultErrorFor<TPath, TMethod>,
|
||||
} as EndpointResult<TPath, TMethod, TExactStatuses>
|
||||
}
|
||||
|
||||
function buildSyntheticErrorResult<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
TExactStatuses extends readonly number[],
|
||||
>(
|
||||
status: SyntheticErrorStatus,
|
||||
code: string,
|
||||
message: string,
|
||||
): EndpointResult<TPath, TMethod, TExactStatuses> {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
error: buildSyntheticError(code, message) as Fallback4xxErrorFor<TPath, TMethod>
|
||||
| Fallback5xxErrorFor<TPath, TMethod>
|
||||
| DefaultErrorFor<TPath, TMethod>,
|
||||
} as EndpointResult<TPath, TMethod, TExactStatuses>
|
||||
}
|
||||
|
||||
async function requestEndpointResult<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
TExactStatuses extends readonly number[],
|
||||
>(
|
||||
endpoint: TPath,
|
||||
method: TMethod,
|
||||
exactErrorStatuses: TExactStatuses,
|
||||
init?: RequestInit,
|
||||
requestPath?: string,
|
||||
): Promise<EndpointResult<TPath, TMethod, TExactStatuses>> {
|
||||
const path = requestPath ?? String(endpoint)
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await backendFetch(path, {
|
||||
method: method.toUpperCase(),
|
||||
...init,
|
||||
})
|
||||
} catch (error) {
|
||||
return buildSyntheticErrorResult<TPath, TMethod, TExactStatuses>(
|
||||
'default',
|
||||
'NETWORK_ERROR',
|
||||
error instanceof Error ? error.message : 'Request failed before the server responded.',
|
||||
)
|
||||
}
|
||||
|
||||
let text = ''
|
||||
try {
|
||||
text = await response.text()
|
||||
} catch (error) {
|
||||
return buildSyntheticErrorResult<TPath, TMethod, TExactStatuses>(
|
||||
resolveFallbackStatus(response.status),
|
||||
'RESPONSE_READ_FAILED',
|
||||
error instanceof Error ? error.message : 'Failed to read response body.',
|
||||
)
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
if (!text) {
|
||||
return buildSyntheticErrorResult<TPath, TMethod, TExactStatuses>(
|
||||
'default',
|
||||
'EMPTY_SUCCESS_RESPONSE',
|
||||
`${path} returned an empty response body.`,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
data: JSON.parse(text) as JsonResponseFor<TPath, TMethod>,
|
||||
}
|
||||
} catch (error) {
|
||||
return buildSyntheticErrorResult<TPath, TMethod, TExactStatuses>(
|
||||
'default',
|
||||
'INVALID_SUCCESS_RESPONSE',
|
||||
error instanceof Error ? error.message : 'Server returned invalid JSON.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return buildSyntheticErrorResult<TPath, TMethod, TExactStatuses>(
|
||||
resolveFallbackStatus(response.status),
|
||||
`HTTP_${response.status}`,
|
||||
`${response.status} ${response.statusText || 'Request failed'}`,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as unknown
|
||||
return buildParsedErrorResult<TPath, TMethod, TExactStatuses>(
|
||||
resolveErrorStatus(response.status, exactErrorStatuses),
|
||||
payload,
|
||||
)
|
||||
} catch {
|
||||
return buildSyntheticErrorResult<TPath, TMethod, TExactStatuses>(
|
||||
resolveFallbackStatus(response.status),
|
||||
`HTTP_${response.status}`,
|
||||
text,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function makeEndpointClient<
|
||||
TPath extends keyof paths,
|
||||
TMethod extends HttpMethod,
|
||||
TExactStatuses extends readonly number[] = [],
|
||||
>(
|
||||
endpoint: TPath,
|
||||
method: TMethod,
|
||||
config?: {
|
||||
exactErrorStatuses?: TExactStatuses
|
||||
},
|
||||
) {
|
||||
const exactErrorStatuses = (config?.exactErrorStatuses ?? []) as TExactStatuses
|
||||
|
||||
return (
|
||||
body?: JsonBodyFor<TPath, TMethod>,
|
||||
init?: RequestInit,
|
||||
requestPath?: string,
|
||||
): Promise<EndpointResult<TPath, TMethod, TExactStatuses>> => {
|
||||
const requestInit = body === undefined
|
||||
? init
|
||||
: buildJsonRequestInit(body, init)
|
||||
return requestEndpointResult(endpoint, method, exactErrorStatuses, requestInit, requestPath)
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
static getHealth = makeEndpointClient('/health', 'get')
|
||||
|
||||
static getModelDownloadProgress(
|
||||
query: QueryFor<'/api/models/download/progress', 'get'>,
|
||||
): Promise<EndpointResult<'/api/models/download/progress', 'get'>> {
|
||||
const path = `/api/models/download/progress${buildQueryString(query as Record<string, unknown>)}`
|
||||
return requestEndpointResult('/api/models/download/progress', 'get', [] as const, undefined, path)
|
||||
}
|
||||
|
||||
static listModels(
|
||||
query?: QueryFor<'/api/models', 'get'>,
|
||||
): Promise<EndpointResult<'/api/models', 'get'>> {
|
||||
const path = `/api/models${buildQueryString(query as Record<string, unknown>)}`
|
||||
return requestEndpointResult('/api/models', 'get', [] as const, undefined, path)
|
||||
}
|
||||
|
||||
|
||||
static getLtxRecommendation = makeEndpointClient('/api/models/ltx-recommendation', 'get')
|
||||
|
||||
static getImgGenRecommendation = makeEndpointClient('/api/models/img-gen-recommendation', 'get')
|
||||
|
||||
static getLtxIcLoraRecommendation = makeEndpointClient('/api/models/ltx-ic-lora-recommendation', 'get')
|
||||
|
||||
static getTextEncoderRecommendation = makeEndpointClient('/api/models/text-encoder-recommendation', 'get')
|
||||
|
||||
static describeCheckpoints = makeEndpointClient('/api/models/describe', 'post')
|
||||
|
||||
static getActiveDownload = makeEndpointClient('/api/models/download/active', 'get')
|
||||
|
||||
static getLtxVersions = makeEndpointClient('/api/models/ltx-versions', 'get')
|
||||
|
||||
static setActiveLtxModel = makeEndpointClient('/api/models/active-ltx-model', 'post')
|
||||
|
||||
static startModelDownload = makeEndpointClient('/api/models/download', 'post')
|
||||
|
||||
static deleteModels = makeEndpointClient('/api/models/delete', 'delete')
|
||||
|
||||
static getRuntimePolicy = makeEndpointClient('/api/runtime-policy', 'get')
|
||||
|
||||
static getGpuInfo = makeEndpointClient('/api/gpu-info', 'get')
|
||||
|
||||
static getSettings = makeEndpointClient('/api/settings', 'get')
|
||||
|
||||
static listGeminiModels = makeEndpointClient('/api/settings/gemini-models', 'get')
|
||||
|
||||
static updateSettings = makeEndpointClient('/api/settings', 'post')
|
||||
|
||||
static suggestGapPrompt = makeEndpointClient('/api/suggest-gap-prompt', 'post', {
|
||||
exactErrorStatuses: [401, 403] as const,
|
||||
})
|
||||
|
||||
static generateVideo = makeEndpointClient('/api/generate', 'post', {
|
||||
exactErrorStatuses: [402] as const,
|
||||
})
|
||||
|
||||
static getGenerateVideoModelSpecs = makeEndpointClient('/api/generate/models-specs', 'get')
|
||||
|
||||
static cancelGeneration = makeEndpointClient('/api/generate/cancel', 'post')
|
||||
|
||||
static getGenerationProgress = makeEndpointClient('/api/generation/progress', 'get')
|
||||
|
||||
static generateImage = makeEndpointClient('/api/generate-image', 'post')
|
||||
|
||||
static enhancePrompt = makeEndpointClient('/api/enhance-prompt', 'post', {
|
||||
exactErrorStatuses: [404, 409] as const,
|
||||
})
|
||||
|
||||
static retake = makeEndpointClient('/api/retake', 'post')
|
||||
|
||||
static extend = makeEndpointClient('/api/extend', 'post')
|
||||
|
||||
static startHuggingFaceLogin = makeEndpointClient('/api/auth/huggingface/login', 'post')
|
||||
|
||||
static getHuggingFaceAuthStatus = makeEndpointClient('/api/auth/huggingface/status', 'get')
|
||||
|
||||
static huggingFaceLogout = makeEndpointClient('/api/auth/huggingface/logout', 'post')
|
||||
|
||||
static checkModelAccess = makeEndpointClient('/api/models/check-access', 'post')
|
||||
|
||||
static generateIcLora = makeEndpointClient('/api/ic-lora/generate', 'post')
|
||||
|
||||
static extractIcLoraConditioning = makeEndpointClient('/api/ic-lora/extract-conditioning', 'post')
|
||||
|
||||
static listIcLoras = makeEndpointClient('/api/ic-loras', 'get')
|
||||
|
||||
static startIcLoraDownload = makeEndpointClient('/api/ic-loras/download', 'post')
|
||||
|
||||
static getIcLoraDownloadProgress(
|
||||
query: QueryFor<'/api/ic-loras/download/progress', 'get'>,
|
||||
): Promise<EndpointResult<'/api/ic-loras/download/progress', 'get'>> {
|
||||
const path = `/api/ic-loras/download/progress${buildQueryString(query as Record<string, unknown>)}`
|
||||
return requestEndpointResult('/api/ic-loras/download/progress', 'get', [] as const, undefined, path)
|
||||
}
|
||||
|
||||
static listLoras = makeEndpointClient('/api/loras', 'get')
|
||||
|
||||
static startLoraDownload = makeEndpointClient('/api/loras/download', 'post')
|
||||
|
||||
static getLoraDownloadProgress(
|
||||
query: QueryFor<'/api/loras/download/progress', 'get'>,
|
||||
): Promise<EndpointResult<'/api/loras/download/progress', 'get'>> {
|
||||
const path = `/api/loras/download/progress${buildQueryString(query as Record<string, unknown>)}`
|
||||
return requestEndpointResult('/api/loras/download/progress', 'get', [] as const, undefined, path)
|
||||
}
|
||||
}
|
||||
|
||||
type ApiClientMethodName = keyof typeof ApiClient
|
||||
|
||||
export type ApiRequestBodyOf<TMethod extends ApiClientMethodName> = (typeof ApiClient)[TMethod] extends (
|
||||
body?: infer TBody,
|
||||
...args: any[]
|
||||
) => Promise<any>
|
||||
? TBody
|
||||
: never
|
||||
|
||||
export type ApiSuccessOf<TMethod extends ApiClientMethodName> = (typeof ApiClient)[TMethod] extends (...args: any[]) => Promise<any>
|
||||
? ApiSuccess<Awaited<ReturnType<(typeof ApiClient)[TMethod]>>>
|
||||
: never
|
||||
|
||||
export type ApiErrorsOf<TMethod extends ApiClientMethodName> = (typeof ApiClient)[TMethod] extends (...args: any[]) => Promise<any>
|
||||
? ApiErrors<Awaited<ReturnType<(typeof ApiClient)[TMethod]>>>
|
||||
: never
|
||||
@@ -0,0 +1,56 @@
|
||||
import { logger } from './logger'
|
||||
|
||||
export type ProjectAssetType = 'video' | 'image'
|
||||
|
||||
export interface ProjectAssetCopyResult {
|
||||
path: string
|
||||
bigThumbnailPath: string
|
||||
smallThumbnailPath: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a video/image file to project storage and return precomputed thumbnail paths.
|
||||
*/
|
||||
export async function addVisualAssetToProject(
|
||||
srcPath: string,
|
||||
projectId: string,
|
||||
type: ProjectAssetType,
|
||||
): Promise<ProjectAssetCopyResult | null> {
|
||||
try {
|
||||
const result = await window.electronAPI.addVisualAssetToProject({ srcPath, projectId, type })
|
||||
if (result.success) {
|
||||
return {
|
||||
path: result.path,
|
||||
bigThumbnailPath: result.bigThumbnailPath,
|
||||
smallThumbnailPath: result.smallThumbnailPath,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
}
|
||||
}
|
||||
logger.warn(`Failed to add asset to project folder: ${result.error}`)
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to add asset to project folder: ${e}`)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a file to project storage without thumbnail generation (audio path).
|
||||
*/
|
||||
export async function addGenericAssetToProject(
|
||||
srcPath: string,
|
||||
projectId: string,
|
||||
): Promise<{ path: string } | null> {
|
||||
try {
|
||||
const result = await window.electronAPI.addGenericAssetToProject({ srcPath, projectId })
|
||||
if (result.success) {
|
||||
return { path: result.path }
|
||||
}
|
||||
logger.warn(`Failed to copy file to project folder: ${result.error}`)
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to copy file to project folder: ${e}`)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
let cached: { url: string; token: string } | null = null
|
||||
|
||||
export async function getBackendCredentials(): Promise<{ url: string; token: string }> {
|
||||
if (!cached) cached = await window.electronAPI.getBackend()
|
||||
return cached
|
||||
}
|
||||
|
||||
export function resetBackendCredentials(): void {
|
||||
cached = null
|
||||
}
|
||||
|
||||
export async function backendFetch(path: string, init?: RequestInit): Promise<Response> {
|
||||
const { url, token } = await getBackendCredentials()
|
||||
const headers = new Headers(init?.headers)
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
return fetch(`${url}${path}`, { ...init, headers })
|
||||
}
|
||||
|
||||
export async function backendWsUrl(path: string): Promise<string> {
|
||||
const { url, token } = await getBackendCredentials()
|
||||
const ws = url.replace('http://', 'ws://')
|
||||
const sep = path.includes('?') ? '&' : '?'
|
||||
return `${ws}${path}${sep}token=${token}`
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { DEFAULT_KEYFRAME_STRENGTH, type KeyframeItem } from './multi-keyframe.ts'
|
||||
import { buildGenerateVideoImageInputs } from './build-generate-video-body.ts'
|
||||
|
||||
const keyframes: KeyframeItem[] = [
|
||||
{ id: 'opening', path: '/frames/opening.png', frameIndex: 0, strength: DEFAULT_KEYFRAME_STRENGTH },
|
||||
{ id: 'ending', path: '/frames/ending.png', frameIndex: 121, strength: DEFAULT_KEYFRAME_STRENGTH },
|
||||
]
|
||||
|
||||
describe('buildGenerateVideoImageInputs', () => {
|
||||
it('maps multi-keyframes to the backend request shape', () => {
|
||||
assert.deepEqual(
|
||||
buildGenerateVideoImageInputs({
|
||||
mode: 'multi-keyframe',
|
||||
imagePath: null,
|
||||
lastImagePath: null,
|
||||
keyframes,
|
||||
}),
|
||||
{
|
||||
keyframes: [
|
||||
{ imagePath: '/frames/opening.png', frameIndex: 0, strength: DEFAULT_KEYFRAME_STRENGTH },
|
||||
{ imagePath: '/frames/ending.png', frameIndex: 121, strength: DEFAULT_KEYFRAME_STRENGTH },
|
||||
],
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('omits image inputs when multi-keyframes are present', () => {
|
||||
const result = buildGenerateVideoImageInputs({
|
||||
mode: 'multi-keyframe',
|
||||
imagePath: '/frames/first.png',
|
||||
lastImagePath: '/frames/last.png',
|
||||
keyframes,
|
||||
})
|
||||
|
||||
assert.equal('imagePath' in result, false)
|
||||
assert.equal('lastImagePath' in result, false)
|
||||
assert.equal('keyframes' in result, true)
|
||||
})
|
||||
|
||||
it('forwards each still\'s owned strength on generate', () => {
|
||||
assert.deepEqual(
|
||||
buildGenerateVideoImageInputs({
|
||||
mode: 'multi-keyframe',
|
||||
imagePath: null,
|
||||
lastImagePath: null,
|
||||
keyframes: [
|
||||
{ id: 'opening', path: '/frames/opening.png', frameIndex: 0, strength: 0.7 },
|
||||
],
|
||||
}),
|
||||
{
|
||||
keyframes: [{ imagePath: '/frames/opening.png', frameIndex: 0, strength: 0.7 }],
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves image inputs outside multi-keyframe mode', () => {
|
||||
assert.deepEqual(
|
||||
buildGenerateVideoImageInputs({
|
||||
mode: 'video',
|
||||
imagePath: '/frames/first.png',
|
||||
lastImagePath: '/frames/last.png',
|
||||
keyframes,
|
||||
}),
|
||||
{
|
||||
imagePath: '/frames/first.png',
|
||||
lastImagePath: '/frames/last.png',
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { GenSpaceMode } from './genspace-multi-keyframe'
|
||||
import { toKeyframeInputs, type KeyframeItem } from './multi-keyframe.ts'
|
||||
|
||||
interface GenerateVideoImageInputs {
|
||||
imagePath?: string
|
||||
lastImagePath?: string
|
||||
keyframes?: Array<{
|
||||
imagePath: string
|
||||
frameIndex: number
|
||||
strength: number
|
||||
}>
|
||||
}
|
||||
|
||||
export function buildGenerateVideoImageInputs({
|
||||
mode,
|
||||
imagePath,
|
||||
lastImagePath,
|
||||
keyframes,
|
||||
}: {
|
||||
mode: GenSpaceMode
|
||||
imagePath: string | null | undefined
|
||||
lastImagePath: string | null | undefined
|
||||
keyframes: KeyframeItem[]
|
||||
}): GenerateVideoImageInputs {
|
||||
if (mode === 'multi-keyframe' && keyframes.length > 0) {
|
||||
return { keyframes: toKeyframeInputs(keyframes) }
|
||||
}
|
||||
|
||||
return {
|
||||
...(imagePath ? { imagePath } : {}),
|
||||
...(lastImagePath ? { lastImagePath } : {}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import {
|
||||
GEMINI_KEY_REQUIRED_SETTINGS_DETAIL,
|
||||
isEnhanceBlockedByMissingGeminiKey,
|
||||
} from './enhance-gemini-key.ts'
|
||||
|
||||
const blocked = {
|
||||
enhanceAvailableForMode: true,
|
||||
enhanceProvider: 'api' as const,
|
||||
hasGeminiApiKey: false,
|
||||
hasEnhanceInput: true,
|
||||
isGenerationInProgressForEnhance: false,
|
||||
isOtherGenerationRunning: false,
|
||||
}
|
||||
|
||||
describe('isEnhanceBlockedByMissingGeminiKey', () => {
|
||||
it('is true when Enhance (API) is selected and no Gemini key is configured', () => {
|
||||
assert.equal(isEnhanceBlockedByMissingGeminiKey(blocked), true)
|
||||
})
|
||||
|
||||
it('is false when enhance is not available for the current mode', () => {
|
||||
assert.equal(
|
||||
isEnhanceBlockedByMissingGeminiKey({ ...blocked, enhanceAvailableForMode: false }),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('is false when a Gemini key is already configured', () => {
|
||||
assert.equal(
|
||||
isEnhanceBlockedByMissingGeminiKey({ ...blocked, hasGeminiApiKey: true }),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('is false when Enhance is using the local provider, even without a Gemini key', () => {
|
||||
assert.equal(
|
||||
isEnhanceBlockedByMissingGeminiKey({ ...blocked, enhanceProvider: 'local' }),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('is false when there is no prompt or image to enhance', () => {
|
||||
assert.equal(
|
||||
isEnhanceBlockedByMissingGeminiKey({ ...blocked, hasEnhanceInput: false }),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('is false while this project is already generating', () => {
|
||||
assert.equal(
|
||||
isEnhanceBlockedByMissingGeminiKey({ ...blocked, isGenerationInProgressForEnhance: true }),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('is false while another project is generating', () => {
|
||||
assert.equal(
|
||||
isEnhanceBlockedByMissingGeminiKey({ ...blocked, isOtherGenerationRunning: true }),
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEMINI_KEY_REQUIRED_SETTINGS_DETAIL', () => {
|
||||
it('opens Settings on the API Keys tab for the Gemini key banner', () => {
|
||||
assert.deepEqual(GEMINI_KEY_REQUIRED_SETTINGS_DETAIL, {
|
||||
tab: 'apiKeys',
|
||||
reason: 'geminiKeyRequired',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
export const GEMINI_KEY_REQUIRED_SETTINGS_DETAIL = {
|
||||
tab: 'apiKeys' as const,
|
||||
reason: 'geminiKeyRequired' as const,
|
||||
}
|
||||
|
||||
export function isEnhanceBlockedByMissingGeminiKey(input: {
|
||||
enhanceAvailableForMode: boolean
|
||||
enhanceProvider: 'local' | 'api'
|
||||
hasGeminiApiKey: boolean
|
||||
hasEnhanceInput: boolean
|
||||
isGenerationInProgressForEnhance: boolean
|
||||
isOtherGenerationRunning: boolean
|
||||
}): boolean {
|
||||
// True when Enhance would run via Gemini but no key is configured — including when local
|
||||
// Enhance is available and the user explicitly picked API. Clicking then opens Settings
|
||||
// instead of hiding the API option.
|
||||
return (
|
||||
input.enhanceAvailableForMode
|
||||
&& input.enhanceProvider === 'api'
|
||||
&& !input.hasGeminiApiKey
|
||||
&& input.hasEnhanceInput
|
||||
&& !input.isGenerationInProgressForEnhance
|
||||
&& !input.isOtherGenerationRunning
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { mediaUrl } from './web-platform'
|
||||
export function pathToFileUrl(filePath: string): string { return mediaUrl(filePath) }
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { fixedMenuPosition } from './fixed-menu-position.ts'
|
||||
|
||||
const trigger = { left: 100, right: 180, top: 500, bottom: 532 }
|
||||
const viewport = { width: 1000, height: 800 }
|
||||
|
||||
describe('fixedMenuPosition', () => {
|
||||
it('anchors a menu above the trigger', () => {
|
||||
const pos = fixedMenuPosition({
|
||||
trigger,
|
||||
placement: 'above',
|
||||
viewport,
|
||||
menuWidth: 160,
|
||||
gap: 8,
|
||||
})
|
||||
assert.equal(pos.left, 100)
|
||||
assert.equal(pos.bottom, 308)
|
||||
assert.equal(pos.top, undefined)
|
||||
})
|
||||
|
||||
it('anchors a menu below the trigger', () => {
|
||||
const pos = fixedMenuPosition({
|
||||
trigger,
|
||||
placement: 'below',
|
||||
viewport,
|
||||
menuWidth: 160,
|
||||
gap: 8,
|
||||
})
|
||||
assert.equal(pos.left, 100)
|
||||
assert.equal(pos.top, 540)
|
||||
assert.equal(pos.bottom, undefined)
|
||||
})
|
||||
|
||||
it('shifts left so a wide menu stays on-screen', () => {
|
||||
const pos = fixedMenuPosition({
|
||||
trigger: { left: 900, right: 980, top: 500, bottom: 532 },
|
||||
placement: 'above',
|
||||
viewport,
|
||||
menuWidth: 160,
|
||||
gap: 8,
|
||||
})
|
||||
assert.equal(pos.left, 820)
|
||||
})
|
||||
|
||||
it('does not use an assumed width when the menu has not been measured', () => {
|
||||
const pos = fixedMenuPosition({
|
||||
trigger: { left: 900, right: 980, top: 500, bottom: 532 },
|
||||
placement: 'above',
|
||||
viewport,
|
||||
gap: 8,
|
||||
})
|
||||
assert.equal(pos.left, 900)
|
||||
})
|
||||
|
||||
it('ignores a viewport-filling width from an unpositioned block menu', () => {
|
||||
const pos = fixedMenuPosition({
|
||||
trigger: { left: 400, right: 480, top: 500, bottom: 532 },
|
||||
placement: 'above',
|
||||
viewport,
|
||||
menuWidth: 1000,
|
||||
gap: 8,
|
||||
})
|
||||
assert.equal(pos.left, 400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
export type MenuPlacement = 'above' | 'below'
|
||||
|
||||
const EDGE_PX = 8
|
||||
|
||||
export function fixedMenuPosition({
|
||||
trigger,
|
||||
placement,
|
||||
viewport,
|
||||
menuWidth,
|
||||
gap = 8,
|
||||
}: {
|
||||
trigger: { left: number; right: number; top: number; bottom: number }
|
||||
placement: MenuPlacement
|
||||
viewport: { width: number; height: number }
|
||||
menuWidth?: number
|
||||
gap?: number
|
||||
}): { left: number; top?: number; bottom?: number } {
|
||||
let left = trigger.left
|
||||
const measuredWidth =
|
||||
menuWidth != null && menuWidth < viewport.width - EDGE_PX * 2 ? menuWidth : undefined
|
||||
if (measuredWidth != null) {
|
||||
const maxLeft = viewport.width - measuredWidth - EDGE_PX
|
||||
if (left > maxLeft) left = trigger.right - measuredWidth
|
||||
left = Math.max(EDGE_PX, Math.min(left, maxLeft))
|
||||
} else {
|
||||
left = Math.max(EDGE_PX, left)
|
||||
}
|
||||
|
||||
if (placement === 'below') {
|
||||
return { left, top: trigger.bottom + gap }
|
||||
}
|
||||
return { left, bottom: viewport.height - trigger.top + gap }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { formatBytes } from './format.ts'
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('formats zero', () => {
|
||||
assert.equal(formatBytes(0), '0 B')
|
||||
})
|
||||
|
||||
it('formats megabytes with one decimal', () => {
|
||||
assert.equal(formatBytes(996_000_000), '949.9 MB')
|
||||
})
|
||||
|
||||
it('formats terabytes instead of overflowing the unit list', () => {
|
||||
assert.equal(formatBytes(1.8 * 1024 ** 4), '1.8 TB')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
// Human-readable byte size, e.g. 996_000_000 -> "949.9 MB". Binary units (1024).
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
// Local, live "is a generation running?" signal for the renderer UI.
|
||||
// Ref-counted because generations can overlap.
|
||||
let activeCount = 0
|
||||
const listeners = new Set<() => void>()
|
||||
function emit() { for (const l of listeners) l() }
|
||||
|
||||
function subscribe(cb: () => void): () => void {
|
||||
listeners.add(cb)
|
||||
return () => { listeners.delete(cb) }
|
||||
}
|
||||
function getSnapshot(): boolean {
|
||||
return activeCount > 0
|
||||
}
|
||||
|
||||
export function useIsGenerationActive(): boolean {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
}
|
||||
|
||||
// Local generation can starve the backend's event loop; withGenerationActive tells main so the
|
||||
// liveness monitor doesn't kill a busy backend. We also keep a local count for the UI signal above.
|
||||
export async function withGenerationActive<T>(fn: () => Promise<T>): Promise<T> {
|
||||
activeCount += 1; emit()
|
||||
void window.electronAPI.notifyGenerationActive({ active: true })
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
activeCount = Math.max(0, activeCount - 1); emit()
|
||||
void window.electronAPI.notifyGenerationActive({ active: false })
|
||||
}
|
||||
}
|
||||
|
||||
/** Evaluate at job start and freeze — live Settings must not flip Stop mid-request. */
|
||||
export function canCancelLocalJob(
|
||||
kind: 'video' | 'image',
|
||||
videoUsesLtxApi: boolean,
|
||||
imageUsesFalApi: boolean,
|
||||
): boolean {
|
||||
if (kind === 'image') return !imageUsesFalApi
|
||||
if (kind === 'video') return !videoUsesLtxApi
|
||||
return false
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user