diff --git a/Cargo.lock b/Cargo.lock index 34cbf510c6..fb760f4c7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,6 +40,15 @@ dependencies = [ "xflags", ] +[[package]] +name = "drawpiletimelapse" +version = "2.2.0-pre" +dependencies = [ + "drawdance", + "regex", + "xflags", +] + [[package]] name = "memchr" version = "2.6.1" diff --git a/Cargo.toml b/Cargo.toml index 1a84e6d162..74e6a4ce94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,8 @@ members = [ "src/drawdance/rust", "src/drawdance/libmsg", "src/tools/dprectool", + "src/tools/drawpile-cmd", + "src/tools/drawpile-timelapse", ] [workspace.package] diff --git a/ChangeLog b/ChangeLog index 358166bf87..0d054e2b11 100644 --- a/ChangeLog +++ b/ChangeLog @@ -23,6 +23,8 @@ Unreleased Version 2.2.0-pre * Feature: Let operators create layers beyond the 256 per-user maximum. They will use layers of user 0 first, then 255, 254 etc. Thanks to haxekhaex2 for reporting. * Fix: Make Drawpile 2.1 binary (dprec) recordings play back properly. Text (dptxt) recordings are not supported. * Fix: Synchronize rendering during recording playback properly. + * Feature: Bring back drawpile-cmd, the command-line tool that renders Drawpile recordings to images. Should also mostly work like it did in Drawpile 2.1. + * Feature: Implement drawpile-timelapse, a new command-line tool that turns Drawpile recordings into timelapse videos. 2023-08-26 Version 2.2.0-beta.7 * Fix: Make classic brushes not go brighter when smudging into transparency. Thanks to cada for reporting. diff --git a/pkg/custom-apprun.sh.in b/pkg/custom-apprun.sh.in index d08eaead68..4c9db8f454 100755 --- a/pkg/custom-apprun.sh.in +++ b/pkg/custom-apprun.sh.in @@ -17,6 +17,14 @@ case "${1-}" in exec=usr/bin/dprectool shift ;; + --drawpile-cmd) + exec=usr/bin/drawpile-cmd + shift + ;; + --drawpile-timelapse) + exec=usr/bin/drawpile-timelapse + shift + ;; --help) echo "Drawpile @GIT_VERSION@" echo @@ -31,6 +39,16 @@ case "${1-}" in echo " Runs dprectool, a command-line tool for" echo " interconverting Drawpile recordings." fi + if [ -x "$this_dir"/usr/bin/drawpile-cmd ]; then + echo "--drawpile-cmd" + echo " Runs drawpile-cmd, a command-line tool for" + echo " rendering Drawpile recordings to images." + fi + if [ -x "$this_dir"/usr/bin/drawpile-timelapse ]; then + echo "--drawpile-timelapse" + echo " Runs drawpile-timelapse, a command-line tool for" + echo " turning Drawpile recordings into timelapse videos." + fi exit 0 ;; esac diff --git a/src/drawdance/libengine/dpengine/image_transform.c b/src/drawdance/libengine/dpengine/image_transform.c index 58e4554652..7d4e50e44b 100644 --- a/src/drawdance/libengine/dpengine/image_transform.c +++ b/src/drawdance/libengine/dpengine/image_transform.c @@ -19,12 +19,6 @@ * If not otherwise noted, this code is wholly based on the Qt framework's * raster paint engine implementation, using it under the GNU General Public * License, version 3. See 3rdparty/licenses/qt/license.GPL3 for details. - * - * -------------------------------------------------------------------- - * - * Parts of this code are based on Krita, using it under the GNU General - * Public License, version 3. See 3rdparty/licenses/krita/COPYING.txt for - * details. */ #include "image_transform.h" #include "dpcommon/conversions.h" @@ -169,40 +163,13 @@ static DP_Pixel8 *fetch_transformed_pixels(int width, int height, return out_buffer; } -unsigned int get_span_opacity(int interpolation, int coverage) +static uint8_t get_span_opacity(int interpolation, int coverage) { switch (interpolation) { case DP_MSG_TRANSFORM_REGION_MODE_NEAREST: return coverage < 128 ? 0u : 255u; default: - return DP_int_to_uint(CLAMP(coverage, 0, 255)); - } -} - -// Multiplying two bytes as if they were floats between 0 and 1. -// Adapted from Krita, see license above. -static uint8_t mul(unsigned int a, unsigned int b) -{ - unsigned int c = a * b + 0x80u; - return DP_uint_to_uint8(((c >> 8u) + c) >> 8u); -} - -// Normal blending with 8 bit pixels. -static void process_span(int len, unsigned int opacity, - DP_Pixel8 *DP_RESTRICT src, DP_Pixel8 *DP_RESTRICT dst) -{ - for (int i = 0; i < len; ++i) { - DP_Pixel8 s = src[i]; - DP_Pixel8 d = dst[i]; - unsigned int sa1 = 255u - mul(s.a, opacity); - if (sa1 != 255u) { - dst[i] = (DP_Pixel8){ - .b = (uint8_t)(mul(s.b, opacity) + mul(d.b, sa1)), - .g = (uint8_t)(mul(s.g, opacity) + mul(d.g, sa1)), - .r = (uint8_t)(mul(s.r, opacity) + mul(d.r, sa1)), - .a = (uint8_t)(mul(s.a, opacity) + mul(d.a, sa1)), - }; - } + return DP_int_to_uint8(CLAMP(coverage, 0, 255)); } } @@ -252,8 +219,8 @@ static void render_spans(int count, const DP_FT_Span *spans, void *user) int pr = spans->x + spans->len; int pl = DP_min_int(l, pr - x); - process_span(pl, get_span_opacity(interpolation, coverage), - src + offset, dst + offset); + DP_blend_pixels8(dst + offset, src + offset, pl, + get_span_opacity(interpolation, coverage)); l -= pl; x += pl; @@ -277,9 +244,9 @@ static DP_FT_Vector transform_outline_point(DP_Transform tf, double x, double y) } bool DP_image_transform_draw(int src_width, int src_height, - const DP_Pixel8 *src_pixels, - DP_DrawContext *dc, DP_Image *dst_img, - DP_Transform tf, int interpolation) + const DP_Pixel8 *src_pixels, DP_DrawContext *dc, + DP_Image *dst_img, DP_Transform tf, + int interpolation) { DP_Transform delta = DP_transform_make(1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0 / 65536.0, 1.0 / 65536.0, 1.0); diff --git a/src/drawdance/libengine/dpengine/pixels.c b/src/drawdance/libengine/dpengine/pixels.c index 1b6ba539a2..69f3d060dd 100644 --- a/src/drawdance/libengine/dpengine/pixels.c +++ b/src/drawdance/libengine/dpengine/pixels.c @@ -2127,3 +2127,56 @@ void DP_posterize_mask(DP_Pixel15 *dst, int posterize_num, const uint16_t *mask, *dst = from_ubgra(posterize(p, o, DP_pixel15_unpremultiply(*dst))); }); } + + +// SPDX-SnippetBegin +// SPDX-License-Identifier: GPL-3.0-or-later +// SDPX—SnippetName: 8 bit multiplication adapted from Krita +static uint8_t mul(unsigned int a, unsigned int b) +{ + unsigned int c = a * b + 0x80u; + return DP_uint_to_uint8(((c >> 8u) + c) >> 8u); +} +// SPDX-SnippetEnd + +void DP_blend_color8_to(DP_Pixel8 *DP_RESTRICT out, + const DP_Pixel8 *DP_RESTRICT dst, DP_UPixel8 color, + int pixel_count, uint8_t opacity) +{ + DP_Pixel8 src = DP_pixel8_premultiply(color); + unsigned int sa1 = 255u - mul(src.a, opacity); + if (sa1 != 255u) { + unsigned int sb = mul(src.b, opacity); + unsigned int sg = mul(src.g, opacity); + unsigned int sr = mul(src.r, opacity); + unsigned int sa = mul(src.a, opacity); + for (int i = 0; i < pixel_count; ++i) { + DP_Pixel8 d = dst[i]; + out[i] = (DP_Pixel8){ + .b = (uint8_t)(sb + mul(d.b, sa1)), + .g = (uint8_t)(sg + mul(d.g, sa1)), + .r = (uint8_t)(sr + mul(d.r, sa1)), + .a = (uint8_t)(sa + mul(d.a, sa1)), + }; + } + } +} + +void DP_blend_pixels8(DP_Pixel8 *DP_RESTRICT dst, + const DP_Pixel8 *DP_RESTRICT src, int pixel_count, + uint8_t opacity) +{ + for (int i = 0; i < pixel_count; ++i) { + DP_Pixel8 s = src[i]; + DP_Pixel8 d = dst[i]; + unsigned int sa1 = 255u - mul(s.a, opacity); + if (sa1 != 255u) { + dst[i] = (DP_Pixel8){ + .b = (uint8_t)(mul(s.b, opacity) + mul(d.b, sa1)), + .g = (uint8_t)(mul(s.g, opacity) + mul(d.g, sa1)), + .r = (uint8_t)(mul(s.r, opacity) + mul(d.r, sa1)), + .a = (uint8_t)(mul(s.a, opacity) + mul(d.a, sa1)), + }; + } + } +} diff --git a/src/drawdance/libengine/dpengine/pixels.h b/src/drawdance/libengine/dpengine/pixels.h index 4c1373520f..67f263d746 100644 --- a/src/drawdance/libengine/dpengine/pixels.h +++ b/src/drawdance/libengine/dpengine/pixels.h @@ -173,4 +173,15 @@ void DP_posterize_mask(DP_Pixel15 *dst, int posterize_num, const uint16_t *mask, int base_skip); +// 8 bit pixel Normal blending. + +void DP_blend_color8_to(DP_Pixel8 *DP_RESTRICT out, + const DP_Pixel8 *DP_RESTRICT dst, DP_UPixel8 color, + int pixel_count, uint8_t opacity); + +void DP_blend_pixels8(DP_Pixel8 *DP_RESTRICT dst, + const DP_Pixel8 *DP_RESTRICT src, int pixel_count, + uint8_t opacity); + + #endif diff --git a/src/drawdance/rust/bindings.rs b/src/drawdance/rust/bindings.rs index ee21d00a00..772cee964d 100644 --- a/src/drawdance/rust/bindings.rs +++ b/src/drawdance/rust/bindings.rs @@ -1,6 +1,5 @@ -/* automatically generated by rust-bindgen 0.66.1 */ +/* automatically generated by rust-bindgen 0.68.1 */ -pub const DP_SIMD_ALIGNMENT: u32 = 32; pub const DP_DRAW_CONTEXT_STAMP_MAX_DIAMETER: u32 = 260; pub const DP_DRAW_CONTEXT_STAMP_BUFFER_SIZE: u32 = 67600; pub const DP_DRAW_CONTEXT_TRANSFORM_BUFFER_SIZE: u32 = 204; @@ -342,12 +341,6 @@ extern "C" { extern "C" { pub fn DP_free(ptr: *mut ::std::os::raw::c_void); } -extern "C" { - pub fn DP_malloc_simd(size: usize) -> *mut ::std::os::raw::c_void; -} -extern "C" { - pub fn DP_malloc_simd_zeroed(size: usize) -> *mut ::std::os::raw::c_void; -} extern "C" { pub fn DP_vformat( fmt: *const ::std::os::raw::c_char, @@ -1916,6 +1909,23 @@ extern "C" { base_skip: ::std::os::raw::c_int, ); } +extern "C" { + pub fn DP_blend_color8_to( + out: *mut DP_Pixel8, + dst: *const DP_Pixel8, + color: DP_UPixel8, + pixel_count: ::std::os::raw::c_int, + opacity: u8, + ); +} +extern "C" { + pub fn DP_blend_pixels8( + dst: *mut DP_Pixel8, + src: *const DP_Pixel8, + pixel_count: ::std::os::raw::c_int, + opacity: u8, + ); +} pub const DP_IMAGE_FILE_TYPE_GUESS: DP_ImageFileType = 0; pub const DP_IMAGE_FILE_TYPE_PNG: DP_ImageFileType = 1; pub const DP_IMAGE_FILE_TYPE_JPEG: DP_ImageFileType = 2; @@ -4015,6 +4025,7 @@ extern "C" { out_lle: *mut *mut DP_LayerListEntry, out_lp: *mut *mut DP_LayerProps, out_os: *mut *const DP_OnionSkin, + out_parent_opacity: *mut u16, ) -> DP_ViewModeContext; } extern "C" { @@ -5149,6 +5160,99 @@ extern "C" { extern "C" { pub fn DP_paint_engine_sample_canvas_state_inc(pe: *mut DP_PaintEngine) -> *mut DP_CanvasState; } +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct DP_SaveFormat { + pub title: *const ::std::os::raw::c_char, + pub extensions: *mut *const ::std::os::raw::c_char, +} +#[test] +fn bindgen_test_layout_DP_SaveFormat() { + const UNINIT: ::std::mem::MaybeUninit = ::std::mem::MaybeUninit::uninit(); + let ptr = UNINIT.as_ptr(); + assert_eq!( + ::std::mem::size_of::(), + 16usize, + concat!("Size of: ", stringify!(DP_SaveFormat)) + ); + assert_eq!( + ::std::mem::align_of::(), + 8usize, + concat!("Alignment of ", stringify!(DP_SaveFormat)) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).title) as usize - ptr as usize }, + 0usize, + concat!( + "Offset of field: ", + stringify!(DP_SaveFormat), + "::", + stringify!(title) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).extensions) as usize - ptr as usize }, + 8usize, + concat!( + "Offset of field: ", + stringify!(DP_SaveFormat), + "::", + stringify!(extensions) + ) + ); +} +extern "C" { + pub fn DP_save_supported_formats() -> *const DP_SaveFormat; +} +pub const DP_SAVE_IMAGE_GUESS: DP_SaveImageType = 0; +pub const DP_SAVE_IMAGE_ORA: DP_SaveImageType = 1; +pub const DP_SAVE_IMAGE_PNG: DP_SaveImageType = 2; +pub const DP_SAVE_IMAGE_JPEG: DP_SaveImageType = 3; +pub type DP_SaveImageType = ::std::os::raw::c_uint; +pub const DP_SAVE_RESULT_SUCCESS: DP_SaveResult = 0; +pub const DP_SAVE_RESULT_BAD_ARGUMENTS: DP_SaveResult = 1; +pub const DP_SAVE_RESULT_NO_EXTENSION: DP_SaveResult = 2; +pub const DP_SAVE_RESULT_UNKNOWN_FORMAT: DP_SaveResult = 3; +pub const DP_SAVE_RESULT_FLATTEN_ERROR: DP_SaveResult = 4; +pub const DP_SAVE_RESULT_OPEN_ERROR: DP_SaveResult = 5; +pub const DP_SAVE_RESULT_WRITE_ERROR: DP_SaveResult = 6; +pub const DP_SAVE_RESULT_INTERNAL_ERROR: DP_SaveResult = 7; +pub const DP_SAVE_RESULT_CANCEL: DP_SaveResult = 8; +pub type DP_SaveResult = ::std::os::raw::c_uint; +extern "C" { + pub fn DP_save( + cs: *mut DP_CanvasState, + dc: *mut DP_DrawContext, + type_: DP_SaveImageType, + path: *const ::std::os::raw::c_char, + ) -> DP_SaveResult; +} +pub type DP_SaveAnimationProgressFn = ::std::option::Option< + unsafe extern "C" fn(user: *mut ::std::os::raw::c_void, progress: f64) -> bool, +>; +extern "C" { + pub fn DP_save_animation_frames( + cs: *mut DP_CanvasState, + path: *const ::std::os::raw::c_char, + crop: *mut DP_Rect, + start: ::std::os::raw::c_int, + end_inclusive: ::std::os::raw::c_int, + progress_fn: DP_SaveAnimationProgressFn, + user: *mut ::std::os::raw::c_void, + ) -> DP_SaveResult; +} +extern "C" { + pub fn DP_save_animation_gif( + cs: *mut DP_CanvasState, + path: *const ::std::os::raw::c_char, + crop: *mut DP_Rect, + start: ::std::os::raw::c_int, + end_inclusive: ::std::os::raw::c_int, + framerate: ::std::os::raw::c_int, + progress_fn: DP_SaveAnimationProgressFn, + user: *mut ::std::os::raw::c_void, + ) -> DP_SaveResult; +} pub const DP_ACCESS_TIER_OPERATOR: DP_AccessTier = 0; pub const DP_ACCESS_TIER_TRUSTED: DP_AccessTier = 1; pub const DP_ACCESS_TIER_AUTHENTICATED: DP_AccessTier = 2; @@ -8572,6 +8676,12 @@ extern "C" { bufsize: usize, ) -> *mut DP_Message; } +extern "C" { + pub fn DP_message_compat_flag_indirect(msg: *mut DP_Message) -> bool; +} +extern "C" { + pub fn DP_message_compat_flag_indirect_set(msg: *mut DP_Message); +} extern "C" { pub fn DP_msg_draw_dabs_classic_indirect(mddc: *mut DP_MsgDrawDabsClassic) -> bool; } diff --git a/src/drawdance/rust/engine/acl.rs b/src/drawdance/rust/engine/acl.rs new file mode 100644 index 0000000000..cf32734c6e --- /dev/null +++ b/src/drawdance/rust/engine/acl.rs @@ -0,0 +1,22 @@ +use crate::{DP_AclState, DP_acl_state_free, DP_acl_state_new}; + +pub struct AclState { + acls: *mut DP_AclState, +} + +impl AclState { + pub fn new() -> Self { + let acls = unsafe { DP_acl_state_new() }; + AclState { acls } + } + + pub fn as_ptr(&mut self) -> *mut DP_AclState { + self.acls + } +} + +impl Drop for AclState { + fn drop(&mut self) { + unsafe { DP_acl_state_free(self.acls) } + } +} diff --git a/src/drawdance/rust/engine/draw_context.rs b/src/drawdance/rust/engine/draw_context.rs new file mode 100644 index 0000000000..45bac80b8d --- /dev/null +++ b/src/drawdance/rust/engine/draw_context.rs @@ -0,0 +1,22 @@ +use crate::{DP_DrawContext, DP_draw_context_free, DP_draw_context_new}; + +pub struct DrawContext { + dc: *mut DP_DrawContext, +} + +impl DrawContext { + pub fn new() -> Self { + let dc = unsafe { DP_draw_context_new() }; + DrawContext { dc } + } + + pub fn as_ptr(&mut self) -> *mut DP_DrawContext { + self.dc + } +} + +impl Drop for DrawContext { + fn drop(&mut self) { + unsafe { DP_draw_context_free(self.dc) } + } +} diff --git a/src/drawdance/rust/engine/image.rs b/src/drawdance/rust/engine/image.rs new file mode 100644 index 0000000000..0ecea5cdef --- /dev/null +++ b/src/drawdance/rust/engine/image.rs @@ -0,0 +1,244 @@ +use core::slice; +use std::{ + ffi::{c_int, CString, NulError}, + io::{self}, + mem::size_of, + num::TryFromIntError, + ptr::{self, copy_nonoverlapping}, +}; + +use crate::{ + dp_error, DP_Image, DP_Output, DP_Quad, DP_UPixel8, DP_blend_color8_to, + DP_file_output_new_from_path, DP_image_free, DP_image_height, DP_image_new, + DP_image_new_subimage, DP_image_pixels, DP_image_transform_pixels, DP_image_width, + DP_image_write_jpeg, DP_image_write_png, DP_output_free, DP_MSG_TRANSFORM_REGION_MODE_BILINEAR, +}; + +use super::DrawContext; + +pub struct Image { + image: *mut DP_Image, +} + +#[derive(Debug)] +pub struct ImageError { + pub message: String, +} + +impl ImageError { + pub fn from_dp_error() -> Self { + Self { + message: dp_error(), + } + } +} + +impl From<&str> for ImageError { + fn from(value: &str) -> Self { + Self { + message: value.to_owned(), + } + } +} + +impl From for ImageError { + fn from(value: TryFromIntError) -> Self { + Self { + message: value.to_string(), + } + } +} + +impl From for ImageError { + fn from(value: NulError) -> Self { + Self { + message: value.to_string(), + } + } +} + +impl Image { + pub fn new(width: usize, height: usize) -> Result { + if width > 0 && height > 0 { + let w = c_int::try_from(width)?; + let h = c_int::try_from(height)?; + let image = unsafe { DP_image_new(w, h) }; + Ok(Self { image }) + } else { + Err(ImageError::from("Empty image")) + } + } + + pub fn new_from_pixels( + width: usize, + height: usize, + pixels: &[u32], + ) -> Result { + let count = width * height; + if pixels.len() >= count { + let img = Self::new(width, height)?; + unsafe { + copy_nonoverlapping( + pixels.as_ptr(), + DP_image_pixels(img.image).cast::(), + count, + ); + } + Ok(img) + } else { + Err(ImageError::from("Not enough pixels")) + } + } + + pub fn new_from_pixels_scaled( + width: usize, + height: usize, + pixels: &[u32], + scale_width: usize, + scale_height: usize, + expand: bool, + dc: &mut DrawContext, + ) -> Result { + if width == 0 || height == 0 { + return Err(ImageError::from("Empty source image")); + } + + if scale_width == 0 || scale_height == 0 { + return Err(ImageError::from("Empty target image")); + } + + let count = width * height; + if pixels.len() < count { + return Err(ImageError::from("Not enough pixels")); + } + + let xratio = scale_width as f64 / width as f64; + let yratio = scale_height as f64 / height as f64; + let (target_width, target_height) = if (xratio - yratio).abs() < 0.01 { + (scale_width, scale_height) + } else if xratio <= yratio { + (scale_width, (height as f64 * xratio) as usize) + } else { + ((width as f64 * yratio) as usize, scale_height) + }; + + let right = c_int::try_from(target_width - 1)?; + let bottom = c_int::try_from(target_height - 1)?; + let dst_quad = DP_Quad { + x1: 0, + y1: 0, + x2: right, + y2: 0, + x3: right, + y3: bottom, + x4: 0, + y4: bottom, + }; + + let image = unsafe { + DP_image_transform_pixels( + c_int::try_from(width)?, + c_int::try_from(height)?, + pixels.as_ptr().cast(), + dc.as_ptr(), + &dst_quad, + DP_MSG_TRANSFORM_REGION_MODE_BILINEAR as i32, + ptr::null_mut(), + ptr::null_mut(), + ) + }; + if image.is_null() { + return Err(ImageError::from_dp_error()); + } + + let img = Image { image }; + if expand && (target_width != scale_width || target_height != scale_height) { + let subimg = unsafe { + DP_image_new_subimage( + img.image, + -c_int::try_from((scale_width - target_width) / 2usize)?, + -c_int::try_from((scale_height - target_height) / 2usize)?, + c_int::try_from(scale_width)?, + c_int::try_from(scale_height)?, + ) + }; + if subimg.is_null() { + Err(ImageError::from_dp_error()) + } else { + Ok(Image { image: subimg }) + } + } else { + Ok(img) + } + } + + pub fn width(&self) -> usize { + unsafe { DP_image_width(self.image) as usize } + } + + pub fn height(&self) -> usize { + unsafe { DP_image_height(self.image) as usize } + } + + pub fn dump(&self, writer: &mut dyn io::Write) -> io::Result<()> { + let pixels = unsafe { DP_image_pixels(self.image) }; + let size = self.width() * self.height() * size_of::(); + writer.write_all(unsafe { slice::from_raw_parts(pixels.cast::(), size) }) + } + + pub fn write_png(&self, path: &str) -> Result<(), ImageError> { + self.write(path, DP_image_write_png) + } + + pub fn write_jpeg(&self, path: &str) -> Result<(), ImageError> { + self.write(path, DP_image_write_jpeg) + } + + fn write( + &self, + path: &str, + func: unsafe extern "C" fn(*mut DP_Image, *mut DP_Output) -> bool, + ) -> Result<(), ImageError> { + let cpath = CString::new(path)?; + let output = unsafe { DP_file_output_new_from_path(cpath.as_ptr()) }; + if output.is_null() { + return Err(ImageError::from_dp_error()); + } + let result = match unsafe { func(self.image, output) } { + true => Ok(()), + false => Err(ImageError::from_dp_error()), + }; + unsafe { DP_output_free(output) }; + result + } + + pub fn blend_with( + &mut self, + src: &Image, + color: DP_UPixel8, + opacity: u8, + ) -> Result<(), ImageError> { + let w = self.width(); + let h = self.height(); + if w != src.width() || h != src.height() { + return Err(ImageError::from("Mismatched dimensions")); + } + + unsafe { + DP_blend_color8_to( + DP_image_pixels(self.image), + DP_image_pixels(src.image), + color, + (w * h) as i32, + opacity, + ); + } + Ok(()) + } +} + +impl Drop for Image { + fn drop(&mut self) { + unsafe { DP_image_free(self.image) } + } +} diff --git a/src/drawdance/rust/engine/mod.rs b/src/drawdance/rust/engine/mod.rs index 55a7d53d8e..89d5b2d53a 100644 --- a/src/drawdance/rust/engine/mod.rs +++ b/src/drawdance/rust/engine/mod.rs @@ -1,7 +1,15 @@ // SPDX-License-Identifier: GPL-3.0-or-later +mod acl; +mod draw_context; +mod image; +mod paint_engine; mod player; mod recorder; +pub use acl::AclState; +pub use draw_context::DrawContext; +pub use image::{Image, ImageError}; +pub use paint_engine::{PaintEngine, PaintEngineError}; pub use player::{Player, PlayerError}; pub use recorder::{Recorder, RecorderError}; diff --git a/src/drawdance/rust/engine/paint_engine.rs b/src/drawdance/rust/engine/paint_engine.rs new file mode 100644 index 0000000000..30f12b1ea5 --- /dev/null +++ b/src/drawdance/rust/engine/paint_engine.rs @@ -0,0 +1,344 @@ +use super::{image::ImageError, AclState, DrawContext, Image, Player}; +use crate::{ + dp_error, msg::Message, DP_AnnotationList, DP_CanvasState, DP_DocumentMetadata, + DP_LayerPropsList, DP_Message, DP_PaintEngine, DP_Pixel8, DP_PlayerResult, DP_Rect, + DP_Timeline, DP_canvas_state_decref, DP_paint_engine_free_join, DP_paint_engine_handle_inc, + DP_paint_engine_new_inc, DP_paint_engine_playback_begin, DP_paint_engine_playback_play, + DP_paint_engine_playback_skip_by, DP_paint_engine_playback_step, + DP_paint_engine_render_everything, DP_paint_engine_tick, DP_paint_engine_view_canvas_state_inc, + DP_save, DP_PLAYER_RECORDING_END, DP_PLAYER_SUCCESS, DP_SAVE_IMAGE_ORA, DP_SAVE_RESULT_SUCCESS, + DP_TILE_SIZE, +}; +use std::{ + ffi::{c_int, c_longlong, c_uint, c_void, CString}, + ptr, + sync::{ + mpsc::{sync_channel, Receiver, SyncSender}, + Barrier, + }, + time::{SystemTime, UNIX_EPOCH}, +}; + +pub struct PaintEngine { + paint_dc: DrawContext, + main_dc: DrawContext, + preview_dc: DrawContext, + acls: AclState, + paint_engine: *mut DP_PaintEngine, + render_barrier: Barrier, + render_width: usize, + render_height: usize, + render_image: Vec, + playback_channel: (SyncSender, Receiver), +} + +#[derive(Debug)] +pub enum PaintEngineError { + PlayerError(DP_PlayerResult, String), +} + +impl PaintEngineError { + fn check_player_result(result: DP_PlayerResult) -> Result<(), Self> { + if result == DP_PLAYER_SUCCESS || result == DP_PLAYER_RECORDING_END { + Ok(()) + } else { + Err(Self::PlayerError(result, dp_error())) + } + } +} + +impl PaintEngine { + const TILE_SIZE: usize = DP_TILE_SIZE as usize; + + pub fn new(player: Option) -> Box { + let mut pe = Box::new(Self { + paint_dc: DrawContext::new(), + main_dc: DrawContext::new(), + preview_dc: DrawContext::new(), + acls: AclState::new(), + paint_engine: ptr::null_mut(), + render_barrier: Barrier::new(2), + render_width: 0, + render_height: 0, + render_image: Vec::new(), + playback_channel: sync_channel(1), + }); + let user: *mut Self = &mut *pe; + pe.paint_engine = unsafe { + DP_paint_engine_new_inc( + pe.paint_dc.as_ptr(), + pe.main_dc.as_ptr(), + pe.preview_dc.as_ptr(), + pe.acls.as_ptr(), + ptr::null_mut(), + Some(Self::on_renderer_tile), + Some(Self::on_renderer_unlock), + Some(Self::on_renderer_resize), + user.cast(), + Some(Self::on_save_point), + user.cast(), + false, + ptr::null(), + Some(Self::on_get_time_ms), + ptr::null_mut(), + match player { + Some(p) => p.move_to_ptr(), + None => ptr::null_mut(), + }, + Some(Self::on_playback), + None, + user.cast(), + ) + }; + pe + } + + pub fn render_width(&self) -> usize { + self.render_width + } + + pub fn render_height(&self) -> usize { + self.render_height + } + + extern "C" fn on_renderer_tile( + user: *mut c_void, + tile_x: c_int, + tile_y: c_int, + pixels: *mut DP_Pixel8, + ) { + let pe = unsafe { user.cast::().as_mut().unwrap_unchecked() }; + let from_x = tile_x as usize * Self::TILE_SIZE; + let from_y = tile_y as usize * Self::TILE_SIZE; + let to_x = (from_x + Self::TILE_SIZE).min(pe.render_width); + let to_y = (from_y + Self::TILE_SIZE).min(pe.render_height); + let width = to_x - from_x; + let height = to_y - from_y; + for i in 0..height { + let src = i * Self::TILE_SIZE; + let dst = (from_y + i) * pe.render_width + from_x; + unsafe { + ptr::copy_nonoverlapping( + pixels.cast::().offset(src as isize), + pe.render_image.as_mut_ptr().offset(dst as isize), + width, + ); + } + } + } + + extern "C" fn on_renderer_unlock(user: *mut c_void) { + let pe = unsafe { user.cast::().as_mut().unwrap_unchecked() }; + pe.render_barrier.wait(); + } + + extern "C" fn on_renderer_resize( + user: *mut c_void, + width: c_int, + height: c_int, + _prev_width: c_int, + _prev_height: c_int, + _offset_x: c_int, + _offset_y: c_int, + ) { + let pe = unsafe { user.cast::().as_mut().unwrap_unchecked() }; + let w = width as usize; + let h = height as usize; + pe.render_width = w; + pe.render_height = h; + pe.render_image.resize(w * h, 0); + } + + extern "C" fn on_save_point( + _user: *mut c_void, + _cs: *mut DP_CanvasState, + _snapshot_requested: bool, + ) { + } + + extern "C" fn on_get_time_ms(_user: *mut c_void) -> c_longlong { + match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(d) => d.as_millis() as i64, + Err(_) => 0, + } + } + + extern "C" fn on_playback(user: *mut c_void, position: c_longlong) { + let pe = unsafe { user.cast::().as_mut().unwrap_unchecked() }; + pe.playback_channel.0.send(position).unwrap(); + } + + pub fn begin_playback(&mut self) -> Result<(), PaintEngineError> { + PaintEngineError::check_player_result(unsafe { + DP_paint_engine_playback_begin(self.paint_engine) + }) + } + + pub fn step_playback(&mut self, steps: i64) -> Result { + self.playback(|paint_engine, user| unsafe { + DP_paint_engine_playback_step(paint_engine, steps, Some(Self::on_push_message), user) + }) + } + + pub fn skip_playback(&mut self, steps: i64) -> Result { + self.playback(|paint_engine, user| unsafe { + DP_paint_engine_playback_skip_by( + paint_engine, + ptr::null_mut(), + steps, + false, + Some(Self::on_push_message), + user, + ) + }) + } + + pub fn play_playback(&mut self, msecs: i64) -> Result { + self.playback(|paint_engine, user| unsafe { + DP_paint_engine_playback_play(paint_engine, msecs, Some(Self::on_push_message), user) + }) + } + + fn playback(&mut self, func: F) -> Result + where + F: FnOnce(*mut DP_PaintEngine, *mut c_void) -> DP_PlayerResult, + { + let mut messages: Vec = Vec::new(); + let user: *mut Vec = &mut messages; + PaintEngineError::check_player_result(func(self.paint_engine, user.cast()))?; + self.handle(false, true, &mut messages); + Ok(self.playback_channel.1.recv().unwrap()) + } + + pub fn handle( + &mut self, + local: bool, + override_acls: bool, + messages: &mut Vec, + ) -> c_int { + let user: *mut Self = self; + unsafe { + DP_paint_engine_handle_inc( + self.paint_engine, + local, + override_acls, + messages.len() as c_int, + messages.as_mut_ptr().cast(), + Some(Self::on_acls_changed), + Some(Self::on_laser_trail), + Some(Self::on_move_pointer), + user.cast(), + ) + } + } + + extern "C" fn on_push_message(user: *mut c_void, msg: *mut DP_Message) { + let messages = unsafe { user.cast::>().as_mut().unwrap_unchecked() }; + messages.push(Message::new_noinc(msg)); + } + + extern "C" fn on_acls_changed(_user: *mut c_void, _acl_change_flags: c_int) {} + + extern "C" fn on_laser_trail( + _user: *mut c_void, + _context_id: c_uint, + _persistence: c_int, + _color: u32, + ) { + } + + extern "C" fn on_move_pointer(_user: *mut c_void, _context_id: c_uint, _x: c_int, _y: c_int) {} + + pub fn render(&mut self) { + let user: *mut Self = self; + let tile_bounds = DP_Rect { + x1: 0, + y1: 0, + x2: u16::MAX as c_int, + y2: u16::MAX as c_int, + }; + unsafe { + DP_paint_engine_tick( + self.paint_engine, + tile_bounds, + false, + Some(Self::on_catchup), + Some(Self::on_reset_lock_changed), + Some(Self::on_recorder_state_changed), + Some(Self::on_layer_props_changed), + Some(Self::on_annotations_changed), + Some(Self::on_document_metadata_changed), + Some(Self::on_timeline_changed), + Some(Self::on_cursor_moved), + Some(Self::on_default_layer_set), + Some(Self::on_undo_depth_limit_set), + user.cast(), + ); + DP_paint_engine_render_everything(self.paint_engine); + } + self.render_barrier.wait(); + } + + extern "C" fn on_catchup(_user: *mut c_void, _progress: c_int) {} + extern "C" fn on_reset_lock_changed(_user: *mut c_void, _locked: bool) {} + extern "C" fn on_recorder_state_changed(_user: *mut c_void, _started: bool) {} + extern "C" fn on_layer_props_changed(_user: *mut c_void, _lpl: *mut DP_LayerPropsList) {} + extern "C" fn on_annotations_changed(_user: *mut c_void, _al: *mut DP_AnnotationList) {} + extern "C" fn on_document_metadata_changed(_user: *mut c_void, _dm: *mut DP_DocumentMetadata) {} + extern "C" fn on_timeline_changed(_user: *mut c_void, _tl: *mut DP_Timeline) {} + extern "C" fn on_cursor_moved( + _user: *mut c_void, + _flags: c_uint, + _context_id: c_uint, + _layer_id: c_int, + _x: c_int, + _y: c_int, + ) { + } + extern "C" fn on_default_layer_set(_user: *mut c_void, _layer_id: c_int) {} + extern "C" fn on_undo_depth_limit_set(_user: *mut c_void, _undo_depth_limit: c_int) {} + + pub fn write_ora(&mut self, path: &str) -> Result<(), ImageError> { + let cpath = CString::new(path)?; + let cs = unsafe { DP_paint_engine_view_canvas_state_inc(self.paint_engine) }; + let result = + unsafe { DP_save(cs, self.main_dc.as_ptr(), DP_SAVE_IMAGE_ORA, cpath.as_ptr()) }; + unsafe { DP_canvas_state_decref(cs) } + if result == DP_SAVE_RESULT_SUCCESS { + Ok(()) + } else { + Err(ImageError::from_dp_error()) + } + } + + pub fn to_image(&self) -> Result { + Ok(Image::new_from_pixels( + self.render_width, + self.render_height, + &self.render_image, + )?) + } + + pub fn to_scaled_image( + &mut self, + width: usize, + height: usize, + expand: bool, + ) -> Result { + Ok(Image::new_from_pixels_scaled( + self.render_width, + self.render_height, + &self.render_image, + width, + height, + expand, + &mut self.main_dc, + )?) + } +} + +impl Drop for PaintEngine { + fn drop(&mut self) { + unsafe { DP_paint_engine_free_join(self.paint_engine) } + } +} diff --git a/src/drawdance/rust/engine/player.rs b/src/drawdance/rust/engine/player.rs index 5c756078cd..dd638e6745 100644 --- a/src/drawdance/rust/engine/player.rs +++ b/src/drawdance/rust/engine/player.rs @@ -84,6 +84,12 @@ impl Player { } } + pub fn move_to_ptr(mut self) -> *mut DP_Player { + let player = self.player; + self.player = ptr::null_mut(); + player + } + pub fn player_type(&self) -> DP_PlayerType { unsafe { DP_player_type(self.player) } } diff --git a/src/drawdance/rust/msg/message.rs b/src/drawdance/rust/msg/message.rs index 79e61c7366..64cc0c1991 100644 --- a/src/drawdance/rust/msg/message.rs +++ b/src/drawdance/rust/msg/message.rs @@ -5,6 +5,7 @@ use crate::{ }; use std::{ffi::CStr, ptr}; +#[repr(C)] pub struct Message { msg: *mut DP_Message, } diff --git a/src/drawdance/rust/wrapper.h b/src/drawdance/rust/wrapper.h index 591814315b..f32c9152e1 100644 --- a/src/drawdance/rust/wrapper.h +++ b/src/drawdance/rust/wrapper.h @@ -1,5 +1,10 @@ #include #include +#include +#include +#include #include +#include +#include #include #include diff --git a/src/tools/CMakeLists.txt b/src/tools/CMakeLists.txt index 5cc43064db..43f8fb4af8 100644 --- a/src/tools/CMakeLists.txt +++ b/src/tools/CMakeLists.txt @@ -1,9 +1,20 @@ # SPDX-License-Identifier: MIT set_property(SOURCE dprectool/main.c PROPERTY SKIP_AUTOGEN ON) +set_property(SOURCE drawpile-cmd/main.c PROPERTY SKIP_AUTOGEN ON) add_cargo_library(dprectool_rust dprectool) add_executable(dprectool dprectool/main.c) target_link_libraries(dprectool PUBLIC dprectool_rust dpengine cmake-config) -install(TARGETS dprectool) +add_cargo_library(drawpile-cmd_rust drawpilecmd) +add_executable(drawpile-cmd drawpile-cmd/main.c) +target_link_libraries(drawpile-cmd PUBLIC drawpile-cmd_rust dpengine cmake-config) + +add_cargo_library(drawpile-timelapse_rust drawpiletimelapse) +add_executable( + drawpile-timelapse drawpile-timelapse/main.cpp drawpile-timelapse/logo.qrc) +target_link_libraries(drawpile-timelapse PUBLIC + Qt${QT_VERSION_MAJOR}::Core drawpile-timelapse_rust dpengine cmake-config) + +install(TARGETS dprectool drawpile-cmd) diff --git a/src/tools/drawpile-cmd/Cargo.toml b/src/tools/drawpile-cmd/Cargo.toml new file mode 100644 index 0000000000..2f8c747590 --- /dev/null +++ b/src/tools/drawpile-cmd/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "drawpilecmd" +version = "2.2.0-pre" +rust-version = "1.66.1" +edition = "2021" + +[lib] +crate-type = ["staticlib"] +path = "lib.rs" + +[dependencies] +drawdance = { path = "../../drawdance/rust" } +xflags = "0.3.1" diff --git a/src/tools/drawpile-cmd/lib.rs b/src/tools/drawpile-cmd/lib.rs new file mode 100644 index 0000000000..3ee6b54333 --- /dev/null +++ b/src/tools/drawpile-cmd/lib.rs @@ -0,0 +1,425 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +use drawdance::{ + dp_cmake_config_version, + engine::{ImageError, PaintEngine, PaintEngineError, Player, PlayerError}, + DP_PLAYER_TYPE_GUESS, DP_PROTOCOL_VERSION, +}; +use std::{ + ffi::{c_int, CStr}, + fs::metadata, + io::{self}, + path::Path, + str::FromStr, +}; + +#[derive(Copy, Clone, Debug)] +pub struct ImageSize { + width: usize, + height: usize, +} + +impl FromStr for ImageSize { + type Err = String; + + fn from_str(s: &str) -> Result { + if let Some((a, b)) = s.to_lowercase().split_once("x") { + let width: usize = a.parse().unwrap_or(0); + let height: usize = b.parse().unwrap_or(0); + if width != 0 && height != 0 { + return Ok(ImageSize { width, height }); + } + } + Err(format!( + "Invalid image size '{s}', must be given as WIDTHxHEIGHT" + )) + } +} + +#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)] +pub enum OutputFormat { + #[default] + Guess, + Ora, + Png, + Jpg, + Jpeg, +} + +impl OutputFormat { + fn suffix(&self) -> &'static str { + match self { + Self::Ora => "ora", + Self::Png => "png", + Self::Jpg => "jpg", + Self::Guess | Self::Jpeg => "jpeg", + } + } +} + +impl FromStr for OutputFormat { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "guess" => Ok(Self::Guess), + "ora" => Ok(Self::Ora), + "png" => Ok(Self::Png), + "jpg" => Ok(Self::Jpg), + "jpeg" => Ok(Self::Jpeg), + _ => Err(format!( + "invalid output format '{s}', should be one of 'guess', \ + 'ora', 'png', 'jpg' or 'jpeg'" + )), + } + } +} + +#[derive(Clone, Copy)] +enum Every { + None, + UndoPoint, + Message, +} + +#[derive(Debug)] +pub struct CmdError { + message: String, +} + +impl From for CmdError { + fn from(err: PlayerError) -> Self { + CmdError { + message: format!( + "Input error: {}", + match &err { + PlayerError::DpError(s) => s, + PlayerError::LoadError(_, s) => s, + PlayerError::ResultError(_, s) => s, + PlayerError::NulError(_) => "Null path", + } + ), + } + } +} + +impl From for CmdError { + fn from(err: PaintEngineError) -> Self { + CmdError { + message: match &err { + PaintEngineError::PlayerError(_, msg) => format!("Player error: {}", msg), + }, + } + } +} + +impl From for CmdError { + fn from(err: ImageError) -> Self { + CmdError { + message: format!("ImageError: {}", err.message), + } + } +} + +impl From for CmdError { + fn from(err: io::Error) -> Self { + CmdError { + message: format!("I/O Error: {}", err.to_string()), + } + } +} + +#[no_mangle] +pub extern "C" fn drawpile_cmd_main() -> c_int { + drawdance::init(); + + let flags = xflags::parse_or_exit! { + /// Displays version information and exits. + optional -v,--version + /// Print extra debugging information. + optional -V,--verbose + /// Output file format. One of 'guess' (the default), 'ora', 'png', + /// 'jpg' or 'jpeg'. The last two are the same format, just with a + /// different extension. Guessing will either use the extension from the + /// value given by -o/--out or default to jpeg. + optional -f,--format output_format: OutputFormat + /// Output file pattern. Use '-' for stdout. The default is to use the + /// input file name with a different file extension. + optional -o,--out output_pattern: String + /// Merging annotations is not supported. Passing this option will just + /// print a warning and has no effect otherwise. + optional -a,--merge-annotations + /// Export image every n sequences. A sequence is anything that can be + /// undone, like a single brush stroke. This option is mutually + /// incompatible with --every-msg. + optional -e,--every-seq every_seq: i64 + /// Export image every n messages. A message is a fine-grained drawing + /// command, a single brush stroke can consist of dozens of these. This + /// option is mutually incompatible with --every-seq. + optional -m,--every-msg every_seq: i64 + /// Performs ACL filtering. This will filter out any commands that the + /// user wasn't allowed to actually perform, such as drawing on a layer + /// that they didn't have permission to draw on. The Drawpile client + /// would also filter these out when playing back a recording. + optional -A,--acl + /// Maximum image size. Any images larger than this will be scaled down, + /// retaining the aspect ratio. This option is not supported for the ora + /// output format. + optional -s,--maxsize max_size: ImageSize + /// Make all images the same size. If --maxsize is given, that size is + /// used, otherwise it is guessed from the first saved image. All images + /// will be scaled, retaining aspect ratio. Blank space is left + /// transparent or black, depending on the output format. This option is + /// not supported for the output ora format. + optional -S,--fixedsize + /// Input recording file(s). + repeated input: String + }; + + if flags.version { + println!("drawpile-cmd {}", dp_cmake_config_version()); + println!( + "Protocol version: {}", + CStr::from_bytes_with_nul(DP_PROTOCOL_VERSION) + .unwrap() + .to_str() + .unwrap() + ); + return 0; + } + + if flags.merge_annotations { + eprintln!("Warning: -a/--merge-annotations has no effect"); + } + + let (every, steps) = if flags.every_seq.is_some() && flags.every_msg.is_some() { + eprintln!("-e/--every-seq and -m/--every-msg are mutually incompatible"); + return 2; + } else if let Some(every_seq) = flags.every_seq { + if every_seq < 1 { + eprintln!("-e/--every-seq must be >= 1"); + return 2; + } + (Every::UndoPoint, every_seq) + } else if let Some(every_msg) = flags.every_msg { + if every_msg < 1 { + eprintln!("-m/--every-msg must be >= 1"); + return 2; + } + (Every::Message, every_msg) + } else { + (Every::None, 0i64) + }; + + let input_paths = flags.input; + if input_paths.is_empty() { + eprintln!("No input file given"); + return 2; + } + + let mut format = flags.format.unwrap_or_default(); + let pre_out_pattern = if let Some(out) = flags.out { + if metadata(&out).map(|m| m.is_dir()).unwrap_or(false) { + format!( + "{}/{}-:idx:.{}", + out, + Path::new(&input_paths[0]) + .file_stem() + .unwrap_or_default() + .to_string_lossy(), + format.suffix() + ) + } else { + out + } + } else { + let first_path = Path::new(&input_paths[0]); + format!( + "{}{}-:idx:.{}", + get_dir_prefix(first_path), + first_path.file_stem().unwrap_or_default().to_string_lossy(), + format.suffix() + ) + }; + + if format == OutputFormat::Guess { + format = if let Some(ext) = Path::new(&pre_out_pattern) + .extension() + .map(|s| s.to_string_lossy()) + { + match ext.to_lowercase().as_str() { + "ora" => OutputFormat::Ora, + "png" => OutputFormat::Png, + "jpg" => OutputFormat::Jpg, + "jpeg" => OutputFormat::Jpeg, + _ => { + eprintln!("Can't guess output format for extension '.{}'", ext); + return 2; + } + } + } else { + eprintln!("Can't guess output format from '{}'", pre_out_pattern); + return 2; + } + } + + if flags.maxsize.is_some() && format == OutputFormat::Ora { + eprintln!("The ora output format doesn't support -s/--max-size"); + return 2; + } + + let out_path = Path::new(&pre_out_pattern); + let out_dir = get_dir_prefix(out_path); + let out_stem = out_path.file_stem().unwrap_or_default().to_string_lossy(); + let out_suffix = format.suffix(); + let out_pattern = if pre_out_pattern.contains(":idx:") { + format!("{}{}.{}", out_dir, out_stem, out_suffix) + } else { + format!("{}{}-:idx:.{}", out_dir, out_stem, out_suffix) + }; + + let acl_override = !flags.acl; + + match dump_recordings( + &input_paths, + acl_override, + every, + steps, + flags.maxsize, + flags.fixedsize, + format, + &out_pattern, + ) { + Ok(_) => 0, + Err(e) => { + eprintln!("{}", e.message); + 1 + } + } +} + +fn get_dir_prefix(path: &Path) -> String { + let dir = path + .parent() + .map(|p| p.to_string_lossy()) + .unwrap_or_default(); + if dir.is_empty() { + "".to_owned() + } else { + format!("{}/", dir) + } +} + +fn dump_recordings( + input_paths: &Vec, + acl_override: bool, + every: Every, + steps: i64, + max_size: Option, + fixed_size: bool, + format: OutputFormat, + out_pattern: &str, +) -> Result<(), CmdError> { + let mut index = 1; + for input_path in input_paths { + dump_recording( + &mut index, + input_path, + acl_override, + every, + steps, + max_size, + fixed_size, + format, + out_pattern, + )?; + } + Ok(()) +} + +fn dump_recording( + index: &mut i32, + input_path: &String, + acl_override: bool, + every: Every, + steps: i64, + max_size: Option, + fixed_size: bool, + format: OutputFormat, + out_pattern: &str, +) -> Result<(), CmdError> { + let mut player = make_player(input_path).and_then(Player::check_compatible)?; + player.set_acl_override(acl_override); + + let mut pe = PaintEngine::new(Some(player)); + pe.begin_playback()?; + + loop { + let pos = match every { + Every::None => skip_playback_to_end(&mut pe)?, + Every::Message => pe.step_playback(steps)?, + Every::UndoPoint => pe.skip_playback(steps)?, + }; + + pe.render(); + let path = out_pattern.replace(":idx:", &index.to_string()); + *index += 1; + + match format { + OutputFormat::Ora => pe.write_ora(&path)?, + OutputFormat::Png | OutputFormat::Jpg | OutputFormat::Jpeg => { + write_flat_image(&mut pe, max_size, fixed_size, format, &path)? + } + _ => panic!("Unhandled output format"), + } + + if pos == -1 { + return Ok(()); + } + } +} + +fn make_player(input_path: &String) -> Result { + if input_path == "-" { + Player::new_from_stdin(DP_PLAYER_TYPE_GUESS) + } else { + Player::new_from_path(DP_PLAYER_TYPE_GUESS, input_path.to_owned()) + } +} + +fn skip_playback_to_end(pe: &mut PaintEngine) -> Result { + loop { + if pe.skip_playback(99999)? == -1 { + break; + } + } + Ok(-1) +} + +fn write_flat_image( + pe: &mut PaintEngine, + max_size: Option, + fixed_size: bool, + format: OutputFormat, + path: &str, +) -> Result<(), ImageError> { + let result = if let Some(ImageSize { width, height }) = max_size { + if fixed_size { + pe.to_scaled_image(width, height, true) + } else if pe.render_width() <= width && pe.render_height() < height { + pe.to_image() + } else { + pe.to_scaled_image(width, height, false) + } + } else { + pe.to_image() + }; + match result { + Ok(img) => match format { + OutputFormat::Png => img.write_png(path)?, + OutputFormat::Jpg | OutputFormat::Jpeg => img.write_jpeg(path)?, + _ => panic!("Unhandled output format"), + }, + Err(e) => eprintln!("Warning: {}", CmdError::from(e).message), + } + Ok(()) +} diff --git a/src/tools/drawpile-cmd/main.c b/src/tools/drawpile-cmd/main.c new file mode 100644 index 0000000000..7c784d57f4 --- /dev/null +++ b/src/tools/drawpile-cmd/main.c @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +int drawpile_cmd_main(void); + +int main(void) +{ + return drawpile_cmd_main(); +} diff --git a/src/tools/drawpile-timelapse/Cargo.toml b/src/tools/drawpile-timelapse/Cargo.toml new file mode 100644 index 0000000000..6dfb160f26 --- /dev/null +++ b/src/tools/drawpile-timelapse/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "drawpiletimelapse" +version = "2.2.0-pre" +rust-version = "1.66.1" +edition = "2021" + +[lib] +crate-type = ["staticlib"] +path = "lib.rs" + +[dependencies] +drawdance = { path = "../../drawdance/rust" } +regex = "1.9.4" +xflags = "0.3.1" diff --git a/src/tools/drawpile-timelapse/lib.rs b/src/tools/drawpile-timelapse/lib.rs new file mode 100644 index 0000000000..bcdf1309d0 --- /dev/null +++ b/src/tools/drawpile-timelapse/lib.rs @@ -0,0 +1,700 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +use drawdance::{ + dp_cmake_config_version, + engine::{Image, ImageError, PaintEngine, PaintEngineError, Player, PlayerError}, + DP_UPixel8, DP_PLAYER_TYPE_GUESS, DP_PROTOCOL_VERSION, +}; +use regex::Regex; +use std::{ + collections::VecDeque, + env::consts::EXE_SUFFIX, + ffi::{c_char, c_int, CStr, OsStr}, + fmt::Display, + fs::File, + io::{self, stdout}, + process::{Command, Stdio}, + str::FromStr, +}; + +#[derive(Copy, Clone, Debug)] +pub struct Dimensions { + width: usize, + height: usize, +} + +impl Dimensions { + fn to_arg(self) -> String { + format!("{}x{}", self.width, self.height) + } +} + +impl Display for Dimensions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}x{}", self.width, self.height) + } +} + +impl FromStr for Dimensions { + type Err = String; + + fn from_str(s: &str) -> Result { + if let Some((a, b)) = s.to_lowercase().split_once("x") { + let width: usize = a.parse().unwrap_or(0); + let height: usize = b.parse().unwrap_or(0); + return Ok(Dimensions { width, height }); + } + Err(format!( + "Invalid dimensions '{s}', must be given as WIDTHxHEIGHT" + )) + } +} + +#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)] +pub enum OutputFormat { + #[default] + Mp4, + Webm, + Custom, + Raw, +} + +impl OutputFormat { + fn to_args(self) -> Vec<&'static str> { + match self { + Self::Mp4 => vec!["-c:v", "libx264", "-pix_fmt", "yuv420p", "-an"], + Self::Webm => vec![ + "-c:v", + "libvpx", + "-pixm_fmt", + "yuv420p", + "-crf", + "10", + "-b:v", + "0", + "-an", + ], + _ => Vec::new(), + } + } +} + +impl FromStr for OutputFormat { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "mp4" => Ok(Self::Mp4), + "webm" => Ok(Self::Webm), + "custom" => Ok(Self::Custom), + "raw" => Ok(Self::Raw), + _ => Err(format!( + "invalid output format '{s}', should be one of 'mp4', 'webm', 'custom' or 'raw'" + )), + } + } +} + +#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)] +pub enum LogoLocation { + #[default] + BottomLeft, + TopLeft, + TopRight, + BottomRight, + None, +} + +impl LogoLocation { + fn get_x(&self, distance_x: usize) -> String { + match self { + Self::BottomLeft | Self::TopLeft => format!("{}", distance_x), + Self::BottomRight | Self::TopRight => format!("W-w-{}", distance_x), + _ => panic!("Invalid logo location"), + } + } + + fn get_y(&self, distance_y: usize) -> String { + match self { + Self::TopLeft | Self::TopRight => format!("{}", distance_y), + Self::BottomLeft | Self::BottomRight => format!("H-h-{}", distance_y), + _ => panic!("Invalid logo location"), + } + } +} + +impl Display for LogoLocation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Self::BottomLeft => "bottom-left", + Self::TopLeft => "top-left", + Self::TopRight => "top-right", + Self::BottomRight => "bottom-right", + Self::None => "none", + } + ) + } +} + +impl FromStr for LogoLocation { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "bottom-left" => Ok(Self::BottomLeft), + "top-left" => Ok(Self::TopLeft), + "top-right" => Ok(Self::TopRight), + "bottom-right" => Ok(Self::BottomRight), + "none" => Ok(Self::None), + _ => Err(format!( + "invalid logo location '{s}', should be one of 'bottom-left', \ + 'top-left', 'top-right', 'bottom-right' or 'none'" + )), + } + } +} + +#[derive(Debug)] +pub struct CmdError { + message: String, +} + +impl From<&str> for CmdError { + fn from(s: &str) -> Self { + CmdError { + message: s.to_owned(), + } + } +} + +impl From for CmdError { + fn from(message: String) -> Self { + CmdError { message } + } +} + +impl From for CmdError { + fn from(err: PlayerError) -> Self { + CmdError { + message: format!( + "Input error: {}", + match &err { + PlayerError::DpError(s) => s, + PlayerError::LoadError(_, s) => s, + PlayerError::ResultError(_, s) => s, + PlayerError::NulError(_) => "Null path", + } + ), + } + } +} + +impl From for CmdError { + fn from(err: PaintEngineError) -> Self { + CmdError { + message: match &err { + PaintEngineError::PlayerError(_, msg) => format!("Player error: {}", msg), + }, + } + } +} + +impl From for CmdError { + fn from(err: ImageError) -> Self { + CmdError { + message: format!("ImageError: {}", err.message), + } + } +} + +impl From for CmdError { + fn from(err: io::Error) -> Self { + CmdError { + message: format!("I/O Error: {}", err.to_string()), + } + } +} + +#[no_mangle] +pub extern "C" fn drawpile_timelapse_main(default_logo_path: *const c_char) -> c_int { + drawdance::init(); + + let flags = xflags::parse_or_exit! { + /// Displays version information and exits. + optional -v,--version + /// Output format. Use 'mp4' or 'webm' for sensible presets in those two + /// formats that should work in most places. Use `custom` to manually + /// specify the output format using -c/--custom-argument. Use `raw` to + /// not run ffmpeg at all and instead just dump the raw frames. Defaults + /// to 'mp4'. + optional -f,--format output_format: OutputFormat + /// Output file path, '-' for stdout. Required. + required -o,--out output_path: String + /// Video dimensions, required. In the form WIDTHxHEIGHT. All frames + /// will be resized to fit into these dimensions. + required -d,--dimensions dimensions: Dimensions + /// Video frame rate. Defaults to 24. Higher values may not work on all + /// platforms and may not play back properly or be allowed to upload! + optional -r,--framerate framerate: i32 + /// Interval between each frame, in milliseconds. Defaults to 10000, + /// higher values mean the timelapse will be faster. + optional -i,--interval interval: i64 + /// Where to put the logo. One of 'bottom-left' (the default), + /// 'top-left', 'top-right', 'bottom-right' will put the logo in that + /// corner. Use 'none' to remove the logo altogether. + optional -l,--logo-location logo_location: LogoLocation + /// Path to the logo. Default is to use the Drawpile logo. + optional -L,--logo-path logo_path: String + /// Opacity of the logo, in percent. Default is 40. + optional -O,--logo-opacity logo_opacity: i32 + /// Distance of the logo from the corner, in the format WIDTHxHEIGHT. + /// Default is 20x20. + optional -D,--logo-distance logo_distance: Dimensions + /// Relative scale of the logo, in percent. Default is 100. + optional -S, --logo-scale logo_scale: i32 + /// The color of the flash when the timelapse is finished, in argb + /// hexadecimal format. Default is 'ffffffff' for a solid white flash. + /// Use 'none' for no flash. + optional -F,--flash flash: String + /// How many seconds to linger on the final result. Default is 5.0. + optional -t,--linger-time linger_time: f64 + /// Path to ffmpeg executable. If not given, it just looks in PATH. + optional -C,--ffmpeg-location ffmpeg_location: String + /// Custom ffmpeg arguments. Repeat this for every argument you want to + /// append, like `-c -pix_fmt -c yuv420`. + repeated -c,--custom-argument custom_arguments: String + /// Only print out what's about to be done and which arguments would be + /// passed to ffmpeg, then exit. Useful for inspecting parameters before + /// waiting through everything being rendered out. + optional -p,--print-only + /// Performs ACL filtering. This will filter out any commands that the + /// user wasn't allowed to actually perform, such as drawing on a layer + /// that they didn't have permission to draw on. The Drawpile client + /// would also filter these out when playing back a recording. + optional -A,--acl + /// Input recording file(s). + repeated input: String + }; + + if flags.version { + println!("drawpile-timelapse {}", dp_cmake_config_version()); + println!( + "Protocol version: {}", + CStr::from_bytes_with_nul(DP_PROTOCOL_VERSION) + .unwrap() + .to_str() + .unwrap() + ); + return 0; + } + + let framerate = flags.framerate.unwrap_or(24); + if framerate < 1 { + eprintln!("Invalid framerate {}", framerate); + return 2; + } + + let interval = flags.interval.unwrap_or(10_000); + if interval < 1 { + eprintln!("Invalid interval {}", interval); + return 2; + } + + let logo_location = flags.logo_location.unwrap_or_default(); + let logo_path = flags.logo_path.unwrap_or_else(|| { + unsafe { CStr::from_ptr(default_logo_path) } + .to_str() + .unwrap_or_default() + .to_owned() + }); + let logo_opacity = flags.logo_opacity.unwrap_or(40).clamp(0, 100); + let logo_scale = flags.logo_scale.unwrap_or(100).max(0); + let logo_distance = flags.logo_distance.unwrap_or(Dimensions { + width: 20, + height: 20, + }); + let logo_enabled = logo_location != LogoLocation::None + && !logo_path.is_empty() + && logo_opacity > 0 + && logo_scale > 0; + + let format = flags.format.unwrap_or_default(); + let dimensions = flags.dimensions; + if dimensions.width < 1 || dimensions.height < 1 { + eprintln!("Invalid dimensions {}", dimensions); + return 2; + } + + let opt_command = if format == OutputFormat::Raw { + None + } else { + let mut command = Command::new( + flags + .ffmpeg_location + .unwrap_or_else(|| format!("ffmpeg{}", EXE_SUFFIX)), + ); + command + .stdin(Stdio::piped()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .arg("-f") + .arg("rawvideo") + .arg("-pix_fmt") + .arg("rgb32") + .arg("-s:v") + .arg(dimensions.to_arg()) + .arg("-r") + .arg(framerate.to_string()) + .arg("-i") + .arg("pipe:0"); + + if logo_enabled { + let scale_filter = if logo_scale == 100 { + "".to_owned() + } else { + let s = logo_scale as f64 / 100.0f64; + format!(",scale=iw*{}:ih*{}", s, s) + }; + command + .arg("-i") + .arg(&logo_path) + .arg("-filter_complex") + .arg(format!( + "[1]lut=a=val*{}{}[a];[0][a]overlay={}:{}", + logo_opacity as f64 / 100.0f64, + scale_filter, + logo_location.get_x(logo_distance.width), + logo_location.get_y(logo_distance.height), + )); + } + + command + .args(format.to_args()) + .arg("-y") + .args(flags.custom_argument) + .arg(flags.out.clone()); + Some(command) + }; + + let flash_str = flags.flash.unwrap_or_else(|| "ffffff".to_owned()); + let rgb_re = Regex::new(r"(?i)\A[0-9a-f]{6}\z").unwrap(); + let argb_re = Regex::new(r"(?i)\A[0-9a-f]{8}\z").unwrap(); + let flash = if flash_str == "none" { + None + } else if rgb_re.is_match(&flash_str) { + Some(0xff000000u32 | u32::from_str_radix(&flash_str, 16).unwrap()) + } else if argb_re.is_match(&flash_str) { + Some(u32::from_str_radix(&flash_str, 16).unwrap()) + } else { + eprintln!("Invalid flash '{}'", flash_str); + return 2; + }; + + let linger_time = flags.linger_time.unwrap_or(5.0f64); + if linger_time < 0.0 { + eprintln!("Invalid linger time {}", linger_time); + return 2; + } + + let acl_override = !flags.acl; + + if flags.print_only { + eprintln!(); + eprintln!("dimensions:\n {}", flags.dimensions.to_arg()); + eprintln!("interval:\n {} msec", interval); + if logo_enabled { + eprintln!("logo location:\n {}", logo_location); + eprintln!("logo path:\n {}", logo_path); + eprintln!("logo opacity:\n {}%", logo_opacity); + eprintln!("logo scale:\n {}%", logo_scale); + eprintln!("logo distance:\n {}", logo_distance); + } + if let Some(flash_color) = flash { + eprintln!("flash:\n {:x}", flash_color); + } else { + eprintln!("flash:\n none"); + } + eprintln!("linger time:\n {} sec", linger_time); + eprintln!("filter acls:\n {}", !acl_override); + if let Some(command) = opt_command { + eprintln!( + "ffmpeg command line:\n {} {}", + command.get_program().to_string_lossy(), + command + .get_args() + .map(OsStr::to_string_lossy) + .collect::>() + .join(" ") + ); + } else { + eprintln!("raw output to:\n {}", flags.out); + } + eprintln!(); + return 0; + } + + let input_paths = flags.input; + if input_paths.is_empty() { + eprintln!("No input file given"); + return 2; + } + + let result = if let Some(command) = opt_command { + make_timelapse_command( + command, + &input_paths, + framerate, + acl_override, + interval, + dimensions.width, + dimensions.height, + flash, + linger_time, + ) + } else if flags.out == "-" { + timelapse( + &mut stdout(), + &input_paths, + framerate, + acl_override, + interval, + dimensions.width, + dimensions.height, + flash, + linger_time, + ) + } else { + make_timelapse_raw( + &flags.out, + &input_paths, + framerate, + acl_override, + interval, + dimensions.width, + dimensions.height, + flash, + linger_time, + ) + }; + + match result { + Ok(_) => 0, + Err(e) => { + eprintln!("{}", e.message); + 1 + } + } +} + +fn make_timelapse_command( + mut command: Command, + input_paths: &Vec, + framerate: i32, + acl_override: bool, + interval: i64, + width: usize, + height: usize, + flash: Option, + linger_time: f64, +) -> Result<(), CmdError> { + let mut child = match command.spawn() { + Ok(c) => c, + Err(e) => { + return Err(CmdError { + message: format!( + "Could not start '{}': {}", + command.get_program().to_string_lossy(), + e + ), + }) + } + }; + let mut pipe = child.stdin.take().ok_or("Can't open stdin")?; + timelapse( + &mut pipe, + input_paths, + framerate, + acl_override, + interval, + width, + height, + flash, + linger_time, + )?; + drop(pipe); + let status = child.wait()?; + if status.success() { + Ok(()) + } else { + Err(CmdError::from(status.to_string())) + } +} + +fn make_timelapse_raw( + path: &String, + input_paths: &Vec, + framerate: i32, + acl_override: bool, + interval: i64, + width: usize, + height: usize, + flash: Option, + linger_time: f64, +) -> Result<(), CmdError> { + let mut f = File::create(path)?; + timelapse( + &mut f, + &input_paths, + framerate, + acl_override, + interval, + width, + height, + flash, + linger_time, + )?; + Ok(()) +} + +struct TimelapseContext<'a> { + writer: &'a mut dyn io::Write, + images: VecDeque, +} + +impl<'a> TimelapseContext<'a> { + fn push(&mut self, img: Image) -> io::Result<()> { + self.images.push_back(img); + while self.images.len() > 2 { + self.shift()?; + } + Ok(()) + } + + fn shift(&mut self) -> io::Result<()> { + self.images.pop_front().unwrap().dump(self.writer) + } +} + +fn timelapse( + writer: &mut dyn io::Write, + input_paths: &Vec, + framerate: i32, + acl_override: bool, + interval: i64, + width: usize, + height: usize, + flash: Option, + linger_time: f64, +) -> Result<(), CmdError> { + let mut ctx = TimelapseContext { + writer, + images: VecDeque::new(), + }; + + for input_path in input_paths { + timelapse_recording(&mut ctx, input_path, acl_override, interval, width, height)?; + } + + if !ctx.images.is_empty() { + let fr = framerate as f64; + let img1 = &ctx.images.front().unwrap(); + let img2 = &ctx.images.back().unwrap(); + + if let Some(color) = flash { + render_flash(ctx.writer, img1, img2, fr, color)?; + } else { + img1.dump(ctx.writer)?; + } + render_linger(ctx.writer, img2, fr, linger_time)?; + } + + ctx.writer.flush()?; + Ok(()) +} + +fn timelapse_recording( + ctx: &mut TimelapseContext, + input_path: &String, + acl_override: bool, + interval: i64, + width: usize, + height: usize, +) -> Result<(), CmdError> { + let mut player = make_player(input_path).and_then(Player::check_compatible)?; + player.set_acl_override(acl_override); + + let mut pe = PaintEngine::new(Some(player)); + pe.begin_playback()?; + + let mut initial = true; + loop { + let pos = if initial { + pe.step_playback(1) + } else { + pe.play_playback(interval) + }?; + + pe.render(); + match pe.to_scaled_image(width, height, true) { + Ok(img) => { + ctx.push(img)?; + initial = false; + } + Err(e) => eprintln!("Warning: {}", CmdError::from(e).message), + } + + if pos == -1 { + return Ok(()); + } + } +} + +fn make_player(input_path: &String) -> Result { + if input_path == "-" { + Player::new_from_stdin(DP_PLAYER_TYPE_GUESS) + } else { + Player::new_from_path(DP_PLAYER_TYPE_GUESS, input_path.to_owned()) + } +} + +fn render_flash( + writer: &mut dyn io::Write, + img1: &Image, + img2: &Image, + fr: f64, + color: u32, +) -> Result<(), CmdError> { + let color = DP_UPixel8 { color }; + let mut dst = Image::new(img1.width(), img1.height())?; + let mut opa = 0.0f64; + + while opa < 255.0f64 { + opa = 255.0f64.min(opa + 2400.0f64 / fr); + dst.blend_with(&img1, color, opa as u8)?; + dst.dump(writer)?; + } + + while opa > 0.0f64 { + opa = 0.0f64.max(opa - 360.0f64 / fr); + dst.blend_with(&img2, color, opa as u8)?; + dst.dump(writer)?; + } + + Ok(()) +} + +fn render_linger( + writer: &mut dyn io::Write, + img: &Image, + fr: f64, + linger_time: f64, +) -> Result<(), CmdError> { + let mut lingered = 0.0; + while lingered <= linger_time { + lingered += 1.0f64 / fr; + img.dump(writer)?; + } + Ok(()) +} diff --git a/src/tools/drawpile-timelapse/logo.qrc b/src/tools/drawpile-timelapse/logo.qrc new file mode 100644 index 0000000000..239cd002b9 --- /dev/null +++ b/src/tools/drawpile-timelapse/logo.qrc @@ -0,0 +1,7 @@ + + + + logo1.png + + + diff --git a/src/tools/drawpile-timelapse/logo1.png b/src/tools/drawpile-timelapse/logo1.png new file mode 100644 index 0000000000..1fe9e88efd Binary files /dev/null and b/src/tools/drawpile-timelapse/logo1.png differ diff --git a/src/tools/drawpile-timelapse/main.cpp b/src/tools/drawpile-timelapse/main.cpp new file mode 100644 index 0000000000..68ab63aa18 --- /dev/null +++ b/src/tools/drawpile-timelapse/main.cpp @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include +#include +#include +#include +#include + +extern "C" int drawpile_timelapse_main(const char *default_logo_path); + +static QString writeDefaultLogo() +{ + QString appDataPath = + QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + if(!QDir{}.mkpath(appDataPath)) { + qWarning("Error creating directory '%s'", qUtf8Printable(appDataPath)); + return QString{}; + } + + QString path = QDir{appDataPath}.absoluteFilePath("logo1.png"); + QSaveFile out{path}; + if(!QFile::exists(path)) { + QFile in{":/timelapse/logo1.png"}; + if(!in.open(QIODevice::ReadOnly)) { + qWarning( + "Error opening '%s': %s", qUtf8Printable(in.fileName()), + qUtf8Printable(in.errorString())); + return QString{}; + } + QByteArray bytes = in.readAll(); + in.close(); + + if(!out.open(QIODevice::WriteOnly)) { + qWarning( + "Error opening '%s': %s", qUtf8Printable(out.fileName()), + qUtf8Printable(out.errorString())); + return QString{}; + } + out.write(bytes); + if(!out.commit()) { + qWarning( + "Error writing '%s': %s", qUtf8Printable(out.fileName()), + qUtf8Printable(out.errorString())); + return QString{}; + } + } + + return path; +} + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + app.setOrganizationName("drawpile"); + app.setOrganizationDomain("drawpile.net"); + app.setApplicationName("drawpile-timelapse"); + return drawpile_timelapse_main(qUtf8Printable(writeDefaultLogo())); +}