diff --git a/daslib/json_boost.das b/daslib/json_boost.das index 5b91bcd1cd..80369732ba 100644 --- a/daslib/json_boost.das +++ b/daslib/json_boost.das @@ -478,11 +478,11 @@ def from_JV(v : JsonValue const explicit?; anything : auto(TT)) { unsafe { let arr & = v.value as _array ret |> reserve(arr |> length) - for (i in range(arr |> length)) { + for (a in arr) { if (typeinfo can_copy(anything[0])) { - ret |> push_clone <| _::from_JV(arr[i], decltype_noref(anything[0])) + ret |> push_clone <| _::from_JV(a, decltype_noref(anything[0])) } else { - ret |> emplace <| _::from_JV(arr[i], decltype_noref(anything[0])) + ret |> emplace <| _::from_JV(a, decltype_noref(anything[0])) } } return <- ret diff --git a/doc/reflections/das2rst.das b/doc/reflections/das2rst.das index 53a28c3af6..f817c2fd49 100644 --- a/doc/reflections/das2rst.das +++ b/doc/reflections/das2rst.das @@ -1239,7 +1239,8 @@ def document_module_stbimage(root : string) { group_by_regex("File writing", mod, %regex~(stbi_write_png|stbi_write_bmp|stbi_write_tga|stbi_write_jpg|stbi_write_hdr)$%%), group_by_regex("Write to memory", mod, %regex~(stbi_write_png_to_memory|stbi_write_bmp_to_memory|stbi_write_tga_to_memory|stbi_write_jpg_to_memory)$%%), group_by_regex("Write settings", mod, %regex~(stbi_flip_vertically_on_write|stbi_write_set_.*|stbi_write_get_.*)$%%), - group_by_regex("Image resizing", mod, %regex~(stbir_resize|stbir_resize_uint8_srgb|stbir_resize_uint8_linear|stbir_resize_float_linear)$%%) + group_by_regex("Image resizing", mod, %regex~(stbir_resize|stbir_resize_uint8_srgb|stbir_resize_uint8_linear|stbir_resize_float_linear)$%%), + group_by_regex("Animated PNG (APNG) writer", mod, %regex~stbi_apng_(begin|frame|end|dropped)$%%) ) document("Image loading, writing, and resizing (stb_image)", mod, "stbimage.rst", groups) } @@ -1437,7 +1438,7 @@ def document_module_strudel_scheduler(root : string) { var mod = find_module("strudel_scheduler") var groups <- array( group_by_regex("Orbit effects", mod, %regex~(init_reverb|init_delay|get_orbit_reverb|get_orbit_delay|get_orbit_chorus)$%%), - group_by_regex("Scheduler lifecycle", mod, %regex~(tick|shutdown_scheduler)$%%) + group_by_regex("Scheduler lifecycle", mod, %regex~(tick|shutdown_scheduler|finalize)$%%) ) document("Voice allocation, effect bus routing, and per-tick mixing", mod, "strudel_scheduler.rst", groups) } diff --git a/doc/source/reference/utils/daslang_live.rst b/doc/source/reference/utils/daslang_live.rst index 8fdf61f112..7291e40b7f 100644 --- a/doc/source/reference/utils/daslang_live.rst +++ b/doc/source/reference/utils/daslang_live.rst @@ -345,9 +345,9 @@ Helper modules * - Module - Description * - ``live/glfw_live`` - - GLFW window that persists across reloads. + - GLFW window that persists across reloads + synthetic mouse driver. * - ``live/opengl_live`` - - OpenGL screenshot command. + - OpenGL screenshot + APNG video recording commands. * - ``live/decs_live`` - Auto-serialization of DECS entities across reloads. * - ``live/live_commands`` @@ -387,6 +387,49 @@ survives reloads. Key functions: * - ``live_get_framebuffer_size(w, h)`` - Query framebuffer dimensions. +Synthetic mouse driver +^^^^^^^^^^^^^^^^^^^^^^ + +``live/glfw_live`` also provides a synthetic-input timeline driver. Events +flow through ``dasGLFW``'s chain dispatcher, so any listener installed on +the window (``ImGui_ImplGlfw``, app callbacks, etc.) receives them +indistinguishably from real OS input. Used by the visual-aids demo to +re-record APNG tours from a JSON timeline. + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Command + - Description + * - ``mouse_pos`` + - Teleport synthetic cursor. Args: ``x``, ``y``. + * - ``mouse_click`` + - Synthetic button press/release. Args: ``button`` (0/1/2), + ``action`` (``"press"`` | ``"release"``). + * - ``mouse_scroll`` + - Synthetic scroll. Args: ``x``, ``y`` (offsets). + * - ``mouse_move_to`` + - Animated linear move to ``(x, y)`` over ``duration_ms`` + (default 250). Per-frame lerp posts one cursor event per frame. + * - ``mouse_play`` + - Play a scripted timeline. Args: ``events`` array of + ``{t_ms, kind, x, y, button, action}`` where ``kind`` is + ``"move"`` | ``"button"`` | ``"scroll"``. Between move events the + per-frame tick lerps and posts one cursor event per frame so any + reader (ImGui, overlays) sees smooth motion. + * - ``mouse_stop`` + - Stop playback and clear the queue. + * - ``mouse_status`` + - Playback status: ``playing``, ``elapsed_ms``, ``cursor_x``, + ``cursor_y``, ``queue_idx``, ``queue_total``. + +``get_synth_cursor() : tuple`` returns +``(active, x, y)``. Overlays that draw a cursor sprite or motion trail +should consult this — when ``active`` the synthetic driver owns the +position, and ``ImGui_ImplGlfw``'s per-frame poll would otherwise +overwrite ``io.MousePos`` with the real OS cursor on focused windows. + ``live/decs_live`` ------------------ diff --git a/doc/source/stdlib/handmade/function-stbimage-stbi_apng_begin-0xbc1c781f9124134e.rst b/doc/source/stdlib/handmade/function-stbimage-stbi_apng_begin-0xbc1c781f9124134e.rst new file mode 100644 index 0000000000..9484a1980c --- /dev/null +++ b/doc/source/stdlib/handmade/function-stbimage-stbi_apng_begin-0xbc1c781f9124134e.rst @@ -0,0 +1 @@ +Begin streaming APNG encoding to ``filename``. ``channels`` is 3 (RGB) or 4 (RGBA). Returns an opaque writer handle; pass it to ``stbi_apng_frame`` and ``stbi_apng_end``, or ``null`` on failure. diff --git a/doc/source/stdlib/handmade/function-stbimage-stbi_apng_dropped-0x45f80abb42af48d6.rst b/doc/source/stdlib/handmade/function-stbimage-stbi_apng_dropped-0x45f80abb42af48d6.rst new file mode 100644 index 0000000000..59df1d9a93 --- /dev/null +++ b/doc/source/stdlib/handmade/function-stbimage-stbi_apng_dropped-0x45f80abb42af48d6.rst @@ -0,0 +1 @@ +Return the running count of frames dropped because the encoder thread's bounded queue was full. diff --git a/doc/source/stdlib/handmade/function-stbimage-stbi_apng_end-0x7f5a70e4959686ea.rst b/doc/source/stdlib/handmade/function-stbimage-stbi_apng_end-0x7f5a70e4959686ea.rst new file mode 100644 index 0000000000..e66fd2d83c --- /dev/null +++ b/doc/source/stdlib/handmade/function-stbimage-stbi_apng_end-0x7f5a70e4959686ea.rst @@ -0,0 +1 @@ +Finalize the APNG file: drain the encoder thread, backpatch the ``acTL`` frame count, write ``IEND``, and free the writer. Returns 1 on success. diff --git a/doc/source/stdlib/handmade/function-stbimage-stbi_apng_frame-0xfc4bb124cd4cfe02.rst b/doc/source/stdlib/handmade/function-stbimage-stbi_apng_frame-0xfc4bb124cd4cfe02.rst new file mode 100644 index 0000000000..ba331b9c8d --- /dev/null +++ b/doc/source/stdlib/handmade/function-stbimage-stbi_apng_frame-0xfc4bb124cd4cfe02.rst @@ -0,0 +1 @@ +Queue one frame on ``writer``. Pixels are expected in bottom-up row order (``glReadPixels`` output) — the encoder thread flips rows top-down before encoding, so callers should not pre-flip. ``stride_bytes`` is the positive pixel-row stride and must be at least ``width * channels``; negative-stride values are rejected. ``delay_ms`` is how long the frame is shown. Returns 1 on success (including the queue-full case, which silently drops the oldest queued frame — see ``stbi_apng_dropped`` for the running count), 0 on invalid input (null pixels / bad stride) **or** if the writer has entered an internal error state from a prior async I/O or encode failure; in that case callers should stop and call ``stbi_apng_end``. diff --git a/modules/dasGlfw/dasglfw/glfw_live.das b/modules/dasGlfw/dasglfw/glfw_live.das index 06ad849643..e958b7ca12 100644 --- a/modules/dasGlfw/dasglfw/glfw_live.das +++ b/modules/dasGlfw/dasglfw/glfw_live.das @@ -22,17 +22,19 @@ require glfw/glfw_boost require opengl/opengl_boost require opengl/opengl_cache require daslib/archive +require daslib/json +require daslib/json_boost +require live/live_commands require live_host +require math var public live_window : GLFWwindow? def public live_create_window(title : string; width, height : int) : GLFWwindow? { //! Creates a GLFW window (or reuses the preserved one on reload). //! Call this from init(). - if (live_window != null) { - // Already have a window (restored from reload) - return live_window - } + // Already have a window (restored from reload) + return live_window if (live_window != null) if (glfwInit() == 0) { panic("glfw_live: can't init glfw") } @@ -61,9 +63,7 @@ def public live_destroy_window() { def public live_begin_frame() : bool { //! Polls GLFW events and checks if the window should close. //! Returns false if the window was closed (signals exit). - if (live_window == null) { - return false - } + return false if (live_window == null) glfwPollEvents() if (glfwWindowShouldClose(live_window) != 0) { request_exit() @@ -107,7 +107,7 @@ def save_glfw_window() { [after_reload] def restore_glfw_window() { var data : array - if (live_load_bytes("__glfw_window", data) && length(data) > 0) { + if (live_load_bytes("__glfw_window", data) && !empty(data)) { var ser = new MemSerializer(data) var arch = Archive(reading = true, stream = ser) var ptr_val : uint64 @@ -121,3 +121,286 @@ def restore_glfw_window() { } } } + +// ============================================================================ +// Synthetic mouse driver +// ============================================================================ +// +// `mouse_pos` / `mouse_click` / `mouse_scroll` are one-shot synthetic events. +// `mouse_move_to` is an animated single-target tween over `duration_ms`. +// `mouse_play` runs a scripted event timeline; between consecutive `move` +// events the per-frame tick linearly interpolates and posts one cursor event +// per frame so anything reading mouse position (ImGui, the visual_aids trail, +// app-level handlers) sees smooth motion. + +enum private MEKind { + move + button + scroll +} + +struct private MouseEvent { + t_ms : int = 0 + kind : MEKind = MEKind.move + x : float = 0.0 + y : float = 0.0 + button : int = 0 + action : int = 0 // 1=press, 0=release (matches GLFW's GLFW_PRESS/GLFW_RELEASE) +} + +var private timeline : array +var private play_start_t : float = 0.0 +var private play_idx : int = 0 +var private playing : bool = false +var private cursor_x : float = 0.0 +var private cursor_y : float = 0.0 +// Cached waypoint for between-move interpolation. -1 = no prior move consumed. +var private last_move_x : float = 0.0 +var private last_move_y : float = 0.0 +var private last_move_t : int = -1 +// Last synthesized click (button + uptime-seconds). Consumed by visual aids +// (imgui_visual_aids.paint_click_flash) to draw a click indicator; the OS +// cursor isn't visible in the GL back buffer so a real click would otherwise +// leave no trace in APNG recordings. +var public synth_click_button : int = -1 +var public synth_click_action : int = 0 // 1 = press, 0 = release +var public synth_click_t : float = -1.0 //! get_uptime() at last click; -1 = no click yet +// Synth events go through dasGLFW's chain dispatcher only — ImGui-GLFW (and +// any other chain listener) sees them via its installed callback. We never +// glfwSetCursorPos: the OS cursor lives outside the GL back buffer (the +// window manager paints it at display time), so warping it has no effect on +// glReadPixels-driven APNG recordings. The visible proof of motion in a +// recording is the visual_aids trail + cursor sprite, both drawn through +// ImGui's draw lists. + +def private synth_cursor_pos(x, y : float) { + cursor_x = x + cursor_y = y + glfw_dispatch_cursor_pos(live_window, double(x), double(y)) +} + +def private synth_mouse_button(button, action : int) { + glfw_post_mouse_button(live_window, button, action, 0) + synth_click_button = button + synth_click_action = action + synth_click_t = get_uptime() +} + +def public get_synth_cursor() : tuple { + //! (active, x, y). When active=true the synthetic driver owns the cursor + //! position — overlays (mouse_trail, cursor_sprite in imgui_visual_aids) + //! should render against (x, y) instead of GetMousePos() since the + //! per-frame ImGui_ImplGlfw polling will otherwise overwrite io.MousePos + //! with the real OS cursor on focused windows. Active during mouse_play + //! and mouse_move_to playback. + return (playing, cursor_x, cursor_y) +} + +def private reset_timeline_state() { + timeline |> clear() + play_idx = 0 + playing = false + last_move_x = cursor_x + last_move_y = cursor_y + last_move_t = -1 +} + +struct MousePosArgs { + x : float = 0.0 + y : float = 0.0 +} + +[live_command(description="Teleport synthetic cursor. Args: x, y")] +def mouse_pos(input : JsonValue?) : JsonValue? { + return JV((error = "no window")) if (live_window == null) + let args = from_JV(input, type) + synth_cursor_pos(args.x, args.y) + return JV((x = args.x, y = args.y)) +} + +struct MouseClickArgs { + @optional button : int = 0 + action : string = "press" +} + +[live_command(description="Synthetic mouse button. Args: button (0/1/2), action (press|release)")] +def mouse_click(input : JsonValue?) : JsonValue? { + return JV((error = "no window")) if (live_window == null) + let args = from_JV(input, type) + var act : int + if (args.action == "press") { + act = 1 + } elif (args.action == "release") { + act = 0 + } else { + return JV((error = "invalid action; expected \"press\" or \"release\"", action = args.action)) + } + synth_mouse_button(args.button, act) + return JV((button = args.button, action = args.action)) +} + +struct MouseScrollArgs { + x : float = 0.0 + y : float = 0.0 +} + +[live_command(description="Synthetic mouse scroll. Args: x, y (offsets)")] +def mouse_scroll(input : JsonValue?) : JsonValue? { + return JV((error = "no window")) if (live_window == null) + let args = from_JV(input, type) + glfw_post_scroll(live_window, double(args.x), double(args.y)) + return JV((x = args.x, y = args.y)) +} + +struct MouseMoveToArgs { + x : float = 0.0 + y : float = 0.0 + @optional duration_ms : int = 250 +} + +[live_command(description="Animated cursor move to (x,y) over duration_ms. Linear interpolation.")] +def mouse_move_to(input : JsonValue?) : JsonValue? { + return JV((error = "no window")) if (live_window == null) + let args = from_JV(input, type) + reset_timeline_state() + timeline |> push(MouseEvent(t_ms = 0, kind = MEKind.move, x = cursor_x, y = cursor_y)) + timeline |> push(MouseEvent(t_ms = max(1, args.duration_ms), kind = MEKind.move, x = args.x, y = args.y)) + play_start_t = get_uptime() + playing = true + return JV((duration_ms = args.duration_ms)) +} + +struct MouseEventWire { + @optional t_ms : int = 0 + kind : string = "move" // "move" | "button" | "scroll" + @optional x : float = 0.0 + @optional y : float = 0.0 + @optional button : int = 0 + @optional action : string = "press" +} + +struct MousePlayArgs { + events : array +} + +def public sort_play_events(var events : array) { + //! Sort a mouse_play timeline by ``t_ms`` ascending. mouse_play only walks + //! the queue forward, so out-of-order input would silently drop earlier + //! events; sort first. Equal-``t_ms`` ordering is unspecified (the + //! underlying ``sort()`` is qsort, not stable). + events |> sort() $(a, b : MouseEventWire) { + return a.t_ms < b.t_ms + } +} + +[live_command(description="Play a scripted mouse timeline. Args: events array of (t_ms, kind, x, y, button, action)")] +def mouse_play(input : JsonValue?) : JsonValue? { + return JV((error = "no window")) if (live_window == null) + var args = from_JV(input, type) + return JV((error = "no events")) if (empty(args.events)) + sort_play_events(args.events) + reset_timeline_state() + timeline |> reserve(length(args.events)) + for (w in args.events) { + var ev = MouseEvent(t_ms = w.t_ms) + if (w.kind == "move") { + ev.kind = MEKind.move + ev.x = w.x + ev.y = w.y + } elif (w.kind == "button") { + ev.kind = MEKind.button + ev.button = w.button + if (w.action == "press") { + ev.action = 1 + } elif (w.action == "release") { + ev.action = 0 + } else { + return JV((error = "invalid button action; expected \"press\" or \"release\"", action = w.action)) + } + } elif (w.kind == "scroll") { + ev.kind = MEKind.scroll + ev.x = w.x + ev.y = w.y + } else { + return JV((error = "unknown event kind", kind = w.kind)) + } + timeline |> push(ev) + } + play_start_t = get_uptime() + playing = true + return JV((queued = length(timeline))) +} + +[live_command(description="Stop synthetic playback and clear the timeline.")] +def mouse_stop(input : JsonValue?) : JsonValue? { + let was_playing = playing + reset_timeline_state() + return JV((stopped = was_playing)) +} + +struct MouseStatusResult { + playing : bool + elapsed_ms : int + cursor_x : float + cursor_y : float + queue_idx : int + queue_total : int +} + +[live_command(description="Synthetic-mouse playback status.")] +def mouse_status(input : JsonValue?) : JsonValue? { + let elapsed = playing ? int((get_uptime() - play_start_t) * 1000.0) : 0 + return JV(MouseStatusResult( + playing = playing, + elapsed_ms = elapsed, + cursor_x = cursor_x, + cursor_y = cursor_y, + queue_idx = play_idx, + queue_total = length(timeline) + )) +} + +[before_update] +def mouse_tick() { + return if (!playing || live_window == null) + let now_ms = int((get_uptime() - play_start_t) * 1000.0) + // Fire every due event (button/scroll instantly; move snaps the cursor to the waypoint). + while (play_idx < length(timeline)) { + let ev = timeline[play_idx] + break if (ev.t_ms > now_ms) + if (ev.kind == MEKind.move) { + synth_cursor_pos(ev.x, ev.y) + last_move_x = ev.x + last_move_y = ev.y + last_move_t = ev.t_ms + } elif (ev.kind == MEKind.button) { + synth_mouse_button(ev.button, ev.action) + } elif (ev.kind == MEKind.scroll) { + glfw_post_scroll(live_window, double(ev.x), double(ev.y)) + } + play_idx++ + } + if (play_idx >= length(timeline)) { + playing = false + return + } + // Between waypoints: lerp from the last consumed move toward the next move. + if (last_move_t >= 0) { + var next_move_idx = play_idx + while (next_move_idx < length(timeline) && timeline[next_move_idx].kind != MEKind.move) { + next_move_idx++ + } + if (next_move_idx < length(timeline)) { + let nm = timeline[next_move_idx] + let span = nm.t_ms - last_move_t + if (span > 0) { + var alpha = float(now_ms - last_move_t) / float(span) + if (alpha < 0.0) { alpha = 0.0; } + if (alpha > 1.0) { alpha = 1.0; } + let x = last_move_x + (nm.x - last_move_x) * alpha + let y = last_move_y + (nm.y - last_move_y) * alpha + synth_cursor_pos(x, y) + } + } + } +} diff --git a/modules/dasGlfw/src/aot_dasGLFW.h b/modules/dasGlfw/src/aot_dasGLFW.h index 4362a34aad..c38de2f977 100644 --- a/modules/dasGlfw/src/aot_dasGLFW.h +++ b/modules/dasGlfw/src/aot_dasGLFW.h @@ -16,4 +16,13 @@ namespace das { DAS_MOD_API void DasGlfw_Shutdown(); DAS_MOD_API void DasGlfw_DestroyWindow ( GLFWwindow * window ); DAS_MOD_API void * DAS_glfwGetNativeWindow ( GLFWwindow* window ); + // chain + synthetic event API + DAS_MOD_API void DasGlfw_ChainAddCursorPos ( GLFWwindow * window, TLambda func, Context * ctx ); + DAS_MOD_API void DasGlfw_ChainAddMouseButton ( GLFWwindow * window, TLambda func, Context * ctx ); + DAS_MOD_API void DasGlfw_ChainAddScroll ( GLFWwindow * window, TLambda func, Context * ctx ); + DAS_MOD_API void DasGlfw_ChainClear ( GLFWwindow * window ); + DAS_MOD_API void DasGlfw_PostCursorPos ( GLFWwindow * window, double x, double y ); + DAS_MOD_API void DasGlfw_DispatchCursorPos ( GLFWwindow * window, double x, double y ); + DAS_MOD_API void DasGlfw_PostMouseButton ( GLFWwindow * window, int button, int action, int mods ); + DAS_MOD_API void DasGlfw_PostScroll ( GLFWwindow * window, double xoff, double yoff ); } diff --git a/modules/dasGlfw/src/dasGLFW.main.cpp b/modules/dasGlfw/src/dasGLFW.main.cpp index 8849e749c2..e470aae1a2 100644 --- a/modules/dasGlfw/src/dasGLFW.main.cpp +++ b/modules/dasGlfw/src/dasGLFW.main.cpp @@ -147,9 +147,151 @@ namespace das { Module_dasGLFW::g_Callbacks[window].scrollCB = { func, ctx }; } + // === Chain callbacks === + // Multi-listener registry alongside the legacy single-slot g_Callbacks. + // Captures any pre-installed C-callback (e.g. ImGui_ImplGlfw) so synthetic + // events posted via DasGlfw_Post* reach every real listener too. + + struct GlfwChainEntry { + Lambda lambda; + Context * context = nullptr; + }; + + struct GlfwChainState { + GLFWcursorposfun prev_cursor_pos = nullptr; + GLFWmousebuttonfun prev_mouse_button = nullptr; + GLFWscrollfun prev_scroll = nullptr; + std::vector cursor_pos_chain; + std::vector mouse_button_chain; + std::vector scroll_chain; + }; + + static thread_local das_map g_GlfwChain; + + void DasGlfw_ChainCursorPosDispatch ( GLFWwindow * w, double x, double y ) { + auto it = g_GlfwChain.find(w); + if ( it == g_GlfwChain.end() ) return; + auto & st = it->second; + if ( st.prev_cursor_pos ) st.prev_cursor_pos(w, x, y); + for ( auto & e : st.cursor_pos_chain ) { + if ( e.context ) { + das_invoke_lambda::invoke( + e.context, nullptr, e.lambda, w, x, y); + } + } + } + + void DasGlfw_ChainMouseButtonDispatch ( GLFWwindow * w, int button, int action, int mods ) { + auto it = g_GlfwChain.find(w); + if ( it == g_GlfwChain.end() ) return; + auto & st = it->second; + if ( st.prev_mouse_button ) st.prev_mouse_button(w, button, action, mods); + for ( auto & e : st.mouse_button_chain ) { + if ( e.context ) { + das_invoke_lambda::invoke( + e.context, nullptr, e.lambda, w, button, action, mods); + } + } + } + + void DasGlfw_ChainScrollDispatch ( GLFWwindow * w, double xoff, double yoff ) { + auto it = g_GlfwChain.find(w); + if ( it == g_GlfwChain.end() ) return; + auto & st = it->second; + if ( st.prev_scroll ) st.prev_scroll(w, xoff, yoff); + for ( auto & e : st.scroll_chain ) { + if ( e.context ) { + das_invoke_lambda::invoke( + e.context, nullptr, e.lambda, w, xoff, yoff); + } + } + } + + // Idempotent dispatcher install: always re-calls glfwSet*Callback so a + // non-self previous is captured as the chain's "prev" (preserves whatever + // ImGui_ImplGlfw or other backend installed). Self-reinstalls are no-ops + // for prev. + static void ensure_chain_cursor_pos_installed ( GLFWwindow * w ) { + auto & st = g_GlfwChain[w]; + auto previous = glfwSetCursorPosCallback(w, DasGlfw_ChainCursorPosDispatch); + if ( previous && previous != &DasGlfw_ChainCursorPosDispatch ) { + st.prev_cursor_pos = previous; + } + } + static void ensure_chain_mouse_button_installed ( GLFWwindow * w ) { + auto & st = g_GlfwChain[w]; + auto previous = glfwSetMouseButtonCallback(w, DasGlfw_ChainMouseButtonDispatch); + if ( previous && previous != &DasGlfw_ChainMouseButtonDispatch ) { + st.prev_mouse_button = previous; + } + } + static void ensure_chain_scroll_installed ( GLFWwindow * w ) { + auto & st = g_GlfwChain[w]; + auto previous = glfwSetScrollCallback(w, DasGlfw_ChainScrollDispatch); + if ( previous && previous != &DasGlfw_ChainScrollDispatch ) { + st.prev_scroll = previous; + } + } + + void DasGlfw_ChainAddCursorPos ( GLFWwindow * w, TLambda func, Context * ctx ) { + ensure_chain_cursor_pos_installed(w); + g_GlfwChain[w].cursor_pos_chain.push_back({ func, ctx }); + } + + void DasGlfw_ChainAddMouseButton ( GLFWwindow * w, TLambda func, Context * ctx ) { + ensure_chain_mouse_button_installed(w); + g_GlfwChain[w].mouse_button_chain.push_back({ func, ctx }); + } + + void DasGlfw_ChainAddScroll ( GLFWwindow * w, TLambda func, Context * ctx ) { + ensure_chain_scroll_installed(w); + g_GlfwChain[w].scroll_chain.push_back({ func, ctx }); + } + + void DasGlfw_ChainClear ( GLFWwindow * w ) { + auto it = g_GlfwChain.find(w); + if ( it == g_GlfwChain.end() ) return; + // Restore the previous GLFW callbacks (e.g. ImGui_ImplGlfw's) before + // tearing down the chain state; otherwise the dispatcher stays + // installed but early-returns on every real event because the map + // entry is gone, silently dropping input. + auto & st = it->second; + glfwSetCursorPosCallback (w, st.prev_cursor_pos); + glfwSetMouseButtonCallback (w, st.prev_mouse_button); + glfwSetScrollCallback (w, st.prev_scroll); + g_GlfwChain.erase(it); + } + + void DasGlfw_PostCursorPos ( GLFWwindow * w, double x, double y ) { + // ImGui_ImplGlfw_UpdateMouseData re-polls glfwGetCursorPos every frame + // on focused windows; without glfwSetCursorPos the synthetic position + // is overwritten one frame after the chain fires. Warps the visible OS + // cursor as a side effect — call DasGlfw_DispatchCursorPos when that's + // unwanted (tests / interactive desktops). + ensure_chain_cursor_pos_installed(w); + glfwSetCursorPos(w, x, y); + DasGlfw_ChainCursorPosDispatch(w, x, y); + } + + void DasGlfw_DispatchCursorPos ( GLFWwindow * w, double x, double y ) { + ensure_chain_cursor_pos_installed(w); + DasGlfw_ChainCursorPosDispatch(w, x, y); + } + + void DasGlfw_PostMouseButton ( GLFWwindow * w, int button, int action, int mods ) { + ensure_chain_mouse_button_installed(w); + DasGlfw_ChainMouseButtonDispatch(w, button, action, mods); + } + + void DasGlfw_PostScroll ( GLFWwindow * w, double xoff, double yoff ) { + ensure_chain_scroll_installed(w); + DasGlfw_ChainScrollDispatch(w, xoff, yoff); + } + void DasGlfw_DestroyWindow ( GLFWwindow * window ) { auto it = Module_dasGLFW::g_Callbacks.find(window); if ( it!=Module_dasGLFW::g_Callbacks.end() ) Module_dasGLFW::g_Callbacks.erase(it); + DasGlfw_ChainClear(window); glfwDestroyWindow(window); } @@ -157,6 +299,7 @@ namespace das { Module_dasGLFW::~Module_dasGLFW() { Module_dasGLFW::g_Callbacks = das_map{}; + g_GlfwChain = das_map{}; } void Module_dasGLFW::initMain () { @@ -175,6 +318,23 @@ namespace das { SideEffects::worstDefault,"DasGlfw_SetScrollCallback"); addExtern(*this,lib,"glfwDestroyWindow", SideEffects::worstDefault,"DasGlfw_DestroyWindow"); + // chain + synthetic event API + addExtern(*this,lib,"glfw_chain_add_cursor_pos", + SideEffects::worstDefault,"DasGlfw_ChainAddCursorPos"); + addExtern(*this,lib,"glfw_chain_add_mouse_button", + SideEffects::worstDefault,"DasGlfw_ChainAddMouseButton"); + addExtern(*this,lib,"glfw_chain_add_scroll", + SideEffects::worstDefault,"DasGlfw_ChainAddScroll"); + addExtern(*this,lib,"glfw_chain_clear", + SideEffects::worstDefault,"DasGlfw_ChainClear"); + addExtern(*this,lib,"glfw_post_cursor_pos", + SideEffects::worstDefault,"DasGlfw_PostCursorPos"); + addExtern(*this,lib,"glfw_dispatch_cursor_pos", + SideEffects::worstDefault,"DasGlfw_DispatchCursorPos"); + addExtern(*this,lib,"glfw_post_mouse_button", + SideEffects::worstDefault,"DasGlfw_PostMouseButton"); + addExtern(*this,lib,"glfw_post_scroll", + SideEffects::worstDefault,"DasGlfw_PostScroll"); addExtern(*this, lib, "glfwGetNativeWindow",SideEffects::worstDefault, "DAS_glfwGetNativeWindow") ->args({"window"}); addExtern(*this, lib, "glfwGetNativeDisplay",SideEffects::worstDefault, "DAS_glfwGetNativeDisplay"); diff --git a/modules/dasOpenGL/opengl/opengl_live.das b/modules/dasOpenGL/opengl/opengl_live.das index 7f57c4aab5..e40f1e3041 100644 --- a/modules/dasOpenGL/opengl/opengl_live.das +++ b/modules/dasOpenGL/opengl/opengl_live.das @@ -13,6 +13,7 @@ module opengl_live shared public require opengl/opengl_boost require live/glfw_live +require math require stbimage require daslib/json require daslib/json_boost @@ -103,9 +104,7 @@ def screenshot(input : JsonValue?) : JsonValue? { if (empty(fname)) { fname = "screenshot.png"; } var w, h : int live_get_framebuffer_size(w, h) - if (w <= 0 || h <= 0) { - return JV("no framebuffer") - } + return JV("no framebuffer") if (w <= 0 || h <= 0) var pixels : array pixels |> resize(w * h * 4) unsafe { @@ -129,3 +128,178 @@ def screenshot(input : JsonValue?) : JsonValue? { } return JV(ScreenshotResult(saved = fname, width = w, height = h)) } + +// ============================================================================ +// APNG video recording +// ============================================================================ +// +// Three live commands wrap a streaming APNG writer (dasStbImage's +// stbi_apng_begin/frame/end). Capture runs from a [before_update] hook +// at an fps throttle, with a max_seconds safety auto-stop. Encode + file I/O +// happen on a worker thread inside the C++ writer — the render loop only +// pays the cost of glReadPixels + a memcpy into the bounded queue. + +struct RecorderState { + writer : void? // StbiApngWriter* opaque handle + fps : float + frame_interval_s : float + next_capture_t : float + max_seconds : float + start_t : float + frames_written : int + width : int + height : int + file : string +} + +var private recorder : RecorderState +var private recorder_active : bool = false + +struct RecordStartArgs { + @optional file : string = "record.apng" + @optional fps : float = 30.0f + @optional max_seconds : float = 60.0f // 0 = no cap +} + +struct RecordStartResult { + file : string + fps : float + width : int + height : int + max_seconds : float +} + +[live_command(description="Begin APNG recording. Args: file, fps, max_seconds.")] +def record_start(input : JsonValue?) : JsonValue? { + return JV((error = "already recording")) if (recorder_active) + let args = from_JV(input, type) + var w, h : int + live_get_framebuffer_size(w, h) + return JV((error = "no framebuffer")) if (w <= 0 || h <= 0) + var writer_ptr : void? + unsafe { + writer_ptr = stbi_apng_begin(args.file, w, h, 4) + } + return JV((error = "could not open file", file = args.file)) if (writer_ptr == null) + let effective_fps = max(args.fps, 1.0f) + recorder.writer = writer_ptr + recorder.fps = effective_fps + recorder.frame_interval_s = 1.0f / effective_fps + recorder.max_seconds = args.max_seconds + recorder.start_t = get_uptime() + recorder.next_capture_t = recorder.start_t + recorder.frames_written = 0 + recorder.width = w + recorder.height = h + recorder.file = args.file + recorder_active = true + return JV(RecordStartResult( + file = args.file, + fps = effective_fps, + width = w, + height = h, + max_seconds = args.max_seconds + )) +} + +struct RecordStopResult { + saved : string + frames : int + dropped : int + duration_s : float + ok : bool +} + +[live_command(description="Stop APNG recording. Returns saved path + frame count.")] +def record_stop(input : JsonValue?) : JsonValue? { + return JV((error = "not recording")) if (!recorder_active) + var dropped : int + var ok : int + unsafe { + dropped = stbi_apng_dropped(recorder.writer) + ok = stbi_apng_end(recorder.writer) + } + let elapsed = get_uptime() - recorder.start_t + let saved = recorder.file + let frames = recorder.frames_written + recorder.writer = null + recorder_active = false + return JV(RecordStopResult( + saved = saved, + frames = frames, + dropped = dropped, + duration_s = elapsed, + ok = ok != 0 + )) +} + +struct RecordStatusResult { + active : bool + file : string + frames : int + elapsed_s : float + fps : float + width : int + height : int + dropped : int +} + +[live_command(description="Recording status — active state, frame count, elapsed.")] +def record_status(input : JsonValue?) : JsonValue? { + var dropped = 0 + if (recorder_active) { + unsafe { + dropped = stbi_apng_dropped(recorder.writer) + } + } + return JV(RecordStatusResult( + active = recorder_active, + file = recorder.file, + frames = recorder.frames_written, + elapsed_s = recorder_active ? get_uptime() - recorder.start_t : 0.0f, + fps = recorder.fps, + width = recorder.width, + height = recorder.height, + dropped = dropped + )) +} + +// Capture hook — fires every frame via [before_update]. No-op when not active. +[before_update] +def record_tick() { + return if (!recorder_active) + let now = get_uptime() + if (recorder.max_seconds > 0.0f && (now - recorder.start_t) >= recorder.max_seconds) { + record_stop(null) + return + } + return if (now < recorder.next_capture_t) + // Skip-missed scheduling: after a stall (long frame, gc pause, etc.) + // resync the deadline to `now + interval` instead of catching up frame + // by frame. The catch-up form would burst-capture every tick until it + // overtook `now`, which both exceeds the requested fps and produces + // wrong playback timing because delay_ms below is a constant. Derive + // delay_ms from the actual gap so playback reflects real spacing. + let last_capture_t = recorder.next_capture_t - recorder.frame_interval_s + let gap_s = now - last_capture_t + recorder.next_capture_t = now + recorder.frame_interval_s + let row_bytes = recorder.width * 4 + var pixels : array + pixels |> resize(recorder.width * recorder.height * 4) + unsafe { + glReadPixels(0, 0, recorder.width, recorder.height, GL_RGBA, GL_UNSIGNED_BYTE, addr(pixels[0])) + } + let delay_ms = int(gap_s * 1000.0f) + var ok : int + unsafe { + ok = stbi_apng_frame(recorder.writer, addr(pixels[0]), row_bytes, delay_ms) + } + // Local `var array` doesn't finalize on scope exit (no smart_ptr). Explicit + // delete avoids growing the heap by width*height*4 bytes per captured frame. + delete pixels + if (ok != 0) { + recorder.frames_written++ + } else { + record_stop(null) + } +} diff --git a/modules/dasStbImage/src/apng_write_impl.h b/modules/dasStbImage/src/apng_write_impl.h new file mode 100644 index 0000000000..fbd6df1a9f --- /dev/null +++ b/modules/dasStbImage/src/apng_write_impl.h @@ -0,0 +1,413 @@ +// Streaming Animated PNG (APNG) writer for daslang. +// +// This header MUST be #included from a translation unit that has +// #define STB_IMAGE_WRITE_IMPLEMENTATION +// already in effect, because we reuse stb's externally-linkable +// stbi_zlib_compress and the stbi_write_png_compression_level global. +// CRC32 is a small internal byte-table impl (apng_crc32_update) so the +// chunk body can be folded into the digest without an extra heap copy. +// +// Public C API (also forward-declared in dasStbImage.cpp for binding): +// void * stbi_apng_begin(const char *filename, int w, int h, int channels); +// int stbi_apng_frame(void *writer, const void *pixels, int stride_bytes, int delay_ms); +// int stbi_apng_end(void *writer); +// int stbi_apng_dropped(void *writer); +// +// Pixel data submitted to stbi_apng_frame is expected to be bottom-up +// (glReadPixels output). The worker thread flips rows top-down before encoding. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +# define stbi__apng_fseeko(fp, off, whence) _fseeki64((fp), (__int64)(off), (whence)) +# define stbi__apng_ftello(fp) _ftelli64(fp) +typedef long long stbi__apng_off_t; +#else +# include +# define stbi__apng_fseeko(fp, off, whence) fseeko((fp), (off_t)(off), (whence)) +# define stbi__apng_ftello(fp) ftello(fp) +typedef off_t stbi__apng_off_t; +#endif + +namespace stbi_apng_detail { + +struct Frame { + std::vector pixels; + int delay_ms; +}; + +static inline void put_be_u32(uint8_t *p, uint32_t v) { + p[0] = (uint8_t)((v >> 24) & 0xFFu); + p[1] = (uint8_t)((v >> 16) & 0xFFu); + p[2] = (uint8_t)((v >> 8) & 0xFFu); + p[3] = (uint8_t)( v & 0xFFu); +} +static inline void put_be_u16(uint8_t *p, uint16_t v) { + p[0] = (uint8_t)((v >> 8) & 0xFFu); + p[1] = (uint8_t)( v & 0xFFu); +} + +// Standard PNG CRC32: poly 0xedb88320, init 0xffffffff, final xor 0xffffffff. +// Byte-table form so we can fold the chunk type and body in two calls without +// allocating a contiguous copy of the body. +static inline const uint32_t * apng_crc_table() { + static const auto t = []() { + std::array arr{}; + for (uint32_t i = 0; i < 256; i++) { + uint32_t c = i; + for (int k = 0; k < 8; k++) + c = (c & 1u) ? (0xedb88320u ^ (c >> 1)) : (c >> 1); + arr[i] = c; + } + return arr; + }(); + return t.data(); +} +static inline uint32_t apng_crc32_update(uint32_t crc, const uint8_t *buf, size_t len) { + const uint32_t * t = apng_crc_table(); + for (size_t i = 0; i < len; i++) + crc = t[(crc ^ buf[i]) & 0xffu] ^ (crc >> 8); + return crc; +} + +// Write one PNG chunk: [length:u32][type:4][body:length][crc:u32]. +// CRC covers type+body, computed incrementally (no body copy). Returns 1 on success. +static int write_chunk(FILE *fp, const char *type, const uint8_t *body, uint32_t body_len) { + uint8_t hdr[8]; + put_be_u32(hdr, body_len); + memcpy(hdr + 4, type, 4); + if (fwrite(hdr, 1, 8, fp) != 8) return 0; + if (body_len && fwrite(body, 1, body_len, fp) != body_len) return 0; + uint32_t crc = 0xffffffffu; + crc = apng_crc32_update(crc, (const uint8_t *)type, 4); + if (body_len) crc = apng_crc32_update(crc, body, body_len); + crc ^= 0xffffffffu; + uint8_t crcbe[4]; + put_be_u32(crcbe, crc); + if (fwrite(crcbe, 1, 4, fp) != 4) return 0; + return 1; +} + +class ApngWriter { +public: + bool begin(const char *filename, int W, int H, int CH); + bool enqueue(const void *pixels, int stride_bytes, int delay_ms); + bool end(); + int dropped(); + +private: + void worker_loop(); + bool emit_frame(Frame &f); + + FILE *fp = nullptr; + std::string filepath; + int w = 0, h = 0, channels = 0; + stbi__apng_off_t acTL_body_offset = -1; + + std::mutex mu; + std::condition_variable cv; + std::deque queue; + int max_queue = 4; + int drop_count = 0; + bool errored = false; + bool stop_requested = false; + + std::thread worker; + int frames_written = 0; // worker-thread-only after begin() returns +}; + +inline bool ApngWriter::begin(const char *filename, int W, int H, int CH) { + if (W <= 0 || H <= 0) return false; + if (CH != 3 && CH != 4) return false; + w = W; h = H; channels = CH; + filepath = filename ? filename : ""; + fp = fopen(filename, "wb"); + if (!fp) return false; + + static const uint8_t sig[8] = {137, 80, 78, 71, 13, 10, 26, 10}; + if (fwrite(sig, 1, 8, fp) != 8) goto fail; + + { + uint8_t ihdr[13]; + put_be_u32(ihdr + 0, (uint32_t)w); + put_be_u32(ihdr + 4, (uint32_t)h); + ihdr[8] = 8; // bit_depth + ihdr[9] = (channels == 4) ? 6 : 2; // color_type: 6=RGBA, 2=RGB + ihdr[10] = 0; // compression = deflate + ihdr[11] = 0; // filter = adaptive (we use 0/None per-row) + ihdr[12] = 0; // interlace = none + if (!write_chunk(fp, "IHDR", ihdr, 13)) goto fail; + } + + { + // Remember acTL body offset for backpatch (skip 4-byte length + 4-byte type). + stbi__apng_off_t before_acTL = stbi__apng_ftello(fp); + if (before_acTL < 0) goto fail; + uint8_t actl[8]; + put_be_u32(actl + 0, 0); // num_frames — backpatched on close + put_be_u32(actl + 4, 0); // num_plays — 0 means loop forever + if (!write_chunk(fp, "acTL", actl, 8)) goto fail; + acTL_body_offset = before_acTL + 8; + } + + { + std::lock_guard lk(mu); + drop_count = 0; + errored = false; + stop_requested = false; + frames_written = 0; + } + worker = std::thread(&ApngWriter::worker_loop, this); + return true; + +fail: + if (fp) { fclose(fp); fp = nullptr; } + return false; +} + +inline bool ApngWriter::enqueue(const void *pixels, int stride_bytes, int delay_ms) { + if (!pixels) return false; + size_t row_bytes = (size_t)w * (size_t)channels; + // Reject strides that would cause OOB reads: must be positive and at + // least row_bytes. Negative stride (bottom-up) is not supported here; + // callers should flip in their pixel buffer instead. + if (stride_bytes <= 0 || (size_t)stride_bytes < row_bytes) return false; + // Guard size_t multiply: row_bytes * h would wrap on 32-bit hosts for + // very large frames; under-allocation here means OOB writes below. + if ((size_t)h != 0 && row_bytes > SIZE_MAX / (size_t)h) return false; + std::unique_lock lk(mu); + if (errored) return false; + if ((int)queue.size() >= max_queue) { + queue.pop_front(); + drop_count++; + } + Frame f; + f.pixels.resize(row_bytes * (size_t)h); + const uint8_t *src = (const uint8_t *)pixels; + if ((size_t)stride_bytes == row_bytes) { + memcpy(f.pixels.data(), src, f.pixels.size()); + } else { + for (int y = 0; y < h; y++) { + memcpy(f.pixels.data() + (size_t)y * row_bytes, + src + (size_t)y * (size_t)stride_bytes, + row_bytes); + } + } + f.delay_ms = delay_ms; + queue.emplace_back(std::move(f)); + lk.unlock(); + cv.notify_one(); + return true; +} + +inline void ApngWriter::worker_loop() { + while (true) { + Frame f; + { + std::unique_lock lk(mu); + cv.wait(lk, [this]{ return !queue.empty() || stop_requested; }); + if (queue.empty() && stop_requested) return; + f = std::move(queue.front()); + queue.pop_front(); + } + if (!emit_frame(f)) { + std::lock_guard lk(mu); + errored = true; + queue.clear(); + return; + } + } +} + +inline bool ApngWriter::emit_frame(Frame &f) { + size_t row_bytes = (size_t)w * (size_t)channels; + + // Flip rows in place: glReadPixels emits bottom-up; PNG is top-down. + { + std::vector tmp(row_bytes); + for (int y = 0; y < h / 2; y++) { + uint8_t *top = f.pixels.data() + (size_t)y * row_bytes; + uint8_t *bot = f.pixels.data() + (size_t)(h - 1 - y) * row_bytes; + memcpy(tmp.data(), top, row_bytes); + memcpy(top, bot, row_bytes); + memcpy(bot, tmp.data(), row_bytes); + } + } + + // Build deflate input: [filter=0x00 || row] per scanline. Guard the size_t + // multiply and the int cast going into stbi_zlib_compress -- enqueue() has + // already rejected dimensions that can't form a row, but the per-frame + // buffer adds one byte per row and feeds an int-sized API. + size_t row_plus_filter = row_bytes + 1; + if (row_plus_filter < row_bytes) return false; + if ((size_t)h != 0 && row_plus_filter > SIZE_MAX / (size_t)h) return false; + size_t in_size = row_plus_filter * (size_t)h; + if (in_size > (size_t)INT_MAX) return false; + std::vector in(in_size); + { + uint8_t *p = in.data(); + for (int y = 0; y < h; y++) { + *p++ = 0; + memcpy(p, f.pixels.data() + (size_t)y * row_bytes, row_bytes); + p += row_bytes; + } + } + + int zlib_len = 0; + unsigned char *zlib_buf = stbi_zlib_compress(in.data(), (int)in.size(), + &zlib_len, stbi_write_png_compression_level); + if (!zlib_buf) return false; + + // fcTL — frame N≥1 gets seq=2N-1, frame 0 gets seq=0. + { + uint8_t fctl[26]; + uint32_t seq = (frames_written == 0) ? 0u : (uint32_t)(2 * frames_written - 1); + put_be_u32(fctl + 0, seq); + put_be_u32(fctl + 4, (uint32_t)w); + put_be_u32(fctl + 8, (uint32_t)h); + put_be_u32(fctl + 12, 0); // x_offset + put_be_u32(fctl + 16, 0); // y_offset + int dms = f.delay_ms; + if (dms < 0) dms = 0; + if (dms > 65535) dms = 65535; + put_be_u16(fctl + 20, (uint16_t)dms); // delay_num — ms + put_be_u16(fctl + 22, 1000); // delay_den — 1/1000 sec base + fctl[24] = 0; // dispose_op = APNG_DISPOSE_OP_NONE + fctl[25] = 0; // blend_op = APNG_BLEND_OP_SOURCE + if (!write_chunk(fp, "fcTL", fctl, 26)) { + STBIW_FREE(zlib_buf); + return false; + } + } + + // IDAT (frame 0) or fdAT (frame N≥1, body = u32 seq || zlib stream). + bool ok; + if (frames_written == 0) { + ok = write_chunk(fp, "IDAT", zlib_buf, (uint32_t)zlib_len) != 0; + } else { + uint32_t fdat_seq = (uint32_t)(2 * frames_written); + std::vector fdat_body(4 + (size_t)zlib_len); + put_be_u32(fdat_body.data(), fdat_seq); + memcpy(fdat_body.data() + 4, zlib_buf, (size_t)zlib_len); + ok = write_chunk(fp, "fdAT", fdat_body.data(), (uint32_t)fdat_body.size()) != 0; + } + STBIW_FREE(zlib_buf); + if (!ok) return false; + + frames_written++; + return true; +} + +inline bool ApngWriter::end() { + { + std::lock_guard lk(mu); + stop_requested = true; + } + cv.notify_one(); + if (worker.joinable()) worker.join(); + + if (!fp) return false; + bool was_errored; + { + std::lock_guard lk(mu); + was_errored = errored; + } + bool result = !was_errored; + + // Zero-frame close: the file would otherwise contain only IHDR + acTL + IEND, + // which isn't a valid PNG/APNG (no IDAT/fdAT). Treat as failure and remove + // the partial file so callers don't end up shipping a corrupt artifact. + if (frames_written == 0) { + fclose(fp); + fp = nullptr; + if (!filepath.empty()) remove(filepath.c_str()); + return false; + } + + // Backpatch acTL.num_frames + recomputed CRC. Even when errored, we still + // emit a valid truncated APNG so the partial recording is salvageable. + if (acTL_body_offset >= 0 && frames_written > 0) { + uint8_t newbody[8]; + put_be_u32(newbody + 0, (uint32_t)frames_written); + put_be_u32(newbody + 4, 0); // num_plays unchanged + uint32_t crc = 0xffffffffu; + crc = apng_crc32_update(crc, (const uint8_t *)"acTL", 4); + crc = apng_crc32_update(crc, newbody, 8); + crc ^= 0xffffffffu; + uint8_t patch[12]; + memcpy(patch, newbody, 8); + put_be_u32(patch + 8, crc); + if (stbi__apng_fseeko(fp, acTL_body_offset, SEEK_SET) != 0) result = false; + else if (fwrite(patch, 1, 12, fp) != 12) result = false; + if (stbi__apng_fseeko(fp, 0, SEEK_END) != 0) result = false; + } + + if (!write_chunk(fp, "IEND", nullptr, 0)) result = false; + fflush(fp); + fclose(fp); + fp = nullptr; + return result; +} + +inline int ApngWriter::dropped() { + std::lock_guard lk(mu); + return drop_count; +} + +} // namespace stbi_apng_detail + + +// ============================================================================ +// C-callable API — opaque void * handle for the daslang binding. +// ============================================================================ + +struct StbiApngWriterImpl { + stbi_apng_detail::ApngWriter impl; +}; + +extern "C" { + +void * stbi_apng_begin(const char *filename, int w, int h, int channels) { + if (!filename) return nullptr; + StbiApngWriterImpl *handle = new StbiApngWriterImpl(); + if (!handle->impl.begin(filename, w, h, channels)) { + delete handle; + return nullptr; + } + return handle; +} + +int stbi_apng_frame(void *writer, const void *pixels, int stride_bytes, int delay_ms) { + if (!writer || !pixels) return 0; + StbiApngWriterImpl *handle = (StbiApngWriterImpl *)writer; + return handle->impl.enqueue(pixels, stride_bytes, delay_ms) ? 1 : 0; +} + +int stbi_apng_end(void *writer) { + if (!writer) return 0; + StbiApngWriterImpl *handle = (StbiApngWriterImpl *)writer; + bool ok = handle->impl.end(); + delete handle; + return ok ? 1 : 0; +} + +int stbi_apng_dropped(void *writer) { + if (!writer) return 0; + StbiApngWriterImpl *handle = (StbiApngWriterImpl *)writer; + return handle->impl.dropped(); +} + +} // extern "C" diff --git a/modules/dasStbImage/src/dasStbImage.cpp b/modules/dasStbImage/src/dasStbImage.cpp index 892a8f70ce..4ba135e5fb 100644 --- a/modules/dasStbImage/src/dasStbImage.cpp +++ b/modules/dasStbImage/src/dasStbImage.cpp @@ -54,6 +54,14 @@ DAS_BASE_BIND_ENUM_FACTORY(stbir_datatype_DasProxy, "stbir_datatype") // stbi_write_*_to_func callback (defined in dasStbImage_impl.cpp) void stbi_write_to_vec_callback ( void * context, void * data, int size ); +// APNG writer (defined in apng_write_impl.h via dasStbImage_impl.cpp) +extern "C" { + void * stbi_apng_begin ( const char * filename, int w, int h, int channels ); + int stbi_apng_frame ( void * writer, const void * pixels, int stride_bytes, int delay_ms ); + int stbi_apng_end ( void * writer ); + int stbi_apng_dropped ( void * writer ); +} + namespace das { // write-to-memory wrappers — encode image, pass temp array to block @@ -220,6 +228,20 @@ class Module_StbImage : public Module { addExtern (*this, lib, "stbi_write_get_tga_with_rle", SideEffects::accessExternal, "stbi_write_get_tga_with_rle"); + // ---- stb_image_write: APNG animated writer (streaming, threaded) ---- + addExtern (*this, lib, "stbi_apng_begin", + SideEffects::modifyExternal, "stbi_apng_begin") + ->args({"filename","w","h","channels"}); + addExtern (*this, lib, "stbi_apng_frame", + SideEffects::modifyExternal, "stbi_apng_frame") + ->args({"writer","pixels","stride_bytes","delay_ms"}); + addExtern (*this, lib, "stbi_apng_end", + SideEffects::modifyExternal, "stbi_apng_end") + ->arg("writer"); + addExtern (*this, lib, "stbi_apng_dropped", + SideEffects::accessExternal, "stbi_apng_dropped") + ->arg("writer"); + // ---- stb_image_write: write to memory (block API) ---- addExtern (*this, lib, "stbi_write_png_to_memory", SideEffects::invoke, "stbi_write_png_to_memory") diff --git a/modules/dasStbImage/src/dasStbImage.h b/modules/dasStbImage/src/dasStbImage.h index cf62746a5d..04dc366399 100644 --- a/modules/dasStbImage/src/dasStbImage.h +++ b/modules/dasStbImage/src/dasStbImage.h @@ -13,6 +13,14 @@ int stbi_write_get_force_png_filter ( ); void stbi_write_set_tga_with_rle ( int rle ); int stbi_write_get_tga_with_rle ( ); +// APNG (animated PNG) writer — see apng_write_impl.h +extern "C" { + void * stbi_apng_begin ( const char * filename, int w, int h, int channels ); + int stbi_apng_frame ( void * writer, const void * pixels, int stride_bytes, int delay_ms ); + int stbi_apng_end ( void * writer ); + int stbi_apng_dropped ( void * writer ); +} + namespace das { // write-to-memory wrappers void stbi_write_png_to_memory ( int x, int y, int comp, const void * data, int stride_bytes, diff --git a/modules/dasStbImage/src/dasStbImage_impl.cpp b/modules/dasStbImage/src/dasStbImage_impl.cpp index 4d7329f01c..289933c627 100644 --- a/modules/dasStbImage/src/dasStbImage_impl.cpp +++ b/modules/dasStbImage/src/dasStbImage_impl.cpp @@ -21,3 +21,6 @@ void stbi_write_to_vec_callback ( void * context, void * data, int size ) { auto * src = (const uint8_t *) data; vec->insert(vec->end(), src, src + size); } + +// APNG (animated PNG) writer — defined here so stb's static helpers are in scope. +#include "apng_write_impl.h" diff --git a/skills/daslang_live.md b/skills/daslang_live.md index 1497acb6f1..3ef0e69f86 100644 --- a/skills/daslang_live.md +++ b/skills/daslang_live.md @@ -86,7 +86,7 @@ def pre_tick() { ... } // Called every frame before update() — used by li ## Helper Modules -### `live/glfw_live` — GLFW Window Lifecycle +### `live/glfw_live` — GLFW Window Lifecycle + Synthetic Mouse Driver Manages a GLFW window that persists across reloads. Provides: @@ -97,9 +97,35 @@ Manages a GLFW window that persists across reloads. Provides: - `live_end_frame()` — swap buffers - `live_get_framebuffer_size(var w, h : int&)` — current framebuffer size +**Synthetic mouse driver.** Events flow through `dasGLFW`'s chain dispatcher (`glfw_chain_add_*` / `glfw_post_*` / `glfw_dispatch_*` C++ bindings), so any listener installed on the window — `ImGui_ImplGlfw`, app callbacks — receives them indistinguishably from real OS input. The visual-aids demo uses this to re-record APNG tours from JSON timelines. + +Live commands: + +- `mouse_pos` — teleport synthetic cursor. Args: `x`, `y`. +- `mouse_click` — synthetic button press/release. Args: `button` (0/1/2), `action` (`"press"` | `"release"`). +- `mouse_scroll` — synthetic scroll. Args: `x`, `y` (offsets). +- `mouse_move_to` — animated linear move to `(x, y)` over `duration_ms` (default 250). Per-frame lerp posts one cursor event per frame so anything reading mouse position sees smooth motion. +- `mouse_play` — scripted timeline. Args: `events` array of `{t_ms, kind, x, y, button, action}` where `kind` is `"move"` | `"button"` | `"scroll"`. Between move events the per-frame `[before_update]` tick lerps and posts one cursor event per frame. +- `mouse_stop` — stop playback, clear the queue. +- `mouse_status` — `{playing, elapsed_ms, cursor_x, cursor_y, queue_idx, queue_total}`. + +Helper: + +- `get_synth_cursor() : tuple` — `(active, x, y)`. Overlays drawing a cursor sprite or motion trail should consult this — when `active` the synthetic driver owns the position, and `ImGui_ImplGlfw`'s per-frame poll would otherwise overwrite `io.MousePos` with the real OS cursor on focused windows. +- `synth_click_button` / `synth_click_action` / `synth_click_t` — public vars consumed by visual-aids overlays to flash a click indicator (the OS cursor isn't visible in the GL back buffer, so a real click leaves no trace in APNG recordings). + +The OS cursor is **not** warped — it lives outside the GL back buffer (the window manager paints it at display time), so warping has no effect on glReadPixels-driven APNG recordings. The visible proof of motion is the visual-aids trail + cursor sprite, both drawn through ImGui's draw lists. + ### `live/opengl_live` — OpenGL Utilities -Provides the `screenshot` live command (captures framebuffer to PNG). +Provides `screenshot` and `record_*` live commands: + +- `screenshot` — captures the current framebuffer to a PNG file. Args: `file` (default `"screenshot.png"`). +- `record_start` — begin streaming APNG recording. Args: `file` (default `"record.apng"`), `fps` (default `30.0`), `max_seconds` (default `60.0`; `0` disables the cap). Capture runs from a `[before_update]` hook at the fps throttle; encode + file I/O happen on a worker thread inside `dasStbImage`'s APNG writer, so the render loop only pays glReadPixels + a memcpy into a bounded queue. Drops the oldest queued frame and bumps a drop counter if the worker falls behind. +- `record_stop` — finalize the file (backpatches `acTL.num_frames`, writes `IEND`) and join the worker. Returns `{saved, frames, dropped, duration_s, ok}`. Auto-fires when `max_seconds` elapses. +- `record_status` — return the recorder's current state (`active`, `frames`, `dropped`, `elapsed_s`, …). + +The recorded file is APNG (animated PNG) — single file, lossless, plays natively in Chrome / Firefox / Safari. Default-image fallback means `stbi_load` decodes frame 0 as a regular PNG. ### `live/decs_live` — DECS Persistence @@ -248,6 +274,7 @@ def cmd_do_thing(input : JsonValue?) : JsonValue? { **Built-in commands** (from helper modules): - `screenshot` — from `live/opengl_live`, captures framebuffer to PNG +- `record_start` / `record_stop` / `record_status` — from `live/opengl_live`, streaming APNG video recording - `help` — built-in, lists all registered commands **Convention:** Prefix game-specific commands with `cmd_` to distinguish them from built-in commands. diff --git a/tests/dasglfw/test_mouse_play_sort.das b/tests/dasglfw/test_mouse_play_sort.das new file mode 100644 index 0000000000..e1f66fc79c --- /dev/null +++ b/tests/dasglfw/test_mouse_play_sort.das @@ -0,0 +1,59 @@ +options gen2 +options no_aot + +require dastest/testing_boost public + +// Self-contained smoke test for the sort technique used by +// `sort_play_events` in modules/dasGlfw/dasglfw/glfw_live.das. We can't +// require live/glfw_live directly here without dragging in the full +// GLFW + OpenGL chain (which the no_aot harness can't link against), +// so we mirror the wire-event shape + comparator and verify the same +// sort() with a `<` lambda lands t_ms-ascending. +// +// `sort()` is qsort, not stable, so this test asserts only the +// ordering invariant mouse_play depends on (non-decreasing t_ms), +// not any tie-break ordering. + +struct TestMouseEventWire { + t_ms : int = 0 + tag : string = "" +} + +def sort_by_t_ms(var events : array) { + events |> sort() $(a, b : TestMouseEventWire) { + return a.t_ms < b.t_ms + } +} + +[test] +def test_sort_orders_by_tms(t : T?) { + var events <- [ + TestMouseEventWire(t_ms = 300, tag = "c"), + TestMouseEventWire(t_ms = 100, tag = "a"), + TestMouseEventWire(t_ms = 200, tag = "b") + ] + sort_by_t_ms(events) + t |> equal(events[0].t_ms, 100) + t |> equal(events[1].t_ms, 200) + t |> equal(events[2].t_ms, 300) +} + +[test] +def test_sort_empty(t : T?) { + var events : array + sort_by_t_ms(events) + t |> equal(length(events), 0) +} + +[test] +def test_sort_already_sorted(t : T?) { + var events <- [ + TestMouseEventWire(t_ms = 100), + TestMouseEventWire(t_ms = 200), + TestMouseEventWire(t_ms = 300) + ] + sort_by_t_ms(events) + t |> equal(events[0].t_ms, 100) + t |> equal(events[1].t_ms, 200) + t |> equal(events[2].t_ms, 300) +} diff --git a/tests/stbimage/test_apng.das b/tests/stbimage/test_apng.das new file mode 100644 index 0000000000..e46350ae1c --- /dev/null +++ b/tests/stbimage/test_apng.das @@ -0,0 +1,301 @@ +options gen2 +options no_unused_block_arguments = false +options no_unused_function_arguments = false +options indenting = 4 + +require stbimage +require strings +require daslib/safe_addr +require daslib/fio +require dastest/testing_boost public + +// Tiny chunk parser. Walks the PNG byte stream once, populating a count +// per chunk type plus the body of acTL (the only chunk we backpatch). +struct ChunkSummary { + actl_body : array // 8 bytes: num_frames(u32 BE) + num_plays(u32 BE) + n_ihdr : int + n_actl : int + n_fctl : int + n_idat : int + n_fdat : int + n_iend : int + bad : bool // signature mismatch or truncated stream +} + +def be_u32_at(b0, b1, b2, b3 : uint8) : uint { + return ((uint(b0) << 24u) | (uint(b1) << 16u) | (uint(b2) << 8u) | uint(b3)) +} + +def parse_png_chunks(path : string) : ChunkSummary { + var out : ChunkSummary + var arr : array + // Read file as raw bytes — fread(path : string) returns a das_string that + // peek_data truncates at the first NUL. Use sized fread(f, buf) instead. + let sz = int(stat(path).size) + if (sz < 8) { + out.bad = true + return <- out + } + arr |> resize(sz) + fopen(path, "rb") $(f) { + if (f != null) { + fread(f, arr) + } + } + let n = length(arr) + // Signature: 137 80 78 71 13 10 26 10 + if (n < 8 || int(arr[0]) != 137 || int(arr[1]) != 80 || int(arr[2]) != 78 || int(arr[3]) != 71) { + out.bad = true + return <- out + } + var p = 8 + while (p + 8 <= n) { + let len = int(be_u32_at(arr[p + 0], arr[p + 1], arr[p + 2], arr[p + 3])) + let t0 = arr[p + 4]; let t1 = arr[p + 5]; let t2 = arr[p + 6]; let t3 = arr[p + 7] + let body_start = p + 8 + let crc_start = body_start + len + if (crc_start + 4 > n) { + out.bad = true + return <- out + } + // Cheap 4-char dispatch — compare bytes directly. IHDR/acTL/fcTL/IDAT/fdAT/IEND. + if (t0 == 73u8 && t1 == 72u8 && t2 == 68u8 && t3 == 82u8) { + out.n_ihdr++ + } elif (t0 == 97u8 && t1 == 99u8 && t2 == 84u8 && t3 == 76u8) { + out.n_actl++ + if (len == 8) { + out.actl_body |> resize(8) + for (i in range(8)) { + out.actl_body[i] = arr[body_start + i] + } + } + } elif (t0 == 102u8 && t1 == 99u8 && t2 == 84u8 && t3 == 76u8) { + out.n_fctl++ + } elif (t0 == 73u8 && t1 == 68u8 && t2 == 65u8 && t3 == 84u8) { + out.n_idat++ + } elif (t0 == 102u8 && t1 == 100u8 && t2 == 65u8 && t3 == 84u8) { + out.n_fdat++ + } elif (t0 == 73u8 && t1 == 69u8 && t2 == 78u8 && t3 == 68u8) { + out.n_iend++ + return <- out + } + p = crc_start + 4 + } + return <- out +} + +def make_gradient_frame(var pixels : array; w : int; h : int; frame_idx : int) { + pixels |> resize(w * h * 4) + for (y in range(h)) { + for (x in range(w)) { + let i = (y * w + x) * 4 + pixels[i + 0] = uint8((x + frame_idx * 16) & int(0xFF)) + pixels[i + 1] = uint8(y & int(0xFF)) + pixels[i + 2] = uint8(frame_idx * 64) + pixels[i + 3] = 0xFFu8 + } + } +} + +[test] +def test_apng_basic(t : T?) { + t |> run("APNG: encode 4 frames, parse chunks, decode frame 0") <| @(tt : T?) { + let fname = "{get_das_root()}/tests/stbimage/_test_output.apng" + let W = 32; let H = 32 + var writer = unsafe(stbi_apng_begin(fname, W, H, 4)) + tt |> success(writer != null, "stbi_apng_begin succeeded") + return if (writer == null) + for (frame in range(4)) { + var pixels : array + make_gradient_frame(pixels, W, H, frame) + var ok : int + unsafe { + ok = stbi_apng_frame(writer, addr(pixels[0]), W * 4, 100) + } + tt |> equal(ok, 1) + } + let end_ok = unsafe(stbi_apng_end(writer)) + tt |> equal(end_ok, 1) + + // Frame 0 decodes as a normal PNG (default-image fallback) + var x = 0; var y = 0; var comp = 0 + var loaded = stbi_load(fname, safe_addr(x), safe_addr(y), safe_addr(comp), 4) + tt |> success(loaded != null, "frame 0 decodes as PNG") + if (loaded != null) { + tt |> equal(x, W) + tt |> equal(y, H) + stbi_image_free(loaded) + } + + // Chunk parse: 1 IHDR, 1 acTL with num_frames=4, 4 fcTL, 1 IDAT, 3 fdAT, 1 IEND + let chunks = parse_png_chunks(fname) + tt |> success(!chunks.bad, "chunk parse: signature ok, stream not truncated") + tt |> equal(chunks.n_ihdr, 1) + tt |> equal(chunks.n_actl, 1) + tt |> equal(chunks.n_fctl, 4) + tt |> equal(chunks.n_idat, 1) + tt |> equal(chunks.n_fdat, 3) + tt |> equal(chunks.n_iend, 1) + // acTL backpatch: num_frames == 4 + tt |> equal(length(chunks.actl_body), 8) + if (length(chunks.actl_body) == 8) { + let nf = be_u32_at(chunks.actl_body[0], chunks.actl_body[1], chunks.actl_body[2], chunks.actl_body[3]) + tt |> equal(int(nf), 4) + } + remove(fname) + } +} + +[test] +def test_apng_rgb_channels(t : T?) { + t |> run("APNG: 3-channel RGB encode works") <| @(tt : T?) { + let fname = "{get_das_root()}/tests/stbimage/_test_output_rgb.apng" + let W = 8; let H = 8 + var writer = unsafe(stbi_apng_begin(fname, W, H, 3)) + tt |> success(writer != null, "begin RGB succeeded") + return if (writer == null) + var pixels : array + pixels |> resize(W * H * 3) + for (i in range(W * H * 3)) { + pixels[i] = uint8(i & int(0xFF)) + } + unsafe { + stbi_apng_frame(writer, addr(pixels[0]), W * 3, 50) + stbi_apng_frame(writer, addr(pixels[0]), W * 3, 50) + } + let end_ok = unsafe(stbi_apng_end(writer)) + tt |> equal(end_ok, 1) + let chunks = parse_png_chunks(fname) + tt |> equal(chunks.n_ihdr, 1) + tt |> equal(chunks.n_idat, 1) + tt |> equal(chunks.n_fdat, 1) + remove(fname) + } +} + +[test] +def test_apng_begin_bad_filename(t : T?) { + t |> run("APNG: begin with unwritable path returns null") <| @(tt : T?) { + // A path that fopen("wb") refuses: the path IS a directory. + let writer = unsafe(stbi_apng_begin(get_das_root(), 16, 16, 4)) + tt |> success(writer == null, "begin on bad path returns null, no crash") + } +} + +[test] +def test_apng_drop_accounting(t : T?) { + t |> run("APNG: submitted == emitted + dropped (worker race-tolerant invariant)") <| @(tt : T?) { + let fname = "{get_das_root()}/tests/stbimage/_test_output_overflow.apng" + // Big-ish frame so deflate takes noticeable time vs. enqueue speed. + let W = 512; let H = 512 + var writer = unsafe(stbi_apng_begin(fname, W, H, 4)) + if (writer == null) { + tt |> success(false, "begin failed") + return + } + var pixels : array + pixels |> resize(W * H * 4) + // Random-ish content so deflate has actual work to do + for (i in range(W * H * 4)) { + pixels[i] = uint8((i * 1103515245 + 12345) & int(0xFF)) + } + // Submit 40 frames as fast as possible — queue cap is 4, so we expect drops. + for (_i in range(40)) { + unsafe { + stbi_apng_frame(writer, addr(pixels[0]), W * 4, 33) + } + } + let dropped_before_end = unsafe(stbi_apng_dropped(writer)) + let end_ok = unsafe(stbi_apng_end(writer)) + tt |> equal(end_ok, 1) + // File is valid even when many frames dropped — verify chunk parse succeeds. + let chunks = parse_png_chunks(fname) + tt |> success(!chunks.bad, "file is valid APNG even after drops") + tt |> equal(chunks.n_ihdr, 1) + tt |> equal(chunks.n_iend, 1) + // Submitter/worker accounting: every submitted frame either lands as an + // fcTL chunk or counts as a drop. drop_count is only updated by enqueue + // (no more after the 40th submit completes), and end() drains the queue, + // so this invariant is race-free even though the worker is concurrent. + tt |> equal(dropped_before_end + chunks.n_fctl, 40) + // num_frames in acTL must equal n_fctl in file + tt |> equal(length(chunks.actl_body), 8) + if (length(chunks.actl_body) == 8) { + let nf = int(be_u32_at(chunks.actl_body[0], chunks.actl_body[1], chunks.actl_body[2], chunks.actl_body[3])) + tt |> equal(nf, chunks.n_fctl) + } + remove(fname) + } +} + +[test] +def test_apng_double_writers(t : T?) { + t |> run("APNG: two parallel writers — no shared state") <| @(tt : T?) { + let path1 = "{get_das_root()}/tests/stbimage/_test_output_p1.apng" + let path2 = "{get_das_root()}/tests/stbimage/_test_output_p2.apng" + var w1 = unsafe(stbi_apng_begin(path1, 32, 32, 4)) + var w2 = unsafe(stbi_apng_begin(path2, 64, 64, 4)) + tt |> success(w1 != null && w2 != null, "both writers opened") + if (w1 == null || w2 == null) { + return + } + var px32 : array; px32 |> resize(32 * 32 * 4) + var px64 : array; px64 |> resize(64 * 64 * 4) + for (_frame in range(2)) { + unsafe { + stbi_apng_frame(w1, addr(px32[0]), 32 * 4, 100) + stbi_apng_frame(w2, addr(px64[0]), 64 * 4, 100) + } + } + let ok1 = unsafe(stbi_apng_end(w1)) + let ok2 = unsafe(stbi_apng_end(w2)) + tt |> equal(ok1, 1) + tt |> equal(ok2, 1) + // Both files should be valid APNGs of their respective sizes + var x = 0; var y = 0; var comp = 0 + var l1 = stbi_load(path1, safe_addr(x), safe_addr(y), safe_addr(comp), 4) + tt |> success(l1 != null, "path1 decodes") + if (l1 != null) { + tt |> equal(x, 32) + tt |> equal(y, 32) + stbi_image_free(l1) + } + x = 0; y = 0; comp = 0 + var l2 = stbi_load(path2, safe_addr(x), safe_addr(y), safe_addr(comp), 4) + tt |> success(l2 != null, "path2 decodes") + if (l2 != null) { + tt |> equal(x, 64) + tt |> equal(y, 64) + stbi_image_free(l2) + } + remove(path1) + remove(path2) + } +} + +[test] +def test_apng_dropped_zero_when_keeping_up(t : T?) { + t |> run("APNG: drop counter stays at 0 for small, slow stream") <| @(tt : T?) { + let fname = "{get_das_root()}/tests/stbimage/_test_output_slow.apng" + let W = 16; let H = 16 + var writer = unsafe(stbi_apng_begin(fname, W, H, 4)) + if (writer == null) { + tt |> success(false, "begin failed") + return + } + var pixels : array + pixels |> resize(W * H * 4) + for (_frame in range(3)) { + unsafe { + stbi_apng_frame(writer, addr(pixels[0]), W * 4, 100) + } + sleep(50u) // ms — give worker time to drain + } + let dropped = unsafe(stbi_apng_dropped(writer)) + tt |> equal(dropped, 0) + unsafe { + stbi_apng_end(writer) + } + remove(fname) + } +}