sreepathi-ravikumar commited on
Commit
26bdd3c
·
verified ·
1 Parent(s): f6a1d86

Update rust_highlight/src/lib.rs

Browse files
Files changed (1) hide show
  1. rust_highlight/src/lib.rs +328 -67
rust_highlight/src/lib.rs CHANGED
@@ -1,83 +1,344 @@
 
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
  }
 
1
+ // rust_highlight/src/lib.rs
2
  use pyo3::prelude::*;
3
+ use opencv::core::{Mat, Point, Scalar, CV_8UC3};
4
+ use opencv::imgproc::{circle, get_text_size, line, put_text, HersheyFonts, LineTypes};
5
+ use std::process::{Command, Stdio};
6
+ use std::io::Write;
7
+ use std::time::Instant;
8
  use std::path::Path;
9
+ use std::f64::consts::PI;
10
 
11
  #[pyfunction]
12
+ fn generate_video_clip(id: usize, text: String, audio_path: String, duration: f64, clips_dir: String) -> PyResult<Option<String>> {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  if !Path::new(&audio_path).exists() {
14
+ return Err(pyo3::exceptions::PyFileNotFoundError::new_err(format!("Audio not found: {}", audio_path)));
15
+ }
16
+
17
+ let skip_spaces = false;
18
+ let fps: f64 = 30.0;
19
+ let animation_frames_per_char: usize = 2;
20
+ let width: i32 = 1280;
21
+ let height: i32 = 720;
22
+ let margin_x: i32 = 40;
23
+ let margin_y: i32 = 60;
24
+ let line_spacing: i32 = 8;
25
+ let font = HersheyFonts::FONT_HERSHEY_SIMPLEX as i32;
26
+ let default_font_scale: f64 = 1.5;
27
+ let header_font_scale: f64 = 2.0;
28
+ let default_thickness: i32 = 2;
29
+ let header_thickness: i32 = 3;
30
+ let default_text_color = Scalar::new(0.0, 0.0, 0.0, 0.0); // BGR Black
31
+ let header_text_color = Scalar::new(255.0, 0.0, 0.0, 0.0); // BGR Blue
32
+ let bg_color = Scalar::new(255.0, 255.0, 255.0, 0.0); // BGR White
33
+ let ffmpeg_preset = "ultrafast";
34
+ let crf = "28";
35
+ // Pen settings
36
+ let pen_color = Scalar::new(0.0, 0.0, 255.0, 0.0); // BGR Red
37
+ let pen_tip_radius: i32 = 5;
38
+ let pen_length: i32 = 20;
39
+ let pen_thickness: i32 = 2;
40
+ let pen_base_angle: i32 = 45;
41
+ let pen_movement_amplitude: i32 = 10;
42
+
43
+ let silent_video_name = format!("silent_video{}.mp4", id);
44
+ let silent_video_path = format!("{}/{}", clips_dir, silent_video_name);
45
+ let final_video_name = format!("clip{}.mp4", id);
46
+ let final_video_path = format!("{}/{}", clips_dir, final_video_name);
47
+
48
+ // Wrap text
49
+ let text_area_width = width - 2 * margin_x;
50
+ let (wrapped_lines, line_styles) = wrap_text_cv(&text, font, default_font_scale, default_thickness, text_area_width);
51
+
52
+ let full_text = wrapped_lines.join("\n");
53
+ if full_text.is_empty() {
54
+ println!("No text to animate.");
55
+ return Ok(None);
56
+ }
57
+
58
+ let visible_indices: Vec<usize> = if skip_spaces {
59
+ full_text.char_indices().filter(|&(_, ch)| ch != ' ' && ch != '\n' && ch != '\t').map(|(i, _)| i).collect()
60
+ } else {
61
+ (0..full_text.len()).collect()
62
+ };
63
+
64
+ let total_glyphs = visible_indices.len();
65
+ println!("Wrapped lines: {} lines, total glyphs (counted): {}", wrapped_lines.len(), total_glyphs);
66
+ if total_glyphs == 0 {
67
+ println!("No text to animate.");
68
+ return Ok(None);
69
  }
70
 
71
+ // Pre-calc line heights and y_positions
72
+ let mut line_heights: Vec<i32> = Vec::new();
73
+ let mut y_positions: Vec<i32> = Vec::new();
74
+ let mut y = margin_y;
75
+ for (i, line) in wrapped_lines.iter().enumerate() {
76
+ let is_header = line_styles[i];
77
+ let font_scale = if is_header { header_font_scale } else { default_font_scale };
78
+ let thickness = if is_header { header_thickness } else { default_thickness };
79
+ let line_for_size = if line.is_empty() { "Ay".to_string() } else { line.clone() };
80
+ let mut base_line = 0;
81
+ let size = get_text_size(&line_for_size, font, font_scale, thickness, &mut base_line).unwrap();
82
+ let h = size.height;
83
+ let lh = h + base_line + line_spacing;
84
+ line_heights.push(lh);
85
+ y_positions.push(y);
86
+ y += lh;
87
  }
88
 
89
+ // FFmpeg for silent video
90
+ let mut child = Command::new("ffmpeg")
91
+ .arg("-y")
92
+ .arg("-f").arg("rawvideo")
93
+ .arg("-pix_fmt").arg("bgr24")
94
+ .arg("-s").arg(format!("{}x{}", width, height))
95
+ .arg("-r").arg(fps.to_string())
96
+ .arg("-i").arg("-")
97
+ .arg("-an")
98
+ .arg("-c:v").arg("libx264")
99
+ .arg("-preset").arg(ffmpeg_preset)
100
+ .arg("-crf").arg(crf)
101
+ .arg("-pix_fmt").arg("yuv420p")
102
+ .arg(&silent_video_path)
103
+ .stdin(Stdio::piped())
104
+ .spawn()
105
+ .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to spawn FFmpeg: {}", e)))?;
106
+
107
+ let mut stdin = child.stdin.take().unwrap();
108
+
109
+ let t0 = Instant::now();
110
+ let mut frames_sent: usize = 0;
111
+ let mut prev_visible_sub = String::new();
112
+ let mut last_pen_x: i32 = 0;
113
+ let mut last_pen_y: i32 = 0;
114
+
115
+ for &idx_in_full in visible_indices.iter() {
116
+ let visible_sub = &full_text[0..=idx_in_full];
117
+ if visible_sub != prev_visible_sub {
118
+ let lines: Vec<&str> = visible_sub.split('\n').collect();
119
+ let last_line = lines.last().unwrap();
120
+ let line_idx = lines.len() - 1;
121
+ let is_header = line_styles[line_idx];
122
+ let font_scale = if is_header { header_font_scale } else { default_font_scale };
123
+ let thickness = if is_header { header_thickness } else { default_thickness };
124
+ let mut base_line = 0;
125
+ let size = get_text_size(last_line, font, font_scale, thickness, &mut base_line).unwrap();
126
+ let w = size.width;
127
+ let h = size.height;
128
+ let pen_x = margin_x + w + 5;
129
+ let pen_y = y_positions[line_idx] + h / 2;
130
+ last_pen_x = pen_x;
131
+ last_pen_y = pen_y;
132
+
133
+ for anim_step in 0..animation_frames_per_char {
134
+ let anim_offset = (anim_step as f64) / (animation_frames_per_char as f64);
135
+ let frame_img = render_frame(
136
+ visible_sub,
137
+ pen_x,
138
+ pen_y,
139
+ anim_offset,
140
+ width as i32,
141
+ height as i32,
142
+ &wrapped_lines,
143
+ &line_styles,
144
+ &y_positions,
145
+ margin_x,
146
+ font,
147
+ default_font_scale,
148
+ header_font_scale,
149
+ default_thickness,
150
+ header_thickness,
151
+ default_text_color,
152
+ header_text_color,
153
+ bg_color,
154
+ pen_color,
155
+ pen_tip_radius,
156
+ pen_length,
157
+ pen_thickness,
158
+ pen_base_angle,
159
+ pen_movement_amplitude,
160
+ )?;
161
+ stdin.write_all(frame_img.data_bytes().unwrap())
162
+ .map_err(|e| pyo3::exceptions::PyIOError::new_err(format!("Failed to write frame: {}", e)))?;
163
+ frames_sent += 1;
164
+ }
165
+ prev_visible_sub = visible_sub.to_string();
166
+ }
167
+ }
168
+
169
+ drop(stdin);
170
+ child.wait().map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("FFmpeg failed: {}", e)))?;
171
+
172
+ let elapsed = t0.elapsed().as_secs_f64();
173
+ println!("Frames sent: {}, elapsed time: {:.3} seconds", frames_sent, elapsed);
174
+
175
+ if !Path::new(&silent_video_path).exists() {
176
+ println!("Silent video generation failed.");
177
+ return Ok(None);
178
  }
