Skip to content

Commit 441c991

Browse files
committed
add youtube audio settings
1 parent 1b87733 commit 441c991

4 files changed

Lines changed: 128 additions & 36 deletions

File tree

backend/src/main.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ enum AudioSourceRef {
8282
Sound { path: String },
8383
}
8484

85+
#[derive(Serialize, Deserialize, Clone)]
86+
#[serde(rename_all = "lowercase")]
87+
enum AudioLoudnessPreset {
88+
Youtube,
89+
}
90+
8591
#[derive(Deserialize, Clone)]
8692
struct AudioSegment {
8793
id: String,
@@ -103,6 +109,7 @@ struct AudioSegment {
103109
struct AudioPlanRequest {
104110
fps: f64,
105111
segments: Vec<AudioSegment>,
112+
loudness: Option<AudioLoudnessPreset>,
106113
}
107114

108115
#[derive(Serialize, Clone)]
@@ -133,6 +140,7 @@ struct AudioSegmentResolved {
133140
struct AudioPlanResolved {
134141
fps: f64,
135142
segments: Vec<AudioSegmentResolved>,
143+
loudness: Option<AudioLoudnessPreset>,
136144
}
137145

138146
static RENDER_AUDIO_PLAN: std::sync::LazyLock<std::sync::Mutex<Option<AudioPlanResolved>>> =
@@ -689,7 +697,11 @@ async fn set_audio_plan_handler(
689697
});
690698
}
691699

692-
*RENDER_AUDIO_PLAN.lock().unwrap() = Some(AudioPlanResolved { fps, segments });
700+
*RENDER_AUDIO_PLAN.lock().unwrap() = Some(AudioPlanResolved {
701+
fps,
702+
segments,
703+
loudness: payload.loudness,
704+
});
693705

694706
(headers, StatusCode::OK)
695707
}
@@ -705,6 +717,7 @@ async fn get_audio_plan_handler(State(_state): State<AppState>) -> impl IntoResp
705717
.unwrap_or(AudioPlanResolved {
706718
fps: 60.0,
707719
segments: Vec::new(),
720+
loudness: None,
708721
});
709722

710723
(headers, Json(plan))

