Upload 28 files
Browse files- LTX2.3/API issues-API问题办法.bat +43 -0
- LTX2.3/API issues-API问题办法.txt +50 -0
- LTX2.3/LTX_Shortcut/LTX Desktop.lnk +0 -0
- LTX2.3/UI/i18n.js +428 -0
- LTX2.3/UI/index.css +775 -0
- LTX2.3/UI/index.html +406 -0
- LTX2.3/UI/index.js +2042 -0
- LTX2.3/main.py +264 -0
- LTX2.3/patches/API模式问题修复说明.md +41 -0
- LTX2.3/patches/__pycache__/api_types.cpython-313.pyc +0 -0
- LTX2.3/patches/__pycache__/app_factory.cpython-313.pyc +0 -0
- LTX2.3/patches/__pycache__/app_factory.cpython-314.pyc +0 -0
- LTX2.3/patches/__pycache__/keep_models_runtime.cpython-313.pyc +0 -0
- LTX2.3/patches/__pycache__/lora_build_hook.cpython-313.pyc +0 -0
- LTX2.3/patches/__pycache__/lora_injection.cpython-313.pyc +0 -0
- LTX2.3/patches/__pycache__/low_vram_runtime.cpython-313.pyc +0 -0
- LTX2.3/patches/api_types.py +315 -0
- LTX2.3/patches/app_factory.py +2288 -0
- LTX2.3/patches/handlers/__pycache__/video_generation_handler.cpython-313.pyc +0 -0
- LTX2.3/patches/handlers/video_generation_handler.py +868 -0
- LTX2.3/patches/keep_models_runtime.py +16 -0
- LTX2.3/patches/launcher.py +20 -0
- LTX2.3/patches/lora_build_hook.py +104 -0
- LTX2.3/patches/lora_injection.py +139 -0
- LTX2.3/patches/low_vram_runtime.py +155 -0
- LTX2.3/patches/runtime_policy.py +21 -0
- LTX2.3/patches/settings.json +22 -0
- LTX2.3/run.bat +38 -0
LTX2.3/API issues-API问题办法.bat
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
chcp 65001 >nul
|
| 3 |
+
title LTX 本地显卡模式修复工具
|
| 4 |
+
|
| 5 |
+
echo ========================================
|
| 6 |
+
echo LTX 本地显卡模式修复工具
|
| 7 |
+
echo ========================================
|
| 8 |
+
echo.
|
| 9 |
+
|
| 10 |
+
:: 检查管理员权限
|
| 11 |
+
net session >nul 2>&1
|
| 12 |
+
if %errorlevel% neq 0 (
|
| 13 |
+
echo [!] 请右键选择"以管理员身份运行"此脚本
|
| 14 |
+
pause
|
| 15 |
+
exit /b 1
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
echo [1/2] 正在修改 VRAM 阈值...
|
| 19 |
+
set "policy_file=C:\Program Files\LTX Desktop\resources\backend\runtime_config\runtime_policy.py"
|
| 20 |
+
|
| 21 |
+
if exist "%policy_file%" (
|
| 22 |
+
powershell -Command "(Get-Content '%policy_file%') -replace 'vram_gb < 31', 'vram_gb < 6' | Set-Content '%policy_file%'"
|
| 23 |
+
echo ^_^ VRAM 阈值已修改为 6GB
|
| 24 |
+
) else (
|
| 25 |
+
echo [!] 未找到 runtime_policy.py,请确认 LTX Desktop 已安装
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
echo.
|
| 29 |
+
echo [2/2] 正在清空 API Key...
|
| 30 |
+
set "settings_file=%USERPROFILE%\AppData\Local\LTXDesktop\settings.json"
|
| 31 |
+
|
| 32 |
+
if exist "%settings_file%" (
|
| 33 |
+
powershell -Command "$content = Get-Content '%settings_file%' -Raw; $content = $content -replace '\"fal_api_key\": \"[^\"]*\"', '\"fal_api_key\": \"\"'; Set-Content -Path '%settings_file%' -Value $content -NoNewline"
|
| 34 |
+
echo ^_^ API Key 已清空
|
| 35 |
+
) else (
|
| 36 |
+
echo [!] 未找到 settings.json,首次运行后会自动创建
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
echo.
|
| 40 |
+
echo ========================================
|
| 41 |
+
echo 修复完成!请重启 LTX Desktop
|
| 42 |
+
echo ========================================
|
| 43 |
+
pause
|
LTX2.3/API issues-API问题办法.txt
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
1. 复制LTX桌面版的快捷方式到LTX_Shortcut
|
| 2 |
+
|
| 3 |
+
2. 运行run.bat
|
| 4 |
+
----
|
| 5 |
+
1. Copy the LTX desktop shortcut to LTX_Shortcut
|
| 6 |
+
|
| 7 |
+
2. Run run.bat
|
| 8 |
+
----
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
【问题描述 / Problem】
|
| 13 |
+
系统强制使用 FAL API 生成图片,即使本地有 GPU 可用。
|
| 14 |
+
System forces FAL API generation even when local GPU is available.
|
| 15 |
+
|
| 16 |
+
【原因 / Cause】
|
| 17 |
+
LTX 强制要求 GPU 有 31GB VRAM 才会使用本地显卡,低于此值会强制走 API 模式。
|
| 18 |
+
LTX requires 31GB VRAM to use local GPU. Below this, it forces API mode.
|
| 19 |
+
|
| 20 |
+
================================================================================
|
| 21 |
+
【修复方法 / Fix Method】
|
| 22 |
+
================================================================================
|
| 23 |
+
|
| 24 |
+
运行: API issues.bat.bat (以管理员身份)
|
| 25 |
+
Run: API issues.bat.bat (as Administrator)
|
| 26 |
+
|
| 27 |
+
================================================================================
|
| 28 |
+
================================================================================
|
| 29 |
+
|
| 30 |
+
【或者手动 / Or Manual】
|
| 31 |
+
|
| 32 |
+
1. 修改 VRAM 阈值 / Modify VRAM Threshold
|
| 33 |
+
文件路径 / File: C:\Program Files\LTX Desktop\resources\backend\runtime_config\runtime_policy.py
|
| 34 |
+
第16行 / Line 16:
|
| 35 |
+
原 / Original: return vram_gb < 31
|
| 36 |
+
改为 / Change: return vram_gb < 6
|
| 37 |
+
|
| 38 |
+
2. 清空 API Key / Clear API Key
|
| 39 |
+
文件路径 / File: C:\Users\<用户名>\AppData\Local\LTXDesktop\settings.json
|
| 40 |
+
原 / Original: "fal_api_key": "xxxxx"
|
| 41 |
+
改为 / Change: "fal_api_key": ""
|
| 42 |
+
|
| 43 |
+
【说明 / Note】
|
| 44 |
+
- VRAM 阈值改为 6GB,意味着 6GB 及以上显存都会使用本地显卡
|
| 45 |
+
- VRAM threshold set to 6GB means 6GB+ VRAM will use local GPU
|
| 46 |
+
- 清空 fal_api_key 避免系统误判为已配置 API
|
| 47 |
+
- Clear fal_api_key to avoid system thinking API is configured
|
| 48 |
+
- 修改后重启程序即可生效
|
| 49 |
+
- Restart LTX Desktop after changes
|
| 50 |
+
================================================================================
|
LTX2.3/LTX_Shortcut/LTX Desktop.lnk
ADDED
|
Binary file (1.94 kB). View file
|
|
|
LTX2.3/UI/i18n.js
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* LTX UI i18n — 与根目录「中英文.html」思路类似,但独立脚本、避免坏 DOM/错误路径。
|
| 3 |
+
* 仅维护文案映射;动态节点由 index.js 在语言切换后刷新。
|
| 4 |
+
*/
|
| 5 |
+
(function (global) {
|
| 6 |
+
const STORAGE_KEY = 'ltx_ui_lang';
|
| 7 |
+
|
| 8 |
+
const STR = {
|
| 9 |
+
zh: {
|
| 10 |
+
tabVideo: '视频生成',
|
| 11 |
+
tabBatch: '智能多帧',
|
| 12 |
+
tabUpscale: '视频增强',
|
| 13 |
+
tabImage: '图像生成',
|
| 14 |
+
promptLabel: '视觉描述词 (Prompt)',
|
| 15 |
+
promptPlaceholder: '在此输入视觉描述词 (Prompt)...',
|
| 16 |
+
promptPlaceholderUpscale: '输入画面增强引导词 (可选)...',
|
| 17 |
+
clearVram: '释放显存',
|
| 18 |
+
clearingVram: '清理中...',
|
| 19 |
+
settingsTitle: '系统高级设置',
|
| 20 |
+
langToggleAriaZh: '切换为 English',
|
| 21 |
+
langToggleAriaEn: 'Switch to 中文',
|
| 22 |
+
sysScanning: '正在扫描 GPU...',
|
| 23 |
+
sysBusy: '运算中...',
|
| 24 |
+
sysOnline: '在线 / 就绪',
|
| 25 |
+
sysStarting: '启动中...',
|
| 26 |
+
sysOffline: '未检测到后端 (Port 3000)',
|
| 27 |
+
advancedSettings: '高级设置',
|
| 28 |
+
deviceSelect: '工作设备选择',
|
| 29 |
+
gpuDetecting: '正在检测 GPU...',
|
| 30 |
+
outputPath: '输出与上传存储路径',
|
| 31 |
+
outputPathPh: '例如: D:\\LTX_outputs',
|
| 32 |
+
savePath: '保存路径',
|
| 33 |
+
outputPathHint:
|
| 34 |
+
'系统默认会在 C 盘保留输出文件。请输入新路径后点击保存按钮。',
|
| 35 |
+
lowVram: '低显存优化',
|
| 36 |
+
lowVramDesc:
|
| 37 |
+
'尽量关闭 fast 超分、在加载管线后尝试 CPU 分层卸载(仅当引擎提供 Diffusers 式 API 才可能生效)。每次生成结束会卸载管线。说明:整模型常驻 GPU 时占用仍可能接近满配(例如约 24GB),要明显降占用需更短时长/更低分辨率或 FP8 等小权重。',
|
| 38 |
+
modelLoraSettings: '模型与LoRA设置',
|
| 39 |
+
modelFolder: '模型文件夹',
|
| 40 |
+
modelFolderPh: '例如: F:\\LTX2.3\\models',
|
| 41 |
+
loraFolder: 'LoRA文件夹',
|
| 42 |
+
loraFolderPh: '例如: F:\\LTX2.3\\loras',
|
| 43 |
+
saveScan: '保存并扫描',
|
| 44 |
+
loraPlacementHintWithDir:
|
| 45 |
+
'将 LoRA 文件放到默认模型目录: <code>{dir}</code>\\loras',
|
| 46 |
+
basicEngine: '基础画面 / Basic EngineSpecs',
|
| 47 |
+
qualityLevel: '清晰度级别',
|
| 48 |
+
aspectRatio: '画幅比例',
|
| 49 |
+
ratio169: '16:9 电影宽幅',
|
| 50 |
+
ratio916: '9:16 移动竖屏',
|
| 51 |
+
resPreviewPrefix: '最终发送规格',
|
| 52 |
+
fpsLabel: '帧率 (FPS)',
|
| 53 |
+
durationLabel: '时长 (秒)',
|
| 54 |
+
cameraMotion: '镜头运动方式',
|
| 55 |
+
motionStatic: 'Static (静止机位)',
|
| 56 |
+
motionDollyIn: 'Dolly In (推近)',
|
| 57 |
+
motionDollyOut: 'Dolly Out (拉远)',
|
| 58 |
+
motionDollyLeft: 'Dolly Left (向左)',
|
| 59 |
+
motionDollyRight: 'Dolly Right (向右)',
|
| 60 |
+
motionJibUp: 'Jib Up (升臂)',
|
| 61 |
+
motionJibDown: 'Jib Down (降臂)',
|
| 62 |
+
motionFocus: 'Focus Shift (焦点)',
|
| 63 |
+
audioGen: '生成 AI 环境音 (Audio Gen)',
|
| 64 |
+
selectModel: '选择模型',
|
| 65 |
+
selectLora: '选择 LoRA',
|
| 66 |
+
defaultModel: '使用默认模型',
|
| 67 |
+
noLora: '不使用 LoRA',
|
| 68 |
+
loraStrength: 'LoRA 强度',
|
| 69 |
+
genSource: '生成媒介 / Generation Source',
|
| 70 |
+
startFrame: '起始帧 (首帧)',
|
| 71 |
+
endFrame: '结束帧 (尾帧)',
|
| 72 |
+
uploadStart: '上传首帧',
|
| 73 |
+
uploadEnd: '上传尾帧 (可选)',
|
| 74 |
+
refAudio: '参考音频 (A2V)',
|
| 75 |
+
uploadAudio: '点击上传音频',
|
| 76 |
+
sourceHint:
|
| 77 |
+
'💡 若仅上传首帧 = 图生视频/音视频;若同时上传首尾帧 = 首尾插帧。',
|
| 78 |
+
imgPreset: '预设分辨率 (Presets)',
|
| 79 |
+
imgOptSquare: '1:1 Square (1024x1024)',
|
| 80 |
+
imgOptLand: '16:9 Landscape (1280x720)',
|
| 81 |
+
imgOptPort: '9:16 Portrait (720x1280)',
|
| 82 |
+
imgOptCustom: 'Custom 自定义...',
|
| 83 |
+
width: '宽度',
|
| 84 |
+
height: '高度',
|
| 85 |
+
samplingSteps: '采样步数 (Steps)',
|
| 86 |
+
upscaleSource: '待超分视频 (Source)',
|
| 87 |
+
upscaleUpload: '拖入低分辨率视频片段',
|
| 88 |
+
targetRes: '目标分辨率',
|
| 89 |
+
upscale1080: '1080P Full HD (2x)',
|
| 90 |
+
upscale720: '720P HD',
|
| 91 |
+
smartMultiFrameGroup: '智能多帧',
|
| 92 |
+
workflowModeLabel: '工作流模式(点击切换)',
|
| 93 |
+
wfSingle: '单次多关键帧',
|
| 94 |
+
wfSegments: '分段拼接',
|
| 95 |
+
uploadImages: '上传图片',
|
| 96 |
+
uploadMulti1: '点击或拖入多张图片',
|
| 97 |
+
uploadMulti2: '支持一次选多张,可多次添加',
|
| 98 |
+
batchStripTitle: '已选图片 · 顺序 = 播放先后',
|
| 99 |
+
batchStripHint: '在缩略图上按住拖动���序;松手落入虚线框位置',
|
| 100 |
+
batchFfmpegHint:
|
| 101 |
+
'💡 <strong>分段模式</strong>:2 张 = 1 段;3 张 = 2 段再拼接。<strong>单次模式</strong>:几张图就几个 latent 锚点,一条视频出片。<br>多段需 <code style="font-size:9px;">ffmpeg</code>:装好后加 PATH,或设环境变量 <code style="font-size:9px;">LTX_FFMPEG_PATH</code>,或在 <code style="font-size:9px;">%LOCALAPPDATA%\\LTXDesktop\\ffmpeg_path.txt</code> 第一行写 ffmpeg.exe 完整路径。',
|
| 102 |
+
globalPromptLabel: '本页全局补充词(可选)',
|
| 103 |
+
globalPromptPh: '与顶部主 Prompt 叠加;单次模式与分段模式均可用',
|
| 104 |
+
bgmLabel: '成片配乐(可选,统一音轨)',
|
| 105 |
+
bgmUploadHint: '上传一条完整 BGM(生成完成后会替换整段成片的音轨)',
|
| 106 |
+
mainRender: '开始渲染',
|
| 107 |
+
waitingTask: '等待分配渲染任务...',
|
| 108 |
+
libHistory: '历史资产 / ASSETS',
|
| 109 |
+
libLog: '系统日志 / LOGS',
|
| 110 |
+
refresh: '刷新',
|
| 111 |
+
logReady: '> LTX-2 Studio Ready. Expecting commands...',
|
| 112 |
+
resizeHandleTitle: '拖动调整面板高度',
|
| 113 |
+
batchNeedTwo: '💡 请上传至少2张图片',
|
| 114 |
+
batchSegTitle: '视频片段设置(分段拼接)',
|
| 115 |
+
batchSegClip: '片段',
|
| 116 |
+
batchSegDuration: '时长',
|
| 117 |
+
batchSegSec: '秒',
|
| 118 |
+
batchSegPrompt: '片段提示词',
|
| 119 |
+
batchSegPromptPh: '此片段的提示词,如:跳舞、吃饭...',
|
| 120 |
+
batchKfPanelTitle: '单次多关键帧 · 时间轴',
|
| 121 |
+
batchTotalDur: '总时长',
|
| 122 |
+
batchTotalSec: '秒',
|
| 123 |
+
batchPanelHint:
|
| 124 |
+
'用「间隔」连接相邻关键帧:第 1 张固定在 0 s,最后一张在<strong>各间隔之和</strong>的终点。顶部总时长与每张的锚点时刻会随间隔即时刷新。因后端按<strong>整数秒</strong>建序列,实际请求里的整段时长为合计秒数<strong>向上取整</strong>(至少 2),略长于小数合计时属正常。镜头与 FPS 仍用左侧「视频生成」。',
|
| 125 |
+
batchKfTitle: '关键帧',
|
| 126 |
+
batchStrength: '引导强度',
|
| 127 |
+
batchGapTitle: '间隔',
|
| 128 |
+
batchSec: '秒',
|
| 129 |
+
batchAnchorStart: '片头',
|
| 130 |
+
batchAnchorEnd: '片尾',
|
| 131 |
+
batchThumbDrag: '按住拖动排序',
|
| 132 |
+
batchThumbRemove: '删除',
|
| 133 |
+
batchAddMore: '+ 继续添加',
|
| 134 |
+
batchGapInputTitle: '上一关键帧到下一关键帧的时长(秒);总时长 = 各间隔之和',
|
| 135 |
+
batchStrengthTitle: '与 Comfy guide strength 类似,中间帧可调低(如 0.2)减轻闪烁',
|
| 136 |
+
batchTotalPillTitle: '等于下方各「间隔」之和,无需单独填写',
|
| 137 |
+
defaultPath: '默认路径',
|
| 138 |
+
phase_loading_model: '加载权重',
|
| 139 |
+
phase_encoding_text: 'T5 编码',
|
| 140 |
+
phase_validating_request: '校验请求',
|
| 141 |
+
phase_uploading_audio: '上传音频',
|
| 142 |
+
phase_uploading_image: '上传图像',
|
| 143 |
+
phase_inference: 'AI 推理',
|
| 144 |
+
phase_downloading_output: '下载结果',
|
| 145 |
+
phase_complete: '完成',
|
| 146 |
+
gpuBusyPrefix: 'GPU 运算中',
|
| 147 |
+
progressStepUnit: '步',
|
| 148 |
+
loaderGpuAlloc: 'GPU 正在分配资源...',
|
| 149 |
+
warnGenerating: '⚠️ 当前正在生成中,请等待完成',
|
| 150 |
+
warnBatchPrompt: '⚠️ 智能多帧请至少填写:顶部主提示词、本页全局补充词或某一「片段提示词」',
|
| 151 |
+
warnNeedPrompt: '⚠️ 请输入提示词后再开始渲染',
|
| 152 |
+
warnVideoLong: '⚠️ 时长设定为 {n}s 极长,可能导致显存溢出或耗时较久。',
|
| 153 |
+
errUpscaleNoVideo: '请先上传待超分的视频',
|
| 154 |
+
errBatchMinImages: '请上传至少2张图片',
|
| 155 |
+
errSingleKfPrompt: '单次多关键帧请至少填写顶部主提示词或本页全局补充词',
|
| 156 |
+
loraNoneLabel: '无',
|
| 157 |
+
modelDefaultLabel: '默认',
|
| 158 |
+
},
|
| 159 |
+
en: {
|
| 160 |
+
tabVideo: 'Video',
|
| 161 |
+
tabBatch: 'Multi-frame',
|
| 162 |
+
tabUpscale: 'Upscale',
|
| 163 |
+
tabImage: 'Image',
|
| 164 |
+
promptLabel: 'Prompt',
|
| 165 |
+
promptPlaceholder: 'Describe the scene...',
|
| 166 |
+
promptPlaceholderUpscale: 'Optional guidance for enhancement...',
|
| 167 |
+
clearVram: 'Clear VRAM',
|
| 168 |
+
clearingVram: 'Clearing...',
|
| 169 |
+
settingsTitle: 'Advanced settings',
|
| 170 |
+
langToggleAriaZh: 'Switch to English',
|
| 171 |
+
langToggleAriaEn: 'Switch to Chinese',
|
| 172 |
+
sysScanning: 'Scanning GPU...',
|
| 173 |
+
sysBusy: 'Busy...',
|
| 174 |
+
sysOnline: 'Online / Ready',
|
| 175 |
+
sysStarting: 'Starting...',
|
| 176 |
+
sysOffline: 'Backend offline (port 3000)',
|
| 177 |
+
advancedSettings: 'Advanced',
|
| 178 |
+
deviceSelect: 'GPU device',
|
| 179 |
+
gpuDetecting: 'Detecting GPU...',
|
| 180 |
+
outputPath: 'Output & upload folder',
|
| 181 |
+
outputPathPh: 'e.g. D:\\LTX_outputs',
|
| 182 |
+
savePath: 'Save path',
|
| 183 |
+
outputPathHint:
|
| 184 |
+
'Outputs default to C: drive. Enter a folder and click Save.',
|
| 185 |
+
lowVram: 'Low-VRAM mode',
|
| 186 |
+
lowVramDesc:
|
| 187 |
+
'Tries to reduce VRAM (engine-dependent). Shorter duration / lower resolution helps more.',
|
| 188 |
+
modelLoraSettings: 'Model & LoRA folders',
|
| 189 |
+
modelFolder: 'Models folder',
|
| 190 |
+
modelFolderPh: 'e.g. F:\\LTX2.3\\models',
|
| 191 |
+
loraFolder: 'LoRAs folder',
|
| 192 |
+
loraFolderPh: 'e.g. F:\\LTX2.3\\loras',
|
| 193 |
+
saveScan: 'Save & scan',
|
| 194 |
+
loraHint: 'Put .safetensors / .ckpt LoRAs here, then refresh lists.',
|
| 195 |
+
basicEngine: 'Basic / Engine',
|
| 196 |
+
qualityLevel: 'Quality',
|
| 197 |
+
aspectRatio: 'Aspect ratio',
|
| 198 |
+
ratio169: '16:9 widescreen',
|
| 199 |
+
ratio916: '9:16 portrait',
|
| 200 |
+
resPreviewPrefix: 'Output',
|
| 201 |
+
fpsLabel: 'FPS',
|
| 202 |
+
durationLabel: 'Duration (s)',
|
| 203 |
+
cameraMotion: 'Camera motion',
|
| 204 |
+
motionStatic: 'Static',
|
| 205 |
+
motionDollyIn: 'Dolly in',
|
| 206 |
+
motionDollyOut: 'Dolly out',
|
| 207 |
+
motionDollyLeft: 'Dolly left',
|
| 208 |
+
motionDollyRight: 'Dolly right',
|
| 209 |
+
motionJibUp: 'Jib up',
|
| 210 |
+
motionJibDown: 'Jib down',
|
| 211 |
+
motionFocus: 'Focus shift',
|
| 212 |
+
audioGen: 'AI ambient audio',
|
| 213 |
+
selectModel: 'Model',
|
| 214 |
+
selectLora: 'LoRA',
|
| 215 |
+
defaultModel: 'Default model',
|
| 216 |
+
noLora: 'No LoRA',
|
| 217 |
+
loraStrength: 'LoRA strength',
|
| 218 |
+
genSource: 'Source media',
|
| 219 |
+
startFrame: 'Start frame',
|
| 220 |
+
endFrame: 'End frame (optional)',
|
| 221 |
+
uploadStart: 'Upload start',
|
| 222 |
+
uploadEnd: 'Upload end (opt.)',
|
| 223 |
+
refAudio: 'Reference audio (A2V)',
|
| 224 |
+
uploadAudio: 'Upload audio',
|
| 225 |
+
sourceHint:
|
| 226 |
+
'💡 Start only = I2V / A2V; start + end = interpolation.',
|
| 227 |
+
imgPreset: 'Resolution presets',
|
| 228 |
+
imgOptSquare: '1:1 (1024×1024)',
|
| 229 |
+
imgOptLand: '16:9 (1280×720)',
|
| 230 |
+
imgOptPort: '9:16 (720×1280)',
|
| 231 |
+
imgOptCustom: 'Custom...',
|
| 232 |
+
width: 'Width',
|
| 233 |
+
height: 'Height',
|
| 234 |
+
samplingSteps: 'Steps',
|
| 235 |
+
upscaleSource: 'Source video',
|
| 236 |
+
upscaleUpload: 'Drop low-res video',
|
| 237 |
+
targetRes: 'Target resolution',
|
| 238 |
+
upscale1080: '1080p Full HD (2×)',
|
| 239 |
+
upscale720: '720p HD',
|
| 240 |
+
smartMultiFrameGroup: 'Smart multi-frame',
|
| 241 |
+
workflowModeLabel: 'Workflow',
|
| 242 |
+
wfSingle: 'Single pass',
|
| 243 |
+
wfSegments: 'Segments',
|
| 244 |
+
uploadImages: 'Upload images',
|
| 245 |
+
uploadMulti1: 'Click or drop multiple images',
|
| 246 |
+
uploadMulti2: 'Multi-select OK; add more anytime.',
|
| 247 |
+
batchStripTitle: 'Order = playback',
|
| 248 |
+
batchStripHint: 'Drag thumbnails to reorder.',
|
| 249 |
+
batchFfmpegHint:
|
| 250 |
+
'💡 <strong>Segments</strong>: 2 images → 1 clip; 3 → 2 clips stitched. <strong>Single</strong>: N images → N latent anchors, one video.<br>Stitching needs <code style="font-size:9px;">ffmpeg</code> on PATH, or <code style="font-size:9px;">LTX_FFMPEG_PATH</code>, or <code style="font-size:9px;">%LOCALAPPDATA%\\LTXDesktop\\ffmpeg_path.txt</code> with full path to ffmpeg.exe.',
|
| 251 |
+
globalPromptLabel: 'Extra prompt (optional)',
|
| 252 |
+
globalPromptPh: 'Appended to main prompt for both modes.',
|
| 253 |
+
bgmLabel: 'Full-length BGM (optional)',
|
| 254 |
+
bgmUploadHint: 'Replaces final mix audio after generation.',
|
| 255 |
+
mainRender: 'Render',
|
| 256 |
+
waitingTask: 'Waiting for task...',
|
| 257 |
+
libHistory: 'Assets',
|
| 258 |
+
libLog: 'Logs',
|
| 259 |
+
refresh: 'Refresh',
|
| 260 |
+
logReady: '> LTX-2 Studio ready.',
|
| 261 |
+
resizeHandleTitle: 'Drag to resize panel',
|
| 262 |
+
batchNeedTwo: '💡 Upload at least 2 images',
|
| 263 |
+
batchSegTitle: 'Segment settings',
|
| 264 |
+
batchSegClip: 'Clip',
|
| 265 |
+
batchSegDuration: 'Duration',
|
| 266 |
+
batchSegSec: 's',
|
| 267 |
+
batchSegPrompt: 'Prompt',
|
| 268 |
+
batchSegPromptPh: 'e.g. dancing, walking...',
|
| 269 |
+
batchKfPanelTitle: 'Single pass · timeline',
|
| 270 |
+
batchTotalDur: 'Total',
|
| 271 |
+
batchTotalSec: 's',
|
| 272 |
+
batchPanelHint:
|
| 273 |
+
'Use gaps between keyframes: first at 0s, last at the sum of gaps. Totals update live. Backend uses whole seconds (ceil, min 2). Motion & FPS use the Video panel.',
|
| 274 |
+
batchKfTitle: 'Keyframe',
|
| 275 |
+
batchStrength: 'Strength',
|
| 276 |
+
batchGapTitle: 'Gap',
|
| 277 |
+
batchSec: 's',
|
| 278 |
+
batchAnchorStart: 'start',
|
| 279 |
+
batchAnchorEnd: 'end',
|
| 280 |
+
batchThumbDrag: 'Drag to reorder',
|
| 281 |
+
batchThumbRemove: 'Remove',
|
| 282 |
+
batchAddMore: '+ Add more',
|
| 283 |
+
batchGapInputTitle: 'Seconds between keyframes; total = sum of gaps',
|
| 284 |
+
batchStrengthTitle: 'Guide strength (lower on middle keys may reduce flicker)',
|
| 285 |
+
batchTotalPillTitle: 'Equals the sum of gaps below',
|
| 286 |
+
defaultPath: 'default',
|
| 287 |
+
phase_loading_model: 'Loading weights',
|
| 288 |
+
phase_encoding_text: 'T5 encode',
|
| 289 |
+
phase_validating_request: 'Validating',
|
| 290 |
+
phase_uploading_audio: 'Uploading audio',
|
| 291 |
+
phase_uploading_image: 'Uploading image',
|
| 292 |
+
phase_inference: 'Inference',
|
| 293 |
+
phase_downloading_output: 'Downloading',
|
| 294 |
+
phase_complete: 'Done',
|
| 295 |
+
gpuBusyPrefix: 'GPU',
|
| 296 |
+
progressStepUnit: 'steps',
|
| 297 |
+
loaderGpuAlloc: 'Allocating GPU...',
|
| 298 |
+
warnGenerating: '⚠️ Already generating, please wait.',
|
| 299 |
+
warnBatchPrompt: '⚠️ Enter main prompt, page extra prompt, or a segment prompt.',
|
| 300 |
+
warnNeedPrompt: '⚠️ Enter a prompt first.',
|
| 301 |
+
warnVideoLong: '⚠️ Duration {n}s is very long; may OOM or take a long time.',
|
| 302 |
+
errUpscaleNoVideo: 'Upload a video to upscale first.',
|
| 303 |
+
errBatchMinImages: 'Upload at least 2 images.',
|
| 304 |
+
errSingleKfNeedPrompt: 'Enter main or page extra prompt for single-pass keyframes.',
|
| 305 |
+
loraNoneLabel: 'none',
|
| 306 |
+
modelDefaultLabel: 'default',
|
| 307 |
+
loraPlacementHintWithDir:
|
| 308 |
+
'Place LoRAs into the default models directory: <code>{dir}</code>\\loras',
|
| 309 |
+
},
|
| 310 |
+
};
|
| 311 |
+
|
| 312 |
+
function getLang() {
|
| 313 |
+
return localStorage.getItem(STORAGE_KEY) === 'en' ? 'en' : 'zh';
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
function setLang(lang) {
|
| 317 |
+
const L = lang === 'en' ? 'en' : 'zh';
|
| 318 |
+
localStorage.setItem(STORAGE_KEY, L);
|
| 319 |
+
document.documentElement.lang = L === 'en' ? 'en' : 'zh-CN';
|
| 320 |
+
try {
|
| 321 |
+
applyI18n();
|
| 322 |
+
} catch (err) {
|
| 323 |
+
console.error('[i18n] applyI18n failed:', err);
|
| 324 |
+
}
|
| 325 |
+
updateLangButton();
|
| 326 |
+
if (typeof global.onUiLanguageChanged === 'function') {
|
| 327 |
+
try {
|
| 328 |
+
global.onUiLanguageChanged();
|
| 329 |
+
} catch (e) {
|
| 330 |
+
console.warn('onUiLanguageChanged', e);
|
| 331 |
+
}
|
| 332 |
+
}
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
function t(key) {
|
| 336 |
+
const L = getLang();
|
| 337 |
+
const table = STR[L] || STR.zh;
|
| 338 |
+
if (Object.prototype.hasOwnProperty.call(table, key)) return table[key];
|
| 339 |
+
if (Object.prototype.hasOwnProperty.call(STR.zh, key)) return STR.zh[key];
|
| 340 |
+
return key;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
function applyI18n(root) {
|
| 344 |
+
root = root || document;
|
| 345 |
+
root.querySelectorAll('[data-i18n]').forEach(function (el) {
|
| 346 |
+
var key = el.getAttribute('data-i18n');
|
| 347 |
+
if (!key) return;
|
| 348 |
+
if (el.tagName === 'OPTION') {
|
| 349 |
+
el.textContent = t(key);
|
| 350 |
+
} else {
|
| 351 |
+
el.textContent = t(key);
|
| 352 |
+
}
|
| 353 |
+
});
|
| 354 |
+
root.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
|
| 355 |
+
var key = el.getAttribute('data-i18n-placeholder');
|
| 356 |
+
if (key) el.placeholder = t(key);
|
| 357 |
+
});
|
| 358 |
+
root.querySelectorAll('[data-i18n-title]').forEach(function (el) {
|
| 359 |
+
var key = el.getAttribute('data-i18n-title');
|
| 360 |
+
if (key) el.title = t(key);
|
| 361 |
+
});
|
| 362 |
+
root.querySelectorAll('[data-i18n-html]').forEach(function (el) {
|
| 363 |
+
var key = el.getAttribute('data-i18n-html');
|
| 364 |
+
if (key) el.innerHTML = t(key);
|
| 365 |
+
});
|
| 366 |
+
root.querySelectorAll('[data-i18n-value]').forEach(function (el) {
|
| 367 |
+
var key = el.getAttribute('data-i18n-value');
|
| 368 |
+
if (key && (el.tagName === 'INPUT' || el.tagName === 'BUTTON')) {
|
| 369 |
+
el.value = t(key);
|
| 370 |
+
}
|
| 371 |
+
});
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
function updateLangButton() {
|
| 375 |
+
var btn = document.getElementById('lang-toggle-btn');
|
| 376 |
+
if (!btn) return;
|
| 377 |
+
btn.textContent = getLang() === 'zh' ? 'EN' : '中';
|
| 378 |
+
btn.setAttribute(
|
| 379 |
+
'aria-label',
|
| 380 |
+
getLang() === 'zh' ? t('langToggleAriaZh') : t('langToggleAriaEn')
|
| 381 |
+
);
|
| 382 |
+
btn.classList.toggle('active', getLang() === 'en');
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
function toggleUiLanguage() {
|
| 386 |
+
try {
|
| 387 |
+
setLang(getLang() === 'zh' ? 'en' : 'zh');
|
| 388 |
+
} catch (err) {
|
| 389 |
+
console.error('[i18n] toggleUiLanguage failed:', err);
|
| 390 |
+
}
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
/** 避免 CSP 拦截内联 onclick;确保按钮一定能触发 */
|
| 394 |
+
function bindLangToggleButton() {
|
| 395 |
+
var btn = document.getElementById('lang-toggle-btn');
|
| 396 |
+
if (!btn || btn.dataset.i18nBound === '1') return;
|
| 397 |
+
btn.dataset.i18nBound = '1';
|
| 398 |
+
btn.removeAttribute('onclick');
|
| 399 |
+
btn.addEventListener('click', function (ev) {
|
| 400 |
+
ev.preventDefault();
|
| 401 |
+
toggleUiLanguage();
|
| 402 |
+
});
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
function boot() {
|
| 406 |
+
document.documentElement.lang = getLang() === 'en' ? 'en' : 'zh-CN';
|
| 407 |
+
try {
|
| 408 |
+
applyI18n();
|
| 409 |
+
} catch (err) {
|
| 410 |
+
console.error('[i18n] applyI18n failed:', err);
|
| 411 |
+
}
|
| 412 |
+
updateLangButton();
|
| 413 |
+
bindLangToggleButton();
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
global.getUiLang = getLang;
|
| 417 |
+
global.setUiLang = setLang;
|
| 418 |
+
global.t = t;
|
| 419 |
+
global.applyI18n = applyI18n;
|
| 420 |
+
global.toggleUiLanguage = toggleUiLanguage;
|
| 421 |
+
global.updateLangToggleButton = updateLangButton;
|
| 422 |
+
|
| 423 |
+
if (document.readyState === 'loading') {
|
| 424 |
+
document.addEventListener('DOMContentLoaded', boot);
|
| 425 |
+
} else {
|
| 426 |
+
boot();
|
| 427 |
+
}
|
| 428 |
+
})(typeof window !== 'undefined' ? window : global);
|
LTX2.3/UI/index.css
ADDED
|
@@ -0,0 +1,775 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--accent: #2563EB; /* Refined blue – not too bright, not purple */
|
| 3 |
+
--accent-hover:#3B82F6;
|
| 4 |
+
--accent-dim: rgba(37,99,235,0.14);
|
| 5 |
+
--accent-ring: rgba(37,99,235,0.35);
|
| 6 |
+
--bg: #111113;
|
| 7 |
+
--panel: #18181B;
|
| 8 |
+
--panel-2: #1F1F23;
|
| 9 |
+
--item: rgba(255,255,255,0.035);
|
| 10 |
+
--border: rgba(255,255,255,0.08);
|
| 11 |
+
--border-2: rgba(255,255,255,0.05);
|
| 12 |
+
--text-dim: #71717A;
|
| 13 |
+
--text-sub: #A1A1AA;
|
| 14 |
+
--text: #FAFAFA;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
* { box-sizing: border-box; -webkit-font-smoothing: antialiased; min-width: 0; }
|
| 18 |
+
body {
|
| 19 |
+
background: var(--bg); margin: 0; color: var(--text);
|
| 20 |
+
font-family: -apple-system, "SF Pro Display", "Segoe UI", sans-serif;
|
| 21 |
+
display: flex; height: 100vh; overflow: hidden;
|
| 22 |
+
font-size: 13px; line-height: 1.5;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.sidebar {
|
| 26 |
+
width: 460px; min-width: 460px;
|
| 27 |
+
background: var(--panel);
|
| 28 |
+
border-right: 1px solid var(--border);
|
| 29 |
+
display: flex; flex-direction: column; z-index: 20;
|
| 30 |
+
overflow-y: auto; overflow-x: hidden;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/* Scrollbar */
|
| 34 |
+
::-webkit-scrollbar { width: 5px; height: 5px; }
|
| 35 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 36 |
+
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 10px; }
|
| 37 |
+
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.18); }
|
| 38 |
+
|
| 39 |
+
.sidebar-header { padding: 24px 24px 4px; }
|
| 40 |
+
|
| 41 |
+
.lang-toggle {
|
| 42 |
+
background: transparent;
|
| 43 |
+
border: 1px solid var(--border);
|
| 44 |
+
color: var(--text-dim);
|
| 45 |
+
padding: 4px 10px;
|
| 46 |
+
border-radius: 6px;
|
| 47 |
+
font-size: 11px;
|
| 48 |
+
cursor: pointer;
|
| 49 |
+
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
| 50 |
+
font-weight: 700;
|
| 51 |
+
min-width: 44px;
|
| 52 |
+
flex-shrink: 0;
|
| 53 |
+
}
|
| 54 |
+
.lang-toggle:hover {
|
| 55 |
+
background: var(--item);
|
| 56 |
+
color: var(--text);
|
| 57 |
+
border-color: var(--accent);
|
| 58 |
+
}
|
| 59 |
+
.lang-toggle.active {
|
| 60 |
+
background: var(--accent);
|
| 61 |
+
color: #fff;
|
| 62 |
+
border-color: var(--accent);
|
| 63 |
+
}
|
| 64 |
+
.sidebar-section { padding: 8px 24px 18px; border-bottom: 1px solid var(--border); }
|
| 65 |
+
|
| 66 |
+
.setting-group {
|
| 67 |
+
background: rgba(255,255,255,0.025);
|
| 68 |
+
border: 1px solid var(--border-2);
|
| 69 |
+
border-radius: 10px;
|
| 70 |
+
padding: 14px;
|
| 71 |
+
margin-bottom: 12px;
|
| 72 |
+
}
|
| 73 |
+
.group-title {
|
| 74 |
+
font-size: 10px; color: var(--text-dim); font-weight: 700;
|
| 75 |
+
text-transform: uppercase; letter-spacing: 0.7px;
|
| 76 |
+
margin-bottom: 12px; padding-bottom: 5px;
|
| 77 |
+
border-bottom: 1px solid var(--border-2);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
/* Mode Tabs */
|
| 81 |
+
.tabs {
|
| 82 |
+
display: flex; gap: 4px; margin-bottom: 14px;
|
| 83 |
+
background: rgba(255,255,255,0.04);
|
| 84 |
+
padding: 4px; border-radius: 10px;
|
| 85 |
+
border: 1px solid var(--border-2);
|
| 86 |
+
}
|
| 87 |
+
.tab {
|
| 88 |
+
flex: 1; padding: 9px 0; text-align: center; border-radius: 7px;
|
| 89 |
+
cursor: pointer; font-size: 12px; color: var(--text-dim);
|
| 90 |
+
transition: all 0.2s; font-weight: 600;
|
| 91 |
+
display: flex; align-items: center; justify-content: center;
|
| 92 |
+
}
|
| 93 |
+
.tab.active { background: var(--accent); color: #fff; box-shadow: 0 1px 6px rgba(10,132,255,0.45); }
|
| 94 |
+
.tab:hover:not(.active) { background: rgba(255,255,255,0.06); color: var(--text); }
|
| 95 |
+
|
| 96 |
+
.label-group { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
| 97 |
+
label { display: block; font-size: 11px; color: var(--text-dim); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
|
| 98 |
+
.val-badge { font-size: 11px; color: var(--accent); font-family: "SF Mono", ui-monospace, monospace; font-weight: 600; }
|
| 99 |
+
|
| 100 |
+
input[type="text"], input[type="number"], select, textarea {
|
| 101 |
+
width: 100%; background: var(--panel-2);
|
| 102 |
+
border: 1px solid var(--border);
|
| 103 |
+
border-radius: 7px; color: var(--text);
|
| 104 |
+
padding: 8px 11px; font-size: 12.5px; outline: none; margin-bottom: 9px;
|
| 105 |
+
/* Only transition border/shadow – NOT background-image to prevent arrow flicker */
|
| 106 |
+
transition: border-color 0.15s, box-shadow 0.15s;
|
| 107 |
+
}
|
| 108 |
+
input:focus, select:focus, textarea:focus {
|
| 109 |
+
border-color: var(--accent);
|
| 110 |
+
box-shadow: 0 0 0 2px var(--accent-ring);
|
| 111 |
+
}
|
| 112 |
+
select {
|
| 113 |
+
-webkit-appearance: none; -moz-appearance: none; appearance: none;
|
| 114 |
+
/* Stable grey arrow – no background shorthand so it won't animate */
|
| 115 |
+
background-color: var(--panel-2);
|
| 116 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2371717A' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
| 117 |
+
background-repeat: no-repeat;
|
| 118 |
+
background-position: right 10px center;
|
| 119 |
+
background-size: 12px;
|
| 120 |
+
padding-right: 28px;
|
| 121 |
+
cursor: pointer;
|
| 122 |
+
/* Explicitly do NOT transition background properties */
|
| 123 |
+
transition: border-color 0.15s, box-shadow 0.15s;
|
| 124 |
+
}
|
| 125 |
+
select:focus { background-color: var(--panel-2); }
|
| 126 |
+
select option { background: #27272A; color: var(--text); }
|
| 127 |
+
textarea { resize: vertical; min-height: 78px; font-family: inherit; }
|
| 128 |
+
|
| 129 |
+
.slider-container { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
|
| 130 |
+
input[type="range"] { flex: 1; accent-color: var(--accent); height: 4px; cursor: pointer; border-radius: 2px; }
|
| 131 |
+
|
| 132 |
+
.upload-zone {
|
| 133 |
+
border: 1px dashed var(--border); border-radius: 10px;
|
| 134 |
+
padding: 18px 10px; text-align: center; cursor: pointer;
|
| 135 |
+
background: rgba(255,255,255,0.03); margin-bottom: 10px; position: relative;
|
| 136 |
+
transition: all 0.2s;
|
| 137 |
+
}
|
| 138 |
+
.upload-zone:hover, .upload-zone.dragover { background: var(--accent-dim); border-color: var(--accent); }
|
| 139 |
+
.upload-zone.has-images {
|
| 140 |
+
padding: 12px; background: rgba(255,255,255,0.025);
|
| 141 |
+
}
|
| 142 |
+
.upload-zone.has-images .upload-placeholder-mini {
|
| 143 |
+
display: flex; align-items: center; gap: 8px; justify-content: center;
|
| 144 |
+
color: var(--text-dim); font-size: 11px;
|
| 145 |
+
}
|
| 146 |
+
.upload-zone.has-images .upload-placeholder-mini span {
|
| 147 |
+
background: var(--item); padding: 6px 12px; border-radius: 6px;
|
| 148 |
+
}
|
| 149 |
+
#batch-images-placeholder { display: block; }
|
| 150 |
+
.upload-zone.has-images #batch-images-placeholder { display: none; }
|
| 151 |
+
|
| 152 |
+
/* 批量模式:上传区下方的横向缩略图条 */
|
| 153 |
+
.batch-thumb-strip-wrap {
|
| 154 |
+
margin-top: 10px;
|
| 155 |
+
margin-bottom: 4px;
|
| 156 |
+
}
|
| 157 |
+
.batch-thumb-strip-head {
|
| 158 |
+
display: flex;
|
| 159 |
+
flex-direction: column;
|
| 160 |
+
gap: 2px;
|
| 161 |
+
margin-bottom: 8px;
|
| 162 |
+
}
|
| 163 |
+
.batch-thumb-strip-title {
|
| 164 |
+
font-size: 11px;
|
| 165 |
+
font-weight: 700;
|
| 166 |
+
color: var(--text-sub);
|
| 167 |
+
}
|
| 168 |
+
.batch-thumb-strip-hint {
|
| 169 |
+
font-size: 10px;
|
| 170 |
+
color: var(--text-dim);
|
| 171 |
+
}
|
| 172 |
+
.batch-images-container {
|
| 173 |
+
display: flex;
|
| 174 |
+
flex-direction: row;
|
| 175 |
+
flex-wrap: nowrap;
|
| 176 |
+
gap: 10px;
|
| 177 |
+
overflow-x: auto;
|
| 178 |
+
overflow-y: visible;
|
| 179 |
+
padding: 6px 4px 14px;
|
| 180 |
+
margin: 0 -4px;
|
| 181 |
+
scrollbar-width: thin;
|
| 182 |
+
scrollbar-color: var(--border) transparent;
|
| 183 |
+
align-items: center;
|
| 184 |
+
}
|
| 185 |
+
.batch-images-container::-webkit-scrollbar { height: 6px; }
|
| 186 |
+
.batch-images-container::-webkit-scrollbar-thumb {
|
| 187 |
+
background: var(--border);
|
| 188 |
+
border-radius: 3px;
|
| 189 |
+
}
|
| 190 |
+
.batch-image-wrapper {
|
| 191 |
+
flex: 0 0 72px;
|
| 192 |
+
width: 72px;
|
| 193 |
+
height: 72px;
|
| 194 |
+
position: relative;
|
| 195 |
+
border-radius: 10px;
|
| 196 |
+
overflow: hidden;
|
| 197 |
+
background: var(--item);
|
| 198 |
+
border: 1px solid var(--border);
|
| 199 |
+
cursor: grab;
|
| 200 |
+
touch-action: none;
|
| 201 |
+
user-select: none;
|
| 202 |
+
-webkit-user-select: none;
|
| 203 |
+
transition:
|
| 204 |
+
flex-basis 0.38s cubic-bezier(0.22, 1, 0.36, 1),
|
| 205 |
+
width 0.38s cubic-bezier(0.22, 1, 0.36, 1),
|
| 206 |
+
min-width 0.38s cubic-bezier(0.22, 1, 0.36, 1),
|
| 207 |
+
margin 0.38s cubic-bezier(0.22, 1, 0.36, 1),
|
| 208 |
+
opacity 0.25s ease,
|
| 209 |
+
border-color 0.2s ease,
|
| 210 |
+
box-shadow 0.2s ease,
|
| 211 |
+
transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
|
| 212 |
+
}
|
| 213 |
+
.batch-image-wrapper:active { cursor: grabbing; }
|
| 214 |
+
.batch-image-wrapper.batch-thumb--source {
|
| 215 |
+
flex: 0 0 0;
|
| 216 |
+
width: 0;
|
| 217 |
+
min-width: 0;
|
| 218 |
+
height: 72px;
|
| 219 |
+
margin: 0;
|
| 220 |
+
padding: 0;
|
| 221 |
+
border: none;
|
| 222 |
+
overflow: hidden;
|
| 223 |
+
opacity: 0;
|
| 224 |
+
background: transparent;
|
| 225 |
+
box-shadow: none;
|
| 226 |
+
pointer-events: none;
|
| 227 |
+
/* 收起必须瞬时:若与占位框同时用 0.38s 过渡,右侧缩略图会与「突然出现」的槽位不同步而闪一下 */
|
| 228 |
+
transition: none !important;
|
| 229 |
+
}
|
| 230 |
+
/* 按下瞬间:冻结其它卡片与槽位动画,避免「槽位插入 + 邻居过渡」两帧打架 */
|
| 231 |
+
.batch-images-container.is-batch-settling .batch-image-wrapper:not(.batch-thumb--source) {
|
| 232 |
+
transition: none !important;
|
| 233 |
+
}
|
| 234 |
+
.batch-images-container.is-batch-settling .batch-thumb-drop-slot {
|
| 235 |
+
animation: none;
|
| 236 |
+
opacity: 1;
|
| 237 |
+
}
|
| 238 |
+
/* 拖动时跟手的浮动缩略图(避免原槽位透明后光标下像「黑块」) */
|
| 239 |
+
.batch-thumb-floating-ghost {
|
| 240 |
+
position: fixed;
|
| 241 |
+
left: 0;
|
| 242 |
+
top: 0;
|
| 243 |
+
z-index: 99999;
|
| 244 |
+
width: 76px;
|
| 245 |
+
height: 76px;
|
| 246 |
+
border-radius: 12px;
|
| 247 |
+
overflow: hidden;
|
| 248 |
+
pointer-events: none;
|
| 249 |
+
will-change: transform;
|
| 250 |
+
box-shadow:
|
| 251 |
+
0 20px 50px rgba(0, 0, 0, 0.45),
|
| 252 |
+
0 10px 28px rgba(0, 0, 0, 0.28),
|
| 253 |
+
0 0 0 1px rgba(255, 255, 255, 0.18);
|
| 254 |
+
transform: translate3d(0, 0, 0) scale(1.06) rotate(-1deg);
|
| 255 |
+
}
|
| 256 |
+
.batch-thumb-floating-ghost img {
|
| 257 |
+
width: 100%;
|
| 258 |
+
height: 100%;
|
| 259 |
+
object-fit: cover;
|
| 260 |
+
display: block;
|
| 261 |
+
pointer-events: none;
|
| 262 |
+
}
|
| 263 |
+
.batch-thumb-drop-slot {
|
| 264 |
+
flex: 0 0 72px;
|
| 265 |
+
width: 72px;
|
| 266 |
+
height: 72px;
|
| 267 |
+
box-sizing: border-box;
|
| 268 |
+
border-radius: 12px;
|
| 269 |
+
border: 2px dashed rgba(255, 255, 255, 0.22);
|
| 270 |
+
background: linear-gradient(145deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.03));
|
| 271 |
+
pointer-events: none;
|
| 272 |
+
transition: border-color 0.35s ease, box-shadow 0.35s ease, opacity 0.35s ease;
|
| 273 |
+
animation: batch-slot-breathe 2.4s ease-in-out infinite;
|
| 274 |
+
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
|
| 275 |
+
}
|
| 276 |
+
@keyframes batch-slot-breathe {
|
| 277 |
+
0%, 100% { opacity: 0.88; }
|
| 278 |
+
50% { opacity: 1; }
|
| 279 |
+
}
|
| 280 |
+
.batch-image-wrapper .batch-thumb-img-wrap {
|
| 281 |
+
width: 100%;
|
| 282 |
+
height: 100%;
|
| 283 |
+
border-radius: 9px;
|
| 284 |
+
overflow: hidden;
|
| 285 |
+
/* 必须让事件落到外层 .batch-image-wrapper,否则 HTML5 drag 无法从 draggable 父级启动 */
|
| 286 |
+
pointer-events: none;
|
| 287 |
+
}
|
| 288 |
+
.batch-image-wrapper .batch-thumb-img {
|
| 289 |
+
width: 100%;
|
| 290 |
+
height: 100%;
|
| 291 |
+
object-fit: cover;
|
| 292 |
+
display: block;
|
| 293 |
+
pointer-events: none;
|
| 294 |
+
user-select: none;
|
| 295 |
+
-webkit-user-drag: none;
|
| 296 |
+
}
|
| 297 |
+
.batch-thumb-remove {
|
| 298 |
+
position: absolute;
|
| 299 |
+
top: 3px;
|
| 300 |
+
right: 3px;
|
| 301 |
+
z-index: 5;
|
| 302 |
+
box-sizing: border-box;
|
| 303 |
+
min-width: 22px;
|
| 304 |
+
height: 22px;
|
| 305 |
+
padding: 0 5px;
|
| 306 |
+
margin: 0;
|
| 307 |
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
| 308 |
+
border-radius: 6px;
|
| 309 |
+
background: rgba(0, 0, 0, 0.5);
|
| 310 |
+
font-family: inherit;
|
| 311 |
+
font-size: 14px;
|
| 312 |
+
font-weight: 400;
|
| 313 |
+
line-height: 1;
|
| 314 |
+
color: rgba(255, 255, 255, 0.9);
|
| 315 |
+
opacity: 0.72;
|
| 316 |
+
cursor: pointer;
|
| 317 |
+
display: flex;
|
| 318 |
+
align-items: center;
|
| 319 |
+
justify-content: center;
|
| 320 |
+
transition: background 0.12s, opacity 0.12s, border-color 0.12s;
|
| 321 |
+
pointer-events: auto;
|
| 322 |
+
}
|
| 323 |
+
.batch-image-wrapper:hover .batch-thumb-remove {
|
| 324 |
+
opacity: 1;
|
| 325 |
+
background: rgba(0, 0, 0, 0.68);
|
| 326 |
+
border-color: rgba(255, 255, 255, 0.2);
|
| 327 |
+
}
|
| 328 |
+
.batch-thumb-remove:hover {
|
| 329 |
+
background: rgba(80, 20, 20, 0.75) !important;
|
| 330 |
+
border-color: rgba(255, 180, 180, 0.35);
|
| 331 |
+
color: #fff;
|
| 332 |
+
}
|
| 333 |
+
.batch-thumb-remove:focus-visible {
|
| 334 |
+
opacity: 1;
|
| 335 |
+
outline: 2px solid var(--accent-dim, rgba(120, 160, 255, 0.6));
|
| 336 |
+
outline-offset: 1px;
|
| 337 |
+
}
|
| 338 |
+
.upload-icon { font-size: 18px; margin-bottom: 6px; opacity: 0.45; }
|
| 339 |
+
.upload-text { font-size: 11px; color: var(--text); }
|
| 340 |
+
.upload-hint { font-size: 10px; color: var(--text-dim); margin-top: 3px; }
|
| 341 |
+
.preview-thumb { width: 100%; height: auto; max-height: 100px; object-fit: contain; border-radius: 8px; display: none; margin-top: 10px; }
|
| 342 |
+
.clear-img-overlay {
|
| 343 |
+
position: absolute; top: 8px; right: 8px; background: rgba(255,59,48,0.85); color: white;
|
| 344 |
+
width: 20px; height: 20px; border-radius: 10px; display: none; align-items: center; justify-content: center;
|
| 345 |
+
font-size: 11px; cursor: pointer; z-index: 5;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
.btn-outline {
|
| 349 |
+
background: var(--panel-2);
|
| 350 |
+
border: 1px solid var(--border);
|
| 351 |
+
color: var(--text-sub); padding: 5px 12px; border-radius: 7px;
|
| 352 |
+
font-size: 11.5px; font-weight: 600; cursor: pointer;
|
| 353 |
+
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
| 354 |
+
display: inline-flex; align-items: center; justify-content: center; gap: 5px;
|
| 355 |
+
white-space: nowrap;
|
| 356 |
+
}
|
| 357 |
+
.btn-outline:hover:not(:disabled) { background: rgba(255,255,255,0.08); color: var(--text); border-color: rgba(255,255,255,0.18); }
|
| 358 |
+
.btn-outline:active { opacity: 0.7; }
|
| 359 |
+
.btn-outline:disabled { opacity: 0.3; cursor: not-allowed; }
|
| 360 |
+
|
| 361 |
+
.btn-icon {
|
| 362 |
+
padding: 5px; background: transparent; border: none; color: var(--text-dim);
|
| 363 |
+
border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center;
|
| 364 |
+
transition: color 0.15s, background 0.15s;
|
| 365 |
+
}
|
| 366 |
+
.btn-icon:hover { color: var(--text-sub); background: rgba(255,255,255,0.07); }
|
| 367 |
+
|
| 368 |
+
.btn-primary {
|
| 369 |
+
width: 100%; padding: 13px;
|
| 370 |
+
background: var(--accent); border: none;
|
| 371 |
+
border-radius: 9px; color: #fff; font-weight: 700; font-size: 13.5px;
|
| 372 |
+
letter-spacing: 0.2px; cursor: pointer; margin-top: 14px;
|
| 373 |
+
transition: background 0.15s;
|
| 374 |
+
}
|
| 375 |
+
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
|
| 376 |
+
.btn-primary:active { opacity: 0.82; }
|
| 377 |
+
.btn-primary:disabled { background: rgba(255,255,255,0.08); color: var(--text-dim); cursor: not-allowed; }
|
| 378 |
+
|
| 379 |
+
.btn-danger {
|
| 380 |
+
width: 100%; padding: 12px; background: #DC2626; border: none;
|
| 381 |
+
border-radius: 9px; color: #fff; font-weight: 700; font-size: 13.5px;
|
| 382 |
+
cursor: pointer; margin-top: 8px; display: none; transition: background 0.15s;
|
| 383 |
+
}
|
| 384 |
+
.btn-danger:hover { background: #EF4444; }
|
| 385 |
+
|
| 386 |
+
/* Workspace */
|
| 387 |
+
.workspace { flex: 1; display: flex; flex-direction: column; background: #0A0A0A; position: relative; overflow: hidden; }
|
| 388 |
+
.viewer { flex: 2; display: flex; align-items: center; justify-content: center; padding: 16px; background: #0A0A0A; position: relative; min-height: 40vh; }
|
| 389 |
+
.monitor {
|
| 390 |
+
width: 100%; height: 100%; max-width: 1650px; border-radius: 10px; border: 1px solid var(--border);
|
| 391 |
+
overflow: hidden; position: relative; background: #070707;
|
| 392 |
+
display: flex; align-items: center; justify-content: center;
|
| 393 |
+
background-image: radial-gradient(rgba(255,255,255,0.02) 1px, transparent 1px);
|
| 394 |
+
background-size: 18px 18px;
|
| 395 |
+
}
|
| 396 |
+
.monitor img, .monitor video {
|
| 397 |
+
width: auto; height: auto; max-width: 100%; max-height: 100%;
|
| 398 |
+
object-fit: contain; display: none; z-index: 2; border-radius: 3px;
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
.progress-container { position: absolute; bottom: 0; left: 0; width: 100%; height: 2px; background: var(--border-2); z-index: 10; }
|
| 402 |
+
#progress-fill { width: 0%; height: 100%; background: var(--accent); transition: width 0.5s; }
|
| 403 |
+
#loading-txt { font-size: 12px; color: var(--text-sub); font-weight: 600; z-index: 5; position: absolute; display: none; }
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
.spinner {
|
| 408 |
+
width: 12px; height: 12px;
|
| 409 |
+
border: 2px solid rgba(255,255,255,0.2);
|
| 410 |
+
border-top-color: currentColor;
|
| 411 |
+
border-radius: 50%;
|
| 412 |
+
animation: spin 1s linear infinite;
|
| 413 |
+
}
|
| 414 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 415 |
+
|
| 416 |
+
.loading-card {
|
| 417 |
+
display: flex; align-items: center; justify-content: center;
|
| 418 |
+
flex-direction: column; gap: 6px; color: var(--text-dim); font-size: 10px;
|
| 419 |
+
background: rgba(37,99,235,0.07) !important;
|
| 420 |
+
border-color: rgba(37,99,235,0.3) !important;
|
| 421 |
+
}
|
| 422 |
+
.loading-card .spinner { width: 28px; height: 28px; border-width: 3px; color: var(--accent); }
|
| 423 |
+
.loading-card:hover { background: rgba(37,99,235,0.14) !important; border-color: var(--accent) !important; }
|
| 424 |
+
|
| 425 |
+
.library { flex: 1.5; border-top: 1px solid var(--border); padding: 14px 20px; display: flex; flex-direction: column; background: #0F0F11; overflow-y: hidden; }
|
| 426 |
+
#log-container { flex: 1; overflow-y: auto; padding-right: 4px; }
|
| 427 |
+
#log { font-family: ui-monospace, "SF Mono", monospace; font-size: 10.5px; color: var(--text-dim); line-height: 1.7; }
|
| 428 |
+
|
| 429 |
+
/* History wrapper: scrollable area for thumbnails only */
|
| 430 |
+
#history-wrapper {
|
| 431 |
+
flex: 1;
|
| 432 |
+
overflow-y: auto;
|
| 433 |
+
min-height: 110px; /* always show at least one row */
|
| 434 |
+
padding-right: 4px;
|
| 435 |
+
}
|
| 436 |
+
#history-container {
|
| 437 |
+
display: grid;
|
| 438 |
+
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
| 439 |
+
justify-content: start;
|
| 440 |
+
gap: 10px; align-content: flex-start;
|
| 441 |
+
padding-bottom: 4px;
|
| 442 |
+
}
|
| 443 |
+
/* Pagination row: hidden, using infinite scroll instead */
|
| 444 |
+
#pagination-bar {
|
| 445 |
+
display: none;
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
.history-card {
|
| 449 |
+
width: 100%; max-width: 200px; aspect-ratio: 16 / 9;
|
| 450 |
+
background: #1A1A1E; border-radius: 7px;
|
| 451 |
+
overflow: hidden; border: 1px solid var(--border);
|
| 452 |
+
cursor: pointer; position: relative; transition: border-color 0.15s, transform 0.15s;
|
| 453 |
+
}
|
| 454 |
+
.history-card:hover { border-color: var(--accent); transform: translateY(-1px); }
|
| 455 |
+
.history-card img, .history-card video {
|
| 456 |
+
width: 100%; height: 100%; object-fit: cover;
|
| 457 |
+
background: #1A1A1E;
|
| 458 |
+
}
|
| 459 |
+
/* 解码/加载完成前避免视频黑块猛闪,与卡片底色一致;就绪后淡入 */
|
| 460 |
+
.history-card .history-thumb-media {
|
| 461 |
+
opacity: 0;
|
| 462 |
+
transition: opacity 0.28s ease;
|
| 463 |
+
}
|
| 464 |
+
.history-card .history-thumb-media.history-thumb-ready {
|
| 465 |
+
opacity: 1;
|
| 466 |
+
}
|
| 467 |
+
.history-type-badge {
|
| 468 |
+
position: absolute; top: 5px; left: 5px; font-size: 8px; padding: 1px 5px; border-radius: 3px;
|
| 469 |
+
background: rgba(0,0,0,0.8); color: var(--text-sub); border: 1px solid rgba(255,255,255,0.06);
|
| 470 |
+
z-index: 2; font-weight: 700; letter-spacing: 0.4px;
|
| 471 |
+
}
|
| 472 |
+
.history-delete-btn {
|
| 473 |
+
position: absolute; top: 5px; right: 5px; width: 20px; height: 20px;
|
| 474 |
+
border-radius: 50%; border: none; background: rgba(255,50,50,0.8); color: #fff;
|
| 475 |
+
font-size: 10px; cursor: pointer; z-index: 3; display: flex; align-items: center; justify-content: center;
|
| 476 |
+
opacity: 0; transition: opacity 0.2s;
|
| 477 |
+
}
|
| 478 |
+
.history-card:hover .history-delete-btn { opacity: 1; }
|
| 479 |
+
.history-delete-btn:hover { background: rgba(255,0,0,0.9); }
|
| 480 |
+
|
| 481 |
+
.vram-bar { width: 160px; height: 5px; background: rgba(255,255,255,0.08); border-radius: 999px; overflow: hidden; display: inline-block; vertical-align: middle; }
|
| 482 |
+
.vram-used { height: 100%; background: var(--accent); width: 0%; transition: width 0.5s; }
|
| 483 |
+
|
| 484 |
+
/* 智能多帧:工作流模式卡片式单选 */
|
| 485 |
+
.smart-param-mode-label {
|
| 486 |
+
font-size: 10px;
|
| 487 |
+
color: var(--text-dim);
|
| 488 |
+
font-weight: 700;
|
| 489 |
+
margin-bottom: 8px;
|
| 490 |
+
letter-spacing: 0.04em;
|
| 491 |
+
text-transform: uppercase;
|
| 492 |
+
}
|
| 493 |
+
.smart-param-modes {
|
| 494 |
+
display: flex;
|
| 495 |
+
flex-direction: row;
|
| 496 |
+
align-items: stretch;
|
| 497 |
+
gap: 0;
|
| 498 |
+
padding: 3px;
|
| 499 |
+
margin-bottom: 12px;
|
| 500 |
+
background: var(--panel-2);
|
| 501 |
+
border-radius: 8px;
|
| 502 |
+
border: 1px solid var(--border);
|
| 503 |
+
}
|
| 504 |
+
.smart-param-mode-opt {
|
| 505 |
+
display: flex;
|
| 506 |
+
align-items: center;
|
| 507 |
+
justify-content: center;
|
| 508 |
+
flex: 1;
|
| 509 |
+
min-width: 0;
|
| 510 |
+
gap: 0;
|
| 511 |
+
margin: 0;
|
| 512 |
+
padding: 6px 8px;
|
| 513 |
+
border-radius: 6px;
|
| 514 |
+
border: none;
|
| 515 |
+
background: transparent;
|
| 516 |
+
cursor: pointer;
|
| 517 |
+
transition: background 0.15s, color 0.15s;
|
| 518 |
+
position: relative;
|
| 519 |
+
}
|
| 520 |
+
.smart-param-mode-opt:hover:not(:has(input:checked)) {
|
| 521 |
+
background: rgba(255, 255, 255, 0.05);
|
| 522 |
+
}
|
| 523 |
+
.smart-param-mode-opt input[type="radio"] {
|
| 524 |
+
position: absolute;
|
| 525 |
+
opacity: 0;
|
| 526 |
+
width: 0;
|
| 527 |
+
height: 0;
|
| 528 |
+
margin: 0;
|
| 529 |
+
}
|
| 530 |
+
.smart-param-mode-opt:has(input:checked) {
|
| 531 |
+
background: var(--accent);
|
| 532 |
+
box-shadow: none;
|
| 533 |
+
}
|
| 534 |
+
.smart-param-mode-opt:has(input:checked) .smart-param-mode-title {
|
| 535 |
+
color: #fff;
|
| 536 |
+
}
|
| 537 |
+
.smart-param-mode-title {
|
| 538 |
+
font-size: 11px;
|
| 539 |
+
font-weight: 600;
|
| 540 |
+
color: var(--text-sub);
|
| 541 |
+
text-align: center;
|
| 542 |
+
line-height: 1.25;
|
| 543 |
+
flex: none;
|
| 544 |
+
min-width: 0;
|
| 545 |
+
}
|
| 546 |
+
/* 单次多关键帧:时间轴面板 */
|
| 547 |
+
.batch-kf-panel {
|
| 548 |
+
background: var(--item);
|
| 549 |
+
border-radius: 10px;
|
| 550 |
+
padding: 12px 14px;
|
| 551 |
+
margin-bottom: 10px;
|
| 552 |
+
border: 1px solid var(--border);
|
| 553 |
+
}
|
| 554 |
+
.batch-kf-panel-hd {
|
| 555 |
+
display: flex;
|
| 556 |
+
flex-wrap: wrap;
|
| 557 |
+
align-items: center;
|
| 558 |
+
justify-content: space-between;
|
| 559 |
+
gap: 10px;
|
| 560 |
+
margin-bottom: 8px;
|
| 561 |
+
}
|
| 562 |
+
.batch-kf-panel-title {
|
| 563 |
+
font-size: 12px;
|
| 564 |
+
font-weight: 700;
|
| 565 |
+
color: var(--text);
|
| 566 |
+
}
|
| 567 |
+
.batch-kf-total-pill {
|
| 568 |
+
font-size: 11px;
|
| 569 |
+
color: var(--text-sub);
|
| 570 |
+
background: var(--panel-2);
|
| 571 |
+
border: 1px solid var(--border);
|
| 572 |
+
border-radius: 999px;
|
| 573 |
+
padding: 6px 12px;
|
| 574 |
+
white-space: nowrap;
|
| 575 |
+
}
|
| 576 |
+
.batch-kf-total-pill strong {
|
| 577 |
+
color: var(--accent);
|
| 578 |
+
font-weight: 800;
|
| 579 |
+
font-variant-numeric: tabular-nums;
|
| 580 |
+
margin: 0 2px;
|
| 581 |
+
}
|
| 582 |
+
.batch-kf-total-unit {
|
| 583 |
+
font-size: 10px;
|
| 584 |
+
color: var(--text-dim);
|
| 585 |
+
}
|
| 586 |
+
.batch-kf-panel-hint {
|
| 587 |
+
font-size: 10px;
|
| 588 |
+
color: var(--text-dim);
|
| 589 |
+
line-height: 1.5;
|
| 590 |
+
margin: 0 0 12px;
|
| 591 |
+
}
|
| 592 |
+
.batch-kf-timeline-col {
|
| 593 |
+
display: flex;
|
| 594 |
+
flex-direction: column;
|
| 595 |
+
gap: 0;
|
| 596 |
+
}
|
| 597 |
+
.batch-kf-kcard {
|
| 598 |
+
border-radius: 10px;
|
| 599 |
+
border: 1px solid var(--border);
|
| 600 |
+
background: rgba(255, 255, 255, 0.03);
|
| 601 |
+
padding: 10px 12px;
|
| 602 |
+
}
|
| 603 |
+
.batch-kf-kcard-head {
|
| 604 |
+
display: flex;
|
| 605 |
+
align-items: center;
|
| 606 |
+
gap: 12px;
|
| 607 |
+
margin-bottom: 10px;
|
| 608 |
+
}
|
| 609 |
+
.batch-kf-kthumb {
|
| 610 |
+
width: 48px;
|
| 611 |
+
height: 48px;
|
| 612 |
+
border-radius: 8px;
|
| 613 |
+
object-fit: cover;
|
| 614 |
+
flex-shrink: 0;
|
| 615 |
+
border: 1px solid var(--border);
|
| 616 |
+
}
|
| 617 |
+
.batch-kf-kcard-titles {
|
| 618 |
+
display: flex;
|
| 619 |
+
flex-direction: column;
|
| 620 |
+
gap: 4px;
|
| 621 |
+
min-width: 0;
|
| 622 |
+
}
|
| 623 |
+
.batch-kf-ktitle {
|
| 624 |
+
font-size: 12px;
|
| 625 |
+
font-weight: 700;
|
| 626 |
+
color: var(--text);
|
| 627 |
+
}
|
| 628 |
+
.batch-kf-anchor {
|
| 629 |
+
font-size: 11px;
|
| 630 |
+
color: var(--accent);
|
| 631 |
+
font-variant-numeric: tabular-nums;
|
| 632 |
+
font-weight: 600;
|
| 633 |
+
}
|
| 634 |
+
.batch-kf-kcard-ctrl {
|
| 635 |
+
display: flex;
|
| 636 |
+
flex-wrap: wrap;
|
| 637 |
+
align-items: center;
|
| 638 |
+
gap: 12px;
|
| 639 |
+
}
|
| 640 |
+
.batch-kf-klabel {
|
| 641 |
+
font-size: 10px;
|
| 642 |
+
color: var(--text-dim);
|
| 643 |
+
display: flex;
|
| 644 |
+
align-items: center;
|
| 645 |
+
gap: 8px;
|
| 646 |
+
}
|
| 647 |
+
.batch-kf-klabel input[type="number"] {
|
| 648 |
+
width: 72px;
|
| 649 |
+
padding: 6px 8px;
|
| 650 |
+
font-size: 12px;
|
| 651 |
+
border-radius: 6px;
|
| 652 |
+
border: 1px solid var(--border);
|
| 653 |
+
background: var(--panel);
|
| 654 |
+
color: var(--text);
|
| 655 |
+
}
|
| 656 |
+
/* 关键帧之间:细时间轴 + 单行紧凑间隔输入 */
|
| 657 |
+
.batch-kf-gap {
|
| 658 |
+
display: flex;
|
| 659 |
+
align-items: stretch;
|
| 660 |
+
gap: 8px;
|
| 661 |
+
padding: 0 0 6px;
|
| 662 |
+
margin: 0 0 0 10px;
|
| 663 |
+
}
|
| 664 |
+
.batch-kf-gap-rail {
|
| 665 |
+
width: 2px;
|
| 666 |
+
flex-shrink: 0;
|
| 667 |
+
border-radius: 2px;
|
| 668 |
+
background: linear-gradient(
|
| 669 |
+
180deg,
|
| 670 |
+
rgba(255, 255, 255, 0.06),
|
| 671 |
+
var(--accent-dim),
|
| 672 |
+
rgba(255, 255, 255, 0.04)
|
| 673 |
+
);
|
| 674 |
+
min-height: 22px;
|
| 675 |
+
align-self: stretch;
|
| 676 |
+
}
|
| 677 |
+
.batch-kf-gap-inner {
|
| 678 |
+
display: flex;
|
| 679 |
+
align-items: center;
|
| 680 |
+
gap: 8px;
|
| 681 |
+
flex: 1;
|
| 682 |
+
min-width: 0;
|
| 683 |
+
padding: 2px 0 4px;
|
| 684 |
+
}
|
| 685 |
+
.batch-kf-gap-ix {
|
| 686 |
+
font-size: 10px;
|
| 687 |
+
font-weight: 600;
|
| 688 |
+
color: var(--text-dim);
|
| 689 |
+
font-variant-numeric: tabular-nums;
|
| 690 |
+
letter-spacing: -0.02em;
|
| 691 |
+
flex-shrink: 0;
|
| 692 |
+
}
|
| 693 |
+
.batch-kf-seg-field {
|
| 694 |
+
display: inline-flex;
|
| 695 |
+
align-items: center;
|
| 696 |
+
gap: 3px;
|
| 697 |
+
margin: 0;
|
| 698 |
+
cursor: text;
|
| 699 |
+
}
|
| 700 |
+
.batch-kf-seg-input {
|
| 701 |
+
width: 46px;
|
| 702 |
+
min-width: 0;
|
| 703 |
+
padding: 2px 5px;
|
| 704 |
+
font-size: 11px;
|
| 705 |
+
font-weight: 600;
|
| 706 |
+
line-height: 1.3;
|
| 707 |
+
border-radius: 4px;
|
| 708 |
+
border: 1px solid var(--border);
|
| 709 |
+
background: rgba(0, 0, 0, 0.2);
|
| 710 |
+
color: var(--text);
|
| 711 |
+
font-variant-numeric: tabular-nums;
|
| 712 |
+
}
|
| 713 |
+
.batch-kf-seg-input:hover {
|
| 714 |
+
border-color: rgba(255, 255, 255, 0.12);
|
| 715 |
+
}
|
| 716 |
+
.batch-kf-seg-input:focus {
|
| 717 |
+
outline: none;
|
| 718 |
+
border-color: var(--accent);
|
| 719 |
+
box-shadow: 0 0 0 1px var(--accent-ring);
|
| 720 |
+
}
|
| 721 |
+
.batch-kf-gap-unit {
|
| 722 |
+
font-size: 10px;
|
| 723 |
+
color: var(--text-dim);
|
| 724 |
+
font-weight: 500;
|
| 725 |
+
flex-shrink: 0;
|
| 726 |
+
}
|
| 727 |
+
|
| 728 |
+
.sub-mode-toggle { display: flex; background: var(--panel-2); border-radius: 7px; padding: 3px; border: 1px solid var(--border); }
|
| 729 |
+
.sub-mode-btn { flex: 1; padding: 6px 0; border-radius: 5px; border: none; background: transparent; font-size: 11.5px; color: var(--text-dim); font-weight: 600; cursor: pointer; transition: background 0.15s, color 0.15s; }
|
| 730 |
+
.sub-mode-btn.active { background: var(--accent); color: #fff; }
|
| 731 |
+
.sub-mode-btn:hover:not(.active) { background: rgba(255,255,255,0.05); color: var(--text-sub); }
|
| 732 |
+
|
| 733 |
+
.vid-section { display: none; margin-top: 12px; }
|
| 734 |
+
.vid-section.active-section { display: block; animation: fadeIn 0.25s ease; }
|
| 735 |
+
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
| 736 |
+
|
| 737 |
+
/* Status indicator */
|
| 738 |
+
@keyframes breathe-orange {
|
| 739 |
+
0%,100% { box-shadow: 0 0 4px #FF9F0A; opacity: 0.7; }
|
| 740 |
+
50% { box-shadow: 0 0 10px #FF9F0A; opacity: 1; }
|
| 741 |
+
}
|
| 742 |
+
.indicator-busy { background: #FF9F0A !important; animation: breathe-orange 1.6s infinite ease-in-out !important; box-shadow: none !important; transition: all 0.3s; }
|
| 743 |
+
.indicator-ready { background: #30D158 !important; box-shadow: 0 0 8px rgba(48,209,88,0.6) !important; animation: none !important; transition: all 0.3s; }
|
| 744 |
+
.indicator-offline { background: #636366 !important; box-shadow: none !important; animation: none !important; transition: all 0.3s; }
|
| 745 |
+
|
| 746 |
+
.res-preview-tag { font-size: 11px; color: var(--accent); margin-bottom: 10px; font-family: ui-monospace, monospace; }
|
| 747 |
+
.top-status { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-dim); margin-bottom: 8px; align-items: center; }
|
| 748 |
+
.checkbox-container { display: flex; align-items: center; gap: 8px; cursor: pointer; background: rgba(255,255,255,0.02); padding: 10px; border-radius: 8px; border: 1px solid var(--border-2); }
|
| 749 |
+
.checkbox-container input { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; margin: 0; }
|
| 750 |
+
.checkbox-container label { margin-bottom: 0; cursor: pointer; text-transform: none; color: var(--text); }
|
| 751 |
+
.flex-row { display: flex; gap: 10px; }
|
| 752 |
+
.flex-1 { flex: 1; min-width: 0; }
|
| 753 |
+
|
| 754 |
+
@media (max-width: 1024px) {
|
| 755 |
+
body { flex-direction: column; overflow-y: auto; }
|
| 756 |
+
.sidebar { width: 100%; min-width: 100%; border-right: none; border-bottom: 1px solid var(--border); height: auto; overflow: visible; }
|
| 757 |
+
.workspace { height: auto; min-height: 100vh; overflow: visible; }
|
| 758 |
+
}
|
| 759 |
+
:root {
|
| 760 |
+
--plyr-color-main: #3F51B5;
|
| 761 |
+
--plyr-video-control-background-hover: rgba(255,255,255,0.1);
|
| 762 |
+
--plyr-control-radius: 6px;
|
| 763 |
+
--plyr-player-width: 100%;
|
| 764 |
+
}
|
| 765 |
+
.plyr {
|
| 766 |
+
border-radius: 8px;
|
| 767 |
+
overflow: hidden;
|
| 768 |
+
width: 100%;
|
| 769 |
+
height: 100%;
|
| 770 |
+
}
|
| 771 |
+
.plyr--video .plyr__controls {
|
| 772 |
+
background: linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0.8));
|
| 773 |
+
padding: 20px 15px 15px 15px;
|
| 774 |
+
}
|
| 775 |
+
|
LTX2.3/UI/index.html
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>LTX-2 | Multi-GPU Cinematic Studio</title>
|
| 7 |
+
<link rel="stylesheet" href="index.css">
|
| 8 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/plyr/3.7.8/plyr.css" />
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
|
| 12 |
+
<aside class="sidebar">
|
| 13 |
+
<div class="sidebar-header">
|
| 14 |
+
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
|
| 15 |
+
<div style="display: flex; align-items: center; gap: 10px;">
|
| 16 |
+
<div id="sys-indicator" class="indicator-ready" style="width: 12px; height: 12px; border-radius: 50%;"></div>
|
| 17 |
+
<span style="font-weight: 800; font-size: 18px;">LTX-2 STUDIO</span>
|
| 18 |
+
</div>
|
| 19 |
+
<div style="display: flex; gap: 8px; align-items: center;">
|
| 20 |
+
<button type="button" id="lang-toggle-btn" class="lang-toggle">EN</button>
|
| 21 |
+
<button id="clearGpuBtn" onclick="clearGpu()" class="btn-outline" data-i18n="clearVram">释放显存</button>
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
|
| 25 |
+
<div class="top-status" style="margin-bottom: 5px;">
|
| 26 |
+
<div style="display: flex; align-items: center; gap: 8px;">
|
| 27 |
+
<span id="sys-status" style="font-weight:bold; color: var(--text-dim); font-size: 12px;" data-i18n="sysScanning">正在扫描 GPU...</span>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
<button type="button" onclick="const el = document.getElementById('sys-settings'); el.style.display = el.style.display === 'none' ? 'block' : 'none';" class="btn-icon" data-i18n-title="settingsTitle" title="系统高级设置">
|
| 31 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
|
| 32 |
+
</button>
|
| 33 |
+
|
| 34 |
+
</div>
|
| 35 |
+
|
| 36 |
+
<div style="font-size: 11px; color: var(--text-dim); margin-bottom: 20px; display: flex; align-items: center; width: 100%;">
|
| 37 |
+
<div class="vram-bar" style="width: 120px; min-width: 120px; margin-top: 0; margin-right: 12px;"><div class="vram-used" id="vram-fill"></div></div>
|
| 38 |
+
<span id="vram-text" style="font-variant-numeric: tabular-nums; flex-shrink: 0; text-align: right;">0/32 GB</span>
|
| 39 |
+
<span id="gpu-name" style="display: none;"></span> <!-- Hidden globally to avoid duplicate -->
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<div id="sys-settings" style="display: none; padding: 14px; background: rgba(0,0,0,0.4) !important; border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); margin-bottom: 15px; box-shadow: 0 4px 15px rgba(0,0,0,0.5); backdrop-filter: blur(10px);">
|
| 43 |
+
<div style="font-size: 13px; font-weight: bold; margin-bottom: 12px; color: #fff;" data-i18n="advancedSettings">高级设置</div>
|
| 44 |
+
|
| 45 |
+
<label style="font-size: 11px; margin-bottom: 6px;" data-i18n="deviceSelect">工作设备选择</label>
|
| 46 |
+
<select id="gpu-selector" onchange="switchGpu(this.value)" style="margin-bottom: 12px; font-size: 11px; padding: 6px;">
|
| 47 |
+
<option value="" data-i18n="gpuDetecting">正在检测 GPU...</option>
|
| 48 |
+
</select>
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
<label style="font-size: 11px; margin-bottom: 6px; margin-top: 12px;" data-i18n="modelLoraSettings">模型与LoRA设置</label>
|
| 52 |
+
<div id="lora-placement-hint" style="font-size: 11px; color: var(--text-dim); margin-top: 8px; line-height: 1.4;" data-i18n="loraPlacementHint">
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
<div class="sidebar-section">
|
| 58 |
+
<div class="tabs">
|
| 59 |
+
<div id="tab-video" class="tab" onclick="switchMode('video')">
|
| 60 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 6px;"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"></rect><line x1="7" y1="2" x2="7" y2="22"></line><line x1="17" y1="2" x2="17" y2="22"></line><line x1="2" y1="12" x2="22" y2="12"></line><line x1="2" y1="7" x2="7" y2="7"></line><line x1="2" y1="17" x2="7" y2="17"></line><line x1="17" y1="17" x2="22" y2="17"></line><line x1="17" y1="7" x2="22" y2="7"></line></svg>
|
| 61 |
+
<span data-i18n="tabVideo">视频生成</span>
|
| 62 |
+
</div>
|
| 63 |
+
<div id="tab-batch" class="tab" onclick="switchMode('batch')">
|
| 64 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 6px;"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>
|
| 65 |
+
<span data-i18n="tabBatch">智能多帧</span>
|
| 66 |
+
</div>
|
| 67 |
+
<div id="tab-upscale" class="tab" onclick="switchMode('upscale')">
|
| 68 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 6px;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
|
| 69 |
+
<span data-i18n="tabUpscale">视频增强</span>
|
| 70 |
+
</div>
|
| 71 |
+
<div id="tab-image" class="tab" onclick="switchMode('image')">
|
| 72 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 6px;"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>
|
| 73 |
+
<span data-i18n="tabImage">图像生成</span>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<label data-i18n="promptLabel">视觉描述词 (Prompt)</label>
|
| 78 |
+
<textarea id="prompt" data-i18n-placeholder="promptPlaceholder" placeholder="在此输入视觉描述词 (Prompt)..." style="height: 90px; margin-bottom: 0;"></textarea>
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
<!-- 视频模式选项 -->
|
| 82 |
+
<div class="sidebar-section" id="video-opts" style="display:none">
|
| 83 |
+
<div class="setting-group">
|
| 84 |
+
<div class="group-title" data-i18n="basicEngine">基础画面 / Basic EngineSpecs</div>
|
| 85 |
+
<div class="flex-row">
|
| 86 |
+
<div class="flex-1">
|
| 87 |
+
<label data-i18n="qualityLevel">清晰度级别</label>
|
| 88 |
+
<select id="vid-quality" onchange="updateResPreview()">
|
| 89 |
+
<option value="1080">1080P Full HD</option>
|
| 90 |
+
<option value="720" selected>720P Standard</option>
|
| 91 |
+
<option value="540">540P Preview</option>
|
| 92 |
+
</select>
|
| 93 |
+
</div>
|
| 94 |
+
<div class="flex-1">
|
| 95 |
+
<label data-i18n="aspectRatio">画幅比例</label>
|
| 96 |
+
<select id="vid-ratio" onchange="updateResPreview()">
|
| 97 |
+
<option value="16:9" data-i18n="ratio169">16:9 电影宽幅</option>
|
| 98 |
+
<option value="9:16" data-i18n="ratio916">9:16 移动竖屏</option>
|
| 99 |
+
</select>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
<div id="res-preview" class="res-preview-tag" style="margin-top: -5px; margin-bottom: 12px;">最终发送: 1280x704</div>
|
| 103 |
+
|
| 104 |
+
<div class="flex-row">
|
| 105 |
+
<div class="flex-1">
|
| 106 |
+
<label data-i18n="fpsLabel">帧率 (FPS)</label>
|
| 107 |
+
<select id="vid-fps">
|
| 108 |
+
<option value="24" selected>24 FPS</option>
|
| 109 |
+
<option value="25">25 FPS</option>
|
| 110 |
+
<option value="30">30 FPS</option>
|
| 111 |
+
<option value="48">48 FPS</option>
|
| 112 |
+
<option value="60">60 FPS</option>
|
| 113 |
+
</select>
|
| 114 |
+
</div>
|
| 115 |
+
<div class="flex-1">
|
| 116 |
+
<label data-i18n="durationLabel">时长 (秒)</label>
|
| 117 |
+
<input type="number" id="vid-duration" value="5" min="1" max="30" step="1">
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
<label style="margin-top: 12px;" data-i18n="cameraMotion">镜头运动方式</label>
|
| 122 |
+
<select id="vid-motion">
|
| 123 |
+
<option value="static" selected data-i18n="motionStatic">Static (静止机位)</option>
|
| 124 |
+
<option value="dolly_in" data-i18n="motionDollyIn">Dolly In (推近)</option>
|
| 125 |
+
<option value="dolly_out" data-i18n="motionDollyOut">Dolly Out (拉远)</option>
|
| 126 |
+
<option value="dolly_left" data-i18n="motionDollyLeft">Dolly Left (向左)</option>
|
| 127 |
+
<option value="dolly_right" data-i18n="motionDollyRight">Dolly Right (向右)</option>
|
| 128 |
+
<option value="jib_up" data-i18n="motionJibUp">Jib Up (升臂)</option>
|
| 129 |
+
<option value="jib_down" data-i18n="motionJibDown">Jib Down (降臂)</option>
|
| 130 |
+
<option value="focus_shift" data-i18n="motionFocus">Focus Shift (焦点)</option>
|
| 131 |
+
</select>
|
| 132 |
+
<div class="checkbox-container" style="margin-top: 8px;">
|
| 133 |
+
<input type="checkbox" id="vid-audio" checked>
|
| 134 |
+
<label for="vid-audio" data-i18n="audioGen">生成 AI 环境音 (Audio Gen)</label>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
<label style="margin-top: 12px;" data-i18n="selectModel">选择模型</label>
|
| 138 |
+
<select id="vid-model" style="margin-bottom: 8px;">
|
| 139 |
+
<option value="" data-i18n="defaultModel">使用默认模型</option>
|
| 140 |
+
</select>
|
| 141 |
+
<label data-i18n="selectLora">选择 LoRA</label>
|
| 142 |
+
<select id="vid-lora" onchange="updateLoraStrength()" style="margin-bottom: 8px;">
|
| 143 |
+
<option value="" data-i18n="noLora">不使用 LoRA</option>
|
| 144 |
+
</select>
|
| 145 |
+
<div id="lora-strength-container" style="display: none;">
|
| 146 |
+
<label data-i18n="loraStrength">LoRA 强度</label>
|
| 147 |
+
<input type="range" id="lora-strength" min="0.1" max="2.0" step="0.1" value="1.0" style="width: 100%;" oninput="document.getElementById('lora-strength-val').textContent = this.value">
|
| 148 |
+
<span id="lora-strength-val" style="font-size: 12px; color: var(--accent);">1.0</span>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
<!-- 生成媒介组 -->
|
| 153 |
+
<div class="setting-group" id="video-source-group">
|
| 154 |
+
<div class="group-title" data-i18n="genSource">生成媒介 / Generation Source</div>
|
| 155 |
+
|
| 156 |
+
<div class="flex-row" style="margin-bottom: 10px;">
|
| 157 |
+
<div class="flex-1">
|
| 158 |
+
<label data-i18n="startFrame">起始帧 (首帧)</label>
|
| 159 |
+
<div class="upload-zone" id="start-frame-drop-zone" onclick="document.getElementById('start-frame-input').click()">
|
| 160 |
+
<div class="clear-img-overlay" id="clear-start-frame-overlay" onclick="event.stopPropagation(); clearFrame('start')">×</div>
|
| 161 |
+
<div id="start-frame-placeholder">
|
| 162 |
+
<div class="upload-icon">🖼️</div>
|
| 163 |
+
<div class="upload-text" data-i18n="uploadStart">上传首帧</div>
|
| 164 |
+
</div>
|
| 165 |
+
<img id="start-frame-preview" class="preview-thumb">
|
| 166 |
+
<input type="file" id="start-frame-input" accept="image/*" style="display:none" onchange="handleFrameUpload(this.files[0], 'start')">
|
| 167 |
+
</div>
|
| 168 |
+
<input type="hidden" id="start-frame-path">
|
| 169 |
+
</div>
|
| 170 |
+
<div class="flex-1">
|
| 171 |
+
<label data-i18n="endFrame">结束帧 (尾帧)</label>
|
| 172 |
+
<div class="upload-zone" id="end-frame-drop-zone" onclick="document.getElementById('end-frame-input').click()">
|
| 173 |
+
<div class="clear-img-overlay" id="clear-end-frame-overlay" onclick="event.stopPropagation(); clearFrame('end')">×</div>
|
| 174 |
+
<div id="end-frame-placeholder">
|
| 175 |
+
<div class="upload-icon">🏁</div>
|
| 176 |
+
<div class="upload-text" data-i18n="uploadEnd">上传尾帧 (可选)</div>
|
| 177 |
+
</div>
|
| 178 |
+
<img id="end-frame-preview" class="preview-thumb">
|
| 179 |
+
<input type="file" id="end-frame-input" accept="image/*" style="display:none" onchange="handleFrameUpload(this.files[0], 'end')">
|
| 180 |
+
</div>
|
| 181 |
+
<input type="hidden" id="end-frame-path">
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
|
| 185 |
+
<div class="flex-row">
|
| 186 |
+
<div class="flex-1">
|
| 187 |
+
<label data-i18n="refAudio">参考音频 (A2V)</label>
|
| 188 |
+
<div class="upload-zone" id="audio-drop-zone" onclick="document.getElementById('vid-audio-input').click()">
|
| 189 |
+
<div class="clear-img-overlay" id="clear-audio-overlay" onclick="event.stopPropagation(); clearUploadedAudio()">×</div>
|
| 190 |
+
<div id="audio-upload-placeholder">
|
| 191 |
+
<div class="upload-icon">🎵</div>
|
| 192 |
+
<div class="upload-text" data-i18n="uploadAudio">点击上传音频</div>
|
| 193 |
+
</div>
|
| 194 |
+
<div id="audio-upload-status" style="display:none;">
|
| 195 |
+
<div class="upload-icon" style="color:var(--accent); opacity:1;">✔️</div>
|
| 196 |
+
<div id="audio-filename-status" class="upload-text"></div>
|
| 197 |
+
</div>
|
| 198 |
+
<input type="file" id="vid-audio-input" accept="audio/*" style="display:none" onchange="handleAudioUpload(this.files[0])">
|
| 199 |
+
</div>
|
| 200 |
+
<input type="hidden" id="uploaded-audio-path">
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
<div style="font-size: 10px; color: var(--text-dim); text-align: center; margin-top: 5px;" data-i18n="sourceHint">
|
| 204 |
+
💡 若仅上传首帧 = 图生视频/音视频;若同时上传首尾帧 = 首尾插帧。
|
| 205 |
+
</div>
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
<!-- 图像模式选项 -->
|
| 210 |
+
<div id="image-opts" class="sidebar-section" style="display:none">
|
| 211 |
+
<label data-i18n="imgPreset">预设分辨率 (Presets)</label>
|
| 212 |
+
<select id="img-res-preset" onchange="applyImgPreset(this.value)">
|
| 213 |
+
<option value="1024x1024" data-i18n="imgOptSquare">1:1 Square (1024x1024)</option>
|
| 214 |
+
<option value="1280x720" data-i18n="imgOptLand">16:9 Landscape (1280x720)</option>
|
| 215 |
+
<option value="720x1280" data-i18n="imgOptPort">9:16 Portrait (720x1280)</option>
|
| 216 |
+
<option value="custom" data-i18n="imgOptCustom">Custom 自定义...</option>
|
| 217 |
+
</select>
|
| 218 |
+
|
| 219 |
+
<div id="img-custom-res" class="flex-row" style="margin-top: 10px;">
|
| 220 |
+
<div class="flex-1"><label data-i18n="width">宽度</label><input type="number" id="img-w" value="1024" onchange="updateImgResPreview()"></div>
|
| 221 |
+
<div class="flex-1"><label data-i18n="height">高度</label><input type="number" id="img-h" value="1024" onchange="updateImgResPreview()"></div>
|
| 222 |
+
</div>
|
| 223 |
+
<div id="img-res-preview" class="res-preview-tag">最终发送: 1024x1024</div>
|
| 224 |
+
|
| 225 |
+
<div class="label-group" style="margin-top: 15px;">
|
| 226 |
+
<label data-i18n="samplingSteps">采样步数 (Steps)</label>
|
| 227 |
+
<span class="val-badge" id="stepsVal">28</span>
|
| 228 |
+
</div>
|
| 229 |
+
<div class="slider-container">
|
| 230 |
+
<input type="range" id="img-steps" min="1" max="50" value="28" oninput="document.getElementById('stepsVal').innerText=this.value">
|
| 231 |
+
</div>
|
| 232 |
+
</div>
|
| 233 |
+
|
| 234 |
+
<!-- 超分模式选项 -->
|
| 235 |
+
<div id="upscale-opts" class="sidebar-section" style="display:none">
|
| 236 |
+
<div class="setting-group">
|
| 237 |
+
<label data-i18n="upscaleSource">待超分视频 (Source)</label>
|
| 238 |
+
<div class="upload-zone" id="upscale-drop-zone" onclick="document.getElementById('upscale-video-input').click()" style="margin-bottom: 0;">
|
| 239 |
+
<div class="clear-img-overlay" id="clear-upscale-overlay" onclick="event.stopPropagation(); clearUpscaleVideo()">×</div>
|
| 240 |
+
<div id="upscale-placeholder">
|
| 241 |
+
<div class="upload-icon">📹</div>
|
| 242 |
+
<div class="upload-text" data-i18n="upscaleUpload">拖入低分辨率视频片段</div>
|
| 243 |
+
</div>
|
| 244 |
+
<div id="upscale-status" style="display:none;">
|
| 245 |
+
<div class="upload-icon" style="color:var(--accent); opacity:1;">✔️</div>
|
| 246 |
+
<div id="upscale-filename" class="upload-text"></div>
|
| 247 |
+
</div>
|
| 248 |
+
<input type="file" id="upscale-video-input" accept="video/*" style="display:none" onchange="handleUpscaleVideoUpload(this.files[0])">
|
| 249 |
+
</div>
|
| 250 |
+
<input type="hidden" id="upscale-video-path">
|
| 251 |
+
</div>
|
| 252 |
+
|
| 253 |
+
<div class="setting-group">
|
| 254 |
+
<label data-i18n="targetRes">目标分辨率</label>
|
| 255 |
+
<select id="upscale-res" style="margin-bottom: 0;">
|
| 256 |
+
<option value="1080p" data-i18n="upscale1080">1080P Full HD (2x)</option>
|
| 257 |
+
<option value="720p" data-i18n="upscale720">720P HD</option>
|
| 258 |
+
</select>
|
| 259 |
+
</div>
|
| 260 |
+
</div>
|
| 261 |
+
|
| 262 |
+
<!-- 智能多帧模式 -->
|
| 263 |
+
<div class="sidebar-section" id="batch-opts" style="display:none">
|
| 264 |
+
<div class="setting-group">
|
| 265 |
+
<div class="group-title" data-i18n="smartMultiFrameGroup">智能多帧</div>
|
| 266 |
+
<div class="smart-param-mode-label" data-i18n="workflowModeLabel">工作流模式(点击切换)</div>
|
| 267 |
+
<div class="smart-param-modes" role="radiogroup" aria-label="工作流模式">
|
| 268 |
+
<label class="smart-param-mode-opt">
|
| 269 |
+
<input type="radio" name="batch-workflow" value="single" checked onchange="onBatchWorkflowChange()">
|
| 270 |
+
<span class="smart-param-mode-title" data-i18n="wfSingle">单次多关键帧</span>
|
| 271 |
+
</label>
|
| 272 |
+
<label class="smart-param-mode-opt">
|
| 273 |
+
<input type="radio" name="batch-workflow" value="segments" onchange="onBatchWorkflowChange()">
|
| 274 |
+
<span class="smart-param-mode-title" data-i18n="wfSegments">分段拼接</span>
|
| 275 |
+
</label>
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
<label data-i18n="uploadImages">上传图片</label>
|
| 279 |
+
<div class="upload-zone" id="batch-images-drop-zone" onclick="document.getElementById('batch-images-input').click()" style="min-height: 72px; margin-bottom: 0;">
|
| 280 |
+
<div id="batch-images-placeholder">
|
| 281 |
+
<div class="upload-icon">📁</div>
|
| 282 |
+
<div class="upload-text" data-i18n="uploadMulti1">点击或拖入多张图片</div>
|
| 283 |
+
<div class="upload-hint" data-i18n="uploadMulti2">支持一次选多张,可多次添加</div>
|
| 284 |
+
</div>
|
| 285 |
+
<input type="file" id="batch-images-input" accept="image/*" multiple style="display:none" onchange="handleBatchImagesUpload(this.files, true)">
|
| 286 |
+
</div>
|
| 287 |
+
<input type="hidden" id="batch-images-path">
|
| 288 |
+
|
| 289 |
+
<div class="batch-thumb-strip-wrap" id="batch-thumb-strip-wrap" style="display: none;">
|
| 290 |
+
<div class="batch-thumb-strip-head">
|
| 291 |
+
<span class="batch-thumb-strip-title" data-i18n="batchStripTitle">已选图片 · 顺序 = 播放先后</span>
|
| 292 |
+
<span class="batch-thumb-strip-hint" data-i18n="batchStripHint">在缩略图上按住拖动排序;松手落入虚线框位置</span>
|
| 293 |
+
</div>
|
| 294 |
+
<div class="batch-images-container" id="batch-images-container"></div>
|
| 295 |
+
</div>
|
| 296 |
+
|
| 297 |
+
<div style="font-size: 10px; color: var(--text-dim); margin-bottom: 12px; margin-top: 10px; line-height: 1.45;" data-i18n-html="batchFfmpegHint">
|
| 298 |
+
💡 <strong>分段模式</strong>:2 张 = 1 段;3 张 = 2 段再拼接。<strong>单次模式</strong>:几张图就几个 latent 锚点,一条视频出片。<br>
|
| 299 |
+
多段需 <code style="font-size:9px;">ffmpeg</code>:装好后加 PATH,或设环境变量 <code style="font-size:9px;">LTX_FFMPEG_PATH</code>,或在 <code style="font-size:9px;">%LOCALAPPDATA%\LTXDesktop\ffmpeg_path.txt</code> 第一行写 ffmpeg.exe 完整路径。
|
| 300 |
+
</div>
|
| 301 |
+
|
| 302 |
+
<label style="margin-top: 4px;" data-i18n="globalPromptLabel">本页全局补充词(可选)</label>
|
| 303 |
+
<textarea id="batch-common-prompt" data-i18n-placeholder="globalPromptPh" placeholder="与顶部主 Prompt 叠加;单次模式与分段模式均可用" style="width: 100%; height: 56px; margin-bottom: 10px; padding: 8px; font-size: 11px; box-sizing: border-box; resize: vertical; border-radius: 8px; border: 1px solid var(--border); background: var(--item); color: var(--text);"></textarea>
|
| 304 |
+
|
| 305 |
+
<label style="margin-top: 8px;" data-i18n="bgmLabel">成片配乐(可选,统一音轨)</label>
|
| 306 |
+
<div class="upload-zone" id="batch-audio-drop-zone" onclick="document.getElementById('batch-audio-input').click()" style="min-height: 44px; margin-bottom: 8px; position: relative;">
|
| 307 |
+
<div class="clear-img-overlay" id="clear-batch-audio-overlay" onclick="event.stopPropagation(); clearBatchBackgroundAudio()" style="display: none;">×</div>
|
| 308 |
+
<div id="batch-audio-placeholder">
|
| 309 |
+
<div class="upload-text" style="font-size: 11px;" data-i18n="bgmUploadHint">上传一条完整 BGM(生成完成后会替换整段成片的音轨)</div>
|
| 310 |
+
</div>
|
| 311 |
+
<div id="batch-audio-status" style="display: none; font-size: 11px; color: var(--accent);"></div>
|
| 312 |
+
<input type="file" id="batch-audio-input" accept="audio/*" style="display:none" onchange="handleBatchBackgroundAudioUpload(this.files[0])">
|
| 313 |
+
</div>
|
| 314 |
+
<input type="hidden" id="batch-background-audio-path">
|
| 315 |
+
|
| 316 |
+
<div id="batch-segments-container" style="margin-top: 15px;"></div>
|
| 317 |
+
</div>
|
| 318 |
+
|
| 319 |
+
<div class="setting-group">
|
| 320 |
+
<div class="group-title" data-i18n="basicEngine">基础画面 / Basic EngineSpecs</div>
|
| 321 |
+
<div class="flex-row">
|
| 322 |
+
<div class="flex-1">
|
| 323 |
+
<label data-i18n="qualityLevel">清晰度级别</label>
|
| 324 |
+
<select id="batch-quality" onchange="updateBatchResPreview()">
|
| 325 |
+
<option value="1080">1080P Full HD</option>
|
| 326 |
+
<option value="720" selected>720P Standard</option>
|
| 327 |
+
<option value="540">540P Preview</option>
|
| 328 |
+
</select>
|
| 329 |
+
</div>
|
| 330 |
+
<div class="flex-1">
|
| 331 |
+
<label data-i18n="aspectRatio">画幅比例</label>
|
| 332 |
+
<select id="batch-ratio" onchange="updateBatchResPreview()">
|
| 333 |
+
<option value="16:9" data-i18n="ratio169">16:9 电影宽幅</option>
|
| 334 |
+
<option value="9:16" data-i18n="ratio916">9:16 移动竖屏</option>
|
| 335 |
+
</select>
|
| 336 |
+
</div>
|
| 337 |
+
</div>
|
| 338 |
+
<div id="batch-res-preview" class="res-preview-tag" style="margin-top: -5px; margin-bottom: 12px;">最终发送: 1280x704</div>
|
| 339 |
+
|
| 340 |
+
<label data-i18n="selectModel">选择模型</label>
|
| 341 |
+
<select id="batch-model" style="margin-bottom: 8px;">
|
| 342 |
+
<option value="" data-i18n="defaultModel">使用默认模型</option>
|
| 343 |
+
</select>
|
| 344 |
+
<label data-i18n="selectLora">选择 LoRA</label>
|
| 345 |
+
<select id="batch-lora" onchange="updateBatchLoraStrength()" style="margin-bottom: 8px;">
|
| 346 |
+
<option value="" data-i18n="noLora">不使用 LoRA</option>
|
| 347 |
+
</select>
|
| 348 |
+
<div id="batch-lora-strength-container" style="display: none;">
|
| 349 |
+
<label data-i18n="loraStrength">LoRA 强度</label>
|
| 350 |
+
<input type="range" id="batch-lora-strength" min="0.1" max="2.0" step="0.1" value="1.2" style="width: 100%;" oninput="document.getElementById('batch-lora-strength-val').textContent = this.value">
|
| 351 |
+
<span id="batch-lora-strength-val" style="font-size: 12px; color: var(--accent);">1.2</span>
|
| 352 |
+
</div>
|
| 353 |
+
</div>
|
| 354 |
+
</div>
|
| 355 |
+
|
| 356 |
+
<div style="padding: 0 30px 30px 30px;">
|
| 357 |
+
<button class="btn-primary" id="mainBtn" onclick="run()" data-i18n="mainRender">开始渲染</button>
|
| 358 |
+
</div>
|
| 359 |
+
</aside>
|
| 360 |
+
|
| 361 |
+
<main class="workspace">
|
| 362 |
+
<section class="viewer" id="viewer-section">
|
| 363 |
+
<div class="monitor" id="viewer">
|
| 364 |
+
<div id="loading-txt" data-i18n="waitingTask">等待分配渲染任务...</div>
|
| 365 |
+
<img id="res-img" src="">
|
| 366 |
+
<div id="video-wrapper" style="width:100%; height:100%; display:none; max-height:100%; align-items:center; justify-content:center;">
|
| 367 |
+
<video id="res-video" autoplay loop playsinline></video>
|
| 368 |
+
</div>
|
| 369 |
+
<div class="progress-container"><div id="progress-fill"></div></div>
|
| 370 |
+
</div>
|
| 371 |
+
</section>
|
| 372 |
+
|
| 373 |
+
<!-- Drag Handle -->
|
| 374 |
+
<div id="resize-handle" style="
|
| 375 |
+
height: 5px; background: transparent; cursor: row-resize;
|
| 376 |
+
flex-shrink: 0; position: relative; z-index: 50;
|
| 377 |
+
display: flex; align-items: center; justify-content: center;
|
| 378 |
+
" data-i18n-title="resizeHandleTitle" title="拖动调整面板高度">
|
| 379 |
+
<div style="width: 40px; height: 3px; background: var(--border); border-radius: 999px; pointer-events: none;"></div>
|
| 380 |
+
</div>
|
| 381 |
+
|
| 382 |
+
<section class="library" id="library-section">
|
| 383 |
+
<div style="display: flex; justify-content: space-between; margin-bottom: 15px; align-items: center; border-bottom: 1px solid var(--border); padding-bottom: 10px;">
|
| 384 |
+
<div style="display: flex; gap: 20px;">
|
| 385 |
+
<span id="tab-history" style="font-size: 11px; font-weight: 800; color: var(--accent); cursor: pointer; border-bottom: 2px solid var(--accent); padding-bottom: 11px; margin-bottom: -11px;" onclick="switchLibTab('history')" data-i18n="libHistory">历史资产 / ASSETS</span>
|
| 386 |
+
<span id="tab-log" style="font-size: 11px; font-weight: 800; color: var(--text-dim); cursor: pointer; border-bottom: 2px solid transparent; padding-bottom: 11px; margin-bottom: -11px;" onclick="switchLibTab('log')" data-i18n="libLog">系统日志 / LOGS</span>
|
| 387 |
+
</div>
|
| 388 |
+
<button type="button" onclick="fetchHistory(currentHistoryPage)" style="background: var(--item); border: 1px solid var(--border); border-radius: 6px; color: var(--text-dim); font-size: 11px; padding: 4px 10px; cursor: pointer;" data-i18n="refresh">刷新</button>
|
| 389 |
+
</div>
|
| 390 |
+
|
| 391 |
+
<div id="log-container" style="display: none; flex: 1; flex-direction: column;">
|
| 392 |
+
<div id="log" data-i18n="logReady">> LTX-2 Studio Ready. Expecting commands...</div>
|
| 393 |
+
</div>
|
| 394 |
+
|
| 395 |
+
<div id="history-wrapper">
|
| 396 |
+
<div id="history-container"></div>
|
| 397 |
+
</div>
|
| 398 |
+
<div id="pagination-bar" style="display:none;"></div>
|
| 399 |
+
</section>
|
| 400 |
+
</main>
|
| 401 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/plyr/3.7.8/plyr.min.js"></script>
|
| 402 |
+
<script src="i18n.js"></script>
|
| 403 |
+
<script src="index.js"></script>
|
| 404 |
+
|
| 405 |
+
</body>
|
| 406 |
+
</html>
|
LTX2.3/UI/index.js
ADDED
|
@@ -0,0 +1,2042 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ─── Resizable panel drag logic ───────────────────────────────────────────────
|
| 2 |
+
(function() {
|
| 3 |
+
const handle = document.getElementById('resize-handle');
|
| 4 |
+
const viewer = document.getElementById('viewer-section');
|
| 5 |
+
const library = document.getElementById('library-section');
|
| 6 |
+
const workspace = document.querySelector('.workspace');
|
| 7 |
+
let dragging = false, startY = 0, startVH = 0;
|
| 8 |
+
|
| 9 |
+
handle.addEventListener('mousedown', (e) => {
|
| 10 |
+
dragging = true;
|
| 11 |
+
startY = e.clientY;
|
| 12 |
+
startVH = viewer.getBoundingClientRect().height;
|
| 13 |
+
document.body.style.cursor = 'row-resize';
|
| 14 |
+
document.body.style.userSelect = 'none';
|
| 15 |
+
handle.querySelector('div').style.background = 'var(--accent)';
|
| 16 |
+
e.preventDefault();
|
| 17 |
+
});
|
| 18 |
+
document.addEventListener('mousemove', (e) => {
|
| 19 |
+
if (!dragging) return;
|
| 20 |
+
const wsH = workspace.getBoundingClientRect().height;
|
| 21 |
+
const delta = e.clientY - startY;
|
| 22 |
+
let newVH = startVH + delta;
|
| 23 |
+
// Clamp: viewer min 150px, library min 100px
|
| 24 |
+
newVH = Math.max(150, Math.min(wsH - 100 - 5, newVH));
|
| 25 |
+
viewer.style.flex = 'none';
|
| 26 |
+
viewer.style.height = newVH + 'px';
|
| 27 |
+
library.style.flex = '1';
|
| 28 |
+
});
|
| 29 |
+
document.addEventListener('mouseup', () => {
|
| 30 |
+
if (dragging) {
|
| 31 |
+
dragging = false;
|
| 32 |
+
document.body.style.cursor = '';
|
| 33 |
+
document.body.style.userSelect = '';
|
| 34 |
+
handle.querySelector('div').style.background = 'var(--border)';
|
| 35 |
+
}
|
| 36 |
+
});
|
| 37 |
+
// Hover highlight
|
| 38 |
+
handle.addEventListener('mouseenter', () => { handle.querySelector('div').style.background = 'var(--text-dim)'; });
|
| 39 |
+
handle.addEventListener('mouseleave', () => { if (!dragging) handle.querySelector('div').style.background = 'var(--border)'; });
|
| 40 |
+
})();
|
| 41 |
+
// ──────────────────────────────────────────────────────────────────────────────
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
// 动态获取当前访问的域名或 IP,自动对齐 3000 端口
|
| 49 |
+
const BASE = `http://${window.location.hostname}:3000`;
|
| 50 |
+
|
| 51 |
+
function _t(k) {
|
| 52 |
+
return typeof window.t === 'function' ? window.t(k) : k;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
let currentMode = 'image';
|
| 56 |
+
let pollInterval = null;
|
| 57 |
+
let availableModels = [];
|
| 58 |
+
let availableLoras = [];
|
| 59 |
+
|
| 60 |
+
// 建议增加一个简单的调试日志,方便在控制台确认地址是否正确
|
| 61 |
+
console.log("Connecting to Backend API at:", BASE);
|
| 62 |
+
|
| 63 |
+
// 模型扫描功能
|
| 64 |
+
async function scanModels() {
|
| 65 |
+
try {
|
| 66 |
+
const url = `${BASE}/api/models`;
|
| 67 |
+
console.log("Scanning models from:", url);
|
| 68 |
+
const res = await fetch(url);
|
| 69 |
+
const data = await res.json().catch(() => ({}));
|
| 70 |
+
console.log("Models response:", res.status, data);
|
| 71 |
+
if (!res.ok) {
|
| 72 |
+
const msg = data.message || data.error || res.statusText;
|
| 73 |
+
addLog(`❌ 模型扫描失败 (${res.status}): ${msg}`);
|
| 74 |
+
availableModels = [];
|
| 75 |
+
updateModelDropdown();
|
| 76 |
+
updateBatchModelDropdown();
|
| 77 |
+
return;
|
| 78 |
+
}
|
| 79 |
+
availableModels = data.models || [];
|
| 80 |
+
updateModelDropdown();
|
| 81 |
+
updateBatchModelDropdown();
|
| 82 |
+
if (availableModels.length > 0) {
|
| 83 |
+
addLog(`📂 已扫描到 ${availableModels.length} 个模型: ${availableModels.map(m => m.name).join(', ')}`);
|
| 84 |
+
}
|
| 85 |
+
} catch (e) {
|
| 86 |
+
console.log("Model scan error:", e);
|
| 87 |
+
addLog(`❌ 模型扫描异常: ${e.message || e}`);
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
function updateModelDropdown() {
|
| 92 |
+
const select = document.getElementById('vid-model');
|
| 93 |
+
if (!select) return;
|
| 94 |
+
select.innerHTML = '<option value="">' + _t('defaultModel') + '</option>';
|
| 95 |
+
availableModels.forEach(model => {
|
| 96 |
+
const opt = document.createElement('option');
|
| 97 |
+
opt.value = model.path;
|
| 98 |
+
opt.textContent = model.name;
|
| 99 |
+
select.appendChild(opt);
|
| 100 |
+
});
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// LoRA 扫描功能
|
| 104 |
+
async function scanLoras() {
|
| 105 |
+
try {
|
| 106 |
+
const url = `${BASE}/api/loras`;
|
| 107 |
+
console.log("Scanning LoRA from:", url);
|
| 108 |
+
const res = await fetch(url);
|
| 109 |
+
const data = await res.json().catch(() => ({}));
|
| 110 |
+
console.log("LoRA response:", res.status, data);
|
| 111 |
+
if (!res.ok) {
|
| 112 |
+
const msg = data.message || data.error || res.statusText;
|
| 113 |
+
addLog(`❌ LoRA 扫描失败 (${res.status}): ${msg}`);
|
| 114 |
+
availableLoras = [];
|
| 115 |
+
updateLoraDropdown();
|
| 116 |
+
updateBatchLoraDropdown();
|
| 117 |
+
return;
|
| 118 |
+
}
|
| 119 |
+
availableLoras = data.loras || [];
|
| 120 |
+
updateLoraDropdown();
|
| 121 |
+
updateBatchLoraDropdown();
|
| 122 |
+
if (data.loras_dir) {
|
| 123 |
+
const hintEl = document.getElementById('lora-placement-hint');
|
| 124 |
+
if (hintEl) {
|
| 125 |
+
const tpl = _t('loraPlacementHintWithDir');
|
| 126 |
+
hintEl.innerHTML = tpl.replace(
|
| 127 |
+
'{dir}',
|
| 128 |
+
escapeHtmlAttr(data.models_dir || data.loras_dir)
|
| 129 |
+
);
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
if (availableLoras.length > 0) {
|
| 133 |
+
addLog(`📂 已扫描到 ${availableLoras.length} 个 LoRA: ${availableLoras.map(l => l.name).join(', ')}`);
|
| 134 |
+
}
|
| 135 |
+
} catch (e) {
|
| 136 |
+
console.log("LoRA scan error:", e);
|
| 137 |
+
addLog(`❌ LoRA 扫描异常: ${e.message || e}`);
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
function updateLoraDropdown() {
|
| 142 |
+
const select = document.getElementById('vid-lora');
|
| 143 |
+
if (!select) return;
|
| 144 |
+
select.innerHTML = '<option value="">' + _t('noLora') + '</option>';
|
| 145 |
+
availableLoras.forEach(lora => {
|
| 146 |
+
const opt = document.createElement('option');
|
| 147 |
+
opt.value = lora.path;
|
| 148 |
+
opt.textContent = lora.name;
|
| 149 |
+
select.appendChild(opt);
|
| 150 |
+
});
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
function updateLoraStrength() {
|
| 154 |
+
const select = document.getElementById('vid-lora');
|
| 155 |
+
const container = document.getElementById('lora-strength-container');
|
| 156 |
+
if (select && container) {
|
| 157 |
+
container.style.display = select.value ? 'flex' : 'none';
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
// 更新批量模式的模型和LoRA下拉框
|
| 162 |
+
function updateBatchModelDropdown() {
|
| 163 |
+
const select = document.getElementById('batch-model');
|
| 164 |
+
if (!select) return;
|
| 165 |
+
select.innerHTML = '<option value="">' + _t('defaultModel') + '</option>';
|
| 166 |
+
availableModels.forEach(model => {
|
| 167 |
+
const opt = document.createElement('option');
|
| 168 |
+
opt.value = model.path;
|
| 169 |
+
opt.textContent = model.name;
|
| 170 |
+
select.appendChild(opt);
|
| 171 |
+
});
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
function updateBatchLoraDropdown() {
|
| 175 |
+
const select = document.getElementById('batch-lora');
|
| 176 |
+
if (!select) return;
|
| 177 |
+
select.innerHTML = '<option value="">' + _t('noLora') + '</option>';
|
| 178 |
+
availableLoras.forEach(lora => {
|
| 179 |
+
const opt = document.createElement('option');
|
| 180 |
+
opt.value = lora.path;
|
| 181 |
+
opt.textContent = lora.name;
|
| 182 |
+
select.appendChild(opt);
|
| 183 |
+
});
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
// 页面加载时更新批量模式的下拉框
|
| 187 |
+
function initBatchDropdowns() {
|
| 188 |
+
updateBatchModelDropdown();
|
| 189 |
+
updateBatchLoraDropdown();
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
// 已移除:模型/LoRA 目录自定义与浏览(保持后端默认路径扫描)
|
| 193 |
+
|
| 194 |
+
// 页面加载时扫描模型和LoRA(使用后端默认目录规则)
|
| 195 |
+
(function() {
|
| 196 |
+
['vid-quality', 'batch-quality'].forEach((id) => {
|
| 197 |
+
const sel = document.getElementById(id);
|
| 198 |
+
if (sel && sel.value === '544') sel.value = '540';
|
| 199 |
+
});
|
| 200 |
+
|
| 201 |
+
setTimeout(() => {
|
| 202 |
+
scanModels();
|
| 203 |
+
scanLoras();
|
| 204 |
+
initBatchDropdowns();
|
| 205 |
+
}, 1500);
|
| 206 |
+
})();
|
| 207 |
+
|
| 208 |
+
// 分辨率自动计算逻辑
|
| 209 |
+
function updateResPreview() {
|
| 210 |
+
const q = document.getElementById('vid-quality').value; // "1080", "720", "540"
|
| 211 |
+
const r = document.getElementById('vid-ratio').value;
|
| 212 |
+
|
| 213 |
+
// 核心修复:后端解析器期待 "1080p", "720p", "540p" 这种标签格式
|
| 214 |
+
let resLabel = q === "1080" ? "1080p" : q === "720" ? "720p" : "540p";
|
| 215 |
+
|
| 216 |
+
/* 与后端一致:宽高均为 64 的倍数(LTX 内核要求) */
|
| 217 |
+
let resDisplay;
|
| 218 |
+
if (r === "16:9") {
|
| 219 |
+
resDisplay = q === "1080" ? "1920x1088" : q === "720" ? "1280x704" : "1024x576";
|
| 220 |
+
} else {
|
| 221 |
+
resDisplay = q === "1080" ? "1088x1920" : q === "720" ? "704x1280" : "576x1024";
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
document.getElementById('res-preview').innerText = `${_t('resPreviewPrefix')}: ${resLabel} (${resDisplay})`;
|
| 225 |
+
return resLabel;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
// 图片分辨率预览
|
| 229 |
+
function updateImgResPreview() {
|
| 230 |
+
const w = document.getElementById('img-w').value;
|
| 231 |
+
const h = document.getElementById('img-h').value;
|
| 232 |
+
document.getElementById('img-res-preview').innerText = `${_t('resPreviewPrefix')}: ${w}x${h}`;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
// 批量模式分辨率预览
|
| 236 |
+
function updateBatchResPreview() {
|
| 237 |
+
const q = document.getElementById('batch-quality').value;
|
| 238 |
+
const r = document.getElementById('batch-ratio').value;
|
| 239 |
+
let resLabel = q === "1080" ? "1080p" : q === "720" ? "720p" : "540p";
|
| 240 |
+
let resDisplay;
|
| 241 |
+
if (r === "16:9") {
|
| 242 |
+
resDisplay = q === "1080" ? "1920x1088" : q === "720" ? "1280x704" : "1024x576";
|
| 243 |
+
} else {
|
| 244 |
+
resDisplay = q === "1080" ? "1088x1920" : q === "720" ? "704x1280" : "576x1024";
|
| 245 |
+
}
|
| 246 |
+
document.getElementById('batch-res-preview').innerText = `${_t('resPreviewPrefix')}: ${resLabel} (${resDisplay})`;
|
| 247 |
+
return resLabel;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
// 批量模式 LoRA 强度切换
|
| 251 |
+
function updateBatchLoraStrength() {
|
| 252 |
+
const select = document.getElementById('batch-lora');
|
| 253 |
+
const container = document.getElementById('batch-lora-strength-container');
|
| 254 |
+
if (select && container) {
|
| 255 |
+
container.style.display = select.value ? 'flex' : 'none';
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// 切换图片预设分辨率
|
| 260 |
+
function applyImgPreset(val) {
|
| 261 |
+
if (val === "custom") {
|
| 262 |
+
document.getElementById('img-custom-res').style.display = 'flex';
|
| 263 |
+
} else {
|
| 264 |
+
const [w, h] = val.split('x');
|
| 265 |
+
document.getElementById('img-w').value = w;
|
| 266 |
+
document.getElementById('img-h').value = h;
|
| 267 |
+
updateImgResPreview();
|
| 268 |
+
// 隐藏自定义区域或保持显示供微调
|
| 269 |
+
// document.getElementById('img-custom-res').style.display = 'none';
|
| 270 |
+
}
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
// 处理帧图片上传
|
| 276 |
+
async function handleFrameUpload(file, frameType) {
|
| 277 |
+
if (!file) return;
|
| 278 |
+
|
| 279 |
+
const preview = document.getElementById(`${frameType}-frame-preview`);
|
| 280 |
+
const placeholder = document.getElementById(`${frameType}-frame-placeholder`);
|
| 281 |
+
const clearOverlay = document.getElementById(`clear-${frameType}-frame-overlay`);
|
| 282 |
+
|
| 283 |
+
const previewReader = new FileReader();
|
| 284 |
+
previewReader.onload = (e) => {
|
| 285 |
+
preview.src = e.target.result;
|
| 286 |
+
preview.style.display = 'block';
|
| 287 |
+
placeholder.style.display = 'none';
|
| 288 |
+
clearOverlay.style.display = 'flex';
|
| 289 |
+
};
|
| 290 |
+
previewReader.readAsDataURL(file);
|
| 291 |
+
|
| 292 |
+
const reader = new FileReader();
|
| 293 |
+
reader.onload = async (e) => {
|
| 294 |
+
const b64Data = e.target.result;
|
| 295 |
+
addLog(`正在上传 ${frameType === 'start' ? '起始帧' : '结束帧'}: ${file.name}...`);
|
| 296 |
+
try {
|
| 297 |
+
const res = await fetch(`${BASE}/api/system/upload-image`, {
|
| 298 |
+
method: 'POST',
|
| 299 |
+
headers: { 'Content-Type': 'application/json' },
|
| 300 |
+
body: JSON.stringify({ image: b64Data, filename: file.name })
|
| 301 |
+
});
|
| 302 |
+
const data = await res.json();
|
| 303 |
+
if (res.ok && data.path) {
|
| 304 |
+
document.getElementById(`${frameType}-frame-path`).value = data.path;
|
| 305 |
+
addLog(`✅ ${frameType === 'start' ? '起始帧' : '结束帧'}上传成功`);
|
| 306 |
+
} else {
|
| 307 |
+
throw new Error(data.error || data.detail || "上传失败");
|
| 308 |
+
}
|
| 309 |
+
} catch (e) {
|
| 310 |
+
addLog(`❌ 帧图片上传失败: ${e.message}`);
|
| 311 |
+
}
|
| 312 |
+
};
|
| 313 |
+
reader.readAsDataURL(file);
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
function clearFrame(frameType) {
|
| 317 |
+
document.getElementById(`${frameType}-frame-input`).value = "";
|
| 318 |
+
document.getElementById(`${frameType}-frame-path`).value = "";
|
| 319 |
+
document.getElementById(`${frameType}-frame-preview`).style.display = 'none';
|
| 320 |
+
document.getElementById(`${frameType}-frame-preview`).src = "";
|
| 321 |
+
document.getElementById(`${frameType}-frame-placeholder`).style.display = 'block';
|
| 322 |
+
document.getElementById(`clear-${frameType}-frame-overlay`).style.display = 'none';
|
| 323 |
+
addLog(`🧹 已清除${frameType === 'start' ? '起始帧' : '结束帧'}`);
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
// 处理图片上传
|
| 327 |
+
async function handleImageUpload(file) {
|
| 328 |
+
if (!file) return;
|
| 329 |
+
|
| 330 |
+
// 预览图片
|
| 331 |
+
const preview = document.getElementById('upload-preview');
|
| 332 |
+
const placeholder = document.getElementById('upload-placeholder');
|
| 333 |
+
const clearOverlay = document.getElementById('clear-img-overlay');
|
| 334 |
+
|
| 335 |
+
const previewReader = new FileReader();
|
| 336 |
+
preview.onload = () => {
|
| 337 |
+
preview.style.display = 'block';
|
| 338 |
+
placeholder.style.display = 'none';
|
| 339 |
+
clearOverlay.style.display = 'flex';
|
| 340 |
+
};
|
| 341 |
+
previewReader.onload = (e) => preview.src = e.target.result;
|
| 342 |
+
previewReader.readAsDataURL(file);
|
| 343 |
+
|
| 344 |
+
// 使用 FileReader 转换为 Base64,绕过后端缺失 python-multipart 的问题
|
| 345 |
+
const reader = new FileReader();
|
| 346 |
+
reader.onload = async (e) => {
|
| 347 |
+
const b64Data = e.target.result;
|
| 348 |
+
addLog(`正在上传参考图: ${file.name}...`);
|
| 349 |
+
try {
|
| 350 |
+
const res = await fetch(`${BASE}/api/system/upload-image`, {
|
| 351 |
+
method: 'POST',
|
| 352 |
+
headers: { 'Content-Type': 'application/json' },
|
| 353 |
+
body: JSON.stringify({
|
| 354 |
+
image: b64Data,
|
| 355 |
+
filename: file.name
|
| 356 |
+
})
|
| 357 |
+
});
|
| 358 |
+
const data = await res.json();
|
| 359 |
+
if (res.ok && data.path) {
|
| 360 |
+
document.getElementById('uploaded-img-path').value = data.path;
|
| 361 |
+
addLog(`✅ 参考图上传成功: ${file.name}`);
|
| 362 |
+
} else {
|
| 363 |
+
const errMsg = data.error || data.detail || "上传失败";
|
| 364 |
+
throw new Error(typeof errMsg === 'string' ? errMsg : JSON.stringify(errMsg));
|
| 365 |
+
}
|
| 366 |
+
} catch (e) {
|
| 367 |
+
addLog(`❌ 图片上传失败: ${e.message}`);
|
| 368 |
+
}
|
| 369 |
+
};
|
| 370 |
+
reader.onerror = () => addLog("❌ 读取本地文件失败");
|
| 371 |
+
reader.readAsDataURL(file);
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
function clearUploadedImage() {
|
| 375 |
+
document.getElementById('vid-image-input').value = "";
|
| 376 |
+
document.getElementById('uploaded-img-path').value = "";
|
| 377 |
+
document.getElementById('upload-preview').style.display = 'none';
|
| 378 |
+
document.getElementById('upload-preview').src = "";
|
| 379 |
+
document.getElementById('upload-placeholder').style.display = 'block';
|
| 380 |
+
document.getElementById('clear-img-overlay').style.display = 'none';
|
| 381 |
+
addLog("🧹 已清除参考图");
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
// 处理音频上传
|
| 385 |
+
async function handleAudioUpload(file) {
|
| 386 |
+
if (!file) return;
|
| 387 |
+
|
| 388 |
+
const placeholder = document.getElementById('audio-upload-placeholder');
|
| 389 |
+
const statusDiv = document.getElementById('audio-upload-status');
|
| 390 |
+
const filenameStatus = document.getElementById('audio-filename-status');
|
| 391 |
+
const clearOverlay = document.getElementById('clear-audio-overlay');
|
| 392 |
+
|
| 393 |
+
placeholder.style.display = 'none';
|
| 394 |
+
filenameStatus.innerText = file.name;
|
| 395 |
+
statusDiv.style.display = 'block';
|
| 396 |
+
clearOverlay.style.display = 'flex';
|
| 397 |
+
|
| 398 |
+
const reader = new FileReader();
|
| 399 |
+
reader.onload = async (e) => {
|
| 400 |
+
const b64Data = e.target.result;
|
| 401 |
+
addLog(`正在上传音频: ${file.name}...`);
|
| 402 |
+
try {
|
| 403 |
+
// 复用图片上传接口,后端已支持任意文件类型
|
| 404 |
+
const res = await fetch(`${BASE}/api/system/upload-image`, {
|
| 405 |
+
method: 'POST',
|
| 406 |
+
headers: { 'Content-Type': 'application/json' },
|
| 407 |
+
body: JSON.stringify({
|
| 408 |
+
image: b64Data,
|
| 409 |
+
filename: file.name
|
| 410 |
+
})
|
| 411 |
+
});
|
| 412 |
+
const data = await res.json();
|
| 413 |
+
if (res.ok && data.path) {
|
| 414 |
+
document.getElementById('uploaded-audio-path').value = data.path;
|
| 415 |
+
addLog(`✅ 音频上传成功: ${file.name}`);
|
| 416 |
+
} else {
|
| 417 |
+
const errMsg = data.error || data.detail || "上传失败";
|
| 418 |
+
throw new Error(typeof errMsg === 'string' ? errMsg : JSON.stringify(errMsg));
|
| 419 |
+
}
|
| 420 |
+
} catch (e) {
|
| 421 |
+
addLog(`❌ 音频上传失败: ${e.message}`);
|
| 422 |
+
}
|
| 423 |
+
};
|
| 424 |
+
reader.onerror = () => addLog("❌ 读取本地音频文件失败");
|
| 425 |
+
reader.readAsDataURL(file);
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
function clearUploadedAudio() {
|
| 429 |
+
document.getElementById('vid-audio-input').value = "";
|
| 430 |
+
document.getElementById('uploaded-audio-path').value = "";
|
| 431 |
+
document.getElementById('audio-upload-placeholder').style.display = 'block';
|
| 432 |
+
document.getElementById('audio-upload-status').style.display = 'none';
|
| 433 |
+
document.getElementById('clear-audio-overlay').style.display = 'none';
|
| 434 |
+
addLog("🧹 已清除音频文件");
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
// 处理超分视频上传
|
| 438 |
+
async function handleUpscaleVideoUpload(file) {
|
| 439 |
+
if (!file) return;
|
| 440 |
+
const placeholder = document.getElementById('upscale-placeholder');
|
| 441 |
+
const statusDiv = document.getElementById('upscale-status');
|
| 442 |
+
const filenameStatus = document.getElementById('upscale-filename');
|
| 443 |
+
const clearOverlay = document.getElementById('clear-upscale-overlay');
|
| 444 |
+
|
| 445 |
+
filenameStatus.innerText = file.name;
|
| 446 |
+
placeholder.style.display = 'none';
|
| 447 |
+
statusDiv.style.display = 'block';
|
| 448 |
+
clearOverlay.style.display = 'flex';
|
| 449 |
+
|
| 450 |
+
const reader = new FileReader();
|
| 451 |
+
reader.onload = async (e) => {
|
| 452 |
+
const b64Data = e.target.result;
|
| 453 |
+
addLog(`正在上传待超分视频: ${file.name}...`);
|
| 454 |
+
try {
|
| 455 |
+
const res = await fetch(`${BASE}/api/system/upload-image`, {
|
| 456 |
+
method: 'POST',
|
| 457 |
+
headers: { 'Content-Type': 'application/json' },
|
| 458 |
+
body: JSON.stringify({ image: b64Data, filename: file.name })
|
| 459 |
+
});
|
| 460 |
+
const data = await res.json();
|
| 461 |
+
if (res.ok && data.path) {
|
| 462 |
+
document.getElementById('upscale-video-path').value = data.path;
|
| 463 |
+
addLog(`✅ 视频上传成功`);
|
| 464 |
+
} else {
|
| 465 |
+
throw new Error(data.error || "上传失败");
|
| 466 |
+
}
|
| 467 |
+
} catch (e) {
|
| 468 |
+
addLog(`❌ 视频上传失败: ${e.message}`);
|
| 469 |
+
}
|
| 470 |
+
};
|
| 471 |
+
reader.readAsDataURL(file);
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
function clearUpscaleVideo() {
|
| 475 |
+
document.getElementById('upscale-video-input').value = "";
|
| 476 |
+
document.getElementById('upscale-video-path').value = "";
|
| 477 |
+
document.getElementById('upscale-placeholder').style.display = 'block';
|
| 478 |
+
document.getElementById('upscale-status').style.display = 'none';
|
| 479 |
+
document.getElementById('clear-upscale-overlay').style.display = 'none';
|
| 480 |
+
addLog("🧹 已清除待超分视频");
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
// 初始化拖拽上传逻辑
|
| 484 |
+
function initDragAndDrop() {
|
| 485 |
+
const audioDropZone = document.getElementById('audio-drop-zone');
|
| 486 |
+
const startFrameDropZone = document.getElementById('start-frame-drop-zone');
|
| 487 |
+
const endFrameDropZone = document.getElementById('end-frame-drop-zone');
|
| 488 |
+
const upscaleDropZone = document.getElementById('upscale-drop-zone');
|
| 489 |
+
const batchImagesDropZone = document.getElementById('batch-images-drop-zone');
|
| 490 |
+
|
| 491 |
+
const zones = [audioDropZone, startFrameDropZone, endFrameDropZone, upscaleDropZone, batchImagesDropZone];
|
| 492 |
+
|
| 493 |
+
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
| 494 |
+
zones.forEach(zone => {
|
| 495 |
+
if (!zone) return;
|
| 496 |
+
zone.addEventListener(eventName, (e) => {
|
| 497 |
+
e.preventDefault();
|
| 498 |
+
e.stopPropagation();
|
| 499 |
+
}, false);
|
| 500 |
+
});
|
| 501 |
+
});
|
| 502 |
+
|
| 503 |
+
['dragenter', 'dragover'].forEach(eventName => {
|
| 504 |
+
zones.forEach(zone => {
|
| 505 |
+
if (!zone) return;
|
| 506 |
+
zone.addEventListener(eventName, () => zone.classList.add('dragover'), false);
|
| 507 |
+
});
|
| 508 |
+
});
|
| 509 |
+
|
| 510 |
+
['dragleave', 'drop'].forEach(eventName => {
|
| 511 |
+
zones.forEach(zone => {
|
| 512 |
+
if (!zone) return;
|
| 513 |
+
zone.addEventListener(eventName, () => zone.classList.remove('dragover'), false);
|
| 514 |
+
});
|
| 515 |
+
});
|
| 516 |
+
|
| 517 |
+
audioDropZone.addEventListener('drop', (e) => {
|
| 518 |
+
const file = e.dataTransfer.files[0];
|
| 519 |
+
if (file && file.type.startsWith('audio/')) handleAudioUpload(file);
|
| 520 |
+
}, false);
|
| 521 |
+
|
| 522 |
+
startFrameDropZone.addEventListener('drop', (e) => {
|
| 523 |
+
const file = e.dataTransfer.files[0];
|
| 524 |
+
if (file && file.type.startsWith('image/')) handleFrameUpload(file, 'start');
|
| 525 |
+
}, false);
|
| 526 |
+
|
| 527 |
+
endFrameDropZone.addEventListener('drop', (e) => {
|
| 528 |
+
const file = e.dataTransfer.files[0];
|
| 529 |
+
if (file && file.type.startsWith('image/')) handleFrameUpload(file, 'end');
|
| 530 |
+
}, false);
|
| 531 |
+
|
| 532 |
+
upscaleDropZone.addEventListener('drop', (e) => {
|
| 533 |
+
const file = e.dataTransfer.files[0];
|
| 534 |
+
if (file && file.type.startsWith('video/')) handleUpscaleVideoUpload(file);
|
| 535 |
+
}, false);
|
| 536 |
+
|
| 537 |
+
// 批量图片拖拽上传
|
| 538 |
+
if (batchImagesDropZone) {
|
| 539 |
+
batchImagesDropZone.addEventListener('drop', (e) => {
|
| 540 |
+
e.preventDefault();
|
| 541 |
+
e.stopPropagation();
|
| 542 |
+
batchImagesDropZone.classList.remove('dragover');
|
| 543 |
+
const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/'));
|
| 544 |
+
if (files.length > 0) handleBatchImagesUpload(files);
|
| 545 |
+
}, false);
|
| 546 |
+
}
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
// 批量图片上传处理
|
| 550 |
+
let batchImages = [];
|
| 551 |
+
/** 单次多关键帧:按 path 记引导强度;按段索引 0..n-2 记「上一张→本张」间隔秒数 */
|
| 552 |
+
const batchKfStrengthByPath = {};
|
| 553 |
+
const batchKfSegDurByIndex = {};
|
| 554 |
+
|
| 555 |
+
function escapeHtmlAttr(s) {
|
| 556 |
+
return String(s)
|
| 557 |
+
.replace(/&/g, '&')
|
| 558 |
+
.replace(/"/g, '"')
|
| 559 |
+
.replace(/</g, '<');
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
function defaultKeyframeStrengthForIndex(i, n) {
|
| 563 |
+
if (n <= 2) return '1';
|
| 564 |
+
if (i === 0) return '0.62';
|
| 565 |
+
if (i === n - 1) return '1';
|
| 566 |
+
return '0.42';
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
function captureBatchKfTimelineFromDom() {
|
| 570 |
+
batchImages.forEach((img, i) => {
|
| 571 |
+
if (!img.path) return;
|
| 572 |
+
const sEl = document.getElementById(`batch-kf-strength-${i}`);
|
| 573 |
+
if (sEl) batchKfStrengthByPath[img.path] = sEl.value.trim();
|
| 574 |
+
});
|
| 575 |
+
const n = batchImages.length;
|
| 576 |
+
for (let j = 0; j < n - 1; j++) {
|
| 577 |
+
const el = document.getElementById(`batch-kf-seg-dur-${j}`);
|
| 578 |
+
if (el) batchKfSegDurByIndex[j] = el.value.trim();
|
| 579 |
+
}
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
/** 读取间隔(秒),非法则回退为 minSeg */
|
| 583 |
+
function readBatchKfSegmentSeconds(n, minSeg) {
|
| 584 |
+
const seg = [];
|
| 585 |
+
for (let j = 0; j < n - 1; j++) {
|
| 586 |
+
let v = parseFloat(document.getElementById(`batch-kf-seg-dur-${j}`)?.value);
|
| 587 |
+
if (!Number.isFinite(v) || v < minSeg) v = minSeg;
|
| 588 |
+
seg.push(v);
|
| 589 |
+
}
|
| 590 |
+
return seg;
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
function updateBatchKfTimelineDerivedUI() {
|
| 594 |
+
if (!batchWorkflowIsSingle() || batchImages.length < 2) return;
|
| 595 |
+
const n = batchImages.length;
|
| 596 |
+
const minSeg = 0.1;
|
| 597 |
+
const seg = readBatchKfSegmentSeconds(n, minSeg);
|
| 598 |
+
let t = 0;
|
| 599 |
+
for (let i = 0; i < n; i++) {
|
| 600 |
+
const label = document.getElementById(`batch-kf-anchor-label-${i}`);
|
| 601 |
+
if (!label) continue;
|
| 602 |
+
if (i === 0) {
|
| 603 |
+
label.textContent = `0.0 s · ${_t('batchAnchorStart')}`;
|
| 604 |
+
} else {
|
| 605 |
+
t += seg[i - 1];
|
| 606 |
+
label.textContent =
|
| 607 |
+
i === n - 1
|
| 608 |
+
? `${t.toFixed(1)} s · ${_t('batchAnchorEnd')}`
|
| 609 |
+
: `${t.toFixed(1)} s`;
|
| 610 |
+
}
|
| 611 |
+
}
|
| 612 |
+
const totalEl = document.getElementById('batch-kf-total-seconds');
|
| 613 |
+
if (totalEl) {
|
| 614 |
+
const sum = seg.reduce((a, b) => a + b, 0);
|
| 615 |
+
totalEl.textContent = sum.toFixed(1);
|
| 616 |
+
}
|
| 617 |
+
}
|
| 618 |
+
async function handleBatchImagesUpload(files, append = true) {
|
| 619 |
+
if (!files || files.length === 0) return;
|
| 620 |
+
addLog(`正在上传 ${files.length} 张图片...`);
|
| 621 |
+
|
| 622 |
+
for (let i = 0; i < files.length; i++) {
|
| 623 |
+
const file = files[i];
|
| 624 |
+
const reader = new FileReader();
|
| 625 |
+
|
| 626 |
+
const imgData = await new Promise((resolve) => {
|
| 627 |
+
reader.onload = async (e) => {
|
| 628 |
+
const b64Data = e.target.result;
|
| 629 |
+
try {
|
| 630 |
+
const res = await fetch(`${BASE}/api/system/upload-image`, {
|
| 631 |
+
method: 'POST',
|
| 632 |
+
headers: { 'Content-Type': 'application/json' },
|
| 633 |
+
body: JSON.stringify({ image: b64Data, filename: file.name })
|
| 634 |
+
});
|
| 635 |
+
const data = await res.json();
|
| 636 |
+
if (res.ok && data.path) {
|
| 637 |
+
resolve({ name: file.name, path: data.path, preview: e.target.result });
|
| 638 |
+
} else {
|
| 639 |
+
resolve(null);
|
| 640 |
+
}
|
| 641 |
+
} catch (e) {
|
| 642 |
+
resolve(null);
|
| 643 |
+
}
|
| 644 |
+
};
|
| 645 |
+
reader.readAsDataURL(file);
|
| 646 |
+
});
|
| 647 |
+
|
| 648 |
+
if (imgData) {
|
| 649 |
+
batchImages.push(imgData);
|
| 650 |
+
addLog(`✅ 图片 ${i + 1}/${files.length} 上传成功: ${file.name}`);
|
| 651 |
+
}
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
renderBatchImages();
|
| 655 |
+
updateBatchSegments();
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
async function handleBatchBackgroundAudioUpload(file) {
|
| 659 |
+
if (!file) return;
|
| 660 |
+
const ph = document.getElementById('batch-audio-placeholder');
|
| 661 |
+
const st = document.getElementById('batch-audio-status');
|
| 662 |
+
const overlay = document.getElementById('clear-batch-audio-overlay');
|
| 663 |
+
const reader = new FileReader();
|
| 664 |
+
reader.onload = async (e) => {
|
| 665 |
+
const b64Data = e.target.result;
|
| 666 |
+
addLog(`正在上传成片配乐: ${file.name}...`);
|
| 667 |
+
try {
|
| 668 |
+
const res = await fetch(`${BASE}/api/system/upload-image`, {
|
| 669 |
+
method: 'POST',
|
| 670 |
+
headers: { 'Content-Type': 'application/json' },
|
| 671 |
+
body: JSON.stringify({ image: b64Data, filename: file.name })
|
| 672 |
+
});
|
| 673 |
+
const data = await res.json();
|
| 674 |
+
if (res.ok && data.path) {
|
| 675 |
+
const hid = document.getElementById('batch-background-audio-path');
|
| 676 |
+
if (hid) hid.value = data.path;
|
| 677 |
+
if (ph) ph.style.display = 'none';
|
| 678 |
+
if (st) {
|
| 679 |
+
st.style.display = 'block';
|
| 680 |
+
st.textContent = '✓ ' + file.name;
|
| 681 |
+
}
|
| 682 |
+
if (overlay) overlay.style.display = 'flex';
|
| 683 |
+
addLog('✅ 成片配乐已上传(将覆盖各片段自带音轨)');
|
| 684 |
+
} else {
|
| 685 |
+
addLog(`❌ 配乐上传失败: ${data.error || '未知错误'}`);
|
| 686 |
+
}
|
| 687 |
+
} catch (err) {
|
| 688 |
+
addLog(`❌ 配乐上传失败: ${err.message}`);
|
| 689 |
+
}
|
| 690 |
+
};
|
| 691 |
+
reader.onerror = () => addLog('❌ 读取音频文件失败');
|
| 692 |
+
reader.readAsDataURL(file);
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
function clearBatchBackgroundAudio() {
|
| 696 |
+
const hid = document.getElementById('batch-background-audio-path');
|
| 697 |
+
const inp = document.getElementById('batch-audio-input');
|
| 698 |
+
if (hid) hid.value = '';
|
| 699 |
+
if (inp) inp.value = '';
|
| 700 |
+
const ph = document.getElementById('batch-audio-placeholder');
|
| 701 |
+
const st = document.getElementById('batch-audio-status');
|
| 702 |
+
const overlay = document.getElementById('clear-batch-audio-overlay');
|
| 703 |
+
if (ph) ph.style.display = 'block';
|
| 704 |
+
if (st) {
|
| 705 |
+
st.style.display = 'none';
|
| 706 |
+
st.textContent = '';
|
| 707 |
+
}
|
| 708 |
+
if (overlay) overlay.style.display = 'none';
|
| 709 |
+
addLog('🧹 已清除成片配乐');
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
function syncBatchDropZoneChrome() {
|
| 713 |
+
const dropZone = document.getElementById('batch-images-drop-zone');
|
| 714 |
+
const placeholder = document.getElementById('batch-images-placeholder');
|
| 715 |
+
const stripWrap = document.getElementById('batch-thumb-strip-wrap');
|
| 716 |
+
if (batchImages.length === 0) {
|
| 717 |
+
if (dropZone) {
|
| 718 |
+
dropZone.classList.remove('has-images');
|
| 719 |
+
const mini = dropZone.querySelector('.upload-placeholder-mini');
|
| 720 |
+
if (mini) mini.remove();
|
| 721 |
+
}
|
| 722 |
+
if (placeholder) placeholder.style.display = 'block';
|
| 723 |
+
if (stripWrap) stripWrap.style.display = 'none';
|
| 724 |
+
return;
|
| 725 |
+
}
|
| 726 |
+
if (placeholder) placeholder.style.display = 'none';
|
| 727 |
+
if (dropZone) dropZone.classList.add('has-images');
|
| 728 |
+
if (stripWrap) stripWrap.style.display = 'block';
|
| 729 |
+
if (dropZone && !dropZone.querySelector('.upload-placeholder-mini')) {
|
| 730 |
+
const mini = document.createElement('div');
|
| 731 |
+
mini.className = 'upload-placeholder-mini';
|
| 732 |
+
mini.innerHTML = '<span>' + _t('batchAddMore') + '</span>';
|
| 733 |
+
dropZone.appendChild(mini);
|
| 734 |
+
}
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
let batchDragPlaceholderEl = null;
|
| 738 |
+
let batchPointerState = null;
|
| 739 |
+
let batchPendingPhX = null;
|
| 740 |
+
let batchPhMoveRaf = null;
|
| 741 |
+
|
| 742 |
+
function batchRemoveFloatingGhost() {
|
| 743 |
+
document.querySelectorAll('.batch-thumb-floating-ghost').forEach((n) => n.remove());
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
function batchCancelPhMoveRaf() {
|
| 747 |
+
if (batchPhMoveRaf != null) {
|
| 748 |
+
cancelAnimationFrame(batchPhMoveRaf);
|
| 749 |
+
batchPhMoveRaf = null;
|
| 750 |
+
}
|
| 751 |
+
batchPendingPhX = null;
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
function batchEnsurePlaceholder() {
|
| 755 |
+
if (batchDragPlaceholderEl && batchDragPlaceholderEl.isConnected) return batchDragPlaceholderEl;
|
| 756 |
+
const el = document.createElement('div');
|
| 757 |
+
el.className = 'batch-thumb-drop-slot';
|
| 758 |
+
el.setAttribute('aria-hidden', 'true');
|
| 759 |
+
batchDragPlaceholderEl = el;
|
| 760 |
+
return el;
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
function batchRemovePlaceholder() {
|
| 764 |
+
if (batchDragPlaceholderEl && batchDragPlaceholderEl.parentNode) {
|
| 765 |
+
batchDragPlaceholderEl.parentNode.removeChild(batchDragPlaceholderEl);
|
| 766 |
+
}
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
function batchComputeInsertIndex(container, placeholder) {
|
| 770 |
+
let t = 0;
|
| 771 |
+
for (const child of container.children) {
|
| 772 |
+
if (child === placeholder) return t;
|
| 773 |
+
if (child.classList && child.classList.contains('batch-image-wrapper')) {
|
| 774 |
+
if (!child.classList.contains('batch-thumb--source')) t++;
|
| 775 |
+
}
|
| 776 |
+
}
|
| 777 |
+
return t;
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
function batchMovePlaceholderFromPoint(container, clientX) {
|
| 781 |
+
const ph = batchEnsurePlaceholder();
|
| 782 |
+
const wrappers = [...container.querySelectorAll('.batch-image-wrapper')];
|
| 783 |
+
let insertBefore = null;
|
| 784 |
+
for (const w of wrappers) {
|
| 785 |
+
if (w.classList.contains('batch-thumb--source')) continue;
|
| 786 |
+
const r = w.getBoundingClientRect();
|
| 787 |
+
if (clientX < r.left + r.width / 2) {
|
| 788 |
+
insertBefore = w;
|
| 789 |
+
break;
|
| 790 |
+
}
|
| 791 |
+
}
|
| 792 |
+
if (insertBefore === null) {
|
| 793 |
+
const vis = wrappers.filter((w) => !w.classList.contains('batch-thumb--source'));
|
| 794 |
+
const last = vis[vis.length - 1];
|
| 795 |
+
if (last) {
|
| 796 |
+
if (last.nextSibling) {
|
| 797 |
+
container.insertBefore(ph, last.nextSibling);
|
| 798 |
+
} else {
|
| 799 |
+
container.appendChild(ph);
|
| 800 |
+
}
|
| 801 |
+
} else {
|
| 802 |
+
container.appendChild(ph);
|
| 803 |
+
}
|
| 804 |
+
} else {
|
| 805 |
+
container.insertBefore(ph, insertBefore);
|
| 806 |
+
}
|
| 807 |
+
}
|
| 808 |
+
|
| 809 |
+
function batchFlushPlaceholderMove() {
|
| 810 |
+
batchPhMoveRaf = null;
|
| 811 |
+
if (!batchPointerState || batchPendingPhX == null) return;
|
| 812 |
+
batchMovePlaceholderFromPoint(batchPointerState.container, batchPendingPhX);
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
function handleBatchPointerMove(e) {
|
| 816 |
+
if (!batchPointerState) return;
|
| 817 |
+
e.preventDefault();
|
| 818 |
+
const st = batchPointerState;
|
| 819 |
+
st.ghostTX = e.clientX - st.offsetX;
|
| 820 |
+
st.ghostTY = e.clientY - st.offsetY;
|
| 821 |
+
batchPendingPhX = e.clientX;
|
| 822 |
+
if (batchPhMoveRaf == null) {
|
| 823 |
+
batchPhMoveRaf = requestAnimationFrame(batchFlushPlaceholderMove);
|
| 824 |
+
}
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
function batchGhostFrame() {
|
| 828 |
+
const st = batchPointerState;
|
| 829 |
+
if (!st || !st.ghostEl || !st.ghostEl.isConnected) {
|
| 830 |
+
return;
|
| 831 |
+
}
|
| 832 |
+
const t = 0.42;
|
| 833 |
+
st.ghostCX += (st.ghostTX - st.ghostCX) * t;
|
| 834 |
+
st.ghostCY += (st.ghostTY - st.ghostCY) * t;
|
| 835 |
+
st.ghostEl.style.transform =
|
| 836 |
+
`translate3d(${st.ghostCX}px,${st.ghostCY}px,0) scale(1.06) rotate(-1deg)`;
|
| 837 |
+
st.ghostRaf = requestAnimationFrame(batchGhostFrame);
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
function batchStartGhostLoop() {
|
| 841 |
+
const st = batchPointerState;
|
| 842 |
+
if (!st || !st.ghostEl) return;
|
| 843 |
+
if (st.ghostRaf != null) cancelAnimationFrame(st.ghostRaf);
|
| 844 |
+
st.ghostRaf = requestAnimationFrame(batchGhostFrame);
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
function batchEndPointerDrag(e) {
|
| 848 |
+
if (!batchPointerState) return;
|
| 849 |
+
if (e.pointerId !== batchPointerState.pointerId) return;
|
| 850 |
+
const st = batchPointerState;
|
| 851 |
+
|
| 852 |
+
batchCancelPhMoveRaf();
|
| 853 |
+
if (st.ghostRaf != null) {
|
| 854 |
+
cancelAnimationFrame(st.ghostRaf);
|
| 855 |
+
st.ghostRaf = null;
|
| 856 |
+
}
|
| 857 |
+
if (st.ghostEl && st.ghostEl.parentNode) {
|
| 858 |
+
st.ghostEl.remove();
|
| 859 |
+
}
|
| 860 |
+
batchPointerState = null;
|
| 861 |
+
|
| 862 |
+
document.removeEventListener('pointermove', handleBatchPointerMove);
|
| 863 |
+
document.removeEventListener('pointerup', batchEndPointerDrag);
|
| 864 |
+
document.removeEventListener('pointercancel', batchEndPointerDrag);
|
| 865 |
+
|
| 866 |
+
try {
|
| 867 |
+
if (st.wrapperEl) st.wrapperEl.releasePointerCapture(st.pointerId);
|
| 868 |
+
} catch (_) {}
|
| 869 |
+
|
| 870 |
+
const { fromIndex, container, wrapperEl } = st;
|
| 871 |
+
container.classList.remove('is-batch-settling');
|
| 872 |
+
if (!batchDragPlaceholderEl || !batchDragPlaceholderEl.parentNode) {
|
| 873 |
+
if (wrapperEl) wrapperEl.classList.remove('batch-thumb--source');
|
| 874 |
+
renderBatchImages();
|
| 875 |
+
updateBatchSegments();
|
| 876 |
+
return;
|
| 877 |
+
}
|
| 878 |
+
const to = batchComputeInsertIndex(container, batchDragPlaceholderEl);
|
| 879 |
+
batchRemovePlaceholder();
|
| 880 |
+
if (wrapperEl) wrapperEl.classList.remove('batch-thumb--source');
|
| 881 |
+
|
| 882 |
+
if (fromIndex !== to && fromIndex >= 0 && to >= 0) {
|
| 883 |
+
const [item] = batchImages.splice(fromIndex, 1);
|
| 884 |
+
batchImages.splice(to, 0, item);
|
| 885 |
+
updateBatchSegments();
|
| 886 |
+
}
|
| 887 |
+
renderBatchImages();
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
function handleBatchPointerDown(e) {
|
| 891 |
+
if (batchPointerState) return;
|
| 892 |
+
if (e.button !== 0) return;
|
| 893 |
+
if (e.target.closest && e.target.closest('.batch-thumb-remove')) return;
|
| 894 |
+
|
| 895 |
+
const wrapper = e.currentTarget;
|
| 896 |
+
const container = document.getElementById('batch-images-container');
|
| 897 |
+
if (!container) return;
|
| 898 |
+
|
| 899 |
+
e.preventDefault();
|
| 900 |
+
e.stopPropagation();
|
| 901 |
+
|
| 902 |
+
const fromIndex = parseInt(wrapper.dataset.index, 10);
|
| 903 |
+
if (Number.isNaN(fromIndex)) return;
|
| 904 |
+
|
| 905 |
+
const rect = wrapper.getBoundingClientRect();
|
| 906 |
+
const offsetX = e.clientX - rect.left;
|
| 907 |
+
const offsetY = e.clientY - rect.top;
|
| 908 |
+
const startLeft = rect.left;
|
| 909 |
+
const startTop = rect.top;
|
| 910 |
+
|
| 911 |
+
const ghost = document.createElement('div');
|
| 912 |
+
ghost.className = 'batch-thumb-floating-ghost';
|
| 913 |
+
const gImg = document.createElement('img');
|
| 914 |
+
const srcImg = wrapper.querySelector('img');
|
| 915 |
+
gImg.src = srcImg ? srcImg.src : '';
|
| 916 |
+
gImg.alt = '';
|
| 917 |
+
ghost.appendChild(gImg);
|
| 918 |
+
document.body.appendChild(ghost);
|
| 919 |
+
|
| 920 |
+
batchPointerState = {
|
| 921 |
+
fromIndex,
|
| 922 |
+
pointerId: e.pointerId,
|
| 923 |
+
wrapperEl: wrapper,
|
| 924 |
+
container,
|
| 925 |
+
ghostEl: ghost,
|
| 926 |
+
offsetX,
|
| 927 |
+
offsetY,
|
| 928 |
+
ghostTX: e.clientX - offsetX,
|
| 929 |
+
ghostTY: e.clientY - offsetY,
|
| 930 |
+
ghostCX: startLeft,
|
| 931 |
+
ghostCY: startTop,
|
| 932 |
+
ghostRaf: null
|
| 933 |
+
};
|
| 934 |
+
|
| 935 |
+
ghost.style.transform =
|
| 936 |
+
`translate3d(${startLeft}px,${startTop}px,0) scale(1.06) rotate(-1deg)`;
|
| 937 |
+
|
| 938 |
+
container.classList.add('is-batch-settling');
|
| 939 |
+
wrapper.classList.add('batch-thumb--source');
|
| 940 |
+
const ph = batchEnsurePlaceholder();
|
| 941 |
+
container.insertBefore(ph, wrapper.nextSibling);
|
| 942 |
+
/* 不在 pointerdown 立刻重算槽位;双 rAF 后再恢复邻居 transition,保证先完成本帧布局再动画 */
|
| 943 |
+
requestAnimationFrame(() => {
|
| 944 |
+
requestAnimationFrame(() => {
|
| 945 |
+
container.classList.remove('is-batch-settling');
|
| 946 |
+
});
|
| 947 |
+
});
|
| 948 |
+
|
| 949 |
+
batchStartGhostLoop();
|
| 950 |
+
|
| 951 |
+
document.addEventListener('pointermove', handleBatchPointerMove, { passive: false });
|
| 952 |
+
document.addEventListener('pointerup', batchEndPointerDrag);
|
| 953 |
+
document.addEventListener('pointercancel', batchEndPointerDrag);
|
| 954 |
+
|
| 955 |
+
try {
|
| 956 |
+
wrapper.setPointerCapture(e.pointerId);
|
| 957 |
+
} catch (_) {}
|
| 958 |
+
}
|
| 959 |
+
|
| 960 |
+
function removeBatchImage(index) {
|
| 961 |
+
if (index < 0 || index >= batchImages.length) return;
|
| 962 |
+
batchImages.splice(index, 1);
|
| 963 |
+
renderBatchImages();
|
| 964 |
+
updateBatchSegments();
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
+
// 横向缩略图:Pointer 拖动排序(避免 HTML5 DnD 在 WebView/部分浏览器失效)
|
| 968 |
+
function renderBatchImages() {
|
| 969 |
+
const container = document.getElementById('batch-images-container');
|
| 970 |
+
if (!container) return;
|
| 971 |
+
|
| 972 |
+
syncBatchDropZoneChrome();
|
| 973 |
+
batchRemovePlaceholder();
|
| 974 |
+
batchCancelPhMoveRaf();
|
| 975 |
+
batchRemoveFloatingGhost();
|
| 976 |
+
batchPointerState = null;
|
| 977 |
+
container.classList.remove('is-batch-settling');
|
| 978 |
+
container.innerHTML = '';
|
| 979 |
+
|
| 980 |
+
batchImages.forEach((img, index) => {
|
| 981 |
+
const wrapper = document.createElement('div');
|
| 982 |
+
wrapper.className = 'batch-image-wrapper';
|
| 983 |
+
wrapper.dataset.index = String(index);
|
| 984 |
+
wrapper.title = _t('batchThumbDrag');
|
| 985 |
+
|
| 986 |
+
const imgWrap = document.createElement('div');
|
| 987 |
+
imgWrap.className = 'batch-thumb-img-wrap';
|
| 988 |
+
const im = document.createElement('img');
|
| 989 |
+
im.className = 'batch-thumb-img';
|
| 990 |
+
im.src = img.preview;
|
| 991 |
+
im.alt = img.name || '';
|
| 992 |
+
im.draggable = false;
|
| 993 |
+
imgWrap.appendChild(im);
|
| 994 |
+
|
| 995 |
+
const del = document.createElement('button');
|
| 996 |
+
del.type = 'button';
|
| 997 |
+
del.className = 'batch-thumb-remove';
|
| 998 |
+
del.title = _t('batchThumbRemove');
|
| 999 |
+
del.setAttribute('aria-label', _t('batchThumbRemove'));
|
| 1000 |
+
del.textContent = '×';
|
| 1001 |
+
del.addEventListener('pointerdown', (ev) => ev.stopPropagation());
|
| 1002 |
+
del.addEventListener('click', (ev) => {
|
| 1003 |
+
ev.stopPropagation();
|
| 1004 |
+
removeBatchImage(index);
|
| 1005 |
+
});
|
| 1006 |
+
|
| 1007 |
+
wrapper.appendChild(imgWrap);
|
| 1008 |
+
wrapper.appendChild(del);
|
| 1009 |
+
|
| 1010 |
+
wrapper.addEventListener('pointerdown', handleBatchPointerDown);
|
| 1011 |
+
|
| 1012 |
+
container.appendChild(wrapper);
|
| 1013 |
+
});
|
| 1014 |
+
}
|
| 1015 |
+
|
| 1016 |
+
function batchWorkflowIsSingle() {
|
| 1017 |
+
const r = document.querySelector('input[name="batch-workflow"]:checked');
|
| 1018 |
+
return !!(r && r.value === 'single');
|
| 1019 |
+
}
|
| 1020 |
+
|
| 1021 |
+
function onBatchWorkflowChange() {
|
| 1022 |
+
updateBatchSegments();
|
| 1023 |
+
}
|
| 1024 |
+
|
| 1025 |
+
// 更新片段设置 UI(分段模式)或单次多关键帧设置
|
| 1026 |
+
function updateBatchSegments() {
|
| 1027 |
+
const container = document.getElementById('batch-segments-container');
|
| 1028 |
+
if (!container) return;
|
| 1029 |
+
|
| 1030 |
+
if (batchImages.length < 2) {
|
| 1031 |
+
container.innerHTML =
|
| 1032 |
+
'<div style="color: var(--text-dim); font-size: 11px;">' +
|
| 1033 |
+
escapeHtmlAttr(_t('batchNeedTwo')) +
|
| 1034 |
+
'</div>';
|
| 1035 |
+
return;
|
| 1036 |
+
}
|
| 1037 |
+
|
| 1038 |
+
if (batchWorkflowIsSingle()) {
|
| 1039 |
+
if (batchImages.length >= 2) captureBatchKfTimelineFromDom();
|
| 1040 |
+
const n = batchImages.length;
|
| 1041 |
+
const defaultTotal = 8;
|
| 1042 |
+
const defaultSeg =
|
| 1043 |
+
n > 1 ? (defaultTotal / (n - 1)).toFixed(1) : '4';
|
| 1044 |
+
let blocks = '';
|
| 1045 |
+
batchImages.forEach((img, i) => {
|
| 1046 |
+
const path = img.path || '';
|
| 1047 |
+
const stDef = defaultKeyframeStrengthForIndex(i, n);
|
| 1048 |
+
const stStored = batchKfStrengthByPath[path];
|
| 1049 |
+
const stVal = stStored !== undefined && stStored !== ''
|
| 1050 |
+
? escapeHtmlAttr(stStored)
|
| 1051 |
+
: stDef;
|
| 1052 |
+
const prev = escapeHtmlAttr(img.preview || '');
|
| 1053 |
+
if (i > 0) {
|
| 1054 |
+
const j = i - 1;
|
| 1055 |
+
const sdStored = batchKfSegDurByIndex[j];
|
| 1056 |
+
const segVal =
|
| 1057 |
+
sdStored !== undefined && sdStored !== ''
|
| 1058 |
+
? escapeHtmlAttr(sdStored)
|
| 1059 |
+
: defaultSeg;
|
| 1060 |
+
blocks += `
|
| 1061 |
+
<div class="batch-kf-gap">
|
| 1062 |
+
<div class="batch-kf-gap-rail" aria-hidden="true"></div>
|
| 1063 |
+
<div class="batch-kf-gap-inner">
|
| 1064 |
+
<span class="batch-kf-gap-ix">${i}→${i + 1}</span>
|
| 1065 |
+
<label class="batch-kf-seg-field">
|
| 1066 |
+
<input type="number" class="batch-kf-seg-input" id="batch-kf-seg-dur-${j}"
|
| 1067 |
+
value="${segVal}" min="0.1" max="120" step="0.1"
|
| 1068 |
+
title="${escapeHtmlAttr(_t('batchGapInputTitle'))}"
|
| 1069 |
+
oninput="updateBatchKfTimelineDerivedUI()">
|
| 1070 |
+
<span class="batch-kf-gap-unit">${escapeHtmlAttr(_t('batchSec'))}</span>
|
| 1071 |
+
</label>
|
| 1072 |
+
</div>
|
| 1073 |
+
</div>`;
|
| 1074 |
+
}
|
| 1075 |
+
blocks += `
|
| 1076 |
+
<div class="batch-kf-kcard">
|
| 1077 |
+
<div class="batch-kf-kcard-head">
|
| 1078 |
+
<img class="batch-kf-kthumb" src="${prev}" alt="">
|
| 1079 |
+
<div class="batch-kf-kcard-titles">
|
| 1080 |
+
<span class="batch-kf-ktitle">${escapeHtmlAttr(_t('batchKfTitle'))} ${i + 1} / ${n}</span>
|
| 1081 |
+
<span class="batch-kf-anchor" id="batch-kf-anchor-label-${i}">—</span>
|
| 1082 |
+
</div>
|
| 1083 |
+
</div>
|
| 1084 |
+
<div class="batch-kf-kcard-ctrl">
|
| 1085 |
+
<label class="batch-kf-klabel">${escapeHtmlAttr(_t('batchStrength'))}
|
| 1086 |
+
<input type="number" id="batch-kf-strength-${i}" value="${stVal}" min="0.1" max="1" step="0.01"
|
| 1087 |
+
title="${escapeHtmlAttr(_t('batchStrengthTitle'))}">
|
| 1088 |
+
</label>
|
| 1089 |
+
</div>
|
| 1090 |
+
</div>`;
|
| 1091 |
+
});
|
| 1092 |
+
container.innerHTML = `
|
| 1093 |
+
<div class="batch-kf-panel" id="batch-kf-timeline-root">
|
| 1094 |
+
<div class="batch-kf-panel-hd">
|
| 1095 |
+
<div class="batch-kf-panel-title">${escapeHtmlAttr(_t('batchKfPanelTitle'))}</div>
|
| 1096 |
+
<div class="batch-kf-total-pill" title="${escapeHtmlAttr(_t('batchTotalPillTitle'))}">
|
| 1097 |
+
${escapeHtmlAttr(_t('batchTotalDur'))} <strong id="batch-kf-total-seconds">—</strong> <span class="batch-kf-total-unit">${escapeHtmlAttr(_t('batchTotalSec'))}</span>
|
| 1098 |
+
</div>
|
| 1099 |
+
</div>
|
| 1100 |
+
<p class="batch-kf-panel-hint">${escapeHtmlAttr(_t('batchPanelHint'))}</p>
|
| 1101 |
+
<div class="batch-kf-timeline-col">
|
| 1102 |
+
${blocks}
|
| 1103 |
+
</div>
|
| 1104 |
+
</div>`;
|
| 1105 |
+
updateBatchKfTimelineDerivedUI();
|
| 1106 |
+
return;
|
| 1107 |
+
}
|
| 1108 |
+
|
| 1109 |
+
let html =
|
| 1110 |
+
'<div style="font-size: 12px; font-weight: bold; margin-bottom: 10px;">' +
|
| 1111 |
+
escapeHtmlAttr(_t('batchSegTitle')) +
|
| 1112 |
+
'</div>';
|
| 1113 |
+
|
| 1114 |
+
for (let i = 0; i < batchImages.length - 1; i++) {
|
| 1115 |
+
const segPh = escapeHtmlAttr(_t('batchSegPromptPh'));
|
| 1116 |
+
html += `
|
| 1117 |
+
<div style="background: var(--item); border-radius: 8px; padding: 10px; margin-bottom: 10px; border: 1px solid var(--border);">
|
| 1118 |
+
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
|
| 1119 |
+
<div style="display: flex; align-items: center; gap: 8px;">
|
| 1120 |
+
<img src="${batchImages[i].preview}" style="width: 40px; height: 40px; border-radius: 4px; object-fit: cover;">
|
| 1121 |
+
<span style="color: var(--accent);">→</span>
|
| 1122 |
+
<img src="${batchImages[i + 1].preview}" style="width: 40px; height: 40px; border-radius: 4px; object-fit: cover;">
|
| 1123 |
+
<span style="font-size: 11px; color: var(--text-dim);">${escapeHtmlAttr(_t('batchSegClip'))} ${i + 1}</span>
|
| 1124 |
+
</div>
|
| 1125 |
+
<div style="display: flex; align-items: center; gap: 6px;">
|
| 1126 |
+
<label style="font-size: 10px; color: var(--text-dim);">${escapeHtmlAttr(_t('batchSegDuration'))}</label>
|
| 1127 |
+
<input type="number" id="batch-segment-duration-${i}" value="5" min="1" max="30" step="1" style="width: 50px; padding: 4px; font-size: 11px;">
|
| 1128 |
+
<span style="font-size: 10px; color: var(--text-dim);">${escapeHtmlAttr(_t('batchSegSec'))}</span>
|
| 1129 |
+
</div>
|
| 1130 |
+
</div>
|
| 1131 |
+
<div>
|
| 1132 |
+
<label style="font-size: 10px;">${escapeHtmlAttr(_t('batchSegPrompt'))}</label>
|
| 1133 |
+
<textarea id="batch-segment-prompt-${i}" placeholder="${segPh}" style="width: 100%; height: 60px; padding: 6px; font-size: 11px; box-sizing: border-box; resize: vertical;"></textarea>
|
| 1134 |
+
</div>
|
| 1135 |
+
</div>
|
| 1136 |
+
`;
|
| 1137 |
+
}
|
| 1138 |
+
|
| 1139 |
+
container.innerHTML = html;
|
| 1140 |
+
}
|
| 1141 |
+
|
| 1142 |
+
let _isGeneratingFlag = false;
|
| 1143 |
+
|
| 1144 |
+
// 系统状态轮询
|
| 1145 |
+
async function checkStatus() {
|
| 1146 |
+
try {
|
| 1147 |
+
const h = await fetch(`${BASE}/health`).then(r => r.json()).catch(() => ({status: "error"}));
|
| 1148 |
+
const g = await fetch(`${BASE}/api/gpu-info`).then(r => r.json()).catch(() => ({gpu_info: {}}));
|
| 1149 |
+
const p = await fetch(`${BASE}/api/generation/progress`).then(r => r.json()).catch(() => ({progress: 0}));
|
| 1150 |
+
const sysGpus = await fetch(`${BASE}/api/system/list-gpus`).then(r => r.json()).catch(() => ({gpus: []}));
|
| 1151 |
+
|
| 1152 |
+
const activeGpu = (sysGpus.gpus || []).find(x => x.active) || (sysGpus.gpus || [])[0] || {};
|
| 1153 |
+
const gpuName = activeGpu.name || g.gpu_info?.name || "GPU";
|
| 1154 |
+
|
| 1155 |
+
const s = document.getElementById('sys-status');
|
| 1156 |
+
const indicator = document.getElementById('sys-indicator');
|
| 1157 |
+
|
| 1158 |
+
const isReady = h.status === "ok" || h.status === "ready" || h.models_loaded;
|
| 1159 |
+
const backendActive = (p && p.progress > 0);
|
| 1160 |
+
|
| 1161 |
+
if (_isGeneratingFlag || backendActive) {
|
| 1162 |
+
s.innerText = `${gpuName}: ${_t('sysBusy')}`;
|
| 1163 |
+
if(indicator) indicator.className = 'indicator-busy';
|
| 1164 |
+
} else {
|
| 1165 |
+
s.innerText = isReady ? `${gpuName}: ${_t('sysOnline')}` : `${gpuName}: ${_t('sysStarting')}`;
|
| 1166 |
+
if(indicator) indicator.className = isReady ? 'indicator-ready' : 'indicator-offline';
|
| 1167 |
+
}
|
| 1168 |
+
s.style.color = "var(--text-dim)";
|
| 1169 |
+
|
| 1170 |
+
const vUsedMB = g.gpu_info?.vramUsed || 0;
|
| 1171 |
+
const vTotalMB = activeGpu.vram_mb || g.gpu_info?.vram || 32768;
|
| 1172 |
+
const vUsedGB = vUsedMB / 1024;
|
| 1173 |
+
const vTotalGB = vTotalMB / 1024;
|
| 1174 |
+
|
| 1175 |
+
document.getElementById('vram-fill').style.width = (vUsedMB / vTotalMB * 100) + "%";
|
| 1176 |
+
document.getElementById('vram-text').innerText = `${vUsedGB.toFixed(1)} / ${vTotalGB.toFixed(0)} GB`;
|
| 1177 |
+
} catch(e) { document.getElementById('sys-status').innerText = _t('sysOffline'); }
|
| 1178 |
+
}
|
| 1179 |
+
setInterval(checkStatus, 1000); // 提升到 1 秒一次实时监控
|
| 1180 |
+
checkStatus();
|
| 1181 |
+
initDragAndDrop();
|
| 1182 |
+
listGpus(); // 初始化 GPU 列表
|
| 1183 |
+
// 已移除:输出目录自定义(保持后端默认路径)
|
| 1184 |
+
|
| 1185 |
+
updateResPreview();
|
| 1186 |
+
updateBatchResPreview();
|
| 1187 |
+
updateImgResPreview();
|
| 1188 |
+
refreshPromptPlaceholder();
|
| 1189 |
+
|
| 1190 |
+
window.onUiLanguageChanged = function () {
|
| 1191 |
+
updateResPreview();
|
| 1192 |
+
updateBatchResPreview();
|
| 1193 |
+
updateImgResPreview();
|
| 1194 |
+
refreshPromptPlaceholder();
|
| 1195 |
+
if (typeof currentMode !== 'undefined' && currentMode === 'batch') {
|
| 1196 |
+
updateBatchSegments();
|
| 1197 |
+
}
|
| 1198 |
+
updateModelDropdown();
|
| 1199 |
+
updateLoraDropdown();
|
| 1200 |
+
updateBatchModelDropdown();
|
| 1201 |
+
updateBatchLoraDropdown();
|
| 1202 |
+
};
|
| 1203 |
+
|
| 1204 |
+
async function setOutputDir() {
|
| 1205 |
+
const dir = document.getElementById('global-out-dir').value.trim();
|
| 1206 |
+
localStorage.setItem('output_dir', dir);
|
| 1207 |
+
try {
|
| 1208 |
+
const res = await fetch(`${BASE}/api/system/set-dir`, {
|
| 1209 |
+
method: 'POST',
|
| 1210 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1211 |
+
body: JSON.stringify({ directory: dir })
|
| 1212 |
+
});
|
| 1213 |
+
if (res.ok) {
|
| 1214 |
+
addLog(`✅ 存储路径更新成功! 当前路径: ${dir || _t('defaultPath')}`);
|
| 1215 |
+
if (typeof fetchHistory === 'function') fetchHistory(currentHistoryPage);
|
| 1216 |
+
}
|
| 1217 |
+
} catch (e) {
|
| 1218 |
+
addLog(`❌ 设置路径时连接异常: ${e.message}`);
|
| 1219 |
+
}
|
| 1220 |
+
}
|
| 1221 |
+
|
| 1222 |
+
async function browseOutputDir() {
|
| 1223 |
+
try {
|
| 1224 |
+
const res = await fetch(`${BASE}/api/system/browse-dir`);
|
| 1225 |
+
const data = await res.json();
|
| 1226 |
+
if (data.status === "success" && data.directory) {
|
| 1227 |
+
document.getElementById('global-out-dir').value = data.directory;
|
| 1228 |
+
// auto apply immediately
|
| 1229 |
+
setOutputDir();
|
| 1230 |
+
addLog(`📂 检测到新路径,已自动套用!`);
|
| 1231 |
+
} else if (data.error) {
|
| 1232 |
+
addLog(`❌ 内部系统权限拦截了弹窗: ${data.error}`);
|
| 1233 |
+
}
|
| 1234 |
+
} catch (e) {
|
| 1235 |
+
addLog(`❌ 无法调出文件夹浏览弹窗, 请直接复制粘贴绝对路径。`);
|
| 1236 |
+
}
|
| 1237 |
+
}
|
| 1238 |
+
|
| 1239 |
+
async function getOutputDir() {
|
| 1240 |
+
try {
|
| 1241 |
+
const res = await fetch(`${BASE}/api/system/get-dir`);
|
| 1242 |
+
const data = await res.json();
|
| 1243 |
+
if (data.directory && data.directory.indexOf('LTXDesktop') === -1 && document.getElementById('global-out-dir')) {
|
| 1244 |
+
document.getElementById('global-out-dir').value = data.directory;
|
| 1245 |
+
}
|
| 1246 |
+
} catch (e) {}
|
| 1247 |
+
}
|
| 1248 |
+
|
| 1249 |
+
function switchMode(m) {
|
| 1250 |
+
currentMode = m;
|
| 1251 |
+
document.getElementById('tab-image').classList.toggle('active', m === 'image');
|
| 1252 |
+
document.getElementById('tab-video').classList.toggle('active', m === 'video');
|
| 1253 |
+
document.getElementById('tab-batch').classList.toggle('active', m === 'batch');
|
| 1254 |
+
document.getElementById('tab-upscale').classList.toggle('active', m === 'upscale');
|
| 1255 |
+
|
| 1256 |
+
document.getElementById('image-opts').style.display = m === 'image' ? 'block' : 'none';
|
| 1257 |
+
document.getElementById('video-opts').style.display = m === 'video' ? 'block' : 'none';
|
| 1258 |
+
document.getElementById('batch-opts').style.display = m === 'batch' ? 'block' : 'none';
|
| 1259 |
+
document.getElementById('upscale-opts').style.display = m === 'upscale' ? 'block' : 'none';
|
| 1260 |
+
if (m === 'batch') updateBatchSegments();
|
| 1261 |
+
|
| 1262 |
+
// 如果切到图像模式,隐藏提示词框外的其他东西
|
| 1263 |
+
refreshPromptPlaceholder();
|
| 1264 |
+
}
|
| 1265 |
+
|
| 1266 |
+
function refreshPromptPlaceholder() {
|
| 1267 |
+
const pe = document.getElementById('prompt');
|
| 1268 |
+
if (!pe) return;
|
| 1269 |
+
pe.placeholder =
|
| 1270 |
+
currentMode === 'upscale' ? _t('promptPlaceholderUpscale') : _t('promptPlaceholder');
|
| 1271 |
+
}
|
| 1272 |
+
|
| 1273 |
+
function showGeneratingView() {
|
| 1274 |
+
if (!_isGeneratingFlag) return;
|
| 1275 |
+
const resImg = document.getElementById('res-img');
|
| 1276 |
+
const videoWrapper = document.getElementById('video-wrapper');
|
| 1277 |
+
if (resImg) resImg.style.display = "none";
|
| 1278 |
+
if (videoWrapper) videoWrapper.style.display = "none";
|
| 1279 |
+
if (player) {
|
| 1280 |
+
try { player.stop(); } catch(_) {}
|
| 1281 |
+
} else {
|
| 1282 |
+
const vid = document.getElementById('res-video');
|
| 1283 |
+
if (vid) { vid.pause(); vid.removeAttribute('src'); vid.load(); }
|
| 1284 |
+
}
|
| 1285 |
+
const loadingTxt = document.getElementById('loading-txt');
|
| 1286 |
+
if (loadingTxt) loadingTxt.style.display = "flex";
|
| 1287 |
+
}
|
| 1288 |
+
|
| 1289 |
+
async function run() {
|
| 1290 |
+
// 防止重复点击(_isGeneratingFlag 比 btn.disabled 更可靠)
|
| 1291 |
+
if (_isGeneratingFlag) {
|
| 1292 |
+
addLog(_t('warnGenerating'));
|
| 1293 |
+
return;
|
| 1294 |
+
}
|
| 1295 |
+
|
| 1296 |
+
const btn = document.getElementById('mainBtn');
|
| 1297 |
+
const promptEl = document.getElementById('prompt');
|
| 1298 |
+
const prompt = promptEl ? promptEl.value.trim() : '';
|
| 1299 |
+
|
| 1300 |
+
function batchHasUsablePrompt() {
|
| 1301 |
+
if (prompt) return true;
|
| 1302 |
+
const c = document.getElementById('batch-common-prompt')?.value?.trim();
|
| 1303 |
+
if (c) return true;
|
| 1304 |
+
if (typeof batchWorkflowIsSingle === 'function' && batchWorkflowIsSingle()) {
|
| 1305 |
+
return false;
|
| 1306 |
+
}
|
| 1307 |
+
if (batchImages.length < 2) return false;
|
| 1308 |
+
for (let i = 0; i < batchImages.length - 1; i++) {
|
| 1309 |
+
if (document.getElementById(`batch-segment-prompt-${i}`)?.value?.trim()) return true;
|
| 1310 |
+
}
|
| 1311 |
+
return false;
|
| 1312 |
+
}
|
| 1313 |
+
|
| 1314 |
+
if (currentMode !== 'upscale') {
|
| 1315 |
+
if (currentMode === 'batch') {
|
| 1316 |
+
if (!batchHasUsablePrompt()) {
|
| 1317 |
+
addLog(_t('warnBatchPrompt'));
|
| 1318 |
+
return;
|
| 1319 |
+
}
|
| 1320 |
+
} else if (!prompt) {
|
| 1321 |
+
addLog(_t('warnNeedPrompt'));
|
| 1322 |
+
return;
|
| 1323 |
+
}
|
| 1324 |
+
}
|
| 1325 |
+
|
| 1326 |
+
if (!btn) {
|
| 1327 |
+
console.error('mainBtn not found');
|
| 1328 |
+
return;
|
| 1329 |
+
}
|
| 1330 |
+
|
| 1331 |
+
// 先设置标志 + 禁用按钮,然后用顶层 try/finally 保证一定能解锁
|
| 1332 |
+
_isGeneratingFlag = true;
|
| 1333 |
+
btn.disabled = true;
|
| 1334 |
+
|
| 1335 |
+
try {
|
| 1336 |
+
// 安全地操作 UI 元素(改用 if 判空,防止 Plyr 接管后 getElementById 返回 null)
|
| 1337 |
+
const loader = document.getElementById('loading-txt');
|
| 1338 |
+
const resImg = document.getElementById('res-img');
|
| 1339 |
+
const resVideo = document.getElementById('res-video');
|
| 1340 |
+
|
| 1341 |
+
if (loader) {
|
| 1342 |
+
loader.style.display = "flex";
|
| 1343 |
+
loader.style.flexDirection = "column";
|
| 1344 |
+
loader.style.alignItems = "center";
|
| 1345 |
+
loader.style.gap = "12px";
|
| 1346 |
+
loader.innerHTML = `
|
| 1347 |
+
<div class="spinner" style="width:48px;height:48px;border-width:4px;color:var(--accent);"></div>
|
| 1348 |
+
<div id="loader-step-text" style="font-size:13px;font-weight:700;color:var(--text-sub);">${escapeHtmlAttr(_t('loaderGpuAlloc'))}</div>
|
| 1349 |
+
`;
|
| 1350 |
+
}
|
| 1351 |
+
if (resImg) resImg.style.display = "none";
|
| 1352 |
+
// 必须隐藏整个 video-wrapper(Plyr 外层容器),否则第二次生成时视频会与 spinner 叠加
|
| 1353 |
+
const videoWrapper = document.getElementById('video-wrapper');
|
| 1354 |
+
if (videoWrapper) videoWrapper.style.display = "none";
|
| 1355 |
+
if (player) { try { player.stop(); } catch(_) {} }
|
| 1356 |
+
else if (resVideo) { resVideo.pause?.(); resVideo.removeAttribute?.('src'); }
|
| 1357 |
+
|
| 1358 |
+
checkStatus();
|
| 1359 |
+
|
| 1360 |
+
// 重置后端状态锁(非关键,失败不影响主流程)
|
| 1361 |
+
try { await fetch(`${BASE}/api/system/reset-state`, { method: 'POST' }); } catch(_) {}
|
| 1362 |
+
|
| 1363 |
+
startProgressPolling();
|
| 1364 |
+
|
| 1365 |
+
// ---- 新增:在历史记录区插入「正在渲染」缩略图卡片 ----
|
| 1366 |
+
const historyContainer = document.getElementById('history-container');
|
| 1367 |
+
if (historyContainer) {
|
| 1368 |
+
const old = document.getElementById('current-loading-card');
|
| 1369 |
+
if (old) old.remove();
|
| 1370 |
+
const loadingCard = document.createElement('div');
|
| 1371 |
+
loadingCard.className = 'history-card loading-card';
|
| 1372 |
+
loadingCard.id = 'current-loading-card';
|
| 1373 |
+
loadingCard.onclick = showGeneratingView;
|
| 1374 |
+
loadingCard.innerHTML = `
|
| 1375 |
+
<div class="spinner"></div>
|
| 1376 |
+
<div id="loading-card-step" style="font-size:10px;color:var(--text-dim);margin-top:4px;">等待中...</div>
|
| 1377 |
+
`;
|
| 1378 |
+
historyContainer.prepend(loadingCard);
|
| 1379 |
+
}
|
| 1380 |
+
|
| 1381 |
+
// ---- 构建请求 ----
|
| 1382 |
+
let endpoint, payload;
|
| 1383 |
+
if (currentMode === 'image') {
|
| 1384 |
+
const w = parseInt(document.getElementById('img-w').value);
|
| 1385 |
+
const h = parseInt(document.getElementById('img-h').value);
|
| 1386 |
+
endpoint = '/api/generate-image';
|
| 1387 |
+
payload = {
|
| 1388 |
+
prompt, width: w, height: h,
|
| 1389 |
+
numSteps: parseInt(document.getElementById('img-steps').value),
|
| 1390 |
+
numImages: 1
|
| 1391 |
+
};
|
| 1392 |
+
addLog(`正在发起图像渲染: ${w}x${h}, Steps: ${payload.numSteps}`);
|
| 1393 |
+
|
| 1394 |
+
} else if (currentMode === 'video') {
|
| 1395 |
+
const res = updateResPreview();
|
| 1396 |
+
const dur = parseFloat(document.getElementById('vid-duration').value);
|
| 1397 |
+
const fps = document.getElementById('vid-fps').value;
|
| 1398 |
+
if (dur > 20) addLog(_t('warnVideoLong').replace('{n}', String(dur)));
|
| 1399 |
+
|
| 1400 |
+
const audio = document.getElementById('vid-audio').checked ? "true" : "false";
|
| 1401 |
+
const audioPath = document.getElementById('uploaded-audio-path').value;
|
| 1402 |
+
const startFramePathValue = document.getElementById('start-frame-path').value;
|
| 1403 |
+
const endFramePathValue = document.getElementById('end-frame-path').value;
|
| 1404 |
+
|
| 1405 |
+
let finalImagePath = null, finalStartFramePath = null, finalEndFramePath = null;
|
| 1406 |
+
if (startFramePathValue && endFramePathValue) {
|
| 1407 |
+
finalStartFramePath = startFramePathValue;
|
| 1408 |
+
finalEndFramePath = endFramePathValue;
|
| 1409 |
+
} else if (startFramePathValue) {
|
| 1410 |
+
finalImagePath = startFramePathValue;
|
| 1411 |
+
}
|
| 1412 |
+
|
| 1413 |
+
endpoint = '/api/generate';
|
| 1414 |
+
const modelSelect = document.getElementById('vid-model');
|
| 1415 |
+
const loraSelect = document.getElementById('vid-lora');
|
| 1416 |
+
const loraStrengthInput = document.getElementById('lora-strength');
|
| 1417 |
+
const modelPath = modelSelect ? modelSelect.value : '';
|
| 1418 |
+
const loraPath = loraSelect ? loraSelect.value : '';
|
| 1419 |
+
const loraStrength = loraStrengthInput ? (parseFloat(loraStrengthInput.value) || 1.0) : 1.0;
|
| 1420 |
+
console.log("modelPath:", modelPath);
|
| 1421 |
+
console.log("loraPath:", loraPath);
|
| 1422 |
+
console.log("loraStrength:", loraStrength);
|
| 1423 |
+
payload = {
|
| 1424 |
+
prompt, resolution: res, model: "ltx-2",
|
| 1425 |
+
cameraMotion: document.getElementById('vid-motion').value,
|
| 1426 |
+
negativePrompt: "low quality, blurry, noisy, static noise, distorted",
|
| 1427 |
+
duration: String(dur), fps, audio,
|
| 1428 |
+
imagePath: finalImagePath,
|
| 1429 |
+
audioPath: audioPath || null,
|
| 1430 |
+
startFramePath: finalStartFramePath,
|
| 1431 |
+
endFramePath: finalEndFramePath,
|
| 1432 |
+
aspectRatio: document.getElementById('vid-ratio').value,
|
| 1433 |
+
modelPath: modelPath || null,
|
| 1434 |
+
loraPath: loraPath || null,
|
| 1435 |
+
loraStrength: loraStrength,
|
| 1436 |
+
};
|
| 1437 |
+
addLog(`正在发起视频渲染: ${res}, 时长: ${dur}s, FPS: ${fps}, 模型: ${modelPath ? modelPath.split(/[/\\]/).pop() : _t('modelDefaultLabel')}, LoRA: ${loraPath ? loraPath.split(/[/\\]/).pop() : _t('loraNoneLabel')}`);
|
| 1438 |
+
|
| 1439 |
+
} else if (currentMode === 'upscale') {
|
| 1440 |
+
const videoPath = document.getElementById('upscale-video-path').value;
|
| 1441 |
+
const targetRes = document.getElementById('upscale-res').value;
|
| 1442 |
+
if (!videoPath) throw new Error(_t('errUpscaleNoVideo'));
|
| 1443 |
+
endpoint = '/api/system/upscale-video';
|
| 1444 |
+
payload = { video_path: videoPath, resolution: targetRes, prompt: "high quality, detailed, 4k", strength: 0.7 };
|
| 1445 |
+
addLog(`正在发起视频超分: 目标 ${targetRes}`);
|
| 1446 |
+
} else if (currentMode === 'batch') {
|
| 1447 |
+
const res = updateBatchResPreview();
|
| 1448 |
+
const commonPromptEl = document.getElementById('batch-common-prompt');
|
| 1449 |
+
const commonPrompt = commonPromptEl ? commonPromptEl.value : '';
|
| 1450 |
+
const modelSelect = document.getElementById('batch-model');
|
| 1451 |
+
const loraSelect = document.getElementById('batch-lora');
|
| 1452 |
+
const loraStrengthInput = document.getElementById('batch-lora-strength');
|
| 1453 |
+
const modelPath = modelSelect ? modelSelect.value : '';
|
| 1454 |
+
const loraPath = loraSelect ? loraSelect.value : '';
|
| 1455 |
+
const loraStrength = loraStrengthInput ? (parseFloat(loraStrengthInput.value) || 1.2) : 1.2;
|
| 1456 |
+
|
| 1457 |
+
if (batchImages.length < 2) {
|
| 1458 |
+
throw new Error(_t('errBatchMinImages'));
|
| 1459 |
+
}
|
| 1460 |
+
|
| 1461 |
+
if (batchWorkflowIsSingle()) {
|
| 1462 |
+
captureBatchKfTimelineFromDom();
|
| 1463 |
+
const fps = document.getElementById('vid-fps').value;
|
| 1464 |
+
const parts = [prompt.trim(), commonPrompt.trim()].filter(Boolean);
|
| 1465 |
+
const combinedPrompt = parts.join(', ');
|
| 1466 |
+
if (!combinedPrompt) {
|
| 1467 |
+
throw new Error(_t('errSingleKfPrompt'));
|
| 1468 |
+
}
|
| 1469 |
+
const nKf = batchImages.length;
|
| 1470 |
+
const minSeg = 0.1;
|
| 1471 |
+
const segDurs = [];
|
| 1472 |
+
for (let j = 0; j < nKf - 1; j++) {
|
| 1473 |
+
let v = parseFloat(document.getElementById(`batch-kf-seg-dur-${j}`)?.value);
|
| 1474 |
+
if (!Number.isFinite(v) || v < minSeg) v = minSeg;
|
| 1475 |
+
segDurs.push(v);
|
| 1476 |
+
}
|
| 1477 |
+
const sumSec = segDurs.reduce((a, b) => a + b, 0);
|
| 1478 |
+
const dur = Math.max(2, Math.ceil(sumSec - 1e-9));
|
| 1479 |
+
const times = [0];
|
| 1480 |
+
let acc = 0;
|
| 1481 |
+
for (let j = 0; j < nKf - 1; j++) {
|
| 1482 |
+
acc += segDurs[j];
|
| 1483 |
+
times.push(acc);
|
| 1484 |
+
}
|
| 1485 |
+
const strengths = [];
|
| 1486 |
+
for (let i = 0; i < nKf; i++) {
|
| 1487 |
+
const sEl = document.getElementById(`batch-kf-strength-${i}`);
|
| 1488 |
+
let sv = parseFloat(sEl?.value);
|
| 1489 |
+
if (!Number.isFinite(sv)) {
|
| 1490 |
+
sv = parseFloat(defaultKeyframeStrengthForIndex(i, nKf));
|
| 1491 |
+
}
|
| 1492 |
+
if (!Number.isFinite(sv)) sv = 1;
|
| 1493 |
+
sv = Math.max(0.1, Math.min(1.0, sv));
|
| 1494 |
+
strengths.push(sv);
|
| 1495 |
+
}
|
| 1496 |
+
endpoint = '/api/generate';
|
| 1497 |
+
payload = {
|
| 1498 |
+
prompt: combinedPrompt,
|
| 1499 |
+
resolution: res,
|
| 1500 |
+
model: "ltx-2",
|
| 1501 |
+
cameraMotion: document.getElementById('vid-motion').value,
|
| 1502 |
+
negativePrompt: "low quality, blurry, noisy, static noise, distorted",
|
| 1503 |
+
duration: String(dur),
|
| 1504 |
+
fps,
|
| 1505 |
+
audio: "false",
|
| 1506 |
+
imagePath: null,
|
| 1507 |
+
audioPath: null,
|
| 1508 |
+
startFramePath: null,
|
| 1509 |
+
endFramePath: null,
|
| 1510 |
+
keyframePaths: batchImages.map((b) => b.path),
|
| 1511 |
+
keyframeStrengths: strengths,
|
| 1512 |
+
keyframeTimes: times,
|
| 1513 |
+
aspectRatio: document.getElementById('batch-ratio').value,
|
| 1514 |
+
modelPath: modelPath || null,
|
| 1515 |
+
loraPath: loraPath || null,
|
| 1516 |
+
loraStrength: loraStrength,
|
| 1517 |
+
};
|
| 1518 |
+
addLog(
|
| 1519 |
+
`单次多关键帧: ${nKf} 锚点, 轴长合计 ${sumSec.toFixed(1)}s → 请求时长 ${dur}s, ${res}, FPS ${fps}`
|
| 1520 |
+
);
|
| 1521 |
+
} else {
|
| 1522 |
+
const segments = [];
|
| 1523 |
+
for (let i = 0; i < batchImages.length - 1; i++) {
|
| 1524 |
+
const duration = parseFloat(document.getElementById(`batch-segment-duration-${i}`)?.value || 5);
|
| 1525 |
+
const segmentPrompt = document.getElementById(`batch-segment-prompt-${i}`)?.value || '';
|
| 1526 |
+
const segParts = [prompt.trim(), commonPrompt.trim(), segmentPrompt.trim()].filter(Boolean);
|
| 1527 |
+
const combinedSegPrompt = segParts.join(', ');
|
| 1528 |
+
segments.push({
|
| 1529 |
+
startImage: batchImages[i].path,
|
| 1530 |
+
endImage: batchImages[i + 1].path,
|
| 1531 |
+
duration: duration,
|
| 1532 |
+
prompt: combinedSegPrompt
|
| 1533 |
+
});
|
| 1534 |
+
}
|
| 1535 |
+
|
| 1536 |
+
endpoint = '/api/generate-batch';
|
| 1537 |
+
const bgAudioEl = document.getElementById('batch-background-audio-path');
|
| 1538 |
+
const backgroundAudioPath = (bgAudioEl && bgAudioEl.value) ? bgAudioEl.value.trim() : null;
|
| 1539 |
+
payload = {
|
| 1540 |
+
segments: segments,
|
| 1541 |
+
resolution: res,
|
| 1542 |
+
model: "ltx-2",
|
| 1543 |
+
aspectRatio: document.getElementById('batch-ratio').value,
|
| 1544 |
+
modelPath: modelPath || null,
|
| 1545 |
+
loraPath: loraPath || null,
|
| 1546 |
+
loraStrength: loraStrength,
|
| 1547 |
+
negativePrompt: "low quality, blurry, noisy, static noise, distorted",
|
| 1548 |
+
backgroundAudioPath: backgroundAudioPath || null
|
| 1549 |
+
};
|
| 1550 |
+
addLog(`分段拼接: ${segments.length} 段, ${res}${backgroundAudioPath ? ',含统一配乐' : ''}`);
|
| 1551 |
+
}
|
| 1552 |
+
}
|
| 1553 |
+
|
| 1554 |
+
// ---- 发送请求 ----
|
| 1555 |
+
const res = await fetch(BASE + endpoint, {
|
| 1556 |
+
method: 'POST',
|
| 1557 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1558 |
+
body: JSON.stringify(payload)
|
| 1559 |
+
});
|
| 1560 |
+
const data = await res.json();
|
| 1561 |
+
if (!res.ok) {
|
| 1562 |
+
const errMsg = data.error || data.detail || "API 拒绝了请求";
|
| 1563 |
+
throw new Error(typeof errMsg === 'string' ? errMsg : JSON.stringify(errMsg));
|
| 1564 |
+
}
|
| 1565 |
+
|
| 1566 |
+
// ---- 显示结果 ----
|
| 1567 |
+
const rawPath = data.image_paths ? data.image_paths[0] : data.video_path;
|
| 1568 |
+
if (rawPath) {
|
| 1569 |
+
try { displayOutput(rawPath); } catch (dispErr) { addLog(`⚠️ 播放器显示异常: ${dispErr.message}`); }
|
| 1570 |
+
}
|
| 1571 |
+
|
| 1572 |
+
// 强制刷新历史记录(不依赖 isLoadingHistory 标志,确保新生成的视频立即显示)
|
| 1573 |
+
setTimeout(() => {
|
| 1574 |
+
isLoadingHistory = false; // 强制重置状态
|
| 1575 |
+
if (typeof fetchHistory === 'function') fetchHistory(1);
|
| 1576 |
+
}, 500);
|
| 1577 |
+
|
| 1578 |
+
} catch (e) {
|
| 1579 |
+
const errText = e && e.message ? e.message : String(e);
|
| 1580 |
+
addLog(`❌ 渲染中断: ${errText}`);
|
| 1581 |
+
const loader = document.getElementById('loading-txt');
|
| 1582 |
+
if (loader) {
|
| 1583 |
+
loader.style.display = 'flex';
|
| 1584 |
+
loader.textContent = '';
|
| 1585 |
+
const span = document.createElement('span');
|
| 1586 |
+
span.style.cssText = 'color:var(--text-sub);font-size:13px;padding:12px;text-align:center;';
|
| 1587 |
+
span.textContent = `渲染失败:${errText}`;
|
| 1588 |
+
loader.appendChild(span);
|
| 1589 |
+
}
|
| 1590 |
+
|
| 1591 |
+
} finally {
|
| 1592 |
+
// ✅ 无论发生什么,这里一定执行,确保按钮永远可以再次点击
|
| 1593 |
+
_isGeneratingFlag = false;
|
| 1594 |
+
btn.disabled = false;
|
| 1595 |
+
stopProgressPolling();
|
| 1596 |
+
checkStatus();
|
| 1597 |
+
// 生成完毕后自动释放显存(不 await 避免阻塞 UI 解锁)
|
| 1598 |
+
setTimeout(() => { clearGpu(); }, 500);
|
| 1599 |
+
}
|
| 1600 |
+
}
|
| 1601 |
+
|
| 1602 |
+
async function clearGpu() {
|
| 1603 |
+
const btn = document.getElementById('clearGpuBtn');
|
| 1604 |
+
btn.disabled = true;
|
| 1605 |
+
btn.innerText = _t('clearingVram');
|
| 1606 |
+
try {
|
| 1607 |
+
const res = await fetch(`${BASE}/api/system/clear-gpu`, {
|
| 1608 |
+
method: 'POST',
|
| 1609 |
+
headers: { 'Content-Type': 'application/json' }
|
| 1610 |
+
});
|
| 1611 |
+
const data = await res.json();
|
| 1612 |
+
if (res.ok) {
|
| 1613 |
+
addLog(`🧹 显存清理成功: ${data.message}`);
|
| 1614 |
+
// 立即触发状态刷新
|
| 1615 |
+
checkStatus();
|
| 1616 |
+
setTimeout(checkStatus, 1000);
|
| 1617 |
+
} else {
|
| 1618 |
+
const errMsg = data.error || data.detail || "后端未实现此接口 (404)";
|
| 1619 |
+
throw new Error(errMsg);
|
| 1620 |
+
}
|
| 1621 |
+
} catch(e) {
|
| 1622 |
+
addLog(`❌ 清理显存失败: ${e.message}`);
|
| 1623 |
+
} finally {
|
| 1624 |
+
btn.disabled = false;
|
| 1625 |
+
btn.innerText = _t('clearVram');
|
| 1626 |
+
}
|
| 1627 |
+
}
|
| 1628 |
+
|
| 1629 |
+
async function listGpus() {
|
| 1630 |
+
try {
|
| 1631 |
+
const res = await fetch(`${BASE}/api/system/list-gpus`);
|
| 1632 |
+
const data = await res.json();
|
| 1633 |
+
if (res.ok && data.gpus) {
|
| 1634 |
+
const selector = document.getElementById('gpu-selector');
|
| 1635 |
+
selector.innerHTML = data.gpus.map(g =>
|
| 1636 |
+
`<option value="${g.id}" ${g.active ? 'selected' : ''}>GPU ${g.id}: ${g.name} (${g.vram})</option>`
|
| 1637 |
+
).join('');
|
| 1638 |
+
|
| 1639 |
+
// 更新当前显示的 GPU 名称
|
| 1640 |
+
const activeGpu = data.gpus.find(g => g.active);
|
| 1641 |
+
if (activeGpu) document.getElementById('gpu-name').innerText = activeGpu.name;
|
| 1642 |
+
}
|
| 1643 |
+
} catch (e) {
|
| 1644 |
+
console.error("Failed to list GPUs", e);
|
| 1645 |
+
}
|
| 1646 |
+
}
|
| 1647 |
+
|
| 1648 |
+
async function switchGpu(id) {
|
| 1649 |
+
if (!id) return;
|
| 1650 |
+
addLog(`🔄 正在切换到 GPU ${id}...`);
|
| 1651 |
+
try {
|
| 1652 |
+
const res = await fetch(`${BASE}/api/system/switch-gpu`, {
|
| 1653 |
+
method: 'POST',
|
| 1654 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1655 |
+
body: JSON.stringify({ gpu_id: parseInt(id) })
|
| 1656 |
+
});
|
| 1657 |
+
const data = await res.json();
|
| 1658 |
+
if (res.ok) {
|
| 1659 |
+
addLog(`✅ 已成功切换到 GPU ${id},模型将重新加载。`);
|
| 1660 |
+
listGpus(); // 重新获取列表以同步状态
|
| 1661 |
+
setTimeout(checkStatus, 1000);
|
| 1662 |
+
} else {
|
| 1663 |
+
throw new Error(data.error || "切换失败");
|
| 1664 |
+
}
|
| 1665 |
+
} catch (e) {
|
| 1666 |
+
addLog(`❌ GPU 切换失败: ${e.message}`);
|
| 1667 |
+
}
|
| 1668 |
+
}
|
| 1669 |
+
|
| 1670 |
+
function startProgressPolling() {
|
| 1671 |
+
if (pollInterval) clearInterval(pollInterval);
|
| 1672 |
+
pollInterval = setInterval(async () => {
|
| 1673 |
+
try {
|
| 1674 |
+
const res = await fetch(`${BASE}/api/generation/progress`);
|
| 1675 |
+
const d = await res.json();
|
| 1676 |
+
if (d.progress > 0) {
|
| 1677 |
+
const ph = String(d.phase || 'inference');
|
| 1678 |
+
const phaseKey = 'phase_' + ph;
|
| 1679 |
+
let phaseStr = _t(phaseKey);
|
| 1680 |
+
if (phaseStr === phaseKey) phaseStr = ph;
|
| 1681 |
+
|
| 1682 |
+
let stepLabel;
|
| 1683 |
+
if (d.current_step !== undefined && d.current_step !== null && d.total_steps) {
|
| 1684 |
+
stepLabel = `${d.current_step}/${d.total_steps} ${_t('progressStepUnit')}`;
|
| 1685 |
+
} else {
|
| 1686 |
+
stepLabel = `${d.progress}%`;
|
| 1687 |
+
}
|
| 1688 |
+
|
| 1689 |
+
document.getElementById('progress-fill').style.width = d.progress + "%";
|
| 1690 |
+
const loaderStep = document.getElementById('loader-step-text');
|
| 1691 |
+
const busyLine = `${_t('gpuBusyPrefix')}: ${stepLabel} [${phaseStr}]`;
|
| 1692 |
+
if (loaderStep) loaderStep.innerText = busyLine;
|
| 1693 |
+
else {
|
| 1694 |
+
const loadingTxt = document.getElementById('loading-txt');
|
| 1695 |
+
if (loadingTxt) loadingTxt.innerText = busyLine;
|
| 1696 |
+
}
|
| 1697 |
+
|
| 1698 |
+
// 同步更新历史缩略图卡片上的进度文字
|
| 1699 |
+
const cardStep = document.getElementById('loading-card-step');
|
| 1700 |
+
if (cardStep) cardStep.innerText = stepLabel;
|
| 1701 |
+
}
|
| 1702 |
+
} catch(e) {}
|
| 1703 |
+
}, 1000);
|
| 1704 |
+
}
|
| 1705 |
+
|
| 1706 |
+
function stopProgressPolling() {
|
| 1707 |
+
clearInterval(pollInterval);
|
| 1708 |
+
pollInterval = null;
|
| 1709 |
+
document.getElementById('progress-fill').style.width = "0%";
|
| 1710 |
+
// 移除渲染中的卡片(生成已结束)
|
| 1711 |
+
const lc = document.getElementById('current-loading-card');
|
| 1712 |
+
if (lc) lc.remove();
|
| 1713 |
+
}
|
| 1714 |
+
|
| 1715 |
+
function displayOutput(fileOrPath) {
|
| 1716 |
+
const img = document.getElementById('res-img');
|
| 1717 |
+
const vid = document.getElementById('res-video');
|
| 1718 |
+
const loader = document.getElementById('loading-txt');
|
| 1719 |
+
|
| 1720 |
+
// 关键BUG修复:切换前强制清除并停止现有视频和声音,避免后台继续播放
|
| 1721 |
+
if(player) {
|
| 1722 |
+
player.stop();
|
| 1723 |
+
} else {
|
| 1724 |
+
vid.pause();
|
| 1725 |
+
vid.removeAttribute('src');
|
| 1726 |
+
vid.load();
|
| 1727 |
+
}
|
| 1728 |
+
|
| 1729 |
+
let url = "";
|
| 1730 |
+
let fileName = fileOrPath;
|
| 1731 |
+
if (fileOrPath.indexOf('\\') !== -1 || fileOrPath.indexOf('/') !== -1) {
|
| 1732 |
+
url = `${BASE}/api/system/file?path=${encodeURIComponent(fileOrPath)}&t=${Date.now()}`;
|
| 1733 |
+
fileName = fileOrPath.split(/[\\/]/).pop();
|
| 1734 |
+
} else {
|
| 1735 |
+
const outInput = document.getElementById('global-out-dir');
|
| 1736 |
+
const globalDir = outInput ? outInput.value.replace(/\\/g, '/').replace(/\/$/, '') : "";
|
| 1737 |
+
if (globalDir && globalDir !== "") {
|
| 1738 |
+
url = `${BASE}/api/system/file?path=${encodeURIComponent(globalDir + '/' + fileOrPath)}&t=${Date.now()}`;
|
| 1739 |
+
} else {
|
| 1740 |
+
url = `${BASE}/outputs/${fileOrPath}?t=${Date.now()}`;
|
| 1741 |
+
}
|
| 1742 |
+
}
|
| 1743 |
+
|
| 1744 |
+
loader.style.display = "none";
|
| 1745 |
+
if (currentMode === 'image') {
|
| 1746 |
+
img.src = url;
|
| 1747 |
+
img.style.display = "block";
|
| 1748 |
+
addLog(`✅ 图像渲染成功: ${fileName}`);
|
| 1749 |
+
} else {
|
| 1750 |
+
document.getElementById('video-wrapper').style.display = "flex";
|
| 1751 |
+
|
| 1752 |
+
if(player) {
|
| 1753 |
+
player.source = {
|
| 1754 |
+
type: 'video',
|
| 1755 |
+
sources: [{ src: url, type: 'video/mp4' }]
|
| 1756 |
+
};
|
| 1757 |
+
player.play();
|
| 1758 |
+
} else {
|
| 1759 |
+
vid.src = url;
|
| 1760 |
+
}
|
| 1761 |
+
addLog(`✅ 视频渲染成功: ${fileName}`);
|
| 1762 |
+
}
|
| 1763 |
+
}
|
| 1764 |
+
|
| 1765 |
+
|
| 1766 |
+
|
| 1767 |
+
function addLog(msg) {
|
| 1768 |
+
const log = document.getElementById('log');
|
| 1769 |
+
if (!log) {
|
| 1770 |
+
console.log('[LTX]', msg);
|
| 1771 |
+
return;
|
| 1772 |
+
}
|
| 1773 |
+
const time = new Date().toLocaleTimeString();
|
| 1774 |
+
log.innerHTML += `<div style="margin-bottom:5px"> <span style="color:var(--text-dim)">[${time}]</span> ${msg}</div>`;
|
| 1775 |
+
log.scrollTop = log.scrollHeight;
|
| 1776 |
+
}
|
| 1777 |
+
|
| 1778 |
+
|
| 1779 |
+
// Force switch to video mode on load
|
| 1780 |
+
window.addEventListener('DOMContentLoaded', () => switchMode('video'));
|
| 1781 |
+
|
| 1782 |
+
|
| 1783 |
+
|
| 1784 |
+
|
| 1785 |
+
|
| 1786 |
+
|
| 1787 |
+
|
| 1788 |
+
|
| 1789 |
+
|
| 1790 |
+
|
| 1791 |
+
|
| 1792 |
+
|
| 1793 |
+
let currentHistoryPage = 1;
|
| 1794 |
+
let isLoadingHistory = false;
|
| 1795 |
+
/** 与上次成功渲染一致时,silent 轮询跳过整表 innerHTML,避免缩略图周期性重新加载 */
|
| 1796 |
+
let _historyListFingerprint = '';
|
| 1797 |
+
|
| 1798 |
+
function switchLibTab(tab) {
|
| 1799 |
+
document.getElementById('log-container').style.display = tab === 'log' ? 'flex' : 'none';
|
| 1800 |
+
const hw = document.getElementById('history-wrapper');
|
| 1801 |
+
if (hw) hw.style.display = tab === 'history' ? 'block' : 'none';
|
| 1802 |
+
|
| 1803 |
+
document.getElementById('tab-log').style.color = tab === 'log' ? 'var(--accent)' : 'var(--text-dim)';
|
| 1804 |
+
document.getElementById('tab-log').style.borderColor = tab === 'log' ? 'var(--accent)' : 'transparent';
|
| 1805 |
+
|
| 1806 |
+
document.getElementById('tab-history').style.color = tab === 'history' ? 'var(--accent)' : 'var(--text-dim)';
|
| 1807 |
+
document.getElementById('tab-history').style.borderColor = tab === 'history' ? 'var(--accent)' : 'transparent';
|
| 1808 |
+
|
| 1809 |
+
if (tab === 'history') {
|
| 1810 |
+
fetchHistory();
|
| 1811 |
+
}
|
| 1812 |
+
}
|
| 1813 |
+
|
| 1814 |
+
async function fetchHistory(isFirstLoad = false, silent = false) {
|
| 1815 |
+
if (isLoadingHistory) return;
|
| 1816 |
+
isLoadingHistory = true;
|
| 1817 |
+
|
| 1818 |
+
try {
|
| 1819 |
+
// 加载所有历史,不分页
|
| 1820 |
+
const res = await fetch(`${BASE}/api/system/history?page=1&limit=10000`);
|
| 1821 |
+
if (!res.ok) {
|
| 1822 |
+
isLoadingHistory = false;
|
| 1823 |
+
return;
|
| 1824 |
+
}
|
| 1825 |
+
const data = await res.json();
|
| 1826 |
+
|
| 1827 |
+
const validHistory = (data.history || []).filter(item => item && item.filename);
|
| 1828 |
+
const fingerprint = validHistory.length === 0
|
| 1829 |
+
? '__empty__'
|
| 1830 |
+
: validHistory.map(h => `${h.type}|${h.filename}`).join('\0');
|
| 1831 |
+
|
| 1832 |
+
if (silent && fingerprint === _historyListFingerprint) {
|
| 1833 |
+
return;
|
| 1834 |
+
}
|
| 1835 |
+
|
| 1836 |
+
const container = document.getElementById('history-container');
|
| 1837 |
+
if (!container) {
|
| 1838 |
+
return;
|
| 1839 |
+
}
|
| 1840 |
+
|
| 1841 |
+
let loadingCardHtml = "";
|
| 1842 |
+
const lc = document.getElementById('current-loading-card');
|
| 1843 |
+
if (lc && _isGeneratingFlag) {
|
| 1844 |
+
loadingCardHtml = lc.outerHTML;
|
| 1845 |
+
}
|
| 1846 |
+
|
| 1847 |
+
if (validHistory.length === 0) {
|
| 1848 |
+
container.innerHTML = loadingCardHtml;
|
| 1849 |
+
const newLcEmpty = document.getElementById('current-loading-card');
|
| 1850 |
+
if (newLcEmpty) newLcEmpty.onclick = showGeneratingView;
|
| 1851 |
+
_historyListFingerprint = fingerprint;
|
| 1852 |
+
return;
|
| 1853 |
+
}
|
| 1854 |
+
|
| 1855 |
+
container.innerHTML = loadingCardHtml;
|
| 1856 |
+
|
| 1857 |
+
const outInput = document.getElementById('global-out-dir');
|
| 1858 |
+
const globalDir = outInput ? outInput.value.replace(/\\/g, '/').replace(/\/$/, '') : "";
|
| 1859 |
+
|
| 1860 |
+
const cardsHtml = validHistory.map((item, index) => {
|
| 1861 |
+
const url = (globalDir && globalDir !== "")
|
| 1862 |
+
? `${BASE}/api/system/file?path=${encodeURIComponent(globalDir + '/' + item.filename)}`
|
| 1863 |
+
: `${BASE}/outputs/${item.filename}`;
|
| 1864 |
+
|
| 1865 |
+
const safeFilename = item.filename.replace(/'/g, "\\'").replace(/"/g, '\\"');
|
| 1866 |
+
const media = item.type === 'video'
|
| 1867 |
+
? `<video data-src="${url}#t=0.001" class="lazy-load history-thumb-media" muted loop preload="none" playsinline onmouseover="if(this.readyState >= 2) this.play()" onmouseout="this.pause()" style="pointer-events: none; object-fit: cover; width: 100%; height: 100%;"></video>`
|
| 1868 |
+
: `<img data-src="${url}" class="lazy-load history-thumb-media" alt="" style="object-fit: cover; width: 100%; height: 100%;">`;
|
| 1869 |
+
return `<div class="history-card" onclick="displayHistoryOutput('${safeFilename}', '${item.type}')">
|
| 1870 |
+
<div class="history-type-badge">${item.type === 'video' ? '🎬 VID' : '🎨 IMG'}</div>
|
| 1871 |
+
<button class="history-delete-btn" onclick="event.stopPropagation(); deleteHistoryItem('${safeFilename}', '${item.type}', this)">✕</button>
|
| 1872 |
+
${media}
|
| 1873 |
+
</div>`;
|
| 1874 |
+
}).join('');
|
| 1875 |
+
|
| 1876 |
+
container.insertAdjacentHTML('beforeend', cardsHtml);
|
| 1877 |
+
|
| 1878 |
+
// 重新绑定loading card点击事件
|
| 1879 |
+
const newLc = document.getElementById('current-loading-card');
|
| 1880 |
+
if (newLc) newLc.onclick = showGeneratingView;
|
| 1881 |
+
|
| 1882 |
+
// 加载可见的图片
|
| 1883 |
+
loadVisibleImages();
|
| 1884 |
+
_historyListFingerprint = fingerprint;
|
| 1885 |
+
} catch(e) {
|
| 1886 |
+
console.error("Failed to load history", e);
|
| 1887 |
+
} finally {
|
| 1888 |
+
isLoadingHistory = false;
|
| 1889 |
+
}
|
| 1890 |
+
}
|
| 1891 |
+
|
| 1892 |
+
async function deleteHistoryItem(filename, type, btn) {
|
| 1893 |
+
if (!confirm(`确定要删除 "${filename}" 吗?`)) return;
|
| 1894 |
+
|
| 1895 |
+
try {
|
| 1896 |
+
const res = await fetch(`${BASE}/api/system/delete-file`, {
|
| 1897 |
+
method: 'POST',
|
| 1898 |
+
headers: {'Content-Type': 'application/json'},
|
| 1899 |
+
body: JSON.stringify({filename: filename, type: type})
|
| 1900 |
+
});
|
| 1901 |
+
|
| 1902 |
+
if (res.ok) {
|
| 1903 |
+
// 删除成功后移除元素
|
| 1904 |
+
const card = btn.closest('.history-card');
|
| 1905 |
+
if (card) {
|
| 1906 |
+
card.remove();
|
| 1907 |
+
}
|
| 1908 |
+
} else {
|
| 1909 |
+
alert('删除失败');
|
| 1910 |
+
}
|
| 1911 |
+
} catch(e) {
|
| 1912 |
+
console.error('Delete failed', e);
|
| 1913 |
+
alert('删除失败');
|
| 1914 |
+
}
|
| 1915 |
+
}
|
| 1916 |
+
|
| 1917 |
+
function loadVisibleImages() {
|
| 1918 |
+
const hw = document.getElementById('history-wrapper');
|
| 1919 |
+
if (!hw) return;
|
| 1920 |
+
|
| 1921 |
+
const lazyMedias = document.querySelectorAll('#history-container .lazy-load');
|
| 1922 |
+
|
| 1923 |
+
// 每次只加载3个媒体元素(图片或视频)
|
| 1924 |
+
let loadedCount = 0;
|
| 1925 |
+
lazyMedias.forEach(media => {
|
| 1926 |
+
if (loadedCount >= 3) return;
|
| 1927 |
+
|
| 1928 |
+
const src = media.dataset.src;
|
| 1929 |
+
if (!src) return;
|
| 1930 |
+
|
| 1931 |
+
// 检查是否在可见区域附近
|
| 1932 |
+
const rect = media.getBoundingClientRect();
|
| 1933 |
+
const containerRect = hw.getBoundingClientRect();
|
| 1934 |
+
|
| 1935 |
+
if (rect.top < containerRect.bottom + 300 && rect.bottom > containerRect.top - 100) {
|
| 1936 |
+
let revealed = false;
|
| 1937 |
+
let thumbRevealTimer;
|
| 1938 |
+
const revealThumb = () => {
|
| 1939 |
+
if (revealed) return;
|
| 1940 |
+
revealed = true;
|
| 1941 |
+
if (thumbRevealTimer) clearTimeout(thumbRevealTimer);
|
| 1942 |
+
media.classList.add('history-thumb-ready');
|
| 1943 |
+
};
|
| 1944 |
+
thumbRevealTimer = setTimeout(revealThumb, 4000);
|
| 1945 |
+
|
| 1946 |
+
if (media.tagName === 'VIDEO') {
|
| 1947 |
+
media.addEventListener('loadeddata', revealThumb, { once: true });
|
| 1948 |
+
media.addEventListener('error', revealThumb, { once: true });
|
| 1949 |
+
} else {
|
| 1950 |
+
media.addEventListener('load', revealThumb, { once: true });
|
| 1951 |
+
media.addEventListener('error', revealThumb, { once: true });
|
| 1952 |
+
}
|
| 1953 |
+
|
| 1954 |
+
media.src = src;
|
| 1955 |
+
media.classList.remove('lazy-load');
|
| 1956 |
+
|
| 1957 |
+
if (media.tagName === 'VIDEO') {
|
| 1958 |
+
media.preload = 'metadata';
|
| 1959 |
+
if (media.readyState >= 2) revealThumb();
|
| 1960 |
+
} else if (media.complete && media.naturalWidth > 0) {
|
| 1961 |
+
revealThumb();
|
| 1962 |
+
}
|
| 1963 |
+
|
| 1964 |
+
loadedCount++;
|
| 1965 |
+
}
|
| 1966 |
+
});
|
| 1967 |
+
|
| 1968 |
+
// 继续检查直到没有更多媒体需要加载
|
| 1969 |
+
if (loadedCount > 0) {
|
| 1970 |
+
setTimeout(loadVisibleImages, 100);
|
| 1971 |
+
}
|
| 1972 |
+
}
|
| 1973 |
+
|
| 1974 |
+
// 监听history-wrapper的滚动事件来懒加载
|
| 1975 |
+
function initHistoryScrollListener() {
|
| 1976 |
+
const hw = document.getElementById('history-wrapper');
|
| 1977 |
+
if (!hw) return;
|
| 1978 |
+
|
| 1979 |
+
let scrollTimeout;
|
| 1980 |
+
hw.addEventListener('scroll', () => {
|
| 1981 |
+
if (scrollTimeout) clearTimeout(scrollTimeout);
|
| 1982 |
+
scrollTimeout = setTimeout(() => {
|
| 1983 |
+
loadVisibleImages();
|
| 1984 |
+
}, 100);
|
| 1985 |
+
});
|
| 1986 |
+
}
|
| 1987 |
+
|
| 1988 |
+
// 页面加载时初始化滚动监听
|
| 1989 |
+
window.addEventListener('DOMContentLoaded', () => {
|
| 1990 |
+
setTimeout(initHistoryScrollListener, 500);
|
| 1991 |
+
});
|
| 1992 |
+
|
| 1993 |
+
function displayHistoryOutput(file, type) {
|
| 1994 |
+
document.getElementById('res-img').style.display = 'none';
|
| 1995 |
+
document.getElementById('video-wrapper').style.display = 'none';
|
| 1996 |
+
|
| 1997 |
+
const mode = type === 'video' ? 'video' : 'image';
|
| 1998 |
+
switchMode(mode);
|
| 1999 |
+
displayOutput(file);
|
| 2000 |
+
}
|
| 2001 |
+
|
| 2002 |
+
window.addEventListener('DOMContentLoaded', () => {
|
| 2003 |
+
// Initialize Plyr Custom Video Component
|
| 2004 |
+
if(window.Plyr) {
|
| 2005 |
+
player = new Plyr('#res-video', {
|
| 2006 |
+
controls: [
|
| 2007 |
+
'play-large', 'play', 'progress', 'current-time',
|
| 2008 |
+
'mute', 'volume', 'fullscreen'
|
| 2009 |
+
],
|
| 2010 |
+
settings: [],
|
| 2011 |
+
loop: { active: true },
|
| 2012 |
+
autoplay: true
|
| 2013 |
+
});
|
| 2014 |
+
}
|
| 2015 |
+
|
| 2016 |
+
// Fetch current directory context to show in UI
|
| 2017 |
+
fetch(`${BASE}/api/system/get-dir`)
|
| 2018 |
+
.then((res) => res.json())
|
| 2019 |
+
.then((data) => {
|
| 2020 |
+
if (data && data.directory) {
|
| 2021 |
+
const outInput = document.getElementById('global-out-dir');
|
| 2022 |
+
if (outInput) outInput.value = data.directory;
|
| 2023 |
+
}
|
| 2024 |
+
})
|
| 2025 |
+
.catch((e) => console.error(e))
|
| 2026 |
+
.finally(() => {
|
| 2027 |
+
/* 先同步输出目录再拉历史,避免短时间内两次 fetchHistory 整表重绘导致缩略图闪两下 */
|
| 2028 |
+
switchLibTab('history');
|
| 2029 |
+
});
|
| 2030 |
+
|
| 2031 |
+
let historyRefreshInterval = null;
|
| 2032 |
+
function startHistoryAutoRefresh() {
|
| 2033 |
+
if (historyRefreshInterval) return;
|
| 2034 |
+
historyRefreshInterval = setInterval(() => {
|
| 2035 |
+
const hc = document.getElementById('history-container');
|
| 2036 |
+
if (hc && hc.offsetParent !== null && !_isGeneratingFlag) {
|
| 2037 |
+
fetchHistory(1, true);
|
| 2038 |
+
}
|
| 2039 |
+
}, 5000);
|
| 2040 |
+
}
|
| 2041 |
+
startHistoryAutoRefresh();
|
| 2042 |
+
});
|
LTX2.3/main.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import subprocess
|
| 4 |
+
import threading
|
| 5 |
+
import time
|
| 6 |
+
import socket
|
| 7 |
+
import logging
|
| 8 |
+
from fastapi import FastAPI
|
| 9 |
+
from fastapi.responses import FileResponse
|
| 10 |
+
from fastapi.staticfiles import StaticFiles
|
| 11 |
+
import uvicorn
|
| 12 |
+
|
| 13 |
+
# ============================================================
|
| 14 |
+
# 配置区 (动态路径适配与补丁挂载)
|
| 15 |
+
# ============================================================
|
| 16 |
+
def resolve_ltx_path():
|
| 17 |
+
import glob, tempfile, subprocess
|
| 18 |
+
sc_dir = os.path.join(os.getcwd(), "LTX_Shortcut")
|
| 19 |
+
os.makedirs(sc_dir, exist_ok=True)
|
| 20 |
+
lnk_files = glob.glob(os.path.join(sc_dir, "*.lnk"))
|
| 21 |
+
if not lnk_files:
|
| 22 |
+
print("\033[91m[ERROR] 未在 LTX_Shortcut 文件夹中找到快捷方式!\n请打开程序目录下的 LTX_Shortcut 文件夹,并将官方 LTX Desktop 的快捷方式复制进去后重试。\033[0m")
|
| 23 |
+
sys.exit(1)
|
| 24 |
+
|
| 25 |
+
lnk_path = lnk_files[0]
|
| 26 |
+
# 使用 VBScript 解析快捷方式,兼容所有 Windows 系统
|
| 27 |
+
vbs_code = f'''Set sh = CreateObject("WScript.Shell")\nSet obj = sh.CreateShortcut("{os.path.abspath(lnk_path)}")\nWScript.Echo obj.TargetPath'''
|
| 28 |
+
fd, vbs_path = tempfile.mkstemp(suffix='.vbs')
|
| 29 |
+
with os.fdopen(fd, 'w') as f:
|
| 30 |
+
f.write(vbs_code)
|
| 31 |
+
try:
|
| 32 |
+
out = subprocess.check_output(['cscript', '//nologo', vbs_path], stderr=subprocess.STDOUT)
|
| 33 |
+
target_exe = out.decode('ansi').strip()
|
| 34 |
+
finally:
|
| 35 |
+
os.remove(vbs_path)
|
| 36 |
+
|
| 37 |
+
if not target_exe or not os.path.exists(target_exe):
|
| 38 |
+
# 如果快捷方式解析失败,或者解析出来的是朋友电脑的路径(当前电脑不存在),自动全盘搜索默认路径
|
| 39 |
+
default_paths = [
|
| 40 |
+
os.path.join(os.environ.get("LOCALAPPDATA", ""), r"Programs\LTX Desktop\LTX Desktop.exe"),
|
| 41 |
+
r"C:\Program Files\LTX Desktop\LTX Desktop.exe",
|
| 42 |
+
r"D:\Program Files\LTX Desktop\LTX Desktop.exe",
|
| 43 |
+
r"E:\Program Files\LTX Desktop\LTX Desktop.exe"
|
| 44 |
+
]
|
| 45 |
+
found = False
|
| 46 |
+
for p in default_paths:
|
| 47 |
+
if os.path.exists(p):
|
| 48 |
+
target_exe = p
|
| 49 |
+
print(f"\033[96m[INFO] 自动检测到 LTX 原版安装路径: {p}\033[0m")
|
| 50 |
+
found = True
|
| 51 |
+
break
|
| 52 |
+
|
| 53 |
+
if not found:
|
| 54 |
+
print(f"\033[91m[ERROR] 未能找到原版 LTX Desktop 的安装路径!\033[0m")
|
| 55 |
+
print("请清理 LTX_Shortcut 文件夹,并将您当前电脑上真正的原版快捷方式重贴复制进去。")
|
| 56 |
+
sys.exit(1)
|
| 57 |
+
|
| 58 |
+
return os.path.dirname(target_exe)
|
| 59 |
+
|
| 60 |
+
USER_PROFILE = os.path.expanduser("~")
|
| 61 |
+
PYTHON_EXE = os.path.join(USER_PROFILE, r"AppData\Local\LTXDesktop\python\python.exe")
|
| 62 |
+
DATA_DIR = os.path.join(USER_PROFILE, r"AppData\Local\LTXDesktop")
|
| 63 |
+
|
| 64 |
+
# 1. 动态获取主安装路径
|
| 65 |
+
LTX_INSTALL_DIR = resolve_ltx_path()
|
| 66 |
+
BACKEND_DIR = os.path.join(LTX_INSTALL_DIR, r"resources\backend")
|
| 67 |
+
UI_FILE_NAME = "UI/index.html"
|
| 68 |
+
|
| 69 |
+
# 环境致命检测:如果官方 Python 还没解压释放,立刻强制中断整个程序
|
| 70 |
+
if not os.path.exists(PYTHON_EXE):
|
| 71 |
+
print(f"\n\033[1;41m [致命错误] 您的电脑上尚未配置好 LTX 的官方渲染核心框架! \033[0m")
|
| 72 |
+
print(f"\033[93m此应用仅是 UI 图形控制台,必需依赖原版软件环境才能生成。在 ({PYTHON_EXE}) 未找到运行引擎。\n")
|
| 73 |
+
print(">> 解决方案:\n1. 请先在您的电脑上正常安装【LTX Desktop 官方原版软件】。")
|
| 74 |
+
print("2. 必需:双击打开运行一次原版软件!(运行后原版软件会在后台自动释放环境)")
|
| 75 |
+
print("3. 把原版软件的快捷方式复制到本文档的 LTX_Shortcut 文件夹里面。")
|
| 76 |
+
print("4. 全部完成后,再重新启动本 run.bat 脚本即可!\033[0m\n")
|
| 77 |
+
os._exit(1)
|
| 78 |
+
|
| 79 |
+
# 2. 从目录读取改动过的 Python 文件 (热修复拦截器)
|
| 80 |
+
PATCHES_DIR = os.path.join(os.getcwd(), "patches")
|
| 81 |
+
os.makedirs(PATCHES_DIR, exist_ok=True)
|
| 82 |
+
|
| 83 |
+
# 3. 默认输出定向至程序根目录
|
| 84 |
+
LOCAL_OUTPUTS = os.path.join(os.getcwd(), "outputs")
|
| 85 |
+
os.makedirs(LOCAL_OUTPUTS, exist_ok=True)
|
| 86 |
+
|
| 87 |
+
# 强制注入自定义输出录至 LTX 缓存数据中
|
| 88 |
+
os.makedirs(DATA_DIR, exist_ok=True)
|
| 89 |
+
with open(os.path.join(DATA_DIR, "custom_dir.txt"), 'w', encoding='utf-8') as f:
|
| 90 |
+
f.write(LOCAL_OUTPUTS)
|
| 91 |
+
|
| 92 |
+
os.environ["LTX_APP_DATA_DIR"] = DATA_DIR
|
| 93 |
+
|
| 94 |
+
# 将 patches 目录优先级提升,做到 Python 无损替换
|
| 95 |
+
os.environ["PYTHONPATH"] = f"{PATCHES_DIR};{BACKEND_DIR}"
|
| 96 |
+
|
| 97 |
+
def get_lan_ip():
|
| 98 |
+
try:
|
| 99 |
+
host_name = socket.gethostname()
|
| 100 |
+
_, _, ip_list = socket.gethostbyname_ex(host_name)
|
| 101 |
+
|
| 102 |
+
candidates = []
|
| 103 |
+
for ip in ip_list:
|
| 104 |
+
if ip.startswith("192.168."):
|
| 105 |
+
return ip
|
| 106 |
+
elif ip.startswith("10.") or (ip.startswith("172.") and 16 <= int(ip.split('.')[1]) <= 31):
|
| 107 |
+
candidates.append(ip)
|
| 108 |
+
|
| 109 |
+
if candidates:
|
| 110 |
+
return candidates[0]
|
| 111 |
+
|
| 112 |
+
# Fallback to the default socket routing approach if no obvious LAN IP found
|
| 113 |
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
| 114 |
+
s.connect(("8.8.8.8", 80))
|
| 115 |
+
ip = s.getsockname()[0]
|
| 116 |
+
s.close()
|
| 117 |
+
return ip
|
| 118 |
+
except:
|
| 119 |
+
return "127.0.0.1"
|
| 120 |
+
|
| 121 |
+
LAN_IP = get_lan_ip()
|
| 122 |
+
|
| 123 |
+
# ============================================================
|
| 124 |
+
# 服务启动逻辑
|
| 125 |
+
# ============================================================
|
| 126 |
+
def check_port_in_use(port):
|
| 127 |
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
| 128 |
+
return s.connect_ex(('127.0.0.1', port)) == 0
|
| 129 |
+
|
| 130 |
+
def launch_backend():
|
| 131 |
+
"""启动核心引擎 - 监听 0.0.0.0 确保局域网可调"""
|
| 132 |
+
if check_port_in_use(3000):
|
| 133 |
+
print(f"\n\033[1;41m [致命错误] 3000 端口已被占用,无法启动核心引擎! \033[0m")
|
| 134 |
+
print("\033[93m>> 绝大多数情况下,这是因为【官方原版 LTX Desktop】正在您的电脑后台运行。\033[0m")
|
| 135 |
+
print(">> 冲突会导致显存爆炸。请检查右下角系统托盘图标,右键完全退出官方软件。")
|
| 136 |
+
print(">> 退出后重新双击 run.bat 启动本程序!\n")
|
| 137 |
+
os._exit(1)
|
| 138 |
+
|
| 139 |
+
print(f"\033[96m[CORE] 核心引擎正在启动...\033[0m")
|
| 140 |
+
# 只开启重要级别的 Python 应用层日志,去除无用的 HTTP 刷屏
|
| 141 |
+
import logging as _logging
|
| 142 |
+
_logging.basicConfig(
|
| 143 |
+
level=_logging.INFO,
|
| 144 |
+
format="[%(asctime)s] %(levelname)s %(name)s: %(message)s",
|
| 145 |
+
datefmt="%H:%M:%S",
|
| 146 |
+
force=True
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
# 构建绝对无损的环境拦截器:防止其他电脑被 cwd 劫持加载原版文件
|
| 150 |
+
launcher_code = f"""
|
| 151 |
+
import sys
|
| 152 |
+
import os
|
| 153 |
+
|
| 154 |
+
patch_dir = r"{PATCHES_DIR}"
|
| 155 |
+
backend_dir = r"{BACKEND_DIR}"
|
| 156 |
+
|
| 157 |
+
# 防御性清除:强行剥离所有的默认 backend_dir 引用
|
| 158 |
+
sys.path = [p for p in sys.path if p and os.path.normpath(p) != os.path.normpath(backend_dir)]
|
| 159 |
+
sys.path = [p for p in sys.path if p and p != "." and p != ""]
|
| 160 |
+
|
| 161 |
+
# 绝对插队注入:优先搜索 PATCHES_DIR
|
| 162 |
+
sys.path.insert(0, patch_dir)
|
| 163 |
+
sys.path.insert(1, backend_dir)
|
| 164 |
+
|
| 165 |
+
import uvicorn
|
| 166 |
+
from ltx2_server import app
|
| 167 |
+
|
| 168 |
+
if __name__ == '__main__':
|
| 169 |
+
uvicorn.run(app, host="0.0.0.0", port=3000, log_level="info", access_log=False)
|
| 170 |
+
"""
|
| 171 |
+
launcher_path = os.path.join(PATCHES_DIR, "launcher.py")
|
| 172 |
+
with open(launcher_path, "w", encoding="utf-8") as f:
|
| 173 |
+
f.write(launcher_code)
|
| 174 |
+
|
| 175 |
+
cmd = [PYTHON_EXE, launcher_path]
|
| 176 |
+
env = os.environ.copy()
|
| 177 |
+
result = subprocess.run(cmd, cwd=BACKEND_DIR, env=env)
|
| 178 |
+
if result.returncode != 0:
|
| 179 |
+
print(f"\n\033[1;41m [致命错误] 核心引擎异常崩溃退出! (Exit Code: {result.returncode})\033[0m")
|
| 180 |
+
print(">> 请检查上述终端报错信息。确认显卡驱动是否正常。")
|
| 181 |
+
os._exit(1)
|
| 182 |
+
|
| 183 |
+
ui_app = FastAPI()
|
| 184 |
+
# 已移除存在安全隐患的静态资源挂载目录
|
| 185 |
+
|
| 186 |
+
@ui_app.get("/")
|
| 187 |
+
async def serve_index():
|
| 188 |
+
return FileResponse(os.path.join(os.getcwd(), UI_FILE_NAME))
|
| 189 |
+
|
| 190 |
+
@ui_app.get("/index.css")
|
| 191 |
+
async def serve_css():
|
| 192 |
+
return FileResponse(os.path.join(os.getcwd(), "UI/index.css"))
|
| 193 |
+
|
| 194 |
+
@ui_app.get("/index.js")
|
| 195 |
+
async def serve_js():
|
| 196 |
+
return FileResponse(os.path.join(os.getcwd(), "UI/index.js"))
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
@ui_app.get("/i18n.js")
|
| 200 |
+
async def serve_i18n():
|
| 201 |
+
return FileResponse(os.path.join(os.getcwd(), "UI/i18n.js"))
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def launch_ui_server():
|
| 205 |
+
print(f"\033[92m[UI] 工作站已就绪!\033[0m")
|
| 206 |
+
print(f"\033[92m[LOCAL] 本机访问: http://127.0.0.1:4000\033[0m")
|
| 207 |
+
print(f"\033[93m[WIFI] 局域网访问: http://{LAN_IP}:4000\033[0m")
|
| 208 |
+
|
| 209 |
+
# 彻底压制 WinError 10054 (客户端强制断开) 的底层警告报错
|
| 210 |
+
if sys.platform == 'win32':
|
| 211 |
+
# Uvicorn 内部会拉起循环,所以只能通过底层 Logging Filter 拦截控制台噪音
|
| 212 |
+
class UvicornAsyncioNoiseFilter(logging.Filter):
|
| 213 |
+
"""压掉客户端断开、Win Proactor 管道收尾等无害 asyncio 控制台刷屏。"""
|
| 214 |
+
|
| 215 |
+
def filter(self, record):
|
| 216 |
+
if record.name != "asyncio":
|
| 217 |
+
return True
|
| 218 |
+
msg = record.getMessage()
|
| 219 |
+
if "_call_connection_lost" in msg or "_ProactorBasePipeTransport" in msg:
|
| 220 |
+
return False
|
| 221 |
+
if hasattr(record, "exc_info") and record.exc_info:
|
| 222 |
+
exc_type, exc_value, _ = record.exc_info
|
| 223 |
+
if isinstance(exc_value, ConnectionResetError) and getattr(
|
| 224 |
+
exc_value, "winerror", None
|
| 225 |
+
) == 10054:
|
| 226 |
+
return False
|
| 227 |
+
if "10054" in msg and "ConnectionResetError" in msg:
|
| 228 |
+
return False
|
| 229 |
+
return True
|
| 230 |
+
|
| 231 |
+
logging.getLogger("asyncio").addFilter(UvicornAsyncioNoiseFilter())
|
| 232 |
+
|
| 233 |
+
uvicorn.run(ui_app, host="0.0.0.0", port=4000, log_level="warning", access_log=False)
|
| 234 |
+
|
| 235 |
+
if __name__ == "__main__":
|
| 236 |
+
os.system('cls' if os.name == 'nt' else 'clear')
|
| 237 |
+
print("\033[1;97;44m LTX-2 CINEMATIC WORKSTATION | NETWORK ENABLED \033[0m\n")
|
| 238 |
+
|
| 239 |
+
threading.Thread(target=launch_backend, daemon=True).start()
|
| 240 |
+
|
| 241 |
+
# 强制校验 3000 端口是否存活
|
| 242 |
+
print("\033[93m[SYS] 正在等待内部核心 3000 端口启动...\033[0m")
|
| 243 |
+
backend_ready = False
|
| 244 |
+
for _ in range(30):
|
| 245 |
+
try:
|
| 246 |
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
| 247 |
+
if s.connect_ex(('127.0.0.1', 3000)) == 0:
|
| 248 |
+
backend_ready = True
|
| 249 |
+
break
|
| 250 |
+
except Exception:
|
| 251 |
+
pass
|
| 252 |
+
time.sleep(1)
|
| 253 |
+
|
| 254 |
+
if backend_ready:
|
| 255 |
+
print("\033[92m[SYS] 3000 端口已通过连通性握手验证!后端装载成功。\033[0m")
|
| 256 |
+
else:
|
| 257 |
+
print("\033[1;41m [崩坏警告] 等待 30 秒后,3000 端口依然无法连通! \033[0m")
|
| 258 |
+
print(">> Uvicorn 可能在后台陷入了死锁,或者被防火墙拦截,前端大概率将无法连接到后端!")
|
| 259 |
+
print(">> 请检查上方是否有 Python 报错。\n")
|
| 260 |
+
|
| 261 |
+
try:
|
| 262 |
+
launch_ui_server()
|
| 263 |
+
except KeyboardInterrupt:
|
| 264 |
+
sys.exit(0)
|
LTX2.3/patches/API模式问题修复说明.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LTX 本地显卡模式修复
|
| 2 |
+
|
| 3 |
+
## 问题描述
|
| 4 |
+
系统强制使用 FAL API 生成图片,即使本地有 GPU 可用。
|
| 5 |
+
|
| 6 |
+
## 原因
|
| 7 |
+
LTX 强制要求 GPU 有 31GB VRAM 才会使用本地显卡,低于此值会强制走 API 模式。
|
| 8 |
+
|
| 9 |
+
## 修复方法
|
| 10 |
+
|
| 11 |
+
### 方法一:自动替换(推荐)
|
| 12 |
+
运行程序后,patches 目录中的文件会自动替换原版文件。
|
| 13 |
+
|
| 14 |
+
### 方法二:手动替换
|
| 15 |
+
|
| 16 |
+
#### 1. 修改 VRAM 阈值
|
| 17 |
+
- **原文件**: `C:\Program Files\LTX Desktop\resources\backend\runtime_config\runtime_policy.py`
|
| 18 |
+
- **找到** (第16行):
|
| 19 |
+
```python
|
| 20 |
+
return vram_gb < 31
|
| 21 |
+
```
|
| 22 |
+
- **改为**:
|
| 23 |
+
```python
|
| 24 |
+
return vram_gb < 6
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
#### 2. 清空无效 API Key
|
| 28 |
+
- **原文件**: `C:\Users\Administrator\AppData\Local\LTXDesktop\settings.json`
|
| 29 |
+
- **找到**:
|
| 30 |
+
```json
|
| 31 |
+
"fal_api_key": "12123",
|
| 32 |
+
```
|
| 33 |
+
- **改为**:
|
| 34 |
+
```json
|
| 35 |
+
"fal_api_key": "",
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
## 说明
|
| 39 |
+
- VRAM 阈值改为 6GB,意味着 6GB 及以上显存都会使用本地显卡
|
| 40 |
+
- 清空 fal_api_key 避免系统误判为已配置 API
|
| 41 |
+
- 修改后重启程序即可生效
|
LTX2.3/patches/__pycache__/api_types.cpython-313.pyc
ADDED
|
Binary file (13.3 kB). View file
|
|
|
LTX2.3/patches/__pycache__/app_factory.cpython-313.pyc
ADDED
|
Binary file (97.8 kB). View file
|
|
|
LTX2.3/patches/__pycache__/app_factory.cpython-314.pyc
ADDED
|
Binary file (59.3 kB). View file
|
|
|
LTX2.3/patches/__pycache__/keep_models_runtime.cpython-313.pyc
ADDED
|
Binary file (888 Bytes). View file
|
|
|
LTX2.3/patches/__pycache__/lora_build_hook.cpython-313.pyc
ADDED
|
Binary file (4.77 kB). View file
|
|
|
LTX2.3/patches/__pycache__/lora_injection.cpython-313.pyc
ADDED
|
Binary file (5.19 kB). View file
|
|
|
LTX2.3/patches/__pycache__/low_vram_runtime.cpython-313.pyc
ADDED
|
Binary file (7.44 kB). View file
|
|
|
LTX2.3/patches/api_types.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic request/response models and TypedDicts for ltx2_server."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Literal, NamedTuple, TypeAlias, TypedDict
|
| 6 |
+
from typing import Annotated
|
| 7 |
+
|
| 8 |
+
from pydantic import BaseModel, Field, StringConstraints
|
| 9 |
+
|
| 10 |
+
NonEmptyPrompt = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
| 11 |
+
ModelFileType = Literal[
|
| 12 |
+
"checkpoint",
|
| 13 |
+
"upsampler",
|
| 14 |
+
"distilled_lora",
|
| 15 |
+
"ic_lora",
|
| 16 |
+
"depth_processor",
|
| 17 |
+
"person_detector",
|
| 18 |
+
"pose_processor",
|
| 19 |
+
"text_encoder",
|
| 20 |
+
"zit",
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class ImageConditioningInput(NamedTuple):
|
| 25 |
+
"""Image conditioning triplet used by all video pipelines."""
|
| 26 |
+
|
| 27 |
+
path: str
|
| 28 |
+
frame_idx: int
|
| 29 |
+
strength: float
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ============================================================
|
| 33 |
+
# TypedDicts for module-level state globals
|
| 34 |
+
# ============================================================
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class GenerationState(TypedDict):
|
| 38 |
+
id: str | None
|
| 39 |
+
cancelled: bool
|
| 40 |
+
result: str | list[str] | None
|
| 41 |
+
error: str | None
|
| 42 |
+
status: str # "idle" | "running" | "complete" | "cancelled" | "error"
|
| 43 |
+
phase: str
|
| 44 |
+
progress: int
|
| 45 |
+
current_step: int
|
| 46 |
+
total_steps: int
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
JsonObject: TypeAlias = dict[str, object]
|
| 50 |
+
VideoCameraMotion = Literal[
|
| 51 |
+
"none",
|
| 52 |
+
"dolly_in",
|
| 53 |
+
"dolly_out",
|
| 54 |
+
"dolly_left",
|
| 55 |
+
"dolly_right",
|
| 56 |
+
"jib_up",
|
| 57 |
+
"jib_down",
|
| 58 |
+
"static",
|
| 59 |
+
"focus_shift",
|
| 60 |
+
]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ============================================================
|
| 64 |
+
# Response Models
|
| 65 |
+
# ============================================================
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class ModelStatusItem(BaseModel):
|
| 69 |
+
id: str
|
| 70 |
+
name: str
|
| 71 |
+
loaded: bool
|
| 72 |
+
downloaded: bool
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class GpuTelemetry(BaseModel):
|
| 76 |
+
name: str
|
| 77 |
+
vram: int
|
| 78 |
+
vramUsed: int
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class HealthResponse(BaseModel):
|
| 82 |
+
status: str
|
| 83 |
+
models_loaded: bool
|
| 84 |
+
active_model: str | None
|
| 85 |
+
gpu_info: GpuTelemetry
|
| 86 |
+
sage_attention: bool
|
| 87 |
+
models_status: list[ModelStatusItem]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class GpuInfoResponse(BaseModel):
|
| 91 |
+
cuda_available: bool
|
| 92 |
+
mps_available: bool = False
|
| 93 |
+
gpu_available: bool = False
|
| 94 |
+
gpu_name: str | None
|
| 95 |
+
vram_gb: int | None
|
| 96 |
+
gpu_info: GpuTelemetry
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class RuntimePolicyResponse(BaseModel):
|
| 100 |
+
force_api_generations: bool
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class GenerationProgressResponse(BaseModel):
|
| 104 |
+
status: str
|
| 105 |
+
phase: str
|
| 106 |
+
progress: int
|
| 107 |
+
currentStep: int | None
|
| 108 |
+
totalSteps: int | None
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class ModelInfo(BaseModel):
|
| 112 |
+
id: str
|
| 113 |
+
name: str
|
| 114 |
+
description: str
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class ModelFileStatus(BaseModel):
|
| 118 |
+
id: ModelFileType
|
| 119 |
+
name: str
|
| 120 |
+
description: str
|
| 121 |
+
downloaded: bool
|
| 122 |
+
size: int
|
| 123 |
+
expected_size: int
|
| 124 |
+
required: bool = True
|
| 125 |
+
is_folder: bool = False
|
| 126 |
+
optional_reason: str | None = None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class TextEncoderStatus(BaseModel):
|
| 130 |
+
downloaded: bool
|
| 131 |
+
size_bytes: int
|
| 132 |
+
size_gb: float
|
| 133 |
+
expected_size_gb: float
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class ModelsStatusResponse(BaseModel):
|
| 137 |
+
models: list[ModelFileStatus]
|
| 138 |
+
all_downloaded: bool
|
| 139 |
+
total_size: int
|
| 140 |
+
downloaded_size: int
|
| 141 |
+
total_size_gb: float
|
| 142 |
+
downloaded_size_gb: float
|
| 143 |
+
models_path: str
|
| 144 |
+
has_api_key: bool
|
| 145 |
+
text_encoder_status: TextEncoderStatus
|
| 146 |
+
use_local_text_encoder: bool
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class DownloadProgressResponse(BaseModel):
|
| 150 |
+
status: str
|
| 151 |
+
current_downloading_file: ModelFileType | None
|
| 152 |
+
current_file_progress: int
|
| 153 |
+
total_progress: int
|
| 154 |
+
total_downloaded_bytes: int
|
| 155 |
+
expected_total_bytes: int
|
| 156 |
+
completed_files: set[ModelFileType]
|
| 157 |
+
all_files: set[ModelFileType]
|
| 158 |
+
error: str | None
|
| 159 |
+
speed_mbps: int
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
class SuggestGapPromptResponse(BaseModel):
|
| 163 |
+
status: str = "success"
|
| 164 |
+
suggested_prompt: str
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
class GenerateVideoResponse(BaseModel):
|
| 168 |
+
status: str
|
| 169 |
+
video_path: str | None = None
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
class GenerateImageResponse(BaseModel):
|
| 173 |
+
status: str
|
| 174 |
+
image_paths: list[str] | None = None
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
class CancelResponse(BaseModel):
|
| 178 |
+
status: str
|
| 179 |
+
id: str | None = None
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
class RetakeResponse(BaseModel):
|
| 183 |
+
status: str
|
| 184 |
+
video_path: str | None = None
|
| 185 |
+
result: JsonObject | None = None
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
class IcLoraExtractResponse(BaseModel):
|
| 189 |
+
conditioning: str
|
| 190 |
+
original: str
|
| 191 |
+
conditioning_type: Literal["canny", "depth"]
|
| 192 |
+
frame_time: float
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
class IcLoraGenerateResponse(BaseModel):
|
| 196 |
+
status: str
|
| 197 |
+
video_path: str | None = None
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
class ModelDownloadStartResponse(BaseModel):
|
| 201 |
+
status: str
|
| 202 |
+
message: str | None = None
|
| 203 |
+
sessionId: str | None = None
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
class TextEncoderDownloadResponse(BaseModel):
|
| 207 |
+
status: str
|
| 208 |
+
message: str | None = None
|
| 209 |
+
sessionId: str | None = None
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
class StatusResponse(BaseModel):
|
| 213 |
+
status: str
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
class ErrorResponse(BaseModel):
|
| 217 |
+
error: str
|
| 218 |
+
message: str | None = None
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
# ============================================================
|
| 222 |
+
# Request Models
|
| 223 |
+
# ============================================================
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
class GenerateVideoRequest(BaseModel):
|
| 227 |
+
prompt: NonEmptyPrompt
|
| 228 |
+
resolution: str = "512p"
|
| 229 |
+
model: str = "fast"
|
| 230 |
+
cameraMotion: VideoCameraMotion = "none"
|
| 231 |
+
negativePrompt: str = ""
|
| 232 |
+
duration: str = "2"
|
| 233 |
+
fps: str = "24"
|
| 234 |
+
audio: str = "false"
|
| 235 |
+
imagePath: str | None = None
|
| 236 |
+
audioPath: str | None = None
|
| 237 |
+
startFramePath: str | None = None
|
| 238 |
+
endFramePath: str | None = None
|
| 239 |
+
# 多张图单次推理:latent 时间轴多锚点(Comfy LTXVAddGuideMulti 思路);≥2 路径时优先于首尾帧
|
| 240 |
+
keyframePaths: list[str] | None = None
|
| 241 |
+
# 与 keyframePaths 等长、0.1–1.0;不传则按 Comfy 类工作流自动降低中间帧强度,减轻闪烁
|
| 242 |
+
keyframeStrengths: list[float] | None = None
|
| 243 |
+
# 与 keyframePaths 等长,单位秒,落在 [0, 整段时长];全提供时按时间映射 latent,否则仍自动均分
|
| 244 |
+
keyframeTimes: list[float] | None = None
|
| 245 |
+
aspectRatio: Literal["16:9", "9:16"] = "16:9"
|
| 246 |
+
modelPath: str | None = None
|
| 247 |
+
loraPath: str | None = None
|
| 248 |
+
loraStrength: float = 1.0
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
class GenerateImageRequest(BaseModel):
|
| 252 |
+
prompt: NonEmptyPrompt
|
| 253 |
+
width: int = 1024
|
| 254 |
+
height: int = 1024
|
| 255 |
+
numSteps: int = 4
|
| 256 |
+
numImages: int = 1
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def _default_model_types() -> set[ModelFileType]:
|
| 260 |
+
return set()
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
class ModelDownloadRequest(BaseModel):
|
| 264 |
+
modelTypes: set[ModelFileType] = Field(default_factory=_default_model_types)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
class RequiredModelsResponse(BaseModel):
|
| 268 |
+
modelTypes: list[ModelFileType]
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
class SuggestGapPromptRequest(BaseModel):
|
| 272 |
+
beforePrompt: str = ""
|
| 273 |
+
afterPrompt: str = ""
|
| 274 |
+
beforeFrame: str | None = None
|
| 275 |
+
afterFrame: str | None = None
|
| 276 |
+
gapDuration: float = 5
|
| 277 |
+
mode: str = "t2v"
|
| 278 |
+
inputImage: str | None = None
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
class RetakeRequest(BaseModel):
|
| 282 |
+
video_path: str
|
| 283 |
+
start_time: float = 0
|
| 284 |
+
duration: float = 0
|
| 285 |
+
prompt: str = ""
|
| 286 |
+
mode: str = "replace_video_only"
|
| 287 |
+
width: int | None = None
|
| 288 |
+
height: int | None = None
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
class IcLoraExtractRequest(BaseModel):
|
| 292 |
+
video_path: str
|
| 293 |
+
conditioning_type: Literal["canny", "depth"] = "canny"
|
| 294 |
+
frame_time: float = 0
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
class IcLoraImageInput(BaseModel):
|
| 298 |
+
path: str
|
| 299 |
+
frame: int = 0
|
| 300 |
+
strength: float = 1.0
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def _default_ic_lora_images() -> list[IcLoraImageInput]:
|
| 304 |
+
return []
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
class IcLoraGenerateRequest(BaseModel):
|
| 308 |
+
video_path: str
|
| 309 |
+
conditioning_type: Literal["canny", "depth"]
|
| 310 |
+
prompt: NonEmptyPrompt
|
| 311 |
+
conditioning_strength: float = 1.0
|
| 312 |
+
num_inference_steps: int = 30
|
| 313 |
+
cfg_guidance_scale: float = 1.0
|
| 314 |
+
negative_prompt: str = ""
|
| 315 |
+
images: list[IcLoraImageInput] = Field(default_factory=_default_ic_lora_images)
|
LTX2.3/patches/app_factory.py
ADDED
|
@@ -0,0 +1,2288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI app factory decoupled from runtime bootstrap side effects."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import base64
|
| 6 |
+
import hmac
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
# 防 OOM 与显存碎片化补丁:在 torch 初始化之前注入环境变量
|
| 10 |
+
os.environ["PYTORCH_ALLOC_CONF"] = "expandable_segments:True"
|
| 11 |
+
import torch # 提升到顶层导入
|
| 12 |
+
from collections.abc import Awaitable, Callable
|
| 13 |
+
from typing import TYPE_CHECKING
|
| 14 |
+
from pathlib import Path # 必须导入,用于处理 Windows 路径
|
| 15 |
+
|
| 16 |
+
from fastapi import FastAPI, Request, UploadFile, File
|
| 17 |
+
from fastapi.exceptions import RequestValidationError
|
| 18 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 19 |
+
from fastapi.responses import JSONResponse
|
| 20 |
+
from pydantic import ConfigDict
|
| 21 |
+
from fastapi.staticfiles import StaticFiles # 必须导入,用于挂载静态目录
|
| 22 |
+
from starlette.responses import Response as StarletteResponse
|
| 23 |
+
import shutil
|
| 24 |
+
import tempfile
|
| 25 |
+
import time
|
| 26 |
+
from api_types import (
|
| 27 |
+
GenerateVideoRequest,
|
| 28 |
+
GenerateVideoResponse,
|
| 29 |
+
ImageConditioningInput,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
from _routes._errors import HTTPError
|
| 33 |
+
from _routes.generation import router as generation_router
|
| 34 |
+
from _routes.health import router as health_router
|
| 35 |
+
from _routes.ic_lora import router as ic_lora_router
|
| 36 |
+
from _routes.image_gen import router as image_gen_router
|
| 37 |
+
from _routes.models import router as models_router
|
| 38 |
+
from _routes.suggest_gap_prompt import router as suggest_gap_prompt_router
|
| 39 |
+
from _routes.retake import router as retake_router
|
| 40 |
+
from _routes.runtime_policy import router as runtime_policy_router
|
| 41 |
+
from _routes.settings import router as settings_router
|
| 42 |
+
from logging_policy import log_http_error, log_unhandled_exception
|
| 43 |
+
from state import init_state_service
|
| 44 |
+
|
| 45 |
+
if TYPE_CHECKING:
|
| 46 |
+
from app_handler import AppHandler
|
| 47 |
+
|
| 48 |
+
# 跨域配置:允许所有来源,解决本地网页调用限制
|
| 49 |
+
DEFAULT_ALLOWED_ORIGINS: list[str] = ["*"]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _ltx_desktop_config_dir() -> Path:
|
| 53 |
+
p = (
|
| 54 |
+
Path(os.environ.get("LOCALAPPDATA", os.path.expanduser("~/AppData/Local")))
|
| 55 |
+
/ "LTXDesktop"
|
| 56 |
+
)
|
| 57 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 58 |
+
return p.resolve()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _extend_generate_video_request_model() -> None:
|
| 62 |
+
"""Keep custom video fields working across upstream request-model changes."""
|
| 63 |
+
annotations = dict(getattr(GenerateVideoRequest, "__annotations__", {}))
|
| 64 |
+
changed = False
|
| 65 |
+
|
| 66 |
+
for field_name, ann in (
|
| 67 |
+
("startFramePath", str | None),
|
| 68 |
+
("endFramePath", str | None),
|
| 69 |
+
("keyframePaths", list[str] | None),
|
| 70 |
+
("keyframeStrengths", list[float] | None),
|
| 71 |
+
("keyframeTimes", list[float] | None),
|
| 72 |
+
):
|
| 73 |
+
if field_name not in annotations:
|
| 74 |
+
annotations[field_name] = ann
|
| 75 |
+
setattr(GenerateVideoRequest, field_name, None)
|
| 76 |
+
changed = True
|
| 77 |
+
|
| 78 |
+
if changed:
|
| 79 |
+
GenerateVideoRequest.__annotations__ = annotations
|
| 80 |
+
|
| 81 |
+
existing_config = dict(getattr(GenerateVideoRequest, "model_config", {}) or {})
|
| 82 |
+
if existing_config.get("extra") != "allow":
|
| 83 |
+
existing_config["extra"] = "allow"
|
| 84 |
+
GenerateVideoRequest.model_config = ConfigDict(**existing_config)
|
| 85 |
+
changed = True
|
| 86 |
+
|
| 87 |
+
if changed:
|
| 88 |
+
GenerateVideoRequest.model_rebuild(force=True)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def create_app(
|
| 92 |
+
*,
|
| 93 |
+
handler: "AppHandler",
|
| 94 |
+
allowed_origins: list[str] | None = None,
|
| 95 |
+
title: str = "LTX-2 Video Generation Server",
|
| 96 |
+
auth_token: str = "",
|
| 97 |
+
admin_token: str = "",
|
| 98 |
+
) -> FastAPI:
|
| 99 |
+
"""Create a configured FastAPI app bound to the provided handler."""
|
| 100 |
+
init_state_service(handler)
|
| 101 |
+
_extend_generate_video_request_model()
|
| 102 |
+
|
| 103 |
+
app = FastAPI(title=title)
|
| 104 |
+
app.state.admin_token = admin_token # type: ignore[attr-defined]
|
| 105 |
+
|
| 106 |
+
# 彻底压制 WinError 10054 (客户端强制断开) 的底层警告报错
|
| 107 |
+
import sys, asyncio
|
| 108 |
+
|
| 109 |
+
if sys.platform == "win32":
|
| 110 |
+
try:
|
| 111 |
+
loop = asyncio.get_event_loop()
|
| 112 |
+
|
| 113 |
+
def silence_winerror_10054(loop, context):
|
| 114 |
+
exc = context.get("exception")
|
| 115 |
+
if (
|
| 116 |
+
isinstance(exc, ConnectionResetError)
|
| 117 |
+
and getattr(exc, "winerror", None) == 10054
|
| 118 |
+
):
|
| 119 |
+
return
|
| 120 |
+
loop.default_exception_handler(context)
|
| 121 |
+
|
| 122 |
+
loop.set_exception_handler(silence_winerror_10054)
|
| 123 |
+
except Exception:
|
| 124 |
+
pass
|
| 125 |
+
|
| 126 |
+
# --- 核心修复:对准 LTX 真正的输出目录 (AppData) ---
|
| 127 |
+
def get_dynamic_output_path():
|
| 128 |
+
base_dir = (
|
| 129 |
+
Path(os.environ.get("LOCALAPPDATA", os.path.expanduser("~/AppData/Local")))
|
| 130 |
+
/ "LTXDesktop"
|
| 131 |
+
).resolve()
|
| 132 |
+
config_file = base_dir / "custom_dir.txt"
|
| 133 |
+
if config_file.exists():
|
| 134 |
+
try:
|
| 135 |
+
custom_dir = config_file.read_text(encoding="utf-8").strip()
|
| 136 |
+
if custom_dir:
|
| 137 |
+
p = Path(custom_dir)
|
| 138 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 139 |
+
return p
|
| 140 |
+
except Exception:
|
| 141 |
+
pass
|
| 142 |
+
default_dir = base_dir / "outputs"
|
| 143 |
+
default_dir.mkdir(parents=True, exist_ok=True)
|
| 144 |
+
return default_dir
|
| 145 |
+
|
| 146 |
+
actual_output_path = get_dynamic_output_path()
|
| 147 |
+
handler.config.outputs_dir = actual_output_path
|
| 148 |
+
|
| 149 |
+
pl = handler.pipelines
|
| 150 |
+
pl._pipeline_signature = None
|
| 151 |
+
from low_vram_runtime import (
|
| 152 |
+
install_low_vram_on_pipelines,
|
| 153 |
+
install_low_vram_pipeline_hooks,
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
install_low_vram_on_pipelines(handler)
|
| 157 |
+
install_low_vram_pipeline_hooks(pl)
|
| 158 |
+
# LoRA:在 SingleGPUModelBuilder.build 时合并权重(model_ledger 不足以让桌面版 DiT 吃到 LoRA)
|
| 159 |
+
from lora_build_hook import install_lora_build_hook
|
| 160 |
+
|
| 161 |
+
install_lora_build_hook()
|
| 162 |
+
|
| 163 |
+
upload_tmp_path = actual_output_path / "uploads"
|
| 164 |
+
|
| 165 |
+
# 如果文件夹不存在则创建,防止挂载失败
|
| 166 |
+
if not actual_output_path.exists():
|
| 167 |
+
actual_output_path.mkdir(parents=True, exist_ok=True)
|
| 168 |
+
if not upload_tmp_path.exists():
|
| 169 |
+
upload_tmp_path.mkdir(parents=True, exist_ok=True)
|
| 170 |
+
|
| 171 |
+
# 挂载静态服务:将该目录映射到 http://127.0.0.1:3000/outputs
|
| 172 |
+
app.mount(
|
| 173 |
+
"/outputs", StaticFiles(directory=str(actual_output_path)), name="outputs"
|
| 174 |
+
)
|
| 175 |
+
# -----------------------------------------------
|
| 176 |
+
|
| 177 |
+
# 配置 CORS
|
| 178 |
+
app.add_middleware(
|
| 179 |
+
CORSMiddleware,
|
| 180 |
+
allow_origins=allowed_origins or DEFAULT_ALLOWED_ORIGINS,
|
| 181 |
+
allow_methods=["*"],
|
| 182 |
+
allow_headers=["*"],
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
# === [全局隔离补丁] ===
|
| 186 |
+
# 强制将每一个新的 HTTP 线程/协程请求的默认显卡都强绑定到用户选定的设备上
|
| 187 |
+
@app.middleware("http")
|
| 188 |
+
async def _sync_gpu_middleware(
|
| 189 |
+
request: Request,
|
| 190 |
+
call_next: Callable[[Request], Awaitable[StarletteResponse]],
|
| 191 |
+
) -> StarletteResponse:
|
| 192 |
+
import torch
|
| 193 |
+
|
| 194 |
+
if (
|
| 195 |
+
torch.cuda.is_available()
|
| 196 |
+
and getattr(handler.config.device, "type", "") == "cuda"
|
| 197 |
+
):
|
| 198 |
+
idx = handler.config.device.index
|
| 199 |
+
if idx is not None:
|
| 200 |
+
# 能够强行夺取那些底层写死了 cuda:0 而忽略 config.device 的第三方库
|
| 201 |
+
torch.cuda.set_device(idx)
|
| 202 |
+
return await call_next(request)
|
| 203 |
+
|
| 204 |
+
# 认证中间件
|
| 205 |
+
@app.middleware("http")
|
| 206 |
+
async def _auth_middleware(
|
| 207 |
+
request: Request,
|
| 208 |
+
call_next: Callable[[Request], Awaitable[StarletteResponse]],
|
| 209 |
+
) -> StarletteResponse:
|
| 210 |
+
# 关键修复:如果是获取生成的图片,直接放行,不检查 Token
|
| 211 |
+
if (
|
| 212 |
+
request.url.path.startswith("/outputs")
|
| 213 |
+
or request.url.path == "/api/system/upload-image"
|
| 214 |
+
):
|
| 215 |
+
return await call_next(request)
|
| 216 |
+
|
| 217 |
+
if not auth_token:
|
| 218 |
+
return await call_next(request)
|
| 219 |
+
if request.method == "OPTIONS":
|
| 220 |
+
return await call_next(request)
|
| 221 |
+
|
| 222 |
+
def _token_matches(candidate: str) -> bool:
|
| 223 |
+
return hmac.compare_digest(candidate, auth_token)
|
| 224 |
+
|
| 225 |
+
# WebSocket 认证
|
| 226 |
+
if request.headers.get("upgrade", "").lower() == "websocket":
|
| 227 |
+
if _token_matches(request.query_params.get("token", "")):
|
| 228 |
+
return await call_next(request)
|
| 229 |
+
return JSONResponse(status_code=401, content={"error": "Unauthorized"})
|
| 230 |
+
|
| 231 |
+
# HTTP 认证 (Bearer/Basic)
|
| 232 |
+
auth_header = request.headers.get("authorization", "")
|
| 233 |
+
if auth_header.startswith("Bearer ") and _token_matches(auth_header[7:]):
|
| 234 |
+
return await call_next(request)
|
| 235 |
+
if auth_header.startswith("Basic "):
|
| 236 |
+
try:
|
| 237 |
+
decoded = base64.b64decode(auth_header[6:]).decode()
|
| 238 |
+
_, _, password = decoded.partition(":")
|
| 239 |
+
if _token_matches(password):
|
| 240 |
+
return await call_next(request)
|
| 241 |
+
except Exception:
|
| 242 |
+
pass
|
| 243 |
+
return JSONResponse(status_code=401, content={"error": "Unauthorized"})
|
| 244 |
+
|
| 245 |
+
# 异常处理逻辑
|
| 246 |
+
_FALLBACK = "An unexpected error occurred"
|
| 247 |
+
|
| 248 |
+
async def _route_http_error_handler(
|
| 249 |
+
request: Request, exc: Exception
|
| 250 |
+
) -> JSONResponse:
|
| 251 |
+
if isinstance(exc, HTTPError):
|
| 252 |
+
log_http_error(request, exc)
|
| 253 |
+
return JSONResponse(
|
| 254 |
+
status_code=exc.status_code, content={"error": exc.detail or _FALLBACK}
|
| 255 |
+
)
|
| 256 |
+
return JSONResponse(status_code=500, content={"error": str(exc) or _FALLBACK})
|
| 257 |
+
|
| 258 |
+
async def _validation_error_handler(
|
| 259 |
+
request: Request, exc: Exception
|
| 260 |
+
) -> JSONResponse:
|
| 261 |
+
if isinstance(exc, RequestValidationError):
|
| 262 |
+
return JSONResponse(
|
| 263 |
+
status_code=422, content={"error": str(exc) or _FALLBACK}
|
| 264 |
+
)
|
| 265 |
+
return JSONResponse(status_code=422, content={"error": str(exc) or _FALLBACK})
|
| 266 |
+
|
| 267 |
+
async def _route_generic_error_handler(
|
| 268 |
+
request: Request, exc: Exception
|
| 269 |
+
) -> JSONResponse:
|
| 270 |
+
log_unhandled_exception(request, exc)
|
| 271 |
+
return JSONResponse(status_code=500, content={"error": str(exc) or _FALLBACK})
|
| 272 |
+
|
| 273 |
+
app.add_exception_handler(RequestValidationError, _validation_error_handler)
|
| 274 |
+
app.add_exception_handler(HTTPError, _route_http_error_handler)
|
| 275 |
+
app.add_exception_handler(Exception, _route_generic_error_handler)
|
| 276 |
+
|
| 277 |
+
# --- 系统功能接口 ---
|
| 278 |
+
@app.post("/api/system/clear-gpu")
|
| 279 |
+
async def route_clear_gpu():
|
| 280 |
+
try:
|
| 281 |
+
import torch
|
| 282 |
+
import gc
|
| 283 |
+
import asyncio
|
| 284 |
+
|
| 285 |
+
# 1. 尝试终止任务并重置运行状态
|
| 286 |
+
if getattr(handler.generation, "is_generation_running", lambda: False)():
|
| 287 |
+
try:
|
| 288 |
+
handler.generation.cancel_generation()
|
| 289 |
+
except Exception:
|
| 290 |
+
pass
|
| 291 |
+
await asyncio.sleep(0.5)
|
| 292 |
+
|
| 293 |
+
# 暴力重置死锁状态
|
| 294 |
+
if hasattr(handler.generation, "_generation_id"):
|
| 295 |
+
handler.generation._generation_id = None
|
| 296 |
+
if hasattr(handler.generation, "_is_generating"):
|
| 297 |
+
handler.generation._is_generating = False
|
| 298 |
+
|
| 299 |
+
# 2. 强制卸载模型: 临时屏蔽底层锁定器
|
| 300 |
+
try:
|
| 301 |
+
mock_swapped = False
|
| 302 |
+
orig_running = None
|
| 303 |
+
if hasattr(handler.pipelines, "_generation_service"):
|
| 304 |
+
orig_running = (
|
| 305 |
+
handler.pipelines._generation_service.is_generation_running
|
| 306 |
+
)
|
| 307 |
+
handler.pipelines._generation_service.is_generation_running = (
|
| 308 |
+
lambda: False
|
| 309 |
+
)
|
| 310 |
+
mock_swapped = True
|
| 311 |
+
try:
|
| 312 |
+
from keep_models_runtime import force_unload_gpu_pipeline
|
| 313 |
+
|
| 314 |
+
force_unload_gpu_pipeline(handler.pipelines)
|
| 315 |
+
finally:
|
| 316 |
+
if mock_swapped:
|
| 317 |
+
handler.pipelines._generation_service.is_generation_running = (
|
| 318 |
+
orig_running
|
| 319 |
+
)
|
| 320 |
+
except Exception as e:
|
| 321 |
+
print(f"Force unload warning: {e}")
|
| 322 |
+
|
| 323 |
+
# 3. 深度清理
|
| 324 |
+
gc.collect()
|
| 325 |
+
if torch.cuda.is_available():
|
| 326 |
+
torch.cuda.empty_cache()
|
| 327 |
+
torch.cuda.ipc_collect()
|
| 328 |
+
try:
|
| 329 |
+
handler.pipelines._pipeline_signature = None
|
| 330 |
+
except Exception:
|
| 331 |
+
pass
|
| 332 |
+
return {
|
| 333 |
+
"status": "success",
|
| 334 |
+
"message": "GPU memory cleared and models unloaded",
|
| 335 |
+
}
|
| 336 |
+
except Exception as e:
|
| 337 |
+
return JSONResponse(status_code=500, content={"error": str(e)})
|
| 338 |
+
|
| 339 |
+
@app.get("/api/system/low-vram-mode")
|
| 340 |
+
async def route_get_low_vram_mode():
|
| 341 |
+
enabled = bool(getattr(handler.pipelines, "low_vram_mode", False))
|
| 342 |
+
return {"enabled": enabled}
|
| 343 |
+
|
| 344 |
+
@app.post("/api/system/low-vram-mode")
|
| 345 |
+
async def route_set_low_vram_mode(request: Request):
|
| 346 |
+
try:
|
| 347 |
+
data = await request.json()
|
| 348 |
+
except Exception:
|
| 349 |
+
data = {}
|
| 350 |
+
enabled = bool(data.get("enabled", False))
|
| 351 |
+
from low_vram_runtime import (
|
| 352 |
+
apply_low_vram_config_tweaks,
|
| 353 |
+
write_low_vram_pref,
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
handler.pipelines.low_vram_mode = enabled
|
| 357 |
+
write_low_vram_pref(enabled)
|
| 358 |
+
if enabled:
|
| 359 |
+
apply_low_vram_config_tweaks(handler)
|
| 360 |
+
return {"status": "success", "enabled": enabled}
|
| 361 |
+
|
| 362 |
+
@app.post("/api/system/reset-state")
|
| 363 |
+
async def route_reset_state():
|
| 364 |
+
"""轻量级状态重置:只清除 generation 状态锁,不卸载 GPU 管线。
|
| 365 |
+
在每次新渲染开始前由前端调用,确保后端状态干净可用。"""
|
| 366 |
+
try:
|
| 367 |
+
gen = handler.generation
|
| 368 |
+
# 强制清除所有可能导致 is_generation_running() 返回 True 的标志
|
| 369 |
+
for attr in (
|
| 370 |
+
"_is_generating",
|
| 371 |
+
"_generation_id",
|
| 372 |
+
"_cancelled",
|
| 373 |
+
"_is_cancelled",
|
| 374 |
+
):
|
| 375 |
+
if hasattr(gen, attr):
|
| 376 |
+
if attr in ("_is_generating", "_cancelled", "_is_cancelled"):
|
| 377 |
+
setattr(gen, attr, False)
|
| 378 |
+
else:
|
| 379 |
+
setattr(gen, attr, None)
|
| 380 |
+
# 某些实现用 threading.Event
|
| 381 |
+
for attr in ("_cancel_event",):
|
| 382 |
+
if hasattr(gen, attr):
|
| 383 |
+
try:
|
| 384 |
+
getattr(gen, attr).clear()
|
| 385 |
+
except Exception:
|
| 386 |
+
pass
|
| 387 |
+
print("[reset-state] Generation state has been reset cleanly.")
|
| 388 |
+
return {"status": "success", "message": "Generation state reset"}
|
| 389 |
+
except Exception as e:
|
| 390 |
+
import traceback
|
| 391 |
+
|
| 392 |
+
traceback.print_exc()
|
| 393 |
+
return JSONResponse(status_code=500, content={"error": str(e)})
|
| 394 |
+
|
| 395 |
+
@app.post("/api/system/set-dir")
|
| 396 |
+
async def route_set_dir(request: Request):
|
| 397 |
+
try:
|
| 398 |
+
data = await request.json()
|
| 399 |
+
new_dir = data.get("directory", "").strip()
|
| 400 |
+
base_dir = (
|
| 401 |
+
Path(
|
| 402 |
+
os.environ.get(
|
| 403 |
+
"LOCALAPPDATA", os.path.expanduser("~/AppData/Local")
|
| 404 |
+
)
|
| 405 |
+
)
|
| 406 |
+
/ "LTXDesktop"
|
| 407 |
+
).resolve()
|
| 408 |
+
config_file = base_dir / "custom_dir.txt"
|
| 409 |
+
if new_dir:
|
| 410 |
+
p = Path(new_dir)
|
| 411 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 412 |
+
config_file.write_text(new_dir, encoding="utf-8")
|
| 413 |
+
else:
|
| 414 |
+
if config_file.exists():
|
| 415 |
+
config_file.unlink()
|
| 416 |
+
# 立即更新全局 config 控制
|
| 417 |
+
handler.config.outputs_dir = get_dynamic_output_path()
|
| 418 |
+
return {"status": "success", "directory": str(get_dynamic_output_path())}
|
| 419 |
+
except Exception as e:
|
| 420 |
+
return JSONResponse(status_code=500, content={"error": str(e)})
|
| 421 |
+
|
| 422 |
+
@app.get("/api/system/get-dir")
|
| 423 |
+
async def route_get_dir():
|
| 424 |
+
return {"status": "success", "directory": str(get_dynamic_output_path())}
|
| 425 |
+
|
| 426 |
+
@app.get("/api/system/browse-dir")
|
| 427 |
+
async def route_browse_dir():
|
| 428 |
+
try:
|
| 429 |
+
import subprocess
|
| 430 |
+
|
| 431 |
+
# 强制将对话框置顶层:通过 STA 线程 + Topmost 属性,避免被窗口锥入后台
|
| 432 |
+
ps_script = (
|
| 433 |
+
"[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null;"
|
| 434 |
+
"[System.Reflection.Assembly]::LoadWithPartialName('System.Drawing') | Out-Null;"
|
| 435 |
+
"$f = New-Object System.Windows.Forms.FolderBrowserDialog;"
|
| 436 |
+
"$f.Description = '\u9009\u62e9 LTX \u89c6\u9891\u548c\u56fe\u50cf\u751f\u6210\u7684\u5168\u5c40\u8f93\u51fa\u76ee\u5f55';"
|
| 437 |
+
"$f.ShowNewFolderButton = $true;"
|
| 438 |
+
# 创建一个雐形助手窗口作为 parent 确保对话框在最顶层
|
| 439 |
+
"$owner = New-Object System.Windows.Forms.Form;"
|
| 440 |
+
"$owner.TopMost = $true;"
|
| 441 |
+
"$owner.StartPosition = 'CenterScreen';"
|
| 442 |
+
"$owner.Size = New-Object System.Drawing.Size(1, 1);"
|
| 443 |
+
"$owner.Show();"
|
| 444 |
+
"$owner.BringToFront();"
|
| 445 |
+
"$owner.Focus();"
|
| 446 |
+
"if ($f.ShowDialog($owner) -eq 'OK') { echo $f.SelectedPath };"
|
| 447 |
+
"$owner.Dispose();"
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
def run_ps():
|
| 451 |
+
process = subprocess.Popen(
|
| 452 |
+
["powershell", "-STA", "-NoProfile", "-Command", ps_script],
|
| 453 |
+
stdout=subprocess.PIPE,
|
| 454 |
+
stderr=subprocess.PIPE,
|
| 455 |
+
text=True,
|
| 456 |
+
# 移除 CREATE_NO_WINDOW 以允许 UI 线程正常弹出
|
| 457 |
+
)
|
| 458 |
+
stdout, _ = process.communicate()
|
| 459 |
+
return stdout.strip()
|
| 460 |
+
|
| 461 |
+
from starlette.concurrency import run_in_threadpool
|
| 462 |
+
|
| 463 |
+
selected_dir = await run_in_threadpool(run_ps)
|
| 464 |
+
return {"status": "success", "directory": selected_dir}
|
| 465 |
+
except Exception as e:
|
| 466 |
+
return JSONResponse(status_code=500, content={"error": str(e)})
|
| 467 |
+
|
| 468 |
+
_LORA_SCAN_SUFFIXES = {".safetensors", ".ckpt", ".pt", ".bin"}
|
| 469 |
+
|
| 470 |
+
@app.get("/api/loras")
|
| 471 |
+
async def route_list_loras(request: Request):
|
| 472 |
+
"""扫描本地 LoRA 目录;前端「设置」里填的路径依赖此接口(官方路由可能不存在)。"""
|
| 473 |
+
raw = (request.query_params.get("dir") or "").strip()
|
| 474 |
+
if raw.startswith("True"):
|
| 475 |
+
raw = raw[4:].lstrip()
|
| 476 |
+
raw = raw.strip().strip('"').strip("'")
|
| 477 |
+
if not raw:
|
| 478 |
+
# 默认规则:LoRA 路径 = 默认 models_dir 下的 `loras` 子目录(规则写死)
|
| 479 |
+
try:
|
| 480 |
+
md = getattr(handler.pipelines, "models_dir", None)
|
| 481 |
+
if md:
|
| 482 |
+
from pathlib import Path
|
| 483 |
+
|
| 484 |
+
root = Path(str(md)).expanduser().resolve() / "loras"
|
| 485 |
+
raw = str(root)
|
| 486 |
+
except Exception:
|
| 487 |
+
raw = ""
|
| 488 |
+
if not raw:
|
| 489 |
+
return {"loras": [], "loras_dir": "", "models_dir": ""}
|
| 490 |
+
|
| 491 |
+
root = Path(raw).expanduser()
|
| 492 |
+
try:
|
| 493 |
+
root = root.resolve()
|
| 494 |
+
except OSError:
|
| 495 |
+
pass
|
| 496 |
+
|
| 497 |
+
if not root.is_dir():
|
| 498 |
+
return {
|
| 499 |
+
"loras": [],
|
| 500 |
+
"error": "not_a_directory",
|
| 501 |
+
"message": "路径不是文件夹或不存在,请检查拼写、盘符与权限",
|
| 502 |
+
"path": str(root),
|
| 503 |
+
"loras_dir": str(root),
|
| 504 |
+
"models_dir": str(root.parent),
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
found: list[dict[str, str]] = []
|
| 508 |
+
try:
|
| 509 |
+
for dirpath, _dirnames, filenames in os.walk(root):
|
| 510 |
+
for fn in filenames:
|
| 511 |
+
suf = Path(fn).suffix.lower()
|
| 512 |
+
if suf in _LORA_SCAN_SUFFIXES:
|
| 513 |
+
full = Path(dirpath) / fn
|
| 514 |
+
if full.is_file():
|
| 515 |
+
try:
|
| 516 |
+
resolved = str(full.resolve())
|
| 517 |
+
except OSError:
|
| 518 |
+
resolved = str(full)
|
| 519 |
+
found.append({"name": fn, "path": resolved})
|
| 520 |
+
except OSError as e:
|
| 521 |
+
return JSONResponse(
|
| 522 |
+
status_code=400,
|
| 523 |
+
content={
|
| 524 |
+
"loras": [],
|
| 525 |
+
"error": "scan_failed",
|
| 526 |
+
"message": str(e),
|
| 527 |
+
"path": str(root),
|
| 528 |
+
},
|
| 529 |
+
)
|
| 530 |
+
|
| 531 |
+
found.sort(key=lambda x: x["name"].lower())
|
| 532 |
+
return {
|
| 533 |
+
"loras": found,
|
| 534 |
+
"loras_dir": str(root),
|
| 535 |
+
"models_dir": str(root.parent),
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
_MODEL_SCAN_SUFFIXES = {
|
| 539 |
+
".safetensors",
|
| 540 |
+
".ckpt",
|
| 541 |
+
".pt",
|
| 542 |
+
".bin",
|
| 543 |
+
".pth",
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
@app.get("/api/models")
|
| 547 |
+
async def route_list_models(request: Request):
|
| 548 |
+
"""扫描本地 checkpoint 目录;需在官方 models_router 之前注册以覆盖空列表行为。"""
|
| 549 |
+
raw = (request.query_params.get("dir") or "").strip()
|
| 550 |
+
if raw.startswith("True"):
|
| 551 |
+
raw = raw[4:].lstrip()
|
| 552 |
+
raw = raw.strip().strip('"').strip("'")
|
| 553 |
+
|
| 554 |
+
if not raw:
|
| 555 |
+
try:
|
| 556 |
+
md = getattr(handler.pipelines, "models_dir", None)
|
| 557 |
+
if md is None or not str(md).strip():
|
| 558 |
+
return {"models": []}
|
| 559 |
+
root = Path(str(md)).expanduser().resolve()
|
| 560 |
+
except OSError:
|
| 561 |
+
return {"models": []}
|
| 562 |
+
if not root.is_dir():
|
| 563 |
+
return {"models": []}
|
| 564 |
+
else:
|
| 565 |
+
root = Path(raw).expanduser()
|
| 566 |
+
try:
|
| 567 |
+
root = root.resolve()
|
| 568 |
+
except OSError:
|
| 569 |
+
pass
|
| 570 |
+
|
| 571 |
+
if not root.is_dir():
|
| 572 |
+
return {
|
| 573 |
+
"models": [],
|
| 574 |
+
"error": "not_a_directory",
|
| 575 |
+
"message": "路径不是文件夹或不存在,请检查拼写、盘符与权限",
|
| 576 |
+
"path": str(root),
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
found: list[dict[str, str]] = []
|
| 580 |
+
try:
|
| 581 |
+
for dirpath, _dirnames, filenames in os.walk(root):
|
| 582 |
+
for fn in filenames:
|
| 583 |
+
suf = Path(fn).suffix.lower()
|
| 584 |
+
if suf in _MODEL_SCAN_SUFFIXES:
|
| 585 |
+
full = Path(dirpath) / fn
|
| 586 |
+
if full.is_file():
|
| 587 |
+
try:
|
| 588 |
+
resolved = str(full.resolve())
|
| 589 |
+
except OSError:
|
| 590 |
+
resolved = str(full)
|
| 591 |
+
found.append({"name": fn, "path": resolved})
|
| 592 |
+
except OSError as e:
|
| 593 |
+
return JSONResponse(
|
| 594 |
+
status_code=400,
|
| 595 |
+
content={
|
| 596 |
+
"models": [],
|
| 597 |
+
"error": "scan_failed",
|
| 598 |
+
"message": str(e),
|
| 599 |
+
"path": str(root),
|
| 600 |
+
},
|
| 601 |
+
)
|
| 602 |
+
|
| 603 |
+
found.sort(key=lambda x: x["name"].lower())
|
| 604 |
+
return {"models": found}
|
| 605 |
+
|
| 606 |
+
@app.get("/api/system/file")
|
| 607 |
+
async def route_serve_file(path: str):
|
| 608 |
+
from fastapi.responses import FileResponse
|
| 609 |
+
|
| 610 |
+
if os.path.exists(path):
|
| 611 |
+
return FileResponse(path)
|
| 612 |
+
return JSONResponse(status_code=404, content={"error": "File not found"})
|
| 613 |
+
|
| 614 |
+
@app.get("/api/system/list-gpus")
|
| 615 |
+
async def route_list_gpus():
|
| 616 |
+
try:
|
| 617 |
+
import torch
|
| 618 |
+
|
| 619 |
+
gpus = []
|
| 620 |
+
if torch.cuda.is_available():
|
| 621 |
+
current_idx = 0
|
| 622 |
+
dev = getattr(handler.config, "device", None)
|
| 623 |
+
if dev is not None and getattr(dev, "index", None) is not None:
|
| 624 |
+
current_idx = dev.index
|
| 625 |
+
for i in range(torch.cuda.device_count()):
|
| 626 |
+
try:
|
| 627 |
+
name = torch.cuda.get_device_name(i)
|
| 628 |
+
except Exception:
|
| 629 |
+
name = f"GPU {i}"
|
| 630 |
+
try:
|
| 631 |
+
vram_bytes = torch.cuda.get_device_properties(i).total_memory
|
| 632 |
+
vram_gb = vram_bytes / (1024**3)
|
| 633 |
+
vram_mb = vram_bytes / (1024**2)
|
| 634 |
+
except Exception:
|
| 635 |
+
vram_gb = 0.0
|
| 636 |
+
vram_mb = 0
|
| 637 |
+
gpus.append(
|
| 638 |
+
{
|
| 639 |
+
"id": i,
|
| 640 |
+
"name": name,
|
| 641 |
+
"vram": f"{vram_gb:.1f} GB",
|
| 642 |
+
"vram_mb": int(vram_mb),
|
| 643 |
+
"active": (i == current_idx),
|
| 644 |
+
}
|
| 645 |
+
)
|
| 646 |
+
return {"status": "success", "gpus": gpus}
|
| 647 |
+
except Exception as e:
|
| 648 |
+
return JSONResponse(status_code=500, content={"error": str(e)})
|
| 649 |
+
|
| 650 |
+
@app.post("/api/system/switch-gpu")
|
| 651 |
+
async def route_switch_gpu(request: Request):
|
| 652 |
+
try:
|
| 653 |
+
import torch
|
| 654 |
+
import gc
|
| 655 |
+
import asyncio
|
| 656 |
+
|
| 657 |
+
data = await request.json()
|
| 658 |
+
gpu_id = data.get("gpu_id")
|
| 659 |
+
|
| 660 |
+
if (
|
| 661 |
+
gpu_id is None
|
| 662 |
+
or not torch.cuda.is_available()
|
| 663 |
+
or gpu_id >= torch.cuda.device_count()
|
| 664 |
+
):
|
| 665 |
+
return JSONResponse(
|
| 666 |
+
status_code=400, content={"error": "Invalid GPU ID"}
|
| 667 |
+
)
|
| 668 |
+
|
| 669 |
+
# 先尝试终止任何可能的卡死任务
|
| 670 |
+
if getattr(handler.generation, "is_generation_running", lambda: False)():
|
| 671 |
+
try:
|
| 672 |
+
handler.generation.cancel_generation()
|
| 673 |
+
except Exception:
|
| 674 |
+
pass
|
| 675 |
+
await asyncio.sleep(0.5)
|
| 676 |
+
if hasattr(handler.generation, "_generation_id"):
|
| 677 |
+
handler.generation._generation_id = None
|
| 678 |
+
if hasattr(handler.generation, "_is_generating"):
|
| 679 |
+
handler.generation._is_generating = False
|
| 680 |
+
|
| 681 |
+
# 1. 卸载当前 GPU 上的模型: 临时屏蔽底层锁定器
|
| 682 |
+
try:
|
| 683 |
+
mock_swapped = False
|
| 684 |
+
orig_running = None
|
| 685 |
+
if hasattr(handler.pipelines, "_generation_service"):
|
| 686 |
+
orig_running = (
|
| 687 |
+
handler.pipelines._generation_service.is_generation_running
|
| 688 |
+
)
|
| 689 |
+
handler.pipelines._generation_service.is_generation_running = (
|
| 690 |
+
lambda: False
|
| 691 |
+
)
|
| 692 |
+
mock_swapped = True
|
| 693 |
+
try:
|
| 694 |
+
from keep_models_runtime import force_unload_gpu_pipeline
|
| 695 |
+
|
| 696 |
+
force_unload_gpu_pipeline(handler.pipelines)
|
| 697 |
+
finally:
|
| 698 |
+
if mock_swapped:
|
| 699 |
+
handler.pipelines._generation_service.is_generation_running = (
|
| 700 |
+
orig_running
|
| 701 |
+
)
|
| 702 |
+
except Exception:
|
| 703 |
+
pass
|
| 704 |
+
gc.collect()
|
| 705 |
+
torch.cuda.empty_cache()
|
| 706 |
+
|
| 707 |
+
try:
|
| 708 |
+
handler.pipelines._pipeline_signature = None
|
| 709 |
+
except Exception:
|
| 710 |
+
pass
|
| 711 |
+
|
| 712 |
+
# 2. 切换全局设备配置
|
| 713 |
+
new_device = torch.device(f"cuda:{gpu_id}")
|
| 714 |
+
handler.config.device = new_device
|
| 715 |
+
|
| 716 |
+
# 3. 核心修复:设置当前进程的默认 CUDA 设备
|
| 717 |
+
# 这会影响到 torch.cuda.current_device() 和后续的模型加载
|
| 718 |
+
torch.cuda.set_device(gpu_id)
|
| 719 |
+
|
| 720 |
+
# 针对底层库可能直接读取 CUDA_VISIBLE_DEVICES 的情况
|
| 721 |
+
# 注意:torch 初始化后修改此变量不一定生效,但对某些库可能有引导作用
|
| 722 |
+
os.environ["CUDA_VISIBLE_DEVICES"] = str(gpu_id)
|
| 723 |
+
|
| 724 |
+
# 4. 【核心修复】同步更新 TextEncoder 的设备指针
|
| 725 |
+
# 根本原因: LTXTextEncoder.self.device 在初始化时硬绑定了旧 GPU,
|
| 726 |
+
# 切换设备后 text context 仍在旧 GPU 上,与已迁移到新 GPU 的
|
| 727 |
+
# Transformer 产生 "cuda:0 and cuda:1" 设备不一致冲突。
|
| 728 |
+
try:
|
| 729 |
+
te_state = None
|
| 730 |
+
# 尝试多种路径访问 text_encoder 状态
|
| 731 |
+
if hasattr(handler, "state") and hasattr(handler.state, "text_encoder"):
|
| 732 |
+
te_state = handler.state.text_encoder
|
| 733 |
+
elif hasattr(handler, "_state") and hasattr(
|
| 734 |
+
handler._state, "text_encoder"
|
| 735 |
+
):
|
| 736 |
+
te_state = handler._state.text_encoder
|
| 737 |
+
|
| 738 |
+
if te_state is not None:
|
| 739 |
+
# 4a. 更新 LTXTextEncoder 服务自身的 device 属性
|
| 740 |
+
if hasattr(te_state, "service") and hasattr(
|
| 741 |
+
te_state.service, "device"
|
| 742 |
+
):
|
| 743 |
+
te_state.service.device = new_device
|
| 744 |
+
print(f"[TextEncoder] device updated to {new_device}")
|
| 745 |
+
|
| 746 |
+
# 4b. 将缓存的 encoder 权重迁移到 CPU,下次推理时再按新设备重加载
|
| 747 |
+
if (
|
| 748 |
+
hasattr(te_state, "cached_encoder")
|
| 749 |
+
and te_state.cached_encoder is not None
|
| 750 |
+
):
|
| 751 |
+
try:
|
| 752 |
+
te_state.cached_encoder.to(torch.device("cpu"))
|
| 753 |
+
except Exception:
|
| 754 |
+
pass
|
| 755 |
+
te_state.cached_encoder = None
|
| 756 |
+
print(
|
| 757 |
+
"[TextEncoder] cached encoder cleared (will reload on new GPU)"
|
| 758 |
+
)
|
| 759 |
+
|
| 760 |
+
# 4c. 清除 API embeddings 缓存(tensor 绑定旧 GPU)
|
| 761 |
+
if hasattr(te_state, "api_embeddings"):
|
| 762 |
+
te_state.api_embeddings = None
|
| 763 |
+
|
| 764 |
+
# 4d. 清除 prompt cache(其中 tensor 也绑定旧 GPU)
|
| 765 |
+
if hasattr(te_state, "prompt_cache") and te_state.prompt_cache:
|
| 766 |
+
te_state.prompt_cache.clear()
|
| 767 |
+
print("[TextEncoder] prompt cache cleared")
|
| 768 |
+
except Exception as _te_err:
|
| 769 |
+
print(f"[TextEncoder] device sync warning (non-fatal): {_te_err}")
|
| 770 |
+
|
| 771 |
+
print(
|
| 772 |
+
f"Switched active GPU to: {torch.cuda.get_device_name(gpu_id)} (ID: {gpu_id})"
|
| 773 |
+
)
|
| 774 |
+
return {"status": "success", "message": f"Switched to GPU {gpu_id}"}
|
| 775 |
+
except Exception as e:
|
| 776 |
+
return JSONResponse(status_code=500, content={"error": str(e)})
|
| 777 |
+
|
| 778 |
+
# --- 核心增强:首尾帧插值与视频超分支持 ---
|
| 779 |
+
from handlers.video_generation_handler import VideoGenerationHandler
|
| 780 |
+
from services.retake_pipeline.ltx_retake_pipeline import LTXRetakePipeline
|
| 781 |
+
from server_utils.media_validation import normalize_optional_path
|
| 782 |
+
from PIL import Image
|
| 783 |
+
|
| 784 |
+
# 1. 增强插值功能 (Monkey Patch VideoGenerationHandler)
|
| 785 |
+
_orig_generate = VideoGenerationHandler.generate
|
| 786 |
+
_orig_generate_video = VideoGenerationHandler.generate_video
|
| 787 |
+
|
| 788 |
+
def patched_generate(self, req: GenerateVideoRequest):
|
| 789 |
+
# === [DEBUG] 打印当前生成状态 ===
|
| 790 |
+
gen = self._generation
|
| 791 |
+
is_running = (
|
| 792 |
+
gen.is_generation_running()
|
| 793 |
+
if hasattr(gen, "is_generation_running")
|
| 794 |
+
else "?方法不存在"
|
| 795 |
+
)
|
| 796 |
+
gen_id = getattr(gen, "_generation_id", "?属性不存在")
|
| 797 |
+
is_gen = getattr(gen, "_is_generating", "?属性不存在")
|
| 798 |
+
cancelled = getattr(
|
| 799 |
+
gen, "_cancelled", getattr(gen, "_is_cancelled", "?属性不存在")
|
| 800 |
+
)
|
| 801 |
+
print(f"\n[PATCH][patched_generate] ==> 收到新请求")
|
| 802 |
+
print(f" is_generation_running() = {is_running}")
|
| 803 |
+
print(f" _generation_id = {gen_id}")
|
| 804 |
+
print(f" _is_generating = {is_gen}")
|
| 805 |
+
print(f" _cancelled = {cancelled}")
|
| 806 |
+
start_frame_path = normalize_optional_path(getattr(req, "startFramePath", None))
|
| 807 |
+
end_frame_path = normalize_optional_path(getattr(req, "endFramePath", None))
|
| 808 |
+
_raw_kf = getattr(req, "keyframePaths", None)
|
| 809 |
+
keyframe_paths_list: list[str] = []
|
| 810 |
+
if isinstance(_raw_kf, list):
|
| 811 |
+
for p in _raw_kf:
|
| 812 |
+
np = normalize_optional_path(p)
|
| 813 |
+
if np:
|
| 814 |
+
keyframe_paths_list.append(np)
|
| 815 |
+
use_multi_keyframes = len(keyframe_paths_list) >= 2
|
| 816 |
+
_raw_kf_st = getattr(req, "keyframeStrengths", None)
|
| 817 |
+
keyframe_strengths_list: list[float] | None = None
|
| 818 |
+
if isinstance(_raw_kf_st, list) and _raw_kf_st:
|
| 819 |
+
try:
|
| 820 |
+
keyframe_strengths_list = [float(x) for x in _raw_kf_st]
|
| 821 |
+
except (TypeError, ValueError):
|
| 822 |
+
keyframe_strengths_list = None
|
| 823 |
+
_raw_kf_t = getattr(req, "keyframeTimes", None)
|
| 824 |
+
keyframe_times_list: list[float] | None = None
|
| 825 |
+
if isinstance(_raw_kf_t, list) and _raw_kf_t:
|
| 826 |
+
try:
|
| 827 |
+
keyframe_times_list = [float(x) for x in _raw_kf_t]
|
| 828 |
+
except (TypeError, ValueError):
|
| 829 |
+
keyframe_times_list = None
|
| 830 |
+
aspect_ratio = getattr(req, "aspectRatio", None)
|
| 831 |
+
print(f" startFramePath = {start_frame_path}")
|
| 832 |
+
print(f" endFramePath = {end_frame_path}")
|
| 833 |
+
print(f" keyframePaths (n={len(keyframe_paths_list)}) = {use_multi_keyframes}")
|
| 834 |
+
print(f" aspectRatio = {aspect_ratio}")
|
| 835 |
+
|
| 836 |
+
# 检查是否有音频
|
| 837 |
+
audio_path = normalize_optional_path(getattr(req, "audioPath", None))
|
| 838 |
+
print(f"[PATCH] audio_path = {audio_path}")
|
| 839 |
+
|
| 840 |
+
# 检查是否有图片(图生视频)
|
| 841 |
+
image_path = normalize_optional_path(getattr(req, "imagePath", None))
|
| 842 |
+
print(f"[PATCH] image_path = {image_path}")
|
| 843 |
+
|
| 844 |
+
# 始终使用自定义逻辑(支持首尾帧和竖屏)
|
| 845 |
+
print(f"[PATCH] 使用自定义逻辑处理")
|
| 846 |
+
|
| 847 |
+
# 计算分辨率
|
| 848 |
+
import uuid
|
| 849 |
+
|
| 850 |
+
resolution = req.resolution
|
| 851 |
+
duration = int(float(req.duration))
|
| 852 |
+
fps = int(float(req.fps))
|
| 853 |
+
|
| 854 |
+
# 宽高均需为 64 的倍数(LTX 内核校验);在近似 16:9 下取整
|
| 855 |
+
RESOLUTION_MAP = {
|
| 856 |
+
"540p": (1024, 576),
|
| 857 |
+
"720p": (1280, 704),
|
| 858 |
+
"1080p": (1920, 1088),
|
| 859 |
+
}
|
| 860 |
+
|
| 861 |
+
def get_16_9_size(res):
|
| 862 |
+
return RESOLUTION_MAP.get(res, (1280, 704))
|
| 863 |
+
|
| 864 |
+
def get_9_16_size(res):
|
| 865 |
+
w, h = get_16_9_size(res)
|
| 866 |
+
return h, w # 交换宽高
|
| 867 |
+
|
| 868 |
+
if req.aspectRatio == "9:16":
|
| 869 |
+
width, height = get_9_16_size(resolution)
|
| 870 |
+
else:
|
| 871 |
+
width, height = get_16_9_size(resolution)
|
| 872 |
+
|
| 873 |
+
# 计算帧数
|
| 874 |
+
num_frames = ((duration * fps) // 8) * 8 + 1
|
| 875 |
+
num_frames = max(num_frames, 9)
|
| 876 |
+
|
| 877 |
+
print(f"[PATCH] 计算得到的分辨率: {width}x{height}, 帧数: {num_frames}")
|
| 878 |
+
|
| 879 |
+
# 多关键帧单次推理时勿用首尾帧属性,避免与 keyframe 列表重复
|
| 880 |
+
if use_multi_keyframes:
|
| 881 |
+
self._start_frame_path = None
|
| 882 |
+
self._end_frame_path = None
|
| 883 |
+
image_path_for_video = None
|
| 884 |
+
else:
|
| 885 |
+
self._start_frame_path = start_frame_path
|
| 886 |
+
self._end_frame_path = end_frame_path
|
| 887 |
+
image_path_for_video = image_path
|
| 888 |
+
|
| 889 |
+
# 无论有没有音频,都使用自定义逻辑支持首尾帧 / 多关键帧
|
| 890 |
+
try:
|
| 891 |
+
result = patched_generate_video(
|
| 892 |
+
self,
|
| 893 |
+
prompt=req.prompt,
|
| 894 |
+
image=None,
|
| 895 |
+
image_path=image_path_for_video,
|
| 896 |
+
height=height,
|
| 897 |
+
width=width,
|
| 898 |
+
num_frames=num_frames,
|
| 899 |
+
fps=fps,
|
| 900 |
+
seed=self._resolve_seed(),
|
| 901 |
+
camera_motion=req.cameraMotion,
|
| 902 |
+
negative_prompt=req.negativePrompt,
|
| 903 |
+
audio_path=audio_path,
|
| 904 |
+
lora_path=getattr(req, "loraPath", None),
|
| 905 |
+
lora_strength=float(getattr(req, "loraStrength", 1.0) or 1.0),
|
| 906 |
+
keyframe_paths=keyframe_paths_list if use_multi_keyframes else None,
|
| 907 |
+
keyframe_strengths=(
|
| 908 |
+
keyframe_strengths_list if use_multi_keyframes else None
|
| 909 |
+
),
|
| 910 |
+
keyframe_times=(
|
| 911 |
+
keyframe_times_list if use_multi_keyframes else None
|
| 912 |
+
),
|
| 913 |
+
)
|
| 914 |
+
print(f"[PATCH][patched_generate] <== 完成, 返回状态: complete")
|
| 915 |
+
return type("Response", (), {"status": "complete", "video_path": result})()
|
| 916 |
+
except Exception as e:
|
| 917 |
+
import traceback
|
| 918 |
+
|
| 919 |
+
print(f"[PATCH][patched_generate] 错误: {e}")
|
| 920 |
+
traceback.print_exc()
|
| 921 |
+
raise
|
| 922 |
+
|
| 923 |
+
def patched_generate_video(
|
| 924 |
+
self,
|
| 925 |
+
prompt,
|
| 926 |
+
image,
|
| 927 |
+
image_path=None,
|
| 928 |
+
height=None,
|
| 929 |
+
width=None,
|
| 930 |
+
num_frames=None,
|
| 931 |
+
fps=None,
|
| 932 |
+
seed=None,
|
| 933 |
+
camera_motion=None,
|
| 934 |
+
negative_prompt=None,
|
| 935 |
+
audio_path=None,
|
| 936 |
+
lora_path=None,
|
| 937 |
+
lora_strength=1.0,
|
| 938 |
+
keyframe_paths: list[str] | None = None,
|
| 939 |
+
keyframe_strengths: list[float] | None = None,
|
| 940 |
+
keyframe_times: list[float] | None = None,
|
| 941 |
+
):
|
| 942 |
+
# === [DEBUG] 打印当前生成状态 ===
|
| 943 |
+
gen = self._generation
|
| 944 |
+
is_running = (
|
| 945 |
+
gen.is_generation_running()
|
| 946 |
+
if hasattr(gen, "is_generation_running")
|
| 947 |
+
else "?方法不存在"
|
| 948 |
+
)
|
| 949 |
+
gen_id = getattr(gen, "_generation_id", "?属性不存在")
|
| 950 |
+
is_gen = getattr(gen, "_is_generating", "?属性不存在")
|
| 951 |
+
print(f"[PATCH][patched_generate_video] ==> 开始推理")
|
| 952 |
+
print(f" is_generation_running() = {is_running}")
|
| 953 |
+
print(f" _generation_id = {gen_id}")
|
| 954 |
+
print(f" _is_generating = {is_gen}")
|
| 955 |
+
print(
|
| 956 |
+
f" resolution = {width}x{height}, frames={num_frames}, fps={fps}"
|
| 957 |
+
)
|
| 958 |
+
print(f" image param = {type(image)}, {image is not None}")
|
| 959 |
+
print(f" image_path = {image_path}")
|
| 960 |
+
# ==================================
|
| 961 |
+
from ltx_pipelines.utils.args import (
|
| 962 |
+
ImageConditioningInput as LtxImageConditioningInput,
|
| 963 |
+
)
|
| 964 |
+
|
| 965 |
+
images_inputs = []
|
| 966 |
+
temp_paths = []
|
| 967 |
+
kf_list = [p for p in (keyframe_paths or []) if p]
|
| 968 |
+
use_multi_kf = len(kf_list) >= 2
|
| 969 |
+
|
| 970 |
+
start_path = getattr(self, "_start_frame_path", None)
|
| 971 |
+
end_path = getattr(self, "_end_frame_path", None)
|
| 972 |
+
print(
|
| 973 |
+
f"[PATCH] start_path={start_path}, end_path={end_path}, multi_kf={use_multi_kf} n={len(kf_list)}"
|
| 974 |
+
)
|
| 975 |
+
|
| 976 |
+
latent_num_frames = (num_frames - 1) // 8 + 1
|
| 977 |
+
last_latent_idx = latent_num_frames - 1
|
| 978 |
+
print(
|
| 979 |
+
f"[PATCH] latent_num_frames={latent_num_frames}, last_latent_idx={last_latent_idx}"
|
| 980 |
+
)
|
| 981 |
+
|
| 982 |
+
if use_multi_kf:
|
| 983 |
+
n_kf = len(kf_list)
|
| 984 |
+
st_override = keyframe_strengths or []
|
| 985 |
+
if len(st_override) not in (0, n_kf):
|
| 986 |
+
print(
|
| 987 |
+
f"[PATCH] keyframeStrengths 长度({len(st_override)})与关键帧数({n_kf})不一致,改用默认强度曲线"
|
| 988 |
+
)
|
| 989 |
+
st_override = []
|
| 990 |
+
|
| 991 |
+
def _default_multi_guide_strength(i: int, n: int) -> float:
|
| 992 |
+
"""对齐 Comfy LTXVAddGuideMulti 常见配置:首尾不全是 1,中间明显减弱以减少邻帧闪烁。"""
|
| 993 |
+
if n <= 2:
|
| 994 |
+
return 1.0
|
| 995 |
+
if i == 0:
|
| 996 |
+
return 0.62
|
| 997 |
+
if i == n - 1:
|
| 998 |
+
return 1.0
|
| 999 |
+
return 0.42
|
| 1000 |
+
|
| 1001 |
+
kt = keyframe_times or []
|
| 1002 |
+
times_match = len(kt) == n_kf
|
| 1003 |
+
if times_match:
|
| 1004 |
+
fps_f = max(float(fps), 0.001)
|
| 1005 |
+
max_t = (num_frames - 1) / fps_f
|
| 1006 |
+
fi_list: list[int] = []
|
| 1007 |
+
for ki in range(n_kf):
|
| 1008 |
+
t_sec = max(0.0, min(max_t, float(kt[ki])))
|
| 1009 |
+
pf = int(round(t_sec * fps_f))
|
| 1010 |
+
pf = min(num_frames - 1, max(0, pf))
|
| 1011 |
+
fi = min(last_latent_idx, pf // 8)
|
| 1012 |
+
fi_list.append(int(fi))
|
| 1013 |
+
for j in range(1, n_kf):
|
| 1014 |
+
if fi_list[j] <= fi_list[j - 1]:
|
| 1015 |
+
fi_list[j] = min(last_latent_idx, fi_list[j - 1] + 1)
|
| 1016 |
+
if n_kf > 1:
|
| 1017 |
+
fi_list[-1] = last_latent_idx
|
| 1018 |
+
print(f"[PATCH] Multi-keyframe: 使用 keyframeTimes 映射 -> {fi_list}")
|
| 1019 |
+
else:
|
| 1020 |
+
fi_list = []
|
| 1021 |
+
prev_fi = -1
|
| 1022 |
+
for ki in range(n_kf):
|
| 1023 |
+
if last_latent_idx <= 0:
|
| 1024 |
+
fi = 0
|
| 1025 |
+
elif ki == 0:
|
| 1026 |
+
fi = 0
|
| 1027 |
+
elif ki == n_kf - 1:
|
| 1028 |
+
fi = last_latent_idx
|
| 1029 |
+
else:
|
| 1030 |
+
pf = int(
|
| 1031 |
+
round(
|
| 1032 |
+
ki * (num_frames - 1) / max(1, (n_kf - 1))
|
| 1033 |
+
)
|
| 1034 |
+
)
|
| 1035 |
+
fi = min(last_latent_idx - 1, max(1, pf // 8))
|
| 1036 |
+
if fi <= prev_fi:
|
| 1037 |
+
fi = min(last_latent_idx - 1, prev_fi + 1)
|
| 1038 |
+
prev_fi = fi
|
| 1039 |
+
fi_list.append(int(fi))
|
| 1040 |
+
|
| 1041 |
+
for ki, kp in enumerate(kf_list):
|
| 1042 |
+
if not os.path.isfile(kp):
|
| 1043 |
+
raise RuntimeError(f"多关键帧路径无效或不存在: {kp}")
|
| 1044 |
+
fi = fi_list[ki]
|
| 1045 |
+
|
| 1046 |
+
if len(st_override) == n_kf:
|
| 1047 |
+
st = float(st_override[ki])
|
| 1048 |
+
st = max(0.1, min(1.0, st))
|
| 1049 |
+
else:
|
| 1050 |
+
st = _default_multi_guide_strength(ki, n_kf)
|
| 1051 |
+
|
| 1052 |
+
img = self._prepare_image(kp, width, height)
|
| 1053 |
+
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
|
| 1054 |
+
img.save(tmp)
|
| 1055 |
+
temp_paths.append(tmp)
|
| 1056 |
+
tmp_normalized = tmp.replace("\\", "/")
|
| 1057 |
+
images_inputs.append(
|
| 1058 |
+
LtxImageConditioningInput(
|
| 1059 |
+
path=tmp_normalized, frame_idx=int(fi), strength=float(st)
|
| 1060 |
+
)
|
| 1061 |
+
)
|
| 1062 |
+
print(
|
| 1063 |
+
f"[PATCH] Multi-keyframe [{ki}]: {tmp_normalized}, "
|
| 1064 |
+
f"frame_idx={fi}, strength={st:.3f}"
|
| 1065 |
+
)
|
| 1066 |
+
else:
|
| 1067 |
+
# 如果没有首尾帧但有 image_path,使用 image_path 作为起始帧
|
| 1068 |
+
if not start_path and not end_path and image_path:
|
| 1069 |
+
print(f"[PATCH] 使用 image_path 作为起始帧: {image_path}")
|
| 1070 |
+
start_path = image_path
|
| 1071 |
+
|
| 1072 |
+
has_image_param = image is not None
|
| 1073 |
+
if has_image_param:
|
| 1074 |
+
print(
|
| 1075 |
+
f"[PATCH] image param is available, will be used as start frame"
|
| 1076 |
+
)
|
| 1077 |
+
|
| 1078 |
+
target_start_path = start_path if start_path else None
|
| 1079 |
+
if not target_start_path and image is not None:
|
| 1080 |
+
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
|
| 1081 |
+
image.save(tmp)
|
| 1082 |
+
temp_paths.append(tmp)
|
| 1083 |
+
target_start_path = tmp
|
| 1084 |
+
print(
|
| 1085 |
+
f"[PATCH] Using image param as start frame: {target_start_path}"
|
| 1086 |
+
)
|
| 1087 |
+
|
| 1088 |
+
if target_start_path:
|
| 1089 |
+
start_img = self._prepare_image(target_start_path, width, height)
|
| 1090 |
+
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
|
| 1091 |
+
start_img.save(tmp)
|
| 1092 |
+
temp_paths.append(tmp)
|
| 1093 |
+
tmp_normalized = tmp.replace("\\", "/")
|
| 1094 |
+
images_inputs.append(
|
| 1095 |
+
LtxImageConditioningInput(
|
| 1096 |
+
path=tmp_normalized, frame_idx=0, strength=1.0
|
| 1097 |
+
)
|
| 1098 |
+
)
|
| 1099 |
+
print(f"[PATCH] Added start frame: {tmp_normalized}, frame_idx=0")
|
| 1100 |
+
|
| 1101 |
+
if end_path:
|
| 1102 |
+
end_img = self._prepare_image(end_path, width, height)
|
| 1103 |
+
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
|
| 1104 |
+
end_img.save(tmp)
|
| 1105 |
+
temp_paths.append(tmp)
|
| 1106 |
+
tmp_normalized = tmp.replace("\\", "/")
|
| 1107 |
+
images_inputs.append(
|
| 1108 |
+
LtxImageConditioningInput(
|
| 1109 |
+
path=tmp_normalized,
|
| 1110 |
+
frame_idx=last_latent_idx,
|
| 1111 |
+
strength=1.0,
|
| 1112 |
+
)
|
| 1113 |
+
)
|
| 1114 |
+
print(
|
| 1115 |
+
f"[PATCH] Added end frame: {tmp_normalized}, frame_idx={last_latent_idx}"
|
| 1116 |
+
)
|
| 1117 |
+
|
| 1118 |
+
print(f"[PATCH] images_inputs count: {len(images_inputs)}")
|
| 1119 |
+
if images_inputs:
|
| 1120 |
+
for idx, img in enumerate(images_inputs):
|
| 1121 |
+
print(
|
| 1122 |
+
f"[PATCH] images_inputs[{idx}]: path={getattr(img, 'path', 'N/A')}, frame_idx={getattr(img, 'frame_idx', 'N/A')}, strength={getattr(img, 'strength', 'N/A')}"
|
| 1123 |
+
)
|
| 1124 |
+
|
| 1125 |
+
print(f"[PATCH] audio_path = {audio_path}")
|
| 1126 |
+
|
| 1127 |
+
if self._generation.is_generation_cancelled():
|
| 1128 |
+
raise RuntimeError("Generation was cancelled")
|
| 1129 |
+
|
| 1130 |
+
# 导入 uuid
|
| 1131 |
+
import uuid
|
| 1132 |
+
|
| 1133 |
+
generation_id = uuid.uuid4().hex[:8]
|
| 1134 |
+
|
| 1135 |
+
# 根据是否有音频选择不同的 pipeline
|
| 1136 |
+
extra_loras_for_hook: tuple | None = None # 供 lora_build_hook 在 DiT build 时融合
|
| 1137 |
+
gpu_slot = getattr(self._pipelines.state, "gpu_slot", None)
|
| 1138 |
+
active = getattr(gpu_slot, "active_pipeline", None) if gpu_slot else None
|
| 1139 |
+
cached_sig = getattr(self._pipelines, "_pipeline_signature", None)
|
| 1140 |
+
|
| 1141 |
+
new_kind = "a2v" if audio_path else "fast"
|
| 1142 |
+
if (
|
| 1143 |
+
cached_sig
|
| 1144 |
+
and isinstance(cached_sig, tuple)
|
| 1145 |
+
and len(cached_sig) > 0
|
| 1146 |
+
and cached_sig[0] != new_kind
|
| 1147 |
+
and active is not None
|
| 1148 |
+
):
|
| 1149 |
+
from keep_models_runtime import force_unload_gpu_pipeline
|
| 1150 |
+
|
| 1151 |
+
print(
|
| 1152 |
+
f"[PATCH] 管线类型切换 {cached_sig[0]} -> {new_kind},强制卸载旧模型"
|
| 1153 |
+
)
|
| 1154 |
+
force_unload_gpu_pipeline(self._pipelines)
|
| 1155 |
+
gpu_slot = getattr(self._pipelines.state, "gpu_slot", None)
|
| 1156 |
+
active = getattr(gpu_slot, "active_pipeline", None) if gpu_slot else None
|
| 1157 |
+
|
| 1158 |
+
if audio_path:
|
| 1159 |
+
desired_sig = ("a2v",)
|
| 1160 |
+
print(f"[PATCH] 加载 A2V pipeline(支持音频)")
|
| 1161 |
+
pipeline_state = self._pipelines.load_a2v_pipeline()
|
| 1162 |
+
self._pipelines._pipeline_signature = desired_sig
|
| 1163 |
+
num_inference_steps = 11
|
| 1164 |
+
else:
|
| 1165 |
+
# Fast:无 LoRA 时走官方 load_gpu_pipeline;有 LoRA 时自建 pipeline。
|
| 1166 |
+
loras = None
|
| 1167 |
+
lora_str = (lora_path or "").strip() if isinstance(lora_path, str) else ""
|
| 1168 |
+
if lora_str:
|
| 1169 |
+
try:
|
| 1170 |
+
from ltx_core.loader import LoraPathStrengthAndSDOps
|
| 1171 |
+
from ltx_core.loader.sd_ops import LTXV_LORA_COMFY_RENAMING_MAP
|
| 1172 |
+
|
| 1173 |
+
if os.path.exists(lora_str):
|
| 1174 |
+
loras = [
|
| 1175 |
+
LoraPathStrengthAndSDOps(
|
| 1176 |
+
path=lora_str,
|
| 1177 |
+
strength=float(lora_strength),
|
| 1178 |
+
sd_ops=LTXV_LORA_COMFY_RENAMING_MAP,
|
| 1179 |
+
)
|
| 1180 |
+
]
|
| 1181 |
+
print(
|
| 1182 |
+
f"[PATCH] LoRA 已就绪: {lora_str}, strength={lora_strength}"
|
| 1183 |
+
)
|
| 1184 |
+
else:
|
| 1185 |
+
print(f"[PATCH] LoRA 文件不存在,将使用无 LoRA Fast: {lora_str}")
|
| 1186 |
+
except Exception as _lora_err:
|
| 1187 |
+
print(f"[PATCH] LoRA 准备失败,回退无 LoRA: {_lora_err}")
|
| 1188 |
+
loras = None
|
| 1189 |
+
|
| 1190 |
+
if loras is not None:
|
| 1191 |
+
lora_key = lora_str
|
| 1192 |
+
lora_st = round(float(lora_strength), 4)
|
| 1193 |
+
else:
|
| 1194 |
+
lora_key = ""
|
| 1195 |
+
lora_st = 0.0
|
| 1196 |
+
desired_sig = ("fast", lora_key, lora_st)
|
| 1197 |
+
|
| 1198 |
+
if loras is not None:
|
| 1199 |
+
print("[PATCH] 构建带 LoRA 的 Fast pipeline(unload 后重建)")
|
| 1200 |
+
# 首次 LoRA 构建时可能触发额外的显存峰值(编译/缓存/权重搬运)。
|
| 1201 |
+
# 通过一次无 LoRA 的 fast pipeline warmup 来降低后续 LoRA 构建的峰值风险。
|
| 1202 |
+
if not getattr(self, "_ltx_lora_warmup_done", False):
|
| 1203 |
+
try:
|
| 1204 |
+
print("[PATCH] LoRA warmup: 先加载无 LoRA fast pipeline 触发缓存")
|
| 1205 |
+
# should_warm=True:尽量触发内核/权重缓存(若实现不同则静默失败也可回退)
|
| 1206 |
+
self._pipelines.load_gpu_pipeline("fast", should_warm=True)
|
| 1207 |
+
from keep_models_runtime import force_unload_gpu_pipeline
|
| 1208 |
+
force_unload_gpu_pipeline(self._pipelines)
|
| 1209 |
+
import gc
|
| 1210 |
+
gc.collect()
|
| 1211 |
+
try:
|
| 1212 |
+
import torch
|
| 1213 |
+
if torch.cuda.is_available():
|
| 1214 |
+
torch.cuda.empty_cache()
|
| 1215 |
+
torch.cuda.ipc_collect()
|
| 1216 |
+
except Exception:
|
| 1217 |
+
pass
|
| 1218 |
+
self._ltx_lora_warmup_done = True
|
| 1219 |
+
except Exception as _warm_err:
|
| 1220 |
+
print(f"[PATCH] LoRA warmup failed (ignore): {_warm_err}")
|
| 1221 |
+
from keep_models_runtime import force_unload_gpu_pipeline
|
| 1222 |
+
|
| 1223 |
+
force_unload_gpu_pipeline(self._pipelines)
|
| 1224 |
+
import gc
|
| 1225 |
+
|
| 1226 |
+
gc.collect()
|
| 1227 |
+
# 防止旧分配/碎片在首次 LoRA 构建时叠加导致 OOM
|
| 1228 |
+
try:
|
| 1229 |
+
import torch
|
| 1230 |
+
if torch.cuda.is_available():
|
| 1231 |
+
torch.cuda.empty_cache()
|
| 1232 |
+
torch.cuda.ipc_collect()
|
| 1233 |
+
except Exception:
|
| 1234 |
+
pass
|
| 1235 |
+
gemma_root = self._pipelines._text_handler.resolve_gemma_root()
|
| 1236 |
+
from runtime_config.model_download_specs import resolve_model_path
|
| 1237 |
+
from services.fast_video_pipeline.ltx_fast_video_pipeline import (
|
| 1238 |
+
LTXFastVideoPipeline,
|
| 1239 |
+
)
|
| 1240 |
+
from state.app_state_types import (
|
| 1241 |
+
GpuSlot,
|
| 1242 |
+
VideoPipelineState,
|
| 1243 |
+
VideoPipelineWarmth,
|
| 1244 |
+
)
|
| 1245 |
+
|
| 1246 |
+
checkpoint_path = str(
|
| 1247 |
+
resolve_model_path(
|
| 1248 |
+
self._pipelines.models_dir,
|
| 1249 |
+
self._pipelines.config.model_download_specs,
|
| 1250 |
+
"checkpoint",
|
| 1251 |
+
)
|
| 1252 |
+
)
|
| 1253 |
+
upsampler_path = str(
|
| 1254 |
+
resolve_model_path(
|
| 1255 |
+
self._pipelines.models_dir,
|
| 1256 |
+
self._pipelines.config.model_download_specs,
|
| 1257 |
+
"upsampler",
|
| 1258 |
+
)
|
| 1259 |
+
)
|
| 1260 |
+
from lora_injection import (
|
| 1261 |
+
_lora_init_kwargs,
|
| 1262 |
+
inject_loras_into_fast_pipeline,
|
| 1263 |
+
)
|
| 1264 |
+
|
| 1265 |
+
lora_kw = _lora_init_kwargs(LTXFastVideoPipeline, loras)
|
| 1266 |
+
ltx_pipe = LTXFastVideoPipeline(
|
| 1267 |
+
checkpoint_path,
|
| 1268 |
+
gemma_root,
|
| 1269 |
+
upsampler_path,
|
| 1270 |
+
self._pipelines.config.device,
|
| 1271 |
+
**lora_kw,
|
| 1272 |
+
)
|
| 1273 |
+
n_inj = inject_loras_into_fast_pipeline(ltx_pipe, loras)
|
| 1274 |
+
if hasattr(ltx_pipe, "pipeline") and hasattr(
|
| 1275 |
+
ltx_pipe.pipeline, "model_ledger"
|
| 1276 |
+
):
|
| 1277 |
+
try:
|
| 1278 |
+
ltx_pipe.pipeline.model_ledger.loras = tuple(loras)
|
| 1279 |
+
except Exception:
|
| 1280 |
+
pass
|
| 1281 |
+
pipeline_state = VideoPipelineState(
|
| 1282 |
+
pipeline=ltx_pipe,
|
| 1283 |
+
warmth=VideoPipelineWarmth.COLD,
|
| 1284 |
+
is_compiled=False,
|
| 1285 |
+
)
|
| 1286 |
+
self._pipelines.state.gpu_slot = GpuSlot(
|
| 1287 |
+
active_pipeline=pipeline_state, generation=None
|
| 1288 |
+
)
|
| 1289 |
+
_ml = getattr(getattr(ltx_pipe, "pipeline", None), "model_ledger", None)
|
| 1290 |
+
_ml_loras = getattr(_ml, "loras", None) if _ml else None
|
| 1291 |
+
print(
|
| 1292 |
+
f"[PATCH] LoRA: __init__ 额外参数={list(lora_kw.keys())}, "
|
| 1293 |
+
f"深度注入点数={n_inj}, model_ledger.loras={_ml_loras}"
|
| 1294 |
+
)
|
| 1295 |
+
if getattr(self._pipelines, "low_vram_mode", False):
|
| 1296 |
+
from low_vram_runtime import try_sequential_offload_on_pipeline_state
|
| 1297 |
+
|
| 1298 |
+
try_sequential_offload_on_pipeline_state(pipeline_state)
|
| 1299 |
+
else:
|
| 1300 |
+
print(f"[PATCH] 加载 Fast pipeline(无 LoRA)")
|
| 1301 |
+
pipeline_state = self._pipelines.load_gpu_pipeline(
|
| 1302 |
+
"fast", should_warm=False
|
| 1303 |
+
)
|
| 1304 |
+
self._pipelines._pipeline_signature = desired_sig
|
| 1305 |
+
num_inference_steps = None
|
| 1306 |
+
extra_loras_for_hook = tuple(loras) if loras else None
|
| 1307 |
+
|
| 1308 |
+
# 在 DiT 权重 build 时融合用户 LoRA(model_ledger 单独赋值往往不够)
|
| 1309 |
+
from lora_build_hook import (
|
| 1310 |
+
install_lora_build_hook,
|
| 1311 |
+
pending_loras_token,
|
| 1312 |
+
reset_pending_loras,
|
| 1313 |
+
)
|
| 1314 |
+
|
| 1315 |
+
install_lora_build_hook()
|
| 1316 |
+
_lora_hook_tok = pending_loras_token(extra_loras_for_hook)
|
| 1317 |
+
try:
|
| 1318 |
+
# 启动 generation 状态(在 pipeline 加载之后)
|
| 1319 |
+
self._generation.start_generation(generation_id)
|
| 1320 |
+
|
| 1321 |
+
# 处理 negative_prompt
|
| 1322 |
+
neg_prompt = (
|
| 1323 |
+
negative_prompt if negative_prompt else self.config.default_negative_prompt
|
| 1324 |
+
)
|
| 1325 |
+
enhanced_prompt = prompt + self.config.camera_motion_prompts.get(
|
| 1326 |
+
camera_motion, ""
|
| 1327 |
+
)
|
| 1328 |
+
|
| 1329 |
+
# 强制使用动态目录,忽略底层原始逻辑
|
| 1330 |
+
dyn_dir = get_dynamic_output_path()
|
| 1331 |
+
output_path = dyn_dir / f"generation_{uuid.uuid4().hex[:8]}.mp4"
|
| 1332 |
+
|
| 1333 |
+
try:
|
| 1334 |
+
self._text.prepare_text_encoding(enhanced_prompt, enhance_prompt=False)
|
| 1335 |
+
# 调整为 64 的倍数(与 LTX 内核 divisible-by-64 校验一致)
|
| 1336 |
+
height = max(64, round(height / 64) * 64)
|
| 1337 |
+
width = max(64, round(width / 64) * 64)
|
| 1338 |
+
|
| 1339 |
+
if audio_path:
|
| 1340 |
+
# A2V pipeline 参数
|
| 1341 |
+
gen_kwargs = {
|
| 1342 |
+
"prompt": enhanced_prompt,
|
| 1343 |
+
"negative_prompt": neg_prompt,
|
| 1344 |
+
"seed": seed,
|
| 1345 |
+
"height": height,
|
| 1346 |
+
"width": width,
|
| 1347 |
+
"num_frames": num_frames,
|
| 1348 |
+
"frame_rate": fps,
|
| 1349 |
+
"num_inference_steps": num_inference_steps,
|
| 1350 |
+
"images": images_inputs,
|
| 1351 |
+
"audio_path": audio_path,
|
| 1352 |
+
"audio_start_time": 0.0,
|
| 1353 |
+
"audio_max_duration": None,
|
| 1354 |
+
"output_path": str(output_path),
|
| 1355 |
+
}
|
| 1356 |
+
else:
|
| 1357 |
+
# Fast pipeline 参数
|
| 1358 |
+
gen_kwargs = {
|
| 1359 |
+
"prompt": enhanced_prompt,
|
| 1360 |
+
"seed": seed,
|
| 1361 |
+
"height": height,
|
| 1362 |
+
"width": width,
|
| 1363 |
+
"num_frames": num_frames,
|
| 1364 |
+
"frame_rate": fps,
|
| 1365 |
+
"images": images_inputs,
|
| 1366 |
+
"output_path": str(output_path),
|
| 1367 |
+
}
|
| 1368 |
+
|
| 1369 |
+
pipeline_state.pipeline.generate(**gen_kwargs)
|
| 1370 |
+
|
| 1371 |
+
# 标记完成
|
| 1372 |
+
self._generation.complete_generation(str(output_path))
|
| 1373 |
+
return str(output_path)
|
| 1374 |
+
finally:
|
| 1375 |
+
self._text.clear_api_embeddings()
|
| 1376 |
+
for p in temp_paths:
|
| 1377 |
+
if os.path.exists(p):
|
| 1378 |
+
os.unlink(p)
|
| 1379 |
+
self._start_frame_path = None
|
| 1380 |
+
self._end_frame_path = None
|
| 1381 |
+
from low_vram_runtime import maybe_release_pipeline_after_task
|
| 1382 |
+
|
| 1383 |
+
try:
|
| 1384 |
+
maybe_release_pipeline_after_task(self)
|
| 1385 |
+
except Exception:
|
| 1386 |
+
pass
|
| 1387 |
+
finally:
|
| 1388 |
+
reset_pending_loras(_lora_hook_tok)
|
| 1389 |
+
|
| 1390 |
+
VideoGenerationHandler.generate = patched_generate
|
| 1391 |
+
VideoGenerationHandler.generate_video = patched_generate_video
|
| 1392 |
+
|
| 1393 |
+
# 2. 增强视频超分功能 (Monkey Patch LTXRetakePipeline)
|
| 1394 |
+
_orig_ltx_retake_run = LTXRetakePipeline._run
|
| 1395 |
+
|
| 1396 |
+
def patched_ltx_retake_run(
|
| 1397 |
+
self, video_path, prompt, start_time, end_time, seed, **kwargs
|
| 1398 |
+
):
|
| 1399 |
+
# 拦截并修改目标宽高
|
| 1400 |
+
target_w = getattr(self, "_target_width", None)
|
| 1401 |
+
target_h = getattr(self, "_target_height", None)
|
| 1402 |
+
target_strength = getattr(self, "_target_strength", 0.7)
|
| 1403 |
+
is_upscale = target_w is not None and target_h is not None
|
| 1404 |
+
|
| 1405 |
+
import ltx_pipelines.utils.media_io as media_io
|
| 1406 |
+
import services.retake_pipeline.ltx_retake_pipeline as lrp
|
| 1407 |
+
import ltx_pipelines.utils.samplers as samplers
|
| 1408 |
+
import ltx_pipelines.utils.helpers as helpers
|
| 1409 |
+
|
| 1410 |
+
_orig_get_meta = media_io.get_videostream_metadata
|
| 1411 |
+
_orig_lrp_get_meta = getattr(lrp, "get_videostream_metadata", _orig_get_meta)
|
| 1412 |
+
_orig_euler_loop = samplers.euler_denoising_loop
|
| 1413 |
+
_orig_noise_video = helpers.noise_video_state
|
| 1414 |
+
|
| 1415 |
+
fps, num_frames, src_w, src_h = _orig_get_meta(video_path)
|
| 1416 |
+
|
| 1417 |
+
if is_upscale:
|
| 1418 |
+
print(
|
| 1419 |
+
f">>> 启动超分内核: {src_w}x{src_h} -> {target_w}x{target_h} (强度: {target_strength})"
|
| 1420 |
+
)
|
| 1421 |
+
|
| 1422 |
+
# 1. 注入分辨率
|
| 1423 |
+
def get_meta_patched(path):
|
| 1424 |
+
return fps, num_frames, target_w, target_h
|
| 1425 |
+
|
| 1426 |
+
media_io.get_videostream_metadata = get_meta_patched
|
| 1427 |
+
lrp.get_videostream_metadata = get_meta_patched
|
| 1428 |
+
|
| 1429 |
+
# 2. 注入起始噪声 (SDEdit 核心:加噪到指定强度)
|
| 1430 |
+
def noise_video_patched(*args, **kwargs_inner):
|
| 1431 |
+
kwargs_inner["noise_scale"] = target_strength
|
| 1432 |
+
return _orig_noise_video(*args, **kwargs_inner)
|
| 1433 |
+
|
| 1434 |
+
helpers.noise_video_state = noise_video_patched
|
| 1435 |
+
|
| 1436 |
+
# 3. 注入采样起点 (从对应噪声位开始去噪)
|
| 1437 |
+
def patched_euler_loop(
|
| 1438 |
+
sigmas, video_state, audio_state, stepper, denoise_fn
|
| 1439 |
+
):
|
| 1440 |
+
full_len = len(sigmas)
|
| 1441 |
+
skip_idx = 0
|
| 1442 |
+
for i, s in enumerate(sigmas):
|
| 1443 |
+
if s <= target_strength:
|
| 1444 |
+
skip_idx = i
|
| 1445 |
+
break
|
| 1446 |
+
skip_idx = min(skip_idx, full_len - 2)
|
| 1447 |
+
new_sigmas = sigmas[skip_idx:]
|
| 1448 |
+
print(
|
| 1449 |
+
f">>> 采样拦截成功: 原步数 {full_len}, 现步数 {len(new_sigmas)}, 起始强度 {new_sigmas[0].item():.2f}"
|
| 1450 |
+
)
|
| 1451 |
+
return _orig_euler_loop(
|
| 1452 |
+
new_sigmas, video_state, audio_state, stepper, denoise_fn
|
| 1453 |
+
)
|
| 1454 |
+
|
| 1455 |
+
samplers.euler_denoising_loop = patched_euler_loop
|
| 1456 |
+
|
| 1457 |
+
kwargs["regenerate_video"] = False
|
| 1458 |
+
kwargs["regenerate_audio"] = False
|
| 1459 |
+
|
| 1460 |
+
try:
|
| 1461 |
+
return _orig_ltx_retake_run(
|
| 1462 |
+
self, video_path, prompt, start_time, end_time, seed, **kwargs
|
| 1463 |
+
)
|
| 1464 |
+
finally:
|
| 1465 |
+
media_io.get_videostream_metadata = _orig_get_meta
|
| 1466 |
+
lrp.get_videostream_metadata = _orig_lrp_get_meta
|
| 1467 |
+
samplers.euler_denoising_loop = _orig_euler_loop
|
| 1468 |
+
helpers.noise_video_state = _orig_noise_video
|
| 1469 |
+
|
| 1470 |
+
return _orig_ltx_retake_run(
|
| 1471 |
+
self, video_path, prompt, start_time, end_time, seed, **kwargs
|
| 1472 |
+
)
|
| 1473 |
+
|
| 1474 |
+
return _orig_ltx_retake_run(
|
| 1475 |
+
self, video_path, prompt, start_time, end_time, seed, **kwargs
|
| 1476 |
+
)
|
| 1477 |
+
|
| 1478 |
+
LTXRetakePipeline._run = patched_ltx_retake_run
|
| 1479 |
+
|
| 1480 |
+
# --- 最终视频超分接口实现 ---
|
| 1481 |
+
@app.post("/api/system/upscale-video")
|
| 1482 |
+
async def route_upscale_video(request: Request):
|
| 1483 |
+
try:
|
| 1484 |
+
import uuid
|
| 1485 |
+
import os
|
| 1486 |
+
from datetime import datetime
|
| 1487 |
+
from ltx_pipelines.utils.media_io import get_videostream_metadata
|
| 1488 |
+
from ltx_core.types import SpatioTemporalScaleFactors
|
| 1489 |
+
|
| 1490 |
+
data = await request.json()
|
| 1491 |
+
video_path = data.get("video_path")
|
| 1492 |
+
target_res = data.get("resolution", "1080p")
|
| 1493 |
+
prompt = data.get("prompt", "high quality, detailed, 4k")
|
| 1494 |
+
strength = data.get("strength", 0.7) # 获取前端传来的重绘幅度
|
| 1495 |
+
|
| 1496 |
+
if not video_path or not os.path.exists(video_path):
|
| 1497 |
+
return JSONResponse(
|
| 1498 |
+
status_code=400, content={"error": "Invalid video path"}
|
| 1499 |
+
)
|
| 1500 |
+
|
| 1501 |
+
# 计算目标宽高 (必须是 32 的倍数)
|
| 1502 |
+
res_map = {"1080p": (1920, 1088), "720p": (1280, 704), "544p": (960, 544)}
|
| 1503 |
+
target_w, target_h = res_map.get(target_res, (1920, 1088))
|
| 1504 |
+
|
| 1505 |
+
fps, num_frames, _, _ = get_videostream_metadata(video_path)
|
| 1506 |
+
|
| 1507 |
+
# 校验帧数 8k+1,如果不符则自动调整
|
| 1508 |
+
scale = SpatioTemporalScaleFactors.default()
|
| 1509 |
+
if (num_frames - 1) % scale.time != 0:
|
| 1510 |
+
# 计算需要调整到的最近的有效帧数 (8k+1)
|
| 1511 |
+
# 找到最接近的8k+1帧数
|
| 1512 |
+
target_k = (num_frames - 1) // scale.time
|
| 1513 |
+
# 选择最接近的k值:向下或向上取整
|
| 1514 |
+
current_k = (num_frames - 1) // scale.time
|
| 1515 |
+
current_remainder = (num_frames - 1) % scale.time
|
| 1516 |
+
|
| 1517 |
+
# 比较向上和向下取整哪个更接近
|
| 1518 |
+
down_k = current_k
|
| 1519 |
+
up_k = current_k + 1
|
| 1520 |
+
|
| 1521 |
+
# 向下取整的帧数
|
| 1522 |
+
down_frames = down_k * scale.time + 1
|
| 1523 |
+
# 向上取整的帧数
|
| 1524 |
+
up_frames = up_k * scale.time + 1
|
| 1525 |
+
|
| 1526 |
+
# 选择差异最小的
|
| 1527 |
+
if abs(num_frames - down_frames) <= abs(num_frames - up_frames):
|
| 1528 |
+
adjusted_frames = down_frames
|
| 1529 |
+
else:
|
| 1530 |
+
adjusted_frames = up_frames
|
| 1531 |
+
|
| 1532 |
+
print(
|
| 1533 |
+
f">>> 帧数调整: {num_frames} -> {adjusted_frames} (符合 8k+1 规则)"
|
| 1534 |
+
)
|
| 1535 |
+
|
| 1536 |
+
# 调整视频帧数 - 截断多余的帧或填充黑帧
|
| 1537 |
+
adjusted_video_path = None
|
| 1538 |
+
try:
|
| 1539 |
+
import cv2
|
| 1540 |
+
import numpy as np
|
| 1541 |
+
import tempfile
|
| 1542 |
+
|
| 1543 |
+
# 使用cv2读取视频
|
| 1544 |
+
cap = cv2.VideoCapture(video_path)
|
| 1545 |
+
if not cap.isOpened():
|
| 1546 |
+
raise Exception("无法打开视频文件")
|
| 1547 |
+
|
| 1548 |
+
frames = []
|
| 1549 |
+
while True:
|
| 1550 |
+
ret, frame = cap.read()
|
| 1551 |
+
if not ret:
|
| 1552 |
+
break
|
| 1553 |
+
frames.append(frame)
|
| 1554 |
+
cap.release()
|
| 1555 |
+
|
| 1556 |
+
original_frame_count = len(frames)
|
| 1557 |
+
|
| 1558 |
+
if adjusted_frames < original_frame_count:
|
| 1559 |
+
# 截断多余的帧
|
| 1560 |
+
frames = frames[:adjusted_frames]
|
| 1561 |
+
print(
|
| 1562 |
+
f">>> 已截断视频: {original_frame_count} -> {len(frames)} 帧"
|
| 1563 |
+
)
|
| 1564 |
+
else:
|
| 1565 |
+
# 填充黑帧 (复制最后一帧)
|
| 1566 |
+
last_frame = frames[-1] if frames else None
|
| 1567 |
+
if last_frame is not None:
|
| 1568 |
+
h, w = last_frame.shape[:2]
|
| 1569 |
+
black_frame = np.zeros((h, w, 3), dtype=np.uint8)
|
| 1570 |
+
while len(frames) < adjusted_frames:
|
| 1571 |
+
frames.append(black_frame.copy())
|
| 1572 |
+
print(
|
| 1573 |
+
f">>> 已填充视频: {original_frame_count} -> {len(frames)} 帧"
|
| 1574 |
+
)
|
| 1575 |
+
|
| 1576 |
+
# 保存调整后的视频到临时文件
|
| 1577 |
+
adjusted_video_fd = tempfile.NamedTemporaryFile(
|
| 1578 |
+
suffix=".mp4", delete=False
|
| 1579 |
+
)
|
| 1580 |
+
adjusted_video_path = adjusted_video_fd.name
|
| 1581 |
+
adjusted_video_fd.close()
|
| 1582 |
+
|
| 1583 |
+
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
| 1584 |
+
out = cv2.VideoWriter(
|
| 1585 |
+
adjusted_video_path,
|
| 1586 |
+
fourcc,
|
| 1587 |
+
fps,
|
| 1588 |
+
(frames[0].shape[1], frames[0].shape[0]),
|
| 1589 |
+
)
|
| 1590 |
+
for frame in frames:
|
| 1591 |
+
out.write(frame)
|
| 1592 |
+
out.release()
|
| 1593 |
+
|
| 1594 |
+
video_path = adjusted_video_path
|
| 1595 |
+
num_frames = adjusted_frames
|
| 1596 |
+
print(
|
| 1597 |
+
f">>> 视频帧数调整完成: {original_frame_count} -> {num_frames}"
|
| 1598 |
+
)
|
| 1599 |
+
|
| 1600 |
+
except ImportError:
|
| 1601 |
+
# cv2不可用,尝试使用LTX内置方法
|
| 1602 |
+
try:
|
| 1603 |
+
from ltx_pipelines.utils.media_io import (
|
| 1604 |
+
read_video_stream,
|
| 1605 |
+
write_video_stream,
|
| 1606 |
+
)
|
| 1607 |
+
import numpy as np
|
| 1608 |
+
|
| 1609 |
+
frames, audio_data = read_video_stream(video_path, fps)
|
| 1610 |
+
original_frame_count = len(frames)
|
| 1611 |
+
|
| 1612 |
+
if adjusted_frames < original_frame_count:
|
| 1613 |
+
frames = frames[:adjusted_frames]
|
| 1614 |
+
else:
|
| 1615 |
+
while len(frames) < adjusted_frames:
|
| 1616 |
+
frames = np.concatenate([frames, frames[-1:]], axis=0)
|
| 1617 |
+
|
| 1618 |
+
import tempfile
|
| 1619 |
+
|
| 1620 |
+
adjusted_video_fd = tempfile.NamedTemporaryFile(
|
| 1621 |
+
suffix=".mp4", delete=False
|
| 1622 |
+
)
|
| 1623 |
+
adjusted_video_path = adjusted_video_fd.name
|
| 1624 |
+
adjusted_video_fd.close()
|
| 1625 |
+
|
| 1626 |
+
write_video_stream(adjusted_video_path, frames, fps)
|
| 1627 |
+
video_path = adjusted_video_path
|
| 1628 |
+
num_frames = adjusted_frames
|
| 1629 |
+
print(
|
| 1630 |
+
f">>> 视频帧数调整完成: {original_frame_count} -> {num_frames}"
|
| 1631 |
+
)
|
| 1632 |
+
|
| 1633 |
+
except Exception as e2:
|
| 1634 |
+
print(f">>> 视频帧数自动调整失败: {e2}")
|
| 1635 |
+
return JSONResponse(
|
| 1636 |
+
status_code=400,
|
| 1637 |
+
content={
|
| 1638 |
+
"error": f"视频帧数({num_frames})不符合 8k+1 规则,且自动调整失败。请手动将视频帧数调整为 8k+1 格式(如 9, 17, 25, 33, 41, 49, 57, 65, 73, 81, 89, 97, 105 等)。"
|
| 1639 |
+
},
|
| 1640 |
+
)
|
| 1641 |
+
except Exception as e:
|
| 1642 |
+
print(f">>> 视频帧数自动调整失败: {e}")
|
| 1643 |
+
return JSONResponse(
|
| 1644 |
+
status_code=400,
|
| 1645 |
+
content={
|
| 1646 |
+
"error": f"视频帧数({num_frames})不符合 8k+1 规则,且自动调整失败。请手动将视频帧数调整为 8k+1 格式(如 9, 17, 25, 33, 41, 49, 57, 65, 73, 81, 89, 97, 105 等)。"
|
| 1647 |
+
},
|
| 1648 |
+
)
|
| 1649 |
+
|
| 1650 |
+
# 1. 加载模型
|
| 1651 |
+
pipeline_state = handler.pipelines.load_retake_pipeline(distilled=True)
|
| 1652 |
+
|
| 1653 |
+
# 3. 启动任务
|
| 1654 |
+
generation_id = uuid.uuid4().hex[:8]
|
| 1655 |
+
handler.generation.start_generation(generation_id)
|
| 1656 |
+
|
| 1657 |
+
# 核心修正:确保文件保存在动态的输出目录
|
| 1658 |
+
save_dir = get_dynamic_output_path()
|
| 1659 |
+
filename = f"upscale_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{generation_id}.mp4"
|
| 1660 |
+
full_output_path = save_dir / filename
|
| 1661 |
+
|
| 1662 |
+
# 3. 执行真正的超分逻辑
|
| 1663 |
+
try:
|
| 1664 |
+
# 注入目标分辨率和重绘幅度
|
| 1665 |
+
pipeline_state.pipeline._target_width = target_w
|
| 1666 |
+
pipeline_state.pipeline._target_height = target_h
|
| 1667 |
+
pipeline_state.pipeline._target_strength = strength
|
| 1668 |
+
|
| 1669 |
+
def do_generate():
|
| 1670 |
+
pipeline_state.pipeline.generate(
|
| 1671 |
+
video_path=str(video_path),
|
| 1672 |
+
prompt=prompt,
|
| 1673 |
+
start_time=0.0,
|
| 1674 |
+
end_time=float(num_frames / fps),
|
| 1675 |
+
seed=int(time.time()) % 2147483647,
|
| 1676 |
+
output_path=str(full_output_path),
|
| 1677 |
+
distilled=True,
|
| 1678 |
+
regenerate_video=True,
|
| 1679 |
+
regenerate_audio=False,
|
| 1680 |
+
)
|
| 1681 |
+
|
| 1682 |
+
# 重要修复:放到线程池运行,避免阻塞主循环导致前端拿不到显存数据
|
| 1683 |
+
from starlette.concurrency import run_in_threadpool
|
| 1684 |
+
|
| 1685 |
+
await run_in_threadpool(do_generate)
|
| 1686 |
+
|
| 1687 |
+
handler.generation.complete_generation(str(full_output_path))
|
| 1688 |
+
return {"status": "complete", "video_path": filename}
|
| 1689 |
+
except Exception as e:
|
| 1690 |
+
# OOM 异常逃逸修复:强制返回友好的异常信息
|
| 1691 |
+
try:
|
| 1692 |
+
handler.generation.cancel_generation()
|
| 1693 |
+
except Exception:
|
| 1694 |
+
pass
|
| 1695 |
+
if hasattr(handler.generation, "_generation_id"):
|
| 1696 |
+
handler.generation._generation_id = None
|
| 1697 |
+
if hasattr(handler.generation, "_is_generating"):
|
| 1698 |
+
handler.generation._is_generating = False
|
| 1699 |
+
|
| 1700 |
+
error_msg = str(e)
|
| 1701 |
+
if "CUDA out of memory" in error_msg:
|
| 1702 |
+
error_msg = "🚨 显存不足 (OOM):视频时长过长或目标分辨率超出了当前显卡的承载极限,请降低目标分辨率重试!"
|
| 1703 |
+
raise RuntimeError(error_msg) from e
|
| 1704 |
+
finally:
|
| 1705 |
+
if hasattr(pipeline_state.pipeline, "_target_width"):
|
| 1706 |
+
del pipeline_state.pipeline._target_width
|
| 1707 |
+
if hasattr(pipeline_state.pipeline, "_target_height"):
|
| 1708 |
+
del pipeline_state.pipeline._target_height
|
| 1709 |
+
if hasattr(pipeline_state.pipeline, "_target_strength"):
|
| 1710 |
+
del pipeline_state.pipeline._target_strength
|
| 1711 |
+
import gc
|
| 1712 |
+
|
| 1713 |
+
gc.collect()
|
| 1714 |
+
if (
|
| 1715 |
+
getattr(torch, "cuda", None) is not None
|
| 1716 |
+
and torch.cuda.is_available()
|
| 1717 |
+
):
|
| 1718 |
+
torch.cuda.empty_cache()
|
| 1719 |
+
from low_vram_runtime import maybe_release_pipeline_after_task
|
| 1720 |
+
|
| 1721 |
+
try:
|
| 1722 |
+
maybe_release_pipeline_after_task(handler)
|
| 1723 |
+
except Exception:
|
| 1724 |
+
pass
|
| 1725 |
+
|
| 1726 |
+
except Exception as e:
|
| 1727 |
+
import traceback
|
| 1728 |
+
|
| 1729 |
+
traceback.print_exc()
|
| 1730 |
+
return JSONResponse(status_code=500, content={"error": str(e)})
|
| 1731 |
+
|
| 1732 |
+
# ------------------
|
| 1733 |
+
|
| 1734 |
+
@app.post("/api/system/upload-image")
|
| 1735 |
+
async def route_upload_image(request: Request):
|
| 1736 |
+
try:
|
| 1737 |
+
import uuid
|
| 1738 |
+
import base64
|
| 1739 |
+
|
| 1740 |
+
# 接收 JSON 而不是 Multipart,绕过 python-multipart 缺失问题
|
| 1741 |
+
data = await request.json()
|
| 1742 |
+
b64_data = data.get("image")
|
| 1743 |
+
filename = data.get("filename", "image.png")
|
| 1744 |
+
|
| 1745 |
+
if not b64_data:
|
| 1746 |
+
return JSONResponse(
|
| 1747 |
+
status_code=400, content={"error": "No image data provided"}
|
| 1748 |
+
)
|
| 1749 |
+
|
| 1750 |
+
# 处理 base64 头部 (例如 data:image/png;base64,...)
|
| 1751 |
+
if "," in b64_data:
|
| 1752 |
+
b64_data = b64_data.split(",")[1]
|
| 1753 |
+
|
| 1754 |
+
image_bytes = base64.b64decode(b64_data)
|
| 1755 |
+
|
| 1756 |
+
# 确保上传目录存在
|
| 1757 |
+
upload_dir = get_dynamic_output_path() / "uploads"
|
| 1758 |
+
upload_dir.mkdir(parents=True, exist_ok=True)
|
| 1759 |
+
|
| 1760 |
+
safe_filename = "".join([c for c in filename if c.isalnum() or c in "._-"])
|
| 1761 |
+
file_path = upload_dir / f"up_{uuid.uuid4().hex[:6]}_{safe_filename}"
|
| 1762 |
+
|
| 1763 |
+
with file_path.open("wb") as buffer:
|
| 1764 |
+
buffer.write(image_bytes)
|
| 1765 |
+
|
| 1766 |
+
return {"status": "success", "path": str(file_path)}
|
| 1767 |
+
except Exception as e:
|
| 1768 |
+
import traceback
|
| 1769 |
+
|
| 1770 |
+
error_msg = f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}"
|
| 1771 |
+
print(f"Upload error: {error_msg}")
|
| 1772 |
+
return JSONResponse(
|
| 1773 |
+
status_code=500, content={"error": str(e), "detail": error_msg}
|
| 1774 |
+
)
|
| 1775 |
+
|
| 1776 |
+
# ------------------
|
| 1777 |
+
# 批量首尾帧:与「视频生成」相同的首尾帧推理,按顺序生成 N-1 段后可选 ffmpeg 拼接
|
| 1778 |
+
# ------------------
|
| 1779 |
+
|
| 1780 |
+
def _find_ffmpeg_binary() -> str | None:
|
| 1781 |
+
"""尽量找到 ffmpeg:环境变量 → imageio-ffmpeg 自带 → PATH → 常见安装位置 → WinGet。"""
|
| 1782 |
+
import shutil
|
| 1783 |
+
import sys
|
| 1784 |
+
|
| 1785 |
+
def _ok(p: str | None) -> str | None:
|
| 1786 |
+
if not p:
|
| 1787 |
+
return None
|
| 1788 |
+
p = os.path.normpath(os.path.expandvars(str(p).strip().strip('"')))
|
| 1789 |
+
return p if os.path.isfile(p) else None
|
| 1790 |
+
|
| 1791 |
+
for env_key in ("LTX_FFMPEG_PATH", "FFMPEG_PATH"):
|
| 1792 |
+
hit = _ok(os.environ.get(env_key))
|
| 1793 |
+
if hit:
|
| 1794 |
+
print(f"[batch-merge] ffmpeg from {env_key}: {hit}")
|
| 1795 |
+
return hit
|
| 1796 |
+
|
| 1797 |
+
try:
|
| 1798 |
+
pref = _ltx_desktop_config_dir() / "ffmpeg_path.txt"
|
| 1799 |
+
if pref.is_file():
|
| 1800 |
+
line = pref.read_text(encoding="utf-8").splitlines()[0].strip()
|
| 1801 |
+
hit = _ok(line)
|
| 1802 |
+
if hit:
|
| 1803 |
+
print(f"[batch-merge] ffmpeg from ffmpeg_path.txt: {hit}")
|
| 1804 |
+
return hit
|
| 1805 |
+
except Exception as _e:
|
| 1806 |
+
print(f"[batch-merge] ffmpeg_path.txt: {_e!r}")
|
| 1807 |
+
|
| 1808 |
+
# imageio-ffmpeg:多数视频/ML 环境会带上独立 ffmpeg 可执行文件
|
| 1809 |
+
try:
|
| 1810 |
+
import imageio_ffmpeg
|
| 1811 |
+
|
| 1812 |
+
hit = _ok(imageio_ffmpeg.get_ffmpeg_exe())
|
| 1813 |
+
if hit:
|
| 1814 |
+
print(f"[batch-merge] ffmpeg from imageio_ffmpeg: {hit}")
|
| 1815 |
+
return hit
|
| 1816 |
+
except Exception as _e:
|
| 1817 |
+
print(f"[batch-merge] imageio_ffmpeg: {_e!r}")
|
| 1818 |
+
|
| 1819 |
+
for name in ("ffmpeg", "ffmpeg.exe"):
|
| 1820 |
+
hit = _ok(shutil.which(name))
|
| 1821 |
+
if hit:
|
| 1822 |
+
print(f"[batch-merge] ffmpeg from PATH which({name}): {hit}")
|
| 1823 |
+
return hit
|
| 1824 |
+
|
| 1825 |
+
# 显式遍历 PATH 中的目录(某些环境下 which 不可靠)
|
| 1826 |
+
path_env = os.environ.get("PATH", "") or os.environ.get("Path", "")
|
| 1827 |
+
for folder in path_env.split(os.pathsep):
|
| 1828 |
+
folder = folder.strip().strip('"')
|
| 1829 |
+
if not folder:
|
| 1830 |
+
continue
|
| 1831 |
+
for exe in ("ffmpeg.exe", "ffmpeg"):
|
| 1832 |
+
hit = _ok(os.path.join(folder, exe))
|
| 1833 |
+
if hit:
|
| 1834 |
+
print(f"[batch-merge] ffmpeg from PATH scan: {hit}")
|
| 1835 |
+
return hit
|
| 1836 |
+
|
| 1837 |
+
localappdata = os.environ.get("LOCALAPPDATA", "") or ""
|
| 1838 |
+
programfiles = os.environ.get("ProgramFiles", r"C:\Program Files")
|
| 1839 |
+
programfiles_x86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
| 1840 |
+
userprofile = os.environ.get("USERPROFILE", "") or ""
|
| 1841 |
+
|
| 1842 |
+
static_candidates: list[str] = [
|
| 1843 |
+
os.path.join(os.path.dirname(sys.executable), "ffmpeg.exe"),
|
| 1844 |
+
os.path.join(os.path.dirname(sys.executable), "ffmpeg"),
|
| 1845 |
+
os.path.join(localappdata, "LTXDesktop", "ffmpeg.exe"),
|
| 1846 |
+
os.path.join(programfiles, "LTX Desktop", "ffmpeg.exe"),
|
| 1847 |
+
os.path.join(programfiles, "ffmpeg", "bin", "ffmpeg.exe"),
|
| 1848 |
+
os.path.join(programfiles_x86, "ffmpeg", "bin", "ffmpeg.exe"),
|
| 1849 |
+
r"C:\ffmpeg\bin\ffmpeg.exe",
|
| 1850 |
+
os.path.join(userprofile, "scoop", "shims", "ffmpeg.exe"),
|
| 1851 |
+
os.path.join(userprofile, "scoop", "apps", "ffmpeg", "current", "bin", "ffmpeg.exe"),
|
| 1852 |
+
r"C:\ProgramData\chocolatey\bin\ffmpeg.exe",
|
| 1853 |
+
]
|
| 1854 |
+
for c in static_candidates:
|
| 1855 |
+
hit = _ok(c)
|
| 1856 |
+
if hit:
|
| 1857 |
+
print(f"[batch-merge] ffmpeg static candidate: {hit}")
|
| 1858 |
+
return hit
|
| 1859 |
+
|
| 1860 |
+
# WinGet 安装的 Gyan / BtbN 等包:在 Packages 下搜索 ffmpeg.exe(限制深度避免过慢)
|
| 1861 |
+
try:
|
| 1862 |
+
wg = os.path.join(localappdata, "Microsoft", "WinGet", "Packages")
|
| 1863 |
+
if os.path.isdir(wg):
|
| 1864 |
+
for root, _dirs, files in os.walk(wg):
|
| 1865 |
+
if "ffmpeg.exe" in files:
|
| 1866 |
+
hit = _ok(os.path.join(root, "ffmpeg.exe"))
|
| 1867 |
+
if hit:
|
| 1868 |
+
print(f"[batch-merge] ffmpeg from WinGet tree: {hit}")
|
| 1869 |
+
return hit
|
| 1870 |
+
# 略过过深目录
|
| 1871 |
+
depth = root[len(wg) :].count(os.sep)
|
| 1872 |
+
if depth > 6:
|
| 1873 |
+
_dirs[:] = []
|
| 1874 |
+
except Exception as _e:
|
| 1875 |
+
print(f"[batch-merge] WinGet scan: {_e!r}")
|
| 1876 |
+
|
| 1877 |
+
print("[batch-merge] ffmpeg not found after extended search")
|
| 1878 |
+
return None
|
| 1879 |
+
|
| 1880 |
+
def _ffmpeg_concat_copy(
|
| 1881 |
+
segment_paths: list[str], output_mp4: str, ffmpeg_bin: str
|
| 1882 |
+
) -> None:
|
| 1883 |
+
import subprocess
|
| 1884 |
+
|
| 1885 |
+
out_abs = os.path.abspath(output_mp4)
|
| 1886 |
+
dyn_abs = os.path.abspath(str(get_dynamic_output_path()))
|
| 1887 |
+
lines: list[str] = []
|
| 1888 |
+
for p in segment_paths:
|
| 1889 |
+
ap = os.path.abspath(p)
|
| 1890 |
+
rel = os.path.relpath(ap, start=dyn_abs)
|
| 1891 |
+
rel = rel.replace("\\", "/")
|
| 1892 |
+
if "'" in rel:
|
| 1893 |
+
rel = rel.replace("'", "'\\''")
|
| 1894 |
+
lines.append(f"file '{rel}'")
|
| 1895 |
+
list_body = "\n".join(lines) + "\n"
|
| 1896 |
+
list_path = os.path.join(dyn_abs, f"_batch_concat_{os.getpid()}_{time.time_ns()}.txt")
|
| 1897 |
+
try:
|
| 1898 |
+
Path(list_path).write_text(list_body, encoding="utf-8")
|
| 1899 |
+
cmd = [
|
| 1900 |
+
ffmpeg_bin,
|
| 1901 |
+
"-y",
|
| 1902 |
+
"-f",
|
| 1903 |
+
"concat",
|
| 1904 |
+
"-safe",
|
| 1905 |
+
"0",
|
| 1906 |
+
"-i",
|
| 1907 |
+
list_path,
|
| 1908 |
+
"-c",
|
| 1909 |
+
"copy",
|
| 1910 |
+
out_abs,
|
| 1911 |
+
]
|
| 1912 |
+
proc = subprocess.run(
|
| 1913 |
+
cmd,
|
| 1914 |
+
capture_output=True,
|
| 1915 |
+
text=True,
|
| 1916 |
+
encoding="utf-8",
|
| 1917 |
+
errors="replace",
|
| 1918 |
+
)
|
| 1919 |
+
if proc.returncode != 0:
|
| 1920 |
+
err = (proc.stderr or proc.stdout or "").strip()
|
| 1921 |
+
raise RuntimeError(
|
| 1922 |
+
f"ffmpeg 拼接失败 (code {proc.returncode}): {err[:800]}"
|
| 1923 |
+
)
|
| 1924 |
+
finally:
|
| 1925 |
+
try:
|
| 1926 |
+
if os.path.isfile(list_path):
|
| 1927 |
+
os.unlink(list_path)
|
| 1928 |
+
except OSError:
|
| 1929 |
+
pass
|
| 1930 |
+
|
| 1931 |
+
def _ffmpeg_mux_background_audio(
|
| 1932 |
+
ffmpeg_bin: str, video_in: str, audio_in: str, video_out: str
|
| 1933 |
+
) -> None:
|
| 1934 |
+
"""成片只保留原视频画面,音轨替换为一条外部音频(与多段各自 AI 音频相比更统一)。"""
|
| 1935 |
+
import subprocess
|
| 1936 |
+
|
| 1937 |
+
out_abs = os.path.abspath(video_out)
|
| 1938 |
+
proc = subprocess.run(
|
| 1939 |
+
[
|
| 1940 |
+
ffmpeg_bin,
|
| 1941 |
+
"-y",
|
| 1942 |
+
"-i",
|
| 1943 |
+
os.path.abspath(video_in),
|
| 1944 |
+
"-i",
|
| 1945 |
+
os.path.abspath(audio_in),
|
| 1946 |
+
"-map",
|
| 1947 |
+
"0:v:0",
|
| 1948 |
+
"-map",
|
| 1949 |
+
"1:a:0",
|
| 1950 |
+
"-c:v",
|
| 1951 |
+
"copy",
|
| 1952 |
+
"-c:a",
|
| 1953 |
+
"aac",
|
| 1954 |
+
"-b:a",
|
| 1955 |
+
"192k",
|
| 1956 |
+
"-shortest",
|
| 1957 |
+
out_abs,
|
| 1958 |
+
],
|
| 1959 |
+
capture_output=True,
|
| 1960 |
+
text=True,
|
| 1961 |
+
encoding="utf-8",
|
| 1962 |
+
errors="replace",
|
| 1963 |
+
)
|
| 1964 |
+
if proc.returncode != 0:
|
| 1965 |
+
err = (proc.stderr or proc.stdout or "").strip()
|
| 1966 |
+
raise RuntimeError(
|
| 1967 |
+
f"配乐混流失败 (code {proc.returncode}): {err[:800]}"
|
| 1968 |
+
)
|
| 1969 |
+
|
| 1970 |
+
@app.post("/api/generate-batch")
|
| 1971 |
+
async def route_generate_batch(request: Request):
|
| 1972 |
+
"""多关键帧:相邻两帧一段首尾帧视频,与 POST /api/generate 同源逻辑;多段用 ffmpeg concat。"""
|
| 1973 |
+
from starlette.concurrency import run_in_threadpool
|
| 1974 |
+
|
| 1975 |
+
from server_utils.media_validation import normalize_optional_path
|
| 1976 |
+
|
| 1977 |
+
try:
|
| 1978 |
+
data = await request.json()
|
| 1979 |
+
segments_in = data.get("segments") or []
|
| 1980 |
+
if not segments_in:
|
| 1981 |
+
return JSONResponse(
|
| 1982 |
+
status_code=400,
|
| 1983 |
+
content={"error": "segments 不能为空"},
|
| 1984 |
+
)
|
| 1985 |
+
|
| 1986 |
+
resolution = data.get("resolution") or "720p"
|
| 1987 |
+
aspect_ratio = data.get("aspectRatio") or "16:9"
|
| 1988 |
+
neg = data.get(
|
| 1989 |
+
"negativePrompt",
|
| 1990 |
+
"low quality, blurry, noisy, static noise, distorted",
|
| 1991 |
+
)
|
| 1992 |
+
model = data.get("model") or "ltx-2"
|
| 1993 |
+
fps = str(data.get("fps") or "24")
|
| 1994 |
+
audio = str(data.get("audio") or "false").lower()
|
| 1995 |
+
camera_motion = data.get("cameraMotion") or "static"
|
| 1996 |
+
model_path = data.get("modelPath")
|
| 1997 |
+
lora_path = data.get("loraPath")
|
| 1998 |
+
lora_strength = float(data.get("loraStrength") or 1.0)
|
| 1999 |
+
|
| 2000 |
+
vg = getattr(handler, "video_generation", None)
|
| 2001 |
+
if vg is None or not callable(getattr(vg, "generate", None)):
|
| 2002 |
+
return JSONResponse(
|
| 2003 |
+
status_code=500,
|
| 2004 |
+
content={"error": "内部错误:找不到 video_generation 处理器"},
|
| 2005 |
+
)
|
| 2006 |
+
|
| 2007 |
+
clip_paths: list[str] = []
|
| 2008 |
+
for idx, seg in enumerate(segments_in):
|
| 2009 |
+
start_raw = seg.get("startImage") or seg.get("startFramePath")
|
| 2010 |
+
end_raw = seg.get("endImage") or seg.get("endFramePath")
|
| 2011 |
+
start_p = normalize_optional_path(start_raw)
|
| 2012 |
+
end_p = normalize_optional_path(end_raw)
|
| 2013 |
+
if not start_p or not os.path.isfile(start_p):
|
| 2014 |
+
return JSONResponse(
|
| 2015 |
+
status_code=400,
|
| 2016 |
+
content={"error": f"片段 {idx + 1} 起始图路径无效"},
|
| 2017 |
+
)
|
| 2018 |
+
if not end_p or not os.path.isfile(end_p):
|
| 2019 |
+
return JSONResponse(
|
| 2020 |
+
status_code=400,
|
| 2021 |
+
content={"error": f"片段 {idx + 1} 结束图路径无效"},
|
| 2022 |
+
)
|
| 2023 |
+
|
| 2024 |
+
dur = seg.get("duration", 5)
|
| 2025 |
+
try:
|
| 2026 |
+
dur_i = int(float(dur))
|
| 2027 |
+
except (TypeError, ValueError):
|
| 2028 |
+
dur_i = 5
|
| 2029 |
+
dur_i = max(1, min(60, dur_i))
|
| 2030 |
+
|
| 2031 |
+
prompt_text = (seg.get("prompt") or "").strip()
|
| 2032 |
+
if not prompt_text:
|
| 2033 |
+
prompt_text = "cinematic transition"
|
| 2034 |
+
|
| 2035 |
+
req = GenerateVideoRequest(
|
| 2036 |
+
prompt=prompt_text,
|
| 2037 |
+
resolution=resolution,
|
| 2038 |
+
model=model,
|
| 2039 |
+
cameraMotion=camera_motion,
|
| 2040 |
+
negativePrompt=neg,
|
| 2041 |
+
duration=str(dur_i),
|
| 2042 |
+
fps=fps,
|
| 2043 |
+
audio=audio,
|
| 2044 |
+
imagePath=None,
|
| 2045 |
+
audioPath=None,
|
| 2046 |
+
startFramePath=start_p,
|
| 2047 |
+
endFramePath=end_p,
|
| 2048 |
+
aspectRatio=aspect_ratio,
|
| 2049 |
+
modelPath=model_path,
|
| 2050 |
+
loraPath=lora_path,
|
| 2051 |
+
loraStrength=lora_strength,
|
| 2052 |
+
)
|
| 2053 |
+
|
| 2054 |
+
def _one_gen(r: GenerateVideoRequest = req):
|
| 2055 |
+
return vg.generate(r)
|
| 2056 |
+
|
| 2057 |
+
resp = await run_in_threadpool(_one_gen)
|
| 2058 |
+
if resp.status != "complete" or not resp.video_path:
|
| 2059 |
+
return JSONResponse(
|
| 2060 |
+
status_code=500,
|
| 2061 |
+
content={
|
| 2062 |
+
"error": f"片段 {idx + 1} 生成失败: status={getattr(resp, 'status', None)}"
|
| 2063 |
+
},
|
| 2064 |
+
)
|
| 2065 |
+
clip_paths.append(str(resp.video_path))
|
| 2066 |
+
|
| 2067 |
+
if len(clip_paths) == 1:
|
| 2068 |
+
final_path = clip_paths[0]
|
| 2069 |
+
else:
|
| 2070 |
+
ff = _find_ffmpeg_binary()
|
| 2071 |
+
if not ff:
|
| 2072 |
+
return JSONResponse(
|
| 2073 |
+
status_code=500,
|
| 2074 |
+
content={
|
| 2075 |
+
"error": (
|
| 2076 |
+
"已生成多段视频,但未找到 ffmpeg,无法拼接。"
|
| 2077 |
+
" 可选:① 安装 ffmpeg 并加入系统 PATH;"
|
| 2078 |
+
" ② 设置环境变量 LTX_FFMPEG_PATH 指向 ffmpeg.exe;"
|
| 2079 |
+
" ③ 在 %LOCALAPPDATA%\\LTXDesktop\\ffmpeg_path.txt 第一行写入 ffmpeg.exe 的完整路径。"
|
| 2080 |
+
),
|
| 2081 |
+
"segment_paths": clip_paths,
|
| 2082 |
+
},
|
| 2083 |
+
)
|
| 2084 |
+
import uuid as _uuid
|
| 2085 |
+
|
| 2086 |
+
out_dir = get_dynamic_output_path()
|
| 2087 |
+
final_path = str(
|
| 2088 |
+
out_dir / f"batch_merged_{_uuid.uuid4().hex[:10]}.mp4"
|
| 2089 |
+
)
|
| 2090 |
+
try:
|
| 2091 |
+
_ffmpeg_concat_copy(clip_paths, final_path, ff)
|
| 2092 |
+
except Exception as ex:
|
| 2093 |
+
return JSONResponse(
|
| 2094 |
+
status_code=500,
|
| 2095 |
+
content={
|
| 2096 |
+
"error": str(ex),
|
| 2097 |
+
"segment_paths": clip_paths,
|
| 2098 |
+
},
|
| 2099 |
+
)
|
| 2100 |
+
|
| 2101 |
+
bg_audio = normalize_optional_path(
|
| 2102 |
+
data.get("backgroundAudioPath")
|
| 2103 |
+
or data.get("batchBackgroundAudioPath")
|
| 2104 |
+
)
|
| 2105 |
+
if bg_audio and os.path.isfile(bg_audio):
|
| 2106 |
+
ff_mux = _find_ffmpeg_binary()
|
| 2107 |
+
if not ff_mux:
|
| 2108 |
+
return JSONResponse(
|
| 2109 |
+
status_code=500,
|
| 2110 |
+
content={
|
| 2111 |
+
"error": "已生成视频,但混入配乐需要 ffmpeg,请配置 LTX_FFMPEG_PATH 或 ffmpeg_path.txt",
|
| 2112 |
+
"video_path": final_path,
|
| 2113 |
+
},
|
| 2114 |
+
)
|
| 2115 |
+
import uuid as _uuid2
|
| 2116 |
+
|
| 2117 |
+
out_mux = str(
|
| 2118 |
+
get_dynamic_output_path()
|
| 2119 |
+
/ f"batch_with_audio_{_uuid2.uuid4().hex[:10]}.mp4"
|
| 2120 |
+
)
|
| 2121 |
+
try:
|
| 2122 |
+
_ffmpeg_mux_background_audio(ff_mux, final_path, bg_audio, out_mux)
|
| 2123 |
+
final_path = out_mux
|
| 2124 |
+
except Exception as ex:
|
| 2125 |
+
return JSONResponse(
|
| 2126 |
+
status_code=500,
|
| 2127 |
+
content={
|
| 2128 |
+
"error": str(ex),
|
| 2129 |
+
"video_path": final_path,
|
| 2130 |
+
},
|
| 2131 |
+
)
|
| 2132 |
+
|
| 2133 |
+
return GenerateVideoResponse(status="complete", video_path=final_path)
|
| 2134 |
+
except Exception as e:
|
| 2135 |
+
import traceback
|
| 2136 |
+
|
| 2137 |
+
traceback.print_exc()
|
| 2138 |
+
return JSONResponse(status_code=500, content={"error": str(e)})
|
| 2139 |
+
|
| 2140 |
+
# ------------------
|
| 2141 |
+
|
| 2142 |
+
@app.get("/api/system/history")
|
| 2143 |
+
async def route_get_history(request: Request):
|
| 2144 |
+
try:
|
| 2145 |
+
import os
|
| 2146 |
+
|
| 2147 |
+
page = int(request.query_params.get("page", 1))
|
| 2148 |
+
limit = int(request.query_params.get("limit", 20))
|
| 2149 |
+
|
| 2150 |
+
history = []
|
| 2151 |
+
dyn_path = get_dynamic_output_path()
|
| 2152 |
+
if dyn_path.exists():
|
| 2153 |
+
for filename in os.listdir(dyn_path):
|
| 2154 |
+
if filename == "uploads":
|
| 2155 |
+
continue
|
| 2156 |
+
full_path = dyn_path / filename
|
| 2157 |
+
if full_path.is_file() and filename.lower().endswith(
|
| 2158 |
+
(".mp4", ".png", ".jpg", ".webp")
|
| 2159 |
+
):
|
| 2160 |
+
mtime = os.path.getmtime(full_path)
|
| 2161 |
+
history.append(
|
| 2162 |
+
{
|
| 2163 |
+
"filename": filename,
|
| 2164 |
+
"type": "video"
|
| 2165 |
+
if filename.lower().endswith(".mp4")
|
| 2166 |
+
else "image",
|
| 2167 |
+
"mtime": mtime,
|
| 2168 |
+
"fullpath": str(full_path),
|
| 2169 |
+
}
|
| 2170 |
+
)
|
| 2171 |
+
history.sort(key=lambda x: x["mtime"], reverse=True)
|
| 2172 |
+
|
| 2173 |
+
total_items = len(history)
|
| 2174 |
+
total_pages = (total_items + limit - 1) // limit
|
| 2175 |
+
start_idx = (page - 1) * limit
|
| 2176 |
+
end_idx = start_idx + limit
|
| 2177 |
+
|
| 2178 |
+
return {
|
| 2179 |
+
"status": "success",
|
| 2180 |
+
"history": history[start_idx:end_idx],
|
| 2181 |
+
"total_pages": total_pages,
|
| 2182 |
+
"current_page": page,
|
| 2183 |
+
"total_items": total_items,
|
| 2184 |
+
}
|
| 2185 |
+
except Exception as e:
|
| 2186 |
+
return JSONResponse(status_code=500, content={"error": str(e)})
|
| 2187 |
+
|
| 2188 |
+
@app.post("/api/system/delete-file")
|
| 2189 |
+
async def route_delete_file(request: Request):
|
| 2190 |
+
try:
|
| 2191 |
+
import os
|
| 2192 |
+
|
| 2193 |
+
data = await request.json()
|
| 2194 |
+
filename = data.get("filename", "")
|
| 2195 |
+
|
| 2196 |
+
if not filename:
|
| 2197 |
+
return JSONResponse(
|
| 2198 |
+
status_code=400, content={"error": "Filename is required"}
|
| 2199 |
+
)
|
| 2200 |
+
|
| 2201 |
+
dyn_path = get_dynamic_output_path()
|
| 2202 |
+
file_path = dyn_path / filename
|
| 2203 |
+
|
| 2204 |
+
if file_path.exists() and file_path.is_file():
|
| 2205 |
+
file_path.unlink()
|
| 2206 |
+
return {"status": "success", "message": "File deleted"}
|
| 2207 |
+
else:
|
| 2208 |
+
return JSONResponse(
|
| 2209 |
+
status_code=404, content={"error": "File not found"}
|
| 2210 |
+
)
|
| 2211 |
+
except Exception as e:
|
| 2212 |
+
return JSONResponse(status_code=500, content={"error": str(e)})
|
| 2213 |
+
|
| 2214 |
+
# 路由注册
|
| 2215 |
+
app.include_router(health_router)
|
| 2216 |
+
app.include_router(generation_router)
|
| 2217 |
+
app.include_router(models_router)
|
| 2218 |
+
app.include_router(settings_router)
|
| 2219 |
+
app.include_router(image_gen_router)
|
| 2220 |
+
app.include_router(suggest_gap_prompt_router)
|
| 2221 |
+
app.include_router(retake_router)
|
| 2222 |
+
app.include_router(ic_lora_router)
|
| 2223 |
+
app.include_router(runtime_policy_router)
|
| 2224 |
+
|
| 2225 |
+
# --- [安全补丁] 状态栏显示修复 ---
|
| 2226 |
+
|
| 2227 |
+
# --- 最终状态栏修复补丁: 只要服务运行且 GPU 没死,就视为就绪 ---
|
| 2228 |
+
from handlers.health_handler import HealthHandler
|
| 2229 |
+
|
| 2230 |
+
if not hasattr(HealthHandler, "_fixed_v2"):
|
| 2231 |
+
_orig_get_health = HealthHandler.get_health
|
| 2232 |
+
|
| 2233 |
+
def patched_health_v2(self):
|
| 2234 |
+
resp = _orig_get_health(self)
|
| 2235 |
+
# 解析:如果后端逻辑还在判断模型未加载,我们检查一下核心状态
|
| 2236 |
+
# 如果系统没有崩溃,我们就强制标记为已加载,让前端允许交互
|
| 2237 |
+
if not resp.models_loaded:
|
| 2238 |
+
# 我们认为只要 API 能通,底层状态服务(state)只要存在,就视为由于异步加载引起的暂时性 False
|
| 2239 |
+
# 直接返回 True,前端会显示"待机就绪"
|
| 2240 |
+
resp.models_loaded = True
|
| 2241 |
+
return resp
|
| 2242 |
+
|
| 2243 |
+
HealthHandler.get_health = patched_health_v2
|
| 2244 |
+
HealthHandler._fixed_v2 = True
|
| 2245 |
+
# ------------------------------------------------------------
|
| 2246 |
+
|
| 2247 |
+
# --- 修复显存采集指针:使得显存监控永远对准当前选定工作的 GPU ---
|
| 2248 |
+
from services.gpu_info.gpu_info_impl import GpuInfoImpl
|
| 2249 |
+
|
| 2250 |
+
if not hasattr(GpuInfoImpl, "_fixed_vram_patch"):
|
| 2251 |
+
_orig_get_gpu_info = GpuInfoImpl.get_gpu_info
|
| 2252 |
+
|
| 2253 |
+
def patched_get_gpu_info(self):
|
| 2254 |
+
import torch
|
| 2255 |
+
|
| 2256 |
+
if self.get_cuda_available():
|
| 2257 |
+
idx = 0
|
| 2258 |
+
if (
|
| 2259 |
+
hasattr(handler.config.device, "index")
|
| 2260 |
+
and handler.config.device.index is not None
|
| 2261 |
+
):
|
| 2262 |
+
idx = handler.config.device.index
|
| 2263 |
+
try:
|
| 2264 |
+
import pynvml
|
| 2265 |
+
|
| 2266 |
+
pynvml.nvmlInit()
|
| 2267 |
+
handle = pynvml.nvmlDeviceGetHandleByIndex(idx)
|
| 2268 |
+
raw_name = pynvml.nvmlDeviceGetName(handle)
|
| 2269 |
+
name = (
|
| 2270 |
+
raw_name.decode("utf-8", errors="replace")
|
| 2271 |
+
if isinstance(raw_name, bytes)
|
| 2272 |
+
else str(raw_name)
|
| 2273 |
+
)
|
| 2274 |
+
memory = pynvml.nvmlDeviceGetMemoryInfo(handle)
|
| 2275 |
+
pynvml.nvmlShutdown()
|
| 2276 |
+
return {
|
| 2277 |
+
"name": f"{name} [ID: {idx}]",
|
| 2278 |
+
"vram": memory.total // (1024 * 1024),
|
| 2279 |
+
"vramUsed": memory.used // (1024 * 1024),
|
| 2280 |
+
}
|
| 2281 |
+
except Exception:
|
| 2282 |
+
pass
|
| 2283 |
+
return _orig_get_gpu_info(self)
|
| 2284 |
+
|
| 2285 |
+
GpuInfoImpl.get_gpu_info = patched_get_gpu_info
|
| 2286 |
+
GpuInfoImpl._fixed_vram_patch = True
|
| 2287 |
+
|
| 2288 |
+
return app
|
LTX2.3/patches/handlers/__pycache__/video_generation_handler.cpython-313.pyc
ADDED
|
Binary file (36.5 kB). View file
|
|
|
LTX2.3/patches/handlers/video_generation_handler.py
ADDED
|
@@ -0,0 +1,868 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Video generation orchestration handler."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
import tempfile
|
| 8 |
+
import time
|
| 9 |
+
import uuid
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from threading import RLock
|
| 13 |
+
from typing import TYPE_CHECKING
|
| 14 |
+
|
| 15 |
+
from PIL import Image
|
| 16 |
+
|
| 17 |
+
from api_types import (
|
| 18 |
+
GenerateVideoRequest,
|
| 19 |
+
GenerateVideoResponse,
|
| 20 |
+
ImageConditioningInput,
|
| 21 |
+
VideoCameraMotion,
|
| 22 |
+
)
|
| 23 |
+
from _routes._errors import HTTPError
|
| 24 |
+
from handlers.base import StateHandlerBase
|
| 25 |
+
from handlers.generation_handler import GenerationHandler
|
| 26 |
+
from handlers.pipelines_handler import PipelinesHandler
|
| 27 |
+
from handlers.text_handler import TextHandler
|
| 28 |
+
from runtime_config.model_download_specs import resolve_model_path
|
| 29 |
+
from server_utils.media_validation import (
|
| 30 |
+
normalize_optional_path,
|
| 31 |
+
validate_audio_file,
|
| 32 |
+
validate_image_file,
|
| 33 |
+
)
|
| 34 |
+
from services.interfaces import LTXAPIClient
|
| 35 |
+
from state.app_state_types import AppState
|
| 36 |
+
from state.app_settings import should_video_generate_with_ltx_api
|
| 37 |
+
|
| 38 |
+
if TYPE_CHECKING:
|
| 39 |
+
from runtime_config.runtime_config import RuntimeConfig
|
| 40 |
+
|
| 41 |
+
logger = logging.getLogger(__name__)
|
| 42 |
+
|
| 43 |
+
FORCED_API_MODEL_MAP: dict[str, str] = {
|
| 44 |
+
"fast": "ltx-2-3-fast",
|
| 45 |
+
"pro": "ltx-2-3-pro",
|
| 46 |
+
}
|
| 47 |
+
FORCED_API_RESOLUTION_MAP: dict[str, dict[str, str]] = {
|
| 48 |
+
"1080p": {"16:9": "1920x1080", "9:16": "1080x1920"},
|
| 49 |
+
"1440p": {"16:9": "2560x1440", "9:16": "1440x2560"},
|
| 50 |
+
"2160p": {"16:9": "3840x2160", "9:16": "2160x3840"},
|
| 51 |
+
}
|
| 52 |
+
A2V_FORCED_API_RESOLUTION = "1920x1080"
|
| 53 |
+
FORCED_API_ALLOWED_ASPECT_RATIOS = {"16:9", "9:16"}
|
| 54 |
+
FORCED_API_ALLOWED_FPS = {24, 25, 48, 50}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _get_allowed_durations(model_id: str, resolution_label: str, fps: int) -> set[int]:
|
| 58 |
+
if model_id == "ltx-2-3-fast" and resolution_label == "1080p" and fps in {24, 25}:
|
| 59 |
+
return {6, 8, 10, 12, 14, 16, 18, 20}
|
| 60 |
+
return {6, 8, 10}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class VideoGenerationHandler(StateHandlerBase):
|
| 64 |
+
def __init__(
|
| 65 |
+
self,
|
| 66 |
+
state: AppState,
|
| 67 |
+
lock: RLock,
|
| 68 |
+
generation_handler: GenerationHandler,
|
| 69 |
+
pipelines_handler: PipelinesHandler,
|
| 70 |
+
text_handler: TextHandler,
|
| 71 |
+
ltx_api_client: LTXAPIClient,
|
| 72 |
+
config: RuntimeConfig,
|
| 73 |
+
) -> None:
|
| 74 |
+
super().__init__(state, lock, config)
|
| 75 |
+
self._generation = generation_handler
|
| 76 |
+
self._pipelines = pipelines_handler
|
| 77 |
+
self._text = text_handler
|
| 78 |
+
self._ltx_api_client = ltx_api_client
|
| 79 |
+
|
| 80 |
+
def generate(self, req: GenerateVideoRequest) -> GenerateVideoResponse:
|
| 81 |
+
if should_video_generate_with_ltx_api(
|
| 82 |
+
force_api_generations=self.config.force_api_generations,
|
| 83 |
+
settings=self.state.app_settings,
|
| 84 |
+
):
|
| 85 |
+
return self._generate_forced_api(req)
|
| 86 |
+
|
| 87 |
+
if self._generation.is_generation_running():
|
| 88 |
+
raise HTTPError(409, "Generation already in progress")
|
| 89 |
+
|
| 90 |
+
resolution = req.resolution
|
| 91 |
+
|
| 92 |
+
duration = int(float(req.duration))
|
| 93 |
+
fps = int(float(req.fps))
|
| 94 |
+
|
| 95 |
+
audio_path = normalize_optional_path(req.audioPath)
|
| 96 |
+
if audio_path:
|
| 97 |
+
return self._generate_a2v(req, duration, fps, audio_path=audio_path)
|
| 98 |
+
|
| 99 |
+
logger.info("Resolution %s - using fast pipeline", resolution)
|
| 100 |
+
|
| 101 |
+
RESOLUTION_MAP_16_9: dict[str, tuple[int, int]] = {
|
| 102 |
+
"540p": (1024, 576),
|
| 103 |
+
"720p": (1280, 704),
|
| 104 |
+
"1080p": (1920, 1088),
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
def get_16_9_size(res: str) -> tuple[int, int]:
|
| 108 |
+
return RESOLUTION_MAP_16_9.get(res, (1280, 704))
|
| 109 |
+
|
| 110 |
+
def get_9_16_size(res: str) -> tuple[int, int]:
|
| 111 |
+
w, h = get_16_9_size(res)
|
| 112 |
+
return h, w
|
| 113 |
+
|
| 114 |
+
match req.aspectRatio:
|
| 115 |
+
case "9:16":
|
| 116 |
+
width, height = get_9_16_size(resolution)
|
| 117 |
+
case "16:9":
|
| 118 |
+
width, height = get_16_9_size(resolution)
|
| 119 |
+
|
| 120 |
+
num_frames = self._compute_num_frames(duration, fps)
|
| 121 |
+
|
| 122 |
+
image = None
|
| 123 |
+
image_path = normalize_optional_path(req.imagePath)
|
| 124 |
+
if image_path:
|
| 125 |
+
image = self._prepare_image(image_path, width, height)
|
| 126 |
+
logger.info("Image: %s -> %sx%s", image_path, width, height)
|
| 127 |
+
|
| 128 |
+
generation_id = self._make_generation_id()
|
| 129 |
+
seed = self._resolve_seed()
|
| 130 |
+
|
| 131 |
+
logger.info(
|
| 132 |
+
f"Request loraPath: '{req.loraPath}', loraStrength: {req.loraStrength}, inferenceSteps: {req.inferenceSteps}"
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
# 尝试支持自定义步数(实验性)
|
| 136 |
+
inference_steps = req.inferenceSteps
|
| 137 |
+
logger.info(f"Using inference steps: {inference_steps}")
|
| 138 |
+
|
| 139 |
+
loras = None
|
| 140 |
+
if req.loraPath and req.loraPath.strip():
|
| 141 |
+
try:
|
| 142 |
+
import os
|
| 143 |
+
from pathlib import Path
|
| 144 |
+
from ltx_core.loader import LoraPathStrengthAndSDOps
|
| 145 |
+
from ltx_core.loader.sd_ops import LTXV_LORA_COMFY_RENAMING_MAP
|
| 146 |
+
|
| 147 |
+
lora_path = req.loraPath.strip()
|
| 148 |
+
logger.info(
|
| 149 |
+
f"LoRA path: {lora_path}, exists: {os.path.exists(lora_path)}"
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
if os.path.exists(lora_path):
|
| 153 |
+
loras = [
|
| 154 |
+
LoraPathStrengthAndSDOps(
|
| 155 |
+
path=lora_path,
|
| 156 |
+
strength=req.loraStrength,
|
| 157 |
+
sd_ops=LTXV_LORA_COMFY_RENAMING_MAP,
|
| 158 |
+
)
|
| 159 |
+
]
|
| 160 |
+
logger.info(
|
| 161 |
+
f"LoRA prepared: {lora_path} with strength {req.loraStrength}"
|
| 162 |
+
)
|
| 163 |
+
else:
|
| 164 |
+
logger.warning(f"LoRA file not found: {lora_path}")
|
| 165 |
+
except Exception as e:
|
| 166 |
+
logger.warning(f"Failed to load LoRA: {e}")
|
| 167 |
+
import traceback
|
| 168 |
+
|
| 169 |
+
logger.warning(f"LoRA traceback: {traceback.format_exc()}")
|
| 170 |
+
loras = None
|
| 171 |
+
|
| 172 |
+
lora_path_req = (req.loraPath or "").strip()
|
| 173 |
+
desired_sig = (
|
| 174 |
+
"fast",
|
| 175 |
+
lora_path_req if loras is not None else "",
|
| 176 |
+
round(float(req.loraStrength), 4) if loras is not None else 0.0,
|
| 177 |
+
)
|
| 178 |
+
try:
|
| 179 |
+
if loras is not None:
|
| 180 |
+
# 强制卸载并重新加载带LoRA的pipeline
|
| 181 |
+
logger.info("Unloading pipeline for LoRA...")
|
| 182 |
+
from keep_models_runtime import force_unload_gpu_pipeline
|
| 183 |
+
|
| 184 |
+
force_unload_gpu_pipeline(self._pipelines)
|
| 185 |
+
|
| 186 |
+
# 强制垃圾回收
|
| 187 |
+
import gc
|
| 188 |
+
|
| 189 |
+
gc.collect()
|
| 190 |
+
# 释放 CUDA 缓存,降低 LoRA 首次构建的显存峰值/碎片风险
|
| 191 |
+
try:
|
| 192 |
+
import torch
|
| 193 |
+
if torch.cuda.is_available():
|
| 194 |
+
torch.cuda.empty_cache()
|
| 195 |
+
torch.cuda.ipc_collect()
|
| 196 |
+
except Exception:
|
| 197 |
+
pass
|
| 198 |
+
|
| 199 |
+
gemma_root = self._pipelines._text_handler.resolve_gemma_root()
|
| 200 |
+
from runtime_config.model_download_specs import resolve_model_path
|
| 201 |
+
from services.fast_video_pipeline.ltx_fast_video_pipeline import (
|
| 202 |
+
LTXFastVideoPipeline,
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
checkpoint_path = str(
|
| 206 |
+
resolve_model_path(
|
| 207 |
+
self._pipelines.models_dir,
|
| 208 |
+
self._pipelines.config.model_download_specs,
|
| 209 |
+
"checkpoint",
|
| 210 |
+
)
|
| 211 |
+
)
|
| 212 |
+
upsampler_path = str(
|
| 213 |
+
resolve_model_path(
|
| 214 |
+
self._pipelines.models_dir,
|
| 215 |
+
self._pipelines.config.model_download_specs,
|
| 216 |
+
"upsampler",
|
| 217 |
+
)
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
logger.info(
|
| 221 |
+
f"Creating pipeline with LoRA: {loras}, steps: {inference_steps}"
|
| 222 |
+
)
|
| 223 |
+
from lora_injection import (
|
| 224 |
+
_lora_init_kwargs,
|
| 225 |
+
inject_loras_into_fast_pipeline,
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
lora_kw = _lora_init_kwargs(LTXFastVideoPipeline, loras)
|
| 229 |
+
pipeline = LTXFastVideoPipeline(
|
| 230 |
+
checkpoint_path,
|
| 231 |
+
gemma_root,
|
| 232 |
+
upsampler_path,
|
| 233 |
+
self._pipelines.config.device,
|
| 234 |
+
**lora_kw,
|
| 235 |
+
)
|
| 236 |
+
n_inj = inject_loras_into_fast_pipeline(pipeline, loras)
|
| 237 |
+
if hasattr(pipeline, "pipeline") and hasattr(
|
| 238 |
+
pipeline.pipeline, "model_ledger"
|
| 239 |
+
):
|
| 240 |
+
try:
|
| 241 |
+
pipeline.pipeline.model_ledger.loras = tuple(loras)
|
| 242 |
+
except Exception:
|
| 243 |
+
pass
|
| 244 |
+
logger.info(
|
| 245 |
+
"LoRA 注入: init_kw=%s, 注入点=%s, model_ledger.loras=%s",
|
| 246 |
+
list(lora_kw.keys()),
|
| 247 |
+
n_inj,
|
| 248 |
+
getattr(
|
| 249 |
+
getattr(pipeline.pipeline, "model_ledger", None),
|
| 250 |
+
"loras",
|
| 251 |
+
None,
|
| 252 |
+
),
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
from state.app_state_types import (
|
| 256 |
+
VideoPipelineState,
|
| 257 |
+
VideoPipelineWarmth,
|
| 258 |
+
GpuSlot,
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
state = VideoPipelineState(
|
| 262 |
+
pipeline=pipeline,
|
| 263 |
+
warmth=VideoPipelineWarmth.COLD,
|
| 264 |
+
is_compiled=False,
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
self._pipelines.state.gpu_slot = GpuSlot(
|
| 268 |
+
active_pipeline=state, generation=None
|
| 269 |
+
)
|
| 270 |
+
logger.info("Pipeline with LoRA loaded successfully")
|
| 271 |
+
else:
|
| 272 |
+
# 无论有没有LoRA,都尝试使用自定义步数重新加载pipeline
|
| 273 |
+
logger.info(f"Loading pipeline with {inference_steps} steps")
|
| 274 |
+
from keep_models_runtime import force_unload_gpu_pipeline
|
| 275 |
+
|
| 276 |
+
force_unload_gpu_pipeline(self._pipelines)
|
| 277 |
+
|
| 278 |
+
import gc
|
| 279 |
+
|
| 280 |
+
gc.collect()
|
| 281 |
+
|
| 282 |
+
gemma_root = self._pipelines._text_handler.resolve_gemma_root()
|
| 283 |
+
from runtime_config.model_download_specs import resolve_model_path
|
| 284 |
+
from services.fast_video_pipeline.ltx_fast_video_pipeline import (
|
| 285 |
+
LTXFastVideoPipeline,
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
checkpoint_path = str(
|
| 289 |
+
resolve_model_path(
|
| 290 |
+
self._pipelines.models_dir,
|
| 291 |
+
self._pipelines.config.model_download_specs,
|
| 292 |
+
"checkpoint",
|
| 293 |
+
)
|
| 294 |
+
)
|
| 295 |
+
upsampler_path = str(
|
| 296 |
+
resolve_model_path(
|
| 297 |
+
self._pipelines.models_dir,
|
| 298 |
+
self._pipelines.config.model_download_specs,
|
| 299 |
+
"upsampler",
|
| 300 |
+
)
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
pipeline = LTXFastVideoPipeline(
|
| 304 |
+
checkpoint_path,
|
| 305 |
+
gemma_root,
|
| 306 |
+
upsampler_path,
|
| 307 |
+
self._pipelines.config.device,
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
from state.app_state_types import (
|
| 311 |
+
VideoPipelineState,
|
| 312 |
+
VideoPipelineWarmth,
|
| 313 |
+
GpuSlot,
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
state = VideoPipelineState(
|
| 317 |
+
pipeline=pipeline,
|
| 318 |
+
warmth=VideoPipelineWarmth.COLD,
|
| 319 |
+
is_compiled=False,
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
self._pipelines.state.gpu_slot = GpuSlot(
|
| 323 |
+
active_pipeline=state, generation=None
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
self._pipelines._pipeline_signature = desired_sig
|
| 327 |
+
|
| 328 |
+
self._generation.start_generation(generation_id)
|
| 329 |
+
|
| 330 |
+
output_path = self.generate_video(
|
| 331 |
+
prompt=req.prompt,
|
| 332 |
+
image=image,
|
| 333 |
+
height=height,
|
| 334 |
+
width=width,
|
| 335 |
+
num_frames=num_frames,
|
| 336 |
+
fps=fps,
|
| 337 |
+
seed=seed,
|
| 338 |
+
camera_motion=req.cameraMotion,
|
| 339 |
+
negative_prompt=req.negativePrompt,
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
self._generation.complete_generation(output_path)
|
| 343 |
+
return GenerateVideoResponse(status="complete", video_path=output_path)
|
| 344 |
+
|
| 345 |
+
except Exception as e:
|
| 346 |
+
self._generation.fail_generation(str(e))
|
| 347 |
+
if "cancelled" in str(e).lower():
|
| 348 |
+
logger.info("Generation cancelled by user")
|
| 349 |
+
return GenerateVideoResponse(status="cancelled")
|
| 350 |
+
|
| 351 |
+
raise HTTPError(500, str(e)) from e
|
| 352 |
+
|
| 353 |
+
def generate_video(
|
| 354 |
+
self,
|
| 355 |
+
prompt: str,
|
| 356 |
+
image: Image.Image | None,
|
| 357 |
+
height: int,
|
| 358 |
+
width: int,
|
| 359 |
+
num_frames: int,
|
| 360 |
+
fps: float,
|
| 361 |
+
seed: int,
|
| 362 |
+
camera_motion: VideoCameraMotion,
|
| 363 |
+
negative_prompt: str,
|
| 364 |
+
) -> str:
|
| 365 |
+
t_total_start = time.perf_counter()
|
| 366 |
+
gen_mode = "i2v" if image is not None else "t2v"
|
| 367 |
+
logger.info(
|
| 368 |
+
"[%s] Generation started (model=fast, %dx%d, %d frames, %d fps)",
|
| 369 |
+
gen_mode,
|
| 370 |
+
width,
|
| 371 |
+
height,
|
| 372 |
+
num_frames,
|
| 373 |
+
int(fps),
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
if self._generation.is_generation_cancelled():
|
| 377 |
+
raise RuntimeError("Generation was cancelled")
|
| 378 |
+
|
| 379 |
+
if not resolve_model_path(
|
| 380 |
+
self.models_dir, self.config.model_download_specs, "checkpoint"
|
| 381 |
+
).exists():
|
| 382 |
+
raise RuntimeError(
|
| 383 |
+
"Models not downloaded. Please download the AI models first using the Model Status menu."
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
total_steps = 8
|
| 387 |
+
|
| 388 |
+
self._generation.update_progress("loading_model", 5, 0, total_steps)
|
| 389 |
+
t_load_start = time.perf_counter()
|
| 390 |
+
pipeline_state = self._pipelines.load_gpu_pipeline("fast", should_warm=False)
|
| 391 |
+
t_load_end = time.perf_counter()
|
| 392 |
+
logger.info("[%s] Pipeline load: %.2fs", gen_mode, t_load_end - t_load_start)
|
| 393 |
+
|
| 394 |
+
self._generation.update_progress("encoding_text", 10, 0, total_steps)
|
| 395 |
+
|
| 396 |
+
enhanced_prompt = prompt + self.config.camera_motion_prompts.get(
|
| 397 |
+
camera_motion, ""
|
| 398 |
+
)
|
| 399 |
+
|
| 400 |
+
images: list[ImageConditioningInput] = []
|
| 401 |
+
temp_image_path: str | None = None
|
| 402 |
+
if image is not None:
|
| 403 |
+
temp_image_path = tempfile.NamedTemporaryFile(
|
| 404 |
+
suffix=".png", delete=False
|
| 405 |
+
).name
|
| 406 |
+
image.save(temp_image_path)
|
| 407 |
+
images = [
|
| 408 |
+
ImageConditioningInput(path=temp_image_path, frame_idx=0, strength=1.0)
|
| 409 |
+
]
|
| 410 |
+
|
| 411 |
+
output_path = self._make_output_path()
|
| 412 |
+
|
| 413 |
+
try:
|
| 414 |
+
settings = self.state.app_settings
|
| 415 |
+
use_api_encoding = not self._text.should_use_local_encoding()
|
| 416 |
+
if image is not None:
|
| 417 |
+
enhance = use_api_encoding and settings.prompt_enhancer_enabled_i2v
|
| 418 |
+
else:
|
| 419 |
+
enhance = use_api_encoding and settings.prompt_enhancer_enabled_t2v
|
| 420 |
+
|
| 421 |
+
encoding_method = "api" if use_api_encoding else "local"
|
| 422 |
+
t_text_start = time.perf_counter()
|
| 423 |
+
self._text.prepare_text_encoding(enhanced_prompt, enhance_prompt=enhance)
|
| 424 |
+
t_text_end = time.perf_counter()
|
| 425 |
+
logger.info(
|
| 426 |
+
"[%s] Text encoding (%s): %.2fs",
|
| 427 |
+
gen_mode,
|
| 428 |
+
encoding_method,
|
| 429 |
+
t_text_end - t_text_start,
|
| 430 |
+
)
|
| 431 |
+
|
| 432 |
+
self._generation.update_progress("inference", 15, 0, total_steps)
|
| 433 |
+
|
| 434 |
+
height = round(height / 64) * 64
|
| 435 |
+
width = round(width / 64) * 64
|
| 436 |
+
|
| 437 |
+
t_inference_start = time.perf_counter()
|
| 438 |
+
pipeline_state.pipeline.generate(
|
| 439 |
+
prompt=enhanced_prompt,
|
| 440 |
+
seed=seed,
|
| 441 |
+
height=height,
|
| 442 |
+
width=width,
|
| 443 |
+
num_frames=num_frames,
|
| 444 |
+
frame_rate=fps,
|
| 445 |
+
images=images,
|
| 446 |
+
output_path=str(output_path),
|
| 447 |
+
)
|
| 448 |
+
t_inference_end = time.perf_counter()
|
| 449 |
+
logger.info(
|
| 450 |
+
"[%s] Inference: %.2fs", gen_mode, t_inference_end - t_inference_start
|
| 451 |
+
)
|
| 452 |
+
|
| 453 |
+
if self._generation.is_generation_cancelled():
|
| 454 |
+
if output_path.exists():
|
| 455 |
+
output_path.unlink()
|
| 456 |
+
raise RuntimeError("Generation was cancelled")
|
| 457 |
+
|
| 458 |
+
t_total_end = time.perf_counter()
|
| 459 |
+
logger.info(
|
| 460 |
+
"[%s] Total generation: %.2fs (load=%.2fs, text=%.2fs, inference=%.2fs)",
|
| 461 |
+
gen_mode,
|
| 462 |
+
t_total_end - t_total_start,
|
| 463 |
+
t_load_end - t_load_start,
|
| 464 |
+
t_text_end - t_text_start,
|
| 465 |
+
t_inference_end - t_inference_start,
|
| 466 |
+
)
|
| 467 |
+
|
| 468 |
+
self._generation.update_progress("complete", 100, total_steps, total_steps)
|
| 469 |
+
return str(output_path)
|
| 470 |
+
finally:
|
| 471 |
+
self._text.clear_api_embeddings()
|
| 472 |
+
if temp_image_path and os.path.exists(temp_image_path):
|
| 473 |
+
os.unlink(temp_image_path)
|
| 474 |
+
|
| 475 |
+
def _generate_a2v(
|
| 476 |
+
self, req: GenerateVideoRequest, duration: int, fps: int, *, audio_path: str
|
| 477 |
+
) -> GenerateVideoResponse:
|
| 478 |
+
if req.model != "pro":
|
| 479 |
+
logger.warning(
|
| 480 |
+
"A2V local requested with model=%s; A2V always uses pro pipeline",
|
| 481 |
+
req.model,
|
| 482 |
+
)
|
| 483 |
+
validated_audio_path = validate_audio_file(audio_path)
|
| 484 |
+
audio_path_str = str(validated_audio_path)
|
| 485 |
+
|
| 486 |
+
# 支持竖屏和横屏
|
| 487 |
+
RESOLUTION_MAP: dict[str, tuple[int, int]] = {
|
| 488 |
+
"540p": (1024, 576),
|
| 489 |
+
"720p": (1280, 704),
|
| 490 |
+
"1080p": (1920, 1088),
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
base_w, base_h = RESOLUTION_MAP.get(req.resolution, (1280, 704))
|
| 494 |
+
|
| 495 |
+
# 根据 aspectRatio 调整分辨率
|
| 496 |
+
if req.aspectRatio == "9:16":
|
| 497 |
+
width, height = base_h, base_w # 竖屏
|
| 498 |
+
else:
|
| 499 |
+
width, height = base_w, base_h # 横屏
|
| 500 |
+
|
| 501 |
+
num_frames = self._compute_num_frames(duration, fps)
|
| 502 |
+
|
| 503 |
+
image = None
|
| 504 |
+
temp_image_path: str | None = None
|
| 505 |
+
image_path = normalize_optional_path(req.imagePath)
|
| 506 |
+
if image_path:
|
| 507 |
+
image = self._prepare_image(image_path, width, height)
|
| 508 |
+
|
| 509 |
+
# 获取首尾帧
|
| 510 |
+
start_frame_path = normalize_optional_path(getattr(req, "startFramePath", None))
|
| 511 |
+
end_frame_path = normalize_optional_path(getattr(req, "endFramePath", None))
|
| 512 |
+
|
| 513 |
+
seed = self._resolve_seed()
|
| 514 |
+
|
| 515 |
+
generation_id = self._make_generation_id()
|
| 516 |
+
|
| 517 |
+
temp_image_paths: list[str] = []
|
| 518 |
+
try:
|
| 519 |
+
a2v_state = self._pipelines.load_a2v_pipeline()
|
| 520 |
+
self._generation.start_generation(generation_id)
|
| 521 |
+
|
| 522 |
+
enhanced_prompt = req.prompt + self.config.camera_motion_prompts.get(
|
| 523 |
+
req.cameraMotion, ""
|
| 524 |
+
)
|
| 525 |
+
neg = (
|
| 526 |
+
req.negativePrompt
|
| 527 |
+
if req.negativePrompt
|
| 528 |
+
else self.config.default_negative_prompt
|
| 529 |
+
)
|
| 530 |
+
|
| 531 |
+
images: list[ImageConditioningInput] = []
|
| 532 |
+
temp_image_paths: list[str] = []
|
| 533 |
+
|
| 534 |
+
# 首帧
|
| 535 |
+
if start_frame_path:
|
| 536 |
+
start_img = self._prepare_image(start_frame_path, width, height)
|
| 537 |
+
temp_start_path = tempfile.NamedTemporaryFile(
|
| 538 |
+
suffix=".png", delete=False
|
| 539 |
+
).name
|
| 540 |
+
start_img.save(temp_start_path)
|
| 541 |
+
temp_image_paths.append(temp_start_path)
|
| 542 |
+
images.append(
|
| 543 |
+
ImageConditioningInput(
|
| 544 |
+
path=temp_start_path, frame_idx=0, strength=1.0
|
| 545 |
+
)
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
# 中间图片(如果有)
|
| 549 |
+
if image is not None and not start_frame_path:
|
| 550 |
+
temp_image_path = tempfile.NamedTemporaryFile(
|
| 551 |
+
suffix=".png", delete=False
|
| 552 |
+
).name
|
| 553 |
+
image.save(temp_image_path)
|
| 554 |
+
temp_image_paths.append(temp_image_path)
|
| 555 |
+
images.append(
|
| 556 |
+
ImageConditioningInput(
|
| 557 |
+
path=temp_image_path, frame_idx=0, strength=1.0
|
| 558 |
+
)
|
| 559 |
+
)
|
| 560 |
+
|
| 561 |
+
# 尾帧
|
| 562 |
+
if end_frame_path:
|
| 563 |
+
last_latent_idx = (num_frames - 1) // 8 + 1 - 1
|
| 564 |
+
end_img = self._prepare_image(end_frame_path, width, height)
|
| 565 |
+
temp_end_path = tempfile.NamedTemporaryFile(
|
| 566 |
+
suffix=".png", delete=False
|
| 567 |
+
).name
|
| 568 |
+
end_img.save(temp_end_path)
|
| 569 |
+
temp_image_paths.append(temp_end_path)
|
| 570 |
+
images.append(
|
| 571 |
+
ImageConditioningInput(
|
| 572 |
+
path=temp_end_path, frame_idx=last_latent_idx, strength=1.0
|
| 573 |
+
)
|
| 574 |
+
)
|
| 575 |
+
|
| 576 |
+
output_path = self._make_output_path()
|
| 577 |
+
|
| 578 |
+
total_steps = 11 # distilled: 8 steps (stage 1) + 3 steps (stage 2)
|
| 579 |
+
|
| 580 |
+
a2v_settings = self.state.app_settings
|
| 581 |
+
a2v_use_api = not self._text.should_use_local_encoding()
|
| 582 |
+
if image is not None:
|
| 583 |
+
a2v_enhance = a2v_use_api and a2v_settings.prompt_enhancer_enabled_i2v
|
| 584 |
+
else:
|
| 585 |
+
a2v_enhance = a2v_use_api and a2v_settings.prompt_enhancer_enabled_t2v
|
| 586 |
+
|
| 587 |
+
self._generation.update_progress("loading_model", 5, 0, total_steps)
|
| 588 |
+
self._generation.update_progress("encoding_text", 10, 0, total_steps)
|
| 589 |
+
self._text.prepare_text_encoding(
|
| 590 |
+
enhanced_prompt, enhance_prompt=a2v_enhance
|
| 591 |
+
)
|
| 592 |
+
self._generation.update_progress("inference", 15, 0, total_steps)
|
| 593 |
+
|
| 594 |
+
a2v_state.pipeline.generate(
|
| 595 |
+
prompt=enhanced_prompt,
|
| 596 |
+
negative_prompt=neg,
|
| 597 |
+
seed=seed,
|
| 598 |
+
height=height,
|
| 599 |
+
width=width,
|
| 600 |
+
num_frames=num_frames,
|
| 601 |
+
frame_rate=fps,
|
| 602 |
+
num_inference_steps=total_steps,
|
| 603 |
+
images=images,
|
| 604 |
+
audio_path=audio_path_str,
|
| 605 |
+
audio_start_time=0.0,
|
| 606 |
+
audio_max_duration=None,
|
| 607 |
+
output_path=str(output_path),
|
| 608 |
+
)
|
| 609 |
+
|
| 610 |
+
if self._generation.is_generation_cancelled():
|
| 611 |
+
if output_path.exists():
|
| 612 |
+
output_path.unlink()
|
| 613 |
+
raise RuntimeError("Generation was cancelled")
|
| 614 |
+
|
| 615 |
+
self._generation.update_progress("complete", 100, total_steps, total_steps)
|
| 616 |
+
self._generation.complete_generation(str(output_path))
|
| 617 |
+
return GenerateVideoResponse(status="complete", video_path=str(output_path))
|
| 618 |
+
|
| 619 |
+
except Exception as e:
|
| 620 |
+
self._generation.fail_generation(str(e))
|
| 621 |
+
if "cancelled" in str(e).lower():
|
| 622 |
+
logger.info("Generation cancelled by user")
|
| 623 |
+
return GenerateVideoResponse(status="cancelled")
|
| 624 |
+
raise HTTPError(500, str(e)) from e
|
| 625 |
+
finally:
|
| 626 |
+
self._text.clear_api_embeddings()
|
| 627 |
+
# 清理所有临时图片
|
| 628 |
+
for tmp_path in temp_image_paths:
|
| 629 |
+
if tmp_path and os.path.exists(tmp_path):
|
| 630 |
+
try:
|
| 631 |
+
os.unlink(tmp_path)
|
| 632 |
+
except Exception:
|
| 633 |
+
pass
|
| 634 |
+
if temp_image_path and os.path.exists(temp_image_path):
|
| 635 |
+
try:
|
| 636 |
+
os.unlink(temp_image_path)
|
| 637 |
+
except Exception:
|
| 638 |
+
pass
|
| 639 |
+
|
| 640 |
+
def _prepare_image(self, image_path: str, width: int, height: int) -> Image.Image:
|
| 641 |
+
validated_path = validate_image_file(image_path)
|
| 642 |
+
try:
|
| 643 |
+
img = Image.open(validated_path).convert("RGB")
|
| 644 |
+
except Exception:
|
| 645 |
+
raise HTTPError(400, f"Invalid image file: {image_path}") from None
|
| 646 |
+
img_w, img_h = img.size
|
| 647 |
+
target_ratio = width / height
|
| 648 |
+
img_ratio = img_w / img_h
|
| 649 |
+
if img_ratio > target_ratio:
|
| 650 |
+
new_h = height
|
| 651 |
+
new_w = int(img_w * (height / img_h))
|
| 652 |
+
else:
|
| 653 |
+
new_w = width
|
| 654 |
+
new_h = int(img_h * (width / img_w))
|
| 655 |
+
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
| 656 |
+
left = (new_w - width) // 2
|
| 657 |
+
top = (new_h - height) // 2
|
| 658 |
+
return resized.crop((left, top, left + width, top + height))
|
| 659 |
+
|
| 660 |
+
@staticmethod
|
| 661 |
+
def _make_generation_id() -> str:
|
| 662 |
+
return uuid.uuid4().hex[:8]
|
| 663 |
+
|
| 664 |
+
@staticmethod
|
| 665 |
+
def _compute_num_frames(duration: int, fps: int) -> int:
|
| 666 |
+
n = ((duration * fps) // 8) * 8 + 1
|
| 667 |
+
return max(n, 9)
|
| 668 |
+
|
| 669 |
+
def _resolve_seed(self) -> int:
|
| 670 |
+
settings = self.state.app_settings
|
| 671 |
+
if settings.seed_locked:
|
| 672 |
+
logger.info("Using locked seed: %s", settings.locked_seed)
|
| 673 |
+
return settings.locked_seed
|
| 674 |
+
return int(time.time()) % 2147483647
|
| 675 |
+
|
| 676 |
+
def _make_output_path(self) -> Path:
|
| 677 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 678 |
+
return (
|
| 679 |
+
self.config.outputs_dir
|
| 680 |
+
/ f"ltx2_video_{timestamp}_{self._make_generation_id()}.mp4"
|
| 681 |
+
)
|
| 682 |
+
|
| 683 |
+
def _generate_forced_api(self, req: GenerateVideoRequest) -> GenerateVideoResponse:
|
| 684 |
+
if self._generation.is_generation_running():
|
| 685 |
+
raise HTTPError(409, "Generation already in progress")
|
| 686 |
+
|
| 687 |
+
generation_id = self._make_generation_id()
|
| 688 |
+
self._generation.start_api_generation(generation_id)
|
| 689 |
+
|
| 690 |
+
audio_path = normalize_optional_path(req.audioPath)
|
| 691 |
+
image_path = normalize_optional_path(req.imagePath)
|
| 692 |
+
has_input_audio = bool(audio_path)
|
| 693 |
+
has_input_image = bool(image_path)
|
| 694 |
+
|
| 695 |
+
try:
|
| 696 |
+
self._generation.update_progress("validating_request", 5, None, None)
|
| 697 |
+
|
| 698 |
+
api_key = self.state.app_settings.ltx_api_key.strip()
|
| 699 |
+
logger.info(
|
| 700 |
+
"Forced API generation route selected (key_present=%s)", bool(api_key)
|
| 701 |
+
)
|
| 702 |
+
if not api_key:
|
| 703 |
+
raise HTTPError(400, "PRO_API_KEY_REQUIRED")
|
| 704 |
+
|
| 705 |
+
requested_model = req.model.strip().lower()
|
| 706 |
+
api_model_id = FORCED_API_MODEL_MAP.get(requested_model)
|
| 707 |
+
if api_model_id is None:
|
| 708 |
+
raise HTTPError(400, "INVALID_FORCED_API_MODEL")
|
| 709 |
+
|
| 710 |
+
resolution_label = req.resolution
|
| 711 |
+
resolution_by_aspect = FORCED_API_RESOLUTION_MAP.get(resolution_label)
|
| 712 |
+
if resolution_by_aspect is None:
|
| 713 |
+
raise HTTPError(400, "INVALID_FORCED_API_RESOLUTION")
|
| 714 |
+
|
| 715 |
+
aspect_ratio = req.aspectRatio.strip()
|
| 716 |
+
if aspect_ratio not in FORCED_API_ALLOWED_ASPECT_RATIOS:
|
| 717 |
+
raise HTTPError(400, "INVALID_FORCED_API_ASPECT_RATIO")
|
| 718 |
+
|
| 719 |
+
api_resolution = resolution_by_aspect[aspect_ratio]
|
| 720 |
+
|
| 721 |
+
prompt = req.prompt
|
| 722 |
+
|
| 723 |
+
if self._generation.is_generation_cancelled():
|
| 724 |
+
raise RuntimeError("Generation was cancelled")
|
| 725 |
+
|
| 726 |
+
if has_input_audio:
|
| 727 |
+
if requested_model != "pro":
|
| 728 |
+
logger.warning(
|
| 729 |
+
"A2V requested with model=%s; overriding to 'pro'",
|
| 730 |
+
requested_model,
|
| 731 |
+
)
|
| 732 |
+
api_model_id = FORCED_API_MODEL_MAP["pro"]
|
| 733 |
+
if api_resolution != A2V_FORCED_API_RESOLUTION:
|
| 734 |
+
logger.warning(
|
| 735 |
+
"A2V requested with resolution=%s; overriding to '%s'",
|
| 736 |
+
api_resolution,
|
| 737 |
+
A2V_FORCED_API_RESOLUTION,
|
| 738 |
+
)
|
| 739 |
+
api_resolution = A2V_FORCED_API_RESOLUTION
|
| 740 |
+
validated_audio_path = validate_audio_file(audio_path)
|
| 741 |
+
validated_image_path: Path | None = None
|
| 742 |
+
if image_path is not None:
|
| 743 |
+
validated_image_path = validate_image_file(image_path)
|
| 744 |
+
|
| 745 |
+
self._generation.update_progress("uploading_audio", 20, None, None)
|
| 746 |
+
audio_uri = self._ltx_api_client.upload_file(
|
| 747 |
+
api_key=api_key,
|
| 748 |
+
file_path=str(validated_audio_path),
|
| 749 |
+
)
|
| 750 |
+
image_uri: str | None = None
|
| 751 |
+
if validated_image_path is not None:
|
| 752 |
+
self._generation.update_progress("uploading_image", 35, None, None)
|
| 753 |
+
image_uri = self._ltx_api_client.upload_file(
|
| 754 |
+
api_key=api_key,
|
| 755 |
+
file_path=str(validated_image_path),
|
| 756 |
+
)
|
| 757 |
+
self._generation.update_progress("inference", 55, None, None)
|
| 758 |
+
video_bytes = self._ltx_api_client.generate_audio_to_video(
|
| 759 |
+
api_key=api_key,
|
| 760 |
+
prompt=prompt,
|
| 761 |
+
audio_uri=audio_uri,
|
| 762 |
+
image_uri=image_uri,
|
| 763 |
+
model=api_model_id,
|
| 764 |
+
resolution=api_resolution,
|
| 765 |
+
)
|
| 766 |
+
self._generation.update_progress("downloading_output", 85, None, None)
|
| 767 |
+
elif has_input_image:
|
| 768 |
+
validated_image_path = validate_image_file(image_path)
|
| 769 |
+
|
| 770 |
+
duration = self._parse_forced_numeric_field(
|
| 771 |
+
req.duration, "INVALID_FORCED_API_DURATION"
|
| 772 |
+
)
|
| 773 |
+
fps = self._parse_forced_numeric_field(
|
| 774 |
+
req.fps, "INVALID_FORCED_API_FPS"
|
| 775 |
+
)
|
| 776 |
+
if fps not in FORCED_API_ALLOWED_FPS:
|
| 777 |
+
raise HTTPError(400, "INVALID_FORCED_API_FPS")
|
| 778 |
+
if duration not in _get_allowed_durations(
|
| 779 |
+
api_model_id, resolution_label, fps
|
| 780 |
+
):
|
| 781 |
+
raise HTTPError(400, "INVALID_FORCED_API_DURATION")
|
| 782 |
+
|
| 783 |
+
generate_audio = self._parse_audio_flag(req.audio)
|
| 784 |
+
self._generation.update_progress("uploading_image", 20, None, None)
|
| 785 |
+
image_uri = self._ltx_api_client.upload_file(
|
| 786 |
+
api_key=api_key,
|
| 787 |
+
file_path=str(validated_image_path),
|
| 788 |
+
)
|
| 789 |
+
self._generation.update_progress("inference", 55, None, None)
|
| 790 |
+
video_bytes = self._ltx_api_client.generate_image_to_video(
|
| 791 |
+
api_key=api_key,
|
| 792 |
+
prompt=prompt,
|
| 793 |
+
image_uri=image_uri,
|
| 794 |
+
model=api_model_id,
|
| 795 |
+
resolution=api_resolution,
|
| 796 |
+
duration=float(duration),
|
| 797 |
+
fps=float(fps),
|
| 798 |
+
generate_audio=generate_audio,
|
| 799 |
+
camera_motion=req.cameraMotion,
|
| 800 |
+
)
|
| 801 |
+
self._generation.update_progress("downloading_output", 85, None, None)
|
| 802 |
+
else:
|
| 803 |
+
duration = self._parse_forced_numeric_field(
|
| 804 |
+
req.duration, "INVALID_FORCED_API_DURATION"
|
| 805 |
+
)
|
| 806 |
+
fps = self._parse_forced_numeric_field(
|
| 807 |
+
req.fps, "INVALID_FORCED_API_FPS"
|
| 808 |
+
)
|
| 809 |
+
if fps not in FORCED_API_ALLOWED_FPS:
|
| 810 |
+
raise HTTPError(400, "INVALID_FORCED_API_FPS")
|
| 811 |
+
if duration not in _get_allowed_durations(
|
| 812 |
+
api_model_id, resolution_label, fps
|
| 813 |
+
):
|
| 814 |
+
raise HTTPError(400, "INVALID_FORCED_API_DURATION")
|
| 815 |
+
|
| 816 |
+
generate_audio = self._parse_audio_flag(req.audio)
|
| 817 |
+
self._generation.update_progress("inference", 55, None, None)
|
| 818 |
+
video_bytes = self._ltx_api_client.generate_text_to_video(
|
| 819 |
+
api_key=api_key,
|
| 820 |
+
prompt=prompt,
|
| 821 |
+
model=api_model_id,
|
| 822 |
+
resolution=api_resolution,
|
| 823 |
+
duration=float(duration),
|
| 824 |
+
fps=float(fps),
|
| 825 |
+
generate_audio=generate_audio,
|
| 826 |
+
camera_motion=req.cameraMotion,
|
| 827 |
+
)
|
| 828 |
+
self._generation.update_progress("downloading_output", 85, None, None)
|
| 829 |
+
|
| 830 |
+
if self._generation.is_generation_cancelled():
|
| 831 |
+
raise RuntimeError("Generation was cancelled")
|
| 832 |
+
|
| 833 |
+
output_path = self._write_forced_api_video(video_bytes)
|
| 834 |
+
if self._generation.is_generation_cancelled():
|
| 835 |
+
output_path.unlink(missing_ok=True)
|
| 836 |
+
raise RuntimeError("Generation was cancelled")
|
| 837 |
+
|
| 838 |
+
self._generation.update_progress("complete", 100, None, None)
|
| 839 |
+
self._generation.complete_generation(str(output_path))
|
| 840 |
+
return GenerateVideoResponse(status="complete", video_path=str(output_path))
|
| 841 |
+
except HTTPError as e:
|
| 842 |
+
self._generation.fail_generation(e.detail)
|
| 843 |
+
raise
|
| 844 |
+
except Exception as e:
|
| 845 |
+
self._generation.fail_generation(str(e))
|
| 846 |
+
if "cancelled" in str(e).lower():
|
| 847 |
+
logger.info("Generation cancelled by user")
|
| 848 |
+
return GenerateVideoResponse(status="cancelled")
|
| 849 |
+
raise HTTPError(500, str(e)) from e
|
| 850 |
+
|
| 851 |
+
def _write_forced_api_video(self, video_bytes: bytes) -> Path:
|
| 852 |
+
output_path = self._make_output_path()
|
| 853 |
+
output_path.write_bytes(video_bytes)
|
| 854 |
+
return output_path
|
| 855 |
+
|
| 856 |
+
@staticmethod
|
| 857 |
+
def _parse_forced_numeric_field(raw_value: str, error_detail: str) -> int:
|
| 858 |
+
try:
|
| 859 |
+
return int(float(raw_value))
|
| 860 |
+
except (TypeError, ValueError):
|
| 861 |
+
raise HTTPError(400, error_detail) from None
|
| 862 |
+
|
| 863 |
+
@staticmethod
|
| 864 |
+
def _parse_audio_flag(audio_value: str | bool) -> bool:
|
| 865 |
+
if isinstance(audio_value, bool):
|
| 866 |
+
return audio_value
|
| 867 |
+
normalized = audio_value.strip().lower()
|
| 868 |
+
return normalized in {"1", "true", "yes", "on"}
|
LTX2.3/patches/keep_models_runtime.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""仅提供强制卸载 GPU 管线。「保持模型加载」功能已移除。"""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def force_unload_gpu_pipeline(pipelines: Any) -> None:
|
| 9 |
+
"""释放推理管线占用的显存(切换 GPU、清理、LoRA 重建等场景)。"""
|
| 10 |
+
try:
|
| 11 |
+
pipelines.unload_gpu_pipeline()
|
| 12 |
+
except Exception:
|
| 13 |
+
try:
|
| 14 |
+
type(pipelines).unload_gpu_pipeline(pipelines)
|
| 15 |
+
except Exception:
|
| 16 |
+
pass
|
LTX2.3/patches/launcher.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import sys
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
patch_dir = r"C:\Users\1-xuanran\Desktop\ltx-TEST\patches"
|
| 6 |
+
backend_dir = r"C:\Program Files\LTX Desktop\resources\backend"
|
| 7 |
+
|
| 8 |
+
# 防御性清除:强行剥离所有的默认 backend_dir 引用
|
| 9 |
+
sys.path = [p for p in sys.path if p and os.path.normpath(p) != os.path.normpath(backend_dir)]
|
| 10 |
+
sys.path = [p for p in sys.path if p and p != "." and p != ""]
|
| 11 |
+
|
| 12 |
+
# 绝对插队注入:优先搜索 PATCHES_DIR
|
| 13 |
+
sys.path.insert(0, patch_dir)
|
| 14 |
+
sys.path.insert(1, backend_dir)
|
| 15 |
+
|
| 16 |
+
import uvicorn
|
| 17 |
+
from ltx2_server import app
|
| 18 |
+
|
| 19 |
+
if __name__ == '__main__':
|
| 20 |
+
uvicorn.run(app, host="0.0.0.0", port=3000, log_level="info", access_log=False)
|
LTX2.3/patches/lora_build_hook.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
在 SingleGPUModelBuilder.build() 时合并「当前请求」的用户 LoRA。
|
| 3 |
+
|
| 4 |
+
桌面版 Fast 管线往往只在 model_ledger 上挂 loras,真正 load 权重时仍用
|
| 5 |
+
初始化时的空 loras Builder;此处对 DiT/Transformer 的 Builder 在 build 前注入。
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import contextvars
|
| 11 |
+
import logging
|
| 12 |
+
from dataclasses import replace
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
# 当前 HTTP 请求/生成任务中要额外融合的 LoRA(LoraPathStrengthAndSDOps 元组)
|
| 18 |
+
_pending_user_loras: contextvars.ContextVar[tuple[Any, ...] | None] = contextvars.ContextVar(
|
| 19 |
+
"ltx_pending_user_loras", default=None
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
_HOOK_INSTALLED = False
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def pending_loras_token(loras: tuple[Any, ...] | None):
|
| 26 |
+
"""返回 contextvar Token,供 finally reset;loras 为 None 表示本任务不用额外 LoRA。"""
|
| 27 |
+
return _pending_user_loras.set(loras)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def reset_pending_loras(token: contextvars.Token | None) -> None:
|
| 31 |
+
if token is not None:
|
| 32 |
+
_pending_user_loras.reset(token)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _get_pending() -> tuple[Any, ...] | None:
|
| 36 |
+
return _pending_user_loras.get()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _is_ltx_diffusion_transformer_builder(builder: Any) -> bool:
|
| 40 |
+
"""避免给 Gemma / VAE / Upsampler 的 Builder 误加视频 LoRA。"""
|
| 41 |
+
cfg = getattr(builder, "model_class_configurator", None)
|
| 42 |
+
if cfg is None:
|
| 43 |
+
return False
|
| 44 |
+
name = getattr(cfg, "__name__", "") or ""
|
| 45 |
+
# 排除明显非 DiT 的
|
| 46 |
+
for bad in (
|
| 47 |
+
"Gemma",
|
| 48 |
+
"VideoEncoder",
|
| 49 |
+
"VideoDecoder",
|
| 50 |
+
"AudioEncoder",
|
| 51 |
+
"AudioDecoder",
|
| 52 |
+
"Vocoder",
|
| 53 |
+
"EmbeddingsProcessor",
|
| 54 |
+
"LatentUpsampler",
|
| 55 |
+
):
|
| 56 |
+
if bad in name:
|
| 57 |
+
return False
|
| 58 |
+
try:
|
| 59 |
+
from ltx_core.model.transformer import LTXModelConfigurator
|
| 60 |
+
|
| 61 |
+
if isinstance(cfg, type):
|
| 62 |
+
try:
|
| 63 |
+
if issubclass(cfg, LTXModelConfigurator):
|
| 64 |
+
return True
|
| 65 |
+
except TypeError:
|
| 66 |
+
pass
|
| 67 |
+
if cfg is LTXModelConfigurator:
|
| 68 |
+
return True
|
| 69 |
+
except ImportError:
|
| 70 |
+
pass
|
| 71 |
+
# 兜底:LTX 主 transformer 配置器命名习惯(排除已列出的 VAE/Gemma)
|
| 72 |
+
return "LTX" in name and "ModelConfigurator" in name
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def install_lora_build_hook() -> None:
|
| 76 |
+
global _HOOK_INSTALLED
|
| 77 |
+
if _HOOK_INSTALLED:
|
| 78 |
+
return
|
| 79 |
+
try:
|
| 80 |
+
from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder
|
| 81 |
+
except ImportError:
|
| 82 |
+
logger.warning("lora_build_hook: 无法导入 SingleGPUModelBuilder,跳过")
|
| 83 |
+
return
|
| 84 |
+
|
| 85 |
+
_orig_build = SingleGPUModelBuilder.build
|
| 86 |
+
|
| 87 |
+
def build(self: Any, *args: Any, **kwargs: Any) -> Any:
|
| 88 |
+
extra = _get_pending()
|
| 89 |
+
if extra and _is_ltx_diffusion_transformer_builder(self):
|
| 90 |
+
have = {getattr(x, "path", None) for x in self.loras}
|
| 91 |
+
add = tuple(x for x in extra if getattr(x, "path", None) not in have)
|
| 92 |
+
if add:
|
| 93 |
+
merged = (*tuple(self.loras), *add)
|
| 94 |
+
self = replace(self, loras=merged)
|
| 95 |
+
logger.info(
|
| 96 |
+
"lora_build_hook: 已向 DiT Builder 合并 %d 个用户 LoRA: %s",
|
| 97 |
+
len(add),
|
| 98 |
+
[getattr(x, "path", x) for x in add],
|
| 99 |
+
)
|
| 100 |
+
return _orig_build(self, *args, **kwargs)
|
| 101 |
+
|
| 102 |
+
SingleGPUModelBuilder.build = build # type: ignore[method-assign]
|
| 103 |
+
_HOOK_INSTALLED = True
|
| 104 |
+
logger.info("lora_build_hook: 已挂载 SingleGPUModelBuilder.build")
|
LTX2.3/patches/lora_injection.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""将用户 LoRA 注入 Fast 视频管线:兼容 ModelLedger 与 LTX-2 DiffusionStage/Builder。"""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import inspect
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _lora_init_kwargs(
|
| 13 |
+
pipeline_cls: type, loras: list[Any] | tuple[Any, ...]
|
| 14 |
+
) -> dict[str, Any]:
|
| 15 |
+
if not loras:
|
| 16 |
+
return {}
|
| 17 |
+
try:
|
| 18 |
+
sig = inspect.signature(pipeline_cls.__init__)
|
| 19 |
+
names = sig.parameters.keys()
|
| 20 |
+
except (TypeError, ValueError):
|
| 21 |
+
return {}
|
| 22 |
+
tup = tuple(loras)
|
| 23 |
+
for key in ("loras", "lora", "extra_loras", "user_loras"):
|
| 24 |
+
if key in names:
|
| 25 |
+
return {key: tup}
|
| 26 |
+
return {}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def inject_loras_into_fast_pipeline(ltx_pipe: Any, loras: list[Any] | tuple[Any, ...]) -> int:
|
| 30 |
+
"""在已构造的管线上尽量把 LoRA 写进会参与 build 的 Builder / ledger。返回成功写入的处数。"""
|
| 31 |
+
if not loras:
|
| 32 |
+
return 0
|
| 33 |
+
tup = tuple(loras)
|
| 34 |
+
patched = 0
|
| 35 |
+
visited: set[int] = set()
|
| 36 |
+
|
| 37 |
+
def visit(obj: Any, depth: int) -> None:
|
| 38 |
+
nonlocal patched
|
| 39 |
+
if obj is None or depth > 10:
|
| 40 |
+
return
|
| 41 |
+
oid = id(obj)
|
| 42 |
+
if oid in visited:
|
| 43 |
+
return
|
| 44 |
+
visited.add(oid)
|
| 45 |
+
|
| 46 |
+
# ModelLedger.loras(旧桌面)
|
| 47 |
+
ml = getattr(obj, "model_ledger", None)
|
| 48 |
+
if ml is not None:
|
| 49 |
+
try:
|
| 50 |
+
ml.loras = tup
|
| 51 |
+
patched += 1
|
| 52 |
+
logger.info("LoRA: 已设置 model_ledger.loras")
|
| 53 |
+
except Exception as e:
|
| 54 |
+
logger.debug("model_ledger.loras: %s", e)
|
| 55 |
+
|
| 56 |
+
# SingleGPUModelBuilder.with_loras(常见与变体属性名)
|
| 57 |
+
for holder in (obj, ml):
|
| 58 |
+
if holder is None:
|
| 59 |
+
continue
|
| 60 |
+
candidates: list[Any] = []
|
| 61 |
+
for attr in (
|
| 62 |
+
"_transformer_builder",
|
| 63 |
+
"transformer_builder",
|
| 64 |
+
"_model_builder",
|
| 65 |
+
"model_builder",
|
| 66 |
+
):
|
| 67 |
+
tb = getattr(holder, attr, None)
|
| 68 |
+
if tb is not None:
|
| 69 |
+
candidates.append((attr, tb))
|
| 70 |
+
try:
|
| 71 |
+
for attr in dir(holder):
|
| 72 |
+
al = attr.lower()
|
| 73 |
+
if "transformer" in al and "builder" in al and attr not in (
|
| 74 |
+
"_transformer_builder",
|
| 75 |
+
"transformer_builder",
|
| 76 |
+
):
|
| 77 |
+
tb = getattr(holder, attr, None)
|
| 78 |
+
if tb is not None:
|
| 79 |
+
candidates.append((attr, tb))
|
| 80 |
+
except Exception:
|
| 81 |
+
pass
|
| 82 |
+
for attr, tb in candidates:
|
| 83 |
+
if hasattr(tb, "with_loras"):
|
| 84 |
+
try:
|
| 85 |
+
new_tb = tb.with_loras(tup)
|
| 86 |
+
setattr(holder, attr, new_tb)
|
| 87 |
+
patched += 1
|
| 88 |
+
logger.info("LoRA: 已更新 %s.with_loras", attr)
|
| 89 |
+
except Exception as e:
|
| 90 |
+
logger.debug("with_loras %s: %s", attr, e)
|
| 91 |
+
|
| 92 |
+
# DiffusionStage(类名或 isinstance)
|
| 93 |
+
is_diffusion = type(obj).__name__ == "DiffusionStage"
|
| 94 |
+
if not is_diffusion:
|
| 95 |
+
try:
|
| 96 |
+
from ltx_pipelines.utils.blocks import DiffusionStage as _DS
|
| 97 |
+
|
| 98 |
+
is_diffusion = isinstance(obj, _DS)
|
| 99 |
+
except ImportError:
|
| 100 |
+
pass
|
| 101 |
+
if is_diffusion:
|
| 102 |
+
tb = getattr(obj, "_transformer_builder", None)
|
| 103 |
+
if tb is not None and hasattr(tb, "with_loras"):
|
| 104 |
+
try:
|
| 105 |
+
obj._transformer_builder = tb.with_loras(tup)
|
| 106 |
+
patched += 1
|
| 107 |
+
logger.info("LoRA: 已写入 DiffusionStage._transformer_builder")
|
| 108 |
+
except Exception as e:
|
| 109 |
+
logger.debug("DiffusionStage: %s", e)
|
| 110 |
+
|
| 111 |
+
# 常见嵌套属性
|
| 112 |
+
for name in (
|
| 113 |
+
"pipeline",
|
| 114 |
+
"inner",
|
| 115 |
+
"_inner",
|
| 116 |
+
"fast_pipeline",
|
| 117 |
+
"_pipeline",
|
| 118 |
+
"stage_1",
|
| 119 |
+
"stage_2",
|
| 120 |
+
"stage",
|
| 121 |
+
"_stage",
|
| 122 |
+
"stages",
|
| 123 |
+
"diffusion",
|
| 124 |
+
"_diffusion",
|
| 125 |
+
):
|
| 126 |
+
try:
|
| 127 |
+
ch = getattr(obj, name, None)
|
| 128 |
+
except Exception:
|
| 129 |
+
continue
|
| 130 |
+
if ch is not None and ch is not obj:
|
| 131 |
+
visit(ch, depth + 1)
|
| 132 |
+
|
| 133 |
+
if isinstance(obj, (list, tuple)):
|
| 134 |
+
for item in obj[:8]:
|
| 135 |
+
visit(item, depth + 1)
|
| 136 |
+
|
| 137 |
+
root = getattr(ltx_pipe, "pipeline", ltx_pipe)
|
| 138 |
+
visit(root, 0)
|
| 139 |
+
return patched
|
LTX2.3/patches/low_vram_runtime.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""低显存模式:尽量降峰值显存(以速度换显存);效果取决于官方管线是否支持 offload。"""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import gc
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
import types
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger("ltx_low_vram")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _ltx_desktop_config_dir() -> Path:
|
| 16 |
+
p = (
|
| 17 |
+
Path(os.environ.get("LOCALAPPDATA", os.path.expanduser("~/AppData/Local")))
|
| 18 |
+
/ "LTXDesktop"
|
| 19 |
+
)
|
| 20 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 21 |
+
return p.resolve()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def low_vram_pref_path() -> Path:
|
| 25 |
+
return _ltx_desktop_config_dir() / "low_vram_mode.pref"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def read_low_vram_pref() -> bool:
|
| 29 |
+
f = low_vram_pref_path()
|
| 30 |
+
if not f.is_file():
|
| 31 |
+
return False
|
| 32 |
+
return f.read_text(encoding="utf-8").strip().lower() in ("1", "true", "yes", "on")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def write_low_vram_pref(enabled: bool) -> None:
|
| 36 |
+
low_vram_pref_path().write_text(
|
| 37 |
+
"true\n" if enabled else "false\n", encoding="utf-8"
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def apply_low_vram_config_tweaks(handler: Any) -> None:
|
| 42 |
+
"""在官方 RuntimeConfig 上尽量关闭 fast 超分等(若字段存在)。"""
|
| 43 |
+
cfg = getattr(handler, "config", None)
|
| 44 |
+
if cfg is None:
|
| 45 |
+
return
|
| 46 |
+
fm = getattr(cfg, "fast_model", None)
|
| 47 |
+
if fm is None:
|
| 48 |
+
return
|
| 49 |
+
try:
|
| 50 |
+
if hasattr(fm, "model_copy"):
|
| 51 |
+
updated = fm.model_copy(update={"use_upscaler": False})
|
| 52 |
+
setattr(cfg, "fast_model", updated)
|
| 53 |
+
elif hasattr(fm, "use_upscaler"):
|
| 54 |
+
setattr(fm, "use_upscaler", False)
|
| 55 |
+
except Exception as e:
|
| 56 |
+
logger.debug("low_vram: 无法关闭 fast_model.use_upscaler: %s", e)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def install_low_vram_on_pipelines(handler: Any) -> None:
|
| 60 |
+
"""启动时读取偏好,挂到 pipelines 上供各补丁读取。"""
|
| 61 |
+
pl = handler.pipelines
|
| 62 |
+
low = read_low_vram_pref()
|
| 63 |
+
setattr(pl, "low_vram_mode", bool(low))
|
| 64 |
+
if low:
|
| 65 |
+
apply_low_vram_config_tweaks(handler)
|
| 66 |
+
logger.info(
|
| 67 |
+
"low_vram_mode: 已开启(尝试关闭 fast 超分;若显存仍高,多为权重常驻 GPU,需降分辨率/时长或 FP8 权重)"
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def install_low_vram_pipeline_hooks(pl: Any) -> None:
|
| 72 |
+
"""在 load_gpu_pipeline / load_a2v 返回后尝试 Diffusers 式 CPU offload(无则静默)。"""
|
| 73 |
+
if getattr(pl, "_ltx_low_vram_hooks_installed", False):
|
| 74 |
+
return
|
| 75 |
+
pl._ltx_low_vram_hooks_installed = True
|
| 76 |
+
|
| 77 |
+
if hasattr(pl, "load_gpu_pipeline"):
|
| 78 |
+
_orig_gpu = pl.load_gpu_pipeline
|
| 79 |
+
pl._ltx_orig_load_gpu_for_low_vram = _orig_gpu
|
| 80 |
+
|
| 81 |
+
def _load_gpu_wrapped(self: Any, *a: Any, **kw: Any) -> Any:
|
| 82 |
+
r = _orig_gpu(*a, **kw)
|
| 83 |
+
if getattr(self, "low_vram_mode", False):
|
| 84 |
+
try_sequential_offload_on_pipeline_state(r)
|
| 85 |
+
return r
|
| 86 |
+
|
| 87 |
+
pl.load_gpu_pipeline = types.MethodType(_load_gpu_wrapped, pl)
|
| 88 |
+
|
| 89 |
+
if hasattr(pl, "load_a2v_pipeline"):
|
| 90 |
+
_orig_a2v = pl.load_a2v_pipeline
|
| 91 |
+
pl._ltx_orig_load_a2v_for_low_vram = _orig_a2v
|
| 92 |
+
|
| 93 |
+
def _load_a2v_wrapped(self: Any, *a: Any, **kw: Any) -> Any:
|
| 94 |
+
r = _orig_a2v(*a, **kw)
|
| 95 |
+
if getattr(self, "low_vram_mode", False):
|
| 96 |
+
try_sequential_offload_on_pipeline_state(r)
|
| 97 |
+
return r
|
| 98 |
+
|
| 99 |
+
pl.load_a2v_pipeline = types.MethodType(_load_a2v_wrapped, pl)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def try_sequential_offload_on_pipeline_state(state: Any) -> None:
|
| 103 |
+
"""若底层为 Diffusers 风格 API,尝试按层 CPU offload(显著变慢、降峰值)。"""
|
| 104 |
+
if state is None:
|
| 105 |
+
return
|
| 106 |
+
root = getattr(state, "pipeline", state)
|
| 107 |
+
candidates: list[Any] = [root]
|
| 108 |
+
inner = getattr(root, "pipeline", None)
|
| 109 |
+
if inner is not None and inner is not root:
|
| 110 |
+
candidates.append(inner)
|
| 111 |
+
for obj in candidates:
|
| 112 |
+
for method_name in (
|
| 113 |
+
"enable_sequential_cpu_offload",
|
| 114 |
+
"enable_model_cpu_offload",
|
| 115 |
+
):
|
| 116 |
+
fn = getattr(obj, method_name, None)
|
| 117 |
+
if callable(fn):
|
| 118 |
+
try:
|
| 119 |
+
fn()
|
| 120 |
+
logger.info(
|
| 121 |
+
"low_vram_mode: 已对管线调用 %s()",
|
| 122 |
+
method_name,
|
| 123 |
+
)
|
| 124 |
+
return
|
| 125 |
+
except Exception as e:
|
| 126 |
+
logger.debug(
|
| 127 |
+
"low_vram_mode: %s() 失败(可忽略): %s",
|
| 128 |
+
method_name,
|
| 129 |
+
e,
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def maybe_release_pipeline_after_task(handler: Any) -> None:
|
| 134 |
+
"""单次生成结束后:低显存模式下强制卸载管线并回收缓存。"""
|
| 135 |
+
pl = getattr(handler, "pipelines", None) or getattr(handler, "_pipelines", None)
|
| 136 |
+
if pl is None or not getattr(pl, "low_vram_mode", False):
|
| 137 |
+
return
|
| 138 |
+
try:
|
| 139 |
+
from keep_models_runtime import force_unload_gpu_pipeline
|
| 140 |
+
|
| 141 |
+
force_unload_gpu_pipeline(pl)
|
| 142 |
+
except Exception as e:
|
| 143 |
+
logger.debug("low_vram_mode: 任务后卸载失败: %s", e)
|
| 144 |
+
try:
|
| 145 |
+
pl._pipeline_signature = None
|
| 146 |
+
except Exception:
|
| 147 |
+
pass
|
| 148 |
+
gc.collect()
|
| 149 |
+
try:
|
| 150 |
+
import torch
|
| 151 |
+
|
| 152 |
+
if torch.cuda.is_available():
|
| 153 |
+
torch.cuda.empty_cache()
|
| 154 |
+
except Exception:
|
| 155 |
+
pass
|
LTX2.3/patches/runtime_policy.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Runtime policy decisions for forced API mode."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def decide_force_api_generations(
|
| 7 |
+
system: str, cuda_available: bool, vram_gb: int | None
|
| 8 |
+
) -> bool:
|
| 9 |
+
"""Return whether API-only generation must be forced for this runtime."""
|
| 10 |
+
if system == "Darwin":
|
| 11 |
+
return True
|
| 12 |
+
|
| 13 |
+
if system in ("Windows", "Linux"):
|
| 14 |
+
if not cuda_available:
|
| 15 |
+
return True
|
| 16 |
+
if vram_gb is None:
|
| 17 |
+
return True
|
| 18 |
+
return vram_gb < 6
|
| 19 |
+
|
| 20 |
+
# Fail closed for non-target platforms unless explicitly relaxed.
|
| 21 |
+
return True
|
LTX2.3/patches/settings.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"use_torch_compile": false,
|
| 3 |
+
"load_on_startup": false,
|
| 4 |
+
"ltx_api_key": "1231",
|
| 5 |
+
"user_prefers_ltx_api_video_generations": false,
|
| 6 |
+
"fal_api_key": "",
|
| 7 |
+
"use_local_text_encoder": true,
|
| 8 |
+
"fast_model": {
|
| 9 |
+
"use_upscaler": true
|
| 10 |
+
},
|
| 11 |
+
"pro_model": {
|
| 12 |
+
"steps": 20,
|
| 13 |
+
"use_upscaler": true
|
| 14 |
+
},
|
| 15 |
+
"prompt_cache_size": 100,
|
| 16 |
+
"prompt_enhancer_enabled_t2v": true,
|
| 17 |
+
"prompt_enhancer_enabled_i2v": false,
|
| 18 |
+
"gemini_api_key": "",
|
| 19 |
+
"seed_locked": false,
|
| 20 |
+
"locked_seed": 42,
|
| 21 |
+
"models_dir": ""
|
| 22 |
+
}
|
LTX2.3/run.bat
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
title LTX-2 Cinematic Workstation
|
| 3 |
+
|
| 4 |
+
echo =========================================================
|
| 5 |
+
echo LTX-2 Cinematic UI Booting...
|
| 6 |
+
echo =========================================================
|
| 7 |
+
echo.
|
| 8 |
+
|
| 9 |
+
set "LTX_PY=%USERPROFILE%\AppData\Local\LTXDesktop\python\python.exe"
|
| 10 |
+
set "LTX_UI_URL=http://127.0.0.1:4000/"
|
| 11 |
+
|
| 12 |
+
if exist "%LTX_PY%" (
|
| 13 |
+
echo [SUCCESS] LTX Bundled Python environment detected!
|
| 14 |
+
echo [INFO] Browser will open automatically when UI is ready...
|
| 15 |
+
start "" powershell -NoProfile -WindowStyle Hidden -Command "$ProgressPreference='SilentlyContinue'; $deadline=(Get-Date).AddSeconds(60); while((Get-Date) -lt $deadline){ try { Invoke-WebRequest -UseBasicParsing '%LTX_UI_URL%' -TimeoutSec 2 | Out-Null; Start-Process '%LTX_UI_URL%'; exit 0 } catch { Start-Sleep -Seconds 1 } }"
|
| 16 |
+
echo [INFO] Starting workspace natively...
|
| 17 |
+
echo ---------------------------------------------------------
|
| 18 |
+
"%LTX_PY%" main.py
|
| 19 |
+
pause
|
| 20 |
+
exit /b
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
python --version >nul 2>&1
|
| 24 |
+
if %errorlevel% equ 0 (
|
| 25 |
+
echo [WARNING] LTX Bundled Python not found.
|
| 26 |
+
echo [INFO] Browser will open automatically when UI is ready...
|
| 27 |
+
start "" powershell -NoProfile -WindowStyle Hidden -Command "$ProgressPreference='SilentlyContinue'; $deadline=(Get-Date).AddSeconds(60); while((Get-Date) -lt $deadline){ try { Invoke-WebRequest -UseBasicParsing '%LTX_UI_URL%' -TimeoutSec 2 | Out-Null; Start-Process '%LTX_UI_URL%'; exit 0 } catch { Start-Sleep -Seconds 1 } }"
|
| 28 |
+
echo [INFO] Falling back to global Python environment...
|
| 29 |
+
echo ---------------------------------------------------------
|
| 30 |
+
python main.py
|
| 31 |
+
pause
|
| 32 |
+
exit /b
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
echo [ERROR] FATAL: No Python interpreter found on this system.
|
| 36 |
+
echo [INFO] Please run install.bat to download and set up Python!
|
| 37 |
+
echo.
|
| 38 |
+
pause
|