diff --git a/Cargo.lock b/Cargo.lock
index e158896..ccaf6e6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,17 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "aes"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58"
+dependencies = [
+ "cipher",
+ "cpubits",
+ "cpufeatures",
+]
+
[[package]]
name = "aho-corasick"
version = "1.1.5"
@@ -187,6 +198,16 @@ dependencies = [
"rand_core 0.10.1",
]
+[[package]]
+name = "cipher"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
[[package]]
name = "cmake"
version = "0.1.58"
@@ -228,6 +249,12 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+[[package]]
+name = "cpubits"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
+
[[package]]
name = "cpufeatures"
version = "0.3.1"
@@ -674,6 +701,15 @@ dependencies = [
"hashbrown",
]
+[[package]]
+name = "inout"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
+dependencies = [
+ "hybrid-array",
+]
+
[[package]]
name = "ipnet"
version = "2.12.2"
@@ -1369,7 +1405,9 @@ dependencies = [
name = "segmentor"
version = "0.1.0"
dependencies = [
+ "aes",
"axum",
+ "base64",
"bytes",
"hyper-util",
"mp4",
diff --git a/Cargo.toml b/Cargo.toml
index 9383be7..942d418 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,7 +16,9 @@ categories = ["multimedia::video", "web-programming::http-server"]
exclude = ["/.github", "/docs", "/demo", "/fuzz", "/tests/fixtures", "/Dockerfile", "/.dockerignore", "/book.toml", "/Makefile"]
[dependencies]
+aes = "0.9"
axum = "0.8"
+base64 = "0.23"
bytes = "1"
hyper-util = { version = "0.1", features = ["http1", "http2", "server-auto", "server-graceful", "service", "tokio"] }
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2"] }
diff --git a/README.md b/README.md
index d84d1f1..6410f93 100644
--- a/README.md
+++ b/README.md
@@ -39,8 +39,10 @@ proportional to the segment asked for, not to the length of the video.
- **No transcoding.** The codecs must already suit the protocol, and there is no bitrate ladder
unless you supply the renditions ([mapper API](docs/mapper-api.md#renditions)). Trimming and
joining files is done on keyframes, not by re-encoding ([clips](docs/mapper-api.md#clips)).
-- **No DRM, no live streaming, no MPEG-TS output.** Segments are fragmented MP4. DRM is planned
- after the features above.
+- **No live streaming, no MPEG-TS output.** Segments are fragmented MP4. DRM is supported through
+ `cbcs` encryption with Widevine, FairPlay, PlayReady, and Clear Key signalling
+ ([mapper API](docs/mapper-api.md#encryption)); video encryption is H.264 for now, with HEVC
+ next.
- **No authentication.** Run it behind a reverse proxy or CDN that provides it; see
[operations](docs/operations.md). TLS itself is optional but built in ([`[server.tls]`](docs/operations.md#tls))
for the case of running with no proxy in front.
@@ -51,7 +53,7 @@ proportional to the segment asked for, not to the length of the video.
| --- | --- |
| Convert files to HLS/DASH once and host static files | FFmpeg, [Shaka Packager](https://github.com/shaka-project/shaka-packager), or Bento4 |
| Serve HLS/DASH without storing packaged output, as a standalone service, with strict resource limits | **segmentor** |
-| DRM, live streaming, or MPEG-TS output today | Shaka Packager |
+| Live streaming or MPEG-TS output today | Shaka Packager |
| A mature on-demand packager that runs inside nginx | Kaltura's [nginx-vod-module](https://github.com/kaltura/nginx-vod-module) (AGPL-3.0) |
segmentor takes its idea, packaging on demand instead of ahead of time, from nginx-vod-module but
diff --git a/admin/index.html b/admin/index.html
index 257cc11..b17c516 100644
--- a/admin/index.html
+++ b/admin/index.html
@@ -68,10 +68,10 @@
segmentor control panel
Player
-
@@ -163,7 +163,7 @@
segmentor control panel
if (!diagnose()) log('cannot start, see warnings under the player');
$('log').innerHTML = '';
markCurrentRow();
- const asset = encodeURIComponent($('asset').value);
+ const asset = encodeURIComponent(assetId());
if ($('proto').value === 'hls') {
const url = `${base()}/hls/${asset}/master.m3u8`;
log('HLS ' + url);
@@ -197,28 +197,45 @@
segmentor control panel
}
/// Plays a row's asset id when a cache or catalog row is clicked, so browsing needs no typing.
-function playAsset(assetId) {
- $('asset').value = assetId;
+function playAsset(id) {
+ selectAsset(id);
load();
}
function markCurrentRow() {
- const current = $('asset').value;
+ const current = assetId();
for (const row of document.querySelectorAll('#cache tbody tr'))
row.classList.toggle('current', row.dataset.asset === current);
- // The picker only ever shows a known asset; typing a custom name falls back to its
- // placeholder rather than pretending one of the listed assets is selected.
- const picker = $('asset-picker');
- picker.value = [...picker.options].some(o => o.value === current) ? current : '';
}
-/// The dropdown next to the Asset field: every asset ID the cache table lists. Picking one plays
-/// it; typing a name that isn't listed (a mapper asset nobody has requested yet, for example)
-/// keeps working in the text field regardless, since the picker is a convenience, not a gate.
-function renderAssetPicker(ids) {
- $('asset-picker').innerHTML = ''
- + ids.map(id => ``).join('');
- markCurrentRow();
+// ---------- asset dropdown ----------
+// Every asset the server can name (its static catalog, or a mapper's optional listing) plus
+// whatever it has loaded, then "Custom…" for any other name. Asset IDs are never empty, so the
+// empty value marks "Custom…" without colliding with one.
+const assetId = () => $('asset-pick').value || $('asset').value.trim();
+
+function showCustomField() {
+ $('asset-custom').hidden = $('asset-pick').value !== '';
+}
+
+/// Rebuilds the options from `ids`, keeping the current choice: a selected asset stays selected
+/// even when the list no longer names it, and "Custom…" stays chosen with its text intact.
+function renderAssetOptions(ids) {
+ const pick = $('asset-pick'), selected = pick.value;
+ const all = [...new Set(selected ? [...ids, selected] : ids)].sort();
+ pick.innerHTML = all.map(id => ``).join('')
+ + '';
+ pick.value = selected;
+ showCustomField();
+}
+
+/// Selects `id`, adding it to the dropdown if it is not listed yet.
+function selectAsset(id) {
+ const pick = $('asset-pick');
+ if (![...pick.options].some(option => option.value === id))
+ pick.add(new Option(id, id), pick.options.length - 1);
+ pick.value = id;
+ showCustomField();
}
function renderPlayerStats() {
@@ -349,7 +366,8 @@
segmentor control panel
: '';
for (const row of $('cache').tBodies[0].rows)
row.onclick = () => playAsset(row.dataset.asset);
- renderAssetPicker([...rows.keys()].sort());
+ renderAssetOptions([...rows.keys()]);
+ markCurrentRow();
}
function escapeHtml(s) {
@@ -406,7 +424,11 @@
Server
- Asset
+Asset
+
+
+ Name Protocol
@@ -48,7 +48,6 @@
segmentor demo
-
@@ -106,23 +105,45 @@
Resolver & cache
const tile = (k, v, cls='') => `
${k}
${v}
`;
const escapeHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
-// ---------- assets to try ----------
-// A quick way in besides typing a name: whatever the static catalog lists (if any) plus whatever
-// has already been played (any resolver). /admin/status has the same trust model as /metrics,
-// which this page already reads, so nothing new is exposed by also reading this.
-async function refreshTryRow() {
+// ---------- asset dropdown ----------
+// Every asset the server can name (its static catalog, or a mapper's optional listing) plus
+// whatever it has loaded, then "Custom…" for any other name. Asset IDs are never empty, so the
+// empty value marks "Custom…" without colliding with one.
+const assetId = () => $('asset-pick').value || $('asset').value.trim();
+
+function showCustomField() {
+ $('asset-custom').hidden = $('asset-pick').value !== '';
+}
+
+/// Rebuilds the options from `ids`, keeping the current choice: a selected asset stays selected
+/// even when the list no longer names it, and "Custom…" stays chosen with its text intact.
+function renderAssetOptions(ids) {
+ const pick = $('asset-pick'), selected = pick.value;
+ const all = [...new Set(selected ? [...ids, selected] : ids)].sort();
+ pick.innerHTML = all.map(id => ``).join('')
+ + '';
+ pick.value = selected;
+ showCustomField();
+}
+
+/// Selects `id`, adding it to the dropdown if it is not listed yet.
+function selectAsset(id) {
+ const pick = $('asset-pick');
+ if (![...pick.options].some(option => option.value === id))
+ pick.add(new Option(id, id), pick.options.length - 1);
+ pick.value = id;
+ showCustomField();
+}
+
+// /admin/status has the same trust model as /metrics, which this page already reads, so nothing
+// new is exposed by also reading it.
+async function refreshAssets() {
try {
const status = await (await fetch(base() + '/admin/status')).json();
- const ids = [...new Set([...(status.resolver.known_assets || []), ...status.cache.assets.map(a => a.asset_id)])].sort();
- const row = $('try-row');
- if (!ids.length) { row.textContent = ''; return; }
- const current = $('asset').value;
- row.innerHTML = 'Try: ' + ids.map(id =>
- ``).join(' ');
- for (const chip of row.querySelectorAll('.chip')) chip.onclick = () => { $('asset').value = chip.dataset.asset; load(); };
- } catch { /* the try row is a convenience; leave it as it was if the origin is unreachable */ }
+ renderAssetOptions([...(status.resolver.known_assets || []), ...status.cache.assets.map(a => a.asset_id)]);
+ } catch { /* the list is a convenience; keep it as it was if the origin is unreachable */ }
}
-setInterval(refreshTryRow, 10000);
+setInterval(refreshAssets, 10000);
// ---------- player ----------
function teardown() {
@@ -149,7 +170,7 @@
Resolver & cache
teardown();
if (!diagnose()) log('cannot start, see warnings under the player');
$('log').innerHTML = '';
- const asset = encodeURIComponent($('asset').value);
+ const asset = encodeURIComponent(assetId());
if ($('proto').value === 'hls') {
const url = `${base()}/hls/${asset}/master.m3u8`;
log('HLS ' + url);
@@ -300,8 +321,11 @@