sreepathi-ravikumar commited on
Commit
f6a1d86
·
verified ·
1 Parent(s): 47dd6cf

Update rust_highlight/src/lib.rs

Browse files
Files changed (1) hide show
  1. rust_highlight/src/lib.rs +56 -276
rust_highlight/src/lib.rs CHANGED
@@ -1,303 +1,83 @@
1
  use pyo3::prelude::*;
2
- use pyo3::exceptions::PyRuntimeError;
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
- text: String,
28
  audio_path: String,
29
- audio_duration: f64,
30
- clips_dir: String,
 
31
  ) -> PyResult<String> {
 
 
 
 
 
 
 
32
  if !Path::new(&audio_path).exists() {
33
- return Err(PyRuntimeError::new_err(format!("Audio not found: {}", audio_path)));
 
 
 
34
  }
35
 
36
- let font_data: &[u8] = include_bytes!("../fonts/LiberationSans-Regular.ttf");
37
- let font = FontVec::try_from_vec(font_data.to_vec())
38
- .map_err(|_| PyRuntimeError::new_err("Failed to load font"))?;
39
-
40
- let (wrapped_lines, line_styles) = wrap_text(&text, &font, WIDTH - 2 * MARGIN_X);
41
- let (_line_heights, y_positions) = calculate_line_positions(&wrapped_lines, &line_styles, &font);
42
-
43
- let full_text = wrapped_lines.join("\n");
44
- let visible_indices: Vec<usize> = full_text
45
- .char_indices()
46
- .filter(|(_, ch)| *ch != ' ' && *ch != '\n' && *ch != '\t')
47
- .map(|(i, _)| i)
48
- .collect();
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
- drop(stdin);
112
- let output = ffmpeg.wait()
113
- .map_err(|e| PyRuntimeError::new_err(format!("FFmpeg error: {}", e)))?;
114
 
115
- if !output.success() {
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
- "-i", &silent_video_path,
 
128
  "-i", &audio_path,
129
- "-filter:v", &format!("setpts={}*PTS", 1.0 / speed_factor),
130
  "-c:v", "libx264",
131
- "-preset", "ultrafast",
 
 
 
132
  "-c:a", "aac",
133
- "-shortest",
134
- &final_video_path,
 
 
 
135
  ])
136
- .status()
137
- .map_err(|e| PyRuntimeError::new_err(format!("Failed to mux audio: {}", e)))?;
138
-
139
- if !status.success() {
140
- return Err(PyRuntimeError::new_err("Audio muxing failed"));
 
 
 
 
 
 
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 rust_video_gen(m: &Bound<PyModule>) -> PyResult<()> {
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
+ }