179
+
180
+ let rendered_duration = frames_sent as f64 / fps;
181
+ println!("Rendered video duration: {:.3}s, Audio duration: {:.3}s", rendered_duration, duration);
182
+
183
+ let multiplier = if rendered_duration > 0.0 && duration > 0.0 {
184
+ duration / rendered_duration
185
+ } else {
186
+ 1.0
187
+ };
188
+
189
+ // Combine with audio
190
+ let mut combine_child = Command::new("ffmpeg")
191
+ .arg("-y")
192
+ .arg("-i").arg(&silent_video_path)
193
+ .arg("-i").arg(&audio_path)
194
+ .arg("-filter:v").arg(format!("setpts=PTS*{}", multiplier))
195
+ .arg("-c:v").arg("libx264")
196
+ .arg("-preset").arg("ultrafast")
197
+ .arg("-crf").arg("28")
198
+ .arg("-pix_fmt").arg("yuv420p")
199
+ .arg("-c:a").arg("aac")
200
+ .arg("-shortest")
201
+ .arg("-map").arg("0:v:0")
202
+ .arg("-map").arg("1:a:0")
203
+ .arg(&final_video_path)
204
+ .spawn()
205
+ .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to spawn FFmpeg for combine: {}", e)))?;
206
+
207
+ combine_child.wait().map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("FFmpeg combine failed: {}", e)))?;
208
+
209
+ // Clean up
210
+ std::fs::remove_file(&silent_video_path)
211
+ .map_err(|e| pyo3::exceptions::PyIOError::new_err(format!("Failed to remove silent video: {}", e)))?;
212
+
213
+ Ok(Some(final_video_path))
214
+ }
215
+
216
+ fn wrap_text_cv(text: &str, font: i32, default_font_scale: f64, default_thickness: i32, max_width: i32) -> (Vec<String>, Vec<bool>) {
217
+ let mut wrapped_lines: Vec<String> = Vec::new();
218
+ let mut styles: Vec<bool> = Vec::new();
219
+ for para in text.lines() {
220
+ let trimmed = para.trim_start();
221
+ let mut is_header = trimmed.starts_with("###");
222
+ let mut para_str = para.to_string();
223
+ if is_header {
224
+ para_str = trimmed[3..].trim().to_string();
225
+ }
226
+ let font_scale = if is_header { header_font_scale } else { default_font_scale }; // Use globals or pass
227
+ let thickness = if is_header { header_thickness } else { default_thickness };
228
+ if para_str.is_empty() {
229
+ wrapped_lines.push("".to_string());
230
+ styles.push(false);
231
+ continue;
232
+ }
233
+ let words: Vec<&str> = para_str.split_whitespace().collect();
234
+ let mut cur = String::new();
235
+ for &w in &words {
236
+ let candidate = if cur.is_empty() { w.to_string() } else { format!("{} {}", cur, w) };
237
+ let mut base_line = 0;
238
+ let size = get_text_size(&candidate, font, font_scale, thickness, &mut base_line).unwrap();
239
+ if size.width <= max_width {
240
+ cur = candidate;
241
+ } else {
242
+ if !cur.is_empty() {
243
+ wrapped_lines.push(cur);
244
+ styles.push(is_header);
245
+ cur = String::new();
246
+ }
247
+ let mut base_line_single = 0;
248
+ let size_single = get_text_size(w, font, font_scale, thickness, &mut base_line_single).unwrap();
249
+ if size_single.width > max_width {
250
+ let mut chunk = String::new();
251
+ for ch in w.chars() {
252
+ let cand2 = format!("{}{}", chunk, ch);
253
+ let mut base_line_ch = 0;
254
+ let size_ch = get_text_size(&cand2, font, font_scale, thickness, &mut base_line_ch).unwrap();
255
+ if size_ch.width <= max_width {
256
+ chunk = cand2;
257
+ } else {
258
+ wrapped_lines.push(chunk);
259
+ styles.push(is_header);
260
+ chunk = ch.to_string();
261
+ }
262
+ }
263
+ if !chunk.is_empty() {
264
+ cur = chunk;
265
+ }
266
+ } else {
267
+ cur = w.to_string();
268
+ }
269
+ }
270
+ }
271
+ if !cur.is_empty() {
272
+ wrapped_lines.push(cur);
273
+ styles.push(is_header);
274
+ }
275
+ }
276
+ (wrapped_lines, styles)
277
+ }
278
+
279
+ fn render_frame(
280
+ visible_text: &str,
281
+ pen_x: i32,
282
+ pen_y: i32,
283
+ anim_offset: f64,
284
+ width: i32,
285
+ height: i32,
286
+ line_styles: &Vec<bool>,
287
+ y_positions: &Vec<i32>,
288
+ margin_x: i32,
289
+ font: i32,
290
+ default_font_scale: f64,
291
+ header_font_scale: f64,
292
+ default_thickness: i32,
293
+ header_thickness: i32,
294
+ default_text_color: Scalar,
295
+ header_text_color: Scalar,
296
+ bg_color: Scalar,
297
+ pen_color: Scalar,
298
+ pen_tip_radius: i32,
299
+ pen_length: i32,
300
+ pen_thickness: i32,
301
+ pen_base_angle: i32,
302
+ pen_movement_amplitude: i32,
303
+ ) -> PyResult<Mat> {
304
+ let mut img = Mat::new_rows_cols_with_default(height, width, CV_8UC3, bg_color)
305
+ .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to create Mat: {}", e)))?;
306
+
307
+ let lines: Vec<&str> = visible_text.split('\n').collect();
308
+ for (idx, &line) in lines.iter().enumerate() {
309
+ let is_header = line_styles[idx];
310
+ let font_scale = if is_header { header_font_scale } else { default_font_scale };
311
+ let thickness = if is_header { header_thickness } else { default_thickness };
312
+ let color = if is_header { header_text_color } else { default_text_color };
313
+ let x = margin_x;
314
+ let y = y_positions[idx];
315
+ let mut base_line = 0;
316
+ let size = get_text_size(line, font, font_scale, thickness, &mut base_line).unwrap();
317
+ let h = size.height;
318
+ let y_draw = y + h;
319
+ if !line.is_empty() {
320
+ put_text(&mut img, line, Point::new(x, y_draw), font, font_scale, color, thickness, LineTypes::LINE_AA as i32, false)
321
+ .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to put text: {}", e)))?;
322
+ }
323
+ }
324
+
325
+ if pen_x > 0 {
326
+ let offset_y = (pen_movement_amplitude as f64 * (anim_offset * PI).sin()) as i32;
327
+ let pen_tip_y = pen_y + offset_y;
328
+ let angle_rad = (pen_base_angle as f64).to_radians();
329
+ let pen_end_x = pen_x + (pen_length as f64 * angle_rad.cos()) as i32;
330
+ let pen_end_y = pen_tip_y - (pen_length as f64 * angle_rad.sin()) as i32;
331
+ line(&mut img, Point::new(pen_x, pen_tip_y), Point::new(pen_end_x, pen_end_y), pen_color, pen_thickness, LineTypes::LINE_8 as i32, 0)
332
+ .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to draw line: {}", e)))?;
333
+ circle(&mut img, Point::new(pen_x, pen_tip_y), pen_tip_radius, pen_color, -1, LineTypes::LINE_8 as i32, 0)
334
+ .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to draw circle: {}", e)))?;
335
+ }
336
+
337
+ Ok(img)
338
  }
339
 
340
  #[pymodule]
341
  fn rust_highlight(_py: Python, m: &PyModule) -> PyResult<()> {
342
+ m.add_function(wrap_pyfunction!(generate_video_clip, m)?)?;
343
  Ok(())
344
  }