electron/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ function createRenderSettingsWindow() {
375375

376376
renderSettingsWindow = new BrowserWindow({
377377
width: 640,
378-
height: 550,
378+
height: 750,
379379
resizable: false,
380380
minimizable: false,
381381
maximizable: false,

render/src/ffmpeg.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,12 @@ pub enum AudioSourceResolved {
241241
Sound { path: String },
242242
}
243243

244+
#[derive(Debug, Clone, Deserialize)]
245+
#[serde(rename_all = "lowercase")]
246+
pub enum AudioLoudnessPreset {
247+
Youtube,
248+
}
249+
244250
#[derive(Debug, Clone, Deserialize)]
245251
pub struct AudioSegmentResolved {
246252
pub id: String,
@@ -262,6 +268,7 @@ pub struct AudioSegmentResolved {
262268
pub struct AudioPlanResolved {
263269
pub fps: f64,
264270
pub segments: Vec<AudioSegmentResolved>,
271+
pub loudness: Option<AudioLoudnessPreset>,
265272
}
266273

267274
pub async fn mux_audio_plan_into_mp4(
@@ -391,9 +398,14 @@ pub async fn mux_audio_plan_into_mp4(
391398
.collect::<String>();
392399

393400
let total_inputs = 1 + seg_count;
394-
filter_parts.push(format!(
395-
"{mix_inputs}amix=inputs={total_inputs}:duration=first:normalize=0,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[aout]"
396-
));
401+
let mut mix_chain = format!(
402+
"{mix_inputs}amix=inputs={total_inputs}:duration=first:normalize=0,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo"
403+
);
404+
if matches!(plan.loudness, Some(AudioLoudnessPreset::Youtube)) {
405+
mix_chain.push_str(",loudnorm=I=-14:TP=-1:LRA=11");
406+
}
407+
mix_chain.push_str("[aout]");
408+
filter_parts.push(mix_chain);
397409

398410
let filter_complex = filter_parts.join(";");
399411

src/ui/render-settings.tsx

Lines changed: 98 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ const encodeOptions = [
1212
{ value: "H264", label: "H264 (software)" },
1313
{ value: "H265", label: "H265 (software)" },
1414
];
15+
const loudnessOptions = [
16+
{ value: "off", label: "Default", help: "Keep the mix as-is." },
17+
{ value: "youtube", label: "YouTube", help: "Target -14 LUFS, -1 dBTP." },
18+
];
1519

1620
const containerStyle: CSSProperties = {
1721
padding: 20,
@@ -35,6 +39,22 @@ const inputStyle: CSSProperties = {
3539
background: "#0f172a",
3640
color: "#e5e7eb",
3741
};
42+
const sectionStyle: CSSProperties = {
43+
background: "#0f172a",
44+
border: "1px solid #1f2a3c",
45+
borderRadius: 12,
46+
padding: 12,
47+
display: "flex",
48+
flexDirection: "column",
49+
gap: 10,
50+
};
51+
const sectionTitleStyle: CSSProperties = {
52+
fontSize: 11,
53+
textTransform: "uppercase",
54+
letterSpacing: "0.08em",
55+
color: "#94a3b8",
56+
fontWeight: 600,
57+
};
3858

3959
export const RenderSettingsPage = () => {
4060
const [width, setWidth] = useState(PROJECT_SETTINGS.width ?? 1920);
@@ -49,6 +69,7 @@ export const RenderSettingsPage = () => {
4969
});
5070
const [encode, setEncode] = useState<"H264" | "H265">("H264");
5171
const [preset, setPreset] = useState("medium");
72+
const [loudness, setLoudness] = useState<"off" | "youtube">("off");
5273
const [cacheGiB, setCacheGiB] = useState(4);
5374
const [status, setStatus] = useState<string | null>(null);
5475
const [busy, setBusy] = useState(false);
@@ -120,13 +141,21 @@ export const RenderSettingsPage = () => {
120141
// ignore; still try to start render
121142
}
122143
try {
144+
const audioPlanPayload: {
145+
fps: number;
146+
segments: typeof audioSegments;
147+
loudness?: "youtube";
148+
} = {
149+
fps: Number(fps),
150+
segments: audioSegments,
151+
};
152+
if (loudness === "youtube") {
153+
audioPlanPayload.loudness = "youtube";
154+
}
123155
await fetch("http://127.0.0.1:3000/render_audio_plan", {
124156
method: "POST",
125157
headers: { "Content-Type": "application/json" },
126-
body: JSON.stringify({
127-
fps: Number(fps),
128-
segments: audioSegments,
129-
}),
158+
body: JSON.stringify(audioPlanPayload),
130159
});
131160
} catch (_error) {
132161
// ignore; still try to start render
@@ -255,33 +284,71 @@ export const RenderSettingsPage = () => {
255284
</div>
256285
</div>
257286

258-
<div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 10 }}>
259-
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
260-
{encodeOptions.map((option) => (
261-
<label
262-
key={option.value}
263-
style={{
264-
display: "inline-flex",
265-
alignItems: "center",
266-
gap: 8,
267-
padding: "10px 12px",
268-
borderRadius: 10,
269-
border: "1px solid #1f2a3c",
270-
background: encode === option.value ? "linear-gradient(90deg, #1f2937, #0f172a)" : "#0f172a",
271-
cursor: "pointer",
272-
userSelect: "none",
273-
}}
274-
>
275-
<input
276-
type="radio"
277-
value={option.value}
278-
checked={encode === option.value}
279-
onChange={() => setEncode(option.value as "H264" | "H265")}
280-
style={{ accentColor: "#5bd5ff" }}
281-
/>
282-
{option.label}
283-
</label>
284-
))}
287+
<div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 12 }}>
288+
<div style={sectionStyle}>
289+
<div style={sectionTitleStyle}>Encoding</div>
290+
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
291+
{encodeOptions.map((option) => (
292+
<label
293+
key={option.value}
294+
style={{
295+
display: "inline-flex",
296+
alignItems: "center",
297+
gap: 8,
298+
padding: "10px 12px",
299+
borderRadius: 10,
300+
border: "1px solid #1f2a3c",
301+
background: encode === option.value ? "linear-gradient(90deg, #1f2937, #0f172a)" : "#0f172a",
302+
cursor: "pointer",
303+
userSelect: "none",
304+
}}
305+
>
306+
<input
307+
type="radio"
308+
value={option.value}
309+
checked={encode === option.value}
310+
onChange={() => setEncode(option.value as "H264" | "H265")}
311+
style={{ accentColor: "#5bd5ff" }}
312+
/>
313+
{option.label}
314+
</label>
315+
))}
316+
</div>
317+
</div>
318+
<div style={sectionStyle}>
319+
<div style={sectionTitleStyle}>Audio</div>
320+
<div style={{ fontSize: 12, color: "#cbd5e1" }}>Loudness</div>
321+
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
322+
{loudnessOptions.map((option) => (
323+
<label
324+
key={option.value}
325+
style={{
326+
display: "inline-flex",
327+
alignItems: "center",
328+
gap: 10,
329+
padding: "10px 12px",
330+
borderRadius: 10,
331+
border: "1px solid #1f2a3c",
332+
background: loudness === option.value ? "linear-gradient(90deg, #1f2937, #0f172a)" : "#0f172a",
333+
cursor: "pointer",
334+
userSelect: "none",
335+
minWidth: 200,
336+
}}
337+
>
338+
<input
339+
type="radio"
340+
value={option.value}
341+
checked={loudness === option.value}
342+
onChange={() => setLoudness(option.value as "off" | "youtube")}
343+
style={{ accentColor: "#5bd5ff" }}
344+
/>
345+
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
346+
<span style={{ fontSize: 12, fontWeight: 600, color: "#e5e7eb" }}>{option.label}</span>
347+
<span style={{ fontSize: 11, color: "#94a3b8" }}>{option.help}</span>
348+
</div>
349+
</label>
350+
))}
351+
</div>
285352
</div>
286353

287354
<div

0 commit comments

Comments
 (0)