Upload 35 files
Browse files- LTX2.3-1.0.4-new/LTX_Shortcut/LTX Desktop.lnk +0 -0
- LTX2.3-1.0.4-new/UI/i18n.js +646 -0
- LTX2.3-1.0.4-new/UI/index.css +985 -0
- LTX2.3-1.0.4-new/UI/index.html +604 -0
- LTX2.3-1.0.4-new/UI/index.js +0 -0
- LTX2.3-1.0.4-new/main.py +266 -0
- LTX2.3-1.0.4-new/patches/API模式问题修复说明.md +41 -0
- LTX2.3-1.0.4-new/patches/__pycache__/api_types.cpython-313.pyc +0 -0
- LTX2.3-1.0.4-new/patches/__pycache__/app_factory.cpython-313.pyc +3 -0
- LTX2.3-1.0.4-new/patches/__pycache__/keep_models_runtime.cpython-313.pyc +0 -0
- LTX2.3-1.0.4-new/patches/__pycache__/lora_build_hook.cpython-313.pyc +0 -0
- LTX2.3-1.0.4-new/patches/__pycache__/lora_injection.cpython-313.pyc +0 -0
- LTX2.3-1.0.4-new/patches/__pycache__/low_vram_runtime.cpython-313.pyc +0 -0
- LTX2.3-1.0.4-new/patches/__pycache__/ltx_dev_video_pipeline.cpython-313.pyc +0 -0
- LTX2.3-1.0.4-new/patches/__pycache__/ltx_fp8_video_pipeline.cpython-313.pyc +0 -0
- LTX2.3-1.0.4-new/patches/__pycache__/tts_worker.cpython-313.pyc +0 -0
- LTX2.3-1.0.4-new/patches/api_types.py +403 -0
- LTX2.3-1.0.4-new/patches/app_factory.py +0 -0
- LTX2.3-1.0.4-new/patches/app_settings_patch.py +22 -0
- LTX2.3-1.0.4-new/patches/handlers/__pycache__/video_generation_handler.cpython-313.pyc +0 -0
- LTX2.3-1.0.4-new/patches/handlers/video_generation_handler.py +882 -0
- LTX2.3-1.0.4-new/patches/keep_models_runtime.py +16 -0
- LTX2.3-1.0.4-new/patches/launcher.py +20 -0
- LTX2.3-1.0.4-new/patches/lora_build_hook.py +172 -0
- LTX2.3-1.0.4-new/patches/lora_injection.py +139 -0
- LTX2.3-1.0.4-new/patches/low_vram_runtime.py +264 -0
- LTX2.3-1.0.4-new/patches/ltx_dev_video_pipeline.py +156 -0
- LTX2.3-1.0.4-new/patches/ltx_fp8_video_pipeline.py +269 -0
- LTX2.3-1.0.4-new/patches/runtime_policy.py +21 -0
- LTX2.3-1.0.4-new/patches/settings.json +23 -0
- LTX2.3-1.0.4-new/patches/tts_worker.py +222 -0
- LTX2.3-1.0.4-new/run.bat +38 -0
- LTX2.3-1.0.4-new/使用说明-Installation Methods/Installation Methods.txt +62 -0
- LTX2.3-1.0.4-new/使用说明-Installation Methods/runtime_policy.py +19 -0
- LTX2.3-1.0.4-new/使用说明-Installation Methods/说明.txt +65 -0
LTX2.3-1.0.4-new/LTX_Shortcut/LTX Desktop.lnk
ADDED
|
Binary file (1.94 kB). View file
|
|
|
LTX2.3-1.0.4-new/UI/i18n.js
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
tabMotion: '视频迁移',
|
| 13 |
+
tabImage: '图像生成',
|
| 14 |
+
promptLabel: '视觉描述词 (Prompt)',
|
| 15 |
+
promptPlaceholder: '在此输入视觉描述词 (Prompt)...',
|
| 16 |
+
seedLabel: '随机种子 (Seed)',
|
| 17 |
+
seedRandom: '随机',
|
| 18 |
+
seedFixed: '固定',
|
| 19 |
+
clearVram: '释放显存',
|
| 20 |
+
clearingVram: '清理中...',
|
| 21 |
+
settingsTitle: '系统高级设置',
|
| 22 |
+
langToggleAriaZh: '切换为 English',
|
| 23 |
+
langToggleAriaEn: 'Switch to 中文',
|
| 24 |
+
sysScanning: '正在扫描 GPU...',
|
| 25 |
+
sysBusy: '运算中...',
|
| 26 |
+
sysOnline: '在线 / 就绪',
|
| 27 |
+
sysStarting: '启动中...',
|
| 28 |
+
sysOffline: '未检测到后端 (Port 3000)',
|
| 29 |
+
advancedSettings: '高级设置',
|
| 30 |
+
deviceSelect: '工作设备选择',
|
| 31 |
+
gpuDetecting: '正在检测 GPU...',
|
| 32 |
+
outputPath: '输出与上传存储路径',
|
| 33 |
+
outputPathPh: '例如: D:\\LTX_outputs',
|
| 34 |
+
savePath: '保存路径',
|
| 35 |
+
outputPathHint:
|
| 36 |
+
'系统默认会在 C 盘保留输出文件。请输入新路径后点击保存按钮。',
|
| 37 |
+
lowVram: '低显存优化',
|
| 38 |
+
lowVramDesc:
|
| 39 |
+
'尽量关闭 fast 超分、在加载管线后尝试 CPU 分层卸载(仅当引擎提供 Diffusers 式 API 才可能生效)。每次生成结束会卸载管线。说明:整模型常驻 GPU 时占用仍可能接近满配(例如约 24GB),要明显降占用需更短时长/更低分辨率或 FP8 等小权重。',
|
| 40 |
+
vramLimitLabel: '可用最高显存上限 (GB, 0为全开优先显存)',
|
| 41 |
+
vramLimitPh: '例如: 12 (0表示无限制)',
|
| 42 |
+
saveLabel: '保存',
|
| 43 |
+
modelCheckpointLabel: '视频模型(蒸馏版)',
|
| 44 |
+
modelCheckpointDefault: '默认官方蒸馏模型',
|
| 45 |
+
modelCheckpointHint: '推荐使用 distilled-fp8;仅显示 LTX 2.3 22B 蒸馏模型,避开 dev 模型。',
|
| 46 |
+
modelCheckpointSaved: '已选择模型',
|
| 47 |
+
modelCheckpointNone: '未找到可切换的蒸馏模型',
|
| 48 |
+
modelCheckpointLoadFail: '模型列表加载失败',
|
| 49 |
+
modelLoraSettings: '模型与LoRA设置',
|
| 50 |
+
modelFolder: '模型文件夹',
|
| 51 |
+
modelFolderPh: '当前 LTX 模型目录',
|
| 52 |
+
loraFolder: 'LoRA文件夹',
|
| 53 |
+
loraFolderPh: '模型目录\\loras',
|
| 54 |
+
loraFolderPath: 'LoRA 文件夹路径(可选)',
|
| 55 |
+
loraFolderPathPlaceholder: '留空使用 模型目录\\loras',
|
| 56 |
+
saveScan: '保存并扫描',
|
| 57 |
+
loraPlacementHint: '将 LoRA 文件放到当前模型目录下的 <code>loras</code> 文件夹。',
|
| 58 |
+
loraPlacementHintWithDir:
|
| 59 |
+
'将 LoRA 文件放到当前模型目录: <code>{dir}</code>\\loras',
|
| 60 |
+
basicEngine: '基础画面 / Basic EngineSpecs',
|
| 61 |
+
qualityLevel: '清晰度级别',
|
| 62 |
+
aspectRatio: '画幅比例',
|
| 63 |
+
ratio169: '16:9 电影宽幅',
|
| 64 |
+
ratio916: '9:16 移动竖屏',
|
| 65 |
+
ratio11: '1:1 方形',
|
| 66 |
+
ratio43: '4:3 经典横幅',
|
| 67 |
+
ratio34: '3:4 经典竖幅',
|
| 68 |
+
ratio219: '21:9 超宽银幕',
|
| 69 |
+
ratio921: '9:21 超长竖屏',
|
| 70 |
+
ratioRef: '跟随参考图',
|
| 71 |
+
ratioCustom: '自定义尺寸',
|
| 72 |
+
ratioRefMissing: '请先上传参考图',
|
| 73 |
+
resPreviewPrefix: '最终发送规格',
|
| 74 |
+
fpsLabel: '帧率 (FPS)',
|
| 75 |
+
durationLabel: '时长 (秒)',
|
| 76 |
+
cameraMotion: '镜头运动方式',
|
| 77 |
+
motionStatic: 'Static (静止机位)',
|
| 78 |
+
motionDollyIn: 'Dolly In (推近)',
|
| 79 |
+
motionDollyOut: 'Dolly Out (拉远)',
|
| 80 |
+
motionDollyLeft: 'Dolly Left (向左)',
|
| 81 |
+
motionDollyRight: 'Dolly Right (向右)',
|
| 82 |
+
motionJibUp: 'Jib Up (升臂)',
|
| 83 |
+
motionJibDown: 'Jib Down (降臂)',
|
| 84 |
+
motionFocus: 'Focus Shift (焦点)',
|
| 85 |
+
audioGen: '生成 AI 环境音 (Audio Gen)',
|
| 86 |
+
selectModel: '选择模型',
|
| 87 |
+
selectLora: '选择 LoRA',
|
| 88 |
+
defaultModel: '使用默认模型',
|
| 89 |
+
noLora: '不使用 LoRA',
|
| 90 |
+
loraStrength: 'LoRA 强度',
|
| 91 |
+
genSource: '生成媒介 / Generation Source',
|
| 92 |
+
startFrame: '起始帧 (首帧)',
|
| 93 |
+
endFrame: '结束帧 (尾帧)',
|
| 94 |
+
uploadStart: '上传首帧',
|
| 95 |
+
uploadEnd: '上传尾帧 (可选)',
|
| 96 |
+
refAudio: '参考音频 (A2V)',
|
| 97 |
+
uploadAudio: '点击上传音频',
|
| 98 |
+
sourceHint:
|
| 99 |
+
'💡 若仅上传首帧 = 图生视频/音视频;若同时上传首尾帧 = 首尾插帧。',
|
| 100 |
+
motionTransferTitle: '视频迁移 / Video Transfer',
|
| 101 |
+
motionRefVideoLabel: '参考视频',
|
| 102 |
+
motionVideoUploadText: '点击或拖拽视频',
|
| 103 |
+
motionVideoUploadHint: '用于动作或运镜迁移',
|
| 104 |
+
motionTargetImageLabel: '目标主体图',
|
| 105 |
+
motionImageUploadText: '点击或拖拽图片',
|
| 106 |
+
motionImageUploadHint: '作为主体/首帧引导',
|
| 107 |
+
motionTransferModeLabel: '迁移类型',
|
| 108 |
+
motionModeAction: '动作迁移',
|
| 109 |
+
motionModeCamera: '运镜迁移',
|
| 110 |
+
motionModeRepaint: '视频重绘',
|
| 111 |
+
motionControlType: '控制类型',
|
| 112 |
+
motionControlCanny: 'Canny 轮廓',
|
| 113 |
+
motionControlDepth: 'Depth 深度',
|
| 114 |
+
motionControlPose: 'Pose 姿态',
|
| 115 |
+
motionControlStrength: '控制强度',
|
| 116 |
+
motionTransferHint:
|
| 117 |
+
'动作迁移使用 Pose 姿态控制;运镜迁移使用原始参考视频作为 IC-LoRA guide。',
|
| 118 |
+
motionRefVideoName: '参考视频',
|
| 119 |
+
motionTargetImageName: '目标主体图',
|
| 120 |
+
motionUploadOk: '✅ {label}上传成功: {name}',
|
| 121 |
+
motionUploadFail: '❌ {label}上传失败: {message}',
|
| 122 |
+
motionClearRefVideo: '🧹 已清除参考视频',
|
| 123 |
+
motionClearTargetImage: '🧹 已清除目标主体图',
|
| 124 |
+
motionErrNeedVideo: '请先上传参考视频',
|
| 125 |
+
motionErrNeedImage: '请先上传目标主体图',
|
| 126 |
+
motionDefaultPromptNotice: '视频迁移未填写提示词,已使用默认视频迁移提示词',
|
| 127 |
+
motionStartLog: '正在发起视频迁移: {type}, 控制强度 {strength}',
|
| 128 |
+
motionStartMeta: 'FPS {fps}, 时长 {duration}s',
|
| 129 |
+
uploadFileStart: '正在上传{label}: {name}...',
|
| 130 |
+
fileReadFail: '读取本地文件失败',
|
| 131 |
+
downloadLabel: '下载',
|
| 132 |
+
queueTitle: '任务队列',
|
| 133 |
+
queueIdle: '空闲',
|
| 134 |
+
queueQueued: '排队中',
|
| 135 |
+
queueRunning: '执行中',
|
| 136 |
+
queueComplete: '已完成',
|
| 137 |
+
queueError: '失败',
|
| 138 |
+
queueCancelled: '已取消',
|
| 139 |
+
queueWaiting: '等待 {n}',
|
| 140 |
+
queueRunningSummary: '执行中 1 / 排队 {n}',
|
| 141 |
+
queueNoTasks: '暂无任务',
|
| 142 |
+
queueViewResult: '查看结果',
|
| 143 |
+
queuePosition: '队列第 {n} 位',
|
| 144 |
+
queueTaskTypeVideo: '视频',
|
| 145 |
+
queueTaskTypeMotion: '迁移',
|
| 146 |
+
queueTaskTypeBatch: '批量',
|
| 147 |
+
queueTaskTypeImage: '图像',
|
| 148 |
+
queueSubmitLog: '📥 已加入队列: {id}(前面还有 {n} 个任务)',
|
| 149 |
+
queueDoneLog: '✅ 队列任务完成: {label}',
|
| 150 |
+
queueFailLog: '❌ 队列任务失败: {label} - {error}',
|
| 151 |
+
queueCancelLog: '🛑 队列任务已取消: {label}',
|
| 152 |
+
replayRun: '重跑',
|
| 153 |
+
replayLoad: '载入参数',
|
| 154 |
+
replayLabel: 'Replay',
|
| 155 |
+
replayMissing: '⚠️ 这个历史任务没有可重放参数',
|
| 156 |
+
replayQueuedLog: '↻ Replay 已加入队列: {id}',
|
| 157 |
+
replayLoadedLog: '↗ 已载入 Replay 参数,可微调后重新渲染',
|
| 158 |
+
replayFailed: 'Replay 失败',
|
| 159 |
+
previewLoadSeed: '载入种子',
|
| 160 |
+
previewLoadParams: '载入参数',
|
| 161 |
+
previewNoReplaySeed: '⚠️ 当前预览没有可载入的种子',
|
| 162 |
+
previewNoReplayParams: '⚠️ 当前预览没有可载入的参数',
|
| 163 |
+
previewSeedLoadedLog: '已载入种子 {seed},并切换为固定种子',
|
| 164 |
+
previewNoDownload: '❌ 当前没有可下载的预览内容',
|
| 165 |
+
imgPreset: '预设分辨率 (Presets)',
|
| 166 |
+
imgOptSquare: '1:1 Square (1024x1024)',
|
| 167 |
+
imgOptLand: '16:9 Landscape (1280x720)',
|
| 168 |
+
imgOptPort: '9:16 Portrait (720x1280)',
|
| 169 |
+
imgOptCustom: 'Custom 自定义...',
|
| 170 |
+
width: '宽度',
|
| 171 |
+
height: '高度',
|
| 172 |
+
samplingSteps: '采样步数 (Steps)',
|
| 173 |
+
smartMultiFrameGroup: '智能多帧',
|
| 174 |
+
workflowModeLabel: '工作流模式(点击切换)',
|
| 175 |
+
wfSingle: '单次多关键帧',
|
| 176 |
+
wfSegments: '分段拼接',
|
| 177 |
+
uploadImages: '上传图片',
|
| 178 |
+
uploadMulti1: '点击或拖入多张图片',
|
| 179 |
+
uploadMulti2: '支持一次选多张,可多次添加',
|
| 180 |
+
batchStripTitle: '已选图片 · 顺序 = 播放先后',
|
| 181 |
+
batchStripHint: '在缩略图上按住拖动排序;松手落入虚线框位置',
|
| 182 |
+
batchFfmpegHint:
|
| 183 |
+
'💡 <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 完整路径。',
|
| 184 |
+
bgmLabel: '成片配乐(可选,统一音轨)',
|
| 185 |
+
bgmUploadHint: '上传一条完整 BGM(生成完成后会替换整段成片的音轨)',
|
| 186 |
+
mainRender: '开始渲染',
|
| 187 |
+
waitingTask: '等待分配渲染任务...',
|
| 188 |
+
libHistory: '历史资产 / ASSETS',
|
| 189 |
+
libLog: '系统日志 / LOGS',
|
| 190 |
+
refresh: '刷新',
|
| 191 |
+
logReady: '> LTX-2 Studio Ready. Expecting commands...',
|
| 192 |
+
resizeHandleTitle: '拖动调整面板高度',
|
| 193 |
+
batchNeedTwo: '💡 请上传至少2张图片',
|
| 194 |
+
batchSegTitle: '视频片段设置(分段拼接)',
|
| 195 |
+
batchSegClip: '片段',
|
| 196 |
+
batchSegDuration: '时长',
|
| 197 |
+
batchSegSec: '秒',
|
| 198 |
+
batchSegPrompt: '片段提示词',
|
| 199 |
+
batchSegPromptPh: '此片段的提示词,如:跳舞、吃饭...',
|
| 200 |
+
batchKfPanelTitle: '单次多关键帧 · 时间轴',
|
| 201 |
+
batchTotalDur: '总时长',
|
| 202 |
+
batchTotalSec: '秒',
|
| 203 |
+
batchPanelHint:
|
| 204 |
+
'为每一张图设置独立持续时间:第 1 张从 0 s 开始,下一张在上一张持续结束后接上,最后一张也会延续自己的时长。因后端按<strong>整数秒</strong>建序列,实际请求里的整段时长为合计秒数<strong>向上取整</strong>(至少 2),略长于小数合计时属正常。镜头与 FPS 仍用左侧「视频生成」。',
|
| 205 |
+
batchKfTitle: '关键帧',
|
| 206 |
+
batchStrength: '引导强度',
|
| 207 |
+
batchFrameDuration: '持续时长',
|
| 208 |
+
batchFrameDurationTitle: '这一张图在时间轴上占用的时长(秒);总时长 = 每张图持续时长之和',
|
| 209 |
+
batchGapTitle: '间隔',
|
| 210 |
+
batchSec: '秒',
|
| 211 |
+
batchAnchorStart: '片头',
|
| 212 |
+
batchAnchorEnd: '片尾',
|
| 213 |
+
batchAnchorLast: '最后一帧开始',
|
| 214 |
+
batchThumbDrag: '按住拖动排序',
|
| 215 |
+
batchThumbRemove: '删除',
|
| 216 |
+
batchAddMore: '+ 继续添加',
|
| 217 |
+
batchGapInputTitle: '这一张图在时间轴上占用的时长(秒);总时长 = 每张图持续时长之和',
|
| 218 |
+
batchStrengthTitle: '与 Comfy guide strength 类似,中间帧可调低(如 0.2)减轻闪烁',
|
| 219 |
+
batchTotalPillTitle: '等于下方各「持续时长」之和,无需单独填写',
|
| 220 |
+
defaultPath: '默认路径',
|
| 221 |
+
phase_loading_model: '加载权重',
|
| 222 |
+
phase_encoding_text: 'T5 编码',
|
| 223 |
+
phase_validating_request: '校验请求',
|
| 224 |
+
phase_uploading_audio: '上传音频',
|
| 225 |
+
phase_uploading_image: '上传图像',
|
| 226 |
+
phase_inference: 'AI 推理',
|
| 227 |
+
phase_downloading_output: '下载结果',
|
| 228 |
+
phase_complete: '完成',
|
| 229 |
+
gpuBusyPrefix: 'GPU 运算中',
|
| 230 |
+
progressStepUnit: '步',
|
| 231 |
+
loaderGpuAlloc: 'GPU 正在分配资源...',
|
| 232 |
+
warnGenerating: '⚠️ 当前正在生成中,请等待完成',
|
| 233 |
+
warnBatchPrompt: '⚠️ 智能多帧请至少填写:顶部主提示词、本页全局补充词或某一「片段提示词」',
|
| 234 |
+
warnNeedPrompt: '⚠️ 请输入提示词后再开始渲染',
|
| 235 |
+
warnVideoLong: '⚠️ 时长设定为 {n}s 极长,可能导致显存溢出或耗时较久。',
|
| 236 |
+
errBatchMinImages: '请上传至少2张图片',
|
| 237 |
+
errSingleKfPrompt: '单次多关键帧请至少填写顶部主提示词或本页全局补充词',
|
| 238 |
+
loraNoneLabel: '无',
|
| 239 |
+
modelDefaultLabel: '默认',
|
| 240 |
+
tabTts: 'TTS 语音',
|
| 241 |
+
ttsStatusBarDetecting: '🔍 正在检测 TTS 模型...',
|
| 242 |
+
ttsTextTitle: '合成文本 / Text',
|
| 243 |
+
ttsTextHint: '支持在文本开头加英文括号描述声音,例如:<code style="font-size:10px;">(年轻女声,温柔甜美)</code>',
|
| 244 |
+
ttsTextPlaceholder: '输入要合成的文本内容...',
|
| 245 |
+
ttsModeTitle: '合成模式 / Mode',
|
| 246 |
+
ttsModeTextOnly: '🗣️ 文字转语音(含声音设计)',
|
| 247 |
+
ttsModeClone: '🎙️ 声音克隆',
|
| 248 |
+
ttsModeUltimate: '⭐ 终极克隆(最高还原度)',
|
| 249 |
+
ttsRefLabel: '📎 参考音频(Reference)',
|
| 250 |
+
ttsRefUploadHint: '点击上传参考音频 (.wav / .mp3)',
|
| 251 |
+
ttsUltimateLabel: '📝 参考音频对应的文本转录(可选)',
|
| 252 |
+
ttsUltimatePlaceholder: '与参考音频完全一致的文本内容...',
|
| 253 |
+
ttsParamsTitle: '高级参数 / Parameters',
|
| 254 |
+
ttsCfgLabel: 'CFG 强度',
|
| 255 |
+
ttsStepsLabel: '推理步数',
|
| 256 |
+
ttsResultTitle: '生成结果 / Output',
|
| 257 |
+
ttsDownload: '⬇️ 下载音频',
|
| 258 |
+
ttsGenBtn: '🎙️ 开始生成语音',
|
| 259 |
+
ttsGenBusy: '⏳ 生成中...',
|
| 260 |
+
ttsErrNoText: '❌ TTS: ��输入合成文本',
|
| 261 |
+
ttsErrNoRef: '❌ TTS: 声音克隆模式需要上传参考音频',
|
| 262 |
+
ttsStatusReady: '✅ VoxCPM2 就绪 — 模型目录: ',
|
| 263 |
+
ttsStatusNoPkq: '❌ voxcpm 包未安装,请在 LTX Python 环境中运行: pip install voxcpm',
|
| 264 |
+
ttsStatusNoDir: '❌ 请将 VoxCPM2 文件夹放到: ',
|
| 265 |
+
ttsStatusNotAvail: '⚠️ TTS 不可用,请检查配置',
|
| 266 |
+
ttsStatusConnErr: '❌ 无法连接后端 TTS 接口: ',
|
| 267 |
+
},
|
| 268 |
+
en: {
|
| 269 |
+
tabVideo: 'Video',
|
| 270 |
+
tabBatch: 'Frames',
|
| 271 |
+
tabMotion: 'Transfer',
|
| 272 |
+
tabImage: 'Image',
|
| 273 |
+
promptLabel: 'Prompt',
|
| 274 |
+
promptPlaceholder: 'Describe the scene...',
|
| 275 |
+
seedLabel: 'Seed',
|
| 276 |
+
seedRandom: 'Random',
|
| 277 |
+
seedFixed: 'Fixed',
|
| 278 |
+
clearVram: 'Clear VRAM',
|
| 279 |
+
clearingVram: 'Clearing...',
|
| 280 |
+
settingsTitle: 'Advanced settings',
|
| 281 |
+
langToggleAriaZh: 'Switch to English',
|
| 282 |
+
langToggleAriaEn: 'Switch to Chinese',
|
| 283 |
+
sysScanning: 'Scanning GPU...',
|
| 284 |
+
sysBusy: 'Busy...',
|
| 285 |
+
sysOnline: 'Online / Ready',
|
| 286 |
+
sysStarting: 'Starting...',
|
| 287 |
+
sysOffline: 'Backend offline (port 3000)',
|
| 288 |
+
advancedSettings: 'Advanced',
|
| 289 |
+
deviceSelect: 'GPU device',
|
| 290 |
+
gpuDetecting: 'Detecting GPU...',
|
| 291 |
+
outputPath: 'Output & upload folder',
|
| 292 |
+
outputPathPh: 'e.g. D:\\LTX_outputs',
|
| 293 |
+
savePath: 'Save path',
|
| 294 |
+
outputPathHint:
|
| 295 |
+
'Outputs default to C: drive. Enter a folder and click Save.',
|
| 296 |
+
lowVram: 'Low-VRAM mode',
|
| 297 |
+
lowVramDesc:
|
| 298 |
+
'Tries to reduce VRAM (engine-dependent). Shorter duration / lower resolution helps more.',
|
| 299 |
+
vramLimitLabel: 'Max VRAM Limit (GB, 0 for unlimited)',
|
| 300 |
+
vramLimitPh: 'e.g. 12 (0 for unlimited)',
|
| 301 |
+
saveLabel: 'Save',
|
| 302 |
+
modelCheckpointLabel: 'Video model (distilled)',
|
| 303 |
+
modelCheckpointDefault: 'Default official distilled model',
|
| 304 |
+
modelCheckpointHint: 'Recommended: distilled-fp8. Only LTX 2.3 22B distilled models are shown; dev models are hidden.',
|
| 305 |
+
modelCheckpointSaved: 'Model selected',
|
| 306 |
+
modelCheckpointNone: 'No switchable distilled models found',
|
| 307 |
+
modelCheckpointLoadFail: 'Failed to load model list',
|
| 308 |
+
modelLoraSettings: 'Model & LoRA folders',
|
| 309 |
+
modelFolder: 'Models folder',
|
| 310 |
+
modelFolderPh: 'Current LTX models directory',
|
| 311 |
+
loraFolder: 'LoRAs folder',
|
| 312 |
+
loraFolderPh: 'models\\loras',
|
| 313 |
+
loraFolderPath: 'LoRA folder path (optional)',
|
| 314 |
+
loraFolderPathPlaceholder: 'Empty = models\\loras',
|
| 315 |
+
saveScan: 'Save & scan',
|
| 316 |
+
loraHint: 'Put .safetensors / .ckpt LoRAs here, then refresh lists.',
|
| 317 |
+
basicEngine: 'Basic / Engine',
|
| 318 |
+
qualityLevel: 'Quality',
|
| 319 |
+
aspectRatio: 'Aspect ratio',
|
| 320 |
+
ratio169: '16:9 widescreen',
|
| 321 |
+
ratio916: '9:16 portrait',
|
| 322 |
+
ratio11: '1:1 square',
|
| 323 |
+
ratio43: '4:3 classic',
|
| 324 |
+
ratio34: '3:4 portrait',
|
| 325 |
+
ratio219: '21:9 ultrawide',
|
| 326 |
+
ratio921: '9:21 tall',
|
| 327 |
+
ratioRef: 'Match reference',
|
| 328 |
+
ratioCustom: 'Custom size',
|
| 329 |
+
ratioRefMissing: 'upload a reference first',
|
| 330 |
+
resPreviewPrefix: 'Output',
|
| 331 |
+
fpsLabel: 'FPS',
|
| 332 |
+
durationLabel: 'Duration (s)',
|
| 333 |
+
cameraMotion: 'Camera motion',
|
| 334 |
+
motionStatic: 'Static',
|
| 335 |
+
motionDollyIn: 'Dolly in',
|
| 336 |
+
motionDollyOut: 'Dolly out',
|
| 337 |
+
motionDollyLeft: 'Dolly left',
|
| 338 |
+
motionDollyRight: 'Dolly right',
|
| 339 |
+
motionJibUp: 'Jib up',
|
| 340 |
+
motionJibDown: 'Jib down',
|
| 341 |
+
motionFocus: 'Focus shift',
|
| 342 |
+
audioGen: 'AI ambient audio',
|
| 343 |
+
selectModel: 'Model',
|
| 344 |
+
selectLora: 'LoRA',
|
| 345 |
+
defaultModel: 'Default model',
|
| 346 |
+
noLora: 'No LoRA',
|
| 347 |
+
loraStrength: 'LoRA strength',
|
| 348 |
+
genSource: 'Source media',
|
| 349 |
+
startFrame: 'Start frame',
|
| 350 |
+
endFrame: 'End frame (optional)',
|
| 351 |
+
uploadStart: 'Upload start',
|
| 352 |
+
uploadEnd: 'Upload end (opt.)',
|
| 353 |
+
refAudio: 'Reference audio (A2V)',
|
| 354 |
+
uploadAudio: 'Upload audio',
|
| 355 |
+
sourceHint:
|
| 356 |
+
'💡 Start only = I2V / A2V; start + end = interpolation.',
|
| 357 |
+
motionTransferTitle: 'Video Transfer',
|
| 358 |
+
motionRefVideoLabel: 'Reference video',
|
| 359 |
+
motionVideoUploadText: 'Click or drop a video',
|
| 360 |
+
motionVideoUploadHint: 'Used for action or camera transfer',
|
| 361 |
+
motionTargetImageLabel: 'Target subject image',
|
| 362 |
+
motionImageUploadText: 'Click or drop an image',
|
| 363 |
+
motionImageUploadHint: 'Guides subject identity / first frame',
|
| 364 |
+
motionTransferModeLabel: 'Transfer type',
|
| 365 |
+
motionModeAction: 'Action transfer',
|
| 366 |
+
motionModeCamera: 'Camera transfer',
|
| 367 |
+
motionModeRepaint: 'Video repaint',
|
| 368 |
+
motionControlType: 'Control type',
|
| 369 |
+
motionControlCanny: 'Canny contour',
|
| 370 |
+
motionControlDepth: 'Depth',
|
| 371 |
+
motionControlPose: 'Pose',
|
| 372 |
+
motionControlStrength: 'Control strength',
|
| 373 |
+
motionTransferHint:
|
| 374 |
+
'Action transfer uses Pose control; camera transfer uses the raw reference video as an IC-LoRA guide.',
|
| 375 |
+
motionRefVideoName: 'reference video',
|
| 376 |
+
motionTargetImageName: 'target subject image',
|
| 377 |
+
motionUploadOk: '✅ {label} uploaded: {name}',
|
| 378 |
+
motionUploadFail: '❌ {label} upload failed: {message}',
|
| 379 |
+
motionClearRefVideo: '🧹 Reference video cleared',
|
| 380 |
+
motionClearTargetImage: '🧹 Target subject image cleared',
|
| 381 |
+
motionErrNeedVideo: 'Upload a reference video first',
|
| 382 |
+
motionErrNeedImage: 'Upload a target subject image first',
|
| 383 |
+
motionDefaultPromptNotice: 'No prompt entered for video transfer; using the default video transfer prompt.',
|
| 384 |
+
motionStartLog: 'Starting video transfer: {type}, strength {strength}',
|
| 385 |
+
motionStartMeta: 'FPS {fps}, duration {duration}s',
|
| 386 |
+
uploadFileStart: 'Uploading {label}: {name}...',
|
| 387 |
+
fileReadFail: 'Failed to read local file',
|
| 388 |
+
downloadLabel: 'Download',
|
| 389 |
+
queueTitle: 'Task Queue',
|
| 390 |
+
queueIdle: 'Idle',
|
| 391 |
+
queueQueued: 'Queued',
|
| 392 |
+
queueRunning: 'Running',
|
| 393 |
+
queueComplete: 'Done',
|
| 394 |
+
queueError: 'Error',
|
| 395 |
+
queueCancelled: 'Cancelled',
|
| 396 |
+
queueWaiting: '{n} waiting',
|
| 397 |
+
queueRunningSummary: 'Running 1 / Queued {n}',
|
| 398 |
+
queueNoTasks: 'No tasks',
|
| 399 |
+
queueViewResult: 'View result',
|
| 400 |
+
queuePosition: 'Queue #{n}',
|
| 401 |
+
queueTaskTypeVideo: 'Video',
|
| 402 |
+
queueTaskTypeMotion: 'Transfer',
|
| 403 |
+
queueTaskTypeBatch: 'Batch',
|
| 404 |
+
queueTaskTypeImage: 'Image',
|
| 405 |
+
queueSubmitLog: '📥 Added to queue: {id} ({n} ahead)',
|
| 406 |
+
queueDoneLog: '✅ Queue task complete: {label}',
|
| 407 |
+
queueFailLog: '❌ Queue task failed: {label} - {error}',
|
| 408 |
+
queueCancelLog: '🛑 Queue task cancelled: {label}',
|
| 409 |
+
replayRun: 'Replay',
|
| 410 |
+
replayLoad: 'Load',
|
| 411 |
+
replayLabel: 'Replay',
|
| 412 |
+
replayMissing: '⚠️ This item has no replay payload',
|
| 413 |
+
replayQueuedLog: '↻ Replay added to queue: {id}',
|
| 414 |
+
replayLoadedLog: '↗ Replay settings loaded; adjust and render again',
|
| 415 |
+
replayFailed: 'Replay failed',
|
| 416 |
+
previewLoadSeed: 'Load Seed',
|
| 417 |
+
previewLoadParams: 'Load Params',
|
| 418 |
+
previewNoReplaySeed: '⚠️ Current preview has no seed to load',
|
| 419 |
+
previewNoReplayParams: '⚠️ Current preview has no params to load',
|
| 420 |
+
previewSeedLoadedLog: 'Loaded seed {seed} and switched to fixed seed',
|
| 421 |
+
previewNoDownload: '❌ No downloadable preview right now',
|
| 422 |
+
imgPreset: 'Resolution presets',
|
| 423 |
+
imgOptSquare: '1:1 (1024×1024)',
|
| 424 |
+
imgOptLand: '16:9 (1280×720)',
|
| 425 |
+
imgOptPort: '9:16 (720×1280)',
|
| 426 |
+
imgOptCustom: 'Custom...',
|
| 427 |
+
width: 'Width',
|
| 428 |
+
height: 'Height',
|
| 429 |
+
samplingSteps: 'Steps',
|
| 430 |
+
smartMultiFrameGroup: 'Smart multi-frame',
|
| 431 |
+
workflowModeLabel: 'Workflow',
|
| 432 |
+
wfSingle: 'Single pass',
|
| 433 |
+
wfSegments: 'Segments',
|
| 434 |
+
uploadImages: 'Upload images',
|
| 435 |
+
uploadMulti1: 'Click or drop multiple images',
|
| 436 |
+
uploadMulti2: 'Multi-select OK; add more anytime.',
|
| 437 |
+
batchStripTitle: 'Order = playback',
|
| 438 |
+
batchStripHint: 'Drag thumbnails to reorder.',
|
| 439 |
+
batchFfmpegHint:
|
| 440 |
+
'💡 <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.',
|
| 441 |
+
bgmLabel: 'Full-length BGM (optional)',
|
| 442 |
+
bgmUploadHint: 'Replaces final mix audio after generation.',
|
| 443 |
+
mainRender: 'Render',
|
| 444 |
+
waitingTask: 'Waiting for task...',
|
| 445 |
+
libHistory: 'Assets',
|
| 446 |
+
libLog: 'Logs',
|
| 447 |
+
refresh: 'Refresh',
|
| 448 |
+
logReady: '> LTX-2 Studio ready.',
|
| 449 |
+
resizeHandleTitle: 'Drag to resize panel',
|
| 450 |
+
batchNeedTwo: '💡 Upload at least 2 images',
|
| 451 |
+
batchSegTitle: 'Segment settings',
|
| 452 |
+
batchSegClip: 'Clip',
|
| 453 |
+
batchSegDuration: 'Duration',
|
| 454 |
+
batchSegSec: 's',
|
| 455 |
+
batchSegPrompt: 'Prompt',
|
| 456 |
+
batchSegPromptPh: 'e.g. dancing, walking...',
|
| 457 |
+
batchKfPanelTitle: 'Single pass · timeline',
|
| 458 |
+
batchTotalDur: 'Total',
|
| 459 |
+
batchTotalSec: 's',
|
| 460 |
+
batchPanelHint:
|
| 461 |
+
'Set an independent duration for each image: the first starts at 0s, each next image starts after the previous duration, and the last image also keeps its own duration. Backend uses whole seconds (ceil, min 2). Motion & FPS use the Video panel.',
|
| 462 |
+
batchKfTitle: 'Keyframe',
|
| 463 |
+
batchStrength: 'Strength',
|
| 464 |
+
batchFrameDuration: 'Duration',
|
| 465 |
+
batchFrameDurationTitle: 'How long this image occupies on the timeline; total = sum of all image durations',
|
| 466 |
+
batchGapTitle: 'Gap',
|
| 467 |
+
batchSec: 's',
|
| 468 |
+
batchAnchorStart: 'start',
|
| 469 |
+
batchAnchorEnd: 'end',
|
| 470 |
+
batchAnchorLast: 'last starts',
|
| 471 |
+
batchThumbDrag: 'Drag to reorder',
|
| 472 |
+
batchThumbRemove: 'Remove',
|
| 473 |
+
batchAddMore: '+ Add more',
|
| 474 |
+
batchGapInputTitle: 'How long this image occupies on the timeline; total = sum of all image durations',
|
| 475 |
+
batchStrengthTitle: 'Guide strength (lower on middle keys may reduce flicker)',
|
| 476 |
+
batchTotalPillTitle: 'Equals the sum of durations below',
|
| 477 |
+
defaultPath: 'default',
|
| 478 |
+
phase_loading_model: 'Loading weights',
|
| 479 |
+
phase_encoding_text: 'T5 encode',
|
| 480 |
+
phase_validating_request: 'Validating',
|
| 481 |
+
phase_uploading_audio: 'Uploading audio',
|
| 482 |
+
phase_uploading_image: 'Uploading image',
|
| 483 |
+
phase_inference: 'Inference',
|
| 484 |
+
phase_downloading_output: 'Downloading',
|
| 485 |
+
phase_complete: 'Done',
|
| 486 |
+
gpuBusyPrefix: 'GPU',
|
| 487 |
+
progressStepUnit: 'steps',
|
| 488 |
+
loaderGpuAlloc: 'Allocating GPU...',
|
| 489 |
+
warnGenerating: '⚠️ Already generating, please wait.',
|
| 490 |
+
warnBatchPrompt: '⚠️ Enter main prompt, page extra prompt, or a segment prompt.',
|
| 491 |
+
warnNeedPrompt: '⚠️ Enter a prompt first.',
|
| 492 |
+
warnVideoLong: '⚠️ Duration {n}s is very long; may OOM or take a long time.',
|
| 493 |
+
errBatchMinImages: 'Upload at least 2 images.',
|
| 494 |
+
errSingleKfNeedPrompt: 'Enter main or page extra prompt for single-pass keyframes.',
|
| 495 |
+
loraNoneLabel: 'none',
|
| 496 |
+
modelDefaultLabel: 'default',
|
| 497 |
+
loraPlacementHintWithDir:
|
| 498 |
+
'Place LoRAs into the current models directory: <code>{dir}</code>\\loras',
|
| 499 |
+
loraPlacementHint: 'Place LoRAs in the <code>loras</code> folder under the current models directory.',
|
| 500 |
+
tabTts: 'TTS',
|
| 501 |
+
ttsStatusBarDetecting: '🔍 Detecting TTS model...',
|
| 502 |
+
ttsTextTitle: 'Synthesis Text',
|
| 503 |
+
ttsTextHint: 'Supports descriptors in brackets at start, e.g. <code style="font-size:10px;">(Young female, soft and sweet)</code>',
|
| 504 |
+
ttsTextPlaceholder: 'Enter text to synthesize...',
|
| 505 |
+
ttsModeTitle: 'Synthesis Mode',
|
| 506 |
+
ttsModeTextOnly: '🗣️ Text to Speech (with sound design)',
|
| 507 |
+
ttsModeClone: '🎙️ Voice Cloning',
|
| 508 |
+
ttsModeUltimate: '⭐ Ultimate Clone (Max similarity)',
|
| 509 |
+
ttsRefLabel: '📎 Reference Audio',
|
| 510 |
+
ttsRefUploadHint: 'Click to upload reference (.wav / .mp3)',
|
| 511 |
+
ttsUltimateLabel: '📝 Audio Transcript (Optional)',
|
| 512 |
+
ttsUltimatePlaceholder: 'Text content matches reference audio exactly...',
|
| 513 |
+
ttsParamsTitle: 'Parameters',
|
| 514 |
+
ttsCfgLabel: 'CFG Strength',
|
| 515 |
+
ttsStepsLabel: 'Inference Steps',
|
| 516 |
+
ttsResultTitle: 'Output',
|
| 517 |
+
ttsDownload: '⬇️ Download Audio',
|
| 518 |
+
ttsGenBtn: '🎙️ Start Synthesis',
|
| 519 |
+
ttsGenBusy: '⏳ Generating...',
|
| 520 |
+
ttsErrNoText: '❌ TTS: Please enter synthesis text',
|
| 521 |
+
ttsErrNoRef: '❌ TTS: Voice cloning requires reference audio',
|
| 522 |
+
ttsStatusReady: '✅ VoxCPM2 Ready — Model dir: ',
|
| 523 |
+
ttsStatusNoPkq: '❌ voxcpm package not installed. Run: pip install voxcpm',
|
| 524 |
+
ttsStatusNoDir: '❌ Put the VoxCPM2 folder at: ',
|
| 525 |
+
ttsStatusNotAvail: '⚠️ TTS unavailable, check config',
|
| 526 |
+
ttsStatusConnErr: '❌ Cannot connect to TTS API: ',
|
| 527 |
+
},
|
| 528 |
+
};
|
| 529 |
+
|
| 530 |
+
function getLang() {
|
| 531 |
+
return localStorage.getItem(STORAGE_KEY) === 'en' ? 'en' : 'zh';
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
function setLang(lang) {
|
| 535 |
+
const L = lang === 'en' ? 'en' : 'zh';
|
| 536 |
+
localStorage.setItem(STORAGE_KEY, L);
|
| 537 |
+
document.documentElement.lang = L === 'en' ? 'en' : 'zh-CN';
|
| 538 |
+
try {
|
| 539 |
+
applyI18n();
|
| 540 |
+
} catch (err) {
|
| 541 |
+
console.error('[i18n] applyI18n failed:', err);
|
| 542 |
+
}
|
| 543 |
+
updateLangButton();
|
| 544 |
+
if (typeof global.onUiLanguageChanged === 'function') {
|
| 545 |
+
try {
|
| 546 |
+
global.onUiLanguageChanged();
|
| 547 |
+
} catch (e) {
|
| 548 |
+
console.warn('onUiLanguageChanged', e);
|
| 549 |
+
}
|
| 550 |
+
}
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
function t(key) {
|
| 554 |
+
const L = getLang();
|
| 555 |
+
const table = STR[L] || STR.zh;
|
| 556 |
+
if (Object.prototype.hasOwnProperty.call(table, key)) return table[key];
|
| 557 |
+
if (Object.prototype.hasOwnProperty.call(STR.zh, key)) return STR.zh[key];
|
| 558 |
+
return key;
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
function applyI18n(root) {
|
| 562 |
+
root = root || document;
|
| 563 |
+
root.querySelectorAll('[data-i18n]').forEach(function (el) {
|
| 564 |
+
var key = el.getAttribute('data-i18n');
|
| 565 |
+
if (!key) return;
|
| 566 |
+
if (el.tagName === 'OPTION') {
|
| 567 |
+
el.textContent = t(key);
|
| 568 |
+
} else {
|
| 569 |
+
el.textContent = t(key);
|
| 570 |
+
}
|
| 571 |
+
});
|
| 572 |
+
root.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
|
| 573 |
+
var key = el.getAttribute('data-i18n-placeholder');
|
| 574 |
+
if (key) el.placeholder = t(key);
|
| 575 |
+
});
|
| 576 |
+
root.querySelectorAll('[data-i18n-title]').forEach(function (el) {
|
| 577 |
+
var key = el.getAttribute('data-i18n-title');
|
| 578 |
+
if (key) el.title = t(key);
|
| 579 |
+
});
|
| 580 |
+
root.querySelectorAll('[data-i18n-html]').forEach(function (el) {
|
| 581 |
+
var key = el.getAttribute('data-i18n-html');
|
| 582 |
+
if (key) el.innerHTML = t(key);
|
| 583 |
+
});
|
| 584 |
+
root.querySelectorAll('[data-i18n-value]').forEach(function (el) {
|
| 585 |
+
var key = el.getAttribute('data-i18n-value');
|
| 586 |
+
if (key && (el.tagName === 'INPUT' || el.tagName === 'BUTTON')) {
|
| 587 |
+
el.value = t(key);
|
| 588 |
+
}
|
| 589 |
+
});
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
function updateLangButton() {
|
| 593 |
+
var btn = document.getElementById('lang-toggle-btn');
|
| 594 |
+
if (!btn) return;
|
| 595 |
+
btn.textContent = getLang() === 'zh' ? 'EN' : '中';
|
| 596 |
+
btn.setAttribute(
|
| 597 |
+
'aria-label',
|
| 598 |
+
getLang() === 'zh' ? t('langToggleAriaZh') : t('langToggleAriaEn')
|
| 599 |
+
);
|
| 600 |
+
btn.classList.toggle('active', getLang() === 'en');
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
function toggleUiLanguage() {
|
| 604 |
+
try {
|
| 605 |
+
setLang(getLang() === 'zh' ? 'en' : 'zh');
|
| 606 |
+
} catch (err) {
|
| 607 |
+
console.error('[i18n] toggleUiLanguage failed:', err);
|
| 608 |
+
}
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
/** 避免 CSP 拦截内联 onclick;确保按钮一定能触发 */
|
| 612 |
+
function bindLangToggleButton() {
|
| 613 |
+
var btn = document.getElementById('lang-toggle-btn');
|
| 614 |
+
if (!btn || btn.dataset.i18nBound === '1') return;
|
| 615 |
+
btn.dataset.i18nBound = '1';
|
| 616 |
+
btn.removeAttribute('onclick');
|
| 617 |
+
btn.addEventListener('click', function (ev) {
|
| 618 |
+
ev.preventDefault();
|
| 619 |
+
toggleUiLanguage();
|
| 620 |
+
});
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
function boot() {
|
| 624 |
+
document.documentElement.lang = getLang() === 'en' ? 'en' : 'zh-CN';
|
| 625 |
+
try {
|
| 626 |
+
applyI18n();
|
| 627 |
+
} catch (err) {
|
| 628 |
+
console.error('[i18n] applyI18n failed:', err);
|
| 629 |
+
}
|
| 630 |
+
updateLangButton();
|
| 631 |
+
bindLangToggleButton();
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
global.getUiLang = getLang;
|
| 635 |
+
global.setUiLang = setLang;
|
| 636 |
+
global.t = t;
|
| 637 |
+
global.applyI18n = applyI18n;
|
| 638 |
+
global.toggleUiLanguage = toggleUiLanguage;
|
| 639 |
+
global.updateLangToggleButton = updateLangButton;
|
| 640 |
+
|
| 641 |
+
if (document.readyState === 'loading') {
|
| 642 |
+
document.addEventListener('DOMContentLoaded', boot);
|
| 643 |
+
} else {
|
| 644 |
+
boot();
|
| 645 |
+
}
|
| 646 |
+
})(typeof window !== 'undefined' ? window : global);
|
LTX2.3-1.0.4-new/UI/index.css
ADDED
|
@@ -0,0 +1,985 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: #333;
|
| 43 |
+
border: 1px solid #555;
|
| 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: #333;
|
| 61 |
+
color: var(--text);
|
| 62 |
+
border-color: #555;
|
| 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 |
+
html[lang="en"] .tabs { gap: 3px; padding: 3px; }
|
| 96 |
+
html[lang="en"] .tab { font-size: 11px; padding: 8px 0; font-weight: 700; }
|
| 97 |
+
html[lang="en"] .tab svg { width: 13px; height: 13px; margin-right: 4px !important; flex-shrink: 0; }
|
| 98 |
+
|
| 99 |
+
.label-group { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
| 100 |
+
label { display: block; font-size: 11px; color: var(--text-dim); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
|
| 101 |
+
.val-badge { font-size: 11px; color: var(--accent); font-family: "SF Mono", ui-monospace, monospace; font-weight: 600; }
|
| 102 |
+
|
| 103 |
+
input[type="text"], input[type="number"], select, textarea {
|
| 104 |
+
width: 100%; background: var(--panel-2);
|
| 105 |
+
border: 1px solid var(--border);
|
| 106 |
+
border-radius: 7px; color: var(--text);
|
| 107 |
+
padding: 8px 11px; font-size: 12.5px; outline: none; margin-bottom: 9px;
|
| 108 |
+
/* Only transition border/shadow – NOT background-image to prevent arrow flicker */
|
| 109 |
+
transition: border-color 0.15s, box-shadow 0.15s;
|
| 110 |
+
}
|
| 111 |
+
input:focus, select:focus, textarea:focus {
|
| 112 |
+
border-color: var(--accent);
|
| 113 |
+
box-shadow: 0 0 0 2px var(--accent-ring);
|
| 114 |
+
}
|
| 115 |
+
select {
|
| 116 |
+
-webkit-appearance: none; -moz-appearance: none; appearance: none;
|
| 117 |
+
/* Stable grey arrow – no background shorthand so it won't animate */
|
| 118 |
+
background-color: var(--panel-2);
|
| 119 |
+
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");
|
| 120 |
+
background-repeat: no-repeat;
|
| 121 |
+
background-position: right 10px center;
|
| 122 |
+
background-size: 12px;
|
| 123 |
+
padding-right: 28px;
|
| 124 |
+
cursor: pointer;
|
| 125 |
+
/* Explicitly do NOT transition background properties */
|
| 126 |
+
transition: border-color 0.15s, box-shadow 0.15s;
|
| 127 |
+
}
|
| 128 |
+
select:focus { background-color: var(--panel-2); }
|
| 129 |
+
select option { background: #27272A; color: var(--text); }
|
| 130 |
+
textarea { resize: vertical; min-height: 78px; font-family: inherit; }
|
| 131 |
+
|
| 132 |
+
.slider-container { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
|
| 133 |
+
input[type="range"] { flex: 1; accent-color: var(--accent); height: 4px; cursor: pointer; border-radius: 2px; }
|
| 134 |
+
|
| 135 |
+
.upload-zone {
|
| 136 |
+
border: 1px dashed var(--border); border-radius: 10px;
|
| 137 |
+
padding: 18px 10px; text-align: center; cursor: pointer;
|
| 138 |
+
background: rgba(255,255,255,0.03); margin-bottom: 10px; position: relative;
|
| 139 |
+
transition: all 0.2s;
|
| 140 |
+
}
|
| 141 |
+
.upload-zone:hover, .upload-zone.dragover { background: var(--accent-dim); border-color: var(--accent); }
|
| 142 |
+
.upload-zone.has-images {
|
| 143 |
+
padding: 12px; background: rgba(255,255,255,0.025);
|
| 144 |
+
}
|
| 145 |
+
.upload-zone.has-images .upload-placeholder-mini {
|
| 146 |
+
display: flex; align-items: center; gap: 8px; justify-content: center;
|
| 147 |
+
color: var(--text-dim); font-size: 11px;
|
| 148 |
+
}
|
| 149 |
+
.upload-zone.has-images .upload-placeholder-mini span {
|
| 150 |
+
background: var(--item); padding: 6px 12px; border-radius: 6px;
|
| 151 |
+
}
|
| 152 |
+
#batch-images-placeholder { display: block; }
|
| 153 |
+
.upload-zone.has-images #batch-images-placeholder { display: none; }
|
| 154 |
+
|
| 155 |
+
/* 批量模式:上传区下方的横向缩略图条 */
|
| 156 |
+
.batch-thumb-strip-wrap {
|
| 157 |
+
margin-top: 10px;
|
| 158 |
+
margin-bottom: 4px;
|
| 159 |
+
}
|
| 160 |
+
.batch-thumb-strip-head {
|
| 161 |
+
display: flex;
|
| 162 |
+
flex-direction: column;
|
| 163 |
+
gap: 2px;
|
| 164 |
+
margin-bottom: 8px;
|
| 165 |
+
}
|
| 166 |
+
.batch-thumb-strip-title {
|
| 167 |
+
font-size: 11px;
|
| 168 |
+
font-weight: 700;
|
| 169 |
+
color: var(--text-sub);
|
| 170 |
+
}
|
| 171 |
+
.batch-thumb-strip-hint {
|
| 172 |
+
font-size: 10px;
|
| 173 |
+
color: var(--text-dim);
|
| 174 |
+
}
|
| 175 |
+
.batch-images-container {
|
| 176 |
+
display: flex;
|
| 177 |
+
flex-direction: row;
|
| 178 |
+
flex-wrap: nowrap;
|
| 179 |
+
gap: 10px;
|
| 180 |
+
overflow-x: auto;
|
| 181 |
+
overflow-y: visible;
|
| 182 |
+
padding: 6px 4px 14px;
|
| 183 |
+
margin: 0 -4px;
|
| 184 |
+
scrollbar-width: thin;
|
| 185 |
+
scrollbar-color: var(--border) transparent;
|
| 186 |
+
align-items: center;
|
| 187 |
+
}
|
| 188 |
+
.batch-images-container::-webkit-scrollbar { height: 6px; }
|
| 189 |
+
.batch-images-container::-webkit-scrollbar-thumb {
|
| 190 |
+
background: var(--border);
|
| 191 |
+
border-radius: 3px;
|
| 192 |
+
}
|
| 193 |
+
.batch-image-wrapper {
|
| 194 |
+
flex: 0 0 72px;
|
| 195 |
+
width: 72px;
|
| 196 |
+
height: 72px;
|
| 197 |
+
position: relative;
|
| 198 |
+
border-radius: 10px;
|
| 199 |
+
overflow: hidden;
|
| 200 |
+
background: var(--item);
|
| 201 |
+
border: 1px solid var(--border);
|
| 202 |
+
cursor: grab;
|
| 203 |
+
touch-action: none;
|
| 204 |
+
user-select: none;
|
| 205 |
+
-webkit-user-select: none;
|
| 206 |
+
transition:
|
| 207 |
+
flex-basis 0.38s cubic-bezier(0.22, 1, 0.36, 1),
|
| 208 |
+
width 0.38s cubic-bezier(0.22, 1, 0.36, 1),
|
| 209 |
+
min-width 0.38s cubic-bezier(0.22, 1, 0.36, 1),
|
| 210 |
+
margin 0.38s cubic-bezier(0.22, 1, 0.36, 1),
|
| 211 |
+
opacity 0.25s ease,
|
| 212 |
+
border-color 0.2s ease,
|
| 213 |
+
box-shadow 0.2s ease,
|
| 214 |
+
transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
|
| 215 |
+
}
|
| 216 |
+
.batch-image-wrapper:active { cursor: grabbing; }
|
| 217 |
+
.batch-image-wrapper.batch-thumb--source {
|
| 218 |
+
flex: 0 0 0;
|
| 219 |
+
width: 0;
|
| 220 |
+
min-width: 0;
|
| 221 |
+
height: 72px;
|
| 222 |
+
margin: 0;
|
| 223 |
+
padding: 0;
|
| 224 |
+
border: none;
|
| 225 |
+
overflow: hidden;
|
| 226 |
+
opacity: 0;
|
| 227 |
+
background: transparent;
|
| 228 |
+
box-shadow: none;
|
| 229 |
+
pointer-events: none;
|
| 230 |
+
/* 收起必须瞬时:若与占位框同时用 0.38s 过渡,右侧缩略图会与「突然出现」的槽位不同步而闪一下 */
|
| 231 |
+
transition: none !important;
|
| 232 |
+
}
|
| 233 |
+
/* 按下瞬间:冻结其它卡片与槽位动画,避免「槽位插入 + 邻居过渡」两帧打架 */
|
| 234 |
+
.batch-images-container.is-batch-settling .batch-image-wrapper:not(.batch-thumb--source) {
|
| 235 |
+
transition: none !important;
|
| 236 |
+
}
|
| 237 |
+
.batch-images-container.is-batch-settling .batch-thumb-drop-slot {
|
| 238 |
+
animation: none;
|
| 239 |
+
opacity: 1;
|
| 240 |
+
}
|
| 241 |
+
/* 拖动时跟手的浮动缩略图(避免原槽位透明后光标下像「黑块」) */
|
| 242 |
+
.batch-thumb-floating-ghost {
|
| 243 |
+
position: fixed;
|
| 244 |
+
left: 0;
|
| 245 |
+
top: 0;
|
| 246 |
+
z-index: 99999;
|
| 247 |
+
width: 76px;
|
| 248 |
+
height: 76px;
|
| 249 |
+
border-radius: 12px;
|
| 250 |
+
overflow: hidden;
|
| 251 |
+
pointer-events: none;
|
| 252 |
+
will-change: transform;
|
| 253 |
+
box-shadow:
|
| 254 |
+
0 20px 50px rgba(0, 0, 0, 0.45),
|
| 255 |
+
0 10px 28px rgba(0, 0, 0, 0.28),
|
| 256 |
+
0 0 0 1px rgba(255, 255, 255, 0.18);
|
| 257 |
+
transform: translate3d(0, 0, 0) scale(1.06) rotate(-1deg);
|
| 258 |
+
}
|
| 259 |
+
.batch-thumb-floating-ghost img {
|
| 260 |
+
width: 100%;
|
| 261 |
+
height: 100%;
|
| 262 |
+
object-fit: cover;
|
| 263 |
+
display: block;
|
| 264 |
+
pointer-events: none;
|
| 265 |
+
}
|
| 266 |
+
.batch-thumb-drop-slot {
|
| 267 |
+
flex: 0 0 72px;
|
| 268 |
+
width: 72px;
|
| 269 |
+
height: 72px;
|
| 270 |
+
box-sizing: border-box;
|
| 271 |
+
border-radius: 12px;
|
| 272 |
+
border: 2px dashed rgba(255, 255, 255, 0.22);
|
| 273 |
+
background: linear-gradient(145deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.03));
|
| 274 |
+
pointer-events: none;
|
| 275 |
+
transition: border-color 0.35s ease, box-shadow 0.35s ease, opacity 0.35s ease;
|
| 276 |
+
animation: batch-slot-breathe 2.4s ease-in-out infinite;
|
| 277 |
+
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
|
| 278 |
+
}
|
| 279 |
+
@keyframes batch-slot-breathe {
|
| 280 |
+
0%, 100% { opacity: 0.88; }
|
| 281 |
+
50% { opacity: 1; }
|
| 282 |
+
}
|
| 283 |
+
.batch-image-wrapper .batch-thumb-img-wrap {
|
| 284 |
+
width: 100%;
|
| 285 |
+
height: 100%;
|
| 286 |
+
border-radius: 9px;
|
| 287 |
+
overflow: hidden;
|
| 288 |
+
/* 必须让事件落到外层 .batch-image-wrapper,否则 HTML5 drag 无法从 draggable 父级启动 */
|
| 289 |
+
pointer-events: none;
|
| 290 |
+
}
|
| 291 |
+
.batch-image-wrapper .batch-thumb-img {
|
| 292 |
+
width: 100%;
|
| 293 |
+
height: 100%;
|
| 294 |
+
object-fit: cover;
|
| 295 |
+
display: block;
|
| 296 |
+
pointer-events: none;
|
| 297 |
+
user-select: none;
|
| 298 |
+
-webkit-user-drag: none;
|
| 299 |
+
}
|
| 300 |
+
.batch-thumb-remove {
|
| 301 |
+
position: absolute;
|
| 302 |
+
top: 3px;
|
| 303 |
+
right: 3px;
|
| 304 |
+
z-index: 5;
|
| 305 |
+
box-sizing: border-box;
|
| 306 |
+
min-width: 22px;
|
| 307 |
+
height: 22px;
|
| 308 |
+
padding: 0 5px;
|
| 309 |
+
margin: 0;
|
| 310 |
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
| 311 |
+
border-radius: 6px;
|
| 312 |
+
background: rgba(0, 0, 0, 0.5);
|
| 313 |
+
font-family: inherit;
|
| 314 |
+
font-size: 14px;
|
| 315 |
+
font-weight: 400;
|
| 316 |
+
line-height: 1;
|
| 317 |
+
color: rgba(255, 255, 255, 0.9);
|
| 318 |
+
opacity: 0.72;
|
| 319 |
+
cursor: pointer;
|
| 320 |
+
display: flex;
|
| 321 |
+
align-items: center;
|
| 322 |
+
justify-content: center;
|
| 323 |
+
transition: background 0.12s, opacity 0.12s, border-color 0.12s;
|
| 324 |
+
pointer-events: auto;
|
| 325 |
+
}
|
| 326 |
+
.batch-image-wrapper:hover .batch-thumb-remove {
|
| 327 |
+
opacity: 1;
|
| 328 |
+
background: rgba(0, 0, 0, 0.68);
|
| 329 |
+
border-color: rgba(255, 255, 255, 0.2);
|
| 330 |
+
}
|
| 331 |
+
.batch-thumb-remove:hover {
|
| 332 |
+
background: rgba(80, 20, 20, 0.75) !important;
|
| 333 |
+
border-color: rgba(255, 180, 180, 0.35);
|
| 334 |
+
color: #fff;
|
| 335 |
+
}
|
| 336 |
+
.batch-thumb-remove:focus-visible {
|
| 337 |
+
opacity: 1;
|
| 338 |
+
outline: 2px solid var(--accent-dim, rgba(120, 160, 255, 0.6));
|
| 339 |
+
outline-offset: 1px;
|
| 340 |
+
}
|
| 341 |
+
.upload-icon { font-size: 18px; margin-bottom: 6px; opacity: 0.45; }
|
| 342 |
+
.upload-text { font-size: 11px; color: var(--text); }
|
| 343 |
+
.upload-hint { font-size: 10px; color: var(--text-dim); margin-top: 3px; }
|
| 344 |
+
.preview-thumb { width: 100%; height: auto; max-height: 100px; object-fit: contain; border-radius: 8px; display: none; margin-top: 10px; }
|
| 345 |
+
.clear-img-overlay {
|
| 346 |
+
position: absolute; top: 8px; right: 8px; background: rgba(255,59,48,0.85); color: white;
|
| 347 |
+
width: 20px; height: 20px; border-radius: 10px; display: none; align-items: center; justify-content: center;
|
| 348 |
+
font-size: 11px; cursor: pointer; z-index: 5;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
.btn-outline {
|
| 352 |
+
background: var(--panel-2);
|
| 353 |
+
border: 1px solid var(--border);
|
| 354 |
+
color: var(--text-sub); padding: 5px 12px; border-radius: 7px;
|
| 355 |
+
font-size: 11.5px; font-weight: 600; cursor: pointer;
|
| 356 |
+
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
| 357 |
+
display: inline-flex; align-items: center; justify-content: center; gap: 5px;
|
| 358 |
+
white-space: nowrap;
|
| 359 |
+
}
|
| 360 |
+
.btn-outline:hover:not(:disabled) { background: rgba(255,255,255,0.08); color: var(--text); border-color: rgba(255,255,255,0.18); }
|
| 361 |
+
.btn-outline:active { opacity: 0.7; }
|
| 362 |
+
.btn-outline:disabled { opacity: 0.3; cursor: not-allowed; }
|
| 363 |
+
|
| 364 |
+
.btn-icon {
|
| 365 |
+
padding: 5px; background: transparent; border: none; color: var(--text-dim);
|
| 366 |
+
border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center;
|
| 367 |
+
transition: color 0.15s, background 0.15s;
|
| 368 |
+
}
|
| 369 |
+
.btn-icon:hover { color: var(--text-sub); background: rgba(255,255,255,0.07); }
|
| 370 |
+
|
| 371 |
+
.btn-primary {
|
| 372 |
+
width: 100%; padding: 13px;
|
| 373 |
+
background: var(--accent); border: none;
|
| 374 |
+
border-radius: 9px; color: #fff; font-weight: 700; font-size: 13.5px;
|
| 375 |
+
letter-spacing: 0.2px; cursor: pointer; margin-top: 14px;
|
| 376 |
+
transition: background 0.15s;
|
| 377 |
+
}
|
| 378 |
+
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
|
| 379 |
+
.btn-primary:active { opacity: 0.82; }
|
| 380 |
+
.btn-primary:disabled { background: rgba(255,255,255,0.08); color: var(--text-dim); cursor: not-allowed; }
|
| 381 |
+
|
| 382 |
+
.btn-danger {
|
| 383 |
+
width: 100%; padding: 12px; background: #DC2626; border: none;
|
| 384 |
+
border-radius: 9px; color: #fff; font-weight: 700; font-size: 13.5px;
|
| 385 |
+
cursor: pointer; margin-top: 8px; display: none; transition: background 0.15s;
|
| 386 |
+
}
|
| 387 |
+
.btn-danger:hover { background: #EF4444; }
|
| 388 |
+
|
| 389 |
+
/* Workspace */
|
| 390 |
+
.workspace { flex: 1; display: flex; flex-direction: column; background: #0A0A0A; position: relative; overflow: hidden; }
|
| 391 |
+
.viewer { flex: 2; display: flex; align-items: center; justify-content: center; padding: 16px; background: #0A0A0A; position: relative; min-height: 40vh; }
|
| 392 |
+
.monitor {
|
| 393 |
+
width: 100%; height: 100%; max-width: 1650px; border-radius: 10px; border: 1px solid var(--border);
|
| 394 |
+
overflow: hidden; position: relative; background: #070707;
|
| 395 |
+
display: flex; align-items: center; justify-content: center;
|
| 396 |
+
background-image: radial-gradient(rgba(255,255,255,0.02) 1px, transparent 1px);
|
| 397 |
+
background-size: 18px 18px;
|
| 398 |
+
}
|
| 399 |
+
.monitor img, .monitor video {
|
| 400 |
+
width: auto; height: auto; max-width: 100%; max-height: 100%;
|
| 401 |
+
object-fit: contain; display: none; z-index: 2; border-radius: 3px;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.progress-container { position: absolute; bottom: 0; left: 0; width: 100%; height: 2px; background: var(--border-2); z-index: 10; }
|
| 405 |
+
#progress-fill { width: 0%; height: 100%; background: var(--accent); transition: width 0.5s; }
|
| 406 |
+
#loading-txt { font-size: 12px; color: var(--text-sub); font-weight: 600; z-index: 5; position: absolute; display: none; }
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
.spinner {
|
| 411 |
+
width: 12px; height: 12px;
|
| 412 |
+
border: 2px solid rgba(255,255,255,0.2);
|
| 413 |
+
border-top-color: currentColor;
|
| 414 |
+
border-radius: 50%;
|
| 415 |
+
animation: spin 1s linear infinite;
|
| 416 |
+
}
|
| 417 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 418 |
+
|
| 419 |
+
.loading-card {
|
| 420 |
+
display: flex; align-items: center; justify-content: center;
|
| 421 |
+
flex-direction: column; gap: 6px; color: var(--text-dim); font-size: 10px;
|
| 422 |
+
background: rgba(37,99,235,0.07) !important;
|
| 423 |
+
border-color: rgba(37,99,235,0.3) !important;
|
| 424 |
+
}
|
| 425 |
+
.loading-card .spinner { width: 28px; height: 28px; border-width: 3px; color: var(--accent); }
|
| 426 |
+
.loading-card:hover { background: rgba(37,99,235,0.14) !important; border-color: var(--accent) !important; }
|
| 427 |
+
|
| 428 |
+
.library { flex: 1.5; border-top: 1px solid var(--border); padding: 14px 20px; display: flex; flex-direction: column; background: #0F0F11; overflow-y: hidden; }
|
| 429 |
+
#log-container { flex: 1; overflow-y: auto; padding-right: 4px; }
|
| 430 |
+
#log { font-family: ui-monospace, "SF Mono", monospace; font-size: 10.5px; color: var(--text-dim); line-height: 1.7; }
|
| 431 |
+
|
| 432 |
+
/* History wrapper: scrollable area for thumbnails only */
|
| 433 |
+
#history-wrapper {
|
| 434 |
+
flex: 1;
|
| 435 |
+
overflow-y: auto;
|
| 436 |
+
min-height: 110px; /* always show at least one row */
|
| 437 |
+
padding-right: 4px;
|
| 438 |
+
}
|
| 439 |
+
#history-container {
|
| 440 |
+
display: grid;
|
| 441 |
+
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
| 442 |
+
justify-content: start;
|
| 443 |
+
gap: 10px; align-content: flex-start;
|
| 444 |
+
padding-bottom: 4px;
|
| 445 |
+
}
|
| 446 |
+
/* Pagination row: hidden, using infinite scroll instead */
|
| 447 |
+
#pagination-bar {
|
| 448 |
+
display: none;
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
.history-card {
|
| 452 |
+
width: 100%; max-width: 200px; aspect-ratio: 16 / 9;
|
| 453 |
+
background: #1A1A1E; border-radius: 7px;
|
| 454 |
+
overflow: hidden; border: 1px solid var(--border);
|
| 455 |
+
cursor: pointer; position: relative; transition: border-color 0.15s, transform 0.15s;
|
| 456 |
+
}
|
| 457 |
+
.history-card:hover { border-color: var(--accent); transform: translateY(-1px); }
|
| 458 |
+
.history-card img, .history-card video {
|
| 459 |
+
width: 100%; height: 100%; object-fit: cover;
|
| 460 |
+
background: #1A1A1E;
|
| 461 |
+
}
|
| 462 |
+
.history-audio-thumb {
|
| 463 |
+
width: 100%; height: 100%; display: flex; flex-direction: column;
|
| 464 |
+
align-items: center; justify-content: center; gap: 6px;
|
| 465 |
+
color: var(--text); background: linear-gradient(135deg, rgba(35,35,40,0.95), rgba(18,18,22,0.98));
|
| 466 |
+
font-size: 11px; font-weight: 700; text-align: center; padding: 10px; box-sizing: border-box;
|
| 467 |
+
}
|
| 468 |
+
.history-audio-icon { font-size: 24px; line-height: 1; }
|
| 469 |
+
#audio-wrapper {
|
| 470 |
+
position: absolute; inset: 0; width: 100%; height: 100%; min-height: 0;
|
| 471 |
+
display: none; flex-direction: column; align-items: stretch; justify-content: stretch; z-index: 2;
|
| 472 |
+
background: #070707;
|
| 473 |
+
background-image: radial-gradient(rgba(255,255,255,0.035) 1px, transparent 1px);
|
| 474 |
+
background-size: 18px 18px;
|
| 475 |
+
}
|
| 476 |
+
.audio-preview-art {
|
| 477 |
+
position: absolute; inset: 0 0 54px 0; display: flex; flex-direction: column;
|
| 478 |
+
align-items: center; justify-content: center; gap: 12px; padding: 24px;
|
| 479 |
+
color: var(--text); cursor: pointer; user-select: none; z-index: 2;
|
| 480 |
+
}
|
| 481 |
+
.audio-preview-icon {
|
| 482 |
+
width: 86px; height: 86px; display: flex; align-items: center; justify-content: center;
|
| 483 |
+
border: 1px solid var(--border); border-radius: 50%; background: rgba(255,255,255,0.04);
|
| 484 |
+
font-size: 46px; font-weight: 800; color: var(--accent);
|
| 485 |
+
}
|
| 486 |
+
#audio-preview-title {
|
| 487 |
+
max-width: min(620px, 86%); font-size: 13px; font-weight: 800; color: var(--text);
|
| 488 |
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center;
|
| 489 |
+
}
|
| 490 |
+
#audio-wrapper .plyr {
|
| 491 |
+
position: absolute; left: 0; right: 0; bottom: 0; top: auto;
|
| 492 |
+
width: 100%; height: auto !important; min-height: 0; z-index: 3;
|
| 493 |
+
border-radius: 0;
|
| 494 |
+
}
|
| 495 |
+
#audio-wrapper .plyr--audio .plyr__controls {
|
| 496 |
+
border-radius: 0; border-top: 1px solid var(--border); background: rgba(12,12,14,0.96);
|
| 497 |
+
}
|
| 498 |
+
#res-audio { width: 100%; }
|
| 499 |
+
.preview-download-btn {
|
| 500 |
+
position: absolute;
|
| 501 |
+
top: 14px;
|
| 502 |
+
right: 14px;
|
| 503 |
+
z-index: 6;
|
| 504 |
+
display: inline-flex;
|
| 505 |
+
align-items: center;
|
| 506 |
+
gap: 8px;
|
| 507 |
+
padding: 8px 12px;
|
| 508 |
+
border-radius: 999px;
|
| 509 |
+
border: 1px solid rgba(255,255,255,0.16);
|
| 510 |
+
background: rgba(10,10,12,0.72);
|
| 511 |
+
color: #fff;
|
| 512 |
+
font-size: 11px;
|
| 513 |
+
font-weight: 800;
|
| 514 |
+
letter-spacing: 0.02em;
|
| 515 |
+
cursor: pointer;
|
| 516 |
+
backdrop-filter: blur(10px);
|
| 517 |
+
box-shadow: 0 8px 24px rgba(0,0,0,0.24);
|
| 518 |
+
transition: transform 0.18s ease, background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
|
| 519 |
+
}
|
| 520 |
+
.preview-download-btn:hover {
|
| 521 |
+
transform: translateY(-1px);
|
| 522 |
+
background: rgba(18,18,22,0.9);
|
| 523 |
+
border-color: rgba(92,214,143,0.4);
|
| 524 |
+
box-shadow: 0 10px 28px rgba(0,0,0,0.3);
|
| 525 |
+
}
|
| 526 |
+
.preview-download-btn:active {
|
| 527 |
+
transform: translateY(0);
|
| 528 |
+
background: rgba(26,26,30,0.96);
|
| 529 |
+
}
|
| 530 |
+
.preview-download-btn-icon {
|
| 531 |
+
width: 24px;
|
| 532 |
+
height: 24px;
|
| 533 |
+
display: inline-flex;
|
| 534 |
+
align-items: center;
|
| 535 |
+
justify-content: center;
|
| 536 |
+
border-radius: 999px;
|
| 537 |
+
background: rgba(255,255,255,0.08);
|
| 538 |
+
color: var(--accent);
|
| 539 |
+
flex-shrink: 0;
|
| 540 |
+
}
|
| 541 |
+
.preview-download-btn-text {
|
| 542 |
+
line-height: 1;
|
| 543 |
+
}
|
| 544 |
+
.preview-replay-actions {
|
| 545 |
+
position: absolute;
|
| 546 |
+
top: 14px;
|
| 547 |
+
left: 14px;
|
| 548 |
+
z-index: 80;
|
| 549 |
+
display: inline-flex;
|
| 550 |
+
gap: 8px;
|
| 551 |
+
align-items: center;
|
| 552 |
+
pointer-events: auto;
|
| 553 |
+
}
|
| 554 |
+
.preview-replay-actions button {
|
| 555 |
+
height: 34px;
|
| 556 |
+
padding: 0 12px;
|
| 557 |
+
border-radius: 999px;
|
| 558 |
+
border: 1px solid rgba(255,255,255,0.16);
|
| 559 |
+
background: rgba(10,10,12,0.72);
|
| 560 |
+
color: #fff;
|
| 561 |
+
font-size: 11px;
|
| 562 |
+
font-weight: 800;
|
| 563 |
+
cursor: pointer;
|
| 564 |
+
backdrop-filter: blur(10px);
|
| 565 |
+
pointer-events: auto;
|
| 566 |
+
}
|
| 567 |
+
.preview-replay-actions button:hover {
|
| 568 |
+
border-color: rgba(92,214,143,0.42);
|
| 569 |
+
background: rgba(18,32,24,0.9);
|
| 570 |
+
color: var(--accent);
|
| 571 |
+
}
|
| 572 |
+
.preview-replay-actions.is-unavailable button {
|
| 573 |
+
opacity: 0.42;
|
| 574 |
+
color: var(--text-dim);
|
| 575 |
+
border-color: rgba(255,255,255,0.08);
|
| 576 |
+
background: rgba(10,10,12,0.54);
|
| 577 |
+
}
|
| 578 |
+
.seed-panel {
|
| 579 |
+
margin-top: 10px;
|
| 580 |
+
padding: 10px;
|
| 581 |
+
border-radius: 10px;
|
| 582 |
+
border: 1px solid var(--border-2);
|
| 583 |
+
background: rgba(255,255,255,0.025);
|
| 584 |
+
}
|
| 585 |
+
.seed-panel-head {
|
| 586 |
+
display: flex;
|
| 587 |
+
align-items: center;
|
| 588 |
+
margin-bottom: 8px;
|
| 589 |
+
color: var(--text-dim);
|
| 590 |
+
font-size: 10px;
|
| 591 |
+
font-weight: 800;
|
| 592 |
+
letter-spacing: 0.6px;
|
| 593 |
+
text-transform: uppercase;
|
| 594 |
+
}
|
| 595 |
+
.seed-control {
|
| 596 |
+
display: grid;
|
| 597 |
+
grid-template-columns: minmax(0, 1fr) 112px;
|
| 598 |
+
gap: 8px;
|
| 599 |
+
align-items: stretch;
|
| 600 |
+
}
|
| 601 |
+
.seed-input-shell {
|
| 602 |
+
height: 36px;
|
| 603 |
+
display: flex;
|
| 604 |
+
align-items: center;
|
| 605 |
+
border-radius: 8px;
|
| 606 |
+
background: var(--panel-2);
|
| 607 |
+
border: 1px solid var(--border);
|
| 608 |
+
overflow: hidden;
|
| 609 |
+
transition: border-color 0.15s, box-shadow 0.15s, opacity 0.15s;
|
| 610 |
+
}
|
| 611 |
+
.seed-input-shell:focus-within {
|
| 612 |
+
border-color: var(--accent);
|
| 613 |
+
box-shadow: 0 0 0 2px var(--accent-ring);
|
| 614 |
+
}
|
| 615 |
+
#seed-value {
|
| 616 |
+
height: 100%;
|
| 617 |
+
width: 100%;
|
| 618 |
+
margin: 0;
|
| 619 |
+
border: 0;
|
| 620 |
+
border-radius: 0;
|
| 621 |
+
background: transparent;
|
| 622 |
+
box-shadow: none;
|
| 623 |
+
color: var(--text);
|
| 624 |
+
font-family: ui-monospace, "SF Mono", monospace;
|
| 625 |
+
font-size: 12px;
|
| 626 |
+
font-weight: 700;
|
| 627 |
+
font-variant-numeric: tabular-nums;
|
| 628 |
+
padding: 0 11px;
|
| 629 |
+
}
|
| 630 |
+
#seed-value:focus { box-shadow: none; }
|
| 631 |
+
.seed-input-shell.is-random #seed-value {
|
| 632 |
+
color: var(--text-dim);
|
| 633 |
+
}
|
| 634 |
+
.seed-mode-tabs {
|
| 635 |
+
height: 36px;
|
| 636 |
+
display: grid;
|
| 637 |
+
grid-template-columns: 1fr 1fr;
|
| 638 |
+
gap: 3px;
|
| 639 |
+
padding: 3px;
|
| 640 |
+
border-radius: 8px;
|
| 641 |
+
border: 1px solid var(--border);
|
| 642 |
+
background: rgba(255,255,255,0.04);
|
| 643 |
+
}
|
| 644 |
+
.seed-mode-option {
|
| 645 |
+
margin: 0;
|
| 646 |
+
height: 28px;
|
| 647 |
+
display: inline-flex;
|
| 648 |
+
align-items: center;
|
| 649 |
+
justify-content: center;
|
| 650 |
+
border-radius: 6px;
|
| 651 |
+
color: var(--text-dim);
|
| 652 |
+
cursor: pointer;
|
| 653 |
+
font-size: 10.5px;
|
| 654 |
+
font-weight: 800;
|
| 655 |
+
letter-spacing: 0;
|
| 656 |
+
text-transform: none;
|
| 657 |
+
transition: background 0.15s, color 0.15s;
|
| 658 |
+
}
|
| 659 |
+
.seed-mode-option input { display: none; }
|
| 660 |
+
.seed-mode-option.is-active {
|
| 661 |
+
background: var(--accent);
|
| 662 |
+
color: #fff;
|
| 663 |
+
}
|
| 664 |
+
.seed-mode-option:not(.is-active):hover {
|
| 665 |
+
background: rgba(255,255,255,0.06);
|
| 666 |
+
color: var(--text-sub);
|
| 667 |
+
}
|
| 668 |
+
.lora-control-head {
|
| 669 |
+
margin-top: 14px;
|
| 670 |
+
}
|
| 671 |
+
/* 解码/加载完成前避免视频黑块猛闪,与卡片底色一致;就绪后淡入 */
|
| 672 |
+
.history-card .history-thumb-media {
|
| 673 |
+
opacity: 0;
|
| 674 |
+
transition: opacity 0.28s ease;
|
| 675 |
+
}
|
| 676 |
+
.history-card .history-thumb-media.history-thumb-ready {
|
| 677 |
+
opacity: 1;
|
| 678 |
+
}
|
| 679 |
+
.history-type-badge {
|
| 680 |
+
position: absolute; top: 5px; left: 5px; font-size: 8px; padding: 1px 5px; border-radius: 3px;
|
| 681 |
+
background: rgba(0,0,0,0.8); color: var(--text-sub); border: 1px solid rgba(255,255,255,0.06);
|
| 682 |
+
z-index: 2; font-weight: 700; letter-spacing: 0.4px;
|
| 683 |
+
}
|
| 684 |
+
.history-delete-btn {
|
| 685 |
+
position: absolute; top: 5px; right: 5px; width: 20px; height: 20px;
|
| 686 |
+
border-radius: 50%; border: none; background: rgba(255,50,50,0.8); color: #fff;
|
| 687 |
+
font-size: 10px; cursor: pointer; z-index: 3; display: flex; align-items: center; justify-content: center;
|
| 688 |
+
opacity: 0; transition: opacity 0.2s;
|
| 689 |
+
}
|
| 690 |
+
.history-card:hover .history-delete-btn { opacity: 1; }
|
| 691 |
+
.history-delete-btn:hover { background: rgba(255,0,0,0.9); }
|
| 692 |
+
.vram-bar { width: 160px; height: 5px; background: rgba(255,255,255,0.08); border-radius: 999px; overflow: hidden; display: inline-block; vertical-align: middle; }
|
| 693 |
+
.vram-used { height: 100%; background: var(--accent); width: 0%; transition: width 0.5s; }
|
| 694 |
+
|
| 695 |
+
/* 智能多帧:工作流模式卡片式单选 */
|
| 696 |
+
.smart-param-mode-label {
|
| 697 |
+
font-size: 10px;
|
| 698 |
+
color: var(--text-dim);
|
| 699 |
+
font-weight: 700;
|
| 700 |
+
margin-bottom: 8px;
|
| 701 |
+
letter-spacing: 0.04em;
|
| 702 |
+
text-transform: uppercase;
|
| 703 |
+
}
|
| 704 |
+
.smart-param-modes {
|
| 705 |
+
display: flex;
|
| 706 |
+
flex-direction: row;
|
| 707 |
+
align-items: stretch;
|
| 708 |
+
gap: 0;
|
| 709 |
+
padding: 3px;
|
| 710 |
+
margin-bottom: 12px;
|
| 711 |
+
background: var(--panel-2);
|
| 712 |
+
border-radius: 8px;
|
| 713 |
+
border: 1px solid var(--border);
|
| 714 |
+
}
|
| 715 |
+
.smart-param-mode-opt {
|
| 716 |
+
display: flex;
|
| 717 |
+
align-items: center;
|
| 718 |
+
justify-content: center;
|
| 719 |
+
flex: 1;
|
| 720 |
+
min-width: 0;
|
| 721 |
+
gap: 0;
|
| 722 |
+
margin: 0;
|
| 723 |
+
padding: 6px 8px;
|
| 724 |
+
border-radius: 6px;
|
| 725 |
+
border: none;
|
| 726 |
+
background: transparent;
|
| 727 |
+
cursor: pointer;
|
| 728 |
+
transition: background 0.15s, color 0.15s;
|
| 729 |
+
position: relative;
|
| 730 |
+
}
|
| 731 |
+
.smart-param-mode-opt:hover:not(:has(input:checked)) {
|
| 732 |
+
background: rgba(255, 255, 255, 0.05);
|
| 733 |
+
}
|
| 734 |
+
.smart-param-mode-opt input[type="radio"] {
|
| 735 |
+
position: absolute;
|
| 736 |
+
opacity: 0;
|
| 737 |
+
width: 0;
|
| 738 |
+
height: 0;
|
| 739 |
+
margin: 0;
|
| 740 |
+
}
|
| 741 |
+
.smart-param-mode-opt:has(input:checked) {
|
| 742 |
+
background: var(--accent);
|
| 743 |
+
box-shadow: none;
|
| 744 |
+
}
|
| 745 |
+
.smart-param-mode-opt:has(input:checked) .smart-param-mode-title {
|
| 746 |
+
color: #fff;
|
| 747 |
+
}
|
| 748 |
+
.smart-param-mode-title {
|
| 749 |
+
font-size: 11px;
|
| 750 |
+
font-weight: 600;
|
| 751 |
+
color: var(--text-sub);
|
| 752 |
+
text-align: center;
|
| 753 |
+
line-height: 1.25;
|
| 754 |
+
flex: none;
|
| 755 |
+
min-width: 0;
|
| 756 |
+
}
|
| 757 |
+
/* 单次多关键帧:时间轴面板 */
|
| 758 |
+
.batch-kf-panel {
|
| 759 |
+
background: var(--item);
|
| 760 |
+
border-radius: 10px;
|
| 761 |
+
padding: 12px 14px;
|
| 762 |
+
margin-bottom: 10px;
|
| 763 |
+
border: 1px solid var(--border);
|
| 764 |
+
}
|
| 765 |
+
.batch-kf-panel-hd {
|
| 766 |
+
display: flex;
|
| 767 |
+
flex-wrap: wrap;
|
| 768 |
+
align-items: center;
|
| 769 |
+
justify-content: space-between;
|
| 770 |
+
gap: 10px;
|
| 771 |
+
margin-bottom: 8px;
|
| 772 |
+
}
|
| 773 |
+
.batch-kf-panel-title {
|
| 774 |
+
font-size: 12px;
|
| 775 |
+
font-weight: 700;
|
| 776 |
+
color: var(--text);
|
| 777 |
+
}
|
| 778 |
+
.batch-kf-total-pill {
|
| 779 |
+
font-size: 11px;
|
| 780 |
+
color: var(--text-sub);
|
| 781 |
+
background: var(--panel-2);
|
| 782 |
+
border: 1px solid var(--border);
|
| 783 |
+
border-radius: 999px;
|
| 784 |
+
padding: 6px 12px;
|
| 785 |
+
white-space: nowrap;
|
| 786 |
+
}
|
| 787 |
+
.batch-kf-total-pill strong {
|
| 788 |
+
color: var(--accent);
|
| 789 |
+
font-weight: 800;
|
| 790 |
+
font-variant-numeric: tabular-nums;
|
| 791 |
+
margin: 0 2px;
|
| 792 |
+
}
|
| 793 |
+
.batch-kf-total-unit {
|
| 794 |
+
font-size: 10px;
|
| 795 |
+
color: var(--text-dim);
|
| 796 |
+
}
|
| 797 |
+
.batch-kf-panel-hint {
|
| 798 |
+
font-size: 10px;
|
| 799 |
+
color: var(--text-dim);
|
| 800 |
+
line-height: 1.5;
|
| 801 |
+
margin: 0 0 12px;
|
| 802 |
+
}
|
| 803 |
+
.batch-kf-timeline-col {
|
| 804 |
+
display: flex;
|
| 805 |
+
flex-direction: column;
|
| 806 |
+
gap: 0;
|
| 807 |
+
}
|
| 808 |
+
.batch-kf-kcard {
|
| 809 |
+
border-radius: 10px;
|
| 810 |
+
border: 1px solid var(--border);
|
| 811 |
+
background: rgba(255, 255, 255, 0.03);
|
| 812 |
+
padding: 10px 12px;
|
| 813 |
+
}
|
| 814 |
+
.batch-kf-kcard-head {
|
| 815 |
+
display: flex;
|
| 816 |
+
align-items: center;
|
| 817 |
+
gap: 12px;
|
| 818 |
+
margin-bottom: 10px;
|
| 819 |
+
}
|
| 820 |
+
.batch-kf-kthumb {
|
| 821 |
+
width: 48px;
|
| 822 |
+
height: 48px;
|
| 823 |
+
border-radius: 8px;
|
| 824 |
+
object-fit: cover;
|
| 825 |
+
flex-shrink: 0;
|
| 826 |
+
border: 1px solid var(--border);
|
| 827 |
+
}
|
| 828 |
+
.batch-kf-kcard-titles {
|
| 829 |
+
display: flex;
|
| 830 |
+
flex-direction: column;
|
| 831 |
+
gap: 4px;
|
| 832 |
+
min-width: 0;
|
| 833 |
+
}
|
| 834 |
+
.batch-kf-ktitle {
|
| 835 |
+
font-size: 12px;
|
| 836 |
+
font-weight: 700;
|
| 837 |
+
color: var(--text);
|
| 838 |
+
}
|
| 839 |
+
.batch-kf-anchor {
|
| 840 |
+
font-size: 11px;
|
| 841 |
+
color: var(--accent);
|
| 842 |
+
font-variant-numeric: tabular-nums;
|
| 843 |
+
font-weight: 600;
|
| 844 |
+
}
|
| 845 |
+
.batch-kf-kcard-ctrl {
|
| 846 |
+
display: flex;
|
| 847 |
+
flex-wrap: wrap;
|
| 848 |
+
align-items: center;
|
| 849 |
+
gap: 12px;
|
| 850 |
+
}
|
| 851 |
+
.batch-kf-klabel {
|
| 852 |
+
font-size: 10px;
|
| 853 |
+
color: var(--text-dim);
|
| 854 |
+
display: flex;
|
| 855 |
+
align-items: center;
|
| 856 |
+
gap: 8px;
|
| 857 |
+
}
|
| 858 |
+
.batch-kf-klabel input[type="number"] {
|
| 859 |
+
width: 72px;
|
| 860 |
+
padding: 6px 8px;
|
| 861 |
+
font-size: 12px;
|
| 862 |
+
border-radius: 6px;
|
| 863 |
+
border: 1px solid var(--border);
|
| 864 |
+
background: var(--panel);
|
| 865 |
+
color: var(--text);
|
| 866 |
+
}
|
| 867 |
+
/* 关键帧之间:细时间轴 + 单行紧凑间隔输入 */
|
| 868 |
+
.batch-kf-gap {
|
| 869 |
+
display: flex;
|
| 870 |
+
align-items: stretch;
|
| 871 |
+
gap: 8px;
|
| 872 |
+
padding: 0 0 6px;
|
| 873 |
+
margin: 0 0 0 10px;
|
| 874 |
+
}
|
| 875 |
+
.batch-kf-gap-rail {
|
| 876 |
+
width: 2px;
|
| 877 |
+
flex-shrink: 0;
|
| 878 |
+
border-radius: 2px;
|
| 879 |
+
background: linear-gradient(
|
| 880 |
+
180deg,
|
| 881 |
+
rgba(255, 255, 255, 0.06),
|
| 882 |
+
var(--accent-dim),
|
| 883 |
+
rgba(255, 255, 255, 0.04)
|
| 884 |
+
);
|
| 885 |
+
min-height: 22px;
|
| 886 |
+
align-self: stretch;
|
| 887 |
+
}
|
| 888 |
+
.batch-kf-gap-inner {
|
| 889 |
+
display: flex;
|
| 890 |
+
align-items: center;
|
| 891 |
+
gap: 8px;
|
| 892 |
+
flex: 1;
|
| 893 |
+
min-width: 0;
|
| 894 |
+
padding: 2px 0 4px;
|
| 895 |
+
}
|
| 896 |
+
.batch-kf-gap-ix {
|
| 897 |
+
font-size: 10px;
|
| 898 |
+
font-weight: 600;
|
| 899 |
+
color: var(--text-dim);
|
| 900 |
+
font-variant-numeric: tabular-nums;
|
| 901 |
+
letter-spacing: -0.02em;
|
| 902 |
+
flex-shrink: 0;
|
| 903 |
+
}
|
| 904 |
+
.batch-kf-seg-field {
|
| 905 |
+
display: inline-flex;
|
| 906 |
+
align-items: center;
|
| 907 |
+
gap: 3px;
|
| 908 |
+
margin: 0;
|
| 909 |
+
cursor: text;
|
| 910 |
+
}
|
| 911 |
+
.batch-kf-seg-input {
|
| 912 |
+
width: 46px;
|
| 913 |
+
min-width: 0;
|
| 914 |
+
padding: 2px 5px;
|
| 915 |
+
font-size: 11px;
|
| 916 |
+
font-weight: 600;
|
| 917 |
+
line-height: 1.3;
|
| 918 |
+
border-radius: 4px;
|
| 919 |
+
border: 1px solid var(--border);
|
| 920 |
+
background: rgba(0, 0, 0, 0.2);
|
| 921 |
+
color: var(--text);
|
| 922 |
+
font-variant-numeric: tabular-nums;
|
| 923 |
+
}
|
| 924 |
+
.batch-kf-seg-input:hover {
|
| 925 |
+
border-color: rgba(255, 255, 255, 0.12);
|
| 926 |
+
}
|
| 927 |
+
.batch-kf-seg-input:focus {
|
| 928 |
+
outline: none;
|
| 929 |
+
border-color: var(--accent);
|
| 930 |
+
box-shadow: 0 0 0 1px var(--accent-ring);
|
| 931 |
+
}
|
| 932 |
+
.batch-kf-gap-unit {
|
| 933 |
+
font-size: 10px;
|
| 934 |
+
color: var(--text-dim);
|
| 935 |
+
font-weight: 500;
|
| 936 |
+
flex-shrink: 0;
|
| 937 |
+
}
|
| 938 |
+
|
| 939 |
+
.sub-mode-toggle { display: flex; background: var(--panel-2); border-radius: 7px; padding: 3px; border: 1px solid var(--border); }
|
| 940 |
+
.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; }
|
| 941 |
+
.sub-mode-btn.active { background: var(--accent); color: #fff; }
|
| 942 |
+
.sub-mode-btn:hover:not(.active) { background: rgba(255,255,255,0.05); color: var(--text-sub); }
|
| 943 |
+
|
| 944 |
+
.vid-section { display: none; margin-top: 12px; }
|
| 945 |
+
.vid-section.active-section { display: block; animation: fadeIn 0.25s ease; }
|
| 946 |
+
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
| 947 |
+
|
| 948 |
+
/* Status indicator */
|
| 949 |
+
@keyframes breathe-orange {
|
| 950 |
+
0%,100% { box-shadow: 0 0 4px #FF9F0A; opacity: 0.7; }
|
| 951 |
+
50% { box-shadow: 0 0 10px #FF9F0A; opacity: 1; }
|
| 952 |
+
}
|
| 953 |
+
.indicator-busy { background: #FF9F0A !important; animation: breathe-orange 1.6s infinite ease-in-out !important; box-shadow: none !important; transition: all 0.3s; }
|
| 954 |
+
.indicator-ready { background: #30D158 !important; box-shadow: 0 0 8px rgba(48,209,88,0.6) !important; animation: none !important; transition: all 0.3s; }
|
| 955 |
+
.indicator-offline { background: #636366 !important; box-shadow: none !important; animation: none !important; transition: all 0.3s; }
|
| 956 |
+
|
| 957 |
+
.res-preview-tag { font-size: 11px; color: var(--accent); margin-bottom: 10px; font-family: ui-monospace, monospace; }
|
| 958 |
+
.top-status { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-dim); margin-bottom: 8px; align-items: center; }
|
| 959 |
+
.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); }
|
| 960 |
+
.checkbox-container input { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; margin: 0; }
|
| 961 |
+
.checkbox-container label { margin-bottom: 0; cursor: pointer; text-transform: none; color: var(--text); }
|
| 962 |
+
.flex-row { display: flex; gap: 10px; }
|
| 963 |
+
.flex-1 { flex: 1; min-width: 0; }
|
| 964 |
+
|
| 965 |
+
@media (max-width: 1024px) {
|
| 966 |
+
body { flex-direction: column; overflow-y: auto; }
|
| 967 |
+
.sidebar { width: 100%; min-width: 100%; border-right: none; border-bottom: 1px solid var(--border); height: auto; overflow: visible; }
|
| 968 |
+
.workspace { height: auto; min-height: 100vh; overflow: visible; }
|
| 969 |
+
}
|
| 970 |
+
:root {
|
| 971 |
+
--plyr-color-main: #3F51B5;
|
| 972 |
+
--plyr-video-control-background-hover: rgba(255,255,255,0.1);
|
| 973 |
+
--plyr-control-radius: 6px;
|
| 974 |
+
--plyr-player-width: 100%;
|
| 975 |
+
}
|
| 976 |
+
.plyr {
|
| 977 |
+
border-radius: 8px;
|
| 978 |
+
overflow: hidden;
|
| 979 |
+
width: 100%;
|
| 980 |
+
height: 100%;
|
| 981 |
+
}
|
| 982 |
+
.plyr--video .plyr__controls {
|
| 983 |
+
background: linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0.8));
|
| 984 |
+
padding: 20px 15px 15px 15px;
|
| 985 |
+
}
|
LTX2.3-1.0.4-new/UI/index.html
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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?v=en-tabs-1">
|
| 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 id="clearGpuBtn" onclick="clearGpu()" class="btn-outline" data-i18n="clearVram">释放显存</button>
|
| 21 |
+
<button type="button" id="lang-toggle-btn" class="lang-toggle">EN</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 |
+
<label style="font-size: 11px; margin-bottom: 6px; margin-top: 12px;" data-i18n="vramLimitLabel">可用最高显存上限 (GB, 0为全开优先显存)</label>
|
| 51 |
+
<div style="display: flex; gap: 6px; margin-bottom: 9px; align-items: stretch;">
|
| 52 |
+
<input type="number" id="vram-limit-input" value="0" data-i18n-placeholder="vramLimitPh" placeholder="例如: 12 (0表示无限制)" style="flex: 1; height: 28px; box-sizing: border-box; font-size: 12px; padding: 0 10px;">
|
| 53 |
+
<button onclick="saveVramLimit()" style="font-size: 12px; padding: 0 10px; height: 28px; box-sizing: border-box; white-space: nowrap; background: #333; border: 1px solid #555; color: #fff; border-radius: 7px; cursor: pointer;" data-i18n="saveLabel">保存</button>
|
| 54 |
+
</div>
|
| 55 |
+
<div id="vram-limit-status" style="font-size: 10px; color: var(--text-dim);"></div>
|
| 56 |
+
|
| 57 |
+
<label style="font-size: 11px; margin-bottom: 6px; margin-top: 12px;" data-i18n="modelCheckpointLabel">视频模型(蒸馏版)</label>
|
| 58 |
+
<div style="display: flex; gap: 6px; margin-bottom: 9px; align-items: stretch;">
|
| 59 |
+
<select id="model-checkpoint-select" onchange="saveSelectedModelCheckpoint()" style="flex: 1; height: 28px; box-sizing: border-box; font-size: 12px; padding: 0 8px;">
|
| 60 |
+
<option value="" data-i18n="modelCheckpointDefault">默认官方蒸馏模型</option>
|
| 61 |
+
</select>
|
| 62 |
+
<button onclick="loadModelCheckpoints()" style="font-size: 12px; padding: 0 10px; height: 28px; box-sizing: border-box; white-space: nowrap; background: #333; border: 1px solid #555; color: #fff; border-radius: 7px; cursor: pointer;" data-i18n="refresh">刷新</button>
|
| 63 |
+
</div>
|
| 64 |
+
<div id="model-checkpoint-status" style="font-size: 10px; color: var(--text-dim); margin-bottom: 6px;" data-i18n="modelCheckpointHint">推荐使用 distilled-fp8;仅显示 LTX 2.3 22B 蒸馏模型,避开 dev 模型。</div>
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
<label style="font-size: 11px; margin-bottom: 6px; margin-top: 12px;" data-i18n="loraFolderPath">LoRA 文件夹路径(可选)</label>
|
| 68 |
+
<div style="display: flex; gap: 6px; margin-bottom: 9px; align-items: stretch;">
|
| 69 |
+
<input type="text" id="lora-dir-input" placeholder="留空使用 模型目录\\loras" data-i18n-placeholder="loraFolderPathPlaceholder" style="flex: 1; height: 28px; box-sizing: border-box; font-size: 12px; padding: 0 10px;">
|
| 70 |
+
<button onclick="saveLoraDir()" style="font-size: 12px; padding: 0 10px; height: 28px; box-sizing: border-box; white-space: nowrap; background: #333; border: 1px solid #555; color: #fff; border-radius: 7px; cursor: pointer;" data-i18n="saveLabel">保存</button>
|
| 71 |
+
</div>
|
| 72 |
+
<div id="lora-placement-hint" style="font-size: 10px; color: var(--text-dim); line-height: 1.45; margin-bottom: 6px;" data-i18n="loraPlacementHint">将 LoRA 文件放到当前模型目录下的 loras 文件夹。</div>
|
| 73 |
+
<div id="lora-dir-status" style="font-size: 10px; color: var(--text-dim);"></div>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
<div id="queue-panel" style="padding: 12px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; margin-bottom: 14px;">
|
| 77 |
+
<div style="display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px;">
|
| 78 |
+
<div style="font-size:12px; font-weight:800; color:var(--text-main);" data-i18n="queueTitle">任务队列</div>
|
| 79 |
+
<div id="queue-summary" style="font-size:10px; color:var(--text-dim);" data-i18n="queueIdle">空闲</div>
|
| 80 |
+
</div>
|
| 81 |
+
<div id="queue-list" style="display:flex; flex-direction:column; gap:6px;"></div>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
<div class="sidebar-section" id="main-tabs-section">
|
| 86 |
+
<div class="tabs">
|
| 87 |
+
<div id="tab-video" class="tab" onclick="switchMode('video')">
|
| 88 |
+
<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>
|
| 89 |
+
<span data-i18n="tabVideo">视频生成</span>
|
| 90 |
+
</div>
|
| 91 |
+
<div id="tab-batch" class="tab" onclick="switchMode('batch')">
|
| 92 |
+
<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>
|
| 93 |
+
<span data-i18n="tabBatch">智能多帧</span>
|
| 94 |
+
</div>
|
| 95 |
+
<div id="tab-motion" class="tab" onclick="switchMode('motion')">
|
| 96 |
+
<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;"><path d="M3 12h5l3-8 4 16 3-8h3"></path></svg>
|
| 97 |
+
<span data-i18n="tabMotion">视频迁移</span>
|
| 98 |
+
</div>
|
| 99 |
+
<div id="tab-image" class="tab" onclick="switchMode('image')">
|
| 100 |
+
<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>
|
| 101 |
+
<span data-i18n="tabImage">图像生成</span>
|
| 102 |
+
</div>
|
| 103 |
+
<div id="tab-tts" class="tab" onclick="switchMode('tts')">
|
| 104 |
+
<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;"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>
|
| 105 |
+
<span data-i18n="tabTts">TTS 语音</span>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
<div id="prompt-container">
|
| 110 |
+
<label data-i18n="promptLabel">视觉描述词 (Prompt)</label>
|
| 111 |
+
<textarea id="prompt" data-i18n-placeholder="promptPlaceholder" placeholder="在此输入视觉描述词 (Prompt)..." style="height: 90px; margin-bottom: 0;"></textarea>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
<div id="seed-settings" class="seed-panel">
|
| 115 |
+
<div class="seed-panel-head">
|
| 116 |
+
<span data-i18n="seedLabel">随机种子 (Seed)</span>
|
| 117 |
+
</div>
|
| 118 |
+
<div class="seed-control">
|
| 119 |
+
<div class="seed-input-shell">
|
| 120 |
+
<input type="number" id="seed-value" min="1" max="2147483647" value="42" disabled>
|
| 121 |
+
</div>
|
| 122 |
+
<div class="seed-mode-tabs" role="radiogroup" aria-label="Seed mode">
|
| 123 |
+
<label class="seed-mode-option is-active">
|
| 124 |
+
<input type="radio" name="seed-mode" value="random" checked onchange="updateSeedModeUI()">
|
| 125 |
+
<span data-i18n="seedRandom">随机</span>
|
| 126 |
+
</label>
|
| 127 |
+
<label class="seed-mode-option">
|
| 128 |
+
<input type="radio" name="seed-mode" value="fixed" onchange="updateSeedModeUI()">
|
| 129 |
+
<span data-i18n="seedFixed">固定</span>
|
| 130 |
+
</label>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
<!-- 视频模式选项 -->
|
| 137 |
+
<div class="sidebar-section" id="video-opts" style="display:none">
|
| 138 |
+
<div class="setting-group">
|
| 139 |
+
<div class="group-title" data-i18n="basicEngine">基础画面 / Basic EngineSpecs</div>
|
| 140 |
+
<div class="flex-row">
|
| 141 |
+
<div class="flex-1">
|
| 142 |
+
<label data-i18n="qualityLevel">清晰度级别</label>
|
| 143 |
+
<select id="vid-quality" onchange="updateResPreview()">
|
| 144 |
+
<option value="1080">1080P Full HD</option>
|
| 145 |
+
<option value="720" selected>720P Standard</option>
|
| 146 |
+
<option value="540">540P Preview</option>
|
| 147 |
+
</select>
|
| 148 |
+
</div>
|
| 149 |
+
<div class="flex-1">
|
| 150 |
+
<label data-i18n="aspectRatio">画幅比例</label>
|
| 151 |
+
<select id="vid-ratio" onchange="updateResPreview()">
|
| 152 |
+
<option value="16:9" data-i18n="ratio169">16:9 电影宽幅</option>
|
| 153 |
+
<option value="9:16" data-i18n="ratio916">9:16 移动竖屏</option>
|
| 154 |
+
<option value="1:1" data-i18n="ratio11">1:1 方形</option>
|
| 155 |
+
<option value="4:3" data-i18n="ratio43">4:3 经典横幅</option>
|
| 156 |
+
<option value="3:4" data-i18n="ratio34">3:4 经典竖幅</option>
|
| 157 |
+
<option value="21:9" data-i18n="ratio219">21:9 超宽银幕</option>
|
| 158 |
+
<option value="9:21" data-i18n="ratio921">9:21 超长竖屏</option>
|
| 159 |
+
<option value="ref" data-i18n="ratioRef">跟随参考图</option>
|
| 160 |
+
<option value="custom" data-i18n="ratioCustom">自定义尺寸</option>
|
| 161 |
+
</select>
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
<div id="vid-custom-size" class="flex-row" style="display:none; margin-top: -2px; margin-bottom: 10px;">
|
| 165 |
+
<div class="flex-1"><label data-i18n="width">宽度</label><input type="number" id="vid-custom-w" value="1280" min="64" step="64" onchange="updateResPreview()"></div>
|
| 166 |
+
<div class="flex-1"><label data-i18n="height">高度</label><input type="number" id="vid-custom-h" value="704" min="64" step="64" onchange="updateResPreview()"></div>
|
| 167 |
+
</div>
|
| 168 |
+
<div id="res-preview" class="res-preview-tag" style="margin-top: -5px; margin-bottom: 12px;">最终发送: 1280x704</div>
|
| 169 |
+
|
| 170 |
+
<div class="flex-row">
|
| 171 |
+
<div class="flex-1">
|
| 172 |
+
<label data-i18n="fpsLabel">帧率 (FPS)</label>
|
| 173 |
+
<select id="vid-fps">
|
| 174 |
+
<option value="24" selected>24 FPS</option>
|
| 175 |
+
<option value="25">25 FPS</option>
|
| 176 |
+
<option value="30">30 FPS</option>
|
| 177 |
+
<option value="48">48 FPS</option>
|
| 178 |
+
<option value="60">60 FPS</option>
|
| 179 |
+
</select>
|
| 180 |
+
</div>
|
| 181 |
+
<div class="flex-1">
|
| 182 |
+
<label data-i18n="durationLabel">时长 (秒)</label>
|
| 183 |
+
<input type="number" id="vid-duration" value="5" min="1" max="30" step="1">
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
|
| 187 |
+
<label style="margin-top: 12px;" data-i18n="cameraMotion">镜头运动方式</label>
|
| 188 |
+
<select id="vid-motion">
|
| 189 |
+
<option value="static" selected data-i18n="motionStatic">Static (静止机位)</option>
|
| 190 |
+
<option value="dolly_in" data-i18n="motionDollyIn">Dolly In (推近)</option>
|
| 191 |
+
<option value="dolly_out" data-i18n="motionDollyOut">Dolly Out (拉远)</option>
|
| 192 |
+
<option value="dolly_left" data-i18n="motionDollyLeft">Dolly Left (向左)</option>
|
| 193 |
+
<option value="dolly_right" data-i18n="motionDollyRight">Dolly Right (向右)</option>
|
| 194 |
+
<option value="jib_up" data-i18n="motionJibUp">Jib Up (升臂)</option>
|
| 195 |
+
<option value="jib_down" data-i18n="motionJibDown">Jib Down (降臂)</option>
|
| 196 |
+
<option value="focus_shift" data-i18n="motionFocus">Focus Shift (焦点)</option>
|
| 197 |
+
</select>
|
| 198 |
+
<div class="checkbox-container" style="margin-top: 8px;">
|
| 199 |
+
<input type="checkbox" id="vid-audio" checked>
|
| 200 |
+
<label for="vid-audio" data-i18n="audioGen">生成 AI 环境音 (Audio Gen)</label>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
<div class="lora-control-head" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
| 204 |
+
<label data-i18n="selectLora" style="margin: 0;">选择 LoRA</label>
|
| 205 |
+
<button type="button" onclick="addLoraSelection('loras-container')" style="background:none; border:none; color:var(--accent); cursor:pointer; font-size:16px; padding:0 4px;" title="添加 LoRA">+</button>
|
| 206 |
+
</div>
|
| 207 |
+
<div id="loras-container" style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px;"></div>
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
<!-- 生成媒介组 -->
|
| 211 |
+
<div class="setting-group" id="video-source-group">
|
| 212 |
+
<div class="group-title" data-i18n="genSource">生成媒介 / Generation Source</div>
|
| 213 |
+
|
| 214 |
+
<div class="flex-row" style="margin-bottom: 10px;">
|
| 215 |
+
<div class="flex-1">
|
| 216 |
+
<label data-i18n="startFrame">起始帧 (首帧)</label>
|
| 217 |
+
<div class="upload-zone" id="start-frame-drop-zone" onclick="document.getElementById('start-frame-input').click()">
|
| 218 |
+
<div class="clear-img-overlay" id="clear-start-frame-overlay" onclick="event.stopPropagation(); clearFrame('start')">×</div>
|
| 219 |
+
<div id="start-frame-placeholder">
|
| 220 |
+
<div class="upload-icon">🖼️</div>
|
| 221 |
+
<div class="upload-text" data-i18n="uploadStart">上传首帧</div>
|
| 222 |
+
</div>
|
| 223 |
+
<img id="start-frame-preview" class="preview-thumb">
|
| 224 |
+
<input type="file" id="start-frame-input" accept="image/*" style="display:none" onchange="handleFrameUpload(this.files[0], 'start')">
|
| 225 |
+
</div>
|
| 226 |
+
<input type="hidden" id="start-frame-path">
|
| 227 |
+
</div>
|
| 228 |
+
<div class="flex-1">
|
| 229 |
+
<label data-i18n="endFrame">结束帧 (尾帧)</label>
|
| 230 |
+
<div class="upload-zone" id="end-frame-drop-zone" onclick="document.getElementById('end-frame-input').click()">
|
| 231 |
+
<div class="clear-img-overlay" id="clear-end-frame-overlay" onclick="event.stopPropagation(); clearFrame('end')">×</div>
|
| 232 |
+
<div id="end-frame-placeholder">
|
| 233 |
+
<div class="upload-icon">🏁</div>
|
| 234 |
+
<div class="upload-text" data-i18n="uploadEnd">上传尾帧 (可选)</div>
|
| 235 |
+
</div>
|
| 236 |
+
<img id="end-frame-preview" class="preview-thumb">
|
| 237 |
+
<input type="file" id="end-frame-input" accept="image/*" style="display:none" onchange="handleFrameUpload(this.files[0], 'end')">
|
| 238 |
+
</div>
|
| 239 |
+
<input type="hidden" id="end-frame-path">
|
| 240 |
+
</div>
|
| 241 |
+
</div>
|
| 242 |
+
|
| 243 |
+
<div class="flex-row">
|
| 244 |
+
<div class="flex-1">
|
| 245 |
+
<label data-i18n="refAudio">参考音频 (A2V)</label>
|
| 246 |
+
<div class="upload-zone" id="audio-drop-zone" onclick="document.getElementById('vid-audio-input').click()">
|
| 247 |
+
<div class="clear-img-overlay" id="clear-audio-overlay" onclick="event.stopPropagation(); clearUploadedAudio()">×</div>
|
| 248 |
+
<div id="audio-upload-placeholder">
|
| 249 |
+
<div class="upload-icon">🎵</div>
|
| 250 |
+
<div class="upload-text" data-i18n="uploadAudio">点击上传音频</div>
|
| 251 |
+
</div>
|
| 252 |
+
<div id="audio-upload-status" style="display:none;">
|
| 253 |
+
<div class="upload-icon" style="color:var(--accent); opacity:1;">✔️</div>
|
| 254 |
+
<div id="audio-filename-status" class="upload-text"></div>
|
| 255 |
+
</div>
|
| 256 |
+
<input type="file" id="vid-audio-input" accept="audio/*" style="display:none" onchange="handleAudioUpload(this.files[0])">
|
| 257 |
+
</div>
|
| 258 |
+
<input type="hidden" id="uploaded-audio-path">
|
| 259 |
+
</div>
|
| 260 |
+
</div>
|
| 261 |
+
<div style="font-size: 10px; color: var(--text-dim); text-align: center; margin-top: 5px;" data-i18n="sourceHint">
|
| 262 |
+
💡 若仅上传首帧 = 图生视频/音视频;若同时上传首尾帧 = 首尾插帧。
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
</div>
|
| 266 |
+
|
| 267 |
+
<!-- 图像模式选项 -->
|
| 268 |
+
<div class="sidebar-section" id="motion-opts" style="display:none">
|
| 269 |
+
<div class="setting-group">
|
| 270 |
+
<div>
|
| 271 |
+
<label data-i18n="motionTransferModeLabel">迁移类型</label>
|
| 272 |
+
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;margin-top:6px;">
|
| 273 |
+
<button type="button" id="motion-mode-action" class="btn-outline" onclick="setVideoTransferMode('action')" style="height:32px;background:var(--accent);color:#fff;border-color:var(--accent);" data-i18n="motionModeAction">动作迁移</button>
|
| 274 |
+
<button type="button" id="motion-mode-camera" class="btn-outline" onclick="setVideoTransferMode('camera')" style="height:32px;" data-i18n="motionModeCamera">运镜迁移</button>
|
| 275 |
+
<button type="button" id="motion-mode-repaint" class="btn-outline" onclick="setVideoTransferMode('repaint')" style="height:32px;" data-i18n="motionModeRepaint">视频重绘</button>
|
| 276 |
+
</div>
|
| 277 |
+
</div>
|
| 278 |
+
|
| 279 |
+
<div id="motion-action-control-section" style="margin-top: 12px;">
|
| 280 |
+
<label data-i18n="motionControlType">控制类型</label>
|
| 281 |
+
<select id="motion-conditioning-type">
|
| 282 |
+
<option value="pose" selected data-i18n="motionControlPose">Pose 姿态</option>
|
| 283 |
+
<option value="canny" data-i18n="motionControlCanny">Canny 轮廓</option>
|
| 284 |
+
<option value="depth" data-i18n="motionControlDepth">Depth 深度</option>
|
| 285 |
+
<option value="video" hidden>Video</option>
|
| 286 |
+
</select>
|
| 287 |
+
</div>
|
| 288 |
+
|
| 289 |
+
<label style="margin-top: 12px;" data-i18n="motionRefVideoLabel">参考动作视频</label>
|
| 290 |
+
<div class="upload-zone" id="motion-video-drop-zone" onclick="document.getElementById('motion-video-input').click()" style="height: 96px;">
|
| 291 |
+
<div class="clear-img-overlay" id="clear-motion-video-overlay" onclick="event.stopPropagation(); clearMotionVideo()">×</div>
|
| 292 |
+
<div id="motion-video-placeholder">
|
| 293 |
+
<div class="upload-icon">🎬</div>
|
| 294 |
+
<div data-i18n="motionVideoUploadText">点击或拖拽视频</div>
|
| 295 |
+
<small data-i18n="motionVideoUploadHint">提取动作/轮廓作为控制</small>
|
| 296 |
+
</div>
|
| 297 |
+
<div id="motion-video-status" style="display:none; padding: 10px; text-align:center;">
|
| 298 |
+
<div style="font-size: 22px;">🎞️</div>
|
| 299 |
+
<div id="motion-video-name" style="font-size: 11px; color: var(--text-sub); word-break: break-all;"></div>
|
| 300 |
+
</div>
|
| 301 |
+
</div>
|
| 302 |
+
<input type="file" id="motion-video-input" accept="video/*" hidden onchange="handleMotionVideoUpload(this.files[0])">
|
| 303 |
+
<input type="hidden" id="motion-video-path">
|
| 304 |
+
|
| 305 |
+
<div id="motion-image-section">
|
| 306 |
+
<label style="margin-top: 12px;" data-i18n="motionTargetImageLabel">目标主体图</label>
|
| 307 |
+
<div class="upload-zone" id="motion-image-drop-zone" onclick="document.getElementById('motion-image-input').click()" style="height: 130px;">
|
| 308 |
+
<div class="clear-img-overlay" id="clear-motion-image-overlay" onclick="event.stopPropagation(); clearMotionImage()">×</div>
|
| 309 |
+
<div id="motion-image-placeholder">
|
| 310 |
+
<div class="upload-icon">🖼️</div>
|
| 311 |
+
<div data-i18n="motionImageUploadText">点击或拖拽图片</div>
|
| 312 |
+
<small data-i18n="motionImageUploadHint">作为主体/首帧引导</small>
|
| 313 |
+
</div>
|
| 314 |
+
<img id="motion-image-preview" style="display:none; width:100%; height:100%; object-fit:cover; border-radius:10px;">
|
| 315 |
+
</div>
|
| 316 |
+
<input type="file" id="motion-image-input" accept="image/*" hidden onchange="handleMotionImageUpload(this.files[0])">
|
| 317 |
+
<input type="hidden" id="motion-image-path">
|
| 318 |
+
</div>
|
| 319 |
+
|
| 320 |
+
<div style="margin-top: 12px;">
|
| 321 |
+
<label style="display:flex;justify-content:space-between;align-items:center;">
|
| 322 |
+
<span id="motion-strength-label" data-i18n="motionControlStrength">控制强度</span>
|
| 323 |
+
<span id="motion-strength-val" style="color:var(--accent);font-size:11px;font-weight:800;">1</span>
|
| 324 |
+
</label>
|
| 325 |
+
<input type="range" id="motion-strength" value="1" min="0" max="2" step="0.05" style="width:100%;" oninput="document.getElementById('motion-strength-val').textContent = Number(this.value).toFixed(2).replace(/\.?0+$/, '')">
|
| 326 |
+
</div>
|
| 327 |
+
</div>
|
| 328 |
+
</div>
|
| 329 |
+
|
| 330 |
+
<div id="image-opts" class="sidebar-section" style="display:none">
|
| 331 |
+
<label data-i18n="imgPreset">预设分辨率 (Presets)</label>
|
| 332 |
+
<select id="img-res-preset" onchange="applyImgPreset(this.value)">
|
| 333 |
+
<option value="1024x1024" data-i18n="imgOptSquare">1:1 Square (1024x1024)</option>
|
| 334 |
+
<option value="1280x720" data-i18n="imgOptLand">16:9 Landscape (1280x720)</option>
|
| 335 |
+
<option value="720x1280" data-i18n="imgOptPort">9:16 Portrait (720x1280)</option>
|
| 336 |
+
<option value="custom" data-i18n="imgOptCustom">Custom 自定义...</option>
|
| 337 |
+
</select>
|
| 338 |
+
|
| 339 |
+
<div id="img-custom-res" class="flex-row" style="margin-top: 10px;">
|
| 340 |
+
<div class="flex-1"><label data-i18n="width">宽度</label><input type="number" id="img-w" value="1024" onchange="updateImgResPreview()"></div>
|
| 341 |
+
<div class="flex-1"><label data-i18n="height">高度</label><input type="number" id="img-h" value="1024" onchange="updateImgResPreview()"></div>
|
| 342 |
+
</div>
|
| 343 |
+
<div id="img-res-preview" class="res-preview-tag">最终发送: 1024x1024</div>
|
| 344 |
+
|
| 345 |
+
<div class="label-group" style="margin-top: 15px;">
|
| 346 |
+
<label data-i18n="samplingSteps">采样步数 (Steps)</label>
|
| 347 |
+
<span class="val-badge" id="stepsVal">28</span>
|
| 348 |
+
</div>
|
| 349 |
+
<div class="slider-container">
|
| 350 |
+
<input type="range" id="img-steps" min="1" max="50" value="28" oninput="document.getElementById('stepsVal').innerText=this.value">
|
| 351 |
+
</div>
|
| 352 |
+
</div>
|
| 353 |
+
|
| 354 |
+
<!-- 智能多帧模式 -->
|
| 355 |
+
<div class="sidebar-section" id="batch-opts" style="display:none">
|
| 356 |
+
<div class="setting-group">
|
| 357 |
+
<div class="group-title" data-i18n="smartMultiFrameGroup">智能多帧</div>
|
| 358 |
+
<div class="smart-param-mode-label" data-i18n="workflowModeLabel">工作流模式(点击切换)</div>
|
| 359 |
+
<div class="smart-param-modes" role="radiogroup" aria-label="工作流模式">
|
| 360 |
+
<label class="smart-param-mode-opt">
|
| 361 |
+
<input type="radio" name="batch-workflow" value="single" checked onchange="onBatchWorkflowChange()">
|
| 362 |
+
<span class="smart-param-mode-title" data-i18n="wfSingle">单次多关键帧</span>
|
| 363 |
+
</label>
|
| 364 |
+
<label class="smart-param-mode-opt">
|
| 365 |
+
<input type="radio" name="batch-workflow" value="segments" onchange="onBatchWorkflowChange()">
|
| 366 |
+
<span class="smart-param-mode-title" data-i18n="wfSegments">分段拼接</span>
|
| 367 |
+
</label>
|
| 368 |
+
</div>
|
| 369 |
+
|
| 370 |
+
<label data-i18n="uploadImages">上传图片</label>
|
| 371 |
+
<div class="upload-zone" id="batch-images-drop-zone" onclick="document.getElementById('batch-images-input').click()" style="min-height: 72px; margin-bottom: 0;">
|
| 372 |
+
<div id="batch-images-placeholder">
|
| 373 |
+
<div class="upload-icon">📁</div>
|
| 374 |
+
<div class="upload-text" data-i18n="uploadMulti1">点击或拖入多张图片</div>
|
| 375 |
+
<div class="upload-hint" data-i18n="uploadMulti2">支持一次选多张,可多次添加</div>
|
| 376 |
+
</div>
|
| 377 |
+
<input type="file" id="batch-images-input" accept="image/*" multiple style="display:none" onchange="handleBatchImagesUpload(this.files, true)">
|
| 378 |
+
</div>
|
| 379 |
+
<input type="hidden" id="batch-images-path">
|
| 380 |
+
|
| 381 |
+
<div class="batch-thumb-strip-wrap" id="batch-thumb-strip-wrap" style="display: none;">
|
| 382 |
+
<div class="batch-thumb-strip-head">
|
| 383 |
+
<span class="batch-thumb-strip-title" data-i18n="batchStripTitle">已选图片 · 顺序 = 播放先后</span>
|
| 384 |
+
<span class="batch-thumb-strip-hint" data-i18n="batchStripHint">在缩略图上按住拖动排序;松手落入虚线框位置</span>
|
| 385 |
+
</div>
|
| 386 |
+
<div class="batch-images-container" id="batch-images-container"></div>
|
| 387 |
+
</div>
|
| 388 |
+
|
| 389 |
+
<div style="font-size: 10px; color: var(--text-dim); margin-bottom: 12px; margin-top: 10px; line-height: 1.45;" data-i18n-html="batchFfmpegHint">
|
| 390 |
+
💡 <strong>分段模式</strong>:2 张 = 1 段;3 张 = 2 段再拼接。<strong>单次模式</strong>:几张图就几个 latent 锚点,一条视频出片。<br>
|
| 391 |
+
多段需 <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 完整��径。
|
| 392 |
+
</div>
|
| 393 |
+
|
| 394 |
+
<label style="margin-top: 8px;" data-i18n="bgmLabel">成片配乐(可选,统一音轨)</label>
|
| 395 |
+
<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;">
|
| 396 |
+
<div class="clear-img-overlay" id="clear-batch-audio-overlay" onclick="event.stopPropagation(); clearBatchBackgroundAudio()" style="display: none;">×</div>
|
| 397 |
+
<div id="batch-audio-placeholder">
|
| 398 |
+
<div class="upload-text" style="font-size: 11px;" data-i18n="bgmUploadHint">上传一条完整 BGM(生成完成后会替换整段成片的音轨)</div>
|
| 399 |
+
</div>
|
| 400 |
+
<div id="batch-audio-status" style="display: none; font-size: 11px; color: var(--accent);"></div>
|
| 401 |
+
<input type="file" id="batch-audio-input" accept="audio/*" style="display:none" onchange="handleBatchBackgroundAudioUpload(this.files[0])">
|
| 402 |
+
</div>
|
| 403 |
+
<input type="hidden" id="batch-background-audio-path">
|
| 404 |
+
|
| 405 |
+
<div id="batch-segments-container" style="margin-top: 15px;"></div>
|
| 406 |
+
</div>
|
| 407 |
+
|
| 408 |
+
<div class="setting-group">
|
| 409 |
+
<div class="group-title" data-i18n="basicEngine">基础画面 / Basic EngineSpecs</div>
|
| 410 |
+
<div class="flex-row">
|
| 411 |
+
<div class="flex-1">
|
| 412 |
+
<label data-i18n="qualityLevel">清晰度级别</label>
|
| 413 |
+
<select id="batch-quality" onchange="updateBatchResPreview()">
|
| 414 |
+
<option value="1080">1080P Full HD</option>
|
| 415 |
+
<option value="720" selected>720P Standard</option>
|
| 416 |
+
<option value="540">540P Preview</option>
|
| 417 |
+
</select>
|
| 418 |
+
</div>
|
| 419 |
+
<div class="flex-1">
|
| 420 |
+
<label data-i18n="aspectRatio">画幅比例</label>
|
| 421 |
+
<select id="batch-ratio" onchange="updateBatchResPreview()">
|
| 422 |
+
<option value="16:9" data-i18n="ratio169">16:9 电影宽幅</option>
|
| 423 |
+
<option value="9:16" data-i18n="ratio916">9:16 移动竖屏</option>
|
| 424 |
+
<option value="1:1" data-i18n="ratio11">1:1 方形</option>
|
| 425 |
+
<option value="4:3" data-i18n="ratio43">4:3 经典横幅</option>
|
| 426 |
+
<option value="3:4" data-i18n="ratio34">3:4 经典竖幅</option>
|
| 427 |
+
<option value="21:9" data-i18n="ratio219">21:9 超宽银幕</option>
|
| 428 |
+
<option value="9:21" data-i18n="ratio921">9:21 超长竖屏</option>
|
| 429 |
+
<option value="ref" data-i18n="ratioRef">跟随参考图</option>
|
| 430 |
+
<option value="custom" data-i18n="ratioCustom">自定义尺寸</option>
|
| 431 |
+
</select>
|
| 432 |
+
</div>
|
| 433 |
+
</div>
|
| 434 |
+
<div id="batch-custom-size" class="flex-row" style="display:none; margin-top: -2px; margin-bottom: 10px;">
|
| 435 |
+
<div class="flex-1"><label data-i18n="width">宽度</label><input type="number" id="batch-custom-w" value="1280" min="64" step="64" onchange="updateBatchResPreview()"></div>
|
| 436 |
+
<div class="flex-1"><label data-i18n="height">高度</label><input type="number" id="batch-custom-h" value="704" min="64" step="64" onchange="updateBatchResPreview()"></div>
|
| 437 |
+
</div>
|
| 438 |
+
<div id="batch-res-preview" class="res-preview-tag" style="margin-top: -5px; margin-bottom: 12px;">最终发送: 1280x704</div>
|
| 439 |
+
|
| 440 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
| 441 |
+
<label data-i18n="selectLora" style="margin: 0;">选择 LoRA</label>
|
| 442 |
+
<button type="button" onclick="addLoraSelection('batch-loras-container')" style="background:none; border:none; color:var(--accent); cursor:pointer; font-size:16px; padding:0 4px;" title="添加 LoRA">+</button>
|
| 443 |
+
</div>
|
| 444 |
+
<div id="batch-loras-container" style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px;"></div>
|
| 445 |
+
</div>
|
| 446 |
+
</div>
|
| 447 |
+
|
| 448 |
+
<!-- TTS 语音合成面板 -->
|
| 449 |
+
<div id="tts-opts" class="sidebar-section" style="display:none; padding-top: 0;">
|
| 450 |
+
<!-- 状态栏 -->
|
| 451 |
+
<div id="tts-status-bar" style="padding: 8px 12px; border-radius: 8px; background: rgba(255,255,255,0.04); border: 1px solid var(--border); margin-bottom: 14px; font-size: 11px; color: var(--text-dim);" data-i18n="ttsStatusBarDetecting">
|
| 452 |
+
🔍 正在检测 TTS 模型...
|
| 453 |
+
</div>
|
| 454 |
+
|
| 455 |
+
<!-- 合成文本 -->
|
| 456 |
+
<div class="setting-group">
|
| 457 |
+
<div class="group-title" data-i18n="ttsTextTitle">合成文本 / Text</div>
|
| 458 |
+
<label style="font-size:11px; color:var(--text-dim); margin-bottom:5px; display:block;" data-i18n-html="ttsTextHint">
|
| 459 |
+
支持在文本开头加英文括号描述声音,例如:<code style="font-size:10px;">(年轻女声,温柔���美)</code>
|
| 460 |
+
</label>
|
| 461 |
+
<textarea id="tts-text" data-i18n-placeholder="ttsTextPlaceholder" placeholder="输入要合成的文本内容..." style="width:100%; height:90px; padding:8px; font-size:12px; box-sizing:border-box; resize:vertical; border-radius:8px; border:1px solid var(--border); background:var(--item); color:var(--text);"></textarea>
|
| 462 |
+
</div>
|
| 463 |
+
|
| 464 |
+
<!-- 合成模式 -->
|
| 465 |
+
<div class="setting-group">
|
| 466 |
+
<div class="group-title" data-i18n="ttsModeTitle">合成模式 / Mode</div>
|
| 467 |
+
<select id="tts-mode" onchange="onTtsModeChange()" style="margin-bottom:8px;">
|
| 468 |
+
<option value="text_only" data-i18n="ttsModeTextOnly">🗣️ 文字转语音(含声音设计)</option>
|
| 469 |
+
<option value="clone" data-i18n="ttsModeClone">🎙️ 声音克隆</option>
|
| 470 |
+
<option value="ultimate_clone" data-i18n="ttsModeUltimate">⭐ 终极克隆(最高还原度)</option>
|
| 471 |
+
</select>
|
| 472 |
+
|
| 473 |
+
<!-- 声音克隆:参考音频 -->
|
| 474 |
+
<div id="tts-ref-section" style="display:none;">
|
| 475 |
+
<label style="font-size:11px; margin-bottom:4px; display:block;" data-i18n="ttsRefLabel">📎 参考音频(Reference)</label>
|
| 476 |
+
<div class="upload-zone" id="tts-ref-drop" onclick="document.getElementById('tts-ref-input').click()" style="min-height:48px; margin-bottom:6px; position:relative;">
|
| 477 |
+
<div class="clear-img-overlay" id="tts-ref-clear" onclick="event.stopPropagation(); clearTtsRef()" style="display:none;">×</div>
|
| 478 |
+
<div id="tts-ref-placeholder">
|
| 479 |
+
<div class="upload-icon">🎵</div>
|
| 480 |
+
<div class="upload-text" style="font-size:11px;" data-i18n="ttsRefUploadHint">点击上传参考音频 (.wav / .mp3)</div>
|
| 481 |
+
</div>
|
| 482 |
+
<div id="tts-ref-status" style="display:none; font-size:11px; color:var(--accent); padding:8px; text-align:center;"></div>
|
| 483 |
+
<input type="file" id="tts-ref-input" accept="audio/*" style="display:none" onchange="handleTtsRefUpload(this.files[0])">
|
| 484 |
+
</div>
|
| 485 |
+
</div>
|
| 486 |
+
|
| 487 |
+
<!-- 终极克隆额外选项 -->
|
| 488 |
+
<div id="tts-ultimate-section" style="display:none;">
|
| 489 |
+
<label style="font-size:11px; margin-bottom:4px; display:block;" data-i18n="ttsUltimateLabel">📝 参考音频对应的文本转录(可选,能显著提升相似度)</label>
|
| 490 |
+
<textarea id="tts-prompt-text" data-i18n-placeholder="ttsUltimatePlaceholder" placeholder="与参考音频完全一致的文本内容..." style="width:100%; height:60px; padding:6px 8px; font-size:11px; box-sizing:border-box; resize:vertical; border-radius:6px; border:1px solid var(--border); background:var(--item); color:var(--text); margin-bottom:6px;"></textarea>
|
| 491 |
+
</div>
|
| 492 |
+
</div>
|
| 493 |
+
|
| 494 |
+
<!-- 参数调节 -->
|
| 495 |
+
<div class="setting-group">
|
| 496 |
+
<div class="group-title" data-i18n="ttsParamsTitle">高级参数 / Parameters</div>
|
| 497 |
+
<div class="flex-row" style="gap:12px;">
|
| 498 |
+
<div class="flex-1">
|
| 499 |
+
<div class="label-group">
|
| 500 |
+
<label style="font-size:11px;" data-i18n="ttsCfgLabel">CFG 强度</label>
|
| 501 |
+
<span class="val-badge" id="ttsCfgVal">2.0</span>
|
| 502 |
+
</div>
|
| 503 |
+
<div class="slider-container">
|
| 504 |
+
<input type="range" id="tts-cfg" min="0.5" max="5.0" step="0.5" value="2.0"
|
| 505 |
+
oninput="document.getElementById('ttsCfgVal').textContent=this.value">
|
| 506 |
+
</div>
|
| 507 |
+
</div>
|
| 508 |
+
<div class="flex-1">
|
| 509 |
+
<div class="label-group">
|
| 510 |
+
<label style="font-size:11px;" data-i18n="ttsStepsLabel">推理步数</label>
|
| 511 |
+
<span class="val-badge" id="ttsStepsVal">10</span>
|
| 512 |
+
</div>
|
| 513 |
+
<div class="slider-container">
|
| 514 |
+
<input type="range" id="tts-steps" min="5" max="50" step="5" value="10"
|
| 515 |
+
oninput="document.getElementById('ttsStepsVal').textContent=this.value">
|
| 516 |
+
</div>
|
| 517 |
+
</div>
|
| 518 |
+
</div>
|
| 519 |
+
</div>
|
| 520 |
+
|
| 521 |
+
<!-- 输出结果播放区 -->
|
| 522 |
+
<div id="tts-result-section" style="display:none;" class="setting-group">
|
| 523 |
+
<div class="group-title" data-i18n="ttsResultTitle">生成结果 / Output</div>
|
| 524 |
+
<audio id="tts-audio-player" controls style="width:100%; border-radius:8px; margin-bottom:6px;"></audio>
|
| 525 |
+
<a id="tts-download-link" href="#" download style="font-size:11px; color:var(--accent); text-decoration:none;" data-i18n="ttsDownload">⬇️ 下载音频</a>
|
| 526 |
+
</div>
|
| 527 |
+
|
| 528 |
+
<div style="padding: 0 0 10px 0;">
|
| 529 |
+
<button class="btn-primary" id="tts-gen-btn" onclick="runTts()" data-i18n="ttsGenBtn">🎙️ 开始生成语音</button>
|
| 530 |
+
</div>
|
| 531 |
+
</div>
|
| 532 |
+
|
| 533 |
+
<div style="padding: 0 30px 30px 30px;">
|
| 534 |
+
<button class="btn-primary" id="mainBtn" onclick="run()" data-i18n="mainRender">开始渲染</button>
|
| 535 |
+
</div>
|
| 536 |
+
</aside>
|
| 537 |
+
|
| 538 |
+
<main class="workspace">
|
| 539 |
+
<section class="viewer" id="viewer-section">
|
| 540 |
+
<div class="monitor" id="viewer">
|
| 541 |
+
<button id="preview-download-btn" type="button" onclick="downloadCurrentPreviewAsset()" class="preview-download-btn" style="display:none;">
|
| 542 |
+
<span class="preview-download-btn-icon" aria-hidden="true">
|
| 543 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
| 544 |
+
<path d="M12 3v12"></path>
|
| 545 |
+
<path d="M7 10l5 5 5-5"></path>
|
| 546 |
+
<path d="M5 21h14"></path>
|
| 547 |
+
</svg>
|
| 548 |
+
</span>
|
| 549 |
+
<span class="preview-download-btn-text" data-i18n="downloadLabel">下载</span>
|
| 550 |
+
</button>
|
| 551 |
+
<div id="preview-replay-actions" class="preview-replay-actions" style="display:none;">
|
| 552 |
+
<button type="button" onclick="loadCurrentPreviewSeed()" data-i18n="previewLoadSeed">载入种子</button>
|
| 553 |
+
<button type="button" onclick="loadCurrentPreviewParams()" data-i18n="previewLoadParams">载入参数</button>
|
| 554 |
+
</div>
|
| 555 |
+
<div id="loading-txt" data-i18n="waitingTask">等待分配渲染任务...</div>
|
| 556 |
+
<img id="res-img" src="">
|
| 557 |
+
<div id="video-wrapper" style="width:100%; height:100%; display:none; max-height:100%; align-items:center; justify-content:center;">
|
| 558 |
+
<video id="res-video" autoplay loop playsinline></video>
|
| 559 |
+
</div>
|
| 560 |
+
<div id="audio-wrapper">
|
| 561 |
+
<div class="audio-preview-art" id="audio-preview-art" role="button" tabindex="0">
|
| 562 |
+
<div class="audio-preview-icon">♪</div>
|
| 563 |
+
<div id="audio-preview-title">TTS Audio</div>
|
| 564 |
+
</div>
|
| 565 |
+
<audio id="res-audio" controls></audio>
|
| 566 |
+
</div>
|
| 567 |
+
<div class="progress-container"><div id="progress-fill"></div></div>
|
| 568 |
+
</div>
|
| 569 |
+
</section>
|
| 570 |
+
|
| 571 |
+
<!-- Drag Handle -->
|
| 572 |
+
<div id="resize-handle" style="
|
| 573 |
+
height: 5px; background: transparent; cursor: row-resize;
|
| 574 |
+
flex-shrink: 0; position: relative; z-index: 50;
|
| 575 |
+
display: flex; align-items: center; justify-content: center;
|
| 576 |
+
" data-i18n-title="resizeHandleTitle" title="拖动调整面板高度">
|
| 577 |
+
<div style="width: 40px; height: 3px; background: var(--border); border-radius: 999px; pointer-events: none;"></div>
|
| 578 |
+
</div>
|
| 579 |
+
|
| 580 |
+
<section class="library" id="library-section">
|
| 581 |
+
<div style="display: flex; justify-content: space-between; margin-bottom: 15px; align-items: center; border-bottom: 1px solid var(--border); padding-bottom: 10px;">
|
| 582 |
+
<div style="display: flex; gap: 20px;">
|
| 583 |
+
<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>
|
| 584 |
+
<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>
|
| 585 |
+
</div>
|
| 586 |
+
<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>
|
| 587 |
+
</div>
|
| 588 |
+
|
| 589 |
+
<div id="log-container" style="display: none; flex: 1; flex-direction: column;">
|
| 590 |
+
<div id="log" data-i18n="logReady">> LTX-2 Studio Ready. Expecting commands...</div>
|
| 591 |
+
</div>
|
| 592 |
+
|
| 593 |
+
<div id="history-wrapper">
|
| 594 |
+
<div id="history-container"></div>
|
| 595 |
+
</div>
|
| 596 |
+
<div id="pagination-bar" style="display:none;"></div>
|
| 597 |
+
</section>
|
| 598 |
+
</main>
|
| 599 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/plyr/3.7.8/plyr.min.js"></script>
|
| 600 |
+
<script src="i18n.js?v=en-tabs-1"></script>
|
| 601 |
+
<script src="index.js?v=model-switch-1"></script>
|
| 602 |
+
|
| 603 |
+
</body>
|
| 604 |
+
</html>
|
LTX2.3-1.0.4-new/UI/index.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
LTX2.3-1.0.4-new/main.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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_NO_CACHE_HEADERS = {"Cache-Control": "no-store, max-age=0"}
|
| 187 |
+
|
| 188 |
+
@ui_app.get("/")
|
| 189 |
+
async def serve_index():
|
| 190 |
+
return FileResponse(os.path.join(os.getcwd(), UI_FILE_NAME), headers=UI_NO_CACHE_HEADERS)
|
| 191 |
+
|
| 192 |
+
@ui_app.get("/index.css")
|
| 193 |
+
async def serve_css():
|
| 194 |
+
return FileResponse(os.path.join(os.getcwd(), "UI/index.css"), headers=UI_NO_CACHE_HEADERS)
|
| 195 |
+
|
| 196 |
+
@ui_app.get("/index.js")
|
| 197 |
+
async def serve_js():
|
| 198 |
+
return FileResponse(os.path.join(os.getcwd(), "UI/index.js"), headers=UI_NO_CACHE_HEADERS)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
@ui_app.get("/i18n.js")
|
| 202 |
+
async def serve_i18n():
|
| 203 |
+
return FileResponse(os.path.join(os.getcwd(), "UI/i18n.js"), headers=UI_NO_CACHE_HEADERS)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def launch_ui_server():
|
| 207 |
+
print(f"\033[92m[UI] 工作站已就绪!\033[0m")
|
| 208 |
+
print(f"\033[92m[LOCAL] 本机访问: http://127.0.0.1:4000\033[0m")
|
| 209 |
+
print(f"\033[93m[WIFI] 局域网访问: http://{LAN_IP}:4000\033[0m")
|
| 210 |
+
|
| 211 |
+
# 彻底压制 WinError 10054 (客户端强制断开) 的底层警告报错
|
| 212 |
+
if sys.platform == 'win32':
|
| 213 |
+
# Uvicorn 内部会拉起循环,所以只能通过底层 Logging Filter 拦截控制台噪音
|
| 214 |
+
class UvicornAsyncioNoiseFilter(logging.Filter):
|
| 215 |
+
"""压掉客户端断开、Win Proactor 管道收尾等无害 asyncio 控制台刷屏。"""
|
| 216 |
+
|
| 217 |
+
def filter(self, record):
|
| 218 |
+
if record.name != "asyncio":
|
| 219 |
+
return True
|
| 220 |
+
msg = record.getMessage()
|
| 221 |
+
if "_call_connection_lost" in msg or "_ProactorBasePipeTransport" in msg:
|
| 222 |
+
return False
|
| 223 |
+
if hasattr(record, "exc_info") and record.exc_info:
|
| 224 |
+
exc_type, exc_value, _ = record.exc_info
|
| 225 |
+
if isinstance(exc_value, ConnectionResetError) and getattr(
|
| 226 |
+
exc_value, "winerror", None
|
| 227 |
+
) == 10054:
|
| 228 |
+
return False
|
| 229 |
+
if "10054" in msg and "ConnectionResetError" in msg:
|
| 230 |
+
return False
|
| 231 |
+
return True
|
| 232 |
+
|
| 233 |
+
logging.getLogger("asyncio").addFilter(UvicornAsyncioNoiseFilter())
|
| 234 |
+
|
| 235 |
+
uvicorn.run(ui_app, host="0.0.0.0", port=4000, log_level="warning", access_log=False)
|
| 236 |
+
|
| 237 |
+
if __name__ == "__main__":
|
| 238 |
+
os.system('cls' if os.name == 'nt' else 'clear')
|
| 239 |
+
print("\033[1;97;44m LTX-2 CINEMATIC WORKSTATION | NETWORK ENABLED \033[0m\n")
|
| 240 |
+
|
| 241 |
+
threading.Thread(target=launch_backend, daemon=True).start()
|
| 242 |
+
|
| 243 |
+
# 强制校验 3000 端口是否存活
|
| 244 |
+
print("\033[93m[SYS] 正在等待内部核心 3000 端口启动...\033[0m")
|
| 245 |
+
backend_ready = False
|
| 246 |
+
for _ in range(30):
|
| 247 |
+
try:
|
| 248 |
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
| 249 |
+
if s.connect_ex(('127.0.0.1', 3000)) == 0:
|
| 250 |
+
backend_ready = True
|
| 251 |
+
break
|
| 252 |
+
except Exception:
|
| 253 |
+
pass
|
| 254 |
+
time.sleep(1)
|
| 255 |
+
|
| 256 |
+
if backend_ready:
|
| 257 |
+
print("\033[92m[SYS] 3000 端口已通过连通性握手验证!后端装载成功。\033[0m")
|
| 258 |
+
else:
|
| 259 |
+
print("\033[1;41m [崩坏警告] 等待 30 秒后,3000 端口依然无法连通! \033[0m")
|
| 260 |
+
print(">> Uvicorn 可能在后台陷入了死锁,或者被防火墙拦截,前端大概率将无法连接到后端!")
|
| 261 |
+
print(">> 请检查上方是否有 Python 报错。\n")
|
| 262 |
+
|
| 263 |
+
try:
|
| 264 |
+
launch_ui_server()
|
| 265 |
+
except KeyboardInterrupt:
|
| 266 |
+
sys.exit(0)
|
LTX2.3-1.0.4-new/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-1.0.4-new/patches/__pycache__/api_types.cpython-313.pyc
ADDED
|
Binary file (16.2 kB). View file
|
|
|
LTX2.3-1.0.4-new/patches/__pycache__/app_factory.cpython-313.pyc
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6a39240a5806a7be8c147f3fbd45dc7ea3f8c0a6a3550741d90423d4345335ee
|
| 3 |
+
size 143860
|
LTX2.3-1.0.4-new/patches/__pycache__/keep_models_runtime.cpython-313.pyc
ADDED
|
Binary file (894 Bytes). View file
|
|
|
LTX2.3-1.0.4-new/patches/__pycache__/lora_build_hook.cpython-313.pyc
ADDED
|
Binary file (8.77 kB). View file
|
|
|
LTX2.3-1.0.4-new/patches/__pycache__/lora_injection.cpython-313.pyc
ADDED
|
Binary file (5.19 kB). View file
|
|
|
LTX2.3-1.0.4-new/patches/__pycache__/low_vram_runtime.cpython-313.pyc
ADDED
|
Binary file (12.1 kB). View file
|
|
|
LTX2.3-1.0.4-new/patches/__pycache__/ltx_dev_video_pipeline.cpython-313.pyc
ADDED
|
Binary file (6.05 kB). View file
|
|
|
LTX2.3-1.0.4-new/patches/__pycache__/ltx_fp8_video_pipeline.cpython-313.pyc
ADDED
|
Binary file (11.1 kB). View file
|
|
|
LTX2.3-1.0.4-new/patches/__pycache__/tts_worker.cpython-313.pyc
ADDED
|
Binary file (11.3 kB). View file
|
|
|
LTX2.3-1.0.4-new/patches/api_types.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
RetakeMode: TypeAlias = Literal[
|
| 63 |
+
"replace_audio_and_video", "replace_video", "replace_audio"
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# ============================================================
|
| 68 |
+
# Response Models
|
| 69 |
+
# ============================================================
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class ModelStatusItem(BaseModel):
|
| 73 |
+
id: str
|
| 74 |
+
name: str
|
| 75 |
+
loaded: bool
|
| 76 |
+
downloaded: bool
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class GpuTelemetry(BaseModel):
|
| 80 |
+
name: str
|
| 81 |
+
vram: int
|
| 82 |
+
vramUsed: int
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class HealthResponse(BaseModel):
|
| 86 |
+
status: str
|
| 87 |
+
models_loaded: bool
|
| 88 |
+
active_model: str | None
|
| 89 |
+
gpu_info: GpuTelemetry
|
| 90 |
+
sage_attention: bool
|
| 91 |
+
models_status: list[ModelStatusItem]
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class GpuInfoResponse(BaseModel):
|
| 95 |
+
cuda_available: bool
|
| 96 |
+
mps_available: bool = False
|
| 97 |
+
gpu_available: bool = False
|
| 98 |
+
gpu_name: str | None
|
| 99 |
+
vram_gb: int | None
|
| 100 |
+
gpu_info: GpuTelemetry
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class RuntimePolicyResponse(BaseModel):
|
| 104 |
+
force_api_generations: bool
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class GenerationProgressResponse(BaseModel):
|
| 108 |
+
status: str
|
| 109 |
+
phase: str
|
| 110 |
+
progress: int
|
| 111 |
+
currentStep: int | None
|
| 112 |
+
totalSteps: int | None
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class ModelInfo(BaseModel):
|
| 116 |
+
id: str
|
| 117 |
+
name: str
|
| 118 |
+
description: str
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
class ModelFileStatus(BaseModel):
|
| 122 |
+
id: ModelFileType
|
| 123 |
+
name: str
|
| 124 |
+
description: str
|
| 125 |
+
downloaded: bool
|
| 126 |
+
size: int
|
| 127 |
+
expected_size: int
|
| 128 |
+
required: bool = True
|
| 129 |
+
is_folder: bool = False
|
| 130 |
+
optional_reason: str | None = None
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class TextEncoderStatus(BaseModel):
|
| 134 |
+
downloaded: bool
|
| 135 |
+
size_bytes: int
|
| 136 |
+
size_gb: float
|
| 137 |
+
expected_size_gb: float
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
class ModelsStatusResponse(BaseModel):
|
| 141 |
+
models: list[ModelFileStatus]
|
| 142 |
+
all_downloaded: bool
|
| 143 |
+
total_size: int
|
| 144 |
+
downloaded_size: int
|
| 145 |
+
total_size_gb: float
|
| 146 |
+
downloaded_size_gb: float
|
| 147 |
+
models_path: str
|
| 148 |
+
has_api_key: bool
|
| 149 |
+
text_encoder_status: TextEncoderStatus
|
| 150 |
+
use_local_text_encoder: bool
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
class DownloadProgressRunningResponse(BaseModel):
|
| 154 |
+
status: Literal["downloading"]
|
| 155 |
+
current_downloading_file: ModelFileType | None
|
| 156 |
+
current_file_progress: float
|
| 157 |
+
total_progress: float
|
| 158 |
+
total_downloaded_bytes: int
|
| 159 |
+
expected_total_bytes: int
|
| 160 |
+
completed_files: set[ModelFileType]
|
| 161 |
+
all_files: set[ModelFileType]
|
| 162 |
+
error: None = None
|
| 163 |
+
speed_bytes_per_sec: float
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
class DownloadProgressCompleteResponse(BaseModel):
|
| 167 |
+
status: Literal["complete"]
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
class DownloadProgressErrorResponse(BaseModel):
|
| 171 |
+
status: Literal["error"]
|
| 172 |
+
error: str
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
DownloadProgressResponse: TypeAlias = (
|
| 176 |
+
DownloadProgressRunningResponse
|
| 177 |
+
| DownloadProgressCompleteResponse
|
| 178 |
+
| DownloadProgressErrorResponse
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
class SuggestGapPromptResponse(BaseModel):
|
| 183 |
+
status: str = "success"
|
| 184 |
+
suggested_prompt: str
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
class GenerateVideoCompleteResponse(BaseModel):
|
| 188 |
+
status: Literal["complete"]
|
| 189 |
+
video_path: str
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
class GenerateVideoCancelledResponse(BaseModel):
|
| 193 |
+
status: Literal["cancelled"]
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
GenerateVideoResponse: TypeAlias = (
|
| 197 |
+
GenerateVideoCompleteResponse | GenerateVideoCancelledResponse
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
class GenerateImageCompleteResponse(BaseModel):
|
| 202 |
+
status: Literal["complete"]
|
| 203 |
+
image_paths: list[str]
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
class GenerateImageCancelledResponse(BaseModel):
|
| 207 |
+
status: Literal["cancelled"]
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
GenerateImageResponse: TypeAlias = (
|
| 211 |
+
GenerateImageCompleteResponse | GenerateImageCancelledResponse
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
class CancelCancellingResponse(BaseModel):
|
| 216 |
+
status: Literal["cancelling"]
|
| 217 |
+
id: str
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
class CancelNoActiveGenerationResponse(BaseModel):
|
| 221 |
+
status: Literal["no_active_generation"]
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
CancelResponse: TypeAlias = CancelCancellingResponse | CancelNoActiveGenerationResponse
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
class RetakeVideoResponse(BaseModel):
|
| 228 |
+
status: Literal["complete"]
|
| 229 |
+
video_path: str
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
class RetakePayloadResponse(BaseModel):
|
| 233 |
+
status: Literal["complete"]
|
| 234 |
+
result: JsonObject
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
class RetakeCancelledResponse(BaseModel):
|
| 238 |
+
status: Literal["cancelled"]
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
RetakeResponse: TypeAlias = (
|
| 242 |
+
RetakeVideoResponse | RetakePayloadResponse | RetakeCancelledResponse
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
class IcLoraExtractResponse(BaseModel):
|
| 247 |
+
conditioning: str
|
| 248 |
+
original: str
|
| 249 |
+
conditioning_type: Literal["canny", "depth", "pose", "video"]
|
| 250 |
+
frame_time: float
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
class IcLoraGenerateCompleteResponse(BaseModel):
|
| 254 |
+
status: Literal["complete"]
|
| 255 |
+
video_path: str
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
class IcLoraGenerateCancelledResponse(BaseModel):
|
| 259 |
+
status: Literal["cancelled"]
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
IcLoraGenerateResponse: TypeAlias = (
|
| 263 |
+
IcLoraGenerateCompleteResponse | IcLoraGenerateCancelledResponse
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
class ModelDownloadStartResponse(BaseModel):
|
| 268 |
+
status: Literal["started"]
|
| 269 |
+
message: str
|
| 270 |
+
sessionId: str
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
class TextEncoderDownloadStartedResponse(BaseModel):
|
| 274 |
+
status: Literal["started"]
|
| 275 |
+
message: str
|
| 276 |
+
sessionId: str
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
class TextEncoderAlreadyDownloadedResponse(BaseModel):
|
| 280 |
+
status: Literal["already_downloaded"]
|
| 281 |
+
message: str
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
TextEncoderDownloadResponse: TypeAlias = (
|
| 285 |
+
TextEncoderDownloadStartedResponse | TextEncoderAlreadyDownloadedResponse
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
class StatusResponse(BaseModel):
|
| 290 |
+
status: str
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
class ErrorResponse(BaseModel):
|
| 294 |
+
error: str
|
| 295 |
+
message: str | None = None
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
# ============================================================
|
| 299 |
+
# Request Models
|
| 300 |
+
# ============================================================
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
class GenerateVideoRequest(BaseModel):
|
| 304 |
+
prompt: NonEmptyPrompt
|
| 305 |
+
resolution: str = "512p"
|
| 306 |
+
model: str = "fast"
|
| 307 |
+
cameraMotion: VideoCameraMotion = "none"
|
| 308 |
+
negativePrompt: str = ""
|
| 309 |
+
duration: str = "2"
|
| 310 |
+
fps: str = "24"
|
| 311 |
+
audio: str = "false"
|
| 312 |
+
imagePath: str | None = None
|
| 313 |
+
audioPath: str | None = None
|
| 314 |
+
startFramePath: str | None = None
|
| 315 |
+
endFramePath: str | None = None
|
| 316 |
+
# 多张图单次推理:latent 时间轴多锚点(Comfy LTXVAddGuideMulti 思路);≥2 路径时优先于首尾帧
|
| 317 |
+
keyframePaths: list[str] | None = None
|
| 318 |
+
# 与 keyframePaths 等长、0.1–1.0;不传则按 Comfy 类工作流自动降低中间帧强度,减轻闪烁
|
| 319 |
+
keyframeStrengths: list[float] | None = None
|
| 320 |
+
# 与 keyframePaths 等长,单位秒,落在 [0, 整段时长];全提供时按时间映射 latent,否则仍自动均分
|
| 321 |
+
keyframeTimes: list[float] | None = None
|
| 322 |
+
aspectRatio: str = "16:9"
|
| 323 |
+
customWidth: int | None = None
|
| 324 |
+
customHeight: int | None = None
|
| 325 |
+
modelPath: str | None = None
|
| 326 |
+
loraPath: str | None = None
|
| 327 |
+
loraStrength: float = 2.0
|
| 328 |
+
loraPaths: list[str] | None = None
|
| 329 |
+
loraStrengths: list[float] | None = None
|
| 330 |
+
seed: int | None = None
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
class GenerateImageRequest(BaseModel):
|
| 334 |
+
prompt: NonEmptyPrompt
|
| 335 |
+
width: int = 1024
|
| 336 |
+
height: int = 1024
|
| 337 |
+
numSteps: int = 4
|
| 338 |
+
numImages: int = 1
|
| 339 |
+
seed: int | None = None
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def _default_model_types() -> set[ModelFileType]:
|
| 343 |
+
return set()
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
class ModelDownloadRequest(BaseModel):
|
| 347 |
+
modelTypes: set[ModelFileType] = Field(default_factory=_default_model_types)
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
class RequiredModelsResponse(BaseModel):
|
| 351 |
+
modelTypes: list[ModelFileType]
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
class SuggestGapPromptRequest(BaseModel):
|
| 355 |
+
beforePrompt: str = ""
|
| 356 |
+
afterPrompt: str = ""
|
| 357 |
+
beforeFrame: str | None = None
|
| 358 |
+
afterFrame: str | None = None
|
| 359 |
+
gapDuration: float = 5
|
| 360 |
+
mode: str = "t2v"
|
| 361 |
+
inputImage: str | None = None
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
class RetakeRequest(BaseModel):
|
| 365 |
+
video_path: str
|
| 366 |
+
start_time: float = 0
|
| 367 |
+
duration: float = 0
|
| 368 |
+
prompt: str = ""
|
| 369 |
+
mode: str = "replace_video_only"
|
| 370 |
+
width: int | None = None
|
| 371 |
+
height: int | None = None
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
class IcLoraExtractRequest(BaseModel):
|
| 375 |
+
video_path: str
|
| 376 |
+
conditioning_type: Literal["canny", "depth", "pose", "video"] = "canny"
|
| 377 |
+
frame_time: float = 0
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
class IcLoraImageInput(BaseModel):
|
| 381 |
+
path: str
|
| 382 |
+
frame: int = 0
|
| 383 |
+
strength: float = 1.0
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def _default_ic_lora_images() -> list[IcLoraImageInput]:
|
| 387 |
+
return []
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
class IcLoraGenerateRequest(BaseModel):
|
| 391 |
+
video_path: str
|
| 392 |
+
conditioning_type: Literal["canny", "depth", "pose", "video"]
|
| 393 |
+
prompt: NonEmptyPrompt
|
| 394 |
+
conditioning_strength: float = 1.0
|
| 395 |
+
num_inference_steps: int = 30
|
| 396 |
+
cfg_guidance_scale: float = 1.0
|
| 397 |
+
negative_prompt: str = ""
|
| 398 |
+
images: list[IcLoraImageInput] = Field(default_factory=_default_ic_lora_images)
|
| 399 |
+
ic_lora_path: str | None = None
|
| 400 |
+
seed: int | None = None
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
ConditioningType: TypeAlias = Literal["canny", "depth", "pose", "video"]
|
LTX2.3-1.0.4-new/patches/app_factory.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
LTX2.3-1.0.4-new/patches/app_settings_patch.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""运行时补丁:给 AppSettings 添加 lora_dir 字段(如果不存在)。"""
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def patch_app_settings():
|
| 8 |
+
try:
|
| 9 |
+
from state.app_settings import AppSettings
|
| 10 |
+
from pydantic import Field
|
| 11 |
+
|
| 12 |
+
if "lora_dir" not in AppSettings.model_fields:
|
| 13 |
+
AppSettings.model_fields["lora_dir"] = Field(
|
| 14 |
+
default="", validation_alias="loraDir", serialization_alias="loraDir"
|
| 15 |
+
)
|
| 16 |
+
AppSettings.model_rebuild(_force=True)
|
| 17 |
+
print("[PATCH] AppSettings patched: added lora_dir field")
|
| 18 |
+
except Exception as e:
|
| 19 |
+
print(f"[PATCH] AppSettings patch failed: {e}")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
patch_app_settings()
|
LTX2.3-1.0.4-new/patches/handlers/__pycache__/video_generation_handler.cpython-313.pyc
ADDED
|
Binary file (36.5 kB). View file
|
|
|
LTX2.3-1.0.4-new/patches/handlers/video_generation_handler.py
ADDED
|
@@ -0,0 +1,882 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = []
|
| 140 |
+
try:
|
| 141 |
+
import os
|
| 142 |
+
from ltx_core.loader import LoraPathStrengthAndSDOps
|
| 143 |
+
from ltx_core.loader.sd_ops import LTXV_LORA_COMFY_RENAMING_MAP
|
| 144 |
+
|
| 145 |
+
# Handle legacy single LoRA
|
| 146 |
+
if req.loraPath and req.loraPath.strip():
|
| 147 |
+
lora_path = req.loraPath.strip()
|
| 148 |
+
if os.path.exists(lora_path):
|
| 149 |
+
loras.append(
|
| 150 |
+
LoraPathStrengthAndSDOps(
|
| 151 |
+
path=lora_path,
|
| 152 |
+
strength=req.loraStrength,
|
| 153 |
+
sd_ops=LTXV_LORA_COMFY_RENAMING_MAP,
|
| 154 |
+
)
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
# Handle multiple LoRAs
|
| 158 |
+
if req.loraPaths and req.loraStrengths:
|
| 159 |
+
for lp, ls in zip(req.loraPaths, req.loraStrengths):
|
| 160 |
+
if lp and lp.strip():
|
| 161 |
+
p = lp.strip()
|
| 162 |
+
if os.path.exists(p):
|
| 163 |
+
# Avoid duplicates if single LoRA was also in paths
|
| 164 |
+
if not any(x.path == p for x in loras):
|
| 165 |
+
loras.append(
|
| 166 |
+
LoraPathStrengthAndSDOps(
|
| 167 |
+
path=p,
|
| 168 |
+
strength=float(ls),
|
| 169 |
+
sd_ops=LTXV_LORA_COMFY_RENAMING_MAP,
|
| 170 |
+
)
|
| 171 |
+
)
|
| 172 |
+
logger.info(f"Multi-LoRA prepared: {p} with strength {ls}")
|
| 173 |
+
else:
|
| 174 |
+
logger.warning(f"Multi-LoRA file not found: {p}")
|
| 175 |
+
except Exception as e:
|
| 176 |
+
logger.warning(f"Failed to load LoRAs: {e}")
|
| 177 |
+
import traceback
|
| 178 |
+
logger.warning(f"LoRA traceback: {traceback.format_exc()}")
|
| 179 |
+
loras = []
|
| 180 |
+
|
| 181 |
+
if not loras:
|
| 182 |
+
loras = None
|
| 183 |
+
|
| 184 |
+
if loras is not None:
|
| 185 |
+
sig_list = []
|
| 186 |
+
for item in sorted(loras, key=lambda x: x.path):
|
| 187 |
+
sig_list.extend([item.path, round(float(item.strength), 4)])
|
| 188 |
+
desired_sig = ("fast", tuple(sig_list))
|
| 189 |
+
else:
|
| 190 |
+
desired_sig = ("fast", "", 0.0)
|
| 191 |
+
|
| 192 |
+
try:
|
| 193 |
+
if loras is not None:
|
| 194 |
+
# 强制卸载并重新加载带LoRA的pipeline
|
| 195 |
+
logger.info("Unloading pipeline for LoRA...")
|
| 196 |
+
from keep_models_runtime import force_unload_gpu_pipeline
|
| 197 |
+
|
| 198 |
+
force_unload_gpu_pipeline(self._pipelines)
|
| 199 |
+
|
| 200 |
+
# 强制垃圾回收
|
| 201 |
+
import gc
|
| 202 |
+
|
| 203 |
+
gc.collect()
|
| 204 |
+
# 释放 CUDA 缓存,降低 LoRA 首次构建的显存峰值/碎片风险
|
| 205 |
+
try:
|
| 206 |
+
import torch
|
| 207 |
+
if torch.cuda.is_available():
|
| 208 |
+
torch.cuda.empty_cache()
|
| 209 |
+
torch.cuda.ipc_collect()
|
| 210 |
+
except Exception:
|
| 211 |
+
pass
|
| 212 |
+
|
| 213 |
+
gemma_root = self._pipelines._text_handler.resolve_gemma_root()
|
| 214 |
+
from runtime_config.model_download_specs import resolve_model_path
|
| 215 |
+
from services.fast_video_pipeline.ltx_fast_video_pipeline import (
|
| 216 |
+
LTXFastVideoPipeline,
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
checkpoint_path = str(
|
| 220 |
+
resolve_model_path(
|
| 221 |
+
self._pipelines.models_dir,
|
| 222 |
+
self._pipelines.config.model_download_specs,
|
| 223 |
+
"checkpoint",
|
| 224 |
+
)
|
| 225 |
+
)
|
| 226 |
+
upsampler_path = str(
|
| 227 |
+
resolve_model_path(
|
| 228 |
+
self._pipelines.models_dir,
|
| 229 |
+
self._pipelines.config.model_download_specs,
|
| 230 |
+
"upsampler",
|
| 231 |
+
)
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
logger.info(
|
| 235 |
+
f"Creating pipeline with LoRA: {loras}, steps: {inference_steps}"
|
| 236 |
+
)
|
| 237 |
+
from lora_injection import (
|
| 238 |
+
_lora_init_kwargs,
|
| 239 |
+
inject_loras_into_fast_pipeline,
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
lora_kw = _lora_init_kwargs(LTXFastVideoPipeline, loras)
|
| 243 |
+
pipeline = LTXFastVideoPipeline(
|
| 244 |
+
checkpoint_path,
|
| 245 |
+
gemma_root,
|
| 246 |
+
upsampler_path,
|
| 247 |
+
self._pipelines.config.device,
|
| 248 |
+
**lora_kw,
|
| 249 |
+
)
|
| 250 |
+
n_inj = inject_loras_into_fast_pipeline(pipeline, loras)
|
| 251 |
+
if hasattr(pipeline, "pipeline") and hasattr(
|
| 252 |
+
pipeline.pipeline, "model_ledger"
|
| 253 |
+
):
|
| 254 |
+
try:
|
| 255 |
+
pipeline.pipeline.model_ledger.loras = tuple(loras)
|
| 256 |
+
except Exception:
|
| 257 |
+
pass
|
| 258 |
+
logger.info(
|
| 259 |
+
"LoRA 注入: init_kw=%s, 注入点=%s, model_ledger.loras=%s",
|
| 260 |
+
list(lora_kw.keys()),
|
| 261 |
+
n_inj,
|
| 262 |
+
getattr(
|
| 263 |
+
getattr(pipeline.pipeline, "model_ledger", None),
|
| 264 |
+
"loras",
|
| 265 |
+
None,
|
| 266 |
+
),
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
from state.app_state_types import (
|
| 270 |
+
VideoPipelineState,
|
| 271 |
+
VideoPipelineWarmth,
|
| 272 |
+
GpuSlot,
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
state = VideoPipelineState(
|
| 276 |
+
pipeline=pipeline,
|
| 277 |
+
warmth=VideoPipelineWarmth.COLD,
|
| 278 |
+
is_compiled=False,
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
self._pipelines.state.gpu_slot = GpuSlot(
|
| 282 |
+
active_pipeline=state, generation=None
|
| 283 |
+
)
|
| 284 |
+
logger.info("Pipeline with LoRA loaded successfully")
|
| 285 |
+
else:
|
| 286 |
+
# 无论有没有LoRA,都尝试使用自定义步数重新加载pipeline
|
| 287 |
+
logger.info(f"Loading pipeline with {inference_steps} steps")
|
| 288 |
+
from keep_models_runtime import force_unload_gpu_pipeline
|
| 289 |
+
|
| 290 |
+
force_unload_gpu_pipeline(self._pipelines)
|
| 291 |
+
|
| 292 |
+
import gc
|
| 293 |
+
|
| 294 |
+
gc.collect()
|
| 295 |
+
|
| 296 |
+
gemma_root = self._pipelines._text_handler.resolve_gemma_root()
|
| 297 |
+
from runtime_config.model_download_specs import resolve_model_path
|
| 298 |
+
from services.fast_video_pipeline.ltx_fast_video_pipeline import (
|
| 299 |
+
LTXFastVideoPipeline,
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
checkpoint_path = str(
|
| 303 |
+
resolve_model_path(
|
| 304 |
+
self._pipelines.models_dir,
|
| 305 |
+
self._pipelines.config.model_download_specs,
|
| 306 |
+
"checkpoint",
|
| 307 |
+
)
|
| 308 |
+
)
|
| 309 |
+
upsampler_path = str(
|
| 310 |
+
resolve_model_path(
|
| 311 |
+
self._pipelines.models_dir,
|
| 312 |
+
self._pipelines.config.model_download_specs,
|
| 313 |
+
"upsampler",
|
| 314 |
+
)
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
pipeline = LTXFastVideoPipeline(
|
| 318 |
+
checkpoint_path,
|
| 319 |
+
gemma_root,
|
| 320 |
+
upsampler_path,
|
| 321 |
+
self._pipelines.config.device,
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
from state.app_state_types import (
|
| 325 |
+
VideoPipelineState,
|
| 326 |
+
VideoPipelineWarmth,
|
| 327 |
+
GpuSlot,
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
state = VideoPipelineState(
|
| 331 |
+
pipeline=pipeline,
|
| 332 |
+
warmth=VideoPipelineWarmth.COLD,
|
| 333 |
+
is_compiled=False,
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
self._pipelines.state.gpu_slot = GpuSlot(
|
| 337 |
+
active_pipeline=state, generation=None
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
self._pipelines._pipeline_signature = desired_sig
|
| 341 |
+
|
| 342 |
+
self._generation.start_generation(generation_id)
|
| 343 |
+
|
| 344 |
+
output_path = self.generate_video(
|
| 345 |
+
prompt=req.prompt,
|
| 346 |
+
image=image,
|
| 347 |
+
height=height,
|
| 348 |
+
width=width,
|
| 349 |
+
num_frames=num_frames,
|
| 350 |
+
fps=fps,
|
| 351 |
+
seed=seed,
|
| 352 |
+
camera_motion=req.cameraMotion,
|
| 353 |
+
negative_prompt=req.negativePrompt,
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
self._generation.complete_generation(output_path)
|
| 357 |
+
return GenerateVideoResponse(status="complete", video_path=output_path)
|
| 358 |
+
|
| 359 |
+
except Exception as e:
|
| 360 |
+
self._generation.fail_generation(str(e))
|
| 361 |
+
if "cancelled" in str(e).lower():
|
| 362 |
+
logger.info("Generation cancelled by user")
|
| 363 |
+
return GenerateVideoResponse(status="cancelled")
|
| 364 |
+
|
| 365 |
+
raise HTTPError(500, str(e)) from e
|
| 366 |
+
|
| 367 |
+
def generate_video(
|
| 368 |
+
self,
|
| 369 |
+
prompt: str,
|
| 370 |
+
image: Image.Image | None,
|
| 371 |
+
height: int,
|
| 372 |
+
width: int,
|
| 373 |
+
num_frames: int,
|
| 374 |
+
fps: float,
|
| 375 |
+
seed: int,
|
| 376 |
+
camera_motion: VideoCameraMotion,
|
| 377 |
+
negative_prompt: str,
|
| 378 |
+
) -> str:
|
| 379 |
+
t_total_start = time.perf_counter()
|
| 380 |
+
gen_mode = "i2v" if image is not None else "t2v"
|
| 381 |
+
logger.info(
|
| 382 |
+
"[%s] Generation started (model=fast, %dx%d, %d frames, %d fps)",
|
| 383 |
+
gen_mode,
|
| 384 |
+
width,
|
| 385 |
+
height,
|
| 386 |
+
num_frames,
|
| 387 |
+
int(fps),
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
if self._generation.is_generation_cancelled():
|
| 391 |
+
raise RuntimeError("Generation was cancelled")
|
| 392 |
+
|
| 393 |
+
if not resolve_model_path(
|
| 394 |
+
self.models_dir, self.config.model_download_specs, "checkpoint"
|
| 395 |
+
).exists():
|
| 396 |
+
raise RuntimeError(
|
| 397 |
+
"Models not downloaded. Please download the AI models first using the Model Status menu."
|
| 398 |
+
)
|
| 399 |
+
|
| 400 |
+
total_steps = 8
|
| 401 |
+
|
| 402 |
+
self._generation.update_progress("loading_model", 5, 0, total_steps)
|
| 403 |
+
t_load_start = time.perf_counter()
|
| 404 |
+
pipeline_state = self._pipelines.load_gpu_pipeline("fast", should_warm=False)
|
| 405 |
+
t_load_end = time.perf_counter()
|
| 406 |
+
logger.info("[%s] Pipeline load: %.2fs", gen_mode, t_load_end - t_load_start)
|
| 407 |
+
|
| 408 |
+
self._generation.update_progress("encoding_text", 10, 0, total_steps)
|
| 409 |
+
|
| 410 |
+
enhanced_prompt = prompt + self.config.camera_motion_prompts.get(
|
| 411 |
+
camera_motion, ""
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
images: list[ImageConditioningInput] = []
|
| 415 |
+
temp_image_path: str | None = None
|
| 416 |
+
if image is not None:
|
| 417 |
+
temp_image_path = tempfile.NamedTemporaryFile(
|
| 418 |
+
suffix=".png", delete=False
|
| 419 |
+
).name
|
| 420 |
+
image.save(temp_image_path)
|
| 421 |
+
images = [
|
| 422 |
+
ImageConditioningInput(path=temp_image_path, frame_idx=0, strength=1.0)
|
| 423 |
+
]
|
| 424 |
+
|
| 425 |
+
output_path = self._make_output_path()
|
| 426 |
+
|
| 427 |
+
try:
|
| 428 |
+
settings = self.state.app_settings
|
| 429 |
+
use_api_encoding = not self._text.should_use_local_encoding()
|
| 430 |
+
if image is not None:
|
| 431 |
+
enhance = use_api_encoding and settings.prompt_enhancer_enabled_i2v
|
| 432 |
+
else:
|
| 433 |
+
enhance = use_api_encoding and settings.prompt_enhancer_enabled_t2v
|
| 434 |
+
|
| 435 |
+
encoding_method = "api" if use_api_encoding else "local"
|
| 436 |
+
t_text_start = time.perf_counter()
|
| 437 |
+
self._text.prepare_text_encoding(enhanced_prompt, enhance_prompt=enhance)
|
| 438 |
+
t_text_end = time.perf_counter()
|
| 439 |
+
logger.info(
|
| 440 |
+
"[%s] Text encoding (%s): %.2fs",
|
| 441 |
+
gen_mode,
|
| 442 |
+
encoding_method,
|
| 443 |
+
t_text_end - t_text_start,
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
self._generation.update_progress("inference", 15, 0, total_steps)
|
| 447 |
+
|
| 448 |
+
height = round(height / 64) * 64
|
| 449 |
+
width = round(width / 64) * 64
|
| 450 |
+
|
| 451 |
+
t_inference_start = time.perf_counter()
|
| 452 |
+
pipeline_state.pipeline.generate(
|
| 453 |
+
prompt=enhanced_prompt,
|
| 454 |
+
seed=seed,
|
| 455 |
+
height=height,
|
| 456 |
+
width=width,
|
| 457 |
+
num_frames=num_frames,
|
| 458 |
+
frame_rate=fps,
|
| 459 |
+
images=images,
|
| 460 |
+
output_path=str(output_path),
|
| 461 |
+
)
|
| 462 |
+
t_inference_end = time.perf_counter()
|
| 463 |
+
logger.info(
|
| 464 |
+
"[%s] Inference: %.2fs", gen_mode, t_inference_end - t_inference_start
|
| 465 |
+
)
|
| 466 |
+
|
| 467 |
+
if self._generation.is_generation_cancelled():
|
| 468 |
+
if output_path.exists():
|
| 469 |
+
output_path.unlink()
|
| 470 |
+
raise RuntimeError("Generation was cancelled")
|
| 471 |
+
|
| 472 |
+
t_total_end = time.perf_counter()
|
| 473 |
+
logger.info(
|
| 474 |
+
"[%s] Total generation: %.2fs (load=%.2fs, text=%.2fs, inference=%.2fs)",
|
| 475 |
+
gen_mode,
|
| 476 |
+
t_total_end - t_total_start,
|
| 477 |
+
t_load_end - t_load_start,
|
| 478 |
+
t_text_end - t_text_start,
|
| 479 |
+
t_inference_end - t_inference_start,
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
self._generation.update_progress("complete", 100, total_steps, total_steps)
|
| 483 |
+
return str(output_path)
|
| 484 |
+
finally:
|
| 485 |
+
self._text.clear_api_embeddings()
|
| 486 |
+
if temp_image_path and os.path.exists(temp_image_path):
|
| 487 |
+
os.unlink(temp_image_path)
|
| 488 |
+
|
| 489 |
+
def _generate_a2v(
|
| 490 |
+
self, req: GenerateVideoRequest, duration: int, fps: int, *, audio_path: str
|
| 491 |
+
) -> GenerateVideoResponse:
|
| 492 |
+
if req.model != "pro":
|
| 493 |
+
logger.warning(
|
| 494 |
+
"A2V local requested with model=%s; A2V always uses pro pipeline",
|
| 495 |
+
req.model,
|
| 496 |
+
)
|
| 497 |
+
validated_audio_path = validate_audio_file(audio_path)
|
| 498 |
+
audio_path_str = str(validated_audio_path)
|
| 499 |
+
|
| 500 |
+
# 支持竖屏和横屏
|
| 501 |
+
RESOLUTION_MAP: dict[str, tuple[int, int]] = {
|
| 502 |
+
"540p": (1024, 576),
|
| 503 |
+
"720p": (1280, 704),
|
| 504 |
+
"1080p": (1920, 1088),
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
base_w, base_h = RESOLUTION_MAP.get(req.resolution, (1280, 704))
|
| 508 |
+
|
| 509 |
+
# 根据 aspectRatio 调整分辨率
|
| 510 |
+
if req.aspectRatio == "9:16":
|
| 511 |
+
width, height = base_h, base_w # 竖屏
|
| 512 |
+
else:
|
| 513 |
+
width, height = base_w, base_h # 横屏
|
| 514 |
+
|
| 515 |
+
num_frames = self._compute_num_frames(duration, fps)
|
| 516 |
+
|
| 517 |
+
image = None
|
| 518 |
+
temp_image_path: str | None = None
|
| 519 |
+
image_path = normalize_optional_path(req.imagePath)
|
| 520 |
+
if image_path:
|
| 521 |
+
image = self._prepare_image(image_path, width, height)
|
| 522 |
+
|
| 523 |
+
# 获取首尾帧
|
| 524 |
+
start_frame_path = normalize_optional_path(getattr(req, "startFramePath", None))
|
| 525 |
+
end_frame_path = normalize_optional_path(getattr(req, "endFramePath", None))
|
| 526 |
+
|
| 527 |
+
seed = self._resolve_seed()
|
| 528 |
+
|
| 529 |
+
generation_id = self._make_generation_id()
|
| 530 |
+
|
| 531 |
+
temp_image_paths: list[str] = []
|
| 532 |
+
try:
|
| 533 |
+
a2v_state = self._pipelines.load_a2v_pipeline()
|
| 534 |
+
self._generation.start_generation(generation_id)
|
| 535 |
+
|
| 536 |
+
enhanced_prompt = req.prompt + self.config.camera_motion_prompts.get(
|
| 537 |
+
req.cameraMotion, ""
|
| 538 |
+
)
|
| 539 |
+
neg = (
|
| 540 |
+
req.negativePrompt
|
| 541 |
+
if req.negativePrompt
|
| 542 |
+
else self.config.default_negative_prompt
|
| 543 |
+
)
|
| 544 |
+
|
| 545 |
+
images: list[ImageConditioningInput] = []
|
| 546 |
+
temp_image_paths: list[str] = []
|
| 547 |
+
|
| 548 |
+
# 首帧
|
| 549 |
+
if start_frame_path:
|
| 550 |
+
start_img = self._prepare_image(start_frame_path, width, height)
|
| 551 |
+
temp_start_path = tempfile.NamedTemporaryFile(
|
| 552 |
+
suffix=".png", delete=False
|
| 553 |
+
).name
|
| 554 |
+
start_img.save(temp_start_path)
|
| 555 |
+
temp_image_paths.append(temp_start_path)
|
| 556 |
+
images.append(
|
| 557 |
+
ImageConditioningInput(
|
| 558 |
+
path=temp_start_path, frame_idx=0, strength=1.0
|
| 559 |
+
)
|
| 560 |
+
)
|
| 561 |
+
|
| 562 |
+
# 中间图片(如果有)
|
| 563 |
+
if image is not None and not start_frame_path:
|
| 564 |
+
temp_image_path = tempfile.NamedTemporaryFile(
|
| 565 |
+
suffix=".png", delete=False
|
| 566 |
+
).name
|
| 567 |
+
image.save(temp_image_path)
|
| 568 |
+
temp_image_paths.append(temp_image_path)
|
| 569 |
+
images.append(
|
| 570 |
+
ImageConditioningInput(
|
| 571 |
+
path=temp_image_path, frame_idx=0, strength=1.0
|
| 572 |
+
)
|
| 573 |
+
)
|
| 574 |
+
|
| 575 |
+
# 尾帧
|
| 576 |
+
if end_frame_path:
|
| 577 |
+
last_latent_idx = (num_frames - 1) // 8 + 1 - 1
|
| 578 |
+
end_img = self._prepare_image(end_frame_path, width, height)
|
| 579 |
+
temp_end_path = tempfile.NamedTemporaryFile(
|
| 580 |
+
suffix=".png", delete=False
|
| 581 |
+
).name
|
| 582 |
+
end_img.save(temp_end_path)
|
| 583 |
+
temp_image_paths.append(temp_end_path)
|
| 584 |
+
images.append(
|
| 585 |
+
ImageConditioningInput(
|
| 586 |
+
path=temp_end_path, frame_idx=last_latent_idx, strength=1.0
|
| 587 |
+
)
|
| 588 |
+
)
|
| 589 |
+
|
| 590 |
+
output_path = self._make_output_path()
|
| 591 |
+
|
| 592 |
+
total_steps = 11 # distilled: 8 steps (stage 1) + 3 steps (stage 2)
|
| 593 |
+
|
| 594 |
+
a2v_settings = self.state.app_settings
|
| 595 |
+
a2v_use_api = not self._text.should_use_local_encoding()
|
| 596 |
+
if image is not None:
|
| 597 |
+
a2v_enhance = a2v_use_api and a2v_settings.prompt_enhancer_enabled_i2v
|
| 598 |
+
else:
|
| 599 |
+
a2v_enhance = a2v_use_api and a2v_settings.prompt_enhancer_enabled_t2v
|
| 600 |
+
|
| 601 |
+
self._generation.update_progress("loading_model", 5, 0, total_steps)
|
| 602 |
+
self._generation.update_progress("encoding_text", 10, 0, total_steps)
|
| 603 |
+
self._text.prepare_text_encoding(
|
| 604 |
+
enhanced_prompt, enhance_prompt=a2v_enhance
|
| 605 |
+
)
|
| 606 |
+
self._generation.update_progress("inference", 15, 0, total_steps)
|
| 607 |
+
|
| 608 |
+
a2v_state.pipeline.generate(
|
| 609 |
+
prompt=enhanced_prompt,
|
| 610 |
+
negative_prompt=neg,
|
| 611 |
+
seed=seed,
|
| 612 |
+
height=height,
|
| 613 |
+
width=width,
|
| 614 |
+
num_frames=num_frames,
|
| 615 |
+
frame_rate=fps,
|
| 616 |
+
num_inference_steps=total_steps,
|
| 617 |
+
images=images,
|
| 618 |
+
audio_path=audio_path_str,
|
| 619 |
+
audio_start_time=0.0,
|
| 620 |
+
audio_max_duration=None,
|
| 621 |
+
output_path=str(output_path),
|
| 622 |
+
)
|
| 623 |
+
|
| 624 |
+
if self._generation.is_generation_cancelled():
|
| 625 |
+
if output_path.exists():
|
| 626 |
+
output_path.unlink()
|
| 627 |
+
raise RuntimeError("Generation was cancelled")
|
| 628 |
+
|
| 629 |
+
self._generation.update_progress("complete", 100, total_steps, total_steps)
|
| 630 |
+
self._generation.complete_generation(str(output_path))
|
| 631 |
+
return GenerateVideoResponse(status="complete", video_path=str(output_path))
|
| 632 |
+
|
| 633 |
+
except Exception as e:
|
| 634 |
+
self._generation.fail_generation(str(e))
|
| 635 |
+
if "cancelled" in str(e).lower():
|
| 636 |
+
logger.info("Generation cancelled by user")
|
| 637 |
+
return GenerateVideoResponse(status="cancelled")
|
| 638 |
+
raise HTTPError(500, str(e)) from e
|
| 639 |
+
finally:
|
| 640 |
+
self._text.clear_api_embeddings()
|
| 641 |
+
# 清理所有临时图片
|
| 642 |
+
for tmp_path in temp_image_paths:
|
| 643 |
+
if tmp_path and os.path.exists(tmp_path):
|
| 644 |
+
try:
|
| 645 |
+
os.unlink(tmp_path)
|
| 646 |
+
except Exception:
|
| 647 |
+
pass
|
| 648 |
+
if temp_image_path and os.path.exists(temp_image_path):
|
| 649 |
+
try:
|
| 650 |
+
os.unlink(temp_image_path)
|
| 651 |
+
except Exception:
|
| 652 |
+
pass
|
| 653 |
+
|
| 654 |
+
def _prepare_image(self, image_path: str, width: int, height: int) -> Image.Image:
|
| 655 |
+
validated_path = validate_image_file(image_path)
|
| 656 |
+
try:
|
| 657 |
+
img = Image.open(validated_path).convert("RGB")
|
| 658 |
+
except Exception:
|
| 659 |
+
raise HTTPError(400, f"Invalid image file: {image_path}") from None
|
| 660 |
+
img_w, img_h = img.size
|
| 661 |
+
target_ratio = width / height
|
| 662 |
+
img_ratio = img_w / img_h
|
| 663 |
+
if img_ratio > target_ratio:
|
| 664 |
+
new_h = height
|
| 665 |
+
new_w = int(img_w * (height / img_h))
|
| 666 |
+
else:
|
| 667 |
+
new_w = width
|
| 668 |
+
new_h = int(img_h * (width / img_w))
|
| 669 |
+
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
| 670 |
+
left = (new_w - width) // 2
|
| 671 |
+
top = (new_h - height) // 2
|
| 672 |
+
return resized.crop((left, top, left + width, top + height))
|
| 673 |
+
|
| 674 |
+
@staticmethod
|
| 675 |
+
def _make_generation_id() -> str:
|
| 676 |
+
return uuid.uuid4().hex[:8]
|
| 677 |
+
|
| 678 |
+
@staticmethod
|
| 679 |
+
def _compute_num_frames(duration: int, fps: int) -> int:
|
| 680 |
+
n = ((duration * fps) // 8) * 8 + 1
|
| 681 |
+
return max(n, 9)
|
| 682 |
+
|
| 683 |
+
def _resolve_seed(self) -> int:
|
| 684 |
+
settings = self.state.app_settings
|
| 685 |
+
if settings.seed_locked:
|
| 686 |
+
logger.info("Using locked seed: %s", settings.locked_seed)
|
| 687 |
+
return settings.locked_seed
|
| 688 |
+
return int(time.time()) % 2147483647
|
| 689 |
+
|
| 690 |
+
def _make_output_path(self) -> Path:
|
| 691 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 692 |
+
return (
|
| 693 |
+
self.config.outputs_dir
|
| 694 |
+
/ f"ltx2_video_{timestamp}_{self._make_generation_id()}.mp4"
|
| 695 |
+
)
|
| 696 |
+
|
| 697 |
+
def _generate_forced_api(self, req: GenerateVideoRequest) -> GenerateVideoResponse:
|
| 698 |
+
if self._generation.is_generation_running():
|
| 699 |
+
raise HTTPError(409, "Generation already in progress")
|
| 700 |
+
|
| 701 |
+
generation_id = self._make_generation_id()
|
| 702 |
+
self._generation.start_api_generation(generation_id)
|
| 703 |
+
|
| 704 |
+
audio_path = normalize_optional_path(req.audioPath)
|
| 705 |
+
image_path = normalize_optional_path(req.imagePath)
|
| 706 |
+
has_input_audio = bool(audio_path)
|
| 707 |
+
has_input_image = bool(image_path)
|
| 708 |
+
|
| 709 |
+
try:
|
| 710 |
+
self._generation.update_progress("validating_request", 5, None, None)
|
| 711 |
+
|
| 712 |
+
api_key = self.state.app_settings.ltx_api_key.strip()
|
| 713 |
+
logger.info(
|
| 714 |
+
"Forced API generation route selected (key_present=%s)", bool(api_key)
|
| 715 |
+
)
|
| 716 |
+
if not api_key:
|
| 717 |
+
raise HTTPError(400, "PRO_API_KEY_REQUIRED")
|
| 718 |
+
|
| 719 |
+
requested_model = req.model.strip().lower()
|
| 720 |
+
api_model_id = FORCED_API_MODEL_MAP.get(requested_model)
|
| 721 |
+
if api_model_id is None:
|
| 722 |
+
raise HTTPError(400, "INVALID_FORCED_API_MODEL")
|
| 723 |
+
|
| 724 |
+
resolution_label = req.resolution
|
| 725 |
+
resolution_by_aspect = FORCED_API_RESOLUTION_MAP.get(resolution_label)
|
| 726 |
+
if resolution_by_aspect is None:
|
| 727 |
+
raise HTTPError(400, "INVALID_FORCED_API_RESOLUTION")
|
| 728 |
+
|
| 729 |
+
aspect_ratio = req.aspectRatio.strip()
|
| 730 |
+
if aspect_ratio not in FORCED_API_ALLOWED_ASPECT_RATIOS:
|
| 731 |
+
raise HTTPError(400, "INVALID_FORCED_API_ASPECT_RATIO")
|
| 732 |
+
|
| 733 |
+
api_resolution = resolution_by_aspect[aspect_ratio]
|
| 734 |
+
|
| 735 |
+
prompt = req.prompt
|
| 736 |
+
|
| 737 |
+
if self._generation.is_generation_cancelled():
|
| 738 |
+
raise RuntimeError("Generation was cancelled")
|
| 739 |
+
|
| 740 |
+
if has_input_audio:
|
| 741 |
+
if requested_model != "pro":
|
| 742 |
+
logger.warning(
|
| 743 |
+
"A2V requested with model=%s; overriding to 'pro'",
|
| 744 |
+
requested_model,
|
| 745 |
+
)
|
| 746 |
+
api_model_id = FORCED_API_MODEL_MAP["pro"]
|
| 747 |
+
if api_resolution != A2V_FORCED_API_RESOLUTION:
|
| 748 |
+
logger.warning(
|
| 749 |
+
"A2V requested with resolution=%s; overriding to '%s'",
|
| 750 |
+
api_resolution,
|
| 751 |
+
A2V_FORCED_API_RESOLUTION,
|
| 752 |
+
)
|
| 753 |
+
api_resolution = A2V_FORCED_API_RESOLUTION
|
| 754 |
+
validated_audio_path = validate_audio_file(audio_path)
|
| 755 |
+
validated_image_path: Path | None = None
|
| 756 |
+
if image_path is not None:
|
| 757 |
+
validated_image_path = validate_image_file(image_path)
|
| 758 |
+
|
| 759 |
+
self._generation.update_progress("uploading_audio", 20, None, None)
|
| 760 |
+
audio_uri = self._ltx_api_client.upload_file(
|
| 761 |
+
api_key=api_key,
|
| 762 |
+
file_path=str(validated_audio_path),
|
| 763 |
+
)
|
| 764 |
+
image_uri: str | None = None
|
| 765 |
+
if validated_image_path is not None:
|
| 766 |
+
self._generation.update_progress("uploading_image", 35, None, None)
|
| 767 |
+
image_uri = self._ltx_api_client.upload_file(
|
| 768 |
+
api_key=api_key,
|
| 769 |
+
file_path=str(validated_image_path),
|
| 770 |
+
)
|
| 771 |
+
self._generation.update_progress("inference", 55, None, None)
|
| 772 |
+
video_bytes = self._ltx_api_client.generate_audio_to_video(
|
| 773 |
+
api_key=api_key,
|
| 774 |
+
prompt=prompt,
|
| 775 |
+
audio_uri=audio_uri,
|
| 776 |
+
image_uri=image_uri,
|
| 777 |
+
model=api_model_id,
|
| 778 |
+
resolution=api_resolution,
|
| 779 |
+
)
|
| 780 |
+
self._generation.update_progress("downloading_output", 85, None, None)
|
| 781 |
+
elif has_input_image:
|
| 782 |
+
validated_image_path = validate_image_file(image_path)
|
| 783 |
+
|
| 784 |
+
duration = self._parse_forced_numeric_field(
|
| 785 |
+
req.duration, "INVALID_FORCED_API_DURATION"
|
| 786 |
+
)
|
| 787 |
+
fps = self._parse_forced_numeric_field(
|
| 788 |
+
req.fps, "INVALID_FORCED_API_FPS"
|
| 789 |
+
)
|
| 790 |
+
if fps not in FORCED_API_ALLOWED_FPS:
|
| 791 |
+
raise HTTPError(400, "INVALID_FORCED_API_FPS")
|
| 792 |
+
if duration not in _get_allowed_durations(
|
| 793 |
+
api_model_id, resolution_label, fps
|
| 794 |
+
):
|
| 795 |
+
raise HTTPError(400, "INVALID_FORCED_API_DURATION")
|
| 796 |
+
|
| 797 |
+
generate_audio = self._parse_audio_flag(req.audio)
|
| 798 |
+
self._generation.update_progress("uploading_image", 20, None, None)
|
| 799 |
+
image_uri = self._ltx_api_client.upload_file(
|
| 800 |
+
api_key=api_key,
|
| 801 |
+
file_path=str(validated_image_path),
|
| 802 |
+
)
|
| 803 |
+
self._generation.update_progress("inference", 55, None, None)
|
| 804 |
+
video_bytes = self._ltx_api_client.generate_image_to_video(
|
| 805 |
+
api_key=api_key,
|
| 806 |
+
prompt=prompt,
|
| 807 |
+
image_uri=image_uri,
|
| 808 |
+
model=api_model_id,
|
| 809 |
+
resolution=api_resolution,
|
| 810 |
+
duration=float(duration),
|
| 811 |
+
fps=float(fps),
|
| 812 |
+
generate_audio=generate_audio,
|
| 813 |
+
camera_motion=req.cameraMotion,
|
| 814 |
+
)
|
| 815 |
+
self._generation.update_progress("downloading_output", 85, None, None)
|
| 816 |
+
else:
|
| 817 |
+
duration = self._parse_forced_numeric_field(
|
| 818 |
+
req.duration, "INVALID_FORCED_API_DURATION"
|
| 819 |
+
)
|
| 820 |
+
fps = self._parse_forced_numeric_field(
|
| 821 |
+
req.fps, "INVALID_FORCED_API_FPS"
|
| 822 |
+
)
|
| 823 |
+
if fps not in FORCED_API_ALLOWED_FPS:
|
| 824 |
+
raise HTTPError(400, "INVALID_FORCED_API_FPS")
|
| 825 |
+
if duration not in _get_allowed_durations(
|
| 826 |
+
api_model_id, resolution_label, fps
|
| 827 |
+
):
|
| 828 |
+
raise HTTPError(400, "INVALID_FORCED_API_DURATION")
|
| 829 |
+
|
| 830 |
+
generate_audio = self._parse_audio_flag(req.audio)
|
| 831 |
+
self._generation.update_progress("inference", 55, None, None)
|
| 832 |
+
video_bytes = self._ltx_api_client.generate_text_to_video(
|
| 833 |
+
api_key=api_key,
|
| 834 |
+
prompt=prompt,
|
| 835 |
+
model=api_model_id,
|
| 836 |
+
resolution=api_resolution,
|
| 837 |
+
duration=float(duration),
|
| 838 |
+
fps=float(fps),
|
| 839 |
+
generate_audio=generate_audio,
|
| 840 |
+
camera_motion=req.cameraMotion,
|
| 841 |
+
)
|
| 842 |
+
self._generation.update_progress("downloading_output", 85, None, None)
|
| 843 |
+
|
| 844 |
+
if self._generation.is_generation_cancelled():
|
| 845 |
+
raise RuntimeError("Generation was cancelled")
|
| 846 |
+
|
| 847 |
+
output_path = self._write_forced_api_video(video_bytes)
|
| 848 |
+
if self._generation.is_generation_cancelled():
|
| 849 |
+
output_path.unlink(missing_ok=True)
|
| 850 |
+
raise RuntimeError("Generation was cancelled")
|
| 851 |
+
|
| 852 |
+
self._generation.update_progress("complete", 100, None, None)
|
| 853 |
+
self._generation.complete_generation(str(output_path))
|
| 854 |
+
return GenerateVideoResponse(status="complete", video_path=str(output_path))
|
| 855 |
+
except HTTPError as e:
|
| 856 |
+
self._generation.fail_generation(e.detail)
|
| 857 |
+
raise
|
| 858 |
+
except Exception as e:
|
| 859 |
+
self._generation.fail_generation(str(e))
|
| 860 |
+
if "cancelled" in str(e).lower():
|
| 861 |
+
logger.info("Generation cancelled by user")
|
| 862 |
+
return GenerateVideoResponse(status="cancelled")
|
| 863 |
+
raise HTTPError(500, str(e)) from e
|
| 864 |
+
|
| 865 |
+
def _write_forced_api_video(self, video_bytes: bytes) -> Path:
|
| 866 |
+
output_path = self._make_output_path()
|
| 867 |
+
output_path.write_bytes(video_bytes)
|
| 868 |
+
return output_path
|
| 869 |
+
|
| 870 |
+
@staticmethod
|
| 871 |
+
def _parse_forced_numeric_field(raw_value: str, error_detail: str) -> int:
|
| 872 |
+
try:
|
| 873 |
+
return int(float(raw_value))
|
| 874 |
+
except (TypeError, ValueError):
|
| 875 |
+
raise HTTPError(400, error_detail) from None
|
| 876 |
+
|
| 877 |
+
@staticmethod
|
| 878 |
+
def _parse_audio_flag(audio_value: str | bool) -> bool:
|
| 879 |
+
if isinstance(audio_value, bool):
|
| 880 |
+
return audio_value
|
| 881 |
+
normalized = audio_value.strip().lower()
|
| 882 |
+
return normalized in {"1", "true", "yes", "on"}
|
LTX2.3-1.0.4-new/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-1.0.4-new/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队列\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-1.0.4-new/patches/lora_build_hook.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
import torch
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
# 当前 HTTP 请求/生成任务中要额外融合的 LoRA(LoraPathStrengthAndSDOps 元组)
|
| 20 |
+
_pending_user_loras: contextvars.ContextVar[tuple[Any, ...] | None] = contextvars.ContextVar(
|
| 21 |
+
"ltx_pending_user_loras", default=None
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
_HOOK_INSTALLED = False
|
| 25 |
+
_FP8_LORA_PATCH_INSTALLED = False
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def pending_loras_token(loras: tuple[Any, ...] | None):
|
| 29 |
+
"""返回 contextvar Token,供 finally reset;loras 为 None 表示本任务不用额外 LoRA。"""
|
| 30 |
+
return _pending_user_loras.set(loras)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def reset_pending_loras(token: contextvars.Token | None) -> None:
|
| 34 |
+
if token is not None:
|
| 35 |
+
_pending_user_loras.reset(token)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _get_pending() -> tuple[Any, ...] | None:
|
| 39 |
+
return _pending_user_loras.get()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _is_ltx_diffusion_transformer_builder(builder: Any) -> bool:
|
| 43 |
+
"""避免给 Gemma / VAE / Upsampler 的 Builder 误加视频 LoRA。"""
|
| 44 |
+
cfg = getattr(builder, "model_class_configurator", None)
|
| 45 |
+
if cfg is None:
|
| 46 |
+
return False
|
| 47 |
+
name = getattr(cfg, "__name__", "") or ""
|
| 48 |
+
# 排除明显非 DiT 的
|
| 49 |
+
for bad in (
|
| 50 |
+
"Gemma",
|
| 51 |
+
"VideoEncoder",
|
| 52 |
+
"VideoDecoder",
|
| 53 |
+
"AudioEncoder",
|
| 54 |
+
"AudioDecoder",
|
| 55 |
+
"Vocoder",
|
| 56 |
+
"EmbeddingsProcessor",
|
| 57 |
+
"LatentUpsampler",
|
| 58 |
+
):
|
| 59 |
+
if bad in name:
|
| 60 |
+
return False
|
| 61 |
+
try:
|
| 62 |
+
from ltx_core.model.transformer import LTXModelConfigurator
|
| 63 |
+
|
| 64 |
+
if isinstance(cfg, type):
|
| 65 |
+
try:
|
| 66 |
+
if issubclass(cfg, LTXModelConfigurator):
|
| 67 |
+
return True
|
| 68 |
+
except TypeError:
|
| 69 |
+
pass
|
| 70 |
+
if cfg is LTXModelConfigurator:
|
| 71 |
+
return True
|
| 72 |
+
except ImportError:
|
| 73 |
+
pass
|
| 74 |
+
# 兜底:LTX 主 transformer 配置器命名习惯(排除已列出的 VAE/Gemma)
|
| 75 |
+
return "LTX" in name and "ModelConfigurator" in name
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _install_fp8_lora_fusion_patch() -> None:
|
| 79 |
+
"""Make LTX's scaled-FP8 LoRA fusion tolerant of checkpoint layout variants."""
|
| 80 |
+
global _FP8_LORA_PATCH_INSTALLED
|
| 81 |
+
if _FP8_LORA_PATCH_INSTALLED:
|
| 82 |
+
return
|
| 83 |
+
try:
|
| 84 |
+
import ltx_core.loader.fuse_loras as fuse_mod
|
| 85 |
+
except ImportError:
|
| 86 |
+
return
|
| 87 |
+
|
| 88 |
+
_orig_scaled = getattr(fuse_mod, "_fuse_delta_with_scaled_fp8", None)
|
| 89 |
+
if _orig_scaled is None:
|
| 90 |
+
return
|
| 91 |
+
|
| 92 |
+
def _quantize_preserve_layout(tensor: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
| 93 |
+
tensor_fp32 = tensor.to(torch.float32)
|
| 94 |
+
fp8_min = torch.finfo(torch.float8_e4m3fn).min
|
| 95 |
+
fp8_max = torch.finfo(torch.float8_e4m3fn).max
|
| 96 |
+
max_abs = torch.amax(torch.abs(tensor_fp32))
|
| 97 |
+
if max_abs == 0:
|
| 98 |
+
max_abs = torch.ones((), dtype=torch.float32, device=tensor_fp32.device)
|
| 99 |
+
scale = fp8_max / max_abs
|
| 100 |
+
quantized = torch.clamp(tensor_fp32 * scale, min=fp8_min, max=fp8_max).to(torch.float8_e4m3fn)
|
| 101 |
+
return quantized, scale.reciprocal()
|
| 102 |
+
|
| 103 |
+
def _patched_scaled(deltas: torch.Tensor, weight: torch.Tensor, key: str, scale_key: str, model_sd: Any) -> dict[str, torch.Tensor]:
|
| 104 |
+
weight_scale = model_sd.sd[scale_key].to(device=weight.device)
|
| 105 |
+
delta = deltas.to(device=weight.device, dtype=torch.float32)
|
| 106 |
+
weight_fp32 = weight.to(torch.float32)
|
| 107 |
+
|
| 108 |
+
# Standard LTX scaled-FP8 layout: checkpoint stores (in, out), LoRA delta is (out, in).
|
| 109 |
+
normal_layout = weight_fp32.t() * weight_scale
|
| 110 |
+
if normal_layout.shape == delta.shape:
|
| 111 |
+
new_weight = normal_layout + delta
|
| 112 |
+
new_fp8_weight, new_weight_scale = fuse_mod.quantize_weight_to_fp8_per_tensor(new_weight)
|
| 113 |
+
return {key: new_fp8_weight, scale_key: new_weight_scale}
|
| 114 |
+
if normal_layout.shape == delta.t().shape:
|
| 115 |
+
new_weight = normal_layout + delta.t()
|
| 116 |
+
new_fp8_weight, new_weight_scale = fuse_mod.quantize_weight_to_fp8_per_tensor(new_weight)
|
| 117 |
+
return {key: new_fp8_weight, scale_key: new_weight_scale}
|
| 118 |
+
|
| 119 |
+
# Some FP8 checkpoints already arrive in the module/storage layout.
|
| 120 |
+
storage_layout = weight_fp32 * weight_scale
|
| 121 |
+
if storage_layout.shape == delta.shape:
|
| 122 |
+
new_weight = storage_layout + delta
|
| 123 |
+
new_fp8_weight, new_weight_scale = _quantize_preserve_layout(new_weight)
|
| 124 |
+
return {key: new_fp8_weight, scale_key: new_weight_scale}
|
| 125 |
+
if storage_layout.shape == delta.t().shape:
|
| 126 |
+
new_weight = storage_layout + delta.t()
|
| 127 |
+
new_fp8_weight, new_weight_scale = _quantize_preserve_layout(new_weight)
|
| 128 |
+
return {key: new_fp8_weight, scale_key: new_weight_scale}
|
| 129 |
+
|
| 130 |
+
print(
|
| 131 |
+
"[PATCH] FP8 LoRA shape mismatch, skip layer: "
|
| 132 |
+
f"{key}, weight={tuple(weight.shape)}, delta={tuple(deltas.shape)}, "
|
| 133 |
+
f"normal={tuple(normal_layout.shape)}, storage={tuple(storage_layout.shape)}"
|
| 134 |
+
)
|
| 135 |
+
return {}
|
| 136 |
+
|
| 137 |
+
fuse_mod._fuse_delta_with_scaled_fp8 = _patched_scaled
|
| 138 |
+
_FP8_LORA_PATCH_INSTALLED = True
|
| 139 |
+
logger.info("lora_build_hook: 已挂载 scaled-FP8 LoRA 融合兼容补丁")
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def install_lora_build_hook() -> None:
|
| 143 |
+
global _HOOK_INSTALLED
|
| 144 |
+
_install_fp8_lora_fusion_patch()
|
| 145 |
+
if _HOOK_INSTALLED:
|
| 146 |
+
return
|
| 147 |
+
try:
|
| 148 |
+
from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder
|
| 149 |
+
except ImportError:
|
| 150 |
+
logger.warning("lora_build_hook: 无法导入 SingleGPUModelBuilder,跳过")
|
| 151 |
+
return
|
| 152 |
+
|
| 153 |
+
_orig_build = SingleGPUModelBuilder.build
|
| 154 |
+
|
| 155 |
+
def build(self: Any, *args: Any, **kwargs: Any) -> Any:
|
| 156 |
+
extra = _get_pending()
|
| 157 |
+
if extra and _is_ltx_diffusion_transformer_builder(self):
|
| 158 |
+
have = {getattr(x, "path", None) for x in self.loras}
|
| 159 |
+
add = tuple(x for x in extra if getattr(x, "path", None) not in have)
|
| 160 |
+
if add:
|
| 161 |
+
merged = (*tuple(self.loras), *add)
|
| 162 |
+
self = replace(self, loras=merged)
|
| 163 |
+
logger.info(
|
| 164 |
+
"lora_build_hook: 已向 DiT Builder 合并 %d 个用户 LoRA: %s",
|
| 165 |
+
len(add),
|
| 166 |
+
[getattr(x, "path", x) for x in add],
|
| 167 |
+
)
|
| 168 |
+
return _orig_build(self, *args, **kwargs)
|
| 169 |
+
|
| 170 |
+
SingleGPUModelBuilder.build = build # type: ignore[method-assign]
|
| 171 |
+
_HOOK_INSTALLED = True
|
| 172 |
+
logger.info("lora_build_hook: 已挂载 SingleGPUModelBuilder.build")
|
LTX2.3-1.0.4-new/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-1.0.4-new/patches/low_vram_runtime.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 restore_full_vram_config_tweaks(handler: Any) -> None:
|
| 60 |
+
"""显存上限为 0 时恢复速度优先配置。"""
|
| 61 |
+
cfg = getattr(handler, "config", None)
|
| 62 |
+
if cfg is None:
|
| 63 |
+
return
|
| 64 |
+
fm = getattr(cfg, "fast_model", None)
|
| 65 |
+
if fm is None:
|
| 66 |
+
return
|
| 67 |
+
try:
|
| 68 |
+
if hasattr(fm, "model_copy"):
|
| 69 |
+
updated = fm.model_copy(update={"use_upscaler": True})
|
| 70 |
+
setattr(cfg, "fast_model", updated)
|
| 71 |
+
elif hasattr(fm, "use_upscaler"):
|
| 72 |
+
setattr(fm, "use_upscaler", True)
|
| 73 |
+
except Exception as e:
|
| 74 |
+
logger.debug("low_vram: 无法恢复 fast_model.use_upscaler: %s", e)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def install_low_vram_on_pipelines(handler: Any) -> None:
|
| 78 |
+
"""启动时读取偏好,挂到 pipelines 上供各补丁读取。"""
|
| 79 |
+
pl = handler.pipelines
|
| 80 |
+
low = read_low_vram_pref() and should_use_cpu_offload()
|
| 81 |
+
setattr(pl, "low_vram_mode", bool(low))
|
| 82 |
+
if low:
|
| 83 |
+
apply_low_vram_config_tweaks(handler)
|
| 84 |
+
logger.info(
|
| 85 |
+
"low_vram_mode: 已开启(尝试关闭 fast 超分;若显存仍高,多为权重常驻 GPU,需降分辨率/时长或 FP8 权重)"
|
| 86 |
+
)
|
| 87 |
+
else:
|
| 88 |
+
restore_full_vram_config_tweaks(handler)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def install_low_vram_pipeline_hooks(pl: Any) -> None:
|
| 92 |
+
"""在 load_gpu_pipeline / load_a2v 返回后尝试 Diffusers 式 CPU offload(无则静默)。"""
|
| 93 |
+
if getattr(pl, "_ltx_low_vram_hooks_installed", False):
|
| 94 |
+
return
|
| 95 |
+
pl._ltx_low_vram_hooks_installed = True
|
| 96 |
+
|
| 97 |
+
if hasattr(pl, "load_gpu_pipeline"):
|
| 98 |
+
_orig_gpu = pl.load_gpu_pipeline
|
| 99 |
+
pl._ltx_orig_load_gpu_for_low_vram = _orig_gpu
|
| 100 |
+
|
| 101 |
+
def _load_gpu_wrapped(self: Any, *a: Any, **kw: Any) -> Any:
|
| 102 |
+
r = _orig_gpu(*a, **kw)
|
| 103 |
+
if getattr(self, "low_vram_mode", False):
|
| 104 |
+
try_sequential_offload_on_pipeline_state(r)
|
| 105 |
+
return r
|
| 106 |
+
|
| 107 |
+
pl.load_gpu_pipeline = types.MethodType(_load_gpu_wrapped, pl)
|
| 108 |
+
|
| 109 |
+
if hasattr(pl, "load_a2v_pipeline"):
|
| 110 |
+
_orig_a2v = pl.load_a2v_pipeline
|
| 111 |
+
pl._ltx_orig_load_a2v_for_low_vram = _orig_a2v
|
| 112 |
+
|
| 113 |
+
def _load_a2v_wrapped(self: Any, *a: Any, **kw: Any) -> Any:
|
| 114 |
+
r = _orig_a2v(*a, **kw)
|
| 115 |
+
if getattr(self, "low_vram_mode", False):
|
| 116 |
+
try_sequential_offload_on_pipeline_state(r)
|
| 117 |
+
return r
|
| 118 |
+
|
| 119 |
+
pl.load_a2v_pipeline = types.MethodType(_load_a2v_wrapped, pl)
|
| 120 |
+
|
| 121 |
+
# Monkey patch: 接管 1.0.3 新增的底层 layer streaming 来实现完美的线性显存控制
|
| 122 |
+
if not getattr(pl, "_ltx_layer_streaming_patched", False):
|
| 123 |
+
pl._ltx_layer_streaming_patched = True
|
| 124 |
+
try:
|
| 125 |
+
def _patch_pipeline_class(cls_name, mod_name):
|
| 126 |
+
import importlib
|
| 127 |
+
try:
|
| 128 |
+
mod = importlib.import_module(mod_name)
|
| 129 |
+
pipeline_cls = getattr(mod, cls_name)
|
| 130 |
+
_orig_call = pipeline_cls.__call__
|
| 131 |
+
|
| 132 |
+
def _patched_call(self, *args, **kwargs):
|
| 133 |
+
lim = get_vram_limit()
|
| 134 |
+
if lim is not None:
|
| 135 |
+
count = get_streaming_prefetch_count()
|
| 136 |
+
kwargs["streaming_prefetch_count"] = count
|
| 137 |
+
if count is None:
|
| 138 |
+
logger.info(
|
| 139 |
+
"low_vram_mode: VRAM limit is unlimited/high. Disabled layer streaming."
|
| 140 |
+
)
|
| 141 |
+
else:
|
| 142 |
+
logger.info(
|
| 143 |
+
"low_vram_mode: Dynamically tuned layer streaming prefetch count to %s for %sGB limit.",
|
| 144 |
+
count,
|
| 145 |
+
lim,
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
return _orig_call(self, *args, **kwargs)
|
| 149 |
+
|
| 150 |
+
pipeline_cls.__call__ = _patched_call
|
| 151 |
+
logger.info(f"low_vram_mode: Successfully patched {cls_name} to override streaming_prefetch_count")
|
| 152 |
+
except Exception as e:
|
| 153 |
+
pass
|
| 154 |
+
|
| 155 |
+
_patch_pipeline_class("DistilledPipeline", "ltx_pipelines.distilled")
|
| 156 |
+
_patch_pipeline_class("TI2VidTwoStagesPipeline", "ltx_pipelines.ti2vid_two_stages")
|
| 157 |
+
_patch_pipeline_class("LTXRetakePipeline", "services.retake_pipeline.ltx_retake_pipeline")
|
| 158 |
+
_patch_pipeline_class("ICLoRAPipeline", "services.ic_lora_pipeline.ltx_ic_lora_pipeline")
|
| 159 |
+
_patch_pipeline_class("A2VPipeline", "services.a2v_pipeline.distilled_a2v_pipeline")
|
| 160 |
+
except Exception:
|
| 161 |
+
pass
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def get_vram_limit() -> float | None:
|
| 165 |
+
try:
|
| 166 |
+
import json
|
| 167 |
+
from pathlib import Path
|
| 168 |
+
settings_file = _ltx_desktop_config_dir() / "settings.json"
|
| 169 |
+
if settings_file.exists():
|
| 170 |
+
with open(settings_file, "r", encoding="utf-8") as f:
|
| 171 |
+
data = json.load(f)
|
| 172 |
+
if "vram_limit" in data:
|
| 173 |
+
lim = data["vram_limit"]
|
| 174 |
+
if lim != "":
|
| 175 |
+
return float(lim)
|
| 176 |
+
except Exception:
|
| 177 |
+
pass
|
| 178 |
+
return None
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def get_streaming_prefetch_count() -> int | None:
|
| 182 |
+
"""把设置里的显存上限映射为 layer streaming 强度。
|
| 183 |
+
|
| 184 |
+
``0`` 或留空表示纯 GPU / 速度优先,不启用层流式加载。
|
| 185 |
+
"""
|
| 186 |
+
lim = get_vram_limit()
|
| 187 |
+
if lim is None or lim == 0:
|
| 188 |
+
return None
|
| 189 |
+
if lim <= 10.0:
|
| 190 |
+
return 1
|
| 191 |
+
if lim >= 25.0:
|
| 192 |
+
return None
|
| 193 |
+
extra_gb = float(lim) - 10.0
|
| 194 |
+
return max(1, min(32, 1 + round(extra_gb / 0.67)))
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def should_use_cpu_offload() -> bool:
|
| 198 |
+
"""只有设置了大于 0 的显存上限时才启用 CPU/offload 慢速兼容路径。"""
|
| 199 |
+
lim = get_vram_limit()
|
| 200 |
+
return lim is not None and lim > 0
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def try_sequential_offload_on_pipeline_state(state: Any) -> None:
|
| 204 |
+
"""按设定最高显存分配,爆显存后写入系统内存"""
|
| 205 |
+
if state is None:
|
| 206 |
+
return
|
| 207 |
+
if not should_use_cpu_offload():
|
| 208 |
+
logger.info(
|
| 209 |
+
"low_vram_mode: VRAM limit is 0/blank. Skip CPU offload for pure GPU speed."
|
| 210 |
+
)
|
| 211 |
+
return
|
| 212 |
+
root = getattr(state, "pipeline", state)
|
| 213 |
+
candidates: list[Any] = [root]
|
| 214 |
+
inner = getattr(root, "pipeline", None)
|
| 215 |
+
if inner is not None and inner is not root:
|
| 216 |
+
candidates.append(inner)
|
| 217 |
+
|
| 218 |
+
# Capped-VRAM mode applies macro offload so T5/VAE can leave GPU while DiT runs.
|
| 219 |
+
# Pure GPU mode returns above and keeps the old fast path.
|
| 220 |
+
for obj in candidates:
|
| 221 |
+
for method_name in (
|
| 222 |
+
"enable_model_cpu_offload",
|
| 223 |
+
"enable_sequential_cpu_offload",
|
| 224 |
+
):
|
| 225 |
+
fn = getattr(obj, method_name, None)
|
| 226 |
+
if callable(fn):
|
| 227 |
+
try:
|
| 228 |
+
fn()
|
| 229 |
+
logger.info(
|
| 230 |
+
"low_vram_mode: 已对管线调用 %s()",
|
| 231 |
+
method_name,
|
| 232 |
+
)
|
| 233 |
+
return
|
| 234 |
+
except Exception as e:
|
| 235 |
+
logger.debug(
|
| 236 |
+
"low_vram_mode: %s() 失败(可忽略): %s",
|
| 237 |
+
method_name,
|
| 238 |
+
e,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def maybe_release_pipeline_after_task(handler: Any) -> None:
|
| 243 |
+
"""单次生成结束后:低显存模式下强制卸载管线并回收缓存。"""
|
| 244 |
+
pl = getattr(handler, "pipelines", None) or getattr(handler, "_pipelines", None)
|
| 245 |
+
if pl is None or not getattr(pl, "low_vram_mode", False):
|
| 246 |
+
return
|
| 247 |
+
try:
|
| 248 |
+
from keep_models_runtime import force_unload_gpu_pipeline
|
| 249 |
+
|
| 250 |
+
force_unload_gpu_pipeline(pl)
|
| 251 |
+
except Exception as e:
|
| 252 |
+
logger.debug("low_vram_mode: 任务后卸载失败: %s", e)
|
| 253 |
+
try:
|
| 254 |
+
pl._pipeline_signature = None
|
| 255 |
+
except Exception:
|
| 256 |
+
pass
|
| 257 |
+
gc.collect()
|
| 258 |
+
try:
|
| 259 |
+
import torch
|
| 260 |
+
|
| 261 |
+
if torch.cuda.is_available():
|
| 262 |
+
torch.cuda.empty_cache()
|
| 263 |
+
except Exception:
|
| 264 |
+
pass
|
LTX2.3-1.0.4-new/patches/ltx_dev_video_pipeline.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patch-side wrapper for LTX dev checkpoints.
|
| 2 |
+
|
| 3 |
+
The desktop Fast wrapper is built around ``DistilledPipeline``. Dev checkpoints
|
| 4 |
+
need the full TI2V two-stage pipeline; otherwise LoRA keys can match the wrong
|
| 5 |
+
stage shape and fail during FP8 fusion.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from collections.abc import Iterator
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Final
|
| 13 |
+
|
| 14 |
+
import torch
|
| 15 |
+
|
| 16 |
+
from api_types import ImageConditioningInput
|
| 17 |
+
from services.ltx_pipeline_common import (
|
| 18 |
+
default_tiling_config,
|
| 19 |
+
encode_video_output,
|
| 20 |
+
video_chunks_number,
|
| 21 |
+
)
|
| 22 |
+
from services.services_utils import AudioOrNone, TilingConfigType, device_supports_fp8
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class LTXDevVideoPipeline:
|
| 26 |
+
pipeline_kind: Final = "dev"
|
| 27 |
+
|
| 28 |
+
def __init__(
|
| 29 |
+
self,
|
| 30 |
+
checkpoint_path: str,
|
| 31 |
+
gemma_root: str | None,
|
| 32 |
+
upsampler_path: str,
|
| 33 |
+
distilled_lora_path: str,
|
| 34 |
+
device: torch.device,
|
| 35 |
+
loras: list[object] | tuple[object, ...] | None = None,
|
| 36 |
+
) -> None:
|
| 37 |
+
from ltx_core.loader import LoraPathStrengthAndSDOps
|
| 38 |
+
from ltx_core.quantization import QuantizationPolicy
|
| 39 |
+
from ltx_pipelines.ti2vid_two_stages import TI2VidTwoStagesPipeline
|
| 40 |
+
from ltx_pipelines.utils.constants import detect_params
|
| 41 |
+
|
| 42 |
+
self._checkpoint_path = checkpoint_path
|
| 43 |
+
self._device = device
|
| 44 |
+
self._params = detect_params(checkpoint_path)
|
| 45 |
+
|
| 46 |
+
quantization = None
|
| 47 |
+
if "fp8" in checkpoint_path.lower() and device_supports_fp8(device):
|
| 48 |
+
try:
|
| 49 |
+
quantization = QuantizationPolicy.fp8_scaled_mm()
|
| 50 |
+
except Exception as exc:
|
| 51 |
+
print(f"[PATCH] Dev FP8 scaled-mm 不可用,回退 fp8_cast: {exc}")
|
| 52 |
+
quantization = QuantizationPolicy.fp8_cast()
|
| 53 |
+
|
| 54 |
+
distilled_lora = []
|
| 55 |
+
checkpoint_name = Path(checkpoint_path).name.lower()
|
| 56 |
+
distilled_lora_name = Path(distilled_lora_path).name.lower() if distilled_lora_path else ""
|
| 57 |
+
incompatible_builtin_lora = (
|
| 58 |
+
"2.3" in checkpoint_name
|
| 59 |
+
and ("2-19b" in distilled_lora_name or "19b" in distilled_lora_name)
|
| 60 |
+
)
|
| 61 |
+
if incompatible_builtin_lora:
|
| 62 |
+
print(
|
| 63 |
+
"[PATCH] Dev two-stage: 跳过不匹配的内置 distilled LoRA "
|
| 64 |
+
f"({distilled_lora_name}),当前 checkpoint 是 {checkpoint_name}"
|
| 65 |
+
)
|
| 66 |
+
elif distilled_lora_path and Path(distilled_lora_path).is_file():
|
| 67 |
+
distilled_lora = [
|
| 68 |
+
LoraPathStrengthAndSDOps(
|
| 69 |
+
path=distilled_lora_path,
|
| 70 |
+
strength=1.0,
|
| 71 |
+
sd_ops=None,
|
| 72 |
+
)
|
| 73 |
+
]
|
| 74 |
+
elif distilled_lora_path:
|
| 75 |
+
print(
|
| 76 |
+
"[PATCH] Dev two-stage: distilled LoRA 不存在,跳过内置 stage-2 distilled LoRA: "
|
| 77 |
+
f"{distilled_lora_path}"
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
self.pipeline = TI2VidTwoStagesPipeline(
|
| 81 |
+
checkpoint_path=checkpoint_path,
|
| 82 |
+
distilled_lora=distilled_lora,
|
| 83 |
+
spatial_upsampler_path=upsampler_path,
|
| 84 |
+
gemma_root=gemma_root or "",
|
| 85 |
+
loras=tuple(loras or ()),
|
| 86 |
+
device=device,
|
| 87 |
+
quantization=quantization,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
def _run_inference(
|
| 91 |
+
self,
|
| 92 |
+
prompt: str,
|
| 93 |
+
seed: int,
|
| 94 |
+
height: int,
|
| 95 |
+
width: int,
|
| 96 |
+
num_frames: int,
|
| 97 |
+
frame_rate: float,
|
| 98 |
+
images: list[ImageConditioningInput],
|
| 99 |
+
tiling_config: TilingConfigType,
|
| 100 |
+
) -> tuple[torch.Tensor | Iterator[torch.Tensor], AudioOrNone]:
|
| 101 |
+
from ltx_pipelines.utils.args import ImageConditioningInput as _LtxImageInput
|
| 102 |
+
try:
|
| 103 |
+
from low_vram_runtime import get_streaming_prefetch_count
|
| 104 |
+
|
| 105 |
+
streaming_prefetch_count = get_streaming_prefetch_count()
|
| 106 |
+
except Exception:
|
| 107 |
+
streaming_prefetch_count = None
|
| 108 |
+
|
| 109 |
+
params = self._params
|
| 110 |
+
return self.pipeline(
|
| 111 |
+
prompt=prompt,
|
| 112 |
+
negative_prompt="",
|
| 113 |
+
seed=seed,
|
| 114 |
+
height=height,
|
| 115 |
+
width=width,
|
| 116 |
+
num_frames=num_frames,
|
| 117 |
+
frame_rate=frame_rate,
|
| 118 |
+
num_inference_steps=params.num_inference_steps,
|
| 119 |
+
video_guider_params=params.video_guider_params,
|
| 120 |
+
audio_guider_params=params.audio_guider_params,
|
| 121 |
+
images=[_LtxImageInput(img.path, img.frame_idx, img.strength) for img in images],
|
| 122 |
+
tiling_config=tiling_config,
|
| 123 |
+
streaming_prefetch_count=streaming_prefetch_count,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
@torch.inference_mode()
|
| 127 |
+
def generate(
|
| 128 |
+
self,
|
| 129 |
+
prompt: str,
|
| 130 |
+
seed: int,
|
| 131 |
+
height: int,
|
| 132 |
+
width: int,
|
| 133 |
+
num_frames: int,
|
| 134 |
+
frame_rate: float,
|
| 135 |
+
images: list[ImageConditioningInput],
|
| 136 |
+
output_path: str,
|
| 137 |
+
) -> None:
|
| 138 |
+
tiling_config = default_tiling_config()
|
| 139 |
+
video, audio = self._run_inference(
|
| 140 |
+
prompt=prompt,
|
| 141 |
+
seed=seed,
|
| 142 |
+
height=height,
|
| 143 |
+
width=width,
|
| 144 |
+
num_frames=num_frames,
|
| 145 |
+
frame_rate=frame_rate,
|
| 146 |
+
images=images,
|
| 147 |
+
tiling_config=tiling_config,
|
| 148 |
+
)
|
| 149 |
+
chunks = video_chunks_number(num_frames, tiling_config)
|
| 150 |
+
encode_video_output(
|
| 151 |
+
video=video,
|
| 152 |
+
audio=audio,
|
| 153 |
+
fps=int(frame_rate),
|
| 154 |
+
output_path=output_path,
|
| 155 |
+
video_chunks_number_value=chunks,
|
| 156 |
+
)
|
LTX2.3-1.0.4-new/patches/ltx_fp8_video_pipeline.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fast pipeline wrapper for pre-quantized FP8 distilled checkpoints.
|
| 2 |
+
|
| 3 |
+
The stock desktop wrapper uses ``QuantizationPolicy.fp8_cast()``, which is
|
| 4 |
+
meant to cast BF16 checkpoints to FP8 while loading. Pre-quantized FP8
|
| 5 |
+
checkpoints already contain FP8 weights plus weight/input scales, so casting
|
| 6 |
+
them again can produce valid-looking inference that decodes to black frames.
|
| 7 |
+
|
| 8 |
+
This wrapper loads those checkpoints with the scaled-FP8 state-dict/module
|
| 9 |
+
layout. When TensorRT-LLM is not available, its FP8Linear forward falls back
|
| 10 |
+
to a PyTorch dequantize-then-linear path, keeping FP8 storage while avoiding
|
| 11 |
+
black output.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from collections.abc import Iterator
|
| 17 |
+
import os
|
| 18 |
+
from types import SimpleNamespace
|
| 19 |
+
from typing import Any, Final, cast
|
| 20 |
+
|
| 21 |
+
import torch
|
| 22 |
+
from torch import nn
|
| 23 |
+
|
| 24 |
+
from api_types import ImageConditioningInput
|
| 25 |
+
from services.ltx_pipeline_common import (
|
| 26 |
+
default_tiling_config,
|
| 27 |
+
encode_video_output,
|
| 28 |
+
video_chunks_number,
|
| 29 |
+
)
|
| 30 |
+
from services.services_utils import AudioOrNone, TilingConfigType
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
_FP8_FALLBACK_INSTALLED = False
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _install_fp8linear_torch_fallback() -> None:
|
| 37 |
+
global _FP8_FALLBACK_INSTALLED
|
| 38 |
+
if _FP8_FALLBACK_INSTALLED:
|
| 39 |
+
return
|
| 40 |
+
|
| 41 |
+
from ltx_core.quantization.fp8_scaled_mm import FP8Linear
|
| 42 |
+
|
| 43 |
+
def _fallback_forward(self: Any, x: torch.Tensor) -> torch.Tensor:
|
| 44 |
+
weight_scale = self.weight_scale.to(dtype=x.dtype, device=x.device)
|
| 45 |
+
weight = (self.weight.to(dtype=x.dtype) * weight_scale).t().contiguous()
|
| 46 |
+
bias = self.bias
|
| 47 |
+
if bias is not None and bias.dtype != x.dtype:
|
| 48 |
+
bias = bias.to(dtype=x.dtype, device=x.device)
|
| 49 |
+
return torch.nn.functional.linear(x, weight, bias)
|
| 50 |
+
|
| 51 |
+
# TensorRT-LLM is not bundled in LTX Desktop, so use a deterministic PyTorch
|
| 52 |
+
# fallback for scaled FP8 checkpoints instead of silently ignoring scales.
|
| 53 |
+
FP8Linear.forward = _fallback_forward # type: ignore[method-assign]
|
| 54 |
+
_FP8_FALLBACK_INSTALLED = True
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _fp8_layer_names(checkpoint_path: str) -> frozenset[str]:
|
| 58 |
+
from safetensors import safe_open
|
| 59 |
+
|
| 60 |
+
names: set[str] = set()
|
| 61 |
+
with safe_open(checkpoint_path, framework="pt", device="cpu") as handle:
|
| 62 |
+
for key in handle.keys():
|
| 63 |
+
if not key.endswith(".weight_scale"):
|
| 64 |
+
continue
|
| 65 |
+
layer_name = key.removeprefix("model.diffusion_model.").removesuffix(
|
| 66 |
+
".weight_scale"
|
| 67 |
+
)
|
| 68 |
+
if layer_name.startswith("transformer_blocks."):
|
| 69 |
+
names.add(layer_name)
|
| 70 |
+
return frozenset(names)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _scaled_fp8_quantization_policy(checkpoint_path: str) -> Any:
|
| 74 |
+
from ltx_core.loader.module_ops import ModuleOps
|
| 75 |
+
from ltx_core.loader.sd_ops import KeyValueOperationResult, SDOps
|
| 76 |
+
from ltx_core.model.transformer import LTXModel
|
| 77 |
+
from ltx_core.quantization.fp8_scaled_mm import FP8Linear
|
| 78 |
+
|
| 79 |
+
fp8_layers = _fp8_layer_names(checkpoint_path)
|
| 80 |
+
|
| 81 |
+
def transpose_fp8_weight(
|
| 82 |
+
key: str, value: torch.Tensor
|
| 83 |
+
) -> list[KeyValueOperationResult]:
|
| 84 |
+
layer_name = key.removesuffix(".weight")
|
| 85 |
+
if layer_name in fp8_layers and value.dim() == 2:
|
| 86 |
+
return [KeyValueOperationResult(key, value.t())]
|
| 87 |
+
return [KeyValueOperationResult(key, value)]
|
| 88 |
+
|
| 89 |
+
def convert_fp8_layers(model: nn.Module) -> nn.Module:
|
| 90 |
+
if not isinstance(model, LTXModel):
|
| 91 |
+
return model
|
| 92 |
+
replacements: list[tuple[nn.Module, str, nn.Linear]] = []
|
| 93 |
+
for name, module in model.named_modules():
|
| 94 |
+
if name not in fp8_layers or not isinstance(module, nn.Linear):
|
| 95 |
+
continue
|
| 96 |
+
parent_name, attr_name = name.rsplit(".", 1)
|
| 97 |
+
replacements.append((model.get_submodule(parent_name), attr_name, module))
|
| 98 |
+
for parent, attr_name, linear in replacements:
|
| 99 |
+
setattr(
|
| 100 |
+
parent,
|
| 101 |
+
attr_name,
|
| 102 |
+
FP8Linear(
|
| 103 |
+
in_features=linear.in_features,
|
| 104 |
+
out_features=linear.out_features,
|
| 105 |
+
bias=linear.bias is not None,
|
| 106 |
+
device=linear.weight.device,
|
| 107 |
+
),
|
| 108 |
+
)
|
| 109 |
+
return model
|
| 110 |
+
|
| 111 |
+
_install_fp8linear_torch_fallback()
|
| 112 |
+
return SimpleNamespace(
|
| 113 |
+
sd_ops=SDOps("fp8_selected_layers_transpose").with_kv_operation(
|
| 114 |
+
transpose_fp8_weight,
|
| 115 |
+
key_prefix="transformer_blocks.",
|
| 116 |
+
key_suffix=".weight",
|
| 117 |
+
),
|
| 118 |
+
module_ops=(
|
| 119 |
+
ModuleOps(
|
| 120 |
+
name="fp8_prepare_selected_layers_for_loading",
|
| 121 |
+
matcher=lambda model: isinstance(model, LTXModel),
|
| 122 |
+
mutator=convert_fp8_layers,
|
| 123 |
+
),
|
| 124 |
+
),
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class LTXFp8VideoPipeline:
|
| 129 |
+
pipeline_kind: Final = "fast-fp8"
|
| 130 |
+
|
| 131 |
+
@staticmethod
|
| 132 |
+
def create(
|
| 133 |
+
checkpoint_path: str,
|
| 134 |
+
gemma_root: str | None,
|
| 135 |
+
upsampler_path: str,
|
| 136 |
+
device: torch.device,
|
| 137 |
+
) -> "LTXFp8VideoPipeline":
|
| 138 |
+
return LTXFp8VideoPipeline(
|
| 139 |
+
checkpoint_path=checkpoint_path,
|
| 140 |
+
gemma_root=gemma_root,
|
| 141 |
+
upsampler_path=upsampler_path,
|
| 142 |
+
device=device,
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
def __init__(
|
| 146 |
+
self,
|
| 147 |
+
checkpoint_path: str,
|
| 148 |
+
gemma_root: str | None,
|
| 149 |
+
upsampler_path: str,
|
| 150 |
+
device: torch.device,
|
| 151 |
+
**_ignored: Any,
|
| 152 |
+
) -> None:
|
| 153 |
+
from ltx_pipelines.distilled import DistilledPipeline
|
| 154 |
+
|
| 155 |
+
self._checkpoint_path = checkpoint_path
|
| 156 |
+
self._gemma_root = gemma_root
|
| 157 |
+
self._upsampler_path = upsampler_path
|
| 158 |
+
self._device = device
|
| 159 |
+
self._quantization = _scaled_fp8_quantization_policy(checkpoint_path)
|
| 160 |
+
|
| 161 |
+
self.pipeline = DistilledPipeline(
|
| 162 |
+
distilled_checkpoint_path=checkpoint_path,
|
| 163 |
+
gemma_root=cast(str, gemma_root),
|
| 164 |
+
spatial_upsampler_path=upsampler_path,
|
| 165 |
+
loras=[],
|
| 166 |
+
device=device,
|
| 167 |
+
quantization=self._quantization,
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
def _run_inference(
|
| 171 |
+
self,
|
| 172 |
+
prompt: str,
|
| 173 |
+
seed: int,
|
| 174 |
+
height: int,
|
| 175 |
+
width: int,
|
| 176 |
+
num_frames: int,
|
| 177 |
+
frame_rate: float,
|
| 178 |
+
images: list[ImageConditioningInput],
|
| 179 |
+
tiling_config: TilingConfigType,
|
| 180 |
+
) -> tuple[torch.Tensor | Iterator[torch.Tensor], AudioOrNone]:
|
| 181 |
+
from ltx_pipelines.utils.args import ImageConditioningInput as _LtxImageInput
|
| 182 |
+
|
| 183 |
+
return self.pipeline(
|
| 184 |
+
prompt=prompt,
|
| 185 |
+
seed=seed,
|
| 186 |
+
height=height,
|
| 187 |
+
width=width,
|
| 188 |
+
num_frames=num_frames,
|
| 189 |
+
frame_rate=frame_rate,
|
| 190 |
+
images=[
|
| 191 |
+
_LtxImageInput(img.path, img.frame_idx, img.strength)
|
| 192 |
+
for img in images
|
| 193 |
+
],
|
| 194 |
+
tiling_config=tiling_config,
|
| 195 |
+
streaming_prefetch_count=2,
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
@torch.inference_mode()
|
| 199 |
+
def generate(
|
| 200 |
+
self,
|
| 201 |
+
prompt: str,
|
| 202 |
+
seed: int,
|
| 203 |
+
height: int,
|
| 204 |
+
width: int,
|
| 205 |
+
num_frames: int,
|
| 206 |
+
frame_rate: float,
|
| 207 |
+
images: list[ImageConditioningInput],
|
| 208 |
+
output_path: str,
|
| 209 |
+
) -> None:
|
| 210 |
+
tiling_config = default_tiling_config()
|
| 211 |
+
video, audio = self._run_inference(
|
| 212 |
+
prompt=prompt,
|
| 213 |
+
seed=seed,
|
| 214 |
+
height=height,
|
| 215 |
+
width=width,
|
| 216 |
+
num_frames=num_frames,
|
| 217 |
+
frame_rate=frame_rate,
|
| 218 |
+
images=images,
|
| 219 |
+
tiling_config=tiling_config,
|
| 220 |
+
)
|
| 221 |
+
chunks = video_chunks_number(num_frames, tiling_config)
|
| 222 |
+
encode_video_output(
|
| 223 |
+
video=video,
|
| 224 |
+
audio=audio,
|
| 225 |
+
fps=int(frame_rate),
|
| 226 |
+
output_path=output_path,
|
| 227 |
+
video_chunks_number_value=chunks,
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
@torch.inference_mode()
|
| 231 |
+
def warmup(self, output_path: str) -> None:
|
| 232 |
+
warmup_frames = 9
|
| 233 |
+
tiling_config = default_tiling_config()
|
| 234 |
+
|
| 235 |
+
try:
|
| 236 |
+
video, audio = self._run_inference(
|
| 237 |
+
prompt="test warmup",
|
| 238 |
+
seed=42,
|
| 239 |
+
height=256,
|
| 240 |
+
width=384,
|
| 241 |
+
num_frames=warmup_frames,
|
| 242 |
+
frame_rate=8,
|
| 243 |
+
images=[],
|
| 244 |
+
tiling_config=tiling_config,
|
| 245 |
+
)
|
| 246 |
+
chunks = video_chunks_number(warmup_frames, tiling_config)
|
| 247 |
+
encode_video_output(
|
| 248 |
+
video=video,
|
| 249 |
+
audio=audio,
|
| 250 |
+
fps=8,
|
| 251 |
+
output_path=output_path,
|
| 252 |
+
video_chunks_number_value=chunks,
|
| 253 |
+
)
|
| 254 |
+
finally:
|
| 255 |
+
if os.path.exists(output_path):
|
| 256 |
+
os.unlink(output_path)
|
| 257 |
+
|
| 258 |
+
def compile_transformer(self) -> None:
|
| 259 |
+
from ltx_pipelines.distilled import DistilledPipeline
|
| 260 |
+
|
| 261 |
+
self.pipeline = DistilledPipeline(
|
| 262 |
+
distilled_checkpoint_path=self._checkpoint_path,
|
| 263 |
+
gemma_root=cast(str, self._gemma_root),
|
| 264 |
+
spatial_upsampler_path=self._upsampler_path,
|
| 265 |
+
loras=[],
|
| 266 |
+
device=self._device,
|
| 267 |
+
quantization=self._quantization,
|
| 268 |
+
torch_compile=True,
|
| 269 |
+
)
|
LTX2.3-1.0.4-new/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-1.0.4-new/patches/settings.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"use_torch_compile": false,
|
| 3 |
+
"load_on_startup": false,
|
| 4 |
+
"ltx_api_key": "",
|
| 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 |
+
"lora_dir": ""
|
| 23 |
+
}
|
LTX2.3-1.0.4-new/patches/tts_worker.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Standalone TTS worker process for VoxCPM-based generation."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
# === [核心修复] 彻底封印 PyTorch 的所有动态编译机制 ===
|
| 6 |
+
import os
|
| 7 |
+
# 1. 禁用 Dynamo 编译器 (PyTorch 2.x)
|
| 8 |
+
os.environ["TORCH_COMPILE_DISABLE"] = "1"
|
| 9 |
+
# 2. 禁用 TorchScript JIT 编译器 (解决 nvrtc 报错)
|
| 10 |
+
os.environ["PYTORCH_JIT"] = "0"
|
| 11 |
+
# 3. 禁用底层算子融合器 NvFuser
|
| 12 |
+
os.environ["NVFUSER_DISABLE"] = "1"
|
| 13 |
+
|
| 14 |
+
import torch
|
| 15 |
+
import torch._dynamo
|
| 16 |
+
torch._dynamo.config.disable = True
|
| 17 |
+
|
| 18 |
+
# 如果环境支持,强行在代码层关闭 nvfuser
|
| 19 |
+
try:
|
| 20 |
+
if hasattr(torch._C, '_jit_set_nvfuser_enabled'):
|
| 21 |
+
torch._C._jit_set_nvfuser_enabled(False)
|
| 22 |
+
except Exception:
|
| 23 |
+
pass
|
| 24 |
+
# ==============================================================
|
| 25 |
+
|
| 26 |
+
import argparse
|
| 27 |
+
import json
|
| 28 |
+
import tempfile
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
import soundfile as sf
|
| 33 |
+
|
| 34 |
+
_MODEL_CACHE: dict[str, object] = {}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _to_1d_float32(audio: np.ndarray) -> np.ndarray:
|
| 40 |
+
arr = np.asarray(audio)
|
| 41 |
+
orig_dtype = arr.dtype
|
| 42 |
+
|
| 43 |
+
if arr.ndim == 0:
|
| 44 |
+
arr = arr.reshape(1)
|
| 45 |
+
elif arr.ndim == 2:
|
| 46 |
+
# Prefer channel-average while keeping the time axis.
|
| 47 |
+
if arr.shape[0] <= 8 and arr.shape[1] > arr.shape[0]:
|
| 48 |
+
arr = arr.mean(axis=0)
|
| 49 |
+
else:
|
| 50 |
+
arr = arr.mean(axis=1)
|
| 51 |
+
elif arr.ndim > 2:
|
| 52 |
+
arr = np.squeeze(arr)
|
| 53 |
+
if arr.ndim != 1:
|
| 54 |
+
arr = arr.reshape(-1)
|
| 55 |
+
|
| 56 |
+
if np.issubdtype(orig_dtype, np.integer):
|
| 57 |
+
scale = float(max(abs(np.iinfo(orig_dtype).min), np.iinfo(orig_dtype).max))
|
| 58 |
+
arr = arr.astype(np.float32) / max(scale, 1.0)
|
| 59 |
+
else:
|
| 60 |
+
arr = arr.astype(np.float32, copy=False)
|
| 61 |
+
|
| 62 |
+
arr = np.nan_to_num(arr, nan=0.0, posinf=0.0, neginf=0.0)
|
| 63 |
+
if arr.size == 0:
|
| 64 |
+
return np.zeros(1, dtype=np.float32)
|
| 65 |
+
|
| 66 |
+
# Remove obvious DC offset.
|
| 67 |
+
arr = arr - float(np.mean(arr))
|
| 68 |
+
return arr
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _resample_linear(audio: np.ndarray, src_sr: int, dst_sr: int) -> np.ndarray:
|
| 72 |
+
if src_sr == dst_sr:
|
| 73 |
+
return audio
|
| 74 |
+
if audio.size <= 1:
|
| 75 |
+
return audio
|
| 76 |
+
dst_len = max(1, int(round(audio.size * float(dst_sr) / float(src_sr))))
|
| 77 |
+
x_old = np.arange(audio.size, dtype=np.float64)
|
| 78 |
+
x_new = np.linspace(0.0, float(audio.size - 1), dst_len, dtype=np.float64)
|
| 79 |
+
out = np.interp(x_new, x_old, audio.astype(np.float64))
|
| 80 |
+
return out.astype(np.float32)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _read_audio_any(path: str) -> tuple[np.ndarray, int]:
|
| 84 |
+
try:
|
| 85 |
+
data, sr = sf.read(path, always_2d=False)
|
| 86 |
+
return np.asarray(data), int(sr)
|
| 87 |
+
except Exception:
|
| 88 |
+
try:
|
| 89 |
+
import librosa
|
| 90 |
+
except Exception as exc:
|
| 91 |
+
raise RuntimeError(
|
| 92 |
+
"参考音频无法解码(建议上传 WAV,或安装 librosa 以支持更多格式)"
|
| 93 |
+
) from exc
|
| 94 |
+
data, sr = librosa.load(path, sr=None, mono=False)
|
| 95 |
+
return np.asarray(data), int(sr)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _prepare_reference_audio(
|
| 99 |
+
path: str, out_dir: Path, target_sr: int, stem: str
|
| 100 |
+
) -> str:
|
| 101 |
+
data, sr = _read_audio_any(path)
|
| 102 |
+
mono = _to_1d_float32(data)
|
| 103 |
+
mono = _resample_linear(mono, sr, target_sr)
|
| 104 |
+
|
| 105 |
+
peak = float(np.max(np.abs(mono))) if mono.size else 0.0
|
| 106 |
+
if peak > 0:
|
| 107 |
+
mono = mono / peak * 0.95
|
| 108 |
+
|
| 109 |
+
out_path = out_dir / f"{stem}.wav"
|
| 110 |
+
sf.write(str(out_path), mono, target_sr, subtype="PCM_16")
|
| 111 |
+
return str(out_path)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _normalize_generated_audio(wav: object) -> np.ndarray:
|
| 115 |
+
if hasattr(wav, "detach") and callable(getattr(wav, "detach")):
|
| 116 |
+
wav = wav.detach().cpu().numpy()
|
| 117 |
+
arr = _to_1d_float32(np.asarray(wav))
|
| 118 |
+
|
| 119 |
+
peak = float(np.max(np.abs(arr))) if arr.size else 0.0
|
| 120 |
+
if peak <= 1e-9:
|
| 121 |
+
return np.zeros(1, dtype=np.float32)
|
| 122 |
+
|
| 123 |
+
# Prevent clipping/noise if model output scale drifts.
|
| 124 |
+
if peak > 1.0:
|
| 125 |
+
arr = arr / peak
|
| 126 |
+
arr = np.clip(arr, -0.98, 0.98)
|
| 127 |
+
return arr.astype(np.float32)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _get_model(model_dir: str):
|
| 131 |
+
if model_dir not in _MODEL_CACHE:
|
| 132 |
+
from voxcpm import VoxCPM
|
| 133 |
+
|
| 134 |
+
_MODEL_CACHE[model_dir] = VoxCPM.from_pretrained(model_dir, load_denoiser=False)
|
| 135 |
+
return _MODEL_CACHE[model_dir]
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def run_generate(req: dict[str, object]) -> dict[str, object]:
|
| 139 |
+
text = str(req.get("text") or "").strip()
|
| 140 |
+
if not text:
|
| 141 |
+
raise RuntimeError("text 不能为空")
|
| 142 |
+
|
| 143 |
+
mode = str(req.get("mode") or "text_only").strip() or "text_only"
|
| 144 |
+
model_dir = str(req.get("model_dir") or "").strip()
|
| 145 |
+
output_dir = Path(str(req.get("output_dir") or ".")).resolve()
|
| 146 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 147 |
+
|
| 148 |
+
cfg_value = float(req.get("cfg_value") or 2.0)
|
| 149 |
+
inference_timesteps = int(req.get("inference_timesteps") or 10)
|
| 150 |
+
|
| 151 |
+
model = _get_model(model_dir)
|
| 152 |
+
sample_rate = int(getattr(getattr(model, "tts_model", None), "sample_rate", 24000))
|
| 153 |
+
|
| 154 |
+
ref_in = req.get("reference_wav_path")
|
| 155 |
+
prompt_in = req.get("prompt_wav_path")
|
| 156 |
+
prompt_text = str(req.get("prompt_text") or "")
|
| 157 |
+
|
| 158 |
+
temp_dir = Path(tempfile.mkdtemp(prefix="ltx_tts_"))
|
| 159 |
+
ref_ready = None
|
| 160 |
+
prompt_ready = None
|
| 161 |
+
try:
|
| 162 |
+
if isinstance(ref_in, str) and ref_in.strip():
|
| 163 |
+
ref_ready = _prepare_reference_audio(
|
| 164 |
+
ref_in.strip(), temp_dir, sample_rate, "reference"
|
| 165 |
+
)
|
| 166 |
+
if isinstance(prompt_in, str) and prompt_in.strip():
|
| 167 |
+
prompt_ready = _prepare_reference_audio(
|
| 168 |
+
prompt_in.strip(), temp_dir, sample_rate, "prompt"
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
if mode in {"clone", "ultimate_clone"} and not ref_ready:
|
| 172 |
+
raise RuntimeError("克隆模式必须提供参考音频")
|
| 173 |
+
|
| 174 |
+
gen_kwargs: dict[str, object] = {
|
| 175 |
+
"text": text,
|
| 176 |
+
"cfg_value": cfg_value,
|
| 177 |
+
"inference_timesteps": inference_timesteps,
|
| 178 |
+
}
|
| 179 |
+
if mode == "clone":
|
| 180 |
+
gen_kwargs["reference_wav_path"] = ref_ready
|
| 181 |
+
elif mode == "ultimate_clone":
|
| 182 |
+
gen_kwargs["reference_wav_path"] = ref_ready
|
| 183 |
+
if prompt_ready:
|
| 184 |
+
gen_kwargs["prompt_wav_path"] = prompt_ready
|
| 185 |
+
if prompt_text:
|
| 186 |
+
gen_kwargs["prompt_text"] = prompt_text
|
| 187 |
+
|
| 188 |
+
wav = model.generate(**gen_kwargs)
|
| 189 |
+
out = _normalize_generated_audio(wav)
|
| 190 |
+
|
| 191 |
+
import uuid
|
| 192 |
+
|
| 193 |
+
fname = f"tts_{uuid.uuid4().hex[:8]}.wav"
|
| 194 |
+
out_path = output_dir / fname
|
| 195 |
+
sf.write(str(out_path), out, sample_rate, subtype="PCM_16")
|
| 196 |
+
return {"status": "complete", "audio_path": fname, "sample_rate": sample_rate}
|
| 197 |
+
finally:
|
| 198 |
+
try:
|
| 199 |
+
for p in temp_dir.glob("*"):
|
| 200 |
+
try:
|
| 201 |
+
p.unlink()
|
| 202 |
+
except Exception:
|
| 203 |
+
pass
|
| 204 |
+
temp_dir.rmdir()
|
| 205 |
+
except Exception:
|
| 206 |
+
pass
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def main() -> int:
|
| 210 |
+
parser = argparse.ArgumentParser()
|
| 211 |
+
parser.add_argument("--request-json", required=True, help="Path to request json")
|
| 212 |
+
args = parser.parse_args()
|
| 213 |
+
|
| 214 |
+
req_path = Path(args.request_json)
|
| 215 |
+
req = json.loads(req_path.read_text(encoding="utf-8"))
|
| 216 |
+
result = run_generate(req)
|
| 217 |
+
print(json.dumps(result, ensure_ascii=False))
|
| 218 |
+
return 0
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
if __name__ == "__main__":
|
| 222 |
+
raise SystemExit(main())
|
LTX2.3-1.0.4-new/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
|
LTX2.3-1.0.4-new/使用说明-Installation Methods/Installation Methods.txt
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
=== Installation Method ===
|
| 2 |
+
|
| 3 |
+
1. Install the LTX.exe installer.
|
| 4 |
+
|
| 5 |
+
2. When you open the software, it will automatically download the environment package. If your network is slow, it is recommended to download the environment package from the cloud drive and extract it to:
|
| 6 |
+
C:\Users\<Username>\AppData\Local
|
| 7 |
+
|
| 8 |
+
3. When you open the software, a window will pop up asking you to set the model directory. Just set it (if you encounter any issues, refer to Issue 1).
|
| 9 |
+
|
| 10 |
+
4. After running the software, you should be able to generate images and videos normally (if you encounter any issues, refer to Issue 2).
|
| 11 |
+
|
| 12 |
+
5. Open the plugin and replace the shortcuts in LTX_Shortcut with your own. Then run run.bat to start.
|
| 13 |
+
|
| 14 |
+
6. Model directory structure: The default 'loras' directory is placed in the root of the model directory.
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
=== Issue 1: How to Reset API and Model Directory ===
|
| 18 |
+
|
| 19 |
+
Open the directory (make sure to enable hidden files and folders):
|
| 20 |
+
C:\Users\<Username>\AppData\Local\LTXDesktop\settings.json
|
| 21 |
+
|
| 22 |
+
Original: "fal_api_key": "xxxxx"
|
| 23 |
+
Change to: "fal_api_key": ""
|
| 24 |
+
|
| 25 |
+
Reopen the software to apply the changes.
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
=== Issue 2: API Mode or Unable to Generate Video ===
|
| 29 |
+
|
| 30 |
+
1. If the video memory is less than 24GB and you are forced to use the API, here's the solution:
|
| 31 |
+
Copy the runtime_policy.py and paste it into the following directory to replace the old file:
|
| 32 |
+
C:\Program Files\LTX Desktop\resources\backend\runtime_config\
|
| 33 |
+
|
| 34 |
+
2. To reset the model directory:
|
| 35 |
+
Open the directory (make sure to enable hidden files and folders):
|
| 36 |
+
C:\Users\<Username>\AppData\Local\LTXDesktop\settings.json
|
| 37 |
+
|
| 38 |
+
Original: "fal_api_key": "xxxxx"
|
| 39 |
+
Change to: "fal_api_key": ""
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
=== Issue 3: TTS Cannot Be Used ===
|
| 43 |
+
|
| 44 |
+
If you are using the environment package from the cloud drive, there should be no errors. If you upgraded from an old version, copy and run the following commands via command line:
|
| 45 |
+
|
| 46 |
+
# 0. Navigate to the LTX Python directory (this uses environment variables to adapt to any user's computer)
|
| 47 |
+
cd $env:LOCALAPPDATA\LTXDesktop\python
|
| 48 |
+
|
| 49 |
+
# 1. Download the official pip installation script
|
| 50 |
+
Invoke-WebRequest -Uri https://bootstrap.pypa.io/get-pip.py -OutFile get-pip.py
|
| 51 |
+
|
| 52 |
+
# 2. Use LTX's Python to run this script to fix/update pip
|
| 53 |
+
.\python.exe get-pip.py
|
| 54 |
+
|
| 55 |
+
# 3. Temporarily set the MSVC compiler environment variable to UTF-8 (to prevent the C++ encoding issue we encountered earlier)
|
| 56 |
+
$env:CL="/utf-8"
|
| 57 |
+
|
| 58 |
+
# 4. Use LTX's Python to install editdistance
|
| 59 |
+
.\python.exe -m pip install editdistance
|
| 60 |
+
|
| 61 |
+
# 5. [Core Defense] Use LTX's Python to install the main package, forcing the use of the GPU mirror source to avoid overwriting
|
| 62 |
+
.\python.exe -m pip install voxcpm --extra-index-url https://download.pytorch.org/whl/cu118
|
LTX2.3-1.0.4-new/使用说明-Installation Methods/runtime_policy.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Runtime policy decisions for forced API mode."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def decide_force_api_generations(system: str, cuda_available: bool, vram_gb: int | None) -> bool:
|
| 7 |
+
"""Return whether API-only generation must be forced for this runtime."""
|
| 8 |
+
if system == "Darwin":
|
| 9 |
+
return True
|
| 10 |
+
|
| 11 |
+
if system in ("Windows", "Linux"):
|
| 12 |
+
if not cuda_available:
|
| 13 |
+
return True
|
| 14 |
+
if vram_gb is None:
|
| 15 |
+
return True
|
| 16 |
+
return vram_gb < 6
|
| 17 |
+
|
| 18 |
+
# Fail closed for non-target platforms unless explicitly relaxed.
|
| 19 |
+
return True
|
LTX2.3-1.0.4-new/使用说明-Installation Methods/说明.txt
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
===安装方法====
|
| 2 |
+
|
| 3 |
+
1. 安装LTX.exe的安装程序
|
| 4 |
+
|
| 5 |
+
2. 打开软件会自动下载环境包,如果网络很差的,推荐下载网盘环境包解压到:
|
| 6 |
+
C:\Users\<用户名>AppData\Local
|
| 7 |
+
|
| 8 |
+
3. 打开软件会弹出设置模型目录,设置就可以了(遇到问题看问题1)
|
| 9 |
+
|
| 10 |
+
4. 运行软件可以正常生图和生视频(遇到问题看问题2)
|
| 11 |
+
|
| 12 |
+
5. 打开插件,将LTX_Shortcut中的快捷方式替换成自己的,运行run.bat就可以了
|
| 13 |
+
|
| 14 |
+
6. 模型目录结构:默认的loras目录放在模型根目录中
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
===问题1:如何重置API和模型目录===
|
| 18 |
+
|
| 19 |
+
打开目录(注意用户名是自己的,需要设置显示隐藏文件夹):
|
| 20 |
+
|
| 21 |
+
C:\Users\<用户名>\AppData\Local\LTXDesktop\settings.json
|
| 22 |
+
|
| 23 |
+
原: "fal_api_key": "xxxxx"
|
| 24 |
+
改为: "fal_api_key": ""
|
| 25 |
+
|
| 26 |
+
重新打开软件就可以了。
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
===问题2:API模式或无法生成视频===
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
1. 显存低于24G,强制使用API的解决办法:
|
| 33 |
+
复制runtime_policy.py,粘贴到以下目录替换:
|
| 34 |
+
C:\Program Files\LTX Desktop\resources\backend\runtime_config\
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
2. 想要重新设置模型目录:
|
| 38 |
+
打开目录(注意用户名是自己的,需要设置显示隐藏文件夹):
|
| 39 |
+
C:\Users\<用户名>\AppData\Local\LTXDesktop\settings.json
|
| 40 |
+
|
| 41 |
+
原: "fal_api_key": "xxxxx"
|
| 42 |
+
改为: "fal_api_key": ""
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
===问题3:TTS无法使用===
|
| 46 |
+
|
| 47 |
+
如果使用的是网盘的环境包一般不会报错,如果是从旧版升级而来,则复制并用命令行运行以下命令:
|
| 48 |
+
|
| 49 |
+
# 0. 进入 LTX Python 目录 (使用环境变量,自动适配任何用户的电脑)
|
| 50 |
+
cd $env:LOCALAPPDATA\LTXDesktop\python
|
| 51 |
+
|
| 52 |
+
# 1. 下载官方的 pip 安装脚本
|
| 53 |
+
Invoke-WebRequest -Uri https://bootstrap.pypa.io/get-pip.py -OutFile get-pip.py
|
| 54 |
+
|
| 55 |
+
# 2. 用 LTX 的 Python 运行这个脚本来修复/更新 pip
|
| 56 |
+
.\python.exe get-pip.py
|
| 57 |
+
|
| 58 |
+
# 3. 临时设置 MSVC 编译器的环境变量为 UTF-8 (以防又遇到刚才的 C++ 乱码)
|
| 59 |
+
$env:CL="/utf-8"
|
| 60 |
+
|
| 61 |
+
# 4. 指定用 LTX 的 Python 安装 editdistance
|
| 62 |
+
.\python.exe -m pip install editdistance
|
| 63 |
+
|
| 64 |
+
# 5. 【核心防御】指定用 LTX 的 Python 安装主包,并强制指定 GPU 镜像源防覆盖
|
| 65 |
+
.\python.exe -m pip install voxcpm --extra-index-url https://download.pytorch.org/whl/cu118
|