From da91c28eadf189e4c40a96a8be032cf1d85cd05d Mon Sep 17 00:00:00 2001 From: HuiNeng6 <3650306360@qq.com> Date: Wed, 25 Mar 2026 05:44:34 +0800 Subject: [PATCH 1/2] feat: add preview image rendering for Gaussian Splat - Add render_preview_images.py script to generate top-down and angled preview images - Modify run.py to render preview images after training (best-effort, non-fatal) - Update runner to upload preview images as domain artifacts - Update README to document new preview image outputs Closes #5 --- README.md | 2 + render_preview_images.py | 307 ++++++++++++++++++++++++++++++++++ run.py | 24 ++- server/rust/runner/src/lib.rs | 69 ++++++++ 4 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 render_preview_images.py diff --git a/README.md b/README.md index 60be944..493962c 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ python3 run.py \ │ ├── splat.ply │ ├── splat_rot.ply │ ├── splat_rot.splat # this is what needs to be uploaded to dmt +│ ├── preview_top.png # NEW: top-down preview image +│ ├── preview_angle.png # NEW: angled 3/4 view preview image │ └── splatfacto │ └── {splat torch model} ``` diff --git a/render_preview_images.py b/render_preview_images.py new file mode 100644 index 0000000..eaa83b5 --- /dev/null +++ b/render_preview_images.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +""" +Render preview images of the trained Gaussian Splat. + +This script generates two preview images: +1. Top-down view: camera directly above centroid, looking straight down +2. Angled view: camera at elevated corner (~45°), looking at centroid + +Usage: + python render_preview_images.py --splat_ply --config_yml --output_dir +""" + +import argparse +import json +import logging +import math +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Tuple + +import numpy as np + +logger = logging.getLogger("render_preview_images") + + +def setup_logger(level: str = "INFO"): + """Setup logging.""" + logging.basicConfig( + level=getattr(logging, level.upper()), + format="%(levelname)s - %(message)s", + handlers=[logging.StreamHandler(sys.stdout)] + ) + + +def read_ply_bounding_box(ply_path: Path) -> Tuple[np.ndarray, np.ndarray]: + """ + Read PLY file and compute bounding box. + + Returns: + Tuple of (centroid, extent) where extent is the full size along each axis. + """ + logger.info(f"Reading PLY file: {ply_path}") + + # Read PLY file header and vertices + vertices = [] + with open(ply_path, 'rb') as f: + # Read header + line = f.readline().decode('ascii').strip() + if line != 'ply': + raise ValueError(f"Invalid PLY file: expected 'ply', got '{line}'") + + format_line = None + num_vertices = 0 + properties = [] + + while True: + line = f.readline().decode('ascii').strip() + if line == 'end_header': + break + + parts = line.split() + if parts[0] == 'format': + format_line = line + elif parts[0] == 'element' and parts[1] == 'vertex': + num_vertices = int(parts[2]) + elif parts[0] == 'property': + properties.append(parts[-1]) # property name + + logger.info(f"PLY format: {format_line}, vertices: {num_vertices}") + + # Find x, y, z property indices + x_idx = properties.index('x') if 'x' in properties else 0 + y_idx = properties.index('y') if 'y' in properties else 1 + z_idx = properties.index('z') if 'z' in properties else 2 + + # Read vertex data + if 'binary' in format_line: + # Binary format + import struct + # Determine bytes per vertex based on properties + float_size = 4 # assume float32 + bytes_per_vertex = len(properties) * float_size + + for _ in range(num_vertices): + data = f.read(bytes_per_vertex) + values = struct.unpack(f'{len(properties)}f', data) + vertices.append([values[x_idx], values[y_idx], values[z_idx]]) + else: + # ASCII format + for _ in range(num_vertices): + line = f.readline().decode('ascii').strip() + values = [float(v) for v in line.split()] + vertices.append([values[x_idx], values[y_idx], values[z_idx]]) + + vertices = np.array(vertices) + + # Compute bounding box + min_coords = np.min(vertices, axis=0) + max_coords = np.max(vertices, axis=0) + centroid = (min_coords + max_coords) / 2 + extent = max_coords - min_coords + + logger.info(f"Bounding box: min={min_coords}, max={max_coords}") + logger.info(f"Centroid: {centroid}") + logger.info(f"Extent: {extent}") + + return centroid, extent + + +def generate_camera_path_topdown( + centroid: np.ndarray, + extent: np.ndarray, + output_path: Path, + fov: float = 60.0, + num_frames: int = 1 +) -> dict: + """ + Generate camera path JSON for top-down view. + + Camera placed directly above centroid, looking straight down. + """ + # Height sufficient to frame the full XZ extent + max_horizontal = max(extent[0], extent[2]) + height = max_horizontal * 1.5 # Add some margin + + camera_path = { + "keyframes": [], + "fov": fov, + "aspect_ratio": 1.0, # Square output + } + + # Single frame for top-down + camera_path["keyframes"].append({ + "matrix": [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], # Y and Z swapped for looking down + [0.0, -1.0, 0.0, 0.0], + [centroid[0], centroid[1] + height, centroid[2], 1.0] + ], + "fov": fov, + "aspect": 1.0 + }) + + with open(output_path, 'w') as f: + json.dump(camera_path, f, indent=2) + + logger.info(f"Generated top-down camera path: {output_path}") + return camera_path + + +def generate_camera_path_angle( + centroid: np.ndarray, + extent: np.ndarray, + output_path: Path, + fov: float = 60.0, + num_frames: int = 1 +) -> dict: + """ + Generate camera path JSON for angled 3/4 view. + + Camera placed at elevated corner (~45° above horizontal), looking at centroid. + """ + # Diagonal distance for corner position + max_extent = max(extent) + diagonal = np.sqrt(extent[0]**2 + extent[2]**2) + + # Camera position at corner with elevation + elevation_angle = math.radians(45) # 45 degrees above horizontal + distance = diagonal * 1.5 # Add margin + + # Position at one corner + camera_x = centroid[0] + distance * math.cos(elevation_angle) / math.sqrt(2) + camera_y = centroid[1] + distance * math.sin(elevation_angle) + camera_z = centroid[2] + distance * math.cos(elevation_angle) / math.sqrt(2) + + camera_path = { + "keyframes": [], + "fov": fov, + "aspect_ratio": 1.0, + } + + camera_path["keyframes"].append({ + "matrix": [ + [0.707, -0.408, 0.577, 0.0], # Approximate rotation matrix + [0.0, 0.816, 0.577, 0.0], + [-0.707, -0.408, 0.577, 0.0], + [camera_x, camera_y, camera_z, 1.0] + ], + "fov": fov, + "aspect": 1.0 + }) + + with open(output_path, 'w') as f: + json.dump(camera_path, f, indent=2) + + logger.info(f"Generated angled camera path: {output_path}") + return camera_path + + +def render_image( + config_path: Path, + camera_path_path: Path, + output_dir: Path, + output_name: str +) -> int: + """ + Render an image using nerfstudio's ns-render. + + Returns: + Exit code (0 for success) + """ + output_path = output_dir / output_name + + cmd = [ + "ns-render", + "gaussian-splat", + "--load-config", str(config_path), + "--camera-path-filename", str(camera_path_path), + "--output-path", str(output_path), + ] + + logger.info(f"Running ns-render: {' '.join(cmd)}") + + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + logger.error(f"ns-render failed: {result.stderr}") + else: + logger.info(f"Rendered image: {output_path}") + + return result.returncode + + +def main(): + parser = argparse.ArgumentParser(description="Render preview images of Gaussian Splat") + parser.add_argument("--splat_ply", type=Path, required=True, help="Path to splat.ply file") + parser.add_argument("--config_yml", type=Path, required=True, help="Path to nerfstudio config.yml") + parser.add_argument("--output_dir", type=Path, required=True, help="Output directory for preview images") + parser.add_argument("--log_level", type=str, default="INFO", help="Log level") + + args = parser.parse_args() + setup_logger(args.log_level) + + # Ensure output directory exists + args.output_dir.mkdir(parents=True, exist_ok=True) + + try: + # Step 1: Compute bounding box from PLY + centroid, extent = read_ply_bounding_box(args.splat_ply) + + # Step 2: Generate camera paths + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + + topdown_camera_path = tmpdir / "camera_topdown.json" + angle_camera_path = tmpdir / "camera_angle.json" + + generate_camera_path_topdown(centroid, extent, topdown_camera_path) + generate_camera_path_angle(centroid, extent, angle_camera_path) + + # Step 3: Render both views + # Top-down preview + exit_code = render_image( + args.config_yml, + topdown_camera_path, + args.output_dir, + "preview_top.png" + ) + + if exit_code != 0: + logger.warning("Failed to render top-down preview, continuing...") + + # Angled preview + exit_code = render_image( + args.config_yml, + angle_camera_path, + args.output_dir, + "preview_angle.png" + ) + + if exit_code != 0: + logger.warning("Failed to render angled preview, continuing...") + + # Check if at least one image was rendered + top_exists = (args.output_dir / "preview_top.png").exists() + angle_exists = (args.output_dir / "preview_angle.png").exists() + + if top_exists: + logger.info(f"Top-down preview saved: {args.output_dir / 'preview_top.png'}") + if angle_exists: + logger.info(f"Angled preview saved: {args.output_dir / 'preview_angle.png'}") + + if not top_exists and not angle_exists: + logger.error("No preview images were rendered successfully") + return 1 + + return 0 + + except Exception as e: + logger.exception(f"Failed to render preview images: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/run.py b/run.py index e49d601..e6b6a2e 100644 --- a/run.py +++ b/run.py @@ -203,5 +203,27 @@ def run_cmd(cmd: list): if exit_code != 0: logger.error("failed to convert splat .ply to .splat") sys.exit(exit_code) + + # Render preview images (best-effort, non-fatal) + logger.info("Rendering Preview Images") + try: + # Get the directory containing run.py for script paths + script_dir = Path(__file__).parent.resolve() + render_script = script_dir / "render_preview_images.py" + + if render_script.exists(): + preview_exit_code = run_python_script( + str(render_script), + "--splat_ply", args.job_root_path / "refined/splatter/splat.ply", + "--config_yml", args.job_root_path / "refined/splatter/splatfacto/config.yml", + "--output_dir", args.job_root_path / "refined/splatter", + "--log_level", args.log_level + ) + if preview_exit_code != 0: + logger.warning("Preview image rendering failed (non-fatal), continuing...") + else: + logger.warning(f"Preview rendering script not found: {render_script}") + except Exception as e: + logger.warning(f"Preview image rendering encountered error (non-fatal): {e}") - sys.exit(exit_code) \ No newline at end of file + sys.exit(0) \ No newline at end of file diff --git a/server/rust/runner/src/lib.rs b/server/rust/runner/src/lib.rs index 217ceec..4760523 100644 --- a/server/rust/runner/src/lib.rs +++ b/server/rust/runner/src/lib.rs @@ -799,11 +799,80 @@ impl compute_runner_api::Runner for HelloRunner { "uploaded": upload_key.as_str(), })) .await; + + // Upload preview images if they exist (best-effort, non-fatal) + let preview_top = job_root.join("refined").join("splatter").join("preview_top.png"); + let preview_angle = job_root.join("refined").join("splatter").join("preview_angle.png"); + let mut preview_uploaded = Vec::new(); + + if preview_top.exists() { + let preview_key = if let Some(suffix) = refined_suffix.as_deref().filter(|s| !s.is_empty()) { + if suffix.starts_with('_') { + format!("refined_splat_preview_top{suffix}") + } else { + format!("refined_splat_preview_top_{suffix}") + } + } else { + "refined_splat_preview_top".to_string() + }; + + match ctx.output + .put_domain_artifact(compute_runner_api::runner::DomainArtifactRequest { + rel_path: preview_key.as_str(), + name: preview_key.as_str(), + data_type: "splat_preview_image", + existing_id: None, + content: compute_runner_api::runner::DomainArtifactContent::File(&preview_top), + }) + .await + { + Ok(_) => { + preview_uploaded.push(preview_key.clone()); + info!(preview_key = %preview_key, "uploaded top-down preview image"); + } + Err(err) => { + warn!(preview_key = %preview_key, %err, "failed to upload top-down preview image (non-fatal)"); + } + } + } + + if preview_angle.exists() { + let preview_key = if let Some(suffix) = refined_suffix.as_deref().filter(|s| !s.is_empty()) { + if suffix.starts_with('_') { + format!("refined_splat_preview_angle{suffix}") + } else { + format!("refined_splat_preview_angle_{suffix}") + } + } else { + "refined_splat_preview_angle".to_string() + }; + + match ctx.output + .put_domain_artifact(compute_runner_api::runner::DomainArtifactRequest { + rel_path: preview_key.as_str(), + name: preview_key.as_str(), + data_type: "splat_preview_image", + existing_id: None, + content: compute_runner_api::runner::DomainArtifactContent::File(&preview_angle), + }) + .await + { + Ok(_) => { + preview_uploaded.push(preview_key.clone()); + info!(preview_key = %preview_key, "uploaded angled preview image"); + } + Err(err) => { + warn!(preview_key = %preview_key, %err, "failed to upload angled preview image (non-fatal)"); + } + } + } + ctx.ctrl .progress(json!({ "progress": 100, "stage": "complete", "status": "succeeded", + "preview_images": preview_uploaded, })) .await?; From 9b8025f11189a1071b02e931b49e85377df332fc Mon Sep 17 00:00:00 2001 From: HuiNeng6 <3650306360@qq.com> Date: Wed, 25 Mar 2026 05:48:38 +0800 Subject: [PATCH 2/2] feat: add preview video rendering for Gaussian Splat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add render_preview_video.py script to generate orbital preview video - Compute bounding box from splat.ply and generate 360° camera path - Render frames using ns-render and encode to MP4 via ffmpeg - Modify run.py to render preview video after training (best-effort) - Update runner to upload preview video as domain artifact - Update README to document new preview video output Closes #6 --- README.md | 1 + render_preview_video.py | 347 ++++++++++++++++++++++++++++++++++ run.py | 21 ++ server/rust/runner/src/lib.rs | 33 ++++ 4 files changed, 402 insertions(+) create mode 100644 render_preview_video.py diff --git a/README.md b/README.md index 493962c..5d4e2c1 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ python3 run.py \ │ ├── splat_rot.splat # this is what needs to be uploaded to dmt │ ├── preview_top.png # NEW: top-down preview image │ ├── preview_angle.png # NEW: angled 3/4 view preview image +│ ├── preview.mp4 # NEW: orbital preview video │ └── splatfacto │ └── {splat torch model} ``` diff --git a/render_preview_video.py b/render_preview_video.py new file mode 100644 index 0000000..d2ceacc --- /dev/null +++ b/render_preview_video.py @@ -0,0 +1,347 @@ +#!/usr/bin/env python3 +""" +Render a preview video of the trained Gaussian Splat. + +This script generates a 360° orbital video of the trained scene: +- Computes bounding box from splat.ply +- Generates an orbital camera path around the centroid +- Renders frames using nerfstudio's ns-render +- Encodes to MP4 using ffmpeg + +Usage: + python render_preview_video.py --splat_ply --config_yml --output_dir +""" + +import argparse +import json +import logging +import math +import subprocess +import sys +import tempfile +import shutil +from pathlib import Path +from typing import Tuple + +import numpy as np + +logger = logging.getLogger("render_preview_video") + + +def setup_logger(level: str = "INFO"): + """Setup logging.""" + logging.basicConfig( + level=getattr(logging, level.upper()), + format="%(levelname)s - %(message)s", + handlers=[logging.StreamHandler(sys.stdout)] + ) + + +def read_ply_bounding_box(ply_path: Path) -> Tuple[np.ndarray, np.ndarray]: + """ + Read PLY file and compute bounding box. + + Returns: + Tuple of (centroid, extent) where extent is the full size along each axis. + """ + logger.info(f"Reading PLY file: {ply_path}") + + vertices = [] + with open(ply_path, 'rb') as f: + # Read header + line = f.readline().decode('ascii').strip() + if line != 'ply': + raise ValueError(f"Invalid PLY file: expected 'ply', got '{line}'") + + format_line = None + num_vertices = 0 + properties = [] + + while True: + line = f.readline().decode('ascii').strip() + if line == 'end_header': + break + + parts = line.split() + if parts[0] == 'format': + format_line = line + elif parts[0] == 'element' and parts[1] == 'vertex': + num_vertices = int(parts[2]) + elif parts[0] == 'property': + properties.append(parts[-1]) + + logger.info(f"PLY format: {format_line}, vertices: {num_vertices}") + + # Find x, y, z property indices + x_idx = properties.index('x') if 'x' in properties else 0 + y_idx = properties.index('y') if 'y' in properties else 1 + z_idx = properties.index('z') if 'z' in properties else 2 + + # Read vertex data + if 'binary' in format_line: + import struct + float_size = 4 + bytes_per_vertex = len(properties) * float_size + + for _ in range(num_vertices): + data = f.read(bytes_per_vertex) + values = struct.unpack(f'{len(properties)}f', data) + vertices.append([values[x_idx], values[y_idx], values[z_idx]]) + else: + for _ in range(num_vertices): + line = f.readline().decode('ascii').strip() + values = [float(v) for v in line.split()] + vertices.append([values[x_idx], values[y_idx], values[z_idx]]) + + vertices = np.array(vertices) + + min_coords = np.min(vertices, axis=0) + max_coords = np.max(vertices, axis=0) + centroid = (min_coords + max_coords) / 2 + extent = max_coords - min_coords + + logger.info(f"Bounding box: min={min_coords}, max={max_coords}") + logger.info(f"Centroid: {centroid}") + logger.info(f"Extent: {extent}") + + return centroid, extent + + +def generate_orbital_camera_path( + centroid: np.ndarray, + extent: np.ndarray, + output_path: Path, + num_frames: int = 150, + fov: float = 60.0, + elevation_degrees: float = 35.0, + orbit_degrees: float = 360.0, + resolution: Tuple[int, int] = (1920, 1080) +) -> dict: + """ + Generate an orbital camera path JSON for ns-render. + + Args: + centroid: Scene centroid + extent: Scene extent (bounding box size) + output_path: Path to write camera path JSON + num_frames: Number of frames (determines video length at 30fps) + fov: Field of view in degrees + elevation_degrees: Camera elevation above horizontal (30-45° recommended) + orbit_degrees: Total orbit angle (270-360°) + resolution: Output resolution (width, height) + + Returns: + Camera path dictionary + """ + # Calculate camera distance to fit the scene + max_extent = max(extent[0], extent[2]) # Use XZ plane extent + diagonal = np.sqrt(extent[0]**2 + extent[2]**2) + + # Camera distance based on FOV and scene size + # Distance = (extent / 2) / tan(fov/2) + fov_rad = math.radians(fov) + distance_factor = 1.5 # Add margin + base_distance = (diagonal / 2) / math.tan(fov_rad / 2) * distance_factor + + # Account for elevation + elevation_rad = math.radians(elevation_degrees) + horizontal_distance = base_distance * math.cos(elevation_rad) + vertical_offset = base_distance * math.sin(elevation_rad) + + logger.info(f"Camera distance: {base_distance:.2f}, horizontal: {horizontal_distance:.2f}") + logger.info(f"Elevation: {elevation_degrees}°, vertical offset: {vertical_offset:.2f}") + + # Generate camera path + keyframes = [] + + for i in range(num_frames): + # Interpolate angle from 0 to orbit_degrees + t = i / max(num_frames - 1, 1) + angle_deg = t * orbit_degrees + angle_rad = math.radians(angle_deg) + + # Camera position on orbit + cam_x = centroid[0] + horizontal_distance * math.cos(angle_rad) + cam_z = centroid[2] + horizontal_distance * math.sin(angle_rad) + cam_y = centroid[1] + vertical_offset + + # Look direction (camera looks at centroid) + look_dir = centroid - np.array([cam_x, cam_y, cam_z]) + look_dir = look_dir / np.linalg.norm(look_dir) + + # Right vector (perpendicular to look direction, in horizontal plane) + world_up = np.array([0.0, 1.0, 0.0]) + right = np.cross(look_dir, world_up) + right = right / np.linalg.norm(right) + + # True up vector + up = np.cross(right, look_dir) + up = up / np.linalg.norm(up) + + # Construct camera-to-world matrix + # Column 0: right, Column 1: up, Column 2: -look_dir, Column 3: position + matrix = np.eye(4) + matrix[0, :3] = right + matrix[1, :3] = up + matrix[2, :3] = -look_dir + matrix[:3, 3] = [cam_x, cam_y, cam_z] + + keyframes.append({ + "matrix": matrix.tolist(), + "fov": fov, + "aspect": resolution[0] / resolution[1] + }) + + camera_path = { + "keyframes": keyframes, + "fov": fov, + "aspect_ratio": resolution[0] / resolution[1], + "seconds": num_frames / 30.0, # Assuming 30 fps + } + + with open(output_path, 'w') as f: + json.dump(camera_path, f, indent=2) + + logger.info(f"Generated orbital camera path with {num_frames} frames: {output_path}") + return camera_path + + +def render_video( + config_path: Path, + camera_path_path: Path, + output_dir: Path, + frames_dir: Path, + output_name: str = "preview.mp4", + resolution: Tuple[int, int] = (1920, 1080), + fps: int = 30 +) -> int: + """ + Render a video using nerfstudio's ns-render and ffmpeg. + + Returns: + Exit code (0 for success) + """ + # Step 1: Render frames using ns-render + frames_dir.mkdir(parents=True, exist_ok=True) + + render_cmd = [ + "ns-render", + "gaussian-splat", + "--load-config", str(config_path), + "--camera-path-filename", str(camera_path_path), + "--output-path", str(frames_dir), + ] + + logger.info(f"Running ns-render: {' '.join(render_cmd)}") + + render_result = subprocess.run(render_cmd, capture_output=True, text=True) + + if render_result.returncode != 0: + logger.error(f"ns-render failed: {render_result.stderr}") + return render_result.returncode + + logger.info(f"Rendered frames to: {frames_dir}") + + # Step 2: Encode frames to MP4 using ffmpeg + output_path = output_dir / output_name + + ffmpeg_cmd = [ + "ffmpeg", + "-y", # Overwrite output + "-framerate", str(fps), + "-i", str(frames_dir / "%05d.png"), + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + "-crf", "23", + "-movflags", "+faststart", + str(output_path) + ] + + logger.info(f"Running ffmpeg: {' '.join(ffmpeg_cmd)}") + + ffmpeg_result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True) + + if ffmpeg_result.returncode != 0: + logger.error(f"ffmpeg failed: {ffmpeg_result.stderr}") + return ffmpeg_result.returncode + + logger.info(f"Encoded video: {output_path}") + + return 0 + + +def main(): + parser = argparse.ArgumentParser(description="Render preview video of Gaussian Splat") + parser.add_argument("--splat_ply", type=Path, required=True, help="Path to splat.ply file") + parser.add_argument("--config_yml", type=Path, required=True, help="Path to nerfstudio config.yml") + parser.add_argument("--output_dir", type=Path, required=True, help="Output directory for preview video") + parser.add_argument("--num_frames", type=int, default=150, help="Number of frames (default: 150 = 5 sec at 30fps)") + parser.add_argument("--elevation", type=float, default=35.0, help="Camera elevation in degrees (default: 35)") + parser.add_argument("--orbit_degrees", type=float, default=360.0, help="Orbit angle in degrees (default: 360)") + parser.add_argument("--resolution", type=str, default="1920x1080", help="Resolution WxH (default: 1920x1080)") + parser.add_argument("--log_level", type=str, default="INFO", help="Log level") + + args = parser.parse_args() + setup_logger(args.log_level) + + # Parse resolution + try: + width, height = map(int, args.resolution.lower().split('x')) + resolution = (width, height) + except: + logger.warning(f"Invalid resolution '{args.resolution}', using 1920x1080") + resolution = (1920, 1080) + + # Ensure output directory exists + args.output_dir.mkdir(parents=True, exist_ok=True) + + try: + # Step 1: Compute bounding box from PLY + centroid, extent = read_ply_bounding_box(args.splat_ply) + + # Step 2: Render video + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + camera_path_file = tmpdir / "camera_path.json" + frames_dir = tmpdir / "frames" + + # Generate orbital camera path + generate_orbital_camera_path( + centroid, extent, camera_path_file, + num_frames=args.num_frames, + elevation_degrees=args.elevation, + orbit_degrees=args.orbit_degrees, + resolution=resolution + ) + + # Render and encode video + exit_code = render_video( + args.config_yml, + camera_path_file, + args.output_dir, + frames_dir, + output_name="preview.mp4", + resolution=resolution + ) + + if exit_code != 0: + logger.warning("Failed to render preview video") + return exit_code + + # Verify output + output_video = args.output_dir / "preview.mp4" + if output_video.exists(): + size_mb = output_video.stat().st_size / (1024 * 1024) + logger.info(f"Preview video saved: {output_video} ({size_mb:.1f} MB)") + return 0 + else: + logger.error("Preview video was not created") + return 1 + + except Exception as e: + logger.exception(f"Failed to render preview video: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/run.py b/run.py index e6b6a2e..11a0f1e 100644 --- a/run.py +++ b/run.py @@ -226,4 +226,25 @@ def run_cmd(cmd: list): except Exception as e: logger.warning(f"Preview image rendering encountered error (non-fatal): {e}") + # Render preview video (best-effort, non-fatal) + logger.info("Rendering Preview Video") + try: + script_dir = Path(__file__).parent.resolve() + video_script = script_dir / "render_preview_video.py" + + if video_script.exists(): + video_exit_code = run_python_script( + str(video_script), + "--splat_ply", args.job_root_path / "refined/splatter/splat.ply", + "--config_yml", args.job_root_path / "refined/splatter/splatfacto/config.yml", + "--output_dir", args.job_root_path / "refined/splatter", + "--log_level", args.log_level + ) + if video_exit_code != 0: + logger.warning("Preview video rendering failed (non-fatal), continuing...") + else: + logger.warning(f"Preview video rendering script not found: {video_script}") + except Exception as e: + logger.warning(f"Preview video rendering encountered error (non-fatal): {e}") + sys.exit(0) \ No newline at end of file diff --git a/server/rust/runner/src/lib.rs b/server/rust/runner/src/lib.rs index 4760523..f8fd743 100644 --- a/server/rust/runner/src/lib.rs +++ b/server/rust/runner/src/lib.rs @@ -867,6 +867,39 @@ impl compute_runner_api::Runner for HelloRunner { } } + // Upload preview video if it exists (best-effort, non-fatal) + let preview_video = job_root.join("refined").join("splatter").join("preview.mp4"); + + if preview_video.exists() { + let video_key = if let Some(suffix) = refined_suffix.as_deref().filter(|s| !s.is_empty()) { + if suffix.starts_with('_') { + format!("refined_splat_preview_video{suffix}") + } else { + format!("refined_splat_preview_video_{suffix}") + } + } else { + "refined_splat_preview_video".to_string() + }; + + match ctx.output + .put_domain_artifact(compute_runner_api::runner::DomainArtifactRequest { + rel_path: video_key.as_str(), + name: video_key.as_str(), + data_type: "splat_preview_video", + existing_id: None, + content: compute_runner_api::runner::DomainArtifactContent::File(&preview_video), + }) + .await + { + Ok(_) => { + info!(video_key = %video_key, "uploaded preview video"); + } + Err(err) => { + warn!(video_key = %video_key, %err, "failed to upload preview video (non-fatal)"); + } + } + } + ctx.ctrl .progress(json!({ "progress": 100,