max / makenotwork
10 files changed,
+371 insertions,
-27 deletions
| @@ -147,7 +147,8 @@ Audio/podcast chapter markers. Sorted by `start_seconds`. | |||
| 147 | 147 | - **FK:** item_id → items CASCADE | |
| 148 | 148 | ||
| 149 | 149 | ### content_insertions | |
| 150 | - | Reusable audio clips (ads, intros, outros) uploaded by creators. | |
| 150 | + | Reusable audio or video clips (ads, intros, outros) uploaded by creators. | |
| 151 | + | `media_type` is `audio` or `video`, derived from the confirmed MIME. | |
| 151 | 152 | ||
| 152 | 153 | - **FK:** user_id → users CASCADE | |
| 153 | 154 | - **Key columns:** title, media_type, storage_key, duration_ms, file_size |
| @@ -188,11 +188,16 @@ pub(super) async fn confirm_insertion( | |||
| 188 | 188 | // Clear the pending upload record now that the upload is confirmed | |
| 189 | 189 | db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key, "main").await?; | |
| 190 | 190 | ||
| 191 | + | // Persist the real media family (audio vs video) derived from the validated | |
| 192 | + | // MIME, rather than assuming audio. Drives the placement type-compat guard | |
| 193 | + | // and which player element the clip renders in. | |
| 194 | + | let media_type = S3Client::insertion_media_type(&req.mime_type); | |
| 195 | + | ||
| 191 | 196 | let insertion = db::content_insertions::create_insertion( | |
| 192 | 197 | &state.db, | |
| 193 | 198 | user.id, | |
| 194 | 199 | &req.title, | |
| 195 | - | "audio", | |
| 200 | + | media_type, | |
| 196 | 201 | &req.s3_key, | |
| 197 | 202 | req.duration_ms, | |
| 198 | 203 | file_size_bytes, | |
| @@ -337,10 +342,16 @@ pub(super) async fn list_placements( | |||
| 337 | 342 | AuthUser(user): AuthUser, | |
| 338 | 343 | Path(item_id): Path<ItemId>, | |
| 339 | 344 | ) -> Result<impl IntoResponse> { | |
| 340 | - | verify_item_ownership(&state, item_id, user.id).await?; | |
| 345 | + | let (item, _project) = verify_item_ownership(&state, item_id, user.id).await?; | |
| 341 | 346 | ||
| 342 | 347 | let placements = db::content_insertions::list_placements_for_item(&state.db, item_id).await?; | |
| 343 | - | let available = db::content_insertions::list_insertions(&state.db, user.id).await?; | |
| 348 | + | // Only offer clips that can legally be placed on this item (a video clip on an | |
| 349 | + | // audio item would be rejected by create_placement, so don't surface it). | |
| 350 | + | let available: Vec<_> = db::content_insertions::list_insertions(&state.db, user.id) | |
| 351 | + | .await? | |
| 352 | + | .into_iter() | |
| 353 | + | .filter(|i| clip_compatible_with_item(&i.media_type, item.item_type)) | |
| 354 | + | .collect(); | |
| 344 | 355 | ||
| 345 | 356 | let placement_display: Vec<crate::templates::PlacementDisplay> = placements | |
| 346 | 357 | .iter() | |
| @@ -384,13 +395,28 @@ pub(super) async fn create_placement( | |||
| 384 | 395 | Json(req): Json<CreatePlacementRequest>, | |
| 385 | 396 | ) -> Result<impl IntoResponse> { | |
| 386 | 397 | user.check_not_suspended()?; | |
| 387 | - | verify_item_ownership(&state, item_id, user.id).await?; | |
| 398 | + | let (item, _project) = verify_item_ownership(&state, item_id, user.id).await?; | |
| 388 | 399 | ||
| 389 | 400 | // Verify the insertion belongs to this user | |
| 390 | 401 | let insertion = db::content_insertions::get_insertion(&state.db, req.insertion_id, user.id) | |
| 391 | 402 | .await? | |
| 392 | 403 | .ok_or(AppError::NotFound)?; | |
| 393 | 404 | ||
| 405 | + | // Only audio/video items host a media player, so only they can carry clips. | |
| 406 | + | // A video clip additionally requires a video item — an audio item renders in | |
| 407 | + | // an `<audio>` element that would silently drop the clip's video track. An | |
| 408 | + | // audio clip on a video item is allowed (plays as a blank-frame segment). | |
| 409 | + | if !item_hosts_insertions(item.item_type) { | |
| 410 | + | return Err(AppError::BadRequest( | |
| 411 | + | "Clips can only be placed on audio or video items".to_string(), | |
| 412 | + | )); | |
| 413 | + | } | |
| 414 | + | if !clip_compatible_with_item(&insertion.media_type, item.item_type) { | |
| 415 | + | return Err(AppError::BadRequest( | |
| 416 | + | "Video clips can only be placed on video items".to_string(), | |
| 417 | + | )); | |
| 418 | + | } | |
| 419 | + | ||
| 394 | 420 | // Validate mid-roll offset | |
| 395 | 421 | if req.position == InsertionPosition::MidRoll && req.offset_ms.is_none() { | |
| 396 | 422 | return Err(AppError::BadRequest( | |
| @@ -437,6 +463,21 @@ pub(super) async fn delete_placement( | |||
| 437 | 463 | // Helpers | |
| 438 | 464 | // ============================================================================= | |
| 439 | 465 | ||
| 466 | + | /// Whether an item type has a media player that can host insertion clips. | |
| 467 | + | /// Only audio and video items render a segment-aware player. | |
| 468 | + | fn item_hosts_insertions(item_type: db::ItemType) -> bool { | |
| 469 | + | matches!(item_type, db::ItemType::Audio | db::ItemType::Video) | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | /// Whether a clip of the given media type may be placed on the given item type. | |
| 473 | + | /// Video clips require a video item; audio clips fit either audio or video items. | |
| 474 | + | fn clip_compatible_with_item(clip_media_type: &str, item_type: db::ItemType) -> bool { | |
| 475 | + | if !item_hosts_insertions(item_type) { | |
| 476 | + | return false; | |
| 477 | + | } | |
| 478 | + | clip_media_type != "video" || item_type == db::ItemType::Video | |
| 479 | + | } | |
| 480 | + | ||
| 440 | 481 | /// Format milliseconds as MM:SS. | |
| 441 | 482 | fn format_duration_ms(ms: i32) -> String { | |
| 442 | 483 | let total_secs = ms / 1000; | |
| @@ -490,4 +531,31 @@ mod tests { | |||
| 490 | 531 | // -1500 / 1000 = -1, -1 / 60 = 0, -1 % 60 = -1 | |
| 491 | 532 | assert_eq!(format_duration_ms(-1500), "0:-1"); | |
| 492 | 533 | } | |
| 534 | + | ||
| 535 | + | #[test] | |
| 536 | + | fn only_audio_and_video_items_host_insertions() { | |
| 537 | + | assert!(item_hosts_insertions(db::ItemType::Audio)); | |
| 538 | + | assert!(item_hosts_insertions(db::ItemType::Video)); | |
| 539 | + | assert!(!item_hosts_insertions(db::ItemType::Text)); | |
| 540 | + | assert!(!item_hosts_insertions(db::ItemType::Digital)); | |
| 541 | + | assert!(!item_hosts_insertions(db::ItemType::Bundle)); | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | #[test] | |
| 545 | + | fn audio_clip_fits_audio_and_video_items() { | |
| 546 | + | assert!(clip_compatible_with_item("audio", db::ItemType::Audio)); | |
| 547 | + | assert!(clip_compatible_with_item("audio", db::ItemType::Video)); | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | #[test] | |
| 551 | + | fn video_clip_requires_video_item() { | |
| 552 | + | assert!(clip_compatible_with_item("video", db::ItemType::Video)); | |
| 553 | + | assert!(!clip_compatible_with_item("video", db::ItemType::Audio)); | |
| 554 | + | } | |
| 555 | + | ||
| 556 | + | #[test] | |
| 557 | + | fn no_clip_fits_a_non_media_item() { | |
| 558 | + | assert!(!clip_compatible_with_item("audio", db::ItemType::Text)); | |
| 559 | + | assert!(!clip_compatible_with_item("video", db::ItemType::Text)); | |
| 560 | + | } | |
| 493 | 561 | } |
| @@ -72,7 +72,7 @@ pub fn verify_content_type(data: &[u8], claimed_type: FileType) -> LayerResult { | |||
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | 74 | match claimed_type { | |
| 75 | - | FileType::Audio | FileType::Insertion => { | |
| 75 | + | FileType::Audio => { | |
| 76 | 76 | // Audio files must be detected as audio/* or application/ogg. | |
| 77 | 77 | // Unrecognized data is rejected (prevents disguised executables/scripts). | |
| 78 | 78 | match detected { | |
| @@ -103,6 +103,43 @@ pub fn verify_content_type(data: &[u8], claimed_type: FileType) -> LayerResult { | |||
| 103 | 103 | }, | |
| 104 | 104 | } | |
| 105 | 105 | } | |
| 106 | + | FileType::Insertion => { | |
| 107 | + | // An insertion clip may be audio (intro/sponsor read) or video | |
| 108 | + | // (pre/mid/post-roll on a video item), so accept either media family. | |
| 109 | + | // Unrecognized data is still rejected (prevents disguised | |
| 110 | + | // executables/scripts). `application/ogg` is a valid audio container. | |
| 111 | + | match detected { | |
| 112 | + | Some(kind) => { | |
| 113 | + | let mime = kind.mime_type(); | |
| 114 | + | let is_media = mime.starts_with("audio/") | |
| 115 | + | || mime.starts_with("video/") | |
| 116 | + | || mime.contains("ogg"); | |
| 117 | + | if !is_media { | |
| 118 | + | return LayerResult { | |
| 119 | + | layer: "content_type", | |
| 120 | + | verdict: LayerVerdict::Fail, | |
| 121 | + | detail: Some(format!( | |
| 122 | + | "File claimed as insertion clip but detected as: {}", | |
| 123 | + | mime | |
| 124 | + | )), | |
| 125 | + | }; | |
| 126 | + | } | |
| 127 | + | LayerResult { | |
| 128 | + | layer: "content_type", | |
| 129 | + | verdict: LayerVerdict::Pass, | |
| 130 | + | detail: Some(mime.to_string()), | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | None => LayerResult { | |
| 134 | + | layer: "content_type", | |
| 135 | + | verdict: LayerVerdict::Fail, | |
| 136 | + | detail: Some( | |
| 137 | + | "Could not detect audio or video format from insertion file header" | |
| 138 | + | .to_string(), | |
| 139 | + | ), | |
| 140 | + | }, | |
| 141 | + | } | |
| 142 | + | } | |
| 106 | 143 | FileType::Cover | FileType::MediaImage => { | |
| 107 | 144 | // Cover/media images must be detected as image/* | |
| 108 | 145 | match detected { |
| @@ -99,6 +99,22 @@ const ALLOWED_VIDEO_TYPES: &[(&str, &str)] = &[ | |||
| 99 | 99 | ("mov", "video/quicktime"), | |
| 100 | 100 | ]; | |
| 101 | 101 | ||
| 102 | + | /// Allowed insertion-clip extensions and MIME types. A clip may be audio (the | |
| 103 | + | /// original use: intros, sponsor reads) or video (pre/mid/post-roll on a video | |
| 104 | + | /// item), so this is the union of the audio and video allow-lists. Kept as one | |
| 105 | + | /// literal because `allowed_types()` returns a `&'static` slice. | |
| 106 | + | const ALLOWED_INSERTION_TYPES: &[(&str, &str)] = &[ | |
| 107 | + | ("mp3", "audio/mpeg"), | |
| 108 | + | ("wav", "audio/wav"), | |
| 109 | + | ("m4a", "audio/mp4"), | |
| 110 | + | ("ogg", "audio/ogg"), | |
| 111 | + | ("flac", "audio/flac"), | |
| 112 | + | ("aac", "audio/aac"), | |
| 113 | + | ("mp4", "video/mp4"), | |
| 114 | + | ("webm", "video/webm"), | |
| 115 | + | ("mov", "video/quicktime"), | |
| 116 | + | ]; | |
| 117 | + | ||
| 102 | 118 | /// MIME types accepted for video uploads | |
| 103 | 119 | const ALLOWED_VIDEO_MIMES: &[&str] = &[ | |
| 104 | 120 | "video/mp4", | |
| @@ -244,7 +260,8 @@ impl FileType { | |||
| 244 | 260 | ||
| 245 | 261 | pub fn allowed_types(&self) -> &'static [(&'static str, &'static str)] { | |
| 246 | 262 | match self { | |
| 247 | - | FileType::Audio | FileType::Insertion => ALLOWED_AUDIO_TYPES, | |
| 263 | + | FileType::Audio => ALLOWED_AUDIO_TYPES, | |
| 264 | + | FileType::Insertion => ALLOWED_INSERTION_TYPES, | |
| 248 | 265 | FileType::Cover | FileType::MediaImage => ALLOWED_IMAGE_TYPES, | |
| 249 | 266 | FileType::Download => ALLOWED_DOWNLOAD_TYPES, | |
| 250 | 267 | FileType::Video | FileType::MediaVideo => ALLOWED_VIDEO_TYPES, | |
| @@ -595,6 +612,19 @@ impl S3Client { | |||
| 595 | 612 | Ok(()) | |
| 596 | 613 | } | |
| 597 | 614 | ||
| 615 | + | /// Classify a validated MIME type as the `media_type` we persist for an | |
| 616 | + | /// insertion clip: `"video"` for `video/*`, otherwise `"audio"`. The MIME is | |
| 617 | + | /// expected to have already passed `validate_content_type`, so the audio | |
| 618 | + | /// fallback is safe (the only non-audio family the insertion allow-list | |
| 619 | + | /// admits is `video/*`). | |
| 620 | + | pub fn insertion_media_type(mime_type: &str) -> &'static str { | |
| 621 | + | if mime_type.starts_with("video/") { | |
| 622 | + | "video" | |
| 623 | + | } else { | |
| 624 | + | "audio" | |
| 625 | + | } | |
| 626 | + | } | |
| 627 | + | ||
| 598 | 628 | /// Validate file extension for the given file type | |
| 599 | 629 | pub fn validate_extension(file_type: FileType, filename: &str) -> Result<()> { | |
| 600 | 630 | if file_type == FileType::Download { | |
| @@ -1221,9 +1251,15 @@ mod tests { | |||
| 1221 | 1251 | ||
| 1222 | 1252 | #[test] | |
| 1223 | 1253 | fn validate_insertion_content_types() { | |
| 1254 | + | // Audio clips (the original use). | |
| 1224 | 1255 | assert!(S3Client::validate_content_type(FileType::Insertion, "audio/mpeg").is_ok()); | |
| 1225 | 1256 | assert!(S3Client::validate_content_type(FileType::Insertion, "audio/wav").is_ok()); | |
| 1226 | 1257 | assert!(S3Client::validate_content_type(FileType::Insertion, "audio/flac").is_ok()); | |
| 1258 | + | // Video clips (pre/mid/post-roll on video items). | |
| 1259 | + | assert!(S3Client::validate_content_type(FileType::Insertion, "video/mp4").is_ok()); | |
| 1260 | + | assert!(S3Client::validate_content_type(FileType::Insertion, "video/webm").is_ok()); | |
| 1261 | + | assert!(S3Client::validate_content_type(FileType::Insertion, "video/quicktime").is_ok()); | |
| 1262 | + | // Neither audio nor video is rejected. | |
| 1227 | 1263 | assert!(S3Client::validate_content_type(FileType::Insertion, "image/png").is_err()); | |
| 1228 | 1264 | } | |
| 1229 | 1265 | ||
| @@ -1232,10 +1268,20 @@ mod tests { | |||
| 1232 | 1268 | assert!(S3Client::validate_extension(FileType::Insertion, "intro.mp3").is_ok()); | |
| 1233 | 1269 | assert!(S3Client::validate_extension(FileType::Insertion, "sponsor.wav").is_ok()); | |
| 1234 | 1270 | assert!(S3Client::validate_extension(FileType::Insertion, "outro.flac").is_ok()); | |
| 1271 | + | assert!(S3Client::validate_extension(FileType::Insertion, "bumper.mp4").is_ok()); | |
| 1272 | + | assert!(S3Client::validate_extension(FileType::Insertion, "bumper.webm").is_ok()); | |
| 1235 | 1273 | assert!(S3Client::validate_extension(FileType::Insertion, "clip.png").is_err()); | |
| 1236 | 1274 | } | |
| 1237 | 1275 | ||
| 1238 | 1276 | #[test] | |
| 1277 | + | fn insertion_media_type_classifies_by_mime_family() { | |
| 1278 | + | assert_eq!(S3Client::insertion_media_type("audio/mpeg"), "audio"); | |
| 1279 | + | assert_eq!(S3Client::insertion_media_type("audio/mp4"), "audio"); | |
| 1280 | + | assert_eq!(S3Client::insertion_media_type("video/mp4"), "video"); | |
| 1281 | + | assert_eq!(S3Client::insertion_media_type("video/webm"), "video"); | |
| 1282 | + | } | |
| 1283 | + | ||
| 1284 | + | #[test] | |
| 1239 | 1285 | fn generate_insertion_key_format() { | |
| 1240 | 1286 | let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); | |
| 1241 | 1287 | let key = S3Client::generate_insertion_key(user_id, "intro.mp3"); |
| @@ -9,16 +9,34 @@ | |||
| 9 | 9 | document.getElementById('insertion-file-input').click(); | |
| 10 | 10 | } | |
| 11 | 11 | ||
| 12 | + | // Map a file extension to a MIME type when the browser doesn't supply one | |
| 13 | + | // (some OSes leave file.type empty). Must stay in sync with the server's | |
| 14 | + | // ALLOWED_INSERTION_TYPES allow-list. | |
| 15 | + | var EXT_MIME = { | |
| 16 | + | mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', ogg: 'audio/ogg', | |
| 17 | + | flac: 'audio/flac', aac: 'audio/aac', | |
| 18 | + | mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime' | |
| 19 | + | }; | |
| 20 | + | ||
| 21 | + | function resolveMime(file) { | |
| 22 | + | if (file.type) return file.type; | |
| 23 | + | var ext = (file.name.split('.').pop() || '').toLowerCase(); | |
| 24 | + | return EXT_MIME[ext] || 'application/octet-stream'; | |
| 25 | + | } | |
| 26 | + | ||
| 12 | 27 | async function handleFileSelected(input) { | |
| 13 | 28 | const file = input.files[0]; | |
| 14 | 29 | if (!file) return; | |
| 15 | 30 | ||
| 16 | - | // Detect duration via temporary audio element | |
| 31 | + | var mime = resolveMime(file); | |
| 32 | + | var isVideo = mime.indexOf('video/') === 0; | |
| 33 | + | ||
| 34 | + | // Detect duration via a temporary media element matching the clip kind | |
| 17 | 35 | var durationMs; | |
| 18 | 36 | try { | |
| 19 | - | durationMs = await detectDuration(file); | |
| 37 | + | durationMs = await detectDuration(file, isVideo); | |
| 20 | 38 | } catch (e) { | |
| 21 | - | showToast('Could not detect audio duration. Please try a different file.'); | |
| 39 | + | showToast('Could not detect clip duration. Please try a different file.'); | |
| 22 | 40 | input.value = ''; | |
| 23 | 41 | return; | |
| 24 | 42 | } | |
| @@ -30,7 +48,7 @@ | |||
| 30 | 48 | headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()), | |
| 31 | 49 | body: JSON.stringify({ | |
| 32 | 50 | file_name: file.name, | |
| 33 | - | content_type: file.type || 'audio/mpeg' | |
| 51 | + | content_type: mime | |
| 34 | 52 | }) | |
| 35 | 53 | }); | |
| 36 | 54 | ||
| @@ -44,7 +62,7 @@ | |||
| 44 | 62 | // Step 2: Upload to S3 | |
| 45 | 63 | var uploadRes = await fetch(presignData.upload_url, { | |
| 46 | 64 | method: 'PUT', | |
| 47 | - | headers: { 'Content-Type': file.type || 'audio/mpeg' }, | |
| 65 | + | headers: { 'Content-Type': mime }, | |
| 48 | 66 | body: file | |
| 49 | 67 | }); | |
| 50 | 68 | ||
| @@ -62,7 +80,7 @@ | |||
| 62 | 80 | title: title, | |
| 63 | 81 | duration_ms: durationMs, | |
| 64 | 82 | file_size: file.size, | |
| 65 | - | mime_type: file.type || 'audio/mpeg' | |
| 83 | + | mime_type: mime | |
| 66 | 84 | }) | |
| 67 | 85 | }); | |
| 68 | 86 | ||
| @@ -80,23 +98,23 @@ | |||
| 80 | 98 | input.value = ''; | |
| 81 | 99 | } | |
| 82 | 100 | ||
| 83 | - | function detectDuration(file) { | |
| 101 | + | function detectDuration(file, isVideo) { | |
| 84 | 102 | return new Promise(function(resolve, reject) { | |
| 85 | - | var audio = document.createElement('audio'); | |
| 86 | - | audio.preload = 'metadata'; | |
| 103 | + | var el = document.createElement(isVideo ? 'video' : 'audio'); | |
| 104 | + | el.preload = 'metadata'; | |
| 87 | 105 | ||
| 88 | - | audio.addEventListener('loadedmetadata', function() { | |
| 89 | - | var ms = Math.round(audio.duration * 1000); | |
| 90 | - | URL.revokeObjectURL(audio.src); | |
| 106 | + | el.addEventListener('loadedmetadata', function() { | |
| 107 | + | var ms = Math.round(el.duration * 1000); | |
| 108 | + | URL.revokeObjectURL(el.src); | |
| 91 | 109 | resolve(ms); | |
| 92 | 110 | }); | |
| 93 | 111 | ||
| 94 | - | audio.addEventListener('error', function() { | |
| 95 | - | URL.revokeObjectURL(audio.src); | |
| 96 | - | reject(new Error('Cannot read audio metadata')); | |
| 112 | + | el.addEventListener('error', function() { | |
| 113 | + | URL.revokeObjectURL(el.src); | |
| 114 | + | reject(new Error('Cannot read media metadata')); | |
| 97 | 115 | }); | |
| 98 | 116 | ||
| 99 | - | audio.src = URL.createObjectURL(file); | |
| 117 | + | el.src = URL.createObjectURL(file); | |
| 100 | 118 | }); | |
| 101 | 119 | } | |
| 102 | 120 |
| @@ -112,6 +112,12 @@ | |||
| 112 | 112 | display: block; | |
| 113 | 113 | } | |
| 114 | 114 | ||
| 115 | + | /* Only the active element is shown; the standby preloads the next segment | |
| 116 | + | off-screen so pre/mid/post-roll transitions are gapless. */ | |
| 117 | + | .video-display video.is-standby { | |
| 118 | + | display: none; | |
| 119 | + | } | |
| 120 | + | ||
| 115 | 121 | /* Player controls */ | |
| 116 | 122 | .media-player { | |
| 117 | 123 | background: var(--light-background); |
| @@ -100,8 +100,11 @@ | |||
| 100 | 100 | ||
| 101 | 101 | var currentSegIndex = 0; | |
| 102 | 102 | var activeEl = mediaA; | |
| 103 | - | // Video uses single element (no dual-element gapless — brief gap acceptable) | |
| 104 | - | var standbyEl = isVideo ? null : mediaB; | |
| 103 | + | // Dual-element gapless for both audio and video: the standby element | |
| 104 | + | // preloads the next segment while the active one plays. For video, the two | |
| 105 | + | // <video> tags are stacked and only the active one is shown (see | |
| 106 | + | // syncVideoVisibility). Falls back to single-element if media-b is absent. | |
| 107 | + | var standbyEl = mediaB || null; | |
| 105 | 108 | var playing = false; | |
| 106 | 109 | var currentSpeed = 1; | |
| 107 | 110 | var currentVolume = volumeSlider.value / 100; | |
| @@ -110,6 +113,14 @@ | |||
| 110 | 113 | return segments[idx] && segments[idx].segment_type !== 'main'; | |
| 111 | 114 | } | |
| 112 | 115 | ||
| 116 | + | // Show the active <video> and hide the standby so stacked elements don't | |
| 117 | + | // both render. No-op for audio (elements are invisible) or single-element. | |
| 118 | + | function syncVideoVisibility() { | |
| 119 | + | if (!isVideo || !standbyEl) return; | |
| 120 | + | activeEl.classList.remove('is-standby'); | |
| 121 | + | standbyEl.classList.add('is-standby'); | |
| 122 | + | } | |
| 123 | + | ||
| 113 | 124 | function loadSegment(el, idx) { | |
| 114 | 125 | if (idx >= segments.length) return; | |
| 115 | 126 | var seg = segments[idx]; | |
| @@ -134,6 +145,9 @@ | |||
| 134 | 145 | } | |
| 135 | 146 | ||
| 136 | 147 | function updateInsertionUI() { | |
| 148 | + | // Keep the visible video element in sync with the active role on every | |
| 149 | + | // segment transition (this runs after each advance/seek/chapter jump). | |
| 150 | + | syncVideoVisibility(); | |
| 137 | 151 | if (isInsertionSegment(currentSegIndex)) { | |
| 138 | 152 | var seg = segments[currentSegIndex]; | |
| 139 | 153 | insertionLabel.textContent = seg.title || seg.segment_type.replace('_', '-'); |
| @@ -39,6 +39,8 @@ | |||
| 39 | 39 | <source src="{{ vurl }}"> | |
| 40 | 40 | {% endif %} | |
| 41 | 41 | </video> | |
| 42 | + | <!-- Standby element for gapless segment transitions (pre/mid/post-roll clips). --> | |
| 43 | + | <video id="media-b" class="is-standby" preload="metadata"></video> | |
| 42 | 44 | </div> | |
| 43 | 45 | ||
| 44 | 46 | <div class="media-player"> |
| @@ -4,11 +4,11 @@ | |||
| 4 | 4 | ||
| 5 | 5 | <div class="insertion-list-toolbar"> | |
| 6 | 6 | <button type="button" class="btn btn-sm insertion-list-upload" onclick="MNW.insertions.startUpload()">Upload Clip</button> | |
| 7 | - | <input type="file" id="insertion-file-input" class="sr-only" accept=".mp3,.wav,.flac,.ogg,.m4a" onchange="MNW.insertions.handleFileSelected(this)"> | |
| 7 | + | <input type="file" id="insertion-file-input" class="sr-only" accept=".mp3,.wav,.flac,.ogg,.m4a,.aac,.mp4,.webm,.mov" onchange="MNW.insertions.handleFileSelected(this)"> | |
| 8 | 8 | </div> | |
| 9 | 9 | ||
| 10 | 10 | {% if insertions.is_empty() %} | |
| 11 | - | {% call ui::empty_state("", "No clips yet. Upload an audio clip to use as a pre-roll, mid-roll, or post-roll on your items.") %} | |
| 11 | + | {% call ui::empty_state("", "No clips yet. Upload an audio or video clip to use as a pre-roll, mid-roll, or post-roll on your items.") %} | |
| 12 | 12 | {% else %} | |
| 13 | 13 | <table class="data-table insertion-list-table"> | |
| 14 | 14 | <thead> |
| @@ -179,3 +179,155 @@ async fn rename_nonexistent_insertion_returns_404() { | |||
| 179 | 179 | resp.status, resp.text | |
| 180 | 180 | ); | |
| 181 | 181 | } | |
| 182 | + | ||
| 183 | + | // ── Video clips ── | |
| 184 | + | ||
| 185 | + | #[tokio::test] | |
| 186 | + | async fn presign_valid_video_succeeds() { | |
| 187 | + | let mut h = TestHarness::with_storage().await; | |
| 188 | + | let _user_id = setup_creator(&mut h).await; | |
| 189 | + | ||
| 190 | + | let resp = h | |
| 191 | + | .client | |
| 192 | + | .post_json( | |
| 193 | + | "/api/users/me/insertions/presign", | |
| 194 | + | r#"{"file_name": "bumper.mp4", "content_type": "video/mp4"}"#, | |
| 195 | + | ) | |
| 196 | + | .await; | |
| 197 | + | assert!( | |
| 198 | + | resp.status.is_success(), | |
| 199 | + | "Valid video presign should succeed: {} {}", | |
| 200 | + | resp.status, resp.text | |
| 201 | + | ); | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | /// Confirm an insertion clip of the given kind and return its JSON body. | |
| 205 | + | /// Uses placeholder bytes: confirm validates size/mime/title synchronously and | |
| 206 | + | /// only sniffs magic bytes later in the async scan worker. | |
| 207 | + | async fn confirm_clip(h: &mut TestHarness, user_id: &str, file: &str, mime: &str) -> Value { | |
| 208 | + | let s3_key = format!("{}/insertions/{}", user_id, file); | |
| 209 | + | h.storage.as_ref().unwrap().put(&s3_key, vec![0u8; 1024]); | |
| 210 | + | let resp = h | |
| 211 | + | .client | |
| 212 | + | .post_json( | |
| 213 | + | "/api/users/me/insertions/confirm", | |
| 214 | + | &format!( | |
| 215 | + | r#"{{"s3_key": "{}", "title": "Clip", "duration_ms": 3000, "file_size": 1024, "mime_type": "{}"}}"#, | |
| 216 | + | s3_key, mime | |
| 217 | + | ), | |
| 218 | + | ) | |
| 219 | + | .await; | |
| 220 | + | assert!( | |
| 221 | + | resp.status.is_success(), | |
| 222 | + | "Confirm {} should succeed: {} {}", | |
| 223 | + | file, resp.status, resp.text | |
| 224 | + | ); | |
| 225 | + | resp.json() | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | #[tokio::test] | |
| 229 | + | async fn confirm_video_persists_video_media_type() { | |
| 230 | + | let mut h = TestHarness::with_storage().await; | |
| 231 | + | let user_id = setup_creator(&mut h).await; | |
| 232 | + | ||
| 233 | + | let body = confirm_clip(&mut h, &user_id, "bumper.mp4", "video/mp4").await; | |
| 234 | + | assert_eq!( | |
| 235 | + | body["media_type"].as_str().unwrap(), | |
| 236 | + | "video", | |
| 237 | + | "A video/mp4 clip must persist media_type=video, not the legacy audio default" | |
| 238 | + | ); | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | #[tokio::test] | |
| 242 | + | async fn confirm_audio_persists_audio_media_type() { | |
| 243 | + | let mut h = TestHarness::with_storage().await; | |
| 244 | + | let user_id = setup_creator(&mut h).await; | |
| 245 | + | ||
| 246 | + | let body = confirm_clip(&mut h, &user_id, "intro.mp3", "audio/mpeg").await; | |
| 247 | + | assert_eq!(body["media_type"].as_str().unwrap(), "audio"); | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | /// POST a placement of `insertion_id` on `item_id` as a pre-roll. | |
| 251 | + | async fn place_pre_roll(h: &mut TestHarness, item_id: &str, insertion_id: &str) -> u16 { | |
| 252 | + | let resp = h | |
| 253 | + | .client | |
| 254 | + | .post_json( | |
| 255 | + | &format!("/api/items/{}/insertions", item_id), | |
| 256 | + | &format!( | |
| 257 | + | r#"{{"insertion_id": "{}", "position": "pre_roll", "sort_order": 0}}"#, | |
| 258 | + | insertion_id | |
| 259 | + | ), | |
| 260 | + | ) | |
| 261 | + | .await; | |
| 262 | + | resp.status.as_u16() | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | #[tokio::test] | |
| 266 | + | async fn video_clip_rejected_on_audio_item() { | |
| 267 | + | let mut h = TestHarness::with_storage().await; | |
| 268 | + | let setup = h.create_creator_with_item("vidonaud", "audio", 0).await; | |
| 269 | + | h.grant_tier(setup.user_id, "small_files").await; | |
| 270 | + | let user_id = setup.user_id.to_string(); | |
| 271 | + | ||
| 272 | + | let clip = confirm_clip(&mut h, &user_id, "bumper.mp4", "video/mp4").await; | |
| 273 | + | let insertion_id = clip["id"].as_str().unwrap().to_string(); | |
| 274 | + | ||
| 275 | + | let status = place_pre_roll(&mut h, &setup.item_id, &insertion_id).await; | |
| 276 | + | assert_eq!( | |
| 277 | + | status, 400, | |
| 278 | + | "A video clip must not be placeable on an audio item" | |
| 279 | + | ); | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | #[tokio::test] | |
| 283 | + | async fn video_clip_allowed_on_video_item() { | |
| 284 | + | let mut h = TestHarness::with_storage().await; | |
| 285 | + | let setup = h.create_creator_with_item("vidonvid", "video", 0).await; | |
| 286 | + | h.grant_tier(setup.user_id, "small_files").await; | |
| 287 | + | let user_id = setup.user_id.to_string(); | |
| 288 | + | ||
| 289 | + | let clip = confirm_clip(&mut h, &user_id, "bumper.mp4", "video/mp4").await; | |
| 290 | + | let insertion_id = clip["id"].as_str().unwrap().to_string(); | |
| 291 | + | ||
| 292 | + | let status = place_pre_roll(&mut h, &setup.item_id, &insertion_id).await; | |
| 293 | + | assert!( | |
| 294 | + | (200..300).contains(&status), | |
| 295 | + | "A video clip must be placeable on a video item, got {}", | |
| 296 | + | status | |
| 297 | + | ); | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | #[tokio::test] | |
| 301 | + | async fn audio_clip_allowed_on_video_item() { | |
| 302 | + | let mut h = TestHarness::with_storage().await; | |
| 303 | + | let setup = h.create_creator_with_item("audonvid", "video", 0).await; | |
| 304 | + | h.grant_tier(setup.user_id, "small_files").await; | |
| 305 | + | let user_id = setup.user_id.to_string(); | |
| 306 | + | ||
| 307 | + | let clip = confirm_clip(&mut h, &user_id, "intro.mp3", "audio/mpeg").await; | |
| 308 | + | let insertion_id = clip["id"].as_str().unwrap().to_string(); | |
| 309 | + | ||
| 310 | + | let status = place_pre_roll(&mut h, &setup.item_id, &insertion_id).await; | |
| 311 | + | assert!( | |
| 312 | + | (200..300).contains(&status), | |
| 313 | + | "An audio clip must be placeable on a video item, got {}", | |
| 314 | + | status | |
| 315 | + | ); | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | #[tokio::test] | |
| 319 | + | async fn clip_rejected_on_non_media_item() { | |
| 320 | + | let mut h = TestHarness::with_storage().await; | |
| 321 | + | let setup = h.create_creator_with_item("cliponwtext", "text", 0).await; | |
| 322 | + | h.grant_tier(setup.user_id, "small_files").await; | |
| 323 | + | let user_id = setup.user_id.to_string(); | |
| 324 | + | ||
| 325 | + | let clip = confirm_clip(&mut h, &user_id, "intro.mp3", "audio/mpeg").await; | |
| 326 | + | let insertion_id = clip["id"].as_str().unwrap().to_string(); | |
| 327 | + | ||
| 328 | + | let status = place_pre_roll(&mut h, &setup.item_id, &insertion_id).await; | |
| 329 | + | assert_eq!( | |
| 330 | + | status, 400, | |
| 331 | + | "Clips must not be placeable on a non-media (text) item" | |
| 332 | + | ); | |
| 333 | + | } |