dx8152 commited on
Commit
a1e8f21
·
verified ·
1 Parent(s): 785a022

Upload 34 files

Browse files
Files changed (34) hide show
  1. LTX2.3-1.0.4-new/API issues-API问题办法.txt +50 -0
  2. LTX2.3-1.0.4-new/LTX_Shortcut/LTX Desktop.lnk +0 -0
  3. LTX2.3-1.0.4-new/UI/i18n.js +646 -0
  4. LTX2.3-1.0.4-new/UI/index.css +982 -0
  5. LTX2.3-1.0.4-new/UI/index.html +604 -0
  6. LTX2.3-1.0.4-new/UI/index.js +0 -0
  7. LTX2.3-1.0.4-new/main.py +266 -0
  8. LTX2.3-1.0.4-new/patches/API模式问题修复说明.md +41 -0
  9. LTX2.3-1.0.4-new/patches/__pycache__/api_types.cpython-313.pyc +0 -0
  10. LTX2.3-1.0.4-new/patches/__pycache__/app_factory.cpython-313.pyc +3 -0
  11. LTX2.3-1.0.4-new/patches/__pycache__/keep_models_runtime.cpython-313.pyc +0 -0
  12. LTX2.3-1.0.4-new/patches/__pycache__/lora_build_hook.cpython-313.pyc +0 -0
  13. LTX2.3-1.0.4-new/patches/__pycache__/lora_injection.cpython-313.pyc +0 -0
  14. LTX2.3-1.0.4-new/patches/__pycache__/low_vram_runtime.cpython-313.pyc +0 -0
  15. LTX2.3-1.0.4-new/patches/__pycache__/ltx_dev_video_pipeline.cpython-313.pyc +0 -0
  16. LTX2.3-1.0.4-new/patches/__pycache__/ltx_fp8_video_pipeline.cpython-313.pyc +0 -0
  17. LTX2.3-1.0.4-new/patches/__pycache__/tts_worker.cpython-313.pyc +0 -0
  18. LTX2.3-1.0.4-new/patches/api_types.py +403 -0
  19. LTX2.3-1.0.4-new/patches/app_factory.py +0 -0
  20. LTX2.3-1.0.4-new/patches/app_settings_patch.py +22 -0
  21. LTX2.3-1.0.4-new/patches/handlers/__pycache__/video_generation_handler.cpython-313.pyc +0 -0
  22. LTX2.3-1.0.4-new/patches/handlers/video_generation_handler.py +882 -0
  23. LTX2.3-1.0.4-new/patches/keep_models_runtime.py +16 -0
  24. LTX2.3-1.0.4-new/patches/launcher.py +20 -0
  25. LTX2.3-1.0.4-new/patches/lora_build_hook.py +172 -0
  26. LTX2.3-1.0.4-new/patches/lora_injection.py +139 -0
  27. LTX2.3-1.0.4-new/patches/low_vram_runtime.py +264 -0
  28. LTX2.3-1.0.4-new/patches/ltx_dev_video_pipeline.py +156 -0
  29. LTX2.3-1.0.4-new/patches/ltx_fp8_video_pipeline.py +269 -0
  30. LTX2.3-1.0.4-new/patches/runtime_policy.py +21 -0
  31. LTX2.3-1.0.4-new/patches/settings.json +23 -0
  32. LTX2.3-1.0.4-new/patches/tts_worker.py +222 -0
  33. LTX2.3-1.0.4-new/run.bat +38 -0
  34. LTX2.3-1.0.4-new/安装TTS环境.txt +17 -0
