Skip to content

Commit 3d168cd

Browse files
committed
Inline fast embed startup fragments
1 parent a08de9c commit 3d168cd

2 files changed

Lines changed: 239 additions & 13 deletions

File tree

services/rend-api/src/main.rs

Lines changed: 202 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ use axum::{
2727
response::{Html, IntoResponse, Response},
2828
routing::{get, post},
2929
};
30-
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
30+
use base64::{
31+
Engine as _,
32+
engine::general_purpose::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD},
33+
};
3134
use bytes::Bytes;
3235
use futures_util::stream;
3336
use hmac::{Hmac, Mac};
@@ -93,6 +96,7 @@ const ORIGIN_PLAYBACK_CACHE_MAX_ENTRIES: usize = 512;
9396
const ORIGIN_PLAYBACK_CACHE_MAX_OBJECT_BYTES: usize = 8 * 1024 * 1024;
9497
const ORIGIN_PLAYBACK_CACHE_MEDIA_TTL: Duration = Duration::from_secs(10 * 60);
9598
const ORIGIN_PLAYBACK_CACHE_MANIFEST_TTL: Duration = Duration::from_secs(60);
99+
const FAST_EMBED_INLINE_STARTUP_MAX_BYTES: usize = 512 * 1024;
96100

97101
#[derive(Clone)]
98102
struct ApiConfig {
@@ -887,6 +891,14 @@ struct FastEmbedPlaybackSelection {
887891
url: String,
888892
}
889893

894+
#[derive(Clone, Debug, PartialEq, Eq)]
895+
struct FastEmbedInlineStartup {
896+
artifact_path: String,
897+
mime_type: String,
898+
startup_b64: String,
899+
segment_urls: Vec<String>,
900+
}
901+
890902
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
891903
struct PlaybackPrefetchHint {
892904
artifact_path: String,
@@ -2335,7 +2347,21 @@ async fn api_fast_embed_inner(
23352347
.as_deref()
23362348
.map(|value| query_flag(Some(value), true))
23372349
.unwrap_or(auto_play);
2338-
let html = render_api_fast_embed_html(&response, &selection, auto_play, controls, muted);
2350+
let inline_startup = if startup == "mse" {
2351+
fast_embed_inline_startup(state.as_ref(), &response, &selection)
2352+
.await
2353+
.unwrap_or(None)
2354+
} else {
2355+
None
2356+
};
2357+
let html = render_api_fast_embed_html(
2358+
&response,
2359+
&selection,
2360+
inline_startup.as_ref(),
2361+
auto_play,
2362+
controls,
2363+
muted,
2364+
);
23392365
let mut rendered = Html(html).into_response();
23402366
let headers = rendered.headers_mut();
23412367
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
@@ -3584,9 +3610,11 @@ fn query_flag(value: Option<&str>, fallback: bool) -> bool {
35843610

35853611
fn fast_embed_startup_mode(value: Option<&str>) -> &'static str {
35863612
match value {
3613+
Some("mse") | Some("inline") | Some("inline-mse") => "mse",
35873614
Some("opener") => "opener",
35883615
Some("hls") | Some("native") => "hls",
3589-
_ => "progressive",
3616+
Some("progressive") => "progressive",
3617+
_ => "mse",
35903618
}
35913619
}
35923620

@@ -3608,7 +3636,7 @@ fn fast_embed_playback_selection(
36083636
}
36093637
}
36103638

3611-
if startup == "progressive"
3639+
if matches!(startup, "mse" | "progressive")
36123640
&& let Some(selection) = fast_embed_progressive_selection(response)
36133641
{
36143642
return Some(selection);
@@ -3702,6 +3730,107 @@ fn fast_embed_progressive_rendition(hints: &[PlaybackPrefetchHint]) -> Option<St
37023730
})
37033731
}
37043732

