diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000000000000000000000000000000000000..d4c653badaf31a15911d63eb0557d8bf49b616fd
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,37 @@
+*.7z filter=lfs diff=lfs merge=lfs -text
+*.arrow filter=lfs diff=lfs merge=lfs -text
+*.bin filter=lfs diff=lfs merge=lfs -text
+*.bz2 filter=lfs diff=lfs merge=lfs -text
+*.ckpt filter=lfs diff=lfs merge=lfs -text
+*.ftz filter=lfs diff=lfs merge=lfs -text
+*.gz filter=lfs diff=lfs merge=lfs -text
+*.h5 filter=lfs diff=lfs merge=lfs -text
+*.joblib filter=lfs diff=lfs merge=lfs -text
+*.lfs.* filter=lfs diff=lfs merge=lfs -text
+*.mlmodel filter=lfs diff=lfs merge=lfs -text
+*.model filter=lfs diff=lfs merge=lfs -text
+*.msgpack filter=lfs diff=lfs merge=lfs -text
+*.npy filter=lfs diff=lfs merge=lfs -text
+*.npz filter=lfs diff=lfs merge=lfs -text
+*.onnx filter=lfs diff=lfs merge=lfs -text
+*.ot filter=lfs diff=lfs merge=lfs -text
+*.parquet filter=lfs diff=lfs merge=lfs -text
+*.pb filter=lfs diff=lfs merge=lfs -text
+*.pickle filter=lfs diff=lfs merge=lfs -text
+*.pkl filter=lfs diff=lfs merge=lfs -text
+*.pt filter=lfs diff=lfs merge=lfs -text
+*.pth filter=lfs diff=lfs merge=lfs -text
+*.rar filter=lfs diff=lfs merge=lfs -text
+*.safetensors filter=lfs diff=lfs merge=lfs -text
+saved_model/**/* filter=lfs diff=lfs merge=lfs -text
+*.tar.* filter=lfs diff=lfs merge=lfs -text
+*.tar filter=lfs diff=lfs merge=lfs -text
+*.tflite filter=lfs diff=lfs merge=lfs -text
+*.tgz filter=lfs diff=lfs merge=lfs -text
+*.wasm filter=lfs diff=lfs merge=lfs -text
+*.xz filter=lfs diff=lfs merge=lfs -text
+*.zip filter=lfs diff=lfs merge=lfs -text
+*.zst filter=lfs diff=lfs merge=lfs -text
+*tfevents* filter=lfs diff=lfs merge=lfs -text
+LTX-Desktop-Setup.exe filter=lfs diff=lfs merge=lfs -text
+LTX2.3-1.0.3/patches/__pycache__/app_factory.cpython-313.pyc filter=lfs diff=lfs merge=lfs -text
diff --git "a/LTX2.3-1.0.3/API issues-API\351\227\256\351\242\230\345\212\236\346\263\225.txt" "b/LTX2.3-1.0.3/API issues-API\351\227\256\351\242\230\345\212\236\346\263\225.txt"
new file mode 100644
index 0000000000000000000000000000000000000000..b8de0b7d531a2e439f7c66ee8021f8e3f6be05a3
--- /dev/null
+++ "b/LTX2.3-1.0.3/API issues-API\351\227\256\351\242\230\345\212\236\346\263\225.txt"
@@ -0,0 +1,35 @@
+1. 复制LTX桌面版的快捷方式到LTX_Shortcut
+
+2. 运行run.bat
+----
+1. Copy the LTX desktop shortcut to LTX_Shortcut
+
+2. Run run.bat
+----
+
+
+
+【问题描述 / Problem】
+系统强制使用 FAL API 生成图片,即使本地有 GPU 可用。
+System forces FAL API generation even when local GPU is available.
+
+
+1. 修改 VRAM 阈值 / Modify VRAM Threshold
+ 文件路径 / File: C:\Program Files\LTX Desktop\resources\backend\runtime_config\runtime_policy.py
+ 第16行 / Line 16:
+ 原 / Original: return vram_gb < 31
+ 改为 / Change: return vram_gb < 6
+
+2. 清空 API Key / Clear API Key
+ 文件路径 / File: C:\Users\<用户名>\AppData\Local\LTXDesktop\settings.json
+ 原 / Original: "fal_api_key": "xxxxx"
+ 改为 / Change: "fal_api_key": ""
+
+【说明 / Note】
+- VRAM 阈值改为 6GB,意味着 6GB 及以上显存都会使用本地显卡
+- VRAM threshold set to 6GB means 6GB+ VRAM will use local GPU
+- 清空 fal_api_key 避免系统误判为已配置 API
+- Clear fal_api_key to avoid system thinking API is configured
+- 修改后重启程序即可生效
+- Restart LTX Desktop after changes
+================================================================================
diff --git a/LTX2.3-1.0.3/LTX_Shortcut/LTX Desktop.lnk b/LTX2.3-1.0.3/LTX_Shortcut/LTX Desktop.lnk
new file mode 100644
index 0000000000000000000000000000000000000000..897e2a372e1c67ffc6ee36cc2b8f92feb8bcb377
Binary files /dev/null and b/LTX2.3-1.0.3/LTX_Shortcut/LTX Desktop.lnk differ
diff --git a/LTX2.3-1.0.3/UI/i18n.js b/LTX2.3-1.0.3/UI/i18n.js
new file mode 100644
index 0000000000000000000000000000000000000000..57eed20ae2027a02d94e3b7b5e412205b21413e3
--- /dev/null
+++ b/LTX2.3-1.0.3/UI/i18n.js
@@ -0,0 +1,432 @@
+/**
+ * LTX UI i18n — 与根目录「中英文.html」思路类似,但独立脚本、避免坏 DOM/错误路径。
+ * 仅维护文案映射;动态节点由 index.js 在语言切换后刷新。
+ */
+(function (global) {
+ const STORAGE_KEY = 'ltx_ui_lang';
+
+ const STR = {
+ zh: {
+ tabVideo: '视频生成',
+ tabBatch: '智能多帧',
+ tabUpscale: '视频增强',
+ tabImage: '图像生成',
+ promptLabel: '视觉描述词 (Prompt)',
+ promptPlaceholder: '在此输入视觉描述词 (Prompt)...',
+ promptPlaceholderUpscale: '输入画面增强引导词 (可选)...',
+ clearVram: '释放显存',
+ clearingVram: '清理中...',
+ settingsTitle: '系统高级设置',
+ langToggleAriaZh: '切换为 English',
+ langToggleAriaEn: 'Switch to 中文',
+ sysScanning: '正在扫描 GPU...',
+ sysBusy: '运算中...',
+ sysOnline: '在线 / 就绪',
+ sysStarting: '启动中...',
+ sysOffline: '未检测到后端 (Port 3000)',
+ advancedSettings: '高级设置',
+ deviceSelect: '工作设备选择',
+ gpuDetecting: '正在检测 GPU...',
+ outputPath: '输出与上传存储路径',
+ outputPathPh: '例如: D:\\LTX_outputs',
+ savePath: '保存路径',
+ outputPathHint:
+ '系统默认会在 C 盘保留输出文件。请输入新路径后点击保存按钮。',
+ lowVram: '低显存优化',
+ lowVramDesc:
+ '尽量关闭 fast 超分、在加载管线后尝试 CPU 分层卸载(仅当引擎提供 Diffusers 式 API 才可能生效)。每次生成结束会卸载管线。说明:整模型常驻 GPU 时占用仍可能接近满配(例如约 24GB),要明显降占用需更短时长/更低分辨率或 FP8 等小权重。',
+ modelLoraSettings: '模型与LoRA设置',
+ modelFolder: '模型文件夹',
+ modelFolderPh: '例如: F:\\LTX2.3\\models',
+ loraFolder: 'LoRA文件夹',
+ loraFolderPh: '例如: F:\\LTX2.3\\loras',
+ loraFolderPath: 'LoRA 文件夹路径',
+ loraFolderPathPlaceholder: '留空使用默认路径',
+ saveScan: '保存并扫描',
+ loraPlacementHintWithDir:
+ '将 LoRA 文件放到默认模型目录: {dir}\\loras',
+ basicEngine: '基础画面 / Basic EngineSpecs',
+ qualityLevel: '清晰度级别',
+ aspectRatio: '画幅比例',
+ ratio169: '16:9 电影宽幅',
+ ratio916: '9:16 移动竖屏',
+ resPreviewPrefix: '最终发送规格',
+ fpsLabel: '帧率 (FPS)',
+ durationLabel: '时长 (秒)',
+ cameraMotion: '镜头运动方式',
+ motionStatic: 'Static (静止机位)',
+ motionDollyIn: 'Dolly In (推近)',
+ motionDollyOut: 'Dolly Out (拉远)',
+ motionDollyLeft: 'Dolly Left (向左)',
+ motionDollyRight: 'Dolly Right (向右)',
+ motionJibUp: 'Jib Up (升臂)',
+ motionJibDown: 'Jib Down (降臂)',
+ motionFocus: 'Focus Shift (焦点)',
+ audioGen: '生成 AI 环境音 (Audio Gen)',
+ selectModel: '选择模型',
+ selectLora: '选择 LoRA',
+ defaultModel: '使用默认模型',
+ noLora: '不使用 LoRA',
+ loraStrength: 'LoRA 强度',
+ genSource: '生成媒介 / Generation Source',
+ startFrame: '起始帧 (首帧)',
+ endFrame: '结束帧 (尾帧)',
+ uploadStart: '上传首帧',
+ uploadEnd: '上传尾帧 (可选)',
+ refAudio: '参考音频 (A2V)',
+ uploadAudio: '点击上传音频',
+ sourceHint:
+ '💡 若仅上传首帧 = 图生视频/音视频;若同时上传首尾帧 = 首尾插帧。',
+ imgPreset: '预设分辨率 (Presets)',
+ imgOptSquare: '1:1 Square (1024x1024)',
+ imgOptLand: '16:9 Landscape (1280x720)',
+ imgOptPort: '9:16 Portrait (720x1280)',
+ imgOptCustom: 'Custom 自定义...',
+ width: '宽度',
+ height: '高度',
+ samplingSteps: '采样步数 (Steps)',
+ upscaleSource: '待超分视频 (Source)',
+ upscaleUpload: '拖入低分辨率视频片段',
+ targetRes: '目标分辨率',
+ upscale1080: '1080P Full HD (2x)',
+ upscale720: '720P HD',
+ smartMultiFrameGroup: '智能多帧',
+ workflowModeLabel: '工作流模式(点击切换)',
+ wfSingle: '单次多关键帧',
+ wfSegments: '分段拼接',
+ uploadImages: '上传图片',
+ uploadMulti1: '点击或拖入多张图片',
+ uploadMulti2: '支持一次选多张,可多次添加',
+ batchStripTitle: '已选图片 · 顺序 = 播放先后',
+ batchStripHint: '在缩略图上按住拖动排序;松手落入虚线框位置',
+ batchFfmpegHint:
+ '💡 分段模式:2 张 = 1 段;3 张 = 2 段再拼接。单次模式:几张图就几个 latent 锚点,一条视频出片。
多段需 ffmpeg:装好后加 PATH,或设环境变量 LTX_FFMPEG_PATH,或在 %LOCALAPPDATA%\\LTXDesktop\\ffmpeg_path.txt 第一行写 ffmpeg.exe 完整路径。',
+ globalPromptLabel: '本页全局补充词(可选)',
+ globalPromptPh: '与顶部主 Prompt 叠加;单次模式与分段模式均可用',
+ bgmLabel: '成片配乐(可选,统一音轨)',
+ bgmUploadHint: '上传一条完整 BGM(生成完成后会替换整段成片的音轨)',
+ mainRender: '开始渲染',
+ waitingTask: '等待分配渲染任务...',
+ libHistory: '历史资产 / ASSETS',
+ libLog: '系统日志 / LOGS',
+ refresh: '刷新',
+ logReady: '> LTX-2 Studio Ready. Expecting commands...',
+ resizeHandleTitle: '拖动调整面板高度',
+ batchNeedTwo: '💡 请上传至少2张图片',
+ batchSegTitle: '视频片段设置(分段拼接)',
+ batchSegClip: '片段',
+ batchSegDuration: '时长',
+ batchSegSec: '秒',
+ batchSegPrompt: '片段提示词',
+ batchSegPromptPh: '此片段的提示词,如:跳舞、吃饭...',
+ batchKfPanelTitle: '单次多关键帧 · 时间轴',
+ batchTotalDur: '总时长',
+ batchTotalSec: '秒',
+ batchPanelHint:
+ '用「间隔」连接相邻关键帧:第 1 张固定在 0 s,最后一张在各间隔之和的终点。顶部总时长与每张的锚点时刻会随间隔即时刷新。因后端按整数秒建序列,实际请求里的整段时长为合计秒数向上取整(至少 2),略长于小数合计时属正常。镜头与 FPS 仍用左侧「视频生成」。',
+ batchKfTitle: '关键帧',
+ batchStrength: '引导强度',
+ batchGapTitle: '间隔',
+ batchSec: '秒',
+ batchAnchorStart: '片头',
+ batchAnchorEnd: '片尾',
+ batchThumbDrag: '按住拖动排序',
+ batchThumbRemove: '删除',
+ batchAddMore: '+ 继续添加',
+ batchGapInputTitle: '上一关键帧到下一关键帧的时长(秒);总时长 = 各间隔之和',
+ batchStrengthTitle: '与 Comfy guide strength 类似,中间帧可调低(如 0.2)减轻闪烁',
+ batchTotalPillTitle: '等于下方各「间隔」之和,无需单独填写',
+ defaultPath: '默认路径',
+ phase_loading_model: '加载权重',
+ phase_encoding_text: 'T5 编码',
+ phase_validating_request: '校验请求',
+ phase_uploading_audio: '上传音频',
+ phase_uploading_image: '上传图像',
+ phase_inference: 'AI 推理',
+ phase_downloading_output: '下载结果',
+ phase_complete: '完成',
+ gpuBusyPrefix: 'GPU 运算中',
+ progressStepUnit: '步',
+ loaderGpuAlloc: 'GPU 正在分配资源...',
+ warnGenerating: '⚠️ 当前正在生成中,请等待完成',
+ warnBatchPrompt: '⚠️ 智能多帧请至少填写:顶部主提示词、本页全局补充词或某一「片段提示词」',
+ warnNeedPrompt: '⚠️ 请输入提示词后再开始渲染',
+ warnVideoLong: '⚠️ 时长设定为 {n}s 极长,可能导致显存溢出或耗时较久。',
+ errUpscaleNoVideo: '请先上传待超分的视频',
+ errBatchMinImages: '请上传至少2张图片',
+ errSingleKfPrompt: '单次多关键帧请至少填写顶部主提示词或本页全局补充词',
+ loraNoneLabel: '无',
+ modelDefaultLabel: '默认',
+ },
+ en: {
+ tabVideo: 'Video',
+ tabBatch: 'Multi-frame',
+ tabUpscale: 'Upscale',
+ tabImage: 'Image',
+ promptLabel: 'Prompt',
+ promptPlaceholder: 'Describe the scene...',
+ promptPlaceholderUpscale: 'Optional guidance for enhancement...',
+ clearVram: 'Clear VRAM',
+ clearingVram: 'Clearing...',
+ settingsTitle: 'Advanced settings',
+ langToggleAriaZh: 'Switch to English',
+ langToggleAriaEn: 'Switch to Chinese',
+ sysScanning: 'Scanning GPU...',
+ sysBusy: 'Busy...',
+ sysOnline: 'Online / Ready',
+ sysStarting: 'Starting...',
+ sysOffline: 'Backend offline (port 3000)',
+ advancedSettings: 'Advanced',
+ deviceSelect: 'GPU device',
+ gpuDetecting: 'Detecting GPU...',
+ outputPath: 'Output & upload folder',
+ outputPathPh: 'e.g. D:\\LTX_outputs',
+ savePath: 'Save path',
+ outputPathHint:
+ 'Outputs default to C: drive. Enter a folder and click Save.',
+ lowVram: 'Low-VRAM mode',
+ lowVramDesc:
+ 'Tries to reduce VRAM (engine-dependent). Shorter duration / lower resolution helps more.',
+ modelLoraSettings: 'Model & LoRA folders',
+ modelFolder: 'Models folder',
+ modelFolderPh: 'e.g. F:\\LTX2.3\\models',
+ loraFolder: 'LoRAs folder',
+ loraFolderPh: 'e.g. F:\\LTX2.3\\loras',
+ loraFolderPath: 'LoRA folder path',
+ loraFolderPathPlaceholder: 'Leave empty for default path',
+ saveScan: 'Save & scan',
+ loraHint: 'Put .safetensors / .ckpt LoRAs here, then refresh lists.',
+ basicEngine: 'Basic / Engine',
+ qualityLevel: 'Quality',
+ aspectRatio: 'Aspect ratio',
+ ratio169: '16:9 widescreen',
+ ratio916: '9:16 portrait',
+ resPreviewPrefix: 'Output',
+ fpsLabel: 'FPS',
+ durationLabel: 'Duration (s)',
+ cameraMotion: 'Camera motion',
+ motionStatic: 'Static',
+ motionDollyIn: 'Dolly in',
+ motionDollyOut: 'Dolly out',
+ motionDollyLeft: 'Dolly left',
+ motionDollyRight: 'Dolly right',
+ motionJibUp: 'Jib up',
+ motionJibDown: 'Jib down',
+ motionFocus: 'Focus shift',
+ audioGen: 'AI ambient audio',
+ selectModel: 'Model',
+ selectLora: 'LoRA',
+ defaultModel: 'Default model',
+ noLora: 'No LoRA',
+ loraStrength: 'LoRA strength',
+ genSource: 'Source media',
+ startFrame: 'Start frame',
+ endFrame: 'End frame (optional)',
+ uploadStart: 'Upload start',
+ uploadEnd: 'Upload end (opt.)',
+ refAudio: 'Reference audio (A2V)',
+ uploadAudio: 'Upload audio',
+ sourceHint:
+ '💡 Start only = I2V / A2V; start + end = interpolation.',
+ imgPreset: 'Resolution presets',
+ imgOptSquare: '1:1 (1024×1024)',
+ imgOptLand: '16:9 (1280×720)',
+ imgOptPort: '9:16 (720×1280)',
+ imgOptCustom: 'Custom...',
+ width: 'Width',
+ height: 'Height',
+ samplingSteps: 'Steps',
+ upscaleSource: 'Source video',
+ upscaleUpload: 'Drop low-res video',
+ targetRes: 'Target resolution',
+ upscale1080: '1080p Full HD (2×)',
+ upscale720: '720p HD',
+ smartMultiFrameGroup: 'Smart multi-frame',
+ workflowModeLabel: 'Workflow',
+ wfSingle: 'Single pass',
+ wfSegments: 'Segments',
+ uploadImages: 'Upload images',
+ uploadMulti1: 'Click or drop multiple images',
+ uploadMulti2: 'Multi-select OK; add more anytime.',
+ batchStripTitle: 'Order = playback',
+ batchStripHint: 'Drag thumbnails to reorder.',
+ batchFfmpegHint:
+ '💡 Segments: 2 images → 1 clip; 3 → 2 clips stitched. Single: N images → N latent anchors, one video.
Stitching needs ffmpeg on PATH, or LTX_FFMPEG_PATH, or %LOCALAPPDATA%\\LTXDesktop\\ffmpeg_path.txt with full path to ffmpeg.exe.',
+ globalPromptLabel: 'Extra prompt (optional)',
+ globalPromptPh: 'Appended to main prompt for both modes.',
+ bgmLabel: 'Full-length BGM (optional)',
+ bgmUploadHint: 'Replaces final mix audio after generation.',
+ mainRender: 'Render',
+ waitingTask: 'Waiting for task...',
+ libHistory: 'Assets',
+ libLog: 'Logs',
+ refresh: 'Refresh',
+ logReady: '> LTX-2 Studio ready.',
+ resizeHandleTitle: 'Drag to resize panel',
+ batchNeedTwo: '💡 Upload at least 2 images',
+ batchSegTitle: 'Segment settings',
+ batchSegClip: 'Clip',
+ batchSegDuration: 'Duration',
+ batchSegSec: 's',
+ batchSegPrompt: 'Prompt',
+ batchSegPromptPh: 'e.g. dancing, walking...',
+ batchKfPanelTitle: 'Single pass · timeline',
+ batchTotalDur: 'Total',
+ batchTotalSec: 's',
+ batchPanelHint:
+ '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.',
+ batchKfTitle: 'Keyframe',
+ batchStrength: 'Strength',
+ batchGapTitle: 'Gap',
+ batchSec: 's',
+ batchAnchorStart: 'start',
+ batchAnchorEnd: 'end',
+ batchThumbDrag: 'Drag to reorder',
+ batchThumbRemove: 'Remove',
+ batchAddMore: '+ Add more',
+ batchGapInputTitle: 'Seconds between keyframes; total = sum of gaps',
+ batchStrengthTitle: 'Guide strength (lower on middle keys may reduce flicker)',
+ batchTotalPillTitle: 'Equals the sum of gaps below',
+ defaultPath: 'default',
+ phase_loading_model: 'Loading weights',
+ phase_encoding_text: 'T5 encode',
+ phase_validating_request: 'Validating',
+ phase_uploading_audio: 'Uploading audio',
+ phase_uploading_image: 'Uploading image',
+ phase_inference: 'Inference',
+ phase_downloading_output: 'Downloading',
+ phase_complete: 'Done',
+ gpuBusyPrefix: 'GPU',
+ progressStepUnit: 'steps',
+ loaderGpuAlloc: 'Allocating GPU...',
+ warnGenerating: '⚠️ Already generating, please wait.',
+ warnBatchPrompt: '⚠️ Enter main prompt, page extra prompt, or a segment prompt.',
+ warnNeedPrompt: '⚠️ Enter a prompt first.',
+ warnVideoLong: '⚠️ Duration {n}s is very long; may OOM or take a long time.',
+ errUpscaleNoVideo: 'Upload a video to upscale first.',
+ errBatchMinImages: 'Upload at least 2 images.',
+ errSingleKfNeedPrompt: 'Enter main or page extra prompt for single-pass keyframes.',
+ loraNoneLabel: 'none',
+ modelDefaultLabel: 'default',
+ loraPlacementHintWithDir:
+ 'Place LoRAs into the default models directory: {dir}\\loras',
+ },
+ };
+
+ function getLang() {
+ return localStorage.getItem(STORAGE_KEY) === 'en' ? 'en' : 'zh';
+ }
+
+ function setLang(lang) {
+ const L = lang === 'en' ? 'en' : 'zh';
+ localStorage.setItem(STORAGE_KEY, L);
+ document.documentElement.lang = L === 'en' ? 'en' : 'zh-CN';
+ try {
+ applyI18n();
+ } catch (err) {
+ console.error('[i18n] applyI18n failed:', err);
+ }
+ updateLangButton();
+ if (typeof global.onUiLanguageChanged === 'function') {
+ try {
+ global.onUiLanguageChanged();
+ } catch (e) {
+ console.warn('onUiLanguageChanged', e);
+ }
+ }
+ }
+
+ function t(key) {
+ const L = getLang();
+ const table = STR[L] || STR.zh;
+ if (Object.prototype.hasOwnProperty.call(table, key)) return table[key];
+ if (Object.prototype.hasOwnProperty.call(STR.zh, key)) return STR.zh[key];
+ return key;
+ }
+
+ function applyI18n(root) {
+ root = root || document;
+ root.querySelectorAll('[data-i18n]').forEach(function (el) {
+ var key = el.getAttribute('data-i18n');
+ if (!key) return;
+ if (el.tagName === 'OPTION') {
+ el.textContent = t(key);
+ } else {
+ el.textContent = t(key);
+ }
+ });
+ root.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
+ var key = el.getAttribute('data-i18n-placeholder');
+ if (key) el.placeholder = t(key);
+ });
+ root.querySelectorAll('[data-i18n-title]').forEach(function (el) {
+ var key = el.getAttribute('data-i18n-title');
+ if (key) el.title = t(key);
+ });
+ root.querySelectorAll('[data-i18n-html]').forEach(function (el) {
+ var key = el.getAttribute('data-i18n-html');
+ if (key) el.innerHTML = t(key);
+ });
+ root.querySelectorAll('[data-i18n-value]').forEach(function (el) {
+ var key = el.getAttribute('data-i18n-value');
+ if (key && (el.tagName === 'INPUT' || el.tagName === 'BUTTON')) {
+ el.value = t(key);
+ }
+ });
+ }
+
+ function updateLangButton() {
+ var btn = document.getElementById('lang-toggle-btn');
+ if (!btn) return;
+ btn.textContent = getLang() === 'zh' ? 'EN' : '中';
+ btn.setAttribute(
+ 'aria-label',
+ getLang() === 'zh' ? t('langToggleAriaZh') : t('langToggleAriaEn')
+ );
+ btn.classList.toggle('active', getLang() === 'en');
+ }
+
+ function toggleUiLanguage() {
+ try {
+ setLang(getLang() === 'zh' ? 'en' : 'zh');
+ } catch (err) {
+ console.error('[i18n] toggleUiLanguage failed:', err);
+ }
+ }
+
+ /** 避免 CSP 拦截内联 onclick;确保按钮一定能触发 */
+ function bindLangToggleButton() {
+ var btn = document.getElementById('lang-toggle-btn');
+ if (!btn || btn.dataset.i18nBound === '1') return;
+ btn.dataset.i18nBound = '1';
+ btn.removeAttribute('onclick');
+ btn.addEventListener('click', function (ev) {
+ ev.preventDefault();
+ toggleUiLanguage();
+ });
+ }
+
+ function boot() {
+ document.documentElement.lang = getLang() === 'en' ? 'en' : 'zh-CN';
+ try {
+ applyI18n();
+ } catch (err) {
+ console.error('[i18n] applyI18n failed:', err);
+ }
+ updateLangButton();
+ bindLangToggleButton();
+ }
+
+ global.getUiLang = getLang;
+ global.setUiLang = setLang;
+ global.t = t;
+ global.applyI18n = applyI18n;
+ global.toggleUiLanguage = toggleUiLanguage;
+ global.updateLangToggleButton = updateLangButton;
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', boot);
+ } else {
+ boot();
+ }
+})(typeof window !== 'undefined' ? window : global);
diff --git a/LTX2.3-1.0.3/UI/index.css b/LTX2.3-1.0.3/UI/index.css
new file mode 100644
index 0000000000000000000000000000000000000000..88be9566f2618d90839f7823236ab92b22e788d4
--- /dev/null
+++ b/LTX2.3-1.0.3/UI/index.css
@@ -0,0 +1,775 @@
+:root {
+ --accent: #2563EB; /* Refined blue – not too bright, not purple */
+ --accent-hover:#3B82F6;
+ --accent-dim: rgba(37,99,235,0.14);
+ --accent-ring: rgba(37,99,235,0.35);
+ --bg: #111113;
+ --panel: #18181B;
+ --panel-2: #1F1F23;
+ --item: rgba(255,255,255,0.035);
+ --border: rgba(255,255,255,0.08);
+ --border-2: rgba(255,255,255,0.05);
+ --text-dim: #71717A;
+ --text-sub: #A1A1AA;
+ --text: #FAFAFA;
+ }
+
+ * { box-sizing: border-box; -webkit-font-smoothing: antialiased; min-width: 0; }
+ body {
+ background: var(--bg); margin: 0; color: var(--text);
+ font-family: -apple-system, "SF Pro Display", "Segoe UI", sans-serif;
+ display: flex; height: 100vh; overflow: hidden;
+ font-size: 13px; line-height: 1.5;
+ }
+
+ .sidebar {
+ width: 460px; min-width: 460px;
+ background: var(--panel);
+ border-right: 1px solid var(--border);
+ display: flex; flex-direction: column; z-index: 20;
+ overflow-y: auto; overflow-x: hidden;
+ }
+
+ /* Scrollbar */
+ ::-webkit-scrollbar { width: 5px; height: 5px; }
+ ::-webkit-scrollbar-track { background: transparent; }
+ ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 10px; }
+ ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.18); }
+
+ .sidebar-header { padding: 24px 24px 4px; }
+
+ .lang-toggle {
+ background: #333;
+ border: 1px solid #555;
+ color: var(--text-dim);
+ padding: 4px 10px;
+ border-radius: 6px;
+ font-size: 11px;
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
+ font-weight: 700;
+ min-width: 44px;
+ flex-shrink: 0;
+ }
+ .lang-toggle:hover {
+ background: var(--item);
+ color: var(--text);
+ border-color: var(--accent);
+ }
+ .lang-toggle.active {
+ background: #333;
+ color: var(--text);
+ border-color: #555;
+ }
+ .sidebar-section { padding: 8px 24px 18px; border-bottom: 1px solid var(--border); }
+
+ .setting-group {
+ background: rgba(255,255,255,0.025);
+ border: 1px solid var(--border-2);
+ border-radius: 10px;
+ padding: 14px;
+ margin-bottom: 12px;
+ }
+ .group-title {
+ font-size: 10px; color: var(--text-dim); font-weight: 700;
+ text-transform: uppercase; letter-spacing: 0.7px;
+ margin-bottom: 12px; padding-bottom: 5px;
+ border-bottom: 1px solid var(--border-2);
+ }
+
+ /* Mode Tabs */
+ .tabs {
+ display: flex; gap: 4px; margin-bottom: 14px;
+ background: rgba(255,255,255,0.04);
+ padding: 4px; border-radius: 10px;
+ border: 1px solid var(--border-2);
+ }
+ .tab {
+ flex: 1; padding: 9px 0; text-align: center; border-radius: 7px;
+ cursor: pointer; font-size: 12px; color: var(--text-dim);
+ transition: all 0.2s; font-weight: 600;
+ display: flex; align-items: center; justify-content: center;
+ }
+ .tab.active { background: var(--accent); color: #fff; box-shadow: 0 1px 6px rgba(10,132,255,0.45); }
+ .tab:hover:not(.active) { background: rgba(255,255,255,0.06); color: var(--text); }
+
+ .label-group { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
+ label { display: block; font-size: 11px; color: var(--text-dim); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
+ .val-badge { font-size: 11px; color: var(--accent); font-family: "SF Mono", ui-monospace, monospace; font-weight: 600; }
+
+ input[type="text"], input[type="number"], select, textarea {
+ width: 100%; background: var(--panel-2);
+ border: 1px solid var(--border);
+ border-radius: 7px; color: var(--text);
+ padding: 8px 11px; font-size: 12.5px; outline: none; margin-bottom: 9px;
+ /* Only transition border/shadow – NOT background-image to prevent arrow flicker */
+ transition: border-color 0.15s, box-shadow 0.15s;
+ }
+ input:focus, select:focus, textarea:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-ring);
+ }
+ select {
+ -webkit-appearance: none; -moz-appearance: none; appearance: none;
+ /* Stable grey arrow – no background shorthand so it won't animate */
+ background-color: var(--panel-2);
+ 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");
+ background-repeat: no-repeat;
+ background-position: right 10px center;
+ background-size: 12px;
+ padding-right: 28px;
+ cursor: pointer;
+ /* Explicitly do NOT transition background properties */
+ transition: border-color 0.15s, box-shadow 0.15s;
+ }
+ select:focus { background-color: var(--panel-2); }
+ select option { background: #27272A; color: var(--text); }
+ textarea { resize: vertical; min-height: 78px; font-family: inherit; }
+
+ .slider-container { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
+ input[type="range"] { flex: 1; accent-color: var(--accent); height: 4px; cursor: pointer; border-radius: 2px; }
+
+ .upload-zone {
+ border: 1px dashed var(--border); border-radius: 10px;
+ padding: 18px 10px; text-align: center; cursor: pointer;
+ background: rgba(255,255,255,0.03); margin-bottom: 10px; position: relative;
+ transition: all 0.2s;
+ }
+ .upload-zone:hover, .upload-zone.dragover { background: var(--accent-dim); border-color: var(--accent); }
+ .upload-zone.has-images {
+ padding: 12px; background: rgba(255,255,255,0.025);
+ }
+ .upload-zone.has-images .upload-placeholder-mini {
+ display: flex; align-items: center; gap: 8px; justify-content: center;
+ color: var(--text-dim); font-size: 11px;
+ }
+ .upload-zone.has-images .upload-placeholder-mini span {
+ background: var(--item); padding: 6px 12px; border-radius: 6px;
+ }
+ #batch-images-placeholder { display: block; }
+ .upload-zone.has-images #batch-images-placeholder { display: none; }
+
+ /* 批量模式:上传区下方的横向缩略图条 */
+ .batch-thumb-strip-wrap {
+ margin-top: 10px;
+ margin-bottom: 4px;
+ }
+ .batch-thumb-strip-head {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ margin-bottom: 8px;
+ }
+ .batch-thumb-strip-title {
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--text-sub);
+ }
+ .batch-thumb-strip-hint {
+ font-size: 10px;
+ color: var(--text-dim);
+ }
+ .batch-images-container {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ gap: 10px;
+ overflow-x: auto;
+ overflow-y: visible;
+ padding: 6px 4px 14px;
+ margin: 0 -4px;
+ scrollbar-width: thin;
+ scrollbar-color: var(--border) transparent;
+ align-items: center;
+ }
+ .batch-images-container::-webkit-scrollbar { height: 6px; }
+ .batch-images-container::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 3px;
+ }
+ .batch-image-wrapper {
+ flex: 0 0 72px;
+ width: 72px;
+ height: 72px;
+ position: relative;
+ border-radius: 10px;
+ overflow: hidden;
+ background: var(--item);
+ border: 1px solid var(--border);
+ cursor: grab;
+ touch-action: none;
+ user-select: none;
+ -webkit-user-select: none;
+ transition:
+ flex-basis 0.38s cubic-bezier(0.22, 1, 0.36, 1),
+ width 0.38s cubic-bezier(0.22, 1, 0.36, 1),
+ min-width 0.38s cubic-bezier(0.22, 1, 0.36, 1),
+ margin 0.38s cubic-bezier(0.22, 1, 0.36, 1),
+ opacity 0.25s ease,
+ border-color 0.2s ease,
+ box-shadow 0.2s ease,
+ transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
+ }
+ .batch-image-wrapper:active { cursor: grabbing; }
+ .batch-image-wrapper.batch-thumb--source {
+ flex: 0 0 0;
+ width: 0;
+ min-width: 0;
+ height: 72px;
+ margin: 0;
+ padding: 0;
+ border: none;
+ overflow: hidden;
+ opacity: 0;
+ background: transparent;
+ box-shadow: none;
+ pointer-events: none;
+ /* 收起必须瞬时:若与占位框同时用 0.38s 过渡,右侧缩略图会与「突然出现」的槽位不同步而闪一下 */
+ transition: none !important;
+ }
+ /* 按下瞬间:冻结其它卡片与槽位动画,避免「槽位插入 + 邻居过渡」两帧打架 */
+ .batch-images-container.is-batch-settling .batch-image-wrapper:not(.batch-thumb--source) {
+ transition: none !important;
+ }
+ .batch-images-container.is-batch-settling .batch-thumb-drop-slot {
+ animation: none;
+ opacity: 1;
+ }
+ /* 拖动时跟手的浮动缩略图(避免原槽位透明后光标下像「黑块」) */
+ .batch-thumb-floating-ghost {
+ position: fixed;
+ left: 0;
+ top: 0;
+ z-index: 99999;
+ width: 76px;
+ height: 76px;
+ border-radius: 12px;
+ overflow: hidden;
+ pointer-events: none;
+ will-change: transform;
+ box-shadow:
+ 0 20px 50px rgba(0, 0, 0, 0.45),
+ 0 10px 28px rgba(0, 0, 0, 0.28),
+ 0 0 0 1px rgba(255, 255, 255, 0.18);
+ transform: translate3d(0, 0, 0) scale(1.06) rotate(-1deg);
+ }
+ .batch-thumb-floating-ghost img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+ pointer-events: none;
+ }
+ .batch-thumb-drop-slot {
+ flex: 0 0 72px;
+ width: 72px;
+ height: 72px;
+ box-sizing: border-box;
+ border-radius: 12px;
+ border: 2px dashed rgba(255, 255, 255, 0.22);
+ background: linear-gradient(145deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.03));
+ pointer-events: none;
+ transition: border-color 0.35s ease, box-shadow 0.35s ease, opacity 0.35s ease;
+ animation: batch-slot-breathe 2.4s ease-in-out infinite;
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
+ }
+ @keyframes batch-slot-breathe {
+ 0%, 100% { opacity: 0.88; }
+ 50% { opacity: 1; }
+ }
+ .batch-image-wrapper .batch-thumb-img-wrap {
+ width: 100%;
+ height: 100%;
+ border-radius: 9px;
+ overflow: hidden;
+ /* 必须让事件落到外层 .batch-image-wrapper,否则 HTML5 drag 无法从 draggable 父级启动 */
+ pointer-events: none;
+ }
+ .batch-image-wrapper .batch-thumb-img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+ pointer-events: none;
+ user-select: none;
+ -webkit-user-drag: none;
+ }
+ .batch-thumb-remove {
+ position: absolute;
+ top: 3px;
+ right: 3px;
+ z-index: 5;
+ box-sizing: border-box;
+ min-width: 22px;
+ height: 22px;
+ padding: 0 5px;
+ margin: 0;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 6px;
+ background: rgba(0, 0, 0, 0.5);
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 1;
+ color: rgba(255, 255, 255, 0.9);
+ opacity: 0.72;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.12s, opacity 0.12s, border-color 0.12s;
+ pointer-events: auto;
+ }
+ .batch-image-wrapper:hover .batch-thumb-remove {
+ opacity: 1;
+ background: rgba(0, 0, 0, 0.68);
+ border-color: rgba(255, 255, 255, 0.2);
+ }
+ .batch-thumb-remove:hover {
+ background: rgba(80, 20, 20, 0.75) !important;
+ border-color: rgba(255, 180, 180, 0.35);
+ color: #fff;
+ }
+ .batch-thumb-remove:focus-visible {
+ opacity: 1;
+ outline: 2px solid var(--accent-dim, rgba(120, 160, 255, 0.6));
+ outline-offset: 1px;
+ }
+ .upload-icon { font-size: 18px; margin-bottom: 6px; opacity: 0.45; }
+ .upload-text { font-size: 11px; color: var(--text); }
+ .upload-hint { font-size: 10px; color: var(--text-dim); margin-top: 3px; }
+ .preview-thumb { width: 100%; height: auto; max-height: 100px; object-fit: contain; border-radius: 8px; display: none; margin-top: 10px; }
+ .clear-img-overlay {
+ position: absolute; top: 8px; right: 8px; background: rgba(255,59,48,0.85); color: white;
+ width: 20px; height: 20px; border-radius: 10px; display: none; align-items: center; justify-content: center;
+ font-size: 11px; cursor: pointer; z-index: 5;
+ }
+
+ .btn-outline {
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ color: var(--text-sub); padding: 5px 12px; border-radius: 7px;
+ font-size: 11.5px; font-weight: 600; cursor: pointer;
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
+ display: inline-flex; align-items: center; justify-content: center; gap: 5px;
+ white-space: nowrap;
+ }
+ .btn-outline:hover:not(:disabled) { background: rgba(255,255,255,0.08); color: var(--text); border-color: rgba(255,255,255,0.18); }
+ .btn-outline:active { opacity: 0.7; }
+ .btn-outline:disabled { opacity: 0.3; cursor: not-allowed; }
+
+ .btn-icon {
+ padding: 5px; background: transparent; border: none; color: var(--text-dim);
+ border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center;
+ transition: color 0.15s, background 0.15s;
+ }
+ .btn-icon:hover { color: var(--text-sub); background: rgba(255,255,255,0.07); }
+
+ .btn-primary {
+ width: 100%; padding: 13px;
+ background: var(--accent); border: none;
+ border-radius: 9px; color: #fff; font-weight: 700; font-size: 13.5px;
+ letter-spacing: 0.2px; cursor: pointer; margin-top: 14px;
+ transition: background 0.15s;
+ }
+ .btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
+ .btn-primary:active { opacity: 0.82; }
+ .btn-primary:disabled { background: rgba(255,255,255,0.08); color: var(--text-dim); cursor: not-allowed; }
+
+ .btn-danger {
+ width: 100%; padding: 12px; background: #DC2626; border: none;
+ border-radius: 9px; color: #fff; font-weight: 700; font-size: 13.5px;
+ cursor: pointer; margin-top: 8px; display: none; transition: background 0.15s;
+ }
+ .btn-danger:hover { background: #EF4444; }
+
+ /* Workspace */
+ .workspace { flex: 1; display: flex; flex-direction: column; background: #0A0A0A; position: relative; overflow: hidden; }
+ .viewer { flex: 2; display: flex; align-items: center; justify-content: center; padding: 16px; background: #0A0A0A; position: relative; min-height: 40vh; }
+ .monitor {
+ width: 100%; height: 100%; max-width: 1650px; border-radius: 10px; border: 1px solid var(--border);
+ overflow: hidden; position: relative; background: #070707;
+ display: flex; align-items: center; justify-content: center;
+ background-image: radial-gradient(rgba(255,255,255,0.02) 1px, transparent 1px);
+ background-size: 18px 18px;
+ }
+ .monitor img, .monitor video {
+ width: auto; height: auto; max-width: 100%; max-height: 100%;
+ object-fit: contain; display: none; z-index: 2; border-radius: 3px;
+ }
+
+ .progress-container { position: absolute; bottom: 0; left: 0; width: 100%; height: 2px; background: var(--border-2); z-index: 10; }
+ #progress-fill { width: 0%; height: 100%; background: var(--accent); transition: width 0.5s; }
+ #loading-txt { font-size: 12px; color: var(--text-sub); font-weight: 600; z-index: 5; position: absolute; display: none; }
+
+
+
+ .spinner {
+ width: 12px; height: 12px;
+ border: 2px solid rgba(255,255,255,0.2);
+ border-top-color: currentColor;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ }
+ @keyframes spin { to { transform: rotate(360deg); } }
+
+ .loading-card {
+ display: flex; align-items: center; justify-content: center;
+ flex-direction: column; gap: 6px; color: var(--text-dim); font-size: 10px;
+ background: rgba(37,99,235,0.07) !important;
+ border-color: rgba(37,99,235,0.3) !important;
+ }
+ .loading-card .spinner { width: 28px; height: 28px; border-width: 3px; color: var(--accent); }
+ .loading-card:hover { background: rgba(37,99,235,0.14) !important; border-color: var(--accent) !important; }
+
+ .library { flex: 1.5; border-top: 1px solid var(--border); padding: 14px 20px; display: flex; flex-direction: column; background: #0F0F11; overflow-y: hidden; }
+ #log-container { flex: 1; overflow-y: auto; padding-right: 4px; }
+ #log { font-family: ui-monospace, "SF Mono", monospace; font-size: 10.5px; color: var(--text-dim); line-height: 1.7; }
+
+ /* History wrapper: scrollable area for thumbnails only */
+ #history-wrapper {
+ flex: 1;
+ overflow-y: auto;
+ min-height: 110px; /* always show at least one row */
+ padding-right: 4px;
+ }
+ #history-container {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ justify-content: start;
+ gap: 10px; align-content: flex-start;
+ padding-bottom: 4px;
+ }
+ /* Pagination row: hidden, using infinite scroll instead */
+ #pagination-bar {
+ display: none;
+ }
+
+ .history-card {
+ width: 100%; max-width: 200px; aspect-ratio: 16 / 9;
+ background: #1A1A1E; border-radius: 7px;
+ overflow: hidden; border: 1px solid var(--border);
+ cursor: pointer; position: relative; transition: border-color 0.15s, transform 0.15s;
+ }
+ .history-card:hover { border-color: var(--accent); transform: translateY(-1px); }
+ .history-card img, .history-card video {
+ width: 100%; height: 100%; object-fit: cover;
+ background: #1A1A1E;
+ }
+ /* 解码/加载完成前避免视频黑块猛闪,与卡片底色一致;就绪后淡入 */
+ .history-card .history-thumb-media {
+ opacity: 0;
+ transition: opacity 0.28s ease;
+ }
+ .history-card .history-thumb-media.history-thumb-ready {
+ opacity: 1;
+ }
+ .history-type-badge {
+ position: absolute; top: 5px; left: 5px; font-size: 8px; padding: 1px 5px; border-radius: 3px;
+ background: rgba(0,0,0,0.8); color: var(--text-sub); border: 1px solid rgba(255,255,255,0.06);
+ z-index: 2; font-weight: 700; letter-spacing: 0.4px;
+ }
+ .history-delete-btn {
+ position: absolute; top: 5px; right: 5px; width: 20px; height: 20px;
+ border-radius: 50%; border: none; background: rgba(255,50,50,0.8); color: #fff;
+ font-size: 10px; cursor: pointer; z-index: 3; display: flex; align-items: center; justify-content: center;
+ opacity: 0; transition: opacity 0.2s;
+ }
+ .history-card:hover .history-delete-btn { opacity: 1; }
+ .history-delete-btn:hover { background: rgba(255,0,0,0.9); }
+
+ .vram-bar { width: 160px; height: 5px; background: rgba(255,255,255,0.08); border-radius: 999px; overflow: hidden; display: inline-block; vertical-align: middle; }
+ .vram-used { height: 100%; background: var(--accent); width: 0%; transition: width 0.5s; }
+
+ /* 智能多帧:工作流模式卡片式单选 */
+ .smart-param-mode-label {
+ font-size: 10px;
+ color: var(--text-dim);
+ font-weight: 700;
+ margin-bottom: 8px;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ }
+ .smart-param-modes {
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+ gap: 0;
+ padding: 3px;
+ margin-bottom: 12px;
+ background: var(--panel-2);
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ }
+ .smart-param-mode-opt {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ min-width: 0;
+ gap: 0;
+ margin: 0;
+ padding: 6px 8px;
+ border-radius: 6px;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+ position: relative;
+ }
+ .smart-param-mode-opt:hover:not(:has(input:checked)) {
+ background: rgba(255, 255, 255, 0.05);
+ }
+ .smart-param-mode-opt input[type="radio"] {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+ margin: 0;
+ }
+ .smart-param-mode-opt:has(input:checked) {
+ background: var(--accent);
+ box-shadow: none;
+ }
+ .smart-param-mode-opt:has(input:checked) .smart-param-mode-title {
+ color: #fff;
+ }
+ .smart-param-mode-title {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-sub);
+ text-align: center;
+ line-height: 1.25;
+ flex: none;
+ min-width: 0;
+ }
+ /* 单次多关键帧:时间轴面板 */
+ .batch-kf-panel {
+ background: var(--item);
+ border-radius: 10px;
+ padding: 12px 14px;
+ margin-bottom: 10px;
+ border: 1px solid var(--border);
+ }
+ .batch-kf-panel-hd {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ margin-bottom: 8px;
+ }
+ .batch-kf-panel-title {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text);
+ }
+ .batch-kf-total-pill {
+ font-size: 11px;
+ color: var(--text-sub);
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 6px 12px;
+ white-space: nowrap;
+ }
+ .batch-kf-total-pill strong {
+ color: var(--accent);
+ font-weight: 800;
+ font-variant-numeric: tabular-nums;
+ margin: 0 2px;
+ }
+ .batch-kf-total-unit {
+ font-size: 10px;
+ color: var(--text-dim);
+ }
+ .batch-kf-panel-hint {
+ font-size: 10px;
+ color: var(--text-dim);
+ line-height: 1.5;
+ margin: 0 0 12px;
+ }
+ .batch-kf-timeline-col {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ }
+ .batch-kf-kcard {
+ border-radius: 10px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.03);
+ padding: 10px 12px;
+ }
+ .batch-kf-kcard-head {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 10px;
+ }
+ .batch-kf-kthumb {
+ width: 48px;
+ height: 48px;
+ border-radius: 8px;
+ object-fit: cover;
+ flex-shrink: 0;
+ border: 1px solid var(--border);
+ }
+ .batch-kf-kcard-titles {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+ }
+ .batch-kf-ktitle {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text);
+ }
+ .batch-kf-anchor {
+ font-size: 11px;
+ color: var(--accent);
+ font-variant-numeric: tabular-nums;
+ font-weight: 600;
+ }
+ .batch-kf-kcard-ctrl {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 12px;
+ }
+ .batch-kf-klabel {
+ font-size: 10px;
+ color: var(--text-dim);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+ .batch-kf-klabel input[type="number"] {
+ width: 72px;
+ padding: 6px 8px;
+ font-size: 12px;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: var(--panel);
+ color: var(--text);
+ }
+ /* 关键帧之间:细时间轴 + 单行紧凑间隔输入 */
+ .batch-kf-gap {
+ display: flex;
+ align-items: stretch;
+ gap: 8px;
+ padding: 0 0 6px;
+ margin: 0 0 0 10px;
+ }
+ .batch-kf-gap-rail {
+ width: 2px;
+ flex-shrink: 0;
+ border-radius: 2px;
+ background: linear-gradient(
+ 180deg,
+ rgba(255, 255, 255, 0.06),
+ var(--accent-dim),
+ rgba(255, 255, 255, 0.04)
+ );
+ min-height: 22px;
+ align-self: stretch;
+ }
+ .batch-kf-gap-inner {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+ min-width: 0;
+ padding: 2px 0 4px;
+ }
+ .batch-kf-gap-ix {
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--text-dim);
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.02em;
+ flex-shrink: 0;
+ }
+ .batch-kf-seg-field {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ margin: 0;
+ cursor: text;
+ }
+ .batch-kf-seg-input {
+ width: 46px;
+ min-width: 0;
+ padding: 2px 5px;
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1.3;
+ border-radius: 4px;
+ border: 1px solid var(--border);
+ background: rgba(0, 0, 0, 0.2);
+ color: var(--text);
+ font-variant-numeric: tabular-nums;
+ }
+ .batch-kf-seg-input:hover {
+ border-color: rgba(255, 255, 255, 0.12);
+ }
+ .batch-kf-seg-input:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 1px var(--accent-ring);
+ }
+ .batch-kf-gap-unit {
+ font-size: 10px;
+ color: var(--text-dim);
+ font-weight: 500;
+ flex-shrink: 0;
+ }
+
+ .sub-mode-toggle { display: flex; background: var(--panel-2); border-radius: 7px; padding: 3px; border: 1px solid var(--border); }
+ .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; }
+ .sub-mode-btn.active { background: var(--accent); color: #fff; }
+ .sub-mode-btn:hover:not(.active) { background: rgba(255,255,255,0.05); color: var(--text-sub); }
+
+ .vid-section { display: none; margin-top: 12px; }
+ .vid-section.active-section { display: block; animation: fadeIn 0.25s ease; }
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
+
+ /* Status indicator */
+ @keyframes breathe-orange {
+ 0%,100% { box-shadow: 0 0 4px #FF9F0A; opacity: 0.7; }
+ 50% { box-shadow: 0 0 10px #FF9F0A; opacity: 1; }
+ }
+ .indicator-busy { background: #FF9F0A !important; animation: breathe-orange 1.6s infinite ease-in-out !important; box-shadow: none !important; transition: all 0.3s; }
+ .indicator-ready { background: #30D158 !important; box-shadow: 0 0 8px rgba(48,209,88,0.6) !important; animation: none !important; transition: all 0.3s; }
+ .indicator-offline { background: #636366 !important; box-shadow: none !important; animation: none !important; transition: all 0.3s; }
+
+ .res-preview-tag { font-size: 11px; color: var(--accent); margin-bottom: 10px; font-family: ui-monospace, monospace; }
+ .top-status { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-dim); margin-bottom: 8px; align-items: center; }
+ .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); }
+ .checkbox-container input { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; margin: 0; }
+ .checkbox-container label { margin-bottom: 0; cursor: pointer; text-transform: none; color: var(--text); }
+ .flex-row { display: flex; gap: 10px; }
+ .flex-1 { flex: 1; min-width: 0; }
+
+ @media (max-width: 1024px) {
+ body { flex-direction: column; overflow-y: auto; }
+ .sidebar { width: 100%; min-width: 100%; border-right: none; border-bottom: 1px solid var(--border); height: auto; overflow: visible; }
+ .workspace { height: auto; min-height: 100vh; overflow: visible; }
+ }
+:root {
+ --plyr-color-main: #3F51B5;
+ --plyr-video-control-background-hover: rgba(255,255,255,0.1);
+ --plyr-control-radius: 6px;
+ --plyr-player-width: 100%;
+}
+.plyr {
+ border-radius: 8px;
+ overflow: hidden;
+ width: 100%;
+ height: 100%;
+}
+.plyr--video .plyr__controls {
+ background: linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0.8));
+ padding: 20px 15px 15px 15px;
+}
+
diff --git a/LTX2.3-1.0.3/UI/index.html b/LTX2.3-1.0.3/UI/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..1f029c40985eee741655351211778a8fdad85b35
--- /dev/null
+++ b/LTX2.3-1.0.3/UI/index.html
@@ -0,0 +1,409 @@
+
+
+
+
+
+ LTX-2 | Multi-GPU Cinematic Studio
+
+
+
+
+
+
+
+
+
+
+
等待分配渲染任务...
+
![]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 历史资产 / ASSETS
+ 系统日志 / LOGS
+
+
+
+
+
+
> LTX-2 Studio Ready. Expecting commands...
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LTX2.3-1.0.3/UI/index.js b/LTX2.3-1.0.3/UI/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..b3dc067043eb165da08f4ed9855d6b64b3fac0a7
--- /dev/null
+++ b/LTX2.3-1.0.3/UI/index.js
@@ -0,0 +1,2082 @@
+// ─── Resizable panel drag logic ───────────────────────────────────────────────
+(function() {
+ const handle = document.getElementById('resize-handle');
+ const viewer = document.getElementById('viewer-section');
+ const library = document.getElementById('library-section');
+ const workspace = document.querySelector('.workspace');
+ let dragging = false, startY = 0, startVH = 0;
+
+ handle.addEventListener('mousedown', (e) => {
+ dragging = true;
+ startY = e.clientY;
+ startVH = viewer.getBoundingClientRect().height;
+ document.body.style.cursor = 'row-resize';
+ document.body.style.userSelect = 'none';
+ handle.querySelector('div').style.background = 'var(--accent)';
+ e.preventDefault();
+ });
+ document.addEventListener('mousemove', (e) => {
+ if (!dragging) return;
+ const wsH = workspace.getBoundingClientRect().height;
+ const delta = e.clientY - startY;
+ let newVH = startVH + delta;
+ // Clamp: viewer min 150px, library min 100px
+ newVH = Math.max(150, Math.min(wsH - 100 - 5, newVH));
+ viewer.style.flex = 'none';
+ viewer.style.height = newVH + 'px';
+ library.style.flex = '1';
+ });
+ document.addEventListener('mouseup', () => {
+ if (dragging) {
+ dragging = false;
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ handle.querySelector('div').style.background = 'var(--border)';
+ }
+ });
+ // Hover highlight
+ handle.addEventListener('mouseenter', () => { handle.querySelector('div').style.background = 'var(--text-dim)'; });
+ handle.addEventListener('mouseleave', () => { if (!dragging) handle.querySelector('div').style.background = 'var(--border)'; });
+})();
+// ──────────────────────────────────────────────────────────────────────────────
+
+
+
+
+
+
+// 动态获取当前访问的域名或 IP,自动对齐 3000 端口
+ const BASE = `http://${window.location.hostname}:3000`;
+
+ function _t(k) {
+ return typeof window.t === 'function' ? window.t(k) : k;
+ }
+
+ let currentMode = 'image';
+ let pollInterval = null;
+ let availableModels = [];
+ let availableLoras = [];
+
+ // 建议增加一个简单的调试日志,方便在控制台确认地址是否正确
+ console.log("Connecting to Backend API at:", BASE);
+
+ // 模型扫描功能
+ async function scanModels() {
+ try {
+ const url = `${BASE}/api/models`;
+ console.log("Scanning models from:", url);
+ const res = await fetch(url);
+ const data = await res.json().catch(() => ({}));
+ console.log("Models response:", res.status, data);
+ if (!res.ok) {
+ const msg = data.message || data.error || res.statusText;
+ addLog(`❌ 模型扫描失败 (${res.status}): ${msg}`);
+ availableModels = [];
+ updateModelDropdown();
+ updateBatchModelDropdown();
+ return;
+ }
+ availableModels = data.models || [];
+ updateModelDropdown();
+ updateBatchModelDropdown();
+ if (availableModels.length > 0) {
+ addLog(`📂 已扫描到 ${availableModels.length} 个模型: ${availableModels.map(m => m.name).join(', ')}`);
+ }
+ } catch (e) {
+ console.log("Model scan error:", e);
+ addLog(`❌ 模型扫描异常: ${e.message || e}`);
+ }
+ }
+
+ function updateModelDropdown() {
+ const select = document.getElementById('vid-model');
+ if (!select) return;
+ select.innerHTML = '';
+ availableModels.forEach(model => {
+ const opt = document.createElement('option');
+ opt.value = model.path;
+ opt.textContent = model.name;
+ select.appendChild(opt);
+ });
+ }
+
+ // LoRA 扫描功能
+ async function scanLoras() {
+ try {
+ const url = `${BASE}/api/loras`;
+ console.log("Scanning LoRA from:", url);
+ const res = await fetch(url);
+ const data = await res.json().catch(() => ({}));
+ console.log("LoRA response:", res.status, data);
+ if (!res.ok) {
+ const msg = data.message || data.error || res.statusText;
+ addLog(`❌ LoRA 扫描失败 (${res.status}): ${msg}`);
+ availableLoras = [];
+ updateLoraDropdown();
+ updateBatchLoraDropdown();
+ return;
+ }
+ availableLoras = data.loras || [];
+ updateLoraDropdown();
+ updateBatchLoraDropdown();
+ if (data.loras_dir) {
+ const hintEl = document.getElementById('lora-placement-hint');
+ if (hintEl) {
+ const tpl = _t('loraPlacementHintWithDir');
+ hintEl.innerHTML = tpl.replace(
+ '{dir}',
+ escapeHtmlAttr(data.models_dir || data.loras_dir)
+ );
+ }
+ }
+ if (availableLoras.length > 0) {
+ addLog(`📂 已扫描到 ${availableLoras.length} 个 LoRA: ${availableLoras.map(l => l.name).join(', ')}`);
+ }
+ } catch (e) {
+ console.log("LoRA scan error:", e);
+ addLog(`❌ LoRA 扫描异常: ${e.message || e}`);
+ }
+ }
+
+ function updateLoraDropdown() {
+ const select = document.getElementById('vid-lora');
+ if (!select) return;
+ select.innerHTML = '';
+ availableLoras.forEach(lora => {
+ const opt = document.createElement('option');
+ opt.value = lora.path;
+ opt.textContent = lora.name;
+ select.appendChild(opt);
+ });
+ }
+
+ function updateLoraStrength() {
+ const select = document.getElementById('vid-lora');
+ const container = document.getElementById('lora-strength-container');
+ if (select && container) {
+ container.style.display = select.value ? 'flex' : 'none';
+ }
+ }
+
+ // 更新批量模式的模型和LoRA下拉框
+ function updateBatchModelDropdown() {
+ const select = document.getElementById('batch-model');
+ if (!select) return;
+ select.innerHTML = '';
+ availableModels.forEach(model => {
+ const opt = document.createElement('option');
+ opt.value = model.path;
+ opt.textContent = model.name;
+ select.appendChild(opt);
+ });
+ }
+
+ function updateBatchLoraDropdown() {
+ const select = document.getElementById('batch-lora');
+ if (!select) return;
+ select.innerHTML = '';
+ availableLoras.forEach(lora => {
+ const opt = document.createElement('option');
+ opt.value = lora.path;
+ opt.textContent = lora.name;
+ select.appendChild(opt);
+ });
+ }
+
+ // 页面加载时更新批量模式的下拉框
+ function initBatchDropdowns() {
+ updateBatchModelDropdown();
+ updateBatchLoraDropdown();
+ }
+
+ // 已移除:模型/LoRA 目录自定义与浏览(保持后端默认路径扫描)
+
+ // 页面加载时扫描模型和LoRA(使用后端默认目录规则)
+ (function() {
+ ['vid-quality', 'batch-quality'].forEach((id) => {
+ const sel = document.getElementById(id);
+ if (sel && sel.value === '544') sel.value = '540';
+ });
+
+ setTimeout(() => {
+ scanModels();
+ scanLoras();
+ initBatchDropdowns();
+ }, 1500);
+ })();
+
+ // 分辨率自动计算逻辑
+ function updateResPreview() {
+ const q = document.getElementById('vid-quality').value; // "1080", "720", "540"
+ const r = document.getElementById('vid-ratio').value;
+
+ // 核心修复:后端解析器期待 "1080p", "720p", "540p" 这种标签格式
+ let resLabel = q === "1080" ? "1080p" : q === "720" ? "720p" : "540p";
+
+ /* 与后端一致:宽高均为 64 的倍数(LTX 内核要求) */
+ let resDisplay;
+ if (r === "16:9") {
+ resDisplay = q === "1080" ? "1920x1088" : q === "720" ? "1280x704" : "1024x576";
+ } else {
+ resDisplay = q === "1080" ? "1088x1920" : q === "720" ? "704x1280" : "576x1024";
+ }
+
+ document.getElementById('res-preview').innerText = `${_t('resPreviewPrefix')}: ${resLabel} (${resDisplay})`;
+ return resLabel;
+ }
+
+ // 图片分辨率预览
+ function updateImgResPreview() {
+ const w = document.getElementById('img-w').value;
+ const h = document.getElementById('img-h').value;
+ document.getElementById('img-res-preview').innerText = `${_t('resPreviewPrefix')}: ${w}x${h}`;
+ }
+
+ // 批量模式分辨率预览
+ function updateBatchResPreview() {
+ const q = document.getElementById('batch-quality').value;
+ const r = document.getElementById('batch-ratio').value;
+ let resLabel = q === "1080" ? "1080p" : q === "720" ? "720p" : "540p";
+ let resDisplay;
+ if (r === "16:9") {
+ resDisplay = q === "1080" ? "1920x1088" : q === "720" ? "1280x704" : "1024x576";
+ } else {
+ resDisplay = q === "1080" ? "1088x1920" : q === "720" ? "704x1280" : "576x1024";
+ }
+ document.getElementById('batch-res-preview').innerText = `${_t('resPreviewPrefix')}: ${resLabel} (${resDisplay})`;
+ return resLabel;
+ }
+
+ // 批量模式 LoRA 强度切换
+ function updateBatchLoraStrength() {
+ const select = document.getElementById('batch-lora');
+ const container = document.getElementById('batch-lora-strength-container');
+ if (select && container) {
+ container.style.display = select.value ? 'flex' : 'none';
+ }
+ }
+
+ // 切换图片预设分辨率
+ function applyImgPreset(val) {
+ if (val === "custom") {
+ document.getElementById('img-custom-res').style.display = 'flex';
+ } else {
+ const [w, h] = val.split('x');
+ document.getElementById('img-w').value = w;
+ document.getElementById('img-h').value = h;
+ updateImgResPreview();
+ // 隐藏自定义区域或保持显示供微调
+ // document.getElementById('img-custom-res').style.display = 'none';
+ }
+ }
+
+
+
+ // 处理帧图片上传
+ async function handleFrameUpload(file, frameType) {
+ if (!file) return;
+
+ const preview = document.getElementById(`${frameType}-frame-preview`);
+ const placeholder = document.getElementById(`${frameType}-frame-placeholder`);
+ const clearOverlay = document.getElementById(`clear-${frameType}-frame-overlay`);
+
+ const previewReader = new FileReader();
+ previewReader.onload = (e) => {
+ preview.src = e.target.result;
+ preview.style.display = 'block';
+ placeholder.style.display = 'none';
+ clearOverlay.style.display = 'flex';
+ };
+ previewReader.readAsDataURL(file);
+
+ const reader = new FileReader();
+ reader.onload = async (e) => {
+ const b64Data = e.target.result;
+ addLog(`正在上传 ${frameType === 'start' ? '起始帧' : '结束帧'}: ${file.name}...`);
+ try {
+ const res = await fetch(`${BASE}/api/system/upload-image`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image: b64Data, filename: file.name })
+ });
+ const data = await res.json();
+ if (res.ok && data.path) {
+ document.getElementById(`${frameType}-frame-path`).value = data.path;
+ addLog(`✅ ${frameType === 'start' ? '起始帧' : '结束帧'}上传成功`);
+ } else {
+ throw new Error(data.error || data.detail || "上传失败");
+ }
+ } catch (e) {
+ addLog(`❌ 帧图片上传失败: ${e.message}`);
+ }
+ };
+ reader.readAsDataURL(file);
+ }
+
+ function clearFrame(frameType) {
+ document.getElementById(`${frameType}-frame-input`).value = "";
+ document.getElementById(`${frameType}-frame-path`).value = "";
+ document.getElementById(`${frameType}-frame-preview`).style.display = 'none';
+ document.getElementById(`${frameType}-frame-preview`).src = "";
+ document.getElementById(`${frameType}-frame-placeholder`).style.display = 'block';
+ document.getElementById(`clear-${frameType}-frame-overlay`).style.display = 'none';
+ addLog(`🧹 已清除${frameType === 'start' ? '起始帧' : '结束帧'}`);
+ }
+
+ // 处理图片上传
+ async function handleImageUpload(file) {
+ if (!file) return;
+
+ // 预览图片
+ const preview = document.getElementById('upload-preview');
+ const placeholder = document.getElementById('upload-placeholder');
+ const clearOverlay = document.getElementById('clear-img-overlay');
+
+ const previewReader = new FileReader();
+ preview.onload = () => {
+ preview.style.display = 'block';
+ placeholder.style.display = 'none';
+ clearOverlay.style.display = 'flex';
+ };
+ previewReader.onload = (e) => preview.src = e.target.result;
+ previewReader.readAsDataURL(file);
+
+ // 使用 FileReader 转换为 Base64,绕过后端缺失 python-multipart 的问题
+ const reader = new FileReader();
+ reader.onload = async (e) => {
+ const b64Data = e.target.result;
+ addLog(`正在上传参考图: ${file.name}...`);
+ try {
+ const res = await fetch(`${BASE}/api/system/upload-image`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ image: b64Data,
+ filename: file.name
+ })
+ });
+ const data = await res.json();
+ if (res.ok && data.path) {
+ document.getElementById('uploaded-img-path').value = data.path;
+ addLog(`✅ 参考图上传成功: ${file.name}`);
+ } else {
+ const errMsg = data.error || data.detail || "上传失败";
+ throw new Error(typeof errMsg === 'string' ? errMsg : JSON.stringify(errMsg));
+ }
+ } catch (e) {
+ addLog(`❌ 图片上传失败: ${e.message}`);
+ }
+ };
+ reader.onerror = () => addLog("❌ 读取本地文件失败");
+ reader.readAsDataURL(file);
+ }
+
+ function clearUploadedImage() {
+ document.getElementById('vid-image-input').value = "";
+ document.getElementById('uploaded-img-path').value = "";
+ document.getElementById('upload-preview').style.display = 'none';
+ document.getElementById('upload-preview').src = "";
+ document.getElementById('upload-placeholder').style.display = 'block';
+ document.getElementById('clear-img-overlay').style.display = 'none';
+ addLog("🧹 已清除参考图");
+ }
+
+ // 处理音频上传
+ async function handleAudioUpload(file) {
+ if (!file) return;
+
+ const placeholder = document.getElementById('audio-upload-placeholder');
+ const statusDiv = document.getElementById('audio-upload-status');
+ const filenameStatus = document.getElementById('audio-filename-status');
+ const clearOverlay = document.getElementById('clear-audio-overlay');
+
+ placeholder.style.display = 'none';
+ filenameStatus.innerText = file.name;
+ statusDiv.style.display = 'block';
+ clearOverlay.style.display = 'flex';
+
+ const reader = new FileReader();
+ reader.onload = async (e) => {
+ const b64Data = e.target.result;
+ addLog(`正在上传音频: ${file.name}...`);
+ try {
+ // 复用图片上传接口,后端已支持任意文件类型
+ const res = await fetch(`${BASE}/api/system/upload-image`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ image: b64Data,
+ filename: file.name
+ })
+ });
+ const data = await res.json();
+ if (res.ok && data.path) {
+ document.getElementById('uploaded-audio-path').value = data.path;
+ addLog(`✅ 音频上传成功: ${file.name}`);
+ } else {
+ const errMsg = data.error || data.detail || "上传失败";
+ throw new Error(typeof errMsg === 'string' ? errMsg : JSON.stringify(errMsg));
+ }
+ } catch (e) {
+ addLog(`❌ 音频上传失败: ${e.message}`);
+ }
+ };
+ reader.onerror = () => addLog("❌ 读取本地音频文件失败");
+ reader.readAsDataURL(file);
+ }
+
+ function clearUploadedAudio() {
+ document.getElementById('vid-audio-input').value = "";
+ document.getElementById('uploaded-audio-path').value = "";
+ document.getElementById('audio-upload-placeholder').style.display = 'block';
+ document.getElementById('audio-upload-status').style.display = 'none';
+ document.getElementById('clear-audio-overlay').style.display = 'none';
+ addLog("🧹 已清除音频文件");
+ }
+
+ // 处理超分视频上传
+ async function handleUpscaleVideoUpload(file) {
+ if (!file) return;
+ const placeholder = document.getElementById('upscale-placeholder');
+ const statusDiv = document.getElementById('upscale-status');
+ const filenameStatus = document.getElementById('upscale-filename');
+ const clearOverlay = document.getElementById('clear-upscale-overlay');
+
+ filenameStatus.innerText = file.name;
+ placeholder.style.display = 'none';
+ statusDiv.style.display = 'block';
+ clearOverlay.style.display = 'flex';
+
+ const reader = new FileReader();
+ reader.onload = async (e) => {
+ const b64Data = e.target.result;
+ addLog(`正在上传待超分视频: ${file.name}...`);
+ try {
+ const res = await fetch(`${BASE}/api/system/upload-image`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image: b64Data, filename: file.name })
+ });
+ const data = await res.json();
+ if (res.ok && data.path) {
+ document.getElementById('upscale-video-path').value = data.path;
+ addLog(`✅ 视频上传成功`);
+ } else {
+ throw new Error(data.error || "上传失败");
+ }
+ } catch (e) {
+ addLog(`❌ 视频上传失败: ${e.message}`);
+ }
+ };
+ reader.readAsDataURL(file);
+ }
+
+ function clearUpscaleVideo() {
+ document.getElementById('upscale-video-input').value = "";
+ document.getElementById('upscale-video-path').value = "";
+ document.getElementById('upscale-placeholder').style.display = 'block';
+ document.getElementById('upscale-status').style.display = 'none';
+ document.getElementById('clear-upscale-overlay').style.display = 'none';
+ addLog("🧹 已清除待超分视频");
+ }
+
+ // 初始化拖拽上传逻辑
+ function initDragAndDrop() {
+ const audioDropZone = document.getElementById('audio-drop-zone');
+ const startFrameDropZone = document.getElementById('start-frame-drop-zone');
+ const endFrameDropZone = document.getElementById('end-frame-drop-zone');
+ const upscaleDropZone = document.getElementById('upscale-drop-zone');
+ const batchImagesDropZone = document.getElementById('batch-images-drop-zone');
+
+ const zones = [audioDropZone, startFrameDropZone, endFrameDropZone, upscaleDropZone, batchImagesDropZone];
+
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
+ zones.forEach(zone => {
+ if (!zone) return;
+ zone.addEventListener(eventName, (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }, false);
+ });
+ });
+
+ ['dragenter', 'dragover'].forEach(eventName => {
+ zones.forEach(zone => {
+ if (!zone) return;
+ zone.addEventListener(eventName, () => zone.classList.add('dragover'), false);
+ });
+ });
+
+ ['dragleave', 'drop'].forEach(eventName => {
+ zones.forEach(zone => {
+ if (!zone) return;
+ zone.addEventListener(eventName, () => zone.classList.remove('dragover'), false);
+ });
+ });
+
+ audioDropZone.addEventListener('drop', (e) => {
+ const file = e.dataTransfer.files[0];
+ if (file && file.type.startsWith('audio/')) handleAudioUpload(file);
+ }, false);
+
+ startFrameDropZone.addEventListener('drop', (e) => {
+ const file = e.dataTransfer.files[0];
+ if (file && file.type.startsWith('image/')) handleFrameUpload(file, 'start');
+ }, false);
+
+ endFrameDropZone.addEventListener('drop', (e) => {
+ const file = e.dataTransfer.files[0];
+ if (file && file.type.startsWith('image/')) handleFrameUpload(file, 'end');
+ }, false);
+
+ upscaleDropZone.addEventListener('drop', (e) => {
+ const file = e.dataTransfer.files[0];
+ if (file && file.type.startsWith('video/')) handleUpscaleVideoUpload(file);
+ }, false);
+
+ // 批量图片拖拽上传
+ if (batchImagesDropZone) {
+ batchImagesDropZone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ batchImagesDropZone.classList.remove('dragover');
+ const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/'));
+ if (files.length > 0) handleBatchImagesUpload(files);
+ }, false);
+ }
+ }
+
+ // 批量图片上传处理
+ let batchImages = [];
+ /** 单次多关键帧:按 path 记引导强度;按段索引 0..n-2 记「上一张→本张」间隔秒数 */
+ const batchKfStrengthByPath = {};
+ const batchKfSegDurByIndex = {};
+
+ function escapeHtmlAttr(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(/ {
+ if (!img.path) return;
+ const sEl = document.getElementById(`batch-kf-strength-${i}`);
+ if (sEl) batchKfStrengthByPath[img.path] = sEl.value.trim();
+ });
+ const n = batchImages.length;
+ for (let j = 0; j < n - 1; j++) {
+ const el = document.getElementById(`batch-kf-seg-dur-${j}`);
+ if (el) batchKfSegDurByIndex[j] = el.value.trim();
+ }
+ }
+
+ /** 读取间隔(秒),非法则回退为 minSeg */
+ function readBatchKfSegmentSeconds(n, minSeg) {
+ const seg = [];
+ for (let j = 0; j < n - 1; j++) {
+ let v = parseFloat(document.getElementById(`batch-kf-seg-dur-${j}`)?.value);
+ if (!Number.isFinite(v) || v < minSeg) v = minSeg;
+ seg.push(v);
+ }
+ return seg;
+ }
+
+ function updateBatchKfTimelineDerivedUI() {
+ if (!batchWorkflowIsSingle() || batchImages.length < 2) return;
+ const n = batchImages.length;
+ const minSeg = 0.1;
+ const seg = readBatchKfSegmentSeconds(n, minSeg);
+ let t = 0;
+ for (let i = 0; i < n; i++) {
+ const label = document.getElementById(`batch-kf-anchor-label-${i}`);
+ if (!label) continue;
+ if (i === 0) {
+ label.textContent = `0.0 s · ${_t('batchAnchorStart')}`;
+ } else {
+ t += seg[i - 1];
+ label.textContent =
+ i === n - 1
+ ? `${t.toFixed(1)} s · ${_t('batchAnchorEnd')}`
+ : `${t.toFixed(1)} s`;
+ }
+ }
+ const totalEl = document.getElementById('batch-kf-total-seconds');
+ if (totalEl) {
+ const sum = seg.reduce((a, b) => a + b, 0);
+ totalEl.textContent = sum.toFixed(1);
+ }
+ }
+ async function handleBatchImagesUpload(files, append = true) {
+ if (!files || files.length === 0) return;
+ addLog(`正在上传 ${files.length} 张图片...`);
+
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+ const reader = new FileReader();
+
+ const imgData = await new Promise((resolve) => {
+ reader.onload = async (e) => {
+ const b64Data = e.target.result;
+ try {
+ const res = await fetch(`${BASE}/api/system/upload-image`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image: b64Data, filename: file.name })
+ });
+ const data = await res.json();
+ if (res.ok && data.path) {
+ resolve({ name: file.name, path: data.path, preview: e.target.result });
+ } else {
+ resolve(null);
+ }
+ } catch (e) {
+ resolve(null);
+ }
+ };
+ reader.readAsDataURL(file);
+ });
+
+ if (imgData) {
+ batchImages.push(imgData);
+ addLog(`✅ 图片 ${i + 1}/${files.length} 上传成功: ${file.name}`);
+ }
+ }
+
+ renderBatchImages();
+ updateBatchSegments();
+ }
+
+ async function handleBatchBackgroundAudioUpload(file) {
+ if (!file) return;
+ const ph = document.getElementById('batch-audio-placeholder');
+ const st = document.getElementById('batch-audio-status');
+ const overlay = document.getElementById('clear-batch-audio-overlay');
+ const reader = new FileReader();
+ reader.onload = async (e) => {
+ const b64Data = e.target.result;
+ addLog(`正在上传成片配乐: ${file.name}...`);
+ try {
+ const res = await fetch(`${BASE}/api/system/upload-image`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image: b64Data, filename: file.name })
+ });
+ const data = await res.json();
+ if (res.ok && data.path) {
+ const hid = document.getElementById('batch-background-audio-path');
+ if (hid) hid.value = data.path;
+ if (ph) ph.style.display = 'none';
+ if (st) {
+ st.style.display = 'block';
+ st.textContent = '✓ ' + file.name;
+ }
+ if (overlay) overlay.style.display = 'flex';
+ addLog('✅ 成片配乐已上传(将覆盖各片段自带音轨)');
+ } else {
+ addLog(`❌ 配乐上传失败: ${data.error || '未知错误'}`);
+ }
+ } catch (err) {
+ addLog(`❌ 配乐上传失败: ${err.message}`);
+ }
+ };
+ reader.onerror = () => addLog('❌ 读取音频文件失败');
+ reader.readAsDataURL(file);
+ }
+
+ function clearBatchBackgroundAudio() {
+ const hid = document.getElementById('batch-background-audio-path');
+ const inp = document.getElementById('batch-audio-input');
+ if (hid) hid.value = '';
+ if (inp) inp.value = '';
+ const ph = document.getElementById('batch-audio-placeholder');
+ const st = document.getElementById('batch-audio-status');
+ const overlay = document.getElementById('clear-batch-audio-overlay');
+ if (ph) ph.style.display = 'block';
+ if (st) {
+ st.style.display = 'none';
+ st.textContent = '';
+ }
+ if (overlay) overlay.style.display = 'none';
+ addLog('🧹 已清除成片配乐');
+ }
+
+ function syncBatchDropZoneChrome() {
+ const dropZone = document.getElementById('batch-images-drop-zone');
+ const placeholder = document.getElementById('batch-images-placeholder');
+ const stripWrap = document.getElementById('batch-thumb-strip-wrap');
+ if (batchImages.length === 0) {
+ if (dropZone) {
+ dropZone.classList.remove('has-images');
+ const mini = dropZone.querySelector('.upload-placeholder-mini');
+ if (mini) mini.remove();
+ }
+ if (placeholder) placeholder.style.display = 'block';
+ if (stripWrap) stripWrap.style.display = 'none';
+ return;
+ }
+ if (placeholder) placeholder.style.display = 'none';
+ if (dropZone) dropZone.classList.add('has-images');
+ if (stripWrap) stripWrap.style.display = 'block';
+ if (dropZone && !dropZone.querySelector('.upload-placeholder-mini')) {
+ const mini = document.createElement('div');
+ mini.className = 'upload-placeholder-mini';
+ mini.innerHTML = '' + _t('batchAddMore') + '';
+ dropZone.appendChild(mini);
+ }
+ }
+
+ let batchDragPlaceholderEl = null;
+ let batchPointerState = null;
+ let batchPendingPhX = null;
+ let batchPhMoveRaf = null;
+
+ function batchRemoveFloatingGhost() {
+ document.querySelectorAll('.batch-thumb-floating-ghost').forEach((n) => n.remove());
+ }
+
+ function batchCancelPhMoveRaf() {
+ if (batchPhMoveRaf != null) {
+ cancelAnimationFrame(batchPhMoveRaf);
+ batchPhMoveRaf = null;
+ }
+ batchPendingPhX = null;
+ }
+
+ function batchEnsurePlaceholder() {
+ if (batchDragPlaceholderEl && batchDragPlaceholderEl.isConnected) return batchDragPlaceholderEl;
+ const el = document.createElement('div');
+ el.className = 'batch-thumb-drop-slot';
+ el.setAttribute('aria-hidden', 'true');
+ batchDragPlaceholderEl = el;
+ return el;
+ }
+
+ function batchRemovePlaceholder() {
+ if (batchDragPlaceholderEl && batchDragPlaceholderEl.parentNode) {
+ batchDragPlaceholderEl.parentNode.removeChild(batchDragPlaceholderEl);
+ }
+ }
+
+ function batchComputeInsertIndex(container, placeholder) {
+ let t = 0;
+ for (const child of container.children) {
+ if (child === placeholder) return t;
+ if (child.classList && child.classList.contains('batch-image-wrapper')) {
+ if (!child.classList.contains('batch-thumb--source')) t++;
+ }
+ }
+ return t;
+ }
+
+ function batchMovePlaceholderFromPoint(container, clientX) {
+ const ph = batchEnsurePlaceholder();
+ const wrappers = [...container.querySelectorAll('.batch-image-wrapper')];
+ let insertBefore = null;
+ for (const w of wrappers) {
+ if (w.classList.contains('batch-thumb--source')) continue;
+ const r = w.getBoundingClientRect();
+ if (clientX < r.left + r.width / 2) {
+ insertBefore = w;
+ break;
+ }
+ }
+ if (insertBefore === null) {
+ const vis = wrappers.filter((w) => !w.classList.contains('batch-thumb--source'));
+ const last = vis[vis.length - 1];
+ if (last) {
+ if (last.nextSibling) {
+ container.insertBefore(ph, last.nextSibling);
+ } else {
+ container.appendChild(ph);
+ }
+ } else {
+ container.appendChild(ph);
+ }
+ } else {
+ container.insertBefore(ph, insertBefore);
+ }
+ }
+
+ function batchFlushPlaceholderMove() {
+ batchPhMoveRaf = null;
+ if (!batchPointerState || batchPendingPhX == null) return;
+ batchMovePlaceholderFromPoint(batchPointerState.container, batchPendingPhX);
+ }
+
+ function handleBatchPointerMove(e) {
+ if (!batchPointerState) return;
+ e.preventDefault();
+ const st = batchPointerState;
+ st.ghostTX = e.clientX - st.offsetX;
+ st.ghostTY = e.clientY - st.offsetY;
+ batchPendingPhX = e.clientX;
+ if (batchPhMoveRaf == null) {
+ batchPhMoveRaf = requestAnimationFrame(batchFlushPlaceholderMove);
+ }
+ }
+
+ function batchGhostFrame() {
+ const st = batchPointerState;
+ if (!st || !st.ghostEl || !st.ghostEl.isConnected) {
+ return;
+ }
+ const t = 0.42;
+ st.ghostCX += (st.ghostTX - st.ghostCX) * t;
+ st.ghostCY += (st.ghostTY - st.ghostCY) * t;
+ st.ghostEl.style.transform =
+ `translate3d(${st.ghostCX}px,${st.ghostCY}px,0) scale(1.06) rotate(-1deg)`;
+ st.ghostRaf = requestAnimationFrame(batchGhostFrame);
+ }
+
+ function batchStartGhostLoop() {
+ const st = batchPointerState;
+ if (!st || !st.ghostEl) return;
+ if (st.ghostRaf != null) cancelAnimationFrame(st.ghostRaf);
+ st.ghostRaf = requestAnimationFrame(batchGhostFrame);
+ }
+
+ function batchEndPointerDrag(e) {
+ if (!batchPointerState) return;
+ if (e.pointerId !== batchPointerState.pointerId) return;
+ const st = batchPointerState;
+
+ batchCancelPhMoveRaf();
+ if (st.ghostRaf != null) {
+ cancelAnimationFrame(st.ghostRaf);
+ st.ghostRaf = null;
+ }
+ if (st.ghostEl && st.ghostEl.parentNode) {
+ st.ghostEl.remove();
+ }
+ batchPointerState = null;
+
+ document.removeEventListener('pointermove', handleBatchPointerMove);
+ document.removeEventListener('pointerup', batchEndPointerDrag);
+ document.removeEventListener('pointercancel', batchEndPointerDrag);
+
+ try {
+ if (st.wrapperEl) st.wrapperEl.releasePointerCapture(st.pointerId);
+ } catch (_) {}
+
+ const { fromIndex, container, wrapperEl } = st;
+ container.classList.remove('is-batch-settling');
+ if (!batchDragPlaceholderEl || !batchDragPlaceholderEl.parentNode) {
+ if (wrapperEl) wrapperEl.classList.remove('batch-thumb--source');
+ renderBatchImages();
+ updateBatchSegments();
+ return;
+ }
+ const to = batchComputeInsertIndex(container, batchDragPlaceholderEl);
+ batchRemovePlaceholder();
+ if (wrapperEl) wrapperEl.classList.remove('batch-thumb--source');
+
+ if (fromIndex !== to && fromIndex >= 0 && to >= 0) {
+ const [item] = batchImages.splice(fromIndex, 1);
+ batchImages.splice(to, 0, item);
+ updateBatchSegments();
+ }
+ renderBatchImages();
+ }
+
+ function handleBatchPointerDown(e) {
+ if (batchPointerState) return;
+ if (e.button !== 0) return;
+ if (e.target.closest && e.target.closest('.batch-thumb-remove')) return;
+
+ const wrapper = e.currentTarget;
+ const container = document.getElementById('batch-images-container');
+ if (!container) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ const fromIndex = parseInt(wrapper.dataset.index, 10);
+ if (Number.isNaN(fromIndex)) return;
+
+ const rect = wrapper.getBoundingClientRect();
+ const offsetX = e.clientX - rect.left;
+ const offsetY = e.clientY - rect.top;
+ const startLeft = rect.left;
+ const startTop = rect.top;
+
+ const ghost = document.createElement('div');
+ ghost.className = 'batch-thumb-floating-ghost';
+ const gImg = document.createElement('img');
+ const srcImg = wrapper.querySelector('img');
+ gImg.src = srcImg ? srcImg.src : '';
+ gImg.alt = '';
+ ghost.appendChild(gImg);
+ document.body.appendChild(ghost);
+
+ batchPointerState = {
+ fromIndex,
+ pointerId: e.pointerId,
+ wrapperEl: wrapper,
+ container,
+ ghostEl: ghost,
+ offsetX,
+ offsetY,
+ ghostTX: e.clientX - offsetX,
+ ghostTY: e.clientY - offsetY,
+ ghostCX: startLeft,
+ ghostCY: startTop,
+ ghostRaf: null
+ };
+
+ ghost.style.transform =
+ `translate3d(${startLeft}px,${startTop}px,0) scale(1.06) rotate(-1deg)`;
+
+ container.classList.add('is-batch-settling');
+ wrapper.classList.add('batch-thumb--source');
+ const ph = batchEnsurePlaceholder();
+ container.insertBefore(ph, wrapper.nextSibling);
+ /* 不在 pointerdown 立刻重算槽位;双 rAF 后再恢复邻居 transition,保证先完成本帧布局再动画 */
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ container.classList.remove('is-batch-settling');
+ });
+ });
+
+ batchStartGhostLoop();
+
+ document.addEventListener('pointermove', handleBatchPointerMove, { passive: false });
+ document.addEventListener('pointerup', batchEndPointerDrag);
+ document.addEventListener('pointercancel', batchEndPointerDrag);
+
+ try {
+ wrapper.setPointerCapture(e.pointerId);
+ } catch (_) {}
+ }
+
+ function removeBatchImage(index) {
+ if (index < 0 || index >= batchImages.length) return;
+ batchImages.splice(index, 1);
+ renderBatchImages();
+ updateBatchSegments();
+ }
+
+ // 横向缩略图:Pointer 拖动排序(避免 HTML5 DnD 在 WebView/部分浏览器失效)
+ function renderBatchImages() {
+ const container = document.getElementById('batch-images-container');
+ if (!container) return;
+
+ syncBatchDropZoneChrome();
+ batchRemovePlaceholder();
+ batchCancelPhMoveRaf();
+ batchRemoveFloatingGhost();
+ batchPointerState = null;
+ container.classList.remove('is-batch-settling');
+ container.innerHTML = '';
+
+ batchImages.forEach((img, index) => {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'batch-image-wrapper';
+ wrapper.dataset.index = String(index);
+ wrapper.title = _t('batchThumbDrag');
+
+ const imgWrap = document.createElement('div');
+ imgWrap.className = 'batch-thumb-img-wrap';
+ const im = document.createElement('img');
+ im.className = 'batch-thumb-img';
+ im.src = img.preview;
+ im.alt = img.name || '';
+ im.draggable = false;
+ imgWrap.appendChild(im);
+
+ const del = document.createElement('button');
+ del.type = 'button';
+ del.className = 'batch-thumb-remove';
+ del.title = _t('batchThumbRemove');
+ del.setAttribute('aria-label', _t('batchThumbRemove'));
+ del.textContent = '×';
+ del.addEventListener('pointerdown', (ev) => ev.stopPropagation());
+ del.addEventListener('click', (ev) => {
+ ev.stopPropagation();
+ removeBatchImage(index);
+ });
+
+ wrapper.appendChild(imgWrap);
+ wrapper.appendChild(del);
+
+ wrapper.addEventListener('pointerdown', handleBatchPointerDown);
+
+ container.appendChild(wrapper);
+ });
+ }
+
+ function batchWorkflowIsSingle() {
+ const r = document.querySelector('input[name="batch-workflow"]:checked');
+ return !!(r && r.value === 'single');
+ }
+
+ function onBatchWorkflowChange() {
+ updateBatchSegments();
+ }
+
+ // 更新片段设置 UI(分段模式)或单次多关键帧设置
+ function updateBatchSegments() {
+ const container = document.getElementById('batch-segments-container');
+ if (!container) return;
+
+ if (batchImages.length < 2) {
+ container.innerHTML =
+ '' +
+ escapeHtmlAttr(_t('batchNeedTwo')) +
+ '
';
+ return;
+ }
+
+ if (batchWorkflowIsSingle()) {
+ if (batchImages.length >= 2) captureBatchKfTimelineFromDom();
+ const n = batchImages.length;
+ const defaultTotal = 8;
+ const defaultSeg =
+ n > 1 ? (defaultTotal / (n - 1)).toFixed(1) : '4';
+ let blocks = '';
+ batchImages.forEach((img, i) => {
+ const path = img.path || '';
+ const stDef = defaultKeyframeStrengthForIndex(i, n);
+ const stStored = batchKfStrengthByPath[path];
+ const stVal = stStored !== undefined && stStored !== ''
+ ? escapeHtmlAttr(stStored)
+ : stDef;
+ const prev = escapeHtmlAttr(img.preview || '');
+ if (i > 0) {
+ const j = i - 1;
+ const sdStored = batchKfSegDurByIndex[j];
+ const segVal =
+ sdStored !== undefined && sdStored !== ''
+ ? escapeHtmlAttr(sdStored)
+ : defaultSeg;
+ blocks += `
+
+
+
+ ${i}→${i + 1}
+
+
+
`;
+ }
+ blocks += `
+
+
+

+
+ ${escapeHtmlAttr(_t('batchKfTitle'))} ${i + 1} / ${n}
+ —
+
+
+
+
+
+
`;
+ });
+ container.innerHTML = `
+
+
+
${escapeHtmlAttr(_t('batchKfPanelTitle'))}
+
+ ${escapeHtmlAttr(_t('batchTotalDur'))} — ${escapeHtmlAttr(_t('batchTotalSec'))}
+
+
+
${escapeHtmlAttr(_t('batchPanelHint'))}
+
+ ${blocks}
+
+
`;
+ updateBatchKfTimelineDerivedUI();
+ return;
+ }
+
+ let html =
+ '' +
+ escapeHtmlAttr(_t('batchSegTitle')) +
+ '
';
+
+ for (let i = 0; i < batchImages.length - 1; i++) {
+ const segPh = escapeHtmlAttr(_t('batchSegPromptPh'));
+ html += `
+
+
+
+

+
→
+

+
${escapeHtmlAttr(_t('batchSegClip'))} ${i + 1}
+
+
+
+
+ ${escapeHtmlAttr(_t('batchSegSec'))}
+
+
+
+
+
+
+
+ `;
+ }
+
+ container.innerHTML = html;
+ }
+
+ let _isGeneratingFlag = false;
+
+ // 系统状态轮询
+ async function checkStatus() {
+ try {
+ const h = await fetch(`${BASE}/health`).then(r => r.json()).catch(() => ({status: "error"}));
+ const g = await fetch(`${BASE}/api/gpu-info`).then(r => r.json()).catch(() => ({gpu_info: {}}));
+ const p = await fetch(`${BASE}/api/generation/progress`).then(r => r.json()).catch(() => ({progress: 0}));
+ const sysGpus = await fetch(`${BASE}/api/system/list-gpus`).then(r => r.json()).catch(() => ({gpus: []}));
+
+ const activeGpu = (sysGpus.gpus || []).find(x => x.active) || (sysGpus.gpus || [])[0] || {};
+ const gpuName = activeGpu.name || g.gpu_info?.name || "GPU";
+
+ const s = document.getElementById('sys-status');
+ const indicator = document.getElementById('sys-indicator');
+
+ const isReady = h.status === "ok" || h.status === "ready" || h.models_loaded;
+ const backendActive = (p && p.progress > 0);
+
+ if (_isGeneratingFlag || backendActive) {
+ s.innerText = `${gpuName}: ${_t('sysBusy')}`;
+ if(indicator) indicator.className = 'indicator-busy';
+ } else {
+ s.innerText = isReady ? `${gpuName}: ${_t('sysOnline')}` : `${gpuName}: ${_t('sysStarting')}`;
+ if(indicator) indicator.className = isReady ? 'indicator-ready' : 'indicator-offline';
+ }
+ s.style.color = "var(--text-dim)";
+
+ const vUsedMB = g.gpu_info?.vramUsed || 0;
+ const vTotalMB = activeGpu.vram_mb || g.gpu_info?.vram || 32768;
+ const vUsedGB = vUsedMB / 1024;
+ const vTotalGB = vTotalMB / 1024;
+
+ document.getElementById('vram-fill').style.width = (vUsedMB / vTotalMB * 100) + "%";
+ document.getElementById('vram-text').innerText = `${vUsedGB.toFixed(1)} / ${vTotalGB.toFixed(0)} GB`;
+ } catch(e) { document.getElementById('sys-status').innerText = _t('sysOffline'); }
+ }
+ setInterval(checkStatus, 1000); // 提升到 1 秒一次实时监控
+ checkStatus();
+ initDragAndDrop();
+ listGpus(); // 初始化 GPU 列表
+ // 已移除:输出目录自定义(保持后端默认路径)
+
+ updateResPreview();
+ updateBatchResPreview();
+ updateImgResPreview();
+ refreshPromptPlaceholder();
+
+ window.onUiLanguageChanged = function () {
+ updateResPreview();
+ updateBatchResPreview();
+ updateImgResPreview();
+ refreshPromptPlaceholder();
+ if (typeof currentMode !== 'undefined' && currentMode === 'batch') {
+ updateBatchSegments();
+ }
+ updateModelDropdown();
+ updateLoraDropdown();
+ updateBatchModelDropdown();
+ updateBatchLoraDropdown();
+ };
+
+ async function setOutputDir() {
+ const dir = document.getElementById('global-out-dir').value.trim();
+ localStorage.setItem('output_dir', dir);
+ try {
+ const res = await fetch(`${BASE}/api/system/set-dir`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ directory: dir })
+ });
+ if (res.ok) {
+ addLog(`✅ 存储路径更新成功! 当前路径: ${dir || _t('defaultPath')}`);
+ if (typeof fetchHistory === 'function') fetchHistory(currentHistoryPage);
+ }
+ } catch (e) {
+ addLog(`❌ 设置路径时连接异常: ${e.message}`);
+ }
+ }
+
+ async function browseOutputDir() {
+ try {
+ const res = await fetch(`${BASE}/api/system/browse-dir`);
+ const data = await res.json();
+ if (data.status === "success" && data.directory) {
+ document.getElementById('global-out-dir').value = data.directory;
+ // auto apply immediately
+ setOutputDir();
+ addLog(`📂 检测到新路径,已自动套用!`);
+ } else if (data.error) {
+ addLog(`❌ 内部系统权限拦截了弹窗: ${data.error}`);
+ }
+ } catch (e) {
+ addLog(`❌ 无法调出文件夹浏览弹窗, 请直接复制粘贴绝对路径。`);
+ }
+ }
+
+ async function getOutputDir() {
+ try {
+ const res = await fetch(`${BASE}/api/system/get-dir`);
+ const data = await res.json();
+ if (data.directory && data.directory.indexOf('LTXDesktop') === -1 && document.getElementById('global-out-dir')) {
+ document.getElementById('global-out-dir').value = data.directory;
+ }
+ } catch (e) {}
+ }
+
+ async function saveLoraDir() {
+ const input = document.getElementById('lora-dir-input');
+ const status = document.getElementById('lora-dir-status');
+ if (!input || !status) return;
+
+ const loraDir = input.value.trim();
+ try {
+ const res = await fetch(`${BASE}/api/lora-dir`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ loraDir: loraDir })
+ });
+ const data = await res.json();
+ if (data && data.status === 'ok') {
+ status.textContent = '✓ 已保存';
+ status.style.color = '#4caf50';
+ setTimeout(() => { status.textContent = ''; }, 3000);
+ } else {
+ status.textContent = '✗ 保存失败: ' + (data.message || JSON.stringify(data));
+ status.style.color = '#f44336';
+ }
+ } catch (e) {
+ status.textContent = '✗ 保存失败: ' + e.message;
+ status.style.color = '#f44336';
+ }
+ }
+
+ async function loadLoraDir() {
+ try {
+ const res = await fetch(`${BASE}/api/lora-dir`);
+ const data = await res.json();
+ if (data && document.getElementById('lora-dir-input')) {
+ document.getElementById('lora-dir-input').value = data.loraDir || '';
+ }
+ } catch (e) {}
+ }
+
+ function switchMode(m) {
+ currentMode = m;
+ document.getElementById('tab-image').classList.toggle('active', m === 'image');
+ document.getElementById('tab-video').classList.toggle('active', m === 'video');
+ document.getElementById('tab-batch').classList.toggle('active', m === 'batch');
+ document.getElementById('tab-upscale').classList.toggle('active', m === 'upscale');
+
+ document.getElementById('image-opts').style.display = m === 'image' ? 'block' : 'none';
+ document.getElementById('video-opts').style.display = m === 'video' ? 'block' : 'none';
+ document.getElementById('batch-opts').style.display = m === 'batch' ? 'block' : 'none';
+ document.getElementById('upscale-opts').style.display = m === 'upscale' ? 'block' : 'none';
+ if (m === 'batch') updateBatchSegments();
+
+ // 如果切到图像模式,隐藏提示词框外的其他东西
+ refreshPromptPlaceholder();
+ }
+
+ function refreshPromptPlaceholder() {
+ const pe = document.getElementById('prompt');
+ if (!pe) return;
+ pe.placeholder =
+ currentMode === 'upscale' ? _t('promptPlaceholderUpscale') : _t('promptPlaceholder');
+ }
+
+ function showGeneratingView() {
+ if (!_isGeneratingFlag) return;
+ const resImg = document.getElementById('res-img');
+ const videoWrapper = document.getElementById('video-wrapper');
+ if (resImg) resImg.style.display = "none";
+ if (videoWrapper) videoWrapper.style.display = "none";
+ if (player) {
+ try { player.stop(); } catch(_) {}
+ } else {
+ const vid = document.getElementById('res-video');
+ if (vid) { vid.pause(); vid.removeAttribute('src'); vid.load(); }
+ }
+ const loadingTxt = document.getElementById('loading-txt');
+ if (loadingTxt) loadingTxt.style.display = "flex";
+ }
+
+ async function run() {
+ // 防止重复点击(_isGeneratingFlag 比 btn.disabled 更可靠)
+ if (_isGeneratingFlag) {
+ addLog(_t('warnGenerating'));
+ return;
+ }
+
+ const btn = document.getElementById('mainBtn');
+ const promptEl = document.getElementById('prompt');
+ const prompt = promptEl ? promptEl.value.trim() : '';
+
+ function batchHasUsablePrompt() {
+ if (prompt) return true;
+ const c = document.getElementById('batch-common-prompt')?.value?.trim();
+ if (c) return true;
+ if (typeof batchWorkflowIsSingle === 'function' && batchWorkflowIsSingle()) {
+ return false;
+ }
+ if (batchImages.length < 2) return false;
+ for (let i = 0; i < batchImages.length - 1; i++) {
+ if (document.getElementById(`batch-segment-prompt-${i}`)?.value?.trim()) return true;
+ }
+ return false;
+ }
+
+ if (currentMode !== 'upscale') {
+ if (currentMode === 'batch') {
+ if (!batchHasUsablePrompt()) {
+ addLog(_t('warnBatchPrompt'));
+ return;
+ }
+ } else if (!prompt) {
+ addLog(_t('warnNeedPrompt'));
+ return;
+ }
+ }
+
+ if (!btn) {
+ console.error('mainBtn not found');
+ return;
+ }
+
+ // 先设置标志 + 禁用按钮,然后用顶层 try/finally 保证一定能解锁
+ _isGeneratingFlag = true;
+ btn.disabled = true;
+
+ try {
+ // 安全地操作 UI 元素(改用 if 判空,防止 Plyr 接管后 getElementById 返回 null)
+ const loader = document.getElementById('loading-txt');
+ const resImg = document.getElementById('res-img');
+ const resVideo = document.getElementById('res-video');
+
+ if (loader) {
+ loader.style.display = "flex";
+ loader.style.flexDirection = "column";
+ loader.style.alignItems = "center";
+ loader.style.gap = "12px";
+ loader.innerHTML = `
+
+ ${escapeHtmlAttr(_t('loaderGpuAlloc'))}
+ `;
+ }
+ if (resImg) resImg.style.display = "none";
+ // 必须隐藏整个 video-wrapper(Plyr 外层容器),否则第二次生成时视频会与 spinner 叠加
+ const videoWrapper = document.getElementById('video-wrapper');
+ if (videoWrapper) videoWrapper.style.display = "none";
+ if (player) { try { player.stop(); } catch(_) {} }
+ else if (resVideo) { resVideo.pause?.(); resVideo.removeAttribute?.('src'); }
+
+ checkStatus();
+
+ // 重置后端状态锁(非关键,失败不影响主流程)
+ try { await fetch(`${BASE}/api/system/reset-state`, { method: 'POST' }); } catch(_) {}
+
+ startProgressPolling();
+
+ // ---- 新增:在历史记录区插入「正在渲染」缩略图卡片 ----
+ const historyContainer = document.getElementById('history-container');
+ if (historyContainer) {
+ const old = document.getElementById('current-loading-card');
+ if (old) old.remove();
+ const loadingCard = document.createElement('div');
+ loadingCard.className = 'history-card loading-card';
+ loadingCard.id = 'current-loading-card';
+ loadingCard.onclick = showGeneratingView;
+ loadingCard.innerHTML = `
+
+ 等待中...
+ `;
+ historyContainer.prepend(loadingCard);
+ }
+
+ // ---- 构建请求 ----
+ let endpoint, payload;
+ if (currentMode === 'image') {
+ const w = parseInt(document.getElementById('img-w').value);
+ const h = parseInt(document.getElementById('img-h').value);
+ endpoint = '/api/generate-image';
+ payload = {
+ prompt, width: w, height: h,
+ numSteps: parseInt(document.getElementById('img-steps').value),
+ numImages: 1
+ };
+ addLog(`正在发起图像渲染: ${w}x${h}, Steps: ${payload.numSteps}`);
+
+ } else if (currentMode === 'video') {
+ const res = updateResPreview();
+ const dur = parseFloat(document.getElementById('vid-duration').value);
+ const fps = document.getElementById('vid-fps').value;
+ if (dur > 20) addLog(_t('warnVideoLong').replace('{n}', String(dur)));
+
+ const audio = document.getElementById('vid-audio').checked ? "true" : "false";
+ const audioPath = document.getElementById('uploaded-audio-path').value;
+ const startFramePathValue = document.getElementById('start-frame-path').value;
+ const endFramePathValue = document.getElementById('end-frame-path').value;
+
+ let finalImagePath = null, finalStartFramePath = null, finalEndFramePath = null;
+ if (startFramePathValue && endFramePathValue) {
+ finalStartFramePath = startFramePathValue;
+ finalEndFramePath = endFramePathValue;
+ } else if (startFramePathValue) {
+ finalImagePath = startFramePathValue;
+ }
+
+ endpoint = '/api/generate';
+ const modelSelect = document.getElementById('vid-model');
+ const loraSelect = document.getElementById('vid-lora');
+ const loraStrengthInput = document.getElementById('lora-strength');
+ const modelPath = modelSelect ? modelSelect.value : '';
+ const loraPath = loraSelect ? loraSelect.value : '';
+ const loraStrength = loraStrengthInput ? (parseFloat(loraStrengthInput.value) || 1.0) : 1.0;
+ console.log("modelPath:", modelPath);
+ console.log("loraPath:", loraPath);
+ console.log("loraStrength:", loraStrength);
+ payload = {
+ prompt, resolution: res, model: "ltx-2",
+ cameraMotion: document.getElementById('vid-motion').value,
+ negativePrompt: "low quality, blurry, noisy, static noise, distorted",
+ duration: String(dur), fps, audio,
+ imagePath: finalImagePath,
+ audioPath: audioPath || null,
+ startFramePath: finalStartFramePath,
+ endFramePath: finalEndFramePath,
+ aspectRatio: document.getElementById('vid-ratio').value,
+ modelPath: modelPath || null,
+ loraPath: loraPath || null,
+ loraStrength: loraStrength,
+ };
+ addLog(`正在发起视频渲染: ${res}, 时长: ${dur}s, FPS: ${fps}, 模型: ${modelPath ? modelPath.split(/[/\\]/).pop() : _t('modelDefaultLabel')}, LoRA: ${loraPath ? loraPath.split(/[/\\]/).pop() : _t('loraNoneLabel')}`);
+
+ } else if (currentMode === 'upscale') {
+ const videoPath = document.getElementById('upscale-video-path').value;
+ const targetRes = document.getElementById('upscale-res').value;
+ if (!videoPath) throw new Error(_t('errUpscaleNoVideo'));
+ endpoint = '/api/system/upscale-video';
+ payload = { video_path: videoPath, resolution: targetRes, prompt: "high quality, detailed, 4k", strength: 0.7 };
+ addLog(`正在发起视频超分: 目标 ${targetRes}`);
+ } else if (currentMode === 'batch') {
+ const res = updateBatchResPreview();
+ const commonPromptEl = document.getElementById('batch-common-prompt');
+ const commonPrompt = commonPromptEl ? commonPromptEl.value : '';
+ const modelSelect = document.getElementById('batch-model');
+ const loraSelect = document.getElementById('batch-lora');
+ const loraStrengthInput = document.getElementById('batch-lora-strength');
+ const modelPath = modelSelect ? modelSelect.value : '';
+ const loraPath = loraSelect ? loraSelect.value : '';
+ const loraStrength = loraStrengthInput ? (parseFloat(loraStrengthInput.value) || 1.2) : 1.2;
+
+ if (batchImages.length < 2) {
+ throw new Error(_t('errBatchMinImages'));
+ }
+
+ if (batchWorkflowIsSingle()) {
+ captureBatchKfTimelineFromDom();
+ const fps = document.getElementById('vid-fps').value;
+ const parts = [prompt.trim(), commonPrompt.trim()].filter(Boolean);
+ const combinedPrompt = parts.join(', ');
+ if (!combinedPrompt) {
+ throw new Error(_t('errSingleKfPrompt'));
+ }
+ const nKf = batchImages.length;
+ const minSeg = 0.1;
+ const segDurs = [];
+ for (let j = 0; j < nKf - 1; j++) {
+ let v = parseFloat(document.getElementById(`batch-kf-seg-dur-${j}`)?.value);
+ if (!Number.isFinite(v) || v < minSeg) v = minSeg;
+ segDurs.push(v);
+ }
+ const sumSec = segDurs.reduce((a, b) => a + b, 0);
+ const dur = Math.max(2, Math.ceil(sumSec - 1e-9));
+ const times = [0];
+ let acc = 0;
+ for (let j = 0; j < nKf - 1; j++) {
+ acc += segDurs[j];
+ times.push(acc);
+ }
+ const strengths = [];
+ for (let i = 0; i < nKf; i++) {
+ const sEl = document.getElementById(`batch-kf-strength-${i}`);
+ let sv = parseFloat(sEl?.value);
+ if (!Number.isFinite(sv)) {
+ sv = parseFloat(defaultKeyframeStrengthForIndex(i, nKf));
+ }
+ if (!Number.isFinite(sv)) sv = 1;
+ sv = Math.max(0.1, Math.min(1.0, sv));
+ strengths.push(sv);
+ }
+ endpoint = '/api/generate';
+ payload = {
+ prompt: combinedPrompt,
+ resolution: res,
+ model: "ltx-2",
+ cameraMotion: document.getElementById('vid-motion').value,
+ negativePrompt: "low quality, blurry, noisy, static noise, distorted",
+ duration: String(dur),
+ fps,
+ audio: "false",
+ imagePath: null,
+ audioPath: null,
+ startFramePath: null,
+ endFramePath: null,
+ keyframePaths: batchImages.map((b) => b.path),
+ keyframeStrengths: strengths,
+ keyframeTimes: times,
+ aspectRatio: document.getElementById('batch-ratio').value,
+ modelPath: modelPath || null,
+ loraPath: loraPath || null,
+ loraStrength: loraStrength,
+ };
+ addLog(
+ `单次多关键帧: ${nKf} 锚点, 轴长合计 ${sumSec.toFixed(1)}s → 请求时长 ${dur}s, ${res}, FPS ${fps}`
+ );
+ } else {
+ const segments = [];
+ for (let i = 0; i < batchImages.length - 1; i++) {
+ const duration = parseFloat(document.getElementById(`batch-segment-duration-${i}`)?.value || 5);
+ const segmentPrompt = document.getElementById(`batch-segment-prompt-${i}`)?.value || '';
+ const segParts = [prompt.trim(), commonPrompt.trim(), segmentPrompt.trim()].filter(Boolean);
+ const combinedSegPrompt = segParts.join(', ');
+ segments.push({
+ startImage: batchImages[i].path,
+ endImage: batchImages[i + 1].path,
+ duration: duration,
+ prompt: combinedSegPrompt
+ });
+ }
+
+ endpoint = '/api/generate-batch';
+ const bgAudioEl = document.getElementById('batch-background-audio-path');
+ const backgroundAudioPath = (bgAudioEl && bgAudioEl.value) ? bgAudioEl.value.trim() : null;
+ payload = {
+ segments: segments,
+ resolution: res,
+ model: "ltx-2",
+ aspectRatio: document.getElementById('batch-ratio').value,
+ modelPath: modelPath || null,
+ loraPath: loraPath || null,
+ loraStrength: loraStrength,
+ negativePrompt: "low quality, blurry, noisy, static noise, distorted",
+ backgroundAudioPath: backgroundAudioPath || null
+ };
+ addLog(`分段拼接: ${segments.length} 段, ${res}${backgroundAudioPath ? ',含统一配乐' : ''}`);
+ }
+ }
+
+ // ---- 发送请求 ----
+ const res = await fetch(BASE + endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ const errMsg = data.error || data.detail || "API 拒绝了请求";
+ throw new Error(typeof errMsg === 'string' ? errMsg : JSON.stringify(errMsg));
+ }
+
+ // ---- 显示结果 ----
+ const rawPath = data.image_paths ? data.image_paths[0] : data.video_path;
+ if (rawPath) {
+ try { displayOutput(rawPath); } catch (dispErr) { addLog(`⚠️ 播放器显示异常: ${dispErr.message}`); }
+ }
+
+ // 强制刷新历史记录(不依赖 isLoadingHistory 标志,确保新生成的视频立即显示)
+ setTimeout(() => {
+ isLoadingHistory = false; // 强制重置状态
+ if (typeof fetchHistory === 'function') fetchHistory(1);
+ }, 500);
+
+ } catch (e) {
+ const errText = e && e.message ? e.message : String(e);
+ addLog(`❌ 渲染中断: ${errText}`);
+ const loader = document.getElementById('loading-txt');
+ if (loader) {
+ loader.style.display = 'flex';
+ loader.textContent = '';
+ const span = document.createElement('span');
+ span.style.cssText = 'color:var(--text-sub);font-size:13px;padding:12px;text-align:center;';
+ span.textContent = `渲染失败:${errText}`;
+ loader.appendChild(span);
+ }
+
+ } finally {
+ // ✅ 无论发生什么,这里一定执行,确保按钮永远可以再次点击
+ _isGeneratingFlag = false;
+ btn.disabled = false;
+ stopProgressPolling();
+ checkStatus();
+ // 生成完毕后自动释放显存(不 await 避免阻塞 UI 解锁)
+ setTimeout(() => { clearGpu(); }, 500);
+ }
+ }
+
+ async function clearGpu() {
+ const btn = document.getElementById('clearGpuBtn');
+ btn.disabled = true;
+ btn.innerText = _t('clearingVram');
+ try {
+ const res = await fetch(`${BASE}/api/system/clear-gpu`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' }
+ });
+ const data = await res.json();
+ if (res.ok) {
+ addLog(`🧹 显存清理成功: ${data.message}`);
+ // 立即触发状态刷新
+ checkStatus();
+ setTimeout(checkStatus, 1000);
+ } else {
+ const errMsg = data.error || data.detail || "后端未实现此接口 (404)";
+ throw new Error(errMsg);
+ }
+ } catch(e) {
+ addLog(`❌ 清理显存失败: ${e.message}`);
+ } finally {
+ btn.disabled = false;
+ btn.innerText = _t('clearVram');
+ }
+ }
+
+ async function listGpus() {
+ try {
+ const res = await fetch(`${BASE}/api/system/list-gpus`);
+ const data = await res.json();
+ if (res.ok && data.gpus) {
+ const selector = document.getElementById('gpu-selector');
+ selector.innerHTML = data.gpus.map(g =>
+ ``
+ ).join('');
+
+ // 更新当前显示的 GPU 名称
+ const activeGpu = data.gpus.find(g => g.active);
+ if (activeGpu) document.getElementById('gpu-name').innerText = activeGpu.name;
+ }
+ } catch (e) {
+ console.error("Failed to list GPUs", e);
+ }
+ }
+
+ async function switchGpu(id) {
+ if (!id) return;
+ addLog(`🔄 正在切换到 GPU ${id}...`);
+ try {
+ const res = await fetch(`${BASE}/api/system/switch-gpu`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ gpu_id: parseInt(id) })
+ });
+ const data = await res.json();
+ if (res.ok) {
+ addLog(`✅ 已成功切换到 GPU ${id},模型将重新加载。`);
+ listGpus(); // 重新获取列表以同步状态
+ setTimeout(checkStatus, 1000);
+ } else {
+ throw new Error(data.error || "切换失败");
+ }
+ } catch (e) {
+ addLog(`❌ GPU 切换失败: ${e.message}`);
+ }
+ }
+
+ function startProgressPolling() {
+ if (pollInterval) clearInterval(pollInterval);
+ pollInterval = setInterval(async () => {
+ try {
+ const res = await fetch(`${BASE}/api/generation/progress`);
+ const d = await res.json();
+ if (d.progress > 0) {
+ const ph = String(d.phase || 'inference');
+ const phaseKey = 'phase_' + ph;
+ let phaseStr = _t(phaseKey);
+ if (phaseStr === phaseKey) phaseStr = ph;
+
+ let stepLabel;
+ if (d.current_step !== undefined && d.current_step !== null && d.total_steps) {
+ stepLabel = `${d.current_step}/${d.total_steps} ${_t('progressStepUnit')}`;
+ } else {
+ stepLabel = `${d.progress}%`;
+ }
+
+ document.getElementById('progress-fill').style.width = d.progress + "%";
+ const loaderStep = document.getElementById('loader-step-text');
+ const busyLine = `${_t('gpuBusyPrefix')}: ${stepLabel} [${phaseStr}]`;
+ if (loaderStep) loaderStep.innerText = busyLine;
+ else {
+ const loadingTxt = document.getElementById('loading-txt');
+ if (loadingTxt) loadingTxt.innerText = busyLine;
+ }
+
+ // 同步更新历史缩略图卡片上的进度文字
+ const cardStep = document.getElementById('loading-card-step');
+ if (cardStep) cardStep.innerText = stepLabel;
+ }
+ } catch(e) {}
+ }, 1000);
+ }
+
+ function stopProgressPolling() {
+ clearInterval(pollInterval);
+ pollInterval = null;
+ document.getElementById('progress-fill').style.width = "0%";
+ // 移除渲染中的卡片(生成已结束)
+ const lc = document.getElementById('current-loading-card');
+ if (lc) lc.remove();
+ }
+
+ function displayOutput(fileOrPath) {
+ const img = document.getElementById('res-img');
+ const vid = document.getElementById('res-video');
+ const loader = document.getElementById('loading-txt');
+
+ // 关键BUG修复:切换前强制清除并停止现有视频和声音,避免后台继续播放
+ if(player) {
+ player.stop();
+ } else {
+ vid.pause();
+ vid.removeAttribute('src');
+ vid.load();
+ }
+
+ let url = "";
+ let fileName = fileOrPath;
+ if (fileOrPath.indexOf('\\') !== -1 || fileOrPath.indexOf('/') !== -1) {
+ url = `${BASE}/api/system/file?path=${encodeURIComponent(fileOrPath)}&t=${Date.now()}`;
+ fileName = fileOrPath.split(/[\\/]/).pop();
+ } else {
+ const outInput = document.getElementById('global-out-dir');
+ const globalDir = outInput ? outInput.value.replace(/\\/g, '/').replace(/\/$/, '') : "";
+ if (globalDir && globalDir !== "") {
+ url = `${BASE}/api/system/file?path=${encodeURIComponent(globalDir + '/' + fileOrPath)}&t=${Date.now()}`;
+ } else {
+ url = `${BASE}/outputs/${fileOrPath}?t=${Date.now()}`;
+ }
+ }
+
+ loader.style.display = "none";
+ if (currentMode === 'image') {
+ img.src = url;
+ img.style.display = "block";
+ addLog(`✅ 图像渲染成功: ${fileName}`);
+ } else {
+ document.getElementById('video-wrapper').style.display = "flex";
+
+ if(player) {
+ player.source = {
+ type: 'video',
+ sources: [{ src: url, type: 'video/mp4' }]
+ };
+ player.play();
+ } else {
+ vid.src = url;
+ }
+ addLog(`✅ 视频渲染成功: ${fileName}`);
+ }
+ }
+
+
+
+ function addLog(msg) {
+ const log = document.getElementById('log');
+ if (!log) {
+ console.log('[LTX]', msg);
+ return;
+ }
+ const time = new Date().toLocaleTimeString();
+ log.innerHTML += ` [${time}] ${msg}
`;
+ log.scrollTop = log.scrollHeight;
+ }
+
+
+// Force switch to video mode on load
+window.addEventListener('DOMContentLoaded', () => switchMode('video'));
+
+
+
+
+
+
+
+
+
+
+
+
+ let currentHistoryPage = 1;
+ let isLoadingHistory = false;
+ /** 与上次成功渲染一致时,silent 轮询跳过整表 innerHTML,避免缩略图周期性重新加载 */
+ let _historyListFingerprint = '';
+
+ function switchLibTab(tab) {
+ document.getElementById('log-container').style.display = tab === 'log' ? 'flex' : 'none';
+ const hw = document.getElementById('history-wrapper');
+ if (hw) hw.style.display = tab === 'history' ? 'block' : 'none';
+
+ document.getElementById('tab-log').style.color = tab === 'log' ? 'var(--accent)' : 'var(--text-dim)';
+ document.getElementById('tab-log').style.borderColor = tab === 'log' ? 'var(--accent)' : 'transparent';
+
+ document.getElementById('tab-history').style.color = tab === 'history' ? 'var(--accent)' : 'var(--text-dim)';
+ document.getElementById('tab-history').style.borderColor = tab === 'history' ? 'var(--accent)' : 'transparent';
+
+ if (tab === 'history') {
+ fetchHistory();
+ }
+ }
+
+ async function fetchHistory(isFirstLoad = false, silent = false) {
+ if (isLoadingHistory) return;
+ isLoadingHistory = true;
+
+ try {
+ // 加载所有历史,不分页
+ const res = await fetch(`${BASE}/api/system/history?page=1&limit=10000`);
+ if (!res.ok) {
+ isLoadingHistory = false;
+ return;
+ }
+ const data = await res.json();
+
+ const validHistory = (data.history || []).filter(item => item && item.filename);
+ const fingerprint = validHistory.length === 0
+ ? '__empty__'
+ : validHistory.map(h => `${h.type}|${h.filename}`).join('\0');
+
+ if (silent && fingerprint === _historyListFingerprint) {
+ return;
+ }
+
+ const container = document.getElementById('history-container');
+ if (!container) {
+ return;
+ }
+
+ let loadingCardHtml = "";
+ const lc = document.getElementById('current-loading-card');
+ if (lc && _isGeneratingFlag) {
+ loadingCardHtml = lc.outerHTML;
+ }
+
+ if (validHistory.length === 0) {
+ container.innerHTML = loadingCardHtml;
+ const newLcEmpty = document.getElementById('current-loading-card');
+ if (newLcEmpty) newLcEmpty.onclick = showGeneratingView;
+ _historyListFingerprint = fingerprint;
+ return;
+ }
+
+ container.innerHTML = loadingCardHtml;
+
+ const outInput = document.getElementById('global-out-dir');
+ const globalDir = outInput ? outInput.value.replace(/\\/g, '/').replace(/\/$/, '') : "";
+
+ const cardsHtml = validHistory.map((item, index) => {
+ const url = (globalDir && globalDir !== "")
+ ? `${BASE}/api/system/file?path=${encodeURIComponent(globalDir + '/' + item.filename)}`
+ : `${BASE}/outputs/${item.filename}`;
+
+ const safeFilename = item.filename.replace(/'/g, "\\'").replace(/"/g, '\\"');
+ const media = item.type === 'video'
+ ? ``
+ : `
`;
+ return `
+
${item.type === 'video' ? '🎬 VID' : '🎨 IMG'}
+
+ ${media}
+
`;
+ }).join('');
+
+ container.insertAdjacentHTML('beforeend', cardsHtml);
+
+ // 重新绑定loading card点击事件
+ const newLc = document.getElementById('current-loading-card');
+ if (newLc) newLc.onclick = showGeneratingView;
+
+ // 加载可见的图片
+ loadVisibleImages();
+ _historyListFingerprint = fingerprint;
+ } catch(e) {
+ console.error("Failed to load history", e);
+ } finally {
+ isLoadingHistory = false;
+ }
+ }
+
+ async function deleteHistoryItem(filename, type, btn) {
+ if (!confirm(`确定要删除 "${filename}" 吗?`)) return;
+
+ try {
+ const res = await fetch(`${BASE}/api/system/delete-file`, {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({filename: filename, type: type})
+ });
+
+ if (res.ok) {
+ // 删除成功后移除元素
+ const card = btn.closest('.history-card');
+ if (card) {
+ card.remove();
+ }
+ } else {
+ alert('删除失败');
+ }
+ } catch(e) {
+ console.error('Delete failed', e);
+ alert('删除失败');
+ }
+ }
+
+ function loadVisibleImages() {
+ const hw = document.getElementById('history-wrapper');
+ if (!hw) return;
+
+ const lazyMedias = document.querySelectorAll('#history-container .lazy-load');
+
+ // 每次只加载3个媒体元素(图片或视频)
+ let loadedCount = 0;
+ lazyMedias.forEach(media => {
+ if (loadedCount >= 3) return;
+
+ const src = media.dataset.src;
+ if (!src) return;
+
+ // 检查是否在可见区域附近
+ const rect = media.getBoundingClientRect();
+ const containerRect = hw.getBoundingClientRect();
+
+ if (rect.top < containerRect.bottom + 300 && rect.bottom > containerRect.top - 100) {
+ let revealed = false;
+ let thumbRevealTimer;
+ const revealThumb = () => {
+ if (revealed) return;
+ revealed = true;
+ if (thumbRevealTimer) clearTimeout(thumbRevealTimer);
+ media.classList.add('history-thumb-ready');
+ };
+ thumbRevealTimer = setTimeout(revealThumb, 4000);
+
+ if (media.tagName === 'VIDEO') {
+ media.addEventListener('loadeddata', revealThumb, { once: true });
+ media.addEventListener('error', revealThumb, { once: true });
+ } else {
+ media.addEventListener('load', revealThumb, { once: true });
+ media.addEventListener('error', revealThumb, { once: true });
+ }
+
+ media.src = src;
+ media.classList.remove('lazy-load');
+
+ if (media.tagName === 'VIDEO') {
+ media.preload = 'metadata';
+ if (media.readyState >= 2) revealThumb();
+ } else if (media.complete && media.naturalWidth > 0) {
+ revealThumb();
+ }
+
+ loadedCount++;
+ }
+ });
+
+ // 继续检查直到没有更多媒体需要加载
+ if (loadedCount > 0) {
+ setTimeout(loadVisibleImages, 100);
+ }
+ }
+
+ // 监听history-wrapper的滚动事件来懒加载
+ function initHistoryScrollListener() {
+ const hw = document.getElementById('history-wrapper');
+ if (!hw) return;
+
+ let scrollTimeout;
+ hw.addEventListener('scroll', () => {
+ if (scrollTimeout) clearTimeout(scrollTimeout);
+ scrollTimeout = setTimeout(() => {
+ loadVisibleImages();
+ }, 100);
+ });
+ }
+
+ // 页面加载时初始化滚动监听
+ window.addEventListener('DOMContentLoaded', () => {
+ setTimeout(initHistoryScrollListener, 500);
+ });
+
+ function displayHistoryOutput(file, type) {
+ document.getElementById('res-img').style.display = 'none';
+ document.getElementById('video-wrapper').style.display = 'none';
+
+ const mode = type === 'video' ? 'video' : 'image';
+ switchMode(mode);
+ displayOutput(file);
+ }
+
+ window.addEventListener('DOMContentLoaded', () => {
+ // Initialize Plyr Custom Video Component
+ if(window.Plyr) {
+ player = new Plyr('#res-video', {
+ controls: [
+ 'play-large', 'play', 'progress', 'current-time',
+ 'mute', 'volume', 'fullscreen'
+ ],
+ settings: [],
+ loop: { active: true },
+ autoplay: true
+ });
+ }
+
+ // Fetch current directory context to show in UI
+ fetch(`${BASE}/api/system/get-dir`)
+ .then((res) => res.json())
+ .then((data) => {
+ if (data && data.directory) {
+ const outInput = document.getElementById('global-out-dir');
+ if (outInput) outInput.value = data.directory;
+ }
+ })
+ .catch((e) => console.error(e))
+ .finally(() => {
+ /* 先同步输出目录再拉历史,避免短时间内两次 fetchHistory 整表重绘导致缩略图闪两下 */
+ switchLibTab('history');
+ });
+
+ // Load LoRA dir from settings
+ loadLoraDir();
+
+ let historyRefreshInterval = null;
+ function startHistoryAutoRefresh() {
+ if (historyRefreshInterval) return;
+ historyRefreshInterval = setInterval(() => {
+ const hc = document.getElementById('history-container');
+ if (hc && hc.offsetParent !== null && !_isGeneratingFlag) {
+ fetchHistory(1, true);
+ }
+ }, 5000);
+ }
+ startHistoryAutoRefresh();
+ });
\ No newline at end of file
diff --git a/LTX2.3-1.0.3/main.py b/LTX2.3-1.0.3/main.py
new file mode 100644
index 0000000000000000000000000000000000000000..623c03912cd3dab626916247af5e9757b61f4803
--- /dev/null
+++ b/LTX2.3-1.0.3/main.py
@@ -0,0 +1,264 @@
+import os
+import sys
+import subprocess
+import threading
+import time
+import socket
+import logging
+from fastapi import FastAPI
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
+import uvicorn
+
+# ============================================================
+# 配置区 (动态路径适配与补丁挂载)
+# ============================================================
+def resolve_ltx_path():
+ import glob, tempfile, subprocess
+ sc_dir = os.path.join(os.getcwd(), "LTX_Shortcut")
+ os.makedirs(sc_dir, exist_ok=True)
+ lnk_files = glob.glob(os.path.join(sc_dir, "*.lnk"))
+ if not lnk_files:
+ print("\033[91m[ERROR] 未在 LTX_Shortcut 文件夹中找到快捷方式!\n请打开程序目录下的 LTX_Shortcut 文件夹,并将官方 LTX Desktop 的快捷方式复制进去后重试。\033[0m")
+ sys.exit(1)
+
+ lnk_path = lnk_files[0]
+ # 使用 VBScript 解析快捷方式,兼容所有 Windows 系统
+ vbs_code = f'''Set sh = CreateObject("WScript.Shell")\nSet obj = sh.CreateShortcut("{os.path.abspath(lnk_path)}")\nWScript.Echo obj.TargetPath'''
+ fd, vbs_path = tempfile.mkstemp(suffix='.vbs')
+ with os.fdopen(fd, 'w') as f:
+ f.write(vbs_code)
+ try:
+ out = subprocess.check_output(['cscript', '//nologo', vbs_path], stderr=subprocess.STDOUT)
+ target_exe = out.decode('ansi').strip()
+ finally:
+ os.remove(vbs_path)
+
+ if not target_exe or not os.path.exists(target_exe):
+ # 如果快捷方式解析失败,或者解析出来的是朋友电脑的路径(当前电脑不存在),自动全盘搜索默认路径
+ default_paths = [
+ os.path.join(os.environ.get("LOCALAPPDATA", ""), r"Programs\LTX Desktop\LTX Desktop.exe"),
+ r"C:\Program Files\LTX Desktop\LTX Desktop.exe",
+ r"D:\Program Files\LTX Desktop\LTX Desktop.exe",
+ r"E:\Program Files\LTX Desktop\LTX Desktop.exe"
+ ]
+ found = False
+ for p in default_paths:
+ if os.path.exists(p):
+ target_exe = p
+ print(f"\033[96m[INFO] 自动检测到 LTX 原版安装路径: {p}\033[0m")
+ found = True
+ break
+
+ if not found:
+ print(f"\033[91m[ERROR] 未能找到原版 LTX Desktop 的安装路径!\033[0m")
+ print("请清理 LTX_Shortcut 文件夹,并将您当前电脑上真正的原版快捷方式重贴复制进去。")
+ sys.exit(1)
+
+ return os.path.dirname(target_exe)
+
+USER_PROFILE = os.path.expanduser("~")
+PYTHON_EXE = os.path.join(USER_PROFILE, r"AppData\Local\LTXDesktop\python\python.exe")
+DATA_DIR = os.path.join(USER_PROFILE, r"AppData\Local\LTXDesktop")
+
+# 1. 动态获取主安装路径
+LTX_INSTALL_DIR = resolve_ltx_path()
+BACKEND_DIR = os.path.join(LTX_INSTALL_DIR, r"resources\backend")
+UI_FILE_NAME = "UI/index.html"
+
+# 环境致命检测:如果官方 Python 还没解压释放,立刻强制中断整个程序
+if not os.path.exists(PYTHON_EXE):
+ print(f"\n\033[1;41m [致命错误] 您的电脑上尚未配置好 LTX 的官方渲染核心框架! \033[0m")
+ print(f"\033[93m此应用仅是 UI 图形控制台,必需依赖原版软件环境才能生成。在 ({PYTHON_EXE}) 未找到运行引擎。\n")
+ print(">> 解决方案:\n1. 请先在您的电脑上正常安装【LTX Desktop 官方原版软件】。")
+ print("2. 必需:双击打开运行一次原版软件!(运行后原版软件会在后台自动释放环境)")
+ print("3. 把原版软件的快捷方式复制到本文档的 LTX_Shortcut 文件夹里面。")
+ print("4. 全部完成后,再重新启动本 run.bat 脚本即可!\033[0m\n")
+ os._exit(1)
+
+# 2. 从目录读取改动过的 Python 文件 (热修复拦截器)
+PATCHES_DIR = os.path.join(os.getcwd(), "patches")
+os.makedirs(PATCHES_DIR, exist_ok=True)
+
+# 3. 默认输出定向至程序根目录
+LOCAL_OUTPUTS = os.path.join(os.getcwd(), "outputs")
+os.makedirs(LOCAL_OUTPUTS, exist_ok=True)
+
+# 强制注入自定义输出录至 LTX 缓存数据中
+os.makedirs(DATA_DIR, exist_ok=True)
+with open(os.path.join(DATA_DIR, "custom_dir.txt"), 'w', encoding='utf-8') as f:
+ f.write(LOCAL_OUTPUTS)
+
+os.environ["LTX_APP_DATA_DIR"] = DATA_DIR
+
+# 将 patches 目录优先级提升,做到 Python 无损替换
+os.environ["PYTHONPATH"] = f"{PATCHES_DIR};{BACKEND_DIR}"
+
+def get_lan_ip():
+ try:
+ host_name = socket.gethostname()
+ _, _, ip_list = socket.gethostbyname_ex(host_name)
+
+ candidates = []
+ for ip in ip_list:
+ if ip.startswith("192.168."):
+ return ip
+ elif ip.startswith("10.") or (ip.startswith("172.") and 16 <= int(ip.split('.')[1]) <= 31):
+ candidates.append(ip)
+
+ if candidates:
+ return candidates[0]
+
+ # Fallback to the default socket routing approach if no obvious LAN IP found
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ s.connect(("8.8.8.8", 80))
+ ip = s.getsockname()[0]
+ s.close()
+ return ip
+ except:
+ return "127.0.0.1"
+
+LAN_IP = get_lan_ip()
+
+# ============================================================
+# 服务启动逻辑
+# ============================================================
+def check_port_in_use(port):
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ return s.connect_ex(('127.0.0.1', port)) == 0
+
+def launch_backend():
+ """启动核心引擎 - 监听 0.0.0.0 确保局域网可调"""
+ if check_port_in_use(3000):
+ print(f"\n\033[1;41m [致命错误] 3000 端口已被占用,无法启动核心引擎! \033[0m")
+ print("\033[93m>> 绝大多数情况下,这是因为【官方原版 LTX Desktop】正在您的电脑后台运行。\033[0m")
+ print(">> 冲突会导致显存爆炸。请检查右下角系统托盘图标,右键完全退出官方软件。")
+ print(">> 退出后重新双击 run.bat 启动本程序!\n")
+ os._exit(1)
+
+ print(f"\033[96m[CORE] 核心引擎正在启动...\033[0m")
+ # 只开启重要级别的 Python 应用层日志,去除无用的 HTTP 刷屏
+ import logging as _logging
+ _logging.basicConfig(
+ level=_logging.INFO,
+ format="[%(asctime)s] %(levelname)s %(name)s: %(message)s",
+ datefmt="%H:%M:%S",
+ force=True
+ )
+
+ # 构建绝对无损的环境拦截器:防止其他电脑被 cwd 劫持加载原版文件
+ launcher_code = f"""
+import sys
+import os
+
+patch_dir = r"{PATCHES_DIR}"
+backend_dir = r"{BACKEND_DIR}"
+
+# 防御性清除:强行剥离所有的默认 backend_dir 引用
+sys.path = [p for p in sys.path if p and os.path.normpath(p) != os.path.normpath(backend_dir)]
+sys.path = [p for p in sys.path if p and p != "." and p != ""]
+
+# 绝对插队注入:优先搜索 PATCHES_DIR
+sys.path.insert(0, patch_dir)
+sys.path.insert(1, backend_dir)
+
+import uvicorn
+from ltx2_server import app
+
+if __name__ == '__main__':
+ uvicorn.run(app, host="0.0.0.0", port=3000, log_level="info", access_log=False)
+"""
+ launcher_path = os.path.join(PATCHES_DIR, "launcher.py")
+ with open(launcher_path, "w", encoding="utf-8") as f:
+ f.write(launcher_code)
+
+ cmd = [PYTHON_EXE, launcher_path]
+ env = os.environ.copy()
+ result = subprocess.run(cmd, cwd=BACKEND_DIR, env=env)
+ if result.returncode != 0:
+ print(f"\n\033[1;41m [致命错误] 核心引擎异常崩溃退出! (Exit Code: {result.returncode})\033[0m")
+ print(">> 请检查上述终端报错信息。确认显卡驱动是否正常。")
+ os._exit(1)
+
+ui_app = FastAPI()
+# 已移除存在安全隐患的静态资源挂载目录
+
+@ui_app.get("/")
+async def serve_index():
+ return FileResponse(os.path.join(os.getcwd(), UI_FILE_NAME))
+
+@ui_app.get("/index.css")
+async def serve_css():
+ return FileResponse(os.path.join(os.getcwd(), "UI/index.css"))
+
+@ui_app.get("/index.js")
+async def serve_js():
+ return FileResponse(os.path.join(os.getcwd(), "UI/index.js"))
+
+
+@ui_app.get("/i18n.js")
+async def serve_i18n():
+ return FileResponse(os.path.join(os.getcwd(), "UI/i18n.js"))
+
+
+def launch_ui_server():
+ print(f"\033[92m[UI] 工作站已就绪!\033[0m")
+ print(f"\033[92m[LOCAL] 本机访问: http://127.0.0.1:4000\033[0m")
+ print(f"\033[93m[WIFI] 局域网访问: http://{LAN_IP}:4000\033[0m")
+
+ # 彻底压制 WinError 10054 (客户端强制断开) 的底层警告报错
+ if sys.platform == 'win32':
+ # Uvicorn 内部会拉起循环,所以只能通过底层 Logging Filter 拦截控制台噪音
+ class UvicornAsyncioNoiseFilter(logging.Filter):
+ """压掉客户端断开、Win Proactor 管道收尾等无害 asyncio 控制台刷屏。"""
+
+ def filter(self, record):
+ if record.name != "asyncio":
+ return True
+ msg = record.getMessage()
+ if "_call_connection_lost" in msg or "_ProactorBasePipeTransport" in msg:
+ return False
+ if hasattr(record, "exc_info") and record.exc_info:
+ exc_type, exc_value, _ = record.exc_info
+ if isinstance(exc_value, ConnectionResetError) and getattr(
+ exc_value, "winerror", None
+ ) == 10054:
+ return False
+ if "10054" in msg and "ConnectionResetError" in msg:
+ return False
+ return True
+
+ logging.getLogger("asyncio").addFilter(UvicornAsyncioNoiseFilter())
+
+ uvicorn.run(ui_app, host="0.0.0.0", port=4000, log_level="warning", access_log=False)
+
+if __name__ == "__main__":
+ os.system('cls' if os.name == 'nt' else 'clear')
+ print("\033[1;97;44m LTX-2 CINEMATIC WORKSTATION | NETWORK ENABLED \033[0m\n")
+
+ threading.Thread(target=launch_backend, daemon=True).start()
+
+ # 强制校验 3000 端口是否存活
+ print("\033[93m[SYS] 正在等待内部核心 3000 端口启动...\033[0m")
+ backend_ready = False
+ for _ in range(30):
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ if s.connect_ex(('127.0.0.1', 3000)) == 0:
+ backend_ready = True
+ break
+ except Exception:
+ pass
+ time.sleep(1)
+
+ if backend_ready:
+ print("\033[92m[SYS] 3000 端口已通过连通性握手验证!后端装载成功。\033[0m")
+ else:
+ print("\033[1;41m [崩坏警告] 等待 30 秒后,3000 端口依然无法连通! \033[0m")
+ print(">> Uvicorn 可能在后台陷入了死锁,或者被防火墙拦截,前端大概率将无法连接到后端!")
+ print(">> 请检查上方是否有 Python 报错。\n")
+
+ try:
+ launch_ui_server()
+ except KeyboardInterrupt:
+ sys.exit(0)
\ No newline at end of file
diff --git "a/LTX2.3-1.0.3/patches/API\346\250\241\345\274\217\351\227\256\351\242\230\344\277\256\345\244\215\350\257\264\346\230\216.md" "b/LTX2.3-1.0.3/patches/API\346\250\241\345\274\217\351\227\256\351\242\230\344\277\256\345\244\215\350\257\264\346\230\216.md"
new file mode 100644
index 0000000000000000000000000000000000000000..6a8315a5893937612f2a354b599d732387b59e00
--- /dev/null
+++ "b/LTX2.3-1.0.3/patches/API\346\250\241\345\274\217\351\227\256\351\242\230\344\277\256\345\244\215\350\257\264\346\230\216.md"
@@ -0,0 +1,41 @@
+# LTX 本地显卡模式修复
+
+## 问题描述
+系统强制使用 FAL API 生成图片,即使本地有 GPU 可用。
+
+## 原因
+LTX 强制要求 GPU 有 31GB VRAM 才会使用本地显卡,低于此值会强制走 API 模式。
+
+## 修复方法
+
+### 方法一:自动替换(推荐)
+运行程序后,patches 目录中的文件会自动替换原版文件。
+
+### 方法二:手动替换
+
+#### 1. 修改 VRAM 阈值
+- **原文件**: `C:\Program Files\LTX Desktop\resources\backend\runtime_config\runtime_policy.py`
+- **找到** (第16行):
+ ```python
+ return vram_gb < 31
+ ```
+- **改为**:
+ ```python
+ return vram_gb < 6
+ ```
+
+#### 2. 清空无效 API Key
+- **原文件**: `C:\Users\Administrator\AppData\Local\LTXDesktop\settings.json`
+- **找到**:
+ ```json
+ "fal_api_key": "12123",
+ ```
+- **改为**:
+ ```json
+ "fal_api_key": "",
+ ```
+
+## 说明
+- VRAM 阈值改为 6GB,意味着 6GB 及以上显存都会使用本地显卡
+- 清空 fal_api_key 避免系统误判为已配置 API
+- 修改后重启程序即可生效
diff --git a/LTX2.3-1.0.3/patches/__pycache__/api_types.cpython-313.pyc b/LTX2.3-1.0.3/patches/__pycache__/api_types.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..18ba190435aa203bb6cb77ea358847ffc5c27b81
Binary files /dev/null and b/LTX2.3-1.0.3/patches/__pycache__/api_types.cpython-313.pyc differ
diff --git a/LTX2.3-1.0.3/patches/__pycache__/app_factory.cpython-313.pyc b/LTX2.3-1.0.3/patches/__pycache__/app_factory.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..317dbfc2cece73c0c50fee159739fe7830785659
--- /dev/null
+++ b/LTX2.3-1.0.3/patches/__pycache__/app_factory.cpython-313.pyc
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2b04b062dc4f636cf8e5af8bc25b6b6ef8e2c4e7898970d7b103f28c41c7ae7a
+size 101329
diff --git a/LTX2.3-1.0.3/patches/__pycache__/app_factory.cpython-314.pyc b/LTX2.3-1.0.3/patches/__pycache__/app_factory.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..cef8770d56c5651ba5e32c2a0cbd113626b86993
Binary files /dev/null and b/LTX2.3-1.0.3/patches/__pycache__/app_factory.cpython-314.pyc differ
diff --git a/LTX2.3-1.0.3/patches/__pycache__/keep_models_runtime.cpython-313.pyc b/LTX2.3-1.0.3/patches/__pycache__/keep_models_runtime.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..c9b4022c3acd41cd68bf7893d1c03b51f97ed49d
Binary files /dev/null and b/LTX2.3-1.0.3/patches/__pycache__/keep_models_runtime.cpython-313.pyc differ
diff --git a/LTX2.3-1.0.3/patches/__pycache__/lora_build_hook.cpython-313.pyc b/LTX2.3-1.0.3/patches/__pycache__/lora_build_hook.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..6eb83d86b761b5d08b8c4480f3d9444d10243c93
Binary files /dev/null and b/LTX2.3-1.0.3/patches/__pycache__/lora_build_hook.cpython-313.pyc differ
diff --git a/LTX2.3-1.0.3/patches/__pycache__/lora_injection.cpython-313.pyc b/LTX2.3-1.0.3/patches/__pycache__/lora_injection.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..98cc614c9860042e35aff6be3c4ef3ac45ffa4e5
Binary files /dev/null and b/LTX2.3-1.0.3/patches/__pycache__/lora_injection.cpython-313.pyc differ
diff --git a/LTX2.3-1.0.3/patches/__pycache__/low_vram_runtime.cpython-313.pyc b/LTX2.3-1.0.3/patches/__pycache__/low_vram_runtime.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..c5669ae991e9bb1ff1bbeaf0d8b32c49d2135912
Binary files /dev/null and b/LTX2.3-1.0.3/patches/__pycache__/low_vram_runtime.cpython-313.pyc differ
diff --git a/LTX2.3-1.0.3/patches/api_types.py b/LTX2.3-1.0.3/patches/api_types.py
new file mode 100644
index 0000000000000000000000000000000000000000..3514f072ae810e787ae3390db4858127663fbd10
--- /dev/null
+++ b/LTX2.3-1.0.3/patches/api_types.py
@@ -0,0 +1,395 @@
+"""Pydantic request/response models and TypedDicts for ltx2_server."""
+
+from __future__ import annotations
+
+from typing import Literal, NamedTuple, TypeAlias, TypedDict
+from typing import Annotated
+
+from pydantic import BaseModel, Field, StringConstraints
+
+NonEmptyPrompt = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
+ModelFileType = Literal[
+ "checkpoint",
+ "upsampler",
+ "distilled_lora",
+ "ic_lora",
+ "depth_processor",
+ "person_detector",
+ "pose_processor",
+ "text_encoder",
+ "zit",
+]
+
+
+class ImageConditioningInput(NamedTuple):
+ """Image conditioning triplet used by all video pipelines."""
+
+ path: str
+ frame_idx: int
+ strength: float
+
+
+# ============================================================
+# TypedDicts for module-level state globals
+# ============================================================
+
+
+class GenerationState(TypedDict):
+ id: str | None
+ cancelled: bool
+ result: str | list[str] | None
+ error: str | None
+ status: str # "idle" | "running" | "complete" | "cancelled" | "error"
+ phase: str
+ progress: int
+ current_step: int
+ total_steps: int
+
+
+JsonObject: TypeAlias = dict[str, object]
+VideoCameraMotion = Literal[
+ "none",
+ "dolly_in",
+ "dolly_out",
+ "dolly_left",
+ "dolly_right",
+ "jib_up",
+ "jib_down",
+ "static",
+ "focus_shift",
+]
+
+RetakeMode: TypeAlias = Literal[
+ "replace_audio_and_video", "replace_video", "replace_audio"
+]
+
+
+# ============================================================
+# Response Models
+# ============================================================
+
+
+class ModelStatusItem(BaseModel):
+ id: str
+ name: str
+ loaded: bool
+ downloaded: bool
+
+
+class GpuTelemetry(BaseModel):
+ name: str
+ vram: int
+ vramUsed: int
+
+
+class HealthResponse(BaseModel):
+ status: str
+ models_loaded: bool
+ active_model: str | None
+ gpu_info: GpuTelemetry
+ sage_attention: bool
+ models_status: list[ModelStatusItem]
+
+
+class GpuInfoResponse(BaseModel):
+ cuda_available: bool
+ mps_available: bool = False
+ gpu_available: bool = False
+ gpu_name: str | None
+ vram_gb: int | None
+ gpu_info: GpuTelemetry
+
+
+class RuntimePolicyResponse(BaseModel):
+ force_api_generations: bool
+
+
+class GenerationProgressResponse(BaseModel):
+ status: str
+ phase: str
+ progress: int
+ currentStep: int | None
+ totalSteps: int | None
+
+
+class ModelInfo(BaseModel):
+ id: str
+ name: str
+ description: str
+
+
+class ModelFileStatus(BaseModel):
+ id: ModelFileType
+ name: str
+ description: str
+ downloaded: bool
+ size: int
+ expected_size: int
+ required: bool = True
+ is_folder: bool = False
+ optional_reason: str | None = None
+
+
+class TextEncoderStatus(BaseModel):
+ downloaded: bool
+ size_bytes: int
+ size_gb: float
+ expected_size_gb: float
+
+
+class ModelsStatusResponse(BaseModel):
+ models: list[ModelFileStatus]
+ all_downloaded: bool
+ total_size: int
+ downloaded_size: int
+ total_size_gb: float
+ downloaded_size_gb: float
+ models_path: str
+ has_api_key: bool
+ text_encoder_status: TextEncoderStatus
+ use_local_text_encoder: bool
+
+
+class DownloadProgressRunningResponse(BaseModel):
+ status: Literal["downloading"]
+ current_downloading_file: ModelFileType | None
+ current_file_progress: float
+ total_progress: float
+ total_downloaded_bytes: int
+ expected_total_bytes: int
+ completed_files: set[ModelFileType]
+ all_files: set[ModelFileType]
+ error: None = None
+ speed_bytes_per_sec: float
+
+
+class DownloadProgressCompleteResponse(BaseModel):
+ status: Literal["complete"]
+
+
+class DownloadProgressErrorResponse(BaseModel):
+ status: Literal["error"]
+ error: str
+
+
+DownloadProgressResponse: TypeAlias = (
+ DownloadProgressRunningResponse
+ | DownloadProgressCompleteResponse
+ | DownloadProgressErrorResponse
+)
+
+
+class SuggestGapPromptResponse(BaseModel):
+ status: str = "success"
+ suggested_prompt: str
+
+
+class GenerateVideoCompleteResponse(BaseModel):
+ status: Literal["complete"]
+ video_path: str
+
+
+class GenerateVideoCancelledResponse(BaseModel):
+ status: Literal["cancelled"]
+
+
+GenerateVideoResponse: TypeAlias = (
+ GenerateVideoCompleteResponse | GenerateVideoCancelledResponse
+)
+
+
+class GenerateImageCompleteResponse(BaseModel):
+ status: Literal["complete"]
+ image_paths: list[str]
+
+
+class GenerateImageCancelledResponse(BaseModel):
+ status: Literal["cancelled"]
+
+
+GenerateImageResponse: TypeAlias = (
+ GenerateImageCompleteResponse | GenerateImageCancelledResponse
+)
+
+
+class CancelCancellingResponse(BaseModel):
+ status: Literal["cancelling"]
+ id: str
+
+
+class CancelNoActiveGenerationResponse(BaseModel):
+ status: Literal["no_active_generation"]
+
+
+CancelResponse: TypeAlias = CancelCancellingResponse | CancelNoActiveGenerationResponse
+
+
+class RetakeVideoResponse(BaseModel):
+ status: Literal["complete"]
+ video_path: str
+
+
+class RetakePayloadResponse(BaseModel):
+ status: Literal["complete"]
+ result: JsonObject
+
+
+class RetakeCancelledResponse(BaseModel):
+ status: Literal["cancelled"]
+
+
+RetakeResponse: TypeAlias = (
+ RetakeVideoResponse | RetakePayloadResponse | RetakeCancelledResponse
+)
+
+
+class IcLoraExtractResponse(BaseModel):
+ conditioning: str
+ original: str
+ conditioning_type: Literal["canny", "depth"]
+ frame_time: float
+
+
+class IcLoraGenerateCompleteResponse(BaseModel):
+ status: Literal["complete"]
+ video_path: str
+
+
+class IcLoraGenerateCancelledResponse(BaseModel):
+ status: Literal["cancelled"]
+
+
+IcLoraGenerateResponse: TypeAlias = (
+ IcLoraGenerateCompleteResponse | IcLoraGenerateCancelledResponse
+)
+
+
+class ModelDownloadStartResponse(BaseModel):
+ status: Literal["started"]
+ message: str
+ sessionId: str
+
+
+class TextEncoderDownloadStartedResponse(BaseModel):
+ status: Literal["started"]
+ message: str
+ sessionId: str
+
+
+class TextEncoderAlreadyDownloadedResponse(BaseModel):
+ status: Literal["already_downloaded"]
+ message: str
+
+
+TextEncoderDownloadResponse: TypeAlias = (
+ TextEncoderDownloadStartedResponse | TextEncoderAlreadyDownloadedResponse
+)
+
+
+class StatusResponse(BaseModel):
+ status: str
+
+
+class ErrorResponse(BaseModel):
+ error: str
+ message: str | None = None
+
+
+# ============================================================
+# Request Models
+# ============================================================
+
+
+class GenerateVideoRequest(BaseModel):
+ prompt: NonEmptyPrompt
+ resolution: str = "512p"
+ model: str = "fast"
+ cameraMotion: VideoCameraMotion = "none"
+ negativePrompt: str = ""
+ duration: str = "2"
+ fps: str = "24"
+ audio: str = "false"
+ imagePath: str | None = None
+ audioPath: str | None = None
+ startFramePath: str | None = None
+ endFramePath: str | None = None
+ # 多张图单次推理:latent 时间轴多锚点(Comfy LTXVAddGuideMulti 思路);≥2 路径时优先于首尾帧
+ keyframePaths: list[str] | None = None
+ # 与 keyframePaths 等长、0.1–1.0;不传则按 Comfy 类工作流自动降低中间帧强度,减轻闪烁
+ keyframeStrengths: list[float] | None = None
+ # 与 keyframePaths 等长,单位秒,落在 [0, 整段时长];全提供时按时间映射 latent,否则仍自动均分
+ keyframeTimes: list[float] | None = None
+ aspectRatio: Literal["16:9", "9:16"] = "16:9"
+ modelPath: str | None = None
+ loraPath: str | None = None
+ loraStrength: float = 1.0
+
+
+class GenerateImageRequest(BaseModel):
+ prompt: NonEmptyPrompt
+ width: int = 1024
+ height: int = 1024
+ numSteps: int = 4
+ numImages: int = 1
+
+
+def _default_model_types() -> set[ModelFileType]:
+ return set()
+
+
+class ModelDownloadRequest(BaseModel):
+ modelTypes: set[ModelFileType] = Field(default_factory=_default_model_types)
+
+
+class RequiredModelsResponse(BaseModel):
+ modelTypes: list[ModelFileType]
+
+
+class SuggestGapPromptRequest(BaseModel):
+ beforePrompt: str = ""
+ afterPrompt: str = ""
+ beforeFrame: str | None = None
+ afterFrame: str | None = None
+ gapDuration: float = 5
+ mode: str = "t2v"
+ inputImage: str | None = None
+
+
+class RetakeRequest(BaseModel):
+ video_path: str
+ start_time: float = 0
+ duration: float = 0
+ prompt: str = ""
+ mode: str = "replace_video_only"
+ width: int | None = None
+ height: int | None = None
+
+
+class IcLoraExtractRequest(BaseModel):
+ video_path: str
+ conditioning_type: Literal["canny", "depth"] = "canny"
+ frame_time: float = 0
+
+
+class IcLoraImageInput(BaseModel):
+ path: str
+ frame: int = 0
+ strength: float = 1.0
+
+
+def _default_ic_lora_images() -> list[IcLoraImageInput]:
+ return []
+
+
+class IcLoraGenerateRequest(BaseModel):
+ video_path: str
+ conditioning_type: Literal["canny", "depth"]
+ prompt: NonEmptyPrompt
+ conditioning_strength: float = 1.0
+ num_inference_steps: int = 30
+ cfg_guidance_scale: float = 1.0
+ negative_prompt: str = ""
+ images: list[IcLoraImageInput] = Field(default_factory=_default_ic_lora_images)
+
+
+ConditioningType: TypeAlias = Literal["canny", "depth"]
diff --git a/LTX2.3-1.0.3/patches/app_factory.py b/LTX2.3-1.0.3/patches/app_factory.py
new file mode 100644
index 0000000000000000000000000000000000000000..927127995d510413ef6715a44c0ce8095695f096
--- /dev/null
+++ b/LTX2.3-1.0.3/patches/app_factory.py
@@ -0,0 +1,2355 @@
+"""FastAPI app factory decoupled from runtime bootstrap side effects."""
+
+from __future__ import annotations
+
+import base64
+import hmac
+import os
+
+# 防 OOM 与显存碎片化补丁:在 torch 初始化之前注入环境变量
+os.environ["PYTORCH_ALLOC_CONF"] = "expandable_segments:True"
+import torch # 提升到顶层导入
+from collections.abc import Awaitable, Callable
+from typing import TYPE_CHECKING
+from pathlib import Path # 必须导入,用于处理 Windows 路径
+
+from fastapi import FastAPI, Request, UploadFile, File
+from fastapi.exceptions import RequestValidationError
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+from pydantic import ConfigDict
+from fastapi.staticfiles import StaticFiles # 必须导入,用于挂载静态目录
+from starlette.responses import Response as StarletteResponse
+import shutil
+import tempfile
+import time
+from api_types import (
+ GenerateVideoRequest,
+ GenerateVideoResponse,
+ ImageConditioningInput,
+)
+
+from _routes._errors import HTTPError
+from _routes.generation import router as generation_router
+from _routes.health import router as health_router
+from _routes.ic_lora import router as ic_lora_router
+from _routes.image_gen import router as image_gen_router
+from _routes.models import router as models_router
+from _routes.suggest_gap_prompt import router as suggest_gap_prompt_router
+from _routes.retake import router as retake_router
+from _routes.runtime_policy import router as runtime_policy_router
+from _routes.settings import router as settings_router
+from logging_policy import log_http_error, log_unhandled_exception
+from state import init_state_service
+
+if TYPE_CHECKING:
+ from app_handler import AppHandler
+
+# 跨域配置:允许所有来源,解决本地网页调用限制
+DEFAULT_ALLOWED_ORIGINS: list[str] = ["*"]
+
+
+def _ltx_desktop_config_dir() -> Path:
+ p = (
+ Path(os.environ.get("LOCALAPPDATA", os.path.expanduser("~/AppData/Local")))
+ / "LTXDesktop"
+ )
+ p.mkdir(parents=True, exist_ok=True)
+ return p.resolve()
+
+
+def _extend_generate_video_request_model() -> None:
+ """Keep custom video fields working across upstream request-model changes."""
+ annotations = dict(getattr(GenerateVideoRequest, "__annotations__", {}))
+ changed = False
+
+ for field_name, ann in (
+ ("startFramePath", str | None),
+ ("endFramePath", str | None),
+ ("keyframePaths", list[str] | None),
+ ("keyframeStrengths", list[float] | None),
+ ("keyframeTimes", list[float] | None),
+ ):
+ if field_name not in annotations:
+ annotations[field_name] = ann
+ setattr(GenerateVideoRequest, field_name, None)
+ changed = True
+
+ if changed:
+ GenerateVideoRequest.__annotations__ = annotations
+
+ existing_config = dict(getattr(GenerateVideoRequest, "model_config", {}) or {})
+ if existing_config.get("extra") != "allow":
+ existing_config["extra"] = "allow"
+ GenerateVideoRequest.model_config = ConfigDict(**existing_config)
+ changed = True
+
+ if changed:
+ GenerateVideoRequest.model_rebuild(force=True)
+
+
+def create_app(
+ *,
+ handler: "AppHandler",
+ allowed_origins: list[str] | None = None,
+ title: str = "LTX-2 Video Generation Server",
+ auth_token: str = "",
+ admin_token: str = "",
+) -> FastAPI:
+ """Create a configured FastAPI app bound to the provided handler."""
+ init_state_service(handler)
+ _extend_generate_video_request_model()
+
+ app = FastAPI(title=title)
+ app.state.admin_token = admin_token # type: ignore[attr-defined]
+
+ # 彻底压制 WinError 10054 (客户端强制断开) 的底层警告报错
+ import sys, asyncio
+
+ if sys.platform == "win32":
+ try:
+ loop = asyncio.get_event_loop()
+
+ def silence_winerror_10054(loop, context):
+ exc = context.get("exception")
+ if (
+ isinstance(exc, ConnectionResetError)
+ and getattr(exc, "winerror", None) == 10054
+ ):
+ return
+ loop.default_exception_handler(context)
+
+ loop.set_exception_handler(silence_winerror_10054)
+ except Exception:
+ pass
+
+ # --- 核心修复:对准 LTX 真正的输出目录 (AppData) ---
+ def get_dynamic_output_path():
+ base_dir = (
+ Path(os.environ.get("LOCALAPPDATA", os.path.expanduser("~/AppData/Local")))
+ / "LTXDesktop"
+ ).resolve()
+ config_file = base_dir / "custom_dir.txt"
+ if config_file.exists():
+ try:
+ custom_dir = config_file.read_text(encoding="utf-8").strip()
+ if custom_dir:
+ p = Path(custom_dir)
+ p.mkdir(parents=True, exist_ok=True)
+ return p
+ except Exception:
+ pass
+ default_dir = base_dir / "outputs"
+ default_dir.mkdir(parents=True, exist_ok=True)
+ return default_dir
+
+ actual_output_path = get_dynamic_output_path()
+ handler.config.outputs_dir = actual_output_path
+
+ pl = handler.pipelines
+ pl._pipeline_signature = None
+ from low_vram_runtime import (
+ install_low_vram_on_pipelines,
+ install_low_vram_pipeline_hooks,
+ )
+
+ install_low_vram_on_pipelines(handler)
+ install_low_vram_pipeline_hooks(pl)
+ # LoRA:在 SingleGPUModelBuilder.build 时合并权重(model_ledger 不足以让桌面版 DiT 吃到 LoRA)
+ from lora_build_hook import install_lora_build_hook
+
+ install_lora_build_hook()
+
+ upload_tmp_path = actual_output_path / "uploads"
+
+ # 如果文件夹不存在则创建,防止挂载失败
+ if not actual_output_path.exists():
+ actual_output_path.mkdir(parents=True, exist_ok=True)
+ if not upload_tmp_path.exists():
+ upload_tmp_path.mkdir(parents=True, exist_ok=True)
+
+ # 挂载静态服务:将该目录映射到 http://127.0.0.1:3000/outputs
+ app.mount(
+ "/outputs", StaticFiles(directory=str(actual_output_path)), name="outputs"
+ )
+ # -----------------------------------------------
+
+ # 配置 CORS
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=allowed_origins or DEFAULT_ALLOWED_ORIGINS,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+ # === [全局隔离补丁] ===
+ # 强制将每一个新的 HTTP 线程/协程请求的默认显卡都强绑定到用户选定的设备上
+ @app.middleware("http")
+ async def _sync_gpu_middleware(
+ request: Request,
+ call_next: Callable[[Request], Awaitable[StarletteResponse]],
+ ) -> StarletteResponse:
+ import torch
+
+ if (
+ torch.cuda.is_available()
+ and getattr(handler.config.device, "type", "") == "cuda"
+ ):
+ idx = handler.config.device.index
+ if idx is not None:
+ # 能够强行夺取那些底层写死了 cuda:0 而忽略 config.device 的第三方库
+ torch.cuda.set_device(idx)
+ return await call_next(request)
+
+ # 认证中间件
+ @app.middleware("http")
+ async def _auth_middleware(
+ request: Request,
+ call_next: Callable[[Request], Awaitable[StarletteResponse]],
+ ) -> StarletteResponse:
+ # 关键修复:如果是获取生成的图片,直接放行,不检查 Token
+ if (
+ request.url.path.startswith("/outputs")
+ or request.url.path == "/api/system/upload-image"
+ ):
+ return await call_next(request)
+
+ if not auth_token:
+ return await call_next(request)
+ if request.method == "OPTIONS":
+ return await call_next(request)
+
+ def _token_matches(candidate: str) -> bool:
+ return hmac.compare_digest(candidate, auth_token)
+
+ # WebSocket 认证
+ if request.headers.get("upgrade", "").lower() == "websocket":
+ if _token_matches(request.query_params.get("token", "")):
+ return await call_next(request)
+ return JSONResponse(status_code=401, content={"error": "Unauthorized"})
+
+ # HTTP 认证 (Bearer/Basic)
+ auth_header = request.headers.get("authorization", "")
+ if auth_header.startswith("Bearer ") and _token_matches(auth_header[7:]):
+ return await call_next(request)
+ if auth_header.startswith("Basic "):
+ try:
+ decoded = base64.b64decode(auth_header[6:]).decode()
+ _, _, password = decoded.partition(":")
+ if _token_matches(password):
+ return await call_next(request)
+ except Exception:
+ pass
+ return JSONResponse(status_code=401, content={"error": "Unauthorized"})
+
+ # 异常处理逻辑
+ _FALLBACK = "An unexpected error occurred"
+
+ async def _route_http_error_handler(
+ request: Request, exc: Exception
+ ) -> JSONResponse:
+ if isinstance(exc, HTTPError):
+ log_http_error(request, exc)
+ return JSONResponse(
+ status_code=exc.status_code, content={"error": exc.detail or _FALLBACK}
+ )
+ return JSONResponse(status_code=500, content={"error": str(exc) or _FALLBACK})
+
+ async def _validation_error_handler(
+ request: Request, exc: Exception
+ ) -> JSONResponse:
+ if isinstance(exc, RequestValidationError):
+ return JSONResponse(
+ status_code=422, content={"error": str(exc) or _FALLBACK}
+ )
+ return JSONResponse(status_code=422, content={"error": str(exc) or _FALLBACK})
+
+ async def _route_generic_error_handler(
+ request: Request, exc: Exception
+ ) -> JSONResponse:
+ log_unhandled_exception(request, exc)
+ return JSONResponse(status_code=500, content={"error": str(exc) or _FALLBACK})
+
+ app.add_exception_handler(RequestValidationError, _validation_error_handler)
+ app.add_exception_handler(HTTPError, _route_http_error_handler)
+ app.add_exception_handler(Exception, _route_generic_error_handler)
+
+ # --- 系统功能接口 ---
+ @app.post("/api/system/clear-gpu")
+ async def route_clear_gpu():
+ try:
+ import torch
+ import gc
+ import asyncio
+
+ # 1. 尝试终止任务并重置运行状态
+ if getattr(handler.generation, "is_generation_running", lambda: False)():
+ try:
+ handler.generation.cancel_generation()
+ except Exception:
+ pass
+ await asyncio.sleep(0.5)
+
+ # 暴力重置死锁状态
+ if hasattr(handler.generation, "_generation_id"):
+ handler.generation._generation_id = None
+ if hasattr(handler.generation, "_is_generating"):
+ handler.generation._is_generating = False
+
+ # 2. 强制卸载模型: 临时屏蔽底层锁定器
+ try:
+ mock_swapped = False
+ orig_running = None
+ if hasattr(handler.pipelines, "_generation_service"):
+ orig_running = (
+ handler.pipelines._generation_service.is_generation_running
+ )
+ handler.pipelines._generation_service.is_generation_running = (
+ lambda: False
+ )
+ mock_swapped = True
+ try:
+ from keep_models_runtime import force_unload_gpu_pipeline
+
+ force_unload_gpu_pipeline(handler.pipelines)
+ finally:
+ if mock_swapped:
+ handler.pipelines._generation_service.is_generation_running = (
+ orig_running
+ )
+ except Exception as e:
+ print(f"Force unload warning: {e}")
+
+ # 3. 深度清理
+ gc.collect()
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+ torch.cuda.ipc_collect()
+ try:
+ handler.pipelines._pipeline_signature = None
+ except Exception:
+ pass
+ return {
+ "status": "success",
+ "message": "GPU memory cleared and models unloaded",
+ }
+ except Exception as e:
+ return JSONResponse(status_code=500, content={"error": str(e)})
+
+ @app.get("/api/system/low-vram-mode")
+ async def route_get_low_vram_mode():
+ enabled = bool(getattr(handler.pipelines, "low_vram_mode", False))
+ return {"enabled": enabled}
+
+ @app.post("/api/system/low-vram-mode")
+ async def route_set_low_vram_mode(request: Request):
+ try:
+ data = await request.json()
+ except Exception:
+ data = {}
+ enabled = bool(data.get("enabled", False))
+ from low_vram_runtime import (
+ apply_low_vram_config_tweaks,
+ write_low_vram_pref,
+ )
+
+ handler.pipelines.low_vram_mode = enabled
+ write_low_vram_pref(enabled)
+ if enabled:
+ apply_low_vram_config_tweaks(handler)
+ return {"status": "success", "enabled": enabled}
+
+ @app.post("/api/system/reset-state")
+ async def route_reset_state():
+ """轻量级状态重置:只清除 generation 状态锁,不卸载 GPU 管线。
+ 在每次新渲染开始前由前端调用,确保后端状态干净可用。"""
+ try:
+ gen = handler.generation
+ # 强制清除所有可能导致 is_generation_running() 返回 True 的标志
+ for attr in (
+ "_is_generating",
+ "_generation_id",
+ "_cancelled",
+ "_is_cancelled",
+ ):
+ if hasattr(gen, attr):
+ if attr in ("_is_generating", "_cancelled", "_is_cancelled"):
+ setattr(gen, attr, False)
+ else:
+ setattr(gen, attr, None)
+ # 某些实现用 threading.Event
+ for attr in ("_cancel_event",):
+ if hasattr(gen, attr):
+ try:
+ getattr(gen, attr).clear()
+ except Exception:
+ pass
+ print("[reset-state] Generation state has been reset cleanly.")
+ return {"status": "success", "message": "Generation state reset"}
+ except Exception as e:
+ import traceback
+
+ traceback.print_exc()
+ return JSONResponse(status_code=500, content={"error": str(e)})
+
+ @app.post("/api/system/set-dir")
+ async def route_set_dir(request: Request):
+ try:
+ data = await request.json()
+ new_dir = data.get("directory", "").strip()
+ base_dir = (
+ Path(
+ os.environ.get(
+ "LOCALAPPDATA", os.path.expanduser("~/AppData/Local")
+ )
+ )
+ / "LTXDesktop"
+ ).resolve()
+ config_file = base_dir / "custom_dir.txt"
+ if new_dir:
+ p = Path(new_dir)
+ p.mkdir(parents=True, exist_ok=True)
+ config_file.write_text(new_dir, encoding="utf-8")
+ else:
+ if config_file.exists():
+ config_file.unlink()
+ # 立即更新全局 config 控制
+ handler.config.outputs_dir = get_dynamic_output_path()
+ return {"status": "success", "directory": str(get_dynamic_output_path())}
+ except Exception as e:
+ return JSONResponse(status_code=500, content={"error": str(e)})
+
+ @app.get("/api/system/get-dir")
+ async def route_get_dir():
+ return {"status": "success", "directory": str(get_dynamic_output_path())}
+
+ @app.get("/api/system/browse-dir")
+ async def route_browse_dir():
+ try:
+ import subprocess
+
+ # 强制将对话框置顶层:通过 STA 线程 + Topmost 属性,避免被窗口锥入后台
+ ps_script = (
+ "[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null;"
+ "[System.Reflection.Assembly]::LoadWithPartialName('System.Drawing') | Out-Null;"
+ "$f = New-Object System.Windows.Forms.FolderBrowserDialog;"
+ "$f.Description = '\u9009\u62e9 LTX \u89c6\u9891\u548c\u56fe\u50cf\u751f\u6210\u7684\u5168\u5c40\u8f93\u51fa\u76ee\u5f55';"
+ "$f.ShowNewFolderButton = $true;"
+ # 创建一个雐形助手窗口作为 parent 确保对话框在最顶层
+ "$owner = New-Object System.Windows.Forms.Form;"
+ "$owner.TopMost = $true;"
+ "$owner.StartPosition = 'CenterScreen';"
+ "$owner.Size = New-Object System.Drawing.Size(1, 1);"
+ "$owner.Show();"
+ "$owner.BringToFront();"
+ "$owner.Focus();"
+ "if ($f.ShowDialog($owner) -eq 'OK') { echo $f.SelectedPath };"
+ "$owner.Dispose();"
+ )
+
+ def run_ps():
+ process = subprocess.Popen(
+ ["powershell", "-STA", "-NoProfile", "-Command", ps_script],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ # 移除 CREATE_NO_WINDOW 以允许 UI 线程正常弹出
+ )
+ stdout, _ = process.communicate()
+ return stdout.strip()
+
+ from starlette.concurrency import run_in_threadpool
+
+ selected_dir = await run_in_threadpool(run_ps)
+ return {"status": "success", "directory": selected_dir}
+ except Exception as e:
+ return JSONResponse(status_code=500, content={"error": str(e)})
+
+ _LORA_SCAN_SUFFIXES = {".safetensors", ".ckpt", ".pt", ".bin"}
+
+ @app.post("/api/lora-dir")
+ async def route_save_lora_dir(request: Request):
+ """保存 LoRA 目录到设置"""
+ try:
+ body = await request.json()
+ lora_dir = body.get("loraDir", "").strip()
+
+ settings_file = Path(
+ os.path.expandvars(r"%LOCALAPPDATA%\LTXDesktop\settings.json")
+ )
+ import json
+
+ if settings_file.exists():
+ with open(settings_file, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ else:
+ data = {}
+
+ data["lora_dir"] = lora_dir
+ data["loraDir"] = lora_dir
+
+ with open(settings_file, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=2)
+
+ return {"status": "ok", "loraDir": lora_dir}
+ except Exception as e:
+ return {"status": "error", "message": str(e)}
+
+ @app.get("/api/lora-dir")
+ async def route_get_lora_dir():
+ """获取 LoRA 目录设置"""
+ try:
+ import json
+
+ settings_file = Path(
+ os.path.expandvars(r"%LOCALAPPDATA%\LTXDesktop\settings.json")
+ )
+ if settings_file.exists():
+ with open(settings_file, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ return {"loraDir": data.get("lora_dir", "") or data.get("loraDir", "")}
+ return {"loraDir": ""}
+ except Exception as e:
+ return {"loraDir": "", "error": str(e)}
+
+ @app.get("/api/loras")
+ async def route_list_loras(request: Request):
+ """扫描本地 LoRA 目录;前端「设置」里填的路径依赖此接口(官方路由可能不存在)。"""
+ from pathlib import Path as _Path
+
+ raw = (request.query_params.get("dir") or "").strip()
+ if raw.startswith("True"):
+ raw = raw[4:].lstrip()
+ raw = raw.strip().strip('"').strip("'")
+
+ if not raw:
+ # 直接从 settings.json 读取 lora_dir
+ try:
+ import json
+
+ settings_file = _Path(
+ os.path.expandvars(r"%LOCALAPPDATA%\LTXDesktop\settings.json")
+ )
+ if settings_file.exists():
+ with open(settings_file, "r", encoding="utf-8") as f:
+ settings_data = json.load(f)
+ custom_lora_dir = settings_data.get(
+ "lora_dir", ""
+ ) or settings_data.get("loraDir", "")
+ if custom_lora_dir and str(custom_lora_dir).strip():
+ raw = str(custom_lora_dir).strip()
+ except Exception as e:
+ print(f"[PATCH] Failed to read lora_dir from settings: {e}")
+
+ if not raw:
+ # 默认规则:LoRA 路径 = 默认 models_dir 下的 `loras` 子目录(规则写死)
+ try:
+ md = getattr(handler.pipelines, "models_dir", None)
+ if md:
+ root = _Path(str(md)).expanduser().resolve() / "loras"
+ raw = str(root)
+ except Exception:
+ raw = ""
+
+ if not raw:
+ return {"loras": [], "loras_dir": "", "models_dir": ""}
+
+ root = _Path(raw).expanduser()
+ try:
+ root = root.resolve()
+ except OSError:
+ pass
+
+ if not root.is_dir():
+ return {
+ "loras": [],
+ "error": "not_a_directory",
+ "message": "路径不是文件夹或不存在,请检查拼写、盘符与权限",
+ "path": str(root),
+ "loras_dir": str(root),
+ "models_dir": str(root.parent),
+ }
+
+ found: list[dict[str, str]] = []
+ try:
+ for dirpath, _dirnames, filenames in os.walk(root):
+ for fn in filenames:
+ suf = _Path(fn).suffix.lower()
+ if suf in _LORA_SCAN_SUFFIXES:
+ full = _Path(dirpath) / fn
+ if full.is_file():
+ try:
+ resolved = str(full.resolve())
+ except OSError:
+ resolved = str(full)
+ found.append({"name": fn, "path": resolved})
+ except OSError as e:
+ return JSONResponse(
+ status_code=400,
+ content={
+ "loras": [],
+ "error": "scan_failed",
+ "message": str(e),
+ "path": str(root),
+ },
+ )
+
+ found.sort(key=lambda x: x["name"].lower())
+ return {
+ "loras": found,
+ "loras_dir": str(root),
+ "models_dir": str(root.parent),
+ }
+
+ _MODEL_SCAN_SUFFIXES = {
+ ".safetensors",
+ ".ckpt",
+ ".pt",
+ ".bin",
+ ".pth",
+ }
+
+ @app.get("/api/models")
+ async def route_list_models(request: Request):
+ """扫描本地 checkpoint 目录;需在官方 models_router 之前注册以覆盖空列表行为。"""
+ raw = (request.query_params.get("dir") or "").strip()
+ if raw.startswith("True"):
+ raw = raw[4:].lstrip()
+ raw = raw.strip().strip('"').strip("'")
+
+ if not raw:
+ try:
+ md = getattr(handler.pipelines, "models_dir", None)
+ if md is None or not str(md).strip():
+ return {"models": []}
+ root = Path(str(md)).expanduser().resolve()
+ except OSError:
+ return {"models": []}
+ if not root.is_dir():
+ return {"models": []}
+ else:
+ root = Path(raw).expanduser()
+ try:
+ root = root.resolve()
+ except OSError:
+ pass
+
+ if not root.is_dir():
+ return {
+ "models": [],
+ "error": "not_a_directory",
+ "message": "路径不是文件夹或不存在,请检查拼写、盘符与权限",
+ "path": str(root),
+ }
+
+ found: list[dict[str, str]] = []
+ try:
+ for dirpath, _dirnames, filenames in os.walk(root):
+ for fn in filenames:
+ suf = Path(fn).suffix.lower()
+ if suf in _MODEL_SCAN_SUFFIXES:
+ full = Path(dirpath) / fn
+ if full.is_file():
+ try:
+ resolved = str(full.resolve())
+ except OSError:
+ resolved = str(full)
+ found.append({"name": fn, "path": resolved})
+ except OSError as e:
+ return JSONResponse(
+ status_code=400,
+ content={
+ "models": [],
+ "error": "scan_failed",
+ "message": str(e),
+ "path": str(root),
+ },
+ )
+
+ found.sort(key=lambda x: x["name"].lower())
+ return {"models": found}
+
+ @app.get("/api/system/file")
+ async def route_serve_file(path: str):
+ from fastapi.responses import FileResponse
+
+ if os.path.exists(path):
+ return FileResponse(path)
+ return JSONResponse(status_code=404, content={"error": "File not found"})
+
+ @app.get("/api/system/list-gpus")
+ async def route_list_gpus():
+ try:
+ import torch
+
+ gpus = []
+ if torch.cuda.is_available():
+ current_idx = 0
+ dev = getattr(handler.config, "device", None)
+ if dev is not None and getattr(dev, "index", None) is not None:
+ current_idx = dev.index
+ for i in range(torch.cuda.device_count()):
+ try:
+ name = torch.cuda.get_device_name(i)
+ except Exception:
+ name = f"GPU {i}"
+ try:
+ vram_bytes = torch.cuda.get_device_properties(i).total_memory
+ vram_gb = vram_bytes / (1024**3)
+ vram_mb = vram_bytes / (1024**2)
+ except Exception:
+ vram_gb = 0.0
+ vram_mb = 0
+ gpus.append(
+ {
+ "id": i,
+ "name": name,
+ "vram": f"{vram_gb:.1f} GB",
+ "vram_mb": int(vram_mb),
+ "active": (i == current_idx),
+ }
+ )
+ return {"status": "success", "gpus": gpus}
+ except Exception as e:
+ return JSONResponse(status_code=500, content={"error": str(e)})
+
+ @app.post("/api/system/switch-gpu")
+ async def route_switch_gpu(request: Request):
+ try:
+ import torch
+ import gc
+ import asyncio
+
+ data = await request.json()
+ gpu_id = data.get("gpu_id")
+
+ if (
+ gpu_id is None
+ or not torch.cuda.is_available()
+ or gpu_id >= torch.cuda.device_count()
+ ):
+ return JSONResponse(
+ status_code=400, content={"error": "Invalid GPU ID"}
+ )
+
+ # 先尝试终止任何可能的卡死任务
+ if getattr(handler.generation, "is_generation_running", lambda: False)():
+ try:
+ handler.generation.cancel_generation()
+ except Exception:
+ pass
+ await asyncio.sleep(0.5)
+ if hasattr(handler.generation, "_generation_id"):
+ handler.generation._generation_id = None
+ if hasattr(handler.generation, "_is_generating"):
+ handler.generation._is_generating = False
+
+ # 1. 卸载当前 GPU 上的模型: 临时屏蔽底层锁定器
+ try:
+ mock_swapped = False
+ orig_running = None
+ if hasattr(handler.pipelines, "_generation_service"):
+ orig_running = (
+ handler.pipelines._generation_service.is_generation_running
+ )
+ handler.pipelines._generation_service.is_generation_running = (
+ lambda: False
+ )
+ mock_swapped = True
+ try:
+ from keep_models_runtime import force_unload_gpu_pipeline
+
+ force_unload_gpu_pipeline(handler.pipelines)
+ finally:
+ if mock_swapped:
+ handler.pipelines._generation_service.is_generation_running = (
+ orig_running
+ )
+ except Exception:
+ pass
+ gc.collect()
+ torch.cuda.empty_cache()
+
+ try:
+ handler.pipelines._pipeline_signature = None
+ except Exception:
+ pass
+
+ # 2. 切换全局设备配置
+ new_device = torch.device(f"cuda:{gpu_id}")
+ handler.config.device = new_device
+
+ # 3. 核心修复:设置当前进程的默认 CUDA 设备
+ # 这会影响到 torch.cuda.current_device() 和后续的模型加载
+ torch.cuda.set_device(gpu_id)
+
+ # 针对底层库可能直接读取 CUDA_VISIBLE_DEVICES 的情况
+ # 注意:torch 初始化后修改此变量不一定生效,但对某些库可能有引导作用
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(gpu_id)
+
+ # 4. 【核心修复】同步更新 TextEncoder 的设备指针
+ # 根本原因: LTXTextEncoder.self.device 在初始化时硬绑定了旧 GPU,
+ # 切换设备后 text context 仍在旧 GPU 上,与已迁移到新 GPU 的
+ # Transformer 产生 "cuda:0 and cuda:1" 设备不一致冲突。
+ try:
+ te_state = None
+ # 尝试多种路径访问 text_encoder 状态
+ if hasattr(handler, "state") and hasattr(handler.state, "text_encoder"):
+ te_state = handler.state.text_encoder
+ elif hasattr(handler, "_state") and hasattr(
+ handler._state, "text_encoder"
+ ):
+ te_state = handler._state.text_encoder
+
+ if te_state is not None:
+ # 4a. 更新 LTXTextEncoder 服务自身的 device 属性
+ if hasattr(te_state, "service") and hasattr(
+ te_state.service, "device"
+ ):
+ te_state.service.device = new_device
+ print(f"[TextEncoder] device updated to {new_device}")
+
+ # 4b. 将缓存的 encoder 权重迁移到 CPU,下次推理时再按新设备重加载
+ if (
+ hasattr(te_state, "cached_encoder")
+ and te_state.cached_encoder is not None
+ ):
+ try:
+ te_state.cached_encoder.to(torch.device("cpu"))
+ except Exception:
+ pass
+ te_state.cached_encoder = None
+ print(
+ "[TextEncoder] cached encoder cleared (will reload on new GPU)"
+ )
+
+ # 4c. 清除 API embeddings 缓存(tensor 绑定旧 GPU)
+ if hasattr(te_state, "api_embeddings"):
+ te_state.api_embeddings = None
+
+ # 4d. 清除 prompt cache(其中 tensor 也绑定旧 GPU)
+ if hasattr(te_state, "prompt_cache") and te_state.prompt_cache:
+ te_state.prompt_cache.clear()
+ print("[TextEncoder] prompt cache cleared")
+ except Exception as _te_err:
+ print(f"[TextEncoder] device sync warning (non-fatal): {_te_err}")
+
+ print(
+ f"Switched active GPU to: {torch.cuda.get_device_name(gpu_id)} (ID: {gpu_id})"
+ )
+ return {"status": "success", "message": f"Switched to GPU {gpu_id}"}
+ except Exception as e:
+ return JSONResponse(status_code=500, content={"error": str(e)})
+
+ # --- 核心增强:首尾帧插值与视频超分支持 ---
+ from handlers.video_generation_handler import VideoGenerationHandler
+ from services.retake_pipeline.ltx_retake_pipeline import LTXRetakePipeline
+ from server_utils.media_validation import normalize_optional_path
+ from PIL import Image
+
+ # 1. 增强插值功能 (Monkey Patch VideoGenerationHandler)
+ _orig_generate = VideoGenerationHandler.generate
+ _orig_generate_video = VideoGenerationHandler.generate_video
+
+ def patched_generate(self, req: GenerateVideoRequest):
+ # === [DEBUG] 打印当前生成状态 ===
+ gen = self._generation
+ is_running = (
+ gen.is_generation_running()
+ if hasattr(gen, "is_generation_running")
+ else "?方法不存在"
+ )
+ gen_id = getattr(gen, "_generation_id", "?属性不存在")
+ is_gen = getattr(gen, "_is_generating", "?属性不存在")
+ cancelled = getattr(
+ gen, "_cancelled", getattr(gen, "_is_cancelled", "?属性不存在")
+ )
+ print(f"\n[PATCH][patched_generate] ==> 收到新请求")
+ print(f" is_generation_running() = {is_running}")
+ print(f" _generation_id = {gen_id}")
+ print(f" _is_generating = {is_gen}")
+ print(f" _cancelled = {cancelled}")
+ start_frame_path = normalize_optional_path(getattr(req, "startFramePath", None))
+ end_frame_path = normalize_optional_path(getattr(req, "endFramePath", None))
+ _raw_kf = getattr(req, "keyframePaths", None)
+ keyframe_paths_list: list[str] = []
+ if isinstance(_raw_kf, list):
+ for p in _raw_kf:
+ np = normalize_optional_path(p)
+ if np:
+ keyframe_paths_list.append(np)
+ use_multi_keyframes = len(keyframe_paths_list) >= 2
+ _raw_kf_st = getattr(req, "keyframeStrengths", None)
+ keyframe_strengths_list: list[float] | None = None
+ if isinstance(_raw_kf_st, list) and _raw_kf_st:
+ try:
+ keyframe_strengths_list = [float(x) for x in _raw_kf_st]
+ except (TypeError, ValueError):
+ keyframe_strengths_list = None
+ _raw_kf_t = getattr(req, "keyframeTimes", None)
+ keyframe_times_list: list[float] | None = None
+ if isinstance(_raw_kf_t, list) and _raw_kf_t:
+ try:
+ keyframe_times_list = [float(x) for x in _raw_kf_t]
+ except (TypeError, ValueError):
+ keyframe_times_list = None
+ aspect_ratio = getattr(req, "aspectRatio", None)
+ print(f" startFramePath = {start_frame_path}")
+ print(f" endFramePath = {end_frame_path}")
+ print(f" keyframePaths (n={len(keyframe_paths_list)}) = {use_multi_keyframes}")
+ print(f" aspectRatio = {aspect_ratio}")
+
+ # 检查是否有音频
+ audio_path = normalize_optional_path(getattr(req, "audioPath", None))
+ print(f"[PATCH] audio_path = {audio_path}")
+
+ # 检查是否有图片(图生视频)
+ image_path = normalize_optional_path(getattr(req, "imagePath", None))
+ print(f"[PATCH] image_path = {image_path}")
+
+ # 始终使用自定义逻辑(支持首尾帧和竖屏)
+ print(f"[PATCH] 使用自定义逻辑处理")
+
+ # 计算分辨率
+ import uuid
+
+ resolution = req.resolution
+ duration = int(float(req.duration))
+ fps = int(float(req.fps))
+
+ # 宽高均需为 64 的倍数(LTX 内核校验);在近似 16:9 下取整
+ RESOLUTION_MAP = {
+ "540p": (1024, 576),
+ "720p": (1280, 704),
+ "1080p": (1920, 1088),
+ }
+
+ def get_16_9_size(res):
+ return RESOLUTION_MAP.get(res, (1280, 704))
+
+ def get_9_16_size(res):
+ w, h = get_16_9_size(res)
+ return h, w # 交换宽高
+
+ if req.aspectRatio == "9:16":
+ width, height = get_9_16_size(resolution)
+ else:
+ width, height = get_16_9_size(resolution)
+
+ # 计算帧数
+ num_frames = ((duration * fps) // 8) * 8 + 1
+ num_frames = max(num_frames, 9)
+
+ print(f"[PATCH] 计算得到的分辨率: {width}x{height}, 帧数: {num_frames}")
+
+ # 多关键帧单次推理时勿用首尾帧属性,避免与 keyframe 列表重复
+ if use_multi_keyframes:
+ self._start_frame_path = None
+ self._end_frame_path = None
+ image_path_for_video = None
+ else:
+ self._start_frame_path = start_frame_path
+ self._end_frame_path = end_frame_path
+ image_path_for_video = image_path
+
+ # 无论有没有音频,都使用自定义逻辑支持首尾帧 / 多关键帧
+ try:
+ result = patched_generate_video(
+ self,
+ prompt=req.prompt,
+ image=None,
+ image_path=image_path_for_video,
+ height=height,
+ width=width,
+ num_frames=num_frames,
+ fps=fps,
+ seed=self._resolve_seed(),
+ camera_motion=req.cameraMotion,
+ negative_prompt=req.negativePrompt,
+ audio_path=audio_path,
+ lora_path=getattr(req, "loraPath", None),
+ lora_strength=float(getattr(req, "loraStrength", 1.0) or 1.0),
+ keyframe_paths=keyframe_paths_list if use_multi_keyframes else None,
+ keyframe_strengths=(
+ keyframe_strengths_list if use_multi_keyframes else None
+ ),
+ keyframe_times=(keyframe_times_list if use_multi_keyframes else None),
+ )
+ print(f"[PATCH][patched_generate] <== 完成, 返回状态: complete")
+ return type("Response", (), {"status": "complete", "video_path": result})()
+ except Exception as e:
+ import traceback
+
+ print(f"[PATCH][patched_generate] 错误: {e}")
+ traceback.print_exc()
+ raise
+
+ def patched_generate_video(
+ self,
+ prompt,
+ image,
+ image_path=None,
+ height=None,
+ width=None,
+ num_frames=None,
+ fps=None,
+ seed=None,
+ camera_motion=None,
+ negative_prompt=None,
+ audio_path=None,
+ lora_path=None,
+ lora_strength=1.0,
+ keyframe_paths: list[str] | None = None,
+ keyframe_strengths: list[float] | None = None,
+ keyframe_times: list[float] | None = None,
+ ):
+ # === [DEBUG] 打印当前生成状态 ===
+ gen = self._generation
+ is_running = (
+ gen.is_generation_running()
+ if hasattr(gen, "is_generation_running")
+ else "?方法不存在"
+ )
+ gen_id = getattr(gen, "_generation_id", "?属性不存在")
+ is_gen = getattr(gen, "_is_generating", "?属性不存在")
+ print(f"[PATCH][patched_generate_video] ==> 开始推理")
+ print(f" is_generation_running() = {is_running}")
+ print(f" _generation_id = {gen_id}")
+ print(f" _is_generating = {is_gen}")
+ print(
+ f" resolution = {width}x{height}, frames={num_frames}, fps={fps}"
+ )
+ print(f" image param = {type(image)}, {image is not None}")
+ print(f" image_path = {image_path}")
+ # ==================================
+ from ltx_pipelines.utils.args import (
+ ImageConditioningInput as LtxImageConditioningInput,
+ )
+
+ images_inputs = []
+ temp_paths = []
+ kf_list = [p for p in (keyframe_paths or []) if p]
+ use_multi_kf = len(kf_list) >= 2
+
+ start_path = getattr(self, "_start_frame_path", None)
+ end_path = getattr(self, "_end_frame_path", None)
+ print(
+ f"[PATCH] start_path={start_path}, end_path={end_path}, multi_kf={use_multi_kf} n={len(kf_list)}"
+ )
+
+ latent_num_frames = (num_frames - 1) // 8 + 1
+ last_latent_idx = latent_num_frames - 1
+ print(
+ f"[PATCH] latent_num_frames={latent_num_frames}, last_latent_idx={last_latent_idx}"
+ )
+
+ if use_multi_kf:
+ n_kf = len(kf_list)
+ st_override = keyframe_strengths or []
+ if len(st_override) not in (0, n_kf):
+ print(
+ f"[PATCH] keyframeStrengths 长度({len(st_override)})与关键帧数({n_kf})不一致,改用默认强度曲线"
+ )
+ st_override = []
+
+ def _default_multi_guide_strength(i: int, n: int) -> float:
+ """对齐 Comfy LTXVAddGuideMulti 常见配置:首尾不全是 1,中间明显减弱以减少邻帧闪烁。"""
+ if n <= 2:
+ return 1.0
+ if i == 0:
+ return 0.62
+ if i == n - 1:
+ return 1.0
+ return 0.42
+
+ kt = keyframe_times or []
+ times_match = len(kt) == n_kf
+ if times_match:
+ fps_f = max(float(fps), 0.001)
+ max_t = (num_frames - 1) / fps_f
+ fi_list: list[int] = []
+ for ki in range(n_kf):
+ t_sec = max(0.0, min(max_t, float(kt[ki])))
+ pf = int(round(t_sec * fps_f))
+ pf = min(num_frames - 1, max(0, pf))
+ fi = min(last_latent_idx, pf // 8)
+ fi_list.append(int(fi))
+ for j in range(1, n_kf):
+ if fi_list[j] <= fi_list[j - 1]:
+ fi_list[j] = min(last_latent_idx, fi_list[j - 1] + 1)
+ if n_kf > 1:
+ fi_list[-1] = last_latent_idx
+ print(f"[PATCH] Multi-keyframe: 使用 keyframeTimes 映射 -> {fi_list}")
+ else:
+ fi_list = []
+ prev_fi = -1
+ for ki in range(n_kf):
+ if last_latent_idx <= 0:
+ fi = 0
+ elif ki == 0:
+ fi = 0
+ elif ki == n_kf - 1:
+ fi = last_latent_idx
+ else:
+ pf = int(round(ki * (num_frames - 1) / max(1, (n_kf - 1))))
+ fi = min(last_latent_idx - 1, max(1, pf // 8))
+ if fi <= prev_fi:
+ fi = min(last_latent_idx - 1, prev_fi + 1)
+ prev_fi = fi
+ fi_list.append(int(fi))
+
+ for ki, kp in enumerate(kf_list):
+ if not os.path.isfile(kp):
+ raise RuntimeError(f"多关键帧路径无效或不存在: {kp}")
+ fi = fi_list[ki]
+
+ if len(st_override) == n_kf:
+ st = float(st_override[ki])
+ st = max(0.1, min(1.0, st))
+ else:
+ st = _default_multi_guide_strength(ki, n_kf)
+
+ img = self._prepare_image(kp, width, height)
+ tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
+ img.save(tmp)
+ temp_paths.append(tmp)
+ tmp_normalized = tmp.replace("\\", "/")
+ images_inputs.append(
+ LtxImageConditioningInput(
+ path=tmp_normalized, frame_idx=int(fi), strength=float(st)
+ )
+ )
+ print(
+ f"[PATCH] Multi-keyframe [{ki}]: {tmp_normalized}, "
+ f"frame_idx={fi}, strength={st:.3f}"
+ )
+ else:
+ # 如果没有首尾帧但有 image_path,使用 image_path 作为起始帧
+ if not start_path and not end_path and image_path:
+ print(f"[PATCH] 使用 image_path 作为起始帧: {image_path}")
+ start_path = image_path
+
+ has_image_param = image is not None
+ if has_image_param:
+ print(f"[PATCH] image param is available, will be used as start frame")
+
+ target_start_path = start_path if start_path else None
+ if not target_start_path and image is not None:
+ tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
+ image.save(tmp)
+ temp_paths.append(tmp)
+ target_start_path = tmp
+ print(f"[PATCH] Using image param as start frame: {target_start_path}")
+
+ if target_start_path:
+ start_img = self._prepare_image(target_start_path, width, height)
+ tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
+ start_img.save(tmp)
+ temp_paths.append(tmp)
+ tmp_normalized = tmp.replace("\\", "/")
+ images_inputs.append(
+ LtxImageConditioningInput(
+ path=tmp_normalized, frame_idx=0, strength=1.0
+ )
+ )
+ print(f"[PATCH] Added start frame: {tmp_normalized}, frame_idx=0")
+
+ if end_path:
+ end_img = self._prepare_image(end_path, width, height)
+ tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
+ end_img.save(tmp)
+ temp_paths.append(tmp)
+ tmp_normalized = tmp.replace("\\", "/")
+ images_inputs.append(
+ LtxImageConditioningInput(
+ path=tmp_normalized,
+ frame_idx=last_latent_idx,
+ strength=1.0,
+ )
+ )
+ print(
+ f"[PATCH] Added end frame: {tmp_normalized}, frame_idx={last_latent_idx}"
+ )
+
+ print(f"[PATCH] images_inputs count: {len(images_inputs)}")
+ if images_inputs:
+ for idx, img in enumerate(images_inputs):
+ print(
+ 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')}"
+ )
+
+ print(f"[PATCH] audio_path = {audio_path}")
+
+ if self._generation.is_generation_cancelled():
+ raise RuntimeError("Generation was cancelled")
+
+ # 导入 uuid
+ import uuid
+
+ generation_id = uuid.uuid4().hex[:8]
+
+ # 根据是否有音频选择不同的 pipeline
+ extra_loras_for_hook: tuple | None = (
+ None # 供 lora_build_hook 在 DiT build 时融合
+ )
+ gpu_slot = getattr(self._pipelines.state, "gpu_slot", None)
+ active = getattr(gpu_slot, "active_pipeline", None) if gpu_slot else None
+ cached_sig = getattr(self._pipelines, "_pipeline_signature", None)
+
+ new_kind = "a2v" if audio_path else "fast"
+ if (
+ cached_sig
+ and isinstance(cached_sig, tuple)
+ and len(cached_sig) > 0
+ and cached_sig[0] != new_kind
+ and active is not None
+ ):
+ from keep_models_runtime import force_unload_gpu_pipeline
+
+ print(f"[PATCH] 管线类型切换 {cached_sig[0]} -> {new_kind},强制卸载旧模型")
+ force_unload_gpu_pipeline(self._pipelines)
+ gpu_slot = getattr(self._pipelines.state, "gpu_slot", None)
+ active = getattr(gpu_slot, "active_pipeline", None) if gpu_slot else None
+
+ if audio_path:
+ desired_sig = ("a2v",)
+ print(f"[PATCH] 加载 A2V pipeline(支持音频)")
+ pipeline_state = self._pipelines.load_a2v_pipeline()
+ self._pipelines._pipeline_signature = desired_sig
+ num_inference_steps = 11
+ else:
+ # Fast:无 LoRA 时走官方 load_gpu_pipeline;有 LoRA 时自建 pipeline。
+ loras = None
+ lora_str = (lora_path or "").strip() if isinstance(lora_path, str) else ""
+ if lora_str:
+ try:
+ from ltx_core.loader import LoraPathStrengthAndSDOps
+ from ltx_core.loader.sd_ops import LTXV_LORA_COMFY_RENAMING_MAP
+
+ if os.path.exists(lora_str):
+ loras = [
+ LoraPathStrengthAndSDOps(
+ path=lora_str,
+ strength=float(lora_strength),
+ sd_ops=LTXV_LORA_COMFY_RENAMING_MAP,
+ )
+ ]
+ print(
+ f"[PATCH] LoRA 已就绪: {lora_str}, strength={lora_strength}"
+ )
+ else:
+ print(
+ f"[PATCH] LoRA 文件不存在,将使用无 LoRA Fast: {lora_str}"
+ )
+ except Exception as _lora_err:
+ print(f"[PATCH] LoRA 准备失败,回退无 LoRA: {_lora_err}")
+ loras = None
+
+ if loras is not None:
+ lora_key = lora_str
+ lora_st = round(float(lora_strength), 4)
+ else:
+ lora_key = ""
+ lora_st = 0.0
+ desired_sig = ("fast", lora_key, lora_st)
+
+ if loras is not None:
+ print("[PATCH] 构建带 LoRA 的 Fast pipeline(unload 后重建)")
+ # 首次 LoRA 构建时可能触发额外的显存峰值(编译/缓存/权重搬运)。
+ # 通过一次无 LoRA 的 fast pipeline warmup 来降低后续 LoRA 构建的峰值风险。
+ if not getattr(self, "_ltx_lora_warmup_done", False):
+ try:
+ print(
+ "[PATCH] LoRA warmup: 先加载无 LoRA fast pipeline 触发缓存"
+ )
+ # should_warm=True:尽量触发内核/权重缓存(若实现不同则静默失败也可回退)
+ self._pipelines.load_gpu_pipeline("fast", should_warm=True)
+ from keep_models_runtime import force_unload_gpu_pipeline
+
+ force_unload_gpu_pipeline(self._pipelines)
+ import gc
+
+ gc.collect()
+ try:
+ import torch
+
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+ torch.cuda.ipc_collect()
+ except Exception:
+ pass
+ self._ltx_lora_warmup_done = True
+ except Exception as _warm_err:
+ print(f"[PATCH] LoRA warmup failed (ignore): {_warm_err}")
+ from keep_models_runtime import force_unload_gpu_pipeline
+
+ force_unload_gpu_pipeline(self._pipelines)
+ import gc
+
+ gc.collect()
+ # 防止旧分配/碎片在首次 LoRA 构建时叠加导致 OOM
+ try:
+ import torch
+
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+ torch.cuda.ipc_collect()
+ except Exception:
+ pass
+ gemma_root = self._pipelines._text_handler.resolve_gemma_root()
+ from runtime_config.model_download_specs import resolve_model_path
+ from services.fast_video_pipeline.ltx_fast_video_pipeline import (
+ LTXFastVideoPipeline,
+ )
+ from state.app_state_types import (
+ GpuSlot,
+ VideoPipelineState,
+ VideoPipelineWarmth,
+ )
+
+ checkpoint_path = str(
+ resolve_model_path(
+ self._pipelines.models_dir,
+ self._pipelines.config.model_download_specs,
+ "checkpoint",
+ )
+ )
+ upsampler_path = str(
+ resolve_model_path(
+ self._pipelines.models_dir,
+ self._pipelines.config.model_download_specs,
+ "upsampler",
+ )
+ )
+ from lora_injection import (
+ _lora_init_kwargs,
+ inject_loras_into_fast_pipeline,
+ )
+
+ lora_kw = _lora_init_kwargs(LTXFastVideoPipeline, loras)
+ ltx_pipe = LTXFastVideoPipeline(
+ checkpoint_path,
+ gemma_root,
+ upsampler_path,
+ self._pipelines.config.device,
+ **lora_kw,
+ )
+ n_inj = inject_loras_into_fast_pipeline(ltx_pipe, loras)
+ if hasattr(ltx_pipe, "pipeline") and hasattr(
+ ltx_pipe.pipeline, "model_ledger"
+ ):
+ try:
+ ltx_pipe.pipeline.model_ledger.loras = tuple(loras)
+ except Exception:
+ pass
+ pipeline_state = VideoPipelineState(
+ pipeline=ltx_pipe,
+ warmth=VideoPipelineWarmth.COLD,
+ is_compiled=False,
+ )
+ self._pipelines.state.gpu_slot = GpuSlot(active_pipeline=pipeline_state)
+ _ml = getattr(getattr(ltx_pipe, "pipeline", None), "model_ledger", None)
+ _ml_loras = getattr(_ml, "loras", None) if _ml else None
+ print(
+ f"[PATCH] LoRA: __init__ 额外参数={list(lora_kw.keys())}, "
+ f"深度注入点数={n_inj}, model_ledger.loras={_ml_loras}"
+ )
+ if getattr(self._pipelines, "low_vram_mode", False):
+ from low_vram_runtime import (
+ try_sequential_offload_on_pipeline_state,
+ )
+
+ try_sequential_offload_on_pipeline_state(pipeline_state)
+ else:
+ print(f"[PATCH] 加载 Fast pipeline(无 LoRA)")
+ pipeline_state = self._pipelines.load_gpu_pipeline(
+ "fast", should_warm=False
+ )
+ self._pipelines._pipeline_signature = desired_sig
+ num_inference_steps = None
+ extra_loras_for_hook = tuple(loras) if loras else None
+
+ # 在 DiT 权重 build 时融合用户 LoRA(model_ledger 单独赋值往往不够)
+ from lora_build_hook import (
+ install_lora_build_hook,
+ pending_loras_token,
+ reset_pending_loras,
+ )
+
+ install_lora_build_hook()
+ _lora_hook_tok = pending_loras_token(extra_loras_for_hook)
+ try:
+ # 启动 generation 状态(在 pipeline 加载之后)
+ self._generation.start_generation(generation_id)
+
+ # 处理 negative_prompt
+ neg_prompt = (
+ negative_prompt
+ if negative_prompt
+ else self.config.default_negative_prompt
+ )
+ enhanced_prompt = prompt + self.config.camera_motion_prompts.get(
+ camera_motion, ""
+ )
+
+ # 强制使用动态目录,忽略底层原始逻辑
+ dyn_dir = get_dynamic_output_path()
+ output_path = dyn_dir / f"generation_{uuid.uuid4().hex[:8]}.mp4"
+
+ try:
+ self._text.prepare_text_encoding(enhanced_prompt, enhance_prompt=False)
+ # 调整为 64 的倍数(与 LTX 内核 divisible-by-64 校验一致)
+ height = max(64, round(height / 64) * 64)
+ width = max(64, round(width / 64) * 64)
+
+ if audio_path:
+ # A2V pipeline 参数
+ gen_kwargs = {
+ "prompt": enhanced_prompt,
+ "negative_prompt": neg_prompt,
+ "seed": seed,
+ "height": height,
+ "width": width,
+ "num_frames": num_frames,
+ "frame_rate": fps,
+ "num_inference_steps": num_inference_steps,
+ "images": images_inputs,
+ "audio_path": audio_path,
+ "audio_start_time": 0.0,
+ "audio_max_duration": None,
+ "output_path": str(output_path),
+ }
+ else:
+ # Fast pipeline 参数
+ gen_kwargs = {
+ "prompt": enhanced_prompt,
+ "seed": seed,
+ "height": height,
+ "width": width,
+ "num_frames": num_frames,
+ "frame_rate": fps,
+ "images": images_inputs,
+ "output_path": str(output_path),
+ }
+
+ pipeline_state.pipeline.generate(**gen_kwargs)
+
+ # 标记完成
+ self._generation.complete_generation(str(output_path))
+ return str(output_path)
+ finally:
+ self._text.clear_api_embeddings()
+ for p in temp_paths:
+ if os.path.exists(p):
+ os.unlink(p)
+ self._start_frame_path = None
+ self._end_frame_path = None
+ from low_vram_runtime import maybe_release_pipeline_after_task
+
+ try:
+ maybe_release_pipeline_after_task(self)
+ except Exception:
+ pass
+ finally:
+ reset_pending_loras(_lora_hook_tok)
+
+ VideoGenerationHandler.generate = patched_generate
+ VideoGenerationHandler.generate_video = patched_generate_video
+
+ # 2. 增强视频超分功能 (Monkey Patch LTXRetakePipeline)
+ _orig_ltx_retake_run = LTXRetakePipeline._run
+
+ def patched_ltx_retake_run(
+ self, video_path, prompt, start_time, end_time, seed, **kwargs
+ ):
+ # 拦截并修改目标宽高
+ target_w = getattr(self, "_target_width", None)
+ target_h = getattr(self, "_target_height", None)
+ target_strength = getattr(self, "_target_strength", 0.7)
+ is_upscale = target_w is not None and target_h is not None
+
+ import ltx_pipelines.utils.media_io as media_io
+ import services.retake_pipeline.ltx_retake_pipeline as lrp
+ import ltx_pipelines.utils.samplers as samplers
+ import ltx_pipelines.utils.helpers as helpers
+
+ _orig_get_meta = media_io.get_videostream_metadata
+ _orig_lrp_get_meta = getattr(lrp, "get_videostream_metadata", _orig_get_meta)
+ _orig_euler_loop = samplers.euler_denoising_loop
+ _orig_noise_video = helpers.noise_video_state
+
+ fps, num_frames, src_w, src_h = _orig_get_meta(video_path)
+
+ if is_upscale:
+ print(
+ f">>> 启动超分内核: {src_w}x{src_h} -> {target_w}x{target_h} (强度: {target_strength})"
+ )
+
+ # 1. 注入分辨率
+ def get_meta_patched(path):
+ return fps, num_frames, target_w, target_h
+
+ media_io.get_videostream_metadata = get_meta_patched
+ lrp.get_videostream_metadata = get_meta_patched
+
+ # 2. 注入起始噪声 (SDEdit 核心:加噪到指定强度)
+ def noise_video_patched(*args, **kwargs_inner):
+ kwargs_inner["noise_scale"] = target_strength
+ return _orig_noise_video(*args, **kwargs_inner)
+
+ helpers.noise_video_state = noise_video_patched
+
+ # 3. 注入采样起点 (从对应噪声位开始去噪)
+ def patched_euler_loop(
+ sigmas, video_state, audio_state, stepper, denoise_fn
+ ):
+ full_len = len(sigmas)
+ skip_idx = 0
+ for i, s in enumerate(sigmas):
+ if s <= target_strength:
+ skip_idx = i
+ break
+ skip_idx = min(skip_idx, full_len - 2)
+ new_sigmas = sigmas[skip_idx:]
+ print(
+ f">>> 采样拦截成功: 原步数 {full_len}, 现步数 {len(new_sigmas)}, 起始强度 {new_sigmas[0].item():.2f}"
+ )
+ return _orig_euler_loop(
+ new_sigmas, video_state, audio_state, stepper, denoise_fn
+ )
+
+ samplers.euler_denoising_loop = patched_euler_loop
+
+ kwargs["regenerate_video"] = False
+ kwargs["regenerate_audio"] = False
+
+ try:
+ return _orig_ltx_retake_run(
+ self, video_path, prompt, start_time, end_time, seed, **kwargs
+ )
+ finally:
+ media_io.get_videostream_metadata = _orig_get_meta
+ lrp.get_videostream_metadata = _orig_lrp_get_meta
+ samplers.euler_denoising_loop = _orig_euler_loop
+ helpers.noise_video_state = _orig_noise_video
+
+ return _orig_ltx_retake_run(
+ self, video_path, prompt, start_time, end_time, seed, **kwargs
+ )
+
+ return _orig_ltx_retake_run(
+ self, video_path, prompt, start_time, end_time, seed, **kwargs
+ )
+
+ LTXRetakePipeline._run = patched_ltx_retake_run
+
+ # --- 最终视频超分接口实现 ---
+ @app.post("/api/system/upscale-video")
+ async def route_upscale_video(request: Request):
+ try:
+ import uuid
+ import os
+ from datetime import datetime
+ from ltx_pipelines.utils.media_io import get_videostream_metadata
+ from ltx_core.types import SpatioTemporalScaleFactors
+
+ data = await request.json()
+ video_path = data.get("video_path")
+ target_res = data.get("resolution", "1080p")
+ prompt = data.get("prompt", "high quality, detailed, 4k")
+ strength = data.get("strength", 0.7) # 获取前端传来的重绘幅度
+
+ if not video_path or not os.path.exists(video_path):
+ return JSONResponse(
+ status_code=400, content={"error": "Invalid video path"}
+ )
+
+ # 计算目标宽高 (必须是 32 的倍数)
+ res_map = {"1080p": (1920, 1088), "720p": (1280, 704), "544p": (960, 544)}
+ target_w, target_h = res_map.get(target_res, (1920, 1088))
+
+ fps, num_frames, _, _ = get_videostream_metadata(video_path)
+
+ # 校验帧数 8k+1,如果不符则自动调整
+ scale = SpatioTemporalScaleFactors.default()
+ if (num_frames - 1) % scale.time != 0:
+ # 计算需要调整到的最近的有效帧数 (8k+1)
+ # 找到最接近的8k+1帧数
+ target_k = (num_frames - 1) // scale.time
+ # 选择最接近的k值:向下或向上取整
+ current_k = (num_frames - 1) // scale.time
+ current_remainder = (num_frames - 1) % scale.time
+
+ # 比较向上和向下取整哪个更接近
+ down_k = current_k
+ up_k = current_k + 1
+
+ # 向下取整的帧数
+ down_frames = down_k * scale.time + 1
+ # 向上取整的帧数
+ up_frames = up_k * scale.time + 1
+
+ # 选择差异最小的
+ if abs(num_frames - down_frames) <= abs(num_frames - up_frames):
+ adjusted_frames = down_frames
+ else:
+ adjusted_frames = up_frames
+
+ print(
+ f">>> 帧数调整: {num_frames} -> {adjusted_frames} (符合 8k+1 规则)"
+ )
+
+ # 调整视频帧数 - 截断多余的帧或填充黑帧
+ adjusted_video_path = None
+ try:
+ import cv2
+ import numpy as np
+ import tempfile
+
+ # 使用cv2读取视频
+ cap = cv2.VideoCapture(video_path)
+ if not cap.isOpened():
+ raise Exception("无法打开视频文件")
+
+ frames = []
+ while True:
+ ret, frame = cap.read()
+ if not ret:
+ break
+ frames.append(frame)
+ cap.release()
+
+ original_frame_count = len(frames)
+
+ if adjusted_frames < original_frame_count:
+ # 截断多余的帧
+ frames = frames[:adjusted_frames]
+ print(
+ f">>> 已截断视频: {original_frame_count} -> {len(frames)} 帧"
+ )
+ else:
+ # 填充黑帧 (复制最后一帧)
+ last_frame = frames[-1] if frames else None
+ if last_frame is not None:
+ h, w = last_frame.shape[:2]
+ black_frame = np.zeros((h, w, 3), dtype=np.uint8)
+ while len(frames) < adjusted_frames:
+ frames.append(black_frame.copy())
+ print(
+ f">>> 已填充视频: {original_frame_count} -> {len(frames)} 帧"
+ )
+
+ # 保存调整后的视频到临时文件
+ adjusted_video_fd = tempfile.NamedTemporaryFile(
+ suffix=".mp4", delete=False
+ )
+ adjusted_video_path = adjusted_video_fd.name
+ adjusted_video_fd.close()
+
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v")
+ out = cv2.VideoWriter(
+ adjusted_video_path,
+ fourcc,
+ fps,
+ (frames[0].shape[1], frames[0].shape[0]),
+ )
+ for frame in frames:
+ out.write(frame)
+ out.release()
+
+ video_path = adjusted_video_path
+ num_frames = adjusted_frames
+ print(
+ f">>> 视频帧数调整完成: {original_frame_count} -> {num_frames}"
+ )
+
+ except ImportError:
+ # cv2不可用,尝试使用LTX内置方法
+ try:
+ from ltx_pipelines.utils.media_io import (
+ read_video_stream,
+ write_video_stream,
+ )
+ import numpy as np
+
+ frames, audio_data = read_video_stream(video_path, fps)
+ original_frame_count = len(frames)
+
+ if adjusted_frames < original_frame_count:
+ frames = frames[:adjusted_frames]
+ else:
+ while len(frames) < adjusted_frames:
+ frames = np.concatenate([frames, frames[-1:]], axis=0)
+
+ import tempfile
+
+ adjusted_video_fd = tempfile.NamedTemporaryFile(
+ suffix=".mp4", delete=False
+ )
+ adjusted_video_path = adjusted_video_fd.name
+ adjusted_video_fd.close()
+
+ write_video_stream(adjusted_video_path, frames, fps)
+ video_path = adjusted_video_path
+ num_frames = adjusted_frames
+ print(
+ f">>> 视频帧数调整完成: {original_frame_count} -> {num_frames}"
+ )
+
+ except Exception as e2:
+ print(f">>> 视频帧数自动调整失败: {e2}")
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": f"视频帧数({num_frames})不符合 8k+1 规则,且自动调整失败。请手动将视频帧数调整为 8k+1 格式(如 9, 17, 25, 33, 41, 49, 57, 65, 73, 81, 89, 97, 105 等)。"
+ },
+ )
+ except Exception as e:
+ print(f">>> 视频帧数自动调整失败: {e}")
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": f"视频帧数({num_frames})不符合 8k+1 规则,且自动调整失败。请手动将视频帧数调整为 8k+1 格式(如 9, 17, 25, 33, 41, 49, 57, 65, 73, 81, 89, 97, 105 等)。"
+ },
+ )
+
+ # 1. 加载模型
+ pipeline_state = handler.pipelines.load_retake_pipeline(distilled=True)
+
+ # 3. 启动任务
+ generation_id = uuid.uuid4().hex[:8]
+ handler.generation.start_generation(generation_id)
+
+ # 核心修正:确保文件保存在动态的输出目录
+ save_dir = get_dynamic_output_path()
+ filename = f"upscale_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{generation_id}.mp4"
+ full_output_path = save_dir / filename
+
+ # 3. 执行真正的超分逻辑
+ try:
+ # 注入目标分辨率和重绘幅度
+ pipeline_state.pipeline._target_width = target_w
+ pipeline_state.pipeline._target_height = target_h
+ pipeline_state.pipeline._target_strength = strength
+
+ def do_generate():
+ pipeline_state.pipeline.generate(
+ video_path=str(video_path),
+ prompt=prompt,
+ start_time=0.0,
+ end_time=float(num_frames / fps),
+ seed=int(time.time()) % 2147483647,
+ output_path=str(full_output_path),
+ distilled=True,
+ regenerate_video=True,
+ regenerate_audio=False,
+ )
+
+ # 重要修复:放到线程池运行,避免阻塞主循环导致前端拿不到显存数据
+ from starlette.concurrency import run_in_threadpool
+
+ await run_in_threadpool(do_generate)
+
+ handler.generation.complete_generation(str(full_output_path))
+ return {"status": "complete", "video_path": filename}
+ except Exception as e:
+ # OOM 异常逃逸修复:强制返回友好的异常信息
+ try:
+ handler.generation.cancel_generation()
+ except Exception:
+ pass
+ if hasattr(handler.generation, "_generation_id"):
+ handler.generation._generation_id = None
+ if hasattr(handler.generation, "_is_generating"):
+ handler.generation._is_generating = False
+
+ error_msg = str(e)
+ if "CUDA out of memory" in error_msg:
+ error_msg = "🚨 显存不足 (OOM):视频时长过长或目标分辨率超出了当前显卡的承载极限,请降低目标分辨率重试!"
+ raise RuntimeError(error_msg) from e
+ finally:
+ if hasattr(pipeline_state.pipeline, "_target_width"):
+ del pipeline_state.pipeline._target_width
+ if hasattr(pipeline_state.pipeline, "_target_height"):
+ del pipeline_state.pipeline._target_height
+ if hasattr(pipeline_state.pipeline, "_target_strength"):
+ del pipeline_state.pipeline._target_strength
+ import gc
+
+ gc.collect()
+ if (
+ getattr(torch, "cuda", None) is not None
+ and torch.cuda.is_available()
+ ):
+ torch.cuda.empty_cache()
+ from low_vram_runtime import maybe_release_pipeline_after_task
+
+ try:
+ maybe_release_pipeline_after_task(handler)
+ except Exception:
+ pass
+
+ except Exception as e:
+ import traceback
+
+ traceback.print_exc()
+ return JSONResponse(status_code=500, content={"error": str(e)})
+
+ # ------------------
+
+ @app.post("/api/system/upload-image")
+ async def route_upload_image(request: Request):
+ try:
+ import uuid
+ import base64
+
+ # 接收 JSON 而不是 Multipart,绕过 python-multipart 缺失问题
+ data = await request.json()
+ b64_data = data.get("image")
+ filename = data.get("filename", "image.png")
+
+ if not b64_data:
+ return JSONResponse(
+ status_code=400, content={"error": "No image data provided"}
+ )
+
+ # 处理 base64 头部 (例如 data:image/png;base64,...)
+ if "," in b64_data:
+ b64_data = b64_data.split(",")[1]
+
+ image_bytes = base64.b64decode(b64_data)
+
+ # 确保上传目录存在
+ upload_dir = get_dynamic_output_path() / "uploads"
+ upload_dir.mkdir(parents=True, exist_ok=True)
+
+ safe_filename = "".join([c for c in filename if c.isalnum() or c in "._-"])
+ file_path = upload_dir / f"up_{uuid.uuid4().hex[:6]}_{safe_filename}"
+
+ with file_path.open("wb") as buffer:
+ buffer.write(image_bytes)
+
+ return {"status": "success", "path": str(file_path)}
+ except Exception as e:
+ import traceback
+
+ error_msg = f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}"
+ print(f"Upload error: {error_msg}")
+ return JSONResponse(
+ status_code=500, content={"error": str(e), "detail": error_msg}
+ )
+
+ # ------------------
+ # 批量首尾帧:与「视频生成」相同的首尾帧推理,按顺序生成 N-1 段后可选 ffmpeg 拼接
+ # ------------------
+
+ def _find_ffmpeg_binary() -> str | None:
+ """尽量找到 ffmpeg:环境变量 → imageio-ffmpeg 自带 → PATH → 常见安装位置 → WinGet。"""
+ import shutil
+ import sys
+
+ def _ok(p: str | None) -> str | None:
+ if not p:
+ return None
+ p = os.path.normpath(os.path.expandvars(str(p).strip().strip('"')))
+ return p if os.path.isfile(p) else None
+
+ for env_key in ("LTX_FFMPEG_PATH", "FFMPEG_PATH"):
+ hit = _ok(os.environ.get(env_key))
+ if hit:
+ print(f"[batch-merge] ffmpeg from {env_key}: {hit}")
+ return hit
+
+ try:
+ pref = _ltx_desktop_config_dir() / "ffmpeg_path.txt"
+ if pref.is_file():
+ line = pref.read_text(encoding="utf-8").splitlines()[0].strip()
+ hit = _ok(line)
+ if hit:
+ print(f"[batch-merge] ffmpeg from ffmpeg_path.txt: {hit}")
+ return hit
+ except Exception as _e:
+ print(f"[batch-merge] ffmpeg_path.txt: {_e!r}")
+
+ # imageio-ffmpeg:多数视频/ML 环境会带上独立 ffmpeg 可执行文件
+ try:
+ import imageio_ffmpeg
+
+ hit = _ok(imageio_ffmpeg.get_ffmpeg_exe())
+ if hit:
+ print(f"[batch-merge] ffmpeg from imageio_ffmpeg: {hit}")
+ return hit
+ except Exception as _e:
+ print(f"[batch-merge] imageio_ffmpeg: {_e!r}")
+
+ for name in ("ffmpeg", "ffmpeg.exe"):
+ hit = _ok(shutil.which(name))
+ if hit:
+ print(f"[batch-merge] ffmpeg from PATH which({name}): {hit}")
+ return hit
+
+ # 显式遍历 PATH 中的目录(某些环境下 which 不可靠)
+ path_env = os.environ.get("PATH", "") or os.environ.get("Path", "")
+ for folder in path_env.split(os.pathsep):
+ folder = folder.strip().strip('"')
+ if not folder:
+ continue
+ for exe in ("ffmpeg.exe", "ffmpeg"):
+ hit = _ok(os.path.join(folder, exe))
+ if hit:
+ print(f"[batch-merge] ffmpeg from PATH scan: {hit}")
+ return hit
+
+ localappdata = os.environ.get("LOCALAPPDATA", "") or ""
+ programfiles = os.environ.get("ProgramFiles", r"C:\Program Files")
+ programfiles_x86 = os.environ.get(
+ "ProgramFiles(x86)", r"C:\Program Files (x86)"
+ )
+ userprofile = os.environ.get("USERPROFILE", "") or ""
+
+ static_candidates: list[str] = [
+ os.path.join(os.path.dirname(sys.executable), "ffmpeg.exe"),
+ os.path.join(os.path.dirname(sys.executable), "ffmpeg"),
+ os.path.join(localappdata, "LTXDesktop", "ffmpeg.exe"),
+ os.path.join(programfiles, "LTX Desktop", "ffmpeg.exe"),
+ os.path.join(programfiles, "ffmpeg", "bin", "ffmpeg.exe"),
+ os.path.join(programfiles_x86, "ffmpeg", "bin", "ffmpeg.exe"),
+ r"C:\ffmpeg\bin\ffmpeg.exe",
+ os.path.join(userprofile, "scoop", "shims", "ffmpeg.exe"),
+ os.path.join(
+ userprofile, "scoop", "apps", "ffmpeg", "current", "bin", "ffmpeg.exe"
+ ),
+ r"C:\ProgramData\chocolatey\bin\ffmpeg.exe",
+ ]
+ for c in static_candidates:
+ hit = _ok(c)
+ if hit:
+ print(f"[batch-merge] ffmpeg static candidate: {hit}")
+ return hit
+
+ # WinGet 安装的 Gyan / BtbN 等包:在 Packages 下搜索 ffmpeg.exe(限制深度避免过慢)
+ try:
+ wg = os.path.join(localappdata, "Microsoft", "WinGet", "Packages")
+ if os.path.isdir(wg):
+ for root, _dirs, files in os.walk(wg):
+ if "ffmpeg.exe" in files:
+ hit = _ok(os.path.join(root, "ffmpeg.exe"))
+ if hit:
+ print(f"[batch-merge] ffmpeg from WinGet tree: {hit}")
+ return hit
+ # 略过过深目录
+ depth = root[len(wg) :].count(os.sep)
+ if depth > 6:
+ _dirs[:] = []
+ except Exception as _e:
+ print(f"[batch-merge] WinGet scan: {_e!r}")
+
+ print("[batch-merge] ffmpeg not found after extended search")
+ return None
+
+ def _ffmpeg_concat_copy(
+ segment_paths: list[str], output_mp4: str, ffmpeg_bin: str
+ ) -> None:
+ import subprocess
+
+ out_abs = os.path.abspath(output_mp4)
+ dyn_abs = os.path.abspath(str(get_dynamic_output_path()))
+ lines: list[str] = []
+ for p in segment_paths:
+ ap = os.path.abspath(p)
+ rel = os.path.relpath(ap, start=dyn_abs)
+ rel = rel.replace("\\", "/")
+ if "'" in rel:
+ rel = rel.replace("'", "'\\''")
+ lines.append(f"file '{rel}'")
+ list_body = "\n".join(lines) + "\n"
+ list_path = os.path.join(
+ dyn_abs, f"_batch_concat_{os.getpid()}_{time.time_ns()}.txt"
+ )
+ try:
+ Path(list_path).write_text(list_body, encoding="utf-8")
+ cmd = [
+ ffmpeg_bin,
+ "-y",
+ "-f",
+ "concat",
+ "-safe",
+ "0",
+ "-i",
+ list_path,
+ "-c",
+ "copy",
+ out_abs,
+ ]
+ proc = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ )
+ if proc.returncode != 0:
+ err = (proc.stderr or proc.stdout or "").strip()
+ raise RuntimeError(
+ f"ffmpeg 拼接失败 (code {proc.returncode}): {err[:800]}"
+ )
+ finally:
+ try:
+ if os.path.isfile(list_path):
+ os.unlink(list_path)
+ except OSError:
+ pass
+
+ def _ffmpeg_mux_background_audio(
+ ffmpeg_bin: str, video_in: str, audio_in: str, video_out: str
+ ) -> None:
+ """成片只保留原视频画面,音轨替换为一条外部音频(与多段各自 AI 音频相比更统一)。"""
+ import subprocess
+
+ out_abs = os.path.abspath(video_out)
+ proc = subprocess.run(
+ [
+ ffmpeg_bin,
+ "-y",
+ "-i",
+ os.path.abspath(video_in),
+ "-i",
+ os.path.abspath(audio_in),
+ "-map",
+ "0:v:0",
+ "-map",
+ "1:a:0",
+ "-c:v",
+ "copy",
+ "-c:a",
+ "aac",
+ "-b:a",
+ "192k",
+ "-shortest",
+ out_abs,
+ ],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ )
+ if proc.returncode != 0:
+ err = (proc.stderr or proc.stdout or "").strip()
+ raise RuntimeError(f"配乐混流失败 (code {proc.returncode}): {err[:800]}")
+
+ @app.post("/api/generate-batch")
+ async def route_generate_batch(request: Request):
+ """多关键帧:相邻两帧一段首尾帧视频,与 POST /api/generate 同源逻辑;多段用 ffmpeg concat。"""
+ from starlette.concurrency import run_in_threadpool
+
+ from server_utils.media_validation import normalize_optional_path
+
+ try:
+ data = await request.json()
+ segments_in = data.get("segments") or []
+ if not segments_in:
+ return JSONResponse(
+ status_code=400,
+ content={"error": "segments 不能为空"},
+ )
+
+ resolution = data.get("resolution") or "720p"
+ aspect_ratio = data.get("aspectRatio") or "16:9"
+ neg = data.get(
+ "negativePrompt",
+ "low quality, blurry, noisy, static noise, distorted",
+ )
+ model = data.get("model") or "ltx-2"
+ fps = str(data.get("fps") or "24")
+ audio = str(data.get("audio") or "false").lower()
+ camera_motion = data.get("cameraMotion") or "static"
+ model_path = data.get("modelPath")
+ lora_path = data.get("loraPath")
+ lora_strength = float(data.get("loraStrength") or 1.0)
+
+ vg = getattr(handler, "video_generation", None)
+ if vg is None or not callable(getattr(vg, "generate", None)):
+ return JSONResponse(
+ status_code=500,
+ content={"error": "内部错误:找不到 video_generation 处理器"},
+ )
+
+ clip_paths: list[str] = []
+ for idx, seg in enumerate(segments_in):
+ start_raw = seg.get("startImage") or seg.get("startFramePath")
+ end_raw = seg.get("endImage") or seg.get("endFramePath")
+ start_p = normalize_optional_path(start_raw)
+ end_p = normalize_optional_path(end_raw)
+ if not start_p or not os.path.isfile(start_p):
+ return JSONResponse(
+ status_code=400,
+ content={"error": f"片段 {idx + 1} 起始图路径无效"},
+ )
+ if not end_p or not os.path.isfile(end_p):
+ return JSONResponse(
+ status_code=400,
+ content={"error": f"片段 {idx + 1} 结束图路径无效"},
+ )
+
+ dur = seg.get("duration", 5)
+ try:
+ dur_i = int(float(dur))
+ except (TypeError, ValueError):
+ dur_i = 5
+ dur_i = max(1, min(60, dur_i))
+
+ prompt_text = (seg.get("prompt") or "").strip()
+ if not prompt_text:
+ prompt_text = "cinematic transition"
+
+ req = GenerateVideoRequest(
+ prompt=prompt_text,
+ resolution=resolution,
+ model=model,
+ cameraMotion=camera_motion,
+ negativePrompt=neg,
+ duration=str(dur_i),
+ fps=fps,
+ audio=audio,
+ imagePath=None,
+ audioPath=None,
+ startFramePath=start_p,
+ endFramePath=end_p,
+ aspectRatio=aspect_ratio,
+ modelPath=model_path,
+ loraPath=lora_path,
+ loraStrength=lora_strength,
+ )
+
+ def _one_gen(r: GenerateVideoRequest = req):
+ return vg.generate(r)
+
+ resp = await run_in_threadpool(_one_gen)
+ if resp.status != "complete" or not resp.video_path:
+ return JSONResponse(
+ status_code=500,
+ content={
+ "error": f"片段 {idx + 1} 生成失败: status={getattr(resp, 'status', None)}"
+ },
+ )
+ clip_paths.append(str(resp.video_path))
+
+ if len(clip_paths) == 1:
+ final_path = clip_paths[0]
+ else:
+ ff = _find_ffmpeg_binary()
+ if not ff:
+ return JSONResponse(
+ status_code=500,
+ content={
+ "error": (
+ "已生成多段视频,但未找到 ffmpeg,无法拼接。"
+ " 可选:① 安装 ffmpeg 并加入系统 PATH;"
+ " ② 设置环境变量 LTX_FFMPEG_PATH 指向 ffmpeg.exe;"
+ " ③ 在 %LOCALAPPDATA%\\LTXDesktop\\ffmpeg_path.txt 第一行写入 ffmpeg.exe 的完整路径。"
+ ),
+ "segment_paths": clip_paths,
+ },
+ )
+ import uuid as _uuid
+
+ out_dir = get_dynamic_output_path()
+ final_path = str(out_dir / f"batch_merged_{_uuid.uuid4().hex[:10]}.mp4")
+ try:
+ _ffmpeg_concat_copy(clip_paths, final_path, ff)
+ except Exception as ex:
+ return JSONResponse(
+ status_code=500,
+ content={
+ "error": str(ex),
+ "segment_paths": clip_paths,
+ },
+ )
+
+ bg_audio = normalize_optional_path(
+ data.get("backgroundAudioPath") or data.get("batchBackgroundAudioPath")
+ )
+ if bg_audio and os.path.isfile(bg_audio):
+ ff_mux = _find_ffmpeg_binary()
+ if not ff_mux:
+ return JSONResponse(
+ status_code=500,
+ content={
+ "error": "已生成视频,但混入配乐需要 ffmpeg,请配置 LTX_FFMPEG_PATH 或 ffmpeg_path.txt",
+ "video_path": final_path,
+ },
+ )
+ import uuid as _uuid2
+
+ out_mux = str(
+ get_dynamic_output_path()
+ / f"batch_with_audio_{_uuid2.uuid4().hex[:10]}.mp4"
+ )
+ try:
+ _ffmpeg_mux_background_audio(ff_mux, final_path, bg_audio, out_mux)
+ final_path = out_mux
+ except Exception as ex:
+ return JSONResponse(
+ status_code=500,
+ content={
+ "error": str(ex),
+ "video_path": final_path,
+ },
+ )
+
+ return GenerateVideoResponse(status="complete", video_path=final_path)
+ except Exception as e:
+ import traceback
+
+ traceback.print_exc()
+ return JSONResponse(status_code=500, content={"error": str(e)})
+
+ # ------------------
+
+ @app.get("/api/system/history")
+ async def route_get_history(request: Request):
+ try:
+ import os
+
+ page = int(request.query_params.get("page", 1))
+ limit = int(request.query_params.get("limit", 20))
+
+ history = []
+ dyn_path = get_dynamic_output_path()
+ if dyn_path.exists():
+ for filename in os.listdir(dyn_path):
+ if filename == "uploads":
+ continue
+ full_path = dyn_path / filename
+ if full_path.is_file() and filename.lower().endswith(
+ (".mp4", ".png", ".jpg", ".webp")
+ ):
+ mtime = os.path.getmtime(full_path)
+ history.append(
+ {
+ "filename": filename,
+ "type": "video"
+ if filename.lower().endswith(".mp4")
+ else "image",
+ "mtime": mtime,
+ "fullpath": str(full_path),
+ }
+ )
+ history.sort(key=lambda x: x["mtime"], reverse=True)
+
+ total_items = len(history)
+ total_pages = (total_items + limit - 1) // limit
+ start_idx = (page - 1) * limit
+ end_idx = start_idx + limit
+
+ return {
+ "status": "success",
+ "history": history[start_idx:end_idx],
+ "total_pages": total_pages,
+ "current_page": page,
+ "total_items": total_items,
+ }
+ except Exception as e:
+ return JSONResponse(status_code=500, content={"error": str(e)})
+
+ @app.post("/api/system/delete-file")
+ async def route_delete_file(request: Request):
+ try:
+ import os
+
+ data = await request.json()
+ filename = data.get("filename", "")
+
+ if not filename:
+ return JSONResponse(
+ status_code=400, content={"error": "Filename is required"}
+ )
+
+ dyn_path = get_dynamic_output_path()
+ file_path = dyn_path / filename
+
+ if file_path.exists() and file_path.is_file():
+ file_path.unlink()
+ return {"status": "success", "message": "File deleted"}
+ else:
+ return JSONResponse(
+ status_code=404, content={"error": "File not found"}
+ )
+ except Exception as e:
+ return JSONResponse(status_code=500, content={"error": str(e)})
+
+ # 路由注册
+ app.include_router(health_router)
+ app.include_router(generation_router)
+ app.include_router(models_router)
+ app.include_router(settings_router)
+ app.include_router(image_gen_router)
+ app.include_router(suggest_gap_prompt_router)
+ app.include_router(retake_router)
+ app.include_router(ic_lora_router)
+ app.include_router(runtime_policy_router)
+
+ # --- [安全补丁] 状态栏显示修复 ---
+
+ # --- 最终状态栏修复补丁: 只要服务运行且 GPU 没死,就视为就绪 ---
+ from handlers.health_handler import HealthHandler
+
+ if not hasattr(HealthHandler, "_fixed_v2"):
+ _orig_get_health = HealthHandler.get_health
+
+ def patched_health_v2(self):
+ resp = _orig_get_health(self)
+ # 解析:如果后端逻辑还在判断模型未加载,我们检查一下核心状态
+ # 如果系统没有崩溃,我们就强制标记为已加载,让前端允许交互
+ if not resp.models_loaded:
+ # 我们认为只要 API 能通,底层状态服务(state)只要存在,就视为由于异步加载引起的暂时性 False
+ # 直接返回 True,前端会显示"待机就绪"
+ resp.models_loaded = True
+ return resp
+
+ HealthHandler.get_health = patched_health_v2
+ HealthHandler._fixed_v2 = True
+ # ------------------------------------------------------------
+
+ # --- 修复显存采集指针:使得显存监控永远对准当前选定工作的 GPU ---
+ from services.gpu_info.gpu_info_impl import GpuInfoImpl
+
+ if not hasattr(GpuInfoImpl, "_fixed_vram_patch"):
+ _orig_get_gpu_info = GpuInfoImpl.get_gpu_info
+
+ def patched_get_gpu_info(self):
+ import torch
+
+ if self.get_cuda_available():
+ idx = 0
+ if (
+ hasattr(handler.config.device, "index")
+ and handler.config.device.index is not None
+ ):
+ idx = handler.config.device.index
+ try:
+ import pynvml
+
+ pynvml.nvmlInit()
+ handle = pynvml.nvmlDeviceGetHandleByIndex(idx)
+ raw_name = pynvml.nvmlDeviceGetName(handle)
+ name = (
+ raw_name.decode("utf-8", errors="replace")
+ if isinstance(raw_name, bytes)
+ else str(raw_name)
+ )
+ memory = pynvml.nvmlDeviceGetMemoryInfo(handle)
+ pynvml.nvmlShutdown()
+ return {
+ "name": f"{name} [ID: {idx}]",
+ "vram": memory.total // (1024 * 1024),
+ "vramUsed": memory.used // (1024 * 1024),
+ }
+ except Exception:
+ pass
+ return _orig_get_gpu_info(self)
+
+ GpuInfoImpl.get_gpu_info = patched_get_gpu_info
+ GpuInfoImpl._fixed_vram_patch = True
+
+ return app
diff --git a/LTX2.3-1.0.3/patches/app_settings_patch.py b/LTX2.3-1.0.3/patches/app_settings_patch.py
new file mode 100644
index 0000000000000000000000000000000000000000..5187b64004e8d3ac020c45caacb626230429d9a0
--- /dev/null
+++ b/LTX2.3-1.0.3/patches/app_settings_patch.py
@@ -0,0 +1,22 @@
+"""运行时补丁:给 AppSettings 添加 lora_dir 字段(如果不存在)。"""
+
+import sys
+import os
+
+
+def patch_app_settings():
+ try:
+ from state.app_settings import AppSettings
+ from pydantic import Field
+
+ if "lora_dir" not in AppSettings.model_fields:
+ AppSettings.model_fields["lora_dir"] = Field(
+ default="", validation_alias="loraDir", serialization_alias="loraDir"
+ )
+ AppSettings.model_rebuild(_force=True)
+ print("[PATCH] AppSettings patched: added lora_dir field")
+ except Exception as e:
+ print(f"[PATCH] AppSettings patch failed: {e}")
+
+
+patch_app_settings()
diff --git a/LTX2.3-1.0.3/patches/handlers/__pycache__/video_generation_handler.cpython-313.pyc b/LTX2.3-1.0.3/patches/handlers/__pycache__/video_generation_handler.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..d22db5171a7ef63227d5d27e1d0f188b872413e9
Binary files /dev/null and b/LTX2.3-1.0.3/patches/handlers/__pycache__/video_generation_handler.cpython-313.pyc differ
diff --git a/LTX2.3-1.0.3/patches/handlers/video_generation_handler.py b/LTX2.3-1.0.3/patches/handlers/video_generation_handler.py
new file mode 100644
index 0000000000000000000000000000000000000000..5ec2f29a7bc77e97dc980fdc502441bb582c72ff
--- /dev/null
+++ b/LTX2.3-1.0.3/patches/handlers/video_generation_handler.py
@@ -0,0 +1,868 @@
+"""Video generation orchestration handler."""
+
+from __future__ import annotations
+
+import logging
+import os
+import tempfile
+import time
+import uuid
+from datetime import datetime
+from pathlib import Path
+from threading import RLock
+from typing import TYPE_CHECKING
+
+from PIL import Image
+
+from api_types import (
+ GenerateVideoRequest,
+ GenerateVideoResponse,
+ ImageConditioningInput,
+ VideoCameraMotion,
+)
+from _routes._errors import HTTPError
+from handlers.base import StateHandlerBase
+from handlers.generation_handler import GenerationHandler
+from handlers.pipelines_handler import PipelinesHandler
+from handlers.text_handler import TextHandler
+from runtime_config.model_download_specs import resolve_model_path
+from server_utils.media_validation import (
+ normalize_optional_path,
+ validate_audio_file,
+ validate_image_file,
+)
+from services.interfaces import LTXAPIClient
+from state.app_state_types import AppState
+from state.app_settings import should_video_generate_with_ltx_api
+
+if TYPE_CHECKING:
+ from runtime_config.runtime_config import RuntimeConfig
+
+logger = logging.getLogger(__name__)
+
+FORCED_API_MODEL_MAP: dict[str, str] = {
+ "fast": "ltx-2-3-fast",
+ "pro": "ltx-2-3-pro",
+}
+FORCED_API_RESOLUTION_MAP: dict[str, dict[str, str]] = {
+ "1080p": {"16:9": "1920x1080", "9:16": "1080x1920"},
+ "1440p": {"16:9": "2560x1440", "9:16": "1440x2560"},
+ "2160p": {"16:9": "3840x2160", "9:16": "2160x3840"},
+}
+A2V_FORCED_API_RESOLUTION = "1920x1080"
+FORCED_API_ALLOWED_ASPECT_RATIOS = {"16:9", "9:16"}
+FORCED_API_ALLOWED_FPS = {24, 25, 48, 50}
+
+
+def _get_allowed_durations(model_id: str, resolution_label: str, fps: int) -> set[int]:
+ if model_id == "ltx-2-3-fast" and resolution_label == "1080p" and fps in {24, 25}:
+ return {6, 8, 10, 12, 14, 16, 18, 20}
+ return {6, 8, 10}
+
+
+class VideoGenerationHandler(StateHandlerBase):
+ def __init__(
+ self,
+ state: AppState,
+ lock: RLock,
+ generation_handler: GenerationHandler,
+ pipelines_handler: PipelinesHandler,
+ text_handler: TextHandler,
+ ltx_api_client: LTXAPIClient,
+ config: RuntimeConfig,
+ ) -> None:
+ super().__init__(state, lock, config)
+ self._generation = generation_handler
+ self._pipelines = pipelines_handler
+ self._text = text_handler
+ self._ltx_api_client = ltx_api_client
+
+ def generate(self, req: GenerateVideoRequest) -> GenerateVideoResponse:
+ if should_video_generate_with_ltx_api(
+ force_api_generations=self.config.force_api_generations,
+ settings=self.state.app_settings,
+ ):
+ return self._generate_forced_api(req)
+
+ if self._generation.is_generation_running():
+ raise HTTPError(409, "Generation already in progress")
+
+ resolution = req.resolution
+
+ duration = int(float(req.duration))
+ fps = int(float(req.fps))
+
+ audio_path = normalize_optional_path(req.audioPath)
+ if audio_path:
+ return self._generate_a2v(req, duration, fps, audio_path=audio_path)
+
+ logger.info("Resolution %s - using fast pipeline", resolution)
+
+ RESOLUTION_MAP_16_9: dict[str, tuple[int, int]] = {
+ "540p": (1024, 576),
+ "720p": (1280, 704),
+ "1080p": (1920, 1088),
+ }
+
+ def get_16_9_size(res: str) -> tuple[int, int]:
+ return RESOLUTION_MAP_16_9.get(res, (1280, 704))
+
+ def get_9_16_size(res: str) -> tuple[int, int]:
+ w, h = get_16_9_size(res)
+ return h, w
+
+ match req.aspectRatio:
+ case "9:16":
+ width, height = get_9_16_size(resolution)
+ case "16:9":
+ width, height = get_16_9_size(resolution)
+
+ num_frames = self._compute_num_frames(duration, fps)
+
+ image = None
+ image_path = normalize_optional_path(req.imagePath)
+ if image_path:
+ image = self._prepare_image(image_path, width, height)
+ logger.info("Image: %s -> %sx%s", image_path, width, height)
+
+ generation_id = self._make_generation_id()
+ seed = self._resolve_seed()
+
+ logger.info(
+ f"Request loraPath: '{req.loraPath}', loraStrength: {req.loraStrength}, inferenceSteps: {req.inferenceSteps}"
+ )
+
+ # 尝试支持自定义步数(实验性)
+ inference_steps = req.inferenceSteps
+ logger.info(f"Using inference steps: {inference_steps}")
+
+ loras = None
+ if req.loraPath and req.loraPath.strip():
+ try:
+ import os
+ from pathlib import Path
+ from ltx_core.loader import LoraPathStrengthAndSDOps
+ from ltx_core.loader.sd_ops import LTXV_LORA_COMFY_RENAMING_MAP
+
+ lora_path = req.loraPath.strip()
+ logger.info(
+ f"LoRA path: {lora_path}, exists: {os.path.exists(lora_path)}"
+ )
+
+ if os.path.exists(lora_path):
+ loras = [
+ LoraPathStrengthAndSDOps(
+ path=lora_path,
+ strength=req.loraStrength,
+ sd_ops=LTXV_LORA_COMFY_RENAMING_MAP,
+ )
+ ]
+ logger.info(
+ f"LoRA prepared: {lora_path} with strength {req.loraStrength}"
+ )
+ else:
+ logger.warning(f"LoRA file not found: {lora_path}")
+ except Exception as e:
+ logger.warning(f"Failed to load LoRA: {e}")
+ import traceback
+
+ logger.warning(f"LoRA traceback: {traceback.format_exc()}")
+ loras = None
+
+ lora_path_req = (req.loraPath or "").strip()
+ desired_sig = (
+ "fast",
+ lora_path_req if loras is not None else "",
+ round(float(req.loraStrength), 4) if loras is not None else 0.0,
+ )
+ try:
+ if loras is not None:
+ # 强制卸载并重新加载带LoRA的pipeline
+ logger.info("Unloading pipeline for LoRA...")
+ from keep_models_runtime import force_unload_gpu_pipeline
+
+ force_unload_gpu_pipeline(self._pipelines)
+
+ # 强制垃圾回收
+ import gc
+
+ gc.collect()
+ # 释放 CUDA 缓存,降低 LoRA 首次构建的显存峰值/碎片风险
+ try:
+ import torch
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+ torch.cuda.ipc_collect()
+ except Exception:
+ pass
+
+ gemma_root = self._pipelines._text_handler.resolve_gemma_root()
+ from runtime_config.model_download_specs import resolve_model_path
+ from services.fast_video_pipeline.ltx_fast_video_pipeline import (
+ LTXFastVideoPipeline,
+ )
+
+ checkpoint_path = str(
+ resolve_model_path(
+ self._pipelines.models_dir,
+ self._pipelines.config.model_download_specs,
+ "checkpoint",
+ )
+ )
+ upsampler_path = str(
+ resolve_model_path(
+ self._pipelines.models_dir,
+ self._pipelines.config.model_download_specs,
+ "upsampler",
+ )
+ )
+
+ logger.info(
+ f"Creating pipeline with LoRA: {loras}, steps: {inference_steps}"
+ )
+ from lora_injection import (
+ _lora_init_kwargs,
+ inject_loras_into_fast_pipeline,
+ )
+
+ lora_kw = _lora_init_kwargs(LTXFastVideoPipeline, loras)
+ pipeline = LTXFastVideoPipeline(
+ checkpoint_path,
+ gemma_root,
+ upsampler_path,
+ self._pipelines.config.device,
+ **lora_kw,
+ )
+ n_inj = inject_loras_into_fast_pipeline(pipeline, loras)
+ if hasattr(pipeline, "pipeline") and hasattr(
+ pipeline.pipeline, "model_ledger"
+ ):
+ try:
+ pipeline.pipeline.model_ledger.loras = tuple(loras)
+ except Exception:
+ pass
+ logger.info(
+ "LoRA 注入: init_kw=%s, 注入点=%s, model_ledger.loras=%s",
+ list(lora_kw.keys()),
+ n_inj,
+ getattr(
+ getattr(pipeline.pipeline, "model_ledger", None),
+ "loras",
+ None,
+ ),
+ )
+
+ from state.app_state_types import (
+ VideoPipelineState,
+ VideoPipelineWarmth,
+ GpuSlot,
+ )
+
+ state = VideoPipelineState(
+ pipeline=pipeline,
+ warmth=VideoPipelineWarmth.COLD,
+ is_compiled=False,
+ )
+
+ self._pipelines.state.gpu_slot = GpuSlot(
+ active_pipeline=state, generation=None
+ )
+ logger.info("Pipeline with LoRA loaded successfully")
+ else:
+ # 无论有没有LoRA,都尝试使用自定义步数重新加载pipeline
+ logger.info(f"Loading pipeline with {inference_steps} steps")
+ from keep_models_runtime import force_unload_gpu_pipeline
+
+ force_unload_gpu_pipeline(self._pipelines)
+
+ import gc
+
+ gc.collect()
+
+ gemma_root = self._pipelines._text_handler.resolve_gemma_root()
+ from runtime_config.model_download_specs import resolve_model_path
+ from services.fast_video_pipeline.ltx_fast_video_pipeline import (
+ LTXFastVideoPipeline,
+ )
+
+ checkpoint_path = str(
+ resolve_model_path(
+ self._pipelines.models_dir,
+ self._pipelines.config.model_download_specs,
+ "checkpoint",
+ )
+ )
+ upsampler_path = str(
+ resolve_model_path(
+ self._pipelines.models_dir,
+ self._pipelines.config.model_download_specs,
+ "upsampler",
+ )
+ )
+
+ pipeline = LTXFastVideoPipeline(
+ checkpoint_path,
+ gemma_root,
+ upsampler_path,
+ self._pipelines.config.device,
+ )
+
+ from state.app_state_types import (
+ VideoPipelineState,
+ VideoPipelineWarmth,
+ GpuSlot,
+ )
+
+ state = VideoPipelineState(
+ pipeline=pipeline,
+ warmth=VideoPipelineWarmth.COLD,
+ is_compiled=False,
+ )
+
+ self._pipelines.state.gpu_slot = GpuSlot(
+ active_pipeline=state, generation=None
+ )
+
+ self._pipelines._pipeline_signature = desired_sig
+
+ self._generation.start_generation(generation_id)
+
+ output_path = self.generate_video(
+ prompt=req.prompt,
+ image=image,
+ height=height,
+ width=width,
+ num_frames=num_frames,
+ fps=fps,
+ seed=seed,
+ camera_motion=req.cameraMotion,
+ negative_prompt=req.negativePrompt,
+ )
+
+ self._generation.complete_generation(output_path)
+ return GenerateVideoResponse(status="complete", video_path=output_path)
+
+ except Exception as e:
+ self._generation.fail_generation(str(e))
+ if "cancelled" in str(e).lower():
+ logger.info("Generation cancelled by user")
+ return GenerateVideoResponse(status="cancelled")
+
+ raise HTTPError(500, str(e)) from e
+
+ def generate_video(
+ self,
+ prompt: str,
+ image: Image.Image | None,
+ height: int,
+ width: int,
+ num_frames: int,
+ fps: float,
+ seed: int,
+ camera_motion: VideoCameraMotion,
+ negative_prompt: str,
+ ) -> str:
+ t_total_start = time.perf_counter()
+ gen_mode = "i2v" if image is not None else "t2v"
+ logger.info(
+ "[%s] Generation started (model=fast, %dx%d, %d frames, %d fps)",
+ gen_mode,
+ width,
+ height,
+ num_frames,
+ int(fps),
+ )
+
+ if self._generation.is_generation_cancelled():
+ raise RuntimeError("Generation was cancelled")
+
+ if not resolve_model_path(
+ self.models_dir, self.config.model_download_specs, "checkpoint"
+ ).exists():
+ raise RuntimeError(
+ "Models not downloaded. Please download the AI models first using the Model Status menu."
+ )
+
+ total_steps = 8
+
+ self._generation.update_progress("loading_model", 5, 0, total_steps)
+ t_load_start = time.perf_counter()
+ pipeline_state = self._pipelines.load_gpu_pipeline("fast", should_warm=False)
+ t_load_end = time.perf_counter()
+ logger.info("[%s] Pipeline load: %.2fs", gen_mode, t_load_end - t_load_start)
+
+ self._generation.update_progress("encoding_text", 10, 0, total_steps)
+
+ enhanced_prompt = prompt + self.config.camera_motion_prompts.get(
+ camera_motion, ""
+ )
+
+ images: list[ImageConditioningInput] = []
+ temp_image_path: str | None = None
+ if image is not None:
+ temp_image_path = tempfile.NamedTemporaryFile(
+ suffix=".png", delete=False
+ ).name
+ image.save(temp_image_path)
+ images = [
+ ImageConditioningInput(path=temp_image_path, frame_idx=0, strength=1.0)
+ ]
+
+ output_path = self._make_output_path()
+
+ try:
+ settings = self.state.app_settings
+ use_api_encoding = not self._text.should_use_local_encoding()
+ if image is not None:
+ enhance = use_api_encoding and settings.prompt_enhancer_enabled_i2v
+ else:
+ enhance = use_api_encoding and settings.prompt_enhancer_enabled_t2v
+
+ encoding_method = "api" if use_api_encoding else "local"
+ t_text_start = time.perf_counter()
+ self._text.prepare_text_encoding(enhanced_prompt, enhance_prompt=enhance)
+ t_text_end = time.perf_counter()
+ logger.info(
+ "[%s] Text encoding (%s): %.2fs",
+ gen_mode,
+ encoding_method,
+ t_text_end - t_text_start,
+ )
+
+ self._generation.update_progress("inference", 15, 0, total_steps)
+
+ height = round(height / 64) * 64
+ width = round(width / 64) * 64
+
+ t_inference_start = time.perf_counter()
+ pipeline_state.pipeline.generate(
+ prompt=enhanced_prompt,
+ seed=seed,
+ height=height,
+ width=width,
+ num_frames=num_frames,
+ frame_rate=fps,
+ images=images,
+ output_path=str(output_path),
+ )
+ t_inference_end = time.perf_counter()
+ logger.info(
+ "[%s] Inference: %.2fs", gen_mode, t_inference_end - t_inference_start
+ )
+
+ if self._generation.is_generation_cancelled():
+ if output_path.exists():
+ output_path.unlink()
+ raise RuntimeError("Generation was cancelled")
+
+ t_total_end = time.perf_counter()
+ logger.info(
+ "[%s] Total generation: %.2fs (load=%.2fs, text=%.2fs, inference=%.2fs)",
+ gen_mode,
+ t_total_end - t_total_start,
+ t_load_end - t_load_start,
+ t_text_end - t_text_start,
+ t_inference_end - t_inference_start,
+ )
+
+ self._generation.update_progress("complete", 100, total_steps, total_steps)
+ return str(output_path)
+ finally:
+ self._text.clear_api_embeddings()
+ if temp_image_path and os.path.exists(temp_image_path):
+ os.unlink(temp_image_path)
+
+ def _generate_a2v(
+ self, req: GenerateVideoRequest, duration: int, fps: int, *, audio_path: str
+ ) -> GenerateVideoResponse:
+ if req.model != "pro":
+ logger.warning(
+ "A2V local requested with model=%s; A2V always uses pro pipeline",
+ req.model,
+ )
+ validated_audio_path = validate_audio_file(audio_path)
+ audio_path_str = str(validated_audio_path)
+
+ # 支持竖屏和横屏
+ RESOLUTION_MAP: dict[str, tuple[int, int]] = {
+ "540p": (1024, 576),
+ "720p": (1280, 704),
+ "1080p": (1920, 1088),
+ }
+
+ base_w, base_h = RESOLUTION_MAP.get(req.resolution, (1280, 704))
+
+ # 根据 aspectRatio 调整分辨率
+ if req.aspectRatio == "9:16":
+ width, height = base_h, base_w # 竖屏
+ else:
+ width, height = base_w, base_h # 横屏
+
+ num_frames = self._compute_num_frames(duration, fps)
+
+ image = None
+ temp_image_path: str | None = None
+ image_path = normalize_optional_path(req.imagePath)
+ if image_path:
+ image = self._prepare_image(image_path, width, height)
+
+ # 获取首尾帧
+ start_frame_path = normalize_optional_path(getattr(req, "startFramePath", None))
+ end_frame_path = normalize_optional_path(getattr(req, "endFramePath", None))
+
+ seed = self._resolve_seed()
+
+ generation_id = self._make_generation_id()
+
+ temp_image_paths: list[str] = []
+ try:
+ a2v_state = self._pipelines.load_a2v_pipeline()
+ self._generation.start_generation(generation_id)
+
+ enhanced_prompt = req.prompt + self.config.camera_motion_prompts.get(
+ req.cameraMotion, ""
+ )
+ neg = (
+ req.negativePrompt
+ if req.negativePrompt
+ else self.config.default_negative_prompt
+ )
+
+ images: list[ImageConditioningInput] = []
+ temp_image_paths: list[str] = []
+
+ # 首帧
+ if start_frame_path:
+ start_img = self._prepare_image(start_frame_path, width, height)
+ temp_start_path = tempfile.NamedTemporaryFile(
+ suffix=".png", delete=False
+ ).name
+ start_img.save(temp_start_path)
+ temp_image_paths.append(temp_start_path)
+ images.append(
+ ImageConditioningInput(
+ path=temp_start_path, frame_idx=0, strength=1.0
+ )
+ )
+
+ # 中间图片(如果有)
+ if image is not None and not start_frame_path:
+ temp_image_path = tempfile.NamedTemporaryFile(
+ suffix=".png", delete=False
+ ).name
+ image.save(temp_image_path)
+ temp_image_paths.append(temp_image_path)
+ images.append(
+ ImageConditioningInput(
+ path=temp_image_path, frame_idx=0, strength=1.0
+ )
+ )
+
+ # 尾帧
+ if end_frame_path:
+ last_latent_idx = (num_frames - 1) // 8 + 1 - 1
+ end_img = self._prepare_image(end_frame_path, width, height)
+ temp_end_path = tempfile.NamedTemporaryFile(
+ suffix=".png", delete=False
+ ).name
+ end_img.save(temp_end_path)
+ temp_image_paths.append(temp_end_path)
+ images.append(
+ ImageConditioningInput(
+ path=temp_end_path, frame_idx=last_latent_idx, strength=1.0
+ )
+ )
+
+ output_path = self._make_output_path()
+
+ total_steps = 11 # distilled: 8 steps (stage 1) + 3 steps (stage 2)
+
+ a2v_settings = self.state.app_settings
+ a2v_use_api = not self._text.should_use_local_encoding()
+ if image is not None:
+ a2v_enhance = a2v_use_api and a2v_settings.prompt_enhancer_enabled_i2v
+ else:
+ a2v_enhance = a2v_use_api and a2v_settings.prompt_enhancer_enabled_t2v
+
+ self._generation.update_progress("loading_model", 5, 0, total_steps)
+ self._generation.update_progress("encoding_text", 10, 0, total_steps)
+ self._text.prepare_text_encoding(
+ enhanced_prompt, enhance_prompt=a2v_enhance
+ )
+ self._generation.update_progress("inference", 15, 0, total_steps)
+
+ a2v_state.pipeline.generate(
+ prompt=enhanced_prompt,
+ negative_prompt=neg,
+ seed=seed,
+ height=height,
+ width=width,
+ num_frames=num_frames,
+ frame_rate=fps,
+ num_inference_steps=total_steps,
+ images=images,
+ audio_path=audio_path_str,
+ audio_start_time=0.0,
+ audio_max_duration=None,
+ output_path=str(output_path),
+ )
+
+ if self._generation.is_generation_cancelled():
+ if output_path.exists():
+ output_path.unlink()
+ raise RuntimeError("Generation was cancelled")
+
+ self._generation.update_progress("complete", 100, total_steps, total_steps)
+ self._generation.complete_generation(str(output_path))
+ return GenerateVideoResponse(status="complete", video_path=str(output_path))
+
+ except Exception as e:
+ self._generation.fail_generation(str(e))
+ if "cancelled" in str(e).lower():
+ logger.info("Generation cancelled by user")
+ return GenerateVideoResponse(status="cancelled")
+ raise HTTPError(500, str(e)) from e
+ finally:
+ self._text.clear_api_embeddings()
+ # 清理所有临时图片
+ for tmp_path in temp_image_paths:
+ if tmp_path and os.path.exists(tmp_path):
+ try:
+ os.unlink(tmp_path)
+ except Exception:
+ pass
+ if temp_image_path and os.path.exists(temp_image_path):
+ try:
+ os.unlink(temp_image_path)
+ except Exception:
+ pass
+
+ def _prepare_image(self, image_path: str, width: int, height: int) -> Image.Image:
+ validated_path = validate_image_file(image_path)
+ try:
+ img = Image.open(validated_path).convert("RGB")
+ except Exception:
+ raise HTTPError(400, f"Invalid image file: {image_path}") from None
+ img_w, img_h = img.size
+ target_ratio = width / height
+ img_ratio = img_w / img_h
+ if img_ratio > target_ratio:
+ new_h = height
+ new_w = int(img_w * (height / img_h))
+ else:
+ new_w = width
+ new_h = int(img_h * (width / img_w))
+ resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
+ left = (new_w - width) // 2
+ top = (new_h - height) // 2
+ return resized.crop((left, top, left + width, top + height))
+
+ @staticmethod
+ def _make_generation_id() -> str:
+ return uuid.uuid4().hex[:8]
+
+ @staticmethod
+ def _compute_num_frames(duration: int, fps: int) -> int:
+ n = ((duration * fps) // 8) * 8 + 1
+ return max(n, 9)
+
+ def _resolve_seed(self) -> int:
+ settings = self.state.app_settings
+ if settings.seed_locked:
+ logger.info("Using locked seed: %s", settings.locked_seed)
+ return settings.locked_seed
+ return int(time.time()) % 2147483647
+
+ def _make_output_path(self) -> Path:
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ return (
+ self.config.outputs_dir
+ / f"ltx2_video_{timestamp}_{self._make_generation_id()}.mp4"
+ )
+
+ def _generate_forced_api(self, req: GenerateVideoRequest) -> GenerateVideoResponse:
+ if self._generation.is_generation_running():
+ raise HTTPError(409, "Generation already in progress")
+
+ generation_id = self._make_generation_id()
+ self._generation.start_api_generation(generation_id)
+
+ audio_path = normalize_optional_path(req.audioPath)
+ image_path = normalize_optional_path(req.imagePath)
+ has_input_audio = bool(audio_path)
+ has_input_image = bool(image_path)
+
+ try:
+ self._generation.update_progress("validating_request", 5, None, None)
+
+ api_key = self.state.app_settings.ltx_api_key.strip()
+ logger.info(
+ "Forced API generation route selected (key_present=%s)", bool(api_key)
+ )
+ if not api_key:
+ raise HTTPError(400, "PRO_API_KEY_REQUIRED")
+
+ requested_model = req.model.strip().lower()
+ api_model_id = FORCED_API_MODEL_MAP.get(requested_model)
+ if api_model_id is None:
+ raise HTTPError(400, "INVALID_FORCED_API_MODEL")
+
+ resolution_label = req.resolution
+ resolution_by_aspect = FORCED_API_RESOLUTION_MAP.get(resolution_label)
+ if resolution_by_aspect is None:
+ raise HTTPError(400, "INVALID_FORCED_API_RESOLUTION")
+
+ aspect_ratio = req.aspectRatio.strip()
+ if aspect_ratio not in FORCED_API_ALLOWED_ASPECT_RATIOS:
+ raise HTTPError(400, "INVALID_FORCED_API_ASPECT_RATIO")
+
+ api_resolution = resolution_by_aspect[aspect_ratio]
+
+ prompt = req.prompt
+
+ if self._generation.is_generation_cancelled():
+ raise RuntimeError("Generation was cancelled")
+
+ if has_input_audio:
+ if requested_model != "pro":
+ logger.warning(
+ "A2V requested with model=%s; overriding to 'pro'",
+ requested_model,
+ )
+ api_model_id = FORCED_API_MODEL_MAP["pro"]
+ if api_resolution != A2V_FORCED_API_RESOLUTION:
+ logger.warning(
+ "A2V requested with resolution=%s; overriding to '%s'",
+ api_resolution,
+ A2V_FORCED_API_RESOLUTION,
+ )
+ api_resolution = A2V_FORCED_API_RESOLUTION
+ validated_audio_path = validate_audio_file(audio_path)
+ validated_image_path: Path | None = None
+ if image_path is not None:
+ validated_image_path = validate_image_file(image_path)
+
+ self._generation.update_progress("uploading_audio", 20, None, None)
+ audio_uri = self._ltx_api_client.upload_file(
+ api_key=api_key,
+ file_path=str(validated_audio_path),
+ )
+ image_uri: str | None = None
+ if validated_image_path is not None:
+ self._generation.update_progress("uploading_image", 35, None, None)
+ image_uri = self._ltx_api_client.upload_file(
+ api_key=api_key,
+ file_path=str(validated_image_path),
+ )
+ self._generation.update_progress("inference", 55, None, None)
+ video_bytes = self._ltx_api_client.generate_audio_to_video(
+ api_key=api_key,
+ prompt=prompt,
+ audio_uri=audio_uri,
+ image_uri=image_uri,
+ model=api_model_id,
+ resolution=api_resolution,
+ )
+ self._generation.update_progress("downloading_output", 85, None, None)
+ elif has_input_image:
+ validated_image_path = validate_image_file(image_path)
+
+ duration = self._parse_forced_numeric_field(
+ req.duration, "INVALID_FORCED_API_DURATION"
+ )
+ fps = self._parse_forced_numeric_field(
+ req.fps, "INVALID_FORCED_API_FPS"
+ )
+ if fps not in FORCED_API_ALLOWED_FPS:
+ raise HTTPError(400, "INVALID_FORCED_API_FPS")
+ if duration not in _get_allowed_durations(
+ api_model_id, resolution_label, fps
+ ):
+ raise HTTPError(400, "INVALID_FORCED_API_DURATION")
+
+ generate_audio = self._parse_audio_flag(req.audio)
+ self._generation.update_progress("uploading_image", 20, None, None)
+ image_uri = self._ltx_api_client.upload_file(
+ api_key=api_key,
+ file_path=str(validated_image_path),
+ )
+ self._generation.update_progress("inference", 55, None, None)
+ video_bytes = self._ltx_api_client.generate_image_to_video(
+ api_key=api_key,
+ prompt=prompt,
+ image_uri=image_uri,
+ model=api_model_id,
+ resolution=api_resolution,
+ duration=float(duration),
+ fps=float(fps),
+ generate_audio=generate_audio,
+ camera_motion=req.cameraMotion,
+ )
+ self._generation.update_progress("downloading_output", 85, None, None)
+ else:
+ duration = self._parse_forced_numeric_field(
+ req.duration, "INVALID_FORCED_API_DURATION"
+ )
+ fps = self._parse_forced_numeric_field(
+ req.fps, "INVALID_FORCED_API_FPS"
+ )
+ if fps not in FORCED_API_ALLOWED_FPS:
+ raise HTTPError(400, "INVALID_FORCED_API_FPS")
+ if duration not in _get_allowed_durations(
+ api_model_id, resolution_label, fps
+ ):
+ raise HTTPError(400, "INVALID_FORCED_API_DURATION")
+
+ generate_audio = self._parse_audio_flag(req.audio)
+ self._generation.update_progress("inference", 55, None, None)
+ video_bytes = self._ltx_api_client.generate_text_to_video(
+ api_key=api_key,
+ prompt=prompt,
+ model=api_model_id,
+ resolution=api_resolution,
+ duration=float(duration),
+ fps=float(fps),
+ generate_audio=generate_audio,
+ camera_motion=req.cameraMotion,
+ )
+ self._generation.update_progress("downloading_output", 85, None, None)
+
+ if self._generation.is_generation_cancelled():
+ raise RuntimeError("Generation was cancelled")
+
+ output_path = self._write_forced_api_video(video_bytes)
+ if self._generation.is_generation_cancelled():
+ output_path.unlink(missing_ok=True)
+ raise RuntimeError("Generation was cancelled")
+
+ self._generation.update_progress("complete", 100, None, None)
+ self._generation.complete_generation(str(output_path))
+ return GenerateVideoResponse(status="complete", video_path=str(output_path))
+ except HTTPError as e:
+ self._generation.fail_generation(e.detail)
+ raise
+ except Exception as e:
+ self._generation.fail_generation(str(e))
+ if "cancelled" in str(e).lower():
+ logger.info("Generation cancelled by user")
+ return GenerateVideoResponse(status="cancelled")
+ raise HTTPError(500, str(e)) from e
+
+ def _write_forced_api_video(self, video_bytes: bytes) -> Path:
+ output_path = self._make_output_path()
+ output_path.write_bytes(video_bytes)
+ return output_path
+
+ @staticmethod
+ def _parse_forced_numeric_field(raw_value: str, error_detail: str) -> int:
+ try:
+ return int(float(raw_value))
+ except (TypeError, ValueError):
+ raise HTTPError(400, error_detail) from None
+
+ @staticmethod
+ def _parse_audio_flag(audio_value: str | bool) -> bool:
+ if isinstance(audio_value, bool):
+ return audio_value
+ normalized = audio_value.strip().lower()
+ return normalized in {"1", "true", "yes", "on"}
diff --git a/LTX2.3-1.0.3/patches/keep_models_runtime.py b/LTX2.3-1.0.3/patches/keep_models_runtime.py
new file mode 100644
index 0000000000000000000000000000000000000000..bcb51df72448bce95a2a3f8236989928c54ac8b0
--- /dev/null
+++ b/LTX2.3-1.0.3/patches/keep_models_runtime.py
@@ -0,0 +1,16 @@
+"""仅提供强制卸载 GPU 管线。「保持模型加载」功能已移除。"""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+def force_unload_gpu_pipeline(pipelines: Any) -> None:
+ """释放推理管线占用的显存(切换 GPU、清理、LoRA 重建等场景)。"""
+ try:
+ pipelines.unload_gpu_pipeline()
+ except Exception:
+ try:
+ type(pipelines).unload_gpu_pipeline(pipelines)
+ except Exception:
+ pass
diff --git a/LTX2.3-1.0.3/patches/launcher.py b/LTX2.3-1.0.3/patches/launcher.py
new file mode 100644
index 0000000000000000000000000000000000000000..c3140be6779a1b90006587225ed0aa4913c3a4ca
--- /dev/null
+++ b/LTX2.3-1.0.3/patches/launcher.py
@@ -0,0 +1,20 @@
+
+import sys
+import os
+
+patch_dir = r"C:\Users\Administrator\Desktop\LTX2.3-1.0.3\patches"
+backend_dir = r"C:\Program Files\LTX Desktop\resources\backend"
+
+# 防御性清除:强行剥离所有的默认 backend_dir 引用
+sys.path = [p for p in sys.path if p and os.path.normpath(p) != os.path.normpath(backend_dir)]
+sys.path = [p for p in sys.path if p and p != "." and p != ""]
+
+# 绝对插队注入:优先搜索 PATCHES_DIR
+sys.path.insert(0, patch_dir)
+sys.path.insert(1, backend_dir)
+
+import uvicorn
+from ltx2_server import app
+
+if __name__ == '__main__':
+ uvicorn.run(app, host="0.0.0.0", port=3000, log_level="info", access_log=False)
diff --git a/LTX2.3-1.0.3/patches/lora_build_hook.py b/LTX2.3-1.0.3/patches/lora_build_hook.py
new file mode 100644
index 0000000000000000000000000000000000000000..95bc42637283d3185a862ee3acd88e87749346f7
--- /dev/null
+++ b/LTX2.3-1.0.3/patches/lora_build_hook.py
@@ -0,0 +1,104 @@
+"""
+在 SingleGPUModelBuilder.build() 时合并「当前请求」的用户 LoRA。
+
+桌面版 Fast 管线往往只在 model_ledger 上挂 loras,真正 load 权重时仍用
+初始化时的空 loras Builder;此处对 DiT/Transformer 的 Builder 在 build 前注入。
+"""
+
+from __future__ import annotations
+
+import contextvars
+import logging
+from dataclasses import replace
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+# 当前 HTTP 请求/生成任务中要额外融合的 LoRA(LoraPathStrengthAndSDOps 元组)
+_pending_user_loras: contextvars.ContextVar[tuple[Any, ...] | None] = contextvars.ContextVar(
+ "ltx_pending_user_loras", default=None
+)
+
+_HOOK_INSTALLED = False
+
+
+def pending_loras_token(loras: tuple[Any, ...] | None):
+ """返回 contextvar Token,供 finally reset;loras 为 None 表示本任务不用额外 LoRA。"""
+ return _pending_user_loras.set(loras)
+
+
+def reset_pending_loras(token: contextvars.Token | None) -> None:
+ if token is not None:
+ _pending_user_loras.reset(token)
+
+
+def _get_pending() -> tuple[Any, ...] | None:
+ return _pending_user_loras.get()
+
+
+def _is_ltx_diffusion_transformer_builder(builder: Any) -> bool:
+ """避免给 Gemma / VAE / Upsampler 的 Builder 误加视频 LoRA。"""
+ cfg = getattr(builder, "model_class_configurator", None)
+ if cfg is None:
+ return False
+ name = getattr(cfg, "__name__", "") or ""
+ # 排除明显非 DiT 的
+ for bad in (
+ "Gemma",
+ "VideoEncoder",
+ "VideoDecoder",
+ "AudioEncoder",
+ "AudioDecoder",
+ "Vocoder",
+ "EmbeddingsProcessor",
+ "LatentUpsampler",
+ ):
+ if bad in name:
+ return False
+ try:
+ from ltx_core.model.transformer import LTXModelConfigurator
+
+ if isinstance(cfg, type):
+ try:
+ if issubclass(cfg, LTXModelConfigurator):
+ return True
+ except TypeError:
+ pass
+ if cfg is LTXModelConfigurator:
+ return True
+ except ImportError:
+ pass
+ # 兜底:LTX 主 transformer 配置器命名习惯(排除已列出的 VAE/Gemma)
+ return "LTX" in name and "ModelConfigurator" in name
+
+
+def install_lora_build_hook() -> None:
+ global _HOOK_INSTALLED
+ if _HOOK_INSTALLED:
+ return
+ try:
+ from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder
+ except ImportError:
+ logger.warning("lora_build_hook: 无法导入 SingleGPUModelBuilder,跳过")
+ return
+
+ _orig_build = SingleGPUModelBuilder.build
+
+ def build(self: Any, *args: Any, **kwargs: Any) -> Any:
+ extra = _get_pending()
+ if extra and _is_ltx_diffusion_transformer_builder(self):
+ have = {getattr(x, "path", None) for x in self.loras}
+ add = tuple(x for x in extra if getattr(x, "path", None) not in have)
+ if add:
+ merged = (*tuple(self.loras), *add)
+ self = replace(self, loras=merged)
+ logger.info(
+ "lora_build_hook: 已向 DiT Builder 合并 %d 个用户 LoRA: %s",
+ len(add),
+ [getattr(x, "path", x) for x in add],
+ )
+ return _orig_build(self, *args, **kwargs)
+
+ SingleGPUModelBuilder.build = build # type: ignore[method-assign]
+ _HOOK_INSTALLED = True
+ logger.info("lora_build_hook: 已挂载 SingleGPUModelBuilder.build")
diff --git a/LTX2.3-1.0.3/patches/lora_injection.py b/LTX2.3-1.0.3/patches/lora_injection.py
new file mode 100644
index 0000000000000000000000000000000000000000..c7c708293e405e7edf0125e8275f29209f5a9595
--- /dev/null
+++ b/LTX2.3-1.0.3/patches/lora_injection.py
@@ -0,0 +1,139 @@
+"""将用户 LoRA 注入 Fast 视频管线:兼容 ModelLedger 与 LTX-2 DiffusionStage/Builder。"""
+
+from __future__ import annotations
+
+import inspect
+import logging
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+
+def _lora_init_kwargs(
+ pipeline_cls: type, loras: list[Any] | tuple[Any, ...]
+) -> dict[str, Any]:
+ if not loras:
+ return {}
+ try:
+ sig = inspect.signature(pipeline_cls.__init__)
+ names = sig.parameters.keys()
+ except (TypeError, ValueError):
+ return {}
+ tup = tuple(loras)
+ for key in ("loras", "lora", "extra_loras", "user_loras"):
+ if key in names:
+ return {key: tup}
+ return {}
+
+
+def inject_loras_into_fast_pipeline(ltx_pipe: Any, loras: list[Any] | tuple[Any, ...]) -> int:
+ """在已构造的管线上尽量把 LoRA 写进会参与 build 的 Builder / ledger。返回成功写入的处数。"""
+ if not loras:
+ return 0
+ tup = tuple(loras)
+ patched = 0
+ visited: set[int] = set()
+
+ def visit(obj: Any, depth: int) -> None:
+ nonlocal patched
+ if obj is None or depth > 10:
+ return
+ oid = id(obj)
+ if oid in visited:
+ return
+ visited.add(oid)
+
+ # ModelLedger.loras(旧桌面)
+ ml = getattr(obj, "model_ledger", None)
+ if ml is not None:
+ try:
+ ml.loras = tup
+ patched += 1
+ logger.info("LoRA: 已设置 model_ledger.loras")
+ except Exception as e:
+ logger.debug("model_ledger.loras: %s", e)
+
+ # SingleGPUModelBuilder.with_loras(常见与变体属性名)
+ for holder in (obj, ml):
+ if holder is None:
+ continue
+ candidates: list[Any] = []
+ for attr in (
+ "_transformer_builder",
+ "transformer_builder",
+ "_model_builder",
+ "model_builder",
+ ):
+ tb = getattr(holder, attr, None)
+ if tb is not None:
+ candidates.append((attr, tb))
+ try:
+ for attr in dir(holder):
+ al = attr.lower()
+ if "transformer" in al and "builder" in al and attr not in (
+ "_transformer_builder",
+ "transformer_builder",
+ ):
+ tb = getattr(holder, attr, None)
+ if tb is not None:
+ candidates.append((attr, tb))
+ except Exception:
+ pass
+ for attr, tb in candidates:
+ if hasattr(tb, "with_loras"):
+ try:
+ new_tb = tb.with_loras(tup)
+ setattr(holder, attr, new_tb)
+ patched += 1
+ logger.info("LoRA: 已更新 %s.with_loras", attr)
+ except Exception as e:
+ logger.debug("with_loras %s: %s", attr, e)
+
+ # DiffusionStage(类名或 isinstance)
+ is_diffusion = type(obj).__name__ == "DiffusionStage"
+ if not is_diffusion:
+ try:
+ from ltx_pipelines.utils.blocks import DiffusionStage as _DS
+
+ is_diffusion = isinstance(obj, _DS)
+ except ImportError:
+ pass
+ if is_diffusion:
+ tb = getattr(obj, "_transformer_builder", None)
+ if tb is not None and hasattr(tb, "with_loras"):
+ try:
+ obj._transformer_builder = tb.with_loras(tup)
+ patched += 1
+ logger.info("LoRA: 已写入 DiffusionStage._transformer_builder")
+ except Exception as e:
+ logger.debug("DiffusionStage: %s", e)
+
+ # 常见嵌套属性
+ for name in (
+ "pipeline",
+ "inner",
+ "_inner",
+ "fast_pipeline",
+ "_pipeline",
+ "stage_1",
+ "stage_2",
+ "stage",
+ "_stage",
+ "stages",
+ "diffusion",
+ "_diffusion",
+ ):
+ try:
+ ch = getattr(obj, name, None)
+ except Exception:
+ continue
+ if ch is not None and ch is not obj:
+ visit(ch, depth + 1)
+
+ if isinstance(obj, (list, tuple)):
+ for item in obj[:8]:
+ visit(item, depth + 1)
+
+ root = getattr(ltx_pipe, "pipeline", ltx_pipe)
+ visit(root, 0)
+ return patched
diff --git a/LTX2.3-1.0.3/patches/low_vram_runtime.py b/LTX2.3-1.0.3/patches/low_vram_runtime.py
new file mode 100644
index 0000000000000000000000000000000000000000..8001b52fbef52e54f70dafdd19286b56686003f7
--- /dev/null
+++ b/LTX2.3-1.0.3/patches/low_vram_runtime.py
@@ -0,0 +1,155 @@
+"""低显存模式:尽量降峰值显存(以速度换显存);效果取决于官方管线是否支持 offload。"""
+
+from __future__ import annotations
+
+import gc
+import logging
+import os
+import types
+from pathlib import Path
+from typing import Any
+
+logger = logging.getLogger("ltx_low_vram")
+
+
+def _ltx_desktop_config_dir() -> Path:
+ p = (
+ Path(os.environ.get("LOCALAPPDATA", os.path.expanduser("~/AppData/Local")))
+ / "LTXDesktop"
+ )
+ p.mkdir(parents=True, exist_ok=True)
+ return p.resolve()
+
+
+def low_vram_pref_path() -> Path:
+ return _ltx_desktop_config_dir() / "low_vram_mode.pref"
+
+
+def read_low_vram_pref() -> bool:
+ f = low_vram_pref_path()
+ if not f.is_file():
+ return False
+ return f.read_text(encoding="utf-8").strip().lower() in ("1", "true", "yes", "on")
+
+
+def write_low_vram_pref(enabled: bool) -> None:
+ low_vram_pref_path().write_text(
+ "true\n" if enabled else "false\n", encoding="utf-8"
+ )
+
+
+def apply_low_vram_config_tweaks(handler: Any) -> None:
+ """在官方 RuntimeConfig 上尽量关闭 fast 超分等(若字段存在)。"""
+ cfg = getattr(handler, "config", None)
+ if cfg is None:
+ return
+ fm = getattr(cfg, "fast_model", None)
+ if fm is None:
+ return
+ try:
+ if hasattr(fm, "model_copy"):
+ updated = fm.model_copy(update={"use_upscaler": False})
+ setattr(cfg, "fast_model", updated)
+ elif hasattr(fm, "use_upscaler"):
+ setattr(fm, "use_upscaler", False)
+ except Exception as e:
+ logger.debug("low_vram: 无法关闭 fast_model.use_upscaler: %s", e)
+
+
+def install_low_vram_on_pipelines(handler: Any) -> None:
+ """启动时读取偏好,挂到 pipelines 上供各补丁读取。"""
+ pl = handler.pipelines
+ low = read_low_vram_pref()
+ setattr(pl, "low_vram_mode", bool(low))
+ if low:
+ apply_low_vram_config_tweaks(handler)
+ logger.info(
+ "low_vram_mode: 已开启(尝试关闭 fast 超分;若显存仍高,多为权重常驻 GPU,需降分辨率/时长或 FP8 权重)"
+ )
+
+
+def install_low_vram_pipeline_hooks(pl: Any) -> None:
+ """在 load_gpu_pipeline / load_a2v 返回后尝试 Diffusers 式 CPU offload(无则静默)。"""
+ if getattr(pl, "_ltx_low_vram_hooks_installed", False):
+ return
+ pl._ltx_low_vram_hooks_installed = True
+
+ if hasattr(pl, "load_gpu_pipeline"):
+ _orig_gpu = pl.load_gpu_pipeline
+ pl._ltx_orig_load_gpu_for_low_vram = _orig_gpu
+
+ def _load_gpu_wrapped(self: Any, *a: Any, **kw: Any) -> Any:
+ r = _orig_gpu(*a, **kw)
+ if getattr(self, "low_vram_mode", False):
+ try_sequential_offload_on_pipeline_state(r)
+ return r
+
+ pl.load_gpu_pipeline = types.MethodType(_load_gpu_wrapped, pl)
+
+ if hasattr(pl, "load_a2v_pipeline"):
+ _orig_a2v = pl.load_a2v_pipeline
+ pl._ltx_orig_load_a2v_for_low_vram = _orig_a2v
+
+ def _load_a2v_wrapped(self: Any, *a: Any, **kw: Any) -> Any:
+ r = _orig_a2v(*a, **kw)
+ if getattr(self, "low_vram_mode", False):
+ try_sequential_offload_on_pipeline_state(r)
+ return r
+
+ pl.load_a2v_pipeline = types.MethodType(_load_a2v_wrapped, pl)
+
+
+def try_sequential_offload_on_pipeline_state(state: Any) -> None:
+ """若底层为 Diffusers 风格 API,尝试按层 CPU offload(显著变慢、降峰值)。"""
+ if state is None:
+ return
+ root = getattr(state, "pipeline", state)
+ candidates: list[Any] = [root]
+ inner = getattr(root, "pipeline", None)
+ if inner is not None and inner is not root:
+ candidates.append(inner)
+ for obj in candidates:
+ for method_name in (
+ "enable_sequential_cpu_offload",
+ "enable_model_cpu_offload",
+ ):
+ fn = getattr(obj, method_name, None)
+ if callable(fn):
+ try:
+ fn()
+ logger.info(
+ "low_vram_mode: 已对管线调用 %s()",
+ method_name,
+ )
+ return
+ except Exception as e:
+ logger.debug(
+ "low_vram_mode: %s() 失败(可忽略): %s",
+ method_name,
+ e,
+ )
+
+
+def maybe_release_pipeline_after_task(handler: Any) -> None:
+ """单次生成结束后:低显存模式下强制卸载管线并回收缓存。"""
+ pl = getattr(handler, "pipelines", None) or getattr(handler, "_pipelines", None)
+ if pl is None or not getattr(pl, "low_vram_mode", False):
+ return
+ try:
+ from keep_models_runtime import force_unload_gpu_pipeline
+
+ force_unload_gpu_pipeline(pl)
+ except Exception as e:
+ logger.debug("low_vram_mode: 任务后卸载失败: %s", e)
+ try:
+ pl._pipeline_signature = None
+ except Exception:
+ pass
+ gc.collect()
+ try:
+ import torch
+
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+ except Exception:
+ pass
diff --git a/LTX2.3-1.0.3/patches/runtime_policy.py b/LTX2.3-1.0.3/patches/runtime_policy.py
new file mode 100644
index 0000000000000000000000000000000000000000..dc11d555c21cf3fd7cc29fa8bb9bb76f27b98c07
--- /dev/null
+++ b/LTX2.3-1.0.3/patches/runtime_policy.py
@@ -0,0 +1,21 @@
+"""Runtime policy decisions for forced API mode."""
+
+from __future__ import annotations
+
+
+def decide_force_api_generations(
+ system: str, cuda_available: bool, vram_gb: int | None
+) -> bool:
+ """Return whether API-only generation must be forced for this runtime."""
+ if system == "Darwin":
+ return True
+
+ if system in ("Windows", "Linux"):
+ if not cuda_available:
+ return True
+ if vram_gb is None:
+ return True
+ return vram_gb < 6
+
+ # Fail closed for non-target platforms unless explicitly relaxed.
+ return True
diff --git a/LTX2.3-1.0.3/patches/settings.json b/LTX2.3-1.0.3/patches/settings.json
new file mode 100644
index 0000000000000000000000000000000000000000..f4437362da5f0a81df7d56da2d501a511af16455
--- /dev/null
+++ b/LTX2.3-1.0.3/patches/settings.json
@@ -0,0 +1,23 @@
+{
+ "use_torch_compile": false,
+ "load_on_startup": false,
+ "ltx_api_key": "1231",
+ "user_prefers_ltx_api_video_generations": false,
+ "fal_api_key": "",
+ "use_local_text_encoder": true,
+ "fast_model": {
+ "use_upscaler": true
+ },
+ "pro_model": {
+ "steps": 20,
+ "use_upscaler": true
+ },
+ "prompt_cache_size": 100,
+ "prompt_enhancer_enabled_t2v": true,
+ "prompt_enhancer_enabled_i2v": false,
+ "gemini_api_key": "",
+ "seed_locked": false,
+ "locked_seed": 42,
+ "models_dir": "",
+ "lora_dir": ""
+}
diff --git a/LTX2.3-1.0.3/run.bat b/LTX2.3-1.0.3/run.bat
new file mode 100644
index 0000000000000000000000000000000000000000..f5da5e68342a6fdf9c6182b9917472d150f5eeec
--- /dev/null
+++ b/LTX2.3-1.0.3/run.bat
@@ -0,0 +1,38 @@
+@echo off
+title LTX-2 Cinematic Workstation
+
+echo =========================================================
+echo LTX-2 Cinematic UI Booting...
+echo =========================================================
+echo.
+
+set "LTX_PY=%USERPROFILE%\AppData\Local\LTXDesktop\python\python.exe"
+set "LTX_UI_URL=http://127.0.0.1:4000/"
+
+if exist "%LTX_PY%" (
+ echo [SUCCESS] LTX Bundled Python environment detected!
+ echo [INFO] Browser will open automatically when UI is ready...
+ 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 } }"
+ echo [INFO] Starting workspace natively...
+ echo ---------------------------------------------------------
+ "%LTX_PY%" main.py
+ pause
+ exit /b
+)
+
+python --version >nul 2>&1
+if %errorlevel% equ 0 (
+ echo [WARNING] LTX Bundled Python not found.
+ echo [INFO] Browser will open automatically when UI is ready...
+ 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 } }"
+ echo [INFO] Falling back to global Python environment...
+ echo ---------------------------------------------------------
+ python main.py
+ pause
+ exit /b
+)
+
+echo [ERROR] FATAL: No Python interpreter found on this system.
+echo [INFO] Please run install.bat to download and set up Python!
+echo.
+pause
diff --git "a/LTX2.3/API issues-API\351\227\256\351\242\230\345\212\236\346\263\225.bat" "b/LTX2.3/API issues-API\351\227\256\351\242\230\345\212\236\346\263\225.bat"
new file mode 100644
index 0000000000000000000000000000000000000000..e1f82035a43962e7d3c01153d459e42e7f395be9
--- /dev/null
+++ "b/LTX2.3/API issues-API\351\227\256\351\242\230\345\212\236\346\263\225.bat"
@@ -0,0 +1,43 @@
+@echo off
+chcp 65001 >nul
+title LTX 本地显卡模式修复工具
+
+echo ========================================
+echo LTX 本地显卡模式修复工具
+echo ========================================
+echo.
+
+:: 检查管理员权限
+net session >nul 2>&1
+if %errorlevel% neq 0 (
+ echo [!] 请右键选择"以管理员身份运行"此脚本
+ pause
+ exit /b 1
+)
+
+echo [1/2] 正在修改 VRAM 阈值...
+set "policy_file=C:\Program Files\LTX Desktop\resources\backend\runtime_config\runtime_policy.py"
+
+if exist "%policy_file%" (
+ powershell -Command "(Get-Content '%policy_file%') -replace 'vram_gb < 31', 'vram_gb < 6' | Set-Content '%policy_file%'"
+ echo ^_^ VRAM 阈值已修改为 6GB
+) else (
+ echo [!] 未找到 runtime_policy.py,请确认 LTX Desktop 已安装
+)
+
+echo.
+echo [2/2] 正在清空 API Key...
+set "settings_file=%USERPROFILE%\AppData\Local\LTXDesktop\settings.json"
+
+if exist "%settings_file%" (
+ 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"
+ echo ^_^ API Key 已清空
+) else (
+ echo [!] 未找到 settings.json,首次运行后会自动创建
+)
+
+echo.
+echo ========================================
+echo 修复完成!请重启 LTX Desktop
+echo ========================================
+pause
diff --git "a/LTX2.3/API issues-API\351\227\256\351\242\230\345\212\236\346\263\225.txt" "b/LTX2.3/API issues-API\351\227\256\351\242\230\345\212\236\346\263\225.txt"
new file mode 100644
index 0000000000000000000000000000000000000000..9c02511c95f8e280273c5cbe35a88ac02c31fed6
--- /dev/null
+++ "b/LTX2.3/API issues-API\351\227\256\351\242\230\345\212\236\346\263\225.txt"
@@ -0,0 +1,50 @@
+1. 复制LTX桌面版的快捷方式到LTX_Shortcut
+
+2. 运行run.bat
+----
+1. Copy the LTX desktop shortcut to LTX_Shortcut
+
+2. Run run.bat
+----
+
+
+
+【问题描述 / Problem】
+系统强制使用 FAL API 生成图片,即使本地有 GPU 可用。
+System forces FAL API generation even when local GPU is available.
+
+【原因 / Cause】
+LTX 强制要求 GPU 有 31GB VRAM 才会使用本地显卡,低于此值会强制走 API 模式。
+LTX requires 31GB VRAM to use local GPU. Below this, it forces API mode.
+
+================================================================================
+【修复方法 / Fix Method】
+================================================================================
+
+运行: API issues.bat.bat (以管理员身份)
+Run: API issues.bat.bat (as Administrator)
+
+================================================================================
+================================================================================
+
+【或者手动 / Or Manual】
+
+1. 修改 VRAM 阈值 / Modify VRAM Threshold
+ 文件路径 / File: C:\Program Files\LTX Desktop\resources\backend\runtime_config\runtime_policy.py
+ 第16行 / Line 16:
+ 原 / Original: return vram_gb < 31
+ 改为 / Change: return vram_gb < 6
+
+2. 清空 API Key / Clear API Key
+ 文件路径 / File: C:\Users\<用户名>\AppData\Local\LTXDesktop\settings.json
+ 原 / Original: "fal_api_key": "xxxxx"
+ 改为 / Change: "fal_api_key": ""
+
+【说明 / Note】
+- VRAM 阈值改为 6GB,意味着 6GB 及以上显存都会使用本地显卡
+- VRAM threshold set to 6GB means 6GB+ VRAM will use local GPU
+- 清空 fal_api_key 避免系统误判为已配置 API
+- Clear fal_api_key to avoid system thinking API is configured
+- 修改后重启程序即可生效
+- Restart LTX Desktop after changes
+================================================================================
diff --git a/LTX2.3/LTX_Shortcut/LTX Desktop.lnk b/LTX2.3/LTX_Shortcut/LTX Desktop.lnk
new file mode 100644
index 0000000000000000000000000000000000000000..f53c26ec491c5132c4a6e9beb14bc170c4de52bf
Binary files /dev/null and b/LTX2.3/LTX_Shortcut/LTX Desktop.lnk differ
diff --git a/LTX2.3/UI/i18n.js b/LTX2.3/UI/i18n.js
new file mode 100644
index 0000000000000000000000000000000000000000..3e00d5358f1d8a9720f5a7e5d80f81b1f823317b
--- /dev/null
+++ b/LTX2.3/UI/i18n.js
@@ -0,0 +1,428 @@
+/**
+ * LTX UI i18n — 与根目录「中英文.html」思路类似,但独立脚本、避免坏 DOM/错误路径。
+ * 仅维护文案映射;动态节点由 index.js 在语言切换后刷新。
+ */
+(function (global) {
+ const STORAGE_KEY = 'ltx_ui_lang';
+
+ const STR = {
+ zh: {
+ tabVideo: '视频生成',
+ tabBatch: '智能多帧',
+ tabUpscale: '视频增强',
+ tabImage: '图像生成',
+ promptLabel: '视觉描述词 (Prompt)',
+ promptPlaceholder: '在此输入视觉描述词 (Prompt)...',
+ promptPlaceholderUpscale: '输入画面增强引导词 (可选)...',
+ clearVram: '释放显存',
+ clearingVram: '清理中...',
+ settingsTitle: '系统高级设置',
+ langToggleAriaZh: '切换为 English',
+ langToggleAriaEn: 'Switch to 中文',
+ sysScanning: '正在扫描 GPU...',
+ sysBusy: '运算中...',
+ sysOnline: '在线 / 就绪',
+ sysStarting: '启动中...',
+ sysOffline: '未检测到后端 (Port 3000)',
+ advancedSettings: '高级设置',
+ deviceSelect: '工作设备选择',
+ gpuDetecting: '正在检测 GPU...',
+ outputPath: '输出与上传存储路径',
+ outputPathPh: '例如: D:\\LTX_outputs',
+ savePath: '保存路径',
+ outputPathHint:
+ '系统默认会在 C 盘保留输出文件。请输入新路径后点击保存按钮。',
+ lowVram: '低显存优化',
+ lowVramDesc:
+ '尽量关闭 fast 超分、在加载管线后尝试 CPU 分层卸载(仅当引擎提供 Diffusers 式 API 才可能生效)。每次生成结束会卸载管线。说明:整模型常驻 GPU 时占用仍可能接近满配(例如约 24GB),要明显降占用需更短时长/更低分辨率或 FP8 等小权重。',
+ modelLoraSettings: '模型与LoRA设置',
+ modelFolder: '模型文件夹',
+ modelFolderPh: '例如: F:\\LTX2.3\\models',
+ loraFolder: 'LoRA文件夹',
+ loraFolderPh: '例如: F:\\LTX2.3\\loras',
+ saveScan: '保存并扫描',
+ loraPlacementHintWithDir:
+ '将 LoRA 文件放到默认模型目录: {dir}\\loras',
+ basicEngine: '基础画面 / Basic EngineSpecs',
+ qualityLevel: '清晰度级别',
+ aspectRatio: '画幅比例',
+ ratio169: '16:9 电影宽幅',
+ ratio916: '9:16 移动竖屏',
+ resPreviewPrefix: '最终发送规格',
+ fpsLabel: '帧率 (FPS)',
+ durationLabel: '时长 (秒)',
+ cameraMotion: '镜头运动方式',
+ motionStatic: 'Static (静止机位)',
+ motionDollyIn: 'Dolly In (推近)',
+ motionDollyOut: 'Dolly Out (拉远)',
+ motionDollyLeft: 'Dolly Left (向左)',
+ motionDollyRight: 'Dolly Right (向右)',
+ motionJibUp: 'Jib Up (升臂)',
+ motionJibDown: 'Jib Down (降臂)',
+ motionFocus: 'Focus Shift (焦点)',
+ audioGen: '生成 AI 环境音 (Audio Gen)',
+ selectModel: '选择模型',
+ selectLora: '选择 LoRA',
+ defaultModel: '使用默认模型',
+ noLora: '不使用 LoRA',
+ loraStrength: 'LoRA 强度',
+ genSource: '生成媒介 / Generation Source',
+ startFrame: '起始帧 (首帧)',
+ endFrame: '结束帧 (尾帧)',
+ uploadStart: '上传首帧',
+ uploadEnd: '上传尾帧 (可选)',
+ refAudio: '参考音频 (A2V)',
+ uploadAudio: '点击上传音频',
+ sourceHint:
+ '💡 若仅上传首帧 = 图生视频/音视频;若同时上传首尾帧 = 首尾插帧。',
+ imgPreset: '预设分辨率 (Presets)',
+ imgOptSquare: '1:1 Square (1024x1024)',
+ imgOptLand: '16:9 Landscape (1280x720)',
+ imgOptPort: '9:16 Portrait (720x1280)',
+ imgOptCustom: 'Custom 自定义...',
+ width: '宽度',
+ height: '高度',
+ samplingSteps: '采样步数 (Steps)',
+ upscaleSource: '待超分视频 (Source)',
+ upscaleUpload: '拖入低分辨率视频片段',
+ targetRes: '目标分辨率',
+ upscale1080: '1080P Full HD (2x)',
+ upscale720: '720P HD',
+ smartMultiFrameGroup: '智能多帧',
+ workflowModeLabel: '工作流模式(点击切换)',
+ wfSingle: '单次多关键帧',
+ wfSegments: '分段拼接',
+ uploadImages: '上传图片',
+ uploadMulti1: '点击或拖入多张图片',
+ uploadMulti2: '支持一次选多张,可多次添加',
+ batchStripTitle: '已选图片 · 顺序 = 播放先后',
+ batchStripHint: '在缩略图上按住拖动排序;松手落入虚线框位置',
+ batchFfmpegHint:
+ '💡 分段模式:2 张 = 1 段;3 张 = 2 段再拼接。单次模式:几张图就几个 latent 锚点,一条视频出片。
多段需 ffmpeg:装好后加 PATH,或设环境变量 LTX_FFMPEG_PATH,或在 %LOCALAPPDATA%\\LTXDesktop\\ffmpeg_path.txt 第一行写 ffmpeg.exe 完整路径。',
+ globalPromptLabel: '本页全局补充词(可选)',
+ globalPromptPh: '与顶部主 Prompt 叠加;单次模式与分段模式均可用',
+ bgmLabel: '成片配乐(可选,统一音轨)',
+ bgmUploadHint: '上传一条完整 BGM(生成完成后会替换整段成片的音轨)',
+ mainRender: '开始渲染',
+ waitingTask: '等待分配渲染任务...',
+ libHistory: '历史资产 / ASSETS',
+ libLog: '系统日志 / LOGS',
+ refresh: '刷新',
+ logReady: '> LTX-2 Studio Ready. Expecting commands...',
+ resizeHandleTitle: '拖动调整面板高度',
+ batchNeedTwo: '💡 请上传至少2张图片',
+ batchSegTitle: '视频片段设置(分段拼接)',
+ batchSegClip: '片段',
+ batchSegDuration: '时长',
+ batchSegSec: '秒',
+ batchSegPrompt: '片段提示词',
+ batchSegPromptPh: '此片段的提示词,如:跳舞、吃饭...',
+ batchKfPanelTitle: '单次多关键帧 · 时间轴',
+ batchTotalDur: '总时长',
+ batchTotalSec: '秒',
+ batchPanelHint:
+ '用「间隔」连接相邻关键帧:第 1 张固定在 0 s,最后一张在各间隔之和的终点。顶部总时长与每张的锚点时刻会随间隔即时刷新。因后端按整数秒建序列,实际请求里的整段时长为合计秒数向上取整(至少 2),略长于小数合计时属正常。镜头与 FPS 仍用左侧「视频生成」。',
+ batchKfTitle: '关键帧',
+ batchStrength: '引导强度',
+ batchGapTitle: '间隔',
+ batchSec: '秒',
+ batchAnchorStart: '片头',
+ batchAnchorEnd: '片尾',
+ batchThumbDrag: '按住拖动排序',
+ batchThumbRemove: '删除',
+ batchAddMore: '+ 继续添加',
+ batchGapInputTitle: '上一关键帧到下一关键帧的时长(秒);总时长 = 各间隔之和',
+ batchStrengthTitle: '与 Comfy guide strength 类似,中间帧可调低(如 0.2)减轻闪烁',
+ batchTotalPillTitle: '等于下方各「间隔」之和,无需单独填写',
+ defaultPath: '默认路径',
+ phase_loading_model: '加载权重',
+ phase_encoding_text: 'T5 编码',
+ phase_validating_request: '校验请求',
+ phase_uploading_audio: '上传音频',
+ phase_uploading_image: '上传图像',
+ phase_inference: 'AI 推理',
+ phase_downloading_output: '下载结果',
+ phase_complete: '完成',
+ gpuBusyPrefix: 'GPU 运算中',
+ progressStepUnit: '步',
+ loaderGpuAlloc: 'GPU 正在分配资源...',
+ warnGenerating: '⚠️ 当前正在生成中,请等待完成',
+ warnBatchPrompt: '⚠️ 智能多帧请至少填写:顶部主提示词、本页全局补充词或某一「片段提示词」',
+ warnNeedPrompt: '⚠️ 请输入提示词后再开始渲染',
+ warnVideoLong: '⚠️ 时长设定为 {n}s 极长,可能导致显存溢出或耗时较久。',
+ errUpscaleNoVideo: '请先上传待超分的视频',
+ errBatchMinImages: '请上传至少2张图片',
+ errSingleKfPrompt: '单次多关键帧请至少填写顶部主提示词或本页全局补充词',
+ loraNoneLabel: '无',
+ modelDefaultLabel: '默认',
+ },
+ en: {
+ tabVideo: 'Video',
+ tabBatch: 'Multi-frame',
+ tabUpscale: 'Upscale',
+ tabImage: 'Image',
+ promptLabel: 'Prompt',
+ promptPlaceholder: 'Describe the scene...',
+ promptPlaceholderUpscale: 'Optional guidance for enhancement...',
+ clearVram: 'Clear VRAM',
+ clearingVram: 'Clearing...',
+ settingsTitle: 'Advanced settings',
+ langToggleAriaZh: 'Switch to English',
+ langToggleAriaEn: 'Switch to Chinese',
+ sysScanning: 'Scanning GPU...',
+ sysBusy: 'Busy...',
+ sysOnline: 'Online / Ready',
+ sysStarting: 'Starting...',
+ sysOffline: 'Backend offline (port 3000)',
+ advancedSettings: 'Advanced',
+ deviceSelect: 'GPU device',
+ gpuDetecting: 'Detecting GPU...',
+ outputPath: 'Output & upload folder',
+ outputPathPh: 'e.g. D:\\LTX_outputs',
+ savePath: 'Save path',
+ outputPathHint:
+ 'Outputs default to C: drive. Enter a folder and click Save.',
+ lowVram: 'Low-VRAM mode',
+ lowVramDesc:
+ 'Tries to reduce VRAM (engine-dependent). Shorter duration / lower resolution helps more.',
+ modelLoraSettings: 'Model & LoRA folders',
+ modelFolder: 'Models folder',
+ modelFolderPh: 'e.g. F:\\LTX2.3\\models',
+ loraFolder: 'LoRAs folder',
+ loraFolderPh: 'e.g. F:\\LTX2.3\\loras',
+ saveScan: 'Save & scan',
+ loraHint: 'Put .safetensors / .ckpt LoRAs here, then refresh lists.',
+ basicEngine: 'Basic / Engine',
+ qualityLevel: 'Quality',
+ aspectRatio: 'Aspect ratio',
+ ratio169: '16:9 widescreen',
+ ratio916: '9:16 portrait',
+ resPreviewPrefix: 'Output',
+ fpsLabel: 'FPS',
+ durationLabel: 'Duration (s)',
+ cameraMotion: 'Camera motion',
+ motionStatic: 'Static',
+ motionDollyIn: 'Dolly in',
+ motionDollyOut: 'Dolly out',
+ motionDollyLeft: 'Dolly left',
+ motionDollyRight: 'Dolly right',
+ motionJibUp: 'Jib up',
+ motionJibDown: 'Jib down',
+ motionFocus: 'Focus shift',
+ audioGen: 'AI ambient audio',
+ selectModel: 'Model',
+ selectLora: 'LoRA',
+ defaultModel: 'Default model',
+ noLora: 'No LoRA',
+ loraStrength: 'LoRA strength',
+ genSource: 'Source media',
+ startFrame: 'Start frame',
+ endFrame: 'End frame (optional)',
+ uploadStart: 'Upload start',
+ uploadEnd: 'Upload end (opt.)',
+ refAudio: 'Reference audio (A2V)',
+ uploadAudio: 'Upload audio',
+ sourceHint:
+ '💡 Start only = I2V / A2V; start + end = interpolation.',
+ imgPreset: 'Resolution presets',
+ imgOptSquare: '1:1 (1024×1024)',
+ imgOptLand: '16:9 (1280×720)',
+ imgOptPort: '9:16 (720×1280)',
+ imgOptCustom: 'Custom...',
+ width: 'Width',
+ height: 'Height',
+ samplingSteps: 'Steps',
+ upscaleSource: 'Source video',
+ upscaleUpload: 'Drop low-res video',
+ targetRes: 'Target resolution',
+ upscale1080: '1080p Full HD (2×)',
+ upscale720: '720p HD',
+ smartMultiFrameGroup: 'Smart multi-frame',
+ workflowModeLabel: 'Workflow',
+ wfSingle: 'Single pass',
+ wfSegments: 'Segments',
+ uploadImages: 'Upload images',
+ uploadMulti1: 'Click or drop multiple images',
+ uploadMulti2: 'Multi-select OK; add more anytime.',
+ batchStripTitle: 'Order = playback',
+ batchStripHint: 'Drag thumbnails to reorder.',
+ batchFfmpegHint:
+ '💡 Segments: 2 images → 1 clip; 3 → 2 clips stitched. Single: N images → N latent anchors, one video.
Stitching needs ffmpeg on PATH, or LTX_FFMPEG_PATH, or %LOCALAPPDATA%\\LTXDesktop\\ffmpeg_path.txt with full path to ffmpeg.exe.',
+ globalPromptLabel: 'Extra prompt (optional)',
+ globalPromptPh: 'Appended to main prompt for both modes.',
+ bgmLabel: 'Full-length BGM (optional)',
+ bgmUploadHint: 'Replaces final mix audio after generation.',
+ mainRender: 'Render',
+ waitingTask: 'Waiting for task...',
+ libHistory: 'Assets',
+ libLog: 'Logs',
+ refresh: 'Refresh',
+ logReady: '> LTX-2 Studio ready.',
+ resizeHandleTitle: 'Drag to resize panel',
+ batchNeedTwo: '💡 Upload at least 2 images',
+ batchSegTitle: 'Segment settings',
+ batchSegClip: 'Clip',
+ batchSegDuration: 'Duration',
+ batchSegSec: 's',
+ batchSegPrompt: 'Prompt',
+ batchSegPromptPh: 'e.g. dancing, walking...',
+ batchKfPanelTitle: 'Single pass · timeline',
+ batchTotalDur: 'Total',
+ batchTotalSec: 's',
+ batchPanelHint:
+ '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.',
+ batchKfTitle: 'Keyframe',
+ batchStrength: 'Strength',
+ batchGapTitle: 'Gap',
+ batchSec: 's',
+ batchAnchorStart: 'start',
+ batchAnchorEnd: 'end',
+ batchThumbDrag: 'Drag to reorder',
+ batchThumbRemove: 'Remove',
+ batchAddMore: '+ Add more',
+ batchGapInputTitle: 'Seconds between keyframes; total = sum of gaps',
+ batchStrengthTitle: 'Guide strength (lower on middle keys may reduce flicker)',
+ batchTotalPillTitle: 'Equals the sum of gaps below',
+ defaultPath: 'default',
+ phase_loading_model: 'Loading weights',
+ phase_encoding_text: 'T5 encode',
+ phase_validating_request: 'Validating',
+ phase_uploading_audio: 'Uploading audio',
+ phase_uploading_image: 'Uploading image',
+ phase_inference: 'Inference',
+ phase_downloading_output: 'Downloading',
+ phase_complete: 'Done',
+ gpuBusyPrefix: 'GPU',
+ progressStepUnit: 'steps',
+ loaderGpuAlloc: 'Allocating GPU...',
+ warnGenerating: '⚠️ Already generating, please wait.',
+ warnBatchPrompt: '⚠️ Enter main prompt, page extra prompt, or a segment prompt.',
+ warnNeedPrompt: '⚠️ Enter a prompt first.',
+ warnVideoLong: '⚠️ Duration {n}s is very long; may OOM or take a long time.',
+ errUpscaleNoVideo: 'Upload a video to upscale first.',
+ errBatchMinImages: 'Upload at least 2 images.',
+ errSingleKfNeedPrompt: 'Enter main or page extra prompt for single-pass keyframes.',
+ loraNoneLabel: 'none',
+ modelDefaultLabel: 'default',
+ loraPlacementHintWithDir:
+ 'Place LoRAs into the default models directory: {dir}\\loras',
+ },
+ };
+
+ function getLang() {
+ return localStorage.getItem(STORAGE_KEY) === 'en' ? 'en' : 'zh';
+ }
+
+ function setLang(lang) {
+ const L = lang === 'en' ? 'en' : 'zh';
+ localStorage.setItem(STORAGE_KEY, L);
+ document.documentElement.lang = L === 'en' ? 'en' : 'zh-CN';
+ try {
+ applyI18n();
+ } catch (err) {
+ console.error('[i18n] applyI18n failed:', err);
+ }
+ updateLangButton();
+ if (typeof global.onUiLanguageChanged === 'function') {
+ try {
+ global.onUiLanguageChanged();
+ } catch (e) {
+ console.warn('onUiLanguageChanged', e);
+ }
+ }
+ }
+
+ function t(key) {
+ const L = getLang();
+ const table = STR[L] || STR.zh;
+ if (Object.prototype.hasOwnProperty.call(table, key)) return table[key];
+ if (Object.prototype.hasOwnProperty.call(STR.zh, key)) return STR.zh[key];
+ return key;
+ }
+
+ function applyI18n(root) {
+ root = root || document;
+ root.querySelectorAll('[data-i18n]').forEach(function (el) {
+ var key = el.getAttribute('data-i18n');
+ if (!key) return;
+ if (el.tagName === 'OPTION') {
+ el.textContent = t(key);
+ } else {
+ el.textContent = t(key);
+ }
+ });
+ root.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
+ var key = el.getAttribute('data-i18n-placeholder');
+ if (key) el.placeholder = t(key);
+ });
+ root.querySelectorAll('[data-i18n-title]').forEach(function (el) {
+ var key = el.getAttribute('data-i18n-title');
+ if (key) el.title = t(key);
+ });
+ root.querySelectorAll('[data-i18n-html]').forEach(function (el) {
+ var key = el.getAttribute('data-i18n-html');
+ if (key) el.innerHTML = t(key);
+ });
+ root.querySelectorAll('[data-i18n-value]').forEach(function (el) {
+ var key = el.getAttribute('data-i18n-value');
+ if (key && (el.tagName === 'INPUT' || el.tagName === 'BUTTON')) {
+ el.value = t(key);
+ }
+ });
+ }
+
+ function updateLangButton() {
+ var btn = document.getElementById('lang-toggle-btn');
+ if (!btn) return;
+ btn.textContent = getLang() === 'zh' ? 'EN' : '中';
+ btn.setAttribute(
+ 'aria-label',
+ getLang() === 'zh' ? t('langToggleAriaZh') : t('langToggleAriaEn')
+ );
+ btn.classList.toggle('active', getLang() === 'en');
+ }
+
+ function toggleUiLanguage() {
+ try {
+ setLang(getLang() === 'zh' ? 'en' : 'zh');
+ } catch (err) {
+ console.error('[i18n] toggleUiLanguage failed:', err);
+ }
+ }
+
+ /** 避免 CSP 拦截内联 onclick;确保按钮一定能触发 */
+ function bindLangToggleButton() {
+ var btn = document.getElementById('lang-toggle-btn');
+ if (!btn || btn.dataset.i18nBound === '1') return;
+ btn.dataset.i18nBound = '1';
+ btn.removeAttribute('onclick');
+ btn.addEventListener('click', function (ev) {
+ ev.preventDefault();
+ toggleUiLanguage();
+ });
+ }
+
+ function boot() {
+ document.documentElement.lang = getLang() === 'en' ? 'en' : 'zh-CN';
+ try {
+ applyI18n();
+ } catch (err) {
+ console.error('[i18n] applyI18n failed:', err);
+ }
+ updateLangButton();
+ bindLangToggleButton();
+ }
+
+ global.getUiLang = getLang;
+ global.setUiLang = setLang;
+ global.t = t;
+ global.applyI18n = applyI18n;
+ global.toggleUiLanguage = toggleUiLanguage;
+ global.updateLangToggleButton = updateLangButton;
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', boot);
+ } else {
+ boot();
+ }
+})(typeof window !== 'undefined' ? window : global);
diff --git a/LTX2.3/UI/index.css b/LTX2.3/UI/index.css
new file mode 100644
index 0000000000000000000000000000000000000000..3c60d09c1d13f9bb3d7e2a2ae99092beff7dd0c2
--- /dev/null
+++ b/LTX2.3/UI/index.css
@@ -0,0 +1,775 @@
+:root {
+ --accent: #2563EB; /* Refined blue – not too bright, not purple */
+ --accent-hover:#3B82F6;
+ --accent-dim: rgba(37,99,235,0.14);
+ --accent-ring: rgba(37,99,235,0.35);
+ --bg: #111113;
+ --panel: #18181B;
+ --panel-2: #1F1F23;
+ --item: rgba(255,255,255,0.035);
+ --border: rgba(255,255,255,0.08);
+ --border-2: rgba(255,255,255,0.05);
+ --text-dim: #71717A;
+ --text-sub: #A1A1AA;
+ --text: #FAFAFA;
+ }
+
+ * { box-sizing: border-box; -webkit-font-smoothing: antialiased; min-width: 0; }
+ body {
+ background: var(--bg); margin: 0; color: var(--text);
+ font-family: -apple-system, "SF Pro Display", "Segoe UI", sans-serif;
+ display: flex; height: 100vh; overflow: hidden;
+ font-size: 13px; line-height: 1.5;
+ }
+
+ .sidebar {
+ width: 460px; min-width: 460px;
+ background: var(--panel);
+ border-right: 1px solid var(--border);
+ display: flex; flex-direction: column; z-index: 20;
+ overflow-y: auto; overflow-x: hidden;
+ }
+
+ /* Scrollbar */
+ ::-webkit-scrollbar { width: 5px; height: 5px; }
+ ::-webkit-scrollbar-track { background: transparent; }
+ ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 10px; }
+ ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.18); }
+
+ .sidebar-header { padding: 24px 24px 4px; }
+
+ .lang-toggle {
+ background: transparent;
+ border: 1px solid var(--border);
+ color: var(--text-dim);
+ padding: 4px 10px;
+ border-radius: 6px;
+ font-size: 11px;
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
+ font-weight: 700;
+ min-width: 44px;
+ flex-shrink: 0;
+ }
+ .lang-toggle:hover {
+ background: var(--item);
+ color: var(--text);
+ border-color: var(--accent);
+ }
+ .lang-toggle.active {
+ background: var(--accent);
+ color: #fff;
+ border-color: var(--accent);
+ }
+ .sidebar-section { padding: 8px 24px 18px; border-bottom: 1px solid var(--border); }
+
+ .setting-group {
+ background: rgba(255,255,255,0.025);
+ border: 1px solid var(--border-2);
+ border-radius: 10px;
+ padding: 14px;
+ margin-bottom: 12px;
+ }
+ .group-title {
+ font-size: 10px; color: var(--text-dim); font-weight: 700;
+ text-transform: uppercase; letter-spacing: 0.7px;
+ margin-bottom: 12px; padding-bottom: 5px;
+ border-bottom: 1px solid var(--border-2);
+ }
+
+ /* Mode Tabs */
+ .tabs {
+ display: flex; gap: 4px; margin-bottom: 14px;
+ background: rgba(255,255,255,0.04);
+ padding: 4px; border-radius: 10px;
+ border: 1px solid var(--border-2);
+ }
+ .tab {
+ flex: 1; padding: 9px 0; text-align: center; border-radius: 7px;
+ cursor: pointer; font-size: 12px; color: var(--text-dim);
+ transition: all 0.2s; font-weight: 600;
+ display: flex; align-items: center; justify-content: center;
+ }
+ .tab.active { background: var(--accent); color: #fff; box-shadow: 0 1px 6px rgba(10,132,255,0.45); }
+ .tab:hover:not(.active) { background: rgba(255,255,255,0.06); color: var(--text); }
+
+ .label-group { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
+ label { display: block; font-size: 11px; color: var(--text-dim); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
+ .val-badge { font-size: 11px; color: var(--accent); font-family: "SF Mono", ui-monospace, monospace; font-weight: 600; }
+
+ input[type="text"], input[type="number"], select, textarea {
+ width: 100%; background: var(--panel-2);
+ border: 1px solid var(--border);
+ border-radius: 7px; color: var(--text);
+ padding: 8px 11px; font-size: 12.5px; outline: none; margin-bottom: 9px;
+ /* Only transition border/shadow – NOT background-image to prevent arrow flicker */
+ transition: border-color 0.15s, box-shadow 0.15s;
+ }
+ input:focus, select:focus, textarea:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-ring);
+ }
+ select {
+ -webkit-appearance: none; -moz-appearance: none; appearance: none;
+ /* Stable grey arrow – no background shorthand so it won't animate */
+ background-color: var(--panel-2);
+ 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");
+ background-repeat: no-repeat;
+ background-position: right 10px center;
+ background-size: 12px;
+ padding-right: 28px;
+ cursor: pointer;
+ /* Explicitly do NOT transition background properties */
+ transition: border-color 0.15s, box-shadow 0.15s;
+ }
+ select:focus { background-color: var(--panel-2); }
+ select option { background: #27272A; color: var(--text); }
+ textarea { resize: vertical; min-height: 78px; font-family: inherit; }
+
+ .slider-container { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
+ input[type="range"] { flex: 1; accent-color: var(--accent); height: 4px; cursor: pointer; border-radius: 2px; }
+
+ .upload-zone {
+ border: 1px dashed var(--border); border-radius: 10px;
+ padding: 18px 10px; text-align: center; cursor: pointer;
+ background: rgba(255,255,255,0.03); margin-bottom: 10px; position: relative;
+ transition: all 0.2s;
+ }
+ .upload-zone:hover, .upload-zone.dragover { background: var(--accent-dim); border-color: var(--accent); }
+ .upload-zone.has-images {
+ padding: 12px; background: rgba(255,255,255,0.025);
+ }
+ .upload-zone.has-images .upload-placeholder-mini {
+ display: flex; align-items: center; gap: 8px; justify-content: center;
+ color: var(--text-dim); font-size: 11px;
+ }
+ .upload-zone.has-images .upload-placeholder-mini span {
+ background: var(--item); padding: 6px 12px; border-radius: 6px;
+ }
+ #batch-images-placeholder { display: block; }
+ .upload-zone.has-images #batch-images-placeholder { display: none; }
+
+ /* 批量模式:上传区下方的横向缩略图条 */
+ .batch-thumb-strip-wrap {
+ margin-top: 10px;
+ margin-bottom: 4px;
+ }
+ .batch-thumb-strip-head {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ margin-bottom: 8px;
+ }
+ .batch-thumb-strip-title {
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--text-sub);
+ }
+ .batch-thumb-strip-hint {
+ font-size: 10px;
+ color: var(--text-dim);
+ }
+ .batch-images-container {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ gap: 10px;
+ overflow-x: auto;
+ overflow-y: visible;
+ padding: 6px 4px 14px;
+ margin: 0 -4px;
+ scrollbar-width: thin;
+ scrollbar-color: var(--border) transparent;
+ align-items: center;
+ }
+ .batch-images-container::-webkit-scrollbar { height: 6px; }
+ .batch-images-container::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 3px;
+ }
+ .batch-image-wrapper {
+ flex: 0 0 72px;
+ width: 72px;
+ height: 72px;
+ position: relative;
+ border-radius: 10px;
+ overflow: hidden;
+ background: var(--item);
+ border: 1px solid var(--border);
+ cursor: grab;
+ touch-action: none;
+ user-select: none;
+ -webkit-user-select: none;
+ transition:
+ flex-basis 0.38s cubic-bezier(0.22, 1, 0.36, 1),
+ width 0.38s cubic-bezier(0.22, 1, 0.36, 1),
+ min-width 0.38s cubic-bezier(0.22, 1, 0.36, 1),
+ margin 0.38s cubic-bezier(0.22, 1, 0.36, 1),
+ opacity 0.25s ease,
+ border-color 0.2s ease,
+ box-shadow 0.2s ease,
+ transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
+ }
+ .batch-image-wrapper:active { cursor: grabbing; }
+ .batch-image-wrapper.batch-thumb--source {
+ flex: 0 0 0;
+ width: 0;
+ min-width: 0;
+ height: 72px;
+ margin: 0;
+ padding: 0;
+ border: none;
+ overflow: hidden;
+ opacity: 0;
+ background: transparent;
+ box-shadow: none;
+ pointer-events: none;
+ /* 收起必须瞬时:若与占位框同时用 0.38s 过渡,右侧缩略图会与「突然出现」的槽位不同步而闪一下 */
+ transition: none !important;
+ }
+ /* 按下瞬间:冻结其它卡片与槽位动画,避免「槽位插入 + 邻居过渡」两帧打架 */
+ .batch-images-container.is-batch-settling .batch-image-wrapper:not(.batch-thumb--source) {
+ transition: none !important;
+ }
+ .batch-images-container.is-batch-settling .batch-thumb-drop-slot {
+ animation: none;
+ opacity: 1;
+ }
+ /* 拖动时跟手的浮动缩略图(避免原槽位透明后光标下像「黑块」) */
+ .batch-thumb-floating-ghost {
+ position: fixed;
+ left: 0;
+ top: 0;
+ z-index: 99999;
+ width: 76px;
+ height: 76px;
+ border-radius: 12px;
+ overflow: hidden;
+ pointer-events: none;
+ will-change: transform;
+ box-shadow:
+ 0 20px 50px rgba(0, 0, 0, 0.45),
+ 0 10px 28px rgba(0, 0, 0, 0.28),
+ 0 0 0 1px rgba(255, 255, 255, 0.18);
+ transform: translate3d(0, 0, 0) scale(1.06) rotate(-1deg);
+ }
+ .batch-thumb-floating-ghost img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+ pointer-events: none;
+ }
+ .batch-thumb-drop-slot {
+ flex: 0 0 72px;
+ width: 72px;
+ height: 72px;
+ box-sizing: border-box;
+ border-radius: 12px;
+ border: 2px dashed rgba(255, 255, 255, 0.22);
+ background: linear-gradient(145deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.03));
+ pointer-events: none;
+ transition: border-color 0.35s ease, box-shadow 0.35s ease, opacity 0.35s ease;
+ animation: batch-slot-breathe 2.4s ease-in-out infinite;
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
+ }
+ @keyframes batch-slot-breathe {
+ 0%, 100% { opacity: 0.88; }
+ 50% { opacity: 1; }
+ }
+ .batch-image-wrapper .batch-thumb-img-wrap {
+ width: 100%;
+ height: 100%;
+ border-radius: 9px;
+ overflow: hidden;
+ /* 必须让事件落到外层 .batch-image-wrapper,否则 HTML5 drag 无法从 draggable 父级启动 */
+ pointer-events: none;
+ }
+ .batch-image-wrapper .batch-thumb-img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+ pointer-events: none;
+ user-select: none;
+ -webkit-user-drag: none;
+ }
+ .batch-thumb-remove {
+ position: absolute;
+ top: 3px;
+ right: 3px;
+ z-index: 5;
+ box-sizing: border-box;
+ min-width: 22px;
+ height: 22px;
+ padding: 0 5px;
+ margin: 0;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 6px;
+ background: rgba(0, 0, 0, 0.5);
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 1;
+ color: rgba(255, 255, 255, 0.9);
+ opacity: 0.72;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.12s, opacity 0.12s, border-color 0.12s;
+ pointer-events: auto;
+ }
+ .batch-image-wrapper:hover .batch-thumb-remove {
+ opacity: 1;
+ background: rgba(0, 0, 0, 0.68);
+ border-color: rgba(255, 255, 255, 0.2);
+ }
+ .batch-thumb-remove:hover {
+ background: rgba(80, 20, 20, 0.75) !important;
+ border-color: rgba(255, 180, 180, 0.35);
+ color: #fff;
+ }
+ .batch-thumb-remove:focus-visible {
+ opacity: 1;
+ outline: 2px solid var(--accent-dim, rgba(120, 160, 255, 0.6));
+ outline-offset: 1px;
+ }
+ .upload-icon { font-size: 18px; margin-bottom: 6px; opacity: 0.45; }
+ .upload-text { font-size: 11px; color: var(--text); }
+ .upload-hint { font-size: 10px; color: var(--text-dim); margin-top: 3px; }
+ .preview-thumb { width: 100%; height: auto; max-height: 100px; object-fit: contain; border-radius: 8px; display: none; margin-top: 10px; }
+ .clear-img-overlay {
+ position: absolute; top: 8px; right: 8px; background: rgba(255,59,48,0.85); color: white;
+ width: 20px; height: 20px; border-radius: 10px; display: none; align-items: center; justify-content: center;
+ font-size: 11px; cursor: pointer; z-index: 5;
+ }
+
+ .btn-outline {
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ color: var(--text-sub); padding: 5px 12px; border-radius: 7px;
+ font-size: 11.5px; font-weight: 600; cursor: pointer;
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
+ display: inline-flex; align-items: center; justify-content: center; gap: 5px;
+ white-space: nowrap;
+ }
+ .btn-outline:hover:not(:disabled) { background: rgba(255,255,255,0.08); color: var(--text); border-color: rgba(255,255,255,0.18); }
+ .btn-outline:active { opacity: 0.7; }
+ .btn-outline:disabled { opacity: 0.3; cursor: not-allowed; }
+
+ .btn-icon {
+ padding: 5px; background: transparent; border: none; color: var(--text-dim);
+ border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center;
+ transition: color 0.15s, background 0.15s;
+ }
+ .btn-icon:hover { color: var(--text-sub); background: rgba(255,255,255,0.07); }
+
+ .btn-primary {
+ width: 100%; padding: 13px;
+ background: var(--accent); border: none;
+ border-radius: 9px; color: #fff; font-weight: 700; font-size: 13.5px;
+ letter-spacing: 0.2px; cursor: pointer; margin-top: 14px;
+ transition: background 0.15s;
+ }
+ .btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
+ .btn-primary:active { opacity: 0.82; }
+ .btn-primary:disabled { background: rgba(255,255,255,0.08); color: var(--text-dim); cursor: not-allowed; }
+
+ .btn-danger {
+ width: 100%; padding: 12px; background: #DC2626; border: none;
+ border-radius: 9px; color: #fff; font-weight: 700; font-size: 13.5px;
+ cursor: pointer; margin-top: 8px; display: none; transition: background 0.15s;
+ }
+ .btn-danger:hover { background: #EF4444; }
+
+ /* Workspace */
+ .workspace { flex: 1; display: flex; flex-direction: column; background: #0A0A0A; position: relative; overflow: hidden; }
+ .viewer { flex: 2; display: flex; align-items: center; justify-content: center; padding: 16px; background: #0A0A0A; position: relative; min-height: 40vh; }
+ .monitor {
+ width: 100%; height: 100%; max-width: 1650px; border-radius: 10px; border: 1px solid var(--border);
+ overflow: hidden; position: relative; background: #070707;
+ display: flex; align-items: center; justify-content: center;
+ background-image: radial-gradient(rgba(255,255,255,0.02) 1px, transparent 1px);
+ background-size: 18px 18px;
+ }
+ .monitor img, .monitor video {
+ width: auto; height: auto; max-width: 100%; max-height: 100%;
+ object-fit: contain; display: none; z-index: 2; border-radius: 3px;
+ }
+
+ .progress-container { position: absolute; bottom: 0; left: 0; width: 100%; height: 2px; background: var(--border-2); z-index: 10; }
+ #progress-fill { width: 0%; height: 100%; background: var(--accent); transition: width 0.5s; }
+ #loading-txt { font-size: 12px; color: var(--text-sub); font-weight: 600; z-index: 5; position: absolute; display: none; }
+
+
+
+ .spinner {
+ width: 12px; height: 12px;
+ border: 2px solid rgba(255,255,255,0.2);
+ border-top-color: currentColor;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ }
+ @keyframes spin { to { transform: rotate(360deg); } }
+
+ .loading-card {
+ display: flex; align-items: center; justify-content: center;
+ flex-direction: column; gap: 6px; color: var(--text-dim); font-size: 10px;
+ background: rgba(37,99,235,0.07) !important;
+ border-color: rgba(37,99,235,0.3) !important;
+ }
+ .loading-card .spinner { width: 28px; height: 28px; border-width: 3px; color: var(--accent); }
+ .loading-card:hover { background: rgba(37,99,235,0.14) !important; border-color: var(--accent) !important; }
+
+ .library { flex: 1.5; border-top: 1px solid var(--border); padding: 14px 20px; display: flex; flex-direction: column; background: #0F0F11; overflow-y: hidden; }
+ #log-container { flex: 1; overflow-y: auto; padding-right: 4px; }
+ #log { font-family: ui-monospace, "SF Mono", monospace; font-size: 10.5px; color: var(--text-dim); line-height: 1.7; }
+
+ /* History wrapper: scrollable area for thumbnails only */
+ #history-wrapper {
+ flex: 1;
+ overflow-y: auto;
+ min-height: 110px; /* always show at least one row */
+ padding-right: 4px;
+ }
+ #history-container {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ justify-content: start;
+ gap: 10px; align-content: flex-start;
+ padding-bottom: 4px;
+ }
+ /* Pagination row: hidden, using infinite scroll instead */
+ #pagination-bar {
+ display: none;
+ }
+
+ .history-card {
+ width: 100%; max-width: 200px; aspect-ratio: 16 / 9;
+ background: #1A1A1E; border-radius: 7px;
+ overflow: hidden; border: 1px solid var(--border);
+ cursor: pointer; position: relative; transition: border-color 0.15s, transform 0.15s;
+ }
+ .history-card:hover { border-color: var(--accent); transform: translateY(-1px); }
+ .history-card img, .history-card video {
+ width: 100%; height: 100%; object-fit: cover;
+ background: #1A1A1E;
+ }
+ /* 解码/加载完成前避免视频黑块猛闪,与卡片底色一致;就绪后淡入 */
+ .history-card .history-thumb-media {
+ opacity: 0;
+ transition: opacity 0.28s ease;
+ }
+ .history-card .history-thumb-media.history-thumb-ready {
+ opacity: 1;
+ }
+ .history-type-badge {
+ position: absolute; top: 5px; left: 5px; font-size: 8px; padding: 1px 5px; border-radius: 3px;
+ background: rgba(0,0,0,0.8); color: var(--text-sub); border: 1px solid rgba(255,255,255,0.06);
+ z-index: 2; font-weight: 700; letter-spacing: 0.4px;
+ }
+ .history-delete-btn {
+ position: absolute; top: 5px; right: 5px; width: 20px; height: 20px;
+ border-radius: 50%; border: none; background: rgba(255,50,50,0.8); color: #fff;
+ font-size: 10px; cursor: pointer; z-index: 3; display: flex; align-items: center; justify-content: center;
+ opacity: 0; transition: opacity 0.2s;
+ }
+ .history-card:hover .history-delete-btn { opacity: 1; }
+ .history-delete-btn:hover { background: rgba(255,0,0,0.9); }
+
+ .vram-bar { width: 160px; height: 5px; background: rgba(255,255,255,0.08); border-radius: 999px; overflow: hidden; display: inline-block; vertical-align: middle; }
+ .vram-used { height: 100%; background: var(--accent); width: 0%; transition: width 0.5s; }
+
+ /* 智能多帧:工作流模式卡片式单选 */
+ .smart-param-mode-label {
+ font-size: 10px;
+ color: var(--text-dim);
+ font-weight: 700;
+ margin-bottom: 8px;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ }
+ .smart-param-modes {
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+ gap: 0;
+ padding: 3px;
+ margin-bottom: 12px;
+ background: var(--panel-2);
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ }
+ .smart-param-mode-opt {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ min-width: 0;
+ gap: 0;
+ margin: 0;
+ padding: 6px 8px;
+ border-radius: 6px;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+ position: relative;
+ }
+ .smart-param-mode-opt:hover:not(:has(input:checked)) {
+ background: rgba(255, 255, 255, 0.05);
+ }
+ .smart-param-mode-opt input[type="radio"] {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+ margin: 0;
+ }
+ .smart-param-mode-opt:has(input:checked) {
+ background: var(--accent);
+ box-shadow: none;
+ }
+ .smart-param-mode-opt:has(input:checked) .smart-param-mode-title {
+ color: #fff;
+ }
+ .smart-param-mode-title {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-sub);
+ text-align: center;
+ line-height: 1.25;
+ flex: none;
+ min-width: 0;
+ }
+ /* 单次多关键帧:时间轴面板 */
+ .batch-kf-panel {
+ background: var(--item);
+ border-radius: 10px;
+ padding: 12px 14px;
+ margin-bottom: 10px;
+ border: 1px solid var(--border);
+ }
+ .batch-kf-panel-hd {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ margin-bottom: 8px;
+ }
+ .batch-kf-panel-title {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text);
+ }
+ .batch-kf-total-pill {
+ font-size: 11px;
+ color: var(--text-sub);
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 6px 12px;
+ white-space: nowrap;
+ }
+ .batch-kf-total-pill strong {
+ color: var(--accent);
+ font-weight: 800;
+ font-variant-numeric: tabular-nums;
+ margin: 0 2px;
+ }
+ .batch-kf-total-unit {
+ font-size: 10px;
+ color: var(--text-dim);
+ }
+ .batch-kf-panel-hint {
+ font-size: 10px;
+ color: var(--text-dim);
+ line-height: 1.5;
+ margin: 0 0 12px;
+ }
+ .batch-kf-timeline-col {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ }
+ .batch-kf-kcard {
+ border-radius: 10px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.03);
+ padding: 10px 12px;
+ }
+ .batch-kf-kcard-head {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 10px;
+ }
+ .batch-kf-kthumb {
+ width: 48px;
+ height: 48px;
+ border-radius: 8px;
+ object-fit: cover;
+ flex-shrink: 0;
+ border: 1px solid var(--border);
+ }
+ .batch-kf-kcard-titles {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+ }
+ .batch-kf-ktitle {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text);
+ }
+ .batch-kf-anchor {
+ font-size: 11px;
+ color: var(--accent);
+ font-variant-numeric: tabular-nums;
+ font-weight: 600;
+ }
+ .batch-kf-kcard-ctrl {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 12px;
+ }
+ .batch-kf-klabel {
+ font-size: 10px;
+ color: var(--text-dim);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+ .batch-kf-klabel input[type="number"] {
+ width: 72px;
+ padding: 6px 8px;
+ font-size: 12px;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: var(--panel);
+ color: var(--text);
+ }
+ /* 关键帧之间:细时间轴 + 单行紧凑间隔输入 */
+ .batch-kf-gap {
+ display: flex;
+ align-items: stretch;
+ gap: 8px;
+ padding: 0 0 6px;
+ margin: 0 0 0 10px;
+ }
+ .batch-kf-gap-rail {
+ width: 2px;
+ flex-shrink: 0;
+ border-radius: 2px;
+ background: linear-gradient(
+ 180deg,
+ rgba(255, 255, 255, 0.06),
+ var(--accent-dim),
+ rgba(255, 255, 255, 0.04)
+ );
+ min-height: 22px;
+ align-self: stretch;
+ }
+ .batch-kf-gap-inner {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+ min-width: 0;
+ padding: 2px 0 4px;
+ }
+ .batch-kf-gap-ix {
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--text-dim);
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.02em;
+ flex-shrink: 0;
+ }
+ .batch-kf-seg-field {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ margin: 0;
+ cursor: text;
+ }
+ .batch-kf-seg-input {
+ width: 46px;
+ min-width: 0;
+ padding: 2px 5px;
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1.3;
+ border-radius: 4px;
+ border: 1px solid var(--border);
+ background: rgba(0, 0, 0, 0.2);
+ color: var(--text);
+ font-variant-numeric: tabular-nums;
+ }
+ .batch-kf-seg-input:hover {
+ border-color: rgba(255, 255, 255, 0.12);
+ }
+ .batch-kf-seg-input:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 1px var(--accent-ring);
+ }
+ .batch-kf-gap-unit {
+ font-size: 10px;
+ color: var(--text-dim);
+ font-weight: 500;
+ flex-shrink: 0;
+ }
+
+ .sub-mode-toggle { display: flex; background: var(--panel-2); border-radius: 7px; padding: 3px; border: 1px solid var(--border); }
+ .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; }
+ .sub-mode-btn.active { background: var(--accent); color: #fff; }
+ .sub-mode-btn:hover:not(.active) { background: rgba(255,255,255,0.05); color: var(--text-sub); }
+
+ .vid-section { display: none; margin-top: 12px; }
+ .vid-section.active-section { display: block; animation: fadeIn 0.25s ease; }
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
+
+ /* Status indicator */
+ @keyframes breathe-orange {
+ 0%,100% { box-shadow: 0 0 4px #FF9F0A; opacity: 0.7; }
+ 50% { box-shadow: 0 0 10px #FF9F0A; opacity: 1; }
+ }
+ .indicator-busy { background: #FF9F0A !important; animation: breathe-orange 1.6s infinite ease-in-out !important; box-shadow: none !important; transition: all 0.3s; }
+ .indicator-ready { background: #30D158 !important; box-shadow: 0 0 8px rgba(48,209,88,0.6) !important; animation: none !important; transition: all 0.3s; }
+ .indicator-offline { background: #636366 !important; box-shadow: none !important; animation: none !important; transition: all 0.3s; }
+
+ .res-preview-tag { font-size: 11px; color: var(--accent); margin-bottom: 10px; font-family: ui-monospace, monospace; }
+ .top-status { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-dim); margin-bottom: 8px; align-items: center; }
+ .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); }
+ .checkbox-container input { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; margin: 0; }
+ .checkbox-container label { margin-bottom: 0; cursor: pointer; text-transform: none; color: var(--text); }
+ .flex-row { display: flex; gap: 10px; }
+ .flex-1 { flex: 1; min-width: 0; }
+
+ @media (max-width: 1024px) {
+ body { flex-direction: column; overflow-y: auto; }
+ .sidebar { width: 100%; min-width: 100%; border-right: none; border-bottom: 1px solid var(--border); height: auto; overflow: visible; }
+ .workspace { height: auto; min-height: 100vh; overflow: visible; }
+ }
+:root {
+ --plyr-color-main: #3F51B5;
+ --plyr-video-control-background-hover: rgba(255,255,255,0.1);
+ --plyr-control-radius: 6px;
+ --plyr-player-width: 100%;
+}
+.plyr {
+ border-radius: 8px;
+ overflow: hidden;
+ width: 100%;
+ height: 100%;
+}
+.plyr--video .plyr__controls {
+ background: linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0.8));
+ padding: 20px 15px 15px 15px;
+}
+
diff --git a/LTX2.3/UI/index.html b/LTX2.3/UI/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..8b0d1fe1b88ddd8ff16d2734485395a257cca0ba
--- /dev/null
+++ b/LTX2.3/UI/index.html
@@ -0,0 +1,406 @@
+
+
+
+
+
+ LTX-2 | Multi-GPU Cinematic Studio
+
+
+
+
+
+
+
+
+
+
+
等待分配渲染任务...
+
![]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 历史资产 / ASSETS
+ 系统日志 / LOGS
+
+
+
+
+
+
> LTX-2 Studio Ready. Expecting commands...
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LTX2.3/UI/index.js b/LTX2.3/UI/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..635b808f2a6b3a22ba0323d09d5fd8fc35f3e615
--- /dev/null
+++ b/LTX2.3/UI/index.js
@@ -0,0 +1,2042 @@
+// ─── Resizable panel drag logic ───────────────────────────────────────────────
+(function() {
+ const handle = document.getElementById('resize-handle');
+ const viewer = document.getElementById('viewer-section');
+ const library = document.getElementById('library-section');
+ const workspace = document.querySelector('.workspace');
+ let dragging = false, startY = 0, startVH = 0;
+
+ handle.addEventListener('mousedown', (e) => {
+ dragging = true;
+ startY = e.clientY;
+ startVH = viewer.getBoundingClientRect().height;
+ document.body.style.cursor = 'row-resize';
+ document.body.style.userSelect = 'none';
+ handle.querySelector('div').style.background = 'var(--accent)';
+ e.preventDefault();
+ });
+ document.addEventListener('mousemove', (e) => {
+ if (!dragging) return;
+ const wsH = workspace.getBoundingClientRect().height;
+ const delta = e.clientY - startY;
+ let newVH = startVH + delta;
+ // Clamp: viewer min 150px, library min 100px
+ newVH = Math.max(150, Math.min(wsH - 100 - 5, newVH));
+ viewer.style.flex = 'none';
+ viewer.style.height = newVH + 'px';
+ library.style.flex = '1';
+ });
+ document.addEventListener('mouseup', () => {
+ if (dragging) {
+ dragging = false;
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ handle.querySelector('div').style.background = 'var(--border)';
+ }
+ });
+ // Hover highlight
+ handle.addEventListener('mouseenter', () => { handle.querySelector('div').style.background = 'var(--text-dim)'; });
+ handle.addEventListener('mouseleave', () => { if (!dragging) handle.querySelector('div').style.background = 'var(--border)'; });
+})();
+// ──────────────────────────────────────────────────────────────────────────────
+
+
+
+
+
+
+// 动态获取当前访问的域名或 IP,自动对齐 3000 端口
+ const BASE = `http://${window.location.hostname}:3000`;
+
+ function _t(k) {
+ return typeof window.t === 'function' ? window.t(k) : k;
+ }
+
+ let currentMode = 'image';
+ let pollInterval = null;
+ let availableModels = [];
+ let availableLoras = [];
+
+ // 建议增加一个简单的调试日志,方便在控制台确认地址是否正确
+ console.log("Connecting to Backend API at:", BASE);
+
+ // 模型扫描功能
+ async function scanModels() {
+ try {
+ const url = `${BASE}/api/models`;
+ console.log("Scanning models from:", url);
+ const res = await fetch(url);
+ const data = await res.json().catch(() => ({}));
+ console.log("Models response:", res.status, data);
+ if (!res.ok) {
+ const msg = data.message || data.error || res.statusText;
+ addLog(`❌ 模型扫描失败 (${res.status}): ${msg}`);
+ availableModels = [];
+ updateModelDropdown();
+ updateBatchModelDropdown();
+ return;
+ }
+ availableModels = data.models || [];
+ updateModelDropdown();
+ updateBatchModelDropdown();
+ if (availableModels.length > 0) {
+ addLog(`📂 已扫描到 ${availableModels.length} 个模型: ${availableModels.map(m => m.name).join(', ')}`);
+ }
+ } catch (e) {
+ console.log("Model scan error:", e);
+ addLog(`❌ 模型扫描异常: ${e.message || e}`);
+ }
+ }
+
+ function updateModelDropdown() {
+ const select = document.getElementById('vid-model');
+ if (!select) return;
+ select.innerHTML = '';
+ availableModels.forEach(model => {
+ const opt = document.createElement('option');
+ opt.value = model.path;
+ opt.textContent = model.name;
+ select.appendChild(opt);
+ });
+ }
+
+ // LoRA 扫描功能
+ async function scanLoras() {
+ try {
+ const url = `${BASE}/api/loras`;
+ console.log("Scanning LoRA from:", url);
+ const res = await fetch(url);
+ const data = await res.json().catch(() => ({}));
+ console.log("LoRA response:", res.status, data);
+ if (!res.ok) {
+ const msg = data.message || data.error || res.statusText;
+ addLog(`❌ LoRA 扫描失败 (${res.status}): ${msg}`);
+ availableLoras = [];
+ updateLoraDropdown();
+ updateBatchLoraDropdown();
+ return;
+ }
+ availableLoras = data.loras || [];
+ updateLoraDropdown();
+ updateBatchLoraDropdown();
+ if (data.loras_dir) {
+ const hintEl = document.getElementById('lora-placement-hint');
+ if (hintEl) {
+ const tpl = _t('loraPlacementHintWithDir');
+ hintEl.innerHTML = tpl.replace(
+ '{dir}',
+ escapeHtmlAttr(data.models_dir || data.loras_dir)
+ );
+ }
+ }
+ if (availableLoras.length > 0) {
+ addLog(`📂 已扫描到 ${availableLoras.length} 个 LoRA: ${availableLoras.map(l => l.name).join(', ')}`);
+ }
+ } catch (e) {
+ console.log("LoRA scan error:", e);
+ addLog(`❌ LoRA 扫描异常: ${e.message || e}`);
+ }
+ }
+
+ function updateLoraDropdown() {
+ const select = document.getElementById('vid-lora');
+ if (!select) return;
+ select.innerHTML = '';
+ availableLoras.forEach(lora => {
+ const opt = document.createElement('option');
+ opt.value = lora.path;
+ opt.textContent = lora.name;
+ select.appendChild(opt);
+ });
+ }
+
+ function updateLoraStrength() {
+ const select = document.getElementById('vid-lora');
+ const container = document.getElementById('lora-strength-container');
+ if (select && container) {
+ container.style.display = select.value ? 'flex' : 'none';
+ }
+ }
+
+ // 更新批量模式的模型和LoRA下拉框
+ function updateBatchModelDropdown() {
+ const select = document.getElementById('batch-model');
+ if (!select) return;
+ select.innerHTML = '';
+ availableModels.forEach(model => {
+ const opt = document.createElement('option');
+ opt.value = model.path;
+ opt.textContent = model.name;
+ select.appendChild(opt);
+ });
+ }
+
+ function updateBatchLoraDropdown() {
+ const select = document.getElementById('batch-lora');
+ if (!select) return;
+ select.innerHTML = '';
+ availableLoras.forEach(lora => {
+ const opt = document.createElement('option');
+ opt.value = lora.path;
+ opt.textContent = lora.name;
+ select.appendChild(opt);
+ });
+ }
+
+ // 页面加载时更新批量模式的下拉框
+ function initBatchDropdowns() {
+ updateBatchModelDropdown();
+ updateBatchLoraDropdown();
+ }
+
+ // 已移除:模型/LoRA 目录自定义与浏览(保持后端默认路径扫描)
+
+ // 页面加载时扫描模型和LoRA(使用后端默认目录规则)
+ (function() {
+ ['vid-quality', 'batch-quality'].forEach((id) => {
+ const sel = document.getElementById(id);
+ if (sel && sel.value === '544') sel.value = '540';
+ });
+
+ setTimeout(() => {
+ scanModels();
+ scanLoras();
+ initBatchDropdowns();
+ }, 1500);
+ })();
+
+ // 分辨率自动计算逻辑
+ function updateResPreview() {
+ const q = document.getElementById('vid-quality').value; // "1080", "720", "540"
+ const r = document.getElementById('vid-ratio').value;
+
+ // 核心修复:后端解析器期待 "1080p", "720p", "540p" 这种标签格式
+ let resLabel = q === "1080" ? "1080p" : q === "720" ? "720p" : "540p";
+
+ /* 与后端一致:宽高均为 64 的倍数(LTX 内核要求) */
+ let resDisplay;
+ if (r === "16:9") {
+ resDisplay = q === "1080" ? "1920x1088" : q === "720" ? "1280x704" : "1024x576";
+ } else {
+ resDisplay = q === "1080" ? "1088x1920" : q === "720" ? "704x1280" : "576x1024";
+ }
+
+ document.getElementById('res-preview').innerText = `${_t('resPreviewPrefix')}: ${resLabel} (${resDisplay})`;
+ return resLabel;
+ }
+
+ // 图片分辨率预览
+ function updateImgResPreview() {
+ const w = document.getElementById('img-w').value;
+ const h = document.getElementById('img-h').value;
+ document.getElementById('img-res-preview').innerText = `${_t('resPreviewPrefix')}: ${w}x${h}`;
+ }
+
+ // 批量模式分辨率预览
+ function updateBatchResPreview() {
+ const q = document.getElementById('batch-quality').value;
+ const r = document.getElementById('batch-ratio').value;
+ let resLabel = q === "1080" ? "1080p" : q === "720" ? "720p" : "540p";
+ let resDisplay;
+ if (r === "16:9") {
+ resDisplay = q === "1080" ? "1920x1088" : q === "720" ? "1280x704" : "1024x576";
+ } else {
+ resDisplay = q === "1080" ? "1088x1920" : q === "720" ? "704x1280" : "576x1024";
+ }
+ document.getElementById('batch-res-preview').innerText = `${_t('resPreviewPrefix')}: ${resLabel} (${resDisplay})`;
+ return resLabel;
+ }
+
+ // 批量模式 LoRA 强度切换
+ function updateBatchLoraStrength() {
+ const select = document.getElementById('batch-lora');
+ const container = document.getElementById('batch-lora-strength-container');
+ if (select && container) {
+ container.style.display = select.value ? 'flex' : 'none';
+ }
+ }
+
+ // 切换图片预设分辨率
+ function applyImgPreset(val) {
+ if (val === "custom") {
+ document.getElementById('img-custom-res').style.display = 'flex';
+ } else {
+ const [w, h] = val.split('x');
+ document.getElementById('img-w').value = w;
+ document.getElementById('img-h').value = h;
+ updateImgResPreview();
+ // 隐藏自定义区域或保持显示供微调
+ // document.getElementById('img-custom-res').style.display = 'none';
+ }
+ }
+
+
+
+ // 处理帧图片上传
+ async function handleFrameUpload(file, frameType) {
+ if (!file) return;
+
+ const preview = document.getElementById(`${frameType}-frame-preview`);
+ const placeholder = document.getElementById(`${frameType}-frame-placeholder`);
+ const clearOverlay = document.getElementById(`clear-${frameType}-frame-overlay`);
+
+ const previewReader = new FileReader();
+ previewReader.onload = (e) => {
+ preview.src = e.target.result;
+ preview.style.display = 'block';
+ placeholder.style.display = 'none';
+ clearOverlay.style.display = 'flex';
+ };
+ previewReader.readAsDataURL(file);
+
+ const reader = new FileReader();
+ reader.onload = async (e) => {
+ const b64Data = e.target.result;
+ addLog(`正在上传 ${frameType === 'start' ? '起始帧' : '结束帧'}: ${file.name}...`);
+ try {
+ const res = await fetch(`${BASE}/api/system/upload-image`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image: b64Data, filename: file.name })
+ });
+ const data = await res.json();
+ if (res.ok && data.path) {
+ document.getElementById(`${frameType}-frame-path`).value = data.path;
+ addLog(`✅ ${frameType === 'start' ? '起始帧' : '结束帧'}上传成功`);
+ } else {
+ throw new Error(data.error || data.detail || "上传失败");
+ }
+ } catch (e) {
+ addLog(`❌ 帧图片上传失败: ${e.message}`);
+ }
+ };
+ reader.readAsDataURL(file);
+ }
+
+ function clearFrame(frameType) {
+ document.getElementById(`${frameType}-frame-input`).value = "";
+ document.getElementById(`${frameType}-frame-path`).value = "";
+ document.getElementById(`${frameType}-frame-preview`).style.display = 'none';
+ document.getElementById(`${frameType}-frame-preview`).src = "";
+ document.getElementById(`${frameType}-frame-placeholder`).style.display = 'block';
+ document.getElementById(`clear-${frameType}-frame-overlay`).style.display = 'none';
+ addLog(`🧹 已清除${frameType === 'start' ? '起始帧' : '结束帧'}`);
+ }
+
+ // 处理图片上传
+ async function handleImageUpload(file) {
+ if (!file) return;
+
+ // 预览图片
+ const preview = document.getElementById('upload-preview');
+ const placeholder = document.getElementById('upload-placeholder');
+ const clearOverlay = document.getElementById('clear-img-overlay');
+
+ const previewReader = new FileReader();
+ preview.onload = () => {
+ preview.style.display = 'block';
+ placeholder.style.display = 'none';
+ clearOverlay.style.display = 'flex';
+ };
+ previewReader.onload = (e) => preview.src = e.target.result;
+ previewReader.readAsDataURL(file);
+
+ // 使用 FileReader 转换为 Base64,绕过后端缺失 python-multipart 的问题
+ const reader = new FileReader();
+ reader.onload = async (e) => {
+ const b64Data = e.target.result;
+ addLog(`正在上传参考图: ${file.name}...`);
+ try {
+ const res = await fetch(`${BASE}/api/system/upload-image`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ image: b64Data,
+ filename: file.name
+ })
+ });
+ const data = await res.json();
+ if (res.ok && data.path) {
+ document.getElementById('uploaded-img-path').value = data.path;
+ addLog(`✅ 参考图上传成功: ${file.name}`);
+ } else {
+ const errMsg = data.error || data.detail || "上传失败";
+ throw new Error(typeof errMsg === 'string' ? errMsg : JSON.stringify(errMsg));
+ }
+ } catch (e) {
+ addLog(`❌ 图片上传失败: ${e.message}`);
+ }
+ };
+ reader.onerror = () => addLog("❌ 读取本地文件失败");
+ reader.readAsDataURL(file);
+ }
+
+ function clearUploadedImage() {
+ document.getElementById('vid-image-input').value = "";
+ document.getElementById('uploaded-img-path').value = "";
+ document.getElementById('upload-preview').style.display = 'none';
+ document.getElementById('upload-preview').src = "";
+ document.getElementById('upload-placeholder').style.display = 'block';
+ document.getElementById('clear-img-overlay').style.display = 'none';
+ addLog("🧹 已清除参考图");
+ }
+
+ // 处理音频上传
+ async function handleAudioUpload(file) {
+ if (!file) return;
+
+ const placeholder = document.getElementById('audio-upload-placeholder');
+ const statusDiv = document.getElementById('audio-upload-status');
+ const filenameStatus = document.getElementById('audio-filename-status');
+ const clearOverlay = document.getElementById('clear-audio-overlay');
+
+ placeholder.style.display = 'none';
+ filenameStatus.innerText = file.name;
+ statusDiv.style.display = 'block';
+ clearOverlay.style.display = 'flex';
+
+ const reader = new FileReader();
+ reader.onload = async (e) => {
+ const b64Data = e.target.result;
+ addLog(`正在上传音频: ${file.name}...`);
+ try {
+ // 复用图片上传接口,后端已支持任意文件类型
+ const res = await fetch(`${BASE}/api/system/upload-image`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ image: b64Data,
+ filename: file.name
+ })
+ });
+ const data = await res.json();
+ if (res.ok && data.path) {
+ document.getElementById('uploaded-audio-path').value = data.path;
+ addLog(`✅ 音频上传成功: ${file.name}`);
+ } else {
+ const errMsg = data.error || data.detail || "上传失败";
+ throw new Error(typeof errMsg === 'string' ? errMsg : JSON.stringify(errMsg));
+ }
+ } catch (e) {
+ addLog(`❌ 音频上传失败: ${e.message}`);
+ }
+ };
+ reader.onerror = () => addLog("❌ 读取本地音频文件失败");
+ reader.readAsDataURL(file);
+ }
+
+ function clearUploadedAudio() {
+ document.getElementById('vid-audio-input').value = "";
+ document.getElementById('uploaded-audio-path').value = "";
+ document.getElementById('audio-upload-placeholder').style.display = 'block';
+ document.getElementById('audio-upload-status').style.display = 'none';
+ document.getElementById('clear-audio-overlay').style.display = 'none';
+ addLog("🧹 已清除音频文件");
+ }
+
+ // 处理超分视频上传
+ async function handleUpscaleVideoUpload(file) {
+ if (!file) return;
+ const placeholder = document.getElementById('upscale-placeholder');
+ const statusDiv = document.getElementById('upscale-status');
+ const filenameStatus = document.getElementById('upscale-filename');
+ const clearOverlay = document.getElementById('clear-upscale-overlay');
+
+ filenameStatus.innerText = file.name;
+ placeholder.style.display = 'none';
+ statusDiv.style.display = 'block';
+ clearOverlay.style.display = 'flex';
+
+ const reader = new FileReader();
+ reader.onload = async (e) => {
+ const b64Data = e.target.result;
+ addLog(`正在上传待超分视频: ${file.name}...`);
+ try {
+ const res = await fetch(`${BASE}/api/system/upload-image`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image: b64Data, filename: file.name })
+ });
+ const data = await res.json();
+ if (res.ok && data.path) {
+ document.getElementById('upscale-video-path').value = data.path;
+ addLog(`✅ 视频上传成功`);
+ } else {
+ throw new Error(data.error || "上传失败");
+ }
+ } catch (e) {
+ addLog(`❌ 视频上传失败: ${e.message}`);
+ }
+ };
+ reader.readAsDataURL(file);
+ }
+
+ function clearUpscaleVideo() {
+ document.getElementById('upscale-video-input').value = "";
+ document.getElementById('upscale-video-path').value = "";
+ document.getElementById('upscale-placeholder').style.display = 'block';
+ document.getElementById('upscale-status').style.display = 'none';
+ document.getElementById('clear-upscale-overlay').style.display = 'none';
+ addLog("🧹 已清除待超分视频");
+ }
+
+ // 初始化拖拽上传逻辑
+ function initDragAndDrop() {
+ const audioDropZone = document.getElementById('audio-drop-zone');
+ const startFrameDropZone = document.getElementById('start-frame-drop-zone');
+ const endFrameDropZone = document.getElementById('end-frame-drop-zone');
+ const upscaleDropZone = document.getElementById('upscale-drop-zone');
+ const batchImagesDropZone = document.getElementById('batch-images-drop-zone');
+
+ const zones = [audioDropZone, startFrameDropZone, endFrameDropZone, upscaleDropZone, batchImagesDropZone];
+
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
+ zones.forEach(zone => {
+ if (!zone) return;
+ zone.addEventListener(eventName, (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }, false);
+ });
+ });
+
+ ['dragenter', 'dragover'].forEach(eventName => {
+ zones.forEach(zone => {
+ if (!zone) return;
+ zone.addEventListener(eventName, () => zone.classList.add('dragover'), false);
+ });
+ });
+
+ ['dragleave', 'drop'].forEach(eventName => {
+ zones.forEach(zone => {
+ if (!zone) return;
+ zone.addEventListener(eventName, () => zone.classList.remove('dragover'), false);
+ });
+ });
+
+ audioDropZone.addEventListener('drop', (e) => {
+ const file = e.dataTransfer.files[0];
+ if (file && file.type.startsWith('audio/')) handleAudioUpload(file);
+ }, false);
+
+ startFrameDropZone.addEventListener('drop', (e) => {
+ const file = e.dataTransfer.files[0];
+ if (file && file.type.startsWith('image/')) handleFrameUpload(file, 'start');
+ }, false);
+
+ endFrameDropZone.addEventListener('drop', (e) => {
+ const file = e.dataTransfer.files[0];
+ if (file && file.type.startsWith('image/')) handleFrameUpload(file, 'end');
+ }, false);
+
+ upscaleDropZone.addEventListener('drop', (e) => {
+ const file = e.dataTransfer.files[0];
+ if (file && file.type.startsWith('video/')) handleUpscaleVideoUpload(file);
+ }, false);
+
+ // 批量图片拖拽上传
+ if (batchImagesDropZone) {
+ batchImagesDropZone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ batchImagesDropZone.classList.remove('dragover');
+ const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/'));
+ if (files.length > 0) handleBatchImagesUpload(files);
+ }, false);
+ }
+ }
+
+ // 批量图片上传处理
+ let batchImages = [];
+ /** 单次多关键帧:按 path 记引导强度;按段索引 0..n-2 记「上一张→本张」间隔秒数 */
+ const batchKfStrengthByPath = {};
+ const batchKfSegDurByIndex = {};
+
+ function escapeHtmlAttr(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(/ {
+ if (!img.path) return;
+ const sEl = document.getElementById(`batch-kf-strength-${i}`);
+ if (sEl) batchKfStrengthByPath[img.path] = sEl.value.trim();
+ });
+ const n = batchImages.length;
+ for (let j = 0; j < n - 1; j++) {
+ const el = document.getElementById(`batch-kf-seg-dur-${j}`);
+ if (el) batchKfSegDurByIndex[j] = el.value.trim();
+ }
+ }
+
+ /** 读取间隔(秒),非法则回退为 minSeg */
+ function readBatchKfSegmentSeconds(n, minSeg) {
+ const seg = [];
+ for (let j = 0; j < n - 1; j++) {
+ let v = parseFloat(document.getElementById(`batch-kf-seg-dur-${j}`)?.value);
+ if (!Number.isFinite(v) || v < minSeg) v = minSeg;
+ seg.push(v);
+ }
+ return seg;
+ }
+
+ function updateBatchKfTimelineDerivedUI() {
+ if (!batchWorkflowIsSingle() || batchImages.length < 2) return;
+ const n = batchImages.length;
+ const minSeg = 0.1;
+ const seg = readBatchKfSegmentSeconds(n, minSeg);
+ let t = 0;
+ for (let i = 0; i < n; i++) {
+ const label = document.getElementById(`batch-kf-anchor-label-${i}`);
+ if (!label) continue;
+ if (i === 0) {
+ label.textContent = `0.0 s · ${_t('batchAnchorStart')}`;
+ } else {
+ t += seg[i - 1];
+ label.textContent =
+ i === n - 1
+ ? `${t.toFixed(1)} s · ${_t('batchAnchorEnd')}`
+ : `${t.toFixed(1)} s`;
+ }
+ }
+ const totalEl = document.getElementById('batch-kf-total-seconds');
+ if (totalEl) {
+ const sum = seg.reduce((a, b) => a + b, 0);
+ totalEl.textContent = sum.toFixed(1);
+ }
+ }
+ async function handleBatchImagesUpload(files, append = true) {
+ if (!files || files.length === 0) return;
+ addLog(`正在上传 ${files.length} 张图片...`);
+
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+ const reader = new FileReader();
+
+ const imgData = await new Promise((resolve) => {
+ reader.onload = async (e) => {
+ const b64Data = e.target.result;
+ try {
+ const res = await fetch(`${BASE}/api/system/upload-image`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image: b64Data, filename: file.name })
+ });
+ const data = await res.json();
+ if (res.ok && data.path) {
+ resolve({ name: file.name, path: data.path, preview: e.target.result });
+ } else {
+ resolve(null);
+ }
+ } catch (e) {
+ resolve(null);
+ }
+ };
+ reader.readAsDataURL(file);
+ });
+
+ if (imgData) {
+ batchImages.push(imgData);
+ addLog(`✅ 图片 ${i + 1}/${files.length} 上传成功: ${file.name}`);
+ }
+ }
+
+ renderBatchImages();
+ updateBatchSegments();
+ }
+
+ async function handleBatchBackgroundAudioUpload(file) {
+ if (!file) return;
+ const ph = document.getElementById('batch-audio-placeholder');
+ const st = document.getElementById('batch-audio-status');
+ const overlay = document.getElementById('clear-batch-audio-overlay');
+ const reader = new FileReader();
+ reader.onload = async (e) => {
+ const b64Data = e.target.result;
+ addLog(`正在上传成片配乐: ${file.name}...`);
+ try {
+ const res = await fetch(`${BASE}/api/system/upload-image`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image: b64Data, filename: file.name })
+ });
+ const data = await res.json();
+ if (res.ok && data.path) {
+ const hid = document.getElementById('batch-background-audio-path');
+ if (hid) hid.value = data.path;
+ if (ph) ph.style.display = 'none';
+ if (st) {
+ st.style.display = 'block';
+ st.textContent = '✓ ' + file.name;
+ }
+ if (overlay) overlay.style.display = 'flex';
+ addLog('✅ 成片配乐已上传(将覆盖各片段自带音轨)');
+ } else {
+ addLog(`❌ 配乐上传失败: ${data.error || '未知错误'}`);
+ }
+ } catch (err) {
+ addLog(`❌ 配乐上传失败: ${err.message}`);
+ }
+ };
+ reader.onerror = () => addLog('❌ 读取音频文件失败');
+ reader.readAsDataURL(file);
+ }
+
+ function clearBatchBackgroundAudio() {
+ const hid = document.getElementById('batch-background-audio-path');
+ const inp = document.getElementById('batch-audio-input');
+ if (hid) hid.value = '';
+ if (inp) inp.value = '';
+ const ph = document.getElementById('batch-audio-placeholder');
+ const st = document.getElementById('batch-audio-status');
+ const overlay = document.getElementById('clear-batch-audio-overlay');
+ if (ph) ph.style.display = 'block';
+ if (st) {
+ st.style.display = 'none';
+ st.textContent = '';
+ }
+ if (overlay) overlay.style.display = 'none';
+ addLog('🧹 已清除成片配乐');
+ }
+
+ function syncBatchDropZoneChrome() {
+ const dropZone = document.getElementById('batch-images-drop-zone');
+ const placeholder = document.getElementById('batch-images-placeholder');
+ const stripWrap = document.getElementById('batch-thumb-strip-wrap');
+ if (batchImages.length === 0) {
+ if (dropZone) {
+ dropZone.classList.remove('has-images');
+ const mini = dropZone.querySelector('.upload-placeholder-mini');
+ if (mini) mini.remove();
+ }
+ if (placeholder) placeholder.style.display = 'block';
+ if (stripWrap) stripWrap.style.display = 'none';
+ return;
+ }
+ if (placeholder) placeholder.style.display = 'none';
+ if (dropZone) dropZone.classList.add('has-images');
+ if (stripWrap) stripWrap.style.display = 'block';
+ if (dropZone && !dropZone.querySelector('.upload-placeholder-mini')) {
+ const mini = document.createElement('div');
+ mini.className = 'upload-placeholder-mini';
+ mini.innerHTML = '' + _t('batchAddMore') + '';
+ dropZone.appendChild(mini);
+ }
+ }
+
+ let batchDragPlaceholderEl = null;
+ let batchPointerState = null;
+ let batchPendingPhX = null;
+ let batchPhMoveRaf = null;
+
+ function batchRemoveFloatingGhost() {
+ document.querySelectorAll('.batch-thumb-floating-ghost').forEach((n) => n.remove());
+ }
+
+ function batchCancelPhMoveRaf() {
+ if (batchPhMoveRaf != null) {
+ cancelAnimationFrame(batchPhMoveRaf);
+ batchPhMoveRaf = null;
+ }
+ batchPendingPhX = null;
+ }
+
+ function batchEnsurePlaceholder() {
+ if (batchDragPlaceholderEl && batchDragPlaceholderEl.isConnected) return batchDragPlaceholderEl;
+ const el = document.createElement('div');
+ el.className = 'batch-thumb-drop-slot';
+ el.setAttribute('aria-hidden', 'true');
+ batchDragPlaceholderEl = el;
+ return el;
+ }
+
+ function batchRemovePlaceholder() {
+ if (batchDragPlaceholderEl && batchDragPlaceholderEl.parentNode) {
+ batchDragPlaceholderEl.parentNode.removeChild(batchDragPlaceholderEl);
+ }
+ }
+
+ function batchComputeInsertIndex(container, placeholder) {
+ let t = 0;
+ for (const child of container.children) {
+ if (child === placeholder) return t;
+ if (child.classList && child.classList.contains('batch-image-wrapper')) {
+ if (!child.classList.contains('batch-thumb--source')) t++;
+ }
+ }
+ return t;
+ }
+
+ function batchMovePlaceholderFromPoint(container, clientX) {
+ const ph = batchEnsurePlaceholder();
+ const wrappers = [...container.querySelectorAll('.batch-image-wrapper')];
+ let insertBefore = null;
+ for (const w of wrappers) {
+ if (w.classList.contains('batch-thumb--source')) continue;
+ const r = w.getBoundingClientRect();
+ if (clientX < r.left + r.width / 2) {
+ insertBefore = w;
+ break;
+ }
+ }
+ if (insertBefore === null) {
+ const vis = wrappers.filter((w) => !w.classList.contains('batch-thumb--source'));
+ const last = vis[vis.length - 1];
+ if (last) {
+ if (last.nextSibling) {
+ container.insertBefore(ph, last.nextSibling);
+ } else {
+ container.appendChild(ph);
+ }
+ } else {
+ container.appendChild(ph);
+ }
+ } else {
+ container.insertBefore(ph, insertBefore);
+ }
+ }
+
+ function batchFlushPlaceholderMove() {
+ batchPhMoveRaf = null;
+ if (!batchPointerState || batchPendingPhX == null) return;
+ batchMovePlaceholderFromPoint(batchPointerState.container, batchPendingPhX);
+ }
+
+ function handleBatchPointerMove(e) {
+ if (!batchPointerState) return;
+ e.preventDefault();
+ const st = batchPointerState;
+ st.ghostTX = e.clientX - st.offsetX;
+ st.ghostTY = e.clientY - st.offsetY;
+ batchPendingPhX = e.clientX;
+ if (batchPhMoveRaf == null) {
+ batchPhMoveRaf = requestAnimationFrame(batchFlushPlaceholderMove);
+ }
+ }
+
+ function batchGhostFrame() {
+ const st = batchPointerState;
+ if (!st || !st.ghostEl || !st.ghostEl.isConnected) {
+ return;
+ }
+ const t = 0.42;
+ st.ghostCX += (st.ghostTX - st.ghostCX) * t;
+ st.ghostCY += (st.ghostTY - st.ghostCY) * t;
+ st.ghostEl.style.transform =
+ `translate3d(${st.ghostCX}px,${st.ghostCY}px,0) scale(1.06) rotate(-1deg)`;
+ st.ghostRaf = requestAnimationFrame(batchGhostFrame);
+ }
+
+ function batchStartGhostLoop() {
+ const st = batchPointerState;
+ if (!st || !st.ghostEl) return;
+ if (st.ghostRaf != null) cancelAnimationFrame(st.ghostRaf);
+ st.ghostRaf = requestAnimationFrame(batchGhostFrame);
+ }
+
+ function batchEndPointerDrag(e) {
+ if (!batchPointerState) return;
+ if (e.pointerId !== batchPointerState.pointerId) return;
+ const st = batchPointerState;
+
+ batchCancelPhMoveRaf();
+ if (st.ghostRaf != null) {
+ cancelAnimationFrame(st.ghostRaf);
+ st.ghostRaf = null;
+ }
+ if (st.ghostEl && st.ghostEl.parentNode) {
+ st.ghostEl.remove();
+ }
+ batchPointerState = null;
+
+ document.removeEventListener('pointermove', handleBatchPointerMove);
+ document.removeEventListener('pointerup', batchEndPointerDrag);
+ document.removeEventListener('pointercancel', batchEndPointerDrag);
+
+ try {
+ if (st.wrapperEl) st.wrapperEl.releasePointerCapture(st.pointerId);
+ } catch (_) {}
+
+ const { fromIndex, container, wrapperEl } = st;
+ container.classList.remove('is-batch-settling');
+ if (!batchDragPlaceholderEl || !batchDragPlaceholderEl.parentNode) {
+ if (wrapperEl) wrapperEl.classList.remove('batch-thumb--source');
+ renderBatchImages();
+ updateBatchSegments();
+ return;
+ }
+ const to = batchComputeInsertIndex(container, batchDragPlaceholderEl);
+ batchRemovePlaceholder();
+ if (wrapperEl) wrapperEl.classList.remove('batch-thumb--source');
+
+ if (fromIndex !== to && fromIndex >= 0 && to >= 0) {
+ const [item] = batchImages.splice(fromIndex, 1);
+ batchImages.splice(to, 0, item);
+ updateBatchSegments();
+ }
+ renderBatchImages();
+ }
+
+ function handleBatchPointerDown(e) {
+ if (batchPointerState) return;
+ if (e.button !== 0) return;
+ if (e.target.closest && e.target.closest('.batch-thumb-remove')) return;
+
+ const wrapper = e.currentTarget;
+ const container = document.getElementById('batch-images-container');
+ if (!container) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ const fromIndex = parseInt(wrapper.dataset.index, 10);
+ if (Number.isNaN(fromIndex)) return;
+
+ const rect = wrapper.getBoundingClientRect();
+ const offsetX = e.clientX - rect.left;
+ const offsetY = e.clientY - rect.top;
+ const startLeft = rect.left;
+ const startTop = rect.top;
+
+ const ghost = document.createElement('div');
+ ghost.className = 'batch-thumb-floating-ghost';
+ const gImg = document.createElement('img');
+ const srcImg = wrapper.querySelector('img');
+ gImg.src = srcImg ? srcImg.src : '';
+ gImg.alt = '';
+ ghost.appendChild(gImg);
+ document.body.appendChild(ghost);
+
+ batchPointerState = {
+ fromIndex,
+ pointerId: e.pointerId,
+ wrapperEl: wrapper,
+ container,
+ ghostEl: ghost,
+ offsetX,
+ offsetY,
+ ghostTX: e.clientX - offsetX,
+ ghostTY: e.clientY - offsetY,
+ ghostCX: startLeft,
+ ghostCY: startTop,
+ ghostRaf: null
+ };
+
+ ghost.style.transform =
+ `translate3d(${startLeft}px,${startTop}px,0) scale(1.06) rotate(-1deg)`;
+
+ container.classList.add('is-batch-settling');
+ wrapper.classList.add('batch-thumb--source');
+ const ph = batchEnsurePlaceholder();
+ container.insertBefore(ph, wrapper.nextSibling);
+ /* 不在 pointerdown 立刻重算槽位;双 rAF 后再恢复邻居 transition,保证先完成本帧布局再动画 */
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ container.classList.remove('is-batch-settling');
+ });
+ });
+
+ batchStartGhostLoop();
+
+ document.addEventListener('pointermove', handleBatchPointerMove, { passive: false });
+ document.addEventListener('pointerup', batchEndPointerDrag);
+ document.addEventListener('pointercancel', batchEndPointerDrag);
+
+ try {
+ wrapper.setPointerCapture(e.pointerId);
+ } catch (_) {}
+ }
+
+ function removeBatchImage(index) {
+ if (index < 0 || index >= batchImages.length) return;
+ batchImages.splice(index, 1);
+ renderBatchImages();
+ updateBatchSegments();
+ }
+
+ // 横向缩略图:Pointer 拖动排序(避免 HTML5 DnD 在 WebView/部分浏览器失效)
+ function renderBatchImages() {
+ const container = document.getElementById('batch-images-container');
+ if (!container) return;
+
+ syncBatchDropZoneChrome();
+ batchRemovePlaceholder();
+ batchCancelPhMoveRaf();
+ batchRemoveFloatingGhost();
+ batchPointerState = null;
+ container.classList.remove('is-batch-settling');
+ container.innerHTML = '';
+
+ batchImages.forEach((img, index) => {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'batch-image-wrapper';
+ wrapper.dataset.index = String(index);
+ wrapper.title = _t('batchThumbDrag');
+
+ const imgWrap = document.createElement('div');
+ imgWrap.className = 'batch-thumb-img-wrap';
+ const im = document.createElement('img');
+ im.className = 'batch-thumb-img';
+ im.src = img.preview;
+ im.alt = img.name || '';
+ im.draggable = false;
+ imgWrap.appendChild(im);
+
+ const del = document.createElement('button');
+ del.type = 'button';
+ del.className = 'batch-thumb-remove';
+ del.title = _t('batchThumbRemove');
+ del.setAttribute('aria-label', _t('batchThumbRemove'));
+ del.textContent = '×';
+ del.addEventListener('pointerdown', (ev) => ev.stopPropagation());
+ del.addEventListener('click', (ev) => {
+ ev.stopPropagation();
+ removeBatchImage(index);
+ });
+
+ wrapper.appendChild(imgWrap);
+ wrapper.appendChild(del);
+
+ wrapper.addEventListener('pointerdown', handleBatchPointerDown);
+
+ container.appendChild(wrapper);
+ });
+ }
+
+ function batchWorkflowIsSingle() {
+ const r = document.querySelector('input[name="batch-workflow"]:checked');
+ return !!(r && r.value === 'single');
+ }
+
+ function onBatchWorkflowChange() {
+ updateBatchSegments();
+ }
+
+ // 更新片段设置 UI(分段模式)或单次多关键帧设置
+ function updateBatchSegments() {
+ const container = document.getElementById('batch-segments-container');
+ if (!container) return;
+
+ if (batchImages.length < 2) {
+ container.innerHTML =
+ '' +
+ escapeHtmlAttr(_t('batchNeedTwo')) +
+ '
';
+ return;
+ }
+
+ if (batchWorkflowIsSingle()) {
+ if (batchImages.length >= 2) captureBatchKfTimelineFromDom();
+ const n = batchImages.length;
+ const defaultTotal = 8;
+ const defaultSeg =
+ n > 1 ? (defaultTotal / (n - 1)).toFixed(1) : '4';
+ let blocks = '';
+ batchImages.forEach((img, i) => {
+ const path = img.path || '';
+ const stDef = defaultKeyframeStrengthForIndex(i, n);
+ const stStored = batchKfStrengthByPath[path];
+ const stVal = stStored !== undefined && stStored !== ''
+ ? escapeHtmlAttr(stStored)
+ : stDef;
+ const prev = escapeHtmlAttr(img.preview || '');
+ if (i > 0) {
+ const j = i - 1;
+ const sdStored = batchKfSegDurByIndex[j];
+ const segVal =
+ sdStored !== undefined && sdStored !== ''
+ ? escapeHtmlAttr(sdStored)
+ : defaultSeg;
+ blocks += `
+
+
+
+ ${i}→${i + 1}
+
+
+
`;
+ }
+ blocks += `
+
+
+

+
+ ${escapeHtmlAttr(_t('batchKfTitle'))} ${i + 1} / ${n}
+ —
+
+
+
+
+
+
`;
+ });
+ container.innerHTML = `
+
+
+
${escapeHtmlAttr(_t('batchKfPanelTitle'))}
+
+ ${escapeHtmlAttr(_t('batchTotalDur'))} — ${escapeHtmlAttr(_t('batchTotalSec'))}
+
+
+
${escapeHtmlAttr(_t('batchPanelHint'))}
+
+ ${blocks}
+
+
`;
+ updateBatchKfTimelineDerivedUI();
+ return;
+ }
+
+ let html =
+ '' +
+ escapeHtmlAttr(_t('batchSegTitle')) +
+ '
';
+
+ for (let i = 0; i < batchImages.length - 1; i++) {
+ const segPh = escapeHtmlAttr(_t('batchSegPromptPh'));
+ html += `
+
+
+
+

+
→
+

+
${escapeHtmlAttr(_t('batchSegClip'))} ${i + 1}
+
+
+
+
+ ${escapeHtmlAttr(_t('batchSegSec'))}
+
+
+
+
+
+
+
+ `;
+ }
+
+ container.innerHTML = html;
+ }
+
+ let _isGeneratingFlag = false;
+
+ // 系统状态轮询
+ async function checkStatus() {
+ try {
+ const h = await fetch(`${BASE}/health`).then(r => r.json()).catch(() => ({status: "error"}));
+ const g = await fetch(`${BASE}/api/gpu-info`).then(r => r.json()).catch(() => ({gpu_info: {}}));
+ const p = await fetch(`${BASE}/api/generation/progress`).then(r => r.json()).catch(() => ({progress: 0}));
+ const sysGpus = await fetch(`${BASE}/api/system/list-gpus`).then(r => r.json()).catch(() => ({gpus: []}));
+
+ const activeGpu = (sysGpus.gpus || []).find(x => x.active) || (sysGpus.gpus || [])[0] || {};
+ const gpuName = activeGpu.name || g.gpu_info?.name || "GPU";
+
+ const s = document.getElementById('sys-status');
+ const indicator = document.getElementById('sys-indicator');
+
+ const isReady = h.status === "ok" || h.status === "ready" || h.models_loaded;
+ const backendActive = (p && p.progress > 0);
+
+ if (_isGeneratingFlag || backendActive) {
+ s.innerText = `${gpuName}: ${_t('sysBusy')}`;
+ if(indicator) indicator.className = 'indicator-busy';
+ } else {
+ s.innerText = isReady ? `${gpuName}: ${_t('sysOnline')}` : `${gpuName}: ${_t('sysStarting')}`;
+ if(indicator) indicator.className = isReady ? 'indicator-ready' : 'indicator-offline';
+ }
+ s.style.color = "var(--text-dim)";
+
+ const vUsedMB = g.gpu_info?.vramUsed || 0;
+ const vTotalMB = activeGpu.vram_mb || g.gpu_info?.vram || 32768;
+ const vUsedGB = vUsedMB / 1024;
+ const vTotalGB = vTotalMB / 1024;
+
+ document.getElementById('vram-fill').style.width = (vUsedMB / vTotalMB * 100) + "%";
+ document.getElementById('vram-text').innerText = `${vUsedGB.toFixed(1)} / ${vTotalGB.toFixed(0)} GB`;
+ } catch(e) { document.getElementById('sys-status').innerText = _t('sysOffline'); }
+ }
+ setInterval(checkStatus, 1000); // 提升到 1 秒一次实时监控
+ checkStatus();
+ initDragAndDrop();
+ listGpus(); // 初始化 GPU 列表
+ // 已移除:输出目录自定义(保持后端默认路径)
+
+ updateResPreview();
+ updateBatchResPreview();
+ updateImgResPreview();
+ refreshPromptPlaceholder();
+
+ window.onUiLanguageChanged = function () {
+ updateResPreview();
+ updateBatchResPreview();
+ updateImgResPreview();
+ refreshPromptPlaceholder();
+ if (typeof currentMode !== 'undefined' && currentMode === 'batch') {
+ updateBatchSegments();
+ }
+ updateModelDropdown();
+ updateLoraDropdown();
+ updateBatchModelDropdown();
+ updateBatchLoraDropdown();
+ };
+
+ async function setOutputDir() {
+ const dir = document.getElementById('global-out-dir').value.trim();
+ localStorage.setItem('output_dir', dir);
+ try {
+ const res = await fetch(`${BASE}/api/system/set-dir`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ directory: dir })
+ });
+ if (res.ok) {
+ addLog(`✅ 存储路径更新成功! 当前路径: ${dir || _t('defaultPath')}`);
+ if (typeof fetchHistory === 'function') fetchHistory(currentHistoryPage);
+ }
+ } catch (e) {
+ addLog(`❌ 设置路径时连接异常: ${e.message}`);
+ }
+ }
+
+ async function browseOutputDir() {
+ try {
+ const res = await fetch(`${BASE}/api/system/browse-dir`);
+ const data = await res.json();
+ if (data.status === "success" && data.directory) {
+ document.getElementById('global-out-dir').value = data.directory;
+ // auto apply immediately
+ setOutputDir();
+ addLog(`📂 检测到新路径,已自动套用!`);
+ } else if (data.error) {
+ addLog(`❌ 内部系统权限拦截了弹窗: ${data.error}`);
+ }
+ } catch (e) {
+ addLog(`❌ 无法调出文件夹浏览弹窗, 请直接复制粘贴绝对路径。`);
+ }
+ }
+
+ async function getOutputDir() {
+ try {
+ const res = await fetch(`${BASE}/api/system/get-dir`);
+ const data = await res.json();
+ if (data.directory && data.directory.indexOf('LTXDesktop') === -1 && document.getElementById('global-out-dir')) {
+ document.getElementById('global-out-dir').value = data.directory;
+ }
+ } catch (e) {}
+ }
+
+ function switchMode(m) {
+ currentMode = m;
+ document.getElementById('tab-image').classList.toggle('active', m === 'image');
+ document.getElementById('tab-video').classList.toggle('active', m === 'video');
+ document.getElementById('tab-batch').classList.toggle('active', m === 'batch');
+ document.getElementById('tab-upscale').classList.toggle('active', m === 'upscale');
+
+ document.getElementById('image-opts').style.display = m === 'image' ? 'block' : 'none';
+ document.getElementById('video-opts').style.display = m === 'video' ? 'block' : 'none';
+ document.getElementById('batch-opts').style.display = m === 'batch' ? 'block' : 'none';
+ document.getElementById('upscale-opts').style.display = m === 'upscale' ? 'block' : 'none';
+ if (m === 'batch') updateBatchSegments();
+
+ // 如果切到图像模式,隐藏提示词框外的其他东西
+ refreshPromptPlaceholder();
+ }
+
+ function refreshPromptPlaceholder() {
+ const pe = document.getElementById('prompt');
+ if (!pe) return;
+ pe.placeholder =
+ currentMode === 'upscale' ? _t('promptPlaceholderUpscale') : _t('promptPlaceholder');
+ }
+
+ function showGeneratingView() {
+ if (!_isGeneratingFlag) return;
+ const resImg = document.getElementById('res-img');
+ const videoWrapper = document.getElementById('video-wrapper');
+ if (resImg) resImg.style.display = "none";
+ if (videoWrapper) videoWrapper.style.display = "none";
+ if (player) {
+ try { player.stop(); } catch(_) {}
+ } else {
+ const vid = document.getElementById('res-video');
+ if (vid) { vid.pause(); vid.removeAttribute('src'); vid.load(); }
+ }
+ const loadingTxt = document.getElementById('loading-txt');
+ if (loadingTxt) loadingTxt.style.display = "flex";
+ }
+
+ async function run() {
+ // 防止重复点击(_isGeneratingFlag 比 btn.disabled 更可靠)
+ if (_isGeneratingFlag) {
+ addLog(_t('warnGenerating'));
+ return;
+ }
+
+ const btn = document.getElementById('mainBtn');
+ const promptEl = document.getElementById('prompt');
+ const prompt = promptEl ? promptEl.value.trim() : '';
+
+ function batchHasUsablePrompt() {
+ if (prompt) return true;
+ const c = document.getElementById('batch-common-prompt')?.value?.trim();
+ if (c) return true;
+ if (typeof batchWorkflowIsSingle === 'function' && batchWorkflowIsSingle()) {
+ return false;
+ }
+ if (batchImages.length < 2) return false;
+ for (let i = 0; i < batchImages.length - 1; i++) {
+ if (document.getElementById(`batch-segment-prompt-${i}`)?.value?.trim()) return true;
+ }
+ return false;
+ }
+
+ if (currentMode !== 'upscale') {
+ if (currentMode === 'batch') {
+ if (!batchHasUsablePrompt()) {
+ addLog(_t('warnBatchPrompt'));
+ return;
+ }
+ } else if (!prompt) {
+ addLog(_t('warnNeedPrompt'));
+ return;
+ }
+ }
+
+ if (!btn) {
+ console.error('mainBtn not found');
+ return;
+ }
+
+ // 先设置标志 + 禁用按钮,然后用顶层 try/finally 保证一定能解锁
+ _isGeneratingFlag = true;
+ btn.disabled = true;
+
+ try {
+ // 安全地操作 UI 元素(改用 if 判空,防止 Plyr 接管后 getElementById 返回 null)
+ const loader = document.getElementById('loading-txt');
+ const resImg = document.getElementById('res-img');
+ const resVideo = document.getElementById('res-video');
+
+ if (loader) {
+ loader.style.display = "flex";
+ loader.style.flexDirection = "column";
+ loader.style.alignItems = "center";
+ loader.style.gap = "12px";
+ loader.innerHTML = `
+
+ ${escapeHtmlAttr(_t('loaderGpuAlloc'))}
+ `;
+ }
+ if (resImg) resImg.style.display = "none";
+ // 必须隐藏整个 video-wrapper(Plyr 外层容器),否则第二次生成时视频会与 spinner 叠加
+ const videoWrapper = document.getElementById('video-wrapper');
+ if (videoWrapper) videoWrapper.style.display = "none";
+ if (player) { try { player.stop(); } catch(_) {} }
+ else if (resVideo) { resVideo.pause?.(); resVideo.removeAttribute?.('src'); }
+
+ checkStatus();
+
+ // 重置后端状态锁(非关键,失败不影响主流程)
+ try { await fetch(`${BASE}/api/system/reset-state`, { method: 'POST' }); } catch(_) {}
+
+ startProgressPolling();
+
+ // ---- 新增:在历史记录区插入「正在渲染」缩略图卡片 ----
+ const historyContainer = document.getElementById('history-container');
+ if (historyContainer) {
+ const old = document.getElementById('current-loading-card');
+ if (old) old.remove();
+ const loadingCard = document.createElement('div');
+ loadingCard.className = 'history-card loading-card';
+ loadingCard.id = 'current-loading-card';
+ loadingCard.onclick = showGeneratingView;
+ loadingCard.innerHTML = `
+
+ 等待中...
+ `;
+ historyContainer.prepend(loadingCard);
+ }
+
+ // ---- 构建请求 ----
+ let endpoint, payload;
+ if (currentMode === 'image') {
+ const w = parseInt(document.getElementById('img-w').value);
+ const h = parseInt(document.getElementById('img-h').value);
+ endpoint = '/api/generate-image';
+ payload = {
+ prompt, width: w, height: h,
+ numSteps: parseInt(document.getElementById('img-steps').value),
+ numImages: 1
+ };
+ addLog(`正在发起图像渲染: ${w}x${h}, Steps: ${payload.numSteps}`);
+
+ } else if (currentMode === 'video') {
+ const res = updateResPreview();
+ const dur = parseFloat(document.getElementById('vid-duration').value);
+ const fps = document.getElementById('vid-fps').value;
+ if (dur > 20) addLog(_t('warnVideoLong').replace('{n}', String(dur)));
+
+ const audio = document.getElementById('vid-audio').checked ? "true" : "false";
+ const audioPath = document.getElementById('uploaded-audio-path').value;
+ const startFramePathValue = document.getElementById('start-frame-path').value;
+ const endFramePathValue = document.getElementById('end-frame-path').value;
+
+ let finalImagePath = null, finalStartFramePath = null, finalEndFramePath = null;
+ if (startFramePathValue && endFramePathValue) {
+ finalStartFramePath = startFramePathValue;
+ finalEndFramePath = endFramePathValue;
+ } else if (startFramePathValue) {
+ finalImagePath = startFramePathValue;
+ }
+
+ endpoint = '/api/generate';
+ const modelSelect = document.getElementById('vid-model');
+ const loraSelect = document.getElementById('vid-lora');
+ const loraStrengthInput = document.getElementById('lora-strength');
+ const modelPath = modelSelect ? modelSelect.value : '';
+ const loraPath = loraSelect ? loraSelect.value : '';
+ const loraStrength = loraStrengthInput ? (parseFloat(loraStrengthInput.value) || 1.0) : 1.0;
+ console.log("modelPath:", modelPath);
+ console.log("loraPath:", loraPath);
+ console.log("loraStrength:", loraStrength);
+ payload = {
+ prompt, resolution: res, model: "ltx-2",
+ cameraMotion: document.getElementById('vid-motion').value,
+ negativePrompt: "low quality, blurry, noisy, static noise, distorted",
+ duration: String(dur), fps, audio,
+ imagePath: finalImagePath,
+ audioPath: audioPath || null,
+ startFramePath: finalStartFramePath,
+ endFramePath: finalEndFramePath,
+ aspectRatio: document.getElementById('vid-ratio').value,
+ modelPath: modelPath || null,
+ loraPath: loraPath || null,
+ loraStrength: loraStrength,
+ };
+ addLog(`正在发起视频渲染: ${res}, 时长: ${dur}s, FPS: ${fps}, 模型: ${modelPath ? modelPath.split(/[/\\]/).pop() : _t('modelDefaultLabel')}, LoRA: ${loraPath ? loraPath.split(/[/\\]/).pop() : _t('loraNoneLabel')}`);
+
+ } else if (currentMode === 'upscale') {
+ const videoPath = document.getElementById('upscale-video-path').value;
+ const targetRes = document.getElementById('upscale-res').value;
+ if (!videoPath) throw new Error(_t('errUpscaleNoVideo'));
+ endpoint = '/api/system/upscale-video';
+ payload = { video_path: videoPath, resolution: targetRes, prompt: "high quality, detailed, 4k", strength: 0.7 };
+ addLog(`正在发起视频超分: 目标 ${targetRes}`);
+ } else if (currentMode === 'batch') {
+ const res = updateBatchResPreview();
+ const commonPromptEl = document.getElementById('batch-common-prompt');
+ const commonPrompt = commonPromptEl ? commonPromptEl.value : '';
+ const modelSelect = document.getElementById('batch-model');
+ const loraSelect = document.getElementById('batch-lora');
+ const loraStrengthInput = document.getElementById('batch-lora-strength');
+ const modelPath = modelSelect ? modelSelect.value : '';
+ const loraPath = loraSelect ? loraSelect.value : '';
+ const loraStrength = loraStrengthInput ? (parseFloat(loraStrengthInput.value) || 1.2) : 1.2;
+
+ if (batchImages.length < 2) {
+ throw new Error(_t('errBatchMinImages'));
+ }
+
+ if (batchWorkflowIsSingle()) {
+ captureBatchKfTimelineFromDom();
+ const fps = document.getElementById('vid-fps').value;
+ const parts = [prompt.trim(), commonPrompt.trim()].filter(Boolean);
+ const combinedPrompt = parts.join(', ');
+ if (!combinedPrompt) {
+ throw new Error(_t('errSingleKfPrompt'));
+ }
+ const nKf = batchImages.length;
+ const minSeg = 0.1;
+ const segDurs = [];
+ for (let j = 0; j < nKf - 1; j++) {
+ let v = parseFloat(document.getElementById(`batch-kf-seg-dur-${j}`)?.value);
+ if (!Number.isFinite(v) || v < minSeg) v = minSeg;
+ segDurs.push(v);
+ }
+ const sumSec = segDurs.reduce((a, b) => a + b, 0);
+ const dur = Math.max(2, Math.ceil(sumSec - 1e-9));
+ const times = [0];
+ let acc = 0;
+ for (let j = 0; j < nKf - 1; j++) {
+ acc += segDurs[j];
+ times.push(acc);
+ }
+ const strengths = [];
+ for (let i = 0; i < nKf; i++) {
+ const sEl = document.getElementById(`batch-kf-strength-${i}`);
+ let sv = parseFloat(sEl?.value);
+ if (!Number.isFinite(sv)) {
+ sv = parseFloat(defaultKeyframeStrengthForIndex(i, nKf));
+ }
+ if (!Number.isFinite(sv)) sv = 1;
+ sv = Math.max(0.1, Math.min(1.0, sv));
+ strengths.push(sv);
+ }
+ endpoint = '/api/generate';
+ payload = {
+ prompt: combinedPrompt,
+ resolution: res,
+ model: "ltx-2",
+ cameraMotion: document.getElementById('vid-motion').value,
+ negativePrompt: "low quality, blurry, noisy, static noise, distorted",
+ duration: String(dur),
+ fps,
+ audio: "false",
+ imagePath: null,
+ audioPath: null,
+ startFramePath: null,
+ endFramePath: null,
+ keyframePaths: batchImages.map((b) => b.path),
+ keyframeStrengths: strengths,
+ keyframeTimes: times,
+ aspectRatio: document.getElementById('batch-ratio').value,
+ modelPath: modelPath || null,
+ loraPath: loraPath || null,
+ loraStrength: loraStrength,
+ };
+ addLog(
+ `单次多关键帧: ${nKf} 锚点, 轴长合计 ${sumSec.toFixed(1)}s → 请求时长 ${dur}s, ${res}, FPS ${fps}`
+ );
+ } else {
+ const segments = [];
+ for (let i = 0; i < batchImages.length - 1; i++) {
+ const duration = parseFloat(document.getElementById(`batch-segment-duration-${i}`)?.value || 5);
+ const segmentPrompt = document.getElementById(`batch-segment-prompt-${i}`)?.value || '';
+ const segParts = [prompt.trim(), commonPrompt.trim(), segmentPrompt.trim()].filter(Boolean);
+ const combinedSegPrompt = segParts.join(', ');
+ segments.push({
+ startImage: batchImages[i].path,
+ endImage: batchImages[i + 1].path,
+ duration: duration,
+ prompt: combinedSegPrompt
+ });
+ }
+
+ endpoint = '/api/generate-batch';
+ const bgAudioEl = document.getElementById('batch-background-audio-path');
+ const backgroundAudioPath = (bgAudioEl && bgAudioEl.value) ? bgAudioEl.value.trim() : null;
+ payload = {
+ segments: segments,
+ resolution: res,
+ model: "ltx-2",
+ aspectRatio: document.getElementById('batch-ratio').value,
+ modelPath: modelPath || null,
+ loraPath: loraPath || null,
+ loraStrength: loraStrength,
+ negativePrompt: "low quality, blurry, noisy, static noise, distorted",
+ backgroundAudioPath: backgroundAudioPath || null
+ };
+ addLog(`分段拼接: ${segments.length} 段, ${res}${backgroundAudioPath ? ',含统一配乐' : ''}`);
+ }
+ }
+
+ // ---- 发送请求 ----
+ const res = await fetch(BASE + endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ const errMsg = data.error || data.detail || "API 拒绝了请求";
+ throw new Error(typeof errMsg === 'string' ? errMsg : JSON.stringify(errMsg));
+ }
+
+ // ---- 显示结果 ----
+ const rawPath = data.image_paths ? data.image_paths[0] : data.video_path;
+ if (rawPath) {
+ try { displayOutput(rawPath); } catch (dispErr) { addLog(`⚠️ 播放器显示异常: ${dispErr.message}`); }
+ }
+
+ // 强制刷新历史记录(不依赖 isLoadingHistory 标志,确保新生成的视频立即显示)
+ setTimeout(() => {
+ isLoadingHistory = false; // 强制重置状态
+ if (typeof fetchHistory === 'function') fetchHistory(1);
+ }, 500);
+
+ } catch (e) {
+ const errText = e && e.message ? e.message : String(e);
+ addLog(`❌ 渲染中断: ${errText}`);
+ const loader = document.getElementById('loading-txt');
+ if (loader) {
+ loader.style.display = 'flex';
+ loader.textContent = '';
+ const span = document.createElement('span');
+ span.style.cssText = 'color:var(--text-sub);font-size:13px;padding:12px;text-align:center;';
+ span.textContent = `渲染失败:${errText}`;
+ loader.appendChild(span);
+ }
+
+ } finally {
+ // ✅ 无论发生什么,这里一定执行,确保按钮永远可以再次点击
+ _isGeneratingFlag = false;
+ btn.disabled = false;
+ stopProgressPolling();
+ checkStatus();
+ // 生成完毕后自动释放显存(不 await 避免阻塞 UI 解锁)
+ setTimeout(() => { clearGpu(); }, 500);
+ }
+ }
+
+ async function clearGpu() {
+ const btn = document.getElementById('clearGpuBtn');
+ btn.disabled = true;
+ btn.innerText = _t('clearingVram');
+ try {
+ const res = await fetch(`${BASE}/api/system/clear-gpu`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' }
+ });
+ const data = await res.json();
+ if (res.ok) {
+ addLog(`🧹 显存清理成功: ${data.message}`);
+ // 立即触发状态刷新
+ checkStatus();
+ setTimeout(checkStatus, 1000);
+ } else {
+ const errMsg = data.error || data.detail || "后端未实现此接口 (404)";
+ throw new Error(errMsg);
+ }
+ } catch(e) {
+ addLog(`❌ 清理显存失败: ${e.message}`);
+ } finally {
+ btn.disabled = false;
+ btn.innerText = _t('clearVram');
+ }
+ }
+
+ async function listGpus() {
+ try {
+ const res = await fetch(`${BASE}/api/system/list-gpus`);
+ const data = await res.json();
+ if (res.ok && data.gpus) {
+ const selector = document.getElementById('gpu-selector');
+ selector.innerHTML = data.gpus.map(g =>
+ ``
+ ).join('');
+
+ // 更新当前显示的 GPU 名称
+ const activeGpu = data.gpus.find(g => g.active);
+ if (activeGpu) document.getElementById('gpu-name').innerText = activeGpu.name;
+ }
+ } catch (e) {
+ console.error("Failed to list GPUs", e);
+ }
+ }
+
+ async function switchGpu(id) {
+ if (!id) return;
+ addLog(`🔄 正在切换到 GPU ${id}...`);
+ try {
+ const res = await fetch(`${BASE}/api/system/switch-gpu`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ gpu_id: parseInt(id) })
+ });
+ const data = await res.json();
+ if (res.ok) {
+ addLog(`✅ 已成功切换到 GPU ${id},模型将重新加载。`);
+ listGpus(); // 重新获取列表以同步状态
+ setTimeout(checkStatus, 1000);
+ } else {
+ throw new Error(data.error || "切换失败");
+ }
+ } catch (e) {
+ addLog(`❌ GPU 切换失败: ${e.message}`);
+ }
+ }
+
+ function startProgressPolling() {
+ if (pollInterval) clearInterval(pollInterval);
+ pollInterval = setInterval(async () => {
+ try {
+ const res = await fetch(`${BASE}/api/generation/progress`);
+ const d = await res.json();
+ if (d.progress > 0) {
+ const ph = String(d.phase || 'inference');
+ const phaseKey = 'phase_' + ph;
+ let phaseStr = _t(phaseKey);
+ if (phaseStr === phaseKey) phaseStr = ph;
+
+ let stepLabel;
+ if (d.current_step !== undefined && d.current_step !== null && d.total_steps) {
+ stepLabel = `${d.current_step}/${d.total_steps} ${_t('progressStepUnit')}`;
+ } else {
+ stepLabel = `${d.progress}%`;
+ }
+
+ document.getElementById('progress-fill').style.width = d.progress + "%";
+ const loaderStep = document.getElementById('loader-step-text');
+ const busyLine = `${_t('gpuBusyPrefix')}: ${stepLabel} [${phaseStr}]`;
+ if (loaderStep) loaderStep.innerText = busyLine;
+ else {
+ const loadingTxt = document.getElementById('loading-txt');
+ if (loadingTxt) loadingTxt.innerText = busyLine;
+ }
+
+ // 同步更新历史缩略图卡片上的进度文字
+ const cardStep = document.getElementById('loading-card-step');
+ if (cardStep) cardStep.innerText = stepLabel;
+ }
+ } catch(e) {}
+ }, 1000);
+ }
+
+ function stopProgressPolling() {
+ clearInterval(pollInterval);
+ pollInterval = null;
+ document.getElementById('progress-fill').style.width = "0%";
+ // 移除渲染中的卡片(生成已结束)
+ const lc = document.getElementById('current-loading-card');
+ if (lc) lc.remove();
+ }
+
+ function displayOutput(fileOrPath) {
+ const img = document.getElementById('res-img');
+ const vid = document.getElementById('res-video');
+ const loader = document.getElementById('loading-txt');
+
+ // 关键BUG修复:切换前强制清除并停止现有视频和声音,避免后台继续播放
+ if(player) {
+ player.stop();
+ } else {
+ vid.pause();
+ vid.removeAttribute('src');
+ vid.load();
+ }
+
+ let url = "";
+ let fileName = fileOrPath;
+ if (fileOrPath.indexOf('\\') !== -1 || fileOrPath.indexOf('/') !== -1) {
+ url = `${BASE}/api/system/file?path=${encodeURIComponent(fileOrPath)}&t=${Date.now()}`;
+ fileName = fileOrPath.split(/[\\/]/).pop();
+ } else {
+ const outInput = document.getElementById('global-out-dir');
+ const globalDir = outInput ? outInput.value.replace(/\\/g, '/').replace(/\/$/, '') : "";
+ if (globalDir && globalDir !== "") {
+ url = `${BASE}/api/system/file?path=${encodeURIComponent(globalDir + '/' + fileOrPath)}&t=${Date.now()}`;
+ } else {
+ url = `${BASE}/outputs/${fileOrPath}?t=${Date.now()}`;
+ }
+ }
+
+ loader.style.display = "none";
+ if (currentMode === 'image') {
+ img.src = url;
+ img.style.display = "block";
+ addLog(`✅ 图像渲染成功: ${fileName}`);
+ } else {
+ document.getElementById('video-wrapper').style.display = "flex";
+
+ if(player) {
+ player.source = {
+ type: 'video',
+ sources: [{ src: url, type: 'video/mp4' }]
+ };
+ player.play();
+ } else {
+ vid.src = url;
+ }
+ addLog(`✅ 视频渲染成功: ${fileName}`);
+ }
+ }
+
+
+
+ function addLog(msg) {
+ const log = document.getElementById('log');
+ if (!log) {
+ console.log('[LTX]', msg);
+ return;
+ }
+ const time = new Date().toLocaleTimeString();
+ log.innerHTML += ` [${time}] ${msg}
`;
+ log.scrollTop = log.scrollHeight;
+ }
+
+
+// Force switch to video mode on load
+window.addEventListener('DOMContentLoaded', () => switchMode('video'));
+
+
+
+
+
+
+
+
+
+
+
+
+ let currentHistoryPage = 1;
+ let isLoadingHistory = false;
+ /** 与上次成功渲染一致时,silent 轮询跳过整表 innerHTML,避免缩略图周期性重新加载 */
+ let _historyListFingerprint = '';
+
+ function switchLibTab(tab) {
+ document.getElementById('log-container').style.display = tab === 'log' ? 'flex' : 'none';
+ const hw = document.getElementById('history-wrapper');
+ if (hw) hw.style.display = tab === 'history' ? 'block' : 'none';
+
+ document.getElementById('tab-log').style.color = tab === 'log' ? 'var(--accent)' : 'var(--text-dim)';
+ document.getElementById('tab-log').style.borderColor = tab === 'log' ? 'var(--accent)' : 'transparent';
+
+ document.getElementById('tab-history').style.color = tab === 'history' ? 'var(--accent)' : 'var(--text-dim)';
+ document.getElementById('tab-history').style.borderColor = tab === 'history' ? 'var(--accent)' : 'transparent';
+
+ if (tab === 'history') {
+ fetchHistory();
+ }
+ }
+
+ async function fetchHistory(isFirstLoad = false, silent = false) {
+ if (isLoadingHistory) return;
+ isLoadingHistory = true;
+
+ try {
+ // 加载所有历史,不分页
+ const res = await fetch(`${BASE}/api/system/history?page=1&limit=10000`);
+ if (!res.ok) {
+ isLoadingHistory = false;
+ return;
+ }
+ const data = await res.json();
+
+ const validHistory = (data.history || []).filter(item => item && item.filename);
+ const fingerprint = validHistory.length === 0
+ ? '__empty__'
+ : validHistory.map(h => `${h.type}|${h.filename}`).join('\0');
+
+ if (silent && fingerprint === _historyListFingerprint) {
+ return;
+ }
+
+ const container = document.getElementById('history-container');
+ if (!container) {
+ return;
+ }
+
+ let loadingCardHtml = "";
+ const lc = document.getElementById('current-loading-card');
+ if (lc && _isGeneratingFlag) {
+ loadingCardHtml = lc.outerHTML;
+ }
+
+ if (validHistory.length === 0) {
+ container.innerHTML = loadingCardHtml;
+ const newLcEmpty = document.getElementById('current-loading-card');
+ if (newLcEmpty) newLcEmpty.onclick = showGeneratingView;
+ _historyListFingerprint = fingerprint;
+ return;
+ }
+
+ container.innerHTML = loadingCardHtml;
+
+ const outInput = document.getElementById('global-out-dir');
+ const globalDir = outInput ? outInput.value.replace(/\\/g, '/').replace(/\/$/, '') : "";
+
+ const cardsHtml = validHistory.map((item, index) => {
+ const url = (globalDir && globalDir !== "")
+ ? `${BASE}/api/system/file?path=${encodeURIComponent(globalDir + '/' + item.filename)}`
+ : `${BASE}/outputs/${item.filename}`;
+
+ const safeFilename = item.filename.replace(/'/g, "\\'").replace(/"/g, '\\"');
+ const media = item.type === 'video'
+ ? ``
+ : `
`;
+ return `
+
${item.type === 'video' ? '🎬 VID' : '🎨 IMG'}
+
+ ${media}
+
`;
+ }).join('');
+
+ container.insertAdjacentHTML('beforeend', cardsHtml);
+
+ // 重新绑定loading card点击事件
+ const newLc = document.getElementById('current-loading-card');
+ if (newLc) newLc.onclick = showGeneratingView;
+
+ // 加载可见的图片
+ loadVisibleImages();
+ _historyListFingerprint = fingerprint;
+ } catch(e) {
+ console.error("Failed to load history", e);
+ } finally {
+ isLoadingHistory = false;
+ }
+ }
+
+ async function deleteHistoryItem(filename, type, btn) {
+ if (!confirm(`确定要删除 "${filename}" 吗?`)) return;
+
+ try {
+ const res = await fetch(`${BASE}/api/system/delete-file`, {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({filename: filename, type: type})
+ });
+
+ if (res.ok) {
+ // 删除成功后移除元素
+ const card = btn.closest('.history-card');
+ if (card) {
+ card.remove();
+ }
+ } else {
+ alert('删除失败');
+ }
+ } catch(e) {
+ console.error('Delete failed', e);
+ alert('删除失败');
+ }
+ }
+
+ function loadVisibleImages() {
+ const hw = document.getElementById('history-wrapper');
+ if (!hw) return;
+
+ const lazyMedias = document.querySelectorAll('#history-container .lazy-load');
+
+ // 每次只加载3个媒体元素(图片或视频)
+ let loadedCount = 0;
+ lazyMedias.forEach(media => {
+ if (loadedCount >= 3) return;
+
+ const src = media.dataset.src;
+ if (!src) return;
+
+ // 检查是否在可见区域附近
+ const rect = media.getBoundingClientRect();
+ const containerRect = hw.getBoundingClientRect();
+
+ if (rect.top < containerRect.bottom + 300 && rect.bottom > containerRect.top - 100) {
+ let revealed = false;
+ let thumbRevealTimer;
+ const revealThumb = () => {
+ if (revealed) return;
+ revealed = true;
+ if (thumbRevealTimer) clearTimeout(thumbRevealTimer);
+ media.classList.add('history-thumb-ready');
+ };
+ thumbRevealTimer = setTimeout(revealThumb, 4000);
+
+ if (media.tagName === 'VIDEO') {
+ media.addEventListener('loadeddata', revealThumb, { once: true });
+ media.addEventListener('error', revealThumb, { once: true });
+ } else {
+ media.addEventListener('load', revealThumb, { once: true });
+ media.addEventListener('error', revealThumb, { once: true });
+ }
+
+ media.src = src;
+ media.classList.remove('lazy-load');
+
+ if (media.tagName === 'VIDEO') {
+ media.preload = 'metadata';
+ if (media.readyState >= 2) revealThumb();
+ } else if (media.complete && media.naturalWidth > 0) {
+ revealThumb();
+ }
+
+ loadedCount++;
+ }
+ });
+
+ // 继续检查直到没有更多媒体需要加载
+ if (loadedCount > 0) {
+ setTimeout(loadVisibleImages, 100);
+ }
+ }
+
+ // 监听history-wrapper的滚动事件来懒加载
+ function initHistoryScrollListener() {
+ const hw = document.getElementById('history-wrapper');
+ if (!hw) return;
+
+ let scrollTimeout;
+ hw.addEventListener('scroll', () => {
+ if (scrollTimeout) clearTimeout(scrollTimeout);
+ scrollTimeout = setTimeout(() => {
+ loadVisibleImages();
+ }, 100);
+ });
+ }
+
+ // 页面加载时初始化滚动监听
+ window.addEventListener('DOMContentLoaded', () => {
+ setTimeout(initHistoryScrollListener, 500);
+ });
+
+ function displayHistoryOutput(file, type) {
+ document.getElementById('res-img').style.display = 'none';
+ document.getElementById('video-wrapper').style.display = 'none';
+
+ const mode = type === 'video' ? 'video' : 'image';
+ switchMode(mode);
+ displayOutput(file);
+ }
+
+ window.addEventListener('DOMContentLoaded', () => {
+ // Initialize Plyr Custom Video Component
+ if(window.Plyr) {
+ player = new Plyr('#res-video', {
+ controls: [
+ 'play-large', 'play', 'progress', 'current-time',
+ 'mute', 'volume', 'fullscreen'
+ ],
+ settings: [],
+ loop: { active: true },
+ autoplay: true
+ });
+ }
+
+ // Fetch current directory context to show in UI
+ fetch(`${BASE}/api/system/get-dir`)
+ .then((res) => res.json())
+ .then((data) => {
+ if (data && data.directory) {
+ const outInput = document.getElementById('global-out-dir');
+ if (outInput) outInput.value = data.directory;
+ }
+ })
+ .catch((e) => console.error(e))
+ .finally(() => {
+ /* 先同步输出目录再拉历史,避免短时间内两次 fetchHistory 整表重绘导致缩略图闪两下 */
+ switchLibTab('history');
+ });
+
+ let historyRefreshInterval = null;
+ function startHistoryAutoRefresh() {
+ if (historyRefreshInterval) return;
+ historyRefreshInterval = setInterval(() => {
+ const hc = document.getElementById('history-container');
+ if (hc && hc.offsetParent !== null && !_isGeneratingFlag) {
+ fetchHistory(1, true);
+ }
+ }, 5000);
+ }
+ startHistoryAutoRefresh();
+ });
\ No newline at end of file
diff --git a/LTX2.3/main.py b/LTX2.3/main.py
new file mode 100644
index 0000000000000000000000000000000000000000..623c03912cd3dab626916247af5e9757b61f4803
--- /dev/null
+++ b/LTX2.3/main.py
@@ -0,0 +1,264 @@
+import os
+import sys
+import subprocess
+import threading
+import time
+import socket
+import logging
+from fastapi import FastAPI
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
+import uvicorn
+
+# ============================================================
+# 配置区 (动态路径适配与补丁挂载)
+# ============================================================
+def resolve_ltx_path():
+ import glob, tempfile, subprocess
+ sc_dir = os.path.join(os.getcwd(), "LTX_Shortcut")
+ os.makedirs(sc_dir, exist_ok=True)
+ lnk_files = glob.glob(os.path.join(sc_dir, "*.lnk"))
+ if not lnk_files:
+ print("\033[91m[ERROR] 未在 LTX_Shortcut 文件夹中找到快捷方式!\n请打开程序目录下的 LTX_Shortcut 文件夹,并将官方 LTX Desktop 的快捷方式复制进去后重试。\033[0m")
+ sys.exit(1)
+
+ lnk_path = lnk_files[0]
+ # 使用 VBScript 解析快捷方式,兼容所有 Windows 系统
+ vbs_code = f'''Set sh = CreateObject("WScript.Shell")\nSet obj = sh.CreateShortcut("{os.path.abspath(lnk_path)}")\nWScript.Echo obj.TargetPath'''
+ fd, vbs_path = tempfile.mkstemp(suffix='.vbs')
+ with os.fdopen(fd, 'w') as f:
+ f.write(vbs_code)
+ try:
+ out = subprocess.check_output(['cscript', '//nologo', vbs_path], stderr=subprocess.STDOUT)
+ target_exe = out.decode('ansi').strip()
+ finally:
+ os.remove(vbs_path)
+
+ if not target_exe or not os.path.exists(target_exe):
+ # 如果快捷方式解析失败,或者解析出来的是朋友电脑的路径(当前电脑不存在),自动全盘搜索默认路径
+ default_paths = [
+ os.path.join(os.environ.get("LOCALAPPDATA", ""), r"Programs\LTX Desktop\LTX Desktop.exe"),
+ r"C:\Program Files\LTX Desktop\LTX Desktop.exe",
+ r"D:\Program Files\LTX Desktop\LTX Desktop.exe",
+ r"E:\Program Files\LTX Desktop\LTX Desktop.exe"
+ ]
+ found = False
+ for p in default_paths:
+ if os.path.exists(p):
+ target_exe = p
+ print(f"\033[96m[INFO] 自动检测到 LTX 原版安装路径: {p}\033[0m")
+ found = True
+ break
+
+ if not found:
+ print(f"\033[91m[ERROR] 未能找到原版 LTX Desktop 的安装路径!\033[0m")
+ print("请清理 LTX_Shortcut 文件夹,并将您当前电脑上真正的原版快捷方式重贴复制进去。")
+ sys.exit(1)
+
+ return os.path.dirname(target_exe)
+
+USER_PROFILE = os.path.expanduser("~")
+PYTHON_EXE = os.path.join(USER_PROFILE, r"AppData\Local\LTXDesktop\python\python.exe")
+DATA_DIR = os.path.join(USER_PROFILE, r"AppData\Local\LTXDesktop")
+
+# 1. 动态获取主安装路径
+LTX_INSTALL_DIR = resolve_ltx_path()
+BACKEND_DIR = os.path.join(LTX_INSTALL_DIR, r"resources\backend")
+UI_FILE_NAME = "UI/index.html"
+
+# 环境致命检测:如果官方 Python 还没解压释放,立刻强制中断整个程序
+if not os.path.exists(PYTHON_EXE):
+ print(f"\n\033[1;41m [致命错误] 您的电脑上尚未配置好 LTX 的官方渲染核心框架! \033[0m")
+ print(f"\033[93m此应用仅是 UI 图形控制台,必需依赖原版软件环境才能生成。在 ({PYTHON_EXE}) 未找到运行引擎。\n")
+ print(">> 解决方案:\n1. 请先在您的电脑上正常安装【LTX Desktop 官方原版软件】。")
+ print("2. 必需:双击打开运行一次原版软件!(运行后原版软件会在后台自动释放环境)")
+ print("3. 把原版软件的快捷方式复制到本文档的 LTX_Shortcut 文件夹里面。")
+ print("4. 全部完成后,再重新启动本 run.bat 脚本即可!\033[0m\n")
+ os._exit(1)
+
+# 2. 从目录读取改动过的 Python 文件 (热修复拦截器)
+PATCHES_DIR = os.path.join(os.getcwd(), "patches")
+os.makedirs(PATCHES_DIR, exist_ok=True)
+
+# 3. 默认输出定向至程序根目录
+LOCAL_OUTPUTS = os.path.join(os.getcwd(), "outputs")
+os.makedirs(LOCAL_OUTPUTS, exist_ok=True)
+
+# 强制注入自定义输出录至 LTX 缓存数据中
+os.makedirs(DATA_DIR, exist_ok=True)
+with open(os.path.join(DATA_DIR, "custom_dir.txt"), 'w', encoding='utf-8') as f:
+ f.write(LOCAL_OUTPUTS)
+
+os.environ["LTX_APP_DATA_DIR"] = DATA_DIR
+
+# 将 patches 目录优先级提升,做到 Python 无损替换
+os.environ["PYTHONPATH"] = f"{PATCHES_DIR};{BACKEND_DIR}"
+
+def get_lan_ip():
+ try:
+ host_name = socket.gethostname()
+ _, _, ip_list = socket.gethostbyname_ex(host_name)
+
+ candidates = []
+ for ip in ip_list:
+ if ip.startswith("192.168."):
+ return ip
+ elif ip.startswith("10.") or (ip.startswith("172.") and 16 <= int(ip.split('.')[1]) <= 31):
+ candidates.append(ip)
+
+ if candidates:
+ return candidates[0]
+
+ # Fallback to the default socket routing approach if no obvious LAN IP found
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ s.connect(("8.8.8.8", 80))
+ ip = s.getsockname()[0]
+ s.close()
+ return ip
+ except:
+ return "127.0.0.1"
+
+LAN_IP = get_lan_ip()
+
+# ============================================================
+# 服务启动逻辑
+# ============================================================
+def check_port_in_use(port):
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ return s.connect_ex(('127.0.0.1', port)) == 0
+
+def launch_backend():
+ """启动核心引擎 - 监听 0.0.0.0 确保局域网可调"""
+ if check_port_in_use(3000):
+ print(f"\n\033[1;41m [致命错误] 3000 端口已被占用,无法启动核心引擎! \033[0m")
+ print("\033[93m>> 绝大多数情况下,这是因为【官方原版 LTX Desktop】正在您的电脑后台运行。\033[0m")
+ print(">> 冲突会导致显存爆炸。请检查右下角系统托盘图标,右键完全退出官方软件。")
+ print(">> 退出后重新双击 run.bat 启动本程序!\n")
+ os._exit(1)
+
+ print(f"\033[96m[CORE] 核心引擎正在启动...\033[0m")
+ # 只开启重要级别的 Python 应用层日志,去除无用的 HTTP 刷屏
+ import logging as _logging
+ _logging.basicConfig(
+ level=_logging.INFO,
+ format="[%(asctime)s] %(levelname)s %(name)s: %(message)s",
+ datefmt="%H:%M:%S",
+ force=True
+ )
+
+ # 构建绝对无损的环境拦截器:防止其他电脑被 cwd 劫持加载原版文件
+ launcher_code = f"""
+import sys
+import os
+
+patch_dir = r"{PATCHES_DIR}"
+backend_dir = r"{BACKEND_DIR}"
+
+# 防御性清除:强行剥离所有的默认 backend_dir 引用
+sys.path = [p for p in sys.path if p and os.path.normpath(p) != os.path.normpath(backend_dir)]
+sys.path = [p for p in sys.path if p and p != "." and p != ""]
+
+# 绝对插队注入:优先搜索 PATCHES_DIR
+sys.path.insert(0, patch_dir)
+sys.path.insert(1, backend_dir)
+
+import uvicorn
+from ltx2_server import app
+
+if __name__ == '__main__':
+ uvicorn.run(app, host="0.0.0.0", port=3000, log_level="info", access_log=False)
+"""
+ launcher_path = os.path.join(PATCHES_DIR, "launcher.py")
+ with open(launcher_path, "w", encoding="utf-8") as f:
+ f.write(launcher_code)
+
+ cmd = [PYTHON_EXE, launcher_path]
+ env = os.environ.copy()
+ result = subprocess.run(cmd, cwd=BACKEND_DIR, env=env)
+ if result.returncode != 0:
+ print(f"\n\033[1;41m [致命错误] 核心引擎异常崩溃退出! (Exit Code: {result.returncode})\033[0m")
+ print(">> 请检查上述终端报错信息。确认显卡驱动是否正常。")
+ os._exit(1)
+
+ui_app = FastAPI()
+# 已移除存在安全隐患的静态资源挂载目录
+
+@ui_app.get("/")
+async def serve_index():
+ return FileResponse(os.path.join(os.getcwd(), UI_FILE_NAME))
+
+@ui_app.get("/index.css")
+async def serve_css():
+ return FileResponse(os.path.join(os.getcwd(), "UI/index.css"))
+
+@ui_app.get("/index.js")
+async def serve_js():
+ return FileResponse(os.path.join(os.getcwd(), "UI/index.js"))
+
+
+@ui_app.get("/i18n.js")
+async def serve_i18n():
+ return FileResponse(os.path.join(os.getcwd(), "UI/i18n.js"))
+
+
+def launch_ui_server():
+ print(f"\033[92m[UI] 工作站已就绪!\033[0m")
+ print(f"\033[92m[LOCAL] 本机访问: http://127.0.0.1:4000\033[0m")
+ print(f"\033[93m[WIFI] 局域网访问: http://{LAN_IP}:4000\033[0m")
+
+ # 彻底压制 WinError 10054 (客户端强制断开) 的底层警告报错
+ if sys.platform == 'win32':
+ # Uvicorn 内部会拉起循环,所以只能通过底层 Logging Filter 拦截控制台噪音
+ class UvicornAsyncioNoiseFilter(logging.Filter):
+ """压掉客户端断开、Win Proactor 管道收尾等无害 asyncio 控制台刷屏。"""
+
+ def filter(self, record):
+ if record.name != "asyncio":
+ return True
+ msg = record.getMessage()
+ if "_call_connection_lost" in msg or "_ProactorBasePipeTransport" in msg:
+ return False
+ if hasattr(record, "exc_info") and record.exc_info:
+ exc_type, exc_value, _ = record.exc_info
+ if isinstance(exc_value, ConnectionResetError) and getattr(
+ exc_value, "winerror", None
+ ) == 10054:
+ return False
+ if "10054" in msg and "ConnectionResetError" in msg:
+ return False
+ return True
+
+ logging.getLogger("asyncio").addFilter(UvicornAsyncioNoiseFilter())
+
+ uvicorn.run(ui_app, host="0.0.0.0", port=4000, log_level="warning", access_log=False)
+
+if __name__ == "__main__":
+ os.system('cls' if os.name == 'nt' else 'clear')
+ print("\033[1;97;44m LTX-2 CINEMATIC WORKSTATION | NETWORK ENABLED \033[0m\n")
+
+ threading.Thread(target=launch_backend, daemon=True).start()
+
+ # 强制校验 3000 端口是否存活
+ print("\033[93m[SYS] 正在等待内部核心 3000 端口启动...\033[0m")
+ backend_ready = False
+ for _ in range(30):
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ if s.connect_ex(('127.0.0.1', 3000)) == 0:
+ backend_ready = True
+ break
+ except Exception:
+ pass
+ time.sleep(1)
+
+ if backend_ready:
+ print("\033[92m[SYS] 3000 端口已通过连通性握手验证!后端装载成功。\033[0m")
+ else:
+ print("\033[1;41m [崩坏警告] 等待 30 秒后,3000 端口依然无法连通! \033[0m")
+ print(">> Uvicorn 可能在后台陷入了死锁,或者被防火墙拦截,前端大概率将无法连接到后端!")
+ print(">> 请检查上方是否有 Python 报错。\n")
+
+ try:
+ launch_ui_server()
+ except KeyboardInterrupt:
+ sys.exit(0)
\ No newline at end of file
diff --git "a/LTX2.3/patches/API\346\250\241\345\274\217\351\227\256\351\242\230\344\277\256\345\244\215\350\257\264\346\230\216.md" "b/LTX2.3/patches/API\346\250\241\345\274\217\351\227\256\351\242\230\344\277\256\345\244\215\350\257\264\346\230\216.md"
new file mode 100644
index 0000000000000000000000000000000000000000..6a8315a5893937612f2a354b599d732387b59e00
--- /dev/null
+++ "b/LTX2.3/patches/API\346\250\241\345\274\217\351\227\256\351\242\230\344\277\256\345\244\215\350\257\264\346\230\216.md"
@@ -0,0 +1,41 @@
+# LTX 本地显卡模式修复
+
+## 问题描述
+系统强制使用 FAL API 生成图片,即使本地有 GPU 可用。
+
+## 原因
+LTX 强制要求 GPU 有 31GB VRAM 才会使用本地显卡,低于此值会强制走 API 模式。
+
+## 修复方法
+
+### 方法一:自动替换(推荐)
+运行程序后,patches 目录中的文件会自动替换原版文件。
+
+### 方法二:手动替换
+
+#### 1. 修改 VRAM 阈值
+- **原文件**: `C:\Program Files\LTX Desktop\resources\backend\runtime_config\runtime_policy.py`
+- **找到** (第16行):
+ ```python
+ return vram_gb < 31
+ ```
+- **改为**:
+ ```python
+ return vram_gb < 6
+ ```
+
+#### 2. 清空无效 API Key
+- **原文件**: `C:\Users\Administrator\AppData\Local\LTXDesktop\settings.json`
+- **找到**:
+ ```json
+ "fal_api_key": "12123",
+ ```
+- **改为**:
+ ```json
+ "fal_api_key": "",
+ ```
+
+## 说明
+- VRAM 阈值改为 6GB,意味着 6GB 及以上显存都会使用本地显卡
+- 清空 fal_api_key 避免系统误判为已配置 API
+- 修改后重启程序即可生效
diff --git a/LTX2.3/patches/__pycache__/api_types.cpython-313.pyc b/LTX2.3/patches/__pycache__/api_types.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..028bb50411a19c566cf9907bb4aaf5d41d3dc01a
Binary files /dev/null and b/LTX2.3/patches/__pycache__/api_types.cpython-313.pyc differ
diff --git a/LTX2.3/patches/__pycache__/app_factory.cpython-313.pyc b/LTX2.3/patches/__pycache__/app_factory.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..e063bf72eb75d83cfcb205d49eaf907c6c98e0f4
Binary files /dev/null and b/LTX2.3/patches/__pycache__/app_factory.cpython-313.pyc differ
diff --git a/LTX2.3/patches/__pycache__/app_factory.cpython-314.pyc b/LTX2.3/patches/__pycache__/app_factory.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..cef8770d56c5651ba5e32c2a0cbd113626b86993
Binary files /dev/null and b/LTX2.3/patches/__pycache__/app_factory.cpython-314.pyc differ
diff --git a/LTX2.3/patches/__pycache__/keep_models_runtime.cpython-313.pyc b/LTX2.3/patches/__pycache__/keep_models_runtime.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..c9b4022c3acd41cd68bf7893d1c03b51f97ed49d
Binary files /dev/null and b/LTX2.3/patches/__pycache__/keep_models_runtime.cpython-313.pyc differ
diff --git a/LTX2.3/patches/__pycache__/lora_build_hook.cpython-313.pyc b/LTX2.3/patches/__pycache__/lora_build_hook.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..0c31ce1f32c7cebc4caac0011dfd337ace99b738
Binary files /dev/null and b/LTX2.3/patches/__pycache__/lora_build_hook.cpython-313.pyc differ
diff --git a/LTX2.3/patches/__pycache__/lora_injection.cpython-313.pyc b/LTX2.3/patches/__pycache__/lora_injection.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..98cc614c9860042e35aff6be3c4ef3ac45ffa4e5
Binary files /dev/null and b/LTX2.3/patches/__pycache__/lora_injection.cpython-313.pyc differ
diff --git a/LTX2.3/patches/__pycache__/low_vram_runtime.cpython-313.pyc b/LTX2.3/patches/__pycache__/low_vram_runtime.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..02c531e2d2692c3d723bcefaada8d02f118320b6
Binary files /dev/null and b/LTX2.3/patches/__pycache__/low_vram_runtime.cpython-313.pyc differ
diff --git a/LTX2.3/patches/api_types.py b/LTX2.3/patches/api_types.py
new file mode 100644
index 0000000000000000000000000000000000000000..968eada48f30052d8abceb3a7d36856571e8ac51
--- /dev/null
+++ b/LTX2.3/patches/api_types.py
@@ -0,0 +1,315 @@
+"""Pydantic request/response models and TypedDicts for ltx2_server."""
+
+from __future__ import annotations
+
+from typing import Literal, NamedTuple, TypeAlias, TypedDict
+from typing import Annotated
+
+from pydantic import BaseModel, Field, StringConstraints
+
+NonEmptyPrompt = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
+ModelFileType = Literal[
+ "checkpoint",
+ "upsampler",
+ "distilled_lora",
+ "ic_lora",
+ "depth_processor",
+ "person_detector",
+ "pose_processor",
+ "text_encoder",
+ "zit",
+]
+
+
+class ImageConditioningInput(NamedTuple):
+ """Image conditioning triplet used by all video pipelines."""
+
+ path: str
+ frame_idx: int
+ strength: float
+
+
+# ============================================================
+# TypedDicts for module-level state globals
+# ============================================================
+
+
+class GenerationState(TypedDict):
+ id: str | None
+ cancelled: bool
+ result: str | list[str] | None
+ error: str | None
+ status: str # "idle" | "running" | "complete" | "cancelled" | "error"
+ phase: str
+ progress: int
+ current_step: int
+ total_steps: int
+
+
+JsonObject: TypeAlias = dict[str, object]
+VideoCameraMotion = Literal[
+ "none",
+ "dolly_in",
+ "dolly_out",
+ "dolly_left",
+ "dolly_right",
+ "jib_up",
+ "jib_down",
+ "static",
+ "focus_shift",
+]
+
+
+# ============================================================
+# Response Models
+# ============================================================
+
+
+class ModelStatusItem(BaseModel):
+ id: str
+ name: str
+ loaded: bool
+ downloaded: bool
+
+
+class GpuTelemetry(BaseModel):
+ name: str
+ vram: int
+ vramUsed: int
+
+
+class HealthResponse(BaseModel):
+ status: str
+ models_loaded: bool
+ active_model: str | None
+ gpu_info: GpuTelemetry
+ sage_attention: bool
+ models_status: list[ModelStatusItem]
+
+
+class GpuInfoResponse(BaseModel):
+ cuda_available: bool
+ mps_available: bool = False
+ gpu_available: bool = False
+ gpu_name: str | None
+ vram_gb: int | None
+ gpu_info: GpuTelemetry
+
+
+class RuntimePolicyResponse(BaseModel):
+ force_api_generations: bool
+
+
+class GenerationProgressResponse(BaseModel):
+ status: str
+ phase: str
+ progress: int
+ currentStep: int | None
+ totalSteps: int | None
+
+
+class ModelInfo(BaseModel):
+ id: str
+ name: str
+ description: str
+
+
+class ModelFileStatus(BaseModel):
+ id: ModelFileType
+ name: str
+ description: str
+ downloaded: bool
+ size: int
+ expected_size: int
+ required: bool = True
+ is_folder: bool = False
+ optional_reason: str | None = None
+
+
+class TextEncoderStatus(BaseModel):
+ downloaded: bool
+ size_bytes: int
+ size_gb: float
+ expected_size_gb: float
+
+
+class ModelsStatusResponse(BaseModel):
+ models: list[ModelFileStatus]
+ all_downloaded: bool
+ total_size: int
+ downloaded_size: int
+ total_size_gb: float
+ downloaded_size_gb: float
+ models_path: str
+ has_api_key: bool
+ text_encoder_status: TextEncoderStatus
+ use_local_text_encoder: bool
+
+
+class DownloadProgressResponse(BaseModel):
+ status: str
+ current_downloading_file: ModelFileType | None
+ current_file_progress: int
+ total_progress: int
+ total_downloaded_bytes: int
+ expected_total_bytes: int
+ completed_files: set[ModelFileType]
+ all_files: set[ModelFileType]
+ error: str | None
+ speed_mbps: int
+
+
+class SuggestGapPromptResponse(BaseModel):
+ status: str = "success"
+ suggested_prompt: str
+
+
+class GenerateVideoResponse(BaseModel):
+ status: str
+ video_path: str | None = None
+
+
+class GenerateImageResponse(BaseModel):
+ status: str
+ image_paths: list[str] | None = None
+
+
+class CancelResponse(BaseModel):
+ status: str
+ id: str | None = None
+
+
+class RetakeResponse(BaseModel):
+ status: str
+ video_path: str | None = None
+ result: JsonObject | None = None
+
+
+class IcLoraExtractResponse(BaseModel):
+ conditioning: str
+ original: str
+ conditioning_type: Literal["canny", "depth"]
+ frame_time: float
+
+
+class IcLoraGenerateResponse(BaseModel):
+ status: str
+ video_path: str | None = None
+
+
+class ModelDownloadStartResponse(BaseModel):
+ status: str
+ message: str | None = None
+ sessionId: str | None = None
+
+
+class TextEncoderDownloadResponse(BaseModel):
+ status: str
+ message: str | None = None
+ sessionId: str | None = None
+
+
+class StatusResponse(BaseModel):
+ status: str
+
+
+class ErrorResponse(BaseModel):
+ error: str
+ message: str | None = None
+
+
+# ============================================================
+# Request Models
+# ============================================================
+
+
+class GenerateVideoRequest(BaseModel):
+ prompt: NonEmptyPrompt
+ resolution: str = "512p"
+ model: str = "fast"
+ cameraMotion: VideoCameraMotion = "none"
+ negativePrompt: str = ""
+ duration: str = "2"
+ fps: str = "24"
+ audio: str = "false"
+ imagePath: str | None = None
+ audioPath: str | None = None
+ startFramePath: str | None = None
+ endFramePath: str | None = None
+ # 多张图单次推理:latent 时间轴多锚点(Comfy LTXVAddGuideMulti 思路);≥2 路径时优先于首尾帧
+ keyframePaths: list[str] | None = None
+ # 与 keyframePaths 等长、0.1–1.0;不传则按 Comfy 类工作流自动降低中间帧强度,减轻闪烁
+ keyframeStrengths: list[float] | None = None
+ # 与 keyframePaths 等长,单位秒,落在 [0, 整段时长];全提供时按时间映射 latent,否则仍自动均分
+ keyframeTimes: list[float] | None = None
+ aspectRatio: Literal["16:9", "9:16"] = "16:9"
+ modelPath: str | None = None
+ loraPath: str | None = None
+ loraStrength: float = 1.0
+
+
+class GenerateImageRequest(BaseModel):
+ prompt: NonEmptyPrompt
+ width: int = 1024
+ height: int = 1024
+ numSteps: int = 4
+ numImages: int = 1
+
+
+def _default_model_types() -> set[ModelFileType]:
+ return set()
+
+
+class ModelDownloadRequest(BaseModel):
+ modelTypes: set[ModelFileType] = Field(default_factory=_default_model_types)
+
+
+class RequiredModelsResponse(BaseModel):
+ modelTypes: list[ModelFileType]
+
+
+class SuggestGapPromptRequest(BaseModel):
+ beforePrompt: str = ""
+ afterPrompt: str = ""
+ beforeFrame: str | None = None
+ afterFrame: str | None = None
+ gapDuration: float = 5
+ mode: str = "t2v"
+ inputImage: str | None = None
+
+
+class RetakeRequest(BaseModel):
+ video_path: str
+ start_time: float = 0
+ duration: float = 0
+ prompt: str = ""
+ mode: str = "replace_video_only"
+ width: int | None = None
+ height: int | None = None
+
+
+class IcLoraExtractRequest(BaseModel):
+ video_path: str
+ conditioning_type: Literal["canny", "depth"] = "canny"
+ frame_time: float = 0
+
+
+class IcLoraImageInput(BaseModel):
+ path: str
+ frame: int = 0
+ strength: float = 1.0
+
+
+def _default_ic_lora_images() -> list[IcLoraImageInput]:
+ return []
+
+
+class IcLoraGenerateRequest(BaseModel):
+ video_path: str
+ conditioning_type: Literal["canny", "depth"]
+ prompt: NonEmptyPrompt
+ conditioning_strength: float = 1.0
+ num_inference_steps: int = 30
+ cfg_guidance_scale: float = 1.0
+ negative_prompt: str = ""
+ images: list[IcLoraImageInput] = Field(default_factory=_default_ic_lora_images)
diff --git a/LTX2.3/patches/app_factory.py b/LTX2.3/patches/app_factory.py
new file mode 100644
index 0000000000000000000000000000000000000000..a7d0882c39d2d8794e87a8bf69835ae86789ad85
--- /dev/null
+++ b/LTX2.3/patches/app_factory.py
@@ -0,0 +1,2288 @@
+"""FastAPI app factory decoupled from runtime bootstrap side effects."""
+
+from __future__ import annotations
+
+import base64
+import hmac
+import os
+
+# 防 OOM 与显存碎片化补丁:在 torch 初始化之前注入环境变量
+os.environ["PYTORCH_ALLOC_CONF"] = "expandable_segments:True"
+import torch # 提升到顶层导入
+from collections.abc import Awaitable, Callable
+from typing import TYPE_CHECKING
+from pathlib import Path # 必须导入,用于处理 Windows 路径
+
+from fastapi import FastAPI, Request, UploadFile, File
+from fastapi.exceptions import RequestValidationError
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+from pydantic import ConfigDict
+from fastapi.staticfiles import StaticFiles # 必须导入,用于挂载静态目录
+from starlette.responses import Response as StarletteResponse
+import shutil
+import tempfile
+import time
+from api_types import (
+ GenerateVideoRequest,
+ GenerateVideoResponse,
+ ImageConditioningInput,
+)
+
+from _routes._errors import HTTPError
+from _routes.generation import router as generation_router
+from _routes.health import router as health_router
+from _routes.ic_lora import router as ic_lora_router
+from _routes.image_gen import router as image_gen_router
+from _routes.models import router as models_router
+from _routes.suggest_gap_prompt import router as suggest_gap_prompt_router
+from _routes.retake import router as retake_router
+from _routes.runtime_policy import router as runtime_policy_router
+from _routes.settings import router as settings_router
+from logging_policy import log_http_error, log_unhandled_exception
+from state import init_state_service
+
+if TYPE_CHECKING:
+ from app_handler import AppHandler
+
+# 跨域配置:允许所有来源,解决本地网页调用限制
+DEFAULT_ALLOWED_ORIGINS: list[str] = ["*"]
+
+
+def _ltx_desktop_config_dir() -> Path:
+ p = (
+ Path(os.environ.get("LOCALAPPDATA", os.path.expanduser("~/AppData/Local")))
+ / "LTXDesktop"
+ )
+ p.mkdir(parents=True, exist_ok=True)
+ return p.resolve()
+
+
+def _extend_generate_video_request_model() -> None:
+ """Keep custom video fields working across upstream request-model changes."""
+ annotations = dict(getattr(GenerateVideoRequest, "__annotations__", {}))
+ changed = False
+
+ for field_name, ann in (
+ ("startFramePath", str | None),
+ ("endFramePath", str | None),
+ ("keyframePaths", list[str] | None),
+ ("keyframeStrengths", list[float] | None),
+ ("keyframeTimes", list[float] | None),
+ ):
+ if field_name not in annotations:
+ annotations[field_name] = ann
+ setattr(GenerateVideoRequest, field_name, None)
+ changed = True
+
+ if changed:
+ GenerateVideoRequest.__annotations__ = annotations
+
+ existing_config = dict(getattr(GenerateVideoRequest, "model_config", {}) or {})
+ if existing_config.get("extra") != "allow":
+ existing_config["extra"] = "allow"
+ GenerateVideoRequest.model_config = ConfigDict(**existing_config)
+ changed = True
+
+ if changed:
+ GenerateVideoRequest.model_rebuild(force=True)
+
+
+def create_app(
+ *,
+ handler: "AppHandler",
+ allowed_origins: list[str] | None = None,
+ title: str = "LTX-2 Video Generation Server",
+ auth_token: str = "",
+ admin_token: str = "",
+) -> FastAPI:
+ """Create a configured FastAPI app bound to the provided handler."""
+ init_state_service(handler)
+ _extend_generate_video_request_model()
+
+ app = FastAPI(title=title)
+ app.state.admin_token = admin_token # type: ignore[attr-defined]
+
+ # 彻底压制 WinError 10054 (客户端强制断开) 的底层警告报错
+ import sys, asyncio
+
+ if sys.platform == "win32":
+ try:
+ loop = asyncio.get_event_loop()
+
+ def silence_winerror_10054(loop, context):
+ exc = context.get("exception")
+ if (
+ isinstance(exc, ConnectionResetError)
+ and getattr(exc, "winerror", None) == 10054
+ ):
+ return
+ loop.default_exception_handler(context)
+
+ loop.set_exception_handler(silence_winerror_10054)
+ except Exception:
+ pass
+
+ # --- 核心修复:对准 LTX 真正的输出目录 (AppData) ---
+ def get_dynamic_output_path():
+ base_dir = (
+ Path(os.environ.get("LOCALAPPDATA", os.path.expanduser("~/AppData/Local")))
+ / "LTXDesktop"
+ ).resolve()
+ config_file = base_dir / "custom_dir.txt"
+ if config_file.exists():
+ try:
+ custom_dir = config_file.read_text(encoding="utf-8").strip()
+ if custom_dir:
+ p = Path(custom_dir)
+ p.mkdir(parents=True, exist_ok=True)
+ return p
+ except Exception:
+ pass
+ default_dir = base_dir / "outputs"
+ default_dir.mkdir(parents=True, exist_ok=True)
+ return default_dir
+
+ actual_output_path = get_dynamic_output_path()
+ handler.config.outputs_dir = actual_output_path
+
+ pl = handler.pipelines
+ pl._pipeline_signature = None
+ from low_vram_runtime import (
+ install_low_vram_on_pipelines,
+ install_low_vram_pipeline_hooks,
+ )
+
+ install_low_vram_on_pipelines(handler)
+ install_low_vram_pipeline_hooks(pl)
+ # LoRA:在 SingleGPUModelBuilder.build 时合并权重(model_ledger 不足以让桌面版 DiT 吃到 LoRA)
+ from lora_build_hook import install_lora_build_hook
+
+ install_lora_build_hook()
+
+ upload_tmp_path = actual_output_path / "uploads"
+
+ # 如果文件夹不存在则创建,防止挂载失败
+ if not actual_output_path.exists():
+ actual_output_path.mkdir(parents=True, exist_ok=True)
+ if not upload_tmp_path.exists():
+ upload_tmp_path.mkdir(parents=True, exist_ok=True)
+
+ # 挂载静态服务:将该目录映射到 http://127.0.0.1:3000/outputs
+ app.mount(
+ "/outputs", StaticFiles(directory=str(actual_output_path)), name="outputs"
+ )
+ # -----------------------------------------------
+
+ # 配置 CORS
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=allowed_origins or DEFAULT_ALLOWED_ORIGINS,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+ # === [全局隔离补丁] ===
+ # 强制将每一个新的 HTTP 线程/协程请求的默认显卡都强绑定到用户选定的设备上
+ @app.middleware("http")
+ async def _sync_gpu_middleware(
+ request: Request,
+ call_next: Callable[[Request], Awaitable[StarletteResponse]],
+ ) -> StarletteResponse:
+ import torch
+
+ if (
+ torch.cuda.is_available()
+ and getattr(handler.config.device, "type", "") == "cuda"
+ ):
+ idx = handler.config.device.index
+ if idx is not None:
+ # 能够强行夺取那些底层写死了 cuda:0 而忽略 config.device 的第三方库
+ torch.cuda.set_device(idx)
+ return await call_next(request)
+
+ # 认证中间件
+ @app.middleware("http")
+ async def _auth_middleware(
+ request: Request,
+ call_next: Callable[[Request], Awaitable[StarletteResponse]],
+ ) -> StarletteResponse:
+ # 关键修复:如果是获取生成的图片,直接放行,不检查 Token
+ if (
+ request.url.path.startswith("/outputs")
+ or request.url.path == "/api/system/upload-image"
+ ):
+ return await call_next(request)
+
+ if not auth_token:
+ return await call_next(request)
+ if request.method == "OPTIONS":
+ return await call_next(request)
+
+ def _token_matches(candidate: str) -> bool:
+ return hmac.compare_digest(candidate, auth_token)
+
+ # WebSocket 认证
+ if request.headers.get("upgrade", "").lower() == "websocket":
+ if _token_matches(request.query_params.get("token", "")):
+ return await call_next(request)
+ return JSONResponse(status_code=401, content={"error": "Unauthorized"})
+
+ # HTTP 认证 (Bearer/Basic)
+ auth_header = request.headers.get("authorization", "")
+ if auth_header.startswith("Bearer ") and _token_matches(auth_header[7:]):
+ return await call_next(request)
+ if auth_header.startswith("Basic "):
+ try:
+ decoded = base64.b64decode(auth_header[6:]).decode()
+ _, _, password = decoded.partition(":")
+ if _token_matches(password):
+ return await call_next(request)
+ except Exception:
+ pass
+ return JSONResponse(status_code=401, content={"error": "Unauthorized"})
+
+ # 异常处理逻辑
+ _FALLBACK = "An unexpected error occurred"
+
+ async def _route_http_error_handler(
+ request: Request, exc: Exception
+ ) -> JSONResponse:
+ if isinstance(exc, HTTPError):
+ log_http_error(request, exc)
+ return JSONResponse(
+ status_code=exc.status_code, content={"error": exc.detail or _FALLBACK}
+ )
+ return JSONResponse(status_code=500, content={"error": str(exc) or _FALLBACK})
+
+ async def _validation_error_handler(
+ request: Request, exc: Exception
+ ) -> JSONResponse:
+ if isinstance(exc, RequestValidationError):
+ return JSONResponse(
+ status_code=422, content={"error": str(exc) or _FALLBACK}
+ )
+ return JSONResponse(status_code=422, content={"error": str(exc) or _FALLBACK})
+
+ async def _route_generic_error_handler(
+ request: Request, exc: Exception
+ ) -> JSONResponse:
+ log_unhandled_exception(request, exc)
+ return JSONResponse(status_code=500, content={"error": str(exc) or _FALLBACK})
+
+ app.add_exception_handler(RequestValidationError, _validation_error_handler)
+ app.add_exception_handler(HTTPError, _route_http_error_handler)
+ app.add_exception_handler(Exception, _route_generic_error_handler)
+
+ # --- 系统功能接口 ---
+ @app.post("/api/system/clear-gpu")
+ async def route_clear_gpu():
+ try:
+ import torch
+ import gc
+ import asyncio
+
+ # 1. 尝试终止任务并重置运行状态
+ if getattr(handler.generation, "is_generation_running", lambda: False)():
+ try:
+ handler.generation.cancel_generation()
+ except Exception:
+ pass
+ await asyncio.sleep(0.5)
+
+ # 暴力重置死锁状态
+ if hasattr(handler.generation, "_generation_id"):
+ handler.generation._generation_id = None
+ if hasattr(handler.generation, "_is_generating"):
+ handler.generation._is_generating = False
+
+ # 2. 强制卸载模型: 临时屏蔽底层锁定器
+ try:
+ mock_swapped = False
+ orig_running = None
+ if hasattr(handler.pipelines, "_generation_service"):
+ orig_running = (
+ handler.pipelines._generation_service.is_generation_running
+ )
+ handler.pipelines._generation_service.is_generation_running = (
+ lambda: False
+ )
+ mock_swapped = True
+ try:
+ from keep_models_runtime import force_unload_gpu_pipeline
+
+ force_unload_gpu_pipeline(handler.pipelines)
+ finally:
+ if mock_swapped:
+ handler.pipelines._generation_service.is_generation_running = (
+ orig_running
+ )
+ except Exception as e:
+ print(f"Force unload warning: {e}")
+
+ # 3. 深度清理
+ gc.collect()
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+ torch.cuda.ipc_collect()
+ try:
+ handler.pipelines._pipeline_signature = None
+ except Exception:
+ pass
+ return {
+ "status": "success",
+ "message": "GPU memory cleared and models unloaded",
+ }
+ except Exception as e:
+ return JSONResponse(status_code=500, content={"error": str(e)})
+
+ @app.get("/api/system/low-vram-mode")
+ async def route_get_low_vram_mode():
+ enabled = bool(getattr(handler.pipelines, "low_vram_mode", False))
+ return {"enabled": enabled}
+
+ @app.post("/api/system/low-vram-mode")
+ async def route_set_low_vram_mode(request: Request):
+ try:
+ data = await request.json()
+ except Exception:
+ data = {}
+ enabled = bool(data.get("enabled", False))
+ from low_vram_runtime import (
+ apply_low_vram_config_tweaks,
+ write_low_vram_pref,
+ )
+
+ handler.pipelines.low_vram_mode = enabled
+ write_low_vram_pref(enabled)
+ if enabled:
+ apply_low_vram_config_tweaks(handler)
+ return {"status": "success", "enabled": enabled}
+
+ @app.post("/api/system/reset-state")
+ async def route_reset_state():
+ """轻量级状态重置:只清除 generation 状态锁,不卸载 GPU 管线。
+ 在每次新渲染开始前由前端调用,确保后端状态干净可用。"""
+ try:
+ gen = handler.generation
+ # 强制清除所有可能导致 is_generation_running() 返回 True 的标志
+ for attr in (
+ "_is_generating",
+ "_generation_id",
+ "_cancelled",
+ "_is_cancelled",
+ ):
+ if hasattr(gen, attr):
+ if attr in ("_is_generating", "_cancelled", "_is_cancelled"):
+ setattr(gen, attr, False)
+ else:
+ setattr(gen, attr, None)
+ # 某些实现用 threading.Event
+ for attr in ("_cancel_event",):
+ if hasattr(gen, attr):
+ try:
+ getattr(gen, attr).clear()
+ except Exception:
+ pass
+ print("[reset-state] Generation state has been reset cleanly.")
+ return {"status": "success", "message": "Generation state reset"}
+ except Exception as e:
+ import traceback
+
+ traceback.print_exc()
+ return JSONResponse(status_code=500, content={"error": str(e)})
+
+ @app.post("/api/system/set-dir")
+ async def route_set_dir(request: Request):
+ try:
+ data = await request.json()
+ new_dir = data.get("directory", "").strip()
+ base_dir = (
+ Path(
+ os.environ.get(
+ "LOCALAPPDATA", os.path.expanduser("~/AppData/Local")
+ )
+ )
+ / "LTXDesktop"
+ ).resolve()
+ config_file = base_dir / "custom_dir.txt"
+ if new_dir:
+ p = Path(new_dir)
+ p.mkdir(parents=True, exist_ok=True)
+ config_file.write_text(new_dir, encoding="utf-8")
+ else:
+ if config_file.exists():
+ config_file.unlink()
+ # 立即更新全局 config 控制
+ handler.config.outputs_dir = get_dynamic_output_path()
+ return {"status": "success", "directory": str(get_dynamic_output_path())}
+ except Exception as e:
+ return JSONResponse(status_code=500, content={"error": str(e)})
+
+ @app.get("/api/system/get-dir")
+ async def route_get_dir():
+ return {"status": "success", "directory": str(get_dynamic_output_path())}
+
+ @app.get("/api/system/browse-dir")
+ async def route_browse_dir():
+ try:
+ import subprocess
+
+ # 强制将对话框置顶层:通过 STA 线程 + Topmost 属性,避免被窗口锥入后台
+ ps_script = (
+ "[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null;"
+ "[System.Reflection.Assembly]::LoadWithPartialName('System.Drawing') | Out-Null;"
+ "$f = New-Object System.Windows.Forms.FolderBrowserDialog;"
+ "$f.Description = '\u9009\u62e9 LTX \u89c6\u9891\u548c\u56fe\u50cf\u751f\u6210\u7684\u5168\u5c40\u8f93\u51fa\u76ee\u5f55';"
+ "$f.ShowNewFolderButton = $true;"
+ # 创建一个雐形助手窗口作为 parent 确保对话框在最顶层
+ "$owner = New-Object System.Windows.Forms.Form;"
+ "$owner.TopMost = $true;"
+ "$owner.StartPosition = 'CenterScreen';"
+ "$owner.Size = New-Object System.Drawing.Size(1, 1);"
+ "$owner.Show();"
+ "$owner.BringToFront();"
+ "$owner.Focus();"
+ "if ($f.ShowDialog($owner) -eq 'OK') { echo $f.SelectedPath };"
+ "$owner.Dispose();"
+ )
+
+ def run_ps():
+ process = subprocess.Popen(
+ ["powershell", "-STA", "-NoProfile", "-Command", ps_script],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ # 移除 CREATE_NO_WINDOW 以允许 UI 线程正常弹出
+ )
+ stdout, _ = process.communicate()
+ return stdout.strip()
+
+ from starlette.concurrency import run_in_threadpool
+
+ selected_dir = await run_in_threadpool(run_ps)
+ return {"status": "success", "directory": selected_dir}
+ except Exception as e:
+ return JSONResponse(status_code=500, content={"error": str(e)})
+
+ _LORA_SCAN_SUFFIXES = {".safetensors", ".ckpt", ".pt", ".bin"}
+
+ @app.get("/api/loras")
+ async def route_list_loras(request: Request):
+ """扫描本地 LoRA 目录;前端「设置」里填的路径依赖此接口(官方路由可能不存在)。"""
+ raw = (request.query_params.get("dir") or "").strip()
+ if raw.startswith("True"):
+ raw = raw[4:].lstrip()
+ raw = raw.strip().strip('"').strip("'")
+ if not raw:
+ # 默认规则:LoRA 路径 = 默认 models_dir 下的 `loras` 子目录(规则写死)
+ try:
+ md = getattr(handler.pipelines, "models_dir", None)
+ if md:
+ from pathlib import Path
+
+ root = Path(str(md)).expanduser().resolve() / "loras"
+ raw = str(root)
+ except Exception:
+ raw = ""
+ if not raw:
+ return {"loras": [], "loras_dir": "", "models_dir": ""}
+
+ root = Path(raw).expanduser()
+ try:
+ root = root.resolve()
+ except OSError:
+ pass
+
+ if not root.is_dir():
+ return {
+ "loras": [],
+ "error": "not_a_directory",
+ "message": "路径不是文件夹或不存在,请检查拼写、盘符与权限",
+ "path": str(root),
+ "loras_dir": str(root),
+ "models_dir": str(root.parent),
+ }
+
+ found: list[dict[str, str]] = []
+ try:
+ for dirpath, _dirnames, filenames in os.walk(root):
+ for fn in filenames:
+ suf = Path(fn).suffix.lower()
+ if suf in _LORA_SCAN_SUFFIXES:
+ full = Path(dirpath) / fn
+ if full.is_file():
+ try:
+ resolved = str(full.resolve())
+ except OSError:
+ resolved = str(full)
+ found.append({"name": fn, "path": resolved})
+ except OSError as e:
+ return JSONResponse(
+ status_code=400,
+ content={
+ "loras": [],
+ "error": "scan_failed",
+ "message": str(e),
+ "path": str(root),
+ },
+ )
+
+ found.sort(key=lambda x: x["name"].lower())
+ return {
+ "loras": found,
+ "loras_dir": str(root),
+ "models_dir": str(root.parent),
+ }
+
+ _MODEL_SCAN_SUFFIXES = {
+ ".safetensors",
+ ".ckpt",
+ ".pt",
+ ".bin",
+ ".pth",
+ }
+
+ @app.get("/api/models")
+ async def route_list_models(request: Request):
+ """扫描本地 checkpoint 目录;需在官方 models_router 之前注册以覆盖空列表行为。"""
+ raw = (request.query_params.get("dir") or "").strip()
+ if raw.startswith("True"):
+ raw = raw[4:].lstrip()
+ raw = raw.strip().strip('"').strip("'")
+
+ if not raw:
+ try:
+ md = getattr(handler.pipelines, "models_dir", None)
+ if md is None or not str(md).strip():
+ return {"models": []}
+ root = Path(str(md)).expanduser().resolve()
+ except OSError:
+ return {"models": []}
+ if not root.is_dir():
+ return {"models": []}
+ else:
+ root = Path(raw).expanduser()
+ try:
+ root = root.resolve()
+ except OSError:
+ pass
+
+ if not root.is_dir():
+ return {
+ "models": [],
+ "error": "not_a_directory",
+ "message": "路径不是文件夹或不存在,请检查拼写、盘符与权限",
+ "path": str(root),
+ }
+
+ found: list[dict[str, str]] = []
+ try:
+ for dirpath, _dirnames, filenames in os.walk(root):
+ for fn in filenames:
+ suf = Path(fn).suffix.lower()
+ if suf in _MODEL_SCAN_SUFFIXES:
+ full = Path(dirpath) / fn
+ if full.is_file():
+ try:
+ resolved = str(full.resolve())
+ except OSError:
+ resolved = str(full)
+ found.append({"name": fn, "path": resolved})
+ except OSError as e:
+ return JSONResponse(
+ status_code=400,
+ content={
+ "models": [],
+ "error": "scan_failed",
+ "message": str(e),
+ "path": str(root),
+ },
+ )
+
+ found.sort(key=lambda x: x["name"].lower())
+ return {"models": found}
+
+ @app.get("/api/system/file")
+ async def route_serve_file(path: str):
+ from fastapi.responses import FileResponse
+
+ if os.path.exists(path):
+ return FileResponse(path)
+ return JSONResponse(status_code=404, content={"error": "File not found"})
+
+ @app.get("/api/system/list-gpus")
+ async def route_list_gpus():
+ try:
+ import torch
+
+ gpus = []
+ if torch.cuda.is_available():
+ current_idx = 0
+ dev = getattr(handler.config, "device", None)
+ if dev is not None and getattr(dev, "index", None) is not None:
+ current_idx = dev.index
+ for i in range(torch.cuda.device_count()):
+ try:
+ name = torch.cuda.get_device_name(i)
+ except Exception:
+ name = f"GPU {i}"
+ try:
+ vram_bytes = torch.cuda.get_device_properties(i).total_memory
+ vram_gb = vram_bytes / (1024**3)
+ vram_mb = vram_bytes / (1024**2)
+ except Exception:
+ vram_gb = 0.0
+ vram_mb = 0
+ gpus.append(
+ {
+ "id": i,
+ "name": name,
+ "vram": f"{vram_gb:.1f} GB",
+ "vram_mb": int(vram_mb),
+ "active": (i == current_idx),
+ }
+ )
+ return {"status": "success", "gpus": gpus}
+ except Exception as e:
+ return JSONResponse(status_code=500, content={"error": str(e)})
+
+ @app.post("/api/system/switch-gpu")
+ async def route_switch_gpu(request: Request):
+ try:
+ import torch
+ import gc
+ import asyncio
+
+ data = await request.json()
+ gpu_id = data.get("gpu_id")
+
+ if (
+ gpu_id is None
+ or not torch.cuda.is_available()
+ or gpu_id >= torch.cuda.device_count()
+ ):
+ return JSONResponse(
+ status_code=400, content={"error": "Invalid GPU ID"}
+ )
+
+ # 先尝试终止任何可能的卡死任务
+ if getattr(handler.generation, "is_generation_running", lambda: False)():
+ try:
+ handler.generation.cancel_generation()
+ except Exception:
+ pass
+ await asyncio.sleep(0.5)
+ if hasattr(handler.generation, "_generation_id"):
+ handler.generation._generation_id = None
+ if hasattr(handler.generation, "_is_generating"):
+ handler.generation._is_generating = False
+
+ # 1. 卸载当前 GPU 上的模型: 临时屏蔽底层锁定器
+ try:
+ mock_swapped = False
+ orig_running = None
+ if hasattr(handler.pipelines, "_generation_service"):
+ orig_running = (
+ handler.pipelines._generation_service.is_generation_running
+ )
+ handler.pipelines._generation_service.is_generation_running = (
+ lambda: False
+ )
+ mock_swapped = True
+ try:
+ from keep_models_runtime import force_unload_gpu_pipeline
+
+ force_unload_gpu_pipeline(handler.pipelines)
+ finally:
+ if mock_swapped:
+ handler.pipelines._generation_service.is_generation_running = (
+ orig_running
+ )
+ except Exception:
+ pass
+ gc.collect()
+ torch.cuda.empty_cache()
+
+ try:
+ handler.pipelines._pipeline_signature = None
+ except Exception:
+ pass
+
+ # 2. 切换全局设备配置
+ new_device = torch.device(f"cuda:{gpu_id}")
+ handler.config.device = new_device
+
+ # 3. 核心修复:设置当前进程的默认 CUDA 设备
+ # 这会影响到 torch.cuda.current_device() 和后续的模型加载
+ torch.cuda.set_device(gpu_id)
+
+ # 针对底层库可能直接读取 CUDA_VISIBLE_DEVICES 的情况
+ # 注意:torch 初始化后修改此变量不一定生效,但对某些库可能有引导作用
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(gpu_id)
+
+ # 4. 【核心修复】同步更新 TextEncoder 的设备指针
+ # 根本原因: LTXTextEncoder.self.device 在初始化时硬绑定了旧 GPU,
+ # 切换设备后 text context 仍在旧 GPU 上,与已迁移到新 GPU 的
+ # Transformer 产生 "cuda:0 and cuda:1" 设备不一致冲突。
+ try:
+ te_state = None
+ # 尝试多种路径访问 text_encoder 状态
+ if hasattr(handler, "state") and hasattr(handler.state, "text_encoder"):
+ te_state = handler.state.text_encoder
+ elif hasattr(handler, "_state") and hasattr(
+ handler._state, "text_encoder"
+ ):
+ te_state = handler._state.text_encoder
+
+ if te_state is not None:
+ # 4a. 更新 LTXTextEncoder 服务自身的 device 属性
+ if hasattr(te_state, "service") and hasattr(
+ te_state.service, "device"
+ ):
+ te_state.service.device = new_device
+ print(f"[TextEncoder] device updated to {new_device}")
+
+ # 4b. 将缓存的 encoder 权重迁移到 CPU,下次推理时再按新设备重加载
+ if (
+ hasattr(te_state, "cached_encoder")
+ and te_state.cached_encoder is not None
+ ):
+ try:
+ te_state.cached_encoder.to(torch.device("cpu"))
+ except Exception:
+ pass
+ te_state.cached_encoder = None
+ print(
+ "[TextEncoder] cached encoder cleared (will reload on new GPU)"
+ )
+
+ # 4c. 清除 API embeddings 缓存(tensor 绑定旧 GPU)
+ if hasattr(te_state, "api_embeddings"):
+ te_state.api_embeddings = None
+
+ # 4d. 清除 prompt cache(其中 tensor 也绑定旧 GPU)
+ if hasattr(te_state, "prompt_cache") and te_state.prompt_cache:
+ te_state.prompt_cache.clear()
+ print("[TextEncoder] prompt cache cleared")
+ except Exception as _te_err:
+ print(f"[TextEncoder] device sync warning (non-fatal): {_te_err}")
+
+ print(
+ f"Switched active GPU to: {torch.cuda.get_device_name(gpu_id)} (ID: {gpu_id})"
+ )
+ return {"status": "success", "message": f"Switched to GPU {gpu_id}"}
+ except Exception as e:
+ return JSONResponse(status_code=500, content={"error": str(e)})
+
+ # --- 核心增强:首尾帧插值与视频超分支持 ---
+ from handlers.video_generation_handler import VideoGenerationHandler
+ from services.retake_pipeline.ltx_retake_pipeline import LTXRetakePipeline
+ from server_utils.media_validation import normalize_optional_path
+ from PIL import Image
+
+ # 1. 增强插值功能 (Monkey Patch VideoGenerationHandler)
+ _orig_generate = VideoGenerationHandler.generate
+ _orig_generate_video = VideoGenerationHandler.generate_video
+
+ def patched_generate(self, req: GenerateVideoRequest):
+ # === [DEBUG] 打印当前生成状态 ===
+ gen = self._generation
+ is_running = (
+ gen.is_generation_running()
+ if hasattr(gen, "is_generation_running")
+ else "?方法不存在"
+ )
+ gen_id = getattr(gen, "_generation_id", "?属性不存在")
+ is_gen = getattr(gen, "_is_generating", "?属性不存在")
+ cancelled = getattr(
+ gen, "_cancelled", getattr(gen, "_is_cancelled", "?属性不存在")
+ )
+ print(f"\n[PATCH][patched_generate] ==> 收到新请求")
+ print(f" is_generation_running() = {is_running}")
+ print(f" _generation_id = {gen_id}")
+ print(f" _is_generating = {is_gen}")
+ print(f" _cancelled = {cancelled}")
+ start_frame_path = normalize_optional_path(getattr(req, "startFramePath", None))
+ end_frame_path = normalize_optional_path(getattr(req, "endFramePath", None))
+ _raw_kf = getattr(req, "keyframePaths", None)
+ keyframe_paths_list: list[str] = []
+ if isinstance(_raw_kf, list):
+ for p in _raw_kf:
+ np = normalize_optional_path(p)
+ if np:
+ keyframe_paths_list.append(np)
+ use_multi_keyframes = len(keyframe_paths_list) >= 2
+ _raw_kf_st = getattr(req, "keyframeStrengths", None)
+ keyframe_strengths_list: list[float] | None = None
+ if isinstance(_raw_kf_st, list) and _raw_kf_st:
+ try:
+ keyframe_strengths_list = [float(x) for x in _raw_kf_st]
+ except (TypeError, ValueError):
+ keyframe_strengths_list = None
+ _raw_kf_t = getattr(req, "keyframeTimes", None)
+ keyframe_times_list: list[float] | None = None
+ if isinstance(_raw_kf_t, list) and _raw_kf_t:
+ try:
+ keyframe_times_list = [float(x) for x in _raw_kf_t]
+ except (TypeError, ValueError):
+ keyframe_times_list = None
+ aspect_ratio = getattr(req, "aspectRatio", None)
+ print(f" startFramePath = {start_frame_path}")
+ print(f" endFramePath = {end_frame_path}")
+ print(f" keyframePaths (n={len(keyframe_paths_list)}) = {use_multi_keyframes}")
+ print(f" aspectRatio = {aspect_ratio}")
+
+ # 检查是否有音频
+ audio_path = normalize_optional_path(getattr(req, "audioPath", None))
+ print(f"[PATCH] audio_path = {audio_path}")
+
+ # 检查是否有图片(图生视频)
+ image_path = normalize_optional_path(getattr(req, "imagePath", None))
+ print(f"[PATCH] image_path = {image_path}")
+
+ # 始终使用自定义逻辑(支持首尾帧和竖屏)
+ print(f"[PATCH] 使用自定义逻辑处理")
+
+ # 计算分辨率
+ import uuid
+
+ resolution = req.resolution
+ duration = int(float(req.duration))
+ fps = int(float(req.fps))
+
+ # 宽高均需为 64 的倍数(LTX 内核校验);在近似 16:9 下取整
+ RESOLUTION_MAP = {
+ "540p": (1024, 576),
+ "720p": (1280, 704),
+ "1080p": (1920, 1088),
+ }
+
+ def get_16_9_size(res):
+ return RESOLUTION_MAP.get(res, (1280, 704))
+
+ def get_9_16_size(res):
+ w, h = get_16_9_size(res)
+ return h, w # 交换宽高
+
+ if req.aspectRatio == "9:16":
+ width, height = get_9_16_size(resolution)
+ else:
+ width, height = get_16_9_size(resolution)
+
+ # 计算帧数
+ num_frames = ((duration * fps) // 8) * 8 + 1
+ num_frames = max(num_frames, 9)
+
+ print(f"[PATCH] 计算得到的分辨率: {width}x{height}, 帧数: {num_frames}")
+
+ # 多关键帧单次推理时勿用首尾帧属性,避免与 keyframe 列表重复
+ if use_multi_keyframes:
+ self._start_frame_path = None
+ self._end_frame_path = None
+ image_path_for_video = None
+ else:
+ self._start_frame_path = start_frame_path
+ self._end_frame_path = end_frame_path
+ image_path_for_video = image_path
+
+ # 无论有没有音频,都使用自定义逻辑支持首尾帧 / 多关键帧
+ try:
+ result = patched_generate_video(
+ self,
+ prompt=req.prompt,
+ image=None,
+ image_path=image_path_for_video,
+ height=height,
+ width=width,
+ num_frames=num_frames,
+ fps=fps,
+ seed=self._resolve_seed(),
+ camera_motion=req.cameraMotion,
+ negative_prompt=req.negativePrompt,
+ audio_path=audio_path,
+ lora_path=getattr(req, "loraPath", None),
+ lora_strength=float(getattr(req, "loraStrength", 1.0) or 1.0),
+ keyframe_paths=keyframe_paths_list if use_multi_keyframes else None,
+ keyframe_strengths=(
+ keyframe_strengths_list if use_multi_keyframes else None
+ ),
+ keyframe_times=(
+ keyframe_times_list if use_multi_keyframes else None
+ ),
+ )
+ print(f"[PATCH][patched_generate] <== 完成, 返回状态: complete")
+ return type("Response", (), {"status": "complete", "video_path": result})()
+ except Exception as e:
+ import traceback
+
+ print(f"[PATCH][patched_generate] 错误: {e}")
+ traceback.print_exc()
+ raise
+
+ def patched_generate_video(
+ self,
+ prompt,
+ image,
+ image_path=None,
+ height=None,
+ width=None,
+ num_frames=None,
+ fps=None,
+ seed=None,
+ camera_motion=None,
+ negative_prompt=None,
+ audio_path=None,
+ lora_path=None,
+ lora_strength=1.0,
+ keyframe_paths: list[str] | None = None,
+ keyframe_strengths: list[float] | None = None,
+ keyframe_times: list[float] | None = None,
+ ):
+ # === [DEBUG] 打印当前生成状态 ===
+ gen = self._generation
+ is_running = (
+ gen.is_generation_running()
+ if hasattr(gen, "is_generation_running")
+ else "?方法不存在"
+ )
+ gen_id = getattr(gen, "_generation_id", "?属性不存在")
+ is_gen = getattr(gen, "_is_generating", "?属性不存在")
+ print(f"[PATCH][patched_generate_video] ==> 开始推理")
+ print(f" is_generation_running() = {is_running}")
+ print(f" _generation_id = {gen_id}")
+ print(f" _is_generating = {is_gen}")
+ print(
+ f" resolution = {width}x{height}, frames={num_frames}, fps={fps}"
+ )
+ print(f" image param = {type(image)}, {image is not None}")
+ print(f" image_path = {image_path}")
+ # ==================================
+ from ltx_pipelines.utils.args import (
+ ImageConditioningInput as LtxImageConditioningInput,
+ )
+
+ images_inputs = []
+ temp_paths = []
+ kf_list = [p for p in (keyframe_paths or []) if p]
+ use_multi_kf = len(kf_list) >= 2
+
+ start_path = getattr(self, "_start_frame_path", None)
+ end_path = getattr(self, "_end_frame_path", None)
+ print(
+ f"[PATCH] start_path={start_path}, end_path={end_path}, multi_kf={use_multi_kf} n={len(kf_list)}"
+ )
+
+ latent_num_frames = (num_frames - 1) // 8 + 1
+ last_latent_idx = latent_num_frames - 1
+ print(
+ f"[PATCH] latent_num_frames={latent_num_frames}, last_latent_idx={last_latent_idx}"
+ )
+
+ if use_multi_kf:
+ n_kf = len(kf_list)
+ st_override = keyframe_strengths or []
+ if len(st_override) not in (0, n_kf):
+ print(
+ f"[PATCH] keyframeStrengths 长度({len(st_override)})与关键帧数({n_kf})不一致,改用默认强度曲线"
+ )
+ st_override = []
+
+ def _default_multi_guide_strength(i: int, n: int) -> float:
+ """对齐 Comfy LTXVAddGuideMulti 常见配置:首尾不全是 1,中间明显减弱以减少邻帧闪烁。"""
+ if n <= 2:
+ return 1.0
+ if i == 0:
+ return 0.62
+ if i == n - 1:
+ return 1.0
+ return 0.42
+
+ kt = keyframe_times or []
+ times_match = len(kt) == n_kf
+ if times_match:
+ fps_f = max(float(fps), 0.001)
+ max_t = (num_frames - 1) / fps_f
+ fi_list: list[int] = []
+ for ki in range(n_kf):
+ t_sec = max(0.0, min(max_t, float(kt[ki])))
+ pf = int(round(t_sec * fps_f))
+ pf = min(num_frames - 1, max(0, pf))
+ fi = min(last_latent_idx, pf // 8)
+ fi_list.append(int(fi))
+ for j in range(1, n_kf):
+ if fi_list[j] <= fi_list[j - 1]:
+ fi_list[j] = min(last_latent_idx, fi_list[j - 1] + 1)
+ if n_kf > 1:
+ fi_list[-1] = last_latent_idx
+ print(f"[PATCH] Multi-keyframe: 使用 keyframeTimes 映射 -> {fi_list}")
+ else:
+ fi_list = []
+ prev_fi = -1
+ for ki in range(n_kf):
+ if last_latent_idx <= 0:
+ fi = 0
+ elif ki == 0:
+ fi = 0
+ elif ki == n_kf - 1:
+ fi = last_latent_idx
+ else:
+ pf = int(
+ round(
+ ki * (num_frames - 1) / max(1, (n_kf - 1))
+ )
+ )
+ fi = min(last_latent_idx - 1, max(1, pf // 8))
+ if fi <= prev_fi:
+ fi = min(last_latent_idx - 1, prev_fi + 1)
+ prev_fi = fi
+ fi_list.append(int(fi))
+
+ for ki, kp in enumerate(kf_list):
+ if not os.path.isfile(kp):
+ raise RuntimeError(f"多关键帧路径无效或不存在: {kp}")
+ fi = fi_list[ki]
+
+ if len(st_override) == n_kf:
+ st = float(st_override[ki])
+ st = max(0.1, min(1.0, st))
+ else:
+ st = _default_multi_guide_strength(ki, n_kf)
+
+ img = self._prepare_image(kp, width, height)
+ tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
+ img.save(tmp)
+ temp_paths.append(tmp)
+ tmp_normalized = tmp.replace("\\", "/")
+ images_inputs.append(
+ LtxImageConditioningInput(
+ path=tmp_normalized, frame_idx=int(fi), strength=float(st)
+ )
+ )
+ print(
+ f"[PATCH] Multi-keyframe [{ki}]: {tmp_normalized}, "
+ f"frame_idx={fi}, strength={st:.3f}"
+ )
+ else:
+ # 如果没有首尾帧但有 image_path,使用 image_path 作为起始帧
+ if not start_path and not end_path and image_path:
+ print(f"[PATCH] 使用 image_path 作为起始帧: {image_path}")
+ start_path = image_path
+
+ has_image_param = image is not None
+ if has_image_param:
+ print(
+ f"[PATCH] image param is available, will be used as start frame"
+ )
+
+ target_start_path = start_path if start_path else None
+ if not target_start_path and image is not None:
+ tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
+ image.save(tmp)
+ temp_paths.append(tmp)
+ target_start_path = tmp
+ print(
+ f"[PATCH] Using image param as start frame: {target_start_path}"
+ )
+
+ if target_start_path:
+ start_img = self._prepare_image(target_start_path, width, height)
+ tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
+ start_img.save(tmp)
+ temp_paths.append(tmp)
+ tmp_normalized = tmp.replace("\\", "/")
+ images_inputs.append(
+ LtxImageConditioningInput(
+ path=tmp_normalized, frame_idx=0, strength=1.0
+ )
+ )
+ print(f"[PATCH] Added start frame: {tmp_normalized}, frame_idx=0")
+
+ if end_path:
+ end_img = self._prepare_image(end_path, width, height)
+ tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
+ end_img.save(tmp)
+ temp_paths.append(tmp)
+ tmp_normalized = tmp.replace("\\", "/")
+ images_inputs.append(
+ LtxImageConditioningInput(
+ path=tmp_normalized,
+ frame_idx=last_latent_idx,
+ strength=1.0,
+ )
+ )
+ print(
+ f"[PATCH] Added end frame: {tmp_normalized}, frame_idx={last_latent_idx}"
+ )
+
+ print(f"[PATCH] images_inputs count: {len(images_inputs)}")
+ if images_inputs:
+ for idx, img in enumerate(images_inputs):
+ print(
+ 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')}"
+ )
+
+ print(f"[PATCH] audio_path = {audio_path}")
+
+ if self._generation.is_generation_cancelled():
+ raise RuntimeError("Generation was cancelled")
+
+ # 导入 uuid
+ import uuid
+
+ generation_id = uuid.uuid4().hex[:8]
+
+ # 根据是否有音频选择不同的 pipeline
+ extra_loras_for_hook: tuple | None = None # 供 lora_build_hook 在 DiT build 时融合
+ gpu_slot = getattr(self._pipelines.state, "gpu_slot", None)
+ active = getattr(gpu_slot, "active_pipeline", None) if gpu_slot else None
+ cached_sig = getattr(self._pipelines, "_pipeline_signature", None)
+
+ new_kind = "a2v" if audio_path else "fast"
+ if (
+ cached_sig
+ and isinstance(cached_sig, tuple)
+ and len(cached_sig) > 0
+ and cached_sig[0] != new_kind
+ and active is not None
+ ):
+ from keep_models_runtime import force_unload_gpu_pipeline
+
+ print(
+ f"[PATCH] 管线类型切换 {cached_sig[0]} -> {new_kind},强制卸载旧模型"
+ )
+ force_unload_gpu_pipeline(self._pipelines)
+ gpu_slot = getattr(self._pipelines.state, "gpu_slot", None)
+ active = getattr(gpu_slot, "active_pipeline", None) if gpu_slot else None
+
+ if audio_path:
+ desired_sig = ("a2v",)
+ print(f"[PATCH] 加载 A2V pipeline(支持音频)")
+ pipeline_state = self._pipelines.load_a2v_pipeline()
+ self._pipelines._pipeline_signature = desired_sig
+ num_inference_steps = 11
+ else:
+ # Fast:无 LoRA 时走官方 load_gpu_pipeline;有 LoRA 时自建 pipeline。
+ loras = None
+ lora_str = (lora_path or "").strip() if isinstance(lora_path, str) else ""
+ if lora_str:
+ try:
+ from ltx_core.loader import LoraPathStrengthAndSDOps
+ from ltx_core.loader.sd_ops import LTXV_LORA_COMFY_RENAMING_MAP
+
+ if os.path.exists(lora_str):
+ loras = [
+ LoraPathStrengthAndSDOps(
+ path=lora_str,
+ strength=float(lora_strength),
+ sd_ops=LTXV_LORA_COMFY_RENAMING_MAP,
+ )
+ ]
+ print(
+ f"[PATCH] LoRA 已就绪: {lora_str}, strength={lora_strength}"
+ )
+ else:
+ print(f"[PATCH] LoRA 文件不存在,将使用无 LoRA Fast: {lora_str}")
+ except Exception as _lora_err:
+ print(f"[PATCH] LoRA 准备失败,回退无 LoRA: {_lora_err}")
+ loras = None
+
+ if loras is not None:
+ lora_key = lora_str
+ lora_st = round(float(lora_strength), 4)
+ else:
+ lora_key = ""
+ lora_st = 0.0
+ desired_sig = ("fast", lora_key, lora_st)
+
+ if loras is not None:
+ print("[PATCH] 构建带 LoRA 的 Fast pipeline(unload 后重建)")
+ # 首次 LoRA 构建时可能触发额外的显存峰值(编译/缓存/权重搬运)。
+ # 通过一次无 LoRA 的 fast pipeline warmup 来降低后续 LoRA 构建的峰值风险。
+ if not getattr(self, "_ltx_lora_warmup_done", False):
+ try:
+ print("[PATCH] LoRA warmup: 先加载无 LoRA fast pipeline 触发缓存")
+ # should_warm=True:尽量触发内核/权重缓存(若实现不同则静默失败也可回退)
+ self._pipelines.load_gpu_pipeline("fast", should_warm=True)
+ from keep_models_runtime import force_unload_gpu_pipeline
+ force_unload_gpu_pipeline(self._pipelines)
+ import gc
+ gc.collect()
+ try:
+ import torch
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+ torch.cuda.ipc_collect()
+ except Exception:
+ pass
+ self._ltx_lora_warmup_done = True
+ except Exception as _warm_err:
+ print(f"[PATCH] LoRA warmup failed (ignore): {_warm_err}")
+ from keep_models_runtime import force_unload_gpu_pipeline
+
+ force_unload_gpu_pipeline(self._pipelines)
+ import gc
+
+ gc.collect()
+ # 防止旧分配/碎片在首次 LoRA 构建时叠加导致 OOM
+ try:
+ import torch
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+ torch.cuda.ipc_collect()
+ except Exception:
+ pass
+ gemma_root = self._pipelines._text_handler.resolve_gemma_root()
+ from runtime_config.model_download_specs import resolve_model_path
+ from services.fast_video_pipeline.ltx_fast_video_pipeline import (
+ LTXFastVideoPipeline,
+ )
+ from state.app_state_types import (
+ GpuSlot,
+ VideoPipelineState,
+ VideoPipelineWarmth,
+ )
+
+ checkpoint_path = str(
+ resolve_model_path(
+ self._pipelines.models_dir,
+ self._pipelines.config.model_download_specs,
+ "checkpoint",
+ )
+ )
+ upsampler_path = str(
+ resolve_model_path(
+ self._pipelines.models_dir,
+ self._pipelines.config.model_download_specs,
+ "upsampler",
+ )
+ )
+ from lora_injection import (
+ _lora_init_kwargs,
+ inject_loras_into_fast_pipeline,
+ )
+
+ lora_kw = _lora_init_kwargs(LTXFastVideoPipeline, loras)
+ ltx_pipe = LTXFastVideoPipeline(
+ checkpoint_path,
+ gemma_root,
+ upsampler_path,
+ self._pipelines.config.device,
+ **lora_kw,
+ )
+ n_inj = inject_loras_into_fast_pipeline(ltx_pipe, loras)
+ if hasattr(ltx_pipe, "pipeline") and hasattr(
+ ltx_pipe.pipeline, "model_ledger"
+ ):
+ try:
+ ltx_pipe.pipeline.model_ledger.loras = tuple(loras)
+ except Exception:
+ pass
+ pipeline_state = VideoPipelineState(
+ pipeline=ltx_pipe,
+ warmth=VideoPipelineWarmth.COLD,
+ is_compiled=False,
+ )
+ self._pipelines.state.gpu_slot = GpuSlot(
+ active_pipeline=pipeline_state, generation=None
+ )
+ _ml = getattr(getattr(ltx_pipe, "pipeline", None), "model_ledger", None)
+ _ml_loras = getattr(_ml, "loras", None) if _ml else None
+ print(
+ f"[PATCH] LoRA: __init__ 额外参数={list(lora_kw.keys())}, "
+ f"深度注入点数={n_inj}, model_ledger.loras={_ml_loras}"
+ )
+ if getattr(self._pipelines, "low_vram_mode", False):
+ from low_vram_runtime import try_sequential_offload_on_pipeline_state
+
+ try_sequential_offload_on_pipeline_state(pipeline_state)
+ else:
+ print(f"[PATCH] 加载 Fast pipeline(无 LoRA)")
+ pipeline_state = self._pipelines.load_gpu_pipeline(
+ "fast", should_warm=False
+ )
+ self._pipelines._pipeline_signature = desired_sig
+ num_inference_steps = None
+ extra_loras_for_hook = tuple(loras) if loras else None
+
+ # 在 DiT 权重 build 时融合用户 LoRA(model_ledger 单独赋值往往不够)
+ from lora_build_hook import (
+ install_lora_build_hook,
+ pending_loras_token,
+ reset_pending_loras,
+ )
+
+ install_lora_build_hook()
+ _lora_hook_tok = pending_loras_token(extra_loras_for_hook)
+ try:
+ # 启动 generation 状态(在 pipeline 加载之后)
+ self._generation.start_generation(generation_id)
+
+ # 处理 negative_prompt
+ neg_prompt = (
+ negative_prompt if negative_prompt else self.config.default_negative_prompt
+ )
+ enhanced_prompt = prompt + self.config.camera_motion_prompts.get(
+ camera_motion, ""
+ )
+
+ # 强制使用动态目录,忽略底层原始逻辑
+ dyn_dir = get_dynamic_output_path()
+ output_path = dyn_dir / f"generation_{uuid.uuid4().hex[:8]}.mp4"
+
+ try:
+ self._text.prepare_text_encoding(enhanced_prompt, enhance_prompt=False)
+ # 调整为 64 的倍数(与 LTX 内核 divisible-by-64 校验一致)
+ height = max(64, round(height / 64) * 64)
+ width = max(64, round(width / 64) * 64)
+
+ if audio_path:
+ # A2V pipeline 参数
+ gen_kwargs = {
+ "prompt": enhanced_prompt,
+ "negative_prompt": neg_prompt,
+ "seed": seed,
+ "height": height,
+ "width": width,
+ "num_frames": num_frames,
+ "frame_rate": fps,
+ "num_inference_steps": num_inference_steps,
+ "images": images_inputs,
+ "audio_path": audio_path,
+ "audio_start_time": 0.0,
+ "audio_max_duration": None,
+ "output_path": str(output_path),
+ }
+ else:
+ # Fast pipeline 参数
+ gen_kwargs = {
+ "prompt": enhanced_prompt,
+ "seed": seed,
+ "height": height,
+ "width": width,
+ "num_frames": num_frames,
+ "frame_rate": fps,
+ "images": images_inputs,
+ "output_path": str(output_path),
+ }
+
+ pipeline_state.pipeline.generate(**gen_kwargs)
+
+ # 标记完成
+ self._generation.complete_generation(str(output_path))
+ return str(output_path)
+ finally:
+ self._text.clear_api_embeddings()
+ for p in temp_paths:
+ if os.path.exists(p):
+ os.unlink(p)
+ self._start_frame_path = None
+ self._end_frame_path = None
+ from low_vram_runtime import maybe_release_pipeline_after_task
+
+ try:
+ maybe_release_pipeline_after_task(self)
+ except Exception:
+ pass
+ finally:
+ reset_pending_loras(_lora_hook_tok)
+
+ VideoGenerationHandler.generate = patched_generate
+ VideoGenerationHandler.generate_video = patched_generate_video
+
+ # 2. 增强视频超分功能 (Monkey Patch LTXRetakePipeline)
+ _orig_ltx_retake_run = LTXRetakePipeline._run
+
+ def patched_ltx_retake_run(
+ self, video_path, prompt, start_time, end_time, seed, **kwargs
+ ):
+ # 拦截并修改目标宽高
+ target_w = getattr(self, "_target_width", None)
+ target_h = getattr(self, "_target_height", None)
+ target_strength = getattr(self, "_target_strength", 0.7)
+ is_upscale = target_w is not None and target_h is not None
+
+ import ltx_pipelines.utils.media_io as media_io
+ import services.retake_pipeline.ltx_retake_pipeline as lrp
+ import ltx_pipelines.utils.samplers as samplers
+ import ltx_pipelines.utils.helpers as helpers
+
+ _orig_get_meta = media_io.get_videostream_metadata
+ _orig_lrp_get_meta = getattr(lrp, "get_videostream_metadata", _orig_get_meta)
+ _orig_euler_loop = samplers.euler_denoising_loop
+ _orig_noise_video = helpers.noise_video_state
+
+ fps, num_frames, src_w, src_h = _orig_get_meta(video_path)
+
+ if is_upscale:
+ print(
+ f">>> 启动超分内核: {src_w}x{src_h} -> {target_w}x{target_h} (强度: {target_strength})"
+ )
+
+ # 1. 注入分辨率
+ def get_meta_patched(path):
+ return fps, num_frames, target_w, target_h
+
+ media_io.get_videostream_metadata = get_meta_patched
+ lrp.get_videostream_metadata = get_meta_patched
+
+ # 2. 注入起始噪声 (SDEdit 核心:加噪到指定强度)
+ def noise_video_patched(*args, **kwargs_inner):
+ kwargs_inner["noise_scale"] = target_strength
+ return _orig_noise_video(*args, **kwargs_inner)
+
+ helpers.noise_video_state = noise_video_patched
+
+ # 3. 注入采样起点 (从对应噪声位开始去噪)
+ def patched_euler_loop(
+ sigmas, video_state, audio_state, stepper, denoise_fn
+ ):
+ full_len = len(sigmas)
+ skip_idx = 0
+ for i, s in enumerate(sigmas):
+ if s <= target_strength:
+ skip_idx = i
+ break
+ skip_idx = min(skip_idx, full_len - 2)
+ new_sigmas = sigmas[skip_idx:]
+ print(
+ f">>> 采样拦截成功: 原步数 {full_len}, 现步数 {len(new_sigmas)}, 起始强度 {new_sigmas[0].item():.2f}"
+ )
+ return _orig_euler_loop(
+ new_sigmas, video_state, audio_state, stepper, denoise_fn
+ )
+
+ samplers.euler_denoising_loop = patched_euler_loop
+
+ kwargs["regenerate_video"] = False
+ kwargs["regenerate_audio"] = False
+
+ try:
+ return _orig_ltx_retake_run(
+ self, video_path, prompt, start_time, end_time, seed, **kwargs
+ )
+ finally:
+ media_io.get_videostream_metadata = _orig_get_meta
+ lrp.get_videostream_metadata = _orig_lrp_get_meta
+ samplers.euler_denoising_loop = _orig_euler_loop
+ helpers.noise_video_state = _orig_noise_video
+
+ return _orig_ltx_retake_run(
+ self, video_path, prompt, start_time, end_time, seed, **kwargs
+ )
+
+ return _orig_ltx_retake_run(
+ self, video_path, prompt, start_time, end_time, seed, **kwargs
+ )
+
+ LTXRetakePipeline._run = patched_ltx_retake_run
+
+ # --- 最终视频超分接口实现 ---
+ @app.post("/api/system/upscale-video")
+ async def route_upscale_video(request: Request):
+ try:
+ import uuid
+ import os
+ from datetime import datetime
+ from ltx_pipelines.utils.media_io import get_videostream_metadata
+ from ltx_core.types import SpatioTemporalScaleFactors
+
+ data = await request.json()
+ video_path = data.get("video_path")
+ target_res = data.get("resolution", "1080p")
+ prompt = data.get("prompt", "high quality, detailed, 4k")
+ strength = data.get("strength", 0.7) # 获取前端传来的重绘幅度
+
+ if not video_path or not os.path.exists(video_path):
+ return JSONResponse(
+ status_code=400, content={"error": "Invalid video path"}
+ )
+
+ # 计算目标宽高 (必须是 32 的倍数)
+ res_map = {"1080p": (1920, 1088), "720p": (1280, 704), "544p": (960, 544)}
+ target_w, target_h = res_map.get(target_res, (1920, 1088))
+
+ fps, num_frames, _, _ = get_videostream_metadata(video_path)
+
+ # 校验帧数 8k+1,如果不符则自动调整
+ scale = SpatioTemporalScaleFactors.default()
+ if (num_frames - 1) % scale.time != 0:
+ # 计算需要调整到的最近的有效帧数 (8k+1)
+ # 找到最接近的8k+1帧数
+ target_k = (num_frames - 1) // scale.time
+ # 选择最接近的k值:向下或向上取整
+ current_k = (num_frames - 1) // scale.time
+ current_remainder = (num_frames - 1) % scale.time
+
+ # 比较向上和向下取整哪个更接近
+ down_k = current_k
+ up_k = current_k + 1
+
+ # 向下取整的帧数
+ down_frames = down_k * scale.time + 1
+ # 向上取整的帧数
+ up_frames = up_k * scale.time + 1
+
+ # 选择差异最小的
+ if abs(num_frames - down_frames) <= abs(num_frames - up_frames):
+ adjusted_frames = down_frames
+ else:
+ adjusted_frames = up_frames
+
+ print(
+ f">>> 帧数调整: {num_frames} -> {adjusted_frames} (符合 8k+1 规则)"
+ )
+
+ # 调整视频帧数 - 截断多余的帧或填充黑帧
+ adjusted_video_path = None
+ try:
+ import cv2
+ import numpy as np
+ import tempfile
+
+ # 使用cv2读取视频
+ cap = cv2.VideoCapture(video_path)
+ if not cap.isOpened():
+ raise Exception("无法打开视频文件")
+
+ frames = []
+ while True:
+ ret, frame = cap.read()
+ if not ret:
+ break
+ frames.append(frame)
+ cap.release()
+
+ original_frame_count = len(frames)
+
+ if adjusted_frames < original_frame_count:
+ # 截断多余的帧
+ frames = frames[:adjusted_frames]
+ print(
+ f">>> 已截断视频: {original_frame_count} -> {len(frames)} 帧"
+ )
+ else:
+ # 填充黑帧 (复制最后一帧)
+ last_frame = frames[-1] if frames else None
+ if last_frame is not None:
+ h, w = last_frame.shape[:2]
+ black_frame = np.zeros((h, w, 3), dtype=np.uint8)
+ while len(frames) < adjusted_frames:
+ frames.append(black_frame.copy())
+ print(
+ f">>> 已填充视频: {original_frame_count} -> {len(frames)} 帧"
+ )
+
+ # 保存调整后的视频到临时文件
+ adjusted_video_fd = tempfile.NamedTemporaryFile(
+ suffix=".mp4", delete=False
+ )
+ adjusted_video_path = adjusted_video_fd.name
+ adjusted_video_fd.close()
+
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v")
+ out = cv2.VideoWriter(
+ adjusted_video_path,
+ fourcc,
+ fps,
+ (frames[0].shape[1], frames[0].shape[0]),
+ )
+ for frame in frames:
+ out.write(frame)
+ out.release()
+
+ video_path = adjusted_video_path
+ num_frames = adjusted_frames
+ print(
+ f">>> 视频帧数调整完成: {original_frame_count} -> {num_frames}"
+ )
+
+ except ImportError:
+ # cv2不可用,尝试使用LTX内置方法
+ try:
+ from ltx_pipelines.utils.media_io import (
+ read_video_stream,
+ write_video_stream,
+ )
+ import numpy as np
+
+ frames, audio_data = read_video_stream(video_path, fps)
+ original_frame_count = len(frames)
+
+ if adjusted_frames < original_frame_count:
+ frames = frames[:adjusted_frames]
+ else:
+ while len(frames) < adjusted_frames:
+ frames = np.concatenate([frames, frames[-1:]], axis=0)
+
+ import tempfile
+
+ adjusted_video_fd = tempfile.NamedTemporaryFile(
+ suffix=".mp4", delete=False
+ )
+ adjusted_video_path = adjusted_video_fd.name
+ adjusted_video_fd.close()
+
+ write_video_stream(adjusted_video_path, frames, fps)
+ video_path = adjusted_video_path
+ num_frames = adjusted_frames
+ print(
+ f">>> 视频帧数调整完成: {original_frame_count} -> {num_frames}"
+ )
+
+ except Exception as e2:
+ print(f">>> 视频帧数自动调整失败: {e2}")
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": f"视频帧数({num_frames})不符合 8k+1 规则,且自动调整失败。请手动将视频帧数调整为 8k+1 格式(如 9, 17, 25, 33, 41, 49, 57, 65, 73, 81, 89, 97, 105 等)。"
+ },
+ )
+ except Exception as e:
+ print(f">>> 视频帧数自动调整失败: {e}")
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": f"视频帧数({num_frames})不符合 8k+1 规则,且自动调整失败。请手动将视频帧数调整为 8k+1 格式(如 9, 17, 25, 33, 41, 49, 57, 65, 73, 81, 89, 97, 105 等)。"
+ },
+ )
+
+ # 1. 加载模型
+ pipeline_state = handler.pipelines.load_retake_pipeline(distilled=True)
+
+ # 3. 启动任务
+ generation_id = uuid.uuid4().hex[:8]
+ handler.generation.start_generation(generation_id)
+
+ # 核心修正:确保文件保存在动态的输出目录
+ save_dir = get_dynamic_output_path()
+ filename = f"upscale_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{generation_id}.mp4"
+ full_output_path = save_dir / filename
+
+ # 3. 执行真正的超分逻辑
+ try:
+ # 注入目标分辨率和重绘幅度
+ pipeline_state.pipeline._target_width = target_w
+ pipeline_state.pipeline._target_height = target_h
+ pipeline_state.pipeline._target_strength = strength
+
+ def do_generate():
+ pipeline_state.pipeline.generate(
+ video_path=str(video_path),
+ prompt=prompt,
+ start_time=0.0,
+ end_time=float(num_frames / fps),
+ seed=int(time.time()) % 2147483647,
+ output_path=str(full_output_path),
+ distilled=True,
+ regenerate_video=True,
+ regenerate_audio=False,
+ )
+
+ # 重要修复:放到线程池运行,避免阻塞主循环导致前端拿不到显存数据
+ from starlette.concurrency import run_in_threadpool
+
+ await run_in_threadpool(do_generate)
+
+ handler.generation.complete_generation(str(full_output_path))
+ return {"status": "complete", "video_path": filename}
+ except Exception as e:
+ # OOM 异常逃逸修复:强制返回友好的异常信息
+ try:
+ handler.generation.cancel_generation()
+ except Exception:
+ pass
+ if hasattr(handler.generation, "_generation_id"):
+ handler.generation._generation_id = None
+ if hasattr(handler.generation, "_is_generating"):
+ handler.generation._is_generating = False
+
+ error_msg = str(e)
+ if "CUDA out of memory" in error_msg:
+ error_msg = "🚨 显存不足 (OOM):视频时长过长或目标分辨率超出了当前显卡的承载极限,请降低目标分辨率重试!"
+ raise RuntimeError(error_msg) from e
+ finally:
+ if hasattr(pipeline_state.pipeline, "_target_width"):
+ del pipeline_state.pipeline._target_width
+ if hasattr(pipeline_state.pipeline, "_target_height"):
+ del pipeline_state.pipeline._target_height
+ if hasattr(pipeline_state.pipeline, "_target_strength"):
+ del pipeline_state.pipeline._target_strength
+ import gc
+
+ gc.collect()
+ if (
+ getattr(torch, "cuda", None) is not None
+ and torch.cuda.is_available()
+ ):
+ torch.cuda.empty_cache()
+ from low_vram_runtime import maybe_release_pipeline_after_task
+
+ try:
+ maybe_release_pipeline_after_task(handler)
+ except Exception:
+ pass
+
+ except Exception as e:
+ import traceback
+
+ traceback.print_exc()
+ return JSONResponse(status_code=500, content={"error": str(e)})
+
+ # ------------------
+
+ @app.post("/api/system/upload-image")
+ async def route_upload_image(request: Request):
+ try:
+ import uuid
+ import base64
+
+ # 接收 JSON 而不是 Multipart,绕过 python-multipart 缺失问题
+ data = await request.json()
+ b64_data = data.get("image")
+ filename = data.get("filename", "image.png")
+
+ if not b64_data:
+ return JSONResponse(
+ status_code=400, content={"error": "No image data provided"}
+ )
+
+ # 处理 base64 头部 (例如 data:image/png;base64,...)
+ if "," in b64_data:
+ b64_data = b64_data.split(",")[1]
+
+ image_bytes = base64.b64decode(b64_data)
+
+ # 确保上传目录存在
+ upload_dir = get_dynamic_output_path() / "uploads"
+ upload_dir.mkdir(parents=True, exist_ok=True)
+
+ safe_filename = "".join([c for c in filename if c.isalnum() or c in "._-"])
+ file_path = upload_dir / f"up_{uuid.uuid4().hex[:6]}_{safe_filename}"
+
+ with file_path.open("wb") as buffer:
+ buffer.write(image_bytes)
+
+ return {"status": "success", "path": str(file_path)}
+ except Exception as e:
+ import traceback
+
+ error_msg = f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}"
+ print(f"Upload error: {error_msg}")
+ return JSONResponse(
+ status_code=500, content={"error": str(e), "detail": error_msg}
+ )
+
+ # ------------------
+ # 批量首尾帧:与「视频生成」相同的首尾帧推理,按顺序生成 N-1 段后可选 ffmpeg 拼接
+ # ------------------
+
+ def _find_ffmpeg_binary() -> str | None:
+ """尽量找到 ffmpeg:环境变量 → imageio-ffmpeg 自带 → PATH → 常见安装位置 → WinGet。"""
+ import shutil
+ import sys
+
+ def _ok(p: str | None) -> str | None:
+ if not p:
+ return None
+ p = os.path.normpath(os.path.expandvars(str(p).strip().strip('"')))
+ return p if os.path.isfile(p) else None
+
+ for env_key in ("LTX_FFMPEG_PATH", "FFMPEG_PATH"):
+ hit = _ok(os.environ.get(env_key))
+ if hit:
+ print(f"[batch-merge] ffmpeg from {env_key}: {hit}")
+ return hit
+
+ try:
+ pref = _ltx_desktop_config_dir() / "ffmpeg_path.txt"
+ if pref.is_file():
+ line = pref.read_text(encoding="utf-8").splitlines()[0].strip()
+ hit = _ok(line)
+ if hit:
+ print(f"[batch-merge] ffmpeg from ffmpeg_path.txt: {hit}")
+ return hit
+ except Exception as _e:
+ print(f"[batch-merge] ffmpeg_path.txt: {_e!r}")
+
+ # imageio-ffmpeg:多数视频/ML 环境会带上独立 ffmpeg 可执行文件
+ try:
+ import imageio_ffmpeg
+
+ hit = _ok(imageio_ffmpeg.get_ffmpeg_exe())
+ if hit:
+ print(f"[batch-merge] ffmpeg from imageio_ffmpeg: {hit}")
+ return hit
+ except Exception as _e:
+ print(f"[batch-merge] imageio_ffmpeg: {_e!r}")
+
+ for name in ("ffmpeg", "ffmpeg.exe"):
+ hit = _ok(shutil.which(name))
+ if hit:
+ print(f"[batch-merge] ffmpeg from PATH which({name}): {hit}")
+ return hit
+
+ # 显式遍历 PATH 中的目录(某些环境下 which 不可靠)
+ path_env = os.environ.get("PATH", "") or os.environ.get("Path", "")
+ for folder in path_env.split(os.pathsep):
+ folder = folder.strip().strip('"')
+ if not folder:
+ continue
+ for exe in ("ffmpeg.exe", "ffmpeg"):
+ hit = _ok(os.path.join(folder, exe))
+ if hit:
+ print(f"[batch-merge] ffmpeg from PATH scan: {hit}")
+ return hit
+
+ localappdata = os.environ.get("LOCALAPPDATA", "") or ""
+ programfiles = os.environ.get("ProgramFiles", r"C:\Program Files")
+ programfiles_x86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
+ userprofile = os.environ.get("USERPROFILE", "") or ""
+
+ static_candidates: list[str] = [
+ os.path.join(os.path.dirname(sys.executable), "ffmpeg.exe"),
+ os.path.join(os.path.dirname(sys.executable), "ffmpeg"),
+ os.path.join(localappdata, "LTXDesktop", "ffmpeg.exe"),
+ os.path.join(programfiles, "LTX Desktop", "ffmpeg.exe"),
+ os.path.join(programfiles, "ffmpeg", "bin", "ffmpeg.exe"),
+ os.path.join(programfiles_x86, "ffmpeg", "bin", "ffmpeg.exe"),
+ r"C:\ffmpeg\bin\ffmpeg.exe",
+ os.path.join(userprofile, "scoop", "shims", "ffmpeg.exe"),
+ os.path.join(userprofile, "scoop", "apps", "ffmpeg", "current", "bin", "ffmpeg.exe"),
+ r"C:\ProgramData\chocolatey\bin\ffmpeg.exe",
+ ]
+ for c in static_candidates:
+ hit = _ok(c)
+ if hit:
+ print(f"[batch-merge] ffmpeg static candidate: {hit}")
+ return hit
+
+ # WinGet 安装的 Gyan / BtbN 等包:在 Packages 下搜索 ffmpeg.exe(限制深度避免过慢)
+ try:
+ wg = os.path.join(localappdata, "Microsoft", "WinGet", "Packages")
+ if os.path.isdir(wg):
+ for root, _dirs, files in os.walk(wg):
+ if "ffmpeg.exe" in files:
+ hit = _ok(os.path.join(root, "ffmpeg.exe"))
+ if hit:
+ print(f"[batch-merge] ffmpeg from WinGet tree: {hit}")
+ return hit
+ # 略过过深目录
+ depth = root[len(wg) :].count(os.sep)
+ if depth > 6:
+ _dirs[:] = []
+ except Exception as _e:
+ print(f"[batch-merge] WinGet scan: {_e!r}")
+
+ print("[batch-merge] ffmpeg not found after extended search")
+ return None
+
+ def _ffmpeg_concat_copy(
+ segment_paths: list[str], output_mp4: str, ffmpeg_bin: str
+ ) -> None:
+ import subprocess
+
+ out_abs = os.path.abspath(output_mp4)
+ dyn_abs = os.path.abspath(str(get_dynamic_output_path()))
+ lines: list[str] = []
+ for p in segment_paths:
+ ap = os.path.abspath(p)
+ rel = os.path.relpath(ap, start=dyn_abs)
+ rel = rel.replace("\\", "/")
+ if "'" in rel:
+ rel = rel.replace("'", "'\\''")
+ lines.append(f"file '{rel}'")
+ list_body = "\n".join(lines) + "\n"
+ list_path = os.path.join(dyn_abs, f"_batch_concat_{os.getpid()}_{time.time_ns()}.txt")
+ try:
+ Path(list_path).write_text(list_body, encoding="utf-8")
+ cmd = [
+ ffmpeg_bin,
+ "-y",
+ "-f",
+ "concat",
+ "-safe",
+ "0",
+ "-i",
+ list_path,
+ "-c",
+ "copy",
+ out_abs,
+ ]
+ proc = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ )
+ if proc.returncode != 0:
+ err = (proc.stderr or proc.stdout or "").strip()
+ raise RuntimeError(
+ f"ffmpeg 拼接失败 (code {proc.returncode}): {err[:800]}"
+ )
+ finally:
+ try:
+ if os.path.isfile(list_path):
+ os.unlink(list_path)
+ except OSError:
+ pass
+
+ def _ffmpeg_mux_background_audio(
+ ffmpeg_bin: str, video_in: str, audio_in: str, video_out: str
+ ) -> None:
+ """成片只保留原视频画面,音轨替换为一条外部音频(与多段各自 AI 音频相比更统一)。"""
+ import subprocess
+
+ out_abs = os.path.abspath(video_out)
+ proc = subprocess.run(
+ [
+ ffmpeg_bin,
+ "-y",
+ "-i",
+ os.path.abspath(video_in),
+ "-i",
+ os.path.abspath(audio_in),
+ "-map",
+ "0:v:0",
+ "-map",
+ "1:a:0",
+ "-c:v",
+ "copy",
+ "-c:a",
+ "aac",
+ "-b:a",
+ "192k",
+ "-shortest",
+ out_abs,
+ ],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ )
+ if proc.returncode != 0:
+ err = (proc.stderr or proc.stdout or "").strip()
+ raise RuntimeError(
+ f"配乐混流失败 (code {proc.returncode}): {err[:800]}"
+ )
+
+ @app.post("/api/generate-batch")
+ async def route_generate_batch(request: Request):
+ """多关键帧:相邻两帧一段首尾帧视频,与 POST /api/generate 同源逻辑;多段用 ffmpeg concat。"""
+ from starlette.concurrency import run_in_threadpool
+
+ from server_utils.media_validation import normalize_optional_path
+
+ try:
+ data = await request.json()
+ segments_in = data.get("segments") or []
+ if not segments_in:
+ return JSONResponse(
+ status_code=400,
+ content={"error": "segments 不能为空"},
+ )
+
+ resolution = data.get("resolution") or "720p"
+ aspect_ratio = data.get("aspectRatio") or "16:9"
+ neg = data.get(
+ "negativePrompt",
+ "low quality, blurry, noisy, static noise, distorted",
+ )
+ model = data.get("model") or "ltx-2"
+ fps = str(data.get("fps") or "24")
+ audio = str(data.get("audio") or "false").lower()
+ camera_motion = data.get("cameraMotion") or "static"
+ model_path = data.get("modelPath")
+ lora_path = data.get("loraPath")
+ lora_strength = float(data.get("loraStrength") or 1.0)
+
+ vg = getattr(handler, "video_generation", None)
+ if vg is None or not callable(getattr(vg, "generate", None)):
+ return JSONResponse(
+ status_code=500,
+ content={"error": "内部错误:找不到 video_generation 处理器"},
+ )
+
+ clip_paths: list[str] = []
+ for idx, seg in enumerate(segments_in):
+ start_raw = seg.get("startImage") or seg.get("startFramePath")
+ end_raw = seg.get("endImage") or seg.get("endFramePath")
+ start_p = normalize_optional_path(start_raw)
+ end_p = normalize_optional_path(end_raw)
+ if not start_p or not os.path.isfile(start_p):
+ return JSONResponse(
+ status_code=400,
+ content={"error": f"片段 {idx + 1} 起始图路径无效"},
+ )
+ if not end_p or not os.path.isfile(end_p):
+ return JSONResponse(
+ status_code=400,
+ content={"error": f"片段 {idx + 1} 结束图路径无效"},
+ )
+
+ dur = seg.get("duration", 5)
+ try:
+ dur_i = int(float(dur))
+ except (TypeError, ValueError):
+ dur_i = 5
+ dur_i = max(1, min(60, dur_i))
+
+ prompt_text = (seg.get("prompt") or "").strip()
+ if not prompt_text:
+ prompt_text = "cinematic transition"
+
+ req = GenerateVideoRequest(
+ prompt=prompt_text,
+ resolution=resolution,
+ model=model,
+ cameraMotion=camera_motion,
+ negativePrompt=neg,
+ duration=str(dur_i),
+ fps=fps,
+ audio=audio,
+ imagePath=None,
+ audioPath=None,
+ startFramePath=start_p,
+ endFramePath=end_p,
+ aspectRatio=aspect_ratio,
+ modelPath=model_path,
+ loraPath=lora_path,
+ loraStrength=lora_strength,
+ )
+
+ def _one_gen(r: GenerateVideoRequest = req):
+ return vg.generate(r)
+
+ resp = await run_in_threadpool(_one_gen)
+ if resp.status != "complete" or not resp.video_path:
+ return JSONResponse(
+ status_code=500,
+ content={
+ "error": f"片段 {idx + 1} 生成失败: status={getattr(resp, 'status', None)}"
+ },
+ )
+ clip_paths.append(str(resp.video_path))
+
+ if len(clip_paths) == 1:
+ final_path = clip_paths[0]
+ else:
+ ff = _find_ffmpeg_binary()
+ if not ff:
+ return JSONResponse(
+ status_code=500,
+ content={
+ "error": (
+ "已生成多段视频,但未找到 ffmpeg,无法拼接。"
+ " 可选:① 安装 ffmpeg 并加入系统 PATH;"
+ " ② 设置环境变量 LTX_FFMPEG_PATH 指向 ffmpeg.exe;"
+ " ③ 在 %LOCALAPPDATA%\\LTXDesktop\\ffmpeg_path.txt 第一行写入 ffmpeg.exe 的完整路径。"
+ ),
+ "segment_paths": clip_paths,
+ },
+ )
+ import uuid as _uuid
+
+ out_dir = get_dynamic_output_path()
+ final_path = str(
+ out_dir / f"batch_merged_{_uuid.uuid4().hex[:10]}.mp4"
+ )
+ try:
+ _ffmpeg_concat_copy(clip_paths, final_path, ff)
+ except Exception as ex:
+ return JSONResponse(
+ status_code=500,
+ content={
+ "error": str(ex),
+ "segment_paths": clip_paths,
+ },
+ )
+
+ bg_audio = normalize_optional_path(
+ data.get("backgroundAudioPath")
+ or data.get("batchBackgroundAudioPath")
+ )
+ if bg_audio and os.path.isfile(bg_audio):
+ ff_mux = _find_ffmpeg_binary()
+ if not ff_mux:
+ return JSONResponse(
+ status_code=500,
+ content={
+ "error": "已生成视频,但混入配乐需要 ffmpeg,请配置 LTX_FFMPEG_PATH 或 ffmpeg_path.txt",
+ "video_path": final_path,
+ },
+ )
+ import uuid as _uuid2
+
+ out_mux = str(
+ get_dynamic_output_path()
+ / f"batch_with_audio_{_uuid2.uuid4().hex[:10]}.mp4"
+ )
+ try:
+ _ffmpeg_mux_background_audio(ff_mux, final_path, bg_audio, out_mux)
+ final_path = out_mux
+ except Exception as ex:
+ return JSONResponse(
+ status_code=500,
+ content={
+ "error": str(ex),
+ "video_path": final_path,
+ },
+ )
+
+ return GenerateVideoResponse(status="complete", video_path=final_path)
+ except Exception as e:
+ import traceback
+
+ traceback.print_exc()
+ return JSONResponse(status_code=500, content={"error": str(e)})
+
+ # ------------------
+
+ @app.get("/api/system/history")
+ async def route_get_history(request: Request):
+ try:
+ import os
+
+ page = int(request.query_params.get("page", 1))
+ limit = int(request.query_params.get("limit", 20))
+
+ history = []
+ dyn_path = get_dynamic_output_path()
+ if dyn_path.exists():
+ for filename in os.listdir(dyn_path):
+ if filename == "uploads":
+ continue
+ full_path = dyn_path / filename
+ if full_path.is_file() and filename.lower().endswith(
+ (".mp4", ".png", ".jpg", ".webp")
+ ):
+ mtime = os.path.getmtime(full_path)
+ history.append(
+ {
+ "filename": filename,
+ "type": "video"
+ if filename.lower().endswith(".mp4")
+ else "image",
+ "mtime": mtime,
+ "fullpath": str(full_path),
+ }
+ )
+ history.sort(key=lambda x: x["mtime"], reverse=True)
+
+ total_items = len(history)
+ total_pages = (total_items + limit - 1) // limit
+ start_idx = (page - 1) * limit
+ end_idx = start_idx + limit
+
+ return {
+ "status": "success",
+ "history": history[start_idx:end_idx],
+ "total_pages": total_pages,
+ "current_page": page,
+ "total_items": total_items,
+ }
+ except Exception as e:
+ return JSONResponse(status_code=500, content={"error": str(e)})
+
+ @app.post("/api/system/delete-file")
+ async def route_delete_file(request: Request):
+ try:
+ import os
+
+ data = await request.json()
+ filename = data.get("filename", "")
+
+ if not filename:
+ return JSONResponse(
+ status_code=400, content={"error": "Filename is required"}
+ )
+
+ dyn_path = get_dynamic_output_path()
+ file_path = dyn_path / filename
+
+ if file_path.exists() and file_path.is_file():
+ file_path.unlink()
+ return {"status": "success", "message": "File deleted"}
+ else:
+ return JSONResponse(
+ status_code=404, content={"error": "File not found"}
+ )
+ except Exception as e:
+ return JSONResponse(status_code=500, content={"error": str(e)})
+
+ # 路由注册
+ app.include_router(health_router)
+ app.include_router(generation_router)
+ app.include_router(models_router)
+ app.include_router(settings_router)
+ app.include_router(image_gen_router)
+ app.include_router(suggest_gap_prompt_router)
+ app.include_router(retake_router)
+ app.include_router(ic_lora_router)
+ app.include_router(runtime_policy_router)
+
+ # --- [安全补丁] 状态栏显示修复 ---
+
+ # --- 最终状态栏修复补丁: 只要服务运行且 GPU 没死,就视为就绪 ---
+ from handlers.health_handler import HealthHandler
+
+ if not hasattr(HealthHandler, "_fixed_v2"):
+ _orig_get_health = HealthHandler.get_health
+
+ def patched_health_v2(self):
+ resp = _orig_get_health(self)
+ # 解析:如果后端逻辑还在判断模型未加载,我们检查一下核心状态
+ # 如果系统没有崩溃,我们就强制标记为已加载,让前端允许交互
+ if not resp.models_loaded:
+ # 我们认为只要 API 能通,底层状态服务(state)只要存在,就视为由于异步加载引起的暂时性 False
+ # 直接返回 True,前端会显示"待机就绪"
+ resp.models_loaded = True
+ return resp
+
+ HealthHandler.get_health = patched_health_v2
+ HealthHandler._fixed_v2 = True
+ # ------------------------------------------------------------
+
+ # --- 修复显存采集指针:使得显存监控永远对准当前选定工作的 GPU ---
+ from services.gpu_info.gpu_info_impl import GpuInfoImpl
+
+ if not hasattr(GpuInfoImpl, "_fixed_vram_patch"):
+ _orig_get_gpu_info = GpuInfoImpl.get_gpu_info
+
+ def patched_get_gpu_info(self):
+ import torch
+
+ if self.get_cuda_available():
+ idx = 0
+ if (
+ hasattr(handler.config.device, "index")
+ and handler.config.device.index is not None
+ ):
+ idx = handler.config.device.index
+ try:
+ import pynvml
+
+ pynvml.nvmlInit()
+ handle = pynvml.nvmlDeviceGetHandleByIndex(idx)
+ raw_name = pynvml.nvmlDeviceGetName(handle)
+ name = (
+ raw_name.decode("utf-8", errors="replace")
+ if isinstance(raw_name, bytes)
+ else str(raw_name)
+ )
+ memory = pynvml.nvmlDeviceGetMemoryInfo(handle)
+ pynvml.nvmlShutdown()
+ return {
+ "name": f"{name} [ID: {idx}]",
+ "vram": memory.total // (1024 * 1024),
+ "vramUsed": memory.used // (1024 * 1024),
+ }
+ except Exception:
+ pass
+ return _orig_get_gpu_info(self)
+
+ GpuInfoImpl.get_gpu_info = patched_get_gpu_info
+ GpuInfoImpl._fixed_vram_patch = True
+
+ return app
diff --git a/LTX2.3/patches/handlers/__pycache__/video_generation_handler.cpython-313.pyc b/LTX2.3/patches/handlers/__pycache__/video_generation_handler.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..d22db5171a7ef63227d5d27e1d0f188b872413e9
Binary files /dev/null and b/LTX2.3/patches/handlers/__pycache__/video_generation_handler.cpython-313.pyc differ
diff --git a/LTX2.3/patches/handlers/video_generation_handler.py b/LTX2.3/patches/handlers/video_generation_handler.py
new file mode 100644
index 0000000000000000000000000000000000000000..5ec2f29a7bc77e97dc980fdc502441bb582c72ff
--- /dev/null
+++ b/LTX2.3/patches/handlers/video_generation_handler.py
@@ -0,0 +1,868 @@
+"""Video generation orchestration handler."""
+
+from __future__ import annotations
+
+import logging
+import os
+import tempfile
+import time
+import uuid
+from datetime import datetime
+from pathlib import Path
+from threading import RLock
+from typing import TYPE_CHECKING
+
+from PIL import Image
+
+from api_types import (
+ GenerateVideoRequest,
+ GenerateVideoResponse,
+ ImageConditioningInput,
+ VideoCameraMotion,
+)
+from _routes._errors import HTTPError
+from handlers.base import StateHandlerBase
+from handlers.generation_handler import GenerationHandler
+from handlers.pipelines_handler import PipelinesHandler
+from handlers.text_handler import TextHandler
+from runtime_config.model_download_specs import resolve_model_path
+from server_utils.media_validation import (
+ normalize_optional_path,
+ validate_audio_file,
+ validate_image_file,
+)
+from services.interfaces import LTXAPIClient
+from state.app_state_types import AppState
+from state.app_settings import should_video_generate_with_ltx_api
+
+if TYPE_CHECKING:
+ from runtime_config.runtime_config import RuntimeConfig
+
+logger = logging.getLogger(__name__)
+
+FORCED_API_MODEL_MAP: dict[str, str] = {
+ "fast": "ltx-2-3-fast",
+ "pro": "ltx-2-3-pro",
+}
+FORCED_API_RESOLUTION_MAP: dict[str, dict[str, str]] = {
+ "1080p": {"16:9": "1920x1080", "9:16": "1080x1920"},
+ "1440p": {"16:9": "2560x1440", "9:16": "1440x2560"},
+ "2160p": {"16:9": "3840x2160", "9:16": "2160x3840"},
+}
+A2V_FORCED_API_RESOLUTION = "1920x1080"
+FORCED_API_ALLOWED_ASPECT_RATIOS = {"16:9", "9:16"}
+FORCED_API_ALLOWED_FPS = {24, 25, 48, 50}
+
+
+def _get_allowed_durations(model_id: str, resolution_label: str, fps: int) -> set[int]:
+ if model_id == "ltx-2-3-fast" and resolution_label == "1080p" and fps in {24, 25}:
+ return {6, 8, 10, 12, 14, 16, 18, 20}
+ return {6, 8, 10}
+
+
+class VideoGenerationHandler(StateHandlerBase):
+ def __init__(
+ self,
+ state: AppState,
+ lock: RLock,
+ generation_handler: GenerationHandler,
+ pipelines_handler: PipelinesHandler,
+ text_handler: TextHandler,
+ ltx_api_client: LTXAPIClient,
+ config: RuntimeConfig,
+ ) -> None:
+ super().__init__(state, lock, config)
+ self._generation = generation_handler
+ self._pipelines = pipelines_handler
+ self._text = text_handler
+ self._ltx_api_client = ltx_api_client
+
+ def generate(self, req: GenerateVideoRequest) -> GenerateVideoResponse:
+ if should_video_generate_with_ltx_api(
+ force_api_generations=self.config.force_api_generations,
+ settings=self.state.app_settings,
+ ):
+ return self._generate_forced_api(req)
+
+ if self._generation.is_generation_running():
+ raise HTTPError(409, "Generation already in progress")
+
+ resolution = req.resolution
+
+ duration = int(float(req.duration))
+ fps = int(float(req.fps))
+
+ audio_path = normalize_optional_path(req.audioPath)
+ if audio_path:
+ return self._generate_a2v(req, duration, fps, audio_path=audio_path)
+
+ logger.info("Resolution %s - using fast pipeline", resolution)
+
+ RESOLUTION_MAP_16_9: dict[str, tuple[int, int]] = {
+ "540p": (1024, 576),
+ "720p": (1280, 704),
+ "1080p": (1920, 1088),
+ }
+
+ def get_16_9_size(res: str) -> tuple[int, int]:
+ return RESOLUTION_MAP_16_9.get(res, (1280, 704))
+
+ def get_9_16_size(res: str) -> tuple[int, int]:
+ w, h = get_16_9_size(res)
+ return h, w
+
+ match req.aspectRatio:
+ case "9:16":
+ width, height = get_9_16_size(resolution)
+ case "16:9":
+ width, height = get_16_9_size(resolution)
+
+ num_frames = self._compute_num_frames(duration, fps)
+
+ image = None
+ image_path = normalize_optional_path(req.imagePath)
+ if image_path:
+ image = self._prepare_image(image_path, width, height)
+ logger.info("Image: %s -> %sx%s", image_path, width, height)
+
+ generation_id = self._make_generation_id()
+ seed = self._resolve_seed()
+
+ logger.info(
+ f"Request loraPath: '{req.loraPath}', loraStrength: {req.loraStrength}, inferenceSteps: {req.inferenceSteps}"
+ )
+
+ # 尝试支持自定义步数(实验性)
+ inference_steps = req.inferenceSteps
+ logger.info(f"Using inference steps: {inference_steps}")
+
+ loras = None
+ if req.loraPath and req.loraPath.strip():
+ try:
+ import os
+ from pathlib import Path
+ from ltx_core.loader import LoraPathStrengthAndSDOps
+ from ltx_core.loader.sd_ops import LTXV_LORA_COMFY_RENAMING_MAP
+
+ lora_path = req.loraPath.strip()
+ logger.info(
+ f"LoRA path: {lora_path}, exists: {os.path.exists(lora_path)}"
+ )
+
+ if os.path.exists(lora_path):
+ loras = [
+ LoraPathStrengthAndSDOps(
+ path=lora_path,
+ strength=req.loraStrength,
+ sd_ops=LTXV_LORA_COMFY_RENAMING_MAP,
+ )
+ ]
+ logger.info(
+ f"LoRA prepared: {lora_path} with strength {req.loraStrength}"
+ )
+ else:
+ logger.warning(f"LoRA file not found: {lora_path}")
+ except Exception as e:
+ logger.warning(f"Failed to load LoRA: {e}")
+ import traceback
+
+ logger.warning(f"LoRA traceback: {traceback.format_exc()}")
+ loras = None
+
+ lora_path_req = (req.loraPath or "").strip()
+ desired_sig = (
+ "fast",
+ lora_path_req if loras is not None else "",
+ round(float(req.loraStrength), 4) if loras is not None else 0.0,
+ )
+ try:
+ if loras is not None:
+ # 强制卸载并重新加载带LoRA的pipeline
+ logger.info("Unloading pipeline for LoRA...")
+ from keep_models_runtime import force_unload_gpu_pipeline
+
+ force_unload_gpu_pipeline(self._pipelines)
+
+ # 强制垃圾回收
+ import gc
+
+ gc.collect()
+ # 释放 CUDA 缓存,降低 LoRA 首次构建的显存峰值/碎片风险
+ try:
+ import torch
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+ torch.cuda.ipc_collect()
+ except Exception:
+ pass
+
+ gemma_root = self._pipelines._text_handler.resolve_gemma_root()
+ from runtime_config.model_download_specs import resolve_model_path
+ from services.fast_video_pipeline.ltx_fast_video_pipeline import (
+ LTXFastVideoPipeline,
+ )
+
+ checkpoint_path = str(
+ resolve_model_path(
+ self._pipelines.models_dir,
+ self._pipelines.config.model_download_specs,
+ "checkpoint",
+ )
+ )
+ upsampler_path = str(
+ resolve_model_path(
+ self._pipelines.models_dir,
+ self._pipelines.config.model_download_specs,
+ "upsampler",
+ )
+ )
+
+ logger.info(
+ f"Creating pipeline with LoRA: {loras}, steps: {inference_steps}"
+ )
+ from lora_injection import (
+ _lora_init_kwargs,
+ inject_loras_into_fast_pipeline,
+ )
+
+ lora_kw = _lora_init_kwargs(LTXFastVideoPipeline, loras)
+ pipeline = LTXFastVideoPipeline(
+ checkpoint_path,
+ gemma_root,
+ upsampler_path,
+ self._pipelines.config.device,
+ **lora_kw,
+ )
+ n_inj = inject_loras_into_fast_pipeline(pipeline, loras)
+ if hasattr(pipeline, "pipeline") and hasattr(
+ pipeline.pipeline, "model_ledger"
+ ):
+ try:
+ pipeline.pipeline.model_ledger.loras = tuple(loras)
+ except Exception:
+ pass
+ logger.info(
+ "LoRA 注入: init_kw=%s, 注入点=%s, model_ledger.loras=%s",
+ list(lora_kw.keys()),
+ n_inj,
+ getattr(
+ getattr(pipeline.pipeline, "model_ledger", None),
+ "loras",
+ None,
+ ),
+ )
+
+ from state.app_state_types import (
+ VideoPipelineState,
+ VideoPipelineWarmth,
+ GpuSlot,
+ )
+
+ state = VideoPipelineState(
+ pipeline=pipeline,
+ warmth=VideoPipelineWarmth.COLD,
+ is_compiled=False,
+ )
+
+ self._pipelines.state.gpu_slot = GpuSlot(
+ active_pipeline=state, generation=None
+ )
+ logger.info("Pipeline with LoRA loaded successfully")
+ else:
+ # 无论有没有LoRA,都尝试使用自定义步数重新加载pipeline
+ logger.info(f"Loading pipeline with {inference_steps} steps")
+ from keep_models_runtime import force_unload_gpu_pipeline
+
+ force_unload_gpu_pipeline(self._pipelines)
+
+ import gc
+
+ gc.collect()
+
+ gemma_root = self._pipelines._text_handler.resolve_gemma_root()
+ from runtime_config.model_download_specs import resolve_model_path
+ from services.fast_video_pipeline.ltx_fast_video_pipeline import (
+ LTXFastVideoPipeline,
+ )
+
+ checkpoint_path = str(
+ resolve_model_path(
+ self._pipelines.models_dir,
+ self._pipelines.config.model_download_specs,
+ "checkpoint",
+ )
+ )
+ upsampler_path = str(
+ resolve_model_path(
+ self._pipelines.models_dir,
+ self._pipelines.config.model_download_specs,
+ "upsampler",
+ )
+ )
+
+ pipeline = LTXFastVideoPipeline(
+ checkpoint_path,
+ gemma_root,
+ upsampler_path,
+ self._pipelines.config.device,
+ )
+
+ from state.app_state_types import (
+ VideoPipelineState,
+ VideoPipelineWarmth,
+ GpuSlot,
+ )
+
+ state = VideoPipelineState(
+ pipeline=pipeline,
+ warmth=VideoPipelineWarmth.COLD,
+ is_compiled=False,
+ )
+
+ self._pipelines.state.gpu_slot = GpuSlot(
+ active_pipeline=state, generation=None
+ )
+
+ self._pipelines._pipeline_signature = desired_sig
+
+ self._generation.start_generation(generation_id)
+
+ output_path = self.generate_video(
+ prompt=req.prompt,
+ image=image,
+ height=height,
+ width=width,
+ num_frames=num_frames,
+ fps=fps,
+ seed=seed,
+ camera_motion=req.cameraMotion,
+ negative_prompt=req.negativePrompt,
+ )
+
+ self._generation.complete_generation(output_path)
+ return GenerateVideoResponse(status="complete", video_path=output_path)
+
+ except Exception as e:
+ self._generation.fail_generation(str(e))
+ if "cancelled" in str(e).lower():
+ logger.info("Generation cancelled by user")
+ return GenerateVideoResponse(status="cancelled")
+
+ raise HTTPError(500, str(e)) from e
+
+ def generate_video(
+ self,
+ prompt: str,
+ image: Image.Image | None,
+ height: int,
+ width: int,
+ num_frames: int,
+ fps: float,
+ seed: int,
+ camera_motion: VideoCameraMotion,
+ negative_prompt: str,
+ ) -> str:
+ t_total_start = time.perf_counter()
+ gen_mode = "i2v" if image is not None else "t2v"
+ logger.info(
+ "[%s] Generation started (model=fast, %dx%d, %d frames, %d fps)",
+ gen_mode,
+ width,
+ height,
+ num_frames,
+ int(fps),
+ )
+
+ if self._generation.is_generation_cancelled():
+ raise RuntimeError("Generation was cancelled")
+
+ if not resolve_model_path(
+ self.models_dir, self.config.model_download_specs, "checkpoint"
+ ).exists():
+ raise RuntimeError(
+ "Models not downloaded. Please download the AI models first using the Model Status menu."
+ )
+
+ total_steps = 8
+
+ self._generation.update_progress("loading_model", 5, 0, total_steps)
+ t_load_start = time.perf_counter()
+ pipeline_state = self._pipelines.load_gpu_pipeline("fast", should_warm=False)
+ t_load_end = time.perf_counter()
+ logger.info("[%s] Pipeline load: %.2fs", gen_mode, t_load_end - t_load_start)
+
+ self._generation.update_progress("encoding_text", 10, 0, total_steps)
+
+ enhanced_prompt = prompt + self.config.camera_motion_prompts.get(
+ camera_motion, ""
+ )
+
+ images: list[ImageConditioningInput] = []
+ temp_image_path: str | None = None
+ if image is not None:
+ temp_image_path = tempfile.NamedTemporaryFile(
+ suffix=".png", delete=False
+ ).name
+ image.save(temp_image_path)
+ images = [
+ ImageConditioningInput(path=temp_image_path, frame_idx=0, strength=1.0)
+ ]
+
+ output_path = self._make_output_path()
+
+ try:
+ settings = self.state.app_settings
+ use_api_encoding = not self._text.should_use_local_encoding()
+ if image is not None:
+ enhance = use_api_encoding and settings.prompt_enhancer_enabled_i2v
+ else:
+ enhance = use_api_encoding and settings.prompt_enhancer_enabled_t2v
+
+ encoding_method = "api" if use_api_encoding else "local"
+ t_text_start = time.perf_counter()
+ self._text.prepare_text_encoding(enhanced_prompt, enhance_prompt=enhance)
+ t_text_end = time.perf_counter()
+ logger.info(
+ "[%s] Text encoding (%s): %.2fs",
+ gen_mode,
+ encoding_method,
+ t_text_end - t_text_start,
+ )
+
+ self._generation.update_progress("inference", 15, 0, total_steps)
+
+ height = round(height / 64) * 64
+ width = round(width / 64) * 64
+
+ t_inference_start = time.perf_counter()
+ pipeline_state.pipeline.generate(
+ prompt=enhanced_prompt,
+ seed=seed,
+ height=height,
+ width=width,
+ num_frames=num_frames,
+ frame_rate=fps,
+ images=images,
+ output_path=str(output_path),
+ )
+ t_inference_end = time.perf_counter()
+ logger.info(
+ "[%s] Inference: %.2fs", gen_mode, t_inference_end - t_inference_start
+ )
+
+ if self._generation.is_generation_cancelled():
+ if output_path.exists():
+ output_path.unlink()
+ raise RuntimeError("Generation was cancelled")
+
+ t_total_end = time.perf_counter()
+ logger.info(
+ "[%s] Total generation: %.2fs (load=%.2fs, text=%.2fs, inference=%.2fs)",
+ gen_mode,
+ t_total_end - t_total_start,
+ t_load_end - t_load_start,
+ t_text_end - t_text_start,
+ t_inference_end - t_inference_start,
+ )
+
+ self._generation.update_progress("complete", 100, total_steps, total_steps)
+ return str(output_path)
+ finally:
+ self._text.clear_api_embeddings()
+ if temp_image_path and os.path.exists(temp_image_path):
+ os.unlink(temp_image_path)
+
+ def _generate_a2v(
+ self, req: GenerateVideoRequest, duration: int, fps: int, *, audio_path: str
+ ) -> GenerateVideoResponse:
+ if req.model != "pro":
+ logger.warning(
+ "A2V local requested with model=%s; A2V always uses pro pipeline",
+ req.model,
+ )
+ validated_audio_path = validate_audio_file(audio_path)
+ audio_path_str = str(validated_audio_path)
+
+ # 支持竖屏和横屏
+ RESOLUTION_MAP: dict[str, tuple[int, int]] = {
+ "540p": (1024, 576),
+ "720p": (1280, 704),
+ "1080p": (1920, 1088),
+ }
+
+ base_w, base_h = RESOLUTION_MAP.get(req.resolution, (1280, 704))
+
+ # 根据 aspectRatio 调整分辨率
+ if req.aspectRatio == "9:16":
+ width, height = base_h, base_w # 竖屏
+ else:
+ width, height = base_w, base_h # 横屏
+
+ num_frames = self._compute_num_frames(duration, fps)
+
+ image = None
+ temp_image_path: str | None = None
+ image_path = normalize_optional_path(req.imagePath)
+ if image_path:
+ image = self._prepare_image(image_path, width, height)
+
+ # 获取首尾帧
+ start_frame_path = normalize_optional_path(getattr(req, "startFramePath", None))
+ end_frame_path = normalize_optional_path(getattr(req, "endFramePath", None))
+
+ seed = self._resolve_seed()
+
+ generation_id = self._make_generation_id()
+
+ temp_image_paths: list[str] = []
+ try:
+ a2v_state = self._pipelines.load_a2v_pipeline()
+ self._generation.start_generation(generation_id)
+
+ enhanced_prompt = req.prompt + self.config.camera_motion_prompts.get(
+ req.cameraMotion, ""
+ )
+ neg = (
+ req.negativePrompt
+ if req.negativePrompt
+ else self.config.default_negative_prompt
+ )
+
+ images: list[ImageConditioningInput] = []
+ temp_image_paths: list[str] = []
+
+ # 首帧
+ if start_frame_path:
+ start_img = self._prepare_image(start_frame_path, width, height)
+ temp_start_path = tempfile.NamedTemporaryFile(
+ suffix=".png", delete=False
+ ).name
+ start_img.save(temp_start_path)
+ temp_image_paths.append(temp_start_path)
+ images.append(
+ ImageConditioningInput(
+ path=temp_start_path, frame_idx=0, strength=1.0
+ )
+ )
+
+ # 中间图片(如果有)
+ if image is not None and not start_frame_path:
+ temp_image_path = tempfile.NamedTemporaryFile(
+ suffix=".png", delete=False
+ ).name
+ image.save(temp_image_path)
+ temp_image_paths.append(temp_image_path)
+ images.append(
+ ImageConditioningInput(
+ path=temp_image_path, frame_idx=0, strength=1.0
+ )
+ )
+
+ # 尾帧
+ if end_frame_path:
+ last_latent_idx = (num_frames - 1) // 8 + 1 - 1
+ end_img = self._prepare_image(end_frame_path, width, height)
+ temp_end_path = tempfile.NamedTemporaryFile(
+ suffix=".png", delete=False
+ ).name
+ end_img.save(temp_end_path)
+ temp_image_paths.append(temp_end_path)
+ images.append(
+ ImageConditioningInput(
+ path=temp_end_path, frame_idx=last_latent_idx, strength=1.0
+ )
+ )
+
+ output_path = self._make_output_path()
+
+ total_steps = 11 # distilled: 8 steps (stage 1) + 3 steps (stage 2)
+
+ a2v_settings = self.state.app_settings
+ a2v_use_api = not self._text.should_use_local_encoding()
+ if image is not None:
+ a2v_enhance = a2v_use_api and a2v_settings.prompt_enhancer_enabled_i2v
+ else:
+ a2v_enhance = a2v_use_api and a2v_settings.prompt_enhancer_enabled_t2v
+
+ self._generation.update_progress("loading_model", 5, 0, total_steps)
+ self._generation.update_progress("encoding_text", 10, 0, total_steps)
+ self._text.prepare_text_encoding(
+ enhanced_prompt, enhance_prompt=a2v_enhance
+ )
+ self._generation.update_progress("inference", 15, 0, total_steps)
+
+ a2v_state.pipeline.generate(
+ prompt=enhanced_prompt,
+ negative_prompt=neg,
+ seed=seed,
+ height=height,
+ width=width,
+ num_frames=num_frames,
+ frame_rate=fps,
+ num_inference_steps=total_steps,
+ images=images,
+ audio_path=audio_path_str,
+ audio_start_time=0.0,
+ audio_max_duration=None,
+ output_path=str(output_path),
+ )
+
+ if self._generation.is_generation_cancelled():
+ if output_path.exists():
+ output_path.unlink()
+ raise RuntimeError("Generation was cancelled")
+
+ self._generation.update_progress("complete", 100, total_steps, total_steps)
+ self._generation.complete_generation(str(output_path))
+ return GenerateVideoResponse(status="complete", video_path=str(output_path))
+
+ except Exception as e:
+ self._generation.fail_generation(str(e))
+ if "cancelled" in str(e).lower():
+ logger.info("Generation cancelled by user")
+ return GenerateVideoResponse(status="cancelled")
+ raise HTTPError(500, str(e)) from e
+ finally:
+ self._text.clear_api_embeddings()
+ # 清理所有临时图片
+ for tmp_path in temp_image_paths:
+ if tmp_path and os.path.exists(tmp_path):
+ try:
+ os.unlink(tmp_path)
+ except Exception:
+ pass
+ if temp_image_path and os.path.exists(temp_image_path):
+ try:
+ os.unlink(temp_image_path)
+ except Exception:
+ pass
+
+ def _prepare_image(self, image_path: str, width: int, height: int) -> Image.Image:
+ validated_path = validate_image_file(image_path)
+ try:
+ img = Image.open(validated_path).convert("RGB")
+ except Exception:
+ raise HTTPError(400, f"Invalid image file: {image_path}") from None
+ img_w, img_h = img.size
+ target_ratio = width / height
+ img_ratio = img_w / img_h
+ if img_ratio > target_ratio:
+ new_h = height
+ new_w = int(img_w * (height / img_h))
+ else:
+ new_w = width
+ new_h = int(img_h * (width / img_w))
+ resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
+ left = (new_w - width) // 2
+ top = (new_h - height) // 2
+ return resized.crop((left, top, left + width, top + height))
+
+ @staticmethod
+ def _make_generation_id() -> str:
+ return uuid.uuid4().hex[:8]
+
+ @staticmethod
+ def _compute_num_frames(duration: int, fps: int) -> int:
+ n = ((duration * fps) // 8) * 8 + 1
+ return max(n, 9)
+
+ def _resolve_seed(self) -> int:
+ settings = self.state.app_settings
+ if settings.seed_locked:
+ logger.info("Using locked seed: %s", settings.locked_seed)
+ return settings.locked_seed
+ return int(time.time()) % 2147483647
+
+ def _make_output_path(self) -> Path:
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ return (
+ self.config.outputs_dir
+ / f"ltx2_video_{timestamp}_{self._make_generation_id()}.mp4"
+ )
+
+ def _generate_forced_api(self, req: GenerateVideoRequest) -> GenerateVideoResponse:
+ if self._generation.is_generation_running():
+ raise HTTPError(409, "Generation already in progress")
+
+ generation_id = self._make_generation_id()
+ self._generation.start_api_generation(generation_id)
+
+ audio_path = normalize_optional_path(req.audioPath)
+ image_path = normalize_optional_path(req.imagePath)
+ has_input_audio = bool(audio_path)
+ has_input_image = bool(image_path)
+
+ try:
+ self._generation.update_progress("validating_request", 5, None, None)
+
+ api_key = self.state.app_settings.ltx_api_key.strip()
+ logger.info(
+ "Forced API generation route selected (key_present=%s)", bool(api_key)
+ )
+ if not api_key:
+ raise HTTPError(400, "PRO_API_KEY_REQUIRED")
+
+ requested_model = req.model.strip().lower()
+ api_model_id = FORCED_API_MODEL_MAP.get(requested_model)
+ if api_model_id is None:
+ raise HTTPError(400, "INVALID_FORCED_API_MODEL")
+
+ resolution_label = req.resolution
+ resolution_by_aspect = FORCED_API_RESOLUTION_MAP.get(resolution_label)
+ if resolution_by_aspect is None:
+ raise HTTPError(400, "INVALID_FORCED_API_RESOLUTION")
+
+ aspect_ratio = req.aspectRatio.strip()
+ if aspect_ratio not in FORCED_API_ALLOWED_ASPECT_RATIOS:
+ raise HTTPError(400, "INVALID_FORCED_API_ASPECT_RATIO")
+
+ api_resolution = resolution_by_aspect[aspect_ratio]
+
+ prompt = req.prompt
+
+ if self._generation.is_generation_cancelled():
+ raise RuntimeError("Generation was cancelled")
+
+ if has_input_audio:
+ if requested_model != "pro":
+ logger.warning(
+ "A2V requested with model=%s; overriding to 'pro'",
+ requested_model,
+ )
+ api_model_id = FORCED_API_MODEL_MAP["pro"]
+ if api_resolution != A2V_FORCED_API_RESOLUTION:
+ logger.warning(
+ "A2V requested with resolution=%s; overriding to '%s'",
+ api_resolution,
+ A2V_FORCED_API_RESOLUTION,
+ )
+ api_resolution = A2V_FORCED_API_RESOLUTION
+ validated_audio_path = validate_audio_file(audio_path)
+ validated_image_path: Path | None = None
+ if image_path is not None:
+ validated_image_path = validate_image_file(image_path)
+
+ self._generation.update_progress("uploading_audio", 20, None, None)
+ audio_uri = self._ltx_api_client.upload_file(
+ api_key=api_key,
+ file_path=str(validated_audio_path),
+ )
+ image_uri: str | None = None
+ if validated_image_path is not None:
+ self._generation.update_progress("uploading_image", 35, None, None)
+ image_uri = self._ltx_api_client.upload_file(
+ api_key=api_key,
+ file_path=str(validated_image_path),
+ )
+ self._generation.update_progress("inference", 55, None, None)
+ video_bytes = self._ltx_api_client.generate_audio_to_video(
+ api_key=api_key,
+ prompt=prompt,
+ audio_uri=audio_uri,
+ image_uri=image_uri,
+ model=api_model_id,
+ resolution=api_resolution,
+ )
+ self._generation.update_progress("downloading_output", 85, None, None)
+ elif has_input_image:
+ validated_image_path = validate_image_file(image_path)
+
+ duration = self._parse_forced_numeric_field(
+ req.duration, "INVALID_FORCED_API_DURATION"
+ )
+ fps = self._parse_forced_numeric_field(
+ req.fps, "INVALID_FORCED_API_FPS"
+ )
+ if fps not in FORCED_API_ALLOWED_FPS:
+ raise HTTPError(400, "INVALID_FORCED_API_FPS")
+ if duration not in _get_allowed_durations(
+ api_model_id, resolution_label, fps
+ ):
+ raise HTTPError(400, "INVALID_FORCED_API_DURATION")
+
+ generate_audio = self._parse_audio_flag(req.audio)
+ self._generation.update_progress("uploading_image", 20, None, None)
+ image_uri = self._ltx_api_client.upload_file(
+ api_key=api_key,
+ file_path=str(validated_image_path),
+ )
+ self._generation.update_progress("inference", 55, None, None)
+ video_bytes = self._ltx_api_client.generate_image_to_video(
+ api_key=api_key,
+ prompt=prompt,
+ image_uri=image_uri,
+ model=api_model_id,
+ resolution=api_resolution,
+ duration=float(duration),
+ fps=float(fps),
+ generate_audio=generate_audio,
+ camera_motion=req.cameraMotion,
+ )
+ self._generation.update_progress("downloading_output", 85, None, None)
+ else:
+ duration = self._parse_forced_numeric_field(
+ req.duration, "INVALID_FORCED_API_DURATION"
+ )
+ fps = self._parse_forced_numeric_field(
+ req.fps, "INVALID_FORCED_API_FPS"
+ )
+ if fps not in FORCED_API_ALLOWED_FPS:
+ raise HTTPError(400, "INVALID_FORCED_API_FPS")
+ if duration not in _get_allowed_durations(
+ api_model_id, resolution_label, fps
+ ):
+ raise HTTPError(400, "INVALID_FORCED_API_DURATION")
+
+ generate_audio = self._parse_audio_flag(req.audio)
+ self._generation.update_progress("inference", 55, None, None)
+ video_bytes = self._ltx_api_client.generate_text_to_video(
+ api_key=api_key,
+ prompt=prompt,
+ model=api_model_id,
+ resolution=api_resolution,
+ duration=float(duration),
+ fps=float(fps),
+ generate_audio=generate_audio,
+ camera_motion=req.cameraMotion,
+ )
+ self._generation.update_progress("downloading_output", 85, None, None)
+
+ if self._generation.is_generation_cancelled():
+ raise RuntimeError("Generation was cancelled")
+
+ output_path = self._write_forced_api_video(video_bytes)
+ if self._generation.is_generation_cancelled():
+ output_path.unlink(missing_ok=True)
+ raise RuntimeError("Generation was cancelled")
+
+ self._generation.update_progress("complete", 100, None, None)
+ self._generation.complete_generation(str(output_path))
+ return GenerateVideoResponse(status="complete", video_path=str(output_path))
+ except HTTPError as e:
+ self._generation.fail_generation(e.detail)
+ raise
+ except Exception as e:
+ self._generation.fail_generation(str(e))
+ if "cancelled" in str(e).lower():
+ logger.info("Generation cancelled by user")
+ return GenerateVideoResponse(status="cancelled")
+ raise HTTPError(500, str(e)) from e
+
+ def _write_forced_api_video(self, video_bytes: bytes) -> Path:
+ output_path = self._make_output_path()
+ output_path.write_bytes(video_bytes)
+ return output_path
+
+ @staticmethod
+ def _parse_forced_numeric_field(raw_value: str, error_detail: str) -> int:
+ try:
+ return int(float(raw_value))
+ except (TypeError, ValueError):
+ raise HTTPError(400, error_detail) from None
+
+ @staticmethod
+ def _parse_audio_flag(audio_value: str | bool) -> bool:
+ if isinstance(audio_value, bool):
+ return audio_value
+ normalized = audio_value.strip().lower()
+ return normalized in {"1", "true", "yes", "on"}
diff --git a/LTX2.3/patches/keep_models_runtime.py b/LTX2.3/patches/keep_models_runtime.py
new file mode 100644
index 0000000000000000000000000000000000000000..bcb51df72448bce95a2a3f8236989928c54ac8b0
--- /dev/null
+++ b/LTX2.3/patches/keep_models_runtime.py
@@ -0,0 +1,16 @@
+"""仅提供强制卸载 GPU 管线。「保持模型加载」功能已移除。"""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+def force_unload_gpu_pipeline(pipelines: Any) -> None:
+ """释放推理管线占用的显存(切换 GPU、清理、LoRA 重建等场景)。"""
+ try:
+ pipelines.unload_gpu_pipeline()
+ except Exception:
+ try:
+ type(pipelines).unload_gpu_pipeline(pipelines)
+ except Exception:
+ pass
diff --git a/LTX2.3/patches/launcher.py b/LTX2.3/patches/launcher.py
new file mode 100644
index 0000000000000000000000000000000000000000..21c1e52bfd0d9e665a5c7a747c177b24a8e82ea6
--- /dev/null
+++ b/LTX2.3/patches/launcher.py
@@ -0,0 +1,20 @@
+
+import sys
+import os
+
+patch_dir = r"C:\Users\1-xuanran\Desktop\ltx-TEST\patches"
+backend_dir = r"C:\Program Files\LTX Desktop\resources\backend"
+
+# 防御性清除:强行剥离所有的默认 backend_dir 引用
+sys.path = [p for p in sys.path if p and os.path.normpath(p) != os.path.normpath(backend_dir)]
+sys.path = [p for p in sys.path if p and p != "." and p != ""]
+
+# 绝对插队注入:优先搜索 PATCHES_DIR
+sys.path.insert(0, patch_dir)
+sys.path.insert(1, backend_dir)
+
+import uvicorn
+from ltx2_server import app
+
+if __name__ == '__main__':
+ uvicorn.run(app, host="0.0.0.0", port=3000, log_level="info", access_log=False)
diff --git a/LTX2.3/patches/lora_build_hook.py b/LTX2.3/patches/lora_build_hook.py
new file mode 100644
index 0000000000000000000000000000000000000000..95bc42637283d3185a862ee3acd88e87749346f7
--- /dev/null
+++ b/LTX2.3/patches/lora_build_hook.py
@@ -0,0 +1,104 @@
+"""
+在 SingleGPUModelBuilder.build() 时合并「当前请求」的用户 LoRA。
+
+桌面版 Fast 管线往往只在 model_ledger 上挂 loras,真正 load 权重时仍用
+初始化时的空 loras Builder;此处对 DiT/Transformer 的 Builder 在 build 前注入。
+"""
+
+from __future__ import annotations
+
+import contextvars
+import logging
+from dataclasses import replace
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+# 当前 HTTP 请求/生成任务中要额外融合的 LoRA(LoraPathStrengthAndSDOps 元组)
+_pending_user_loras: contextvars.ContextVar[tuple[Any, ...] | None] = contextvars.ContextVar(
+ "ltx_pending_user_loras", default=None
+)
+
+_HOOK_INSTALLED = False
+
+
+def pending_loras_token(loras: tuple[Any, ...] | None):
+ """返回 contextvar Token,供 finally reset;loras 为 None 表示本任务不用额外 LoRA。"""
+ return _pending_user_loras.set(loras)
+
+
+def reset_pending_loras(token: contextvars.Token | None) -> None:
+ if token is not None:
+ _pending_user_loras.reset(token)
+
+
+def _get_pending() -> tuple[Any, ...] | None:
+ return _pending_user_loras.get()
+
+
+def _is_ltx_diffusion_transformer_builder(builder: Any) -> bool:
+ """避免给 Gemma / VAE / Upsampler 的 Builder 误加视频 LoRA。"""
+ cfg = getattr(builder, "model_class_configurator", None)
+ if cfg is None:
+ return False
+ name = getattr(cfg, "__name__", "") or ""
+ # 排除明显非 DiT 的
+ for bad in (
+ "Gemma",
+ "VideoEncoder",
+ "VideoDecoder",
+ "AudioEncoder",
+ "AudioDecoder",
+ "Vocoder",
+ "EmbeddingsProcessor",
+ "LatentUpsampler",
+ ):
+ if bad in name:
+ return False
+ try:
+ from ltx_core.model.transformer import LTXModelConfigurator
+
+ if isinstance(cfg, type):
+ try:
+ if issubclass(cfg, LTXModelConfigurator):
+ return True
+ except TypeError:
+ pass
+ if cfg is LTXModelConfigurator:
+ return True
+ except ImportError:
+ pass
+ # 兜底:LTX 主 transformer 配置器命名习惯(排除已列出的 VAE/Gemma)
+ return "LTX" in name and "ModelConfigurator" in name
+
+
+def install_lora_build_hook() -> None:
+ global _HOOK_INSTALLED
+ if _HOOK_INSTALLED:
+ return
+ try:
+ from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder
+ except ImportError:
+ logger.warning("lora_build_hook: 无法导入 SingleGPUModelBuilder,跳过")
+ return
+
+ _orig_build = SingleGPUModelBuilder.build
+
+ def build(self: Any, *args: Any, **kwargs: Any) -> Any:
+ extra = _get_pending()
+ if extra and _is_ltx_diffusion_transformer_builder(self):
+ have = {getattr(x, "path", None) for x in self.loras}
+ add = tuple(x for x in extra if getattr(x, "path", None) not in have)
+ if add:
+ merged = (*tuple(self.loras), *add)
+ self = replace(self, loras=merged)
+ logger.info(
+ "lora_build_hook: 已向 DiT Builder 合并 %d 个用户 LoRA: %s",
+ len(add),
+ [getattr(x, "path", x) for x in add],
+ )
+ return _orig_build(self, *args, **kwargs)
+
+ SingleGPUModelBuilder.build = build # type: ignore[method-assign]
+ _HOOK_INSTALLED = True
+ logger.info("lora_build_hook: 已挂载 SingleGPUModelBuilder.build")
diff --git a/LTX2.3/patches/lora_injection.py b/LTX2.3/patches/lora_injection.py
new file mode 100644
index 0000000000000000000000000000000000000000..c7c708293e405e7edf0125e8275f29209f5a9595
--- /dev/null
+++ b/LTX2.3/patches/lora_injection.py
@@ -0,0 +1,139 @@
+"""将用户 LoRA 注入 Fast 视频管线:兼容 ModelLedger 与 LTX-2 DiffusionStage/Builder。"""
+
+from __future__ import annotations
+
+import inspect
+import logging
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+
+def _lora_init_kwargs(
+ pipeline_cls: type, loras: list[Any] | tuple[Any, ...]
+) -> dict[str, Any]:
+ if not loras:
+ return {}
+ try:
+ sig = inspect.signature(pipeline_cls.__init__)
+ names = sig.parameters.keys()
+ except (TypeError, ValueError):
+ return {}
+ tup = tuple(loras)
+ for key in ("loras", "lora", "extra_loras", "user_loras"):
+ if key in names:
+ return {key: tup}
+ return {}
+
+
+def inject_loras_into_fast_pipeline(ltx_pipe: Any, loras: list[Any] | tuple[Any, ...]) -> int:
+ """在已构造的管线上尽量把 LoRA 写进会参与 build 的 Builder / ledger。返回成功写入的处数。"""
+ if not loras:
+ return 0
+ tup = tuple(loras)
+ patched = 0
+ visited: set[int] = set()
+
+ def visit(obj: Any, depth: int) -> None:
+ nonlocal patched
+ if obj is None or depth > 10:
+ return
+ oid = id(obj)
+ if oid in visited:
+ return
+ visited.add(oid)
+
+ # ModelLedger.loras(旧桌面)
+ ml = getattr(obj, "model_ledger", None)
+ if ml is not None:
+ try:
+ ml.loras = tup
+ patched += 1
+ logger.info("LoRA: 已设置 model_ledger.loras")
+ except Exception as e:
+ logger.debug("model_ledger.loras: %s", e)
+
+ # SingleGPUModelBuilder.with_loras(常见与变体属性名)
+ for holder in (obj, ml):
+ if holder is None:
+ continue
+ candidates: list[Any] = []
+ for attr in (
+ "_transformer_builder",
+ "transformer_builder",
+ "_model_builder",
+ "model_builder",
+ ):
+ tb = getattr(holder, attr, None)
+ if tb is not None:
+ candidates.append((attr, tb))
+ try:
+ for attr in dir(holder):
+ al = attr.lower()
+ if "transformer" in al and "builder" in al and attr not in (
+ "_transformer_builder",
+ "transformer_builder",
+ ):
+ tb = getattr(holder, attr, None)
+ if tb is not None:
+ candidates.append((attr, tb))
+ except Exception:
+ pass
+ for attr, tb in candidates:
+ if hasattr(tb, "with_loras"):
+ try:
+ new_tb = tb.with_loras(tup)
+ setattr(holder, attr, new_tb)
+ patched += 1
+ logger.info("LoRA: 已更新 %s.with_loras", attr)
+ except Exception as e:
+ logger.debug("with_loras %s: %s", attr, e)
+
+ # DiffusionStage(类名或 isinstance)
+ is_diffusion = type(obj).__name__ == "DiffusionStage"
+ if not is_diffusion:
+ try:
+ from ltx_pipelines.utils.blocks import DiffusionStage as _DS
+
+ is_diffusion = isinstance(obj, _DS)
+ except ImportError:
+ pass
+ if is_diffusion:
+ tb = getattr(obj, "_transformer_builder", None)
+ if tb is not None and hasattr(tb, "with_loras"):
+ try:
+ obj._transformer_builder = tb.with_loras(tup)
+ patched += 1
+ logger.info("LoRA: 已写入 DiffusionStage._transformer_builder")
+ except Exception as e:
+ logger.debug("DiffusionStage: %s", e)
+
+ # 常见嵌套属性
+ for name in (
+ "pipeline",
+ "inner",
+ "_inner",
+ "fast_pipeline",
+ "_pipeline",
+ "stage_1",
+ "stage_2",
+ "stage",
+ "_stage",
+ "stages",
+ "diffusion",
+ "_diffusion",
+ ):
+ try:
+ ch = getattr(obj, name, None)
+ except Exception:
+ continue
+ if ch is not None and ch is not obj:
+ visit(ch, depth + 1)
+
+ if isinstance(obj, (list, tuple)):
+ for item in obj[:8]:
+ visit(item, depth + 1)
+
+ root = getattr(ltx_pipe, "pipeline", ltx_pipe)
+ visit(root, 0)
+ return patched
diff --git a/LTX2.3/patches/low_vram_runtime.py b/LTX2.3/patches/low_vram_runtime.py
new file mode 100644
index 0000000000000000000000000000000000000000..8001b52fbef52e54f70dafdd19286b56686003f7
--- /dev/null
+++ b/LTX2.3/patches/low_vram_runtime.py
@@ -0,0 +1,155 @@
+"""低显存模式:尽量降峰值显存(以速度换显存);效果取决于官方管线是否支持 offload。"""
+
+from __future__ import annotations
+
+import gc
+import logging
+import os
+import types
+from pathlib import Path
+from typing import Any
+
+logger = logging.getLogger("ltx_low_vram")
+
+
+def _ltx_desktop_config_dir() -> Path:
+ p = (
+ Path(os.environ.get("LOCALAPPDATA", os.path.expanduser("~/AppData/Local")))
+ / "LTXDesktop"
+ )
+ p.mkdir(parents=True, exist_ok=True)
+ return p.resolve()
+
+
+def low_vram_pref_path() -> Path:
+ return _ltx_desktop_config_dir() / "low_vram_mode.pref"
+
+
+def read_low_vram_pref() -> bool:
+ f = low_vram_pref_path()
+ if not f.is_file():
+ return False
+ return f.read_text(encoding="utf-8").strip().lower() in ("1", "true", "yes", "on")
+
+
+def write_low_vram_pref(enabled: bool) -> None:
+ low_vram_pref_path().write_text(
+ "true\n" if enabled else "false\n", encoding="utf-8"
+ )
+
+
+def apply_low_vram_config_tweaks(handler: Any) -> None:
+ """在官方 RuntimeConfig 上尽量关闭 fast 超分等(若字段存在)。"""
+ cfg = getattr(handler, "config", None)
+ if cfg is None:
+ return
+ fm = getattr(cfg, "fast_model", None)
+ if fm is None:
+ return
+ try:
+ if hasattr(fm, "model_copy"):
+ updated = fm.model_copy(update={"use_upscaler": False})
+ setattr(cfg, "fast_model", updated)
+ elif hasattr(fm, "use_upscaler"):
+ setattr(fm, "use_upscaler", False)
+ except Exception as e:
+ logger.debug("low_vram: 无法关闭 fast_model.use_upscaler: %s", e)
+
+
+def install_low_vram_on_pipelines(handler: Any) -> None:
+ """启动时读取偏好,挂到 pipelines 上供各补丁读取。"""
+ pl = handler.pipelines
+ low = read_low_vram_pref()
+ setattr(pl, "low_vram_mode", bool(low))
+ if low:
+ apply_low_vram_config_tweaks(handler)
+ logger.info(
+ "low_vram_mode: 已开启(尝试关闭 fast 超分;若显存仍高,多为权重常驻 GPU,需降分辨率/时长或 FP8 权重)"
+ )
+
+
+def install_low_vram_pipeline_hooks(pl: Any) -> None:
+ """在 load_gpu_pipeline / load_a2v 返回后尝试 Diffusers 式 CPU offload(无则静默)。"""
+ if getattr(pl, "_ltx_low_vram_hooks_installed", False):
+ return
+ pl._ltx_low_vram_hooks_installed = True
+
+ if hasattr(pl, "load_gpu_pipeline"):
+ _orig_gpu = pl.load_gpu_pipeline
+ pl._ltx_orig_load_gpu_for_low_vram = _orig_gpu
+
+ def _load_gpu_wrapped(self: Any, *a: Any, **kw: Any) -> Any:
+ r = _orig_gpu(*a, **kw)
+ if getattr(self, "low_vram_mode", False):
+ try_sequential_offload_on_pipeline_state(r)
+ return r
+
+ pl.load_gpu_pipeline = types.MethodType(_load_gpu_wrapped, pl)
+
+ if hasattr(pl, "load_a2v_pipeline"):
+ _orig_a2v = pl.load_a2v_pipeline
+ pl._ltx_orig_load_a2v_for_low_vram = _orig_a2v
+
+ def _load_a2v_wrapped(self: Any, *a: Any, **kw: Any) -> Any:
+ r = _orig_a2v(*a, **kw)
+ if getattr(self, "low_vram_mode", False):
+ try_sequential_offload_on_pipeline_state(r)
+ return r
+
+ pl.load_a2v_pipeline = types.MethodType(_load_a2v_wrapped, pl)
+
+
+def try_sequential_offload_on_pipeline_state(state: Any) -> None:
+ """若底层为 Diffusers 风格 API,尝试按层 CPU offload(显著变慢、降峰值)。"""
+ if state is None:
+ return
+ root = getattr(state, "pipeline", state)
+ candidates: list[Any] = [root]
+ inner = getattr(root, "pipeline", None)
+ if inner is not None and inner is not root:
+ candidates.append(inner)
+ for obj in candidates:
+ for method_name in (
+ "enable_sequential_cpu_offload",
+ "enable_model_cpu_offload",
+ ):
+ fn = getattr(obj, method_name, None)
+ if callable(fn):
+ try:
+ fn()
+ logger.info(
+ "low_vram_mode: 已对管线调用 %s()",
+ method_name,
+ )
+ return
+ except Exception as e:
+ logger.debug(
+ "low_vram_mode: %s() 失败(可忽略): %s",
+ method_name,
+ e,
+ )
+
+
+def maybe_release_pipeline_after_task(handler: Any) -> None:
+ """单次生成结束后:低显存模式下强制卸载管线并回收缓存。"""
+ pl = getattr(handler, "pipelines", None) or getattr(handler, "_pipelines", None)
+ if pl is None or not getattr(pl, "low_vram_mode", False):
+ return
+ try:
+ from keep_models_runtime import force_unload_gpu_pipeline
+
+ force_unload_gpu_pipeline(pl)
+ except Exception as e:
+ logger.debug("low_vram_mode: 任务后卸载失败: %s", e)
+ try:
+ pl._pipeline_signature = None
+ except Exception:
+ pass
+ gc.collect()
+ try:
+ import torch
+
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+ except Exception:
+ pass
diff --git a/LTX2.3/patches/runtime_policy.py b/LTX2.3/patches/runtime_policy.py
new file mode 100644
index 0000000000000000000000000000000000000000..dc11d555c21cf3fd7cc29fa8bb9bb76f27b98c07
--- /dev/null
+++ b/LTX2.3/patches/runtime_policy.py
@@ -0,0 +1,21 @@
+"""Runtime policy decisions for forced API mode."""
+
+from __future__ import annotations
+
+
+def decide_force_api_generations(
+ system: str, cuda_available: bool, vram_gb: int | None
+) -> bool:
+ """Return whether API-only generation must be forced for this runtime."""
+ if system == "Darwin":
+ return True
+
+ if system in ("Windows", "Linux"):
+ if not cuda_available:
+ return True
+ if vram_gb is None:
+ return True
+ return vram_gb < 6
+
+ # Fail closed for non-target platforms unless explicitly relaxed.
+ return True
diff --git a/LTX2.3/patches/settings.json b/LTX2.3/patches/settings.json
new file mode 100644
index 0000000000000000000000000000000000000000..5c0f81063acff1ee9d694d71400da88b63c232b7
--- /dev/null
+++ b/LTX2.3/patches/settings.json
@@ -0,0 +1,22 @@
+{
+ "use_torch_compile": false,
+ "load_on_startup": false,
+ "ltx_api_key": "1231",
+ "user_prefers_ltx_api_video_generations": false,
+ "fal_api_key": "",
+ "use_local_text_encoder": true,
+ "fast_model": {
+ "use_upscaler": true
+ },
+ "pro_model": {
+ "steps": 20,
+ "use_upscaler": true
+ },
+ "prompt_cache_size": 100,
+ "prompt_enhancer_enabled_t2v": true,
+ "prompt_enhancer_enabled_i2v": false,
+ "gemini_api_key": "",
+ "seed_locked": false,
+ "locked_seed": 42,
+ "models_dir": ""
+}
diff --git a/LTX2.3/run.bat b/LTX2.3/run.bat
new file mode 100644
index 0000000000000000000000000000000000000000..f5da5e68342a6fdf9c6182b9917472d150f5eeec
--- /dev/null
+++ b/LTX2.3/run.bat
@@ -0,0 +1,38 @@
+@echo off
+title LTX-2 Cinematic Workstation
+
+echo =========================================================
+echo LTX-2 Cinematic UI Booting...
+echo =========================================================
+echo.
+
+set "LTX_PY=%USERPROFILE%\AppData\Local\LTXDesktop\python\python.exe"
+set "LTX_UI_URL=http://127.0.0.1:4000/"
+
+if exist "%LTX_PY%" (
+ echo [SUCCESS] LTX Bundled Python environment detected!
+ echo [INFO] Browser will open automatically when UI is ready...
+ 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 } }"
+ echo [INFO] Starting workspace natively...
+ echo ---------------------------------------------------------
+ "%LTX_PY%" main.py
+ pause
+ exit /b
+)
+
+python --version >nul 2>&1
+if %errorlevel% equ 0 (
+ echo [WARNING] LTX Bundled Python not found.
+ echo [INFO] Browser will open automatically when UI is ready...
+ 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 } }"
+ echo [INFO] Falling back to global Python environment...
+ echo ---------------------------------------------------------
+ python main.py
+ pause
+ exit /b
+)
+
+echo [ERROR] FATAL: No Python interpreter found on this system.
+echo [INFO] Please run install.bat to download and set up Python!
+echo.
+pause
diff --git a/README.md b/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..4b4a25255f86b6108e90c1b5a94fe0f1d43024b2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,53 @@
+---
+license: apache-2.0
+base_model:
+- Lightricks/LTX-2.3
+pipeline_tag: text-to-video
+tags:
+- code
+---
+
+Cloud image: https://studio.aigate.cc/images/1093173874974662656?channel=R6P1L7N3J
+
+Limited availability—free 72-hour online trial: https://c2c730053ea44cc890a14f772cc8d060.region1.waas.aigate.cc
+
+
+----
+
+Updated April 3, 2026:
+
+Official version 1.0.3 has been released, significantly reducing video memory usage. Now, graphics cards with 12GB or more of video memory can run the program. Our tests show that, in a 10-second 720p frame test, the maximum video memory usage is only 13GB!
+Download Link: https://github.com/Lightricks/LTX-Desktop/releases/tag/v1.0.3
+
+
+
+----
+
+April 2, 2026 Update:
+
+1. Added LoRA functionality (place LoRA in the `loras` folder within the model directory).(Quick test LoRa: https://civitai.com/models/2482513/ltx23)
+
+2. Added model selection capability (currently testing quantization to reduce GPU memory usage; modifying the model does not currently lower the GPU memory requirement, pending future updates).
+
+3. Added multi-frame insertion functionality, with two generation modes: Mode 1: Inserts multiple frames into a latent space to directly generate a long video. Mode 2: Generates many independent first and last frame segments, which are then stitched together to form a complete video.
+
+
+----
+
+This program mainly optimizes the desktop version of LTX, breaking the generation time limitations and lowering the barrier to use. It now only requires 24GB to run, whereas the desktop version needs 32GB.
+
+Compared to the messy and complex workflows and error-prone nodes in the ComfyUI version, this one integrates all features, including image-to-video, text-to-video, start/end frames, lip-sync, video enhancement, and image generation.
+
+No need to install any third-party software—just install the LTX desktop version and you’re good to go. It’s very simple and efficient.
+
+Tutorial: https://youtu.be/rM_wUogtrOU
+
+Desktop version software download address: https://ltx.io/ltx-desktop
+
+The GitHub version has been uploaded: https://github.com/hero8152/LTX2.3-Multifunctional/tree/main
+
+It can be accessed via ComfyUI, node address: https://github.com/supart/ComfyUI_TY_LTX_Desktop_Bridge
+
+
+
+