dx8152 commited on
Commit
ec8ed77
·
verified ·
1 Parent(s): c76292b

Upload 28 files

Browse files
LTX2.3/API issues-API问题办法.bat ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ chcp 65001 >nul
3
+ title LTX 本地显卡模式修复工具
4
+
5
+ echo ========================================
6
+ echo LTX 本地显卡模式修复工具
7
+ echo ========================================
8
+ echo.
9
+
10
+ :: 检查管理员权限
11
+ net session >nul 2>&1
12
+ if %errorlevel% neq 0 (
13
+ echo [!] 请右键选择"以管理员身份运行"此脚本
14
+ pause
15
+ exit /b 1
16
+ )
17
+
18
+ echo [1/2] 正在修改 VRAM 阈值...
19
+ set "policy_file=C:\Program Files\LTX Desktop\resources\backend\runtime_config\runtime_policy.py"
20
+
21
+ if exist "%policy_file%" (
22
+ powershell -Command "(Get-Content '%policy_file%') -replace 'vram_gb < 31', 'vram_gb < 6' | Set-Content '%policy_file%'"
23
+ echo ^_^ VRAM 阈值已修改为 6GB
24
+ ) else (
25
+ echo [!] 未找到 runtime_policy.py,请确认 LTX Desktop 已安装
26
+ )
27
+
28
+ echo.
29
+ echo [2/2] 正在清空 API Key...
30
+ set "settings_file=%USERPROFILE%\AppData\Local\LTXDesktop\settings.json"
31
+
32
+ if exist "%settings_file%" (
33
+ powershell -Command "$content = Get-Content '%settings_file%' -Raw; $content = $content -replace '\"fal_api_key\": \"[^\"]*\"', '\"fal_api_key\": \"\"'; Set-Content -Path '%settings_file%' -Value $content -NoNewline"
34
+ echo ^_^ API Key 已清空
35
+ ) else (
36
+ echo [!] 未找到 settings.json,首次运行后会自动创建
37
+ )
38
+
39
+ echo.
40
+ echo ========================================
41
+ echo 修复完成!请重启 LTX Desktop
42
+ echo ========================================
43
+ pause
LTX2.3/API issues-API问题办法.txt ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 1. 复制LTX桌面版的快捷方式到LTX_Shortcut
2
+
3
+ 2. 运行run.bat
4
+ ----
5
+ 1. Copy the LTX desktop shortcut to LTX_Shortcut
6
+
7
+ 2. Run run.bat
8
+ ----
9
+
10
+
11
+
12
+ 【问题描述 / Problem】
13
+ 系统强制使用 FAL API 生成图片,即使本地有 GPU 可用。
14
+ System forces FAL API generation even when local GPU is available.
15
+
16
+ 【原因 / Cause】
17
+ LTX 强制要求 GPU 有 31GB VRAM 才会使用本地显卡,低于此值会强制走 API 模式。
18
+ LTX requires 31GB VRAM to use local GPU. Below this, it forces API mode.
19
+
20
+ ================================================================================
21
+ 【修复方法 / Fix Method】
22
+ ================================================================================
23
+
24
+ 运行: API issues.bat.bat (以管理员身份)
25
+ Run: API issues.bat.bat (as Administrator)
26
+
27
+ ================================================================================
28
+ ================================================================================
29
+
30
+ 【或者手动 / Or Manual】
31
+
32
+ 1. 修改 VRAM 阈值 / Modify VRAM Threshold
33
+ 文件路径 / File: C:\Program Files\LTX Desktop\resources\backend\runtime_config\runtime_policy.py
34
+ 第16行 / Line 16:
35
+ 原 / Original: return vram_gb < 31
36
+ 改为 / Change: return vram_gb < 6
37
+
38
+ 2. 清空 API Key / Clear API Key
39
+ 文件路径 / File: C:\Users\<用户名>\AppData\Local\LTXDesktop\settings.json
40
+ 原 / Original: "fal_api_key": "xxxxx"
41
+ 改为 / Change: "fal_api_key": ""
42
+
43
+ 【说明 / Note】
44
+ - VRAM 阈值改为 6GB,意味着 6GB 及以上显存都会使用本地显卡
45
+ - VRAM threshold set to 6GB means 6GB+ VRAM will use local GPU
46
+ - 清空 fal_api_key 避免系统误判为已配置 API
47
+ - Clear fal_api_key to avoid system thinking API is configured
48
+ - 修改后重启程序即可生效
49
+ - Restart LTX Desktop after changes
50
+ ================================================================================
LTX2.3/LTX_Shortcut/LTX Desktop.lnk ADDED
Binary file (1.94 kB). View file
 
LTX2.3/UI/i18n.js ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * LTX UI i18n — 与根目录「中英文.html」思路类似,但独立脚本、避免坏 DOM/错误路径。
3
+ * 仅维护文案映射;动态节点由 index.js 在语言切换后刷新。
4
+ */
5
+ (function (global) {
6
+ const STORAGE_KEY = 'ltx_ui_lang';
7
+
8
+ const STR = {
9
+ zh: {
10
+ tabVideo: '视频生成',
11
+ tabBatch: '智能多帧',
12
+ tabUpscale: '视频增强',
13
+ tabImage: '图像生成',
14
+ promptLabel: '视觉描述词 (Prompt)',
15
+ promptPlaceholder: '在此输入视觉描述词 (Prompt)...',
16
+ promptPlaceholderUpscale: '输入画面增强引导词 (可选)...',
17
+ clearVram: '释放显存',
18
+ clearingVram: '清理中...',
19
+ settingsTitle: '系统高级设置',
20
+ langToggleAriaZh: '切换为 English',
21
+ langToggleAriaEn: 'Switch to 中文',
22
+ sysScanning: '正在扫描 GPU...',
23
+ sysBusy: '运算中...',
24
+ sysOnline: '在线 / 就绪',
25
+ sysStarting: '启动中...',
26
+ sysOffline: '未检测到后端 (Port 3000)',
27
+ advancedSettings: '高级设置',
28
+ deviceSelect: '工作设备选择',
29
+ gpuDetecting: '正在检测 GPU...',
30
+ outputPath: '输出与上传存储路径',
31
+ outputPathPh: '例如: D:\\LTX_outputs',
32
+ savePath: '保存路径',
33
+ outputPathHint:
34
+ '系统默认会在 C 盘保留输出文件。请输入新路径后点击保存按钮。',
35
+ lowVram: '低显存优化',
36
+ lowVramDesc:
37
+ '尽量关闭 fast 超分、在加载管线后尝试 CPU 分层卸载(仅当引擎提供 Diffusers 式 API 才可能生效)。每次生成结束会卸载管线。说明:整模型常驻 GPU 时占用仍可能接近满配(例如约 24GB),要明显降占用需更短时长/更低分辨率或 FP8 等小权重。',
38
+ modelLoraSettings: '模型与LoRA设置',
39
+ modelFolder: '模型文件夹',
40
+ modelFolderPh: '例如: F:\\LTX2.3\\models',
41
+ loraFolder: 'LoRA文件夹',
42
+ loraFolderPh: '例如: F:\\LTX2.3\\loras',
43
+ saveScan: '保存并扫描',
44
+ loraPlacementHintWithDir:
45
+ '将 LoRA 文件放到默认模型目录: <code>{dir}</code>\\loras',
46
+ basicEngine: '基础画面 / Basic EngineSpecs',
47
+ qualityLevel: '清晰度级别',
48
+ aspectRatio: '画幅比例',
49
+ ratio169: '16:9 电影宽幅',
50
+ ratio916: '9:16 移动竖屏',
51
+ resPreviewPrefix: '最终发送规格',
52
+ fpsLabel: '帧率 (FPS)',
53
+ durationLabel: '时长 (秒)',
54
+ cameraMotion: '镜头运动方式',
55
+ motionStatic: 'Static (静止机位)',
56
+ motionDollyIn: 'Dolly In (推近)',
57
+ motionDollyOut: 'Dolly Out (拉远)',
58
+ motionDollyLeft: 'Dolly Left (向左)',
59
+ motionDollyRight: 'Dolly Right (向右)',
60
+ motionJibUp: 'Jib Up (升臂)',
61
+ motionJibDown: 'Jib Down (降臂)',
62
+ motionFocus: 'Focus Shift (焦点)',
63
+ audioGen: '生成 AI 环境音 (Audio Gen)',
64
+ selectModel: '选择模型',
65
+ selectLora: '选择 LoRA',
66
+ defaultModel: '使用默认模型',
67
+ noLora: '不使用 LoRA',
68
+ loraStrength: 'LoRA 强度',
69
+ genSource: '生成媒介 / Generation Source',
70
+ startFrame: '起始帧 (首帧)',
71
+ endFrame: '结束帧 (尾帧)',
72
+ uploadStart: '上传首帧',
73
+ uploadEnd: '上传尾帧 (可选)',
74
+ refAudio: '参考音频 (A2V)',
75
+ uploadAudio: '点击上传音频',
76
+ sourceHint:
77
+ '💡 若仅上传首帧 = 图生视频/音视频;若同时上传首尾帧 = 首尾插帧。',
78
+ imgPreset: '预设分辨率 (Presets)',
79
+ imgOptSquare: '1:1 Square (1024x1024)',
80
+ imgOptLand: '16:9 Landscape (1280x720)',
81
+ imgOptPort: '9:16 Portrait (720x1280)',
82
+ imgOptCustom: 'Custom 自定义...',
83
+ width: '宽度',
84
+ height: '高度',
85
+ samplingSteps: '采样步数 (Steps)',
86
+ upscaleSource: '待超分视频 (Source)',
87
+ upscaleUpload: '拖入低分辨率视频片段',
88
+ targetRes: '目标分辨率',
89
+ upscale1080: '1080P Full HD (2x)',
90
+ upscale720: '720P HD',
91
+ smartMultiFrameGroup: '智能多帧',
92
+ workflowModeLabel: '工作流模式(点击切换)',
93
+ wfSingle: '单次多关键帧',
94
+ wfSegments: '分段拼接',
95
+ uploadImages: '上传图片',
96
+ uploadMulti1: '点击或拖入多张图片',
97
+ uploadMulti2: '支持一次选多张,可多次添加',
98
+ batchStripTitle: '已选图片 · 顺序 = 播放先后',
99
+ batchStripHint: '在缩略图上按住拖动���序;松手落入虚线框位置',
100
+ batchFfmpegHint:
101
+ '💡 <strong>分段模式</strong>:2 张 = 1 段;3 张 = 2 段再拼接。<strong>单次模式</strong>:几张图就几个 latent 锚点,一条视频出片。<br>多段需 <code style="font-size:9px;">ffmpeg</code>:装好后加 PATH,或设环境变量 <code style="font-size:9px;">LTX_FFMPEG_PATH</code>,或在 <code style="font-size:9px;">%LOCALAPPDATA%\\LTXDesktop\\ffmpeg_path.txt</code> 第一行写 ffmpeg.exe 完整路径。',
102
+ globalPromptLabel: '本页全局补充词(可选)',
103
+ globalPromptPh: '与顶部主 Prompt 叠加;单次模式与分段模式均可用',
104
+ bgmLabel: '成片配乐(可选,统一音轨)',
105
+ bgmUploadHint: '上传一条完整 BGM(生成完成后会替换整段成片的音轨)',
106
+ mainRender: '开始渲染',
107
+ waitingTask: '等待分配渲染任务...',
108
+ libHistory: '历史资产 / ASSETS',
109
+ libLog: '系统日志 / LOGS',
110
+ refresh: '刷新',
111
+ logReady: '> LTX-2 Studio Ready. Expecting commands...',
112
+ resizeHandleTitle: '拖动调整面板高度',
113
+ batchNeedTwo: '💡 请上传至少2张图片',
114
+ batchSegTitle: '视频片段设置(分段拼接)',
115
+ batchSegClip: '片段',
116
+ batchSegDuration: '时长',
117
+ batchSegSec: '秒',
118
+ batchSegPrompt: '片段提示词',
119
+ batchSegPromptPh: '此片段的提示词,如:跳舞、吃饭...',
120
+ batchKfPanelTitle: '单次多关键帧 · 时间轴',
121
+ batchTotalDur: '总时长',
122
+ batchTotalSec: '秒',
123
+ batchPanelHint:
124
+ '用「间隔」连接相邻关键帧:第 1 张固定在 0 s,最后一张在<strong>各间隔之和</strong>的终点。顶部总时长与每张的锚点时刻会随间隔即时刷新。因后端按<strong>整数秒</strong>建序列,实际请求里的整段时长为合计秒数<strong>向上取整</strong>(至少 2),略长于小数合计时属正常。镜头与 FPS 仍用左侧「视频生成」。',
125
+ batchKfTitle: '关键帧',
126
+ batchStrength: '引导强度',
127
+ batchGapTitle: '间隔',
128
+ batchSec: '秒',
129
+ batchAnchorStart: '片头',
130
+ batchAnchorEnd: '片尾',
131
+ batchThumbDrag: '按住拖动排序',
132
+ batchThumbRemove: '删除',
133
+ batchAddMore: '+ 继续添加',
134
+ batchGapInputTitle: '上一关键帧到下一关键帧的时长(秒);总时长 = 各间隔之和',
135
+ batchStrengthTitle: '与 Comfy guide strength 类似,中间帧可调低(如 0.2)减轻闪烁',
136
+ batchTotalPillTitle: '等于下方各「间隔」之和,无需单独填写',
137
+ defaultPath: '默认路径',
138
+ phase_loading_model: '加载权重',
139
+ phase_encoding_text: 'T5 编码',
140
+ phase_validating_request: '校验请求',
141
+ phase_uploading_audio: '上传音频',
142
+ phase_uploading_image: '上传图像',
143
+ phase_inference: 'AI 推理',
144
+ phase_downloading_output: '下载结果',
145
+ phase_complete: '完成',
146
+ gpuBusyPrefix: 'GPU 运算中',
147
+ progressStepUnit: '步',
148
+ loaderGpuAlloc: 'GPU 正在分配资源...',
149
+ warnGenerating: '⚠️ 当前正在生成中,请等待完成',
150
+ warnBatchPrompt: '⚠️ 智能多帧请至少填写:顶部主提示词、本页全局补充词或某一「片段提示词」',
151
+ warnNeedPrompt: '⚠️ 请输入提示词后再开始渲染',
152
+ warnVideoLong: '⚠️ 时长设定为 {n}s 极长,可能导致显存溢出或耗时较久。',
153
+ errUpscaleNoVideo: '请先上传待超分的视频',
154
+ errBatchMinImages: '请上传至少2张图片',
155
+ errSingleKfPrompt: '单次多关键帧请至少填写顶部主提示词或本页全局补充词',
156
+ loraNoneLabel: '无',
157
+ modelDefaultLabel: '默认',
158
+ },
159
+ en: {
160
+ tabVideo: 'Video',
161
+ tabBatch: 'Multi-frame',
162
+ tabUpscale: 'Upscale',
163
+ tabImage: 'Image',
164
+ promptLabel: 'Prompt',
165
+ promptPlaceholder: 'Describe the scene...',
166
+ promptPlaceholderUpscale: 'Optional guidance for enhancement...',
167
+ clearVram: 'Clear VRAM',
168
+ clearingVram: 'Clearing...',
169
+ settingsTitle: 'Advanced settings',
170
+ langToggleAriaZh: 'Switch to English',
171
+ langToggleAriaEn: 'Switch to Chinese',
172
+ sysScanning: 'Scanning GPU...',
173
+ sysBusy: 'Busy...',
174
+ sysOnline: 'Online / Ready',
175
+ sysStarting: 'Starting...',
176
+ sysOffline: 'Backend offline (port 3000)',
177
+ advancedSettings: 'Advanced',
178
+ deviceSelect: 'GPU device',
179
+ gpuDetecting: 'Detecting GPU...',
180
+ outputPath: 'Output & upload folder',
181
+ outputPathPh: 'e.g. D:\\LTX_outputs',
182
+ savePath: 'Save path',
183
+ outputPathHint:
184
+ 'Outputs default to C: drive. Enter a folder and click Save.',
185
+ lowVram: 'Low-VRAM mode',
186
+ lowVramDesc:
187
+ 'Tries to reduce VRAM (engine-dependent). Shorter duration / lower resolution helps more.',
188
+ modelLoraSettings: 'Model & LoRA folders',
189
+ modelFolder: 'Models folder',
190
+ modelFolderPh: 'e.g. F:\\LTX2.3\\models',
191
+ loraFolder: 'LoRAs folder',
192
+ loraFolderPh: 'e.g. F:\\LTX2.3\\loras',
193
+ saveScan: 'Save & scan',
194
+ loraHint: 'Put .safetensors / .ckpt LoRAs here, then refresh lists.',
195
+ basicEngine: 'Basic / Engine',
196
+ qualityLevel: 'Quality',
197
+ aspectRatio: 'Aspect ratio',
198
+ ratio169: '16:9 widescreen',
199
+ ratio916: '9:16 portrait',
200
+ resPreviewPrefix: 'Output',
201
+ fpsLabel: 'FPS',
202
+ durationLabel: 'Duration (s)',
203
+ cameraMotion: 'Camera motion',
204
+ motionStatic: 'Static',
205
+ motionDollyIn: 'Dolly in',
206
+ motionDollyOut: 'Dolly out',
207
+ motionDollyLeft: 'Dolly left',
208
+ motionDollyRight: 'Dolly right',
209
+ motionJibUp: 'Jib up',
210
+ motionJibDown: 'Jib down',
211
+ motionFocus: 'Focus shift',
212
+ audioGen: 'AI ambient audio',
213
+ selectModel: 'Model',
214
+ selectLora: 'LoRA',
215
+ defaultModel: 'Default model',
216
+ noLora: 'No LoRA',
217
+ loraStrength: 'LoRA strength',
218
+ genSource: 'Source media',
219
+ startFrame: 'Start frame',
220
+ endFrame: 'End frame (optional)',
221
+ uploadStart: 'Upload start',
222
+ uploadEnd: 'Upload end (opt.)',
223
+ refAudio: 'Reference audio (A2V)',
224
+ uploadAudio: 'Upload audio',
225
+ sourceHint:
226
+ '💡 Start only = I2V / A2V; start + end = interpolation.',
227
+ imgPreset: 'Resolution presets',
228
+ imgOptSquare: '1:1 (1024×1024)',
229
+ imgOptLand: '16:9 (1280×720)',
230
+ imgOptPort: '9:16 (720×1280)',
231
+ imgOptCustom: 'Custom...',
232
+ width: 'Width',
233
+ height: 'Height',
234
+ samplingSteps: 'Steps',
235
+ upscaleSource: 'Source video',
236
+ upscaleUpload: 'Drop low-res video',
237
+ targetRes: 'Target resolution',
238
+ upscale1080: '1080p Full HD (2×)',
239
+ upscale720: '720p HD',
240
+ smartMultiFrameGroup: 'Smart multi-frame',
241
+ workflowModeLabel: 'Workflow',
242
+ wfSingle: 'Single pass',
243
+ wfSegments: 'Segments',
244
+ uploadImages: 'Upload images',
245
+ uploadMulti1: 'Click or drop multiple images',
246
+ uploadMulti2: 'Multi-select OK; add more anytime.',
247
+ batchStripTitle: 'Order = playback',
248
+ batchStripHint: 'Drag thumbnails to reorder.',
249
+ batchFfmpegHint:
250
+ '💡 <strong>Segments</strong>: 2 images → 1 clip; 3 → 2 clips stitched. <strong>Single</strong>: N images → N latent anchors, one video.<br>Stitching needs <code style="font-size:9px;">ffmpeg</code> on PATH, or <code style="font-size:9px;">LTX_FFMPEG_PATH</code>, or <code style="font-size:9px;">%LOCALAPPDATA%\\LTXDesktop\\ffmpeg_path.txt</code> with full path to ffmpeg.exe.',
251
+ globalPromptLabel: 'Extra prompt (optional)',
252
+ globalPromptPh: 'Appended to main prompt for both modes.',
253
+ bgmLabel: 'Full-length BGM (optional)',
254
+ bgmUploadHint: 'Replaces final mix audio after generation.',
255
+ mainRender: 'Render',
256
+ waitingTask: 'Waiting for task...',
257
+ libHistory: 'Assets',
258
+ libLog: 'Logs',
259
+ refresh: 'Refresh',
260
+ logReady: '> LTX-2 Studio ready.',
261
+ resizeHandleTitle: 'Drag to resize panel',
262
+ batchNeedTwo: '💡 Upload at least 2 images',
263
+ batchSegTitle: 'Segment settings',
264
+ batchSegClip: 'Clip',
265
+ batchSegDuration: 'Duration',
266
+ batchSegSec: 's',
267
+ batchSegPrompt: 'Prompt',
268
+ batchSegPromptPh: 'e.g. dancing, walking...',
269
+ batchKfPanelTitle: 'Single pass · timeline',
270
+ batchTotalDur: 'Total',
271
+ batchTotalSec: 's',
272
+ batchPanelHint:
273
+ 'Use gaps between keyframes: first at 0s, last at the sum of gaps. Totals update live. Backend uses whole seconds (ceil, min 2). Motion & FPS use the Video panel.',
274
+ batchKfTitle: 'Keyframe',
275
+ batchStrength: 'Strength',
276
+ batchGapTitle: 'Gap',
277
+ batchSec: 's',
278
+ batchAnchorStart: 'start',
279
+ batchAnchorEnd: 'end',
280
+ batchThumbDrag: 'Drag to reorder',
281
+ batchThumbRemove: 'Remove',
282
+ batchAddMore: '+ Add more',
283
+ batchGapInputTitle: 'Seconds between keyframes; total = sum of gaps',
284
+ batchStrengthTitle: 'Guide strength (lower on middle keys may reduce flicker)',
285
+ batchTotalPillTitle: 'Equals the sum of gaps below',
286
+ defaultPath: 'default',
287
+ phase_loading_model: 'Loading weights',
288
+ phase_encoding_text: 'T5 encode',
289
+ phase_validating_request: 'Validating',
290
+ phase_uploading_audio: 'Uploading audio',
291
+ phase_uploading_image: 'Uploading image',
292
+ phase_inference: 'Inference',
293
+ phase_downloading_output: 'Downloading',
294
+ phase_complete: 'Done',
295
+ gpuBusyPrefix: 'GPU',
296
+ progressStepUnit: 'steps',
297
+ loaderGpuAlloc: 'Allocating GPU...',
298
+ warnGenerating: '⚠️ Already generating, please wait.',
299
+ warnBatchPrompt: '⚠️ Enter main prompt, page extra prompt, or a segment prompt.',
300
+ warnNeedPrompt: '⚠️ Enter a prompt first.',
301
+ warnVideoLong: '⚠️ Duration {n}s is very long; may OOM or take a long time.',
302
+ errUpscaleNoVideo: 'Upload a video to upscale first.',
303
+ errBatchMinImages: 'Upload at least 2 images.',
304
+ errSingleKfNeedPrompt: 'Enter main or page extra prompt for single-pass keyframes.',
305
+ loraNoneLabel: 'none',
306
+ modelDefaultLabel: 'default',
307
+ loraPlacementHintWithDir:
308
+ 'Place LoRAs into the default models directory: <code>{dir}</code>\\loras',
309
+ },
310
+ };
311
+
312
+ function getLang() {
313
+ return localStorage.getItem(STORAGE_KEY) === 'en' ? 'en' : 'zh';
314
+ }
315
+
316
+ function setLang(lang) {
317
+ const L = lang === 'en' ? 'en' : 'zh';
318
+ localStorage.setItem(STORAGE_KEY, L);
319
+ document.documentElement.lang = L === 'en' ? 'en' : 'zh-CN';
320
+ try {
321
+ applyI18n();
322
+ } catch (err) {
323
+ console.error('[i18n] applyI18n failed:', err);
324
+ }
325
+ updateLangButton();
326
+ if (typeof global.onUiLanguageChanged === 'function') {
327
+ try {
328
+ global.onUiLanguageChanged();
329
+ } catch (e) {
330
+ console.warn('onUiLanguageChanged', e);
331
+ }
332
+ }
333
+ }
334
+
335
+ function t(key) {
336
+ const L = getLang();
337
+ const table = STR[L] || STR.zh;
338
+ if (Object.prototype.hasOwnProperty.call(table, key)) return table[key];
339
+ if (Object.prototype.hasOwnProperty.call(STR.zh, key)) return STR.zh[key];
340
+ return key;
341
+ }
342
+
343
+ function applyI18n(root) {
344
+ root = root || document;
345
+ root.querySelectorAll('[data-i18n]').forEach(function (el) {
346
+ var key = el.getAttribute('data-i18n');
347
+ if (!key) return;
348
+ if (el.tagName === 'OPTION') {
349
+ el.textContent = t(key);
350
+ } else {
351
+ el.textContent = t(key);
352
+ }
353
+ });
354
+ root.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
355
+ var key = el.getAttribute('data-i18n-placeholder');
356
+ if (key) el.placeholder = t(key);
357
+ });
358
+ root.querySelectorAll('[data-i18n-title]').forEach(function (el) {
359
+ var key = el.getAttribute('data-i18n-title');
360
+ if (key) el.title = t(key);
361
+ });
362
+ root.querySelectorAll('[data-i18n-html]').forEach(function (el) {
363
+ var key = el.getAttribute('data-i18n-html');
364
+ if (key) el.innerHTML = t(key);
365
+ });
366
+ root.querySelectorAll('[data-i18n-value]').forEach(function (el) {
367
+ var key = el.getAttribute('data-i18n-value');
368
+ if (key && (el.tagName === 'INPUT' || el.tagName === 'BUTTON')) {
369
+ el.value = t(key);
370
+ }
371
+ });
372
+ }
373
+
374
+ function updateLangButton() {
375
+ var btn = document.getElementById('lang-toggle-btn');
376
+ if (!btn) return;
377
+ btn.textContent = getLang() === 'zh' ? 'EN' : '中';
378
+ btn.setAttribute(
379
+ 'aria-label',
380
+ getLang() === 'zh' ? t('langToggleAriaZh') : t('langToggleAriaEn')
381
+ );
382
+ btn.classList.toggle('active', getLang() === 'en');
383
+ }
384
+
385
+ function toggleUiLanguage() {
386
+ try {
387
+ setLang(getLang() === 'zh' ? 'en' : 'zh');
388
+ } catch (err) {
389
+ console.error('[i18n] toggleUiLanguage failed:', err);
390
+ }
391
+ }
392
+
393
+ /** 避免 CSP 拦截内联 onclick;确保按钮一定能触发 */
394
+ function bindLangToggleButton() {
395
+ var btn = document.getElementById('lang-toggle-btn');
396
+ if (!btn || btn.dataset.i18nBound === '1') return;
397
+ btn.dataset.i18nBound = '1';
398
+ btn.removeAttribute('onclick');
399
+ btn.addEventListener('click', function (ev) {
400
+ ev.preventDefault();
401
+ toggleUiLanguage();
402
+ });
403
+ }
404
+
405
+ function boot() {
406
+ document.documentElement.lang = getLang() === 'en' ? 'en' : 'zh-CN';
407
+ try {
408
+ applyI18n();
409
+ } catch (err) {
410
+ console.error('[i18n] applyI18n failed:', err);
411
+ }
412
+ updateLangButton();
413
+ bindLangToggleButton();
414
+ }
415
+
416
+ global.getUiLang = getLang;
417
+ global.setUiLang = setLang;
418
+ global.t = t;
419
+ global.applyI18n = applyI18n;
420
+ global.toggleUiLanguage = toggleUiLanguage;
421
+ global.updateLangToggleButton = updateLangButton;
422
+
423
+ if (document.readyState === 'loading') {
424
+ document.addEventListener('DOMContentLoaded', boot);
425
+ } else {
426
+ boot();
427
+ }
428
+ })(typeof window !== 'undefined' ? window : global);
LTX2.3/UI/index.css ADDED
@@ -0,0 +1,775 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --accent: #2563EB; /* Refined blue – not too bright, not purple */
3
+ --accent-hover:#3B82F6;
4
+ --accent-dim: rgba(37,99,235,0.14);
5
+ --accent-ring: rgba(37,99,235,0.35);
6
+ --bg: #111113;
7
+ --panel: #18181B;
8
+ --panel-2: #1F1F23;
9
+ --item: rgba(255,255,255,0.035);
10
+ --border: rgba(255,255,255,0.08);
11
+ --border-2: rgba(255,255,255,0.05);
12
+ --text-dim: #71717A;
13
+ --text-sub: #A1A1AA;
14
+ --text: #FAFAFA;
15
+ }
16
+
17
+ * { box-sizing: border-box; -webkit-font-smoothing: antialiased; min-width: 0; }
18
+ body {
19
+ background: var(--bg); margin: 0; color: var(--text);
20
+ font-family: -apple-system, "SF Pro Display", "Segoe UI", sans-serif;
21
+ display: flex; height: 100vh; overflow: hidden;
22
+ font-size: 13px; line-height: 1.5;
23
+ }
24
+
25
+ .sidebar {
26
+ width: 460px; min-width: 460px;
27
+ background: var(--panel);
28
+ border-right: 1px solid var(--border);
29
+ display: flex; flex-direction: column; z-index: 20;
30
+ overflow-y: auto; overflow-x: hidden;
31
+ }
32
+
33
+ /* Scrollbar */
34
+ ::-webkit-scrollbar { width: 5px; height: 5px; }
35
+ ::-webkit-scrollbar-track { background: transparent; }
36
+ ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 10px; }
37
+ ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.18); }
38
+
39
+ .sidebar-header { padding: 24px 24px 4px; }
40
+
41
+ .lang-toggle {
42
+ background: transparent;
43
+ border: 1px solid var(--border);
44
+ color: var(--text-dim);
45
+ padding: 4px 10px;
46
+ border-radius: 6px;
47
+ font-size: 11px;
48
+ cursor: pointer;
49
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
50
+ font-weight: 700;
51
+ min-width: 44px;
52
+ flex-shrink: 0;
53
+ }
54
+ .lang-toggle:hover {
55
+ background: var(--item);
56
+ color: var(--text);
57
+ border-color: var(--accent);
58
+ }
59
+ .lang-toggle.active {
60
+ background: var(--accent);
61
+ color: #fff;
62
+ border-color: var(--accent);
63
+ }
64
+ .sidebar-section { padding: 8px 24px 18px; border-bottom: 1px solid var(--border); }
65
+
66
+ .setting-group {
67
+ background: rgba(255,255,255,0.025);
68
+ border: 1px solid var(--border-2);
69
+ border-radius: 10px;
70
+ padding: 14px;
71
+ margin-bottom: 12px;
72
+ }
73
+ .group-title {
74
+ font-size: 10px; color: var(--text-dim); font-weight: 700;
75
+ text-transform: uppercase; letter-spacing: 0.7px;
76
+ margin-bottom: 12px; padding-bottom: 5px;
77
+ border-bottom: 1px solid var(--border-2);
78
+ }
79
+
80
+ /* Mode Tabs */
81
+ .tabs {
82
+ display: flex; gap: 4px; margin-bottom: 14px;
83
+ background: rgba(255,255,255,0.04);
84
+ padding: 4px; border-radius: 10px;
85
+ border: 1px solid var(--border-2);
86
+ }
87
+ .tab {
88
+ flex: 1; padding: 9px 0; text-align: center; border-radius: 7px;
89
+ cursor: pointer; font-size: 12px; color: var(--text-dim);
90
+ transition: all 0.2s; font-weight: 600;
91
+ display: flex; align-items: center; justify-content: center;
92
+ }
93
+ .tab.active { background: var(--accent); color: #fff; box-shadow: 0 1px 6px rgba(10,132,255,0.45); }
94
+ .tab:hover:not(.active) { background: rgba(255,255,255,0.06); color: var(--text); }
95
+
96
+ .label-group { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
97
+ label { display: block; font-size: 11px; color: var(--text-dim); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
98
+ .val-badge { font-size: 11px; color: var(--accent); font-family: "SF Mono", ui-monospace, monospace; font-weight: 600; }
99
+
100
+ input[type="text"], input[type="number"], select, textarea {
101
+ width: 100%; background: var(--panel-2);
102
+ border: 1px solid var(--border);
103
+ border-radius: 7px; color: var(--text);
104
+ padding: 8px 11px; font-size: 12.5px; outline: none; margin-bottom: 9px;
105
+ /* Only transition border/shadow – NOT background-image to prevent arrow flicker */
106
+ transition: border-color 0.15s, box-shadow 0.15s;
107
+ }
108
+ input:focus, select:focus, textarea:focus {
109
+ border-color: var(--accent);
110
+ box-shadow: 0 0 0 2px var(--accent-ring);
111
+ }
112
+ select {
113
+ -webkit-appearance: none; -moz-appearance: none; appearance: none;
114
+ /* Stable grey arrow – no background shorthand so it won't animate */
115
+ background-color: var(--panel-2);
116
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2371717A' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
117
+ background-repeat: no-repeat;
118
+ background-position: right 10px center;
119
+ background-size: 12px;
120
+ padding-right: 28px;
121
+ cursor: pointer;
122
+ /* Explicitly do NOT transition background properties */
123
+ transition: border-color 0.15s, box-shadow 0.15s;
124
+ }
125
+ select:focus { background-color: var(--panel-2); }
126
+ select option { background: #27272A; color: var(--text); }
127
+ textarea { resize: vertical; min-height: 78px; font-family: inherit; }
128
+
129
+ .slider-container { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
130
+ input[type="range"] { flex: 1; accent-color: var(--accent); height: 4px; cursor: pointer; border-radius: 2px; }
131
+
132
+ .upload-zone {
133
+ border: 1px dashed var(--border); border-radius: 10px;
134
+ padding: 18px 10px; text-align: center; cursor: pointer;
135
+ background: rgba(255,255,255,0.03); margin-bottom: 10px; position: relative;
136
+ transition: all 0.2s;
137
+ }
138
+ .upload-zone:hover, .upload-zone.dragover { background: var(--accent-dim); border-color: var(--accent); }
139
+ .upload-zone.has-images {
140
+ padding: 12px; background: rgba(255,255,255,0.025);
141
+ }
142
+ .upload-zone.has-images .upload-placeholder-mini {
143
+ display: flex; align-items: center; gap: 8px; justify-content: center;
144
+ color: var(--text-dim); font-size: 11px;
145
+ }
146
+ .upload-zone.has-images .upload-placeholder-mini span {
147
+ background: var(--item); padding: 6px 12px; border-radius: 6px;
148
+ }
149
+ #batch-images-placeholder { display: block; }
150
+ .upload-zone.has-images #batch-images-placeholder { display: none; }
151
+
152
+ /* 批量模式:上传区下方的横向缩略图条 */
153
+ .batch-thumb-strip-wrap {
154
+ margin-top: 10px;
155
+ margin-bottom: 4px;
156
+ }
157
+ .batch-thumb-strip-head {
158
+ display: flex;
159
+ flex-direction: column;
160
+ gap: 2px;
161
+ margin-bottom: 8px;
162
+ }
163
+ .batch-thumb-strip-title {
164
+ font-size: 11px;
165
+ font-weight: 700;
166
+ color: var(--text-sub);
167
+ }
168
+ .batch-thumb-strip-hint {
169
+ font-size: 10px;
170
+ color: var(--text-dim);
171
+ }
172
+ .batch-images-container {
173
+ display: flex;
174
+ flex-direction: row;
175
+ flex-wrap: nowrap;
176
+ gap: 10px;
177
+ overflow-x: auto;
178
+ overflow-y: visible;
179
+ padding: 6px 4px 14px;
180
+ margin: 0 -4px;
181
+ scrollbar-width: thin;
182
+ scrollbar-color: var(--border) transparent;
183
+ align-items: center;
184
+ }
185
+ .batch-images-container::-webkit-scrollbar { height: 6px; }
186
+ .batch-images-container::-webkit-scrollbar-thumb {
187
+ background: var(--border);
188
+ border-radius: 3px;
189
+ }
190
+ .batch-image-wrapper {
191
+ flex: 0 0 72px;
192
+ width: 72px;
193
+ height: 72px;
194
+ position: relative;
195
+ border-radius: 10px;
196
+ overflow: hidden;
197
+ background: var(--item);
198
+ border: 1px solid var(--border);
199
+ cursor: grab;
200
+ touch-action: none;
201
+ user-select: none;
202
+ -webkit-user-select: none;
203
+ transition:
204
+ flex-basis 0.38s cubic-bezier(0.22, 1, 0.36, 1),
205
+ width 0.38s cubic-bezier(0.22, 1, 0.36, 1),
206
+ min-width 0.38s cubic-bezier(0.22, 1, 0.36, 1),
207
+ margin 0.38s cubic-bezier(0.22, 1, 0.36, 1),
208
+ opacity 0.25s ease,
209
+ border-color 0.2s ease,
210
+ box-shadow 0.2s ease,
211
+ transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
212
+ }
213
+ .batch-image-wrapper:active { cursor: grabbing; }
214
+ .batch-image-wrapper.batch-thumb--source {
215
+ flex: 0 0 0;
216
+ width: 0;
217
+ min-width: 0;
218
+ height: 72px;
219
+ margin: 0;
220
+ padding: 0;
221
+ border: none;
222
+ overflow: hidden;
223
+ opacity: 0;
224
+ background: transparent;
225
+ box-shadow: none;
226
+ pointer-events: none;
227
+ /* 收起必须瞬时:若与占位框同时用 0.38s 过渡,右侧缩略图会与「突然出现」的槽位不同步而闪一下 */
228
+ transition: none !important;
229
+ }
230
+ /* 按下瞬间:冻结其它卡片与槽位动画,避免「槽位插入 + 邻居过渡」两帧打架 */
231
+ .batch-images-container.is-batch-settling .batch-image-wrapper:not(.batch-thumb--source) {
232
+ transition: none !important;
233
+ }
234
+ .batch-images-container.is-batch-settling .batch-thumb-drop-slot {
235
+ animation: none;
236
+ opacity: 1;
237
+ }
238
+ /* 拖动时跟手的浮动缩略图(避免原槽位透明后光标下像「黑块」) */
239
+ .batch-thumb-floating-ghost {
240
+ position: fixed;
241
+ left: 0;
242
+ top: 0;
243
+ z-index: 99999;
244
+ width: 76px;
245
+ height: 76px;
246
+ border-radius: 12px;
247
+ overflow: hidden;
248
+ pointer-events: none;
249
+ will-change: transform;
250
+ box-shadow:
251
+ 0 20px 50px rgba(0, 0, 0, 0.45),
252
+ 0 10px 28px rgba(0, 0, 0, 0.28),
253
+ 0 0 0 1px rgba(255, 255, 255, 0.18);
254
+ transform: translate3d(0, 0, 0) scale(1.06) rotate(-1deg);
255
+ }
256
+ .batch-thumb-floating-ghost img {
257
+ width: 100%;
258
+ height: 100%;
259
+ object-fit: cover;
260
+ display: block;
261
+ pointer-events: none;
262
+ }
263
+ .batch-thumb-drop-slot {
264
+ flex: 0 0 72px;
265
+ width: 72px;
266
+ height: 72px;
267
+ box-sizing: border-box;
268
+ border-radius: 12px;
269
+ border: 2px dashed rgba(255, 255, 255, 0.22);
270
+ background: linear-gradient(145deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.03));
271
+ pointer-events: none;
272
+ transition: border-color 0.35s ease, box-shadow 0.35s ease, opacity 0.35s ease;
273
+ animation: batch-slot-breathe 2.4s ease-in-out infinite;
274
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
275
+ }
276
+ @keyframes batch-slot-breathe {
277
+ 0%, 100% { opacity: 0.88; }
278
+ 50% { opacity: 1; }
279
+ }
280
+ .batch-image-wrapper .batch-thumb-img-wrap {
281
+ width: 100%;
282
+ height: 100%;
283
+ border-radius: 9px;
284
+ overflow: hidden;
285
+ /* 必须让事件落到外层 .batch-image-wrapper,否则 HTML5 drag 无法从 draggable 父级启动 */
286
+ pointer-events: none;
287
+ }
288
+ .batch-image-wrapper .batch-thumb-img {
289
+ width: 100%;
290
+ height: 100%;
291
+ object-fit: cover;
292
+ display: block;
293
+ pointer-events: none;
294
+ user-select: none;
295
+ -webkit-user-drag: none;
296
+ }
297
+ .batch-thumb-remove {
298
+ position: absolute;
299
+ top: 3px;
300
+ right: 3px;
301
+ z-index: 5;
302
+ box-sizing: border-box;
303
+ min-width: 22px;
304
+ height: 22px;
305
+ padding: 0 5px;
306
+ margin: 0;
307
+ border: 1px solid rgba(255, 255, 255, 0.12);
308
+ border-radius: 6px;
309
+ background: rgba(0, 0, 0, 0.5);
310
+ font-family: inherit;
311
+ font-size: 14px;
312
+ font-weight: 400;
313
+ line-height: 1;
314
+ color: rgba(255, 255, 255, 0.9);
315
+ opacity: 0.72;
316
+ cursor: pointer;
317
+ display: flex;
318
+ align-items: center;
319
+ justify-content: center;
320
+ transition: background 0.12s, opacity 0.12s, border-color 0.12s;
321
+ pointer-events: auto;
322
+ }
323
+ .batch-image-wrapper:hover .batch-thumb-remove {
324
+ opacity: 1;
325
+ background: rgba(0, 0, 0, 0.68);
326
+ border-color: rgba(255, 255, 255, 0.2);
327
+ }
328
+ .batch-thumb-remove:hover {
329
+ background: rgba(80, 20, 20, 0.75) !important;
330
+ border-color: rgba(255, 180, 180, 0.35);
331
+ color: #fff;
332
+ }
333
+ .batch-thumb-remove:focus-visible {
334
+ opacity: 1;
335
+ outline: 2px solid var(--accent-dim, rgba(120, 160, 255, 0.6));
336
+ outline-offset: 1px;
337
+ }
338
+ .upload-icon { font-size: 18px; margin-bottom: 6px; opacity: 0.45; }
339
+ .upload-text { font-size: 11px; color: var(--text); }
340
+ .upload-hint { font-size: 10px; color: var(--text-dim); margin-top: 3px; }
341
+ .preview-thumb { width: 100%; height: auto; max-height: 100px; object-fit: contain; border-radius: 8px; display: none; margin-top: 10px; }
342
+ .clear-img-overlay {
343
+ position: absolute; top: 8px; right: 8px; background: rgba(255,59,48,0.85); color: white;
344
+ width: 20px; height: 20px; border-radius: 10px; display: none; align-items: center; justify-content: center;
345
+ font-size: 11px; cursor: pointer; z-index: 5;
346
+ }
347
+
348
+ .btn-outline {
349
+ background: var(--panel-2);
350
+ border: 1px solid var(--border);
351
+ color: var(--text-sub); padding: 5px 12px; border-radius: 7px;
352
+ font-size: 11.5px; font-weight: 600; cursor: pointer;
353
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
354
+ display: inline-flex; align-items: center; justify-content: center; gap: 5px;
355
+ white-space: nowrap;
356
+ }
357
+ .btn-outline:hover:not(:disabled) { background: rgba(255,255,255,0.08); color: var(--text); border-color: rgba(255,255,255,0.18); }
358
+ .btn-outline:active { opacity: 0.7; }
359
+ .btn-outline:disabled { opacity: 0.3; cursor: not-allowed; }
360
+
361
+ .btn-icon {
362
+ padding: 5px; background: transparent; border: none; color: var(--text-dim);
363
+ border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center;
364
+ transition: color 0.15s, background 0.15s;
365
+ }
366
+ .btn-icon:hover { color: var(--text-sub); background: rgba(255,255,255,0.07); }
367
+
368
+ .btn-primary {
369
+ width: 100%; padding: 13px;
370
+ background: var(--accent); border: none;
371
+ border-radius: 9px; color: #fff; font-weight: 700; font-size: 13.5px;
372
+ letter-spacing: 0.2px; cursor: pointer; margin-top: 14px;
373
+ transition: background 0.15s;
374
+ }
375
+ .btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
376
+ .btn-primary:active { opacity: 0.82; }
377
+ .btn-primary:disabled { background: rgba(255,255,255,0.08); color: var(--text-dim); cursor: not-allowed; }
378
+
379
+ .btn-danger {
380
+ width: 100%; padding: 12px; background: #DC2626; border: none;
381
+ border-radius: 9px; color: #fff; font-weight: 700; font-size: 13.5px;
382
+ cursor: pointer; margin-top: 8px; display: none; transition: background 0.15s;
383
+ }
384
+ .btn-danger:hover { background: #EF4444; }
385
+
386
+ /* Workspace */
387
+ .workspace { flex: 1; display: flex; flex-direction: column; background: #0A0A0A; position: relative; overflow: hidden; }
388
+ .viewer { flex: 2; display: flex; align-items: center; justify-content: center; padding: 16px; background: #0A0A0A; position: relative; min-height: 40vh; }
389
+ .monitor {
390
+ width: 100%; height: 100%; max-width: 1650px; border-radius: 10px; border: 1px solid var(--border);
391
+ overflow: hidden; position: relative; background: #070707;
392
+ display: flex; align-items: center; justify-content: center;
393
+ background-image: radial-gradient(rgba(255,255,255,0.02) 1px, transparent 1px);
394
+ background-size: 18px 18px;
395
+ }
396
+ .monitor img, .monitor video {
397
+ width: auto; height: auto; max-width: 100%; max-height: 100%;
398
+ object-fit: contain; display: none; z-index: 2; border-radius: 3px;
399
+ }
400
+
401
+ .progress-container { position: absolute; bottom: 0; left: 0; width: 100%; height: 2px; background: var(--border-2); z-index: 10; }
402
+ #progress-fill { width: 0%; height: 100%; background: var(--accent); transition: width 0.5s; }
403
+ #loading-txt { font-size: 12px; color: var(--text-sub); font-weight: 600; z-index: 5; position: absolute; display: none; }
404
+
405
+
406
+
407
+ .spinner {
408
+ width: 12px; height: 12px;
409
+ border: 2px solid rgba(255,255,255,0.2);
410
+ border-top-color: currentColor;
411
+ border-radius: 50%;
412
+ animation: spin 1s linear infinite;
413
+ }
414
+ @keyframes spin { to { transform: rotate(360deg); } }
415
+
416
+ .loading-card {
417
+ display: flex; align-items: center; justify-content: center;
418
+ flex-direction: column; gap: 6px; color: var(--text-dim); font-size: 10px;
419
+ background: rgba(37,99,235,0.07) !important;
420
+ border-color: rgba(37,99,235,0.3) !important;
421
+ }
422
+ .loading-card .spinner { width: 28px; height: 28px; border-width: 3px; color: var(--accent); }
423
+ .loading-card:hover { background: rgba(37,99,235,0.14) !important; border-color: var(--accent) !important; }
424
+
425
+ .library { flex: 1.5; border-top: 1px solid var(--border); padding: 14px 20px; display: flex; flex-direction: column; background: #0F0F11; overflow-y: hidden; }
426
+ #log-container { flex: 1; overflow-y: auto; padding-right: 4px; }
427
+ #log { font-family: ui-monospace, "SF Mono", monospace; font-size: 10.5px; color: var(--text-dim); line-height: 1.7; }
428
+
429
+ /* History wrapper: scrollable area for thumbnails only */
430
+ #history-wrapper {
431
+ flex: 1;
432
+ overflow-y: auto;
433
+ min-height: 110px; /* always show at least one row */
434
+ padding-right: 4px;
435
+ }
436
+ #history-container {
437
+ display: grid;
438
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
439
+ justify-content: start;
440
+ gap: 10px; align-content: flex-start;
441
+ padding-bottom: 4px;
442
+ }
443
+ /* Pagination row: hidden, using infinite scroll instead */
444
+ #pagination-bar {
445
+ display: none;
446
+ }
447
+
448
+ .history-card {
449
+ width: 100%; max-width: 200px; aspect-ratio: 16 / 9;
450
+ background: #1A1A1E; border-radius: 7px;
451
+ overflow: hidden; border: 1px solid var(--border);
452
+ cursor: pointer; position: relative; transition: border-color 0.15s, transform 0.15s;
453
+ }
454
+ .history-card:hover { border-color: var(--accent); transform: translateY(-1px); }
455
+ .history-card img, .history-card video {
456
+ width: 100%; height: 100%; object-fit: cover;
457
+ background: #1A1A1E;
458
+ }
459
+ /* 解码/加载完成前避免视频黑块猛闪,与卡片底色一致;就绪后淡入 */
460
+ .history-card .history-thumb-media {
461
+ opacity: 0;
462
+ transition: opacity 0.28s ease;
463
+ }
464
+ .history-card .history-thumb-media.history-thumb-ready {
465
+ opacity: 1;
466
+ }
467
+ .history-type-badge {
468
+ position: absolute; top: 5px; left: 5px; font-size: 8px; padding: 1px 5px; border-radius: 3px;
469
+ background: rgba(0,0,0,0.8); color: var(--text-sub); border: 1px solid rgba(255,255,255,0.06);
470
+ z-index: 2; font-weight: 700; letter-spacing: 0.4px;
471
+ }
472
+ .history-delete-btn {
473
+ position: absolute; top: 5px; right: 5px; width: 20px; height: 20px;
474
+ border-radius: 50%; border: none; background: rgba(255,50,50,0.8); color: #fff;
475
+ font-size: 10px; cursor: pointer; z-index: 3; display: flex; align-items: center; justify-content: center;
476
+ opacity: 0; transition: opacity 0.2s;
477
+ }
478
+ .history-card:hover .history-delete-btn { opacity: 1; }
479
+ .history-delete-btn:hover { background: rgba(255,0,0,0.9); }
480
+
481
+ .vram-bar { width: 160px; height: 5px; background: rgba(255,255,255,0.08); border-radius: 999px; overflow: hidden; display: inline-block; vertical-align: middle; }
482
+ .vram-used { height: 100%; background: var(--accent); width: 0%; transition: width 0.5s; }
483
+
484
+ /* 智能多帧:工作流模式卡片式单选 */
485
+ .smart-param-mode-label {
486
+ font-size: 10px;
487
+ color: var(--text-dim);
488
+ font-weight: 700;
489
+ margin-bottom: 8px;
490
+ letter-spacing: 0.04em;
491
+ text-transform: uppercase;
492
+ }
493
+ .smart-param-modes {
494
+ display: flex;
495
+ flex-direction: row;
496
+ align-items: stretch;
497
+ gap: 0;
498
+ padding: 3px;
499
+ margin-bottom: 12px;
500
+ background: var(--panel-2);
501
+ border-radius: 8px;
502
+ border: 1px solid var(--border);
503
+ }
504
+ .smart-param-mode-opt {
505
+ display: flex;
506
+ align-items: center;
507
+ justify-content: center;
508
+ flex: 1;
509
+ min-width: 0;
510
+ gap: 0;
511
+ margin: 0;
512
+ padding: 6px 8px;
513
+ border-radius: 6px;
514
+ border: none;
515
+ background: transparent;
516
+ cursor: pointer;
517
+ transition: background 0.15s, color 0.15s;
518
+ position: relative;
519
+ }
520
+ .smart-param-mode-opt:hover:not(:has(input:checked)) {
521
+ background: rgba(255, 255, 255, 0.05);
522
+ }
523
+ .smart-param-mode-opt input[type="radio"] {
524
+ position: absolute;
525
+ opacity: 0;
526
+ width: 0;
527
+ height: 0;
528
+ margin: 0;
529
+ }
530
+ .smart-param-mode-opt:has(input:checked) {
531
+ background: var(--accent);
532
+ box-shadow: none;
533
+ }
534
+ .smart-param-mode-opt:has(input:checked) .smart-param-mode-title {
535
+ color: #fff;
536
+ }
537
+ .smart-param-mode-title {
538
+ font-size: 11px;
539
+ font-weight: 600;
540
+ color: var(--text-sub);
541
+ text-align: center;
542
+ line-height: 1.25;
543
+ flex: none;
544
+ min-width: 0;
545
+ }
546
+ /* 单次多关键帧:时间轴面板 */
547
+ .batch-kf-panel {
548
+ background: var(--item);
549
+ border-radius: 10px;
550
+ padding: 12px 14px;
551
+ margin-bottom: 10px;
552
+ border: 1px solid var(--border);
553
+ }
554
+ .batch-kf-panel-hd {
555
+ display: flex;
556
+ flex-wrap: wrap;
557
+ align-items: center;
558
+ justify-content: space-between;
559
+ gap: 10px;
560
+ margin-bottom: 8px;
561
+ }
562
+ .batch-kf-panel-title {
563
+ font-size: 12px;
564
+ font-weight: 700;
565
+ color: var(--text);
566
+ }
567
+ .batch-kf-total-pill {
568
+ font-size: 11px;
569
+ color: var(--text-sub);
570
+ background: var(--panel-2);
571
+ border: 1px solid var(--border);
572
+ border-radius: 999px;
573
+ padding: 6px 12px;
574
+ white-space: nowrap;
575
+ }
576
+ .batch-kf-total-pill strong {
577
+ color: var(--accent);
578
+ font-weight: 800;
579
+ font-variant-numeric: tabular-nums;
580
+ margin: 0 2px;
581
+ }
582
+ .batch-kf-total-unit {
583
+ font-size: 10px;
584
+ color: var(--text-dim);
585
+ }
586
+ .batch-kf-panel-hint {
587
+ font-size: 10px;
588
+ color: var(--text-dim);
589
+ line-height: 1.5;
590
+ margin: 0 0 12px;
591
+ }
592
+ .batch-kf-timeline-col {
593
+ display: flex;
594
+ flex-direction: column;
595
+ gap: 0;
596
+ }
597
+ .batch-kf-kcard {
598
+ border-radius: 10px;
599
+ border: 1px solid var(--border);
600
+ background: rgba(255, 255, 255, 0.03);
601
+ padding: 10px 12px;
602
+ }
603
+ .batch-kf-kcard-head {
604
+ display: flex;
605
+ align-items: center;
606
+ gap: 12px;
607
+ margin-bottom: 10px;
608
+ }
609
+ .batch-kf-kthumb {
610
+ width: 48px;
611
+ height: 48px;
612
+ border-radius: 8px;
613
+ object-fit: cover;
614
+ flex-shrink: 0;
615
+ border: 1px solid var(--border);
616
+ }
617
+ .batch-kf-kcard-titles {
618
+ display: flex;
619
+ flex-direction: column;
620
+ gap: 4px;
621
+ min-width: 0;
622
+ }
623
+ .batch-kf-ktitle {
624
+ font-size: 12px;
625
+ font-weight: 700;
626
+ color: var(--text);
627
+ }
628
+ .batch-kf-anchor {
629
+ font-size: 11px;
630
+ color: var(--accent);
631
+ font-variant-numeric: tabular-nums;
632
+ font-weight: 600;
633
+ }
634
+ .batch-kf-kcard-ctrl {
635
+ display: flex;
636
+ flex-wrap: wrap;
637
+ align-items: center;
638
+ gap: 12px;
639
+ }
640
+ .batch-kf-klabel {
641
+ font-size: 10px;
642
+ color: var(--text-dim);
643
+ display: flex;
644
+ align-items: center;
645
+ gap: 8px;
646
+ }
647
+ .batch-kf-klabel input[type="number"] {
648
+ width: 72px;
649
+ padding: 6px 8px;
650
+ font-size: 12px;
651
+ border-radius: 6px;
652
+ border: 1px solid var(--border);
653
+ background: var(--panel);
654
+ color: var(--text);
655
+ }
656
+ /* 关键帧之间:细时间轴 + 单行紧凑间隔输入 */
657
+ .batch-kf-gap {
658
+ display: flex;
659
+ align-items: stretch;
660
+ gap: 8px;
661
+ padding: 0 0 6px;
662
+ margin: 0 0 0 10px;
663
+ }
664
+ .batch-kf-gap-rail {
665
+ width: 2px;
666
+ flex-shrink: 0;
667
+ border-radius: 2px;
668
+ background: linear-gradient(
669
+ 180deg,
670
+ rgba(255, 255, 255, 0.06),
671
+ var(--accent-dim),
672
+ rgba(255, 255, 255, 0.04)
673
+ );
674
+ min-height: 22px;
675
+ align-self: stretch;
676
+ }
677
+ .batch-kf-gap-inner {
678
+ display: flex;
679
+ align-items: center;
680
+ gap: 8px;
681
+ flex: 1;
682
+ min-width: 0;
683
+ padding: 2px 0 4px;
684
+ }
685
+ .batch-kf-gap-ix {
686
+ font-size: 10px;
687
+ font-weight: 600;
688
+ color: var(--text-dim);
689
+ font-variant-numeric: tabular-nums;
690
+ letter-spacing: -0.02em;
691
+ flex-shrink: 0;
692
+ }
693
+ .batch-kf-seg-field {
694
+ display: inline-flex;
695
+ align-items: center;
696
+ gap: 3px;
697
+ margin: 0;
698
+ cursor: text;
699
+ }
700
+ .batch-kf-seg-input {
701
+ width: 46px;
702
+ min-width: 0;
703
+ padding: 2px 5px;
704
+ font-size: 11px;
705
+ font-weight: 600;
706
+ line-height: 1.3;
707
+ border-radius: 4px;
708
+ border: 1px solid var(--border);
709
+ background: rgba(0, 0, 0, 0.2);
710
+ color: var(--text);
711
+ font-variant-numeric: tabular-nums;
712
+ }
713
+ .batch-kf-seg-input:hover {
714
+ border-color: rgba(255, 255, 255, 0.12);
715
+ }
716
+ .batch-kf-seg-input:focus {
717
+ outline: none;
718
+ border-color: var(--accent);
719
+ box-shadow: 0 0 0 1px var(--accent-ring);
720
+ }
721
+ .batch-kf-gap-unit {
722
+ font-size: 10px;
723
+ color: var(--text-dim);
724
+ font-weight: 500;
725
+ flex-shrink: 0;
726
+ }
727
+
728
+ .sub-mode-toggle { display: flex; background: var(--panel-2); border-radius: 7px; padding: 3px; border: 1px solid var(--border); }
729
+ .sub-mode-btn { flex: 1; padding: 6px 0; border-radius: 5px; border: none; background: transparent; font-size: 11.5px; color: var(--text-dim); font-weight: 600; cursor: pointer; transition: background 0.15s, color 0.15s; }
730
+ .sub-mode-btn.active { background: var(--accent); color: #fff; }
731
+ .sub-mode-btn:hover:not(.active) { background: rgba(255,255,255,0.05); color: var(--text-sub); }
732
+
733
+ .vid-section { display: none; margin-top: 12px; }
734
+ .vid-section.active-section { display: block; animation: fadeIn 0.25s ease; }
735
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
736
+
737
+ /* Status indicator */
738
+ @keyframes breathe-orange {
739
+ 0%,100% { box-shadow: 0 0 4px #FF9F0A; opacity: 0.7; }
740
+ 50% { box-shadow: 0 0 10px #FF9F0A; opacity: 1; }
741
+ }
742
+ .indicator-busy { background: #FF9F0A !important; animation: breathe-orange 1.6s infinite ease-in-out !important; box-shadow: none !important; transition: all 0.3s; }
743
+ .indicator-ready { background: #30D158 !important; box-shadow: 0 0 8px rgba(48,209,88,0.6) !important; animation: none !important; transition: all 0.3s; }
744
+ .indicator-offline { background: #636366 !important; box-shadow: none !important; animation: none !important; transition: all 0.3s; }
745
+
746
+ .res-preview-tag { font-size: 11px; color: var(--accent); margin-bottom: 10px; font-family: ui-monospace, monospace; }
747
+ .top-status { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-dim); margin-bottom: 8px; align-items: center; }
748
+ .checkbox-container { display: flex; align-items: center; gap: 8px; cursor: pointer; background: rgba(255,255,255,0.02); padding: 10px; border-radius: 8px; border: 1px solid var(--border-2); }
749
+ .checkbox-container input { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; margin: 0; }
750
+ .checkbox-container label { margin-bottom: 0; cursor: pointer; text-transform: none; color: var(--text); }
751
+ .flex-row { display: flex; gap: 10px; }
752
+ .flex-1 { flex: 1; min-width: 0; }
753
+
754
+ @media (max-width: 1024px) {
755
+ body { flex-direction: column; overflow-y: auto; }
756
+ .sidebar { width: 100%; min-width: 100%; border-right: none; border-bottom: 1px solid var(--border); height: auto; overflow: visible; }
757
+ .workspace { height: auto; min-height: 100vh; overflow: visible; }
758
+ }
759
+ :root {
760
+ --plyr-color-main: #3F51B5;
761
+ --plyr-video-control-background-hover: rgba(255,255,255,0.1);
762
+ --plyr-control-radius: 6px;
763
+ --plyr-player-width: 100%;
764
+ }
765
+ .plyr {
766
+ border-radius: 8px;
767
+ overflow: hidden;
768
+ width: 100%;
769
+ height: 100%;
770
+ }
771
+ .plyr--video .plyr__controls {
772
+ background: linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0.8));
773
+ padding: 20px 15px 15px 15px;
774
+ }
775
+
LTX2.3/UI/index.html ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>LTX-2 | Multi-GPU Cinematic Studio</title>
7
+ <link rel="stylesheet" href="index.css">
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/plyr/3.7.8/plyr.css" />
9
+ </head>
10
+ <body>
11
+
12
+ <aside class="sidebar">
13
+ <div class="sidebar-header">
14
+ <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
15
+ <div style="display: flex; align-items: center; gap: 10px;">
16
+ <div id="sys-indicator" class="indicator-ready" style="width: 12px; height: 12px; border-radius: 50%;"></div>
17
+ <span style="font-weight: 800; font-size: 18px;">LTX-2 STUDIO</span>
18
+ </div>
19
+ <div style="display: flex; gap: 8px; align-items: center;">
20
+ <button type="button" id="lang-toggle-btn" class="lang-toggle">EN</button>
21
+ <button id="clearGpuBtn" onclick="clearGpu()" class="btn-outline" data-i18n="clearVram">释放显存</button>
22
+ </div>
23
+ </div>
24
+
25
+ <div class="top-status" style="margin-bottom: 5px;">
26
+ <div style="display: flex; align-items: center; gap: 8px;">
27
+ <span id="sys-status" style="font-weight:bold; color: var(--text-dim); font-size: 12px;" data-i18n="sysScanning">正在扫描 GPU...</span>
28
+ </div>
29
+
30
+ <button type="button" onclick="const el = document.getElementById('sys-settings'); el.style.display = el.style.display === 'none' ? 'block' : 'none';" class="btn-icon" data-i18n-title="settingsTitle" title="系统高级设置">
31
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
32
+ </button>
33
+
34
+ </div>
35
+
36
+ <div style="font-size: 11px; color: var(--text-dim); margin-bottom: 20px; display: flex; align-items: center; width: 100%;">
37
+ <div class="vram-bar" style="width: 120px; min-width: 120px; margin-top: 0; margin-right: 12px;"><div class="vram-used" id="vram-fill"></div></div>
38
+ <span id="vram-text" style="font-variant-numeric: tabular-nums; flex-shrink: 0; text-align: right;">0/32 GB</span>
39
+ <span id="gpu-name" style="display: none;"></span> <!-- Hidden globally to avoid duplicate -->
40
+ </div>
41
+
42
+ <div id="sys-settings" style="display: none; padding: 14px; background: rgba(0,0,0,0.4) !important; border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); margin-bottom: 15px; box-shadow: 0 4px 15px rgba(0,0,0,0.5); backdrop-filter: blur(10px);">
43
+ <div style="font-size: 13px; font-weight: bold; margin-bottom: 12px; color: #fff;" data-i18n="advancedSettings">高级设置</div>
44
+
45
+ <label style="font-size: 11px; margin-bottom: 6px;" data-i18n="deviceSelect">工作设备选择</label>
46
+ <select id="gpu-selector" onchange="switchGpu(this.value)" style="margin-bottom: 12px; font-size: 11px; padding: 6px;">
47
+ <option value="" data-i18n="gpuDetecting">正在检测 GPU...</option>
48
+ </select>
49
+
50
+
51
+ <label style="font-size: 11px; margin-bottom: 6px; margin-top: 12px;" data-i18n="modelLoraSettings">模型与LoRA设置</label>
52
+ <div id="lora-placement-hint" style="font-size: 11px; color: var(--text-dim); margin-top: 8px; line-height: 1.4;" data-i18n="loraPlacementHint">
53
+ </div>
54
+ </div>
55
+ </div>
56
+
57
+ <div class="sidebar-section">
58
+ <div class="tabs">
59
+ <div id="tab-video" class="tab" onclick="switchMode('video')">
60
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 6px;"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"></rect><line x1="7" y1="2" x2="7" y2="22"></line><line x1="17" y1="2" x2="17" y2="22"></line><line x1="2" y1="12" x2="22" y2="12"></line><line x1="2" y1="7" x2="7" y2="7"></line><line x1="2" y1="17" x2="7" y2="17"></line><line x1="17" y1="17" x2="22" y2="17"></line><line x1="17" y1="7" x2="22" y2="7"></line></svg>
61
+ <span data-i18n="tabVideo">视频生成</span>
62
+ </div>
63
+ <div id="tab-batch" class="tab" onclick="switchMode('batch')">
64
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 6px;"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>
65
+ <span data-i18n="tabBatch">智能多帧</span>
66
+ </div>
67
+ <div id="tab-upscale" class="tab" onclick="switchMode('upscale')">
68
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 6px;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
69
+ <span data-i18n="tabUpscale">视频增强</span>
70
+ </div>
71
+ <div id="tab-image" class="tab" onclick="switchMode('image')">
72
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 6px;"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>
73
+ <span data-i18n="tabImage">图像生成</span>
74
+ </div>
75
+ </div>
76
+
77
+ <label data-i18n="promptLabel">视觉描述词 (Prompt)</label>
78
+ <textarea id="prompt" data-i18n-placeholder="promptPlaceholder" placeholder="在此输入视觉描述词 (Prompt)..." style="height: 90px; margin-bottom: 0;"></textarea>
79
+ </div>
80
+
81
+ <!-- 视频模式选项 -->
82
+ <div class="sidebar-section" id="video-opts" style="display:none">
83
+ <div class="setting-group">
84
+ <div class="group-title" data-i18n="basicEngine">基础画面 / Basic EngineSpecs</div>
85
+ <div class="flex-row">
86
+ <div class="flex-1">
87
+ <label data-i18n="qualityLevel">清晰度级别</label>
88
+ <select id="vid-quality" onchange="updateResPreview()">
89
+ <option value="1080">1080P Full HD</option>
90
+ <option value="720" selected>720P Standard</option>
91
+ <option value="540">540P Preview</option>
92
+ </select>
93
+ </div>
94
+ <div class="flex-1">
95
+ <label data-i18n="aspectRatio">画幅比例</label>
96
+ <select id="vid-ratio" onchange="updateResPreview()">
97
+ <option value="16:9" data-i18n="ratio169">16:9 电影宽幅</option>
98
+ <option value="9:16" data-i18n="ratio916">9:16 移动竖屏</option>
99
+ </select>
100
+ </div>
101
+ </div>
102
+ <div id="res-preview" class="res-preview-tag" style="margin-top: -5px; margin-bottom: 12px;">最终发送: 1280x704</div>
103
+
104
+ <div class="flex-row">
105
+ <div class="flex-1">
106
+ <label data-i18n="fpsLabel">帧率 (FPS)</label>
107
+ <select id="vid-fps">
108
+ <option value="24" selected>24 FPS</option>
109
+ <option value="25">25 FPS</option>
110
+ <option value="30">30 FPS</option>
111
+ <option value="48">48 FPS</option>
112
+ <option value="60">60 FPS</option>
113
+ </select>
114
+ </div>
115
+ <div class="flex-1">
116
+ <label data-i18n="durationLabel">时长 (秒)</label>
117
+ <input type="number" id="vid-duration" value="5" min="1" max="30" step="1">
118
+ </div>
119
+ </div>
120
+
121
+ <label style="margin-top: 12px;" data-i18n="cameraMotion">镜头运动方式</label>
122
+ <select id="vid-motion">
123
+ <option value="static" selected data-i18n="motionStatic">Static (静止机位)</option>
124
+ <option value="dolly_in" data-i18n="motionDollyIn">Dolly In (推近)</option>
125
+ <option value="dolly_out" data-i18n="motionDollyOut">Dolly Out (拉远)</option>
126
+ <option value="dolly_left" data-i18n="motionDollyLeft">Dolly Left (向左)</option>
127
+ <option value="dolly_right" data-i18n="motionDollyRight">Dolly Right (向右)</option>
128
+ <option value="jib_up" data-i18n="motionJibUp">Jib Up (升臂)</option>
129
+ <option value="jib_down" data-i18n="motionJibDown">Jib Down (降臂)</option>
130
+ <option value="focus_shift" data-i18n="motionFocus">Focus Shift (焦点)</option>
131
+ </select>
132
+ <div class="checkbox-container" style="margin-top: 8px;">
133
+ <input type="checkbox" id="vid-audio" checked>
134
+ <label for="vid-audio" data-i18n="audioGen">生成 AI 环境音 (Audio Gen)</label>
135
+ </div>
136
+
137
+ <label style="margin-top: 12px;" data-i18n="selectModel">选择模型</label>
138
+ <select id="vid-model" style="margin-bottom: 8px;">
139
+ <option value="" data-i18n="defaultModel">使用默认模型</option>
140
+ </select>
141
+ <label data-i18n="selectLora">选择 LoRA</label>
142
+ <select id="vid-lora" onchange="updateLoraStrength()" style="margin-bottom: 8px;">
143
+ <option value="" data-i18n="noLora">不使用 LoRA</option>
144
+ </select>
145
+ <div id="lora-strength-container" style="display: none;">
146
+ <label data-i18n="loraStrength">LoRA 强度</label>
147
+ <input type="range" id="lora-strength" min="0.1" max="2.0" step="0.1" value="1.0" style="width: 100%;" oninput="document.getElementById('lora-strength-val').textContent = this.value">
148
+ <span id="lora-strength-val" style="font-size: 12px; color: var(--accent);">1.0</span>
149
+ </div>
150
+ </div>
151
+
152
+ <!-- 生成媒介组 -->
153
+ <div class="setting-group" id="video-source-group">
154
+ <div class="group-title" data-i18n="genSource">生成媒介 / Generation Source</div>
155
+
156
+ <div class="flex-row" style="margin-bottom: 10px;">
157
+ <div class="flex-1">
158
+ <label data-i18n="startFrame">起始帧 (首帧)</label>
159
+ <div class="upload-zone" id="start-frame-drop-zone" onclick="document.getElementById('start-frame-input').click()">
160
+ <div class="clear-img-overlay" id="clear-start-frame-overlay" onclick="event.stopPropagation(); clearFrame('start')">×</div>
161
+ <div id="start-frame-placeholder">
162
+ <div class="upload-icon">🖼️</div>
163
+ <div class="upload-text" data-i18n="uploadStart">上传首帧</div>
164
+ </div>
165
+ <img id="start-frame-preview" class="preview-thumb">
166
+ <input type="file" id="start-frame-input" accept="image/*" style="display:none" onchange="handleFrameUpload(this.files[0], 'start')">
167
+ </div>
168
+ <input type="hidden" id="start-frame-path">
169
+ </div>
170
+ <div class="flex-1">
171
+ <label data-i18n="endFrame">结束帧 (尾帧)</label>
172
+ <div class="upload-zone" id="end-frame-drop-zone" onclick="document.getElementById('end-frame-input').click()">
173
+ <div class="clear-img-overlay" id="clear-end-frame-overlay" onclick="event.stopPropagation(); clearFrame('end')">×</div>
174
+ <div id="end-frame-placeholder">
175
+ <div class="upload-icon">🏁</div>
176
+ <div class="upload-text" data-i18n="uploadEnd">上传尾帧 (可选)</div>
177
+ </div>
178
+ <img id="end-frame-preview" class="preview-thumb">
179
+ <input type="file" id="end-frame-input" accept="image/*" style="display:none" onchange="handleFrameUpload(this.files[0], 'end')">
180
+ </div>
181
+ <input type="hidden" id="end-frame-path">
182
+ </div>
183
+ </div>
184
+
185
+ <div class="flex-row">
186
+ <div class="flex-1">
187
+ <label data-i18n="refAudio">参考音频 (A2V)</label>
188
+ <div class="upload-zone" id="audio-drop-zone" onclick="document.getElementById('vid-audio-input').click()">
189
+ <div class="clear-img-overlay" id="clear-audio-overlay" onclick="event.stopPropagation(); clearUploadedAudio()">×</div>
190
+ <div id="audio-upload-placeholder">
191
+ <div class="upload-icon">🎵</div>
192
+ <div class="upload-text" data-i18n="uploadAudio">点击上传音频</div>
193
+ </div>
194
+ <div id="audio-upload-status" style="display:none;">
195
+ <div class="upload-icon" style="color:var(--accent); opacity:1;">✔️</div>
196
+ <div id="audio-filename-status" class="upload-text"></div>
197
+ </div>
198
+ <input type="file" id="vid-audio-input" accept="audio/*" style="display:none" onchange="handleAudioUpload(this.files[0])">
199
+ </div>
200
+ <input type="hidden" id="uploaded-audio-path">
201
+ </div>
202
+ </div>
203
+ <div style="font-size: 10px; color: var(--text-dim); text-align: center; margin-top: 5px;" data-i18n="sourceHint">
204
+ 💡 若仅上传首帧 = 图生视频/音视频;若同时上传首尾帧 = 首尾插帧。
205
+ </div>
206
+ </div>
207
+ </div>
208
+
209
+ <!-- 图像模式选项 -->
210
+ <div id="image-opts" class="sidebar-section" style="display:none">
211
+ <label data-i18n="imgPreset">预设分辨率 (Presets)</label>
212
+ <select id="img-res-preset" onchange="applyImgPreset(this.value)">
213
+ <option value="1024x1024" data-i18n="imgOptSquare">1:1 Square (1024x1024)</option>
214
+ <option value="1280x720" data-i18n="imgOptLand">16:9 Landscape (1280x720)</option>
215
+ <option value="720x1280" data-i18n="imgOptPort">9:16 Portrait (720x1280)</option>
216
+ <option value="custom" data-i18n="imgOptCustom">Custom 自定义...</option>
217
+ </select>
218
+
219
+ <div id="img-custom-res" class="flex-row" style="margin-top: 10px;">
220
+ <div class="flex-1"><label data-i18n="width">宽度</label><input type="number" id="img-w" value="1024" onchange="updateImgResPreview()"></div>
221
+ <div class="flex-1"><label data-i18n="height">高度</label><input type="number" id="img-h" value="1024" onchange="updateImgResPreview()"></div>
222
+ </div>
223
+ <div id="img-res-preview" class="res-preview-tag">最终发送: 1024x1024</div>
224
+
225
+ <div class="label-group" style="margin-top: 15px;">
226
+ <label data-i18n="samplingSteps">采样步数 (Steps)</label>
227
+ <span class="val-badge" id="stepsVal">28</span>
228
+ </div>
229
+ <div class="slider-container">
230
+ <input type="range" id="img-steps" min="1" max="50" value="28" oninput="document.getElementById('stepsVal').innerText=this.value">
231
+ </div>
232
+ </div>
233
+
234
+ <!-- 超分模式选项 -->
235
+ <div id="upscale-opts" class="sidebar-section" style="display:none">
236
+ <div class="setting-group">
237
+ <label data-i18n="upscaleSource">待超分视频 (Source)</label>
238
+ <div class="upload-zone" id="upscale-drop-zone" onclick="document.getElementById('upscale-video-input').click()" style="margin-bottom: 0;">
239
+ <div class="clear-img-overlay" id="clear-upscale-overlay" onclick="event.stopPropagation(); clearUpscaleVideo()">×</div>
240
+ <div id="upscale-placeholder">
241
+ <div class="upload-icon">📹</div>
242
+ <div class="upload-text" data-i18n="upscaleUpload">拖入低分辨率视频片段</div>
243
+ </div>
244
+ <div id="upscale-status" style="display:none;">
245
+ <div class="upload-icon" style="color:var(--accent); opacity:1;">✔️</div>
246
+ <div id="upscale-filename" class="upload-text"></div>
247
+ </div>
248
+ <input type="file" id="upscale-video-input" accept="video/*" style="display:none" onchange="handleUpscaleVideoUpload(this.files[0])">
249
+ </div>
250
+ <input type="hidden" id="upscale-video-path">
251
+ </div>
252
+
253
+ <div class="setting-group">
254
+ <label data-i18n="targetRes">目标分辨率</label>
255
+ <select id="upscale-res" style="margin-bottom: 0;">
256
+ <option value="1080p" data-i18n="upscale1080">1080P Full HD (2x)</option>
257
+ <option value="720p" data-i18n="upscale720">720P HD</option>
258
+ </select>
259
+ </div>
260
+ </div>
261
+
262
+ <!-- 智能多帧模式 -->
263
+ <div class="sidebar-section" id="batch-opts" style="display:none">
264
+ <div class="setting-group">
265
+ <div class="group-title" data-i18n="smartMultiFrameGroup">智能多帧</div>
266
+ <div class="smart-param-mode-label" data-i18n="workflowModeLabel">工作流模式(点击切换)</div>
267
+ <div class="smart-param-modes" role="radiogroup" aria-label="工作流模式">
268
+ <label class="smart-param-mode-opt">
269
+ <input type="radio" name="batch-workflow" value="single" checked onchange="onBatchWorkflowChange()">
270
+ <span class="smart-param-mode-title" data-i18n="wfSingle">单次多关键帧</span>
271
+ </label>
272
+ <label class="smart-param-mode-opt">
273
+ <input type="radio" name="batch-workflow" value="segments" onchange="onBatchWorkflowChange()">
274
+ <span class="smart-param-mode-title" data-i18n="wfSegments">分段拼接</span>
275
+ </label>
276
+ </div>
277
+
278
+ <label data-i18n="uploadImages">上传图片</label>
279
+ <div class="upload-zone" id="batch-images-drop-zone" onclick="document.getElementById('batch-images-input').click()" style="min-height: 72px; margin-bottom: 0;">
280
+ <div id="batch-images-placeholder">
281
+ <div class="upload-icon">📁</div>
282
+ <div class="upload-text" data-i18n="uploadMulti1">点击或拖入多张图片</div>
283
+ <div class="upload-hint" data-i18n="uploadMulti2">支持一次选多张,可多次添加</div>
284
+ </div>
285
+ <input type="file" id="batch-images-input" accept="image/*" multiple style="display:none" onchange="handleBatchImagesUpload(this.files, true)">
286
+ </div>
287
+ <input type="hidden" id="batch-images-path">
288
+
289
+ <div class="batch-thumb-strip-wrap" id="batch-thumb-strip-wrap" style="display: none;">
290
+ <div class="batch-thumb-strip-head">
291
+ <span class="batch-thumb-strip-title" data-i18n="batchStripTitle">已选图片 · 顺序 = 播放先后</span>
292
+ <span class="batch-thumb-strip-hint" data-i18n="batchStripHint">在缩略图上按住拖动排序;松手落入虚线框位置</span>
293
+ </div>
294
+ <div class="batch-images-container" id="batch-images-container"></div>
295
+ </div>
296
+
297
+ <div style="font-size: 10px; color: var(--text-dim); margin-bottom: 12px; margin-top: 10px; line-height: 1.45;" data-i18n-html="batchFfmpegHint">
298
+ 💡 <strong>分段模式</strong>:2 张 = 1 段;3 张 = 2 段再拼接。<strong>单次模式</strong>:几张图就几个 latent 锚点,一条视频出片。<br>
299
+ 多段需 <code style="font-size:9px;">ffmpeg</code>:装好后加 PATH,或设环境变量 <code style="font-size:9px;">LTX_FFMPEG_PATH</code>,或在 <code style="font-size:9px;">%LOCALAPPDATA%\LTXDesktop\ffmpeg_path.txt</code> 第一行写 ffmpeg.exe 完整路径。
300
+ </div>
301
+
302
+ <label style="margin-top: 4px;" data-i18n="globalPromptLabel">本页全局补充词(可选)</label>
303
+ <textarea id="batch-common-prompt" data-i18n-placeholder="globalPromptPh" placeholder="与顶部主 Prompt 叠加;单次模式与分段模式均可用" style="width: 100%; height: 56px; margin-bottom: 10px; padding: 8px; font-size: 11px; box-sizing: border-box; resize: vertical; border-radius: 8px; border: 1px solid var(--border); background: var(--item); color: var(--text);"></textarea>
304
+
305
+ <label style="margin-top: 8px;" data-i18n="bgmLabel">成片配乐(可选,统一音轨)</label>
306
+ <div class="upload-zone" id="batch-audio-drop-zone" onclick="document.getElementById('batch-audio-input').click()" style="min-height: 44px; margin-bottom: 8px; position: relative;">
307
+ <div class="clear-img-overlay" id="clear-batch-audio-overlay" onclick="event.stopPropagation(); clearBatchBackgroundAudio()" style="display: none;">×</div>
308
+ <div id="batch-audio-placeholder">
309
+ <div class="upload-text" style="font-size: 11px;" data-i18n="bgmUploadHint">上传一条完整 BGM(生成完成后会替换整段成片的音轨)</div>
310
+ </div>
311
+ <div id="batch-audio-status" style="display: none; font-size: 11px; color: var(--accent);"></div>
312
+ <input type="file" id="batch-audio-input" accept="audio/*" style="display:none" onchange="handleBatchBackgroundAudioUpload(this.files[0])">
313
+ </div>
314
+ <input type="hidden" id="batch-background-audio-path">
315
+
316
+ <div id="batch-segments-container" style="margin-top: 15px;"></div>
317
+ </div>
318
+
319
+ <div class="setting-group">
320
+ <div class="group-title" data-i18n="basicEngine">基础画面 / Basic EngineSpecs</div>
321
+ <div class="flex-row">
322
+ <div class="flex-1">
323
+ <label data-i18n="qualityLevel">清晰度级别</label>
324
+ <select id="batch-quality" onchange="updateBatchResPreview()">
325
+ <option value="1080">1080P Full HD</option>
326
+ <option value="720" selected>720P Standard</option>
327
+ <option value="540">540P Preview</option>
328
+ </select>
329
+ </div>
330
+ <div class="flex-1">
331
+ <label data-i18n="aspectRatio">画幅比例</label>
332
+ <select id="batch-ratio" onchange="updateBatchResPreview()">
333
+ <option value="16:9" data-i18n="ratio169">16:9 电影宽幅</option>
334
+ <option value="9:16" data-i18n="ratio916">9:16 移动竖屏</option>
335
+ </select>
336
+ </div>
337
+ </div>
338
+ <div id="batch-res-preview" class="res-preview-tag" style="margin-top: -5px; margin-bottom: 12px;">最终发送: 1280x704</div>
339
+
340
+ <label data-i18n="selectModel">选择模型</label>
341
+ <select id="batch-model" style="margin-bottom: 8px;">
342
+ <option value="" data-i18n="defaultModel">使用默认模型</option>
343
+ </select>
344
+ <label data-i18n="selectLora">选择 LoRA</label>
345
+ <select id="batch-lora" onchange="updateBatchLoraStrength()" style="margin-bottom: 8px;">
346
+ <option value="" data-i18n="noLora">不使用 LoRA</option>
347
+ </select>
348
+ <div id="batch-lora-strength-container" style="display: none;">
349
+ <label data-i18n="loraStrength">LoRA 强度</label>
350
+ <input type="range" id="batch-lora-strength" min="0.1" max="2.0" step="0.1" value="1.2" style="width: 100%;" oninput="document.getElementById('batch-lora-strength-val').textContent = this.value">
351
+ <span id="batch-lora-strength-val" style="font-size: 12px; color: var(--accent);">1.2</span>
352
+ </div>
353
+ </div>
354
+ </div>
355
+
356
+ <div style="padding: 0 30px 30px 30px;">
357
+ <button class="btn-primary" id="mainBtn" onclick="run()" data-i18n="mainRender">开始渲染</button>
358
+ </div>
359
+ </aside>
360
+
361
+ <main class="workspace">
362
+ <section class="viewer" id="viewer-section">
363
+ <div class="monitor" id="viewer">
364
+ <div id="loading-txt" data-i18n="waitingTask">等待分配渲染任务...</div>
365
+ <img id="res-img" src="">
366
+ <div id="video-wrapper" style="width:100%; height:100%; display:none; max-height:100%; align-items:center; justify-content:center;">
367
+ <video id="res-video" autoplay loop playsinline></video>
368
+ </div>
369
+ <div class="progress-container"><div id="progress-fill"></div></div>
370
+ </div>
371
+ </section>
372
+
373
+ <!-- Drag Handle -->
374
+ <div id="resize-handle" style="
375
+ height: 5px; background: transparent; cursor: row-resize;
376
+ flex-shrink: 0; position: relative; z-index: 50;
377
+ display: flex; align-items: center; justify-content: center;
378
+ " data-i18n-title="resizeHandleTitle" title="拖动调整面板高度">
379
+ <div style="width: 40px; height: 3px; background: var(--border); border-radius: 999px; pointer-events: none;"></div>
380
+ </div>
381
+
382
+ <section class="library" id="library-section">
383
+ <div style="display: flex; justify-content: space-between; margin-bottom: 15px; align-items: center; border-bottom: 1px solid var(--border); padding-bottom: 10px;">
384
+ <div style="display: flex; gap: 20px;">
385
+ <span id="tab-history" style="font-size: 11px; font-weight: 800; color: var(--accent); cursor: pointer; border-bottom: 2px solid var(--accent); padding-bottom: 11px; margin-bottom: -11px;" onclick="switchLibTab('history')" data-i18n="libHistory">历史资产 / ASSETS</span>
386
+ <span id="tab-log" style="font-size: 11px; font-weight: 800; color: var(--text-dim); cursor: pointer; border-bottom: 2px solid transparent; padding-bottom: 11px; margin-bottom: -11px;" onclick="switchLibTab('log')" data-i18n="libLog">系统日志 / LOGS</span>
387
+ </div>
388
+ <button type="button" onclick="fetchHistory(currentHistoryPage)" style="background: var(--item); border: 1px solid var(--border); border-radius: 6px; color: var(--text-dim); font-size: 11px; padding: 4px 10px; cursor: pointer;" data-i18n="refresh">刷新</button>
389
+ </div>
390
+
391
+ <div id="log-container" style="display: none; flex: 1; flex-direction: column;">
392
+ <div id="log" data-i18n="logReady">> LTX-2 Studio Ready. Expecting commands...</div>
393
+ </div>
394
+
395
+ <div id="history-wrapper">
396
+ <div id="history-container"></div>
397
+ </div>
398
+ <div id="pagination-bar" style="display:none;"></div>
399
+ </section>
400
+ </main>
401
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/plyr/3.7.8/plyr.min.js"></script>
402
+ <script src="i18n.js"></script>
403
+ <script src="index.js"></script>
404
+
405
+ </body>
406
+ </html>
LTX2.3/UI/index.js ADDED
@@ -0,0 +1,2042 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ─── Resizable panel drag logic ───────────────────────────────────────────────
2
+ (function() {
3
+ const handle = document.getElementById('resize-handle');
4
+ const viewer = document.getElementById('viewer-section');
5
+ const library = document.getElementById('library-section');
6
+ const workspace = document.querySelector('.workspace');
7
+ let dragging = false, startY = 0, startVH = 0;
8
+
9
+ handle.addEventListener('mousedown', (e) => {
10
+ dragging = true;
11
+ startY = e.clientY;
12
+ startVH = viewer.getBoundingClientRect().height;
13
+ document.body.style.cursor = 'row-resize';
14
+ document.body.style.userSelect = 'none';
15
+ handle.querySelector('div').style.background = 'var(--accent)';
16
+ e.preventDefault();
17
+ });
18
+ document.addEventListener('mousemove', (e) => {
19
+ if (!dragging) return;
20
+ const wsH = workspace.getBoundingClientRect().height;
21
+ const delta = e.clientY - startY;
22
+ let newVH = startVH + delta;
23
+ // Clamp: viewer min 150px, library min 100px
24
+ newVH = Math.max(150, Math.min(wsH - 100 - 5, newVH));
25
+ viewer.style.flex = 'none';
26
+ viewer.style.height = newVH + 'px';
27
+ library.style.flex = '1';
28
+ });
29
+ document.addEventListener('mouseup', () => {
30
+ if (dragging) {
31
+ dragging = false;
32
+ document.body.style.cursor = '';
33
+ document.body.style.userSelect = '';
34
+ handle.querySelector('div').style.background = 'var(--border)';
35
+ }
36
+ });
37
+ // Hover highlight
38
+ handle.addEventListener('mouseenter', () => { handle.querySelector('div').style.background = 'var(--text-dim)'; });
39
+ handle.addEventListener('mouseleave', () => { if (!dragging) handle.querySelector('div').style.background = 'var(--border)'; });
40
+ })();
41
+ // ──────────────────────────────────────────────────────────────────────────────
42
+
43
+
44
+
45
+
46
+
47
+
48
+ // 动态获取当前访问的域名或 IP,自动对齐 3000 端口
49
+ const BASE = `http://${window.location.hostname}:3000`;
50
+
51
+ function _t(k) {
52
+ return typeof window.t === 'function' ? window.t(k) : k;
53
+ }
54
+
55
+ let currentMode = 'image';
56
+ let pollInterval = null;
57
+ let availableModels = [];
58
+ let availableLoras = [];
59
+
60
+ // 建议增加一个简单的调试日志,方便在控制台确认地址是否正确
61
+ console.log("Connecting to Backend API at:", BASE);
62
+
63
+ // 模型扫描功能
64
+ async function scanModels() {
65
+ try {
66
+ const url = `${BASE}/api/models`;
67
+ console.log("Scanning models from:", url);
68
+ const res = await fetch(url);
69
+ const data = await res.json().catch(() => ({}));
70
+ console.log("Models response:", res.status, data);
71
+ if (!res.ok) {
72
+ const msg = data.message || data.error || res.statusText;
73
+ addLog(`❌ 模型扫描失败 (${res.status}): ${msg}`);
74
+ availableModels = [];
75
+ updateModelDropdown();
76
+ updateBatchModelDropdown();
77
+ return;
78
+ }
79
+ availableModels = data.models || [];
80
+ updateModelDropdown();
81
+ updateBatchModelDropdown();
82
+ if (availableModels.length > 0) {
83
+ addLog(`📂 已扫描到 ${availableModels.length} 个模型: ${availableModels.map(m => m.name).join(', ')}`);
84
+ }
85
+ } catch (e) {
86
+ console.log("Model scan error:", e);
87
+ addLog(`❌ 模型扫描异常: ${e.message || e}`);
88
+ }
89
+ }
90
+
91
+ function updateModelDropdown() {
92
+ const select = document.getElementById('vid-model');
93
+ if (!select) return;
94
+ select.innerHTML = '<option value="">' + _t('defaultModel') + '</option>';
95
+ availableModels.forEach(model => {
96
+ const opt = document.createElement('option');
97
+ opt.value = model.path;
98
+ opt.textContent = model.name;
99
+ select.appendChild(opt);
100
+ });
101
+ }
102
+
103
+ // LoRA 扫描功能
104
+ async function scanLoras() {
105
+ try {
106
+ const url = `${BASE}/api/loras`;
107
+ console.log("Scanning LoRA from:", url);
108
+ const res = await fetch(url);
109
+ const data = await res.json().catch(() => ({}));
110
+ console.log("LoRA response:", res.status, data);
111
+ if (!res.ok) {
112
+ const msg = data.message || data.error || res.statusText;
113
+ addLog(`❌ LoRA 扫描失败 (${res.status}): ${msg}`);
114
+ availableLoras = [];
115
+ updateLoraDropdown();
116
+ updateBatchLoraDropdown();
117
+ return;
118
+ }
119
+ availableLoras = data.loras || [];
120
+ updateLoraDropdown();
121
+ updateBatchLoraDropdown();
122
+ if (data.loras_dir) {
123
+ const hintEl = document.getElementById('lora-placement-hint');
124
+ if (hintEl) {
125
+ const tpl = _t('loraPlacementHintWithDir');
126
+ hintEl.innerHTML = tpl.replace(
127
+ '{dir}',
128
+ escapeHtmlAttr(data.models_dir || data.loras_dir)
129
+ );
130
+ }
131
+ }
132
+ if (availableLoras.length > 0) {
133
+ addLog(`📂 已扫描到 ${availableLoras.length} 个 LoRA: ${availableLoras.map(l => l.name).join(', ')}`);
134
+ }
135
+ } catch (e) {
136
+ console.log("LoRA scan error:", e);
137
+ addLog(`❌ LoRA 扫描异常: ${e.message || e}`);
138
+ }
139
+ }
140
+
141
+ function updateLoraDropdown() {
142
+ const select = document.getElementById('vid-lora');
143
+ if (!select) return;
144
+ select.innerHTML = '<option value="">' + _t('noLora') + '</option>';
145
+ availableLoras.forEach(lora => {
146
+ const opt = document.createElement('option');
147
+ opt.value = lora.path;
148
+ opt.textContent = lora.name;
149
+ select.appendChild(opt);
150
+ });
151
+ }
152
+
153
+ function updateLoraStrength() {
154
+ const select = document.getElementById('vid-lora');
155
+ const container = document.getElementById('lora-strength-container');
156
+ if (select && container) {
157
+ container.style.display = select.value ? 'flex' : 'none';
158
+ }
159
+ }
160
+
161
+ // 更新批量模式的模型和LoRA下拉框
162
+ function updateBatchModelDropdown() {
163
+ const select = document.getElementById('batch-model');
164
+ if (!select) return;
165
+ select.innerHTML = '<option value="">' + _t('defaultModel') + '</option>';
166
+ availableModels.forEach(model => {
167
+ const opt = document.createElement('option');
168
+ opt.value = model.path;
169
+ opt.textContent = model.name;
170
+ select.appendChild(opt);
171
+ });
172
+ }
173
+
174
+ function updateBatchLoraDropdown() {
175
+ const select = document.getElementById('batch-lora');
176
+ if (!select) return;
177
+ select.innerHTML = '<option value="">' + _t('noLora') + '</option>';
178
+ availableLoras.forEach(lora => {
179
+ const opt = document.createElement('option');
180
+ opt.value = lora.path;
181
+ opt.textContent = lora.name;
182
+ select.appendChild(opt);
183
+ });
184
+ }
185
+
186
+ // 页面加载时更新批量模式的下拉框
187
+ function initBatchDropdowns() {
188
+ updateBatchModelDropdown();
189
+ updateBatchLoraDropdown();
190
+ }
191
+
192
+ // 已移除:模型/LoRA 目录自定义与浏览(保持后端默认路径扫描)
193
+
194
+ // 页面加载时扫描模型和LoRA(使用后端默认目录规则)
195
+ (function() {
196
+ ['vid-quality', 'batch-quality'].forEach((id) => {
197
+ const sel = document.getElementById(id);
198
+ if (sel && sel.value === '544') sel.value = '540';
199
+ });
200
+
201
+ setTimeout(() => {
202
+ scanModels();
203
+ scanLoras();
204
+ initBatchDropdowns();
205
+ }, 1500);
206
+ })();
207
+
208
+ // 分辨率自动计算逻辑
209
+ function updateResPreview() {
210
+ const q = document.getElementById('vid-quality').value; // "1080", "720", "540"
211
+ const r = document.getElementById('vid-ratio').value;
212
+
213
+ // 核心修复:后端解析器期待 "1080p", "720p", "540p" 这种标签格式
214
+ let resLabel = q === "1080" ? "1080p" : q === "720" ? "720p" : "540p";
215
+
216
+ /* 与后端一致:宽高均为 64 的倍数(LTX 内核要求) */
217
+ let resDisplay;
218
+ if (r === "16:9") {
219
+ resDisplay = q === "1080" ? "1920x1088" : q === "720" ? "1280x704" : "1024x576";
220
+ } else {
221
+ resDisplay = q === "1080" ? "1088x1920" : q === "720" ? "704x1280" : "576x1024";
222
+ }
223
+
224
+ document.getElementById('res-preview').innerText = `${_t('resPreviewPrefix')}: ${resLabel} (${resDisplay})`;
225
+ return resLabel;
226
+ }
227
+
228
+ // 图片分辨率预览
229
+ function updateImgResPreview() {
230
+ const w = document.getElementById('img-w').value;
231
+ const h = document.getElementById('img-h').value;
232
+ document.getElementById('img-res-preview').innerText = `${_t('resPreviewPrefix')}: ${w}x${h}`;
233
+ }
234
+
235
+ // 批量模式分辨率预览
236
+ function updateBatchResPreview() {
237
+ const q = document.getElementById('batch-quality').value;
238
+ const r = document.getElementById('batch-ratio').value;
239
+ let resLabel = q === "1080" ? "1080p" : q === "720" ? "720p" : "540p";
240
+ let resDisplay;
241
+ if (r === "16:9") {
242
+ resDisplay = q === "1080" ? "1920x1088" : q === "720" ? "1280x704" : "1024x576";
243
+ } else {
244
+ resDisplay = q === "1080" ? "1088x1920" : q === "720" ? "704x1280" : "576x1024";
245
+ }
246
+ document.getElementById('batch-res-preview').innerText = `${_t('resPreviewPrefix')}: ${resLabel} (${resDisplay})`;
247
+ return resLabel;
248
+ }
249
+
250
+ // 批量模式 LoRA 强度切换
251
+ function updateBatchLoraStrength() {
252
+ const select = document.getElementById('batch-lora');
253
+ const container = document.getElementById('batch-lora-strength-container');
254
+ if (select && container) {
255
+ container.style.display = select.value ? 'flex' : 'none';
256
+ }
257
+ }
258
+
259
+ // 切换图片预设分辨率
260
+ function applyImgPreset(val) {
261
+ if (val === "custom") {
262
+ document.getElementById('img-custom-res').style.display = 'flex';
263
+ } else {
264
+ const [w, h] = val.split('x');
265
+ document.getElementById('img-w').value = w;
266
+ document.getElementById('img-h').value = h;
267
+ updateImgResPreview();
268
+ // 隐藏自定义区域或保持显示供微调
269
+ // document.getElementById('img-custom-res').style.display = 'none';
270
+ }
271
+ }
272
+
273
+
274
+
275
+ // 处理帧图片上传
276
+ async function handleFrameUpload(file, frameType) {
277
+ if (!file) return;
278
+
279
+ const preview = document.getElementById(`${frameType}-frame-preview`);
280
+ const placeholder = document.getElementById(`${frameType}-frame-placeholder`);
281
+ const clearOverlay = document.getElementById(`clear-${frameType}-frame-overlay`);
282
+
283
+ const previewReader = new FileReader();
284
+ previewReader.onload = (e) => {
285
+ preview.src = e.target.result;
286
+ preview.style.display = 'block';
287
+ placeholder.style.display = 'none';
288
+ clearOverlay.style.display = 'flex';
289
+ };
290
+ previewReader.readAsDataURL(file);
291
+
292
+ const reader = new FileReader();
293
+ reader.onload = async (e) => {
294
+ const b64Data = e.target.result;
295
+ addLog(`正在上传 ${frameType === 'start' ? '起始帧' : '结束帧'}: ${file.name}...`);
296
+ try {
297
+ const res = await fetch(`${BASE}/api/system/upload-image`, {
298
+ method: 'POST',
299
+ headers: { 'Content-Type': 'application/json' },
300
+ body: JSON.stringify({ image: b64Data, filename: file.name })
301
+ });
302
+ const data = await res.json();
303
+ if (res.ok && data.path) {
304
+ document.getElementById(`${frameType}-frame-path`).value = data.path;
305
+ addLog(`✅ ${frameType === 'start' ? '起始帧' : '结束帧'}上传成功`);
306
+ } else {
307
+ throw new Error(data.error || data.detail || "上传失败");
308
+ }
309
+ } catch (e) {
310
+ addLog(`❌ 帧图片上传失败: ${e.message}`);
311
+ }
312
+ };
313
+ reader.readAsDataURL(file);
314
+ }
315
+
316
+ function clearFrame(frameType) {
317
+ document.getElementById(`${frameType}-frame-input`).value = "";
318
+ document.getElementById(`${frameType}-frame-path`).value = "";
319
+ document.getElementById(`${frameType}-frame-preview`).style.display = 'none';
320
+ document.getElementById(`${frameType}-frame-preview`).src = "";
321
+ document.getElementById(`${frameType}-frame-placeholder`).style.display = 'block';
322
+ document.getElementById(`clear-${frameType}-frame-overlay`).style.display = 'none';
323
+ addLog(`🧹 已清除${frameType === 'start' ? '起始帧' : '结束帧'}`);
324
+ }
325
+
326
+ // 处理图片上传
327
+ async function handleImageUpload(file) {
328
+ if (!file) return;
329
+
330
+ // 预览图片
331
+ const preview = document.getElementById('upload-preview');
332
+ const placeholder = document.getElementById('upload-placeholder');
333
+ const clearOverlay = document.getElementById('clear-img-overlay');
334
+
335
+ const previewReader = new FileReader();
336
+ preview.onload = () => {
337
+ preview.style.display = 'block';
338
+ placeholder.style.display = 'none';
339
+ clearOverlay.style.display = 'flex';
340
+ };
341
+ previewReader.onload = (e) => preview.src = e.target.result;
342
+ previewReader.readAsDataURL(file);
343
+
344
+ // 使用 FileReader 转换为 Base64,绕过后端缺失 python-multipart 的问题
345
+ const reader = new FileReader();
346
+ reader.onload = async (e) => {
347
+ const b64Data = e.target.result;
348
+ addLog(`正在上传参考图: ${file.name}...`);
349
+ try {
350
+ const res = await fetch(`${BASE}/api/system/upload-image`, {
351
+ method: 'POST',
352
+ headers: { 'Content-Type': 'application/json' },
353
+ body: JSON.stringify({
354
+ image: b64Data,
355
+ filename: file.name
356
+ })
357
+ });
358
+ const data = await res.json();
359
+ if (res.ok && data.path) {
360
+ document.getElementById('uploaded-img-path').value = data.path;
361
+ addLog(`✅ 参考图上传成功: ${file.name}`);
362
+ } else {
363
+ const errMsg = data.error || data.detail || "上传失败";
364
+ throw new Error(typeof errMsg === 'string' ? errMsg : JSON.stringify(errMsg));
365
+ }
366
+ } catch (e) {
367
+ addLog(`❌ 图片上传失败: ${e.message}`);
368
+ }
369
+ };
370
+ reader.onerror = () => addLog("❌ 读取本地文件失败");
371
+ reader.readAsDataURL(file);
372
+ }
373
+
374
+ function clearUploadedImage() {
375
+ document.getElementById('vid-image-input').value = "";
376
+ document.getElementById('uploaded-img-path').value = "";
377
+ document.getElementById('upload-preview').style.display = 'none';
378
+ document.getElementById('upload-preview').src = "";
379
+ document.getElementById('upload-placeholder').style.display = 'block';
380
+ document.getElementById('clear-img-overlay').style.display = 'none';
381
+ addLog("🧹 已清除参考图");
382
+ }
383
+
384
+ // 处理音频上传
385
+ async function handleAudioUpload(file) {
386
+ if (!file) return;
387
+
388
+ const placeholder = document.getElementById('audio-upload-placeholder');
389
+ const statusDiv = document.getElementById('audio-upload-status');
390
+ const filenameStatus = document.getElementById('audio-filename-status');
391
+ const clearOverlay = document.getElementById('clear-audio-overlay');
392
+
393
+ placeholder.style.display = 'none';
394
+ filenameStatus.innerText = file.name;
395
+ statusDiv.style.display = 'block';
396
+ clearOverlay.style.display = 'flex';
397
+
398
+ const reader = new FileReader();
399
+ reader.onload = async (e) => {
400
+ const b64Data = e.target.result;
401
+ addLog(`正在上传音频: ${file.name}...`);
402
+ try {
403
+ // 复用图片上传接口,后端已支持任意文件类型
404
+ const res = await fetch(`${BASE}/api/system/upload-image`, {
405
+ method: 'POST',
406
+ headers: { 'Content-Type': 'application/json' },
407
+ body: JSON.stringify({
408
+ image: b64Data,
409
+ filename: file.name
410
+ })
411
+ });
412
+ const data = await res.json();
413
+ if (res.ok && data.path) {
414
+ document.getElementById('uploaded-audio-path').value = data.path;
415
+ addLog(`✅ 音频上传成功: ${file.name}`);
416
+ } else {
417
+ const errMsg = data.error || data.detail || "上传失败";
418
+ throw new Error(typeof errMsg === 'string' ? errMsg : JSON.stringify(errMsg));
419
+ }
420
+ } catch (e) {
421
+ addLog(`❌ 音频上传失败: ${e.message}`);
422
+ }
423
+ };
424
+ reader.onerror = () => addLog("❌ 读取本地音频文件失败");
425
+ reader.readAsDataURL(file);
426
+ }
427
+
428
+ function clearUploadedAudio() {
429
+ document.getElementById('vid-audio-input').value = "";
430
+ document.getElementById('uploaded-audio-path').value = "";
431
+ document.getElementById('audio-upload-placeholder').style.display = 'block';
432
+ document.getElementById('audio-upload-status').style.display = 'none';
433
+ document.getElementById('clear-audio-overlay').style.display = 'none';
434
+ addLog("🧹 已清除音频文件");
435
+ }
436
+
437
+ // 处理超分视频上传
438
+ async function handleUpscaleVideoUpload(file) {
439
+ if (!file) return;
440
+ const placeholder = document.getElementById('upscale-placeholder');
441
+ const statusDiv = document.getElementById('upscale-status');
442
+ const filenameStatus = document.getElementById('upscale-filename');
443
+ const clearOverlay = document.getElementById('clear-upscale-overlay');
444
+
445
+ filenameStatus.innerText = file.name;
446
+ placeholder.style.display = 'none';
447
+ statusDiv.style.display = 'block';
448
+ clearOverlay.style.display = 'flex';
449
+
450
+ const reader = new FileReader();
451
+ reader.onload = async (e) => {
452
+ const b64Data = e.target.result;
453
+ addLog(`正在上传待超分视频: ${file.name}...`);
454
+ try {
455
+ const res = await fetch(`${BASE}/api/system/upload-image`, {
456
+ method: 'POST',
457
+ headers: { 'Content-Type': 'application/json' },
458
+ body: JSON.stringify({ image: b64Data, filename: file.name })
459
+ });
460
+ const data = await res.json();
461
+ if (res.ok && data.path) {
462
+ document.getElementById('upscale-video-path').value = data.path;
463
+ addLog(`✅ 视频上传成功`);
464
+ } else {
465
+ throw new Error(data.error || "上传失败");
466
+ }
467
+ } catch (e) {
468
+ addLog(`❌ 视频上传失败: ${e.message}`);
469
+ }
470
+ };
471
+ reader.readAsDataURL(file);
472
+ }
473
+
474
+ function clearUpscaleVideo() {
475
+ document.getElementById('upscale-video-input').value = "";
476
+ document.getElementById('upscale-video-path').value = "";
477
+ document.getElementById('upscale-placeholder').style.display = 'block';
478
+ document.getElementById('upscale-status').style.display = 'none';
479
+ document.getElementById('clear-upscale-overlay').style.display = 'none';
480
+ addLog("🧹 已清除待超分视频");
481
+ }
482
+
483
+ // 初始化拖拽上传逻辑
484
+ function initDragAndDrop() {
485
+ const audioDropZone = document.getElementById('audio-drop-zone');
486
+ const startFrameDropZone = document.getElementById('start-frame-drop-zone');
487
+ const endFrameDropZone = document.getElementById('end-frame-drop-zone');
488
+ const upscaleDropZone = document.getElementById('upscale-drop-zone');
489
+ const batchImagesDropZone = document.getElementById('batch-images-drop-zone');
490
+
491
+ const zones = [audioDropZone, startFrameDropZone, endFrameDropZone, upscaleDropZone, batchImagesDropZone];
492
+
493
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
494
+ zones.forEach(zone => {
495
+ if (!zone) return;
496
+ zone.addEventListener(eventName, (e) => {
497
+ e.preventDefault();
498
+ e.stopPropagation();
499
+ }, false);
500
+ });
501
+ });
502
+
503
+ ['dragenter', 'dragover'].forEach(eventName => {
504
+ zones.forEach(zone => {
505
+ if (!zone) return;
506
+ zone.addEventListener(eventName, () => zone.classList.add('dragover'), false);
507
+ });
508
+ });
509
+
510
+ ['dragleave', 'drop'].forEach(eventName => {
511
+ zones.forEach(zone => {
512
+ if (!zone) return;
513
+ zone.addEventListener(eventName, () => zone.classList.remove('dragover'), false);
514
+ });
515
+ });
516
+
517
+ audioDropZone.addEventListener('drop', (e) => {
518
+ const file = e.dataTransfer.files[0];
519
+ if (file && file.type.startsWith('audio/')) handleAudioUpload(file);
520
+ }, false);
521
+
522
+ startFrameDropZone.addEventListener('drop', (e) => {
523
+ const file = e.dataTransfer.files[0];
524
+ if (file && file.type.startsWith('image/')) handleFrameUpload(file, 'start');
525
+ }, false);
526
+
527
+ endFrameDropZone.addEventListener('drop', (e) => {
528
+ const file = e.dataTransfer.files[0];
529
+ if (file && file.type.startsWith('image/')) handleFrameUpload(file, 'end');
530
+ }, false);
531
+
532
+ upscaleDropZone.addEventListener('drop', (e) => {
533
+ const file = e.dataTransfer.files[0];
534
+ if (file && file.type.startsWith('video/')) handleUpscaleVideoUpload(file);
535
+ }, false);
536
+
537
+ // 批量图片拖拽上传
538
+ if (batchImagesDropZone) {
539
+ batchImagesDropZone.addEventListener('drop', (e) => {
540
+ e.preventDefault();
541
+ e.stopPropagation();
542
+ batchImagesDropZone.classList.remove('dragover');
543
+ const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/'));
544
+ if (files.length > 0) handleBatchImagesUpload(files);
545
+ }, false);
546
+ }
547
+ }
548
+
549
+ // 批量图片上传处理
550
+ let batchImages = [];
551
+ /** 单次多关键帧:按 path 记引导强度;按段索引 0..n-2 记「上一张→本张」间隔秒数 */
552
+ const batchKfStrengthByPath = {};
553
+ const batchKfSegDurByIndex = {};
554
+
555
+ function escapeHtmlAttr(s) {
556
+ return String(s)
557
+ .replace(/&/g, '&amp;')
558
+ .replace(/"/g, '&quot;')
559
+ .replace(/</g, '&lt;');
560
+ }
561
+
562
+ function defaultKeyframeStrengthForIndex(i, n) {
563
+ if (n <= 2) return '1';
564
+ if (i === 0) return '0.62';
565
+ if (i === n - 1) return '1';
566
+ return '0.42';
567
+ }
568
+
569
+ function captureBatchKfTimelineFromDom() {
570
+ batchImages.forEach((img, i) => {
571
+ if (!img.path) return;
572
+ const sEl = document.getElementById(`batch-kf-strength-${i}`);
573
+ if (sEl) batchKfStrengthByPath[img.path] = sEl.value.trim();
574
+ });
575
+ const n = batchImages.length;
576
+ for (let j = 0; j < n - 1; j++) {
577
+ const el = document.getElementById(`batch-kf-seg-dur-${j}`);
578
+ if (el) batchKfSegDurByIndex[j] = el.value.trim();
579
+ }
580
+ }
581
+
582
+ /** 读取间隔(秒),非法则回退为 minSeg */
583
+ function readBatchKfSegmentSeconds(n, minSeg) {
584
+ const seg = [];
585
+ for (let j = 0; j < n - 1; j++) {
586
+ let v = parseFloat(document.getElementById(`batch-kf-seg-dur-${j}`)?.value);
587
+ if (!Number.isFinite(v) || v < minSeg) v = minSeg;
588
+ seg.push(v);
589
+ }
590
+ return seg;
591
+ }
592
+
593
+ function updateBatchKfTimelineDerivedUI() {
594
+ if (!batchWorkflowIsSingle() || batchImages.length < 2) return;
595
+ const n = batchImages.length;
596
+ const minSeg = 0.1;
597
+ const seg = readBatchKfSegmentSeconds(n, minSeg);
598
+ let t = 0;
599
+ for (let i = 0; i < n; i++) {
600
+ const label = document.getElementById(`batch-kf-anchor-label-${i}`);
601
+ if (!label) continue;
602
+ if (i === 0) {
603
+ label.textContent = `0.0 s · ${_t('batchAnchorStart')}`;
604
+ } else {
605
+ t += seg[i - 1];
606
+ label.textContent =
607
+ i === n - 1
608
+ ? `${t.toFixed(1)} s · ${_t('batchAnchorEnd')}`
609
+ : `${t.toFixed(1)} s`;
610
+ }
611
+ }
612
+ const totalEl = document.getElementById('batch-kf-total-seconds');
613
+ if (totalEl) {
614
+ const sum = seg.reduce((a, b) => a + b, 0);
615
+ totalEl.textContent = sum.toFixed(1);
616
+ }
617
+ }
618
+ async function handleBatchImagesUpload(files, append = true) {
619
+ if (!files || files.length === 0) return;
620
+ addLog(`正在上传 ${files.length} 张图片...`);
621
+
622
+ for (let i = 0; i < files.length; i++) {
623
+ const file = files[i];
624
+ const reader = new FileReader();
625
+
626
+ const imgData = await new Promise((resolve) => {
627
+ reader.onload = async (e) => {
628
+ const b64Data = e.target.result;
629
+ try {
630
+ const res = await fetch(`${BASE}/api/system/upload-image`, {
631
+ method: 'POST',
632
+ headers: { 'Content-Type': 'application/json' },
633
+ body: JSON.stringify({ image: b64Data, filename: file.name })
634
+ });
635
+ const data = await res.json();
636
+ if (res.ok && data.path) {
637
+ resolve({ name: file.name, path: data.path, preview: e.target.result });
638
+ } else {
639
+ resolve(null);
640
+ }
641
+ } catch (e) {
642
+ resolve(null);
643
+ }
644
+ };
645
+ reader.readAsDataURL(file);
646
+ });
647
+
648
+ if (imgData) {
649
+ batchImages.push(imgData);
650
+ addLog(`✅ 图片 ${i + 1}/${files.length} 上传成功: ${file.name}`);
651
+ }
652
+ }
653
+
654
+ renderBatchImages();
655
+ updateBatchSegments();
656
+ }
657
+
658
+ async function handleBatchBackgroundAudioUpload(file) {
659
+ if (!file) return;
660
+ const ph = document.getElementById('batch-audio-placeholder');
661
+ const st = document.getElementById('batch-audio-status');
662
+ const overlay = document.getElementById('clear-batch-audio-overlay');
663
+ const reader = new FileReader();
664
+ reader.onload = async (e) => {
665
+ const b64Data = e.target.result;
666
+ addLog(`正在上传成片配乐: ${file.name}...`);
667
+ try {
668
+ const res = await fetch(`${BASE}/api/system/upload-image`, {
669
+ method: 'POST',
670
+ headers: { 'Content-Type': 'application/json' },
671
+ body: JSON.stringify({ image: b64Data, filename: file.name })
672
+ });
673
+ const data = await res.json();
674
+ if (res.ok && data.path) {
675
+ const hid = document.getElementById('batch-background-audio-path');
676
+ if (hid) hid.value = data.path;
677
+ if (ph) ph.style.display = 'none';
678
+ if (st) {
679
+ st.style.display = 'block';
680
+ st.textContent = '✓ ' + file.name;
681
+ }
682
+ if (overlay) overlay.style.display = 'flex';
683
+ addLog('✅ 成片配乐已上传(将覆盖各片段自带音轨)');
684
+ } else {
685
+ addLog(`❌ 配乐上传失败: ${data.error || '未知错误'}`);
686
+ }
687
+ } catch (err) {
688
+ addLog(`❌ 配乐上传失败: ${err.message}`);
689
+ }
690
+ };
691
+ reader.onerror = () => addLog('❌ 读取音频文件失败');
692
+ reader.readAsDataURL(file);
693
+ }
694
+
695
+ function clearBatchBackgroundAudio() {
696
+ const hid = document.getElementById('batch-background-audio-path');
697
+ const inp = document.getElementById('batch-audio-input');
698
+ if (hid) hid.value = '';
699
+ if (inp) inp.value = '';
700
+ const ph = document.getElementById('batch-audio-placeholder');
701
+ const st = document.getElementById('batch-audio-status');
702
+ const overlay = document.getElementById('clear-batch-audio-overlay');
703
+ if (ph) ph.style.display = 'block';
704
+ if (st) {
705
+ st.style.display = 'none';
706
+ st.textContent = '';
707
+ }
708
+ if (overlay) overlay.style.display = 'none';
709
+ addLog('🧹 已清除成片配乐');
710
+ }
711
+
712
+ function syncBatchDropZoneChrome() {
713
+ const dropZone = document.getElementById('batch-images-drop-zone');
714
+ const placeholder = document.getElementById('batch-images-placeholder');
715
+ const stripWrap = document.getElementById('batch-thumb-strip-wrap');
716
+ if (batchImages.length === 0) {
717
+ if (dropZone) {
718
+ dropZone.classList.remove('has-images');
719
+ const mini = dropZone.querySelector('.upload-placeholder-mini');
720
+ if (mini) mini.remove();
721
+ }
722
+ if (placeholder) placeholder.style.display = 'block';
723
+ if (stripWrap) stripWrap.style.display = 'none';
724
+ return;
725
+ }
726
+ if (placeholder) placeholder.style.display = 'none';
727
+ if (dropZone) dropZone.classList.add('has-images');
728
+ if (stripWrap) stripWrap.style.display = 'block';
729
+ if (dropZone && !dropZone.querySelector('.upload-placeholder-mini')) {
730
+ const mini = document.createElement('div');
731
+ mini.className = 'upload-placeholder-mini';
732
+ mini.innerHTML = '<span>' + _t('batchAddMore') + '</span>';
733
+ dropZone.appendChild(mini);
734
+ }
735
+ }
736
+
737
+ let batchDragPlaceholderEl = null;
738
+ let batchPointerState = null;
739
+ let batchPendingPhX = null;
740
+ let batchPhMoveRaf = null;
741
+
742
+ function batchRemoveFloatingGhost() {
743
+ document.querySelectorAll('.batch-thumb-floating-ghost').forEach((n) => n.remove());
744
+ }
745
+
746
+ function batchCancelPhMoveRaf() {
747
+ if (batchPhMoveRaf != null) {
748
+ cancelAnimationFrame(batchPhMoveRaf);
749
+ batchPhMoveRaf = null;
750
+ }
751
+ batchPendingPhX = null;
752
+ }
753
+
754
+ function batchEnsurePlaceholder() {
755
+ if (batchDragPlaceholderEl && batchDragPlaceholderEl.isConnected) return batchDragPlaceholderEl;
756
+ const el = document.createElement('div');
757
+ el.className = 'batch-thumb-drop-slot';
758
+ el.setAttribute('aria-hidden', 'true');
759
+ batchDragPlaceholderEl = el;
760
+ return el;
761
+ }
762
+
763
+ function batchRemovePlaceholder() {
764
+ if (batchDragPlaceholderEl && batchDragPlaceholderEl.parentNode) {
765
+ batchDragPlaceholderEl.parentNode.removeChild(batchDragPlaceholderEl);
766
+ }
767
+ }
768
+
769
+ function batchComputeInsertIndex(container, placeholder) {
770
+ let t = 0;
771
+ for (const child of container.children) {
772
+ if (child === placeholder) return t;
773
+ if (child.classList && child.classList.contains('batch-image-wrapper')) {
774
+ if (!child.classList.contains('batch-thumb--source')) t++;
775
+ }
776
+ }
777
+ return t;
778
+ }
779
+
780
+ function batchMovePlaceholderFromPoint(container, clientX) {
781
+ const ph = batchEnsurePlaceholder();
782
+ const wrappers = [...container.querySelectorAll('.batch-image-wrapper')];
783
+ let insertBefore = null;
784
+ for (const w of wrappers) {
785
+ if (w.classList.contains('batch-thumb--source')) continue;
786
+ const r = w.getBoundingClientRect();
787
+ if (clientX < r.left + r.width / 2) {
788
+ insertBefore = w;
789
+ break;
790
+ }
791
+ }
792
+ if (insertBefore === null) {
793
+ const vis = wrappers.filter((w) => !w.classList.contains('batch-thumb--source'));
794
+ const last = vis[vis.length - 1];
795
+ if (last) {
796
+ if (last.nextSibling) {
797
+ container.insertBefore(ph, last.nextSibling);
798
+ } else {
799
+ container.appendChild(ph);
800
+ }
801
+ } else {
802
+ container.appendChild(ph);
803
+ }
804
+ } else {
805
+ container.insertBefore(ph, insertBefore);
806
+ }
807
+ }
808
+
809
+ function batchFlushPlaceholderMove() {
810
+ batchPhMoveRaf = null;
811
+ if (!batchPointerState || batchPendingPhX == null) return;
812
+ batchMovePlaceholderFromPoint(batchPointerState.container, batchPendingPhX);
813
+ }
814
+
815
+ function handleBatchPointerMove(e) {
816
+ if (!batchPointerState) return;
817
+ e.preventDefault();
818
+ const st = batchPointerState;
819
+ st.ghostTX = e.clientX - st.offsetX;
820
+ st.ghostTY = e.clientY - st.offsetY;
821
+ batchPendingPhX = e.clientX;
822
+ if (batchPhMoveRaf == null) {
823
+ batchPhMoveRaf = requestAnimationFrame(batchFlushPlaceholderMove);
824
+ }
825
+ }
826
+
827
+ function batchGhostFrame() {
828
+ const st = batchPointerState;
829
+ if (!st || !st.ghostEl || !st.ghostEl.isConnected) {
830
+ return;
831
+ }
832
+ const t = 0.42;
833
+ st.ghostCX += (st.ghostTX - st.ghostCX) * t;
834
+ st.ghostCY += (st.ghostTY - st.ghostCY) * t;
835
+ st.ghostEl.style.transform =
836
+ `translate3d(${st.ghostCX}px,${st.ghostCY}px,0) scale(1.06) rotate(-1deg)`;
837
+ st.ghostRaf = requestAnimationFrame(batchGhostFrame);
838
+ }
839
+
840
+ function batchStartGhostLoop() {
841
+ const st = batchPointerState;
842
+ if (!st || !st.ghostEl) return;
843
+ if (st.ghostRaf != null) cancelAnimationFrame(st.ghostRaf);
844
+ st.ghostRaf = requestAnimationFrame(batchGhostFrame);
845
+ }
846
+
847
+ function batchEndPointerDrag(e) {
848
+ if (!batchPointerState) return;
849
+ if (e.pointerId !== batchPointerState.pointerId) return;
850
+ const st = batchPointerState;
851
+
852
+ batchCancelPhMoveRaf();
853
+ if (st.ghostRaf != null) {
854
+ cancelAnimationFrame(st.ghostRaf);
855
+ st.ghostRaf = null;
856
+ }
857
+ if (st.ghostEl && st.ghostEl.parentNode) {
858
+ st.ghostEl.remove();
859
+ }
860
+ batchPointerState = null;
861
+
862
+ document.removeEventListener('pointermove', handleBatchPointerMove);
863
+ document.removeEventListener('pointerup', batchEndPointerDrag);
864
+ document.removeEventListener('pointercancel', batchEndPointerDrag);
865
+
866
+ try {
867
+ if (st.wrapperEl) st.wrapperEl.releasePointerCapture(st.pointerId);
868
+ } catch (_) {}
869
+
870
+ const { fromIndex, container, wrapperEl } = st;
871
+ container.classList.remove('is-batch-settling');
872
+ if (!batchDragPlaceholderEl || !batchDragPlaceholderEl.parentNode) {
873
+ if (wrapperEl) wrapperEl.classList.remove('batch-thumb--source');
874
+ renderBatchImages();
875
+ updateBatchSegments();
876
+ return;
877
+ }
878
+ const to = batchComputeInsertIndex(container, batchDragPlaceholderEl);
879
+ batchRemovePlaceholder();
880
+ if (wrapperEl) wrapperEl.classList.remove('batch-thumb--source');
881
+
882
+ if (fromIndex !== to && fromIndex >= 0 && to >= 0) {
883
+ const [item] = batchImages.splice(fromIndex, 1);
884
+ batchImages.splice(to, 0, item);
885
+ updateBatchSegments();
886
+ }
887
+ renderBatchImages();
888
+ }
889
+
890
+ function handleBatchPointerDown(e) {
891
+ if (batchPointerState) return;
892
+ if (e.button !== 0) return;
893
+ if (e.target.closest && e.target.closest('.batch-thumb-remove')) return;
894
+
895
+ const wrapper = e.currentTarget;
896
+ const container = document.getElementById('batch-images-container');
897
+ if (!container) return;
898
+
899
+ e.preventDefault();
900
+ e.stopPropagation();
901
+
902
+ const fromIndex = parseInt(wrapper.dataset.index, 10);
903
+ if (Number.isNaN(fromIndex)) return;
904
+
905
+ const rect = wrapper.getBoundingClientRect();
906
+ const offsetX = e.clientX - rect.left;
907
+ const offsetY = e.clientY - rect.top;
908
+ const startLeft = rect.left;
909
+ const startTop = rect.top;
910
+
911
+ const ghost = document.createElement('div');
912
+ ghost.className = 'batch-thumb-floating-ghost';
913
+ const gImg = document.createElement('img');
914
+ const srcImg = wrapper.querySelector('img');
915
+ gImg.src = srcImg ? srcImg.src : '';
916
+ gImg.alt = '';
917
+ ghost.appendChild(gImg);
918
+ document.body.appendChild(ghost);
919
+
920
+ batchPointerState = {
921
+ fromIndex,
922
+ pointerId: e.pointerId,
923
+ wrapperEl: wrapper,
924
+ container,
925
+ ghostEl: ghost,
926
+ offsetX,
927
+ offsetY,
928
+ ghostTX: e.clientX - offsetX,
929
+ ghostTY: e.clientY - offsetY,
930
+ ghostCX: startLeft,
931
+ ghostCY: startTop,
932
+ ghostRaf: null
933
+ };
934
+
935
+ ghost.style.transform =
936
+ `translate3d(${startLeft}px,${startTop}px,0) scale(1.06) rotate(-1deg)`;
937
+
938
+ container.classList.add('is-batch-settling');
939
+ wrapper.classList.add('batch-thumb--source');
940
+ const ph = batchEnsurePlaceholder();
941
+ container.insertBefore(ph, wrapper.nextSibling);
942
+ /* 不在 pointerdown 立刻重算槽位;双 rAF 后再恢复邻居 transition,保证先完成本帧布局再动画 */
943
+ requestAnimationFrame(() => {
944
+ requestAnimationFrame(() => {
945
+ container.classList.remove('is-batch-settling');
946
+ });
947
+ });
948
+
949
+ batchStartGhostLoop();
950
+
951
+ document.addEventListener('pointermove', handleBatchPointerMove, { passive: false });
952
+ document.addEventListener('pointerup', batchEndPointerDrag);
953
+ document.addEventListener('pointercancel', batchEndPointerDrag);
954
+
955
+ try {
956
+ wrapper.setPointerCapture(e.pointerId);
957
+ } catch (_) {}
958
+ }
959
+
960
+ function removeBatchImage(index) {
961
+ if (index < 0 || index >= batchImages.length) return;
962
+ batchImages.splice(index, 1);
963
+ renderBatchImages();
964
+ updateBatchSegments();
965
+ }
966
+
967
+ // 横向缩略图:Pointer 拖动排序(避免 HTML5 DnD 在 WebView/部分浏览器失效)
968
+ function renderBatchImages() {
969
+ const container = document.getElementById('batch-images-container');
970
+ if (!container) return;
971
+
972
+ syncBatchDropZoneChrome();
973
+ batchRemovePlaceholder();
974
+ batchCancelPhMoveRaf();
975
+ batchRemoveFloatingGhost();
976
+ batchPointerState = null;
977
+ container.classList.remove('is-batch-settling');
978
+ container.innerHTML = '';
979
+
980
+ batchImages.forEach((img, index) => {
981
+ const wrapper = document.createElement('div');
982
+ wrapper.className = 'batch-image-wrapper';
983
+ wrapper.dataset.index = String(index);
984
+ wrapper.title = _t('batchThumbDrag');
985
+
986
+ const imgWrap = document.createElement('div');
987
+ imgWrap.className = 'batch-thumb-img-wrap';
988
+ const im = document.createElement('img');
989
+ im.className = 'batch-thumb-img';
990
+ im.src = img.preview;
991
+ im.alt = img.name || '';
992
+ im.draggable = false;
993
+ imgWrap.appendChild(im);
994
+
995
+ const del = document.createElement('button');
996
+ del.type = 'button';
997
+ del.className = 'batch-thumb-remove';
998
+ del.title = _t('batchThumbRemove');
999
+ del.setAttribute('aria-label', _t('batchThumbRemove'));
1000
+ del.textContent = '×';
1001
+ del.addEventListener('pointerdown', (ev) => ev.stopPropagation());
1002
+ del.addEventListener('click', (ev) => {
1003
+ ev.stopPropagation();
1004
+ removeBatchImage(index);
1005
+ });
1006
+
1007
+ wrapper.appendChild(imgWrap);
1008
+ wrapper.appendChild(del);
1009
+
1010
+ wrapper.addEventListener('pointerdown', handleBatchPointerDown);
1011
+
1012
+ container.appendChild(wrapper);
1013
+ });
1014
+ }
1015
+
1016
+ function batchWorkflowIsSingle() {
1017
+ const r = document.querySelector('input[name="batch-workflow"]:checked');
1018
+ return !!(r && r.value === 'single');
1019
+ }
1020
+
1021
+ function onBatchWorkflowChange() {
1022
+ updateBatchSegments();
1023
+ }
1024
+
1025
+ // 更新片段设置 UI(分段模式)或单次多关键帧设置
1026
+ function updateBatchSegments() {
1027
+ const container = document.getElementById('batch-segments-container');
1028
+ if (!container) return;
1029
+
1030
+ if (batchImages.length < 2) {
1031
+ container.innerHTML =
1032
+ '<div style="color: var(--text-dim); font-size: 11px;">' +
1033
+ escapeHtmlAttr(_t('batchNeedTwo')) +
1034
+ '</div>';
1035
+ return;
1036
+ }
1037
+
1038
+ if (batchWorkflowIsSingle()) {
1039
+ if (batchImages.length >= 2) captureBatchKfTimelineFromDom();
1040
+ const n = batchImages.length;
1041
+ const defaultTotal = 8;
1042
+ const defaultSeg =
1043
+ n > 1 ? (defaultTotal / (n - 1)).toFixed(1) : '4';
1044
+ let blocks = '';
1045
+ batchImages.forEach((img, i) => {
1046
+ const path = img.path || '';
1047
+ const stDef = defaultKeyframeStrengthForIndex(i, n);
1048
+ const stStored = batchKfStrengthByPath[path];
1049
+ const stVal = stStored !== undefined && stStored !== ''
1050
+ ? escapeHtmlAttr(stStored)
1051
+ : stDef;
1052
+ const prev = escapeHtmlAttr(img.preview || '');
1053
+ if (i > 0) {
1054
+ const j = i - 1;
1055
+ const sdStored = batchKfSegDurByIndex[j];
1056
+ const segVal =
1057
+ sdStored !== undefined && sdStored !== ''
1058
+ ? escapeHtmlAttr(sdStored)
1059
+ : defaultSeg;
1060
+ blocks += `
1061
+ <div class="batch-kf-gap">
1062
+ <div class="batch-kf-gap-rail" aria-hidden="true"></div>
1063
+ <div class="batch-kf-gap-inner">
1064
+ <span class="batch-kf-gap-ix">${i}→${i + 1}</span>
1065
+ <label class="batch-kf-seg-field">
1066
+ <input type="number" class="batch-kf-seg-input" id="batch-kf-seg-dur-${j}"
1067
+ value="${segVal}" min="0.1" max="120" step="0.1"
1068
+ title="${escapeHtmlAttr(_t('batchGapInputTitle'))}"
1069
+ oninput="updateBatchKfTimelineDerivedUI()">
1070
+ <span class="batch-kf-gap-unit">${escapeHtmlAttr(_t('batchSec'))}</span>
1071
+ </label>
1072
+ </div>
1073
+ </div>`;
1074
+ }
1075
+ blocks += `
1076
+ <div class="batch-kf-kcard">
1077
+ <div class="batch-kf-kcard-head">
1078
+ <img class="batch-kf-kthumb" src="${prev}" alt="">
1079
+ <div class="batch-kf-kcard-titles">
1080
+ <span class="batch-kf-ktitle">${escapeHtmlAttr(_t('batchKfTitle'))} ${i + 1} / ${n}</span>
1081
+ <span class="batch-kf-anchor" id="batch-kf-anchor-label-${i}">—</span>
1082
+ </div>
1083
+ </div>
1084
+ <div class="batch-kf-kcard-ctrl">
1085
+ <label class="batch-kf-klabel">${escapeHtmlAttr(_t('batchStrength'))}
1086
+ <input type="number" id="batch-kf-strength-${i}" value="${stVal}" min="0.1" max="1" step="0.01"
1087
+ title="${escapeHtmlAttr(_t('batchStrengthTitle'))}">
1088
+ </label>
1089
+ </div>
1090
+ </div>`;
1091
+ });
1092
+ container.innerHTML = `
1093
+ <div class="batch-kf-panel" id="batch-kf-timeline-root">
1094
+ <div class="batch-kf-panel-hd">
1095
+ <div class="batch-kf-panel-title">${escapeHtmlAttr(_t('batchKfPanelTitle'))}</div>
1096
+ <div class="batch-kf-total-pill" title="${escapeHtmlAttr(_t('batchTotalPillTitle'))}">
1097
+ ${escapeHtmlAttr(_t('batchTotalDur'))} <strong id="batch-kf-total-seconds">—</strong> <span class="batch-kf-total-unit">${escapeHtmlAttr(_t('batchTotalSec'))}</span>
1098
+ </div>
1099
+ </div>
1100
+ <p class="batch-kf-panel-hint">${escapeHtmlAttr(_t('batchPanelHint'))}</p>
1101
+ <div class="batch-kf-timeline-col">
1102
+ ${blocks}
1103
+ </div>
1104
+ </div>`;
1105
+ updateBatchKfTimelineDerivedUI();
1106
+ return;
1107
+ }
1108
+
1109
+ let html =
1110
+ '<div style="font-size: 12px; font-weight: bold; margin-bottom: 10px;">' +
1111
+ escapeHtmlAttr(_t('batchSegTitle')) +
1112
+ '</div>';
1113
+
1114
+ for (let i = 0; i < batchImages.length - 1; i++) {
1115
+ const segPh = escapeHtmlAttr(_t('batchSegPromptPh'));
1116
+ html += `
1117
+ <div style="background: var(--item); border-radius: 8px; padding: 10px; margin-bottom: 10px; border: 1px solid var(--border);">
1118
+ <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
1119
+ <div style="display: flex; align-items: center; gap: 8px;">
1120
+ <img src="${batchImages[i].preview}" style="width: 40px; height: 40px; border-radius: 4px; object-fit: cover;">
1121
+ <span style="color: var(--accent);">→</span>
1122
+ <img src="${batchImages[i + 1].preview}" style="width: 40px; height: 40px; border-radius: 4px; object-fit: cover;">
1123
+ <span style="font-size: 11px; color: var(--text-dim);">${escapeHtmlAttr(_t('batchSegClip'))} ${i + 1}</span>
1124
+ </div>
1125
+ <div style="display: flex; align-items: center; gap: 6px;">
1126
+ <label style="font-size: 10px; color: var(--text-dim);">${escapeHtmlAttr(_t('batchSegDuration'))}</label>
1127
+ <input type="number" id="batch-segment-duration-${i}" value="5" min="1" max="30" step="1" style="width: 50px; padding: 4px; font-size: 11px;">
1128
+ <span style="font-size: 10px; color: var(--text-dim);">${escapeHtmlAttr(_t('batchSegSec'))}</span>
1129
+ </div>
1130
+ </div>
1131
+ <div>
1132
+ <label style="font-size: 10px;">${escapeHtmlAttr(_t('batchSegPrompt'))}</label>
1133
+ <textarea id="batch-segment-prompt-${i}" placeholder="${segPh}" style="width: 100%; height: 60px; padding: 6px; font-size: 11px; box-sizing: border-box; resize: vertical;"></textarea>
1134
+ </div>
1135
+ </div>
1136
+ `;
1137
+ }
1138
+
1139
+ container.innerHTML = html;
1140
+ }
1141
+
1142
+ let _isGeneratingFlag = false;
1143
+
1144
+ // 系统状态轮询
1145
+ async function checkStatus() {
1146
+ try {
1147
+ const h = await fetch(`${BASE}/health`).then(r => r.json()).catch(() => ({status: "error"}));
1148
+ const g = await fetch(`${BASE}/api/gpu-info`).then(r => r.json()).catch(() => ({gpu_info: {}}));
1149
+ const p = await fetch(`${BASE}/api/generation/progress`).then(r => r.json()).catch(() => ({progress: 0}));
1150
+ const sysGpus = await fetch(`${BASE}/api/system/list-gpus`).then(r => r.json()).catch(() => ({gpus: []}));
1151
+
1152
+ const activeGpu = (sysGpus.gpus || []).find(x => x.active) || (sysGpus.gpus || [])[0] || {};
1153
+ const gpuName = activeGpu.name || g.gpu_info?.name || "GPU";
1154
+
1155
+ const s = document.getElementById('sys-status');
1156
+ const indicator = document.getElementById('sys-indicator');
1157
+
1158
+ const isReady = h.status === "ok" || h.status === "ready" || h.models_loaded;
1159
+ const backendActive = (p && p.progress > 0);
1160
+
1161
+ if (_isGeneratingFlag || backendActive) {
1162
+ s.innerText = `${gpuName}: ${_t('sysBusy')}`;
1163
+ if(indicator) indicator.className = 'indicator-busy';
1164
+ } else {
1165
+ s.innerText = isReady ? `${gpuName}: ${_t('sysOnline')}` : `${gpuName}: ${_t('sysStarting')}`;
1166
+ if(indicator) indicator.className = isReady ? 'indicator-ready' : 'indicator-offline';
1167
+ }
1168
+ s.style.color = "var(--text-dim)";
1169
+
1170
+ const vUsedMB = g.gpu_info?.vramUsed || 0;
1171
+ const vTotalMB = activeGpu.vram_mb || g.gpu_info?.vram || 32768;
1172
+ const vUsedGB = vUsedMB / 1024;
1173
+ const vTotalGB = vTotalMB / 1024;
1174
+
1175
+ document.getElementById('vram-fill').style.width = (vUsedMB / vTotalMB * 100) + "%";
1176
+ document.getElementById('vram-text').innerText = `${vUsedGB.toFixed(1)} / ${vTotalGB.toFixed(0)} GB`;
1177
+ } catch(e) { document.getElementById('sys-status').innerText = _t('sysOffline'); }
1178
+ }
1179
+ setInterval(checkStatus, 1000); // 提升到 1 秒一次实时监控
1180
+ checkStatus();
1181
+ initDragAndDrop();
1182
+ listGpus(); // 初始化 GPU 列表
1183
+ // 已移除:输出目录自定义(保持后端默认路径)
1184
+
1185
+ updateResPreview();
1186
+ updateBatchResPreview();
1187
+ updateImgResPreview();
1188
+ refreshPromptPlaceholder();
1189
+
1190
+ window.onUiLanguageChanged = function () {
1191
+ updateResPreview();
1192
+ updateBatchResPreview();
1193
+ updateImgResPreview();
1194
+ refreshPromptPlaceholder();
1195
+ if (typeof currentMode !== 'undefined' && currentMode === 'batch') {
1196
+ updateBatchSegments();
1197
+ }
1198
+ updateModelDropdown();
1199
+ updateLoraDropdown();
1200
+ updateBatchModelDropdown();
1201
+ updateBatchLoraDropdown();
1202
+ };
1203
+
1204
+ async function setOutputDir() {
1205
+ const dir = document.getElementById('global-out-dir').value.trim();
1206
+ localStorage.setItem('output_dir', dir);
1207
+ try {
1208
+ const res = await fetch(`${BASE}/api/system/set-dir`, {
1209
+ method: 'POST',
1210
+ headers: { 'Content-Type': 'application/json' },
1211
+ body: JSON.stringify({ directory: dir })
1212
+ });
1213
+ if (res.ok) {
1214
+ addLog(`✅ 存储路径更新成功! 当前路径: ${dir || _t('defaultPath')}`);
1215
+ if (typeof fetchHistory === 'function') fetchHistory(currentHistoryPage);
1216
+ }
1217
+ } catch (e) {
1218
+ addLog(`❌ 设置路径时连接异常: ${e.message}`);
1219
+ }
1220
+ }
1221
+
1222
+ async function browseOutputDir() {
1223
+ try {
1224
+ const res = await fetch(`${BASE}/api/system/browse-dir`);
1225
+ const data = await res.json();
1226
+ if (data.status === "success" && data.directory) {
1227
+ document.getElementById('global-out-dir').value = data.directory;
1228
+ // auto apply immediately
1229
+ setOutputDir();
1230
+ addLog(`📂 检测到新路径,已自动套用!`);
1231
+ } else if (data.error) {
1232
+ addLog(`❌ 内部系统权限拦截了弹窗: ${data.error}`);
1233
+ }
1234
+ } catch (e) {
1235
+ addLog(`❌ 无法调出文件夹浏览弹窗, 请直接复制粘贴绝对路径。`);
1236
+ }
1237
+ }
1238
+
1239
+ async function getOutputDir() {
1240
+ try {
1241
+ const res = await fetch(`${BASE}/api/system/get-dir`);
1242
+ const data = await res.json();
1243
+ if (data.directory && data.directory.indexOf('LTXDesktop') === -1 && document.getElementById('global-out-dir')) {
1244
+ document.getElementById('global-out-dir').value = data.directory;
1245
+ }
1246
+ } catch (e) {}
1247
+ }
1248
+
1249
+ function switchMode(m) {
1250
+ currentMode = m;
1251
+ document.getElementById('tab-image').classList.toggle('active', m === 'image');
1252
+ document.getElementById('tab-video').classList.toggle('active', m === 'video');
1253
+ document.getElementById('tab-batch').classList.toggle('active', m === 'batch');
1254
+ document.getElementById('tab-upscale').classList.toggle('active', m === 'upscale');
1255
+
1256
+ document.getElementById('image-opts').style.display = m === 'image' ? 'block' : 'none';
1257
+ document.getElementById('video-opts').style.display = m === 'video' ? 'block' : 'none';
1258
+ document.getElementById('batch-opts').style.display = m === 'batch' ? 'block' : 'none';
1259
+ document.getElementById('upscale-opts').style.display = m === 'upscale' ? 'block' : 'none';
1260
+ if (m === 'batch') updateBatchSegments();
1261
+
1262
+ // 如果切到图像模式,隐藏提示词框外的其他东西
1263
+ refreshPromptPlaceholder();
1264
+ }
1265
+
1266
+ function refreshPromptPlaceholder() {
1267
+ const pe = document.getElementById('prompt');
1268
+ if (!pe) return;
1269
+ pe.placeholder =
1270
+ currentMode === 'upscale' ? _t('promptPlaceholderUpscale') : _t('promptPlaceholder');
1271
+ }
1272
+
1273
+ function showGeneratingView() {
1274
+ if (!_isGeneratingFlag) return;
1275
+ const resImg = document.getElementById('res-img');
1276
+ const videoWrapper = document.getElementById('video-wrapper');
1277
+ if (resImg) resImg.style.display = "none";
1278
+ if (videoWrapper) videoWrapper.style.display = "none";
1279
+ if (player) {
1280
+ try { player.stop(); } catch(_) {}
1281
+ } else {
1282
+ const vid = document.getElementById('res-video');
1283
+ if (vid) { vid.pause(); vid.removeAttribute('src'); vid.load(); }
1284
+ }
1285
+ const loadingTxt = document.getElementById('loading-txt');
1286
+ if (loadingTxt) loadingTxt.style.display = "flex";
1287
+ }
1288
+
1289
+ async function run() {
1290
+ // 防止重复点击(_isGeneratingFlag 比 btn.disabled 更可靠)
1291
+ if (_isGeneratingFlag) {
1292
+ addLog(_t('warnGenerating'));
1293
+ return;
1294
+ }
1295
+
1296
+ const btn = document.getElementById('mainBtn');
1297
+ const promptEl = document.getElementById('prompt');
1298
+ const prompt = promptEl ? promptEl.value.trim() : '';
1299
+
1300
+ function batchHasUsablePrompt() {
1301
+ if (prompt) return true;
1302
+ const c = document.getElementById('batch-common-prompt')?.value?.trim();
1303
+ if (c) return true;
1304
+ if (typeof batchWorkflowIsSingle === 'function' && batchWorkflowIsSingle()) {
1305
+ return false;
1306
+ }
1307
+ if (batchImages.length < 2) return false;
1308
+ for (let i = 0; i < batchImages.length - 1; i++) {
1309
+ if (document.getElementById(`batch-segment-prompt-${i}`)?.value?.trim()) return true;
1310
+ }
1311
+ return false;
1312
+ }
1313
+
1314
+ if (currentMode !== 'upscale') {
1315
+ if (currentMode === 'batch') {
1316
+ if (!batchHasUsablePrompt()) {
1317
+ addLog(_t('warnBatchPrompt'));
1318
+ return;
1319
+ }
1320
+ } else if (!prompt) {
1321
+ addLog(_t('warnNeedPrompt'));
1322
+ return;
1323
+ }
1324
+ }
1325
+
1326
+ if (!btn) {
1327
+ console.error('mainBtn not found');
1328
+ return;
1329
+ }
1330
+
1331
+ // 先设置标志 + 禁用按钮,然后用顶层 try/finally 保证一定能解锁
1332
+ _isGeneratingFlag = true;
1333
+ btn.disabled = true;
1334
+
1335
+ try {
1336
+ // 安全地操作 UI 元素(改用 if 判空,防止 Plyr 接管后 getElementById 返回 null)
1337
+ const loader = document.getElementById('loading-txt');
1338
+ const resImg = document.getElementById('res-img');
1339
+ const resVideo = document.getElementById('res-video');
1340
+
1341
+ if (loader) {
1342
+ loader.style.display = "flex";
1343
+ loader.style.flexDirection = "column";
1344
+ loader.style.alignItems = "center";
1345
+ loader.style.gap = "12px";
1346
+ loader.innerHTML = `
1347
+ <div class="spinner" style="width:48px;height:48px;border-width:4px;color:var(--accent);"></div>
1348
+ <div id="loader-step-text" style="font-size:13px;font-weight:700;color:var(--text-sub);">${escapeHtmlAttr(_t('loaderGpuAlloc'))}</div>
1349
+ `;
1350
+ }
1351
+ if (resImg) resImg.style.display = "none";
1352
+ // 必须隐藏整个 video-wrapper(Plyr 外层容器),否则第二次生成时视频会与 spinner 叠加
1353
+ const videoWrapper = document.getElementById('video-wrapper');
1354
+ if (videoWrapper) videoWrapper.style.display = "none";
1355
+ if (player) { try { player.stop(); } catch(_) {} }
1356
+ else if (resVideo) { resVideo.pause?.(); resVideo.removeAttribute?.('src'); }
1357
+
1358
+ checkStatus();
1359
+
1360
+ // 重置后端状态锁(非关键,失败不影响主流程)
1361
+ try { await fetch(`${BASE}/api/system/reset-state`, { method: 'POST' }); } catch(_) {}
1362
+
1363
+ startProgressPolling();
1364
+
1365
+ // ---- 新增:在历史记录区插入「正在渲染」缩略图卡片 ----
1366
+ const historyContainer = document.getElementById('history-container');
1367
+ if (historyContainer) {
1368
+ const old = document.getElementById('current-loading-card');
1369
+ if (old) old.remove();
1370
+ const loadingCard = document.createElement('div');
1371
+ loadingCard.className = 'history-card loading-card';
1372
+ loadingCard.id = 'current-loading-card';
1373
+ loadingCard.onclick = showGeneratingView;
1374
+ loadingCard.innerHTML = `
1375
+ <div class="spinner"></div>
1376
+ <div id="loading-card-step" style="font-size:10px;color:var(--text-dim);margin-top:4px;">等待中...</div>
1377
+ `;
1378
+ historyContainer.prepend(loadingCard);
1379
+ }
1380
+
1381
+ // ---- 构建请求 ----
1382
+ let endpoint, payload;
1383
+ if (currentMode === 'image') {
1384
+ const w = parseInt(document.getElementById('img-w').value);
1385
+ const h = parseInt(document.getElementById('img-h').value);
1386
+ endpoint = '/api/generate-image';
1387
+ payload = {
1388
+ prompt, width: w, height: h,
1389
+ numSteps: parseInt(document.getElementById('img-steps').value),
1390
+ numImages: 1
1391
+ };
1392
+ addLog(`正在发起图像渲染: ${w}x${h}, Steps: ${payload.numSteps}`);
1393
+
1394
+ } else if (currentMode === 'video') {
1395
+ const res = updateResPreview();
1396
+ const dur = parseFloat(document.getElementById('vid-duration').value);
1397
+ const fps = document.getElementById('vid-fps').value;
1398
+ if (dur > 20) addLog(_t('warnVideoLong').replace('{n}', String(dur)));
1399
+
1400
+ const audio = document.getElementById('vid-audio').checked ? "true" : "false";
1401
+ const audioPath = document.getElementById('uploaded-audio-path').value;
1402
+ const startFramePathValue = document.getElementById('start-frame-path').value;
1403
+ const endFramePathValue = document.getElementById('end-frame-path').value;
1404
+
1405
+ let finalImagePath = null, finalStartFramePath = null, finalEndFramePath = null;
1406
+ if (startFramePathValue && endFramePathValue) {
1407
+ finalStartFramePath = startFramePathValue;
1408
+ finalEndFramePath = endFramePathValue;
1409
+ } else if (startFramePathValue) {
1410
+ finalImagePath = startFramePathValue;
1411
+ }
1412
+
1413
+ endpoint = '/api/generate';
1414
+ const modelSelect = document.getElementById('vid-model');
1415
+ const loraSelect = document.getElementById('vid-lora');
1416
+ const loraStrengthInput = document.getElementById('lora-strength');
1417
+ const modelPath = modelSelect ? modelSelect.value : '';
1418
+ const loraPath = loraSelect ? loraSelect.value : '';
1419
+ const loraStrength = loraStrengthInput ? (parseFloat(loraStrengthInput.value) || 1.0) : 1.0;
1420
+ console.log("modelPath:", modelPath);
1421
+ console.log("loraPath:", loraPath);
1422
+ console.log("loraStrength:", loraStrength);
1423
+ payload = {
1424
+ prompt, resolution: res, model: "ltx-2",
1425
+ cameraMotion: document.getElementById('vid-motion').value,
1426
+ negativePrompt: "low quality, blurry, noisy, static noise, distorted",
1427
+ duration: String(dur), fps, audio,
1428
+ imagePath: finalImagePath,
1429
+ audioPath: audioPath || null,
1430
+ startFramePath: finalStartFramePath,
1431
+ endFramePath: finalEndFramePath,
1432
+ aspectRatio: document.getElementById('vid-ratio').value,
1433
+ modelPath: modelPath || null,
1434
+ loraPath: loraPath || null,
1435
+ loraStrength: loraStrength,
1436
+ };
1437
+ addLog(`正在发起视频渲染: ${res}, 时长: ${dur}s, FPS: ${fps}, 模型: ${modelPath ? modelPath.split(/[/\\]/).pop() : _t('modelDefaultLabel')}, LoRA: ${loraPath ? loraPath.split(/[/\\]/).pop() : _t('loraNoneLabel')}`);
1438
+
1439
+ } else if (currentMode === 'upscale') {
1440
+ const videoPath = document.getElementById('upscale-video-path').value;
1441
+ const targetRes = document.getElementById('upscale-res').value;
1442
+ if (!videoPath) throw new Error(_t('errUpscaleNoVideo'));
1443
+ endpoint = '/api/system/upscale-video';
1444
+ payload = { video_path: videoPath, resolution: targetRes, prompt: "high quality, detailed, 4k", strength: 0.7 };
1445
+ addLog(`正在发起视频超分: 目标 ${targetRes}`);
1446
+ } else if (currentMode === 'batch') {
1447
+ const res = updateBatchResPreview();
1448
+ const commonPromptEl = document.getElementById('batch-common-prompt');
1449
+ const commonPrompt = commonPromptEl ? commonPromptEl.value : '';
1450
+ const modelSelect = document.getElementById('batch-model');
1451
+ const loraSelect = document.getElementById('batch-lora');
1452
+ const loraStrengthInput = document.getElementById('batch-lora-strength');
1453
+ const modelPath = modelSelect ? modelSelect.value : '';
1454
+ const loraPath = loraSelect ? loraSelect.value : '';
1455
+ const loraStrength = loraStrengthInput ? (parseFloat(loraStrengthInput.value) || 1.2) : 1.2;
1456
+
1457
+ if (batchImages.length < 2) {
1458
+ throw new Error(_t('errBatchMinImages'));
1459
+ }
1460
+
1461
+ if (batchWorkflowIsSingle()) {
1462
+ captureBatchKfTimelineFromDom();
1463
+ const fps = document.getElementById('vid-fps').value;
1464
+ const parts = [prompt.trim(), commonPrompt.trim()].filter(Boolean);
1465
+ const combinedPrompt = parts.join(', ');
1466
+ if (!combinedPrompt) {
1467
+ throw new Error(_t('errSingleKfPrompt'));
1468
+ }
1469
+ const nKf = batchImages.length;
1470
+ const minSeg = 0.1;
1471
+ const segDurs = [];
1472
+ for (let j = 0; j < nKf - 1; j++) {
1473
+ let v = parseFloat(document.getElementById(`batch-kf-seg-dur-${j}`)?.value);
1474
+ if (!Number.isFinite(v) || v < minSeg) v = minSeg;
1475
+ segDurs.push(v);
1476
+ }
1477
+ const sumSec = segDurs.reduce((a, b) => a + b, 0);
1478
+ const dur = Math.max(2, Math.ceil(sumSec - 1e-9));
1479
+ const times = [0];
1480
+ let acc = 0;
1481
+ for (let j = 0; j < nKf - 1; j++) {
1482
+ acc += segDurs[j];
1483
+ times.push(acc);
1484
+ }
1485
+ const strengths = [];
1486
+ for (let i = 0; i < nKf; i++) {
1487
+ const sEl = document.getElementById(`batch-kf-strength-${i}`);
1488
+ let sv = parseFloat(sEl?.value);
1489
+ if (!Number.isFinite(sv)) {
1490
+ sv = parseFloat(defaultKeyframeStrengthForIndex(i, nKf));
1491
+ }
1492
+ if (!Number.isFinite(sv)) sv = 1;
1493
+ sv = Math.max(0.1, Math.min(1.0, sv));
1494
+ strengths.push(sv);
1495
+ }
1496
+ endpoint = '/api/generate';
1497
+ payload = {
1498
+ prompt: combinedPrompt,
1499
+ resolution: res,
1500
+ model: "ltx-2",
1501
+ cameraMotion: document.getElementById('vid-motion').value,
1502
+ negativePrompt: "low quality, blurry, noisy, static noise, distorted",
1503
+ duration: String(dur),
1504
+ fps,
1505
+ audio: "false",
1506
+ imagePath: null,
1507
+ audioPath: null,
1508
+ startFramePath: null,
1509
+ endFramePath: null,
1510
+ keyframePaths: batchImages.map((b) => b.path),
1511
+ keyframeStrengths: strengths,
1512
+ keyframeTimes: times,
1513
+ aspectRatio: document.getElementById('batch-ratio').value,
1514
+ modelPath: modelPath || null,
1515
+ loraPath: loraPath || null,
1516
+ loraStrength: loraStrength,
1517
+ };
1518
+ addLog(
1519
+ `单次多关键帧: ${nKf} 锚点, 轴长合计 ${sumSec.toFixed(1)}s → 请求时长 ${dur}s, ${res}, FPS ${fps}`
1520
+ );
1521
+ } else {
1522
+ const segments = [];
1523
+ for (let i = 0; i < batchImages.length - 1; i++) {
1524
+ const duration = parseFloat(document.getElementById(`batch-segment-duration-${i}`)?.value || 5);
1525
+ const segmentPrompt = document.getElementById(`batch-segment-prompt-${i}`)?.value || '';
1526
+ const segParts = [prompt.trim(), commonPrompt.trim(), segmentPrompt.trim()].filter(Boolean);
1527
+ const combinedSegPrompt = segParts.join(', ');
1528
+ segments.push({
1529
+ startImage: batchImages[i].path,
1530
+ endImage: batchImages[i + 1].path,
1531
+ duration: duration,
1532
+ prompt: combinedSegPrompt
1533
+ });
1534
+ }
1535
+
1536
+ endpoint = '/api/generate-batch';
1537
+ const bgAudioEl = document.getElementById('batch-background-audio-path');
1538
+ const backgroundAudioPath = (bgAudioEl && bgAudioEl.value) ? bgAudioEl.value.trim() : null;
1539
+ payload = {
1540
+ segments: segments,
1541
+ resolution: res,
1542
+ model: "ltx-2",
1543
+ aspectRatio: document.getElementById('batch-ratio').value,
1544
+ modelPath: modelPath || null,
1545
+ loraPath: loraPath || null,
1546
+ loraStrength: loraStrength,
1547
+ negativePrompt: "low quality, blurry, noisy, static noise, distorted",
1548
+ backgroundAudioPath: backgroundAudioPath || null
1549
+ };
1550
+ addLog(`分段拼接: ${segments.length} 段, ${res}${backgroundAudioPath ? ',含统一配乐' : ''}`);
1551
+ }
1552
+ }
1553
+
1554
+ // ---- 发送请求 ----
1555
+ const res = await fetch(BASE + endpoint, {
1556
+ method: 'POST',
1557
+ headers: { 'Content-Type': 'application/json' },
1558
+ body: JSON.stringify(payload)
1559
+ });
1560
+ const data = await res.json();
1561
+ if (!res.ok) {
1562
+ const errMsg = data.error || data.detail || "API 拒绝了请求";
1563
+ throw new Error(typeof errMsg === 'string' ? errMsg : JSON.stringify(errMsg));
1564
+ }
1565
+
1566
+ // ---- 显示结果 ----
1567
+ const rawPath = data.image_paths ? data.image_paths[0] : data.video_path;
1568
+ if (rawPath) {
1569
+ try { displayOutput(rawPath); } catch (dispErr) { addLog(`⚠️ 播放器显示异常: ${dispErr.message}`); }
1570
+ }
1571
+
1572
+ // 强制刷新历史记录(不依赖 isLoadingHistory 标志,确保新生成的视频立即显示)
1573
+ setTimeout(() => {
1574
+ isLoadingHistory = false; // 强制重置状态
1575
+ if (typeof fetchHistory === 'function') fetchHistory(1);
1576
+ }, 500);
1577
+
1578
+ } catch (e) {
1579
+ const errText = e && e.message ? e.message : String(e);
1580
+ addLog(`❌ 渲染中断: ${errText}`);
1581
+ const loader = document.getElementById('loading-txt');
1582
+ if (loader) {
1583
+ loader.style.display = 'flex';
1584
+ loader.textContent = '';
1585
+ const span = document.createElement('span');
1586
+ span.style.cssText = 'color:var(--text-sub);font-size:13px;padding:12px;text-align:center;';
1587
+ span.textContent = `渲染失败:${errText}`;
1588
+ loader.appendChild(span);
1589
+ }
1590
+
1591
+ } finally {
1592
+ // ✅ 无论发生什么,这里一定执行,确保按钮永远可以再次点击
1593
+ _isGeneratingFlag = false;
1594
+ btn.disabled = false;
1595
+ stopProgressPolling();
1596
+ checkStatus();
1597
+ // 生成完毕后自动释放显存(不 await 避免阻塞 UI 解锁)
1598
+ setTimeout(() => { clearGpu(); }, 500);
1599
+ }
1600
+ }
1601
+
1602
+ async function clearGpu() {
1603
+ const btn = document.getElementById('clearGpuBtn');
1604
+ btn.disabled = true;
1605
+ btn.innerText = _t('clearingVram');
1606
+ try {
1607
+ const res = await fetch(`${BASE}/api/system/clear-gpu`, {
1608
+ method: 'POST',
1609
+ headers: { 'Content-Type': 'application/json' }
1610
+ });
1611
+ const data = await res.json();
1612
+ if (res.ok) {
1613
+ addLog(`🧹 显存清理成功: ${data.message}`);
1614
+ // 立即触发状态刷新
1615
+ checkStatus();
1616
+ setTimeout(checkStatus, 1000);
1617
+ } else {
1618
+ const errMsg = data.error || data.detail || "后端未实现此接口 (404)";
1619
+ throw new Error(errMsg);
1620
+ }
1621
+ } catch(e) {
1622
+ addLog(`❌ 清理显存失败: ${e.message}`);
1623
+ } finally {
1624
+ btn.disabled = false;
1625
+ btn.innerText = _t('clearVram');
1626
+ }
1627
+ }
1628
+
1629
+ async function listGpus() {
1630
+ try {
1631
+ const res = await fetch(`${BASE}/api/system/list-gpus`);
1632
+ const data = await res.json();
1633
+ if (res.ok && data.gpus) {
1634
+ const selector = document.getElementById('gpu-selector');
1635
+ selector.innerHTML = data.gpus.map(g =>
1636
+ `<option value="${g.id}" ${g.active ? 'selected' : ''}>GPU ${g.id}: ${g.name} (${g.vram})</option>`
1637
+ ).join('');
1638
+
1639
+ // 更新当前显示的 GPU 名称
1640
+ const activeGpu = data.gpus.find(g => g.active);
1641
+ if (activeGpu) document.getElementById('gpu-name').innerText = activeGpu.name;
1642
+ }
1643
+ } catch (e) {
1644
+ console.error("Failed to list GPUs", e);
1645
+ }
1646
+ }
1647
+
1648
+ async function switchGpu(id) {
1649
+ if (!id) return;
1650
+ addLog(`🔄 正在切换到 GPU ${id}...`);
1651
+ try {
1652
+ const res = await fetch(`${BASE}/api/system/switch-gpu`, {
1653
+ method: 'POST',
1654
+ headers: { 'Content-Type': 'application/json' },
1655
+ body: JSON.stringify({ gpu_id: parseInt(id) })
1656
+ });
1657
+ const data = await res.json();
1658
+ if (res.ok) {
1659
+ addLog(`✅ 已成功切换到 GPU ${id},模型将重新加载。`);
1660
+ listGpus(); // 重新获取列表以同步状态
1661
+ setTimeout(checkStatus, 1000);
1662
+ } else {
1663
+ throw new Error(data.error || "切换失败");
1664
+ }
1665
+ } catch (e) {
1666
+ addLog(`❌ GPU 切换失败: ${e.message}`);
1667
+ }
1668
+ }
1669
+
1670
+ function startProgressPolling() {
1671
+ if (pollInterval) clearInterval(pollInterval);
1672
+ pollInterval = setInterval(async () => {
1673
+ try {
1674
+ const res = await fetch(`${BASE}/api/generation/progress`);
1675
+ const d = await res.json();
1676
+ if (d.progress > 0) {
1677
+ const ph = String(d.phase || 'inference');
1678
+ const phaseKey = 'phase_' + ph;
1679
+ let phaseStr = _t(phaseKey);
1680
+ if (phaseStr === phaseKey) phaseStr = ph;
1681
+
1682
+ let stepLabel;
1683
+ if (d.current_step !== undefined && d.current_step !== null && d.total_steps) {
1684
+ stepLabel = `${d.current_step}/${d.total_steps} ${_t('progressStepUnit')}`;
1685
+ } else {
1686
+ stepLabel = `${d.progress}%`;
1687
+ }
1688
+
1689
+ document.getElementById('progress-fill').style.width = d.progress + "%";
1690
+ const loaderStep = document.getElementById('loader-step-text');
1691
+ const busyLine = `${_t('gpuBusyPrefix')}: ${stepLabel} [${phaseStr}]`;
1692
+ if (loaderStep) loaderStep.innerText = busyLine;
1693
+ else {
1694
+ const loadingTxt = document.getElementById('loading-txt');
1695
+ if (loadingTxt) loadingTxt.innerText = busyLine;
1696
+ }
1697
+
1698
+ // 同步更新历史缩略图卡片上的进度文字
1699
+ const cardStep = document.getElementById('loading-card-step');
1700
+ if (cardStep) cardStep.innerText = stepLabel;
1701
+ }
1702
+ } catch(e) {}
1703
+ }, 1000);
1704
+ }
1705
+
1706
+ function stopProgressPolling() {
1707
+ clearInterval(pollInterval);
1708
+ pollInterval = null;
1709
+ document.getElementById('progress-fill').style.width = "0%";
1710
+ // 移除渲染中的卡片(生成已结束)
1711
+ const lc = document.getElementById('current-loading-card');
1712
+ if (lc) lc.remove();
1713
+ }
1714
+
1715
+ function displayOutput(fileOrPath) {
1716
+ const img = document.getElementById('res-img');
1717
+ const vid = document.getElementById('res-video');
1718
+ const loader = document.getElementById('loading-txt');
1719
+
1720
+ // 关键BUG修复:切换前强制清除并停止现有视频和声音,避免后台继续播放
1721
+ if(player) {
1722
+ player.stop();
1723
+ } else {
1724
+ vid.pause();
1725
+ vid.removeAttribute('src');
1726
+ vid.load();
1727
+ }
1728
+
1729
+ let url = "";
1730
+ let fileName = fileOrPath;
1731
+ if (fileOrPath.indexOf('\\') !== -1 || fileOrPath.indexOf('/') !== -1) {
1732
+ url = `${BASE}/api/system/file?path=${encodeURIComponent(fileOrPath)}&t=${Date.now()}`;
1733
+ fileName = fileOrPath.split(/[\\/]/).pop();
1734
+ } else {
1735
+ const outInput = document.getElementById('global-out-dir');
1736
+ const globalDir = outInput ? outInput.value.replace(/\\/g, '/').replace(/\/$/, '') : "";
1737
+ if (globalDir && globalDir !== "") {
1738
+ url = `${BASE}/api/system/file?path=${encodeURIComponent(globalDir + '/' + fileOrPath)}&t=${Date.now()}`;
1739
+ } else {
1740
+ url = `${BASE}/outputs/${fileOrPath}?t=${Date.now()}`;
1741
+ }
1742
+ }
1743
+
1744
+ loader.style.display = "none";
1745
+ if (currentMode === 'image') {
1746
+ img.src = url;
1747
+ img.style.display = "block";
1748
+ addLog(`✅ 图像渲染成功: ${fileName}`);
1749
+ } else {
1750
+ document.getElementById('video-wrapper').style.display = "flex";
1751
+
1752
+ if(player) {
1753
+ player.source = {
1754
+ type: 'video',
1755
+ sources: [{ src: url, type: 'video/mp4' }]
1756
+ };
1757
+ player.play();
1758
+ } else {
1759
+ vid.src = url;
1760
+ }
1761
+ addLog(`✅ 视频渲染成功: ${fileName}`);
1762
+ }
1763
+ }
1764
+
1765
+
1766
+
1767
+ function addLog(msg) {
1768
+ const log = document.getElementById('log');
1769
+ if (!log) {
1770
+ console.log('[LTX]', msg);
1771
+ return;
1772
+ }
1773
+ const time = new Date().toLocaleTimeString();
1774
+ log.innerHTML += `<div style="margin-bottom:5px"> <span style="color:var(--text-dim)">[${time}]</span> ${msg}</div>`;
1775
+ log.scrollTop = log.scrollHeight;
1776
+ }
1777
+
1778
+
1779
+ // Force switch to video mode on load
1780
+ window.addEventListener('DOMContentLoaded', () => switchMode('video'));
1781
+
1782
+
1783
+
1784
+
1785
+
1786
+
1787
+
1788
+
1789
+
1790
+
1791
+
1792
+
1793
+ let currentHistoryPage = 1;
1794
+ let isLoadingHistory = false;
1795
+ /** 与上次成功渲染一致时,silent 轮询跳过整表 innerHTML,避免缩略图周期性重新加载 */
1796
+ let _historyListFingerprint = '';
1797
+
1798
+ function switchLibTab(tab) {
1799
+ document.getElementById('log-container').style.display = tab === 'log' ? 'flex' : 'none';
1800
+ const hw = document.getElementById('history-wrapper');
1801
+ if (hw) hw.style.display = tab === 'history' ? 'block' : 'none';
1802
+
1803
+ document.getElementById('tab-log').style.color = tab === 'log' ? 'var(--accent)' : 'var(--text-dim)';
1804
+ document.getElementById('tab-log').style.borderColor = tab === 'log' ? 'var(--accent)' : 'transparent';
1805
+
1806
+ document.getElementById('tab-history').style.color = tab === 'history' ? 'var(--accent)' : 'var(--text-dim)';
1807
+ document.getElementById('tab-history').style.borderColor = tab === 'history' ? 'var(--accent)' : 'transparent';
1808
+
1809
+ if (tab === 'history') {
1810
+ fetchHistory();
1811
+ }
1812
+ }
1813
+
1814
+ async function fetchHistory(isFirstLoad = false, silent = false) {
1815
+ if (isLoadingHistory) return;
1816
+ isLoadingHistory = true;
1817
+
1818
+ try {
1819
+ // 加载所有历史,不分页
1820
+ const res = await fetch(`${BASE}/api/system/history?page=1&limit=10000`);
1821
+ if (!res.ok) {
1822
+ isLoadingHistory = false;
1823
+ return;
1824
+ }
1825
+ const data = await res.json();
1826
+
1827
+ const validHistory = (data.history || []).filter(item => item && item.filename);
1828
+ const fingerprint = validHistory.length === 0
1829
+ ? '__empty__'
1830
+ : validHistory.map(h => `${h.type}|${h.filename}`).join('\0');
1831
+
1832
+ if (silent && fingerprint === _historyListFingerprint) {
1833
+ return;
1834
+ }
1835
+
1836
+ const container = document.getElementById('history-container');
1837
+ if (!container) {
1838
+ return;
1839
+ }
1840
+
1841
+ let loadingCardHtml = "";
1842
+ const lc = document.getElementById('current-loading-card');
1843
+ if (lc && _isGeneratingFlag) {
1844
+ loadingCardHtml = lc.outerHTML;
1845
+ }
1846
+
1847
+ if (validHistory.length === 0) {
1848
+ container.innerHTML = loadingCardHtml;
1849
+ const newLcEmpty = document.getElementById('current-loading-card');
1850
+ if (newLcEmpty) newLcEmpty.onclick = showGeneratingView;
1851
+ _historyListFingerprint = fingerprint;
1852
+ return;
1853
+ }
1854
+
1855
+ container.innerHTML = loadingCardHtml;
1856
+
1857
+ const outInput = document.getElementById('global-out-dir');
1858
+ const globalDir = outInput ? outInput.value.replace(/\\/g, '/').replace(/\/$/, '') : "";
1859
+
1860
+ const cardsHtml = validHistory.map((item, index) => {
1861
+ const url = (globalDir && globalDir !== "")
1862
+ ? `${BASE}/api/system/file?path=${encodeURIComponent(globalDir + '/' + item.filename)}`
1863
+ : `${BASE}/outputs/${item.filename}`;
1864
+
1865
+ const safeFilename = item.filename.replace(/'/g, "\\'").replace(/"/g, '\\"');
1866
+ const media = item.type === 'video'
1867
+ ? `<video data-src="${url}#t=0.001" class="lazy-load history-thumb-media" muted loop preload="none" playsinline onmouseover="if(this.readyState >= 2) this.play()" onmouseout="this.pause()" style="pointer-events: none; object-fit: cover; width: 100%; height: 100%;"></video>`
1868
+ : `<img data-src="${url}" class="lazy-load history-thumb-media" alt="" style="object-fit: cover; width: 100%; height: 100%;">`;
1869
+ return `<div class="history-card" onclick="displayHistoryOutput('${safeFilename}', '${item.type}')">
1870
+ <div class="history-type-badge">${item.type === 'video' ? '🎬 VID' : '🎨 IMG'}</div>
1871
+ <button class="history-delete-btn" onclick="event.stopPropagation(); deleteHistoryItem('${safeFilename}', '${item.type}', this)">✕</button>
1872
+ ${media}
1873
+ </div>`;
1874
+ }).join('');
1875
+
1876
+ container.insertAdjacentHTML('beforeend', cardsHtml);
1877
+
1878
+ // 重新绑定loading card点击事件
1879
+ const newLc = document.getElementById('current-loading-card');
1880
+ if (newLc) newLc.onclick = showGeneratingView;
1881
+
1882
+ // 加载可见的图片
1883
+ loadVisibleImages();
1884
+ _historyListFingerprint = fingerprint;
1885
+ } catch(e) {
1886
+ console.error("Failed to load history", e);
1887
+ } finally {
1888
+ isLoadingHistory = false;
1889
+ }
1890
+ }
1891
+
1892
+ async function deleteHistoryItem(filename, type, btn) {
1893
+ if (!confirm(`确定要删除 "${filename}" 吗?`)) return;
1894
+
1895
+ try {
1896
+ const res = await fetch(`${BASE}/api/system/delete-file`, {
1897
+ method: 'POST',
1898
+ headers: {'Content-Type': 'application/json'},
1899
+ body: JSON.stringify({filename: filename, type: type})
1900
+ });
1901
+
1902
+ if (res.ok) {
1903
+ // 删除成功后移除元素
1904
+ const card = btn.closest('.history-card');
1905
+ if (card) {
1906
+ card.remove();
1907
+ }
1908
+ } else {
1909
+ alert('删除失败');
1910
+ }
1911
+ } catch(e) {
1912
+ console.error('Delete failed', e);
1913
+ alert('删除失败');
1914
+ }
1915
+ }
1916
+
1917
+ function loadVisibleImages() {
1918
+ const hw = document.getElementById('history-wrapper');
1919
+ if (!hw) return;
1920
+
1921
+ const lazyMedias = document.querySelectorAll('#history-container .lazy-load');
1922
+
1923
+ // 每次只加载3个媒体元素(图片或视频)
1924
+ let loadedCount = 0;
1925
+ lazyMedias.forEach(media => {
1926
+ if (loadedCount >= 3) return;
1927
+
1928
+ const src = media.dataset.src;
1929
+ if (!src) return;
1930
+
1931
+ // 检查是否在可见区域附近
1932
+ const rect = media.getBoundingClientRect();
1933
+ const containerRect = hw.getBoundingClientRect();
1934
+
1935
+ if (rect.top < containerRect.bottom + 300 && rect.bottom > containerRect.top - 100) {
1936
+ let revealed = false;
1937
+ let thumbRevealTimer;
1938
+ const revealThumb = () => {
1939
+ if (revealed) return;
1940
+ revealed = true;
1941
+ if (thumbRevealTimer) clearTimeout(thumbRevealTimer);
1942
+ media.classList.add('history-thumb-ready');
1943
+ };
1944
+ thumbRevealTimer = setTimeout(revealThumb, 4000);
1945
+
1946
+ if (media.tagName === 'VIDEO') {
1947
+ media.addEventListener('loadeddata', revealThumb, { once: true });
1948
+ media.addEventListener('error', revealThumb, { once: true });
1949
+ } else {
1950
+ media.addEventListener('load', revealThumb, { once: true });
1951
+ media.addEventListener('error', revealThumb, { once: true });
1952
+ }
1953
+
1954
+ media.src = src;
1955
+ media.classList.remove('lazy-load');
1956
+
1957
+ if (media.tagName === 'VIDEO') {
1958
+ media.preload = 'metadata';
1959
+ if (media.readyState >= 2) revealThumb();
1960
+ } else if (media.complete && media.naturalWidth > 0) {
1961
+ revealThumb();
1962
+ }
1963
+
1964
+ loadedCount++;
1965
+ }
1966
+ });
1967
+
1968
+ // 继续检查直到没有更多媒体需要加载
1969
+ if (loadedCount > 0) {
1970
+ setTimeout(loadVisibleImages, 100);
1971
+ }
1972
+ }
1973
+
1974
+ // 监听history-wrapper的滚动事件来懒加载
1975
+ function initHistoryScrollListener() {
1976
+ const hw = document.getElementById('history-wrapper');
1977
+ if (!hw) return;
1978
+
1979
+ let scrollTimeout;
1980
+ hw.addEventListener('scroll', () => {
1981
+ if (scrollTimeout) clearTimeout(scrollTimeout);
1982
+ scrollTimeout = setTimeout(() => {
1983
+ loadVisibleImages();
1984
+ }, 100);
1985
+ });
1986
+ }
1987
+
1988
+ // 页面加载时初始化滚动监听
1989
+ window.addEventListener('DOMContentLoaded', () => {
1990
+ setTimeout(initHistoryScrollListener, 500);
1991
+ });
1992
+
1993
+ function displayHistoryOutput(file, type) {
1994
+ document.getElementById('res-img').style.display = 'none';
1995
+ document.getElementById('video-wrapper').style.display = 'none';
1996
+
1997
+ const mode = type === 'video' ? 'video' : 'image';
1998
+ switchMode(mode);
1999
+ displayOutput(file);
2000
+ }
2001
+
2002
+ window.addEventListener('DOMContentLoaded', () => {
2003
+ // Initialize Plyr Custom Video Component
2004
+ if(window.Plyr) {
2005
+ player = new Plyr('#res-video', {
2006
+ controls: [
2007
+ 'play-large', 'play', 'progress', 'current-time',
2008
+ 'mute', 'volume', 'fullscreen'
2009
+ ],
2010
+ settings: [],
2011
+ loop: { active: true },
2012
+ autoplay: true
2013
+ });
2014
+ }
2015
+
2016
+ // Fetch current directory context to show in UI
2017
+ fetch(`${BASE}/api/system/get-dir`)
2018
+ .then((res) => res.json())
2019
+ .then((data) => {
2020
+ if (data && data.directory) {
2021
+ const outInput = document.getElementById('global-out-dir');
2022
+ if (outInput) outInput.value = data.directory;
2023
+ }
2024
+ })
2025
+ .catch((e) => console.error(e))
2026
+ .finally(() => {
2027
+ /* 先同步输出目录再拉历史,避免短时间内两次 fetchHistory 整表重绘导致缩略图闪两下 */
2028
+ switchLibTab('history');
2029
+ });
2030
+
2031
+ let historyRefreshInterval = null;
2032
+ function startHistoryAutoRefresh() {
2033
+ if (historyRefreshInterval) return;
2034
+ historyRefreshInterval = setInterval(() => {
2035
+ const hc = document.getElementById('history-container');
2036
+ if (hc && hc.offsetParent !== null && !_isGeneratingFlag) {
2037
+ fetchHistory(1, true);
2038
+ }
2039
+ }, 5000);
2040
+ }
2041
+ startHistoryAutoRefresh();
2042
+ });
LTX2.3/main.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import subprocess
4
+ import threading
5
+ import time
6
+ import socket
7
+ import logging
8
+ from fastapi import FastAPI
9
+ from fastapi.responses import FileResponse
10
+ from fastapi.staticfiles import StaticFiles
11
+ import uvicorn
12
+
13
+ # ============================================================
14
+ # 配置区 (动态路径适配与补丁挂载)
15
+ # ============================================================
16
+ def resolve_ltx_path():
17
+ import glob, tempfile, subprocess
18
+ sc_dir = os.path.join(os.getcwd(), "LTX_Shortcut")
19
+ os.makedirs(sc_dir, exist_ok=True)
20
+ lnk_files = glob.glob(os.path.join(sc_dir, "*.lnk"))
21
+ if not lnk_files:
22
+ print("\033[91m[ERROR] 未在 LTX_Shortcut 文件夹中找到快捷方式!\n请打开程序目录下的 LTX_Shortcut 文件夹,并将官方 LTX Desktop 的快捷方式复制进去后重试。\033[0m")
23
+ sys.exit(1)
24
+
25
+ lnk_path = lnk_files[0]
26
+ # 使用 VBScript 解析快捷方式,兼容所有 Windows 系统
27
+ vbs_code = f'''Set sh = CreateObject("WScript.Shell")\nSet obj = sh.CreateShortcut("{os.path.abspath(lnk_path)}")\nWScript.Echo obj.TargetPath'''
28
+ fd, vbs_path = tempfile.mkstemp(suffix='.vbs')
29
+ with os.fdopen(fd, 'w') as f:
30
+ f.write(vbs_code)
31
+ try:
32
+ out = subprocess.check_output(['cscript', '//nologo', vbs_path], stderr=subprocess.STDOUT)
33
+ target_exe = out.decode('ansi').strip()
34
+ finally:
35
+ os.remove(vbs_path)
36
+
37
+ if not target_exe or not os.path.exists(target_exe):
38
+ # 如果快捷方式解析失败,或者解析出来的是朋友电脑的路径(当前电脑不存在),自动全盘搜索默认路径
39
+ default_paths = [
40
+ os.path.join(os.environ.get("LOCALAPPDATA", ""), r"Programs\LTX Desktop\LTX Desktop.exe"),
41
+ r"C:\Program Files\LTX Desktop\LTX Desktop.exe",
42
+ r"D:\Program Files\LTX Desktop\LTX Desktop.exe",
43
+ r"E:\Program Files\LTX Desktop\LTX Desktop.exe"
44
+ ]
45
+ found = False
46
+ for p in default_paths:
47
+ if os.path.exists(p):
48
+ target_exe = p
49
+ print(f"\033[96m[INFO] 自动检测到 LTX 原版安装路径: {p}\033[0m")
50
+ found = True
51
+ break
52
+
53
+ if not found:
54
+ print(f"\033[91m[ERROR] 未能找到原版 LTX Desktop 的安装路径!\033[0m")
55
+ print("请清理 LTX_Shortcut 文件夹,并将您当前电脑上真正的原版快捷方式重贴复制进去。")
56
+ sys.exit(1)
57
+
58
+ return os.path.dirname(target_exe)
59
+
60
+ USER_PROFILE = os.path.expanduser("~")
61
+ PYTHON_EXE = os.path.join(USER_PROFILE, r"AppData\Local\LTXDesktop\python\python.exe")
62
+ DATA_DIR = os.path.join(USER_PROFILE, r"AppData\Local\LTXDesktop")
63
+
64
+ # 1. 动态获取主安装路径
65
+ LTX_INSTALL_DIR = resolve_ltx_path()
66
+ BACKEND_DIR = os.path.join(LTX_INSTALL_DIR, r"resources\backend")
67
+ UI_FILE_NAME = "UI/index.html"
68
+
69
+ # 环境致命检测:如果官方 Python 还没解压释放,立刻强制中断整个程序
70
+ if not os.path.exists(PYTHON_EXE):
71
+ print(f"\n\033[1;41m [致命错误] 您的电脑上尚未配置好 LTX 的官方渲染核心框架! \033[0m")
72
+ print(f"\033[93m此应用仅是 UI 图形控制台,必需依赖原版软件环境才能生成。在 ({PYTHON_EXE}) 未找到运行引擎。\n")
73
+ print(">> 解决方案:\n1. 请先在您的电脑上正常安装【LTX Desktop 官方原版软件】。")
74
+ print("2. 必需:双击打开运行一次原版软件!(运行后原版软件会在后台自动释放环境)")
75
+ print("3. 把原版软件的快捷方式复制到本文档的 LTX_Shortcut 文件夹里面。")
76
+ print("4. 全部完成后,再重新启动本 run.bat 脚本即可!\033[0m\n")
77
+ os._exit(1)
78
+
79
+ # 2. 从目录读取改动过的 Python 文件 (热修复拦截器)
80
+ PATCHES_DIR = os.path.join(os.getcwd(), "patches")
81
+ os.makedirs(PATCHES_DIR, exist_ok=True)
82
+
83
+ # 3. 默认输出定向至程序根目录
84
+ LOCAL_OUTPUTS = os.path.join(os.getcwd(), "outputs")
85
+ os.makedirs(LOCAL_OUTPUTS, exist_ok=True)
86
+
87
+ # 强制注入自定义输出录至 LTX 缓存数据中
88
+ os.makedirs(DATA_DIR, exist_ok=True)
89
+ with open(os.path.join(DATA_DIR, "custom_dir.txt"), 'w', encoding='utf-8') as f:
90
+ f.write(LOCAL_OUTPUTS)
91
+
92
+ os.environ["LTX_APP_DATA_DIR"] = DATA_DIR
93
+
94
+ # 将 patches 目录优先级提升,做到 Python 无损替换
95
+ os.environ["PYTHONPATH"] = f"{PATCHES_DIR};{BACKEND_DIR}"
96
+
97
+ def get_lan_ip():
98
+ try:
99
+ host_name = socket.gethostname()
100
+ _, _, ip_list = socket.gethostbyname_ex(host_name)
101
+
102
+ candidates = []
103
+ for ip in ip_list:
104
+ if ip.startswith("192.168."):
105
+ return ip
106
+ elif ip.startswith("10.") or (ip.startswith("172.") and 16 <= int(ip.split('.')[1]) <= 31):
107
+ candidates.append(ip)
108
+
109
+ if candidates:
110
+ return candidates[0]
111
+
112
+ # Fallback to the default socket routing approach if no obvious LAN IP found
113
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
114
+ s.connect(("8.8.8.8", 80))
115
+ ip = s.getsockname()[0]
116
+ s.close()
117
+ return ip
118
+ except:
119
+ return "127.0.0.1"
120
+
121
+ LAN_IP = get_lan_ip()
122
+
123
+ # ============================================================
124
+ # 服务启动逻辑
125
+ # ============================================================
126
+ def check_port_in_use(port):
127
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
128
+ return s.connect_ex(('127.0.0.1', port)) == 0
129
+
130
+ def launch_backend():
131
+ """启动核心引擎 - 监听 0.0.0.0 确保局域网可调"""
132
+ if check_port_in_use(3000):
133
+ print(f"\n\033[1;41m [致命错误] 3000 端口已被占用,无法启动核心引擎! \033[0m")
134
+ print("\033[93m>> 绝大多数情况下,这是因为【官方原版 LTX Desktop】正在您的电脑后台运行。\033[0m")
135
+ print(">> 冲突会导致显存爆炸。请检查右下角系统托盘图标,右键完全退出官方软件。")
136
+ print(">> 退出后重新双击 run.bat 启动本程序!\n")
137
+ os._exit(1)
138
+
139
+ print(f"\033[96m[CORE] 核心引擎正在启动...\033[0m")
140
+ # 只开启重要级别的 Python 应用层日志,去除无用的 HTTP 刷屏
141
+ import logging as _logging
142
+ _logging.basicConfig(
143
+ level=_logging.INFO,
144
+ format="[%(asctime)s] %(levelname)s %(name)s: %(message)s",
145
+ datefmt="%H:%M:%S",
146
+ force=True
147
+ )
148
+
149
+ # 构建绝对无损的环境拦截器:防止其他电脑被 cwd 劫持加载原版文件
150
+ launcher_code = f"""
151
+ import sys
152
+ import os
153
+
154
+ patch_dir = r"{PATCHES_DIR}"
155
+ backend_dir = r"{BACKEND_DIR}"
156
+
157
+ # 防御性清除:强行剥离所有的默认 backend_dir 引用
158
+ sys.path = [p for p in sys.path if p and os.path.normpath(p) != os.path.normpath(backend_dir)]
159
+ sys.path = [p for p in sys.path if p and p != "." and p != ""]
160
+
161
+ # 绝对插队注入:优先搜索 PATCHES_DIR
162
+ sys.path.insert(0, patch_dir)
163
+ sys.path.insert(1, backend_dir)
164
+
165
+ import uvicorn
166
+ from ltx2_server import app
167
+
168
+ if __name__ == '__main__':
169
+ uvicorn.run(app, host="0.0.0.0", port=3000, log_level="info", access_log=False)
170
+ """
171
+ launcher_path = os.path.join(PATCHES_DIR, "launcher.py")
172
+ with open(launcher_path, "w", encoding="utf-8") as f:
173
+ f.write(launcher_code)
174
+
175
+ cmd = [PYTHON_EXE, launcher_path]
176
+ env = os.environ.copy()
177
+ result = subprocess.run(cmd, cwd=BACKEND_DIR, env=env)
178
+ if result.returncode != 0:
179
+ print(f"\n\033[1;41m [致命错误] 核心引擎异常崩溃退出! (Exit Code: {result.returncode})\033[0m")
180
+ print(">> 请检查上述终端报错信息。确认显卡驱动是否正常。")
181
+ os._exit(1)
182
+
183
+ ui_app = FastAPI()
184
+ # 已移除存在安全隐患的静态资源挂载目录
185
+
186
+ @ui_app.get("/")
187
+ async def serve_index():
188
+ return FileResponse(os.path.join(os.getcwd(), UI_FILE_NAME))
189
+
190
+ @ui_app.get("/index.css")
191
+ async def serve_css():
192
+ return FileResponse(os.path.join(os.getcwd(), "UI/index.css"))
193
+
194
+ @ui_app.get("/index.js")
195
+ async def serve_js():
196
+ return FileResponse(os.path.join(os.getcwd(), "UI/index.js"))
197
+
198
+
199
+ @ui_app.get("/i18n.js")
200
+ async def serve_i18n():
201
+ return FileResponse(os.path.join(os.getcwd(), "UI/i18n.js"))
202
+
203
+
204
+ def launch_ui_server():
205
+ print(f"\033[92m[UI] 工作站已就绪!\033[0m")
206
+ print(f"\033[92m[LOCAL] 本机访问: http://127.0.0.1:4000\033[0m")
207
+ print(f"\033[93m[WIFI] 局域网访问: http://{LAN_IP}:4000\033[0m")
208
+
209
+ # 彻底压制 WinError 10054 (客户端强制断开) 的底层警告报错
210
+ if sys.platform == 'win32':
211
+ # Uvicorn 内部会拉起循环,所以只能通过底层 Logging Filter 拦截控制台噪音
212
+ class UvicornAsyncioNoiseFilter(logging.Filter):
213
+ """压掉客户端断开、Win Proactor 管道收尾等无害 asyncio 控制台刷屏。"""
214
+
215
+ def filter(self, record):
216
+ if record.name != "asyncio":
217
+ return True
218
+ msg = record.getMessage()
219
+ if "_call_connection_lost" in msg or "_ProactorBasePipeTransport" in msg:
220
+ return False
221
+ if hasattr(record, "exc_info") and record.exc_info:
222
+ exc_type, exc_value, _ = record.exc_info
223
+ if isinstance(exc_value, ConnectionResetError) and getattr(
224
+ exc_value, "winerror", None
225
+ ) == 10054:
226
+ return False
227
+ if "10054" in msg and "ConnectionResetError" in msg:
228
+ return False
229
+ return True
230
+
231
+ logging.getLogger("asyncio").addFilter(UvicornAsyncioNoiseFilter())
232
+
233
+ uvicorn.run(ui_app, host="0.0.0.0", port=4000, log_level="warning", access_log=False)
234
+
235
+ if __name__ == "__main__":
236
+ os.system('cls' if os.name == 'nt' else 'clear')
237
+ print("\033[1;97;44m LTX-2 CINEMATIC WORKSTATION | NETWORK ENABLED \033[0m\n")
238
+
239
+ threading.Thread(target=launch_backend, daemon=True).start()
240
+
241
+ # 强制校验 3000 端口是否存活
242
+ print("\033[93m[SYS] 正在等待内部核心 3000 端口启动...\033[0m")
243
+ backend_ready = False
244
+ for _ in range(30):
245
+ try:
246
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
247
+ if s.connect_ex(('127.0.0.1', 3000)) == 0:
248
+ backend_ready = True
249
+ break
250
+ except Exception:
251
+ pass
252
+ time.sleep(1)
253
+
254
+ if backend_ready:
255
+ print("\033[92m[SYS] 3000 端口已通过连通性握手验证!后端装载成功。\033[0m")
256
+ else:
257
+ print("\033[1;41m [崩坏警告] 等待 30 秒后,3000 端口依然无法连通! \033[0m")
258
+ print(">> Uvicorn 可能在后台陷入了死锁,或者被防火墙拦截,前端大概率将无法连接到后端!")
259
+ print(">> 请检查上方是否有 Python 报错。\n")
260
+
261
+ try:
262
+ launch_ui_server()
263
+ except KeyboardInterrupt:
264
+ sys.exit(0)
LTX2.3/patches/API模式问题修复说明.md ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LTX 本地显卡模式修复
2
+
3
+ ## 问题描述
4
+ 系统强制使用 FAL API 生成图片,即使本地有 GPU 可用。
5
+
6
+ ## 原因
7
+ LTX 强制要求 GPU 有 31GB VRAM 才会使用本地显卡,低于此值会强制走 API 模式。
8
+
9
+ ## 修复方法
10
+
11
+ ### 方法一:自动替换(推荐)
12
+ 运行程序后,patches 目录中的文件会自动替换原版文件。
13
+
14
+ ### 方法二:手动替换
15
+
16
+ #### 1. 修改 VRAM 阈值
17
+ - **原文件**: `C:\Program Files\LTX Desktop\resources\backend\runtime_config\runtime_policy.py`
18
+ - **找到** (第16行):
19
+ ```python
20
+ return vram_gb < 31
21
+ ```
22
+ - **改为**:
23
+ ```python
24
+ return vram_gb < 6
25
+ ```
26
+
27
+ #### 2. 清空无效 API Key
28
+ - **原文件**: `C:\Users\Administrator\AppData\Local\LTXDesktop\settings.json`
29
+ - **找到**:
30
+ ```json
31
+ "fal_api_key": "12123",
32
+ ```
33
+ - **改为**:
34
+ ```json
35
+ "fal_api_key": "",
36
+ ```
37
+
38
+ ## 说明
39
+ - VRAM 阈值改为 6GB,意味着 6GB 及以上显存都会使用本地显卡
40
+ - 清空 fal_api_key 避免系统误判为已配置 API
41
+ - 修改后重启程序即可生效
LTX2.3/patches/__pycache__/api_types.cpython-313.pyc ADDED
Binary file (13.3 kB). View file
 
LTX2.3/patches/__pycache__/app_factory.cpython-313.pyc ADDED
Binary file (97.8 kB). View file
 
LTX2.3/patches/__pycache__/app_factory.cpython-314.pyc ADDED
Binary file (59.3 kB). View file
 
LTX2.3/patches/__pycache__/keep_models_runtime.cpython-313.pyc ADDED
Binary file (888 Bytes). View file
 
LTX2.3/patches/__pycache__/lora_build_hook.cpython-313.pyc ADDED
Binary file (4.77 kB). View file
 
LTX2.3/patches/__pycache__/lora_injection.cpython-313.pyc ADDED
Binary file (5.19 kB). View file
 
LTX2.3/patches/__pycache__/low_vram_runtime.cpython-313.pyc ADDED
Binary file (7.44 kB). View file
 
LTX2.3/patches/api_types.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic request/response models and TypedDicts for ltx2_server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal, NamedTuple, TypeAlias, TypedDict
6
+ from typing import Annotated
7
+
8
+ from pydantic import BaseModel, Field, StringConstraints
9
+
10
+ NonEmptyPrompt = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
11
+ ModelFileType = Literal[
12
+ "checkpoint",
13
+ "upsampler",
14
+ "distilled_lora",
15
+ "ic_lora",
16
+ "depth_processor",
17
+ "person_detector",
18
+ "pose_processor",
19
+ "text_encoder",
20
+ "zit",
21
+ ]
22
+
23
+
24
+ class ImageConditioningInput(NamedTuple):
25
+ """Image conditioning triplet used by all video pipelines."""
26
+
27
+ path: str
28
+ frame_idx: int
29
+ strength: float
30
+
31
+
32
+ # ============================================================
33
+ # TypedDicts for module-level state globals
34
+ # ============================================================
35
+
36
+
37
+ class GenerationState(TypedDict):
38
+ id: str | None
39
+ cancelled: bool
40
+ result: str | list[str] | None
41
+ error: str | None
42
+ status: str # "idle" | "running" | "complete" | "cancelled" | "error"
43
+ phase: str
44
+ progress: int
45
+ current_step: int
46
+ total_steps: int
47
+
48
+
49
+ JsonObject: TypeAlias = dict[str, object]
50
+ VideoCameraMotion = Literal[
51
+ "none",
52
+ "dolly_in",
53
+ "dolly_out",
54
+ "dolly_left",
55
+ "dolly_right",
56
+ "jib_up",
57
+ "jib_down",
58
+ "static",
59
+ "focus_shift",
60
+ ]
61
+
62
+
63
+ # ============================================================
64
+ # Response Models
65
+ # ============================================================
66
+
67
+
68
+ class ModelStatusItem(BaseModel):
69
+ id: str
70
+ name: str
71
+ loaded: bool
72
+ downloaded: bool
73
+
74
+
75
+ class GpuTelemetry(BaseModel):
76
+ name: str
77
+ vram: int
78
+ vramUsed: int
79
+
80
+
81
+ class HealthResponse(BaseModel):
82
+ status: str
83
+ models_loaded: bool
84
+ active_model: str | None
85
+ gpu_info: GpuTelemetry
86
+ sage_attention: bool
87
+ models_status: list[ModelStatusItem]
88
+
89
+
90
+ class GpuInfoResponse(BaseModel):
91
+ cuda_available: bool
92
+ mps_available: bool = False
93
+ gpu_available: bool = False
94
+ gpu_name: str | None
95
+ vram_gb: int | None
96
+ gpu_info: GpuTelemetry
97
+
98
+
99
+ class RuntimePolicyResponse(BaseModel):
100
+ force_api_generations: bool
101
+
102
+
103
+ class GenerationProgressResponse(BaseModel):
104
+ status: str
105
+ phase: str
106
+ progress: int
107
+ currentStep: int | None
108
+ totalSteps: int | None
109
+
110
+
111
+ class ModelInfo(BaseModel):
112
+ id: str
113
+ name: str
114
+ description: str
115
+
116
+
117
+ class ModelFileStatus(BaseModel):
118
+ id: ModelFileType
119
+ name: str
120
+ description: str
121
+ downloaded: bool
122
+ size: int
123
+ expected_size: int
124
+ required: bool = True
125
+ is_folder: bool = False
126
+ optional_reason: str | None = None
127
+
128
+
129
+ class TextEncoderStatus(BaseModel):
130
+ downloaded: bool
131
+ size_bytes: int
132
+ size_gb: float
133
+ expected_size_gb: float
134
+
135
+
136
+ class ModelsStatusResponse(BaseModel):
137
+ models: list[ModelFileStatus]
138
+ all_downloaded: bool
139
+ total_size: int
140
+ downloaded_size: int
141
+ total_size_gb: float
142
+ downloaded_size_gb: float
143
+ models_path: str
144
+ has_api_key: bool
145
+ text_encoder_status: TextEncoderStatus
146
+ use_local_text_encoder: bool
147
+
148
+
149
+ class DownloadProgressResponse(BaseModel):
150
+ status: str
151
+ current_downloading_file: ModelFileType | None
152
+ current_file_progress: int
153
+ total_progress: int
154
+ total_downloaded_bytes: int
155
+ expected_total_bytes: int
156
+ completed_files: set[ModelFileType]
157
+ all_files: set[ModelFileType]
158
+ error: str | None
159
+ speed_mbps: int
160
+
161
+
162
+ class SuggestGapPromptResponse(BaseModel):
163
+ status: str = "success"
164
+ suggested_prompt: str
165
+
166
+
167
+ class GenerateVideoResponse(BaseModel):
168
+ status: str
169
+ video_path: str | None = None
170
+
171
+
172
+ class GenerateImageResponse(BaseModel):
173
+ status: str
174
+ image_paths: list[str] | None = None
175
+
176
+
177
+ class CancelResponse(BaseModel):
178
+ status: str
179
+ id: str | None = None
180
+
181
+
182
+ class RetakeResponse(BaseModel):
183
+ status: str
184
+ video_path: str | None = None
185
+ result: JsonObject | None = None
186
+
187
+
188
+ class IcLoraExtractResponse(BaseModel):
189
+ conditioning: str
190
+ original: str
191
+ conditioning_type: Literal["canny", "depth"]
192
+ frame_time: float
193
+
194
+
195
+ class IcLoraGenerateResponse(BaseModel):
196
+ status: str
197
+ video_path: str | None = None
198
+
199
+
200
+ class ModelDownloadStartResponse(BaseModel):
201
+ status: str
202
+ message: str | None = None
203
+ sessionId: str | None = None
204
+
205
+
206
+ class TextEncoderDownloadResponse(BaseModel):
207
+ status: str
208
+ message: str | None = None
209
+ sessionId: str | None = None
210
+
211
+
212
+ class StatusResponse(BaseModel):
213
+ status: str
214
+
215
+
216
+ class ErrorResponse(BaseModel):
217
+ error: str
218
+ message: str | None = None
219
+
220
+
221
+ # ============================================================
222
+ # Request Models
223
+ # ============================================================
224
+
225
+
226
+ class GenerateVideoRequest(BaseModel):
227
+ prompt: NonEmptyPrompt
228
+ resolution: str = "512p"
229
+ model: str = "fast"
230
+ cameraMotion: VideoCameraMotion = "none"
231
+ negativePrompt: str = ""
232
+ duration: str = "2"
233
+ fps: str = "24"
234
+ audio: str = "false"
235
+ imagePath: str | None = None
236
+ audioPath: str | None = None
237
+ startFramePath: str | None = None
238
+ endFramePath: str | None = None
239
+ # 多张图单次推理:latent 时间轴多锚点(Comfy LTXVAddGuideMulti 思路);≥2 路径时优先于首尾帧
240
+ keyframePaths: list[str] | None = None
241
+ # 与 keyframePaths 等长、0.1–1.0;不传则按 Comfy 类工作流自动降低中间帧强度,减轻闪烁
242
+ keyframeStrengths: list[float] | None = None
243
+ # 与 keyframePaths 等长,单位秒,落在 [0, 整段时长];全提供时按时间映射 latent,否则仍自动均分
244
+ keyframeTimes: list[float] | None = None
245
+ aspectRatio: Literal["16:9", "9:16"] = "16:9"
246
+ modelPath: str | None = None
247
+ loraPath: str | None = None
248
+ loraStrength: float = 1.0
249
+
250
+
251
+ class GenerateImageRequest(BaseModel):
252
+ prompt: NonEmptyPrompt
253
+ width: int = 1024
254
+ height: int = 1024
255
+ numSteps: int = 4
256
+ numImages: int = 1
257
+
258
+
259
+ def _default_model_types() -> set[ModelFileType]:
260
+ return set()
261
+
262
+
263
+ class ModelDownloadRequest(BaseModel):
264
+ modelTypes: set[ModelFileType] = Field(default_factory=_default_model_types)
265
+
266
+
267
+ class RequiredModelsResponse(BaseModel):
268
+ modelTypes: list[ModelFileType]
269
+
270
+
271
+ class SuggestGapPromptRequest(BaseModel):
272
+ beforePrompt: str = ""
273
+ afterPrompt: str = ""
274
+ beforeFrame: str | None = None
275
+ afterFrame: str | None = None
276
+ gapDuration: float = 5
277
+ mode: str = "t2v"
278
+ inputImage: str | None = None
279
+
280
+
281
+ class RetakeRequest(BaseModel):
282
+ video_path: str
283
+ start_time: float = 0
284
+ duration: float = 0
285
+ prompt: str = ""
286
+ mode: str = "replace_video_only"
287
+ width: int | None = None
288
+ height: int | None = None
289
+
290
+
291
+ class IcLoraExtractRequest(BaseModel):
292
+ video_path: str
293
+ conditioning_type: Literal["canny", "depth"] = "canny"
294
+ frame_time: float = 0
295
+
296
+
297
+ class IcLoraImageInput(BaseModel):
298
+ path: str
299
+ frame: int = 0
300
+ strength: float = 1.0
301
+
302
+
303
+ def _default_ic_lora_images() -> list[IcLoraImageInput]:
304
+ return []
305
+
306
+
307
+ class IcLoraGenerateRequest(BaseModel):
308
+ video_path: str
309
+ conditioning_type: Literal["canny", "depth"]
310
+ prompt: NonEmptyPrompt
311
+ conditioning_strength: float = 1.0
312
+ num_inference_steps: int = 30
313
+ cfg_guidance_scale: float = 1.0
314
+ negative_prompt: str = ""
315
+ images: list[IcLoraImageInput] = Field(default_factory=_default_ic_lora_images)
LTX2.3/patches/app_factory.py ADDED
@@ -0,0 +1,2288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI app factory decoupled from runtime bootstrap side effects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hmac
7
+ import os
8
+
9
+ # 防 OOM 与显存碎片化补丁:在 torch 初始化之前注入环境变量
10
+ os.environ["PYTORCH_ALLOC_CONF"] = "expandable_segments:True"
11
+ import torch # 提升到顶层导入
12
+ from collections.abc import Awaitable, Callable
13
+ from typing import TYPE_CHECKING
14
+ from pathlib import Path # 必须导入,用于处理 Windows 路径
15
+
16
+ from fastapi import FastAPI, Request, UploadFile, File
17
+ from fastapi.exceptions import RequestValidationError
18
+ from fastapi.middleware.cors import CORSMiddleware
19
+ from fastapi.responses import JSONResponse
20
+ from pydantic import ConfigDict
21
+ from fastapi.staticfiles import StaticFiles # 必须导入,用于挂载静态目录
22
+ from starlette.responses import Response as StarletteResponse
23
+ import shutil
24
+ import tempfile
25
+ import time
26
+ from api_types import (
27
+ GenerateVideoRequest,
28
+ GenerateVideoResponse,
29
+ ImageConditioningInput,
30
+ )
31
+
32
+ from _routes._errors import HTTPError
33
+ from _routes.generation import router as generation_router
34
+ from _routes.health import router as health_router
35
+ from _routes.ic_lora import router as ic_lora_router
36
+ from _routes.image_gen import router as image_gen_router
37
+ from _routes.models import router as models_router
38
+ from _routes.suggest_gap_prompt import router as suggest_gap_prompt_router
39
+ from _routes.retake import router as retake_router
40
+ from _routes.runtime_policy import router as runtime_policy_router
41
+ from _routes.settings import router as settings_router
42
+ from logging_policy import log_http_error, log_unhandled_exception
43
+ from state import init_state_service
44
+
45
+ if TYPE_CHECKING:
46
+ from app_handler import AppHandler
47
+
48
+ # 跨域配置:允许所有来源,解决本地网页调用限制
49
+ DEFAULT_ALLOWED_ORIGINS: list[str] = ["*"]
50
+
51
+
52
+ def _ltx_desktop_config_dir() -> Path:
53
+ p = (
54
+ Path(os.environ.get("LOCALAPPDATA", os.path.expanduser("~/AppData/Local")))
55
+ / "LTXDesktop"
56
+ )
57
+ p.mkdir(parents=True, exist_ok=True)
58
+ return p.resolve()
59
+
60
+
61
+ def _extend_generate_video_request_model() -> None:
62
+ """Keep custom video fields working across upstream request-model changes."""
63
+ annotations = dict(getattr(GenerateVideoRequest, "__annotations__", {}))
64
+ changed = False
65
+
66
+ for field_name, ann in (
67
+ ("startFramePath", str | None),
68
+ ("endFramePath", str | None),
69
+ ("keyframePaths", list[str] | None),
70
+ ("keyframeStrengths", list[float] | None),
71
+ ("keyframeTimes", list[float] | None),
72
+ ):
73
+ if field_name not in annotations:
74
+ annotations[field_name] = ann
75
+ setattr(GenerateVideoRequest, field_name, None)
76
+ changed = True
77
+
78
+ if changed:
79
+ GenerateVideoRequest.__annotations__ = annotations
80
+
81
+ existing_config = dict(getattr(GenerateVideoRequest, "model_config", {}) or {})
82
+ if existing_config.get("extra") != "allow":
83
+ existing_config["extra"] = "allow"
84
+ GenerateVideoRequest.model_config = ConfigDict(**existing_config)
85
+ changed = True
86
+
87
+ if changed:
88
+ GenerateVideoRequest.model_rebuild(force=True)
89
+
90
+
91
+ def create_app(
92
+ *,
93
+ handler: "AppHandler",
94
+ allowed_origins: list[str] | None = None,
95
+ title: str = "LTX-2 Video Generation Server",
96
+ auth_token: str = "",
97
+ admin_token: str = "",
98
+ ) -> FastAPI:
99
+ """Create a configured FastAPI app bound to the provided handler."""
100
+ init_state_service(handler)
101
+ _extend_generate_video_request_model()
102
+
103
+ app = FastAPI(title=title)
104
+ app.state.admin_token = admin_token # type: ignore[attr-defined]
105
+
106
+ # 彻底压制 WinError 10054 (客户端强制断开) 的底层警告报错
107
+ import sys, asyncio
108
+
109
+ if sys.platform == "win32":
110
+ try:
111
+ loop = asyncio.get_event_loop()
112
+
113
+ def silence_winerror_10054(loop, context):
114
+ exc = context.get("exception")
115
+ if (
116
+ isinstance(exc, ConnectionResetError)
117
+ and getattr(exc, "winerror", None) == 10054
118
+ ):
119
+ return
120
+ loop.default_exception_handler(context)
121
+
122
+ loop.set_exception_handler(silence_winerror_10054)
123
+ except Exception:
124
+ pass
125
+
126
+ # --- 核心修复:对准 LTX 真正的输出目录 (AppData) ---
127
+ def get_dynamic_output_path():
128
+ base_dir = (
129
+ Path(os.environ.get("LOCALAPPDATA", os.path.expanduser("~/AppData/Local")))
130
+ / "LTXDesktop"
131
+ ).resolve()
132
+ config_file = base_dir / "custom_dir.txt"
133
+ if config_file.exists():
134
+ try:
135
+ custom_dir = config_file.read_text(encoding="utf-8").strip()
136
+ if custom_dir:
137
+ p = Path(custom_dir)
138
+ p.mkdir(parents=True, exist_ok=True)
139
+ return p
140
+ except Exception:
141
+ pass
142
+ default_dir = base_dir / "outputs"
143
+ default_dir.mkdir(parents=True, exist_ok=True)
144
+ return default_dir
145
+
146
+ actual_output_path = get_dynamic_output_path()
147
+ handler.config.outputs_dir = actual_output_path
148
+
149
+ pl = handler.pipelines
150
+ pl._pipeline_signature = None
151
+ from low_vram_runtime import (
152
+ install_low_vram_on_pipelines,
153
+ install_low_vram_pipeline_hooks,
154
+ )
155
+
156
+ install_low_vram_on_pipelines(handler)
157
+ install_low_vram_pipeline_hooks(pl)
158
+ # LoRA:在 SingleGPUModelBuilder.build 时合并权重(model_ledger 不足以让桌面版 DiT 吃到 LoRA)
159
+ from lora_build_hook import install_lora_build_hook
160
+
161
+ install_lora_build_hook()
162
+
163
+ upload_tmp_path = actual_output_path / "uploads"
164
+
165
+ # 如果文件夹不存在则创建,防止挂载失败
166
+ if not actual_output_path.exists():
167
+ actual_output_path.mkdir(parents=True, exist_ok=True)
168
+ if not upload_tmp_path.exists():
169
+ upload_tmp_path.mkdir(parents=True, exist_ok=True)
170
+
171
+ # 挂载静态服务:将该目录映射到 http://127.0.0.1:3000/outputs
172
+ app.mount(
173
+ "/outputs", StaticFiles(directory=str(actual_output_path)), name="outputs"
174
+ )
175
+ # -----------------------------------------------
176
+
177
+ # 配置 CORS
178
+ app.add_middleware(
179
+ CORSMiddleware,
180
+ allow_origins=allowed_origins or DEFAULT_ALLOWED_ORIGINS,
181
+ allow_methods=["*"],
182
+ allow_headers=["*"],
183
+ )
184
+
185
+ # === [全局隔离补丁] ===
186
+ # 强制将每一个新的 HTTP 线程/协程请求的默认显卡都强绑定到用户选定的设备上
187
+ @app.middleware("http")
188
+ async def _sync_gpu_middleware(
189
+ request: Request,
190
+ call_next: Callable[[Request], Awaitable[StarletteResponse]],
191
+ ) -> StarletteResponse:
192
+ import torch
193
+
194
+ if (
195
+ torch.cuda.is_available()
196
+ and getattr(handler.config.device, "type", "") == "cuda"
197
+ ):
198
+ idx = handler.config.device.index
199
+ if idx is not None:
200
+ # 能够强行夺取那些底层写死了 cuda:0 而忽略 config.device 的第三方库
201
+ torch.cuda.set_device(idx)
202
+ return await call_next(request)
203
+
204
+ # 认证中间件
205
+ @app.middleware("http")
206
+ async def _auth_middleware(
207
+ request: Request,
208
+ call_next: Callable[[Request], Awaitable[StarletteResponse]],
209
+ ) -> StarletteResponse:
210
+ # 关键修复:如果是获取生成的图片,直接放行,不检查 Token
211
+ if (
212
+ request.url.path.startswith("/outputs")
213
+ or request.url.path == "/api/system/upload-image"
214
+ ):
215
+ return await call_next(request)
216
+
217
+ if not auth_token:
218
+ return await call_next(request)
219
+ if request.method == "OPTIONS":
220
+ return await call_next(request)
221
+
222
+ def _token_matches(candidate: str) -> bool:
223
+ return hmac.compare_digest(candidate, auth_token)
224
+
225
+ # WebSocket 认证
226
+ if request.headers.get("upgrade", "").lower() == "websocket":
227
+ if _token_matches(request.query_params.get("token", "")):
228
+ return await call_next(request)
229
+ return JSONResponse(status_code=401, content={"error": "Unauthorized"})
230
+
231
+ # HTTP 认证 (Bearer/Basic)
232
+ auth_header = request.headers.get("authorization", "")
233
+ if auth_header.startswith("Bearer ") and _token_matches(auth_header[7:]):
234
+ return await call_next(request)
235
+ if auth_header.startswith("Basic "):
236
+ try:
237
+ decoded = base64.b64decode(auth_header[6:]).decode()
238
+ _, _, password = decoded.partition(":")
239
+ if _token_matches(password):
240
+ return await call_next(request)
241
+ except Exception:
242
+ pass
243
+ return JSONResponse(status_code=401, content={"error": "Unauthorized"})
244
+
245
+ # 异常处理逻辑
246
+ _FALLBACK = "An unexpected error occurred"
247
+
248
+ async def _route_http_error_handler(
249
+ request: Request, exc: Exception
250
+ ) -> JSONResponse:
251
+ if isinstance(exc, HTTPError):
252
+ log_http_error(request, exc)
253
+ return JSONResponse(
254
+ status_code=exc.status_code, content={"error": exc.detail or _FALLBACK}
255
+ )
256
+ return JSONResponse(status_code=500, content={"error": str(exc) or _FALLBACK})
257
+
258
+ async def _validation_error_handler(
259
+ request: Request, exc: Exception
260
+ ) -> JSONResponse:
261
+ if isinstance(exc, RequestValidationError):
262
+ return JSONResponse(
263
+ status_code=422, content={"error": str(exc) or _FALLBACK}
264
+ )
265
+ return JSONResponse(status_code=422, content={"error": str(exc) or _FALLBACK})
266
+
267
+ async def _route_generic_error_handler(
268
+ request: Request, exc: Exception
269
+ ) -> JSONResponse:
270
+ log_unhandled_exception(request, exc)
271
+ return JSONResponse(status_code=500, content={"error": str(exc) or _FALLBACK})
272
+
273
+ app.add_exception_handler(RequestValidationError, _validation_error_handler)
274
+ app.add_exception_handler(HTTPError, _route_http_error_handler)
275
+ app.add_exception_handler(Exception, _route_generic_error_handler)
276
+
277
+ # --- 系统功能接口 ---
278
+ @app.post("/api/system/clear-gpu")
279
+ async def route_clear_gpu():
280
+ try:
281
+ import torch
282
+ import gc
283
+ import asyncio
284
+
285
+ # 1. 尝试终止任务并重置运行状态
286
+ if getattr(handler.generation, "is_generation_running", lambda: False)():
287
+ try:
288
+ handler.generation.cancel_generation()
289
+ except Exception:
290
+ pass
291
+ await asyncio.sleep(0.5)
292
+
293
+ # 暴力重置死锁状态
294
+ if hasattr(handler.generation, "_generation_id"):
295
+ handler.generation._generation_id = None
296
+ if hasattr(handler.generation, "_is_generating"):
297
+ handler.generation._is_generating = False
298
+
299
+ # 2. 强制卸载模型: 临时屏蔽底层锁定器
300
+ try:
301
+ mock_swapped = False
302
+ orig_running = None
303
+ if hasattr(handler.pipelines, "_generation_service"):
304
+ orig_running = (
305
+ handler.pipelines._generation_service.is_generation_running
306
+ )
307
+ handler.pipelines._generation_service.is_generation_running = (
308
+ lambda: False
309
+ )
310
+ mock_swapped = True
311
+ try:
312
+ from keep_models_runtime import force_unload_gpu_pipeline
313
+
314
+ force_unload_gpu_pipeline(handler.pipelines)
315
+ finally:
316
+ if mock_swapped:
317
+ handler.pipelines._generation_service.is_generation_running = (
318
+ orig_running
319
+ )
320
+ except Exception as e:
321
+ print(f"Force unload warning: {e}")
322
+
323
+ # 3. 深度清理
324
+ gc.collect()
325
+ if torch.cuda.is_available():
326
+ torch.cuda.empty_cache()
327
+ torch.cuda.ipc_collect()
328
+ try:
329
+ handler.pipelines._pipeline_signature = None
330
+ except Exception:
331
+ pass
332
+ return {
333
+ "status": "success",
334
+ "message": "GPU memory cleared and models unloaded",
335
+ }
336
+ except Exception as e:
337
+ return JSONResponse(status_code=500, content={"error": str(e)})
338
+
339
+ @app.get("/api/system/low-vram-mode")
340
+ async def route_get_low_vram_mode():
341
+ enabled = bool(getattr(handler.pipelines, "low_vram_mode", False))
342
+ return {"enabled": enabled}
343
+
344
+ @app.post("/api/system/low-vram-mode")
345
+ async def route_set_low_vram_mode(request: Request):
346
+ try:
347
+ data = await request.json()
348
+ except Exception:
349
+ data = {}
350
+ enabled = bool(data.get("enabled", False))
351
+ from low_vram_runtime import (
352
+ apply_low_vram_config_tweaks,
353
+ write_low_vram_pref,
354
+ )
355
+
356
+ handler.pipelines.low_vram_mode = enabled
357
+ write_low_vram_pref(enabled)
358
+ if enabled:
359
+ apply_low_vram_config_tweaks(handler)
360
+ return {"status": "success", "enabled": enabled}
361
+
362
+ @app.post("/api/system/reset-state")
363
+ async def route_reset_state():
364
+ """轻量级状态重置:只清除 generation 状态锁,不卸载 GPU 管线。
365
+ 在每次新渲染开始前由前端调用,确保后端状态干净可用。"""
366
+ try:
367
+ gen = handler.generation
368
+ # 强制清除所有可能导致 is_generation_running() 返回 True 的标志
369
+ for attr in (
370
+ "_is_generating",
371
+ "_generation_id",
372
+ "_cancelled",
373
+ "_is_cancelled",
374
+ ):
375
+ if hasattr(gen, attr):
376
+ if attr in ("_is_generating", "_cancelled", "_is_cancelled"):
377
+ setattr(gen, attr, False)
378
+ else:
379
+ setattr(gen, attr, None)
380
+ # 某些实现用 threading.Event
381
+ for attr in ("_cancel_event",):
382
+ if hasattr(gen, attr):
383
+ try:
384
+ getattr(gen, attr).clear()
385
+ except Exception:
386
+ pass
387
+ print("[reset-state] Generation state has been reset cleanly.")
388
+ return {"status": "success", "message": "Generation state reset"}
389
+ except Exception as e:
390
+ import traceback
391
+
392
+ traceback.print_exc()
393
+ return JSONResponse(status_code=500, content={"error": str(e)})
394
+
395
+ @app.post("/api/system/set-dir")
396
+ async def route_set_dir(request: Request):
397
+ try:
398
+ data = await request.json()
399
+ new_dir = data.get("directory", "").strip()
400
+ base_dir = (
401
+ Path(
402
+ os.environ.get(
403
+ "LOCALAPPDATA", os.path.expanduser("~/AppData/Local")
404
+ )
405
+ )
406
+ / "LTXDesktop"
407
+ ).resolve()
408
+ config_file = base_dir / "custom_dir.txt"
409
+ if new_dir:
410
+ p = Path(new_dir)
411
+ p.mkdir(parents=True, exist_ok=True)
412
+ config_file.write_text(new_dir, encoding="utf-8")
413
+ else:
414
+ if config_file.exists():
415
+ config_file.unlink()
416
+ # 立即更新全局 config 控制
417
+ handler.config.outputs_dir = get_dynamic_output_path()
418
+ return {"status": "success", "directory": str(get_dynamic_output_path())}
419
+ except Exception as e:
420
+ return JSONResponse(status_code=500, content={"error": str(e)})
421
+
422
+ @app.get("/api/system/get-dir")
423
+ async def route_get_dir():
424
+ return {"status": "success", "directory": str(get_dynamic_output_path())}
425
+
426
+ @app.get("/api/system/browse-dir")
427
+ async def route_browse_dir():
428
+ try:
429
+ import subprocess
430
+
431
+ # 强制将对话框置顶层:通过 STA 线程 + Topmost 属性,避免被窗口锥入后台
432
+ ps_script = (
433
+ "[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null;"
434
+ "[System.Reflection.Assembly]::LoadWithPartialName('System.Drawing') | Out-Null;"
435
+ "$f = New-Object System.Windows.Forms.FolderBrowserDialog;"
436
+ "$f.Description = '\u9009\u62e9 LTX \u89c6\u9891\u548c\u56fe\u50cf\u751f\u6210\u7684\u5168\u5c40\u8f93\u51fa\u76ee\u5f55';"
437
+ "$f.ShowNewFolderButton = $true;"
438
+ # 创建一个雐形助手窗口作为 parent 确保对话框在最顶层
439
+ "$owner = New-Object System.Windows.Forms.Form;"
440
+ "$owner.TopMost = $true;"
441
+ "$owner.StartPosition = 'CenterScreen';"
442
+ "$owner.Size = New-Object System.Drawing.Size(1, 1);"
443
+ "$owner.Show();"
444
+ "$owner.BringToFront();"
445
+ "$owner.Focus();"
446
+ "if ($f.ShowDialog($owner) -eq 'OK') { echo $f.SelectedPath };"
447
+ "$owner.Dispose();"
448
+ )
449
+
450
+ def run_ps():
451
+ process = subprocess.Popen(
452
+ ["powershell", "-STA", "-NoProfile", "-Command", ps_script],
453
+ stdout=subprocess.PIPE,
454
+ stderr=subprocess.PIPE,
455
+ text=True,
456
+ # 移除 CREATE_NO_WINDOW 以允许 UI 线程正常弹出
457
+ )
458
+ stdout, _ = process.communicate()
459
+ return stdout.strip()
460
+
461
+ from starlette.concurrency import run_in_threadpool
462
+
463
+ selected_dir = await run_in_threadpool(run_ps)
464
+ return {"status": "success", "directory": selected_dir}
465
+ except Exception as e:
466
+ return JSONResponse(status_code=500, content={"error": str(e)})
467
+
468
+ _LORA_SCAN_SUFFIXES = {".safetensors", ".ckpt", ".pt", ".bin"}
469
+
470
+ @app.get("/api/loras")
471
+ async def route_list_loras(request: Request):
472
+ """扫描本地 LoRA 目录;前端「设置」里填的路径依赖此接口(官方路由可能不存在)。"""
473
+ raw = (request.query_params.get("dir") or "").strip()
474
+ if raw.startswith("True"):
475
+ raw = raw[4:].lstrip()
476
+ raw = raw.strip().strip('"').strip("'")
477
+ if not raw:
478
+ # 默认规则:LoRA 路径 = 默认 models_dir 下的 `loras` 子目录(规则写死)
479
+ try:
480
+ md = getattr(handler.pipelines, "models_dir", None)
481
+ if md:
482
+ from pathlib import Path
483
+
484
+ root = Path(str(md)).expanduser().resolve() / "loras"
485
+ raw = str(root)
486
+ except Exception:
487
+ raw = ""
488
+ if not raw:
489
+ return {"loras": [], "loras_dir": "", "models_dir": ""}
490
+
491
+ root = Path(raw).expanduser()
492
+ try:
493
+ root = root.resolve()
494
+ except OSError:
495
+ pass
496
+
497
+ if not root.is_dir():
498
+ return {
499
+ "loras": [],
500
+ "error": "not_a_directory",
501
+ "message": "路径不是文件夹或不存在,请检查拼写、盘符与权限",
502
+ "path": str(root),
503
+ "loras_dir": str(root),
504
+ "models_dir": str(root.parent),
505
+ }
506
+
507
+ found: list[dict[str, str]] = []
508
+ try:
509
+ for dirpath, _dirnames, filenames in os.walk(root):
510
+ for fn in filenames:
511
+ suf = Path(fn).suffix.lower()
512
+ if suf in _LORA_SCAN_SUFFIXES:
513
+ full = Path(dirpath) / fn
514
+ if full.is_file():
515
+ try:
516
+ resolved = str(full.resolve())
517
+ except OSError:
518
+ resolved = str(full)
519
+ found.append({"name": fn, "path": resolved})
520
+ except OSError as e:
521
+ return JSONResponse(
522
+ status_code=400,
523
+ content={
524
+ "loras": [],
525
+ "error": "scan_failed",
526
+ "message": str(e),
527
+ "path": str(root),
528
+ },
529
+ )
530
+
531
+ found.sort(key=lambda x: x["name"].lower())
532
+ return {
533
+ "loras": found,
534
+ "loras_dir": str(root),
535
+ "models_dir": str(root.parent),
536
+ }
537
+
538
+ _MODEL_SCAN_SUFFIXES = {
539
+ ".safetensors",
540
+ ".ckpt",
541
+ ".pt",
542
+ ".bin",
543
+ ".pth",
544
+ }
545
+
546
+ @app.get("/api/models")
547
+ async def route_list_models(request: Request):
548
+ """扫描本地 checkpoint 目录;需在官方 models_router 之前注册以覆盖空列表行为。"""
549
+ raw = (request.query_params.get("dir") or "").strip()
550
+ if raw.startswith("True"):
551
+ raw = raw[4:].lstrip()
552
+ raw = raw.strip().strip('"').strip("'")
553
+
554
+ if not raw:
555
+ try:
556
+ md = getattr(handler.pipelines, "models_dir", None)
557
+ if md is None or not str(md).strip():
558
+ return {"models": []}
559
+ root = Path(str(md)).expanduser().resolve()
560
+ except OSError:
561
+ return {"models": []}
562
+ if not root.is_dir():
563
+ return {"models": []}
564
+ else:
565
+ root = Path(raw).expanduser()
566
+ try:
567
+ root = root.resolve()
568
+ except OSError:
569
+ pass
570
+
571
+ if not root.is_dir():
572
+ return {
573
+ "models": [],
574
+ "error": "not_a_directory",
575
+ "message": "路径不是文件夹或不存在,请检查拼写、盘符与权限",
576
+ "path": str(root),
577
+ }
578
+
579
+ found: list[dict[str, str]] = []
580
+ try:
581
+ for dirpath, _dirnames, filenames in os.walk(root):
582
+ for fn in filenames:
583
+ suf = Path(fn).suffix.lower()
584
+ if suf in _MODEL_SCAN_SUFFIXES:
585
+ full = Path(dirpath) / fn
586
+ if full.is_file():
587
+ try:
588
+ resolved = str(full.resolve())
589
+ except OSError:
590
+ resolved = str(full)
591
+ found.append({"name": fn, "path": resolved})
592
+ except OSError as e:
593
+ return JSONResponse(
594
+ status_code=400,
595
+ content={
596
+ "models": [],
597
+ "error": "scan_failed",
598
+ "message": str(e),
599
+ "path": str(root),
600
+ },
601
+ )
602
+
603
+ found.sort(key=lambda x: x["name"].lower())
604
+ return {"models": found}
605
+
606
+ @app.get("/api/system/file")
607
+ async def route_serve_file(path: str):
608
+ from fastapi.responses import FileResponse
609
+
610
+ if os.path.exists(path):
611
+ return FileResponse(path)
612
+ return JSONResponse(status_code=404, content={"error": "File not found"})
613
+
614
+ @app.get("/api/system/list-gpus")
615
+ async def route_list_gpus():
616
+ try:
617
+ import torch
618
+
619
+ gpus = []
620
+ if torch.cuda.is_available():
621
+ current_idx = 0
622
+ dev = getattr(handler.config, "device", None)
623
+ if dev is not None and getattr(dev, "index", None) is not None:
624
+ current_idx = dev.index
625
+ for i in range(torch.cuda.device_count()):
626
+ try:
627
+ name = torch.cuda.get_device_name(i)
628
+ except Exception:
629
+ name = f"GPU {i}"
630
+ try:
631
+ vram_bytes = torch.cuda.get_device_properties(i).total_memory
632
+ vram_gb = vram_bytes / (1024**3)
633
+ vram_mb = vram_bytes / (1024**2)
634
+ except Exception:
635
+ vram_gb = 0.0
636
+ vram_mb = 0
637
+ gpus.append(
638
+ {
639
+ "id": i,
640
+ "name": name,
641
+ "vram": f"{vram_gb:.1f} GB",
642
+ "vram_mb": int(vram_mb),
643
+ "active": (i == current_idx),
644
+ }
645
+ )
646
+ return {"status": "success", "gpus": gpus}
647
+ except Exception as e:
648
+ return JSONResponse(status_code=500, content={"error": str(e)})
649
+
650
+ @app.post("/api/system/switch-gpu")
651
+ async def route_switch_gpu(request: Request):
652
+ try:
653
+ import torch
654
+ import gc
655
+ import asyncio
656
+
657
+ data = await request.json()
658
+ gpu_id = data.get("gpu_id")
659
+
660
+ if (
661
+ gpu_id is None
662
+ or not torch.cuda.is_available()
663
+ or gpu_id >= torch.cuda.device_count()
664
+ ):
665
+ return JSONResponse(
666
+ status_code=400, content={"error": "Invalid GPU ID"}
667
+ )
668
+
669
+ # 先尝试终止任何可能的卡死任务
670
+ if getattr(handler.generation, "is_generation_running", lambda: False)():
671
+ try:
672
+ handler.generation.cancel_generation()
673
+ except Exception:
674
+ pass
675
+ await asyncio.sleep(0.5)
676
+ if hasattr(handler.generation, "_generation_id"):
677
+ handler.generation._generation_id = None
678
+ if hasattr(handler.generation, "_is_generating"):
679
+ handler.generation._is_generating = False
680
+
681
+ # 1. 卸载当前 GPU 上的模型: 临时屏蔽底层锁定器
682
+ try:
683
+ mock_swapped = False
684
+ orig_running = None
685
+ if hasattr(handler.pipelines, "_generation_service"):
686
+ orig_running = (
687
+ handler.pipelines._generation_service.is_generation_running
688
+ )
689
+ handler.pipelines._generation_service.is_generation_running = (
690
+ lambda: False
691
+ )
692
+ mock_swapped = True
693
+ try:
694
+ from keep_models_runtime import force_unload_gpu_pipeline
695
+
696
+ force_unload_gpu_pipeline(handler.pipelines)
697
+ finally:
698
+ if mock_swapped:
699
+ handler.pipelines._generation_service.is_generation_running = (
700
+ orig_running
701
+ )
702
+ except Exception:
703
+ pass
704
+ gc.collect()
705
+ torch.cuda.empty_cache()
706
+
707
+ try:
708
+ handler.pipelines._pipeline_signature = None
709
+ except Exception:
710
+ pass
711
+
712
+ # 2. 切换全局设备配置
713
+ new_device = torch.device(f"cuda:{gpu_id}")
714
+ handler.config.device = new_device
715
+
716
+ # 3. 核心修复:设置当前进程的默认 CUDA 设备
717
+ # 这会影响到 torch.cuda.current_device() 和后续的模型加载
718
+ torch.cuda.set_device(gpu_id)
719
+
720
+ # 针对底层库可能直接读取 CUDA_VISIBLE_DEVICES 的情况
721
+ # 注意:torch 初始化后修改此变量不一定生效,但对某些库可能有引导作用
722
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(gpu_id)
723
+
724
+ # 4. 【核心修复】同步更新 TextEncoder 的设备指针
725
+ # 根本原因: LTXTextEncoder.self.device 在初始化时硬绑定了旧 GPU,
726
+ # 切换设备后 text context 仍在旧 GPU 上,与已迁移到新 GPU 的
727
+ # Transformer 产生 "cuda:0 and cuda:1" 设备不一致冲突。
728
+ try:
729
+ te_state = None
730
+ # 尝试多种路径访问 text_encoder 状态
731
+ if hasattr(handler, "state") and hasattr(handler.state, "text_encoder"):
732
+ te_state = handler.state.text_encoder
733
+ elif hasattr(handler, "_state") and hasattr(
734
+ handler._state, "text_encoder"
735
+ ):
736
+ te_state = handler._state.text_encoder
737
+
738
+ if te_state is not None:
739
+ # 4a. 更新 LTXTextEncoder 服务自身的 device 属性
740
+ if hasattr(te_state, "service") and hasattr(
741
+ te_state.service, "device"
742
+ ):
743
+ te_state.service.device = new_device
744
+ print(f"[TextEncoder] device updated to {new_device}")
745
+
746
+ # 4b. 将缓存的 encoder 权重迁移到 CPU,下次推理时再按新设备重加载
747
+ if (
748
+ hasattr(te_state, "cached_encoder")
749
+ and te_state.cached_encoder is not None
750
+ ):
751
+ try:
752
+ te_state.cached_encoder.to(torch.device("cpu"))
753
+ except Exception:
754
+ pass
755
+ te_state.cached_encoder = None
756
+ print(
757
+ "[TextEncoder] cached encoder cleared (will reload on new GPU)"
758
+ )
759
+
760
+ # 4c. 清除 API embeddings 缓存(tensor 绑定旧 GPU)
761
+ if hasattr(te_state, "api_embeddings"):
762
+ te_state.api_embeddings = None
763
+
764
+ # 4d. 清除 prompt cache(其中 tensor 也绑定旧 GPU)
765
+ if hasattr(te_state, "prompt_cache") and te_state.prompt_cache:
766
+ te_state.prompt_cache.clear()
767
+ print("[TextEncoder] prompt cache cleared")
768
+ except Exception as _te_err:
769
+ print(f"[TextEncoder] device sync warning (non-fatal): {_te_err}")
770
+
771
+ print(
772
+ f"Switched active GPU to: {torch.cuda.get_device_name(gpu_id)} (ID: {gpu_id})"
773
+ )
774
+ return {"status": "success", "message": f"Switched to GPU {gpu_id}"}
775
+ except Exception as e:
776
+ return JSONResponse(status_code=500, content={"error": str(e)})
777
+
778
+ # --- 核心增强:首尾帧插值与视频超分支持 ---
779
+ from handlers.video_generation_handler import VideoGenerationHandler
780
+ from services.retake_pipeline.ltx_retake_pipeline import LTXRetakePipeline
781
+ from server_utils.media_validation import normalize_optional_path
782
+ from PIL import Image
783
+
784
+ # 1. 增强插值功能 (Monkey Patch VideoGenerationHandler)
785
+ _orig_generate = VideoGenerationHandler.generate
786
+ _orig_generate_video = VideoGenerationHandler.generate_video
787
+
788
+ def patched_generate(self, req: GenerateVideoRequest):
789
+ # === [DEBUG] 打印当前生成状态 ===
790
+ gen = self._generation
791
+ is_running = (
792
+ gen.is_generation_running()
793
+ if hasattr(gen, "is_generation_running")
794
+ else "?方法不存在"
795
+ )
796
+ gen_id = getattr(gen, "_generation_id", "?属性不存在")
797
+ is_gen = getattr(gen, "_is_generating", "?属性不存在")
798
+ cancelled = getattr(
799
+ gen, "_cancelled", getattr(gen, "_is_cancelled", "?属性不存在")
800
+ )
801
+ print(f"\n[PATCH][patched_generate] ==> 收到新请求")
802
+ print(f" is_generation_running() = {is_running}")
803
+ print(f" _generation_id = {gen_id}")
804
+ print(f" _is_generating = {is_gen}")
805
+ print(f" _cancelled = {cancelled}")
806
+ start_frame_path = normalize_optional_path(getattr(req, "startFramePath", None))
807
+ end_frame_path = normalize_optional_path(getattr(req, "endFramePath", None))
808
+ _raw_kf = getattr(req, "keyframePaths", None)
809
+ keyframe_paths_list: list[str] = []
810
+ if isinstance(_raw_kf, list):
811
+ for p in _raw_kf:
812
+ np = normalize_optional_path(p)
813
+ if np:
814
+ keyframe_paths_list.append(np)
815
+ use_multi_keyframes = len(keyframe_paths_list) >= 2
816
+ _raw_kf_st = getattr(req, "keyframeStrengths", None)
817
+ keyframe_strengths_list: list[float] | None = None
818
+ if isinstance(_raw_kf_st, list) and _raw_kf_st:
819
+ try:
820
+ keyframe_strengths_list = [float(x) for x in _raw_kf_st]
821
+ except (TypeError, ValueError):
822
+ keyframe_strengths_list = None
823
+ _raw_kf_t = getattr(req, "keyframeTimes", None)
824
+ keyframe_times_list: list[float] | None = None
825
+ if isinstance(_raw_kf_t, list) and _raw_kf_t:
826
+ try:
827
+ keyframe_times_list = [float(x) for x in _raw_kf_t]
828
+ except (TypeError, ValueError):
829
+ keyframe_times_list = None
830
+ aspect_ratio = getattr(req, "aspectRatio", None)
831
+ print(f" startFramePath = {start_frame_path}")
832
+ print(f" endFramePath = {end_frame_path}")
833
+ print(f" keyframePaths (n={len(keyframe_paths_list)}) = {use_multi_keyframes}")
834
+ print(f" aspectRatio = {aspect_ratio}")
835
+
836
+ # 检查是否有音频
837
+ audio_path = normalize_optional_path(getattr(req, "audioPath", None))
838
+ print(f"[PATCH] audio_path = {audio_path}")
839
+
840
+ # 检查是否有图片(图生视频)
841
+ image_path = normalize_optional_path(getattr(req, "imagePath", None))
842
+ print(f"[PATCH] image_path = {image_path}")
843
+
844
+ # 始终使用自定义逻辑(支持首尾帧和竖屏)
845
+ print(f"[PATCH] 使用自定义逻辑处理")
846
+
847
+ # 计算分辨率
848
+ import uuid
849
+
850
+ resolution = req.resolution
851
+ duration = int(float(req.duration))
852
+ fps = int(float(req.fps))
853
+
854
+ # 宽高均需为 64 的倍数(LTX 内核校验);在近似 16:9 下取整
855
+ RESOLUTION_MAP = {
856
+ "540p": (1024, 576),
857
+ "720p": (1280, 704),
858
+ "1080p": (1920, 1088),
859
+ }
860
+
861
+ def get_16_9_size(res):
862
+ return RESOLUTION_MAP.get(res, (1280, 704))
863
+
864
+ def get_9_16_size(res):
865
+ w, h = get_16_9_size(res)
866
+ return h, w # 交换宽高
867
+
868
+ if req.aspectRatio == "9:16":
869
+ width, height = get_9_16_size(resolution)
870
+ else:
871
+ width, height = get_16_9_size(resolution)
872
+
873
+ # 计算帧数
874
+ num_frames = ((duration * fps) // 8) * 8 + 1
875
+ num_frames = max(num_frames, 9)
876
+
877
+ print(f"[PATCH] 计算得到的分辨率: {width}x{height}, 帧数: {num_frames}")
878
+
879
+ # 多关键帧单次推理时勿用首尾帧属性,避免与 keyframe 列表重复
880
+ if use_multi_keyframes:
881
+ self._start_frame_path = None
882
+ self._end_frame_path = None
883
+ image_path_for_video = None
884
+ else:
885
+ self._start_frame_path = start_frame_path
886
+ self._end_frame_path = end_frame_path
887
+ image_path_for_video = image_path
888
+
889
+ # 无论有没有音频,都使用自定义逻辑支持首尾帧 / 多关键帧
890
+ try:
891
+ result = patched_generate_video(
892
+ self,
893
+ prompt=req.prompt,
894
+ image=None,
895
+ image_path=image_path_for_video,
896
+ height=height,
897
+ width=width,
898
+ num_frames=num_frames,
899
+ fps=fps,
900
+ seed=self._resolve_seed(),
901
+ camera_motion=req.cameraMotion,
902
+ negative_prompt=req.negativePrompt,
903
+ audio_path=audio_path,
904
+ lora_path=getattr(req, "loraPath", None),
905
+ lora_strength=float(getattr(req, "loraStrength", 1.0) or 1.0),
906
+ keyframe_paths=keyframe_paths_list if use_multi_keyframes else None,
907
+ keyframe_strengths=(
908
+ keyframe_strengths_list if use_multi_keyframes else None
909
+ ),
910
+ keyframe_times=(
911
+ keyframe_times_list if use_multi_keyframes else None
912
+ ),
913
+ )
914
+ print(f"[PATCH][patched_generate] <== 完成, 返回状态: complete")
915
+ return type("Response", (), {"status": "complete", "video_path": result})()
916
+ except Exception as e:
917
+ import traceback
918
+
919
+ print(f"[PATCH][patched_generate] 错误: {e}")
920
+ traceback.print_exc()
921
+ raise
922
+
923
+ def patched_generate_video(
924
+ self,
925
+ prompt,
926
+ image,
927
+ image_path=None,
928
+ height=None,
929
+ width=None,
930
+ num_frames=None,
931
+ fps=None,
932
+ seed=None,
933
+ camera_motion=None,
934
+ negative_prompt=None,
935
+ audio_path=None,
936
+ lora_path=None,
937
+ lora_strength=1.0,
938
+ keyframe_paths: list[str] | None = None,
939
+ keyframe_strengths: list[float] | None = None,
940
+ keyframe_times: list[float] | None = None,
941
+ ):
942
+ # === [DEBUG] 打印当前生成状态 ===
943
+ gen = self._generation
944
+ is_running = (
945
+ gen.is_generation_running()
946
+ if hasattr(gen, "is_generation_running")
947
+ else "?方法不存在"
948
+ )
949
+ gen_id = getattr(gen, "_generation_id", "?属性不存在")
950
+ is_gen = getattr(gen, "_is_generating", "?属性不存在")
951
+ print(f"[PATCH][patched_generate_video] ==> 开始推理")
952
+ print(f" is_generation_running() = {is_running}")
953
+ print(f" _generation_id = {gen_id}")
954
+ print(f" _is_generating = {is_gen}")
955
+ print(
956
+ f" resolution = {width}x{height}, frames={num_frames}, fps={fps}"
957
+ )
958
+ print(f" image param = {type(image)}, {image is not None}")
959
+ print(f" image_path = {image_path}")
960
+ # ==================================
961
+ from ltx_pipelines.utils.args import (
962
+ ImageConditioningInput as LtxImageConditioningInput,
963
+ )
964
+
965
+ images_inputs = []
966
+ temp_paths = []
967
+ kf_list = [p for p in (keyframe_paths or []) if p]
968
+ use_multi_kf = len(kf_list) >= 2
969
+
970
+ start_path = getattr(self, "_start_frame_path", None)
971
+ end_path = getattr(self, "_end_frame_path", None)
972
+ print(
973
+ f"[PATCH] start_path={start_path}, end_path={end_path}, multi_kf={use_multi_kf} n={len(kf_list)}"
974
+ )
975
+
976
+ latent_num_frames = (num_frames - 1) // 8 + 1
977
+ last_latent_idx = latent_num_frames - 1
978
+ print(
979
+ f"[PATCH] latent_num_frames={latent_num_frames}, last_latent_idx={last_latent_idx}"
980
+ )
981
+
982
+ if use_multi_kf:
983
+ n_kf = len(kf_list)
984
+ st_override = keyframe_strengths or []
985
+ if len(st_override) not in (0, n_kf):
986
+ print(
987
+ f"[PATCH] keyframeStrengths 长度({len(st_override)})与关键帧数({n_kf})不一致,改用默认强度曲线"
988
+ )
989
+ st_override = []
990
+
991
+ def _default_multi_guide_strength(i: int, n: int) -> float:
992
+ """对齐 Comfy LTXVAddGuideMulti 常见配置:首尾不全是 1,中间明显减弱以减少邻帧闪烁。"""
993
+ if n <= 2:
994
+ return 1.0
995
+ if i == 0:
996
+ return 0.62
997
+ if i == n - 1:
998
+ return 1.0
999
+ return 0.42
1000
+
1001
+ kt = keyframe_times or []
1002
+ times_match = len(kt) == n_kf
1003
+ if times_match:
1004
+ fps_f = max(float(fps), 0.001)
1005
+ max_t = (num_frames - 1) / fps_f
1006
+ fi_list: list[int] = []
1007
+ for ki in range(n_kf):
1008
+ t_sec = max(0.0, min(max_t, float(kt[ki])))
1009
+ pf = int(round(t_sec * fps_f))
1010
+ pf = min(num_frames - 1, max(0, pf))
1011
+ fi = min(last_latent_idx, pf // 8)
1012
+ fi_list.append(int(fi))
1013
+ for j in range(1, n_kf):
1014
+ if fi_list[j] <= fi_list[j - 1]:
1015
+ fi_list[j] = min(last_latent_idx, fi_list[j - 1] + 1)
1016
+ if n_kf > 1:
1017
+ fi_list[-1] = last_latent_idx
1018
+ print(f"[PATCH] Multi-keyframe: 使用 keyframeTimes 映射 -> {fi_list}")
1019
+ else:
1020
+ fi_list = []
1021
+ prev_fi = -1
1022
+ for ki in range(n_kf):
1023
+ if last_latent_idx <= 0:
1024
+ fi = 0
1025
+ elif ki == 0:
1026
+ fi = 0
1027
+ elif ki == n_kf - 1:
1028
+ fi = last_latent_idx
1029
+ else:
1030
+ pf = int(
1031
+ round(
1032
+ ki * (num_frames - 1) / max(1, (n_kf - 1))
1033
+ )
1034
+ )
1035
+ fi = min(last_latent_idx - 1, max(1, pf // 8))
1036
+ if fi <= prev_fi:
1037
+ fi = min(last_latent_idx - 1, prev_fi + 1)
1038
+ prev_fi = fi
1039
+ fi_list.append(int(fi))
1040
+
1041
+ for ki, kp in enumerate(kf_list):
1042
+ if not os.path.isfile(kp):
1043
+ raise RuntimeError(f"多关键帧路径无效或不存在: {kp}")
1044
+ fi = fi_list[ki]
1045
+
1046
+ if len(st_override) == n_kf:
1047
+ st = float(st_override[ki])
1048
+ st = max(0.1, min(1.0, st))
1049
+ else:
1050
+ st = _default_multi_guide_strength(ki, n_kf)
1051
+
1052
+ img = self._prepare_image(kp, width, height)
1053
+ tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
1054
+ img.save(tmp)
1055
+ temp_paths.append(tmp)
1056
+ tmp_normalized = tmp.replace("\\", "/")
1057
+ images_inputs.append(
1058
+ LtxImageConditioningInput(
1059
+ path=tmp_normalized, frame_idx=int(fi), strength=float(st)
1060
+ )
1061
+ )
1062
+ print(
1063
+ f"[PATCH] Multi-keyframe [{ki}]: {tmp_normalized}, "
1064
+ f"frame_idx={fi}, strength={st:.3f}"
1065
+ )
1066
+ else:
1067
+ # 如果没有首尾帧但有 image_path,使用 image_path 作为起始帧
1068
+ if not start_path and not end_path and image_path:
1069
+ print(f"[PATCH] 使用 image_path 作为起始帧: {image_path}")
1070
+ start_path = image_path
1071
+
1072
+ has_image_param = image is not None
1073
+ if has_image_param:
1074
+ print(
1075
+ f"[PATCH] image param is available, will be used as start frame"
1076
+ )
1077
+
1078
+ target_start_path = start_path if start_path else None
1079
+ if not target_start_path and image is not None:
1080
+ tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
1081
+ image.save(tmp)
1082
+ temp_paths.append(tmp)
1083
+ target_start_path = tmp
1084
+ print(
1085
+ f"[PATCH] Using image param as start frame: {target_start_path}"
1086
+ )
1087
+
1088
+ if target_start_path:
1089
+ start_img = self._prepare_image(target_start_path, width, height)
1090
+ tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
1091
+ start_img.save(tmp)
1092
+ temp_paths.append(tmp)
1093
+ tmp_normalized = tmp.replace("\\", "/")
1094
+ images_inputs.append(
1095
+ LtxImageConditioningInput(
1096
+ path=tmp_normalized, frame_idx=0, strength=1.0
1097
+ )
1098
+ )
1099
+ print(f"[PATCH] Added start frame: {tmp_normalized}, frame_idx=0")
1100
+
1101
+ if end_path:
1102
+ end_img = self._prepare_image(end_path, width, height)
1103
+ tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
1104
+ end_img.save(tmp)
1105
+ temp_paths.append(tmp)
1106
+ tmp_normalized = tmp.replace("\\", "/")
1107
+ images_inputs.append(
1108
+ LtxImageConditioningInput(
1109
+ path=tmp_normalized,
1110
+ frame_idx=last_latent_idx,
1111
+ strength=1.0,
1112
+ )
1113
+ )
1114
+ print(
1115
+ f"[PATCH] Added end frame: {tmp_normalized}, frame_idx={last_latent_idx}"
1116
+ )
1117
+
1118
+ print(f"[PATCH] images_inputs count: {len(images_inputs)}")
1119
+ if images_inputs:
1120
+ for idx, img in enumerate(images_inputs):
1121
+ print(
1122
+ f"[PATCH] images_inputs[{idx}]: path={getattr(img, 'path', 'N/A')}, frame_idx={getattr(img, 'frame_idx', 'N/A')}, strength={getattr(img, 'strength', 'N/A')}"
1123
+ )
1124
+
1125
+ print(f"[PATCH] audio_path = {audio_path}")
1126
+
1127
+ if self._generation.is_generation_cancelled():
1128
+ raise RuntimeError("Generation was cancelled")
1129
+
1130
+ # 导入 uuid
1131
+ import uuid
1132
+
1133
+ generation_id = uuid.uuid4().hex[:8]
1134
+
1135
+ # 根据是否有音频选择不同的 pipeline
1136
+ extra_loras_for_hook: tuple | None = None # 供 lora_build_hook 在 DiT build 时融合
1137
+ gpu_slot = getattr(self._pipelines.state, "gpu_slot", None)
1138
+ active = getattr(gpu_slot, "active_pipeline", None) if gpu_slot else None
1139
+ cached_sig = getattr(self._pipelines, "_pipeline_signature", None)
1140
+
1141
+ new_kind = "a2v" if audio_path else "fast"
1142
+ if (
1143
+ cached_sig
1144
+ and isinstance(cached_sig, tuple)
1145
+ and len(cached_sig) > 0
1146
+ and cached_sig[0] != new_kind
1147
+ and active is not None
1148
+ ):
1149
+ from keep_models_runtime import force_unload_gpu_pipeline
1150
+
1151
+ print(
1152
+ f"[PATCH] 管线类型切换 {cached_sig[0]} -> {new_kind},强制卸载旧模型"
1153
+ )
1154
+ force_unload_gpu_pipeline(self._pipelines)
1155
+ gpu_slot = getattr(self._pipelines.state, "gpu_slot", None)
1156
+ active = getattr(gpu_slot, "active_pipeline", None) if gpu_slot else None
1157
+
1158
+ if audio_path:
1159
+ desired_sig = ("a2v",)
1160
+ print(f"[PATCH] 加载 A2V pipeline(支持音频)")
1161
+ pipeline_state = self._pipelines.load_a2v_pipeline()
1162
+ self._pipelines._pipeline_signature = desired_sig
1163
+ num_inference_steps = 11
1164
+ else:
1165
+ # Fast:无 LoRA 时走官方 load_gpu_pipeline;有 LoRA 时自建 pipeline。
1166
+ loras = None
1167
+ lora_str = (lora_path or "").strip() if isinstance(lora_path, str) else ""
1168
+ if lora_str:
1169
+ try:
1170
+ from ltx_core.loader import LoraPathStrengthAndSDOps
1171
+ from ltx_core.loader.sd_ops import LTXV_LORA_COMFY_RENAMING_MAP
1172
+
1173
+ if os.path.exists(lora_str):
1174
+ loras = [
1175
+ LoraPathStrengthAndSDOps(
1176
+ path=lora_str,
1177
+ strength=float(lora_strength),
1178
+ sd_ops=LTXV_LORA_COMFY_RENAMING_MAP,
1179
+ )
1180
+ ]
1181
+ print(
1182
+ f"[PATCH] LoRA 已就绪: {lora_str}, strength={lora_strength}"
1183
+ )
1184
+ else:
1185
+ print(f"[PATCH] LoRA 文件不存在,将使用无 LoRA Fast: {lora_str}")
1186
+ except Exception as _lora_err:
1187
+ print(f"[PATCH] LoRA 准备失败,回退无 LoRA: {_lora_err}")
1188
+ loras = None
1189
+
1190
+ if loras is not None:
1191
+ lora_key = lora_str
1192
+ lora_st = round(float(lora_strength), 4)
1193
+ else:
1194
+ lora_key = ""
1195
+ lora_st = 0.0
1196
+ desired_sig = ("fast", lora_key, lora_st)
1197
+
1198
+ if loras is not None:
1199
+ print("[PATCH] 构建带 LoRA 的 Fast pipeline(unload 后重建)")
1200
+ # 首次 LoRA 构建时可能触发额外的显存峰值(编译/缓存/权重搬运)。
1201
+ # 通过一次无 LoRA 的 fast pipeline warmup 来降低后续 LoRA 构建的峰值风险。
1202
+ if not getattr(self, "_ltx_lora_warmup_done", False):
1203
+ try:
1204
+ print("[PATCH] LoRA warmup: 先加载无 LoRA fast pipeline 触发缓存")
1205
+ # should_warm=True:尽量触发内核/权重缓存(若实现不同则静默失败也可回退)
1206
+ self._pipelines.load_gpu_pipeline("fast", should_warm=True)
1207
+ from keep_models_runtime import force_unload_gpu_pipeline
1208
+ force_unload_gpu_pipeline(self._pipelines)
1209
+ import gc
1210
+ gc.collect()
1211
+ try:
1212
+ import torch
1213
+ if torch.cuda.is_available():
1214
+ torch.cuda.empty_cache()
1215
+ torch.cuda.ipc_collect()
1216
+ except Exception:
1217
+ pass
1218
+ self._ltx_lora_warmup_done = True
1219
+ except Exception as _warm_err:
1220
+ print(f"[PATCH] LoRA warmup failed (ignore): {_warm_err}")
1221
+ from keep_models_runtime import force_unload_gpu_pipeline
1222
+
1223
+ force_unload_gpu_pipeline(self._pipelines)
1224
+ import gc
1225
+
1226
+ gc.collect()
1227
+ # 防止旧分配/碎片在首次 LoRA 构建时叠加导致 OOM
1228
+ try:
1229
+ import torch
1230
+ if torch.cuda.is_available():
1231
+ torch.cuda.empty_cache()
1232
+ torch.cuda.ipc_collect()
1233
+ except Exception:
1234
+ pass
1235
+ gemma_root = self._pipelines._text_handler.resolve_gemma_root()
1236
+ from runtime_config.model_download_specs import resolve_model_path
1237
+ from services.fast_video_pipeline.ltx_fast_video_pipeline import (
1238
+ LTXFastVideoPipeline,
1239
+ )
1240
+ from state.app_state_types import (
1241
+ GpuSlot,
1242
+ VideoPipelineState,
1243
+ VideoPipelineWarmth,
1244
+ )
1245
+
1246
+ checkpoint_path = str(
1247
+ resolve_model_path(
1248
+ self._pipelines.models_dir,
1249
+ self._pipelines.config.model_download_specs,
1250
+ "checkpoint",
1251
+ )
1252
+ )
1253
+ upsampler_path = str(
1254
+ resolve_model_path(
1255
+ self._pipelines.models_dir,
1256
+ self._pipelines.config.model_download_specs,
1257
+ "upsampler",
1258
+ )
1259
+ )
1260
+ from lora_injection import (
1261
+ _lora_init_kwargs,
1262
+ inject_loras_into_fast_pipeline,
1263
+ )
1264
+
1265
+ lora_kw = _lora_init_kwargs(LTXFastVideoPipeline, loras)
1266
+ ltx_pipe = LTXFastVideoPipeline(
1267
+ checkpoint_path,
1268
+ gemma_root,
1269
+ upsampler_path,
1270
+ self._pipelines.config.device,
1271
+ **lora_kw,
1272
+ )
1273
+ n_inj = inject_loras_into_fast_pipeline(ltx_pipe, loras)
1274
+ if hasattr(ltx_pipe, "pipeline") and hasattr(
1275
+ ltx_pipe.pipeline, "model_ledger"
1276
+ ):
1277
+ try:
1278
+ ltx_pipe.pipeline.model_ledger.loras = tuple(loras)
1279
+ except Exception:
1280
+ pass
1281
+ pipeline_state = VideoPipelineState(
1282
+ pipeline=ltx_pipe,
1283
+ warmth=VideoPipelineWarmth.COLD,
1284
+ is_compiled=False,
1285
+ )
1286
+ self._pipelines.state.gpu_slot = GpuSlot(
1287
+ active_pipeline=pipeline_state, generation=None
1288
+ )
1289
+ _ml = getattr(getattr(ltx_pipe, "pipeline", None), "model_ledger", None)
1290
+ _ml_loras = getattr(_ml, "loras", None) if _ml else None
1291
+ print(
1292
+ f"[PATCH] LoRA: __init__ 额外参数={list(lora_kw.keys())}, "
1293
+ f"深度注入点数={n_inj}, model_ledger.loras={_ml_loras}"
1294
+ )
1295
+ if getattr(self._pipelines, "low_vram_mode", False):
1296
+ from low_vram_runtime import try_sequential_offload_on_pipeline_state
1297
+
1298
+ try_sequential_offload_on_pipeline_state(pipeline_state)
1299
+ else:
1300
+ print(f"[PATCH] 加载 Fast pipeline(无 LoRA)")
1301
+ pipeline_state = self._pipelines.load_gpu_pipeline(
1302
+ "fast", should_warm=False
1303
+ )
1304
+ self._pipelines._pipeline_signature = desired_sig
1305
+ num_inference_steps = None
1306
+ extra_loras_for_hook = tuple(loras) if loras else None
1307
+
1308
+ # 在 DiT 权重 build 时融合用户 LoRA(model_ledger 单独赋值往往不够)
1309
+ from lora_build_hook import (
1310
+ install_lora_build_hook,
1311
+ pending_loras_token,
1312
+ reset_pending_loras,
1313
+ )
1314
+
1315
+ install_lora_build_hook()
1316
+ _lora_hook_tok = pending_loras_token(extra_loras_for_hook)
1317
+ try:
1318
+ # 启动 generation 状态(在 pipeline 加载之后)
1319
+ self._generation.start_generation(generation_id)
1320
+
1321
+ # 处理 negative_prompt
1322
+ neg_prompt = (
1323
+ negative_prompt if negative_prompt else self.config.default_negative_prompt
1324
+ )
1325
+ enhanced_prompt = prompt + self.config.camera_motion_prompts.get(
1326
+ camera_motion, ""
1327
+ )
1328
+
1329
+ # 强制使用动态目录,忽略底层原始逻辑
1330
+ dyn_dir = get_dynamic_output_path()
1331
+ output_path = dyn_dir / f"generation_{uuid.uuid4().hex[:8]}.mp4"
1332
+
1333
+ try:
1334
+ self._text.prepare_text_encoding(enhanced_prompt, enhance_prompt=False)
1335
+ # 调整为 64 的倍数(与 LTX 内核 divisible-by-64 校验一致)
1336
+ height = max(64, round(height / 64) * 64)
1337
+ width = max(64, round(width / 64) * 64)
1338
+
1339
+ if audio_path:
1340
+ # A2V pipeline 参数
1341
+ gen_kwargs = {
1342
+ "prompt": enhanced_prompt,
1343
+ "negative_prompt": neg_prompt,
1344
+ "seed": seed,
1345
+ "height": height,
1346
+ "width": width,
1347
+ "num_frames": num_frames,
1348
+ "frame_rate": fps,
1349
+ "num_inference_steps": num_inference_steps,
1350
+ "images": images_inputs,
1351
+ "audio_path": audio_path,
1352
+ "audio_start_time": 0.0,
1353
+ "audio_max_duration": None,
1354
+ "output_path": str(output_path),
1355
+ }
1356
+ else:
1357
+ # Fast pipeline 参数
1358
+ gen_kwargs = {
1359
+ "prompt": enhanced_prompt,
1360
+ "seed": seed,
1361
+ "height": height,
1362
+ "width": width,
1363
+ "num_frames": num_frames,
1364
+ "frame_rate": fps,
1365
+ "images": images_inputs,
1366
+ "output_path": str(output_path),
1367
+ }
1368
+
1369
+ pipeline_state.pipeline.generate(**gen_kwargs)
1370
+
1371
+ # 标记完成
1372
+ self._generation.complete_generation(str(output_path))
1373
+ return str(output_path)
1374
+ finally:
1375
+ self._text.clear_api_embeddings()
1376
+ for p in temp_paths:
1377
+ if os.path.exists(p):
1378
+ os.unlink(p)
1379
+ self._start_frame_path = None
1380
+ self._end_frame_path = None
1381
+ from low_vram_runtime import maybe_release_pipeline_after_task
1382
+
1383
+ try:
1384
+ maybe_release_pipeline_after_task(self)
1385
+ except Exception:
1386
+ pass
1387
+ finally:
1388
+ reset_pending_loras(_lora_hook_tok)
1389
+
1390
+ VideoGenerationHandler.generate = patched_generate
1391
+ VideoGenerationHandler.generate_video = patched_generate_video
1392
+
1393
+ # 2. 增强视频超分功能 (Monkey Patch LTXRetakePipeline)
1394
+ _orig_ltx_retake_run = LTXRetakePipeline._run
1395
+
1396
+ def patched_ltx_retake_run(
1397
+ self, video_path, prompt, start_time, end_time, seed, **kwargs
1398
+ ):
1399
+ # 拦截并修改目标宽高
1400
+ target_w = getattr(self, "_target_width", None)
1401
+ target_h = getattr(self, "_target_height", None)
1402
+ target_strength = getattr(self, "_target_strength", 0.7)
1403
+ is_upscale = target_w is not None and target_h is not None
1404
+
1405
+ import ltx_pipelines.utils.media_io as media_io
1406
+ import services.retake_pipeline.ltx_retake_pipeline as lrp
1407
+ import ltx_pipelines.utils.samplers as samplers
1408
+ import ltx_pipelines.utils.helpers as helpers
1409
+
1410
+ _orig_get_meta = media_io.get_videostream_metadata
1411
+ _orig_lrp_get_meta = getattr(lrp, "get_videostream_metadata", _orig_get_meta)
1412
+ _orig_euler_loop = samplers.euler_denoising_loop
1413
+ _orig_noise_video = helpers.noise_video_state
1414
+
1415
+ fps, num_frames, src_w, src_h = _orig_get_meta(video_path)
1416
+
1417
+ if is_upscale:
1418
+ print(
1419
+ f">>> 启动超分内核: {src_w}x{src_h} -> {target_w}x{target_h} (强度: {target_strength})"
1420
+ )
1421
+
1422
+ # 1. 注入分辨率
1423
+ def get_meta_patched(path):
1424
+ return fps, num_frames, target_w, target_h
1425
+
1426
+ media_io.get_videostream_metadata = get_meta_patched
1427
+ lrp.get_videostream_metadata = get_meta_patched
1428
+
1429
+ # 2. 注入起始噪声 (SDEdit 核心:加噪到指定强度)
1430
+ def noise_video_patched(*args, **kwargs_inner):
1431
+ kwargs_inner["noise_scale"] = target_strength
1432
+ return _orig_noise_video(*args, **kwargs_inner)
1433
+
1434
+ helpers.noise_video_state = noise_video_patched
1435
+
1436
+ # 3. 注入采样起点 (从对应噪声位开始去噪)
1437
+ def patched_euler_loop(
1438
+ sigmas, video_state, audio_state, stepper, denoise_fn
1439
+ ):
1440
+ full_len = len(sigmas)
1441
+ skip_idx = 0
1442
+ for i, s in enumerate(sigmas):
1443
+ if s <= target_strength:
1444
+ skip_idx = i
1445
+ break
1446
+ skip_idx = min(skip_idx, full_len - 2)
1447
+ new_sigmas = sigmas[skip_idx:]
1448
+ print(
1449
+ f">>> 采样拦截成功: 原步数 {full_len}, 现步数 {len(new_sigmas)}, 起始强度 {new_sigmas[0].item():.2f}"
1450
+ )
1451
+ return _orig_euler_loop(
1452
+ new_sigmas, video_state, audio_state, stepper, denoise_fn
1453
+ )
1454
+
1455
+ samplers.euler_denoising_loop = patched_euler_loop
1456
+
1457
+ kwargs["regenerate_video"] = False
1458
+ kwargs["regenerate_audio"] = False
1459
+
1460
+ try:
1461
+ return _orig_ltx_retake_run(
1462
+ self, video_path, prompt, start_time, end_time, seed, **kwargs
1463
+ )
1464
+ finally:
1465
+ media_io.get_videostream_metadata = _orig_get_meta
1466
+ lrp.get_videostream_metadata = _orig_lrp_get_meta
1467
+ samplers.euler_denoising_loop = _orig_euler_loop
1468
+ helpers.noise_video_state = _orig_noise_video
1469
+
1470
+ return _orig_ltx_retake_run(
1471
+ self, video_path, prompt, start_time, end_time, seed, **kwargs
1472
+ )
1473
+
1474
+ return _orig_ltx_retake_run(
1475
+ self, video_path, prompt, start_time, end_time, seed, **kwargs
1476
+ )
1477
+
1478
+ LTXRetakePipeline._run = patched_ltx_retake_run
1479
+
1480
+ # --- 最终视频超分接口实现 ---
1481
+ @app.post("/api/system/upscale-video")
1482
+ async def route_upscale_video(request: Request):
1483
+ try:
1484
+ import uuid
1485
+ import os
1486
+ from datetime import datetime
1487
+ from ltx_pipelines.utils.media_io import get_videostream_metadata
1488
+ from ltx_core.types import SpatioTemporalScaleFactors
1489
+
1490
+ data = await request.json()
1491
+ video_path = data.get("video_path")
1492
+ target_res = data.get("resolution", "1080p")
1493
+ prompt = data.get("prompt", "high quality, detailed, 4k")
1494
+ strength = data.get("strength", 0.7) # 获取前端传来的重绘幅度
1495
+
1496
+ if not video_path or not os.path.exists(video_path):
1497
+ return JSONResponse(
1498
+ status_code=400, content={"error": "Invalid video path"}
1499
+ )
1500
+
1501
+ # 计算目标宽高 (必须是 32 的倍数)
1502
+ res_map = {"1080p": (1920, 1088), "720p": (1280, 704), "544p": (960, 544)}
1503
+ target_w, target_h = res_map.get(target_res, (1920, 1088))
1504
+
1505
+ fps, num_frames, _, _ = get_videostream_metadata(video_path)
1506
+
1507
+ # 校验帧数 8k+1,如果不符则自动调整
1508
+ scale = SpatioTemporalScaleFactors.default()
1509
+ if (num_frames - 1) % scale.time != 0:
1510
+ # 计算需要调整到的最近的有效帧数 (8k+1)
1511
+ # 找到最接近的8k+1帧数
1512
+ target_k = (num_frames - 1) // scale.time
1513
+ # 选择最接近的k值:向下或向上取整
1514
+ current_k = (num_frames - 1) // scale.time
1515
+ current_remainder = (num_frames - 1) % scale.time
1516
+
1517
+ # 比较向上和向下取整哪个更接近
1518
+ down_k = current_k
1519
+ up_k = current_k + 1
1520
+
1521
+ # 向下取整的帧数
1522
+ down_frames = down_k * scale.time + 1
1523
+ # 向上取整的帧数
1524
+ up_frames = up_k * scale.time + 1
1525
+
1526
+ # 选择差异最小的
1527
+ if abs(num_frames - down_frames) <= abs(num_frames - up_frames):
1528
+ adjusted_frames = down_frames
1529
+ else:
1530
+ adjusted_frames = up_frames
1531
+
1532
+ print(
1533
+ f">>> 帧数调整: {num_frames} -> {adjusted_frames} (符合 8k+1 规则)"
1534
+ )
1535
+
1536
+ # 调整视频帧数 - 截断多余的帧或填充黑帧
1537
+ adjusted_video_path = None
1538
+ try:
1539
+ import cv2
1540
+ import numpy as np
1541
+ import tempfile
1542
+
1543
+ # 使用cv2读取视频
1544
+ cap = cv2.VideoCapture(video_path)
1545
+ if not cap.isOpened():
1546
+ raise Exception("无法打开视频文件")
1547
+
1548
+ frames = []
1549
+ while True:
1550
+ ret, frame = cap.read()
1551
+ if not ret:
1552
+ break
1553
+ frames.append(frame)
1554
+ cap.release()
1555
+
1556
+ original_frame_count = len(frames)
1557
+
1558
+ if adjusted_frames < original_frame_count:
1559
+ # 截断多余的帧
1560
+ frames = frames[:adjusted_frames]
1561
+ print(
1562
+ f">>> 已截断视频: {original_frame_count} -> {len(frames)} 帧"
1563
+ )
1564
+ else:
1565
+ # 填充黑帧 (复制最后一帧)
1566
+ last_frame = frames[-1] if frames else None
1567
+ if last_frame is not None:
1568
+ h, w = last_frame.shape[:2]
1569
+ black_frame = np.zeros((h, w, 3), dtype=np.uint8)
1570
+ while len(frames) < adjusted_frames:
1571
+ frames.append(black_frame.copy())
1572
+ print(
1573
+ f">>> 已填充视频: {original_frame_count} -> {len(frames)} 帧"
1574
+ )
1575
+
1576
+ # 保存调整后的视频到临时文件
1577
+ adjusted_video_fd = tempfile.NamedTemporaryFile(
1578
+ suffix=".mp4", delete=False
1579
+ )
1580
+ adjusted_video_path = adjusted_video_fd.name
1581
+ adjusted_video_fd.close()
1582
+
1583
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v")
1584
+ out = cv2.VideoWriter(
1585
+ adjusted_video_path,
1586
+ fourcc,
1587
+ fps,
1588
+ (frames[0].shape[1], frames[0].shape[0]),
1589
+ )
1590
+ for frame in frames:
1591
+ out.write(frame)
1592
+ out.release()
1593
+
1594
+ video_path = adjusted_video_path
1595
+ num_frames = adjusted_frames
1596
+ print(
1597
+ f">>> 视频帧数调整完成: {original_frame_count} -> {num_frames}"
1598
+ )
1599
+
1600
+ except ImportError:
1601
+ # cv2不可用,尝试使用LTX内置方法
1602
+ try:
1603
+ from ltx_pipelines.utils.media_io import (
1604
+ read_video_stream,
1605
+ write_video_stream,
1606
+ )
1607
+ import numpy as np
1608
+
1609
+ frames, audio_data = read_video_stream(video_path, fps)
1610
+ original_frame_count = len(frames)
1611
+
1612
+ if adjusted_frames < original_frame_count:
1613
+ frames = frames[:adjusted_frames]
1614
+ else:
1615
+ while len(frames) < adjusted_frames:
1616
+ frames = np.concatenate([frames, frames[-1:]], axis=0)
1617
+
1618
+ import tempfile
1619
+
1620
+ adjusted_video_fd = tempfile.NamedTemporaryFile(
1621
+ suffix=".mp4", delete=False
1622
+ )
1623
+ adjusted_video_path = adjusted_video_fd.name
1624
+ adjusted_video_fd.close()
1625
+
1626
+ write_video_stream(adjusted_video_path, frames, fps)
1627
+ video_path = adjusted_video_path
1628
+ num_frames = adjusted_frames
1629
+ print(
1630
+ f">>> 视频帧数调整完成: {original_frame_count} -> {num_frames}"
1631
+ )
1632
+
1633
+ except Exception as e2:
1634
+ print(f">>> 视频帧数自动调整失败: {e2}")
1635
+ return JSONResponse(
1636
+ status_code=400,
1637
+ content={
1638
+ "error": f"视频帧数({num_frames})不符合 8k+1 规则,且自动调整失败。请手动将视频帧数调整为 8k+1 格式(如 9, 17, 25, 33, 41, 49, 57, 65, 73, 81, 89, 97, 105 等)。"
1639
+ },
1640
+ )
1641
+ except Exception as e:
1642
+ print(f">>> 视频帧数自动调整失败: {e}")
1643
+ return JSONResponse(
1644
+ status_code=400,
1645
+ content={
1646
+ "error": f"视频帧数({num_frames})不符合 8k+1 规则,且自动调整失败。请手动将视频帧数调整为 8k+1 格式(如 9, 17, 25, 33, 41, 49, 57, 65, 73, 81, 89, 97, 105 等)。"
1647
+ },
1648
+ )
1649
+
1650
+ # 1. 加载模型
1651
+ pipeline_state = handler.pipelines.load_retake_pipeline(distilled=True)
1652
+
1653
+ # 3. 启动任务
1654
+ generation_id = uuid.uuid4().hex[:8]
1655
+ handler.generation.start_generation(generation_id)
1656
+
1657
+ # 核心修正:确保文件保存在动态的输出目录
1658
+ save_dir = get_dynamic_output_path()
1659
+ filename = f"upscale_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{generation_id}.mp4"
1660
+ full_output_path = save_dir / filename
1661
+
1662
+ # 3. 执行真正的超分逻辑
1663
+ try:
1664
+ # 注入目标分辨率和重绘幅度
1665
+ pipeline_state.pipeline._target_width = target_w
1666
+ pipeline_state.pipeline._target_height = target_h
1667
+ pipeline_state.pipeline._target_strength = strength
1668
+
1669
+ def do_generate():
1670
+ pipeline_state.pipeline.generate(
1671
+ video_path=str(video_path),
1672
+ prompt=prompt,
1673
+ start_time=0.0,
1674
+ end_time=float(num_frames / fps),
1675
+ seed=int(time.time()) % 2147483647,
1676
+ output_path=str(full_output_path),
1677
+ distilled=True,
1678
+ regenerate_video=True,
1679
+ regenerate_audio=False,
1680
+ )
1681
+
1682
+ # 重要修复:放到线程池运行,避免阻塞主循环导致前端拿不到显存数据
1683
+ from starlette.concurrency import run_in_threadpool
1684
+
1685
+ await run_in_threadpool(do_generate)
1686
+
1687
+ handler.generation.complete_generation(str(full_output_path))
1688
+ return {"status": "complete", "video_path": filename}
1689
+ except Exception as e:
1690
+ # OOM 异常逃逸修复:强制返回友好的异常信息
1691
+ try:
1692
+ handler.generation.cancel_generation()
1693
+ except Exception:
1694
+ pass
1695
+ if hasattr(handler.generation, "_generation_id"):
1696
+ handler.generation._generation_id = None
1697
+ if hasattr(handler.generation, "_is_generating"):
1698
+ handler.generation._is_generating = False
1699
+
1700
+ error_msg = str(e)
1701
+ if "CUDA out of memory" in error_msg:
1702
+ error_msg = "🚨 显存不足 (OOM):视频时长过长或目标分辨率超出了当前显卡的承载极限,请降低目标分辨率重试!"
1703
+ raise RuntimeError(error_msg) from e
1704
+ finally:
1705
+ if hasattr(pipeline_state.pipeline, "_target_width"):
1706
+ del pipeline_state.pipeline._target_width
1707
+ if hasattr(pipeline_state.pipeline, "_target_height"):
1708
+ del pipeline_state.pipeline._target_height
1709
+ if hasattr(pipeline_state.pipeline, "_target_strength"):
1710
+ del pipeline_state.pipeline._target_strength
1711
+ import gc
1712
+
1713
+ gc.collect()
1714
+ if (
1715
+ getattr(torch, "cuda", None) is not None
1716
+ and torch.cuda.is_available()
1717
+ ):
1718
+ torch.cuda.empty_cache()
1719
+ from low_vram_runtime import maybe_release_pipeline_after_task
1720
+
1721
+ try:
1722
+ maybe_release_pipeline_after_task(handler)
1723
+ except Exception:
1724
+ pass
1725
+
1726
+ except Exception as e:
1727
+ import traceback
1728
+
1729
+ traceback.print_exc()
1730
+ return JSONResponse(status_code=500, content={"error": str(e)})
1731
+
1732
+ # ------------------
1733
+
1734
+ @app.post("/api/system/upload-image")
1735
+ async def route_upload_image(request: Request):
1736
+ try:
1737
+ import uuid
1738
+ import base64
1739
+
1740
+ # 接收 JSON 而不是 Multipart,绕过 python-multipart 缺失问题
1741
+ data = await request.json()
1742
+ b64_data = data.get("image")
1743
+ filename = data.get("filename", "image.png")
1744
+
1745
+ if not b64_data:
1746
+ return JSONResponse(
1747
+ status_code=400, content={"error": "No image data provided"}
1748
+ )
1749
+
1750
+ # 处理 base64 头部 (例如 data:image/png;base64,...)
1751
+ if "," in b64_data:
1752
+ b64_data = b64_data.split(",")[1]
1753
+
1754
+ image_bytes = base64.b64decode(b64_data)
1755
+
1756
+ # 确保上传目录存在
1757
+ upload_dir = get_dynamic_output_path() / "uploads"
1758
+ upload_dir.mkdir(parents=True, exist_ok=True)
1759
+
1760
+ safe_filename = "".join([c for c in filename if c.isalnum() or c in "._-"])
1761
+ file_path = upload_dir / f"up_{uuid.uuid4().hex[:6]}_{safe_filename}"
1762
+
1763
+ with file_path.open("wb") as buffer:
1764
+ buffer.write(image_bytes)
1765
+
1766
+ return {"status": "success", "path": str(file_path)}
1767
+ except Exception as e:
1768
+ import traceback
1769
+
1770
+ error_msg = f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}"
1771
+ print(f"Upload error: {error_msg}")
1772
+ return JSONResponse(
1773
+ status_code=500, content={"error": str(e), "detail": error_msg}
1774
+ )
1775
+
1776
+ # ------------------
1777
+ # 批量首尾帧:与「视频生成」相同的首尾帧推理,按顺序生成 N-1 段后可选 ffmpeg 拼接
1778
+ # ------------------
1779
+
1780
+ def _find_ffmpeg_binary() -> str | None:
1781
+ """尽量找到 ffmpeg:环境变量 → imageio-ffmpeg 自带 → PATH → 常见安装位置 → WinGet。"""
1782
+ import shutil
1783
+ import sys
1784
+
1785
+ def _ok(p: str | None) -> str | None:
1786
+ if not p:
1787
+ return None
1788
+ p = os.path.normpath(os.path.expandvars(str(p).strip().strip('"')))
1789
+ return p if os.path.isfile(p) else None
1790
+
1791
+ for env_key in ("LTX_FFMPEG_PATH", "FFMPEG_PATH"):
1792
+ hit = _ok(os.environ.get(env_key))
1793
+ if hit:
1794
+ print(f"[batch-merge] ffmpeg from {env_key}: {hit}")
1795
+ return hit
1796
+
1797
+ try:
1798
+ pref = _ltx_desktop_config_dir() / "ffmpeg_path.txt"
1799
+ if pref.is_file():
1800
+ line = pref.read_text(encoding="utf-8").splitlines()[0].strip()
1801
+ hit = _ok(line)
1802
+ if hit:
1803
+ print(f"[batch-merge] ffmpeg from ffmpeg_path.txt: {hit}")
1804
+ return hit
1805
+ except Exception as _e:
1806
+ print(f"[batch-merge] ffmpeg_path.txt: {_e!r}")
1807
+
1808
+ # imageio-ffmpeg:多数视频/ML 环境会带上独立 ffmpeg 可执行文件
1809
+ try:
1810
+ import imageio_ffmpeg
1811
+
1812
+ hit = _ok(imageio_ffmpeg.get_ffmpeg_exe())
1813
+ if hit:
1814
+ print(f"[batch-merge] ffmpeg from imageio_ffmpeg: {hit}")
1815
+ return hit
1816
+ except Exception as _e:
1817
+ print(f"[batch-merge] imageio_ffmpeg: {_e!r}")
1818
+
1819
+ for name in ("ffmpeg", "ffmpeg.exe"):
1820
+ hit = _ok(shutil.which(name))
1821
+ if hit:
1822
+ print(f"[batch-merge] ffmpeg from PATH which({name}): {hit}")
1823
+ return hit
1824
+
1825
+ # 显式遍历 PATH 中的目录(某些环境下 which 不可靠)
1826
+ path_env = os.environ.get("PATH", "") or os.environ.get("Path", "")
1827
+ for folder in path_env.split(os.pathsep):
1828
+ folder = folder.strip().strip('"')
1829
+ if not folder:
1830
+ continue
1831
+ for exe in ("ffmpeg.exe", "ffmpeg"):
1832
+ hit = _ok(os.path.join(folder, exe))
1833
+ if hit:
1834
+ print(f"[batch-merge] ffmpeg from PATH scan: {hit}")
1835
+ return hit
1836
+
1837
+ localappdata = os.environ.get("LOCALAPPDATA", "") or ""
1838
+ programfiles = os.environ.get("ProgramFiles", r"C:\Program Files")
1839
+ programfiles_x86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
1840
+ userprofile = os.environ.get("USERPROFILE", "") or ""
1841
+
1842
+ static_candidates: list[str] = [
1843
+ os.path.join(os.path.dirname(sys.executable), "ffmpeg.exe"),
1844
+ os.path.join(os.path.dirname(sys.executable), "ffmpeg"),
1845
+ os.path.join(localappdata, "LTXDesktop", "ffmpeg.exe"),
1846
+ os.path.join(programfiles, "LTX Desktop", "ffmpeg.exe"),
1847
+ os.path.join(programfiles, "ffmpeg", "bin", "ffmpeg.exe"),
1848
+ os.path.join(programfiles_x86, "ffmpeg", "bin", "ffmpeg.exe"),
1849
+ r"C:\ffmpeg\bin\ffmpeg.exe",
1850
+ os.path.join(userprofile, "scoop", "shims", "ffmpeg.exe"),
1851
+ os.path.join(userprofile, "scoop", "apps", "ffmpeg", "current", "bin", "ffmpeg.exe"),
1852
+ r"C:\ProgramData\chocolatey\bin\ffmpeg.exe",
1853
+ ]
1854
+ for c in static_candidates:
1855
+ hit = _ok(c)
1856
+ if hit:
1857
+ print(f"[batch-merge] ffmpeg static candidate: {hit}")
1858
+ return hit
1859
+
1860
+ # WinGet 安装的 Gyan / BtbN 等包:在 Packages 下搜索 ffmpeg.exe(限制深度避免过慢)
1861
+ try:
1862
+ wg = os.path.join(localappdata, "Microsoft", "WinGet", "Packages")
1863
+ if os.path.isdir(wg):
1864
+ for root, _dirs, files in os.walk(wg):
1865
+ if "ffmpeg.exe" in files:
1866
+ hit = _ok(os.path.join(root, "ffmpeg.exe"))
1867
+ if hit:
1868
+ print(f"[batch-merge] ffmpeg from WinGet tree: {hit}")
1869
+ return hit
1870
+ # 略过过深目录
1871
+ depth = root[len(wg) :].count(os.sep)
1872
+ if depth > 6:
1873
+ _dirs[:] = []
1874
+ except Exception as _e:
1875
+ print(f"[batch-merge] WinGet scan: {_e!r}")
1876
+
1877
+ print("[batch-merge] ffmpeg not found after extended search")
1878
+ return None
1879
+
1880
+ def _ffmpeg_concat_copy(
1881
+ segment_paths: list[str], output_mp4: str, ffmpeg_bin: str
1882
+ ) -> None:
1883
+ import subprocess
1884
+
1885
+ out_abs = os.path.abspath(output_mp4)
1886
+ dyn_abs = os.path.abspath(str(get_dynamic_output_path()))
1887
+ lines: list[str] = []
1888
+ for p in segment_paths:
1889
+ ap = os.path.abspath(p)
1890
+ rel = os.path.relpath(ap, start=dyn_abs)
1891
+ rel = rel.replace("\\", "/")
1892
+ if "'" in rel:
1893
+ rel = rel.replace("'", "'\\''")
1894
+ lines.append(f"file '{rel}'")
1895
+ list_body = "\n".join(lines) + "\n"
1896
+ list_path = os.path.join(dyn_abs, f"_batch_concat_{os.getpid()}_{time.time_ns()}.txt")
1897
+ try:
1898
+ Path(list_path).write_text(list_body, encoding="utf-8")
1899
+ cmd = [
1900
+ ffmpeg_bin,
1901
+ "-y",
1902
+ "-f",
1903
+ "concat",
1904
+ "-safe",
1905
+ "0",
1906
+ "-i",
1907
+ list_path,
1908
+ "-c",
1909
+ "copy",
1910
+ out_abs,
1911
+ ]
1912
+ proc = subprocess.run(
1913
+ cmd,
1914
+ capture_output=True,
1915
+ text=True,
1916
+ encoding="utf-8",
1917
+ errors="replace",
1918
+ )
1919
+ if proc.returncode != 0:
1920
+ err = (proc.stderr or proc.stdout or "").strip()
1921
+ raise RuntimeError(
1922
+ f"ffmpeg 拼接失败 (code {proc.returncode}): {err[:800]}"
1923
+ )
1924
+ finally:
1925
+ try:
1926
+ if os.path.isfile(list_path):
1927
+ os.unlink(list_path)
1928
+ except OSError:
1929
+ pass
1930
+
1931
+ def _ffmpeg_mux_background_audio(
1932
+ ffmpeg_bin: str, video_in: str, audio_in: str, video_out: str
1933
+ ) -> None:
1934
+ """成片只保留原视频画面,音轨替换为一条外部音频(与多段各自 AI 音频相比更统一)。"""
1935
+ import subprocess
1936
+
1937
+ out_abs = os.path.abspath(video_out)
1938
+ proc = subprocess.run(
1939
+ [
1940
+ ffmpeg_bin,
1941
+ "-y",
1942
+ "-i",
1943
+ os.path.abspath(video_in),
1944
+ "-i",
1945
+ os.path.abspath(audio_in),
1946
+ "-map",
1947
+ "0:v:0",
1948
+ "-map",
1949
+ "1:a:0",
1950
+ "-c:v",
1951
+ "copy",
1952
+ "-c:a",
1953
+ "aac",
1954
+ "-b:a",
1955
+ "192k",
1956
+ "-shortest",
1957
+ out_abs,
1958
+ ],
1959
+ capture_output=True,
1960
+ text=True,
1961
+ encoding="utf-8",
1962
+ errors="replace",
1963
+ )
1964
+ if proc.returncode != 0:
1965
+ err = (proc.stderr or proc.stdout or "").strip()
1966
+ raise RuntimeError(
1967
+ f"配乐混流失败 (code {proc.returncode}): {err[:800]}"
1968
+ )
1969
+
1970
+ @app.post("/api/generate-batch")
1971
+ async def route_generate_batch(request: Request):
1972
+ """多关键帧:相邻两帧一段首尾帧视频,与 POST /api/generate 同源逻辑;多段用 ffmpeg concat。"""
1973
+ from starlette.concurrency import run_in_threadpool
1974
+
1975
+ from server_utils.media_validation import normalize_optional_path
1976
+
1977
+ try:
1978
+ data = await request.json()
1979
+ segments_in = data.get("segments") or []
1980
+ if not segments_in:
1981
+ return JSONResponse(
1982
+ status_code=400,
1983
+ content={"error": "segments 不能为空"},
1984
+ )
1985
+
1986
+ resolution = data.get("resolution") or "720p"
1987
+ aspect_ratio = data.get("aspectRatio") or "16:9"
1988
+ neg = data.get(
1989
+ "negativePrompt",
1990
+ "low quality, blurry, noisy, static noise, distorted",
1991
+ )
1992
+ model = data.get("model") or "ltx-2"
1993
+ fps = str(data.get("fps") or "24")
1994
+ audio = str(data.get("audio") or "false").lower()
1995
+ camera_motion = data.get("cameraMotion") or "static"
1996
+ model_path = data.get("modelPath")
1997
+ lora_path = data.get("loraPath")
1998
+ lora_strength = float(data.get("loraStrength") or 1.0)
1999
+
2000
+ vg = getattr(handler, "video_generation", None)
2001
+ if vg is None or not callable(getattr(vg, "generate", None)):
2002
+ return JSONResponse(
2003
+ status_code=500,
2004
+ content={"error": "内部错误:找不到 video_generation 处理器"},
2005
+ )
2006
+
2007
+ clip_paths: list[str] = []
2008
+ for idx, seg in enumerate(segments_in):
2009
+ start_raw = seg.get("startImage") or seg.get("startFramePath")
2010
+ end_raw = seg.get("endImage") or seg.get("endFramePath")
2011
+ start_p = normalize_optional_path(start_raw)
2012
+ end_p = normalize_optional_path(end_raw)
2013
+ if not start_p or not os.path.isfile(start_p):
2014
+ return JSONResponse(
2015
+ status_code=400,
2016
+ content={"error": f"片段 {idx + 1} 起始图路径无效"},
2017
+ )
2018
+ if not end_p or not os.path.isfile(end_p):
2019
+ return JSONResponse(
2020
+ status_code=400,
2021
+ content={"error": f"片段 {idx + 1} 结束图路径无效"},
2022
+ )
2023
+
2024
+ dur = seg.get("duration", 5)
2025
+ try:
2026
+ dur_i = int(float(dur))
2027
+ except (TypeError, ValueError):
2028
+ dur_i = 5
2029
+ dur_i = max(1, min(60, dur_i))
2030
+
2031
+ prompt_text = (seg.get("prompt") or "").strip()
2032
+ if not prompt_text:
2033
+ prompt_text = "cinematic transition"
2034
+
2035
+ req = GenerateVideoRequest(
2036
+ prompt=prompt_text,
2037
+ resolution=resolution,
2038
+ model=model,
2039
+ cameraMotion=camera_motion,
2040
+ negativePrompt=neg,
2041
+ duration=str(dur_i),
2042
+ fps=fps,
2043
+ audio=audio,
2044
+ imagePath=None,
2045
+ audioPath=None,
2046
+ startFramePath=start_p,
2047
+ endFramePath=end_p,
2048
+ aspectRatio=aspect_ratio,
2049
+ modelPath=model_path,
2050
+ loraPath=lora_path,
2051
+ loraStrength=lora_strength,
2052
+ )
2053
+
2054
+ def _one_gen(r: GenerateVideoRequest = req):
2055
+ return vg.generate(r)
2056
+
2057
+ resp = await run_in_threadpool(_one_gen)
2058
+ if resp.status != "complete" or not resp.video_path:
2059
+ return JSONResponse(
2060
+ status_code=500,
2061
+ content={
2062
+ "error": f"片段 {idx + 1} 生成失败: status={getattr(resp, 'status', None)}"
2063
+ },
2064
+ )
2065
+ clip_paths.append(str(resp.video_path))
2066
+
2067
+ if len(clip_paths) == 1:
2068
+ final_path = clip_paths[0]
2069
+ else:
2070
+ ff = _find_ffmpeg_binary()
2071
+ if not ff:
2072
+ return JSONResponse(
2073
+ status_code=500,
2074
+ content={
2075
+ "error": (
2076
+ "已生成多段视频,但未找到 ffmpeg,无法拼接。"
2077
+ " 可选:① 安装 ffmpeg 并加入系统 PATH;"
2078
+ " ② 设置环境变量 LTX_FFMPEG_PATH 指向 ffmpeg.exe;"
2079
+ " ③ 在 %LOCALAPPDATA%\\LTXDesktop\\ffmpeg_path.txt 第一行写入 ffmpeg.exe 的完整路径。"
2080
+ ),
2081
+ "segment_paths": clip_paths,
2082
+ },
2083
+ )
2084
+ import uuid as _uuid
2085
+
2086
+ out_dir = get_dynamic_output_path()
2087
+ final_path = str(
2088
+ out_dir / f"batch_merged_{_uuid.uuid4().hex[:10]}.mp4"
2089
+ )
2090
+ try:
2091
+ _ffmpeg_concat_copy(clip_paths, final_path, ff)
2092
+ except Exception as ex:
2093
+ return JSONResponse(
2094
+ status_code=500,
2095
+ content={
2096
+ "error": str(ex),
2097
+ "segment_paths": clip_paths,
2098
+ },
2099
+ )
2100
+
2101
+ bg_audio = normalize_optional_path(
2102
+ data.get("backgroundAudioPath")
2103
+ or data.get("batchBackgroundAudioPath")
2104
+ )
2105
+ if bg_audio and os.path.isfile(bg_audio):
2106
+ ff_mux = _find_ffmpeg_binary()
2107
+ if not ff_mux:
2108
+ return JSONResponse(
2109
+ status_code=500,
2110
+ content={
2111
+ "error": "已生成视频,但混入配乐需要 ffmpeg,请配置 LTX_FFMPEG_PATH 或 ffmpeg_path.txt",
2112
+ "video_path": final_path,
2113
+ },
2114
+ )
2115
+ import uuid as _uuid2
2116
+
2117
+ out_mux = str(
2118
+ get_dynamic_output_path()
2119
+ / f"batch_with_audio_{_uuid2.uuid4().hex[:10]}.mp4"
2120
+ )
2121
+ try:
2122
+ _ffmpeg_mux_background_audio(ff_mux, final_path, bg_audio, out_mux)
2123
+ final_path = out_mux
2124
+ except Exception as ex:
2125
+ return JSONResponse(
2126
+ status_code=500,
2127
+ content={
2128
+ "error": str(ex),
2129
+ "video_path": final_path,
2130
+ },
2131
+ )
2132
+
2133
+ return GenerateVideoResponse(status="complete", video_path=final_path)
2134
+ except Exception as e:
2135
+ import traceback
2136
+
2137
+ traceback.print_exc()
2138
+ return JSONResponse(status_code=500, content={"error": str(e)})
2139
+
2140
+ # ------------------
2141
+
2142
+ @app.get("/api/system/history")
2143
+ async def route_get_history(request: Request):
2144
+ try:
2145
+ import os
2146
+
2147
+ page = int(request.query_params.get("page", 1))
2148
+ limit = int(request.query_params.get("limit", 20))
2149
+
2150
+ history = []
2151
+ dyn_path = get_dynamic_output_path()
2152
+ if dyn_path.exists():
2153
+ for filename in os.listdir(dyn_path):
2154
+ if filename == "uploads":
2155
+ continue
2156
+ full_path = dyn_path / filename
2157
+ if full_path.is_file() and filename.lower().endswith(
2158
+ (".mp4", ".png", ".jpg", ".webp")
2159
+ ):
2160
+ mtime = os.path.getmtime(full_path)
2161
+ history.append(
2162
+ {
2163
+ "filename": filename,
2164
+ "type": "video"
2165
+ if filename.lower().endswith(".mp4")
2166
+ else "image",
2167
+ "mtime": mtime,
2168
+ "fullpath": str(full_path),
2169
+ }
2170
+ )
2171
+ history.sort(key=lambda x: x["mtime"], reverse=True)
2172
+
2173
+ total_items = len(history)
2174
+ total_pages = (total_items + limit - 1) // limit
2175
+ start_idx = (page - 1) * limit
2176
+ end_idx = start_idx + limit
2177
+
2178
+ return {
2179
+ "status": "success",
2180
+ "history": history[start_idx:end_idx],
2181
+ "total_pages": total_pages,
2182
+ "current_page": page,
2183
+ "total_items": total_items,
2184
+ }
2185
+ except Exception as e:
2186
+ return JSONResponse(status_code=500, content={"error": str(e)})
2187
+
2188
+ @app.post("/api/system/delete-file")
2189
+ async def route_delete_file(request: Request):
2190
+ try:
2191
+ import os
2192
+
2193
+ data = await request.json()
2194
+ filename = data.get("filename", "")
2195
+
2196
+ if not filename:
2197
+ return JSONResponse(
2198
+ status_code=400, content={"error": "Filename is required"}
2199
+ )
2200
+
2201
+ dyn_path = get_dynamic_output_path()
2202
+ file_path = dyn_path / filename
2203
+
2204
+ if file_path.exists() and file_path.is_file():
2205
+ file_path.unlink()
2206
+ return {"status": "success", "message": "File deleted"}
2207
+ else:
2208
+ return JSONResponse(
2209
+ status_code=404, content={"error": "File not found"}
2210
+ )
2211
+ except Exception as e:
2212
+ return JSONResponse(status_code=500, content={"error": str(e)})
2213
+
2214
+ # 路由注册
2215
+ app.include_router(health_router)
2216
+ app.include_router(generation_router)
2217
+ app.include_router(models_router)
2218
+ app.include_router(settings_router)
2219
+ app.include_router(image_gen_router)
2220
+ app.include_router(suggest_gap_prompt_router)
2221
+ app.include_router(retake_router)
2222
+ app.include_router(ic_lora_router)
2223
+ app.include_router(runtime_policy_router)
2224
+
2225
+ # --- [安全补丁] 状态栏显示修复 ---
2226
+
2227
+ # --- 最终状态栏修复补丁: 只要服务运行且 GPU 没死,就视为就绪 ---
2228
+ from handlers.health_handler import HealthHandler
2229
+
2230
+ if not hasattr(HealthHandler, "_fixed_v2"):
2231
+ _orig_get_health = HealthHandler.get_health
2232
+
2233
+ def patched_health_v2(self):
2234
+ resp = _orig_get_health(self)
2235
+ # 解析:如果后端逻辑还在判断模型未加载,我们检查一下核心状态
2236
+ # 如果系统没有崩溃,我们就强制标记为已加载,让前端允许交互
2237
+ if not resp.models_loaded:
2238
+ # 我们认为只要 API 能通,底层状态服务(state)只要存在,就视为由于异步加载引起的暂时性 False
2239
+ # 直接返回 True,前端会显示"待机就绪"
2240
+ resp.models_loaded = True
2241
+ return resp
2242
+
2243
+ HealthHandler.get_health = patched_health_v2
2244
+ HealthHandler._fixed_v2 = True
2245
+ # ------------------------------------------------------------
2246
+
2247
+ # --- 修复显存采集指针:使得显存监控永远对准当前选定工作的 GPU ---
2248
+ from services.gpu_info.gpu_info_impl import GpuInfoImpl
2249
+
2250
+ if not hasattr(GpuInfoImpl, "_fixed_vram_patch"):
2251
+ _orig_get_gpu_info = GpuInfoImpl.get_gpu_info
2252
+
2253
+ def patched_get_gpu_info(self):
2254
+ import torch
2255
+
2256
+ if self.get_cuda_available():
2257
+ idx = 0
2258
+ if (
2259
+ hasattr(handler.config.device, "index")
2260
+ and handler.config.device.index is not None
2261
+ ):
2262
+ idx = handler.config.device.index
2263
+ try:
2264
+ import pynvml
2265
+
2266
+ pynvml.nvmlInit()
2267
+ handle = pynvml.nvmlDeviceGetHandleByIndex(idx)
2268
+ raw_name = pynvml.nvmlDeviceGetName(handle)
2269
+ name = (
2270
+ raw_name.decode("utf-8", errors="replace")
2271
+ if isinstance(raw_name, bytes)
2272
+ else str(raw_name)
2273
+ )
2274
+ memory = pynvml.nvmlDeviceGetMemoryInfo(handle)
2275
+ pynvml.nvmlShutdown()
2276
+ return {
2277
+ "name": f"{name} [ID: {idx}]",
2278
+ "vram": memory.total // (1024 * 1024),
2279
+ "vramUsed": memory.used // (1024 * 1024),
2280
+ }
2281
+ except Exception:
2282
+ pass
2283
+ return _orig_get_gpu_info(self)
2284
+
2285
+ GpuInfoImpl.get_gpu_info = patched_get_gpu_info
2286
+ GpuInfoImpl._fixed_vram_patch = True
2287
+
2288
+ return app
LTX2.3/patches/handlers/__pycache__/video_generation_handler.cpython-313.pyc ADDED
Binary file (36.5 kB). View file
 
LTX2.3/patches/handlers/video_generation_handler.py ADDED
@@ -0,0 +1,868 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Video generation orchestration handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import tempfile
8
+ import time
9
+ import uuid
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from threading import RLock
13
+ from typing import TYPE_CHECKING
14
+
15
+ from PIL import Image
16
+
17
+ from api_types import (
18
+ GenerateVideoRequest,
19
+ GenerateVideoResponse,
20
+ ImageConditioningInput,
21
+ VideoCameraMotion,
22
+ )
23
+ from _routes._errors import HTTPError
24
+ from handlers.base import StateHandlerBase
25
+ from handlers.generation_handler import GenerationHandler
26
+ from handlers.pipelines_handler import PipelinesHandler
27
+ from handlers.text_handler import TextHandler
28
+ from runtime_config.model_download_specs import resolve_model_path
29
+ from server_utils.media_validation import (
30
+ normalize_optional_path,
31
+ validate_audio_file,
32
+ validate_image_file,
33
+ )
34
+ from services.interfaces import LTXAPIClient
35
+ from state.app_state_types import AppState
36
+ from state.app_settings import should_video_generate_with_ltx_api
37
+
38
+ if TYPE_CHECKING:
39
+ from runtime_config.runtime_config import RuntimeConfig
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ FORCED_API_MODEL_MAP: dict[str, str] = {
44
+ "fast": "ltx-2-3-fast",
45
+ "pro": "ltx-2-3-pro",
46
+ }
47
+ FORCED_API_RESOLUTION_MAP: dict[str, dict[str, str]] = {
48
+ "1080p": {"16:9": "1920x1080", "9:16": "1080x1920"},
49
+ "1440p": {"16:9": "2560x1440", "9:16": "1440x2560"},
50
+ "2160p": {"16:9": "3840x2160", "9:16": "2160x3840"},
51
+ }
52
+ A2V_FORCED_API_RESOLUTION = "1920x1080"
53
+ FORCED_API_ALLOWED_ASPECT_RATIOS = {"16:9", "9:16"}
54
+ FORCED_API_ALLOWED_FPS = {24, 25, 48, 50}
55
+
56
+
57
+ def _get_allowed_durations(model_id: str, resolution_label: str, fps: int) -> set[int]:
58
+ if model_id == "ltx-2-3-fast" and resolution_label == "1080p" and fps in {24, 25}:
59
+ return {6, 8, 10, 12, 14, 16, 18, 20}
60
+ return {6, 8, 10}
61
+
62
+
63
+ class VideoGenerationHandler(StateHandlerBase):
64
+ def __init__(
65
+ self,
66
+ state: AppState,
67
+ lock: RLock,
68
+ generation_handler: GenerationHandler,
69
+ pipelines_handler: PipelinesHandler,
70
+ text_handler: TextHandler,
71
+ ltx_api_client: LTXAPIClient,
72
+ config: RuntimeConfig,
73
+ ) -> None:
74
+ super().__init__(state, lock, config)
75
+ self._generation = generation_handler
76
+ self._pipelines = pipelines_handler
77
+ self._text = text_handler
78
+ self._ltx_api_client = ltx_api_client
79
+
80
+ def generate(self, req: GenerateVideoRequest) -> GenerateVideoResponse:
81
+ if should_video_generate_with_ltx_api(
82
+ force_api_generations=self.config.force_api_generations,
83
+ settings=self.state.app_settings,
84
+ ):
85
+ return self._generate_forced_api(req)
86
+
87
+ if self._generation.is_generation_running():
88
+ raise HTTPError(409, "Generation already in progress")
89
+
90
+ resolution = req.resolution
91
+
92
+ duration = int(float(req.duration))
93
+ fps = int(float(req.fps))
94
+
95
+ audio_path = normalize_optional_path(req.audioPath)
96
+ if audio_path:
97
+ return self._generate_a2v(req, duration, fps, audio_path=audio_path)
98
+
99
+ logger.info("Resolution %s - using fast pipeline", resolution)
100
+
101
+ RESOLUTION_MAP_16_9: dict[str, tuple[int, int]] = {
102
+ "540p": (1024, 576),
103
+ "720p": (1280, 704),
104
+ "1080p": (1920, 1088),
105
+ }
106
+
107
+ def get_16_9_size(res: str) -> tuple[int, int]:
108
+ return RESOLUTION_MAP_16_9.get(res, (1280, 704))
109
+
110
+ def get_9_16_size(res: str) -> tuple[int, int]:
111
+ w, h = get_16_9_size(res)
112
+ return h, w
113
+
114
+ match req.aspectRatio:
115
+ case "9:16":
116
+ width, height = get_9_16_size(resolution)
117
+ case "16:9":
118
+ width, height = get_16_9_size(resolution)
119
+
120
+ num_frames = self._compute_num_frames(duration, fps)
121
+
122
+ image = None
123
+ image_path = normalize_optional_path(req.imagePath)
124
+ if image_path:
125
+ image = self._prepare_image(image_path, width, height)
126
+ logger.info("Image: %s -> %sx%s", image_path, width, height)
127
+
128
+ generation_id = self._make_generation_id()
129
+ seed = self._resolve_seed()
130
+
131
+ logger.info(
132
+ f"Request loraPath: '{req.loraPath}', loraStrength: {req.loraStrength}, inferenceSteps: {req.inferenceSteps}"
133
+ )
134
+
135
+ # 尝试支持自定义步数(实验性)
136
+ inference_steps = req.inferenceSteps
137
+ logger.info(f"Using inference steps: {inference_steps}")
138
+
139
+ loras = None
140
+ if req.loraPath and req.loraPath.strip():
141
+ try:
142
+ import os
143
+ from pathlib import Path
144
+ from ltx_core.loader import LoraPathStrengthAndSDOps
145
+ from ltx_core.loader.sd_ops import LTXV_LORA_COMFY_RENAMING_MAP
146
+
147
+ lora_path = req.loraPath.strip()
148
+ logger.info(
149
+ f"LoRA path: {lora_path}, exists: {os.path.exists(lora_path)}"
150
+ )
151
+
152
+ if os.path.exists(lora_path):
153
+ loras = [
154
+ LoraPathStrengthAndSDOps(
155
+ path=lora_path,
156
+ strength=req.loraStrength,
157
+ sd_ops=LTXV_LORA_COMFY_RENAMING_MAP,
158
+ )
159
+ ]
160
+ logger.info(
161
+ f"LoRA prepared: {lora_path} with strength {req.loraStrength}"
162
+ )
163
+ else:
164
+ logger.warning(f"LoRA file not found: {lora_path}")
165
+ except Exception as e:
166
+ logger.warning(f"Failed to load LoRA: {e}")
167
+ import traceback
168
+
169
+ logger.warning(f"LoRA traceback: {traceback.format_exc()}")
170
+ loras = None
171
+
172
+ lora_path_req = (req.loraPath or "").strip()
173
+ desired_sig = (
174
+ "fast",
175
+ lora_path_req if loras is not None else "",
176
+ round(float(req.loraStrength), 4) if loras is not None else 0.0,
177
+ )
178
+ try:
179
+ if loras is not None:
180
+ # 强制卸载并重新加载带LoRA的pipeline
181
+ logger.info("Unloading pipeline for LoRA...")
182
+ from keep_models_runtime import force_unload_gpu_pipeline
183
+
184
+ force_unload_gpu_pipeline(self._pipelines)
185
+
186
+ # 强制垃圾回收
187
+ import gc
188
+
189
+ gc.collect()
190
+ # 释放 CUDA 缓存,降低 LoRA 首次构建的显存峰值/碎片风险
191
+ try:
192
+ import torch
193
+ if torch.cuda.is_available():
194
+ torch.cuda.empty_cache()
195
+ torch.cuda.ipc_collect()
196
+ except Exception:
197
+ pass
198
+
199
+ gemma_root = self._pipelines._text_handler.resolve_gemma_root()
200
+ from runtime_config.model_download_specs import resolve_model_path
201
+ from services.fast_video_pipeline.ltx_fast_video_pipeline import (
202
+ LTXFastVideoPipeline,
203
+ )
204
+
205
+ checkpoint_path = str(
206
+ resolve_model_path(
207
+ self._pipelines.models_dir,
208
+ self._pipelines.config.model_download_specs,
209
+ "checkpoint",
210
+ )
211
+ )
212
+ upsampler_path = str(
213
+ resolve_model_path(
214
+ self._pipelines.models_dir,
215
+ self._pipelines.config.model_download_specs,
216
+ "upsampler",
217
+ )
218
+ )
219
+
220
+ logger.info(
221
+ f"Creating pipeline with LoRA: {loras}, steps: {inference_steps}"
222
+ )
223
+ from lora_injection import (
224
+ _lora_init_kwargs,
225
+ inject_loras_into_fast_pipeline,
226
+ )
227
+
228
+ lora_kw = _lora_init_kwargs(LTXFastVideoPipeline, loras)
229
+ pipeline = LTXFastVideoPipeline(
230
+ checkpoint_path,
231
+ gemma_root,
232
+ upsampler_path,
233
+ self._pipelines.config.device,
234
+ **lora_kw,
235
+ )
236
+ n_inj = inject_loras_into_fast_pipeline(pipeline, loras)
237
+ if hasattr(pipeline, "pipeline") and hasattr(
238
+ pipeline.pipeline, "model_ledger"
239
+ ):
240
+ try:
241
+ pipeline.pipeline.model_ledger.loras = tuple(loras)
242
+ except Exception:
243
+ pass
244
+ logger.info(
245
+ "LoRA 注入: init_kw=%s, 注入点=%s, model_ledger.loras=%s",
246
+ list(lora_kw.keys()),
247
+ n_inj,
248
+ getattr(
249
+ getattr(pipeline.pipeline, "model_ledger", None),
250
+ "loras",
251
+ None,
252
+ ),
253
+ )
254
+
255
+ from state.app_state_types import (
256
+ VideoPipelineState,
257
+ VideoPipelineWarmth,
258
+ GpuSlot,
259
+ )
260
+
261
+ state = VideoPipelineState(
262
+ pipeline=pipeline,
263
+ warmth=VideoPipelineWarmth.COLD,
264
+ is_compiled=False,
265
+ )
266
+
267
+ self._pipelines.state.gpu_slot = GpuSlot(
268
+ active_pipeline=state, generation=None
269
+ )
270
+ logger.info("Pipeline with LoRA loaded successfully")
271
+ else:
272
+ # 无论有没有LoRA,都尝试使用自定义步数重新加载pipeline
273
+ logger.info(f"Loading pipeline with {inference_steps} steps")
274
+ from keep_models_runtime import force_unload_gpu_pipeline
275
+
276
+ force_unload_gpu_pipeline(self._pipelines)
277
+
278
+ import gc
279
+
280
+ gc.collect()
281
+
282
+ gemma_root = self._pipelines._text_handler.resolve_gemma_root()
283
+ from runtime_config.model_download_specs import resolve_model_path
284
+ from services.fast_video_pipeline.ltx_fast_video_pipeline import (
285
+ LTXFastVideoPipeline,
286
+ )
287
+
288
+ checkpoint_path = str(
289
+ resolve_model_path(
290
+ self._pipelines.models_dir,
291
+ self._pipelines.config.model_download_specs,
292
+ "checkpoint",
293
+ )
294
+ )
295
+ upsampler_path = str(
296
+ resolve_model_path(
297
+ self._pipelines.models_dir,
298
+ self._pipelines.config.model_download_specs,
299
+ "upsampler",
300
+ )
301
+ )
302
+
303
+ pipeline = LTXFastVideoPipeline(
304
+ checkpoint_path,
305
+ gemma_root,
306
+ upsampler_path,
307
+ self._pipelines.config.device,
308
+ )
309
+
310
+ from state.app_state_types import (
311
+ VideoPipelineState,
312
+ VideoPipelineWarmth,
313
+ GpuSlot,
314
+ )
315
+
316
+ state = VideoPipelineState(
317
+ pipeline=pipeline,
318
+ warmth=VideoPipelineWarmth.COLD,
319
+ is_compiled=False,
320
+ )
321
+
322
+ self._pipelines.state.gpu_slot = GpuSlot(
323
+ active_pipeline=state, generation=None
324
+ )
325
+
326
+ self._pipelines._pipeline_signature = desired_sig
327
+
328
+ self._generation.start_generation(generation_id)
329
+
330
+ output_path = self.generate_video(
331
+ prompt=req.prompt,
332
+ image=image,
333
+ height=height,
334
+ width=width,
335
+ num_frames=num_frames,
336
+ fps=fps,
337
+ seed=seed,
338
+ camera_motion=req.cameraMotion,
339
+ negative_prompt=req.negativePrompt,
340
+ )
341
+
342
+ self._generation.complete_generation(output_path)
343
+ return GenerateVideoResponse(status="complete", video_path=output_path)
344
+
345
+ except Exception as e:
346
+ self._generation.fail_generation(str(e))
347
+ if "cancelled" in str(e).lower():
348
+ logger.info("Generation cancelled by user")
349
+ return GenerateVideoResponse(status="cancelled")
350
+
351
+ raise HTTPError(500, str(e)) from e
352
+
353
+ def generate_video(
354
+ self,
355
+ prompt: str,
356
+ image: Image.Image | None,
357
+ height: int,
358
+ width: int,
359
+ num_frames: int,
360
+ fps: float,
361
+ seed: int,
362
+ camera_motion: VideoCameraMotion,
363
+ negative_prompt: str,
364
+ ) -> str:
365
+ t_total_start = time.perf_counter()
366
+ gen_mode = "i2v" if image is not None else "t2v"
367
+ logger.info(
368
+ "[%s] Generation started (model=fast, %dx%d, %d frames, %d fps)",
369
+ gen_mode,
370
+ width,
371
+ height,
372
+ num_frames,
373
+ int(fps),
374
+ )
375
+
376
+ if self._generation.is_generation_cancelled():
377
+ raise RuntimeError("Generation was cancelled")
378
+
379
+ if not resolve_model_path(
380
+ self.models_dir, self.config.model_download_specs, "checkpoint"
381
+ ).exists():
382
+ raise RuntimeError(
383
+ "Models not downloaded. Please download the AI models first using the Model Status menu."
384
+ )
385
+
386
+ total_steps = 8
387
+
388
+ self._generation.update_progress("loading_model", 5, 0, total_steps)
389
+ t_load_start = time.perf_counter()
390
+ pipeline_state = self._pipelines.load_gpu_pipeline("fast", should_warm=False)
391
+ t_load_end = time.perf_counter()
392
+ logger.info("[%s] Pipeline load: %.2fs", gen_mode, t_load_end - t_load_start)
393
+
394
+ self._generation.update_progress("encoding_text", 10, 0, total_steps)
395
+
396
+ enhanced_prompt = prompt + self.config.camera_motion_prompts.get(
397
+ camera_motion, ""
398
+ )
399
+
400
+ images: list[ImageConditioningInput] = []
401
+ temp_image_path: str | None = None
402
+ if image is not None:
403
+ temp_image_path = tempfile.NamedTemporaryFile(
404
+ suffix=".png", delete=False
405
+ ).name
406
+ image.save(temp_image_path)
407
+ images = [
408
+ ImageConditioningInput(path=temp_image_path, frame_idx=0, strength=1.0)
409
+ ]
410
+
411
+ output_path = self._make_output_path()
412
+
413
+ try:
414
+ settings = self.state.app_settings
415
+ use_api_encoding = not self._text.should_use_local_encoding()
416
+ if image is not None:
417
+ enhance = use_api_encoding and settings.prompt_enhancer_enabled_i2v
418
+ else:
419
+ enhance = use_api_encoding and settings.prompt_enhancer_enabled_t2v
420
+
421
+ encoding_method = "api" if use_api_encoding else "local"
422
+ t_text_start = time.perf_counter()
423
+ self._text.prepare_text_encoding(enhanced_prompt, enhance_prompt=enhance)
424
+ t_text_end = time.perf_counter()
425
+ logger.info(
426
+ "[%s] Text encoding (%s): %.2fs",
427
+ gen_mode,
428
+ encoding_method,
429
+ t_text_end - t_text_start,
430
+ )
431
+
432
+ self._generation.update_progress("inference", 15, 0, total_steps)
433
+
434
+ height = round(height / 64) * 64
435
+ width = round(width / 64) * 64
436
+
437
+ t_inference_start = time.perf_counter()
438
+ pipeline_state.pipeline.generate(
439
+ prompt=enhanced_prompt,
440
+ seed=seed,
441
+ height=height,
442
+ width=width,
443
+ num_frames=num_frames,
444
+ frame_rate=fps,
445
+ images=images,
446
+ output_path=str(output_path),
447
+ )
448
+ t_inference_end = time.perf_counter()
449
+ logger.info(
450
+ "[%s] Inference: %.2fs", gen_mode, t_inference_end - t_inference_start
451
+ )
452
+
453
+ if self._generation.is_generation_cancelled():
454
+ if output_path.exists():
455
+ output_path.unlink()
456
+ raise RuntimeError("Generation was cancelled")
457
+
458
+ t_total_end = time.perf_counter()
459
+ logger.info(
460
+ "[%s] Total generation: %.2fs (load=%.2fs, text=%.2fs, inference=%.2fs)",
461
+ gen_mode,
462
+ t_total_end - t_total_start,
463
+ t_load_end - t_load_start,
464
+ t_text_end - t_text_start,
465
+ t_inference_end - t_inference_start,
466
+ )
467
+
468
+ self._generation.update_progress("complete", 100, total_steps, total_steps)
469
+ return str(output_path)
470
+ finally:
471
+ self._text.clear_api_embeddings()
472
+ if temp_image_path and os.path.exists(temp_image_path):
473
+ os.unlink(temp_image_path)
474
+
475
+ def _generate_a2v(
476
+ self, req: GenerateVideoRequest, duration: int, fps: int, *, audio_path: str
477
+ ) -> GenerateVideoResponse:
478
+ if req.model != "pro":
479
+ logger.warning(
480
+ "A2V local requested with model=%s; A2V always uses pro pipeline",
481
+ req.model,
482
+ )
483
+ validated_audio_path = validate_audio_file(audio_path)
484
+ audio_path_str = str(validated_audio_path)
485
+
486
+ # 支持竖屏和横屏
487
+ RESOLUTION_MAP: dict[str, tuple[int, int]] = {
488
+ "540p": (1024, 576),
489
+ "720p": (1280, 704),
490
+ "1080p": (1920, 1088),
491
+ }
492
+
493
+ base_w, base_h = RESOLUTION_MAP.get(req.resolution, (1280, 704))
494
+
495
+ # 根据 aspectRatio 调整分辨率
496
+ if req.aspectRatio == "9:16":
497
+ width, height = base_h, base_w # 竖屏
498
+ else:
499
+ width, height = base_w, base_h # 横屏
500
+
501
+ num_frames = self._compute_num_frames(duration, fps)
502
+
503
+ image = None
504
+ temp_image_path: str | None = None
505
+ image_path = normalize_optional_path(req.imagePath)
506
+ if image_path:
507
+ image = self._prepare_image(image_path, width, height)
508
+
509
+ # 获取首尾帧
510
+ start_frame_path = normalize_optional_path(getattr(req, "startFramePath", None))
511
+ end_frame_path = normalize_optional_path(getattr(req, "endFramePath", None))
512
+
513
+ seed = self._resolve_seed()
514
+
515
+ generation_id = self._make_generation_id()
516
+
517
+ temp_image_paths: list[str] = []
518
+ try:
519
+ a2v_state = self._pipelines.load_a2v_pipeline()
520
+ self._generation.start_generation(generation_id)
521
+
522
+ enhanced_prompt = req.prompt + self.config.camera_motion_prompts.get(
523
+ req.cameraMotion, ""
524
+ )
525
+ neg = (
526
+ req.negativePrompt
527
+ if req.negativePrompt
528
+ else self.config.default_negative_prompt
529
+ )
530
+
531
+ images: list[ImageConditioningInput] = []
532
+ temp_image_paths: list[str] = []
533
+
534
+ # 首帧
535
+ if start_frame_path:
536
+ start_img = self._prepare_image(start_frame_path, width, height)
537
+ temp_start_path = tempfile.NamedTemporaryFile(
538
+ suffix=".png", delete=False
539
+ ).name
540
+ start_img.save(temp_start_path)
541
+ temp_image_paths.append(temp_start_path)
542
+ images.append(
543
+ ImageConditioningInput(
544
+ path=temp_start_path, frame_idx=0, strength=1.0
545
+ )
546
+ )
547
+
548
+ # 中间图片(如果有)
549
+ if image is not None and not start_frame_path:
550
+ temp_image_path = tempfile.NamedTemporaryFile(
551
+ suffix=".png", delete=False
552
+ ).name
553
+ image.save(temp_image_path)
554
+ temp_image_paths.append(temp_image_path)
555
+ images.append(
556
+ ImageConditioningInput(
557
+ path=temp_image_path, frame_idx=0, strength=1.0
558
+ )
559
+ )
560
+
561
+ # 尾帧
562
+ if end_frame_path:
563
+ last_latent_idx = (num_frames - 1) // 8 + 1 - 1
564
+ end_img = self._prepare_image(end_frame_path, width, height)
565
+ temp_end_path = tempfile.NamedTemporaryFile(
566
+ suffix=".png", delete=False
567
+ ).name
568
+ end_img.save(temp_end_path)
569
+ temp_image_paths.append(temp_end_path)
570
+ images.append(
571
+ ImageConditioningInput(
572
+ path=temp_end_path, frame_idx=last_latent_idx, strength=1.0
573
+ )
574
+ )
575
+
576
+ output_path = self._make_output_path()
577
+
578
+ total_steps = 11 # distilled: 8 steps (stage 1) + 3 steps (stage 2)
579
+
580
+ a2v_settings = self.state.app_settings
581
+ a2v_use_api = not self._text.should_use_local_encoding()
582
+ if image is not None:
583
+ a2v_enhance = a2v_use_api and a2v_settings.prompt_enhancer_enabled_i2v
584
+ else:
585
+ a2v_enhance = a2v_use_api and a2v_settings.prompt_enhancer_enabled_t2v
586
+
587
+ self._generation.update_progress("loading_model", 5, 0, total_steps)
588
+ self._generation.update_progress("encoding_text", 10, 0, total_steps)
589
+ self._text.prepare_text_encoding(
590
+ enhanced_prompt, enhance_prompt=a2v_enhance
591
+ )
592
+ self._generation.update_progress("inference", 15, 0, total_steps)
593
+
594
+ a2v_state.pipeline.generate(
595
+ prompt=enhanced_prompt,
596
+ negative_prompt=neg,
597
+ seed=seed,
598
+ height=height,
599
+ width=width,
600
+ num_frames=num_frames,
601
+ frame_rate=fps,
602
+ num_inference_steps=total_steps,
603
+ images=images,
604
+ audio_path=audio_path_str,
605
+ audio_start_time=0.0,
606
+ audio_max_duration=None,
607
+ output_path=str(output_path),
608
+ )
609
+
610
+ if self._generation.is_generation_cancelled():
611
+ if output_path.exists():
612
+ output_path.unlink()
613
+ raise RuntimeError("Generation was cancelled")
614
+
615
+ self._generation.update_progress("complete", 100, total_steps, total_steps)
616
+ self._generation.complete_generation(str(output_path))
617
+ return GenerateVideoResponse(status="complete", video_path=str(output_path))
618
+
619
+ except Exception as e:
620
+ self._generation.fail_generation(str(e))
621
+ if "cancelled" in str(e).lower():
622
+ logger.info("Generation cancelled by user")
623
+ return GenerateVideoResponse(status="cancelled")
624
+ raise HTTPError(500, str(e)) from e
625
+ finally:
626
+ self._text.clear_api_embeddings()
627
+ # 清理所有临时图片
628
+ for tmp_path in temp_image_paths:
629
+ if tmp_path and os.path.exists(tmp_path):
630
+ try:
631
+ os.unlink(tmp_path)
632
+ except Exception:
633
+ pass
634
+ if temp_image_path and os.path.exists(temp_image_path):
635
+ try:
636
+ os.unlink(temp_image_path)
637
+ except Exception:
638
+ pass
639
+
640
+ def _prepare_image(self, image_path: str, width: int, height: int) -> Image.Image:
641
+ validated_path = validate_image_file(image_path)
642
+ try:
643
+ img = Image.open(validated_path).convert("RGB")
644
+ except Exception:
645
+ raise HTTPError(400, f"Invalid image file: {image_path}") from None
646
+ img_w, img_h = img.size
647
+ target_ratio = width / height
648
+ img_ratio = img_w / img_h
649
+ if img_ratio > target_ratio:
650
+ new_h = height
651
+ new_w = int(img_w * (height / img_h))
652
+ else:
653
+ new_w = width
654
+ new_h = int(img_h * (width / img_w))
655
+ resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
656
+ left = (new_w - width) // 2
657
+ top = (new_h - height) // 2
658
+ return resized.crop((left, top, left + width, top + height))
659
+
660
+ @staticmethod
661
+ def _make_generation_id() -> str:
662
+ return uuid.uuid4().hex[:8]
663
+
664
+ @staticmethod
665
+ def _compute_num_frames(duration: int, fps: int) -> int:
666
+ n = ((duration * fps) // 8) * 8 + 1
667
+ return max(n, 9)
668
+
669
+ def _resolve_seed(self) -> int:
670
+ settings = self.state.app_settings
671
+ if settings.seed_locked:
672
+ logger.info("Using locked seed: %s", settings.locked_seed)
673
+ return settings.locked_seed
674
+ return int(time.time()) % 2147483647
675
+
676
+ def _make_output_path(self) -> Path:
677
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
678
+ return (
679
+ self.config.outputs_dir
680
+ / f"ltx2_video_{timestamp}_{self._make_generation_id()}.mp4"
681
+ )
682
+
683
+ def _generate_forced_api(self, req: GenerateVideoRequest) -> GenerateVideoResponse:
684
+ if self._generation.is_generation_running():
685
+ raise HTTPError(409, "Generation already in progress")
686
+
687
+ generation_id = self._make_generation_id()
688
+ self._generation.start_api_generation(generation_id)
689
+
690
+ audio_path = normalize_optional_path(req.audioPath)
691
+ image_path = normalize_optional_path(req.imagePath)
692
+ has_input_audio = bool(audio_path)
693
+ has_input_image = bool(image_path)
694
+
695
+ try:
696
+ self._generation.update_progress("validating_request", 5, None, None)
697
+
698
+ api_key = self.state.app_settings.ltx_api_key.strip()
699
+ logger.info(
700
+ "Forced API generation route selected (key_present=%s)", bool(api_key)
701
+ )
702
+ if not api_key:
703
+ raise HTTPError(400, "PRO_API_KEY_REQUIRED")
704
+
705
+ requested_model = req.model.strip().lower()
706
+ api_model_id = FORCED_API_MODEL_MAP.get(requested_model)
707
+ if api_model_id is None:
708
+ raise HTTPError(400, "INVALID_FORCED_API_MODEL")
709
+
710
+ resolution_label = req.resolution
711
+ resolution_by_aspect = FORCED_API_RESOLUTION_MAP.get(resolution_label)
712
+ if resolution_by_aspect is None:
713
+ raise HTTPError(400, "INVALID_FORCED_API_RESOLUTION")
714
+
715
+ aspect_ratio = req.aspectRatio.strip()
716
+ if aspect_ratio not in FORCED_API_ALLOWED_ASPECT_RATIOS:
717
+ raise HTTPError(400, "INVALID_FORCED_API_ASPECT_RATIO")
718
+
719
+ api_resolution = resolution_by_aspect[aspect_ratio]
720
+
721
+ prompt = req.prompt
722
+
723
+ if self._generation.is_generation_cancelled():
724
+ raise RuntimeError("Generation was cancelled")
725
+
726
+ if has_input_audio:
727
+ if requested_model != "pro":
728
+ logger.warning(
729
+ "A2V requested with model=%s; overriding to 'pro'",
730
+ requested_model,
731
+ )
732
+ api_model_id = FORCED_API_MODEL_MAP["pro"]
733
+ if api_resolution != A2V_FORCED_API_RESOLUTION:
734
+ logger.warning(
735
+ "A2V requested with resolution=%s; overriding to '%s'",
736
+ api_resolution,
737
+ A2V_FORCED_API_RESOLUTION,
738
+ )
739
+ api_resolution = A2V_FORCED_API_RESOLUTION
740
+ validated_audio_path = validate_audio_file(audio_path)
741
+ validated_image_path: Path | None = None
742
+ if image_path is not None:
743
+ validated_image_path = validate_image_file(image_path)
744
+
745
+ self._generation.update_progress("uploading_audio", 20, None, None)
746
+ audio_uri = self._ltx_api_client.upload_file(
747
+ api_key=api_key,
748
+ file_path=str(validated_audio_path),
749
+ )
750
+ image_uri: str | None = None
751
+ if validated_image_path is not None:
752
+ self._generation.update_progress("uploading_image", 35, None, None)
753
+ image_uri = self._ltx_api_client.upload_file(
754
+ api_key=api_key,
755
+ file_path=str(validated_image_path),
756
+ )
757
+ self._generation.update_progress("inference", 55, None, None)
758
+ video_bytes = self._ltx_api_client.generate_audio_to_video(
759
+ api_key=api_key,
760
+ prompt=prompt,
761
+ audio_uri=audio_uri,
762
+ image_uri=image_uri,
763
+ model=api_model_id,
764
+ resolution=api_resolution,
765
+ )
766
+ self._generation.update_progress("downloading_output", 85, None, None)
767
+ elif has_input_image:
768
+ validated_image_path = validate_image_file(image_path)
769
+
770
+ duration = self._parse_forced_numeric_field(
771
+ req.duration, "INVALID_FORCED_API_DURATION"
772
+ )
773
+ fps = self._parse_forced_numeric_field(
774
+ req.fps, "INVALID_FORCED_API_FPS"
775
+ )
776
+ if fps not in FORCED_API_ALLOWED_FPS:
777
+ raise HTTPError(400, "INVALID_FORCED_API_FPS")
778
+ if duration not in _get_allowed_durations(
779
+ api_model_id, resolution_label, fps
780
+ ):
781
+ raise HTTPError(400, "INVALID_FORCED_API_DURATION")
782
+
783
+ generate_audio = self._parse_audio_flag(req.audio)
784
+ self._generation.update_progress("uploading_image", 20, None, None)
785
+ image_uri = self._ltx_api_client.upload_file(
786
+ api_key=api_key,
787
+ file_path=str(validated_image_path),
788
+ )
789
+ self._generation.update_progress("inference", 55, None, None)
790
+ video_bytes = self._ltx_api_client.generate_image_to_video(
791
+ api_key=api_key,
792
+ prompt=prompt,
793
+ image_uri=image_uri,
794
+ model=api_model_id,
795
+ resolution=api_resolution,
796
+ duration=float(duration),
797
+ fps=float(fps),
798
+ generate_audio=generate_audio,
799
+ camera_motion=req.cameraMotion,
800
+ )
801
+ self._generation.update_progress("downloading_output", 85, None, None)
802
+ else:
803
+ duration = self._parse_forced_numeric_field(
804
+ req.duration, "INVALID_FORCED_API_DURATION"
805
+ )
806
+ fps = self._parse_forced_numeric_field(
807
+ req.fps, "INVALID_FORCED_API_FPS"
808
+ )
809
+ if fps not in FORCED_API_ALLOWED_FPS:
810
+ raise HTTPError(400, "INVALID_FORCED_API_FPS")
811
+ if duration not in _get_allowed_durations(
812
+ api_model_id, resolution_label, fps
813
+ ):
814
+ raise HTTPError(400, "INVALID_FORCED_API_DURATION")
815
+
816
+ generate_audio = self._parse_audio_flag(req.audio)
817
+ self._generation.update_progress("inference", 55, None, None)
818
+ video_bytes = self._ltx_api_client.generate_text_to_video(
819
+ api_key=api_key,
820
+ prompt=prompt,
821
+ model=api_model_id,
822
+ resolution=api_resolution,
823
+ duration=float(duration),
824
+ fps=float(fps),
825
+ generate_audio=generate_audio,
826
+ camera_motion=req.cameraMotion,
827
+ )
828
+ self._generation.update_progress("downloading_output", 85, None, None)
829
+
830
+ if self._generation.is_generation_cancelled():
831
+ raise RuntimeError("Generation was cancelled")
832
+
833
+ output_path = self._write_forced_api_video(video_bytes)
834
+ if self._generation.is_generation_cancelled():
835
+ output_path.unlink(missing_ok=True)
836
+ raise RuntimeError("Generation was cancelled")
837
+
838
+ self._generation.update_progress("complete", 100, None, None)
839
+ self._generation.complete_generation(str(output_path))
840
+ return GenerateVideoResponse(status="complete", video_path=str(output_path))
841
+ except HTTPError as e:
842
+ self._generation.fail_generation(e.detail)
843
+ raise
844
+ except Exception as e:
845
+ self._generation.fail_generation(str(e))
846
+ if "cancelled" in str(e).lower():
847
+ logger.info("Generation cancelled by user")
848
+ return GenerateVideoResponse(status="cancelled")
849
+ raise HTTPError(500, str(e)) from e
850
+
851
+ def _write_forced_api_video(self, video_bytes: bytes) -> Path:
852
+ output_path = self._make_output_path()
853
+ output_path.write_bytes(video_bytes)
854
+ return output_path
855
+
856
+ @staticmethod
857
+ def _parse_forced_numeric_field(raw_value: str, error_detail: str) -> int:
858
+ try:
859
+ return int(float(raw_value))
860
+ except (TypeError, ValueError):
861
+ raise HTTPError(400, error_detail) from None
862
+
863
+ @staticmethod
864
+ def _parse_audio_flag(audio_value: str | bool) -> bool:
865
+ if isinstance(audio_value, bool):
866
+ return audio_value
867
+ normalized = audio_value.strip().lower()
868
+ return normalized in {"1", "true", "yes", "on"}
LTX2.3/patches/keep_models_runtime.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """仅提供强制卸载 GPU 管线。「保持模型加载」功能已移除。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def force_unload_gpu_pipeline(pipelines: Any) -> None:
9
+ """释放推理管线占用的显存(切换 GPU、清理、LoRA 重建等场景)。"""
10
+ try:
11
+ pipelines.unload_gpu_pipeline()
12
+ except Exception:
13
+ try:
14
+ type(pipelines).unload_gpu_pipeline(pipelines)
15
+ except Exception:
16
+ pass
LTX2.3/patches/launcher.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import sys
3
+ import os
4
+
5
+ patch_dir = r"C:\Users\1-xuanran\Desktop\ltx-TEST\patches"
6
+ backend_dir = r"C:\Program Files\LTX Desktop\resources\backend"
7
+
8
+ # 防御性清除:强行剥离所有的默认 backend_dir 引用
9
+ sys.path = [p for p in sys.path if p and os.path.normpath(p) != os.path.normpath(backend_dir)]
10
+ sys.path = [p for p in sys.path if p and p != "." and p != ""]
11
+
12
+ # 绝对插队注入:优先搜索 PATCHES_DIR
13
+ sys.path.insert(0, patch_dir)
14
+ sys.path.insert(1, backend_dir)
15
+
16
+ import uvicorn
17
+ from ltx2_server import app
18
+
19
+ if __name__ == '__main__':
20
+ uvicorn.run(app, host="0.0.0.0", port=3000, log_level="info", access_log=False)
LTX2.3/patches/lora_build_hook.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 在 SingleGPUModelBuilder.build() 时合并「当前请求」的用户 LoRA。
3
+
4
+ 桌面版 Fast 管线往往只在 model_ledger 上挂 loras,真正 load 权重时仍用
5
+ 初始化时的空 loras Builder;此处对 DiT/Transformer 的 Builder 在 build 前注入。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextvars
11
+ import logging
12
+ from dataclasses import replace
13
+ from typing import Any
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # 当前 HTTP 请求/生成任务中要额外融合的 LoRA(LoraPathStrengthAndSDOps 元组)
18
+ _pending_user_loras: contextvars.ContextVar[tuple[Any, ...] | None] = contextvars.ContextVar(
19
+ "ltx_pending_user_loras", default=None
20
+ )
21
+
22
+ _HOOK_INSTALLED = False
23
+
24
+
25
+ def pending_loras_token(loras: tuple[Any, ...] | None):
26
+ """返回 contextvar Token,供 finally reset;loras 为 None 表示本任务不用额外 LoRA。"""
27
+ return _pending_user_loras.set(loras)
28
+
29
+
30
+ def reset_pending_loras(token: contextvars.Token | None) -> None:
31
+ if token is not None:
32
+ _pending_user_loras.reset(token)
33
+
34
+
35
+ def _get_pending() -> tuple[Any, ...] | None:
36
+ return _pending_user_loras.get()
37
+
38
+
39
+ def _is_ltx_diffusion_transformer_builder(builder: Any) -> bool:
40
+ """避免给 Gemma / VAE / Upsampler 的 Builder 误加视频 LoRA。"""
41
+ cfg = getattr(builder, "model_class_configurator", None)
42
+ if cfg is None:
43
+ return False
44
+ name = getattr(cfg, "__name__", "") or ""
45
+ # 排除明显非 DiT 的
46
+ for bad in (
47
+ "Gemma",
48
+ "VideoEncoder",
49
+ "VideoDecoder",
50
+ "AudioEncoder",
51
+ "AudioDecoder",
52
+ "Vocoder",
53
+ "EmbeddingsProcessor",
54
+ "LatentUpsampler",
55
+ ):
56
+ if bad in name:
57
+ return False
58
+ try:
59
+ from ltx_core.model.transformer import LTXModelConfigurator
60
+
61
+ if isinstance(cfg, type):
62
+ try:
63
+ if issubclass(cfg, LTXModelConfigurator):
64
+ return True
65
+ except TypeError:
66
+ pass
67
+ if cfg is LTXModelConfigurator:
68
+ return True
69
+ except ImportError:
70
+ pass
71
+ # 兜底:LTX 主 transformer 配置器命名习惯(排除已列出的 VAE/Gemma)
72
+ return "LTX" in name and "ModelConfigurator" in name
73
+
74
+
75
+ def install_lora_build_hook() -> None:
76
+ global _HOOK_INSTALLED
77
+ if _HOOK_INSTALLED:
78
+ return
79
+ try:
80
+ from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder
81
+ except ImportError:
82
+ logger.warning("lora_build_hook: 无法导入 SingleGPUModelBuilder,跳过")
83
+ return
84
+
85
+ _orig_build = SingleGPUModelBuilder.build
86
+
87
+ def build(self: Any, *args: Any, **kwargs: Any) -> Any:
88
+ extra = _get_pending()
89
+ if extra and _is_ltx_diffusion_transformer_builder(self):
90
+ have = {getattr(x, "path", None) for x in self.loras}
91
+ add = tuple(x for x in extra if getattr(x, "path", None) not in have)
92
+ if add:
93
+ merged = (*tuple(self.loras), *add)
94
+ self = replace(self, loras=merged)
95
+ logger.info(
96
+ "lora_build_hook: 已向 DiT Builder 合并 %d 个用户 LoRA: %s",
97
+ len(add),
98
+ [getattr(x, "path", x) for x in add],
99
+ )
100
+ return _orig_build(self, *args, **kwargs)
101
+
102
+ SingleGPUModelBuilder.build = build # type: ignore[method-assign]
103
+ _HOOK_INSTALLED = True
104
+ logger.info("lora_build_hook: 已挂载 SingleGPUModelBuilder.build")
LTX2.3/patches/lora_injection.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """将用户 LoRA 注入 Fast 视频管线:兼容 ModelLedger 与 LTX-2 DiffusionStage/Builder。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import logging
7
+ from typing import Any
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def _lora_init_kwargs(
13
+ pipeline_cls: type, loras: list[Any] | tuple[Any, ...]
14
+ ) -> dict[str, Any]:
15
+ if not loras:
16
+ return {}
17
+ try:
18
+ sig = inspect.signature(pipeline_cls.__init__)
19
+ names = sig.parameters.keys()
20
+ except (TypeError, ValueError):
21
+ return {}
22
+ tup = tuple(loras)
23
+ for key in ("loras", "lora", "extra_loras", "user_loras"):
24
+ if key in names:
25
+ return {key: tup}
26
+ return {}
27
+
28
+
29
+ def inject_loras_into_fast_pipeline(ltx_pipe: Any, loras: list[Any] | tuple[Any, ...]) -> int:
30
+ """在已构造的管线上尽量把 LoRA 写进会参与 build 的 Builder / ledger。返回成功写入的处数。"""
31
+ if not loras:
32
+ return 0
33
+ tup = tuple(loras)
34
+ patched = 0
35
+ visited: set[int] = set()
36
+
37
+ def visit(obj: Any, depth: int) -> None:
38
+ nonlocal patched
39
+ if obj is None or depth > 10:
40
+ return
41
+ oid = id(obj)
42
+ if oid in visited:
43
+ return
44
+ visited.add(oid)
45
+
46
+ # ModelLedger.loras(旧桌面)
47
+ ml = getattr(obj, "model_ledger", None)
48
+ if ml is not None:
49
+ try:
50
+ ml.loras = tup
51
+ patched += 1
52
+ logger.info("LoRA: 已设置 model_ledger.loras")
53
+ except Exception as e:
54
+ logger.debug("model_ledger.loras: %s", e)
55
+
56
+ # SingleGPUModelBuilder.with_loras(常见与变体属性名)
57
+ for holder in (obj, ml):
58
+ if holder is None:
59
+ continue
60
+ candidates: list[Any] = []
61
+ for attr in (
62
+ "_transformer_builder",
63
+ "transformer_builder",
64
+ "_model_builder",
65
+ "model_builder",
66
+ ):
67
+ tb = getattr(holder, attr, None)
68
+ if tb is not None:
69
+ candidates.append((attr, tb))
70
+ try:
71
+ for attr in dir(holder):
72
+ al = attr.lower()
73
+ if "transformer" in al and "builder" in al and attr not in (
74
+ "_transformer_builder",
75
+ "transformer_builder",
76
+ ):
77
+ tb = getattr(holder, attr, None)
78
+ if tb is not None:
79
+ candidates.append((attr, tb))
80
+ except Exception:
81
+ pass
82
+ for attr, tb in candidates:
83
+ if hasattr(tb, "with_loras"):
84
+ try:
85
+ new_tb = tb.with_loras(tup)
86
+ setattr(holder, attr, new_tb)
87
+ patched += 1
88
+ logger.info("LoRA: 已更新 %s.with_loras", attr)
89
+ except Exception as e:
90
+ logger.debug("with_loras %s: %s", attr, e)
91
+
92
+ # DiffusionStage(类名或 isinstance)
93
+ is_diffusion = type(obj).__name__ == "DiffusionStage"
94
+ if not is_diffusion:
95
+ try:
96
+ from ltx_pipelines.utils.blocks import DiffusionStage as _DS
97
+
98
+ is_diffusion = isinstance(obj, _DS)
99
+ except ImportError:
100
+ pass
101
+ if is_diffusion:
102
+ tb = getattr(obj, "_transformer_builder", None)
103
+ if tb is not None and hasattr(tb, "with_loras"):
104
+ try:
105
+ obj._transformer_builder = tb.with_loras(tup)
106
+ patched += 1
107
+ logger.info("LoRA: 已写入 DiffusionStage._transformer_builder")
108
+ except Exception as e:
109
+ logger.debug("DiffusionStage: %s", e)
110
+
111
+ # 常见嵌套属性
112
+ for name in (
113
+ "pipeline",
114
+ "inner",
115
+ "_inner",
116
+ "fast_pipeline",
117
+ "_pipeline",
118
+ "stage_1",
119
+ "stage_2",
120
+ "stage",
121
+ "_stage",
122
+ "stages",
123
+ "diffusion",
124
+ "_diffusion",
125
+ ):
126
+ try:
127
+ ch = getattr(obj, name, None)
128
+ except Exception:
129
+ continue
130
+ if ch is not None and ch is not obj:
131
+ visit(ch, depth + 1)
132
+
133
+ if isinstance(obj, (list, tuple)):
134
+ for item in obj[:8]:
135
+ visit(item, depth + 1)
136
+
137
+ root = getattr(ltx_pipe, "pipeline", ltx_pipe)
138
+ visit(root, 0)
139
+ return patched
LTX2.3/patches/low_vram_runtime.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """低显存模式:尽量降峰值显存(以速度换显存);效果取决于官方管线是否支持 offload。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gc
6
+ import logging
7
+ import os
8
+ import types
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ logger = logging.getLogger("ltx_low_vram")
13
+
14
+
15
+ def _ltx_desktop_config_dir() -> Path:
16
+ p = (
17
+ Path(os.environ.get("LOCALAPPDATA", os.path.expanduser("~/AppData/Local")))
18
+ / "LTXDesktop"
19
+ )
20
+ p.mkdir(parents=True, exist_ok=True)
21
+ return p.resolve()
22
+
23
+
24
+ def low_vram_pref_path() -> Path:
25
+ return _ltx_desktop_config_dir() / "low_vram_mode.pref"
26
+
27
+
28
+ def read_low_vram_pref() -> bool:
29
+ f = low_vram_pref_path()
30
+ if not f.is_file():
31
+ return False
32
+ return f.read_text(encoding="utf-8").strip().lower() in ("1", "true", "yes", "on")
33
+
34
+
35
+ def write_low_vram_pref(enabled: bool) -> None:
36
+ low_vram_pref_path().write_text(
37
+ "true\n" if enabled else "false\n", encoding="utf-8"
38
+ )
39
+
40
+
41
+ def apply_low_vram_config_tweaks(handler: Any) -> None:
42
+ """在官方 RuntimeConfig 上尽量关闭 fast 超分等(若字段存在)。"""
43
+ cfg = getattr(handler, "config", None)
44
+ if cfg is None:
45
+ return
46
+ fm = getattr(cfg, "fast_model", None)
47
+ if fm is None:
48
+ return
49
+ try:
50
+ if hasattr(fm, "model_copy"):
51
+ updated = fm.model_copy(update={"use_upscaler": False})
52
+ setattr(cfg, "fast_model", updated)
53
+ elif hasattr(fm, "use_upscaler"):
54
+ setattr(fm, "use_upscaler", False)
55
+ except Exception as e:
56
+ logger.debug("low_vram: 无法关闭 fast_model.use_upscaler: %s", e)
57
+
58
+
59
+ def install_low_vram_on_pipelines(handler: Any) -> None:
60
+ """启动时读取偏好,挂到 pipelines 上供各补丁读取。"""
61
+ pl = handler.pipelines
62
+ low = read_low_vram_pref()
63
+ setattr(pl, "low_vram_mode", bool(low))
64
+ if low:
65
+ apply_low_vram_config_tweaks(handler)
66
+ logger.info(
67
+ "low_vram_mode: 已开启(尝试关闭 fast 超分;若显存仍高,多为权重常驻 GPU,需降分辨率/时长或 FP8 权重)"
68
+ )
69
+
70
+
71
+ def install_low_vram_pipeline_hooks(pl: Any) -> None:
72
+ """在 load_gpu_pipeline / load_a2v 返回后尝试 Diffusers 式 CPU offload(无则静默)。"""
73
+ if getattr(pl, "_ltx_low_vram_hooks_installed", False):
74
+ return
75
+ pl._ltx_low_vram_hooks_installed = True
76
+
77
+ if hasattr(pl, "load_gpu_pipeline"):
78
+ _orig_gpu = pl.load_gpu_pipeline
79
+ pl._ltx_orig_load_gpu_for_low_vram = _orig_gpu
80
+
81
+ def _load_gpu_wrapped(self: Any, *a: Any, **kw: Any) -> Any:
82
+ r = _orig_gpu(*a, **kw)
83
+ if getattr(self, "low_vram_mode", False):
84
+ try_sequential_offload_on_pipeline_state(r)
85
+ return r
86
+
87
+ pl.load_gpu_pipeline = types.MethodType(_load_gpu_wrapped, pl)
88
+
89
+ if hasattr(pl, "load_a2v_pipeline"):
90
+ _orig_a2v = pl.load_a2v_pipeline
91
+ pl._ltx_orig_load_a2v_for_low_vram = _orig_a2v
92
+
93
+ def _load_a2v_wrapped(self: Any, *a: Any, **kw: Any) -> Any:
94
+ r = _orig_a2v(*a, **kw)
95
+ if getattr(self, "low_vram_mode", False):
96
+ try_sequential_offload_on_pipeline_state(r)
97
+ return r
98
+
99
+ pl.load_a2v_pipeline = types.MethodType(_load_a2v_wrapped, pl)
100
+
101
+
102
+ def try_sequential_offload_on_pipeline_state(state: Any) -> None:
103
+ """若底层为 Diffusers 风格 API,尝试按层 CPU offload(显著变慢、降峰值)。"""
104
+ if state is None:
105
+ return
106
+ root = getattr(state, "pipeline", state)
107
+ candidates: list[Any] = [root]
108
+ inner = getattr(root, "pipeline", None)
109
+ if inner is not None and inner is not root:
110
+ candidates.append(inner)
111
+ for obj in candidates:
112
+ for method_name in (
113
+ "enable_sequential_cpu_offload",
114
+ "enable_model_cpu_offload",
115
+ ):
116
+ fn = getattr(obj, method_name, None)
117
+ if callable(fn):
118
+ try:
119
+ fn()
120
+ logger.info(
121
+ "low_vram_mode: 已对管线调用 %s()",
122
+ method_name,
123
+ )
124
+ return
125
+ except Exception as e:
126
+ logger.debug(
127
+ "low_vram_mode: %s() 失败(可忽略): %s",
128
+ method_name,
129
+ e,
130
+ )
131
+
132
+
133
+ def maybe_release_pipeline_after_task(handler: Any) -> None:
134
+ """单次生成结束后:低显存模式下强制卸载管线并回收缓存。"""
135
+ pl = getattr(handler, "pipelines", None) or getattr(handler, "_pipelines", None)
136
+ if pl is None or not getattr(pl, "low_vram_mode", False):
137
+ return
138
+ try:
139
+ from keep_models_runtime import force_unload_gpu_pipeline
140
+
141
+ force_unload_gpu_pipeline(pl)
142
+ except Exception as e:
143
+ logger.debug("low_vram_mode: 任务后卸载失败: %s", e)
144
+ try:
145
+ pl._pipeline_signature = None
146
+ except Exception:
147
+ pass
148
+ gc.collect()
149
+ try:
150
+ import torch
151
+
152
+ if torch.cuda.is_available():
153
+ torch.cuda.empty_cache()
154
+ except Exception:
155
+ pass
LTX2.3/patches/runtime_policy.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runtime policy decisions for forced API mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def decide_force_api_generations(
7
+ system: str, cuda_available: bool, vram_gb: int | None
8
+ ) -> bool:
9
+ """Return whether API-only generation must be forced for this runtime."""
10
+ if system == "Darwin":
11
+ return True
12
+
13
+ if system in ("Windows", "Linux"):
14
+ if not cuda_available:
15
+ return True
16
+ if vram_gb is None:
17
+ return True
18
+ return vram_gb < 6
19
+
20
+ # Fail closed for non-target platforms unless explicitly relaxed.
21
+ return True
LTX2.3/patches/settings.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "use_torch_compile": false,
3
+ "load_on_startup": false,
4
+ "ltx_api_key": "1231",
5
+ "user_prefers_ltx_api_video_generations": false,
6
+ "fal_api_key": "",
7
+ "use_local_text_encoder": true,
8
+ "fast_model": {
9
+ "use_upscaler": true
10
+ },
11
+ "pro_model": {
12
+ "steps": 20,
13
+ "use_upscaler": true
14
+ },
15
+ "prompt_cache_size": 100,
16
+ "prompt_enhancer_enabled_t2v": true,
17
+ "prompt_enhancer_enabled_i2v": false,
18
+ "gemini_api_key": "",
19
+ "seed_locked": false,
20
+ "locked_seed": 42,
21
+ "models_dir": ""
22
+ }
LTX2.3/run.bat ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ title LTX-2 Cinematic Workstation
3
+
4
+ echo =========================================================
5
+ echo LTX-2 Cinematic UI Booting...
6
+ echo =========================================================
7
+ echo.
8
+
9
+ set "LTX_PY=%USERPROFILE%\AppData\Local\LTXDesktop\python\python.exe"
10
+ set "LTX_UI_URL=http://127.0.0.1:4000/"
11
+
12
+ if exist "%LTX_PY%" (
13
+ echo [SUCCESS] LTX Bundled Python environment detected!
14
+ echo [INFO] Browser will open automatically when UI is ready...
15
+ start "" powershell -NoProfile -WindowStyle Hidden -Command "$ProgressPreference='SilentlyContinue'; $deadline=(Get-Date).AddSeconds(60); while((Get-Date) -lt $deadline){ try { Invoke-WebRequest -UseBasicParsing '%LTX_UI_URL%' -TimeoutSec 2 | Out-Null; Start-Process '%LTX_UI_URL%'; exit 0 } catch { Start-Sleep -Seconds 1 } }"
16
+ echo [INFO] Starting workspace natively...
17
+ echo ---------------------------------------------------------
18
+ "%LTX_PY%" main.py
19
+ pause
20
+ exit /b
21
+ )
22
+
23
+ python --version >nul 2>&1
24
+ if %errorlevel% equ 0 (
25
+ echo [WARNING] LTX Bundled Python not found.
26
+ echo [INFO] Browser will open automatically when UI is ready...
27
+ start "" powershell -NoProfile -WindowStyle Hidden -Command "$ProgressPreference='SilentlyContinue'; $deadline=(Get-Date).AddSeconds(60); while((Get-Date) -lt $deadline){ try { Invoke-WebRequest -UseBasicParsing '%LTX_UI_URL%' -TimeoutSec 2 | Out-Null; Start-Process '%LTX_UI_URL%'; exit 0 } catch { Start-Sleep -Seconds 1 } }"
28
+ echo [INFO] Falling back to global Python environment...
29
+ echo ---------------------------------------------------------
30
+ python main.py
31
+ pause
32
+ exit /b
33
+ )
34
+
35
+ echo [ERROR] FATAL: No Python interpreter found on this system.
36
+ echo [INFO] Please run install.bat to download and set up Python!
37
+ echo.
38
+ pause