Skip to content

Commit

Permalink
feat: video aggregation
Browse files Browse the repository at this point in the history
  • Loading branch information
Okabe-Rintarou-0 committed Sep 28, 2024
1 parent 6b5c89b commit d410e4f
Show file tree
Hide file tree
Showing 13 changed files with 288 additions and 14 deletions.
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
90 changes: 85 additions & 5 deletions src-tauri/src/app/basic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<R: Runtime>(
window: Window<R>,
params: &VideoAggregateParams,
) -> Result<i32> {
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",
&params.main_video_path,
"-i",
&params.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())
}
}
4 changes: 4 additions & 0 deletions src-tauri/src/error/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 16 additions & 1 deletion src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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<R: Runtime>(
window: Window<R>,
params: VideoAggregateParams,
) -> Result<i32> {
App::run_video_aggregate(window, &params).await
}

#[tauri::command]
async fn collect_relationship() -> Result<RelationshipTopo> {
APP.collect_relationship().await
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src-tauri/src/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -971,3 +971,15 @@ pub struct RelationshipTopo {
pub nodes: Vec<RelationshipNode>,
pub edges: Vec<RelationshipEdge>,
}
#[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,
}
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
},
"package": {
"productName": "SJTU Canvas Helper",
"version": "1.3.17"
"version": "1.3.18"
},
"tauri": {
"allowlist": {
Expand Down
8 changes: 8 additions & 0 deletions src/components/change_log_modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ export function ChangeLogModal({ open, onCancel, onOk }: {
overflow: "scroll",
}}>
<Typography>
<Title level={4}>v1.3.18 2024/9/28</Title>
<Paragraph>
<ul>
<li>新增更详细的操作提示,且提示现在可以关闭(点击不再显示)</li>
<li>支持合并视频(需要预先安装好 ffmpeg)</li>
<li>支持以 JSON 格式查看配置文件(“设置-显示配置文件”)</li>
</ul>
</Paragraph>
<Title level={4}>v1.3.17 2024/9/27</Title>
<Paragraph>
<ul>
Expand Down
14 changes: 10 additions & 4 deletions src/components/path_selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<InputRef, PathSelectorProps>(({ value, onChange }, ref) => {
const DEFAULT_PLACE_HOLDER = "请输入文件下载保存目录";

export const PathSelector = React.forwardRef<InputRef, PathSelectorProps>(({ value, onChange, extensions, directory = true, placeholder = DEFAULT_PLACE_HOLDER }, ref) => {
const [saveDir, setSaveDir] = useState<string>("");

useEffect(() => {
Expand All @@ -19,8 +24,9 @@ export const PathSelector = React.forwardRef<InputRef, PathSelectorProps>(({ 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;
Expand All @@ -34,7 +40,7 @@ export const PathSelector = React.forwardRef<InputRef, PathSelectorProps>(({ val
}
}
return <Space.Compact style={{ width: "100%" }} >
<Input ref={ref} width={"100%"} placeholder="请输入文件下载保存目录" value={saveDir} onChange={(e) => setSaveDir(e.target.value)} />
<Input ref={ref} width={"100%"} placeholder={placeholder} value={saveDir} onChange={(e) => setSaveDir(e.target.value)} />
<Button onClick={handleSelectDirectory}>选择</Button>
</Space.Compact>
});
131 changes: 131 additions & 0 deletions src/components/video_aggregator.tsx
Original file line number Diff line number Diff line change
@@ -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": <Badge color="blue" text="未知" />,
"installed": <Badge color="green" text="已安装" />,
"uninstalled": <Badge color="red" text="未安装" />
}

const DEFAULT_SUB_VIDEO_SIZE_PERCENTAGE = 25;
const DEFAULT_SUB_VIDEO_ALPHA = 100;

export default function VideoAggregator() {
const [ffmpegState, setFfmpegState] = useState<FfmpegState>("unknown");
const [output, setOutput] = useState<string>("");
const [running, setRunning] = useState<boolean>(false);
const [form] = useForm<VideoAggregateParams>();
const [messageApi, contextHolder] = useMessage();
const preRef = useRef<HTMLPreElement>(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<String>("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 <Space size="large" direction="vertical" style={{ width: "100%" }}>
{contextHolder}
<Space>
<div>ffmpeg 是否正确安装且设置环境变量:{ffmpegBadgeMap[ffmpegState]}</div>
<Button onClick={handleCheckFfmpegState}>检查</Button>
</Space>
<Form form={form} onFinish={handleSubmit} preserve={false}>
<Form.Item label="主视频(黑板录屏)路径" name="mainVideoPath" required rules={[{
required: true,
message: "请输入主视频(黑板录屏)路径!"
}]}>
<PathSelector directory={false} extensions={["mp4"]} placeholder="请选择正确文件路径" />
</Form.Item>
<Form.Item label="副视频(PPT 录屏)路径" name="subVideoPath" required rules={[{
required: true,
message: "请输入副视频(PPT 录屏)路径!"
}]}>
<PathSelector directory={false} extensions={["mp4"]} placeholder="请选择正确文件路径" />
</Form.Item>
<Form.Item label="输出文件夹" name="outputDir" required rules={[{
required: true,
message: "请输入输出文件夹"
}]}>
<PathSelector placeholder="请选择文件夹" />
</Form.Item>
<Form.Item label="输出视频名" name="outputName" required rules={[{
required: true,
message: "请输入输出视频名!"
}]}>
<Input suffix=".mp4" />
</Form.Item>
<Form.Item label="副视频透明度" name="subVideoAlpha" initialValue={DEFAULT_SUB_VIDEO_ALPHA}>
<InputNumber min={0} max={100} suffix="%" />
</Form.Item>
<Form.Item label="副视频大小" name="subVideoSizePercentage" initialValue={DEFAULT_SUB_VIDEO_SIZE_PERCENTAGE}>
<InputNumber min={0} max={100} suffix="%" />
</Form.Item>
<Form.Item>
<Button disabled={running} type="primary" htmlType="submit">
开始合并
</Button>
</Form.Item>
</Form>
<Divider orientation="left">执行输出</Divider>
<pre style={{ width: "100%", maxHeight: "500px", overflow: "scroll" }} ref={preRef} >
<code style={{ width: "100%", whiteSpace: "pre-wrap" }}>
{output}
</code>
</pre>
</Space>
}
Loading

0 comments on commit d410e4f

Please sign in to comment.