LTX2.3-1.0.4-new/API issues-API问题办法.txt ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 1. 复制LTX桌面版的快捷方式到LTX_Shortcut
2
+
3
+ 2. 运行run.bat
4
+ ----
5
+ 1. Copy the LTX desktop shortcut to LTX_Shortcut
6
+
7
+ 2. Run run.bat
8
+ ----
9
+
10
+
11
+
12
+ 【问题描述 / Problem】
13
+ 系统强制使用 FAL API 生成图片,即使本地有 GPU 可用。
14
+ System forces FAL API generation even when local GPU is available.
15
+
16
+ 【原因 / Cause】
17
+ LTX 强制要求 GPU 有 31GB VRAM 才会使用本地显卡,低于此值会强制走 API 模式。
18
+ LTX requires 31GB VRAM to use local GPU. Below this, it forces API mode.
19
+
20
+ ================================================================================
21
+ 【修复方法 / Fix Method】
22
+ ================================================================================
23
+
24
+ 运行: API issues.bat.bat (以管理员身份)
25
+ Run: API issues.bat.bat (as Administrator)
26
+
27
+ ================================================================================
28
+ ================================================================================
29
+
30
+ 【或者手动 / Or Manual】
31
+
32
+ 1. 修改 VRAM 阈值 / Modify VRAM Threshold
33
+ 文件路径 / File: C:\Program Files\LTX Desktop\resources\backend\runtime_config\runtime_policy.py
34
+ 第16行 / Line 16:
35
+ 原 / Original: return vram_gb < 31
36
+ 改为 / Change: return vram_gb < 6
37
+
38
+ 2. 清空 API Key / Clear API Key
39
+ 文件路径 / File: C:\Users\<用户名>\AppData\Local\LTXDesktop\settings.json
40
+ 原 / Original: "fal_api_key": "xxxxx"
41
+ 改为 / Change: "fal_api_key": ""
42
+
43
+ 【说明 / Note】
44
+ - VRAM 阈值改为 6GB,意味着 6GB 及以上显存都会使用本地显卡
45
+ - VRAM threshold set to 6GB means 6GB+ VRAM will use local GPU
46
+ - 清空 fal_api_key 避免系统误判为已配置 API
47
+ - Clear fal_api_key to avoid system thinking API is configured
48
+ - 修改后重启程序即可生效
49
+ - Restart LTX Desktop after changes
50
+ ================================================================================
LTX2.3-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,982 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ /* 解码/加载完成前避免视频黑块猛闪,与卡片底色一致;就绪后淡入 */
669
+ .history-card .history-thumb-media {
670
+ opacity: 0;
671
+ transition: opacity 0.28s ease;
672
+ }
673
+ .history-card .history-thumb-media.history-thumb-ready {
674
+ opacity: 1;
675
+ }
676
+ .history-type-badge {
677
+ position: absolute; top: 5px; left: 5px; font-size: 8px; padding: 1px 5px; border-radius: 3px;
678
+ background: rgba(0,0,0,0.8); color: var(--text-sub); border: 1px solid rgba(255,255,255,0.06);
679
+ z-index: 2; font-weight: 700; letter-spacing: 0.4px;
680
+ }
681
+ .history-delete-btn {
682
+ position: absolute; top: 5px; right: 5px; width: 20px; height: 20px;
683
+ border-radius: 50%; border: none; background: rgba(255,50,50,0.8); color: #fff;
684
+ font-size: 10px; cursor: pointer; z-index: 3; display: flex; align-items: center; justify-content: center;
685
+ opacity: 0; transition: opacity 0.2s;
686
+ }
687
+ .history-card:hover .history-delete-btn { opacity: 1; }
688
+ .history-delete-btn:hover { background: rgba(255,0,0,0.9); }
689
+ .vram-bar { width: 160px; height: 5px; background: rgba(255,255,255,0.08); border-radius: 999px; overflow: hidden; display: inline-block; vertical-align: middle; }
690
+ .vram-used { height: 100%; background: var(--accent); width: 0%; transition: width 0.5s; }
691
+
692
+ /* 智能多帧:工作流模式卡片式单选 */
693
+ .smart-param-mode-label {
694
+ font-size: 10px;
695
+ color: var(--text-dim);
696
+ font-weight: 700;
697
+ margin-bottom: 8px;
698
+ letter-spacing: 0.04em;
699
+ text-transform: uppercase;
700
+ }
701
+ .smart-param-modes {
702
+ display: flex;
703
+ flex-direction: row;
704
+ align-items: stretch;
705
+ gap: 0;
706
+ padding: 3px;
707
+ margin-bottom: 12px;
708
+ background: var(--panel-2);
709
+ border-radius: 8px;
710
+ border: 1px solid var(--border);
711
+ }
712
+ .smart-param-mode-opt {
713
+ display: flex;
714
+ align-items: center;
715
+ justify-content: center;
716
+ flex: 1;
717
+ min-width: 0;
718
+ gap: 0;
719
+ margin: 0;
720
+ padding: 6px 8px;
721
+ border-radius: 6px;
722
+ border: none;
723
+ background: transparent;
724
+ cursor: pointer;
725
+ transition: background 0.15s, color 0.15s;
726
+ position: relative;
727
+ }
728
+ .smart-param-mode-opt:hover:not(:has(input:checked)) {
729
+ background: rgba(255, 255, 255, 0.05);
730
+ }
731
+ .smart-param-mode-opt input[type="radio"] {
732
+ position: absolute;
733
+ opacity: 0;
734
+ width: 0;
735
+ height: 0;
736
+ margin: 0;
737
+ }
738
+ .smart-param-mode-opt:has(input:checked) {
739
+ background: var(--accent);
740
+ box-shadow: none;
741
+ }
742
+ .smart-param-mode-opt:has(input:checked) .smart-param-mode-title {
743
+ color: #fff;
744
+ }
745
+ .smart-param-mode-title {
746
+ font-size: 11px;
747
+ font-weight: 600;
748
+ color: var(--text-sub);
749
+ text-align: center;
750
+ line-height: 1.25;
751
+ flex: none;
752
+ min-width: 0;
753
+ }
754
+ /* 单次多关键帧:时间轴面板 */
755
+ .batch-kf-panel {
756
+ background: var(--item);
757
+ border-radius: 10px;
758
+ padding: 12px 14px;
759
+ margin-bottom: 10px;
760
+ border: 1px solid var(--border);
761
+ }
762
+ .batch-kf-panel-hd {
763
+ display: flex;
764
+ flex-wrap: wrap;
765
+ align-items: center;
766
+ justify-content: space-between;
767
+ gap: 10px;
768
+ margin-bottom: 8px;
769
+ }
770
+ .batch-kf-panel-title {
771
+ font-size: 12px;
772
+ font-weight: 700;
773
+ color: var(--text);
774
+ }
775
+ .batch-kf-total-pill {
776
+ font-size: 11px;
777
+ color: var(--text-sub);
778
+ background: var(--panel-2);
779
+ border: 1px solid var(--border);
780
+ border-radius: 999px;
781
+ padding: 6px 12px;
782
+ white-space: nowrap;
783
+ }
784
+ .batch-kf-total-pill strong {
785
+ color: var(--accent);
786
+ font-weight: 800;
787
+ font-variant-numeric: tabular-nums;
788
+ margin: 0 2px;
789
+ }
790
+ .batch-kf-total-unit {
791
+ font-size: 10px;
792
+ color: var(--text-dim);
793
+ }
794
+ .batch-kf-panel-hint {
795
+ font-size: 10px;
796
+ color: var(--text-dim);
797
+ line-height: 1.5;
798
+ margin: 0 0 12px;
799
+ }
800
+ .batch-kf-timeline-col {
801
+ display: flex;
802
+ flex-direction: column;
803
+ gap: 0;
804
+ }
805
+ .batch-kf-kcard {
806
+ border-radius: 10px;
807
+ border: 1px solid var(--border);
808
+ background: rgba(255, 255, 255, 0.03);
809
+ padding: 10px 12px;
810
+ }
811
+ .batch-kf-kcard-head {
812
+ display: flex;
813
+ align-items: center;
814
+ gap: 12px;
815
+ margin-bottom: 10px;
816
+ }
817
+ .batch-kf-kthumb {
818
+ width: 48px;
819
+ height: 48px;
820
+ border-radius: 8px;
821
+ object-fit: cover;
822
+ flex-shrink: 0;
823
+ border: 1px solid var(--border);
824
+ }
825
+ .batch-kf-kcard-titles {
826
+ display: flex;
827
+ flex-direction: column;
828
+ gap: 4px;
829
+ min-width: 0;
830
+ }
831
+ .batch-kf-ktitle {
832
+ font-size: 12px;
833
+ font-weight: 700;
834
+ color: var(--text);
835
+ }
836
+ .batch-kf-anchor {
837
+ font-size: 11px;
838
+ color: var(--accent);
839
+ font-variant-numeric: tabular-nums;
840
+ font-weight: 600;
841
+ }
842
+ .batch-kf-kcard-ctrl {
843
+ display: flex;
844
+ flex-wrap: wrap;
845
+ align-items: center;
846
+ gap: 12px;
847
+ }
848
+ .batch-kf-klabel {
849
+ font-size: 10px;
850
+ color: var(--text-dim);
851
+ display: flex;
852
+ align-items: center;
853
+ gap: 8px;
854
+ }
855
+ .batch-kf-klabel input[type="number"] {
856
+ width: 72px;
857
+ padding: 6px 8px;
858
+ font-size: 12px;
859
+ border-radius: 6px;
860
+ border: 1px solid var(--border);
861
+ background: var(--panel);
862
+ color: var(--text);
863
+ }
864
+ /* 关键帧之间:细时间轴 + 单行紧凑间隔输入 */
865
+ .batch-kf-gap {
866
+ display: flex;
867
+ align-items: stretch;
868
+ gap: 8px;
869
+ padding: 0 0 6px;
870
+ margin: 0 0 0 10px;
871
+ }
872
+ .batch-kf-gap-rail {
873
+ width: 2px;
874
+ flex-shrink: 0;
875
+ border-radius: 2px;
876
+ background: linear-gradient(
877
+ 180deg,
878
+ rgba(255, 255, 255, 0.06),
879
+ var(--accent-dim),
880
+ rgba(255, 255, 255, 0.04)
881
+ );
882
+ min-height: 22px;
883
+ align-self: stretch;
884
+ }
885
+ .batch-kf-gap-inner {
886
+ display: flex;
887
+ align-items: center;
888
+ gap: 8px;
889
+ flex: 1;
890
+ min-width: 0;
891
+ padding: 2px 0 4px;
892
+ }
893
+ .batch-kf-gap-ix {
894
+ font-size: 10px;
895
+ font-weight: 600;
896
+ color: var(--text-dim);
897
+ font-variant-numeric: tabular-nums;
898
+ letter-spacing: -0.02em;
899
+ flex-shrink: 0;
900
+ }
901
+ .batch-kf-seg-field {
902
+ display: inline-flex;
903
+ align-items: center;
904
+ gap: 3px;
905
+ margin: 0;
906
+ cursor: text;
907
+ }
908
+ .batch-kf-seg-input {
909
+ width: 46px;
910
+ min-width: 0;
911
+ padding: 2px 5px;
912
+ font-size: 11px;
913
+ font-weight: 600;
914
+ line-height: 1.3;
915
+ border-radius: 4px;
916
+ border: 1px solid var(--border);
917
+ background: rgba(0, 0, 0, 0.2);
918
+ color: var(--text);
919
+ font-variant-numeric: tabular-nums;
920
+ }
921
+ .batch-kf-seg-input:hover {
922
+ border-color: rgba(255, 255, 255, 0.12);
923
+ }
924
+ .batch-kf-seg-input:focus {
925
+ outline: none;
926
+ border-color: var(--accent);
927
+ box-shadow: 0 0 0 1px var(--accent-ring);
928
+ }
929
+ .batch-kf-gap-unit {
930
+ font-size: 10px;
931
+ color: var(--text-dim);
932
+ font-weight: 500;
933
+ flex-shrink: 0;
934
+ }
935
+
936
+ .sub-mode-toggle { display: flex; background: var(--panel-2); border-radius: 7px; padding: 3px; border: 1px solid var(--border); }
937
+ .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; }
938
+ .sub-mode-btn.active { background: var(--accent); color: #fff; }
939
+ .sub-mode-btn:hover:not(.active) { background: rgba(255,255,255,0.05); color: var(--text-sub); }
940
+
941
+ .vid-section { display: none; margin-top: 12px; }
942
+ .vid-section.active-section { display: block; animation: fadeIn 0.25s ease; }
943
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
944
+
945
+ /* Status indicator */
946
+ @keyframes breathe-orange {
947
+ 0%,100% { box-shadow: 0 0 4px #FF9F0A; opacity: 0.7; }
948
+ 50% { box-shadow: 0 0 10px #FF9F0A; opacity: 1; }
949
+ }
950
+ .indicator-busy { background: #FF9F0A !important; animation: breathe-orange 1.6s infinite ease-in-out !important; box-shadow: none !important; transition: all 0.3s; }
951
+ .indicator-ready { background: #30D158 !important; box-shadow: 0 0 8px rgba(48,209,88,0.6) !important; animation: none !important; transition: all 0.3s; }
952
+ .indicator-offline { background: #636366 !important; box-shadow: none !important; animation: none !important; transition: all 0.3s; }
953
+
954
+ .res-preview-tag { font-size: 11px; color: var(--accent); margin-bottom: 10px; font-family: ui-monospace, monospace; }
955
+ .top-status { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-dim); margin-bottom: 8px; align-items: center; }
956
+ .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); }
957
+ .checkbox-container input { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; margin: 0; }
958
+ .checkbox-container label { margin-bottom: 0; cursor: pointer; text-transform: none; color: var(--text); }
959
+ .flex-row { display: flex; gap: 10px; }
960
+ .flex-1 { flex: 1; min-width: 0; }
961
+
962
+ @media (max-width: 1024px) {
963
+ body { flex-direction: column; overflow-y: auto; }
964
+ .sidebar { width: 100%; min-width: 100%; border-right: none; border-bottom: 1px solid var(--border); height: auto; overflow: visible; }
965
+ .workspace { height: auto; min-height: 100vh; overflow: visible; }
966
+ }
967
+ :root {
968
+ --plyr-color-main: #3F51B5;
969
+ --plyr-video-control-background-hover: rgba(255,255,255,0.1);
970
+ --plyr-control-radius: 6px;
971
+ --plyr-player-width: 100%;
972
+ }
973
+ .plyr {
974
+ border-radius: 8px;
975
+ overflow: hidden;
976
+ width: 100%;
977
+ height: 100%;
978
+ }
979
+ .plyr--video .plyr__controls {
980
+ background: linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0.8));
981
+ padding: 20px 15px 15px 15px;
982
+ }
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 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/安装TTS环境.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 0. 进入 LTX Python 目录 (使用环境变量,自动适配任何用户的电脑)
2
+ cd $env:LOCALAPPDATA\LTXDesktop\python
3
+
4
+ # 1. 下载官方的 pip 安装脚本
5
+ Invoke-WebRequest -Uri https://bootstrap.pypa.io/get-pip.py -OutFile get-pip.py
6
+
7
+ # 2. 用 LTX 的 Python 运行这个脚本来修复/更新 pip
8
+ .\python.exe get-pip.py
9
+
10
+ # 3. 临时设置 MSVC 编译器的环境变量为 UTF-8 (以防又遇到刚才的 C++ 乱码)
11
+ $env:CL="/utf-8"
12
+
13
+ # 4. 指定用 LTX 的 Python 安装 editdistance
14
+ .\python.exe -m pip install editdistance
15
+
16
+ # 5. 【核心防御】指定用 LTX 的 Python 安装主包,并强制指定 GPU 镜像源防覆盖
17
+ .\python.exe -m pip install voxcpm --extra-index-url https://download.pytorch.org/whl/cu118