diff --git a/src/ccextractor.c b/src/ccextractor.c index 71378849a..f1a6d444f 100644 --- a/src/ccextractor.c +++ b/src/ccextractor.c @@ -202,6 +202,12 @@ int start_ccx() if (!ret) ret = tmp; break; + case CCX_SM_SCC: + mprint("\rAnalyzing data in SCC (Scenarist Closed Caption) mode\n"); + tmp = raw_loop(ctx); + if (!ret) + ret = tmp; + break; case CCX_SM_RCWT: mprint("\rAnalyzing data in CCExtractor's binary format\n"); tmp = rcwt_loop(ctx); diff --git a/src/lib_ccx/ccx_common_constants.h b/src/lib_ccx/ccx_common_constants.h index bce9b2678..eb62f8b54 100644 --- a/src/lib_ccx/ccx_common_constants.h +++ b/src/lib_ccx/ccx_common_constants.h @@ -212,6 +212,7 @@ enum ccx_stream_mode_enum CCX_SM_GXF = 11, CCX_SM_MKV = 12, CCX_SM_MXF = 13, + CCX_SM_SCC = 14, // Scenarist Closed Caption input CCX_SM_AUTODETECT = 16 }; diff --git a/src/lib_ccx/ccx_common_option.c b/src/lib_ccx/ccx_common_option.c index 942911319..4ec02ecf6 100644 --- a/src/lib_ccx/ccx_common_option.c +++ b/src/lib_ccx/ccx_common_option.c @@ -152,6 +152,8 @@ void init_options(struct ccx_s_options *options) options->settings_dtvcc.services_enabled, 0, CCX_DTVCC_MAX_SERVICES * sizeof(options->settings_dtvcc.services_enabled[0])); + options->scc_framerate = 0; // Default: 29.97fps + #ifdef WITH_LIBCURL options->curlposturl = NULL; #endif diff --git a/src/lib_ccx/ccx_common_option.h b/src/lib_ccx/ccx_common_option.h index 3822c290a..ef5b243b4 100644 --- a/src/lib_ccx/ccx_common_option.h +++ b/src/lib_ccx/ccx_common_option.h @@ -195,6 +195,7 @@ struct ccx_s_options // Options from user parameters int multiprogram; int out_interval; int segment_on_key_frames_only; + int scc_framerate; // SCC input framerate: 0=29.97 (default), 1=24, 2=25, 3=30 #ifdef WITH_LIBCURL char *curlposturl; #endif diff --git a/src/lib_ccx/general_loop.c b/src/lib_ccx/general_loop.c index 807d1a5c0..d82a03314 100644 --- a/src/lib_ccx/general_loop.c +++ b/src/lib_ccx/general_loop.c @@ -575,6 +575,7 @@ int raw_loop(struct lib_ccx_ctx *ctx) struct lib_cc_decode *dec_ctx = NULL; int caps = 0; int is_dvdraw = 0; // Flag to track if this is DVD raw format + int is_scc = 0; // Flag to track if this is SCC format int is_mcc_output = 0; // Flag for MCC output format dec_ctx = update_decoder_list(ctx); @@ -607,13 +608,20 @@ int raw_loop(struct lib_ccx_ctx *ctx) break; // Check if this is DVD raw format using Rust detection - if (!is_dvdraw && ccxr_is_dvdraw_header(data->buffer, (unsigned int)data->len)) + if (!is_dvdraw && !is_scc && ccxr_is_dvdraw_header(data->buffer, (unsigned int)data->len)) { is_dvdraw = 1; mprint("Detected McPoodle's DVD raw format\n"); } - if (is_mcc_output && !is_dvdraw) + // Check if this is SCC format using Rust detection + if (!is_scc && !is_dvdraw && ccxr_is_scc_file(data->buffer, (unsigned int)data->len)) + { + is_scc = 1; + mprint("Detected SCC (Scenarist Closed Caption) format\n"); + } + + if (is_mcc_output && !is_dvdraw && !is_scc) { // For MCC output, encode raw data directly without decoding // This preserves the original CEA-608 byte pairs in CDP format @@ -626,6 +634,11 @@ int raw_loop(struct lib_ccx_ctx *ctx) // Use Rust implementation - handles timing internally ret = ccxr_process_dvdraw(dec_ctx, dec_sub, data->buffer, (unsigned int)data->len); } + else if (is_scc) + { + // Use Rust SCC implementation - handles timing internally via SMPTE timecodes + ret = ccxr_process_scc(dec_ctx, dec_sub, data->buffer, (unsigned int)data->len, ccx_options.scc_framerate); + } else { ret = process_raw(dec_ctx, dec_sub, data->buffer, data->len); diff --git a/src/lib_ccx/lib_ccx.h b/src/lib_ccx/lib_ccx.h index b38519ad3..7aae4d2cb 100644 --- a/src/lib_ccx/lib_ccx.h +++ b/src/lib_ccx/lib_ccx.h @@ -43,7 +43,7 @@ struct file_report }; // Stuff for telxcc.c -#define MAX_TLT_PAGES_EXTRACT 8 // Maximum number of teletext pages to extract simultaneously +#define MAX_TLT_PAGES_EXTRACT 8 // Maximum number of teletext pages to extract simultaneously struct ccx_s_teletext_config { @@ -55,11 +55,11 @@ struct ccx_s_teletext_config uint8_t nonempty : 1; // produce at least one (dummy) frame // uint8_t se_mode : 1; // search engine compatible mode => Uses CCExtractor's write_format // uint64_t utc_refvalue; // UTC referential value => Moved to ccx_decoders_common, so can be used for other decoders (608/xds) too - uint16_t user_page; // Page selected by user (legacy, first page) + uint16_t user_page; // Page selected by user (legacy, first page) // Multi-page teletext extraction (issue #665) - uint16_t user_pages[MAX_TLT_PAGES_EXTRACT]; // Pages selected by user for extraction - int num_user_pages; // Number of pages to extract (0 = auto-detect single page) - int extract_all_pages; // If 1, extract all detected subtitle pages + uint16_t user_pages[MAX_TLT_PAGES_EXTRACT]; // Pages selected by user for extraction + int num_user_pages; // Number of pages to extract (0 = auto-detect single page) + int extract_all_pages; // If 1, extract all detected subtitle pages int dolevdist; // 0=Don't attempt to correct errors int levdistmincnt, levdistmaxpct; // Means 2 fails or less is "the same", 10% or less is also "the same" struct ccx_boundary_time extraction_start, extraction_end; // Segment we actually process @@ -183,6 +183,10 @@ size_t process_raw(struct lib_cc_decode *ctx, struct cc_subtitle *sub, unsigned unsigned int ccxr_process_dvdraw(struct lib_cc_decode *ctx, struct cc_subtitle *sub, const unsigned char *buffer, unsigned int len); int ccxr_is_dvdraw_header(const unsigned char *buffer, unsigned int len); +// Rust FFI: SCC (Scenarist Closed Caption) format processing (see src/rust/src/demuxer/scc.rs) +unsigned int ccxr_process_scc(struct lib_cc_decode *ctx, struct cc_subtitle *sub, const unsigned char *buffer, unsigned int len, int framerate); +int ccxr_is_scc_file(const unsigned char *buffer, unsigned int len); + int general_loop(struct lib_ccx_ctx *ctx); void process_hex(struct lib_ccx_ctx *ctx, char *filename); int rcwt_loop(struct lib_ccx_ctx *ctx); diff --git a/src/lib_ccx/stream_functions.c b/src/lib_ccx/stream_functions.c index 6f0a7402e..9f6000fa9 100644 --- a/src/lib_ccx/stream_functions.c +++ b/src/lib_ccx/stream_functions.c @@ -78,6 +78,30 @@ void detect_stream_type(struct ccx_demuxer *ctx) ctx->startbytes[7] == 0xf8) ctx->stream_mode = CCX_SM_MCPOODLESRAW; } + // Check for SCC (Scenarist Closed Caption) text format + // SCC files start with "Scenarist_SCC V1.0" (18 bytes), optionally with UTF-8 BOM (3 bytes) + if (ctx->stream_mode == CCX_SM_ELEMENTARY_OR_NOT_FOUND) + { + unsigned char *check_buf = ctx->startbytes; + int check_pos = 0; + + // Skip UTF-8 BOM if present + if (ctx->startbytes_avail >= 3 && + ctx->startbytes[0] == 0xEF && + ctx->startbytes[1] == 0xBB && + ctx->startbytes[2] == 0xBF) + { + check_buf += 3; + check_pos = 3; + } + + if (ctx->startbytes_avail >= check_pos + 18 && + memcmp(check_buf, "Scenarist_SCC V1.0", 18) == 0) + { + ctx->stream_mode = CCX_SM_SCC; + mprint("Detected SCC (Scenarist Closed Caption) format\n"); + } + } #ifdef WTV_DEBUG if (ctx->stream_mode == CCX_SM_ELEMENTARY_OR_NOT_FOUND && ctx->startbytes_avail >= 6) { diff --git a/src/rust/lib_ccxr/src/common/constants.rs b/src/rust/lib_ccxr/src/common/constants.rs index a93bf2399..8b83539e4 100644 --- a/src/rust/lib_ccxr/src/common/constants.rs +++ b/src/rust/lib_ccxr/src/common/constants.rs @@ -278,6 +278,7 @@ pub enum StreamMode { Gxf = 11, Mkv = 12, Mxf = 13, + Scc = 14, // Scenarist Closed Caption input Autodetect = 16, } #[derive(Debug, Eq, Clone, Copy)] diff --git a/src/rust/lib_ccxr/src/common/options.rs b/src/rust/lib_ccxr/src/common/options.rs index 55f698dd6..2c4995873 100644 --- a/src/rust/lib_ccxr/src/common/options.rs +++ b/src/rust/lib_ccxr/src/common/options.rs @@ -517,6 +517,8 @@ pub struct Options { pub multiprogram: bool, pub out_interval: i32, pub segment_on_key_frames_only: bool, + /// SCC input framerate: 0=29.97 (default), 1=24, 2=25, 3=30 + pub scc_framerate: i32, pub debug_mask: DebugMessageMask, #[cfg(feature = "with_libcurl")] @@ -618,6 +620,7 @@ impl Default for Options { multiprogram: Default::default(), out_interval: -1, segment_on_key_frames_only: Default::default(), + scc_framerate: 0, // 0 = 29.97fps (default) debug_mask: DebugMessageMask::new( DebugMessageFlag::GENERIC_NOTICE, DebugMessageFlag::VERBOSE, diff --git a/src/rust/src/args.rs b/src/rust/src/args.rs index f802281df..2a5cf3fb6 100644 --- a/src/rust/src/args.rs +++ b/src/rust/src/args.rs @@ -290,6 +290,11 @@ pub struct Args { /// DVD Recorder) #[arg(long="90090", verbatim_doc_comment, help_heading=OPTIONS_AFFECTING_INPUT_FILES)] pub mpeg90090: bool, + /// Set the frame rate for SCC (Scenarist Closed Caption) input files. + /// Valid values: 29.97 (default), 24, 25, 30 + /// Example: --scc-framerate 25 + #[arg(long="scc-framerate", verbatim_doc_comment, value_name="fps", help_heading=OPTIONS_AFFECTING_INPUT_FILES)] + pub scc_framerate: Option, /// By default, ccextractor will process input files in /// sequence as if they were all one large file (i.e. /// split by a generic, non video-aware tool. If you diff --git a/src/rust/src/common.rs b/src/rust/src/common.rs index e5af90d4d..f400ae9a4 100755 --- a/src/rust/src/common.rs +++ b/src/rust/src/common.rs @@ -275,6 +275,7 @@ pub unsafe fn copy_from_rust(ccx_s_options: *mut ccx_s_options, options: Options (*ccx_s_options).multiprogram = options.multiprogram as _; (*ccx_s_options).out_interval = options.out_interval; (*ccx_s_options).segment_on_key_frames_only = options.segment_on_key_frames_only as _; + (*ccx_s_options).scc_framerate = options.scc_framerate; #[cfg(feature = "with_libcurl")] { if options.curlposturl.is_some() { @@ -531,6 +532,7 @@ pub unsafe fn copy_to_rust(ccx_s_options: *const ccx_s_options) -> Options { options.multiprogram = (*ccx_s_options).multiprogram != 0; options.out_interval = (*ccx_s_options).out_interval; options.segment_on_key_frames_only = (*ccx_s_options).segment_on_key_frames_only != 0; + options.scc_framerate = (*ccx_s_options).scc_framerate; // Handle optional features with conditional compilation #[cfg(feature = "with_libcurl")] @@ -873,6 +875,7 @@ impl CType for StreamMode { StreamMode::Gxf => ccx_stream_mode_enum_CCX_SM_GXF as _, StreamMode::Mkv => ccx_stream_mode_enum_CCX_SM_MKV as _, StreamMode::Mxf => ccx_stream_mode_enum_CCX_SM_MXF as _, + StreamMode::Scc => ccx_stream_mode_enum_CCX_SM_SCC as _, StreamMode::Autodetect => ccx_stream_mode_enum_CCX_SM_AUTODETECT as _, _ => ccx_stream_mode_enum_CCX_SM_ELEMENTARY_OR_NOT_FOUND as _, } diff --git a/src/rust/src/ctorust.rs b/src/rust/src/ctorust.rs index 0101951b0..6854a109c 100755 --- a/src/rust/src/ctorust.rs +++ b/src/rust/src/ctorust.rs @@ -287,6 +287,7 @@ impl FromCType for StreamMode { 11 => StreamMode::Gxf, 12 => StreamMode::Mkv, 13 => StreamMode::Mxf, + 14 => StreamMode::Scc, 16 => StreamMode::Autodetect, _ => StreamMode::ElementaryOrNotFound, }) diff --git a/src/rust/src/demuxer/mod.rs b/src/rust/src/demuxer/mod.rs index 4af6af76d..37698963f 100644 --- a/src/rust/src/demuxer/mod.rs +++ b/src/rust/src/demuxer/mod.rs @@ -39,4 +39,5 @@ pub mod common_types; pub mod demux; pub mod demuxer_data; pub mod dvdraw; +pub mod scc; pub mod stream_functions; diff --git a/src/rust/src/demuxer/scc.rs b/src/rust/src/demuxer/scc.rs new file mode 100644 index 000000000..debedf5fd --- /dev/null +++ b/src/rust/src/demuxer/scc.rs @@ -0,0 +1,452 @@ +//! SCC (Scenarist Closed Caption) format parser +//! +//! This module provides functionality to parse SCC files and extract CEA-608 caption data. +//! SCC is a text-based format commonly used in professional video production. +//! +//! # Format Specification +//! +//! ```text +//! Scenarist_SCC V1.0 +//! +//! 00:00:00:00 9420 9420 94ad 94ad 9470 9470 4c6f 7265 +//! 00:00:02:15 942c 942c +//! ``` +//! +//! - **Header:** `Scenarist_SCC V1.0` (first line) +//! - **Lines:** `HH:MM:SS:FF\t` (SMPTE timecode + TAB + space-separated hex bytes) +//! - **Hex pairs:** CEA-608 byte pairs with odd parity bits (e.g., `9420` = RCL command) +//! - **Frame rate:** 29.97fps NTSC drop-frame (default), also supports 24/25/30fps + +/// SCC format header +pub const SCC_HEADER: &[u8] = b"Scenarist_SCC V1.0"; + +/// Frame rate options for SMPTE timecode conversion +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SccFrameRate { + /// NTSC drop-frame (default) - 29.97fps + #[default] + Fps29_97, + /// Film - 24fps + Fps24, + /// PAL - 25fps + Fps25, + /// NTSC non-drop-frame - 30fps + Fps30, +} + +impl SccFrameRate { + /// Convert from integer value (used in FFI) + /// 0=29.97 (default), 1=24, 2=25, 3=30 + pub fn from_int(value: i32) -> Self { + match value { + 1 => SccFrameRate::Fps24, + 2 => SccFrameRate::Fps25, + 3 => SccFrameRate::Fps30, + _ => SccFrameRate::Fps29_97, + } + } + + /// Get milliseconds per frame for this frame rate + pub fn ms_per_frame(&self) -> f64 { + match self { + SccFrameRate::Fps29_97 => 1001.0 / 30.0, // ~33.37ms + SccFrameRate::Fps24 => 1000.0 / 24.0, // ~41.67ms + SccFrameRate::Fps25 => 1000.0 / 25.0, // 40ms + SccFrameRate::Fps30 => 1000.0 / 30.0, // ~33.33ms + } + } +} + +/// Result of parsing an SCC file +#[derive(Debug, Clone)] +pub struct SccParseResult { + /// Parsed caption lines with timing + pub lines: Vec, + /// Number of bytes consumed from the input + pub bytes_consumed: usize, +} + +/// Single line from SCC file with timing and caption data +#[derive(Debug, Clone)] +pub struct SccLine { + /// Timestamp in milliseconds + pub time_ms: i64, + /// CEA-608 byte pairs + pub pairs: Vec<[u8; 2]>, +} + +/// Caption block type for Field 1 (CC1/CC3) +pub const CC_TYPE_FIELD1: u8 = 0x04; + +/// Caption block type for Field 2 (CC2/CC4) +#[allow(dead_code)] +pub const CC_TYPE_FIELD2: u8 = 0x05; + +/// Check if buffer starts with SCC header +/// +/// # Arguments +/// * `buffer` - The byte buffer to check +/// +/// # Returns +/// `true` if the buffer starts with the SCC header signature +pub fn is_scc_file(buffer: &[u8]) -> bool { + // Skip UTF-8 BOM if present + let data = if buffer.len() >= 3 && buffer[0] == 0xEF && buffer[1] == 0xBB && buffer[2] == 0xBF { + &buffer[3..] + } else { + buffer + }; + + data.len() >= SCC_HEADER.len() && &data[..SCC_HEADER.len()] == SCC_HEADER +} + +/// Parse SMPTE timecode to milliseconds +/// +/// Format: HH:MM:SS:FF or HH:MM:SS;FF (drop-frame uses semicolon) +/// +/// # Arguments +/// * `timecode` - The SMPTE timecode string +/// * `fps` - The frame rate to use for conversion +/// +/// # Returns +/// The time in milliseconds, or None if parsing fails +pub fn parse_smpte_timecode(timecode: &str, fps: SccFrameRate) -> Option { + let parts: Vec<&str> = timecode.split([':', ';']).collect(); + if parts.len() != 4 { + return None; + } + + let h: u32 = parts[0].parse().ok()?; + let m: u32 = parts[1].parse().ok()?; + let s: u32 = parts[2].parse().ok()?; + let f: u32 = parts[3].parse().ok()?; + + // Convert to milliseconds based on frame rate + let ms_per_frame = fps.ms_per_frame(); + let total_ms = (h * 3600000 + m * 60000 + s * 1000) as f64 + (f as f64 * ms_per_frame); + + Some(total_ms as i64) +} + +/// Parse hex pair string (e.g., "9420") to bytes +/// +/// # Arguments +/// * `s` - A 4-character hex string +/// +/// # Returns +/// The two bytes, or None if parsing fails +pub fn parse_hex_pair(s: &str) -> Option<[u8; 2]> { + if s.len() != 4 { + return None; + } + let b1 = u8::from_str_radix(&s[0..2], 16).ok()?; + let b2 = u8::from_str_radix(&s[2..4], 16).ok()?; + Some([b1, b2]) +} + +/// Parse space-separated hex pairs from SCC line +/// +/// # Arguments +/// * `hex_str` - Space-separated hex pairs (e.g., "9420 9420 94ad") +/// +/// # Returns +/// Vector of byte pairs +pub fn parse_hex_pairs(hex_str: &str) -> Vec<[u8; 2]> { + hex_str + .split_whitespace() + .filter_map(parse_hex_pair) + .collect() +} + +/// Parse entire SCC file content +/// +/// # Arguments +/// * `content` - The SCC file content as a string +/// * `fps` - The frame rate to use for timecode conversion +/// +/// # Returns +/// Parse result containing all caption lines +pub fn parse_scc(content: &str, fps: SccFrameRate) -> SccParseResult { + let mut lines = Vec::new(); + + for line in content.lines() { + let line = line.trim(); + + // Skip empty lines, header, and comments + if line.is_empty() || line.starts_with("Scenarist_SCC") { + continue; + } + + // Parse timecode and hex data + // Format: "HH:MM:SS:FF\t" + if let Some(tab_pos) = line.find('\t') { + let timecode = &line[..tab_pos]; + let hex_data = &line[tab_pos + 1..]; + + if let Some(time_ms) = parse_smpte_timecode(timecode, fps) { + let pairs = parse_hex_pairs(hex_data); + if !pairs.is_empty() { + lines.push(SccLine { time_ms, pairs }); + } + } + } + } + + SccParseResult { + lines, + bytes_consumed: content.len(), + } +} + +/// Process SCC with callbacks (similar to dvdraw pattern) +/// +/// This function parses the SCC format and calls callbacks for each caption block +/// and timing update. +/// +/// # Arguments +/// * `content` - The SCC file content as a string +/// * `fps` - The frame rate to use for timecode conversion +/// * `callback` - Called for each caption pair: (cc_type, data1, data2) +/// * `timing_callback` - Called with the timestamp in milliseconds before each line +/// +/// # Returns +/// The number of bytes consumed +pub fn parse_scc_with_callbacks( + content: &str, + fps: SccFrameRate, + mut callback: F, + mut timing_callback: T, +) -> usize +where + F: FnMut(u8, u8, u8), + T: FnMut(i64), +{ + let result = parse_scc(content, fps); + + for line in &result.lines { + // Call timing callback with this line's timestamp + timing_callback(line.time_ms); + + // Process each caption pair as field 1 (CC1) + for pair in &line.pairs { + callback(CC_TYPE_FIELD1, pair[0], pair[1]); + } + } + + result.bytes_consumed +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_scc_file() { + assert!(is_scc_file(b"Scenarist_SCC V1.0\n\n")); + assert!(is_scc_file(b"\xEF\xBB\xBFScenarist_SCC V1.0\n")); // With BOM + assert!(!is_scc_file(b"Not an SCC file")); + assert!(!is_scc_file(b"Scenarist_SCC V2.0")); // Wrong version + assert!(!is_scc_file(b"")); // Empty + assert!(!is_scc_file(b"Scenarist")); // Too short + } + + #[test] + fn test_scc_frame_rate_default() { + assert_eq!(SccFrameRate::default(), SccFrameRate::Fps29_97); + } + + #[test] + fn test_scc_frame_rate_from_int() { + assert_eq!(SccFrameRate::from_int(0), SccFrameRate::Fps29_97); + assert_eq!(SccFrameRate::from_int(1), SccFrameRate::Fps24); + assert_eq!(SccFrameRate::from_int(2), SccFrameRate::Fps25); + assert_eq!(SccFrameRate::from_int(3), SccFrameRate::Fps30); + assert_eq!(SccFrameRate::from_int(99), SccFrameRate::Fps29_97); // Invalid defaults + } + + #[test] + fn test_parse_smpte_timecode() { + // 29.97fps + assert_eq!( + parse_smpte_timecode("00:00:00:00", SccFrameRate::Fps29_97), + Some(0) + ); + assert_eq!( + parse_smpte_timecode("00:00:01:00", SccFrameRate::Fps29_97), + Some(1000) + ); + assert_eq!( + parse_smpte_timecode("00:01:00:00", SccFrameRate::Fps29_97), + Some(60000) + ); + assert_eq!( + parse_smpte_timecode("01:00:00:00", SccFrameRate::Fps29_97), + Some(3600000) + ); + + // 25fps - 25 frames = 1 second + assert_eq!( + parse_smpte_timecode("00:00:00:25", SccFrameRate::Fps25), + Some(1000) + ); + + // 24fps - 24 frames = 1 second + let time_24 = parse_smpte_timecode("00:00:00:24", SccFrameRate::Fps24).unwrap(); + assert!(time_24 >= 999 && time_24 <= 1001); // Approximately 1 second + + // 30fps - 30 frames = 1 second + let time_30 = parse_smpte_timecode("00:00:00:30", SccFrameRate::Fps30).unwrap(); + assert!(time_30 >= 999 && time_30 <= 1001); // Approximately 1 second + + // Drop-frame separator (semicolon) + assert_eq!( + parse_smpte_timecode("00:00:01;00", SccFrameRate::Fps29_97), + Some(1000) + ); + + // Invalid timecodes + assert_eq!( + parse_smpte_timecode("00:00:00", SccFrameRate::Fps29_97), + None + ); + assert_eq!( + parse_smpte_timecode("invalid", SccFrameRate::Fps29_97), + None + ); + assert_eq!(parse_smpte_timecode("", SccFrameRate::Fps29_97), None); + } + + #[test] + fn test_parse_hex_pair() { + assert_eq!(parse_hex_pair("9420"), Some([0x94, 0x20])); + assert_eq!(parse_hex_pair("80ff"), Some([0x80, 0xFF])); + assert_eq!(parse_hex_pair("0000"), Some([0x00, 0x00])); + assert_eq!(parse_hex_pair("ABCD"), Some([0xAB, 0xCD])); // Uppercase + assert_eq!(parse_hex_pair("xyz"), None); // Invalid + assert_eq!(parse_hex_pair("94"), None); // Too short + assert_eq!(parse_hex_pair("942020"), None); // Too long + assert_eq!(parse_hex_pair(""), None); // Empty + } + + #[test] + fn test_parse_hex_pairs() { + let pairs = parse_hex_pairs("9420 9420 94ad 94ad"); + assert_eq!(pairs.len(), 4); + assert_eq!(pairs[0], [0x94, 0x20]); + assert_eq!(pairs[1], [0x94, 0x20]); + assert_eq!(pairs[2], [0x94, 0xAD]); + assert_eq!(pairs[3], [0x94, 0xAD]); + + // With extra whitespace + let pairs2 = parse_hex_pairs(" 9420 9420 "); + assert_eq!(pairs2.len(), 2); + + // Empty + let pairs3 = parse_hex_pairs(""); + assert!(pairs3.is_empty()); + + // Mixed valid and invalid + let pairs4 = parse_hex_pairs("9420 invalid 94ad"); + assert_eq!(pairs4.len(), 2); + } + + #[test] + fn test_parse_scc_simple() { + let content = "Scenarist_SCC V1.0\n\n00:00:00:00\t9420 9420\n00:00:01:00\t942c 942c\n"; + let result = parse_scc(content, SccFrameRate::Fps29_97); + assert_eq!(result.lines.len(), 2); + assert_eq!(result.lines[0].time_ms, 0); + assert_eq!(result.lines[0].pairs.len(), 2); + assert_eq!(result.lines[0].pairs[0], [0x94, 0x20]); + assert_eq!(result.lines[1].time_ms, 1000); + assert_eq!(result.lines[1].pairs[0], [0x94, 0x2C]); + } + + #[test] + fn test_parse_scc_with_bom() { + let content = "\u{FEFF}Scenarist_SCC V1.0\n\n00:00:00:00\t9420\n"; + let result = parse_scc(content, SccFrameRate::Fps29_97); + assert_eq!(result.lines.len(), 1); + } + + #[test] + fn test_parse_scc_empty_lines() { + let content = "Scenarist_SCC V1.0\n\n\n\n00:00:00:00\t9420\n\n\n"; + let result = parse_scc(content, SccFrameRate::Fps29_97); + assert_eq!(result.lines.len(), 1); + } + + #[test] + fn test_parse_scc_real_sample() { + // Sample from the plan with actual caption commands + // Note: "a1a0" is "!" with parity + space (proper 4-char hex pair) + let content = r#"Scenarist_SCC V1.0 + +00:00:00:00 9420 9420 94ad 94ad 9470 9470 4869 2074 6865 7265 a1a0 +00:00:02:00 942c 942c +00:00:04:00 9420 9420 9470 9470 5465 7374 696e 6720 5343 4320 696e 7075 742e +00:00:06:00 942c 942c +"#; + let result = parse_scc(content, SccFrameRate::Fps29_97); + assert_eq!(result.lines.len(), 4); + + // First line: "Hi there!" with positioning commands + assert_eq!(result.lines[0].time_ms, 0); + assert_eq!(result.lines[0].pairs.len(), 11); + + // Second line: EDM (Erase Displayed Memory) + assert_eq!(result.lines[1].time_ms, 2000); + assert_eq!(result.lines[1].pairs[0], [0x94, 0x2C]); + + // Third line: "Testing SCC input." + assert_eq!(result.lines[2].time_ms, 4000); + + // Fourth line: EDM + assert_eq!(result.lines[3].time_ms, 6000); + } + + #[test] + fn test_parse_scc_with_callbacks() { + let content = "Scenarist_SCC V1.0\n\n00:00:00:00\t9420 9420\n00:00:01:00\t942c\n"; + + let mut caption_count = 0; + let mut timing_count = 0; + let mut last_time_ms: i64 = -1; + + parse_scc_with_callbacks( + content, + SccFrameRate::Fps29_97, + |cc_type, _d1, _d2| { + assert_eq!(cc_type, CC_TYPE_FIELD1); + caption_count += 1; + }, + |time_ms| { + timing_count += 1; + last_time_ms = time_ms; + }, + ); + + assert_eq!(caption_count, 3); // 2 from first line + 1 from second + assert_eq!(timing_count, 2); // One per line + assert_eq!(last_time_ms, 1000); // Last timing was at 1 second + } + + #[test] + fn test_ms_per_frame() { + // 29.97fps: ~33.37ms per frame + let fps_29_97 = SccFrameRate::Fps29_97.ms_per_frame(); + assert!(fps_29_97 > 33.0 && fps_29_97 < 34.0); + + // 24fps: ~41.67ms per frame + let fps_24 = SccFrameRate::Fps24.ms_per_frame(); + assert!(fps_24 > 41.0 && fps_24 < 42.0); + + // 25fps: 40ms per frame + let fps_25 = SccFrameRate::Fps25.ms_per_frame(); + assert!((fps_25 - 40.0).abs() < 0.001); + + // 30fps: ~33.33ms per frame + let fps_30 = SccFrameRate::Fps30.ms_per_frame(); + assert!(fps_30 > 33.0 && fps_30 < 34.0); + } +} diff --git a/src/rust/src/demuxer/stream_functions.rs b/src/rust/src/demuxer/stream_functions.rs index 012874d5c..f09575323 100644 --- a/src/rust/src/demuxer/stream_functions.rs +++ b/src/rust/src/demuxer/stream_functions.rs @@ -174,6 +174,31 @@ unsafe fn detect_stream_type_common(ctx: &mut CcxDemuxer, ccx_options: &mut Opti ctx.stream_mode = StreamMode::McpoodlesRaw; } + // Check for SCC (Scenarist Closed Caption) text format + // SCC files start with "Scenarist_SCC V1.0" (18 bytes), optionally with UTF-8 BOM (3 bytes) + if ctx.stream_mode == StreamMode::ElementaryOrNotFound { + let mut check_buf = &ctx.startbytes[..]; + let mut check_pos = 0; + + // Skip UTF-8 BOM if present + if ctx.startbytes_avail >= 3 + && ctx.startbytes[0] == 0xEF + && ctx.startbytes[1] == 0xBB + && ctx.startbytes[2] == 0xBF + { + check_buf = &ctx.startbytes[3..]; + check_pos = 3; + } + + if ctx.startbytes_avail >= check_pos + 18 + && check_buf.len() >= 18 + && &check_buf[..18] == b"Scenarist_SCC V1.0" + { + ctx.stream_mode = StreamMode::Scc; + info!("Detected SCC (Scenarist Closed Caption) format"); + } + } + // Hex dump check #[cfg(feature = "wtv_debug")] { diff --git a/src/rust/src/libccxr_exports/demuxer.rs b/src/rust/src/libccxr_exports/demuxer.rs index fa478e01e..82f930c05 100755 --- a/src/rust/src/libccxr_exports/demuxer.rs +++ b/src/rust/src/libccxr_exports/demuxer.rs @@ -554,6 +554,110 @@ pub unsafe extern "C" fn ccxr_process_dvdraw( bytes_consumed as c_uint } +// ============================================================================ +// SCC (Scenarist Closed Caption) Format Processing +// ============================================================================ + +use crate::demuxer::scc::{is_scc_file, parse_scc_with_callbacks, SccFrameRate}; + +// External C function declarations for timing +extern "C" { + fn ccxr_set_current_pts(ctx: *mut ccx_common_timing_ctx, pts: i64); +} + +/// Check if a buffer contains SCC file header. +/// +/// # Safety +/// +/// `buffer` must be a valid pointer to at least `len` bytes. +#[no_mangle] +pub unsafe extern "C" fn ccxr_is_scc_file(buffer: *const c_uchar, len: c_uint) -> c_int { + if buffer.is_null() || len < 18 { + return 0; + } + let slice = std::slice::from_raw_parts(buffer, len as usize); + if is_scc_file(slice) { + 1 + } else { + 0 + } +} + +/// Process SCC file and extract captions. +/// +/// This function parses the SCC text format, extracts caption data, +/// sets timing appropriately, and calls do_cb() for each caption block. +/// +/// # Safety +/// +/// - `ctx` must be a valid pointer to a lib_cc_decode structure +/// - `sub` must be a valid pointer to a cc_subtitle structure +/// - `buffer` must be a valid pointer to at least `len` bytes +/// +/// # Arguments +/// +/// - `framerate`: 0=29.97 (default), 1=24, 2=25, 3=30 +/// +/// # Returns +/// +/// The number of bytes consumed from the buffer. +#[no_mangle] +pub unsafe extern "C" fn ccxr_process_scc( + ctx: *mut lib_cc_decode, + sub: *mut cc_subtitle, + buffer: *const c_uchar, + len: c_uint, + framerate: c_int, +) -> c_uint { + if ctx.is_null() || sub.is_null() || buffer.is_null() || len == 0 { + return 0; + } + + let slice = std::slice::from_raw_parts(buffer, len as usize); + + // Convert to string (SCC is text-based) + // Skip UTF-8 BOM if present + let text_slice = if slice.len() >= 3 && slice[0] == 0xEF && slice[1] == 0xBB && slice[2] == 0xBF + { + &slice[3..] + } else { + slice + }; + + let content = match std::str::from_utf8(text_slice) { + Ok(s) => s, + Err(_) => return 0, + }; + + let fps = SccFrameRate::from_int(framerate); + + // Get the timing context from lib_cc_decode + let timing_ctx = (*ctx).timing; + if timing_ctx.is_null() { + return 0; + } + + let bytes_consumed = parse_scc_with_callbacks( + content, + fps, + |cc_type, data1, data2| { + // Build caption block and call do_cb + // SCC is always field 1 (CC1) + let mut cc_block: [c_uchar; 3] = [cc_type, data1, data2]; + do_cb(ctx, cc_block.as_mut_ptr(), sub); + }, + |time_ms| { + // Set timing for this caption line + // Convert ms to 90kHz clock (PTS) + let pts = time_ms * 90; + ccxr_set_current_pts(timing_ctx, pts); + ccxr_set_fts(timing_ctx); + }, + ); + + bytes_consumed as c_uint +} + #[cfg(test)] #[allow(clippy::field_reassign_with_default)] mod tests { diff --git a/src/rust/src/parser.rs b/src/rust/src/parser.rs index 2a28e32d7..a51cabd73 100644 --- a/src/rust/src/parser.rs +++ b/src/rust/src/parser.rs @@ -865,6 +865,24 @@ impl OptionsExt for Options { if args.mpeg90090 { set_mpeg_clock_freq(90090); } + + // Handle SCC framerate option + if let Some(ref fps_str) = args.scc_framerate { + self.scc_framerate = match fps_str.as_str() { + "29.97" | "29" => 0, + "24" => 1, + "25" => 2, + "30" => 3, + _ => { + eprintln!( + "Invalid SCC framerate '{}'. Using default 29.97fps", + fps_str + ); + 0 + } + }; + } + if args.no_scte20 { self.noscte20 = true; }