Skip to main content

max / makenotwork

Support video clips in dynamic insertions Insertion clips were audio-only (validation + a hardcoded media_type) even though the segment player already ran on video items. Make video clips a first-class path: - Upload: new ALLOWED_INSERTION_TYPES (audio + video); dedicated Insertion arm in content-type scanning accepting audio-or-video magic. - Persist real media_type derived from the confirmed MIME. - Placement guard: clips only on audio/video items; video clips require a video item; audio clips fit either. Dropdown filtered to compatible clips. - Player: dual-element gapless video (second <video> + visibility toggle); client duration detection and MIME resolution handle video clips. Video mid-roll still re-buffers the main asset (segment-model reload); the overlay rewrite to remove it is tracked as a follow-up. Gate: lib 1787 + integration content_insertions 15 (7 new) + creator_media 12 green; clippy --lib --tests clean.
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-01 15:56 UTC
Signed with PGP, not checked
Commit: 8a9ca199bf392110572649675ab4fedef58bfb34
Parent: 03e2a7b
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>