Skip to content

Commit c620993

Browse files
committed
ffmpeg
1 parent 4a31c8f commit c620993

6 files changed

Lines changed: 157 additions & 6 deletions

File tree

backend/src/ffmpeg.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub mod hw_decoder;
22
pub mod sw_decoder;
33
pub(crate) mod command;
4+
pub(crate) mod bin;
45

56
use serde::Deserialize;
67
use std::process::Command;
@@ -25,7 +26,8 @@ struct FfprobeOutput {
2526
}
2627

2728
fn run_ffprobe(path: &str, select_streams: Option<&str>, entries: &str) -> Result<FfprobeOutput, String> {
28-
let mut cmd = Command::new("ffprobe");
29+
let ffprobe = bin::ffprobe_path()?;
30+
let mut cmd = Command::new(ffprobe);
2931
cmd.arg("-v")
3032
.arg("error")
3133
.arg("-print_format")

backend/src/ffmpeg/bin.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
use std::io;
2+
use std::process::Command;
3+
use std::sync::{Mutex, OnceLock};
4+
5+
static FFMPEG_PATH: OnceLock<Mutex<Option<String>>> = OnceLock::new();
6+
static FFPROBE_PATH: OnceLock<Mutex<Option<String>>> = OnceLock::new();
7+
8+
fn read_env_path(env_var: &str) -> Option<String> {
9+
let value = std::env::var(env_var).ok()?;
10+
let trimmed = value.trim();
11+
if trimmed.is_empty() {
12+
None
13+
} else {
14+
Some(trimmed.to_string())
15+
}
16+
}
17+
18+
fn resolve_with_cache(
19+
cache: &OnceLock<Mutex<Option<String>>>,
20+
name: &str,
21+
env_var: &str,
22+
) -> Result<String, String> {
23+
let lock = cache.get_or_init(|| Mutex::new(None));
24+
let mut cached = lock.lock().unwrap();
25+
if let Some(path) = cached.as_ref() {
26+
return Ok(path.clone());
27+
}
28+
29+
match Command::new(name).arg("-version").output() {
30+
Ok(_) => {
31+
let path = name.to_string();
32+
*cached = Some(path.clone());
33+
Ok(path)
34+
}
35+
Err(error) if error.kind() == io::ErrorKind::NotFound => {
36+
if let Some(path) = read_env_path(env_var) {
37+
*cached = Some(path.clone());
38+
Ok(path)
39+
} else {
40+
Err(format!(
41+
"{name} not found on PATH and {env_var} is not set"
42+
))
43+
}
44+
}
45+
Err(error) => Err(format!("failed to run {name}: {error}")),
46+
}
47+
}
48+
49+
pub(crate) fn ffmpeg_path() -> Result<String, String> {
50+
resolve_with_cache(&FFMPEG_PATH, "ffmpeg", "FRAMESCRIPT_FFMPEG_PATH")
51+
}
52+
53+
pub(crate) fn ffprobe_path() -> Result<String, String> {
54+
resolve_with_cache(&FFPROBE_PATH, "ffprobe", "FRAMESCRIPT_FFPROBE_PATH")
55+
}

backend/src/ffmpeg/command.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use std::io::{self, Read};
22
use std::process::{Command, Stdio};
33

4+
use crate::ffmpeg::bin::ffmpeg_path;
5+
46
pub(crate) fn extract_frames_rgba(
57
path: &str,
68
start_frame: usize,
@@ -24,7 +26,8 @@ pub(crate) fn extract_frames_rgba(
2426
start_frame, end_frame, dst_width, dst_height
2527
);
2628

27-
let mut cmd = Command::new("ffmpeg");
29+
let ffmpeg = ffmpeg_path()?;
30+
let mut cmd = Command::new(ffmpeg);
2831
cmd.arg("-hide_banner")
2932
.arg("-loglevel")
3033
.arg("error")

electron/main.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import fs from "node:fs";
1010
import path from "node:path";
1111
import { fileURLToPath } from "node:url";
1212
import { pathToFileURL } from "node:url";
13+
import * as ffmpegInstaller from "@ffmpeg-installer/ffmpeg";
14+
import * as ffprobeInstaller from "@ffprobe-installer/ffprobe";
1315

1416
const __filename = fileURLToPath(import.meta.url);
1517
const __dirname = path.dirname(__filename);
@@ -18,6 +20,29 @@ const useDevServer = process.env.VITE_DEV_SERVER_URL !== undefined;
1820
const runMode = process.env.FRAMESCRIPT_RUN_MODE ?? (useDevServer ? "dev" : "bin");
1921
const useBinaries = runMode !== "dev";
2022

23+
const resolveBundledBinaryPath = (installer: unknown) => {
24+
const candidate =
25+
(installer as { path?: string; default?: { path?: string } } | undefined)?.path ??
26+
(installer as { default?: { path?: string } } | undefined)?.default?.path;
27+
if (typeof candidate === "string" && candidate.trim().length > 0) {
28+
return candidate;
29+
}
30+
return null;
31+
};
32+
33+
function getFfmpegEnv(): NodeJS.ProcessEnv {
34+
const env: NodeJS.ProcessEnv = {};
35+
const ffmpegPath = process.env.FRAMESCRIPT_FFMPEG_PATH ?? resolveBundledBinaryPath(ffmpegInstaller);
36+
const ffprobePath = process.env.FRAMESCRIPT_FFPROBE_PATH ?? resolveBundledBinaryPath(ffprobeInstaller);
37+
if (ffmpegPath) {
38+
env.FRAMESCRIPT_FFMPEG_PATH = ffmpegPath;
39+
}
40+
if (ffprobePath) {
41+
env.FRAMESCRIPT_FFPROBE_PATH = ffprobePath;
42+
}
43+
return env;
44+
}
45+
2146
let mainWindow: BrowserWindow | null = null;
2247
let backendProcess: ChildProcess | null = null;
2348
let backendHealthyPromise: Promise<void> | null = null;
@@ -88,6 +113,10 @@ function startBackend(): Promise<void> {
88113
backendProcess = spawn("cargo", ["run"], {
89114
cwd: backendCwd,
90115
stdio: "pipe",
116+
env: {
117+
...process.env,
118+
...getFfmpegEnv(),
119+
},
91120
});
92121

93122
console.log("[backend] spawn: cargo run (dev)");
@@ -103,6 +132,10 @@ function startBackend(): Promise<void> {
103132

104133
backendProcess = spawn(info.path, [], {
105134
stdio: "pipe",
135+
env: {
136+
...process.env,
137+
...getFfmpegEnv(),
138+
},
106139
});
107140

108141
console.log("[backend] spawn:", info.path);
@@ -224,6 +257,7 @@ function startRenderProcess(payload: RenderStartPayload) {
224257
cwd: renderCwd,
225258
env: {
226259
...process.env,
260+
...getFfmpegEnv(),
227261
RENDER_PAGE_URL: getRenderPageUrl(),
228262
RENDER_OUTPUT_PATH: getRenderOutputPath(),
229263
},
@@ -257,6 +291,7 @@ function startRenderProcess(payload: RenderStartPayload) {
257291
renderChild = spawn(binPath, [argsString], {
258292
env: {
259293
...process.env,
294+
...getFfmpegEnv(),
260295
RENDER_PAGE_URL: getRenderPageUrl(),
261296
RENDER_OUTPUT_PATH: getRenderOutputPath(),
262297
},

electron/types.d.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
declare module "@ffmpeg-installer/ffmpeg" {
2+
export const path: string;
3+
const ffmpeg: { path: string };
4+
export default ffmpeg;
5+
}
6+
7+
declare module "@ffprobe-installer/ffprobe" {
8+
export const path: string;
9+
const ffprobe: { path: string };
10+
export default ffprobe;
11+
}

render/src/ffmpeg.rs

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,59 @@
11
use std::{
22
error::Error,
33
collections::BTreeMap,
4+
io,
45
path::{Path, PathBuf},
56
process::Stdio,
7+
sync::{Mutex, OnceLock},
68
};
79

810
use serde::Deserialize;
911
use tokio::{
1012
fs,
1113
io::AsyncWriteExt,
12-
process::{Child, ChildStdin, Command},
14+
process::{Child, ChildStdin, Command as TokioCommand},
1315
};
1416

17+
static FFMPEG_PATH: OnceLock<Mutex<Option<String>>> = OnceLock::new();
18+
19+
fn read_env_path(env_var: &str) -> Option<String> {
20+
let value = std::env::var(env_var).ok()?;
21+
let trimmed = value.trim();
22+
if trimmed.is_empty() {
23+
None
24+
} else {
25+
Some(trimmed.to_string())
26+
}
27+
}
28+
29+
fn resolve_ffmpeg_path() -> Result<String, Box<dyn Error>> {
30+
let lock = FFMPEG_PATH.get_or_init(|| Mutex::new(None));
31+
let mut cached = lock.lock().unwrap();
32+
if let Some(path) = cached.as_ref() {
33+
return Ok(path.clone());
34+
}
35+
36+
match std::process::Command::new("ffmpeg")
37+
.arg("-version")
38+
.output()
39+
{
40+
Ok(_) => {
41+
let path = "ffmpeg".to_string();
42+
*cached = Some(path.clone());
43+
Ok(path)
44+
}
45+
Err(error) if error.kind() == io::ErrorKind::NotFound => {
46+
if let Some(path) = read_env_path("FRAMESCRIPT_FFMPEG_PATH") {
47+
*cached = Some(path.clone());
48+
Ok(path)
49+
} else {
50+
Err("ffmpeg not found on PATH and FRAMESCRIPT_FFMPEG_PATH is not set".into())
51+
}
52+
}
53+
Err(error) => Err(format!("failed to run ffmpeg: {error}").into()),
54+
}
55+
}
56+
1557
pub struct SegmentWriter {
1658
child: Child,
1759
stdin: ChildStdin,
@@ -36,7 +78,8 @@ impl SegmentWriter {
3678

3779
let preset = preset.unwrap_or("medium");
3880

39-
let mut cmd = Command::new("ffmpeg");
81+
let ffmpeg = resolve_ffmpeg_path()?;
82+
let mut cmd = TokioCommand::new(ffmpeg);
4083
cmd.arg("-y")
4184
.arg("-hide_banner")
4285
.arg("-loglevel")
@@ -138,7 +181,8 @@ pub async fn concat_segments_mp4(
138181

139182
fs::write(&list_path, lines).await?;
140183

141-
let status = Command::new("ffmpeg")
184+
let ffmpeg = resolve_ffmpeg_path()?;
185+
let status = TokioCommand::new(ffmpeg)
142186
.arg("-y")
143187
.arg("-hide_banner")
144188
.arg("-loglevel")
@@ -221,7 +265,8 @@ pub async fn mux_audio_plan_into_mp4(
221265
}
222266
}
223267

224-
let mut cmd = Command::new("ffmpeg");
268+
let ffmpeg = resolve_ffmpeg_path()?;
269+
let mut cmd = TokioCommand::new(ffmpeg);
225270
cmd.arg("-y")
226271
.arg("-hide_banner")
227272
.arg("-loglevel")

0 commit comments

Comments
 (0)