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..89a4bccb9452a6 --- /dev/null +++ b/shared/media-playback/media-playback/timecode.c @@ -0,0 +1,119 @@ +/* + * 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 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; + 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; + 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; + + *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; + } + + 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..c8440a0f6a2be9 --- /dev/null +++ b/test/media-playback/test_timecode.c @@ -0,0 +1,244 @@ +/* + * 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 +#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) * 60 + 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 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[] = { + 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 (!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; + } + + 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; +}