diff --git a/engine/openbor.c b/engine/openbor.c index 328648652..0a9b0eef0 100644 --- a/engine/openbor.c +++ b/engine/openbor.c @@ -1927,10 +1927,10 @@ void execute_onblockz_script(entity *ent) } } -void execute_onblocka_script(entity *ent, entity *other) +void execute_onblocky_script(entity *ent, entity *other) { ScriptVariant tempvar; - Script *cs = ent->scripts->onblocka_script; + Script *cs = ent->scripts->onblocky_script; if(Script_IsInitialized(cs)) { ScriptVariant_Init(&tempvar); @@ -17256,8 +17256,8 @@ s_model *load_cached_model(char *name, char *owner, char unload) case CMD_MODEL_ONBLOCKZSCRIPT: pos += lcmHandleCommandScripts(&arglist, buf + pos, newchar->scripts->onblockz_script, "onblockzscript", filename, 1, 0); break; - case CMD_MODEL_ONBLOCKASCRIPT: - pos += lcmHandleCommandScripts(&arglist, buf + pos, newchar->scripts->onblocka_script, "onblockascript", filename, 1, 0); + case CMD_MODEL_ONBLOCKYSCRIPT: + pos += lcmHandleCommandScripts(&arglist, buf + pos, newchar->scripts->onblocky_script, "onblockyscript", filename, 1, 0); break; case CMD_MODEL_ONMOVEXSCRIPT: pos += lcmHandleCommandScripts(&arglist, buf + pos, newchar->scripts->onmovex_script, "onmovexscript", filename, 1, 0); @@ -31837,7 +31837,7 @@ void check_gravity(entity *e) { self->velocity.y = 0; self->hithead = other; - execute_onblocka_script(self, other); + execute_onblocky_script(self, other); } } else @@ -39974,13 +39974,13 @@ int common_trymove(float xdir, float zdir) { xdir = 0; if ( self->falling && (self->modeldata.hitwalltype < 0 || (self->modeldata.hitwalltype >= 0 && level->walls[wall].type == self->modeldata.hitwalltype)) ) hit |= 1; - execute_onblockw_script(self, &level->walls[wall], PLANE_X, wall); + execute_onblockw_script(self, &level->walls[wall], wall, PLANE_X); } if(zdir && (wall = checkwall_below(self->position.x, z, T_MAX_CHECK_ALTITUDE)) >= 0 && level->walls[wall].height > self->position.y) { zdir = 0; if ( self->falling && (self->modeldata.hitwalltype < 0 || (self->modeldata.hitwalltype >= 0 && level->walls[wall].type == self->modeldata.hitwalltype)) ) hit |= 1; - execute_onblockw_script(self, &level->walls[wall], PLANE_Z, wall); + execute_onblockw_script(self, &level->walls[wall], wall, PLANE_Z); } if ( hit && !self->hitwall && validanim(self, ANI_HITWALL) ) ent_set_anim(self, ANI_HITWALL, 0); @@ -50914,6 +50914,11 @@ void update(int ingame, int usevwait) getinterval(); if(playrecstatus->status == A_REC_PLAY && !_pause && level) if ( !playRecordedInputs() ) stopRecordInputs(); inputrefresh(playrecstatus->status); +#ifdef WEBM + movie_playback_update( + (bothnewkeys & (FLAG_ESC | FLAG_ANYBUTTON)) != 0 + ); +#endif if(playrecstatus->status == A_REC_REC && !_pause && level) if ( !recordInputs() ) stopRecordInputs(); if ((!_pause && ingame == 1) || alwaysupdate) @@ -51861,71 +51866,86 @@ int playgif(char *filename, int x, int y, int noskip) #ifdef WEBM -// Returns 0 on error, -1 on escape +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Preserve blocking playwebm() behavior as a wrapper around +* the reusable movie APIs. Return 1 on completion, 0 on error, +* or -1 when creator-enabled input interrupts playback. +*/ int playwebm(const char *path, int noskip) { int retval = 1; - webm_context *ctx = NULL; - yuv_video_mode info; - s_screen *rgb_frame = NULL; - - ctx = webm_start_playback(path, savedata.musicvol); - if(ctx == NULL) {retval=0; goto quit;} - - // set video output to YUV mode - webm_get_video_info(ctx, &info); - int status = video_setup_yuv_overlay(&info); - if(!status) {retval=0; goto quit;} + int source_id = -1; + s_movie_playback *playback = NULL; - // allocate s_screen for screenshot capture - yuv_init(2); - rgb_frame = allocscreen(info.width, info.height, PIXEL_16); - if(!rgb_frame) {retval=0; goto quit;} + movie_playback_stop_all(); + source_id = movie_source_load(path, MOVIE_LOADING_STREAM); + if(source_id < 0) { + return 0; + } + playback = movie_playback_play( + source_id, + MOVIE_CHANNEL_AUTO, + savedata.musicvol, + true + ); + if(!playback) { + retval = 0; + goto quit; + } + if(!movie_playback_set_interrupt(playback, !noskip)) { + retval = 0; + goto quit; + } - u64 start_time = timer_uticks(); - u64 next_frame_time = 0; - yuv_frame *frame = NULL; + clearscreen(vscreen); + video_copy_screen(vscreen); + while(playback->active) { + int interrupt_requested; + int present_frame; - while(1) - { inputrefresh(playrecstatus->status); - if(!noskip && (bothnewkeys & (FLAG_ESC | FLAG_ANYBUTTON))) - { + interrupt_requested = + (bothnewkeys & (FLAG_ESC | FLAG_ANYBUTTON)) != 0; + if(interrupt_requested && playback->interrupt) { retval = -1; - yuv_frame_destroy(frame); - break; } - else if(frame && !noscreenshot && (bothnewkeys & FLAG_SCREENSHOT)) - { - yuv_to_rgb(frame, rgb_frame); - screenshot(rgb_frame, NULL, 0); + movie_playback_update(interrupt_requested); + if(!playback->active) { + break; } - - u64 time_passed = timer_uticks() - start_time; - - if(next_frame_time <= time_passed) - { - // display the current frame - if(frame) - { - video_display_yuv_frame(); - yuv_frame_destroy(frame); + present_frame = playback->frame_dirty; + if(present_frame) { + clearscreen(vscreen); + if(!movie_playback_draw_to_screen(vscreen, playback->index)) { + retval = 0; + break; } - - // prepare the next frame for display - frame = webm_get_next_frame(ctx); - if(frame == NULL) break; - video_prepare_yuv_frame(frame); - next_frame_time = frame->timestamp / 1000; } - else usleep(next_frame_time - time_passed); + if(playback->current_frame && + !noscreenshot && + (bothnewkeys & FLAG_SCREENSHOT)) { + screenshot(vscreen, getpal, 1); + } + if(present_frame) { + video_copy_screen(vscreen); + } + usleep(1000); } quit: - if(ctx) webm_close(ctx); - if(rgb_frame) freescreen(&rgb_frame); - yuv_clear(); - video_set_mode(videomodes); + if(playback && playback->failed && retval > 0) { + retval = 0; + } + if(playback && playback->active) { + movie_playback_stop(playback); + } + while(source_id >= 0 && !movie_source_unload(source_id)) { + movie_playback_update(0); + usleep(1000); + } return retval; } #endif diff --git a/engine/openbor.h b/engine/openbor.h index 030563a35..ff629c737 100644 --- a/engine/openbor.h +++ b/engine/openbor.h @@ -54,6 +54,7 @@ #ifdef WEBM #include "yuv.h" #include "vidplay.h" +#include "movie_playback.h" #endif /* @@ -2975,7 +2976,7 @@ typedef struct Script *onblockp_script; //execute when blocked by platform. Script *onblocko_script; //execute when blocked by obstacle. Script *onblockz_script; //execute when blocked by Z. - Script *onblocka_script; //execute when "hit head". + Script *onblocky_script; //execute when "hit head". Script *onmovex_script; //execute when moving along X axis. Script *onmovez_script; //execute when moving along Z axis. Script *onmovea_script; //execute when moving along A axis. @@ -4458,7 +4459,7 @@ void execute_onblockw_script (entity *ent, s_terrain *wall, i void execute_onblockp_script (entity *ent, int plane, entity *platform); void execute_onblocko_script (entity *ent, int plane, entity *other); void execute_onblockz_script (entity *ent); -void execute_onblocka_script (entity *ent, entity *other); +void execute_onblocky_script (entity *ent, entity *other); void execute_onmovex_script (entity *ent); void execute_onmovez_script (entity *ent); void execute_onmovea_script (entity *ent); diff --git a/engine/openborscript.c b/engine/openborscript.c index 7a1163aee..655dd6ce7 100644 --- a/engine/openborscript.c +++ b/engine/openborscript.c @@ -423,6 +423,9 @@ void _freeheapnode(void *ptr) void Script_Global_Clear() { int i, size; +#ifdef WEBM + movie_playback_shutdown(); +#endif List_Clear(&theFunctionList); // dump all un-freed variants size = List_GetSize(&scriptheap); @@ -15865,7 +15868,7 @@ HRESULT openbor_hallfame(ScriptVariant **varlist , ScriptVariant **pretvar, int HRESULT openbor_playwebm(ScriptVariant **varlist , ScriptVariant **pretvar, int paramCount) { LONG temp = 0; //noskip - extern int playwebm(char * filename, int noskip); // avoid implicit declaration + extern int playwebm(const char *filename, int noskip); // avoid implicit declaration if(paramCount < 1) { diff --git a/engine/sdl/sblaster.c b/engine/sdl/sblaster.c index 52a47dc6c..484babf90 100644 --- a/engine/sdl/sblaster.c +++ b/engine/sdl/sblaster.c @@ -16,7 +16,6 @@ static SDL_AudioSpec cspec; static SDL_AudioDeviceID audio_dev; static int voicevol = 15; static int buffsize = 4096; -static unsigned int audio_lock_depth; static void callback(void *userdata, Uint8 *stream, int len) { @@ -80,55 +79,31 @@ void SB_playstop() return; } - started = 0; //SDL_CloseAudio(); SDL_CloseAudioDevice(audio_dev); + started = 0; audio_dev = 0; - audio_lock_depth = 0; } /* * Caskey, Damon V. -* 2026-08-04 +* 2026-08-17 * -* Exclude the SDL callback while the main thread -* publishes or tears down shared mixer state. Nested -* engine helpers share one device lock. +* Delegate every lock pair to SDL. Its audio device +* mutex owns recursive depth per thread while excluding +* the callback and all other threads. A process-wide +* depth counter cannot represent that ownership safely. */ void SB_lock_audio() { - if(!started) - { - return; - } - - if(audio_lock_depth++ == 0) - { - SB_lock_audio_direct(); - } + SB_lock_audio_direct(); } void SB_unlock_audio() { - if(!started || audio_lock_depth == 0) - { - return; - } - - if(--audio_lock_depth == 0) - { - SB_unlock_audio_direct(); - } + SB_unlock_audio_direct(); } -/* -* Caskey, Damon V. -* 2026-08-05 -* -* Lock the SDL device without participating in the -* main-thread nesting counter. Live PCM producer -* threads use this pair exactly once per publication. -*/ void SB_lock_audio_direct() { if(started) diff --git a/engine/source/gamelib/sound_stream.c b/engine/source/gamelib/sound_stream.c index f1dcbc3d0..1599102fc 100644 --- a/engine/source/gamelib/sound_stream.c +++ b/engine/source/gamelib/sound_stream.c @@ -214,6 +214,7 @@ int sound_stream_fill( buffer->frame_count = frames_to_read; buffer->terminal = !stream->looping && frames_to_read == available_frames; buffer->ready = 1; + stream->ready_buffer_count++; stream->next_source_frame += frames_to_read; stream->write_buffer = (stream->write_buffer + 1U) % SOUND_STREAM_BUFFER_COUNT; SB_unlock_audio(); @@ -273,6 +274,7 @@ int sound_stream_push_locked( buffer->frame_count = frame_count; buffer->terminal = terminal ? 1 : 0; buffer->ready = 1; + stream->ready_buffer_count++; stream->next_source_frame += frame_count; stream->source_frame_count = stream->next_source_frame; stream->producer_finished = terminal ? 1 : 0; diff --git a/engine/source/gamelib/sound_stream.h b/engine/source/gamelib/sound_stream.h index 23eaba938..a9aff6e40 100644 --- a/engine/source/gamelib/sound_stream.h +++ b/engine/source/gamelib/sound_stream.h @@ -15,16 +15,15 @@ /* * Caskey, Damon V. -* 2026-08-01 +* 2026-08-15 * -* Streamed channels use SOUND_STREAM_BUFFER_COUNT - * retained PCM buffers. At the largest supported input - * format, each SOUND_STREAM_BUFFER_SIZE buffer supplies - * approximately 57 milliseconds of data. Total reserve - * is approximately 57 milliseconds multiplied by - * SOUND_STREAM_BUFFER_COUNT. +* Streamed channels retain SOUND_STREAM_BUFFER_COUNT PCM buffers. +* Eight buffers absorb source I/O, decoder, and demux scheduling +* jitter without forcing the real-time mixer into silence. At the +* largest supported input format, each buffer supplies approximately +* 57 milliseconds of data. */ -#define SOUND_STREAM_BUFFER_COUNT 4U +#define SOUND_STREAM_BUFFER_COUNT 8U #define SOUND_STREAM_BUFFER_SIZE (16U * 1024U) #define SOUND_STREAM_HANDLE_CLOSED (-1) @@ -44,10 +43,13 @@ typedef struct s_sound_stream { uint64_t source_frame_count; uint64_t loop_start_frame; uint64_t fp_buffer_position; + uint64_t underrun_count; size_t block_align; + unsigned int ready_buffer_count; int handle; int looping; int producer_finished; + int underrun_active; } s_sound_stream; typedef bool (*sound_stream_read_callback)( diff --git a/engine/source/gamelib/soundmix.c b/engine/source/gamelib/soundmix.c index 538fd64da..974e33e40 100644 --- a/engine/source/gamelib/soundmix.c +++ b/engine/source/gamelib/soundmix.c @@ -76,7 +76,7 @@ Caution: move vorbis headers here otherwise the structs will This way we don't need to increase the volume too much in the audio files, preventing distortions and quality loss */ #define MAX_SAMPLE_VOLUME 100 // 64 for backw. compat -#define MAX_MUSIC_VOLUME 60 // 64 for backw. compat +#define MAX_MUSIC_VOLUME SOUND_VOLUME_DIVISOR_MUSIC // 64 for backw. compat // Hardware settings for SoundBlaster (change only if latency is too big) #define SB_BUFFER_SIZE 0x8000 #define SB_BUFFER_SIZE_MASK 0x7FFF @@ -1432,9 +1432,16 @@ static void sound_mix_stream_channel( for(;;) { stream_buffer = &stream->buffer[stream->read_buffer]; if(!stream_buffer->ready) { + if(!stream->producer_finished && !stream->underrun_active) { + stream->underrun_active = 1; + if(stream->underrun_count < UINT64_MAX) { + stream->underrun_count++; + } + } stream->fp_buffer_position = buffer_position_fixed; return; } + stream->underrun_active = 0; buffer_length_fixed = SOUND_SAMPLE_INT_TO_FIX(stream_buffer->frame_count); if(buffer_position_fixed < buffer_length_fixed) { @@ -1444,6 +1451,9 @@ static void sound_mix_stream_channel( buffer_position_fixed -= buffer_length_fixed; terminal = stream_buffer->terminal; stream_buffer->ready = 0; + if(stream->ready_buffer_count > 0) { + stream->ready_buffer_count--; + } stream->read_buffer = (stream->read_buffer + 1U) % SOUND_STREAM_BUFFER_COUNT; if(terminal) { @@ -1864,6 +1874,41 @@ static sound_sample_fixed_t sound_sample_period_calculate(unsigned int speed, in return (sound_sample_fixed_t)sample_period; } +/* +* Caskey, Damon V. +* 2026-08-13 +* +* Calculate a producer stream period directly from its decimal +* playback rate. Retaining the caller's full rate until the final +* fixed-point conversion prevents movie and audio clocks from +* diverging through integer percentage rounding. +*/ +static sound_sample_fixed_t sound_sample_period_calculate_decimal( + double speed, + int sample_frequency +) +{ + long double sample_period; + const long double period_max = (long double)UINT64_MAX; + + if(!(speed > 0.0) || sample_frequency <= 0 || playfrequency <= 0) { + return 0; + } + + sample_period = + (long double)SOUND_SAMPLE_FIXED_ONE * + (long double)speed * + (long double)sample_frequency / + (long double)playfrequency; + if(sample_period >= period_max - 0.5L) { + return UINT64_MAX; + } + if(sample_period < 1.0L) { + return 1; + } + return (sound_sample_fixed_t)(sample_period + 0.5L); +} + #define SOUND_STREAM_UPDATE_BYTE_BUDGET (256U * 1024U) static unsigned int sound_stream_update_cursor; @@ -3124,6 +3169,102 @@ bool sound_set_channel_period(int channel, uint64_t period) { return true; } +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Change producer stream speed only while its play ID still owns +* the requested generic sound channel. Decimal rates retain their +* precision through the final fixed-point period conversion. +*/ +bool sound_set_channel_speed_owned(int channel, int play_id, double speed) { + channelstruct *record; + sound_sample_fixed_t period; + + if(play_id < 0 || !(speed > 0.0)) { + return false; + } + + SB_lock_audio(); + record = sound_channel_pool_get(&sound_channel_pool, channel); + if(!record || + record->playid != play_id || + record->stream_source != SOUND_CHANNEL_STREAM_SOURCE_PUSH || + record->frequency <= 0) { + SB_unlock_audio(); + return false; + } + + period = sound_sample_period_calculate_decimal(speed, record->frequency); + if(!period) { + SB_unlock_audio(); + return false; + } + record->fp_period = period; + SB_unlock_audio(); + return true; +} + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Pause a producer stream only while its play ID still +* identifies the live channel record. +*/ +bool sound_pause_channel_owned(int channel, int play_id, int toggle) { + channelstruct *record; + + SB_lock_audio(); + record = sound_channel_pool_get(&sound_channel_pool, channel); + if(play_id < 0 || + !record || + record->playid != play_id || + record->stream_source != SOUND_CHANNEL_STREAM_SOURCE_PUSH) { + SB_unlock_audio(); + return false; + } + + sound_channel_pool_pause(&sound_channel_pool, channel, toggle); + SB_unlock_audio(); + return true; +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Snapshot the source frame consumed by an active push stream only while +* the supplied play ID still owns its channel. Reading under the SDL audio +* lock turns the callback's fixed-point cursor into a stable playback clock +* without exposing mutable mixer state to decoder or presentation threads. +*/ +bool sound_get_channel_pcm_stream_playback_frame_owned( + int channel, + int play_id, + uint64_t *playback_frame +) { + channelstruct *record; + + if(play_id < 0 || !playback_frame) { + return false; + } + + SB_lock_audio(); + record = sound_channel_pool_get(&sound_channel_pool, channel); + if(!record || + !sound_channel_pool_is_active(&sound_channel_pool, channel) || + record->playid != play_id || + record->stream_source != SOUND_CHANNEL_STREAM_SOURCE_PUSH) { + SB_unlock_audio(); + return false; + } + + *playback_frame = SOUND_SAMPLE_FIX_TO_INT(record->fp_samplepos); + SB_unlock_audio(); + return true; +} + /* * Caskey, Damon V. * 2026-08-04 @@ -3440,11 +3581,75 @@ int sound_open_channel_pcm_stream( /* * Caskey, Damon V. -* 2026-08-05 +* 2026-08-15 * -* Copy one live PCM block into a generic channel's -* rotating queue. Direct SDL locking is used because -* producers such as WebM publish from decoder threads. +* Estimate when a full producer ring will release its next slot. The +* decoder wakes shortly before that boundary, with a 50 millisecond cap +* so pause, stop, and ownership changes remain responsive. +*/ +static unsigned int sound_stream_producer_retry_delay_microseconds( + const channelstruct *record +) { + const s_sound_stream *stream; + const s_sound_stream_buffer *read_buffer; + const uint64_t delay_maximum = UINT64_C(50000); + const uint64_t delay_minimum = UINT64_C(1000); + const uint64_t delay_margin = UINT64_C(1000); + sound_sample_fixed_t buffer_length; + sound_sample_fixed_t remaining; + uint64_t output_frames; + uint64_t maximum_delay_frames; + uint64_t delay; + + if(!record || record->fp_period == 0 || playfrequency <= 0) { + return (unsigned int)delay_minimum; + } + + stream = &record->stream; + read_buffer = &stream->buffer[stream->read_buffer]; + if(!read_buffer->ready) { + return (unsigned int)delay_minimum; + } + + buffer_length = SOUND_SAMPLE_INT_TO_FIX(read_buffer->frame_count); + if(stream->fp_buffer_position >= buffer_length) { + return (unsigned int)delay_minimum; + } + + remaining = buffer_length - stream->fp_buffer_position; + output_frames = remaining / record->fp_period; + if(remaining % record->fp_period) { + output_frames++; + } + + maximum_delay_frames = + ((uint64_t)playfrequency * delay_maximum + UINT64_C(999999)) / + UINT64_C(1000000); + if(output_frames >= maximum_delay_frames) { + return (unsigned int)delay_maximum; + } + + delay = output_frames * UINT64_C(1000000) / + (uint64_t)playfrequency; + delay = delay > delay_margin + ? delay - delay_margin + : delay_minimum; + if(delay < delay_minimum) { + delay = delay_minimum; + } + return (unsigned int)delay; +} + +/* +* Caskey, Damon V. +* 2026-08-15 +* +* Copy one live PCM block into a generic channel's rotating queue. +* Direct SDL locking is used because producers such as WebM publish +* from decoder threads. Optional telemetry is captured while the same +* lock is held so producers do not need a second status query. Full +* queues return a bounded retry delay instead of encouraging millisecond +* polling against the SDL device lock. * * Returns 1 when queued, 0 while all buffers are ready, * and -1 after replacement or malformed input. @@ -3454,11 +3659,20 @@ int sound_queue_channel_pcm_stream( int play_id, const void *pcm, uint64_t frame_count, - int terminal + int terminal, + uint64_t *underrun_count, + unsigned int *retry_delay_microseconds ) { channelstruct *record; int result; + if(underrun_count) { + *underrun_count = 0; + } + if(retry_delay_microseconds) { + *retry_delay_microseconds = 1000U; + } + SB_lock_audio_direct(); record = sound_channel_pool_get(&sound_channel_pool, channel); if(play_id < 0 || @@ -3476,10 +3690,54 @@ int sound_queue_channel_pcm_stream( frame_count, terminal ); + if(underrun_count) { + *underrun_count = record->stream.underrun_count; + } + if(result == 0 && retry_delay_microseconds) { + *retry_delay_microseconds = + sound_stream_producer_retry_delay_microseconds(record); + } SB_unlock_audio_direct(); return result; } +/* +* Caskey, Damon V. +* 2026-08-15 +* +* Read live-producer queue state only while the supplied play ID still +* owns the channel. The SDL device lock makes the snapshot coherent with +* both producer publication and real-time mixer consumption. +*/ +bool sound_get_channel_pcm_stream_status_owned( + int channel, + int play_id, + s_sound_pcm_stream_status *status +) { + channelstruct *record; + + if(play_id < 0 || !status) { + return false; + } + + SB_lock_audio(); + record = sound_channel_pool_get(&sound_channel_pool, channel); + if(!record || + !sound_channel_pool_is_active(&sound_channel_pool, channel) || + record->playid != play_id || + record->stream_source != SOUND_CHANNEL_STREAM_SOURCE_PUSH) { + SB_unlock_audio(); + return false; + } + + status->underrun_count = record->stream.underrun_count; + status->ready_buffer_count = record->stream.ready_buffer_count; + status->producer_finished = record->stream.producer_finished; + status->underrun_active = record->stream.underrun_active; + SB_unlock_audio(); + return true; +} + /* * Caskey, Damon V. * 2026-08-05 diff --git a/engine/source/gamelib/soundmix.h b/engine/source/gamelib/soundmix.h index 72929c4b1..5c296a9e0 100644 --- a/engine/source/gamelib/soundmix.h +++ b/engine/source/gamelib/soundmix.h @@ -26,6 +26,7 @@ #define SOUND_OUTPUT_BITS_DEFAULT 24 #define SOUND_OUTPUT_FREQUENCY_DEFAULT 48000 #define SOUND_CHANNEL_MUSIC_DEFAULT 0 +#define SOUND_VOLUME_DIVISOR_MUSIC 60 #define SOUND_PLAY_CHANCE_MAX 100U #define CHANNEL_PLAYING 1 #define CHANNEL_LOOPING 2 @@ -110,6 +111,21 @@ typedef struct bool stream; } s_soundcache; +/* +* Caskey, Damon V. +* 2026-08-15 +* +* Snapshot live-producer buffering without exposing mutable mixer +* internals. WebM uses this state to complete preroll before enabling +* output. Ongoing underrun telemetry rides the normal publication call. +*/ +typedef struct s_sound_pcm_stream_status { + uint64_t underrun_count; + unsigned int ready_buffer_count; + int producer_finished; + int underrun_active; +} s_sound_pcm_stream_status; + typedef struct s_audio_global { List samplelist; @@ -162,6 +178,13 @@ size_t sound_group_set_position( void sound_volume_sample(int channel, int lvolume, int rvolume); bool sound_set_channel_loop_offset(int channel, uint64_t loop_start_frame); bool sound_set_channel_period(int channel, uint64_t period); +bool sound_set_channel_speed_owned(int channel, int play_id, double speed); +bool sound_pause_channel_owned(int channel, int play_id, int toggle); +bool sound_get_channel_pcm_stream_playback_frame_owned( + int channel, + int play_id, + uint64_t *playback_frame +); bool sound_set_channel_priority(int channel, unsigned int priority); bool sound_set_channel_position(int channel, uint64_t sample_position); bool sound_set_channel_volume(int channel, unsigned int spatial_channel, int volume); @@ -169,7 +192,20 @@ bool sound_set_channel_volume_divisor(int channel, int volume_divisor); int sound_getpos_sample(int channel); int sound_open_music(char *filename, char *packname, int volume, int loop, u32 music_offset); int sound_open_channel_pcm_stream(int channel, int frequency, int channels, int volume); -int sound_queue_channel_pcm_stream(int channel, int play_id, const void *pcm, uint64_t frame_count, int terminal); +int sound_queue_channel_pcm_stream( + int channel, + int play_id, + const void *pcm, + uint64_t frame_count, + int terminal, + uint64_t *underrun_count, + unsigned int *retry_delay_microseconds +); +bool sound_get_channel_pcm_stream_status_owned( + int channel, + int play_id, + s_sound_pcm_stream_status *status +); void sound_close_channel_pcm_stream(int channel, int play_id); void sound_close_music(); void sound_update_music(); diff --git a/engine/source/gamelib/types.h b/engine/source/gamelib/types.h index 6bdf14dbd..1fda72529 100644 --- a/engine/source/gamelib/types.h +++ b/engine/source/gamelib/types.h @@ -115,7 +115,8 @@ typedef enum e_object_type { OBJECT_TYPE_FLASH, OBJECT_TYPE_GLOBAL_CONFIG, OBJECT_TYPE_MODEL, - OBJECT_TYPE_SOUND + OBJECT_TYPE_SOUND, + OBJECT_TYPE_MOVIE_PLAYBACK } e_object_type; typedef struct @@ -352,4 +353,3 @@ typedef struct #endif #endif - diff --git a/engine/source/openborscript/commands.c b/engine/source/openborscript/commands.c index 36478f59d..e1b7cc843 100644 --- a/engine/source/openborscript/commands.c +++ b/engine/source/openborscript/commands.c @@ -501,7 +501,7 @@ List *createModelCommandList(void) LIST_ADD(CMD_MODEL_OFFSET, "offset"); LIST_ADD(CMD_MODEL_ON_BIND_UPDATE_OTHER_TO_SELF_SCRIPT, "on_bind_update_other_to_self_script"); LIST_ADD(CMD_MODEL_ON_BIND_UPDATE_SELF_TO_OTHER_SCRIPT, "on_bind_update_self_to_other_script"); - LIST_ADD(CMD_MODEL_ONBLOCKASCRIPT, "onblockascript"); + LIST_ADD(CMD_MODEL_ONBLOCKYSCRIPT, "onblockyscript"); LIST_ADD(CMD_MODEL_ONBLOCKOSCRIPT, "onblockoscript"); LIST_ADD(CMD_MODEL_ONBLOCKPSCRIPT, "onblockpscript"); LIST_ADD(CMD_MODEL_ONBLOCKSSCRIPT, "onblocksscript"); diff --git a/engine/source/openborscript/commands.h b/engine/source/openborscript/commands.h index db92dfed6..b8c9d86bd 100644 --- a/engine/source/openborscript/commands.h +++ b/engine/source/openborscript/commands.h @@ -608,7 +608,7 @@ typedef enum modelCommand CMD_MODEL_OFFSET, CMD_MODEL_ON_BIND_UPDATE_OTHER_TO_SELF_SCRIPT, CMD_MODEL_ON_BIND_UPDATE_SELF_TO_OTHER_SCRIPT, - CMD_MODEL_ONBLOCKASCRIPT, + CMD_MODEL_ONBLOCKYSCRIPT, CMD_MODEL_ONBLOCKOSCRIPT, CMD_MODEL_ONBLOCKPSCRIPT, CMD_MODEL_ONBLOCKSSCRIPT, diff --git a/engine/source/openborscript/config.h b/engine/source/openborscript/config.h index c4535e3f2..18eb6c541 100644 --- a/engine/source/openborscript/config.h +++ b/engine/source/openborscript/config.h @@ -29,6 +29,7 @@ #include "recursive_effect.h" #include "spawn_hud.h" #include "status_dial.h" +#include "movie.h" //osc diff --git a/engine/source/openborscript/constants.c b/engine/source/openborscript/constants.c index 34a00d87c..db610f02c 100644 --- a/engine/source/openborscript/constants.c +++ b/engine/source/openborscript/constants.c @@ -1142,6 +1142,33 @@ bool mapstrings_transconst(ScriptVariant **varlist, int paramCount) ICMPCONST(SOUND_PROPERTY_OWNER_ID) ICMPCONST(SOUND_PROPERTY_END) + /* Movie playback object property access. */ + ICMPCONST(MOVIE_PROPERTY_ACTIVE) + ICMPCONST(MOVIE_PROPERTY_BLACK_FILTER) + ICMPCONST(MOVIE_PROPERTY_CHANNEL) + ICMPCONST(MOVIE_PROPERTY_DURATION) + ICMPCONST(MOVIE_PROPERTY_HEIGHT) + ICMPCONST(MOVIE_PROPERTY_INTERRUPT) + ICMPCONST(MOVIE_PROPERTY_OFFSET_X) + ICMPCONST(MOVIE_PROPERTY_OFFSET_Y) + ICMPCONST(MOVIE_PROPERTY_PAUSED) + ICMPCONST(MOVIE_PROPERTY_POSITION) + ICMPCONST(MOVIE_PROPERTY_REPEAT) + ICMPCONST(MOVIE_PROPERTY_SOUND_CHANNEL) + ICMPCONST(MOVIE_PROPERTY_SOURCE) + ICMPCONST(MOVIE_PROPERTY_SPEED) + ICMPCONST(MOVIE_PROPERTY_WIDTH) + ICMPCONST(MOVIE_PROPERTY_END) +#ifdef WEBM + ICMPCONST(MOVIE_CHANNEL_COUNT) + ICMPCONST(MOVIE_CHANNEL_AUTO) + ICMPCONSTU64(MOVIE_SIZE_NATIVE) + ICMPCONST(MOVIE_LOADING_STREAM) + ICMPCONST(MOVIE_LOADING_CACHE) + ICMPCONST(MOVIE_LOADING_AUTO) + ICMPCONST(MOVIE_LOADING_END) +#endif + /* Key flags. */ ICMPCONST(FLAG_ANYBUTTON) ICMPCONST(FLAG_ATTACK) diff --git a/engine/source/openborscript/index.c b/engine/source/openborscript/index.c index c3fe37039..205a60c78 100644 --- a/engine/source/openborscript/index.c +++ b/engine/source/openborscript/index.c @@ -925,6 +925,50 @@ const char *Script_GetFunctionName(void *functionRef) { return "playwebm"; } + else if (functionRef == ((void *)openbor_movie_load)) + { + return "movie_load"; + } + else if (functionRef == ((void *)openbor_movie_unload)) + { + return "movie_unload"; + } + else if (functionRef == ((void *)openbor_movie_play)) + { + return "movie_play"; + } + else if (functionRef == ((void *)openbor_movie_draw_to_screen)) + { + return "movie_draw_to_screen"; + } + else if (functionRef == ((void *)openbor_movie_set_sound_channel)) + { + return "movie_set_sound_channel"; + } + else if (functionRef == ((void *)openbor_movie_stop)) + { + return "movie_stop"; + } + else if (functionRef == ((void *)openbor_movie_get_channel_object)) + { + return "movie_get_channel_object"; + } + else if (functionRef == ((void *)openbor_movie_get_channel_index)) + { + return "movie_get_channel_index"; + } + else if (functionRef == ((void *)openbor_movie_get_channel_mask)) + { + return "movie_get_channel_mask"; + } + else if (functionRef == ((void *)openbor_movie_get_property)) + { + return "movie_get_property"; + } + else if (functionRef == ((void *)openbor_movie_set_property)) + { + return "movie_set_property"; + } else if (functionRef == ((void *)openbor_playgif)) { return "playgif"; @@ -1755,6 +1799,28 @@ void Script_LoadSystemFunctions() (void *)openbor_menu_options, "options"); List_InsertAfter(&theFunctionList, (void *)openbor_playwebm, "playwebm"); + List_InsertAfter(&theFunctionList, + (void *)openbor_movie_load, "movie_load"); + List_InsertAfter(&theFunctionList, + (void *)openbor_movie_unload, "movie_unload"); + List_InsertAfter(&theFunctionList, + (void *)openbor_movie_play, "movie_play"); + List_InsertAfter(&theFunctionList, + (void *)openbor_movie_draw_to_screen, "movie_draw_to_screen"); + List_InsertAfter(&theFunctionList, + (void *)openbor_movie_set_sound_channel, "movie_set_sound_channel"); + List_InsertAfter(&theFunctionList, + (void *)openbor_movie_stop, "movie_stop"); + List_InsertAfter(&theFunctionList, + (void *)openbor_movie_get_channel_object, "movie_get_channel_object"); + List_InsertAfter(&theFunctionList, + (void *)openbor_movie_get_channel_index, "movie_get_channel_index"); + List_InsertAfter(&theFunctionList, + (void *)openbor_movie_get_channel_mask, "movie_get_channel_mask"); + List_InsertAfter(&theFunctionList, + (void *)openbor_movie_get_property, "movie_get_property"); + List_InsertAfter(&theFunctionList, + (void *)openbor_movie_set_property, "movie_set_property"); List_InsertAfter(&theFunctionList, (void *)openbor_playgif, "playgif"); List_InsertAfter(&theFunctionList, diff --git a/engine/source/openborscript/movie.c b/engine/source/openborscript/movie.c new file mode 100644 index 000000000..dc4140bd8 --- /dev/null +++ b/engine/source/openborscript/movie.c @@ -0,0 +1,576 @@ +/* + * OpenBOR - http://www.chronocrash.com + * ----------------------------------------------------------------------- + * All rights reserved. See LICENSE in OpenBOR root for license details. + * + * Copyright (c) OpenBOR Team + */ + +#include "scriptcommon.h" + +extern s_savedata savedata; + +#ifdef WEBM + +typedef struct s_movie_property_info { + e_movie_properties property; + e_property_access_config_flags config_flags; + size_t offset; + const char *id_string; + VARTYPE type; +} s_movie_property_info; + +#define PROPERTY_MEMBER_OFFSET(type, member) ((size_t)&(((type*)0)->member)) + +static const s_movie_property_info movie_properties[] = { + { MOVIE_PROPERTY_ACTIVE, PROPERTY_ACCESS_CONFIG_READ, + PROPERTY_MEMBER_OFFSET(s_movie_playback, active), + "MOVIE_PROPERTY_ACTIVE", VT_INTEGER }, + { MOVIE_PROPERTY_BLACK_FILTER, PROPERTY_ACCESS_CONFIG_MACRO_DEFAULT, + PROPERTY_MEMBER_OFFSET(s_movie_playback, black_filter), + "MOVIE_PROPERTY_BLACK_FILTER", VT_INTEGER }, + { MOVIE_PROPERTY_CHANNEL, PROPERTY_ACCESS_CONFIG_READ, + PROPERTY_MEMBER_OFFSET(s_movie_playback, index), + "MOVIE_PROPERTY_CHANNEL", VT_INTEGER }, + { MOVIE_PROPERTY_DURATION, PROPERTY_ACCESS_CONFIG_READ, + PROPERTY_MEMBER_OFFSET(s_movie_playback, duration), + "MOVIE_PROPERTY_DURATION", VT_UINTEGER64 }, + { MOVIE_PROPERTY_HEIGHT, PROPERTY_ACCESS_CONFIG_MACRO_DEFAULT, + PROPERTY_MEMBER_OFFSET(s_movie_playback, height), + "MOVIE_PROPERTY_HEIGHT", VT_UINTEGER64 }, + { MOVIE_PROPERTY_INTERRUPT, PROPERTY_ACCESS_CONFIG_MACRO_DEFAULT, + PROPERTY_MEMBER_OFFSET(s_movie_playback, interrupt), + "MOVIE_PROPERTY_INTERRUPT", VT_INTEGER }, + { MOVIE_PROPERTY_OFFSET_X, PROPERTY_ACCESS_CONFIG_MACRO_DEFAULT, + PROPERTY_MEMBER_OFFSET(s_movie_playback, offset_x), + "MOVIE_PROPERTY_OFFSET_X", VT_INTEGER }, + { MOVIE_PROPERTY_OFFSET_Y, PROPERTY_ACCESS_CONFIG_MACRO_DEFAULT, + PROPERTY_MEMBER_OFFSET(s_movie_playback, offset_y), + "MOVIE_PROPERTY_OFFSET_Y", VT_INTEGER }, + { MOVIE_PROPERTY_PAUSED, PROPERTY_ACCESS_CONFIG_MACRO_DEFAULT, + PROPERTY_MEMBER_OFFSET(s_movie_playback, paused), + "MOVIE_PROPERTY_PAUSED", VT_INTEGER }, + { MOVIE_PROPERTY_POSITION, PROPERTY_ACCESS_CONFIG_MACRO_DEFAULT, + PROPERTY_MEMBER_OFFSET(s_movie_playback, position), + "MOVIE_PROPERTY_POSITION", VT_UINTEGER64 }, + { MOVIE_PROPERTY_REPEAT, PROPERTY_ACCESS_CONFIG_MACRO_DEFAULT, + PROPERTY_MEMBER_OFFSET(s_movie_playback, repeat), + "MOVIE_PROPERTY_REPEAT", VT_INTEGER }, + { MOVIE_PROPERTY_SOUND_CHANNEL, PROPERTY_ACCESS_CONFIG_READ, + PROPERTY_MEMBER_OFFSET(s_movie_playback, sound_channel), + "MOVIE_PROPERTY_SOUND_CHANNEL", VT_INTEGER }, + { MOVIE_PROPERTY_SOURCE, PROPERTY_ACCESS_CONFIG_READ, + PROPERTY_MEMBER_OFFSET(s_movie_playback, source_id), + "MOVIE_PROPERTY_SOURCE", VT_INTEGER }, + { MOVIE_PROPERTY_SPEED, PROPERTY_ACCESS_CONFIG_MACRO_DEFAULT, + PROPERTY_MEMBER_OFFSET(s_movie_playback, speed), + "MOVIE_PROPERTY_SPEED", VT_DECIMAL }, + { MOVIE_PROPERTY_WIDTH, PROPERTY_ACCESS_CONFIG_MACRO_DEFAULT, + PROPERTY_MEMBER_OFFSET(s_movie_playback, width), + "MOVIE_PROPERTY_WIDTH", VT_UINTEGER64 }, + { MOVIE_PROPERTY_END, PROPERTY_ACCESS_CONFIG_NONE, 0, + "Movie", VT_EMPTY } +}; + +#undef PROPERTY_MEMBER_OFFSET + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Build metadata for the stable movie playback object API. +*/ +static const s_property_access_map movie_get_property_map( + const void *acting_object_param, + const unsigned int property_index_param +) +{ + s_property_access_map property_map = { 0 }; + const s_movie_playback *acting_object = acting_object_param; + const s_movie_property_info *info = NULL; + size_t property_cursor; + + for(property_cursor = 0; + property_cursor < sizeof(movie_properties) / sizeof(movie_properties[0]); + property_cursor++) { + if(movie_properties[property_cursor].property == property_index_param) { + info = &movie_properties[property_cursor]; + break; + } + } + + if(info) { + property_map.config_flags = info->config_flags; + property_map.field = + (const void*)((const char*)acting_object + info->offset); + property_map.id_string = info->id_string; + property_map.type = info->type; + } else { + property_map.config_flags = PROPERTY_ACCESS_CONFIG_NONE; + property_map.id_string = "Movie"; + property_map.type = VT_EMPTY; + } + return property_map; +} + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Load one reusable streamed, cached, or automatic movie source +* and return its stable integer ID. Omitted mode remains stream. +*/ +HRESULT openbor_movie_load( + ScriptVariant **varlist, + ScriptVariant **pretvar, + int paramCount +) +{ + const char *self_name = "movie_load(string path, int loading)"; + LONG loading = MOVIE_LOADING_STREAM; + int source_id; + + ScriptVariant_Clear(*pretvar); + if(paramCount < 1 || + varlist[0]->vt != VT_STR || + (paramCount > 1 && + FAILED(ScriptVariant_IntegerValue(varlist[1], &loading))) || + loading < 0 || loading >= MOVIE_LOADING_END) { + printf("\nScript error: %s. Invalid path or loading mode.\n", + self_name); + *pretvar = NULL; + return E_FAIL; + } + + source_id = movie_source_load( + StrCache_Get(varlist[0]->strVal), + (e_movie_loading_mode)loading + ); + if(source_id < 0) { + printf("\nScript error: %s. Source could not be loaded.\n", + self_name); + *pretvar = NULL; + return E_FAIL; + } + + ScriptVariant_ChangeType(*pretvar, VT_INTEGER); + (*pretvar)->lVal = (LONG)source_id; + return S_OK; +} + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Release one source ID when no playback still references it. +*/ +HRESULT openbor_movie_unload( + ScriptVariant **varlist, + ScriptVariant **pretvar, + int paramCount +) +{ + LONG source_id; + + *pretvar = NULL; + if(paramCount < 1 || + FAILED(ScriptVariant_IntegerValue(varlist[0], &source_id)) || + !movie_source_unload((int)source_id)) { + printf("\nScript error: movie_unload(int source). Source is invalid or still in use.\n"); + return E_FAIL; + } + return S_OK; +} + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Play a reusable source on the first free movie channel or +* replace the explicitly selected channel. +*/ +HRESULT openbor_movie_play( + ScriptVariant **varlist, + ScriptVariant **pretvar, + int paramCount +) +{ + const char *self_name = "movie_play(int source, int channel)"; + LONG source_id; + LONG channel = MOVIE_CHANNEL_AUTO; + s_movie_playback *playback; + + ScriptVariant_Clear(*pretvar); + if(paramCount < 1 || + FAILED(ScriptVariant_IntegerValue(varlist[0], &source_id))) { + goto error; + } + if(paramCount > 1 && + varlist[1]->vt != VT_EMPTY && + !(varlist[1]->vt == VT_PTR && !varlist[1]->ptrVal) && + FAILED(ScriptVariant_IntegerValue(varlist[1], &channel))) { + goto error; + } + if(channel != MOVIE_CHANNEL_AUTO && + (channel < 0 || (unsigned int)channel >= MOVIE_CHANNEL_COUNT)) { + goto error; + } + + playback = movie_playback_play( + (int)source_id, + (int)channel, + savedata.musicvol, + false + ); + if(!playback) { + printf("\nScript error: %s. Source is invalid or no channel could be acquired.\n", + self_name); + *pretvar = NULL; + return E_FAIL; + } + + ScriptVariant_ChangeType(*pretvar, VT_PTR); + (*pretvar)->ptrVal = playback; + return S_OK; + +error: + printf("\nScript error: %s. Source or channel is invalid.\n", + self_name); + *pretvar = NULL; + return E_FAIL; +} + +/* +* Caskey, Damon V. +* 2026-08-13 +* +* Draw the retained frame from one movie channel to a creator-owned +* 32-bit screen. Call position determines composition order. +*/ +HRESULT openbor_movie_draw_to_screen( + ScriptVariant **varlist, + ScriptVariant **pretvar, + int paramCount +) +{ + s_screen *screen; + LONG channel; + + *pretvar = NULL; + if(paramCount < 2 || + varlist[0]->vt != VT_PTR || + !varlist[0]->ptrVal || + FAILED(ScriptVariant_IntegerValue(varlist[1], &channel))) { + goto error; + } + screen = varlist[0]->ptrVal; + if(channel < 0 || + (unsigned int)channel >= MOVIE_CHANNEL_COUNT || + !movie_playback_draw_to_screen(screen, (int)channel)) { + goto error; + } + return S_OK; + +error: + printf("\nScript error: movie_draw_to_screen(void screen, int channel). Screen or movie channel is invalid.\n"); + return E_FAIL; +} + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Reopen a playback at its current position on a selected +* sound channel without changing its movie channel. +*/ +HRESULT openbor_movie_set_sound_channel( + ScriptVariant **varlist, + ScriptVariant **pretvar, + int paramCount +) +{ + LONG channel; + + *pretvar = NULL; + if(paramCount < 2 || + varlist[0]->vt != VT_PTR || + !varlist[0]->ptrVal || + FAILED(ScriptVariant_IntegerValue(varlist[1], &channel)) || + !movie_playback_set_sound_channel( + varlist[0]->ptrVal, + (int)channel, + savedata.musicvol + )) { + printf("\nScript error: movie_set_sound_channel(void playback, int channel). Playback or channel is invalid.\n"); + return E_FAIL; + } + return S_OK; +} + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Stop and recycle one stable movie playback channel. +*/ +HRESULT openbor_movie_stop( + ScriptVariant **varlist, + ScriptVariant **pretvar, + int paramCount +) +{ + *pretvar = NULL; + if(paramCount < 1 || + varlist[0]->vt != VT_PTR || + !varlist[0]->ptrVal || + movie_playback_get_index(varlist[0]->ptrVal) < 0) { + printf("\nScript error: movie_stop(void playback). Playback is invalid.\n"); + return E_FAIL; + } + movie_playback_stop(varlist[0]->ptrVal); + return S_OK; +} + +HRESULT openbor_movie_get_channel_object( + ScriptVariant **varlist, + ScriptVariant **pretvar, + int paramCount +) +{ + LONG channel; + s_movie_playback *playback; + + ScriptVariant_Clear(*pretvar); + if(paramCount < 1 || + FAILED(ScriptVariant_IntegerValue(varlist[0], &channel)) || + channel < 0 || + (unsigned int)channel >= MOVIE_CHANNEL_COUNT || + !movie_playback_init()) { + printf("\nScript error: movie_get_channel_object(int channel). Channel must be from 0 through %u.\n", + MOVIE_CHANNEL_COUNT - 1U); + *pretvar = NULL; + return E_FAIL; + } + + playback = movie_playback_get((int)channel); + ScriptVariant_ChangeType(*pretvar, VT_PTR); + (*pretvar)->ptrVal = playback; + return S_OK; +} + +HRESULT openbor_movie_get_channel_index( + ScriptVariant **varlist, + ScriptVariant **pretvar, + int paramCount +) +{ + int channel; + + ScriptVariant_Clear(*pretvar); + if(paramCount < 1 || + varlist[0]->vt != VT_PTR || + !varlist[0]->ptrVal || + (channel = movie_playback_get_index(varlist[0]->ptrVal)) < 0) { + printf("\nScript error: movie_get_channel_index(void playback). Playback is invalid.\n"); + *pretvar = NULL; + return E_FAIL; + } + + ScriptVariant_ChangeType(*pretvar, VT_INTEGER); + (*pretvar)->lVal = (LONG)channel; + return S_OK; +} + +HRESULT openbor_movie_get_channel_mask( + ScriptVariant **varlist, + ScriptVariant **pretvar, + int paramCount +) +{ + (void)varlist; + (void)paramCount; + ScriptVariant_ChangeType(*pretvar, VT_UINTEGER64); + (*pretvar)->ullVal = movie_playback_get_active_mask(); + return S_OK; +} + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Read one synchronized movie playback property or dump its +* complete public property map for diagnostics. +*/ +HRESULT openbor_movie_get_property( + ScriptVariant **varlist, + ScriptVariant **pretvar, + int paramCount +) +{ + s_movie_playback snapshot; + int property_index; + s_property_access_map property_map; + + ScriptVariant_Clear(*pretvar); + if(paramCount < 2 || + varlist[0]->vt != VT_PTR || + !varlist[0]->ptrVal || + varlist[1]->vt != VT_INTEGER || + !movie_playback_get_snapshot(varlist[0]->ptrVal, &snapshot)) { + goto error; + } + + property_index = (int)varlist[1]->lVal; + if(property_index >= 0 && property_index < MOVIE_PROPERTY_END) { + property_map = movie_get_property_map( + &snapshot, + (unsigned int)property_index + ); + return property_access_get_member(&property_map, *pretvar); + } + if(property_index == PROPERTY_ACCESS_DUMP) { + property_access_dump_members( + movie_get_property_map, + MOVIE_PROPERTY_END, + &snapshot + ); + return S_OK; + } + +error: + printf("\nScript error: movie_get_property(void playback, int property). Playback or property is invalid.\n"); + *pretvar = NULL; + return E_FAIL; +} + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Route writable properties through lifecycle-aware helpers so +* clocks, decoding, scaling, and composition remain synchronized. +*/ +HRESULT openbor_movie_set_property( + ScriptVariant **varlist, + ScriptVariant **pretvar, + int paramCount +) +{ + s_movie_playback *playback; + int property_index; + LONG integer_value; + DOUBLE decimal_value; + uint64_t unsigned_value; + bool result = false; + + *pretvar = NULL; + if(paramCount < 3 || + varlist[0]->vt != VT_PTR || + !varlist[0]->ptrVal || + varlist[1]->vt != VT_INTEGER || + movie_playback_get_index(varlist[0]->ptrVal) < 0) { + goto error_object; + } + + playback = varlist[0]->ptrVal; + property_index = (int)varlist[1]->lVal; + switch((e_movie_properties)property_index) { + case MOVIE_PROPERTY_BLACK_FILTER: + result = SUCCEEDED(ScriptVariant_IntegerValue(varlist[2], &integer_value)) && + movie_playback_set_black_filter(playback, integer_value != 0); + break; + case MOVIE_PROPERTY_HEIGHT: + result = SUCCEEDED(ScriptVariant_Unsigned64Value(varlist[2], &unsigned_value)) && + movie_playback_set_height(playback, unsigned_value); + break; + case MOVIE_PROPERTY_INTERRUPT: + result = SUCCEEDED(ScriptVariant_IntegerValue(varlist[2], &integer_value)) && + movie_playback_set_interrupt(playback, integer_value != 0); + break; + case MOVIE_PROPERTY_OFFSET_X: + result = SUCCEEDED(ScriptVariant_IntegerValue(varlist[2], &integer_value)) && + movie_playback_set_offset_x(playback, (int)integer_value); + break; + case MOVIE_PROPERTY_OFFSET_Y: + result = SUCCEEDED(ScriptVariant_IntegerValue(varlist[2], &integer_value)) && + movie_playback_set_offset_y(playback, (int)integer_value); + break; + case MOVIE_PROPERTY_PAUSED: + result = SUCCEEDED(ScriptVariant_IntegerValue(varlist[2], &integer_value)) && + movie_playback_set_paused(playback, integer_value != 0); + break; + case MOVIE_PROPERTY_POSITION: + result = SUCCEEDED(ScriptVariant_Unsigned64Value(varlist[2], &unsigned_value)) && + movie_playback_set_position(playback, unsigned_value, savedata.musicvol); + break; + case MOVIE_PROPERTY_REPEAT: + result = SUCCEEDED(ScriptVariant_IntegerValue(varlist[2], &integer_value)) && + movie_playback_set_repeat(playback, integer_value != 0); + break; + case MOVIE_PROPERTY_SPEED: + result = SUCCEEDED(ScriptVariant_DecimalValue(varlist[2], &decimal_value)) && + movie_playback_set_speed(playback, decimal_value); + break; + case MOVIE_PROPERTY_WIDTH: + result = SUCCEEDED(ScriptVariant_Unsigned64Value(varlist[2], &unsigned_value)) && + movie_playback_set_width(playback, unsigned_value); + break; + case MOVIE_PROPERTY_ACTIVE: + case MOVIE_PROPERTY_CHANNEL: + case MOVIE_PROPERTY_DURATION: + case MOVIE_PROPERTY_SOUND_CHANNEL: + case MOVIE_PROPERTY_SOURCE: + case MOVIE_PROPERTY_END: + default: + printf("\nScript error: movie_set_property(void playback, int property, value). Property id %d is read-only or unknown.\n", + property_index); + return E_FAIL; + } + + if(result) { + return S_OK; + } + printf("\nScript error: movie_set_property(void playback, int property, value). Value is invalid for property id %d.\n", + property_index); + return E_FAIL; + +error_object: + printf("\nScript error: movie_set_property(void playback, int property, value). Playback or property is invalid.\n"); + return E_FAIL; +} + +#else + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Keep the generic movie API visible on decoder-free builds +* while reporting that playback is unavailable. +*/ +static HRESULT openbor_movie_unsupported(ScriptVariant **pretvar) +{ + ScriptVariant_Clear(*pretvar); + printf("Movie playback is not supported on this platform.\n"); + return S_OK; +} + +#define MOVIE_UNSUPPORTED(name) \ + HRESULT name(ScriptVariant **v, ScriptVariant **r, int c) \ + { (void)v; (void)c; return openbor_movie_unsupported(r); } + +MOVIE_UNSUPPORTED(openbor_movie_load) +MOVIE_UNSUPPORTED(openbor_movie_unload) +MOVIE_UNSUPPORTED(openbor_movie_play) +MOVIE_UNSUPPORTED(openbor_movie_draw_to_screen) +MOVIE_UNSUPPORTED(openbor_movie_set_sound_channel) +MOVIE_UNSUPPORTED(openbor_movie_stop) +MOVIE_UNSUPPORTED(openbor_movie_get_channel_object) +MOVIE_UNSUPPORTED(openbor_movie_get_channel_index) +MOVIE_UNSUPPORTED(openbor_movie_get_channel_mask) +MOVIE_UNSUPPORTED(openbor_movie_get_property) +MOVIE_UNSUPPORTED(openbor_movie_set_property) + +#undef MOVIE_UNSUPPORTED + +#endif diff --git a/engine/source/openborscript/movie.h b/engine/source/openborscript/movie.h new file mode 100644 index 000000000..0af2255e5 --- /dev/null +++ b/engine/source/openborscript/movie.h @@ -0,0 +1,43 @@ +/* + * OpenBOR - http://www.chronocrash.com + * ----------------------------------------------------------------------- + * All rights reserved. See LICENSE in OpenBOR root for license details. + * + * Copyright (c) OpenBOR Team + */ + +#ifndef OPENBORSCRIPT_MOVIE_H +#define OPENBORSCRIPT_MOVIE_H + +typedef enum e_movie_properties { + MOVIE_PROPERTY_ACTIVE, + MOVIE_PROPERTY_BLACK_FILTER, + MOVIE_PROPERTY_CHANNEL, + MOVIE_PROPERTY_DURATION, /* Milliseconds, read only. */ + MOVIE_PROPERTY_HEIGHT, + MOVIE_PROPERTY_INTERRUPT, + MOVIE_PROPERTY_OFFSET_X, + MOVIE_PROPERTY_OFFSET_Y, + MOVIE_PROPERTY_PAUSED, + MOVIE_PROPERTY_POSITION, /* Milliseconds; writing seeks. */ + MOVIE_PROPERTY_REPEAT, + MOVIE_PROPERTY_SOUND_CHANNEL, + MOVIE_PROPERTY_SOURCE, + MOVIE_PROPERTY_SPEED, + MOVIE_PROPERTY_WIDTH, + MOVIE_PROPERTY_END +} e_movie_properties; + +HRESULT openbor_movie_load(ScriptVariant **varlist, ScriptVariant **pretvar, int paramCount); +HRESULT openbor_movie_unload(ScriptVariant **varlist, ScriptVariant **pretvar, int paramCount); +HRESULT openbor_movie_play(ScriptVariant **varlist, ScriptVariant **pretvar, int paramCount); +HRESULT openbor_movie_draw_to_screen(ScriptVariant **varlist, ScriptVariant **pretvar, int paramCount); +HRESULT openbor_movie_set_sound_channel(ScriptVariant **varlist, ScriptVariant **pretvar, int paramCount); +HRESULT openbor_movie_stop(ScriptVariant **varlist, ScriptVariant **pretvar, int paramCount); +HRESULT openbor_movie_get_channel_object(ScriptVariant **varlist, ScriptVariant **pretvar, int paramCount); +HRESULT openbor_movie_get_channel_index(ScriptVariant **varlist, ScriptVariant **pretvar, int paramCount); +HRESULT openbor_movie_get_channel_mask(ScriptVariant **varlist, ScriptVariant **pretvar, int paramCount); +HRESULT openbor_movie_get_property(ScriptVariant **varlist, ScriptVariant **pretvar, int paramCount); +HRESULT openbor_movie_set_property(ScriptVariant **varlist, ScriptVariant **pretvar, int paramCount); + +#endif diff --git a/engine/source/webmlib/movie_playback.c b/engine/source/webmlib/movie_playback.c new file mode 100644 index 000000000..51da1a2ae --- /dev/null +++ b/engine/source/webmlib/movie_playback.c @@ -0,0 +1,1812 @@ +/* + * OpenBOR - http://www.chronocrash.com + * ----------------------------------------------------------------------- + * All rights reserved. See LICENSE in OpenBOR root for license details. + * + * Copyright (c) OpenBOR Team + */ + +#include +#include +#include +#include +#include +#include +#include + +/* +* Caskey, Damon V. +* 2026-08-13 +* +* Allocate an optional movie cache without the engine's fatal +* allocation wrapper. Automatic mode must be able to recover from +* allocation pressure by falling back to streamed playback. +*/ +static void *movie_cache_allocate_nonfatal(size_t size) +{ + return malloc(size); +} + +#include "globals.h" +#include "ram.h" +#include "screen.h" +#include "sound_channel.h" +#include "soundmix.h" +#include "timer.h" +#include "vidplay.h" +#include "movie_playback.h" + +#define MOVIE_SOURCE_CAPACITY_INITIAL 64U +#define MOVIE_CACHE_READ_CHUNK_SIZE (1024U * 1024U) +#define MOVIE_CACHE_RESERVE_MINIMUM (UINT64_C(128) * UINT64_C(1024) * UINT64_C(1024)) +#define MOVIE_SPEED_ROUNDING_FACTOR 1000.0 +#define MOVIE_VIDEO_LATE_TOLERANCE_NANOSECONDS UINT64_C(50000000) +#define MOVIE_PRESENTATION_DROP_REPORT_INTERVAL UINT64_C(16) + +typedef enum e_movie_cache_result { + MOVIE_CACHE_RESULT_SUCCESS, + MOVIE_CACHE_RESULT_MEMORY, + MOVIE_CACHE_RESULT_IO +} e_movie_cache_result; + +typedef struct s_movie_source { + char *path; + unsigned char *cache_buffer; + size_t cache_size; + unsigned int references; + e_movie_loading_mode loading_mode; + int active; + int id; +} s_movie_source; + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Own asynchronous decoder lifecycle independently from the script-visible +* playback record. Each decoder holds its movie source through CLOSED. +*/ +typedef struct s_movie_decoder { + webm_context *context; + int source_id; + int channel; + int ready; + struct s_movie_decoder *next; +} s_movie_decoder; + +typedef struct s_movie_decoder_request { + uint64_t position; + int source_id; + int volume; + int pending; +} s_movie_decoder_request; + +typedef struct s_movie_playback_pool { + s_movie_playback channel[MOVIE_CHANNEL_COUNT]; + s_movie_decoder *decoder[MOVIE_CHANNEL_COUNT]; + s_movie_decoder_request decoder_request[MOVIE_CHANNEL_COUNT]; + unsigned int retiring_decoder_count[MOVIE_CHANNEL_COUNT]; + s_movie_decoder *retired_decoder; + s_movie_source *source; + size_t source_capacity; + int next_source_id; + uint64_t active_mask; + uint64_t cached_bytes; + int initialized; + int yuv_initialized; +} s_movie_playback_pool; + +static s_movie_playback_pool movie_playback_pool; + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Convert creator-supplied forward playback speed to one canonical float. +* Reject negative rates because backward navigation is provided by explicit +* seeks, then clamp the upper range and round to thousandths so video timing, +* audio resampling, property reads, and decoder reopens share one rate. +*/ +static bool movie_playback_sanitize_speed( + double requested_speed, + float *applied_speed +) +{ + if(!applied_speed || !isfinite(requested_speed)) { + return false; + } + + if(requested_speed < MOVIE_SPEED_MIN) { + return false; + } + if(requested_speed > MOVIE_SPEED_MAX) { + requested_speed = MOVIE_SPEED_MAX; + } + requested_speed = round( + requested_speed * MOVIE_SPEED_ROUNDING_FACTOR + ) / MOVIE_SPEED_ROUNDING_FACTOR; + + *applied_speed = (float)requested_speed; + if(*applied_speed == 0.0f) { + *applied_speed = 0.0f; + } + return true; +} + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Validate screens and allocate exact logical dimensions +* over the engine's four-pixel-aligned screen storage. +*/ +static bool movie_playback_screen_valid(const s_screen *screen) +{ + return screen && + screen->magic == screen_magic && + screen->pixelformat == PIXEL_32 && + screen->width > 0 && + screen->height > 0; +} + +static bool movie_playback_dimensions_valid(int width, int height) +{ + int allocation_width; + + if(width < 1 || height < 1 || width > INT_MAX - 3) { + return false; + } + allocation_width = (width + 3) & ~3; + return allocation_width <= + INT_MAX / height / (int)sizeof(uint32_t); +} + +static s_screen *movie_playback_alloc_screen(int width, int height) +{ + s_screen *screen; + + if(!movie_playback_dimensions_valid(width, height)) { + return NULL; + } + + screen = allocscreen((width + 3) & ~3, height, PIXEL_32); + if(screen) { + screen->width = width; + } + return screen; +} + +/* +* Caskey, Damon V. +* 2026-08-15 +* +* Resolve each creator-selected composition dimension. Zero follows the +* destination screen, MOVIE_SIZE_NATIVE follows the decoded video, and an +* explicit positive value requests that exact processing size. +*/ +static bool movie_playback_resolve_dimensions( + const s_movie_playback *playback, + const s_screen *destination, + int *width, + int *height +) +{ + uint64_t resolved_width = playback->width; + uint64_t resolved_height = playback->height; + + if(resolved_width == 0) { + resolved_width = (uint64_t)destination->width; + } else if(resolved_width == MOVIE_SIZE_NATIVE) { + resolved_width = (uint64_t)playback->current_frame->width; + } + if(resolved_height == 0) { + resolved_height = (uint64_t)destination->height; + } else if(resolved_height == MOVIE_SIZE_NATIVE) { + resolved_height = (uint64_t)playback->current_frame->height; + } + + if(resolved_width > (uint64_t)INT_MAX || + resolved_height > (uint64_t)INT_MAX) { + return false; + } + *width = (int)resolved_width; + *height = (int)resolved_height; + return movie_playback_dimensions_valid(*width, *height); +} + +static char *movie_copy_path(const char *path) +{ + char *copy; + size_t length; + + if(!path || !path[0]) { + return NULL; + } + + length = strlen(path) + 1; + copy = malloc(length); + if(copy) { + memcpy(copy, path, length); + } + return copy; +} + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Grow stable integer-addressed movie source storage. IDs are +* never reused during a session, so unloaded IDs stay invalid. +*/ +static bool movie_source_reserve(size_t required_capacity) +{ + s_movie_source *source; + size_t previous_capacity; + size_t capacity; + + if(required_capacity <= movie_playback_pool.source_capacity) { + return true; + } + + previous_capacity = movie_playback_pool.source_capacity; + capacity = previous_capacity + ? previous_capacity + : MOVIE_SOURCE_CAPACITY_INITIAL; + while(capacity < required_capacity) { + if(capacity > SIZE_MAX / 2U) { + return false; + } + capacity *= 2U; + } + if(capacity > SIZE_MAX / sizeof(*source)) { + return false; + } + + source = realloc( + movie_playback_pool.source, + capacity * sizeof(*source) + ); + if(!source) { + return false; + } + memset( + source + previous_capacity, + 0, + (capacity - previous_capacity) * sizeof(*source) + ); + movie_playback_pool.source = source; + movie_playback_pool.source_capacity = capacity; + return true; +} + +static s_movie_source *movie_source_get(int source_id) +{ + s_movie_source *source; + + if(!movie_playback_pool.initialized || + source_id < 0 || + (size_t)source_id >= movie_playback_pool.source_capacity) { + return NULL; + } + + source = &movie_playback_pool.source[source_id]; + return source->active && source->id == source_id ? source : NULL; +} + +static void movie_source_release(int source_id) +{ + s_movie_source *source = movie_source_get(source_id); + + if(source && source->references) { + --source->references; + } +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Reap completed decoder retirement during runtime updates. The wait option +* provides synchronous shutdown after playback has stopped. Decoder source +* references remain valid through lifecycle completion. +*/ +static void movie_playback_reap_retired_decoders(int wait) +{ + s_movie_decoder **cursor = &movie_playback_pool.retired_decoder; + + while(*cursor) { + s_movie_decoder *decoder = *cursor; + int closed; + + if(wait) { + webm_close(decoder->context); + closed = 1; + } + else { + closed = webm_poll_closed(decoder->context); + } + if(!closed) { + cursor = &decoder->next; + continue; + } + + *cursor = decoder->next; + if(decoder->channel >= 0 && + (unsigned int)decoder->channel < MOVIE_CHANNEL_COUNT && + movie_playback_pool.retiring_decoder_count[ + decoder->channel + ]) { + --movie_playback_pool.retiring_decoder_count[ + decoder->channel + ]; + } + movie_source_release(decoder->source_id); + free(decoder); + } +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Queue the channel decoder for asynchronous retirement. Per-channel counts +* gate decoder creation, and one request slot coalesces repeated pending seeks. +*/ +static void movie_playback_retire_decoder(s_movie_playback *playback) +{ + int channel; + s_movie_decoder *decoder; + + if(!playback) { + return; + } + channel = playback->index; + if(channel >= 0 && + (unsigned int)channel < MOVIE_CHANNEL_COUNT && + &movie_playback_pool.channel[channel] == playback && + (decoder = movie_playback_pool.decoder[channel])) { + movie_playback_pool.decoder[channel] = NULL; + playback->context = NULL; + decoder->channel = channel; + ++movie_playback_pool.retiring_decoder_count[channel]; + webm_request_close(decoder->context); + decoder->next = movie_playback_pool.retired_decoder; + movie_playback_pool.retired_decoder = decoder; + } +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Retire the current decoder and cancel pending replacement intent when the +* owning playback itself closes. Its queued look-ahead frame is released +* immediately because it belongs to the canceled decode sequence. +*/ +static void movie_playback_close_decoder(s_movie_playback *playback) +{ + int channel; + + if(!playback) { + return; + } + channel = playback->index; + movie_playback_retire_decoder(playback); + if(channel >= 0 && (unsigned int)channel < MOVIE_CHANNEL_COUNT) { + memset( + &movie_playback_pool.decoder_request[channel], + 0, + sizeof(movie_playback_pool.decoder_request[channel]) + ); + } + yuv_frame_destroy(playback->next_frame); + playback->next_frame = NULL; +} + +static void movie_playback_close_media(s_movie_playback *playback) +{ + if(!playback) { + return; + } + + movie_playback_close_decoder(playback); + yuv_frame_destroy(playback->current_frame); + playback->current_frame = NULL; + if(playback->rgb_frame) { + freescreen(&playback->rgb_frame); + } + if(playback->scaled_frame) { + freescreen(&playback->scaled_frame); + } +} + +static void movie_playback_reset_record(s_movie_playback *playback) +{ + e_object_type object_type; + int index; + int source_id; + + if(!playback) { + return; + } + + object_type = playback->object_type; + index = playback->index; + source_id = playback->source_id; + movie_playback_close_media(playback); + movie_source_release(source_id); + memset(playback, 0, sizeof(*playback)); + playback->object_type = object_type; + playback->index = index; + playback->source_id = -1; + playback->sound_channel = SOUND_CHANNEL_MUSIC_DEFAULT; + playback->speed = 1.0; + playback->interrupt = 1; +} + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Initialize one stable bank of 64 movie playback objects, +* dynamic source storage, and shared 32-bit YUV conversion. +*/ +bool movie_playback_init(void) +{ + unsigned int channel; + + if(movie_playback_pool.initialized) { + return true; + } + + memset(&movie_playback_pool, 0, sizeof(movie_playback_pool)); + movie_playback_pool.initialized = 1; + for(channel = 0; channel < MOVIE_CHANNEL_COUNT; channel++) { + movie_playback_pool.channel[channel].object_type = + OBJECT_TYPE_MOVIE_PLAYBACK; + movie_playback_pool.channel[channel].index = (int)channel; + movie_playback_pool.channel[channel].source_id = -1; + movie_playback_reset_record(&movie_playback_pool.channel[channel]); + } + + if(!webm_lifecycle_init() || + !movie_source_reserve(MOVIE_SOURCE_CAPACITY_INITIAL)) { + webm_lifecycle_shutdown(); + memset(&movie_playback_pool, 0, sizeof(movie_playback_pool)); + return false; + } + + yuv_init(4); + movie_playback_pool.yuv_initialized = 1; + return true; +} + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Stop every playback, release loaded sources, and clear the +* shared converter during script or engine shutdown. +*/ +void movie_playback_shutdown(void) +{ + size_t source_id; + + if(!movie_playback_pool.initialized) { + return; + } + + movie_playback_stop_all(); + movie_playback_reap_retired_decoders(1); + for(source_id = 0; + source_id < movie_playback_pool.source_capacity; + source_id++) { + free(movie_playback_pool.source[source_id].path); + free(movie_playback_pool.source[source_id].cache_buffer); + } + free(movie_playback_pool.source); + if(movie_playback_pool.yuv_initialized) { + yuv_clear(); + } + webm_lifecycle_shutdown(); + memset(&movie_playback_pool, 0, sizeof(movie_playback_pool)); +} + +/* +* Caskey, Damon V. +* 2026-08-13 +* +* Determine whether an automatic movie cache preserves both its +* share of total memory and the required free-memory cushion. +* The budget scales without an upper cap for high-memory systems. +*/ +static bool movie_source_auto_cache_fits( + uint64_t file_size, + uint64_t total_memory, + uint64_t available_memory, + uint64_t cached_bytes +) +{ + uint64_t cache_budget; + uint64_t memory_reserve; + + if(!file_size || + !total_memory || + !available_memory || + file_size > (uint64_t)SIZE_MAX) { + return false; + } + + cache_budget = total_memory / 4U; + memory_reserve = total_memory / 8U; + if(memory_reserve < MOVIE_CACHE_RESERVE_MINIMUM) { + memory_reserve = MOVIE_CACHE_RESERVE_MINIMUM; + } + + if(file_size > cache_budget || + cached_bytes > cache_budget - file_size || + available_memory <= memory_reserve || + file_size > available_memory - memory_reserve) { + return false; + } + return true; +} + +/* +* Caskey, Damon V. +* 2026-08-13 +* +* Read one validated movie source into a nonfatal allocation using +* bounded packfile reads. Allocation pressure is distinguished from +* I/O failure so automatic mode only falls back for memory limits. +*/ +static e_movie_cache_result movie_source_read_cache( + int handle, + uint64_t file_size, + unsigned char **cache_buffer, + size_t *cache_size +) +{ + unsigned char *buffer; + uint64_t read_position = 0; + + if(handle < 0 || + !file_size || + file_size > (uint64_t)SIZE_MAX || + !cache_buffer || + !cache_size) { + return MOVIE_CACHE_RESULT_IO; + } + + buffer = movie_cache_allocate_nonfatal((size_t)file_size); + if(!buffer) { + return MOVIE_CACHE_RESULT_MEMORY; + } + + while(read_position < file_size) { + uint64_t bytes_remaining = file_size - read_position; + int read_size = bytes_remaining > MOVIE_CACHE_READ_CHUNK_SIZE + ? (int)MOVIE_CACHE_READ_CHUNK_SIZE + : (int)bytes_remaining; + + if(readpackfile( + handle, + buffer + (size_t)read_position, + read_size + ) != read_size) { + free(buffer); + return MOVIE_CACHE_RESULT_IO; + } + read_position += (uint64_t)read_size; + } + + *cache_buffer = buffer; + *cache_size = (size_t)file_size; + return MOVIE_CACHE_RESULT_SUCCESS; +} + +/* +* Caskey, Damon V. +* 2026-08-13 +* +* Load one reusable movie source according to its stream, cache, or automatic +* memory policy. Forced cache reports failure; automatic cache may select +* streaming when memory requirements are not met. +*/ +int movie_source_load(const char *path, e_movie_loading_mode loading_mode) +{ + s_movie_source source = { 0 }; + e_movie_cache_result cache_result = MOVIE_CACHE_RESULT_SUCCESS; + packfile_signed_offset_t file_size; + bool attempt_cache; + int handle = -1; + int source_id; + + if(!movie_playback_init() || + !path || !path[0] || + loading_mode < 0 || loading_mode >= MOVIE_LOADING_END || + movie_playback_pool.next_source_id == INT_MAX) { + return -1; + } + + source.path = movie_copy_path(path); + if(!source.path) { + return -1; + } + + handle = openpackfile(path, packfile); + if(handle < 0) { + free(source.path); + return -1; + } + file_size = seekpackfile64(handle, 0, SEEK_END); + if(file_size <= 0 || seekpackfile64(handle, 0, SEEK_SET) < 0) { + closepackfile(handle); + free(source.path); + return -1; + } + + attempt_cache = loading_mode == MOVIE_LOADING_CACHE || + (loading_mode == MOVIE_LOADING_AUTO && + movie_source_auto_cache_fits( + (uint64_t)file_size, + getSystemRam(BYTES), + getFreeRam(BYTES), + movie_playback_pool.cached_bytes + )); + if(attempt_cache) { + cache_result = movie_source_read_cache( + handle, + (uint64_t)file_size, + &source.cache_buffer, + &source.cache_size + ); + } + closepackfile(handle); + + if(cache_result == MOVIE_CACHE_RESULT_IO || + (cache_result == MOVIE_CACHE_RESULT_MEMORY && + loading_mode == MOVIE_LOADING_CACHE)) { + free(source.path); + free(source.cache_buffer); + return -1; + } + source.loading_mode = source.cache_buffer + ? MOVIE_LOADING_CACHE + : MOVIE_LOADING_STREAM; + + source_id = movie_playback_pool.next_source_id; + if(!movie_source_reserve((size_t)source_id + 1U)) { + free(source.path); + free(source.cache_buffer); + return -1; + } + + source.id = source_id; + source.active = 1; + movie_playback_pool.source[source_id] = source; + movie_playback_pool.cached_bytes += (uint64_t)source.cache_size; + ++movie_playback_pool.next_source_id; + return source_id; +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Unload a source only after active and asynchronously retiring decoders +* release it. Each call first reaps completed teardown without waiting. +*/ +bool movie_source_unload(int source_id) +{ + s_movie_source *source; + + movie_playback_reap_retired_decoders(0); + source = movie_source_get(source_id); + + if(!source || source->references) { + return false; + } + + if((uint64_t)source->cache_size <= movie_playback_pool.cached_bytes) { + movie_playback_pool.cached_bytes -= (uint64_t)source->cache_size; + } else { + movie_playback_pool.cached_bytes = 0; + } + free(source->path); + free(source->cache_buffer); + memset(source, 0, sizeof(*source)); + source->id = -1; + return true; +} + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Validate playback pointers by exact address arithmetic and +* stable channel metadata without dereferencing foreign data. +*/ +s_movie_playback *movie_playback_get(int channel) +{ + if(!movie_playback_pool.initialized || + channel < 0 || + (unsigned int)channel >= MOVIE_CHANNEL_COUNT) { + return NULL; + } + return &movie_playback_pool.channel[channel]; +} + +int movie_playback_get_index(const s_movie_playback *playback) +{ + const s_movie_playback *indexed_playback; + uintptr_t base_address; + uintptr_t playback_address; + uintptr_t playback_offset; + unsigned int channel; + + if(!movie_playback_pool.initialized || !playback) { + return -1; + } + + base_address = (uintptr_t)(const void*)&movie_playback_pool.channel[0]; + playback_address = (uintptr_t)(const void*)playback; + if(playback_address < base_address) { + return -1; + } + + playback_offset = playback_address - base_address; + if(playback_offset >= sizeof(movie_playback_pool.channel) || + playback_offset % sizeof(movie_playback_pool.channel[0])) { + return -1; + } + + channel = (unsigned int)( + playback_offset / sizeof(movie_playback_pool.channel[0]) + ); + indexed_playback = &movie_playback_pool.channel[channel]; + if((uintptr_t)(const void*)indexed_playback != playback_address || + indexed_playback->object_type != OBJECT_TYPE_MOVIE_PLAYBACK || + indexed_playback->index != (int)channel) { + return -1; + } + return (int)channel; +} + +bool movie_playback_get_snapshot( + const s_movie_playback *playback, + s_movie_playback *snapshot +) +{ + int channel = movie_playback_get_index(playback); + + if(channel < 0 || !snapshot) { + return false; + } + *snapshot = movie_playback_pool.channel[channel]; + return true; +} + +uint64_t movie_playback_get_active_mask(void) +{ + return movie_playback_pool.initialized + ? movie_playback_pool.active_mask + : UINT64_C(0); +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Test whether the current channel decoder has crossed its asynchronous +* READY boundary. Property mutations may update pending playback intent +* before this point without touching partially initialized decoder fields. +*/ +static int movie_playback_decoder_ready( + const s_movie_playback *playback +) +{ + int channel; + s_movie_decoder *decoder; + + if(!playback) { + return 0; + } + channel = playback->index; + if(channel < 0 || (unsigned int)channel >= MOVIE_CHANNEL_COUNT) { + return 0; + } + decoder = movie_playback_pool.decoder[channel]; + return decoder && decoder->ready; +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Use consumed PCM time as the master during forward playback. Each +* successful owned-channel snapshot also reanchors the monotonic fallback, +* preserving continuity for silent media and replaced audio channels. +*/ +static uint64_t movie_playback_position_now( + s_movie_playback *playback, + uint64_t now +) +{ + uint64_t audio_position; + double elapsed; + double position; + + if(playback->paused || + playback->speed == 0.0 || + !movie_playback_decoder_ready(playback)) { + return playback->position_anchor; + } + if(playback->speed > 0.0 && + webm_get_audio_playback_position( + playback->context, + &audio_position + )) { + playback->position_anchor = audio_position; + playback->clock_anchor = now; + return audio_position; + } + + elapsed = now >= playback->clock_anchor + ? (double)(now - playback->clock_anchor) * 1000.0 * playback->speed + : 0.0; + position = (double)playback->position_anchor + elapsed; + if(position <= 0.0) { + return 0; + } + if(position >= (double)UINT64_MAX) { + return UINT64_MAX; + } + return (uint64_t)position; +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Start the latest pending decoder request after the retiring decoder reaches +* CLOSED. One pending slot coalesces repeated seeks during teardown. +*/ +static int movie_playback_start_pending_decoder( + s_movie_playback *playback +) +{ + s_movie_decoder_request *request; + s_movie_source *source; + s_movie_decoder *decoder; + webm_context *context; + int channel = playback->index; + + if(channel < 0 || (unsigned int)channel >= MOVIE_CHANNEL_COUNT) { + return -1; + } + request = &movie_playback_pool.decoder_request[channel]; + if(!request->pending) { + return movie_playback_pool.decoder[channel] ? 1 : -1; + } + if(movie_playback_pool.retiring_decoder_count[channel]) { + return 0; + } + source = movie_source_get(request->source_id); + if(!source || playback->source_id != request->source_id) { + request->pending = 0; + return -1; + } + + decoder = calloc(1, sizeof(*decoder)); + if(!decoder) { + request->pending = 0; + return -1; + } + ++source->references; + context = webm_start_playback_ex( + source->path, + request->volume, + playback->sound_channel, + source->cache_buffer, + source->cache_size, + request->position, + playback->speed > 0.0, + playback->replace_all_audio + ); + if(!context) { + movie_source_release(request->source_id); + request->pending = 0; + free(decoder); + return -1; + } + + decoder->context = context; + decoder->source_id = request->source_id; + decoder->channel = channel; + movie_playback_pool.decoder[channel] = decoder; + playback->context = context; + request->pending = 0; + return 1; +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Begin an asynchronous WebM open or seek from a reusable source while +* preserving the last frame until replacement is ready. Decoder ownership +* retains an additional source reference through asynchronous teardown. +*/ +static bool movie_playback_begin_media( + s_movie_playback *playback, + uint64_t position, + int volume +) +{ + s_movie_source *source = movie_source_get(playback->source_id); + s_movie_decoder_request *request; + int channel = playback->index; + + if(!source || + channel < 0 || + (unsigned int)channel >= MOVIE_CHANNEL_COUNT) { + return false; + } + + movie_playback_retire_decoder(playback); + yuv_frame_destroy(playback->next_frame); + playback->next_frame = NULL; + playback->current_frame_presented = playback->current_frame != NULL; + request = &movie_playback_pool.decoder_request[channel]; + request->position = position; + request->source_id = playback->source_id; + request->volume = volume; + request->pending = 1; + playback->volume = volume; + playback->position_anchor = position; + playback->position = position / UINT64_C(1000000); + playback->terminal_pending = 0; + playback->failed = 0; + return movie_playback_start_pending_decoder(playback) >= 0; +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Poll an asynchronous decoder and commit its immutable media metadata once +* READY is published. Movie clocks begin at this boundary, preventing open, +* seek, and preroll latency from advancing the presentation timeline. +*/ +static int movie_playback_poll_decoder(s_movie_playback *playback) +{ + s_movie_decoder *decoder; + e_webm_decoder_state state; + yuv_video_mode video_mode = { 0 }; + uint64_t duration; + int channel = playback->index; + + if(channel < 0 || + (unsigned int)channel >= MOVIE_CHANNEL_COUNT || + !(decoder = movie_playback_pool.decoder[channel])) { + return -1; + } + if(decoder->ready) { + return 1; + } + + state = webm_get_decoder_state(decoder->context); + if(state == WEBM_DECODER_STATE_FAILED || + state == WEBM_DECODER_STATE_CLOSING || + state == WEBM_DECODER_STATE_CLOSED) { + return -1; + } + if(state != WEBM_DECODER_STATE_READY) { + return 0; + } + + webm_get_video_info(decoder->context, &video_mode); + duration = webm_get_duration(decoder->context); + if((video_mode.width & 1) || + (video_mode.height & 1) || + video_mode.width > (INT_MAX >> 16) || + video_mode.height > (INT_MAX >> 16) || + !movie_playback_dimensions_valid( + video_mode.width, + video_mode.height + )) { + printf( + "Error: Movie frame dimensions %d*%d cannot be represented by the 32-bit screen renderer.\n", + video_mode.width, + video_mode.height + ); + return -1; + } + + if(playback->video_mode.width != video_mode.width || + playback->video_mode.height != video_mode.height) { + yuv_frame_destroy(playback->current_frame); + playback->current_frame = NULL; + playback->current_frame_presented = 0; + if(playback->rgb_frame) { + freescreen(&playback->rgb_frame); + } + if(playback->scaled_frame) { + freescreen(&playback->scaled_frame); + } + } + playback->video_mode = video_mode; + playback->duration = duration / UINT64_C(1000000); + if(duration && playback->position_anchor >= duration) { + playback->position_anchor = duration - 1U; + } + playback->clock_anchor = timer_uticks(); + playback->position = + playback->position_anchor / UINT64_C(1000000); + playback->terminal_pending = 0; + decoder->ready = 1; + + if(playback->speed > 0.0) { + webm_set_audio_speed(decoder->context, playback->speed); + } + webm_set_audio_paused( + decoder->context, + playback->paused || playback->speed == 0.0 + ); + return 1; +} + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Create one playback from a reusable source. Automatic mode takes the lowest +* free slot; explicit selection replaces that channel. Exclusive audio mode +* stops other channel playback for fullscreen ownership. +*/ +s_movie_playback *movie_playback_play( + int source_id, + int movie_channel, + int volume, + bool replace_all_audio +) +{ + s_movie_source *source; + s_movie_playback *playback; + uint64_t available_mask; + int channel; + + if(!movie_playback_init() || + !(source = movie_source_get(source_id)) || + (movie_channel != MOVIE_CHANNEL_AUTO && + (movie_channel < 0 || + (unsigned int)movie_channel >= MOVIE_CHANNEL_COUNT))) { + return NULL; + } + + if(movie_channel == MOVIE_CHANNEL_AUTO) { + available_mask = ~movie_playback_pool.active_mask; + channel = sound_channel_mask_first(available_mask); + if(channel < 0 || (unsigned int)channel >= MOVIE_CHANNEL_COUNT) { + return NULL; + } + } else { + channel = movie_channel; + } + + playback = &movie_playback_pool.channel[channel]; + movie_playback_stop(playback); + playback->source_id = source_id; + playback->replace_all_audio = replace_all_audio; + ++source->references; + if(!movie_playback_begin_media(playback, 0, volume)) { + movie_playback_reset_record(playback); + return NULL; + } + + playback->active = 1; + movie_playback_pool.active_mask |= UINT64_C(1) << channel; + return playback; +} + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Stop individual or grouped playbacks and release their source +* references and fixed channel slots. +*/ +void movie_playback_stop(s_movie_playback *playback) +{ + int channel = movie_playback_get_index(playback); + + if(channel < 0) { + return; + } + movie_playback_pool.active_mask &= ~(UINT64_C(1) << channel); + movie_playback_reset_record(&movie_playback_pool.channel[channel]); +} + +void movie_playback_stop_all(void) +{ + uint64_t active_mask; + int channel; + + if(!movie_playback_pool.initialized) { + return; + } + + active_mask = movie_playback_pool.active_mask; + while((channel = sound_channel_mask_first(active_mask)) >= 0) { + active_mask &= ~(UINT64_C(1) << channel); + movie_playback_stop(&movie_playback_pool.channel[channel]); + } +} + +/* +* Caskey, Damon V. +* 2026-08-15 +* +* Protect absolute black after conversion when a movie frame will be drawn +* into a subscreen that reserves RGB 0,0,0 as its transparency mask. +*/ +static void movie_playback_filter_black( + const s_movie_playback *playback, + s_screen *frame +) +{ + size_t pixel_count; + size_t pixel_index; + uint32_t *pixel; + uint32_t protected_black; + + if(!playback->black_filter) { + return; + } + + pixel_count = (size_t)frame->width * (size_t)frame->height; + pixel = (uint32_t *)frame->data; + protected_black = colour32(1, 1, 1); + for(pixel_index = 0; pixel_index < pixel_count; pixel_index++) { + if(pixel[pixel_index] == 0) { + pixel[pixel_index] = protected_black; + } + } +} + +/* +* Caskey, Damon V. +* 2026-08-15 +* +* Convert a current frame only when its pixels or destination dimensions +* change. Resized output converts directly from YUV at the requested size, +* avoiding a full-resolution RGB conversion and subsequent scaling pass. +*/ +static bool movie_playback_prepare_frame( + s_movie_playback *playback, + const s_screen *destination, + s_screen **render_frame +) +{ + int height; + int width; + + if(!playback->current_frame) { + return false; + } + if(!movie_playback_resolve_dimensions( + playback, + destination, + &width, + &height + )) { + return false; + } + + if(playback->frame_dirty) { + playback->rgb_frame_dirty = 1; + playback->scaled_frame_dirty = 1; + } + + if(width == playback->current_frame->width && + height == playback->current_frame->height) { + if(!playback->rgb_frame) { + playback->rgb_frame = movie_playback_alloc_screen(width, height); + playback->rgb_frame_dirty = 1; + } + if(!playback->rgb_frame) { + return false; + } + if(playback->rgb_frame_dirty) { + yuv_to_rgb(playback->current_frame, playback->rgb_frame); + movie_playback_filter_black(playback, playback->rgb_frame); + playback->rgb_frame_dirty = 0; + } + playback->frame_dirty = 0; + *render_frame = playback->rgb_frame; + return true; + } + + if(playback->scaled_frame && + (playback->scaled_frame->width != width || + playback->scaled_frame->height != height)) { + freescreen(&playback->scaled_frame); + } + if(!playback->scaled_frame) { + playback->scaled_frame = movie_playback_alloc_screen(width, height); + playback->scaled_frame_dirty = 1; + } + if(!playback->scaled_frame) { + return false; + } + if(playback->scaled_frame_dirty) { + if(!yuv_to_rgb_scaled( + playback->current_frame, + playback->scaled_frame + )) { + return false; + } + movie_playback_filter_black(playback, playback->scaled_frame); + playback->scaled_frame_dirty = 0; + } + + playback->frame_dirty = 0; + *render_frame = playback->scaled_frame; + return true; +} + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Copy a prepared 32-bit movie frame with signed offsets and +* destination clipping independent of global draw state. +*/ +static void movie_playback_copy_frame( + s_screen *destination, + const s_screen *source, + int offset_x, + int offset_y +) +{ + const unsigned char *source_row; + unsigned char *destination_row; + int64_t destination_x = offset_x; + int64_t destination_y = offset_y; + int source_x = 0; + int source_y = 0; + int width = source->width; + int height = source->height; + int row; + + if(destination_x < 0) { + int64_t clipped_width = -destination_x; + if(clipped_width >= width) return; + source_x = (int)clipped_width; + width -= source_x; + destination_x = 0; + } + if(destination_y < 0) { + int64_t clipped_height = -destination_y; + if(clipped_height >= height) return; + source_y = (int)clipped_height; + height -= source_y; + destination_y = 0; + } + if(destination_x >= destination->width || + destination_y >= destination->height) { + return; + } + if(width > destination->width - destination_x) { + width = (int)(destination->width - destination_x); + } + if(height > destination->height - destination_y) { + height = (int)(destination->height - destination_y); + } + if(width <= 0 || height <= 0) { + return; + } + + source_row = source->data + + ((source_y * source->width + source_x) * (int)sizeof(uint32_t)); + destination_row = destination->data + + (((int)destination_y * destination->width + (int)destination_x) * + (int)sizeof(uint32_t)); + for(row = 0; row < height; row++) { + memcpy(destination_row, source_row, (size_t)width * sizeof(uint32_t)); + source_row += source->width * (int)sizeof(uint32_t); + destination_row += destination->width * (int)sizeof(uint32_t); + } +} + +static bool movie_playback_render_to( + s_movie_playback *playback, + s_screen *destination +) +{ + s_screen *render_frame; + + if(!movie_playback_screen_valid(destination)) { + return false; + } + if(!playback->current_frame) { + return true; + } + if(!movie_playback_prepare_frame( + playback, + destination, + &render_frame + )) { + return false; + } + + movie_playback_copy_frame( + destination, + render_frame, + playback->offset_x, + playback->offset_y + ); + playback->current_frame_presented = 1; + return true; +} + +/* +* Caskey, Damon V. +* 2026-08-18 +* +* Record decoded frames omitted after exceeding the audio-master lateness +* tolerance. Track maximum lateness and rate-limit diagnostic output. +*/ +static void movie_playback_record_presentation_drop( + s_movie_playback *playback, + uint64_t frame_timestamp, + uint64_t position +) +{ + uint64_t lateness = position >= frame_timestamp + ? position - frame_timestamp + : 0; + + if(playback->presentation_drop_count < UINT64_MAX) { + ++playback->presentation_drop_count; + } + if(lateness > playback->maximum_presentation_lateness) { + playback->maximum_presentation_lateness = lateness; + } + if(playback->presentation_drop_count == 1U || + !(playback->presentation_drop_count % + MOVIE_PRESENTATION_DROP_REPORT_INTERVAL)) { + printf( + "\nWarning: Movie channel %d discarded %" PRIu64 + " late decoded video frame%s for audio synchronization " + "(maximum lateness=%" PRIu64 " ms).\n", + playback->index, + playback->presentation_drop_count, + playback->presentation_drop_count == 1U ? "" : "s", + playback->maximum_presentation_lateness / UINT64_C(1000000) + ); + } +} + +/* +* Caskey, Damon V. +* 2026-08-18 +* +* Transfer the look-ahead frame into presentation ownership and mark it pending +* until composition acknowledges it. +*/ +static void movie_playback_promote_next_frame( + s_movie_playback *playback +) +{ + yuv_frame_destroy(playback->current_frame); + playback->current_frame = playback->next_frame; + playback->next_frame = NULL; + playback->frame_dirty = 1; + playback->current_frame_presented = 0; +} + +/* +* Caskey, Damon V. +* 2026-08-18 +* +* Promote one due frame per presentation opportunity under the audio master. +* Protect frames within the lateness tolerance and bound work to the initial +* decoded-queue snapshot. +*/ +static void movie_playback_poll_frames( + s_movie_playback *playback, + uint64_t position, + int *terminal +) +{ + int remaining = webm_get_pending_frame_count(playback->context); + if(playback->next_frame) { + ++remaining; + } + while(remaining-- > 0) { + int result; + + if(!playback->next_frame) { + result = webm_try_get_next_frame( + playback->context, + &playback->next_frame + ); + if(result == 0) break; + if(result < 0) { + *terminal = 1; + break; + } + } + + if(playback->current_frame && + !playback->current_frame_presented) { + uint64_t current_timestamp = + playback->current_frame->timestamp; + + if(playback->next_frame->timestamp > position || + current_timestamp > position || + position - current_timestamp <= + MOVIE_VIDEO_LATE_TOLERANCE_NANOSECONDS) { + break; + } + movie_playback_record_presentation_drop( + playback, + current_timestamp, + position + ); + yuv_frame_destroy(playback->current_frame); + playback->current_frame = NULL; + playback->frame_dirty = 0; + } + + if(playback->next_frame->timestamp > position) { + if(!playback->current_frame) { + movie_playback_promote_next_frame(playback); + } + break; + } + + if(position - playback->next_frame->timestamp > + MOVIE_VIDEO_LATE_TOLERANCE_NANOSECONDS && + remaining > 1) { + movie_playback_record_presentation_drop( + playback, + playback->next_frame->timestamp, + position + ); + yuv_frame_destroy(playback->next_frame); + playback->next_frame = NULL; + continue; + } + + movie_playback_promote_next_frame(playback); + break; + } +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Advance playback clocks and decoder state independently from composition. +* Lifecycle polling keeps open, seek, preroll, and teardown work off the +* engine thread. Backward navigation remains an explicit seek operation. +*/ +void movie_playback_update(int interrupt_requested) +{ + uint64_t active_mask; + uint64_t now; + int channel; + + if(!movie_playback_pool.initialized) { + return; + } + + movie_playback_reap_retired_decoders(0); + now = timer_uticks(); + active_mask = movie_playback_pool.active_mask; + while((channel = sound_channel_mask_first(active_mask)) >= 0) { + s_movie_playback *playback = &movie_playback_pool.channel[channel]; + uint64_t position; + int decoder_result; + int terminal = 0; + + active_mask &= ~(UINT64_C(1) << channel); + if(interrupt_requested && playback->interrupt) { + movie_playback_stop(playback); + continue; + } + decoder_result = movie_playback_start_pending_decoder(playback); + if(decoder_result > 0) { + decoder_result = movie_playback_poll_decoder(playback); + } + if(decoder_result < 0) { + movie_playback_stop(playback); + playback->failed = 1; + continue; + } + if(decoder_result == 0) { + continue; + } + if(playback->terminal_pending) { + if(playback->repeat) { + if(!movie_playback_begin_media( + playback, + 0, + playback->volume + )) { + movie_playback_stop(playback); + } + } else { + movie_playback_stop(playback); + } + continue; + } + + position = movie_playback_position_now(playback, now); + playback->position = position / UINT64_C(1000000); + if(playback->duration && playback->position > playback->duration) { + playback->position = playback->duration; + } + + if((playback->speed > 0.0 && !playback->paused) || + !playback->current_frame) { + movie_playback_poll_frames(playback, position, &terminal); + } + + if(terminal) { + if(playback->frame_dirty) { + playback->terminal_pending = 1; + } else if(playback->repeat) { + if(!movie_playback_begin_media( + playback, + 0, + playback->volume + )) { + movie_playback_stop(playback); + } + } else { + movie_playback_stop(playback); + } + } + } +} + +/* +* Caskey, Damon V. +* 2026-08-13 +* +* Draw the retained frame from one active movie channel to a +* creator-selected 32-bit screen. Playback and composition remain +* independent so script call order controls clearing and layering. +*/ +bool movie_playback_draw_to_screen(s_screen *screen, int channel) +{ + s_movie_playback *playback; + + if(!movie_playback_pool.initialized || + channel < 0 || + (unsigned int)channel >= MOVIE_CHANNEL_COUNT || + !movie_playback_screen_valid(screen)) { + return false; + } + + playback = &movie_playback_pool.channel[channel]; + if(!playback->active || + !(movie_playback_pool.active_mask & (UINT64_C(1) << channel))) { + return false; + } + return movie_playback_render_to(playback, screen); +} + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Property mutation helpers preserve decoder, timing, audio, +* scaling, signed-rate, and external screen invariants. +*/ +bool movie_playback_set_height(s_movie_playback *playback, uint64_t height) +{ + if(movie_playback_get_index(playback) < 0 || + (height != MOVIE_SIZE_NATIVE && height > (uint64_t)INT_MAX)) { + return false; + } + playback->height = height; + playback->frame_dirty = 1; + return true; +} + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Protect exact-black movie pixels from the subscreen color key +* without modifying any nonzero near-black shades. +*/ +bool movie_playback_set_black_filter( + s_movie_playback *playback, + int black_filter +) +{ + if(movie_playback_get_index(playback) < 0) { + return false; + } + playback->black_filter = black_filter != 0; + playback->frame_dirty = 1; + return true; +} + +bool movie_playback_set_interrupt(s_movie_playback *playback, int interrupt) +{ + if(movie_playback_get_index(playback) < 0) { + return false; + } + playback->interrupt = interrupt != 0; + return true; +} + +bool movie_playback_set_offset_x(s_movie_playback *playback, int offset_x) +{ + if(movie_playback_get_index(playback) < 0) { + return false; + } + playback->offset_x = offset_x; + return true; +} + +bool movie_playback_set_offset_y(s_movie_playback *playback, int offset_y) +{ + if(movie_playback_get_index(playback) < 0) { + return false; + } + playback->offset_y = offset_y; + return true; +} + +bool movie_playback_set_paused(s_movie_playback *playback, int paused) +{ + uint64_t now; + + if(movie_playback_get_index(playback) < 0 || !playback->active) { + return false; + } + paused = paused != 0; + if(playback->paused == paused) { + return true; + } + + now = timer_uticks(); + if(paused) { + playback->position_anchor = movie_playback_position_now(playback, now); + playback->position = + playback->position_anchor / UINT64_C(1000000); + } else { + playback->clock_anchor = now; + } + playback->paused = paused; + if(movie_playback_decoder_ready(playback)) { + webm_set_audio_paused( + playback->context, + paused || playback->speed == 0.0 + ); + } + return true; +} + +bool movie_playback_set_position( + s_movie_playback *playback, + uint64_t position, + int volume +) +{ + uint64_t position_ns; + + if(movie_playback_get_index(playback) < 0 || !playback->active) { + return false; + } + if(playback->duration && position >= playback->duration) { + position = playback->duration - 1U; + } + if(position > UINT64_MAX / UINT64_C(1000000)) { + return false; + } + + position_ns = position * UINT64_C(1000000); + if(!movie_playback_begin_media(playback, position_ns, volume)) { + movie_playback_stop(playback); + return false; + } + return true; +} + +bool movie_playback_set_repeat(s_movie_playback *playback, int repeat) +{ + if(movie_playback_get_index(playback) < 0) { + return false; + } + playback->repeat = repeat != 0; + return true; +} + +bool movie_playback_set_sound_channel( + s_movie_playback *playback, + int sound_channel, + int volume +) +{ + uint64_t position; + + if(movie_playback_get_index(playback) < 0 || + !playback->active || + sound_channel < 0 || + (unsigned int)sound_channel >= SOUND_CHANNEL_COUNT_MAX) { + return false; + } + if(playback->sound_channel == sound_channel) { + return true; + } + + position = movie_playback_position_now(playback, timer_uticks()); + playback->sound_channel = sound_channel; + if(!movie_playback_begin_media(playback, position, volume)) { + movie_playback_stop(playback); + return false; + } + return true; +} + +bool movie_playback_set_speed(s_movie_playback *playback, double speed) +{ + uint64_t now; + uint64_t position; + double previous_speed; + float applied_speed; + + if(movie_playback_get_index(playback) < 0 || + !playback->active || + !movie_playback_sanitize_speed(speed, &applied_speed)) { + return false; + } + if(playback->speed == applied_speed) { + return true; + } + + now = timer_uticks(); + position = movie_playback_position_now(playback, now); + previous_speed = playback->speed; + playback->position_anchor = position; + playback->clock_anchor = now; + playback->speed = applied_speed; + playback->position = position / UINT64_C(1000000); + + if(playback->speed > 0.0 && previous_speed == 0.0) { + if(!movie_playback_begin_media(playback, position, playback->volume)) { + movie_playback_stop(playback); + return false; + } + } else if(playback->speed > 0.0 && + movie_playback_decoder_ready(playback)) { + webm_set_audio_speed(playback->context, playback->speed); + webm_set_audio_paused(playback->context, playback->paused); + } else if(playback->speed == 0.0) { + playback->terminal_pending = 0; + if(movie_playback_decoder_ready(playback)) { + webm_set_audio_paused(playback->context, 1); + } + } + return true; +} + +bool movie_playback_set_width(s_movie_playback *playback, uint64_t width) +{ + if(movie_playback_get_index(playback) < 0 || + (width != MOVIE_SIZE_NATIVE && width > (uint64_t)INT_MAX)) { + return false; + } + playback->width = width; + playback->frame_dirty = 1; + return true; +} diff --git a/engine/source/webmlib/movie_playback.h b/engine/source/webmlib/movie_playback.h new file mode 100644 index 000000000..d90c3a4f7 --- /dev/null +++ b/engine/source/webmlib/movie_playback.h @@ -0,0 +1,125 @@ +/* + * OpenBOR - http://www.chronocrash.com + * ----------------------------------------------------------------------- + * All rights reserved. See LICENSE in OpenBOR root for license details. + * + * Copyright (c) OpenBOR Team + */ + +#ifndef MOVIE_PLAYBACK_H +#define MOVIE_PLAYBACK_H + +#include +#include + +#include "types.h" +#include "yuv.h" + +#define MOVIE_CHANNEL_COUNT 64U +#define MOVIE_CHANNEL_AUTO (-1) +#define MOVIE_SPEED_MIN 0.0 +#define MOVIE_SPEED_MAX 16.0 +#define MOVIE_SIZE_NATIVE UINT64_MAX + +typedef enum e_movie_loading_mode { + MOVIE_LOADING_STREAM, + MOVIE_LOADING_CACHE, + MOVIE_LOADING_AUTO, + MOVIE_LOADING_END +} e_movie_loading_mode; + +struct webm_context; + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Stable script-visible movie playback object. The source, +* decoder, frame, and clock members remain engine-managed. +*/ +typedef struct s_movie_playback { + e_object_type object_type; + int index; + int active; + int paused; + int repeat; + int interrupt; + int black_filter; + int offset_x; + int offset_y; + uint64_t width; + uint64_t height; + int sound_channel; + int source_id; + int failed; + float speed; + uint64_t duration; + uint64_t position; + + struct webm_context *context; + yuv_video_mode video_mode; + yuv_frame *current_frame; + yuv_frame *next_frame; + s_screen *rgb_frame; + s_screen *scaled_frame; + uint64_t clock_anchor; + uint64_t position_anchor; + int volume; + bool replace_all_audio; + int frame_dirty; + int rgb_frame_dirty; + int scaled_frame_dirty; + int terminal_pending; + int current_frame_presented; + uint64_t presentation_drop_count; + uint64_t maximum_presentation_lateness; +} s_movie_playback; + +bool movie_playback_init(void); +void movie_playback_shutdown(void); + +int movie_source_load(const char *path, e_movie_loading_mode loading_mode); +bool movie_source_unload(int source_id); + +s_movie_playback *movie_playback_play( + int source_id, + int movie_channel, + int volume, + bool replace_all_audio +); +s_movie_playback *movie_playback_get(int channel); +int movie_playback_get_index(const s_movie_playback *playback); +bool movie_playback_get_snapshot( + const s_movie_playback *playback, + s_movie_playback *snapshot +); +uint64_t movie_playback_get_active_mask(void); +void movie_playback_update(int interrupt_requested); +bool movie_playback_draw_to_screen(s_screen *screen, int channel); +void movie_playback_stop(s_movie_playback *playback); +void movie_playback_stop_all(void); + +bool movie_playback_set_height(s_movie_playback *playback, uint64_t height); +bool movie_playback_set_black_filter( + s_movie_playback *playback, + int black_filter +); +bool movie_playback_set_interrupt(s_movie_playback *playback, int interrupt); +bool movie_playback_set_offset_x(s_movie_playback *playback, int offset_x); +bool movie_playback_set_offset_y(s_movie_playback *playback, int offset_y); +bool movie_playback_set_paused(s_movie_playback *playback, int paused); +bool movie_playback_set_position( + s_movie_playback *playback, + uint64_t position, + int volume +); +bool movie_playback_set_repeat(s_movie_playback *playback, int repeat); +bool movie_playback_set_sound_channel( + s_movie_playback *playback, + int sound_channel, + int volume +); +bool movie_playback_set_speed(s_movie_playback *playback, double speed); +bool movie_playback_set_width(s_movie_playback *playback, uint64_t width); + +#endif diff --git a/engine/source/webmlib/vidplay.c b/engine/source/webmlib/vidplay.c index 338f1c91f..541377680 100644 --- a/engine/source/webmlib/vidplay.c +++ b/engine/source/webmlib/vidplay.c @@ -10,8 +10,10 @@ #include #include #include +#include #include #include +#include #include "nestegg/nestegg.h" // libvpx @@ -28,17 +30,47 @@ #include "globals.h" #include "borendian.h" #include "soundmix.h" +#include "timer.h" -// lowering these might save a bit of memory but could also cause lag -#define PACKET_QUEUE_SIZE 20 -#define FRAME_QUEUE_SIZE 10 +/* +* Caskey, Damon V. +* 2026-08-18 +* +* Configure bounded decoder reserves and worker priorities. Sixty-four audio +* packets absorb Vorbis scheduling jitter. One hundred twenty-eight compressed +* video packets cover roughly two seconds at 60 FPS. Six decoded YUV frames +* provide 100-200 milliseconds of presentation read-ahead across 30-60 FPS, +* with three required for preroll. Full decoded-frame queues block their +* producer to preserve presentation order. Audio and demux producers use high +* priority; VP8 decoding uses normal priority to preserve engine capacity. PCM +* output uses an eight-buffer ring. +*/ +#define AUDIO_PACKET_QUEUE_SIZE 64 +#define VIDEO_PACKET_QUEUE_SIZE 128 +#define FRAME_QUEUE_SIZE 6 +#define VIDEO_DECODER_THREAD_MAX 8U +#define AUDIO_RECOVERY_PCM_BUFFER_COUNT \ + (SOUND_STREAM_BUFFER_COUNT / 2U) +#define AUDIO_PREROLL_BUFFER_COUNT ((SOUND_STREAM_BUFFER_COUNT * 3U) / 4U) +#define VIDEO_PREROLL_FRAME_COUNT ((FRAME_QUEUE_SIZE + 1) / 2) +#define WEBM_PREROLL_TIMEOUT_MICROSECONDS UINT64_C(2000000) #define debug_printf(...) //printf(__VA_ARGS__) +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Decoder queues and track contexts retain a pointer to +* their owning playback's independent stop state. +*/ typedef struct { int start; int size; int max_size; + uint64_t replacement_count; + uint64_t resync_count; + SDL_atomic_t *quit; bor_mutex *mutex; bor_cond *not_full; bor_cond *not_empty; @@ -47,12 +79,23 @@ typedef struct { typedef struct { FixedSizeQueue *packet_queue; + FixedSizeQueue *video_packet_queue; + FixedSizeQueue *video_frame_queue; vorbis_context vorbis_ctx; int frequency; int avail_samples; int last_samples; int channel; int stream_play_id; + int output_active; + int output_paused; + int aligning; + uint64_t observed_underrun_count; + uint64_t reported_underrun_count; + uint64_t playback_start_timestamp; + uint64_t output_timestamp; + uint64_t leading_silence_frames; + SDL_atomic_t *quit; uint8_t pcm_buffer[SOUND_STREAM_BUFFER_SIZE]; } audio_context; @@ -64,12 +107,22 @@ typedef struct { int height; int display_width; int display_height; + int playback_paused; uint64_t frame_delay; + SDL_atomic_t *quit; } video_context; +typedef struct { + int packhandle; + packfile_signed_offset_t stream_size; + const unsigned char *cache_buffer; + size_t cache_size; + size_t cache_position; +} webm_io_context; + // see header for typedef struct webm_context { - int packhandle; + webm_io_context io_ctx; nestegg *nestegg_ctx; audio_context audio_ctx; video_context video_ctx; @@ -79,39 +132,266 @@ struct webm_context { bor_thread *the_demux_thread; bor_thread *the_video_thread; bor_thread *the_audio_thread; + bor_thread *the_lifecycle_thread; + bor_mutex *lifecycle_mutex; + bor_cond *lifecycle_condition; + char *path; + uint64_t duration; + uint64_t seek_timestamp; + e_webm_decoder_state decoder_state; + int volume; + int sound_channel; + int play_audio; + int replace_all_audio; + int close_requested; + int video_initialized; + int audio_initialized; + SDL_atomic_t quit; }; +static bor_mutex *webm_lifecycle_operation_mutex; + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Read the decoder stop flag with SDL atomics. Queue, demux, codec, and +* lifecycle workers share this flag without relying on volatile accesses +* that provide no cross-thread synchronization in C. +*/ +static int webm_stop_is_requested(SDL_atomic_t *stop) +{ + return !stop || SDL_AtomicGet(stop) != 0; +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Publish decoder cancellation through the same SDL atomic consumed by every +* queue and worker, preserving one stop path across lifecycle transitions. +*/ +static void webm_request_decoder_stop(SDL_atomic_t *stop) +{ + if(stop) { + SDL_AtomicSet(stop, 1); + } +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Serialize decoder resource acquisition and release on lifecycle workers. +* Independent decoding remains concurrent, while sound-channel replacement, +* container setup, and teardown cannot overtake one another during rapid +* asynchronous channel replacement. +*/ +int webm_lifecycle_init(void) +{ + if(webm_lifecycle_operation_mutex) { + return 1; + } + webm_lifecycle_operation_mutex = mutex_create(); + return webm_lifecycle_operation_mutex != NULL; +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Release shared lifecycle serialization after every decoder has reached +* CLOSED and its lifecycle thread has been reaped during engine shutdown. +*/ +void webm_lifecycle_shutdown(void) +{ + if(webm_lifecycle_operation_mutex) { + mutex_destroy(webm_lifecycle_operation_mutex); + webm_lifecycle_operation_mutex = NULL; + } +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Retain the source path for the lifetime of an asynchronous decoder open. +* The movie source registry may otherwise release its caller-owned string +* before the lifecycle worker has finished opening a streamed source. +*/ +static char *webm_copy_path(const char *path) +{ + char *copy; + size_t length; + + if(!path || !path[0]) { + return NULL; + } + length = strlen(path) + 1U; + copy = malloc(length); + if(copy) { + memcpy(copy, path, length); + } + return copy; +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Read asynchronous cancellation under the lifecycle mutex. The engine +* thread publishes only this request; the lifecycle worker remains the sole +* writer of the decoder stop flag consumed by demux and codec workers. +*/ +static int webm_close_is_requested(webm_context *ctx) +{ + int close_requested; -static int quit_video; + mutex_lock(ctx->lifecycle_mutex); + close_requested = ctx->close_requested; + mutex_unlock(ctx->lifecycle_mutex); + return close_requested; +} -int webm_read(void *buffer, size_t length, void *userdata) +/* +* Caskey, Damon V. +* 2026-08-13 +* +* Read or seek either a creator-requested memory cache or the +* ordinary packfile stream through the same Nestegg I/O. Streamed +* reads supply complete spans, while successful seeks return zero +* as required by the decoder callback contract. +*/ +static int webm_io_read(void *buffer, size_t length, void *userdata) { - int bytesRead = readpackfile((int)(size_t)userdata, buffer, length); - if (bytesRead < 0) return -1; - else if (bytesRead == 0) return 0; - else return 1; + webm_io_context *io_ctx = userdata; + packfile_signed_offset_t stream_position; + size_t read_position = 0; + int bytesRead; + + if(io_ctx->cache_buffer) { + size_t remaining; + + if(io_ctx->cache_position > io_ctx->cache_size) { + return -1; + } + remaining = io_ctx->cache_size - io_ctx->cache_position; + if(remaining < length) { + return 0; + } + memcpy( + buffer, + io_ctx->cache_buffer + io_ctx->cache_position, + length + ); + io_ctx->cache_position += length; + return 1; + } + + stream_position = seekpackfile64(io_ctx->packhandle, 0, SEEK_CUR); + if(stream_position < 0 || + stream_position > io_ctx->stream_size || + (uint64_t)length > + (uint64_t)(io_ctx->stream_size - stream_position)) { + return stream_position < 0 ? -1 : 0; + } + + while(read_position < length) { + size_t remaining = length - read_position; + int read_length = remaining > (size_t)INT_MAX + ? INT_MAX + : (int)remaining; + + bytesRead = readpackfile( + io_ctx->packhandle, + (unsigned char*)buffer + read_position, + read_length + ); + if(bytesRead <= 0) { + return -1; + } + read_position += (size_t)bytesRead; + } + if(read_position != length) { + return -1; + } + return 1; } -int webm_seek(int64_t offset, int whence, void *userdata) +static int webm_io_seek(int64_t offset, int whence, void *userdata) { - return seekpackfile((int)(size_t)userdata, (int)offset, whence); + webm_io_context *io_ctx = userdata; + + if(io_ctx->cache_buffer) { + int64_t base; + int64_t position; + + switch(whence) { + case SEEK_SET: + base = 0; + break; + case SEEK_CUR: + base = (int64_t)io_ctx->cache_position; + break; + case SEEK_END: + base = (int64_t)io_ctx->cache_size; + break; + default: + return -1; + } + + position = base + offset; + if(position < 0 || (uint64_t)position > (uint64_t)io_ctx->cache_size) { + return -1; + } + io_ctx->cache_position = (size_t)position; + return 0; + } + + return seekpackfile64( + io_ctx->packhandle, + (packfile_signed_offset_t)offset, + whence + ) < 0 ? -1 : 0; } -int64_t webm_tell(void *userdata) +static int64_t webm_io_tell(void *userdata) { - return seekpackfile((int)(size_t)userdata, 0, SEEK_CUR); + webm_io_context *io_ctx = userdata; + + if(io_ctx->cache_buffer) { + return (int64_t)io_ctx->cache_position; + } + return seekpackfile64(io_ctx->packhandle, 0, SEEK_CUR); } -FixedSizeQueue *queue_init(int max_size) +static FixedSizeQueue *queue_init(int max_size, SDL_atomic_t *quit) { FixedSizeQueue *queue; + + if(max_size < 1 || !quit) { + return NULL; + } queue = malloc(sizeof(*queue) - sizeof(queue->data) + (max_size * sizeof(void *))); + if(!queue) { + return NULL; + } queue->start = 0; queue->size = 0; queue->max_size = max_size; + queue->replacement_count = 0; + queue->resync_count = 0; + queue->quit = quit; queue->mutex = mutex_create(); queue->not_full = cond_create(); queue->not_empty = cond_create(); + if(!queue->mutex || !queue->not_full || !queue->not_empty) { + if(queue->not_full) cond_destroy(queue->not_full); + if(queue->not_empty) cond_destroy(queue->not_empty); + if(queue->mutex) mutex_destroy(queue->mutex); + free(queue); + return NULL; + } return queue; } @@ -127,7 +407,7 @@ int queue_insert(FixedSizeQueue *queue, void *data) { while(cond_wait_timed(queue->not_full, queue->mutex, 10) != 0) { - if (quit_video) + if(webm_stop_is_requested(queue->quit)) { mutex_unlock(queue->mutex); return -1; @@ -155,7 +435,7 @@ void *queue_get(FixedSizeQueue *queue) { while (cond_wait_timed(queue->not_empty, queue->mutex, 10) != 0) { - if (quit_video) + if(webm_stop_is_requested(queue->quit)) { mutex_unlock(queue->mutex); return NULL; @@ -173,6 +453,308 @@ void *queue_get(FixedSizeQueue *queue) return data; } +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Remove one queued item without stalling the main +* engine thread when a decoder has not produced it yet. +*/ +static int queue_try_get(FixedSizeQueue *queue, void **data) +{ + if(!queue || !data) { + return 0; + } + + mutex_lock(queue->mutex); + if(queue->size == 0) { + mutex_unlock(queue->mutex); + return 0; + } + + *data = queue->data[queue->start]; + --queue->size; + queue->start = (queue->start + 1) % queue->max_size; + cond_signal(queue->not_full); + mutex_unlock(queue->mutex); + return 1; +} + +/* +* Caskey, Damon V. +* 2026-08-15 +* +* Snapshot queue occupancy and cumulative discard and resynchronization totals +* under the queue lock for diagnostics. +*/ +static int queue_get_status( + FixedSizeQueue *queue, + uint64_t *replacement_count, + uint64_t *resync_count +) +{ + int size; + + if(replacement_count) { + *replacement_count = 0; + } + if(resync_count) { + *resync_count = 0; + } + if(!queue) { + return -1; + } + + mutex_lock(queue->mutex); + size = queue->size; + if(replacement_count) { + *replacement_count = queue->replacement_count; + } + if(resync_count) { + *resync_count = queue->resync_count; + } + mutex_unlock(queue->mutex); + return size; +} + +/* +* Caskey, Damon V. +* 2026-08-15 +* +* Identify a VP8 keyframe from its uncompressed frame tag and start code. +* WebM video packets may contain laced frames; recovery resumes only when +* the first frame in a packet establishes an independent decode sequence. +*/ +static int video_packet_is_keyframe(nestegg_packet *packet) +{ + unsigned char *data; + size_t data_size; + + if(!packet || + nestegg_packet_data(packet, 0, &data, &data_size) < 0 || + !data || + data_size < 10U) { + return 0; + } + + return !(data[0] & 1U) && + data[3] == 0x9dU && + data[4] == 0x01U && + data[5] == 0x2aU; +} + +/* +* Caskey, Damon V. +* 2026-08-15 +* +* Read playback pause state under the same frame-queue lock used by +* webm_set_audio_paused(). Paused movies retain their complete compressed +* and decoded queues because their audio reserve is intentionally idle. +*/ +static int video_playback_is_paused(video_context *video_ctx) +{ + int paused; + + if(!video_ctx || !video_ctx->frame_queue) { + return 1; + } + + mutex_lock(video_ctx->frame_queue->mutex); + paused = video_ctx->playback_paused; + mutex_unlock(video_ctx->frame_queue->mutex); + return paused; +} + +/* +* Caskey, Damon V. +* 2026-08-15 +* +* Authorize compressed-video recovery during active playback when the audio +* packet queue is empty and the PCM reserve reaches its critical threshold. +*/ +static int webm_audio_reserve_is_critical(webm_context *ctx) +{ + s_sound_pcm_stream_status status; + + if(!ctx || + ctx->audio_track < 0 || + video_playback_is_paused(&ctx->video_ctx) || + queue_get_status(ctx->audio_ctx.packet_queue, NULL, NULL) != 0 || + !sound_get_channel_pcm_stream_status_owned( + ctx->audio_ctx.channel, + ctx->audio_ctx.stream_play_id, + &status + )) { + return 0; + } + + return !status.producer_finished && + status.ready_buffer_count <= AUDIO_RECOVERY_PCM_BUFFER_COUNT; +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Try to publish the first verified keyframe following a compressed-video +* queue reset without waiting. The demuxer is the queue's only producer, so +* capacity remains available after the stale backlog has been discarded. +*/ +static int video_packet_queue_try_insert( + FixedSizeQueue *queue, + nestegg_packet *packet +) +{ + int index; + + if(!queue || !packet) { + return -1; + } + + mutex_lock(queue->mutex); + if(webm_stop_is_requested(queue->quit)) { + mutex_unlock(queue->mutex); + return -1; + } + if(queue->size == queue->max_size) { + mutex_unlock(queue->mutex); + return 0; + } + index = (queue->start + queue->size) % queue->max_size; + queue->data[index] = packet; + queue->size++; + cond_signal(queue->not_empty); + mutex_unlock(queue->mutex); + return 1; +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Atomically discard the stale compressed-video backlog and optionally make +* the packet that triggered recovery its new head when that packet is already +* a verified keyframe. Packet destruction occurs after releasing the queue +* lock so the decoder can resume immediately from the replacement boundary. +*/ +static int video_packet_queue_begin_resync( + FixedSizeQueue *queue, + nestegg_packet *recovery_keyframe +) +{ + nestegg_packet *discarded_packets[VIDEO_PACKET_QUEUE_SIZE]; + int discarded_count; + int i; + + if(!queue || + queue->max_size < 1 || + queue->max_size > VIDEO_PACKET_QUEUE_SIZE) { + return -1; + } + + mutex_lock(queue->mutex); + if(webm_stop_is_requested(queue->quit)) { + mutex_unlock(queue->mutex); + return -1; + } + discarded_count = queue->size; + for(i = 0; i < discarded_count; ++i) { + int index = (queue->start + i) % queue->max_size; + + discarded_packets[i] = queue->data[index]; + queue->data[index] = NULL; + } + queue->start = 0; + queue->size = 0; + if((uint64_t)discarded_count > + UINT64_MAX - queue->replacement_count) { + queue->replacement_count = UINT64_MAX; + } + else { + queue->replacement_count += (uint64_t)discarded_count; + } + if(queue->resync_count < UINT64_MAX) { + queue->resync_count++; + } + if(recovery_keyframe) { + queue->data[0] = recovery_keyframe; + queue->size = 1; + cond_signal(queue->not_empty); + } + cond_signal(queue->not_full); + mutex_unlock(queue->mutex); + + for(i = 0; i < discarded_count; ++i) { + if(discarded_packets[i]) { + nestegg_free_packet(discarded_packets[i]); + } + } + return 1; +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Record a newly demuxed packet discarded while waiting for the first +* verified keyframe after a compressed-video queue reset. +*/ +static void video_packet_queue_record_drop(FixedSizeQueue *queue) +{ + if(!queue) { + return; + } + + mutex_lock(queue->mutex); + if(queue->replacement_count < UINT64_MAX) { + queue->replacement_count++; + } + mutex_unlock(queue->mutex); +} + +/* +* Caskey, Damon V. +* 2026-08-15 +* +* Insert compressed video with bounded blocking while audio has usable +* reserves. Return zero without consuming the packet to request keyframe +* recovery when the audio reserve becomes critical. +*/ +static int video_packet_queue_insert( + webm_context *ctx, + nestegg_packet *packet +) +{ + FixedSizeQueue *queue; + + if(!ctx || !(queue = ctx->video_ctx.packet_queue)) { + return -1; + } + + while(!webm_stop_is_requested(&ctx->quit)) { + int index; + int queue_full; + + mutex_lock(queue->mutex); + if(queue->size < queue->max_size) { + index = (queue->start + queue->size) % queue->max_size; + queue->data[index] = packet; + queue->size++; + cond_signal(queue->not_empty); + mutex_unlock(queue->mutex); + return 1; + } + + cond_wait_timed(queue->not_full, queue->mutex, 10); + queue_full = queue->size == queue->max_size; + mutex_unlock(queue->mutex); + if(queue_full && webm_audio_reserve_is_critical(ctx)) { + return 0; + } + } + return -1; +} + void queue_destroy(FixedSizeQueue *queue) { cond_destroy(queue->not_full); @@ -181,10 +763,12 @@ void queue_destroy(FixedSizeQueue *queue) free(queue); } -// used to keep playing current BGM in videos with no audio track +// Service existing BGM for videos without an audio track. static int bgm_update_thread(void *data) { - while (!quit_video) + webm_context *ctx = data; + + while(!webm_stop_is_requested(&ctx->quit)) { sound_update_music(); usleep(5000); @@ -192,6 +776,127 @@ static int bgm_update_thread(void *data) return 0; } +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Convert a nanosecond interval to the number of complete PCM +* frames needed to reach or pass its end without overflowing. +*/ +static uint64_t audio_nanoseconds_to_frames( + uint64_t nanoseconds, + int frequency +) +{ + const uint64_t nanoseconds_per_second = UINT64_C(1000000000); + uint64_t whole_seconds; + uint64_t remainder; + uint64_t frames; + uint64_t remainder_frames; + + if(frequency <= 0) { + return UINT64_MAX; + } + + whole_seconds = nanoseconds / nanoseconds_per_second; + remainder = nanoseconds % nanoseconds_per_second; + if(whole_seconds > UINT64_MAX / (uint64_t)frequency) { + return UINT64_MAX; + } + frames = whole_seconds * (uint64_t)frequency; + remainder_frames = remainder * (uint64_t)frequency; + if(remainder_frames) { + remainder_frames = + ((remainder_frames - 1U) / nanoseconds_per_second) + 1U; + } + if(frames > UINT64_MAX - remainder_frames) { + return UINT64_MAX; + } + return frames + remainder_frames; +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Convert a consumed PCM source-frame count to its media timeline duration +* without overflowing the 64-bit nanosecond clock. Whole seconds are split +* before multiplication so long-running streams retain bounded arithmetic. +*/ +static uint64_t audio_frames_to_nanoseconds( + uint64_t frame_count, + int frequency +) +{ + const uint64_t nanoseconds_per_second = UINT64_C(1000000000); + uint64_t whole_seconds; + uint64_t remainder_frames; + uint64_t remainder_nanoseconds; + uint64_t nanoseconds; + + if(frequency <= 0) { + return UINT64_MAX; + } + + whole_seconds = frame_count / (uint64_t)frequency; + remainder_frames = frame_count % (uint64_t)frequency; + if(whole_seconds > UINT64_MAX / nanoseconds_per_second) { + return UINT64_MAX; + } + nanoseconds = whole_seconds * nanoseconds_per_second; + remainder_nanoseconds = + remainder_frames * nanoseconds_per_second / (uint64_t)frequency; + return nanoseconds <= UINT64_MAX - remainder_nanoseconds + ? nanoseconds + remainder_nanoseconds + : UINT64_MAX; +} + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Align decoded audio to the requested movie timestamp. Samples +* preceding the target are consumed silently. If Nestegg returns +* the first audio packet after the target, equivalent silence is +* inserted so audio retains its position on the movie timeline. +*/ +static void audio_align_packet( + audio_context *audio_ctx, + uint64_t packet_timestamp +) +{ + uint64_t frame_count; + int available_samples; + int skipped_samples; + + if(!audio_ctx->aligning || audio_ctx->avail_samples <= 0) { + return; + } + + if(packet_timestamp < audio_ctx->output_timestamp) { + available_samples = audio_ctx->avail_samples; + frame_count = audio_nanoseconds_to_frames( + audio_ctx->output_timestamp - packet_timestamp, + audio_ctx->frequency + ); + skipped_samples = frame_count < (uint64_t)available_samples + ? (int)frame_count + : available_samples; + vorbis_skip_pcm(&audio_ctx->vorbis_ctx, (size_t)skipped_samples); + audio_ctx->avail_samples -= skipped_samples; + if(skipped_samples < available_samples) { + audio_ctx->aligning = 0; + } + return; + } + + audio_ctx->leading_silence_frames = audio_nanoseconds_to_frames( + packet_timestamp - audio_ctx->output_timestamp, + audio_ctx->frequency + ); + audio_ctx->aligning = 0; +} + static int audio_decode_frame( audio_context *audio_ctx, uint8_t *audio_buf, @@ -208,6 +913,25 @@ static int audio_decode_frame( while (samples) { + if(audio_ctx->leading_silence_frames) { + int silence_samples = audio_ctx->leading_silence_frames < + (uint64_t)samples + ? (int)audio_ctx->leading_silence_frames + : samples; + size_t silence_bytes = + (size_t)silence_samples * + (size_t)vorbis_ctx->channels * + sizeof(int16_t); + + memset(audio_buf, 0, silence_bytes); + audio_buf += silence_bytes; + audio_ctx->leading_silence_frames -= + (uint64_t)silence_samples; + samples -= silence_samples; + samples_written += silence_samples; + continue; + } + if (audio_ctx->avail_samples == 0) { nestegg_packet *pkt; @@ -231,6 +955,8 @@ static int audio_decode_frame( audio_ctx->avail_samples = vorbis_packet(vorbis_ctx, data, data_size); } nestegg_free_packet(pkt); + audio_align_packet(audio_ctx, timestamp); + continue; } int samples_read = MIN(audio_ctx->avail_samples, samples); @@ -245,6 +971,14 @@ static int audio_decode_frame( return samples_written * vorbis_ctx->channels * (int)sizeof(int16_t); } +/* +* Caskey, Damon V. +* 2026-08-15 +* +* Decode and publish WebM audio from an elevated-priority producer. +* High-resolution VP8 decoding and software composition must not prevent +* this thread from replenishing the real-time mixer's PCM reserve. +*/ static int audio_thread(void *data) { audio_context *audio_ctx = (audio_context *)data; @@ -252,8 +986,13 @@ static int audio_thread(void *data) int frame_count; int queue_result; int terminal; + unsigned int retry_delay_microseconds; + uint64_t underrun_count; - while(!quit_video) + /* Priority elevation may be denied on restricted platforms. */ + SDL_SetThreadPriority(SDL_THREAD_PRIORITY_HIGH); + + while(!webm_stop_is_requested(audio_ctx->quit)) { decoded_bytes = audio_decode_frame( audio_ctx, @@ -278,20 +1017,94 @@ static int audio_thread(void *data) } #endif - do { - queue_result = sound_queue_channel_pcm_stream( - audio_ctx->channel, - audio_ctx->stream_play_id, - frame_count > 0 ? audio_ctx->pcm_buffer : NULL, - (uint64_t)frame_count, - terminal - ); - if(queue_result == 0) { - usleep(1000); + queue_result = 1; + if(audio_ctx->output_active) { + do { + queue_result = sound_queue_channel_pcm_stream( + audio_ctx->channel, + audio_ctx->stream_play_id, + frame_count > 0 ? audio_ctx->pcm_buffer : NULL, + (uint64_t)frame_count, + terminal, + &underrun_count, + &retry_delay_microseconds + ); + if(queue_result == 0) { + usleep(retry_delay_microseconds); + } + } while(!webm_stop_is_requested(audio_ctx->quit) && + queue_result == 0); + + if(queue_result > 0 && + underrun_count != audio_ctx->observed_underrun_count) { + audio_ctx->observed_underrun_count = underrun_count; + if(!audio_ctx->reported_underrun_count || + (underrun_count > audio_ctx->reported_underrun_count && + underrun_count - audio_ctx->reported_underrun_count >= + UINT64_C(16))) { + s_sound_pcm_stream_status status = { 0 }; + uint64_t dropped_packets; + uint64_t resync_count; + int audio_packets; + int decoded_frames; + int video_packets; + + sound_get_channel_pcm_stream_status_owned( + audio_ctx->channel, + audio_ctx->stream_play_id, + &status + ); + audio_packets = queue_get_status( + audio_ctx->packet_queue, + NULL, + NULL + ); + video_packets = queue_get_status( + audio_ctx->video_packet_queue, + &dropped_packets, + &resync_count + ); + decoded_frames = queue_get_status( + audio_ctx->video_frame_queue, + NULL, + NULL + ); + audio_ctx->reported_underrun_count = underrun_count; + printf( + "\nWarning: WebM audio underrun on sound channel %d " + "(%" PRIu64 " total): PCM=%u/%u, " + "packets=%d/%d audio and %d/%d video, " + "frames=%d/%d, " + "recovery=%" PRIu64 " compressed packets discarded in %" PRIu64 + " keyframe resync event%s.\n", + audio_ctx->channel, + underrun_count, + status.ready_buffer_count, + SOUND_STREAM_BUFFER_COUNT, + audio_packets, + AUDIO_PACKET_QUEUE_SIZE, + video_packets, + VIDEO_PACKET_QUEUE_SIZE, + decoded_frames, + FRAME_QUEUE_SIZE, + dropped_packets, + resync_count, + resync_count == 1U ? "" : "s" + ); + } } - } while(!quit_video && queue_result == 0); - if(queue_result < 0 || terminal) { + if(queue_result < 0) { + /* + * Keep draining this track when another producer replaces + * its sound channel. Otherwise the full audio packet queue + * would also stall video demuxing. + */ + audio_ctx->output_active = 0; + } + } + + if(terminal) { return 0; } } @@ -299,62 +1112,266 @@ static int audio_thread(void *data) return 0; } -static void init_audio(nestegg *ctx, int track, audio_context *audio_ctx, int volume) +/* +* Caskey, Damon V. +* 2026-08-18 +* +* Snapshot the decoded-video preroll reserve under its queue lock. A terminal +* marker satisfies preroll for streams containing fewer than three frames. +*/ +static int video_preroll_ready( + video_context *video_ctx, + int *decoded_frame_count +) { - // read vorbis header and initialize vorbis decoding - unsigned chunk, chunks; - vorbis_init(&(audio_ctx->vorbis_ctx)); - nestegg_track_codec_data_count(ctx, track, &chunks); - assert(chunks == 3); - for (chunk=0; chunkvorbis_ctx), data, data_size, chunk); - } + FixedSizeQueue *queue; + int decoded_frames = 0; + int terminal = 0; + int i; - // initialize audio decoding context - vorbis_prepare(&(audio_ctx->vorbis_ctx)); - nestegg_audio_params audioParams; - nestegg_track_audio_params(ctx, track, &audioParams); - audio_ctx->vorbis_ctx.channels = audioParams.channels; - audio_ctx->frequency = (int)audioParams.rate; - audio_ctx->avail_samples = audio_ctx->last_samples = 0; - audio_ctx->packet_queue = queue_init(PACKET_QUEUE_SIZE); - printf("Audio track: %f Hz, %d channels, %d bits/sample\n", - audioParams.rate, audioParams.channels, audioParams.depth / audioParams.channels); - if(audio_ctx->frequency < SOUND_MUSIC_FREQUENCY_MIN || - audio_ctx->frequency > SOUND_MUSIC_FREQUENCY_MAX) - { - printf("Warning: the audio frequency (%i Hz) is outside the supported %i-%i Hz range\n", - audio_ctx->frequency, - SOUND_MUSIC_FREQUENCY_MIN, - SOUND_MUSIC_FREQUENCY_MAX); + if(decoded_frame_count) { + *decoded_frame_count = 0; + } + if(!video_ctx || !(queue = video_ctx->frame_queue)) { + return 0; } - /* WebM audio retains its legacy behavior by replacing all playback. */ - sound_stopall_sample(true); - audio_ctx->channel = SOUND_CHANNEL_MUSIC_DEFAULT; - audio_ctx->stream_play_id = sound_open_channel_pcm_stream( - audio_ctx->channel, - audio_ctx->frequency, + mutex_lock(queue->mutex); + for(i = 0; i < queue->size; ++i) { + int index = (queue->start + i) % queue->max_size; + + if(queue->data[index]) { + ++decoded_frames; + } + else { + terminal = 1; + } + } + mutex_unlock(queue->mutex); + + if(decoded_frame_count) { + *decoded_frame_count = decoded_frames; + } + return decoded_frames >= VIDEO_PREROLL_FRAME_COUNT || terminal; +} + +/* +* Caskey, Damon V. +* 2026-08-18 +* +* Hold the asynchronous READY boundary until decoded video and owned PCM have +* useful reserves. Silent and short streams satisfy preroll through track +* absence or a terminal marker. A bounded timeout limits initialization delay. +*/ +static void webm_wait_for_preroll(webm_context *ctx) +{ + uint64_t start_time; + + if(!ctx || !ctx->video_initialized) { + return; + } + start_time = timer_uticks(); + + while(!webm_stop_is_requested(&ctx->quit) && + !webm_close_is_requested(ctx)) { + s_sound_pcm_stream_status status = { 0 }; + uint64_t now; + int audio_ready = !ctx->audio_initialized; + int decoded_frame_count = 0; + int video_ready = video_preroll_ready( + &ctx->video_ctx, + &decoded_frame_count + ); + + if(ctx->audio_initialized) { + audio_context *audio_ctx = &ctx->audio_ctx; + + if(!sound_get_channel_pcm_stream_status_owned( + audio_ctx->channel, + audio_ctx->stream_play_id, + &status + )) { + audio_ready = 1; + } + else { + audio_ctx->observed_underrun_count = + status.underrun_count; + audio_ready = + status.ready_buffer_count >= + AUDIO_PREROLL_BUFFER_COUNT || + status.producer_finished; + } + } + if(video_ready && audio_ready) { + return; + } + + now = timer_uticks(); + if(now < start_time || + now - start_time >= WEBM_PREROLL_TIMEOUT_MICROSECONDS) { + printf( + "\nWarning: WebM preroll timed out with video=%d/%d " + "decoded frames and audio=%u/%u PCM buffers.\n", + decoded_frame_count, + VIDEO_PREROLL_FRAME_COUNT, + status.ready_buffer_count, + ctx->audio_initialized + ? AUDIO_PREROLL_BUFFER_COUNT + : 0U + ); + return; + } + usleep(1000); + } +} + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Initialize WebM audio on an explicit generic channel with play-ID ownership +* and optional exclusive audio control. +*/ +static int init_audio( + nestegg *ctx, + int track, + audio_context *audio_ctx, + int volume, + int sound_channel, + uint64_t seek_timestamp, + int replace_all_audio, + SDL_atomic_t *quit +) +{ + // read vorbis header and initialize vorbis decoding + unsigned chunk, chunks; + nestegg_audio_params audioParams; + + vorbis_init(&(audio_ctx->vorbis_ctx)); + if(nestegg_track_codec_data_count(ctx, track, &chunks) < 0 || chunks != 3) { + vorbis_destroy(&(audio_ctx->vorbis_ctx)); + return -1; + } + for (chunk=0; chunkvorbis_ctx)); + return -1; + } + vorbis_headerpacket(&(audio_ctx->vorbis_ctx), data, data_size, chunk); + } + + // initialize audio decoding context + vorbis_prepare(&(audio_ctx->vorbis_ctx)); + if(nestegg_track_audio_params(ctx, track, &audioParams) < 0 || + audioParams.rate < 1.0 || + audioParams.rate > (double)INT_MAX || + (audioParams.channels != CHANNEL_TYPE_MONO && + audioParams.channels != CHANNEL_TYPE_STEREO)) { + vorbis_destroy(&(audio_ctx->vorbis_ctx)); + return -1; + } + audio_ctx->vorbis_ctx.channels = audioParams.channels; + audio_ctx->frequency = (int)audioParams.rate; + audio_ctx->avail_samples = audio_ctx->last_samples = 0; + audio_ctx->aligning = 1; + audio_ctx->playback_start_timestamp = seek_timestamp; + audio_ctx->output_timestamp = seek_timestamp <= + UINT64_MAX - audioParams.codec_delay + ? seek_timestamp + audioParams.codec_delay + : UINT64_MAX; + audio_ctx->quit = quit; + audio_ctx->packet_queue = queue_init(AUDIO_PACKET_QUEUE_SIZE, quit); + if(!audio_ctx->packet_queue) { + vorbis_destroy(&(audio_ctx->vorbis_ctx)); + return -1; + } + printf("Audio track: %f Hz, %d channels, %d bits/sample\n", + audioParams.rate, audioParams.channels, audioParams.depth); + if(audio_ctx->frequency < SOUND_MUSIC_FREQUENCY_MIN || + audio_ctx->frequency > SOUND_MUSIC_FREQUENCY_MAX) + { + printf("\nWarning: the audio frequency (%i Hz) is outside the supported %i-%i Hz range\n", + audio_ctx->frequency, + SOUND_MUSIC_FREQUENCY_MIN, + SOUND_MUSIC_FREQUENCY_MAX); + } + + if(replace_all_audio) { + /* Fullscreen playback requests exclusive audio ownership. */ + sound_stopall_sample(true); + } + audio_ctx->channel = sound_channel; + audio_ctx->stream_play_id = sound_open_channel_pcm_stream( + audio_ctx->channel, + audio_ctx->frequency, audioParams.channels, volume ); - assert(audio_ctx->stream_play_id >= 0); - sound_volume_music(volume, volume); + if(audio_ctx->stream_play_id < 0) { + queue_destroy(audio_ctx->packet_queue); + audio_ctx->packet_queue = NULL; + vorbis_destroy(&(audio_ctx->vorbis_ctx)); + return -1; + } + + audio_ctx->output_active = 1; + sound_set_channel_volume( + audio_ctx->channel, + SOUND_SPATIAL_CHANNEL_LEFT, + volume + ); + sound_set_channel_volume( + audio_ctx->channel, + SOUND_SPATIAL_CHANNEL_RIGHT, + volume + ); + sound_set_channel_volume_divisor( + audio_ctx->channel, + SOUND_VOLUME_DIVISOR_MUSIC + ); + if(!sound_pause_channel_owned( + audio_ctx->channel, + audio_ctx->stream_play_id, + 1 + )) { + sound_close_channel_pcm_stream( + audio_ctx->channel, + audio_ctx->stream_play_id + ); + queue_destroy(audio_ctx->packet_queue); + audio_ctx->packet_queue = NULL; + vorbis_destroy(&(audio_ctx->vorbis_ctx)); + return -1; + } + audio_ctx->output_paused = 1; + { + s_sound_pcm_stream_status status; + + if(sound_get_channel_pcm_stream_status_owned( + audio_ctx->channel, + audio_ctx->stream_play_id, + &status + )) { + audio_ctx->observed_underrun_count = status.underrun_count; + } + } + return 0; } static void close_audio(audio_context *audio_ctx) { // empty and free the packet queue - while(audio_ctx->packet_queue->size) + while(audio_ctx->packet_queue && audio_ctx->packet_queue->size) { nestegg_packet *packet = queue_get(audio_ctx->packet_queue); if(packet) nestegg_free_packet(packet); } - queue_destroy(audio_ctx->packet_queue); + if(audio_ctx->packet_queue) { + queue_destroy(audio_ctx->packet_queue); + } // close the vorbis decoding context vorbis_destroy(&(audio_ctx->vorbis_ctx)); @@ -365,21 +1382,50 @@ static void close_audio(audio_context *audio_ctx) ); } +/* +* Caskey, Damon V. +* 2026-08-13 +* +* Resolve timestamps for laced video frames without requiring the +* optional WebM DefaultDuration element. Prefer the track default, +* then the packet duration, and finally preserve decode order with +* the smallest representable timestamp step. +*/ +static uint64_t video_packet_frame_delay( + video_context *video_ctx, + nestegg_packet *packet, + unsigned int chunks +) +{ + uint64_t packet_duration = 0; + + if(video_ctx->frame_delay || chunks < 2) { + return video_ctx->frame_delay; + } + nestegg_packet_duration(packet, &packet_duration); + if(packet_duration) { + packet_duration /= chunks; + } + return packet_duration ? packet_duration : UINT64_C(1); +} + static int video_thread(void *data) { video_context *ctx = (video_context*) data; + uint64_t frame_delay; uint64_t timestamp; - while(!quit_video) + while(!webm_stop_is_requested(ctx->quit)) { unsigned int chunk, chunks; nestegg_packet *pkt; debug_printf("video queue size=%i\n", ctx->packet_queue->size); pkt = queue_get(ctx->packet_queue); - if (quit_video || pkt == NULL) break; + if(webm_stop_is_requested(ctx->quit) || pkt == NULL) break; nestegg_packet_count(pkt, &chunks); nestegg_packet_tstamp(pkt, ×tamp); + frame_delay = video_packet_frame_delay(ctx, pkt, chunks); for (chunk = 0; chunk < chunks; ++chunk) { @@ -392,14 +1438,23 @@ static int video_thread(void *data) if (vpx_codec_decode(&ctx->vpx_ctx, data, data_size, NULL, 0)) { printf("Error: libvpx failed to decode chunk\n"); - quit_video = 1; + webm_request_decoder_stop(ctx->quit); break; } while((img = vpx_codec_get_frame(&ctx->vpx_ctx, &iter))) { - assert(img->d_w == ctx->width); - assert(img->d_h == ctx->height); + if(img->d_w != (unsigned int)ctx->width || + img->d_h != (unsigned int)ctx->height) { + printf("Error: WebM frame dimensions changed during playback\n"); + webm_request_decoder_stop(ctx->quit); + break; + } yuv_frame *frame = yuv_frame_create(img->d_w, img->d_h); + if(!frame) { + printf("Error: Unable to allocate a decoded WebM frame\n"); + webm_request_decoder_stop(ctx->quit); + break; + } frame->timestamp = timestamp; int y; @@ -411,14 +1466,17 @@ static int video_thread(void *data) memcpy(frame->cb+(y*img->d_w/2), img->planes[2]+(y*img->stride[2]), img->d_w / 2); } - if (queue_insert(ctx->frame_queue, (void *)frame) < 0) + if(queue_insert(ctx->frame_queue, (void *)frame) < 0) { debug_printf("destroying last frame\n"); yuv_frame_destroy(frame); break; } - timestamp += ctx->frame_delay; + timestamp = timestamp <= UINT64_MAX - frame_delay + ? timestamp + frame_delay + : UINT64_MAX; } + if(webm_stop_is_requested(ctx->quit)) break; } nestegg_free_packet(pkt); } @@ -427,14 +1485,98 @@ static int video_thread(void *data) return 0; } +/* +* Caskey, Damon V. +* 2026-08-15 +* +* Select a bounded VP8 worker count from source resolution and available +* logical processors. Multi-core systems retain one processor for the main +* engine and audio when possible, while one- and two-core devices remain +* able to use every processor required for practical decoding. +*/ +static unsigned int video_decoder_thread_count( + unsigned int width, + unsigned int height +) +{ + uint64_t pixel_count = (uint64_t)width * (uint64_t)height; + unsigned int resolution_limit; + unsigned int threads; + int cpu_count = SDL_GetCPUCount(); + + if(pixel_count <= UINT64_C(640) * UINT64_C(480)) { + resolution_limit = 1; + } else if(pixel_count <= UINT64_C(1280) * UINT64_C(720)) { + resolution_limit = 2; + } else if(pixel_count <= UINT64_C(1920) * UINT64_C(1080)) { + resolution_limit = 4; + } else { + resolution_limit = VIDEO_DECODER_THREAD_MAX; + } + + threads = cpu_count > 0 ? (unsigned int)cpu_count : 1U; + if(threads > 2U) { + --threads; + } + if(threads > resolution_limit) { + threads = resolution_limit; + } + if(threads > VIDEO_DECODER_THREAD_MAX) { + threads = VIDEO_DECODER_THREAD_MAX; + } + return threads ? threads : 1U; +} + // returns 0 on success, -1 on error -static int init_video(nestegg *nestegg_ctx, int track, video_context *video_ctx) +/* +* Caskey, Damon V. +* 2026-08-15 +* +* Initialize a resolution-aware multithreaded video decoder and queues +* against their owning stop state. DefaultDuration is optional - packet +* timestamps and packet duration retain playback timing when omitted. +*/ +static int init_video( + nestegg *nestegg_ctx, + int track, + video_context *video_ctx, + SDL_atomic_t *quit +) { nestegg_video_params video_params; - nestegg_track_video_params(nestegg_ctx, track, &video_params); - assert(video_params.stereo_mode == NESTEGG_VIDEO_MONO); + vpx_codec_dec_cfg_t decoder_config = { 0 }; + int has_default_duration; + + if(nestegg_track_video_params(nestegg_ctx, track, &video_params) < 0) { + printf("Error: Unable to read WebM video track parameters\n"); + return -1; + } + if(video_params.stereo_mode != NESTEGG_VIDEO_MONO || + video_params.width < 2 || + video_params.height < 2 || + (video_params.width & 1U) || + (video_params.height & 1U) || + video_params.width > (unsigned int)(INT_MAX >> 16) || + video_params.height > (unsigned int)(INT_MAX >> 16) || + video_params.width > (unsigned int)INT_MAX / video_params.height || + video_params.display_width > (unsigned int)INT_MAX || + video_params.display_height > (unsigned int)INT_MAX) { + printf("Error: Unsupported WebM video dimensions or stereo mode\n"); + return -1; + } - if (vpx_codec_dec_init(&(video_ctx->vpx_ctx), vpx_codec_vp8_dx(), NULL, 0)) + decoder_config.threads = video_decoder_thread_count( + video_params.width, + video_params.height + ); + decoder_config.w = video_params.width; + decoder_config.h = video_params.height; + if(vpx_codec_dec_init( + &(video_ctx->vpx_ctx), + vpx_codec_vp8_dx(), + &decoder_config, + 0 + )) { printf("Error: failed to initialize libvpx\n"); return -1; @@ -443,13 +1585,40 @@ static int init_video(nestegg *nestegg_ctx, int track, video_context *video_ctx) video_ctx->height = video_params.height; video_ctx->display_width = video_params.display_width; video_ctx->display_height = video_params.display_height; - nestegg_track_default_duration(nestegg_ctx, track, &(video_ctx->frame_delay)); - printf("Video track: resolution=%i*%i, display resolution=%i*%i, %.2f frames/second\n", + video_ctx->playback_paused = 1; + has_default_duration = nestegg_track_default_duration( + nestegg_ctx, + track, + &(video_ctx->frame_delay) + ) == 0 && video_ctx->frame_delay; + if(has_default_duration) { + printf("\n\nVideo track: resolution=%i*%i, display resolution=%i*%i, %.2f frames/second\n", video_params.width, video_params.height, video_params.display_width, video_params.display_height, 1000000000.0 / video_ctx->frame_delay); - video_ctx->packet_queue = queue_init(PACKET_QUEUE_SIZE); - video_ctx->frame_queue = queue_init(FRAME_QUEUE_SIZE); + } else { + video_ctx->frame_delay = 0; + printf("\n\nVideo track: resolution=%i*%i, display resolution=%i*%i, packet-timestamp timing\n", + video_params.width, video_params.height, + video_params.display_width, video_params.display_height); + } + printf( + "Video decoder: %u thread%s.\n", + decoder_config.threads, + decoder_config.threads == 1U ? "" : "s" + ); + video_ctx->quit = quit; + video_ctx->packet_queue = queue_init(VIDEO_PACKET_QUEUE_SIZE, quit); + video_ctx->frame_queue = queue_init(FRAME_QUEUE_SIZE, quit); + if(!video_ctx->packet_queue || !video_ctx->frame_queue) { + printf("Error: Unable to allocate WebM video queues\n"); + if(video_ctx->packet_queue) queue_destroy(video_ctx->packet_queue); + if(video_ctx->frame_queue) queue_destroy(video_ctx->frame_queue); + video_ctx->packet_queue = NULL; + video_ctx->frame_queue = NULL; + vpx_codec_destroy(&(video_ctx->vpx_ctx)); + return -1; + } return 0; } @@ -457,7 +1626,7 @@ static void close_video(video_context *video_ctx) { if(vpx_codec_destroy(&(video_ctx->vpx_ctx))) { - printf("Warning: failed to destroy libvpx context: %s\n", vpx_codec_error(&video_ctx->vpx_ctx)); + printf("\nWarning: failed to destroy libvpx context: %s\n", vpx_codec_error(&video_ctx->vpx_ctx)); } if(video_ctx->packet_queue) { @@ -478,11 +1647,55 @@ static void close_video(video_context *video_ctx) } } +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Report the first completed compressed-video recovery and then every +* sixteenth occurrence. The discard total includes stale queued packets and +* newly demuxed packets skipped before the next verified keyframe. +*/ +static void video_packet_queue_report_resync(FixedSizeQueue *queue) +{ + uint64_t dropped_packets; + uint64_t resync_count; + + queue_get_status(queue, &dropped_packets, &resync_count); + if(resync_count && + (resync_count == 1U || !(resync_count % UINT64_C(16)))) { + printf( + "\nWarning: WebM video backlog recovery discarded %" PRIu64 + " compressed packets in %" PRIu64 + " keyframe resync event%s.\n", + dropped_packets, + resync_count, + resync_count == 1U ? "" : "s" + ); + } +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Route container packets to their decoder queues. WebM playback with audio +* elevates this shared producer and protects a critical audio reserve from +* compressed-video head-of-line blocking. Recovery atomically discards the +* stale compressed queue and resumes admission at a verified keyframe so the +* decoder does not spend the recovery interval processing obsolete work. +*/ static int demux_thread(void *data) { webm_context *ctx = (webm_context *)data; nestegg_packet *pkt; + int discard_video_until_keyframe = 0; int r; + + if(ctx->audio_track >= 0) { + /* Priority elevation may be denied on restricted platforms. */ + SDL_SetThreadPriority(SDL_THREAD_PRIORITY_HIGH); + } + while ((r = nestegg_read_packet(ctx->nestegg_ctx, &pkt)) > 0) { unsigned int track; @@ -498,136 +1711,739 @@ static int demux_thread(void *data) } else if (track == ctx->video_track) { - if (queue_insert(ctx->video_ctx.packet_queue, pkt) < 0) - { + int insert_result; + + if(discard_video_until_keyframe) { + if(video_packet_is_keyframe(pkt)) { + insert_result = video_packet_queue_try_insert( + ctx->video_ctx.packet_queue, + pkt + ); + if(insert_result > 0) { + discard_video_until_keyframe = 0; + video_packet_queue_report_resync( + ctx->video_ctx.packet_queue + ); + if(webm_stop_is_requested(&ctx->quit)) break; + continue; + } + if(insert_result < 0) { + nestegg_free_packet(pkt); + break; + } + } + video_packet_queue_record_drop( + ctx->video_ctx.packet_queue + ); + nestegg_free_packet(pkt); + if(webm_stop_is_requested(&ctx->quit)) break; + continue; + } + insert_result = video_packet_queue_insert(ctx, pkt); + if(insert_result < 0) { nestegg_free_packet(pkt); break; } + if(insert_result == 0) { + int packet_is_keyframe = video_packet_is_keyframe(pkt); + + if(video_packet_queue_begin_resync( + ctx->video_ctx.packet_queue, + packet_is_keyframe ? pkt : NULL + ) < 0) { + nestegg_free_packet(pkt); + break; + } + if(packet_is_keyframe) { + video_packet_queue_report_resync( + ctx->video_ctx.packet_queue + ); + if(webm_stop_is_requested(&ctx->quit)) break; + continue; + } + video_packet_queue_record_drop( + ctx->video_ctx.packet_queue + ); + nestegg_free_packet(pkt); + discard_video_until_keyframe = 1; + if(webm_stop_is_requested(&ctx->quit)) break; + continue; + } + } + else + { + nestegg_free_packet(pkt); } - if (quit_video) break; + if(webm_stop_is_requested(&ctx->quit)) break; + } + if(discard_video_until_keyframe) { + video_packet_queue_report_resync(ctx->video_ctx.packet_queue); } - queue_insert(ctx->video_ctx.packet_queue, NULL); if (ctx->audio_track >= 0) queue_insert(ctx->audio_ctx.packet_queue, NULL); + queue_insert(ctx->video_ctx.packet_queue, NULL); return 0; } -webm_context *webm_start_playback(const char *path, int volume) +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Publish an asynchronous lifecycle stage unless teardown was requested. +* Cancellation also raises the decoder stop flag so in-progress preroll and +* decoder workers leave their bounded waits without engine-thread joins. +*/ +static int webm_publish_decoder_state( + webm_context *ctx, + e_webm_decoder_state state +) { - webm_context *ctx; - nestegg_io io; - int video_track = -1, audio_track = -1; + int accepted; - quit_video = 0; - ctx = malloc(sizeof(*ctx)); - if(!ctx) return NULL; - memset(ctx, 0, sizeof(*ctx)); + mutex_lock(ctx->lifecycle_mutex); + accepted = !ctx->close_requested; + if(accepted) { + ctx->decoder_state = state; + } + else { + ctx->decoder_state = WEBM_DECODER_STATE_CLOSING; + webm_request_decoder_stop(&ctx->quit); + } + mutex_unlock(ctx->lifecycle_mutex); + return accepted; +} - // set up I/O callbacks - io.read = webm_read; - io.seek = webm_seek; - io.tell = webm_tell; +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Join decoder workers and release every resource opened by the lifecycle +* thread. Initialization flags make the same path safe for partial opens, +* failed seeks, ordinary stops, and engine shutdown. +*/ +static void webm_close_resources(webm_context *ctx) +{ + webm_request_decoder_stop(&ctx->quit); + if(ctx->the_demux_thread) { + thread_join(ctx->the_demux_thread); + ctx->the_demux_thread = NULL; + } + if(ctx->the_video_thread) { + thread_join(ctx->the_video_thread); + ctx->the_video_thread = NULL; + } + if(ctx->the_audio_thread) { + thread_join(ctx->the_audio_thread); + ctx->the_audio_thread = NULL; + } + if(ctx->video_initialized) { + close_video(&ctx->video_ctx); + ctx->video_initialized = 0; + } + if(ctx->audio_initialized) { + close_audio(&ctx->audio_ctx); + ctx->audio_initialized = 0; + } + if(ctx->nestegg_ctx) { + nestegg_destroy(ctx->nestegg_ctx); + ctx->nestegg_ctx = NULL; + } + if(ctx->io_ctx.packhandle >= 0) { + closepackfile(ctx->io_ctx.packhandle); + ctx->io_ctx.packhandle = -1; + } +} - // open video file - ctx->packhandle = openpackfile(path, packfile); - if(ctx->packhandle < 0) - { - printf("Error: Unable to open file %s for playback\n", path); - goto error1; +/* +* Caskey, Damon V. +* 2026-08-18 +* +* Perform container open, initial seek, and decoder creation on the lifecycle +* worker, then publish PREROLLING while video and optional audio establish +* their reserves. Explicit seeks use the same decoder path as initial playback. +*/ +static int webm_open_resources(webm_context *ctx) +{ + nestegg_io io; + int video_track = -1; + int audio_track = -1; + unsigned int num_tracks; + unsigned int i; + + io.read = webm_io_read; + io.seek = webm_io_seek; + io.tell = webm_io_tell; + io.userdata = &ctx->io_ctx; + + if(ctx->io_ctx.cache_buffer) { + if(!ctx->io_ctx.cache_size) { + printf("Error: Cached WebM source %s has no data\n", ctx->path); + return -1; + } + } + else { + ctx->io_ctx.packhandle = openpackfile(ctx->path, packfile); + if(ctx->io_ctx.packhandle < 0) { + printf("Error: Unable to open file %s for playback\n", ctx->path); + return -1; + } + ctx->io_ctx.stream_size = seekpackfile64( + ctx->io_ctx.packhandle, + 0, + SEEK_END + ); + if(ctx->io_ctx.stream_size <= 0 || + seekpackfile64(ctx->io_ctx.packhandle, 0, SEEK_SET) != 0) { + printf("Error: Unable to size WebM stream %s\n", ctx->path); + return -1; + } } - io.userdata = (void*)(size_t)ctx->packhandle; - if(nestegg_init(&(ctx->nestegg_ctx), io, NULL, -1) < 0) goto error2; - // get number of tracks - unsigned int num_tracks, i; - if(nestegg_track_count(ctx->nestegg_ctx, &num_tracks) < 0) goto error3; + if(!webm_publish_decoder_state( + ctx, + WEBM_DECODER_STATE_OPENING + )) { + return -1; + } + if(nestegg_init(&ctx->nestegg_ctx, io, NULL, -1) < 0) { + printf("Error: Unable to initialize WebM container %s\n", ctx->path); + return -1; + } + nestegg_duration(ctx->nestegg_ctx, &ctx->duration); + if(nestegg_track_count(ctx->nestegg_ctx, &num_tracks) < 0) { + printf("Error: Unable to read WebM tracks from %s\n", ctx->path); + return -1; + } - // find the first video and audio tracks - for (i = 0; i < num_tracks; i++) - { + for(i = 0; i < num_tracks; ++i) { int track_type = nestegg_track_type(ctx->nestegg_ctx, i); int codec = nestegg_track_codec_id(ctx->nestegg_ctx, i); - if (track_type == NESTEGG_TRACK_VIDEO) - { - if(codec != NESTEGG_CODEC_VP8) - { + + if(track_type == NESTEGG_TRACK_VIDEO && video_track < 0) { + if(codec != NESTEGG_CODEC_VP8) { printf("Error: unsupported video codec; only VP8 is supported\n"); - goto error3; + return -1; } - video_track = i; + video_track = (int)i; } - else if (track_type == NESTEGG_TRACK_AUDIO) - { - if(codec != NESTEGG_CODEC_VORBIS) - { + else if(track_type == NESTEGG_TRACK_AUDIO && audio_track < 0) { + if(codec != NESTEGG_CODEC_VORBIS) { printf("Error: unsupported audio codec; only Vorbis is supported\n"); - goto error3; + return -1; } - audio_track = i; + audio_track = (int)i; } } + if(video_track < 0) { + printf("Error: WebM file %s does not contain a video track\n", ctx->path); + return -1; + } + + if(!webm_publish_decoder_state( + ctx, + WEBM_DECODER_STATE_SEEKING + )) { + return -1; + } + if(ctx->duration && ctx->seek_timestamp >= ctx->duration) { + ctx->seek_timestamp = ctx->duration - 1U; + } + if(ctx->seek_timestamp && + nestegg_track_seek( + ctx->nestegg_ctx, + (unsigned int)video_track, + ctx->seek_timestamp + ) < 0) { + printf("Error: Unable to seek WebM file %s\n", ctx->path); + return -1; + } + if(!webm_publish_decoder_state( + ctx, + WEBM_DECODER_STATE_OPENING + )) { + return -1; + } - // set up video ctx->video_track = video_track; - init_video(ctx->nestegg_ctx, ctx->video_track, &(ctx->video_ctx)); - ctx->the_video_thread = thread_create(video_thread, "video", &(ctx->video_ctx)); - assert(ctx->the_video_thread); + if(init_video( + ctx->nestegg_ctx, + ctx->video_track, + &ctx->video_ctx, + &ctx->quit + ) < 0) { + return -1; + } + ctx->video_initialized = 1; + ctx->the_video_thread = thread_create( + video_thread, + "video", + &ctx->video_ctx + ); + if(!ctx->the_video_thread) { + printf("Error: Unable to start WebM video decoder thread\n"); + return -1; + } - // set up audio, if applicable - ctx->audio_track = audio_track; - if (audio_track >= 0) - { - // use the audio track of this file - init_audio(ctx->nestegg_ctx, ctx->audio_track, &(ctx->audio_ctx), volume); - ctx->the_audio_thread = thread_create(audio_thread, "audio", &(ctx->audio_ctx)); - assert(ctx->the_audio_thread); + if(audio_track >= 0 && ctx->play_audio) { + ctx->audio_track = audio_track; + if(init_audio( + ctx->nestegg_ctx, + ctx->audio_track, + &ctx->audio_ctx, + ctx->volume, + ctx->sound_channel, + ctx->seek_timestamp, + ctx->replace_all_audio, + &ctx->quit + ) == 0) { + ctx->audio_initialized = 1; + ctx->audio_ctx.video_packet_queue = + ctx->video_ctx.packet_queue; + ctx->audio_ctx.video_frame_queue = + ctx->video_ctx.frame_queue; + ctx->the_audio_thread = thread_create( + audio_thread, + "audio", + &ctx->audio_ctx + ); + if(!ctx->the_audio_thread) { + printf("Error: Unable to start WebM audio decoder thread\n"); + return -1; + } + } + else { + printf( + "\nWarning: Unable to open the WebM audio track on channel %d\n", + ctx->sound_channel + ); + ctx->audio_track = -1; + } } - else if (sound_query_music(NULL, NULL)) - { - // continue to play the BGM that's already playing - ctx->the_audio_thread = thread_create(bgm_update_thread, "bgm", NULL); - assert(ctx->the_audio_thread); + else if(audio_track < 0 && + ctx->replace_all_audio && + sound_query_music(NULL, NULL)) { + /* Service channel-zero music during silent fullscreen playback. */ + ctx->the_audio_thread = thread_create(bgm_update_thread, "bgm", ctx); + if(!ctx->the_audio_thread) { + printf("Error: Unable to start WebM audio service thread\n"); + return -1; + } } - // finally, start the demuxing thread ctx->the_demux_thread = thread_create(demux_thread, "demux", ctx); - assert(ctx->the_demux_thread); + if(!ctx->the_demux_thread) { + printf("Error: Unable to start WebM demux thread\n"); + return -1; + } + if(!webm_publish_decoder_state( + ctx, + WEBM_DECODER_STATE_PREROLLING + )) { + return -1; + } + return webm_stop_is_requested(&ctx->quit) ? -1 : 0; +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Own the decoder lifecycle after the constructor returns. Successful opens +* wait in READY until an asynchronous close request arrives; failed opens +* remain observable as FAILED until the movie layer retires the context. +*/ +static int webm_lifecycle_thread(void *data) +{ + webm_context *ctx = data; + int open_result; + + mutex_lock(webm_lifecycle_operation_mutex); + open_result = webm_open_resources(ctx); + mutex_unlock(webm_lifecycle_operation_mutex); + if(open_result == 0) { + webm_wait_for_preroll(ctx); + if(webm_stop_is_requested(&ctx->quit) || + webm_close_is_requested(ctx)) { + open_result = -1; + } + } + if(open_result < 0) { + mutex_lock(webm_lifecycle_operation_mutex); + webm_close_resources(ctx); + mutex_unlock(webm_lifecycle_operation_mutex); + } + + mutex_lock(ctx->lifecycle_mutex); + if(ctx->close_requested) { + ctx->decoder_state = WEBM_DECODER_STATE_CLOSING; + } + else { + ctx->decoder_state = open_result == 0 + ? WEBM_DECODER_STATE_READY + : WEBM_DECODER_STATE_FAILED; + } + cond_broadcast(ctx->lifecycle_condition); + while(!ctx->close_requested) { + cond_wait(ctx->lifecycle_condition, ctx->lifecycle_mutex); + } + ctx->decoder_state = WEBM_DECODER_STATE_CLOSING; + webm_request_decoder_stop(&ctx->quit); + mutex_unlock(ctx->lifecycle_mutex); + + if(open_result == 0) { + mutex_lock(webm_lifecycle_operation_mutex); + webm_close_resources(ctx); + mutex_unlock(webm_lifecycle_operation_mutex); + } + + mutex_lock(ctx->lifecycle_mutex); + ctx->decoder_state = WEBM_DECODER_STATE_CLOSED; + cond_broadcast(ctx->lifecycle_condition); + mutex_unlock(ctx->lifecycle_mutex); + return 0; +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Allocate an independently owned decoder request and return as soon as its +* lifecycle worker starts. Container I/O, seeking, codec initialization, and +* audio preroll continue asynchronously behind the published state machine. +*/ +webm_context *webm_start_playback_ex( + const char *path, + int volume, + int sound_channel, + const unsigned char *cache_buffer, + size_t cache_size, + uint64_t seek_timestamp, + int play_audio, + int replace_all_audio +) +{ + webm_context *ctx; + + if(!path || !path[0] || + sound_channel < 0 || + (unsigned int)sound_channel >= SOUND_CHANNEL_COUNT_MAX || + (cache_buffer && !cache_size)) { + printf("Error: Invalid WebM playback request\n"); + return NULL; + } + if(!webm_lifecycle_init()) { + printf("Error: Unable to initialize WebM lifecycle synchronization\n"); + return NULL; + } + + ctx = calloc(1, sizeof(*ctx)); + if(!ctx) { + printf("Error: Unable to allocate WebM playback context\n"); + return NULL; + } + ctx->path = webm_copy_path(path); + ctx->lifecycle_mutex = mutex_create(); + ctx->lifecycle_condition = cond_create(); + if(!ctx->path || !ctx->lifecycle_mutex || !ctx->lifecycle_condition) { + if(ctx->lifecycle_condition) { + cond_destroy(ctx->lifecycle_condition); + } + if(ctx->lifecycle_mutex) { + mutex_destroy(ctx->lifecycle_mutex); + } + free(ctx->path); + free(ctx); + return NULL; + } + + ctx->io_ctx.packhandle = -1; + ctx->io_ctx.cache_buffer = cache_buffer; + ctx->io_ctx.cache_size = cache_size; + ctx->audio_track = -1; + ctx->video_track = -1; + ctx->seek_timestamp = seek_timestamp; + ctx->volume = volume; + ctx->sound_channel = sound_channel; + ctx->play_audio = play_audio != 0; + ctx->replace_all_audio = replace_all_audio != 0; + ctx->decoder_state = WEBM_DECODER_STATE_OPENING; + SDL_AtomicSet(&ctx->quit, 0); + ctx->the_lifecycle_thread = thread_create( + webm_lifecycle_thread, + "webm-lifecycle", + ctx + ); + if(!ctx->the_lifecycle_thread) { + cond_destroy(ctx->lifecycle_condition); + mutex_destroy(ctx->lifecycle_mutex); + free(ctx->path); + free(ctx); + return NULL; + } return ctx; +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Snapshot one decoder lifecycle state under its owner mutex so the movie +* layer can advance transitions without reading partially published state. +*/ +e_webm_decoder_state webm_get_decoder_state(webm_context *ctx) +{ + e_webm_decoder_state state; + + if(!ctx) { + return WEBM_DECODER_STATE_FAILED; + } + mutex_lock(ctx->lifecycle_mutex); + state = ctx->decoder_state; + mutex_unlock(ctx->lifecycle_mutex); + return state; +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Request decoder cancellation without waiting for lifecycle, demux, audio, +* or video workers. The owning movie channel later reaps the context after +* CLOSED is observable. +*/ +void webm_request_close(webm_context *ctx) +{ + if(!ctx) { + return; + } + + mutex_lock(ctx->lifecycle_mutex); + if(ctx->decoder_state != WEBM_DECODER_STATE_CLOSED) { + ctx->close_requested = 1; + ctx->decoder_state = WEBM_DECODER_STATE_CLOSING; + cond_broadcast(ctx->lifecycle_condition); + } + mutex_unlock(ctx->lifecycle_mutex); +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Reap one decoder only after asynchronous teardown is complete. CLOSED is +* published at the lifecycle worker's exit boundary, keeping this poll free +* of container I/O and decoder-thread waits during normal engine updates. +*/ +int webm_poll_closed(webm_context *ctx) +{ + if(!ctx || + webm_get_decoder_state(ctx) != WEBM_DECODER_STATE_CLOSED) { + return 0; + } -error3: - nestegg_destroy(ctx->nestegg_ctx); -error2: - closepackfile(ctx->packhandle); -error1: + thread_join(ctx->the_lifecycle_thread); + cond_destroy(ctx->lifecycle_condition); + mutex_destroy(ctx->lifecycle_mutex); + free(ctx->path); free(ctx); - return NULL; + return 1; } +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Finalize decoder ownership synchronously where blocking is permitted. +* Runtime movie stop and replacement use asynchronous request and poll calls. +*/ void webm_close(webm_context *ctx) { - quit_video = 1; - thread_join(ctx->the_demux_thread); - thread_join(ctx->the_video_thread); - close_video(&(ctx->video_ctx)); - if (ctx->the_audio_thread) thread_join(ctx->the_audio_thread); - if (ctx->audio_track >= 0) close_audio(&(ctx->audio_ctx)); - nestegg_destroy(ctx->nestegg_ctx); - closepackfile(ctx->packhandle); + if(!ctx) { + return; + } + + webm_request_close(ctx); + thread_join(ctx->the_lifecycle_thread); + cond_destroy(ctx->lifecycle_condition); + mutex_destroy(ctx->lifecycle_mutex); + free(ctx->path); free(ctx); } void webm_get_video_info(webm_context *ctx, yuv_video_mode *dims) { - assert(ctx); - assert(dims); + if(!ctx || !dims || + webm_get_decoder_state(ctx) != WEBM_DECODER_STATE_READY) { + return; + } dims->width = ctx->video_ctx.width; dims->height = ctx->video_ctx.height; dims->display_width = ctx->video_ctx.display_width; dims->display_height = ctx->video_ctx.display_height; } -yuv_frame *webm_get_next_frame(webm_context *ctx) +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Return the container duration in nanoseconds. +*/ +uint64_t webm_get_duration(webm_context *ctx) +{ + return ctx && + webm_get_decoder_state(ctx) == WEBM_DECODER_STATE_READY + ? ctx->duration + : 0; +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Resolve the movie timeline from PCM frames consumed by this WebM object's +* owned sound channel. The clock stops with real audio during pauses and +* underruns, and becomes unavailable if another producer takes the channel. +*/ +int webm_get_audio_playback_position( + webm_context *ctx, + uint64_t *position +) +{ + audio_context *audio_ctx; + uint64_t playback_frame; + uint64_t elapsed; + + if(!ctx || !position || + webm_get_decoder_state(ctx) != WEBM_DECODER_STATE_READY || + ctx->audio_track < 0) { + return 0; + } + + audio_ctx = &ctx->audio_ctx; + if(!sound_get_channel_pcm_stream_playback_frame_owned( + audio_ctx->channel, + audio_ctx->stream_play_id, + &playback_frame + )) { + return 0; + } + + elapsed = audio_frames_to_nanoseconds( + playback_frame, + audio_ctx->frequency + ); + *position = elapsed <= UINT64_MAX - audio_ctx->playback_start_timestamp + ? audio_ctx->playback_start_timestamp + elapsed + : UINT64_MAX; + if(ctx->duration && *position > ctx->duration) { + *position = ctx->duration; + } + return 1; +} + +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Apply pause state to decoder backpressure and audio output only after the +* asynchronous lifecycle publishes READY. Pending requests retain their +* desired pause state in the owning movie record until that transition. +*/ +void webm_set_audio_paused(webm_context *ctx, int paused) +{ + audio_context *audio_ctx; + + if(!ctx || + webm_get_decoder_state(ctx) != WEBM_DECODER_STATE_READY) { + return; + } + paused = paused != 0; + if(ctx->video_ctx.frame_queue) { + mutex_lock(ctx->video_ctx.frame_queue->mutex); + ctx->video_ctx.playback_paused = paused; + mutex_unlock(ctx->video_ctx.frame_queue->mutex); + } + if(ctx->audio_track < 0) { + return; + } + + audio_ctx = &ctx->audio_ctx; + if(audio_ctx->output_paused == paused) { + return; + } + + if(sound_pause_channel_owned( + audio_ctx->channel, + audio_ctx->stream_play_id, + paused + )) { + audio_ctx->output_paused = paused; + } +} + +void webm_set_audio_speed(webm_context *ctx, double speed) +{ + if(ctx && + speed > 0.0 && + webm_get_decoder_state(ctx) == WEBM_DECODER_STATE_READY && + ctx->audio_track >= 0) { + sound_set_channel_speed_owned( + ctx->audio_ctx.channel, + ctx->audio_ctx.stream_play_id, + speed + ); + } +} + +/* +* Caskey, Damon V. +* 2026-08-15 +* +* Snapshot decoded-frame occupancy to bound dequeue attempts during the current +* engine update. Frames published afterward remain queued for the next update. +*/ +int webm_get_pending_frame_count(webm_context *ctx) +{ + int count; + + if(!ctx || + webm_get_decoder_state(ctx) != WEBM_DECODER_STATE_READY) { + return 0; + } + count = queue_get_status( + ctx->video_ctx.frame_queue, + NULL, + NULL + ); + return count > 0 ? count : 0; +} + +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Poll decoded video without blocking the main engine update. +*/ +int webm_try_get_next_frame(webm_context *ctx, yuv_frame **frame) { - debug_printf("frame queue size=%i\n", ctx->video_ctx.frame_queue->size); - return (yuv_frame *)queue_get(ctx->video_ctx.frame_queue); + e_webm_decoder_state state; + void *queued_frame; + + if(!ctx || !frame) { + return -1; + } + state = webm_get_decoder_state(ctx); + if(state != WEBM_DECODER_STATE_READY) { + return state == WEBM_DECODER_STATE_FAILED || + state == WEBM_DECODER_STATE_CLOSING || + state == WEBM_DECODER_STATE_CLOSED + ? -1 + : 0; + } + if(!queue_try_get(ctx->video_ctx.frame_queue, &queued_frame)) { + return webm_stop_is_requested(&ctx->quit) ? -1 : 0; + } + + *frame = queued_frame; + return queued_frame ? 1 : -1; } diff --git a/engine/source/webmlib/vidplay.h b/engine/source/webmlib/vidplay.h index 6276e3e03..cabd35d8e 100644 --- a/engine/source/webmlib/vidplay.h +++ b/engine/source/webmlib/vidplay.h @@ -9,15 +9,57 @@ #ifndef VIDPLAY_H #define VIDPLAY_H +#include +#include + #include "yuv.h" struct webm_context; typedef struct webm_context webm_context; -webm_context *webm_start_playback(const char *path, int volume); +/* +* Caskey, Damon V. +* 2026-08-17 +* +* Describe each externally observable stage of the asynchronous WebM +* decoder lifecycle. Callers poll these states without joining decoder +* workers or performing container I/O on the engine thread. +*/ +typedef enum e_webm_decoder_state { + WEBM_DECODER_STATE_OPENING, + WEBM_DECODER_STATE_SEEKING, + WEBM_DECODER_STATE_PREROLLING, + WEBM_DECODER_STATE_READY, + WEBM_DECODER_STATE_FAILED, + WEBM_DECODER_STATE_CLOSING, + WEBM_DECODER_STATE_CLOSED +} e_webm_decoder_state; + +int webm_lifecycle_init(void); +void webm_lifecycle_shutdown(void); +webm_context *webm_start_playback_ex( + const char *path, + int volume, + int sound_channel, + const unsigned char *cache_buffer, + size_t cache_size, + uint64_t seek_timestamp, + int play_audio, + int replace_all_audio +); +e_webm_decoder_state webm_get_decoder_state(webm_context *ctx); void webm_get_video_info(webm_context *ctx, yuv_video_mode *dims); +uint64_t webm_get_duration(webm_context *ctx); +int webm_get_audio_playback_position( + webm_context *ctx, + uint64_t *position +); +void webm_set_audio_paused(webm_context *ctx, int paused); +void webm_set_audio_speed(webm_context *ctx, double speed); +void webm_request_close(webm_context *ctx); +int webm_poll_closed(webm_context *ctx); void webm_close(webm_context *ctx); -yuv_frame *webm_get_next_frame(webm_context *ctx); +int webm_get_pending_frame_count(webm_context *ctx); +int webm_try_get_next_frame(webm_context *ctx, yuv_frame **frame); #endif - diff --git a/engine/source/webmlib/vorbis.c b/engine/source/webmlib/vorbis.c index b55112001..5829d660d 100644 --- a/engine/source/webmlib/vorbis.c +++ b/engine/source/webmlib/vorbis.c @@ -92,5 +92,27 @@ void vorbis_getpcm(vorbis_context *ctx, void *buffer, size_t samples) vorbis_synthesis_read(&ctx->v, samples); } +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Consume decoded PCM without packing it into an output buffer. +* Seek alignment uses this to discard samples preceding the +* requested movie position. +*/ +void vorbis_skip_pcm(vorbis_context *ctx, size_t samples) +{ +#if TREMOR + ogg_int32_t **pcm; +#else + float **pcm; +#endif + int avail_samples; + + if(!samples) return; + avail_samples = vorbis_synthesis_pcmout(&ctx->v, &pcm); + assert(avail_samples >= 0 && (size_t)avail_samples >= samples); + vorbis_synthesis_read(&ctx->v, samples); +} diff --git a/engine/source/webmlib/vorbis.h b/engine/source/webmlib/vorbis.h index f206add6b..edc681755 100644 --- a/engine/source/webmlib/vorbis.h +++ b/engine/source/webmlib/vorbis.h @@ -31,5 +31,6 @@ void vorbis_prepare(vorbis_context *ctx); void vorbis_headerpacket(vorbis_context *ctx, void *data, size_t size, int packetCount); int vorbis_packet(vorbis_context *ctx, void *data, size_t size); void vorbis_getpcm(vorbis_context *ctx, void *buffer, size_t samples); +void vorbis_skip_pcm(vorbis_context *ctx, size_t samples); #endif diff --git a/engine/source/webmlib/yuv.c b/engine/source/webmlib/yuv.c index 9b474fa11..037e70909 100644 --- a/engine/source/webmlib/yuv.c +++ b/engine/source/webmlib/yuv.c @@ -117,12 +117,40 @@ static int free_bits_at_bottom(uint32_t a) return 1 + free_bits_at_bottom ( a >> 1); } +/* +* Caskey, Damon V. +* 2026-08-12 +* +* Allocate one complete YUV 4:2:0 frame with checked +* dimensions and exact chroma plane capacity. +*/ yuv_frame *yuv_frame_create(int width, int height) { - yuv_frame *frame = malloc(sizeof(yuv_frame)); - frame->lum = malloc(width * height); - frame->cr = malloc(width * height / 2); - frame->cb = malloc(width * height / 2); + yuv_frame *frame; + size_t luma_size; + size_t chroma_size; + + if(width < 1 || height < 1 || + (width & 1) || (height & 1) || + (size_t)width > SIZE_MAX / (size_t)height) { + return NULL; + } + + luma_size = (size_t)width * (size_t)height; + chroma_size = luma_size / 4U; + frame = calloc(1, sizeof(*frame)); + if(!frame) { + return NULL; + } + frame->lum = malloc(luma_size); + frame->cr = malloc(chroma_size); + frame->cb = malloc(chroma_size); + if(!frame->lum || !frame->cr || !frame->cb) { + yuv_frame_destroy(frame); + return NULL; + } + frame->width = width; + frame->height = height; return frame; } @@ -397,3 +425,94 @@ void yuv_to_rgb(yuv_frame *in, s_screen *out) #endif } +/* +* Caskey, Damon V. +* 2026-08-15 +* +* Convert YUV 4:2:0 directly into the requested 32-bit output dimensions. +* Nearest-neighbor source selection matches the movie compositing scaler +* without first converting every full-resolution source pixel. +*/ +bool yuv_to_rgb_scaled(const yuv_frame *in, s_screen *out) +{ + const unsigned char *cb; + const unsigned char *cr; + const unsigned char *lum; + uint32_t *destination; + uint64_t x_step; + uint64_t y_position = 0; + uint64_t y_step; + int chroma_width; + int destination_x; + int destination_y; + + if(!yuv_initialized || bytes_per_pixel != 4 || !in || !out || + !in->lum || !in->cb || !in->cr || + in->width < 2 || in->height < 2 || + (in->width & 1) || (in->height & 1) || + out->width < 1 || out->height < 1 || + pixelbytes[out->pixelformat] != 4) { + return false; + } + + lum = (const unsigned char *)in->lum; +#if REVERSE_COLOR + cr = (const unsigned char *)in->cb; + cb = (const unsigned char *)in->cr; +#else + cr = (const unsigned char *)in->cr; + cb = (const unsigned char *)in->cb; +#endif + destination = (uint32_t *)out->data; + chroma_width = in->width / 2; + x_step = ((uint64_t)in->width << 16) / (uint64_t)out->width; + y_step = ((uint64_t)in->height << 16) / (uint64_t)out->height; + + for(destination_y = 0; + destination_y < out->height; + destination_y++) { + const unsigned char *cb_row; + const unsigned char *cr_row; + const unsigned char *lum_row; + uint64_t x_position = 0; + int source_y = (int)(y_position >> 16); + + if(source_y >= in->height) { + source_y = in->height - 1; + } + cb_row = cb + (source_y / 2) * chroma_width; + cr_row = cr + (source_y / 2) * chroma_width; + lum_row = lum + source_y * in->width; + + for(destination_x = 0; + destination_x < out->width; + destination_x++) { + int cb_value; + int cb_b; + int cr_value; + int cr_r; + int crb_g; + int luminance; + int source_x = (int)(x_position >> 16); + + if(source_x >= in->width) { + source_x = in->width - 1; + } + cb_value = cb_row[source_x / 2]; + cr_value = cr_row[source_x / 2]; + cr_r = 256 + colortab[cr_value]; + crb_g = 768 + 256 + + colortab[cr_value + 256] + + colortab[cb_value + 512]; + cb_b = 1536 + 256 + colortab[cb_value + 768]; + luminance = lum_row[source_x]; + *destination++ = + rgb_2_pix[luminance + cr_r] | + rgb_2_pix[luminance + crb_g] | + rgb_2_pix[luminance + cb_b]; + x_position += x_step; + } + y_position += y_step; + } + return true; +} diff --git a/engine/source/webmlib/yuv.h b/engine/source/webmlib/yuv.h index 7a665aca7..e4c71450b 100644 --- a/engine/source/webmlib/yuv.h +++ b/engine/source/webmlib/yuv.h @@ -13,6 +13,8 @@ typedef struct { u64 timestamp; + int width; + int height; void *lum; void *cb; void *cr; @@ -40,4 +42,7 @@ void yuv_clear(void); // Performs YUV->RGB color conversion on a single frame; requires initialized context void yuv_to_rgb(yuv_frame *in, s_screen *out); +// Converts and nearest-neighbor scales a YUV frame directly to a 32-bit screen +bool yuv_to_rgb_scaled(const yuv_frame *in, s_screen *out); + #endif diff --git a/engine/version.tmp b/engine/version.tmp index 822303817..a8ecaa12f 100644 --- a/engine/version.tmp +++ b/engine/version.tmp @@ -12,9 +12,9 @@ #define VERSION_NAME "OpenBOR" #define VERSION_MAJOR "4" #define VERSION_MINOR "0" -#define VERSION_BUILD "7904" -#define VERSION_BUILD_INT 7904 -#define VERSION_COMMIT "bcec7cb" +#define VERSION_BUILD "7982" +#define VERSION_BUILD_INT 7982 +#define VERSION_COMMIT "ae48540" #define VERSION "v"VERSION_MAJOR"."VERSION_MINOR" Build "VERSION_BUILD" (commit hash: "VERSION_COMMIT")" #endif