max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 <noreply@anthropic.com>
10 files changed,
+371 insertions,
-27 deletions
| @@ -147,7 +147,8 @@ | |||
| 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 |
| @@ -99,6 +99,22 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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,9 +1268,19 @@ | |||
| 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 | ||
| 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 | + | ||
| 1238 | 1284 | #[test] | |
| 1239 | 1285 | fn generate_insertion_key_format() { | |
| 1240 | 1286 | let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); |
| @@ -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('_', '-'); |
| @@ -72,7 +72,7 @@ | |||
| 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 @@ | |||
| 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 { |
| @@ -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> |