// Schaeffler Turntable Animation job type for Flamenco 3.x // Pipeline: STEP -> STL (cadquery) -> Blender scene setup -> Blender -a render -> FFmpeg video // // Task flow: // 1. convert-step : STEP → STL via cadquery // 2. setup-scene : turntable_setup.py imports STL, applies materials/camera/animation, // saves a ready-to-render .blend to output_dir/scene.blend // 3. render-frames : blender --background scene.blend --python turntable_gpu_setup.py -a // Blender's native -a keeps GPU scene (BVH, textures) loaded for ALL // frames — no per-frame re-upload overhead. // 4. compose-video : FFmpeg encodes frame PNGs → MP4 const JOB_TYPE = { label: "Schaeffler Turntable", settings: [ { key: "step_path", type: "string", required: true, description: "Absolute path to STEP file" }, { key: "output_dir", type: "string", required: true, description: "Directory for rendered frames and final video" }, { key: "output_name", type: "string", required: true, default: "turntable", description: "Base name for output files" }, { key: "frame_count", type: "int32", default: 120, description: "Number of frames to render" }, { key: "fps", type: "int32", default: 30, description: "Frames per second for output video" }, { key: "turntable_degrees", type: "int32", default: 360, description: "Total rotation in degrees" }, { key: "width", type: "int32", default: 1920, description: "Output width in pixels" }, { key: "height", type: "int32", default: 1080, description: "Output height in pixels" }, { key: "engine", type: "string", default: "cycles", description: "Blender render engine: cycles or eevee" }, { key: "samples", type: "int32", default: 128, description: "Render samples" }, { key: "stl_quality", type: "string", default: "low", description: "STL mesh quality: low or high" }, { key: "part_colors_json", type: "string", default: "{}", description: "JSON dict mapping part names to hex colors" }, { key: "template_path", type: "string", default: "", description: "Path to .blend template file (empty = factory settings)" }, { key: "target_collection", type: "string", default: "Product", description: "Blender collection name to import geometry into" }, { key: "material_library_path", type: "string", default: "", description: "Path to material library .blend file" }, { key: "material_map_json", type: "string", default: "{}", description: "JSON dict mapping part names to material names" }, { key: "part_names_ordered_json", type: "string", default: "[]", description: "JSON array of STEP part names in solid order (for index-based matching)" }, { key: "lighting_only", type: "bool", default: false, description: "Use template only for World/HDRI lighting; always auto-frame with computed camera" }, { key: "cycles_device", type: "string", default: "auto", description: "Cycles compute device: auto (try GPU, fall back to CPU), gpu (force GPU), cpu (force CPU)" }, { key: "shadow_catcher", type: "bool", default: false, description: "Enable Shadowcatcher collection from template and position plane under product (Cycles only)" }, { key: "rotation_x", type: "float", default: 0.0, description: "Product rotation around X axis in degrees (render position)" }, { key: "rotation_y", type: "float", default: 0.0, description: "Product rotation around Y axis in degrees (render position)" }, { key: "rotation_z", type: "float", default: 0.0, description: "Product rotation around Z axis in degrees (render position)" }, { key: "turntable_axis", type: "string", default: "world_z", description: "Turntable rotation axis: world_z (default), world_x, or world_y" }, { key: "bg_color", type: "string", default: "", description: "Solid background hex color for compositing (e.g. #1a1a2e); empty = HDR visible as background" }, { key: "camera_orbit", type: "bool", default: true, description: "Rotate camera around product instead of rotating product (true = better GPU performance, BVH cached)" }, { key: "noise_threshold", type: "string", default: "", description: "Adaptive sampling noise threshold (empty = Blender default 0.01)" }, { key: "denoiser", type: "string", default: "", description: "Cycles denoiser: OPTIX, OPENIMAGEDENOISE, or empty for auto" }, { key: "denoising_input_passes", type: "string", default: "", description: "Denoising input passes: RGB, RGB_ALBEDO, RGB_ALBEDO_NORMAL, or empty for default" }, { key: "denoising_prefilter", type: "string", default: "", description: "Denoising prefilter: NONE, FAST, ACCURATE, or empty for default" }, { key: "denoising_quality", type: "string", default: "", description: "Denoising quality: HIGH, BALANCED, FAST, or empty for default (Blender 4.2+)" }, { key: "denoising_use_gpu", type: "string", default: "", description: "Route OIDN denoising through GPU: 1, 0, or empty for auto" }, ], }; function compileJob(job) { const settings = job.settings; // Cache STL next to STEP file: {step_dir}/{step_stem}_{quality}.stl const stepDir = settings.step_path.replace(/\/[^/]+$/, ""); const stepBasename = settings.step_path.replace(/.*\//, ""); const stepStem = stepBasename.replace(/\.[^.]+$/, ""); const stlPath = stepDir + "/" + stepStem + "_" + settings.stl_quality + ".stl"; const framesDir = settings.output_dir + "/frames"; const scenePath = settings.output_dir + "/scene.blend"; const videoPath = settings.output_dir + "/" + settings.output_name + ".mp4"; // Task 1: Convert STEP to STL const convertTask = author.Task("convert-step", "misc"); convertTask.addCommand(author.Command("exec", { exe: "{python}", args: [ "/opt/flamenco/scripts/convert_step.py", settings.step_path, stlPath, settings.stl_quality, ], })); job.addTask(convertTask); // Task 2: Setup Blender scene and save to scene.blend // turntable_setup.py imports the STL, assigns materials, sets up the // camera rig and pivot animation, configures the compositor (bg_color), // and saves the complete scene — ready for native -a rendering. const setupTask = author.Task("setup-scene", "blender"); setupTask.addCommand(author.Command("exec", { exe: "{blender}", args: [ "--background", "--python", "/opt/flamenco/scripts/turntable_setup.py", "--", stlPath, framesDir, String(settings.frame_count), String(settings.turntable_degrees), String(settings.width), String(settings.height), settings.engine, String(settings.samples), settings.part_colors_json, settings.template_path || "", settings.target_collection || "Product", settings.material_library_path || "", settings.material_map_json || "{}", settings.part_names_ordered_json || "[]", settings.lighting_only ? "1" : "0", settings.cycles_device || "gpu", settings.shadow_catcher ? "1" : "0", String(settings.rotation_x || 0), String(settings.rotation_y || 0), String(settings.rotation_z || 0), settings.turntable_axis || "world_z", settings.bg_color || "", settings.transparent_bg ? "1" : "0", scenePath, settings.camera_orbit !== false ? "1" : "0", settings.noise_threshold || "", settings.denoiser || "", settings.denoising_input_passes || "", settings.denoising_prefilter || "", settings.denoising_quality || "", settings.denoising_use_gpu || "", ], })); setupTask.addDependency(convertTask); job.addTask(setupTask); // Task 3: Render all frames using Blender's native -a (--render-anim) // turntable_gpu_setup.py re-applies GPU preferences (user-level, not stored // in .blend), then -a renders all frames in one process — GPU scene stays // loaded between frames, no per-frame BVH re-upload. const renderTask = author.Task("render-frames", "blender"); renderTask.addCommand(author.Command("exec", { exe: "{blender}", args: [ "--background", scenePath, "--python", "/opt/flamenco/scripts/turntable_gpu_setup.py", "-a", ], })); renderTask.addDependency(setupTask); job.addTask(renderTask); // Task 4: Compose video with FFmpeg // Blender writes transparent PNG frames (film_transparent=True) when bg_color is set. // FFmpeg composites them over a solid colour background using the lavfi color source. // Without bg_color, frames are opaque and encoded directly. const composeTask = author.Task("compose-video", "misc"); const bgHex = (settings.bg_color || "").replace(/^#/, ""); const ffmpegArgs = bgHex ? [ "-y", // Background: solid colour at video resolution and frame rate "-f", "lavfi", "-i", "color=c=0x" + bgHex + ":size=" + String(settings.width) + "x" + String(settings.height) + ":rate=" + String(settings.fps), // Foreground: transparent PNG frame sequence "-framerate", String(settings.fps), "-i", framesDir + "/frame_%04d.png", // Composite foreground over background "-filter_complex", "[0:v][1:v]overlay=0:0:shortest=1", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "medium", "-crf", "18", videoPath, ] : [ "-y", "-framerate", String(settings.fps), "-i", framesDir + "/frame_%04d.png", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "medium", "-crf", "18", videoPath, ]; composeTask.addCommand(author.Command("exec", { exe: "ffmpeg", args: ffmpegArgs, })); composeTask.addDependency(renderTask); job.addTask(composeTask); }