From d410e4f4ae597eba987db6c7758f301c8205c8af Mon Sep 17 00:00:00 2001 From: "923048992@qq.com" <923048992@qq.com> Date: Sat, 28 Sep 2024 23:40:42 +0800 Subject: [PATCH] feat: video aggregation --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/app/basic.rs | 90 +++++++++++++++++-- src-tauri/src/error/mod.rs | 4 + src-tauri/src/main.rs | 17 +++- src-tauri/src/model/mod.rs | 12 +++ src-tauri/tauri.conf.json | 2 +- src/components/change_log_modal.tsx | 8 ++ src/components/path_selector.tsx | 14 ++- src/components/video_aggregator.tsx | 131 ++++++++++++++++++++++++++++ src/lib/model.ts | 11 +++ src/lib/utils.tsx | 4 + src/page/video.tsx | 5 +- 13 files changed, 288 insertions(+), 14 deletions(-) create mode 100644 src/components/video_aggregator.tsx diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 170cde2..ca905b6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3339,7 +3339,7 @@ checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] name = "sjtu_canvas_helper" -version = "1.3.17" +version = "1.3.18" dependencies = [ "bardecoder", "base64 0.22.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e11b23a..f6ba1e0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sjtu_canvas_helper" -version = "1.3.17" +version = "1.3.18" description = "SJTU Canvas Helper" authors = ["Okabe"] edition = "2021" diff --git a/src-tauri/src/app/basic.rs b/src-tauri/src/app/basic.rs index 34250c0..736027e 100644 --- a/src-tauri/src/app/basic.rs +++ b/src-tauri/src/app/basic.rs @@ -6,10 +6,12 @@ use std::{ fs, io::Write, path::{Path, PathBuf}, + process::{Command, Stdio}, sync::Arc, time::Duration, }; -use tauri::api::path::config_dir; +use tauri::{api::path::config_dir, Runtime, Window}; +use tokio::{io::AsyncReadExt, process::Command as TokioCommand}; use tokio::{sync::RwLock, task::JoinSet}; use uuid::Uuid; use warp::{hyper::Response, Filter}; @@ -183,10 +185,7 @@ impl App { tracing::info!("Read current account: {:?}", account_info); let config_path = App::get_config_path(&account_info.current_account); tracing::info!("Read config path: {}", config_path); - let config = match App::read_config_from_file(&config_path) { - Ok(config) => config, - Err(_) => Default::default(), - }; + let config = App::read_config_from_file(&config_path).unwrap_or_default(); let base_url = Self::get_base_url(&config.account_type); let client = Client::with_base_url(base_url); @@ -967,4 +966,85 @@ impl App { fs::remove_file(&pdf_path)?; Ok(pdf_content) } + + pub fn is_ffmpeg_installed() -> bool { + let output = Command::new("ffmpeg").arg("-version").output(); + match output { + Ok(output) => output.status.success(), + Err(_) => false, + } + } + + // return execute command, whether succeeded and exit code + pub async fn run_video_aggregate( + window: Window, + params: &VideoAggregateParams, + ) -> Result { + let scale_percentage = params.sub_video_size_percentage as f64 / 100.0; + let scale_width = format!("iw*{}", scale_percentage); + let scale_height = format!("ih*{}", scale_percentage); + + let alpha_value = params.sub_video_alpha as f64 / 100.0; + let output_path = format!("{}/{}", params.output_dir, params.output_name); + + let mut command = TokioCommand::new("ffmpeg") + .args([ + "-i", + ¶ms.main_video_path, + "-i", + ¶ms.sub_video_path, + "-filter_complex", + &format!( + "[1:v]scale={}:{}[overlay];[0:v][overlay]overlay=W-w:H-h:format=auto:alpha={}", + scale_width, scale_height, alpha_value + ), + "-c:a", + "copy", + &output_path, + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + // catch stdout + let mut stdout = command.stdout.take().ok_or(AppError::OpenStdoutError)?; + let mut stderr = command.stderr.take().ok_or(AppError::OpenStderrError)?; + + let command_str = format!( + "ffmpeg -i \"{}\" -i \"{}\" -filter_complex \"[1:v]scale={}:{}[overlay];[0:v][overlay]overlay=W-w:H-h:format=auto:alpha={}\" -c:a copy \"{}\"", + params.main_video_path, + params.sub_video_path, + scale_width, + scale_height, + alpha_value, + output_path + ); + let _ = window.emit("ffmpeg://output", command_str + "\n"); + let window_cloned = window.clone(); + + tokio::spawn(async move { + let mut buffer = [0; 128]; + while let Ok(bytes_read) = stdout.read(&mut buffer).await { + if bytes_read == 0 { + break; // EOF + } + let output = String::from_utf8_lossy(&buffer[..bytes_read]); + let _ = window.emit("ffmpeg://output", output.to_string()); + } + }); + + tokio::spawn(async move { + let mut buffer = [0; 128]; + while let Ok(bytes_read) = stderr.read(&mut buffer).await { + if bytes_read == 0 { + break; // EOF + } + let output = String::from_utf8_lossy(&buffer[..bytes_read]); + let _ = window_cloned.emit("ffmpeg://output", output.to_string()); + } + }); + + let status = command.wait().await?; + Ok(status.code().unwrap_or_default()) + } } diff --git a/src-tauri/src/error/mod.rs b/src-tauri/src/error/mod.rs index e63e102..c45b412 100644 --- a/src-tauri/src/error/mod.rs +++ b/src-tauri/src/error/mod.rs @@ -40,6 +40,10 @@ pub enum AppError { NotAllowedToCreateDefaultAccount, #[error("Mutex error")] MutexError, + #[error("Failed to open stdout")] + OpenStdoutError, + #[error("Failed to open stderr")] + OpenStderrError, } impl serde::Serialize for AppError { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 0133fde..937c916 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,7 +5,7 @@ use error::Result; use model::{ Account, AccountInfo, AppConfig, Assignment, CalendarEvent, CanvasVideo, Colors, Course, DiscussionTopic, File, Folder, FullDiscussion, QRCodeScanResult, RelationshipTopo, Subject, - Submission, User, UserSubmissions, VideoCourse, VideoInfo, VideoPlayInfo, + Submission, User, UserSubmissions, VideoAggregateParams, VideoCourse, VideoInfo, VideoPlayInfo, }; use tauri::{Runtime, Window}; @@ -24,6 +24,19 @@ lazy_static! { static ref APP: App = App::new(); } +#[tauri::command] +fn is_ffmpeg_installed() -> bool { + App::is_ffmpeg_installed() +} + +#[tauri::command] +async fn run_video_aggregate( + window: Window, + params: VideoAggregateParams, +) -> Result { + App::run_video_aggregate(window, ¶ms).await +} + #[tauri::command] async fn collect_relationship() -> Result { APP.collect_relationship().await @@ -528,6 +541,8 @@ async fn main() -> Result<()> { APP.init().await?; tauri::Builder::default() .invoke_handler(tauri::generate_handler![ + is_ffmpeg_installed, + run_video_aggregate, collect_relationship, switch_account, create_account, diff --git a/src-tauri/src/model/mod.rs b/src-tauri/src/model/mod.rs index cc97995..cc050f9 100644 --- a/src-tauri/src/model/mod.rs +++ b/src-tauri/src/model/mod.rs @@ -971,3 +971,15 @@ pub struct RelationshipTopo { pub nodes: Vec, pub edges: Vec, } +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VideoAggregateParams { + pub main_video_path: String, + pub sub_video_path: String, + pub output_dir: String, + pub output_name: String, + // 0% ~ 100%, 100% by default + pub sub_video_alpha: u8, + // 0% ~ 50%, 25% by default + pub sub_video_size_percentage: u8, +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c153251..b131f1e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -7,7 +7,7 @@ }, "package": { "productName": "SJTU Canvas Helper", - "version": "1.3.17" + "version": "1.3.18" }, "tauri": { "allowlist": { diff --git a/src/components/change_log_modal.tsx b/src/components/change_log_modal.tsx index bb4e4fc..abcdf2f 100644 --- a/src/components/change_log_modal.tsx +++ b/src/components/change_log_modal.tsx @@ -11,6 +11,14 @@ export function ChangeLogModal({ open, onCancel, onOk }: { overflow: "scroll", }}> + v1.3.18 2024/9/28 + +
    +
  • 新增更详细的操作提示,且提示现在可以关闭(点击不再显示)
  • +
  • 支持合并视频(需要预先安装好 ffmpeg)
  • +
  • 支持以 JSON 格式查看配置文件(“设置-显示配置文件”)
  • +
+
v1.3.17 2024/9/27
    diff --git a/src/components/path_selector.tsx b/src/components/path_selector.tsx index 9ff6ed9..0bbc07d 100644 --- a/src/components/path_selector.tsx +++ b/src/components/path_selector.tsx @@ -5,10 +5,15 @@ import { getConfig } from "../lib/store"; interface PathSelectorProps { value?: string, - onChange?: (value: string) => void + onChange?: (value: string) => void, + directory?: boolean, + extensions?: string[], + placeholder?: string } -export const PathSelector = React.forwardRef(({ value, onChange }, ref) => { +const DEFAULT_PLACE_HOLDER = "请输入文件下载保存目录"; + +export const PathSelector = React.forwardRef(({ value, onChange, extensions, directory = true, placeholder = DEFAULT_PLACE_HOLDER }, ref) => { const [saveDir, setSaveDir] = useState(""); useEffect(() => { @@ -19,8 +24,9 @@ export const PathSelector = React.forwardRef(({ val const config = await getConfig(true); const path = config.save_path.length > 0 ? config.save_path : undefined; const saveDir = await open({ - directory: true, + directory, defaultPath: path, + filters: extensions ? [{ name: "", extensions }] : undefined }); if (!saveDir) { return; @@ -34,7 +40,7 @@ export const PathSelector = React.forwardRef(({ val } } return - setSaveDir(e.target.value)} /> + setSaveDir(e.target.value)} /> }); \ No newline at end of file diff --git a/src/components/video_aggregator.tsx b/src/components/video_aggregator.tsx new file mode 100644 index 0000000..16ec33e --- /dev/null +++ b/src/components/video_aggregator.tsx @@ -0,0 +1,131 @@ +import { invoke } from "@tauri-apps/api"; +import { Badge, Button, Divider, Form, Input, InputNumber, Space } from "antd"; +import { useForm } from "antd/es/form/Form"; +import { useEffect, useRef, useState } from "react"; +import { PathSelector } from "./path_selector"; +import useMessage from "antd/es/message/useMessage"; +import { VideoAggregateParams } from "../lib/model"; +import { appWindow } from "@tauri-apps/api/window"; + +type FfmpegState = "unknown" | "installed" | "uninstalled"; + +const ffmpegBadgeMap = { + "unknown": , + "installed": , + "uninstalled": +} + +const DEFAULT_SUB_VIDEO_SIZE_PERCENTAGE = 25; +const DEFAULT_SUB_VIDEO_ALPHA = 100; + +export default function VideoAggregator() { + const [ffmpegState, setFfmpegState] = useState("unknown"); + const [output, setOutput] = useState(""); + const [running, setRunning] = useState(false); + const [form] = useForm(); + const [messageApi, contextHolder] = useMessage(); + const preRef = useRef(null); + + const handleCheckFfmpegState = async () => { + let installed = await invoke("is_ffmpeg_installed"); + if (installed) { + setFfmpegState("installed"); + } else { + setFfmpegState("uninstalled"); + } + return installed; + } + + const preCheckFfmpegState = async () => { + let ok = await handleCheckFfmpegState(); + if (!ok) { + messageApi.error("ffmpeg 未安装或未设置环境变量"); + } + return ok; + } + + useEffect(() => { + let unlistenOutput = appWindow.listen("ffmpeg://output", ({ payload }) => { + setOutput(output => output + payload); + if (preRef.current) { + preRef.current.scrollTop = preRef.current.scrollHeight; + } + }); + return () => { + unlistenOutput.then(f => f()); + } + }, []); + + const handleSubmit = async (params: VideoAggregateParams) => { + params.outputName += ".mp4"; + console.log("params: ", params); + if (!preCheckFfmpegState()) { + return; + } + + setRunning(true); + setOutput(""); + try { + let exitCode = await invoke("run_video_aggregate", { params }); + if (exitCode === 0) { + messageApi.success("合并成功!🎉"); + } else { + messageApi.error("合并失败!🥹, exit code: " + exitCode); + } + } catch (e) { + messageApi.error("合并失败!🥹" + e); + } + setRunning(false); + } + + return + {contextHolder} + +
    ffmpeg 是否正确安装且设置环境变量:{ffmpegBadgeMap[ffmpegState]}
    + +
    +
    + + + + + + + + + + + + + + + + + + + + + +
    + 执行输出 +
    +            
    +                {output}
    +            
    +        
    +
    +} \ No newline at end of file diff --git a/src/lib/model.ts b/src/lib/model.ts index 2c2364f..cf62cea 100644 --- a/src/lib/model.ts +++ b/src/lib/model.ts @@ -512,3 +512,14 @@ export interface DraggableItem { content: string; data: any; } + +export interface VideoAggregateParams { + mainVideoPath: string; + subVideoPath: string; + outputDir: string; + outputName: string; + // 0% ~ 100%, 100% by default + subVideoAlpha: number; + // 0% ~ 50%, 25% by default + subVideoSizePercentage: number; +} diff --git a/src/lib/utils.tsx b/src/lib/utils.tsx index e361c43..7182da2 100644 --- a/src/lib/utils.tsx +++ b/src/lib/utils.tsx @@ -228,6 +228,10 @@ export function scrollToTop() { window.scrollTo(0, 0); } +export function scrollToEnd() { + window.scrollTo(0, document.body.scrollHeight); +} + export async function checkForUpdates(messageApi: MessageInstance) { try { const messageKey = "checking"; diff --git a/src/page/video.tsx b/src/page/video.tsx index 76ecfc1..e3c8120 100644 --- a/src/page/video.tsx +++ b/src/page/video.tsx @@ -5,7 +5,7 @@ import { SwapOutlined } from '@ant-design/icons'; import { VideoInfo, VideoPlayInfo, VideoDownloadTask, CanvasVideo } from "../lib/model"; import useMessage from "antd/es/message/useMessage"; import { getConfig, saveConfig } from "../lib/store"; -import { Button, Checkbox, Select, Space, Table } from "antd"; +import { Button, Checkbox, Divider, Select, Space, Table } from "antd"; import VideoDownloadTable from "../components/video_download_table"; import videoStyles from "../css/video_player.module.css"; import { LoginAlert } from "../components/login_alert"; @@ -13,6 +13,7 @@ import { useCourses, useQRCode } from "../lib/hooks"; import CourseSelect from "../components/course_select"; import ClosableAlert from "../components/closable_alert"; import { VIDEO_PAGE_HINT_ALERT_KEY } from "../lib/constants"; +import VideoAggregator from "../components/video_aggregator"; export default function VideoPage() { const [downloadTasks, setDownloadTasks] = useState([]); @@ -367,6 +368,8 @@ export default function VideoPage() { } + 视频合并 + } \ No newline at end of file