diff --git a/.gitignore b/.gitignore index bbced1501..93833dc38 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ node_modules .output .env .cargo +.qoder target dist src-tauri/target diff --git a/docs/transcode/SOLUTION.md b/docs/transcode/SOLUTION.md new file mode 100644 index 000000000..eb88eaef7 --- /dev/null +++ b/docs/transcode/SOLUTION.md @@ -0,0 +1,285 @@ +# 录像转码模块 (Transcode) + +## 功能概述 + +将 JumpServer Guacamole 协议的会话录像(`.tar` 归档文件)转码为 H.264 MP4 视频文件。 + +支持三个平台的原生硬件/软件编码: + +| 平台 | 编码器 | 编码方式 | +|------|--------|----------| +| macOS | VideoToolbox | 硬件加速(GPU/ANE) | +| Linux | OpenH264 | 软件编码(CPU) | +| Windows | IMFSinkWriter | 系统级管道(自动选择硬件/软件编码器) | + +## 架构 + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Tauri Command: transcode_replays │ +│ (mod.rs) │ +│ - 接收 tar 文件路径列表 + 输出目录 + 用户配置 │ +│ - 解压 tar → 提取 replay.json + .part.gz │ +│ - gzip 解压得到原始 guacamole 数据 │ +│ - 调用 transcode_to_mp4 生成视频 │ +│ - 通过 Tauri emit("transcode-progress") 向前端报告进度 │ +└──────────────────────────┬───────────────────────────────────────────┘ + │ + ┌─────────────────┼──────────────────┐ + ▼ ▼ ▼ + ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ + │ parser.rs │ │ renderer.rs │ │ transcode.rs │ + │ │ │ │ │ │ + │ Guacamole │ │ 多图层画布 │ │ 编码+封装管线 │ + │ 协议解析器 │ │ 渲染器 │ │ (平台分支) │ + └──────────────┘ └──────────────┘ └───────┬──────────┘ + │ + ┌──────────────────┼─────────────────────┐ + ▼ ▼ ▼ + ┌────────────────┐ ┌────────────────┐ ┌──────────────────┐ + │ macOS / Linux │ │ Windows │ │ encoder.rs │ + │ │ │ │ │ │ + │ 多线程 chunk │ │ 单线程 │ │ 平台编码器 │ + │ 并行编码 │ │ Sink Writer │ │ 抽象 │ + │ + 手工 MP4 │ │ 直写 │ │ │ + └────────────────┘ └────────────────┘ └──────────────────┘ +``` + +### 模块说明 + +| 文件 | 职责 | +|------|------| +| `mod.rs` | Tauri command 入口、tar/gzip 解压、进度事件、分辨率/码率/功率配置 | +| `parser.rs` | 零拷贝解析 Guacamole 协议的 length-prefixed 指令格式 | +| `renderer.rs` | 维护多图层画布,处理 `size`/`img`/`blob`/`cfill`/`rect`/`copy` 绘图指令,合成 RGB 帧 | +| `transcode.rs` | 编码管线:帧时间线构建、渲染调度、缩放、编码、MP4 封装(按平台分支) | +| `encoder.rs` | 平台编码器抽象:macOS VideoToolbox / Linux OpenH264 / Windows IMFSinkWriter | + +## 转码流程 + +### 共享阶段(所有平台) + +``` +tar 文件 + ├─ .replay.json ← 会话元数据 (serde_json 解析) + └─ .0.part.gz ← gzip 压缩的 guacamole 录像(可多 part) + │ + ▼ (flate2 解压,按 part 序号拼接) + guacamole 原始指令流 + │ + ├─► scan_max_canvas_size 扫描全部 size 指令,获取最大画布尺寸 + ├─► parse_and_build_timeline 按 100ms 间隔采样帧时间线 + │ + ▼ + FrameInfo 列表 (timestamp + instruction_offset) +``` + +### macOS / Linux 编码路径 + +``` +FrameInfo 列表 + │ + ▼ (按 chunk 分割,每 chunk 50 帧) + │ + ├─► chunk 0: 主线程编码 → 提取 canonical SPS/PPS + │ + ├─► chunk 1..N: std::thread::spawn 多线程并行 + │ 每个线程: + │ 1. 从 instruction_offset 恢复 parser + renderer 状态 + │ 2. 逐帧 composite → RGB + │ 3. fast_image_resize 缩放至目标分辨率 + │ 4. RGB → I420/YUV420 转换 + │ 5. VideoToolbox / OpenH264 编码 → NAL units + │ 6. 帧去重 (FNV-1a hash),相同帧记录 repeat_count + │ + ▼ (收集所有 ChunkResult) + │ + ▼ write_mp4_faststart: + 1. 写入临时文件: mdat (裸 H.264 NAL 流 + 4 字节长度前缀) + 2. 追加 moov (手动构建 ISOBMFF box) + 3. 重排为 faststart 布局: ftyp → moov → mdat + 4. 删除临时文件 + +输出: MP4 文件 (ftyp + moov + mdat) +``` + +### Windows 编码路径 + +``` +FrameInfo 列表 + │ + ▼ (单线程单遍渲染) + │ + ├─► SinkWriterEncoder::new(output_path, w, h, bitrate, fps) + │ 1. CoInitializeEx + MFStartup + │ 2. MFCreateSinkWriterFromURL → IMFSinkWriter + │ 3. 配置输出类型: H.264 + High Profile + 码率 + │ 4. AddStream → 获取 stream_index + │ 5. 配置输入类型: RGB24 (BGR DIB 字节序) + │ 6. BeginWriting + │ + ├─► 逐帧循环: + │ 1. parser 回放至 sync 时间点 + │ 2. renderer.composite_into → RGB 帧 + │ 3. fast_image_resize 缩放至目标分辨率 + │ 4. 帧去重 (FNV-1a hash) + │ 5. write_frame: RGB→BGR 交换 + 垂直翻转 → IMFMediaBuffer → IMFSample → WriteSample + │ Sink Writer 内部自动完成: BGR→NV12 色彩转换 → H.264 编码 → MP4 封装 + │ + ├─► SinkWriterEncoder::finalize → Finalize + │ + ▼ + +输出: MP4 文件 (由系统 MP4 Muxer Sink 生成) +``` + +## 关键设计 + +### 帧采样策略 + +- 固定 10fps 输出,每 100ms 一个采样点 +- 首遍扫描 guac 指令流构建 `FrameInfo` 时间线 +- 最多 600 帧(长录像自动降采样) + +### 帧去重 + +- 对每帧 RGB 数据计算 FNV-1a 哈希(步长 8 像素采样) +- 连续相同帧不重复编码,而是记录 `repeat_count` +- macOS/Linux: 在 MP4 `stts` box 中写入重复计数 +- Windows: 重复调用 `WriteSample` 写入相同帧数据 + +### 分辨率对齐 + +- 编码宽高对齐到 16 的倍数(`& !15`) +- 确保 H.264 宏块(16×16)完整对齐,避免编码器内部填充导致的画质劣化 + +### 码率计算 + +```rust +// 针对屏幕录制内容优化(文字、图标、细线条) +bitrate = pixels × 5 bps // 5 bits per pixel +clamp(800 Kbps, 20 Mbps) +``` + +| 分辨率 | 码率 | +|--------|------| +| 1920×1080 | 10.4 Mbps | +| 1280×768 | 4.9 Mbps | +| 1024×768 | 3.9 Mbps | +| 640×360 | 1.2 Mbps | + +### 并行编码(macOS / Linux) + +- 帧列表按 chunk 分割(每 chunk 50 帧) +- 首 chunk 主线程编码以提取 canonical SPS/PPS +- 剩余 chunk 分配到 `min(可用 CPU × cpu_fraction, chunk 数)` 个线程并行 +- 每个线程独立创建 encoder 实例,独立解析 guac 指令恢复渲染状态 + +### Windows 单线程管线 + +- IMFSinkWriter 封装了完整的编码+封装管道,内部自动选择最优编码器(硬件优先) +- 单线程逐帧渲染→写入,无需手工管理 NAL/MP4 +- 硬件加速时编码器吞吐远高于渲染速度,单线程不构成瓶颈 + +### MP4 封装(macOS / Linux) + +- 手工构建 ISOBMFF box:`ftyp` → `moov` → `mdat` +- faststart 布局:moov 置于 mdat 之前,支持流式播放 +- 两遍写入:先写临时文件(mdat + moov),再重排为最终布局 + +## 平台编码器详情 + +### macOS — VideoToolbox + +``` +RGB → I420 (手工转换,BT.601 矩阵) + → shiguredo_video_toolbox crate + → Hardware Encoder (GPU/ANE) + → NAL units (Annex B) + → 手工拆分 + 4 字节长度前缀 +``` + +- Profile: Baseline + CAVLC +- GOP: 50 帧 (5 秒@10fps) +- 优先编码速度 (`prioritize_encoding_speed_over_quality: true`) + +### Linux — OpenH264 + +``` +RGB → YUV420 (openh264 crate 内置 RgbSliceU8 → YUVBuffer) + → openh264::Encoder + → NAL units (Annex B) + → split_annex_b 手工拆分 + → 4 字节长度前缀 +``` + +- 纯软件编码,CPU 密集型 +- 支持多线程 chunk 并行 + +### Windows — IMFSinkWriter + +``` +RGB → BGR (逐像素 R/B 交换,DIB 字节序) + → 垂直翻转 (top-down → bottom-up) + → IMFMediaBuffer → IMFSample + → IMFSinkWriter::WriteSample + → [系统内部] Color Converter DSP (BGR→NV12) + → [系统内部] H.264 Encoder MFT (硬件优先: QSV/NVENC/AMF) + → [系统内部] MP4 Muxer Sink +``` + +- Profile: High (CABAC 熵编码) +- 编码器由系统自动选择,优先硬件 +- 无需手工管理 NAL、SPS/PPS、MP4 box + +## 前端配置 + +### 用户可选参数 + +| 参数 | 选项 | 说明 | 平台影响 | +|------|------|------|----------| +| `filename_style` | `original` / `friendly` / `friendly_uuid` | 输出文件命名格式 | 全平台 | +| `output_resolution` | `original` / `p1080` / `p720` / `p360` | 输出分辨率 | 全平台 | +| `transcode_power` | `auto` / `full` / `fast` / `medium` / `low` | CPU 使用率 | macOS/Linux 有效;Windows 固定 `auto` | + +### 进度事件 + +通过 Tauri `emit("transcode-progress", TranscodeProgress)` 发送: + +```typescript +interface TranscodeProgress { + file: string; // 文件名或 session ID + index: number; // 当前文件在批次中的索引 + total: number; // 批次总文件数 + progress: number; // 0–100 + message: string; // 状态描述 + success?: boolean; // 完成时设置 + output?: string; // 输出文件路径 + duration?: number; // 转码耗时(秒) + metadata?: ReplayMetadata; // 会话元数据(首次事件时发送) +} +``` + +## 依赖 + +| crate | 用途 | 平台 | +|-------|------|------| +| `flate2` | gzip 解压 `.part.gz` | 全平台 | +| `tar` | 解压 `.tar` 归档 | 全平台 | +| `image` | PNG/JPEG/WebP 解码(guacamole `blob` 指令) | 全平台 | +| `fast_image_resize` | 双线性插值缩放 | 全平台 | +| `base64` | guacamole `blob` 指令中的 base64 解码 | 全平台 | +| `serde` / `serde_json` | replay.json 解析 | 全平台 | +| `tokio` | 异步运行时 + `spawn_blocking` | 全平台 | +| `num_cpus` | 动态计算并行线程数 | macOS / Linux | +| `shiguredo_video_toolbox` | VideoToolbox 硬件编码器 | macOS | +| `openh264` | OpenH264 软件编码器 | Linux | +| `windows` (MediaFoundation) | IMFSinkWriter 系统级编码管道 | Windows | + +## 已知限制 + +- 仅支持 Guacamole 协议录像(RDP/VNC/SSH 通过 Guacamole 网关的场景) +- 画布尺寸取录像中 `size` 指令的最大值,不支持录像中途分辨率动态切换 +- 不支持音频轨道(guacamole `audio` 指令被忽略) +- 帧采样为固定间隔,非事件驱动,静态画面会产生冗余帧(通过帧去重缓解) +- Windows 路径为单线程编码,不支持 chunk 并行 diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 52576f144..e6fc4ed91 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -60,12 +60,93 @@ "Resource": "Resource", "Database": "Database", "Web": "Web", + "Tool": "Tools", + "Transcode": "Replay Transcode", "OfflinePlayer": "Offline Player", "Favorite": "Favorite" }, "Operation": { "Search": "Search" }, + "Transcode": { + "Title": "Replay Transcode", + "Description": "Choose one or multiple .tar recording archive files for MP4 conversion.", + "SelectedArchives": "{count} archive(s) selected", + "TotalProgress": "Overall {progress}%", + "CompletedCount": "Completed {completed}/{total}", + "InProgress": "Processing {processing}/{total}", + "NotStarted": "Not started", + "ArchivesTitle": "Replay archives", + "ArchivesHint": "Single and multiple selection are both supported, and you can append more archives later.", + "SelectArchives": "Add archives", + "ClearArchives": "Clear list", + "EmptyArchives": "Add replay archives to transcode", + "OutputTitle": "Output directory", + "OutputHint": "Generated MP4 files will be written to this directory.", + "OutputDirectory": "Output directory", + "OutputDirPlaceholder": "No output directory selected", + "SelectOutputDir": "Select output directory", + "Start": "Start transcoding", + "Running": "Transcoding", + "ProgressTitle": "Task progress", + "ProgressHint": "Each replay file shows its current stage, progress percentage, and final result.", + "SuccessCount": "Success {count}", + "FailedCount": "Failed {count}", + "QueuedCount": "Queued {count}", + "OutputFile": "Output file:", + "OpenFile": "Open", + "OpenFailed": "Could not open file", + "ErrorDetail": "Error:", + "Completed": "Transcoding completed", + "CompletedAll": "All transcoding completed", + "CompletedWithErrors": "Completed with failures", + "CompletedSummary": "{count} MP4 file(s) generated successfully", + "CompletedSummaryWithErrors": "{success} succeeded, {failed} failed", + "StartFailed": "Failed to start transcoding", + "SelectArchivesFirst": "Select replay archives first", + "SelectOutputDirFirst": "Output directory not set. Go to settings now?", + "OutputDirRequired": "Please select an output directory", + "SelectArchivesFailed": "Failed to select replay archives", + "SelectOutputDirFailed": "Failed to select output directory", + "StatusPending": "Pending", + "StatusQueued": "Queued", + "StatusProcessing": "Processing", + "StatusSuccess": "Success", + "StatusFailed": "Failed", + "Waiting": "Waiting to start", + "EmptyProgress": "Per-file progress will appear here after transcoding starts", + "UnknownError": "Unknown error", + "MetaUser": "User", + "MetaAsset": "Asset", + "MetaAccount": "Account", + "MetaRemoteAddr": "Remote IP", + "MetaProtocol": "Protocol", + "MetaDateStart": "Started at", + "MetaDateEnd": "Ended at", + "Settings": "Settings", + "Prompt": "Notice", + "Close": "Close", + "Cancel": "Cancel", + "Confirm": "Confirm", + "FilenameStyle": "Filename style", + "FilenameOriginal": "Original (UUID)", + "FilenameFriendly": "Friendly name (user-asset-account)", + "FilenameFriendlyUuid": "Friendly name + UUID", + "OutputResolution": "Output resolution", + "ResolutionOriginal": "Original", + "Resolution1080p": "1080p (1920×1080)", + "Resolution720p": "720p (1280×720)", + "Resolution360p": "360p (640×360)", + "TranscodeStarted": "Transcoding may take a while. Please keep the app open and wait patiently!", + "TranscodePower": "Transcode power", + "TranscodePowerHint": "Different power modes use a corresponding share of system CPU for transcoding, which affects transcoding efficiency", + "PowerAuto": "Auto", + "PowerAutoHint": "Windows uses the system encoding pipeline; transcoding power is managed automatically", + "PowerFull": "Full speed (all CPUs)", + "PowerFast": "Fast (3/4 CPU)", + "PowerMedium": "Medium (1/2 CPU)", + "PowerLow": "Low (1/4 CPU)" + }, "AssetCard": { "Activated": "Activated", "Deactivated": "Deactivated", diff --git a/i18n/locales/zh.json b/i18n/locales/zh.json index f6e3ae649..954400ffd 100644 --- a/i18n/locales/zh.json +++ b/i18n/locales/zh.json @@ -60,12 +60,93 @@ "Other": "其他", "Tool": "工具", "Favorite": "收藏", + "Tool": "工具", + "Transcode": "录像转码", "OfflinePlayer": "离线播放器", "Player": "播放器" }, "Operation": { "Search": "搜索" }, + "Transcode": { + "Title": "录像转码", + "Description": "选择一个或多个录像压缩文件(.tar),将录像转换为 mp4 格式。", + "SelectedArchives": "已选择 {count} 个录像包", + "TotalProgress": "总进度 {progress}%", + "CompletedCount": "已完成 {completed}/{total}", + "InProgress": "处理中 {processing}/{total}", + "NotStarted": "尚未开始", + "ArchivesTitle": "录像转码任务", + "ArchivesHint": "支持单选或多选,可继续追加录像文件。", + "SelectArchives": "添加文件", + "ClearArchives": "清空列表", + "EmptyArchives": "请添加要转码的录像压缩文件", + "OutputTitle": "输出目录", + "OutputHint": "转码后的 MP4 文件会输出到该目录下。", + "OutputDirectory": "输出目录", + "OutputDirPlaceholder": "尚未选择输出目录", + "SelectOutputDir": "选择目录", + "Start": "开始转码", + "Running": "转码中", + "ProgressTitle": "任务进度", + "ProgressHint": "每个录像文件都会显示当前阶段、进度百分比和最终结果。", + "SuccessCount": "成功 {count}", + "FailedCount": "失败 {count}", + "QueuedCount": "排队中 {count}", + "OutputFile": "输出文件:", + "OpenFile": "打开", + "OpenFailed": "无法打开文件", + "ErrorDetail": "错误信息:", + "Completed": "转码完成", + "CompletedAll": "全部转码完成", + "CompletedWithErrors": "转码完成,但存在失败项", + "CompletedSummary": "成功生成 {count} 个 MP4 文件", + "CompletedSummaryWithErrors": "成功 {success} 个,失败 {failed} 个", + "StartFailed": "启动转码失败", + "SelectArchivesFirst": "请先选择录像压缩文件", + "SelectOutputDirFirst": "未设置输出目录,是否立即去设置?", + "OutputDirRequired": "请选择输出目录", + "SelectArchivesFailed": "选择录像压缩文件失败", + "SelectOutputDirFailed": "选择输出目录失败", + "StatusPending": "待开始", + "StatusQueued": "排队中", + "StatusProcessing": "处理中", + "StatusSuccess": "成功", + "StatusFailed": "失败", + "Waiting": "等待开始", + "EmptyProgress": "开始转码后,这里会实时显示每个录像文件的处理进度", + "UnknownError": "未知错误", + "MetaUser": "用户", + "MetaAsset": "资产", + "MetaAccount": "账号", + "MetaRemoteAddr": "远端 IP", + "MetaProtocol": "协议", + "MetaDateStart": "开始时间", + "MetaDateEnd": "结束时间", + "Settings": "转码设置", + "Prompt": "提示", + "Close": "关闭", + "Cancel": "取消", + "Confirm": "确认", + "FilenameStyle": "文件名风格", + "FilenameOriginal": "原文件名(UUID)", + "FilenameFriendly": "友好名称(用户-资产-账号)", + "FilenameFriendlyUuid": "友好名称 + UUID", + "OutputResolution": "输出分辨率", + "ResolutionOriginal": "原始分辨率", + "Resolution1080p": "1080p (1920×1080)", + "Resolution720p": "720p (1280×720)", + "Resolution360p": "360p (640×360)", + "TranscodeStarted": "录像转码时间较长,请勿退出应用,耐心等待!", + "TranscodePower": "转码功率", + "TranscodePowerHint": "选择不同的功率模式将利用对应系统CPU份额进行转码,转码效率将受影响", + "PowerAuto": "自动", + "PowerAutoHint": "Windows 使用系统级编码管道,转码功率由系统自动管理", + "PowerFull": "全速(所有CPU)", + "PowerFast": "极速(3/4 CPU)", + "PowerMedium": "中等(1/2 CPU)", + "PowerLow": "低速(1/4 CPU)" + }, "AssetCard": { "Activated": "已激活", "Deactivated": "未激活", diff --git a/nuxt.config.ts b/nuxt.config.ts index 2463f4ca2..cb1ac88aa 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -10,6 +10,9 @@ export default defineNuxtConfig({ "reka-ui/nuxt", "pinia-plugin-persistedstate/nuxt" ], + ui: { + fonts: false + }, i18n: { locales: [ { code: "zh", name: "简体中文", file: "zh.json" }, diff --git a/package.json b/package.json index 931c4035a..3116c0367 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-log": "~2.8.0", "@tauri-apps/plugin-notification": "^2.3.3", + "@tauri-apps/plugin-opener": "^2.5.4", "@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-shell": "^2.3.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7df94cf0a..02c1d2ca7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@tauri-apps/plugin-notification': specifier: ^2.3.3 version: 2.3.3 + '@tauri-apps/plugin-opener': + specifier: ^2.5.4 + version: 2.5.4 '@tauri-apps/plugin-os': specifier: ^2.3.2 version: 2.3.2 @@ -2846,6 +2849,9 @@ packages: '@tauri-apps/plugin-notification@2.3.3': resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==} + '@tauri-apps/plugin-opener@2.5.4': + resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==} + '@tauri-apps/plugin-os@2.3.2': resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==} @@ -9577,6 +9583,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.9.1 + '@tauri-apps/plugin-opener@2.5.4': + dependencies: + '@tauri-apps/api': 2.11.0 + '@tauri-apps/plugin-os@2.3.2': dependencies: '@tauri-apps/api': 2.9.1 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bac641179..0ed28a17c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,18 +8,25 @@ version = "4.1.2" dependencies = [ "anyhow", "axum", + "base64 0.22.1", "chrono", "env_logger", + "fast_image_resize", "flate2", "font-kit", "image", "keyring", "keyring-core", + "libc", "log", + "num_cpus", "oauth2", + "openh264", "reqwest 0.12.28", "serde", "serde_json", + "shiguredo_video_toolbox", + "tar", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", @@ -40,6 +47,7 @@ dependencies = [ "tokio", "url", "window-vibrancy 0.7.1", + "windows 0.58.0", ] [[package]] @@ -113,24 +121,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -146,12 +136,6 @@ dependencies = [ "alloc-no-stdlib", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - [[package]] name = "android-native-keyring-store" version = "1.0.0" @@ -318,32 +302,12 @@ dependencies = [ "rustversion", ] -[[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] - [[package]] name = "assoc" version = "0.1.3" @@ -516,49 +480,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "av-scenechange" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey 0.1.1", - "rayon", - "thiserror 2.0.18", - "v_frame", - "y4m", -] - -[[package]] -name = "av1-grain" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" -dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom 8.0.0", - "num-rational", - "v_frame", -] - -[[package]] -name = "avif-serialize" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" -dependencies = [ - "arrayvec", -] - [[package]] name = "axum" version = "0.8.9" @@ -642,7 +563,7 @@ version = "0.69.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cexpr", "clang-sys", "itertools 0.12.1", @@ -659,6 +580,26 @@ dependencies = [ "which", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.0", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.2", + "shlex 1.3.0", + "syn 2.0.117", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -674,12 +615,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - [[package]] name = "bitflags" version = "1.3.2" @@ -688,31 +623,13 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] -[[package]] -name = "bitpacking" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96a7139abd3d9cebf8cd6f920a389cf3dc9576172e32f4563f188cae3c3eb019" -dependencies = [ - "crunchy", -] - -[[package]] -name = "bitstream-io" -version = "4.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" -dependencies = [ - "no_std_io2", -] - [[package]] name = "bitvec" version = "1.0.1" @@ -765,31 +682,6 @@ dependencies = [ "piper", ] -[[package]] -name = "bon" -version = "3.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" -dependencies = [ - "bon-macros", - "rustversion", -] - -[[package]] -name = "bon-macros" -version = "3.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" -dependencies = [ - "darling", - "ident_case", - "prettyplease", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.117", -] - [[package]] name = "borsh" version = "1.6.1" @@ -853,12 +745,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "built" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" - [[package]] name = "bumpalo" version = "3.20.3" @@ -946,7 +832,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cairo-sys-rs", "glib", "libc", @@ -1028,12 +914,6 @@ dependencies = [ "shlex 2.0.1", ] -[[package]] -name = "census" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" - [[package]] name = "cesu8" version = "1.1.0" @@ -1090,9 +970,9 @@ checksum = "18758054972164c3264f7c8386f5fc6da6114cb46b619fd365d4e3b2dc3ae487" [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1172,12 +1052,6 @@ dependencies = [ "error-code", ] -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - [[package]] name = "colorchoice" version = "1.0.5" @@ -1297,7 +1171,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-graphics-types 0.2.0", "foreign-types 0.5.0", @@ -1321,7 +1195,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.10.1", "libc", ] @@ -1374,16 +1248,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - [[package]] name = "crossbeam-epoch" version = "0.9.18" @@ -1529,17 +1393,11 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" -[[package]] -name = "datasketches" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c286de4e81ea2590afc24d754e0f83810c566f50a1388fa75ebd57928c0d9745" - [[package]] name = "db-keystore" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc758963ac0d7ead30f47ff2a02a2fd3456cf4d7fc695cf9bae72e951a3df2b" +checksum = "f9a1b2f1087c32a26a8c6c7d89eb80b50c33d8466e2431ea2a8345c969cded18" dependencies = [ "anyhow", "clap", @@ -1674,7 +1532,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "libc", "objc2", @@ -1762,12 +1620,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "downcast-rs" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" - [[package]] name = "dpi" version = "0.1.2" @@ -1926,26 +1778,6 @@ dependencies = [ "log", ] -[[package]] -name = "equator" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -2000,27 +1832,24 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "exr" -version = "1.74.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", -] - [[package]] name = "fallible-iterator" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" +[[package]] +name = "fast_image_resize" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12dd43e5011e8d8411a3215a0d57a2ec5c68282fb90eb5d7221fab0113442174" +dependencies = [ + "cfg-if", + "document-features", + "num-traits", + "thiserror 2.0.18", +] + [[package]] name = "fastbloom" version = "0.14.1" @@ -2032,12 +1861,6 @@ dependencies = [ "siphasher", ] -[[package]] -name = "fastdivide" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471" - [[package]] name = "fastrand" version = "2.4.1" @@ -2140,7 +1963,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c7e611d49285d4c4b2e1727b72cf05353558885cc5252f93707b845dfcaf3d3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "byteorder", "core-foundation 0.9.4", "core-graphics 0.23.2", @@ -2221,16 +2044,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "fs4" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" -dependencies = [ - "rustix 1.1.4", - "windows-sys 0.59.0", -] - [[package]] name = "funty" version = "2.0.0" @@ -2537,16 +2350,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "gif" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" -dependencies = [ - "color_quant", - "weezl", -] - [[package]] name = "gio" version = "0.18.4" @@ -2585,7 +2388,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "futures-channel", "futures-core", "futures-executor", @@ -2749,17 +2552,6 @@ dependencies = [ "foldhash 0.1.5", ] -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -2827,17 +2619,11 @@ dependencies = [ "markup5ever", ] -[[package]] -name = "htmlescape" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" - [[package]] name = "http" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -2880,9 +2666,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.10.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -3114,17 +2900,10 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", - "color_quant", - "exr", - "gif", "image-webp", "moxcms", "num-traits", "png 0.18.1", - "qoi", - "ravif", - "rayon", - "rgb", "tiff", "zune-core", "zune-jpeg", @@ -3140,12 +2919,6 @@ dependencies = [ "quick-error", ] -[[package]] -name = "imgref" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" - [[package]] name = "indexmap" version = "1.9.3" @@ -3188,17 +2961,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "intrusive-collections" version = "0.9.7" @@ -3208,22 +2970,13 @@ dependencies = [ "memoffset", ] -[[package]] -name = "inventory" -version = "0.3.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" -dependencies = [ - "rustversion", -] - [[package]] name = "io-uring" version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d09b98f7eace8982db770e4408e7470b028ce513ac28fecdc6bf4c30fe92b62" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "libc", ] @@ -3308,9 +3061,9 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.27" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "392c70591e8749fe235ddaf513e6f58b26bce3dcc16524cecc8936f75afa161e" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" dependencies = [ "jiff-static", "log", @@ -3321,9 +3074,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.27" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b605b0c050d845fc355bb11eb3f9a8deddc218ea60c76e61aa1f2adfb2c96a" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" dependencies = [ "proc-macro2", "quote", @@ -3416,13 +3169,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -3454,7 +3206,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "serde", "unicode-segmentation", ] @@ -3512,18 +3264,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" -[[package]] -name = "lebe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - -[[package]] -name = "levenshtein_automata" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25" - [[package]] name = "libappindicator" version = "0.9.0" @@ -3564,16 +3304,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "libfuzzer-sys" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" -dependencies = [ - "arbitrary", - "cc", -] - [[package]] name = "libloading" version = "0.7.4" @@ -3644,7 +3374,7 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "libc", ] @@ -3693,9 +3423,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.31" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" dependencies = [ "value-bag", ] @@ -3713,41 +3443,17 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "loop9" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] - -[[package]] -name = "lru" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" -dependencies = [ - "hashbrown 0.16.1", -] - [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lz4_flex" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" - [[package]] name = "mac-notification-sys" -version = "0.6.12" +version = "0.6.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" +checksum = "50efa634682b3fc5a1ab6f3dd5b2bce7b848011fc485b53b063dc68f2f74feae" dependencies = [ "cc", "objc2", @@ -3781,40 +3487,12 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" -[[package]] -name = "maybe-rayon" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" -dependencies = [ - "cfg-if", - "rayon", -] - -[[package]] -name = "measure_time" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51c55d61e72fc3ab704396c5fa16f4c184db37978ae4e94ca8959693a235fc0e" -dependencies = [ - "log", -] - [[package]] name = "memchr" version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" -[[package]] -name = "memmap2" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" -dependencies = [ - "libc", -] - [[package]] name = "memoffset" version = "0.9.1" @@ -3926,10 +3604,13 @@ dependencies = [ ] [[package]] -name = "murmurhash32" -version = "0.3.1" +name = "nasm-rs" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" +checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149" +dependencies = [ + "log", +] [[package]] name = "native-tls" @@ -3954,7 +3635,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "jni-sys 0.3.1", "log", "ndk-sys", @@ -3990,21 +3671,12 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", ] -[[package]] -name = "no_std_io2" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" -dependencies = [ - "memchr", -] - [[package]] name = "nom" version = "7.1.3" @@ -4024,12 +3696,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - [[package]] name = "notify-rust" version = "4.17.0" @@ -4092,17 +3758,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "num-integer" version = "0.1.46" @@ -4143,6 +3798,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_enum" version = "0.7.6" @@ -4210,7 +3875,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "objc2", "objc2-core-foundation", @@ -4224,7 +3889,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", "objc2-foundation", ] @@ -4245,7 +3910,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "dispatch2", "objc2", ] @@ -4256,7 +3921,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "dispatch2", "objc2", "objc2-core-foundation", @@ -4289,7 +3954,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -4316,7 +3981,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "libc", "objc2", @@ -4329,7 +3994,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", "objc2-core-foundation", ] @@ -4340,7 +4005,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", "objc2-app-kit", "objc2-foundation", @@ -4352,7 +4017,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -4364,7 +4029,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "objc2", "objc2-cloud-kit", @@ -4395,7 +4060,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "objc2", "objc2-app-kit", @@ -4415,12 +4080,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "oneshot" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" - [[package]] name = "opaque-debug" version = "0.3.1" @@ -4439,13 +4098,34 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openh264" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e0b96c5808e7e5cf977056b4da08796895f993570b4a0ad658f90764c3c18fe" +dependencies = [ + "openh264-sys2", + "wide", +] + +[[package]] +name = "openh264-sys2" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "881ad6de62993ff97a2cfbc63e5f127ecc930255718f9adc4c68c1dfeb308132" +dependencies = [ + "cc", + "nasm-rs", + "walkdir", +] + [[package]] name = "openssl" version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "foreign-types 0.3.2", "libc", @@ -4472,9 +4152,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-src" -version = "300.6.0+3.6.2" +version = "300.6.1+3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" dependencies = [ "cc", ] @@ -4498,15 +4178,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered-float" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" -dependencies = [ - "num-traits", -] - [[package]] name = "ordered-multimap" version = "0.7.3" @@ -4567,15 +4238,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "ownedbytes" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbd56f7631767e61784dc43f8580f403f4475bd4aaa4da003e6295e1bab4a7e" -dependencies = [ - "stable_deref_trait", -] - [[package]] name = "owo-colors" version = "3.5.0" @@ -4645,18 +4307,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - [[package]] name = "pastey" version = "0.2.3" @@ -4813,7 +4463,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "crc32fast", "fdeflate", "flate2", @@ -4963,30 +4613,11 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "profiling" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" -dependencies = [ - "profiling-procmacros", -] - -[[package]] -name = "profiling-procmacros" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" -dependencies = [ - "quote", - "syn 2.0.117", -] - [[package]] name = "prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", @@ -4994,9 +4625,9 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools 0.14.0", @@ -5047,15 +4678,6 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - [[package]] name = "quick-error" version = "2.0.1" @@ -5227,66 +4849,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59cad018caf63deb318e5a4586d99a24424a364f40f1e5778c29aca23f4fc73e" dependencies = [ - "rand_core 0.6.4", -] - -[[package]] -name = "rapidhash" -version = "4.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" -dependencies = [ - "rustversion", -] - -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools 0.14.0", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand 0.9.4", - "rand_chacha 0.9.0", - "simd_helpers", - "thiserror 2.0.18", - "v_frame", - "wasm-bindgen", + "rand_core 0.6.4", ] [[package]] -name = "ravif" -version = "0.13.0" +name = "rapidhash" +version = "4.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", + "rustversion", ] [[package]] @@ -5295,33 +4867,13 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -5357,9 +4909,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -5380,9 +4932,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rend" @@ -5502,12 +5054,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" - [[package]] name = "ring" version = "0.17.14" @@ -5567,7 +5113,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "once_cell", "serde", "serde_derive", @@ -5616,16 +5162,6 @@ dependencies = [ "ordered-multimap", ] -[[package]] -name = "rust-stemmers" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" -dependencies = [ - "serde", - "serde_derive", -] - [[package]] name = "rust_decimal" version = "1.42.0" @@ -5680,7 +5216,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -5693,7 +5229,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -5716,9 +5252,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -5786,6 +5322,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -5898,7 +5443,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -5921,7 +5466,7 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cssparser", "derive_more", "log", @@ -6064,9 +5609,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", "bs58", @@ -6084,9 +5629,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling", "proc-macro2", @@ -6162,6 +5707,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "shiguredo_video_toolbox" +version = "2026.1.1" +source = "git+https://github.com/shiguredo/video-toolbox-rs?branch=develop#ab879496965a3cc67f342a9124a71a0421220ef5" +dependencies = [ + "bindgen 0.72.1", + "log", +] + [[package]] name = "shlex" version = "1.3.0" @@ -6241,15 +5795,6 @@ dependencies = [ "simdutf8", ] -[[package]] -name = "simd_helpers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] - [[package]] name = "simdutf8" version = "0.1.5" @@ -6271,15 +5816,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" -[[package]] -name = "sketches-ddsketch" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05e40b6cf54d988dc1a2223531b969c9a9e30906ad90ef64890c27b4bfbb46ea" -dependencies = [ - "serde", -] - [[package]] name = "slab" version = "0.4.12" @@ -6515,7 +6051,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -6543,161 +6079,13 @@ dependencies = [ "version-compare", ] -[[package]] -name = "tantivy" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edde6a10743fff00a4e1a8c9ef020bf5f3cbad301b7d2d39f2b07f123c4eac07" -dependencies = [ - "aho-corasick", - "arc-swap", - "base64 0.22.1", - "bitpacking", - "bon", - "byteorder", - "census", - "crc32fast", - "crossbeam-channel", - "datasketches", - "downcast-rs 2.0.2", - "fastdivide", - "fnv", - "fs4", - "htmlescape", - "itertools 0.14.0", - "levenshtein_automata", - "log", - "lru", - "lz4_flex", - "measure_time", - "memmap2", - "once_cell", - "oneshot", - "rayon", - "regex", - "rust-stemmers", - "rustc-hash 2.1.2", - "serde", - "serde_json", - "sketches-ddsketch", - "smallvec", - "tantivy-bitpacker", - "tantivy-columnar", - "tantivy-common", - "tantivy-fst", - "tantivy-query-grammar", - "tantivy-stacker", - "tantivy-tokenizer-api", - "tempfile", - "thiserror 2.0.18", - "time", - "typetag", - "uuid", - "winapi", -] - -[[package]] -name = "tantivy-bitpacker" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fed3d674429bcd2de5d0a6d1aa5495fed8afd9c5ecce993019caf7615f53fa4" -dependencies = [ - "bitpacking", -] - -[[package]] -name = "tantivy-columnar" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c57166f5bcfd478f370ab8445afb4678dce44801fa5ce5c451aaf8595583c5dc" -dependencies = [ - "downcast-rs 2.0.2", - "fastdivide", - "itertools 0.14.0", - "serde", - "tantivy-bitpacker", - "tantivy-common", - "tantivy-sstable", - "tantivy-stacker", -] - -[[package]] -name = "tantivy-common" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbf10915aa75da3c3b0d58b58853d2e889efbaf32d4982a4c3715dde6bba23e5" -dependencies = [ - "async-trait", - "byteorder", - "ownedbytes", - "serde", - "time", -] - -[[package]] -name = "tantivy-fst" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18" -dependencies = [ - "byteorder", - "regex-syntax", - "utf8-ranges", -] - -[[package]] -name = "tantivy-query-grammar" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfadb8526b6da90704feb293b0701a6aae62ea14983143344be2dc5ce30f1d82" -dependencies = [ - "fnv", - "nom 7.1.3", - "ordered-float", - "serde", - "serde_json", -] - -[[package]] -name = "tantivy-sstable" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a2cfc3ac5164cbadc28965ffb145a8f47582a60ae5897859ad8d4316596c606" -dependencies = [ - "futures-util", - "itertools 0.14.0", - "tantivy-bitpacker", - "tantivy-common", - "tantivy-fst", - "zstd", -] - -[[package]] -name = "tantivy-stacker" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cbb051742da9d53ca9e8fff43a9b10e319338b24e2c0e15d0372df19ffeb951" -dependencies = [ - "murmurhash32", - "tantivy-common", -] - -[[package]] -name = "tantivy-tokenizer-api" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac258c2c6390673f2685813afeeafcb8c4e0ee7de8dd3fc46838dcc37263f98" -dependencies = [ - "serde", -] - [[package]] name = "tao" version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "core-foundation 0.10.1", "core-graphics 0.25.0", @@ -6725,7 +6113,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -6813,7 +6201,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy 0.6.0", - "windows", + "windows 0.61.3", ] [[package]] @@ -7055,7 +6443,7 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.18", "url", - "windows", + "windows 0.61.3", "zbus", ] @@ -7083,7 +6471,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b89f8958295714318799bec00dd5d746b1a7c8610268fcd0b2c4e6f2e99b0ed2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "itertools 0.14.0", "serde", "strum 0.28.0", @@ -7182,7 +6570,7 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "log", "serde", "serde_json", @@ -7213,7 +6601,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -7238,7 +6626,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -7299,7 +6687,7 @@ checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" dependencies = [ "quick-xml 0.37.5", "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-version", ] @@ -7465,9 +6853,7 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -7659,7 +7045,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", "futures-util", "http", @@ -7824,7 +7210,7 @@ dependencies = [ "antithesis_sdk", "arc-swap", "bigdecimal", - "bitflags 2.11.1", + "bitflags 2.13.0", "branches", "bumpalo", "bytemuck", @@ -7848,7 +7234,7 @@ dependencies = [ "num-traits", "pack1", "parking_lot", - "pastey 0.2.3", + "pastey", "polling", "rand 0.9.4", "rapidhash", @@ -7864,7 +7250,6 @@ dependencies = [ "smallvec", "strum 0.26.3", "strum_macros 0.26.4", - "tantivy", "tempfile", "thiserror 2.0.18", "tracing", @@ -7906,7 +7291,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b94707e5605411cddbd5a1bd6cc2d6b72181b02223b36b37ba350ed6b71877" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "memchr", "miette", "strum 0.26.3", @@ -7921,7 +7306,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12a86ce113e5dcedeaad7809d9fa1dc00f837f40ccd8012ac1d2144c57672a34" dependencies = [ - "bindgen", + "bindgen 0.69.5", "env_logger", "parking_lot", "tracing", @@ -7970,7 +7355,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4512c7b28bb3bc09be1ba480ee60234ed9bbeb6686c9348323589ffcec504b93" dependencies = [ - "bindgen", + "bindgen 0.69.5", "env_logger", "genawaiter", "parking_lot", @@ -8000,33 +7385,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" - -[[package]] -name = "typetag" -version = "0.2.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a897b12c6c1151ad0b138b8db50252dc301f93bc3b027db05eec82aeed298c" -dependencies = [ - "erased-serde", - "inventory", - "once_cell", - "serde", - "typetag-impl", -] - -[[package]] -name = "typetag-impl" -version = "0.2.22" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf808357c6ed7e13ba0f3277ec8d8f21b2d501274895104263985330c726c1c5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uds_windows" @@ -8097,9 +7458,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -8160,12 +7521,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" -[[package]] -name = "utf8-ranges" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" - [[package]] name = "utf8-width" version = "0.1.8" @@ -8186,9 +7541,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -8197,17 +7552,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "v_frame" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", -] - [[package]] name = "valuable" version = "0.1.1" @@ -8303,9 +7647,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" dependencies = [ "cfg-if", "once_cell", @@ -8316,9 +7660,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" dependencies = [ "js-sys", "wasm-bindgen", @@ -8326,9 +7670,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8336,9 +7680,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" dependencies = [ "bumpalo", "proc-macro2", @@ -8349,9 +7693,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" dependencies = [ "unicode-ident", ] @@ -8397,7 +7741,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -8410,7 +7754,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", - "downcast-rs 1.2.1", + "downcast-rs", "rustix 1.1.4", "smallvec", "wayland-sys", @@ -8422,7 +7766,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "rustix 1.1.4", "wayland-backend", "wayland-scanner", @@ -8434,7 +7778,7 @@ version = "0.32.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -8446,7 +7790,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -8475,9 +7819,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" dependencies = [ "js-sys", "wasm-bindgen", @@ -8575,10 +7919,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", ] [[package]] @@ -8599,7 +7943,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -8621,6 +7965,16 @@ dependencies = [ "rustix 0.38.44", ] +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "winapi" version = "0.3.9" @@ -8682,6 +8036,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -8704,14 +8068,27 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -8723,8 +8100,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -8741,6 +8118,17 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -8752,6 +8140,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -8820,6 +8219,15 @@ dependencies = [ "windows-strings 0.5.1", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -8838,6 +8246,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -9212,7 +8630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.0", "indexmap 2.14.0", "log", "serde", @@ -9304,7 +8722,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -9367,12 +8785,6 @@ dependencies = [ "rustix 1.1.4", ] -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - [[package]] name = "yeslogic-fontconfig-sys" version = "6.0.1" @@ -9386,9 +8798,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -9409,9 +8821,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.15.0" +version = "5.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" dependencies = [ "async-broadcast", "async-executor", @@ -9455,9 +8867,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.15.0" +version = "5.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", @@ -9481,18 +8893,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.49" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.49" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", @@ -9591,49 +9003,12 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "zune-core" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] - [[package]] name = "zune-jpeg" version = "0.5.15" @@ -9645,9 +9020,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.11.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" dependencies = [ "endi", "enumflags2", @@ -9659,9 +9034,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.11.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", @@ -9672,9 +9047,9 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.3.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" dependencies = [ "proc-macro2", "quote", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 59eb60ac9..0b2020938 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,7 +22,6 @@ features = [ ] [dependencies] url = "2.5.7" log = "0.4.31" -image = "0.25.8" oauth2 = "5.0.0" axum = "0.8.9" keyring = "4.0.1" @@ -32,6 +31,10 @@ flate2 = "1.1.5" font-kit = "0.14" anyhow = "1.0.100" serde_json = "1.0" +tar = "0.4" +base64 = "0.22" +fast_image_resize = "6.0" +num_cpus = "1.16" env_logger = "0.11.8" tauri-build = "2.5.0" tauri-plugin-log = "2.8" @@ -50,7 +53,12 @@ tauri-plugin-prevent-default = "5.0.0" [dependencies.tokio] version = "1.46.1" -features = ["full"] +features = ["rt-multi-thread", "macros", "sync", "time", "net"] + +[dependencies.image] +version = "0.25.8" +default-features = false +features = ["png", "jpeg", "webp"] [dependencies.tauri] version = "2.11" @@ -79,3 +87,23 @@ features = [ [target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies] tauri-plugin-updater = "2.9.0" tauri-plugin-window-state = "2.4.0" + +[target.'cfg(target_os = "macos")'.dependencies] +libc = "0.2" +shiguredo_video_toolbox = { git = "https://github.com/shiguredo/video-toolbox-rs", branch = "develop" } + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.58", features = [ + "Win32_Media_MediaFoundation", + "Win32_System_Com", +] } + +[target.'cfg(target_os = "linux")'.dependencies] +openh264 = "0.7" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = true +opt-level = "s" +panic = "abort" diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index 7250290c9..d2f6e1e55 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -2,10 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "main", "description": "Capabilities for the app window", - "windows": [ - "main", - "secondary" - ], + "windows": ["main", "secondary"], "permissions": [ "core:path:default", "core:event:default", @@ -21,7 +18,23 @@ "core:window:allow-center", "core:window:allow-close", "core:window:allow-start-dragging", - "shell:allow-open", + "opener:default", + { + "identifier": "opener:allow-open-path", + "allow": [ + { + "path": "**" + } + ] + }, + { + "identifier": "opener:allow-open-url", + "allow": [ + { + "url": "**" + } + ] + }, { "identifier": "shell:allow-execute", "allow": [ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0065e5227..fa024b674 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod commands; mod http; mod service; mod setup; +mod transcode; mod utils; use crate::setup::apply_window_effects; @@ -30,6 +31,7 @@ use crate::commands::video_player::{ }; use crate::commands::window_control::{close_window, minimize_window, toggle_maximize_window}; use crate::service::oauth::AuthFlowState; +use crate::transcode::transcode_replays; use crate::utils::is_auth_callback; use log::{error, info, warn}; @@ -209,6 +211,7 @@ pub fn run() { write_video_player_gzip_file, read_video_player_text_stream, delete_video_player_file, + transcode_replays, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/transcode/encoder.rs b/src-tauri/src/transcode/encoder.rs new file mode 100644 index 000000000..4ba8c76b8 --- /dev/null +++ b/src-tauri/src/transcode/encoder.rs @@ -0,0 +1,630 @@ +#[cfg(target_os = "linux")] +use openh264::encoder::Encoder as OpenH264Inner; +#[cfg(target_os = "linux")] +use openh264::formats::{RgbSliceU8, YUVBuffer}; + +pub struct EncodedOutput { + pub data: Vec, + pub is_keyframe: bool, + pub sample_size: u32, +} + +pub trait H264Encoder: Send { + fn encode_frame( + &mut self, + rgb: &[u8], + width: usize, + height: usize, + ) -> Result; + + fn flush(&mut self) -> Result<(), String> { + Ok(()) + } + + fn sps(&self) -> &[u8]; + fn pps(&self) -> &[u8]; +} + +#[cfg(target_os = "linux")] +pub struct OpenH264Encoder { + inner: OpenH264Inner, + sps: Vec, + pps: Vec, + _bitrate: u32, + _gop_size: u32, +} + +#[cfg(target_os = "linux")] +impl OpenH264Encoder { + pub fn new(bitrate: u32, gop_size: u32) -> Result { + let inner = + OpenH264Inner::new().map_err(|e| format!("create openh264 encoder failed: {}", e))?; + Ok(Self { + inner, + sps: Vec::new(), + pps: Vec::new(), + _bitrate: bitrate, + _gop_size: gop_size, + }) + } +} + +#[cfg(target_os = "linux")] +impl H264Encoder for OpenH264Encoder { + fn encode_frame( + &mut self, + rgb: &[u8], + width: usize, + height: usize, + ) -> Result { + let rgb_slice = RgbSliceU8::new(rgb, (width, height)); + let yuv = YUVBuffer::from_rgb_source(rgb_slice); + + let bs = self + .inner + .encode(&yuv) + .map_err(|e| format!("encode frame failed: {}", e))?; + let raw = bs.to_vec(); + + if raw.is_empty() { + return Ok(EncodedOutput { + data: Vec::new(), + is_keyframe: false, + sample_size: 0, + }); + } + + let nal_units = split_annex_b(&raw); + let mut is_key = false; + let mut data = Vec::new(); + let mut sample_size: u32 = 0; + + for nal in nal_units { + if nal.is_empty() { + continue; + } + + match nal[0] & 0x1F { + 7 => { + self.sps = nal.to_vec(); + continue; + } + 8 => { + self.pps = nal.to_vec(); + continue; + } + 5 => { + is_key = true; + } + _ => {} + } + + let len = nal.len() as u32; + data.extend_from_slice(&len.to_be_bytes()); + data.extend_from_slice(nal); + sample_size += 4 + len; + } + + Ok(EncodedOutput { + data, + is_keyframe: is_key, + sample_size, + }) + } + + fn sps(&self) -> &[u8] { + &self.sps + } + + fn pps(&self) -> &[u8] { + &self.pps + } +} + +#[cfg(target_os = "macos")] +pub mod vt { + use super::{EncodedOutput, H264Encoder}; + use shiguredo_video_toolbox::{ + CodecConfig, EncodeOptions, Encoder, EncoderConfig, FnEncodeHandler, FrameData, + H264EncoderConfig, H264EntropyMode, H264Profile, PixelFormat, + }; + use std::sync::mpsc; + + pub struct VideoToolboxEncoder { + inner: Encoder>, + rx: mpsc::Receiver, + i420_y: Vec, + i420_u: Vec, + i420_v: Vec, + sps: Vec, + pps: Vec, + next_pts: u64, + } + + struct EncodedFrameData { + data: Vec, + is_keyframe: bool, + sps: Vec, + pps: Vec, + } + + impl VideoToolboxEncoder { + pub fn new(width: u32, height: u32, bitrate: u32, gop_size: u32) -> Result { + let (tx, rx) = mpsc::channel(); + + let config = EncoderConfig { + width, + height, + codec: CodecConfig::H264(H264EncoderConfig { + profile: H264Profile::Baseline, + entropy_mode: H264EntropyMode::Cavlc, + }), + pixel_format: PixelFormat::I420, + average_bitrate: Some(bitrate as u64), + fps_numerator: 10, + fps_denominator: 1, + prioritize_encoding_speed_over_quality: true, + real_time: true, + maximize_power_efficiency: true, + allow_frame_reordering: false, + allow_temporal_compression: true, + max_key_frame_interval: std::num::NonZero::new(gop_size), + max_key_frame_interval_duration: None, + max_frame_delay_count: None, + }; + + let handler = FnEncodeHandler::new(move |result| { + if let Ok(frame) = result { + let _ = tx.send(EncodedFrameData { + data: frame.data, + is_keyframe: frame.keyframe, + sps: frame.sps_list.into_iter().flatten().collect(), + pps: frame.pps_list.into_iter().flatten().collect(), + }); + } + }); + + let inner = Encoder::new(config, handler) + .map_err(|e| format!("VideoToolbox init failed: {}", e))?; + + let frame_size = (width * height) as usize; + let uv_size = ((width / 2) * (height / 2)) as usize; + Ok(Self { + inner, + rx, + i420_y: vec![0u8; frame_size], + i420_u: vec![0u8; uv_size], + i420_v: vec![0u8; uv_size], + sps: Vec::new(), + pps: Vec::new(), + next_pts: 0, + }) + } + + fn rgb_to_i420(&mut self, rgb: &[u8], width: usize, height: usize) { + let total_pixels = width * height; + let y_plane = &mut self.i420_y[..total_pixels]; + let rgb_slice = &rgb[..total_pixels * 3]; + + for i in 0..total_pixels { + let src = i * 3; + let r = rgb_slice[src] as i32; + let g = rgb_slice[src + 1] as i32; + let b = rgb_slice[src + 2] as i32; + y_plane[i] = ((66 * r + 129 * g + 25 * b + 128) >> 8) as u8 + 16; + } + + let uv_w = width / 2; + let uv_h = height / 2; + let stride = width * 3; + + for cy in 0..uv_h { + let row_base = cy * 2 * stride; + let uv_row = cy * uv_w; + for cx in 0..uv_w { + let sx = cx * 2; + let tl = row_base + sx * 3; + let tr = tl + 3; + let bl = tl + stride; + let br = bl + 3; + + let r = (rgb_slice[tl] as i32 + + rgb_slice[tr] as i32 + + rgb_slice[bl] as i32 + + rgb_slice[br] as i32) + >> 2; + let g = (rgb_slice[tl + 1] as i32 + + rgb_slice[tr + 1] as i32 + + rgb_slice[bl + 1] as i32 + + rgb_slice[br + 1] as i32) + >> 2; + let b = (rgb_slice[tl + 2] as i32 + + rgb_slice[tr + 2] as i32 + + rgb_slice[bl + 2] as i32 + + rgb_slice[br + 2] as i32) + >> 2; + + let uv_idx = uv_row + cx; + self.i420_u[uv_idx] = (((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128) as u8; + self.i420_v[uv_idx] = (((112 * r - 94 * g - 18 * b + 128) >> 8) + 128) as u8; + } + } + } + } + + impl H264Encoder for VideoToolboxEncoder { + fn encode_frame( + &mut self, + rgb: &[u8], + width: usize, + height: usize, + ) -> Result { + self.rgb_to_i420(rgb, width, height); + + let frame_data = FrameData::I420 { + y: &self.i420_y, + u: &self.i420_u, + v: &self.i420_v, + }; + + let opts = EncodeOptions::default(); + let pts = self.next_pts; + self.next_pts += 1; + + self.inner + .encode(&frame_data, &opts, pts) + .map_err(|e| format!("VideoToolbox encode failed: {}", e))?; + + match self.rx.recv() { + Ok(frame) => { + if !frame.sps.is_empty() { + self.sps = frame.sps; + } + if !frame.pps.is_empty() { + self.pps = frame.pps; + } + let sample_size = frame.data.len() as u32; + Ok(EncodedOutput { + data: frame.data, + is_keyframe: frame.is_keyframe, + sample_size, + }) + } + Err(_) => Ok(EncodedOutput { + data: Vec::new(), + is_keyframe: false, + sample_size: 0, + }), + } + } + + fn flush(&mut self) -> Result<(), String> { + self.inner + .finish() + .map_err(|e| format!("VideoToolbox flush failed: {}", e)) + } + + fn sps(&self) -> &[u8] { + &self.sps + } + + fn pps(&self) -> &[u8] { + &self.pps + } + } +} + +#[cfg(windows)] +pub mod sink { + use std::path::Path; + use windows::core::{Interface, GUID, PCWSTR}; + use windows::Win32::Media::MediaFoundation::{ + IMF2DBuffer, IMFSample, MFCreate2DMediaBuffer, MFCreateSample, MFShutdown, MFStartup, + MFVideoFormat_H264, MFSTARTUP_FULL, MF_MT_AVG_BITRATE, MF_MT_FRAME_RATE, MF_MT_FRAME_SIZE, + MF_MT_INTERLACE_MODE, MF_MT_MAJOR_TYPE, MF_MT_MPEG2_PROFILE, MF_MT_SUBTYPE, + }; + use windows::Win32::System::Com::{CoInitializeEx, CoUninitialize, COINIT_MULTITHREADED}; + + const MF_MT_VIDEO: GUID = GUID::from_u128(0x73646976_0000_0010_8000_00AA00389B71); + const MF_VIDEO_FORMAT_RGB24: GUID = GUID::from_u128(0x00000018_0000_0010_8000_00AA00389B71); + const MF_VIDEO_FORMAT_RGB32: GUID = GUID::from_u128(0x00000016_0000_0010_8000_00AA00389B71); + const MF_VIDEO_INTERLACE_PROGRESSIVE: u32 = 2; + + pub struct SinkWriterEncoder { + writer: windows::Win32::Media::MediaFoundation::IMFSinkWriter, + stream_index: u32, + frame_duration_100ns: i64, + pts: i64, + width: u32, + height: u32, + } + + impl SinkWriterEncoder { + pub fn new( + output_path: &Path, + width: u32, + height: u32, + bitrate: u32, + fps: u32, + ) -> Result { + unsafe { + CoInitializeEx(None, COINIT_MULTITHREADED) + .ok() + .map_err(|e| format!("CoInitializeEx: {}", e))?; + MFStartup(0x20070, MFSTARTUP_FULL).map_err(|e| format!("MFStartup: {}", e))?; + + let path_str: Vec = output_path + .to_string_lossy() + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let url = PCWSTR(path_str.as_ptr()); + + let writer = windows::Win32::Media::MediaFoundation::MFCreateSinkWriterFromURL( + url, + Option::<&windows::Win32::Media::MediaFoundation::IMFByteStream>::None, + None, + ) + .map_err(|e| format!("MFCreateSinkWriterFromURL: {}", e))?; + + let output_type = windows::Win32::Media::MediaFoundation::MFCreateMediaType() + .map_err(|e| format!("MFCreateMediaType(output): {}", e))?; + output_type + .SetGUID(&MF_MT_MAJOR_TYPE, &MF_MT_VIDEO) + .map_err(|e| format!("output MAJOR_TYPE: {}", e))?; + output_type + .SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_H264) + .map_err(|e| format!("output SUBTYPE H264: {}", e))?; + output_type + .SetUINT32(&MF_MT_AVG_BITRATE, bitrate) + .map_err(|e| format!("output AVG_BITRATE: {}", e))?; + output_type + .SetUINT64(&MF_MT_FRAME_SIZE, ((width as u64) << 32) | (height as u64)) + .map_err(|e| format!("output FRAME_SIZE: {}", e))?; + output_type + .SetUINT64(&MF_MT_FRAME_RATE, ((fps as u64) << 32) | 1u64) + .map_err(|e| format!("output FRAME_RATE: {}", e))?; + output_type + .SetUINT32(&MF_MT_INTERLACE_MODE, MF_VIDEO_INTERLACE_PROGRESSIVE) + .map_err(|e| format!("output INTERLACE_MODE: {}", e))?; + + const E_AVE_NC_H264_V_PROFILE_HIGH: u32 = 100; + output_type + .SetUINT32(&MF_MT_MPEG2_PROFILE, E_AVE_NC_H264_V_PROFILE_HIGH) + .map_err(|e| format!("output MPEG2_PROFILE: {}", e))?; + + let stream_index = writer + .AddStream(&output_type) + .map_err(|e| format!("AddStream: {}", e))?; + + let input_type = windows::Win32::Media::MediaFoundation::MFCreateMediaType() + .map_err(|e| format!("MFCreateMediaType(input): {}", e))?; + input_type + .SetGUID(&MF_MT_MAJOR_TYPE, &MF_MT_VIDEO) + .map_err(|e| format!("input MAJOR_TYPE: {}", e))?; + input_type + .SetGUID(&MF_MT_SUBTYPE, &MF_VIDEO_FORMAT_RGB32) + .map_err(|e| format!("input SUBTYPE RGB32: {}", e))?; + input_type + .SetUINT64(&MF_MT_FRAME_SIZE, ((width as u64) << 32) | (height as u64)) + .map_err(|e| format!("input FRAME_SIZE: {}", e))?; + input_type + .SetUINT64(&MF_MT_FRAME_RATE, ((fps as u64) << 32) | 1u64) + .map_err(|e| format!("input FRAME_RATE: {}", e))?; + input_type + .SetUINT32(&MF_MT_INTERLACE_MODE, MF_VIDEO_INTERLACE_PROGRESSIVE) + .map_err(|e| format!("input INTERLACE_MODE: {}", e))?; + + writer + .SetInputMediaType(stream_index, &input_type, None) + .map_err(|e| format!("SetInputMediaType: {}", e))?; + + writer + .BeginWriting() + .map_err(|e| format!("BeginWriting: {}", e))?; + + log::info!( + "SinkWriter encoder initialized: {}x{}, bitrate={}, fps={}", + width, + height, + bitrate, + fps, + ); + + Ok(Self { + writer, + stream_index, + frame_duration_100ns: 10_000_000 / fps as i64, + pts: 0, + width, + height, + }) + } + } + + pub fn write_frame( + &mut self, + rgb: &[u8], + width: usize, + height: usize, + repeat_count: u32, + ) -> Result<(), String> { + unsafe { + let buffer: windows::Win32::Media::MediaFoundation::IMFMediaBuffer = + MFCreate2DMediaBuffer(self.width, self.height, 0x15, false) + .map_err(|e| format!("MFCreate2DMediaBuffer: {}", e))?; + + let buffer_2d: IMF2DBuffer = buffer + .cast() + .map_err(|e| format!("cast to IMF2DBuffer: {}", e))?; + + let mut scanline0: *mut u8 = std::ptr::null_mut(); + let mut pitch: i32 = 0; + buffer_2d + .Lock2D(&mut scanline0, &mut pitch) + .map_err(|e| format!("Lock2D: {}", e))?; + + let pitch_bytes = pitch as usize; + let total = pitch_bytes * self.height as usize; + let dst = std::slice::from_raw_parts_mut(scanline0, total); + + dst.fill(0); + + let src_stride = width * 3; + let rows = height.min(self.height as usize); + for y in 0..rows { + let src_off = y * src_stride; + let dst_off = y * pitch_bytes; + let pixels = ((src_stride / 3) + .min(pitch_bytes / 4) + .min(self.width as usize)) + .min((rgb.len() - src_off) / 3) + .min((dst.len() - dst_off) / 4); + for i in 0..pixels { + let s = src_off + i * 3; + let d = dst_off + i * 4; + dst[d] = rgb[s + 2]; + dst[d + 1] = rgb[s + 1]; + dst[d + 2] = rgb[s]; + dst[d + 3] = 0; + } + } + + buffer_2d + .Unlock2D() + .map_err(|e| format!("Unlock2D: {}", e))?; + + buffer + .SetCurrentLength(total as u32) + .map_err(|e| format!("SetCurrentLength: {}", e))?; + + let sample: IMFSample = + MFCreateSample().map_err(|e| format!("MFCreateSample: {}", e))?; + sample + .AddBuffer(&buffer) + .map_err(|e| format!("AddBuffer: {}", e))?; + sample + .SetSampleTime(self.pts) + .map_err(|e| format!("SetSampleTime: {}", e))?; + let duration = self.frame_duration_100ns * repeat_count.max(1) as i64; + sample + .SetSampleDuration(duration) + .map_err(|e| format!("SetSampleDuration: {}", e))?; + + self.writer + .WriteSample(self.stream_index, &sample) + .map_err(|e| format!("WriteSample: {}", e))?; + + self.pts += duration; + Ok(()) + } + } + + pub fn finalize(self) -> Result<(), String> { + unsafe { + self.writer + .Finalize() + .map_err(|e| format!("Finalize: {}", e))?; + } + Ok(()) + } + } + + impl Drop for SinkWriterEncoder { + fn drop(&mut self) { + unsafe { + let _ = MFShutdown(); + CoUninitialize(); + } + } + } + + unsafe impl Send for SinkWriterEncoder {} +} + +#[cfg(target_os = "macos")] +pub fn create_encoder( + width: u32, + height: u32, + bitrate: u32, + gop_size: u32, +) -> Result, String> { + let enc = vt::VideoToolboxEncoder::new(width, height, bitrate, gop_size) + .map_err(|e| format!("VideoToolbox encoder init failed: {}", e))?; + log::info!( + "using VideoToolbox hardware encoder ({}x{} bitrate={}bps gop={})", + width, + height, + bitrate, + gop_size + ); + Ok(Box::new(enc)) +} + +#[cfg(target_os = "linux")] +pub fn create_encoder( + _width: u32, + _height: u32, + bitrate: u32, + gop_size: u32, +) -> Result, String> { + log::info!( + "using OpenH264 software encoder (bitrate={}bps gop={})", + bitrate, + gop_size + ); + Ok(Box::new( + OpenH264Encoder::new(bitrate, gop_size) + .map_err(|e| format!("OpenH264 init failed: {}", e))?, + )) +} + +#[cfg(windows)] +pub fn create_encoder( + _width: u32, + _height: u32, + _bitrate: u32, + _gop_size: u32, +) -> Result, String> { + Err("not used on Windows — SinkWriter handles encoding".to_string()) +} + +#[cfg(target_os = "linux")] +fn split_annex_b(data: &[u8]) -> Vec<&[u8]> { + let mut sc_positions: Vec = Vec::new(); + let mut sc_lengths: Vec = Vec::new(); + let mut i = 0; + + while i < data.len() { + if i + 3 < data.len() + && data[i] == 0 + && data[i + 1] == 0 + && data[i + 2] == 0 + && data[i + 3] == 1 + { + sc_positions.push(i); + sc_lengths.push(4); + i += 4; + } else if i + 2 < data.len() && data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 { + sc_positions.push(i); + sc_lengths.push(3); + i += 3; + } else { + i += 1; + } + } + + let mut nals = Vec::new(); + for (j, &sc_pos) in sc_positions.iter().enumerate() { + let nal_start = sc_pos + sc_lengths[j]; + let nal_end = if j + 1 < sc_positions.len() { + sc_positions[j + 1] + } else { + data.len() + }; + if nal_start < nal_end { + nals.push(&data[nal_start..nal_end]); + } + } + nals +} diff --git a/src-tauri/src/transcode/mod.rs b/src-tauri/src/transcode/mod.rs new file mode 100644 index 000000000..a0db74448 --- /dev/null +++ b/src-tauri/src/transcode/mod.rs @@ -0,0 +1,768 @@ +pub mod encoder; +/// Replay transcoding module. +/// +/// Provides Tauri commands for converting JumpServer guacamole session +/// recordings (`.tar` archives containing `.part.gz` + `.replay.json`) +/// into H.264 MP4 video files. +mod parser; +mod renderer; +mod transcode; + +use flate2::read::GzDecoder; +use log::{error, info}; +use serde::{Deserialize, Serialize}; +use std::io::Read; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::path::{Path, PathBuf}; +#[cfg(windows)] +use std::sync::Arc; +use tauri::{AppHandle, Emitter}; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ReplayMetadata { + pub id: String, + #[serde(default)] + pub user: String, + #[serde(default)] + pub asset: String, + #[serde(default)] + pub account: String, + #[serde(default)] + pub login_from: String, + #[serde(default)] + pub remote_addr: String, + #[serde(default)] + pub protocol: String, + #[serde(default)] + pub date_start: String, + #[serde(default)] + pub date_end: String, + #[serde(default)] + pub org_id: String, + #[serde(default)] + pub user_id: String, + #[serde(default)] + pub asset_id: String, + #[serde(default)] + pub account_id: String, + #[serde(default, rename = "type")] + pub recording_type: String, + #[serde(default)] + pub files: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +#[serde(rename_all = "snake_case")] +pub enum FilenameStyle { + #[default] + Original, + Friendly, + FriendlyUuid, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum OutputResolution { + #[default] + Original, + P1080, + P720, + P360, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum TranscodePower { + Auto, + #[default] + Full, + Fast, + Medium, + Low, +} + +impl TranscodePower { + fn cpu_fraction(&self) -> f64 { + match self { + TranscodePower::Auto | TranscodePower::Full => 1.0, + TranscodePower::Fast => 0.75, + TranscodePower::Medium => 0.5, + TranscodePower::Low => 0.25, + } + } +} + +impl OutputResolution { + fn target_height(&self) -> Option { + match self { + OutputResolution::Original => None, + OutputResolution::P1080 => Some(1080), + OutputResolution::P720 => Some(720), + OutputResolution::P360 => Some(360), + } + } +} + +fn compute_target_dimensions( + src_width: u32, + src_height: u32, + resolution: &OutputResolution, +) -> (u32, u32) { + let Some(target_h) = resolution.target_height() else { + return (src_width & !15, src_height & !15); + }; + if src_height <= target_h { + return (src_width & !15, src_height & !15); + } + let scale = target_h as f64 / src_height as f64; + let w = ((src_width as f64 * scale).round() as u32).max(16) & !15; + let h = target_h & !15; + (w, h) +} + +pub(crate) fn bitrate_for_resolution(width: u32, height: u32) -> u32 { + let pixels = (width as u64) * (height as u64); + let bitrate = pixels * 50 / 10; + (bitrate as u32).clamp(800_000, 20_000_000) +} + +#[derive(Debug, Serialize, Clone)] +pub struct TranscodeResult { + id: String, + input: String, + output: String, + success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + metadata: Option, +} + +#[derive(Serialize, Clone)] +struct TranscodeProgress { + file: String, + index: usize, + total: usize, + progress: f32, + message: String, + /// Set on the terminal per-task event (progress=100) so the UI can mark + /// the task complete before `transcode_replays` returns the full batch. + #[serde(skip_serializing_if = "Option::is_none")] + success: Option, + #[serde(skip_serializing_if = "Option::is_none")] + output: Option, + /// Transcoding duration in seconds (only set on completion events) + #[serde(skip_serializing_if = "Option::is_none")] + duration: Option, + /// Populated on the first (0%) per-task event so the UI can display + /// session details (user, asset, account, remote_addr, date range) without + /// waiting for the batch to finish. + #[serde(skip_serializing_if = "Option::is_none")] + metadata: Option, +} + +/// Transcode one or more `.tar` replay archives to H.264 MP4 video files. +/// +/// Each `.tar` should contain: +/// - `.replay.json` — session metadata +/// - `.0.part.gz` — gzip-compressed guacamole recording +/// +/// Emits `transcode-progress` events with per-file progress (0–100%). +#[tauri::command] +pub async fn transcode_replays( + app: AppHandle, + tar_paths: Vec, + output_dir: String, + filename_style: Option, + output_resolution: Option, + transcode_power: Option, +) -> Result, String> { + let total = tar_paths.len(); + if total == 0 { + return Ok(Vec::new()); + } + + let style = filename_style.unwrap_or_default(); + let resolution = output_resolution.unwrap_or_default(); + let power = transcode_power.unwrap_or_default(); + + info!("starting replay transcoding: files={}", total); + + #[cfg(not(windows))] + { + let mut results = Vec::with_capacity(total); + + for (idx, tar_path_str) in tar_paths.into_iter().enumerate() { + let app_handle = app.clone(); + let output_dir = output_dir.clone(); + let style = style.clone(); + let resolution = resolution.clone(); + let power = power.clone(); + let panic_session_id = extract_session_id( + PathBuf::from(&tar_path_str) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .as_ref(), + ); + + let result = tokio::task::spawn_blocking(move || { + catch_unwind(AssertUnwindSafe(|| { + transcode_single_tar( + app_handle, + tar_path_str, + output_dir, + idx, + total, + style, + resolution, + power, + ) + })) + }) + .await; + + match result { + Ok(Ok(task_result)) => results.push(task_result), + Ok(Err(panic_payload)) => { + let err = format!( + "transcoding task panicked: {}", + panic_payload_to_string(panic_payload) + ); + emit_progress( + &app, + &panic_session_id, + idx, + total, + 100.0, + "failed".into(), + Some(false), + None, + None, + None, + ); + results.push(TranscodeResult { + id: panic_session_id, + input: String::new(), + output: String::new(), + success: false, + error: Some(err), + metadata: None, + }); + } + Err(e) => { + let err = format!("spawn transcoding task failed: {}", e); + emit_progress( + &app, + &panic_session_id, + idx, + total, + 100.0, + "failed".into(), + Some(false), + None, + None, + None, + ); + results.push(TranscodeResult { + id: panic_session_id, + input: String::new(), + output: String::new(), + success: false, + error: Some(err), + metadata: None, + }); + } + } + } + + Ok(results) + } + + #[cfg(windows)] + { + const MAX_CONCURRENT: usize = 2; + let semaphore = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT)); + let mut handles = Vec::with_capacity(total); + + info!( + "Windows parallel transcoding: files={}, concurrency={}", + total, MAX_CONCURRENT + ); + + for (idx, tar_path_str) in tar_paths.into_iter().enumerate() { + let app_handle = app.clone(); + let output_dir = output_dir.clone(); + let style = style.clone(); + let resolution = resolution.clone(); + let power = power.clone(); + let permit = semaphore.clone().acquire_owned().await.unwrap(); + let panic_session_id = extract_session_id( + PathBuf::from(&tar_path_str) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .as_ref(), + ); + + let handle = tokio::task::spawn_blocking(move || { + let _permit = permit; + catch_unwind(AssertUnwindSafe(|| { + transcode_single_tar( + app_handle, + tar_path_str, + output_dir, + idx, + total, + style, + resolution, + power, + ) + })) + }); + + handles.push((idx, panic_session_id, handle)); + } + + let mut results = Vec::with_capacity(total); + + for (idx, panic_session_id, handle) in handles { + let result = handle.await; + match result { + Ok(Ok(task_result)) => results.push((idx, task_result)), + Ok(Err(panic_payload)) => { + let err = format!( + "transcoding task panicked: {}", + panic_payload_to_string(panic_payload) + ); + emit_progress( + &app, + &panic_session_id, + idx, + total, + 100.0, + "failed".into(), + Some(false), + None, + None, + None, + ); + results.push(( + idx, + TranscodeResult { + id: panic_session_id, + input: String::new(), + output: String::new(), + success: false, + error: Some(err), + metadata: None, + }, + )); + } + Err(e) => { + let err = format!("spawn transcoding task failed: {}", e); + emit_progress( + &app, + &panic_session_id, + idx, + total, + 100.0, + "failed".into(), + Some(false), + None, + None, + None, + ); + results.push(( + idx, + TranscodeResult { + id: panic_session_id, + input: String::new(), + output: String::new(), + success: false, + error: Some(err), + metadata: None, + }, + )); + } + } + } + + results.sort_by_key(|(idx, _)| *idx); + Ok(results.into_iter().map(|(_, r)| r).collect()) + } +} + +fn panic_payload_to_string(payload: Box) -> String { + match payload.downcast::() { + Ok(message) => *message, + Err(payload) => match payload.downcast::<&'static str>() { + Ok(message) => (*message).to_string(), + Err(_) => "unknown panic".to_string(), + }, + } +} + +fn transcode_single_tar( + app: AppHandle, + tar_path_str: String, + output_dir: String, + file_index: usize, + total: usize, + style: FilenameStyle, + resolution: OutputResolution, + power: TranscodePower, +) -> TranscodeResult { + let tar_path = PathBuf::from(&tar_path_str); + let tar_name = tar_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let fallback_session_id = extract_session_id(&tar_name); + + let extraction = match extract_tar_payloads(&tar_path, &fallback_session_id) { + Ok(v) => v, + Err((sid, err)) => { + error!("tar extraction failed for {}: {}", tar_name, err); + emit_progress( + &app, + &sid, + file_index, + total, + 100.0, + err.clone(), + Some(false), + None, + None, + None, + ); + return TranscodeResult { + id: sid, + input: tar_path_str, + output: String::new(), + success: false, + error: Some(err), + metadata: None, + }; + } + }; + + let metadata = match serde_json::from_slice::(&extraction.replay_json) { + Ok(m) => m, + Err(e) => { + let err = format!("parse replay.json failed: {}", e); + error!("{} for {}", err, tar_name); + emit_progress( + &app, + &fallback_session_id, + file_index, + total, + 100.0, + err.clone(), + Some(false), + None, + None, + None, + ); + return TranscodeResult { + id: fallback_session_id, + input: tar_path_str, + output: String::new(), + success: false, + error: Some(err), + metadata: None, + }; + } + }; + + // First per-task event — ship the metadata so the UI can show session + // details the moment the task appears in the list. + emit_progress( + &app, + &metadata.id, + file_index, + total, + 0.0, + "extracting archive".into(), + None, + None, + Some(metadata.clone()), + None, + ); + + let inner_result = transcode_single_tar_inner( + &app, + &metadata, + extraction.parts, + &output_dir, + file_index, + total, + &style, + &resolution, + &power, + ); + + let (output_path, success, error_message) = match &inner_result { + Ok(path) => (path.clone(), true, None), + Err(err) => { + error!("transcode failed for {}: {}", tar_name, err); + (String::new(), false, Some(err.clone())) + } + }; + + TranscodeResult { + id: metadata.id.clone(), + input: tar_path_str, + output: output_path, + success, + error: error_message, + metadata: Some(metadata), + } +} + +struct ExtractedTar { + replay_json: Vec, + parts: Vec<(usize, Vec)>, +} + +fn extract_tar_payloads( + tar_path: &Path, + fallback_session_id: &str, +) -> Result { + let tar_file = std::fs::File::open(tar_path).map_err(|e| { + ( + fallback_session_id.to_string(), + format!("open tar failed: {}", e), + ) + })?; + let mut archive = tar::Archive::new(tar_file); + + let mut replay_json: Option> = None; + let mut parts: Vec<(usize, Vec)> = Vec::new(); + + for entry_result in archive.entries().map_err(|e| { + ( + fallback_session_id.to_string(), + format!("tar read failed: {}", e), + ) + })? { + let mut entry = entry_result.map_err(|e| { + ( + fallback_session_id.to_string(), + format!("tar entry error: {}", e), + ) + })?; + let entry_path = entry + .path() + .map_err(|e| { + ( + fallback_session_id.to_string(), + format!("entry path error: {}", e), + ) + })? + .to_path_buf(); + let filename = entry_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + + if filename.ends_with(".replay.json") { + let mut buf = Vec::new(); + entry.read_to_end(&mut buf).map_err(|e| { + ( + fallback_session_id.to_string(), + format!("read replay.json failed: {}", e), + ) + })?; + replay_json = Some(buf); + } else if let Some(index) = parse_part_index(&filename) { + let mut buf = Vec::new(); + entry.read_to_end(&mut buf).map_err(|e| { + ( + fallback_session_id.to_string(), + format!("read part.gz failed for {}: {}", filename, e), + ) + })?; + parts.push((index, buf)); + } + } + + let replay_json = replay_json.ok_or_else(|| { + ( + fallback_session_id.to_string(), + "replay.json not found in tar archive".to_string(), + ) + })?; + if parts.is_empty() { + return Err(( + fallback_session_id.to_string(), + ".part.gz file not found in tar archive".to_string(), + )); + } + parts.sort_by_key(|(index, _)| *index); + + Ok(ExtractedTar { replay_json, parts }) +} + +/// Parse the numeric index from a `..part.gz` filename. +fn parse_part_index(filename: &str) -> Option { + let suffix = ".part.gz"; + if !filename.ends_with(suffix) { + return None; + } + let stem = &filename[..filename.len() - suffix.len()]; + let dot = stem.rfind('.')?; + stem[dot + 1..].parse::().ok() +} + +fn sanitize_filename_field(s: &str) -> String { + s.chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + _ => c, + }) + .collect::() + .trim() + .to_string() +} + +fn build_output_filename(metadata: &ReplayMetadata, style: &FilenameStyle) -> String { + match style { + FilenameStyle::Original => format!("{}.mp4", metadata.id), + FilenameStyle::Friendly => { + let user = sanitize_filename_field(&metadata.user); + let asset = sanitize_filename_field(&metadata.asset); + let account = sanitize_filename_field(&metadata.account); + format!("{}-{}-{}.mp4", user, asset, account) + } + FilenameStyle::FriendlyUuid => { + let user = sanitize_filename_field(&metadata.user); + let asset = sanitize_filename_field(&metadata.asset); + let account = sanitize_filename_field(&metadata.account); + format!("{}-{}-{}({}).mp4", user, asset, account, metadata.id) + } + } +} + +fn transcode_single_tar_inner( + app: &AppHandle, + metadata: &ReplayMetadata, + parts: Vec<(usize, Vec)>, + output_dir: &str, + file_index: usize, + total: usize, + style: &FilenameStyle, + resolution: &OutputResolution, + power: &TranscodePower, +) -> Result { + let session_id = &metadata.id; + + let mut guac_data = Vec::new(); + for (idx, gz) in &parts { + let mut decoder = GzDecoder::new(gz.as_slice()); + decoder + .read_to_end(&mut guac_data) + .map_err(|e| format!("gzip decompress failed for part {}: {}", idx, e))?; + } + + std::fs::create_dir_all(output_dir).map_err(|e| format!("create output dir failed: {}", e))?; + + let output_path = PathBuf::from(output_dir).join(build_output_filename(metadata, style)); + + let transcode_start = std::time::Instant::now(); + + let app_clone = app.clone(); + let session_id_clone = session_id.to_string(); + + match transcode::transcode_to_mp4( + &guac_data, + &output_path, + resolution.clone(), + power.cpu_fraction(), + move |pct| { + emit_progress( + &app_clone, + &session_id_clone, + file_index, + total, + pct, + format!("encoding: {:.0}%", pct), + None, + None, + None, + None, + ); + }, + ) { + Ok(()) => { + let duration = transcode_start.elapsed().as_secs_f64(); + emit_progress( + app, + session_id, + file_index, + total, + 100.0, + "done".into(), + Some(true), + Some(output_path.to_string_lossy().into_owned()), + None, + Some(duration), + ); + } + Err(e) => { + let err = format!("transcoding failed: {}", e); + emit_progress( + app, + session_id, + file_index, + total, + 100.0, + err.clone(), + Some(false), + None, + None, + None, + ); + return Err(err); + } + } + + Ok(output_path.to_string_lossy().into_owned()) +} + +fn emit_progress( + app: &AppHandle, + file: &str, + index: usize, + total: usize, + progress: f32, + message: String, + success: Option, + output: Option, + metadata: Option, + duration: Option, +) { + let _ = app.emit( + "transcode-progress", + TranscodeProgress { + file: file.to_string(), + index, + total, + progress, + message, + success, + output, + metadata, + duration, + }, + ); +} + +fn extract_session_id(tar_name: &str) -> String { + tar_name + .strip_suffix(".tar") + .unwrap_or(tar_name) + .to_string() +} diff --git a/src-tauri/src/transcode/parser.rs b/src-tauri/src/transcode/parser.rs new file mode 100644 index 000000000..db69c681c --- /dev/null +++ b/src-tauri/src/transcode/parser.rs @@ -0,0 +1,110 @@ +/// Guacamole protocol instruction parser (zero-copy). +/// +/// The Guacamole protocol uses length-prefixed elements: +/// `length.value,length.value,...;` +/// +/// Example: `4.size,2.-1,2.11,2.16;` +/// - opcode: "size" +/// - args: ["-1", "11", "16"] +/// +/// All string values borrow from the input slice — no heap allocation per element. + +#[derive(Debug, Clone)] +pub struct Instruction<'a> { + pub opcode: &'a str, + pub args: Vec<&'a str>, +} + +pub struct Parser<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Parser<'a> { + pub fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + + pub fn current_offset(&self) -> usize { + self.pos + } + + fn remaining(&self) -> bool { + self.pos < self.data.len() + } + + fn skip_whitespace(&mut self) { + while self.pos < self.data.len() + && (self.data[self.pos] == b'\n' || self.data[self.pos] == b'\r') + { + self.pos += 1; + } + } + + /// Parse a length-prefixed element: `digits.content` + /// Returns a borrowed `&str` slice — no allocation. + fn parse_element(&mut self) -> Option<&'a str> { + self.skip_whitespace(); + + if !self.remaining() { + return None; + } + + let len_start = self.pos; + while self.pos < self.data.len() && self.data[self.pos].is_ascii_digit() { + self.pos += 1; + } + + if self.pos == len_start || self.pos >= self.data.len() || self.data[self.pos] != b'.' { + return None; + } + + let len_str = std::str::from_utf8(&self.data[len_start..self.pos]).ok()?; + let len: usize = len_str.parse().ok()?; + + self.pos += 1; // skip '.' + + if self.pos + len > self.data.len() { + return None; + } + + let value = std::str::from_utf8(&self.data[self.pos..self.pos + len]).ok()?; + self.pos += len; + + Some(value) + } + + /// Parse the next complete instruction (opcode + args), terminated by ';'. + pub fn next_instruction(&mut self) -> Option> { + self.skip_whitespace(); + + if !self.remaining() { + return None; + } + + let opcode = self.parse_element()?; + let mut args = Vec::new(); + + loop { + if !self.remaining() { + break; + } + + match self.data[self.pos] { + b',' => { + self.pos += 1; + if let Some(arg) = self.parse_element() { + args.push(arg); + } + } + b';' => { + self.pos += 1; + break; + } + _ => break, + } + } + + Some(Instruction { opcode, args }) + } +} diff --git a/src-tauri/src/transcode/renderer.rs b/src-tauri/src/transcode/renderer.rs new file mode 100644 index 000000000..05aec29d9 --- /dev/null +++ b/src-tauri/src/transcode/renderer.rs @@ -0,0 +1,401 @@ +/// Frame renderer that composites Guacamole drawing instructions onto a pixel canvas. +/// +/// Supports the subset of Guacamole protocol needed for replay: +/// - `size`: set layer dimensions +/// - `img` + `blob` + `end`: draw PNG images onto layers +/// - `rect`, `cfill`: fill rectangles with solid color +/// - `cursor`: update cursor position (visual only) +use base64::Engine; +use image::{DynamicImage, GenericImageView, ImageReader, Rgba, RgbaImage}; +use std::collections::BTreeMap; +use std::io::Cursor; + +const DEFAULT_LAYER: i32 = 0; + +struct PendingImageStream { + layer_id: i32, + x: u32, + y: u32, + mime: String, + data: Vec, +} + +struct Layer { + width: u32, + height: u32, + pixels: RgbaImage, +} + +impl Layer { + fn new(width: u32, height: u32) -> Self { + Self { + width, + height, + pixels: RgbaImage::new(width, height), + } + } + + fn resize(&mut self, width: u32, height: u32) { + self.width = width; + self.height = height; + self.pixels = RgbaImage::new(width, height); + } +} + +pub struct Renderer { + layers: BTreeMap, + width: u32, + height: u32, + pending_streams: BTreeMap, +} + +impl Renderer { + pub fn new(width: u32, height: u32) -> Self { + let mut layers = BTreeMap::new(); + layers.insert(DEFAULT_LAYER, Layer::new(width, height)); + + Self { + layers, + width, + height, + pending_streams: BTreeMap::new(), + } + } + + pub fn width(&self) -> u32 { + self.width + } + + pub fn height(&self) -> u32 { + self.height + } + + /// Resize the main display (layer 0). + pub fn resize_display(&mut self, width: u32, height: u32) { + if self.width == width && self.height == height { + return; + } + self.width = width; + self.height = height; + + if let Some(layer) = self.layers.get_mut(&DEFAULT_LAYER) { + layer.resize(width, height); + } else { + self.layers.insert(DEFAULT_LAYER, Layer::new(width, height)); + } + } + + fn ensure_layer(&mut self, layer_id: i32) -> &mut Layer { + self.layers.entry(layer_id).or_insert_with(|| { + if layer_id < 0 { + Layer::new(0, 0) + } else { + Layer::new(self.width, self.height) + } + }) + } + + fn ensure_layer_fits(layer: &mut Layer, width: u32, height: u32) { + if width <= layer.width && height <= layer.height { + return; + } + + let next_width = width.max(layer.width); + let next_height = height.max(layer.height); + let mut next_pixels = RgbaImage::new(next_width, next_height); + + for y in 0..layer.height { + for x in 0..layer.width { + let pixel = *layer.pixels.get_pixel(x, y); + next_pixels.put_pixel(x, y, pixel); + } + } + + layer.width = next_width; + layer.height = next_height; + layer.pixels = next_pixels; + } + + /// Process a `size` instruction: `size,layer_id,width,height` + pub fn handle_size(&mut self, args: &[&str]) { + if args.len() < 3 { + return; + } + let layer_id: i32 = args[0].parse().unwrap_or(DEFAULT_LAYER); + let w: u32 = args[1].parse().unwrap_or(0); + let h: u32 = args[2].parse().unwrap_or(0); + + if w == 0 || h == 0 { + return; + } + + if layer_id == DEFAULT_LAYER { + self.resize_display(w, h); + } else { + match self.layers.get_mut(&layer_id) { + Some(layer) => layer.resize(w, h), + None => { + self.layers.insert(layer_id, Layer::new(w, h)); + } + } + } + } + + /// Process an `img` instruction: `img,stream,mask,layer,mime,x,y` + pub fn handle_img(&mut self, args: &[&str]) { + if args.len() < 6 { + return; + } + let stream_id: i32 = args[0].parse().unwrap_or(-1); + let layer_id: i32 = args[2].parse().unwrap_or(DEFAULT_LAYER); + let x: u32 = args[4].parse().unwrap_or(0); + let y: u32 = args[5].parse().unwrap_or(0); + let mime = args[3].to_string(); + + self.pending_streams.insert( + stream_id, + PendingImageStream { + layer_id, + x, + y, + mime, + data: Vec::new(), + }, + ); + } + + /// Process a `blob` instruction: `blob,stream,data` + pub fn handle_blob(&mut self, args: &[&str]) { + if args.len() < 2 { + return; + } + + let stream_id: i32 = args[0].parse().unwrap_or(-1); + let Some(stream) = self.pending_streams.get_mut(&stream_id) else { + return; + }; + + let bytes = match base64::engine::general_purpose::STANDARD.decode(args[1]) { + Ok(b) => b, + Err(_) => match base64::engine::general_purpose::URL_SAFE.decode(args[1]) { + Ok(b) => b, + Err(_) => return, + }, + }; + + stream.data.extend_from_slice(&bytes); + } + + pub fn handle_end(&mut self, args: &[&str]) { + let Some(stream_id) = args.first().and_then(|value| value.parse::().ok()) else { + return; + }; + + let Some(stream) = self.pending_streams.remove(&stream_id) else { + return; + }; + + let img = match decode_image(&stream.mime, &stream.data) { + Some(img) => img, + None => return, + }; + + let (img_w, img_h) = img.dimensions(); + let x0 = stream.x; + let y0 = stream.y; + let layer_id = stream.layer_id; + + let layer = self.ensure_layer(layer_id); + Self::ensure_layer_fits(layer, x0 + img_w, y0 + img_h); + + blit_image(layer, &img, x0, y0); + } + + /// Process a `rect` instruction — defines a clipping region, ignored for simplicity. + pub fn handle_rect(&mut self, args: &[&str]) { + if args.len() < 6 { + return; + } + } + + /// Process a `cfill` instruction: `cfill,mask,layer,r,g,b,a` + pub fn handle_cfill(&mut self, args: &[&str]) { + if args.len() < 6 { + return; + } + let layer_id: i32 = args[1].parse().unwrap_or(DEFAULT_LAYER); + let r: u8 = args[2].parse().unwrap_or(0); + let g: u8 = args[3].parse().unwrap_or(0); + let b: u8 = args[4].parse().unwrap_or(0); + let a: u8 = args[5].parse().unwrap_or(255); + + let layer = self.ensure_layer(layer_id); + let color = Rgba([r, g, b, a]); + for pixel in layer.pixels.pixels_mut() { + *pixel = color; + } + } + + /// Process a `copy` instruction: + /// `copy,srcLayer,srcX,srcY,width,height,mask,dstLayer,dstX,dstY` + pub fn handle_copy(&mut self, args: &[&str]) { + if args.len() < 9 { + return; + } + + let src_layer_id: i32 = args[0].parse().unwrap_or(DEFAULT_LAYER); + let src_x: u32 = args[1].parse().unwrap_or(0); + let src_y: u32 = args[2].parse().unwrap_or(0); + let width: u32 = args[3].parse().unwrap_or(0); + let height: u32 = args[4].parse().unwrap_or(0); + let dst_layer_id: i32 = args[6].parse().unwrap_or(DEFAULT_LAYER); + let dst_x: u32 = args[7].parse().unwrap_or(0); + let dst_y: u32 = args[8].parse().unwrap_or(0); + + if width == 0 || height == 0 { + return; + } + + let Some(src_layer) = self.layers.get(&src_layer_id) else { + return; + }; + + let mut copied = Vec::with_capacity((width * height) as usize); + for dy in 0..height { + for dx in 0..width { + let sx = src_x + dx; + let sy = src_y + dy; + let pixel = if sx < src_layer.width && sy < src_layer.height { + *src_layer.pixels.get_pixel(sx, sy) + } else { + Rgba([0, 0, 0, 0]) + }; + copied.push(pixel); + } + } + + let dst_layer = self.ensure_layer(dst_layer_id); + Self::ensure_layer_fits(dst_layer, dst_x + width, dst_y + height); + + let mut idx = 0usize; + for dy in 0..height { + for dx in 0..width { + let pixel = copied[idx]; + idx += 1; + let px = dst_x + dx; + let py = dst_y + dy; + if px < dst_layer.width && py < dst_layer.height { + dst_layer.pixels.put_pixel(px, py, pixel); + } + } + } + } + + /// Composite all visible layers into a pre-allocated RGB buffer. + /// The buffer must be at least `target_width * target_height * 3` bytes. + pub fn composite_into(&self, frame: &mut [u8], target_width: u32, target_height: u32) { + let frame_len = (target_width * target_height * 3) as usize; + if frame.len() < frame_len { + return; + } + + // Fill with white background + for byte in frame[..frame_len].iter_mut() { + *byte = 255; + } + + for (layer_id, layer) in &self.layers { + if *layer_id < 0 || layer.width == 0 || layer.height == 0 { + continue; + } + + let copy_w = layer.width.min(target_width); + let copy_h = layer.height.min(target_height); + + for y in 0..copy_h { + for x in 0..copy_w { + let pixel = layer.pixels.get_pixel(x, y); + let a = pixel.0[3]; + if a == 0 { + continue; + } + + let dst_idx = ((y * target_width + x) * 3) as usize; + if a == 255 { + frame[dst_idx] = pixel.0[0]; + frame[dst_idx + 1] = pixel.0[1]; + frame[dst_idx + 2] = pixel.0[2]; + } else { + // Integer alpha blending: faster than floating-point + let a_u32 = a as u32; + let inv_a = 255 - a_u32; + frame[dst_idx] = ((pixel.0[0] as u32 * a_u32 + + frame[dst_idx] as u32 * inv_a) + / 255) as u8; + frame[dst_idx + 1] = ((pixel.0[1] as u32 * a_u32 + + frame[dst_idx + 1] as u32 * inv_a) + / 255) as u8; + frame[dst_idx + 2] = ((pixel.0[2] as u32 * a_u32 + + frame[dst_idx + 2] as u32 * inv_a) + / 255) as u8; + } + } + } + } + } +} + +/// Bulk blit a decoded image onto a layer. +fn blit_image(layer: &mut Layer, img: &DynamicImage, x0: u32, y0: u32) { + let (img_w, img_h) = img.dimensions(); + + for dy in 0..img_h { + let py = y0 + dy; + if py >= layer.height { + break; + } + for dx in 0..img_w { + let px = x0 + dx; + if px >= layer.width { + break; + } + let pixel = img.get_pixel(dx, dy); + let a = pixel.0[3]; + if a == 0 { + continue; + } + if a == 255 { + layer.pixels.put_pixel(px, py, pixel); + } else { + let existing = *layer.pixels.get_pixel(px, py); + let af = a as f32 / 255.0; + let inv = 1.0 - af; + layer.pixels.put_pixel( + px, + py, + Rgba([ + (pixel.0[0] as f32 * af + existing.0[0] as f32 * inv) as u8, + (pixel.0[1] as f32 * af + existing.0[1] as f32 * inv) as u8, + (pixel.0[2] as f32 * af + existing.0[2] as f32 * inv) as u8, + 255, + ]), + ); + } + } + } +} + +fn decode_image(mime: &str, data: &[u8]) -> Option { + let format = if mime.contains("png") { + image::ImageFormat::Png + } else if mime.contains("jpeg") || mime.contains("jpg") { + image::ImageFormat::Jpeg + } else if mime.contains("webp") { + image::ImageFormat::WebP + } else { + image::guess_format(data).ok()? + }; + + let reader = ImageReader::with_format(Cursor::new(data), format); + reader.decode().ok() +} diff --git a/src-tauri/src/transcode/transcode.rs b/src-tauri/src/transcode/transcode.rs new file mode 100644 index 000000000..5ce90c412 --- /dev/null +++ b/src-tauri/src/transcode/transcode.rs @@ -0,0 +1,1505 @@ +#![allow(dead_code)] + +use crate::transcode::encoder::{create_encoder, H264Encoder}; +use crate::transcode::parser::Parser; +use crate::transcode::renderer::Renderer; +use crate::transcode::{ + bitrate_for_resolution, compute_target_dimensions, OutputResolution, +}; +use fast_image_resize::images::{Image, ImageRef}; +use fast_image_resize::PixelType::U8x3; +use fast_image_resize::{FilterType, ResizeAlg, ResizeOptions, Resizer}; +use log::info; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::Path; +use std::sync::mpsc; +use std::sync::{Arc, Mutex}; + +use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering}; + +const DEFAULT_WIDTH: u32 = 1024; +const DEFAULT_HEIGHT: u32 = 768; +const FPS: u32 = 10; +const TIME_SCALE: u32 = 1000; +const SAMPLE_DURATION: u32 = TIME_SCALE / FPS; +const FRAME_INTERVAL_MS: u64 = 1000 / FPS as u64; +const FRAMES_PER_CHUNK: usize = 50; +const GOP_SIZE: u32 = 50; + +#[derive(Clone)] +struct FrameInfo { + timestamp: u64, + instruction_offset: usize, +} + +struct ChunkResult { + chunk_id: usize, + nals: Vec, + sample_sizes: Vec, + keyframe_indices: Vec, + sample_repeat_counts: Vec, + sps: Vec, + pps: Vec, + enc_width: u32, + enc_height: u32, +} + +struct PreparedFrame { + rgb: Arc<[u8]>, + width: usize, + height: usize, + repeat_count: u32, +} + +fn payload_to_string(payload: Box) -> String { + match payload.downcast::() { + Ok(s) => *s, + Err(p) => match p.downcast::<&'static str>() { + Ok(s) => (*s).to_string(), + Err(_) => "unknown panic".to_string(), + }, + } +} + +#[cfg(not(windows))] +pub fn transcode_to_mp4( + guac_data: &[u8], + output_path: &Path, + resolution: OutputResolution, + cpu_fraction: f64, + progress_callback: impl Fn(f32) + Send + Sync + 'static, +) -> Result<(), String> { + let total_start = std::time::Instant::now(); + + let last_pct = Arc::new(AtomicU32::new(0)); + let cb = Arc::new(Mutex::new(progress_callback)); + let report_progress = { + let last_pct = Arc::clone(&last_pct); + let cb = Arc::clone(&cb); + move |pct: f32| { + let pct_u32 = (pct * 100.0) as u32; + let mut last = last_pct.load(Ordering::Relaxed); + while pct_u32 > last { + match last_pct.compare_exchange_weak( + last, + pct_u32, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => { + cb.lock().unwrap()(pct); + break; + } + Err(actual) => { + last = actual; + } + } + } + } + }; + + report_progress(5.0); + + let timeline = parse_and_build_timeline(guac_data)?; + let frames = timeline.frames; + let total_frames = frames.len(); + + if total_frames < 2 { + return Err("not enough frames to encode".to_string()); + } + + report_progress(10.0); + + let (max_w, max_h) = (timeline.max_width, timeline.max_height); + let (enc_w_raw, enc_h_raw) = if resolution != OutputResolution::Original { + compute_target_dimensions(max_w, max_h, &resolution) + } else { + (max_w, max_h) + }; + + let enc_w = enc_w_raw.max(16) & !15; + let enc_h = enc_h_raw.max(16) & !15; + + if enc_w != enc_w_raw || enc_h != enc_h_raw { + log::info!( + "encoder dimensions aligned to 16: {}x{} -> {}x{}", + enc_w_raw, + enc_h_raw, + enc_w, + enc_h + ); + } + + let target_dims = Some((enc_w, enc_h)); + + let bitrate = bitrate_for_resolution(enc_w, enc_h); + let gop_size = GOP_SIZE; + + let first_chunk_frames = &frames[..frames.len().min(FRAMES_PER_CHUNK)]; + let first_result = encode_single_chunk( + 0, + first_chunk_frames, + guac_data, + &[], + &[], + target_dims, + bitrate, + gop_size, + )?; + + let canonical_sps = first_result.sps.clone(); + let canonical_pps = first_result.pps.clone(); + + if canonical_sps.is_empty() || canonical_pps.is_empty() { + return Err("failed to get SPS/PPS from first chunk".to_string()); + } + + let num_chunks = (total_frames + FRAMES_PER_CHUNK - 1) / FRAMES_PER_CHUNK; + let num_cores = ((num_cpus::get() as f64 * cpu_fraction).round() as usize) + .min(num_chunks) + .max(1); + + let chunk_size = (num_chunks + num_cores - 1) / num_cores; + let actual_cores = (num_chunks + chunk_size - 1) / chunk_size; + + let guac_data_arc = Arc::new(guac_data.to_vec()); + let first_chunk_frame_count = total_frames.min(FRAMES_PER_CHUNK); + let encoded_frames = Arc::new(AtomicUsize::new(first_chunk_frame_count)); + let report_progress_arc = Arc::new(Mutex::new(report_progress)); + let canonical_sps_arc = Arc::new(canonical_sps); + let canonical_pps_arc = Arc::new(canonical_pps); + + let mut handles = Vec::new(); + + for core_id in 0..actual_cores { + let start_chunk = core_id * chunk_size; + let end_chunk = (start_chunk + chunk_size).min(num_chunks); + + if start_chunk >= num_chunks { + break; + } + + let actual_start = if core_id == 0 { + start_chunk.max(1) + } else { + start_chunk + }; + if actual_start >= end_chunk { + continue; + } + + let guac_data_clone = Arc::clone(&guac_data_arc); + let frames_clone = frames.clone(); + let encoded_clone = Arc::clone(&encoded_frames); + let callback_clone = Arc::clone(&report_progress_arc); + let sps_clone = Arc::clone(&canonical_sps_arc); + let pps_clone = Arc::clone(&canonical_pps_arc); + + let handle = std::thread::spawn(move || { + encode_chunks( + actual_start, + end_chunk, + FRAMES_PER_CHUNK, + &guac_data_clone, + &frames_clone, + total_frames, + encoded_clone, + callback_clone, + &sps_clone, + &pps_clone, + target_dims, + bitrate, + gop_size, + ) + }); + + handles.push(handle); + } + + let mut all_results = vec![first_result]; + let mut first_error: Option = None; + + for handle in handles { + match handle.join() { + Ok(Ok(results)) => { + if first_error.is_none() { + all_results.extend(results); + } + } + Ok(Err(e)) => { + if first_error.is_none() { + first_error = Some(e); + } + } + Err(payload) => { + if first_error.is_none() { + first_error = Some(payload_to_string(payload)); + } + } + } + } + + if let Some(err) = first_error { + return Err(err); + } + + all_results.sort_by_key(|r| r.chunk_id); + + report_progress_arc.lock().unwrap()(95.0); + + write_mp4_faststart(output_path, all_results, &frames)?; + + report_progress_arc.lock().unwrap()(100.0); + + info!( + "transcode completed in {:.2}s → {:?}", + total_start.elapsed().as_secs_f64(), + output_path + ); + Ok(()) +} + +#[cfg(windows)] +pub fn transcode_to_mp4( + guac_data: &[u8], + output_path: &Path, + resolution: OutputResolution, + _cpu_fraction: f64, + progress_callback: impl Fn(f32) + Send + Sync + 'static, +) -> Result<(), String> { + use crate::transcode::encoder::sink::SinkWriterEncoder; + use fast_image_resize::images::{Image, ImageRef}; + use fast_image_resize::PixelType::U8x3; + use fast_image_resize::{FilterType, ResizeAlg, ResizeOptions, Resizer}; + use std::sync::{Arc, Mutex}; + + let total_start = std::time::Instant::now(); + + let last_pct = Arc::new(AtomicU32::new(0)); + let cb = Arc::new(Mutex::new(progress_callback)); + let report_progress = { + let last_pct = Arc::clone(&last_pct); + let cb = Arc::clone(&cb); + move |pct: f32| { + let pct_u32 = (pct * 100.0) as u32; + let mut last = last_pct.load(Ordering::Relaxed); + while pct_u32 > last { + match last_pct.compare_exchange_weak( + last, + pct_u32, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => { + cb.lock().unwrap()(pct); + break; + } + Err(actual) => { + last = actual; + } + } + } + } + }; + + report_progress(5.0); + + let timeline = parse_and_build_timeline(guac_data)?; + let frames = timeline.frames; + let total_frames = frames.len(); + + if total_frames < 2 { + return Err("not enough frames to encode".to_string()); + } + + report_progress(10.0); + + let (max_w, max_h) = (timeline.max_width, timeline.max_height); + let (enc_w_raw, enc_h_raw) = if resolution != OutputResolution::Original { + compute_target_dimensions(max_w, max_h, &resolution) + } else { + (max_w, max_h) + }; + + let enc_w = enc_w_raw.max(16) & !15; + let enc_h = enc_h_raw.max(16) & !15; + + if enc_w != enc_w_raw || enc_h != enc_h_raw { + log::info!( + "encoder dimensions aligned to 16: {}x{} -> {}x{}", + enc_w_raw, + enc_h_raw, + enc_w, + enc_h + ); + } + + let target_dims = Some((enc_w, enc_h)); + let bitrate = bitrate_for_resolution(enc_w, enc_h); + + let mut encoder = SinkWriterEncoder::new(output_path, enc_w, enc_h, bitrate, FPS)?; + + let mut parser = Parser::new(guac_data); + let mut renderer = Renderer::new(DEFAULT_WIDTH, DEFAULT_HEIGHT); + let mut width = DEFAULT_WIDTH; + let mut height = DEFAULT_HEIGHT; + + let mut frame_buf = vec![255u8; (max_w * max_h * 3) as usize]; + let mut rgba_buf: Vec = Vec::new(); + let mut resizer = Resizer::new(); + let resize_options = + ResizeOptions::new().resize_alg(ResizeAlg::Convolution(FilterType::Bilinear)); + + let mut frame_idx = 0; + + let mut prev_frame_hash: u64 = 0; + let mut prev_rgb: Option> = None; + let mut prev_src_w: usize = 0; + let mut prev_src_h: usize = 0; + let mut pending_repeat_count: u32 = 0; + let mut first_frame = true; + + while let Some(inst) = parser.next_instruction() { + match inst.opcode { + "size" => { + renderer.handle_size(&inst.args); + if inst.args.len() >= 3 { + let lid: i32 = inst.args[0].parse().unwrap_or(-1); + if lid == 0 { + let w: u32 = inst.args[1].parse().unwrap_or(0); + let h: u32 = inst.args[2].parse().unwrap_or(0); + if w > 0 && h > 0 { + width = width.max(w); + height = height.max(h); + let needed = (width * height * 3) as usize; + if frame_buf.len() < needed { + frame_buf.resize(needed, 255); + } + } + } + } + } + "img" => renderer.handle_img(&inst.args), + "blob" => renderer.handle_blob(&inst.args), + "end" => renderer.handle_end(&inst.args), + "copy" => renderer.handle_copy(&inst.args), + "rect" => renderer.handle_rect(&inst.args), + "cfill" => renderer.handle_cfill(&inst.args), + "sync" => { + if let Some(ts_str) = inst.args.first() { + if let Ok(ts) = ts_str.parse::() { + while frame_idx < frames.len() && frames[frame_idx].timestamp <= ts { + let rw = renderer.width(); + let rh = renderer.height(); + + let (enc_w_frame, enc_h_frame) = if let Some((tw, th)) = target_dims { + (tw as usize, th as usize) + } else { + ((rw & !1) as usize, (rh & !1) as usize) + }; + + if enc_w_frame >= 2 && enc_h_frame >= 2 { + renderer.composite_into(&mut frame_buf, rw, rh); + + let frame_hash = + hash_frame_sample(&frame_buf, rw as usize, rh as usize); + + if !first_frame + && frame_hash == prev_frame_hash + && enc_w_frame == prev_src_w + && enc_h_frame == prev_src_h + { + pending_repeat_count += 1; + } else { + if let Some(ref prev_data) = prev_rgb { + encoder.write_frame( + prev_data, + prev_src_w, + prev_src_h, + pending_repeat_count, + )?; + } + + let src_len = (rw * rh * 3) as usize; + let rgb_data: Arc<[u8]>; + let src_w: usize; + let src_h: usize; + + if target_dims.is_some() + && (enc_w_frame != rw as usize + || enc_h_frame != rh as usize) + { + let src_view = ImageRef::new( + rw, + rh, + &frame_buf[..src_len], + U8x3, + ) + .map_err(|e| format!("create image view: {}", e))?; + + rgba_buf.resize( + (enc_w_frame * enc_h_frame * 3) as usize, + 0, + ); + let mut dst_image = Image::from_slice_u8( + enc_w_frame as u32, + enc_h_frame as u32, + &mut rgba_buf, + U8x3, + ) + .map_err(|e| format!("create dst image: {}", e))?; + + resizer + .resize(&src_view, &mut dst_image, &resize_options) + .map_err(|e| format!("resize failed: {}", e))?; + + rgb_data = rgba_buf.as_slice().into(); + src_w = enc_w_frame; + src_h = enc_h_frame; + } else if enc_w_frame == rw as usize + && enc_h_frame == rh as usize + { + rgb_data = frame_buf[..src_len].into(); + src_w = enc_w_frame; + src_h = enc_h_frame; + } else { + let mut cropped = vec![ + 255u8; + (enc_w_frame * enc_h_frame * 3) as usize + ]; + let src_stride = rw as usize * 3; + let dst_stride = enc_w_frame * 3; + for y in 0..enc_h_frame.min(rh as usize) { + let src_off = y * src_stride; + let dst_off = y * dst_stride; + let copy_len = dst_stride.min(src_stride); + cropped[dst_off..dst_off + copy_len] + .copy_from_slice( + &frame_buf[src_off..src_off + copy_len], + ); + } + rgb_data = cropped.into(); + src_w = enc_w_frame; + src_h = enc_h_frame; + } + + prev_rgb = Some(rgb_data); + prev_src_w = src_w; + prev_src_h = src_h; + prev_frame_hash = frame_hash; + pending_repeat_count = 1; + first_frame = false; + } + } + + frame_idx += 1; + if frame_idx >= frames.len() { + break; + } + } + + let pct = 10.0 + (frame_idx as f32 / total_frames as f32) * 85.0; + report_progress(pct); + + if frame_idx >= frames.len() { + break; + } + } + } + } + _ => {} + } + } + + if let Some(ref prev_data) = prev_rgb { + encoder.write_frame(prev_data, prev_src_w, prev_src_h, pending_repeat_count)?; + } + + encoder.finalize()?; + + report_progress(100.0); + + info!( + "transcode completed in {:.2}s → {:?}", + total_start.elapsed().as_secs_f64(), + output_path + ); + Ok(()) +} + +struct TimelineInfo { + frames: Vec, + max_width: u32, + max_height: u32, +} + +fn parse_and_build_timeline(guac_data: &[u8]) -> Result { + let mut parser = Parser::new(guac_data); + let mut frames = Vec::new(); + let mut max_w: u32 = 1024; + let mut max_h: u32 = 768; + let mut next_emit_ms: u64 = 0; + let mut emit_initialized = false; + let mut instruction_offset = 0; + + while let Some(inst) = parser.next_instruction() { + match inst.opcode { + "sync" => { + if let Some(ts_str) = inst.args.first() { + if let Ok(ts) = ts_str.parse::() { + if !emit_initialized { + next_emit_ms = ts + FRAME_INTERVAL_MS; + emit_initialized = true; + } + + while next_emit_ms <= ts { + frames.push(FrameInfo { + timestamp: next_emit_ms, + instruction_offset, + }); + next_emit_ms += FRAME_INTERVAL_MS; + } + } + } + } + "size" => { + if inst.args.len() >= 3 { + let lid: i32 = inst.args[0].parse().unwrap_or(-1); + if lid == 0 { + let w: u32 = inst.args[1].parse().unwrap_or(0); + let h: u32 = inst.args[2].parse().unwrap_or(0); + if w > 0 && h > 0 { + max_w = max_w.max(w); + max_h = max_h.max(h); + } + } + } + } + _ => {} + } + instruction_offset = parser.current_offset(); + } + + Ok(TimelineInfo { frames, max_width: max_w, max_height: max_h }) +} + +fn encode_chunks( + start_chunk: usize, + end_chunk: usize, + frames_per_chunk: usize, + guac_data: &[u8], + frames: &[FrameInfo], + total_frames: usize, + encoded_frames: Arc, + callback: Arc>, + canonical_sps: &[u8], + canonical_pps: &[u8], + target_dims: Option<(u32, u32)>, + bitrate: u32, + gop_size: u32, +) -> Result, String> { + let mut results = Vec::new(); + + for chunk_id in start_chunk..end_chunk { + let start_frame = chunk_id * frames_per_chunk; + let end_frame = (start_frame + frames_per_chunk).min(total_frames); + + if start_frame >= total_frames { + break; + } + + let chunk_frames = &frames[start_frame..end_frame]; + let chunk_frame_count = end_frame - start_frame; + + let result = encode_single_chunk( + chunk_id, + chunk_frames, + guac_data, + canonical_sps, + canonical_pps, + target_dims, + bitrate, + gop_size, + )?; + results.push(result); + + let done = + encoded_frames.fetch_add(chunk_frame_count, Ordering::Relaxed) + chunk_frame_count; + let pct = 10.0 + (done as f32 / total_frames as f32) * 85.0; + callback.lock().unwrap()(pct); + } + + Ok(results) +} + +fn encode_single_chunk( + chunk_id: usize, + frames: &[FrameInfo], + guac_data: &[u8], + canonical_sps: &[u8], + canonical_pps: &[u8], + target_dims: Option<(u32, u32)>, + bitrate: u32, + gop_size: u32, +) -> Result { + if frames.is_empty() { + let (ew, eh) = target_dims.unwrap_or((DEFAULT_WIDTH, DEFAULT_HEIGHT)); + return Ok(ChunkResult { + chunk_id, + nals: Vec::new(), + sample_sizes: Vec::new(), + keyframe_indices: Vec::new(), + sample_repeat_counts: Vec::new(), + sps: canonical_sps.to_vec(), + pps: canonical_pps.to_vec(), + enc_width: ew, + enc_height: eh, + }); + } + + let first_frame_offset = frames[0].instruction_offset; + + let mut parser = Parser::new(guac_data); + let mut renderer = Renderer::new(DEFAULT_WIDTH, DEFAULT_HEIGHT); + let mut width = DEFAULT_WIDTH; + let mut height = DEFAULT_HEIGHT; + + let mut instruction_offset = 0; + while instruction_offset < first_frame_offset { + if let Some(inst) = parser.next_instruction() { + match inst.opcode { + "size" => { + renderer.handle_size(&inst.args); + if inst.args.len() >= 3 { + let lid: i32 = inst.args[0].parse().unwrap_or(-1); + if lid == 0 { + let w: u32 = inst.args[1].parse().unwrap_or(0); + let h: u32 = inst.args[2].parse().unwrap_or(0); + if w > 0 && h > 0 { + width = width.max(w); + height = height.max(h); + } + } + } + } + "img" => renderer.handle_img(&inst.args), + "blob" => renderer.handle_blob(&inst.args), + "end" => renderer.handle_end(&inst.args), + "copy" => renderer.handle_copy(&inst.args), + "rect" => renderer.handle_rect(&inst.args), + "cfill" => renderer.handle_cfill(&inst.args), + _ => {} + } + instruction_offset = parser.current_offset(); + } else { + break; + } + } + + let mut resizer = Resizer::new(); + let resize_options = + ResizeOptions::new().resize_alg(ResizeAlg::Convolution(FilterType::Bilinear)); + + let mut frame_buf = vec![255u8; (width * height * 3) as usize]; + let mut rgba_buf: Vec = Vec::new(); + + let mut prev_frame_hash: u64 = 0; + let mut first_frame = true; + let mut pending_repeat_count: u32 = 0; + let mut prev_repeat_count: u32 = 0; + + let (tx, rx) = mpsc::channel::(); + + let enc_w_fixed = target_dims.map(|(w, _)| w as usize); + let enc_h_fixed = target_dims.map(|(_, h)| h as usize); + let sps_owned = canonical_sps.to_vec(); + let pps_owned = canonical_pps.to_vec(); + + let encoder_handle = std::thread::spawn(move || { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + encoder_thread_fn( + rx, + enc_w_fixed, + enc_h_fixed, + bitrate, + gop_size, + &sps_owned, + &pps_owned, + ) + })); + match result { + Ok(inner) => inner, + Err(payload) => Err(payload_to_string(payload)), + } + }); + + let mut frame_idx = 0; + + 'parser: while let Some(inst) = parser.next_instruction() { + match inst.opcode { + "size" => { + renderer.handle_size(&inst.args); + if inst.args.len() >= 3 { + let lid: i32 = inst.args[0].parse().unwrap_or(-1); + if lid == 0 { + let w: u32 = inst.args[1].parse().unwrap_or(0); + let h: u32 = inst.args[2].parse().unwrap_or(0); + if w > 0 && h > 0 { + width = width.max(w); + height = height.max(h); + let needed = (width * height * 3) as usize; + if frame_buf.len() < needed { + frame_buf.resize(needed, 255); + } + } + } + } + } + "img" => renderer.handle_img(&inst.args), + "blob" => renderer.handle_blob(&inst.args), + "end" => renderer.handle_end(&inst.args), + "copy" => renderer.handle_copy(&inst.args), + "rect" => renderer.handle_rect(&inst.args), + "cfill" => renderer.handle_cfill(&inst.args), + "sync" => { + if let Some(ts_str) = inst.args.first() { + if let Ok(ts) = ts_str.parse::() { + while frame_idx < frames.len() && frames[frame_idx].timestamp <= ts { + let rw = renderer.width(); + let rh = renderer.height(); + + let (enc_w, enc_h) = if let Some((tw, th)) = target_dims { + (tw as usize, th as usize) + } else { + ((rw & !1) as usize, (rh & !1) as usize) + }; + + if enc_w >= 2 && enc_h >= 2 { + renderer.composite_into(&mut frame_buf, rw, rh); + + let frame_hash = + hash_frame_sample(&frame_buf, rw as usize, rh as usize); + + if !first_frame && frame_hash == prev_frame_hash { + pending_repeat_count += 1; + } else { + let repeat_count = if first_frame { + prev_repeat_count = 1; + pending_repeat_count = 1; + 1 + } else { + let rc = prev_repeat_count; + prev_repeat_count = pending_repeat_count; + pending_repeat_count = 1; + rc + }; + + let src_len = (rw * rh * 3) as usize; + let rgb_data: Arc<[u8]>; + let src_w: usize; + let src_h: usize; + + if target_dims.is_some() + && (enc_w != rw as usize || enc_h != rh as usize) + { + let src_view = + ImageRef::new(rw, rh, &frame_buf[..src_len], U8x3) + .map_err(|e| format!("create image view: {}", e))?; + + rgba_buf.resize((enc_w * enc_h * 3) as usize, 0); + let mut dst_image = Image::from_slice_u8( + enc_w as u32, + enc_h as u32, + &mut rgba_buf, + U8x3, + ) + .map_err(|e| format!("create dst image: {}", e))?; + + resizer + .resize(&src_view, &mut dst_image, &resize_options) + .map_err(|e| format!("resize failed: {}", e))?; + + rgb_data = rgba_buf.as_slice().into(); + src_w = enc_w; + src_h = enc_h; + } else if enc_w == rw as usize && enc_h == rh as usize { + rgb_data = frame_buf[..src_len].into(); + src_w = enc_w; + src_h = enc_h; + } else { + let mut cropped = vec![255u8; (enc_w * enc_h * 3) as usize]; + let src_stride = rw as usize * 3; + let dst_stride = enc_w * 3; + for y in 0..enc_h.min(rh as usize) { + let src_off = y * src_stride; + let dst_off = y * dst_stride; + let copy_len = dst_stride.min(src_stride); + cropped[dst_off..dst_off + copy_len].copy_from_slice( + &frame_buf[src_off..src_off + copy_len], + ); + } + rgb_data = cropped.into(); + src_w = enc_w; + src_h = enc_h; + } + + if tx + .send(PreparedFrame { + rgb: rgb_data, + width: src_w, + height: src_h, + repeat_count, + }) + .is_err() + { + break 'parser; + } + + first_frame = false; + } + + prev_frame_hash = frame_hash; + } + + frame_idx += 1; + if frame_idx >= frames.len() { + break; + } + } + + if frame_idx >= frames.len() { + break; + } + } + } + } + _ => {} + } + } + + if pending_repeat_count > 1 { + let _ = tx.send(PreparedFrame { + rgb: Arc::from(&[][..]), + width: 0, + height: 0, + repeat_count: pending_repeat_count, + }); + } + + drop(tx); + + match encoder_handle.join() { + Ok(Ok(r)) => Ok(ChunkResult { + chunk_id, + nals: r.nals, + sample_sizes: r.sample_sizes, + keyframe_indices: r.keyframe_indices, + sample_repeat_counts: r.sample_repeat_counts, + sps: r.sps, + pps: r.pps, + enc_width: r.enc_width, + enc_height: r.enc_height, + }), + Ok(Err(e)) => Err(e), + Err(_) => Err("encoder thread panicked".to_string()), + } +} + +fn encoder_thread_fn( + rx: mpsc::Receiver, + enc_w_fixed: Option, + enc_h_fixed: Option, + bitrate: u32, + gop_size: u32, + canonical_sps: &[u8], + canonical_pps: &[u8], +) -> Result { + let mut encoder: Option> = None; + let mut nals = Vec::new(); + let mut sample_sizes = Vec::new(); + let mut keyframe_indices = Vec::new(); + let mut sample_repeat_counts = Vec::new(); + let mut max_enc_w: u32 = 0; + let mut max_enc_h: u32 = 0; + let mut has_prev_sample = false; + let mut sps = Vec::new(); + let mut pps = Vec::new(); + + for prepared in rx { + if prepared.rgb.is_empty() { + if has_prev_sample { + *sample_repeat_counts.last_mut().unwrap() += prepared.repeat_count; + } + continue; + } + + if encoder.is_none() { + encoder = Some(create_encoder( + prepared.width as u32, + prepared.height as u32, + bitrate, + gop_size, + )?); + } + + let enc = encoder.as_mut().unwrap(); + let output = enc.encode_frame(&prepared.rgb, prepared.width, prepared.height)?; + + if output.sample_size > 0 { + let sample_idx = sample_sizes.len(); + nals.extend_from_slice(&output.data); + sample_sizes.push(output.sample_size); + sample_repeat_counts.push(prepared.repeat_count); + if output.is_keyframe { + keyframe_indices.push(sample_idx); + } + max_enc_w = max_enc_w.max(prepared.width as u32); + max_enc_h = max_enc_h.max(prepared.height as u32); + + if !enc.sps().is_empty() { + sps = enc.sps().to_vec(); + } + if !enc.pps().is_empty() { + pps = enc.pps().to_vec(); + } + has_prev_sample = true; + } else if has_prev_sample { + *sample_repeat_counts.last_mut().unwrap() += prepared.repeat_count; + } + } + + if let Some(ref mut enc) = encoder { + let _ = enc.flush(); + } + + let (out_sps, out_pps) = if canonical_sps.is_empty() { + (sps, pps) + } else { + (canonical_sps.to_vec(), canonical_pps.to_vec()) + }; + + let final_w = if max_enc_w > 0 { + max_enc_w + } else { + enc_w_fixed.unwrap_or(DEFAULT_WIDTH as usize) as u32 + }; + let final_h = if max_enc_h > 0 { + max_enc_h + } else { + enc_h_fixed.unwrap_or(DEFAULT_HEIGHT as usize) as u32 + }; + + Ok(ChunkResultInner { + nals, + sample_sizes, + keyframe_indices, + sample_repeat_counts, + sps: out_sps, + pps: out_pps, + enc_width: final_w, + enc_height: final_h, + }) +} + +struct ChunkResultInner { + nals: Vec, + sample_sizes: Vec, + keyframe_indices: Vec, + sample_repeat_counts: Vec, + sps: Vec, + pps: Vec, + enc_width: u32, + enc_height: u32, +} + +pub(crate) fn hash_frame_sample(rgb: &[u8], width: usize, height: usize) -> u64 { + let stride = width * 3; + let step = 8usize; + let mut hash: u64 = 0xcbf29ce484222325; + + let mut y = 0; + while y < height { + let mut x = 0; + while x < width { + let idx = y * stride + x * 3; + if idx + 2 < rgb.len() { + let val = rgb[idx] as u64 + rgb[idx + 1] as u64 + rgb[idx + 2] as u64; + hash ^= val; + hash = hash.wrapping_mul(0x100000001b3); + } + x += step; + } + y += step; + } + + hash +} + +fn write_mp4_faststart( + output_path: &Path, + results: Vec, + frames: &[FrameInfo], +) -> Result<(), String> { + let tmp_path = output_path.with_extension("mp4.tmp"); + + let (mdat_header_pos, mdat_payload_start, bytes_written) = + write_mp4_to_file(&tmp_path, &results)?; + let _ = frames; + + if bytes_written == 0 { + let _ = std::fs::remove_file(&tmp_path); + return Err("no data written".to_string()); + } + + let moov_offset = mdat_payload_start + bytes_written; + + let mut tmp_file = + std::fs::File::open(&tmp_path).map_err(|e| format!("open temp file: {}", e))?; + + let ftyp = make_ftyp(); + + tmp_file + .seek(SeekFrom::Start(moov_offset)) + .map_err(|e| format!("seek to moov: {}", e))?; + let mut moov_data = Vec::new(); + tmp_file + .read_to_end(&mut moov_data) + .map_err(|e| format!("read moov: {}", e))?; + + let moov_size = moov_data.len() as u64; + let moov_adjusted = rebuild_moov_with_offset(&moov_data, moov_size); + + let final_file = + std::fs::File::create(output_path).map_err(|e| format!("create output: {}", e))?; + let mut w = std::io::BufWriter::new(final_file); + + w.write_all(&ftyp).map_err(|e| e.to_string())?; + w.write_all(&moov_adjusted).map_err(|e| e.to_string())?; + + drop(tmp_file); + let mut tmp_file = std::fs::File::open(&tmp_path).map_err(|e| format!("reopen temp: {}", e))?; + tmp_file + .seek(SeekFrom::Start(mdat_header_pos)) + .map_err(|e| format!("seek to mdat: {}", e))?; + + let mdat_total = 8 + bytes_written; + let mut remaining = mdat_total; + let mut buf = vec![0u8; 64 * 1024]; + while remaining > 0 { + let to_read = buf.len().min(remaining as usize); + let n = tmp_file + .read(&mut buf[..to_read]) + .map_err(|e| format!("read mdat: {}", e))?; + if n == 0 { + break; + } + w.write_all(&buf[..n]).map_err(|e| e.to_string())?; + remaining -= n as u64; + } + + w.flush().map_err(|e| e.to_string())?; + drop(w); + + let _ = std::fs::remove_file(&tmp_path); + Ok(()) +} + +fn rebuild_moov_with_offset(moov_data: &[u8], moov_size: u64) -> Vec { + let mut result = moov_data.to_vec(); + let moov_pos = match find_box_in_data(&result, b"moov") { + Some(p) => p, + None => return result, + }; + let trak_pos = match find_box_in_data(&result[moov_pos + 8..], b"trak") { + Some(p) => moov_pos + 8 + p, + None => return result, + }; + let mdia_pos = match find_box_in_data(&result[trak_pos + 8..], b"mdia") { + Some(p) => trak_pos + 8 + p, + None => return result, + }; + let minf_pos = match find_box_in_data(&result[mdia_pos + 8..], b"minf") { + Some(p) => mdia_pos + 8 + p, + None => return result, + }; + let stbl_pos = match find_box_in_data(&result[minf_pos + 8..], b"stbl") { + Some(p) => minf_pos + 8 + p, + None => return result, + }; + let co64_pos = match find_box_in_data(&result[stbl_pos + 8..], b"co64") { + Some(p) => stbl_pos + 8 + p, + None => return result, + }; + + let entries_offset = co64_pos + 16; + if entries_offset + 8 > result.len() { + return result; + } + let entry_count = u32::from_be_bytes([ + result[co64_pos + 12], + result[co64_pos + 13], + result[co64_pos + 14], + result[co64_pos + 15], + ]) as usize; + + for i in 0..entry_count { + let off = entries_offset + i * 8; + if off + 8 <= result.len() { + let old = u64::from_be_bytes([ + result[off], + result[off + 1], + result[off + 2], + result[off + 3], + result[off + 4], + result[off + 5], + result[off + 6], + result[off + 7], + ]); + let new = old + moov_size; + result[off..off + 8].copy_from_slice(&new.to_be_bytes()); + } + } + + result +} + +fn find_box_in_data(data: &[u8], box_type: &[u8; 4]) -> Option { + let mut pos = 0; + while pos + 8 <= data.len() { + let size = + u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize; + if &data[pos + 4..pos + 8] == box_type { + return Some(pos); + } + if size < 8 || pos + size > data.len() { + return None; + } + pos += size; + } + None +} + +fn write_mp4_to_file(path: &Path, results: &[ChunkResult]) -> Result<(u64, u64, u64), String> { + let file = std::fs::File::create(path).map_err(|e| format!("create file: {}", e))?; + let mut w = std::io::BufWriter::new(file); + + let ftyp = make_ftyp(); + w.write_all(&ftyp).map_err(|e| e.to_string())?; + + let mdat_header_pos = ftyp.len() as u64; + w.write_all(&0u32.to_be_bytes()) + .map_err(|e| e.to_string())?; + w.write_all(b"mdat").map_err(|e| e.to_string())?; + + let mdat_payload_start = mdat_header_pos + 8; + + let mut all_sample_sizes = Vec::new(); + let mut all_keyframe_indices = Vec::new(); + let mut all_repeat_counts = Vec::new(); + let mut sps = Vec::new(); + let mut pps = Vec::new(); + let mut bytes_written: u64 = 0; + let mut max_w: u32 = 0; + let mut max_h: u32 = 0; + + for result in results { + if !result.sps.is_empty() && !result.pps.is_empty() { + sps = result.sps.clone(); + pps = result.pps.clone(); + break; + } + } + + for result in results { + w.write_all(&result.nals).map_err(|e| e.to_string())?; + + let base_sample_idx = all_sample_sizes.len(); + for &k in &result.keyframe_indices { + all_keyframe_indices.push(base_sample_idx + k); + } + + all_sample_sizes.extend_from_slice(&result.sample_sizes); + all_repeat_counts.extend_from_slice(&result.sample_repeat_counts); + bytes_written += result.nals.len() as u64; + max_w = max_w.max(result.enc_width); + max_h = max_h.max(result.enc_height); + } + + w.flush().map_err(|e| e.to_string())?; + + if all_sample_sizes.len() < 2 { + return Err("not enough frames to encode".to_string()); + } + if sps.is_empty() || pps.is_empty() { + return Err("missing SPS or PPS in encoded stream".to_string()); + } + + let enc_width = (max_w & !1) as u32; + let enc_height = (max_h & !1) as u32; + let mdat_size = 8u64 + bytes_written; + + w.seek(SeekFrom::Start(mdat_header_pos)) + .map_err(|e| e.to_string())?; + w.write_all(&(mdat_size as u32).to_be_bytes()) + .map_err(|e| e.to_string())?; + + w.seek(SeekFrom::Start(mdat_payload_start + bytes_written)) + .map_err(|e| e.to_string())?; + + let moov = make_moov( + enc_width, + enc_height, + &all_repeat_counts, + &all_sample_sizes, + &all_keyframe_indices, + &sps, + &pps, + mdat_payload_start, + ); + + w.write_all(&moov).map_err(|e| e.to_string())?; + w.flush().map_err(|e| e.to_string())?; + + Ok((mdat_header_pos, mdat_payload_start, bytes_written)) +} + +fn box_raw(typ: &[u8; 4], payload: &[u8]) -> Vec { + let mut b = Vec::with_capacity(8 + payload.len()); + b.extend_from_slice(&((8 + payload.len()) as u32).to_be_bytes()); + b.extend_from_slice(typ); + b.extend_from_slice(payload); + b +} + +fn fullbox(typ: &[u8; 4], ver: u8, flags: u32, payload: &[u8]) -> Vec { + let mut b = Vec::with_capacity(12 + payload.len()); + b.extend_from_slice(&((12 + payload.len()) as u32).to_be_bytes()); + b.extend_from_slice(typ); + b.push(ver); + b.push((flags >> 16) as u8); + b.push((flags >> 8) as u8); + b.push(flags as u8); + b.extend_from_slice(payload); + b +} + +fn make_ftyp() -> Vec { + let mut p = Vec::new(); + p.extend_from_slice(b"isom"); + p.extend_from_slice(&0u32.to_be_bytes()); + p.extend_from_slice(b"isom"); + p.extend_from_slice(b"iso2"); + p.extend_from_slice(b"avc1"); + p.extend_from_slice(b"mp41"); + box_raw(b"ftyp", &p) +} + +fn make_moov( + w: u32, + h: u32, + repeat_counts: &[u32], + sizes: &[u32], + keys: &[usize], + sps: &[u8], + pps: &[u8], + offset: u64, +) -> Vec { + let unique_samples = repeat_counts.len() as u32; + let total_display_samples: u64 = repeat_counts.iter().map(|&c| c as u64).sum(); + let dur = total_display_samples * SAMPLE_DURATION as u64; + + let mut mvhd_p = Vec::new(); + mvhd_p.extend_from_slice(&0u32.to_be_bytes()); + mvhd_p.extend_from_slice(&0u32.to_be_bytes()); + mvhd_p.extend_from_slice(&TIME_SCALE.to_be_bytes()); + mvhd_p.extend_from_slice(&(dur as u32).to_be_bytes()); + mvhd_p.extend_from_slice(&0x00010000u32.to_be_bytes()); + mvhd_p.extend_from_slice(&0x0100u16.to_be_bytes()); + mvhd_p.extend_from_slice(&[0u8; 10]); + for v in [0x00010000u32, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000] { + mvhd_p.extend_from_slice(&v.to_be_bytes()); + } + mvhd_p.extend_from_slice(&[0u8; 24]); + mvhd_p.extend_from_slice(&2u32.to_be_bytes()); + let mvhd = fullbox(b"mvhd", 0, 0, &mvhd_p); + + let mut tkhd_p = Vec::new(); + tkhd_p.extend_from_slice(&0u32.to_be_bytes()); + tkhd_p.extend_from_slice(&0u32.to_be_bytes()); + tkhd_p.extend_from_slice(&1u32.to_be_bytes()); + tkhd_p.extend_from_slice(&0u32.to_be_bytes()); + tkhd_p.extend_from_slice(&(dur as u32).to_be_bytes()); + tkhd_p.extend_from_slice(&[0u8; 8]); + tkhd_p.extend_from_slice(&0u16.to_be_bytes()); + tkhd_p.extend_from_slice(&0u16.to_be_bytes()); + tkhd_p.extend_from_slice(&0u16.to_be_bytes()); + tkhd_p.extend_from_slice(&0u16.to_be_bytes()); + for v in [0x00010000u32, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000] { + tkhd_p.extend_from_slice(&v.to_be_bytes()); + } + tkhd_p.extend_from_slice(&(w << 16).to_be_bytes()); + tkhd_p.extend_from_slice(&(h << 16).to_be_bytes()); + let tkhd = fullbox(b"tkhd", 0, 3, &tkhd_p); + + let mut mdhd_p = Vec::new(); + mdhd_p.extend_from_slice(&0u32.to_be_bytes()); + mdhd_p.extend_from_slice(&0u32.to_be_bytes()); + mdhd_p.extend_from_slice(&TIME_SCALE.to_be_bytes()); + mdhd_p.extend_from_slice(&(dur as u32).to_be_bytes()); + mdhd_p.extend_from_slice(&0x55C4u16.to_be_bytes()); + mdhd_p.extend_from_slice(&0u16.to_be_bytes()); + let mdhd = fullbox(b"mdhd", 0, 0, &mdhd_p); + + let mut hdlr_p = Vec::new(); + hdlr_p.extend_from_slice(&0u32.to_be_bytes()); + hdlr_p.extend_from_slice(b"vide"); + hdlr_p.extend_from_slice(&[0u8; 12]); + hdlr_p.extend_from_slice(b"VideoHandler\0"); + let hdlr = fullbox(b"hdlr", 0, 0, &hdlr_p); + + let vmhd = fullbox(b"vmhd", 0, 1, &[0u8; 8]); + + let url_box = fullbox(b"url ", 0, 1, &[]); + let mut dref_p = Vec::new(); + dref_p.extend_from_slice(&1u32.to_be_bytes()); + dref_p.extend_from_slice(&url_box); + let dref = fullbox(b"dref", 0, 0, &dref_p); + let dinf = box_raw(b"dinf", &dref); + + let avcc = make_avcc(sps, pps); + let mut avc1_p = Vec::new(); + avc1_p.extend_from_slice(&[0u8; 6]); + avc1_p.extend_from_slice(&1u16.to_be_bytes()); + avc1_p.extend_from_slice(&0u16.to_be_bytes()); + avc1_p.extend_from_slice(&0u16.to_be_bytes()); + avc1_p.extend_from_slice(&[0u8; 12]); + avc1_p.extend_from_slice(&(w as u16).to_be_bytes()); + avc1_p.extend_from_slice(&(h as u16).to_be_bytes()); + avc1_p.extend_from_slice(&0x00480000u32.to_be_bytes()); + avc1_p.extend_from_slice(&0x00480000u32.to_be_bytes()); + avc1_p.extend_from_slice(&0u32.to_be_bytes()); + avc1_p.extend_from_slice(&1u16.to_be_bytes()); + avc1_p.extend_from_slice(&[0u8; 32]); + avc1_p.extend_from_slice(&0x0018u16.to_be_bytes()); + avc1_p.extend_from_slice(&(-1i16).to_be_bytes()); + avc1_p.extend_from_slice(&avcc); + let avc1 = box_raw(b"avc1", &avc1_p); + + let mut stsd_p = Vec::new(); + stsd_p.extend_from_slice(&1u32.to_be_bytes()); + stsd_p.extend_from_slice(&avc1); + let stsd = fullbox(b"stsd", 0, 0, &stsd_p); + + let stts_entries = build_stts_entries(repeat_counts); + let mut stts_p = Vec::new(); + stts_p.extend_from_slice(&(stts_entries.len() as u32).to_be_bytes()); + for (count, duration) in &stts_entries { + stts_p.extend_from_slice(&count.to_be_bytes()); + stts_p.extend_from_slice(&duration.to_be_bytes()); + } + let stts = fullbox(b"stts", 0, 0, &stts_p); + + let mut stsz_p = Vec::new(); + stsz_p.extend_from_slice(&0u32.to_be_bytes()); + stsz_p.extend_from_slice(&unique_samples.to_be_bytes()); + for &s in sizes { + stsz_p.extend_from_slice(&s.to_be_bytes()); + } + let stsz = fullbox(b"stsz", 0, 0, &stsz_p); + + let mut stsc_p = Vec::new(); + stsc_p.extend_from_slice(&1u32.to_be_bytes()); + stsc_p.extend_from_slice(&1u32.to_be_bytes()); + stsc_p.extend_from_slice(&unique_samples.to_be_bytes()); + stsc_p.extend_from_slice(&1u32.to_be_bytes()); + let stsc = fullbox(b"stsc", 0, 0, &stsc_p); + + let mut co64_p = Vec::new(); + co64_p.extend_from_slice(&1u32.to_be_bytes()); + co64_p.extend_from_slice(&offset.to_be_bytes()); + let co64 = fullbox(b"co64", 0, 0, &co64_p); + + let mut stbl_p = Vec::new(); + stbl_p.extend_from_slice(&stsd); + stbl_p.extend_from_slice(&stts); + stbl_p.extend_from_slice(&stsz); + stbl_p.extend_from_slice(&stsc); + stbl_p.extend_from_slice(&co64); + + if keys.len() < unique_samples as usize { + let mut stss_p = Vec::new(); + stss_p.extend_from_slice(&(keys.len() as u32).to_be_bytes()); + for &k in keys { + stss_p.extend_from_slice(&((k + 1) as u32).to_be_bytes()); + } + let stss = fullbox(b"stss", 0, 0, &stss_p); + stbl_p.extend_from_slice(&stss); + } + + let stbl = box_raw(b"stbl", &stbl_p); + + let mut minf_p = Vec::new(); + minf_p.extend_from_slice(&vmhd); + minf_p.extend_from_slice(&dinf); + minf_p.extend_from_slice(&stbl); + let minf = box_raw(b"minf", &minf_p); + + let mut mdia_p = Vec::new(); + mdia_p.extend_from_slice(&mdhd); + mdia_p.extend_from_slice(&hdlr); + mdia_p.extend_from_slice(&minf); + let mdia = box_raw(b"mdia", &mdia_p); + + let mut trak_p = Vec::new(); + trak_p.extend_from_slice(&tkhd); + trak_p.extend_from_slice(&mdia); + let trak = box_raw(b"trak", &trak_p); + + let mut moov_p = Vec::new(); + moov_p.extend_from_slice(&mvhd); + moov_p.extend_from_slice(&trak); + box_raw(b"moov", &moov_p) +} + +fn build_stts_entries(repeat_counts: &[u32]) -> Vec<(u32, u32)> { + if repeat_counts.is_empty() { + return Vec::new(); + } + let mut entries: Vec<(u32, u32)> = Vec::new(); + let mut current_duration = repeat_counts[0] * SAMPLE_DURATION; + let mut current_count: u32 = 1; + + for &rc in &repeat_counts[1..] { + let duration = rc * SAMPLE_DURATION; + if duration == current_duration { + current_count += 1; + } else { + entries.push((current_count, current_duration)); + current_duration = duration; + current_count = 1; + } + } + entries.push((current_count, current_duration)); + entries +} + +fn make_avcc(sps: &[u8], pps: &[u8]) -> Vec { + let profile = sps.get(1).copied().unwrap_or(66); + let compat = sps.get(2).copied().unwrap_or(0); + let level = sps.get(3).copied().unwrap_or(30); + + let mut p = Vec::new(); + p.push(1); + p.push(profile); + p.push(compat); + p.push(level); + p.push(0xFF); + p.push(0xE1); + p.extend_from_slice(&(sps.len() as u16).to_be_bytes()); + p.extend_from_slice(sps); + p.push(1); + p.extend_from_slice(&(pps.len() as u16).to_be_bytes()); + p.extend_from_slice(pps); + + box_raw(b"avcC", &p) +} diff --git a/ui/components/SettingItems/settingItems.vue b/ui/components/SettingItems/settingItems.vue index 8e6e7bdf3..ee0362355 100644 --- a/ui/components/SettingItems/settingItems.vue +++ b/ui/components/SettingItems/settingItems.vue @@ -106,7 +106,7 @@ const onSwitch = (v: boolean) => { }; const openDownloadPage = async (url: string) => { - await useTauriShellOpen(url); + await useTauriOpenerOpenUrl(url); }; const selectExecutablePath = async () => { diff --git a/ui/components/SideBar/profile.vue b/ui/components/SideBar/profile.vue index 0d94a20cf..c012d7890 100644 --- a/ui/components/SideBar/profile.vue +++ b/ui/components/SideBar/profile.vue @@ -638,7 +638,7 @@ onMounted(async () => { openModal.value = false; navigateTo({ path: localePath({ path: "/auth/browser" }), query: { auth_url: url } }); if (url && typeof url === "string") { - useTauriShellOpen(url); + useTauriOpenerOpenUrl(url); } unlisten?.(); }); diff --git a/ui/components/SideBar/sideBar.vue b/ui/components/SideBar/sideBar.vue index 9d72680d2..cb6bd9955 100644 --- a/ui/components/SideBar/sideBar.vue +++ b/ui/components/SideBar/sideBar.vue @@ -52,7 +52,7 @@ const sideBarItems = computed(() => { icon: "mingcute:device-line", to: localePath("device"), disabled: isLoading.value - }, + }, { label: t("Menu.Web"), icon: "mingcute:web-line", @@ -71,9 +71,15 @@ const sideBarItems = computed(() => { }, { label: t("Menu.Player"), - icon: "line-md:play", + icon: "lucide:clapperboard", to: localePath("videoplayer"), disabled: isLoading.value + }, + { + label: t("Menu.Transcode"), + icon: "lucide:repeat-2", + to: localePath({ path: "/transcode" }), + disabled: isLoading.value } ]; }); @@ -228,7 +234,7 @@ const debouncedSidebarSearch = useDebounceFn(emitSearch, 200); } .light .search-input input { - background-color: var(--bg-hover-light); + background-color: var(--bg-hover-light); } .menu nav[data-collapsed="true"] { diff --git a/ui/components/Transcode/metaPopover.vue b/ui/components/Transcode/metaPopover.vue new file mode 100644 index 000000000..c6859fc95 --- /dev/null +++ b/ui/components/Transcode/metaPopover.vue @@ -0,0 +1,111 @@ + + + diff --git a/ui/modules/tauri.ts b/ui/modules/tauri.ts index 5f9fc389a..3cc8f491f 100644 --- a/ui/modules/tauri.ts +++ b/ui/modules/tauri.ts @@ -7,6 +7,7 @@ import * as tauriWindow from "@tauri-apps/api/window"; import * as tauriClipboardManager from "@tauri-apps/plugin-clipboard-manager"; import * as tauriDialog from "@tauri-apps/plugin-dialog"; import * as tauriNotification from "@tauri-apps/plugin-notification"; +import * as tauriOpener from "@tauri-apps/plugin-opener"; import * as tauriOs from "@tauri-apps/plugin-os"; import * as tauriProgress from "@tauri-apps/plugin-process"; import * as tauriShell from "@tauri-apps/plugin-shell"; @@ -64,6 +65,11 @@ const tauriModules = [ prefix: "Notification", importPath: "@tauri-apps/plugin-notification" }, + { + module: tauriOpener, + prefix: "Opener", + importPath: "@tauri-apps/plugin-opener" + }, { module: tauriDialog, prefix: "Dialog", diff --git a/ui/pages/auth/browser.vue b/ui/pages/auth/browser.vue index 902d4b913..cbf10b8d6 100644 --- a/ui/pages/auth/browser.vue +++ b/ui/pages/auth/browser.vue @@ -60,7 +60,7 @@ const handleCopyUrl = () => { const handleOpenManually = () => { if (url.value && typeof url.value === "string") { - useTauriShellOpen(url.value); + useTauriOpenerOpenUrl(url.value); } }; diff --git a/ui/pages/setting/about.vue b/ui/pages/setting/about.vue index 7056ab2ef..7bd52bbc9 100644 --- a/ui/pages/setting/about.vue +++ b/ui/pages/setting/about.vue @@ -40,7 +40,7 @@ onMounted(async () => { const openLink = async (url: string) => { try { - await useTauriShellOpen(url); + await useTauriOpenerOpenUrl(url); } catch (e) { console.error("open link failed", e); } diff --git a/ui/pages/transcode.vue b/ui/pages/transcode.vue new file mode 100644 index 000000000..579fd0a08 --- /dev/null +++ b/ui/pages/transcode.vue @@ -0,0 +1,534 @@ + + + diff --git a/ui/store/modules/transcode.ts b/ui/store/modules/transcode.ts new file mode 100644 index 000000000..b290d5d51 --- /dev/null +++ b/ui/store/modules/transcode.ts @@ -0,0 +1,521 @@ +import type { UnlistenFn } from "@tauri-apps/api/event"; + +export type TranscodeTaskStatus = "pending" | "queued" | "processing" | "success" | "error"; +export type FilenameStyle = "original" | "friendly" | "friendly_uuid"; +export type OutputResolution = "original" | "p1080" | "p720" | "p360"; +export type TranscodePower = "auto" | "full" | "fast" | "medium" | "low"; + +export interface ReplayMetadata { + id: string; + user: string; + asset: string; + account: string; + login_from: string; + remote_addr: string; + protocol: string; + date_start: string; + date_end: string; + org_id: string; + user_id: string; + asset_id: string; + account_id: string; + recording_type: string; + files: unknown[]; +} + +export interface TranscodeProgressPayload { + file: string; + index: number; + total: number; + progress: number; + message: string; + success?: boolean | null; + output?: string | null; + metadata?: ReplayMetadata | null; + duration?: number | null; +} + +export interface TranscodeResult { + id: string; + input: string; + output: string; + success: boolean; + error?: string; + metadata?: ReplayMetadata | null; +} + +export interface TranscodeTaskItem { + index: number; + path: string; + displayName: string; + progress: number; + message: string; + status: TranscodeTaskStatus; + output: string; + error: string; + metadata: ReplayMetadata | null; + duration?: number | null; +} + +let listenerRegistered = false; + +export const useTranscodeStore = defineStore( + "transcode", + () => { + const { t } = useI18n(); + const toast = useToast(); + + const archivePaths = ref([]); + const outputDir = ref(""); + const filenameStyle = ref("original"); + const outputResolution = ref("original"); + const transcodePower = ref("fast"); + const isTranscoding = ref(false); + const taskItems = ref([]); + const pendingPaths = ref([]); + const currentBatchOffset = ref(0); + const unlistenProgress = ref(null); + + const queuedCount = computed(() => taskItems.value.filter((item) => item.status === "queued").length); + const totalProgress = computed(() => { + const active = taskItems.value.filter((item) => item.status !== "queued"); + if (!active.length) return 0; + const sum = active.reduce((acc, item) => acc + (item.progress || 0), 0); + return Math.round(sum / active.length); + }); + const successCount = computed(() => taskItems.value.filter((item) => item.status === "success").length); + const failedCount = computed(() => taskItems.value.filter((item) => item.status === "error").length); + const processingCount = computed(() => taskItems.value.filter((item) => item.status === "processing").length); + const completedCount = computed(() => successCount.value + failedCount.value); + const canStart = computed(() => taskItems.value.some((item) => item.status === "pending") && !isTranscoding.value); + + const getDisplayName = (path: string) => path.split(/[\\/]/).pop() || path; + + const buildTaskItem = (path: string, index: number, previous?: TranscodeTaskItem): TranscodeTaskItem => ({ + index, + path, + displayName: getDisplayName(path), + progress: previous?.progress ?? 0, + message: previous?.message ?? t("Transcode.Waiting"), + status: previous?.status ?? "pending", + output: previous?.output ?? "", + error: previous?.error ?? "", + metadata: previous?.metadata ?? null, + duration: previous?.duration ?? null + }); + + const buildQueuedTask = (path: string, index: number): TranscodeTaskItem => ({ + index, + path, + displayName: getDisplayName(path), + progress: 0, + message: t("Transcode.StatusQueued"), + status: "queued", + output: "", + error: "", + metadata: null + }); + + const rebuildTaskItems = (paths: string[]) => { + const previous = new Map(taskItems.value.map((item) => [item.path, item])); + taskItems.value = paths.map((path, index) => buildTaskItem(path, index, previous.get(path))); + }; + + const patchTaskItem = (index: number, patch: Partial) => { + const current = taskItems.value[index]; + if (!current) return; + taskItems.value[index] = { ...current, ...patch }; + }; + + const markTaskCompleted = ( + index: number, + success: boolean, + output: string, + message: string, + metadata: ReplayMetadata | null, + duration?: number | null + ) => { + const current = taskItems.value[index]; + if (!current) return; + if (current.status === "success" || current.status === "error") return; + taskItems.value[index] = { + ...current, + progress: 100, + status: success ? "success" : "error", + output: output || current.output, + message, + error: success ? "" : message, + metadata: metadata ?? current.metadata, + duration: duration ?? null + }; + }; + + const setArchives = (paths: string[]) => { + if (isTranscoding.value) return; + archivePaths.value = paths; + rebuildTaskItems(paths); + }; + + const appendArchives = (newPaths: string[]) => { + if (isTranscoding.value) { + const current = new Set([...archivePaths.value, ...pendingPaths.value]); + const next = newPaths.filter((p) => !current.has(p)); + if (!next.length) return; + pendingPaths.value = [...pendingPaths.value, ...next]; + const base = taskItems.value.length; + const queuedItems = next.map((path, i) => buildQueuedTask(path, base + i)); + taskItems.value = [...taskItems.value, ...queuedItems]; + return; + } + const existingPaths = taskItems.value.map((item) => item.path); + const next = Array.from(new Set([...existingPaths, ...newPaths])); + archivePaths.value = next; + rebuildTaskItems(next); + }; + + const removeArchive = (path: string) => { + const queuedIndex = pendingPaths.value.indexOf(path); + if (queuedIndex !== -1) { + pendingPaths.value = pendingPaths.value.filter((p) => p !== path); + taskItems.value = taskItems.value.filter((item) => item.path !== path); + return; + } + const task = taskItems.value.find((item) => item.path === path); + if (task?.status === "processing") return; + archivePaths.value = archivePaths.value.filter((p) => p !== path); + rebuildTaskItems(archivePaths.value); + }; + + const clearArchives = () => { + if (isTranscoding.value) return; + archivePaths.value = []; + taskItems.value = []; + pendingPaths.value = []; + }; + + const flushQueue = () => { + if (!pendingPaths.value.length) return false; + const paths = [...pendingPaths.value]; + const queuedSet = new Set(paths); + const completed = taskItems.value.filter( + (item) => !queuedSet.has(item.path) && (item.status === "success" || item.status === "error") + ); + pendingPaths.value = []; + archivePaths.value = paths; + taskItems.value = [...completed, ...paths.map((path, index) => buildTaskItem(path, completed.length + index))]; + return true; + }; + + const tryAdvanceQueue = (): boolean => { + const stats = taskItems.value.reduce( + (acc, item) => { + acc[item.status] = (acc[item.status] || 0) + 1; + return acc; + }, + {} as Record + ); + + const inFlight = (stats.pending ?? 0) + (stats.processing ?? 0); + if (inFlight > 0) { + console.warn( + "[transcode] batch still has in-flight items; chain will NOT advance. pending=%d processing=%d", + stats.pending ?? 0, + stats.processing ?? 0 + ); + return false; + } + + const queuedItems = taskItems.value.filter((item) => item.status === "queued"); + if (!queuedItems.length) { + return false; + } + + const completed = taskItems.value.filter((item) => item.status === "success" || item.status === "error"); + archivePaths.value = queuedItems.map((item) => item.path); + const promoted = queuedItems.map((item, index) => buildTaskItem(item.path, completed.length + index)); + taskItems.value = [...completed, ...promoted]; + currentBatchOffset.value = completed.length; + return true; + }; + + const setOutputDir = (dir: string) => { + outputDir.value = dir; + }; + + const setFilenameStyle = (style: FilenameStyle) => { + filenameStyle.value = style; + }; + + const setOutputResolution = (resolution: OutputResolution) => { + outputResolution.value = resolution; + }; + + const setTranscodePower = (power: TranscodePower) => { + transcodePower.value = power; + }; + + const findTaskIndexByFile = (file: string): number => { + if (!file) return -1; + return taskItems.value.findIndex( + (item) => + item.displayName === file || + item.path === file || + item.path.endsWith(`/${file}`) || + item.path.endsWith(`\\${file}`) + ); + }; + + const handleProgressEvent = (payload: TranscodeProgressPayload) => { + let targetIndex = -1; + + if (typeof payload.index === "number") { + const global = currentBatchOffset.value + payload.index; + const candidate = taskItems.value[global]; + if (candidate && candidate.status !== "success" && candidate.status !== "error") { + targetIndex = global; + } + } + + if (targetIndex === -1) { + targetIndex = taskItems.value.findIndex((item) => { + if (item.status === "success" || item.status === "error") return false; + const base = item.path.split(/[\\/]/).pop() || ""; + return ( + item.displayName === payload.file || + item.path === payload.file || + item.path.endsWith(`/${payload.file}`) || + item.path.endsWith(`\\${payload.file}`) || + base === payload.file || + base === `${payload.file}.tar` + ); + }); + } + + if (targetIndex === -1) return; + + const successFlag = payload.success; + const outputValue = payload.output || ""; + const incomingMetadata = payload.metadata ?? null; + const incomingDuration = payload.duration ?? null; + + if (successFlag === true || successFlag === false) { + const message = successFlag ? t("Transcode.Completed") : payload.message || t("Transcode.StatusFailed"); + markTaskCompleted(targetIndex, successFlag, outputValue, message, incomingMetadata, incomingDuration); + return; + } + + patchTaskItem(targetIndex, { + progress: Math.max(0, Math.min(100, payload.progress)), + message: payload.message, + status: "processing", + ...(incomingMetadata ? { metadata: incomingMetadata } : {}) + }); + }; + + const findTaskIndexForResult = (result: TranscodeResult): number => { + if (result.input) { + const byInput = taskItems.value.findIndex( + (item) => item.path === result.input && item.status !== "success" && item.status !== "error" + ); + if (byInput !== -1) return byInput; + } + + const byPath = taskItems.value.findIndex( + (item) => + item.status !== "success" && + item.status !== "error" && + (item.path === result.id || + item.displayName === result.id || + item.path.endsWith(`/${result.id}`) || + item.path.endsWith(`\\${result.id}`)) + ); + if (byPath !== -1) return byPath; + + if (result.metadata?.id) { + const sessionId = result.metadata.id; + return taskItems.value.findIndex((item) => { + if (item.status === "success" || item.status === "error") return false; + const base = item.path.split(/[\\/]/).pop() || ""; + return item.path === sessionId || base === sessionId || base === `${sessionId}.tar`; + }); + } + return -1; + }; + + const applyBatchResults = (results: TranscodeResult[]) => { + results.forEach((result) => { + const targetIndex = findTaskIndexForResult(result); + if (targetIndex === -1) { + console.warn("[transcode] applyBatchResults: no task matched id=%s input=%s", result.id, result.input); + return; + } + markTaskCompleted( + targetIndex, + result.success, + result.output, + result.success ? t("Transcode.Completed") : result.error || t("Transcode.StatusFailed"), + result.metadata ?? null + ); + }); + }; + + const markAllPendingAsError = (message: string) => { + taskItems.value.forEach((item, index) => { + if (item.status === "success" || item.status === "error") return; + taskItems.value[index] = { + ...item, + status: "error", + error: message, + message + }; + }); + pendingPaths.value = []; + }; + + watch( + () => completedCount.value, + (completed) => { + const total = taskItems.value.length; + if (total === 0) return; + if (completed < total) return; + if (!isTranscoding.value) return; + if (pendingPaths.value.length > 0) return; + const failed = failedCount.value; + const succeeded = successCount.value; + toast.add({ + title: failed > 0 ? t("Transcode.CompletedWithErrors") : t("Transcode.CompletedAll"), + description: + failed > 0 + ? t("Transcode.CompletedSummaryWithErrors", { + success: succeeded, + failed + }) + : t("Transcode.CompletedSummary", { count: succeeded }), + color: failed > 0 ? "error" : "primary", + icon: failed > 0 ? "line-md:close-circle" : "line-md:check-all", + progress: failed > 0, + duration: 4000 + }); + } + ); + + const registerProgressListener = async () => { + if (listenerRegistered) return; + listenerRegistered = true; + unlistenProgress.value = await useTauriEventListen("transcode-progress", (event) => { + handleProgressEvent(event.payload as TranscodeProgressPayload); + }); + }; + + void registerProgressListener(); + + const startTranscode = async (options?: { chained?: boolean }) => { + if (!options?.chained) { + const pendingItems = taskItems.value.filter((item) => item.status === "pending"); + if (!pendingItems.length) { + console.warn("[transcode] startTranscode: no pending tasks, skipping"); + return; + } + } + + if (!outputDir.value) { + toast.add({ + title: t("Transcode.SelectOutputDirFirst"), + color: "error", + icon: "line-md:close-circle", + progress: true, + duration: 3000 + }); + return; + } + + isTranscoding.value = true; + if (!options?.chained) { + const pendingItems = taskItems.value.filter((item) => item.status === "pending"); + archivePaths.value = pendingItems.map((item) => item.path); + // Offset = number of completed tasks before the first pending task + const firstPendingIndex = taskItems.value.findIndex((item) => item.status === "pending"); + currentBatchOffset.value = firstPendingIndex >= 0 ? firstPendingIndex : 0; + } + + if (!archivePaths.value.length) { + console.warn("[transcode] startTranscode: archivePaths empty after filter, skipping"); + isTranscoding.value = false; + return; + } + + try { + try { + const results = (await useTauriCoreInvoke("transcode_replays", { + tarPaths: archivePaths.value, + outputDir: outputDir.value, + filenameStyle: filenameStyle.value, + outputResolution: outputResolution.value, + transcodePower: transcodePower.value + })) as TranscodeResult[]; + + applyBatchResults(results); + } catch (error) { + const message = + error instanceof Error ? error.message : typeof error === "string" ? error : t("Transcode.UnknownError"); + + markAllPendingAsError(message); + + toast.add({ + title: t("Transcode.StartFailed"), + description: message, + color: "error", + icon: "line-md:close-circle", + progress: true, + duration: 4000 + }); + } + + const chained = tryAdvanceQueue(); + if (!chained) { + return; + } + + await startTranscode({ chained: true }); + } finally { + isTranscoding.value = false; + } + }; + + return { + archivePaths, + outputDir, + filenameStyle, + outputResolution, + transcodePower, + isTranscoding, + taskItems, + pendingPaths, + totalProgress, + successCount, + failedCount, + processingCount, + queuedCount, + completedCount, + canStart, + setArchives, + appendArchives, + removeArchive, + clearArchives, + setOutputDir, + setFilenameStyle, + setOutputResolution, + setTranscodePower, + applyBatchResults, + markAllPendingAsError, + startTranscode + }; + }, + { + persist: { + key: "transcode", + storage: localStorage, + pick: ["outputDir", "filenameStyle", "outputResolution", "transcodePower"] + } + } +);