Skip to content

Commit d782b02

Browse files
committed
Serve stored progressive playback objects
1 parent 1ced3a8 commit d782b02

3 files changed

Lines changed: 227 additions & 10 deletions

File tree

scripts/backfill-public-playback-aliases.mjs

Lines changed: 111 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Options:
3131
--prefix PREFIX Public alias prefix. Defaults to REND_PUBLIC_PLAYBACK_ALIAS_PREFIX or v.
3232
--acl public-read|inherit Alias object ACL. Defaults to REND_PUBLIC_PLAYBACK_ALIAS_ACL or public-read.
3333
--public-base-url URL Optional unauthenticated GET verification base URL.
34+
--backfill-progressive Create hls/<rendition>/progressive.mp4 source objects before alias copy.
3435
--env-file PATH Load storage credentials from a specific env file.
3536
--dry-run List counts without writing aliases.
3637
`);
@@ -44,6 +45,7 @@ function parseArgs(argv) {
4445
prefix: process.env.REND_PUBLIC_PLAYBACK_ALIAS_PREFIX || "v",
4546
acl: process.env.REND_PUBLIC_PLAYBACK_ALIAS_ACL || "public-read",
4647
publicBaseUrl: "",
48+
backfillProgressive: false,
4749
envFile: "",
4850
dryRun: false,
4951
};
@@ -84,6 +86,10 @@ function parseArgs(argv) {
8486
index += 1;
8587
continue;
8688
}
89+
if (arg === "--backfill-progressive") {
90+
options.backfillProgressive = true;
91+
continue;
92+
}
8793
if (arg === "--env-file") {
8894
options.envFile = String(next || "");
8995
index += 1;
@@ -438,6 +444,7 @@ function playbackArtifactKeysForAsset(allKeys, assetId) {
438444
relative.endsWith(".m3u8") ||
439445
relative.endsWith(".m4s") ||
440446
relative.endsWith(".ts") ||
447+
relative.endsWith("/progressive.mp4") ||
441448
/\/init_[A-Za-z0-9_-]+\.mp4$/.test(relative)
442449
);
443450
})
@@ -466,6 +473,97 @@ function cacheControlForKey(key) {
466473
return "public, max-age=31536000, immutable";
467474
}
468475

476+
const startupRenditions = ["360p", "480p", "720p", "1080p", "2k", "4k"];
477+
478+
function mediaSegmentNamesFromPlaylist(playlistText) {
479+
return playlistText
480+
.split(/\r?\n/)
481+
.map((line) => line.trim())
482+
.filter((line) => {
483+
if (!line || line.startsWith("#")) return false;
484+
if (line.includes("/") || line.includes("\\") || line.includes("..")) {
485+
return false;
486+
}
487+
return /^segment_[0-9]+\.m4s$/.test(line);
488+
});
489+
}
490+
491+
async function getObjectBuffer(env, key, bucket = env.S3_BUCKET) {
492+
const response = await s3Fetch(env, {
493+
method: "GET",
494+
key,
495+
bucket,
496+
});
497+
return Buffer.from(await response.arrayBuffer());
498+
}
499+
500+
async function putObjectBuffer(
501+
env,
502+
key,
503+
body,
504+
contentType,
505+
bucket = env.S3_BUCKET,
506+
) {
507+
await s3Fetch(env, {
508+
method: "PUT",
509+
key,
510+
bucket,
511+
headers: {
512+
"content-type": contentType,
513+
},
514+
body,
515+
});
516+
}
517+
518+
async function backfillProgressiveObjects(env, options, assetId, allKeys) {
519+
if (!options.backfillProgressive) return allKeys;
520+
const knownKeys = new Set(allKeys);
521+
const writtenKeys = [];
522+
523+
for (const rendition of startupRenditions) {
524+
const prefix = `videos/${assetId}/hls/${rendition}`;
525+
const targetKey = `${prefix}/progressive.mp4`;
526+
if (knownKeys.has(targetKey)) continue;
527+
528+
const playlistKey = `${prefix}/index.m3u8`;
529+
const initKey = `${prefix}/init_${rendition}.mp4`;
530+
if (!knownKeys.has(playlistKey) || !knownKeys.has(initKey)) continue;
531+
532+
const playlist = String(await getObjectBuffer(env, playlistKey));
533+
const segmentNames = mediaSegmentNamesFromPlaylist(playlist);
534+
const segmentKeys = segmentNames.map((name) => `${prefix}/${name}`);
535+
if (!segmentKeys.length || segmentKeys.some((key) => !knownKeys.has(key))) {
536+
continue;
537+
}
538+
539+
log(
540+
`asset=${assetId} progressive_plan rendition=${rendition} segments=${segmentKeys.length} dry_run=${options.dryRun}`,
541+
);
542+
if (options.dryRun) {
543+
knownKeys.add(targetKey);
544+
writtenKeys.push(targetKey);
545+
continue;
546+
}
547+
548+
const parts = [await getObjectBuffer(env, initKey)];
549+
for (const segmentKey of segmentKeys) {
550+
parts.push(await getObjectBuffer(env, segmentKey));
551+
}
552+
const body = Buffer.concat(parts);
553+
await putObjectBuffer(env, targetKey, body, "video/mp4");
554+
knownKeys.add(targetKey);
555+
writtenKeys.push(targetKey);
556+
log(
557+
`asset=${assetId} progressive_written rendition=${rendition} bytes=${body.byteLength}`,
558+
);
559+
}
560+
561+
if (writtenKeys.length) {
562+
return [...knownKeys].sort();
563+
}
564+
return allKeys;
565+
}
566+
469567
async function mapLimit(items, limit, mapper) {
470568
let nextIndex = 0;
471569
const results = [];
@@ -540,7 +638,12 @@ async function verifyPublicGet(options, assetId) {
540638

541639
async function backfillAsset(env, options, assetId) {
542640
const sourcePrefix = `videos/${assetId}/`;
543-
const allKeys = await listObjectKeys(env, sourcePrefix, env.S3_BUCKET);
641+
const allKeys = await backfillProgressiveObjects(
642+
env,
643+
options,
644+
assetId,
645+
await listObjectKeys(env, sourcePrefix, env.S3_BUCKET),
646+
);
544647
const artifactKeys = playbackArtifactKeysForAsset(allKeys, assetId);
545648
if (!artifactKeys.some((key) => key.endsWith("/hls/master.m3u8"))) {
546649
throw new Error(`asset=${assetId} has no hls/master.m3u8 object`);
@@ -559,10 +662,13 @@ async function backfillAsset(env, options, assetId) {
559662
);
560663
if (options.dryRun) return;
561664

562-
let copiedBytes = 0;
563-
await mapLimit(planned, 6, async ({ objectKey, aliasKey }) => {
564-
copiedBytes += await putAlias(env, options, objectKey, aliasKey);
565-
});
665+
const copiedSizes = await mapLimit(
666+
planned,
667+
6,
668+
async ({ objectKey, aliasKey }) =>
669+
putAlias(env, options, objectKey, aliasKey),
670+
);
671+
const copiedBytes = copiedSizes.reduce((total, size) => total + size, 0);
566672
log(
567673
`asset=${assetId} aliases_written=${planned.length} bytes=${copiedBytes}`,
568674
);

services/rend-api/src/main.rs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2562,10 +2562,6 @@ async fn playback_origin_artifact_inner(
25622562
)
25632563
.map_err(|_| AppError::unauthorized("unauthorized playback token"))?;
25642564

2565-
if hls_progressive_rendition(&artifact.artifact_path).is_some() {
2566-
return origin_playback_progressive_fmp4_response(state, artifact).await;
2567-
}
2568-
25692565
let range_header = if is_hls_manifest_artifact_path(&artifact.artifact_path) {
25702566
None
25712567
} else {
@@ -2575,6 +2571,24 @@ async fn playback_origin_artifact_inner(
25752571
.and_then(normalize_single_byte_range_header)
25762572
};
25772573

2574+
if hls_progressive_rendition(&artifact.artifact_path).is_some() {
2575+
match origin_playback_artifact_full_bytes(state.as_ref(), &artifact).await {
2576+
Ok((bytes, content_type, cache_status)) => {
2577+
return Ok(origin_playback_artifact_bytes_response(
2578+
artifact,
2579+
bytes,
2580+
content_type,
2581+
cache_status,
2582+
range_header.as_deref(),
2583+
));
2584+
}
2585+
Err(error) if error.status == StatusCode::NOT_FOUND => {
2586+
return origin_playback_progressive_fmp4_response(state, artifact).await;
2587+
}
2588+
Err(error) => return Err(error),
2589+
}
2590+
}
2591+
25782592
let cache_ttl = origin_playback_cache_ttl(&artifact);
25792593
if cache_ttl.is_some() {
25802594
let (bytes, content_type, cache_status) =

services/rend-api/src/media.rs

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ use anyhow::{Context, Result};
1010
use aws_sdk_s3::{Client as S3Client, primitives::ByteStream, types::ObjectCannedAcl};
1111
use serde::Deserialize;
1212
use sqlx::{PgPool, Postgres, Transaction};
13-
use tokio::{fs, io, process::Command, time};
13+
use tokio::{
14+
fs,
15+
io::{self, AsyncWriteExt},
16+
process::Command,
17+
time,
18+
};
1419

1520
use crate::{
1621
billing,
@@ -588,6 +593,56 @@ async fn generate_and_upload_hls(
588593
rendition.name
589594
);
590595

596+
let init_segment_path = segment_paths
597+
.iter()
598+
.find(|path| {
599+
path.file_name()
600+
.and_then(|name| name.to_str())
601+
.is_some_and(is_hls_init_segment_name)
602+
})
603+
.cloned();
604+
let media_segment_paths = segment_paths
605+
.iter()
606+
.filter(|path| {
607+
path.file_name()
608+
.and_then(|name| name.to_str())
609+
.is_some_and(is_hls_media_segment_name)
610+
})
611+
.cloned()
612+
.collect::<Vec<_>>();
613+
614+
if let Some(init_segment_path) = init_segment_path.as_ref()
615+
&& !media_segment_paths.is_empty()
616+
{
617+
let progressive_path = variant_dir.join("progressive.mp4");
618+
write_progressive_fmp4(&progressive_path, init_segment_path, &media_segment_paths)
619+
.await
620+
.with_context(|| format!("failed to create {} progressive fMP4", rendition.name))?;
621+
let progressive_duration_ms = media_segment_paths
622+
.iter()
623+
.map(|segment_path| {
624+
let file_name = segment_path.file_name()?.to_string_lossy();
625+
let duration_key = format!("{}/{}", rendition.name, file_name);
626+
segment_durations
627+
.get(&duration_key)
628+
.copied()
629+
.or(Some(i64::from(HLS_TARGET_SEGMENT_SECONDS) * 1_000))
630+
})
631+
.flatten()
632+
.sum::<i64>();
633+
let artifact = upload_generated_file(
634+
request,
635+
&progressive_path,
636+
hls_progressive_object_key(&request.asset_id, rendition.name),
637+
"video/mp4",
638+
"segment",
639+
Some(progressive_duration_ms),
640+
Some(rendition.resolution_tier),
641+
)
642+
.await?;
643+
artifacts.push(artifact);
644+
}
645+
591646
for segment_path in segment_paths {
592647
let file_name = segment_path
593648
.file_name()
@@ -621,6 +676,40 @@ async fn generate_and_upload_hls(
621676
Ok(artifacts)
622677
}
623678

679+
async fn write_progressive_fmp4(
680+
output_path: &Path,
681+
init_segment_path: &Path,
682+
media_segment_paths: &[PathBuf],
683+
) -> Result<()> {
684+
let mut output = fs::File::create(output_path)
685+
.await
686+
.with_context(|| format!("failed to create {}", output_path.display()))?;
687+
append_file(&mut output, init_segment_path).await?;
688+
for segment_path in media_segment_paths {
689+
append_file(&mut output, segment_path).await?;
690+
}
691+
output
692+
.flush()
693+
.await
694+
.with_context(|| format!("failed to flush {}", output_path.display()))?;
695+
Ok(())
696+
}
697+
698+
async fn append_file(output: &mut fs::File, input_path: &Path) -> Result<()> {
699+
let mut input = fs::File::open(input_path)
700+
.await
701+
.with_context(|| format!("failed to open {}", input_path.display()))?;
702+
let copied = io::copy(&mut input, output)
703+
.await
704+
.with_context(|| format!("failed to append {}", input_path.display()))?;
705+
anyhow::ensure!(
706+
copied > 0,
707+
"media fragment {} is empty",
708+
input_path.display()
709+
);
710+
Ok(())
711+
}
712+
624713
async fn upload_generated_file(
625714
request: &ProcessMediaRequest,
626715
path: &Path,
@@ -1243,6 +1332,10 @@ pub fn hls_segment_object_key(asset_id: &str, rendition_name: &str, segment_name
12431332
format!("videos/{asset_id}/hls/{rendition_name}/{segment_name}")
12441333
}
12451334

1335+
pub fn hls_progressive_object_key(asset_id: &str, rendition_name: &str) -> String {
1336+
format!("videos/{asset_id}/hls/{rendition_name}/progressive.mp4")
1337+
}
1338+
12461339
pub fn normalize_public_playback_alias_prefix(value: &str) -> Result<String> {
12471340
let prefix = value.trim().trim_matches('/');
12481341
anyhow::ensure!(
@@ -1360,6 +1453,10 @@ mod tests {
13601453
hls_segment_object_key("asset-123", "720p", "segment_00000.m4s"),
13611454
"videos/asset-123/hls/720p/segment_00000.m4s"
13621455
);
1456+
assert_eq!(
1457+
hls_progressive_object_key("asset-123", "720p"),
1458+
"videos/asset-123/hls/720p/progressive.mp4"
1459+
);
13631460
}
13641461

13651462
#[test]

0 commit comments

Comments
 (0)