From b39ed4ba7665f04c3e2e276aa77c70ac1c0a21ce Mon Sep 17 00:00:00 2001 From: Michael Date: Thu, 14 May 2026 12:02:16 +0100 Subject: [PATCH 1/3] media-playback: Add embedded timecode sync Use FFmpeg S12M frame side data to align network media sources by embedded timecode instead of packet arrival time. Expose the setting for FFmpeg network sources and keep it disabled for local files and full decode paths. --- CMakeLists.txt | 5 + plugins/obs-ffmpeg/data/locale/en-US.ini | 1 + plugins/obs-ffmpeg/obs-ffmpeg-source.c | 16 +- shared/media-playback/CMakeLists.txt | 2 + shared/media-playback/media-playback/decode.c | 39 ++++ shared/media-playback/media-playback/decode.h | 4 + .../media-playback/media-playback.h | 1 + shared/media-playback/media-playback/media.c | 78 +++++++ shared/media-playback/media-playback/media.h | 3 + .../media-playback/media-playback/timecode.c | 113 ++++++++++ .../media-playback/media-playback/timecode.h | 34 +++ test/media-playback/CMakeLists.txt | 10 + test/media-playback/test_timecode.c | 195 ++++++++++++++++++ 13 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 shared/media-playback/media-playback/timecode.c create mode 100644 shared/media-playback/media-playback/timecode.h create mode 100644 test/media-playback/CMakeLists.txt create mode 100644 test/media-playback/test_timecode.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 4af6f90b753486..0acf433fc3e5f7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,6 +18,7 @@ include(helpers) option(ENABLE_UI "Enable building with UI (requires Qt)" ON) option(ENABLE_SCRIPTING "Enable scripting support" ON) option(ENABLE_HEVC "Enable HEVC encoders" ON) +option(ENABLE_UNIT_TESTS "Enable unit tests" OFF) add_subdirectory(libobs) if(OS_WINDOWS) @@ -31,6 +32,10 @@ endif() add_subdirectory(plugins) add_subdirectory(test/test-input) +if(ENABLE_UNIT_TESTS) + enable_testing() + add_subdirectory(test/media-playback) +endif() add_subdirectory(frontend) diff --git a/plugins/obs-ffmpeg/data/locale/en-US.ini b/plugins/obs-ffmpeg/data/locale/en-US.ini index 2331d1e4917b1b..6e7731bbea33cc 100644 --- a/plugins/obs-ffmpeg/data/locale/en-US.ini +++ b/plugins/obs-ffmpeg/data/locale/en-US.ini @@ -77,6 +77,7 @@ Looping="Loop" Input="Input" InputFormat="Input Format" BufferingMB="Network Buffering" +SyncToTimecodes="Use embedded timecode for sync" HardwareDecode="Use hardware decoding when available" ClearOnMediaEnd="Show nothing when playback ends" RestartWhenActivated="Restart playback when source becomes active" diff --git a/plugins/obs-ffmpeg/obs-ffmpeg-source.c b/plugins/obs-ffmpeg/obs-ffmpeg-source.c index 26241152ad5408..1b21addb817a24 100644 --- a/plugins/obs-ffmpeg/obs-ffmpeg-source.c +++ b/plugins/obs-ffmpeg/obs-ffmpeg-source.c @@ -46,6 +46,7 @@ struct ffmpeg_source { bool is_local_file; bool is_hw_decoding; bool full_decode; + bool sync_to_timecodes; bool is_clear_on_media_end; bool restart_on_activate; bool close_when_inactive; @@ -99,12 +100,14 @@ static bool is_local_file_modified(obs_properties_t *props, obs_property_t *prop obs_property_t *local_file = obs_properties_get(props, "local_file"); obs_property_t *looping = obs_properties_get(props, "looping"); obs_property_t *buffering = obs_properties_get(props, "buffering_mb"); + obs_property_t *sync_to_timecodes = obs_properties_get(props, "sync_to_timecodes"); obs_property_t *seekable = obs_properties_get(props, "seekable"); obs_property_t *speed = obs_properties_get(props, "speed_percent"); obs_property_t *reconnect_delay_sec = obs_properties_get(props, "reconnect_delay_sec"); obs_property_set_visible(input, !enabled); obs_property_set_visible(input_format, !enabled); obs_property_set_visible(buffering, !enabled); + obs_property_set_visible(sync_to_timecodes, !enabled); obs_property_set_visible(local_file, enabled); obs_property_set_visible(looping, enabled); obs_property_set_visible(speed, enabled); @@ -121,6 +124,7 @@ static void ffmpeg_source_defaults(obs_data_t *settings) obs_data_set_default_bool(settings, "clear_on_media_end", true); obs_data_set_default_bool(settings, "restart_on_activate", true); obs_data_set_default_bool(settings, "linear_alpha", false); + obs_data_set_default_bool(settings, "sync_to_timecodes", false); obs_data_set_default_int(settings, "reconnect_delay_sec", 10); obs_data_set_default_int(settings, "buffering_mb", 2); obs_data_set_default_int(settings, "speed_percent", 100); @@ -189,6 +193,8 @@ static obs_properties_t *ffmpeg_source_getproperties(void *data) obs_properties_add_bool(props, "hw_decode", obs_module_text("HardwareDecode")); + obs_properties_add_bool(props, "sync_to_timecodes", obs_module_text("SyncToTimecodes")); + obs_properties_add_bool(props, "clear_on_media_end", obs_module_text("ClearOnMediaEnd")); prop = obs_properties_add_bool(props, "close_when_inactive", obs_module_text("CloseFileWhenInactive")); @@ -229,12 +235,14 @@ static void dump_source_info(struct ffmpeg_source *s, const char *input, const c "\tis_clear_on_media_end: %s\n" "\trestart_on_activate: %s\n" "\tclose_when_inactive: %s\n" + "\tsync_to_timecodes: %s\n" "\tfull_decode: %s\n" "\tffmpeg_options: %s", input ? input : "(null)", input_format ? input_format : "(null)", s->speed_percent, s->is_looping ? "yes" : "no", s->is_linear_alpha ? "yes" : "no", s->is_hw_decoding ? "yes" : "no", s->is_clear_on_media_end ? "yes" : "no", s->restart_on_activate ? "yes" : "no", - s->close_when_inactive ? "yes" : "no", s->full_decode ? "yes" : "no", s->ffmpeg_options); + s->close_when_inactive ? "yes" : "no", s->sync_to_timecodes ? "yes" : "no", + s->full_decode ? "yes" : "no", s->ffmpeg_options); } static void get_frame(void *opaque, struct obs_source_frame *f) @@ -309,6 +317,7 @@ static void ffmpeg_source_open(struct ffmpeg_source *s) .reconnecting = s->reconnecting, .request_preload = s->is_stinger, .full_decode = s->full_decode, + .sync_to_timecodes = s->sync_to_timecodes, }; s->media = media_playback_create(&info); @@ -407,6 +416,7 @@ static void ffmpeg_source_update(void *data, obs_data_t *settings) bool is_linear_alpha; int speed_percent; bool is_looping; + bool sync_to_timecodes = false; bfree(s->input_format); @@ -414,6 +424,7 @@ static void ffmpeg_source_update(void *data, obs_data_t *settings) input = obs_data_get_string(settings, "local_file"); input_format = NULL; is_looping = obs_data_get_bool(settings, "looping"); + sync_to_timecodes = false; if (s->input && !should_restart_media) should_restart_media |= strcmp(s->input, input) != 0; @@ -424,6 +435,7 @@ static void ffmpeg_source_update(void *data, obs_data_t *settings) s->reconnect_delay_sec = (int)obs_data_get_int(settings, "reconnect_delay_sec"); s->reconnect_delay_sec = s->reconnect_delay_sec == 0 ? 10 : s->reconnect_delay_sec; is_looping = false; + sync_to_timecodes = obs_data_get_bool(settings, "sync_to_timecodes"); } stop_reconnect_thread(s); @@ -437,6 +449,7 @@ static void ffmpeg_source_update(void *data, obs_data_t *settings) /* Restart media source if these properties are changed */ if (s->is_hw_decoding != is_hw_decoding || s->range != range || s->speed_percent != speed_percent || + s->sync_to_timecodes != sync_to_timecodes || (s->ffmpeg_options && strcmp(s->ffmpeg_options, ffmpeg_options) != 0)) should_restart_media = true; @@ -456,6 +469,7 @@ static void ffmpeg_source_update(void *data, obs_data_t *settings) s->input_format = input_format ? bstrdup(input_format) : NULL; s->is_hw_decoding = is_hw_decoding; s->full_decode = obs_data_get_bool(settings, "full_decode"); + s->sync_to_timecodes = sync_to_timecodes; s->is_clear_on_media_end = obs_data_get_bool(settings, "clear_on_media_end"); s->restart_on_activate = !astrcmpi_n(input, RIST_PROTO, sizeof(RIST_PROTO) - 1) ? false diff --git a/shared/media-playback/CMakeLists.txt b/shared/media-playback/CMakeLists.txt index 711c09b11a4bf7..7df7c6cea62812 100644 --- a/shared/media-playback/CMakeLists.txt +++ b/shared/media-playback/CMakeLists.txt @@ -17,6 +17,8 @@ target_sources( media-playback/media-playback.h media-playback/media.c media-playback/media.h + media-playback/timecode.c + media-playback/timecode.h ) target_include_directories(media-playback INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}") diff --git a/shared/media-playback/media-playback/decode.c b/shared/media-playback/media-playback/decode.c index fea698a6900fca..5e74f25725550f 100644 --- a/shared/media-playback/media-playback/decode.c +++ b/shared/media-playback/media-playback/decode.c @@ -18,7 +18,11 @@ #include "media-playback.h" #include "media.h" +#include "timecode.h" #include +#include + +#define NSEC_PER_SEC 1000000000LL enum AVHWDeviceType hw_priority[] = { AV_HWDEVICE_TYPE_CUDA, AV_HWDEVICE_TYPE_D3D11VA, AV_HWDEVICE_TYPE_DXVA2, AV_HWDEVICE_TYPE_VAAPI, @@ -131,6 +135,22 @@ static uint16_t get_max_luminance(const AVStream *stream) return max_luminance; } +static AVRational get_stream_frame_rate(const AVStream *stream) +{ + AVRational frame_rate = stream->avg_frame_rate.num && stream->avg_frame_rate.den ? stream->avg_frame_rate + : stream->r_frame_rate; + + if (frame_rate.num > 0 && frame_rate.den > 0) + return frame_rate; + + return (AVRational){30, 1}; +} + +static int64_t get_frame_duration(AVRational frame_rate) +{ + return av_rescale_q(frame_rate.den, (AVRational){1, frame_rate.num}, (AVRational){1, NSEC_PER_SEC}); +} + bool mp_decode_init(mp_media_t *m, enum AVMediaType type, bool hw) { struct mp_decode *d = type == AVMEDIA_TYPE_VIDEO ? &m->v : &m->a; @@ -150,6 +170,8 @@ bool mp_decode_init(mp_media_t *m, enum AVMediaType type, bool hw) if (type == AVMEDIA_TYPE_VIDEO) d->max_luminance = get_max_luminance(stream); + d->timecode_frame_rate = get_stream_frame_rate(stream); + d->timecode_frame_duration = get_frame_duration(d->timecode_frame_rate); if (id == AV_CODEC_ID_VP8 || id == AV_CODEC_ID_VP9) { AVDictionaryEntry *tag = NULL; @@ -213,6 +235,7 @@ void mp_decode_clear_packets(struct mp_decode *d) deque_pop_front(&d->packets, &pkt, sizeof(pkt)); mp_media_free_packet(d->m, pkt); } + d->frame_timecode_valid = false; } void mp_decode_free(struct mp_decode *d) @@ -320,6 +343,18 @@ static int decode_packet(struct mp_decode *d, int *got_frame) return ret; } +static bool frame_timecode(struct mp_decode *d, int64_t *timestamp) +{ + const AVFrameSideData *side_data = NULL; + + if (d->audio || !d->m->sync_to_timecodes) + return false; + + side_data = av_frame_get_side_data(d->frame, AV_FRAME_DATA_S12M_TIMECODE); + return side_data && mp_s12m_timecode_parse(side_data->data, side_data->size, d->timecode_frame_rate, + d->timecode_frame_duration, timestamp); +} + bool mp_decode_next(struct mp_decode *d) { bool eof = d->m->eof; @@ -327,6 +362,7 @@ bool mp_decode_next(struct mp_decode *d) int ret; d->frame_ready = false; + d->frame_timecode_valid = false; if (!eof && !d->packets.size) return true; @@ -392,6 +428,8 @@ bool mp_decode_next(struct mp_decode *d) d->frame_pts = av_rescale_q(d->in_frame->best_effort_timestamp, d->stream->time_base, (AVRational){1, 1000000000}); + d->frame_timecode_valid = frame_timecode(d, &d->frame_timecode); + int64_t duration = d->in_frame->duration; if (!duration) duration = get_estimated_duration(d, last_pts); @@ -417,5 +455,6 @@ void mp_decode_flush(struct mp_decode *d) d->eof = false; d->frame_pts = 0; d->frame_ready = false; + d->frame_timecode_valid = false; d->next_pts = 0; } diff --git a/shared/media-playback/media-playback/decode.h b/shared/media-playback/media-playback/decode.h index 02b1acbd54c7d5..67db66b07f463d 100644 --- a/shared/media-playback/media-playback/decode.h +++ b/shared/media-playback/media-playback/decode.h @@ -50,6 +50,9 @@ struct mp_decode { int64_t last_duration; int64_t frame_pts; int64_t next_pts; + AVRational timecode_frame_rate; + int64_t timecode_frame_duration; + int64_t frame_timecode; AVFrame *in_frame; AVFrame *sw_frame; AVFrame *hw_frame; @@ -57,6 +60,7 @@ struct mp_decode { enum AVPixelFormat hw_format; bool got_first_keyframe; bool frame_ready; + bool frame_timecode_valid; bool eof; bool hw; uint16_t max_luminance; diff --git a/shared/media-playback/media-playback/media-playback.h b/shared/media-playback/media-playback/media-playback.h index 812287b8450636..464a16701d7ba1 100644 --- a/shared/media-playback/media-playback/media-playback.h +++ b/shared/media-playback/media-playback/media-playback.h @@ -46,6 +46,7 @@ struct mp_media_info { bool reconnecting; bool request_preload; bool full_decode; + bool sync_to_timecodes; }; extern media_playback_t *media_playback_create(const struct mp_media_info *info); diff --git a/shared/media-playback/media-playback/media.c b/shared/media-playback/media-playback/media.c index 9211fcea84f6bf..5f2afa842803b9 100644 --- a/shared/media-playback/media-playback/media.c +++ b/shared/media-playback/media-playback/media.c @@ -27,6 +27,66 @@ static int64_t base_sys_ts = 0; +#define NSEC_PER_SEC 1000000000LL +#define TIMECODE_DAY_NS (24LL * 60 * 60 * NSEC_PER_SEC) +#define TIMECODE_SYNC_DELAY_NS (2LL * NSEC_PER_SEC) + +static pthread_mutex_t timecode_sync_mutex = PTHREAD_MUTEX_INITIALIZER; +static bool timecode_sync_anchor_valid = false; +static int64_t timecode_sync_anchor_code_ns = 0; +static int64_t timecode_sync_anchor_obs_ns = 0; + +static inline int64_t timecode_wrap_delta(int64_t delta) +{ + while (delta > TIMECODE_DAY_NS / 2) + delta -= TIMECODE_DAY_NS; + while (delta < -TIMECODE_DAY_NS / 2) + delta += TIMECODE_DAY_NS; + + return delta; +} + +static inline int64_t timecode_mod_day(int64_t timestamp) +{ + timestamp %= TIMECODE_DAY_NS; + if (timestamp < 0) + timestamp += TIMECODE_DAY_NS; + + return timestamp; +} + +static int64_t timecode_sync_timestamp(int64_t timecode_ns) +{ + const int64_t now = (int64_t)os_gettime_ns() - base_sys_ts; + int64_t timestamp = 0; + + pthread_mutex_lock(&timecode_sync_mutex); + + if (!timecode_sync_anchor_valid) { + timecode_sync_anchor_code_ns = timecode_ns; + timecode_sync_anchor_obs_ns = now + TIMECODE_SYNC_DELAY_NS; + timecode_sync_anchor_valid = true; + timestamp = timecode_sync_anchor_obs_ns; + } else { + const int64_t expected_code_ns = timecode_sync_anchor_code_ns + (now - timecode_sync_anchor_obs_ns); + const int64_t expected_day_ns = timecode_mod_day(expected_code_ns); + const int64_t delta = timecode_wrap_delta(timecode_ns - expected_day_ns); + const int64_t extended_code_ns = expected_code_ns + delta; + + timestamp = timecode_sync_anchor_obs_ns + (extended_code_ns - timecode_sync_anchor_code_ns); + } + + pthread_mutex_unlock(&timecode_sync_mutex); + + return timestamp; +} + +static inline void reset_timecode_sync(mp_media_t *m) +{ + m->timecode_sync_active = false; + m->timecode_sync_offset = 0; +} + static inline enum video_format convert_pixel_format(int f) { switch (f) { @@ -366,6 +426,9 @@ void mp_media_next_audio(mp_media_t *m) audio.timestamp = m->full_decode ? d->frame_pts : m->base_ts + d->frame_pts - m->start_ts + m->play_sys_ts - base_sys_ts; + if (!m->full_decode && m->timecode_sync_active) + audio.timestamp += m->timecode_sync_offset; + if (audio.format == AUDIO_FORMAT_UNKNOWN) return; @@ -453,6 +516,18 @@ void mp_media_next_video(mp_media_t *m, bool preload) frame->timestamp = m->full_decode ? d->frame_pts : (m->base_ts + d->frame_pts - m->start_ts + m->play_sys_ts - base_sys_ts); + if (!m->full_decode) { + if (m->sync_to_timecodes && d->frame_timecode_valid) { + const int64_t timestamp = timecode_sync_timestamp(d->frame_timecode); + + m->timecode_sync_offset = timestamp - frame->timestamp; + m->timecode_sync_active = true; + } + + if (m->timecode_sync_active) + frame->timestamp += m->timecode_sync_offset; + } + frame->width = f->width; frame->height = f->height; frame->max_luminance = d->max_luminance; @@ -562,6 +637,7 @@ bool mp_media_reset(mp_media_t *m) m->eof = false; m->base_ts += next_ts; m->seek_next_ts = false; + reset_timecode_sync(m); seek_to(m, start_time); @@ -723,6 +799,7 @@ static bool init_avformat(mp_media_t *m) static void reset_ts(mp_media_t *m) { + reset_timecode_sync(m); m->base_ts += mp_media_get_base_pts(m); m->play_sys_ts = (int64_t)os_gettime_ns(); m->start_ts = m->next_pts_ns = mp_media_get_next_min_pts(m); @@ -889,6 +966,7 @@ bool mp_media_init(mp_media_t *media, const struct mp_media_info *info) media->speed = info->speed; media->request_preload = info->request_preload; media->is_local_file = info->is_local_file; + media->sync_to_timecodes = info->sync_to_timecodes && !info->is_local_file && !info->full_decode; da_init(media->packet_pool); if (!info->is_local_file || media->speed < 1 || media->speed > 200) diff --git a/shared/media-playback/media-playback/media.h b/shared/media-playback/media-playback/media.h index 281d4e09860ee5..80e85b5bfe0780 100644 --- a/shared/media-playback/media-playback/media.h +++ b/shared/media-playback/media-playback/media.h @@ -83,6 +83,9 @@ struct mp_media { int64_t start_ts; int64_t base_ts; bool full_decode; + bool sync_to_timecodes; + bool timecode_sync_active; + int64_t timecode_sync_offset; uint64_t interrupt_poll_ts; diff --git a/shared/media-playback/media-playback/timecode.c b/shared/media-playback/media-playback/timecode.c new file mode 100644 index 00000000000000..37e950f975ee67 --- /dev/null +++ b/shared/media-playback/media-playback/timecode.c @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026 + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#include "timecode.h" + +#include + +#define S12M_TIMECODE_WORDS 4 +#define NSEC_PER_SEC 1000000000LL + +static bool bcd_value(uint32_t tens, uint32_t units, uint32_t limit, uint32_t *value) +{ + if (tens > 9 || units > 9) + return false; + + *value = tens * 10 + units; + return *value <= limit; +} + +static uint32_t timecode_frame_count(AVRational frame_rate) +{ + int64_t frames = 0; + + if (frame_rate.num <= 0 || frame_rate.den <= 0) + return 0; + + frames = ((int64_t)frame_rate.num + frame_rate.den / 2) / frame_rate.den; + return frames > 0 && frames <= UINT32_MAX ? (uint32_t)frames : 0; +} + +static bool read_s12m_timecode(uint32_t timecode, AVRational frame_rate, int64_t frame_duration, int64_t *timestamp) +{ + uint32_t hours = 0; + uint32_t minutes = 0; + uint32_t seconds = 0; + uint32_t frame = 0; + const uint32_t frames = timecode_frame_count(frame_rate); + const bool drop_frame = !!(timecode & (1U << 30)); + + if (frame_duration <= 0 || !frames) + return false; + + if (!bcd_value((timecode >> 4) & 0x3, timecode & 0xf, 23, &hours)) + return false; + if (!bcd_value((timecode >> 12) & 0x7, (timecode >> 8) & 0xf, 59, &minutes)) + return false; + if (!bcd_value((timecode >> 20) & 0x7, (timecode >> 16) & 0xf, 59, &seconds)) + return false; + if (!bcd_value((timecode >> 28) & 0x3, (timecode >> 24) & 0xf, 39, &frame)) + return false; + + if (frame_rate.num > 0 && frame_rate.den > 0 && av_cmp_q(frame_rate, (AVRational){30, 1}) > 0) { + const bool field = av_cmp_q(frame_rate, (AVRational){50, 1}) == 0 ? !!(timecode & (1U << 7)) + : !!(timecode & (1U << 23)); + frame = frame * 2 + field; + } + + if (frame >= frames) + return false; + + if (drop_frame) { + const uint32_t drop_frames = frames / 30 * 2; + const uint32_t total_minutes = hours * 60 + minutes; + int64_t frame_count = 0; + + if (!drop_frames || frames % 30) + return false; + if (seconds == 0 && minutes % 10 && frame < drop_frames) + return false; + + frame_count = ((hours * 60LL + minutes) * 60LL + seconds) * frames + frame - + drop_frames * (total_minutes - total_minutes / 10); + *timestamp = frame_count * frame_duration; + } else { + *timestamp = ((hours * 60LL + minutes) * 60LL + seconds) * NSEC_PER_SEC + frame * frame_duration; + } + + return true; +} + +bool mp_s12m_timecode_parse(const uint8_t *data, size_t size, AVRational frame_rate, int64_t frame_duration, + int64_t *timestamp) +{ + uint32_t values[S12M_TIMECODE_WORDS] = {0}; + uint32_t count = 0; + + if (!data || !timestamp || size < sizeof(uint32_t) * 2) + return false; + + if (size > sizeof(values)) + size = sizeof(values); + + memcpy(values, data, size); + + count = values[0]; + if (count < 1 || count > 3 || size < sizeof(uint32_t) * (count + 1)) + return false; + + return read_s12m_timecode(values[1], frame_rate, frame_duration, timestamp); +} diff --git a/shared/media-playback/media-playback/timecode.h b/shared/media-playback/media-playback/timecode.h new file mode 100644 index 00000000000000..9dc205d653bb9f --- /dev/null +++ b/shared/media-playback/media-playback/timecode.h @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#pragma once + +#include +#include +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +extern bool mp_s12m_timecode_parse(const uint8_t *data, size_t size, AVRational frame_rate, int64_t frame_duration, + int64_t *timestamp); + +#ifdef __cplusplus +} +#endif diff --git a/test/media-playback/CMakeLists.txt b/test/media-playback/CMakeLists.txt new file mode 100644 index 00000000000000..a7881cadf54d24 --- /dev/null +++ b/test/media-playback/CMakeLists.txt @@ -0,0 +1,10 @@ +add_executable(test_media_playback_timecode) + +target_sources( + test_media_playback_timecode + PRIVATE test_timecode.c ../../shared/media-playback/media-playback/timecode.c +) + +target_include_directories(test_media_playback_timecode PRIVATE ../../shared/media-playback/media-playback) + +add_test(NAME test_media_playback_timecode COMMAND test_media_playback_timecode) diff --git a/test/media-playback/test_timecode.c b/test/media-playback/test_timecode.c new file mode 100644 index 00000000000000..5dc922ec72ce6e --- /dev/null +++ b/test/media-playback/test_timecode.c @@ -0,0 +1,195 @@ +#include "timecode.h" + +#include +#include + +#define NSEC_PER_SEC 1000000000LL + +static uint32_t s12m_timecode(uint32_t hours, uint32_t minutes, uint32_t seconds, uint32_t frame) +{ + return ((frame / 10) << 28) | ((frame % 10) << 24) | ((seconds / 10) << 20) | ((seconds % 10) << 16) | + ((minutes / 10) << 12) | ((minutes % 10) << 8) | ((hours / 10) << 4) | (hours % 10); +} + +static bool parses_s12m_timecode(void) +{ + uint32_t data[] = { + 1, + s12m_timecode(12, 34, 56, 7), + 0, + 0, + }; + int64_t timestamp = 0; + const int64_t frame_duration = 33333333; + const int64_t expected = ((12 * 60 + 34) * 60 + 56) * NSEC_PER_SEC + 7 * frame_duration; + + return mp_s12m_timecode_parse((const uint8_t *)data, sizeof(data), (AVRational){30, 1}, frame_duration, + ×tamp) && + timestamp == expected; +} + +static bool parses_high_frame_rate_s12m_timecode(void) +{ + uint32_t data[] = { + 1, + s12m_timecode(1, 2, 3, 5) | (1U << 7), + 0, + 0, + }; + int64_t timestamp = 0; + const int64_t frame_duration = 20000000; + const int64_t expected = ((1 * 60 + 2) * 60 + 3) * NSEC_PER_SEC + 11 * frame_duration; + + return mp_s12m_timecode_parse((const uint8_t *)data, sizeof(data), (AVRational){50, 1}, frame_duration, + ×tamp) && + timestamp == expected; +} + +static bool parses_60hz_s12m_timecode(void) +{ + uint32_t data[] = { + 1, + s12m_timecode(1, 2, 3, 5) | (1U << 23), + 0, + 0, + }; + int64_t timestamp = 0; + const int64_t frame_duration = 16683333; + const int64_t expected = ((1 * 60 + 2) * 60 + 3) * NSEC_PER_SEC + 11 * frame_duration; + + return mp_s12m_timecode_parse((const uint8_t *)data, sizeof(data), (AVRational){60000, 1001}, frame_duration, + ×tamp) && + timestamp == expected; +} + +static bool parses_drop_frame_s12m_timecode(void) +{ + uint32_t data[] = { + 1, + s12m_timecode(1, 2, 3, 4) | (1U << 30), + 0, + 0, + }; + int64_t timestamp = 0; + const int64_t frame_duration = 33366667; + const int64_t dropped_frames = 2 * (62 - 6); + const int64_t expected = (((1 * 60 + 2) * 60 + 3) * 30 + 4 - dropped_frames) * frame_duration; + + return mp_s12m_timecode_parse((const uint8_t *)data, sizeof(data), (AVRational){30000, 1001}, frame_duration, + ×tamp) && + timestamp == expected; +} + +static bool rejects_invalid_s12m_count(void) +{ + uint32_t data[] = { + 4, + s12m_timecode(12, 34, 56, 7), + 0, + 0, + }; + int64_t timestamp = 0; + + return !mp_s12m_timecode_parse((const uint8_t *)data, sizeof(data), (AVRational){30, 1}, 33333333, ×tamp); +} + +static bool rejects_truncated_s12m_timecode(void) +{ + uint32_t data[] = { + 2, + s12m_timecode(12, 34, 56, 7), + }; + int64_t timestamp = 0; + + return !mp_s12m_timecode_parse((const uint8_t *)data, sizeof(data), (AVRational){30, 1}, 33333333, ×tamp); +} + +static bool rejects_invalid_s12m_value(void) +{ + uint32_t data[] = { + 1, + s12m_timecode(29, 34, 56, 7), + 0, + 0, + }; + int64_t timestamp = 0; + + return !mp_s12m_timecode_parse((const uint8_t *)data, sizeof(data), (AVRational){30, 1}, 33333333, ×tamp); +} + +static bool rejects_out_of_range_s12m_frame(void) +{ + uint32_t data[] = { + 1, + s12m_timecode(12, 34, 56, 35), + 0, + 0, + }; + int64_t timestamp = 0; + + return !mp_s12m_timecode_parse((const uint8_t *)data, sizeof(data), (AVRational){30, 1}, 33333333, ×tamp); +} + +static bool rejects_invalid_drop_frame_s12m_timecode(void) +{ + uint32_t data[] = { + 1, + s12m_timecode(1, 2, 0, 1) | (1U << 30), + 0, + 0, + }; + int64_t timestamp = 0; + + return !mp_s12m_timecode_parse((const uint8_t *)data, sizeof(data), (AVRational){30000, 1001}, 33366667, + ×tamp); +} + +int main(void) +{ + if (!parses_s12m_timecode()) { + fprintf(stderr, "parses_s12m_timecode failed\n"); + return 1; + } + + if (!parses_high_frame_rate_s12m_timecode()) { + fprintf(stderr, "parses_high_frame_rate_s12m_timecode failed\n"); + return 1; + } + + if (!parses_60hz_s12m_timecode()) { + fprintf(stderr, "parses_60hz_s12m_timecode failed\n"); + return 1; + } + + if (!parses_drop_frame_s12m_timecode()) { + fprintf(stderr, "parses_drop_frame_s12m_timecode failed\n"); + return 1; + } + + if (!rejects_invalid_s12m_count()) { + fprintf(stderr, "rejects_invalid_s12m_count failed\n"); + return 1; + } + + if (!rejects_truncated_s12m_timecode()) { + fprintf(stderr, "rejects_truncated_s12m_timecode failed\n"); + return 1; + } + + if (!rejects_invalid_s12m_value()) { + fprintf(stderr, "rejects_invalid_s12m_value failed\n"); + return 1; + } + + if (!rejects_out_of_range_s12m_frame()) { + fprintf(stderr, "rejects_out_of_range_s12m_frame failed\n"); + return 1; + } + + if (!rejects_invalid_drop_frame_s12m_timecode()) { + fprintf(stderr, "rejects_invalid_drop_frame_s12m_timecode failed\n"); + return 1; + } + + return 0; +} From a4135eccce6d4f9033a803edfaf926008bd84666 Mon Sep 17 00:00:00 2001 From: Michael Date: Thu, 14 May 2026 15:56:03 +0100 Subject: [PATCH 2/3] media-playback: Fix S12M fractional cadence S12M non-drop timecode at fractional rates used wall-clock seconds plus a frame offset. That made adjacent labels at a second boundary shorter than one frame, causing timecode sync to jitter. Use frame counts for fractional non-drop timecode so 30000/1001 and 60000/1001 sources keep a steady frame cadence. Keep exact integer-rate and drop-frame paths unchanged. --- .../media-playback/media-playback/timecode.c | 14 +++++--- test/media-playback/test_timecode.c | 35 ++++++++++++++++++- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/shared/media-playback/media-playback/timecode.c b/shared/media-playback/media-playback/timecode.c index 37e950f975ee67..89a4bccb9452a6 100644 --- a/shared/media-playback/media-playback/timecode.c +++ b/shared/media-playback/media-playback/timecode.c @@ -41,6 +41,12 @@ static uint32_t timecode_frame_count(AVRational frame_rate) return frames > 0 && frames <= UINT32_MAX ? (uint32_t)frames : 0; } +static int64_t timecode_frame_number(uint32_t hours, uint32_t minutes, uint32_t seconds, uint32_t frames, + uint32_t frame) +{ + return ((hours * 60LL + minutes) * 60LL + seconds) * frames + frame; +} + static bool read_s12m_timecode(uint32_t timecode, AVRational frame_rate, int64_t frame_duration, int64_t *timestamp) { uint32_t hours = 0; @@ -74,16 +80,16 @@ static bool read_s12m_timecode(uint32_t timecode, AVRational frame_rate, int64_t if (drop_frame) { const uint32_t drop_frames = frames / 30 * 2; const uint32_t total_minutes = hours * 60 + minutes; - int64_t frame_count = 0; + const int64_t frame_count = timecode_frame_number(hours, minutes, seconds, frames, frame); if (!drop_frames || frames % 30) return false; if (seconds == 0 && minutes % 10 && frame < drop_frames) return false; - frame_count = ((hours * 60LL + minutes) * 60LL + seconds) * frames + frame - - drop_frames * (total_minutes - total_minutes / 10); - *timestamp = frame_count * frame_duration; + *timestamp = (frame_count - drop_frames * (total_minutes - total_minutes / 10)) * frame_duration; + } else if (av_cmp_q(frame_rate, (AVRational){frames, 1}) != 0) { + *timestamp = timecode_frame_number(hours, minutes, seconds, frames, frame) * frame_duration; } else { *timestamp = ((hours * 60LL + minutes) * 60LL + seconds) * NSEC_PER_SEC + frame * frame_duration; } diff --git a/test/media-playback/test_timecode.c b/test/media-playback/test_timecode.c index 5dc922ec72ce6e..ab596b5be27dfa 100644 --- a/test/media-playback/test_timecode.c +++ b/test/media-playback/test_timecode.c @@ -55,7 +55,7 @@ static bool parses_60hz_s12m_timecode(void) }; int64_t timestamp = 0; const int64_t frame_duration = 16683333; - const int64_t expected = ((1 * 60 + 2) * 60 + 3) * NSEC_PER_SEC + 11 * frame_duration; + const int64_t expected = (((1 * 60 + 2) * 60 + 3) * 60 + 11) * frame_duration; return mp_s12m_timecode_parse((const uint8_t *)data, sizeof(data), (AVRational){60000, 1001}, frame_duration, ×tamp) && @@ -80,6 +80,34 @@ static bool parses_drop_frame_s12m_timecode(void) timestamp == expected; } +static bool keeps_fractional_s12m_frame_cadence(void) +{ + uint32_t frame29[] = { + 1, + s12m_timecode(0, 0, 0, 29), + 0, + 0, + }; + uint32_t frame30[] = { + 1, + s12m_timecode(0, 0, 1, 0), + 0, + 0, + }; + int64_t first = 0; + int64_t second = 0; + const int64_t frame_duration = 33366667; + + if (!mp_s12m_timecode_parse((const uint8_t *)frame29, sizeof(frame29), (AVRational){30000, 1001}, + frame_duration, &first)) + return false; + if (!mp_s12m_timecode_parse((const uint8_t *)frame30, sizeof(frame30), (AVRational){30000, 1001}, + frame_duration, &second)) + return false; + + return second - first == frame_duration; +} + static bool rejects_invalid_s12m_count(void) { uint32_t data[] = { @@ -166,6 +194,11 @@ int main(void) return 1; } + if (!keeps_fractional_s12m_frame_cadence()) { + fprintf(stderr, "keeps_fractional_s12m_frame_cadence failed\n"); + return 1; + } + if (!rejects_invalid_s12m_count()) { fprintf(stderr, "rejects_invalid_s12m_count failed\n"); return 1; From c0e8f9ba8e2b80a81816facab769ed788c8e7d69 Mon Sep 17 00:00:00 2001 From: Michael Date: Thu, 14 May 2026 21:25:53 +0100 Subject: [PATCH 3/3] media-playback: Add test copyright header --- test/media-playback/test_timecode.c | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/media-playback/test_timecode.c b/test/media-playback/test_timecode.c index ab596b5be27dfa..c8440a0f6a2be9 100644 --- a/test/media-playback/test_timecode.c +++ b/test/media-playback/test_timecode.c @@ -1,3 +1,19 @@ +/* + * Copyright (c) 2026 + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + #include "timecode.h" #include