Update rust_highlight/src/lib.rs
Browse files- rust_highlight/src/lib.rs +56 -276
rust_highlight/src/lib.rs
CHANGED
|
@@ -1,303 +1,83 @@
|
|
| 1 |
use pyo3::prelude::*;
|
| 2 |
-
use
|
| 3 |
-
use image::{ImageBuffer, Rgb};
|
| 4 |
-
use imageproc::drawing::{draw_text_mut, draw_line_segment_mut, draw_filled_circle_mut};
|
| 5 |
-
use ab_glyph::{FontVec, PxScale, Font as AbFont};
|
| 6 |
-
use std::process::{Command, Stdio};
|
| 7 |
-
use std::io::Write;
|
| 8 |
use std::path::Path;
|
| 9 |
|
| 10 |
-
const WIDTH: u32 = 1280;
|
| 11 |
-
const HEIGHT: u32 = 720;
|
| 12 |
-
const MARGIN_X: u32 = 40;
|
| 13 |
-
const MARGIN_Y: u32 = 60;
|
| 14 |
-
const LINE_SPACING: u32 = 8;
|
| 15 |
-
const FPS: u32 = 30;
|
| 16 |
-
const ANIMATION_FRAMES_PER_CHAR: usize = 2;
|
| 17 |
-
const BG_COLOR: Rgb<u8> = Rgb([255, 255, 255]);
|
| 18 |
-
const DEFAULT_TEXT_COLOR: Rgb<u8> = Rgb([0, 0, 0]);
|
| 19 |
-
const HEADER_TEXT_COLOR: Rgb<u8> = Rgb([0, 0, 255]);
|
| 20 |
-
const PEN_COLOR: Rgb<u8> = Rgb([255, 0, 0]);
|
| 21 |
-
const FFMPEG_PRESET: &str = "ultrafast";
|
| 22 |
-
const CRF: u32 = 28;
|
| 23 |
-
|
| 24 |
#[pyfunction]
|
| 25 |
fn render_video(
|
| 26 |
id: usize,
|
| 27 |
-
|
| 28 |
audio_path: String,
|
| 29 |
-
|
| 30 |
-
|
|
|
|
| 31 |
) -> PyResult<String> {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
if !Path::new(&audio_path).exists() {
|
| 33 |
-
return Err(
|
|
|
|
|
|
|
|
|
|
| 34 |
}
|
| 35 |
|
| 36 |
-
let
|
| 37 |
-
let
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
.
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
let total_frames = visible_indices.len() * ANIMATION_FRAMES_PER_CHAR;
|
| 51 |
-
println!("Rendering {} frames for {} glyphs", total_frames, visible_indices.len());
|
| 52 |
-
|
| 53 |
-
let silent_video_path = format!("{}/silent_video{}.mp4", clips_dir, id);
|
| 54 |
-
let mut ffmpeg = Command::new("ffmpeg")
|
| 55 |
-
.args(&[
|
| 56 |
-
"-y",
|
| 57 |
-
"-f", "rawvideo",
|
| 58 |
-
"-pix_fmt", "rgb24",
|
| 59 |
-
"-s", &format!("{}x{}", WIDTH, HEIGHT),
|
| 60 |
-
"-r", &FPS.to_string(),
|
| 61 |
-
"-i", "-",
|
| 62 |
-
"-an",
|
| 63 |
-
"-c:v", "libx264",
|
| 64 |
-
"-preset", FFMPEG_PRESET,
|
| 65 |
-
"-crf", &CRF.to_string(),
|
| 66 |
-
"-pix_fmt", "yuv420p",
|
| 67 |
-
&silent_video_path,
|
| 68 |
-
])
|
| 69 |
-
.stdin(Stdio::piped())
|
| 70 |
-
.spawn()
|
| 71 |
-
.map_err(|e| PyRuntimeError::new_err(format!("Failed to spawn FFmpeg: {}", e)))?;
|
| 72 |
-
|
| 73 |
-
let mut stdin = ffmpeg.stdin.take().unwrap();
|
| 74 |
-
|
| 75 |
-
let mut prev_text_len = 0;
|
| 76 |
-
for &char_idx in visible_indices.iter() {
|
| 77 |
-
let visible_text = &full_text[..=char_idx];
|
| 78 |
-
|
| 79 |
-
if visible_text.len() != prev_text_len {
|
| 80 |
-
let lines: Vec<&str> = visible_text.split('\n').collect();
|
| 81 |
-
let last_line = lines.last().unwrap_or(&"");
|
| 82 |
-
let line_idx = lines.len() - 1;
|
| 83 |
-
|
| 84 |
-
let (pen_x, pen_y) = calculate_pen_position(
|
| 85 |
-
last_line,
|
| 86 |
-
&font,
|
| 87 |
-
line_idx,
|
| 88 |
-
&line_styles,
|
| 89 |
-
&y_positions,
|
| 90 |
-
);
|
| 91 |
-
|
| 92 |
-
for anim_step in 0..ANIMATION_FRAMES_PER_CHAR {
|
| 93 |
-
let frame = render_frame(
|
| 94 |
-
visible_text,
|
| 95 |
-
&line_styles,
|
| 96 |
-
&y_positions,
|
| 97 |
-
&font,
|
| 98 |
-
pen_x,
|
| 99 |
-
pen_y,
|
| 100 |
-
anim_step as f32 / ANIMATION_FRAMES_PER_CHAR as f32,
|
| 101 |
-
);
|
| 102 |
-
|
| 103 |
-
stdin.write_all(frame.as_raw())
|
| 104 |
-
.map_err(|e| PyRuntimeError::new_err(format!("Failed to write frame: {}", e)))?;
|
| 105 |
-
}
|
| 106 |
-
|
| 107 |
-
prev_text_len = visible_text.len();
|
| 108 |
-
}
|
| 109 |
}
|
| 110 |
|
| 111 |
-
|
| 112 |
-
let output = ffmpeg.wait()
|
| 113 |
-
.map_err(|e| PyRuntimeError::new_err(format!("FFmpeg error: {}", e)))?;
|
| 114 |
|
| 115 |
-
|
| 116 |
-
return Err(PyRuntimeError::new_err("FFmpeg encoding failed"));
|
| 117 |
-
}
|
| 118 |
-
|
| 119 |
-
let rendered_duration = total_frames as f64 / FPS as f64;
|
| 120 |
-
let speed_factor = rendered_duration / audio_duration;
|
| 121 |
-
|
| 122 |
-
let final_video_path = format!("{}/clip{}.mp4", clips_dir, id);
|
| 123 |
-
|
| 124 |
let status = Command::new("ffmpeg")
|
| 125 |
.args(&[
|
| 126 |
"-y",
|
| 127 |
-
"-
|
|
|
|
| 128 |
"-i", &audio_path,
|
| 129 |
-
"-
|
| 130 |
"-c:v", "libx264",
|
| 131 |
-
"-preset", "
|
|
|
|
|
|
|
|
|
|
| 132 |
"-c:a", "aac",
|
| 133 |
-
"-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
| 135 |
])
|
| 136 |
-
.status()
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
}
|
| 142 |
-
|
| 143 |
-
let _ = std::fs::remove_file(&silent_video_path);
|
| 144 |
-
println!("Final video saved: {}", final_video_path);
|
| 145 |
-
Ok(final_video_path)
|
| 146 |
-
}
|
| 147 |
-
|
| 148 |
-
fn wrap_text(text: &str, font: &FontVec, max_width: u32) -> (Vec<String>, Vec<bool>) {
|
| 149 |
-
let mut wrapped = Vec::new();
|
| 150 |
-
let mut styles = Vec::new();
|
| 151 |
-
|
| 152 |
-
for line in text.lines() {
|
| 153 |
-
let is_header = line.trim_start().starts_with("###");
|
| 154 |
-
let clean_line = if is_header {
|
| 155 |
-
line.trim_start_matches("###").trim()
|
| 156 |
-
} else {
|
| 157 |
-
line
|
| 158 |
-
};
|
| 159 |
-
|
| 160 |
-
let scale = if is_header { PxScale::from(60.0) } else { PxScale::from(45.0) };
|
| 161 |
-
|
| 162 |
-
if clean_line.is_empty() {
|
| 163 |
-
wrapped.push(String::new());
|
| 164 |
-
styles.push(false);
|
| 165 |
-
continue;
|
| 166 |
-
}
|
| 167 |
-
|
| 168 |
-
let words: Vec<&str> = clean_line.split_whitespace().collect();
|
| 169 |
-
let mut current = String::new();
|
| 170 |
-
|
| 171 |
-
for word in words {
|
| 172 |
-
let candidate = if current.is_empty() {
|
| 173 |
-
word.to_string()
|
| 174 |
-
} else {
|
| 175 |
-
format!("{} {}", current, word)
|
| 176 |
-
};
|
| 177 |
-
|
| 178 |
-
let width = measure_text(&candidate, font, scale);
|
| 179 |
-
|
| 180 |
-
if width <= max_width as f32 {
|
| 181 |
-
current = candidate;
|
| 182 |
-
} else {
|
| 183 |
-
if !current.is_empty() {
|
| 184 |
-
wrapped.push(current.clone());
|
| 185 |
-
styles.push(is_header);
|
| 186 |
-
}
|
| 187 |
-
current = word.to_string();
|
| 188 |
-
}
|
| 189 |
-
}
|
| 190 |
-
|
| 191 |
-
if !current.is_empty() {
|
| 192 |
-
wrapped.push(current);
|
| 193 |
-
styles.push(is_header);
|
| 194 |
-
}
|
| 195 |
-
}
|
| 196 |
-
|
| 197 |
-
(wrapped, styles)
|
| 198 |
-
}
|
| 199 |
-
|
| 200 |
-
fn measure_text(text: &str, font: &FontVec, scale: PxScale) -> f32 {
|
| 201 |
-
use ab_glyph::ScaleFont;
|
| 202 |
-
let scaled = font.as_scaled(scale);
|
| 203 |
-
|
| 204 |
-
text.chars()
|
| 205 |
-
.filter_map(|c| {
|
| 206 |
-
let glyph_id = font.glyph_id(c);
|
| 207 |
-
Some(scaled.h_advance(glyph_id))
|
| 208 |
-
})
|
| 209 |
-
.sum()
|
| 210 |
-
}
|
| 211 |
-
|
| 212 |
-
fn calculate_line_positions(lines: &[String], styles: &[bool], font: &FontVec) -> (Vec<u32>, Vec<u32>) {
|
| 213 |
-
use ab_glyph::ScaleFont;
|
| 214 |
-
let mut heights = Vec::new();
|
| 215 |
-
let mut positions = Vec::new();
|
| 216 |
-
let mut y = MARGIN_Y;
|
| 217 |
-
|
| 218 |
-
for (i, _line) in lines.iter().enumerate() {
|
| 219 |
-
let scale = if styles[i] { PxScale::from(60.0) } else { PxScale::from(45.0) };
|
| 220 |
-
let scaled = font.as_scaled(scale);
|
| 221 |
-
let height = (scaled.ascent() - scaled.descent()).ceil() as u32 + LINE_SPACING;
|
| 222 |
-
|
| 223 |
-
heights.push(height);
|
| 224 |
-
positions.push(y);
|
| 225 |
-
y += height;
|
| 226 |
-
}
|
| 227 |
-
|
| 228 |
-
(heights, positions)
|
| 229 |
-
}
|
| 230 |
-
|
| 231 |
-
fn calculate_pen_position(
|
| 232 |
-
last_line: &str,
|
| 233 |
-
font: &FontVec,
|
| 234 |
-
line_idx: usize,
|
| 235 |
-
styles: &[bool],
|
| 236 |
-
y_positions: &[u32],
|
| 237 |
-
) -> (f32, f32) {
|
| 238 |
-
let scale = if styles.get(line_idx).copied().unwrap_or(false) {
|
| 239 |
-
PxScale::from(60.0)
|
| 240 |
-
} else {
|
| 241 |
-
PxScale::from(45.0)
|
| 242 |
-
};
|
| 243 |
-
|
| 244 |
-
let width = measure_text(last_line, font, scale);
|
| 245 |
-
let pen_x = MARGIN_X as f32 + width + 5.0;
|
| 246 |
-
let pen_y = y_positions.get(line_idx).copied().unwrap_or(MARGIN_Y) as f32 + 20.0;
|
| 247 |
-
|
| 248 |
-
(pen_x, pen_y)
|
| 249 |
-
}
|
| 250 |
-
|
| 251 |
-
fn render_frame(
|
| 252 |
-
visible_text: &str,
|
| 253 |
-
line_styles: &[bool],
|
| 254 |
-
y_positions: &[u32],
|
| 255 |
-
font: &FontVec,
|
| 256 |
-
pen_x: f32,
|
| 257 |
-
pen_y: f32,
|
| 258 |
-
anim_offset: f32,
|
| 259 |
-
) -> ImageBuffer<Rgb<u8>, Vec<u8>> {
|
| 260 |
-
let mut img = ImageBuffer::from_pixel(WIDTH, HEIGHT, BG_COLOR);
|
| 261 |
-
|
| 262 |
-
let visible_lines: Vec<&str> = visible_text.split('\n').collect();
|
| 263 |
-
for (idx, line) in visible_lines.iter().enumerate() {
|
| 264 |
-
if idx >= line_styles.len() {
|
| 265 |
-
break;
|
| 266 |
-
}
|
| 267 |
-
|
| 268 |
-
let is_header = line_styles[idx];
|
| 269 |
-
let scale = if is_header { PxScale::from(60.0) } else { PxScale::from(45.0) };
|
| 270 |
-
let color = if is_header { HEADER_TEXT_COLOR } else { DEFAULT_TEXT_COLOR };
|
| 271 |
-
let y = y_positions.get(idx).copied().unwrap_or(MARGIN_Y);
|
| 272 |
-
|
| 273 |
-
if !line.is_empty() {
|
| 274 |
-
draw_text_mut(&mut img, color, MARGIN_X as i32, y as i32, scale, font, line);
|
| 275 |
-
}
|
| 276 |
-
}
|
| 277 |
-
|
| 278 |
-
if pen_x > 0.0 {
|
| 279 |
-
let pen_offset = (anim_offset * std::f32::consts::PI).sin() * 10.0;
|
| 280 |
-
let pen_tip_y = pen_y + pen_offset;
|
| 281 |
-
|
| 282 |
-
let angle = 45.0_f32.to_radians();
|
| 283 |
-
let pen_end_x = pen_x + 20.0 * angle.cos();
|
| 284 |
-
let pen_end_y = pen_tip_y - 20.0 * angle.sin();
|
| 285 |
-
|
| 286 |
-
draw_line_segment_mut(
|
| 287 |
-
&mut img,
|
| 288 |
-
(pen_x, pen_tip_y),
|
| 289 |
-
(pen_end_x, pen_end_y),
|
| 290 |
-
PEN_COLOR,
|
| 291 |
-
);
|
| 292 |
-
|
| 293 |
-
draw_filled_circle_mut(&mut img, (pen_x as i32, pen_tip_y as i32), 5, PEN_COLOR);
|
| 294 |
-
}
|
| 295 |
-
|
| 296 |
-
img
|
| 297 |
}
|
| 298 |
|
| 299 |
#[pymodule]
|
| 300 |
-
fn
|
| 301 |
m.add_function(wrap_pyfunction!(render_video, m)?)?;
|
| 302 |
Ok(())
|
| 303 |
-
}
|
|
|
|
| 1 |
use pyo3::prelude::*;
|
| 2 |
+
use std::process::Command;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
use std::path::Path;
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
#[pyfunction]
|
| 6 |
fn render_video(
|
| 7 |
id: usize,
|
| 8 |
+
image_path: String,
|
| 9 |
audio_path: String,
|
| 10 |
+
duration: f64,
|
| 11 |
+
words: Vec<(String, (u32, u32, u32, u32))>,
|
| 12 |
+
output_dir: String,
|
| 13 |
) -> PyResult<String> {
|
| 14 |
+
// Validate paths
|
| 15 |
+
if !Path::new(&image_path).exists() {
|
| 16 |
+
return Err(pyo3::exceptions::PyFileNotFoundError::new_err(format!(
|
| 17 |
+
"Image not found: {}",
|
| 18 |
+
image_path
|
| 19 |
+
)));
|
| 20 |
+
}
|
| 21 |
if !Path::new(&audio_path).exists() {
|
| 22 |
+
return Err(pyo3::exceptions::PyFileNotFoundError::new_err(format!(
|
| 23 |
+
"Audio not found: {}",
|
| 24 |
+
audio_path
|
| 25 |
+
)));
|
| 26 |
}
|
| 27 |
|
| 28 |
+
let output_path = format!("{}/clip{}.mp4", output_dir, id);
|
| 29 |
+
let duration_str = duration.to_string();
|
| 30 |
+
let n_words = words.len() as f64;
|
| 31 |
+
let highlight_duration = duration / n_words;
|
| 32 |
+
|
| 33 |
+
// Build filter graph
|
| 34 |
+
let mut filters = vec![];
|
| 35 |
+
for (i, (_, (x, y, w, h))) in words.iter().enumerate() {
|
| 36 |
+
let start = i as f64 * highlight_duration;
|
| 37 |
+
let end = start + highlight_duration;
|
| 38 |
+
filters.push(format!(
|
| 39 |
+
"drawbox=x={x}:y={y}:w={w}:h={h}:color=yellow@0.5:t=fill:enable='between(t,{start:.2},{end:.2})'"
|
| 40 |
+
));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
}
|
| 42 |
|
| 43 |
+
let filter_chain = filters.join(",");
|
|
|
|
|
|
|
| 44 |
|
| 45 |
+
// FFmpeg command
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
let status = Command::new("ffmpeg")
|
| 47 |
.args(&[
|
| 48 |
"-y",
|
| 49 |
+
"-loop", "1",
|
| 50 |
+
"-i", &image_path,
|
| 51 |
"-i", &audio_path,
|
| 52 |
+
"-vf", &filter_chain,
|
| 53 |
"-c:v", "libx264",
|
| 54 |
+
"-preset", "fast",
|
| 55 |
+
"-crf", "18",
|
| 56 |
+
"-pix_fmt", "yuv420p",
|
| 57 |
+
"-movflags", "+faststart",
|
| 58 |
"-c:a", "aac",
|
| 59 |
+
"-b:a", "192k",
|
| 60 |
+
"-r", "60",
|
| 61 |
+
"-fps_mode", "vfr", // Replaces deprecated -vsync
|
| 62 |
+
"-t", &duration_str,
|
| 63 |
+
&output_path,
|
| 64 |
])
|
| 65 |
+
.status();
|
| 66 |
+
|
| 67 |
+
match status {
|
| 68 |
+
Ok(exit_status) if exit_status.success() => Ok(output_path),
|
| 69 |
+
Ok(_) => Err(pyo3::exceptions::PyRuntimeError::new_err(
|
| 70 |
+
"FFmpeg command failed. Check filter syntax.",
|
| 71 |
+
)),
|
| 72 |
+
Err(e) => Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
|
| 73 |
+
"Failed to execute FFmpeg: {}",
|
| 74 |
+
e
|
| 75 |
+
))),
|
| 76 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
}
|
| 78 |
|
| 79 |
#[pymodule]
|
| 80 |
+
fn rust_highlight(_py: Python, m: &PyModule) -> PyResult<()> {
|
| 81 |
m.add_function(wrap_pyfunction!(render_video, m)?)?;
|
| 82 |
Ok(())
|
| 83 |
+
}
|