3733+
async fn fast_embed_inline_startup(
3734+
state: &AppState,
3735+
response: &PlaybackBootstrapResponse,
3736+
selection: &FastEmbedPlaybackSelection,
3737+
) -> Result<Option<FastEmbedInlineStartup>, AppError> {
3738+
let Some(rendition) = hls_progressive_rendition(&selection.artifact_path) else {
3739+
return Ok(None);
3740+
};
3741+
3742+
let master_artifact = origin_playback_artifact(&response.asset_id, "hls/master.m3u8")?;
3743+
let playlist_artifact = hls_progressive_playlist_artifact(&response.asset_id, rendition)?;
3744+
let init_artifact = hls_progressive_init_artifact(&response.asset_id, rendition)?;
3745+
3746+
let (master_part, playlist_part) = tokio::try_join!(
3747+
origin_playback_artifact_full_bytes(state, &master_artifact),
3748+
origin_playback_artifact_full_bytes(state, &playlist_artifact)
3749+
)?;
3750+
let (master_bytes, _, _) = master_part;
3751+
let (playlist_bytes, _, _) = playlist_part;
3752+
let master = std::str::from_utf8(&master_bytes)
3753+
.map_err(|_| AppError::bad_gateway("invalid master playlist"))?;
3754+
let playlist = std::str::from_utf8(&playlist_bytes)
3755+
.map_err(|_| AppError::bad_gateway("invalid media playlist"))?;
3756+
let segment_names = hls_progressive_segment_names(playlist);
3757+
let Some(first_segment) = segment_names.first() else {
3758+
return Ok(None);
3759+
};
3760+
3761+
let first_segment_artifact =
3762+
hls_progressive_segment_artifact(&response.asset_id, rendition, first_segment)?;
3763+
let (init_part, first_segment_part) = tokio::try_join!(
3764+
origin_playback_artifact_full_bytes(state, &init_artifact),
3765+
origin_playback_artifact_full_bytes(state, &first_segment_artifact)
3766+
)?;
3767+
let (init_bytes, _, _) = init_part;
3768+
let (first_segment_bytes, _, _) = first_segment_part;
3769+
3770+
let startup_len = init_bytes.len() + first_segment_bytes.len();
3771+
if startup_len > FAST_EMBED_INLINE_STARTUP_MAX_BYTES {
3772+
return Ok(None);
3773+
}
3774+
3775+
let mut startup = Vec::with_capacity(startup_len);
3776+
startup.extend_from_slice(&init_bytes);
3777+
startup.extend_from_slice(&first_segment_bytes);
3778+
3779+
let mime_type = hls_master_codecs_for_rendition(master, rendition)
3780+
.map(|codecs| format!("video/mp4; codecs=\"{codecs}\""))
3781+
.unwrap_or_else(|| "video/mp4".to_owned());
3782+
let segment_urls = segment_names
3783+
.iter()
3784+
.skip(1)
3785+
.map(|segment| {
3786+
artifact_url(
3787+
&state.config.playback_base_url,
3788+
&response.asset_id,
3789+
&format!("hls/{rendition}/{segment}"),
3790+
)
3791+
})
3792+
.collect::<Vec<_>>();
3793+
3794+
Ok(Some(FastEmbedInlineStartup {
3795+
artifact_path: format!(
3796+
"hls/{rendition}/init_{rendition}.mp4+hls/{rendition}/{first_segment}"
3797+
),
3798+
mime_type,
3799+
startup_b64: BASE64_STANDARD.encode(startup),
3800+
segment_urls,
3801+
}))
3802+
}
3803+
3804+
fn hls_master_codecs_for_rendition(master: &str, rendition: &str) -> Option<String> {
3805+
let rendition_path = format!("{rendition}/index.m3u8");
3806+
let mut pending_codecs = None;
3807+
for line in master.lines().map(str::trim) {
3808+
if line.starts_with("#EXT-X-STREAM-INF:") {
3809+
pending_codecs = hls_attribute_value(line, "CODECS");
3810+
continue;
3811+
}
3812+
if line.is_empty() || line.starts_with('#') {
3813+
continue;
3814+
}
3815+
let matches_rendition =
3816+
line == rendition_path || line.ends_with(&format!("/{rendition_path}"));
3817+
if matches_rendition {
3818+
return pending_codecs;
3819+
}
3820+
pending_codecs = None;
3821+
}
3822+
None
3823+
}
3824+
3825+
fn hls_attribute_value(line: &str, name: &str) -> Option<String> {
3826+
let attributes = line.split_once(':')?.1;
3827+
let prefix = format!("{name}=\"");
3828+
let start = attributes.find(&prefix)? + prefix.len();
3829+
let value = &attributes[start..];
3830+
let end = value.find('"')?;
3831+
Some(value[..end].to_owned())
3832+
}
3833+
37053834
fn html_escape(value: impl AsRef<str>) -> String {
37063835
value
37073836
.as_ref()
@@ -3711,9 +3840,28 @@ fn html_escape(value: impl AsRef<str>) -> String {
37113840
.replace('"', "&quot;")
37123841
}
37133842

3843+
fn script_json(value: serde_json::Value) -> String {
3844+
serde_json::to_string(&value)
3845+
.unwrap_or_else(|_| "null".to_owned())
3846+
.replace('<', "\\u003c")
3847+
}
3848+
3849+
fn inline_startup_script_json(inline_startup: Option<&FastEmbedInlineStartup>) -> String {
3850+
match inline_startup {
3851+
Some(inline) => script_json(serde_json::json!({
3852+
"artifactPath": inline.artifact_path,
3853+
"mimeType": inline.mime_type,
3854+
"startup": inline.startup_b64,
3855+
"segmentUrls": inline.segment_urls,
3856+
})),
3857+
None => "null".to_owned(),
3858+
}
3859+
}
3860+
37143861
fn render_api_fast_embed_html(
37153862
response: &PlaybackBootstrapResponse,
37163863
selection: &FastEmbedPlaybackSelection,
3864+
inline_startup: Option<&FastEmbedInlineStartup>,
37173865
auto_play: bool,
37183866
controls: bool,
37193867
muted: bool,
@@ -3722,7 +3870,40 @@ fn render_api_fast_embed_html(
37223870
let controls_attr = if controls { " controls" } else { "" };
37233871
let muted_attr = if muted { " muted" } else { "" };
37243872
let poster_attr = "";
3725-
let preload_link = if selection.content_type == "video/mp4" {
3873+
let selected_label = if inline_startup.is_some() {
3874+
"mse_inline"
3875+
} else {
3876+
selection.label
3877+
};
3878+
let selected_artifact = inline_startup
3879+
.map(|inline| inline.artifact_path.as_str())
3880+
.unwrap_or(&selection.artifact_path);
3881+
let playback_engine = if inline_startup.is_some() {
3882+
"mse-inline"
3883+
} else {
3884+
"native"
3885+
};
3886+
let source_attrs = if inline_startup.is_some() {
3887+
String::new()
3888+
} else {
3889+
format!(
3890+
r#" src="{}" type="{}""#,
3891+
html_escape(&selection.url),
3892+
html_escape(&selection.content_type)
3893+
)
3894+
};
3895+
let preload_link = if let Some(inline) = inline_startup {
3896+
inline
3897+
.segment_urls
3898+
.first()
3899+
.map(|url| {
3900+
format!(
3901+
r#"<link rel="preload" as="fetch" href="{}" type="video/mp4" crossorigin="use-credentials" fetchpriority="high">"#,
3902+
html_escape(url)
3903+
)
3904+
})
3905+
.unwrap_or_default()
3906+
} else if selection.content_type == "video/mp4" {
37263907
format!(
37273908
r#"<link rel="preload" as="video" href="{}" type="{}" crossorigin="use-credentials" fetchpriority="high">"#,
37283909
html_escape(&selection.url),
@@ -3731,6 +3912,13 @@ fn render_api_fast_embed_html(
37313912
} else {
37323913
String::new()
37333914
};
3915+
let inline_startup_json = inline_startup_script_json(inline_startup);
3916+
let fallback_json = script_json(serde_json::json!({
3917+
"artifactPath": selection.artifact_path,
3918+
"contentType": selection.content_type,
3919+
"label": selection.label,
3920+
"url": selection.url,
3921+
}));
37343922

37353923
format!(
37363924
r#"<!doctype html>
@@ -3751,23 +3939,25 @@ body{{overflow:hidden}}
37513939
</style>
37523940
</head>
37533941
<body>
3754-
<main class="rend-fast" aria-label="Video player" data-rend-player-state="ready" data-rend-player-selected="{label}" data-rend-player-artifact="{artifact_path}" data-rend-ready-status="ready" data-rend-source-state="{source_state}" data-rend-playable-state="{playable_state}" data-rend-playback-engine="native" data-rend-document-start-ms="0" data-rend-bootstrap-ms="0" data-rend-asset-id="{asset_id}">
3755-
<video class="rend-fast__video" src="{url}" type="{content_type}"{poster_attr}{auto_play_attr}{controls_attr}{muted_attr} playsinline preload="auto" crossorigin="use-credentials"></video>
3942+
<main class="rend-fast" aria-label="Video player" data-rend-player-state="ready" data-rend-player-selected="{label}" data-rend-player-artifact="{artifact_path}" data-rend-ready-status="ready" data-rend-source-state="{source_state}" data-rend-playable-state="{playable_state}" data-rend-playback-engine="{playback_engine}" data-rend-document-start-ms="0" data-rend-bootstrap-ms="0" data-rend-asset-id="{asset_id}">
3943+
<video class="rend-fast__video"{source_attrs}{poster_attr}{auto_play_attr}{controls_attr}{muted_attr} playsinline preload="auto" crossorigin="use-credentials"></video>
37563944
<div class="rend-fast__message" role="status" aria-live="polite">Ready</div>
37573945
</main>
37583946
<script>
3759-
(()=>{{const root=document.querySelector("[data-rend-player-state]");const video=document.querySelector("video");if(!root||!video)return;const mark=(name)=>{{if(!root.getAttribute(name))root.setAttribute(name,String(Math.round(performance.now())))}};const dims=()=>{{if(video.videoWidth)root.setAttribute("data-rend-selected-width",String(video.videoWidth));if(video.videoHeight)root.setAttribute("data-rend-selected-height",String(video.videoHeight))}};video.addEventListener("loadedmetadata",()=>{{dims();mark("data-rend-metadata-ms")}},{{once:true}});video.addEventListener("canplay",()=>{{dims();mark("data-rend-canplay-ms")}},{{once:true}});video.addEventListener("playing",()=>{{root.setAttribute("data-rend-player-state","playing");dims()}});if("requestVideoFrameCallback"in video){{video.requestVideoFrameCallback(()=>{{dims();mark("data-rend-first-frame-ms")}})}}else{{video.addEventListener("playing",()=>mark("data-rend-first-frame-ms"),{{once:true}})}}if({auto_play_js})video.play().catch(()=>{{}})}})();
3947+
(()=>{{const root=document.querySelector("[data-rend-player-state]");const video=document.querySelector("video");if(!root||!video)return;const inlineStartup={inline_startup_json};const fallback={fallback_json};const autoPlay={auto_play_js};const mark=(name)=>{{if(!root.getAttribute(name))root.setAttribute(name,String(Math.round(performance.now())))}};const dims=()=>{{if(video.videoWidth)root.setAttribute("data-rend-selected-width",String(video.videoWidth));if(video.videoHeight)root.setAttribute("data-rend-selected-height",String(video.videoHeight))}};const play=()=>{{if(autoPlay)video.play().catch(()=>{{}})}};const selection=(label,artifactPath,engine)=>{{root.setAttribute("data-rend-player-selected",label);root.setAttribute("data-rend-player-artifact",artifactPath);root.setAttribute("data-rend-playback-engine",engine);root.setAttribute("data-rend-player-state","ready")}};const bytes=(value)=>{{const binary=atob(value);const output=new Uint8Array(binary.length);for(let i=0;i<binary.length;i++)output[i]=binary.charCodeAt(i);return output}};const append=(buffer,data)=>new Promise((resolve,reject)=>{{const done=()=>{{cleanup();resolve()}};const fail=()=>{{cleanup();reject(new Error("append failed"))}};const cleanup=()=>{{buffer.removeEventListener("updateend",done);buffer.removeEventListener("error",fail)}};buffer.addEventListener("updateend",done);buffer.addEventListener("error",fail);buffer.appendBuffer(data)}});const sourceOpen=(mediaSource)=>new Promise((resolve,reject)=>{{if(mediaSource.readyState==="open"){{resolve();return}}const done=()=>{{cleanup();resolve()}};const fail=()=>{{cleanup();reject(new Error("source open failed"))}};const cleanup=()=>{{mediaSource.removeEventListener("sourceopen",done);mediaSource.removeEventListener("sourceclose",fail);mediaSource.removeEventListener("sourceended",fail)}};mediaSource.addEventListener("sourceopen",done,{{once:true}});mediaSource.addEventListener("sourceclose",fail,{{once:true}});mediaSource.addEventListener("sourceended",fail,{{once:true}})}});const startNative=()=>{{selection(fallback.label,fallback.artifactPath,"native");if(fallback.url&&video.getAttribute("src")!==fallback.url){{video.src=fallback.url;video.load()}}play()}};const startInline=async()=>{{if(!inlineStartup||!("MediaSource"in window)||!MediaSource.isTypeSupported(inlineStartup.mimeType))return false;selection("mse_inline",inlineStartup.artifactPath,"mse-inline");const mediaSource=new MediaSource();const objectUrl=URL.createObjectURL(mediaSource);video.removeAttribute("src");video.src=objectUrl;video.load();await sourceOpen(mediaSource);const sourceBuffer=mediaSource.addSourceBuffer(inlineStartup.mimeType);await append(sourceBuffer,bytes(inlineStartup.startup));play();(async()=>{{for(const url of inlineStartup.segmentUrls){{const response=await fetch(url,{{credentials:"include"}});if(!response.ok)throw new Error("segment fetch failed");await append(sourceBuffer,new Uint8Array(await response.arrayBuffer()))}}if(mediaSource.readyState==="open")mediaSource.endOfStream()}})().catch(()=>{{}});return true}};video.addEventListener("loadedmetadata",()=>{{dims();mark("data-rend-metadata-ms")}},{{once:true}});video.addEventListener("canplay",()=>{{dims();mark("data-rend-canplay-ms")}},{{once:true}});video.addEventListener("playing",()=>{{root.setAttribute("data-rend-player-state","playing");dims()}});if("requestVideoFrameCallback"in video){{video.requestVideoFrameCallback(()=>{{dims();mark("data-rend-first-frame-ms")}})}}else{{video.addEventListener("playing",()=>mark("data-rend-first-frame-ms"),{{once:true}})}}startInline().then((started)=>{{if(!started)startNative()}}).catch(()=>startNative())}})();
37603948
</script>
37613949
</body>
37623950
</html>"#,
37633951
asset_id = html_escape(&response.asset_id),
3764-
artifact_path = html_escape(&selection.artifact_path),
3952+
artifact_path = html_escape(selected_artifact),
37653953
auto_play_js = if auto_play { "true" } else { "false" },
3766-
content_type = html_escape(&selection.content_type),
3767-
label = html_escape(selection.label),
3954+
fallback_json = fallback_json,
3955+
inline_startup_json = inline_startup_json,
3956+
label = html_escape(selected_label),
37683957
playable_state = html_escape(&response.playable_state),
3958+
playback_engine = html_escape(playback_engine),
3959+
source_attrs = source_attrs,
37693960
source_state = html_escape(&response.source_state),
3770-
url = html_escape(&selection.url),
37713961
)
37723962
}
37733963

services/rend-api/src/tests.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1567,7 +1567,7 @@ fn api_fast_embed_prefers_progressive_startup_without_token_material() {
15671567
)
15681568
.unwrap();
15691569
let selection = fast_embed_playback_selection(&response, "progressive").unwrap();
1570-
let html = render_api_fast_embed_html(&response, &selection, true, false, true);
1570+
let html = render_api_fast_embed_html(&response, &selection, None, true, false, true);
15711571

15721572
assert_eq!(selection.label, "progressive_mp4");
15731573
assert_eq!(selection.artifact_path, "hls/360p/progressive.mp4");
@@ -1586,6 +1586,42 @@ fn api_fast_embed_prefers_progressive_startup_without_token_material() {
15861586
);
15871587
}
15881588

1589+
#[test]
1590+
fn api_fast_embed_can_render_inline_mse_startup_without_token_material() {
1591+
let response = playback_bootstrap_response(
1592+
Some(asset_record("hls_ready")),
1593+
&hls_artifact_records(),
1594+
"https://api.rend.so",
1595+
&test_issuer(),
1596+
8,
1597+
NOW,
1598+
)
1599+
.unwrap();
1600+
let selection = fast_embed_playback_selection(&response, "mse").unwrap();
1601+
let inline = FastEmbedInlineStartup {
1602+
artifact_path: "hls/360p/init_360p.mp4+hls/360p/segment_00000.m4s".to_owned(),
1603+
mime_type: "video/mp4; codecs=\"avc1.64001f,mp4a.40.2\"".to_owned(),
1604+
startup_b64: BASE64_STANDARD.encode([0, 1, 2, 3]),
1605+
segment_urls: vec!["https://api.rend.so/v/asset-123/hls/360p/segment_00001.m4s".to_owned()],
1606+
};
1607+
let html = render_api_fast_embed_html(&response, &selection, Some(&inline), true, false, true);
1608+
1609+
assert_eq!(fast_embed_startup_mode(None), "mse");
1610+
assert_eq!(selection.label, "progressive_mp4");
1611+
assert!(html.contains("data-rend-player-selected=\"mse_inline\""));
1612+
assert!(html.contains("data-rend-playback-engine=\"mse-inline\""));
1613+
assert!(html.contains("\"mimeType\":\"video/mp4; codecs=\\\"avc1.64001f,mp4a.40.2\\\"\""));
1614+
assert!(html.contains("segment_00001.m4s"));
1615+
assert!(html.contains("rel=\"preload\" as=\"fetch\""));
1616+
assert!(!html.contains("src=\"https://api.rend.so/v/asset-123/hls/360p/progressive.mp4\""));
1617+
assert!(!html.contains(&response.playback_token), "{html}");
1618+
assert!(!html.contains("?token="), "{html}");
1619+
assert!(
1620+
!html.to_ascii_lowercase().contains("authorization"),
1621+
"{html}"
1622+
);
1623+
}
1624+
15891625
#[tokio::test]
15901626
async fn api_fast_embed_rejects_malformed_asset_id_without_auth() {
15911627
let app = build_app(test_state(), Duration::from_secs(10));

0 commit comments

Comments
 (0)