From ef5bbe7198749b5a11091b943e440998235cecc4 Mon Sep 17 00:00:00 2001 From: Dubhghlas McLaughlin <103212704+mcdubhghlas@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:12:39 -0500 Subject: [PATCH 1/9] Compute Movie Writer output resolution in a reusable helper --- main/main.cpp | 15 +-------------- servers/movie_writer/movie_writer.cpp | 18 ++++++++++++++++++ servers/movie_writer/movie_writer.h | 5 +++++ 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/main/main.cpp b/main/main.cpp index 04b51d69e3b..d6337b8696a 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -4718,20 +4718,7 @@ int Main::start() { } if (movie_writer) { - Size2i movie_size = Size2i(GLOBAL_GET("display/window/size/viewport_width"), GLOBAL_GET("display/window/size/viewport_height")); - String stretch_mode = GLOBAL_GET("display/window/stretch/mode"); - if (stretch_mode != "viewport") { - // `canvas_items` and `disabled` modes use the window size override instead, - // which allows for higher resolution recording with 2D elements designed for a lower resolution. - const int window_width_override = GLOBAL_GET("display/window/size/window_width_override"); - if (window_width_override > 0) { - movie_size.width = window_width_override; - } - const int window_height_override = GLOBAL_GET("display/window/size/window_height_override"); - if (window_height_override > 0) { - movie_size.height = window_height_override; - } - } + const Size2i movie_size = MovieWriter::get_output_size(); movie_writer->begin(movie_size, fixed_fps, Engine::get_singleton()->get_write_movie_path()); } diff --git a/servers/movie_writer/movie_writer.cpp b/servers/movie_writer/movie_writer.cpp index aaeca072ad9..278a1c4d245 100644 --- a/servers/movie_writer/movie_writer.cpp +++ b/servers/movie_writer/movie_writer.cpp @@ -48,6 +48,24 @@ MovieWriter *MovieWriter::writers[MovieWriter::MAX_WRITERS]; uint32_t MovieWriter::writer_count = 0; +Size2i MovieWriter::get_output_size() { + Size2i movie_size = Size2i(GLOBAL_GET("display/window/size/viewport_width"), GLOBAL_GET("display/window/size/viewport_height")); + const String stretch_mode = GLOBAL_GET("display/window/stretch/mode"); + if (stretch_mode != "viewport") { + // `canvas_items` and `disabled` modes use the window size override instead, + // which allows for higher resolution recording with 2D elements designed for a lower resolution. + const int window_width_override = GLOBAL_GET("display/window/size/window_width_override"); + if (window_width_override > 0) { + movie_size.width = window_width_override; + } + const int window_height_override = GLOBAL_GET("display/window/size/window_height_override"); + if (window_height_override > 0) { + movie_size.height = window_height_override; + } + } + return movie_size; +} + void MovieWriter::add_writer(MovieWriter *p_writer) { ERR_FAIL_COND(writer_count == MAX_WRITERS); writers[writer_count++] = p_writer; diff --git a/servers/movie_writer/movie_writer.h b/servers/movie_writer/movie_writer.h index 8572b8c007b..66480024436 100644 --- a/servers/movie_writer/movie_writer.h +++ b/servers/movie_writer/movie_writer.h @@ -94,6 +94,11 @@ class MovieWriter : public Object { static void add_writer(MovieWriter *p_writer); static MovieWriter *find_writer_for_file(const String &p_file); + // The effective output resolution used when recording, derived from the project's + // viewport size and (when the stretch mode is not "viewport") the window size overrides. + // Single source of truth shared by the recorder and the editor camera preview. + static Size2i get_output_size(); + void begin(const Size2i &p_movie_size, uint32_t p_fps, const String &p_base_path); void add_frame(); From 0c91e757cc73d3566d50ca447e2cccbbb561597a Mon Sep 17 00:00:00 2001 From: Dubhghlas McLaughlin <103212704+mcdubhghlas@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:12:57 -0500 Subject: [PATCH 2/9] Add Movie Mode to preview Movie Writer framing in the 3D editor --- editor/scene/3d/node_3d_editor_plugin.cpp | 150 +++++++++++++++++++--- editor/scene/3d/node_3d_editor_plugin.h | 7 + 2 files changed, 138 insertions(+), 19 deletions(-) diff --git a/editor/scene/3d/node_3d_editor_plugin.cpp b/editor/scene/3d/node_3d_editor_plugin.cpp index c6cb21dc22c..81e49595d60 100644 --- a/editor/scene/3d/node_3d_editor_plugin.cpp +++ b/editor/scene/3d/node_3d_editor_plugin.cpp @@ -110,6 +110,7 @@ #include "scene/resources/3d/sky_material.h" #include "scene/resources/packed_scene.h" #include "scene/resources/surface_tool.h" +#include "servers/movie_writer/movie_writer.h" constexpr real_t DISTANCE_DEFAULT = 4; @@ -3119,6 +3120,7 @@ void Node3DEditorViewport::_notification(int p_what) { case NOTIFICATION_RESIZED: { callable_mp(this, &Node3DEditorViewport::update_transform_gizmo_view).call_deferred(); + _update_movie_preview_size(); } break; case NOTIFICATION_PROCESS: { @@ -3568,31 +3570,70 @@ void Node3DEditorViewport::_draw() { Math::round(2 * EDSCALE)); } if (previewing) { - Size2 ss = Size2(GLOBAL_GET("display/window/size/viewport_width"), GLOBAL_GET("display/window/size/viewport_height")); - float aspect = ss.aspect(); - Size2 s = get_size(); + const Size2 s = get_size(); + + if (movie_mode) { + // `movie_preview_vp` renders the previewed camera through a viewport with Movie + // Writer's output aspect, so Camera3D's keep-aspect behavior matches the recorded + // framing (same framing, not every rendering property) for any panel/target aspect. + // Use the preview viewport's actual integer size as the displayed frame so the + // texture maps 1:1 into the panel (no rescale); _update_movie_preview_size() already + // fits it inside the panel. Fall back to an aspect-fit before it has been sized. + Rect2 frame_rect; + if (movie_preview_vp && movie_preview_vp->get_size() != Size2i()) { + frame_rect.size = movie_preview_vp->get_size(); + } else { + const float aspect = Size2(MovieWriter::get_output_size()).aspect(); + if (s.aspect() > aspect) { + frame_rect.size = Size2(s.height * aspect, s.height); + } else { + frame_rect.size = Size2(s.width, s.width / aspect); + } + } + frame_rect.position = ((s - frame_rect.size) * 0.5).floor(); + frame_rect = Rect2(Vector2(), s).intersection(frame_rect); - Rect2 draw_rect; + if (movie_preview_vp) { + const Ref frame_tex = movie_preview_vp->get_texture(); + if (frame_tex.is_valid()) { + surface->draw_texture_rect(frame_tex, frame_rect, false); + } + } - switch (previewing->get_keep_aspect_mode()) { - case Camera3D::KEEP_WIDTH: { - draw_rect.size = Size2(s.width, s.width / aspect); - draw_rect.position.x = 0; - draw_rect.position.y = (s.height - draw_rect.size.y) * 0.5; + // Fill the area outside the Movie Writer output aspect ratio with opaque black. + const Color letterbox_color = Color(0, 0, 0, 1); + const real_t rect_right = frame_rect.position.x + frame_rect.size.x; + const real_t rect_bottom = frame_rect.position.y + frame_rect.size.y; + surface->draw_rect(Rect2(0, 0, s.width, frame_rect.position.y), letterbox_color, true); + surface->draw_rect(Rect2(0, rect_bottom, s.width, s.height - rect_bottom), letterbox_color, true); + surface->draw_rect(Rect2(0, frame_rect.position.y, frame_rect.position.x, frame_rect.size.y), letterbox_color, true); + surface->draw_rect(Rect2(rect_right, frame_rect.position.y, s.width - rect_right, frame_rect.size.y), letterbox_color, true); - } break; - case Camera3D::KEEP_HEIGHT: { - draw_rect.size = Size2(s.height * aspect, s.height); - draw_rect.position.y = 0; - draw_rect.position.x = (s.width - draw_rect.size.x) * 0.5; + surface->draw_rect(frame_rect, Color(0.6, 0.6, 0.1, 0.5), false, Math::round(2 * EDSCALE)); + } else { + // Historical preview outline: the project viewport aspect region within the + // full-panel camera preview, using the camera's keep-aspect mode. + const Size2 ss = Size2(GLOBAL_GET("display/window/size/viewport_width"), GLOBAL_GET("display/window/size/viewport_height")); + const float aspect = ss.aspect(); + + Rect2 draw_rect; + switch (previewing->get_keep_aspect_mode()) { + case Camera3D::KEEP_WIDTH: { + draw_rect.size = Size2(s.width, s.width / aspect); + draw_rect.position.x = 0; + draw_rect.position.y = (s.height - draw_rect.size.y) * 0.5; + } break; + case Camera3D::KEEP_HEIGHT: { + draw_rect.size = Size2(s.height * aspect, s.height); + draw_rect.position.y = 0; + draw_rect.position.x = (s.width - draw_rect.size.x) * 0.5; + } break; + } - } break; + draw_rect = Rect2(Vector2(), s).intersection(draw_rect); + surface->draw_rect(draw_rect, Color(0.6, 0.6, 0.1, 0.5), false, Math::round(2 * EDSCALE)); } - draw_rect = Rect2(Vector2(), s).intersection(draw_rect); - - surface->draw_rect(draw_rect, Color(0.6, 0.6, 0.1, 0.5), false, Math::round(2 * EDSCALE)); - } else { if (zoom_indicator_delay > 0.0) { if (is_freelook_active()) { @@ -3900,6 +3941,12 @@ void Node3DEditorViewport::_menu_option(int p_option) { } } } break; + case VIEW_MOVIE_MODE: { + int idx = view_display_menu->get_popup()->get_item_index(VIEW_MOVIE_MODE); + movie_mode = !view_display_menu->get_popup()->is_item_checked(idx); + view_display_menu->get_popup()->set_item_checked(idx, movie_mode); + _update_movie_preview(); + } break; case VIEW_GIZMOS: { int idx = view_display_menu->get_popup()->get_item_index(VIEW_GIZMOS); bool current = view_display_menu->get_popup()->is_item_checked(idx); @@ -4185,6 +4232,7 @@ void Node3DEditorViewport::_toggle_camera_preview(bool p_activate) { if (!preview) { preview_camera->hide(); } + _update_movie_preview(); surface->queue_redraw(); } else { @@ -4192,10 +4240,58 @@ void Node3DEditorViewport::_toggle_camera_preview(bool p_activate) { previewing->connect(SceneStringName(tree_exiting), callable_mp(this, &Node3DEditorViewport::_preview_exited_scene)); previewing->connect(CoreStringName(property_list_changed), callable_mp(this, &Node3DEditorViewport::_preview_camera_property_changed)); RS::get_singleton()->viewport_attach_camera(viewport->get_viewport_rid(), preview->get_camera()); //replace + _update_movie_preview(); surface->queue_redraw(); } } +void Node3DEditorViewport::_update_movie_preview() { + // When previewing a camera with Movie Mode on, render that camera through a dedicated + // SubViewport with Movie Writer's output aspect (sized to the displayed frame, see + // _update_movie_preview_size()) so Camera3D's keep-aspect behavior matches the recorded + // framing, for both keep-aspect modes and whether the panel is wider or narrower than output. + const bool active = previewing != nullptr && movie_mode; + if (active && !movie_preview_vp) { + movie_preview_vp = memnew(SubViewport); + movie_preview_vp->set_disable_input(true); + add_child(movie_preview_vp); + } + if (movie_preview_vp) { + if (active) { + movie_preview_vp->set_world_3d(viewport->find_world_3d()); + movie_preview_vp->set_update_mode(SubViewport::UPDATE_ALWAYS); + RS::get_singleton()->viewport_attach_camera(movie_preview_vp->get_viewport_rid(), previewing->get_camera()); + _update_movie_preview_size(); + } else { + RS::get_singleton()->viewport_attach_camera(movie_preview_vp->get_viewport_rid(), RID()); + movie_preview_vp->set_update_mode(SubViewport::UPDATE_DISABLED); + } + } + surface->queue_redraw(); +} + +void Node3DEditorViewport::_update_movie_preview_size() { + // Render only at the displayed frame size (preserving Movie Writer's output aspect), not at + // the full output resolution: the framing depends only on the aspect, so this avoids a + // second 4K/8K render per frame just to show it letterboxed in the panel. + if (!movie_preview_vp || previewing == nullptr || !movie_mode) { + return; + } + const Size2 s = get_size(); + const float output_aspect = Size2(MovieWriter::get_output_size()).aspect(); + Size2i preview_size; + if (s.aspect() > output_aspect) { + preview_size = Size2i(Math::round(s.height * output_aspect), Math::round(s.height)); + } else { + preview_size = Size2i(Math::round(s.width), Math::round(s.width / output_aspect)); + } + preview_size.x = MAX(preview_size.x, 1); + preview_size.y = MAX(preview_size.y, 1); + if (movie_preview_vp->get_size() != preview_size) { + movie_preview_vp->set_size(preview_size); + } +} + void Node3DEditorViewport::_toggle_cinema_preview(bool p_activate) { previewing_cinema = p_activate; _update_navigation_controls_visibility(); @@ -4215,6 +4311,7 @@ void Node3DEditorViewport::_toggle_cinema_preview(bool p_activate) { preview_camera->show(); } view_display_menu->show(); + _update_movie_preview(); surface->queue_redraw(); } } @@ -4461,6 +4558,12 @@ void Node3DEditorViewport::set_state(const Dictionary &p_state) { surface->queue_redraw(); } } + if (p_state.has("movie_mode")) { + movie_mode = p_state["movie_mode"]; + + int idx = view_display_menu->get_popup()->get_item_index(VIEW_MOVIE_MODE); + view_display_menu->get_popup()->set_item_checked(idx, movie_mode); + } if (preview_camera->is_connected(SceneStringName(toggled), callable_mp(this, &Node3DEditorViewport::_toggle_camera_preview))) { preview_camera->disconnect(SceneStringName(toggled), callable_mp(this, &Node3DEditorViewport::_toggle_camera_preview)); @@ -4479,6 +4582,9 @@ void Node3DEditorViewport::set_state(const Dictionary &p_state) { } } preview_camera->connect(SceneStringName(toggled), callable_mp(this, &Node3DEditorViewport::_toggle_camera_preview)); + + // Reconcile the Movie Mode preview now that both `movie_mode` and `previewing` are restored. + _update_movie_preview(); } Dictionary Node3DEditorViewport::get_state() const { @@ -4518,6 +4624,7 @@ Dictionary Node3DEditorViewport::get_state() const { d["frame_time"] = view_display_menu->get_popup()->is_item_checked(view_display_menu->get_popup()->get_item_index(VIEW_FRAME_TIME)); d["half_res"] = view_display_menu->get_popup()->is_item_checked(view_display_menu->get_popup()->get_item_index(VIEW_HALF_RESOLUTION)); d["cinematic_preview"] = view_display_menu->get_popup()->is_item_checked(view_display_menu->get_popup()->get_item_index(VIEW_CINEMATIC_PREVIEW)); + d["movie_mode"] = view_display_menu->get_popup()->is_item_checked(view_display_menu->get_popup()->get_item_index(VIEW_MOVIE_MODE)); if (previewing) { d["previewing"] = EditorNode::get_singleton()->get_edited_scene()->get_path_to(previewing); } @@ -5741,6 +5848,10 @@ Node3DEditorViewport::Node3DEditorViewport(Node3DEditor *p_spatial_editor, int p add_child(surface); surface->set_anchors_and_offsets_preset(Control::PRESET_FULL_RECT); surface->set_clip_contents(true); + + // Keep the Movie Mode preview render in sync with the project's Movie Writer output + // resolution (panel resizing is handled in NOTIFICATION_RESIZED). + ProjectSettings::get_singleton()->connect("settings_changed", callable_mp(this, &Node3DEditorViewport::_update_movie_preview)); camera = memnew(Camera3D); camera->set_disable_gizmos(true); camera->set_cull_mask(((1 << 20) - 1) | (1 << (GIZMO_BASE_LAYER + p_index)) | (1 << GIZMO_EDIT_LAYER) | (1 << GIZMO_GRID_LAYER) | (1 << MISC_TOOL_LAYER)); @@ -5867,6 +5978,7 @@ Node3DEditorViewport::Node3DEditorViewport(Node3DEditor *p_spatial_editor, int p view_display_menu->get_popup()->add_separator(); view_display_menu->get_popup()->add_check_shortcut(ED_SHORTCUT("spatial_editor/view_cinematic_preview", TTRC("Cinematic Preview")), VIEW_CINEMATIC_PREVIEW); + view_display_menu->get_popup()->add_check_shortcut(ED_SHORTCUT("spatial_editor/view_movie_mode", TTRC("Movie Mode")), VIEW_MOVIE_MODE); view_display_menu->get_popup()->add_separator(); view_display_menu->get_popup()->add_shortcut(ED_GET_SHORTCUT("spatial_editor/focus_origin"), VIEW_CENTER_TO_ORIGIN); diff --git a/editor/scene/3d/node_3d_editor_plugin.h b/editor/scene/3d/node_3d_editor_plugin.h index 5502434ad99..f881a1e129c 100644 --- a/editor/scene/3d/node_3d_editor_plugin.h +++ b/editor/scene/3d/node_3d_editor_plugin.h @@ -175,6 +175,7 @@ class Node3DEditorViewport : public Control { VIEW_LOCK_ROTATION, VIEW_CINEMATIC_PREVIEW, + VIEW_MOVIE_MODE, VIEW_AUTO_ORTHOGONAL, VIEW_MAX }; @@ -492,10 +493,16 @@ class Node3DEditorViewport : public Control { bool previewing_camera = false; bool previewing_cinema = false; + bool movie_mode = false; + // Renders the previewed camera at Movie Writer's output aspect so the framing shown in + // Movie Mode matches the recorded output regardless of editor panel/target aspect. + SubViewport *movie_preview_vp = nullptr; bool _is_node_locked(const Node *p_node) const; void _preview_exited_scene(); void _preview_camera_property_changed(); void _update_centered_labels(); + void _update_movie_preview(); + void _update_movie_preview_size(); void _toggle_camera_preview(bool); void _toggle_cinema_preview(bool); void _init_gizmo_instance(int p_idx); From 233f98839dda3ce244dc051bc35a0bb399221a90 Mon Sep 17 00:00:00 2001 From: Dubhghlas McLaughlin <103212704+mcdubhghlas@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:00:03 -0500 Subject: [PATCH 3/9] Preserve alpha through FSR and TAA. --- .../renderer_rd/shaders/effects/fsr_upscale.glsl | 14 ++++++++++---- .../renderer_rd/shaders/effects/taa_resolve.glsl | 4 +++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/servers/rendering/renderer_rd/shaders/effects/fsr_upscale.glsl b/servers/rendering/renderer_rd/shaders/effects/fsr_upscale.glsl index c0ec39a9892..0345b2e5568 100644 --- a/servers/rendering/renderer_rd/shaders/effects/fsr_upscale.glsl +++ b/servers/rendering/renderer_rd/shaders/effects/fsr_upscale.glsl @@ -116,33 +116,39 @@ void FsrRcasInputH(inout AH1 r, inout AH1 g, inout AH1 b) {} #include "thirdparty/amd-fsr/ffx_fsr1.h" void fsr_easu_pass(AU2 pos) { + // EASU only upscales RGB; upscale alpha with a bilinear sample at the same position + // so transparent viewport backgrounds survive FSR. + AF2 alpha_uv = (AF2(pos) + AF2(0.5, 0.5)) / AF2(params.upscaled_width, params.upscaled_height); + AF1 alpha = textureLod(source_image, alpha_uv, 0.0).a; #ifdef MODE_FSR_UPSCALE_NORMAL AH3 Gamma2Color = AH3(0, 0, 0); FsrEasuH(Gamma2Color, pos, Const0, Const1, Const2, Const3); - imageStore(fsr_image, ASU2(pos), AH4(Gamma2Color, 1)); + imageStore(fsr_image, ASU2(pos), AH4(Gamma2Color, AH1(alpha))); #else AF3 Gamma2Color = AF3(0, 0, 0); FsrEasuF(Gamma2Color, pos, Const0, Const1, Const2, Const3); - imageStore(fsr_image, ASU2(pos), AF4(Gamma2Color, 1)); + imageStore(fsr_image, ASU2(pos), AF4(Gamma2Color, alpha)); #endif } void fsr_rcas_pass(AU2 pos) { + // RCAS is 1:1 with its input; carry the input alpha through unchanged. + AF1 alpha = texelFetch(source_image, ASU2(pos), 0).a; #ifdef MODE_FSR_UPSCALE_NORMAL AH3 Gamma2Color = AH3(0, 0, 0); FsrRcasH(Gamma2Color.r, Gamma2Color.g, Gamma2Color.b, pos, Const0); - imageStore(fsr_image, ASU2(pos), AH4(Gamma2Color, 1)); + imageStore(fsr_image, ASU2(pos), AH4(Gamma2Color, AH1(alpha))); #else AF3 Gamma2Color = AF3(0, 0, 0); FsrRcasF(Gamma2Color.r, Gamma2Color.g, Gamma2Color.b, pos, Const0); - imageStore(fsr_image, ASU2(pos), AF4(Gamma2Color, 1)); + imageStore(fsr_image, ASU2(pos), AF4(Gamma2Color, alpha)); #endif } diff --git a/servers/rendering/renderer_rd/shaders/effects/taa_resolve.glsl b/servers/rendering/renderer_rd/shaders/effects/taa_resolve.glsl index 71e8d945ccd..5668ddde7a2 100644 --- a/servers/rendering/renderer_rd/shaders/effects/taa_resolve.glsl +++ b/servers/rendering/renderer_rd/shaders/effects/taa_resolve.glsl @@ -377,5 +377,7 @@ void main() { const vec2 uv = (gl_GlobalInvocationID.xy + 0.5f) / params.resolution; vec3 result = temporal_antialiasing(pos_group_top_left, pos_group, pos_screen, uv, history_buffer); - imageStore(output_buffer, ivec2(gl_GlobalInvocationID.xy), vec4(result, 1.0)); + // Carry the current frame's alpha through so transparent viewport backgrounds survive TAA. + float alpha = imageLoad(color_buffer, ivec2(gl_GlobalInvocationID.xy)).a; + imageStore(output_buffer, ivec2(gl_GlobalInvocationID.xy), vec4(result, alpha)); } From fe47a637f00c8fc834ec22c4004d8aedd21578a2 Mon Sep 17 00:00:00 2001 From: Dubhghlas McLaughlin <103212704+mcdubhghlas@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:03:42 -0500 Subject: [PATCH 4/9] MovieWriter: Add 16-bit and 32-bit EXR sequence output --- servers/movie_writer/movie_writer.cpp | 6 +- servers/movie_writer/movie_writer.h | 6 + servers/movie_writer/movie_writer_exr.cpp | 193 ++++++++++++++++++++++ servers/movie_writer/movie_writer_exr.h | 78 +++++++++ servers/register_server_types.cpp | 19 +++ 5 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 servers/movie_writer/movie_writer_exr.cpp create mode 100644 servers/movie_writer/movie_writer_exr.h diff --git a/servers/movie_writer/movie_writer.cpp b/servers/movie_writer/movie_writer.cpp index 278a1c4d245..36fe44dc801 100644 --- a/servers/movie_writer/movie_writer.cpp +++ b/servers/movie_writer/movie_writer.cpp @@ -175,6 +175,8 @@ void MovieWriter::_bind_methods() { GLOBAL_DEF(PropertyInfo(Variant::FLOAT, "editor/movie_writer/ogv/audio_quality", PROPERTY_HINT_RANGE, "-0.1,1.0,0.01"), 0.5); GLOBAL_DEF(PropertyInfo(Variant::INT, "editor/movie_writer/ogv/encoding_speed", PROPERTY_HINT_ENUM, "Fastest (Lowest Efficiency):4,Fast (Low Efficiency):3,Slow (High Efficiency):2,Slowest (Highest Efficiency):1"), 4); GLOBAL_DEF(PropertyInfo(Variant::INT, "editor/movie_writer/ogv/keyframe_interval", PROPERTY_HINT_RANGE, "1,1024,1"), 64); + // EXR (.exr) output channel storage. Needs "rendering/viewport/hdr_2d" for the added precision that avoids banding. + GLOBAL_DEF(PropertyInfo(Variant::INT, "editor/movie_writer/exr/bit_depth", PROPERTY_HINT_ENUM, "16-bit (Half Float):0,32-bit (Full Float):1"), 0); // Used by the editor. GLOBAL_DEF_BASIC("editor/movie_writer/movie_file", ""); @@ -251,7 +253,9 @@ void MovieWriter::add_frame() { vp_tex->resize(movie_size.width, movie_size.height, Image::INTERPOLATE_BILINEAR); } - if (RenderingServer::get_singleton()->viewport_is_using_hdr_2d(main_vp_rid)) { + if (RenderingServer::get_singleton()->viewport_is_using_hdr_2d(main_vp_rid) && !wants_float_output()) { + // Standard 8-bit writers convert the floating-point viewport frame to 8-bit sRGB. + // Float writers (e.g. EXR) keep the linear floating-point frame to avoid banding. vp_tex->convert(Image::FORMAT_RGBA8); vp_tex->linear_to_srgb(); } diff --git a/servers/movie_writer/movie_writer.h b/servers/movie_writer/movie_writer.h index 66480024436..d82032b9317 100644 --- a/servers/movie_writer/movie_writer.h +++ b/servers/movie_writer/movie_writer.h @@ -71,6 +71,12 @@ class MovieWriter : public Object { virtual uint32_t get_audio_mix_rate() const; virtual AudioServer::SpeakerMode get_audio_speaker_mode() const; + // Writers that output floating-point / deep-color frames (e.g. EXR) return true so add_frame() + // keeps the linear float image instead of converting it to 8-bit sRGB. Note this is + // display-referred deep color (more bits of precision to avoid banding), not scene-referred + // HDR: the frame is still the tonemapped [0,1] output, just stored at higher precision. + virtual bool wants_float_output() const { return false; } + virtual Error write_begin(const Size2i &p_movie_size, uint32_t p_fps, const String &p_base_path); virtual Error write_frame(const Ref &p_image, const int32_t *p_audio_data); virtual void write_end(); diff --git a/servers/movie_writer/movie_writer_exr.cpp b/servers/movie_writer/movie_writer_exr.cpp new file mode 100644 index 00000000000..075eb96976b --- /dev/null +++ b/servers/movie_writer/movie_writer_exr.cpp @@ -0,0 +1,193 @@ +/**************************************************************************/ +/* movie_writer_exr.cpp */ +/**************************************************************************/ +/* This file is part of: */ +/* REDOT ENGINE */ +/* https://redotengine.org */ +/**************************************************************************/ +/* Copyright (c) 2024-present Redot Engine contributors */ +/* (see REDOT_AUTHORS.md) */ +/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */ +/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/**************************************************************************/ + +#include "movie_writer_exr.h" + +#include "core/config/project_settings.h" +#include "core/io/dir_access.h" +#include "servers/display/display_server.h" +#include "servers/rendering/rendering_server.h" + +uint32_t MovieWriterEXR::get_audio_mix_rate() const { + return mix_rate; +} + +AudioServer::SpeakerMode MovieWriterEXR::get_audio_speaker_mode() const { + return speaker_mode; +} + +void MovieWriterEXR::get_supported_extensions(List *r_extensions) const { + r_extensions->push_back("exr"); +} + +bool MovieWriterEXR::handles_file(const String &p_path) const { + return p_path.get_extension().to_lower() == "exr"; +} + +String MovieWriterEXR::zeros_str(uint32_t p_index) { + char zeros[MAX_TRAILING_ZEROS + 1]; + for (uint32_t i = 0; i < MAX_TRAILING_ZEROS; i++) { + uint32_t idx = MAX_TRAILING_ZEROS - i - 1; + uint32_t digit = (p_index / uint32_t(Math::pow(double(10), double(idx)))) % 10; + zeros[i] = '0' + digit; + } + zeros[MAX_TRAILING_ZEROS] = 0; + return zeros; +} + +Error MovieWriterEXR::write_begin(const Size2i &p_movie_size, uint32_t p_fps, const String &p_base_path) { + base_path = p_base_path.get_basename(); + if (base_path.is_relative_path()) { + base_path = "res://" + base_path; + } + + frame_count = 0; + full_float = int(GLOBAL_GET("editor/movie_writer/exr/bit_depth")) == 1; + + // The captured frame is only floating-point (and thus band-free) when the viewport renders at + // floating-point precision, controlled by rendering/viewport/hdr_2d. + const RID main_vp = RenderingServer::get_singleton()->viewport_find_from_screen_attachment(DisplayServer::MAIN_WINDOW_ID); + if (main_vp.is_valid() && !RenderingServer::get_singleton()->viewport_is_using_hdr_2d(main_vp)) { + WARN_PRINT("MovieWriter: EXR output works best with 'rendering/viewport/hdr_2d' enabled; without it the frame is captured at 8-bit precision and will still band."); + } + + { + // Remove existing frames before writing anew. + Ref d = DirAccess::open(base_path.get_base_dir()); + ERR_FAIL_COND_V(d.is_null(), FAILED); + + String file = base_path.get_file(); + uint32_t idx = 0; + while (true) { + String path = file + zeros_str(idx) + ".exr"; + if (d->remove(path) != OK) { + break; + } + idx++; + } + } + + f_wav = FileAccess::open(base_path + ".wav", FileAccess::WRITE_READ); + ERR_FAIL_COND_V(f_wav.is_null(), ERR_CANT_OPEN); + + fps = p_fps; + + f_wav->store_buffer((const uint8_t *)"RIFF", 4); + int total_size = 4 /* WAVE */ + 8 /* fmt+size */ + 16 /* format */ + 8 /* data+size */; + f_wav->store_32(total_size); // Will store final later. + f_wav->store_buffer((const uint8_t *)"WAVE", 4); + + /* FORMAT CHUNK */ + + f_wav->store_buffer((const uint8_t *)"fmt ", 4); + + uint32_t channels = 2; + switch (speaker_mode) { + case AudioServer::SPEAKER_MODE_STEREO: + channels = 2; + break; + case AudioServer::SPEAKER_SURROUND_31: + channels = 4; + break; + case AudioServer::SPEAKER_SURROUND_51: + channels = 6; + break; + case AudioServer::SPEAKER_SURROUND_71: + channels = 8; + break; + } + + f_wav->store_32(16); // Standard format, no extra fields. + f_wav->store_16(1); // Compression code, standard PCM. + f_wav->store_16(channels); + + f_wav->store_32(mix_rate); + + int bits_per_sample = 32; + int blockalign = bits_per_sample / 8 * channels; + int bytes_per_sec = mix_rate * blockalign; + + audio_block_size = (mix_rate / fps) * blockalign; + + f_wav->store_32(bytes_per_sec); + f_wav->store_16(blockalign); + f_wav->store_16(bits_per_sample); + + /* DATA CHUNK */ + + f_wav->store_buffer((const uint8_t *)"data", 4); + f_wav->store_32(0); // Data size, stored on end. + wav_data_size_pos = f_wav->get_position(); + + return OK; +} + +Error MovieWriterEXR::write_frame(const Ref &p_image, const int32_t *p_audio_data) { + ERR_FAIL_COND_V(f_wav.is_null(), ERR_UNCONFIGURED); + + // save_exr requires a floating-point format; keep the frame linear at the requested precision. + const Image::Format target = full_float ? Image::FORMAT_RGBAF : Image::FORMAT_RGBAH; + Ref frame = p_image; + if (frame->get_format() != target) { + frame = frame->duplicate(); + frame->convert(target); + } + + Vector exr_buffer = frame->save_exr_to_buffer(false); + ERR_FAIL_COND_V_MSG(exr_buffer.is_empty(), ERR_UNAVAILABLE, "MovieWriter: Failed to encode EXR frame (is the TinyEXR module enabled?)."); + + Ref fi = FileAccess::open(base_path + zeros_str(frame_count) + ".exr", FileAccess::WRITE); + ERR_FAIL_COND_V(fi.is_null(), ERR_CANT_CREATE); + fi->store_buffer(exr_buffer.ptr(), exr_buffer.size()); + + f_wav->store_buffer((const uint8_t *)p_audio_data, audio_block_size); + + frame_count++; + + return OK; +} + +void MovieWriterEXR::write_end() { + if (f_wav.is_valid()) { + uint32_t total_size = 4 /* WAVE */ + 8 /* fmt+size */ + 16 /* format */ + 8 /* data+size */; + uint32_t datasize = f_wav->get_position() - wav_data_size_pos; + f_wav->seek(4); + f_wav->store_32(total_size + datasize); + f_wav->seek(0x28); + f_wav->store_32(datasize); + } +} + +MovieWriterEXR::MovieWriterEXR() { + mix_rate = GLOBAL_GET("editor/movie_writer/mix_rate"); + speaker_mode = AudioServer::SpeakerMode(int(GLOBAL_GET("editor/movie_writer/speaker_mode"))); +} diff --git a/servers/movie_writer/movie_writer_exr.h b/servers/movie_writer/movie_writer_exr.h new file mode 100644 index 00000000000..ce46a4abd75 --- /dev/null +++ b/servers/movie_writer/movie_writer_exr.h @@ -0,0 +1,78 @@ +/**************************************************************************/ +/* movie_writer_exr.h */ +/**************************************************************************/ +/* This file is part of: */ +/* REDOT ENGINE */ +/* https://redotengine.org */ +/**************************************************************************/ +/* Copyright (c) 2024-present Redot Engine contributors */ +/* (see REDOT_AUTHORS.md) */ +/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */ +/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/**************************************************************************/ + +#pragma once + +// Writes a numbered sequence of OpenEXR frames (plus a sidecar .wav for audio), preserving the +// frame as floating-point deep color so smooth gradients / shadow terminators do not band as they +// do in 8-bit output. Enable "rendering/viewport/hdr_2d" for the added precision. Note: frames are +// the tonemapped, display-referred [0,1] output at higher bit depth, not scene-referred HDR. + +#include "servers/movie_writer/movie_writer.h" + +class MovieWriterEXR : public MovieWriter { + GDCLASS(MovieWriterEXR, MovieWriter) + + enum { + MAX_TRAILING_ZEROS = 8 + }; + + uint32_t mix_rate = 48000; + AudioServer::SpeakerMode speaker_mode = AudioServer::SPEAKER_MODE_STEREO; + String base_path; + uint32_t frame_count = 0; + uint32_t fps = 0; + bool full_float = false; + + uint32_t audio_block_size = 0; + + Ref f_wav; + uint32_t wav_data_size_pos = 0; + + String zeros_str(uint32_t p_index); + +protected: + virtual uint32_t get_audio_mix_rate() const override; + virtual AudioServer::SpeakerMode get_audio_speaker_mode() const override; + virtual void get_supported_extensions(List *r_extensions) const override; + + virtual bool wants_float_output() const override { return true; } + + virtual Error write_begin(const Size2i &p_movie_size, uint32_t p_fps, const String &p_base_path) override; + virtual Error write_frame(const Ref &p_image, const int32_t *p_audio_data) override; + virtual void write_end() override; + + virtual bool handles_file(const String &p_path) const override; + +public: + MovieWriterEXR(); +}; diff --git a/servers/register_server_types.cpp b/servers/register_server_types.cpp index 27313e9b28a..420f438d6eb 100644 --- a/servers/register_server_types.cpp +++ b/servers/register_server_types.cpp @@ -67,6 +67,10 @@ #include "display/display_server.h" #include "display/native_menu.h" #include "movie_writer/movie_writer.h" +#include "modules/modules_enabled.gen.h" // For MODULE_TINYEXR_ENABLED. +#ifdef MODULE_TINYEXR_ENABLED +#include "movie_writer/movie_writer_exr.h" +#endif #include "movie_writer/movie_writer_pngwav.h" #include "rendering/renderer_rd/framebuffer_cache_rd.h" #include "rendering/renderer_rd/storage_rd/render_data_rd.h" @@ -143,6 +147,9 @@ static bool has_server_feature_callback(const String &p_feature) { } static MovieWriterPNGWAV *writer_pngwav = nullptr; +#ifdef MODULE_TINYEXR_ENABLED +static MovieWriterEXR *writer_exr = nullptr; +#endif void register_server_types() { OS::get_singleton()->benchmark_begin_measure("Servers", "Register Extensions"); @@ -357,6 +364,13 @@ void register_server_types() { MovieWriter::add_writer(writer_pngwav); } +#ifdef MODULE_TINYEXR_ENABLED + if (GD_IS_CLASS_ENABLED(MovieWriterEXR)) { + writer_exr = memnew(MovieWriterEXR); + MovieWriter::add_writer(writer_exr); + } +#endif + OS::get_singleton()->benchmark_end_measure("Servers", "Register Extensions"); } @@ -368,6 +382,11 @@ void unregister_server_types() { if (GD_IS_CLASS_ENABLED(MovieWriterPNGWAV)) { memdelete(writer_pngwav); } +#ifdef MODULE_TINYEXR_ENABLED + if (GD_IS_CLASS_ENABLED(MovieWriterEXR)) { + memdelete(writer_exr); + } +#endif OS::get_singleton()->benchmark_end_measure("Servers", "Unregister Extensions"); } From 4047a170cb92c55905251fe485995caaf548c36d Mon Sep 17 00:00:00 2001 From: Dubhghlas McLaughlin <103212704+mcdubhghlas@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:08:43 -0500 Subject: [PATCH 5/9] documentation needed for the movie maker. --- doc/classes/ProjectSettings.xml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/doc/classes/ProjectSettings.xml b/doc/classes/ProjectSettings.xml index c83ae5b2288..83e1fe401df 100644 --- a/doc/classes/ProjectSettings.xml +++ b/doc/classes/ProjectSettings.xml @@ -1122,6 +1122,11 @@ If [code]true[/code], requests V-Sync to be disabled when writing a movie (similar to setting [member display/window/vsync/vsync_mode] to [b]Disabled[/b]). This can speed up video writing if the hardware is fast enough to render, encode and save the video at a framerate higher than the monitor's refresh rate. [b]Note:[/b] [member editor/movie_writer/disable_vsync] has no effect if the operating system or graphics driver forces V-Sync with no way for applications to disable it. + + The channel storage used when writing an OpenEXR image sequence ([code].exr[/code] file extension): [code]0[/code] for 16-bit half-float channels (smaller files), [code]1[/code] for 32-bit full-float channels. + EXR output stores each frame in floating point, which avoids the color banding visible in 8-bit formats such as PNG on smooth gradients and shadows. For the added precision to be meaningful, enable [member rendering/viewport/hdr_2d]; otherwise the frame is captured at 8-bit precision and simply stored in a floating-point file. + [b]Note:[/b] Frames are the tonemapped, display-referred output stored at higher precision (deep color), not scene-referred high dynamic range. Requires the TinyEXR module to be enabled at build time. + The number of frames per second to record in the video when writing a movie. Simulation speed will adjust to always match the specified framerate, which means the engine will appear to run slower at higher [member editor/movie_writer/fps] values. Certain FPS values will require you to adjust [member editor/movie_writer/mix_rate] to prevent audio from desynchronizing over time. This can be specified manually on the command line using the [code]--fixed-fps <fps>[/code] [url=$DOCS_URL/tutorials/editor/command_line_tutorial.html]command line argument[/url]. @@ -1131,10 +1136,11 @@ The output path for the movie. The file extension determines the [MovieWriter] that will be used. - Redot has 3 built-in [MovieWriter]s: + Redot has 4 built-in [MovieWriter]s: - OGV container with Theora for video and Vorbis for audio ([code].ogv[/code] file extension). Lossy compression, medium file sizes, fast encoding. The lossy compression quality can be adjusted by changing [member ProjectSettings.editor/movie_writer/video_quality] and [member ProjectSettings.editor/movie_writer/ogv/audio_quality]. The resulting file can be viewed in Redot with [VideoStreamPlayer] and most video players, but not web browsers as they don't support Theora. - AVI container with MJPEG for video and uncompressed audio ([code].avi[/code] file extension). Lossy compression, medium file sizes, fast encoding. The lossy compression quality can be adjusted by changing [member ProjectSettings.editor/movie_writer/video_quality]. The resulting file can be viewed in most video players, but it must be converted to another format for viewing on the web or by Redot with [VideoStreamPlayer]. MJPEG does not support transparency. AVI output is currently limited to a file of 4 GB in size at most. - PNG image sequence for video and WAV for audio ([code].png[/code] file extension). Lossless compression, large file sizes, slow encoding. Designed to be encoded to a video file with another tool such as [url=https://ffmpeg.org/]FFmpeg[/url] after recording. Transparency is currently not supported, even if the root viewport is set to be transparent. + - OpenEXR image sequence for video and WAV for audio ([code].exr[/code] file extension). Stores each frame in floating point (16-bit half or 32-bit full, see [member editor/movie_writer/exr/bit_depth]) to avoid the color banding of 8-bit formats on smooth gradients and shadows. Enable [member rendering/viewport/hdr_2d] for the added precision. Designed for compositing/grading; encode to a video file afterwards with another tool. Requires the TinyEXR module. If you need to encode to a different format or pipe a stream through third-party software, you can extend this [MovieWriter] class to create your own movie writers. When using PNG output, the frame number will be appended at the end of the file name. It starts from 0 and is padded with 8 digits to ensure correct sorting and easier processing. For example, if the output path is [code]/tmp/hello.png[/code], the first two frames will be [code]/tmp/hello00000000.png[/code] and [code]/tmp/hello00000001.png[/code]. The audio will be saved at [code]/tmp/hello.wav[/code]. From bc5493ba0cc0150f626f7b6edfba12acd0f2927e Mon Sep 17 00:00:00 2001 From: Dubhghlas McLaughlin <103212704+mcdubhghlas@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:00:08 -0500 Subject: [PATCH 6/9] pre-commit go brr. --- servers/register_server_types.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/servers/register_server_types.cpp b/servers/register_server_types.cpp index 420f438d6eb..1e431cc5db8 100644 --- a/servers/register_server_types.cpp +++ b/servers/register_server_types.cpp @@ -66,8 +66,8 @@ #include "debugger/servers_debugger.h" #include "display/display_server.h" #include "display/native_menu.h" -#include "movie_writer/movie_writer.h" #include "modules/modules_enabled.gen.h" // For MODULE_TINYEXR_ENABLED. +#include "movie_writer/movie_writer.h" #ifdef MODULE_TINYEXR_ENABLED #include "movie_writer/movie_writer_exr.h" #endif From e6e6741c85db0e5c50273d83eabbb744c271aba9 Mon Sep 17 00:00:00 2001 From: Dubhghlas McLaughlin <103212704+mcdubhghlas@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:28:43 -0500 Subject: [PATCH 7/9] Add 16-bit PNG output path to Movie Writer. --- core/io/image.cpp | 8 +++ core/io/image.h | 5 ++ doc/classes/ProjectSettings.xml | 6 +- drivers/png/png_driver_common.cpp | 75 ++++++++++++++++++++ drivers/png/png_driver_common.h | 5 ++ drivers/png/resource_saver_png.cpp | 8 +++ drivers/png/resource_saver_png.h | 1 + servers/movie_writer/movie_writer.cpp | 3 + servers/movie_writer/movie_writer_pngwav.cpp | 5 +- servers/movie_writer/movie_writer_pngwav.h | 5 ++ 10 files changed, 119 insertions(+), 2 deletions(-) diff --git a/core/io/image.cpp b/core/io/image.cpp index a1fc2ec9379..73d36c56597 100644 --- a/core/io/image.cpp +++ b/core/io/image.cpp @@ -2591,6 +2591,14 @@ Vector Image::save_png_to_buffer() const { return save_png_buffer_func(Ref((Image *)this)); } +Vector Image::save_png_16bit_to_buffer() const { + if (save_png_16bit_buffer_func != nullptr) { + return save_png_16bit_buffer_func(Ref((Image *)this)); + } + // Fall back to the 8-bit encoder if no 16-bit PNG backend is available. + return save_png_to_buffer(); +} + Vector Image::save_jpg_to_buffer(float p_quality) const { if (save_jpg_buffer_func == nullptr) { return Vector(); diff --git a/core/io/image.h b/core/io/image.h index 0b9ab6b250d..3c62610e5d1 100644 --- a/core/io/image.h +++ b/core/io/image.h @@ -53,6 +53,7 @@ class Image; /// @{ typedef Error (*SavePNGFunc)(const String &p_path, const Ref &p_img); typedef Vector (*SavePNGBufferFunc)(const Ref &p_img); +typedef Vector (*SavePNG16BitBufferFunc)(const Ref &p_img); typedef Error (*SaveJPGFunc)(const String &p_path, const Ref &p_img, float p_quality); typedef Vector (*SaveJPGBufferFunc)(const Ref &p_img, float p_quality); @@ -205,6 +206,7 @@ class Image : public Resource { static inline SaveWebPFunc save_webp_func = nullptr; static inline SaveDDSFunc save_dds_func = nullptr; static inline SavePNGBufferFunc save_png_buffer_func = nullptr; + static inline SavePNG16BitBufferFunc save_png_16bit_buffer_func = nullptr; static inline SaveEXRBufferFunc save_exr_buffer_func = nullptr; static inline SaveJPGBufferFunc save_jpg_buffer_func = nullptr; static inline SaveWebPBufferFunc save_webp_buffer_func = nullptr; @@ -377,6 +379,9 @@ class Image : public Resource { Error save_jpg(const String &p_path, float p_quality = 0.75) const; Error save_dds(const String &p_path) const; Vector save_png_to_buffer() const; + // Encodes a 16-bit-per-channel sRGB PNG (deep color, avoids 8-bit banding). Falls back to + // the 8-bit encoder if no 16-bit PNG backend is available. + Vector save_png_16bit_to_buffer() const; Vector save_jpg_to_buffer(float p_quality = 0.75) const; Vector save_exr_to_buffer(bool p_grayscale = false) const; Vector save_dds_to_buffer() const; diff --git a/doc/classes/ProjectSettings.xml b/doc/classes/ProjectSettings.xml index 83e1fe401df..2cbb6fab02f 100644 --- a/doc/classes/ProjectSettings.xml +++ b/doc/classes/ProjectSettings.xml @@ -1139,7 +1139,7 @@ Redot has 4 built-in [MovieWriter]s: - OGV container with Theora for video and Vorbis for audio ([code].ogv[/code] file extension). Lossy compression, medium file sizes, fast encoding. The lossy compression quality can be adjusted by changing [member ProjectSettings.editor/movie_writer/video_quality] and [member ProjectSettings.editor/movie_writer/ogv/audio_quality]. The resulting file can be viewed in Redot with [VideoStreamPlayer] and most video players, but not web browsers as they don't support Theora. - AVI container with MJPEG for video and uncompressed audio ([code].avi[/code] file extension). Lossy compression, medium file sizes, fast encoding. The lossy compression quality can be adjusted by changing [member ProjectSettings.editor/movie_writer/video_quality]. The resulting file can be viewed in most video players, but it must be converted to another format for viewing on the web or by Redot with [VideoStreamPlayer]. MJPEG does not support transparency. AVI output is currently limited to a file of 4 GB in size at most. - - PNG image sequence for video and WAV for audio ([code].png[/code] file extension). Lossless compression, large file sizes, slow encoding. Designed to be encoded to a video file with another tool such as [url=https://ffmpeg.org/]FFmpeg[/url] after recording. Transparency is currently not supported, even if the root viewport is set to be transparent. + - PNG image sequence for video and WAV for audio ([code].png[/code] file extension). Lossless compression, large file sizes, slow encoding. Designed to be encoded to a video file with another tool such as [url=https://ffmpeg.org/]FFmpeg[/url] after recording. Can be written at 8-bit or, to avoid color banding, 16-bit per channel (see [member editor/movie_writer/png/bit_depth]). Transparency is currently not supported, even if the root viewport is set to be transparent. - OpenEXR image sequence for video and WAV for audio ([code].exr[/code] file extension). Stores each frame in floating point (16-bit half or 32-bit full, see [member editor/movie_writer/exr/bit_depth]) to avoid the color banding of 8-bit formats on smooth gradients and shadows. Enable [member rendering/viewport/hdr_2d] for the added precision. Designed for compositing/grading; encode to a video file afterwards with another tool. Requires the TinyEXR module. If you need to encode to a different format or pipe a stream through third-party software, you can extend this [MovieWriter] class to create your own movie writers. When using PNG output, the frame number will be appended at the end of the file name. It starts from 0 and is padded with 8 digits to ensure correct sorting and easier processing. For example, if the output path is [code]/tmp/hello.png[/code], the first two frames will be [code]/tmp/hello00000000.png[/code] and [code]/tmp/hello00000001.png[/code]. The audio will be saved at [code]/tmp/hello.wav[/code]. @@ -1154,6 +1154,10 @@ Forces keyframes at the specified interval (in frame count). Higher values can improve compression up to a certain level at the expense of higher latency when seeking. + + The bits per channel used when writing a PNG image sequence ([code].png[/code] file extension): [code]8[/code] for a standard 8-bit PNG, [code]16[/code] for a 16-bit sRGB PNG. + 16-bit output is deep color and avoids the banding visible in 8-bit on smooth gradients and shadows, while remaining a normal PNG that opens in any image tool. For the added precision to be meaningful, enable [member rendering/viewport/hdr_2d]; otherwise the frame is captured at 8-bit precision and simply stored in a 16-bit file. + The speaker mode to use in the recorded audio when writing a movie. See [enum AudioServer.SpeakerMode] for possible values. diff --git a/drivers/png/png_driver_common.cpp b/drivers/png/png_driver_common.cpp index 194bd63dd77..af22ef869b7 100644 --- a/drivers/png/png_driver_common.cpp +++ b/drivers/png/png_driver_common.cpp @@ -39,8 +39,11 @@ #include "png_driver_common.h" #include "core/config/engine.h" +#include "core/math/math_funcs.h" +#include "core/templates/local_vector.h" #include +#include namespace PNGDriverCommon { @@ -211,6 +214,78 @@ Error image_to_png(const Ref &p_image, Vector &p_buffer) { return OK; } +Error image_to_png_16bit(const Ref &p_image, Vector &p_buffer) { + ERR_FAIL_COND_V(p_image.is_null() || p_image->is_empty(), ERR_INVALID_PARAMETER); + + // Float formats hold linear light and are sRGB-encoded below; 8-bit sources are already sRGB. + const bool linear_input = p_image->get_format() == Image::FORMAT_RGBAH || p_image->get_format() == Image::FORMAT_RGBAF; + Ref source = p_image; + if (source->get_format() != Image::FORMAT_RGBAF) { + source = source->duplicate(); + source->convert(Image::FORMAT_RGBAF); + } + + const int width = source->get_width(); + const int height = source->get_height(); + const Vector src_data = source->get_data(); + const float *src = reinterpret_cast(src_data.ptr()); + + // Convert to 16-bit sRGB integer samples (RGB is gamma-encoded, alpha stays linear). + LocalVector samples; + samples.resize((uint32_t)width * (uint32_t)height * 4); + for (int64_t p = 0; p < (int64_t)width * height; p++) { + for (int c = 0; c < 4; c++) { + float v = src[p * 4 + c]; + if (linear_input && c < 3) { + v = v <= 0.0031308f ? 12.92f * v : 1.055f * Math::pow(v, 1.0f / 2.4f) - 0.055f; + } + v = CLAMP(v, 0.0f, 1.0f); + samples[p * 4 + c] = (uint16_t)Math::round(v * 65535.0f); + } + } + + png_structp png_ptr = png_create_write_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr); + ERR_FAIL_NULL_V(png_ptr, FAILED); + png_infop info_ptr = png_create_info_struct(png_ptr); + if (info_ptr == nullptr) { + png_destroy_write_struct(&png_ptr, nullptr); + ERR_FAIL_V(FAILED); + } + if (setjmp(png_jmpbuf(png_ptr))) { + png_destroy_write_struct(&png_ptr, &info_ptr); + ERR_FAIL_V_MSG(FAILED, "Failed to encode 16-bit PNG."); + } + + png_set_write_fn( + png_ptr, &p_buffer, + [](png_structp p_png, png_bytep p_data, png_size_t p_length) { + Vector *buffer = static_cast *>(png_get_io_ptr(p_png)); + const int64_t ofs = buffer->size(); + buffer->resize(ofs + (int64_t)p_length); + memcpy(buffer->ptrw() + ofs, p_data, p_length); + }, + nullptr); + + png_set_IHDR(png_ptr, info_ptr, width, height, 16, PNG_COLOR_TYPE_RGB_ALPHA, + PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT); + png_set_sRGB_gAMA_and_cHRM(png_ptr, info_ptr, PNG_sRGB_INTENT_PERCEPTUAL); + png_write_info(png_ptr, info_ptr); +#ifndef BIG_ENDIAN_ENABLED + png_set_swap(png_ptr); // PNG stores 16-bit samples big-endian; swap on little-endian hosts. +#endif + + LocalVector rows; + rows.resize(height); + for (int y = 0; y < height; y++) { + rows[y] = reinterpret_cast(&samples[(uint32_t)y * (uint32_t)width * 4]); + } + png_write_image(png_ptr, rows.ptr()); + png_write_end(png_ptr, info_ptr); + png_destroy_write_struct(&png_ptr, &info_ptr); + + return OK; +} + /// @name APNG functions /// @{ diff --git a/drivers/png/png_driver_common.h b/drivers/png/png_driver_common.h index 00cdb99f67b..eee9c1cafb1 100644 --- a/drivers/png/png_driver_common.h +++ b/drivers/png/png_driver_common.h @@ -50,6 +50,11 @@ Error png_to_image(const uint8_t *p_source, size_t p_size, bool p_force_linear, /// Contents of p_buffer is unspecified if error returned. Error image_to_png(const Ref &p_image, Vector &p_buffer); +/// Append p_image, as a 16-bit-per-channel sRGB PNG, to p_buffer. +/// Float (linear) sources are sRGB-encoded; 8-bit sources are scaled up. Used for deep-color, +/// banding-free output. Contents of p_buffer is unspecified if error returned. +Error image_to_png_16bit(const Ref &p_image, Vector &p_buffer); + /// Attempt to load apng from buffer (p_source, p_size) into p_frames Error apng_to_image_frames(const uint8_t *p_source, size_t p_size, bool p_force_linear, uint32_t p_frame_limit, Ref p_frames); } // namespace PNGDriverCommon diff --git a/drivers/png/resource_saver_png.cpp b/drivers/png/resource_saver_png.cpp index aa7195d3dd1..3c49c7696f6 100644 --- a/drivers/png/resource_saver_png.cpp +++ b/drivers/png/resource_saver_png.cpp @@ -80,6 +80,13 @@ Vector ResourceSaverPNG::save_image_to_buffer(const Ref &p_img) return buffer; } +Vector ResourceSaverPNG::save_image_16bit_to_buffer(const Ref &p_img) { + Vector buffer; + Error err = PNGDriverCommon::image_to_png_16bit(p_img, buffer); + ERR_FAIL_COND_V_MSG(err, Vector(), "Can't convert image to 16-bit PNG."); + return buffer; +} + bool ResourceSaverPNG::recognize(const Ref &p_resource) const { return (p_resource.is_valid() && p_resource->is_class("ImageTexture")); } @@ -93,4 +100,5 @@ void ResourceSaverPNG::get_recognized_extensions(const Ref &p_resource ResourceSaverPNG::ResourceSaverPNG() { Image::save_png_func = &save_image; Image::save_png_buffer_func = &save_image_to_buffer; + Image::save_png_16bit_buffer_func = &save_image_16bit_to_buffer; } diff --git a/drivers/png/resource_saver_png.h b/drivers/png/resource_saver_png.h index 766202a1e13..7d68e8d0dc2 100644 --- a/drivers/png/resource_saver_png.h +++ b/drivers/png/resource_saver_png.h @@ -45,6 +45,7 @@ class ResourceSaverPNG : public ResourceFormatSaver { public: static Error save_image(const String &p_path, const Ref &p_img); static Vector save_image_to_buffer(const Ref &p_img); + static Vector save_image_16bit_to_buffer(const Ref &p_img); virtual Error save(const Ref &p_resource, const String &p_path, uint32_t p_flags = 0) override; virtual bool recognize(const Ref &p_resource) const override; diff --git a/servers/movie_writer/movie_writer.cpp b/servers/movie_writer/movie_writer.cpp index 36fe44dc801..067e51ee020 100644 --- a/servers/movie_writer/movie_writer.cpp +++ b/servers/movie_writer/movie_writer.cpp @@ -175,6 +175,9 @@ void MovieWriter::_bind_methods() { GLOBAL_DEF(PropertyInfo(Variant::FLOAT, "editor/movie_writer/ogv/audio_quality", PROPERTY_HINT_RANGE, "-0.1,1.0,0.01"), 0.5); GLOBAL_DEF(PropertyInfo(Variant::INT, "editor/movie_writer/ogv/encoding_speed", PROPERTY_HINT_ENUM, "Fastest (Lowest Efficiency):4,Fast (Low Efficiency):3,Slow (High Efficiency):2,Slowest (Highest Efficiency):1"), 4); GLOBAL_DEF(PropertyInfo(Variant::INT, "editor/movie_writer/ogv/keyframe_interval", PROPERTY_HINT_RANGE, "1,1024,1"), 64); + // PNG (.png) output bit depth per channel. 16-bit is deep color (avoids banding) and needs + // "rendering/viewport/hdr_2d" for the added precision to be meaningful. + GLOBAL_DEF(PropertyInfo(Variant::INT, "editor/movie_writer/png/bit_depth", PROPERTY_HINT_ENUM, "8-bit:8,16-bit:16"), 8); // EXR (.exr) output channel storage. Needs "rendering/viewport/hdr_2d" for the added precision that avoids banding. GLOBAL_DEF(PropertyInfo(Variant::INT, "editor/movie_writer/exr/bit_depth", PROPERTY_HINT_ENUM, "16-bit (Half Float):0,32-bit (Full Float):1"), 0); diff --git a/servers/movie_writer/movie_writer_pngwav.cpp b/servers/movie_writer/movie_writer_pngwav.cpp index c3819f270a0..fc6061a4ce0 100644 --- a/servers/movie_writer/movie_writer_pngwav.cpp +++ b/servers/movie_writer/movie_writer_pngwav.cpp @@ -74,6 +74,9 @@ Error MovieWriterPNGWAV::write_begin(const Size2i &p_movie_size, uint32_t p_fps, base_path = "res://" + base_path; } + frame_count = 0; + bit_depth_16 = int(GLOBAL_GET("editor/movie_writer/png/bit_depth")) == 16; + { //Remove existing files before writing anew uint32_t idx = 0; @@ -150,7 +153,7 @@ Error MovieWriterPNGWAV::write_begin(const Size2i &p_movie_size, uint32_t p_fps, Error MovieWriterPNGWAV::write_frame(const Ref &p_image, const int32_t *p_audio_data) { ERR_FAIL_COND_V(f_wav.is_null(), ERR_UNCONFIGURED); - Vector png_buffer = p_image->save_png_to_buffer(); + Vector png_buffer = bit_depth_16 ? p_image->save_png_16bit_to_buffer() : p_image->save_png_to_buffer(); Ref fi = FileAccess::open(base_path + zeros_str(frame_count) + ".png", FileAccess::WRITE); fi->store_buffer(png_buffer.ptr(), png_buffer.size()); diff --git a/servers/movie_writer/movie_writer_pngwav.h b/servers/movie_writer/movie_writer_pngwav.h index 51e9657a84f..a04beca043e 100644 --- a/servers/movie_writer/movie_writer_pngwav.h +++ b/servers/movie_writer/movie_writer_pngwav.h @@ -52,6 +52,8 @@ class MovieWriterPNGWAV : public MovieWriter { String base_path; uint32_t frame_count = 0; uint32_t fps = 0; + // When true, frames are written as 16-bit-per-channel PNGs (deep color, avoids banding). + bool bit_depth_16 = false; uint32_t audio_block_size = 0; @@ -65,6 +67,9 @@ class MovieWriterPNGWAV : public MovieWriter { virtual AudioServer::SpeakerMode get_audio_speaker_mode() const override; virtual void get_supported_extensions(List *r_extensions) const override; + // 16-bit PNG output keeps the linear float frame so it can be encoded at full precision. + virtual bool wants_float_output() const override { return bit_depth_16; } + virtual Error write_begin(const Size2i &p_movie_size, uint32_t p_fps, const String &p_base_path) override; virtual Error write_frame(const Ref &p_image, const int32_t *p_audio_data) override; virtual void write_end() override; From b36e29187900cd1ec1d5c451aa4de1be2b945aba Mon Sep 17 00:00:00 2001 From: Dubhghlas McLaughlin <103212704+mcdubhghlas@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:41:40 -0500 Subject: [PATCH 8/9] Fixed - cinematic Preview camera swap doesn't refresh Movie Mode; Fixed - Movie frame drawn after surface overlays, hiding them; Fixed - Preview keeps UPDATE_ALWAYS while viewport hidden --- drivers/png/png_driver_common.cpp | 7 +- editor/scene/3d/node_3d_editor_plugin.cpp | 84 +++++++++++++---------- editor/scene/3d/node_3d_editor_plugin.h | 1 + 3 files changed, 52 insertions(+), 40 deletions(-) diff --git a/drivers/png/png_driver_common.cpp b/drivers/png/png_driver_common.cpp index af22ef869b7..eda6eee81f2 100644 --- a/drivers/png/png_driver_common.cpp +++ b/drivers/png/png_driver_common.cpp @@ -217,8 +217,11 @@ Error image_to_png(const Ref &p_image, Vector &p_buffer) { Error image_to_png_16bit(const Ref &p_image, Vector &p_buffer) { ERR_FAIL_COND_V(p_image.is_null() || p_image->is_empty(), ERR_INVALID_PARAMETER); - // Float formats hold linear light and are sRGB-encoded below; 8-bit sources are already sRGB. - const bool linear_input = p_image->get_format() == Image::FORMAT_RGBAH || p_image->get_format() == Image::FORMAT_RGBAF; + // Float/HDR formats hold linear light and are sRGB-encoded below; 8-bit sources are already sRGB. + const Image::Format input_format = p_image->get_format(); + const bool linear_input = input_format == Image::FORMAT_RGBH || input_format == Image::FORMAT_RGBAH || + input_format == Image::FORMAT_RGBF || input_format == Image::FORMAT_RGBAF || + input_format == Image::FORMAT_RGBE9995; Ref source = p_image; if (source->get_format() != Image::FORMAT_RGBAF) { source = source->duplicate(); diff --git a/editor/scene/3d/node_3d_editor_plugin.cpp b/editor/scene/3d/node_3d_editor_plugin.cpp index 81e49595d60..db27325b2a2 100644 --- a/editor/scene/3d/node_3d_editor_plugin.cpp +++ b/editor/scene/3d/node_3d_editor_plugin.cpp @@ -3115,6 +3115,8 @@ void Node3DEditorViewport::_notification(int p_what) { } else { set_freelook_active(false); } + // Stop/resume the Movie Mode preview render with the viewport's visibility. + _update_movie_preview(); callable_mp(this, &Node3DEditorViewport::update_transform_gizmo_view).call_deferred(); } break; @@ -3168,6 +3170,7 @@ void Node3DEditorViewport::_notification(int p_what) { previewing->connect(SceneStringName(tree_exited), callable_mp(this, &Node3DEditorViewport::_preview_exited_scene)); previewing->connect(CoreStringName(property_list_changed), callable_mp(this, &Node3DEditorViewport::_preview_camera_property_changed)); RS::get_singleton()->viewport_attach_camera(viewport->get_viewport_rid(), cam->get_camera()); + _update_movie_preview(); surface->queue_redraw(); } } @@ -3501,6 +3504,26 @@ static void draw_indicator_bar(Control &p_surface, real_t p_fill, const Ref frame_tex = movie_preview_vp->get_texture(); + if (frame_tex.is_valid()) { + surface->draw_texture_rect(frame_tex, frame_rect, false); + } + // Fill the area outside the Movie Writer output aspect ratio with opaque black. + const Color letterbox_color = Color(0, 0, 0, 1); + const real_t rect_right = frame_rect.position.x + frame_rect.size.x; + const real_t rect_bottom = frame_rect.position.y + frame_rect.size.y; + surface->draw_rect(Rect2(0, 0, s.width, frame_rect.position.y), letterbox_color, true); + surface->draw_rect(Rect2(0, rect_bottom, s.width, s.height - rect_bottom), letterbox_color, true); + surface->draw_rect(Rect2(0, frame_rect.position.y, frame_rect.position.x, frame_rect.size.y), letterbox_color, true); + surface->draw_rect(Rect2(rect_right, frame_rect.position.y, s.width - rect_right, frame_rect.size.y), letterbox_color, true); + } + EditorPluginList *over_plugin_list = EditorNode::get_singleton()->get_editor_plugins_over(); if (!over_plugin_list->is_empty()) { over_plugin_list->forward_3d_draw_over_viewport(surface); @@ -3573,43 +3596,9 @@ void Node3DEditorViewport::_draw() { const Size2 s = get_size(); if (movie_mode) { - // `movie_preview_vp` renders the previewed camera through a viewport with Movie - // Writer's output aspect, so Camera3D's keep-aspect behavior matches the recorded - // framing (same framing, not every rendering property) for any panel/target aspect. - // Use the preview viewport's actual integer size as the displayed frame so the - // texture maps 1:1 into the panel (no rescale); _update_movie_preview_size() already - // fits it inside the panel. Fall back to an aspect-fit before it has been sized. - Rect2 frame_rect; - if (movie_preview_vp && movie_preview_vp->get_size() != Size2i()) { - frame_rect.size = movie_preview_vp->get_size(); - } else { - const float aspect = Size2(MovieWriter::get_output_size()).aspect(); - if (s.aspect() > aspect) { - frame_rect.size = Size2(s.height * aspect, s.height); - } else { - frame_rect.size = Size2(s.width, s.width / aspect); - } - } - frame_rect.position = ((s - frame_rect.size) * 0.5).floor(); - frame_rect = Rect2(Vector2(), s).intersection(frame_rect); - - if (movie_preview_vp) { - const Ref frame_tex = movie_preview_vp->get_texture(); - if (frame_tex.is_valid()) { - surface->draw_texture_rect(frame_tex, frame_rect, false); - } - } - - // Fill the area outside the Movie Writer output aspect ratio with opaque black. - const Color letterbox_color = Color(0, 0, 0, 1); - const real_t rect_right = frame_rect.position.x + frame_rect.size.x; - const real_t rect_bottom = frame_rect.position.y + frame_rect.size.y; - surface->draw_rect(Rect2(0, 0, s.width, frame_rect.position.y), letterbox_color, true); - surface->draw_rect(Rect2(0, rect_bottom, s.width, s.height - rect_bottom), letterbox_color, true); - surface->draw_rect(Rect2(0, frame_rect.position.y, frame_rect.position.x, frame_rect.size.y), letterbox_color, true); - surface->draw_rect(Rect2(rect_right, frame_rect.position.y, s.width - rect_right, frame_rect.size.y), letterbox_color, true); - - surface->draw_rect(frame_rect, Color(0.6, 0.6, 0.1, 0.5), false, Math::round(2 * EDSCALE)); + // The Movie Writer frame texture and letterbox were drawn at the start of _draw (before + // overlays); draw the frame outline here so it sits on top of them. + surface->draw_rect(_movie_frame_rect(), Color(0.6, 0.6, 0.1, 0.5), false, Math::round(2 * EDSCALE)); } else { // Historical preview outline: the project viewport aspect region within the // full-panel camera preview, using the camera's keep-aspect mode. @@ -4250,7 +4239,7 @@ void Node3DEditorViewport::_update_movie_preview() { // SubViewport with Movie Writer's output aspect (sized to the displayed frame, see // _update_movie_preview_size()) so Camera3D's keep-aspect behavior matches the recorded // framing, for both keep-aspect modes and whether the panel is wider or narrower than output. - const bool active = previewing != nullptr && movie_mode; + const bool active = previewing != nullptr && movie_mode && is_visible_in_tree(); if (active && !movie_preview_vp) { movie_preview_vp = memnew(SubViewport); movie_preview_vp->set_disable_input(true); @@ -4292,6 +4281,25 @@ void Node3DEditorViewport::_update_movie_preview_size() { } } +Rect2 Node3DEditorViewport::_movie_frame_rect() const { + // The rectangle within the panel that shows the Movie Writer output frame (aspect-fit). + const Size2 s = get_size(); + Rect2 frame_rect; + if (movie_preview_vp && movie_preview_vp->get_size() != Size2i()) { + // Use the preview viewport's actual integer size so its texture maps 1:1 (no rescale). + frame_rect.size = movie_preview_vp->get_size(); + } else { + const float aspect = Size2(MovieWriter::get_output_size()).aspect(); + if (s.aspect() > aspect) { + frame_rect.size = Size2(s.height * aspect, s.height); + } else { + frame_rect.size = Size2(s.width, s.width / aspect); + } + } + frame_rect.position = ((s - frame_rect.size) * 0.5).floor(); + return Rect2(Vector2(), s).intersection(frame_rect); +} + void Node3DEditorViewport::_toggle_cinema_preview(bool p_activate) { previewing_cinema = p_activate; _update_navigation_controls_visibility(); diff --git a/editor/scene/3d/node_3d_editor_plugin.h b/editor/scene/3d/node_3d_editor_plugin.h index f881a1e129c..781b9cc1b8f 100644 --- a/editor/scene/3d/node_3d_editor_plugin.h +++ b/editor/scene/3d/node_3d_editor_plugin.h @@ -503,6 +503,7 @@ class Node3DEditorViewport : public Control { void _update_centered_labels(); void _update_movie_preview(); void _update_movie_preview_size(); + Rect2 _movie_frame_rect() const; void _toggle_camera_preview(bool); void _toggle_cinema_preview(bool); void _init_gizmo_instance(int p_idx); From d39ef298c03e7e502b9ce471409f90323e48f64c Mon Sep 17 00:00:00 2001 From: Dubhghlas McLaughlin <103212704+mcdubhghlas@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:57:09 -0500 Subject: [PATCH 9/9] fixup! Add 16-bit PNG output path to Movie Writer. --- drivers/png/png_driver_common.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/drivers/png/png_driver_common.cpp b/drivers/png/png_driver_common.cpp index eda6eee81f2..33c554aa729 100644 --- a/drivers/png/png_driver_common.cpp +++ b/drivers/png/png_driver_common.cpp @@ -264,7 +264,10 @@ Error image_to_png_16bit(const Ref &p_image, Vector &p_buffer) { [](png_structp p_png, png_bytep p_data, png_size_t p_length) { Vector *buffer = static_cast *>(png_get_io_ptr(p_png)); const int64_t ofs = buffer->size(); - buffer->resize(ofs + (int64_t)p_length); + if (buffer->resize(ofs + (int64_t)p_length) != OK) { + // Enter libpng's error path (setjmp) rather than writing past the allocation. + png_error(p_png, "Out of memory while encoding 16-bit PNG."); + } memcpy(buffer->ptrw() + ofs, p_data, p_length); }, nullptr);